@codeyam-editor/codeyam-editor 0.1.7 → 0.1.9

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 (45) hide show
  1. package/npm/cloud.js +477 -41
  2. package/npm/editor-dev.js +24 -2
  3. package/npm/editor.js +15 -5
  4. package/npm/fleet-add-failure.js +406 -25
  5. package/npm/fleet-branch.js +124 -1
  6. package/npm/fleet-build-data.js +12 -1
  7. package/npm/fleet-cleanup.js +84 -3
  8. package/npm/fleet-datastore-health.js +107 -0
  9. package/npm/fleet-memory.js +29 -4
  10. package/npm/fleet-queue.js +141 -0
  11. package/npm/fleet-revision.js +108 -0
  12. package/npm/fleet-run-setup.js +66 -0
  13. package/npm/fleet-session-activity.js +162 -0
  14. package/npm/fleet-vm-access.js +162 -0
  15. package/npm/fleet-vm-readiness.js +57 -16
  16. package/npm/ingress-invoke.js +46 -2
  17. package/npm/launch-config.js +169 -2
  18. package/npm/scenario-check.js +708 -35
  19. package/npm/scenario-handlers.js +18 -0
  20. package/npm/scenario-interactivity.js +477 -23
  21. package/npm/scenario-issues.js +72 -0
  22. package/npm/scenario-mocks.js +245 -39
  23. package/npm/scenario-playwright.js +137 -26
  24. package/npm/utils.js +262 -22
  25. package/package.json +5 -5
  26. package/ui/dist/assets/{ContainerInspector-Cu1tSN-o.js → ContainerInspector-C7I9wctz.js} +1 -1
  27. package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
  28. package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
  29. package/ui/dist/assets/{ProjectLauncherScreen-Dmm6Z0mv.js → ProjectLauncherScreen-E0NYYiwy.js} +1 -1
  30. package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
  31. package/ui/dist/assets/{ScenarioDataPanelFullPage-BqMxWVKW.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
  32. package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
  33. package/ui/dist/assets/index-BRoAenc1.css +1 -0
  34. package/ui/dist/assets/index-iMR5siiG.js +27 -0
  35. package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
  36. package/ui/dist/index.html +2 -2
  37. package/ui/dist/preview-subdir-demo/index.html +62 -0
  38. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  39. package/ui/dist/assets/EditorShell--V2ExYQ6.js +0 -260
  40. package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
  41. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +0 -9
  42. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +0 -21
  43. package/ui/dist/assets/index-CjxWhkSy.css +0 -1
  44. package/ui/dist/assets/index-VuyqBbGJ.js +0 -27
  45. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +0 -1
@@ -334,16 +334,36 @@ async function collectVisibleTextLength(target) {
334
334
  }
335
335
 
336
336
  // Inject a capture-only stylesheet that snaps entrance animations to their
337
- // FINAL frame: remove animation/transition timing and force the common
338
- // "hidden until revealed" symptoms (`opacity:0`, an entrance `transform`) back
339
- // to their resting values. This is the belt to `scrollThroughDocument`'s
340
- // suspenders — it covers pure-CSS keyframe entrances that are mid-flight or
341
- // stuck at an `opacity:0` start state even after the observers fired. It
342
- // targets the generic CSS symptom, not any framework's reveal class, so it
343
- // works for any stack. The caller gates this OFF when a scenario declares an
344
- // interactive state, so an intentionally animated/collapsed interactive frame
345
- // is never clobbered. Idempotent (a single injected style id) and best-effort.
346
- // Returns true when the style is present after the call.
337
+ // FINAL frame: remove animation/transition timing, then reveal the elements an
338
+ // entrance animation left INVISIBLE. This is the belt to
339
+ // `scrollThroughDocument`'s suspenders — it covers pure-CSS keyframe entrances
340
+ // that are mid-flight or stuck at an `opacity:0` start state even after the
341
+ // observers fired. It targets the generic CSS symptom, not any framework's
342
+ // reveal class, so it works for any stack. The caller gates this OFF when a
343
+ // scenario declares an interactive state, so an intentionally animated /
344
+ // collapsed interactive frame is never clobbered. Idempotent (a single injected
345
+ // style id) and best-effort. Returns true when the style is present after the
346
+ // call.
347
+ //
348
+ // The reveal is per-element and conditional ON THE ELEMENT BEING INVISIBLE,
349
+ // never a blanket `opacity: 1 !important` / `transform: none !important` over
350
+ // `*`. A blanket rule cannot tell an entrance animation's `opacity: 0` from
351
+ // DELIBERATE, resting state — a disabled control's dim, a muted row, a
352
+ // collapsed chevron's rotation — so it silently flattened every one of them out
353
+ // of every screenshot the capture pipeline produced. Two scenarios differing
354
+ // only in such a state then captured byte-identically and collided in the
355
+ // distinct-capture gate, and the collision was unfixable in the component: the
356
+ // state rendered correctly in a real browser (verified: computed opacity 0.4 vs
357
+ // 1) and was erased only at capture time. Anything already visible is now left
358
+ // exactly as the app rendered it.
359
+ //
360
+ // The reveal's TRANSFORM half additionally stops at the SVG boundary, because a
361
+ // CSS `transform` overrides the SVG `transform` presentation attribute: inside
362
+ // an `<svg>` it erases the static geometry that CONSTRUCTS the drawing instead
363
+ // of neutralizing an entrance animation, so a revealed SVG node lands on the
364
+ // origin. Invisible SVG nodes are therefore revealed in place — opacity forced,
365
+ // transform left alone. See the loop below for why the boundary is
366
+ // `ownerSVGElement` rather than `closest("svg")`.
347
367
  async function forceFinalVisualState(target) {
348
368
  return target.evaluate(() => {
349
369
  const STYLE_ID = "__codeyam_force_final_state";
@@ -356,16 +376,54 @@ async function forceFinalVisualState(target) {
356
376
  if (typeof document.createElement !== "function") return false;
357
377
  const style = document.createElement("style");
358
378
  style.id = STYLE_ID;
379
+ // Temporal properties only — these carry no resting state, so removing
380
+ // them cannot erase anything the app meant to show.
359
381
  style.textContent =
360
382
  "*, *::before, *::after {" +
361
383
  "animation: none !important;" +
362
384
  "transition: none !important;" +
363
- "opacity: 1 !important;" +
364
- "transform: none !important;" +
365
385
  "}";
366
386
  const head = document.head || document.documentElement;
367
387
  if (!head || typeof head.appendChild !== "function") return false;
368
388
  head.appendChild(style);
389
+
390
+ // With animations disabled above, an element held back by an entrance
391
+ // animation now computes to its pre-animation resting state — typically
392
+ // `opacity: 0`, often paired with a translate/scale that parks it offscreen.
393
+ // Reveal exactly those, and only those: a fully transparent element shows
394
+ // nothing either way, so forcing it can hide no real state, while an element
395
+ // at any visible opacity is left untouched.
396
+ //
397
+ // The transform half of the reveal STOPS at the SVG boundary. A CSS
398
+ // `transform` overrides the SVG `transform` presentation attribute, so
399
+ // inside an `<svg>` a forced `transform: none` does not neutralize an
400
+ // entrance animation — it erases the static rotate/translate/scale that
401
+ // CONSTRUCTS the drawing, revealing the node collapsed on the origin. The
402
+ // opacity half is still right there (a node at ~0 shows nothing either
403
+ // way), so an invisible SVG node is revealed IN PLACE. The boundary test is
404
+ // `ownerSVGElement` and NOT `closest("svg")`: HTML inside a
405
+ // `<foreignObject>` is a real HTMLElement with no `ownerSVGElement`, so it
406
+ // keeps the full reveal — `closest("svg")` would silently strand its
407
+ // genuine CSS entrance transform.
408
+ if (typeof document.querySelectorAll !== "function") return true;
409
+ const INVISIBLE_EPSILON = 0.01;
410
+ for (const el of document.querySelectorAll("*")) {
411
+ let computed = null;
412
+ try {
413
+ computed = getComputedStyle(el);
414
+ } catch (_) {
415
+ continue;
416
+ }
417
+ if (!computed) continue;
418
+ const opacity = parseFloat(computed.opacity);
419
+ if (!Number.isFinite(opacity) || opacity > INVISIBLE_EPSILON) continue;
420
+ if (!el.style || typeof el.style.setProperty !== "function") continue;
421
+ const inSvg =
422
+ el.ownerSVGElement != null ||
423
+ (typeof el.tagName === "string" && el.tagName.toLowerCase() === "svg");
424
+ el.style.setProperty("opacity", "1", "important");
425
+ if (!inSvg) el.style.setProperty("transform", "none", "important");
426
+ }
369
427
  return true;
370
428
  });
371
429
  }
@@ -870,6 +928,59 @@ async function collectInteractiveLabels(frame) {
870
928
  }, INTERACTIVE_SELECTOR);
871
929
  }
