@codeyam-editor/codeyam-editor 0.1.1 → 0.1.3

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 (53) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1929 -0
  4. package/npm/container.js +22 -3
  5. package/npm/editor-dev.js +368 -37
  6. package/npm/editor.js +133 -52
  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-cleanup.js +297 -0
  14. package/npm/fleet-effect-status.js +179 -0
  15. package/npm/fleet-env.js +125 -0
  16. package/npm/fleet-grouping.js +56 -0
  17. package/npm/fleet-job-badge.js +39 -0
  18. package/npm/fleet-memory.js +157 -0
  19. package/npm/fleet-pool-policy.js +78 -0
  20. package/npm/fleet-pool.js +401 -0
  21. package/npm/fleet-queue.js +40 -0
  22. package/npm/fleet-reconcile.js +36 -0
  23. package/npm/fleet-reset.js +222 -0
  24. package/npm/fleet-roster.js +81 -0
  25. package/npm/launch-config.js +622 -0
  26. package/npm/preflight-guidance.js +77 -0
  27. package/npm/scenario-check.js +323 -14
  28. package/npm/scenario-handlers.js +16 -5
  29. package/npm/scenario-interactivity.js +245 -0
  30. package/npm/scenario-metrics.js +16 -2
  31. package/npm/scenario-mocks.js +62 -25
  32. package/npm/scenario-playwright.js +350 -8
  33. package/npm/utils.js +562 -22
  34. package/npm/vm-urls.js +117 -0
  35. package/package.json +8 -7
  36. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  37. package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
  38. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  39. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  40. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
  41. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
  42. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
  43. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  44. package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
  45. package/ui/dist/assets/index-KDFAgt2e.js +27 -0
  46. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  47. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  48. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  49. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  50. package/ui/dist/favicon.ico +0 -0
  51. package/ui/dist/favicon.png +0 -0
  52. package/ui/dist/index.html +16 -0
  53. package/npm/postinstall.js +0 -132
package/npm/container.js CHANGED
@@ -8,12 +8,31 @@ const path = require("path");
8
8
  * inline monitoring UI at http://localhost:14199.
9
9
  */
10
10
 
11
- const { ensureServer, rootDir, killChildProcess } = require("./utils");
11
+ const {
12
+ ensureServer,
13
+ rootDir,
14
+ killChildProcess,
15
+ readMergedEditorConfig,
16
+ resolveLauncherPort,
17
+ } = require("./utils");
12
18
  const { open } = require("./open");
13
19
 
14
- const PORT = 14199;
15
-
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
+ */
16
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
+
17
36
  const serverChild = await ensureServer(PORT);
18
37
  const url = `http://localhost:${PORT}`;
19
38
  console.error(`Container UI: ${url}`);
package/npm/editor-dev.js CHANGED
@@ -14,17 +14,47 @@
14
14
  * Rust binary, then forwards args to it.
15
15
  * codeyam-editor-dev editor step 1
16
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`.
17
27
  */
18
28
 
19
- const { execSync, spawn } = require("child_process");
29
+ const { execSync, spawn, spawnSync } = require("child_process");
20
30
  const path = require("path");
21
- const fs = require("fs");
22
- const { ensureServer, restartServer, waitForPort, ensureBinary, uiDistDir, rootDir, killChildProcess } = require("./utils");
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");
23
43
  const { open } = require("./open");
44
+ const { classifyProject } = require("./preflight-guidance");
45
+ const { isEmptyProjectDir } = require("./empty-project-dir");
24
46
 
25
- // Tell the Rust binary to emit "codeyam-editor-dev" in all instructions,
26
- // permissions, and installed files instead of "codeyam-editor".
27
- process.env.CODEYAM_CLI = "codeyam-editor-dev";
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
+ }
28
58
 
29
59
  const args = process.argv.slice(2);
30
60
  const KNOWN_SUBCOMMANDS = ["start", "init", "editor"];
@@ -42,85 +72,386 @@ function hasSubcommand(argv = args) {
42
72
  return false;
43
73
  }
44
74
 
45
- /** Build the Rust binary from the editor repo. */
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
+ */
46
80
  function buildBinary() {
47
81
  console.error("Building Rust binary...");
48
82
  try {
49
83
  execSync("cargo build", { stdio: "inherit", cwd: rootDir });
84
+ return true;
50
85
  } catch {
51
- console.error("Failed to build Rust binary. Continuing with existing build (if any)...");
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.`;
52
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;
53
246
  }
