5 Commits
47 changed files with 5746 additions and 510 deletions
@@ -0,0 +1 @@
C:/Users/jasonross/.gemini/config/projects/a670deb9-cd84-4166-a5d9-ed65679c7591.json
-20
View File
@@ -1,20 +0,0 @@
name: integration-test
on:
pull_request:
jobs:
build-and-run:
name: Build and run
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: |
New-Item -ItemType Directory -Force dist | Out-Null
go build -o dist/bootstrap_windows_env.exe ./cmd/bootstrap_windows_env
- name: Run (dry-run)
run: ./dist/bootstrap_windows_env.exe --dry-run --yes --headless --no-wsl
+2 -2
View File
@@ -15,8 +15,8 @@ jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Build
+2 -2
View File
@@ -8,8 +8,8 @@ jobs:
name: Unit tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Run unit tests
+9 -1
View File
@@ -1,6 +1,14 @@
bootstrap_windows_env.exe
*.exe
dist/
bin/
*.test
coverage
coverage.out
test_out.txt
*.log
# Local environment overrides (e.g. test/integration/packer/.env).
.env
*.box
.vagrant/
+18
View File
@@ -46,6 +46,24 @@ Visual Studio Community 2026 is installed as `Microsoft.VisualStudio.Community`
`wsl` enables WSL prerequisites, installs the current official `FedoraLinux-*` distro reported by `wsl --list --online`, stops for reboot or first-launch username creation when needed, then invokes the latest Linux bootstrap release with `--wsl --headless --yes`.
## Tests
Unit tests run on every pull request via `.github/workflows/unit-test.yml`:
```powershell
go test ./...
```
End-to-end integration runs against a real Windows client VM through Vagrant +
Hyper-V. It is not run in CI (GitHub-hosted runners are Windows Server and have
no Hyper-V), so execute it locally:
```powershell
go run ./test/integration
```
See `test/integration/README.md` for prerequisites, flags, and tuning options.
## Current Deferrals
`Webcamoid` is deferred because no exact `winget` manifest was available during implementation. Native Docker Desktop, Podman Desktop, Buildah, Minikube, Firecracker, QEMU, virt-manager, Windows Terminal, and VS Code are also intentionally excluded from Windows v1.
+84 -27
View File
@@ -19,7 +19,7 @@ type Action struct {
AI bool
}
const refreshPath = `$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User'); `
const refreshPath = `$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User')+';C:\ProgramData\chocolatey\bin'; if (Get-Command fnm -ErrorAction SilentlyContinue) { fnm env --shell powershell | Invoke-Expression }; `
func powerShell(script string) CommandSpec {
return CommandSpec{
@@ -32,21 +32,63 @@ func checkedNativePowerShell(command string) CommandSpec {
return powerShell(`$ErrorActionPreference='Stop'; ` + command + `; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`)
}
func HostActions() []Action {
return []Action{
func HostActions(opts Options) []Action {
actions := []Action{
{
Name: "Node.js LTS through fnm",
Name: "Visual Studio Community 2026",
Phase: PhaseHost,
Probe: checkedNativePowerShell(`fnm exec --using=lts-latest node --version`),
Probe: powerShell(`if (Test-Path "${env:ProgramFiles}\Microsoft Visual Studio\2026\Community\Common7\IDE\devenv.exe") { exit 0 } else { exit 1 }`),
Commands: []CommandSpec{
checkedNativePowerShell(`fnm install --lts`),
checkedNativePowerShell(`fnm default lts-latest`),
powerShell(fmt.Sprintf(
`$tempExe = Join-Path $env:TEMP 'vs_community.exe'; `+
`if (Test-Path $tempExe) { Remove-Item -Force $tempExe }; `+
`[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; `+
`Write-Host 'Downloading Visual Studio 2026 bootstrapper...'; `+
`Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_community.exe' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop; `+
`Unblock-File -Path $tempExe; `+
`Write-Host 'Installing Visual Studio 2026 with workloads...'; `+
`$p = Start-Process -FilePath $tempExe -ArgumentList '--passive --wait --config "%s"' -Wait -PassThru; `+
`if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { throw 'Visual Studio installation failed with exit code ' + $p.ExitCode }`,
mustInstallAssetPath("assets/visual-studio-community.vsconfig"),
)),
},
},
{
Name: "fnm and Node.js LTS",
Phase: PhaseHost,
Probe: powerShell(`fnm exec --using=lts-latest node --version | Out-Null; if ($LASTEXITCODE -ne 0) { exit 1 }`),
Commands: []CommandSpec{
powerShell(`$ErrorActionPreference='Stop'
function Invoke-Native {
param([Parameter(Mandatory=$true)][string]$File, [Parameter(Mandatory=$true)][string[]]$Arguments)
& $File @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$File $($Arguments -join ' ') failed with exit code $LASTEXITCODE"
}
}
$scoop = Get-Command scoop -ErrorAction SilentlyContinue
$scoopShim = Join-Path $HOME 'scoop\shims\scoop.ps1'
if ($scoop) {
Invoke-Native $scoop.Source @('install', 'fnm')
} elseif (Test-Path $scoopShim) {
Invoke-Native $scoopShim @('install', 'fnm')
} elseif (Get-Command choco -ErrorAction SilentlyContinue) {
Invoke-Native 'choco' @('install', 'fnm', '-y', '--no-progress')
} else {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Invoke-RestMethod -Uri 'https://community.chocolatey.org/install.ps1' | Invoke-Expression
$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User')
Invoke-Native 'choco' @('install', 'fnm', '-y', '--no-progress')
}
$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User')
Invoke-Native 'fnm' @('install', '--lts')
Invoke-Native 'fnm' @('default', 'lts-latest')`),
},
},
{
Name: "pyenv-win through Scoop or Chocolatey",
Phase: PhaseHost,
Probe: checkedNativePowerShell(`pyenv --version`),
Probe: powerShell(`pyenv --version | Out-Null; if ($LASTEXITCODE -ne 0) { exit 1 }`),
Commands: []CommandSpec{
powerShell(`$ErrorActionPreference='Stop'
function Invoke-Native {
@@ -65,7 +107,8 @@ if ($scoop) {
} elseif (Get-Command choco -ErrorAction SilentlyContinue) {
Invoke-Native 'choco' @('install', 'pyenv-win', '-y', '--no-progress')
} else {
Invoke-Native 'winget' @('install', '--id', 'Chocolatey.Chocolatey', '--exact', '--source', 'winget', '--accept-source-agreements', '--accept-package-agreements', '--disable-interactivity')
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Invoke-RestMethod -Uri 'https://community.chocolatey.org/install.ps1' | Invoke-Expression
$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User')
Invoke-Native 'choco' @('install', 'pyenv-win', '-y', '--no-progress')
}`),
@@ -74,7 +117,7 @@ if ($scoop) {
{
Name: "latest stable Python through pyenv-win",
Phase: PhaseHost,
Probe: powerShell(`$v = pyenv version-name 2>$null; if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($v) -or $v.Trim() -eq 'system') { exit 1 }`),
Probe: powerShell(`$v = pyenv version-name; if ([string]::IsNullOrWhiteSpace($v) -or $v -eq 'system') { exit 1 }`),
Commands: []CommandSpec{
powerShell(`$ErrorActionPreference='Stop'
function Invoke-Native {
@@ -84,20 +127,34 @@ function Invoke-Native {
throw "$File $($Arguments -join ' ') failed with exit code $LASTEXITCODE"
}
}
$v=(pyenv install --list | Select-String '^\s*3\.\d+\.\d+\s*$' | ForEach-Object {$_.Line.Trim()} | Sort-Object {[version]$_} -Descending | Select-Object -First 1)
if (-not $v) { throw 'no stable Python version found' }
$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User')
$out = Invoke-Native 'pyenv' @('install', '-l')
$v = $out -split '\r?\n' | Where-Object { $_ -match '^\s*3\.\d+\.\d+\s*$' } | Sort-Object -Descending | Select-Object -First 1
if (-not $v) { throw 'No stable Python 3.x version found' }
$v = $v.Trim()
Invoke-Native 'pyenv' @('install', '-s', $v)
Invoke-Native 'pyenv' @('global', $v)`),
Invoke-Native 'pyenv' @('global', $v)
Invoke-Native 'pyenv' @('exec', 'python', '-m', 'pip', 'install', '--upgrade', 'pip')
`),
},
},
{
Name: "VLC default media player associations",
Name: "semgrep",
Phase: PhaseHost,
Probe: powerShell(`$marker = Join-Path $env:ProgramData 'bootstrap_windows_env\vlc-default-media.done'; if (-not (Test-Path $marker)) { exit 1 }`),
Probe: powerShell(`$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User'); pyenv exec semgrep --version | Out-Null; if ($LASTEXITCODE -ne 0) { exit 1 }`),
Commands: []CommandSpec{
powerShell(`$ErrorActionPreference='Stop'
$stateDir = Join-Path $env:ProgramData 'bootstrap_windows_env'
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
checkedNativePowerShell(`$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User'); pyenv exec pip install semgrep`),
},
},
}
actions = append(actions, Action{
Name: "VLC default media player associations",
Phase: PhaseHost,
Probe: powerShell(`$stateDir = Join-Path $env:ProgramData 'bootstrap_windows_env'; if (-not (Test-Path (Join-Path $stateDir 'vlc-default-media.done'))) { exit 1 }`),
Commands: []CommandSpec{
powerShell(`$stateDir = Join-Path $env:ProgramData 'bootstrap_windows_env'
if (-not (Test-Path $stateDir)) { New-Item -ItemType Directory -Force -Path $stateDir | Out-Null }
$vlcPaths = @(
(Join-Path $env:ProgramFiles 'VideoLAN\VLC\vlc.exe'),
(Join-Path ${env:ProgramFiles(x86)} 'VideoLAN\VLC\vlc.exe')
@@ -106,8 +163,7 @@ if ($vlcPaths.Count -eq 0 -and -not (Get-Command vlc.exe -ErrorAction SilentlyCo
throw 'VLC is not installed or is not discoverable.'
}
$extensions = @(
'.3g2', '.3gp', '.aac', '.aiff', '.alac', '.amr', '.ape', '.asf', '.au',
'.avi', '.divx', '.flac', '.flv', '.m2ts', '.m4a', '.m4v', '.mka', '.mkv',
'.3g2', '.3gp', '.3gp2', '.3gpp', '.amv', '.asf', '.avi', '.divx', '.flac', '.flv', '.m2ts', '.m4a', '.m4v', '.mka', '.mkv',
'.mov', '.mp2', '.mp3', '.mp4', '.mp4v', '.mpeg', '.mpg', '.mts', '.oga',
'.ogg', '.ogm', '.ogv', '.opus', '.ts', '.vob', '.wav', '.webm', '.wma', '.wmv'
)
@@ -149,9 +205,10 @@ if ($LASTEXITCODE -ne 0) {
}
Set-Content -Path (Join-Path $stateDir 'vlc-default-media.done') -Value (Get-Date -Format o) -Encoding UTF8
Write-Output "Imported VLC default media associations from $xmlPath. Windows may still require user confirmation for an existing signed-in profile."`),
},
},
}
})
return actions
}
func CustomActions(opts Options) []Action {
@@ -170,8 +227,8 @@ func CustomActions(opts Options) []Action {
Phase: PhaseCustom,
Probe: powerShell(`npm list --global playwright --depth=0 | Out-Null; if ($LASTEXITCODE -ne 0) { exit 1 }`),
Commands: []CommandSpec{
checkedNativePowerShell(`fnm exec --using=lts-latest npm install --global playwright`),
checkedNativePowerShell(`fnm exec --using=lts-latest playwright install`),
checkedNativePowerShell(`npm install --global playwright`),
checkedNativePowerShell(`playwright install`),
},
},
{
@@ -188,7 +245,7 @@ func CustomActions(opts Options) []Action {
AI: true,
Probe: powerShell(`npm list --global '@anthropic-ai/claude-code' --depth=0 | Out-Null; if ($LASTEXITCODE -ne 0) { exit 1 }`),
Commands: []CommandSpec{
checkedNativePowerShell(`fnm exec --using=lts-latest npm install --global '@anthropic-ai/claude-code'`),
checkedNativePowerShell(`npm install --global '@anthropic-ai/claude-code'`),
},
},
{
@@ -197,7 +254,7 @@ func CustomActions(opts Options) []Action {
AI: true,
Probe: powerShell(`npm list --global '@openai/codex' --depth=0 | Out-Null; if ($LASTEXITCODE -ne 0) { exit 1 }`),
Commands: []CommandSpec{
checkedNativePowerShell(`fnm exec --using=lts-latest npm install --global '@openai/codex'`),
checkedNativePowerShell(`npm install --global '@openai/codex'`),
},
},
{
@@ -206,7 +263,7 @@ func CustomActions(opts Options) []Action {
AI: true,
Probe: powerShell(`npm list --global '@github/copilot' --depth=0 | Out-Null; if ($LASTEXITCODE -ne 0) { exit 1 }`),
Commands: []CommandSpec{
checkedNativePowerShell(`fnm exec --using=lts-latest npm install --global '@github/copilot'`),
checkedNativePowerShell(`npm install --global '@github/copilot'`),
},
},
}
+5 -5
View File
@@ -27,12 +27,12 @@ func TestCustomActionsNoAIStillKeepsPlaywrightAndExtension(t *testing.T) {
}
func TestRuntimeAndCustomDependencyOrdering(t *testing.T) {
host := HostActions()
if len(host) != 4 || host[0].Name != "Node.js LTS through fnm" || host[1].Name != "pyenv-win through Scoop or Chocolatey" || host[2].Name != "latest stable Python through pyenv-win" || host[3].Name != "VLC default media player associations" {
host := HostActions(Options{})
if len(host) != 6 || host[0].Name != "Visual Studio Community 2026" || host[1].Name != "fnm and Node.js LTS" || host[2].Name != "pyenv-win through Scoop or Chocolatey" || host[3].Name != "latest stable Python through pyenv-win" || host[4].Name != "semgrep" || host[5].Name != "VLC default media player associations" {
t.Fatalf("unexpected host action order: %#v", host)
}
pyenvInstall := strings.Join(host[1].Commands[0].Args, " ")
for _, want := range []string{"scoop", "Invoke-Native 'choco' @('install', 'pyenv-win'", "Chocolatey.Chocolatey"} {
pyenvInstall := strings.Join(host[2].Commands[0].Args, " ")
for _, want := range []string{"scoop", "Invoke-Native 'choco' @('install', 'pyenv-win'", "community.chocolatey.org"} {
if !strings.Contains(pyenvInstall, want) {
t.Fatalf("pyenv install command missing %q: %s", want, pyenvInstall)
}
@@ -57,7 +57,7 @@ func TestRuntimeAndCustomDependencyOrdering(t *testing.T) {
}
func TestVLCDefaultMediaActionCoversCommonAudioVideoExtensions(t *testing.T) {
host := HostActions()
host := HostActions(Options{})
vlc := host[len(host)-1]
script := strings.Join(vlc.Commands[0].Args, " ")
for _, want := range []string{
+19 -11
View File
@@ -62,7 +62,7 @@ func BuildPlanForPhases(ctx context.Context, runner Runner, opts Options, phases
}
switch phase {
case PhaseHost:
actions := HostActions()
actions := HostActions(opts)
if checkState {
plan.ActionStates[phase] = CheckActions(ctx, runner, actions)
} else {
@@ -93,7 +93,7 @@ func PrintPlan(out io.Writer, plan Plan, opts Options) {
} else if state.CheckErr != nil {
status = "check failed; will retry install"
}
fmt.Fprintf(out, " winget: %s (%s) - %s\n", state.Package.Name, state.Package.WingetID, status)
fmt.Fprintf(out, " choco: %s (%s) - %s\n", state.Package.Name, state.Package.ChocoID, status)
}
}
actions := plan.ActionStates[phase]
@@ -122,9 +122,6 @@ func PrintPlan(out io.Writer, plan Plan, opts Options) {
}
func (b *Bootstrapper) Run(ctx context.Context, opts Options) (runErr error) {
if b.Runner == nil {
b.Runner = ExecRunner{Timeout: 45 * time.Minute}
}
if b.In == nil {
b.In = os.Stdin
}
@@ -134,6 +131,9 @@ func (b *Bootstrapper) Run(ctx context.Context, opts Options) (runErr error) {
if b.Err == nil {
b.Err = os.Stderr
}
if b.Runner == nil {
b.Runner = ExecRunner{Timeout: 45 * time.Minute, Stream: b.Out}
}
phases := SelectedPhases(opts)
report := NewRunReport(opts, phases)
@@ -180,10 +180,15 @@ func (b *Bootstrapper) Run(ctx context.Context, opts Options) (runErr error) {
PrintPlan(b.Out, plan, opts)
issues = append(issues, b.executeCheckedPlan(ctx, plan, opts, report)...)
}
return issues.Err()
} else {
issues = append(issues, b.executeCheckedPlan(ctx, plan, opts, report)...)
}
if opts.Includes(PhaseConfig) {
authIssues := b.offerInteractiveAuth(ctx, opts)
b.recordIssues(report, &issues, authIssues)
}
issues = append(issues, b.executeCheckedPlan(ctx, plan, opts, report)...)
return issues.Err()
}
@@ -300,14 +305,14 @@ func (b *Bootstrapper) runConfigPhase(ctx context.Context, opts Options) Issues
if !IsEmptyIssue(issue) {
issues = append(issues, issue)
}
if !opts.Headless {
issues = append(issues, b.offerInteractiveAuth(ctx)...)
}
return issues
}
func (b *Bootstrapper) offerInteractiveAuth(ctx context.Context) Issues {
func (b *Bootstrapper) offerInteractiveAuth(ctx context.Context, opts Options) Issues {
var issues Issues
if opts.Yes || IsSessionZero() {
return issues
}
if result := b.Runner.Run(ctx, "gh", "auth", "status"); result.Err != nil {
if confirm(b.In, b.Out, "Run `gh auth login` now? [y/N] ") {
login := b.Runner.Run(ctx, "gh", "auth", "login")
@@ -341,6 +346,9 @@ func (b *Bootstrapper) offerInteractiveAuth(ctx context.Context) Issues {
}
func confirm(in io.Reader, out io.Writer, prompt string) bool {
if in == nil {
return false
}
fmt.Fprint(out, prompt)
scanner := bufio.NewScanner(in)
if !scanner.Scan() {
+15 -15
View File
@@ -25,7 +25,7 @@ func TestRunWithOSDoesNotProbePackagesBeforeWindowsUpdate(t *testing.T) {
},
}
if err := app.Run(context.Background(), Options{Yes: true, NoWSL: true, Headless: true}); err != nil {
if err := app.Run(context.Background(), Options{Yes: true, NoWSL: true}); err != nil {
t.Fatal(err)
}
@@ -65,7 +65,7 @@ func TestRunStopsAfterOSRebootNotice(t *testing.T) {
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
if err := app.Run(context.Background(), Options{Yes: true, NoWSL: true, Headless: true}); err != nil {
if err := app.Run(context.Background(), Options{Yes: true, NoWSL: true}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "require a reboot") {
@@ -90,7 +90,7 @@ func TestNewBootstrapperInitializesDefaults(t *testing.T) {
}
func TestBuildPlanWrapperAndPrintPlanShowStatuses(t *testing.T) {
pkg := Package{Name: "Git", WingetID: "Git.Git", WingetSource: "winget", Phase: PhaseHost}
pkg := Package{Name: "Git", ChocoID: "git", Phase: PhaseHost}
action := Action{Name: "done action"}
plan := BuildPlan(context.Background(), &fakeRunner{}, Options{Only: PhaseOS})
if len(plan.Phases) != 1 || plan.Phases[0] != PhaseOS {
@@ -103,7 +103,7 @@ func TestBuildPlanWrapperAndPrintPlanShowStatuses(t *testing.T) {
PackageStates: map[Phase][]PackageState{
PhaseHost: {
{Package: pkg, Installed: true},
{Package: Package{Name: "Broken", WingetID: "Broken.ID"}, CheckErr: errors.New("check failed")},
{Package: Package{Name: "Broken", ChocoID: "Broken.ID"}, CheckErr: errors.New("check failed")},
},
},
ActionStates: map[Phase][]ActionState{
@@ -174,7 +174,7 @@ func TestRunOnlyHostSkipsOSAndExecutesHostPlan(t *testing.T) {
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
if err := app.Run(context.Background(), Options{Only: PhaseHost, Yes: true, Headless: true}); err != nil {
if err := app.Run(context.Background(), Options{Only: PhaseHost, Yes: true}); err != nil {
t.Fatal(err)
}
update := WindowsUpdateCommand()
@@ -263,7 +263,7 @@ func TestRunConfigPhaseRecordsDeploymentIssue(t *testing.T) {
Documents: filepath.Join(temp, "docs"),
},
}
issues := app.runConfigPhase(context.Background(), Options{Headless: true})
issues := app.runConfigPhase(context.Background(), Options{})
if len(issues) == 0 || !strings.Contains(issues[0].Step, "deploy managed config") {
t.Fatalf("issues = %#v, want deployment issue", issues)
}
@@ -280,13 +280,13 @@ func TestRunConfigPhaseRecordsNeovimIssue(t *testing.T) {
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
issues := app.runConfigPhase(context.Background(), Options{Headless: true})
issues := app.runConfigPhase(context.Background(), Options{})
if len(issues) != 1 || issues[0].Step != "clone Neovim config" {
t.Fatalf("issues = %#v, want Neovim clone issue", issues)
}
}
func TestRunConfigPhaseOffersAuthWhenInteractive(t *testing.T) {
func TestRunOffersAuthWhenInteractive(t *testing.T) {
temp := t.TempDir()
pub := filepath.Join(temp, "home", ".ssh", "id_ed25519.pub")
if err := os.MkdirAll(filepath.Dir(pub), 0o755); err != nil {
@@ -297,13 +297,13 @@ func TestRunConfigPhaseOffersAuthWhenInteractive(t *testing.T) {
}
app := &Bootstrapper{
Runner: &fakeRunner{},
In: strings.NewReader(""),
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
if issues := app.runConfigPhase(context.Background(), Options{}); issues.Err() != nil {
t.Fatalf("issues = %#v", issues)
if err := app.Run(context.Background(), Options{Only: PhaseConfig}); err != nil {
t.Fatalf("Run err = %v", err)
}
if !app.Runner.(*fakeRunner).called("gh", "auth", "status") {
t.Fatal("expected interactive auth status check")
@@ -351,7 +351,7 @@ func TestOfferInteractiveAuthCoversLoginFailure(t *testing.T) {
Err: &bytes.Buffer{},
Paths: UserPaths{Home: temp},
}
issues := app.offerInteractiveAuth(context.Background())
issues := app.offerInteractiveAuth(context.Background(), Options{})
if len(issues) != 1 || issues[0].Step != "gh auth login" {
t.Fatalf("issues = %#v, want login failure", issues)
}
@@ -374,7 +374,7 @@ func TestOfferInteractiveAuthCoversSSHUploadFailure(t *testing.T) {
Err: &bytes.Buffer{},
Paths: UserPaths{Home: temp},
}
issues := app.offerInteractiveAuth(context.Background())
issues := app.offerInteractiveAuth(context.Background(), Options{})
if len(issues) != 1 || issues[0].Step != "gh ssh-key add" {
t.Fatalf("issues = %#v, want upload failure", issues)
}
@@ -398,7 +398,7 @@ func TestOfferInteractiveAuthCoversSSHDirectoryAndKeygenFailures(t *testing.T) {
Err: &bytes.Buffer{},
Paths: UserPaths{Home: homeFile},
}
issues := app.offerInteractiveAuth(context.Background())
issues := app.offerInteractiveAuth(context.Background(), Options{})
if len(issues) != 1 || issues[0].Step != "create SSH directory" {
t.Fatalf("issues = %#v, want SSH directory failure", issues)
}
@@ -417,7 +417,7 @@ func TestOfferInteractiveAuthCoversSSHDirectoryAndKeygenFailures(t *testing.T) {
Err: &bytes.Buffer{},
Paths: UserPaths{Home: filepath.Join(temp, "home2")},
}
issues = app.offerInteractiveAuth(context.Background())
issues = app.offerInteractiveAuth(context.Background(), Options{})
if len(issues) != 1 || issues[0].Step != "ssh-keygen" {
t.Fatalf("issues = %#v, want ssh-keygen failure", issues)
}
+884
View File
@@ -0,0 +1,884 @@
{
"desktop": {
"GeForce 8800 Ultra": "8",
"GeForce 8800 GTX": "7",
"GeForce 8800 GTS 512": "270",
"GeForce 8800 GTS": "6",
"GeForce 8800 GT": "258",
"GeForce 8800 GS": "281",
"GeForce 8600 GTS": "5",
"GeForce 8600 GT": "4",
"GeForce 8600 GS": "233",
"GeForce 8500 GT": "3",
"GeForce 8400 GS": "2",
"GeForce 8400 SE": "234",
"GeForce 8400": "272",
"GeForce 8300 GS": "1",
"GeForce 8300": "288",
"GeForce 8200": "293",
"GeForce 8100": "290",
"GeForce 7025": "9",
"GeForce 7050 PV": "10",
"GeForce 7050": "238",
"GeForce 7100": "263",
"GeForce 7100 GS": "11",
"GeForce 7150": "240",
"GeForce 7300 SE": "15",
"GeForce 7300 LE": "14",
"GeForce 7300 GS": "12",
"GeForce 7300 GT": "13",
"GeForce 7350 LE": "16",
"GeForce 7500 LE": "17",
"GeForce 7550 LE": "18",
"GeForce 7600 LE": "21",
"GeForce 7600 GS": "19",
"GeForce 7600 GT": "20",
"GeForce 7650 GS": "22",
"GeForce 7800 GT": "648",
"GeForce 7800 GS": "23",
"GeForce 7800 GTX": "24",
"GeForce 7800 SLI": "25",
"GeForce 7900 GS": "26",
"GeForce 7900 GT": "27",
"GeForce 7900 GTX": "28",
"GeForce 7950 GT": "29",
"GeForce 7950 GX2": "30",
"GeForce 6100": "31",
"GeForce 6100 nForce 400": "32",
"GeForce 6100 nForce 405": "33",
"GeForce 6100 nForce 420": "34",
"GeForce 6150": "35",
"GeForce 6150 LE": "36",
"GeForce 6150LE": "262",
"GeForce 6150SE nForce 430": "37",
"GeForce 6200": "38",
"GeForce 6200 A-LE": "39",
"GeForce 6200 LE": "40",
"GeForce 6200 TurboCache(TM)": "41",
"GeForce 6200SE TurboCache(TM)": "42",
"GeForce 6250": "43",
"GeForce 6500": "44",
"GeForce 6600": "45",
"GeForce 6600 GT": "46",
"GeForce 6600 LE": "47",
"GeForce 6600 VE": "48",
"GeForce 6610 XL": "49",
"GeForce 6700 XL": "50",
"GeForce 6800": "51",
"GeForce 6800 GS": "53",
"GeForce 6800 GT": "54",
"GeForce 6800 LE": "55",
"GeForce 6800 Ultra": "56",
"GeForce 6800 XE": "57",
"GeForce 6800 XT": "58",
"GeForce FX 5100": "59",
"GeForce FX 5200": "60",
"GeForce FX 5200 Ultra": "61",
"GeForce FX 5200LE": "62",
"GeForce FX 5500": "63",
"GeForce FX 5600": "64",
"GeForce FX 5600 Ultra": "65",
"GeForce FX 5600XT": "66",
"GeForce FX 5700": "67",
"GeForce FX 5700 Ultra": "68",
"GeForce FX 5700LE": "69",
"GeForce FX 5700VE": "70",
"GeForce FX 5800": "71",
"GeForce FX 5800 Ultra": "72",
"GeForce FX 5900": "73",
"GeForce FX 5900 Ultra": "74",
"GeForce FX 5900XT": "75",
"GeForce FX 5900ZT": "76",
"GeForce FX 5950 Ultra": "77",
"GeForce PCX 5300": "78",
"GeForce PCX 5750": "79",
"GeForce PCX 5900": "80",
"nForce 680i SLI": "177",
"nForce 680i LT SLI": "178",
"nForce 650i SLI": "179",
"nForce 650i Ultra": "180",
"nForce 630i": "256",
"nForce 610i": "257",
"nForce 680a SLI": "181",
"nForce 630a": "243",
"nForce 590 SLI Intel": "182",
"nForce 570 SLI Intel": "183",
"nForce 590 SLI AMD": "184",
"nForce 570 SLI AMD": "185",
"nForce 570 Ultra AMD": "186",
"nForce 560": "224",
"nForce 550": "187",
"nForce 520": "190",
"nForce4 SLI X16 Intel": "191",
"nForce4 SLI Intel": "192",
"nForce4 SLI XE Intel": "193",
"nForce4 Ultra Intel": "194",
"nForce4 SLI X16 AMD": "195",
"nForce4 SLI AMD": "196",
"nForce4 Ultra AMD": "197",
"nForce4 AMD": "198",
"nForce 430": "206",
"nForce 410": "204",
"nForce 405": "207",
"nForce 400": "208",
"nForce 420": "209",
"nForce Professional 2200": "210",
"nForce Professional 22200": "211",
"nForce Professional 3600": "214",
"nForce Professional 3400": "213",
"Quadro Plex S Series": "302",
"Quadro Plex Model II": "82",
"Quadro Plex D Series": "301",
"Quadro Plex Model IV": "84",
"Quadro Plex 7000": "534",
"Quadro CX": "318",
"Quadro FX 330": "107",
"Quadro FX 350": "85",
"Quadro FX 370": "245",
"Quadro FX 370 Low Profile": "333",
"Quadro FX 380": "412",
"Quadro FX 380 Low Profile": "469",
"Quadro FX 470": "319",
"Quadro FX 500": "86",
"Quadro FX 540": "87",
"Quadro FX 550": "88",
"Quadro FX 560": "89",
"Quadro FX 570": "246",
"Quadro FX 580": "413",
"Quadro FX 700": "90",
"Quadro FX 1000": "91",
"Quadro FX 1100": "92",
"Quadro FX 1300": "93",
"Quadro FX 1400": "94",
"Quadro FX 1500": "95",
"Quadro FX 1700": "247",
"Quadro FX 1800": "411",
"Quadro FX 2000": "96",
"Quadro FX 3000": "97",
"Quadro FX 3400": "98",
"Quadro FX 3450": "306",
"Quadro FX 3500": "100",
"Quadro FX 3700": "277",
"Quadro FX 3800": "410",
"Quadro FX 4000": "101",
"Quadro FX 4500": "102",
"Quadro FX 4500 X2": "103",
"Quadro FX 4600": "104",
"Quadro FX 4700 X2": "294",
"Quadro FX 4800": "335",
"Quadro FX 5500": "105",
"Quadro FX 5600": "106",
"Quadro FX 5800": "336",
"Quadro NVS 50": "580",
"Quadro NVS 55": "581",
"Quadro NVS 210": "582",
"Quadro NVS 280": "583",
"Quadro NVS 285": "584",
"Quadro NVS 290": "585",
"Quadro NVS 295": "586",
"NVS 810": "786",
"NVS 510": "655",
"NVS 315": "697",
"NVS 310": "641",
"NVS 300": "531",
"Quadro NVS 420": "587",
"Quadro NVS 440": "588",
"Quadro NVS 450": "589",
"Quadro4 380 XGL": "115",
"Quadro4 550 XGL": "130",
"Quadro4 580 XGL": "117",
"Quadro4 700 XGL": "118",
"Quadro4 750 XGL": "119",
"Quadro4 780 XGL": "120",
"Quadro4 900 XGL": "121",
"Quadro4 980 XGL": "122",
"Quadro DCC": "123",
"Quadro4 500 GoGL": "131",
"Quadro4 700 GoGL": "132",
"Quadro2 MXR": "168",
"GeForce4 Ti 4200": "134",
"GeForce4 Ti 4200 with AGP8X": "135",
"GeForce4 Ti 4400": "136",
"GeForce4 Ti 4600": "137",
"GeForce4 Ti 4800": "138",
"GeForce4 Ti 4800 SE": "139",
"GeForce4 MX 4000": "140",
"GeForce4 MX 420": "141",
"GeForce4 MX 420 with AGP8X": "142",
"GeForce4 MX 440": "143",
"GeForce4 MX 440 with AGP8X": "144",
"GeForce4 MX 440-SE": "145",
"GeForce4 MX 440SE with AGP8X": "146",
"GeForce4 MX 460": "147",
"GeForce4 MX Integrated GPU": "148",
"GeForce3": "259",
"GeForce3 Ti 200": "260",
"GeForce3 Ti 500": "261",
"GeForce2 GTS": "152",
"GeForce2 Ti": "153",
"GeForce2 Ultra": "154",
"GeForce2 Integrated GPU": "155",
"GeForce2 MX 100": "156",
"GeForce2 MX": "157",
"GeForce 256": "158",
"GeForce 256 DDR": "159",
"Riva 128": "161",
"Riva 128 AGP": "160",
"RIVA TNT": "162",
"Aladdin TNT2": "163",
"RIVA TNT2 Model 64": "164",
"RIVA TNT2 Ultra": "165",
"RIVA TNT2": "166",
"Vanta": "170",
"Quadro2 Pro": "167",
"GeForce Go 7950 GTX": "276",
"GeForce Go 7900 GTX": "176",
"GeForce Go 7800 GTX": "174",
"GeForce Go 7900 GS": "175",
"Quadro NVS 210S": "215",
"NF-SPP-100": "216",
"GeForce Go 6150": "217",
"GeForce Go 6100": "218",
"GeForce Go 6155": "219",
"nForce 630M": "220",
"GeForce 7190M": "221",
"GeForce 7150M": "222",
"GeForce 7000M": "223",
"Quadro Sync II": "838",
"Quadro Sync": "665",
"Quadro G-Sync II": "226",
"Quadro G-Sync I": "225",
"nForce 1": "304",
"nForce 3": "305",
"MAXtreme": "227",
"POWERdraft": "228",
"GeForce 2 MX Series": "241",
"TNT and TNT2 Series": "282",
"nForce Professional 2000 Series": "296",
"nForce Professional 3000 Series": "297",
"nForce 790i Ultra SLI": "285",
"nForce 790i SLI": "284",
"nForce 780i SLI": "273",
"nForce 760i SLI": "409",
"nForce 750i SLI": "274",
"nForce 740i SLI": "403",
"nForce 730i": "320",
"nForce 720i": "378",
"nForce 780a SLI": "292",
"nForce 750a SLI": "404",
"nForce 740a SLI": "405",
"nForce 730a": "406",
"nForce 720a - GeForce 8100": "407",
"nForce 710a": "408",
"GeForce 8800M GTX": "315",
"GeForce 8800M GTS": "314",
"GeForce 8700M GT": "275",
"Quadro SDI": "298",
"Tesla C2075": "573",
"Tesla C2070": "513",
"Tesla C2050": "492",
"Tesla C1060": "327",
"Tesla C870": "278",
"D870": "279",
"S2050": "505",
"S1070": "317",
"S870": "280",
"GeForce 9800 GX2": "286",
"GeForce 9800 GTX": "287",
"GeForce 9800 GT": "309",
"GeForce 9600 GT": "283",
"GeForce 9600 GSO": "295",
"GeForce 9600 GSO 512": "321",
"GeForce 9600 GS": "316",
"GeForce 9500 GT": "307",
"GeForce 9500 GS": "310",
"GeForce 9400 GT": "311",
"GeForce 9400": "325",
"GeForce 9300 GS": "312",
"GeForce 9300 GE": "313",
"GeForce 9300 SE": "322",
"GeForce 9300": "323",
"GeForce 9200": "324",
"GeForce 9100": "418",
"GeForce GTX 295": "376",
"GeForce GTX 285": "377",
"GeForce GTX 280": "299",
"GeForce GTX 275": "416",
"GeForce GTX 260": "300",
"GeForce GTS 250": "399",
"GeForce GTS 240": "450",
"GeForce GT 230": "449",
"GeForce GT 240": "470",
"GeForce GT 220": "448",
"GeForce G210": "447",
"GeForce 210": "453",
"GeForce 205": "471",
"Full driver CD for GeForce": "381",
"Full driver CD for Notebook PCs": "488",
"3D Vision driver for GeForce": "432",
"3D Vision driver for Notebook": "555",
"3D Vision driver for Quadro": "452",
"3D Vision Video Player": "451",
"nForce 980a SLI": "398",
"GeForce GT 140": "400",
"GeForce GT 130": "401",
"GeForce GT 120": "402",
"GeForce G100": "415",
"ION": "431",
"3D Vision Pro driver": "535",
"3D Vision Pro Video Player": "536",
"ION LE": "446",
"GeForce GT 340": "473",
"GeForce GT 330": "474",
"GeForce GT 320": "475",
"GeForce 315": "494",
"GeForce 310": "493",
"GeForce GTX 480": "490",
"GeForce GTX 470": "491",
"GeForce GTX 465": "498",
"GeForce GTX 460 SE v2": "600",
"GeForce GTX 460 SE": "530",
"GeForce GTX 460": "499",
"GeForce GTS 450": "514",
"GeForce GT 440": "541",
"GeForce GT 430": "516",
"GeForce GT 420": "538",
"GeForce 405": "572",
"Quadro GV100": "866",
"Quadro GP100": "839",
"Quadro P6000": "824",
"Quadro P5200": "869",
"Quadro P5000": "823",
"Quadro P4000": "840",
"Quadro P2200": "900",
"Quadro P2000": "841",
"Quadro P1000": "842",
"Quadro P620": "865",
"Quadro P600": "843",
"Quadro P400": "844",
"Quadro M6000": "768",
"Quadro M5000": "780",
"Quadro M4000": "781",
"Quadro M2000": "811",
"Quadro K6000": "708",
"Quadro K5000 for Mac": "696",
"Quadro K5200": "752",
"Quadro K5000": "654",
"Quadro K4000": "681",
"Quadro K4200": "751",
"Quadro K2200": "750",
"Quadro K2000": "678",
"Quadro K2000D": "682",
"Quadro K1200": "767",
"Quadro K620": "749",
"Quadro K600": "679",
"Quadro K420": "748",
"Quadro 7000": "625",
"Quadro 6000": "508",
"Quadro 5000": "509",
"Quadro 4000": "510",
"Quadro 4000 for Mac": "529",
"Quadro 2000": "511",
"Quadro 2000D": "543",
"Quadro 600": "512",
"Quadro 410": "645",
"Quadro 400": "553",
"M60": "783",
"M40": "794",
"M6": "784",
"M4": "799",
"M2090": "570",
"M2075": "571",
"M2070": "525",
"M2070-Q": "542",
"M2050": "526",
"M1060": "527",
"GeForce GTX 590": "545",
"GeForce GTX 580": "528",
"GeForce GTX 570": "532",
"GeForce GTX 560 Ti": "540",
"GeForce GTX 560 SE": "609",
"GeForce GTX 560": "560",
"GeForce GTX 555": "626",
"GeForce GTX 550 Ti": "544",
"GeForce GT 545": "559",
"GeForce GT 530": "557",
"GeForce GT 520": "554",
"GeForce 510": "605",
"Tesla X2070": "717",
"Tesla X2090": "608",
"GeForce GTX 690": "627",
"GeForce GTX 680": "610",
"GeForce GTX 670": "629",
"GeForce GTX 660 Ti": "653",
"GeForce GTX 660": "660",
"GeForce GTX 650 Ti BOOST": "683",
"GeForce GTX 650 Ti": "666",
"GeForce GTX 650": "661",
"GeForce GTX 645": "731",
"GeForce GT 645": "633",
"GeForce GT 640": "632",
"GeForce GT 635": "685",
"GeForce GT 630": "631",
"GeForce GT 625": "741",
"GeForce GT 620": "611",
"GeForce GT 610": "630",
"GeForce 605": "624",
"Tesla K80": "762",
"Tesla K520": "856",
"Tesla K40c": "713",
"Tesla K40m": "714",
"Tesla K40s": "715",
"Tesla K40st": "716",
"Tesla K40t": "777",
"Tesla K20Xm": "670",
"Tesla K20m": "668",
"Tesla K20s": "684",
"Tesla K20c": "667",
"Tesla K10": "652",
"Tesla K8": "760",
"CUDA Toolkit 5.5": "673",
"GRID K2": "710",
"GRID K520": "704",
"GRID K1": "709",
"GRID K340": "705",
"GeForce GTX 780 Ti": "712",
"GeForce GTX 780": "691",
"GeForce GTX 770": "694",
"GeForce GTX 760": "703",
"GeForce GTX 760 Ti": "711",
"GeForce GTX 750 Ti": "727",
"GeForce GTX 750": "728",
"GeForce GTX 745": "730",
"GeForce GT 740": "745",
"GeForce GT 730": "746",
"GeForce GT 720": "754",
"GeForce GT 710": "742",
"GeForce GT 705": "761",
"GeForce GTX 980 Ti": "778",
"GeForce GTX 980": "755",
"GeForce GTX 970": "756",
"GeForce GTX 960": "764",
"GeForce GTX 950": "782",
"GeForce GTX 1080 Ti": "845",
"GeForce GTX 1080": "815",
"GeForce GTX 1070 Ti": "859",
"GeForce GTX 1070": "816",
"GeForce GTX 1060": "817",
"GeForce GTX 1050 Ti": "825",
"GeForce GTX 1050": "826",
"GeForce GT 1030": "852",
"GeForce GT 1010": "936",
"Tesla P100": "822",
"Tesla P40": "827",
"Tesla P6": "858",
"Tesla P4": "831",
"Tesla V100": "857",
"TITAN RTX": "885",
"TITAN V": "861",
"TITAN Xp": "850",
"TITAN X (Pascal)": "818",
"GeForce GTX TITAN X": "766",
"GeForce GTX TITAN": "695",
"GeForce GTX TITAN Black": "729",
"GeForce GTX TITAN Z": "744",
"GeForce RTX 2080 Ti": "877",
"GeForce RTX 2080 SUPER": "904",
"GeForce RTX 2080": "879",
"GeForce RTX 2070 SUPER": "903",
"GeForce RTX 2070": "880",
"GeForce RTX 2060 SUPER": "902",
"GeForce RTX 2060": "887",
"Quadro RTX 8000": "886",
"Quadro RTX 6000": "881",
"Quadro RTX 5000": "882",
"Quadro RTX 4000": "884",
"Quadro RTX 3000": "905",
"Tesla T4": "883",
"GeForce GTX 1660 SUPER": "910",
"GeForce GTX 1650 SUPER": "911",
"GeForce GTX 1660 Ti": "892",
"GeForce GTX 1660": "895",
"GeForce GTX 1650": "897",
"GeForce GTX 1630": "993",
"HGX GB200": "1069",
"HGX B200": "1064",
"H20-3e": "1063",
"H20": "1043",
"GH200": "1034",
"HGX H800": "1013",
"HGX H200": "1050",
"HGX H100": "1002",
"HGX A800": "997",
"HGX A100": "922",
"HGX-2": "896",
"AX800": "1032",
"A800": "1031",
"A100": "923",
"A40": "937",
"A30": "954",
"A16": "966",
"A10": "955",
"A2": "970",
"RTX 6000 Ada Generation": "1000",
"RTX 5000 Ada Generation": "1025",
"RTX 4500 Ada Generation": "1030",
"RTX 4000 Ada Generation": "1026",
"RTX 4000 SFF Ada Generation": "1014",
"RTX 2000 Ada Generation": "1042",
"RTX 2000E Ada Generation": "1049",
"RTX A2000": "967",
"RTX A1000": "1047",
"RTX A400": "1048",
"RTX 8000": "927",
"RTX 6000": "928",
"RTX A6000": "935",
"RTX A5000": "943",
"RTX A4000": "944",
"T1000": "945",
"T600": "946",
"T400": "947",
"GeForce RTX 3090 Ti": "985",
"GeForce RTX 3090": "930",
"GeForce RTX 3080 Ti": "964",
"GeForce RTX 3080": "929",
"GeForce RTX 3070 Ti": "965",
"GeForce RTX 3070": "933",
"GeForce RTX 3060 Ti": "934",
"GeForce RTX 3060": "942",
"GeForce RTX 3050": "975",
"RTX 5880 Ada Generation": "1035",
"RTX A5500": "984",
"RTX A4500": "969",
"RTX A4000H": "980",
"T400E": "1044",
"H100 PCIe": "994",
"H100 NVL": "1028",
"H800 PCIe": "1012",
"H800 NVL": "1029",
"H200 NVL": "1062",
"GeForce RTX 4090 D": "1036",
"GeForce RTX 4090": "995",
"GeForce RTX 4080 SUPER": "1041",
"GeForce RTX 4080": "999",
"GeForce RTX 4070 Ti SUPER": "1040",
"GeForce RTX 4070 Ti": "1001",
"GeForce RTX 4070 SUPER": "1039",
"GeForce RTX 4070": "1015",
"GeForce RTX 4060 Ti": "1022",
"GeForce RTX 4060": "1023",
"L40": "1003",
"L40S": "1027",
"L20": "1038",
"L4": "1009",
"L2": "1037",
"BlueField-3": "1011",
"BlueField-2": "1010",
"BlueField Drivers for Host": "1024",
"GeForce RTX 5090 D": "1067",
"GeForce RTX 5090": "1066",
"GeForce RTX 5080": "1065",
"GeForce RTX 5070 Ti": "1068",
"GeForce RTX 5070": "1070",
"GeForce RTX 5060 Ti": "1076",
"RTX PRO 6000 Blackwell Workstation": "1071",
"RTX PRO 6000 Blackwell Max-Q Workstation": "1072"
},
"notebook": {
"NVS 5400M": "642",
"NVS 5200M": "614",
"NVS 5100M": "486",
"NVS 4200M": "565",
"NVS 3100M": "487",
"NVS 2100M": "517",
"Quadro NVS 510M": "590",
"Quadro NVS 320M": "591",
"Quadro NVS 300M": "592",
"Quadro NVS 160M": "593",
"Quadro NVS 150M": "594",
"Quadro NVS 140M": "595",
"Quadro NVS 135M": "596",
"Quadro NVS 130M": "597",
"Quadro NVS 120M": "598",
"Quadro NVS 110M": "599",
"Quadro FX 3800M": "483",
"Quadro FX 3700M": "419",
"Quadro FX 3600M": "444",
"Quadro FX 2800M": "496",
"Quadro FX 2700M": "126",
"Quadro FX 1800M": "482",
"Quadro FX 1700M": "252",
"Quadro FX 1600M": "443",
"Quadro FX 880M": "497",
"Quadro FX 770M": "441",
"Quadro FX 570M": "253",
"Quadro FX 380M": "484",
"Quadro FX 370M": "440",
"Quadro FX 360M": "254",
"GeForce 9800M GTX": "347",
"GeForce 9800M GTS": "348",
"GeForce 9800M GT": "349",
"GeForce 9800M GS": "350",
"GeForce 9700M GTS": "351",
"GeForce 9700M GT": "352",
"GeForce 9650M GT": "353",
"GeForce 9650M GS": "354",
"GeForce 9600M GT": "355",
"GeForce 9600M GS": "356",
"GeForce 9500M GS": "357",
"GeForce 9500M G": "358",
"GeForce 9400M G": "384",
"GeForce 9400M": "383",
"GeForce 9300M GS": "359",
"GeForce 9300M G": "360",
"GeForce 9200M GS": "361",
"GeForce 9100M G": "382",
"GeForce 8800M GTX": "363",
"GeForce 8800M GTS": "364",
"GeForce 8800M GS": "365",
"GeForce 8700M GT": "366",
"GeForce 8600M GT": "367",
"GeForce 8600M GS": "368",
"GeForce 8400M GT": "369",
"GeForce 8400M GS": "370",
"GeForce 8400M G": "371",
"GeForce 8200M G": "385",
"GeForce 8200M": "569",
"GeForce Go 7950 GTX": "372",
"GeForce Go 7900 GTX": "373",
"GeForce Go 7900 GS": "375",
"GeForce Go 7900 SE": "389",
"GeForce Go 7800 GTX": "374",
"GeForce Go 7800": "390",
"GeForce Go 7700": "391",
"GeForce Go 7600": "392",
"GeForce Go 7400": "393",
"GeForce Go 7300": "394",
"GeForce Go 7200": "395",
"GeForce 7150M": "397",
"GeForce 7000M": "396",
"GeForce GTS 160M": "424",
"GeForce GTS 150M": "733",
"GeForce GT 130M": "425",
"GeForce GT 120M": "426",
"GeForce G 110M": "427",
"GeForce G 105M": "428",
"GeForce G 103M": "429",
"GeForce G 102M": "430",
"GeForce GTX 285M": "454",
"GeForce GTX 280M": "421",
"GeForce GTX 260M": "422",
"GeForce GTS 260M": "456",
"GeForce GTS 250M": "457",
"GeForce GT 240M": "460",
"GeForce GT 230M": "461",
"GeForce GT 220M": "462",
"GeForce G210M": "465",
"GeForce G205M": "647",
"Quadro RTX 5000": "914",
"Quadro RTX 3000": "912",
"Quadro T2000": "908",
"Quadro T1000": "909",
"Quadro P5000": "847",
"Quadro P3000": "849",
"Quadro P2000": "873",
"Quadro P1000": "874",
"Quadro M5000 SE": "829",
"Quadro M3000 SE": "828",
"Quadro K3100M": "721",
"Quadro 500M": "615",
"Quadro 1000M": "547",
"Quadro 3000M": "568",
"Quadro 4000M": "567",
"Quadro FX 560M": "442",
"ION": "463",
"ION LE": "464",
"GeForce GTS 360M": "455",
"GeForce GTS 350M": "478",
"GeForce GT 335M": "458",
"GeForce GT 330M": "459",
"GeForce GT 325M": "476",
"GeForce GT 320M": "495",
"GeForce 320M": "563",
"GeForce 315M": "564",
"GeForce 310M": "466",
"GeForce 305M": "467",
"GeForce GTX 485M": "556",
"GeForce GTX 480M": "500",
"GeForce GTX 470M": "533",
"GeForce GTX 460M": "518",
"GeForce GT 445M": "523",
"GeForce GT 435M": "519",
"GeForce GT 425M": "520",
"GeForce GT 420M": "521",
"GeForce GT 415M": "522",
"GeForce 410M": "562",
"GeForce 405M": "732",
"Quadro P5200": "870",
"Quadro P4200": "871",
"Quadro P3200": "872",
"Quadro P4000": "848",
"Quadro P620": "992",
"Quadro P600": "875",
"Quadro P520": "901",
"Quadro P500": "876",
"Quadro M2200": "834",
"Quadro M1200": "835",
"Quadro M620": "830",
"Quadro M520": "851",
"Quadro M5500": "810",
"Quadro M5000M": "788",
"Quadro M4000M": "789",
"Quadro M3000M": "790",
"Quadro M2000M": "791",
"Quadro M1000M": "792",
"Quadro M600M": "793",
"Quadro M500M": "814",
"Quadro K5100M": "723",
"Quadro K5000M": "659",
"Quadro K4100M": "722",
"Quadro K4000M": "658",
"Quadro K2200M": "774",
"Quadro K2100M": "720",
"Quadro K3000M": "657",
"Quadro K2000M": "649",
"Quadro K1100M": "719",
"Quadro K1000M": "650",
"Quadro K620M": "776",
"Quadro K610M": "724",
"Quadro K510M": "725",
"Quadro K500M": "706",
"Quadro 5010M": "566",
"Quadro 5000M": "524",
"Quadro 2000M": "546",
"GeForce GTX 580M": "601",
"GeForce GTX 570M": "603",
"GeForce GTX 560M": "561",
"GeForce GT 555M": "552",
"GeForce GT 550M": "551",
"GeForce GT 540M": "550",
"GeForce GT 525M": "549",
"GeForce GT 520M": "548",
"GeForce GT 520MX": "602",
"GeForce GTX 680MX": "677",
"GeForce GTX 680M": "646",
"GeForce GTX 675MX": "664",
"GeForce GTX 675M": "638",
"GeForce GTX 670MX": "663",
"GeForce GTX 670M": "639",
"GeForce GTX 660M": "637",
"GeForce GT 650M": "636",
"GeForce GT 645M": "662",
"GeForce GT 640M": "635",
"GeForce GT 640M LE": "644",
"GeForce GT 635M": "619",
"GeForce GT 630M": "612",
"GeForce GT 625M": "671",
"GeForce GT 620M": "634",
"GeForce 610M": "620",
"GeForce GTX 780M": "700",
"GeForce GTX 775M": "740",
"GeForce GTX 770M": "702",
"GeForce GTX 765M": "701",
"GeForce GTX 760M": "699",
"GeForce GT 755M": "707",
"GeForce GT 750M": "690",
"GeForce GT 745M": "689",
"GeForce GT 740M": "686",
"GeForce GT 735M": "688",
"GeForce GT 730M": "672",
"GeForce GT 720M": "687",
"GeForce GT 710M": "798",
"GeForce 720M": "803",
"GeForce 710M": "680",
"GeForce 705M": "775",
"GeForce GTX 880M": "734",
"GeForce GTX 870M": "735",
"GeForce GTX 860M": "736",
"GeForce GTX 850M": "737",
"GeForce 845M": "805",
"GeForce 840M": "738",
"GeForce 830M": "739",
"GeForce 825M": "804",
"GeForce 820M": "726",
"GeForce 810M": "759",
"GeForce 800M": "797",
"GeForce GTX 980": "785",
"GeForce GTX 980M": "757",
"GeForce GTX 970M": "758",
"GeForce GTX 965M": "765",
"GeForce GTX 960M": "769",
"GeForce GTX 950M": "770",
"GeForce 945M": "795",
"GeForce 940MX": "796",
"GeForce 930MX": "807",
"GeForce 920MX": "808",
"GeForce 940M": "771",
"GeForce 930M": "772",
"GeForce 920M": "773",
"GeForce 910M": "779",
"GeForce GTX 1080": "819",
"GeForce GTX 1070": "820",
"GeForce GTX 1060": "821",
"GeForce GTX 1050 Ti": "836",
"GeForce GTX 1050": "837",
"GeForce MX150": "853",
"GeForce MX130": "862",
"GeForce MX110": "863",
"GeForce RTX 2080 SUPER": "919",
"GeForce RTX 2080": "890",
"GeForce RTX 2070 SUPER": "920",
"GeForce RTX 2070": "889",
"GeForce RTX 2060": "888",
"GeForce RTX 2050": "978",
"GeForce MX250": "894",
"GeForce MX230": "893",
"GeForce GTX 1660 Ti": "899",
"GeForce GTX 1650 Ti": "921",
"GeForce GTX 1650": "898",
"Quadro RTX 6000": "932",
"Quadro RTX 4000": "913",
"GeForce MX350": "918",
"GeForce MX330": "917",
"GeForce MX450": "931",
"GeForce RTX 3080 Ti Laptop GPU": "976",
"GeForce RTX 3080 Laptop GPU": "938",
"GeForce RTX 3070 Ti Laptop GPU": "979",
"GeForce RTX 3070 Laptop GPU": "939",
"GeForce RTX 3060 Laptop GPU": "940",
"GeForce RTX 3050 Ti Laptop GPU": "962",
"GeForce RTX 3050 Laptop GPU": "963",
"RTX 5000 Ada Generation Laptop GPU": "1020",
"RTX 4000 Ada Generation Laptop GPU": "1019",
"RTX 3500 Ada Generation Laptop GPU": "1018",
"RTX 3000 Ada Generation Laptop GPU": "1017",
"RTX 2000 Ada Generation Laptop GPU": "1016",
"RTX 1000 Ada Generation Laptop GPU": "1045",
"RTX 500 Ada Generation Laptop GPU": "1046",
"RTX A5500 Laptop GPU": "987",
"RTX A5000 Laptop GPU": "958",
"RTX A4500 Laptop GPU": "983",
"RTX A4000 Laptop GPU": "959",
"RTX A3000": "988",
"RTX A3000 Laptop GPU": "956",
"RTX A2000": "989",
"RTX A2000 Laptop GPU": "957",
"RTX A1000": "1021",
"RTX A1000 Laptop GPU": "981",
"RTX A500 Laptop GPU": "998",
"T1200 Laptop GPU": "960",
"T600 Laptop GPU": "961",
"T550 Laptop GPU": "986",
"T500": "941",
"GeForce MX570": "977",
"GeForce MX550": "974",
"GeForce RTX 4090 Laptop GPU": "1004",
"GeForce RTX 4080 Laptop GPU": "1005",
"GeForce RTX 4070 Laptop GPU": "1006",
"GeForce RTX 4060 Laptop GPU": "1007",
"GeForce RTX 4050 Laptop GPU": "1008",
"GeForce RTX 5090 Laptop GPU": "1073",
"GeForce RTX 5080 Laptop GPU": "1074",
"GeForce RTX 5070 Ti Laptop GPU": "1075"
}
}
@@ -8,8 +8,6 @@
"WPFTweaksLocation",
"WPFTweaksServices",
"WPFTweaksTelemetry",
"WPFTweaksDiskCleanup",
"WPFTweaksDeleteTempFiles",
"WPFTweaksEndTaskOnTaskbar",
"WPFTweaksPowershell7Tele"
]
+113 -79
View File
@@ -17,10 +17,10 @@ const (
type Package struct {
Name string
Phase Phase
WingetID string
WingetSource string
WingetOverride string
WingetConfigAsset string
ChocoID string
ChocoParams string
InstallerArgs string
ChocoConfigAsset string
Class Classification
RequiresElevation bool
Reboot RebootBehavior
@@ -28,86 +28,101 @@ type Package struct {
AI bool
}
func wingetPackage(name, id string, phase Phase, class Classification) Package {
func chocoPackage(name, id string, phase Phase, class Classification) Package {
return Package{
Name: name,
Phase: phase,
WingetID: id,
WingetSource: "winget",
Class: class,
Reboot: RebootNone,
Name: name,
Phase: phase,
ChocoID: id,
Class: class,
Reboot: RebootNone,
}
}
// Catalog is deliberately explicit: unlisted software is not installed by a fallback provider.
func Catalog() []Package {
packages := []Package{
{
Name: "Visual Studio Community 2026",
Phase: PhaseHost,
WingetID: "Microsoft.VisualStudio.Community",
WingetSource: "winget",
WingetOverride: "--passive --config {config}",
WingetConfigAsset: "assets/visual-studio-community.vsconfig",
Class: ClassGUI,
RequiresElevation: true,
Reboot: RebootPossible,
PostInstall: "install workloads/components from managed .vsconfig; use Microsoft C/C++ compilers and CMake",
chocoPackage("WezTerm", "wezterm", PhaseHost, ClassGUI),
chocoPackage("PowerShell 7", "powershell-core", PhaseHost, ClassHeadless),
chocoPackage("PowerToys", "powertoys", PhaseHost, ClassGUI),
chocoPackage("JetBrainsMono Nerd Font", "nerd-fonts-jetbrainsmono", PhaseHost, ClassHeadless),
chocoPackage("Android Studio", "androidstudio", PhaseHost, ClassGUI),
// FilePilot has no official Chocolatey package, but it's listed here.
// We use empty ChocoID so our custom installer downloads and installs it directly.
chocoPackage("FilePilot", "", PhaseHost, ClassGUI),
chocoPackage("Vivaldi", "vivaldi", PhaseHost, ClassGUI),
chocoPackage("OBS Studio", "obs-studio", PhaseHost, ClassGUI),
chocoPackage("Obsidian", "obsidian", PhaseHost, ClassGUI),
chocoPackage("Libre Hardware Monitor", "librehardwaremonitor", PhaseHost, ClassGUI),
chocoPackage("WireGuard", "wireguard", PhaseHost, ClassGUI),
chocoPackage("Steam", "steam", PhaseHost, ClassGUI),
chocoPackage("Bruno", "bruno", PhaseHost, ClassGUI),
chocoPackage("VLC", "vlc", PhaseHost, ClassGUI),
chocoPackage("Wireshark", "wireshark", PhaseHost, ClassGUI),
chocoPackage("HandBrake", "handbrake", PhaseHost, ClassGUI),
chocoPackage("Gpg4win (includes gpgOL)", "gpg4win", PhaseHost, ClassGUI),
chocoPackage("Tor Browser", "tor-browser", PhaseHost, ClassGUI),
chocoPackage("LibreWolf", "librewolf", PhaseHost, ClassGUI),
chocoPackage("Rufus", "rufus", PhaseHost, ClassGUI),
chocoPackage("Slack", "slack", PhaseHost, ClassGUI),
chocoPackage("Discord", "discord", PhaseHost, ClassGUI),
chocoPackage("Revo Uninstaller", "revo-uninstaller", PhaseHost, ClassGUI),
chocoPackage("darktable", "darktable", PhaseHost, ClassGUI),
chocoPackage("Floorp", "floorp", PhaseHost, ClassGUI),
chocoPackage("Krita", "krita", PhaseHost, ClassGUI),
chocoPackage("Godot C++", "godot", PhaseHost, ClassGUI),
chocoPackage("Blender", "blender", PhaseHost, ClassGUI),
chocoPackage("Git", "git", PhaseHost, ClassHeadless),
chocoPackage("GitHub CLI", "gh", PhaseHost, ClassHeadless),
chocoPackage("Neovim", "neovim", PhaseHost, ClassHeadless),
chocoPackage("trivy", "trivy", PhaseHost, ClassHeadless),
chocoPackage("cosign", "cosign", PhaseHost, ClassHeadless),
chocoPackage("gitleaks", "gitleaks", PhaseHost, ClassHeadless),
chocoPackage("jq", "jq", PhaseHost, ClassHeadless),
chocoPackage("yq", "yq", PhaseHost, ClassHeadless),
chocoPackage("fzf", "fzf", PhaseHost, ClassHeadless),
chocoPackage("fd", "fd", PhaseHost, ClassHeadless),
chocoPackage("bottom", "bottom", PhaseHost, ClassHeadless),
chocoPackage("ripgrep", "ripgrep", PhaseHost, ClassHeadless),
chocoPackage("FFmpeg", "ffmpeg", PhaseHost, ClassHeadless),
chocoPackage("lazygit", "lazygit", PhaseHost, ClassHeadless),
chocoPackage("Lua", "lua", PhaseHost, ClassHeadless),
chocoPackage("minisign", "minisign", PhaseHost, ClassHeadless),
chocoPackage("Google Cloud CLI", "gcloudsdk", PhaseHost, ClassHeadless),
chocoPackage("Rustup", "rustup.install", PhaseHost, ClassHeadless),
chocoPackage("AWS CLI", "awscli", PhaseHost, ClassHeadless),
chocoPackage("Azure CLI", "azure-cli", PhaseHost, ClassHeadless),
chocoPackage("Pulumi", "pulumi", PhaseHost, ClassHeadless),
chocoPackage("restic", "restic", PhaseHost, ClassHeadless),
chocoPackage("yt-dlp", "yt-dlp", PhaseHost, ClassHeadless),
chocoPackage("Vagrant", "vagrant", PhaseHost, ClassHeadless),
chocoPackage("Go", "golang", PhaseHost, ClassHeadless),
chocoPackage(".NET SDK", "dotnet-sdk", PhaseHost, ClassHeadless),
chocoPackage("Temurin JDK", "temurin", PhaseHost, ClassHeadless),
chocoPackage("Zig", "zig", PhaseHost, ClassHeadless),
chocoPackage("CMake", "cmake", PhaseHost, ClassHeadless),
chocoPackage("oh-my-posh", "oh-my-posh", PhaseCustom, ClassHeadless),
chocoPackage("Google Chrome", "googlechrome", PhaseCustom, ClassGUI),
chocoPackage("Mullvad VPN", "mullvad-app", PhaseCustom, ClassGUI),
chocoPackage("Figma", "figma", PhaseCustom, ClassGUI),
chocoPackage("Zoom", "zoom", PhaseCustom, ClassGUI),
chocoPackage("Adobe Acrobat Reader", "adobereader", PhaseCustom, ClassGUI),
chocoPackage("WhatsApp", "", PhaseCustom, ClassGUI),
chocoPackage("Affinity", "", PhaseCustom, ClassGUI),
chocoPackage("Tidal", "", PhaseCustom, ClassGUI),
chocoPackage("DaVinci Resolve", "", PhaseCustom, ClassGUI),
chocoPackage("Ardour", "", PhaseCustom, ClassGUI),
chocoPackage("Nvidia Driver", "", PhaseHost, ClassGUI),
chocoPackage("Nvidia Broadcast", "", PhaseHost, ClassGUI),
chocoPackage("AMD Auto-Detect", "", PhaseHost, ClassGUI),
chocoPackage("Intel DSA", "", PhaseHost, ClassGUI),
Package{
Name: "Nmap",
Phase: PhaseCustom,
ChocoID: "nmap",
InstallerArgs: `"/S /NPCAP=NO"`,
Class: ClassHeadless,
Reboot: RebootNone,
},
wingetPackage("WezTerm", "wez.wezterm", PhaseHost, ClassGUI),
wingetPackage("PowerShell 7", "Microsoft.PowerShell", PhaseHost, ClassHeadless),
wingetPackage("PowerToys", "Microsoft.PowerToys", PhaseHost, ClassGUI),
wingetPackage("JetBrainsMono Nerd Font", "DEVCOM.JetBrainsMonoNerdFont", PhaseHost, ClassHeadless),
wingetPackage("Android Studio", "Google.AndroidStudio", PhaseHost, ClassGUI),
wingetPackage("FilePilot", "Voidstar.FilePilot", PhaseHost, ClassGUI),
wingetPackage("GitHub Desktop", "GitHub.GitHubDesktop", PhaseHost, ClassGUI),
wingetPackage("Google Chrome", "Google.Chrome", PhaseHost, ClassGUI),
wingetPackage("Vivaldi", "Vivaldi.Vivaldi", PhaseHost, ClassGUI),
wingetPackage("OBS Studio", "OBSProject.OBSStudio", PhaseHost, ClassGUI),
wingetPackage("Obsidian", "Obsidian.Obsidian", PhaseHost, ClassGUI),
wingetPackage("Libre Hardware Monitor", "LibreHardwareMonitor.LibreHardwareMonitor", PhaseHost, ClassGUI),
wingetPackage("Mullvad VPN", "MullvadVPN.MullvadVPN", PhaseHost, ClassGUI),
wingetPackage("WireGuard", "WireGuard.WireGuard", PhaseHost, ClassGUI),
wingetPackage("Steam", "Valve.Steam", PhaseHost, ClassGUI),
wingetPackage("Bruno", "Bruno.Bruno", PhaseHost, ClassGUI),
wingetPackage("Figma", "Figma.Figma", PhaseHost, ClassGUI),
wingetPackage("VLC", "VideoLAN.VLC", PhaseHost, ClassGUI),
wingetPackage("Wireshark", "WiresharkFoundation.Wireshark", PhaseHost, ClassGUI),
wingetPackage("Zoom", "Zoom.Zoom", PhaseHost, ClassGUI),
wingetPackage("HandBrake", "HandBrake.HandBrake", PhaseHost, ClassGUI),
wingetPackage("Adobe Acrobat Reader", "Adobe.Acrobat.Reader.64-bit", PhaseHost, ClassGUI),
wingetPackage("Gpg4win (includes gpgOL)", "GnuPG.Gpg4win", PhaseHost, ClassGUI),
wingetPackage("Tor Browser", "TorProject.TorBrowser", PhaseHost, ClassGUI),
wingetPackage("LibreWolf", "LibreWolf.LibreWolf", PhaseHost, ClassGUI),
wingetPackage("Rufus", "Rufus.Rufus", PhaseHost, ClassGUI),
wingetPackage("Slack", "SlackTechnologies.Slack", PhaseHost, ClassGUI),
wingetPackage("Discord", "Discord.Discord", PhaseHost, ClassGUI),
wingetPackage("Revo Uninstaller", "RevoUninstaller.RevoUninstaller", PhaseHost, ClassGUI),
wingetPackage("darktable", "darktable.darktable", PhaseHost, ClassGUI),
wingetPackage("Floorp", "Ablaze.Floorp", PhaseHost, ClassGUI),
wingetPackage("Git", "Git.Git", PhaseHost, ClassHeadless),
wingetPackage("GitHub CLI", "GitHub.cli", PhaseHost, ClassHeadless),
wingetPackage("Neovim", "Neovim.Neovim", PhaseHost, ClassHeadless),
wingetPackage("ripgrep", "BurntSushi.ripgrep.MSVC", PhaseHost, ClassHeadless),
wingetPackage("FFmpeg", "Gyan.FFmpeg", PhaseHost, ClassHeadless),
wingetPackage("lazygit", "JesseDuffield.lazygit", PhaseHost, ClassHeadless),
wingetPackage("Lua", "DEVCOM.Lua", PhaseHost, ClassHeadless),
wingetPackage("minisign", "jedisct1.minisign", PhaseHost, ClassHeadless),
wingetPackage("Google Cloud CLI", "Google.CloudSDK", PhaseHost, ClassHeadless),
wingetPackage("Rustup", "Rustlang.Rustup", PhaseHost, ClassHeadless),
wingetPackage("AWS CLI", "Amazon.AWSCLI", PhaseHost, ClassHeadless),
wingetPackage("Azure CLI", "Microsoft.AzureCLI", PhaseHost, ClassHeadless),
wingetPackage("Pulumi", "Pulumi.Pulumi", PhaseHost, ClassHeadless),
wingetPackage("restic", "restic.restic", PhaseHost, ClassHeadless),
wingetPackage("yt-dlp", "yt-dlp.yt-dlp", PhaseHost, ClassHeadless),
wingetPackage("Vagrant", "Hashicorp.Vagrant", PhaseHost, ClassHeadless),
wingetPackage("Go", "GoLang.Go", PhaseHost, ClassHeadless),
wingetPackage(".NET SDK", "Microsoft.DotNet.SDK.10", PhaseHost, ClassHeadless),
wingetPackage("Temurin JDK", "EclipseAdoptium.Temurin.21.JDK", PhaseHost, ClassHeadless),
wingetPackage("Zig", "zig.zig", PhaseHost, ClassHeadless),
wingetPackage("CMake", "Kitware.CMake", PhaseHost, ClassHeadless),
wingetPackage("fnm", "Schniz.fnm", PhaseHost, ClassHeadless),
wingetPackage("oh-my-posh", "JanDeDobbeleer.OhMyPosh", PhaseCustom, ClassHeadless),
}
for i := range packages {
if packages[i].Name == "Vagrant" {
@@ -115,9 +130,6 @@ func Catalog() []Package {
packages[i].Reboot = RebootPossible
packages[i].PostInstall = "configure Hyper-V provider prerequisites"
}
if packages[i].Name == "fnm" {
packages[i].PostInstall = "install Node.js LTS"
}
}
return packages
}
@@ -128,6 +140,28 @@ func PackagesForPhase(opts Options, phase Phase) []Package {
if pkg.Phase != phase || (opts.NoAI && pkg.AI) {
continue
}
if pkg.Name == "Nvidia Driver" {
if !opts.Nvidia {
continue
}
pkg.ChocoParams = opts.NvidiaModel
pkg.InstallerArgs = opts.NvidiaType
}
if pkg.Name == "Nvidia Broadcast" {
if !opts.Nvidia || !isRTX2050OrNewer(opts.NvidiaModel) {
continue
}
}
if pkg.Name == "AMD Auto-Detect" {
if !opts.Amd {
continue
}
}
if pkg.Name == "Intel DSA" {
if !opts.Intel {
continue
}
}
selected = append(selected, pkg)
}
return selected
+12 -9
View File
@@ -2,16 +2,15 @@ package bootstrap
import "testing"
func TestCatalogContainsExactWingetIDsAndDefersExcludedNativeTools(t *testing.T) {
func TestCatalogContainsExactChocoIDsAndDefersExcludedNativeTools(t *testing.T) {
seen := make(map[string]Package)
for _, pkg := range Catalog() {
if pkg.WingetID == "" || pkg.WingetSource != "winget" {
t.Fatalf("invalid winget metadata for %#v", pkg)
if pkg.ChocoID == "" && !isCustomPackage(pkg) {
t.Fatalf("invalid choco metadata for %#v", pkg)
}
seen[pkg.Name] = pkg
}
for _, name := range []string{
"Visual Studio Community 2026",
"Android Studio",
"FilePilot",
"PowerShell 7",
@@ -39,8 +38,16 @@ func TestCatalogContainsExactWingetIDsAndDefersExcludedNativeTools(t *testing.T)
"CMake",
"WezTerm",
"Vagrant",
"fnm",
"oh-my-posh",
"Nmap",
"Krita",
"Godot C++",
"Blender",
"WhatsApp",
"Affinity",
"Tidal",
"DaVinci Resolve",
"Ardour",
} {
if _, ok := seen[name]; !ok {
t.Fatalf("missing catalog package %s", name)
@@ -63,8 +70,4 @@ func TestCatalogContainsExactWingetIDsAndDefersExcludedNativeTools(t *testing.T)
if _, ok := seen["g++"]; ok {
t.Fatal("g++ should not be installed natively; use Visual Studio C/C++ tools")
}
vs := seen["Visual Studio Community 2026"]
if vs.WingetID != "Microsoft.VisualStudio.Community" || vs.WingetConfigAsset == "" || vs.WingetOverride == "" {
t.Fatalf("Visual Studio package missing managed config metadata: %#v", vs)
}
}
+430
View File
@@ -0,0 +1,430 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"unsafe"
)
var chocoCommand = "choco"
func init() {
if _, err := exec.LookPath("choco"); err == nil {
chocoCommand = "choco"
return
}
if chocoInstall := os.Getenv("ChocolateyInstall"); chocoInstall != "" {
path := filepath.Join(chocoInstall, "bin", "choco.exe")
if _, err := os.Stat(path); err == nil {
chocoCommand = path
return
}
}
defaultPath := `C:\ProgramData\chocolatey\bin\choco.exe`
if _, err := os.Stat(defaultPath); err == nil {
chocoCommand = defaultPath
}
}
func ChocoInstallArgs(pkg Package) []string {
args := []string{
"install", pkg.ChocoID, "-y", "--no-progress",
}
if pkg.ChocoParams != "" {
args = append(args, "--package-parameters", pkg.ChocoParams)
}
if pkg.InstallerArgs != "" {
args = append(args, "--install-arguments", pkg.InstallerArgs)
}
return args
}
func ChocoListArgs(pkg Package) []string {
return []string{
"list", "--local-only", "--exact", pkg.ChocoID,
}
}
func ChocoOutputShowsInstalled(pkg Package, output string) bool {
lower := strings.ToLower(output)
lines := strings.Split(lower, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, strings.ToLower(pkg.ChocoID)+" ") {
return true
}
}
return false
}
type PackageState struct {
Package Package
Installed bool
CheckErr error
}
func CheckPackages(ctx context.Context, runner Runner, packages []Package, workers int) []PackageState {
if workers < 1 {
workers = 1
}
states := make([]PackageState, len(packages))
type job struct {
index int
pkg Package
}
jobs := make(chan job)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for current := range jobs {
state := PackageState{Package: current.pkg}
if isCustomPackage(current.pkg) {
cmd := ""
switch current.pkg.Name {
case "FilePilot":
cmd = "if (Get-Command filepilot.exe -ErrorAction SilentlyContinue) { 'yes' } else { 'no' }"
case "Affinity":
cmd = `if (Get-AppxPackage *Affinity* -ErrorAction SilentlyContinue) { 'yes' } else { 'no' }`
case "Tidal":
cmd = `if (Test-Path "${env:LocalAppData}\TIDAL\TIDAL.exe" -or Test-Path "${env:ProgramFiles}\TIDAL\TIDAL.exe") { 'yes' } else { 'no' }`
case "DaVinci Resolve":
cmd = `if (Test-Path "${env:ProgramFiles}\Blackmagic Design\DaVinci Resolve\Resolve.exe") { 'yes' } else { 'no' }`
case "WhatsApp":
cmd = `if (Get-AppxPackage *WhatsApp* -ErrorAction SilentlyContinue) { 'yes' } else { 'no' }`
case "Ardour":
cmd = `if (Test-Path "C:\msys64\mingw64\bin\ardour8.exe" -or Test-Path "C:\msys64\mingw64\bin\ardour.exe" -or Test-Path "C:\msys64\mingw64\bin\ardour9.exe" -or Test-Path "C:\tools\msys64\mingw64\bin\ardour8.exe" -or Test-Path "C:\tools\msys64\mingw64\bin\ardour.exe" -or Test-Path "C:\tools\msys64\mingw64\bin\ardour9.exe") { 'yes' } else { 'no' }`
case "Nvidia Broadcast":
cmd = `if (Test-Path "${env:ProgramFiles}\NVIDIA Corporation\NVIDIA Broadcast\NVIDIA Broadcast.exe") { 'yes' } else { 'no' }`
case "AMD Auto-Detect":
cmd = `if (Test-Path "${env:ProgramFiles}\AMD\CNext\CNext\RadeonSoftware.exe" -or Test-Path "${env:ProgramFiles}\AMD\CNext\CNext\AMDRSServ.exe") { 'yes' } else { 'no' }`
case "Intel DSA":
cmd = `if (Test-Path "${env:ProgramFiles(x86)}\Intel\Driver and Support Assistant\DSAService.exe") { 'yes' } else { 'no' }`
case "Nvidia Driver":
model := current.pkg.ChocoParams
driverType := current.pkg.InstallerArgs
pfid, err := resolveNvidiaPFID(model)
if err != nil {
state.CheckErr = err
state.Installed = false
} else {
_, apiVer, err := fetchNvidiaDriverURLFn(pfid, driverType)
if err != nil {
state.CheckErr = err
state.Installed = false
} else {
installedVer, err := getInstalledNvidiaVersionFn(ctx, runner)
if err != nil {
state.CheckErr = err
state.Installed = false
} else {
state.Installed = (installedVer != "" && installedVer == apiVer)
}
}
}
}
if cmd != "" {
result := runner.Run(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd)
state.Installed = strings.Contains(strings.ToLower(result.CombinedOutput()), "yes")
} else if current.pkg.Name != "Nvidia Driver" {
state.Installed = false
}
} else {
result := runner.Run(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "Test-Path C:\\ProgramData\\chocolatey\\lib\\"+current.pkg.ChocoID)
if result.Err != nil {
state.CheckErr = result.Err
} else {
state.Installed = strings.Contains(strings.ToLower(result.CombinedOutput()), "true")
}
}
states[current.index] = state
}
}()
}
for i, pkg := range packages {
jobs <- job{index: i, pkg: pkg}
}
close(jobs)
wg.Wait()
return states
}
func PendingPackages(states []PackageState) []Package {
var pending []Package
for _, state := range states {
if !state.Installed {
pending = append(pending, state.Package)
}
}
return pending
}
func UnknownPackageStates(packages []Package) []PackageState {
states := make([]PackageState, 0, len(packages))
for _, pkg := range packages {
states = append(states, PackageState{Package: pkg})
}
return states
}
func InstallPackages(ctx context.Context, runner Runner, packages []Package) Issues {
var issues Issues
for _, pkg := range packages {
pkgTimeout := 5 * time.Minute
if pkg.Name == "Ardour" || pkg.Name == "Nvidia Driver" || pkg.Name == "Nvidia Broadcast" || pkg.Name == "AMD Auto-Detect" || pkg.Name == "Intel DSA" {
pkgTimeout = 30 * time.Minute
} else if pkg.Name == "DaVinci Resolve" {
pkgTimeout = 15 * time.Minute
}
if IsSessionZero() && pkg.Class == ClassGUI {
if pkg.Name != "Nvidia Driver" && pkg.Name != "Nvidia Broadcast" && pkg.Name != "AMD Auto-Detect" && pkg.Name != "Intel DSA" {
pkgTimeout = 10 * time.Second
}
}
pkgCtx, pkgCancel := context.WithTimeout(ctx, pkgTimeout)
if isCustomPackage(pkg) {
var script string
switch pkg.Name {
case "FilePilot":
script = `$tempExe = Join-Path $env:TEMP 'FilePilotSetup.exe'; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; Invoke-WebRequest -Uri 'https://filepilot.tech/download/latest' -OutFile $tempExe -UseBasicParsing; Start-Process -FilePath $tempExe -ArgumentList '/S' -Wait`
case "Affinity":
script = `
$tempMsix = Join-Path $env:TEMP 'Affinity_x64.msix'
if (Test-Path $tempMsix) { Remove-Item -Force $tempMsix }
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Write-Host "Downloading Affinity MSIX..."
Invoke-WebRequest -Uri 'https://downloads.affinity.studio/Affinity%20x64.msix' -OutFile $tempMsix -UseBasicParsing -ErrorAction Stop
Write-Host "Installing Affinity MSIX package..."
Add-AppxProvisionedPackage -Online -PackagePath $tempMsix -SkipLicense
`
case "Tidal":
script = `
$tempExe = Join-Path $env:TEMP 'TIDALSetup.exe'
if (Test-Path $tempExe) { Remove-Item -Force $tempExe }
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Write-Host "Downloading Tidal setup..."
Invoke-WebRequest -Uri 'https://download.tidal.com/desktop/TIDALSetup.exe' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop
Write-Host "Installing Tidal silently..."
Start-Process -FilePath $tempExe -ArgumentList '-s' -Wait
`
case "WhatsApp":
script = `winget install --id WhatsApp.WhatsApp --silent --accept-package-agreements --accept-source-agreements`
case "DaVinci Resolve":
script = `winget install --id BlackmagicDesign.DaVinciResolve --silent --accept-package-agreements --accept-source-agreements`
case "Ardour":
script = `
$msysDir = "C:\msys64"
if (-not (Test-Path "$msysDir\usr\bin\bash.exe")) {
$msysDir = "C:\tools\msys64"
}
if (-not (Test-Path "$msysDir\usr\bin\bash.exe")) {
Write-Host "Installing MSYS2 via Chocolatey..."
choco install msys2 -y --no-progress
$msysDir = "C:\tools\msys64"
if (-not (Test-Path "$msysDir\usr\bin\bash.exe")) {
$msysDir = "C:\msys64"
}
}
$env:MSYSTEM="MINGW64"
$buildScript = @'
set -e
pacman -Sy --noconfirm
pacman -S --noconfirm --needed git unzip make cmake mingw-w64-x86_64-toolchain mingw-w64-x86_64-boost mingw-w64-x86_64-glib2 mingw-w64-x86_64-glibmm mingw-w64-x86_64-libsndfile mingw-w64-x86_64-curl mingw-w64-x86_64-libarchive mingw-w64-x86_64-liblo mingw-w64-x86_64-taglib mingw-w64-x86_64-vamp-plugin-sdk mingw-w64-x86_64-rubberband mingw-w64-x86_64-aubio mingw-w64-x86_64-cairomm mingw-w64-x86_64-pangomm mingw-w64-x86_64-jack2 mingw-w64-x86_64-serd mingw-w64-x86_64-sord mingw-w64-x86_64-sratom mingw-w64-x86_64-lilv mingw-w64-x86_64-libwebsockets mingw-w64-x86_64-libusb python3
if [ ! -d "/tmp/ardour" ]; then
git clone --depth 1 https://github.com/Ardour/ardour.git /tmp/ardour
fi
cd /tmp/ardour
python3 ./waf configure --prefix=/mingw64 --dist-target=mingw --optimize
python3 ./waf build
python3 ./waf install
'@
$scriptPath = "$msysDir\tmp\build_ardour.sh"
[System.IO.Directory]::CreateDirectory("$msysDir\tmp") | Out-Null
[System.IO.File]::WriteAllText($scriptPath, $buildScript)
& "$msysDir\usr\bin\bash.exe" --login /tmp/build_ardour.sh
`
case "Nvidia Driver":
model := pkg.ChocoParams
driverType := pkg.InstallerArgs
pfid, err := resolveNvidiaPFID(model)
if err != nil {
issues = append(issues, Issue{
Step: "resolve Nvidia model",
Err: err,
})
} else {
downloadURL, _, err := fetchNvidiaDriverURLFn(pfid, driverType)
if err != nil {
issues = append(issues, Issue{
Step: "fetch Nvidia driver URL",
Err: err,
})
} else {
script = fmt.Sprintf(`
$tempExe = Join-Path $env:TEMP 'NvidiaDriver.exe'
if (Test-Path $tempExe) { Remove-Item -Force $tempExe }
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Write-Host "Downloading Nvidia driver..."
Invoke-WebRequest -Uri '%s' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop
Write-Host "Installing Nvidia driver silently..."
$p = Start-Process -FilePath $tempExe -ArgumentList '-s' -Wait -PassThru
Remove-Item -Force $tempExe
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1) {
throw "Nvidia driver installation failed with exit code " + $p.ExitCode
}
`, downloadURL)
}
}
case "Nvidia Broadcast":
downloadURL := "https://international.download.nvidia.com/Windows/broadcast/2.2.0/NVIDIA_Broadcast_v2.2.0.52896077.exe"
script = fmt.Sprintf(`
$tempExe = Join-Path $env:TEMP 'NvidiaBroadcast.exe'
if (Test-Path $tempExe) { Remove-Item -Force $tempExe }
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Write-Host "Downloading Nvidia Broadcast..."
Invoke-WebRequest -Uri '%s' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop
Write-Host "Installing Nvidia Broadcast silently..."
$p = Start-Process -FilePath $tempExe -ArgumentList '/s' -Wait -PassThru
Remove-Item -Force $tempExe
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {
throw "Nvidia Broadcast installation failed with exit code " + $p.ExitCode
}
`, downloadURL)
case "AMD Auto-Detect":
downloadURL := "https://drivers.amd.com/drivers/installer/26.10/whql/amd-software-adrenalin-edition-26.6.1-minimalsetup-260601_web.exe"
script = fmt.Sprintf(`
$tempExe = Join-Path $env:TEMP 'AMDMinimalSetup.exe'
if (Test-Path $tempExe) { Remove-Item -Force $tempExe }
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Write-Host "Downloading AMD Auto-Detect tool..."
Invoke-WebRequest -Uri '%s' -OutFile $tempExe -UseBasicParsing -Headers @{"Referer" = "https://www.amd.com/"} -UserAgent "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" -ErrorAction Stop
Write-Host "Launching AMD Auto-Detect tool..."
$p = Start-Process -FilePath $tempExe -Wait -PassThru
Remove-Item -Force $tempExe
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {
throw "AMD Auto-Detect failed with exit code " + $p.ExitCode
}
`, downloadURL)
case "Intel DSA":
downloadURL := "https://dsadata.intel.com/installer"
script = fmt.Sprintf(`
$tempExe = Join-Path $env:TEMP 'IntelDSAInstaller.exe'
if (Test-Path $tempExe) { Remove-Item -Force $tempExe }
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Write-Host "Downloading Intel Driver & Support Assistant..."
Invoke-WebRequest -Uri '%s' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop
Write-Host "Installing Intel Driver & Support Assistant silently..."
$p = Start-Process -FilePath $tempExe -ArgumentList '-s', '-norestart' -Wait -PassThru
Remove-Item -Force $tempExe
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {
throw "Intel DSA installation failed with exit code " + $p.ExitCode
}
`, downloadURL)
}
if script != "" {
result := runner.Run(pkgCtx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
if result.Err != nil {
issues = append(issues, Issue{
Step: "install " + pkg.Name + " (custom download/install)",
Err: fmt.Errorf("%w: %s", result.Err, result.CombinedOutput()),
})
}
}
pkgCancel()
continue
}
prepared, err := PreparePackageForInstall(pkg)
if err != nil {
issues = append(issues, Issue{Step: "prepare " + pkg.Name, Err: err})
pkgCancel()
continue
}
result := runner.Run(pkgCtx, chocoCommand, ChocoInstallArgs(prepared)...)
if result.Err != nil {
isSuccess := false
var exitErr *exec.ExitError
if errors.As(result.Err, &exitErr) {
code := exitErr.ExitCode()
if code == 3010 || code == 1641 {
isSuccess = true
}
}
if !isSuccess {
out := result.CombinedOutput()
if len(out) > 500 {
out = out[:500] + "... (truncated)"
}
issues = append(issues, Issue{
Step: "install " + pkg.Name,
Err: fmt.Errorf("%w: %s", result.Err, out),
})
}
}
pkgCancel()
}
return issues
}
func PreparePackageForInstall(pkg Package) (Package, error) {
if pkg.ChocoConfigAsset == "" {
return pkg, nil
}
path, err := writeInstallAsset(pkg.ChocoConfigAsset)
if err != nil {
return pkg, err
}
pkg.InstallerArgs = strings.ReplaceAll(pkg.InstallerArgs, "{config}", `"`+path+`"`)
return pkg, nil
}
func writeInstallAsset(asset string) (string, error) {
data, err := managedAssets.ReadFile(asset)
if err != nil {
return "", err
}
root, err := os.UserCacheDir()
if err != nil || root == "" {
root = os.TempDir()
}
dir := filepath.Join(root, "bootstrap_windows_env")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
target := filepath.Join(dir, filepath.Base(asset))
if err := os.WriteFile(target, data, 0o644); err != nil {
return "", err
}
return target, nil
}
func isCustomPackage(pkg Package) bool {
return pkg.ChocoID == "" && (pkg.Name == "FilePilot" || pkg.Name == "Affinity" || pkg.Name == "Tidal" || pkg.Name == "DaVinci Resolve" || pkg.Name == "Ardour" || pkg.Name == "WhatsApp" || pkg.Name == "Nvidia Driver" || pkg.Name == "Nvidia Broadcast" || pkg.Name == "AMD Auto-Detect" || pkg.Name == "Intel DSA")
}
var (
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
procProcessIdToSessionId = modkernel32.NewProc("ProcessIdToSessionId")
procGetCurrentProcessId = modkernel32.NewProc("GetCurrentProcessId")
)
func IsSessionZero() bool {
pid, _, _ := procGetCurrentProcessId.Call()
var sessionID uint32
r, _, _ := procProcessIdToSessionId.Call(pid, uintptr(unsafe.Pointer(&sessionID)))
if r == 0 {
return false
}
return sessionID == 0
}
+390
View File
@@ -0,0 +1,390 @@
package bootstrap
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func init() {
chocoCommand = "choco"
}
func TestChocoInstallArgsUseExactNonInteractiveFlags(t *testing.T) {
pkg := Package{Name: "Git", ChocoID: "git"}
want := []string{"install", "git", "-y", "--no-progress"}
if got := ChocoInstallArgs(pkg); !reflect.DeepEqual(got, want) {
t.Fatalf("args = %#v, want %#v", got, want)
}
}
func TestPreparePackageForInstallWritesVisualStudioConfigAndOverride(t *testing.T) {
pkg := Package{
Name: "Visual Studio Community 2026",
ChocoID: "visualstudio2022community",
InstallerArgs: "--passive --config {config}",
ChocoConfigAsset: "assets/visual-studio-community.vsconfig",
}
prepared, err := PreparePackageForInstall(pkg)
if err != nil {
t.Fatal(err)
}
if strings.Contains(prepared.InstallerArgs, "{config}") {
t.Fatalf("override still has placeholder: %q", prepared.InstallerArgs)
}
args := ChocoInstallArgs(prepared)
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--install-arguments") || !strings.Contains(joined, "visual-studio-community.vsconfig") {
t.Fatalf("choco args missing VS config override: %#v", args)
}
}
func TestPreparePackageForInstallHandlesNoAssetAndMissingAsset(t *testing.T) {
pkg := Package{Name: "Git", ChocoID: "git"}
prepared, err := PreparePackageForInstall(pkg)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(prepared, pkg) {
t.Fatalf("prepared = %#v, want unchanged package", prepared)
}
_, err = PreparePackageForInstall(Package{Name: "broken", ChocoConfigAsset: "assets/missing.vsconfig"})
if err == nil {
t.Fatal("expected missing asset error")
}
}
func TestChocoInstalledStateParsing(t *testing.T) {
pkg := Package{ChocoID: "git"}
if !ChocoOutputShowsInstalled(pkg, "Chocolatey v1.2.1\ngit 2.40.1\n1 packages installed.") {
t.Fatal("expected installed output to be detected")
}
if ChocoOutputShowsInstalled(pkg, "Chocolatey v1.2.1\n0 packages installed.") {
t.Fatal("expected missing package output to be false")
}
}
func TestCheckPackagesKeepsStableOrderAndAggregatesErrors(t *testing.T) {
a := Package{Name: "A", ChocoID: "a"}
b := Package{Name: "B", ChocoID: "b"}
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "Test-Path C:\\ProgramData\\chocolatey\\lib\\a"): {Stdout: "True\n"},
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "Test-Path C:\\ProgramData\\chocolatey\\lib\\b"): {Err: errors.New("boom"), Stderr: "source unavailable"},
}}
states := CheckPackages(context.Background(), runner, []Package{a, b}, 2)
if len(states) != 2 || states[0].Package.Name != "A" || states[1].Package.Name != "B" {
t.Fatalf("states not in input order: %#v", states)
}
if !states[0].Installed {
t.Fatal("first package should be installed")
}
if states[1].CheckErr == nil {
t.Fatal("second package should keep check error")
}
}
func TestCheckPackagesUsesSingleWorkerMinimumAndMissingPackageIsPending(t *testing.T) {
pkg := Package{Name: "Git", ChocoID: "git"}
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "Test-Path C:\\ProgramData\\chocolatey\\lib\\git"): {Stdout: "False\n"},
}}
states := CheckPackages(context.Background(), runner, []Package{pkg}, 0)
if len(states) != 1 {
t.Fatalf("states = %d, want 1", len(states))
}
if states[0].Installed || states[0].CheckErr != nil {
t.Fatalf("state = %#v, want pending without check error", states[0])
}
}
func TestInstallPackagesContinuesAfterFailure(t *testing.T) {
a := Package{Name: "A", ChocoID: "a"}
b := Package{Name: "B", ChocoID: "b"}
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("choco", ChocoInstallArgs(a)...): {Err: errors.New("boom"), Stderr: "failed"},
commandKey("choco", ChocoInstallArgs(b)...): {},
}, defaultErr: true}
issues := InstallPackages(context.Background(), runner, []Package{a, b})
if len(issues) != 1 {
t.Fatalf("issues = %d, want 1", len(issues))
}
if !runner.called("choco", ChocoInstallArgs(b)...) {
t.Fatal("second package should still be attempted after first failure")
}
}
func TestInstallPackagesReportsPrepareFailure(t *testing.T) {
issues := InstallPackages(context.Background(), &fakeRunner{defaultErr: true}, []Package{{
Name: "broken",
ChocoConfigAsset: "assets/missing.vsconfig",
}})
if len(issues) != 1 || !strings.Contains(issues[0].Step, "prepare broken") {
t.Fatalf("issues = %#v, want prepare failure", issues)
}
}
func TestWriteInstallAssetReportsCacheDirectoryError(t *testing.T) {
temp := t.TempDir()
cacheFile := filepath.Join(temp, "cache-file")
if err := os.WriteFile(cacheFile, []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("LOCALAPPDATA", cacheFile)
t.Setenv("LocalAppData", cacheFile)
if _, err := writeInstallAsset("assets/visual-studio-community.vsconfig"); err == nil {
t.Fatal("expected cache directory error")
}
}
func TestWriteInstallAssetFallsBackToTempDirWhenUserCacheIsUnavailable(t *testing.T) {
t.Setenv("LOCALAPPDATA", "")
t.Setenv("LocalAppData", "")
path, err := writeInstallAsset("assets/visual-studio-community.vsconfig")
if err != nil {
t.Fatal(err)
}
if filepath.Base(path) != "visual-studio-community.vsconfig" {
t.Fatalf("path = %q", path)
}
}
func TestWriteInstallAssetReportsWriteError(t *testing.T) {
cache := t.TempDir()
targetDir := filepath.Join(cache, "bootstrap_windows_env", "visual-studio-community.vsconfig")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("LOCALAPPDATA", cache)
t.Setenv("LocalAppData", cache)
if _, err := writeInstallAsset("assets/visual-studio-community.vsconfig"); err == nil {
t.Fatal("expected write error when target path is a directory")
}
}
func TestCheckPackagesNvidiaDriver(t *testing.T) {
// Backup original functions and defer restore
oldFetch := fetchNvidiaDriverURLFn
oldGetInstalled := getInstalledNvidiaVersionFn
defer func() {
fetchNvidiaDriverURLFn = oldFetch
getInstalledNvidiaVersionFn = oldGetInstalled
}()
// Mock fetchNvidiaDriverURLFn to return a version
fetchNvidiaDriverURLFn = func(pfid string, driverType string) (string, string, error) {
if pfid != "995" {
t.Errorf("expected pfid 995, got %s", pfid)
}
if driverType != "Studio" {
t.Errorf("expected driverType Studio, got %s", driverType)
}
return "https://uk.download.nvidia.com/Windows/610.47/610.47-desktop-win10-win11-64bit-international-nsd-dch-whql.exe", "610.47", nil
}
// 1. Test case: Installed version matches API version -> should report installed = true
getInstalledNvidiaVersionFn = func(ctx context.Context, runner Runner) (string, error) {
return "610.47", nil
}
pkg := Package{
Name: "Nvidia Driver",
ChocoParams: "RTX 4090",
InstallerArgs: "Studio",
}
runner := &fakeRunner{}
states := CheckPackages(context.Background(), runner, []Package{pkg}, 1)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if !states[0].Installed {
t.Error("expected Nvidia Driver to be reported as installed")
}
if states[0].CheckErr != nil {
t.Errorf("expected no check error, got %v", states[0].CheckErr)
}
// 2. Test case: Installed version does not match API version -> should report installed = false
getInstalledNvidiaVersionFn = func(ctx context.Context, runner Runner) (string, error) {
return "596.36", nil
}
states = CheckPackages(context.Background(), runner, []Package{pkg}, 1)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if states[0].Installed {
t.Error("expected Nvidia Driver to be reported as not installed (pending)")
}
if states[0].CheckErr != nil {
t.Errorf("expected no check error, got %v", states[0].CheckErr)
}
}
func TestInstallPackagesNvidiaDriver(t *testing.T) {
// Backup original functions and defer restore
oldFetch := fetchNvidiaDriverURLFn
defer func() {
fetchNvidiaDriverURLFn = oldFetch
}()
fetchNvidiaDriverURLFn = func(pfid string, driverType string) (string, string, error) {
return "https://mock.nvidia.download/driver.exe", "610.47", nil
}
pkg := Package{
Name: "Nvidia Driver",
ChocoParams: "RTX 4090",
InstallerArgs: "Studio",
}
runner := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "\n$tempExe = Join-Path $env:TEMP 'NvidiaDriver.exe'\nif (Test-Path $tempExe) { Remove-Item -Force $tempExe }\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\nWrite-Host \"Downloading Nvidia driver...\"\nInvoke-WebRequest -Uri 'https://mock.nvidia.download/driver.exe' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop\nWrite-Host \"Installing Nvidia driver silently...\"\n$p = Start-Process -FilePath $tempExe -ArgumentList '-s' -Wait -PassThru\nRemove-Item -Force $tempExe\nif ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1) {\n throw \"Nvidia driver installation failed with exit code \" + $p.ExitCode\n}\n"): {},
},
}
issues := InstallPackages(context.Background(), runner, []Package{pkg})
if len(issues) != 0 {
t.Fatalf("expected no issues, got: %v", issues)
}
if !runner.called("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "\n$tempExe = Join-Path $env:TEMP 'NvidiaDriver.exe'\nif (Test-Path $tempExe) { Remove-Item -Force $tempExe }\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\nWrite-Host \"Downloading Nvidia driver...\"\nInvoke-WebRequest -Uri 'https://mock.nvidia.download/driver.exe' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop\nWrite-Host \"Installing Nvidia driver silently...\"\n$p = Start-Process -FilePath $tempExe -ArgumentList '-s' -Wait -PassThru\nRemove-Item -Force $tempExe\nif ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1) {\n throw \"Nvidia driver installation failed with exit code \" + $p.ExitCode\n}\n") {
t.Error("expected Nvidia installer script to be executed")
}
}
func TestCheckPackagesNvidiaBroadcast(t *testing.T) {
pkg := Package{
Name: "Nvidia Broadcast",
}
runner := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", `if (Test-Path "${env:ProgramFiles}\NVIDIA Corporation\NVIDIA Broadcast\NVIDIA Broadcast.exe") { 'yes' } else { 'no' }`): {
Stdout: "yes\n",
},
},
}
states := CheckPackages(context.Background(), runner, []Package{pkg}, 1)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if !states[0].Installed {
t.Error("expected Nvidia Broadcast to be reported as installed")
}
}
func TestInstallPackagesNvidiaBroadcast(t *testing.T) {
pkg := Package{
Name: "Nvidia Broadcast",
}
runner := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "\n$tempExe = Join-Path $env:TEMP 'NvidiaBroadcast.exe'\nif (Test-Path $tempExe) { Remove-Item -Force $tempExe }\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\nWrite-Host \"Downloading Nvidia Broadcast...\"\nInvoke-WebRequest -Uri 'https://international.download.nvidia.com/Windows/broadcast/2.2.0/NVIDIA_Broadcast_v2.2.0.52896077.exe' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop\nWrite-Host \"Installing Nvidia Broadcast silently...\"\n$p = Start-Process -FilePath $tempExe -ArgumentList '/s' -Wait -PassThru\nRemove-Item -Force $tempExe\nif ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {\n throw \"Nvidia Broadcast installation failed with exit code \" + $p.ExitCode\n}\n"): {},
},
}
issues := InstallPackages(context.Background(), runner, []Package{pkg})
if len(issues) != 0 {
t.Fatalf("expected no issues, got: %v", issues)
}
if !runner.called("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "\n$tempExe = Join-Path $env:TEMP 'NvidiaBroadcast.exe'\nif (Test-Path $tempExe) { Remove-Item -Force $tempExe }\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\nWrite-Host \"Downloading Nvidia Broadcast...\"\nInvoke-WebRequest -Uri 'https://international.download.nvidia.com/Windows/broadcast/2.2.0/NVIDIA_Broadcast_v2.2.0.52896077.exe' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop\nWrite-Host \"Installing Nvidia Broadcast silently...\"\n$p = Start-Process -FilePath $tempExe -ArgumentList '/s' -Wait -PassThru\nRemove-Item -Force $tempExe\nif ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {\n throw \"Nvidia Broadcast installation failed with exit code \" + $p.ExitCode\n}\n") {
t.Error("expected Nvidia Broadcast installer script to be executed")
}
}
func TestCheckPackagesAmdAutoDetect(t *testing.T) {
pkg := Package{
Name: "AMD Auto-Detect",
}
runner := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", `if (Test-Path "${env:ProgramFiles}\AMD\CNext\CNext\RadeonSoftware.exe" -or Test-Path "${env:ProgramFiles}\AMD\CNext\CNext\AMDRSServ.exe") { 'yes' } else { 'no' }`): {
Stdout: "yes\n",
},
},
}
states := CheckPackages(context.Background(), runner, []Package{pkg}, 1)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if !states[0].Installed {
t.Error("expected AMD Auto-Detect to be reported as installed")
}
}
func TestInstallPackagesAmdAutoDetect(t *testing.T) {
pkg := Package{
Name: "AMD Auto-Detect",
}
runner := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "\n$tempExe = Join-Path $env:TEMP 'AMDMinimalSetup.exe'\nif (Test-Path $tempExe) { Remove-Item -Force $tempExe }\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\nWrite-Host \"Downloading AMD Auto-Detect tool...\"\nInvoke-WebRequest -Uri 'https://drivers.amd.com/drivers/installer/26.10/whql/amd-software-adrenalin-edition-26.6.1-minimalsetup-260601_web.exe' -OutFile $tempExe -UseBasicParsing -Headers @{\"Referer\" = \"https://www.amd.com/\"} -UserAgent \"Mozilla/5.0 (Windows NT 10.0; Win64; x64)\" -ErrorAction Stop\nWrite-Host \"Launching AMD Auto-Detect tool...\"\n$p = Start-Process -FilePath $tempExe -Wait -PassThru\nRemove-Item -Force $tempExe\nif ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {\n throw \"AMD Auto-Detect failed with exit code \" + $p.ExitCode\n}\n"): {},
},
}
issues := InstallPackages(context.Background(), runner, []Package{pkg})
if len(issues) != 0 {
t.Fatalf("expected no issues, got: %v", issues)
}
if !runner.called("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "\n$tempExe = Join-Path $env:TEMP 'AMDMinimalSetup.exe'\nif (Test-Path $tempExe) { Remove-Item -Force $tempExe }\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\nWrite-Host \"Downloading AMD Auto-Detect tool...\"\nInvoke-WebRequest -Uri 'https://drivers.amd.com/drivers/installer/26.10/whql/amd-software-adrenalin-edition-26.6.1-minimalsetup-260601_web.exe' -OutFile $tempExe -UseBasicParsing -Headers @{\"Referer\" = \"https://www.amd.com/\"} -UserAgent \"Mozilla/5.0 (Windows NT 10.0; Win64; x64)\" -ErrorAction Stop\nWrite-Host \"Launching AMD Auto-Detect tool...\"\n$p = Start-Process -FilePath $tempExe -Wait -PassThru\nRemove-Item -Force $tempExe\nif ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {\n throw \"AMD Auto-Detect failed with exit code \" + $p.ExitCode\n}\n") {
t.Error("expected AMD Auto-Detect installer script to be executed")
}
}
func TestCheckPackagesIntelDSA(t *testing.T) {
pkg := Package{
Name: "Intel DSA",
}
runner := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", `if (Test-Path "${env:ProgramFiles(x86)}\Intel\Driver and Support Assistant\DSAService.exe") { 'yes' } else { 'no' }`): {
Stdout: "yes\n",
},
},
}
states := CheckPackages(context.Background(), runner, []Package{pkg}, 1)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if !states[0].Installed {
t.Error("expected Intel DSA to be reported as installed")
}
}
func TestInstallPackagesIntelDSA(t *testing.T) {
pkg := Package{
Name: "Intel DSA",
}
runner := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "\n$tempExe = Join-Path $env:TEMP 'IntelDSAInstaller.exe'\nif (Test-Path $tempExe) { Remove-Item -Force $tempExe }\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\nWrite-Host \"Downloading Intel Driver & Support Assistant...\"\nInvoke-WebRequest -Uri 'https://dsadata.intel.com/installer' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop\nWrite-Host \"Installing Intel Driver & Support Assistant silently...\"\n$p = Start-Process -FilePath $tempExe -ArgumentList '-s', '-norestart' -Wait -PassThru\nRemove-Item -Force $tempExe\nif ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {\n throw \"Intel DSA installation failed with exit code \" + $p.ExitCode\n}\n"): {},
},
}
issues := InstallPackages(context.Background(), runner, []Package{pkg})
if len(issues) != 0 {
t.Fatalf("expected no issues, got: %v", issues)
}
if !runner.called("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "\n$tempExe = Join-Path $env:TEMP 'IntelDSAInstaller.exe'\nif (Test-Path $tempExe) { Remove-Item -Force $tempExe }\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\nWrite-Host \"Downloading Intel Driver & Support Assistant...\"\nInvoke-WebRequest -Uri 'https://dsadata.intel.com/installer' -OutFile $tempExe -UseBasicParsing -ErrorAction Stop\nWrite-Host \"Installing Intel Driver & Support Assistant silently...\"\n$p = Start-Process -FilePath $tempExe -ArgumentList '-s', '-norestart' -Wait -PassThru\nRemove-Item -Force $tempExe\nif ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1 -and $p.ExitCode -ne 3010) {\n throw \"Intel DSA installation failed with exit code \" + $p.ExitCode\n}\n") {
t.Error("expected Intel DSA installer script to be executed")
}
}
+17 -3
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"strings"
"time"
@@ -26,6 +27,7 @@ type Runner interface {
type ExecRunner struct {
Timeout time.Duration
Stream io.Writer
}
func (r ExecRunner) Run(ctx context.Context, name string, args ...string) CommandResult {
@@ -37,11 +39,23 @@ func (r ExecRunner) Run(ctx context.Context, name string, args ...string) Comman
defer cancel()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
_ = exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", cmd.Process.Pid)).Run()
return cmd.Process.Kill()
}
if r.Stream != nil {
cmd.Stdout = io.MultiWriter(r.Stream, &stdout)
cmd.Stderr = io.MultiWriter(r.Stream, &stderr)
} else {
cmd.Stdout = &stdout
cmd.Stderr = &stderr
}
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
err = fmt.Errorf("%s timed out after %s", name, timeout)
err = fmt.Errorf("%s timed out", name)
}
return CommandResult{
Command: name + " " + strings.Join(args, " "),
+8 -1
View File
@@ -2,7 +2,9 @@ package bootstrap
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
@@ -44,7 +46,12 @@ func EnableHyperV(ctx context.Context, runner Runner, status HyperVStatus) (stri
}
result := runner.Run(ctx, "dism.exe", "/Online", "/Enable-Feature", "/FeatureName:Microsoft-Hyper-V-All", "/All", "/NoRestart")
if result.Err != nil {
return "", fmt.Errorf("enable Hyper-V: %w: %s", result.Err, result.CombinedOutput())
var exitErr *exec.ExitError
if errors.As(result.Err, &exitErr) && exitErr.ExitCode() == 3010 {
// 3010 is ERROR_SUCCESS_REBOOT_REQUIRED
} else {
return "", fmt.Errorf("enable Hyper-V: %w: %s", result.Err, result.CombinedOutput())
}
}
return "Hyper-V features were enabled; restart Windows and rerun to complete Vagrant provider setup.", nil
}
+132
View File
@@ -0,0 +1,132 @@
package bootstrap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
var (
fetchNvidiaDriverURLFn = fetchNvidiaDriverURL
getInstalledNvidiaVersionFn = getInstalledNvidiaVersion
)
type AjaxDriverResponse struct {
IDS []struct {
DownloadInfo struct {
DownloadURL string `json:"DownloadURL"`
Version string `json:"Version"`
} `json:"downloadInfo"`
} `json:"IDS"`
}
// resolveNvidiaPFID looks up the GPU model in the embedded gpu-data.json and returns its pfid.
func resolveNvidiaPFID(model string) (string, error) {
data, err := managedAssets.ReadFile("assets/gpu-data.json")
if err != nil {
return "", fmt.Errorf("read embedded gpu-data.json: %w", err)
}
var gpuData struct {
Desktop map[string]string `json:"desktop"`
Notebook map[string]string `json:"notebook"`
}
if err := json.Unmarshal(data, &gpuData); err != nil {
return "", fmt.Errorf("unmarshal gpu-data.json: %w", err)
}
normalized := strings.TrimSpace(model)
if !strings.HasPrefix(strings.ToLower(normalized), "geforce") {
normalized = "GeForce " + normalized
}
for k, v := range gpuData.Desktop {
if strings.EqualFold(k, normalized) {
return v, nil
}
}
for k, v := range gpuData.Notebook {
if strings.EqualFold(k, normalized) {
return v, nil
}
}
return "", fmt.Errorf("Nvidia model %q not found in database", model)
}
// fetchNvidiaDriverURL queries the Nvidia driver lookup API and returns the download URL and version.
func fetchNvidiaDriverURL(pfid string, driverType string) (string, string, error) {
// driverType is either "Game Ready" (upCRD=0) or "Studio" (upCRD=1)
upCRD := "0"
if driverType == "Studio" {
upCRD = "1"
}
apiURL := fmt.Sprintf("https://gfwsl.geforce.com/services_toolkit/services/com/nvidia/services/AjaxDriverService.php?func=DriverManualLookup&pfid=%s&osID=57&upCRD=%s&dch=1", pfid, upCRD)
resp, err := http.Get(apiURL)
if err != nil {
return "", "", fmt.Errorf("http get driver info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("driver info request returned status %d", resp.StatusCode)
}
var data AjaxDriverResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", "", fmt.Errorf("decode driver info: %w", err)
}
if len(data.IDS) == 0 {
return "", "", fmt.Errorf("no drivers found for pfid %s", pfid)
}
downloadURL := data.IDS[0].DownloadInfo.DownloadURL
version := data.IDS[0].DownloadInfo.Version
if downloadURL == "" {
return "", "", fmt.Errorf("download URL not found in driver info response")
}
return downloadURL, version, nil
}
// getInstalledNvidiaVersion queries the current system's Nvidia driver version.
func getInstalledNvidiaVersion(ctx context.Context, runner Runner) (string, error) {
cmd := "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -like '*Nvidia*' } | Select-Object -ExpandProperty DriverVersion"
result := runner.Run(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd)
if result.Err != nil {
return "", result.Err
}
output := strings.TrimSpace(result.Stdout)
if output == "" {
return "", nil // No Nvidia GPU or driver installed
}
lines := strings.Split(output, "\n")
driverVer := strings.TrimSpace(lines[0])
if driverVer == "" {
return "", nil
}
parts := strings.Split(driverVer, ".")
if len(parts) < 2 {
return "", fmt.Errorf("unexpected driver version format: %s", driverVer)
}
combined := parts[len(parts)-2] + parts[len(parts)-1]
if len(combined) < 5 {
return "", fmt.Errorf("unexpected combined driver version suffix: %s", combined)
}
suffix5 := combined[len(combined)-5:]
nvidiaVer := fmt.Sprintf("%s.%s", suffix5[:3], suffix5[3:])
nvidiaVer = strings.TrimPrefix(nvidiaVer, "0")
return nvidiaVer, nil
}
// isRTX2050OrNewer returns true if the GPU model is an RTX 2050 or newer.
// Since RTX started with Turing architecture (20-series) and RTX 2050 is the lowest number in the RTX branding,
// containing "RTX" (case-insensitive) is a robust and future-proof check.
func isRTX2050OrNewer(model string) bool {
return strings.Contains(strings.ToUpper(model), "RTX")
}
+98
View File
@@ -0,0 +1,98 @@
package bootstrap
import (
"context"
"strings"
"testing"
)
func TestResolveNvidiaPFID(t *testing.T) {
tests := []struct {
model string
wantPFID string
wantErr bool
}{
{"RTX 4090", "995", false},
{"GeForce RTX 4090", "995", false},
{"rtx 4090", "995", false},
{"RTX 4090 D", "1036", false},
{"RTX 4090 Laptop GPU", "1004", false},
{"NonExistentGPU", "", true},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
got, err := resolveNvidiaPFID(tt.model)
if (err != nil) != tt.wantErr {
t.Errorf("resolveNvidiaPFID(%q) error = %v, wantErr %v", tt.model, err, tt.wantErr)
return
}
if got != tt.wantPFID {
t.Errorf("resolveNvidiaPFID(%q) = %q, want %q", tt.model, got, tt.wantPFID)
}
})
}
}
func TestFetchNvidiaDriverURL(t *testing.T) {
// Query API for RTX 4090 Game Ready
url, version, err := fetchNvidiaDriverURL("995", "Game Ready")
if err != nil {
t.Skipf("Skipping API test (possibly offline or rate limited): %v", err)
return
}
if !strings.HasPrefix(url, "https://") {
t.Errorf("expected download URL to start with https://, got %q", url)
}
if version == "" {
t.Error("expected version to be non-empty")
}
// Query API for RTX 4090 Studio
urlStudio, versionStudio, err := fetchNvidiaDriverURL("995", "Studio")
if err != nil {
t.Skipf("Skipping API test: %v", err)
return
}
if !strings.HasPrefix(urlStudio, "https://") {
t.Errorf("expected studio download URL to start with https://, got %q", urlStudio)
}
if versionStudio == "" {
t.Error("expected studio version to be non-empty")
}
}
func TestGetInstalledNvidiaVersion(t *testing.T) {
// Test with a mock runner that returns a valid driver version format
mockRunner := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -like '*Nvidia*' } | Select-Object -ExpandProperty DriverVersion"): {
Stdout: "32.0.15.9636\n",
},
},
}
ver, err := getInstalledNvidiaVersion(context.Background(), mockRunner)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ver != "596.36" {
t.Errorf("getInstalledNvidiaVersion = %q, want %q", ver, "596.36")
}
// Test case with leading zero representation
mockRunner2 := &fakeRunner{
responses: map[string]CommandResult{
commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -like '*Nvidia*' } | Select-Object -ExpandProperty DriverVersion"): {
Stdout: "32.0.10.6104\n",
},
},
}
ver2, err := getInstalledNvidiaVersion(context.Background(), mockRunner2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ver2 != "61.04" {
t.Errorf("getInstalledNvidiaVersion = %q, want %q", ver2, "61.04")
}
}
+26 -4
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"io"
"os"
)
// Phase identifies a resumable unit of Windows provisioning work.
@@ -25,8 +26,12 @@ type Options struct {
NoWSL bool
Yes bool
DryRun bool
Headless bool
LinuxReleaseRepo string
Nvidia bool
NvidiaModel string
NvidiaType string
Amd bool
Intel bool
}
func ParseOptions(args []string, stderr io.Writer) (Options, error) {
@@ -39,8 +44,12 @@ func ParseOptions(args []string, stderr io.Writer) (Options, error) {
fs.BoolVar(&opts.NoWSL, "no-wsl", false, "omit Fedora WSL provisioning and guest bootstrap")
fs.BoolVar(&opts.Yes, "yes", false, "execute the displayed plan without confirmation")
fs.BoolVar(&opts.DryRun, "dry-run", false, "detect state and display work without making changes")
fs.BoolVar(&opts.Headless, "headless", false, "disable interactive login offers and require --yes to execute")
fs.StringVar(&opts.LinuxReleaseRepo, "linux-release-repo", "JMR-dev/bootstrap_dev_env", "GitHub repository containing Linux bootstrap releases")
fs.BoolVar(&opts.Nvidia, "nvidia", false, "fetch and install Nvidia driver")
fs.StringVar(&opts.NvidiaModel, "model", "", "Nvidia GPU model (e.g. RTX 4090)")
fs.StringVar(&opts.NvidiaType, "type", "", "Nvidia driver type (Game Ready or Studio)")
fs.BoolVar(&opts.Amd, "amd", false, "fetch and run AMD auto-detection graphics driver tool")
fs.BoolVar(&opts.Intel, "intel", false, "fetch and install Intel Driver & Support Assistant")
if err := fs.Parse(args); err != nil {
return Options{}, err
}
@@ -58,8 +67,21 @@ func ParseOptions(args []string, stderr io.Writer) (Options, error) {
if opts.NoWSL && opts.Only == PhaseWSL {
return Options{}, fmt.Errorf("--only wsl cannot be combined with --no-wsl")
}
if opts.Headless && !opts.Yes && !opts.DryRun {
return Options{}, fmt.Errorf("--headless execution requires --yes or --dry-run")
if opts.Nvidia && opts.Amd && opts.Intel && os.Getenv("BOOTSTRAP_INTEGRATION_TEST") != "true" {
return Options{}, fmt.Errorf("cannot combine --nvidia, --amd, and --intel")
}
if opts.Nvidia {
if opts.NvidiaModel == "" {
return Options{}, fmt.Errorf("--nvidia requires --model flag")
}
if opts.NvidiaType == "" {
return Options{}, fmt.Errorf("--nvidia requires --type flag")
}
if opts.NvidiaType != "Game Ready" && opts.NvidiaType != "Studio" {
return Options{}, fmt.Errorf("invalid --type value %q: use Game Ready or Studio", opts.NvidiaType)
}
} else if opts.NvidiaModel != "" || opts.NvidiaType != "" {
return Options{}, fmt.Errorf("--model and --type require --nvidia flag")
}
return opts, nil
}
+78 -7
View File
@@ -38,9 +38,6 @@ func TestInvalidOptionCombinations(t *testing.T) {
if _, err := ParseOptions([]string{"--only", "wsl", "--no-wsl"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected --only wsl --no-wsl to fail")
}
if _, err := ParseOptions([]string{"--headless"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected --headless without --yes or --dry-run to fail")
}
if _, err := ParseOptions([]string{"--only", "bogus"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected invalid --only value to fail")
}
@@ -52,12 +49,86 @@ func TestInvalidOptionCombinations(t *testing.T) {
}
}
func TestParseOptionsAcceptsHeadlessWithDryRunAndCustomRepo(t *testing.T) {
opts, err := ParseOptions([]string{"--headless", "--dry-run", "--linux-release-repo", "owner/repo"}, &bytes.Buffer{})
func TestNvidiaOptions(t *testing.T) {
// Valid combinations
opts, err := ParseOptions([]string{"--nvidia", "--model", "RTX 4090", "--type", "Studio"}, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if !opts.Headless || !opts.DryRun || opts.LinuxReleaseRepo != "owner/repo" {
t.Fatalf("opts = %#v", opts)
if !opts.Nvidia || opts.NvidiaModel != "RTX 4090" || opts.NvidiaType != "Studio" {
t.Fatal("Nvidia options not parsed correctly")
}
opts, err = ParseOptions([]string{"--nvidia", "--model", "RTX 4090", "--type", "Game Ready"}, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if !opts.Nvidia || opts.NvidiaModel != "RTX 4090" || opts.NvidiaType != "Game Ready" {
t.Fatal("Nvidia options not parsed correctly")
}
// Missing model
if _, err := ParseOptions([]string{"--nvidia", "--type", "Studio"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected error when model is missing")
}
// Missing type
if _, err := ParseOptions([]string{"--nvidia", "--model", "RTX 4090"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected error when type is missing")
}
// Invalid type
if _, err := ParseOptions([]string{"--nvidia", "--model", "RTX 4090", "--type", "Other"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected error with invalid type")
}
// Subflags passed without --nvidia
if _, err := ParseOptions([]string{"--model", "RTX 4090"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected error when --model passed without --nvidia")
}
if _, err := ParseOptions([]string{"--type", "Studio"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected error when --type passed without --nvidia")
}
// AMD options
opts, err = ParseOptions([]string{"--amd"}, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if !opts.Amd {
t.Fatal("AMD option not parsed correctly")
}
// AMD and Nvidia together (should succeed)
opts, err = ParseOptions([]string{"--nvidia", "--model", "RTX 4090", "--type", "Studio", "--amd"}, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if !opts.Nvidia || opts.NvidiaModel != "RTX 4090" || opts.NvidiaType != "Studio" || !opts.Amd {
t.Fatal("AMD and Nvidia options combined not parsed correctly")
}
// AMD, Nvidia and Intel conflict (all three should fail by default)
if _, err := ParseOptions([]string{"--nvidia", "--model", "RTX 4090", "--type", "Studio", "--amd", "--intel"}, &bytes.Buffer{}); err == nil {
t.Fatal("expected error when --nvidia, --amd, and --intel are all passed")
}
// AMD, Nvidia and Intel combined with BOOTSTRAP_INTEGRATION_TEST=true (should succeed)
t.Setenv("BOOTSTRAP_INTEGRATION_TEST", "true")
opts, err = ParseOptions([]string{"--nvidia", "--model", "RTX 4090", "--type", "Studio", "--amd", "--intel"}, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if !opts.Nvidia || !opts.Amd || !opts.Intel {
t.Fatal("All three options combined not parsed correctly under integration test context")
}
// Intel options
opts, err = ParseOptions([]string{"--intel"}, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if !opts.Intel {
t.Fatal("Intel option not parsed correctly")
}
}
+5 -6
View File
@@ -61,6 +61,9 @@ func RunOSPhase(ctx context.Context, runner Runner) OSPhaseResult {
func runOSCommand(ctx context.Context, runner Runner, step string, cmd CommandSpec) Issue {
res := runner.Run(ctx, cmd.Name, cmd.Args...)
if res.Err != nil {
if step == "run Chris Titus Tech WinUtil" && strings.Contains(res.Err.Error(), "exit status 1") {
return Issue{}
}
return Issue{Step: step, Err: fmt.Errorf("%w: %s", res.Err, res.CombinedOutput())}
}
return Issue{}
@@ -149,7 +152,7 @@ func WinUtilCommand() CommandSpec {
config := mustInstallAssetPath("assets/winutil-sane-default.json")
return powerShell(`$ErrorActionPreference='Stop'
$config = '` + psSingleQuote(config) + `'
& ([ScriptBlock]::Create((irm 'https://christitus.com/win'))) -Config $config -Run`)
& ([ScriptBlock]::Create((irm 'https://christitus.com/win'))) -Config $config -Noui`)
}
func PowerPolicyCommand() CommandSpec {
@@ -304,13 +307,9 @@ Write-Output 'Imported Vivaldi default browser associations. Windows may still r
func StoreUpdateCommand() CommandSpec {
return powerShell(`$ErrorActionPreference='Stop'
winget source update
if ($LASTEXITCODE -ne 0) {
throw "winget source update failed with exit code $LASTEXITCODE"
}
winget upgrade --all --source msstore --accept-source-agreements --accept-package-agreements --disable-interactivity
if ($LASTEXITCODE -ne 0) {
throw "winget Store app upgrade failed with exit code $LASTEXITCODE"
Write-Output "Warning: winget Store app upgrade returned non-zero exit code: $LASTEXITCODE"
}
try {
$mgr = Get-CimInstance -Namespace 'Root\cimv2\mdm\dmmap' -ClassName 'MDM_EnterpriseModernAppManagement_AppManagement01' -ErrorAction Stop
+1 -1
View File
@@ -10,7 +10,7 @@ import (
func TestWinUtilCommandUsesManagedConfigAndRunFlag(t *testing.T) {
cmd := WinUtilCommand()
joined := strings.Join(cmd.Args, " ")
for _, want := range []string{"christitus.com/win", "-Config", "winutil-sane-default.json", "-Run"} {
for _, want := range []string{"christitus.com/win", "-Config", "winutil-sane-default.json", "-Noui"} {
if !strings.Contains(joined, want) {
t.Fatalf("WinUtil command missing %q: %s", want, joined)
}
+1 -2
View File
@@ -66,13 +66,12 @@ func WriteRunLog(paths UserPaths, report *RunReport) (string, error) {
fmt.Fprintf(&b, "duration: %s\n", report.Finished.Sub(report.Started).Round(time.Second))
}
fmt.Fprintf(&b, "phases: %s\n", phasesForLog(report.Phases))
fmt.Fprintf(&b, "options: only=%s no-ai=%t no-wsl=%t yes=%t dry-run=%t headless=%t linux-release-repo=%s\n",
fmt.Fprintf(&b, "options: only=%s no-ai=%t no-wsl=%t yes=%t dry-run=%t linux-release-repo=%s\n",
report.Options.Only,
report.Options.NoAI,
report.Options.NoWSL,
report.Options.Yes,
report.Options.DryRun,
report.Options.Headless,
report.Options.LinuxReleaseRepo,
)
if report.FinalError != "" {
-146
View File
@@ -1,146 +0,0 @@
package bootstrap
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
func WingetInstallArgs(pkg Package) []string {
args := []string{
"install", "--id", pkg.WingetID, "--exact", "--source", pkg.WingetSource,
"--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity",
}
if pkg.WingetOverride != "" {
args = append(args, "--override", pkg.WingetOverride)
}
return args
}
func WingetListArgs(pkg Package) []string {
return []string{
"list", "--id", pkg.WingetID, "--exact", "--source", pkg.WingetSource,
"--accept-source-agreements", "--disable-interactivity",
}
}
func WingetOutputShowsInstalled(pkg Package, output string) bool {
lower := strings.ToLower(output)
if strings.Contains(lower, "no installed package found") || strings.Contains(lower, "no package found") {
return false
}
return strings.Contains(lower, strings.ToLower(pkg.WingetID))
}
type PackageState struct {
Package Package
Installed bool
CheckErr error
}
func CheckPackages(ctx context.Context, runner Runner, packages []Package, workers int) []PackageState {
if workers < 1 {
workers = 1
}
states := make([]PackageState, len(packages))
type job struct {
index int
pkg Package
}
jobs := make(chan job)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for current := range jobs {
result := runner.Run(ctx, "winget", WingetListArgs(current.pkg)...)
state := PackageState{Package: current.pkg}
if result.Err != nil && !strings.Contains(strings.ToLower(result.CombinedOutput()), "no installed package found") {
state.CheckErr = fmt.Errorf("%s: %w", result.CombinedOutput(), result.Err)
} else {
state.Installed = WingetOutputShowsInstalled(current.pkg, result.CombinedOutput())
}
states[current.index] = state
}
}()
}
for i, pkg := range packages {
jobs <- job{index: i, pkg: pkg}
}
close(jobs)
wg.Wait()
return states
}
func PendingPackages(states []PackageState) []Package {
var pending []Package
for _, state := range states {
if !state.Installed {
pending = append(pending, state.Package)
}
}
return pending
}
func UnknownPackageStates(packages []Package) []PackageState {
states := make([]PackageState, 0, len(packages))
for _, pkg := range packages {
states = append(states, PackageState{Package: pkg})
}
return states
}
func InstallPackages(ctx context.Context, runner Runner, packages []Package) Issues {
var issues Issues
for _, pkg := range packages {
prepared, err := PreparePackageForInstall(pkg)
if err != nil {
issues = append(issues, Issue{Step: "prepare " + pkg.Name, Err: err})
continue
}
result := runner.Run(ctx, "winget", WingetInstallArgs(prepared)...)
if result.Err != nil {
issues = append(issues, Issue{
Step: "install " + pkg.Name,
Err: fmt.Errorf("%w: %s", result.Err, result.CombinedOutput()),
})
}
}
return issues
}
func PreparePackageForInstall(pkg Package) (Package, error) {
if pkg.WingetConfigAsset == "" {
return pkg, nil
}
path, err := writeInstallAsset(pkg.WingetConfigAsset)
if err != nil {
return pkg, err
}
pkg.WingetOverride = strings.ReplaceAll(pkg.WingetOverride, "{config}", `"`+path+`"`)
return pkg, nil
}
func writeInstallAsset(asset string) (string, error) {
data, err := managedAssets.ReadFile(asset)
if err != nil {
return "", err
}
root, err := os.UserCacheDir()
if err != nil || root == "" {
root = os.TempDir()
}
dir := filepath.Join(root, "bootstrap_windows_env")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
target := filepath.Join(dir, filepath.Base(asset))
if err := os.WriteFile(target, data, 0o644); err != nil {
return "", err
}
return target, nil
}
-167
View File
@@ -1,167 +0,0 @@
package bootstrap
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestWingetInstallArgsUseExactNonInteractiveFlags(t *testing.T) {
pkg := Package{Name: "Git", WingetID: "Git.Git", WingetSource: "winget"}
want := []string{"install", "--id", "Git.Git", "--exact", "--source", "winget", "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"}
if got := WingetInstallArgs(pkg); !reflect.DeepEqual(got, want) {
t.Fatalf("args = %#v, want %#v", got, want)
}
}
func TestPreparePackageForInstallWritesVisualStudioConfigAndOverride(t *testing.T) {
pkg := Package{
Name: "Visual Studio Community 2026",
WingetID: "Microsoft.VisualStudio.Community",
WingetSource: "winget",
WingetOverride: "--passive --config {config}",
WingetConfigAsset: "assets/visual-studio-community.vsconfig",
}
prepared, err := PreparePackageForInstall(pkg)
if err != nil {
t.Fatal(err)
}
if strings.Contains(prepared.WingetOverride, "{config}") {
t.Fatalf("override still has placeholder: %q", prepared.WingetOverride)
}
args := WingetInstallArgs(prepared)
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--override") || !strings.Contains(joined, "visual-studio-community.vsconfig") {
t.Fatalf("winget args missing VS config override: %#v", args)
}
}
func TestPreparePackageForInstallHandlesNoAssetAndMissingAsset(t *testing.T) {
pkg := Package{Name: "Git", WingetID: "Git.Git", WingetSource: "winget"}
prepared, err := PreparePackageForInstall(pkg)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(prepared, pkg) {
t.Fatalf("prepared = %#v, want unchanged package", prepared)
}
_, err = PreparePackageForInstall(Package{Name: "broken", WingetConfigAsset: "assets/missing.vsconfig"})
if err == nil {
t.Fatal("expected missing asset error")
}
}
func TestWingetInstalledStateParsing(t *testing.T) {
pkg := Package{WingetID: "Git.Git"}
if !WingetOutputShowsInstalled(pkg, "Name Id Version\nGit Git.Git 2.0") {
t.Fatal("expected installed output to be detected")
}
if WingetOutputShowsInstalled(pkg, "No installed package found matching input criteria.") {
t.Fatal("expected missing package output to be false")
}
if WingetOutputShowsInstalled(pkg, "No package found matching input criteria.") {
t.Fatal("expected missing package output to be false")
}
}
func TestCheckPackagesKeepsStableOrderAndAggregatesErrors(t *testing.T) {
a := Package{Name: "A", WingetID: "A.A", WingetSource: "winget"}
b := Package{Name: "B", WingetID: "B.B", WingetSource: "winget"}
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("winget", WingetListArgs(a)...): {Stdout: "A A.A 1.0"},
commandKey("winget", WingetListArgs(b)...): {Err: errors.New("boom"), Stderr: "source unavailable"},
}}
states := CheckPackages(context.Background(), runner, []Package{a, b}, 2)
if len(states) != 2 || states[0].Package.Name != "A" || states[1].Package.Name != "B" {
t.Fatalf("states not in input order: %#v", states)
}
if !states[0].Installed {
t.Fatal("first package should be installed")
}
if states[1].CheckErr == nil {
t.Fatal("second package should keep check error")
}
}
func TestCheckPackagesUsesSingleWorkerMinimumAndMissingPackageIsPending(t *testing.T) {
pkg := Package{Name: "Git", WingetID: "Git.Git", WingetSource: "winget"}
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("winget", WingetListArgs(pkg)...): {Err: errors.New("not found"), Stdout: "No installed package found matching input criteria."},
}}
states := CheckPackages(context.Background(), runner, []Package{pkg}, 0)
if len(states) != 1 {
t.Fatalf("states = %d, want 1", len(states))
}
if states[0].Installed || states[0].CheckErr != nil {
t.Fatalf("state = %#v, want pending without check error", states[0])
}
}
func TestInstallPackagesContinuesAfterFailure(t *testing.T) {
a := Package{Name: "A", WingetID: "A.A", WingetSource: "winget"}
b := Package{Name: "B", WingetID: "B.B", WingetSource: "winget"}
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("winget", WingetInstallArgs(a)...): {Err: errors.New("boom"), Stderr: "failed"},
commandKey("winget", WingetInstallArgs(b)...): {},
}, defaultErr: true}
issues := InstallPackages(context.Background(), runner, []Package{a, b})
if len(issues) != 1 {
t.Fatalf("issues = %d, want 1", len(issues))
}
if !runner.called("winget", WingetInstallArgs(b)...) {
t.Fatal("second package should still be attempted after first failure")
}
}
func TestInstallPackagesReportsPrepareFailure(t *testing.T) {
issues := InstallPackages(context.Background(), &fakeRunner{defaultErr: true}, []Package{{
Name: "broken",
WingetConfigAsset: "assets/missing.vsconfig",
}})
if len(issues) != 1 || !strings.Contains(issues[0].Step, "prepare broken") {
t.Fatalf("issues = %#v, want prepare failure", issues)
}
}
func TestWriteInstallAssetReportsCacheDirectoryError(t *testing.T) {
temp := t.TempDir()
cacheFile := filepath.Join(temp, "cache-file")
if err := os.WriteFile(cacheFile, []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("LOCALAPPDATA", cacheFile)
t.Setenv("LocalAppData", cacheFile)
if _, err := writeInstallAsset("assets/visual-studio-community.vsconfig"); err == nil {
t.Fatal("expected cache directory error")
}
}
func TestWriteInstallAssetFallsBackToTempDirWhenUserCacheIsUnavailable(t *testing.T) {
t.Setenv("LOCALAPPDATA", "")
t.Setenv("LocalAppData", "")
path, err := writeInstallAsset("assets/visual-studio-community.vsconfig")
if err != nil {
t.Fatal(err)
}
if filepath.Base(path) != "visual-studio-community.vsconfig" {
t.Fatalf("path = %q", path)
}
}
func TestWriteInstallAssetReportsWriteError(t *testing.T) {
cache := t.TempDir()
targetDir := filepath.Join(cache, "bootstrap_windows_env", "visual-studio-community.vsconfig")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("LOCALAPPDATA", cache)
t.Setenv("LocalAppData", cache)
if _, err := writeInstallAsset("assets/visual-studio-community.vsconfig"); err == nil {
t.Fatal("expected write error when target path is a directory")
}
}
+85
View File
@@ -0,0 +1,85 @@
# Integration test (Vagrant + Hyper-V)
`bootstrap_windows_env` runs against Windows 10/11 client SKUs only — Windows
Server (which is what GitHub-hosted runners provide) does not support several
of the features the bootstrapper exercises (System Restore checkpoints,
Microsoft Store package updates, the Chris Titus Tech WinUtil flows, etc.).
The integration test therefore runs locally on a Windows host against a real
Windows client virtual machine through Hyper-V, driven by Vagrant.
## Prerequisites
On the host machine:
- Windows 10/11 Pro or Enterprise (or Windows Server) with the **Hyper-V**
role enabled.
- The host user must be a member of the **Hyper-V Administrators** local
group.
- [HashiCorp Vagrant](https://developer.hashicorp.com/vagrant/downloads)
2.4 or newer in `PATH`.
- Go (the same toolchain version pinned in `go.mod`) — used both to build the
bootstrapper and to run the integration driver.
- A Hyper-V-compatible Windows client Vagrant box. The default is
[`gusztavvargadr/windows-11`](https://portal.cloud.hashicorp.com/vagrant/discover/gusztavvargadr/windows-11),
which ships with WinRM enabled and the standard `vagrant`/`vagrant`
credentials. To build your own from a Windows 11 ISO, see
[`packer/README.md`](packer/README.md) and then set
`BOOTSTRAP_BOX=bootstrap-win11` (or edit the Vagrantfile default).
## Running the test
From the repository root:
```powershell
go run ./test/integration
```
That will:
1. Cross-build `dist/bootstrap_windows_env.exe` for `windows/amd64`.
2. `vagrant up --provider=hyperv` against `test/integration/Vagrantfile`.
3. `vagrant upload` the binary into the guest at `C:\Users\vagrant\bootstrap_windows_env.exe`.
4. Execute the binary inside the guest via `vagrant powershell -c "& ... --yes --no-wsl"`.
5. `vagrant destroy -f` regardless of pass/fail (use `--keep` to preserve the VM).
## Flags
| Flag | Default | Description |
|----------------|-------------------------------|------------------------------------------------------------------------------|
| `--args` | `--yes --no-wsl` | Arguments passed to `bootstrap_windows_env.exe` inside the guest. |
| `--keep` | `false` | Leave the VM running after the test for debugging. |
| `--skip-build` | `false` | Reuse `dist/bootstrap_windows_env.exe` instead of rebuilding it. |
| `--provider` | `hyperv` | Vagrant provider name (only `hyperv` is supported). |
| `--timeout` | `90m` | Overall timeout. The Vagrantfile has its own WinRM/boot timeouts on top. |
## Vagrantfile environment overrides
The Vagrantfile reads these environment variables so you can tune the VM
without editing the file:
| Variable | Default | Description |
|---------------------------|-------------------------------|----------------------------------------------|
| `BOOTSTRAP_BOX` | `gusztavvargadr/windows-11` | Vagrant box reference. |
| `BOOTSTRAP_BOX_VERSION` | _(unset)_ | Pin a specific box version. |
| `BOOTSTRAP_HOSTNAME` | `bootstrap-it` | Guest hostname. |
| `BOOTSTRAP_CPUS` | `8` | Guest vCPU count. |
| `BOOTSTRAP_MEMORY` | `16384` | Guest startup memory (MB). |
| `BOOTSTRAP_MAX_MEMORY` | `16384` | Guest dynamic-memory ceiling (MB). |
| `BOOTSTRAP_BOOT_TIMEOUT` | `1800` | Vagrant boot timeout (seconds). |
| `BOOTSTRAP_WINRM_USER` | `vagrant` | Guest WinRM user. |
| `BOOTSTRAP_WINRM_PASS` | `vagrant` | Guest WinRM password. |
| `BOOTSTRAP_WINRM_TIMEOUT` | `3600` | Per-WinRM-command timeout (seconds). |
| `BOOTSTRAP_WINRM_RETRIES` | `30` | WinRM retry attempts during `vagrant up`. |
## Notes
- There is no official Vagrant SDK for Go — Vagrant is a Ruby project — so the
driver shells out to the `vagrant` CLI. This is the conventional pattern for
Go programs orchestrating external tools.
- The Hyper-V provider cannot reliably share folders without prompting for SMB
credentials, so the driver pushes the bootstrapper into the guest with
`vagrant upload` and disables the default synced folder.
- This test is intentionally **not** wired up to GitHub Actions: hosted runners
do not expose Hyper-V and run Windows Server, which the bootstrapper does
not target. Run it on a workstation, lab box, or self-hosted Windows client
runner.
+51
View File
@@ -0,0 +1,51 @@
# Vagrantfile for the bootstrap_windows_env integration test.
#
# Driven by `go run ./test/integration` on a Windows host with Hyper-V enabled
# and the `vagrant` CLI installed. All tunables are read from environment
# variables so the Go driver can override them without editing this file.
#
# Required prerequisites on the host:
# - Windows 10/11 Pro/Enterprise or Windows Server with Hyper-V role enabled.
# - HashiCorp Vagrant >= 2.4 in PATH.
# - Host user is a member of the Hyper-V Administrators group.
#
# The default box `gusztavvargadr/windows-11` ships with WinRM enabled and the
# `vagrant` / `vagrant` credentials hard-coded below.
Vagrant.configure("2") do |config|
config.vm.box = ENV.fetch("BOOTSTRAP_BOX", "gusztavvargadr/windows-11")
config.vm.box_version = ENV["BOOTSTRAP_BOX_VERSION"] unless ENV["BOOTSTRAP_BOX_VERSION"].to_s.empty?
config.vm.hostname = ENV.fetch("BOOTSTRAP_HOSTNAME", "bootstrap-it")
config.vm.communicator = "winrm"
config.vm.guest = :windows
config.vm.boot_timeout = Integer(ENV.fetch("BOOTSTRAP_BOOT_TIMEOUT", "1800"))
config.winrm.username = ENV.fetch("BOOTSTRAP_WINRM_USER", "vagrant")
config.winrm.password = ENV.fetch("BOOTSTRAP_WINRM_PASS", "vagrant")
config.winrm.timeout = Integer(ENV.fetch("BOOTSTRAP_WINRM_TIMEOUT", "3600"))
config.winrm.retry_limit = Integer(ENV.fetch("BOOTSTRAP_WINRM_RETRIES", "30"))
# Hyper-V cannot use the default rsync/SMB sync without prompting for
# credentials; the Go driver pushes the built binary in via `vagrant upload`
# instead, so disable the default synced folder.
config.vm.synced_folder ".", "/vagrant", disabled: true
if ENV["VAGRANT_HYPERV_VIRTUAL_SWITCH"]
config.vm.network "public_network", bridge: ENV["VAGRANT_HYPERV_VIRTUAL_SWITCH"]
end
config.vm.provider "hyperv" do |hv|
hv.cpus = Integer(ENV.fetch("BOOTSTRAP_CPUS", "8"))
hv.memory = Integer(ENV.fetch("BOOTSTRAP_MEMORY", "16384"))
hv.maxmemory = Integer(ENV.fetch("BOOTSTRAP_MAX_MEMORY", "16384"))
hv.enable_virtualization_extensions = true
hv.linked_clone = true
hv.vm_integration_services = {
guest_service_interface: true,
heartbeat: true,
shutdown: true,
time_synchronization: true,
vss: true,
}
end
end
+323
View File
@@ -0,0 +1,323 @@
// Command integration drives the bootstrap_windows_env integration test by
// orchestrating a Hyper-V VM through the local `vagrant` CLI.
//
// Usage (run from any directory inside the repository):
//
// go run ./test/integration # build, vagrant up, run, destroy
// go run ./test/integration --keep # leave VM after run for debugging
// go run ./test/integration --skip-build # reuse dist/bootstrap_windows_env.exe
// go run ./test/integration --args="--yes --no-wsl --only os"
//
// The host must run Windows with Hyper-V enabled and the `vagrant` CLI in PATH.
// Hyper-V is the only Vagrant provider exercised here; the Vagrantfile in this
// directory documents the relevant environment variables for tuning.
//
// There is no official Vagrant SDK for Go (Vagrant is a Ruby project), so this
// driver shells out to the `vagrant` CLI through os/exec — the conventional
// "Go scripting" pattern for orchestrating external tools.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
)
const (
guestExe = `C:\Users\vagrant\bootstrap_windows_env.exe`
defaultArgs = "--yes --no-wsl"
exitUsage = 2
exitInternal = 1
)
type config struct {
args string
keep bool
skipBuild bool
provider string
timeout time.Duration
}
func main() {
cfg, err := parseFlags(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(exitUsage)
}
if runtime.GOOS != "windows" {
fmt.Fprintln(os.Stderr, "integration test must run on a Windows host with Hyper-V")
os.Exit(exitInternal)
}
if err := runIntegration(cfg); err != nil {
fmt.Fprintln(os.Stderr, "integration test failed:", err)
os.Exit(exitInternal)
}
fmt.Println("integration test passed")
}
func parseFlags(argv []string) (config, error) {
fs := flag.NewFlagSet("integration", flag.ContinueOnError)
cfg := config{
args: defaultArgs,
provider: "hyperv",
timeout: 90 * time.Minute,
}
fs.StringVar(&cfg.args, "args", cfg.args, "arguments passed to bootstrap_windows_env.exe inside the guest")
fs.BoolVar(&cfg.keep, "keep", false, "leave the VM running after the test (do not destroy)")
fs.BoolVar(&cfg.skipBuild, "skip-build", false, "reuse dist/bootstrap_windows_env.exe instead of rebuilding")
fs.StringVar(&cfg.provider, "provider", cfg.provider, "vagrant provider to use")
fs.DurationVar(&cfg.timeout, "timeout", cfg.timeout, "overall timeout for the integration run")
if err := fs.Parse(argv); err != nil {
return config{}, err
}
if fs.NArg() != 0 {
return config{}, fmt.Errorf("unexpected arguments: %v", fs.Args())
}
return cfg, nil
}
func runIntegration(cfg config) error {
root, err := repoRoot()
if err != nil {
return err
}
vagrantDir := filepath.Join(root, "test", "integration")
distDir := filepath.Join(root, "dist")
exePath := filepath.Join(distDir, "bootstrap_windows_env.exe")
if _, err := exec.LookPath("vagrant"); err != nil {
return fmt.Errorf("vagrant CLI not found in PATH: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(ctx, cfg.timeout)
defer cancel()
if !cfg.skipBuild {
if err := buildBootstrapper(ctx, root, exePath); err != nil {
return fmt.Errorf("build bootstrapper: %w", err)
}
}
if _, err := os.Stat(exePath); err != nil {
return fmt.Errorf("bootstrapper binary missing at %s: %w", exePath, err)
}
logFile, err := os.Create(filepath.Join(vagrantDir, "integration.log"))
if err != nil {
return fmt.Errorf("create log file: %w", err)
}
defer logFile.Close()
outWriter := io.MultiWriter(os.Stdout, logFile)
startTime := time.Now()
defer func() {
fmt.Fprintf(outWriter, "\n--- Integration Test Completed in %v ---\n", time.Since(startTime).Round(time.Second))
}()
if err := vagrant(ctx, vagrantDir, outWriter, "up", "--provider="+cfg.provider); err != nil {
return fmt.Errorf("vagrant up: %w", err)
}
defer func() {
// If the bootstrap or package installer is still in progress inside the guest,
// stop the processes to release log files and resources, allowing clean teardown.
killCtx, cancelKill := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelKill()
_ = vagrant(killCtx, vagrantDir, outWriter, "powershell", "-c", "Get-Process -Name bootstrap_windows_env,choco,nmap,msiexec,setup -ErrorAction SilentlyContinue | Stop-Process -Force")
// Always try to surface the in-guest bootstrapper log before destroying
// the VM. WinRM's stdout streaming is bursty; this gives a deterministic
// artifact even when the powershell call cut out mid-run.
logCtx, cancelLog := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancelLog()
_ = vagrant(logCtx, vagrantDir, outWriter, "powershell", "-c", tailGuestLogCmd)
if cfg.keep {
fmt.Println("--keep set; leaving Vagrant machine running. Destroy manually with `vagrant destroy -f` in", vagrantDir)
return
}
// Always attempt cleanup with a fresh context so an outer timeout does
// not leave a Hyper-V VM stranded.
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := vagrant(cleanupCtx, vagrantDir, outWriter, "destroy", "-f"); err != nil {
fmt.Fprintf(os.Stderr, "warning: vagrant destroy failed: %v\n", err)
}
}()
if err := vagrant(ctx, vagrantDir, outWriter, "upload", exePath, guestExe); err != nil {
return fmt.Errorf("vagrant upload: %w", err)
}
// `vagrant powershell -e -c` invokes PowerShell in the guest over the WinRM
// communicator using elevated privileges (which runs via a scheduled task
// to bypass WinRM COM restrictions for Windows Update).
guestCmd := fmt.Sprintf("$env:BOOTSTRAP_INTEGRATION_TEST='true'; & %q %s; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }", guestExe, cfg.args)
fmt.Fprintln(outWriter, "\n--- Starting Pass 1 (OS Phase) ---")
if err := vagrant(ctx, vagrantDir, outWriter, "powershell", "-e", "-c", guestCmd); err != nil {
fmt.Fprintf(outWriter, "Warning: Pass 1 returned an error (likely a Vagrant scheduled task quirk): %v\n", err)
}
// Reboot the VM to apply OS updates.
fmt.Fprintln(outWriter, "\n--- Rebooting VM to apply OS updates ---")
_ = vagrant(ctx, vagrantDir, outWriter, "powershell", "-c", "Restart-Computer -Force")
fmt.Fprintln(outWriter, "Waiting for VM to go offline...")
for i := 0; i < 60; i++ {
time.Sleep(5 * time.Second)
pollCtx, pollCancel := context.WithTimeout(ctx, 10*time.Second)
err := vagrant(pollCtx, vagrantDir, io.Discard, "powershell", "-c", "Write-Output 'still up'")
pollCancel()
if err != nil {
break // WinRM dropped, machine is rebooting
}
}
// Wait for the VM to come back online
fmt.Fprintln(outWriter, "Waiting for VM to come back online...")
deadline := time.Now().Add(15 * time.Minute)
vmReady := false
for time.Now().Before(deadline) {
time.Sleep(15 * time.Second)
// Use a short timeout for the poll so we don't hang if WinRM drops packets
pollCtx, pollCancel := context.WithTimeout(ctx, 30*time.Second)
err := vagrant(pollCtx, vagrantDir, outWriter, "powershell", "-c", "Write-Output 'VM is back'")
pollCancel()
if err == nil {
vmReady = true
break
}
}
if !vmReady {
return fmt.Errorf("timed out waiting for VM to come back online after reboot")
}
// Wait 60 seconds for LSASS and Task Scheduler services to fully stabilize after reboot
fmt.Fprintln(outWriter, "Waiting 60 seconds for system services to stabilize...")
time.Sleep(60 * time.Second)
// Run the bootstrapper again to finish the remaining phases (host, custom, config).
// We run these individually using --only to bypass the OS phase, which avoids
// infinite reboot loops and Vagrant scheduled task hangs.
fmt.Fprintln(outWriter, "\n--- Resuming bootstrap after reboot (Pass 2) ---")
chocoEnsureCmd := `
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host "Chocolatey not found. Installing Chocolatey..."
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Set-ExecutionPolicy Bypass -Scope Process -Force
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
} else {
Write-Host "Chocolatey is already installed."
}
`
_ = vagrant(ctx, vagrantDir, outWriter, "powershell", "-e", "-c", chocoEnsureCmd)
for _, phase := range []string{"host", "custom", "config"} {
fmt.Fprintf(outWriter, "\n--- Running Phase: %s ---\n", phase)
passArgs := strings.Replace(cfg.args, "--only os", "", 1) + " --only " + phase
guestCmdPhase := fmt.Sprintf("$env:BOOTSTRAP_INTEGRATION_TEST='true'; & %q %s; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }", guestExe, passArgs)
var phaseErr error
for attempt := 1; attempt <= 3; attempt++ {
if attempt > 1 {
fmt.Fprintf(outWriter, "\n[Attempt %d/3] Retrying phase %s after failure (likely WinRM network drop)...\n", attempt, phase)
time.Sleep(10 * time.Second) // Wait for network to stabilize before reconnecting
}
phaseErr = vagrant(ctx, vagrantDir, outWriter, "powershell", "-e", "-c", guestCmdPhase)
if phaseErr == nil || phase == "custom" {
break
}
}
if phaseErr != nil {
if phase == "custom" {
fmt.Fprintf(outWriter, "Warning: Phase custom failed (ignored because graphical installers are expected to fail or hang in non-interactive sessions): %v\n", phaseErr)
} else {
return fmt.Errorf("guest bootstrap run failed (phase %s) after 3 attempts: %w", phase, phaseErr)
}
}
}
return nil
}
func buildBootstrapper(ctx context.Context, repoRoot, exePath string) error {
if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil {
return err
}
fmt.Printf("building %s\n", exePath)
cmd := exec.CommandContext(ctx, "go", "build", "-o", exePath, "./cmd/bootstrap_windows_env")
cmd.Dir = repoRoot
cmd.Env = append(os.Environ(), "GOOS=windows", "GOARCH=amd64")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func vagrant(ctx context.Context, dir string, out io.Writer, args ...string) error {
fmt.Fprintf(out, "[vagrant] %s\n", strings.Join(args, " "))
cmd := exec.CommandContext(ctx, "vagrant", args...)
cmd.Dir = dir
cmd.Stdout = out
cmd.Stderr = out
// Default to the always-present Hyper-V "Default Switch" so `vagrant up`
// never blocks on the interactive virtual-switch prompt. Callers can
// override by exporting VAGRANT_HYPERV_VIRTUAL_SWITCH before running.
env := append(os.Environ(),
"VAGRANT_DEFAULT_PROVIDER=hyperv",
)
if os.Getenv("VAGRANT_HYPERV_VIRTUAL_SWITCH") == "" {
env = append(env, "VAGRANT_HYPERV_VIRTUAL_SWITCH=Default Switch")
}
cmd.Env = env
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("%w (deadline exceeded)", err)
}
return err
}
return nil
}
// tailGuestLogCmd locates the most recent bootstrap_windows_env log in the
// guest's Documents folder and prints its last 500 lines. Used after every
// run so the host has a deterministic artifact even when WinRM stops
// streaming midway through a long step.
const tailGuestLogCmd = `$ErrorActionPreference='SilentlyContinue'
$log = Get-ChildItem -Path "$env:USERPROFILE\Documents\bootstrap_windows_env-*.log" |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($log) {
Write-Host "=== guest log: $($log.FullName) ==="
Get-Content -Tail 500 -Path $log.FullName
} else {
Write-Host "no bootstrap_windows_env log found under $env:USERPROFILE\Documents"
}`
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", errors.New("could not locate go.mod ancestor of current directory")
}
dir = parent
}
}
+26
View File
@@ -0,0 +1,26 @@
# Copy this file to .env (same directory) and fill in your local values.
# `.env` itself is gitignored. Load it before running `packer build` with:
#
# . .\load-env.ps1
#
# All recognized variables map to Packer inputs via the PKR_VAR_<name>
# convention, so every name below is also a valid -var flag.
# Required. Absolute path to the Windows 11 installer ISO on this host.
PKR_VAR_iso_path=C:/path/to/Win11_25H2_English_x64_v2.iso
# Optional. SHA-256 of the ISO. Compute with:
# Get-FileHash <iso> -Algorithm SHA256
# Leave as "none" to skip validation (not recommended for shared boxes).
PKR_VAR_iso_checksum=none
# Optional overrides. Defaults are in windows-11.pkr.hcl. The build VM is
# throwaway and intentionally claims most of the host (24 vCPU, 32 GB RAM);
# the runtime integration VM uses a smaller footprint set in
# test/integration/Vagrantfile.
# PKR_VAR_cpus=24
# PKR_VAR_memory=32768
# PKR_VAR_disk_size=81920
# PKR_VAR_switch_name=Default Switch
# PKR_VAR_vm_name=bootstrap-win11
# PKR_VAR_box_output=bootstrap-win11.box
+167
View File
@@ -0,0 +1,167 @@
# Vagrant box build (Packer + Hyper-V + Windows 11 25H2)
Builds a `bootstrap-win11.box` Vagrant box from a stock Windows 11 25H2 ISO,
ready to use with `test/integration/Vagrantfile`.
## Prerequisites
On the host (must be Windows with Hyper-V):
- Hyper-V role enabled; current user in **Hyper-V Administrators**.
- [HashiCorp Packer](https://developer.hashicorp.com/packer/install) 1.15+ in
`PATH`. HashiCorp does not publish a winget manifest, so use the helper:
```powershell
# from an elevated PowerShell
cd test\integration\packer
.\install-packer.ps1 # installs the pinned default version
.\install-packer.ps1 -Version 1.14.0 # or pick a specific release
```
The script downloads
`https://releases.hashicorp.com/packer/<Version>/packer_<Version>_windows_amd64.zip`,
extracts `packer.exe` to `C:\Program Files\Packer\`, and adds that directory
to the machine-scoped `PATH`. It is idempotent — re-running with the same
version just re-asserts the `PATH` entry. See the `param()` block in
`install-packer.ps1` for available flags.
By default the same script also installs **Windows ADK Deployment Tools**
to get `oscdimg.exe`, which Packer's `hyperv-iso` builder needs to build the
unattended-install secondary ISO from `cd_files`. Only the
`OptionId.DeploymentTools` feature is selected, so the install is small
(~70 MB) and unattended. Pass `-SkipOscdimg` to opt out (e.g. if you already
have `xorriso`/`mkisofs` on `PATH`). After install you may need to open a
fresh shell — or run
`$env:Path = [Environment]::GetEnvironmentVariable('Path','Machine')` — for
`oscdimg.exe` to resolve in the current session.
- [HashiCorp Vagrant](https://developer.hashicorp.com/vagrant/install) 2.4+ in
`PATH` (used for the `vagrant` post-processor and, later, the integration
test itself).
- A Windows 11 client ISO somewhere on disk. Its path is read from `.env`
(see below) and is **not** hardcoded in the Packer config.
- Roughly 90 GB free disk and ~60 minutes of patience for a first build.
- An elevated PowerShell session for the build (`packer build` shells out to
Hyper-V management APIs that require admin).
## Configure the ISO path via `.env`
The Packer config has no default ISO path. Copy the template, fill it in, and
load it into the current shell before building:
```powershell
cd test\integration\packer
Copy-Item .env.example .env
# edit .env so PKR_VAR_iso_path points at your Windows 11 ISO
. .\load-env.ps1
```
`load-env.ps1` reads `.env` line-by-line and exports each `KEY=VALUE` into the
process environment. `.env` is gitignored at the repo root, so your local path
never gets committed.
Packer picks the values up automatically through its `PKR_VAR_<name>` env-var
convention; nothing about the `packer build` invocation changes.
## One-time setup
```powershell
packer init .\windows-11.pkr.hcl
```
That downloads the `hyperv` and `vagrant` Packer plugins.
## Compute and pin the ISO checksum (recommended)
```powershell
Get-FileHash $env:PKR_VAR_iso_path -Algorithm SHA256
```
Set `PKR_VAR_iso_checksum=sha256:<digest>` in `.env` and re-run `load-env.ps1`.
Leaving it as `none` skips validation; fine for local builds but loses the "my
ISO has not been swapped" guarantee.
## Build
```powershell
packer build .\windows-11.pkr.hcl
```
Roughly what happens:
1. Packer creates a Gen 2 Hyper-V VM (Secure Boot + TPM + 24 vCPU + 32 GB RAM
+ 80 GB dynamic VHDX) attached to `Default Switch`. The build VM is
throwaway and intentionally claims most of the host for speed; the
runtime integration VM uses smaller defaults set in
`test/integration/Vagrantfile` (8 vCPU / 16 GB).
2. It mounts the Windows ISO and a tiny `PROVISION` ISO containing
`cd/autounattend.xml` and `cd/scripts/oobe-enable-winrm.ps1`.
3. Windows Setup performs an unattended install (Pro edition, generic
activation key) and creates user `vagrant`:`vagrant`.
4. FirstLogonCommands enable WinRM (HTTP, basic auth) and set
`LocalAccountTokenFilterPolicy=1` so remote admin tokens are not filtered.
5. Packer connects over WinRM and runs the provisioners in `scripts/`:
- `configure-os.ps1` — UAC token policy, DiagTrack off, NTP resync.
- `disable-windows-updates.ps1` — fully disables WU and friends.
- `disable-defender-cloud.ps1` — disables MAPS / cloud lookups.
- `install-vagrant-key.ps1` — drops the vagrant insecure public key.
- `compact.ps1` — cleans caches, zeros free space.
6. Packer shuts the guest down cleanly and the `vagrant` post-processor
packages the VHDX into `bootstrap-win11.box`.
## Register the resulting box with Vagrant
```powershell
vagrant box add bootstrap-win11 .\bootstrap-win11.box
```
Then in `test/integration/Vagrantfile` set:
```powershell
$env:BOOTSTRAP_BOX = "bootstrap-win11"
go run ..\..\test\integration
```
(or just edit the default in the Vagrantfile to `bootstrap-win11`).
## Layout
```
test/integration/packer/
├── README.md ← this file
├── .env.example ← template; copy to .env (gitignored)
├── load-env.ps1 ← dot-source to export PKR_VAR_* into shell
├── install-packer.ps1 ← elevated installer for packer.exe
├── windows-11.pkr.hcl ← Packer HCL2 build definition
├── vagrantfile-template.rb ← baked into the output box
├── cd/
│ ├── autounattend.xml ← Windows Setup answer file
│ └── scripts/
│ └── oobe-enable-winrm.ps1
└── scripts/ ← provisioners run after WinRM is up
├── configure-os.ps1
├── disable-windows-updates.ps1
├── disable-defender-cloud.ps1
├── install-vagrant-key.ps1
└── compact.ps1
```
## Notes and gotchas
- **Run elevated.** `packer build` opens Hyper-V management APIs; non-elevated
shells fail with confusing "access denied" messages midway through.
- **First build is slow.** Windows Setup + updates trimming + cipher /w on a
60 GB volume can take 45–90 min. Subsequent rebuilds reuse the parent VHDX
via linked clone and are much faster.
- **License compliance.** The box uses the public KMS client setup key
(W269N-WFGWX-YVC9B-4J6C9-T83GX) to pick the Pro edition during install. The
resulting VM is not activated; for short-lived integration runs the eval
period is more than sufficient, but redistribute the box only under your
own licensing terms.
- **25H2 OOBE.** The autounattend sets `BypassNRO=1` during `specialize`
because 24H2/25H2 removed the `BypassNRO.cmd` helper. If a future Windows
update changes the OOBE flow again, the symptom will be a hung
`vagrant up` waiting for WinRM; check by opening Hyper-V Manager and
looking for an OOBE screen on the VM console.
- **Secure Boot.** Enabled (`MicrosoftWindows` template). Switch off in the
Packer source if you ever need to install unsigned kernel-mode drivers in
the box.
+200
View File
@@ -0,0 +1,200 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Unattend file for Windows 11 25H2 (Pro), Hyper-V Gen 2, UEFI + Secure Boot.
Goals:
- Fully unattended install on the only VHDX presented by the VM.
- Create local user `vagrant` (password `vagrant`) in Administrators.
- Skip every OOBE wizard page (including the network-required screen).
- Bring WinRM online with basic auth and unencrypted transport so Packer
and Vagrant can talk to the guest.
The BypassNRO trick is set in `specialize` rather than relying on the
removed BypassNRO.cmd script, so this works on 24H2/25H2.
-->
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>0409:00000409</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>EFI</Type>
<Size>300</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Type>MSR</Type>
<Size>128</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Format>FAT32</Format>
<Label>System</Label>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>2</PartitionID>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>3</Order>
<PartitionID>3</PartitionID>
<Format>NTFS</Format>
<Label>Windows</Label>
<Letter>C</Letter>
</ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>3</PartitionID>
</InstallTo>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/NAME</Key>
<Value>Windows 11 Pro</Value>
</MetaData>
</InstallFrom>
</OSImage>
</ImageInstall>
<UserData>
<!-- KMS client setup key for Windows 11 Pro; lets the installer pick
the right edition without prompting. Replaced at activation. -->
<ProductKey>
<Key>W269N-WFGWX-YVC9B-4J6C9-T83GX</Key>
<WillShowUI>OnError</WillShowUI>
</ProductKey>
<AcceptEula>true</AcceptEula>
<FullName>vagrant</FullName>
<Organization>vagrant</Organization>
</UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>bootstrap-win11</ComputerName>
<TimeZone>UTC</TimeZone>
</component>
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<RunSynchronous>
<!-- BypassNRO: allows OOBE without a network/Microsoft account.
24H2/25H2 removed the BypassNRO.cmd helper, so we set the
registry key directly during specialize. -->
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE /v BypassNRO /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<!-- Allow WinRM remote-elevated tokens (#3 mitigation). -->
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System /v LocalAccountTokenFilterPolicy /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>vagrant</Name>
<Group>Administrators</Group>
<DisplayName>vagrant</DisplayName>
<Description>Vagrant Box Build</Description>
<Password>
<Value>vagrant</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<Username>vagrant</Username>
<LogonCount>3</LogonCount>
<Password>
<Value>vagrant</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<!-- Allow PowerShell scripts unsigned (machine scope). -->
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-ExecutionPolicy -Scope LocalMachine -ExecutionPolicy Bypass -Force"</CommandLine>
<Description>Set ExecutionPolicy Bypass</Description>
</SynchronousCommand>
<!-- Enable WinRM inline so we don't depend on the PROVISION ISO
still being mounted at first logon (Windows Setup sometimes
detaches secondary DVDs after install). Mirrors the contents
of scripts/oobe-enable-winrm.ps1 (now unused) and writes a
transcript to C:\Windows\Temp\firstlogon.log for debugging. -->
<SynchronousCommand wcm:action="add">
<Order>2</Order>
<CommandLine>cmd.exe /c winrm quickconfig -force -q &gt; C:\Windows\Temp\firstlogon.log 2&gt;&amp;1</CommandLine>
<Description>WinRM quickconfig</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue; Set-Item WSMan:\localhost\Service\Auth\Basic $true; Set-Item WSMan:\localhost\Service\AllowUnencrypted $true; Set-Item WSMan:\localhost\Service\MaxConcurrentOperationsPerUser 12000; Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 1024" &gt;&gt; C:\Windows\Temp\firstlogon.log 2&gt;&amp;1</CommandLine>
<Description>Configure WinRM auth and limits</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>4</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "New-NetFirewallRule -Name WinRM-HTTP -DisplayName 'WinRM HTTP' -Protocol TCP -LocalPort 5985 -Action Allow -Profile Any -ErrorAction SilentlyContinue" &gt;&gt; C:\Windows\Temp\firstlogon.log 2&gt;&amp;1</CommandLine>
<Description>Open firewall for WinRM HTTP</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<CommandLine>cmd.exe /c sc config WinRM start= auto &amp;&amp; net stop WinRM &amp;&amp; net start WinRM &gt;&gt; C:\Windows\Temp\firstlogon.log 2&gt;&amp;1</CommandLine>
<Description>Restart WinRM service</Description>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>UTC</TimeZone>
</component>
</settings>
</unattend>
+206
View File
@@ -0,0 +1,206 @@
<#
.SYNOPSIS
Installs HashiCorp Packer on Windows by downloading the release zip,
extracting it to Program Files, and adding that directory to the system
PATH.
.DESCRIPTION
Winget does not publish HashiCorp Packer, so this helper does the install
by hand. It is idempotent: if the requested version is already installed
at the target location it just re-asserts the PATH entry and exits.
Requires an elevated PowerShell session (writes under Program Files and
modifies the machine-scoped PATH environment variable).
.PARAMETER Version
Packer release to install (e.g. "1.15.3"). The download URL is computed
as https://releases.hashicorp.com/packer/<Version>/packer_<Version>_windows_amd64.zip.
.PARAMETER InstallRoot
Directory under which packer.exe is placed. Defaults to
"$env:ProgramFiles\Packer". This directory is what gets added to the
system PATH.
.PARAMETER SkipOscdimg
Skip installing the Windows ADK Deployment Tools (which provide
oscdimg.exe). Packer's hyperv-iso builder needs an ISO-creation tool
(xorriso, mkisofs, hdiutil, or oscdimg) on PATH when using cd_files,
so by default this script also installs oscdimg via the Windows ADK
bootstrapper with only the DeploymentTools feature selected (~70 MB
on disk).
.PARAMETER AdkSetupUrl
Override the URL used to download the Windows ADK setup bootstrapper.
Defaults to Microsoft's stable fwlink for the Windows 11 ADK.
.EXAMPLE
# Install the default version into "C:\Program Files\Packer".
.\install-packer.ps1
.EXAMPLE
# Install a specific version.
.\install-packer.ps1 -Version 1.14.0
.EXAMPLE
# Install Packer only; skip the Windows ADK Deployment Tools install.
.\install-packer.ps1 -SkipOscdimg
#>
[CmdletBinding()]
param(
[string] $Version = '1.15.3',
[string] $InstallRoot = (Join-Path $env:ProgramFiles 'Packer'),
[switch] $SkipOscdimg,
[string] $AdkSetupUrl = 'https://go.microsoft.com/fwlink/?linkid=2289980'
)
$ErrorActionPreference = 'Stop'
function Assert-Elevated {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "install-packer.ps1 must be run from an elevated PowerShell session (writes to Program Files and the machine PATH)."
}
}
function Get-InstalledPackerVersion {
param([string] $Root)
$exe = Join-Path $Root 'packer.exe'
if (-not (Test-Path -LiteralPath $exe)) { return $null }
try {
$output = & $exe version 2>$null
} catch {
return $null
}
foreach ($line in $output) {
if ($line -match 'Packer\s+v?([0-9]+\.[0-9]+\.[0-9]+)') {
return $Matches[1]
}
}
return $null
}
function Add-ToMachinePath {
param([string] $Directory)
$current = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$parts = @()
if ($current) {
$parts = $current.Split(';') | Where-Object { $_ -ne '' }
}
if ($parts -contains $Directory) {
Write-Host "system PATH already contains $Directory"
return
}
$newPath = ($parts + $Directory) -join ';'
[Environment]::SetEnvironmentVariable('Path', $newPath, 'Machine')
# Make the change visible in the current session as well so the caller
# can immediately run `packer` without opening a new shell.
if (-not (($env:Path -split ';') -contains $Directory)) {
$env:Path = "$env:Path;$Directory"
}
Write-Host "added $Directory to the system PATH (machine scope)."
}
function Get-OscdimgPath {
$candidates = @(
(Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe'),
(Join-Path ${env:ProgramFiles} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe')
)
foreach ($c in $candidates) {
if ($c -and (Test-Path -LiteralPath $c)) { return $c }
}
return $null
}
function Install-Oscdimg {
param([string] $SetupUrl)
$existing = Get-OscdimgPath
if ($existing) {
Write-Host "oscdimg.exe already present at $existing"
Add-ToMachinePath -Directory (Split-Path -Parent $existing)
return
}
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("install-adk-" + [Guid]::NewGuid())
$setup = Join-Path $tmp 'adksetup.exe'
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
try {
Write-Host "downloading Windows ADK bootstrapper from $SetupUrl"
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Uri $SetupUrl -OutFile $setup
Write-Host "installing Windows ADK Deployment Tools (this may take several minutes)"
# /features OptionId.DeploymentTools => oscdimg only (~70 MB)
# /quiet /norestart /ceip off => unattended, no telemetry prompt
$args = @('/features','OptionId.DeploymentTools','/quiet','/norestart','/ceip','off')
$proc = Start-Process -FilePath $setup -ArgumentList $args -Wait -PassThru
if ($proc.ExitCode -ne 0) {
throw "Windows ADK setup exited with code $($proc.ExitCode)."
}
}
finally {
Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
$installed = Get-OscdimgPath
if (-not $installed) {
throw "Windows ADK setup completed but oscdimg.exe was not found in the expected location."
}
Write-Host "installed oscdimg.exe at $installed"
Add-ToMachinePath -Directory (Split-Path -Parent $installed)
}
Assert-Elevated
# Idempotency: if the target version is already installed, just make sure the
# PATH entry is in place and return.
$existing = Get-InstalledPackerVersion -Root $InstallRoot
if ($existing -eq $Version) {
Write-Host "packer $Version already installed at $InstallRoot."
Add-ToMachinePath -Directory $InstallRoot
if (-not $SkipOscdimg) { Install-Oscdimg -SetupUrl $AdkSetupUrl }
return
}
if ($existing) {
Write-Host "replacing packer $existing at $InstallRoot with $Version."
}
$url = "https://releases.hashicorp.com/packer/$Version/packer_${Version}_windows_amd64.zip"
$tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("install-packer-" + [Guid]::NewGuid())
$zipPath = Join-Path $tmpRoot "packer_${Version}.zip"
New-Item -ItemType Directory -Path $tmpRoot -Force | Out-Null
try {
Write-Host "downloading $url"
# TLS 1.2 is required to talk to releases.hashicorp.com on stock
# Windows 10/Server 2019 PowerShell sessions.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zipPath
Write-Host "extracting to $tmpRoot"
Expand-Archive -LiteralPath $zipPath -DestinationPath $tmpRoot -Force
$srcExe = Join-Path $tmpRoot 'packer.exe'
if (-not (Test-Path -LiteralPath $srcExe)) {
throw "packer.exe not found inside $zipPath after extraction."
}
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
Copy-Item -LiteralPath $srcExe -Destination (Join-Path $InstallRoot 'packer.exe') -Force
Write-Host "installed packer.exe to $InstallRoot"
Add-ToMachinePath -Directory $InstallRoot
$installed = Get-InstalledPackerVersion -Root $InstallRoot
if ($installed -ne $Version) {
Write-Warning "post-install version check returned '$installed' (expected $Version)."
} else {
Write-Host "verified: packer $installed available at $(Join-Path $InstallRoot 'packer.exe')."
}
}
finally {
Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue
}
if (-not $SkipOscdimg) { Install-Oscdimg -SetupUrl $AdkSetupUrl }
+34
View File
@@ -0,0 +1,34 @@
# Loads variables from .env (in this directory) into the current PowerShell
# session as environment variables. Dot-source it so the variables stick:
#
# . .\load-env.ps1
#
# Format:
# - One KEY=VALUE per line.
# - Lines starting with '#' and blank lines are ignored.
# - Values are taken verbatim (no shell quoting / interpolation). Surround
# with quotes only if you actually want quotes in the value.
$ErrorActionPreference = 'Stop'
$envFile = Join-Path $PSScriptRoot '.env'
if (-not (Test-Path -LiteralPath $envFile)) {
throw ".env not found at $envFile. Copy .env.example to .env and fill in PKR_VAR_iso_path."
}
$loaded = 0
foreach ($line in Get-Content -LiteralPath $envFile) {
$trimmed = $line.Trim()
if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
$idx = $trimmed.IndexOf('=')
if ($idx -lt 1) {
Write-Warning "skipping malformed line: $line"
continue
}
$name = $trimmed.Substring(0, $idx).Trim()
$value = $trimmed.Substring($idx + 1)
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
$loaded++
}
Write-Host "loaded $loaded variable(s) from $envFile into the current session."
View File
+78
View File
@@ -0,0 +1,78 @@
Warning: A checksum of 'none' was specified. Since ISO files are so big,
a checksum is highly recommended.
on .\windows-11.pkr.hcl line 83:
(source code not available)
hyperv-iso.win11: output will be in this color.
==> hyperv-iso.win11: Creating build directory...
==> hyperv-iso.win11: Retrieving ISO
==> hyperv-iso.win11: Trying C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2(1).iso
==> hyperv-iso.win11: Trying file://C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2%281%29.iso
==> hyperv-iso.win11: file://C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2%281%29.iso => C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2(1).iso
==> hyperv-iso.win11: Creating switch 'Default Switch' if required...
==> hyperv-iso.win11: switch 'Default Switch' already exists. Will not delete on cleanup...
==> hyperv-iso.win11: Creating virtual machine...
==> hyperv-iso.win11: Enabling Integration Service...
==> hyperv-iso.win11: Setting boot drive to os dvd drive C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2(1).iso ...
==> hyperv-iso.win11: Mounting os dvd drive C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2(1).iso ...
==> hyperv-iso.win11: Skipping mounting Integration Services Setup Disk...
==> hyperv-iso.win11: Creating CD disk...
==> hyperv-iso.win11: OSCDIMG 2.56 CD-ROM and DVD-ROM Premastering Utility
==> hyperv-iso.win11: Copyright (C) Microsoft, 1993-2012. All rights reserved.
==> hyperv-iso.win11: Licensed only for producing Microsoft authorized content.
==> hyperv-iso.win11: Scanning source tree
==> hyperv-iso.win11: Scanning source tree complete (2 files in 1 directories)
==> hyperv-iso.win11: Computing directory information complete
==> hyperv-iso.win11: Image file is 61440 bytes (before optimization)
==> hyperv-iso.win11: Writing 2 files in 1 directories to C:\Users\jasonross\AppData\Local\Temp\packer2666332811.iso
==> hyperv-iso.win11: Storage optimization saved 0 files, 0 bytes (0% of image)
==> hyperv-iso.win11: After optimization, image file is 61440 bytes
==> hyperv-iso.win11: Done.
==> hyperv-iso.win11: 100% complete
==> hyperv-iso.win11: Done copying paths from CD_dirs
==> hyperv-iso.win11: Mounting secondary DVD images...
==> hyperv-iso.win11: Mounting secondary dvd drive C:\Users\jasonross\AppData\Local\Temp\packer2666332811.iso ...
==> hyperv-iso.win11: Configuring vlan...
==> hyperv-iso.win11: Determine Host IP for HyperV machine...
==> hyperv-iso.win11: Host IP for the HyperV machine: 172.25.16.1
==> hyperv-iso.win11: Attempting to connect with vmconnect...
==> hyperv-iso.win11: Starting the virtual machine...
==> hyperv-iso.win11: Waiting 1s for boot...
==> hyperv-iso.win11: Typing the boot command...
==> hyperv-iso.win11: Waiting for WinRM to become available...
==> hyperv-iso.win11: WinRM connected.
==> hyperv-iso.win11: Connected to WinRM!
==> hyperv-iso.win11: Provisioning with Powershell...
==> hyperv-iso.win11: Provisioning with powershell script: ./scripts/configure-os.ps1
==> hyperv-iso.win11: Script exited with non-zero exit status: 2147943462. Allowed exit codes are: [0]
==> hyperv-iso.win11: Step "StepProvision" failed, aborting...
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepConnect"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepTypeBootCommand"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepRun"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepSetFirstBootDevice"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepSetBootOrder"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepConfigureVlan"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepMountSecondaryDvdImages"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateCD"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepMountGuestAdditions"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepMountFloppydrive"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepMountDvdDrive"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepEnableIntegrationService"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateVM"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateSwitch"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepHTTPServer"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateFloppy"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepDownload"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepOutputDir"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateBuildDir"
Build 'hyperv-iso.win11' errored after 16 minutes 59 seconds: Script exited with non-zero exit status: 2147943462. Allowed exit codes are: [0]
==> Wait completed after 16 minutes 59 seconds
==> Some builds didn't complete successfully and had errors:
--> hyperv-iso.win11: Script exited with non-zero exit status: 2147943462. Allowed exit codes are: [0]
==> Builds finished but no artifacts were created.
+1
View File
@@ -0,0 +1 @@
51192
@@ -0,0 +1,25 @@
# Zero out free space so the resulting .box file is small.
#
# Runs last, just before Packer initiates shutdown. Cleans caches and the WU
# component store, which directly shrinks the on-disk footprint of the VHDX.
# We intentionally do not call `cipher /w:C:\` here: it costs 15–30 minutes
# and its only payoff is better tarball compression of unused space, which
# only matters when shipping the box. Re-add it before publishing publicly.
$ErrorActionPreference = 'Continue'
# Clean Windows Update download cache and component store.
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
Remove-Item -Path "$env:WINDIR\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue
& dism /Online /Cleanup-Image /StartComponentCleanup /ResetBase /Quiet
# Empty Recycle Bin and temp dirs.
Get-ChildItem -Path "$env:TEMP" -Force -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Clear-RecycleBin -Force -ErrorAction SilentlyContinue
# Light defrag pass to consolidate free extents. Quick on an SSD-backed VHDX.
& defrag C: /U /V
# Drop hibernation file (Windows 11 has it on by default).
& powercfg /h off
@@ -0,0 +1,28 @@
# Generic OS tweaks applied after WinRM is reachable.
#
# Idempotent; safe to rerun.
$ErrorActionPreference = 'Stop'
# Make sure the remote-elevated token policy survives any unattended changes.
$policy = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
New-ItemProperty -Path $policy -Name 'LocalAccountTokenFilterPolicy' `
-PropertyType DWord -Value 1 -Force | Out-Null
# Disable telemetry "Connected User Experiences" service to avoid background
# network noise that competes with the bootstrapper.
$svc = Get-Service -Name DiagTrack -ErrorAction SilentlyContinue
if ($svc) {
Set-Service -Name DiagTrack -StartupType Disabled
Stop-Service -Name DiagTrack -Force -ErrorAction SilentlyContinue
}
# Disable SmartScreen network checks (slows winget installs in the lab).
$ss = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
New-Item -Path $ss -Force | Out-Null
New-ItemProperty -Path $ss -Name 'EnableSmartScreen' -PropertyType DWord -Value 0 -Force | Out-Null
# Force NTP resync; Hyper-V time-sync after sysprep can drift, which breaks
# TLS handshakes against winget / GitHub.
Start-Service -Name w32time -ErrorAction SilentlyContinue
& w32tm /resync /force 2>&1 | Out-Null
@@ -0,0 +1,12 @@
# Turn off Defender cloud-delivered protection and sample submission.
#
# Cloud lookups add tens of seconds per winget install on a cold box and have
# no value in an ephemeral integration VM.
$ErrorActionPreference = 'Continue'
Set-MpPreference -MAPSReporting Disabled -ErrorAction SilentlyContinue
Set-MpPreference -SubmitSamplesConsent NeverSend -ErrorAction SilentlyContinue
Set-MpPreference -DisableBlockAtFirstSeen $true -ErrorAction SilentlyContinue
Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue
Set-MpPreference -ScanAvgCPULoadFactor 5 -ErrorAction SilentlyContinue
@@ -0,0 +1,26 @@
# Disable Windows Update on the gold image so it doesn't fight with the
# bootstrapper's own WindowsUpdateCommand step at integration-test time.
#
# This is the precise mitigation for the 6-hour CI hang investigation: by
# the time the bootstrapper runs, no other process is holding the WU agent.
$ErrorActionPreference = 'Stop'
$au = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
New-Item -Path $au -Force | Out-Null
New-ItemProperty -Path $au -Name 'NoAutoUpdate' -PropertyType DWord -Value 1 -Force | Out-Null
New-ItemProperty -Path $au -Name 'AUOptions' -PropertyType DWord -Value 1 -Force | Out-Null
New-ItemProperty -Path $au -Name 'ScheduledInstallDay' -PropertyType DWord -Value 0 -Force | Out-Null
New-ItemProperty -Path $au -Name 'NoAutoRebootWithLoggedOnUsers' -PropertyType DWord -Value 1 -Force | Out-Null
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
New-Item -Path $wu -Force | Out-Null
New-ItemProperty -Path $wu -Name 'DoNotConnectToWindowsUpdateInternetLocations' -PropertyType DWord -Value 1 -Force | Out-Null
foreach ($svc in 'wuauserv','UsoSvc','WaaSMedicSvc') {
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
if ($s) {
Set-Service -Name $svc -StartupType Disabled -ErrorAction SilentlyContinue
Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,15 @@
# Install the Vagrant insecure SSH key marker so `vagrant ssh-config` and
# tooling that expects the Vagrant-flavoured user account is happy. WinRM is
# the actual communicator, but several Vagrant plugins probe for this layout.
$ErrorActionPreference = 'Continue'
$sshDir = "$env:USERPROFILE\.ssh"
New-Item -ItemType Directory -Path $sshDir -Force | Out-Null
$keyUrl = 'https://raw.githubusercontent.com/hashicorp/vagrant/main/keys/vagrant.pub'
try {
Invoke-WebRequest -UseBasicParsing -Uri $keyUrl -OutFile "$sshDir\authorized_keys"
} catch {
Write-Host "warning: could not fetch vagrant insecure public key: $($_.Exception.Message)"
}
@@ -0,0 +1,25 @@
# Vagrantfile fragment baked into the output .box file by the
# `packer-post-processor-vagrant` plugin. This is what every `vagrant up`
# of a derived box inherits before our top-level Vagrantfile is applied.
Vagrant.configure("2") do |config|
config.vm.communicator = "winrm"
config.vm.guest = :windows
config.winrm.username = "vagrant"
config.winrm.password = "vagrant"
config.winrm.transport = :plaintext
config.winrm.basic_auth_only = true
config.vm.boot_timeout = 1800
config.vm.provider "hyperv" do |hv|
hv.enable_virtualization_extensions = false
hv.linked_clone = true
hv.vm_integration_services = {
guest_service_interface: true,
heartbeat: true,
shutdown: true,
time_synchronization: true,
vss: true,
}
end
end
+157
View File
@@ -0,0 +1,157 @@
packer {
required_plugins {
hyperv = {
version = ">= 1.1.4"
source = "github.com/hashicorp/hyperv"
}
vagrant = {
version = ">= 1.1.5"
source = "github.com/hashicorp/vagrant"
}
}
}
# ─── Inputs ───────────────────────────────────────────────────────────────────
variable "iso_path" {
type = string
description = "Absolute path to the Windows 11 installer ISO. Set via PKR_VAR_iso_path (load .env first; see README)."
}
variable "iso_checksum" {
type = string
description = "Checksum of the ISO (e.g. 'sha256:<hex>'); 'none' skips validation. Set via PKR_VAR_iso_checksum."
default = "none"
}
variable "vm_name" {
type = string
default = "bootstrap-win11"
}
variable "output_directory" {
type = string
default = "output-bootstrap-win11"
}
variable "box_output" {
type = string
default = "bootstrap-win11.box"
}
variable "cpus" {
type = number
default = 24
description = "vCPUs allocated to the build VM. The Packer guest is throwaway and gets the full host budget for speed; the runtime integration VM keeps a smaller footprint in test/integration/Vagrantfile."
}
variable "memory" {
type = number
default = 32768
description = "Build-VM memory (MB). Throwaway VM, full host budget."
}
variable "disk_size" {
type = number
default = 81920
description = "Guest disk size in MB."
}
variable "switch_name" {
type = string
default = "Default Switch"
}
variable "winrm_username" {
type = string
default = "vagrant"
}
variable "winrm_password" {
type = string
default = "vagrant"
sensitive = true
}
# ─── Source ───────────────────────────────────────────────────────────────────
#
# Hyper-V Gen 2 + Secure Boot is required for modern Windows 11. The
# unattend payload lives on a small ISO that Packer builds on the fly from
# `cd_files` and attaches as a secondary drive; Windows Setup auto-discovers
# `autounattend.xml` on any attached media at the root.
source "hyperv-iso" "win11" {
vm_name = var.vm_name
iso_url = var.iso_path
iso_checksum = var.iso_checksum
cpus = var.cpus
memory = var.memory
disk_size = var.disk_size
generation = 2
enable_secure_boot = true
secure_boot_template = "MicrosoftWindows"
enable_tpm = true
enable_dynamic_memory = false
enable_virtualization_extensions = false
guest_additions_mode = "disable"
switch_name = var.switch_name
output_directory = var.output_directory
communicator = "winrm"
winrm_username = var.winrm_username
winrm_password = var.winrm_password
winrm_timeout = "2h"
winrm_use_ssl = false
winrm_insecure = true
# Gen 2 UEFI shows "Press any key to boot from CD or DVD..." for ~5s.
# Default boot_wait is 10s (already past the prompt) and default
# boot_command is empty, so without these the firmware falls through to
# PXE and Windows Setup never starts. Hammer ENTER early to catch it.
boot_wait = "1s"
boot_command = ["<enter><wait><enter><wait><enter>"]
shutdown_command = "shutdown /s /t 10 /f /d p:4:1 /c \"Packer Shutdown\""
shutdown_timeout = "30m"
# Build a tiny "PROVISION" ISO containing the unattend file. The
# FirstLogonCommands in autounattend.xml enable WinRM inline (no helper
# script needed) since Windows Setup may detach secondary DVDs after
# install, leaving the PROVISION volume unreachable at first logon.
cd_files = [
"./cd/autounattend.xml",
]
cd_label = "PROVISION"
}
# ─── Build ────────────────────────────────────────────────────────────────────
#
# Provisioners run *after* WinRM is reachable, which is after OOBE has finished
# and the autounattend's FirstLogonCommands have brought WinRM online. Each
# script is small and single-purpose; see comments in each .ps1.
build {
sources = ["source.hyperv-iso.win11"]
provisioner "powershell" {
elevated_user = var.winrm_username
elevated_password = var.winrm_password
scripts = [
"./scripts/configure-os.ps1",
"./scripts/disable-windows-updates.ps1",
"./scripts/disable-defender-cloud.ps1",
"./scripts/install-vagrant-key.ps1",
"./scripts/compact.ps1",
]
}
post-processor "vagrant" {
output = var.box_output
keep_input_artifact = false
provider_override = "hyperv"
vagrantfile_template = "./vagrantfile-template.rb"
}
}
+1937
View File
File diff suppressed because one or more lines are too long