Skip to content

Commit ee9c9ed

Browse files
author
User
committed
feat: dev tools autoinstall in Windows VMs
- UnattendedRequest extended with dev_tools list and use_host_ollama - autounattend.xml injects FirstLogonCommand with winget + pip + npm - Tool IDs match sandbox setup (python, git, node, vscode, uv, just, etc.) - Frontend modal shows 11 tool checkboxes + OLLAMA_HOST toggle - Default preselect: python, git, node, vscode, uv
1 parent ee7a81b commit ee9c9ed

2 files changed

Lines changed: 153 additions & 8 deletions

File tree

webapp/backend/app/main.py

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,8 @@ class UnattendedRequest(BaseModel):
462462
username: str = "user"
463463
password: str = "password"
464464
timezone: str = "Europe/Vienna"
465+
dev_tools: list[str] | None = None # dev tool keys (python, git, node, etc.)
466+
use_host_ollama: bool = False # set OLLAMA_HOST to host gateway
465467

466468

467469
# ── Template Storage ─────────────────────────────────────────────────────
@@ -1084,8 +1086,29 @@ def _generate_ubuntu_autoinstall(hostname: str, username: str, password: str, ti
10841086
"""
10851087

10861088

1087-
def _generate_autounattend(hostname: str, username: str, password: str, timezone: str) -> str:
1088-
"""Generate Windows autounattend.xml content."""
1089+
def _generate_autounattend(
1090+
hostname: str, username: str, password: str, timezone: str,
1091+
dev_tools: list[str] | None = None, use_host_ollama: bool = False,
1092+
) -> str:
1093+
"""Generate Windows autounattend.xml content.
1094+
1095+
If dev_tools is provided, injects a FirstLogonCommand that installs
1096+
the selected dev tooling via winget + pip + npm.
1097+
"""
1098+
dev_script = _generate_win_dev_setup_ps1(username, dev_tools, use_host_ollama)
1099+
first_logon = ""
1100+
if dev_script:
1101+
escaped = dev_script.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
1102+
first_logon = f"""
1103+
<FirstLogonCommands>
1104+
<SynchronousCommand wcm:action="add">
1105+
<CommandLine>powershell -ExecutionPolicy Bypass -Command "{escaped}"</CommandLine>
1106+
<Description>Dev Environment Setup</Description>
1107+
<Order>1</Order>
1108+
<RequiresUserInput>false</RequiresUserInput>
1109+
</SynchronousCommand>
1110+
</FirstLogonCommands>"""
1111+
10891112
return f"""<?xml version="1.0" encoding="utf-8"?>
10901113
<unattend xmlns="urn:schemas-microsoft-com:unattend">
10911114
<settings pass="windowsPE">
@@ -1138,12 +1161,86 @@ def _generate_autounattend(hostname: str, username: str, password: str, timezone
11381161
<Model>Automated VM</Model>
11391162
</OEMInformation>
11401163
<TimeZone>{timezone}</TimeZone>
1164+
{first_logon}
11411165
</component>
11421166
</settings>
11431167
</unattend>
11441168
"""
11451169

11461170

1171+
def _generate_win_dev_setup_ps1(
1172+
username: str = "user",
1173+
tools: list[str] | None = None,
1174+
use_host_ollama: bool = False,
1175+
) -> str:
1176+
"""Generate a PowerShell script to install dev tools in a Windows VM.
1177+
1178+
Runs winget installs silently, then pip/npm setup. Uses the same tool IDs
1179+
as the Sandbox dev setup.
1180+
"""
1181+
if not tools:
1182+
return ""
1183+
1184+
tool_map = {
1185+
"python": "Python.Python.3.12",
1186+
"git": "Git.Git",
1187+
"node": "OpenJS.NodeJS.LTS",
1188+
"just": "Casey.Just",
1189+
"vscode": "Microsoft.VisualStudioCode",
1190+
"notepad++": "Notepad++.Notepad++",
1191+
"uv": "astral-sh.uv",
1192+
"windsurf": "Codeium.Windsurf",
1193+
"cursor": "Anysphere.Cursor",
1194+
"antigravity": "Google.Antigravity",
1195+
}
1196+
1197+
winget_ids = [
1198+
tool_map[t] for t in tools if t in tool_map
1199+
]
1200+
has_python = "python" in tools
1201+
has_claude = any(t in ("claude_desktop", "claudesktop", "claudedesktop") for t in tools)
1202+
1203+
lines = [
1204+
"# Dev Environment Setup (generated by virtualization-mcp)",
1205+
"$logFile = \"$env:USERPROFILE\\Desktop\\dev-setup.log\"",
1206+
"Start-Transcript -Path $logFile -Append",
1207+
"Write-Host '--- Dev Environment Setup ---' -ForegroundColor Cyan",
1208+
]
1209+
1210+
for wid in winget_ids:
1211+
lines.append(
1212+
f'Write-Host "Installing {wid}..." -ForegroundColor Yellow; '
1213+
f'& winget install -e --id {wid} --source winget --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null'
1214+
)
1215+
1216+
if has_python:
1217+
lines.append(
1218+
'Write-Host "Upgrading pip..." -ForegroundColor Yellow; '
1219+
'python -m pip install --upgrade pip 2>&1 | Out-Null'
1220+
)
1221+
1222+
if has_claude:
1223+
lines.append(
1224+
'Write-Host "Downloading Claude Desktop..." -ForegroundColor Yellow; '
1225+
'$msix = \"$env:TEMP\\Claude.msix\"; '
1226+
'Invoke-WebRequest -Uri "https://claude.ai/api/desktop/win32/x64/msix/latest/redirect" -OutFile $msix -UseBasicParsing; '
1227+
'Add-AppxPackage -Path $msix -ErrorAction SilentlyContinue'
1228+
)
1229+
1230+
if use_host_ollama:
1231+
lines.append(
1232+
'$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue | Select-Object -First 1).NextHop; '
1233+
'if ($gw) { '
1234+
'$env:OLLAMA_HOST = \"http://$gw`:11434\"; '
1235+
'[Environment]::SetEnvironmentVariable(\"OLLAMA_HOST\", $env:OLLAMA_HOST, \"User\") '
1236+
'}'
1237+
)
1238+
1239+
lines.append("Write-Host 'Dev setup complete!' -ForegroundColor Green")
1240+
lines.append("Stop-Transcript")
1241+
return "; ".join(lines)
1242+
1243+
11471244
UNATTENDED_DIR = os.path.join(_repo_root, "assets", "unattended")
11481245

11491246

@@ -1174,15 +1271,21 @@ async def setup_unattended_install(name: str, request: UnattendedRequest):
11741271
"message": "Cloud-init ISO generated. Attach it as the secondary drive after the main ISO.",
11751272
}
11761273
elif os_type == "windows":
1177-
content = _generate_autounattend(request.hostname, request.username, request.password, request.timezone)
1274+
content = _generate_autounattend(
1275+
request.hostname, request.username, request.password, request.timezone,
1276+
dev_tools=request.dev_tools, use_host_ollama=request.use_host_ollama,
1277+
)
11781278
dest = os.path.join(UNATTENDED_DIR, f"{name}-autounattend.xml")
11791279
with open(dest, "w") as f:
11801280
f.write(content)
1281+
msg = "autounattend.xml generated."
1282+
if request.dev_tools:
1283+
msg += f" Dev tools ({len(request.dev_tools)} selected) will install on first login."
11811284
return {
11821285
"success": True,
11831286
"os_type": "windows",
11841287
"file": dest,
1185-
"message": "autounattend.xml generated. Attach it via VBoxManage storageattach.",
1288+
"message": msg,
11861289
}
11871290
else:
11881291
raise HTTPException(status_code=400, detail=f"Unsupported OS type: {os_type}")

webapp/frontend/src/pages/virtualbox.tsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export default function VirtualBox() {
105105
const [showUnattended, setShowUnattended] = useState<string | null>(null);
106106
const [unattendedUsername, setUnattendedUsername] = useState("user");
107107
const [unattendedPassword, setUnattendedPassword] = useState("password");
108+
const [unattendedDevTools, setUnattendedDevTools] = useState<Record<string, boolean>>({});
109+
const [unattendedUseOllama, setUnattendedUseOllama] = useState(false);
108110

109111
const fetchVMs = async () => {
110112
setLoading(true);
@@ -271,10 +273,20 @@ export default function VirtualBox() {
271273

272274
const submitUnattended = async (vmName: string) => {
273275
try {
276+
const devTools = Object.entries(unattendedDevTools)
277+
.filter(([, v]) => v)
278+
.map(([k]) => k);
274279
await fetch(`${API_BASE}/api/v1/vms/${encodeURIComponent(vmName)}/unattended`, {
275280
method: "POST",
276281
headers: { "Content-Type": "application/json" },
277-
body: JSON.stringify({ os_type: "ubuntu", hostname: vmName, username: unattendedUsername, password: unattendedPassword }),
282+
body: JSON.stringify({
283+
os_type: "windows",
284+
hostname: vmName,
285+
username: unattendedUsername,
286+
password: unattendedPassword,
287+
dev_tools: devTools.length > 0 ? devTools : undefined,
288+
use_host_ollama: unattendedUseOllama,
289+
}),
278290
});
279291
setShowUnattended(null);
280292
} catch (e: any) { console.error(e); }
@@ -768,7 +780,7 @@ export default function VirtualBox() {
768780
VRDP
769781
</button>
770782
)}
771-
<button onClick={() => setShowUnattended(vm.name)}
783+
<button onClick={() => { setShowUnattended(vm.name); setUnattendedDevTools({python: true, git: true, node: true, vscode: true, uv: true}); }}
772784
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg bg-white/5 text-muted-foreground hover:bg-white/15 hover:text-white transition-colors font-medium">
773785
Autoinstall
774786
</button>
@@ -1121,10 +1133,10 @@ export default function VirtualBox() {
11211133
{showUnattended && (
11221134
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
11231135
onClick={() => setShowUnattended(null)}>
1124-
<div className="bg-card border border-border rounded-xl shadow-xl max-w-md w-full p-6 space-y-4"
1136+
<div className="bg-card border border-border rounded-xl shadow-xl max-w-lg w-full max-h-[85vh] overflow-y-auto p-6 space-y-4"
11251137
onClick={(e) => e.stopPropagation()}>
11261138
<h3 className="font-semibold text-lg">Autoinstall: {showUnattended}</h3>
1127-
<p className="text-sm text-muted-foreground">Generates answer file (autoinstall.yaml or autounattend.xml). For Ubuntu, creates a cloud-init ISO.</p>
1139+
<p className="text-sm text-muted-foreground">For Windows VMs, selected dev tools install via winget on first login.</p>
11281140
<div>
11291141
<label className="block text-sm font-medium mb-1">Username</label>
11301142
<input value={unattendedUsername} onChange={(e) => setUnattendedUsername(e.target.value)}
@@ -1135,6 +1147,36 @@ export default function VirtualBox() {
11351147
<input type="password" value={unattendedPassword} onChange={(e) => setUnattendedPassword(e.target.value)}
11361148
className="w-full bg-background/50 border border-input rounded px-3 py-2" />
11371149
</div>
1150+
<div className="border-t border-border pt-3">
1151+
<p className="text-sm font-medium mb-2">Dev Tools (Windows only)</p>
1152+
<div className="grid grid-cols-2 gap-2">
1153+
{[
1154+
["python", "Python 3.12"],
1155+
["git", "Git"],
1156+
["node", "Node.js LTS"],
1157+
["vscode", "VS Code"],
1158+
["uv", "uv (pip+venv)"],
1159+
["just", "Just"],
1160+
["notepad++", "Notepad++"],
1161+
["windsurf", "Windsurf"],
1162+
["cursor", "Cursor"],
1163+
["antigravity", "Antigravity"],
1164+
["claude_desktop", "Claude Desktop"],
1165+
].map(([id, label]) => (
1166+
<label key={id} className="flex items-center gap-2 text-sm py-1 px-2 rounded hover:bg-white/5 cursor-pointer">
1167+
<input type="checkbox" checked={unattendedDevTools[id] ?? false}
1168+
onChange={(e) => setUnattendedDevTools(prev => ({ ...prev, [id]: e.target.checked }))}
1169+
className="rounded border-white/20 bg-white/5" />
1170+
{label}
1171+
</label>
1172+
))}
1173+
</div>
1174+
<label className="flex items-center gap-2 text-sm mt-2 py-1 px-2 rounded hover:bg-white/5 cursor-pointer">
1175+
<input type="checkbox" checked={unattendedUseOllama} onChange={() => setUnattendedUseOllama(!unattendedUseOllama)}
1176+
className="rounded border-white/20 bg-white/5" />
1177+
Use host Ollama (set OLLAMA_HOST)
1178+
</label>
1179+
</div>
11381180
<div className="flex gap-2 pt-2">
11391181
<button onClick={() => setShowUnattended(null)}
11401182
className="flex-1 py-2 rounded-lg border border-border hover:bg-white/10">Cancel</button>

0 commit comments

Comments
 (0)