@rackbops/ac-agent 2.0.0-alpha.8 → 2.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1397 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Inventory the language toolchains installed on this machine (Windows + WSL)
4
+ into the artifact-console store, where the dashboard shows it and the
5
+ claude-memory-sync git log is its running history.
6
+
7
+ .DESCRIPTION
8
+ Probes a curated catalog of toolchain executables (language runtimes,
9
+ compilers, package managers, version managers, build tools, VCS / container /
10
+ infra CLIs) on the Windows side and inside each WSL distro, capturing each
11
+ tool's version, official Home + GitHub links, path, and best-effort install
12
+ source. Also dumps globally-installed packages (npm -g, pipx, uv, cargo, gem,
13
+ go). On a real change it writes toolchain-inventory-<machine>.md into the
14
+ artifacts store -- one file per box, since what is installed here is a
15
+ per-machine fact and the store is shared across machines (issue #231) --
16
+ and renders it via build.py, which lands it into the memory repo; a
17
+ no-change run does nothing. The script never writes into its own directory
18
+ (scratch/state go to %TEMP% / %LOCALAPPDATA%), so it is safe to keep under a
19
+ git repo (artifacts-console/artifact-console/data) -- and, since #320, inside the
20
+ installed package.
21
+
22
+ .PARAMETER IncludeEditors
23
+ Also probe editors / IDEs (VS Code, Neovim, Vim, Emacs, Sublime, Helix).
24
+ Off by default. To turn it on permanently, either pass this switch or flip
25
+ $IncludeEditorsDefault below to $true.
26
+
27
+ .PARAMETER WslDistro
28
+ WSL distro to probe. Defaults to 'Ubuntu'. Pass '' (empty) to skip WSL.
29
+
30
+ .PARAMETER SourcesDir
31
+ The artifact store root the report is written under. Derived from the console's
32
+ config when omitted -- it used to be a hardcoded `R:\repos\claude-memory-sync\
33
+ artifacts`, correct on one machine and resolving on a second only through a
34
+ junction (issue #231).
35
+
36
+ .PARAMETER PythonExe
37
+ The console's own python.exe, used for both the store-root lookup and the
38
+ build.py render. Passed by sched_spec.job_argv (sys.executable) and by
39
+ Register-InventoryTask.ps1 (resolved beside pythonw.exe) -- bare `python` on
40
+ PATH is not necessarily the console's interpreter (issue #632), especially
41
+ under pipx. Falls back to `python` on PATH when omitted, so a hand run still
42
+ works.
43
+
44
+ .PARAMETER OutFile
45
+ Write the report here instead of the store (for local testing). The store
46
+ render/land is skipped when you pass a path -- what would be rendered is the STORE's
47
+ copy of the report, which this run did not write. An empty value is the same as
48
+ omitting it: the report goes to the store, and that run does render.
49
+
50
+ .PARAMETER SshTargets
51
+ `alias=user@host;alias2=user@host2` -- Linux hosts to probe over key-based SSH
52
+ alongside Windows + WSL (issue #715), one `toolchain-inventory-<alias>.md` per alias.
53
+ Empty (the default) probes no SSH hosts. An unreachable host is isolated: its previous
54
+ report is left untouched, every other target (local, WSL, remaining hosts) still
55
+ publishes, and the process exits 2 so the task's Last Result shows it.
56
+
57
+ .PARAMETER NoRender
58
+ Write the report but don't render/land it into artifact-console.
59
+
60
+ .PARAMETER NoSync
61
+ Render into the docroot but don't land into the memory repo (build.py
62
+ --no-sync); the hourly ClaudeMemorySync task still commits it later.
63
+
64
+ .EXAMPLE
65
+ .\Get-ToolchainInventory.ps1
66
+ .EXAMPLE
67
+ .\Get-ToolchainInventory.ps1 -IncludeEditors
68
+ .EXAMPLE
69
+ .\Get-ToolchainInventory.ps1 -OutFile $env:TEMP\inv.md # local test, no render
70
+ #>
71
+ [CmdletBinding()]
72
+ param(
73
+ [switch]$IncludeEditors,
74
+ [string]$WslDistro = 'Ubuntu',
75
+ [string]$SourcesDir = '',
76
+ [string]$PythonExe = '',
77
+ [string]$OutFile,
78
+ [string]$SshTargets = '',
79
+ [switch]$NoRender,
80
+ [switch]$NoSync
81
+ )
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Editable defaults -- change these instead of the code below.
85
+ # Flip $IncludeEditorsDefault to $true to always include editors/IDEs.
86
+ # ---------------------------------------------------------------------------
87
+ $IncludeEditorsDefault = $false
88
+
89
+ # The report's home is the artifact-console store. On a change the script writes its
90
+ # report there and renders it via build.py, which also lands it in the claude-memory-sync
91
+ # repo, whose git log IS the history.
92
+ #
93
+ # This payload ships INSIDE the console (artifact-console/data/), so the console dir --
94
+ # where build.py and scheduled_tasks.py live -- is simply this script's PARENT. #320 (PR
95
+ # #330) moved the file here from the repo's PowerShell/ drawer as a pure rename (R100),
96
+ # leaving the drawer-era `Join-Path (Split-Path $PSScriptRoot -Parent) 'artifact-console'`
97
+ # resolving to a doubled artifact-console/artifact-console that has never existed. The
98
+ # render guard below tests that path, so its condition became permanently TRUE and the
99
+ # render branch unreachable: a run with something to publish wrote its report to the store,
100
+ # printed a yellow skip and exited 0. (A no-change run returns earlier still and is not
101
+ # affected.) So the .md kept reaching the store, but nothing rendered it into the docroot
102
+ # and nothing recorded it for landing -- sync_memory stages only the paths build.py records,
103
+ # never a blanket add -- so no automatic `chore(artifacts): sync Toolchain-Inventory/...`
104
+ # commit has landed since, every later touch made by hand. (The last one is 2026-07-20, which
105
+ # is an outer bound rather than a fingerprint: #231's per-machine rename sits between it and
106
+ # this move.) The registrar's half of the
107
+ # same rename was caught in #334; this was the other half, and it sat unnoticed for a month
108
+ # because the skip message named neither the path nor which half was missing.
109
+ #
110
+ # The parent is right in a WHEEL too, and not by luck: data/ installs as the
111
+ # `artifact_console_data` package while the modules install FLAT beside it, so
112
+ # site-packages/artifact_console_data/.. is site-packages/, which holds build.py.
113
+ #
114
+ # The store ROOT is not spelled out here any more (issue #231). It was
115
+ # `R:\repos\claude-memory-sync\artifacts`: right on the box it was written on, and on a
116
+ # second machine right only by way of a junction. Register-InventoryTask.ps1 passes the
117
+ # config-derived value; a hand run asks the console for the same one.
118
+ $ArtifactConsoleDir = Split-Path $PSScriptRoot -Parent
119
+ $ArtifactCategory = 'Toolchain-Inventory'
120
+
121
+ $ErrorActionPreference = 'Stop'
122
+ $includeEd = $IncludeEditors.IsPresent -or $IncludeEditorsDefault
123
+
124
+ # The interpreter for BOTH Python call sites below. `python` on PATH is not
125
+ # necessarily the console's own interpreter -- under pipx it never is (#632) --
126
+ # so a caller that knows the right one (sched_spec.job_argv, the registrar) passes
127
+ # it explicitly. PATH is only the fallback, for a hand run with nothing supplied.
128
+ $pyExe = if ($PythonExe) { $PythonExe } else { 'python' }
129
+
130
+ # Needed before the output path is chosen, not just in the report body: this report
131
+ # describes ONE machine, so the machine is part of its filename.
132
+ $hostName = [System.Net.Dns]::GetHostName()
133
+
134
+ if (-not $SourcesDir -and -not $OutFile) {
135
+ $jobPaths = Join-Path $ArtifactConsoleDir 'scheduled_tasks.py'
136
+ try {
137
+ # Same native-exit trap as the render below: a python that RUNS and exits non-zero
138
+ # does not throw, so without this the catch could never fire on the commonest
139
+ # failures -- a traceback, an unconfigured store, or the Windows App Execution
140
+ # Alias stub, which is on PATH and exits non-zero rather than being absent.
141
+ # ConvertFrom-Json would just yield nothing, $SourcesDir would end up empty, and
142
+ # the reason would never be printed. (A genuinely missing command is the one case
143
+ # that always threw: CommandNotFoundException, which this catch already handled.)
144
+ $jobPathsJson = & $pyExe $jobPaths --job-paths toolchain-inventory
145
+ if ($LASTEXITCODE -ne 0) { throw "scheduled_tasks.py --job-paths exited $LASTEXITCODE" }
146
+ $SourcesDir = ($jobPathsJson | ConvertFrom-Json).args.SourcesDir
147
+ } catch {
148
+ Write-Host "! could not ask the console for the store root: $($_.Exception.Message)" -ForegroundColor Yellow
149
+ }
150
+ }
151
+
152
+ $ArtifactSourcesDir = $SourcesDir
153
+
154
+ # This script lives in a git repo (artifacts-console) -- and, since #320, ships inside the
155
+ # installed package, where writing would be worse still -- so it must NEVER write into its
156
+ # own directory. Scratch (probe files) and state (last-run) go to out-of-repo temp /
157
+ # local-appdata; the report itself goes to the artifacts store.
158
+ $tmpDir = Join-Path ([IO.Path]::GetTempPath()) 'toolchain-inventory'
159
+ $stateDir = Join-Path $env:LOCALAPPDATA 'toolchain-inventory'
160
+ New-Item -ItemType Directory -Force -Path $tmpDir, $stateDir | Out-Null
161
+
162
+ # One file per machine. This report is a per-machine fact -- what is installed on THIS
163
+ # box -- and it used to be written under a single machine-agnostic name into a git-synced
164
+ # store shared by every machine. With the weekly task registered on two boxes, each run
165
+ # overwrote the other's and the category became a flip-flop: every run a large diff
166
+ # reverting the previous one, so the git log that is supposed to BE the history recorded
167
+ # no machine's toolchain drift legibly (issue #231). Keyed by hostname, each machine owns
168
+ # one file, diffs stay meaningful, and no coordination between boxes is needed.
169
+ $machine = ($hostName -replace '[^A-Za-z0-9._-]', '-')
170
+ $fileName = "toolchain-inventory-$machine.md"
171
+
172
+ $storeDir = if ($ArtifactSourcesDir) { Join-Path $ArtifactSourcesDir $ArtifactCategory } else { '' }
173
+ $storeAvailable = [bool]$ArtifactSourcesDir -and (Test-Path $ArtifactSourcesDir)
174
+ if (-not $OutFile) {
175
+ if ($storeAvailable) {
176
+ New-Item -ItemType Directory -Force -Path $storeDir | Out-Null
177
+ $OutFile = Join-Path $storeDir $fileName
178
+ } else {
179
+ $OutFile = Join-Path $stateDir $fileName
180
+ Write-Host "! artifacts store not found; writing locally to $OutFile" -ForegroundColor Yellow
181
+ }
182
+ }
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Tool catalog. One row per tool:
186
+ # Category | Name | Exe | Ver (arg array) | Alt (fallback arg array or $null)
187
+ # Add a row here to track a new tool -- it is picked up on both Windows and WSL.
188
+ # ---------------------------------------------------------------------------
189
+ $catalog = @(
190
+ # --- Language runtimes & compilers ---
191
+ @{ Cat='Languages'; Name='Node.js'; Exe='node'; Ver=@('--version') }
192
+ @{ Cat='Languages'; Name='Deno'; Exe='deno'; Ver=@('--version') }
193
+ @{ Cat='Languages'; Name='Bun'; Exe='bun'; Ver=@('--version') }
194
+ @{ Cat='Languages'; Name='Python'; Exe='python'; Ver=@('--version') }
195
+ @{ Cat='Languages'; Name='Python3'; Exe='python3'; Ver=@('--version') }
196
+ @{ Cat='Languages'; Name='Py launcher'; Exe='py'; Ver=@('--version') }
197
+ @{ Cat='Languages'; Name='Go'; Exe='go'; Ver=@('version') }
198
+ @{ Cat='Languages'; Name='Rust (rustc)';Exe='rustc'; Ver=@('--version') }
199
+ @{ Cat='Languages'; Name='Java'; Exe='java'; Ver=@('-version') }
200
+ @{ Cat='Languages'; Name='javac (JDK)'; Exe='javac'; Ver=@('-version') }
201
+ @{ Cat='Languages'; Name='.NET SDK'; Exe='dotnet'; Ver=@('--version') }
202
+ @{ Cat='Languages'; Name='Ruby'; Exe='ruby'; Ver=@('--version') }
203
+ @{ Cat='Languages'; Name='PHP'; Exe='php'; Ver=@('--version') }
204
+ @{ Cat='Languages'; Name='Lua'; Exe='lua'; Ver=@('-v') }
205
+ @{ Cat='Languages'; Name='LuaJIT'; Exe='luajit'; Ver=@('-v') }
206
+ @{ Cat='Languages'; Name='Perl'; Exe='perl'; Ver=@('--version') }
207
+ @{ Cat='Languages'; Name='GCC'; Exe='gcc'; Ver=@('--version') }
208
+ @{ Cat='Languages'; Name='G++'; Exe='g++'; Ver=@('--version') }
209
+ @{ Cat='Languages'; Name='Clang'; Exe='clang'; Ver=@('--version') }
210
+ @{ Cat='Languages'; Name='Zig'; Exe='zig'; Ver=@('version') }
211
+ @{ Cat='Languages'; Name='Kotlin'; Exe='kotlin'; Ver=@('-version') }
212
+ @{ Cat='Languages'; Name='Scala'; Exe='scala'; Ver=@('-version') }
213
+ @{ Cat='Languages'; Name='Swift'; Exe='swift'; Ver=@('--version') }
214
+ @{ Cat='Languages'; Name='GHC (Haskell)';Exe='ghc'; Ver=@('--version') }
215
+ @{ Cat='Languages'; Name='Elixir'; Exe='elixir'; Ver=@('--version') }
216
+ @{ Cat='Languages'; Name='Julia'; Exe='julia'; Ver=@('--version') }
217
+ @{ Cat='Languages'; Name='R'; Exe='R'; Ver=@('--version') }
218
+ @{ Cat='Languages'; Name='Dart'; Exe='dart'; Ver=@('--version') }
219
+ @{ Cat='Languages'; Name='Nim'; Exe='nim'; Ver=@('--version') }
220
+ @{ Cat='Languages'; Name='OCaml'; Exe='ocaml'; Ver=@('-version') }
221
+
222
+ # --- Package managers ---
223
+ @{ Cat='Package managers'; Name='npm'; Exe='npm'; Ver=@('--version') }
224
+ @{ Cat='Package managers'; Name='Yarn'; Exe='yarn'; Ver=@('--version') }
225
+ @{ Cat='Package managers'; Name='pnpm'; Exe='pnpm'; Ver=@('--version') }
226
+ @{ Cat='Package managers'; Name='pip'; Exe='pip'; Ver=@('--version') }
227
+ @{ Cat='Package managers'; Name='pipx'; Exe='pipx'; Ver=@('--version') }
228
+ @{ Cat='Package managers'; Name='Poetry'; Exe='poetry'; Ver=@('--version') }
229
+ @{ Cat='Package managers'; Name='uv'; Exe='uv'; Ver=@('--version') }
230
+ @{ Cat='Package managers'; Name='Conda'; Exe='conda'; Ver=@('--version') }
231
+ @{ Cat='Package managers'; Name='Cargo'; Exe='cargo'; Ver=@('--version') }
232
+ @{ Cat='Package managers'; Name='Gem'; Exe='gem'; Ver=@('--version') }
233
+ @{ Cat='Package managers'; Name='Bundler'; Exe='bundle'; Ver=@('--version') }
234
+ @{ Cat='Package managers'; Name='Composer'; Exe='composer'; Ver=@('--version') }
235
+ @{ Cat='Package managers'; Name='Maven'; Exe='mvn'; Ver=@('-version') }
236
+ @{ Cat='Package managers'; Name='Gradle'; Exe='gradle'; Ver=@('--version') }
237
+ @{ Cat='Package managers'; Name='LuaRocks'; Exe='luarocks'; Ver=@('--version') }
238
+
239
+ # --- Version managers ---
240
+ @{ Cat='Version managers'; Name='nvm'; Exe='nvm'; Ver=@('--version'); Alt=@('version') }
241
+ @{ Cat='Version managers'; Name='fnm'; Exe='fnm'; Ver=@('--version') }
242
+ @{ Cat='Version managers'; Name='Volta'; Exe='volta'; Ver=@('--version') }
243
+ @{ Cat='Version managers'; Name='pyenv'; Exe='pyenv'; Ver=@('--version') }
244
+ @{ Cat='Version managers'; Name='rustup'; Exe='rustup'; Ver=@('--version') }
245
+ @{ Cat='Version managers'; Name='rbenv'; Exe='rbenv'; Ver=@('--version') }
246
+ @{ Cat='Version managers'; Name='asdf'; Exe='asdf'; Ver=@('--version') }
247
+ @{ Cat='Version managers'; Name='SDKMAN'; Exe='sdk'; Ver=@('version') }
248
+
249
+ # --- Build tools & runners ---
250
+ @{ Cat='Build tools'; Name='Make'; Exe='make'; Ver=@('--version') }
251
+ @{ Cat='Build tools'; Name='CMake'; Exe='cmake'; Ver=@('--version') }
252
+ @{ Cat='Build tools'; Name='Ninja'; Exe='ninja'; Ver=@('--version') }
253
+ @{ Cat='Build tools'; Name='Meson'; Exe='meson'; Ver=@('--version') }
254
+ @{ Cat='Build tools'; Name='Bazel'; Exe='bazel'; Ver=@('--version') }
255
+ @{ Cat='Build tools'; Name='just'; Exe='just'; Ver=@('--version') }
256
+ @{ Cat='Build tools'; Name='MSBuild'; Exe='msbuild'; Ver=@('-version') }
257
+ @{ Cat='Build tools'; Name='Ant'; Exe='ant'; Ver=@('-version') }
258
+
259
+ # --- VCS, containers & infra ---
260
+ @{ Cat='VCS & infra'; Name='Git'; Exe='git'; Ver=@('--version') }
261
+ @{ Cat='VCS & infra'; Name='GitHub CLI'; Exe='gh'; Ver=@('--version') }
262
+ @{ Cat='VCS & infra'; Name='Docker'; Exe='docker'; Ver=@('--version') }
263
+ @{ Cat='VCS & infra'; Name='Docker Compose'; Exe='docker-compose'; Ver=@('--version') }
264
+ @{ Cat='VCS & infra'; Name='Podman'; Exe='podman'; Ver=@('--version') }
265
+ @{ Cat='VCS & infra'; Name='kubectl'; Exe='kubectl'; Ver=@('version','--client') }
266
+ @{ Cat='VCS & infra'; Name='Helm'; Exe='helm'; Ver=@('version','--short') }
267
+ @{ Cat='VCS & infra'; Name='Terraform'; Exe='terraform'; Ver=@('--version') }
268
+ @{ Cat='VCS & infra'; Name='OpenTofu'; Exe='tofu'; Ver=@('--version') }
269
+ @{ Cat='VCS & infra'; Name='Ansible'; Exe='ansible'; Ver=@('--version') }
270
+ # cloudflared -- added for #715: nitro/nucbox both front their consoles through a
271
+ # Cloudflare Tunnel, and the epic's exit criterion names it explicitly.
272
+ @{ Cat='VCS & infra'; Name='cloudflared'; Exe='cloudflared'; Ver=@('--version') }
273
+ )
274
+
275
+ $editorCatalog = @(
276
+ @{ Cat='Editors'; Name='VS Code'; Exe='code'; Ver=@('--version') }
277
+ @{ Cat='Editors'; Name='Neovim'; Exe='nvim'; Ver=@('--version') }
278
+ @{ Cat='Editors'; Name='Vim'; Exe='vim'; Ver=@('--version') }
279
+ @{ Cat='Editors'; Name='Emacs'; Exe='emacs'; Ver=@('--version') }
280
+ @{ Cat='Editors'; Name='Sublime'; Exe='subl'; Ver=@('--version') }
281
+ @{ Cat='Editors'; Name='Helix'; Exe='hx'; Ver=@('--version') }
282
+ )
283
+ if ($includeEd) { $catalog += $editorCatalog }
284
+
285
+ # Fixed category display order.
286
+ $catOrder = @('Languages','Package managers','Version managers','Build tools','VCS & infra','Editors')
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Reference links per tool (keyed by catalog Name): official Home + GitHub repo
290
+ # (owner/repo; URL derived). Blank where none is canonical. Add a tool here when
291
+ # you add it to $catalog. Rendered as the Home / GitHub columns.
292
+ # ---------------------------------------------------------------------------
293
+ $refLinks = @{
294
+ # Languages
295
+ 'Node.js' = @{ Home='https://nodejs.org'; Repo='nodejs/node' }
296
+ 'Deno' = @{ Home='https://deno.com'; Repo='denoland/deno' }
297
+ 'Bun' = @{ Home='https://bun.sh'; Repo='oven-sh/bun' }
298
+ 'Python' = @{ Home='https://python.org'; Repo='python/cpython' }
299
+ 'Python3' = @{ Home='https://python.org'; Repo='python/cpython' }
300
+ 'Py launcher' = @{ Home='https://docs.python.org/using/windows.html'; Repo='python/cpython' }
301
+ 'Go' = @{ Home='https://go.dev'; Repo='golang/go' }
302
+ 'Rust (rustc)' = @{ Home='https://rust-lang.org'; Repo='rust-lang/rust' }
303
+ 'Java' = @{ Home='https://openjdk.org'; Repo='openjdk/jdk' }
304
+ 'javac (JDK)' = @{ Home='https://openjdk.org'; Repo='openjdk/jdk' }
305
+ '.NET SDK' = @{ Home='https://dotnet.microsoft.com'; Repo='dotnet/sdk' }
306
+ 'Ruby' = @{ Home='https://ruby-lang.org'; Repo='ruby/ruby' }
307
+ 'PHP' = @{ Home='https://php.net'; Repo='php/php-src' }
308
+ 'Lua' = @{ Home='https://lua.org'; Repo='lua/lua' }
309
+ 'LuaJIT' = @{ Home='https://luajit.org'; Repo='LuaJIT/LuaJIT' }
310
+ 'Perl' = @{ Home='https://perl.org'; Repo='Perl/perl5' }
311
+ 'GCC' = @{ Home='https://gcc.gnu.org'; Repo='gcc-mirror/gcc' }
312
+ 'G++' = @{ Home='https://gcc.gnu.org'; Repo='gcc-mirror/gcc' }
313
+ 'Clang' = @{ Home='https://clang.llvm.org'; Repo='llvm/llvm-project' }
314
+ 'Zig' = @{ Home='https://ziglang.org'; Repo='ziglang/zig' }
315
+ 'Kotlin' = @{ Home='https://kotlinlang.org'; Repo='JetBrains/kotlin' }
316
+ 'Scala' = @{ Home='https://scala-lang.org'; Repo='scala/scala' }
317
+ 'Swift' = @{ Home='https://swift.org'; Repo='apple/swift' }
318
+ 'GHC (Haskell)' = @{ Home='https://haskell.org'; Repo='' }
319
+ 'Elixir' = @{ Home='https://elixir-lang.org'; Repo='elixir-lang/elixir' }
320
+ 'Julia' = @{ Home='https://julialang.org'; Repo='JuliaLang/julia' }
321
+ 'R' = @{ Home='https://r-project.org'; Repo='' }
322
+ 'Dart' = @{ Home='https://dart.dev'; Repo='dart-lang/sdk' }
323
+ 'Nim' = @{ Home='https://nim-lang.org'; Repo='nim-lang/Nim' }
324
+ 'OCaml' = @{ Home='https://ocaml.org'; Repo='ocaml/ocaml' }
325
+ # Package managers
326
+ 'npm' = @{ Home='https://npmjs.com'; Repo='npm/cli' }
327
+ 'Yarn' = @{ Home='https://yarnpkg.com'; Repo='yarnpkg/berry' }
328
+ 'pnpm' = @{ Home='https://pnpm.io'; Repo='pnpm/pnpm' }
329
+ 'pip' = @{ Home='https://pip.pypa.io'; Repo='pypa/pip' }
330
+ 'pipx' = @{ Home='https://pipx.pypa.io'; Repo='pypa/pipx' }
331
+ 'Poetry' = @{ Home='https://python-poetry.org'; Repo='python-poetry/poetry' }
332
+ 'uv' = @{ Home='https://docs.astral.sh/uv'; Repo='astral-sh/uv' }
333
+ 'Conda' = @{ Home='https://conda.io'; Repo='conda/conda' }
334
+ 'Cargo' = @{ Home='https://doc.rust-lang.org/cargo'; Repo='rust-lang/cargo' }
335
+ 'Gem' = @{ Home='https://rubygems.org'; Repo='rubygems/rubygems' }
336
+ 'Bundler' = @{ Home='https://bundler.io'; Repo='rubygems/rubygems' }
337
+ 'Composer' = @{ Home='https://getcomposer.org'; Repo='composer/composer' }
338
+ 'Maven' = @{ Home='https://maven.apache.org'; Repo='apache/maven' }
339
+ 'Gradle' = @{ Home='https://gradle.org'; Repo='gradle/gradle' }
340
+ 'LuaRocks' = @{ Home='https://luarocks.org'; Repo='luarocks/luarocks' }
341
+ # Version managers
342
+ 'nvm' = @{ Home=''; Repo='nvm-sh/nvm' }
343
+ 'fnm' = @{ Home=''; Repo='Schniz/fnm' }
344
+ 'Volta' = @{ Home='https://volta.sh'; Repo='volta-cli/volta' }
345
+ 'pyenv' = @{ Home=''; Repo='pyenv/pyenv' }
346
+ 'rustup' = @{ Home='https://rustup.rs'; Repo='rust-lang/rustup' }
347
+ 'rbenv' = @{ Home=''; Repo='rbenv/rbenv' }
348
+ 'asdf' = @{ Home='https://asdf-vm.com'; Repo='asdf-vm/asdf' }
349
+ 'SDKMAN' = @{ Home='https://sdkman.io'; Repo='sdkman/sdkman-cli' }
350
+ # Build tools
351
+ 'Make' = @{ Home='https://gnu.org/software/make'; Repo='' }
352
+ 'CMake' = @{ Home='https://cmake.org'; Repo='Kitware/CMake' }
353
+ 'Ninja' = @{ Home='https://ninja-build.org'; Repo='ninja-build/ninja' }
354
+ 'Meson' = @{ Home='https://mesonbuild.com'; Repo='mesonbuild/meson' }
355
+ 'Bazel' = @{ Home='https://bazel.build'; Repo='bazelbuild/bazel' }
356
+ 'just' = @{ Home='https://just.systems'; Repo='casey/just' }
357
+ 'MSBuild' = @{ Home='https://learn.microsoft.com/visualstudio/msbuild/msbuild'; Repo='dotnet/msbuild' }
358
+ 'Ant' = @{ Home='https://ant.apache.org'; Repo='apache/ant' }
359
+ # VCS & infra
360
+ 'Git' = @{ Home='https://git-scm.com'; Repo='git/git' }
361
+ 'GitHub CLI' = @{ Home='https://cli.github.com'; Repo='cli/cli' }
362
+ 'Docker' = @{ Home='https://docker.com'; Repo='docker/cli' }
363
+ 'Docker Compose' = @{ Home='https://docs.docker.com/compose'; Repo='docker/compose' }
364
+ 'Podman' = @{ Home='https://podman.io'; Repo='containers/podman' }
365
+ 'kubectl' = @{ Home='https://kubernetes.io'; Repo='kubernetes/kubernetes' }
366
+ 'Helm' = @{ Home='https://helm.sh'; Repo='helm/helm' }
367
+ 'Terraform' = @{ Home='https://terraform.io'; Repo='hashicorp/terraform' }
368
+ 'OpenTofu' = @{ Home='https://opentofu.org'; Repo='opentofu/opentofu' }
369
+ 'Ansible' = @{ Home='https://ansible.com'; Repo='ansible/ansible' }
370
+ 'cloudflared' = @{ Home='https://developers.cloudflare.com/cloudflared'; Repo='cloudflare/cloudflared' }
371
+ # Editors
372
+ 'VS Code' = @{ Home='https://code.visualstudio.com'; Repo='microsoft/vscode' }
373
+ 'Neovim' = @{ Home='https://neovim.io'; Repo='neovim/neovim' }
374
+ 'Vim' = @{ Home='https://vim.org'; Repo='vim/vim' }
375
+ 'Emacs' = @{ Home='https://gnu.org/software/emacs'; Repo='' }
376
+ 'Sublime' = @{ Home='https://sublimetext.com'; Repo='' }
377
+ 'Helix' = @{ Home='https://helix-editor.com'; Repo='helix-editor/helix' }
378
+ }
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # Helpers
382
+ # ---------------------------------------------------------------------------
383
+ function Get-CleanVersion([string]$raw) {
384
+ if (-not $raw) { return '' }
385
+ $line = ($raw -split "`n" | Where-Object { $_.Trim() -ne '' } | Select-Object -First 1)
386
+ if (-not $line) { return '' }
387
+ $line = $line.Trim()
388
+ $m = [regex]::Match($line, '\d+\.\d+(\.\d+){0,3}')
389
+ if ($m.Success) { return $m.Value }
390
+ if ($line.Length -gt 48) { return $line.Substring(0,48) }
391
+ return $line
392
+ }
393
+
394
+ function Get-WinSource([string]$path) {
395
+ if (-not $path) { return '' }
396
+ $p = $path.ToLower()
397
+ if ($p -like '*\scoop\*') { return 'scoop' }
398
+ if ($p -like '*\chocolatey\*') { return 'choco' }
399
+ if ($p -like '*\winget*' -or
400
+ $p -like '*\windowsapps\*') { return 'winget/store' }
401
+ if ($p -like '*\.cargo\*') { return 'cargo' }
402
+ if ($p -like '*\nvm*') { return 'nvm' }
403
+ if ($p -like '*\program files*') { return 'installer' }
404
+ if ($p -like '*\appdata\local\programs\*') { return 'user-install' }
405
+ return ''
406
+ }
407
+
408
+ function Get-WslSource([string]$path) {
409
+ if (-not $path) { return '' }
410
+ if ($path -like '*/.cargo/*') { return 'cargo' }
411
+ if ($path -like '*/.local/*') { return 'pip/pipx' }
412
+ if ($path -like '*/.nvm/*') { return 'nvm' }
413
+ if ($path -like '*/snap/*') { return 'snap' }
414
+ if ($path -like '/usr/*' -or $path -like '/bin/*') { return 'apt/system' }
415
+ return ''
416
+ }
417
+
418
+ # Probe a single tool on the Windows side. Returns a result hashtable or $null.
419
+ function Get-WinToolInfo($tool) {
420
+ # Only real executables / scripts -- never PS aliases, functions, or cmdlets
421
+ # (e.g. `R` is the built-in alias for Invoke-History, not the R language).
422
+ $cmd = Get-Command $tool.Exe -CommandType Application,ExternalScript -ErrorAction SilentlyContinue |
423
+ Select-Object -First 1
424
+ if (-not $cmd) { return $null }
425
+ $path = if ($cmd.Source) { $cmd.Source } else { $cmd.Name }
426
+ $raw = ''
427
+ try { $raw = (& $tool.Exe @($tool.Ver) 2>&1 | Out-String) } catch { $raw = '' }
428
+ $ver = Get-CleanVersion $raw
429
+ if (-not $ver -and $tool.Alt) {
430
+ try { $raw = (& $tool.Exe @($tool.Alt) 2>&1 | Out-String) } catch { $raw = '' }
431
+ $ver = Get-CleanVersion $raw
432
+ }
433
+ return @{ Cat=$tool.Cat; Name=$tool.Name; Version=$ver; Path=$path; Source=(Get-WinSource $path) }
434
+ }
435
+
436
+ function Format-HomeCell($homeUrl) {
437
+ if (-not $homeUrl) { return '-' }
438
+ $label = ($homeUrl -replace '^https?://','') -replace '/$',''
439
+ return "[$label]($homeUrl)"
440
+ }
441
+ function Format-RepoCell($repo) {
442
+ if (-not $repo) { return '-' }
443
+ return "[$repo](https://github.com/$repo)"
444
+ }
445
+ function Format-Table-Md($rows) {
446
+ if (-not $rows -or $rows.Count -eq 0) { return "_none found_`n" }
447
+ $sb = New-Object System.Text.StringBuilder
448
+ [void]$sb.AppendLine('| Tool | Version | Home | GitHub | Source | Path |')
449
+ [void]$sb.AppendLine('|------|---------|------|--------|--------|------|')
450
+ foreach ($r in ($rows | Sort-Object Name)) {
451
+ $ver = if ($r.Version) { $r.Version } else { '(present)' }
452
+ $src = if ($r.Source) { $r.Source } else { '-' }
453
+ $ref = $refLinks[$r.Name]
454
+ $homeCell = Format-HomeCell $ref.Home
455
+ $ghCell = Format-RepoCell $ref.Repo
456
+ [void]$sb.AppendLine("| $($r.Name) | $ver | $homeCell | $ghCell | $src | ``$($r.Path)`` |")
457
+ }
458
+ return $sb.ToString()
459
+ }
460
+
461
+ function Format-SideSection($rows) {
462
+ $sb = New-Object System.Text.StringBuilder
463
+ foreach ($cat in $catOrder) {
464
+ $catRows = $rows | Where-Object { $_.Cat -eq $cat }
465
+ if (-not $catRows) { continue }
466
+ [void]$sb.AppendLine("### $cat`n")
467
+ [void]$sb.AppendLine((Format-Table-Md $catRows))
468
+ }
469
+ return $sb.ToString()
470
+ }
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # "Unrecognized dev tool on PATH" heads-up. Scans the dirs where dev package
474
+ # managers drop user-installed CLIs (cargo/go/dotnet/npm/scoop/.local) and flags
475
+ # any executable whose name isn't a catalogued tool or a known companion binary
476
+ # of one. High-signal by construction: it never looks in system dirs, so a hit
477
+ # is almost always a real tool you installed but haven't catalogued yet.
478
+ # ---------------------------------------------------------------------------
479
+ $knownExe = @{}
480
+ foreach ($t in ($catalog + $editorCatalog)) { $knownExe[$t.Exe.ToLower()] = $true }
481
+
482
+ # Auxiliary binaries that ship WITH a catalogued tool -- not "new tools".
483
+ $companionExe = @{}
484
+ foreach ($n in @(
485
+ 'rustfmt','rustdoc','clippy-driver','cargo-clippy','cargo-fmt','cargo-miri',
486
+ 'rust-gdb','rust-gdbgui','rust-lldb','rust-analyzer',
487
+ 'gofmt','godoc','npx','pnpx','corepack','yarnpkg',
488
+ 'uvx','uvw','rls',
489
+ 'pydoc','2to3','idle','wheel','activate',
490
+ 'php-cgi','phpdbg','erl','epmd','escript','iex','mix','rebar3',
491
+ 'bundler','rdoc','ri'
492
+ )) { $companionExe[$n] = $true }
493
+
494
+ function Test-KnownTool([string]$name) {
495
+ $n = $name.ToLower()
496
+ if ($knownExe.ContainsKey($n) -or $companionExe.ContainsKey($n)) { return $true }
497
+ # version-suffixed interpreters/tools: python3.12, pip3, ruby3.3, perl5.40, node18, php8.3
498
+ if ($n -match '^(python|pip|ruby|perl|php|node|lua|luajit|clang|gcc|dotnet)w?[-.]?[0-9][0-9.]*$') { return $true }
499
+ return $false
500
+ }
501
+
502
+ function Get-UncataloguedWin {
503
+ $dirs = @(
504
+ (Join-Path $env:USERPROFILE '.cargo\bin')
505
+ (Join-Path $env:USERPROFILE '.dotnet\tools')
506
+ (Join-Path $env:USERPROFILE '.local\bin')
507
+ (Join-Path $env:APPDATA 'npm')
508
+ (Join-Path $env:USERPROFILE 'scoop\shims')
509
+ )
510
+ if (Get-Command go -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {
511
+ try {
512
+ $gb = if ($env:GOBIN) { $env:GOBIN } else { Join-Path ((& go env GOPATH 2>$null).Trim()) 'bin' }
513
+ if ($gb) { $dirs += $gb }
514
+ } catch { Write-Verbose "go env GOPATH failed; leaving the Go bin dir out of the scan." }
515
+ }
516
+ $seen = @{}; $out = @()
517
+ foreach ($d in ($dirs | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique)) {
518
+ Get-ChildItem $d -File -ErrorAction SilentlyContinue |
519
+ Where-Object { $_.Extension -in '.exe','.cmd','.bat','.ps1' } |
520
+ ForEach-Object {
521
+ $base = [IO.Path]::GetFileNameWithoutExtension($_.Name)
522
+ $bl = $base.ToLower()
523
+ if ($base -and -not (Test-KnownTool $base) -and -not $seen.ContainsKey($bl)) {
524
+ $seen[$bl] = $true
525
+ $out += @{ Name = $base; Path = $_.FullName }
526
+ }
527
+ }
528
+ }
529
+ return $out
530
+ }
531
+
532
+ function Format-Unknown-Md($items) {
533
+ $items = @($items)
534
+ $sb = New-Object System.Text.StringBuilder
535
+ [void]$sb.AppendLine('### Uncatalogued on PATH')
536
+ [void]$sb.AppendLine('')
537
+ [void]$sb.AppendLine('_Dev-manager binaries not in the catalog. Add a `$catalog` row (+ `$refLinks`) to track one._')
538
+ [void]$sb.AppendLine('')
539
+ [void]$sb.AppendLine('| Tool | Path |')
540
+ [void]$sb.AppendLine('|------|------|')
541
+ $cap = 60; $i = 0
542
+ foreach ($u in ($items | Sort-Object { $_.Name })) {
543
+ if ($i -ge $cap) { break }
544
+ [void]$sb.AppendLine("| $($u.Name) | ``$($u.Path)`` |")
545
+ $i++
546
+ }
547
+ if ($items.Count -gt $cap) { [void]$sb.AppendLine("`n_+ $($items.Count - $cap) more._") }
548
+ return $sb.ToString()
549
+ }
550
+
551
+ # ---------------------------------------------------------------------------
552
+ # Host-agnostic bash probe (issue #714, TC-2). The probe script makes no
553
+ # assumption about how it gets run -- WSL today (`wsl.exe -d <distro> -- bash
554
+ # <file>`), SSH next (TC-3) -- so "build the script", "run it via some
555
+ # transport" and "parse its output" are three separable functions, each
556
+ # testable without wsl.exe or a registered distro.
557
+ # ---------------------------------------------------------------------------
558
+
559
+ function New-ToolchainProbeScript {
560
+ <#
561
+ Builds the probe script TEXT (LF line endings) with the tool catalog embedded as a
562
+ quoted heredoc, so the whole probe is one self-contained file any transport can run --
563
+ a temp file for WSL, stdin for SSH -- instead of reading a second file only the same
564
+ host can see.
565
+ #>
566
+ param([Parameter(Mandatory)][array]$Catalog)
567
+
568
+ $tsvLines = $(foreach ($t in $Catalog) { "{0}`t{1}`t{2}" -f $t.Name, $t.Exe, ($t.Ver -join ' ') })
569
+ $tsv = ($tsvLines -join "`n")
570
+
571
+ $probeSh = @'
572
+ #!/usr/bin/env bash
573
+ interop="${TOOLCHAIN_PROBE_INTEROP_PREFIX:-/mnt/}"
574
+ declare -A known
575
+ while IFS=$'\t' read -r name exe verargs; do
576
+ [ -z "$exe" ] && continue
577
+ known["$(printf '%s' "$exe" | tr 'A-Z' 'a-z')"]=1
578
+ loc=$(command -v "$exe" 2>/dev/null) || continue
579
+ case "$loc" in "$interop"*) continue;; esac # skip Windows tools seen via interop
580
+ ver=$($exe $verargs 2>&1 | grep -m1 . | tr -d '\r')
581
+ printf '%s\t%s\t%s\n' "$name" "$ver" "$loc"
582
+ done <<'__CATALOG__'
583
+ __TSV__
584
+ __CATALOG__
585
+ echo "===GLOBALS==="
586
+ run(){ local e="$1"; local lbl="$2"; shift 2; local p; p=$(command -v "$e" 2>/dev/null) || return; case "$p" in "$interop"*) return;; esac; echo "### $lbl"; "$@" 2>/dev/null; echo; }
587
+ run npm "npm -g" npm ls -g --depth=0
588
+ run pipx "pipx" pipx list --short
589
+ run uv "uv tool" uv tool list
590
+ run cargo "cargo install" cargo install --list
591
+ run gem "gem (user)" gem list --local
592
+ if command -v go >/dev/null 2>&1; then b="$(go env GOBIN)"; [ -z "$b" ] && b="$(go env GOPATH)/bin"; if [ -d "$b" ]; then echo "### go install"; ls -1 "$b" 2>/dev/null; echo; fi; fi
593
+ echo "===UNKNOWN==="
594
+ for c in rustfmt rustdoc clippy-driver cargo-clippy cargo-fmt cargo-miri rust-gdb rust-gdbgui rust-lldb rust-analyzer rls gofmt godoc npx corepack uvx uvw pydoc 2to3 idle bundler rdoc ri erl epmd escript iex mix rebar3; do known[$c]=1; done
595
+ gb=""; if command -v go >/dev/null 2>&1; then gb="$(go env GOBIN 2>/dev/null)"; [ -z "$gb" ] && gb="$(go env GOPATH 2>/dev/null)/bin"; fi
596
+ for d in "$HOME/.cargo/bin" "$gb" "$HOME/.local/bin" "$HOME/.dotnet/tools" /usr/local/bin; do
597
+ [ -d "$d" ] || continue
598
+ for f in "$d"/*; do
599
+ [ -f "$f" ] && [ -x "$f" ] || continue
600
+ b=$(basename "$f"); bl=$(printf '%s' "$b" | tr 'A-Z' 'a-z')
601
+ case "$bl" in python[0-9]*|pip[0-9]*|ruby[0-9]*|perl[0-9]*|php[0-9]*|node[0-9]*|*.ps1) continue;; esac
602
+ [ -n "${known[$bl]}" ] && continue
603
+ printf '%s\t%s\n' "$b" "$f"
604
+ done
605
+ done | sort -u
606
+ '@
607
+ $probeSh = ($probeSh -replace "`r`n", "`n").Replace('__TSV__', $tsv)
608
+ return $probeSh
609
+ }
610
+
611
+ function Invoke-ToolchainProbe {
612
+ <#
613
+ Runs probe script text through a transport and returns the raw stdout as one string.
614
+ -Transport is a scriptblock: (ScriptText) -> raw output string. Today's only transport
615
+ is WSL (defined next to its call site, below); TC-3 adds an SSH one with the same shape.
616
+ #>
617
+ param(
618
+ [Parameter(Mandatory)][string]$ScriptText,
619
+ [Parameter(Mandatory)][scriptblock]$Transport
620
+ )
621
+ return & $Transport $ScriptText
622
+ }
623
+
624
+ function ConvertFrom-ToolchainProbeOutput {
625
+ <#
626
+ Parses one probe run's raw stdout into catalogued rows, the globals text and the
627
+ uncatalogued-tool list -- the `===GLOBALS===` / `===UNKNOWN===` sentinel split that
628
+ used to live inline in the WSL block. Same shapes as before: Rows carry
629
+ Cat/Name/Version/Path/Source (Source via Get-WslSource); Unknown carries Name/Path.
630
+ #>
631
+ param(
632
+ [Parameter(Mandatory)][AllowEmptyString()][string]$Raw,
633
+ [Parameter(Mandatory)][array]$Catalog
634
+ )
635
+
636
+ $rows = @()
637
+ $unknown = @()
638
+
639
+ $g = $Raw -split '===GLOBALS==='
640
+ $toolOut = $g[0]
641
+ $rest = if ($g.Count -gt 1) { $g[1] } else { '' }
642
+ $u = $rest -split '===UNKNOWN==='
643
+ $globalsText = $u[0].Trim()
644
+ $unknownOut = if ($u.Count -gt 1) { $u[1] } else { '' }
645
+
646
+ foreach ($line in ($toolOut -split "`n")) {
647
+ if (-not $line.Trim()) { continue }
648
+ $parts = $line -split "`t"
649
+ if ($parts.Count -lt 3) { continue }
650
+ $name = $parts[0].Trim()
651
+ $ver = Get-CleanVersion $parts[1]
652
+ $loc = $parts[2].Trim()
653
+ $catEntry = $Catalog | Where-Object { $_.Name -eq $name } | Select-Object -First 1
654
+ if (-not $catEntry) { continue }
655
+ $rows += @{ Cat = $catEntry.Cat; Name = $name; Version = $ver; Path = $loc; Source = (Get-WslSource $loc) }
656
+ }
657
+
658
+ foreach ($line in ($unknownOut -split "`n")) {
659
+ if (-not $line.Trim()) { continue }
660
+ $p = $line -split "`t"
661
+ if ($p.Count -lt 2) { continue }
662
+ $unknown += @{ Name = $p[0].Trim(); Path = $p[1].Trim() }
663
+ }
664
+
665
+ return @{ Rows = @($rows); GlobalsText = $globalsText; Unknown = @($unknown) }
666
+ }
667
+
668
+ # Change detection: ignore only the volatile _Generated: line, so a no-change run does not
669
+ # rewrite a report or create an empty commit. Top-level (not inside the dot-source guard)
670
+ # because Publish-ToolchainReport below -- called both for real and by the pwsh-driven
671
+ # tests, which dot-source this script -- reads it.
672
+ function Remove-GeneratedLine([string]$text) {
673
+ ($text -split "`n" | Where-Object { $_ -notmatch '^_Generated:' }) -join "`n"
674
+ }
675
+
676
+ # ---------------------------------------------------------------------------
677
+ # SSH toolchain probing (issue #715, TC-3). Reuses the host-agnostic probe above (the
678
+ # script text and the parser are already transport-agnostic) and adds the report-building
679
+ # and publish steps as their own functions, so the local Windows+WSL report and each
680
+ # remote host's report share one implementation instead of two copy-pasted blocks.
681
+ # ---------------------------------------------------------------------------
682
+
683
+ function ConvertTo-ToolchainSshTargets {
684
+ <#
685
+ `alias=user@host;alias2=user@host2` -> an array of (alias, target) pairs -- each
686
+ pair itself a 2-element array, so `$alias, $target = $pair` destructures it.
687
+
688
+ A malformed fragment is SKIPPED with a loud warning, not thrown -- config.py's own
689
+ validation (a target must not contain ';') is the real guard against this ever
690
+ reaching here from a registered task, but a hand run can pass -SshTargets directly,
691
+ bypassing that validation entirely. Throwing here would take the #715 failure-isolation
692
+ guarantee and undo it at the one entry point upstream of the whole per-target loop:
693
+ one bad fragment would abort every OTHER configured host's probe before it even
694
+ started, which is precisely the "one bad target kills the fleet" failure this script
695
+ exists to prevent for an unreachable HOST -- a malformed fragment deserves the same
696
+ isolation, not a worse outcome.
697
+ #>
698
+ param([string]$Raw)
699
+
700
+ $result = @()
701
+ if (-not $Raw) { return , $result }
702
+ foreach ($item in ($Raw -split ';')) {
703
+ $item = $item.Trim()
704
+ if (-not $item) { continue }
705
+ $parts = $item -split '=', 2
706
+ if ($parts.Count -ne 2 -or -not $parts[0].Trim() -or -not $parts[1].Trim()) {
707
+ Write-Host "! malformed -SshTargets entry skipped: '$item' (expected alias=user@host)" -ForegroundColor Yellow
708
+ continue
709
+ }
710
+ $result += , @($parts[0].Trim(), $parts[1].Trim())
711
+ }
712
+ return , $result
713
+ }
714
+
715
+ function Format-GlobalsSection {
716
+ <#
717
+ Renders zero or more `{Label; Text}` blocks under one "### Global packages" heading --
718
+ Windows has several labelled blocks (npm -g, pipx, ...); a remote/WSL side has one
719
+ unlabelled block whose text already carries its own "### <label>" sub-headers (the
720
+ probe script's own `run()` output). Empty/blank-text blocks are dropped, and the
721
+ heading itself is omitted when nothing is left -- so a host with no global packages
722
+ installed renders no section at all, exactly as before this was extracted.
723
+ #>
724
+ param([array]$Blocks)
725
+
726
+ $blocks = @($Blocks | Where-Object { $_ -and $_.Text })
727
+ if (-not $blocks.Count) { return '' }
728
+ $sb = New-Object System.Text.StringBuilder
729
+ [void]$sb.AppendLine('### Global packages')
730
+ [void]$sb.AppendLine('')
731
+ foreach ($b in $blocks) {
732
+ if ($b.Label) {
733
+ [void]$sb.AppendLine("**$($b.Label)**")
734
+ [void]$sb.AppendLine('')
735
+ }
736
+ [void]$sb.AppendLine('```')
737
+ [void]$sb.AppendLine($b.Text)
738
+ [void]$sb.AppendLine('```')
739
+ [void]$sb.AppendLine('')
740
+ }
741
+ return $sb.ToString()
742
+ }
743
+
744
+ function ConvertTo-ToolchainSidecarTools($rows) {
745
+ <#
746
+ Rows (Cat/Name/Version/Path/Source) -> the sidecar's `tools` array, in the SAME order
747
+ Format-SideSection/Format-Table-Md render them (walk $catOrder, then Sort-Object Name
748
+ within each category) -- so the .json and the .md agree on tool order. `version` gets
749
+ the same '(present)' fallback Format-Table-Md uses for a blank Version, so the field is
750
+ never empty (a validate() requirement, #716); `source` is '' rather than omitted when
751
+ unknown, since every tool has the key.
752
+ #>
753
+ $out = @()
754
+ foreach ($cat in $catOrder) {
755
+ $catRows = @($rows | Where-Object { $_.Cat -eq $cat } | Sort-Object Name)
756
+ foreach ($r in $catRows) {
757
+ $ver = if ($r.Version) { $r.Version } else { '(present)' }
758
+ $out += [ordered]@{
759
+ name = $r.Name
760
+ category = $r.Cat
761
+ version = $ver
762
+ path = $r.Path
763
+ source = $(if ($r.Source) { $r.Source } else { '' })
764
+ }
765
+ }
766
+ }
767
+ return , @($out)
768
+ }
769
+
770
+ function ConvertTo-ToolchainSidecarUnknown($items) {
771
+ <#
772
+ Unknown (Name/Path) -> the sidecar's `uncatalogued` array, sorted by Name. Unlike
773
+ Format-Unknown-Md this carries every entry -- no display cap -- since the JSON is a
774
+ data contract, not a rendered table.
775
+ #>
776
+ $out = @()
777
+ foreach ($u in ($items | Sort-Object { $_.Name })) {
778
+ $out += [ordered]@{ name = $u.Name; path = $u.Path }
779
+ }
780
+ return , @($out)
781
+ }
782
+
783
+ function New-ToolchainReport {
784
+ <#
785
+ Assembles ONE report's full Markdown text AND its JSON sidecar (#716, TC-4) from the
786
+ same -Sides data: title, `_Generated:` line, an optional summary line and an optional
787
+ `> History:` line, then one `## <Title>` section per entry in -Sides (tool table,
788
+ global packages, uncatalogued-on-PATH) via Format-SideSection / Format-GlobalsSection
789
+ / Format-Unknown-Md for the Markdown half, and ConvertTo-ToolchainSidecarTools /
790
+ ConvertTo-ToolchainSidecarUnknown for the sidecar half -- both read the same
791
+ -Sides.Rows / -Sides.Unknown, so the two documents can't drift apart from the same
792
+ call. Each -Sides entry now also carries a `Side` key ('windows' | 'wsl:<distro>' |
793
+ 'linux'), the sidecar's per-side identity -- distinct from `Title`, the Markdown
794
+ heading text.
795
+
796
+ Used for the LOCAL report today (Windows + WSL, two Sides entries, the summary and
797
+ history lines populated) and for a remote host's report (#715: one Sides entry with
798
+ Title 'Linux', no summary line, no history line) -- the difference between the two is
799
+ entirely in what the CALLER passes, not a second code path.
800
+
801
+ Returns @{ Markdown = <string>; Sidecar = <ordered hashtable> }. -HostId is the
802
+ report-file suffix (the store's identity for the box, e.g. 'nitro' or the sanitised
803
+ local $machine) -- the sidecar's `host`; -Hostname is the box as it reports itself
804
+ (`hostname`); -Os is 'windows' or 'linux'; -GeneratedIso is the run's UTC timestamp,
805
+ second precision, e.g. '2026-09-15T14:02:11Z' (the sidecar's `generated` -- distinct
806
+ from -Now, the local-time display string the Markdown's `_Generated:` line uses).
807
+ #>
808
+ param(
809
+ [Parameter(Mandatory)][string]$HostLabel,
810
+ [Parameter(Mandatory)][string]$GeneratedOn,
811
+ [Parameter(Mandatory)][string]$Now,
812
+ [string]$SummaryLine = '',
813
+ [string]$HistoryRel = '',
814
+ [Parameter(Mandatory)][array]$Sides,
815
+ [Parameter(Mandatory)][string]$HostId,
816
+ [Parameter(Mandatory)][string]$Hostname,
817
+ [Parameter(Mandatory)][ValidateSet('windows', 'linux')][string]$Os,
818
+ [Parameter(Mandatory)][string]$GeneratedIso
819
+ )
820
+
821
+ $md = New-Object System.Text.StringBuilder
822
+ [void]$md.AppendLine("# Toolchain Inventory: $HostLabel")
823
+ [void]$md.AppendLine('')
824
+ [void]$md.AppendLine("_Generated: $Now on ${GeneratedOn}_")
825
+ [void]$md.AppendLine('')
826
+ if ($SummaryLine) {
827
+ [void]$md.AppendLine($SummaryLine)
828
+ [void]$md.AppendLine('')
829
+ }
830
+ if ($HistoryRel) {
831
+ [void]$md.AppendLine("> History: ``git log -- $HistoryRel`` in the claude-memory-sync repo.")
832
+ [void]$md.AppendLine('')
833
+ }
834
+ $sidecarSides = @()
835
+ foreach ($side in $Sides) {
836
+ [void]$md.AppendLine("## $($side.Title)")
837
+ [void]$md.AppendLine('')
838
+ [void]$md.Append((Format-SideSection $side.Rows))
839
+ [void]$md.Append((Format-GlobalsSection $side.GlobalsBlocks))
840
+ if (@($side.Unknown).Count -gt 0) {
841
+ [void]$md.Append((Format-Unknown-Md $side.Unknown))
842
+ [void]$md.AppendLine('')
843
+ }
844
+ $sidecarSides += [ordered]@{
845
+ side = $side.Side
846
+ tools = (ConvertTo-ToolchainSidecarTools $side.Rows)
847
+ uncatalogued = (ConvertTo-ToolchainSidecarUnknown $side.Unknown)
848
+ }
849
+ }
850
+ $sidecar = [ordered]@{
851
+ schemaVersion = 1
852
+ host = $HostId
853
+ hostname = $Hostname
854
+ os = $Os
855
+ generated = $GeneratedIso
856
+ sides = @($sidecarSides)
857
+ }
858
+ return @{
859
+ Markdown = ($md.ToString() -replace "`r`n", "`n")
860
+ Sidecar = $sidecar
861
+ }
862
+ }
863
+
864
+ function Publish-ToolchainReport {
865
+ <#
866
+ Change-detect, write-if-changed, and render/land ONE report -- extracted so the local
867
+ report and each remote host's report (#715) share one implementation instead of the
868
+ local report carrying its own copy-pasted change-detection + render tail.
869
+
870
+ Ignores only the volatile `_Generated:` line when comparing (Remove-GeneratedLine), so
871
+ a no-change run does not rewrite the file or create an empty commit. -Label is '' for
872
+ the local report, so its two status lines stay BYTE-IDENTICAL to before #715 (#714's
873
+ acceptance greps for one) and '<alias>: ' for a remote. -SkipRenderReason prints its
874
+ own message instead of rendering -- the local -OutFile testing path, which writes
875
+ somewhere other than the store and so must not render the store's (different, possibly
876
+ absent) copy. Returns 'written', 'unchanged' or 'render-failed' (written to disk, but
877
+ build.py exited non-zero) -- NEVER terminates the script itself, unlike the pre-#715
878
+ local-only flow this was extracted from. A bare `exit` here would abort the enclosing
879
+ per-target loop in Invoke-RemoteToolchainInventory via $ErrorActionPreference='Stop'
880
+ (exit is not a catchable exception, so the surrounding try/catch would not save the
881
+ remaining targets), defeating the whole point of #715's failure isolation for every
882
+ target queued after the one whose render failed -- and, for the LOCAL report, would
883
+ skip SSH probing entirely on a render failure that has nothing to do with SSH
884
+ reachability. Callers decide what 'render-failed' means for the run as a whole: the
885
+ main flow folds it into the final exit code (matching the pre-#715 behaviour of exiting
886
+ 1 on a local render failure) but only AFTER SSH probing has run; Invoke-RemoteToolchainInventory
887
+ folds it into Failed like an unreachable host, so the affected alias is visible and the
888
+ loop still completes every other target.
889
+
890
+ -Sidecar (#716, TC-4) is the JSON sidecar object New-ToolchainReport built alongside
891
+ -Text, published from the SAME change verdict as the .md -- "land both or neither" --
892
+ EXCEPT that a missing sidecar is also its own trigger: the .md alone changing (or not)
893
+ decides whether $FileName itself is rewritten (so the sidecar's `generated` timestamp
894
+ never forces a no-op git diff on the .md), but the sidecar is (re)written whenever it
895
+ is either due a real change OR simply absent from disk -- covering both this feature's
896
+ first rollout (an unchanged .md that never had a sidecar) and recovery after build.py
897
+ rejects one as invalid and deletes it (build.py's own comment). Serialised with
898
+ `ConvertTo-Json -Depth 6`, LF-normalised, no BOM, keys in the order
899
+ toolchain_sidecar.py documents (that module is this contract's other half -- the
900
+ console side that validates it). Not atomic across the two files (round-2 review,
901
+ #716): a process kill between the .md write and the sidecar write below could leave a
902
+ stale-but-valid sidecar the "missing" check can't detect, since it checks presence,
903
+ not content. Consistent with every other write in this script (none are transactional
904
+ either) -- accepted rather than solved here.
905
+ #>
906
+ param(
907
+ [Parameter(Mandatory)][string]$FileName,
908
+ [Parameter(Mandatory)][string]$Text,
909
+ [Parameter(Mandatory)][object]$Sidecar,
910
+ [string]$Rel = '',
911
+ [string]$Label = '',
912
+ [switch]$NoRender,
913
+ [string]$SkipRenderReason = ''
914
+ )
915
+
916
+ $sidecarFileName = [IO.Path]::ChangeExtension($FileName, '.json')
917
+
918
+ $mdChanged = $true
919
+ if (Test-Path $FileName) {
920
+ $old = ([IO.File]::ReadAllText($FileName)) -replace "`r", ''
921
+ if ((Remove-GeneratedLine $old) -eq (Remove-GeneratedLine $Text)) { $mdChanged = $false }
922
+ }
923
+ # A missing sidecar is ALSO a publish trigger, independent of the .md's own diff --
924
+ # otherwise a host whose report text happens not to change this run (the common case
925
+ # most weeks) can never get its FIRST sidecar after this feature ships, or recover one
926
+ # build.py rejected as invalid and (per build.py's own comment) deleted: the .md text
927
+ # is identical either way, so a check keyed on the .md alone reports 'unchanged'
928
+ # forever and the sidecar never lands (#716 review). This still writes ONLY the
929
+ # sidecar when the .md itself is unchanged -- never touching $FileName -- so a
930
+ # backfill run does not bump `_Generated:` or create a no-op git diff on the .md.
931
+ $sidecarMissing = -not (Test-Path $sidecarFileName)
932
+ if (-not $mdChanged -and -not $sidecarMissing) {
933
+ Write-Host "= ${Label}No toolchain changes since last run; nothing to publish." -ForegroundColor DarkGray
934
+ return 'unchanged'
935
+ }
936
+
937
+ if ($mdChanged) {
938
+ [IO.File]::WriteAllText($FileName, $Text, (New-Object System.Text.UTF8Encoding($false)))
939
+ Write-Host "= ${Label}Report written: $FileName" -ForegroundColor Green
940
+ } else {
941
+ # Deliberately doesn't say "unchanged": this branch still returns 'written' below
942
+ # and callers (Invoke-RemoteToolchainInventory's $written bucket, the end-of-run
943
+ # summary) report it as such -- a message containing "unchanged" here would read
944
+ # as contradicting that summary line to anyone scanning a scheduled-task log.
945
+ Write-Host "= ${Label}Report text unchanged; sidecar (re)written." -ForegroundColor DarkGray
946
+ }
947
+ $sidecarJson = (($Sidecar | ConvertTo-Json -Depth 6) -replace "`r`n", "`n") + "`n"
948
+ [IO.File]::WriteAllText($sidecarFileName, $sidecarJson, (New-Object System.Text.UTF8Encoding($false)))
949
+
950
+ if ($NoRender) {
951
+ Write-Host "= -NoRender: skipped console render/land." -ForegroundColor DarkGray
952
+ } elseif ($SkipRenderReason) {
953
+ Write-Host $SkipRenderReason -ForegroundColor DarkGray
954
+ } elseif (-not $storeAvailable -or -not (Test-Path (Join-Path $ArtifactConsoleDir 'build.py'))) {
955
+ $missing = @()
956
+ if (-not $storeAvailable) { $missing += "store not found: '$ArtifactSourcesDir'" }
957
+ if (-not (Test-Path (Join-Path $ArtifactConsoleDir 'build.py'))) {
958
+ $missing += "build.py not found under '$ArtifactConsoleDir'"
959
+ }
960
+ Write-Host "! $($missing -join '; '); wrote the report but skipped render." -ForegroundColor Yellow
961
+ } else {
962
+ $buildArgs = @('build.py', '--report', $Rel)
963
+ if ($NoSync) { $buildArgs += '--no-sync' }
964
+ Push-Location $ArtifactConsoleDir
965
+ # Piped through Write-Host, not left as a bare native call (issue #715 follow-up):
966
+ # Publish-ToolchainReport's own return value is CAPTURED by every caller
967
+ # ($localResult = ...; $result = ... in Invoke-RemoteToolchainInventory), and a
968
+ # captured function's un-redirected native-command output is swallowed into that
969
+ # same captured collection instead of reaching the console/log -- verified directly
970
+ # (`$x = function { & cmd /c echo hi }` yields $x = @('hi ', <return value>), and
971
+ # nothing is printed) -- which is why build.py's own "rendered / committed /
972
+ # pushed" lines stopped reaching the job log the moment this call moved from
973
+ # top-level script code into a function whose result every caller assigns.
974
+ # Write-Host bypasses the success-output stream entirely, so it is never captured
975
+ # regardless of how the caller uses this function's return value.
976
+ try { & $pyExe @buildArgs 2>&1 | ForEach-Object { Write-Host $_ } } finally { Pop-Location }
977
+ # A native command's non-zero exit does NOT throw, even under $ErrorActionPreference =
978
+ # 'Stop': $PSNativeCommandUseErrorActionPreference is off by default (verified $false on
979
+ # pwsh 7.6.5). Without this check the line below announces a render that did not happen
980
+ # and the script still exits 0 -- the same silent-success shape as the bug that buried
981
+ # this whole branch pre-#320. Exit non-zero so the task's LastTaskResult shows it.
982
+ if ($LASTEXITCODE -ne 0) {
983
+ Write-Host "! build.py exited $LASTEXITCODE; the report is written but NOT rendered: $Rel" -ForegroundColor Red
984
+ return 'render-failed'
985
+ }
986
+ Write-Host "= Rendered to artifact-console$(if($NoSync){' (no sync)'}else{' + landed to memory repo'}): $Rel" -ForegroundColor Green
987
+ }
988
+ return 'written'
989
+ }
990
+
991
+ function Resolve-ToolchainSshExe {
992
+ <#
993
+ ssh.exe resolved explicitly: the scheduled task's PATH is not the interactive one.
994
+ Prefers the built-in OpenSSH client (present on every Windows 10/11 box since the
995
+ optional feature shipped in-box) over whatever `ssh` a `Get-Command` search turns up,
996
+ for the same reason Register-InventoryTask.ps1 resolves pwsh explicitly rather than
997
+ trusting PATH.
998
+ #>
999
+ $sshExe = Join-Path $env:SystemRoot 'System32\OpenSSH\ssh.exe'
1000
+ if (Test-Path -LiteralPath $sshExe) { return $sshExe }
1001
+ $cmd = Get-Command ssh -ErrorAction SilentlyContinue
1002
+ if ($cmd) { return $cmd.Source }
1003
+ return $null
1004
+ }
1005
+
1006
+ function Invoke-ToolchainSshTransport {
1007
+ <#
1008
+ Runs one SSH command, feeding -ScriptText to its stdin as EXACT bytes -- UTF-8, no BOM,
1009
+ no CR -- via a raw System.Diagnostics.Process rather than PowerShell's `|` pipeline
1010
+ operator into a native command.
1011
+
1012
+ THE BUG THIS EXISTS TO FIX (issue #715 follow-up, caught by the real Melody run against
1013
+ real nitro/nucbox -- both `ssh exited 127: bash: line 1: $'hostname\r': command not
1014
+ found`). The original transport was `$ScriptText | & $sshExe ...`: piping a string to a
1015
+ native command through PowerShell's own pipeline (or calling `.WriteLine()` on its
1016
+ StandardInput directly) terminates it with `Environment.NewLine` (`\r\n` on Windows)
1017
+ regardless of the string's own line endings -- verified directly (`od -An -c` on the
1018
+ raw bytes a real local process received). A trailing CRLF on its own does not reliably
1019
+ reproduce a FAILURE against a real local bash (a real local `bash -s` runs `hostname\r\n`
1020
+ fine); the exact failure mode over a real ssh channel could not be reproduced from this
1021
+ sandbox (no live target, and the orchestrator's post-merge run is the one place that
1022
+ can exercise real ssh). What IS provably true, and is what this function and its own
1023
+ tests are built around, is that writing exact, caller-specified bytes -- no CR/CRLF
1024
+ PowerShell's own string-to-native-pipe handling would otherwise add -- is unambiguously
1025
+ correct regardless of the precise mechanism behind the original failure, and a raw
1026
+ `System.Diagnostics.Process` with a direct byte-stream write is the one way to guarantee
1027
+ it. No fake `-Transport` scriptblock test can see ANY of this: it is specific to real
1028
+ native-process stdin behaviour, which is why this is now its own function, driven
1029
+ directly by a REAL local bash standing in for ssh (see test_toolchain_ssh_transport.py)
1030
+ rather than only ever exercised through a fake transport. The remote-side band-aid
1031
+ (`tr -d '\r'`) was rejected: the SENDER has to be correct, not the receiver made
1032
+ tolerant of a bug whose exact trigger isn't even fully pinned down.
1033
+
1034
+ -Arguments is the full, exact argv `$SshExe` runs with (`-o BatchMode=yes ...
1035
+ <target> bash -s` in production) -- passed in whole by the caller, not built here, so
1036
+ a test can point -SshExe at a real bash and -Arguments at just `@('-s')`, skipping the
1037
+ ssh-specific flags/target entirely, and still drive the SAME stdin-writing code this
1038
+ function uses for real. Throws (naming the exit code and any stderr) on a non-zero
1039
+ exit, matching the original transport's contract.
1040
+ #>
1041
+ param(
1042
+ [Parameter(Mandatory)][string]$SshExe,
1043
+ [Parameter(Mandatory)][string[]]$Arguments,
1044
+ [Parameter(Mandatory)][AllowEmptyString()][string]$ScriptText
1045
+ )
1046
+ if (-not $SshExe) { throw 'ssh.exe not found' }
1047
+
1048
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
1049
+ $psi.FileName = $SshExe
1050
+ foreach ($a in $Arguments) { [void]$psi.ArgumentList.Add($a) }
1051
+ $psi.RedirectStandardInput = $true
1052
+ $psi.RedirectStandardOutput = $true
1053
+ $psi.RedirectStandardError = $true
1054
+ # Explicit UTF-8 on the READ side too, not just the write: with neither set, .NET
1055
+ # falls back to the console's own output encoding (on this box, code page 437) for
1056
+ # decoding the child's stdout/stderr, silently mangling any non-ASCII byte a remote
1057
+ # tool's version string or path can carry -- verified directly (round-tripping
1058
+ # 'cafe unicode' through an unset-encoding child comes back corrupted). "Byte-exact"
1059
+ # cuts both ways: the fix is for the WRITE side's bug, but the read side gets the same
1060
+ # standard while this function is already being rewritten.
1061
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
1062
+ $psi.StandardOutputEncoding = $utf8NoBom
1063
+ $psi.StandardErrorEncoding = $utf8NoBom
1064
+ $psi.UseShellExecute = $false
1065
+ $psi.CreateNoWindow = $true
1066
+
1067
+ $proc = [System.Diagnostics.Process]::Start($psi)
1068
+ try {
1069
+ # Reads started BEFORE writing stdin, and run async throughout: the child can start
1070
+ # producing output before we finish writing input, and on a large enough probe
1071
+ # script writing everything first (with nothing draining stdout/stderr) risks the
1072
+ # classic same-process pipe deadlock -- the child blocks on a full stdout/stderr
1073
+ # pipe while we block trying to finish writing a full stdin pipe.
1074
+ $stdoutTask = $proc.StandardOutput.ReadToEndAsync()
1075
+ $stderrTask = $proc.StandardError.ReadToEndAsync()
1076
+ try {
1077
+ # LF only, defensively: New-ToolchainProbeScript already emits LF-only text
1078
+ # (its own `-replace "`r`n", "`n"`), but this is the one place a CR could
1079
+ # still slip through -- a one-line literal like 'hostname' has no line ending
1080
+ # of its own to normalise, and a future caller might pass one that does.
1081
+ $normalized = $ScriptText -replace "`r`n", "`n" -replace "`r", "`n"
1082
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
1083
+ $proc.StandardInput.BaseStream.Write($bytes, 0, $bytes.Length)
1084
+ $proc.StandardInput.BaseStream.Flush()
1085
+ } catch {
1086
+ # A process that exits (or closes its own stdin) before or while we're still
1087
+ # writing -- e.g. a real ssh auth failure ("Permission denied (publickey)"),
1088
+ # which never even spawns bash -- throws here ("the pipe is being closed")
1089
+ # BEFORE the real, useful failure reason below is ever reached. Swallowing it
1090
+ # is deliberate: the process's own exit code + stderr, read next, is the
1091
+ # actual cause, and a caller told "the pipe is being closed" instead of
1092
+ # "Permission denied" or "Connection refused" has lost the one thing this
1093
+ # whole function exists to report accurately.
1094
+ Write-Verbose "stdin write failed (process likely already exited): $($_.Exception.Message)"
1095
+ } finally {
1096
+ try { $proc.StandardInput.Close() } catch { Write-Verbose "stdin already closed." }
1097
+ }
1098
+ $proc.WaitForExit()
1099
+ $stdout = $stdoutTask.GetAwaiter().GetResult()
1100
+ $stderr = $stderrTask.GetAwaiter().GetResult()
1101
+
1102
+ if ($proc.ExitCode -ne 0) {
1103
+ throw "ssh exited $($proc.ExitCode)$(if ($stderr.Trim()) { ": $($stderr.Trim())" })"
1104
+ }
1105
+ return $stdout
1106
+ } finally {
1107
+ $proc.Dispose()
1108
+ }
1109
+ }
1110
+
1111
+ function Invoke-RemoteToolchainInventory {
1112
+ <#
1113
+ Probes every configured SSH target, publishing one toolchain-inventory-<alias>.md per
1114
+ reachable host and leaving an unreachable host's previous report untouched (#715).
1115
+
1116
+ -Transport is `param($target, $scriptText)` -> raw stdout as one string, and throws (or
1117
+ the caller treats a null/empty hostname response as unreachable too) when the host
1118
+ can't be reached; the production transport pipes the script over
1119
+ `ssh -o BatchMode=yes -o ConnectTimeout=8 <target> bash -s` (script on stdin, no remote
1120
+ temp file), with ssh.exe resolved explicitly since the scheduled task's PATH is not the
1121
+ interactive one. -Now is the SAME 'yyyy-MM-dd HH:mm' string the local report's
1122
+ _Generated: line uses, so every report from one run agrees. -LocalHost names where the
1123
+ SSH hop originates, for the remote _Generated: line's "via ssh from <local>".
1124
+
1125
+ Per target, in order: the hostname (the transport given the one-line script
1126
+ `hostname`, so the title/`_Generated:` line carries the real name, not the alias) --
1127
+ then the host-agnostic probe script through the same transport, parsed, built into a
1128
+ single-`linux`-side report, and published under `-Label '<alias>: '`. A failure at any
1129
+ step for one target is caught, printed as `! <alias>: unreachable (<reason>)`, and does
1130
+ NOT stop the loop -- the local report and every other target still publish.
1131
+ #>
1132
+ param(
1133
+ [Parameter(Mandatory)][array]$Targets,
1134
+ [Parameter(Mandatory)][scriptblock]$Transport,
1135
+ [Parameter(Mandatory)][string]$Now,
1136
+ [Parameter(Mandatory)][string]$NowIso,
1137
+ [Parameter(Mandatory)][string]$LocalHost,
1138
+ [switch]$NoRender
1139
+ )
1140
+
1141
+ $written = @()
1142
+ $unchanged = @()
1143
+ $failed = @()
1144
+
1145
+ foreach ($pair in $Targets) {
1146
+ $alias, $target = $pair
1147
+ try {
1148
+ $remoteHostName = ((& $Transport $target 'hostname') | Out-String).Trim()
1149
+ if (-not $remoteHostName) { throw 'empty hostname response' }
1150
+
1151
+ $probeScript = New-ToolchainProbeScript -Catalog $catalog
1152
+ $raw = (& $Transport $target $probeScript) | Out-String
1153
+ $parsed = ConvertFrom-ToolchainProbeOutput -Raw $raw -Catalog $catalog
1154
+
1155
+ $side = @{
1156
+ Title = 'Linux'
1157
+ Side = 'linux'
1158
+ Rows = $parsed.Rows
1159
+ GlobalsBlocks = @(@{ Label = $null; Text = $parsed.GlobalsText })
1160
+ Unknown = $parsed.Unknown
1161
+ }
1162
+ $report = New-ToolchainReport -HostLabel "$remoteHostName ($alias)" `
1163
+ -GeneratedOn "$remoteHostName via ssh from $LocalHost" -Now $Now -Sides @($side) `
1164
+ -HostId $alias -Hostname $remoteHostName -Os 'linux' -GeneratedIso $NowIso
1165
+
1166
+ $remoteFileName = "toolchain-inventory-$alias.md"
1167
+ $remoteOutPath = if ($storeAvailable) { Join-Path $storeDir $remoteFileName } else { Join-Path $stateDir $remoteFileName }
1168
+ $remoteRel = "$ArtifactCategory/$remoteFileName"
1169
+
1170
+ $result = Publish-ToolchainReport -FileName $remoteOutPath -Text $report.Markdown `
1171
+ -Sidecar $report.Sidecar -Rel $remoteRel -Label "${alias}: " -NoRender:$NoRender
1172
+ # 'render-failed' (build.py exited non-zero) is isolated exactly like an
1173
+ # unreachable host: the report DID reach the store, but is not counted as a
1174
+ # clean success, and this alias must not stop the loop for the others.
1175
+ if ($result -eq 'written') { $written += $alias }
1176
+ elseif ($result -eq 'unchanged') { $unchanged += $alias }
1177
+ else { $failed += $alias }
1178
+ } catch {
1179
+ Write-Host "! ${alias}: unreachable ($($_.Exception.Message))" -ForegroundColor Yellow
1180
+ $failed += $alias
1181
+ }
1182
+ }
1183
+
1184
+ return @{
1185
+ Written = $written
1186
+ Unchanged = $unchanged
1187
+ Failed = $failed
1188
+ ExitCode = $(if ($failed.Count -gt 0) { 2 } else { 0 })
1189
+ }
1190
+ }
1191
+
1192
+ # Run the probing/report/render flow only when executed (pwsh -File / & script.ps1),
1193
+ # not when dot-sourced for testing (. ./Get-ToolchainInventory.ps1), which only wants
1194
+ # the functions and the catalog above defined.
1195
+ if ($MyInvocation.InvocationName -ne '.') {
1196
+
1197
+ Write-Host "= Toolchain inventory: probing Windows side..." -ForegroundColor Cyan
1198
+
1199
+ # ---------------------------------------------------------------------------
1200
+ # Windows probing
1201
+ # ---------------------------------------------------------------------------
1202
+ $winRows = @()
1203
+ foreach ($tool in $catalog) {
1204
+ $r = Get-WinToolInfo $tool
1205
+ if ($r) { $winRows += $r; Write-Host (" + {0,-16} {1}" -f $r.Name, $r.Version) }
1206
+ }
1207
+
1208
+ # Windows global packages
1209
+ $winGlobals = [ordered]@{}
1210
+ function Add-WinGlobal($label, $exe, $cmdArgs) {
1211
+ if (Get-Command $exe -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {
1212
+ try {
1213
+ # stdout only -- keep error spew (e.g. a missing npm global prefix) out of the report
1214
+ $o = (& $exe @cmdArgs 2>$null | Out-String).Trim()
1215
+ if ($o) { $script:winGlobals[$label] = $o }
1216
+ } catch { Write-Verbose "$label ($exe) failed; leaving it out of the report." }
1217
+ }
1218
+ }
1219
+ Add-WinGlobal 'npm -g' 'npm' @('ls','-g','--depth=0')
1220
+ Add-WinGlobal 'pipx' 'pipx' @('list','--short')
1221
+ Add-WinGlobal 'uv tool' 'uv' @('tool','list')
1222
+ Add-WinGlobal 'cargo install' 'cargo' @('install','--list')
1223
+ Add-WinGlobal 'gem (user)' 'gem' @('list','--local')
1224
+ if (Get-Command go -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {
1225
+ try {
1226
+ $gp = (& go env GOPATH 2>$null).Trim()
1227
+ $gobin = if ($env:GOBIN) { $env:GOBIN } else { Join-Path $gp 'bin' }
1228
+ if (Test-Path $gobin) {
1229
+ $bins = Get-ChildItem $gobin -File -ErrorAction SilentlyContinue |
1230
+ Select-Object -ExpandProperty Name
1231
+ if ($bins) { $winGlobals['go install'] = ($bins -join "`n") }
1232
+ }
1233
+ } catch { Write-Verbose "go env GOPATH failed; leaving the 'go install' list out of the report." }
1234
+ }
1235
+
1236
+ # Uncatalogued dev tools sitting on PATH (heads-up section). @() forces array
1237
+ # semantics so a single hit isn't unwrapped to a bare hashtable (whose .Count
1238
+ # would be its key count, not 1).
1239
+ $winUnknown = @(Get-UncataloguedWin)
1240
+ if ($winUnknown.Count) { Write-Host (" ~ {0} uncatalogued on PATH" -f $winUnknown.Count) -ForegroundColor DarkYellow }
1241
+
1242
+ # ---------------------------------------------------------------------------
1243
+ # WSL probing. The probe logic is written to a real .sh file on disk and run
1244
+ # via `bash <file>` -- NOT passed as a `bash -lc <string>` argument, because
1245
+ # wsl.exe strips backslash escapes (\t \n \r) from command-line arguments,
1246
+ # which silently breaks IFS/printf/tr. A script on disk is immune to that.
1247
+ # ---------------------------------------------------------------------------
1248
+ function ConvertTo-WslPath([string]$winPath) {
1249
+ $p = (& wsl.exe -d $WslDistro wslpath -a ($winPath -replace '\\','/') 2>$null)
1250
+ $p = if ($p) { "$p".Trim() } else { '' }
1251
+ if (-not $p) {
1252
+ $drive = $winPath.Substring(0,1).ToLower()
1253
+ $p = "/mnt/$drive" + ($winPath.Substring(2) -replace '\\','/')
1254
+ }
1255
+ return $p
1256
+ }
1257
+
1258
+ $wslRows = @()
1259
+ $wslGlobalsText = ''
1260
+ $wslUnknown = @()
1261
+ $wslAvailable = $false
1262
+ if ($WslDistro) {
1263
+ $wslExe = Get-Command wsl.exe -ErrorAction SilentlyContinue
1264
+ if ($wslExe) {
1265
+ # Is the distro registered? (wsl -l -q emits UTF-16 unless WSL_UTF8 set)
1266
+ $env:WSL_UTF8 = '1'
1267
+ $distros = (& wsl.exe -l -q 2>$null) -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
1268
+ if ($distros -contains $WslDistro) {
1269
+ $wslAvailable = $true
1270
+ Write-Host "= Probing WSL:$WslDistro side..." -ForegroundColor Cyan
1271
+ $utf8 = New-Object System.Text.UTF8Encoding($false)
1272
+
1273
+ $probeScript = New-ToolchainProbeScript -Catalog $catalog
1274
+
1275
+ # WSL transport: the script is written to a real file on disk and run via
1276
+ # `bash <path>`, never passed as a `bash -lc <string>` argument -- wsl.exe
1277
+ # strips backslash escapes from arguments, per the header comment above.
1278
+ # TC-3's SSH transport will pipe `bash -s` on stdin instead.
1279
+ $wslTransport = {
1280
+ param([string]$ScriptText)
1281
+ $shPath = Join-Path $tmpDir 'probe.sh'
1282
+ [IO.File]::WriteAllText($shPath, $ScriptText, $utf8)
1283
+ $wslSh = ConvertTo-WslPath $shPath
1284
+ $raw = (& wsl.exe -d $WslDistro -- bash $wslSh 2>$null | Out-String) -replace "`r",''
1285
+ Remove-Item $shPath -ErrorAction SilentlyContinue
1286
+ return $raw
1287
+ }
1288
+
1289
+ $raw = Invoke-ToolchainProbe -ScriptText $probeScript -Transport $wslTransport
1290
+ $parsed = ConvertFrom-ToolchainProbeOutput -Raw $raw -Catalog $catalog
1291
+ $wslRows = $parsed.Rows
1292
+ $wslGlobalsText = $parsed.GlobalsText
1293
+ $wslUnknown = $parsed.Unknown
1294
+ foreach ($r in $wslRows) { Write-Host (" + {0,-16} {1}" -f $r.Name, $r.Version) }
1295
+ if ($wslUnknown.Count) { Write-Host (" ~ {0} uncatalogued on PATH" -f $wslUnknown.Count) -ForegroundColor DarkYellow }
1296
+ } else {
1297
+ Write-Host "! WSL distro '$WslDistro' not registered; skipping WSL side." -ForegroundColor Yellow
1298
+ }
1299
+ } else {
1300
+ Write-Host "! wsl.exe not found; skipping WSL side." -ForegroundColor Yellow
1301
+ }
1302
+ }
1303
+
1304
+ # ---------------------------------------------------------------------------
1305
+ # Build + publish the LOCAL report (Windows + WSL), via the shared functions above.
1306
+ # ---------------------------------------------------------------------------
1307
+ $now = Get-Date -Format 'yyyy-MM-dd HH:mm'
1308
+ $nowIso = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
1309
+
1310
+ $winGlobalsBlocks = @($winGlobals.Keys | ForEach-Object { @{ Label = $_; Text = $winGlobals[$_] } })
1311
+ $sides = @(@{ Title = 'Windows'; Side = 'windows'; Rows = $winRows; GlobalsBlocks = $winGlobalsBlocks; Unknown = $winUnknown })
1312
+ # The machine goes in the TITLE, not only the _Generated: line -- a reader who opens the
1313
+ # rendered page should not have to work out whose toolchain they are looking at, which
1314
+ # was a defect even back when only one machine wrote this file (issue #231).
1315
+ $wslCountLabel = if ($wslAvailable) { "WSL:$WslDistro $($wslRows.Count) tools" } else { 'WSL not probed' }
1316
+ if ($wslAvailable) {
1317
+ $sides += @{ Title = "WSL: $WslDistro"; Side = "wsl:$WslDistro"; Rows = $wslRows
1318
+ GlobalsBlocks = @(@{ Label = $null; Text = $wslGlobalsText }); Unknown = $wslUnknown }
1319
+ }
1320
+ $localReport = New-ToolchainReport -HostLabel $hostName `
1321
+ -GeneratedOn "$hostName (editors: $(if($includeEd){'included'}else{'excluded'}))" -Now $now `
1322
+ -SummaryLine "**Windows:** $($winRows.Count) tools &middot; **$wslCountLabel**" `
1323
+ -HistoryRel "$ArtifactCategory/$fileName" -Sides $sides `
1324
+ -HostId $machine -Hostname $hostName -Os 'windows' -GeneratedIso $nowIso
1325
+ $mdText = $localReport.Markdown
1326
+
1327
+ # Always record the last run time (out-of-repo state, untracked).
1328
+ [IO.File]::WriteAllText((Join-Path $stateDir 'last-run.txt'), "$now on $hostName`n",
1329
+ (New-Object System.Text.UTF8Encoding($false)))
1330
+
1331
+ # -OutFile is the local-test path: the report went where the caller asked, NOT into the
1332
+ # store -- but the store's copy (named by $rel below) is a different file that may be
1333
+ # stale or absent, so rendering here would publish something this run did not write.
1334
+ # `$PSBoundParameters` is a bind-time snapshot, so it still answers "did the caller pass
1335
+ # it" after the store branch earlier in the script assigned $OutFile itself. Indexed
1336
+ # rather than .ContainsKey(), so an explicitly EMPTY `-OutFile ''` is falsy here exactly
1337
+ # as it is at the two `-not $OutFile` tests above: that run does write to the store, so
1338
+ # it is the one that SHOULD render.
1339
+ $localSkipReason = if ($PSBoundParameters['OutFile']) {
1340
+ '= -OutFile: wrote the report there; skipped the store render/land.'
1341
+ } else { '' }
1342
+ $localResult = Publish-ToolchainReport -FileName $OutFile -Text $mdText -Sidecar $localReport.Sidecar `
1343
+ -Rel "$ArtifactCategory/$fileName" -NoRender:$NoRender -SkipRenderReason $localSkipReason
1344
+
1345
+ # ---------------------------------------------------------------------------
1346
+ # SSH toolchain probing (issue #715, TC-3). Runs regardless of whether the local
1347
+ # report changed -- a stable local box must not hide a drifting SSH host, or vice
1348
+ # versa, which is exactly why the local branch above no longer returns early.
1349
+ # ---------------------------------------------------------------------------
1350
+ $sshTargetPairs = ConvertTo-ToolchainSshTargets $SshTargets
1351
+ # Every fragment malformed (only reachable from a hand-typed -SshTargets -- the
1352
+ # scheduled/registered path always carries config.py-validated values) must not look
1353
+ # identical to "no SSH targets configured": that would silently probe nothing and
1354
+ # still exit 0, hiding a typo behind the one Yellow line ConvertTo-ToolchainSshTargets
1355
+ # already printed per skipped fragment. $SshTargets non-empty but $sshTargetPairs
1356
+ # empty is the tell.
1357
+ $sshTargetsAllMalformed = [bool]$SshTargets -and (@($sshTargetPairs).Count -eq 0)
1358
+ if ($sshTargetsAllMalformed) {
1359
+ Write-Host "! -SshTargets was set but every fragment was malformed; nothing was probed." -ForegroundColor Red
1360
+ }
1361
+ $remote = $null
1362
+ if (@($sshTargetPairs).Count -gt 0) {
1363
+ Write-Host "= Probing $(@($sshTargetPairs).Count) SSH host(s)..." -ForegroundColor Cyan
1364
+ $sshExe = Resolve-ToolchainSshExe
1365
+ # Thin closure over Invoke-ToolchainSshTransport (issue #715 follow-up): the real
1366
+ # byte-exact stdin write lives there, driven directly by its own tests against a
1367
+ # real local bash; this just supplies the production argv and executable.
1368
+ $sshTransport = {
1369
+ param([string]$Target, [string]$ScriptText)
1370
+ Invoke-ToolchainSshTransport -SshExe $sshExe `
1371
+ -Arguments @('-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', $Target, 'bash', '-s') `
1372
+ -ScriptText $ScriptText
1373
+ }
1374
+ $remote = Invoke-RemoteToolchainInventory -Targets $sshTargetPairs -Transport $sshTransport `
1375
+ -Now $now -NowIso $nowIso -LocalHost $hostName -NoRender:$NoRender
1376
+ if ($remote.Written.Count -gt 0) {
1377
+ Write-Host ("= SSH hosts written: {0}" -f ($remote.Written -join ', ')) -ForegroundColor Green
1378
+ }
1379
+ if ($remote.Unchanged.Count -gt 0) {
1380
+ Write-Host ("= SSH hosts unchanged: {0}" -f ($remote.Unchanged -join ', ')) -ForegroundColor DarkGray
1381
+ }
1382
+ if ($remote.Failed.Count -gt 0) {
1383
+ Write-Host ("! SSH hosts unreachable: {0}" -f ($remote.Failed -join ', ')) -ForegroundColor Yellow
1384
+ }
1385
+ }
1386
+
1387
+ Write-Host "= Done." -ForegroundColor Cyan
1388
+ # A remote failure (2) outranks a local-only render failure (1, the pre-#715 behaviour
1389
+ # of exiting non-zero when build.py fails) -- both are folded in HERE, after SSH
1390
+ # probing has already run to completion, never as an early `exit` from inside
1391
+ # Publish-ToolchainReport (which would abort probing before it started).
1392
+ $exitCode = 0
1393
+ if ($localResult -eq 'render-failed') { $exitCode = 1 }
1394
+ if ($sshTargetsAllMalformed) { $exitCode = 2 }
1395
+ if ($remote -and $remote.ExitCode -ne 0) { $exitCode = $remote.ExitCode }
1396
+ if ($exitCode -ne 0) { exit $exitCode }
1397
+ }