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