@codeyam-editor/codeyam-editor 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1521 -0
  4. package/npm/container.js +28 -5
  5. package/npm/editor-dev.js +378 -0
  6. package/npm/editor.js +199 -12
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-cleanup.js +124 -0
  10. package/npm/fleet-env.js +125 -0
  11. package/npm/fleet-memory.js +127 -0
  12. package/npm/fleet-pool-policy.js +78 -0
  13. package/npm/fleet-pool.js +401 -0
  14. package/npm/fleet-queue.js +40 -0
  15. package/npm/fleet-reconcile.js +36 -0
  16. package/npm/fleet-reset.js +144 -0
  17. package/npm/fleet-roster.js +81 -0
  18. package/npm/launch-config.js +544 -0
  19. package/npm/preflight-guidance.js +68 -0
  20. package/npm/scenario-check.js +355 -290
  21. package/npm/scenario-handlers.js +82 -0
  22. package/npm/scenario-interactivity.js +199 -0
  23. package/npm/scenario-issues.js +35 -0
  24. package/npm/scenario-metrics.js +108 -0
  25. package/npm/scenario-mocks.js +107 -0
  26. package/npm/scenario-playwright.js +355 -0
  27. package/npm/utils.js +548 -22
  28. package/npm/vm-urls.js +117 -0
  29. package/package.json +13 -6
  30. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  31. package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
  32. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  33. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  34. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
  35. package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
  37. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  38. package/ui/dist/assets/index-BUYNFqKK.js +27 -0
  39. package/ui/dist/assets/index-DVr5rY2d.css +1 -0
  40. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  41. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  42. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  43. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  44. package/ui/dist/favicon.ico +0 -0
  45. package/ui/dist/favicon.png +0 -0
  46. package/ui/dist/index.html +16 -0
  47. package/npm/postinstall.js +0 -95
package/npm/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 } = 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}`);
@@ -23,9 +42,13 @@ async function main() {
23
42
 
24
43
  process.on("SIGINT", () => {
25
44
  clearInterval(keepAlive);
26
- if (serverChild) serverChild.kill("SIGINT");
45
+ killChildProcess(serverChild);
27
46
  process.exit(0);
28
47
  });
29
48
  }
30
49
 
31
- main();
50
+ module.exports = { main };
51
+
52
+ if (require.main === module) {
53
+ main();
54
+ }
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Development CLI for running the codeyam-editor against any project.
5
+ *
6
+ * Two modes:
7
+ *
8
+ * Launcher — no subcommand (or --restart): builds the Rust binary and UI
9
+ * from the editor repo, starts the server, waits for readiness, opens browser.
10
+ * codeyam-editor-dev
11
+ * codeyam-editor-dev --restart
12
+ *
13
+ * Passthrough — any subcommand (start, init, editor, ...): rebuilds the
14
+ * Rust binary, then forwards args to it.
15
+ * codeyam-editor-dev editor step 1
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`.
27
+ */
28
+
29
+ const { execSync, spawn, spawnSync } = require("child_process");
30
+ const path = require("path");
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");
44
+ const { open } = require("./open");
45
+ const { classifyProject } = require("./preflight-guidance");
46
+ const { isEmptyProjectDir } = require("./empty-project-dir");
47
+
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
+ }
59
+
60
+ const args = process.argv.slice(2);
61
+ const KNOWN_SUBCOMMANDS = ["start", "init", "editor"];
62
+
63
+ /**
64
+ * Check if the first non-flag argument is a known subcommand.
65
+ * Accepts an explicit argv array so the helper is testable without mutating
66
+ * process.argv (the module-level `args` const is the production default).
67
+ */
68
+ function hasSubcommand(argv = args) {
69
+ for (const arg of argv) {
70
+ if (arg.startsWith("-")) continue;
71
+ return KNOWN_SUBCOMMANDS.includes(arg);
72
+ }
73
+ return false;
74
+ }
75
+
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
+ */
81
+ function buildBinary() {
82
+ console.error("Building Rust binary...");
83
+ try {
84
+ execSync("cargo build", { stdio: "inherit", cwd: rootDir });
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
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
+ };
210
+
211
+ if (require.main === module) {
212
+ // --- Passthrough mode: build binary, then forward to it ---
213
+ if (hasSubcommand()) {
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
+ }
229
+ const binary = ensureBinary();
230
+ const child = spawn(binary, args, {
231
+ stdio: "inherit",
232
+ cwd: process.cwd(),
233
+ env: process.env,
234
+ });
235
+ child.on("exit", (code) => {
236
+ process.exit(code ?? 1);
237
+ });
238
+ } else {
239
+ // --- Launcher mode: build everything, start server, open browser ---
240
+ const shouldRestart = args.includes("--restart");
241
+
242
+ const launcherMain = async () => {
243
+ const projectDir = process.cwd();
244
+ console.error(`Editor repo: ${rootDir}`);
245
+ console.error(`Target project: ${projectDir}`);
246
+
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);
302
+
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
+ }
328
+ }
329
+
330
+ const serverChild = shouldRestart
331
+ ? await restartServer(SERVER_PORT)
332
+ : await ensureServer(SERVER_PORT);
333
+
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
+ }
351
+ }
352
+
353
+ const url = `http://localhost:${SERVER_PORT}`;
354
+ console.error(`Editor UI: ${url}`);
355
+ if (hasCommand) {
356
+ console.error(`Live Preview: http://localhost:${appPort}`);
357
+ }
358
+ console.error(`Project: ${projectDir}`);
359
+ open(url);
360
+ const banner = postLaunchInitBanner({
361
+ preflightAction: preflight.action,
362
+ wasEmpty,
363
+ });
364
+ if (banner) {
365
+ console.error(banner);
366
+ }
367
+ const keepAlive = setInterval(() => {}, 60_000);
368
+
369
+ process.on("SIGINT", () => {
370
+ clearInterval(keepAlive);
371
+ killChildProcess(serverChild);
372
+ process.exit(0);
373
+ });
374
+ };
375
+
376
+ launcherMain();
377
+ }
378
+ }
package/npm/editor.js CHANGED
@@ -13,18 +13,170 @@
13
13
  */