872
930
 
931
+ // How many candidate labels an error message may echo. Deliberately tighter
932
+ // than `collectInteractiveLabels`' own 20-label read cap: the labels are the
933
+ // USER'S page content, so every one printed is a line of their app leaked into
934
+ // the terminal. Enough to answer "did you mean one of these?", not enough to
935
+ // transcribe the page.
936
+ const CANDIDATE_LABEL_CAP = 8;
937
+
938
+ // Render the candidate labels for an error message, capped and counted. Shared
939
+ // by the no-match and action-failed paths so the echo policy lives in one place.
940
+ async function candidateLabelSummary(frame) {
941
+ const candidates = await collectInteractiveLabels(frame);
942
+ if (candidates.length === 0) return "(none found on page)";
943
+ const shown = candidates.slice(0, CANDIDATE_LABEL_CAP);
944
+ const rest = candidates.length - shown.length;
945
+ return rest > 0 ? `${shown.join(", ")} (+${rest} more)` : shown.join(", ");
946
+ }
947
+
948
+ // Compose the "no element matched" error, branched on the form the caller
949
+ // ACTUALLY passed. Pure and string-returning so both branches are asserted
950
+ // without a browser.
951
+ //
952
+ // The substring advice ("prefer an exact CSS selector") only applies to a text
953
+ // label. Printing it at a caller who already passed `#id` contradicts its own
954
+ // input and reads as misdirection — the failure mode that sent two sessions
955
+ // hunting a selector bug when the page had simply not hydrated. The candidate
956
+ // labels ride with the text branch for the same reason: they answer "did you
957
+ // mean one of these?", a question only a text match can have asked, and every
958
+ // label printed is a line of the user's own page echoed into the terminal.
959
+ function noMatchGuidance({ targetDesc, matchedByText, candidateLabels }) {
960
+ const queryParamHint =
961
+ `Many filter/status/sort states are also reachable directly via a ` +
962
+ `URL query param (e.g. add "?status=active" to the path) with no ` +
963
+ `interaction at all.`;
964
+ if (matchedByText) {
965
+ return (
966
+ `preview-interact: no element matched ${targetDesc}. ` +
967
+ `Text is matched as a case-insensitive SUBSTRING, so a misspelled or ` +
968
+ `over-specific label matches nothing — prefer an exact/role/testid CSS ` +
969
+ `selector (e.g. {"selector":"[data-testid=\\"save\\"]"}) for a precise ` +
970
+ `target. ${queryParamHint} ` +
971
+ `Candidate interactive labels: ${candidateLabels}`
972
+ );
973
+ }
974
+ return (
975
+ `preview-interact: no element matched ${targetDesc}. ` +
976
+ `That is already an exact CSS selector, so nothing on the page matches ` +
977
+ `it as written — confirm the element is present in THIS state (it may be ` +
978
+ `behind a tab, dialog, or conditional render), that the id/attribute is ` +
979
+ `spelled as the markup emits it, and that the page rendered and hydrated ` +
980
+ `at all. ${queryParamHint}`
981
+ );
982
+ }
983
+
873
984
  // Describe the elements a substring text match resolved, so an ambiguity
