@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a

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.
Files changed (56) hide show
  1. package/npm/cli.js +19 -0
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +2184 -0
  4. package/npm/container.js +54 -0
  5. package/npm/editor-dev.js +459 -0
  6. package/npm/editor.js +272 -0
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-branch.js +49 -0
  10. package/npm/fleet-broker-death.js +44 -0
  11. package/npm/fleet-broker-debounce.js +63 -0
  12. package/npm/fleet-broker-status.js +45 -0
  13. package/npm/fleet-build-data.js +81 -0
  14. package/npm/fleet-cleanup.js +297 -0
  15. package/npm/fleet-effect-status.js +179 -0
  16. package/npm/fleet-env.js +125 -0
  17. package/npm/fleet-grouping.js +56 -0
  18. package/npm/fleet-job-badge.js +83 -0
  19. package/npm/fleet-memory.js +157 -0
  20. package/npm/fleet-plans.js +96 -0
  21. package/npm/fleet-pool-policy.js +78 -0
  22. package/npm/fleet-pool.js +401 -0
  23. package/npm/fleet-queue.js +40 -0
  24. package/npm/fleet-reconcile.js +93 -0
  25. package/npm/fleet-reset.js +222 -0
  26. package/npm/fleet-roster.js +81 -0
  27. package/npm/launch-config.js +717 -0
  28. package/npm/open.js +15 -0
  29. package/npm/preflight-guidance.js +77 -0
  30. package/npm/scenario-check.js +604 -0
  31. package/npm/scenario-handlers.js +82 -0
  32. package/npm/scenario-interactivity.js +245 -0
  33. package/npm/scenario-issues.js +35 -0
  34. package/npm/scenario-metrics.js +108 -0
  35. package/npm/scenario-mocks.js +107 -0
  36. package/npm/scenario-playwright.js +534 -0
  37. package/npm/utils.js +823 -0
  38. package/npm/vm-urls.js +117 -0
  39. package/package.json +32 -0
  40. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  41. package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
  42. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  43. package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
  44. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  45. package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
  46. package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
  47. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  48. package/ui/dist/assets/index-DzoF9RKw.js +27 -0
  49. package/ui/dist/assets/index-ckz94rKI.css +1 -0
  50. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  51. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  52. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  53. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  54. package/ui/dist/favicon.ico +0 -0
  55. package/ui/dist/favicon.png +0 -0
  56. package/ui/dist/index.html +16 -0
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require("path");
4
+ /**
5
+ * Start the container monitoring UI.
6
+ *
7
+ * Starts the Rust backend (if not already running) and opens the
8
+ * inline monitoring UI at http://localhost:14199.
9
+ */
10
+
11
+ const {
12
+ ensureServer,
13
+ rootDir,
14
+ killChildProcess,
15
+ readMergedEditorConfig,
16
+ resolveLauncherPort,
17
+ } = require("./utils");
18
+ const { open } = require("./open");
19
+
20
+ /**
21
+ * npm wrapper entry point for the container binary: ensures the Rust
22
+ * editor server is running and opens the container monitoring UI in
23
+ * a browser.
24
+ */
25
+ async function main() {
26
+ // Same resolution order as the editor launcher: env var
27
+ // (CODEYAM_EDITOR_PORT) → merged editor.json + editor.local.json
28
+ // (proxy.controlPort) → hard fallback (14199).
29
+ const merged = readMergedEditorConfig(rootDir);
30
+ const PORT = resolveLauncherPort(
31
+ process.env.CODEYAM_EDITOR_PORT,
32
+ merged.proxy && merged.proxy.controlPort,
33
+ 14199,
34
+ );
35
+
36
+ const serverChild = await ensureServer(PORT);
37
+ const url = `http://localhost:${PORT}`;
38
+ console.error(`Container UI: ${url}`);
39
+ console.error(`Logs: ${path.join(rootDir, ".codeyam", "logs")}`);
40
+ open(url);
41
+ const keepAlive = setInterval(() => {}, 60_000);
42
+
43
+ process.on("SIGINT", () => {
44
+ clearInterval(keepAlive);
45
+ killChildProcess(serverChild);
46
+ process.exit(0);
47
+ });
48
+ }
49
+
50
+ module.exports = { main };
51
+
52
+ if (require.main === module) {
53
+ main();
54
+ }
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Development CLI for running the codeyam-editor against any project.
5
+ *
6
+ * Two modes:
7
+ *
8
+ * Launcher — no subcommand (or --restart): builds the Rust binary and UI
9
+ * from the editor repo, starts the server, waits for readiness, opens browser.
10
+ * codeyam-editor-dev
11
+ * codeyam-editor-dev --restart
12
+ *
13
+ * Passthrough — any subcommand (start, init, editor, ...): rebuilds the
14
+ * Rust binary, then forwards args to it.
15
+ * codeyam-editor-dev editor step 1
16
+ * codeyam-editor-dev editor advance
17
+ *
18
+ * The wrapper is symlinked twice by scripts/bootstrap.sh:
19
+ * codeyam-editor-dev → this file (preserves the -dev branding;
20
+ * scaffolded content emits "codeyam-editor-dev").
21
+ * codeyam-editor → this file (shadows the canonical name on PATH
22
+ * so hooks and skills that emit `codeyam-editor` still get the
23
+ * auto-rebuild behavior on local dev; scaffolded content emits the
24
+ * canonical "codeyam-editor").
25
+ * The invocation name is read from `process.argv[1]` to decide which
26
+ * branding the Rust binary should use via `CODEYAM_CLI`.
27
+ */
28
+
29
+ const { execSync, spawn, spawnSync } = require("child_process");
30
+ const path = require("path");
31
+ const {
32
+ ensureServer,
33
+ restartServer,
34
+ waitForPort,
35
+ ensureBinary,
36
+ rootDir,
37
+ killChildProcess,
38
+ readMergedEditorConfig,
39
+ resolveLauncherPort,
40
+ staleBinaryBanner,
41
+ enforceFreshBinary,
42
+ } = require("./utils");
43
+ const { open } = require("./open");
44
+ const { classifyProject } = require("./preflight-guidance");
45
+ const { isEmptyProjectDir } = require("./empty-project-dir");
46
+
47
+ // When invoked through the `codeyam-editor-dev` symlink, tell the Rust
48
+ // binary to emit "codeyam-editor-dev" in all scaffolded commands,
49
+ // permissions, and installed files. When invoked through the
50
+ // `codeyam-editor` symlink, leave CODEYAM_CLI unset so the binary
51
+ // emits the canonical "codeyam-editor" — matching the cloud install
52
+ // path and the new hook/skill convention. The runtime cost of the
53
+ // rebuild-on-passthrough behavior is identical for both names.
54
+ const invokedAs = path.basename(process.argv[1] || "");
55
+ if (invokedAs === "codeyam-editor-dev") {
56
+ process.env.CODEYAM_CLI = "codeyam-editor-dev";
57
+ }
58
+
59
+ const args = process.argv.slice(2);
60
+ const KNOWN_SUBCOMMANDS = ["start", "init", "editor"];
61
+
62
+ /**
63
+ * Check if the first non-flag argument is a known subcommand.
64
+ * Accepts an explicit argv array so the helper is testable without mutating
65
+ * process.argv (the module-level `args` const is the production default).
66
+ */
67
+ function hasSubcommand(argv = args) {
68
+ for (const arg of argv) {
69
+ if (arg.startsWith("-")) continue;
70
+ return KNOWN_SUBCOMMANDS.includes(arg);
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Build the Rust binary from the editor repo. Returns true on success,
77
+ * false if `cargo build` failed. This function only reports the outcome —
78
+ * the caller decides whether a failed build is fatal (see staleBinaryBanner).
79
+ */
80
+ function buildBinary() {
81
+ console.error("Building Rust binary...");
82
+ try {
83
+ execSync("cargo build", { stdio: "inherit", cwd: rootDir });
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Decide whether the dev wrapper should rebuild the Rust binary (and the UI,
92
+ * for the launcher path) before forwarding to it. The answer is always yes:
93
+ * `codeyam-editor-dev` ALWAYS rebuilds before running. Running fresh source is
94
+ * the entire purpose of the dev wrapper — if a developer wants a frozen tool
95
+ * that never rebuilds, the cargo-installed `codeyam-editor` binary is exactly
96
+ * that.
97
+ *
98
+ * `cargo build` is idempotent: a sub-second no-op that does not relink when
99
+ * nothing changed. So always-building costs nothing for a developer who only
100
+ * uses the editor as a tool against their own app (the binary never changes →
101
+ * the server-side stale-binary watcher never fires → zero mid-session
102
+ * restarts), and produces a rebuild+restart only when editor source actually
103
+ * changed — which is exactly when the developer wants the new code live.
104
+ *
105
+ * The injected cwd / repoRoot / env are retained so the contract — "rebuild
106
+ * regardless of where you run it or what env is set" — is demonstrable in a
107
+ * unit test across the dimensions that used to gate the decision (self-host
108
+ * vs. external project, `CODEYAM_DEV_REBUILD` set or not). They no longer
109
+ * influence the result.
110
+ */
111
+ function shouldRebuildOnPassthrough() {
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Returns true when the merged project config has a non-empty start
117
+ * command — either at `apps[0].startCommand` (per-app, preferred) or at
118
+ * the top-level `startCommand` (legacy default). Mirrors the Rust
119
+ * `resolve_start_command` precedence in
120
+ * `crates/codeyam-editor/src/commands/start.rs`.
121
+ *
122
+ * When neither slot has a non-empty string, the editor server runs in
123
+ * "editor UI only" mode and the launcher must skip the dev-server port
124
+ * wait — otherwise the user stares at "Waiting for dev server on port
125
+ * N..." for the full 15s timeout.
126
+ */
127
+ function hasStartCommand(mergedConfig) {
128
+ if (mergedConfig == null || typeof mergedConfig !== "object") return false;
129
+ const apps = Array.isArray(mergedConfig.apps) ? mergedConfig.apps : null;
130
+ if (apps && apps.length > 0) {
131
+ const cmd = apps[0] && apps[0].startCommand;
132
+ if (typeof cmd === "string" && cmd.trim() !== "") return true;
133
+ }
134
+ const topLevel = mergedConfig.startCommand;
135
+ return typeof topLevel === "string" && topLevel.trim() !== "";
136
+ }
137
+
138
+ /**
139
+ * Decide what the launcher should do given a `classifyProject` result.
140
+ * Pure — no fs, no spawn, no process.exit — so `launcherMain`'s branching
141
+ * is unit-testable without building a binary or spawning a server (the same
142
+ * policy/classification split as `classifyPortHolder` in utils.js).
143
+ *
144
+ * modern → { action: "proceed" } launch as-is
145
+ * none → { action: "init" } greenfield: init, then launch
146
+ * legacy → { action: "bail", guidance, code: 2 } print guidance and exit
147
+ *
148
+ * Greenfield auto-inits because the dev launcher is a one-shot "build
149
+ * everything, run the editor against this project" tool — running it in a
150
+ * fresh dir should just work. Legacy bails instead: `cmd_init`'s legacy
151
+ * branch deliberately skips `editor.json` creation so `/codeyam-onboard`
152
+ * can migrate from the preserved classification, so auto-running init would
153
+ * strand them. The Rust-side `start_preflight` stays the safety net for
154
+ * direct `codeyam-editor start` calls; this launcher composes init + start.
155
+ *
156
+ * Intentional divergence (improve39): this launcher auto-inits ALL `none`
157
+ * projects (empty or existing-source); the Rust `start_preflight` now
158
+ * auto-inits only the EMPTY case and still bails (exit 2) on existing-source.
159
+ * That gap is pre-existing and deliberate — the dev launcher is a local
160
+ * convenience, not the direct-`start` contract.
161
+ */
162
+ function planPreflightAction(classification) {
163
+ if (classification.kind === "legacy") {
164
+ return { action: "bail", guidance: classification.guidance, code: 2 };
165
+ }
166
+ if (classification.kind === "none") {
167
+ return { action: "init" };
168
+ }
169
+ return { action: "proceed" };
170
+ }
171
+
172
+ /**
173
+ * Pick the launcher's post-launch tail banner — printed right next to the
174
+ * `Editor UI: http://…` line where the user is actually looking, after the
175
+ * editor server is up. Pure (no fs, no spawn) so the three-arm decision is
176
+ * unit-testable without spawning a binary, mirroring `planPreflightAction`.
177
+ *
178
+ * Skill assumption: /codeyam-editor scaffolds a project from scratch;
179
+ * /codeyam-onboard migrates an existing source tree. Empty greenfield
180
+ * (no non-hidden entries before init) must route to the scaffold path;
181
+ * existing greenfield routes to the migration path. Mirrors the same
182
+ * split in `cmd_init` (init.rs:10-24) — keep both in sync. Do NOT
183
+ * "simplify" by collapsing the branches; the skill each path names is the
184
+ * whole point.
185
+ *
186
+ * action !== "init" → null (modern: proceed, no banner;
187
+ * legacy already bailed earlier)
188
+ * action "init", wasEmpty → greenfield message. /codeyam-editor is driven
189
+ * from inside the browser UI, never hand-invoked,
190
+ * so the wording follows `browserOpened`: if the
191
+ * launcher actually opened a tab, promise it; if
192
+ * not (e.g. --restart attach), point at the URL
193
+ * so the user still has a path forward. The
194
+ * banner must never promise a tab that didn't
195
+ * appear.
196
+ * action "init", !wasEmpty → /codeyam-onboard migrate-existing
197
+ */
198
+ function postLaunchInitBanner({ preflightAction, wasEmpty, browserOpened, url }) {
199
+ if (preflightAction !== "init") return null;
200
+ if (wasEmpty) {
201
+ return browserOpened
202
+ ? "\nA browser window is opening where you can begin your project."
203
+ : `\nOpen ${url} in your browser to begin your project.`;
204
+ }
205
+ return (
206
+ "\nInitialized .codeyam/ in this directory. To populate scenarios, " +
207
+ "open Claude Code here and run `/codeyam-onboard`."
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Decide whether the launcher should open a browser tab to the editor UI.
213
+ * Pure (no fs, no spawn) so the gating is unit-testable without a binary,
214
+ * mirroring `planPreflightAction` / `postLaunchInitBanner`.
215
+ *
216
+ * The browser open belongs ONLY on a genuine cold start — bringing the
217
+ * editor server up fresh against a not-yet-running port. Two cases must
218
+ * NOT re-pop a tab, because one is presumably already open:
219
+ *
220
+ * shouldRestart → false `--restart` (rebuild-self, restart-server routed
221
+ * through this wrapper, a manual --restart) tears
222
+ * down and respawns a server the user is already
223
+ * looking at. Re-opening spams tabs — the reported
224
+ * symptom. Mirrors the Rust `--no-open` restart
225
+ * spawn in restart_server.rs.
226
+ * wasRunning → false `ensureServer` attached to an already-running
227
+ * same-project server (returned null) — no fresh
228
+ * process, so no new tab.
229
+ *
230
+ * Only a fresh spawn on a cold start (neither restart nor attach) opens the
231
+ * UI, matching the Rust `no_open`-gated `open_url` in start.rs.
232
+ *
233
+ * freshInit → true A brand-new empty project that was just init'd
234
+ * this run. The entire greenfield onboarding flow
235
+ * is driven from inside the editor UI, and a
236
+ * project that didn't exist a moment ago has no
237
+ * pre-existing tab for the --restart tab-spam guard
238
+ * to protect. So a fresh init opens regardless of
239
+ * `shouldRestart` — UNLESS we merely attached to an
240
+ * already-running server (`wasRunning`), which
241
+ * implies a tab is already up.
242
+ */
243
+ function shouldOpenBrowser({ shouldRestart, wasRunning, freshInit }) {
244
+ if (freshInit) return !wasRunning;
245
+ return !shouldRestart && !wasRunning;
246
+ }
247
+
248
+ module.exports = {
249
+ hasSubcommand,
250
+ buildBinary,
251
+ shouldRebuildOnPassthrough,
252
+ staleBinaryBanner,
253
+ hasStartCommand,
254
+ planPreflightAction,
255
+ postLaunchInitBanner,
256
+ shouldOpenBrowser,
257
+ };
258
+
259
+ if (require.main === module) {
260
+ // --- Passthrough mode: build binary, then forward to it ---
261
+ if (hasSubcommand()) {
262
+ // Always rebuild before forwarding — running fresh source is the whole
263
+ // point of the dev wrapper. `cargo build` is a sub-second no-op when
264
+ // nothing changed (no relink → the server-side stale-binary watcher never
265
+ // fires), and relinks only when editor source actually changed, which is
266
+ // exactly when the developer wants the new code live. `ensureBinary()`
267
+ // still builds once if no binary exists at all.
268
+ if (shouldRebuildOnPassthrough()) {
269
+ enforceFreshBinary(buildBinary(), { action: "run editor commands" });
270
+ }
271
+ const binary = ensureBinary();
272
+ const child = spawn(binary, args, {
273
+ stdio: "inherit",
274
+ cwd: process.cwd(),
275
+ env: process.env,
276
+ });
277
+ child.on("exit", (code) => {
278
+ process.exit(code ?? 1);
279
+ });
280
+ } else {
281
+ // --- Launcher mode: build everything, start server, open browser ---
282
+ const shouldRestart = args.includes("--restart");
283
+
284
+ const launcherMain = async () => {
285
+ const projectDir = process.cwd();
286
+ console.error(`Editor repo: ${rootDir}`);
287
+ console.error(`Target project: ${projectDir}`);
288
+
289
+ // Greenfield projects are auto-initialized here so the dev launcher
290
+ // stays a one-shot "build everything, run the editor against this
291
+ // project" tool. Legacy projects bail with guidance; modern projects
292
+ // skip init. See `planPreflightAction` for the rationale on each
293
+ // branch. Subcommand pass-through (handled above) is unaffected — the
294
+ // binary classifies its own subcommands.
295
+ const preflight = planPreflightAction(classifyProject(projectDir));
296
+ // Capture emptiness BEFORE init runs — the spawn below creates
297
+ // .codeyam/ (and install_editor_files writes .claude/ etc.), which
298
+ // would make every project look non-empty by the time the
299
+ // post-launch banner prints. Same constraint as the Rust comment in
300
+ // `init_greenfield_branch` (init.rs:82-85). Computed as a sibling
301
+ // local (not folded into `preflight`) because emptiness is
302
+ // orthogonal to the preflight kind.
303
+ const wasEmpty = isEmptyProjectDir(projectDir);
304
+ if (preflight.action === "bail") {
305
+ console.error(preflight.guidance);
306
+ process.exit(preflight.code);
307
+ }
308
+ if (preflight.action === "init") {
309
+ // Run init from projectDir before the config reads below so the
310
+ // freshly-written .codeyam/editor.json feeds port resolution.
311
+ // ensureBinary builds the binary first if this is a fresh checkout.
312
+ console.error(`Initializing codeyam-editor in ${projectDir}...`);
313
+ const initBinary = ensureBinary();
314
+ const initResult = spawnSync(initBinary, ["init"], {
315
+ cwd: projectDir,
316
+ stdio: "inherit",
317
+ env: process.env,
318
+ });
319
+ if (initResult.status !== 0) {
320
+ console.error(
321
+ `codeyam-editor init exited ${initResult.status ?? "by signal"}; aborting launch.`,
322
+ );
323
+ process.exit(1);
324
+ }
325
+ }
326
+
327
+ // Editor server's control port is owned by the editor repo's
328
+ // merged config (rootDir), not the target project's. The target
329
+ // project's own `editor.local.json` only contributes to the
330
+ // app/dev-server port below.
331
+ const editorMerged = readMergedEditorConfig(rootDir);
332
+ const SERVER_PORT = resolveLauncherPort(
333
+ process.env.CODEYAM_EDITOR_PORT,
334
+ editorMerged.proxy && editorMerged.proxy.controlPort,
335
+ 14199,
336
+ );
337
+
338
+ // Target project's dev-server port comes from its own
339
+ // editor.json + editor.local.json (developers iterating on the
340
+ // editor against a real client project rely on the override
341
+ // file here too).
342
+ const projectMerged = readMergedEditorConfig(projectDir);
343
+ const appPort = resolveLauncherPort(undefined, projectMerged.port, 3000);
344
+
345
+ // Always rebuild the Rust binary + UI before launching — same contract
346
+ // as the passthrough path. The build runs BEFORE the server (re)starts
347
+ // below, so the new server records its start time after the relink and
348
+ // the stale-binary watcher reads it as fresh (no immediate SIGUSR1). A
349
+ // no-op `cargo build` (nothing changed) doesn't relink, so an external
350
+ // client-project launch is never disrupted. `ensureBinary()` still
351
+ // builds once if no binary exists.
352
+ if (shouldRebuildOnPassthrough()) {
353
+ enforceFreshBinary(buildBinary(), {
354
+ action: "run editor commands",
355
+ continueVerb: "launching with",
356
+ });
357
+
358
+ // Build the UI from the editor repo
359
+ console.error("Building UI...");
360
+ try {
361
+ execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
362
+ } catch {
363
+ console.error("Failed to build UI. Continuing with existing build (if any)...");
364
+ }
365
+ }
366
+
367
+ const serverChild = shouldRestart
368
+ ? await restartServer(SERVER_PORT)
369
+ : await ensureServer(SERVER_PORT);
370
+
371
+ // Re-read the (possibly just-initialized) project config to decide
372
+ // whether to wait on a dev server at all. On a project where init
373
+ // couldn't detect a framework (or a user-configured "editor UI
374
+ // only" setup), `startCommand` is empty — the editor server itself
375
+ // prints "No startCommand configured. Running editor UI only." and
376
+ // never spawns a dev server, so waiting 15s for `appPort + 1` is a
377
+ // pure UX hang. The launcher silently skips the wait; the server's
378
+ // own log line is the canonical signal to the user.
379
+ const postInitMerged = readMergedEditorConfig(projectDir);
380
+ const hasCommand = hasStartCommand(postInitMerged);
381
+ if (hasCommand) {
382
+ const viteInternalPort = appPort + 1;
383
+ console.error(`Waiting for dev server on port ${viteInternalPort}...`);
384
+ const viteReady = await waitForPort(viteInternalPort, 15000, 300);
385
+ if (!viteReady) {
386
+ console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`);
387
+ }
388
+ }
389
+
390
+ const url = `http://localhost:${SERVER_PORT}`;
391
+ console.error(`Editor UI: ${url}`);
392
+ if (hasCommand) {
393
+ console.error(`Live Preview: http://localhost:${appPort}`);
394
+ }
395
+ console.error(`Project: ${projectDir}`);
396
+ // Open the browser only on a genuine cold start. On the --restart path
397
+ // (rebuild-self, restart-server, manual --restart) and when we attached
398
+ // to an already-running server, a tab is presumably already open — the
399
+ // `Editor UI: <url>` line above is the signal. `ensureServer` returns
400
+ // null when it attached rather than spawning, so that is `wasRunning`.
401
+ // Exception: a fresh greenfield init (`freshInit`) opens regardless of
402
+ // --restart, because a project that didn't exist a moment ago has no
403
+ // pre-existing tab to protect and the onboarding flow lives in the UI.
404
+ // Compute the open decision once and feed the SAME boolean into the
405
+ // banner so it can never promise a tab that didn't appear.
406
+ const freshInit = preflight.action === "init" && wasEmpty;
407
+ const openedBrowser = shouldOpenBrowser({
408
+ shouldRestart,
409
+ wasRunning: serverChild === null,
410
+ freshInit,
411
+ });
412
+ if (openedBrowser) {
413
+ open(url);
414
+ }
415
+ const banner = postLaunchInitBanner({
416
+ preflightAction: preflight.action,
417
+ wasEmpty,
418
+ browserOpened: openedBrowser,
419
+ url,
420
+ });
421
+ if (banner) {
422
+ console.error(banner);
423
+ }
424
+ const keepAlive = setInterval(() => {}, 60_000);
425
+
426
+ // Track launcher-initiated teardown so the unexpected-exit handler below
427
+ // can tell "we killed it on SIGINT" from "the server died on its own".
428
+ let tearingDown = false;
429
+
430
+ process.on("SIGINT", () => {
431
+ tearingDown = true;
432
+ clearInterval(keepAlive);
433
+ killChildProcess(serverChild);
434
+ process.exit(0);
435
+ });
436
+
437
+ // Defense-in-depth against orphaned launchers: if the spawned server
438
+ // child exits and we did NOT initiate it (SIGINT), clear keepAlive and
439
+ // exit rather than lingering as a node process holding a dead child
440
+ // handle. We deliberately do NOT respawn — an exited server should let
441
+ // the launcher exit cleanly so stale supervisors can't accumulate across
442
+ // repeated launches. The in-place SIGUSR1 re-exec keeps the SAME pid, so
443
+ // it does NOT emit an "exit" here and does not trip this path; only a
444
+ // genuinely dead server does. `serverChild` is null when we attached to
445
+ // an already-running server (nothing of ours to watch).
446
+ if (serverChild) {
447
+ serverChild.on("exit", (code) => {
448
+ if (tearingDown) {
449
+ return;
450
+ }
451
+ clearInterval(keepAlive);
452
+ process.exit(code ?? 1);
453
+ });
454
+ }
455
+ };
456
+
457
+ launcherMain();
458
+ }
459
+ }