@svenroth-ai/shipwright 0.24.2-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/bin/shipwright.mjs +48 -4
- package/lib/preflight.mjs +50 -6
- package/lib/probe-path.mjs +84 -0
- package/lib/server.mjs +49 -3
- package/lib/util.mjs +88 -83
- package/package.json +2 -1
package/bin/shipwright.mjs
CHANGED
|
@@ -142,7 +142,32 @@ export function printSummary(log, { plugin, server, port }) {
|
|
|
142
142
|
log(`========================================`);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Decide whether to boot the Command Center after the plugin phase.
|
|
147
|
+
*
|
|
148
|
+
* The cold-start failure this closes: when a hard prerequisite (Claude / uv /
|
|
149
|
+
* Python) is missing, the plugin phase is correctly SKIPPED — but the bin then
|
|
150
|
+
* booted the Command Center anyway, i.e. a WebUI that launches a Claude with NO
|
|
151
|
+
* `/shipwright-*` commands. That is useless, so the default flow now STOPS.
|
|
152
|
+
* `--webui-only` is the explicit opt-out (it never runs the plugin phase, so
|
|
153
|
+
* `pluginSkipped` is false there and it still boots).
|
|
154
|
+
*
|
|
155
|
+
* @param {{ pluginsOnly: boolean, webuiOnly: boolean, pluginSkipped: boolean }} s
|
|
156
|
+
* @returns {{ boot: boolean, reason: "plugins-only" | "prereqs-missing" | "ok" }}
|
|
157
|
+
*/
|
|
158
|
+
export function serverBootDecision({ pluginsOnly, webuiOnly, pluginSkipped }) {
|
|
159
|
+
if (pluginsOnly) return { boot: false, reason: "plugins-only" };
|
|
160
|
+
if (pluginSkipped && !webuiOnly) return { boot: false, reason: "prereqs-missing" };
|
|
161
|
+
return { boot: true, reason: "ok" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function main(argv = process.argv.slice(2), log = (m) => console.log(m), deps = {}) {
|
|
165
|
+
// Seams (default to the real implementations) so main's control flow — the
|
|
166
|
+
// prereqs-missing hard-stop especially — is unit-testable without a real
|
|
167
|
+
// toolchain or a live :3847.
|
|
168
|
+
const runPluginPhase = deps.pluginPhase ?? pluginPhase;
|
|
169
|
+
const runEnsureServer = deps.ensureServer ?? ensureServer;
|
|
170
|
+
|
|
146
171
|
const args = parseArgs(argv);
|
|
147
172
|
if (args.version) { log(SELF_VERSION); return 0; }
|
|
148
173
|
if (args.help) { log(HELP); return 0; }
|
|
@@ -161,13 +186,32 @@ export async function main(argv = process.argv.slice(2), log = (m) => console.lo
|
|
|
161
186
|
let server = null;
|
|
162
187
|
|
|
163
188
|
if (!args.webuiOnly) {
|
|
164
|
-
plugin = await
|
|
189
|
+
plugin = await runPluginPhase(log);
|
|
165
190
|
exitCode = Math.max(exitCode, plugin.exitCode ?? 0);
|
|
166
191
|
}
|
|
167
192
|
|
|
168
|
-
|
|
193
|
+
const bootDecision = serverBootDecision({
|
|
194
|
+
pluginsOnly: args.pluginsOnly,
|
|
195
|
+
webuiOnly: args.webuiOnly,
|
|
196
|
+
pluginSkipped: plugin?.skipped === true,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (bootDecision.reason === "prereqs-missing") {
|
|
200
|
+
// Hard-stop: a Command Center with no plugins would launch a Claude with no
|
|
201
|
+
// /shipwright-* commands. The per-tool install hints already printed above
|
|
202
|
+
// (renderVerdict); here we say why we stopped and how to override.
|
|
203
|
+
log(`\n${MARK.fail} Not starting the Command Center — required tools are missing.`);
|
|
204
|
+
log(` Shipwright needs Claude Code, Python 3.11+ with uv, and git installed first;`);
|
|
205
|
+
log(` without them the plugins cannot install and the Command Center would be empty.`);
|
|
206
|
+
log(` Install whichever tools are marked missing above, then re-run npx @svenroth-ai/shipwright@latest`);
|
|
207
|
+
log(` (To start the Command Center anyway, without plugins: add --webui-only.)`);
|
|
208
|
+
printSummary(log, { plugin, server: null, port: null });
|
|
209
|
+
return Math.max(exitCode, 1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (bootDecision.boot) {
|
|
169
213
|
try {
|
|
170
|
-
server = await
|
|
214
|
+
server = await runEnsureServer({ port, pkgRoot: PKG_ROOT, packageVersion: SELF_VERSION, noOpen: args.noOpen, log });
|
|
171
215
|
} catch (e) {
|
|
172
216
|
log(`${MARK.fail} ${String(e?.message ?? e)}`);
|
|
173
217
|
exitCode = Math.max(exitCode, 1);
|
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
|
|
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
|
-
|
|
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 —
|
|
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
|
|
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:
|
|
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/server.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { spawn } from "node:child_process";
|
|
19
|
-
import { readFileSync } from "node:fs";
|
|
19
|
+
import { readFileSync, openSync, closeSync, mkdirSync } from "node:fs";
|
|
20
20
|
import os from "node:os";
|
|
21
21
|
|
|
22
22
|
import { compareSemver } from "./util.mjs";
|
|
@@ -149,7 +149,52 @@ function runPlan(plan) {
|
|
|
149
149
|
return child.pid ?? null;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The file the DETACHED packaged server's stdout+stderr is redirected to — and
|
|
154
|
+
* the SAME path the boot readiness-timeout error tells the user to read. Kept as
|
|
155
|
+
* one exported function so the two can never drift apart again: the boot used to
|
|
156
|
+
* spawn with `stdio:"ignore"` (output discarded) while the error pointed the
|
|
157
|
+
* user at this log, which nothing wrote — an undiagnosable dead end.
|
|
158
|
+
*/
|
|
159
|
+
export function bootLogPath() {
|
|
160
|
+
return path.join(os.homedir(), ".shipwright-webui", "server-manual.log");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Open the boot log for append, creating its dir. Returns an fd, or null when
|
|
164
|
+
* it cannot be opened — logging is best-effort and must NEVER block the boot. */
|
|
165
|
+
function defaultOpenBootLog() {
|
|
166
|
+
try {
|
|
167
|
+
mkdirSync(path.dirname(bootLogPath()), { recursive: true });
|
|
168
|
+
return openSync(bootLogPath(), "a");
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the `stdio` for the detached boot: route BOTH stdout and stderr at the
|
|
176
|
+
* log fd when it opens, else fall back to `"ignore"` (the pre-fix behaviour, now
|
|
177
|
+
* only the degraded path). Pure over its `open` seam so both branches are
|
|
178
|
+
* unit-testable without touching the filesystem.
|
|
179
|
+
* @param {() => number | null} [open]
|
|
180
|
+
*/
|
|
181
|
+
export function resolveBootStdio(open = defaultOpenBootLog) {
|
|
182
|
+
const fd = open();
|
|
183
|
+
return fd == null ? "ignore" : ["ignore", fd, fd];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const defaultBootServer = (port, pkgRoot) => {
|
|
187
|
+
const plan = bootSpawnPlan(port, pkgRoot);
|
|
188
|
+
const stdio = resolveBootStdio();
|
|
189
|
+
const child = spawn(plan.command, plan.args, { ...plan.options, stdio });
|
|
190
|
+
child.unref();
|
|
191
|
+
// The child dups the fd at spawn time; drop the parent's copy so the
|
|
192
|
+
// short-lived npx process leaves no handle open (both slots share one fd).
|
|
193
|
+
if (Array.isArray(stdio) && typeof stdio[1] === "number") {
|
|
194
|
+
try { closeSync(stdio[1]); } catch { /* already gone — nothing to do */ }
|
|
195
|
+
}
|
|
196
|
+
return child.pid ?? null;
|
|
197
|
+
};
|
|
153
198
|
const defaultSpawnSwapper = (port, pkgRoot) => runPlan(swapperSpawnPlan(port, pkgRoot));
|
|
154
199
|
|
|
155
200
|
/** Default: read the swapper's durable verdict (pid + ok) if present. */
|
|
@@ -279,7 +324,8 @@ export async function ensureServer(opts) {
|
|
|
279
324
|
if (!ready.ok) {
|
|
280
325
|
throw new Error(
|
|
281
326
|
`the packaged server did not become ready on ${url} within ${timeoutMs} ms — ` +
|
|
282
|
-
`check
|
|
327
|
+
`check ${bootLogPath()} (if that file is empty or absent, its directory was unwritable — ` +
|
|
328
|
+
`re-run from a terminal to see the server's output directly)`,
|
|
283
329
|
);
|
|
284
330
|
}
|
|
285
331
|
result = { ...result, pid, version: ready.probe.version };
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
"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": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@lydell/node-pty": "^1.1.0",
|
|
51
51
|
"@xterm/addon-serialize": "0.14.0",
|
|
52
52
|
"@xterm/headless": "6.0.0",
|
|
53
|
+
"cron-parser": "^5.5.0",
|
|
53
54
|
"hono": "^4.12.34",
|
|
54
55
|
"ignore": "^5.3.2",
|
|
55
56
|
"p-queue": "^9.2.0",
|