Repository navigation
Expand file tree
/
Copy pathsetup.ps1
More file actions
1563 lines (1371 loc) · 73.5 KB
/
Copy pathsetup.ps1
File metadata and controls
1563 lines (1371 loc) · 73.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Installation Helper bootstrap wizard for Windows.
.DESCRIPTION
Configures Windows, and only Windows: the software catalogue, the npm-based CLI tools, the
installs no package mapping can express, and the four system settings. It no longer runs the
Ansible playbook. The playbook was invoked as `-i localhost, -c local` from inside WSL, so it
configured the WSL distribution rather than Windows, which was never what this wizard was for.
See docs/regression_ledger.md.
What remains of the WSL side is Ansible itself, installed inside the distribution because it is
wanted there. Nothing else in WSL is touched.
Every run ends by asking the machine what it actually ended up with, per application: installed,
missing, or not requested. There is no flag to turn it off, because a run that reports success
without checking is how every regression in docs/regression_ledger.md reached a real machine.
The full result goes to installation_verify.log in the user profile, named on screen when the
wizard finishes, and a missing application makes this script exit non-zero.
Uses Microsoft.PowerShell.ConsoleGuiTools (Out-ConsoleGridView) for the multi-select picker
with filter-as-you-type.
.PARAMETER Profile
Accepted so the documented invocation keeps working, and it skips the interactive selection the
same way -NonInteractive does. The profile file itself is an Ansible override for the Linux
side and no longer applies, which the run says out loud rather than pretending otherwise.
.PARAMETER NonInteractive
Use group_vars defaults; never prompt.
.PARAMETER NoColor
Disable themed output (also honored via $env:NO_COLOR).
.PARAMETER SkipSystemUpgrade
Do not upgrade the already-installed winget and Chocolatey packages first.
.PARAMETER Software
The starting point the checklist would have started from, for a run with no checklist.
defaults is the group_vars values as they are, all turns every selectable toggle on except
opencode_use_v1, which stays off so OpenCode stays on version 2, and none turns every selectable
toggle off so -EnableKey can build a run up from nothing. Implies -NonInteractive.
.PARAMETER EnableKey
Toggle keys to turn on, whatever group_vars says. Comma separated, or a PowerShell array, and
the keys are the ones -ListSoftware prints. A key the wizard does not offer is refused rather
than ignored, because a typo that installs nothing while the run reports success is this
project's most repeated defect. Implies -NonInteractive.
.PARAMETER DisableKey
Toggle keys to turn off. Applied after -EnableKey, so a key named in both ends up off: the
option that removes software wins over the option that adds it. Implies -NonInteractive.
.PARAMETER ListSoftware
Print every key -EnableKey and -DisableKey accept, with its default state and its checklist
label, and exit. Changes nothing.
.PARAMETER PrintPlan
Print what this run would install and apply, phase by phase, and exit. Changes nothing, reaches
no network and probes no package manager. This is where the bash wizard prints its resolved
ansible-playbook command: there is no playbook command here, because this wizard no longer runs
the playbook, so the resolved set itself is the answer to what the run would do.
There is deliberately no -DesktopEnvironment or -DesktopAction to match the bash wizard's
--desktop-environment and --desktop-action. Those choose what the playbook does about KDE or
GNOME, this wizard configures Windows and runs no playbook, and a switch that cannot change
anything is worse than an absent one.
Exit codes: 0 success, 1 at least one item failed, 2 bad usage, 3 every phase reported success
and the verification found something the run asked for missing from the machine.
.EXAMPLE
.\setup.ps1
.EXAMPLE
.\setup.ps1 -NonInteractive
.EXAMPLE
.\setup.ps1 -NonInteractive -WhatIf
.EXAMPLE
.\setup.ps1 -ListSoftware
.EXAMPLE
.\setup.ps1 -Software none -EnableKey install_k3d -PrintPlan
.EXAMPLE
.\setup.ps1 -Software all -DisableKey install_gridcoin,enable_hyperv
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string] $Profile,
[switch] $NonInteractive,
[switch] $NoColor,
[switch] $SkipSystemUpgrade,
[ValidateSet('defaults', 'all', 'none')]
[string] $Software,
[string[]] $EnableKey,
[string[]] $DisableKey,
[switch] $ListSoftware,
[switch] $PrintPlan,
# Permit an elevated run. Only correct on a machine that is thrown away afterwards, which in
# practice means a hosted CI runner, where every process is elevated and the guard cannot be
# satisfied. Explicit rather than detected, so privilege behaviour never changes by accident.
[switch] $AllowAdministrator
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$AnsibleDir = Join-Path $ScriptDir 'ansible'
$AllVars = Join-Path $AnsibleDir 'group_vars\all.yaml'
$WindowsVars = Join-Path $AnsibleDir 'group_vars\windows.yaml'
# Native Windows installation and configuration. Kept in their own files so they can be tested
# without driving the whole wizard, and because Ansible cannot do this job from here at all: it
# runs inside WSL against localhost, so its facts describe the WSL distribution rather than
# Windows. WindowsSoftware.ps1 is first because the other three read the toggles through it.
. (Join-Path $ScriptDir 'windows\WindowsSoftware.ps1')
. (Join-Path $ScriptDir 'windows\WindowsNpmTools.ps1')
. (Join-Path $ScriptDir 'windows\WindowsCustomInstalls.ps1')
. (Join-Path $ScriptDir 'windows\WindowsSettings.ps1')
# Last, because it asks the machine about what the other four installed and reads their tables to
# know what to ask about.
. (Join-Path $ScriptDir 'windows\WindowsVerify.ps1')
# Hidden from the checklist. Only two reasons qualify: the value is not a boolean the
# checklist could render, or getting it wrong costs a working machine. Everything else
# belongs on the checklist, because a setting the user cannot reach is a setting they
# cannot work around when it breaks. Must stay identical to EXCLUDED_VARS in setup.sh,
# and e2e/tier1/wizard_parse.sh fails if the two ever disagree.
$ExcludedVars = @(
# Not booleans, or derived from the environment.
'non_root_user', 'non_root_home', 'default_wallpaper',
# A logging switch rather than software.
'allow_callback_failure',
# The bootstrap every later role assumes has run.
'install_system_core',
# Needs a swap device sized for RAM plus a resume kernel parameter.
'setup_hibernate',
# These rewrite the bootloader. A mistake here means the machine does not boot.
'setup_systemd_boot', 'setup_grub',
'remove_distro_grub', 'remove_distro_systemd_boot'
)
# Checklist keys -Software all leaves off rather than on, because turning one on is not more software but a
# switch away from the default. opencode_use_v1 replaces OpenCode version 2 with version 1 and removes version
# 2, so "install all" would downgrade OpenCode, which the owner ruled out on 2026-10-15. -EnableKey still turns
# one on. Must stay identical to ALL_SOFTWARE_KEEPS_OFF in setup.sh, and e2e/tier1/wizard_parse.sh fails if the
# two ever disagree.
$AllSoftwareKeepsOff = @('opencode_use_v1')
# System settings share the checklist with software, so their labels have to say what
# they do. "Wsl" next to "Chrome" tells the user nothing about what ticking it changes.
# Keys not listed here fall through to the default prefix strip.
$LabelOverrides = @{
'set_custom_wallpaper' = 'System: set the desktop wallpaper'
'setup_wsl' = 'System: install WSL and Ubuntu (Windows)'
'enable_hyperv' = 'System: enable Hyper-V and the WSL and .NET and Sandbox features. Needs a reboot (Windows)'
'opencode_use_v1' = 'Switch OpenCode from v2 to v1'
}
if ($env:NO_COLOR -or $NoColor) {
$script:UseColor = $false
} else {
$script:UseColor = $true
}
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
function Set-Theme {
<#
.SYNOPSIS
Themes the console, and gives up quietly when there is no console to theme.
.DESCRIPTION
Clear-Host and the [Console] colour properties both go through the real console handle, and
both throw "The handle is invalid" when stdout is a pipe or a file. That killed the whole
wizard on its first line of work whenever it was run unattended, which is precisely the run
where the exit code and the summary are the only things anyone reads. Cosmetics must never
be able to end a run.
#>
try {
if ($script:UseColor) {
[Console]::BackgroundColor = [ConsoleColor]::Black
[Console]::ForegroundColor = [ConsoleColor]::DarkGreen
}
Clear-Host
} catch {
Write-Verbose "no console to theme: $($_.Exception.Message)"
}
}
function Write-Banner {
# [Console]::WindowWidth throws "The handle is invalid" when stdout is a pipe or a file, for the
# same reason Set-Theme has to tolerate it. 80 is the fallback because that is the width the
# banner was drawn for.
$w = 80
try { $w = [Math]::Max(60, [Console]::WindowWidth) } catch { Write-Verbose 'no console, using a fixed banner width' }
$border = '=' * $w
Write-Host $border -ForegroundColor Green
Write-Host ' INSTALLATION HELPER SETUP'.PadRight($w) -ForegroundColor Green
Write-Host ' Windows Configuration Wizard'.PadRight($w) -ForegroundColor DarkGreen
Write-Host $border -ForegroundColor Green
Write-Host
}
function Write-Section { param([string]$Title)
Write-Host
Write-Host " >> $Title" -ForegroundColor Green
Write-Host (' ' + ('-' * ($Title.Length + 4))) -ForegroundColor DarkGreen
Write-Host
}
function Write-Hint { param([string]$Text) Write-Host " $Text" -ForegroundColor DarkGreen }
function Write-Status { param([string]$Text) Write-Host " [*] $Text" -ForegroundColor Green }
function Write-Err { param([string]$Text) Write-Host " [!] $Text" -ForegroundColor Red; throw $Text }
function Invoke-AndShowNpmTools {
<#
.SYNOPSIS
Installs the npm-based CLI tools and renders the outcome.
.DESCRIPTION
Separate from the winget and Chocolatey pass because these come from npm, not from a package
mapping. The ai_tools Ansible role has a Windows task file for each and none of them can run,
see docs/regression_ledger.md.
#>
param(
[string[]] $OnlyKeys,
[hashtable] $ToggleOverride
)
$toggles = Get-WindowsSoftwareToggle `
-AllVarsPath (Join-Path $AnsibleDir 'group_vars\all.yaml') `
-WindowsVarsPath (Join-Path $AnsibleDir 'group_vars\windows.yaml') `
-ToggleOverride $ToggleOverride
$openCodeVersion = Get-OpenCodeVersion -AllVarsPath $AllVars -WindowsVarsPath $WindowsVars `
-ToggleOverride $ToggleOverride -OnlyKeys $OnlyKeys
$r = Invoke-WindowsNpmToolInstall -Toggles $toggles -OnlyKeys $OnlyKeys -OpenCodeVersion $openCodeVersion
if ($r.NpmMissing) {
Write-Hint ' npm is not on PATH, so the CLI tools below were NOT installed:'
Write-Hint " $(($r.Wanted | ForEach-Object { $_.Display }) -join ', ')"
Write-Hint ' Install Node.js first, then rerun. Saying so rather than skipping quietly, because'
Write-Hint ' a silent skip would leave you believing these are present.'
return $r
}
if ($r.Results.Count -eq 0) {
Write-Status 'CLI tools: none enabled'
return $r
}
Write-Status "CLI tools: $($r.Installed.Count) installed, $($r.Present.Count) already present, $($r.Failed.Count) failed"
foreach ($i in @($r.Installed | Where-Object { $_.Detail })) {
Write-Hint " $($i.Display) ($($i.Package)): $($i.Detail)"
}
foreach ($f in $r.Failed) {
Write-Host " [!] $($f.Display) ($($f.Package)): $($f.Detail)" -ForegroundColor Red
}
return $r
}
function Format-DiskSize {
<#
.SYNOPSIS
A byte count in the largest unit that keeps it readable.
#>
[OutputType([string])]
param([Parameter(Mandatory)] [long] $Byte)
if ($Byte -ge 1GB) { return ('{0:N2} GB' -f ($Byte / 1GB)) }
if ($Byte -ge 1MB) { return ('{0:N1} MB' -f ($Byte / 1MB)) }
return ('{0:N0} KB' -f ($Byte / 1KB))
}
function Clear-WindowsCacheAndShow {
<#
.SYNOPSIS
Frees the installer downloads the package phases left behind, and prints what that freed.
.DESCRIPTION
Placed between the packages and the phases that need room, because that is where the disk
ran out: the Android SDK unzips into it and `wsl --install` needs a distribution image, and
in run 32864564543 both failed with messages that never said disk. See
Clear-WindowsPackageCache in setup/windows/WindowsSoftware.ps1 for what is removed and what
is kept.
The free space either side is printed in the same shape the workflow's own cleanup step
prints, so the two numbers can be read against each other in one log.
Nothing here can fail the run. A cache that could not be emptied is named and the run
continues, because the disk is then exactly as it was before this step was reached.
#>
param()
Write-Section 'Reclaiming disk from the package caches'
$r = Clear-WindowsPackageCache
foreach ($location in $r.Locations) {
if ($location.Detail) {
Write-Status ("{0}: {1}" -f $location.Name, $location.Detail)
} else {
Write-Status ("{0}: removed {1} item(s), {2}" -f $location.Name, $location.Items, (Format-DiskSize -Byte $location.Bytes))
}
}
# -1 is what Get-FreeDiskByte answers when the volume could not be read, and printing that as a
# size would be a lie dressed as a measurement.
if ($r.FreeBefore -ge 0 -and $r.FreeAfter -ge 0) {
Write-Status ("free on {0}: {1} before, {2} after" -f $env:SystemDrive,
(Format-DiskSize -Byte $r.FreeBefore), (Format-DiskSize -Byte $r.FreeAfter))
} else {
Write-Status ("free space on {0} could not be read, so only the removed sizes above are measured" -f $env:SystemDrive)
}
foreach ($failure in $r.Failed) {
Write-Host " [!] could not remove $($failure.Path): $($failure.Detail)" -ForegroundColor Red
}
return $r
}
function Invoke-AndShowCustomInstalls {
<#
.SYNOPSIS
Runs the installs a package mapping cannot express and renders the outcome.
.DESCRIPTION
Java, Node, Flutter, the Android SDK, pipx, Gridcoin and OpenCode desktop. Separate from the mapping pass because each
needs more than one package, or no package exists at all. See
setup/windows/WindowsCustomInstalls.ps1 for what each one does and why.
#>
param(
[string[]] $OnlyKeys,
[hashtable] $ToggleOverride
)
$toggles = Get-WindowsSoftwareToggle `
-AllVarsPath (Join-Path $AnsibleDir 'group_vars\all.yaml') `
-WindowsVarsPath (Join-Path $AnsibleDir 'group_vars\windows.yaml') `
-ToggleOverride $ToggleOverride
$openCodeVersion = Get-OpenCodeVersion -AllVarsPath $AllVars -WindowsVarsPath $WindowsVars `
-ToggleOverride $ToggleOverride -OnlyKeys $OnlyKeys
$mappings = Get-WindowsSoftwareMapping -WindowsMappingPath (Join-Path $AnsibleDir 'vars\Windows.yaml')
$r = Invoke-WindowsCustomInstall -Toggles $toggles -OnlyKeys $OnlyKeys -OpenCodeVersion $openCodeVersion -Mappings $mappings
if ($r.Results.Count -eq 0) {
Write-Status 'SDKs and standalone installers: none enabled'
return $r
}
Write-Status "SDKs and standalone installers: $($r.Installed.Count) done, $($r.Present.Count) already present, $($r.Failed.Count) failed"
# The OpenCode rows name what they removed or moved, which nothing else on the screen would say.
foreach ($i in @($r.Installed | Where-Object { $_.Key -in @('opencode_desktop', 'opencode_data') -and $_.Detail })) {
Write-Hint " [INFO] $($i.Key): $($i.Detail)"
}
foreach ($f in $r.Failed) {
Write-Host " [!] $($f.Key) ($($f.Package)): $($f.Detail)" -ForegroundColor Red
}
return $r
}
function Show-WindowsSoftwareResult {
<#
.SYNOPSIS
Renders the result of the native Windows install.
.DESCRIPTION
Names every failure and every toggle that had nowhere to go. A count on its own would
let a package quietly not install, which is the class of bug this replaces.
#>
param([Parameter(Mandatory)] $Result)
Write-Status "Windows packages: $($Result.Installed.Count) installed, $($Result.Present.Count) already present, $($Result.Skipped.Count) not attempted, $($Result.Failed.Count) failed"
# Each one with its reason, the way the settings phase prints them, because a skip here can be a
# per-user copy standing in for a machine-wide install, and the reason carries the command that
# fixes it. A count alone scrolled that away with the package lines above it.
foreach ($s in $Result.Skipped) {
Write-Hint " $($s.Key) ($($s.Package)): $($s.Detail)"
}
foreach ($f in $Result.Failed) {
Write-Host " [!] $($f.Key) ($($f.Package)) via $($f.Manager): $($f.Detail)" -ForegroundColor Red
}
if ($Result.Unmapped.Count -gt 0) {
Write-Hint " $($Result.Unmapped.Count) enabled toggle(s) have no Windows package mapping and were not installed:"
Write-Hint " $($Result.Unmapped -join ', ')"
Write-Hint ' Some of those are Linux-only by design. The rest are handled by Ansible roles that cannot run on Windows yet, see docs/regression_ledger.md.'
}
}
function Invoke-AndShowDevEnvironment {
<#
.SYNOPSIS
Writes the development environment variables and renders the outcome.
.DESCRIPTION
The Windows half of what roles/env_variables does on Linux and macOS. env_windows.yaml held
these and could never run, because the playbook is invoked from inside WSL and reports
os_family Debian, so on Windows they were never written at all. Not gated on a toggle, for
the same reason the Unix role is not: the paths are where this project keeps tool state, and
a machine configured by either side should agree about them.
#>
param()
$r = Set-WindowsDevEnvironment
$installed = @($r | Where-Object { $_.Status -eq 'installed' })
$present = @($r | Where-Object { $_.Status -eq 'present' })
$failed = @($r | Where-Object { $_.Status -eq 'failed' })
Write-Status "Environment variables: $($installed.Count) written, $($present.Count) already correct, $($failed.Count) failed"
foreach ($f in $failed) {
Write-Host " [!] $($f.Package): $($f.Detail)" -ForegroundColor Red
}
if ($installed.Count -gt 0) {
Write-Hint ' A shell already open will not see these. Open a new one, and log out and back in for anything started by Explorer.'
}
return [PSCustomObject]@{
Results = $r
Installed = $installed
Present = $present
Skipped = @($r | Where-Object { $_.Status -eq 'skipped' })
Failed = $failed
}
}
function Invoke-AndShowWindowsSettings {
<#
.SYNOPSIS
Applies the three Windows system settings and renders the outcome.
.DESCRIPTION
The optional features, the WSL distribution, the wallpaper and the hibernate task. Each one
had a windows_core task file that can never run, and until now nothing else applied them, so
four toggles the user could switch on did nothing at all. See
setup/windows/WindowsSettings.ps1 for what each one does and how it differs from the dead
Ansible.
#>
param(
[string[]] $OnlyKeys,
[hashtable] $ToggleOverride
)
$toggles = Get-WindowsGroupVarToggle -AllVarsPath $AllVars -WindowsVarsPath $WindowsVars -ToggleOverride $ToggleOverride
$r = Invoke-WindowsSystemSetting -Toggles $toggles -SetupDir $ScriptDir -OnlyKeys $OnlyKeys
if ($r.Results.Count -eq 0) {
Write-Status 'Windows system settings: none enabled'
return $r
}
Write-Status "Windows system settings: $($r.Installed.Count) applied, $($r.Present.Count) already set, $($r.Skipped.Count) not attempted, $($r.Failed.Count) failed"
foreach ($s in $r.Skipped) {
Write-Hint " $($s.Key) ($($s.Package)): $($s.Detail)"
}
foreach ($f in $r.Failed) {
Write-Host " [!] $($f.Key) ($($f.Package)): $($f.Detail)" -ForegroundColor Red
}
if ($r.RebootRequired) {
Write-Hint ' Some of those need a reboot before they do anything. They are reported as applied and not as active, because a feature that is enabled and not yet running is not a finished job.'
}
return $r
}
function Invoke-AndShowWslAnsible {
<#
.SYNOPSIS
Installs Ansible inside WSL and renders the outcome.
.DESCRIPTION
The only thing this wizard still does to WSL. The playbook used to be run from here against
localhost inside the distribution, which configured Linux rather than Windows, and that is
gone. Ansible itself stays, because it is wanted in there.
A problem here is reported rather than thrown. This runs after the Windows work, and losing
an entire finished Windows run to an apt failure inside WSL would throw away everything that
did succeed.
#>
param()
$r = Install-AnsibleInWsl
Write-Status "Ansible inside WSL: $($r.Installed.Count) installed, $($r.Present.Count) already present, $($r.Skipped.Count) not attempted, $($r.Failed.Count) failed"
foreach ($s in $r.Skipped) {
Write-Hint " $($s.Key) ($($s.Package)): $($s.Detail)"
}
foreach ($f in $r.Failed) {
Write-Host " [!] $($f.Key) ($($f.Package)): $($f.Detail)" -ForegroundColor Red
}
return $r
}
function Invoke-AndShowVerification {
<#
.SYNOPSIS
Asks the machine what actually landed, prints the verdicts, and returns the result.
.DESCRIPTION
The last thing a run does, on every path that installs anything, which is what setup.sh does
with finish_run on Linux and macOS. Everything above this point reports what the installers
believed they did. This reports what is on the disk, which is a different claim and the only
one worth exiting on.
It installs nothing and changes nothing, so there is no option to skip it. A run that says
success without asking the machine is the shape of every regression in
docs/regression_ledger.md.
The whole report goes to the log and to the screen. The Ansible side prints only the report
lines it lifts out of the play's own output, because Ansible surrounds them with a task list
nobody can read a verdict out of. Here the report is all there is.
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[string[]] $OnlyKeys,
[hashtable] $ToggleOverride
)
Write-Section 'Verifying what actually landed'
Write-Hint ' This asks winget, Chocolatey, npm and the disk itself. It installs and changes nothing.'
Write-Hint ' It asks winget once per package, so it takes a couple of minutes on a full selection.'
$result = Invoke-WindowsVerification -AnsibleDir $AnsibleDir -OnlyKeys $OnlyKeys -ToggleOverride $ToggleOverride
Write-Host
foreach ($line in $result.Report) { Write-Host " $line" }
Write-Host
if (-not $result.LogWritten) {
Write-Hint " The report above could not be written to $($result.LogPath), so this screen is the only copy."
} elseif ($result.Failed.Count -eq 0) {
Write-Status "Verification passed. Full result: $($result.LogPath)"
} else {
Write-Host " [!] Verification FAILED: $($result.Failed.Count) item(s) requested and not present. Full result: $($result.LogPath)" -ForegroundColor Red
}
return $result
}
function Get-PhaseFailure {
<#
.SYNOPSIS
One phase's failures, normalised to Key, Package and Detail.
.DESCRIPTION
The phases return slightly different shapes: the npm tools carry a Display name rather than
a Key, and when npm is absent they carry no results at all, only the list of tools that were
wanted. That last case is a total loss reported as a single flag, which is exactly how the
old count read it as one failure however many tools the user had asked for.
#>
[CmdletBinding()]
[OutputType([PSCustomObject[]])]
param([Parameter(Mandatory)] [AllowNull()] $Result)
if ($null -eq $Result) { return @() }
$properties = $Result.PSObject.Properties.Name
if (($properties -contains 'NpmMissing') -and $Result.NpmMissing) {
return @($Result.Wanted | ForEach-Object {
[PSCustomObject]@{
Key = $_.Key
Package = $_.Package
Detail = 'npm is not on PATH, so this was never installed. Install Node.js first, then rerun.'
}
})
}
return @($Result.Failed | ForEach-Object {
[PSCustomObject]@{
Key = if ($_.PSObject.Properties.Name -contains 'Key') { $_.Key } else { $_.Display }
Package = $_.Package
Detail = $_.Detail
}
})
}
function Write-RunSummary {
<#
.SYNOPSIS
Prints every failure from every phase, by key and package, and returns how many there were.
.DESCRIPTION
The last thing the run prints, unconditionally. Each phase already names its own failures as
it goes, but a long run scrolls them off the screen, and the one line that used to reconcile
them sat inside a condition that skipped it whenever the playbook had failed too. So a
Windows package failure could be printed early, buried under the playbook's output, and
never mentioned again.
Both entry paths call this, because the non-interactive branch and the interactive one each
had their own copy of the reconciliation and only one of them ever got fixed.
#>
[CmdletBinding()]
[OutputType([int])]
param([Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Phase)
Write-Section 'Summary'
$total = 0
foreach ($label in $Phase.Keys) {
$failures = @(Get-PhaseFailure -Result $Phase[$label])
if ($failures.Count -eq 0) {
Write-Status "${label}: nothing failed"
continue
}
$total += $failures.Count
Write-Host " [!] ${label}: $($failures.Count) failed" -ForegroundColor Red
foreach ($f in $failures) {
Write-Host " $($f.Key) ($($f.Package)): $($f.Detail)" -ForegroundColor Red
}
}
foreach ($label in $Phase.Keys) {
$result = $Phase[$label]
if ($null -eq $result) { continue }
if (($result.PSObject.Properties.Name -contains 'RebootRequired') -and $result.RebootRequired) {
Write-Hint " ${label} changed something that needs a reboot before it takes effect. Reboot, then rerun this wizard to confirm."
}
}
if ($total -eq 0) {
Write-Status 'Nothing failed.'
} else {
Write-Host " [!] $total item(s) failed in total. Exiting non-zero so this is not read as a clean run." -ForegroundColor Red
}
return $total
}
# ---------------------------------------------------------------------------
# Guards
# ---------------------------------------------------------------------------
function Assert-NotAdmin {
<#
.SYNOPSIS
Refuse to run elevated, unless the caller says it means to.
.DESCRIPTION
Running this wizard elevated puts root-owned files in a normal user's profile and installs
per-user software for the wrong account, which is why the refusal exists and why it is the
first thing Invoke-Main does.
-AllowAdministrator is the documented way past it, and it is a parameter rather than an
environment sniff for the same reason -PasswordlessSudo is: privilege behaviour should change
because someone asked, not because the script noticed something about its surroundings.
The case that needs it is a hosted continuous integration runner. GitHub's own documentation
states that "Windows virtual machines are configured to run as administrators with User
Account Control (UAC) disabled", so every process on such a runner reads as elevated and this
guard would refuse on its first line, every time, with no way to comply. There is no user
profile to pollute there, because the machine is destroyed after the job.
#>
if (-not (Test-IsAdministrator)) {
return
}
if ($AllowAdministrator) {
Write-Hint 'Running elevated because -AllowAdministrator was passed. On a real machine this leaves root-owned files in your profile.'
return
}
Write-Err 'Do not run this script as Administrator. Open a normal PowerShell 7 window and run it there. It asks for administrator rights itself, one Windows prompt per step that needs them. Pass -AllowAdministrator only on a throwaway machine such as a CI runner.'
}
function Assert-AnsibleDir {
if (-not (Test-Path $AnsibleDir)) {
Write-Err "Ansible directory not found at $AnsibleDir"
}
}
# ---------------------------------------------------------------------------
# WSL — Ansible only
# ---------------------------------------------------------------------------
function Install-AnsibleInWsl {
<#
.SYNOPSIS
Ensures WSL exists and Ansible is installed inside it, and returns a result summary.
.DESCRIPTION
Returns an object in the same shape as the Windows phases rather than throwing, so the run
summary can name what went wrong here alongside everything else. It used to throw through
Write-Err, which was correct while the whole point of the wizard was the playbook and is
wrong now: WSL is no longer on the critical path for anything this wizard does to Windows.
The apt branch adds the Ansible PPA because Ubuntu's own archive lags, which is the only apt
work left in here. The system upgrade that used to run first was there to prepare the
playbook, and the playbook is gone.
#>
[CmdletBinding(SupportsShouldProcess)]
param()
$results = [System.Collections.Generic.List[object]]::new()
$add = {
param([string] $Status, [string] $Detail)
$results.Add([PSCustomObject]@{ Key = 'ansible'; Package = 'ansible'; Status = $Status; Detail = $Detail })
}
if (-not (Get-Command wsl -CommandType Application -ErrorAction SilentlyContinue)) {
& $add 'failed' 'wsl.exe is not on this machine, so Ansible cannot be installed inside it. Run: wsl --install -d Ubuntu'
return New-WslResult -Results $results
}
$previousNative = $PSNativeCommandUseErrorActionPreference
try {
# Every one of these exits non-zero for reasons that are answers rather than faults: no
# distribution registered, ansible-playbook absent, a package manager that failed.
$PSNativeCommandUseErrorActionPreference = $false
$distros = @(Get-WslDistributionName)
if ($distros.Count -eq 0) {
& $add 'failed' 'WSL is installed but no Linux distribution is registered, so there is nothing to install Ansible into. Run: wsl --install -d Ubuntu'
return New-WslResult -Results $results
}
# Every registered distribution is tried, in order, until one answers, and each is asked as
# root rather than as its default user.
#
# Both halves of that are measured rather than preferred. A runner has two distributions,
# the image's own Ubuntu and the Ubuntu-26.04 the workflow registers, and taking the first
# one picked the freshly registered one, which answered an empty string at exit code 0:
# registered with --no-launch, never initialised, no user but root, and nothing in
# /etc/os-release that a shell could read. The distribution that would have worked was
# second in the list. Asking one and giving up is how a machine with a working WSL still
# gets told it has none.
$distro = $null
$distroId = $null
$probeFails = [System.Collections.Generic.List[string]]::new()
# /etc/os-release is read with cat and parsed on this side, and that is the whole point.
# `wsl -d X -- bash -c '<script>'` returns an empty string at exit code 0 from PowerShell,
# measured on 2026-08-22 against a working Ubuntu on a real machine: the argument reaches
# wsl.exe mangled by PowerShell's native argument passing, bash runs something harmless and
# prints nothing, and the caller cannot tell that from a distribution with no os-release.
# That, and not the freshly registered distribution, is why the wizard reported
# "cannot auto-install Ansible for distro family ''". cat with no shell in the way returns
# all 399 bytes of the file, and the same call shape is used for the install below.
foreach ($candidate in $distros) {
$osRelease = wsl --distribution $candidate --user root -- cat /etc/os-release 2>&1
$probeRc = $LASTEXITCODE
$fields = @{}
foreach ($line in @($osRelease)) {
if ("$line" -match '^\s*([A-Z_]+)=(.*)$') {
$fields[$Matches[1]] = $Matches[2].Trim().Trim('"').Trim("'")
}
}
$answer = if ($fields['ID_LIKE']) { $fields['ID_LIKE'] } elseif ($fields['ID']) { $fields['ID'] } else { '' }
if ($probeRc -eq 0 -and $answer) {
$distro = $candidate
$distroId = $answer
break
}
$probeFails.Add("$candidate exited $probeRc and gave no ID or ID_LIKE")
}
if (-not $distro) {
& $add 'failed' ("no registered WSL distribution could say what it is, so Ansible cannot " +
"be installed into one. Asked each of them for /etc/os-release: " +
($probeFails -join '; ') + ". A distribution installed with --no-launch has never " +
"completed its first run, so launch it once by hand and try again.")
return New-WslResult -Results $results
}
$null = wsl --distribution $distro --user root -- which ansible-playbook 2>&1
if ($LASTEXITCODE -eq 0) {
& $add 'present' "ansible-playbook is already on PATH inside '$distro'"
return New-WslResult -Results $results
}
# A list of argument lists rather than one shell string, and no sudo anywhere.
#
# No shell, because a command handed to `bash -c` through wsl.exe does not survive
# PowerShell's argument passing, which is what the probe above documents. Each step is its
# own wsl call with its arguments as an array, which needs no quoting at all and gives an
# exit code per step, so a failure names the step that failed instead of the whole chain.
#
# No sudo, because these run as root already. Asking for it would fail on a distribution
# that does not ship it, which Arch does not, and on a fresh registration where no user
# exists to be in its sudoers.
# Each step carries whether it may fail. Every one of these reaches the network, and on
# 2026-08-25 the Windows defaults cell of sweep 32839893770 lost the whole phase to one of
# them: `add-apt-repository --yes --update ppa:ansible/ansible` exited 1 after printing
# "Adding repository." The PPA was not the problem, measured the same day, it publishes for
# this suite and https://ppa.launchpadcontent.net/ansible/ansible/ubuntu/dists/resolute/Release
# answers 200. What failed was the apt update that `--update` performs, and there was no retry
# anywhere in this sequence.
#
# Optional means a failure is reported and the sequence continues. Only the PPA is optional,
# and only because the distribution's own ansible is inside the version range setup.sh
# accepts, so losing the PPA costs a newer ansible-core rather than costing Ansible. Every
# other step is required, because without it nothing is installed at all.
# The refresh the shared retry rule runs before a retry, each family's own package list.
$refreshArgs = switch -Regex ($distroId) {
'debian|ubuntu' { ,@('apt-get', 'update', '-q') }
'fedora|rhel|centos' { ,@('dnf', 'makecache', '--refresh') }
'arch' { ,@('pacman', '-Syy', '--noconfirm') }
default { $null }
}
$steps = switch -Regex ($distroId) {
'debian|ubuntu' { ,@(
@{ Args = @('apt-get', 'update', '-q'); Optional = $false },
@{ Args = @('apt-get', 'install', '-y', 'software-properties-common'); Optional = $false },
@{ Args = @('add-apt-repository', '--yes', '--update', 'ppa:ansible/ansible'); Optional = $true },
@{ Args = @('apt-get', 'install', '-y', 'ansible'); Optional = $false }
) }
'fedora|rhel|centos' { ,@( @{ Args = @('dnf', 'install', '-y', 'ansible'); Optional = $false } ) }
'arch' { ,@( @{ Args = @('pacman', '-S', '--noconfirm', 'ansible'); Optional = $false } ) }
default { $null }
}
if (-not $steps) {
& $add 'failed' ("no package manager is known for the distribution family '$distroId' " +
"reported by '$distro'. Install Ansible by hand inside WSL.")
return New-WslResult -Results $results
}
$rendered = ($steps | ForEach-Object { $_.Args -join ' ' }) -join ' && '
if (-not $PSCmdlet.ShouldProcess("$distroId inside WSL", 'install ansible')) {
& $add 'skipped' "WhatIf, would run in '$distro': $rendered"
return New-WslResult -Results $results
}
Write-Status "Installing Ansible inside WSL ($distro, family $distroId)..."
$skippedSteps = [System.Collections.Generic.List[string]]::new()
$refreshLabel = $refreshArgs -join ' '
$refreshOnce = {
wsl --distribution $distro --user root -- @($refreshArgs) | Out-Host
if ($LASTEXITCODE -ne 0) { return "'$refreshLabel' exited $LASTEXITCODE inside '$distro'" }
return ''
}
foreach ($step in $steps) {
$label = $step.Args -join ' '
# These reach the network, so they follow the retry rule every package manager shares,
# through the one helper in setup/windows/WindowsSoftware.ps1.
$stepOnce = {
wsl --distribution $distro --user root -- @($step.Args) | Out-Host
$code = $LASTEXITCODE
[PSCustomObject]@{ Succeeded = ($code -eq 0); Final = $false; Failure = "'$label' exited $code" }
}
$retry = Invoke-PackageManagerRetry -Package $label -Attempt $stepOnce -Refresh $refreshOnce
if (-not $retry.Succeeded) {
if ($step.Optional) {
$skippedSteps.Add($label)
Write-Hint (" $($retry.Detail). It is not required, " +
"so the sequence continues with the distribution's own package instead")
continue
}
& $add 'failed' ("$($retry.Detail), inside '$distro', so " +
"Ansible is not installed in there. The whole sequence is: $rendered")
return New-WslResult -Results $results
}
}
# Asked rather than assumed. This phase used to report installed on the strength of the last
# step exiting zero, which is the shape this repository has been burnt by repeatedly: a
# command that succeeds and leaves nothing behind. `which` is the same question the early-exit
# probe above asks, so a present run and an installed run are decided the same way.
$null = wsl --distribution $distro --user root -- which ansible-playbook 2>&1
if ($LASTEXITCODE -ne 0) {
& $add 'failed' ("every install step exited zero inside '$distro' and ansible-playbook is " +
"still not on PATH there, so nothing usable was installed. The sequence was: $rendered")
return New-WslResult -Results $results
}
$note = if ($skippedSteps.Count -gt 0) {
"installed inside WSL, without $($skippedSteps -join ', '), which failed and is not required"
} else {
'installed inside WSL'
}
& $add 'installed' $note
return New-WslResult -Results $results
} finally {
$PSNativeCommandUseErrorActionPreference = $previousNative
}
}
function New-WslResult {
<#
.SYNOPSIS
Wraps the WSL result rows in the same summary shape every other phase returns.
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param([Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Results)
return [PSCustomObject]@{
Results = $Results
Installed = @($Results | Where-Object { $_.Status -eq 'installed' })
Present = @($Results | Where-Object { $_.Status -eq 'present' })
Skipped = @($Results | Where-Object { $_.Status -eq 'skipped' })
Failed = @($Results | Where-Object { $_.Status -eq 'failed' })
}
}
function Invoke-WindowsSystemUpgrade {
<#
.SYNOPSIS
Upgrades the packages already installed on Windows.
.DESCRIPTION
ShouldProcess is not decoration here. Both upgrades are native commands, which -WhatIf
cannot intercept on its own, so without the guard a dry run of this wizard upgraded every
winget and Chocolatey package on the machine before printing a single "would install" line.
#>
[CmdletBinding(SupportsShouldProcess)]
param()
if ($SkipSystemUpgrade) {
Write-Status 'Skipping Windows system upgrade (-SkipSystemUpgrade).'
return
}
Write-Status 'Upgrading the packages already installed on Windows (skip with -SkipSystemUpgrade)...'
if (Get-Command winget -ErrorAction SilentlyContinue) {
if ($PSCmdlet.ShouldProcess('every installed winget package', 'winget upgrade --all')) {
Write-Status 'winget: upgrading all installed packages...'
& winget upgrade --all --accept-package-agreements --accept-source-agreements --silent --disable-interactivity
if ($LASTEXITCODE -ne 0) {
Write-Hint "winget exited with code $LASTEXITCODE (some packages may have no upgrade); continuing."
}
}
} else {
Write-Hint 'winget not found; skipping winget upgrade.'
}
if (Get-Command choco -ErrorAction SilentlyContinue) {
if ($PSCmdlet.ShouldProcess('every installed Chocolatey package', 'choco upgrade all')) {
Write-Status 'chocolatey: upgrading all installed packages...'
& choco upgrade all -y --no-progress
if ($LASTEXITCODE -ne 0) {
Write-Hint "choco exited with code $LASTEXITCODE; continuing."
}
}
} else {
Write-Hint 'chocolatey not found; skipping choco upgrade.'
}
Write-Hint 'Windows Update (OS patches) is not triggered automatically — handled by Windows itself or Settings > Windows Update.'
}
# The Ansible collections install went with the playbook run. requirements.yaml exists so the
# playbook can resolve its collections, and nothing this wizard still does needs them: a user who
# later runs the playbook inside WSL by hand installs them there with
# `ansible-galaxy collection install -r requirements.yaml`, which is what setup.sh does on Linux.
# ---------------------------------------------------------------------------
# ConsoleGuiTools bootstrap — required for the new picker UX
# ---------------------------------------------------------------------------
function Install-ConsoleGuiTools {
<#
.SYNOPSIS
Makes the software picker's module available, and returns whether it is.
.DESCRIPTION
False means the picker cannot open, and the caller then runs the default software list, the
same one -NonInteractive installs. Until 2026-10-10 a failed install threw here, which ended
the wizard before it had installed anything, although only the customise screen needs this
module. That is the shape Invoke-WindowsSoftwareInstall already follows for a missing winget:
say what is missing and what it costs, and carry on with everything that does not need it.
#>
[CmdletBinding()]
[OutputType([bool])]
param()
if (Get-Module -ListAvailable -Name Microsoft.PowerShell.ConsoleGuiTools) {
return $true
}
Write-Status 'Installing Microsoft.PowerShell.ConsoleGuiTools (one-time)...'
# Under the retry rule every install follows, with only a wait before a retry, because
# Install-Module has no refresh step of its own to run first.
$gallery = Invoke-PackageManagerRetry -Package 'Microsoft.PowerShell.ConsoleGuiTools' -Attempt {
try {
Install-Module -Name Microsoft.PowerShell.ConsoleGuiTools -Scope CurrentUser -Force -AcceptLicense -ErrorAction Stop
[PSCustomObject]@{ Succeeded = $true; Final = $false; Failure = '' }
} catch {
[PSCustomObject]@{ Succeeded = $false; Final = $false; Failure = $_.Exception.Message }
}
}
if ($gallery.Succeeded) {
return $true
}
Write-Host " [!] Could not install Microsoft.PowerShell.ConsoleGuiTools, so the software picker cannot open: $($gallery.Detail)" -ForegroundColor Yellow
Write-Hint ' This run continues with the default software list from group_vars, the same list -NonInteractive installs.'
Write-Hint ' To choose individual applications, run: Install-Module Microsoft.PowerShell.ConsoleGuiTools -Scope CurrentUser, then run setup.ps1 again.'
return $false
}
# ---------------------------------------------------------------------------
# Toggle reader
# ---------------------------------------------------------------------------
function Read-Toggles {
<#
.SYNOPSIS
One group_vars file's boolean toggles, minus the ones the checklist deliberately hides.
.DESCRIPTION
The character class is [a-z0-9_], the same class WindowsSoftware.ps1 uses in
$script:ToggleLinePattern, because a toggle name may carry a digit. It was [a-z_] here, which
cannot match install_k3d: the toggle is enabled by default, and it was invisible to the
checklist, so a user could neither see it nor untick it. Any name with a digit in it would
have gone the same way. See docs/regression_ledger.md.
#>
param([string]$FilePath)
if (-not (Test-Path $FilePath)) { return @() }
return Get-Content $FilePath |
Where-Object { $_ -match '^[a-z0-9_]+: (true|false)' } |
Where-Object { ($_ -split ':')[0] -notin $ExcludedVars } |
ForEach-Object {
if ($_ -match '^([a-z0-9_]+): (true|false)') {
[PSCustomObject]@{ Key = $Matches[1]; Enabled = ($Matches[2] -eq 'true') }
}
}
}
function Get-ToggleLabel {
<#