874
985
  // warning can name the competing controls (e.g. the preset button "Bet" vs the
875
986
  // disclosure button "…or bet on"). Reads each matched element's inner text via
@@ -1017,17 +1128,17 @@ async function performInteraction(
1017
1128
  }
1018
1129
 
1019
1130
  if (matchCount === 0) {
1020
- const candidates = await collectInteractiveLabels(frame);
1021
- const candidateList =
1022
- candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
1131
+ // Candidate labels are collected ONLY for the text branch — see
1132
+ // `noMatchGuidance` for why a CSS-selector miss neither needs them nor
1133
+ // should echo the user's page content back at them.
1023
1134
  throw new Error(
1024
- `preview-interact: no element matched ${targetDesc}. ` +
1025
- `Text is matched as a case-insensitive SUBSTRING, so a misspelled or ` +
1026
- `over-specific label matches nothing — prefer an exact/role/testid CSS ` +
1027
- `selector (e.g. {"selector":"[data-testid=\\"save\\"]"}) for a precise ` +
1028
- `target. Many filter/status/sort states are also reachable directly via a ` +
1029
- `URL query param (e.g. add "?status=active" to the path) with no ` +
1030
- `interaction at all. Candidate interactive labels: ${candidateList}`,
1135
+ noMatchGuidance({
1136
+ targetDesc,
1137
+ matchedByText,
1138
+ candidateLabels: matchedByText
1139
+ ? await candidateLabelSummary(frame)
1140
+ : null,
1141
+ }),
1031
1142
  );
1032
1143
  }
