@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6

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 (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/utils.js CHANGED
@@ -2,6 +2,7 @@ const { execSync, spawn, spawnSync } = require("child_process");
2
2
  const net = require("net");
3
3
  const path = require("path");
4
4
  const fs = require("fs");
5
+ const readline = require("readline");
5
6
 
6
7
  const rootDir = path.resolve(__dirname, "..");
7
8
  const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
@@ -102,6 +103,34 @@ function findBinary() {
102
103
  return null;
103
104
  }
104
105
 
106
+ /**
107
+ * Whether this install is a dev checkout (the source tree) rather than an
108
+ * npm-installed package. Signalled by a `Cargo.toml` at `rootDir` — present in
109
+ * the git checkout, absent in the npm install dir. The single source of truth
110
+ * for the checkout-vs-installed distinction; both `ensureBinary` (build-from-
111
+ * source vs error) and `serverSpawnEnv` (distribution-channel gating) read it.
112
+ * Accepts an explicit dir so it is unit-testable against a temp tree.
113
+ */
114
+ function isDevCheckout(dir = rootDir) {
115
+ return fs.existsSync(path.join(dir, "Cargo.toml"));
116
+ }
117
+
118
+ /**
119
+ * Env for a launched editor-server, tagging the distribution channel so the
120
+ * server's update-availability check knows an npm install can be upgraded via
121
+ * `npm install -g …@latest`. Pure: returns a NEW object, never mutates the
122
+ * input.
123
+ *
124
+ * Installed (non-dev-checkout) → set `CODEYAM_DISTRIBUTION=npm`, so the server
125
+ * polls the npm registry and surfaces the upgrade banner. Dev checkout → leave
126
+ * the env untouched (any pre-existing value from the parent env is preserved),
127
+ * so a `codeyam-editor` run from source is never nagged to "upgrade".
128
+ */
129
+ function serverSpawnEnv(baseEnv, { devCheckout } = {}) {
130
+ if (devCheckout) return { ...baseEnv };
131
+ return { ...baseEnv, CODEYAM_DISTRIBUTION: "npm" };
132
+ }
133
+
105
134
  /**
106
135
  * Return the binary path, building from source if we're in a dev checkout.
107
136
  *
@@ -119,9 +148,9 @@ function ensureBinary() {
119
148
  const existing = findBinary();
120
149
  if (existing) return existing;
121
150
 
122
- const isDevCheckout = fs.existsSync(path.join(rootDir, "Cargo.toml"));
151
+ const devCheckout = isDevCheckout();
123
152
 
124
- if (!isDevCheckout) {
153
+ if (!devCheckout) {
125
154
  const info = platformPackageInfo();
126
155
  if (!info) {
127
156
  console.error(
@@ -276,6 +305,25 @@ function enforceFreshBinary(buildOk, { action, continueVerb = "continuing with"
276
305
  );
277
306
  }
278
307
 
308
+ // Default boot budget for the editor server. A real cold boot legitimately
309
+ // spends ~7–10s on plan-claims git migration + reap, a PTY-broker probe/respawn
310
+ // (worse when a stale orphan broker's socket is dead), and a branch-tip fetch.
311
+ // 30s is ~3× that — comfortable headroom without letting a genuinely hung
312
+ // server hang indefinitely.
313
+ const DEFAULT_START_TIMEOUT_MS = 30000;
314
+
315
+ /**
316
+ * Resolve the boot budget (ms) for `ensureServer`, honoring the
317
+ * `CODEYAM_EDITOR_START_TIMEOUT_MS` override. Mirrors the existing env-read
318
+ * style (`CODEYAM_ALLOW_STALE`): parse the raw value, accept only a positive
319
+ * integer, and fall back to the 30s default on missing/non-numeric/≤0 input.
320
+ * Exported so it's unit-testable without spawning the real binary.
321
+ */
322
+ function resolveStartTimeoutMs(env = process.env) {
323
+ const parsed = Number.parseInt(env.CODEYAM_EDITOR_START_TIMEOUT_MS, 10);
324
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_START_TIMEOUT_MS;
325
+ }
326
+
279
327
  function isPlainJsonObject(value) {
280
328
  return value !== null && typeof value === "object" && !Array.isArray(value);
281
329
  }
@@ -491,9 +539,14 @@ async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
491
539
 
492
540
  /**
493
541
  * GET /api/codeyam-server-identity from the listener on `port` with a
494
- * sub-second timeout. Returns the parsed `{projectDir, pid, version,
495
- * startedAt}` body on a 2xx response, or `null` on any failure
496
- * (timeout, non-200, parse failure, connection refused).
542
+ * sub-second timeout. Returns the parsed identity body on a 2xx response,
543
+ * or `null` on any failure (timeout, non-200, parse failure, connection
544
+ * refused). Two identity shapes are recognized:
545
+ * - a per-project editor: `{projectDir, pid, version, startedAt}`
546
+ * - the Project Launcher (selector): `{role: "launcher", pid, version}`
547
+ * — deliberately NO `projectDir`, since the selector isn't bound to one
548
+ * project. We must NOT reject it for the missing `projectDir`, or the
549
+ * wrapper would misclassify the live selector as a foreign squatter.
497
550
  *
498
551
  * `null` carries two distinct meanings that the caller must
499
552
  * disambiguate by also checking `isPortInUse`:
@@ -513,9 +566,10 @@ async function fetchServerIdentity(port, timeoutMs = 500) {
513
566
  });
514
567
  if (!resp.ok) return null;
515
568
  const body = await resp.json();
516
- if (!body || typeof body !== "object" || typeof body.projectDir !== "string") {
517
- return null;
518
- }
569
+ if (!body || typeof body !== "object") return null;
570
+ // The Project Launcher advertises a `launcher` role and no projectDir.
571
+ if (body.role === "launcher") return body;
572
+ if (typeof body.projectDir !== "string") return null;
519
573
  return body;
520
574
  } catch {
521
575
  return null;
@@ -572,11 +626,47 @@ function formatCrossProjectError(port, holder, verb) {
572
626
  );
573
627
  }
574
628
 
629
+ /**
630
+ * Build the "server never bound the port" failure message, shown only after
631
+ * the boot budget AND one automatic retry have both been exhausted. Pure
632
+ * string builder (same shape as `formatForeignListenerError` /
633
+ * `formatCrossProjectError`): names what happened, the likely cause, where to
634
+ * look, and concrete next steps. Stack-agnostic — no app-type/framework
635
+ * assumptions.
636
+ */
637
+ function formatServerStartTimeoutError(port, timeoutMs) {
638
+ const seconds = Math.round(timeoutMs / 1000);
639
+ return [
640
+ `codeyam-editor server did not bind port ${port} within ${seconds}s, ` +
641
+ `even after one automatic retry.`,
642
+ "",
643
+ "Likely cause: a slow cold boot (plan-claims git migration, PTY-broker " +
644
+ "respawn) — often a stale codeyam-editor-pty-broker whose socket is gone.",
645
+ "",
646
+ "Or: with 2+ projects registered this launch diverts to the Project " +
647
+ "Launcher (selector). An old binary that still discards --port binds the " +
648
+ "selector on a DIFFERENT ephemeral port, so the wait for this one times " +
649
+ "out — check .codeyam/logs/editor-server.out.log for a `Project Launcher " +
650
+ "running at` line and rebuild the editor.",
651
+ "",
652
+ "Where to look:",
653
+ " .codeyam/logs/editor-server.log",
654
+ " .codeyam/logs/editor-server.out.log",
655
+ "",
656
+ "What to try:",
657
+ " - Re-run the launcher.",
658
+ " - If a stale codeyam-editor-pty-broker is lingering, kill it, then re-run.",
659
+ " - If boots are consistently slow, raise the budget, e.g. " +
660
+ "CODEYAM_EDITOR_START_TIMEOUT_MS=60000.",
661
+ ].join("\n");
662
+ }
663
+
575
664
  /**
576
665
  * Classify whatever is listening on `port` relative to `projectDir`.
577
666
  * Returns one of:
578
667
  * - `{ kind: "free" }` — nothing on the port
579
668
  * - `{ kind: "foreign" }` — port held by a non-editor process
669
+ * - `{ kind: "launcher", identity }` — held by the Project Launcher (selector)
580
670
  * - `{ kind: "cross-project", wantDir, haveDir, identity }`
581
671
  * — held by another project's editor
582
672
  * - `{ kind: "same-project", identity }` — held by THIS project's editor
@@ -596,6 +686,12 @@ async function classifyPortHolder(port, projectDir = process.cwd()) {
596
686
  if (identity === null) {
597
687
  return { kind: "foreign" };
598
688
  }
689
+ // The Project Launcher (selector) holds the port in multi-project mode. It is
690
+ // codeyam-editor, not a foreign process and not a per-project editor, so it
691
+ // gets its own kind — the caller just re-opens it rather than refusing.
692
+ if (identity.role === "launcher") {
693
+ return { kind: "launcher", identity };
694
+ }
599
695
  const wantDir = await canonicalisePath(projectDir);
600
696
  const haveDir = await canonicalisePath(identity.projectDir);
601
697
  if (wantDir !== haveDir) {
@@ -631,6 +727,76 @@ function isInteractiveLauncher(stream = process.stdout) {
631
727
  return Boolean(stream && stream.isTTY);
632
728
  }
633
729
 
730
+ /**
731
+ * Ask a yes/no question at the terminal and resolve `true` only on an
732
+ * affirmative `y`/`yes` (case-insensitive). A bare Enter, EOF, `n`, or any
733
+ * other text resolves `false` — the safe default for a destructive prompt.
734
+ *
735
+ * Mirrors `promptForInstanceName` in `npm/cloud.js`: readline reads from stdin
736
+ * and writes its prompt to stderr by default, so the question never pollutes a
737
+ * piped stdout. `input`/`output` are injectable so the helper is unit-testable
738
+ * by feeding a fake stream without a real TTY.
739
+ */
740
+ function promptYesNo(question, { input = process.stdin, output = process.stderr } = {}) {
741
+ return new Promise((resolve) => {
742
+ const rl = readline.createInterface({ input, output });
743
+ rl.question(question, (ans) => {
744
+ rl.close();
745
+ const normalized = String(ans).trim().toLowerCase();
746
+ resolve(normalized === "y" || normalized === "yes");
747
+ });
748
+ });
749
+ }
750
+
751
+ /**
752
+ * Decide what to do when `port` is held by a DIFFERENT project's editor server.
753
+ *
754
+ * Policy (shared by both launcher entry points so the wording and gating stay
755
+ * identical):
756
+ * - Non-interactive launcher (cloud container, agent-driven, piped/CI): print
757
+ * `formatCrossProjectError` and return `{ action: "abort" }` — never kill
758
+ * another project's server without a human at the keyboard, since the kill
759
+ * also tears down any Claude agent attached via the PTY broker.
760
+ * - Interactive launcher: print the full who/what/where, then ask whether to
761
+ * stop the other server and take over. On `y` → `stopServer(port)` and
762
+ * return `{ action: "took-over" }`. On anything else → `{ action: "declined" }`.
763
+ *
764
+ * Gates on `process.stdin.isTTY` (the INPUT stream — a human can only answer a
765
+ * prompt when stdin is a terminal), not stdout. The resolver prints the full
766
+ * `formatCrossProjectError` message in BOTH the abort and the interactive paths,
767
+ * so the caller only decides exit-vs-continue.
768
+ *
769
+ * Dependencies are injected (defaulting to the real implementations) so the
770
+ * resolver is unit-testable without a TTY or a live server — same style as
771
+ * `classifyPortHolder` / `resolveBootedServer`.
772
+ */
773
+ async function resolveCrossProjectCollision(
774
+ port,
775
+ holder,
776
+ verb,
777
+ {
778
+ isInteractive = isInteractiveLauncher,
779
+ prompt = promptYesNo,
780
+ stop = stopServer,
781
+ } = {},
782
+ ) {
783
+ console.error(formatCrossProjectError(port, holder, verb));
784
+ if (!isInteractive(process.stdin)) {
785
+ return { action: "abort" };
786
+ }
787
+ const pidNote = Number.isInteger(holder.identity && holder.identity.pid)
788
+ ? ` (pid ${holder.identity.pid})`
789
+ : "";
790
+ const accepted = await prompt(
791
+ `\nKill that server${pidNote} and start this project's editor instead? [y/N] `,
792
+ );
793
+ if (!accepted) {
794
+ return { action: "declined" };
795
+ }
796
+ await stop(port);
797
+ return { action: "took-over" };
798
+ }
799
+
634
800
  /**
635
801
  * Spawn options for the editor server child. The server is spawned `detached:
636
802
  * true` so it leads its OWN session + process group (setsid), decoupling its
@@ -742,22 +908,60 @@ function killChildProcess(child) {
742
908
  }
743
909
  }
744
910
 
745
- function findListeningPids(port) {
746
- // Process discovery on Windows is best-effort — `lsof` is not installed and
747
- // a `netstat -ano` parser is a follow-up. The API-shutdown path and the
748
- // PID-file path together stop the server cleanly in normal operation.
749
- if (process.platform === "win32") return [];
911
+ /** Parse `lsof -t` stdout (one pid per line) into an array of integer pids. */
912
+ function parseLsofPids(stdout) {
913
+ return (stdout || "")
914
+ .split(/\s+/)
915
+ .map((line) => parseInt(line, 10))
916
+ .filter((pid) => Number.isInteger(pid));
917
+ }
750
918
 
751
- const result = spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
919
+ /** Run `lsof` for the listeners on a TCP port. Returns the raw spawnSync
920
+ * result (`{ error?, stdout }`) so callers can distinguish a spawn failure
921
+ * from a genuinely empty result. */
922
+ function defaultRunLsof(port) {
923
+ return spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
752
924
  encoding: "utf8",
753
925
  });
