@codeyam-editor/codeyam-editor 0.1.0 → 0.1.2

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 (47) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1521 -0
  4. package/npm/container.js +28 -5
  5. package/npm/editor-dev.js +378 -0
  6. package/npm/editor.js +199 -12
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-cleanup.js +124 -0
  10. package/npm/fleet-env.js +125 -0
  11. package/npm/fleet-memory.js +127 -0
  12. package/npm/fleet-pool-policy.js +78 -0
  13. package/npm/fleet-pool.js +401 -0
  14. package/npm/fleet-queue.js +40 -0
  15. package/npm/fleet-reconcile.js +36 -0
  16. package/npm/fleet-reset.js +144 -0
  17. package/npm/fleet-roster.js +81 -0
  18. package/npm/launch-config.js +544 -0
  19. package/npm/preflight-guidance.js +68 -0
  20. package/npm/scenario-check.js +355 -290
  21. package/npm/scenario-handlers.js +82 -0
  22. package/npm/scenario-interactivity.js +199 -0
  23. package/npm/scenario-issues.js +35 -0
  24. package/npm/scenario-metrics.js +108 -0
  25. package/npm/scenario-mocks.js +107 -0
  26. package/npm/scenario-playwright.js +355 -0
  27. package/npm/utils.js +548 -22
  28. package/npm/vm-urls.js +117 -0
  29. package/package.json +13 -6
  30. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  31. package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
  32. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  33. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  34. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
  35. package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
  37. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  38. package/ui/dist/assets/index-BUYNFqKK.js +27 -0
  39. package/ui/dist/assets/index-DVr5rY2d.css +1 -0
  40. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  41. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  42. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  43. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  44. package/ui/dist/favicon.ico +0 -0
  45. package/ui/dist/favicon.png +0 -0
  46. package/ui/dist/index.html +16 -0
  47. package/npm/postinstall.js +0 -95
package/npm/utils.js CHANGED
@@ -6,35 +6,116 @@ const fs = require("fs");
6
6
  const rootDir = path.resolve(__dirname, "..");
7
7
  const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
8
8
 
9
- /** Find the compiled binary — check npm-installed location first, then dev builds. */
10
- function findBinary() {
11
- const candidates = [
12
- path.join(rootDir, "bin", "codeyam-editor"), // npm install (postinstall)
13
- path.join(rootDir, "target", "debug", "codeyam-editor"), // local dev (debug)
14
- path.join(rootDir, "target", "release", "codeyam-editor"), // local dev (release)
15
- ];
9
+ /**
10
+ * Map (process.platform, process.arch) → the platform sub-package that
11
+ * carries the matching native binary. Kept as a small data table so adding
12
+ * a new platform (e.g. linux/arm64) is one entry, not a new branch.
13
+ */
14
+ const PLATFORM_PACKAGES = {
15
+ "darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor" },
16
+ "darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor" },
17
+ "linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor" },
18
+ "win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe" },
19
+ };
20
+
21
+ /**
22
+ * Platform-correct executable filename for the codeyam-editor binary.
23
+ * Windows requires the `.exe` extension; on Unix the binary has no extension.
24
+ * Accepts an explicit platform so the helper is testable across platforms.
25
+ */
26
+ function binaryName(platform = process.platform) {
27
+ return platform === "win32" ? "codeyam-editor.exe" : "codeyam-editor";
28
+ }
16
29
 