1033
1144
 
@@ -1069,12 +1180,9 @@ async function performInteraction(
1069
1180
  );
1070
1181
  }
1071
1182
  } catch (error) {
1072
- const candidates = await collectInteractiveLabels(frame);
1073
- const candidateList =
1074
- candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
1075
1183
  throw new Error(
1076
1184
  `preview-interact: action "${action}" failed against ${targetDesc}: ${error.message || String(error)}. ` +
1077
- `Candidate interactive labels: ${candidateList}`,
1185
+ `Candidate interactive labels: ${await candidateLabelSummary(frame)}`,
1078
1186
  );
1079
1187
  }
1080
1188
  }
@@ -1151,6 +1259,7 @@ async function performInteractionSequence(
1151
1259
 
1152
1260
  module.exports = {
1153
1261
  logCaptureTiming,
1262
+ defaultReadServerState,
1154
1263
  resolveTcpTarget,
1155
1264
  assertAppPortReachable,
1156
1265
  escapeHtmlAttribute,
@@ -1172,6 +1281,8 @@ module.exports = {
1172
1281
  loadScenarioInIframe,
1173
1282
  loadScenarioTopLevel,
1174
1283
  collectInteractiveLabels,
1284
+ candidateLabelSummary,
1285
+ noMatchGuidance,
1175
1286
  describeMatchCandidates,
1176
1287
  performInteraction,
1177
1288
  waitForPredicate,
package/npm/utils.js CHANGED
@@ -5,7 +5,23 @@ const fs = require("fs");
5
5
  const readline = require("readline");
6
6
 
7
7
  const rootDir = path.resolve(__dirname, "..");
8
- const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
8
+
9
+ /**
10
+ * Path to the server-state file for `projectDir`.
11
+ *
12
+ * The editor server writes its state into the PROJECT it serves, not into the
13
+ * editor repo — so this must be resolved against the target project. It was
14
+ * previously a module-level const bound to `rootDir`, which made
15
+ * `readServerState` return `null` for every external-project launch and left
16
+ * the pid-based recovery in `stopServer` dead in exactly the configuration
17
+ * `codeyam-editor-dev` exists to serve.
18
+ *
19
+ * Defaults to `process.cwd()` because every in-repo caller already runs from
20
+ * the project root; external-project callers must pass their `projectDir`.
21
+ */
22
+ function serverStatePathFor(projectDir = process.cwd()) {
23
+ return path.join(projectDir, ".codeyam", "server-state.json");
24
+ }
9
25
 
10
26
  /**
11
27
  * Map (process.platform, process.arch) → the platform sub-package that
@@ -459,6 +475,56 @@ function resolveWrappedAppPort(merged, envValue, fallback) {
459
475
  return resolveLauncherPort(envValue, mergedValue, fallback);
460
476
  }
461
477
 
478
+ /** True for a value usable as a TCP port: an integer in 1..=65535. */
479
+ function isUsablePort(value) {
480
+ return Number.isInteger(value) && value > 0 && value <= 65535;
481
+ }
482
+
483
+ /**
484
+ * Resolve the control port a RUNNING server for `projectDir` is actually on.
485
+ *
486
+ * Mirrors `resolve_control_port` in
487
+ * `crates/codeyam-editor/src/commands/editor/control_port.rs`:
488
+ * `server-state.json` > `CODEYAM_CONTROL_PORT` > the config default. The
489
+ * editor server runs on a dynamic port whenever the Project Launcher or the
490
+ * desktop supervisor started it, and `editor.json`'s `proxy.controlPort` is
491
+ * only the static default — so `server-state.json` is the only reliable
492
+ * discriminator. The npm launchers used the INVERSE (config only, state file
493
+ * never consulted), which is why a `--restart` could target a port nothing was
494
+ * on while the live server sat on another.
495
+ *
496
+ * This resolves the port to SEARCH for, never the port to bind: the launcher
497
+ * deliberately binds the editor repo's own control port (see the comment at
498
+ * the `SERVER_PORT` resolution in `editor-dev.js`).
499
+ */
500
+ function resolveRunningControlPort(projectDir = process.cwd(), configPort) {
501
+ const state = readServerState(projectDir);
502
+ if (state && isUsablePort(state.controlPort)) return state.controlPort;
503
+
504
+ const envValue = process.env.CODEYAM_CONTROL_PORT;
505
+ if (envValue !== undefined && envValue !== null && String(envValue).trim() !== "") {
506
+ const parsed = parseInt(String(envValue).trim(), 10);
507
+ if (isUsablePort(parsed)) return parsed;
508
+ }
509
+
510
+ return configPort;
511
+ }
512
+
513
+ /**
514
+ * Resolve the proxy HTTP port the server will bind for `projectDir`.
515
+ *
516
+ * Mirrors `resolve_proxy_http_port` in
517
+ * `crates/codeyam-editor/src/commands/start.rs`: `CODEYAM_PROXY_HTTP_PORT` >
518
+ * `proxy.httpPort` > 14100 (`default_http_port` in
519
+ * `crates/types/src/config.rs`). The start path binds this address
520
+ * unconditionally, so it is the port a second server actually collides on —
521
+ * the resource the control-port classification never looked at.
522
+ */
523
+ function resolveProxyHttpPort(merged, envValue, fallback = 14100) {
524
+ const configured = merged && merged.proxy && merged.proxy.httpPort;
525
+ return resolveLauncherPort(envValue, configured, fallback);
526
+ }
527
+
462
528
  /** Try to connect to a specific host:port. Returns true if something is listening. */
463
529
  function tryConnect(port, host) {
464
530
  return new Promise((resolve) => {
@@ -502,17 +568,17 @@ async function waitForPortToClose(port, timeoutMs = 10000, intervalMs = 200) {
502
568
  return false;
503
569
  }
504
570
 
505
- function readServerState() {
571
+ function readServerState(projectDir = process.cwd()) {
506
572
  try {
507
- return JSON.parse(fs.readFileSync(serverStatePath, "utf8"));
573
+ return JSON.parse(fs.readFileSync(serverStatePathFor(projectDir), "utf8"));
508
574
  } catch {
509
575
  return null;
510
576
  }
511
577
  }
512
578
 
513
- function clearServerState() {
579
+ function clearServerState(projectDir = process.cwd()) {
514
580
  try {
515
- fs.unlinkSync(serverStatePath);
581
+ fs.unlinkSync(serverStatePathFor(projectDir));
516
582
  } catch {}
517
583
  }
518
584
 
@@ -626,6 +692,100 @@ function formatCrossProjectError(port, holder, verb) {
626
692
  );
627
693
  }
628
694
 
695
+ /**
696
+ * Build the "the proxy port this server must bind is already taken" error
697
+ * string. Lives next to the other `format*Error` builders so the wording is
698
+ * unit-testable without a running server. Names the port, the pid holding it,
699
+ * and the config key to change — the three things the old failure mode made
700
+ * the user dig out of `.codeyam/logs/editor-server.out.log` by hand.
701
+ */
702
+ function formatProxyPortConflictError(httpPort, pids = []) {
703
+ const pidNote = pids.length > 0 ? ` (pid ${pids.join(", ")})` : "";
704
+ return (
705
+ `Port ${httpPort} is already in use${pidNote}, and the codeyam-editor ` +
706
+ `server binds it at startup — spawning now would fail with EADDRINUSE ` +
707
+ `and leave nothing listening.\n` +
708
+ `\n` +
709
+ `Free that port, or change httpPort in .codeyam/editor.local.json, e.g.:\n` +
710
+ ` codeyam-editor editor config-override proxy.httpPort ${httpPort + 100}`
711
+ );
712
+ }
713
+
714
+ /**
715
+ * Make sure the proxy HTTP port is free before a spawn that would bind it.
716
+ *
717
+ * The proxy port — not the control port — is the resource that actually
718
+ * conflicts: two servers for the same project legitimately resolve DIFFERENT
719
+ * control ports (the npm wrappers use the editor repo's config, the Rust binary
720
+ * uses the project's own), but both bind the project's `proxy.httpPort`. So a
721
+ * control-port classification can report "free" while the spawn is already
722
+ * doomed, which is exactly how a failed restart came to look like a successful
723
+ * one.
724
+ *
725
+ * Ownership is decided on the control port named by the project's own
726
+ * `server-state.json`, never by probing the identity endpoint through the proxy
727
+ * port itself: the proxy forwards to the wrapped app, so a probe there reports
728
+ * whatever the app answers and would misclassify this project's own server as
729
+ * foreign. Teardown likewise goes through the control port, where the shutdown
730
+ * endpoint lives.
731
+ *
732
+ * Returns `{ kind: "free" }`, `{ kind: "cleared" }` (a stale same-project server
733
+ * was stopped), or `{ kind: "conflict", pids }` — the caller owns the policy.
734
+ * Effects are injected so this is testable without a live server.
735
+ */
736
+ async function ensureProxyPortAvailable(
737
+ httpPort,
738
+ projectDir = process.cwd(),
739
+ { classify = classifyPortHolder, stop = stopServer, isInUse = isPortInUse } = {},
740
+ ) {
741
+ if (!isUsablePort(httpPort) || !(await isInUse(httpPort))) {
742
+ return { kind: "free" };
743
+ }
744
+
745
+ const state = readServerState(projectDir);
746
+ const statePort = state && isUsablePort(state.controlPort) ? state.controlPort : null;
747
+ if (statePort !== null) {
748
+ const holder = await classify(statePort, projectDir);
749
+ if (holder.kind === "same-project") {
750
+ console.error(
751
+ `Port ${httpPort} is held by this project's own server on control ` +
752
+ `port ${statePort}; stopping it before starting a fresh one...`,
753
+ );
754
+ await stop(statePort, projectDir);
755
+ if (await waitForPortToClose(httpPort, 5000, 200)) {
756
+ return { kind: "cleared" };
757
+ }
758
+ }
759
+ }
760
+
761
+ return { kind: "conflict", pids: findListeningPids(httpPort) };
762
+ }
763
+
764
+ /**
765
+ * Build the "the launcher is not going to print a URL for a server that never
766
+ * came up" message, including the tail of the server output log.
767
+ *
768
+ * `tail` is the already-read log tail (see `readServerOutputLogTail`) or `null`
769
+ * when there was nothing readable — passed in rather than read here so the
770
+ * wording is testable without a filesystem, matching the other `format*Error`
771
+ * builders. The diagnostic was never missing from that log; it was written to a
772
+ * file nobody was told to read, which is why the tail is inlined into the
773
+ * message rather than merely pointed at.
774
+ */
775
+ function formatLaunchFailureError(port, projectDir, tail = null) {
776
+ const logPath = path.join(projectDir, ".codeyam", "logs", "editor-server.out.log");
777
+ const head =
778
+ `Editor server is not listening on port ${port} — not printing a URL ` +
779
+ `that would refuse the connection.`;
780
+ if (tail === null) {
781
+ return (
782
+ `${head}\n\nNo readable ${logPath}; also check ` +
783
+ `${path.join(projectDir, ".codeyam", "logs", "editor-server.log")}.`
784
+ );
785
+ }
786
+ return `${head}\n\n--- tail of ${logPath} ---\n${tail}\n--- end of log tail ---`;
787
+ }
788
+
629
789
  /**
630
790
  * Build the "server never bound the port" failure message, shown only after
631
791
  * the boot budget AND one automatic retry have both been exhausted. Pure
@@ -833,6 +993,47 @@ function openServerOutputLog(cwd) {
833
993
  }
834
994
  }
835
995
 
996
+ /**
997
+ * Last `lines` non-empty-trailing lines of the launcher-managed server output
998
+ * log for `projectDir`, or `null` when there is nothing readable there.
999
+ *
1000
+ * The diagnostic text for a failed launch was never missing — the exact
1001
+ * `codeyam-editor cannot bind 127.0.0.1:<port>` line sat in this file the whole
1002
+ * time. It was written to a file nobody was told to read, so the launcher
1003
+ * surfaces the tail itself rather than printing a URL and exiting 0.
1004
+ *
1005
+ * Reads at most the trailing 64 KiB so a long-lived append-only log never costs
1006
+ * a full file read. Best-effort by design: a missing or unreadable log degrades
1007
+ * to `null`, never to a throw on the failure path.
1008
+ */
1009
+ function readServerOutputLogTail(projectDir = process.cwd(), lines = 40) {
1010
+ const MAX_TAIL_BYTES = 64 * 1024;
1011
+ let fd = null;
1012
+ try {
1013
+ const logPath = path.join(projectDir, ".codeyam", "logs", "editor-server.out.log");
1014
+ fd = fs.openSync(logPath, "r");
1015
+ const size = fs.fstatSync(fd).size;
1016
+ const length = Math.min(size, MAX_TAIL_BYTES);
1017
+ if (length === 0) return null;
1018
+ const buf = Buffer.alloc(length);
1019
+ fs.readSync(fd, buf, 0, length, size - length);
1020
+ const all = buf.toString("utf8").split("\n");
1021
+ while (all.length > 0 && all[all.length - 1].trim() === "") all.pop();
1022
+ if (all.length === 0) return null;
1023
+ return all.slice(-lines).join("\n");
1024
+ } catch {
1025
+ return null;
1026
+ } finally {
1027
+ if (fd !== null) {
1028
+ try {
1029
+ fs.closeSync(fd);
1030
+ } catch {
1031
+ /* best-effort close on a diagnostic-only read */
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+
836
1037
  /**
837
1038
  * Format one launcher-teardown-signal record for
838
1039
  * `.codeyam/logs/launcher-signals.log`. Pure: every input is passed in, so the
@@ -965,23 +1166,23 @@ function findListeningPids(port, runLsof = defaultRunLsof, sleep = sleepSync) {
965
1166
  }
966
1167
 
967
1168
  /** Stop the Rust backend server if it is running. */
968
- async function stopServer(port = 14199) {
1169
+ async function stopServer(port = 14199, projectDir = process.cwd()) {
969
1170
  if (!(await isPortInUse(port))) {
970
- clearServerState();
1171
+ clearServerState(projectDir);
971
1172
  return false;
972
1173
  }
973
1174
 
974
1175
  if (await requestServerShutdown(port)) {
975
1176
  if (await waitForPortToClose(port, 10000, 200)) {
976
- clearServerState();
1177
+ clearServerState(projectDir);
977
1178
  return true;
978
1179
  }
979
1180
  }
980
1181
 
981
- const state = readServerState();
1182
+ const state = readServerState(projectDir);
982
1183
  if (state && Number.isInteger(state.pid) && tryKillPid(state.pid)) {
983
1184
  if (await waitForPortToClose(port, 5000, 200)) {
984
- clearServerState();
1185
+ clearServerState(projectDir);
985
1186
  return true;
986
1187
  }
987
1188
  }
@@ -991,20 +1192,37 @@ async function stopServer(port = 14199) {
991
1192
  }
992
1193
 
993
1194
  if (await waitForPortToClose(port, 5000, 200)) {
994
- clearServerState();
1195
+ clearServerState(projectDir);
995
1196
  return true;
996
1197
  }
997
1198
 
998
1199
  throw new Error(`Server on port ${port} did not stop cleanly`);
999
1200
  }
1000
1201
 
1001
- /** Stop any existing server on the port, then start a fresh one. */
1002
- async function restartServer(port = 14199) {
1003
- if (await isPortInUse(port)) {
1004
- console.error(`Stopping existing server on port ${port}...`);
1005
- await stopServer(port);
1202
+ /**
1203
+ * Stop any existing server for this project, then start a fresh one on `port`.
1204
+ *
1205
+ * `port` is the port to BIND (owned by the editor repo's merged config); the
1206
+ * port to TEAR DOWN is resolved separately via `resolveRunningControlPort`,
1207
+ * because a server started outside the npm wrapper records a different, live
1208
+ * control port in the project's `server-state.json`. Gating teardown on `port`
1209
+ * alone is what let a stale server survive a restart and kill the relaunch with
1210
+ * EADDRINUSE on the proxy port. Both are torn down (deduped) so the restart's
1211
+ * historical guarantee — nothing left on the port we are about to bind — holds
1212
+ * whichever port the live server turned out to be on.
1213
+ */
1214
+ async function restartServer(port = 14199, projectDir = process.cwd()) {
1215
+ const runningPort = resolveRunningControlPort(projectDir, port);
1216
+ const seen = new Set();
1217
+ for (const candidate of [runningPort, port]) {
1218
+ if (!isUsablePort(candidate) || seen.has(candidate)) continue;
1219
+ seen.add(candidate);
1220
+ if (await isPortInUse(candidate)) {
1221
+ console.error(`Stopping existing server on port ${candidate}...`);
1222
+ await stopServer(candidate, projectDir);
1223
+ }
1006
1224
  }
1007
- return ensureServer(port);
1225
+ return ensureServer(port, projectDir);
1008
1226
  }
1009
1227
 
1010
1228
  /**
@@ -1016,8 +1234,8 @@ async function restartServer(port = 14199) {
1016
1234
  * mismatch — silently choosing a different port would just relocate the
1017
1235
  * surprise.
1018
1236
  */
1019
- async function ensureServer(port = 14199) {
1020
- const holder = await classifyPortHolder(port);
1237
+ async function ensureServer(port = 14199, projectDir = process.cwd()) {
1238
+ const holder = await classifyPortHolder(port, projectDir);
1021
1239
  switch (holder.kind) {
1022
1240
  case "foreign":
1023
1241
  console.error(formatForeignListenerError(port));
@@ -1056,6 +1274,21 @@ async function ensureServer(port = 14199) {
1056
1274
  break;
1057
1275
  }
1058
1276
 
1277
+ // The control port is free (or was just taken over), but that is not the port
1278
+ // the spawn is most likely to die on. `start.rs` binds `proxy.httpPort`
1279
+ // unconditionally, so a holder there is a guaranteed EADDRINUSE — knowable
1280
+ // with one probe here, and previously discovered only by reading a log after
1281
+ // a silent failure.
1282
+ const proxyHttpPort = resolveProxyHttpPort(
1283
+ readMergedEditorConfig(projectDir),
1284
+ process.env.CODEYAM_PROXY_HTTP_PORT,
1285
+ );
1286
+ const proxyPort = await ensureProxyPortAvailable(proxyHttpPort, projectDir);
1287
+ if (proxyPort.kind === "conflict") {
1288
+ console.error(formatProxyPortConflictError(proxyHttpPort, proxyPort.pids));
1289
+ process.exit(1);
1290
+ }
1291
+
1059
1292
  const binary = ensureBinary();
1060
1293
  const startTimeoutMs = resolveStartTimeoutMs();
1061
1294
 
@@ -1075,12 +1308,12 @@ async function ensureServer(port = 14199) {
1075
1308
  // editor-server.log). `unref()` keeps the detached child from pinning the
1076
1309
  // launcher's event loop alive on its own.
1077
1310
  const spawnAndAwait = async (timeoutMs) => {
1078
- const logFd = openServerOutputLog(process.cwd());
1311
+ const logFd = openServerOutputLog(projectDir);
1079
1312
  const child = spawn(
1080
1313
  binary,
1081
1314
  ["start", "--no-open", "--port", String(port)],
1082
1315
  buildServerSpawnOptions({
1083
- cwd: process.cwd(),
1316
+ cwd: projectDir,
1084
1317
  env: serverSpawnEnv(process.env, { devCheckout: isDevCheckout() }),
1085
1318
  logFd,
1086
1319
  }),
@@ -1215,7 +1448,7 @@ async function awaitServerReadyOrChildExit(child, port, waitForPortFn = waitForP
1215
1448
 
1216
1449
  module.exports = {
1217
1450
  rootDir,
1218
- serverStatePath,
1451
+ serverStatePathFor,
1219
1452
  binaryName,
1220
1453
  brokerBinaryName,
1221
1454
  platformPackageInfo,
@@ -1241,6 +1474,9 @@ module.exports = {
1241
1474
  classifyPortHolder,
1242
1475
  formatForeignListenerError,
1243
1476
  formatCrossProjectError,
1477
+ formatProxyPortConflictError,
1478
+ formatLaunchFailureError,
1479
+ ensureProxyPortAvailable,
1244
1480
  formatServerStartTimeoutError,
1245
1481
  resolveStartTimeoutMs,
1246
1482
  tryKillPid,
@@ -1264,4 +1500,8 @@ module.exports = {
1264
1500
  readMergedEditorConfig,
1265
1501
  resolveLauncherPort,
1266
1502
  resolveWrappedAppPort,
1503
+ resolveRunningControlPort,
1504
+ resolveProxyHttpPort,
1505
+ isUsablePort,
1506
+ readServerOutputLogTail,
1267
1507
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codeyam-editor/codeyam-editor",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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.7",
13
- "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.7",
14
- "@codeyam-editor/codeyam-editor-linux-x64": "0.1.7",
15
- "@codeyam-editor/codeyam-editor-win32-x64": "0.1.7"
12
+ "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.9",
13
+ "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.9",
14
+ "@codeyam-editor/codeyam-editor-linux-x64": "0.1.9",
15
+ "@codeyam-editor/codeyam-editor-win32-x64": "0.1.9"
16
16
  },
17
17
  "keywords": [
18
18
  "codeyam",