926
+ }
754
927
 
755
- if (result.error) return [];
928
+ /** Synchronous millisecond sleep — used to back off between lsof retries. */
929
+ function sleepSync(ms) {
930
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
931
+ }
756
932
 
757
- return result.stdout
758
- .split(/\s+/)
759
- .map((line) => parseInt(line, 10))
760
- .filter((pid) => Number.isInteger(pid));
933
+ /**
934
+ * Find the pids listening on a TCP port via `lsof`. Returns [] on Windows
935
+ * (lsof absent) or when no process is listening.
936
+ *
937
+ * A transient *spawn* failure (e.g. fork `EAGAIN` under heavy concurrent load —
938
+ * the cause of the flaky multi-runner test failure) is RETRIED with a short
939
+ * backoff rather than swallowed into "no listeners found". This matters because
940
+ * `findListeningPids` feeds `stopServer`: a one-off lsof spawn error must not be
941
+ * mistaken for "the server isn't running" and leave it alive. A clean run that
942
+ * legitimately reports no listeners returns [] on the first attempt (no error,
943
+ * so no retry) — free-port behavior and latency are unchanged.
944
+ *
945
+ * `runLsof` and `sleep` are injectable so the retry path is unit-testable
946
+ * without depending on real system load.
947
+ */
948
+ function findListeningPids(port, runLsof = defaultRunLsof, sleep = sleepSync) {
949
+ // Process discovery on Windows is best-effort — `lsof` is not installed and
950
+ // a `netstat -ano` parser is a follow-up. The API-shutdown path and the
951
+ // PID-file path together stop the server cleanly in normal operation.
952
+ if (process.platform === "win32") return [];
953
+
954
+ const MAX_ATTEMPTS = 5;
955
+ const BACKOFF_MS = 50;
956
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
957
+ const result = runLsof(port);
958
+ // No spawn error → trust the result (empty stdout legitimately means "no
959
+ // listeners"; a non-empty stdout yields the pids).
960
+ if (!result.error) return parseLsofPids(result.stdout);
961
+ // Transient spawn failure — back off and retry, unless we're out of tries.
962
+ if (attempt < MAX_ATTEMPTS) sleep(BACKOFF_MS);
963
+ }
964
+ return [];
761
965
  }
