@codeyam-editor/codeyam-editor 0.1.1 → 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 (46) 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 +22 -3
  5. package/npm/editor-dev.js +286 -36
  6. package/npm/editor.js +98 -35
  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 +298 -14
  21. package/npm/scenario-handlers.js +16 -5
  22. package/npm/scenario-interactivity.js +199 -0
  23. package/npm/scenario-metrics.js +16 -2
  24. package/npm/scenario-mocks.js +62 -25
  25. package/npm/scenario-playwright.js +169 -6
  26. package/npm/utils.js +507 -21
  27. package/npm/vm-urls.js +117 -0
  28. package/package.json +8 -7
  29. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  30. package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
  31. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  32. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  33. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
  34. package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
  35. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
  36. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  37. package/ui/dist/assets/index-BUYNFqKK.js +27 -0
  38. package/ui/dist/assets/index-DVr5rY2d.css +1 -0
  39. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  40. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  41. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  42. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  43. package/ui/dist/favicon.ico +0 -0
  44. package/ui/dist/favicon.png +0 -0
  45. package/ui/dist/index.html +16 -0
  46. package/npm/postinstall.js +0 -132
package/npm/container.js CHANGED
@@ -8,12 +8,31 @@ const path = require("path");
8
8
  * inline monitoring UI at http://localhost:14199.
9
9
  */
10
10
 
11
- const { ensureServer, rootDir, killChildProcess } = require("./utils");
11
+ const {
12
+ ensureServer,
13
+ rootDir,
14
+ killChildProcess,
15
+ readMergedEditorConfig,
16
+ resolveLauncherPort,
17
+ } = require("./utils");
12
18
  const { open } = require("./open");
13
19
 
