@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.
- package/README.md +158 -18
- package/data/Get-ToolchainInventory.ps1 +1397 -0
- package/data/README.md +40 -0
- package/dist/ac-agent.mjs +1870 -17
- package/package.json +3 -2
|
@@ -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 · **$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
|
+
}
|