762
966
 
763
967
  /** Stop the Rust backend server if it is running. */
@@ -819,10 +1023,31 @@ async function ensureServer(port = 14199) {
819
1023
  console.error(formatForeignListenerError(port));
820
1024
  process.exit(1);
821
1025
  break;
822
- case "cross-project":
823
- console.error(formatCrossProjectError(port, holder, "attach"));
1026
+ case "cross-project": {
1027
+ const outcome = await resolveCrossProjectCollision(port, holder, "attach");
1028
+ if (outcome.action === "took-over") {
1029
+ // `stopServer` already waited for the port to close, so fall through to
1030
+ // the fresh-boot path below — `spawnAndAwait` binds the now-free port.
1031
+ break;
1032
+ }
1033
+ // "abort" (non-TTY) or "declined" (user said no): the resolver already
1034
+ // printed the cross-project message. On decline, remind how to keep both.
1035
+ if (outcome.action === "declined") {
1036
+ console.error(
1037
+ "Leaving the existing server running. Change controlPort in " +
1038
+ ".codeyam/editor.local.json to run both, e.g.:\n" +
1039
+ " codeyam-editor editor config-override proxy.controlPort 14399",
1040
+ );
1041
+ }
824
1042
  process.exit(1);
825
1043
  break;
1044
+ }
1045
+ case "launcher":
1046
+ // The selector is already up on this port (multi-project mode). Treat it
1047
+ // like an already-running same-project server: don't refuse, don't
1048
+ // double-spawn — just let the caller re-open the port.
1049
+ console.error(`Project selector already running on port ${port}`);
1050
+ return null;
826
1051
  case "same-project":
827
1052
  console.error(`Server already running on port ${port}`);
828
1053
  return null;
@@ -832,6 +1057,12 @@ async function ensureServer(port = 14199) {
832
1057
  }
833
1058
 
834
1059
  const binary = ensureBinary();
1060
+ const startTimeoutMs = resolveStartTimeoutMs();
1061
+
1062
+ // Spawn a fresh server child and race its boot against `timeoutMs`. Factored
1063
+ // into a closure so the auto-retry path re-runs an identical spawn with a
1064
+ // longer budget without duplicating the spawn options.
1065
+ //
835
1066
  // UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is
836
1067
  // now an explicit dev-override only.
837
1068
  //
@@ -843,43 +1074,117 @@ async function ensureServer(port = 14199) {
843
1074
  // `.codeyam/logs/editor-server.out.log` (the Rust server also writes its own
844
1075
  // editor-server.log). `unref()` keeps the detached child from pinning the
845
1076
  // launcher's event loop alive on its own.
846
- const logFd = openServerOutputLog(process.cwd());
847
- const child = spawn(
848
- binary,
849
- ["start", "--no-open", "--port", String(port)],
850
- buildServerSpawnOptions({ cwd: process.cwd(), env: process.env, logFd }),
851
- );
852
- if (logFd !== null) {
853
- try {
854
- fs.closeSync(logFd);
855
- } catch {
856
- /* child holds its own dup of the fd; the launcher's copy is no longer needed */
1077
+ const spawnAndAwait = async (timeoutMs) => {
1078
+ const logFd = openServerOutputLog(process.cwd());
1079
+ const child = spawn(
1080
+ binary,
1081
+ ["start", "--no-open", "--port", String(port)],
1082
+ buildServerSpawnOptions({
1083
+ cwd: process.cwd(),
1084
+ env: serverSpawnEnv(process.env, { devCheckout: isDevCheckout() }),
1085
+ logFd,
1086
+ }),
1087
+ );
1088
+ if (logFd !== null) {
1089
+ try {
1090
+ fs.closeSync(logFd);
1091
+ } catch {
1092
+ /* child holds its own dup of the fd; the launcher's copy is no longer needed */
1093
+ }
857
1094
  }
1095
+ child.unref();
1096
+ const race = await awaitServerReadyOrChildExit(child, port, (p) =>
1097
+ waitForPort(p, timeoutMs),
1098
+ );
1099
+ return { child, ...race };
1100
+ };
1101
+
1102
+ // Orchestrate the boot (re-check-before-kill + one auto-retry) with the real
1103
+ // effects injected, then map the discriminated result onto the process-exit
1104
+ // policy. Keeping the orchestration in `resolveBootedServer` (pure control
1105
+ // flow over injected effects) is what makes the re-check guard and the retry
1106
+ // unit-testable without spawning the real binary or stubbing process.exit.
1107
+ const result = await resolveBootedServer({
1108
+ port,
1109
+ startTimeoutMs,
1110
+ spawnAndAwait,
1111
+ isPortInUseFn: isPortInUse,
1112
+ });
1113
+ switch (result.kind) {
1114
+ case "ready":
1115
+ return result.child;
1116
+ case "early-exit": {
1117
+ // A genuine early exit (preflight bail) is always a hard fail — even on
1118
+ // code 0: the caller needs to know the server isn't running.
1119
+ const { code, signal } = result.exitedBeforeBind ?? { code: null, signal: null };
1120
+ const detail = signal != null ? `signal ${signal}` : `code ${code}`;
1121
+ console.error(
1122
+ `codeyam-editor server exited (${detail}) before binding port ${port}. ` +
1123
+ "Likely a preflight bail; see .codeyam/logs/editor-server.out.log " +
1124
+ "and .codeyam/logs/editor-server.log.",
1125
+ );
1126
+ process.exit(code != null && code !== 0 ? code : 1);
1127
+ break;
1128
+ }
1129
+ case "timeout-exhausted":
1130
+ default:
1131
+ console.error(formatServerStartTimeoutError(port, result.retryTimeoutMs));
1132
+ process.exit(1);
858
1133
  }
859
- child.unref();
1134
+ }
1135
+
1136
+ /**
1137
+ * Boot orchestration for `ensureServer`, isolated from the process-exit policy
1138
+ * and from the spawn/port effects so the re-check-before-kill guard and the
1139
+ * single auto-retry are unit-testable. Pure control flow over two injected
1140
+ * effects:
1141
+ * - `spawnAndAwait(timeoutMs)` → `{ child, outcome, exitedBeforeBind }` —
1142
+ * one boot attempt racing the port against the child exit.
1143
+ * - `isPortInUseFn(port)` → bool — the final re-check probe.
1144
+ *
1145
+ * Returns a discriminated result the caller maps to an action:
1146
+ * - `{ kind: "ready", child }` — server bound; keep this child.
1147
+ * - `{ kind: "early-exit", exitedBeforeBind }` — child exited before bind
1148
+ * (preflight bail); a hard fail, never retried.
1149
+ * - `{ kind: "timeout-exhausted", retryTimeoutMs }` — both the budget and a
1150
+ * longer-budget retry missed; give up with actionable guidance.
1151
+ *
1152
+ * The destructive `child.kill()` only fires after a re-check confirms the port
1153
+ * is NOT listening — a server that bound a hair after the deadline is kept, not
1154
+ * torn down (the reported race). The retry runs at most once.
1155
+ */
1156
+ async function resolveBootedServer({ port, startTimeoutMs, spawnAndAwait, isPortInUseFn }) {
1157
+ // One boot attempt, with the re-check folded in: a post-deadline bind is
1158
+ // reported as "ready" rather than "timeout" so the caller never kills it.
1159
+ const attempt = async (timeoutMs) => {
1160
+ const { child, outcome, exitedBeforeBind } = await spawnAndAwait(timeoutMs);
1161
+ if (outcome === "ready") return { kind: "ready", child };
1162
+ if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) {
1163
+ return { kind: "early-exit", exitedBeforeBind };
1164
+ }
1165
+ // Genuine timeout — re-check before reporting a miss.
1166
+ if (await isPortInUseFn(port)) return { kind: "ready", child };
1167
+ return { kind: "timeout", child };
1168
+ };
860
1169
 
861
- const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port);
1170
+ const first = await attempt(startTimeoutMs);
1171
+ if (first.kind !== "timeout") return first;
862
1172
 
863
- if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) {
864
- const { code, signal } = exitedBeforeBind ?? { code: null, signal: null };
865
- const detail = signal != null ? `signal ${signal}` : `code ${code}`;
866
- console.error(
867
- `codeyam-editor server exited (${detail}) before binding port ${port}. ` +
868
- "Likely a preflight bail; see .codeyam/logs/editor-server.out.log " +
869
- "and .codeyam/logs/editor-server.log.",
870
- );
871
- // Always propagate as a real failure — even on code 0 the caller
872
- // needs to know the server isn't running.
873
- process.exit(code != null && code !== 0 ? code : 1);
874
- }
1173
+ // The boot was just slow (cold caches, broker respawn). Kill the stalled
1174
+ // attempt and auto-retry ONCE with a longer budget — a single retry, not a
1175
+ // loop, so a truly broken server isn't masked behind endless retries.
1176
+ const retryTimeoutMs = startTimeoutMs * 2;
1177
+ console.error(
1178
+ `Server did not bind port ${port} within ${Math.round(startTimeoutMs / 1000)}s; ` +
1179
+ `retrying once with a longer budget...`,
1180
+ );
1181
+ first.child.kill();
875
1182
 
876
- if (outcome === "timeout") {
877
- console.error("Server failed to start within 10 seconds");
878
- child.kill();
879
- process.exit(1);
880
- }
1183
+ const second = await attempt(retryTimeoutMs);
1184
+ if (second.kind !== "timeout") return second;
881
1185
 
882
- return child;
1186
+ second.child.kill();
1187
+ return { kind: "timeout-exhausted", retryTimeoutMs };
883
1188
  }
