@svenroth-ai/shipwright 0.24.3-next.0 → 0.24.4-next.0

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/lib/preflight.mjs CHANGED
@@ -15,8 +15,10 @@
15
15
  */
16
16
 
17
17
  import { spawnSync } from "node:child_process";
18
+ import os from "node:os";
18
19
 
19
20
  import { MIN_NODE, compareSemver, installHint, isWindows } from "./util.mjs";
21
+ import { probeEnv, resolvePosixBin } from "./probe-path.mjs";
20
22
  import { resolveSpawn } from "./win32-spawn.mjs";
21
23
 
22
24
  /**
@@ -31,7 +33,9 @@ import { resolveSpawn } from "./win32-spawn.mjs";
31
33
  * `cmd.exe /d /s /c` instead (see lib/win32-spawn.mjs).
32
34
  *
33
35
  * CALLER INVARIANT, still load-bearing: `cmd` is a fixed internal literal
34
- * (claude / uv / python3 / python / py / git / gh) and `args` is `["--version"]`.
36
+ * (claude / uv / python3 / python / py / git / gh) and `args` is a fixed internal
37
+ * literal too — `["--version"]` for every probe, plus the one `["python",
38
+ * "find", ">=3.11"]` uv-can-supply-Python predicate. NO caller passes user input.
35
39
  * NOTHING here validates that — `defaultRun` is exported from a published `lib/`
36
40
  * and reaches the cmd.exe wrap with no charset gate of its own (unlike
37
41
  * `claude-cli.mjs`, which has `SAFE_ARG`). Only `runPreflight`'s call sites
@@ -48,9 +52,17 @@ import { resolveSpawn } from "./win32-spawn.mjs";
48
52
  * @param {string} cmd @param {string[]} [args]
49
53
  * @returns {{ ok: boolean, stdout: string, stderr: string, code: number | null }}
50
54
  */
