@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
package/npm/utils.js ADDED
@@ -0,0 +1,823 @@
1
+ const { execSync, spawn, spawnSync } = require("child_process");
2
+ const net = require("net");
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+
6
+ const rootDir = path.resolve(__dirname, "..");
7
+ const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
8
+
9
+ /**
10
+ * Map (process.platform, process.arch) → the platform sub-package that
11
+ * carries the matching native binaries. Each sub-package ships TWO
12
+ * binaries side-by-side in its `bin/` dir: the `codeyam-editor` monolith
13
+ * and the dedicated `codeyam-editor-pty-broker` daemon (its own
14
+ * executable file / inode, for a stable honest process name). They live
15
+ * in the same dir so the broker auto-spawn's sibling-of-current-exe
16
+ * resolution finds the broker next to the monolith with no copying.
17
+ * Kept as a small data table so adding a new platform (e.g. linux/arm64)
18
+ * is one entry, not a new branch.
19
+ */
20
+ const PLATFORM_PACKAGES = {
21
+ "darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
22
+ "darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
23
+ "linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
24
+ "win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe", broker: "codeyam-editor-pty-broker.exe" },
25
+ };
26
+
27
+ /**
28
+ * Platform-correct executable filename for the codeyam-editor binary.
29
+ * Windows requires the `.exe` extension; on Unix the binary has no extension.
30
+ * Accepts an explicit platform so the helper is testable across platforms.
31
+ */
32
+ function binaryName(platform = process.platform) {
33
+ return platform === "win32" ? "codeyam-editor.exe" : "codeyam-editor";
34
+ }
35
+
36
+ /**
37
+ * Platform-correct executable filename for the dedicated broker binary.
38
+ * Mirrors {@link binaryName} for the second shipped binary.
39
+ */
40
+ function brokerBinaryName(platform = process.platform) {
41
+ return platform === "win32" ? "codeyam-editor-pty-broker.exe" : "codeyam-editor-pty-broker";
42
+ }
43
+
44
+ /** Look up the platform sub-package metadata for a given platform/arch pair. */
45
+ function platformPackageInfo(platform = process.platform, arch = process.arch) {
46
+ return PLATFORM_PACKAGES[`${platform}-${arch}`] || null;
47
+ }
48
+
49
+ /**
50
+ * Resolve the native binary path.
51
+ *
52
+ * Production install (npm install @codeyam-editor/codeyam-editor):
53
+ * npm installed the matching @codeyam-editor/codeyam-editor-<plat>-<arch>
54
+ * sub-package via optionalDependencies. require.resolve finds the binary
55
+ * inside that sibling package.
56
+ *
57
+ * Dev checkout (`cargo build` in the repo):
58
+ * No platform sub-package installed; fall through to target/debug or
59
+ * target/release.
60
+ *
61
+ * Returns null if no binary can be found at any of these locations — the
62
+ * caller (ensureBinary / cli.js) decides whether to error or to invoke
63
+ * `cargo build` based on whether a Cargo.toml is present.
64
+ */
65
+ function findBinary() {
66
+ const info = platformPackageInfo();
67
+ if (info) {
68
+ try {
69
+ const resolved = require.resolve(`${info.pkg}/bin/${info.binary}`, {
70
+ paths: [rootDir],
71
+ });
72
+ if (fs.existsSync(resolved)) return resolved;
73
+ } catch (e) {
74
+ if (e.code !== "MODULE_NOT_FOUND") throw e;
75
+ }
76
+ }
77
+
78
+ const name = binaryName();
79
+ // Target directories to search, in precedence order. A custom
80
+ // CARGO_TARGET_DIR (cloud VMs set CARGO_TARGET_DIR=/codeyam-build-target)
81
+ // wins over the default ./target — cargo builds ONLY into the configured
82
+ // dir, so ./target may be stale or absent. Honoring it here keeps
83
+ // `ensureBinary` from building successfully and then failing to locate the
84
+ // result. CARGO_TARGET_DIR may be absolute or relative to the workspace
85
+ // root; path.resolve handles both (an absolute value ignores rootDir).
86
+ const targetDirs = [];
87
+ const customTarget = process.env.CARGO_TARGET_DIR;
88
+ if (customTarget) {
89
+ targetDirs.push(path.resolve(rootDir, customTarget));
90
+ }
91
+ targetDirs.push(path.join(rootDir, "target"));
92
+
93
+ const devCandidates = [];
94
+ for (const dir of targetDirs) {
95
+ devCandidates.push(path.join(dir, "debug", name));
96
+ devCandidates.push(path.join(dir, "release", name));
97
+ }
98
+ for (const candidate of devCandidates) {
99
+ if (fs.existsSync(candidate)) return candidate;
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Return the binary path, building from source if we're in a dev checkout.
107
+ *
108
+ * Production install: if findBinary returns null, the platform sub-package
109
+ * is missing — npm install failed silently (likely because the user's
110
+ * platform isn't in the build matrix). Emit a clear, actionable error
111
+ * naming the expected sub-package, and exit 1. **Do not** fall through to
112
+ * `cargo build` — there's no Cargo.toml in the npm install dir, and the
113
+ * resulting "could not find Cargo.toml" error is misleading to users.
114
+ *
115
+ * Dev checkout: `Cargo.toml` is present at rootDir → run `cargo build`,
116
+ * same as the legacy behavior.
117
+ */
118
+ function ensureBinary() {
119
+ const existing = findBinary();
120
+ if (existing) return existing;
121
+
122
+ const isDevCheckout = fs.existsSync(path.join(rootDir, "Cargo.toml"));
123
+
124
+ if (!isDevCheckout) {
125
+ const info = platformPackageInfo();
126
+ if (!info) {
127
+ console.error(
128
+ `codeyam-editor: unsupported platform ${process.platform}/${process.arch}.\n` +
129
+ `Supported: darwin/arm64, darwin/x64, linux/x64, win32/x64.`
130
+ );
131
+ } else {
132
+ console.error(
133
+ `codeyam-editor: native binary missing.\n` +
134
+ `Expected at ${info.pkg}/bin/${info.binary} — npm should have installed ` +
135
+ `that sub-package as an optionalDependency.\n` +
136
+ `Try: npm install --force ${info.pkg}@$(npm view @codeyam-editor/codeyam-editor version)`
137
+ );
138
+ }
139
+ process.exit(1);
140
+ }
141
+
142
+ try {
143
+ execSync("cargo --version", { stdio: "ignore" });
144
+ } catch {
145
+ console.error(
146
+ "codeyam-editor binary not found and `cargo` is not on PATH. " +
147
+ "Install Rust from https://rustup.rs/ or reinstall @codeyam-editor/codeyam-editor."
148
+ );
149
+ process.exit(1);
150
+ }
151
+
152
+ console.error("Building codeyam-editor...");
153
+ try {
154
+ execSync("cargo build", { cwd: rootDir, stdio: "inherit" });
155
+ } catch {
156
+ console.error("Failed to build. Is Rust installed? https://rustup.rs/");
157
+ process.exit(1);
158
+ }
159
+
160
+ const binary = findBinary();
161
+ if (!binary) {
162
+ console.error("Binary not found after build.");
163
+ process.exit(1);
164
+ }
165
+ verifyBrokerBinaryPresent(binary);
166
+ return binary;
167
+ }
168
+
169
+ /**
170
+ * Resolve the dedicated broker binary path. Mirrors {@link findBinary}:
171
+ * the published platform sub-package ships it next to the monolith, and
172
+ * a dev `cargo build` produces it under target/debug|release. Returns
173
+ * null if it can't be located — the Rust broker auto-spawn then falls
174
+ * back to the monolith `editor pty-broker daemon` shim, so a missing
175
+ * broker binary degrades gracefully rather than breaking the editor.
176
+ */
177
+ function findBrokerBinary() {
178
+ const info = platformPackageInfo();
179
+ if (info && info.broker) {
180
+ try {
181
+ const resolved = require.resolve(`${info.pkg}/bin/${info.broker}`, {
182
+ paths: [rootDir],
183
+ });
184
+ if (fs.existsSync(resolved)) return resolved;
185
+ } catch (e) {
186
+ if (e.code !== "MODULE_NOT_FOUND") throw e;
187
+ }
188
+ }
189
+
190
+ const name = brokerBinaryName();
191
+ const targetDirs = [];
192
+ const customTarget = process.env.CARGO_TARGET_DIR;
193
+ if (customTarget) {
194
+ targetDirs.push(path.resolve(rootDir, customTarget));
195
+ }
196
+ targetDirs.push(path.join(rootDir, "target"));
197
+
198
+ for (const dir of targetDirs) {
199
+ for (const profile of ["debug", "release"]) {
200
+ const candidate = path.join(dir, profile, name);
201
+ if (fs.existsSync(candidate)) return candidate;
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Best-effort check that the dedicated broker binary sits next to the
209
+ * monolith (the layout the broker auto-spawn's sibling resolution
210
+ * expects). Logs a one-line note when it's missing — never throws or
211
+ * exits, because the Rust side degrades to the monolith shim. `monolith`
212
+ * is the resolved monolith binary path; the broker is expected as its
213
+ * sibling.
214
+ */
215
+ function verifyBrokerBinaryPresent(monolith) {
216
+ const sibling = path.join(path.dirname(monolith), brokerBinaryName());
217
+ if (fs.existsSync(sibling)) return;
218
+ const located = findBrokerBinary();
219
+ if (located) return;
220
+ console.error(
221
+ "Note: dedicated broker binary (codeyam-editor-pty-broker) not found next to " +
222
+ `${monolith}. The PTY broker will fall back to the monolith \`editor pty-broker daemon\` ` +
223
+ "shim. Reinstall the platform sub-package, or run `cargo build -p codeyam-pty-broker`."
224
+ );
225
+ }
226
+
227
+ /** Resolve the UI dist directory for the binary to serve. */
228
+ function uiDistDir() {
229
+ return path.join(rootDir, "ui", "dist");
230
+ }
231
+
232
+ /**
233
+ * Loud banner printed when `cargo build` failed and a wrapper is about to fall
234
+ * back to a stale binary. Shared by both wrappers (editor.js server boot,
235
+ * editor-dev.js passthrough/launch) so the refusal text and the documented
236
+ * CODEYAM_ALLOW_STALE escape hatch stay identical. `action` names what would
237
+ * run on the stale binary, e.g. "run editor commands" / "start the editor
238
+ * server".
239
+ */
240
+ function staleBinaryBanner(action = "run editor commands") {
241
+ return [
242
+ "",
243
+ "============================================================",
244
+ " cargo build FAILED — the editor source does not compile.",
245
+ ` Refusing to ${action} on a STALE binary,`,
246
+ " which would run code that no longer matches the source.",
247
+ "",
248
+ " Fix the compile error above, then re-run.",
249
+ " To deliberately run the last-good binary anyway, set",
250
+ " CODEYAM_ALLOW_STALE=1.",
251
+ "============================================================",
252
+ "",
253
+ ].join("\n");
254
+ }
255
+
256
+ /**
257
+ * Enforce a fresh binary before a wrapper runs it. A no-op when the rebuild
258
+ * succeeded (`buildOk` true). When it failed, print the stale-binary banner and
259
+ * `process.exit(1)` — UNLESS `CODEYAM_ALLOW_STALE=1`, in which case print a
260
+ * one-line override notice and return so the caller proceeds on the last-good
261
+ * binary. This is the single code path the passthrough, launch, and
262
+ * server-boot call sites share, so they cannot diverge on whether they honor
263
+ * the override. `continueVerb` tailors the override notice ("continuing with"
264
+ * vs "launching with").
265
+ */
266
+ function enforceFreshBinary(buildOk, { action, continueVerb = "continuing with" } = {}) {
267
+ if (buildOk) {
268
+ return;
269
+ }
270
+ console.error(staleBinaryBanner(action));
271
+ if (process.env.CODEYAM_ALLOW_STALE !== "1") {
272
+ process.exit(1);
273
+ }
274
+ console.error(
275
+ `CODEYAM_ALLOW_STALE=1 set — ${continueVerb} the existing (stale) binary.`,
276
+ );
277
+ }
278
+
279
+ function isPlainJsonObject(value) {
280
+ return value !== null && typeof value === "object" && !Array.isArray(value);
281
+ }
282
+
283
+ // Mirrors `deep_merge_json` in crates/types/src/config.rs:1284.
284
+ // Objects merge by key (`null` in overlay removes the key); arrays merge
285
+ // by index (extra overlay items appended, base items past overlay length
286
+ // retained); scalars replace. The JS and Rust sides MUST match — any
287
+ // divergence shows up as "the CLI sees port X but `npm run editor`
288
+ // opens port Y", which is exactly the bug this whole helper exists to
289
+ // prevent.
290
+ function deepMergeJson(base, overlay) {
291
+ if (isPlainJsonObject(base) && isPlainJsonObject(overlay)) {
292
+ for (const k of Object.keys(overlay)) {
293
+ const v = overlay[k];
294
+ if (v === null) {
295
+ delete base[k];
296
+ } else if (Object.prototype.hasOwnProperty.call(base, k)) {
297
+ base[k] = deepMergeJson(base[k], v);
298
+ } else {
299
+ base[k] = v;
300
+ }
301
+ }
302
+ return base;
303
+ }
304
+ if (Array.isArray(base) && Array.isArray(overlay)) {
305
+ for (let i = 0; i < overlay.length; i++) {
306
+ if (i < base.length) {
307
+ base[i] = deepMergeJson(base[i], overlay[i]);
308
+ } else {
309
+ base.push(overlay[i]);
310
+ }
311
+ }
312
+ return base;
313
+ }
314
+ return overlay;
315
+ }
316
+
317
+ // Mirrors the post-merge strip in `merged_config_value`
318
+ // (crates/types/src/config.rs:1272). Any top-level key starting with
319
+ // `_` is documentation-only (`_README`, `_examples`, …) and is removed
320
+ // before the merged config is used by the launcher or handed to the
321
+ // Rust binary. Mutates in place; returns the same object for chaining.
322
+ function stripUnderscoreTopLevelKeys(value) {
323
+ if (isPlainJsonObject(value)) {
324
+ for (const k of Object.keys(value)) {
325
+ if (k.startsWith("_")) delete value[k];
326
+ }
327
+ }
328
+ return value;
329
+ }
330
+
331
+ /**
332
+ * Read `<projectDir>/.codeyam/editor.json` and deep-merge an optional
333
+ * sibling `editor.local.json` on top, returning the merged plain object.
334
+ *
335
+ * This is the JS-side equivalent of `merged_config_value` in
336
+ * crates/types/src/config.rs — same merge rules, same underscore-prefix
337
+ * strip. Launcher scripts call this *before* the Rust binary is built
338
+ * (they invoke `cargo build` themselves), so shelling out to
339
+ * `codeyam-editor editor config-show` is not an option.
340
+ *
341
+ * Returns `{}` if the base file is missing (a freshly cloned repo with
342
+ * no `.codeyam/` directory should not throw at require-time). Throws
343
+ * on malformed JSON, naming the offending file path so the user knows
344
+ * which file to fix.
345
+ */
346
+ function readMergedEditorConfig(projectDir = rootDir) {
347
+ const basePath = path.join(projectDir, ".codeyam", "editor.json");
348
+ if (!fs.existsSync(basePath)) return {};
349
+
350
+ let merged;
351
+ try {
352
+ merged = JSON.parse(fs.readFileSync(basePath, "utf8"));
353
+ } catch (e) {
354
+ throw new Error(`Failed to parse ${basePath}: ${e.message}`);
355
+ }
356
+ if (!isPlainJsonObject(merged)) merged = {};
357
+
358
+ const overlayPath = path.join(projectDir, ".codeyam", "editor.local.json");
359
+ if (fs.existsSync(overlayPath)) {
360
+ let overlay;
361
+ try {
362
+ overlay = JSON.parse(fs.readFileSync(overlayPath, "utf8"));
363
+ } catch (e) {
364
+ throw new Error(`Failed to parse ${overlayPath}: ${e.message}`);
365
+ }
366
+ if (isPlainJsonObject(overlay)) {
367
+ deepMergeJson(merged, overlay);
368
+ }
369
+ }
370
+
371
+ return stripUnderscoreTopLevelKeys(merged);
372
+ }
373
+
374
+ /**
375
+ * Resolve a port for a launcher in priority order:
376
+ * explicit env var → merged-config value → hard fallback.
377
+ *
378
+ * Exported so each launcher's port resolution can be unit-tested
379
+ * without spawning a process. The fallback is the historical hard-coded
380
+ * value (14199 / 5173 / 3000 depending on which slot the caller is
381
+ * resolving) and exists so a fresh checkout with no override file and
382
+ * no env var still works.
383
+ */
384
+ function resolveLauncherPort(envValue, mergedValue, fallback) {
385
+ if (envValue !== undefined && envValue !== null && envValue !== "") {
386
+ const parsed = parseInt(envValue, 10);
387
+ if (Number.isFinite(parsed)) return parsed;
388
+ }
389
+ if (typeof mergedValue === "number" && Number.isFinite(mergedValue)) {
390
+ return mergedValue;
391
+ }
392
+ return fallback;
393
+ }
394
+
395
+ /**
396
+ * Resolve the wrapped-app port (the port the launcher logs and probes).
397
+ * Mirrors `resolve_app_port` in
398
+ * `crates/codeyam-editor/src/commands/start.rs`: `env > apps[0].port >
399
+ * config.port`. Any divergence between this helper and the Rust resolver
400
+ * shows up as "the launcher waits on port X but the backend binds Y",
401
+ * which is the failure mode this whole helper exists to prevent.
402
+ *
403
+ * `merged` is the deep-merged editor.json + editor.local.json blob; pass
404
+ * `null`/`undefined` when no config is available so the launcher's
405
+ * env-and-fallback path still works on a project with no `.codeyam/`.
406
+ */
407
+ function resolveWrappedAppPort(merged, envValue, fallback) {
408
+ const appsZeroPort = merged && merged.apps && merged.apps[0] && merged.apps[0].port;
409
+ const topLevelPort = merged && merged.port;
410
+ const mergedValue = typeof appsZeroPort === "number" ? appsZeroPort : topLevelPort;
411
+ return resolveLauncherPort(envValue, mergedValue, fallback);
412
+ }
413
+
414
+ /** Try to connect to a specific host:port. Returns true if something is listening. */
415
+ function tryConnect(port, host) {
416
+ return new Promise((resolve) => {
417
+ const socket = new net.Socket();
418
+ socket.setTimeout(300);
419
+ socket.once("connect", () => { socket.destroy(); resolve(true); });
420
+ socket.once("error", () => { socket.destroy(); resolve(false); });
421
+ socket.once("timeout", () => { socket.destroy(); resolve(false); });
422
+ socket.connect(port, host);
423
+ });
424
+ }
425
+
426
+ /** Check if a port is in use (tries both IPv4 and IPv6). */
427
+ async function isPortInUse(port) {
428
+ return (await tryConnect(port, "127.0.0.1")) || (await tryConnect(port, "::1"));
429
+ }
430
+
431
+ /**
432
+ * Poll a port until something is listening, or timeout.
433
+ * Returns true if the port became active, false on timeout.
434
+ */
435
+ async function waitForPort(port, timeoutMs = 10000, intervalMs = 200) {
436
+ const deadline = Date.now() + timeoutMs;
437
+ while (Date.now() < deadline) {
438
+ if (await isPortInUse(port)) return true;
439
+ await new Promise((r) => setTimeout(r, intervalMs));
440
+ }
441
+ return false;
442
+ }
443
+
444
+ /**
445
+ * Poll until a port stops listening, or timeout.
446
+ * Returns true if the port became inactive, false on timeout.
447
+ */
448
+ async function waitForPortToClose(port, timeoutMs = 10000, intervalMs = 200) {
449
+ const deadline = Date.now() + timeoutMs;
450
+ while (Date.now() < deadline) {
451
+ if (!(await isPortInUse(port))) return true;
452
+ await new Promise((r) => setTimeout(r, intervalMs));
453
+ }
454
+ return false;
455
+ }
456
+
457
+ function readServerState() {
458
+ try {
459
+ return JSON.parse(fs.readFileSync(serverStatePath, "utf8"));
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+
465
+ function clearServerState() {
466
+ try {
467
+ fs.unlinkSync(serverStatePath);
468
+ } catch {}
469
+ }
470
+
471
+ // Default ports across stopServer / restartServer / ensureServer /
472
+ // requestServerShutdown are a backstop for any future caller that
473
+ // forgets to pass an explicit port. Every production call site now
474
+ // resolves the port from `readMergedEditorConfig` + `resolveLauncherPort`.
475
+ async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
476
+ const controller = new AbortController();
477
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
478
+
479
+ try {
480
+ const resp = await fetch(`http://127.0.0.1:${port}/api/editor-server/shutdown`, {
481
+ method: "POST",
482
+ signal: controller.signal,
483
+ });
484
+ return resp.ok;
485
+ } catch {
486
+ return false;
487
+ } finally {
488
+ clearTimeout(timeout);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * 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).
497
+ *
498
+ * `null` carries two distinct meanings that the caller must
499
+ * disambiguate by also checking `isPortInUse`:
500
+ * - port is free → no listener at all
501
+ * - port is in use → something is listening, but it's not a
502
+ * codeyam-editor server (or it's an old build that predates this
503
+ * endpoint).
504
+ */
505
+ async function fetchServerIdentity(port, timeoutMs = 500) {
506
+ const controller = new AbortController();
507
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
508
+
509
+ try {
510
+ const resp = await fetch(`http://127.0.0.1:${port}/api/codeyam-server-identity`, {
511
+ method: "GET",
512
+ signal: controller.signal,
513
+ });
514
+ if (!resp.ok) return null;
515
+ const body = await resp.json();
516
+ if (!body || typeof body !== "object" || typeof body.projectDir !== "string") {
517
+ return null;
518
+ }
519
+ return body;
520
+ } catch {
521
+ return null;
522
+ } finally {
523
+ clearTimeout(timeout);
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Resolve the canonical (symlink-followed) path for `dir`, falling back to
529
+ * `dir` if `fs.realpath` fails (e.g. the path no longer exists). Both
530
+ * sides of the identity check route through this so a `~/work/foo`
531
+ * symlink and a `/Volumes/dev/foo` direct path compare equal.
532
+ */
533
+ async function canonicalisePath(dir) {
534
+ try {
535
+ return await fs.promises.realpath(dir);
536
+ } catch {
537
+ return dir;
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Build the "port held by a non-codeyam-editor process" error string.
543
+ * Lives next to `classifyPortHolder` so the wording is tested without
544
+ * a running server — both launchers (`ensureServer` and `editor.js`'s
545
+ * `--restart`) print this exact message on a "foreign" classification.
546
+ */
547
+ function formatForeignListenerError(port) {
548
+ return (
549
+ `Port ${port} is in use, but no codeyam-editor server responded ` +
550
+ `at /api/codeyam-server-identity. Free the port or change ` +
551
+ `controlPort in .codeyam/editor.local.json.`
552
+ );
553
+ }
554
+
555
+ /**
556
+ * Build the "another project's editor is on this port" error string.
557
+ * `verb` is the action the launcher would have taken ("attach" /
558
+ * "restart"); the wording is otherwise identical so both launcher
559
+ * paths share the same advice.
560
+ */
561
+ function formatCrossProjectError(port, holder, verb) {
562
+ const pidNote = Number.isInteger(holder.identity && holder.identity.pid)
563
+ ? ` (pid ${holder.identity.pid})`
564
+ : "";
565
+ return (
566
+ `Refusing to ${verb} a codeyam-editor server for a different project.\n` +
567
+ ` This project: ${holder.wantDir}\n` +
568
+ ` Server on port ${port}${pidNote}: ${holder.haveDir}\n` +
569
+ `\n` +
570
+ `Change controlPort in .codeyam/editor.local.json, e.g.:\n` +
571
+ ` codeyam-editor editor config-override proxy.controlPort 14399`
572
+ );
573
+ }
574
+
575
+ /**
576
+ * Classify whatever is listening on `port` relative to `projectDir`.
577
+ * Returns one of:
578
+ * - `{ kind: "free" }` — nothing on the port
579
+ * - `{ kind: "foreign" }` — port held by a non-editor process
580
+ * - `{ kind: "cross-project", wantDir, haveDir, identity }`
581
+ * — held by another project's editor
582
+ * - `{ kind: "same-project", identity }` — held by THIS project's editor
583
+ *
584
+ * Pure: every effect (network, fs) is bounded; no console output, no
585
+ * process.exit. The launcher decides what to do with each kind. Splitting
586
+ * the policy (what to print, when to exit) from the classification keeps
587
+ * the helper testable without mocking process.exit, and lets `ensureServer`
588
+ * and `editor.js`'s `--restart` block share the same logic with different
589
+ * wording.
590
+ */
591
+ async function classifyPortHolder(port, projectDir = process.cwd()) {
592
+ if (!(await isPortInUse(port))) {
593
+ return { kind: "free" };
594
+ }
595
+ const identity = await fetchServerIdentity(port);
596
+ if (identity === null) {
597
+ return { kind: "foreign" };
598
+ }
599
+ const wantDir = await canonicalisePath(projectDir);
600
+ const haveDir = await canonicalisePath(identity.projectDir);
601
+ if (wantDir !== haveDir) {
602
+ return { kind: "cross-project", wantDir, haveDir, identity };
603
+ }
604
+ return { kind: "same-project", identity };
605
+ }
606
+
607
+ function tryKillPid(pid) {
608
+ try {
609
+ // On Windows, `process.kill(pid, "SIGINT")` either errors or no-ops
610
+ // depending on Node version. Drop the signal so Node maps to a
611
+ // platform TerminateProcess. Less graceful, but the child does exit.
612
+ if (process.platform === "win32") {
613
+ process.kill(pid);
614
+ } else {
615
+ process.kill(pid, "SIGINT");
616
+ }
617
+ return true;
618
+ } catch {
619
+ return false;
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Send the platform-correct termination signal to a spawned child process.
625
+ * Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C —
626
+ * `child.kill("SIGINT")` is unreliable on Windows.
627
+ */
628
+ function killChildProcess(child) {
629
+ if (!child) return;
630
+ if (process.platform === "win32") {
631
+ child.kill();
632
+ } else {
633
+ child.kill("SIGINT");
634
+ }
635
+ }
636
+
637
+ function findListeningPids(port) {
638
+ // Process discovery on Windows is best-effort — `lsof` is not installed and
639
+ // a `netstat -ano` parser is a follow-up. The API-shutdown path and the
640
+ // PID-file path together stop the server cleanly in normal operation.
641
+ if (process.platform === "win32") return [];
642
+
643
+ const result = spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
644
+ encoding: "utf8",
645
+ });
646
+
647
+ if (result.error) return [];
648
+
649
+ return result.stdout
650
+ .split(/\s+/)
651
+ .map((line) => parseInt(line, 10))
652
+ .filter((pid) => Number.isInteger(pid));
653
+ }
654
+
655
+ /** Stop the Rust backend server if it is running. */
656
+ async function stopServer(port = 14199) {
657
+ if (!(await isPortInUse(port))) {
658
+ clearServerState();
659
+ return false;
660
+ }
661
+
662
+ if (await requestServerShutdown(port)) {
663
+ if (await waitForPortToClose(port, 10000, 200)) {
664
+ clearServerState();
665
+ return true;
666
+ }
667
+ }
668
+
669
+ const state = readServerState();
670
+ if (state && Number.isInteger(state.pid) && tryKillPid(state.pid)) {
671
+ if (await waitForPortToClose(port, 5000, 200)) {
672
+ clearServerState();
673
+ return true;
674
+ }
675
+ }
676
+
677
+ for (const pid of findListeningPids(port)) {
678
+ tryKillPid(pid);
679
+ }
680
+
681
+ if (await waitForPortToClose(port, 5000, 200)) {
682
+ clearServerState();
683
+ return true;
684
+ }
685
+
686
+ throw new Error(`Server on port ${port} did not stop cleanly`);
687
+ }
688
+
689
+ /** Stop any existing server on the port, then start a fresh one. */
690
+ async function restartServer(port = 14199) {
691
+ if (await isPortInUse(port)) {
692
+ console.error(`Stopping existing server on port ${port}...`);
693
+ await stopServer(port);
694
+ }
695
+ return ensureServer(port);
696
+ }
697
+
698
+ /**
699
+ * Start the Rust backend server if not already running, refusing to attach
700
+ * if the existing listener belongs to a different project (or to a foreign
701
+ * process that doesn't speak the identity endpoint at all). Returns the
702
+ * spawned child on a fresh start, or `null` when we attached to an
703
+ * already-running same-project server. Exits the process non-zero on a
704
+ * mismatch — silently choosing a different port would just relocate the
705
+ * surprise.
706
+ */
707
+ async function ensureServer(port = 14199) {
708
+ const holder = await classifyPortHolder(port);
709
+ switch (holder.kind) {
710
+ case "foreign":
711
+ console.error(formatForeignListenerError(port));
712
+ process.exit(1);
713
+ break;
714
+ case "cross-project":
715
+ console.error(formatCrossProjectError(port, holder, "attach"));
716
+ process.exit(1);
717
+ break;
718
+ case "same-project":
719
+ console.error(`Server already running on port ${port}`);
720
+ return null;
721
+ case "free":
722
+ default:
723
+ break;
724
+ }
725
+
726
+ const binary = ensureBinary();
727
+ // UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is
728
+ // now an explicit dev-override only.
729
+ const child = spawn(binary, ["start", "--no-open", "--port", String(port)], {
730
+ stdio: "inherit",
731
+ cwd: process.cwd(),
732
+ detached: false,
733
+ env: process.env,
734
+ });
735
+
736
+ const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port);
737
+
738
+ if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) {
739
+ const { code, signal } = exitedBeforeBind ?? { code: null, signal: null };
740
+ const detail = signal != null ? `signal ${signal}` : `code ${code}`;
741
+ console.error(
742
+ `codeyam-editor server exited (${detail}) before binding port ${port}. ` +
743
+ "Likely a preflight bail; see output above.",
744
+ );
745
+ // Always propagate as a real failure — even on code 0 the caller
746
+ // needs to know the server isn't running.
747
+ process.exit(code != null && code !== 0 ? code : 1);
748
+ }
749
+
750
+ if (outcome === "timeout") {
751
+ console.error("Server failed to start within 10 seconds");
752
+ child.kill();
753
+ process.exit(1);
754
+ }
755
+
756
+ return child;
757
+ }
758
+
759
+ /**
760
+ * Race a child process's exit against a port-becomes-listening poll.
761
+ * Returns `{ outcome, exitedBeforeBind }` where `outcome` is one of
762
+ * `"ready"` (port bound first), `"timeout"` (poll exhausted before the
763
+ * port bound and the child is still alive), or `"exited"` (child exited
764
+ * before the port bound). `exitedBeforeBind` is `{ code, signal }` if
765
+ * the child exited at any point during the race, else `null`.
766
+ *
767
+ * Exists as its own helper so the silent-clean-exit branch of
768
+ * `ensureServer` is unit-testable without spawning the real Rust
769
+ * binary. `waitForPortFn` defaults to `waitForPort`; tests pass a fake
770
+ * that resolves synchronously.
771
+ */
772
+ async function awaitServerReadyOrChildExit(child, port, waitForPortFn = waitForPort) {
773
+ let exitedBeforeBind = null;
774
+ const exitPromise = new Promise((resolve) => {
775
+ child.once("exit", (code, signal) => {
776
+ exitedBeforeBind = { code, signal };
777
+ resolve("exited");
778
+ });
779
+ });
780
+ const portPromise = waitForPortFn(port).then((ok) => (ok ? "ready" : "timeout"));
781
+ const outcome = await Promise.race([portPromise, exitPromise]);
782
+ return { outcome, exitedBeforeBind };
783
+ }
784
+
785
+ module.exports = {
786
+ rootDir,
787
+ serverStatePath,
788
+ binaryName,
789
+ brokerBinaryName,
790
+ platformPackageInfo,
791
+ PLATFORM_PACKAGES,
792
+ findBinary,
793
+ findBrokerBinary,
794
+ verifyBrokerBinaryPresent,
795
+ ensureBinary,
796
+ staleBinaryBanner,
797
+ enforceFreshBinary,
798
+ uiDistDir,
799
+ tryConnect,
800
+ isPortInUse,
801
+ waitForPort,
802
+ waitForPortToClose,
803
+ readServerState,
804
+ clearServerState,
805
+ requestServerShutdown,
806
+ fetchServerIdentity,
807
+ canonicalisePath,
808
+ classifyPortHolder,
809
+ formatForeignListenerError,
810
+ formatCrossProjectError,
811
+ tryKillPid,
812
+ findListeningPids,
813
+ stopServer,
814
+ restartServer,
815
+ ensureServer,
816
+ awaitServerReadyOrChildExit,
817
+ killChildProcess,
818
+ deepMergeJson,
819
+ stripUnderscoreTopLevelKeys,
820
+ readMergedEditorConfig,
821
+ resolveLauncherPort,
822
+ resolveWrappedAppPort,
823
+ };