17
- for (const candidate of candidates) {
18
- if (fs.existsSync(candidate)) {
19
- return candidate;
30
+ /** Look up the platform sub-package metadata for a given platform/arch pair. */
31
+ function platformPackageInfo(platform = process.platform, arch = process.arch) {
32
+ return PLATFORM_PACKAGES[`${platform}-${arch}`] || null;
33
+ }
34
+
35
+ /**
36
+ * Resolve the native binary path.
37
+ *
38
+ * Production install (npm install @codeyam-editor/codeyam-editor):
39
+ * npm installed the matching @codeyam-editor/codeyam-editor-<plat>-<arch>
40
+ * sub-package via optionalDependencies. require.resolve finds the binary
41
+ * inside that sibling package.
42
+ *
43
+ * Dev checkout (`cargo build` in the repo):
44
+ * No platform sub-package installed; fall through to target/debug or
45
+ * target/release.
46
+ *
47
+ * Returns null if no binary can be found at any of these locations — the
48
+ * caller (ensureBinary / cli.js) decides whether to error or to invoke
49
+ * `cargo build` based on whether a Cargo.toml is present.
50
+ */
51
+ function findBinary() {
52
+ const info = platformPackageInfo();
53
+ if (info) {
54
+ try {
55
+ const resolved = require.resolve(`${info.pkg}/bin/${info.binary}`, {
56
+ paths: [rootDir],
57
+ });
58
+ if (fs.existsSync(resolved)) return resolved;
59
+ } catch (e) {
60
+ if (e.code !== "MODULE_NOT_FOUND") throw e;
20
61
  }
21
62
  }
22
63
 
64
+ const name = binaryName();
65
+ const devCandidates = [
66
+ path.join(rootDir, "target", "debug", name),
67
+ path.join(rootDir, "target", "release", name),
68
+ ];
69
+ for (const candidate of devCandidates) {
70
+ if (fs.existsSync(candidate)) return candidate;
71
+ }
72
+
23
73
  return null;
24
74
  }
25
75
 
26
- /** Return the binary path — build from source only if no pre-built binary exists. */
76
+ /**
77
+ * Return the binary path, building from source if we're in a dev checkout.
78
+ *
79
+ * Production install: if findBinary returns null, the platform sub-package
80
+ * is missing — npm install failed silently (likely because the user's
81
+ * platform isn't in the build matrix). Emit a clear, actionable error
82
+ * naming the expected sub-package, and exit 1. **Do not** fall through to
83
+ * `cargo build` — there's no Cargo.toml in the npm install dir, and the
84
+ * resulting "could not find Cargo.toml" error is misleading to users.
85
+ *
86
+ * Dev checkout: `Cargo.toml` is present at rootDir → run `cargo build`,
87
+ * same as the legacy behavior.
88
+ */
27
89
  function ensureBinary() {
28
90
  const existing = findBinary();
29
91
  if (existing) return existing;
30
92
 
31
- // No pre-built binary; try building from source (local dev workflow).
93
+ const isDevCheckout = fs.existsSync(path.join(rootDir, "Cargo.toml"));
94
+
95
+ if (!isDevCheckout) {
96
+ const info = platformPackageInfo();
97
+ if (!info) {
98
+ console.error(
99
+ `codeyam-editor: unsupported platform ${process.platform}/${process.arch}.\n` +
100
+ `Supported: darwin/arm64, darwin/x64, linux/x64, win32/x64.`
101
+ );
102
+ } else {
103
+ console.error(
104
+ `codeyam-editor: native binary missing.\n` +
105
+ `Expected at ${info.pkg}/bin/${info.binary} — npm should have installed ` +
106
+ `that sub-package as an optionalDependency.\n` +
107
+ `Try: npm install --force ${info.pkg}@$(npm view @codeyam-editor/codeyam-editor version)`
108
+ );
109
+ }
110
+ process.exit(1);
111
+ }
112
+
32
113
  try {
33
114
  execSync("cargo --version", { stdio: "ignore" });
34
115
  } catch {
35
116
  console.error(
36
- "codeyam-editor binary not found. Try reinstalling:\n" +
37
- " npm install @codeyam-editor/codeyam-editor\n"
117
+ "codeyam-editor binary not found and `cargo` is not on PATH. " +
118
+ "Install Rust from https://rustup.rs/ or reinstall @codeyam-editor/codeyam-editor."
38
119
  );
39
120
  process.exit(1);
40
121
  }
@@ -60,6 +141,224 @@ function uiDistDir() {
60
141
  return path.join(rootDir, "ui", "dist");
61
142
  }
62
143
 
144
+ /**
145
+ * True when `cwd` IS the editor repo root (or is nested under it), so the
146
+ * wrapper is being used to develop the editor itself rather than against an
147
+ * external client project. Compares realpath-resolved paths so a symlinked
148
+ * checkout (`~/work/foo` → `/Volumes/dev/foo`) still matches — the same
149
+ * convention `crates/control-api/src/state.rs:203` documents for the
150
+ * server-side identity probe.
151
+ *
152
+ * Pure and synchronous (uses `fs.realpathSync`). On a realpath failure (an
153
+ * unreadable path, race with a moved file) the un-resolved value is used as
154
+ * a fallback — the same defensive shape `canonicalisePath` uses, so the
155
+ * wrapper never bails on a transient fs hiccup.
156
+ *
157
+ * Gate for the dev wrapper's passthrough `cargo build`: when the target
158
+ * project is the editor itself, rebuilding makes sense (the developer is
159
+ * iterating on editor source). When it is any other project, the rebuild
160
+ * does nothing of value and ripples into the stale-binary watcher's
161
+ * SIGUSR1 re-exec, resetting the user's session for no reason. The
162
+ * `CODEYAM_DEV_REBUILD=1` env preserves the one legitimate "editing the
163
+ * editor against a client project" workflow.
164
+ */
165
+ function isSelfHostTarget(cwd, repoRoot) {
166
+ const safeReal = (p) => {
167
+ try {
168
+ return fs.realpathSync(p);
169
+ } catch {
170
+ return p;
171
+ }
172
+ };
173
+ const cwdReal = safeReal(cwd);
174
+ const repoReal = safeReal(repoRoot);
175
+ if (cwdReal === repoReal) return true;
176
+ const sep = path.sep;
177
+ return cwdReal.startsWith(repoReal.endsWith(sep) ? repoReal : repoReal + sep);
178
+ }
179
+
180
+ /**
181
+ * Loud banner printed when `cargo build` failed and a wrapper is about to fall
182
+ * back to a stale binary. Shared by both wrappers (editor.js server boot,
183
+ * editor-dev.js passthrough/launch) so the refusal text and the documented
184
+ * CODEYAM_ALLOW_STALE escape hatch stay identical. `action` names what would
185
+ * run on the stale binary, e.g. "run editor commands" / "start the editor
186
+ * server".
187
+ */
188
+ function staleBinaryBanner(action = "run editor commands") {
189
+ return [
190
+ "",
191
+ "============================================================",
192
+ " cargo build FAILED — the editor source does not compile.",
193
+ ` Refusing to ${action} on a STALE binary,`,
194
+ " which would run code that no longer matches the source.",
195
+ "",
196
+ " Fix the compile error above, then re-run.",
197
+ " To deliberately run the last-good binary anyway, set",
198
+ " CODEYAM_ALLOW_STALE=1.",
199
+ "============================================================",
200
+ "",
201
+ ].join("\n");
202
+ }
203
+
204
+ /**
205
+ * Enforce a fresh binary before a wrapper runs it. A no-op when the rebuild
206
+ * succeeded (`buildOk` true). When it failed, print the stale-binary banner and
207
+ * `process.exit(1)` — UNLESS `CODEYAM_ALLOW_STALE=1`, in which case print a
208
+ * one-line override notice and return so the caller proceeds on the last-good
209
+ * binary. This is the single code path the passthrough, launch, and
210
+ * server-boot call sites share, so they cannot diverge on whether they honor
211
+ * the override. `continueVerb` tailors the override notice ("continuing with"
212
+ * vs "launching with").
213
+ */
214
+ function enforceFreshBinary(buildOk, { action, continueVerb = "continuing with" } = {}) {
215
+ if (buildOk) {
216
+ return;
217
+ }
218
+ console.error(staleBinaryBanner(action));
219
+ if (process.env.CODEYAM_ALLOW_STALE !== "1") {
220
+ process.exit(1);
221
+ }
222
+ console.error(
223
+ `CODEYAM_ALLOW_STALE=1 set — ${continueVerb} the existing (stale) binary.`,
224
+ );
225
+ }
226
+
227
+ function isPlainJsonObject(value) {
228
+ return value !== null && typeof value === "object" && !Array.isArray(value);
229
+ }
230
+
231
+ // Mirrors `deep_merge_json` in crates/types/src/config.rs:1284.
232
+ // Objects merge by key (`null` in overlay removes the key); arrays merge
233
+ // by index (extra overlay items appended, base items past overlay length
234
+ // retained); scalars replace. The JS and Rust sides MUST match — any
235
+ // divergence shows up as "the CLI sees port X but `npm run editor`
236
+ // opens port Y", which is exactly the bug this whole helper exists to
237
+ // prevent.
238
+ function deepMergeJson(base, overlay) {
239
+ if (isPlainJsonObject(base) && isPlainJsonObject(overlay)) {
240
+ for (const k of Object.keys(overlay)) {
241
+ const v = overlay[k];
242
+ if (v === null) {
243
+ delete base[k];
244
+ } else if (Object.prototype.hasOwnProperty.call(base, k)) {
245
+ base[k] = deepMergeJson(base[k], v);
246
+ } else {
247
+ base[k] = v;
248
+ }
249
+ }
250
+ return base;
251
+ }
252
+ if (Array.isArray(base) && Array.isArray(overlay)) {
253
+ for (let i = 0; i < overlay.length; i++) {
254
+ if (i < base.length) {
255
+ base[i] = deepMergeJson(base[i], overlay[i]);
256
+ } else {
257
+ base.push(overlay[i]);
258
+ }
259
+ }
260
+ return base;
261
+ }
262
+ return overlay;
263
+ }
264
+
265
+ // Mirrors the post-merge strip in `merged_config_value`
266
+ // (crates/types/src/config.rs:1272). Any top-level key starting with
267
+ // `_` is documentation-only (`_README`, `_examples`, …) and is removed
268
+ // before the merged config is used by the launcher or handed to the
269
+ // Rust binary. Mutates in place; returns the same object for chaining.
270
+ function stripUnderscoreTopLevelKeys(value) {
271
+ if (isPlainJsonObject(value)) {
272
+ for (const k of Object.keys(value)) {
273
+ if (k.startsWith("_")) delete value[k];
274
+ }
275
+ }
276
+ return value;
277
+ }
278
+
279
+ /**
280
+ * Read `<projectDir>/.codeyam/editor.json` and deep-merge an optional
281
+ * sibling `editor.local.json` on top, returning the merged plain object.
282
+ *
283
+ * This is the JS-side equivalent of `merged_config_value` in
284
+ * crates/types/src/config.rs — same merge rules, same underscore-prefix
285
+ * strip. Launcher scripts call this *before* the Rust binary is built
286
+ * (they invoke `cargo build` themselves), so shelling out to
287
+ * `codeyam-editor editor config-show` is not an option.
288
+ *
289
+ * Returns `{}` if the base file is missing (a freshly cloned repo with
290
+ * no `.codeyam/` directory should not throw at require-time). Throws
291
+ * on malformed JSON, naming the offending file path so the user knows
292
+ * which file to fix.
293
+ */
294
+ function readMergedEditorConfig(projectDir = rootDir) {
295
+ const basePath = path.join(projectDir, ".codeyam", "editor.json");
296
+ if (!fs.existsSync(basePath)) return {};
297
+
298
+ let merged;
299
+ try {
300
+ merged = JSON.parse(fs.readFileSync(basePath, "utf8"));
301
+ } catch (e) {
302
+ throw new Error(`Failed to parse ${basePath}: ${e.message}`);
303
+ }
304
+ if (!isPlainJsonObject(merged)) merged = {};
305
+
306
+ const overlayPath = path.join(projectDir, ".codeyam", "editor.local.json");
307
+ if (fs.existsSync(overlayPath)) {
308
+ let overlay;
309
+ try {
310
+ overlay = JSON.parse(fs.readFileSync(overlayPath, "utf8"));
311
+ } catch (e) {
312
+ throw new Error(`Failed to parse ${overlayPath}: ${e.message}`);
313
+ }
314
+ if (isPlainJsonObject(overlay)) {
315
+ deepMergeJson(merged, overlay);
316
+ }
317
+ }
318
+
319
+ return stripUnderscoreTopLevelKeys(merged);
320
+ }
321
+
322
+ /**
323
+ * Resolve a port for a launcher in priority order:
324
+ * explicit env var → merged-config value → hard fallback.
325
+ *
326
+ * Exported so each launcher's port resolution can be unit-tested
327
+ * without spawning a process. The fallback is the historical hard-coded
328
+ * value (14199 / 5173 / 3000 depending on which slot the caller is
329
+ * resolving) and exists so a fresh checkout with no override file and
330
+ * no env var still works.
331
+ */
332
+ function resolveLauncherPort(envValue, mergedValue, fallback) {
333
+ if (envValue !== undefined && envValue !== null && envValue !== "") {
334
+ const parsed = parseInt(envValue, 10);
335
+ if (Number.isFinite(parsed)) return parsed;
336
+ }
337
+ if (typeof mergedValue === "number" && Number.isFinite(mergedValue)) {
338
+ return mergedValue;
339
+ }
340
+ return fallback;
341
+ }
342
+
343
+ /**
344
+ * Resolve the wrapped-app port (the port the launcher logs and probes).
345
+ * Mirrors `resolve_app_port` in
346
+ * `crates/codeyam-editor/src/commands/start.rs`: `env > apps[0].port >
347
+ * config.port`. Any divergence between this helper and the Rust resolver
348
+ * shows up as "the launcher waits on port X but the backend binds Y",
349
+ * which is the failure mode this whole helper exists to prevent.
350
+ *
351
+ * `merged` is the deep-merged editor.json + editor.local.json blob; pass
352
+ * `null`/`undefined` when no config is available so the launcher's
353
+ * env-and-fallback path still works on a project with no `.codeyam/`.
354
+ */
355
+ function resolveWrappedAppPort(merged, envValue, fallback) {
356
+ const appsZeroPort = merged && merged.apps && merged.apps[0] && merged.apps[0].port;
357
+ const topLevelPort = merged && merged.port;
358
+ const mergedValue = typeof appsZeroPort === "number" ? appsZeroPort : topLevelPort;
359
+ return resolveLauncherPort(envValue, mergedValue, fallback);
360
+ }
361
+
63
362
  /** Try to connect to a specific host:port. Returns true if something is listening. */
64
363
  function tryConnect(port, host) {
65
364
  return new Promise((resolve) => {
@@ -117,6 +416,10 @@ function clearServerState() {
117
416
  } catch {}
118
417
  }
119
418
 
419
+ // Default ports across stopServer / restartServer / ensureServer /
420
+ // requestServerShutdown are a backstop for any future caller that
421
+ // forgets to pass an explicit port. Every production call site now
422
+ // resolves the port from `readMergedEditorConfig` + `resolveLauncherPort`.
120
423
  async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
121
424
  const controller = new AbortController();
122
425
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -134,16 +437,155 @@ async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
134
437
  }
135
438
  }
136
439
 
440
+ /**
441
+ * GET /api/codeyam-server-identity from the listener on `port` with a
442
+ * sub-second timeout. Returns the parsed `{projectDir, pid, version,
443
+ * startedAt}` body on a 2xx response, or `null` on any failure
444
+ * (timeout, non-200, parse failure, connection refused).
445
+ *
446
+ * `null` carries two distinct meanings that the caller must
447
+ * disambiguate by also checking `isPortInUse`:
448
+ * - port is free → no listener at all
449
+ * - port is in use → something is listening, but it's not a
450
+ * codeyam-editor server (or it's an old build that predates this
451
+ * endpoint).
452
+ */
453
+ async function fetchServerIdentity(port, timeoutMs = 500) {
454
+ const controller = new AbortController();
455
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
456
+
457
+ try {
458
+ const resp = await fetch(`http://127.0.0.1:${port}/api/codeyam-server-identity`, {
459
+ method: "GET",
460
+ signal: controller.signal,
461
+ });
462
+ if (!resp.ok) return null;
463
+ const body = await resp.json();
464
+ if (!body || typeof body !== "object" || typeof body.projectDir !== "string") {
465
+ return null;
466
+ }
467
+ return body;
468
+ } catch {
469
+ return null;
470
+ } finally {
471
+ clearTimeout(timeout);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Resolve the canonical (symlink-followed) path for `dir`, falling back to
477
+ * `dir` if `fs.realpath` fails (e.g. the path no longer exists). Both
478
+ * sides of the identity check route through this so a `~/work/foo`
479
+ * symlink and a `/Volumes/dev/foo` direct path compare equal.
480
+ */
481
+ async function canonicalisePath(dir) {
482
+ try {
483
+ return await fs.promises.realpath(dir);
484
+ } catch {
485
+ return dir;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Build the "port held by a non-codeyam-editor process" error string.
491
+ * Lives next to `classifyPortHolder` so the wording is tested without
492
+ * a running server — both launchers (`ensureServer` and `editor.js`'s
493
+ * `--restart`) print this exact message on a "foreign" classification.
494
+ */
495
+ function formatForeignListenerError(port) {
496
+ return (
497
+ `Port ${port} is in use, but no codeyam-editor server responded ` +
498
+ `at /api/codeyam-server-identity. Free the port or change ` +
499
+ `controlPort in .codeyam/editor.local.json.`
500
+ );
501
+ }
502
+
503
+ /**
504
+ * Build the "another project's editor is on this port" error string.
505
+ * `verb` is the action the launcher would have taken ("attach" /
506
+ * "restart"); the wording is otherwise identical so both launcher
507
+ * paths share the same advice.
508
+ */
509
+ function formatCrossProjectError(port, holder, verb) {
510
+ const pidNote = Number.isInteger(holder.identity && holder.identity.pid)
511
+ ? ` (pid ${holder.identity.pid})`
512
+ : "";
513
+ return (
514
+ `Refusing to ${verb} a codeyam-editor server for a different project.\n` +
515
+ ` This project: ${holder.wantDir}\n` +
516
+ ` Server on port ${port}${pidNote}: ${holder.haveDir}\n` +
517
+ `\n` +
518
+ `Change controlPort in .codeyam/editor.local.json, e.g.:\n` +
519
+ ` codeyam-editor editor config-override proxy.controlPort 14399`
520
+ );
521
+ }
522
+
523
+ /**
524
+ * Classify whatever is listening on `port` relative to `projectDir`.
525
+ * Returns one of:
526
+ * - `{ kind: "free" }` — nothing on the port
527
+ * - `{ kind: "foreign" }` — port held by a non-editor process
528
+ * - `{ kind: "cross-project", wantDir, haveDir, identity }`
529
+ * — held by another project's editor
530
+ * - `{ kind: "same-project", identity }` — held by THIS project's editor
531
+ *
532
+ * Pure: every effect (network, fs) is bounded; no console output, no
533
+ * process.exit. The launcher decides what to do with each kind. Splitting
534
+ * the policy (what to print, when to exit) from the classification keeps
535
+ * the helper testable without mocking process.exit, and lets `ensureServer`
536
+ * and `editor.js`'s `--restart` block share the same logic with different
537
+ * wording.
538
+ */
539
+ async function classifyPortHolder(port, projectDir = process.cwd()) {
540
+ if (!(await isPortInUse(port))) {
541
+ return { kind: "free" };
542
+ }
543
+ const identity = await fetchServerIdentity(port);
544
+ if (identity === null) {
545
+ return { kind: "foreign" };
546
+ }
547
+ const wantDir = await canonicalisePath(projectDir);
548
+ const haveDir = await canonicalisePath(identity.projectDir);
549
+ if (wantDir !== haveDir) {
550
+ return { kind: "cross-project", wantDir, haveDir, identity };
551
+ }
552
+ return { kind: "same-project", identity };
553
+ }
554
+
137
555
  function tryKillPid(pid) {
138
556
  try {
139
- process.kill(pid, "SIGINT");
557
+ // On Windows, `process.kill(pid, "SIGINT")` either errors or no-ops
558
+ // depending on Node version. Drop the signal so Node maps to a
559
+ // platform TerminateProcess. Less graceful, but the child does exit.
560
+ if (process.platform === "win32") {
561
+ process.kill(pid);
562
+ } else {
563
+ process.kill(pid, "SIGINT");
564
+ }
140
565
  return true;
141
566
  } catch {
142
567
  return false;
143
568
  }
144
569
  }
145
570
 
571
+ /**
572
+ * Send the platform-correct termination signal to a spawned child process.
573
+ * Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C —
574
+ * `child.kill("SIGINT")` is unreliable on Windows.
575
+ */
576
+ function killChildProcess(child) {
577
+ if (!child) return;
578
+ if (process.platform === "win32") {
579
+ child.kill();
580
+ } else {
581
+ child.kill("SIGINT");
582
+ }
583
+ }
584
+
146
585
  function findListeningPids(port) {
586
+ // Process discovery on Windows is best-effort — `lsof` is not installed and
587
+ // a `netstat -ano` parser is a follow-up. The API-shutdown path and the
588
+ // PID-file path together stop the server cleanly in normal operation.
147
589
  if (process.platform === "win32") return [];
148
590
 
149
591
  const result = spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
@@ -201,23 +643,59 @@ async function restartServer(port = 14199) {
201
643
  return ensureServer(port);
202
644
  }
203
645
 
204
- /** Start the Rust backend server if not already running. Returns the child process (or null if already running). */
646
+ /**
647
+ * Start the Rust backend server if not already running, refusing to attach
648
+ * if the existing listener belongs to a different project (or to a foreign
649
+ * process that doesn't speak the identity endpoint at all). Returns the
650
+ * spawned child on a fresh start, or `null` when we attached to an
651
+ * already-running same-project server. Exits the process non-zero on a
652
+ * mismatch — silently choosing a different port would just relocate the
653
+ * surprise.
654
+ */
205
655
  async function ensureServer(port = 14199) {
206
- if (await isPortInUse(port)) {
207
- console.error(`Server already running on port ${port}`);
208
- return null;
656
+ const holder = await classifyPortHolder(port);
657
+ switch (holder.kind) {
658
+ case "foreign":
659
+ console.error(formatForeignListenerError(port));
660
+ process.exit(1);
661
+ break;
662
+ case "cross-project":
663
+ console.error(formatCrossProjectError(port, holder, "attach"));
664
+ process.exit(1);
665
+ break;
666
+ case "same-project":
667
+ console.error(`Server already running on port ${port}`);
668
+ return null;
669
+ case "free":
670
+ default:
671
+ break;
209
672
  }
210
673
 
211
674
  const binary = ensureBinary();
675
+ // UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is
676
+ // now an explicit dev-override only.
212
677
  const child = spawn(binary, ["start", "--no-open", "--port", String(port)], {
213
678
  stdio: "inherit",
214
679
  cwd: process.cwd(),
215
680
  detached: false,
216
- env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() },
681
+ env: process.env,
217
682
  });
218
683
 
219
- const ready = await waitForPort(port);
220
- if (!ready) {
684
+ const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port);
685
+
686
+ if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) {
687
+ const { code, signal } = exitedBeforeBind ?? { code: null, signal: null };
688
+ const detail = signal != null ? `signal ${signal}` : `code ${code}`;
689
+ console.error(
690
+ `codeyam-editor server exited (${detail}) before binding port ${port}. ` +
691
+ "Likely a preflight bail; see output above.",
692
+ );
693
+ // Always propagate as a real failure — even on code 0 the caller
694
+ // needs to know the server isn't running.
695
+ process.exit(code != null && code !== 0 ? code : 1);
696
+ }
697
+
698
+ if (outcome === "timeout") {
221
699
  console.error("Server failed to start within 10 seconds");
222
700
  child.kill();
223
701
  process.exit(1);
@@ -226,18 +704,66 @@ async function ensureServer(port = 14199) {
226
704
  return child;
227
705
  }
228
706
 
707
+ /**
708
+ * Race a child process's exit against a port-becomes-listening poll.
709
+ * Returns `{ outcome, exitedBeforeBind }` where `outcome` is one of
710
+ * `"ready"` (port bound first), `"timeout"` (poll exhausted before the
711
+ * port bound and the child is still alive), or `"exited"` (child exited
712
+ * before the port bound). `exitedBeforeBind` is `{ code, signal }` if
713
+ * the child exited at any point during the race, else `null`.
714
+ *
715
+ * Exists as its own helper so the silent-clean-exit branch of
716
+ * `ensureServer` is unit-testable without spawning the real Rust
717
+ * binary. `waitForPortFn` defaults to `waitForPort`; tests pass a fake
718
+ * that resolves synchronously.
719
+ */
720
+ async function awaitServerReadyOrChildExit(child, port, waitForPortFn = waitForPort) {
721
+ let exitedBeforeBind = null;
722
+ const exitPromise = new Promise((resolve) => {
723
+ child.once("exit", (code, signal) => {
724
+ exitedBeforeBind = { code, signal };
725
+ resolve("exited");
726
+ });
727
+ });
728
+ const portPromise = waitForPortFn(port).then((ok) => (ok ? "ready" : "timeout"));
729
+ const outcome = await Promise.race([portPromise, exitPromise]);
730
+ return { outcome, exitedBeforeBind };
731
+ }
732
+
229
733
  module.exports = {
230
734
  rootDir,
231
735
  serverStatePath,
736
+ binaryName,
737
+ platformPackageInfo,
738
+ PLATFORM_PACKAGES,
232
739
  findBinary,
233
740
  ensureBinary,
741
+ isSelfHostTarget,
742
+ staleBinaryBanner,
743
+ enforceFreshBinary,
234
744
  uiDistDir,
235
745
  tryConnect,
236
746
  isPortInUse,
237
747
  waitForPort,
238
748
  waitForPortToClose,
239
749
  readServerState,
750
+ clearServerState,
751
+ requestServerShutdown,
752
+ fetchServerIdentity,
753
+ canonicalisePath,
754
+ classifyPortHolder,
755
+ formatForeignListenerError,
756
+ formatCrossProjectError,
757
+ tryKillPid,
758
+ findListeningPids,
240
759
  stopServer,
241
760
  restartServer,
242
761
  ensureServer,
762
+ awaitServerReadyOrChildExit,
763
+ killChildProcess,
764
+ deepMergeJson,
765
+ stripUnderscoreTopLevelKeys,
766
+ readMergedEditorConfig,
767
+ resolveLauncherPort,
768
+ resolveWrappedAppPort,
243
769
  };