commit 95c1204db1571fd3357819695362b6bd70a09053 Author: Jason Ross Date: Wed May 27 22:12:14 2026 -0500 mvp diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..5da4001 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,20 @@ +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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1797272 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: release + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to create (e.g. v1.2.3)" + required: true + type: string + +permissions: + contents: write + +jobs: + windows: + 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 + Get-FileHash dist/bootstrap_windows_env.exe -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLower()) bootstrap_windows_env.exe" } | Set-Content dist/bootstrap_windows_env.exe.sha256 + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = "${{ inputs.tag }}" + gh release view $tag 2>$null + if ($LASTEXITCODE -ne 0) { + gh release create $tag --title $tag --target ${{ github.sha }} --notes "Windows bootstrapper $tag" + } + gh release upload $tag dist/bootstrap_windows_env.exe dist/bootstrap_windows_env.exe.sha256 --clobber diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..0ce4283 --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,16 @@ +name: unit-test + +on: + pull_request: + +jobs: + test: + name: Unit tests + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Run unit tests + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd273bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bootstrap_windows_env.exe +dist/ +bin/ +*.test +coverage +coverage.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..357c6c6 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# bootstrap_windows_env + +Windows-native bootstrapper for a personal Windows 11 development workstation, with an optional Fedora WSL handoff. + +The tool is intentionally conservative: + +- Windows packages come from `winget` by exact package ID whenever available. +- Secondary managed providers such as Scoop or Chocolatey are used only for items without a reliable `winget` manifest, such as `pyenv-win`. +- Unsupported packages are reported as deferred instead of installed through ad hoc custom installers. +- WSL guest provisioning expects the published `JMR-dev/bootstrap_dev_env` Linux release to support `--wsl`; this repository does not modify that Linux project. + +## Build + +```powershell +go build ./cmd/bootstrap_windows_env +``` + +Tagged GitHub releases build and upload a precompiled `bootstrap_windows_env.exe`. + +## Usage + +```powershell +.\bootstrap_windows_env.exe +.\bootstrap_windows_env.exe --yes +.\bootstrap_windows_env.exe --dry-run +.\bootstrap_windows_env.exe --only os +.\bootstrap_windows_env.exe --only host +.\bootstrap_windows_env.exe --only custom +.\bootstrap_windows_env.exe --only config +.\bootstrap_windows_env.exe --only wsl +.\bootstrap_windows_env.exe --no-ai +.\bootstrap_windows_env.exe --no-wsl +``` + +## Phases + +`os` runs before any application installation. It invokes Windows Update/Microsoft Update, stops for reboot when required, creates a restore point, runs Chris Titus Tech WinUtil with the managed sane-default config, enables Developer Mode and Windows sudo, applies power/update/UI/store/taskbar defaults, then creates a second restore point before package installation. + +`host` installs exact `winget` packages, configures Hyper-V when supported for Vagrant, provisions Node.js LTS through `fnm`, installs `pyenv-win` through Scoop or Chocolatey, provisions the latest stable Python through `pyenv`, and imports VLC default media-player associations for common audio/video file types. + +Visual Studio Community 2026 is installed as `Microsoft.VisualStudio.Community` with the managed `.vsconfig` exported from the current workstation. That config includes the native desktop workload, Microsoft C/C++ tools, Windows SDKs, and CMake integration. The Windows host intentionally does not install `gcc` or `g++`; native C and C++ builds should use the Visual Studio toolchain. + +`custom` installs `oh-my-posh`, `agy` through Google's official Windows PowerShell installer, Node-based AI CLIs unless `--no-ai` is set, Playwright browsers, and the `JMR-dev/gh-repo-bootstrap` GitHub CLI extension. + +`config` deploys managed WezTerm, PowerShell profile, and `oh-my-posh` assets, backs up differing existing files with numbered suffixes, clones `JMR-dev/nvim-config`, and optionally offers interactive GitHub/SSH setup. + +`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`. + +## 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. diff --git a/cmd/bootstrap_windows_env/main.go b/cmd/bootstrap_windows_env/main.go new file mode 100644 index 0000000..f5316f9 --- /dev/null +++ b/cmd/bootstrap_windows_env/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/JMR-dev/bootstrap_windows_env/internal/bootstrap" +) + +type bootstrapApp interface { + Run(context.Context, bootstrap.Options) error +} + +var newBootstrapper = func() (bootstrapApp, error) { + return bootstrap.NewBootstrapper() +} + +var stderr io.Writer = os.Stderr +var exitProcess = os.Exit + +func main() { + exitProcess(run(os.Args[1:], stderr, newBootstrapper)) +} + +func run(args []string, stderr io.Writer, newApp func() (bootstrapApp, error)) int { + opts, err := bootstrap.ParseOptions(args, stderr) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + app, err := newApp() + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + if err := app.Run(context.Background(), opts); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} diff --git a/cmd/bootstrap_windows_env/main_test.go b/cmd/bootstrap_windows_env/main_test.go new file mode 100644 index 0000000..75f12da --- /dev/null +++ b/cmd/bootstrap_windows_env/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/JMR-dev/bootstrap_windows_env/internal/bootstrap" +) + +type fakeBootstrapApp struct { + err error + opts bootstrap.Options +} + +func (a *fakeBootstrapApp) Run(ctx context.Context, opts bootstrap.Options) error { + a.opts = opts + return a.err +} + +func TestRunReturnsParseFactoryAndExecutionFailures(t *testing.T) { + var stderr bytes.Buffer + if code := run([]string{"--only", "bad"}, &stderr, nil); code != 2 { + t.Fatalf("code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "invalid --only") { + t.Fatalf("stderr = %q", stderr.String()) + } + + stderr.Reset() + if code := run([]string{"--dry-run"}, &stderr, func() (bootstrapApp, error) { + return nil, errors.New("factory failed") + }); code != 1 { + t.Fatalf("code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "factory failed") { + t.Fatalf("stderr = %q", stderr.String()) + } + + stderr.Reset() + if code := run([]string{"--dry-run"}, &stderr, func() (bootstrapApp, error) { + return &fakeBootstrapApp{err: errors.New("run failed")}, nil + }); code != 1 { + t.Fatalf("code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "run failed") { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestRunSuccessPassesParsedOptions(t *testing.T) { + var stderr bytes.Buffer + app := &fakeBootstrapApp{} + code := run([]string{"--dry-run", "--no-wsl"}, &stderr, func() (bootstrapApp, error) { + return app, nil + }) + if code != 0 { + t.Fatalf("code = %d stderr = %q, want success", code, stderr.String()) + } + if !app.opts.DryRun || !app.opts.NoWSL { + t.Fatalf("opts = %#v", app.opts) + } +} + +func TestMainDelegatesToRunAndExits(t *testing.T) { + oldArgs := os.Args + oldStderr := stderr + oldExit := exitProcess + oldNew := newBootstrapper + defer func() { + os.Args = oldArgs + stderr = oldStderr + exitProcess = oldExit + newBootstrapper = oldNew + }() + + var stderrBuffer bytes.Buffer + os.Args = []string{"bootstrap_windows_env", "--only", "bad"} + stderr = &stderrBuffer + newBootstrapper = func() (bootstrapApp, error) { + t.Fatal("newBootstrapper should not be called after parse failure") + return nil, nil + } + exitProcess = func(code int) { + panic(code) + } + + defer func() { + recovered := recover() + if recovered != 2 { + t.Fatalf("exit code = %#v, want 2", recovered) + } + if !strings.Contains(stderrBuffer.String(), "invalid --only") { + t.Fatalf("stderr = %q", stderrBuffer.String()) + } + }() + main() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..59d0afe --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/JMR-dev/bootstrap_windows_env + +go 1.22 diff --git a/internal/bootstrap/actions.go b/internal/bootstrap/actions.go new file mode 100644 index 0000000..d0666e6 --- /dev/null +++ b/internal/bootstrap/actions.go @@ -0,0 +1,271 @@ +package bootstrap + +import ( + "context" + "fmt" + "strings" +) + +type CommandSpec struct { + Name string + Args []string +} + +type Action struct { + Name string + Phase Phase + Probe CommandSpec + Commands []CommandSpec + AI bool +} + +const refreshPath = `$env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User'); ` + +func powerShell(script string) CommandSpec { + return CommandSpec{ + Name: "powershell.exe", + Args: []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", refreshPath + script}, + } +} + +func checkedNativePowerShell(command string) CommandSpec { + return powerShell(`$ErrorActionPreference='Stop'; ` + command + `; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`) +} + +func HostActions() []Action { + return []Action{ + { + Name: "Node.js LTS through fnm", + Phase: PhaseHost, + Probe: checkedNativePowerShell(`fnm exec --using=lts-latest node --version`), + Commands: []CommandSpec{ + checkedNativePowerShell(`fnm install --lts`), + checkedNativePowerShell(`fnm default lts-latest`), + }, + }, + { + Name: "pyenv-win through Scoop or Chocolatey", + Phase: PhaseHost, + Probe: checkedNativePowerShell(`pyenv --version`), + 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', 'pyenv') +} elseif (Test-Path $scoopShim) { + Invoke-Native $scoopShim @('install', 'pyenv') +} 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') + $env:Path=[Environment]::GetEnvironmentVariable('Path','Machine')+';'+[Environment]::GetEnvironmentVariable('Path','User') + Invoke-Native 'choco' @('install', 'pyenv-win', '-y', '--no-progress') +}`), + }, + }, + { + 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 }`), + 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" + } +} +$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' } +Invoke-Native 'pyenv' @('install', '-s', $v) +Invoke-Native 'pyenv' @('global', $v)`), + }, + }, + { + Name: "VLC default media player associations", + Phase: PhaseHost, + Probe: powerShell(`$marker = Join-Path $env:ProgramData 'bootstrap_windows_env\vlc-default-media.done'; if (-not (Test-Path $marker)) { exit 1 }`), + Commands: []CommandSpec{ + powerShell(`$ErrorActionPreference='Stop' +$stateDir = Join-Path $env:ProgramData 'bootstrap_windows_env' +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') +) | Where-Object { $_ -and (Test-Path $_) } +if ($vlcPaths.Count -eq 0 -and -not (Get-Command vlc.exe -ErrorAction SilentlyContinue)) { + 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', + '.mov', '.mp2', '.mp3', '.mp4', '.mp4v', '.mpeg', '.mpg', '.mts', '.oga', + '.ogg', '.ogm', '.ogv', '.opus', '.ts', '.vob', '.wav', '.webm', '.wma', '.wmv' +) +$registered = @{} +$capabilityPaths = @( + 'HKLM:\SOFTWARE\Clients\Media\VLC\Capabilities\FileAssociations', + 'HKLM:\SOFTWARE\VideoLAN\VLC\Capabilities\FileAssociations', + 'HKLM:\SOFTWARE\Applications\vlc.exe\Capabilities\FileAssociations', + 'HKCU:\SOFTWARE\Clients\Media\VLC\Capabilities\FileAssociations', + 'HKCU:\SOFTWARE\Applications\vlc.exe\Capabilities\FileAssociations' +) +foreach ($path in $capabilityPaths) { + if (-not (Test-Path $path)) { continue } + $props = Get-ItemProperty $path + foreach ($ext in $extensions) { + if ($props.PSObject.Properties.Name -contains $ext) { + $registered[$ext] = [string]$props.$ext + } + } +} +$xmlPath = Join-Path $stateDir 'VlcDefaultAssociations.xml' +$lines = New-Object System.Collections.Generic.List[string] +$lines.Add('') +$lines.Add('') +foreach ($ext in $extensions) { + $progId = $registered[$ext] + if ([string]::IsNullOrWhiteSpace($progId)) { + $progId = 'VLC' + $ext + } + $safeExt = [System.Security.SecurityElement]::Escape($ext) + $safeProgId = [System.Security.SecurityElement]::Escape($progId) + $lines.Add(" ") +} +$lines.Add('') +$lines | Set-Content -Path $xmlPath -Encoding UTF8 +dism.exe /Online /Import-DefaultAppAssociations:$xmlPath +if ($LASTEXITCODE -ne 0) { + throw "dism.exe default association import failed with exit code $LASTEXITCODE" +} +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."`), + }, + }, + } +} + +func CustomActions(opts Options) []Action { + actions := []Action{ + { + Name: "agy", + Phase: PhaseCustom, + AI: true, + Probe: powerShell(`agy --version`), + Commands: []CommandSpec{ + powerShell(`Invoke-RestMethod https://antigravity.google/cli/install.ps1 | Invoke-Expression`), + }, + }, + { + Name: "Playwright CLI", + 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`), + }, + }, + { + Name: "GitHub CLI gh-repo-bootstrap extension", + Phase: PhaseCustom, + Probe: powerShell(`if (-not (gh extension list | Select-String 'JMR-dev/gh-repo-bootstrap')) { exit 1 }`), + Commands: []CommandSpec{ + checkedNativePowerShell(`gh extension install JMR-dev/gh-repo-bootstrap`), + }, + }, + { + Name: "Claude Code", + Phase: PhaseCustom, + 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'`), + }, + }, + { + Name: "OpenAI Codex CLI", + Phase: PhaseCustom, + 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'`), + }, + }, + { + Name: "GitHub Copilot CLI", + Phase: PhaseCustom, + 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'`), + }, + }, + } + if !opts.NoAI { + return actions + } + filtered := make([]Action, 0, len(actions)) + for _, action := range actions { + if !action.AI { + filtered = append(filtered, action) + } + } + return filtered +} + +type ActionState struct { + Action Action + Installed bool + CheckErr error +} + +func CheckActions(ctx context.Context, runner Runner, actions []Action) []ActionState { + states := make([]ActionState, 0, len(actions)) + for _, action := range actions { + result := runner.Run(ctx, action.Probe.Name, action.Probe.Args...) + states = append(states, ActionState{Action: action, Installed: result.Err == nil}) + } + return states +} + +func PendingActions(states []ActionState) []Action { + var pending []Action + for _, state := range states { + if !state.Installed { + pending = append(pending, state.Action) + } + } + return pending +} + +func UnknownActionStates(actions []Action) []ActionState { + states := make([]ActionState, 0, len(actions)) + for _, action := range actions { + states = append(states, ActionState{Action: action}) + } + return states +} + +func ExecuteActions(ctx context.Context, runner Runner, actions []Action) Issues { + var issues Issues + for _, action := range actions { + for _, command := range action.Commands { + result := runner.Run(ctx, command.Name, command.Args...) + if result.Err != nil { + output := strings.TrimSpace(result.CombinedOutput()) + issues = append(issues, Issue{Step: action.Name, Err: fmt.Errorf("%w: %s", result.Err, output)}) + break + } + } + } + return issues +} diff --git a/internal/bootstrap/actions_test.go b/internal/bootstrap/actions_test.go new file mode 100644 index 0000000..ea4c69d --- /dev/null +++ b/internal/bootstrap/actions_test.go @@ -0,0 +1,127 @@ +package bootstrap + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestCustomActionsNoAIStillKeepsPlaywrightAndExtension(t *testing.T) { + actions := CustomActions(Options{NoAI: true}) + names := make(map[string]bool) + for _, action := range actions { + if action.AI { + t.Fatalf("AI action was included with --no-ai: %s", action.Name) + } + names[action.Name] = true + } + for _, want := range []string{"Playwright CLI", "GitHub CLI gh-repo-bootstrap extension"} { + if !names[want] { + t.Fatalf("missing non-AI action %s", want) + } + } + if names["agy"] { + t.Fatal("agy should be skipped with --no-ai") + } +} + +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" { + 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"} { + if !strings.Contains(pyenvInstall, want) { + t.Fatalf("pyenv install command missing %q: %s", want, pyenvInstall) + } + } + custom := CustomActions(Options{}) + if len(custom) == 0 || custom[0].Name != "agy" { + t.Fatalf("unexpected custom action order: %#v", custom) + } + foundPlaywright := false + foundAI := false + for _, action := range custom { + if action.Name == "Playwright CLI" { + foundPlaywright = true + } + if action.AI { + foundAI = true + } + } + if !foundPlaywright || !foundAI { + t.Fatalf("custom actions missing playwright or AI actions: %#v", custom) + } +} + +func TestVLCDefaultMediaActionCoversCommonAudioVideoExtensions(t *testing.T) { + host := HostActions() + vlc := host[len(host)-1] + script := strings.Join(vlc.Commands[0].Args, " ") + for _, want := range []string{ + "VideoLAN\\VLC\\vlc.exe", + "VlcDefaultAssociations.xml", + "/Import-DefaultAppAssociations", + ".mp3", + ".flac", + ".mp4", + ".mkv", + ".avi", + ".webm", + ".wmv", + "vlc-default-media.done", + } { + if !strings.Contains(script, want) { + t.Fatalf("VLC default media action missing %q", want) + } + } +} + +func TestPendingActionsFiltersInstalledActions(t *testing.T) { + actions := []Action{ + {Name: "done"}, + {Name: "pending"}, + } + pending := PendingActions([]ActionState{ + {Action: actions[0], Installed: true}, + {Action: actions[1]}, + }) + if len(pending) != 1 || pending[0].Name != "pending" { + t.Fatalf("pending = %#v, want only pending action", pending) + } +} + +func TestExecuteActionsRunsCommandsAndStopsCurrentActionOnFailure(t *testing.T) { + first := CommandSpec{Name: "first", Args: []string{"ok"}} + fail := CommandSpec{Name: "second", Args: []string{"fail"}} + skipped := CommandSpec{Name: "third", Args: []string{"skipped"}} + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey(fail.Name, fail.Args...): {Err: errors.New("boom"), Stderr: "failed"}, + }} + issues := ExecuteActions(context.Background(), runner, []Action{{ + Name: "compound action", + Commands: []CommandSpec{first, fail, skipped}, + }}) + if len(issues) != 1 || !strings.Contains(issues[0].Err.Error(), "failed") { + t.Fatalf("issues = %#v, want second command failure", issues) + } + if !runner.called(first.Name, first.Args...) || !runner.called(fail.Name, fail.Args...) { + t.Fatal("expected first and failing commands to run") + } + if runner.called(skipped.Name, skipped.Args...) { + t.Fatal("commands after a failing command should not run for the same action") + } +} + +func TestExecuteActionsReturnsNoIssuesOnSuccess(t *testing.T) { + command := CommandSpec{Name: "ok"} + issues := ExecuteActions(context.Background(), &fakeRunner{}, []Action{{ + Name: "successful action", + Commands: []CommandSpec{command}, + }}) + if err := issues.Err(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go new file mode 100644 index 0000000..ce8d9d2 --- /dev/null +++ b/internal/bootstrap/app.go @@ -0,0 +1,351 @@ +package bootstrap + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +type Bootstrapper struct { + Runner Runner + In io.Reader + Out io.Writer + Err io.Writer + Paths UserPaths +} + +func NewBootstrapper() (*Bootstrapper, error) { + paths, err := DefaultUserPaths() + if err != nil { + return nil, err + } + return &Bootstrapper{ + Runner: ExecRunner{Timeout: 45 * time.Minute}, + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + Paths: paths, + }, nil +} + +type Plan struct { + Phases []Phase + PackageStates map[Phase][]PackageState + ActionStates map[Phase][]ActionState + DeferredNativeWork []string +} + +func BuildPlan(ctx context.Context, runner Runner, opts Options) Plan { + return BuildPlanForPhases(ctx, runner, opts, SelectedPhases(opts), true) +} + +func BuildPlanForPhases(ctx context.Context, runner Runner, opts Options, phases []Phase, checkState bool) Plan { + plan := Plan{ + Phases: append([]Phase(nil), phases...), + PackageStates: make(map[Phase][]PackageState), + ActionStates: make(map[Phase][]ActionState), + DeferredNativeWork: DeferredWindowsTools, + } + for _, phase := range plan.Phases { + packages := PackagesForPhase(opts, phase) + if len(packages) > 0 { + if checkState { + plan.PackageStates[phase] = CheckPackages(ctx, runner, packages, 8) + } else { + plan.PackageStates[phase] = UnknownPackageStates(packages) + } + } + switch phase { + case PhaseHost: + actions := HostActions() + if checkState { + plan.ActionStates[phase] = CheckActions(ctx, runner, actions) + } else { + plan.ActionStates[phase] = UnknownActionStates(actions) + } + case PhaseCustom: + actions := CustomActions(opts) + if checkState { + plan.ActionStates[phase] = CheckActions(ctx, runner, actions) + } else { + plan.ActionStates[phase] = UnknownActionStates(actions) + } + } + } + return plan +} + +func PrintPlan(out io.Writer, plan Plan, opts Options) { + fmt.Fprintln(out, "Windows bootstrap plan") + for _, phase := range plan.Phases { + fmt.Fprintf(out, "\n[%s]\n", phase) + states := plan.PackageStates[phase] + if len(states) > 0 { + for _, state := range states { + status := "pending" + if state.Installed { + status = "installed" + } 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) + } + } + actions := plan.ActionStates[phase] + for _, state := range actions { + status := "pending" + if state.Installed { + status = "done" + } + fmt.Fprintf(out, " action: %s - %s\n", state.Action.Name, status) + } + if phase == PhaseOS { + fmt.Fprintln(out, " os: install Windows/Microsoft updates and stop before packages if reboot is required") + fmt.Fprintln(out, " os: create restore point, run WinUtil config, apply developer/power/update/UI/store/taskbar defaults") + fmt.Fprintln(out, " os: create second restore point before host package installation") + } + if phase == PhaseConfig { + fmt.Fprintln(out, " config: deploy managed WezTerm, PowerShell, oh-my-posh, and Neovim config") + } + if phase == PhaseWSL { + fmt.Fprintf(out, " wsl: install official Fedora WSL and invoke %s release with --wsl\n", opts.LinuxReleaseRepo) + } + } + if len(plan.DeferredNativeWork) > 0 && opts.Includes(PhaseHost) { + fmt.Fprintf(out, "\nDeferred native Windows workloads: %s\n", strings.Join(plan.DeferredNativeWork, ", ")) + } +} + +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 + } + if b.Out == nil { + b.Out = os.Stdout + } + if b.Err == nil { + b.Err = os.Stderr + } + + phases := SelectedPhases(opts) + report := NewRunReport(opts, phases) + defer func() { + report.Finish(runErr) + logPath, err := WriteRunLog(b.Paths, report) + if err != nil { + fmt.Fprintf(b.Err, "ERROR: write run log: %v\n", err) + if runErr == nil { + runErr = fmt.Errorf("write run log: %w", err) + } + return + } + fmt.Fprintf(b.Out, "Run log: %s\n", logPath) + }() + + checkState := opts.DryRun || !hasPhase(phases, PhaseOS) + plan := BuildPlanForPhases(ctx, b.Runner, opts, phases, checkState) + PrintPlan(b.Out, plan, opts) + if opts.DryRun { + return nil + } + if !opts.Yes { + if !confirm(b.In, b.Out, "Proceed with this plan? [y/N] ") { + fmt.Fprintln(b.Out, "Aborted.") + report.Event("aborted by user") + return nil + } + } + + var issues Issues + if hasPhase(phases, PhaseOS) { + result := b.runOSPhase(ctx, report) + b.recordIssues(report, &issues, result.Issues) + if result.Stop { + report.Event("stopped after OS phase") + return issues.Err() + } + + remaining := phasesWithout(phases, PhaseOS) + if len(remaining) > 0 { + report.Event("checking post-OS package and action state") + plan = BuildPlanForPhases(ctx, b.Runner, opts, remaining, true) + PrintPlan(b.Out, plan, opts) + issues = append(issues, b.executeCheckedPlan(ctx, plan, opts, report)...) + } + return issues.Err() + } + + issues = append(issues, b.executeCheckedPlan(ctx, plan, opts, report)...) + return issues.Err() +} + +func (b *Bootstrapper) runOSPhase(ctx context.Context, report *RunReport) OSPhaseResult { + report.Event("phase os started") + result := RunOSPhase(ctx, b.Runner) + for _, notice := range result.Notices { + b.recordNotice(report, notice) + } + if !result.Stop { + report.Event("phase os completed") + } + return result +} + +func (b *Bootstrapper) executeCheckedPlan(ctx context.Context, plan Plan, opts Options, report *RunReport) Issues { + var issues Issues + for _, phase := range plan.Phases { + report.Event("phase %s started", phase) + switch phase { + case PhaseOS: + result := b.runOSPhase(ctx, report) + b.recordIssues(report, &issues, result.Issues) + if result.Stop { + return issues + } + case PhaseHost: + b.recordIssues(report, &issues, InstallPackages(ctx, b.Runner, PendingPackages(plan.PackageStates[phase]))) + if notice, issue := b.configureHyperV(ctx); issue != nil { + b.recordIssues(report, &issues, Issues{*issue}) + } else if notice != "" { + b.recordNotice(report, notice) + } + b.recordIssues(report, &issues, ExecuteActions(ctx, b.Runner, PendingActions(plan.ActionStates[phase]))) + case PhaseCustom: + b.recordIssues(report, &issues, InstallPackages(ctx, b.Runner, PendingPackages(plan.PackageStates[phase]))) + b.recordIssues(report, &issues, ExecuteActions(ctx, b.Runner, PendingActions(plan.ActionStates[phase]))) + case PhaseConfig: + b.recordIssues(report, &issues, b.runConfigPhase(ctx, opts)) + case PhaseWSL: + result := RunWSLPhase(ctx, b.Runner, opts) + if result.Issue != nil { + b.recordIssues(report, &issues, Issues{{Step: "wsl", Err: result.Issue}}) + } + if result.Notice != "" { + b.recordNotice(report, result.Notice) + } + } + report.Event("phase %s completed", phase) + } + return issues +} + +func (b *Bootstrapper) recordNotice(report *RunReport, notice string) { + fmt.Fprintln(b.Out, notice) + report.Notice(notice) +} + +func (b *Bootstrapper) recordIssues(report *RunReport, all *Issues, issues Issues) { + if len(issues) == 0 { + return + } + *all = append(*all, issues...) + for _, issue := range issues { + fmt.Fprintf(b.Err, "ERROR: %s: %v\n", issue.Step, issue.Err) + report.Issue(issue) + } +} + +func hasPhase(phases []Phase, target Phase) bool { + for _, phase := range phases { + if phase == target { + return true + } + } + return false +} + +func phasesWithout(phases []Phase, target Phase) []Phase { + filtered := make([]Phase, 0, len(phases)) + for _, phase := range phases { + if phase != target { + filtered = append(filtered, phase) + } + } + return filtered +} + +func (b *Bootstrapper) configureHyperV(ctx context.Context) (string, *Issue) { + status, err := DetectHyperV(ctx, b.Runner) + if err != nil { + issue := Issue{Step: "detect Hyper-V", Err: err} + return "", &issue + } + notice, err := EnableHyperV(ctx, b.Runner, status) + if err != nil { + issue := Issue{Step: "configure Hyper-V", Err: err} + return "", &issue + } + return notice, nil +} + +func (b *Bootstrapper) runConfigPhase(ctx context.Context, opts Options) Issues { + var issues Issues + deployments, err := DeployConfigAssets(b.Paths) + if err != nil { + issues = append(issues, Issue{Step: "deploy managed config", Err: err}) + } else { + for _, line := range FileSummary(deployments) { + fmt.Fprintln(b.Out, line) + } + } + issue := ConfigureNeovim(ctx, b.Runner, b.Paths) + 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 { + var issues 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") + if login.Err != nil { + issues = append(issues, Issue{Step: "gh auth login", Err: fmt.Errorf("%w: %s", login.Err, login.CombinedOutput())}) + } + } + } + key := filepath.Join(b.Paths.Home, ".ssh", "id_ed25519") + pub := key + ".pub" + if _, err := os.Stat(pub); os.IsNotExist(err) { + if confirm(b.In, b.Out, "Create and upload an ed25519 Git SSH key with gh? [y/N] ") { + if err := os.MkdirAll(filepath.Dir(key), 0o700); err != nil { + issues = append(issues, Issue{Step: "create SSH directory", Err: err}) + return issues + } + host, _ := os.Hostname() + email := os.Getenv("USERNAME") + "@" + host + gen := b.Runner.Run(ctx, "ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", "") + if gen.Err != nil { + issues = append(issues, Issue{Step: "ssh-keygen", Err: fmt.Errorf("%w: %s", gen.Err, gen.CombinedOutput())}) + return issues + } + add := b.Runner.Run(ctx, "gh", "ssh-key", "add", pub, "--title", "bootstrap_windows_env "+host) + if add.Err != nil { + issues = append(issues, Issue{Step: "gh ssh-key add", Err: fmt.Errorf("%w: %s", add.Err, add.CombinedOutput())}) + } + } + } + return issues +} + +func confirm(in io.Reader, out io.Writer, prompt string) bool { + fmt.Fprint(out, prompt) + scanner := bufio.NewScanner(in) + if !scanner.Scan() { + return false + } + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "y" || answer == "yes" +} diff --git a/internal/bootstrap/app_test.go b/internal/bootstrap/app_test.go new file mode 100644 index 0000000..1adf9f9 --- /dev/null +++ b/internal/bootstrap/app_test.go @@ -0,0 +1,473 @@ +package bootstrap + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunWithOSDoesNotProbePackagesBeforeWindowsUpdate(t *testing.T) { + temp := t.TempDir() + runner := &fakeRunner{} + app := &Bootstrapper{ + Runner: runner, + In: strings.NewReader(""), + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: UserPaths{ + Home: temp, + LocalAppData: filepath.Join(temp, "AppData", "Local"), + Documents: filepath.Join(temp, "Documents"), + }, + } + + if err := app.Run(context.Background(), Options{Yes: true, NoWSL: true, Headless: true}); err != nil { + t.Fatal(err) + } + + update := WindowsUpdateCommand() + runner.mu.Lock() + defer runner.mu.Unlock() + if len(runner.calls) == 0 { + t.Fatal("expected runner calls") + } + if got, want := runner.calls[0], commandKey(update.Name, update.Args...); got != want { + t.Fatalf("first command = %q, want Windows Update command %q", got, want) + } +} + +func TestRunWithDefaultsCanDryRunOnlyOS(t *testing.T) { + temp := t.TempDir() + app := &Bootstrapper{Paths: testUserPaths(temp)} + if err := app.Run(context.Background(), Options{Only: PhaseOS, DryRun: true}); err != nil { + t.Fatal(err) + } +} + +func TestRunStopsAfterOSRebootNotice(t *testing.T) { + temp := t.TempDir() + update := WindowsUpdateCommand() + reboot := windowsRebootProbeCommand() + restore := RestorePointCommand("bootstrap_windows_env: before OS configuration") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey(update.Name, update.Args...): {}, + commandKey(reboot.Name, reboot.Args...): {Stdout: "true\n"}, + }, defaultErr: true} + var out bytes.Buffer + app := &Bootstrapper{ + Runner: runner, + In: strings.NewReader(""), + Out: &out, + Err: &bytes.Buffer{}, + Paths: testUserPaths(temp), + } + if err := app.Run(context.Background(), Options{Yes: true, NoWSL: true, Headless: true}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "require a reboot") { + t.Fatalf("output missing reboot notice:\n%s", out.String()) + } + if runner.called(restore.Name, restore.Args...) { + t.Fatal("restore point should not run after reboot stop") + } +} + +func TestNewBootstrapperInitializesDefaults(t *testing.T) { + app, err := NewBootstrapper() + if err != nil { + t.Fatal(err) + } + if app.Runner == nil || app.In == nil || app.Out == nil || app.Err == nil { + t.Fatalf("bootstrapper defaults not populated: %#v", app) + } + if app.Paths.Home == "" || app.Paths.Documents == "" { + t.Fatalf("paths not populated: %#v", app.Paths) + } +} + +func TestBuildPlanWrapperAndPrintPlanShowStatuses(t *testing.T) { + pkg := Package{Name: "Git", WingetID: "Git.Git", WingetSource: "winget", Phase: PhaseHost} + action := Action{Name: "done action"} + plan := BuildPlan(context.Background(), &fakeRunner{}, Options{Only: PhaseOS}) + if len(plan.Phases) != 1 || plan.Phases[0] != PhaseOS { + t.Fatalf("plan phases = %#v, want only OS", plan.Phases) + } + + var out bytes.Buffer + PrintPlan(&out, Plan{ + Phases: []Phase{PhaseHost, PhaseConfig, PhaseWSL}, + PackageStates: map[Phase][]PackageState{ + PhaseHost: { + {Package: pkg, Installed: true}, + {Package: Package{Name: "Broken", WingetID: "Broken.ID"}, CheckErr: errors.New("check failed")}, + }, + }, + ActionStates: map[Phase][]ActionState{ + PhaseHost: {{Action: action, Installed: true}}, + }, + DeferredNativeWork: []string{"Docker Desktop"}, + }, Options{LinuxReleaseRepo: "JMR-dev/bootstrap_dev_env"}) + text := out.String() + for _, want := range []string{"installed", "check failed; will retry install", "done action - done", "config: deploy managed", "official Fedora WSL", "Deferred native Windows workloads"} { + if !strings.Contains(text, want) { + t.Fatalf("plan output missing %q:\n%s", want, text) + } + } +} + +func TestRunAbortWritesLogWithoutExecuting(t *testing.T) { + temp := t.TempDir() + runner := &fakeRunner{defaultErr: true} + var out, errOut bytes.Buffer + app := &Bootstrapper{ + Runner: runner, + In: strings.NewReader("no\n"), + Out: &out, + Err: &errOut, + Paths: testUserPaths(temp), + } + if err := app.Run(context.Background(), Options{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 0 { + t.Fatalf("runner calls = %#v, want none after abort", runner.calls) + } + if !strings.Contains(out.String(), "Aborted.") || !strings.Contains(out.String(), "Run log:") { + t.Fatalf("output missing abort/log notice:\n%s", out.String()) + } +} + +func TestRunReturnsLogWriteFailure(t *testing.T) { + temp := t.TempDir() + docFile := filepath.Join(temp, "Documents") + if err := os.WriteFile(docFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + app := &Bootstrapper{ + Runner: &fakeRunner{}, + In: strings.NewReader(""), + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: UserPaths{ + Home: temp, + LocalAppData: filepath.Join(temp, "local"), + Documents: docFile, + }, + } + if err := app.Run(context.Background(), Options{DryRun: true}); err == nil || !strings.Contains(err.Error(), "write run log") { + t.Fatalf("error = %v, want log write failure", err) + } +} + +func TestRunOnlyHostSkipsOSAndExecutesHostPlan(t *testing.T) { + temp := t.TempDir() + runner := &fakeRunner{} + var out bytes.Buffer + app := &Bootstrapper{ + Runner: runner, + In: strings.NewReader(""), + Out: &out, + Err: &bytes.Buffer{}, + Paths: testUserPaths(temp), + } + if err := app.Run(context.Background(), Options{Only: PhaseHost, Yes: true, Headless: true}); err != nil { + t.Fatal(err) + } + update := WindowsUpdateCommand() + if runner.called(update.Name, update.Args...) { + t.Fatal("host-only run should not execute OS phase") + } + if !strings.Contains(out.String(), "Run log:") { + t.Fatalf("missing run log output:\n%s", out.String()) + } +} + +func TestExecuteCheckedPlanRecordsWSLNoticeAndIssue(t *testing.T) { + temp := t.TempDir() + var out, errOut bytes.Buffer + app := &Bootstrapper{ + Runner: &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Err: errors.New("not installed")}, + }}, + Out: &out, + Err: &errOut, + Paths: testUserPaths(temp), + } + report := NewRunReport(Options{}, []Phase{PhaseWSL}) + issues := app.executeCheckedPlan(context.Background(), Plan{Phases: []Phase{PhaseWSL}}, Options{}, report) + if err := issues.Err(); err != nil { + t.Fatal(err) + } + if len(report.Notices) != 1 || !strings.Contains(out.String(), "WSL prerequisites") { + t.Fatalf("notice/report mismatch: %#v\n%s", report.Notices, out.String()) + } + + app.Runner = &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Err: errors.New("not installed")}, + commandKey("wsl.exe", "--install", "--no-distribution"): {Err: errors.New("blocked"), Stderr: "no admin"}, + }} + report = NewRunReport(Options{}, []Phase{PhaseWSL}) + issues = app.executeCheckedPlan(context.Background(), Plan{Phases: []Phase{PhaseWSL}}, Options{}, report) + if issues.Err() == nil || len(report.Issues) != 1 || !strings.Contains(errOut.String(), "ERROR: wsl") { + t.Fatalf("issues = %#v report = %#v stderr = %s", issues, report.Issues, errOut.String()) + } +} + +func TestExecuteCheckedPlanCoversOSStopAndHostConfigureIssue(t *testing.T) { + temp := t.TempDir() + update := WindowsUpdateCommand() + reboot := windowsRebootProbeCommand() + app := &Bootstrapper{ + Runner: &fakeRunner{responses: map[string]CommandResult{ + commandKey(update.Name, update.Args...): {}, + commandKey(reboot.Name, reboot.Args...): {Stdout: "true\n"}, + }, defaultErr: true}, + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: testUserPaths(temp), + } + report := NewRunReport(Options{}, []Phase{PhaseOS}) + issues := app.executeCheckedPlan(context.Background(), Plan{Phases: []Phase{PhaseOS}}, Options{}, report) + if issues.Err() != nil { + t.Fatal(issues.Err()) + } + if len(report.Notices) != 1 || !strings.Contains(report.Events[len(report.Events)-1], "notice:") { + t.Fatalf("report = %#v, want reboot notice and early return", report) + } + + app.Runner = staticRunner{result: CommandResult{Err: errors.New("detect failed"), Stderr: "blocked"}} + report = NewRunReport(Options{}, []Phase{PhaseHost}) + issues = app.executeCheckedPlan(context.Background(), Plan{Phases: []Phase{PhaseHost}}, Options{}, report) + if len(issues) != 1 || issues[0].Step != "detect Hyper-V" { + t.Fatalf("issues = %#v, want Hyper-V detect issue", issues) + } +} + +func TestRunConfigPhaseRecordsDeploymentIssue(t *testing.T) { + temp := t.TempDir() + homeFile := filepath.Join(temp, "home") + if err := os.WriteFile(homeFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + app := &Bootstrapper{ + Runner: &fakeRunner{}, + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: UserPaths{ + Home: homeFile, + LocalAppData: filepath.Join(temp, "local"), + Documents: filepath.Join(temp, "docs"), + }, + } + issues := app.runConfigPhase(context.Background(), Options{Headless: true}) + if len(issues) == 0 || !strings.Contains(issues[0].Step, "deploy managed config") { + t.Fatalf("issues = %#v, want deployment issue", issues) + } +} + +func TestRunConfigPhaseRecordsNeovimIssue(t *testing.T) { + temp := t.TempDir() + target := filepath.Join(temp, "local", "nvim") + app := &Bootstrapper{ + Runner: &fakeRunner{responses: map[string]CommandResult{ + commandKey("git", "clone", "https://github.com/JMR-dev/nvim-config.git", target): {Err: errors.New("clone failed"), Stderr: "offline"}, + }}, + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: testUserPaths(temp), + } + issues := app.runConfigPhase(context.Background(), Options{Headless: true}) + if len(issues) != 1 || issues[0].Step != "clone Neovim config" { + t.Fatalf("issues = %#v, want Neovim clone issue", issues) + } +} + +func TestRunConfigPhaseOffersAuthWhenInteractive(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 { + t.Fatal(err) + } + if err := os.WriteFile(pub, []byte("pub"), 0o644); err != nil { + t.Fatal(err) + } + app := &Bootstrapper{ + Runner: &fakeRunner{}, + In: strings.NewReader(""), + 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 !app.Runner.(*fakeRunner).called("gh", "auth", "status") { + t.Fatal("expected interactive auth status check") + } +} + +func TestConfigureHyperVRecordsDetectAndEnableFailures(t *testing.T) { + app := &Bootstrapper{Runner: staticRunner{result: CommandResult{Err: errors.New("probe failed"), Stderr: "blocked"}}} + if notice, issue := app.configureHyperV(context.Background()); notice != "" || issue == nil || issue.Step != "detect Hyper-V" { + t.Fatalf("notice = %q issue = %#v, want detect issue", notice, issue) + } + + app.Runner = &queueRunner{results: []CommandResult{ + {Stdout: "Edition=Windows 11 Pro\nSupported=True\nEnabled=False\nRestartRequired=False\n"}, + {Err: errors.New("dism failed"), Stderr: "feature unavailable"}, + }} + if notice, issue := app.configureHyperV(context.Background()); notice != "" || issue == nil || issue.Step != "configure Hyper-V" { + t.Fatalf("notice = %q issue = %#v, want configure issue", notice, issue) + } + + app.Runner = &queueRunner{results: []CommandResult{ + {Stdout: "Edition=Windows 11 Pro\nSupported=True\nEnabled=False\nRestartRequired=False\n"}, + {}, + }} + if notice, issue := app.configureHyperV(context.Background()); issue != nil || !strings.Contains(notice, "features were enabled") { + t.Fatalf("notice = %q issue = %#v, want enable notice", notice, issue) + } +} + +func TestOfferInteractiveAuthCoversLoginFailure(t *testing.T) { + temp := t.TempDir() + host, _ := os.Hostname() + email := os.Getenv("USERNAME") + "@" + host + key := filepath.Join(temp, ".ssh", "id_ed25519") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("gh", "auth", "status"): {Err: errors.New("not logged in")}, + commandKey("gh", "auth", "login"): {Err: errors.New("login failed"), Stderr: "bad browser"}, + commandKey("ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", ""): {}, + commandKey("gh", "ssh-key", "add", key+".pub", "--title", "bootstrap_windows_env "+host): {Err: errors.New("upload failed"), Stderr: "denied"}, + }, defaultErr: true} + app := &Bootstrapper{ + Runner: runner, + In: strings.NewReader("y\n"), + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: UserPaths{Home: temp}, + } + issues := app.offerInteractiveAuth(context.Background()) + if len(issues) != 1 || issues[0].Step != "gh auth login" { + t.Fatalf("issues = %#v, want login failure", issues) + } +} + +func TestOfferInteractiveAuthCoversSSHUploadFailure(t *testing.T) { + temp := t.TempDir() + host, _ := os.Hostname() + email := os.Getenv("USERNAME") + "@" + host + key := filepath.Join(temp, ".ssh", "id_ed25519") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("gh", "auth", "status"): {}, + commandKey("ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", ""): {}, + commandKey("gh", "ssh-key", "add", key+".pub", "--title", "bootstrap_windows_env "+host): {Err: errors.New("upload failed"), Stderr: "denied"}, + }, defaultErr: true} + app := &Bootstrapper{ + Runner: runner, + In: strings.NewReader("y\n"), + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: UserPaths{Home: temp}, + } + issues := app.offerInteractiveAuth(context.Background()) + if len(issues) != 1 || issues[0].Step != "gh ssh-key add" { + t.Fatalf("issues = %#v, want upload failure", issues) + } + if !runner.called("ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", "") { + t.Fatal("expected ssh-keygen command") + } +} + +func TestOfferInteractiveAuthCoversSSHDirectoryAndKeygenFailures(t *testing.T) { + temp := t.TempDir() + homeFile := filepath.Join(temp, "home") + if err := os.WriteFile(homeFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + app := &Bootstrapper{ + Runner: &fakeRunner{responses: map[string]CommandResult{ + commandKey("gh", "auth", "status"): {}, + }}, + In: strings.NewReader("y\n"), + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: UserPaths{Home: homeFile}, + } + issues := app.offerInteractiveAuth(context.Background()) + if len(issues) != 1 || issues[0].Step != "create SSH directory" { + t.Fatalf("issues = %#v, want SSH directory failure", issues) + } + + host, _ := os.Hostname() + email := os.Getenv("USERNAME") + "@" + host + key := filepath.Join(temp, "home2", ".ssh", "id_ed25519") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("gh", "auth", "status"): {}, + commandKey("ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", ""): {Err: errors.New("keygen failed"), Stderr: "bad key"}, + }, defaultErr: true} + app = &Bootstrapper{ + Runner: runner, + In: strings.NewReader("y\n"), + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Paths: UserPaths{Home: filepath.Join(temp, "home2")}, + } + issues = app.offerInteractiveAuth(context.Background()) + if len(issues) != 1 || issues[0].Step != "ssh-keygen" { + t.Fatalf("issues = %#v, want ssh-keygen failure", issues) + } + if runner.called("gh", "ssh-key", "add", key+".pub", "--title", "bootstrap_windows_env "+host) { + t.Fatal("ssh key should not be uploaded after key generation failure") + } +} + +func TestConfirmHandlesYesNoAndMissingInput(t *testing.T) { + if !confirm(strings.NewReader("YES\n"), &bytes.Buffer{}, "prompt") { + t.Fatal("YES should confirm") + } + if confirm(strings.NewReader("n\n"), &bytes.Buffer{}, "prompt") { + t.Fatal("n should not confirm") + } + if confirm(strings.NewReader(""), &bytes.Buffer{}, "prompt") { + t.Fatal("missing input should not confirm") + } +} + +func TestRecordIssuesEmptyAndHasPhaseFalse(t *testing.T) { + app := &Bootstrapper{Err: &bytes.Buffer{}} + report := NewRunReport(Options{}, nil) + var issues Issues + app.recordIssues(report, &issues, nil) + if len(issues) != 0 || len(report.Issues) != 0 { + t.Fatalf("issues = %#v report = %#v, want no changes", issues, report.Issues) + } + if hasPhase([]Phase{PhaseHost}, PhaseOS) { + t.Fatal("hasPhase should be false for missing phase") + } +} + +func testUserPaths(root string) UserPaths { + return UserPaths{ + Home: filepath.Join(root, "home"), + LocalAppData: filepath.Join(root, "local"), + Documents: filepath.Join(root, "Documents"), + } +} + +type queueRunner struct { + results []CommandResult +} + +func (r *queueRunner) Run(ctx context.Context, name string, args ...string) CommandResult { + if len(r.results) == 0 { + return CommandResult{} + } + result := r.results[0] + r.results = r.results[1:] + return result +} diff --git a/internal/bootstrap/assets/Microsoft.PowerShell_profile.ps1 b/internal/bootstrap/assets/Microsoft.PowerShell_profile.ps1 new file mode 100644 index 0000000..1c12453 --- /dev/null +++ b/internal/bootstrap/assets/Microsoft.PowerShell_profile.ps1 @@ -0,0 +1,31 @@ +# Managed by bootstrap_windows_env. Local additions belong in Microsoft.PowerShell_profile.local.ps1. +$ompConfig = Join-Path $HOME '.config\oh-my-posh\jmr.omp.json' +if (Get-Command oh-my-posh -ErrorAction SilentlyContinue) { + oh-my-posh init pwsh --config $ompConfig | Invoke-Expression +} + +if (Get-Command fnm -ErrorAction SilentlyContinue) { + fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression +} + +$pyenvRoots = @( + $env:PYENV, + (Join-Path $HOME '.pyenv\pyenv-win'), + (Join-Path $HOME 'scoop\apps\pyenv\current\pyenv-win') +) | Where-Object { $_ } | Select-Object -Unique +foreach ($root in $pyenvRoots) { + $pyenvBin = Join-Path $root 'bin' + $pyenvShims = Join-Path $root 'shims' + if ((Test-Path $pyenvBin) -and ($env:Path -notlike "*$pyenvBin*")) { + $env:PYENV = $root + $env:PYENV_ROOT = $root + $env:PYENV_HOME = $root + $env:Path = "$pyenvBin;$pyenvShims;$env:Path" + break + } +} + +$localProfile = Join-Path (Split-Path -Parent $PROFILE) 'Microsoft.PowerShell_profile.local.ps1' +if (Test-Path $localProfile) { + . $localProfile +} diff --git a/internal/bootstrap/assets/jmr.omp.json b/internal/bootstrap/assets/jmr.omp.json new file mode 100644 index 0000000..ed493c2 --- /dev/null +++ b/internal/bootstrap/assets/jmr.omp.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json", + "version": 3, + "final_space": true, + "blocks": [ + { + "type": "prompt", + "alignment": "left", + "segments": [ + { + "type": "path", + "style": "plain", + "foreground": "#7aa2f7", + "template": "{{ .Path }} " + }, + { + "type": "git", + "style": "plain", + "foreground": "#9ece6a", + "template": "{{ .HEAD }}{{ if .Working.Changed }}*{{ end }} " + } + ] + } + ] +} diff --git a/internal/bootstrap/assets/visual-studio-community.vsconfig b/internal/bootstrap/assets/visual-studio-community.vsconfig new file mode 100644 index 0000000..129ed1a --- /dev/null +++ b/internal/bootstrap/assets/visual-studio-community.vsconfig @@ -0,0 +1,97 @@ +{ + "version": "1.0", + "components": [ + "Component.Microsoft.NET.AppModernization", + "Component.Microsoft.VisualStudio.LiveShare.2022", + "Component.Microsoft.VisualStudio.RazorExtension", + "Component.Microsoft.VisualStudio.Web.AzureFunctions", + "Component.Microsoft.Web.LibraryManager", + "Component.Microsoft.Windows.DriverKit", + "Component.VisualStudio.GitHub.Copilot", + "ComponentGroup.Microsoft.NET.AppModernization", + "maui.blazor", + "maui.core", + "maui.windows", + "Microsoft.Component.ClickOnce", + "Microsoft.Component.CodeAnalysis.SDK", + "Microsoft.Component.MSBuild", + "Microsoft.Component.PythonTools", + "Microsoft.ComponentGroup.Blend", + "Microsoft.ComponentGroup.ClickOnce.Publish", + "Microsoft.Net.Component.4.6.2.TargetingPack", + "Microsoft.Net.Component.4.7.2.TargetingPack", + "Microsoft.Net.Component.4.8.SDK", + "Microsoft.Net.Component.4.8.TargetingPack", + "Microsoft.Net.ComponentGroup.4.8.DeveloperTools", + "Microsoft.Net.ComponentGroup.DevelopmentPrerequisites", + "Microsoft.NetCore.Component.DevelopmentTools", + "Microsoft.NetCore.Component.Runtime.10.0", + "Microsoft.NetCore.Component.Runtime.8.0", + "Microsoft.NetCore.Component.SDK", + "Microsoft.NetCore.Component.Web", + "Microsoft.VisualStudio.Component.AppInsights.Tools", + "Microsoft.VisualStudio.Component.CoreEditor", + "Microsoft.VisualStudio.Component.CppBuildInsights", + "Microsoft.VisualStudio.Component.Debugger.JustInTime", + "Microsoft.VisualStudio.Component.DiagnosticTools", + "Microsoft.VisualStudio.Component.DockerTools", + "Microsoft.VisualStudio.Component.DotNetModelBuilder", + "Microsoft.VisualStudio.Component.EntityFramework", + "Microsoft.VisualStudio.Component.FSharp", + "Microsoft.VisualStudio.Component.FSharp.Desktop", + "Microsoft.VisualStudio.Component.FSharp.WebTemplates", + "Microsoft.VisualStudio.Component.Graphics.Tools", + "Microsoft.VisualStudio.Component.IISExpress", + "Microsoft.VisualStudio.Component.IntelliCode", + "Microsoft.VisualStudio.Component.JavaScript.Diagnostics", + "Microsoft.VisualStudio.Component.JavaScript.TypeScript", + "Microsoft.VisualStudio.Component.ManagedDesktop.Core", + "Microsoft.VisualStudio.Component.ManagedDesktop.Prerequisites", + "Microsoft.VisualStudio.Component.MSODBC.SQL", + "Microsoft.VisualStudio.Component.MSSQL.CMDLnUtils", + "Microsoft.VisualStudio.Component.NuGet", + "Microsoft.VisualStudio.Component.Roslyn.Compiler", + "Microsoft.VisualStudio.Component.Roslyn.LanguageServices", + "Microsoft.VisualStudio.Component.SQL.CLR", + "Microsoft.VisualStudio.Component.SQL.DataSources", + "Microsoft.VisualStudio.Component.SQL.LocalDB.Runtime", + "Microsoft.VisualStudio.Component.SQL.SSDT", + "Microsoft.VisualStudio.Component.TextTemplating", + "Microsoft.VisualStudio.Component.TypeScript.TSServer", + "Microsoft.VisualStudio.Component.VC.ASAN", + "Microsoft.VisualStudio.Component.VC.ATL", + "Microsoft.VisualStudio.Component.VC.CMake.Project", + "Microsoft.VisualStudio.Component.VC.CoreIde", + "Microsoft.VisualStudio.Component.VC.DiagnosticTools", + "Microsoft.VisualStudio.Component.VC.Llvm.Clang", + "Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset", + "Microsoft.VisualStudio.Component.VC.Redist.14.Latest", + "Microsoft.VisualStudio.Component.VC.TestAdapterForBoostTest", + "Microsoft.VisualStudio.Component.VC.TestAdapterForGoogleTest", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "Microsoft.VisualStudio.Component.Vcpkg", + "Microsoft.VisualStudio.Component.VSSDK", + "Microsoft.VisualStudio.Component.Web", + "Microsoft.VisualStudio.Component.WebDeploy", + "Microsoft.VisualStudio.Component.Windows11SDK.22621", + "Microsoft.VisualStudio.Component.Windows11SDK.26100", + "Microsoft.VisualStudio.Component.Windows11Sdk.WindowsPerformanceToolkit", + "Microsoft.VisualStudio.Component.WslDebugging", + "Microsoft.VisualStudio.ComponentGroup.AzureFunctions", + "Microsoft.VisualStudio.ComponentGroup.MSIX.Packaging", + "Microsoft.VisualStudio.ComponentGroup.NativeDesktop.Core", + "Microsoft.VisualStudio.ComponentGroup.NativeDesktop.Llvm.Clang", + "Microsoft.VisualStudio.ComponentGroup.VisualStudioExtension.Prerequisites", + "Microsoft.VisualStudio.ComponentGroup.Web", + "Microsoft.VisualStudio.ComponentGroup.Web.CloudTools", + "Microsoft.VisualStudio.ComponentGroup.WebToolsExtensions", + "Microsoft.VisualStudio.ComponentGroup.WebToolsExtensions.CMake", + "Microsoft.VisualStudio.Workload.CoreEditor", + "Microsoft.VisualStudio.Workload.ManagedDesktop", + "Microsoft.VisualStudio.Workload.NativeDesktop", + "Microsoft.VisualStudio.Workload.NetWeb", + "Microsoft.VisualStudio.Workload.Python", + "Microsoft.VisualStudio.Workload.VisualStudioExtension" + ], + "extensions": [] +} diff --git a/internal/bootstrap/assets/wezterm.lua b/internal/bootstrap/assets/wezterm.lua new file mode 100644 index 0000000..ae23e95 --- /dev/null +++ b/internal/bootstrap/assets/wezterm.lua @@ -0,0 +1,9 @@ +local wezterm = require 'wezterm' + +return { + color_scheme = 'Builtin Solarized Dark', + font = wezterm.font('JetBrainsMono Nerd Font'), + font_size = 11.0, + default_prog = { 'pwsh.exe', '-NoLogo' }, + window_decorations = 'RESIZE', +} diff --git a/internal/bootstrap/assets/winutil-sane-default.json b/internal/bootstrap/assets/winutil-sane-default.json new file mode 100644 index 0000000..e766d50 --- /dev/null +++ b/internal/bootstrap/assets/winutil-sane-default.json @@ -0,0 +1,15 @@ +[ + "WPFTweaksActivity", + "WPFTweaksConsumerFeatures", + "WPFTweaksDisableExplorerAutoDiscovery", + "WPFTweaksWPBT", + "WPFTweaksDVR", + "WPFTweaksDeBloat", + "WPFTweaksLocation", + "WPFTweaksServices", + "WPFTweaksTelemetry", + "WPFTweaksDiskCleanup", + "WPFTweaksDeleteTempFiles", + "WPFTweaksEndTaskOnTaskbar", + "WPFTweaksPowershell7Tele" +] diff --git a/internal/bootstrap/catalog.go b/internal/bootstrap/catalog.go new file mode 100644 index 0000000..460a78e --- /dev/null +++ b/internal/bootstrap/catalog.go @@ -0,0 +1,141 @@ +package bootstrap + +type Classification string + +const ( + ClassHeadless Classification = "headless" + ClassGUI Classification = "gui" +) + +type RebootBehavior string + +const ( + RebootNone RebootBehavior = "none" + RebootPossible RebootBehavior = "possible" +) + +type Package struct { + Name string + Phase Phase + WingetID string + WingetSource string + WingetOverride string + WingetConfigAsset string + Class Classification + RequiresElevation bool + Reboot RebootBehavior + PostInstall string + AI bool +} + +func wingetPackage(name, id string, phase Phase, class Classification) Package { + return Package{ + Name: name, + Phase: phase, + WingetID: id, + WingetSource: "winget", + 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", + }, + 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" { + packages[i].RequiresElevation = true + 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 +} + +func PackagesForPhase(opts Options, phase Phase) []Package { + var selected []Package + for _, pkg := range Catalog() { + if pkg.Phase != phase || (opts.NoAI && pkg.AI) { + continue + } + selected = append(selected, pkg) + } + return selected +} + +// DeferredWindowsTools records intentionally unsupported native workloads in v1. +var DeferredWindowsTools = []string{ + "Webcamoid (no current exact winget manifest)", + "Docker Desktop", "Podman Desktop", "Buildah", "Minikube", "Firecracker", + "QEMU", "virt-manager", "Windows Terminal", "Visual Studio Code", +} diff --git a/internal/bootstrap/catalog_test.go b/internal/bootstrap/catalog_test.go new file mode 100644 index 0000000..4eec460 --- /dev/null +++ b/internal/bootstrap/catalog_test.go @@ -0,0 +1,70 @@ +package bootstrap + +import "testing" + +func TestCatalogContainsExactWingetIDsAndDefersExcludedNativeTools(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) + } + seen[pkg.Name] = pkg + } + for _, name := range []string{ + "Visual Studio Community 2026", + "Android Studio", + "FilePilot", + "PowerShell 7", + "Libre Hardware Monitor", + "Mullvad VPN", + "WireGuard", + "Steam", + "Bruno", + "Figma", + "VLC", + "HandBrake", + "Adobe Acrobat Reader", + "Gpg4win (includes gpgOL)", + "Tor Browser", + "LibreWolf", + "Google Cloud CLI", + "Rustup", + "AWS CLI", + "Azure CLI", + "Rufus", + "Slack", + "Discord", + "Revo Uninstaller", + "lazygit", + "CMake", + "WezTerm", + "Vagrant", + "fnm", + "oh-my-posh", + } { + if _, ok := seen[name]; !ok { + t.Fatalf("missing catalog package %s", name) + } + } + for _, deferred := range DeferredWindowsTools { + if _, ok := seen[deferred]; ok { + t.Fatalf("%s should be deferred, not installed natively", deferred) + } + } + if !seen["Vagrant"].RequiresElevation || seen["Vagrant"].PostInstall == "" { + t.Fatal("Vagrant should carry Hyper-V/elevation metadata") + } + if _, ok := seen["pyenv-win"]; ok { + t.Fatal("pyenv-win is installed through managed host actions, not the winget catalog") + } + if _, ok := seen["gcc"]; ok { + t.Fatal("gcc should not be installed natively; use Visual Studio C/C++ tools") + } + 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) + } +} diff --git a/internal/bootstrap/command.go b/internal/bootstrap/command.go new file mode 100644 index 0000000..0930837 --- /dev/null +++ b/internal/bootstrap/command.go @@ -0,0 +1,70 @@ +package bootstrap + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +type CommandResult struct { + Command string + Stdout string + Stderr string + Err error +} + +func (r CommandResult) CombinedOutput() string { + return strings.TrimSpace(strings.TrimSpace(r.Stdout) + "\n" + strings.TrimSpace(r.Stderr)) +} + +type Runner interface { + Run(ctx context.Context, name string, args ...string) CommandResult +} + +type ExecRunner struct { + Timeout time.Duration +} + +func (r ExecRunner) Run(ctx context.Context, name string, args ...string) CommandResult { + timeout := r.Timeout + if timeout == 0 { + timeout = 30 * time.Minute + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if ctx.Err() == context.DeadlineExceeded { + err = fmt.Errorf("%s timed out after %s", name, timeout) + } + return CommandResult{ + Command: name + " " + strings.Join(args, " "), + Stdout: stdout.String(), + Stderr: stderr.String(), + Err: err, + } +} + +type Issue struct { + Step string + Err error +} + +type Issues []Issue + +func (issues Issues) Err() error { + if len(issues) == 0 { + return nil + } + var parts []string + for _, issue := range issues { + parts = append(parts, fmt.Sprintf("%s: %v", issue.Step, issue.Err)) + } + return fmt.Errorf("%d step(s) failed: %s", len(issues), strings.Join(parts, "; ")) +} diff --git a/internal/bootstrap/command_test.go b/internal/bootstrap/command_test.go new file mode 100644 index 0000000..9f38676 --- /dev/null +++ b/internal/bootstrap/command_test.go @@ -0,0 +1,23 @@ +package bootstrap + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestExecRunnerCapturesOutputAndTimeout(t *testing.T) { + result := ExecRunner{}.Run(context.Background(), "cmd.exe", "/C", "echo ok") + if result.Err != nil { + t.Fatal(result.Err) + } + if !strings.Contains(result.Stdout, "ok") || !strings.Contains(result.Command, "cmd.exe /C echo ok") { + t.Fatalf("result = %#v", result) + } + + result = ExecRunner{Timeout: time.Millisecond}.Run(context.Background(), "powershell.exe", "-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Seconds 5") + if result.Err == nil || !strings.Contains(result.Err.Error(), "timed out") { + t.Fatalf("result error = %v, want timeout", result.Err) + } +} diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go new file mode 100644 index 0000000..6c591cd --- /dev/null +++ b/internal/bootstrap/config.go @@ -0,0 +1,151 @@ +package bootstrap + +import ( + "bytes" + "context" + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +//go:embed assets/* +var managedAssets embed.FS + +type UserPaths struct { + Home string + LocalAppData string + Documents string +} + +func DefaultUserPaths() (UserPaths, error) { + home, err := os.UserHomeDir() + if err != nil { + return UserPaths{}, err + } + local := os.Getenv("LOCALAPPDATA") + if local == "" { + local = filepath.Join(home, "AppData", "Local") + } + documents := filepath.Join(home, "Documents") + return UserPaths{Home: home, LocalAppData: local, Documents: documents}, nil +} + +type AssetDeployment struct { + Name string + Target string + Backup string + Changed bool +} + +func ConfigTargets(paths UserPaths) map[string]string { + return map[string]string{ + "assets/wezterm.lua": filepath.Join(paths.Home, ".wezterm.lua"), + "assets/Microsoft.PowerShell_profile.ps1": filepath.Join(paths.Documents, "PowerShell", "Microsoft.PowerShell_profile.ps1"), + "assets/jmr.omp.json": filepath.Join(paths.Home, ".config", "oh-my-posh", "jmr.omp.json"), + } +} + +func DeployConfigAssets(paths UserPaths) ([]AssetDeployment, error) { + targets := ConfigTargets(paths) + order := []string{"assets/wezterm.lua", "assets/Microsoft.PowerShell_profile.ps1", "assets/jmr.omp.json"} + deployments := make([]AssetDeployment, 0, len(order)) + for _, name := range order { + data, err := fs.ReadFile(managedAssets, name) + if err != nil { + return deployments, err + } + deployment, err := DeployManagedFile(name, targets[name], data) + if err != nil { + return deployments, err + } + deployments = append(deployments, deployment) + } + return deployments, nil +} + +func DeployManagedFile(name, target string, data []byte) (AssetDeployment, error) { + deployment := AssetDeployment{Name: name, Target: target} + existing, err := os.ReadFile(target) + if err == nil { + if bytes.Equal(existing, data) { + return deployment, nil + } + deployment.Backup = NextBackupPath(target) + if err := os.Rename(target, deployment.Backup); err != nil { + return deployment, fmt.Errorf("back up %s: %w", target, err) + } + } else if !os.IsNotExist(err) { + return deployment, fmt.Errorf("read %s: %w", target, err) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return deployment, err + } + if err := os.WriteFile(target, data, 0o644); err != nil { + return deployment, fmt.Errorf("write %s: %w", target, err) + } + deployment.Changed = true + return deployment, nil +} + +func NextBackupPath(target string) string { + base := target + ".bak" + if _, err := os.Stat(base); os.IsNotExist(err) { + return base + } + for n := 1; ; n++ { + candidate := fmt.Sprintf("%s.%d", base, n) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + } +} + +func ConfigureNeovim(ctx context.Context, runner Runner, paths UserPaths) Issue { + target := filepath.Join(paths.LocalAppData, "nvim") + if _, err := os.Stat(target); err == nil { + remote := runner.Run(ctx, "git", "-C", target, "config", "--get", "remote.origin.url") + if remote.Err == nil && strings.Contains(strings.ToLower(remote.Stdout), "jmr-dev/nvim-config") { + return Issue{} + } + backup := NextBackupPath(target) + if err := os.Rename(target, backup); err != nil { + return Issue{Step: "back up Neovim config", Err: err} + } + } + result := runner.Run(ctx, "git", "clone", "https://github.com/JMR-dev/nvim-config.git", target) + if result.Err != nil { + return Issue{Step: "clone Neovim config", Err: fmt.Errorf("%w: %s", result.Err, result.CombinedOutput())} + } + return Issue{} +} + +func IsEmptyIssue(issue Issue) bool { + return issue.Step == "" && issue.Err == nil +} + +func FileSummary(deployments []AssetDeployment) []string { + var summary []string + for _, deployment := range deployments { + switch { + case !deployment.Changed: + summary = append(summary, deployment.Target+" (already managed)") + case deployment.Backup != "": + summary = append(summary, deployment.Target+" (backed up to "+deployment.Backup+")") + default: + summary = append(summary, deployment.Target+" (written)") + } + } + return summary +} + +func pathContainsPathEntry(value, entry string) bool { + for _, part := range strings.Split(value, string(os.PathListSeparator)) { + if strings.EqualFold(filepath.Clean(part), filepath.Clean(entry)) { + return true + } + } + return false +} diff --git a/internal/bootstrap/config_test.go b/internal/bootstrap/config_test.go new file mode 100644 index 0000000..3652646 --- /dev/null +++ b/internal/bootstrap/config_test.go @@ -0,0 +1,225 @@ +package bootstrap + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDeployManagedFileBacksUpExistingUnmanagedFile(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "profile.ps1") + if err := os.WriteFile(target, []byte("custom"), 0o644); err != nil { + t.Fatal(err) + } + deployment, err := DeployManagedFile("profile", target, []byte("managed")) + if err != nil { + t.Fatal(err) + } + if !deployment.Changed || deployment.Backup == "" { + t.Fatalf("deployment = %#v, want changed with backup", deployment) + } + if got, _ := os.ReadFile(target); string(got) != "managed" { + t.Fatalf("target = %q", got) + } + if got, _ := os.ReadFile(deployment.Backup); string(got) != "custom" { + t.Fatalf("backup = %q", got) + } + + deployment, err = DeployManagedFile("profile", target, []byte("managed")) + if err != nil { + t.Fatal(err) + } + if deployment.Changed { + t.Fatal("identical managed file should be left untouched") + } +} + +func TestDeployConfigAssetsWritesExpectedTargets(t *testing.T) { + root := t.TempDir() + paths := UserPaths{ + Home: filepath.Join(root, "home"), + LocalAppData: filepath.Join(root, "local"), + Documents: filepath.Join(root, "docs"), + } + deployments, err := DeployConfigAssets(paths) + if err != nil { + t.Fatal(err) + } + if len(deployments) != 3 { + t.Fatalf("deployments = %d, want 3", len(deployments)) + } + profile := filepath.Join(paths.Documents, "PowerShell", "Microsoft.PowerShell_profile.ps1") + data, err := os.ReadFile(profile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "oh-my-posh") || !strings.Contains(string(data), "fnm env") { + t.Fatalf("profile missing expected initialization: %s", data) + } +} + +func TestDefaultUserPathsUsesHomeAndDocuments(t *testing.T) { + paths, err := DefaultUserPaths() + if err != nil { + t.Fatal(err) + } + if paths.Home == "" || paths.LocalAppData == "" || paths.Documents == "" { + t.Fatalf("paths should be populated: %#v", paths) + } + if !strings.HasSuffix(paths.Documents, "Documents") { + t.Fatalf("documents path = %q, want Documents suffix", paths.Documents) + } +} + +func TestDefaultUserPathsFallsBackWhenLocalAppDataMissing(t *testing.T) { + t.Setenv("LOCALAPPDATA", "") + paths, err := DefaultUserPaths() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(paths.Home, "AppData", "Local") + if paths.LocalAppData != want { + t.Fatalf("local app data = %q, want %q", paths.LocalAppData, want) + } +} + +func TestDeployConfigAssetsReportsDeploymentError(t *testing.T) { + root := t.TempDir() + homeFile := filepath.Join(root, "home") + if err := os.WriteFile(homeFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + _, err := DeployConfigAssets(UserPaths{ + Home: homeFile, + LocalAppData: filepath.Join(root, "local"), + Documents: filepath.Join(root, "docs"), + }) + if err == nil { + t.Fatal("expected deployment error when home path is a file") + } +} + +func TestDeployManagedFileReportsReadErrorForDirectoryTarget(t *testing.T) { + target := t.TempDir() + if _, err := DeployManagedFile("directory", target, []byte("managed")); err == nil { + t.Fatal("expected read error for directory target") + } +} + +func TestDeployManagedFileReportsDirectoryCreationError(t *testing.T) { + root := t.TempDir() + parentFile := filepath.Join(root, "parent") + if err := os.WriteFile(parentFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + target := filepath.Join(parentFile, "profile.ps1") + if _, err := DeployManagedFile("profile", target, []byte("managed")); err == nil { + t.Fatal("expected directory creation error") + } +} + +func TestNextBackupPathUsesNumberedSuffix(t *testing.T) { + target := filepath.Join(t.TempDir(), "profile.ps1") + if err := os.WriteFile(target+".bak", []byte("backup"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target+".bak.1", []byte("backup"), 0o644); err != nil { + t.Fatal(err) + } + if got, want := NextBackupPath(target), target+".bak.2"; got != want { + t.Fatalf("backup path = %q, want %q", got, want) + } +} + +func TestConfigureNeovimLeavesManagedCheckoutAlone(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "local", "nvim") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("git", "-C", target, "config", "--get", "remote.origin.url"): {Stdout: "https://github.com/JMR-dev/nvim-config.git\n"}, + }, defaultErr: true} + issue := ConfigureNeovim(context.Background(), runner, UserPaths{LocalAppData: filepath.Join(root, "local")}) + if !IsEmptyIssue(issue) { + t.Fatalf("issue = %#v", issue) + } + if runner.called("git", "clone", "https://github.com/JMR-dev/nvim-config.git", target) { + t.Fatal("managed checkout should not be cloned over") + } +} + +func TestConfigureNeovimClonesMissingConfig(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "local", "nvim") + runner := &fakeRunner{} + issue := ConfigureNeovim(context.Background(), runner, UserPaths{LocalAppData: filepath.Join(root, "local")}) + if !IsEmptyIssue(issue) { + t.Fatalf("issue = %#v", issue) + } + if !runner.called("git", "clone", "https://github.com/JMR-dev/nvim-config.git", target) { + t.Fatal("expected missing Neovim config to be cloned") + } +} + +func TestConfigureNeovimBacksUpUnmanagedConfig(t *testing.T) { + root := t.TempDir() + local := filepath.Join(root, "local") + target := filepath.Join(local, "nvim") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("git", "-C", target, "config", "--get", "remote.origin.url"): {Err: os.ErrNotExist}, + }} + issue := ConfigureNeovim(context.Background(), runner, UserPaths{LocalAppData: local}) + if !IsEmptyIssue(issue) { + t.Fatalf("issue = %#v", issue) + } + if _, err := os.Stat(target + ".bak"); err != nil { + t.Fatalf("expected unmanaged config backup: %v", err) + } + if !runner.called("git", "clone", "https://github.com/JMR-dev/nvim-config.git", target) { + t.Fatal("expected clone after backing up unmanaged config") + } +} + +func TestConfigureNeovimReportsCloneFailure(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "local", "nvim") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("git", "clone", "https://github.com/JMR-dev/nvim-config.git", target): {Err: os.ErrPermission, Stderr: "denied"}, + }} + issue := ConfigureNeovim(context.Background(), runner, UserPaths{LocalAppData: filepath.Join(root, "local")}) + if IsEmptyIssue(issue) || !strings.Contains(issue.Err.Error(), "denied") { + t.Fatalf("issue = %#v, want clone failure", issue) + } +} + +func TestFileSummaryCoversAllDeploymentStatuses(t *testing.T) { + summary := FileSummary([]AssetDeployment{ + {Target: "same"}, + {Target: "new", Changed: true}, + {Target: "backup", Changed: true, Backup: "backup.bak"}, + }) + joined := strings.Join(summary, "\n") + for _, want := range []string{"already managed", "written", "backed up"} { + if !strings.Contains(joined, want) { + t.Fatalf("summary missing %q: %s", want, joined) + } + } +} + +func TestPathContainsPathEntryUsesCleanCaseInsensitiveComparison(t *testing.T) { + entry := filepath.Join("C:\\", "Tools", "bin") + value := strings.Join([]string{filepath.Join("C:\\", "Other"), strings.ToLower(entry) + string(os.PathSeparator)}, string(os.PathListSeparator)) + if !pathContainsPathEntry(value, entry) { + t.Fatalf("%q should contain %q", value, entry) + } + if pathContainsPathEntry(value, filepath.Join("C:\\", "Missing")) { + t.Fatal("unexpected missing path match") + } +} diff --git a/internal/bootstrap/hyperv.go b/internal/bootstrap/hyperv.go new file mode 100644 index 0000000..b2bc656 --- /dev/null +++ b/internal/bootstrap/hyperv.go @@ -0,0 +1,61 @@ +package bootstrap + +import ( + "context" + "fmt" + "strings" +) + +type HyperVStatus struct { + Edition string + Supported bool + Enabled bool + RestartRequired bool +} + +func DetectHyperV(ctx context.Context, runner Runner) (HyperVStatus, error) { + script := `$edition=(Get-ComputerInfo -Property WindowsProductName).WindowsProductName; ` + + `$supported=($edition -match 'Pro|Enterprise|Education'); ` + + `$enabled=$false; if ($supported) { $feature=Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue; $enabled=($feature.State -eq 'Enabled') }; ` + + `$reboot=(Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'); ` + + `Write-Output ('Edition='+$edition); Write-Output ('Supported='+$supported); Write-Output ('Enabled='+$enabled); Write-Output ('RestartRequired='+$reboot)` + result := runner.Run(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script) + if result.Err != nil { + return HyperVStatus{}, fmt.Errorf("detect Hyper-V: %w: %s", result.Err, result.CombinedOutput()) + } + values := parseValues(result.Stdout) + return HyperVStatus{ + Edition: values["Edition"], + Supported: strings.EqualFold(values["Supported"], "True"), + Enabled: strings.EqualFold(values["Enabled"], "True"), + RestartRequired: strings.EqualFold(values["RestartRequired"], "True"), + }, nil +} + +func EnableHyperV(ctx context.Context, runner Runner, status HyperVStatus) (string, error) { + if !status.Supported { + return "Vagrant installed, but Hyper-V provider setup is unavailable on Windows " + status.Edition + "; no alternate provider is substituted.", nil + } + if status.RestartRequired { + return "A pending Windows restart must be completed before Hyper-V-backed Vagrant setup can continue.", nil + } + if status.Enabled { + return "Hyper-V is enabled for the Vagrant provider.", nil + } + 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()) + } + return "Hyper-V features were enabled; restart Windows and rerun to complete Vagrant provider setup.", nil +} + +func parseValues(output string) map[string]string { + values := make(map[string]string) + for _, line := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if ok { + values[key] = value + } + } + return values +} diff --git a/internal/bootstrap/hyperv_test.go b/internal/bootstrap/hyperv_test.go new file mode 100644 index 0000000..26ae76a --- /dev/null +++ b/internal/bootstrap/hyperv_test.go @@ -0,0 +1,75 @@ +package bootstrap + +import ( + "context" + "errors" + "strings" + "testing" +) + +type staticRunner struct { + result CommandResult +} + +func (r staticRunner) Run(ctx context.Context, name string, args ...string) CommandResult { + return r.result +} + +func TestDetectHyperVParsesStatusAndReportsErrors(t *testing.T) { + status, err := DetectHyperV(context.Background(), staticRunner{result: CommandResult{ + Stdout: "Edition=Windows 11 Pro\r\nSupported=True\r\nEnabled=True\r\nRestartRequired=True\r\nignored\r\n", + }}) + if err != nil { + t.Fatal(err) + } + if status.Edition != "Windows 11 Pro" || !status.Supported || !status.Enabled || !status.RestartRequired { + t.Fatalf("status = %#v", status) + } + + _, err = DetectHyperV(context.Background(), staticRunner{result: CommandResult{Err: errors.New("blocked"), Stderr: "denied"}}) + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("error = %v, want detect failure with output", err) + } +} + +func TestEnableHyperVCoversAllStates(t *testing.T) { + cases := []struct { + name string + status HyperVStatus + want string + }{ + {"unsupported", HyperVStatus{Edition: "Windows 11 Home"}, "unavailable"}, + {"restart", HyperVStatus{Supported: true, RestartRequired: true}, "pending Windows restart"}, + {"enabled", HyperVStatus{Supported: true, Enabled: true}, "enabled"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + notice, err := EnableHyperV(context.Background(), &fakeRunner{defaultErr: true}, tc.status) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(notice, tc.want) { + t.Fatalf("notice = %q, want %q", notice, tc.want) + } + }) + } + + runner := &fakeRunner{} + notice, err := EnableHyperV(context.Background(), runner, HyperVStatus{Supported: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(notice, "features were enabled") { + t.Fatalf("notice = %q", notice) + } + if !runner.called("dism.exe", "/Online", "/Enable-Feature", "/FeatureName:Microsoft-Hyper-V-All", "/All", "/NoRestart") { + t.Fatal("expected DISM enable command") + } + + failRunner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("dism.exe", "/Online", "/Enable-Feature", "/FeatureName:Microsoft-Hyper-V-All", "/All", "/NoRestart"): {Err: errors.New("dism failed"), Stderr: "feature missing"}, + }} + if _, err := EnableHyperV(context.Background(), failRunner, HyperVStatus{Supported: true}); err == nil || !strings.Contains(err.Error(), "feature missing") { + t.Fatalf("error = %v, want DISM failure", err) + } +} diff --git a/internal/bootstrap/options.go b/internal/bootstrap/options.go new file mode 100644 index 0000000..4c7e173 --- /dev/null +++ b/internal/bootstrap/options.go @@ -0,0 +1,82 @@ +package bootstrap + +import ( + "flag" + "fmt" + "io" +) + +// Phase identifies a resumable unit of Windows provisioning work. +type Phase string + +const ( + PhaseOS Phase = "os" + PhaseHost Phase = "host" + PhaseCustom Phase = "custom" + PhaseConfig Phase = "config" + PhaseWSL Phase = "wsl" +) + +var PhaseOrder = []Phase{PhaseOS, PhaseHost, PhaseCustom, PhaseConfig, PhaseWSL} + +type Options struct { + Only Phase + NoAI bool + NoWSL bool + Yes bool + DryRun bool + Headless bool + LinuxReleaseRepo string +} + +func ParseOptions(args []string, stderr io.Writer) (Options, error) { + var opts Options + var only string + fs := flag.NewFlagSet("bootstrap_windows_env", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.StringVar(&only, "only", "", "run one phase: os, host, custom, config, or wsl") + fs.BoolVar(&opts.NoAI, "no-ai", false, "omit native AI CLIs and pass --no-ai to the Fedora guest") + 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") + if err := fs.Parse(args); err != nil { + return Options{}, err + } + if fs.NArg() != 0 { + return Options{}, fmt.Errorf("unexpected arguments: %v", fs.Args()) + } + if only != "" { + opts.Only = Phase(only) + switch opts.Only { + case PhaseOS, PhaseHost, PhaseCustom, PhaseConfig, PhaseWSL: + default: + return Options{}, fmt.Errorf("invalid --only value %q: use os, host, custom, config, or wsl", only) + } + } + 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") + } + return opts, nil +} + +func (o Options) Includes(phase Phase) bool { + if o.NoWSL && phase == PhaseWSL { + return false + } + return o.Only == "" || o.Only == phase +} + +func SelectedPhases(opts Options) []Phase { + phases := make([]Phase, 0, len(PhaseOrder)) + for _, phase := range PhaseOrder { + if opts.Includes(phase) { + phases = append(phases, phase) + } + } + return phases +} diff --git a/internal/bootstrap/options_test.go b/internal/bootstrap/options_test.go new file mode 100644 index 0000000..02695c0 --- /dev/null +++ b/internal/bootstrap/options_test.go @@ -0,0 +1,63 @@ +package bootstrap + +import ( + "bytes" + "testing" +) + +func TestSelectedPhasesHonorsOnlyAndNoWSL(t *testing.T) { + defaultPhases := SelectedPhases(Options{}) + if len(defaultPhases) == 0 || defaultPhases[0] != PhaseOS { + t.Fatalf("default phases = %v, want OS phase first", defaultPhases) + } + + opts, err := ParseOptions([]string{"--only", "custom", "--no-ai"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + phases := SelectedPhases(opts) + if len(phases) != 1 || phases[0] != PhaseCustom { + t.Fatalf("phases = %v, want custom only", phases) + } + if !opts.NoAI { + t.Fatal("--no-ai was not parsed") + } + + opts, err = ParseOptions([]string{"--no-wsl"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + for _, phase := range SelectedPhases(opts) { + if phase == PhaseWSL { + t.Fatal("WSL phase was selected with --no-wsl") + } + } +} + +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") + } + if _, err := ParseOptions([]string{"unexpected"}, &bytes.Buffer{}); err == nil { + t.Fatal("expected unexpected positional argument to fail") + } + if _, err := ParseOptions([]string{"--not-a-real-flag"}, &bytes.Buffer{}); err == nil { + t.Fatal("expected flag parse failure") + } +} + +func TestParseOptionsAcceptsHeadlessWithDryRunAndCustomRepo(t *testing.T) { + opts, err := ParseOptions([]string{"--headless", "--dry-run", "--linux-release-repo", "owner/repo"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if !opts.Headless || !opts.DryRun || opts.LinuxReleaseRepo != "owner/repo" { + t.Fatalf("opts = %#v", opts) + } +} diff --git a/internal/bootstrap/osconfig.go b/internal/bootstrap/osconfig.go new file mode 100644 index 0000000..7ffeddd --- /dev/null +++ b/internal/bootstrap/osconfig.go @@ -0,0 +1,393 @@ +package bootstrap + +import ( + "context" + "fmt" + "strings" +) + +type OSPhaseResult struct { + Notices []string + Issues Issues + Stop bool +} + +func RunOSPhase(ctx context.Context, runner Runner) OSPhaseResult { + var result OSPhaseResult + if issue := runOSCommand(ctx, runner, "install Windows updates", WindowsUpdateCommand()); issue.Err != nil { + result.Issues = append(result.Issues, issue) + } + reboot, err := DetectWindowsRebootRequired(ctx, runner) + if err != nil { + result.Issues = append(result.Issues, Issue{Step: "detect pending reboot", Err: err}) + } else if reboot { + result.Notices = append(result.Notices, "Windows Update installed changes that require a reboot. Restart Windows, then rerun this bootstrapper before package installation.") + result.Stop = true + return result + } + if issue := runOSCommand(ctx, runner, "create restore point before OS configuration", RestorePointCommand("bootstrap_windows_env: before OS configuration")); issue.Err != nil { + result.Issues = append(result.Issues, issue) + result.Stop = true + return result + } + if issue := runOSCommand(ctx, runner, "run Chris Titus Tech WinUtil", WinUtilCommand()); issue.Err != nil { + result.Issues = append(result.Issues, issue) + } + for _, step := range []struct { + name string + cmd CommandSpec + fatal bool + }{ + {"apply power settings", PowerPolicyCommand(), false}, + {"enable Developer Mode and Windows sudo", DeveloperModeCommand(), false}, + {"apply Windows Update policy", WindowsUpdatePolicyCommand(), false}, + {"apply Explorer and theme settings", ExplorerThemeCommand(), false}, + {"set Vivaldi default browser associations", VivaldiDefaultBrowserCommand(), false}, + {"update Microsoft Store apps", StoreUpdateCommand(), false}, + {"apply taskbar pins", TaskbarCommand(), false}, + {"create restore point before package installation", RestorePointCommand("bootstrap_windows_env: before package installation"), true}, + } { + if issue := runOSCommand(ctx, runner, step.name, step.cmd); issue.Err != nil { + result.Issues = append(result.Issues, issue) + if step.fatal { + result.Stop = true + return result + } + } + } + return result +} + +func runOSCommand(ctx context.Context, runner Runner, step string, cmd CommandSpec) Issue { + res := runner.Run(ctx, cmd.Name, cmd.Args...) + if res.Err != nil { + return Issue{Step: step, Err: fmt.Errorf("%w: %s", res.Err, res.CombinedOutput())} + } + return Issue{} +} + +func WindowsUpdateCommand() CommandSpec { + return powerShell(`$ErrorActionPreference='Stop' +$serviceManager = New-Object -ComObject Microsoft.Update.ServiceManager +try { + $serviceManager.ClientApplicationID = 'bootstrap_windows_env' + $serviceManager.AddService2('7971f918-a847-4430-9279-4a52d1efe18d', 7, '') | Out-Null + Write-Output 'Microsoft Update enabled for other Microsoft products.' +} catch { + Write-Output "Microsoft Update service registration skipped: $($_.Exception.Message)" +} +$session = New-Object -ComObject Microsoft.Update.Session +$session.ClientApplicationID = 'bootstrap_windows_env' +$searcher = $session.CreateUpdateSearcher() +$search = $searcher.Search("IsInstalled=0 and IsHidden=0") +$updates = New-Object -ComObject Microsoft.Update.UpdateColl +for ($i = 0; $i -lt $search.Updates.Count; $i++) { + $update = $search.Updates.Item($i) + if ($update.InstallationBehavior.CanRequestUserInput) { + Write-Output "Skipping interactive update: $($update.Title)" + continue + } + if (-not $update.EulaAccepted) { + $update.AcceptEula() + } + [void]$updates.Add($update) +} +if ($updates.Count -eq 0) { + Write-Output 'No applicable Windows updates found.' + exit 0 +} +Write-Output "Downloading $($updates.Count) Windows update(s)." +$downloader = $session.CreateUpdateDownloader() +$downloader.Updates = $updates +$downloadResult = $downloader.Download() +Write-Output "Windows Update download result code: $($downloadResult.ResultCode)." +if ($downloadResult.ResultCode -ne 2) { + throw "Windows Update download did not fully succeed; result code: $($downloadResult.ResultCode)" +} +Write-Output "Installing $($updates.Count) Windows update(s)." +$installer = $session.CreateUpdateInstaller() +$installer.Updates = $updates +$installer.ForceQuiet = $true +$installResult = $installer.Install() +Write-Output "Windows Update result code: $($installResult.ResultCode); reboot required: $($installResult.RebootRequired)." +if ($installResult.ResultCode -ne 2) { + throw "Windows Update install did not fully succeed; result code: $($installResult.ResultCode)" +} +exit 0`) +} + +func DetectWindowsRebootRequired(ctx context.Context, runner Runner) (bool, error) { + cmd := powerShell(`$paths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' +) +$pending = $false +foreach ($path in $paths) { + if (Test-Path $path) { $pending = $true } +} +$sessionManager = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue +if ($sessionManager.PendingFileRenameOperations) { $pending = $true } +if ($pending) { 'true' } else { 'false' }`) + res := runner.Run(ctx, cmd.Name, cmd.Args...) + if res.Err != nil { + return false, fmt.Errorf("%w: %s", res.Err, res.CombinedOutput()) + } + return containsLine(res.Stdout, "true"), nil +} + +func RestorePointCommand(description string) CommandSpec { + return powerShell(fmt.Sprintf(`$ErrorActionPreference='Stop' +try { Enable-ComputerRestore -Drive "$env:SystemDrive\" -ErrorAction SilentlyContinue } catch {} +$restorePolicy = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' +New-Item -Path $restorePolicy -Force | Out-Null +Set-ItemProperty -Path $restorePolicy -Name SystemRestorePointCreationFrequency -Type DWord -Value 0 +Checkpoint-Computer -Description %q -RestorePointType 'MODIFY_SETTINGS' +Write-Output 'Restore point created: %s'`, description, description)) +} + +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`) +} + +func PowerPolicyCommand() CommandSpec { + return 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" + } +} +$stateDir = Join-Path $env:ProgramData 'bootstrap_windows_env' +New-Item -ItemType Directory -Force -Path $stateDir | Out-Null +$policyScript = Join-Path $stateDir 'Apply-PowerPolicy.ps1' +@' +$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" + } +} +$isLaptop = @(Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue).Count -gt 0 +$onBattery = $false +if ($isLaptop) { + $batteries = @(Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue) + $onBattery = ($batteries | Where-Object { $_.BatteryStatus -in 1, 4, 5 }).Count -gt 0 +} +if ($isLaptop -and $onBattery) { + Invoke-Native 'powercfg.exe' @('/setactive', 'SCHEME_MAX') + $lockSeconds = 120 +} else { + Invoke-Native 'powercfg.exe' @('/setactive', 'SCHEME_MIN') + $lockSeconds = 300 +} +New-Item -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Force | Out-Null +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name InactivityTimeoutSecs -Type DWord -Value $lockSeconds +'@ | Set-Content -Path $policyScript -Encoding UTF8 +Invoke-Native 'powercfg.exe' @('/hibernate', 'on') +New-Item -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FlyoutMenuSettings' -Force | Out-Null +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FlyoutMenuSettings' -Name ShowHibernateOption -Type DWord -Value 1 +foreach ($scheme in @('SCHEME_MIN', 'SCHEME_MAX')) { + Invoke-Native 'powercfg.exe' @('/setacvalueindex', $scheme, 'SUB_VIDEO', 'VIDEOIDLE', '900') + Invoke-Native 'powercfg.exe' @('/setdcvalueindex', $scheme, 'SUB_VIDEO', 'VIDEOIDLE', '300') + Invoke-Native 'powercfg.exe' @('/setacvalueindex', $scheme, 'SUB_SLEEP', 'STANDBYIDLE', '0') + Invoke-Native 'powercfg.exe' @('/setdcvalueindex', $scheme, 'SUB_SLEEP', 'STANDBYIDLE', '900') + Invoke-Native 'powercfg.exe' @('/setacvalueindex', $scheme, 'SUB_BUTTONS', 'LIDACTION', '0') + Invoke-Native 'powercfg.exe' @('/setdcvalueindex', $scheme, 'SUB_BUTTONS', 'LIDACTION', '1') + Invoke-Native 'powercfg.exe' @('/setacvalueindex', $scheme, 'SUB_BUTTONS', 'PBUTTONACTION', '3') + Invoke-Native 'powercfg.exe' @('/setdcvalueindex', $scheme, 'SUB_BUTTONS', 'PBUTTONACTION', '3') + Invoke-Native 'powercfg.exe' @('/setacvalueindex', $scheme, 'SUB_BUTTONS', 'SBUTTONACTION', '1') + Invoke-Native 'powercfg.exe' @('/setdcvalueindex', $scheme, 'SUB_BUTTONS', 'SBUTTONACTION', '1') +} +Invoke-Native 'powershell.exe' @('-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $policyScript) +$taskArg = '-NoLogo -NoProfile -ExecutionPolicy Bypass -File "' + $policyScript + '"' +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $taskArg +$logonTrigger = New-ScheduledTaskTrigger -AtLogOn +$repeatTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650) +Register-ScheduledTask -TaskName 'BootstrapWindowsEnv Power Policy Refresh' -Action $action -Trigger @($logonTrigger, $repeatTrigger) -RunLevel Highest -Force | Out-Null`) +} + +func WindowsUpdatePolicyCommand() CommandSpec { + return powerShell(`$ErrorActionPreference='Stop' +$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' +$au = Join-Path $wu 'AU' +New-Item -Path $wu -Force | Out-Null +New-Item -Path $au -Force | Out-Null +Set-ItemProperty -Path $wu -Name DeferFeatureUpdates -Type DWord -Value 1 +Set-ItemProperty -Path $wu -Name DeferFeatureUpdatesPeriodInDays -Type DWord -Value 90 +Set-ItemProperty -Path $wu -Name BranchReadinessLevel -Type DWord -Value 16 +Set-ItemProperty -Path $wu -Name SetActiveHours -Type DWord -Value 1 +Set-ItemProperty -Path $wu -Name ActiveHoursStart -Type DWord -Value 6 +Set-ItemProperty -Path $wu -Name ActiveHoursEnd -Type DWord -Value 22 +Set-ItemProperty -Path $au -Name AllowMUUpdateService -Type DWord -Value 1 +$ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' +New-Item -Path $ux -Force | Out-Null +Set-ItemProperty -Path $ux -Name ActiveHoursStart -Type DWord -Value 6 +Set-ItemProperty -Path $ux -Name ActiveHoursEnd -Type DWord -Value 22`) +} + +func DeveloperModeCommand() CommandSpec { + return powerShell(`$ErrorActionPreference='Stop' +$unlock = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' +New-Item -Path $unlock -Force | Out-Null +Set-ItemProperty -Path $unlock -Name AllowDevelopmentWithoutDevLicense -Type DWord -Value 1 +Set-ItemProperty -Path $unlock -Name AllowAllTrustedApps -Type DWord -Value 1 +$devSettings = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\DeveloperSettings' +New-Item -Path $devSettings -Force | Out-Null +Set-ItemProperty -Path $devSettings -Name EnableSudo -Type DWord -Value 1 +Set-ItemProperty -Path $devSettings -Name SudoMode -Type DWord -Value 1 +if (Get-Command sudo.exe -ErrorAction SilentlyContinue) { + sudo.exe config --enable normal + if ($LASTEXITCODE -ne 0) { + throw "sudo.exe config failed with exit code $LASTEXITCODE" + } +} else { + Write-Output 'sudo.exe is not present on this Windows build; registry preference was written for builds that support Windows sudo.' +}`) +} + +func ExplorerThemeCommand() CommandSpec { + return powerShell(`$ErrorActionPreference='Stop' +$classic = 'HKCU:\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32' +New-Item -Path $classic -Force | Out-Null +Set-Item -Path $classic -Value '' +$theme = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' +New-Item -Path $theme -Force | Out-Null +Set-ItemProperty -Path $theme -Name AppsUseLightTheme -Type DWord -Value 0 +Set-ItemProperty -Path $theme -Name SystemUsesLightTheme -Type DWord -Value 0 +Stop-Process -Name explorer -Force -ErrorAction SilentlyContinue`) +} + +func VivaldiDefaultBrowserCommand() CommandSpec { + return powerShell(`$ErrorActionPreference='Stop' +$progId = 'VivaldiHTM' +$htmlProgId = $progId +$roots = @('HKLM:\SOFTWARE\Clients\StartMenuInternet', 'HKCU:\SOFTWARE\Clients\StartMenuInternet') +foreach ($root in $roots) { + if (-not (Test-Path $root)) { continue } + Get-ChildItem $root -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -like '*Vivaldi*' } | ForEach-Object { + $cap = Join-Path $_.PSPath 'Capabilities' + $url = Join-Path $cap 'URLAssociations' + $file = Join-Path $cap 'FileAssociations' + if (Test-Path $url) { + $props = Get-ItemProperty $url + if ($props.http) { $script:progId = $props.http } + } + if (Test-Path $file) { + $props = Get-ItemProperty $file + if ($props.'.html') { $script:htmlProgId = $props.'.html' } + } + } +} +$xml = Join-Path $env:ProgramData 'bootstrap_windows_env\DefaultAssociations.xml' +New-Item -ItemType Directory -Force -Path (Split-Path $xml) | Out-Null +@" + + + + + + + +"@ | Set-Content -Path $xml -Encoding UTF8 +dism.exe /Online /Import-DefaultAppAssociations:$xml +if ($LASTEXITCODE -ne 0) { + throw "dism.exe default association import failed with exit code $LASTEXITCODE" +} +Write-Output 'Imported Vivaldi default browser associations. Windows may still require user confirmation for an existing signed-in profile.'`) +} + +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" +} +try { + $mgr = Get-CimInstance -Namespace 'Root\cimv2\mdm\dmmap' -ClassName 'MDM_EnterpriseModernAppManagement_AppManagement01' -ErrorAction Stop + Invoke-CimMethod -InputObject $mgr -MethodName UpdateScanMethod | Out-Null +} catch { + Write-Output "Store update scan fallback skipped: $($_.Exception.Message)" +} +try { Start-Process wsreset.exe -ArgumentList '-i' -WindowStyle Hidden -Wait } catch { Write-Output "wsreset fallback skipped: $($_.Exception.Message)" }`) +} + +func TaskbarCommand() CommandSpec { + return powerShell(`$ErrorActionPreference='Stop' +function Invoke-TaskbarVerb { + param([string]$Path, [string]$Pattern) + if (-not (Test-Path $Path)) { return $false } + $shell = New-Object -ComObject Shell.Application + $folder = $shell.Namespace((Split-Path $Path)) + $item = $folder.ParseName((Split-Path $Path -Leaf)) + if (-not $item) { return $false } + $verb = $item.Verbs() | Where-Object { ($_.Name -replace '&','') -match $Pattern } | Select-Object -First 1 + if ($verb) { $verb.DoIt(); return $true } + return $false +} +$failures = New-Object System.Collections.Generic.List[string] +$edgePaths = @( + "$env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe", + "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe" +) +foreach ($edge in $edgePaths) { + if ((Test-Path $edge) -and -not (Invoke-TaskbarVerb -Path $edge -Pattern 'Unpin from taskbar|Unpin from Taskbar')) { + $failures.Add("could not unpin Edge from $edge") + } +} +if (-not (Invoke-TaskbarVerb -Path "$env:windir\System32\Taskmgr.exe" -Pattern 'Pin to taskbar|Pin to Taskbar')) { + $failures.Add('could not pin Task Manager to taskbar') +} +$settingsShortcut = Join-Path $env:ProgramData 'bootstrap_windows_env\Settings.lnk' +New-Item -ItemType Directory -Force -Path (Split-Path $settingsShortcut) | Out-Null +$wsh = New-Object -ComObject WScript.Shell +$shortcut = $wsh.CreateShortcut($settingsShortcut) +$shortcut.TargetPath = 'explorer.exe' +$shortcut.Arguments = 'ms-settings:' +$shortcut.IconLocation = "$env:windir\ImmersiveControlPanel\SystemSettings.exe" +$shortcut.Save() +if (-not (Invoke-TaskbarVerb -Path $settingsShortcut -Pattern 'Pin to taskbar|Pin to Taskbar')) { + $failures.Add('could not pin Settings to taskbar') +} +if ($failures.Count -gt 0) { + throw ($failures -join '; ') +}`) +} + +func mustInstallAssetPath(asset string) string { + path, err := writeInstallAsset(asset) + if err != nil { + return asset + } + return path +} + +func psSingleQuote(value string) string { + return strings.ReplaceAll(value, `'`, `''`) +} + +func containsLine(output, needle string) bool { + for _, line := range stringsSplitLines(output) { + if line == needle { + return true + } + } + return false +} + +func stringsSplitLines(output string) []string { + var lines []string + for _, line := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") { + lines = append(lines, strings.TrimSpace(strings.ToLower(line))) + } + return lines +} diff --git a/internal/bootstrap/osconfig_test.go b/internal/bootstrap/osconfig_test.go new file mode 100644 index 0000000..8dca4ee --- /dev/null +++ b/internal/bootstrap/osconfig_test.go @@ -0,0 +1,252 @@ +package bootstrap + +import ( + "context" + "errors" + "strings" + "testing" +) + +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"} { + if !strings.Contains(joined, want) { + t.Fatalf("WinUtil command missing %q: %s", want, joined) + } + } +} + +func TestPowerPolicyCommandContainsDesktopAndLaptopSettings(t *testing.T) { + cmd := PowerPolicyCommand() + joined := strings.Join(cmd.Args, " ") + for _, want := range []string{ + "Invoke-Native 'powercfg.exe' @('/hibernate', 'on')", + "@('/setacvalueindex', $scheme, 'SUB_VIDEO', 'VIDEOIDLE', '900')", + "@('/setdcvalueindex', $scheme, 'SUB_VIDEO', 'VIDEOIDLE', '300')", + "@('/setacvalueindex', $scheme, 'SUB_SLEEP', 'STANDBYIDLE', '0')", + "@('/setdcvalueindex', $scheme, 'SUB_SLEEP', 'STANDBYIDLE', '900')", + "'LIDACTION', '0'", + "'LIDACTION', '1'", + "'PBUTTONACTION', '3'", + "'SBUTTONACTION', '1'", + "InactivityTimeoutSecs", + "SCHEME_MIN", + "SCHEME_MAX", + } { + if !strings.Contains(joined, want) { + t.Fatalf("power policy command missing %q", want) + } + } + if strings.Contains(joined, "SCHEME_CURRENT") { + t.Fatal("power policy should configure explicit schemes, not SCHEME_CURRENT") + } +} + +func TestRunOSPhaseStopsBeforeRestorePointWhenRebootIsRequired(t *testing.T) { + update := WindowsUpdateCommand() + reboot := windowsRebootProbeCommand() + restore := RestorePointCommand("bootstrap_windows_env: before OS configuration") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey(update.Name, update.Args...): {}, + commandKey(reboot.Name, reboot.Args...): {Stdout: "true\n"}, + }, defaultErr: true} + result := RunOSPhase(context.Background(), runner) + if !result.Stop { + t.Fatal("expected OS phase to stop for pending reboot") + } + if len(result.Issues) != 0 { + t.Fatalf("issues = %#v, want none", result.Issues) + } + if runner.called(restore.Name, restore.Args...) { + t.Fatal("restore point should not be attempted before required reboot") + } +} + +func TestRunOSPhaseRunsSecondRestorePointAfterConfiguration(t *testing.T) { + runner := &fakeRunner{} + result := RunOSPhase(context.Background(), runner) + if result.Stop { + t.Fatalf("OS phase unexpectedly stopped: %#v", result) + } + if err := result.Issues.Err(); err != nil { + t.Fatal(err) + } + first := RestorePointCommand("bootstrap_windows_env: before OS configuration") + second := RestorePointCommand("bootstrap_windows_env: before package installation") + if !runner.called(first.Name, first.Args...) || !runner.called(second.Name, second.Args...) { + t.Fatal("expected both restore point commands to run") + } +} + +func TestRunOSPhaseContinuesAfterNonRestoreFailure(t *testing.T) { + update := WindowsUpdateCommand() + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey(update.Name, update.Args...): {Err: context.Canceled, Stderr: "update failed"}, + }} + result := RunOSPhase(context.Background(), runner) + if result.Stop { + t.Fatalf("non-restore failure should be reported but should not stop the OS phase: %#v", result) + } + if result.Issues.Err() == nil { + t.Fatal("expected Windows Update issue") + } + second := RestorePointCommand("bootstrap_windows_env: before package installation") + if !runner.called(second.Name, second.Args...) { + t.Fatal("expected final restore point after non-restore failure") + } +} + +func TestRunOSPhaseStopsWhenSecondRestorePointFails(t *testing.T) { + second := RestorePointCommand("bootstrap_windows_env: before package installation") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey(second.Name, second.Args...): {Err: context.Canceled, Stderr: "restore failed"}, + }} + result := RunOSPhase(context.Background(), runner) + if !result.Stop { + t.Fatal("expected OS phase to stop when final restore point fails") + } + if result.Issues.Err() == nil { + t.Fatal("expected restore point issue") + } +} + +func TestRunOSPhaseStopsWhenFirstRestorePointFails(t *testing.T) { + first := RestorePointCommand("bootstrap_windows_env: before OS configuration") + winutil := WinUtilCommand() + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey(first.Name, first.Args...): {Err: errors.New("restore failed"), Stderr: "blocked"}, + }} + result := RunOSPhase(context.Background(), runner) + if !result.Stop { + t.Fatal("expected first restore point failure to stop OS phase") + } + if result.Issues.Err() == nil { + t.Fatal("expected restore point issue") + } + if runner.called(winutil.Name, winutil.Args...) { + t.Fatal("WinUtil should not run after first restore point failure") + } +} + +func TestRunOSPhaseRecordsWinUtilFailureAndContinuesToFinalRestorePoint(t *testing.T) { + winutil := WinUtilCommand() + second := RestorePointCommand("bootstrap_windows_env: before package installation") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey(winutil.Name, winutil.Args...): {Err: errors.New("winutil failed"), Stderr: "script failed"}, + }} + result := RunOSPhase(context.Background(), runner) + if result.Stop { + t.Fatalf("WinUtil failure should not stop OS phase: %#v", result) + } + if result.Issues.Err() == nil { + t.Fatal("expected WinUtil issue") + } + if !runner.called(second.Name, second.Args...) { + t.Fatal("expected final restore point after WinUtil failure") + } +} + +func TestDetectWindowsRebootRequiredReportsProbeFailure(t *testing.T) { + _, err := DetectWindowsRebootRequired(context.Background(), &fakeRunner{defaultErr: true}) + if err == nil || !strings.Contains(err.Error(), "unexpected command") { + t.Fatalf("error = %v, want probe failure", err) + } +} + +func TestRunOSPhaseRecordsRebootProbeFailureAndContinues(t *testing.T) { + reboot := windowsRebootProbeCommand() + second := RestorePointCommand("bootstrap_windows_env: before package installation") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey(reboot.Name, reboot.Args...): {Err: errors.New("probe failed"), Stderr: "bad registry"}, + }} + result := RunOSPhase(context.Background(), runner) + if result.Stop { + t.Fatalf("reboot probe failure should be recorded but should not stop OS phase: %#v", result) + } + if result.Issues.Err() == nil { + t.Fatal("expected reboot probe issue") + } + if !runner.called(second.Name, second.Args...) { + t.Fatal("expected final restore point after reboot probe failure") + } +} + +func TestRestorePointCommandDisablesFrequencyThrottle(t *testing.T) { + cmd := RestorePointCommand("bootstrap_windows_env: test") + joined := strings.Join(cmd.Args, " ") + for _, want := range []string{"SystemRestorePointCreationFrequency", "Checkpoint-Computer"} { + if !strings.Contains(joined, want) { + t.Fatalf("restore point command missing %q", want) + } + } +} + +func TestPolicyAndExplorerCommandsCoverRequestedRegistrySettings(t *testing.T) { + policy := strings.Join(WindowsUpdatePolicyCommand().Args, " ") + for _, want := range []string{"DeferFeatureUpdatesPeriodInDays", "90", "ActiveHoursStart", "ActiveHoursEnd", "AllowMUUpdateService"} { + if !strings.Contains(policy, want) { + t.Fatalf("policy command missing %q", want) + } + } + explorer := strings.Join(ExplorerThemeCommand().Args, " ") + for _, want := range []string{"86ca1aa0-34aa-4e8b-a509-50c905bae2a2", "AppsUseLightTheme", "SystemUsesLightTheme"} { + if !strings.Contains(explorer, want) { + t.Fatalf("explorer/theme command missing %q", want) + } + } +} + +func TestDeveloperModeCommandEnablesAnywhereInstallAndWindowsSudo(t *testing.T) { + cmd := DeveloperModeCommand() + joined := strings.Join(cmd.Args, " ") + for _, want := range []string{ + "AppModelUnlock", + "AllowDevelopmentWithoutDevLicense", + "AllowAllTrustedApps", + "DeveloperSettings", + "EnableSudo", + "SudoMode", + "sudo.exe config --enable normal", + } { + if !strings.Contains(joined, want) { + t.Fatalf("developer mode command missing %q", want) + } + } + if !strings.Contains(joined, "$LASTEXITCODE") { + t.Fatal("developer mode command should check sudo.exe exit code") + } +} + +func TestOSCommandsCheckNativeExitCodes(t *testing.T) { + for name, cmd := range map[string]CommandSpec{ + "Windows Update": WindowsUpdateCommand(), + "Vivaldi": VivaldiDefaultBrowserCommand(), + "Store": StoreUpdateCommand(), + } { + joined := strings.Join(cmd.Args, " ") + if !strings.Contains(joined, "$LASTEXITCODE") && !strings.Contains(joined, "ResultCode") { + t.Fatalf("%s command should check native or COM result codes", name) + } + } +} + +func TestMustInstallAssetPathFallsBackToInputWhenAssetIsMissing(t *testing.T) { + if got := mustInstallAssetPath("assets/not-present.json"); got != "assets/not-present.json" { + t.Fatalf("got %q, want original asset path", got) + } +} + +func windowsRebootProbeCommand() CommandSpec { + return powerShell(`$paths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' +) +$pending = $false +foreach ($path in $paths) { + if (Test-Path $path) { $pending = $true } +} +$sessionManager = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue +if ($sessionManager.PendingFileRenameOperations) { $pending = $true } +if ($pending) { 'true' } else { 'false' }`) +} diff --git a/internal/bootstrap/runlog.go b/internal/bootstrap/runlog.go new file mode 100644 index 0000000..aea4e98 --- /dev/null +++ b/internal/bootstrap/runlog.go @@ -0,0 +1,114 @@ +package bootstrap + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type RunReport struct { + Started time.Time + Finished time.Time + Options Options + Phases []Phase + Events []string + Notices []string + Issues Issues + FinalError string +} + +func NewRunReport(opts Options, phases []Phase) *RunReport { + return &RunReport{ + Started: time.Now(), + Options: opts, + Phases: append([]Phase(nil), phases...), + } +} + +func (r *RunReport) Event(format string, args ...any) { + r.Events = append(r.Events, fmt.Sprintf(format, args...)) +} + +func (r *RunReport) Notice(notice string) { + r.Notices = append(r.Notices, notice) + r.Event("notice: %s", notice) +} + +func (r *RunReport) Issue(issue Issue) { + r.Issues = append(r.Issues, issue) + r.Event("error: %s: %v", issue.Step, issue.Err) +} + +func (r *RunReport) Finish(err error) { + r.Finished = time.Now() + if err != nil { + r.FinalError = err.Error() + } +} + +func WriteRunLog(paths UserPaths, report *RunReport) (string, error) { + documents := paths.Documents + if documents == "" { + return "", fmt.Errorf("documents path is empty") + } + if err := os.MkdirAll(documents, 0o755); err != nil { + return "", err + } + name := "bootstrap_windows_env-" + report.Started.Format("20060102-150405") + ".log" + path := filepath.Join(documents, name) + var b strings.Builder + fmt.Fprintf(&b, "bootstrap_windows_env run\n") + fmt.Fprintf(&b, "started: %s\n", report.Started.Format(time.RFC3339)) + if !report.Finished.IsZero() { + fmt.Fprintf(&b, "finished: %s\n", report.Finished.Format(time.RFC3339)) + 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", + report.Options.Only, + report.Options.NoAI, + report.Options.NoWSL, + report.Options.Yes, + report.Options.DryRun, + report.Options.Headless, + report.Options.LinuxReleaseRepo, + ) + if report.FinalError != "" { + fmt.Fprintf(&b, "result: failed\n") + fmt.Fprintf(&b, "error: %s\n", report.FinalError) + } else { + fmt.Fprintf(&b, "result: completed\n") + } + if len(report.Events) > 0 { + fmt.Fprintf(&b, "\nevents:\n") + for _, event := range report.Events { + fmt.Fprintf(&b, "- %s\n", event) + } + } + if len(report.Notices) > 0 { + fmt.Fprintf(&b, "\nnotices:\n") + for _, notice := range report.Notices { + fmt.Fprintf(&b, "- %s\n", notice) + } + } + if len(report.Issues) > 0 { + fmt.Fprintf(&b, "\nerrors:\n") + for _, issue := range report.Issues { + fmt.Fprintf(&b, "- %s: %v\n", issue.Step, issue.Err) + } + } + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + return "", err + } + return path, nil +} + +func phasesForLog(phases []Phase) string { + parts := make([]string, 0, len(phases)) + for _, phase := range phases { + parts = append(parts, string(phase)) + } + return strings.Join(parts, ",") +} diff --git a/internal/bootstrap/runlog_test.go b/internal/bootstrap/runlog_test.go new file mode 100644 index 0000000..2535a0d --- /dev/null +++ b/internal/bootstrap/runlog_test.go @@ -0,0 +1,70 @@ +package bootstrap + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteRunLogWritesResultsToDocuments(t *testing.T) { + documents := filepath.Join(t.TempDir(), "Documents") + report := NewRunReport(Options{Yes: true, NoWSL: true}, []Phase{PhaseOS, PhaseHost}) + report.Event("phase os started") + report.Notice("reboot required later") + report.Issue(Issue{Step: "apply power settings", Err: errors.New("powercfg failed")}) + report.Finish(report.Issues.Err()) + + path, err := WriteRunLog(UserPaths{Documents: documents}, report) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(path) != documents { + t.Fatalf("log path = %s, want under %s", path, documents) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(data) + for _, want := range []string{"result: failed", "phase os started", "reboot required later", "apply power settings: powercfg failed"} { + if !strings.Contains(content, want) { + t.Fatalf("log missing %q:\n%s", want, content) + } + } +} + +func TestWriteRunLogHandlesUnfinishedReportAndEmptyDocumentsPath(t *testing.T) { + documents := filepath.Join(t.TempDir(), "Documents") + report := NewRunReport(Options{}, []Phase{PhaseOS}) + path, err := WriteRunLog(UserPaths{Documents: documents}, report) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(data) + if strings.Contains(content, "finished:") || !strings.Contains(content, "result: completed") { + t.Fatalf("unexpected unfinished log content:\n%s", content) + } + + if _, err := WriteRunLog(UserPaths{}, report); err == nil { + t.Fatal("expected empty documents path error") + } +} + +func TestWriteRunLogReportsWriteFailure(t *testing.T) { + documents := filepath.Join(t.TempDir(), "Documents") + report := NewRunReport(Options{}, []Phase{PhaseOS}) + report.Started = report.Started.UTC().Truncate(0) + logName := "bootstrap_windows_env-" + report.Started.Format("20060102-150405") + ".log" + if err := os.MkdirAll(filepath.Join(documents, logName), 0o755); err != nil { + t.Fatal(err) + } + if _, err := WriteRunLog(UserPaths{Documents: documents}, report); err == nil { + t.Fatal("expected write failure when log path is a directory") + } +} diff --git a/internal/bootstrap/test_helpers_test.go b/internal/bootstrap/test_helpers_test.go new file mode 100644 index 0000000..b87fa7d --- /dev/null +++ b/internal/bootstrap/test_helpers_test.go @@ -0,0 +1,46 @@ +package bootstrap + +import ( + "context" + "errors" + "strings" + "sync" +) + +type fakeRunner struct { + mu sync.Mutex + responses map[string]CommandResult + calls []string + defaultErr bool +} + +func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) CommandResult { + r.mu.Lock() + defer r.mu.Unlock() + key := commandKey(name, args...) + r.calls = append(r.calls, key) + if result, ok := r.responses[key]; ok { + result.Command = strings.ReplaceAll(key, "\x00", " ") + return result + } + if r.defaultErr { + return CommandResult{Command: strings.ReplaceAll(key, "\x00", " "), Err: errors.New("unexpected command")} + } + return CommandResult{Command: strings.ReplaceAll(key, "\x00", " ")} +} + +func (r *fakeRunner) called(name string, args ...string) bool { + key := commandKey(name, args...) + r.mu.Lock() + defer r.mu.Unlock() + for _, call := range r.calls { + if call == key { + return true + } + } + return false +} + +func commandKey(name string, args ...string) string { + return name + "\x00" + strings.Join(args, "\x00") +} diff --git a/internal/bootstrap/winget.go b/internal/bootstrap/winget.go new file mode 100644 index 0000000..4597f98 --- /dev/null +++ b/internal/bootstrap/winget.go @@ -0,0 +1,146 @@ +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 +} diff --git a/internal/bootstrap/winget_test.go b/internal/bootstrap/winget_test.go new file mode 100644 index 0000000..359e446 --- /dev/null +++ b/internal/bootstrap/winget_test.go @@ -0,0 +1,167 @@ +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") + } +} diff --git a/internal/bootstrap/wsl.go b/internal/bootstrap/wsl.go new file mode 100644 index 0000000..d2f071f --- /dev/null +++ b/internal/bootstrap/wsl.go @@ -0,0 +1,212 @@ +package bootstrap + +import ( + "context" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +type WSLState struct { + Installed bool + Version2Default bool + RestartRequired bool + FedoraAvailable string + FedoraInstalled string + FedoraReady bool + GuestConfigured bool +} + +type WSLTransition string + +const ( + WSLInstallPrerequisites WSLTransition = "install-prerequisites" + WSLWaitForRestart WSLTransition = "wait-for-restart" + WSLSetDefaultVersion WSLTransition = "set-default-version" + WSLInstallFedora WSLTransition = "install-fedora" + WSLWaitForFedoraUser WSLTransition = "wait-for-fedora-user" + WSLRunGuestBootstrap WSLTransition = "run-guest-bootstrap" + WSLComplete WSLTransition = "complete" +) + +func NextWSLTransition(state WSLState) WSLTransition { + switch { + case !state.Installed: + return WSLInstallPrerequisites + case state.RestartRequired: + return WSLWaitForRestart + case !state.Version2Default: + return WSLSetDefaultVersion + case state.FedoraInstalled == "": + return WSLInstallFedora + case !state.FedoraReady: + return WSLWaitForFedoraUser + case !state.GuestConfigured: + return WSLRunGuestBootstrap + default: + return WSLComplete + } +} + +func DetectWSL(ctx context.Context, runner Runner) (WSLState, error) { + var state WSLState + status := runner.Run(ctx, "wsl.exe", "--status") + if status.Err != nil { + return state, nil + } + state.Installed = true + state.Version2Default = strings.Contains(strings.ToLower(status.CombinedOutput()), "default version: 2") + restart := runner.Run(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + `if ((Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired')) { 'true' } else { 'false' }`) + state.RestartRequired = strings.Contains(strings.ToLower(restart.Stdout), "true") + + online := runner.Run(ctx, "wsl.exe", "--list", "--online") + if online.Err != nil { + return state, fmt.Errorf("list online WSL distributions: %w: %s", online.Err, online.CombinedOutput()) + } + state.FedoraAvailable = SelectOfficialFedora(online.Stdout) + if state.FedoraAvailable == "" { + return state, fmt.Errorf("no official FedoraLinux-* distribution was reported by wsl --list --online") + } + installed := runner.Run(ctx, "wsl.exe", "--list", "--quiet") + if installed.Err != nil { + return state, fmt.Errorf("list installed WSL distributions: %w: %s", installed.Err, installed.CombinedOutput()) + } + for _, line := range strings.Split(strings.ReplaceAll(installed.Stdout, "\r", ""), "\n") { + distro := strings.TrimSpace(strings.ReplaceAll(line, "\x00", "")) + if strings.HasPrefix(distro, "FedoraLinux-") { + state.FedoraInstalled = distro + break + } + } + if state.FedoraInstalled == "" { + return state, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + ready := runner.Run(probeCtx, "wsl.exe", "-d", state.FedoraInstalled, "--exec", "sh", "-lc", "id -u >/dev/null") + state.FedoraReady = ready.Err == nil + if state.FedoraReady { + marker := runner.Run(ctx, "wsl.exe", "-d", state.FedoraInstalled, "--exec", "sh", "-lc", "test -f ~/.local/state/bootstrap_dev_env/wsl-complete") + state.GuestConfigured = marker.Err == nil + } + return state, nil +} + +var fedoraPattern = regexp.MustCompile(`(?i)\bFedoraLinux-(\d+)\b`) + +func SelectOfficialFedora(output string) string { + matches := fedoraPattern.FindAllStringSubmatch(output, -1) + type release struct { + name string + version int + } + var releases []release + for _, match := range matches { + version, _ := strconv.Atoi(match[1]) + releases = append(releases, release{name: match[0], version: version}) + } + sort.Slice(releases, func(i, j int) bool { return releases[i].version > releases[j].version }) + if len(releases) == 0 { + return "" + } + return releases[0].name +} + +type WSLResult struct { + Notice string + Issue error + Done bool +} + +func RunWSLPhase(ctx context.Context, runner Runner, opts Options) WSLResult { + state, err := DetectWSL(ctx, runner) + if err != nil { + return WSLResult{Issue: err} + } + switch NextWSLTransition(state) { + case WSLInstallPrerequisites: + result := runner.Run(ctx, "wsl.exe", "--install", "--no-distribution") + if result.Err != nil { + return WSLResult{Issue: fmt.Errorf("install WSL prerequisites: %w: %s", result.Err, result.CombinedOutput())} + } + return WSLResult{Notice: "WSL prerequisites were installed. Restart Windows, then rerun this bootstrapper to install Fedora."} + case WSLWaitForRestart: + return WSLResult{Notice: "Windows has a pending restart. Restart Windows, then rerun this bootstrapper to resume Fedora WSL setup."} + case WSLSetDefaultVersion: + result := runner.Run(ctx, "wsl.exe", "--set-default-version", "2") + if result.Err != nil { + return WSLResult{Issue: fmt.Errorf("set WSL default version to 2: %w: %s", result.Err, result.CombinedOutput())} + } + return WSLResult{Notice: "WSL default version was set to 2. Rerun this bootstrapper to continue Fedora setup."} + case WSLInstallFedora: + result := runner.Run(ctx, "wsl.exe", "--install", "--distribution", state.FedoraAvailable, "--no-launch") + if result.Err != nil { + return WSLResult{Issue: fmt.Errorf("install %s: %w: %s", state.FedoraAvailable, result.Err, result.CombinedOutput())} + } + return WSLResult{Notice: fmt.Sprintf("%s was installed. Run `wsl -d %s` once to create its Linux username, then rerun this bootstrapper.", state.FedoraAvailable, state.FedoraAvailable)} + case WSLWaitForFedoraUser: + return WSLResult{Notice: fmt.Sprintf("Fedora requires first-launch setup. Run `wsl -d %s` and create its Linux username, then rerun this bootstrapper.", state.FedoraInstalled)} + case WSLRunGuestBootstrap: + invocation := GuestBootstrapInvocation(opts, state.FedoraInstalled) + result := runner.Run(ctx, invocation.Name, invocation.Args...) + if result.Err != nil { + return WSLResult{Issue: fmt.Errorf("run Fedora bootstrap: %w: %s", result.Err, result.CombinedOutput())} + } + return WSLResult{Notice: "Fedora WSL guest provisioning completed.", Done: true} + default: + return WSLResult{Notice: "Fedora WSL guest was already provisioned.", Done: true} + } +} + +func GuestBootstrapInvocation(opts Options, distro string) CommandSpec { + arguments := []string{"--wsl", "--headless", "--yes"} + if opts.NoAI { + arguments = append(arguments, "--no-ai") + } + script := `set -eu +repo="$1" +shift +arch="$(uname -m)" +case "$arch" in + x86_64) asset_arch="amd64" ;; + aarch64|arm64) asset_arch="arm64" ;; + *) echo "Unsupported guest architecture: $arch" >&2; exit 1 ;; +esac +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +curl -fsSL "https://api.github.com/repos/${repo}/releases/latest" -o "$work/release.json" +python3 - "$work/release.json" "$asset_arch" > "$work/assets" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +arch = sys.argv[2] +assets = data.get("assets", []) +binaries = [a for a in assets if "linux" in a["name"].lower() and arch in a["name"].lower() and "sha256" not in a["name"].lower() and "checksum" not in a["name"].lower()] +if not binaries: + raise SystemExit("No Linux bootstrap release asset found for " + arch) +binary = binaries[0] +print(binary["browser_download_url"]) +checksums = [a for a in assets if "sha256" in a["name"].lower() or "checksum" in a["name"].lower()] +print(checksums[0]["browser_download_url"] if checksums else "") +PY +binary_url="$(sed -n '1p' "$work/assets")" +checksum_url="$(sed -n '2p' "$work/assets")" +curl -fsSL "$binary_url" -o "$work/bootstrap_dev_env" +chmod +x "$work/bootstrap_dev_env" +if [ -n "$checksum_url" ]; then + curl -fsSL "$checksum_url" -o "$work/checksums" + expected="$(grep "$(basename "$binary_url")" "$work/checksums" | awk '{print $1}' | head -n 1)" + [ -n "$expected" ] || { echo "Published checksum did not contain the selected asset" >&2; exit 1; } + actual="$(sha256sum "$work/bootstrap_dev_env" | awk '{print $1}')" + [ "$expected" = "$actual" ] || { echo "Linux bootstrap checksum verification failed" >&2; exit 1; } +fi +"$work/bootstrap_dev_env" "$@" +mkdir -p "$HOME/.local/state/bootstrap_dev_env" +touch "$HOME/.local/state/bootstrap_dev_env/wsl-complete"` + args := []string{"-d", distro, "--exec", "sh", "-lc", script, "bootstrap-wsl", opts.LinuxReleaseRepo} + args = append(args, arguments...) + return CommandSpec{Name: "wsl.exe", Args: args} +} diff --git a/internal/bootstrap/wsl_test.go b/internal/bootstrap/wsl_test.go new file mode 100644 index 0000000..c520cd0 --- /dev/null +++ b/internal/bootstrap/wsl_test.go @@ -0,0 +1,255 @@ +package bootstrap + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestSelectOfficialFedoraChoosesCurrentSuccessor(t *testing.T) { + output := `The following is a list of valid distributions: +FedoraLinux-42 +FedoraLinux-43 +Ubuntu` + if got := SelectOfficialFedora(output); got != "FedoraLinux-43" { + t.Fatalf("got %q, want FedoraLinux-43", got) + } +} + +func TestWSLStateTransitions(t *testing.T) { + cases := []struct { + name string + state WSLState + want WSLTransition + }{ + {"absent", WSLState{}, WSLInstallPrerequisites}, + {"restart", WSLState{Installed: true, RestartRequired: true}, WSLWaitForRestart}, + {"set v2", WSLState{Installed: true}, WSLSetDefaultVersion}, + {"fedora missing", WSLState{Installed: true, Version2Default: true, FedoraAvailable: "FedoraLinux-42"}, WSLInstallFedora}, + {"first launch", WSLState{Installed: true, Version2Default: true, FedoraInstalled: "FedoraLinux-42"}, WSLWaitForFedoraUser}, + {"run guest", WSLState{Installed: true, Version2Default: true, FedoraInstalled: "FedoraLinux-42", FedoraReady: true}, WSLRunGuestBootstrap}, + {"complete", WSLState{Installed: true, Version2Default: true, FedoraInstalled: "FedoraLinux-42", FedoraReady: true, GuestConfigured: true}, WSLComplete}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := NextWSLTransition(tc.state); got != tc.want { + t.Fatalf("got %s, want %s", got, tc.want) + } + }) + } +} + +func TestGuestBootstrapInvocationPassesWSLAndNoAI(t *testing.T) { + invocation := GuestBootstrapInvocation(Options{NoAI: true, LinuxReleaseRepo: "JMR-dev/bootstrap_dev_env"}, "FedoraLinux-42") + joined := strings.Join(invocation.Args, " ") + for _, want := range []string{"-d FedoraLinux-42", "JMR-dev/bootstrap_dev_env", "--wsl", "--headless", "--yes", "--no-ai"} { + if !strings.Contains(joined, want) { + t.Fatalf("invocation missing %q: %s", want, joined) + } + } + if !strings.Contains(joined, "sha256") { + t.Fatal("guest script should verify checksum when a checksum asset is published") + } +} + +func TestRunWSLPhaseStopsForFedoraFirstLaunch(t *testing.T) { + onlineArgs := []string{"--list", "--online"} + installedArgs := []string{"--list", "--quiet"} + probeArgs := []string{"-d", "FedoraLinux-42", "--exec", "sh", "-lc", "id -u >/dev/null"} + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Stdout: "Default Version: 2"}, + commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `if ((Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired')) { 'true' } else { 'false' }`): {Stdout: "false"}, + commandKey("wsl.exe", onlineArgs...): {Stdout: "FedoraLinux-42"}, + commandKey("wsl.exe", installedArgs...): {Stdout: "FedoraLinux-42\n"}, + commandKey("wsl.exe", probeArgs...): {Err: errors.New("first launch required")}, + }, defaultErr: true} + result := RunWSLPhase(context.Background(), runner, Options{}) + if result.Issue != nil { + t.Fatal(result.Issue) + } + if !strings.Contains(result.Notice, "first-launch") { + t.Fatalf("notice = %q, want first-launch guidance", result.Notice) + } +} + +func TestRunWSLPhaseSetsDefaultVersion2(t *testing.T) { + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Stdout: "Default Version: 1"}, + commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `if ((Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired')) { 'true' } else { 'false' }`): {Stdout: "false"}, + commandKey("wsl.exe", "--list", "--online"): {Stdout: "FedoraLinux-42"}, + commandKey("wsl.exe", "--list", "--quiet"): {Stdout: ""}, + }} + result := RunWSLPhase(context.Background(), runner, Options{}) + if result.Issue != nil { + t.Fatal(result.Issue) + } + if !runner.called("wsl.exe", "--set-default-version", "2") { + t.Fatal("expected WSL default version to be set to 2") + } +} + +func TestRunWSLPhaseReportsGuestInvocationFailure(t *testing.T) { + onlineArgs := []string{"--list", "--online"} + installedArgs := []string{"--list", "--quiet"} + probeArgs := []string{"-d", "FedoraLinux-42", "--exec", "sh", "-lc", "id -u >/dev/null"} + markerArgs := []string{"-d", "FedoraLinux-42", "--exec", "sh", "-lc", "test -f ~/.local/state/bootstrap_dev_env/wsl-complete"} + invocation := GuestBootstrapInvocation(Options{LinuxReleaseRepo: "JMR-dev/bootstrap_dev_env"}, "FedoraLinux-42") + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Stdout: "Default Version: 2"}, + commandKey("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `if ((Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired')) { 'true' } else { 'false' }`): {Stdout: "false"}, + commandKey("wsl.exe", onlineArgs...): {Stdout: "FedoraLinux-42"}, + commandKey("wsl.exe", installedArgs...): {Stdout: "FedoraLinux-42\n"}, + commandKey("wsl.exe", probeArgs...): {}, + commandKey("wsl.exe", markerArgs...): {Err: errors.New("missing marker")}, + commandKey(invocation.Name, invocation.Args...): {Err: errors.New("download failed")}, + }} + result := RunWSLPhase(context.Background(), runner, Options{LinuxReleaseRepo: "JMR-dev/bootstrap_dev_env"}) + if result.Issue == nil || !strings.Contains(result.Issue.Error(), "download failed") { + t.Fatalf("issue = %v, want guest invocation failure", result.Issue) + } +} + +func TestDetectWSLCoversAbsentAndListFailures(t *testing.T) { + state, err := DetectWSL(context.Background(), &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Err: errors.New("not installed")}, + }, defaultErr: true}) + if err != nil { + t.Fatal(err) + } + if state.Installed { + t.Fatalf("state = %#v, want WSL absent", state) + } + + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Stdout: "Default Version: 2"}, + commandKey("powershell.exe", wslRebootProbeArgs()...): {Stdout: "false"}, + commandKey("wsl.exe", "--list", "--online"): {Err: errors.New("offline"), Stderr: "network"}, + }, defaultErr: true} + if _, err := DetectWSL(context.Background(), runner); err == nil || !strings.Contains(err.Error(), "network") { + t.Fatalf("error = %v, want online list failure", err) + } + + runner = &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Stdout: "Default Version: 2"}, + commandKey("powershell.exe", wslRebootProbeArgs()...): {Stdout: "false"}, + commandKey("wsl.exe", "--list", "--online"): {Stdout: "Ubuntu"}, + }, defaultErr: true} + if _, err := DetectWSL(context.Background(), runner); err == nil || !strings.Contains(err.Error(), "no official FedoraLinux") { + t.Fatalf("error = %v, want missing Fedora error", err) + } + + runner = &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Stdout: "Default Version: 2"}, + commandKey("powershell.exe", wslRebootProbeArgs()...): {Stdout: "false"}, + commandKey("wsl.exe", "--list", "--online"): {Stdout: "FedoraLinux-42"}, + commandKey("wsl.exe", "--list", "--quiet"): {Err: errors.New("list failed"), Stderr: "bad list"}, + }, defaultErr: true} + if _, err := DetectWSL(context.Background(), runner); err == nil || !strings.Contains(err.Error(), "bad list") { + t.Fatalf("error = %v, want installed list failure", err) + } +} + +func TestSelectOfficialFedoraReturnsEmptyWhenMissing(t *testing.T) { + if got := SelectOfficialFedora("Ubuntu\nDebian"); got != "" { + t.Fatalf("got %q, want empty result", got) + } +} + +func TestRunWSLPhaseInstallsPrerequisitesAndReportsFailures(t *testing.T) { + runner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Err: errors.New("not installed")}, + }} + result := RunWSLPhase(context.Background(), runner, Options{}) + if result.Issue != nil { + t.Fatal(result.Issue) + } + if !runner.called("wsl.exe", "--install", "--no-distribution") || !strings.Contains(result.Notice, "Restart Windows") { + t.Fatalf("result = %#v, want prerequisite install notice", result) + } + + failRunner := &fakeRunner{responses: map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Err: errors.New("not installed")}, + commandKey("wsl.exe", "--install", "--no-distribution"): {Err: errors.New("install failed"), Stderr: "denied"}, + }} + result = RunWSLPhase(context.Background(), failRunner, Options{}) + if result.Issue == nil || !strings.Contains(result.Issue.Error(), "denied") { + t.Fatalf("result = %#v, want prerequisite install failure", result) + } +} + +func TestRunWSLPhaseCoversRestartFedoraInstallGuestSuccessAndComplete(t *testing.T) { + restartRunner := &fakeRunner{responses: wslDetectResponses("Default Version: 2", "true", "FedoraLinux-42", "", nil, nil)} + result := RunWSLPhase(context.Background(), restartRunner, Options{}) + if result.Issue != nil || !strings.Contains(result.Notice, "pending restart") { + t.Fatalf("result = %#v, want restart notice", result) + } + + installRunner := &fakeRunner{responses: wslDetectResponses("Default Version: 2", "false", "FedoraLinux-42", "", nil, nil)} + result = RunWSLPhase(context.Background(), installRunner, Options{}) + if result.Issue != nil { + t.Fatal(result.Issue) + } + if !installRunner.called("wsl.exe", "--install", "--distribution", "FedoraLinux-42", "--no-launch") { + t.Fatal("expected Fedora install command") + } + + failInstall := wslDetectResponses("Default Version: 2", "false", "FedoraLinux-42", "", nil, nil) + failInstall[commandKey("wsl.exe", "--install", "--distribution", "FedoraLinux-42", "--no-launch")] = CommandResult{Err: errors.New("install failed"), Stderr: "bad distro"} + result = RunWSLPhase(context.Background(), &fakeRunner{responses: failInstall}, Options{}) + if result.Issue == nil || !strings.Contains(result.Issue.Error(), "bad distro") { + t.Fatalf("result = %#v, want Fedora install failure", result) + } + + opts := Options{LinuxReleaseRepo: "JMR-dev/bootstrap_dev_env"} + invocation := GuestBootstrapInvocation(opts, "FedoraLinux-42") + guestResponses := wslDetectResponses("Default Version: 2", "false", "FedoraLinux-42", "FedoraLinux-42\n", nil, errors.New("missing marker")) + guestResponses[commandKey(invocation.Name, invocation.Args...)] = CommandResult{} + result = RunWSLPhase(context.Background(), &fakeRunner{responses: guestResponses}, opts) + if result.Issue != nil || !result.Done || !strings.Contains(result.Notice, "completed") { + t.Fatalf("result = %#v, want guest success", result) + } + + completeRunner := &fakeRunner{responses: wslDetectResponses("Default Version: 2", "false", "FedoraLinux-42", "FedoraLinux-42\n", nil, nil)} + result = RunWSLPhase(context.Background(), completeRunner, Options{}) + if result.Issue != nil || !result.Done || !strings.Contains(result.Notice, "already provisioned") { + t.Fatalf("result = %#v, want already complete", result) + } +} + +func TestRunWSLPhaseReportsSetDefaultFailure(t *testing.T) { + responses := wslDetectResponses("Default Version: 1", "false", "FedoraLinux-42", "", nil, nil) + responses[commandKey("wsl.exe", "--set-default-version", "2")] = CommandResult{Err: errors.New("set failed"), Stderr: "blocked"} + result := RunWSLPhase(context.Background(), &fakeRunner{responses: responses}, Options{}) + if result.Issue == nil || !strings.Contains(result.Issue.Error(), "blocked") { + t.Fatalf("result = %#v, want set default failure", result) + } +} + +func TestRunWSLPhaseReportsDetectionFailure(t *testing.T) { + responses := wslDetectResponses("Default Version: 2", "false", "Ubuntu", "", nil, nil) + result := RunWSLPhase(context.Background(), &fakeRunner{responses: responses}, Options{}) + if result.Issue == nil || !strings.Contains(result.Issue.Error(), "no official FedoraLinux") { + t.Fatalf("result = %#v, want detection failure", result) + } +} + +func wslRebootProbeArgs() []string { + return []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `if ((Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired')) { 'true' } else { 'false' }`} +} + +func wslDetectResponses(statusOut, rebootOut, onlineOut, installedOut string, readyErr, markerErr error) map[string]CommandResult { + responses := map[string]CommandResult{ + commandKey("wsl.exe", "--status"): {Stdout: statusOut}, + commandKey("powershell.exe", wslRebootProbeArgs()...): {Stdout: rebootOut}, + commandKey("wsl.exe", "--list", "--online"): {Stdout: onlineOut}, + commandKey("wsl.exe", "--list", "--quiet"): {Stdout: installedOut}, + } + if strings.Contains(installedOut, "FedoraLinux-42") { + responses[commandKey("wsl.exe", "-d", "FedoraLinux-42", "--exec", "sh", "-lc", "id -u >/dev/null")] = CommandResult{Err: readyErr} + if readyErr == nil { + responses[commandKey("wsl.exe", "-d", "FedoraLinux-42", "--exec", "sh", "-lc", "test -f ~/.local/state/bootstrap_dev_env/wsl-complete")] = CommandResult{Err: markerErr} + } + } + return responses +}