14
14
 
15
15
  const { execSync } = require("child_process");
16
+ const fs = require("fs");
17
+ const os = require("os");
16
18
  const path = require("path");
17
- const { ensureServer, restartServer, waitForPort, rootDir } = 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");
18
34
  const { open } = require("./open");
19
35
 
20
- const SERVER_PORT = 14199;
21
- const VITE_PORT = 5173;
22
- // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
23
- // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
24
- const VITE_INTERNAL_PORT = VITE_PORT + 1;
25
36
  const shouldRestart = process.argv.includes("--restart");
26
37
 
38
+ function buildBinary() {
39
+ console.error("Building Rust binary...");
40
+ try {
41
+ execSync("cargo build", { stdio: "inherit", cwd: rootDir });
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ // Resolve <CARGO_HOME>/bin (falling back to $HOME/.cargo/bin). Mirrors
49
+ // `installed_binary_path()` in crates/control-api/src/pty_broker_startup.rs
50
+ // — the Rust broker auto-spawn execs the binary at this path, so we MUST
51
+ // keep it in sync with the freshly-built debug binary or `npm run editor`
52
+ // will start a server whose PTY-broker auto-spawn execs a stale install.
53
+ function cargoBinDir() {
54
+ if (process.env.CARGO_HOME) return path.join(process.env.CARGO_HOME, "bin");
55
+ return path.join(os.homedir(), ".cargo", "bin");
56
+ }
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
+
85
+ // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor
86
+ // atomically (write to tmp in same dir, then rename), and refresh the
87
+ // codeyam-editor-pty-broker hardlink so it points at the new inode.
88
+ //
89
+ // The PTY broker auto-spawn (control-api/src/pty_broker_startup.rs) always
90
+ // resolves the *installed* path, NOT current_exe() — that was a deliberate
91
+ // fix to prevent a fork-bomb where test binaries spawned themselves. So a
92
+ // fresh `target/debug/codeyam-editor` is not enough; the install path must
93
+ // be refreshed too. Without this, build tabs hang on "Reconnecting..."
94
+ // because the broker daemon errors out (`unrecognized subcommand`) before
95
+ // the UDS socket opens.
96
+ function installBinary() {
97
+ const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
98
+ if (!fs.existsSync(srcPath)) {
99
+ console.error(`Skipping install: ${srcPath} not found.`);
100
+ return;
101
+ }
102
+ const binDir = cargoBinDir();
103
+ const dstPath = path.join(binDir, "codeyam-editor");
104
+ const linkPath = path.join(binDir, "codeyam-editor-pty-broker");
105
+ const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
106
+ console.error(`Installing fresh binary to ${dstPath}...`);
107
+ try {
108
+ fs.mkdirSync(binDir, { recursive: true });
109
+ linkOrCopyBinary(srcPath, dstPath, tmpPath);
110
+ try {
111
+ fs.unlinkSync(linkPath);
112
+ } catch (e) {
113
+ if (e.code !== "ENOENT") throw e;
114
+ }
115
+ fs.linkSync(dstPath, linkPath);
116
+ } catch (err) {
117
+ console.error(`Failed to install binary: ${err.message}`);
118
+ try { fs.unlinkSync(tmpPath); } catch {}
119
+ }
120
+ }
121
+
122
+ // Evict Rust build artifacts not touched in the last day so target/ doesn't
123
+ // grow unboundedly. Must run after the old server is stopped (it holds files
124
+ // in target/debug/ open) and before the new server starts. Non-fatal.
125
+ function sweepStaleArtifacts() {
126
+ console.error("Sweeping stale Rust build artifacts (idle >1 day)...");
127
+ try {
128
+ execSync("cargo sweep --time 1", { stdio: "inherit", cwd: rootDir });
129
+ } catch {
130
+ console.error("cargo-sweep failed (install with `cargo install cargo-sweep`). Continuing.");
131
+ }
132
+ }
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
+ */
27
139
  async function main() {
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;
163
+
164
+ // Rebuild the Rust binary so code changes are picked up on restart.
165
+ // This script is only invoked from a source checkout (via `npm run editor`),
166
+ // so Cargo.toml and the crates tree are always present.
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" });
172
+ // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so
173
+ // the broker auto-spawn execs a binary that knows the `pty-broker
174
+ // daemon` subcommand. Skip if the build failed — installing a stale
175
+ // debug binary over a working install is worse than leaving it.
176
+ if (buildOk) {
177
+ installBinary();
178
+ }
179
+
28
180
  // Build the UI so the backend can serve it as static files
29
181
  console.error("Building UI...");
30
182
  try {
@@ -37,10 +189,33 @@ async function main() {
37
189
  }
38
190
 
39
191
  // Start the Rust backend — it serves ui/dist/ and launches the Vite dev
40
- // server (configured via startCommand in editor.json) for Live Preview HMR
41
- const serverChild = shouldRestart
42
- ? await restartServer(SERVER_PORT)
43
- : await ensureServer(SERVER_PORT);
192
+ // server (configured via startCommand in editor.json) for Live Preview HMR.
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.
214
+ }
215
+ if (shouldRestart) {
216
+ sweepStaleArtifacts();
217
+ }
218
+ const serverChild = await ensureServer(SERVER_PORT);
44
219
 
45
220
  // Wait for the actual Vite dev server on the internal port (not the reverse
46
221
  // proxy on VITE_PORT, which starts immediately with the Rust binary).
@@ -59,9 +234,21 @@ async function main() {
59
234
 
60
235
  process.on("SIGINT", () => {
61
236
  clearInterval(keepAlive);
62
- if (serverChild) serverChild.kill("SIGINT");
237
+ killChildProcess(serverChild);
63
238
  process.exit(0);
64
239
  });
65
240
  }
66
241
 
67
- main();
242
+ module.exports = {
243
+ buildBinary,
244
+ staleBinaryBanner,
245
+ cargoBinDir,
246
+ installBinary,
247
+ linkOrCopyBinary,
248
+ main,
249
+ sweepStaleArtifacts,
250
+ };
251
+
252
+ if (require.main === module) {
253
+ main();
254
+ }