@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.
@@ -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
- export async function main(argv = process.argv.slice(2), log = (m) => console.log(m)) {
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 pluginPhase(log);
189
+ plugin = await runPluginPhase(log);
165
190
  exitCode = Math.max(exitCode, plugin.exitCode ?? 0);
166
191
  }
167
192
 
168
- if (!args.pluginsOnly) {
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 ensureServer({ port, pkgRoot: PKG_ROOT, packageVersion: SELF_VERSION, noOpen: args.noOpen, log });
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 (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
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
- const defaultBootServer = (port, pkgRoot) => runPlan(bootSpawnPlan(port, pkgRoot));
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 ~/.shipwright-webui/server-manual.log`,
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.1-next.0",
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",