@rackbops/ac-agent 2.0.0-alpha.56 → 2.0.0-alpha.58
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 +3 -3
- package/data/Get-ToolchainInventory.ps1 +16 -2
- package/data/README.md +23 -11
- package/dist/ac-agent.mjs +21 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -516,7 +516,7 @@ capability, plugin}`. See **Security model** for how a grant reaches the agent i
|
|
|
516
516
|
sizes and line counts ever leave this scan -- file content is never read into the payload.
|
|
517
517
|
- **`toolchain-inventory`** (#52, 52b; `timeoutMs: 600_000` -- about seventy version probes plus a
|
|
518
518
|
WSL probe) ships and runs 1.x's own `Get-ToolchainInventory.ps1` (vendored byte for byte from 1.x
|
|
519
|
-
`main` at `
|
|
519
|
+
`main` at `da0848f`, `data/README.md`) through `pwsh -NoProfile -File <script> -OutFile <state>/scans/
|
|
520
520
|
toolchain-inventory-<hostname>.md -NoRender`, with the child's `cwd` set to `<state>/scans` --
|
|
521
521
|
**never a project directory**, since the script's own `pnpm --version` probe rewrites
|
|
522
522
|
`pnpm-lock.yaml` in whatever directory it runs in. **Windows-only**: on any other platform this
|
|
@@ -907,7 +907,7 @@ kind's jobs, and one holding only `agent.exec.claude` never reads these):
|
|
|
907
907
|
| `already-current` | exit 0 and the version is unchanged; winget's `0x8A15002B` (no applicable update) also maps here, and means "no newer version this machine can install", which is not always the same as "current" | for the winget code |
|
|
908
908
|
| `needs-sudoers` | `apt`: `sudo -n` refused, in sudo 1.9's wording (`a password is required`, `is not in the sudoers file`, `is not allowed to execute`, `is not allowed to run sudo on`, `user <u> may not run sudo on <host>`) or sudo-rs's, the default `sudo` of recent Ubuntu (`interactive authentication is required`, `I'm afraid I can't do that`, `user <u> may not run <command> on <host>`) | the sudoers rule to add, with what it grants |
|
|
909
909
|
| `needs-elevation` | `winget`: its `0x8A150019` return code (command requires administrator privileges) | the command to paste into an elevated terminal |
|
|
910
|
-
| `failed` | any other non-zero exit (the last output line
|
|
910
|
+
| `failed` | any other non-zero exit (the last output line that is not closing advice such as `Or consider using native installation with: claude install`, printable and cut at 300 characters), winget's `0x8A150014` (no installed package with that id), the output cap, a version that cannot be read after an exit 0, a command that could not start | what failed |
|
|
911
911
|
| `timed-out` | the 15-minute limit | check the package before retrying: it may be half-installed |
|
|
912
912
|
|
|
913
913
|
The winget return codes are pinned by number from winget's own generated table
|
|
@@ -918,7 +918,7 @@ package, or an installer that elevates itself, when its caller is not elevated h
|
|
|
918
918
|
here**: it may relaunch the installer through UAC, which puts a consent prompt on the logon user's
|
|
919
919
|
desktop (the agent is a logon task in that session) that the agent cannot answer; the job then waits
|
|
920
920
|
until it is answered or the 15-minute limit fires (`timed-out`), and a limited `taskkill` cannot end an
|
|
921
|
-
elevated installer. Anything else an installer fails with is `failed`, with its last output line.
|
|
921
|
+
elevated installer. Anything else an installer fails with is `failed`, with its last output line that is not closing advice.
|
|
922
922
|
|
|
923
923
|
**Audit.** Two lines in the agent log per run: `toolchain upgrade started: "<tool>" via <manager>
|
|
924
924
|
<packageId> (version of <cmd>)` when the job starts (so a run the agent does not survive is on record),
|
|
@@ -276,6 +276,17 @@ $catalog = @(
|
|
|
276
276
|
# (Rackbops/Tooling#877 adds this one), and the 2.0 Agents panel's Environment row is labelled
|
|
277
277
|
# the same. `claude --version` prints one line, `<semver> (Claude Code)`.
|
|
278
278
|
@{ Cat='VCS & infra'; Name='Claude Code CLI'; Exe='claude'; Ver=@('--version') }
|
|
279
|
+
# Cloud CLIs -- added for Rackbops/Tooling#1307 (fleet toolchain inventory and standard). The
|
|
280
|
+
# standard keys tools by their exact Name here (Rackbops/Tooling#1308 adds these three). The
|
|
281
|
+
# version is the first number on the first non-empty line of the captured `--version` text, and
|
|
282
|
+
# stderr is merged into that text, so a notice a CLI writes there first would win. The CLI's own
|
|
283
|
+
# line is `azure-cli` padded to 25 columns and then the version (` *` ends it when an update
|
|
284
|
+
# exists; core/telemetry/extension lines follow), `Google Cloud SDK <ver>` (component lines
|
|
285
|
+
# follow; the shape third-party guides show, Google documents the flag but no output), and
|
|
286
|
+
# `aws-cli/<ver> Python/<ver> <os>/<release> ...` (one line).
|
|
287
|
+
@{ Cat='VCS & infra'; Name='Azure CLI'; Exe='az'; Ver=@('--version') }
|
|
288
|
+
@{ Cat='VCS & infra'; Name='Google Cloud CLI'; Exe='gcloud'; Ver=@('--version') }
|
|
289
|
+
@{ Cat='VCS & infra'; Name='AWS CLI'; Exe='aws'; Ver=@('--version') }
|
|
279
290
|
@{ Cat='VCS & infra'; Name='Docker Compose'; Exe='docker-compose'; Ver=@('--version') }
|
|
280
291
|
@{ Cat='VCS & infra'; Name='Podman'; Exe='podman'; Ver=@('--version') }
|
|
281
292
|
@{ Cat='VCS & infra'; Name='kubectl'; Exe='kubectl'; Ver=@('version','--client') }
|
|
@@ -377,6 +388,9 @@ $refLinks = @{
|
|
|
377
388
|
'GitHub CLI' = @{ Home='https://cli.github.com'; Repo='cli/cli' }
|
|
378
389
|
'Docker' = @{ Home='https://docker.com'; Repo='docker/cli' }
|
|
379
390
|
'Claude Code CLI' = @{ Home='https://code.claude.com/docs'; Repo='anthropics/claude-code' }
|
|
391
|
+
'Azure CLI' = @{ Home='https://learn.microsoft.com/cli/azure'; Repo='Azure/azure-cli' }
|
|
392
|
+
'Google Cloud CLI' = @{ Home='https://cloud.google.com/sdk'; Repo='' }
|
|
393
|
+
'AWS CLI' = @{ Home='https://aws.amazon.com/cli'; Repo='aws/aws-cli' }
|
|
380
394
|
'Docker Compose' = @{ Home='https://docs.docker.com/compose'; Repo='docker/compose' }
|
|
381
395
|
'Podman' = @{ Home='https://podman.io'; Repo='containers/podman' }
|
|
382
396
|
'kubectl' = @{ Home='https://kubernetes.io'; Repo='kubernetes/kubernetes' }
|
|
@@ -503,7 +517,7 @@ foreach ($n in @(
|
|
|
503
517
|
'rust-gdb','rust-gdbgui','rust-lldb','rust-analyzer',
|
|
504
518
|
'gofmt','godoc','npx','pnpx','corepack','yarnpkg',
|
|
505
519
|
'uvx','uvw','rls',
|
|
506
|
-
'pydoc','2to3','idle','wheel','activate',
|
|
520
|
+
'pydoc','2to3','idle','wheel','activate','aws_completer',
|
|
507
521
|
'php-cgi','phpdbg','erl','epmd','escript','iex','mix','rebar3',
|
|
508
522
|
'bundler','rdoc','ri'
|
|
509
523
|
)) { $companionExe[$n] = $true }
|
|
@@ -624,7 +638,7 @@ run cargo "cargo install" cargo install --list
|
|
|
624
638
|
run gem "gem (user)" gem list --local
|
|
625
639
|
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
|
|
626
640
|
echo "===UNKNOWN==="
|
|
627
|
-
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
|
|
641
|
+
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 aws_completer; do known[$c]=1; done
|
|
628
642
|
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
|
|
629
643
|
for d in "$HOME/.cargo/bin" "$gb" "$HOME/.local/bin" "$HOME/.dotnet/tools" /usr/local/bin; do
|
|
630
644
|
[ -d "$d" ] || continue
|
package/data/README.md
CHANGED
|
@@ -7,13 +7,13 @@ text (see `../text-plugin.mjs`).
|
|
|
7
7
|
|
|
8
8
|
A copy of `artifact-console/data/Get-ToolchainInventory.ps1` from
|
|
9
9
|
[`Rackbops/artifacts-console`](https://github.com/Rackbops/artifacts-console) (the 1.x app), pinned
|
|
10
|
-
to commit `
|
|
11
|
-
that repo, `git -C <artifacts-console checkout> show
|
|
10
|
+
to commit `da0848f` on that repo's `main`, byte-identical, with no local addition. From a checkout of
|
|
11
|
+
that repo, `git -C <artifacts-console checkout> show da0848fa166511cc4aa572dca1391e3c6a851133:artifact-console/data/Get-ToolchainInventory.ps1 | diff - <this repo>/packages/agent/data/Get-ToolchainInventory.ps1`
|
|
12
12
|
prints nothing.
|
|
13
13
|
|
|
14
|
-
- 1,
|
|
14
|
+
- 1,846 lines, 103,473 bytes
|
|
15
15
|
- LF line endings, no BOM, ASCII-only
|
|
16
|
-
- sha256 `
|
|
16
|
+
- sha256 `8d9a8bf57f7870338498b6fe877393009732b25591db62c7ac866075d45a674f`
|
|
17
17
|
|
|
18
18
|
The hash is platform-stable because both repos' `.gitattributes` normalize this file to
|
|
19
19
|
`text=auto eol=lf`, and `toolchain-inventory.test.ts` asserts the embedded text hashes to the value
|
|
@@ -24,13 +24,15 @@ above -- a re-vendored script whose bytes moved fails that test rather than sile
|
|
|
24
24
|
against 1.x's own output, which is the entire point of vendoring rather than rewriting it. The
|
|
25
25
|
`Claude Code CLI` catalog row (after `Docker`) and its `$refLinks` entry, which this agent's
|
|
26
26
|
inventory reports for Rackbops/artifact-console#673, come from 1.x `main` itself since
|
|
27
|
-
artifacts-console#755
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
artifacts-console#755, and so do the `Azure CLI`, `Google Cloud CLI` and `AWS CLI` rows (after
|
|
28
|
+
`Claude Code CLI`) with their `$refLinks` entries, which it reports for Rackbops/Tooling#1307, since
|
|
29
|
+
artifacts-console#761. 1.x's `v1-maintenance` branch carries the same rows since
|
|
30
|
+
artifacts-console#750 and #760, but that branch's script is an older revision without the
|
|
31
|
+
`## Containers` section, so it is not a source to re-copy from. To pick up a newer 1.x revision:
|
|
32
|
+
copy the file from the new `main` commit, update the commit hash and the three numbers above, and
|
|
33
|
+
re-run the embedding/drift-guard tests (`toolchain-inventory.test.ts`) -- they pin the script's
|
|
34
|
+
catalog names (the `Claude Code CLI` and cloud CLI rows included) and schema-version literal, so a
|
|
35
|
+
re-vendor whose shape moved is caught there, not discovered on a card.
|
|
34
36
|
|
|
35
37
|
**Provenance note (Tooling#721 F3, landed 2026-09-29):** re-pinned from `7280ae6` to `8e33836`
|
|
36
38
|
(`git diff --stat 7280ae6 8e33836 -- artifact-console/data/Get-ToolchainInventory.ps1`: 456
|
|
@@ -54,3 +56,13 @@ suite pins directly, are both identical to before.
|
|
|
54
56
|
lines (Rackbops/artifact-console#673) to `5de373e`, byte-identical, via artifacts-console#755 (the
|
|
55
57
|
1.x `main` twin of artifacts-console#750, for artifacts-console#754). `git hash-object` gives
|
|
56
58
|
`50ee5b9ad2cf7ae3722d438bb956daedc322df71` for both files, so the three numbers above are unchanged.
|
|
59
|
+
|
|
60
|
+
**Provenance note (2026-10-17):** re-pinned from `5de373e` to `da0848f`, byte-identical, via
|
|
61
|
+
artifacts-console#761 (the 1.x `main` twin of #760, for Rackbops/Tooling#1307 via
|
|
62
|
+
artifacts-console#758; the 1.x release that knows the three names is `ac-pipeline` 0.4.2). From
|
|
63
|
+
`5de373e` the file gains 14 lines and changes 2: the three catalog rows after `Claude Code CLI`
|
|
64
|
+
under an eight-line comment, their three `$refLinks` entries, and `aws_completer` -- the companion
|
|
65
|
+
binary AWS's installer links beside `aws` -- added to both companion-known lists (the PowerShell
|
|
66
|
+
`$companionExe` and the bash probe's `known[...]` loop), so it is not reported as an uncatalogued
|
|
67
|
+
tool. `git hash-object` gives `5c16db59dae014027172a0c906e17f40aa0c14d6` for both files; the three
|
|
68
|
+
numbers above were re-measured (1,846 lines, 103,473 bytes).
|
package/dist/ac-agent.mjs
CHANGED
|
@@ -47,7 +47,7 @@ function _mergeNamespaces(n, m) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
var name = "@rackbops/ac-agent";
|
|
50
|
-
var version = "2.0.0-alpha.
|
|
50
|
+
var version = "2.0.0-alpha.58";
|
|
51
51
|
var type = "module";
|
|
52
52
|
var description = "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.";
|
|
53
53
|
var bin = {
|
|
@@ -5210,7 +5210,12 @@ function createGrantState(initial, persist) {
|
|
|
5210
5210
|
let current = initial;
|
|
5211
5211
|
return {
|
|
5212
5212
|
revision: () => current?.revision ?? null,
|
|
5213
|
-
|
|
5213
|
+
// Own keys only (#722): `grants` is parsed from JSON, a plain object, and `constructor` passes
|
|
5214
|
+
// PLUGIN_ID, so a bare `grants[pluginId]` read the inherited `Object` (no `.includes`) and the
|
|
5215
|
+
// guard answered 500 instead of 403.
|
|
5216
|
+
allows: (pluginId, capability) => current !== null && Object.hasOwn(current.grants, pluginId)
|
|
5217
|
+
? (current.grants[pluginId] ?? []).includes(capability)
|
|
5218
|
+
: false,
|
|
5214
5219
|
replace: (set) => {
|
|
5215
5220
|
persist(set);
|
|
5216
5221
|
current = set;
|
|
@@ -66328,7 +66333,8 @@ const WINDOWS = {
|
|
|
66328
66333
|
};
|
|
66329
66334
|
const TIERS = [200_000, 1_000_000];
|
|
66330
66335
|
function windowFor(model, occupancy) {
|
|
66331
|
-
|
|
66336
|
+
// Own keys only (#722): `model` is text from a transcript, as in 1.x's dict lookup.
|
|
66337
|
+
const win = Object.hasOwn(WINDOWS, model) ? WINDOWS[model] : undefined;
|
|
66332
66338
|
if (win !== undefined)
|
|
66333
66339
|
return { window: win, mapped: true };
|
|
66334
66340
|
for (const tier of TIERS) {
|
|
@@ -66738,7 +66744,8 @@ function isPlainObject(v) {
|
|
|
66738
66744
|
/** USD-equivalent for one assistant turn, at list API rates. `0` for an unpriced model -- the
|
|
66739
66745
|
* caller counts its tokens and surfaces the id instead of guessing. */
|
|
66740
66746
|
function recordCost(model, usage) {
|
|
66741
|
-
|
|
66747
|
+
// Own keys only (#722): `model` is text from a transcript, as in 1.x's dict lookup.
|
|
66748
|
+
const rate = Object.hasOwn(PRICES, model) ? PRICES[model] : undefined;
|
|
66742
66749
|
if (!rate)
|
|
66743
66750
|
return 0;
|
|
66744
66751
|
const creation = isPlainObject(usage.cache_creation) ? usage.cache_creation : {};
|
|
@@ -66827,7 +66834,7 @@ function scanFile(fileBuffer, mtime, size, stem, parentName, fullPathComponents)
|
|
|
66827
66834
|
turns++;
|
|
66828
66835
|
if (model) {
|
|
66829
66836
|
models.add(model);
|
|
66830
|
-
if (!(model
|
|
66837
|
+
if (!Object.hasOwn(PRICES, model))
|
|
66831
66838
|
unpriced.add(model);
|
|
66832
66839
|
}
|
|
66833
66840
|
cost += recordCost(model, usage);
|
|
@@ -66939,13 +66946,13 @@ function collect(root, previous, signal, fs, now) {
|
|
|
66939
66946
|
};
|
|
66940
66947
|
}
|
|
66941
66948
|
|
|
66942
|
-
var scriptText = "<#\n.SYNOPSIS\n Inventory the language toolchains installed on this machine (Windows + WSL)\n into the artifact-console store, where the dashboard shows it and the\n claude-memory-sync git log is its running history.\n\n.DESCRIPTION\n Probes a curated catalog of toolchain executables (language runtimes,\n compilers, package managers, version managers, build tools, VCS / container /\n infra CLIs) on the Windows side and inside each WSL distro, capturing each\n tool's version, official Home + GitHub links, path, and best-effort install\n source. Also dumps globally-installed packages (npm -g, pipx, uv, cargo, gem,\n go). On a real change it writes toolchain-inventory-<machine>.md into the\n artifacts store -- one file per box, since what is installed here is a\n per-machine fact and the store is shared across machines (issue #231) --\n and renders it via build.py, which lands it into the memory repo; a\n no-change run does nothing. The script never writes into its own directory\n (scratch/state go to %TEMP% / %LOCALAPPDATA%), so it is safe to keep under a\n git repo (artifacts-console/artifact-console/data) -- and, since #320, inside the\n installed package.\n\n On a Linux side (WSL or an -SshTargets host) that has docker on PATH and can run\n `docker ps`, also adds a Containers section (issue #721): every RUNNING container's\n name, image reference, image digest, best-effort package list (dpkg/apk/rpm, or\n \"unknown\" when none apply) and catalogued tools found on its own PATH (the same\n catalog pass, run inside the container). Windows hosts never get this section --\n no Docker on the Windows boxes today. Every docker call the probe makes is\n READ-ONLY (`ps`, `inspect`, `exec` running only the package query and the catalog\n pass) -- nothing here pulls, starts, stops, restarts or removes a container, and a\n container that vanishes mid-probe is skipped or reported with unknown\n packages/tools rather than failing the run.\n\n.PARAMETER IncludeEditors\n Also probe editors / IDEs (VS Code, Neovim, Vim, Emacs, Sublime, Helix).\n Off by default. To turn it on permanently, either pass this switch or flip\n $IncludeEditorsDefault below to $true.\n\n.PARAMETER WslDistro\n WSL distro to probe. Defaults to 'Ubuntu'. Pass '' (empty) to skip WSL.\n\n.PARAMETER SourcesDir\n The artifact store root the report is written under. Derived from the console's\n config when omitted -- it used to be a hardcoded `R:\\repos\\claude-memory-sync\\\n artifacts`, correct on one machine and resolving on a second only through a\n junction (issue #231).\n\n.PARAMETER PythonExe\n The console's own python.exe, used for both the store-root lookup and the\n build.py render. Passed by sched_spec.job_argv (sys.executable) and by\n Register-InventoryTask.ps1 (resolved beside pythonw.exe) -- bare `python` on\n PATH is not necessarily the console's interpreter (issue #632), especially\n under pipx. Falls back to `python` on PATH when omitted, so a hand run still\n works.\n\n.PARAMETER OutFile\n Write the report here instead of the store (for local testing). The store\n render/land is skipped when you pass a path -- what would be rendered is the STORE's\n copy of the report, which this run did not write. An empty value is the same as\n omitting it: the report goes to the store, and that run does render.\n\n.PARAMETER SshTargets\n `alias=user@host;alias2=user@host2` -- Linux hosts to probe over key-based SSH\n alongside Windows + WSL (issue #715), one `toolchain-inventory-<alias>.md` per alias.\n Empty (the default) probes no SSH hosts. An unreachable host is isolated: its previous\n report is left untouched, every other target (local, WSL, remaining hosts) still\n publishes, and the process exits 2 so the task's Last Result shows it.\n\n.PARAMETER NoRender\n Write the report but don't render/land it into artifact-console.\n\n.PARAMETER NoSync\n Render into the docroot but don't land into the memory repo (build.py\n --no-sync); the hourly ClaudeMemorySync task still commits it later.\n\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1 -IncludeEditors\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1 -OutFile $env:TEMP\\inv.md # local test, no render\n#>\n[CmdletBinding()]\nparam(\n [switch]$IncludeEditors,\n [string]$WslDistro = 'Ubuntu',\n [string]$SourcesDir = '',\n [string]$PythonExe = '',\n [string]$OutFile,\n [string]$SshTargets = '',\n [switch]$NoRender,\n [switch]$NoSync\n)\n\n# ---------------------------------------------------------------------------\n# Editable defaults -- change these instead of the code below.\n# Flip $IncludeEditorsDefault to $true to always include editors/IDEs.\n# ---------------------------------------------------------------------------\n$IncludeEditorsDefault = $false\n\n# The report's home is the artifact-console store. On a change the script writes its\n# report there and renders it via build.py, which also lands it in the claude-memory-sync\n# repo, whose git log IS the history.\n#\n# This payload ships INSIDE the console (artifact-console/data/), so the console dir --\n# where build.py and scheduled_tasks.py live -- is simply this script's PARENT. #320 (PR\n# #330) moved the file here from the repo's PowerShell/ drawer as a pure rename (R100),\n# leaving the drawer-era `Join-Path (Split-Path $PSScriptRoot -Parent) 'artifact-console'`\n# resolving to a doubled artifact-console/artifact-console that has never existed. The\n# render guard below tests that path, so its condition became permanently TRUE and the\n# render branch unreachable: a run with something to publish wrote its report to the store,\n# printed a yellow skip and exited 0. (A no-change run returns earlier still and is not\n# affected.) So the .md kept reaching the store, but nothing rendered it into the docroot\n# and nothing recorded it for landing -- sync_memory stages only the paths build.py records,\n# never a blanket add -- so no automatic `chore(artifacts): sync Toolchain-Inventory/...`\n# commit has landed since, every later touch made by hand. (The last one is 2026-07-20, which\n# is an outer bound rather than a fingerprint: #231's per-machine rename sits between it and\n# this move.) The registrar's half of the\n# same rename was caught in #334; this was the other half, and it sat unnoticed for a month\n# because the skip message named neither the path nor which half was missing.\n#\n# The parent is right in a WHEEL too, and not by luck: data/ installs as the\n# `artifact_console_data` package while the modules install FLAT beside it, so\n# site-packages/artifact_console_data/.. is site-packages/, which holds build.py.\n#\n# The store ROOT is not spelled out here any more (issue #231). It was\n# `R:\\repos\\claude-memory-sync\\artifacts`: right on the box it was written on, and on a\n# second machine right only by way of a junction. Register-InventoryTask.ps1 passes the\n# config-derived value; a hand run asks the console for the same one.\n$ArtifactConsoleDir = Split-Path $PSScriptRoot -Parent\n$ArtifactCategory = 'Toolchain-Inventory'\n\n$ErrorActionPreference = 'Stop'\n$includeEd = $IncludeEditors.IsPresent -or $IncludeEditorsDefault\n\n# The interpreter for BOTH Python call sites below. `python` on PATH is not\n# necessarily the console's own interpreter -- under pipx it never is (#632) --\n# so a caller that knows the right one (sched_spec.job_argv, the registrar) passes\n# it explicitly. PATH is only the fallback, for a hand run with nothing supplied.\n$pyExe = if ($PythonExe) { $PythonExe } else { 'python' }\n\n# Needed before the output path is chosen, not just in the report body: this report\n# describes ONE machine, so the machine is part of its filename.\n$hostName = [System.Net.Dns]::GetHostName()\n\nif (-not $SourcesDir -and -not $OutFile) {\n $jobPaths = Join-Path $ArtifactConsoleDir 'scheduled_tasks.py'\n try {\n # Same native-exit trap as the render below: a python that RUNS and exits non-zero\n # does not throw, so without this the catch could never fire on the commonest\n # failures -- a traceback, an unconfigured store, or the Windows App Execution\n # Alias stub, which is on PATH and exits non-zero rather than being absent.\n # ConvertFrom-Json would just yield nothing, $SourcesDir would end up empty, and\n # the reason would never be printed. (A genuinely missing command is the one case\n # that always threw: CommandNotFoundException, which this catch already handled.)\n $jobPathsJson = & $pyExe $jobPaths --job-paths toolchain-inventory\n if ($LASTEXITCODE -ne 0) { throw \"scheduled_tasks.py --job-paths exited $LASTEXITCODE\" }\n $SourcesDir = ($jobPathsJson | ConvertFrom-Json).args.SourcesDir\n } catch {\n Write-Host \"! could not ask the console for the store root: $($_.Exception.Message)\" -ForegroundColor Yellow\n }\n}\n\n$ArtifactSourcesDir = $SourcesDir\n\n# This script lives in a git repo (artifacts-console) -- and, since #320, ships inside the\n# installed package, where writing would be worse still -- so it must NEVER write into its\n# own directory. Scratch (probe files) and state (last-run) go to out-of-repo temp /\n# local-appdata; the report itself goes to the artifacts store.\n$tmpDir = Join-Path ([IO.Path]::GetTempPath()) 'toolchain-inventory'\n$stateDir = Join-Path $env:LOCALAPPDATA 'toolchain-inventory'\nNew-Item -ItemType Directory -Force -Path $tmpDir, $stateDir | Out-Null\n\n# One file per machine. This report is a per-machine fact -- what is installed on THIS\n# box -- and it used to be written under a single machine-agnostic name into a git-synced\n# store shared by every machine. With the weekly task registered on two boxes, each run\n# overwrote the other's and the category became a flip-flop: every run a large diff\n# reverting the previous one, so the git log that is supposed to BE the history recorded\n# no machine's toolchain drift legibly (issue #231). Keyed by hostname, each machine owns\n# one file, diffs stay meaningful, and no coordination between boxes is needed.\n$machine = ($hostName -replace '[^A-Za-z0-9._-]', '-')\n$fileName = \"toolchain-inventory-$machine.md\"\n\n$storeDir = if ($ArtifactSourcesDir) { Join-Path $ArtifactSourcesDir $ArtifactCategory } else { '' }\n$storeAvailable = [bool]$ArtifactSourcesDir -and (Test-Path $ArtifactSourcesDir)\nif (-not $OutFile) {\n if ($storeAvailable) {\n New-Item -ItemType Directory -Force -Path $storeDir | Out-Null\n $OutFile = Join-Path $storeDir $fileName\n } else {\n $OutFile = Join-Path $stateDir $fileName\n Write-Host \"! artifacts store not found; writing locally to $OutFile\" -ForegroundColor Yellow\n }\n}\n\n# ---------------------------------------------------------------------------\n# Tool catalog. One row per tool:\n# Category | Name | Exe | Ver (arg array) | Alt (fallback arg array or $null)\n# Add a row here to track a new tool -- it is picked up on both Windows and WSL.\n# ---------------------------------------------------------------------------\n$catalog = @(\n # --- Language runtimes & compilers ---\n @{ Cat='Languages'; Name='Node.js'; Exe='node'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Deno'; Exe='deno'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Bun'; Exe='bun'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Python'; Exe='python'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Python3'; Exe='python3'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Py launcher'; Exe='py'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Go'; Exe='go'; Ver=@('version') }\n @{ Cat='Languages'; Name='Rust (rustc)';Exe='rustc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Java'; Exe='java'; Ver=@('-version') }\n @{ Cat='Languages'; Name='javac (JDK)'; Exe='javac'; Ver=@('-version') }\n @{ Cat='Languages'; Name='.NET SDK'; Exe='dotnet'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Ruby'; Exe='ruby'; Ver=@('--version') }\n @{ Cat='Languages'; Name='PHP'; Exe='php'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Lua'; Exe='lua'; Ver=@('-v') }\n @{ Cat='Languages'; Name='LuaJIT'; Exe='luajit'; Ver=@('-v') }\n @{ Cat='Languages'; Name='Perl'; Exe='perl'; Ver=@('--version') }\n @{ Cat='Languages'; Name='GCC'; Exe='gcc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='G++'; Exe='g++'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Clang'; Exe='clang'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Zig'; Exe='zig'; Ver=@('version') }\n @{ Cat='Languages'; Name='Kotlin'; Exe='kotlin'; Ver=@('-version') }\n @{ Cat='Languages'; Name='Scala'; Exe='scala'; Ver=@('-version') }\n @{ Cat='Languages'; Name='Swift'; Exe='swift'; Ver=@('--version') }\n @{ Cat='Languages'; Name='GHC (Haskell)';Exe='ghc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Elixir'; Exe='elixir'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Julia'; Exe='julia'; Ver=@('--version') }\n @{ Cat='Languages'; Name='R'; Exe='R'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Dart'; Exe='dart'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Nim'; Exe='nim'; Ver=@('--version') }\n @{ Cat='Languages'; Name='OCaml'; Exe='ocaml'; Ver=@('-version') }\n\n # --- Package managers ---\n @{ Cat='Package managers'; Name='npm'; Exe='npm'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Yarn'; Exe='yarn'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pnpm'; Exe='pnpm'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pip'; Exe='pip'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pipx'; Exe='pipx'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Poetry'; Exe='poetry'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='uv'; Exe='uv'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Conda'; Exe='conda'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Cargo'; Exe='cargo'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Gem'; Exe='gem'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Bundler'; Exe='bundle'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Composer'; Exe='composer'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Maven'; Exe='mvn'; Ver=@('-version') }\n @{ Cat='Package managers'; Name='Gradle'; Exe='gradle'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='LuaRocks'; Exe='luarocks'; Ver=@('--version') }\n\n # --- Version managers ---\n @{ Cat='Version managers'; Name='nvm'; Exe='nvm'; Ver=@('--version'); Alt=@('version') }\n @{ Cat='Version managers'; Name='fnm'; Exe='fnm'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='Volta'; Exe='volta'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='pyenv'; Exe='pyenv'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='rustup'; Exe='rustup'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='rbenv'; Exe='rbenv'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='asdf'; Exe='asdf'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='SDKMAN'; Exe='sdk'; Ver=@('version') }\n\n # --- Build tools & runners ---\n @{ Cat='Build tools'; Name='Make'; Exe='make'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='CMake'; Exe='cmake'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Ninja'; Exe='ninja'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Meson'; Exe='meson'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Bazel'; Exe='bazel'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='just'; Exe='just'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='MSBuild'; Exe='msbuild'; Ver=@('-version') }\n @{ Cat='Build tools'; Name='Ant'; Exe='ant'; Ver=@('-version') }\n\n # --- VCS, containers & infra ---\n @{ Cat='VCS & infra'; Name='Git'; Exe='git'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='GitHub CLI'; Exe='gh'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Docker'; Exe='docker'; Ver=@('--version') }\n # Claude Code CLI -- added for Rackbops/artifact-console#673 (fleet environment consistency,\n # epic Rackbops/artifact-console#676): the fleet standard keys tools by their exact Name here\n # (Rackbops/Tooling#877 adds this one), and the 2.0 Agents panel's Environment row is labelled\n # the same. `claude --version` prints one line, `<semver> (Claude Code)`.\n @{ Cat='VCS & infra'; Name='Claude Code CLI'; Exe='claude'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Docker Compose'; Exe='docker-compose'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Podman'; Exe='podman'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='kubectl'; Exe='kubectl'; Ver=@('version','--client') }\n @{ Cat='VCS & infra'; Name='Helm'; Exe='helm'; Ver=@('version','--short') }\n @{ Cat='VCS & infra'; Name='Terraform'; Exe='terraform'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='OpenTofu'; Exe='tofu'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Ansible'; Exe='ansible'; Ver=@('--version') }\n # cloudflared -- added for #715: nitro/nucbox both front their consoles through a\n # Cloudflare Tunnel, and the epic's exit criterion names it explicitly.\n @{ Cat='VCS & infra'; Name='cloudflared'; Exe='cloudflared'; Ver=@('--version') }\n)\n\n$editorCatalog = @(\n @{ Cat='Editors'; Name='VS Code'; Exe='code'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Neovim'; Exe='nvim'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Vim'; Exe='vim'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Emacs'; Exe='emacs'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Sublime'; Exe='subl'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Helix'; Exe='hx'; Ver=@('--version') }\n)\nif ($includeEd) { $catalog += $editorCatalog }\n\n# Fixed category display order.\n$catOrder = @('Languages','Package managers','Version managers','Build tools','VCS & infra','Editors')\n\n# ---------------------------------------------------------------------------\n# Reference links per tool (keyed by catalog Name): official Home + GitHub repo\n# (owner/repo; URL derived). Blank where none is canonical. Add a tool here when\n# you add it to $catalog. Rendered as the Home / GitHub columns.\n# ---------------------------------------------------------------------------\n$refLinks = @{\n # Languages\n 'Node.js' = @{ Home='https://nodejs.org'; Repo='nodejs/node' }\n 'Deno' = @{ Home='https://deno.com'; Repo='denoland/deno' }\n 'Bun' = @{ Home='https://bun.sh'; Repo='oven-sh/bun' }\n 'Python' = @{ Home='https://python.org'; Repo='python/cpython' }\n 'Python3' = @{ Home='https://python.org'; Repo='python/cpython' }\n 'Py launcher' = @{ Home='https://docs.python.org/using/windows.html'; Repo='python/cpython' }\n 'Go' = @{ Home='https://go.dev'; Repo='golang/go' }\n 'Rust (rustc)' = @{ Home='https://rust-lang.org'; Repo='rust-lang/rust' }\n 'Java' = @{ Home='https://openjdk.org'; Repo='openjdk/jdk' }\n 'javac (JDK)' = @{ Home='https://openjdk.org'; Repo='openjdk/jdk' }\n '.NET SDK' = @{ Home='https://dotnet.microsoft.com'; Repo='dotnet/sdk' }\n 'Ruby' = @{ Home='https://ruby-lang.org'; Repo='ruby/ruby' }\n 'PHP' = @{ Home='https://php.net'; Repo='php/php-src' }\n 'Lua' = @{ Home='https://lua.org'; Repo='lua/lua' }\n 'LuaJIT' = @{ Home='https://luajit.org'; Repo='LuaJIT/LuaJIT' }\n 'Perl' = @{ Home='https://perl.org'; Repo='Perl/perl5' }\n 'GCC' = @{ Home='https://gcc.gnu.org'; Repo='gcc-mirror/gcc' }\n 'G++' = @{ Home='https://gcc.gnu.org'; Repo='gcc-mirror/gcc' }\n 'Clang' = @{ Home='https://clang.llvm.org'; Repo='llvm/llvm-project' }\n 'Zig' = @{ Home='https://ziglang.org'; Repo='ziglang/zig' }\n 'Kotlin' = @{ Home='https://kotlinlang.org'; Repo='JetBrains/kotlin' }\n 'Scala' = @{ Home='https://scala-lang.org'; Repo='scala/scala' }\n 'Swift' = @{ Home='https://swift.org'; Repo='apple/swift' }\n 'GHC (Haskell)' = @{ Home='https://haskell.org'; Repo='' }\n 'Elixir' = @{ Home='https://elixir-lang.org'; Repo='elixir-lang/elixir' }\n 'Julia' = @{ Home='https://julialang.org'; Repo='JuliaLang/julia' }\n 'R' = @{ Home='https://r-project.org'; Repo='' }\n 'Dart' = @{ Home='https://dart.dev'; Repo='dart-lang/sdk' }\n 'Nim' = @{ Home='https://nim-lang.org'; Repo='nim-lang/Nim' }\n 'OCaml' = @{ Home='https://ocaml.org'; Repo='ocaml/ocaml' }\n # Package managers\n 'npm' = @{ Home='https://npmjs.com'; Repo='npm/cli' }\n 'Yarn' = @{ Home='https://yarnpkg.com'; Repo='yarnpkg/berry' }\n 'pnpm' = @{ Home='https://pnpm.io'; Repo='pnpm/pnpm' }\n 'pip' = @{ Home='https://pip.pypa.io'; Repo='pypa/pip' }\n 'pipx' = @{ Home='https://pipx.pypa.io'; Repo='pypa/pipx' }\n 'Poetry' = @{ Home='https://python-poetry.org'; Repo='python-poetry/poetry' }\n 'uv' = @{ Home='https://docs.astral.sh/uv'; Repo='astral-sh/uv' }\n 'Conda' = @{ Home='https://conda.io'; Repo='conda/conda' }\n 'Cargo' = @{ Home='https://doc.rust-lang.org/cargo'; Repo='rust-lang/cargo' }\n 'Gem' = @{ Home='https://rubygems.org'; Repo='rubygems/rubygems' }\n 'Bundler' = @{ Home='https://bundler.io'; Repo='rubygems/rubygems' }\n 'Composer' = @{ Home='https://getcomposer.org'; Repo='composer/composer' }\n 'Maven' = @{ Home='https://maven.apache.org'; Repo='apache/maven' }\n 'Gradle' = @{ Home='https://gradle.org'; Repo='gradle/gradle' }\n 'LuaRocks' = @{ Home='https://luarocks.org'; Repo='luarocks/luarocks' }\n # Version managers\n 'nvm' = @{ Home=''; Repo='nvm-sh/nvm' }\n 'fnm' = @{ Home=''; Repo='Schniz/fnm' }\n 'Volta' = @{ Home='https://volta.sh'; Repo='volta-cli/volta' }\n 'pyenv' = @{ Home=''; Repo='pyenv/pyenv' }\n 'rustup' = @{ Home='https://rustup.rs'; Repo='rust-lang/rustup' }\n 'rbenv' = @{ Home=''; Repo='rbenv/rbenv' }\n 'asdf' = @{ Home='https://asdf-vm.com'; Repo='asdf-vm/asdf' }\n 'SDKMAN' = @{ Home='https://sdkman.io'; Repo='sdkman/sdkman-cli' }\n # Build tools\n 'Make' = @{ Home='https://gnu.org/software/make'; Repo='' }\n 'CMake' = @{ Home='https://cmake.org'; Repo='Kitware/CMake' }\n 'Ninja' = @{ Home='https://ninja-build.org'; Repo='ninja-build/ninja' }\n 'Meson' = @{ Home='https://mesonbuild.com'; Repo='mesonbuild/meson' }\n 'Bazel' = @{ Home='https://bazel.build'; Repo='bazelbuild/bazel' }\n 'just' = @{ Home='https://just.systems'; Repo='casey/just' }\n 'MSBuild' = @{ Home='https://learn.microsoft.com/visualstudio/msbuild/msbuild'; Repo='dotnet/msbuild' }\n 'Ant' = @{ Home='https://ant.apache.org'; Repo='apache/ant' }\n # VCS & infra\n 'Git' = @{ Home='https://git-scm.com'; Repo='git/git' }\n 'GitHub CLI' = @{ Home='https://cli.github.com'; Repo='cli/cli' }\n 'Docker' = @{ Home='https://docker.com'; Repo='docker/cli' }\n 'Claude Code CLI' = @{ Home='https://code.claude.com/docs'; Repo='anthropics/claude-code' }\n 'Docker Compose' = @{ Home='https://docs.docker.com/compose'; Repo='docker/compose' }\n 'Podman' = @{ Home='https://podman.io'; Repo='containers/podman' }\n 'kubectl' = @{ Home='https://kubernetes.io'; Repo='kubernetes/kubernetes' }\n 'Helm' = @{ Home='https://helm.sh'; Repo='helm/helm' }\n 'Terraform' = @{ Home='https://terraform.io'; Repo='hashicorp/terraform' }\n 'OpenTofu' = @{ Home='https://opentofu.org'; Repo='opentofu/opentofu' }\n 'Ansible' = @{ Home='https://ansible.com'; Repo='ansible/ansible' }\n 'cloudflared' = @{ Home='https://developers.cloudflare.com/cloudflared'; Repo='cloudflare/cloudflared' }\n # Editors\n 'VS Code' = @{ Home='https://code.visualstudio.com'; Repo='microsoft/vscode' }\n 'Neovim' = @{ Home='https://neovim.io'; Repo='neovim/neovim' }\n 'Vim' = @{ Home='https://vim.org'; Repo='vim/vim' }\n 'Emacs' = @{ Home='https://gnu.org/software/emacs'; Repo='' }\n 'Sublime' = @{ Home='https://sublimetext.com'; Repo='' }\n 'Helix' = @{ Home='https://helix-editor.com'; Repo='helix-editor/helix' }\n}\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\nfunction Get-CleanVersion([string]$raw) {\n if (-not $raw) { return '' }\n $line = ($raw -split \"`n\" | Where-Object { $_.Trim() -ne '' } | Select-Object -First 1)\n if (-not $line) { return '' }\n $line = $line.Trim()\n $m = [regex]::Match($line, '\\d+\\.\\d+(\\.\\d+){0,3}')\n if ($m.Success) { return $m.Value }\n if ($line.Length -gt 48) { return $line.Substring(0,48) }\n return $line\n}\n\nfunction Get-WinSource([string]$path) {\n if (-not $path) { return '' }\n $p = $path.ToLower()\n if ($p -like '*\\scoop\\*') { return 'scoop' }\n if ($p -like '*\\chocolatey\\*') { return 'choco' }\n if ($p -like '*\\winget*' -or\n $p -like '*\\windowsapps\\*') { return 'winget/store' }\n if ($p -like '*\\.cargo\\*') { return 'cargo' }\n if ($p -like '*\\nvm*') { return 'nvm' }\n if ($p -like '*\\program files*') { return 'installer' }\n if ($p -like '*\\appdata\\local\\programs\\*') { return 'user-install' }\n return ''\n}\n\nfunction Get-WslSource([string]$path) {\n if (-not $path) { return '' }\n if ($path -like '*/.cargo/*') { return 'cargo' }\n if ($path -like '*/.local/*') { return 'pip/pipx' }\n if ($path -like '*/.nvm/*') { return 'nvm' }\n if ($path -like '*/snap/*') { return 'snap' }\n if ($path -like '/usr/*' -or $path -like '/bin/*') { return 'apt/system' }\n return ''\n}\n\n# Probe a single tool on the Windows side. Returns a result hashtable or $null.\nfunction Get-WinToolInfo($tool) {\n # Only real executables / scripts -- never PS aliases, functions, or cmdlets\n # (e.g. `R` is the built-in alias for Invoke-History, not the R language).\n $cmd = Get-Command $tool.Exe -CommandType Application,ExternalScript -ErrorAction SilentlyContinue |\n Select-Object -First 1\n if (-not $cmd) { return $null }\n $path = if ($cmd.Source) { $cmd.Source } else { $cmd.Name }\n $raw = ''\n try { $raw = (& $tool.Exe @($tool.Ver) 2>&1 | Out-String) } catch { $raw = '' }\n $ver = Get-CleanVersion $raw\n if (-not $ver -and $tool.Alt) {\n try { $raw = (& $tool.Exe @($tool.Alt) 2>&1 | Out-String) } catch { $raw = '' }\n $ver = Get-CleanVersion $raw\n }\n return @{ Cat=$tool.Cat; Name=$tool.Name; Version=$ver; Path=$path; Source=(Get-WinSource $path) }\n}\n\nfunction Format-HomeCell($homeUrl) {\n if (-not $homeUrl) { return '-' }\n $label = ($homeUrl -replace '^https?://','') -replace '/$',''\n return \"[$label]($homeUrl)\"\n}\nfunction Format-RepoCell($repo) {\n if (-not $repo) { return '-' }\n return \"[$repo](https://github.com/$repo)\"\n}\nfunction Format-Table-Md($rows) {\n if (-not $rows -or $rows.Count -eq 0) { return \"_none found_`n\" }\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('| Tool | Version | Home | GitHub | Source | Path |')\n [void]$sb.AppendLine('|------|---------|------|--------|--------|------|')\n foreach ($r in ($rows | Sort-Object Name)) {\n $ver = if ($r.Version) { $r.Version } else { '(present)' }\n $src = if ($r.Source) { $r.Source } else { '-' }\n $ref = $refLinks[$r.Name]\n $homeCell = Format-HomeCell $ref.Home\n $ghCell = Format-RepoCell $ref.Repo\n [void]$sb.AppendLine(\"| $($r.Name) | $ver | $homeCell | $ghCell | $src | ``$($r.Path)`` |\")\n }\n return $sb.ToString()\n}\n\nfunction Format-SideSection($rows) {\n $sb = New-Object System.Text.StringBuilder\n foreach ($cat in $catOrder) {\n $catRows = $rows | Where-Object { $_.Cat -eq $cat }\n if (-not $catRows) { continue }\n [void]$sb.AppendLine(\"### $cat`n\")\n [void]$sb.AppendLine((Format-Table-Md $catRows))\n }\n return $sb.ToString()\n}\n\n# ---------------------------------------------------------------------------\n# \"Unrecognized dev tool on PATH\" heads-up. Scans the dirs where dev package\n# managers drop user-installed CLIs (cargo/go/dotnet/npm/scoop/.local) and flags\n# any executable whose name isn't a catalogued tool or a known companion binary\n# of one. High-signal by construction: it never looks in system dirs, so a hit\n# is almost always a real tool you installed but haven't catalogued yet.\n# ---------------------------------------------------------------------------\n$knownExe = @{}\nforeach ($t in ($catalog + $editorCatalog)) { $knownExe[$t.Exe.ToLower()] = $true }\n\n# Auxiliary binaries that ship WITH a catalogued tool -- not \"new tools\".\n$companionExe = @{}\nforeach ($n in @(\n 'rustfmt','rustdoc','clippy-driver','cargo-clippy','cargo-fmt','cargo-miri',\n 'rust-gdb','rust-gdbgui','rust-lldb','rust-analyzer',\n 'gofmt','godoc','npx','pnpx','corepack','yarnpkg',\n 'uvx','uvw','rls',\n 'pydoc','2to3','idle','wheel','activate',\n 'php-cgi','phpdbg','erl','epmd','escript','iex','mix','rebar3',\n 'bundler','rdoc','ri'\n)) { $companionExe[$n] = $true }\n\nfunction Test-KnownTool([string]$name) {\n $n = $name.ToLower()\n if ($knownExe.ContainsKey($n) -or $companionExe.ContainsKey($n)) { return $true }\n # version-suffixed interpreters/tools: python3.12, pip3, ruby3.3, perl5.40, node18, php8.3\n if ($n -match '^(python|pip|ruby|perl|php|node|lua|luajit|clang|gcc|dotnet)w?[-.]?[0-9][0-9.]*$') { return $true }\n return $false\n}\n\nfunction Get-UncataloguedWin {\n $dirs = @(\n (Join-Path $env:USERPROFILE '.cargo\\bin')\n (Join-Path $env:USERPROFILE '.dotnet\\tools')\n (Join-Path $env:USERPROFILE '.local\\bin')\n (Join-Path $env:APPDATA 'npm')\n (Join-Path $env:USERPROFILE 'scoop\\shims')\n )\n if (Get-Command go -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n $gb = if ($env:GOBIN) { $env:GOBIN } else { Join-Path ((& go env GOPATH 2>$null).Trim()) 'bin' }\n if ($gb) { $dirs += $gb }\n } catch { Write-Verbose \"go env GOPATH failed; leaving the Go bin dir out of the scan.\" }\n }\n $seen = @{}; $out = @()\n foreach ($d in ($dirs | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique)) {\n Get-ChildItem $d -File -ErrorAction SilentlyContinue |\n Where-Object { $_.Extension -in '.exe','.cmd','.bat','.ps1' } |\n ForEach-Object {\n $base = [IO.Path]::GetFileNameWithoutExtension($_.Name)\n $bl = $base.ToLower()\n if ($base -and -not (Test-KnownTool $base) -and -not $seen.ContainsKey($bl)) {\n $seen[$bl] = $true\n $out += @{ Name = $base; Path = $_.FullName }\n }\n }\n }\n return $out\n}\n\nfunction Format-Unknown-Md($items) {\n $items = @($items)\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('### Uncatalogued on PATH')\n [void]$sb.AppendLine('')\n [void]$sb.AppendLine('_Dev-manager binaries not in the catalog. Add a `$catalog` row (+ `$refLinks`) to track one._')\n [void]$sb.AppendLine('')\n [void]$sb.AppendLine('| Tool | Path |')\n [void]$sb.AppendLine('|------|------|')\n $cap = 60; $i = 0\n foreach ($u in ($items | Sort-Object { $_.Name })) {\n if ($i -ge $cap) { break }\n [void]$sb.AppendLine(\"| $($u.Name) | ``$($u.Path)`` |\")\n $i++\n }\n if ($items.Count -gt $cap) { [void]$sb.AppendLine(\"`n_+ $($items.Count - $cap) more._\") }\n return $sb.ToString()\n}\n\n# ---------------------------------------------------------------------------\n# Host-agnostic bash probe (issue #714, TC-2). The probe script makes no\n# assumption about how it gets run -- WSL today (`wsl.exe -d <distro> -- bash\n# <file>`), SSH next (TC-3) -- so \"build the script\", \"run it via some\n# transport\" and \"parse its output\" are three separable functions, each\n# testable without wsl.exe or a registered distro.\n# ---------------------------------------------------------------------------\n\nfunction New-ToolchainProbeScript {\n <#\n Builds the probe script TEXT (LF line endings) with the tool catalog embedded as a\n quoted heredoc, so the whole probe is one self-contained file any transport can run --\n a temp file for WSL, stdin for SSH -- instead of reading a second file only the same\n host can see.\n #>\n param([Parameter(Mandatory)][array]$Catalog)\n\n $tsvLines = $(foreach ($t in $Catalog) { \"{0}`t{1}`t{2}\" -f $t.Name, $t.Exe, ($t.Ver -join ' ') })\n $tsv = ($tsvLines -join \"`n\")\n\n # The catalog-pass body (issue #721 decision 2): reused VERBATIM in two places below --\n # once at top level for this host/side, and again (unchanged text) fed into each running\n # container via `docker exec -i <id> bash -s` in the Containers block -- reuse, not a\n # second catalog. A function, not inline, precisely so the SAME text can be dropped in\n # both places via one `.Replace()` below. `known` is populated as a side effect (every\n # catalog Exe name, found or not) for the uncatalogued-on-PATH scan further down the HOST\n # script to reuse; it must already be `declare -A`'d by the CALLER -- a `declare` INSIDE\n # this function would make it local and the later scan would see an empty array.\n $catalogPassFn = @'\ncatalog_pass() {\n while IFS=$'\\t' read -r name exe verargs; do\n [ -z \"$exe\" ] && continue\n known[\"$(printf '%s' \"$exe\" | tr 'A-Z' 'a-z')\"]=1\n loc=$(command -v \"$exe\" 2>/dev/null) || continue\n case \"$loc\" in \"${TOOLCHAIN_PROBE_INTEROP_PREFIX:-/mnt/}\"*) continue;; esac # skip Windows tools seen via interop\n ver=$($exe $verargs 2>&1 | grep -m1 . | tr -d '\\r')\n printf '%s\\t%s\\t%s\\n' \"$name\" \"$ver\" \"$loc\"\n done\n}\n'@\n $catalogPassFn = ($catalogPassFn -replace \"`r`n\", \"`n\").Trim(\"`n\")\n\n $probeSh = @'\n#!/usr/bin/env bash\ninterop=\"${TOOLCHAIN_PROBE_INTEROP_PREFIX:-/mnt/}\"\n__CATALOG_PASS_FN__\ndeclare -A known\ncatalog_pass <<'__CATALOG__'\n__TSV__\n__CATALOG__\necho \"===GLOBALS===\"\nrun(){ 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; }\nrun npm \"npm -g\" npm ls -g --depth=0\nrun pipx \"pipx\" pipx list --short\nrun uv \"uv tool\" uv tool list\nrun cargo \"cargo install\" cargo install --list\nrun gem \"gem (user)\" gem list --local\nif 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\necho \"===UNKNOWN===\"\nfor 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\ngb=\"\"; 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\nfor d in \"$HOME/.cargo/bin\" \"$gb\" \"$HOME/.local/bin\" \"$HOME/.dotnet/tools\" /usr/local/bin; do\n [ -d \"$d\" ] || continue\n for f in \"$d\"/*; do\n [ -f \"$f\" ] && [ -x \"$f\" ] || continue\n b=$(basename \"$f\"); bl=$(printf '%s' \"$b\" | tr 'A-Z' 'a-z')\n case \"$bl\" in python[0-9]*|pip[0-9]*|ruby[0-9]*|perl[0-9]*|php[0-9]*|node[0-9]*|*.ps1) continue;; esac\n [ -n \"${known[$bl]}\" ] && continue\n printf '%s\\t%s\\n' \"$b\" \"$f\"\n done\ndone | sort -u\n\n# Containers (issue #721): a running container's package list + catalogued tools, on a\n# Linux side only -- Windows never has docker on PATH here, so this whole block is inert\n# there. Emitted ONLY when docker is on PATH and `docker ps` itself succeeds, so an\n# absent/broken docker leaves NO ===CONTAINERS=== marker at all (decision 1: the sidecar\n# then carries no `containers` key) -- distinct from docker present with zero running\n# containers, which DOES print the marker with zero ===CONTAINER=== blocks (sidecar:\n# `containers: []`). Every docker verb below is read-only (ps, inspect, exec) -- decision\n# 4 -- and each container's failure is isolated from every other's (decision 3): `docker\n# inspect` failing (the container vanished between `ps` and here) skips that container\n# entirely, since decision 6 pins `imageId` as ALWAYS present and sha256-shaped, unlike\n# packages/tools, which have an explicit 'unknown' escape hatch.\nif command -v docker >/dev/null 2>&1 && docker ps >/dev/null 2>&1; then\n echo \"===CONTAINERS===\"\n # Process substitution (`< <(...)`), never `docker ps | while read`: this loop's body\n # feeds heredocs to nested `docker exec -i ... bash -s` calls below, and a `| while`\n # would redirect the LOOP's own stdin to the `docker ps` pipe -- ambiguous with heredoc\n # parsing. Process substitution never touches this script's own stdin, so the nested\n # heredocs (parsed once, at script-parse time, from the ORIGINAL stdin this whole probe\n # arrived on) stay unambiguous no matter how many containers the loop iterates over.\n mapfile -t __rb_containers < <(docker ps --no-trunc --format '{{.ID}}'$'\\t''{{.Names}}'$'\\t''{{.Image}}' 2>/dev/null)\n for __rb_line in \"${__rb_containers[@]}\"; do\n IFS=$'\\t' read -r cid cname cimage <<< \"$__rb_line\"\n [ -z \"$cid\" ] && continue\n image_id=$(docker inspect -f '{{.Image}}' \"$cid\" 2>/dev/null)\n [ -z \"$image_id\" ] && continue # vanished between ps and inspect: skip, not an error\n echo \"===CONTAINER===\"\n printf '%s\\t%s\\t%s\\n' \"$cname\" \"$cimage\" \"$image_id\"\n\n # Packages: ONE exec, a three-way query with an exit-code sentinel for \"none of the\n # three\" (decision 2). The chosen manager echoes a __PM_*__ marker as its own FIRST\n # line before its listing, so the caller (below, on the HOST -- never inside the\n # container) knows which format the rows are in without a second exec. dpkg-query's\n # and rpm's own format strings already emit name<TAB>version; apk's `-v` output\n # (`name-ver-rN`) is parsed into the same two-column shape OUTSIDE the container, on\n # the host, by the sed pass below -- no sed dependency inside the target image. `\\$`\n # (not `$`) before dpkg's `${Package}`/`${Version}` placeholders: this whole query is\n # single-quoted at the bash level (so THIS script's own shell never touches it), but\n # `sh -c` still double-quote-parses its one argument, where a bare `$` would try (and\n # fail) to expand an unset shell variable -- `\\$` survives that parse as a literal `$`\n # for dpkg-query itself to interpret. `\\t`/`\\n` are not recognised escapes inside\n # POSIX double quotes, so they reach dpkg-query/rpm untouched, exactly as their own\n # format-string escapes expect.\n #\n # `timeout 20` (round-1 review): a hung package manager inside ONE container (a wedged\n # dpkg lock, a stuck apk network call despite -no-network commands being used here)\n # would otherwise hang this exec indefinitely -- and with it the WHOLE remote probe,\n # every OTHER container and every OTHER SSH target queued after it, since nothing\n # upstream of this call bounds it. `timeout`'s own 124 exit on expiry is just another\n # non-zero $pkg_rc to the existing check below -- no new branch, same 'unknown' outcome\n # a real exec failure already gets.\n pkg_out=$(timeout 20 docker exec \"$cid\" sh -c '\n if command -v dpkg-query >/dev/null 2>&1; then\n echo \"__PM_DPKG__\"\n dpkg-query -W -f=\"\\${Package}\\t\\${Version}\\n\" 2>/dev/null\n elif command -v apk >/dev/null 2>&1; then\n echo \"__PM_APK__\"\n apk info -v 2>/dev/null\n elif command -v rpm >/dev/null 2>&1; then\n echo \"__PM_RPM__\"\n rpm -qa --qf \"%{NAME}\\t%{VERSION}\\n\" 2>/dev/null\n else\n exit 7\n fi\n ' 2>/dev/null)\n pkg_rc=$?\n echo \"===PACKAGES===\"\n if [ $pkg_rc -ne 0 ] || [ -z \"$pkg_out\" ]; then\n echo \"__UNKNOWN__\"\n else\n pm_marker=$(printf '%s\\n' \"$pkg_out\" | head -1)\n case \"$pm_marker\" in\n __PM_APK__)\n # name-ver-rN -> name<TAB>version (decision 2): greedy backtracking on the first\n # group finds the RIGHTMOST hyphen whose remainder looks like a version (starts\n # with a digit, optionally followed by -rN), so a hyphenated package name (e.g.\n # ca-certificates-20230506-r0 -> \"ca-certificates\" / \"20230506-r0\") splits\n # correctly without a package database to consult.\n echo \"$pm_marker\"\n printf '%s\\n' \"$pkg_out\" | tail -n +2 | sed -n 's/^\\(.*\\)-\\([0-9][^-]*\\(-r[0-9][0-9]*\\)\\{0,1\\}\\)$/\\1\\t\\2/p'\n ;;\n __PM_DPKG__|__PM_RPM__)\n printf '%s\\n' \"$pkg_out\"\n ;;\n *)\n echo \"__UNKNOWN__\"\n ;;\n esac\n fi\n\n # Tools: the SAME catalog pass, fed the SAME catalog TSV, run inside the container via\n # `docker exec -i <id> bash -s` (decision 2). No separate \"has bash\" probe first (that\n # would be a THIRD exec'd command, past decision 4's \"packages query + catalog pass\"\n # allowance) -- a container with no bash just makes this `docker exec` itself fail,\n # which the exit-code check below treats identically to \"vanished\" (decision 3:\n # unknown, run continues). A zero-tools-found container (bash present, nothing on its\n # PATH is catalogued) is NOT unknown -- exit 0 with empty stdout, kept distinct.\n # `timeout 20` -- same round-1-review reasoning as the package query above: this pass\n # runs the full catalog (~70 `command -v` + version-flag checks), so a single wedged\n # tool inside one container must not be able to hang the whole remote probe.\n tools_out=$(timeout 20 docker exec -i \"$cid\" bash -s 2>/dev/null <<'__RB_CONTAINER_PROBE__'\n__CATALOG_PASS_FN__\ndeclare -A known\ncatalog_pass <<'__CATALOG__'\n__TSV__\n__CATALOG__\n__RB_CONTAINER_PROBE__\n)\n tools_rc=$?\n echo \"===TOOLS===\"\n if [ $tools_rc -ne 0 ]; then\n echo \"__UNKNOWN__\"\n else\n printf '%s\\n' \"$tools_out\"\n fi\n echo \"===CONTAINER_END===\"\n done\nfi\n'@\n $probeSh = ($probeSh -replace \"`r`n\", \"`n\").Replace('__CATALOG_PASS_FN__', $catalogPassFn).Replace('__TSV__', $tsv)\n return $probeSh\n}\n\nfunction Invoke-ToolchainProbe {\n <#\n Runs probe script text through a transport and returns the raw stdout as one string.\n -Transport is a scriptblock: (ScriptText) -> raw output string. Today's only transport\n is WSL (defined next to its call site, below); TC-3 adds an SSH one with the same shape.\n #>\n param(\n [Parameter(Mandatory)][string]$ScriptText,\n [Parameter(Mandatory)][scriptblock]$Transport\n )\n return & $Transport $ScriptText\n}\n\nfunction ConvertFrom-ToolchainProbeOutput {\n <#\n Parses one probe run's raw stdout into catalogued rows, the globals text, the\n uncatalogued-tool list and (issue #721) the containers list -- the `===GLOBALS===` /\n `===UNKNOWN===` / `===CONTAINERS===` sentinel split that used to live inline in the WSL\n block. Same shapes as before: Rows carry Cat/Name/Version/Path/Source (Source via\n Get-WslSource); Unknown carries Name/Path. `Containers` carries\n Name/Image/ImageId/PackageManager/Packages/Tools (see ConvertFrom-ToolchainContainersOutput);\n `ContainersReported` is $true only when a `===CONTAINERS===` marker appeared at all --\n distinct from an empty `Containers` array, which can ALSO mean \"no docker on this side\"\n (decision 1: the caller must tell those two apart to decide whether the sidecar carries\n a `containers` key at all).\n #>\n param(\n [Parameter(Mandatory)][AllowEmptyString()][string]$Raw,\n [Parameter(Mandatory)][array]$Catalog\n )\n\n $rows = @()\n $unknown = @()\n\n $g = $Raw -split '===GLOBALS==='\n $toolOut = $g[0]\n $rest = if ($g.Count -gt 1) { $g[1] } else { '' }\n $u = $rest -split '===UNKNOWN==='\n $globalsText = $u[0].Trim()\n $afterUnknown = if ($u.Count -gt 1) { $u[1] } else { '' }\n\n $c = $afterUnknown -split '===CONTAINERS==='\n $unknownOut = $c[0]\n $containersReported = $c.Count -gt 1\n $containersOut = if ($containersReported) { $c[1] } else { '' }\n\n foreach ($line in ($toolOut -split \"`n\")) {\n if (-not $line.Trim()) { continue }\n $parts = $line -split \"`t\"\n if ($parts.Count -lt 3) { continue }\n $name = $parts[0].Trim()\n $ver = Get-CleanVersion $parts[1]\n $loc = $parts[2].Trim()\n $catEntry = $Catalog | Where-Object { $_.Name -eq $name } | Select-Object -First 1\n if (-not $catEntry) { continue }\n $rows += @{ Cat = $catEntry.Cat; Name = $name; Version = $ver; Path = $loc; Source = (Get-WslSource $loc) }\n }\n\n foreach ($line in ($unknownOut -split \"`n\")) {\n if (-not $line.Trim()) { continue }\n $p = $line -split \"`t\"\n if ($p.Count -lt 2) { continue }\n $unknown += @{ Name = $p[0].Trim(); Path = $p[1].Trim() }\n }\n\n $containers = if ($containersReported) { ConvertFrom-ToolchainContainersOutput -Raw $containersOut -Catalog $Catalog } else { @() }\n\n return @{\n Rows = @($rows); GlobalsText = $globalsText; Unknown = @($unknown)\n Containers = @($containers); ContainersReported = $containersReported\n }\n}\n\nfunction ConvertFrom-ToolchainContainerPackages([string]$Raw) {\n <#\n The `===PACKAGES===` block for ONE container -> @{ PackageManager; Packages }.\n PackageManager is 'dpkg' | 'apk' | 'rpm' | 'unknown' (decision 6); Packages is the\n string 'unknown' (mirroring PackageManager) or an array of @{Name;Version}. The probe\n script (New-ToolchainProbeScript) already normalises apk's `name-ver-rN` into\n name<TAB>version on the host side, so every known manager's rows share one shape here.\n #>\n $lines = @($Raw -split \"`n\" | Where-Object { $_.Trim() -ne '' })\n if (-not $lines.Count -or $lines[0].Trim() -eq '__UNKNOWN__') {\n return @{ PackageManager = 'unknown'; Packages = 'unknown' }\n }\n $pm = switch ($lines[0].Trim()) {\n '__PM_DPKG__' { 'dpkg' }\n '__PM_APK__' { 'apk' }\n '__PM_RPM__' { 'rpm' }\n default { $null }\n }\n if (-not $pm) { return @{ PackageManager = 'unknown'; Packages = 'unknown' } }\n $out = @()\n foreach ($line in ($lines | Select-Object -Skip 1)) {\n $p = $line -split \"`t\", 2\n if ($p.Count -lt 2) { continue }\n $out += @{ Name = $p[0].Trim(); Version = $p[1].Trim() }\n }\n # No leading comma here, unlike this file's other ConvertTo-*/ConvertFrom-* functions'\n # OWN bare `return , @(...)`: THIS array is a plain hashtable-literal VALUE, not itself\n # the function's top-level return -- PowerShell's auto-unwrap-a-single-element-stream\n # quirk (what the comma trick compensates for) only fires at a function-call boundary,\n # and constructing @($out) directly inside a `return @{ ... }` hashtable literal is not\n # one -- confirmed by this file's OWN `return @{ Rows = @($rows); ... }` a few functions\n # up, which uses no comma at this exact same position. A leading comma here double-wraps\n # into an array-of-one-array instead (caught in review: Packages came back as\n # `[[{...}]]`).\n return @{ PackageManager = $pm; Packages = @($out) }\n}\n\nfunction ConvertFrom-ToolchainContainerTools {\n <#\n The `===TOOLS===` block for ONE container -> the string 'unknown' (no bash in the\n container, decision 3) or an array shaped exactly like ConvertFrom-ToolchainProbeOutput's\n own Rows (Cat/Name/Version/Path/Source, matched against -Catalog) -- the SAME catalog\n pass, so a container's catalogued tools carry the same category/refLinks-ready Name the\n rest of the report does. `Source` is always '' here (Get-WslSource's heuristics are\n host-filesystem-shaped, e.g. WSL mount prefixes -- meaningless for a container's own\n rootfs), matching ConvertTo-ToolchainSidecarTools's own '(present)'/'' fallbacks.\n #>\n param([string]$Raw, [array]$Catalog)\n $lines = @($Raw -split \"`n\" | Where-Object { $_.Trim() -ne '' })\n if (-not $lines.Count -or $lines[0].Trim() -eq '__UNKNOWN__') { return 'unknown' }\n $out = @()\n foreach ($line in $lines) {\n $parts = $line -split \"`t\"\n if ($parts.Count -lt 3) { continue }\n $tname = $parts[0].Trim(); $ver = Get-CleanVersion $parts[1]; $loc = $parts[2].Trim()\n $catEntry = $Catalog | Where-Object { $_.Name -eq $tname } | Select-Object -First 1\n if (-not $catEntry) { continue }\n $out += @{ Cat = $catEntry.Cat; Name = $tname; Version = $ver; Path = $loc; Source = '' }\n }\n return , @($out)\n}\n\nfunction ConvertFrom-ToolchainContainersOutput {\n <#\n Parses the `===CONTAINERS===` block (issue #721) into an array of container hashtables:\n @{ Name; Image; ImageId; PackageManager; Packages; Tools }. One `===CONTAINER===` block\n per running container; a container whose `docker inspect` failed between `ps` and the\n probe reaching it is never emitted at all (skipped upstream, in bash) rather than\n reported with a missing/invalid ImageId -- decision 6 pins the digest as ALWAYS present\n and sha256-shaped, unlike Packages/Tools, which have an explicit 'unknown' escape hatch.\n #>\n param(\n [Parameter(Mandatory)][AllowEmptyString()][string]$Raw,\n [Parameter(Mandatory)][array]$Catalog\n )\n\n $out = @()\n $blocks = $Raw -split '===CONTAINER==='\n foreach ($block in $blocks) {\n if (-not $block.Trim()) { continue }\n\n $afterHeader = $block -split '===PACKAGES==='\n if ($afterHeader.Count -lt 2) { continue }\n $headerLine = ($afterHeader[0] -split \"`n\" | Where-Object { $_.Trim() -ne '' } | Select-Object -First 1)\n if (-not $headerLine) { continue }\n $hp = $headerLine -split \"`t\"\n if ($hp.Count -lt 3) { continue }\n $name = $hp[0].Trim(); $image = $hp[1].Trim(); $imageId = $hp[2].Trim()\n\n $afterPackages = $afterHeader[1] -split '===TOOLS==='\n $packagesRaw = $afterPackages[0]\n $toolsSection = if ($afterPackages.Count -gt 1) { $afterPackages[1] } else { '' }\n $toolsRaw = ($toolsSection -split '===CONTAINER_END===')[0]\n\n $pkgResult = ConvertFrom-ToolchainContainerPackages $packagesRaw\n $tools = ConvertFrom-ToolchainContainerTools -Raw $toolsRaw -Catalog $Catalog\n\n $out += @{\n Name = $name; Image = $image; ImageId = $imageId\n PackageManager = $pkgResult.PackageManager; Packages = $pkgResult.Packages; Tools = $tools\n }\n }\n return , @($out)\n}\n\n# Change detection: ignore only the volatile _Generated: line, so a no-change run does not\n# rewrite a report or create an empty commit. Top-level (not inside the dot-source guard)\n# because Publish-ToolchainReport below -- called both for real and by the pwsh-driven\n# tests, which dot-source this script -- reads it.\nfunction Remove-GeneratedLine([string]$text) {\n ($text -split \"`n\" | Where-Object { $_ -notmatch '^_Generated:' }) -join \"`n\"\n}\n\n# ---------------------------------------------------------------------------\n# SSH toolchain probing (issue #715, TC-3). Reuses the host-agnostic probe above (the\n# script text and the parser are already transport-agnostic) and adds the report-building\n# and publish steps as their own functions, so the local Windows+WSL report and each\n# remote host's report share one implementation instead of two copy-pasted blocks.\n# ---------------------------------------------------------------------------\n\nfunction ConvertTo-ToolchainSshTargets {\n <#\n `alias=user@host;alias2=user@host2` -> an array of (alias, target) pairs -- each\n pair itself a 2-element array, so `$alias, $target = $pair` destructures it.\n\n A malformed fragment is SKIPPED with a loud warning, not thrown -- config.py's own\n validation (a target must not contain ';') is the real guard against this ever\n reaching here from a registered task, but a hand run can pass -SshTargets directly,\n bypassing that validation entirely. Throwing here would take the #715 failure-isolation\n guarantee and undo it at the one entry point upstream of the whole per-target loop:\n one bad fragment would abort every OTHER configured host's probe before it even\n started, which is precisely the \"one bad target kills the fleet\" failure this script\n exists to prevent for an unreachable HOST -- a malformed fragment deserves the same\n isolation, not a worse outcome.\n #>\n param([string]$Raw)\n\n $result = @()\n if (-not $Raw) { return , $result }\n foreach ($item in ($Raw -split ';')) {\n $item = $item.Trim()\n if (-not $item) { continue }\n $parts = $item -split '=', 2\n if ($parts.Count -ne 2 -or -not $parts[0].Trim() -or -not $parts[1].Trim()) {\n Write-Host \"! malformed -SshTargets entry skipped: '$item' (expected alias=user@host)\" -ForegroundColor Yellow\n continue\n }\n $result += , @($parts[0].Trim(), $parts[1].Trim())\n }\n return , $result\n}\n\nfunction Format-GlobalsSection {\n <#\n Renders zero or more `{Label; Text}` blocks under one \"### Global packages\" heading --\n Windows has several labelled blocks (npm -g, pipx, ...); a remote/WSL side has one\n unlabelled block whose text already carries its own \"### <label>\" sub-headers (the\n probe script's own `run()` output). Empty/blank-text blocks are dropped, and the\n heading itself is omitted when nothing is left -- so a host with no global packages\n installed renders no section at all, exactly as before this was extracted.\n #>\n param([array]$Blocks)\n\n $blocks = @($Blocks | Where-Object { $_ -and $_.Text })\n if (-not $blocks.Count) { return '' }\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('### Global packages')\n [void]$sb.AppendLine('')\n foreach ($b in $blocks) {\n if ($b.Label) {\n [void]$sb.AppendLine(\"**$($b.Label)**\")\n [void]$sb.AppendLine('')\n }\n [void]$sb.AppendLine('```')\n [void]$sb.AppendLine($b.Text)\n [void]$sb.AppendLine('```')\n [void]$sb.AppendLine('')\n }\n return $sb.ToString()\n}\n\nfunction ConvertTo-ToolchainSidecarTools($rows) {\n <#\n Rows (Cat/Name/Version/Path/Source) -> the sidecar's `tools` array, in the SAME order\n Format-SideSection/Format-Table-Md render them (walk $catOrder, then Sort-Object Name\n within each category) -- so the .json and the .md agree on tool order. `version` gets\n the same '(present)' fallback Format-Table-Md uses for a blank Version, so the field is\n never empty (a validate() requirement, #716); `source` is '' rather than omitted when\n unknown, since every tool has the key.\n #>\n $out = @()\n foreach ($cat in $catOrder) {\n $catRows = @($rows | Where-Object { $_.Cat -eq $cat } | Sort-Object Name)\n foreach ($r in $catRows) {\n $ver = if ($r.Version) { $r.Version } else { '(present)' }\n $out += [ordered]@{\n name = $r.Name\n category = $r.Cat\n version = $ver\n path = $r.Path\n source = $(if ($r.Source) { $r.Source } else { '' })\n }\n }\n }\n return , @($out)\n}\n\nfunction ConvertTo-ToolchainSidecarUnknown($items) {\n <#\n Unknown (Name/Path) -> the sidecar's `uncatalogued` array, sorted by Name. Unlike\n Format-Unknown-Md this carries every entry -- no display cap -- since the JSON is a\n data contract, not a rendered table.\n #>\n $out = @()\n foreach ($u in ($items | Sort-Object { $_.Name })) {\n $out += [ordered]@{ name = $u.Name; path = $u.Path }\n }\n return , @($out)\n}\n\nfunction ConvertTo-ToolchainSidecarContainers($containers) {\n <#\n Container list (Name/Image/ImageId/PackageManager/Packages/Tools) -> the sidecar's\n top-level `containers` array (issue #721 decision 6): {name, image, imageId,\n packageManager, packages, tools}. `packages`/`tools` are each either the literal\n string 'unknown' or a list -- `packages` of {name, version}, `tools` of the SAME shape\n ConvertTo-ToolchainSidecarTools's own `tools` entries use (name/category/version/path/\n source), so a container's catalogued tools round-trip through the identical field set\n the per-side tools already carry. `containers` is its OWN top-level key, not nested\n under any side, since a container isn't scoped to \"windows\"/\"wsl:<distro>\"/\"linux\" the\n way a side is -- it's a fact about the host as a whole.\n #>\n $out = @()\n foreach ($c in $containers) {\n $packagesOut = if ($c.Packages -eq 'unknown') { 'unknown' } else {\n , @($c.Packages | ForEach-Object { [ordered]@{ name = $_.Name; version = $_.Version } })\n }\n $toolsOut = if ($c.Tools -eq 'unknown') { 'unknown' } else {\n , @($c.Tools | ForEach-Object {\n $ver = if ($_.Version) { $_.Version } else { '(present)' }\n [ordered]@{ name = $_.Name; category = $_.Cat; version = $ver; path = $_.Path; source = $(if ($_.Source) { $_.Source } else { '' }) }\n })\n }\n $out += [ordered]@{\n name = $c.Name\n image = $c.Image\n imageId = $c.ImageId\n packageManager = $c.PackageManager\n packages = $packagesOut\n tools = $toolsOut\n }\n }\n return , @($out)\n}\n\nfunction Format-ContainersSection {\n <#\n Renders the #721 `## Containers` section: one summary table (name, image, image ID --\n the first 12 hex chars after `sha256:` -- package manager, package count and tools\n found), then one `<details><summary>container name</summary>` block per container\n holding its full package table (name, version) and catalogued-tools table. Full lists\n stay in the Markdown, not sidecar-only: Publish-ToolchainReport's change detection\n compares the .md text (ignoring only `_Generated:`), and the sidecar is rewritten only\n when the .md changed or was missing -- a sidecar-only package list would freeze the\n moment the .md stopped changing for an unrelated reason (the #721 plan's own caught\n trap). Called only when the probe actually reported a Containers section (docker was on\n PATH and `docker ps` succeeded) -- an empty array still renders the \"_none running_\"\n line, distinct from the caller skipping this function entirely (no docker at all).\n #>\n param([array]$Containers)\n\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('## Containers')\n [void]$sb.AppendLine('')\n if (-not $Containers -or $Containers.Count -eq 0) {\n [void]$sb.AppendLine('_none running_')\n [void]$sb.AppendLine('')\n return $sb.ToString()\n }\n\n [void]$sb.AppendLine('| Name | Image | Image ID | Package manager | Packages | Tools |')\n [void]$sb.AppendLine('|------|-------|----------|------------------|----------|-------|')\n foreach ($c in ($Containers | Sort-Object Name)) {\n $shortId = if ($c.ImageId -match '^sha256:(.{12})') { $Matches[1] } else { $c.ImageId }\n $pkgCount = if ($c.Packages -eq 'unknown') { 'unknown' } else { \"$(@($c.Packages).Count)\" }\n $toolCount = if ($c.Tools -eq 'unknown') { 'unknown' } else { \"$(@($c.Tools).Count)\" }\n [void]$sb.AppendLine(\"| $($c.Name) | ``$($c.Image)`` | ``$shortId`` | $($c.PackageManager) | $pkgCount | $toolCount |\")\n }\n [void]$sb.AppendLine('')\n\n foreach ($c in ($Containers | Sort-Object Name)) {\n [void]$sb.AppendLine(\"<details><summary>$($c.Name)</summary>\")\n [void]$sb.AppendLine('')\n if ($c.Packages -eq 'unknown') {\n [void]$sb.AppendLine('_packages: unknown (no dpkg/apk/rpm found)_')\n } elseif (@($c.Packages).Count -eq 0) {\n [void]$sb.AppendLine('_no packages reported_')\n } else {\n [void]$sb.AppendLine('| Package | Version |')\n [void]$sb.AppendLine('|---------|---------|')\n foreach ($p in ($c.Packages | Sort-Object Name)) {\n [void]$sb.AppendLine(\"| $($p.Name) | $($p.Version) |\")\n }\n }\n [void]$sb.AppendLine('')\n if ($c.Tools -eq 'unknown') {\n [void]$sb.AppendLine('_tools: unknown (no bash in the container)_')\n } elseif (@($c.Tools).Count -eq 0) {\n [void]$sb.AppendLine('_no catalogued tools found_')\n } else {\n [void]$sb.AppendLine('| Tool | Version | Path |')\n [void]$sb.AppendLine('|------|---------|------|')\n foreach ($t in ($c.Tools | Sort-Object Name)) {\n [void]$sb.AppendLine(\"| $($t.Name) | $($t.Version) | ``$($t.Path)`` |\")\n }\n }\n [void]$sb.AppendLine('')\n [void]$sb.AppendLine('</details>')\n [void]$sb.AppendLine('')\n }\n return $sb.ToString()\n}\n\nfunction New-ToolchainReport {\n <#\n Assembles ONE report's full Markdown text AND its JSON sidecar (#716, TC-4) from the\n same -Sides data: title, `_Generated:` line, an optional summary line and an optional\n `> History:` line, then one `## <Title>` section per entry in -Sides (tool table,\n global packages, uncatalogued-on-PATH) via Format-SideSection / Format-GlobalsSection\n / Format-Unknown-Md for the Markdown half, and ConvertTo-ToolchainSidecarTools /\n ConvertTo-ToolchainSidecarUnknown for the sidecar half -- both read the same\n -Sides.Rows / -Sides.Unknown, so the two documents can't drift apart from the same\n call. Each -Sides entry now also carries a `Side` key ('windows' | 'wsl:<distro>' |\n 'linux'), the sidecar's per-side identity -- distinct from `Title`, the Markdown\n heading text.\n\n Used for the LOCAL report today (Windows + WSL, two Sides entries, the summary and\n history lines populated) and for a remote host's report (#715: one Sides entry with\n Title 'Linux', no summary line, no history line) -- the difference between the two is\n entirely in what the CALLER passes, not a second code path.\n\n Returns @{ Markdown = <string>; Sidecar = <ordered hashtable> }. -HostId is the\n report-file suffix (the store's identity for the box, e.g. 'nitro' or the sanitised\n local $machine) -- the sidecar's `host`; -Hostname is the box as it reports itself\n (`hostname`); -Os is 'windows' or 'linux'; -GeneratedIso is the run's UTC timestamp,\n second precision, e.g. '2026-09-15T14:02:11Z' (the sidecar's `generated` -- distinct\n from -Now, the local-time display string the Markdown's `_Generated:` line uses).\n\n -Containers / -ContainersReported (issue #721) are REPORT-level, not per-Side: a\n container isn't scoped to one side's identity the way tools/uncatalogued are, so a\n `## Containers` section (Format-ContainersSection) renders once, after every -Sides\n entry, and the sidecar's `containers` key is added once, top-level -- only when\n -ContainersReported is $true (docker was on PATH and `docker ps` succeeded on\n whichever side actually ran the bash probe). -ContainersReported $false must NOT add\n the key at all, even with -Containers empty -- that is how a host/side with no docker\n stays indistinguishable from before this feature (no `containers` key), while docker\n present with zero running containers still gets `containers: []`.\n #>\n param(\n [Parameter(Mandatory)][string]$HostLabel,\n [Parameter(Mandatory)][string]$GeneratedOn,\n [Parameter(Mandatory)][string]$Now,\n [string]$SummaryLine = '',\n [string]$HistoryRel = '',\n [Parameter(Mandatory)][array]$Sides,\n [Parameter(Mandatory)][string]$HostId,\n [Parameter(Mandatory)][string]$Hostname,\n [Parameter(Mandatory)][ValidateSet('windows', 'linux')][string]$Os,\n [Parameter(Mandatory)][string]$GeneratedIso,\n [array]$Containers = @(),\n [bool]$ContainersReported = $false\n )\n\n $md = New-Object System.Text.StringBuilder\n [void]$md.AppendLine(\"# Toolchain Inventory: $HostLabel\")\n [void]$md.AppendLine('')\n [void]$md.AppendLine(\"_Generated: $Now on ${GeneratedOn}_\")\n [void]$md.AppendLine('')\n if ($SummaryLine) {\n [void]$md.AppendLine($SummaryLine)\n [void]$md.AppendLine('')\n }\n if ($HistoryRel) {\n [void]$md.AppendLine(\"> History: ``git log -- $HistoryRel`` in the claude-memory-sync repo.\")\n [void]$md.AppendLine('')\n }\n $sidecarSides = @()\n foreach ($side in $Sides) {\n [void]$md.AppendLine(\"## $($side.Title)\")\n [void]$md.AppendLine('')\n [void]$md.Append((Format-SideSection $side.Rows))\n [void]$md.Append((Format-GlobalsSection $side.GlobalsBlocks))\n if (@($side.Unknown).Count -gt 0) {\n [void]$md.Append((Format-Unknown-Md $side.Unknown))\n [void]$md.AppendLine('')\n }\n $sidecarSides += [ordered]@{\n side = $side.Side\n tools = (ConvertTo-ToolchainSidecarTools $side.Rows)\n uncatalogued = (ConvertTo-ToolchainSidecarUnknown $side.Unknown)\n }\n }\n if ($ContainersReported) {\n [void]$md.Append((Format-ContainersSection $Containers))\n }\n $sidecar = [ordered]@{\n schemaVersion = 1\n host = $HostId\n hostname = $Hostname\n os = $Os\n generated = $GeneratedIso\n sides = @($sidecarSides)\n }\n if ($ContainersReported) {\n $sidecar['containers'] = (ConvertTo-ToolchainSidecarContainers $Containers)\n }\n return @{\n Markdown = ($md.ToString() -replace \"`r`n\", \"`n\")\n Sidecar = $sidecar\n }\n}\n\nfunction Publish-ToolchainReport {\n <#\n Change-detect, write-if-changed, and render/land ONE report -- extracted so the local\n report and each remote host's report (#715) share one implementation instead of the\n local report carrying its own copy-pasted change-detection + render tail.\n\n Ignores only the volatile `_Generated:` line when comparing (Remove-GeneratedLine), so\n a no-change run does not rewrite the file or create an empty commit. -Label is '' for\n the local report, so its two status lines stay BYTE-IDENTICAL to before #715 (#714's\n acceptance greps for one) and '<alias>: ' for a remote. -SkipRenderReason prints its\n own message instead of rendering -- the local -OutFile testing path, which writes\n somewhere other than the store and so must not render the store's (different, possibly\n absent) copy. Returns 'written', 'unchanged' or 'render-failed' (written to disk, but\n build.py exited non-zero) -- NEVER terminates the script itself, unlike the pre-#715\n local-only flow this was extracted from. A bare `exit` here would abort the enclosing\n per-target loop in Invoke-RemoteToolchainInventory via $ErrorActionPreference='Stop'\n (exit is not a catchable exception, so the surrounding try/catch would not save the\n remaining targets), defeating the whole point of #715's failure isolation for every\n target queued after the one whose render failed -- and, for the LOCAL report, would\n skip SSH probing entirely on a render failure that has nothing to do with SSH\n reachability. Callers decide what 'render-failed' means for the run as a whole: the\n main flow folds it into the final exit code (matching the pre-#715 behaviour of exiting\n 1 on a local render failure) but only AFTER SSH probing has run; Invoke-RemoteToolchainInventory\n folds it into Failed like an unreachable host, so the affected alias is visible and the\n loop still completes every other target.\n\n -Sidecar (#716, TC-4) is the JSON sidecar object New-ToolchainReport built alongside\n -Text, published from the SAME change verdict as the .md -- \"land both or neither\" --\n EXCEPT that a missing sidecar is also its own trigger: the .md alone changing (or not)\n decides whether $FileName itself is rewritten (so the sidecar's `generated` timestamp\n never forces a no-op git diff on the .md), but the sidecar is (re)written whenever it\n is either due a real change OR simply absent from disk -- covering both this feature's\n first rollout (an unchanged .md that never had a sidecar) and recovery after build.py\n rejects one as invalid and deletes it (build.py's own comment). Serialised with\n `ConvertTo-Json -Depth 6`, LF-normalised, no BOM, keys in the order\n toolchain_sidecar.py documents (that module is this contract's other half -- the\n console side that validates it). Not atomic across the two files (round-2 review,\n #716): a process kill between the .md write and the sidecar write below could leave a\n stale-but-valid sidecar the \"missing\" check can't detect, since it checks presence,\n not content. Consistent with every other write in this script (none are transactional\n either) -- accepted rather than solved here.\n #>\n param(\n [Parameter(Mandatory)][string]$FileName,\n [Parameter(Mandatory)][string]$Text,\n [Parameter(Mandatory)][object]$Sidecar,\n [string]$Rel = '',\n [string]$Label = '',\n [switch]$NoRender,\n [string]$SkipRenderReason = ''\n )\n\n $sidecarFileName = [IO.Path]::ChangeExtension($FileName, '.json')\n\n $mdChanged = $true\n if (Test-Path $FileName) {\n $old = ([IO.File]::ReadAllText($FileName)) -replace \"`r\", ''\n if ((Remove-GeneratedLine $old) -eq (Remove-GeneratedLine $Text)) { $mdChanged = $false }\n }\n # A missing sidecar is ALSO a publish trigger, independent of the .md's own diff --\n # otherwise a host whose report text happens not to change this run (the common case\n # most weeks) can never get its FIRST sidecar after this feature ships, or recover one\n # build.py rejected as invalid and (per build.py's own comment) deleted: the .md text\n # is identical either way, so a check keyed on the .md alone reports 'unchanged'\n # forever and the sidecar never lands (#716 review). This still writes ONLY the\n # sidecar when the .md itself is unchanged -- never touching $FileName -- so a\n # backfill run does not bump `_Generated:` or create a no-op git diff on the .md.\n $sidecarMissing = -not (Test-Path $sidecarFileName)\n if (-not $mdChanged -and -not $sidecarMissing) {\n Write-Host \"= ${Label}No toolchain changes since last run; nothing to publish.\" -ForegroundColor DarkGray\n return 'unchanged'\n }\n\n if ($mdChanged) {\n [IO.File]::WriteAllText($FileName, $Text, (New-Object System.Text.UTF8Encoding($false)))\n Write-Host \"= ${Label}Report written: $FileName\" -ForegroundColor Green\n } else {\n # Deliberately doesn't say \"unchanged\": this branch still returns 'written' below\n # and callers (Invoke-RemoteToolchainInventory's $written bucket, the end-of-run\n # summary) report it as such -- a message containing \"unchanged\" here would read\n # as contradicting that summary line to anyone scanning a scheduled-task log.\n Write-Host \"= ${Label}Report text unchanged; sidecar (re)written.\" -ForegroundColor DarkGray\n }\n $sidecarJson = (($Sidecar | ConvertTo-Json -Depth 6) -replace \"`r`n\", \"`n\") + \"`n\"\n [IO.File]::WriteAllText($sidecarFileName, $sidecarJson, (New-Object System.Text.UTF8Encoding($false)))\n\n if ($NoRender) {\n Write-Host \"= -NoRender: skipped console render/land.\" -ForegroundColor DarkGray\n } elseif ($SkipRenderReason) {\n Write-Host $SkipRenderReason -ForegroundColor DarkGray\n } elseif (-not $storeAvailable -or -not (Test-Path (Join-Path $ArtifactConsoleDir 'build.py'))) {\n $missing = @()\n if (-not $storeAvailable) { $missing += \"store not found: '$ArtifactSourcesDir'\" }\n if (-not (Test-Path (Join-Path $ArtifactConsoleDir 'build.py'))) {\n $missing += \"build.py not found under '$ArtifactConsoleDir'\"\n }\n Write-Host \"! $($missing -join '; '); wrote the report but skipped render.\" -ForegroundColor Yellow\n } else {\n $buildArgs = @('build.py', '--report', $Rel)\n if ($NoSync) { $buildArgs += '--no-sync' }\n Push-Location $ArtifactConsoleDir\n # Piped through Write-Host, not left as a bare native call (issue #715 follow-up):\n # Publish-ToolchainReport's own return value is CAPTURED by every caller\n # ($localResult = ...; $result = ... in Invoke-RemoteToolchainInventory), and a\n # captured function's un-redirected native-command output is swallowed into that\n # same captured collection instead of reaching the console/log -- verified directly\n # (`$x = function { & cmd /c echo hi }` yields $x = @('hi ', <return value>), and\n # nothing is printed) -- which is why build.py's own \"rendered / committed /\n # pushed\" lines stopped reaching the job log the moment this call moved from\n # top-level script code into a function whose result every caller assigns.\n # Write-Host bypasses the success-output stream entirely, so it is never captured\n # regardless of how the caller uses this function's return value.\n try { & $pyExe @buildArgs 2>&1 | ForEach-Object { Write-Host $_ } } finally { Pop-Location }\n # A native command's non-zero exit does NOT throw, even under $ErrorActionPreference =\n # 'Stop': $PSNativeCommandUseErrorActionPreference is off by default (verified $false on\n # pwsh 7.6.5). Without this check the line below announces a render that did not happen\n # and the script still exits 0 -- the same silent-success shape as the bug that buried\n # this whole branch pre-#320. Exit non-zero so the task's LastTaskResult shows it.\n if ($LASTEXITCODE -ne 0) {\n Write-Host \"! build.py exited $LASTEXITCODE; the report is written but NOT rendered: $Rel\" -ForegroundColor Red\n return 'render-failed'\n }\n Write-Host \"= Rendered to artifact-console$(if($NoSync){' (no sync)'}else{' + landed to memory repo'}): $Rel\" -ForegroundColor Green\n }\n return 'written'\n}\n\nfunction Resolve-ToolchainSshExe {\n <#\n ssh.exe resolved explicitly: the scheduled task's PATH is not the interactive one.\n Prefers the built-in OpenSSH client (present on every Windows 10/11 box since the\n optional feature shipped in-box) over whatever `ssh` a `Get-Command` search turns up,\n for the same reason Register-InventoryTask.ps1 resolves pwsh explicitly rather than\n trusting PATH.\n\n `$env:SystemRoot` is skipped entirely when it's null/empty (issue #636 review round\n 2: `Join-Path $null ...` throws \"Cannot bind argument to parameter 'Path'\", which\n this function did unguarded before this fix, real on Linux where SystemRoot is never\n set. This script is Windows/WSL-shaped throughout and every env var it reads\n (SystemRoot here, LOCALAPPDATA/USERPROFILE/APPDATA elsewhere) is real in production,\n so this guard changes nothing on an actual Windows box -- it was added because this\n function was reached by a cross-platform test (the tools-site push, #636), the first\n thing in this file to call this function outside a fake -Transport/-SshExe. That test\n was retired with the push itself (Rackbops/Tooling#667 C3); the guard stays since\n it's still correct and free. The other env reads in this script are NOT similarly\n guarded (LOCALAPPDATA in $stateDir, USERPROFILE/APPDATA in Get-UncataloguedWin) --\n fixing those is out of scope here since nothing currently reaches them from a\n non-Windows test either.\n #>\n if ($env:SystemRoot) {\n $sshExe = Join-Path $env:SystemRoot 'System32\\OpenSSH\\ssh.exe'\n if (Test-Path -LiteralPath $sshExe) { return $sshExe }\n }\n $cmd = Get-Command ssh -ErrorAction SilentlyContinue\n if ($cmd) { return $cmd.Source }\n return $null\n}\n\nfunction Invoke-ToolchainSshTransport {\n <#\n Runs one SSH command, feeding -ScriptText to its stdin as EXACT bytes -- UTF-8, no BOM,\n no CR -- via a raw System.Diagnostics.Process rather than PowerShell's `|` pipeline\n operator into a native command.\n\n THE BUG THIS EXISTS TO FIX (issue #715 follow-up, caught by the real Melody run against\n real nitro/nucbox -- both `ssh exited 127: bash: line 1: $'hostname\\r': command not\n found`). The original transport was `$ScriptText | & $sshExe ...`: piping a string to a\n native command through PowerShell's own pipeline (or calling `.WriteLine()` on its\n StandardInput directly) terminates it with `Environment.NewLine` (`\\r\\n` on Windows)\n regardless of the string's own line endings -- verified directly (`od -An -c` on the\n raw bytes a real local process received). A trailing CRLF on its own does not reliably\n reproduce a FAILURE against a real local bash (a real local `bash -s` runs `hostname\\r\\n`\n fine); the exact failure mode over a real ssh channel could not be reproduced from this\n sandbox (no live target, and the orchestrator's post-merge run is the one place that\n can exercise real ssh). What IS provably true, and is what this function and its own\n tests are built around, is that writing exact, caller-specified bytes -- no CR/CRLF\n PowerShell's own string-to-native-pipe handling would otherwise add -- is unambiguously\n correct regardless of the precise mechanism behind the original failure, and a raw\n `System.Diagnostics.Process` with a direct byte-stream write is the one way to guarantee\n it. No fake `-Transport` scriptblock test can see ANY of this: it is specific to real\n native-process stdin behaviour, which is why this is now its own function, driven\n directly by a REAL local bash standing in for ssh (see test_toolchain_ssh_transport.py)\n rather than only ever exercised through a fake transport. The remote-side band-aid\n (`tr -d '\\r'`) was rejected: the SENDER has to be correct, not the receiver made\n tolerant of a bug whose exact trigger isn't even fully pinned down.\n\n -Arguments is the full, exact argv `$SshExe` runs with (`-o BatchMode=yes ...\n <target> bash -s` in production) -- passed in whole by the caller, not built here, so\n a test can point -SshExe at a real bash and -Arguments at just `@('-s')`, skipping the\n ssh-specific flags/target entirely, and still drive the SAME stdin-writing code this\n function uses for real. Throws (naming the exit code and any stderr) on a non-zero\n exit, matching the original transport's contract.\n #>\n param(\n [Parameter(Mandatory)][string]$SshExe,\n [Parameter(Mandatory)][string[]]$Arguments,\n [Parameter(Mandatory)][AllowEmptyString()][string]$ScriptText\n )\n if (-not $SshExe) { throw 'ssh.exe not found' }\n\n $psi = New-Object System.Diagnostics.ProcessStartInfo\n $psi.FileName = $SshExe\n foreach ($a in $Arguments) { [void]$psi.ArgumentList.Add($a) }\n $psi.RedirectStandardInput = $true\n $psi.RedirectStandardOutput = $true\n $psi.RedirectStandardError = $true\n # Explicit UTF-8 on the READ side too, not just the write: with neither set, .NET\n # falls back to the console's own output encoding (on this box, code page 437) for\n # decoding the child's stdout/stderr, silently mangling any non-ASCII byte a remote\n # tool's version string or path can carry -- verified directly (round-tripping\n # 'cafe unicode' through an unset-encoding child comes back corrupted). \"Byte-exact\"\n # cuts both ways: the fix is for the WRITE side's bug, but the read side gets the same\n # standard while this function is already being rewritten.\n $utf8NoBom = New-Object System.Text.UTF8Encoding($false)\n $psi.StandardOutputEncoding = $utf8NoBom\n $psi.StandardErrorEncoding = $utf8NoBom\n $psi.UseShellExecute = $false\n $psi.CreateNoWindow = $true\n\n $proc = [System.Diagnostics.Process]::Start($psi)\n try {\n # Reads started BEFORE writing stdin, and run async throughout: the child can start\n # producing output before we finish writing input, and on a large enough probe\n # script writing everything first (with nothing draining stdout/stderr) risks the\n # classic same-process pipe deadlock -- the child blocks on a full stdout/stderr\n # pipe while we block trying to finish writing a full stdin pipe.\n $stdoutTask = $proc.StandardOutput.ReadToEndAsync()\n $stderrTask = $proc.StandardError.ReadToEndAsync()\n try {\n # LF only, defensively: New-ToolchainProbeScript already emits LF-only text\n # (its own `-replace \"`r`n\", \"`n\"`), but this is the one place a CR could\n # still slip through -- a one-line literal like 'hostname' has no line ending\n # of its own to normalise, and a future caller might pass one that does.\n $normalized = $ScriptText -replace \"`r`n\", \"`n\" -replace \"`r\", \"`n\"\n $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)\n $proc.StandardInput.BaseStream.Write($bytes, 0, $bytes.Length)\n $proc.StandardInput.BaseStream.Flush()\n } catch {\n # A process that exits (or closes its own stdin) before or while we're still\n # writing -- e.g. a real ssh auth failure (\"Permission denied (publickey)\"),\n # which never even spawns bash -- throws here (\"the pipe is being closed\")\n # BEFORE the real, useful failure reason below is ever reached. Swallowing it\n # is deliberate: the process's own exit code + stderr, read next, is the\n # actual cause, and a caller told \"the pipe is being closed\" instead of\n # \"Permission denied\" or \"Connection refused\" has lost the one thing this\n # whole function exists to report accurately.\n Write-Verbose \"stdin write failed (process likely already exited): $($_.Exception.Message)\"\n } finally {\n try { $proc.StandardInput.Close() } catch { Write-Verbose \"stdin already closed.\" }\n }\n $proc.WaitForExit()\n $stdout = $stdoutTask.GetAwaiter().GetResult()\n $stderr = $stderrTask.GetAwaiter().GetResult()\n\n if ($proc.ExitCode -ne 0) {\n throw \"ssh exited $($proc.ExitCode)$(if ($stderr.Trim()) { \": $($stderr.Trim())\" })\"\n }\n return $stdout\n } finally {\n $proc.Dispose()\n }\n}\n\nfunction Invoke-RemoteToolchainInventory {\n <#\n Probes every configured SSH target, publishing one toolchain-inventory-<alias>.md per\n reachable host and leaving an unreachable host's previous report untouched (#715).\n\n -Transport is `param($target, $scriptText)` -> raw stdout as one string, and throws (or\n the caller treats a null/empty hostname response as unreachable too) when the host\n can't be reached; the production transport pipes the script over\n `ssh -o BatchMode=yes -o ConnectTimeout=8 <target> bash -s` (script on stdin, no remote\n temp file), with ssh.exe resolved explicitly since the scheduled task's PATH is not the\n interactive one. -Now is the SAME 'yyyy-MM-dd HH:mm' string the local report's\n _Generated: line uses, so every report from one run agrees. -LocalHost names where the\n SSH hop originates, for the remote _Generated: line's \"via ssh from <local>\".\n\n Per target, in order: the hostname (the transport given the one-line script\n `hostname`, so the title/`_Generated:` line carries the real name, not the alias) --\n then the host-agnostic probe script through the same transport, parsed, built into a\n single-`linux`-side report, and published under `-Label '<alias>: '`. A failure at any\n step for one target is caught, printed as `! <alias>: unreachable (<reason>)`, and does\n NOT stop the loop -- the local report and every other target still publish.\n\n Returns `{Written, Unchanged, Failed, RenderFailed, ExitCode}`. `RenderFailed` is the\n subset of `Failed` whose underlying `Publish-ToolchainReport` result was specifically\n `'render-failed'`, not a genuinely unreachable host -- the report DID reach the store\n even though build.py's render/land step failed. Kept as its own bucket for diagnostic\n value even though the one consumer that needed the distinction (the tools-site\n page-data push's change detection) was retired in Rackbops/Tooling#667, E2/C3.\n #>\n param(\n [Parameter(Mandatory)][array]$Targets,\n [Parameter(Mandatory)][scriptblock]$Transport,\n [Parameter(Mandatory)][string]$Now,\n [Parameter(Mandatory)][string]$NowIso,\n [Parameter(Mandatory)][string]$LocalHost,\n [switch]$NoRender\n )\n\n $written = @()\n $unchanged = @()\n $failed = @()\n $renderFailed = @()\n\n foreach ($pair in $Targets) {\n $alias, $target = $pair\n try {\n $remoteHostName = ((& $Transport $target 'hostname') | Out-String).Trim()\n if (-not $remoteHostName) { throw 'empty hostname response' }\n\n $probeScript = New-ToolchainProbeScript -Catalog $catalog\n $raw = (& $Transport $target $probeScript) | Out-String\n $parsed = ConvertFrom-ToolchainProbeOutput -Raw $raw -Catalog $catalog\n\n $side = @{\n Title = 'Linux'\n Side = 'linux'\n Rows = $parsed.Rows\n GlobalsBlocks = @(@{ Label = $null; Text = $parsed.GlobalsText })\n Unknown = $parsed.Unknown\n }\n $report = New-ToolchainReport -HostLabel \"$remoteHostName ($alias)\" `\n -GeneratedOn \"$remoteHostName via ssh from $LocalHost\" -Now $Now -Sides @($side) `\n -HostId $alias -Hostname $remoteHostName -Os 'linux' -GeneratedIso $NowIso `\n -Containers $parsed.Containers -ContainersReported $parsed.ContainersReported\n\n $remoteFileName = \"toolchain-inventory-$alias.md\"\n $remoteOutPath = if ($storeAvailable) { Join-Path $storeDir $remoteFileName } else { Join-Path $stateDir $remoteFileName }\n $remoteRel = \"$ArtifactCategory/$remoteFileName\"\n\n $result = Publish-ToolchainReport -FileName $remoteOutPath -Text $report.Markdown `\n -Sidecar $report.Sidecar -Rel $remoteRel -Label \"${alias}: \" -NoRender:$NoRender\n # 'render-failed' (build.py exited non-zero) is isolated exactly like an\n # unreachable host: the report DID reach the store, but is not counted as a\n # clean success, and this alias must not stop the loop for the others. It\n # still joins $failed (every existing caller's \"not a clean success\" meaning\n # is unchanged) but ALSO its own $renderFailed bucket, because unlike a\n # genuinely unreachable host it DID just write a fresh sidecar to the store --\n # kept as diagnostic detail (see this function's own docstring).\n if ($result -eq 'written') { $written += $alias }\n elseif ($result -eq 'unchanged') { $unchanged += $alias }\n else {\n $failed += $alias\n if ($result -eq 'render-failed') { $renderFailed += $alias }\n }\n } catch {\n Write-Host \"! ${alias}: unreachable ($($_.Exception.Message))\" -ForegroundColor Yellow\n $failed += $alias\n }\n }\n\n return @{\n Written = $written\n Unchanged = $unchanged\n Failed = $failed\n RenderFailed = $renderFailed\n ExitCode = $(if ($failed.Count -gt 0) { 2 } else { 0 })\n }\n}\n\n# Run the probing/report/render flow only when executed (pwsh -File / & script.ps1),\n# not when dot-sourced for testing (. ./Get-ToolchainInventory.ps1), which only wants\n# the functions and the catalog above defined.\nif ($MyInvocation.InvocationName -ne '.') {\n\n Write-Host \"= Toolchain inventory: probing Windows side...\" -ForegroundColor Cyan\n\n # ---------------------------------------------------------------------------\n # Windows probing\n # ---------------------------------------------------------------------------\n $winRows = @()\n foreach ($tool in $catalog) {\n $r = Get-WinToolInfo $tool\n if ($r) { $winRows += $r; Write-Host (\" + {0,-16} {1}\" -f $r.Name, $r.Version) }\n }\n\n # Windows global packages\n $winGlobals = [ordered]@{}\n function Add-WinGlobal($label, $exe, $cmdArgs) {\n if (Get-Command $exe -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n # stdout only -- keep error spew (e.g. a missing npm global prefix) out of the report\n $o = (& $exe @cmdArgs 2>$null | Out-String).Trim()\n if ($o) { $script:winGlobals[$label] = $o }\n } catch { Write-Verbose \"$label ($exe) failed; leaving it out of the report.\" }\n }\n }\n Add-WinGlobal 'npm -g' 'npm' @('ls','-g','--depth=0')\n Add-WinGlobal 'pipx' 'pipx' @('list','--short')\n Add-WinGlobal 'uv tool' 'uv' @('tool','list')\n Add-WinGlobal 'cargo install' 'cargo' @('install','--list')\n Add-WinGlobal 'gem (user)' 'gem' @('list','--local')\n if (Get-Command go -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n $gp = (& go env GOPATH 2>$null).Trim()\n $gobin = if ($env:GOBIN) { $env:GOBIN } else { Join-Path $gp 'bin' }\n if (Test-Path $gobin) {\n $bins = Get-ChildItem $gobin -File -ErrorAction SilentlyContinue |\n Select-Object -ExpandProperty Name\n if ($bins) { $winGlobals['go install'] = ($bins -join \"`n\") }\n }\n } catch { Write-Verbose \"go env GOPATH failed; leaving the 'go install' list out of the report.\" }\n }\n\n # Uncatalogued dev tools sitting on PATH (heads-up section). @() forces array\n # semantics so a single hit isn't unwrapped to a bare hashtable (whose .Count\n # would be its key count, not 1).\n $winUnknown = @(Get-UncataloguedWin)\n if ($winUnknown.Count) { Write-Host (\" ~ {0} uncatalogued on PATH\" -f $winUnknown.Count) -ForegroundColor DarkYellow }\n\n # ---------------------------------------------------------------------------\n # WSL probing. The probe logic is written to a real .sh file on disk and run\n # via `bash <file>` -- NOT passed as a `bash -lc <string>` argument, because\n # wsl.exe strips backslash escapes (\\t \\n \\r) from command-line arguments,\n # which silently breaks IFS/printf/tr. A script on disk is immune to that.\n # ---------------------------------------------------------------------------\n function ConvertTo-WslPath([string]$winPath) {\n $p = (& wsl.exe -d $WslDistro wslpath -a ($winPath -replace '\\\\','/') 2>$null)\n $p = if ($p) { \"$p\".Trim() } else { '' }\n if (-not $p) {\n $drive = $winPath.Substring(0,1).ToLower()\n $p = \"/mnt/$drive\" + ($winPath.Substring(2) -replace '\\\\','/')\n }\n return $p\n }\n\n $wslRows = @()\n $wslGlobalsText = ''\n $wslUnknown = @()\n $wslContainers = @()\n $wslContainersReported = $false\n $wslAvailable = $false\n if ($WslDistro) {\n $wslExe = Get-Command wsl.exe -ErrorAction SilentlyContinue\n if ($wslExe) {\n # Is the distro registered? (wsl -l -q emits UTF-16 unless WSL_UTF8 set)\n $env:WSL_UTF8 = '1'\n $distros = (& wsl.exe -l -q 2>$null) -split \"`n\" | ForEach-Object { $_.Trim() } | Where-Object { $_ }\n if ($distros -contains $WslDistro) {\n $wslAvailable = $true\n Write-Host \"= Probing WSL:$WslDistro side...\" -ForegroundColor Cyan\n $utf8 = New-Object System.Text.UTF8Encoding($false)\n\n $probeScript = New-ToolchainProbeScript -Catalog $catalog\n\n # WSL transport: the script is written to a real file on disk and run via\n # `bash <path>`, never passed as a `bash -lc <string>` argument -- wsl.exe\n # strips backslash escapes from arguments, per the header comment above.\n # TC-3's SSH transport will pipe `bash -s` on stdin instead.\n $wslTransport = {\n param([string]$ScriptText)\n $shPath = Join-Path $tmpDir 'probe.sh'\n [IO.File]::WriteAllText($shPath, $ScriptText, $utf8)\n $wslSh = ConvertTo-WslPath $shPath\n $raw = (& wsl.exe -d $WslDistro -- bash $wslSh 2>$null | Out-String) -replace \"`r\",''\n Remove-Item $shPath -ErrorAction SilentlyContinue\n return $raw\n }\n\n $raw = Invoke-ToolchainProbe -ScriptText $probeScript -Transport $wslTransport\n $parsed = ConvertFrom-ToolchainProbeOutput -Raw $raw -Catalog $catalog\n $wslRows = $parsed.Rows\n $wslGlobalsText = $parsed.GlobalsText\n $wslUnknown = $parsed.Unknown\n $wslContainers = $parsed.Containers\n $wslContainersReported = $parsed.ContainersReported\n foreach ($r in $wslRows) { Write-Host (\" + {0,-16} {1}\" -f $r.Name, $r.Version) }\n if ($wslUnknown.Count) { Write-Host (\" ~ {0} uncatalogued on PATH\" -f $wslUnknown.Count) -ForegroundColor DarkYellow }\n } else {\n Write-Host \"! WSL distro '$WslDistro' not registered; skipping WSL side.\" -ForegroundColor Yellow\n }\n } else {\n Write-Host \"! wsl.exe not found; skipping WSL side.\" -ForegroundColor Yellow\n }\n }\n\n # ---------------------------------------------------------------------------\n # Build + publish the LOCAL report (Windows + WSL), via the shared functions above.\n # ---------------------------------------------------------------------------\n $now = Get-Date -Format 'yyyy-MM-dd HH:mm'\n $nowIso = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')\n\n $winGlobalsBlocks = @($winGlobals.Keys | ForEach-Object { @{ Label = $_; Text = $winGlobals[$_] } })\n $sides = @(@{ Title = 'Windows'; Side = 'windows'; Rows = $winRows; GlobalsBlocks = $winGlobalsBlocks; Unknown = $winUnknown })\n # The machine goes in the TITLE, not only the _Generated: line -- a reader who opens the\n # rendered page should not have to work out whose toolchain they are looking at, which\n # was a defect even back when only one machine wrote this file (issue #231).\n $wslCountLabel = if ($wslAvailable) { \"WSL:$WslDistro $($wslRows.Count) tools\" } else { 'WSL not probed' }\n if ($wslAvailable) {\n $sides += @{ Title = \"WSL: $WslDistro\"; Side = \"wsl:$WslDistro\"; Rows = $wslRows\n GlobalsBlocks = @(@{ Label = $null; Text = $wslGlobalsText }); Unknown = $wslUnknown }\n }\n $localReport = New-ToolchainReport -HostLabel $hostName `\n -GeneratedOn \"$hostName (editors: $(if($includeEd){'included'}else{'excluded'}))\" -Now $now `\n -SummaryLine \"**Windows:** $($winRows.Count) tools · **$wslCountLabel**\" `\n -HistoryRel \"$ArtifactCategory/$fileName\" -Sides $sides `\n -HostId $machine -Hostname $hostName -Os 'windows' -GeneratedIso $nowIso `\n -Containers $wslContainers -ContainersReported $wslContainersReported\n $mdText = $localReport.Markdown\n\n # Always record the last run time (out-of-repo state, untracked).\n [IO.File]::WriteAllText((Join-Path $stateDir 'last-run.txt'), \"$now on $hostName`n\",\n (New-Object System.Text.UTF8Encoding($false)))\n\n # -OutFile is the local-test path: the report went where the caller asked, NOT into the\n # store -- but the store's copy (named by $rel below) is a different file that may be\n # stale or absent, so rendering here would publish something this run did not write.\n # `$PSBoundParameters` is a bind-time snapshot, so it still answers \"did the caller pass\n # it\" after the store branch earlier in the script assigned $OutFile itself. Indexed\n # rather than .ContainsKey(), so an explicitly EMPTY `-OutFile ''` is falsy here exactly\n # as it is at the two `-not $OutFile` tests above: that run does write to the store, so\n # it is the one that SHOULD render.\n $localSkipReason = if ($PSBoundParameters['OutFile']) {\n '= -OutFile: wrote the report there; skipped the store render/land.'\n } else { '' }\n $localResult = Publish-ToolchainReport -FileName $OutFile -Text $mdText -Sidecar $localReport.Sidecar `\n -Rel \"$ArtifactCategory/$fileName\" -NoRender:$NoRender -SkipRenderReason $localSkipReason\n\n # ---------------------------------------------------------------------------\n # SSH toolchain probing (issue #715, TC-3). Runs regardless of whether the local\n # report changed -- a stable local box must not hide a drifting SSH host, or vice\n # versa, which is exactly why the local branch above no longer returns early.\n # ---------------------------------------------------------------------------\n $sshTargetPairs = ConvertTo-ToolchainSshTargets $SshTargets\n # Every fragment malformed (only reachable from a hand-typed -SshTargets -- the\n # scheduled/registered path always carries config.py-validated values) must not look\n # identical to \"no SSH targets configured\": that would silently probe nothing and\n # still exit 0, hiding a typo behind the one Yellow line ConvertTo-ToolchainSshTargets\n # already printed per skipped fragment. $SshTargets non-empty but $sshTargetPairs\n # empty is the tell.\n $sshTargetsAllMalformed = [bool]$SshTargets -and (@($sshTargetPairs).Count -eq 0)\n if ($sshTargetsAllMalformed) {\n Write-Host \"! -SshTargets was set but every fragment was malformed; nothing was probed.\" -ForegroundColor Red\n }\n $remote = $null\n if (@($sshTargetPairs).Count -gt 0) {\n Write-Host \"= Probing $(@($sshTargetPairs).Count) SSH host(s)...\" -ForegroundColor Cyan\n $sshExe = Resolve-ToolchainSshExe\n # Thin closure over Invoke-ToolchainSshTransport (issue #715 follow-up): the real\n # byte-exact stdin write lives there, driven directly by its own tests against a\n # real local bash; this just supplies the production argv and executable.\n $sshTransport = {\n param([string]$Target, [string]$ScriptText)\n Invoke-ToolchainSshTransport -SshExe $sshExe `\n -Arguments @('-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', $Target, 'bash', '-s') `\n -ScriptText $ScriptText\n }\n $remote = Invoke-RemoteToolchainInventory -Targets $sshTargetPairs -Transport $sshTransport `\n -Now $now -NowIso $nowIso -LocalHost $hostName -NoRender:$NoRender\n if ($remote.Written.Count -gt 0) {\n Write-Host (\"= SSH hosts written: {0}\" -f ($remote.Written -join ', ')) -ForegroundColor Green\n }\n if ($remote.Unchanged.Count -gt 0) {\n Write-Host (\"= SSH hosts unchanged: {0}\" -f ($remote.Unchanged -join ', ')) -ForegroundColor DarkGray\n }\n if ($remote.Failed.Count -gt 0) {\n Write-Host (\"! SSH hosts unreachable: {0}\" -f ($remote.Failed -join ', ')) -ForegroundColor Yellow\n }\n }\n\n Write-Host \"= Done.\" -ForegroundColor Cyan\n # A remote failure (2) outranks a local-only render failure (1, the pre-#715 behaviour\n # of exiting non-zero when build.py fails) -- both are folded in HERE, after SSH\n # probing has already run to completion, never as an early `exit` from inside\n # Publish-ToolchainReport (which would abort probing before it started). The ranking\n # only decides what this run's Last Result shows, not whether work happened. (Exit\n # code 3 was the tools-site page-data push's failure signal, retired along with the\n # push itself -- Rackbops/Tooling#667, E2/C3 -- now that the page reads the store\n # directly; 1 and 2 keep their pre-existing meanings unchanged.)\n $exitCode = 0\n if ($localResult -eq 'render-failed') { $exitCode = 1 }\n if ($sshTargetsAllMalformed) { $exitCode = 2 }\n if ($remote -and $remote.ExitCode -ne 0) { $exitCode = $remote.ExitCode }\n if ($exitCode -ne 0) { exit $exitCode }\n}\n";
|
|
66949
|
+
var scriptText = "<#\n.SYNOPSIS\n Inventory the language toolchains installed on this machine (Windows + WSL)\n into the artifact-console store, where the dashboard shows it and the\n claude-memory-sync git log is its running history.\n\n.DESCRIPTION\n Probes a curated catalog of toolchain executables (language runtimes,\n compilers, package managers, version managers, build tools, VCS / container /\n infra CLIs) on the Windows side and inside each WSL distro, capturing each\n tool's version, official Home + GitHub links, path, and best-effort install\n source. Also dumps globally-installed packages (npm -g, pipx, uv, cargo, gem,\n go). On a real change it writes toolchain-inventory-<machine>.md into the\n artifacts store -- one file per box, since what is installed here is a\n per-machine fact and the store is shared across machines (issue #231) --\n and renders it via build.py, which lands it into the memory repo; a\n no-change run does nothing. The script never writes into its own directory\n (scratch/state go to %TEMP% / %LOCALAPPDATA%), so it is safe to keep under a\n git repo (artifacts-console/artifact-console/data) -- and, since #320, inside the\n installed package.\n\n On a Linux side (WSL or an -SshTargets host) that has docker on PATH and can run\n `docker ps`, also adds a Containers section (issue #721): every RUNNING container's\n name, image reference, image digest, best-effort package list (dpkg/apk/rpm, or\n \"unknown\" when none apply) and catalogued tools found on its own PATH (the same\n catalog pass, run inside the container). Windows hosts never get this section --\n no Docker on the Windows boxes today. Every docker call the probe makes is\n READ-ONLY (`ps`, `inspect`, `exec` running only the package query and the catalog\n pass) -- nothing here pulls, starts, stops, restarts or removes a container, and a\n container that vanishes mid-probe is skipped or reported with unknown\n packages/tools rather than failing the run.\n\n.PARAMETER IncludeEditors\n Also probe editors / IDEs (VS Code, Neovim, Vim, Emacs, Sublime, Helix).\n Off by default. To turn it on permanently, either pass this switch or flip\n $IncludeEditorsDefault below to $true.\n\n.PARAMETER WslDistro\n WSL distro to probe. Defaults to 'Ubuntu'. Pass '' (empty) to skip WSL.\n\n.PARAMETER SourcesDir\n The artifact store root the report is written under. Derived from the console's\n config when omitted -- it used to be a hardcoded `R:\\repos\\claude-memory-sync\\\n artifacts`, correct on one machine and resolving on a second only through a\n junction (issue #231).\n\n.PARAMETER PythonExe\n The console's own python.exe, used for both the store-root lookup and the\n build.py render. Passed by sched_spec.job_argv (sys.executable) and by\n Register-InventoryTask.ps1 (resolved beside pythonw.exe) -- bare `python` on\n PATH is not necessarily the console's interpreter (issue #632), especially\n under pipx. Falls back to `python` on PATH when omitted, so a hand run still\n works.\n\n.PARAMETER OutFile\n Write the report here instead of the store (for local testing). The store\n render/land is skipped when you pass a path -- what would be rendered is the STORE's\n copy of the report, which this run did not write. An empty value is the same as\n omitting it: the report goes to the store, and that run does render.\n\n.PARAMETER SshTargets\n `alias=user@host;alias2=user@host2` -- Linux hosts to probe over key-based SSH\n alongside Windows + WSL (issue #715), one `toolchain-inventory-<alias>.md` per alias.\n Empty (the default) probes no SSH hosts. An unreachable host is isolated: its previous\n report is left untouched, every other target (local, WSL, remaining hosts) still\n publishes, and the process exits 2 so the task's Last Result shows it.\n\n.PARAMETER NoRender\n Write the report but don't render/land it into artifact-console.\n\n.PARAMETER NoSync\n Render into the docroot but don't land into the memory repo (build.py\n --no-sync); the hourly ClaudeMemorySync task still commits it later.\n\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1 -IncludeEditors\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1 -OutFile $env:TEMP\\inv.md # local test, no render\n#>\n[CmdletBinding()]\nparam(\n [switch]$IncludeEditors,\n [string]$WslDistro = 'Ubuntu',\n [string]$SourcesDir = '',\n [string]$PythonExe = '',\n [string]$OutFile,\n [string]$SshTargets = '',\n [switch]$NoRender,\n [switch]$NoSync\n)\n\n# ---------------------------------------------------------------------------\n# Editable defaults -- change these instead of the code below.\n# Flip $IncludeEditorsDefault to $true to always include editors/IDEs.\n# ---------------------------------------------------------------------------\n$IncludeEditorsDefault = $false\n\n# The report's home is the artifact-console store. On a change the script writes its\n# report there and renders it via build.py, which also lands it in the claude-memory-sync\n# repo, whose git log IS the history.\n#\n# This payload ships INSIDE the console (artifact-console/data/), so the console dir --\n# where build.py and scheduled_tasks.py live -- is simply this script's PARENT. #320 (PR\n# #330) moved the file here from the repo's PowerShell/ drawer as a pure rename (R100),\n# leaving the drawer-era `Join-Path (Split-Path $PSScriptRoot -Parent) 'artifact-console'`\n# resolving to a doubled artifact-console/artifact-console that has never existed. The\n# render guard below tests that path, so its condition became permanently TRUE and the\n# render branch unreachable: a run with something to publish wrote its report to the store,\n# printed a yellow skip and exited 0. (A no-change run returns earlier still and is not\n# affected.) So the .md kept reaching the store, but nothing rendered it into the docroot\n# and nothing recorded it for landing -- sync_memory stages only the paths build.py records,\n# never a blanket add -- so no automatic `chore(artifacts): sync Toolchain-Inventory/...`\n# commit has landed since, every later touch made by hand. (The last one is 2026-07-20, which\n# is an outer bound rather than a fingerprint: #231's per-machine rename sits between it and\n# this move.) The registrar's half of the\n# same rename was caught in #334; this was the other half, and it sat unnoticed for a month\n# because the skip message named neither the path nor which half was missing.\n#\n# The parent is right in a WHEEL too, and not by luck: data/ installs as the\n# `artifact_console_data` package while the modules install FLAT beside it, so\n# site-packages/artifact_console_data/.. is site-packages/, which holds build.py.\n#\n# The store ROOT is not spelled out here any more (issue #231). It was\n# `R:\\repos\\claude-memory-sync\\artifacts`: right on the box it was written on, and on a\n# second machine right only by way of a junction. Register-InventoryTask.ps1 passes the\n# config-derived value; a hand run asks the console for the same one.\n$ArtifactConsoleDir = Split-Path $PSScriptRoot -Parent\n$ArtifactCategory = 'Toolchain-Inventory'\n\n$ErrorActionPreference = 'Stop'\n$includeEd = $IncludeEditors.IsPresent -or $IncludeEditorsDefault\n\n# The interpreter for BOTH Python call sites below. `python` on PATH is not\n# necessarily the console's own interpreter -- under pipx it never is (#632) --\n# so a caller that knows the right one (sched_spec.job_argv, the registrar) passes\n# it explicitly. PATH is only the fallback, for a hand run with nothing supplied.\n$pyExe = if ($PythonExe) { $PythonExe } else { 'python' }\n\n# Needed before the output path is chosen, not just in the report body: this report\n# describes ONE machine, so the machine is part of its filename.\n$hostName = [System.Net.Dns]::GetHostName()\n\nif (-not $SourcesDir -and -not $OutFile) {\n $jobPaths = Join-Path $ArtifactConsoleDir 'scheduled_tasks.py'\n try {\n # Same native-exit trap as the render below: a python that RUNS and exits non-zero\n # does not throw, so without this the catch could never fire on the commonest\n # failures -- a traceback, an unconfigured store, or the Windows App Execution\n # Alias stub, which is on PATH and exits non-zero rather than being absent.\n # ConvertFrom-Json would just yield nothing, $SourcesDir would end up empty, and\n # the reason would never be printed. (A genuinely missing command is the one case\n # that always threw: CommandNotFoundException, which this catch already handled.)\n $jobPathsJson = & $pyExe $jobPaths --job-paths toolchain-inventory\n if ($LASTEXITCODE -ne 0) { throw \"scheduled_tasks.py --job-paths exited $LASTEXITCODE\" }\n $SourcesDir = ($jobPathsJson | ConvertFrom-Json).args.SourcesDir\n } catch {\n Write-Host \"! could not ask the console for the store root: $($_.Exception.Message)\" -ForegroundColor Yellow\n }\n}\n\n$ArtifactSourcesDir = $SourcesDir\n\n# This script lives in a git repo (artifacts-console) -- and, since #320, ships inside the\n# installed package, where writing would be worse still -- so it must NEVER write into its\n# own directory. Scratch (probe files) and state (last-run) go to out-of-repo temp /\n# local-appdata; the report itself goes to the artifacts store.\n$tmpDir = Join-Path ([IO.Path]::GetTempPath()) 'toolchain-inventory'\n$stateDir = Join-Path $env:LOCALAPPDATA 'toolchain-inventory'\nNew-Item -ItemType Directory -Force -Path $tmpDir, $stateDir | Out-Null\n\n# One file per machine. This report is a per-machine fact -- what is installed on THIS\n# box -- and it used to be written under a single machine-agnostic name into a git-synced\n# store shared by every machine. With the weekly task registered on two boxes, each run\n# overwrote the other's and the category became a flip-flop: every run a large diff\n# reverting the previous one, so the git log that is supposed to BE the history recorded\n# no machine's toolchain drift legibly (issue #231). Keyed by hostname, each machine owns\n# one file, diffs stay meaningful, and no coordination between boxes is needed.\n$machine = ($hostName -replace '[^A-Za-z0-9._-]', '-')\n$fileName = \"toolchain-inventory-$machine.md\"\n\n$storeDir = if ($ArtifactSourcesDir) { Join-Path $ArtifactSourcesDir $ArtifactCategory } else { '' }\n$storeAvailable = [bool]$ArtifactSourcesDir -and (Test-Path $ArtifactSourcesDir)\nif (-not $OutFile) {\n if ($storeAvailable) {\n New-Item -ItemType Directory -Force -Path $storeDir | Out-Null\n $OutFile = Join-Path $storeDir $fileName\n } else {\n $OutFile = Join-Path $stateDir $fileName\n Write-Host \"! artifacts store not found; writing locally to $OutFile\" -ForegroundColor Yellow\n }\n}\n\n# ---------------------------------------------------------------------------\n# Tool catalog. One row per tool:\n# Category | Name | Exe | Ver (arg array) | Alt (fallback arg array or $null)\n# Add a row here to track a new tool -- it is picked up on both Windows and WSL.\n# ---------------------------------------------------------------------------\n$catalog = @(\n # --- Language runtimes & compilers ---\n @{ Cat='Languages'; Name='Node.js'; Exe='node'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Deno'; Exe='deno'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Bun'; Exe='bun'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Python'; Exe='python'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Python3'; Exe='python3'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Py launcher'; Exe='py'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Go'; Exe='go'; Ver=@('version') }\n @{ Cat='Languages'; Name='Rust (rustc)';Exe='rustc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Java'; Exe='java'; Ver=@('-version') }\n @{ Cat='Languages'; Name='javac (JDK)'; Exe='javac'; Ver=@('-version') }\n @{ Cat='Languages'; Name='.NET SDK'; Exe='dotnet'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Ruby'; Exe='ruby'; Ver=@('--version') }\n @{ Cat='Languages'; Name='PHP'; Exe='php'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Lua'; Exe='lua'; Ver=@('-v') }\n @{ Cat='Languages'; Name='LuaJIT'; Exe='luajit'; Ver=@('-v') }\n @{ Cat='Languages'; Name='Perl'; Exe='perl'; Ver=@('--version') }\n @{ Cat='Languages'; Name='GCC'; Exe='gcc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='G++'; Exe='g++'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Clang'; Exe='clang'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Zig'; Exe='zig'; Ver=@('version') }\n @{ Cat='Languages'; Name='Kotlin'; Exe='kotlin'; Ver=@('-version') }\n @{ Cat='Languages'; Name='Scala'; Exe='scala'; Ver=@('-version') }\n @{ Cat='Languages'; Name='Swift'; Exe='swift'; Ver=@('--version') }\n @{ Cat='Languages'; Name='GHC (Haskell)';Exe='ghc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Elixir'; Exe='elixir'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Julia'; Exe='julia'; Ver=@('--version') }\n @{ Cat='Languages'; Name='R'; Exe='R'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Dart'; Exe='dart'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Nim'; Exe='nim'; Ver=@('--version') }\n @{ Cat='Languages'; Name='OCaml'; Exe='ocaml'; Ver=@('-version') }\n\n # --- Package managers ---\n @{ Cat='Package managers'; Name='npm'; Exe='npm'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Yarn'; Exe='yarn'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pnpm'; Exe='pnpm'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pip'; Exe='pip'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pipx'; Exe='pipx'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Poetry'; Exe='poetry'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='uv'; Exe='uv'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Conda'; Exe='conda'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Cargo'; Exe='cargo'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Gem'; Exe='gem'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Bundler'; Exe='bundle'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Composer'; Exe='composer'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Maven'; Exe='mvn'; Ver=@('-version') }\n @{ Cat='Package managers'; Name='Gradle'; Exe='gradle'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='LuaRocks'; Exe='luarocks'; Ver=@('--version') }\n\n # --- Version managers ---\n @{ Cat='Version managers'; Name='nvm'; Exe='nvm'; Ver=@('--version'); Alt=@('version') }\n @{ Cat='Version managers'; Name='fnm'; Exe='fnm'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='Volta'; Exe='volta'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='pyenv'; Exe='pyenv'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='rustup'; Exe='rustup'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='rbenv'; Exe='rbenv'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='asdf'; Exe='asdf'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='SDKMAN'; Exe='sdk'; Ver=@('version') }\n\n # --- Build tools & runners ---\n @{ Cat='Build tools'; Name='Make'; Exe='make'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='CMake'; Exe='cmake'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Ninja'; Exe='ninja'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Meson'; Exe='meson'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Bazel'; Exe='bazel'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='just'; Exe='just'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='MSBuild'; Exe='msbuild'; Ver=@('-version') }\n @{ Cat='Build tools'; Name='Ant'; Exe='ant'; Ver=@('-version') }\n\n # --- VCS, containers & infra ---\n @{ Cat='VCS & infra'; Name='Git'; Exe='git'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='GitHub CLI'; Exe='gh'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Docker'; Exe='docker'; Ver=@('--version') }\n # Claude Code CLI -- added for Rackbops/artifact-console#673 (fleet environment consistency,\n # epic Rackbops/artifact-console#676): the fleet standard keys tools by their exact Name here\n # (Rackbops/Tooling#877 adds this one), and the 2.0 Agents panel's Environment row is labelled\n # the same. `claude --version` prints one line, `<semver> (Claude Code)`.\n @{ Cat='VCS & infra'; Name='Claude Code CLI'; Exe='claude'; Ver=@('--version') }\n # Cloud CLIs -- added for Rackbops/Tooling#1307 (fleet toolchain inventory and standard). The\n # standard keys tools by their exact Name here (Rackbops/Tooling#1308 adds these three). The\n # version is the first number on the first non-empty line of the captured `--version` text, and\n # stderr is merged into that text, so a notice a CLI writes there first would win. The CLI's own\n # line is `azure-cli` padded to 25 columns and then the version (` *` ends it when an update\n # exists; core/telemetry/extension lines follow), `Google Cloud SDK <ver>` (component lines\n # follow; the shape third-party guides show, Google documents the flag but no output), and\n # `aws-cli/<ver> Python/<ver> <os>/<release> ...` (one line).\n @{ Cat='VCS & infra'; Name='Azure CLI'; Exe='az'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Google Cloud CLI'; Exe='gcloud'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='AWS CLI'; Exe='aws'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Docker Compose'; Exe='docker-compose'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Podman'; Exe='podman'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='kubectl'; Exe='kubectl'; Ver=@('version','--client') }\n @{ Cat='VCS & infra'; Name='Helm'; Exe='helm'; Ver=@('version','--short') }\n @{ Cat='VCS & infra'; Name='Terraform'; Exe='terraform'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='OpenTofu'; Exe='tofu'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Ansible'; Exe='ansible'; Ver=@('--version') }\n # cloudflared -- added for #715: nitro/nucbox both front their consoles through a\n # Cloudflare Tunnel, and the epic's exit criterion names it explicitly.\n @{ Cat='VCS & infra'; Name='cloudflared'; Exe='cloudflared'; Ver=@('--version') }\n)\n\n$editorCatalog = @(\n @{ Cat='Editors'; Name='VS Code'; Exe='code'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Neovim'; Exe='nvim'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Vim'; Exe='vim'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Emacs'; Exe='emacs'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Sublime'; Exe='subl'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Helix'; Exe='hx'; Ver=@('--version') }\n)\nif ($includeEd) { $catalog += $editorCatalog }\n\n# Fixed category display order.\n$catOrder = @('Languages','Package managers','Version managers','Build tools','VCS & infra','Editors')\n\n# ---------------------------------------------------------------------------\n# Reference links per tool (keyed by catalog Name): official Home + GitHub repo\n# (owner/repo; URL derived). Blank where none is canonical. Add a tool here when\n# you add it to $catalog. Rendered as the Home / GitHub columns.\n# ---------------------------------------------------------------------------\n$refLinks = @{\n # Languages\n 'Node.js' = @{ Home='https://nodejs.org'; Repo='nodejs/node' }\n 'Deno' = @{ Home='https://deno.com'; Repo='denoland/deno' }\n 'Bun' = @{ Home='https://bun.sh'; Repo='oven-sh/bun' }\n 'Python' = @{ Home='https://python.org'; Repo='python/cpython' }\n 'Python3' = @{ Home='https://python.org'; Repo='python/cpython' }\n 'Py launcher' = @{ Home='https://docs.python.org/using/windows.html'; Repo='python/cpython' }\n 'Go' = @{ Home='https://go.dev'; Repo='golang/go' }\n 'Rust (rustc)' = @{ Home='https://rust-lang.org'; Repo='rust-lang/rust' }\n 'Java' = @{ Home='https://openjdk.org'; Repo='openjdk/jdk' }\n 'javac (JDK)' = @{ Home='https://openjdk.org'; Repo='openjdk/jdk' }\n '.NET SDK' = @{ Home='https://dotnet.microsoft.com'; Repo='dotnet/sdk' }\n 'Ruby' = @{ Home='https://ruby-lang.org'; Repo='ruby/ruby' }\n 'PHP' = @{ Home='https://php.net'; Repo='php/php-src' }\n 'Lua' = @{ Home='https://lua.org'; Repo='lua/lua' }\n 'LuaJIT' = @{ Home='https://luajit.org'; Repo='LuaJIT/LuaJIT' }\n 'Perl' = @{ Home='https://perl.org'; Repo='Perl/perl5' }\n 'GCC' = @{ Home='https://gcc.gnu.org'; Repo='gcc-mirror/gcc' }\n 'G++' = @{ Home='https://gcc.gnu.org'; Repo='gcc-mirror/gcc' }\n 'Clang' = @{ Home='https://clang.llvm.org'; Repo='llvm/llvm-project' }\n 'Zig' = @{ Home='https://ziglang.org'; Repo='ziglang/zig' }\n 'Kotlin' = @{ Home='https://kotlinlang.org'; Repo='JetBrains/kotlin' }\n 'Scala' = @{ Home='https://scala-lang.org'; Repo='scala/scala' }\n 'Swift' = @{ Home='https://swift.org'; Repo='apple/swift' }\n 'GHC (Haskell)' = @{ Home='https://haskell.org'; Repo='' }\n 'Elixir' = @{ Home='https://elixir-lang.org'; Repo='elixir-lang/elixir' }\n 'Julia' = @{ Home='https://julialang.org'; Repo='JuliaLang/julia' }\n 'R' = @{ Home='https://r-project.org'; Repo='' }\n 'Dart' = @{ Home='https://dart.dev'; Repo='dart-lang/sdk' }\n 'Nim' = @{ Home='https://nim-lang.org'; Repo='nim-lang/Nim' }\n 'OCaml' = @{ Home='https://ocaml.org'; Repo='ocaml/ocaml' }\n # Package managers\n 'npm' = @{ Home='https://npmjs.com'; Repo='npm/cli' }\n 'Yarn' = @{ Home='https://yarnpkg.com'; Repo='yarnpkg/berry' }\n 'pnpm' = @{ Home='https://pnpm.io'; Repo='pnpm/pnpm' }\n 'pip' = @{ Home='https://pip.pypa.io'; Repo='pypa/pip' }\n 'pipx' = @{ Home='https://pipx.pypa.io'; Repo='pypa/pipx' }\n 'Poetry' = @{ Home='https://python-poetry.org'; Repo='python-poetry/poetry' }\n 'uv' = @{ Home='https://docs.astral.sh/uv'; Repo='astral-sh/uv' }\n 'Conda' = @{ Home='https://conda.io'; Repo='conda/conda' }\n 'Cargo' = @{ Home='https://doc.rust-lang.org/cargo'; Repo='rust-lang/cargo' }\n 'Gem' = @{ Home='https://rubygems.org'; Repo='rubygems/rubygems' }\n 'Bundler' = @{ Home='https://bundler.io'; Repo='rubygems/rubygems' }\n 'Composer' = @{ Home='https://getcomposer.org'; Repo='composer/composer' }\n 'Maven' = @{ Home='https://maven.apache.org'; Repo='apache/maven' }\n 'Gradle' = @{ Home='https://gradle.org'; Repo='gradle/gradle' }\n 'LuaRocks' = @{ Home='https://luarocks.org'; Repo='luarocks/luarocks' }\n # Version managers\n 'nvm' = @{ Home=''; Repo='nvm-sh/nvm' }\n 'fnm' = @{ Home=''; Repo='Schniz/fnm' }\n 'Volta' = @{ Home='https://volta.sh'; Repo='volta-cli/volta' }\n 'pyenv' = @{ Home=''; Repo='pyenv/pyenv' }\n 'rustup' = @{ Home='https://rustup.rs'; Repo='rust-lang/rustup' }\n 'rbenv' = @{ Home=''; Repo='rbenv/rbenv' }\n 'asdf' = @{ Home='https://asdf-vm.com'; Repo='asdf-vm/asdf' }\n 'SDKMAN' = @{ Home='https://sdkman.io'; Repo='sdkman/sdkman-cli' }\n # Build tools\n 'Make' = @{ Home='https://gnu.org/software/make'; Repo='' }\n 'CMake' = @{ Home='https://cmake.org'; Repo='Kitware/CMake' }\n 'Ninja' = @{ Home='https://ninja-build.org'; Repo='ninja-build/ninja' }\n 'Meson' = @{ Home='https://mesonbuild.com'; Repo='mesonbuild/meson' }\n 'Bazel' = @{ Home='https://bazel.build'; Repo='bazelbuild/bazel' }\n 'just' = @{ Home='https://just.systems'; Repo='casey/just' }\n 'MSBuild' = @{ Home='https://learn.microsoft.com/visualstudio/msbuild/msbuild'; Repo='dotnet/msbuild' }\n 'Ant' = @{ Home='https://ant.apache.org'; Repo='apache/ant' }\n # VCS & infra\n 'Git' = @{ Home='https://git-scm.com'; Repo='git/git' }\n 'GitHub CLI' = @{ Home='https://cli.github.com'; Repo='cli/cli' }\n 'Docker' = @{ Home='https://docker.com'; Repo='docker/cli' }\n 'Claude Code CLI' = @{ Home='https://code.claude.com/docs'; Repo='anthropics/claude-code' }\n 'Azure CLI' = @{ Home='https://learn.microsoft.com/cli/azure'; Repo='Azure/azure-cli' }\n 'Google Cloud CLI' = @{ Home='https://cloud.google.com/sdk'; Repo='' }\n 'AWS CLI' = @{ Home='https://aws.amazon.com/cli'; Repo='aws/aws-cli' }\n 'Docker Compose' = @{ Home='https://docs.docker.com/compose'; Repo='docker/compose' }\n 'Podman' = @{ Home='https://podman.io'; Repo='containers/podman' }\n 'kubectl' = @{ Home='https://kubernetes.io'; Repo='kubernetes/kubernetes' }\n 'Helm' = @{ Home='https://helm.sh'; Repo='helm/helm' }\n 'Terraform' = @{ Home='https://terraform.io'; Repo='hashicorp/terraform' }\n 'OpenTofu' = @{ Home='https://opentofu.org'; Repo='opentofu/opentofu' }\n 'Ansible' = @{ Home='https://ansible.com'; Repo='ansible/ansible' }\n 'cloudflared' = @{ Home='https://developers.cloudflare.com/cloudflared'; Repo='cloudflare/cloudflared' }\n # Editors\n 'VS Code' = @{ Home='https://code.visualstudio.com'; Repo='microsoft/vscode' }\n 'Neovim' = @{ Home='https://neovim.io'; Repo='neovim/neovim' }\n 'Vim' = @{ Home='https://vim.org'; Repo='vim/vim' }\n 'Emacs' = @{ Home='https://gnu.org/software/emacs'; Repo='' }\n 'Sublime' = @{ Home='https://sublimetext.com'; Repo='' }\n 'Helix' = @{ Home='https://helix-editor.com'; Repo='helix-editor/helix' }\n}\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\nfunction Get-CleanVersion([string]$raw) {\n if (-not $raw) { return '' }\n $line = ($raw -split \"`n\" | Where-Object { $_.Trim() -ne '' } | Select-Object -First 1)\n if (-not $line) { return '' }\n $line = $line.Trim()\n $m = [regex]::Match($line, '\\d+\\.\\d+(\\.\\d+){0,3}')\n if ($m.Success) { return $m.Value }\n if ($line.Length -gt 48) { return $line.Substring(0,48) }\n return $line\n}\n\nfunction Get-WinSource([string]$path) {\n if (-not $path) { return '' }\n $p = $path.ToLower()\n if ($p -like '*\\scoop\\*') { return 'scoop' }\n if ($p -like '*\\chocolatey\\*') { return 'choco' }\n if ($p -like '*\\winget*' -or\n $p -like '*\\windowsapps\\*') { return 'winget/store' }\n if ($p -like '*\\.cargo\\*') { return 'cargo' }\n if ($p -like '*\\nvm*') { return 'nvm' }\n if ($p -like '*\\program files*') { return 'installer' }\n if ($p -like '*\\appdata\\local\\programs\\*') { return 'user-install' }\n return ''\n}\n\nfunction Get-WslSource([string]$path) {\n if (-not $path) { return '' }\n if ($path -like '*/.cargo/*') { return 'cargo' }\n if ($path -like '*/.local/*') { return 'pip/pipx' }\n if ($path -like '*/.nvm/*') { return 'nvm' }\n if ($path -like '*/snap/*') { return 'snap' }\n if ($path -like '/usr/*' -or $path -like '/bin/*') { return 'apt/system' }\n return ''\n}\n\n# Probe a single tool on the Windows side. Returns a result hashtable or $null.\nfunction Get-WinToolInfo($tool) {\n # Only real executables / scripts -- never PS aliases, functions, or cmdlets\n # (e.g. `R` is the built-in alias for Invoke-History, not the R language).\n $cmd = Get-Command $tool.Exe -CommandType Application,ExternalScript -ErrorAction SilentlyContinue |\n Select-Object -First 1\n if (-not $cmd) { return $null }\n $path = if ($cmd.Source) { $cmd.Source } else { $cmd.Name }\n $raw = ''\n try { $raw = (& $tool.Exe @($tool.Ver) 2>&1 | Out-String) } catch { $raw = '' }\n $ver = Get-CleanVersion $raw\n if (-not $ver -and $tool.Alt) {\n try { $raw = (& $tool.Exe @($tool.Alt) 2>&1 | Out-String) } catch { $raw = '' }\n $ver = Get-CleanVersion $raw\n }\n return @{ Cat=$tool.Cat; Name=$tool.Name; Version=$ver; Path=$path; Source=(Get-WinSource $path) }\n}\n\nfunction Format-HomeCell($homeUrl) {\n if (-not $homeUrl) { return '-' }\n $label = ($homeUrl -replace '^https?://','') -replace '/$',''\n return \"[$label]($homeUrl)\"\n}\nfunction Format-RepoCell($repo) {\n if (-not $repo) { return '-' }\n return \"[$repo](https://github.com/$repo)\"\n}\nfunction Format-Table-Md($rows) {\n if (-not $rows -or $rows.Count -eq 0) { return \"_none found_`n\" }\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('| Tool | Version | Home | GitHub | Source | Path |')\n [void]$sb.AppendLine('|------|---------|------|--------|--------|------|')\n foreach ($r in ($rows | Sort-Object Name)) {\n $ver = if ($r.Version) { $r.Version } else { '(present)' }\n $src = if ($r.Source) { $r.Source } else { '-' }\n $ref = $refLinks[$r.Name]\n $homeCell = Format-HomeCell $ref.Home\n $ghCell = Format-RepoCell $ref.Repo\n [void]$sb.AppendLine(\"| $($r.Name) | $ver | $homeCell | $ghCell | $src | ``$($r.Path)`` |\")\n }\n return $sb.ToString()\n}\n\nfunction Format-SideSection($rows) {\n $sb = New-Object System.Text.StringBuilder\n foreach ($cat in $catOrder) {\n $catRows = $rows | Where-Object { $_.Cat -eq $cat }\n if (-not $catRows) { continue }\n [void]$sb.AppendLine(\"### $cat`n\")\n [void]$sb.AppendLine((Format-Table-Md $catRows))\n }\n return $sb.ToString()\n}\n\n# ---------------------------------------------------------------------------\n# \"Unrecognized dev tool on PATH\" heads-up. Scans the dirs where dev package\n# managers drop user-installed CLIs (cargo/go/dotnet/npm/scoop/.local) and flags\n# any executable whose name isn't a catalogued tool or a known companion binary\n# of one. High-signal by construction: it never looks in system dirs, so a hit\n# is almost always a real tool you installed but haven't catalogued yet.\n# ---------------------------------------------------------------------------\n$knownExe = @{}\nforeach ($t in ($catalog + $editorCatalog)) { $knownExe[$t.Exe.ToLower()] = $true }\n\n# Auxiliary binaries that ship WITH a catalogued tool -- not \"new tools\".\n$companionExe = @{}\nforeach ($n in @(\n 'rustfmt','rustdoc','clippy-driver','cargo-clippy','cargo-fmt','cargo-miri',\n 'rust-gdb','rust-gdbgui','rust-lldb','rust-analyzer',\n 'gofmt','godoc','npx','pnpx','corepack','yarnpkg',\n 'uvx','uvw','rls',\n 'pydoc','2to3','idle','wheel','activate','aws_completer',\n 'php-cgi','phpdbg','erl','epmd','escript','iex','mix','rebar3',\n 'bundler','rdoc','ri'\n)) { $companionExe[$n] = $true }\n\nfunction Test-KnownTool([string]$name) {\n $n = $name.ToLower()\n if ($knownExe.ContainsKey($n) -or $companionExe.ContainsKey($n)) { return $true }\n # version-suffixed interpreters/tools: python3.12, pip3, ruby3.3, perl5.40, node18, php8.3\n if ($n -match '^(python|pip|ruby|perl|php|node|lua|luajit|clang|gcc|dotnet)w?[-.]?[0-9][0-9.]*$') { return $true }\n return $false\n}\n\nfunction Get-UncataloguedWin {\n $dirs = @(\n (Join-Path $env:USERPROFILE '.cargo\\bin')\n (Join-Path $env:USERPROFILE '.dotnet\\tools')\n (Join-Path $env:USERPROFILE '.local\\bin')\n (Join-Path $env:APPDATA 'npm')\n (Join-Path $env:USERPROFILE 'scoop\\shims')\n )\n if (Get-Command go -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n $gb = if ($env:GOBIN) { $env:GOBIN } else { Join-Path ((& go env GOPATH 2>$null).Trim()) 'bin' }\n if ($gb) { $dirs += $gb }\n } catch { Write-Verbose \"go env GOPATH failed; leaving the Go bin dir out of the scan.\" }\n }\n $seen = @{}; $out = @()\n foreach ($d in ($dirs | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique)) {\n Get-ChildItem $d -File -ErrorAction SilentlyContinue |\n Where-Object { $_.Extension -in '.exe','.cmd','.bat','.ps1' } |\n ForEach-Object {\n $base = [IO.Path]::GetFileNameWithoutExtension($_.Name)\n $bl = $base.ToLower()\n if ($base -and -not (Test-KnownTool $base) -and -not $seen.ContainsKey($bl)) {\n $seen[$bl] = $true\n $out += @{ Name = $base; Path = $_.FullName }\n }\n }\n }\n return $out\n}\n\nfunction Format-Unknown-Md($items) {\n $items = @($items)\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('### Uncatalogued on PATH')\n [void]$sb.AppendLine('')\n [void]$sb.AppendLine('_Dev-manager binaries not in the catalog. Add a `$catalog` row (+ `$refLinks`) to track one._')\n [void]$sb.AppendLine('')\n [void]$sb.AppendLine('| Tool | Path |')\n [void]$sb.AppendLine('|------|------|')\n $cap = 60; $i = 0\n foreach ($u in ($items | Sort-Object { $_.Name })) {\n if ($i -ge $cap) { break }\n [void]$sb.AppendLine(\"| $($u.Name) | ``$($u.Path)`` |\")\n $i++\n }\n if ($items.Count -gt $cap) { [void]$sb.AppendLine(\"`n_+ $($items.Count - $cap) more._\") }\n return $sb.ToString()\n}\n\n# ---------------------------------------------------------------------------\n# Host-agnostic bash probe (issue #714, TC-2). The probe script makes no\n# assumption about how it gets run -- WSL today (`wsl.exe -d <distro> -- bash\n# <file>`), SSH next (TC-3) -- so \"build the script\", \"run it via some\n# transport\" and \"parse its output\" are three separable functions, each\n# testable without wsl.exe or a registered distro.\n# ---------------------------------------------------------------------------\n\nfunction New-ToolchainProbeScript {\n <#\n Builds the probe script TEXT (LF line endings) with the tool catalog embedded as a\n quoted heredoc, so the whole probe is one self-contained file any transport can run --\n a temp file for WSL, stdin for SSH -- instead of reading a second file only the same\n host can see.\n #>\n param([Parameter(Mandatory)][array]$Catalog)\n\n $tsvLines = $(foreach ($t in $Catalog) { \"{0}`t{1}`t{2}\" -f $t.Name, $t.Exe, ($t.Ver -join ' ') })\n $tsv = ($tsvLines -join \"`n\")\n\n # The catalog-pass body (issue #721 decision 2): reused VERBATIM in two places below --\n # once at top level for this host/side, and again (unchanged text) fed into each running\n # container via `docker exec -i <id> bash -s` in the Containers block -- reuse, not a\n # second catalog. A function, not inline, precisely so the SAME text can be dropped in\n # both places via one `.Replace()` below. `known` is populated as a side effect (every\n # catalog Exe name, found or not) for the uncatalogued-on-PATH scan further down the HOST\n # script to reuse; it must already be `declare -A`'d by the CALLER -- a `declare` INSIDE\n # this function would make it local and the later scan would see an empty array.\n $catalogPassFn = @'\ncatalog_pass() {\n while IFS=$'\\t' read -r name exe verargs; do\n [ -z \"$exe\" ] && continue\n known[\"$(printf '%s' \"$exe\" | tr 'A-Z' 'a-z')\"]=1\n loc=$(command -v \"$exe\" 2>/dev/null) || continue\n case \"$loc\" in \"${TOOLCHAIN_PROBE_INTEROP_PREFIX:-/mnt/}\"*) continue;; esac # skip Windows tools seen via interop\n ver=$($exe $verargs 2>&1 | grep -m1 . | tr -d '\\r')\n printf '%s\\t%s\\t%s\\n' \"$name\" \"$ver\" \"$loc\"\n done\n}\n'@\n $catalogPassFn = ($catalogPassFn -replace \"`r`n\", \"`n\").Trim(\"`n\")\n\n $probeSh = @'\n#!/usr/bin/env bash\ninterop=\"${TOOLCHAIN_PROBE_INTEROP_PREFIX:-/mnt/}\"\n__CATALOG_PASS_FN__\ndeclare -A known\ncatalog_pass <<'__CATALOG__'\n__TSV__\n__CATALOG__\necho \"===GLOBALS===\"\nrun(){ 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; }\nrun npm \"npm -g\" npm ls -g --depth=0\nrun pipx \"pipx\" pipx list --short\nrun uv \"uv tool\" uv tool list\nrun cargo \"cargo install\" cargo install --list\nrun gem \"gem (user)\" gem list --local\nif 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\necho \"===UNKNOWN===\"\nfor 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 aws_completer; do known[$c]=1; done\ngb=\"\"; 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\nfor d in \"$HOME/.cargo/bin\" \"$gb\" \"$HOME/.local/bin\" \"$HOME/.dotnet/tools\" /usr/local/bin; do\n [ -d \"$d\" ] || continue\n for f in \"$d\"/*; do\n [ -f \"$f\" ] && [ -x \"$f\" ] || continue\n b=$(basename \"$f\"); bl=$(printf '%s' \"$b\" | tr 'A-Z' 'a-z')\n case \"$bl\" in python[0-9]*|pip[0-9]*|ruby[0-9]*|perl[0-9]*|php[0-9]*|node[0-9]*|*.ps1) continue;; esac\n [ -n \"${known[$bl]}\" ] && continue\n printf '%s\\t%s\\n' \"$b\" \"$f\"\n done\ndone | sort -u\n\n# Containers (issue #721): a running container's package list + catalogued tools, on a\n# Linux side only -- Windows never has docker on PATH here, so this whole block is inert\n# there. Emitted ONLY when docker is on PATH and `docker ps` itself succeeds, so an\n# absent/broken docker leaves NO ===CONTAINERS=== marker at all (decision 1: the sidecar\n# then carries no `containers` key) -- distinct from docker present with zero running\n# containers, which DOES print the marker with zero ===CONTAINER=== blocks (sidecar:\n# `containers: []`). Every docker verb below is read-only (ps, inspect, exec) -- decision\n# 4 -- and each container's failure is isolated from every other's (decision 3): `docker\n# inspect` failing (the container vanished between `ps` and here) skips that container\n# entirely, since decision 6 pins `imageId` as ALWAYS present and sha256-shaped, unlike\n# packages/tools, which have an explicit 'unknown' escape hatch.\nif command -v docker >/dev/null 2>&1 && docker ps >/dev/null 2>&1; then\n echo \"===CONTAINERS===\"\n # Process substitution (`< <(...)`), never `docker ps | while read`: this loop's body\n # feeds heredocs to nested `docker exec -i ... bash -s` calls below, and a `| while`\n # would redirect the LOOP's own stdin to the `docker ps` pipe -- ambiguous with heredoc\n # parsing. Process substitution never touches this script's own stdin, so the nested\n # heredocs (parsed once, at script-parse time, from the ORIGINAL stdin this whole probe\n # arrived on) stay unambiguous no matter how many containers the loop iterates over.\n mapfile -t __rb_containers < <(docker ps --no-trunc --format '{{.ID}}'$'\\t''{{.Names}}'$'\\t''{{.Image}}' 2>/dev/null)\n for __rb_line in \"${__rb_containers[@]}\"; do\n IFS=$'\\t' read -r cid cname cimage <<< \"$__rb_line\"\n [ -z \"$cid\" ] && continue\n image_id=$(docker inspect -f '{{.Image}}' \"$cid\" 2>/dev/null)\n [ -z \"$image_id\" ] && continue # vanished between ps and inspect: skip, not an error\n echo \"===CONTAINER===\"\n printf '%s\\t%s\\t%s\\n' \"$cname\" \"$cimage\" \"$image_id\"\n\n # Packages: ONE exec, a three-way query with an exit-code sentinel for \"none of the\n # three\" (decision 2). The chosen manager echoes a __PM_*__ marker as its own FIRST\n # line before its listing, so the caller (below, on the HOST -- never inside the\n # container) knows which format the rows are in without a second exec. dpkg-query's\n # and rpm's own format strings already emit name<TAB>version; apk's `-v` output\n # (`name-ver-rN`) is parsed into the same two-column shape OUTSIDE the container, on\n # the host, by the sed pass below -- no sed dependency inside the target image. `\\$`\n # (not `$`) before dpkg's `${Package}`/`${Version}` placeholders: this whole query is\n # single-quoted at the bash level (so THIS script's own shell never touches it), but\n # `sh -c` still double-quote-parses its one argument, where a bare `$` would try (and\n # fail) to expand an unset shell variable -- `\\$` survives that parse as a literal `$`\n # for dpkg-query itself to interpret. `\\t`/`\\n` are not recognised escapes inside\n # POSIX double quotes, so they reach dpkg-query/rpm untouched, exactly as their own\n # format-string escapes expect.\n #\n # `timeout 20` (round-1 review): a hung package manager inside ONE container (a wedged\n # dpkg lock, a stuck apk network call despite -no-network commands being used here)\n # would otherwise hang this exec indefinitely -- and with it the WHOLE remote probe,\n # every OTHER container and every OTHER SSH target queued after it, since nothing\n # upstream of this call bounds it. `timeout`'s own 124 exit on expiry is just another\n # non-zero $pkg_rc to the existing check below -- no new branch, same 'unknown' outcome\n # a real exec failure already gets.\n pkg_out=$(timeout 20 docker exec \"$cid\" sh -c '\n if command -v dpkg-query >/dev/null 2>&1; then\n echo \"__PM_DPKG__\"\n dpkg-query -W -f=\"\\${Package}\\t\\${Version}\\n\" 2>/dev/null\n elif command -v apk >/dev/null 2>&1; then\n echo \"__PM_APK__\"\n apk info -v 2>/dev/null\n elif command -v rpm >/dev/null 2>&1; then\n echo \"__PM_RPM__\"\n rpm -qa --qf \"%{NAME}\\t%{VERSION}\\n\" 2>/dev/null\n else\n exit 7\n fi\n ' 2>/dev/null)\n pkg_rc=$?\n echo \"===PACKAGES===\"\n if [ $pkg_rc -ne 0 ] || [ -z \"$pkg_out\" ]; then\n echo \"__UNKNOWN__\"\n else\n pm_marker=$(printf '%s\\n' \"$pkg_out\" | head -1)\n case \"$pm_marker\" in\n __PM_APK__)\n # name-ver-rN -> name<TAB>version (decision 2): greedy backtracking on the first\n # group finds the RIGHTMOST hyphen whose remainder looks like a version (starts\n # with a digit, optionally followed by -rN), so a hyphenated package name (e.g.\n # ca-certificates-20230506-r0 -> \"ca-certificates\" / \"20230506-r0\") splits\n # correctly without a package database to consult.\n echo \"$pm_marker\"\n printf '%s\\n' \"$pkg_out\" | tail -n +2 | sed -n 's/^\\(.*\\)-\\([0-9][^-]*\\(-r[0-9][0-9]*\\)\\{0,1\\}\\)$/\\1\\t\\2/p'\n ;;\n __PM_DPKG__|__PM_RPM__)\n printf '%s\\n' \"$pkg_out\"\n ;;\n *)\n echo \"__UNKNOWN__\"\n ;;\n esac\n fi\n\n # Tools: the SAME catalog pass, fed the SAME catalog TSV, run inside the container via\n # `docker exec -i <id> bash -s` (decision 2). No separate \"has bash\" probe first (that\n # would be a THIRD exec'd command, past decision 4's \"packages query + catalog pass\"\n # allowance) -- a container with no bash just makes this `docker exec` itself fail,\n # which the exit-code check below treats identically to \"vanished\" (decision 3:\n # unknown, run continues). A zero-tools-found container (bash present, nothing on its\n # PATH is catalogued) is NOT unknown -- exit 0 with empty stdout, kept distinct.\n # `timeout 20` -- same round-1-review reasoning as the package query above: this pass\n # runs the full catalog (~70 `command -v` + version-flag checks), so a single wedged\n # tool inside one container must not be able to hang the whole remote probe.\n tools_out=$(timeout 20 docker exec -i \"$cid\" bash -s 2>/dev/null <<'__RB_CONTAINER_PROBE__'\n__CATALOG_PASS_FN__\ndeclare -A known\ncatalog_pass <<'__CATALOG__'\n__TSV__\n__CATALOG__\n__RB_CONTAINER_PROBE__\n)\n tools_rc=$?\n echo \"===TOOLS===\"\n if [ $tools_rc -ne 0 ]; then\n echo \"__UNKNOWN__\"\n else\n printf '%s\\n' \"$tools_out\"\n fi\n echo \"===CONTAINER_END===\"\n done\nfi\n'@\n $probeSh = ($probeSh -replace \"`r`n\", \"`n\").Replace('__CATALOG_PASS_FN__', $catalogPassFn).Replace('__TSV__', $tsv)\n return $probeSh\n}\n\nfunction Invoke-ToolchainProbe {\n <#\n Runs probe script text through a transport and returns the raw stdout as one string.\n -Transport is a scriptblock: (ScriptText) -> raw output string. Today's only transport\n is WSL (defined next to its call site, below); TC-3 adds an SSH one with the same shape.\n #>\n param(\n [Parameter(Mandatory)][string]$ScriptText,\n [Parameter(Mandatory)][scriptblock]$Transport\n )\n return & $Transport $ScriptText\n}\n\nfunction ConvertFrom-ToolchainProbeOutput {\n <#\n Parses one probe run's raw stdout into catalogued rows, the globals text, the\n uncatalogued-tool list and (issue #721) the containers list -- the `===GLOBALS===` /\n `===UNKNOWN===` / `===CONTAINERS===` sentinel split that used to live inline in the WSL\n block. Same shapes as before: Rows carry Cat/Name/Version/Path/Source (Source via\n Get-WslSource); Unknown carries Name/Path. `Containers` carries\n Name/Image/ImageId/PackageManager/Packages/Tools (see ConvertFrom-ToolchainContainersOutput);\n `ContainersReported` is $true only when a `===CONTAINERS===` marker appeared at all --\n distinct from an empty `Containers` array, which can ALSO mean \"no docker on this side\"\n (decision 1: the caller must tell those two apart to decide whether the sidecar carries\n a `containers` key at all).\n #>\n param(\n [Parameter(Mandatory)][AllowEmptyString()][string]$Raw,\n [Parameter(Mandatory)][array]$Catalog\n )\n\n $rows = @()\n $unknown = @()\n\n $g = $Raw -split '===GLOBALS==='\n $toolOut = $g[0]\n $rest = if ($g.Count -gt 1) { $g[1] } else { '' }\n $u = $rest -split '===UNKNOWN==='\n $globalsText = $u[0].Trim()\n $afterUnknown = if ($u.Count -gt 1) { $u[1] } else { '' }\n\n $c = $afterUnknown -split '===CONTAINERS==='\n $unknownOut = $c[0]\n $containersReported = $c.Count -gt 1\n $containersOut = if ($containersReported) { $c[1] } else { '' }\n\n foreach ($line in ($toolOut -split \"`n\")) {\n if (-not $line.Trim()) { continue }\n $parts = $line -split \"`t\"\n if ($parts.Count -lt 3) { continue }\n $name = $parts[0].Trim()\n $ver = Get-CleanVersion $parts[1]\n $loc = $parts[2].Trim()\n $catEntry = $Catalog | Where-Object { $_.Name -eq $name } | Select-Object -First 1\n if (-not $catEntry) { continue }\n $rows += @{ Cat = $catEntry.Cat; Name = $name; Version = $ver; Path = $loc; Source = (Get-WslSource $loc) }\n }\n\n foreach ($line in ($unknownOut -split \"`n\")) {\n if (-not $line.Trim()) { continue }\n $p = $line -split \"`t\"\n if ($p.Count -lt 2) { continue }\n $unknown += @{ Name = $p[0].Trim(); Path = $p[1].Trim() }\n }\n\n $containers = if ($containersReported) { ConvertFrom-ToolchainContainersOutput -Raw $containersOut -Catalog $Catalog } else { @() }\n\n return @{\n Rows = @($rows); GlobalsText = $globalsText; Unknown = @($unknown)\n Containers = @($containers); ContainersReported = $containersReported\n }\n}\n\nfunction ConvertFrom-ToolchainContainerPackages([string]$Raw) {\n <#\n The `===PACKAGES===` block for ONE container -> @{ PackageManager; Packages }.\n PackageManager is 'dpkg' | 'apk' | 'rpm' | 'unknown' (decision 6); Packages is the\n string 'unknown' (mirroring PackageManager) or an array of @{Name;Version}. The probe\n script (New-ToolchainProbeScript) already normalises apk's `name-ver-rN` into\n name<TAB>version on the host side, so every known manager's rows share one shape here.\n #>\n $lines = @($Raw -split \"`n\" | Where-Object { $_.Trim() -ne '' })\n if (-not $lines.Count -or $lines[0].Trim() -eq '__UNKNOWN__') {\n return @{ PackageManager = 'unknown'; Packages = 'unknown' }\n }\n $pm = switch ($lines[0].Trim()) {\n '__PM_DPKG__' { 'dpkg' }\n '__PM_APK__' { 'apk' }\n '__PM_RPM__' { 'rpm' }\n default { $null }\n }\n if (-not $pm) { return @{ PackageManager = 'unknown'; Packages = 'unknown' } }\n $out = @()\n foreach ($line in ($lines | Select-Object -Skip 1)) {\n $p = $line -split \"`t\", 2\n if ($p.Count -lt 2) { continue }\n $out += @{ Name = $p[0].Trim(); Version = $p[1].Trim() }\n }\n # No leading comma here, unlike this file's other ConvertTo-*/ConvertFrom-* functions'\n # OWN bare `return , @(...)`: THIS array is a plain hashtable-literal VALUE, not itself\n # the function's top-level return -- PowerShell's auto-unwrap-a-single-element-stream\n # quirk (what the comma trick compensates for) only fires at a function-call boundary,\n # and constructing @($out) directly inside a `return @{ ... }` hashtable literal is not\n # one -- confirmed by this file's OWN `return @{ Rows = @($rows); ... }` a few functions\n # up, which uses no comma at this exact same position. A leading comma here double-wraps\n # into an array-of-one-array instead (caught in review: Packages came back as\n # `[[{...}]]`).\n return @{ PackageManager = $pm; Packages = @($out) }\n}\n\nfunction ConvertFrom-ToolchainContainerTools {\n <#\n The `===TOOLS===` block for ONE container -> the string 'unknown' (no bash in the\n container, decision 3) or an array shaped exactly like ConvertFrom-ToolchainProbeOutput's\n own Rows (Cat/Name/Version/Path/Source, matched against -Catalog) -- the SAME catalog\n pass, so a container's catalogued tools carry the same category/refLinks-ready Name the\n rest of the report does. `Source` is always '' here (Get-WslSource's heuristics are\n host-filesystem-shaped, e.g. WSL mount prefixes -- meaningless for a container's own\n rootfs), matching ConvertTo-ToolchainSidecarTools's own '(present)'/'' fallbacks.\n #>\n param([string]$Raw, [array]$Catalog)\n $lines = @($Raw -split \"`n\" | Where-Object { $_.Trim() -ne '' })\n if (-not $lines.Count -or $lines[0].Trim() -eq '__UNKNOWN__') { return 'unknown' }\n $out = @()\n foreach ($line in $lines) {\n $parts = $line -split \"`t\"\n if ($parts.Count -lt 3) { continue }\n $tname = $parts[0].Trim(); $ver = Get-CleanVersion $parts[1]; $loc = $parts[2].Trim()\n $catEntry = $Catalog | Where-Object { $_.Name -eq $tname } | Select-Object -First 1\n if (-not $catEntry) { continue }\n $out += @{ Cat = $catEntry.Cat; Name = $tname; Version = $ver; Path = $loc; Source = '' }\n }\n return , @($out)\n}\n\nfunction ConvertFrom-ToolchainContainersOutput {\n <#\n Parses the `===CONTAINERS===` block (issue #721) into an array of container hashtables:\n @{ Name; Image; ImageId; PackageManager; Packages; Tools }. One `===CONTAINER===` block\n per running container; a container whose `docker inspect` failed between `ps` and the\n probe reaching it is never emitted at all (skipped upstream, in bash) rather than\n reported with a missing/invalid ImageId -- decision 6 pins the digest as ALWAYS present\n and sha256-shaped, unlike Packages/Tools, which have an explicit 'unknown' escape hatch.\n #>\n param(\n [Parameter(Mandatory)][AllowEmptyString()][string]$Raw,\n [Parameter(Mandatory)][array]$Catalog\n )\n\n $out = @()\n $blocks = $Raw -split '===CONTAINER==='\n foreach ($block in $blocks) {\n if (-not $block.Trim()) { continue }\n\n $afterHeader = $block -split '===PACKAGES==='\n if ($afterHeader.Count -lt 2) { continue }\n $headerLine = ($afterHeader[0] -split \"`n\" | Where-Object { $_.Trim() -ne '' } | Select-Object -First 1)\n if (-not $headerLine) { continue }\n $hp = $headerLine -split \"`t\"\n if ($hp.Count -lt 3) { continue }\n $name = $hp[0].Trim(); $image = $hp[1].Trim(); $imageId = $hp[2].Trim()\n\n $afterPackages = $afterHeader[1] -split '===TOOLS==='\n $packagesRaw = $afterPackages[0]\n $toolsSection = if ($afterPackages.Count -gt 1) { $afterPackages[1] } else { '' }\n $toolsRaw = ($toolsSection -split '===CONTAINER_END===')[0]\n\n $pkgResult = ConvertFrom-ToolchainContainerPackages $packagesRaw\n $tools = ConvertFrom-ToolchainContainerTools -Raw $toolsRaw -Catalog $Catalog\n\n $out += @{\n Name = $name; Image = $image; ImageId = $imageId\n PackageManager = $pkgResult.PackageManager; Packages = $pkgResult.Packages; Tools = $tools\n }\n }\n return , @($out)\n}\n\n# Change detection: ignore only the volatile _Generated: line, so a no-change run does not\n# rewrite a report or create an empty commit. Top-level (not inside the dot-source guard)\n# because Publish-ToolchainReport below -- called both for real and by the pwsh-driven\n# tests, which dot-source this script -- reads it.\nfunction Remove-GeneratedLine([string]$text) {\n ($text -split \"`n\" | Where-Object { $_ -notmatch '^_Generated:' }) -join \"`n\"\n}\n\n# ---------------------------------------------------------------------------\n# SSH toolchain probing (issue #715, TC-3). Reuses the host-agnostic probe above (the\n# script text and the parser are already transport-agnostic) and adds the report-building\n# and publish steps as their own functions, so the local Windows+WSL report and each\n# remote host's report share one implementation instead of two copy-pasted blocks.\n# ---------------------------------------------------------------------------\n\nfunction ConvertTo-ToolchainSshTargets {\n <#\n `alias=user@host;alias2=user@host2` -> an array of (alias, target) pairs -- each\n pair itself a 2-element array, so `$alias, $target = $pair` destructures it.\n\n A malformed fragment is SKIPPED with a loud warning, not thrown -- config.py's own\n validation (a target must not contain ';') is the real guard against this ever\n reaching here from a registered task, but a hand run can pass -SshTargets directly,\n bypassing that validation entirely. Throwing here would take the #715 failure-isolation\n guarantee and undo it at the one entry point upstream of the whole per-target loop:\n one bad fragment would abort every OTHER configured host's probe before it even\n started, which is precisely the \"one bad target kills the fleet\" failure this script\n exists to prevent for an unreachable HOST -- a malformed fragment deserves the same\n isolation, not a worse outcome.\n #>\n param([string]$Raw)\n\n $result = @()\n if (-not $Raw) { return , $result }\n foreach ($item in ($Raw -split ';')) {\n $item = $item.Trim()\n if (-not $item) { continue }\n $parts = $item -split '=', 2\n if ($parts.Count -ne 2 -or -not $parts[0].Trim() -or -not $parts[1].Trim()) {\n Write-Host \"! malformed -SshTargets entry skipped: '$item' (expected alias=user@host)\" -ForegroundColor Yellow\n continue\n }\n $result += , @($parts[0].Trim(), $parts[1].Trim())\n }\n return , $result\n}\n\nfunction Format-GlobalsSection {\n <#\n Renders zero or more `{Label; Text}` blocks under one \"### Global packages\" heading --\n Windows has several labelled blocks (npm -g, pipx, ...); a remote/WSL side has one\n unlabelled block whose text already carries its own \"### <label>\" sub-headers (the\n probe script's own `run()` output). Empty/blank-text blocks are dropped, and the\n heading itself is omitted when nothing is left -- so a host with no global packages\n installed renders no section at all, exactly as before this was extracted.\n #>\n param([array]$Blocks)\n\n $blocks = @($Blocks | Where-Object { $_ -and $_.Text })\n if (-not $blocks.Count) { return '' }\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('### Global packages')\n [void]$sb.AppendLine('')\n foreach ($b in $blocks) {\n if ($b.Label) {\n [void]$sb.AppendLine(\"**$($b.Label)**\")\n [void]$sb.AppendLine('')\n }\n [void]$sb.AppendLine('```')\n [void]$sb.AppendLine($b.Text)\n [void]$sb.AppendLine('```')\n [void]$sb.AppendLine('')\n }\n return $sb.ToString()\n}\n\nfunction ConvertTo-ToolchainSidecarTools($rows) {\n <#\n Rows (Cat/Name/Version/Path/Source) -> the sidecar's `tools` array, in the SAME order\n Format-SideSection/Format-Table-Md render them (walk $catOrder, then Sort-Object Name\n within each category) -- so the .json and the .md agree on tool order. `version` gets\n the same '(present)' fallback Format-Table-Md uses for a blank Version, so the field is\n never empty (a validate() requirement, #716); `source` is '' rather than omitted when\n unknown, since every tool has the key.\n #>\n $out = @()\n foreach ($cat in $catOrder) {\n $catRows = @($rows | Where-Object { $_.Cat -eq $cat } | Sort-Object Name)\n foreach ($r in $catRows) {\n $ver = if ($r.Version) { $r.Version } else { '(present)' }\n $out += [ordered]@{\n name = $r.Name\n category = $r.Cat\n version = $ver\n path = $r.Path\n source = $(if ($r.Source) { $r.Source } else { '' })\n }\n }\n }\n return , @($out)\n}\n\nfunction ConvertTo-ToolchainSidecarUnknown($items) {\n <#\n Unknown (Name/Path) -> the sidecar's `uncatalogued` array, sorted by Name. Unlike\n Format-Unknown-Md this carries every entry -- no display cap -- since the JSON is a\n data contract, not a rendered table.\n #>\n $out = @()\n foreach ($u in ($items | Sort-Object { $_.Name })) {\n $out += [ordered]@{ name = $u.Name; path = $u.Path }\n }\n return , @($out)\n}\n\nfunction ConvertTo-ToolchainSidecarContainers($containers) {\n <#\n Container list (Name/Image/ImageId/PackageManager/Packages/Tools) -> the sidecar's\n top-level `containers` array (issue #721 decision 6): {name, image, imageId,\n packageManager, packages, tools}. `packages`/`tools` are each either the literal\n string 'unknown' or a list -- `packages` of {name, version}, `tools` of the SAME shape\n ConvertTo-ToolchainSidecarTools's own `tools` entries use (name/category/version/path/\n source), so a container's catalogued tools round-trip through the identical field set\n the per-side tools already carry. `containers` is its OWN top-level key, not nested\n under any side, since a container isn't scoped to \"windows\"/\"wsl:<distro>\"/\"linux\" the\n way a side is -- it's a fact about the host as a whole.\n #>\n $out = @()\n foreach ($c in $containers) {\n $packagesOut = if ($c.Packages -eq 'unknown') { 'unknown' } else {\n , @($c.Packages | ForEach-Object { [ordered]@{ name = $_.Name; version = $_.Version } })\n }\n $toolsOut = if ($c.Tools -eq 'unknown') { 'unknown' } else {\n , @($c.Tools | ForEach-Object {\n $ver = if ($_.Version) { $_.Version } else { '(present)' }\n [ordered]@{ name = $_.Name; category = $_.Cat; version = $ver; path = $_.Path; source = $(if ($_.Source) { $_.Source } else { '' }) }\n })\n }\n $out += [ordered]@{\n name = $c.Name\n image = $c.Image\n imageId = $c.ImageId\n packageManager = $c.PackageManager\n packages = $packagesOut\n tools = $toolsOut\n }\n }\n return , @($out)\n}\n\nfunction Format-ContainersSection {\n <#\n Renders the #721 `## Containers` section: one summary table (name, image, image ID --\n the first 12 hex chars after `sha256:` -- package manager, package count and tools\n found), then one `<details><summary>container name</summary>` block per container\n holding its full package table (name, version) and catalogued-tools table. Full lists\n stay in the Markdown, not sidecar-only: Publish-ToolchainReport's change detection\n compares the .md text (ignoring only `_Generated:`), and the sidecar is rewritten only\n when the .md changed or was missing -- a sidecar-only package list would freeze the\n moment the .md stopped changing for an unrelated reason (the #721 plan's own caught\n trap). Called only when the probe actually reported a Containers section (docker was on\n PATH and `docker ps` succeeded) -- an empty array still renders the \"_none running_\"\n line, distinct from the caller skipping this function entirely (no docker at all).\n #>\n param([array]$Containers)\n\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('## Containers')\n [void]$sb.AppendLine('')\n if (-not $Containers -or $Containers.Count -eq 0) {\n [void]$sb.AppendLine('_none running_')\n [void]$sb.AppendLine('')\n return $sb.ToString()\n }\n\n [void]$sb.AppendLine('| Name | Image | Image ID | Package manager | Packages | Tools |')\n [void]$sb.AppendLine('|------|-------|----------|------------------|----------|-------|')\n foreach ($c in ($Containers | Sort-Object Name)) {\n $shortId = if ($c.ImageId -match '^sha256:(.{12})') { $Matches[1] } else { $c.ImageId }\n $pkgCount = if ($c.Packages -eq 'unknown') { 'unknown' } else { \"$(@($c.Packages).Count)\" }\n $toolCount = if ($c.Tools -eq 'unknown') { 'unknown' } else { \"$(@($c.Tools).Count)\" }\n [void]$sb.AppendLine(\"| $($c.Name) | ``$($c.Image)`` | ``$shortId`` | $($c.PackageManager) | $pkgCount | $toolCount |\")\n }\n [void]$sb.AppendLine('')\n\n foreach ($c in ($Containers | Sort-Object Name)) {\n [void]$sb.AppendLine(\"<details><summary>$($c.Name)</summary>\")\n [void]$sb.AppendLine('')\n if ($c.Packages -eq 'unknown') {\n [void]$sb.AppendLine('_packages: unknown (no dpkg/apk/rpm found)_')\n } elseif (@($c.Packages).Count -eq 0) {\n [void]$sb.AppendLine('_no packages reported_')\n } else {\n [void]$sb.AppendLine('| Package | Version |')\n [void]$sb.AppendLine('|---------|---------|')\n foreach ($p in ($c.Packages | Sort-Object Name)) {\n [void]$sb.AppendLine(\"| $($p.Name) | $($p.Version) |\")\n }\n }\n [void]$sb.AppendLine('')\n if ($c.Tools -eq 'unknown') {\n [void]$sb.AppendLine('_tools: unknown (no bash in the container)_')\n } elseif (@($c.Tools).Count -eq 0) {\n [void]$sb.AppendLine('_no catalogued tools found_')\n } else {\n [void]$sb.AppendLine('| Tool | Version | Path |')\n [void]$sb.AppendLine('|------|---------|------|')\n foreach ($t in ($c.Tools | Sort-Object Name)) {\n [void]$sb.AppendLine(\"| $($t.Name) | $($t.Version) | ``$($t.Path)`` |\")\n }\n }\n [void]$sb.AppendLine('')\n [void]$sb.AppendLine('</details>')\n [void]$sb.AppendLine('')\n }\n return $sb.ToString()\n}\n\nfunction New-ToolchainReport {\n <#\n Assembles ONE report's full Markdown text AND its JSON sidecar (#716, TC-4) from the\n same -Sides data: title, `_Generated:` line, an optional summary line and an optional\n `> History:` line, then one `## <Title>` section per entry in -Sides (tool table,\n global packages, uncatalogued-on-PATH) via Format-SideSection / Format-GlobalsSection\n / Format-Unknown-Md for the Markdown half, and ConvertTo-ToolchainSidecarTools /\n ConvertTo-ToolchainSidecarUnknown for the sidecar half -- both read the same\n -Sides.Rows / -Sides.Unknown, so the two documents can't drift apart from the same\n call. Each -Sides entry now also carries a `Side` key ('windows' | 'wsl:<distro>' |\n 'linux'), the sidecar's per-side identity -- distinct from `Title`, the Markdown\n heading text.\n\n Used for the LOCAL report today (Windows + WSL, two Sides entries, the summary and\n history lines populated) and for a remote host's report (#715: one Sides entry with\n Title 'Linux', no summary line, no history line) -- the difference between the two is\n entirely in what the CALLER passes, not a second code path.\n\n Returns @{ Markdown = <string>; Sidecar = <ordered hashtable> }. -HostId is the\n report-file suffix (the store's identity for the box, e.g. 'nitro' or the sanitised\n local $machine) -- the sidecar's `host`; -Hostname is the box as it reports itself\n (`hostname`); -Os is 'windows' or 'linux'; -GeneratedIso is the run's UTC timestamp,\n second precision, e.g. '2026-09-15T14:02:11Z' (the sidecar's `generated` -- distinct\n from -Now, the local-time display string the Markdown's `_Generated:` line uses).\n\n -Containers / -ContainersReported (issue #721) are REPORT-level, not per-Side: a\n container isn't scoped to one side's identity the way tools/uncatalogued are, so a\n `## Containers` section (Format-ContainersSection) renders once, after every -Sides\n entry, and the sidecar's `containers` key is added once, top-level -- only when\n -ContainersReported is $true (docker was on PATH and `docker ps` succeeded on\n whichever side actually ran the bash probe). -ContainersReported $false must NOT add\n the key at all, even with -Containers empty -- that is how a host/side with no docker\n stays indistinguishable from before this feature (no `containers` key), while docker\n present with zero running containers still gets `containers: []`.\n #>\n param(\n [Parameter(Mandatory)][string]$HostLabel,\n [Parameter(Mandatory)][string]$GeneratedOn,\n [Parameter(Mandatory)][string]$Now,\n [string]$SummaryLine = '',\n [string]$HistoryRel = '',\n [Parameter(Mandatory)][array]$Sides,\n [Parameter(Mandatory)][string]$HostId,\n [Parameter(Mandatory)][string]$Hostname,\n [Parameter(Mandatory)][ValidateSet('windows', 'linux')][string]$Os,\n [Parameter(Mandatory)][string]$GeneratedIso,\n [array]$Containers = @(),\n [bool]$ContainersReported = $false\n )\n\n $md = New-Object System.Text.StringBuilder\n [void]$md.AppendLine(\"# Toolchain Inventory: $HostLabel\")\n [void]$md.AppendLine('')\n [void]$md.AppendLine(\"_Generated: $Now on ${GeneratedOn}_\")\n [void]$md.AppendLine('')\n if ($SummaryLine) {\n [void]$md.AppendLine($SummaryLine)\n [void]$md.AppendLine('')\n }\n if ($HistoryRel) {\n [void]$md.AppendLine(\"> History: ``git log -- $HistoryRel`` in the claude-memory-sync repo.\")\n [void]$md.AppendLine('')\n }\n $sidecarSides = @()\n foreach ($side in $Sides) {\n [void]$md.AppendLine(\"## $($side.Title)\")\n [void]$md.AppendLine('')\n [void]$md.Append((Format-SideSection $side.Rows))\n [void]$md.Append((Format-GlobalsSection $side.GlobalsBlocks))\n if (@($side.Unknown).Count -gt 0) {\n [void]$md.Append((Format-Unknown-Md $side.Unknown))\n [void]$md.AppendLine('')\n }\n $sidecarSides += [ordered]@{\n side = $side.Side\n tools = (ConvertTo-ToolchainSidecarTools $side.Rows)\n uncatalogued = (ConvertTo-ToolchainSidecarUnknown $side.Unknown)\n }\n }\n if ($ContainersReported) {\n [void]$md.Append((Format-ContainersSection $Containers))\n }\n $sidecar = [ordered]@{\n schemaVersion = 1\n host = $HostId\n hostname = $Hostname\n os = $Os\n generated = $GeneratedIso\n sides = @($sidecarSides)\n }\n if ($ContainersReported) {\n $sidecar['containers'] = (ConvertTo-ToolchainSidecarContainers $Containers)\n }\n return @{\n Markdown = ($md.ToString() -replace \"`r`n\", \"`n\")\n Sidecar = $sidecar\n }\n}\n\nfunction Publish-ToolchainReport {\n <#\n Change-detect, write-if-changed, and render/land ONE report -- extracted so the local\n report and each remote host's report (#715) share one implementation instead of the\n local report carrying its own copy-pasted change-detection + render tail.\n\n Ignores only the volatile `_Generated:` line when comparing (Remove-GeneratedLine), so\n a no-change run does not rewrite the file or create an empty commit. -Label is '' for\n the local report, so its two status lines stay BYTE-IDENTICAL to before #715 (#714's\n acceptance greps for one) and '<alias>: ' for a remote. -SkipRenderReason prints its\n own message instead of rendering -- the local -OutFile testing path, which writes\n somewhere other than the store and so must not render the store's (different, possibly\n absent) copy. Returns 'written', 'unchanged' or 'render-failed' (written to disk, but\n build.py exited non-zero) -- NEVER terminates the script itself, unlike the pre-#715\n local-only flow this was extracted from. A bare `exit` here would abort the enclosing\n per-target loop in Invoke-RemoteToolchainInventory via $ErrorActionPreference='Stop'\n (exit is not a catchable exception, so the surrounding try/catch would not save the\n remaining targets), defeating the whole point of #715's failure isolation for every\n target queued after the one whose render failed -- and, for the LOCAL report, would\n skip SSH probing entirely on a render failure that has nothing to do with SSH\n reachability. Callers decide what 'render-failed' means for the run as a whole: the\n main flow folds it into the final exit code (matching the pre-#715 behaviour of exiting\n 1 on a local render failure) but only AFTER SSH probing has run; Invoke-RemoteToolchainInventory\n folds it into Failed like an unreachable host, so the affected alias is visible and the\n loop still completes every other target.\n\n -Sidecar (#716, TC-4) is the JSON sidecar object New-ToolchainReport built alongside\n -Text, published from the SAME change verdict as the .md -- \"land both or neither\" --\n EXCEPT that a missing sidecar is also its own trigger: the .md alone changing (or not)\n decides whether $FileName itself is rewritten (so the sidecar's `generated` timestamp\n never forces a no-op git diff on the .md), but the sidecar is (re)written whenever it\n is either due a real change OR simply absent from disk -- covering both this feature's\n first rollout (an unchanged .md that never had a sidecar) and recovery after build.py\n rejects one as invalid and deletes it (build.py's own comment). Serialised with\n `ConvertTo-Json -Depth 6`, LF-normalised, no BOM, keys in the order\n toolchain_sidecar.py documents (that module is this contract's other half -- the\n console side that validates it). Not atomic across the two files (round-2 review,\n #716): a process kill between the .md write and the sidecar write below could leave a\n stale-but-valid sidecar the \"missing\" check can't detect, since it checks presence,\n not content. Consistent with every other write in this script (none are transactional\n either) -- accepted rather than solved here.\n #>\n param(\n [Parameter(Mandatory)][string]$FileName,\n [Parameter(Mandatory)][string]$Text,\n [Parameter(Mandatory)][object]$Sidecar,\n [string]$Rel = '',\n [string]$Label = '',\n [switch]$NoRender,\n [string]$SkipRenderReason = ''\n )\n\n $sidecarFileName = [IO.Path]::ChangeExtension($FileName, '.json')\n\n $mdChanged = $true\n if (Test-Path $FileName) {\n $old = ([IO.File]::ReadAllText($FileName)) -replace \"`r\", ''\n if ((Remove-GeneratedLine $old) -eq (Remove-GeneratedLine $Text)) { $mdChanged = $false }\n }\n # A missing sidecar is ALSO a publish trigger, independent of the .md's own diff --\n # otherwise a host whose report text happens not to change this run (the common case\n # most weeks) can never get its FIRST sidecar after this feature ships, or recover one\n # build.py rejected as invalid and (per build.py's own comment) deleted: the .md text\n # is identical either way, so a check keyed on the .md alone reports 'unchanged'\n # forever and the sidecar never lands (#716 review). This still writes ONLY the\n # sidecar when the .md itself is unchanged -- never touching $FileName -- so a\n # backfill run does not bump `_Generated:` or create a no-op git diff on the .md.\n $sidecarMissing = -not (Test-Path $sidecarFileName)\n if (-not $mdChanged -and -not $sidecarMissing) {\n Write-Host \"= ${Label}No toolchain changes since last run; nothing to publish.\" -ForegroundColor DarkGray\n return 'unchanged'\n }\n\n if ($mdChanged) {\n [IO.File]::WriteAllText($FileName, $Text, (New-Object System.Text.UTF8Encoding($false)))\n Write-Host \"= ${Label}Report written: $FileName\" -ForegroundColor Green\n } else {\n # Deliberately doesn't say \"unchanged\": this branch still returns 'written' below\n # and callers (Invoke-RemoteToolchainInventory's $written bucket, the end-of-run\n # summary) report it as such -- a message containing \"unchanged\" here would read\n # as contradicting that summary line to anyone scanning a scheduled-task log.\n Write-Host \"= ${Label}Report text unchanged; sidecar (re)written.\" -ForegroundColor DarkGray\n }\n $sidecarJson = (($Sidecar | ConvertTo-Json -Depth 6) -replace \"`r`n\", \"`n\") + \"`n\"\n [IO.File]::WriteAllText($sidecarFileName, $sidecarJson, (New-Object System.Text.UTF8Encoding($false)))\n\n if ($NoRender) {\n Write-Host \"= -NoRender: skipped console render/land.\" -ForegroundColor DarkGray\n } elseif ($SkipRenderReason) {\n Write-Host $SkipRenderReason -ForegroundColor DarkGray\n } elseif (-not $storeAvailable -or -not (Test-Path (Join-Path $ArtifactConsoleDir 'build.py'))) {\n $missing = @()\n if (-not $storeAvailable) { $missing += \"store not found: '$ArtifactSourcesDir'\" }\n if (-not (Test-Path (Join-Path $ArtifactConsoleDir 'build.py'))) {\n $missing += \"build.py not found under '$ArtifactConsoleDir'\"\n }\n Write-Host \"! $($missing -join '; '); wrote the report but skipped render.\" -ForegroundColor Yellow\n } else {\n $buildArgs = @('build.py', '--report', $Rel)\n if ($NoSync) { $buildArgs += '--no-sync' }\n Push-Location $ArtifactConsoleDir\n # Piped through Write-Host, not left as a bare native call (issue #715 follow-up):\n # Publish-ToolchainReport's own return value is CAPTURED by every caller\n # ($localResult = ...; $result = ... in Invoke-RemoteToolchainInventory), and a\n # captured function's un-redirected native-command output is swallowed into that\n # same captured collection instead of reaching the console/log -- verified directly\n # (`$x = function { & cmd /c echo hi }` yields $x = @('hi ', <return value>), and\n # nothing is printed) -- which is why build.py's own \"rendered / committed /\n # pushed\" lines stopped reaching the job log the moment this call moved from\n # top-level script code into a function whose result every caller assigns.\n # Write-Host bypasses the success-output stream entirely, so it is never captured\n # regardless of how the caller uses this function's return value.\n try { & $pyExe @buildArgs 2>&1 | ForEach-Object { Write-Host $_ } } finally { Pop-Location }\n # A native command's non-zero exit does NOT throw, even under $ErrorActionPreference =\n # 'Stop': $PSNativeCommandUseErrorActionPreference is off by default (verified $false on\n # pwsh 7.6.5). Without this check the line below announces a render that did not happen\n # and the script still exits 0 -- the same silent-success shape as the bug that buried\n # this whole branch pre-#320. Exit non-zero so the task's LastTaskResult shows it.\n if ($LASTEXITCODE -ne 0) {\n Write-Host \"! build.py exited $LASTEXITCODE; the report is written but NOT rendered: $Rel\" -ForegroundColor Red\n return 'render-failed'\n }\n Write-Host \"= Rendered to artifact-console$(if($NoSync){' (no sync)'}else{' + landed to memory repo'}): $Rel\" -ForegroundColor Green\n }\n return 'written'\n}\n\nfunction Resolve-ToolchainSshExe {\n <#\n ssh.exe resolved explicitly: the scheduled task's PATH is not the interactive one.\n Prefers the built-in OpenSSH client (present on every Windows 10/11 box since the\n optional feature shipped in-box) over whatever `ssh` a `Get-Command` search turns up,\n for the same reason Register-InventoryTask.ps1 resolves pwsh explicitly rather than\n trusting PATH.\n\n `$env:SystemRoot` is skipped entirely when it's null/empty (issue #636 review round\n 2: `Join-Path $null ...` throws \"Cannot bind argument to parameter 'Path'\", which\n this function did unguarded before this fix, real on Linux where SystemRoot is never\n set. This script is Windows/WSL-shaped throughout and every env var it reads\n (SystemRoot here, LOCALAPPDATA/USERPROFILE/APPDATA elsewhere) is real in production,\n so this guard changes nothing on an actual Windows box -- it was added because this\n function was reached by a cross-platform test (the tools-site push, #636), the first\n thing in this file to call this function outside a fake -Transport/-SshExe. That test\n was retired with the push itself (Rackbops/Tooling#667 C3); the guard stays since\n it's still correct and free. The other env reads in this script are NOT similarly\n guarded (LOCALAPPDATA in $stateDir, USERPROFILE/APPDATA in Get-UncataloguedWin) --\n fixing those is out of scope here since nothing currently reaches them from a\n non-Windows test either.\n #>\n if ($env:SystemRoot) {\n $sshExe = Join-Path $env:SystemRoot 'System32\\OpenSSH\\ssh.exe'\n if (Test-Path -LiteralPath $sshExe) { return $sshExe }\n }\n $cmd = Get-Command ssh -ErrorAction SilentlyContinue\n if ($cmd) { return $cmd.Source }\n return $null\n}\n\nfunction Invoke-ToolchainSshTransport {\n <#\n Runs one SSH command, feeding -ScriptText to its stdin as EXACT bytes -- UTF-8, no BOM,\n no CR -- via a raw System.Diagnostics.Process rather than PowerShell's `|` pipeline\n operator into a native command.\n\n THE BUG THIS EXISTS TO FIX (issue #715 follow-up, caught by the real Melody run against\n real nitro/nucbox -- both `ssh exited 127: bash: line 1: $'hostname\\r': command not\n found`). The original transport was `$ScriptText | & $sshExe ...`: piping a string to a\n native command through PowerShell's own pipeline (or calling `.WriteLine()` on its\n StandardInput directly) terminates it with `Environment.NewLine` (`\\r\\n` on Windows)\n regardless of the string's own line endings -- verified directly (`od -An -c` on the\n raw bytes a real local process received). A trailing CRLF on its own does not reliably\n reproduce a FAILURE against a real local bash (a real local `bash -s` runs `hostname\\r\\n`\n fine); the exact failure mode over a real ssh channel could not be reproduced from this\n sandbox (no live target, and the orchestrator's post-merge run is the one place that\n can exercise real ssh). What IS provably true, and is what this function and its own\n tests are built around, is that writing exact, caller-specified bytes -- no CR/CRLF\n PowerShell's own string-to-native-pipe handling would otherwise add -- is unambiguously\n correct regardless of the precise mechanism behind the original failure, and a raw\n `System.Diagnostics.Process` with a direct byte-stream write is the one way to guarantee\n it. No fake `-Transport` scriptblock test can see ANY of this: it is specific to real\n native-process stdin behaviour, which is why this is now its own function, driven\n directly by a REAL local bash standing in for ssh (see test_toolchain_ssh_transport.py)\n rather than only ever exercised through a fake transport. The remote-side band-aid\n (`tr -d '\\r'`) was rejected: the SENDER has to be correct, not the receiver made\n tolerant of a bug whose exact trigger isn't even fully pinned down.\n\n -Arguments is the full, exact argv `$SshExe` runs with (`-o BatchMode=yes ...\n <target> bash -s` in production) -- passed in whole by the caller, not built here, so\n a test can point -SshExe at a real bash and -Arguments at just `@('-s')`, skipping the\n ssh-specific flags/target entirely, and still drive the SAME stdin-writing code this\n function uses for real. Throws (naming the exit code and any stderr) on a non-zero\n exit, matching the original transport's contract.\n #>\n param(\n [Parameter(Mandatory)][string]$SshExe,\n [Parameter(Mandatory)][string[]]$Arguments,\n [Parameter(Mandatory)][AllowEmptyString()][string]$ScriptText\n )\n if (-not $SshExe) { throw 'ssh.exe not found' }\n\n $psi = New-Object System.Diagnostics.ProcessStartInfo\n $psi.FileName = $SshExe\n foreach ($a in $Arguments) { [void]$psi.ArgumentList.Add($a) }\n $psi.RedirectStandardInput = $true\n $psi.RedirectStandardOutput = $true\n $psi.RedirectStandardError = $true\n # Explicit UTF-8 on the READ side too, not just the write: with neither set, .NET\n # falls back to the console's own output encoding (on this box, code page 437) for\n # decoding the child's stdout/stderr, silently mangling any non-ASCII byte a remote\n # tool's version string or path can carry -- verified directly (round-tripping\n # 'cafe unicode' through an unset-encoding child comes back corrupted). \"Byte-exact\"\n # cuts both ways: the fix is for the WRITE side's bug, but the read side gets the same\n # standard while this function is already being rewritten.\n $utf8NoBom = New-Object System.Text.UTF8Encoding($false)\n $psi.StandardOutputEncoding = $utf8NoBom\n $psi.StandardErrorEncoding = $utf8NoBom\n $psi.UseShellExecute = $false\n $psi.CreateNoWindow = $true\n\n $proc = [System.Diagnostics.Process]::Start($psi)\n try {\n # Reads started BEFORE writing stdin, and run async throughout: the child can start\n # producing output before we finish writing input, and on a large enough probe\n # script writing everything first (with nothing draining stdout/stderr) risks the\n # classic same-process pipe deadlock -- the child blocks on a full stdout/stderr\n # pipe while we block trying to finish writing a full stdin pipe.\n $stdoutTask = $proc.StandardOutput.ReadToEndAsync()\n $stderrTask = $proc.StandardError.ReadToEndAsync()\n try {\n # LF only, defensively: New-ToolchainProbeScript already emits LF-only text\n # (its own `-replace \"`r`n\", \"`n\"`), but this is the one place a CR could\n # still slip through -- a one-line literal like 'hostname' has no line ending\n # of its own to normalise, and a future caller might pass one that does.\n $normalized = $ScriptText -replace \"`r`n\", \"`n\" -replace \"`r\", \"`n\"\n $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)\n $proc.StandardInput.BaseStream.Write($bytes, 0, $bytes.Length)\n $proc.StandardInput.BaseStream.Flush()\n } catch {\n # A process that exits (or closes its own stdin) before or while we're still\n # writing -- e.g. a real ssh auth failure (\"Permission denied (publickey)\"),\n # which never even spawns bash -- throws here (\"the pipe is being closed\")\n # BEFORE the real, useful failure reason below is ever reached. Swallowing it\n # is deliberate: the process's own exit code + stderr, read next, is the\n # actual cause, and a caller told \"the pipe is being closed\" instead of\n # \"Permission denied\" or \"Connection refused\" has lost the one thing this\n # whole function exists to report accurately.\n Write-Verbose \"stdin write failed (process likely already exited): $($_.Exception.Message)\"\n } finally {\n try { $proc.StandardInput.Close() } catch { Write-Verbose \"stdin already closed.\" }\n }\n $proc.WaitForExit()\n $stdout = $stdoutTask.GetAwaiter().GetResult()\n $stderr = $stderrTask.GetAwaiter().GetResult()\n\n if ($proc.ExitCode -ne 0) {\n throw \"ssh exited $($proc.ExitCode)$(if ($stderr.Trim()) { \": $($stderr.Trim())\" })\"\n }\n return $stdout\n } finally {\n $proc.Dispose()\n }\n}\n\nfunction Invoke-RemoteToolchainInventory {\n <#\n Probes every configured SSH target, publishing one toolchain-inventory-<alias>.md per\n reachable host and leaving an unreachable host's previous report untouched (#715).\n\n -Transport is `param($target, $scriptText)` -> raw stdout as one string, and throws (or\n the caller treats a null/empty hostname response as unreachable too) when the host\n can't be reached; the production transport pipes the script over\n `ssh -o BatchMode=yes -o ConnectTimeout=8 <target> bash -s` (script on stdin, no remote\n temp file), with ssh.exe resolved explicitly since the scheduled task's PATH is not the\n interactive one. -Now is the SAME 'yyyy-MM-dd HH:mm' string the local report's\n _Generated: line uses, so every report from one run agrees. -LocalHost names where the\n SSH hop originates, for the remote _Generated: line's \"via ssh from <local>\".\n\n Per target, in order: the hostname (the transport given the one-line script\n `hostname`, so the title/`_Generated:` line carries the real name, not the alias) --\n then the host-agnostic probe script through the same transport, parsed, built into a\n single-`linux`-side report, and published under `-Label '<alias>: '`. A failure at any\n step for one target is caught, printed as `! <alias>: unreachable (<reason>)`, and does\n NOT stop the loop -- the local report and every other target still publish.\n\n Returns `{Written, Unchanged, Failed, RenderFailed, ExitCode}`. `RenderFailed` is the\n subset of `Failed` whose underlying `Publish-ToolchainReport` result was specifically\n `'render-failed'`, not a genuinely unreachable host -- the report DID reach the store\n even though build.py's render/land step failed. Kept as its own bucket for diagnostic\n value even though the one consumer that needed the distinction (the tools-site\n page-data push's change detection) was retired in Rackbops/Tooling#667, E2/C3.\n #>\n param(\n [Parameter(Mandatory)][array]$Targets,\n [Parameter(Mandatory)][scriptblock]$Transport,\n [Parameter(Mandatory)][string]$Now,\n [Parameter(Mandatory)][string]$NowIso,\n [Parameter(Mandatory)][string]$LocalHost,\n [switch]$NoRender\n )\n\n $written = @()\n $unchanged = @()\n $failed = @()\n $renderFailed = @()\n\n foreach ($pair in $Targets) {\n $alias, $target = $pair\n try {\n $remoteHostName = ((& $Transport $target 'hostname') | Out-String).Trim()\n if (-not $remoteHostName) { throw 'empty hostname response' }\n\n $probeScript = New-ToolchainProbeScript -Catalog $catalog\n $raw = (& $Transport $target $probeScript) | Out-String\n $parsed = ConvertFrom-ToolchainProbeOutput -Raw $raw -Catalog $catalog\n\n $side = @{\n Title = 'Linux'\n Side = 'linux'\n Rows = $parsed.Rows\n GlobalsBlocks = @(@{ Label = $null; Text = $parsed.GlobalsText })\n Unknown = $parsed.Unknown\n }\n $report = New-ToolchainReport -HostLabel \"$remoteHostName ($alias)\" `\n -GeneratedOn \"$remoteHostName via ssh from $LocalHost\" -Now $Now -Sides @($side) `\n -HostId $alias -Hostname $remoteHostName -Os 'linux' -GeneratedIso $NowIso `\n -Containers $parsed.Containers -ContainersReported $parsed.ContainersReported\n\n $remoteFileName = \"toolchain-inventory-$alias.md\"\n $remoteOutPath = if ($storeAvailable) { Join-Path $storeDir $remoteFileName } else { Join-Path $stateDir $remoteFileName }\n $remoteRel = \"$ArtifactCategory/$remoteFileName\"\n\n $result = Publish-ToolchainReport -FileName $remoteOutPath -Text $report.Markdown `\n -Sidecar $report.Sidecar -Rel $remoteRel -Label \"${alias}: \" -NoRender:$NoRender\n # 'render-failed' (build.py exited non-zero) is isolated exactly like an\n # unreachable host: the report DID reach the store, but is not counted as a\n # clean success, and this alias must not stop the loop for the others. It\n # still joins $failed (every existing caller's \"not a clean success\" meaning\n # is unchanged) but ALSO its own $renderFailed bucket, because unlike a\n # genuinely unreachable host it DID just write a fresh sidecar to the store --\n # kept as diagnostic detail (see this function's own docstring).\n if ($result -eq 'written') { $written += $alias }\n elseif ($result -eq 'unchanged') { $unchanged += $alias }\n else {\n $failed += $alias\n if ($result -eq 'render-failed') { $renderFailed += $alias }\n }\n } catch {\n Write-Host \"! ${alias}: unreachable ($($_.Exception.Message))\" -ForegroundColor Yellow\n $failed += $alias\n }\n }\n\n return @{\n Written = $written\n Unchanged = $unchanged\n Failed = $failed\n RenderFailed = $renderFailed\n ExitCode = $(if ($failed.Count -gt 0) { 2 } else { 0 })\n }\n}\n\n# Run the probing/report/render flow only when executed (pwsh -File / & script.ps1),\n# not when dot-sourced for testing (. ./Get-ToolchainInventory.ps1), which only wants\n# the functions and the catalog above defined.\nif ($MyInvocation.InvocationName -ne '.') {\n\n Write-Host \"= Toolchain inventory: probing Windows side...\" -ForegroundColor Cyan\n\n # ---------------------------------------------------------------------------\n # Windows probing\n # ---------------------------------------------------------------------------\n $winRows = @()\n foreach ($tool in $catalog) {\n $r = Get-WinToolInfo $tool\n if ($r) { $winRows += $r; Write-Host (\" + {0,-16} {1}\" -f $r.Name, $r.Version) }\n }\n\n # Windows global packages\n $winGlobals = [ordered]@{}\n function Add-WinGlobal($label, $exe, $cmdArgs) {\n if (Get-Command $exe -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n # stdout only -- keep error spew (e.g. a missing npm global prefix) out of the report\n $o = (& $exe @cmdArgs 2>$null | Out-String).Trim()\n if ($o) { $script:winGlobals[$label] = $o }\n } catch { Write-Verbose \"$label ($exe) failed; leaving it out of the report.\" }\n }\n }\n Add-WinGlobal 'npm -g' 'npm' @('ls','-g','--depth=0')\n Add-WinGlobal 'pipx' 'pipx' @('list','--short')\n Add-WinGlobal 'uv tool' 'uv' @('tool','list')\n Add-WinGlobal 'cargo install' 'cargo' @('install','--list')\n Add-WinGlobal 'gem (user)' 'gem' @('list','--local')\n if (Get-Command go -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n $gp = (& go env GOPATH 2>$null).Trim()\n $gobin = if ($env:GOBIN) { $env:GOBIN } else { Join-Path $gp 'bin' }\n if (Test-Path $gobin) {\n $bins = Get-ChildItem $gobin -File -ErrorAction SilentlyContinue |\n Select-Object -ExpandProperty Name\n if ($bins) { $winGlobals['go install'] = ($bins -join \"`n\") }\n }\n } catch { Write-Verbose \"go env GOPATH failed; leaving the 'go install' list out of the report.\" }\n }\n\n # Uncatalogued dev tools sitting on PATH (heads-up section). @() forces array\n # semantics so a single hit isn't unwrapped to a bare hashtable (whose .Count\n # would be its key count, not 1).\n $winUnknown = @(Get-UncataloguedWin)\n if ($winUnknown.Count) { Write-Host (\" ~ {0} uncatalogued on PATH\" -f $winUnknown.Count) -ForegroundColor DarkYellow }\n\n # ---------------------------------------------------------------------------\n # WSL probing. The probe logic is written to a real .sh file on disk and run\n # via `bash <file>` -- NOT passed as a `bash -lc <string>` argument, because\n # wsl.exe strips backslash escapes (\\t \\n \\r) from command-line arguments,\n # which silently breaks IFS/printf/tr. A script on disk is immune to that.\n # ---------------------------------------------------------------------------\n function ConvertTo-WslPath([string]$winPath) {\n $p = (& wsl.exe -d $WslDistro wslpath -a ($winPath -replace '\\\\','/') 2>$null)\n $p = if ($p) { \"$p\".Trim() } else { '' }\n if (-not $p) {\n $drive = $winPath.Substring(0,1).ToLower()\n $p = \"/mnt/$drive\" + ($winPath.Substring(2) -replace '\\\\','/')\n }\n return $p\n }\n\n $wslRows = @()\n $wslGlobalsText = ''\n $wslUnknown = @()\n $wslContainers = @()\n $wslContainersReported = $false\n $wslAvailable = $false\n if ($WslDistro) {\n $wslExe = Get-Command wsl.exe -ErrorAction SilentlyContinue\n if ($wslExe) {\n # Is the distro registered? (wsl -l -q emits UTF-16 unless WSL_UTF8 set)\n $env:WSL_UTF8 = '1'\n $distros = (& wsl.exe -l -q 2>$null) -split \"`n\" | ForEach-Object { $_.Trim() } | Where-Object { $_ }\n if ($distros -contains $WslDistro) {\n $wslAvailable = $true\n Write-Host \"= Probing WSL:$WslDistro side...\" -ForegroundColor Cyan\n $utf8 = New-Object System.Text.UTF8Encoding($false)\n\n $probeScript = New-ToolchainProbeScript -Catalog $catalog\n\n # WSL transport: the script is written to a real file on disk and run via\n # `bash <path>`, never passed as a `bash -lc <string>` argument -- wsl.exe\n # strips backslash escapes from arguments, per the header comment above.\n # TC-3's SSH transport will pipe `bash -s` on stdin instead.\n $wslTransport = {\n param([string]$ScriptText)\n $shPath = Join-Path $tmpDir 'probe.sh'\n [IO.File]::WriteAllText($shPath, $ScriptText, $utf8)\n $wslSh = ConvertTo-WslPath $shPath\n $raw = (& wsl.exe -d $WslDistro -- bash $wslSh 2>$null | Out-String) -replace \"`r\",''\n Remove-Item $shPath -ErrorAction SilentlyContinue\n return $raw\n }\n\n $raw = Invoke-ToolchainProbe -ScriptText $probeScript -Transport $wslTransport\n $parsed = ConvertFrom-ToolchainProbeOutput -Raw $raw -Catalog $catalog\n $wslRows = $parsed.Rows\n $wslGlobalsText = $parsed.GlobalsText\n $wslUnknown = $parsed.Unknown\n $wslContainers = $parsed.Containers\n $wslContainersReported = $parsed.ContainersReported\n foreach ($r in $wslRows) { Write-Host (\" + {0,-16} {1}\" -f $r.Name, $r.Version) }\n if ($wslUnknown.Count) { Write-Host (\" ~ {0} uncatalogued on PATH\" -f $wslUnknown.Count) -ForegroundColor DarkYellow }\n } else {\n Write-Host \"! WSL distro '$WslDistro' not registered; skipping WSL side.\" -ForegroundColor Yellow\n }\n } else {\n Write-Host \"! wsl.exe not found; skipping WSL side.\" -ForegroundColor Yellow\n }\n }\n\n # ---------------------------------------------------------------------------\n # Build + publish the LOCAL report (Windows + WSL), via the shared functions above.\n # ---------------------------------------------------------------------------\n $now = Get-Date -Format 'yyyy-MM-dd HH:mm'\n $nowIso = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')\n\n $winGlobalsBlocks = @($winGlobals.Keys | ForEach-Object { @{ Label = $_; Text = $winGlobals[$_] } })\n $sides = @(@{ Title = 'Windows'; Side = 'windows'; Rows = $winRows; GlobalsBlocks = $winGlobalsBlocks; Unknown = $winUnknown })\n # The machine goes in the TITLE, not only the _Generated: line -- a reader who opens the\n # rendered page should not have to work out whose toolchain they are looking at, which\n # was a defect even back when only one machine wrote this file (issue #231).\n $wslCountLabel = if ($wslAvailable) { \"WSL:$WslDistro $($wslRows.Count) tools\" } else { 'WSL not probed' }\n if ($wslAvailable) {\n $sides += @{ Title = \"WSL: $WslDistro\"; Side = \"wsl:$WslDistro\"; Rows = $wslRows\n GlobalsBlocks = @(@{ Label = $null; Text = $wslGlobalsText }); Unknown = $wslUnknown }\n }\n $localReport = New-ToolchainReport -HostLabel $hostName `\n -GeneratedOn \"$hostName (editors: $(if($includeEd){'included'}else{'excluded'}))\" -Now $now `\n -SummaryLine \"**Windows:** $($winRows.Count) tools · **$wslCountLabel**\" `\n -HistoryRel \"$ArtifactCategory/$fileName\" -Sides $sides `\n -HostId $machine -Hostname $hostName -Os 'windows' -GeneratedIso $nowIso `\n -Containers $wslContainers -ContainersReported $wslContainersReported\n $mdText = $localReport.Markdown\n\n # Always record the last run time (out-of-repo state, untracked).\n [IO.File]::WriteAllText((Join-Path $stateDir 'last-run.txt'), \"$now on $hostName`n\",\n (New-Object System.Text.UTF8Encoding($false)))\n\n # -OutFile is the local-test path: the report went where the caller asked, NOT into the\n # store -- but the store's copy (named by $rel below) is a different file that may be\n # stale or absent, so rendering here would publish something this run did not write.\n # `$PSBoundParameters` is a bind-time snapshot, so it still answers \"did the caller pass\n # it\" after the store branch earlier in the script assigned $OutFile itself. Indexed\n # rather than .ContainsKey(), so an explicitly EMPTY `-OutFile ''` is falsy here exactly\n # as it is at the two `-not $OutFile` tests above: that run does write to the store, so\n # it is the one that SHOULD render.\n $localSkipReason = if ($PSBoundParameters['OutFile']) {\n '= -OutFile: wrote the report there; skipped the store render/land.'\n } else { '' }\n $localResult = Publish-ToolchainReport -FileName $OutFile -Text $mdText -Sidecar $localReport.Sidecar `\n -Rel \"$ArtifactCategory/$fileName\" -NoRender:$NoRender -SkipRenderReason $localSkipReason\n\n # ---------------------------------------------------------------------------\n # SSH toolchain probing (issue #715, TC-3). Runs regardless of whether the local\n # report changed -- a stable local box must not hide a drifting SSH host, or vice\n # versa, which is exactly why the local branch above no longer returns early.\n # ---------------------------------------------------------------------------\n $sshTargetPairs = ConvertTo-ToolchainSshTargets $SshTargets\n # Every fragment malformed (only reachable from a hand-typed -SshTargets -- the\n # scheduled/registered path always carries config.py-validated values) must not look\n # identical to \"no SSH targets configured\": that would silently probe nothing and\n # still exit 0, hiding a typo behind the one Yellow line ConvertTo-ToolchainSshTargets\n # already printed per skipped fragment. $SshTargets non-empty but $sshTargetPairs\n # empty is the tell.\n $sshTargetsAllMalformed = [bool]$SshTargets -and (@($sshTargetPairs).Count -eq 0)\n if ($sshTargetsAllMalformed) {\n Write-Host \"! -SshTargets was set but every fragment was malformed; nothing was probed.\" -ForegroundColor Red\n }\n $remote = $null\n if (@($sshTargetPairs).Count -gt 0) {\n Write-Host \"= Probing $(@($sshTargetPairs).Count) SSH host(s)...\" -ForegroundColor Cyan\n $sshExe = Resolve-ToolchainSshExe\n # Thin closure over Invoke-ToolchainSshTransport (issue #715 follow-up): the real\n # byte-exact stdin write lives there, driven directly by its own tests against a\n # real local bash; this just supplies the production argv and executable.\n $sshTransport = {\n param([string]$Target, [string]$ScriptText)\n Invoke-ToolchainSshTransport -SshExe $sshExe `\n -Arguments @('-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', $Target, 'bash', '-s') `\n -ScriptText $ScriptText\n }\n $remote = Invoke-RemoteToolchainInventory -Targets $sshTargetPairs -Transport $sshTransport `\n -Now $now -NowIso $nowIso -LocalHost $hostName -NoRender:$NoRender\n if ($remote.Written.Count -gt 0) {\n Write-Host (\"= SSH hosts written: {0}\" -f ($remote.Written -join ', ')) -ForegroundColor Green\n }\n if ($remote.Unchanged.Count -gt 0) {\n Write-Host (\"= SSH hosts unchanged: {0}\" -f ($remote.Unchanged -join ', ')) -ForegroundColor DarkGray\n }\n if ($remote.Failed.Count -gt 0) {\n Write-Host (\"! SSH hosts unreachable: {0}\" -f ($remote.Failed -join ', ')) -ForegroundColor Yellow\n }\n }\n\n Write-Host \"= Done.\" -ForegroundColor Cyan\n # A remote failure (2) outranks a local-only render failure (1, the pre-#715 behaviour\n # of exiting non-zero when build.py fails) -- both are folded in HERE, after SSH\n # probing has already run to completion, never as an early `exit` from inside\n # Publish-ToolchainReport (which would abort probing before it started). The ranking\n # only decides what this run's Last Result shows, not whether work happened. (Exit\n # code 3 was the tools-site page-data push's failure signal, retired along with the\n # push itself -- Rackbops/Tooling#667, E2/C3 -- now that the page reads the store\n # directly; 1 and 2 keep their pre-existing meanings unchanged.)\n $exitCode = 0\n if ($localResult -eq 'render-failed') { $exitCode = 1 }\n if ($sshTargetsAllMalformed) { $exitCode = 2 }\n if ($remote -and $remote.ExitCode -ne 0) { $exitCode = $remote.ExitCode }\n if ($exitCode -ne 0) { exit $exitCode }\n}\n";
|
|
66943
66950
|
|
|
66944
66951
|
/**
|
|
66945
66952
|
* Runs 1.x's own `Get-ToolchainInventory.ps1` through `pwsh` (#52, 52b; 1.x had no Linux
|
|
66946
66953
|
* equivalent, `toolchain_sidecar.py`, `sched_spec.py:264-273`), kind `toolchain-inventory`,
|
|
66947
66954
|
* capability `agent.scan.toolchain-inventory`. Shipped and run, not rewritten (`data/README.md`):
|
|
66948
|
-
* the script is 1,
|
|
66955
|
+
* the script is 1,846 lines (1.x `main` at `da0848f`, vendored byte for byte) whose output is
|
|
66949
66956
|
* the byte-compare target, so a TypeScript rewrite would have to re-derive every probe and
|
|
66950
66957
|
* formatting rule and still match it byte for byte.
|
|
66951
66958
|
*
|
|
@@ -75850,12 +75857,18 @@ function sudoersRule(username) {
|
|
|
75850
75857
|
function printable(text, max) {
|
|
75851
75858
|
return text.replace(/[^\x20-\x7e]/g, "?").slice(0, max);
|
|
75852
75859
|
}
|
|
75860
|
+
/** A closing line that advises rather than reports, e.g. the Claude CLI's "Or consider using native
|
|
75861
|
+
* installation with: claude install" printed after the error that actually failed the run. */
|
|
75862
|
+
const ADVICE_LINE = /^(?:or\s+)?(?:consider|try|hint|tip|see|for more)(?=\s|:|$)/i;
|
|
75863
|
+
/** The command's last line that reports something: trailing advice lines are skipped, so the reason
|
|
75864
|
+
* is the failure and not the hint printed after it. All-advice output falls back to its last line. */
|
|
75853
75865
|
function lastNonEmptyLine(text) {
|
|
75854
75866
|
const lines = text
|
|
75855
75867
|
.split(/\r?\n/)
|
|
75856
75868
|
.map((line) => line.trim())
|
|
75857
75869
|
.filter((line) => line !== "");
|
|
75858
|
-
|
|
75870
|
+
const reported = lines.findLast((line) => !ADVICE_LINE.test(line));
|
|
75871
|
+
return printable(reported ?? lines.at(-1) ?? "", MAX_REASON_CHARS);
|
|
75859
75872
|
}
|
|
75860
75873
|
function classifyOutcome(input) {
|
|
75861
75874
|
if (input.limit === "timeout") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rackbops/ac-agent",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.58",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.",
|