884
1189
 
885
1190
  /**
@@ -919,6 +1224,8 @@ module.exports = {
919
1224
  findBrokerBinary,
920
1225
  verifyBrokerBinaryPresent,
921
1226
  ensureBinary,
1227
+ isDevCheckout,
1228
+ serverSpawnEnv,
922
1229
  staleBinaryBanner,
923
1230
  enforceFreshBinary,
924
1231
  uiDistDir,
@@ -934,13 +1241,19 @@ module.exports = {
934
1241
  classifyPortHolder,
935
1242
  formatForeignListenerError,
936
1243
  formatCrossProjectError,
1244
+ formatServerStartTimeoutError,
1245
+ resolveStartTimeoutMs,
937
1246
  tryKillPid,
938
1247
  findListeningPids,
1248
+ parseLsofPids,
939
1249
  stopServer,
940
1250
  restartServer,
941
1251
  ensureServer,
1252
+ resolveBootedServer,
942
1253
  awaitServerReadyOrChildExit,
943
1254
  isInteractiveLauncher,
1255
+ promptYesNo,
1256
+ resolveCrossProjectCollision,
944
1257
  buildServerSpawnOptions,
945
1258
  openServerOutputLog,
946
1259
  formatLauncherSignalRecord,
package/npm/vm-urls.js CHANGED
@@ -68,25 +68,82 @@ function normalizeVmNumber(n) {
68
68
  return num;
69
69
  }
70
70
 
71
+ // A single lowercase DNS label: 1–63 chars of letters/digits/hyphen, no leading
72
+ // or trailing hyphen, NO dots. A custom slug becomes the leftmost label of a
73
+ // `<slug>.<surface>.<domain>` host the wildcard cert (`*.editor.<domain>`) and
74
+ // the wildcard `A` records already cover, so it must be exactly one label —
75
+ // `my.slug` would escape the wildcard and resolve nowhere.
76
+ const SLUG_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
77
+
78
+ // Slugs that would collide with a fixed fleet surface. `dashboard.<domain>` is
79
+ // the operator dashboard's own host; `editor` / `preview` are the per-VM surface
80
+ // labels in `<slug>.editor.<domain>` — a slug of `editor` would mint the
81
+ // nonsensical `editor.editor.<domain>` and shadow the surface segment.
82
+ const RESERVED_SLUGS = Object.freeze(['dashboard', 'editor', 'preview']);
83
+
84
+ /** Validate + normalize a custom VM slug into a single lowercase DNS label.
85
+ * A bad slug would bake a dead host into the URL map or shadow another VM's
86
+ * routing, so we reject it loudly at the derivation boundary — the same posture
87
+ * as `resolveFleetDomain` / `normalizeVmNumber`. Rejects: anything that isn't a
88
+ * single label (dots, leading/trailing hyphen, >63 chars, empty); the reserved
89
+ * words `dashboard` / `editor` / `preview`; and the `vm-<N>` shape, which would
90
+ * collide with the numeric scheme. Returns the trimmed/lowercased slug. */
91
+ function normalizeVmSlug(slug) {
92
+ if (slug == null) throw new Error('VM slug is required');
93
+ const s = String(slug).trim().toLowerCase();
94
+ if (!SLUG_RE.test(s)) {
95
+ throw new Error(
96
+ `VM slug "${slug}" is not a valid single DNS label ` +
97
+ '(lowercase letters, digits, hyphens; no dots; 1–63 chars; ' +
98
+ 'no leading/trailing hyphen)',
99
+ );
100
+ }
101
+ if (RESERVED_SLUGS.includes(s)) {
102
+ throw new Error(
103
+ `VM slug "${s}" is reserved (${RESERVED_SLUGS.join(', ')})`,
104
+ );
105
+ }
106
+ if (/^vm-\d+$/.test(s)) {
107
+ throw new Error(
108
+ `VM slug "${s}" collides with the numeric vm-<N> scheme — pick a name`,
109
+ );
110
+ }
111
+ return s;
112
+ }
113
+
114
+ /** The leftmost hostname label for a VM: the validated `slug` when supplied,
115
+ * else the numeric `vm-<N>` default. Single-sources the "slug overrides the
116
+ * number, otherwise fall back" rule every hostname derivation shares. */
117
+ function vmLabel(n, slug) {
118
+ if (slug != null && String(slug).trim() !== '') return normalizeVmSlug(slug);
119
+ return `vm-${normalizeVmNumber(n)}`;
120
+ }
121
+
71
122
  /** The bare per-VM hostnames (no scheme), derived from the VM number + domain.
72
123
  * `editor` fronts the in-VM editor control API; `preview` fronts the VM's dev
73
124
  * server (the Live Preview). These are what the named-tunnel ingress rules and
74
- * the DNS CNAME records are keyed on — see named-tunnel-config.sh. */
75
- function vmHostnames(n, domain) {
76
- const num = normalizeVmNumber(n);
125
+ * the DNS CNAME records are keyed on — see named-tunnel-config.sh.
126
+ *
127
+ * An optional `slug` overrides the leftmost `vm-<N>` label with a human-readable
128
+ * name (`<slug>.editor.<domain>`); with no slug the output is byte-for-byte the
129
+ * numeric scheme, so the change is a no-op for every existing caller. */
130
+ function vmHostnames(n, domain, slug) {
131
+ const label = vmLabel(n, slug); // validates n (and slug, when present)
77
132
  if (!domain) throw new Error('vmHostnames requires a fleet domain');
78
133
  return {
79
- editor: `vm-${num}.editor.${domain}`,
80
- preview: `vm-${num}.preview.${domain}`,
134
+ editor: `${label}.editor.${domain}`,
135
+ preview: `${label}.preview.${domain}`,
81
136
  };
82
137
  }
83
138
 
84
139
  /** The per-VM public HTTPS URLs, derived from the VM number + domain. The
85
140
  * deterministic replacement for `cloudflared-urls.json[n]` /
86
141
  * `cloudflared-urls.json[n-preview]` — same shape the dashboard surfaced from
87
- * the monitor's recordings, now with no recording, probing, or rotation. */
88
- function vmUrls(n, domain) {
89
- const host = vmHostnames(n, domain);
142
+ * the monitor's recordings, now with no recording, probing, or rotation. An
143
+ * optional `slug` rides through to `vmHostnames` so the URLs carry the custom
144
+ * hostname; absent, they are unchanged. */
145
+ function vmUrls(n, domain, slug) {
146
+ const host = vmHostnames(n, domain, slug);
90
147
  return {
91
148
  editor: `https://${host.editor}`,
92
149
  preview: `https://${host.preview}`,
@@ -108,8 +165,12 @@ function dashboardUrl(domain) {
108
165
 
109
166
  module.exports = {
110
167
  FLEET_DOMAIN_ENV,
168
+ SLUG_RE,
169
+ RESERVED_SLUGS,
111
170
  resolveFleetDomain,
112
171
  normalizeVmNumber,
172
+ normalizeVmSlug,
173
+ vmLabel,
113
174
  vmHostnames,
114
175
  vmUrls,
115
176
  dashboardHostname,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codeyam-editor/codeyam-editor",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Language-agnostic managed execution sandbox for scenario-driven development",
5
5
  "bin": {
6
6
  "codeyam-editor": "./npm/cli.js"
@@ -9,10 +9,10 @@
9
9
  "playwright": "^1.58.2"
10
10
  },
11
11
  "optionalDependencies": {
12
- "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.4",
13
- "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.4",
14
- "@codeyam-editor/codeyam-editor-linux-x64": "0.1.4",
15
- "@codeyam-editor/codeyam-editor-win32-x64": "0.1.4"
12
+ "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.6",
13
+ "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.6",
14
+ "@codeyam-editor/codeyam-editor-linux-x64": "0.1.6",
15
+ "@codeyam-editor/codeyam-editor-win32-x64": "0.1.6"
16
16
  },
17
17
  "keywords": [
18
18
  "codeyam",