14
- const PORT = 14199;
15
-
20
+ /**
21
+ * npm wrapper entry point for the container binary: ensures the Rust
22
+ * editor server is running and opens the container monitoring UI in
23
+ * a browser.
24
+ */
16
25
  async function main() {
26
+ // Same resolution order as the editor launcher: env var
27
+ // (CODEYAM_EDITOR_PORT) → merged editor.json + editor.local.json
28
+ // (proxy.controlPort) → hard fallback (14199).
29
+ const merged = readMergedEditorConfig(rootDir);
30
+ const PORT = resolveLauncherPort(
31
+ process.env.CODEYAM_EDITOR_PORT,
32
+ merged.proxy && merged.proxy.controlPort,
33
+ 14199,
34
+ );
35
+
17
36
  const serverChild = await ensureServer(PORT);
18
37
  const url = `http://localhost:${PORT}`;
19
38
  console.error(`Container UI: ${url}`);
package/npm/editor-dev.js CHANGED
@@ -14,17 +14,48 @@
14
14
  * Rust binary, then forwards args to it.
15
15
  * codeyam-editor-dev editor step 1
16
16
  * codeyam-editor-dev editor advance
17
+ *
18
+ * The wrapper is symlinked twice by scripts/bootstrap.sh:
19
+ * codeyam-editor-dev → this file (preserves the -dev branding;
20
+ * scaffolded content emits "codeyam-editor-dev").
21
+ * codeyam-editor → this file (shadows the canonical name on PATH
22
+ * so hooks and skills that emit `codeyam-editor` still get the
23
+ * auto-rebuild behavior on local dev; scaffolded content emits the
24
+ * canonical "codeyam-editor").
25
+ * The invocation name is read from `process.argv[1]` to decide which
26
+ * branding the Rust binary should use via `CODEYAM_CLI`.
17
27
  */
18
28
 
19
- const { execSync, spawn } = require("child_process");
29
+ const { execSync, spawn, spawnSync } = require("child_process");
20
30
  const path = require("path");
21
- const fs = require("fs");
22
- const { ensureServer, restartServer, waitForPort, ensureBinary, uiDistDir, rootDir, killChildProcess } = require("./utils");
31
+ const {
32
+ ensureServer,
33
+ restartServer,
34
+ waitForPort,
35
+ ensureBinary,
36
+ isSelfHostTarget,
37
+ rootDir,
38
+ killChildProcess,
39
+ readMergedEditorConfig,
40
+ resolveLauncherPort,
41
+ staleBinaryBanner,
42
+ enforceFreshBinary,
43
+ } = require("./utils");
23
44
  const { open } = require("./open");
45
+ const { classifyProject } = require("./preflight-guidance");
46
+ const { isEmptyProjectDir } = require("./empty-project-dir");
24
47
 
25
- // Tell the Rust binary to emit "codeyam-editor-dev" in all instructions,
26
- // permissions, and installed files instead of "codeyam-editor".
27
- process.env.CODEYAM_CLI = "codeyam-editor-dev";
48
+ // When invoked through the `codeyam-editor-dev` symlink, tell the Rust
49
+ // binary to emit "codeyam-editor-dev" in all scaffolded commands,
50
+ // permissions, and installed files. When invoked through the
51
+ // `codeyam-editor` symlink, leave CODEYAM_CLI unset so the binary
52
+ // emits the canonical "codeyam-editor" — matching the cloud install
53
+ // path and the new hook/skill convention. The runtime cost of the
54
+ // rebuild-on-passthrough behavior is identical for both names.
55
+ const invokedAs = path.basename(process.argv[1] || "");
56
+ if (invokedAs === "codeyam-editor-dev") {
57
+ process.env.CODEYAM_CLI = "codeyam-editor-dev";
58
+ }
28
59
 
29
60
  const args = process.argv.slice(2);
30
61
  const KNOWN_SUBCOMMANDS = ["start", "init", "editor"];
@@ -42,78 +73,297 @@ function hasSubcommand(argv = args) {
42
73
  return false;
43
74
  }
44
75
 
45
- /** Build the Rust binary from the editor repo. */
76
+ /**
77
+ * Build the Rust binary from the editor repo. Returns true on success,
78
+ * false if `cargo build` failed. This function only reports the outcome —
79
+ * the caller decides whether a failed build is fatal (see staleBinaryBanner).
80
+ */
46
81
  function buildBinary() {
47
82
  console.error("Building Rust binary...");
48
83
  try {
49
84
  execSync("cargo build", { stdio: "inherit", cwd: rootDir });
85
+ return true;
50
86
  } catch {
51
- console.error("Failed to build Rust binary. Continuing with existing build (if any)...");
87
+ return false;
52
88
  }
53
89
  }
54
90
 
55
- module.exports = { hasSubcommand, buildBinary };
91
+ /**
92
+ * Decide whether the dev wrapper should rebuild the Rust binary (and the UI,
93
+ * for the launcher path) before forwarding to it. Pure: takes injected cwd /
94
+ * repoRoot / env so the four-way decision is testable without spawning cargo
95
+ * or reading process.env.
96
+ *
97
+ * The wrapper rebuilds in two cases:
98
+ * - The target IS the editor repo itself (developing the editor on its
99
+ * own checkout). This is the canonical self-host case.
100
+ * - `CODEYAM_DEV_REBUILD=1` is explicitly set (the documented opt-in for
101
+ * "editing the editor source while running it against an external client
102
+ * project", named in editor-dev.js's earlier history).
103
+ *
104
+ * Otherwise — the common case where a developer uses the editor as a tool
105
+ * against their own app — the wrapper skips the rebuild entirely. The
106
+ * existing binary runs unchanged, and the server-side stale-binary watcher
107
+ * (also gated on self-host) never SIGUSR1's. The combined effect is what the
108
+ * plan calls out: an external-project session is never disrupted by a bare
109
+ * commit, a fleet rebase, or any other event that would have relinked the
110
+ * binary without changing what runs.
111
+ */
112
+ function shouldRebuildOnPassthrough({ cwd, repoRoot, env }) {
113
+ if (env.CODEYAM_DEV_REBUILD === "1") return true;
114
+ return isSelfHostTarget(cwd, repoRoot);
115
+ }
116
+
117
+ /**
118
+ * Returns true when the merged project config has a non-empty start
119
+ * command — either at `apps[0].startCommand` (per-app, preferred) or at
120
+ * the top-level `startCommand` (legacy default). Mirrors the Rust
121
+ * `resolve_start_command` precedence in
122
+ * `crates/codeyam-editor/src/commands/start.rs`.
123
+ *
124
+ * When neither slot has a non-empty string, the editor server runs in
125
+ * "editor UI only" mode and the launcher must skip the dev-server port
126
+ * wait — otherwise the user stares at "Waiting for dev server on port
127
+ * N..." for the full 15s timeout.
128
+ */
129
+ function hasStartCommand(mergedConfig) {
130
+ if (mergedConfig == null || typeof mergedConfig !== "object") return false;
131
+ const apps = Array.isArray(mergedConfig.apps) ? mergedConfig.apps : null;
132
+ if (apps && apps.length > 0) {
133
+ const cmd = apps[0] && apps[0].startCommand;
134
+ if (typeof cmd === "string" && cmd.trim() !== "") return true;
135
+ }
136
+ const topLevel = mergedConfig.startCommand;
137
+ return typeof topLevel === "string" && topLevel.trim() !== "";
138
+ }
139
+
140
+ /**
141
+ * Decide what the launcher should do given a `classifyProject` result.
142
+ * Pure — no fs, no spawn, no process.exit — so `launcherMain`'s branching
143
+ * is unit-testable without building a binary or spawning a server (the same
144
+ * policy/classification split as `classifyPortHolder` in utils.js).
145
+ *
146
+ * modern → { action: "proceed" } launch as-is
147
+ * none → { action: "init" } greenfield: init, then launch
148
+ * legacy → { action: "bail", guidance, code: 2 } print guidance and exit
149
+ *
150
+ * Greenfield auto-inits because the dev launcher is a one-shot "build
151
+ * everything, run the editor against this project" tool — running it in a
152
+ * fresh dir should just work. Legacy bails instead: `cmd_init`'s legacy
153
+ * branch deliberately skips `editor.json` creation so `/codeyam-onboard`
154
+ * can migrate from the preserved classification, so auto-running init would
155
+ * strand them. The Rust-side `start_preflight` stays the safety net for
156
+ * direct `codeyam-editor start` calls; this launcher composes init + start.
157
+ */
158
+ function planPreflightAction(classification) {
159
+ if (classification.kind === "legacy") {
160
+ return { action: "bail", guidance: classification.guidance, code: 2 };
161
+ }
162
+ if (classification.kind === "none") {
163
+ return { action: "init" };
164
+ }
165
+ return { action: "proceed" };
166
+ }
167
+
168
+ /**
169
+ * Pick the launcher's post-launch tail banner — printed right next to the
170
+ * `Editor UI: http://…` line where the user is actually looking, after the
171
+ * editor server is up. Pure (no fs, no spawn) so the three-arm decision is
172
+ * unit-testable without spawning a binary, mirroring `planPreflightAction`.
173
+ *
174
+ * Skill assumption: /codeyam-editor scaffolds a project from scratch;
175
+ * /codeyam-onboard migrates an existing source tree. Empty greenfield
176
+ * (no non-hidden entries before init) must route to the scaffold path;
177
+ * existing greenfield routes to the migration path. Mirrors the same
178
+ * split in `cmd_init` (init.rs:10-24) — keep both in sync. Do NOT
179
+ * "simplify" by collapsing the branches; the skill each path names is the
180
+ * whole point.
181
+ *
182
+ * action !== "init" → null (modern: proceed, no banner;
183
+ * legacy already bailed earlier)
184
+ * action "init", wasEmpty → /codeyam-editor scaffold-from-scratch
185
+ * action "init", !wasEmpty → /codeyam-onboard migrate-existing
186
+ */
187
+ function postLaunchInitBanner({ preflightAction, wasEmpty }) {
188
+ if (preflightAction !== "init") return null;
189
+ if (wasEmpty) {
190
+ return (
191
+ "\nInitialized .codeyam/ in this directory. Open Claude Code here and " +
192
+ "run `/codeyam-editor` to scaffold a new project from scratch."
193
+ );
194
+ }
195
+ return (
196
+ "\nInitialized .codeyam/ in this directory. To populate scenarios, " +
197
+ "open Claude Code here and run `/codeyam-onboard`."
198
+ );
199
+ }
200
+
201
+ module.exports = {
202
+ hasSubcommand,
203
+ buildBinary,
204
+ shouldRebuildOnPassthrough,
205
+ staleBinaryBanner,
206
+ hasStartCommand,
207
+ planPreflightAction,
208
+ postLaunchInitBanner,
209
+ };
56
210
 
57
211
  if (require.main === module) {
58
212
  // --- Passthrough mode: build binary, then forward to it ---
59
213
  if (hasSubcommand()) {
60
- buildBinary();
214
+ // Only rebuild when the target IS the editor repo itself, or when the
215
+ // developer has explicitly opted into the "edit editor against external
216
+ // project" workflow via CODEYAM_DEV_REBUILD=1. On any other project the
217
+ // editor binary is a fixed tool and a `cargo build` here ripples into a
218
+ // stale-binary SIGUSR1 re-exec that resets the user's session for no
219
+ // reason. `ensureBinary()` still builds once if no binary exists at all.
220
+ if (
221
+ shouldRebuildOnPassthrough({
222
+ cwd: process.cwd(),
223
+ repoRoot: rootDir,
224
+ env: process.env,
225
+ })
226
+ ) {
227
+ enforceFreshBinary(buildBinary(), { action: "run editor commands" });
228
+ }
61
229
  const binary = ensureBinary();
62
230
  const child = spawn(binary, args, {
63
231
  stdio: "inherit",
64
232
  cwd: process.cwd(),
65
- env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() },
233
+ env: process.env,
66
234
  });
67
235
  child.on("exit", (code) => {
68
236
  process.exit(code ?? 1);
69
237
  });
70
238
  } else {
71
239
  // --- Launcher mode: build everything, start server, open browser ---
72
- const SERVER_PORT = 14199;
73
240
  const shouldRestart = args.includes("--restart");
74
241
 
75
- const readProjectPort = () => {
76
- try {
77
- const configPath = path.join(process.cwd(), ".codeyam", "editor.json");
78
- const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
79
- return config.port || 3000;
80
- } catch {
81
- return 3000;
82
- }
83
- };
84
-
85
242
  const launcherMain = async () => {
86
243
  const projectDir = process.cwd();
87
244
  console.error(`Editor repo: ${rootDir}`);
88
245
  console.error(`Target project: ${projectDir}`);
89
246
 
90
- buildBinary();
247
+ // Greenfield projects are auto-initialized here so the dev launcher
248
+ // stays a one-shot "build everything, run the editor against this
249
+ // project" tool. Legacy projects bail with guidance; modern projects
250
+ // skip init. See `planPreflightAction` for the rationale on each
251
+ // branch. Subcommand pass-through (handled above) is unaffected — the
252
+ // binary classifies its own subcommands.
253
+ const preflight = planPreflightAction(classifyProject(projectDir));
254
+ // Capture emptiness BEFORE init runs — the spawn below creates
255
+ // .codeyam/ (and install_editor_files writes .claude/ etc.), which
256
+ // would make every project look non-empty by the time the
257
+ // post-launch banner prints. Same constraint as the Rust comment in
258
+ // `init_greenfield_branch` (init.rs:82-85). Computed as a sibling
259
+ // local (not folded into `preflight`) because emptiness is
260
+ // orthogonal to the preflight kind.
261
+ const wasEmpty = isEmptyProjectDir(projectDir);
262
+ if (preflight.action === "bail") {
263
+ console.error(preflight.guidance);
264
+ process.exit(preflight.code);
265
+ }
266
+ if (preflight.action === "init") {
267
+ // Run init from projectDir before the config reads below so the
268
+ // freshly-written .codeyam/editor.json feeds port resolution.
269
+ // ensureBinary builds the binary first if this is a fresh checkout.
270
+ console.error(`Initializing codeyam-editor in ${projectDir}...`);
271
+ const initBinary = ensureBinary();
272
+ const initResult = spawnSync(initBinary, ["init"], {
273
+ cwd: projectDir,
274
+ stdio: "inherit",
275
+ env: process.env,
276
+ });
277
+ if (initResult.status !== 0) {
278
+ console.error(
279
+ `codeyam-editor init exited ${initResult.status ?? "by signal"}; aborting launch.`,
280
+ );
281
+ process.exit(1);
282
+ }
283
+ }
284
+
285
+ // Editor server's control port is owned by the editor repo's
286
+ // merged config (rootDir), not the target project's. The target
287
+ // project's own `editor.local.json` only contributes to the
288
+ // app/dev-server port below.
289
+ const editorMerged = readMergedEditorConfig(rootDir);
290
+ const SERVER_PORT = resolveLauncherPort(
291
+ process.env.CODEYAM_EDITOR_PORT,
292
+ editorMerged.proxy && editorMerged.proxy.controlPort,
293
+ 14199,
294
+ );
295
+
296
+ // Target project's dev-server port comes from its own
297
+ // editor.json + editor.local.json (developers iterating on the
298
+ // editor against a real client project rely on the override
299
+ // file here too).
300
+ const projectMerged = readMergedEditorConfig(projectDir);
301
+ const appPort = resolveLauncherPort(undefined, projectMerged.port, 3000);
91
302
 
92
- // Build the UI from the editor repo
93
- console.error("Building UI...");
94
- try {
95
- execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
96
- } catch {
97
- console.error("Failed to build UI. Continuing with existing build (if any)...");
303
+ // Same gate as the passthrough path: only rebuild the Rust binary +
304
+ // UI when the launcher target IS the editor repo itself, or when
305
+ // CODEYAM_DEV_REBUILD=1 is explicitly set. Launching the editor
306
+ // against an external project must not relink the binary — the
307
+ // server-side stale-binary watcher would otherwise SIGUSR1-restart
308
+ // the freshly-launched server immediately. `ensureBinary()` still
309
+ // builds once if no binary exists.
310
+ const shouldRebuildLaunch = shouldRebuildOnPassthrough({
311
+ cwd: projectDir,
312
+ repoRoot: rootDir,
313
+ env: process.env,
314
+ });
315
+ if (shouldRebuildLaunch) {
316
+ enforceFreshBinary(buildBinary(), {
317
+ action: "run editor commands",
318
+ continueVerb: "launching with",
319
+ });
320
+
321
+ // Build the UI from the editor repo
322
+ console.error("Building UI...");
323
+ try {
324
+ execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
325
+ } catch {
326
+ console.error("Failed to build UI. Continuing with existing build (if any)...");
327
+ }
98
328
  }
99
329
 
100
330
  const serverChild = shouldRestart
101
331
  ? await restartServer(SERVER_PORT)
102
332
  : await ensureServer(SERVER_PORT);
103
333
 
104
- const appPort = readProjectPort();
105
- const viteInternalPort = appPort + 1;
106
- console.error(`Waiting for dev server on port ${viteInternalPort}...`);
107
- const viteReady = await waitForPort(viteInternalPort, 15000, 300);
108
- if (!viteReady) {
109
- console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`);
334
+ // Re-read the (possibly just-initialized) project config to decide
335
+ // whether to wait on a dev server at all. On a project where init
336
+ // couldn't detect a framework (or a user-configured "editor UI
337
+ // only" setup), `startCommand` is empty — the editor server itself
338
+ // prints "No startCommand configured. Running editor UI only." and
339
+ // never spawns a dev server, so waiting 15s for `appPort + 1` is a
340
+ // pure UX hang. The launcher silently skips the wait; the server's
341
+ // own log line is the canonical signal to the user.
342
+ const postInitMerged = readMergedEditorConfig(projectDir);
343
+ const hasCommand = hasStartCommand(postInitMerged);
344
+ if (hasCommand) {
345
+ const viteInternalPort = appPort + 1;
346
+ console.error(`Waiting for dev server on port ${viteInternalPort}...`);
347
+ const viteReady = await waitForPort(viteInternalPort, 15000, 300);
348
+ if (!viteReady) {
349
+ console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`);
350
+ }
110
351
  }
111
352
 
112
353
  const url = `http://localhost:${SERVER_PORT}`;
113
354
  console.error(`Editor UI: ${url}`);
114
- console.error(`Live Preview: http://localhost:${appPort}`);
355
+ if (hasCommand) {
356
+ console.error(`Live Preview: http://localhost:${appPort}`);
357
+ }
115
358
  console.error(`Project: ${projectDir}`);
116
359
  open(url);
360
+ const banner = postLaunchInitBanner({
361
+ preflightAction: preflight.action,
362
+ wasEmpty,
363
+ });
364
+ if (banner) {
365
+ console.error(banner);
366
+ }
117
367
  const keepAlive = setInterval(() => {}, 60_000);
118
368
 
119
369
  process.on("SIGINT", () => {
package/npm/editor.js CHANGED
@@ -16,14 +16,23 @@ const { execSync } = require("child_process");
16
16
  const fs = require("fs");
17
17
  const os = require("os");
18
18
  const path = require("path");
19
- const { ensureServer, stopServer, isPortInUse, waitForPort, rootDir, killChildProcess } = require("./utils");
19
+ const {
20
+ ensureServer,
21
+ stopServer,
22
+ waitForPort,
23
+ rootDir,
24
+ killChildProcess,
25
+ readMergedEditorConfig,
26
+ resolveLauncherPort,
27
+ resolveWrappedAppPort,
28
+ classifyPortHolder,
29
+ formatForeignListenerError,
30
+ formatCrossProjectError,
31
+ staleBinaryBanner,
32
+ enforceFreshBinary,
33
+ } = require("./utils");
20
34
  const { open } = require("./open");
21
35
 
22
- const SERVER_PORT = parseInt(process.env.CODEYAM_EDITOR_PORT || "14199", 10);
23
- const VITE_PORT = parseInt(process.env.PORT || "5173", 10);
24
- // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
25
- // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
26
- const VITE_INTERNAL_PORT = VITE_PORT + 1;
27
36
  const shouldRestart = process.argv.includes("--restart");
28
37
 
29
38
  function buildBinary() {
@@ -32,7 +41,6 @@ function buildBinary() {
32
41
  execSync("cargo build", { stdio: "inherit", cwd: rootDir });
33
42
  return true;
34
43
  } catch {
35
- console.error("Failed to build Rust binary. Continuing with existing build (if any)...");
36
44
  return false;
37
45
  }
38
46
  }
@@ -47,6 +55,33 @@ function cargoBinDir() {
47
55
  return path.join(os.homedir(), ".cargo", "bin");
48
56
  }
49
57
 
58
+ // Place a hardlink at `dstPath` pointing at the same inode as `srcPath`,
59
+ // replacing any existing file at `dstPath`. On EXDEV (cross-filesystem,
60
+ // rare; target/ on a separate mount from the cargo bin dir) falls back
61
+ // to an atomic copy-and-rename via `tmpPath` so we still succeed — at
62
+ // the cost of distinct inodes, which means the dev binary's
63
+ // `stale_cargo_bin_message` check will fire on every boot.
64
+ //
65
+ // Hardlink-by-default is what keeps the stale-binary advisory silent on
66
+ // `npm run editor`: the freshly-installed binary at <cargo_bin> shares
67
+ // an inode with target/debug/codeyam-editor, so the inode-identity
68
+ // check returns "same underlying file" and nothing is printed.
69
+ function linkOrCopyBinary(srcPath, dstPath, tmpPath) {
70
+ try {
71
+ fs.unlinkSync(dstPath);
72
+ } catch (e) {
73
+ if (e.code !== "ENOENT") throw e;
74
+ }
75
+ try {
76
+ fs.linkSync(srcPath, dstPath);
77
+ } catch (err) {
78
+ if (err.code !== "EXDEV") throw err;
79
+ fs.copyFileSync(srcPath, tmpPath);
80
+ fs.chmodSync(tmpPath, 0o755);
81
+ fs.renameSync(tmpPath, dstPath);
82
+ }
83
+ }
84
+
50
85
  // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor
51
86
  // atomically (write to tmp in same dir, then rename), and refresh the
52
87
  // codeyam-editor-pty-broker hardlink so it points at the new inode.
@@ -71,9 +106,7 @@ function installBinary() {
71
106
  console.error(`Installing fresh binary to ${dstPath}...`);
72
107
  try {
73
108
  fs.mkdirSync(binDir, { recursive: true });
74
- fs.copyFileSync(srcPath, tmpPath);
75
- fs.chmodSync(tmpPath, 0o755);
76
- fs.renameSync(tmpPath, dstPath);
109
+ linkOrCopyBinary(srcPath, dstPath, tmpPath);
77
110
  try {
78
111
  fs.unlinkSync(linkPath);
79
112
  } catch (e) {
@@ -98,32 +131,44 @@ function sweepStaleArtifacts() {
98
131
  }
99
132
  }
100
133
 
134
+ /**
135
+ * npm wrapper entry point for the editor binary: resolves ports from
136
+ * the merged editor config, ensures the Rust backend is running, and
137
+ * opens the editor UI.
138
+ */
101
139
  async function main() {
102
- // Sync ports to editor.json so the Rust backend uses them
103
- const editorJsonPath = path.join(rootDir, ".codeyam", "editor.json");
104
- if (fs.existsSync(editorJsonPath)) {
105
- const editorJson = JSON.parse(fs.readFileSync(editorJsonPath, "utf8"));
106
- editorJson.port = VITE_PORT;
107
- editorJson.startCommand = `cd ui && npx vite --port ${VITE_PORT}`;
108
-
109
- const uiApp = editorJson.apps?.find(a => a.dir === "ui");
110
- if (uiApp) {
111
- uiApp.port = VITE_PORT;
112
- uiApp.startCommand = `npx vite --port ${VITE_PORT}`;
113
- }
114
-
115
- if (editorJson.proxy) {
116
- editorJson.proxy.controlPort = SERVER_PORT;
117
- editorJson.proxy.httpPort = SERVER_PORT - 99;
118
- editorJson.proxy.dbPort = SERVER_PORT - 98;
119
- }
120
- fs.writeFileSync(editorJsonPath, JSON.stringify(editorJson, null, 2));
121
- }
140
+ // Read ports from the merged editor config (editor.json + per-developer
141
+ // editor.local.json). The Rust backend reads the same merged config on
142
+ // its own — we resolve here so the launcher's log lines, `waitForPort`
143
+ // calls, and `--port` arg agree with what the binary will actually
144
+ // bind. Env vars still win for scripted/CI invocations
145
+ // (CODEYAM_EDITOR_PORT, PORT), then the merged config, then a hard
146
+ // fallback for fresh checkouts with no override file.
147
+ const merged = readMergedEditorConfig(rootDir);
148
+ const SERVER_PORT = resolveLauncherPort(
149
+ process.env.CODEYAM_EDITOR_PORT,
150
+ merged.proxy && merged.proxy.controlPort,
151
+ 14199,
152
+ );
153
+ // `resolveWrappedAppPort` mirrors `resolve_app_port` in
154
+ // `crates/codeyam-editor/src/commands/start.rs`: `env > apps[0].port >
155
+ // config.port`. Without this the launcher's log line and
156
+ // `waitForPort` probe targeted one port while the backend bound
157
+ // another, producing the misleading "Vite dev server not ready on
158
+ // port N" warning whenever apps[0] declared its own port.
159
+ const VITE_PORT = resolveWrappedAppPort(merged, process.env.PORT, 5173);
160
+ // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
161
+ // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
162
+ const VITE_INTERNAL_PORT = VITE_PORT + 1;
122
163
 
123
164
  // Rebuild the Rust binary so code changes are picked up on restart.
124
165
  // This script is only invoked from a source checkout (via `npm run editor`),
125
166
  // so Cargo.toml and the crates tree are always present.
126
167
  const buildOk = buildBinary();
168
+ // A failed rebuild means the server would boot on a stale binary that no
169
+ // longer matches the source. Refuse by default; CODEYAM_ALLOW_STALE=1
170
+ // forces the old behavior (continue with the last-good install).
171
+ enforceFreshBinary(buildOk, { action: "start the editor server" });
127
172
  // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so
128
173
  // the broker auto-spawn execs a binary that knows the `pty-broker
129
174
  // daemon` subcommand. Skip if the build failed — installing a stale
@@ -145,11 +190,27 @@ async function main() {
145
190
 
146
191
  // Start the Rust backend — it serves ui/dist/ and launches the Vite dev
147
192
  // server (configured via startCommand in editor.json) for Live Preview HMR.
148
- // On --restart: stop the existing server first, sweep stale artifacts while
149
- // target/ is idle (no process holding files open), then start fresh.
150
- if (shouldRestart && (await isPortInUse(SERVER_PORT))) {
151
- console.error(`Stopping existing server on port ${SERVER_PORT}...`);
152
- await stopServer(SERVER_PORT);
193
+ // On --restart: identity-check first, then stop the existing server,
194
+ // sweep stale artifacts while target/ is idle, then start fresh.
195
+ //
196
+ // Stopping someone else's editor server on `--restart` is the worst
197
+ // possible outcome of a port collision — only stop the listener if
198
+ // the identity probe confirms it's this project's server.
199
+ if (shouldRestart) {
200
+ const holder = await classifyPortHolder(SERVER_PORT);
201
+ if (holder.kind === "foreign") {
202
+ console.error(formatForeignListenerError(SERVER_PORT));
203
+ process.exit(1);
204
+ }
205
+ if (holder.kind === "cross-project") {
206
+ console.error(formatCrossProjectError(SERVER_PORT, holder, "restart"));
207
+ process.exit(1);
208
+ }
209
+ if (holder.kind === "same-project") {
210
+ console.error(`Stopping existing server on port ${SERVER_PORT}...`);
211
+ await stopServer(SERVER_PORT);
212
+ }
213
+ // holder.kind === "free": nothing to stop, fall through to ensureServer.
153
214
  }
154
215
  if (shouldRestart) {
155
216
  sweepStaleArtifacts();
@@ -180,8 +241,10 @@ async function main() {
180
241
 
181
242
  module.exports = {
182
243
  buildBinary,
244
+ staleBinaryBanner,
183
245
  cargoBinDir,
184
246
  installBinary,
247
+ linkOrCopyBinary,
185
248
  main,
186
249
  sweepStaleArtifacts,
187
250
  };
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Launcher-side mirror of `is_empty_project_dir` in
5
+ * `crates/codeyam-editor/src/commands/init.rs`. The Rust side decides
6
+ * which `/codeyam-…` skill the init binary points at; this JS mirror
7
+ * decides the same for the launcher's post-launch tail banner. Keep
8
+ * both in sync — the test `empty-project-dir.test.js` and Rust's
9
+ * `is_empty_project_dir_ignores_hidden_entries` /
10
+ * `is_empty_project_dir_detects_visible_entry` pin the contract.
11
+ *
12
+ * A "fresh" project directory has no non-hidden entries — only things
13
+ * like `.git/`, `.gitignore`, or other dotfiles. Hidden entries don't
14
+ * count as project content, so a dir holding only `.git/` and
15
+ * `.gitignore` (the "I just ran `git init`" case) still reads as empty.
16
+ * A read error returns false, matching the Rust `Err(_) => false` branch:
17
+ * a missing / unreadable dir defaults to the existing-project path rather
18
+ * than falsely scaffolding over nothing.
19
+ */
20
+
21
+ const fs = require("node:fs");
22
+
23
+ function isEmptyProjectDir(projectDir) {
24
+ let entries;
25
+ try {
26
+ entries = fs.readdirSync(projectDir);
27
+ } catch {
28
+ return false;
29
+ }
30
+ return entries.every((name) => name.startsWith("."));
31
+ }
32
+
33
+ module.exports = { isEmptyProjectDir };