54
247
 
55
- module.exports = { hasSubcommand, buildBinary };
248
+ module.exports = {
249
+ hasSubcommand,
250
+ buildBinary,
251
+ shouldRebuildOnPassthrough,
252
+ staleBinaryBanner,
253
+ hasStartCommand,
254
+ planPreflightAction,
255
+ postLaunchInitBanner,
256
+ shouldOpenBrowser,
257
+ };
56
258
 
57
259
  if (require.main === module) {
58
260
  // --- Passthrough mode: build binary, then forward to it ---
59
261
  if (hasSubcommand()) {
60
- buildBinary();
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
+ }
61
271
  const binary = ensureBinary();
62
272
  const child = spawn(binary, args, {
63
273
  stdio: "inherit",
64
274
  cwd: process.cwd(),
65
- env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() },
275
+ env: process.env,
66
276
  });
67
277
  child.on("exit", (code) => {
68
278
  process.exit(code ?? 1);
69
279
  });
70
280
  } else {
71
281
  // --- Launcher mode: build everything, start server, open browser ---
72
- const SERVER_PORT = 14199;
73
282
  const shouldRestart = args.includes("--restart");
74
283
 
75
- const readProjectPort = () => {
76
- try {
77
- const configPath = path.join(process.cwd(), ".codeyam", "editor.json");
78
- const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
79
- return config.port || 3000;
80
- } catch {
81
- return 3000;
82
- }
83
- };
84
-
85
284
  const launcherMain = async () => {
86
285
  const projectDir = process.cwd();
87
286
  console.error(`Editor repo: ${rootDir}`);
88
287
  console.error(`Target project: ${projectDir}`);
89
288
 
90
- buildBinary();
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
+ );
91
337
 
92
- // Build the UI from the editor repo
93
- console.error("Building UI...");
94
- try {
95
- execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
96
- } catch {
97
- console.error("Failed to build UI. Continuing with existing build (if any)...");
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
+ }
98
365
  }
99
366
 
100
367
  const serverChild = shouldRestart
101
368
  ? await restartServer(SERVER_PORT)
102
369
  : await ensureServer(SERVER_PORT);
103
370
 
104
- const appPort = readProjectPort();
105
- const viteInternalPort = appPort + 1;
106
- console.error(`Waiting for dev server on port ${viteInternalPort}...`);
107
- const viteReady = await waitForPort(viteInternalPort, 15000, 300);
108
- if (!viteReady) {
109
- console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`);
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
+ }
110
388
  }
111
389
 
112
390
  const url = `http://localhost:${SERVER_PORT}`;
113
391
  console.error(`Editor UI: ${url}`);
114
- console.error(`Live Preview: http://localhost:${appPort}`);
392
+ if (hasCommand) {
393
+ console.error(`Live Preview: http://localhost:${appPort}`);
394
+ }
115
395
  console.error(`Project: ${projectDir}`);
116
- open(url);
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
+ }
117
424
  const keepAlive = setInterval(() => {}, 60_000);
118
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
+
119
430
  process.on("SIGINT", () => {
431
+ tearingDown = true;
120
432
  clearInterval(keepAlive);
121
433
  killChildProcess(serverChild);
122
434
  process.exit(0);
123
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
+ }
124
455
  };
125
456
 
126
457
  launcherMain();