@rackbops/ac-agent 2.0.0-alpha.52 → 2.0.0-alpha.54
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 +234 -9
- package/data/Get-ToolchainInventory.ps1 +6 -0
- package/data/README.md +24 -13
- package/dist/ac-agent.mjs +965 -116
- package/package.json +1 -1
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.54";
|
|
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 = {
|
|
@@ -224,6 +224,13 @@ function managerBinDirs(deps) {
|
|
|
224
224
|
return dirs;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
var searchPath$1 = /*#__PURE__*/Object.freeze({
|
|
228
|
+
__proto__: null,
|
|
229
|
+
managerBinDirs: managerBinDirs,
|
|
230
|
+
regExePath: regExePath,
|
|
231
|
+
searchPath: searchPath
|
|
232
|
+
});
|
|
233
|
+
|
|
227
234
|
const COMMAND_TIMEOUT_MS = 5_000;
|
|
228
235
|
const CLASSIC_KEYS = [
|
|
229
236
|
String.raw `HKCU\Software\Classes\claude`,
|
|
@@ -1811,6 +1818,21 @@ function tunnelPreflightEnvOverride(env) {
|
|
|
1811
1818
|
requireAbsolute(raw, "AC_AGENT_TUNNEL_PREFLIGHT");
|
|
1812
1819
|
return raw;
|
|
1813
1820
|
}
|
|
1821
|
+
/** `AC_AGENT_TOOLCHAIN_UPGRADE` (#675): `1`/`true` or `0`/`false` (case-insensitive, surrounding space
|
|
1822
|
+
* ignored); blank or unset is `null` (the file decides); anything else throws, naming the value, like every
|
|
1823
|
+
* other `AC_AGENT_*` override that cannot be read: guessing at an opt-in that installs software would be
|
|
1824
|
+
* the wrong way to fail. */
|
|
1825
|
+
function toolchainUpgradeEnvOverride(env) {
|
|
1826
|
+
const raw = env?.AC_AGENT_TOOLCHAIN_UPGRADE;
|
|
1827
|
+
if (raw === undefined || raw.trim() === "")
|
|
1828
|
+
return null;
|
|
1829
|
+
const value = raw.trim().toLowerCase();
|
|
1830
|
+
if (value === "1" || value === "true")
|
|
1831
|
+
return true;
|
|
1832
|
+
if (value === "0" || value === "false")
|
|
1833
|
+
return false;
|
|
1834
|
+
throw new Error(`AC_AGENT_TOOLCHAIN_UPGRADE must be 1, true, 0 or false, got: ${raw}`);
|
|
1835
|
+
}
|
|
1814
1836
|
/** A `tunnel_config` / `tunnel_preflight` value read from `agent.toml`: `null` for an absent key, a
|
|
1815
1837
|
* non-string or a blank string, else the string as written (the verb refuses a relative one). */
|
|
1816
1838
|
function tunnelPathFromFile(raw) {
|
|
@@ -1837,6 +1859,7 @@ function loadConfig(dir, deps) {
|
|
|
1837
1859
|
const envPublishDir = publishDirEnvOverride(deps.env);
|
|
1838
1860
|
const envTunnelConfig = tunnelConfigEnvOverride(deps.env);
|
|
1839
1861
|
const envTunnelPreflight = tunnelPreflightEnvOverride(deps.env);
|
|
1862
|
+
const envToolchainUpgrade = toolchainUpgradeEnvOverride(deps.env);
|
|
1840
1863
|
const defaultReposRoot = () => path.join(deps.homedir(), "repos");
|
|
1841
1864
|
const defaultClaudeProjectsDir = () => path.join(deps.homedir(), ".claude", "projects");
|
|
1842
1865
|
const defaultPublish = () => defaultPublishDir(deps.platform, deps.env);
|
|
@@ -1852,6 +1875,7 @@ function loadConfig(dir, deps) {
|
|
|
1852
1875
|
publishDir: envPublishDir ?? defaultPublish(),
|
|
1853
1876
|
tunnelConfig: envTunnelConfig,
|
|
1854
1877
|
tunnelPreflight: envTunnelPreflight,
|
|
1878
|
+
toolchainUpgrade: envToolchainUpgrade ?? false,
|
|
1855
1879
|
},
|
|
1856
1880
|
path: configPath,
|
|
1857
1881
|
source: "defaults",
|
|
@@ -1871,6 +1895,7 @@ function loadConfig(dir, deps) {
|
|
|
1871
1895
|
publishDir: envPublishDir ?? (typeof raw.publish_dir === "string" ? raw.publish_dir : defaultPublish()),
|
|
1872
1896
|
tunnelConfig: envTunnelConfig ?? tunnelPathFromFile(raw.tunnel_config),
|
|
1873
1897
|
tunnelPreflight: envTunnelPreflight ?? tunnelPathFromFile(raw.tunnel_preflight),
|
|
1898
|
+
toolchainUpgrade: envToolchainUpgrade ?? raw.toolchain_upgrade === true,
|
|
1874
1899
|
},
|
|
1875
1900
|
path: configPath,
|
|
1876
1901
|
source: "file",
|
|
@@ -1894,6 +1919,9 @@ function writeConfig(dir, config, deps) {
|
|
|
1894
1919
|
toml.tunnel_config = config.tunnelConfig;
|
|
1895
1920
|
if (config.tunnelPreflight !== null)
|
|
1896
1921
|
toml.tunnel_preflight = config.tunnelPreflight;
|
|
1922
|
+
// The opt-in (#675) is written only when it is on: absent means `false`, which is what a fresh file says.
|
|
1923
|
+
if (config.toolchainUpgrade)
|
|
1924
|
+
toml.toolchain_upgrade = true;
|
|
1897
1925
|
deps.writeFile(configPath, stringify$1(toml));
|
|
1898
1926
|
return configPath;
|
|
1899
1927
|
}
|
|
@@ -22298,6 +22326,7 @@ async function status(deps, now = () => new Date()) {
|
|
|
22298
22326
|
reposRoot: loaded.config.reposRoot,
|
|
22299
22327
|
reposCount: listRepos(loaded.config.reposRoot, deps.repoFs).repos.length,
|
|
22300
22328
|
publishDir: loaded.config.publishDir,
|
|
22329
|
+
toolchainUpgrade: loaded.config.toolchainUpgrade,
|
|
22301
22330
|
grants,
|
|
22302
22331
|
virtualized: virtualization.virtualized,
|
|
22303
22332
|
lanAddress: deps.ips[0] ?? null,
|
|
@@ -23090,6 +23119,36 @@ function healthResultFrom(ok, body) {
|
|
|
23090
23119
|
const version = typeof body === "object" && body !== null ? body.version : undefined;
|
|
23091
23120
|
return typeof version === "string" ? { ok, version } : { ok };
|
|
23092
23121
|
}
|
|
23122
|
+
/** What `registerToolchainUpgradeVerb` is given (#675), apart from the process-level pieces `realServe` has
|
|
23123
|
+
* to build itself. Here, and exported, so the wiring that matters (gate 2: the verb is on only when THIS
|
|
23124
|
+
* machine's loaded `agent.toml` says so) is a function with a test, not a line in `realServe` that nothing
|
|
23125
|
+
* but a real run reaches. */
|
|
23126
|
+
function toolchainUpgradeDeps(args) {
|
|
23127
|
+
return {
|
|
23128
|
+
enabled: () => args.loaded.config.toolchainUpgrade,
|
|
23129
|
+
platform: args.platform,
|
|
23130
|
+
which: args.which,
|
|
23131
|
+
searchPath: args.searchPath,
|
|
23132
|
+
run: args.run,
|
|
23133
|
+
jobs: args.jobs,
|
|
23134
|
+
username: () => {
|
|
23135
|
+
try {
|
|
23136
|
+
return args.userInfo().username;
|
|
23137
|
+
}
|
|
23138
|
+
catch {
|
|
23139
|
+
return "<user>";
|
|
23140
|
+
}
|
|
23141
|
+
},
|
|
23142
|
+
cwd: args.homedir,
|
|
23143
|
+
logEvent: args.logEvent,
|
|
23144
|
+
};
|
|
23145
|
+
}
|
|
23146
|
+
/** How many jobs `POST /v1/update` must refuse to restart the agent under: every kind that is running, plus
|
|
23147
|
+
* an upgrade that has passed its cap check but not yet started its job (#675; its pre-spawn waits can take
|
|
23148
|
+
* tens of seconds, during which no job exists). */
|
|
23149
|
+
function runningJobCount(jobs, upgrade) {
|
|
23150
|
+
return () => jobs.runningCount() + upgrade.pendingStarts();
|
|
23151
|
+
}
|
|
23093
23152
|
async function buildServiceDeps(overrides = {}) {
|
|
23094
23153
|
const os = await import('node:os');
|
|
23095
23154
|
const fs = await import('node:fs');
|
|
@@ -23217,6 +23276,8 @@ async function realServe(overrides = {}) {
|
|
|
23217
23276
|
const { realPublishFsIo } = await Promise.resolve().then(function () { return publish$1; });
|
|
23218
23277
|
const { registerPublishVerb } = await Promise.resolve().then(function () { return publish; });
|
|
23219
23278
|
const { registerTunnelVerb, realTunnelFs } = await Promise.resolve().then(function () { return tunnel; });
|
|
23279
|
+
const { registerToolchainUpgradeVerb } = await Promise.resolve().then(function () { return toolchainUpgrade; });
|
|
23280
|
+
const { searchPath } = await Promise.resolve().then(function () { return searchPath$1; });
|
|
23220
23281
|
// `--config-dir`/`--state-dir` (E4 finding 13, #241) are what `install` bakes into the registered
|
|
23221
23282
|
// task/unit, so a `serve` launched that way never falls back to re-resolving from its own
|
|
23222
23283
|
// environment (a Task Scheduler process / systemd --user unit does not see the installing
|
|
@@ -23425,6 +23486,20 @@ async function realServe(overrides = {}) {
|
|
|
23425
23486
|
which: which$1,
|
|
23426
23487
|
});
|
|
23427
23488
|
registerGhVerb(registry$3, { run, which: which$1, logEvent: (message) => log$1.write(message) });
|
|
23489
|
+
// The toolchain upgrade (#675): installs software on THIS machine, so it needs the console's grant AND
|
|
23490
|
+
// this machine's own `toolchain_upgrade = true` (read from the config loaded at start-up). Registered
|
|
23491
|
+
// here, not beside the tunnel verb, because it shares the job registry created just above.
|
|
23492
|
+
const toolchainUpgrade$1 = registerToolchainUpgradeVerb(registry$3, toolchainUpgradeDeps({
|
|
23493
|
+
loaded,
|
|
23494
|
+
platform: process.platform,
|
|
23495
|
+
which: which$1,
|
|
23496
|
+
searchPath: async () => (await searchPath({ platform: process.platform, env: process.env, run })).path,
|
|
23497
|
+
run,
|
|
23498
|
+
jobs,
|
|
23499
|
+
userInfo: () => os.userInfo(),
|
|
23500
|
+
homedir: () => os.homedir(),
|
|
23501
|
+
logEvent: (message) => log$1.write(message),
|
|
23502
|
+
}));
|
|
23428
23503
|
const readGrantsResult = readGrants(dirs.stateDir, readFile);
|
|
23429
23504
|
if (readGrantsResult.kind === "malformed") {
|
|
23430
23505
|
log$1.write(`grants.json unreadable (${readGrantsResult.error}), starting with no grants`);
|
|
@@ -23483,7 +23558,7 @@ async function realServe(overrides = {}) {
|
|
|
23483
23558
|
env: process.env,
|
|
23484
23559
|
}),
|
|
23485
23560
|
selfUpdateSupported: selfUpdateSupported(process.platform),
|
|
23486
|
-
runningJobs: (
|
|
23561
|
+
runningJobs: runningJobCount(jobs, toolchainUpgrade$1),
|
|
23487
23562
|
now: () => new Date(),
|
|
23488
23563
|
logEvent: (message) => log$1.write(message),
|
|
23489
23564
|
});
|
|
@@ -64427,6 +64502,39 @@ var lock = /*#__PURE__*/Object.freeze({
|
|
|
64427
64502
|
/** `agent.log`, rotated to `agent.log.1` past 1 MiB on open -- checked once at `serve` startup,
|
|
64428
64503
|
* not on every write, so a long-running agent's own line-by-line appends stay cheap. */
|
|
64429
64504
|
const MAX_SIZE_BYTES = 1024 * 1024;
|
|
64505
|
+
/** The characters that end a line, rewrite one, or reorder one: every C0 and C1 control character and DEL
|
|
64506
|
+
* (`\n`, `\r`, an ANSI escape that moves the cursor up and erases), U+2028 and U+2029 (the line and
|
|
64507
|
+
* paragraph separators that Python's `splitlines` and most editors break on), and the bidirectional
|
|
64508
|
+
* formatting controls U+202A-202E and U+2066-2069 (which reorder what a terminal shows). */
|
|
64509
|
+
function isLineBreaking(code) {
|
|
64510
|
+
return (code <= 0x1f ||
|
|
64511
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
64512
|
+
code === 0x2028 ||
|
|
64513
|
+
code === 0x2029 ||
|
|
64514
|
+
(code >= 0x202a && code <= 0x202e) ||
|
|
64515
|
+
(code >= 0x2066 && code <= 0x2069));
|
|
64516
|
+
}
|
|
64517
|
+
/** One log entry is one line, whatever it was built from (#675): a request line carries the request's
|
|
64518
|
+
* own path, which an unauthenticated caller chooses and which arrives percent-decoded, so `%0A` would
|
|
64519
|
+
* otherwise start a line of the caller's own wording (a forged audit line) and an escape sequence would
|
|
64520
|
+
* erase real ones on the screen of whoever reads the file. Each such character is written as `\xNN`, or
|
|
64521
|
+
* `\uNNNN` above U+00FF. */
|
|
64522
|
+
function oneLine(line) {
|
|
64523
|
+
let out = "";
|
|
64524
|
+
for (const ch of line) {
|
|
64525
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
64526
|
+
if (!isLineBreaking(code)) {
|
|
64527
|
+
out += ch;
|
|
64528
|
+
}
|
|
64529
|
+
else if (code <= 0xff) {
|
|
64530
|
+
out += `\\x${code.toString(16).padStart(2, "0")}`;
|
|
64531
|
+
}
|
|
64532
|
+
else {
|
|
64533
|
+
out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
64534
|
+
}
|
|
64535
|
+
}
|
|
64536
|
+
return out;
|
|
64537
|
+
}
|
|
64430
64538
|
function openLog(stateDir, deps) {
|
|
64431
64539
|
const logPath = path.join(stateDir, "agent.log");
|
|
64432
64540
|
const rotatedPath = path.join(stateDir, "agent.log.1");
|
|
@@ -64435,7 +64543,7 @@ function openLog(stateDir, deps) {
|
|
|
64435
64543
|
deps.rename(logPath, rotatedPath);
|
|
64436
64544
|
}
|
|
64437
64545
|
return {
|
|
64438
|
-
write: (line) => deps.appendFile(logPath, `${line}\n`),
|
|
64546
|
+
write: (line) => deps.appendFile(logPath, `${oneLine(line)}\n`),
|
|
64439
64547
|
};
|
|
64440
64548
|
}
|
|
64441
64549
|
|
|
@@ -64762,6 +64870,100 @@ const streamSSE = (c, cb, onError) => {
|
|
|
64762
64870
|
return c.newResponse(stream.responseReadable);
|
|
64763
64871
|
};
|
|
64764
64872
|
|
|
64873
|
+
/**
|
|
64874
|
+
* The agent's job-events SSE response, shared by every verb that exposes a job stream (#49 49b for the
|
|
64875
|
+
* claude jobs; #675 extracted it so `GET /v1/toolchain/upgrade/:jobId/events` is the same algorithm, not
|
|
64876
|
+
* a copy). The route is the host's own (`packages/host/src/tasks/route.ts:14-90`) with `exitCode` added to
|
|
64877
|
+
* the `done` frame -- `line` and `done` are the exact event names the host emits and the shell's
|
|
64878
|
+
* `useTasks.ts` listens for (E4 §5 decision 12).
|
|
64879
|
+
*
|
|
64880
|
+
* `kind` is the capability boundary: a verb serves only its own kind's jobs, so a job of another kind is
|
|
64881
|
+
* `404 no such job`, exactly like an unknown id. A plugin holding `agent.exec.claude` therefore never
|
|
64882
|
+
* reads an upgrade job's log, and one holding `agent.toolchain.upgrade` never reads a claude job's.
|
|
64883
|
+
*/
|
|
64884
|
+
async function jobEventsResponse(jobs, id, c, kind) {
|
|
64885
|
+
const initial = jobs.get(id);
|
|
64886
|
+
if (!initial || initial.view.kind !== kind)
|
|
64887
|
+
return c.json({ ok: false, error: "no such job" }, 404);
|
|
64888
|
+
// streamSSE sets Cache-Control: no-cache itself -- only X-Accel-Buffering (disables a
|
|
64889
|
+
// fronting proxy's response buffering, so lines aren't held back) needs setting explicitly.
|
|
64890
|
+
c.header("X-Accel-Buffering", "no");
|
|
64891
|
+
return streamSSE(c, async (stream) => {
|
|
64892
|
+
if (initial.view.status !== "running") {
|
|
64893
|
+
for (let i = 0; i < initial.lines.length; i++) {
|
|
64894
|
+
if (stream.aborted)
|
|
64895
|
+
return;
|
|
64896
|
+
await stream.writeSSE({
|
|
64897
|
+
event: "line",
|
|
64898
|
+
data: JSON.stringify(initial.lines[i]),
|
|
64899
|
+
id: String(initial.linesOffset + i),
|
|
64900
|
+
});
|
|
64901
|
+
}
|
|
64902
|
+
await stream.writeSSE({
|
|
64903
|
+
event: "done",
|
|
64904
|
+
data: JSON.stringify({
|
|
64905
|
+
status: initial.view.status,
|
|
64906
|
+
exitCode: initial.view.exitCode ?? null,
|
|
64907
|
+
}),
|
|
64908
|
+
});
|
|
64909
|
+
return;
|
|
64910
|
+
}
|
|
64911
|
+
// Subscribe BEFORE replaying, synchronously with the `initial` snapshot above -- see
|
|
64912
|
+
// packages/host/src/tasks/route.ts:41-48 for why this ordering is what closes the race
|
|
64913
|
+
// window between a line being logged and it landing in neither the snapshot nor the
|
|
64914
|
+
// subscription.
|
|
64915
|
+
let replaying = true;
|
|
64916
|
+
const queued = [];
|
|
64917
|
+
let resolveDone;
|
|
64918
|
+
const donePromise = new Promise((resolve) => {
|
|
64919
|
+
resolveDone = resolve;
|
|
64920
|
+
});
|
|
64921
|
+
const unsubscribe = jobs.subscribe(id, {
|
|
64922
|
+
line: (line, lineId) => {
|
|
64923
|
+
if (replaying)
|
|
64924
|
+
queued.push({ line, id: lineId });
|
|
64925
|
+
else
|
|
64926
|
+
void stream.writeSSE({
|
|
64927
|
+
event: "line",
|
|
64928
|
+
data: JSON.stringify(line),
|
|
64929
|
+
id: String(lineId),
|
|
64930
|
+
});
|
|
64931
|
+
},
|
|
64932
|
+
done: (status, exitCode) => resolveDone({ status, exitCode }),
|
|
64933
|
+
});
|
|
64934
|
+
stream.onAbort(unsubscribe);
|
|
64935
|
+
for (let i = 0; i < initial.lines.length; i++) {
|
|
64936
|
+
if (stream.aborted)
|
|
64937
|
+
return;
|
|
64938
|
+
await stream.writeSSE({
|
|
64939
|
+
event: "line",
|
|
64940
|
+
data: JSON.stringify(initial.lines[i]),
|
|
64941
|
+
id: String(initial.linesOffset + i),
|
|
64942
|
+
});
|
|
64943
|
+
}
|
|
64944
|
+
for (const q of queued) {
|
|
64945
|
+
if (stream.aborted)
|
|
64946
|
+
return;
|
|
64947
|
+
await stream.writeSSE({ event: "line", data: JSON.stringify(q.line), id: String(q.id) });
|
|
64948
|
+
}
|
|
64949
|
+
replaying = false;
|
|
64950
|
+
const final = await donePromise;
|
|
64951
|
+
unsubscribe();
|
|
64952
|
+
await stream.writeSSE({
|
|
64953
|
+
event: "done",
|
|
64954
|
+
data: JSON.stringify({ status: final.status, exitCode: final.exitCode }),
|
|
64955
|
+
});
|
|
64956
|
+
});
|
|
64957
|
+
}
|
|
64958
|
+
|
|
64959
|
+
/** How long a terminated child is given on POSIX before `SIGKILL` follows the polite `SIGTERM`. */
|
|
64960
|
+
const KILL_GRACE_MS = 10_000;
|
|
64961
|
+
/** How long after a limit fires the job waits for the child to close before it is finished regardless
|
|
64962
|
+
* (the grace period above, and five seconds more). */
|
|
64963
|
+
const LIMIT_SETTLE_MS = KILL_GRACE_MS + 5_000;
|
|
64964
|
+
/** How much a forced finish discards from a pipe it could not close before it closes it (see `forceFinish`):
|
|
64965
|
+
* generous for any process that merely logs, a small fraction of what a flood writes in a second. */
|
|
64966
|
+
const DRAIN_BUDGET_BYTES = 64 * 1024 * 1024;
|
|
64765
64967
|
function spawnJob(argv, options, handle, spawnFn = spawn) {
|
|
64766
64968
|
const [cmd, ...args] = argv;
|
|
64767
64969
|
handle.log(`${options.header} in ${options.cwd}`);
|
|
@@ -64774,25 +64976,152 @@ function spawnJob(argv, options, handle, spawnFn = spawn) {
|
|
|
64774
64976
|
});
|
|
64775
64977
|
let stdoutBuf = "";
|
|
64776
64978
|
let stderrBuf = "";
|
|
64979
|
+
let settled = false;
|
|
64980
|
+
let limitHit = false;
|
|
64981
|
+
let exited = false;
|
|
64982
|
+
let exitCode = null;
|
|
64983
|
+
let loggedChars = 0;
|
|
64984
|
+
let graceTimer;
|
|
64985
|
+
let timeoutTimer;
|
|
64986
|
+
let settleTimer;
|
|
64987
|
+
let exitTimer;
|
|
64988
|
+
/** The one place a limit fires: tell the caller, say so in the log, and kill the child. A child that has
|
|
64989
|
+
* already exited is not killed (its pid may belong to another process by now, and `taskkill /t /f` would
|
|
64990
|
+
* take that one and everything it started): the limit is recorded, the rest of the output dropped, and
|
|
64991
|
+
* the job finishes when the output closes or `exitSettleMs` runs out. */
|
|
64992
|
+
function hitLimit(limit, message) {
|
|
64993
|
+
if (limitHit || settled)
|
|
64994
|
+
return;
|
|
64995
|
+
limitHit = true;
|
|
64996
|
+
options.onLimit?.(limit);
|
|
64997
|
+
handle.log(message);
|
|
64998
|
+
if (exited)
|
|
64999
|
+
return;
|
|
65000
|
+
killChild(child, spawnFn);
|
|
65001
|
+
if (process.platform !== "win32") {
|
|
65002
|
+
// SIGTERM asks politely; a child that ignores it (an installer mid-transaction) is killed outright.
|
|
65003
|
+
graceTimer = setTimeout(() => {
|
|
65004
|
+
if (!settled && !exited)
|
|
65005
|
+
child.kill("SIGKILL");
|
|
65006
|
+
}, KILL_GRACE_MS);
|
|
65007
|
+
graceTimer.unref?.();
|
|
65008
|
+
}
|
|
65009
|
+
settleTimer = setTimeout(forceFinish, options.limitSettleMs ?? LIMIT_SETTLE_MS);
|
|
65010
|
+
settleTimer.unref?.();
|
|
65011
|
+
}
|
|
65012
|
+
/** The child never closed (a descendant, out of the agent's reach, still holds its output pipes): finish
|
|
65013
|
+
* the job anyway so it cannot stay `running` and hold its slot forever. A child that had exited keeps
|
|
65014
|
+
* its real exit code (a killed one that exited is the same: its code is whatever the kill left, `null`
|
|
65015
|
+
* for a signal); one that was killed and never even exited finishes `failed` with none.
|
|
65016
|
+
*
|
|
65017
|
+
* The streams are DRAINED from here on rather than destroyed: closing the read end of a pipe another
|
|
65018
|
+
* process still holds makes that process's next write raise SIGPIPE, which by default ends it, and these
|
|
65019
|
+
* are the processes the agent must not disturb (a root-owned `apt-get` in the middle of a dpkg
|
|
65020
|
+
* transaction, a service a maintainer script started). What they write is discarded. The drain has a
|
|
65021
|
+
* budget (`drainBudgetBytes`): a descendant that FLOODS the pipe costs the agent most of a core for as
|
|
65022
|
+
* long as it runs, so past the budget the streams are destroyed after all, which ends only that one. */
|
|
65023
|
+
function forceFinish() {
|
|
65024
|
+
if (settled)
|
|
65025
|
+
return;
|
|
65026
|
+
settled = true;
|
|
65027
|
+
stopTimers();
|
|
65028
|
+
const budget = options.drainBudgetBytes ?? DRAIN_BUDGET_BYTES;
|
|
65029
|
+
let drained = 0;
|
|
65030
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
65031
|
+
stream?.removeAllListeners("data");
|
|
65032
|
+
stream?.on("data", (chunk) => {
|
|
65033
|
+
drained += chunk.length;
|
|
65034
|
+
if (drained > budget) {
|
|
65035
|
+
child.stdout?.destroy();
|
|
65036
|
+
child.stderr?.destroy();
|
|
65037
|
+
}
|
|
65038
|
+
});
|
|
65039
|
+
stream?.resume();
|
|
65040
|
+
}
|
|
65041
|
+
if (exited) {
|
|
65042
|
+
if (stdoutBuf !== "")
|
|
65043
|
+
emit(stdoutBuf);
|
|
65044
|
+
if (stderrBuf !== "")
|
|
65045
|
+
emit(stderrBuf);
|
|
65046
|
+
handle.log("[the command exited, but its output never closed; processes it started may still be running]");
|
|
65047
|
+
handle.finish(exitCode === 0 && !limitHit ? "done" : "failed", exitCode);
|
|
65048
|
+
return;
|
|
65049
|
+
}
|
|
65050
|
+
handle.log("[killed, but its output never closed; processes it started may still be running]");
|
|
65051
|
+
handle.finish("failed", null);
|
|
65052
|
+
}
|
|
65053
|
+
/** Every line the child prints goes through here, so the output cap counts exactly what is logged. */
|
|
65054
|
+
function emit(line) {
|
|
65055
|
+
if (limitHit)
|
|
65056
|
+
return; // after a limit fires, the rest of the output is dropped, not logged
|
|
65057
|
+
loggedChars += line.length + 1;
|
|
65058
|
+
if (options.maxOutputChars !== undefined && loggedChars > options.maxOutputChars) {
|
|
65059
|
+
hitLimit("output-cap", `[output capped at ${options.maxOutputChars} characters; killed]`);
|
|
65060
|
+
return;
|
|
65061
|
+
}
|
|
65062
|
+
handle.log(line);
|
|
65063
|
+
}
|
|
64777
65064
|
function flush(buf, chunk) {
|
|
64778
65065
|
const combined = buf + chunk;
|
|
64779
65066
|
const lines = combined.split("\n");
|
|
64780
65067
|
const remainder = lines.pop() ?? "";
|
|
64781
65068
|
for (const line of lines)
|
|
64782
|
-
|
|
65069
|
+
emit(line);
|
|
64783
65070
|
return remainder;
|
|
64784
65071
|
}
|
|
65072
|
+
/** The cap also counts what is held back waiting for a newline: a child that prints without ever ending
|
|
65073
|
+
* a line (a progress bar redrawn with `\r`, or a runaway) must not grow the agent's memory unboundedly
|
|
65074
|
+
* just because `emit` has not seen a line yet. */
|
|
65075
|
+
function checkHeldOutput() {
|
|
65076
|
+
const cap = options.maxOutputChars;
|
|
65077
|
+
if (cap !== undefined && loggedChars + stdoutBuf.length + stderrBuf.length > cap) {
|
|
65078
|
+
hitLimit("output-cap", `[output capped at ${cap} characters; killed]`);
|
|
65079
|
+
}
|
|
65080
|
+
}
|
|
64785
65081
|
child.stdout?.on("data", (chunk) => {
|
|
65082
|
+
if (limitHit)
|
|
65083
|
+
return;
|
|
64786
65084
|
stdoutBuf = flush(stdoutBuf, chunk.toString());
|
|
65085
|
+
checkHeldOutput();
|
|
64787
65086
|
});
|
|
64788
65087
|
child.stderr?.on("data", (chunk) => {
|
|
65088
|
+
if (limitHit)
|
|
65089
|
+
return;
|
|
64789
65090
|
stderrBuf = flush(stderrBuf, chunk.toString());
|
|
65091
|
+
checkHeldOutput();
|
|
65092
|
+
});
|
|
65093
|
+
if (options.timeoutMs !== undefined) {
|
|
65094
|
+
const ms = options.timeoutMs;
|
|
65095
|
+
timeoutTimer = setTimeout(() => hitLimit("timeout", `[timed out after ${ms} ms; killed]`), ms);
|
|
65096
|
+
timeoutTimer.unref?.();
|
|
65097
|
+
}
|
|
65098
|
+
function stopTimers() {
|
|
65099
|
+
if (timeoutTimer !== undefined)
|
|
65100
|
+
clearTimeout(timeoutTimer);
|
|
65101
|
+
if (graceTimer !== undefined)
|
|
65102
|
+
clearTimeout(graceTimer);
|
|
65103
|
+
if (settleTimer !== undefined)
|
|
65104
|
+
clearTimeout(settleTimer);
|
|
65105
|
+
}
|
|
65106
|
+
child.on("exit", (code) => {
|
|
65107
|
+
if (settled)
|
|
65108
|
+
return;
|
|
65109
|
+
exited = true;
|
|
65110
|
+
exitCode = code;
|
|
65111
|
+
// The command is over: nothing is left to time out, and a late kill is unsafe (pid reuse). The grace
|
|
65112
|
+
// timer, if one is pending, checks `exited` itself; `forceFinish` returns once `settled`.
|
|
65113
|
+
if (timeoutTimer !== undefined)
|
|
65114
|
+
clearTimeout(timeoutTimer);
|
|
65115
|
+
if (options.exitSettleMs !== undefined) {
|
|
65116
|
+
exitTimer = setTimeout(forceFinish, options.exitSettleMs);
|
|
65117
|
+
exitTimer.unref?.();
|
|
65118
|
+
}
|
|
64790
65119
|
});
|
|
64791
|
-
let settled = false;
|
|
64792
65120
|
child.on("error", (err) => {
|
|
64793
65121
|
if (settled)
|
|
64794
65122
|
return;
|
|
64795
65123
|
settled = true;
|
|
65124
|
+
stopTimers();
|
|
64796
65125
|
handle.log(`[spawn failed] ${err.message}`);
|
|
64797
65126
|
handle.finish("failed", null);
|
|
64798
65127
|
});
|
|
@@ -64800,22 +65129,47 @@ function spawnJob(argv, options, handle, spawnFn = spawn) {
|
|
|
64800
65129
|
if (settled)
|
|
64801
65130
|
return;
|
|
64802
65131
|
settled = true;
|
|
65132
|
+
stopTimers();
|
|
64803
65133
|
// A trailing partial line (no final newline) is flushed here rather than dropped.
|
|
64804
65134
|
if (stdoutBuf !== "")
|
|
64805
|
-
|
|
65135
|
+
emit(stdoutBuf);
|
|
64806
65136
|
if (stderrBuf !== "")
|
|
64807
|
-
|
|
64808
|
-
handle.finish(code === 0 ? "done" : "failed", code);
|
|
65137
|
+
emit(stderrBuf);
|
|
65138
|
+
handle.finish(code === 0 && !limitHit ? "done" : "failed", code);
|
|
64809
65139
|
});
|
|
64810
65140
|
}
|
|
65141
|
+
/** Kill a child and, on win32, everything it started: `taskkill /pid <pid> /t /f` (the shell is never
|
|
65142
|
+
* involved; a failure to launch it is swallowed because `child.kill()` below is the fallback). */
|
|
65143
|
+
function killChild(child, spawnFn) {
|
|
65144
|
+
if (process.platform === "win32" && child.pid !== undefined) {
|
|
65145
|
+
try {
|
|
65146
|
+
// By absolute path (as `regExePath` does for reg.exe): a bare name is searched for in the working
|
|
65147
|
+
// directory before PATH.
|
|
65148
|
+
const taskkill = path.win32.join(process.env.SystemRoot ?? process.env.windir ?? String.raw `C:\Windows`, "System32", "taskkill.exe");
|
|
65149
|
+
const killer = spawnFn(taskkill, ["/pid", String(child.pid), "/t", "/f"], {
|
|
65150
|
+
shell: false,
|
|
65151
|
+
windowsHide: true,
|
|
65152
|
+
stdio: "ignore",
|
|
65153
|
+
});
|
|
65154
|
+
killer.on("error", () => { });
|
|
65155
|
+
}
|
|
65156
|
+
catch {
|
|
65157
|
+
// fall through to the plain kill
|
|
65158
|
+
}
|
|
65159
|
+
}
|
|
65160
|
+
child.kill();
|
|
65161
|
+
}
|
|
64811
65162
|
|
|
64812
65163
|
/**
|
|
64813
65164
|
* `POST /v1/exec/claude`, `GET /v1/jobs`, `GET /v1/jobs/:id`, `POST /v1/jobs/clear-finished`,
|
|
64814
|
-
* `GET /v1/jobs/:id/events` (#49, 49b, items 3-5). The SSE route is the host's own
|
|
64815
|
-
*
|
|
64816
|
-
*
|
|
64817
|
-
* (
|
|
64818
|
-
*
|
|
65165
|
+
* `GET /v1/jobs/:id/events` (#49, 49b, items 3-5). The SSE route is `jobs/events.ts`'s (the host's own
|
|
65166
|
+
* algorithm, `packages/host/src/tasks/route.ts:14-90`, with `exitCode` added to the `done` frame; #675
|
|
65167
|
+
* extracted it so the upgrade verb shares it), so a browser `EventSource` deliberately isn't the client
|
|
65168
|
+
* here (the token travels as a header; the host's pinned client is what actually calls this).
|
|
65169
|
+
*
|
|
65170
|
+
* Every route here serves the `claude` kind only (#675): the registry also holds `toolchain-upgrade` jobs,
|
|
65171
|
+
* which `agent.exec.claude` must not list, read, stream or clear, and which must not use up one of the
|
|
65172
|
+
* claude jobs' four slots (`runningCount("claude")`).
|
|
64819
65173
|
*/
|
|
64820
65174
|
const MAX_PROMPT_LENGTH = 8192;
|
|
64821
65175
|
const PERMISSION_MODES = ["plan", "acceptEdits", "bypassPermissions"];
|
|
@@ -64894,7 +65248,7 @@ function registerClaudeVerbs(registry, deps) {
|
|
|
64894
65248
|
: "claude not found on PATH";
|
|
64895
65249
|
return c.json({ ok: false, error }, 502);
|
|
64896
65250
|
}
|
|
64897
|
-
if (deps.jobs.runningCount() >= MAX_RUNNING_CLAUDE_JOBS) {
|
|
65251
|
+
if (deps.jobs.runningCount("claude") >= MAX_RUNNING_CLAUDE_JOBS) {
|
|
64898
65252
|
return c.json({ ok: false, error: "too many running jobs" }, 429);
|
|
64899
65253
|
}
|
|
64900
65254
|
const argv = [
|
|
@@ -64925,7 +65279,7 @@ function registerClaudeVerbs(registry, deps) {
|
|
|
64925
65279
|
capability: "agent.exec.claude",
|
|
64926
65280
|
method: "GET",
|
|
64927
65281
|
path: "/jobs",
|
|
64928
|
-
handler: async (c) => c.json({ ok: true, jobs: deps.jobs.list() }),
|
|
65282
|
+
handler: async (c) => c.json({ ok: true, jobs: deps.jobs.list().filter((job) => job.kind === "claude") }),
|
|
64929
65283
|
});
|
|
64930
65284
|
registry.register({
|
|
64931
65285
|
capability: "agent.exec.claude",
|
|
@@ -64933,8 +65287,10 @@ function registerClaudeVerbs(registry, deps) {
|
|
|
64933
65287
|
path: "/jobs/:id",
|
|
64934
65288
|
handler: async (c) => {
|
|
64935
65289
|
const entry = deps.jobs.get(c.req.param("id") ?? "");
|
|
64936
|
-
|
|
65290
|
+
// Another kind's job (#675) is "no such job" here, exactly like an unknown id.
|
|
65291
|
+
if (entry?.view.kind !== "claude") {
|
|
64937
65292
|
return c.json({ ok: false, error: "no such job" }, 404);
|
|
65293
|
+
}
|
|
64938
65294
|
return c.json({ ok: true, job: entry.view });
|
|
64939
65295
|
},
|
|
64940
65296
|
});
|
|
@@ -64942,87 +65298,13 @@ function registerClaudeVerbs(registry, deps) {
|
|
|
64942
65298
|
capability: "agent.exec.claude",
|
|
64943
65299
|
method: "POST",
|
|
64944
65300
|
path: "/jobs/clear-finished",
|
|
64945
|
-
handler: async (c) => c.json({ ok: true, cleared: deps.jobs.clearFinished() }),
|
|
65301
|
+
handler: async (c) => c.json({ ok: true, cleared: deps.jobs.clearFinished("claude") }),
|
|
64946
65302
|
});
|
|
64947
65303
|
registry.register({
|
|
64948
65304
|
capability: "agent.exec.claude",
|
|
64949
65305
|
method: "GET",
|
|
64950
65306
|
path: "/jobs/:id/events",
|
|
64951
|
-
handler: async (c) =>
|
|
64952
|
-
const id = c.req.param("id") ?? "";
|
|
64953
|
-
const initial = deps.jobs.get(id);
|
|
64954
|
-
if (!initial)
|
|
64955
|
-
return c.json({ ok: false, error: "no such job" }, 404);
|
|
64956
|
-
// streamSSE sets Cache-Control: no-cache itself -- only X-Accel-Buffering (disables a
|
|
64957
|
-
// fronting proxy's response buffering, so lines aren't held back) needs setting explicitly.
|
|
64958
|
-
c.header("X-Accel-Buffering", "no");
|
|
64959
|
-
return streamSSE(c, async (stream) => {
|
|
64960
|
-
if (initial.view.status !== "running") {
|
|
64961
|
-
for (let i = 0; i < initial.lines.length; i++) {
|
|
64962
|
-
if (stream.aborted)
|
|
64963
|
-
return;
|
|
64964
|
-
await stream.writeSSE({
|
|
64965
|
-
event: "line",
|
|
64966
|
-
data: JSON.stringify(initial.lines[i]),
|
|
64967
|
-
id: String(initial.linesOffset + i),
|
|
64968
|
-
});
|
|
64969
|
-
}
|
|
64970
|
-
await stream.writeSSE({
|
|
64971
|
-
event: "done",
|
|
64972
|
-
data: JSON.stringify({
|
|
64973
|
-
status: initial.view.status,
|
|
64974
|
-
exitCode: initial.view.exitCode ?? null,
|
|
64975
|
-
}),
|
|
64976
|
-
});
|
|
64977
|
-
return;
|
|
64978
|
-
}
|
|
64979
|
-
// Subscribe BEFORE replaying, synchronously with the `initial` snapshot above -- see
|
|
64980
|
-
// packages/host/src/tasks/route.ts:41-48 for why this ordering is what closes the race
|
|
64981
|
-
// window between a line being logged and it landing in neither the snapshot nor the
|
|
64982
|
-
// subscription.
|
|
64983
|
-
let replaying = true;
|
|
64984
|
-
const queued = [];
|
|
64985
|
-
let resolveDone;
|
|
64986
|
-
const donePromise = new Promise((resolve) => {
|
|
64987
|
-
resolveDone = resolve;
|
|
64988
|
-
});
|
|
64989
|
-
const unsubscribe = deps.jobs.subscribe(id, {
|
|
64990
|
-
line: (line, lineId) => {
|
|
64991
|
-
if (replaying)
|
|
64992
|
-
queued.push({ line, id: lineId });
|
|
64993
|
-
else
|
|
64994
|
-
void stream.writeSSE({
|
|
64995
|
-
event: "line",
|
|
64996
|
-
data: JSON.stringify(line),
|
|
64997
|
-
id: String(lineId),
|
|
64998
|
-
});
|
|
64999
|
-
},
|
|
65000
|
-
done: (status, exitCode) => resolveDone({ status, exitCode }),
|
|
65001
|
-
});
|
|
65002
|
-
stream.onAbort(unsubscribe);
|
|
65003
|
-
for (let i = 0; i < initial.lines.length; i++) {
|
|
65004
|
-
if (stream.aborted)
|
|
65005
|
-
return;
|
|
65006
|
-
await stream.writeSSE({
|
|
65007
|
-
event: "line",
|
|
65008
|
-
data: JSON.stringify(initial.lines[i]),
|
|
65009
|
-
id: String(initial.linesOffset + i),
|
|
65010
|
-
});
|
|
65011
|
-
}
|
|
65012
|
-
for (const q of queued) {
|
|
65013
|
-
if (stream.aborted)
|
|
65014
|
-
return;
|
|
65015
|
-
await stream.writeSSE({ event: "line", data: JSON.stringify(q.line), id: String(q.id) });
|
|
65016
|
-
}
|
|
65017
|
-
replaying = false;
|
|
65018
|
-
const final = await donePromise;
|
|
65019
|
-
unsubscribe();
|
|
65020
|
-
await stream.writeSSE({
|
|
65021
|
-
event: "done",
|
|
65022
|
-
data: JSON.stringify({ status: final.status, exitCode: final.exitCode }),
|
|
65023
|
-
});
|
|
65024
|
-
});
|
|
65025
|
-
},
|
|
65307
|
+
handler: async (c) => jobEventsResponse(deps.jobs, c.req.param("id") ?? "", c, "claude"),
|
|
65026
65308
|
});
|
|
65027
65309
|
}
|
|
65028
65310
|
|
|
@@ -65374,15 +65656,18 @@ function createJobRegistry(opts = {}) {
|
|
|
65374
65656
|
// it forward to find the oldest finished entry.
|
|
65375
65657
|
const entries = new Map();
|
|
65376
65658
|
let seq = 0;
|
|
65377
|
-
|
|
65659
|
+
/** The cap is per kind (#675): a plugin holding only `agent.exec.claude` that runs a few hundred
|
|
65660
|
+
* trivial jobs must not evict a finished upgrade job (and its outcome) the console has yet to read. */
|
|
65661
|
+
function evictOldestFinishedIfOverCap(kind) {
|
|
65378
65662
|
let finishedCount = 0;
|
|
65379
|
-
for (const e of entries.values())
|
|
65380
|
-
if (e.view.status !== "running")
|
|
65663
|
+
for (const e of entries.values()) {
|
|
65664
|
+
if (e.view.status !== "running" && e.view.kind === kind)
|
|
65381
65665
|
finishedCount++;
|
|
65666
|
+
}
|
|
65382
65667
|
if (finishedCount <= maxFinished)
|
|
65383
65668
|
return;
|
|
65384
65669
|
for (const [id, e] of entries) {
|
|
65385
|
-
if (e.view.status !== "running") {
|
|
65670
|
+
if (e.view.status !== "running" && e.view.kind === kind) {
|
|
65386
65671
|
entries.delete(id);
|
|
65387
65672
|
return;
|
|
65388
65673
|
}
|
|
@@ -65437,7 +65722,7 @@ function createJobRegistry(opts = {}) {
|
|
|
65437
65722
|
}
|
|
65438
65723
|
}
|
|
65439
65724
|
entry.subscribers.clear();
|
|
65440
|
-
evictOldestFinishedIfOverCap();
|
|
65725
|
+
evictOldestFinishedIfOverCap(entry.view.kind);
|
|
65441
65726
|
},
|
|
65442
65727
|
};
|
|
65443
65728
|
}
|
|
@@ -65465,17 +65750,18 @@ function createJobRegistry(opts = {}) {
|
|
|
65465
65750
|
entry.subscribers.add(on);
|
|
65466
65751
|
return () => entry.subscribers.delete(on);
|
|
65467
65752
|
},
|
|
65468
|
-
runningCount: () => {
|
|
65753
|
+
runningCount: (kind) => {
|
|
65469
65754
|
let count = 0;
|
|
65470
|
-
for (const e of entries.values())
|
|
65471
|
-
if (e.view.status === "running")
|
|
65755
|
+
for (const e of entries.values()) {
|
|
65756
|
+
if (e.view.status === "running" && (kind === undefined || e.view.kind === kind))
|
|
65472
65757
|
count++;
|
|
65758
|
+
}
|
|
65473
65759
|
return count;
|
|
65474
65760
|
},
|
|
65475
|
-
clearFinished: () => {
|
|
65761
|
+
clearFinished: (kind) => {
|
|
65476
65762
|
let cleared = 0;
|
|
65477
65763
|
for (const [id, e] of entries) {
|
|
65478
|
-
if (e.view.status !== "running") {
|
|
65764
|
+
if (e.view.status !== "running" && (kind === undefined || e.view.kind === kind)) {
|
|
65479
65765
|
entries.delete(id);
|
|
65480
65766
|
cleared++;
|
|
65481
65767
|
}
|
|
@@ -66559,14 +66845,15 @@ function collect(root, previous, signal, fs, now) {
|
|
|
66559
66845
|
};
|
|
66560
66846
|
}
|
|
66561
66847
|
|
|
66562
|
-
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 @{ 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 '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";
|
|
66848
|
+
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";
|
|
66563
66849
|
|
|
66564
66850
|
/**
|
|
66565
66851
|
* Runs 1.x's own `Get-ToolchainInventory.ps1` through `pwsh` (#52, 52b; 1.x had no Linux
|
|
66566
66852
|
* equivalent, `toolchain_sidecar.py`, `sched_spec.py:264-273`), kind `toolchain-inventory`,
|
|
66567
66853
|
* capability `agent.scan.toolchain-inventory`. Shipped and run, not rewritten (`data/README.md`):
|
|
66568
|
-
* the script is 1,
|
|
66569
|
-
* would have to re-derive every probe and
|
|
66854
|
+
* the script is 1,832 lines (1.x `main` at `8e33836` plus the six lines #673 added) whose output is
|
|
66855
|
+
* the byte-compare target, so a TypeScript rewrite would have to re-derive every probe and
|
|
66856
|
+
* formatting rule and still match it byte for byte.
|
|
66570
66857
|
*
|
|
66571
66858
|
* Windows-only, as 1.x was: on any other platform, `run` returns a skipped payload without
|
|
66572
66859
|
* spawning anything. The invocation is the script's own local-test path (`-OutFile -NoRender`,
|
|
@@ -66607,7 +66894,7 @@ function ensureScript(fs, stateDir) {
|
|
|
66607
66894
|
function sanitizeMachine(hostname) {
|
|
66608
66895
|
return hostname.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
66609
66896
|
}
|
|
66610
|
-
function lastNonEmptyLine$
|
|
66897
|
+
function lastNonEmptyLine$2(text) {
|
|
66611
66898
|
const lines = text
|
|
66612
66899
|
.split("\n")
|
|
66613
66900
|
.map((line) => line.replace(/\r$/, ""))
|
|
@@ -66687,7 +66974,7 @@ async function runToolchainInventory(deps, signal) {
|
|
|
66687
66974
|
throw new Error("scan aborted");
|
|
66688
66975
|
}
|
|
66689
66976
|
if (result.code !== 0) {
|
|
66690
|
-
const detail = lastNonEmptyLine$
|
|
66977
|
+
const detail = lastNonEmptyLine$2(result.stderr) || lastNonEmptyLine$2(result.stdout);
|
|
66691
66978
|
throw new Error(`Get-ToolchainInventory.ps1 exited ${result.code}: ${detail}`);
|
|
66692
66979
|
}
|
|
66693
66980
|
const resultKind = classifyResult(result.stdout);
|
|
@@ -66931,7 +67218,7 @@ const PREREQ_TABLE = [
|
|
|
66931
67218
|
},
|
|
66932
67219
|
];
|
|
66933
67220
|
|
|
66934
|
-
const VERSION_TIMEOUT_MS = 15_000;
|
|
67221
|
+
const VERSION_TIMEOUT_MS$1 = 15_000;
|
|
66935
67222
|
function firstNonEmptyLine(text) {
|
|
66936
67223
|
const lines = text
|
|
66937
67224
|
.split(/\r?\n/)
|
|
@@ -66939,7 +67226,7 @@ function firstNonEmptyLine(text) {
|
|
|
66939
67226
|
.filter((l) => l !== "");
|
|
66940
67227
|
return lines[0] ?? "";
|
|
66941
67228
|
}
|
|
66942
|
-
function lastNonEmptyLine(...texts) {
|
|
67229
|
+
function lastNonEmptyLine$1(...texts) {
|
|
66943
67230
|
const lines = texts
|
|
66944
67231
|
.join("\n")
|
|
66945
67232
|
.split(/\r?\n/)
|
|
@@ -66968,7 +67255,7 @@ async function verdictForFound(entry, deps, refreshedEnv, foundPath, onPath) {
|
|
|
66968
67255
|
return { ...base, status: "ok", path: foundPath, version: "", detail: "" };
|
|
66969
67256
|
}
|
|
66970
67257
|
const versionResult = await deps.run(foundPath, ["--version"], {
|
|
66971
|
-
timeoutMs: VERSION_TIMEOUT_MS,
|
|
67258
|
+
timeoutMs: VERSION_TIMEOUT_MS$1,
|
|
66972
67259
|
env: refreshedEnv,
|
|
66973
67260
|
});
|
|
66974
67261
|
if (versionResult.timedOut) {
|
|
@@ -66986,17 +67273,17 @@ async function verdictForFound(entry, deps, refreshedEnv, foundPath, onPath) {
|
|
|
66986
67273
|
status: "broken",
|
|
66987
67274
|
path: foundPath,
|
|
66988
67275
|
version: "",
|
|
66989
|
-
detail: lastNonEmptyLine(versionResult.stdout, versionResult.stderr),
|
|
67276
|
+
detail: lastNonEmptyLine$1(versionResult.stdout, versionResult.stderr),
|
|
66990
67277
|
};
|
|
66991
67278
|
}
|
|
66992
67279
|
const version = firstNonEmptyLine(versionResult.stdout);
|
|
66993
67280
|
if (entry.cmd === "gh") {
|
|
66994
67281
|
const authResult = await deps.run(foundPath, ["auth", "status"], {
|
|
66995
|
-
timeoutMs: VERSION_TIMEOUT_MS,
|
|
67282
|
+
timeoutMs: VERSION_TIMEOUT_MS$1,
|
|
66996
67283
|
env: refreshedEnv,
|
|
66997
67284
|
});
|
|
66998
67285
|
if (authResult.code !== 0) {
|
|
66999
|
-
const stderrLine = lastNonEmptyLine(authResult.stderr);
|
|
67286
|
+
const stderrLine = lastNonEmptyLine$1(authResult.stderr);
|
|
67000
67287
|
return {
|
|
67001
67288
|
...base,
|
|
67002
67289
|
status: "unauthenticated",
|
|
@@ -67738,7 +68025,7 @@ const MAX_TOTAL_DECODED_BYTES = 48 * 1024 * 1024;
|
|
|
67738
68025
|
// body-too-large 413 first, never reaching (and never able to report) the more specific decoded-total
|
|
67739
68026
|
// 400 below. +2 MB of headroom comfortably covers realistic JSON structure for up to MAX_FILES
|
|
67740
68027
|
// entries (tens of KB in practice) so both checks are independently reachable.
|
|
67741
|
-
const MAX_REQUEST_BODY_BYTES = 66 * 1024 * 1024;
|
|
68028
|
+
const MAX_REQUEST_BODY_BYTES$1 = 66 * 1024 * 1024;
|
|
67742
68029
|
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
67743
68030
|
/** `Buffer.from(s, "base64")` is lenient -- it silently drops characters outside the base64 alphabet
|
|
67744
68031
|
* rather than throwing, so "does it decode" needs its own check, not a try/catch around the decode. */
|
|
@@ -67795,7 +68082,7 @@ function registerPublishVerb(registry, deps) {
|
|
|
67795
68082
|
path: "/publish/mirror",
|
|
67796
68083
|
middleware: [
|
|
67797
68084
|
bodyLimit({
|
|
67798
|
-
maxSize: MAX_REQUEST_BODY_BYTES,
|
|
68085
|
+
maxSize: MAX_REQUEST_BODY_BYTES$1,
|
|
67799
68086
|
onError: (c) => c.json({ ok: false, error: "request body too large" }, 413),
|
|
67800
68087
|
}),
|
|
67801
68088
|
],
|
|
@@ -75255,6 +75542,568 @@ var tunnel = /*#__PURE__*/Object.freeze({
|
|
|
75255
75542
|
registerTunnelVerb: registerTunnelVerb
|
|
75256
75543
|
});
|
|
75257
75544
|
|
|
75545
|
+
/**
|
|
75546
|
+
* `POST /v1/toolchain/upgrade`, `GET /v1/toolchain/upgrade/:jobId`, `GET /v1/toolchain/upgrade/:jobId/events`
|
|
75547
|
+
* (#675, PR 1 of 2), capability `agent.toolchain.upgrade`: the agent installs software on the machine it
|
|
75548
|
+
* runs on, so this is the one verb whose request is a security surface. The design (roshne's decisions on
|
|
75549
|
+
* #675, 2026-10-05):
|
|
75550
|
+
*
|
|
75551
|
+
* - TWO gates, both required. The console's grant (the registry's guard, in front of every handler here) and
|
|
75552
|
+
* the machine owner's own opt-in, `toolchain_upgrade = true` in this machine's `agent.toml`
|
|
75553
|
+
* (`deps.enabled`, the config the agent loaded at start), the first middleware after the grant guard, so
|
|
75554
|
+
* a machine that has not opted in does no work at all for a request: not even its body is limited or read.
|
|
75555
|
+
* The POST's body is then capped at `MAX_REQUEST_BODY_BYTES` before it is read.
|
|
75556
|
+
* - FIXED COMMAND SHAPES, never free text. `commandFor` is the only place an upgrade argv is built, from
|
|
75557
|
+
* three literal shapes and one validated id. Every field is checked against a grammar before anything
|
|
75558
|
+
* spawns; a request that does not match is a `400` naming the field. A valid id can still name a package
|
|
75559
|
+
* the operator did not mean (the console decides which one), which is the accepted worst case. The one
|
|
75560
|
+
* other program the request can name is `cmd`, and only to read its version: it must be one of the tools
|
|
75561
|
+
* the agent's own preflight already runs `--version` on (`VERSION_READABLE_CMDS`), never an executable of
|
|
75562
|
+
* the request's choosing.
|
|
75563
|
+
* - NO ELEVATION in the agent: it never tries to elevate itself. Linux needs a sudoers rule for the agent's
|
|
75564
|
+
* user (`needs-sudoers`). On Windows the agent is a limited logon task: winget answers `0x8A150019` when
|
|
75565
|
+
* the command itself needs administrator rights (`needs-elevation`, with the command to paste into an
|
|
75566
|
+
* elevated window). An installer that elevates itself through UAC may instead put a prompt on the user's
|
|
75567
|
+
* desktop, which the agent cannot answer (not verified here; see the README).
|
|
75568
|
+
* - One upgrade at a time, as a job with streamed output (apt and winget outlast the host client's 30 s
|
|
75569
|
+
* body budget), a 15-minute timeout and a 1 MiB output cap, both enforced by `spawnJob`.
|
|
75570
|
+
* - AUDIT: two log lines per run. One when the job starts (tool, manager, id, `cmd`), so a run the agent does
|
|
75571
|
+
* not survive still leaves a record; one when it ends (tool, manager, id, before, after, outcome, exit
|
|
75572
|
+
* code, `cmd`). The console-supplied `tool` is printed in quotes, and its grammar has no quote.
|
|
75573
|
+
*/
|
|
75574
|
+
const UPGRADE_CAPABILITY = "agent.toolchain.upgrade";
|
|
75575
|
+
const KIND = "toolchain-upgrade";
|
|
75576
|
+
/** 15 minutes: a large winget installer is the slow case; past this it is hung, and is killed. */
|
|
75577
|
+
const UPGRADE_TIMEOUT_MS = 15 * 60_000;
|
|
75578
|
+
/** 1 MiB of logged output: a runaway installer must not fill the agent's memory with log lines. */
|
|
75579
|
+
const UPGRADE_MAX_OUTPUT_CHARS = 1_048_576;
|
|
75580
|
+
const VERSION_TIMEOUT_MS = 20_000;
|
|
75581
|
+
/** After the upgrade command has exited, how long its output may stay open (a descendant it left running
|
|
75582
|
+
* can hold the pipes) before the job is finished with the command's real exit code anyway. */
|
|
75583
|
+
const EXIT_SETTLE_MS = 5_000;
|
|
75584
|
+
/** The most bytes of request body the POST reads: the largest valid body is a few hundred, so a granted
|
|
75585
|
+
* console cannot make the agent buffer an unbounded one. */
|
|
75586
|
+
const MAX_REQUEST_BODY_BYTES = 4096;
|
|
75587
|
+
const MAX_CONCURRENT_UPGRADES = 1;
|
|
75588
|
+
/** Finished jobs' outcomes kept for the status route (the job registry itself keeps 200 finished jobs). */
|
|
75589
|
+
const MAX_REMEMBERED_OUTCOMES = 200;
|
|
75590
|
+
const MAX_REASON_CHARS = 300;
|
|
75591
|
+
const NOT_OPTED_IN_ERROR = "toolchain upgrades are not enabled on this machine (agent.toml toolchain_upgrade)";
|
|
75592
|
+
const MANAGERS = ["winget", "apt", "native"];
|
|
75593
|
+
// The tool key lands in a job label and, in quotes, in the audit lines, so it is words and a little
|
|
75594
|
+
// punctuation: no quote, ":", "(", ")", ">" or ",", the characters that would let one run's line imitate
|
|
75595
|
+
// another's outcome. Spaces stay (the standard's keys have them: "GitHub CLI").
|
|
75596
|
+
const TOOL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,63}$/;
|
|
75597
|
+
/** The commands whose `--version` this verb may run: the agent's own preflight table, minus the one entry
|
|
75598
|
+
* the preflight itself never executes (a Chromium-family `--version` can open a window, E4 §5 decision
|
|
75599
|
+
* 19). The Environment panel sends the `cmd` of a preflight row, so these are the only values it ever
|
|
75600
|
+
* sends; anything else is a request to run a program of the console's choosing. */
|
|
75601
|
+
const VERSION_READABLE_CMDS = PREREQ_TABLE.map((entry) => entry.cmd).filter((cmd) => cmd !== "chromium");
|
|
75602
|
+
// winget: a `Publisher.Name` id (`Git.Git`, `Microsoft.PowerShell`); must start alphanumeric, so it can
|
|
75603
|
+
// never be read as an option, and must contain a ".".
|
|
75604
|
+
const WINGET_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$/;
|
|
75605
|
+
// apt: the Debian package-name grammar (2+ characters, lowercase letters, digits, "+", "-", ".").
|
|
75606
|
+
const APT_PACKAGE_PATTERN = /^[a-z0-9][a-z0-9+.-]{1,127}$/;
|
|
75607
|
+
const NATIVE_ID = "claude";
|
|
75608
|
+
function isManager(value) {
|
|
75609
|
+
return typeof value === "string" && MANAGERS.includes(value);
|
|
75610
|
+
}
|
|
75611
|
+
function refuse$1(error) {
|
|
75612
|
+
return { ok: false, error };
|
|
75613
|
+
}
|
|
75614
|
+
/** The pure half of the request check: every refusal names its field, and no process is started on a
|
|
75615
|
+
* refusal. `platform` decides which managers exist on this machine. A field is read only when it is the
|
|
75616
|
+
* body's OWN property, so a body cannot reach an inherited one. */
|
|
75617
|
+
function validateUpgradeRequest(body, platform) {
|
|
75618
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
75619
|
+
return refuse$1("invalid JSON body");
|
|
75620
|
+
}
|
|
75621
|
+
const field = (name) => Object.hasOwn(body, name) ? body[name] : undefined;
|
|
75622
|
+
const tool = field("tool");
|
|
75623
|
+
if (typeof tool !== "string" || tool === "")
|
|
75624
|
+
return refuse$1("tool: must be a non-empty string");
|
|
75625
|
+
if (!TOOL_PATTERN.test(tool)) {
|
|
75626
|
+
return refuse$1("tool: must be 1 to 64 letters, digits, spaces or . _ + - (starting with a letter or digit)");
|
|
75627
|
+
}
|
|
75628
|
+
const cmd = field("cmd");
|
|
75629
|
+
if (typeof cmd !== "string" || cmd === "")
|
|
75630
|
+
return refuse$1("cmd: must be a non-empty string");
|
|
75631
|
+
// An allowlist, not a grammar: only the preflight's own executed tools pass, so no name, path or
|
|
75632
|
+
// separator that is not one of them can reach `which`.
|
|
75633
|
+
if (!VERSION_READABLE_CMDS.includes(cmd)) {
|
|
75634
|
+
return refuse$1(`cmd: not a tool this agent reads the version of (${VERSION_READABLE_CMDS.join(", ")})`);
|
|
75635
|
+
}
|
|
75636
|
+
const manager = field("manager");
|
|
75637
|
+
if (!isManager(manager))
|
|
75638
|
+
return refuse$1(`manager: must be one of ${MANAGERS.join(", ")}`);
|
|
75639
|
+
if (manager === "winget" && platform !== "win32") {
|
|
75640
|
+
return refuse$1("manager: winget is only available on win32 machines");
|
|
75641
|
+
}
|
|
75642
|
+
if (manager === "apt" && platform !== "linux") {
|
|
75643
|
+
return refuse$1("manager: apt is only available on linux machines");
|
|
75644
|
+
}
|
|
75645
|
+
const packageId = field("packageId");
|
|
75646
|
+
if (typeof packageId !== "string" || packageId === "") {
|
|
75647
|
+
return refuse$1("packageId: must be a non-empty string");
|
|
75648
|
+
}
|
|
75649
|
+
if (manager === "winget") {
|
|
75650
|
+
if (!WINGET_ID_PATTERN.test(packageId)) {
|
|
75651
|
+
return refuse$1("packageId: not a winget package id (letters, digits and . + _ -, starting with a letter or digit)");
|
|
75652
|
+
}
|
|
75653
|
+
if (!packageId.includes(".")) {
|
|
75654
|
+
return refuse$1('packageId: a winget package id has the form Publisher.Name (it needs a ".")');
|
|
75655
|
+
}
|
|
75656
|
+
}
|
|
75657
|
+
else if (manager === "apt") {
|
|
75658
|
+
if (!APT_PACKAGE_PATTERN.test(packageId)) {
|
|
75659
|
+
return refuse$1("packageId: not a Debian package name (lowercase letters, digits, + - ., 2 to 128 characters)");
|
|
75660
|
+
}
|
|
75661
|
+
// apt-get reads a trailing "-" on a package argument as "remove this package".
|
|
75662
|
+
if (packageId.endsWith("-")) {
|
|
75663
|
+
return refuse$1('packageId: a trailing "-" would make apt-get remove the package');
|
|
75664
|
+
}
|
|
75665
|
+
}
|
|
75666
|
+
else {
|
|
75667
|
+
if (packageId !== NATIVE_ID)
|
|
75668
|
+
return refuse$1(`packageId: the native installer only knows "${NATIVE_ID}"`);
|
|
75669
|
+
if (cmd !== NATIVE_ID)
|
|
75670
|
+
return refuse$1(`cmd: a native upgrade runs the "${NATIVE_ID}" binary`);
|
|
75671
|
+
}
|
|
75672
|
+
return { ok: true, value: { tool, cmd, manager, packageId } };
|
|
75673
|
+
}
|
|
75674
|
+
// ---- the command ---------------------------------------------------------------------------------
|
|
75675
|
+
/** The three literal command shapes (the only argv this verb ever builds). `executablePath` is the
|
|
75676
|
+
* resolved `winget`, `sudo` or `claude`; `packageId` was validated by `validateUpgradeRequest`. */
|
|
75677
|
+
function commandFor(manager, packageId, executablePath) {
|
|
75678
|
+
switch (manager) {
|
|
75679
|
+
case "winget":
|
|
75680
|
+
return [
|
|
75681
|
+
executablePath,
|
|
75682
|
+
"upgrade",
|
|
75683
|
+
"--id",
|
|
75684
|
+
packageId,
|
|
75685
|
+
"--exact",
|
|
75686
|
+
"--silent",
|
|
75687
|
+
"--accept-package-agreements",
|
|
75688
|
+
"--accept-source-agreements",
|
|
75689
|
+
"--disable-interactivity",
|
|
75690
|
+
];
|
|
75691
|
+
case "apt":
|
|
75692
|
+
// --no-remove (apt-get(8): "If any packages are to be removed apt-get immediately aborts"): with -y
|
|
75693
|
+
// alone a package whose new version Conflicts with or Breaks another installed package removes that
|
|
75694
|
+
// one without asking. After `-y`, so the sudoers rule's trailing `*` still matches.
|
|
75695
|
+
return [
|
|
75696
|
+
executablePath,
|
|
75697
|
+
"-n",
|
|
75698
|
+
"apt-get",
|
|
75699
|
+
"install",
|
|
75700
|
+
"--only-upgrade",
|
|
75701
|
+
"-y",
|
|
75702
|
+
"--no-remove",
|
|
75703
|
+
packageId,
|
|
75704
|
+
];
|
|
75705
|
+
case "native":
|
|
75706
|
+
return [executablePath, "update"];
|
|
75707
|
+
}
|
|
75708
|
+
}
|
|
75709
|
+
/** The command as an operator would type it: the executable by name, not by the path this agent found. */
|
|
75710
|
+
function typedCommand(manager, packageId) {
|
|
75711
|
+
const [exe, ...args] = commandFor(manager, packageId, "winget");
|
|
75712
|
+
return [exe, ...args].join(" ");
|
|
75713
|
+
}
|
|
75714
|
+
// winget's documented return codes (microsoft/winget-cli `doc/windows/package-manager/winget/returnCodes.md`,
|
|
75715
|
+
// generated by `winget error`), pinned by number. Node reports a Windows exit code as unsigned; `>>> 0`
|
|
75716
|
+
// below makes a signed one compare the same.
|
|
75717
|
+
const WINGET_COMMAND_REQUIRES_ADMIN = 0x8a150019;
|
|
75718
|
+
const WINGET_UPDATE_NOT_APPLICABLE = 0x8a15002b;
|
|
75719
|
+
const WINGET_NO_APPLICATIONS_FOUND = 0x8a150014;
|
|
75720
|
+
// sudo's own wording when `-n` cannot proceed for want of a rule: sudo 1.9 (sudoers' logging.c, sudo(8)),
|
|
75721
|
+
// then sudo-rs, the default `sudo` of recent Ubuntu (strings read from the 0.2.13 binary on Ubuntu 26.04: "interactive
|
|
75722
|
+
// authentication is required", "I'm sorry <user>. I'm afraid I can't do that", "Sorry, user <u> may not
|
|
75723
|
+
// run <command> on <host>", "... is not allowed to execute '<command>' as <u> on <host>").
|
|
75724
|
+
const SUDO_REFUSALS = [
|
|
75725
|
+
/sudo: a password is required/i,
|
|
75726
|
+
/is not in the sudoers file/i,
|
|
75727
|
+
/is not allowed to execute/i,
|
|
75728
|
+
/is not allowed to run sudo on/i,
|
|
75729
|
+
/interactive authentication is required/i,
|
|
75730
|
+
/I'm afraid I can't do that/i,
|
|
75731
|
+
// "Sorry, user <u> may not run sudo on <host>" (sudo 1.9) and "... may not run <command> on <host>" (sudo-rs)
|
|
75732
|
+
/user \S+ may not run .+ on /i,
|
|
75733
|
+
];
|
|
75734
|
+
function sudoersRule(username) {
|
|
75735
|
+
return `${username} ALL=(root) NOPASSWD: /usr/bin/apt-get install --only-upgrade -y *`;
|
|
75736
|
+
}
|
|
75737
|
+
/** Printable ASCII only, and bounded: this text ends up in a job log, a status body and an audit line. */
|
|
75738
|
+
function printable(text, max) {
|
|
75739
|
+
return text.replace(/[^\x20-\x7e]/g, "?").slice(0, max);
|
|
75740
|
+
}
|
|
75741
|
+
function lastNonEmptyLine(text) {
|
|
75742
|
+
const lines = text
|
|
75743
|
+
.split(/\r?\n/)
|
|
75744
|
+
.map((line) => line.trim())
|
|
75745
|
+
.filter((line) => line !== "");
|
|
75746
|
+
return printable(lines.at(-1) ?? "", MAX_REASON_CHARS);
|
|
75747
|
+
}
|
|
75748
|
+
function classifyOutcome(input) {
|
|
75749
|
+
if (input.limit === "timeout") {
|
|
75750
|
+
return {
|
|
75751
|
+
status: "timed-out",
|
|
75752
|
+
reason: `killed after ${UPGRADE_TIMEOUT_MS / 60_000} minutes; the package may be half-installed, check it before retrying`,
|
|
75753
|
+
};
|
|
75754
|
+
}
|
|
75755
|
+
if (input.limit === "output-cap") {
|
|
75756
|
+
return {
|
|
75757
|
+
status: "failed",
|
|
75758
|
+
reason: `output passed ${UPGRADE_MAX_OUTPUT_CHARS} characters; the rest was dropped`,
|
|
75759
|
+
};
|
|
75760
|
+
}
|
|
75761
|
+
if (input.exitCode === null) {
|
|
75762
|
+
return {
|
|
75763
|
+
status: "failed",
|
|
75764
|
+
reason: lastNonEmptyLine(input.output) ||
|
|
75765
|
+
"the command ended without an exit code (killed by a signal, or it could not be started)",
|
|
75766
|
+
};
|
|
75767
|
+
}
|
|
75768
|
+
const code = input.exitCode >>> 0;
|
|
75769
|
+
if (input.manager === "winget") {
|
|
75770
|
+
if (code === WINGET_COMMAND_REQUIRES_ADMIN) {
|
|
75771
|
+
return {
|
|
75772
|
+
status: "needs-elevation",
|
|
75773
|
+
reason: `this package needs administrator rights; run in an elevated terminal: ${typedCommand("winget", input.packageId)}`,
|
|
75774
|
+
};
|
|
75775
|
+
}
|
|
75776
|
+
if (code === WINGET_UPDATE_NOT_APPLICABLE) {
|
|
75777
|
+
return {
|
|
75778
|
+
status: "already-current",
|
|
75779
|
+
reason: "winget reports no applicable update (no newer version this machine can install)",
|
|
75780
|
+
};
|
|
75781
|
+
}
|
|
75782
|
+
if (code === WINGET_NO_APPLICATIONS_FOUND) {
|
|
75783
|
+
return { status: "failed", reason: "winget found no installed package with that id" };
|
|
75784
|
+
}
|
|
75785
|
+
}
|
|
75786
|
+
if (code !== 0) {
|
|
75787
|
+
if (input.manager === "apt" && SUDO_REFUSALS.some((pattern) => pattern.test(input.output))) {
|
|
75788
|
+
return {
|
|
75789
|
+
status: "needs-sudoers",
|
|
75790
|
+
reason: `sudo refused: add a sudoers rule for this agent's user: ${sudoersRule(input.username)} (that rule lets any process of that user become root without a password; see the agent README, Operator setup)`,
|
|
75791
|
+
};
|
|
75792
|
+
}
|
|
75793
|
+
return { status: "failed", reason: lastNonEmptyLine(input.output) || `exit ${code}` };
|
|
75794
|
+
}
|
|
75795
|
+
// Exit 0: what changed decides it.
|
|
75796
|
+
if (input.after === "missing") {
|
|
75797
|
+
return {
|
|
75798
|
+
status: "failed",
|
|
75799
|
+
reason: "the command succeeded but the tool's version cannot be read afterwards",
|
|
75800
|
+
};
|
|
75801
|
+
}
|
|
75802
|
+
const aptUpgraded = input.manager === "apt" && Number(/(\d+) upgraded,/.exec(input.output)?.[1] ?? "0") > 0;
|
|
75803
|
+
if (input.after !== input.before || aptUpgraded)
|
|
75804
|
+
return { status: "upgraded" };
|
|
75805
|
+
return { status: "already-current" };
|
|
75806
|
+
}
|
|
75807
|
+
/** The lines `spawnJob` writes to the job log about the command rather than from it, from a forced finish
|
|
75808
|
+
* (the `[timed out` and `[output capped` ones never matter here: a limit decides the outcome before the
|
|
75809
|
+
* captured text is read). `[spawn failed] <message>` is not among them on purpose: with no output at all,
|
|
75810
|
+
* it IS the reason the run failed. */
|
|
75811
|
+
const SPAWN_DIAGNOSTIC = /^\[(?:killed, but its output never closed|the command exited, but its output never closed)/;
|
|
75812
|
+
// dpkg-query's own format syntax (a status column, a tab, the package name): not a template literal.
|
|
75813
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: ${...} is dpkg-query's format syntax
|
|
75814
|
+
const DPKG_SHOWFORMAT = "--showformat=${db:Status-Abbrev}\t${Package}\n";
|
|
75815
|
+
function notFoundError(name, found) {
|
|
75816
|
+
if (!found.found && found.reason === "shim") {
|
|
75817
|
+
return `${name} is a .cmd shim; install the native binary`;
|
|
75818
|
+
}
|
|
75819
|
+
return `${name} not found on PATH`;
|
|
75820
|
+
}
|
|
75821
|
+
/** The most `<exe> --version` output a version read collects (`RunOptions.maxOutputLength`). */
|
|
75822
|
+
const VERSION_MAX_OUTPUT = 65_536;
|
|
75823
|
+
const VERSION_DEADLINE_MS = VERSION_TIMEOUT_MS + 5_000;
|
|
75824
|
+
/** First non-empty line of `<exe> --version`, or `missing` when it cannot be run, exits non-zero, prints
|
|
75825
|
+
* nothing, or has not answered by `deadlineMs` (the read's own timeout kills the child, but its `close`
|
|
75826
|
+
* event waits for every holder of the pipes, which a child's own descendant could hold open). */
|
|
75827
|
+
async function readVersion(run, exe, env, deadlineMs) {
|
|
75828
|
+
let deadline;
|
|
75829
|
+
try {
|
|
75830
|
+
const result = await Promise.race([
|
|
75831
|
+
run(exe, ["--version"], {
|
|
75832
|
+
timeoutMs: VERSION_TIMEOUT_MS,
|
|
75833
|
+
env,
|
|
75834
|
+
maxOutputLength: VERSION_MAX_OUTPUT,
|
|
75835
|
+
}),
|
|
75836
|
+
new Promise((resolve) => {
|
|
75837
|
+
deadline = setTimeout(() => resolve(null), deadlineMs);
|
|
75838
|
+
}),
|
|
75839
|
+
]);
|
|
75840
|
+
if (result === null || result.timedOut || result.code !== 0)
|
|
75841
|
+
return "missing";
|
|
75842
|
+
const first = result.stdout
|
|
75843
|
+
.split(/\r?\n/)
|
|
75844
|
+
.map((line) => line.trim())
|
|
75845
|
+
.find((line) => line !== "");
|
|
75846
|
+
return first === undefined ? "missing" : printable(first, 200);
|
|
75847
|
+
}
|
|
75848
|
+
catch {
|
|
75849
|
+
return "missing";
|
|
75850
|
+
}
|
|
75851
|
+
finally {
|
|
75852
|
+
if (deadline !== undefined)
|
|
75853
|
+
clearTimeout(deadline);
|
|
75854
|
+
}
|
|
75855
|
+
}
|
|
75856
|
+
function registerToolchainUpgradeVerb(registry, deps) {
|
|
75857
|
+
const spawnJob$1 = deps.spawnJob ?? spawnJob;
|
|
75858
|
+
const versionDeadlineMs = deps.versionDeadlineMs ?? VERSION_DEADLINE_MS;
|
|
75859
|
+
/** A log line is a record, never a dependency: a logger that throws (a full disk) must not fail a
|
|
75860
|
+
* request that has already created a job, nor reject the unawaited `finalize`. */
|
|
75861
|
+
const logEvent = (message) => {
|
|
75862
|
+
try {
|
|
75863
|
+
deps.logEvent(message);
|
|
75864
|
+
}
|
|
75865
|
+
catch {
|
|
75866
|
+
// nothing else to do with it
|
|
75867
|
+
}
|
|
75868
|
+
};
|
|
75869
|
+
const outcomes = new Map();
|
|
75870
|
+
// Upgrades between the cap check and `jobs.start`: the awaits in between (PATH refresh, version read)
|
|
75871
|
+
// would otherwise let two concurrent requests both see "none running".
|
|
75872
|
+
let starting = 0;
|
|
75873
|
+
function remember(jobId, outcome) {
|
|
75874
|
+
outcomes.set(jobId, outcome);
|
|
75875
|
+
if (outcomes.size > MAX_REMEMBERED_OUTCOMES) {
|
|
75876
|
+
const oldest = outcomes.keys().next().value;
|
|
75877
|
+
if (oldest !== undefined)
|
|
75878
|
+
outcomes.delete(oldest);
|
|
75879
|
+
}
|
|
75880
|
+
}
|
|
75881
|
+
// Gate 2: the machine owner's opt-in. A middleware, so it runs right after the grant guard and before
|
|
75882
|
+
// anything reads (or even size-limits) the body.
|
|
75883
|
+
const optInGate = async (c, next) => {
|
|
75884
|
+
if (!deps.enabled()) {
|
|
75885
|
+
return c.json({ ok: false, error: NOT_OPTED_IN_ERROR, reason: "not-opted-in" }, 403);
|
|
75886
|
+
}
|
|
75887
|
+
await next();
|
|
75888
|
+
};
|
|
75889
|
+
registry.register({
|
|
75890
|
+
capability: UPGRADE_CAPABILITY,
|
|
75891
|
+
method: "POST",
|
|
75892
|
+
path: "/toolchain/upgrade",
|
|
75893
|
+
middleware: [
|
|
75894
|
+
optInGate,
|
|
75895
|
+
bodyLimit({
|
|
75896
|
+
maxSize: MAX_REQUEST_BODY_BYTES,
|
|
75897
|
+
onError: (c) => c.json({ ok: false, error: "request body too large" }, 413),
|
|
75898
|
+
}),
|
|
75899
|
+
],
|
|
75900
|
+
handler: async (c) => {
|
|
75901
|
+
let body;
|
|
75902
|
+
try {
|
|
75903
|
+
body = await c.req.json();
|
|
75904
|
+
}
|
|
75905
|
+
catch {
|
|
75906
|
+
return c.json({ ok: false, error: "invalid JSON body" }, 400);
|
|
75907
|
+
}
|
|
75908
|
+
const validated = validateUpgradeRequest(body, deps.platform);
|
|
75909
|
+
if (!validated.ok)
|
|
75910
|
+
return c.json({ ok: false, error: validated.error }, 400);
|
|
75911
|
+
const request = validated.value;
|
|
75912
|
+
if (deps.jobs.runningCount(KIND) + starting >= MAX_CONCURRENT_UPGRADES) {
|
|
75913
|
+
return c.json({ ok: false, error: "an upgrade is already running on this machine" }, 429);
|
|
75914
|
+
}
|
|
75915
|
+
starting++;
|
|
75916
|
+
try {
|
|
75917
|
+
const env = { PATH: await deps.searchPath() };
|
|
75918
|
+
// The program that performs the upgrade: winget, sudo (for apt-get) or the native claude.
|
|
75919
|
+
const executableName = request.manager === "winget" ? "winget" : request.manager === "apt" ? "sudo" : "claude";
|
|
75920
|
+
const executable = deps.which(executableName, env);
|
|
75921
|
+
if (!executable.found) {
|
|
75922
|
+
return c.json({ ok: false, error: notFoundError(executableName, executable) }, 502);
|
|
75923
|
+
}
|
|
75924
|
+
// The tool whose version is read before and after.
|
|
75925
|
+
const target = deps.which(request.cmd, env);
|
|
75926
|
+
if (!target.found) {
|
|
75927
|
+
return c.json({ ok: false, error: notFoundError(request.cmd, target) }, 502);
|
|
75928
|
+
}
|
|
75929
|
+
const before = await readVersion(deps.run, target.path, env, versionDeadlineMs);
|
|
75930
|
+
// apt-get falls back to treating a package argument that matches nothing as a regular expression
|
|
75931
|
+
// (apt-get(8): "a POSIX regular expression ... applied to all package names"), which with
|
|
75932
|
+
// --only-upgrade would upgrade every installed package it matches. So the name must be an
|
|
75933
|
+
// installed package, exactly: dpkg-query takes the name literally (the grammar has no wildcard).
|
|
75934
|
+
// Asked LAST, straight before the spawn: the version read above can take up to 25 s, and a package
|
|
75935
|
+
// purged in that window would otherwise reach apt-get as the pattern it falls back to.
|
|
75936
|
+
if (request.manager === "apt") {
|
|
75937
|
+
const dpkgQuery = deps.which("dpkg-query", env);
|
|
75938
|
+
if (!dpkgQuery.found) {
|
|
75939
|
+
return c.json({ ok: false, error: notFoundError("dpkg-query", dpkgQuery) }, 502);
|
|
75940
|
+
}
|
|
75941
|
+
const query = await deps.run(dpkgQuery.path, ["--show", DPKG_SHOWFORMAT, "--", request.packageId], { timeoutMs: VERSION_TIMEOUT_MS, env, maxOutputLength: VERSION_MAX_OUTPUT });
|
|
75942
|
+
const installed = query.code === 0 &&
|
|
75943
|
+
query.stdout.split("\n").some((line) => {
|
|
75944
|
+
const [status, name] = line.replace(/\r$/, "").split("\t");
|
|
75945
|
+
// dpkg's status column is desired-state, current-state, error flag: "ii " is installed, "hi "
|
|
75946
|
+
// is installed and held. The current state ("i") is what makes it installed.
|
|
75947
|
+
return status?.[1] === "i" && name === request.packageId;
|
|
75948
|
+
});
|
|
75949
|
+
if (!installed) {
|
|
75950
|
+
return c.json({ ok: false, error: `packageId: ${request.packageId} is not an installed package` }, 400);
|
|
75951
|
+
}
|
|
75952
|
+
}
|
|
75953
|
+
const argv = commandFor(request.manager, request.packageId, executable.path);
|
|
75954
|
+
const handle = deps.jobs.start({
|
|
75955
|
+
kind: KIND,
|
|
75956
|
+
label: `${request.tool} via ${request.manager} ${request.packageId}`,
|
|
75957
|
+
repo: request.manager,
|
|
75958
|
+
permissionMode: "",
|
|
75959
|
+
});
|
|
75960
|
+
// A line at the START as well as the end: a run the agent does not survive (a restart, a crash)
|
|
75961
|
+
// otherwise leaves no record of what it was upgrading.
|
|
75962
|
+
logEvent(`toolchain upgrade started: ${JSON.stringify(request.tool)} via ${request.manager} ${request.packageId} (version of ${request.cmd})`);
|
|
75963
|
+
// What the child printed, for the outcome: the last lines only, and never the job's own header.
|
|
75964
|
+
const captured = [];
|
|
75965
|
+
let sawHeader = false;
|
|
75966
|
+
let limit;
|
|
75967
|
+
const finalize = async (exitCode) => {
|
|
75968
|
+
let outcome;
|
|
75969
|
+
try {
|
|
75970
|
+
const afterEnv = { PATH: await deps.searchPath() };
|
|
75971
|
+
const afterTarget = deps.which(request.cmd, afterEnv);
|
|
75972
|
+
const after = afterTarget.found
|
|
75973
|
+
? await readVersion(deps.run, afterTarget.path, afterEnv, versionDeadlineMs)
|
|
75974
|
+
: "missing";
|
|
75975
|
+
const verdict = classifyOutcome({
|
|
75976
|
+
manager: request.manager,
|
|
75977
|
+
packageId: request.packageId,
|
|
75978
|
+
exitCode,
|
|
75979
|
+
output: captured.join("\n"),
|
|
75980
|
+
before,
|
|
75981
|
+
after,
|
|
75982
|
+
limit,
|
|
75983
|
+
username: deps.username(),
|
|
75984
|
+
});
|
|
75985
|
+
outcome = { ...verdict, before, after };
|
|
75986
|
+
}
|
|
75987
|
+
catch (err) {
|
|
75988
|
+
const message = printable(err instanceof Error ? err.message : String(err), MAX_REASON_CHARS);
|
|
75989
|
+
outcome = { status: "failed", reason: message, before, after: "missing" };
|
|
75990
|
+
}
|
|
75991
|
+
const ok = outcome.status === "upgraded" || outcome.status === "already-current";
|
|
75992
|
+
try {
|
|
75993
|
+
// The outcome and the audit line first, then the job-log trailer: a job log that fails to take
|
|
75994
|
+
// a line (the only one of these that can throw) must not cost the console its outcome or the
|
|
75995
|
+
// operator the audit line.
|
|
75996
|
+
remember(handle.id, outcome);
|
|
75997
|
+
// `cmd` is named because the verb does not tie it to the package id: the versions are of `cmd`.
|
|
75998
|
+
logEvent(`toolchain upgrade: ${JSON.stringify(request.tool)} via ${request.manager} ${request.packageId}: ${before} -> ${outcome.after} (${outcome.status}, exit ${exitCode ?? "none"}, version of ${request.cmd})`);
|
|
75999
|
+
handle.log(`after: ${outcome.after}`);
|
|
76000
|
+
handle.log(`result: ${outcome.status}${outcome.reason ? ` (${outcome.reason})` : ""}`);
|
|
76001
|
+
}
|
|
76002
|
+
catch {
|
|
76003
|
+
// A logging failure must neither stop the job finishing nor become an unhandled rejection
|
|
76004
|
+
// (`finalize` runs unawaited, and an unhandled rejection ends the agent process).
|
|
76005
|
+
}
|
|
76006
|
+
finally {
|
|
76007
|
+
// Always: a job that never finishes would hold the one upgrade slot for good.
|
|
76008
|
+
handle.finish(ok ? "done" : "failed", exitCode);
|
|
76009
|
+
}
|
|
76010
|
+
};
|
|
76011
|
+
// The child's output flows to the job; `finish` is held back until the outcome (and its trailer
|
|
76012
|
+
// lines) is known, then called once.
|
|
76013
|
+
const spawnHandle = {
|
|
76014
|
+
log: (line) => {
|
|
76015
|
+
handle.log(line);
|
|
76016
|
+
if (!sawHeader) {
|
|
76017
|
+
sawHeader = true;
|
|
76018
|
+
return;
|
|
76019
|
+
}
|
|
76020
|
+
// spawnJob's own diagnostics are not the command's words: the reason a failed run reports is
|
|
76021
|
+
// the last thing the COMMAND said, which a forced finish's note must not replace.
|
|
76022
|
+
if (SPAWN_DIAGNOSTIC.test(line))
|
|
76023
|
+
return;
|
|
76024
|
+
captured.push(line);
|
|
76025
|
+
if (captured.length > 400)
|
|
76026
|
+
captured.shift();
|
|
76027
|
+
},
|
|
76028
|
+
finish: (_status, exitCode) => {
|
|
76029
|
+
void finalize(exitCode);
|
|
76030
|
+
},
|
|
76031
|
+
};
|
|
76032
|
+
try {
|
|
76033
|
+
spawnJob$1(argv, {
|
|
76034
|
+
cwd: deps.cwd(),
|
|
76035
|
+
env: {
|
|
76036
|
+
NO_COLOR: "1",
|
|
76037
|
+
...(request.manager === "apt"
|
|
76038
|
+
? { DEBIAN_FRONTEND: "noninteractive", LC_ALL: "C" }
|
|
76039
|
+
: {}),
|
|
76040
|
+
},
|
|
76041
|
+
header: `$ ${argv.join(" ")}`,
|
|
76042
|
+
timeoutMs: UPGRADE_TIMEOUT_MS,
|
|
76043
|
+
maxOutputChars: UPGRADE_MAX_OUTPUT_CHARS,
|
|
76044
|
+
exitSettleMs: EXIT_SETTLE_MS,
|
|
76045
|
+
onLimit: (which) => {
|
|
76046
|
+
limit = which;
|
|
76047
|
+
},
|
|
76048
|
+
}, spawnHandle);
|
|
76049
|
+
}
|
|
76050
|
+
catch (err) {
|
|
76051
|
+
// A launch that throws before any child exists must not leave a job "running" for good.
|
|
76052
|
+
handle.log(`[spawn failed] ${printable(err instanceof Error ? err.message : String(err), MAX_REASON_CHARS)}`);
|
|
76053
|
+
handle.finish("failed", null);
|
|
76054
|
+
throw err;
|
|
76055
|
+
}
|
|
76056
|
+
// spawnJob logs the header synchronously; the child's own output arrives later.
|
|
76057
|
+
handle.log(`before: ${before}`);
|
|
76058
|
+
return c.json({ ok: true, jobId: handle.id }, 202);
|
|
76059
|
+
}
|
|
76060
|
+
catch (err) {
|
|
76061
|
+
logEvent(`toolchain upgrade: request failed: ${printable(err instanceof Error ? err.message : String(err), MAX_REASON_CHARS)}`);
|
|
76062
|
+
return c.json({ ok: false, error: "the upgrade could not be started" }, 500);
|
|
76063
|
+
}
|
|
76064
|
+
finally {
|
|
76065
|
+
starting--;
|
|
76066
|
+
}
|
|
76067
|
+
},
|
|
76068
|
+
});
|
|
76069
|
+
registry.register({
|
|
76070
|
+
capability: UPGRADE_CAPABILITY,
|
|
76071
|
+
method: "GET",
|
|
76072
|
+
path: "/toolchain/upgrade/:jobId",
|
|
76073
|
+
handler: async (c) => {
|
|
76074
|
+
const jobId = c.req.param("jobId") ?? "";
|
|
76075
|
+
const entry = deps.jobs.get(jobId);
|
|
76076
|
+
// Another kind's job is "no such job" here, exactly like an unknown id.
|
|
76077
|
+
if (!entry || entry.view.kind !== KIND)
|
|
76078
|
+
return c.json({ ok: false, error: "no such job" }, 404);
|
|
76079
|
+
const outcome = outcomes.get(jobId);
|
|
76080
|
+
return c.json({ ok: true, job: entry.view, ...(outcome !== undefined ? { outcome } : {}) });
|
|
76081
|
+
},
|
|
76082
|
+
});
|
|
76083
|
+
registry.register({
|
|
76084
|
+
capability: UPGRADE_CAPABILITY,
|
|
76085
|
+
method: "GET",
|
|
76086
|
+
path: "/toolchain/upgrade/:jobId/events",
|
|
76087
|
+
handler: async (c) => jobEventsResponse(deps.jobs, c.req.param("jobId") ?? "", c, KIND),
|
|
76088
|
+
});
|
|
76089
|
+
return { pendingStarts: () => starting };
|
|
76090
|
+
}
|
|
76091
|
+
|
|
76092
|
+
var toolchainUpgrade = /*#__PURE__*/Object.freeze({
|
|
76093
|
+
__proto__: null,
|
|
76094
|
+
MAX_REQUEST_BODY_BYTES: MAX_REQUEST_BODY_BYTES,
|
|
76095
|
+
NOT_OPTED_IN_ERROR: NOT_OPTED_IN_ERROR,
|
|
76096
|
+
UPGRADE_CAPABILITY: UPGRADE_CAPABILITY,
|
|
76097
|
+
UPGRADE_MAX_OUTPUT_CHARS: UPGRADE_MAX_OUTPUT_CHARS,
|
|
76098
|
+
UPGRADE_TIMEOUT_MS: UPGRADE_TIMEOUT_MS,
|
|
76099
|
+
VERSION_READABLE_CMDS: VERSION_READABLE_CMDS,
|
|
76100
|
+
classifyOutcome: classifyOutcome,
|
|
76101
|
+
commandFor: commandFor,
|
|
76102
|
+
registerToolchainUpgradeVerb: registerToolchainUpgradeVerb,
|
|
76103
|
+
sudoersRule: sudoersRule,
|
|
76104
|
+
validateUpgradeRequest: validateUpgradeRequest
|
|
76105
|
+
});
|
|
76106
|
+
|
|
75258
76107
|
/**
|
|
75259
76108
|
* #659 PR 2: where an update comes from and what is allowed. The agent installs only an exact
|
|
75260
76109
|
* published version of `@rackbops/ac-agent` from the public npm registry -- never a URL, a tarball
|
|
@@ -75687,4 +76536,4 @@ var updater = /*#__PURE__*/Object.freeze({
|
|
|
75687
76536
|
selfUpdateSupported: selfUpdateSupported
|
|
75688
76537
|
});
|
|
75689
76538
|
|
|
75690
|
-
export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, healthResultFrom, isEntryPoint, main, realExists };
|
|
76539
|
+
export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, healthResultFrom, isEntryPoint, main, realExists, runningJobCount, toolchainUpgradeDeps };
|