51
- export function defaultRun(cmd, args = ["--version"]) {
55
+ export function defaultRun(cmd, args = ["--version"], deps = {}) {
56
+ const platform = deps.platform ?? process.platform;
57
+ const homedir = deps.homedir ?? os.homedir();
58
+ const env = probeEnv(platform, homedir, deps.env ?? process.env);
52
59
  try {
53
- const plan = resolveSpawn([cmd, ...args]);
60
+ // Windows resolves shims via PATHEXT against the augmented `env`; POSIX
61
+ // resolves the absolute path itself so the augmented PATH actually applies.
62
+ const plan =
63
+ platform === "win32"
64
+ ? resolveSpawn([cmd, ...args], { platform, env })
65
+ : { command: resolvePosixBin(cmd, env), args };
54
66
  // UNREACHABLE as of iterate-2026-07-31 (win32-spawn divergence 5: an
55
67
  // unresolvable bare name comes back as itself). `spawnSync` does NOT throw
56
68
  // for a missing binary — it returns `{ error: ENOENT, status: null }` — so
@@ -63,6 +75,7 @@ export function defaultRun(cmd, args = ["--version"]) {
63
75
  encoding: "utf-8",
64
76
  shell: false,
65
77
  timeout: 8000,
78
+ env,
66
79
  windowsVerbatimArguments: plan.windowsVerbatimArguments,
67
80
  });
68
81
  const stdout = (r.stdout ?? "").toString();
@@ -132,13 +145,33 @@ export function runPreflight(deps = {}) {
132
145
  hard: true,
133
146
  });
134
147
 
135
- // python — TEST-RUN probe (Store-stub trap), require >= 3.11.
148
+ // python — the stack runs every hook via `uv run`, which resolves a
149
+ // uv-MANAGED interpreter. So a working system python3 >= 3.11 satisfies the
150
+ // gate, but so does uv being able to PROVIDE 3.11+ — the common Mac case where
151
+ // `uv python install 3.11` left system `python3` at 3.9. System probe first
152
+ // (TEST-RUN, Store-stub trap); only fall through to uv when it is short.
136
153
  const py = resolvePython(run);
137
- const pyOk = py != null && compareSemver(py.version, "3.11.0") >= 0;
154
+ const sysOk = py != null && compareSemver(py.version, "3.11.0") >= 0;
155
+ let pyOk = sysOk;
156
+ let pyDetail = py ? `${py.version} (${py.bin})` : "not found (tried python3, python, py)";
157
+ if (!pyOk && uv.ok) {
158
+ // `uv python find` locates an installed 3.11+ WITHOUT downloading — a clean,
159
+ // side-effect-free predicate for "uv can supply Python for the hooks".
160
+ const uvPy = run("uv", ["python", "find", ">=3.11"]);
161
+ // Gate on EXIT CODE, not defaultRun's version-shaped `ok`: `uv python find`
162
+ // prints a PATH, and a resolved interpreter whose path carries no decimal
163
+ // (e.g. a `/usr/local/bin/python3` symlink) would fail the `\d+\.\d+` output
164
+ // test despite a clean exit — re-introducing the very false "not found" this
165
+ // change removes. uv exits 0 on a hit, 2 on a miss (verified 2026-08-22).
166
+ if (uvPy.code === 0) {
167
+ pyOk = true;
168
+ pyDetail = `${extractVersion(uvPy.stdout + uvPy.stderr) || "3.11+"} (uv-managed)`;
169
+ }
170
+ }
138
171
  checks.push({
139
172
  name: "python",
140
173
  ok: pyOk,
141
- detail: py ? `${py.version} (${py.bin})` : "not found (tried python3, python, py)",
174
+ detail: pyDetail,
142
175
  hint: pyOk ? undefined : installHint("python", platform),
143
176
  hard: true,
144
177
  });
@@ -204,5 +237,16 @@ export function renderVerdict(result, mark) {
204
237
  lines.push(` ${glyph} ${c.name}: ${c.detail}`);
205
238
  if (!c.ok && c.hint) lines.push(` -> ${c.hint}`);
206
239
  }
240
+ // A just-installed tool lands in ~/.local/bin but the running shell's PATH was
241
+ // captured before the install — so "not found" here is often really "not on
242
+ // PATH yet". Say so, once, OS-aware, whenever a hard prerequisite is missing.
243
+ if (result.checks.some((c) => c.hard && !c.ok)) {
244
+ lines.push("");
245
+ lines.push(
246
+ result.isWindows
247
+ ? " Note: if you JUST installed one of these, open a NEW terminal so it lands on your PATH, then re-run."
248
+ : " Note: if you JUST installed one of these, open a new terminal (or `source ~/.zshrc` / `~/.bashrc`) so it lands on your PATH, then re-run.",
249
+ );
250
+ }
207
251
  return lines.join("\n");
208
252
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * probe-path.mjs — where the prerequisite probe LOOKS for a binary.
3
+ *
4
+ * Split out of preflight.mjs (iterate-2026-08-22): the "augment the lookup PATH
5
+ * and resolve a bare name to an absolute executable" concern is cohesive and
6
+ * independently testable, and keeping it here holds preflight.mjs under the
7
+ * 300-line ceiling. preflight.mjs owns the VERDICT; this module owns the LOOKUP.
8
+ */
9
+
10
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ /**
14
+ * Return a copy of `baseEnv` whose PATH also contains the well-known user-level
15
+ * bin dirs that the uv and Claude Code installers write to.
16
+ *
17
+ * WHY (Mac cold-start, iterate-2026-08-22): those installers drop their binary
18
+ * in `~/.local/bin` (and Homebrew in `/opt/homebrew/bin` | `/usr/local/bin`) and
19
+ * only APPEND that dir to the shell rc. The npx process that runs this
20
+ * bootstrapper still carries the PATH captured before the install, so a
21
+ * `shell: false` probe reported a perfectly-installed uv/claude as ABSENT until
22
+ * the user opened a fresh terminal. Augmenting the lookup PATH lets the probe
23
+ * find a just-installed tool without the restart.
24
+ *
25
+ * The extra dirs are APPENDED, never prepended — an explicitly-configured tool
26
+ * on the real PATH always wins; `~/.local/bin` is only a fallback. Bare-name,
27
+ * PATH-only: no cwd is ever added, so the win32-spawn security posture holds.
28
+ *
29
+ * Windows subtlety: `process.env` is case-insensitive but a plain object is not,
30
+ * so we collapse every `path`-cased key into a single canonical `PATH` — never
31
+ * hand spawn both a stale `Path` and an augmented `PATH`.
32
+ *
33
+ * @param {NodeJS.Platform} platform
34
+ * @param {string} homedir
35
+ * @param {Record<string, string | undefined>} [baseEnv]
36
+ * @returns {Record<string, string>}
37
+ */
38
+ export function probeEnv(platform, homedir, baseEnv = {}) {
39
+ const sep = platform === "win32" ? ";" : ":";
40
+ const extra =
41
+ platform === "win32"
42
+ ? [path.win32.join(homedir, ".local", "bin")]
43
+ : [path.posix.join(homedir, ".local", "bin"), "/opt/homebrew/bin", "/usr/local/bin"];
44
+
45
+ const out = {};
46
+ let current = "";
47
+ for (const [k, v] of Object.entries(baseEnv)) {
48
+ if (k.toLowerCase() === "path") {
49
+ if (v != null) current = v;
50
+ continue; // drop every path-cased key; a single PATH is re-added below
51
+ }
52
+ if (v != null) out[k] = v;
53
+ }
54
+ const merged = current ? current.split(sep).filter(Boolean) : [];
55
+ for (const d of extra) if (!merged.includes(d)) merged.push(d);
56
+ out.PATH = merged.join(sep);
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Resolve a bare command name to an absolute executable path by scanning the
62
+ * (already augmented) PATH — POSIX only. Node's own bare-name lookup does not
63
+ * reliably honour `options.env.PATH`, so we resolve the absolute path ourselves
64
+ * and spawn THAT, making the augmented PATH actually take effect. PATH-only,
65
+ * never cwd. Returns the bare name unchanged when nothing resolves (spawn then
66
+ * yields the same ENOENT → `ok:false` verdict as before).
67
+ * @param {string} name @param {Record<string,string|undefined>} env
68
+ */
69
+ export function resolvePosixBin(name, env) {
70
+ if (name.includes("/")) return name; // already a path
71
+ for (const dir of (env.PATH ?? "").split(":")) {
72
+ if (!dir) continue;
73
+ const cand = path.posix.join(dir, name);
74
+ try {
75
+ if (statSync(cand).isFile()) {
76
+ accessSync(cand, fsConstants.X_OK);
77
+ return cand;
78
+ }
79
+ } catch {
80
+ // not a usable executable here — keep searching
81
+ }
82
+ }
83
+ return name;
84
+ }
package/lib/util.mjs CHANGED
@@ -1,83 +1,88 @@
1
- /**
2
- * util.mjs — pure, dependency-free helpers shared across the bootstrapper.
3
- *
4
- * Everything here is side-effect free and unit-testable in isolation: SemVer
5
- * parsing/compare (attach-vs-swap + stale-copy decisions ride on it), platform
6
- * detection, and the actionable install hints a missing prerequisite must
7
- * print. No `claude`, no network, no filesystem — those live in the sibling
8
- * modules behind injected seams.
9
- */
10
-
11
- /** @param {string} v @returns {[number, number, number] | null} */
12
- export function parseSemver(v) {
13
- if (typeof v !== "string") return null;
14
- const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
15
- if (!m) return null;
16
- return [Number(m[1]), Number(m[2]), Number(m[3])];
17
- }
18
-
19
- /**
20
- * Compare two SemVer-shaped strings by MAJOR.MINOR.PATCH.
21
- * Returns -1 (a<b), 0 (equal or either unparseable), 1 (a>b). Pre-release
22
- * tails are ignored — the bootstrapper only ever needs the numeric triple.
23
- * @param {string} a @param {string} b @returns {-1 | 0 | 1}
24
- */
25
- export function compareSemver(a, b) {
26
- const pa = parseSemver(a);
27
- const pb = parseSemver(b);
28
- if (!pa || !pb) return 0;
29
- for (let i = 0; i < 3; i++) {
30
- if (pa[i] < pb[i]) return -1;
31
- if (pa[i] > pb[i]) return 1;
32
- }
33
- return 0;
34
- }
35
-
36
- /** @param {NodeJS.Platform} [platform] */
37
- export function isWindows(platform = process.platform) {
38
- return platform === "win32";
39
- }
40
-
41
- /**
42
- * Platform-correct, copy-pasteable install command for a missing prerequisite.
43
- * A missing tool must never be a vague "install it" — the user gets the exact
44
- * line for THEIR OS. Mirrors the pointers `scripts/verify-setup.sh` prints.
45
- * @param {"uv"|"python"|"node"|"git"|"claude"} tool
46
- * @param {NodeJS.Platform} [platform]
47
- * @returns {string}
48
- */
49
- export function installHint(tool, platform = process.platform) {
50
- const win = isWindows(platform);
51
- switch (tool) {
52
- case "uv":
53
- return win
54
- ? 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
55
- : "curl -LsSf https://astral.sh/uv/install.sh | sh";
56
- case "python":
57
- return win
58
- ? "install Python 3.11+ from https://www.python.org/downloads/ (NOT the Microsoft Store stub)"
59
- : "install Python 3.11+ (e.g. `brew install python` / `apt install python3`)";
60
- case "node":
61
- return "install Node.js >= 20.12.0 from https://nodejs.org/";
62
- case "git":
63
- return win
64
- ? "install Git from https://git-scm.com/download/win"
65
- : "install git (e.g. `brew install git` / `apt install git`)";
66
- case "claude":
67
- return "install Claude Code — https://docs.claude.com/en/docs/claude-code";
68
- default:
69
- return "";
70
- }
71
- }
72
-
73
- /** Minimum Node the packaged server needs (mirrors server/package.json engines). */
74
- export const MIN_NODE = "20.12.0";
75
-
76
- /** ASCII status glyphs — no emoji, PowerShell-5.1-safe, honest verdict blocks. */
77
- export const MARK = Object.freeze({
78
- pass: "[OK]",
79
- fail: "[!!]",
80
- warn: "[??]",
81
- skip: "[--]",
82
- info: "[..]",
83
- });
1
+ /**
2
+ * util.mjs — pure, dependency-free helpers shared across the bootstrapper.
3
+ *
4
+ * Everything here is side-effect free and unit-testable in isolation: SemVer
5
+ * parsing/compare (attach-vs-swap + stale-copy decisions ride on it), platform
6
+ * detection, and the actionable install hints a missing prerequisite must
7
+ * print. No `claude`, no network, no filesystem — those live in the sibling
8
+ * modules behind injected seams.
9
+ */
10
+
11
+ /** @param {string} v @returns {[number, number, number] | null} */
12
+ export function parseSemver(v) {
13
+ if (typeof v !== "string") return null;
14
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
15
+ if (!m) return null;
16
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
17
+ }
18
+
19
+ /**
20
+ * Compare two SemVer-shaped strings by MAJOR.MINOR.PATCH.
21
+ * Returns -1 (a<b), 0 (equal or either unparseable), 1 (a>b). Pre-release
22
+ * tails are ignored — the bootstrapper only ever needs the numeric triple.
23
+ * @param {string} a @param {string} b @returns {-1 | 0 | 1}
24
+ */
25
+ export function compareSemver(a, b) {
26
+ const pa = parseSemver(a);
27
+ const pb = parseSemver(b);
28
+ if (!pa || !pb) return 0;
29
+ for (let i = 0; i < 3; i++) {
30
+ if (pa[i] < pb[i]) return -1;
31
+ if (pa[i] > pb[i]) return 1;
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ /** @param {NodeJS.Platform} [platform] */
37
+ export function isWindows(platform = process.platform) {
38
+ return platform === "win32";
39
+ }
40
+
41
+ /**
42
+ * Platform-correct, copy-pasteable install command for a missing prerequisite.
43
+ * A missing tool must never be a vague "install it" — the user gets the exact
44
+ * line for THEIR OS. Mirrors the pointers `scripts/verify-setup.sh` prints.
45
+ * @param {"uv"|"python"|"node"|"git"|"claude"} tool
46
+ * @param {NodeJS.Platform} [platform]
47
+ * @returns {string}
48
+ */
49
+ export function installHint(tool, platform = process.platform) {
50
+ const win = isWindows(platform);
51
+ switch (tool) {
52
+ case "uv":
53
+ return win
54
+ ? 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
55
+ : "curl -LsSf https://astral.sh/uv/install.sh | sh";
56
+ case "python":
57
+ // Coupled to uv: the whole stack runs via `uv run`, which resolves a
58
+ // uv-MANAGED Python — a system python3 is neither required nor sufficient.
59
+ // Lead with the uv path; the OS package manager / python.org is the fallback.
60
+ return win
61
+ ? "install Python 3.11+ via uv (`uv python install 3.11`) — or from https://www.python.org/downloads/ (NOT the Microsoft Store stub)"
62
+ : "install Python 3.11+ via uv (`uv python install 3.11`) — or your package manager (e.g. `brew install python@3.11`)";
63
+ case "node":
64
+ return "install Node.js >= 20.12.0 from https://nodejs.org/";
65
+ case "git":
66
+ return win
67
+ ? "install Git from https://git-scm.com/download/win"
68
+ : "install git (e.g. `brew install git` / `apt install git`)";
69
+ case "claude":
70
+ return win
71
+ ? 'powershell -c "irm https://claude.ai/install.ps1 | iex"'
72
+ : "curl -fsSL https://claude.ai/install.sh | bash";
73
+ default:
74
+ return "";
75
+ }
76
+ }
77
+
78
+ /** Minimum Node the packaged server needs (mirrors server/package.json engines). */
79
+ export const MIN_NODE = "20.12.0";
80
+
81
+ /** ASCII status glyphs — no emoji, PowerShell-5.1-safe, honest verdict blocks. */
82
+ export const MARK = Object.freeze({
83
+ pass: "[OK]",
84
+ fail: "[!!]",
85
+ warn: "[??]",
86
+ skip: "[--]",
87
+ info: "[..]",
88
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svenroth-ai/shipwright",
3
- "version": "0.24.3-next.0",
3
+ "version": "0.24.4-next.0",
4
4
  "description": "One command to install AND update Shipwright — the /shipwright-* SDLC plugins and the Command Center. Run `npx @svenroth-ai/shipwright@latest`.",
5
5
  "type": "module",
6
6
  "bin": {