@svenroth-ai/shipwright 0.24.1-next.0 → 0.24.3-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 +81 -6
- package/lib/server.mjs +49 -3
- package/package.json +2 -1
package/bin/shipwright.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* the installed plugin set changed.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
16
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
|
|
@@ -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);
|
|
@@ -178,8 +222,39 @@ export async function main(argv = process.argv.slice(2), log = (m) => console.lo
|
|
|
178
222
|
return exitCode;
|
|
179
223
|
}
|
|
180
224
|
|
|
225
|
+
/**
|
|
226
|
+
* True when this module is the process entry point — INCLUDING when it is
|
|
227
|
+
* invoked through a symlink. npm/npx install the executable as a symlink in
|
|
228
|
+
* `node_modules/.bin/`, so on macOS/Linux `process.argv[1]` is that symlink
|
|
229
|
+
* while Node realpaths the entry for `import.meta.url` (the real file). A plain
|
|
230
|
+
* `path.resolve` comparison of the two therefore never matches and `main()`
|
|
231
|
+
* never runs — the bootstrapper exits 0 having done NOTHING (the silent-no-op
|
|
232
|
+
* bug: `npx @svenroth-ai/shipwright` printed nothing on the Mac). Windows hid it
|
|
233
|
+
* because its npm `.cmd` shim invokes node with the REAL path, not a symlink.
|
|
234
|
+
*
|
|
235
|
+
* Fix: realpath BOTH sides before comparing, so a symlink and its target compare
|
|
236
|
+
* equal. The realpath is applied SYMMETRICALLY: if EITHER side cannot be
|
|
237
|
+
* realpath'd (`realpathSync` throws — ENOENT from an odd launcher, or a
|
|
238
|
+
* virtualized path such as Yarn PnP that has no on-disk realpath, or EACCES /
|
|
239
|
+
* ELOOP), BOTH sides fall back to plain `path.resolve`. A mixed branch — one
|
|
240
|
+
* side realpath'd, the other only resolved — would spuriously mismatch and
|
|
241
|
+
* re-introduce the very silent-no-op this fixes; symmetry rules that out. An
|
|
242
|
+
* entry-point guard must also never crash the bootstrapper on import, hence the
|
|
243
|
+
* catch. The real npx entry always has an existing argv[1], so both sides
|
|
244
|
+
* realpath and canonicalize identically.
|
|
245
|
+
*/
|
|
246
|
+
export function isMainModule(argv1, metaUrl) {
|
|
247
|
+
if (!argv1) return false;
|
|
248
|
+
const self = fileURLToPath(metaUrl);
|
|
249
|
+
try {
|
|
250
|
+
return realpathSync(argv1) === realpathSync(self);
|
|
251
|
+
} catch {
|
|
252
|
+
return path.resolve(argv1) === path.resolve(self);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
181
256
|
// Run only when invoked as a script — importing (tests) stays side-effect free.
|
|
182
|
-
if (
|
|
257
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
183
258
|
main().then((code) => process.exit(code)).catch((e) => {
|
|
184
259
|
console.error(`[shipwright] fatal: ${String(e?.stack ?? e)}`);
|
|
185
260
|
process.exit(1);
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svenroth-ai/shipwright",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.3-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",
|