@codeyam-editor/codeyam-editor 0.1.0-staging.27ed016 → 0.1.0-staging.2b41530
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/container.js +17 -3
- package/npm/editor-dev.js +29 -15
- package/npm/editor.js +50 -31
- package/npm/scenario-check.js +4 -1
- package/npm/scenario-handlers.js +16 -5
- package/npm/utils.js +272 -5
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-apa7PmSB.js +38 -0
- package/ui/dist/assets/ScenarioDataPanel-BujcFaQj.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-I0tbkDlL.js → ScenarioDataPanelFullPage-fKkVQmo_.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-BMYCJ5Xn.js +1 -0
- package/ui/dist/assets/index-CL9DurHP.css +1 -0
- package/ui/dist/assets/{index-6jueUhB_.js → index-DlYjR4o_.js} +2 -2
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-1FTR28BA.js +0 -32
- package/ui/dist/assets/ScenarioDataPanel-Bb7egwjC.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-DIwirbX6.js +0 -1
- package/ui/dist/assets/index-Bik6WLGP.css +0 -1
package/npm/cli.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const { spawn } = require("child_process");
|
|
4
|
-
const { ensureBinary
|
|
4
|
+
const { ensureBinary } = require("./utils");
|
|
5
5
|
|
|
6
6
|
const binary = ensureBinary();
|
|
7
7
|
|
|
8
|
+
// The Rust binary embeds `ui/dist` at build time, so no `CODEYAM_EDITOR_UI_DIR`
|
|
9
|
+
// is required. Devs who want to override the bundle can still export the env
|
|
10
|
+
// var manually — the binary's resolver checks it first.
|
|
8
11
|
const child = spawn(binary, process.argv.slice(2), {
|
|
9
12
|
stdio: "inherit",
|
|
10
13
|
cwd: process.cwd(),
|
|
11
|
-
env:
|
|
14
|
+
env: process.env,
|
|
12
15
|
});
|
|
13
16
|
|
|
14
17
|
child.on("exit", (code) => {
|
package/npm/container.js
CHANGED
|
@@ -8,12 +8,26 @@ const path = require("path");
|
|
|
8
8
|
* inline monitoring UI at http://localhost:14199.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
const {
|
|
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
|
-
|
|
16
20
|
async function main() {
|
|
21
|
+
// Same resolution order as the editor launcher: env var
|
|
22
|
+
// (CODEYAM_EDITOR_PORT) → merged editor.json + editor.local.json
|
|
23
|
+
// (proxy.controlPort) → hard fallback (14199).
|
|
24
|
+
const merged = readMergedEditorConfig(rootDir);
|
|
25
|
+
const PORT = resolveLauncherPort(
|
|
26
|
+
process.env.CODEYAM_EDITOR_PORT,
|
|
27
|
+
merged.proxy && merged.proxy.controlPort,
|
|
28
|
+
14199,
|
|
29
|
+
);
|
|
30
|
+
|
|
17
31
|
const serverChild = await ensureServer(PORT);
|
|
18
32
|
const url = `http://localhost:${PORT}`;
|
|
19
33
|
console.error(`Container UI: ${url}`);
|
package/npm/editor-dev.js
CHANGED
|
@@ -18,8 +18,16 @@
|
|
|
18
18
|
|
|
19
19
|
const { execSync, spawn } = require("child_process");
|
|
20
20
|
const path = require("path");
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
const {
|
|
22
|
+
ensureServer,
|
|
23
|
+
restartServer,
|
|
24
|
+
waitForPort,
|
|
25
|
+
ensureBinary,
|
|
26
|
+
rootDir,
|
|
27
|
+
killChildProcess,
|
|
28
|
+
readMergedEditorConfig,
|
|
29
|
+
resolveLauncherPort,
|
|
30
|
+
} = require("./utils");
|
|
23
31
|
const { open } = require("./open");
|
|
24
32
|
|
|
25
33
|
// Tell the Rust binary to emit "codeyam-editor-dev" in all instructions,
|
|
@@ -62,31 +70,38 @@ if (require.main === module) {
|
|
|
62
70
|
const child = spawn(binary, args, {
|
|
63
71
|
stdio: "inherit",
|
|
64
72
|
cwd: process.cwd(),
|
|
65
|
-
env:
|
|
73
|
+
env: process.env,
|
|
66
74
|
});
|
|
67
75
|
child.on("exit", (code) => {
|
|
68
76
|
process.exit(code ?? 1);
|
|
69
77
|
});
|
|
70
78
|
} else {
|
|
71
79
|
// --- Launcher mode: build everything, start server, open browser ---
|
|
72
|
-
const SERVER_PORT = 14199;
|
|
73
80
|
const shouldRestart = args.includes("--restart");
|
|
74
81
|
|
|
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
82
|
const launcherMain = async () => {
|
|
86
83
|
const projectDir = process.cwd();
|
|
87
84
|
console.error(`Editor repo: ${rootDir}`);
|
|
88
85
|
console.error(`Target project: ${projectDir}`);
|
|
89
86
|
|
|
87
|
+
// Editor server's control port is owned by the editor repo's
|
|
88
|
+
// merged config (rootDir), not the target project's. The target
|
|
89
|
+
// project's own `editor.local.json` only contributes to the
|
|
90
|
+
// app/dev-server port below.
|
|
91
|
+
const editorMerged = readMergedEditorConfig(rootDir);
|
|
92
|
+
const SERVER_PORT = resolveLauncherPort(
|
|
93
|
+
process.env.CODEYAM_EDITOR_PORT,
|
|
94
|
+
editorMerged.proxy && editorMerged.proxy.controlPort,
|
|
95
|
+
14199,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Target project's dev-server port comes from its own
|
|
99
|
+
// editor.json + editor.local.json (developers iterating on the
|
|
100
|
+
// editor against a real client project rely on the override
|
|
101
|
+
// file here too).
|
|
102
|
+
const projectMerged = readMergedEditorConfig(projectDir);
|
|
103
|
+
const appPort = resolveLauncherPort(undefined, projectMerged.port, 3000);
|
|
104
|
+
|
|
90
105
|
buildBinary();
|
|
91
106
|
|
|
92
107
|
// Build the UI from the editor repo
|
|
@@ -101,7 +116,6 @@ if (require.main === module) {
|
|
|
101
116
|
? await restartServer(SERVER_PORT)
|
|
102
117
|
: await ensureServer(SERVER_PORT);
|
|
103
118
|
|
|
104
|
-
const appPort = readProjectPort();
|
|
105
119
|
const viteInternalPort = appPort + 1;
|
|
106
120
|
console.error(`Waiting for dev server on port ${viteInternalPort}...`);
|
|
107
121
|
const viteReady = await waitForPort(viteInternalPort, 15000, 300);
|
package/npm/editor.js
CHANGED
|
@@ -16,14 +16,20 @@ 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 {
|
|
19
|
+
const {
|
|
20
|
+
ensureServer,
|
|
21
|
+
stopServer,
|
|
22
|
+
waitForPort,
|
|
23
|
+
rootDir,
|
|
24
|
+
killChildProcess,
|
|
25
|
+
readMergedEditorConfig,
|
|
26
|
+
resolveLauncherPort,
|
|
27
|
+
classifyPortHolder,
|
|
28
|
+
formatForeignListenerError,
|
|
29
|
+
formatCrossProjectError,
|
|
30
|
+
} = require("./utils");
|
|
20
31
|
const { open } = require("./open");
|
|
21
32
|
|
|
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
33
|
const shouldRestart = process.argv.includes("--restart");
|
|
28
34
|
|
|
29
35
|
function buildBinary() {
|
|
@@ -99,26 +105,23 @@ function sweepStaleArtifacts() {
|
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
async function main() {
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
fs.writeFileSync(editorJsonPath, JSON.stringify(editorJson, null, 2));
|
|
121
|
-
}
|
|
108
|
+
// Read ports from the merged editor config (editor.json + per-developer
|
|
109
|
+
// editor.local.json). The Rust backend reads the same merged config on
|
|
110
|
+
// its own — we resolve here so the launcher's log lines, `waitForPort`
|
|
111
|
+
// calls, and `--port` arg agree with what the binary will actually
|
|
112
|
+
// bind. Env vars still win for scripted/CI invocations
|
|
113
|
+
// (CODEYAM_EDITOR_PORT, PORT), then the merged config, then a hard
|
|
114
|
+
// fallback for fresh checkouts with no override file.
|
|
115
|
+
const merged = readMergedEditorConfig(rootDir);
|
|
116
|
+
const SERVER_PORT = resolveLauncherPort(
|
|
117
|
+
process.env.CODEYAM_EDITOR_PORT,
|
|
118
|
+
merged.proxy && merged.proxy.controlPort,
|
|
119
|
+
14199,
|
|
120
|
+
);
|
|
121
|
+
const VITE_PORT = resolveLauncherPort(process.env.PORT, merged.port, 5173);
|
|
122
|
+
// The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
|
|
123
|
+
// VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
|
|
124
|
+
const VITE_INTERNAL_PORT = VITE_PORT + 1;
|
|
122
125
|
|
|
123
126
|
// Rebuild the Rust binary so code changes are picked up on restart.
|
|
124
127
|
// This script is only invoked from a source checkout (via `npm run editor`),
|
|
@@ -145,11 +148,27 @@ async function main() {
|
|
|
145
148
|
|
|
146
149
|
// Start the Rust backend — it serves ui/dist/ and launches the Vite dev
|
|
147
150
|
// server (configured via startCommand in editor.json) for Live Preview HMR.
|
|
148
|
-
// On --restart: stop the existing server
|
|
149
|
-
// target/ is idle
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
// On --restart: identity-check first, then stop the existing server,
|
|
152
|
+
// sweep stale artifacts while target/ is idle, then start fresh.
|
|
153
|
+
//
|
|
154
|
+
// Stopping someone else's editor server on `--restart` is the worst
|
|
155
|
+
// possible outcome of a port collision — only stop the listener if
|
|
156
|
+
// the identity probe confirms it's this project's server.
|
|
157
|
+
if (shouldRestart) {
|
|
158
|
+
const holder = await classifyPortHolder(SERVER_PORT);
|
|
159
|
+
if (holder.kind === "foreign") {
|
|
160
|
+
console.error(formatForeignListenerError(SERVER_PORT));
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
if (holder.kind === "cross-project") {
|
|
164
|
+
console.error(formatCrossProjectError(SERVER_PORT, holder, "restart"));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
if (holder.kind === "same-project") {
|
|
168
|
+
console.error(`Stopping existing server on port ${SERVER_PORT}...`);
|
|
169
|
+
await stopServer(SERVER_PORT);
|
|
170
|
+
}
|
|
171
|
+
// holder.kind === "free": nothing to stop, fall through to ensureServer.
|
|
153
172
|
}
|
|
154
173
|
if (shouldRestart) {
|
|
155
174
|
sweepStaleArtifacts();
|
package/npm/scenario-check.js
CHANGED
|
@@ -80,7 +80,10 @@ async function runScenarioCheck(config) {
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
page.on("requestfailed", (request) => {
|
|
83
|
-
|
|
83
|
+
const issue = handleRequestFailed(request);
|
|
84
|
+
if (issue) {
|
|
85
|
+
pushIssue(issues, issue);
|
|
86
|
+
}
|
|
84
87
|
});
|
|
85
88
|
|
|
86
89
|
let loaded = false;
|
package/npm/scenario-handlers.js
CHANGED
|
@@ -56,11 +56,22 @@ function handlePageError(error) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function handleRequestFailed(request) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
59
|
+
const errorText = request.failure()?.errorText || "Request failed";
|
|
60
|
+
|
|
61
|
+
// Filter benign request cancellations. `net::ERR_ABORTED` is what Playwright
|
|
62
|
+
// emits when a request is in-flight at the moment the page is closed (or the
|
|
63
|
+
// iframe is destroyed). For scenarios whose pages fetch large payloads (the
|
|
64
|
+
// editor's own EditorShell mounts a 2.2MB `/api/tests` fetch), the
|
|
65
|
+
// browser.close() at the end of capture races the fetch and produces this
|
|
66
|
+
// event AFTER the screenshot has already been taken — there is no real
|
|
67
|
+
// failure to surface. Genuine network failures arrive under different
|
|
68
|
+
// codes (net::ERR_CONNECTION_REFUSED, net::ERR_NAME_NOT_RESOLVED, etc.)
|
|
69
|
+
// and continue to be reported.
|
|
70
|
+
if (errorText.includes("net::ERR_ABORTED")) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return createIssue("requestfailed", errorText, { url: request.url() });
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
module.exports = {
|
package/npm/utils.js
CHANGED
|
@@ -141,6 +141,122 @@ function uiDistDir() {
|
|
|
141
141
|
return path.join(rootDir, "ui", "dist");
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
function isPlainJsonObject(value) {
|
|
145
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Mirrors `deep_merge_json` in crates/types/src/config.rs:1284.
|
|
149
|
+
// Objects merge by key (`null` in overlay removes the key); arrays merge
|
|
150
|
+
// by index (extra overlay items appended, base items past overlay length
|
|
151
|
+
// retained); scalars replace. The JS and Rust sides MUST match — any
|
|
152
|
+
// divergence shows up as "the CLI sees port X but `npm run editor`
|
|
153
|
+
// opens port Y", which is exactly the bug this whole helper exists to
|
|
154
|
+
// prevent.
|
|
155
|
+
function deepMergeJson(base, overlay) {
|
|
156
|
+
if (isPlainJsonObject(base) && isPlainJsonObject(overlay)) {
|
|
157
|
+
for (const k of Object.keys(overlay)) {
|
|
158
|
+
const v = overlay[k];
|
|
159
|
+
if (v === null) {
|
|
160
|
+
delete base[k];
|
|
161
|
+
} else if (Object.prototype.hasOwnProperty.call(base, k)) {
|
|
162
|
+
base[k] = deepMergeJson(base[k], v);
|
|
163
|
+
} else {
|
|
164
|
+
base[k] = v;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return base;
|
|
168
|
+
}
|
|
169
|
+
if (Array.isArray(base) && Array.isArray(overlay)) {
|
|
170
|
+
for (let i = 0; i < overlay.length; i++) {
|
|
171
|
+
if (i < base.length) {
|
|
172
|
+
base[i] = deepMergeJson(base[i], overlay[i]);
|
|
173
|
+
} else {
|
|
174
|
+
base.push(overlay[i]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return base;
|
|
178
|
+
}
|
|
179
|
+
return overlay;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Mirrors the post-merge strip in `merged_config_value`
|
|
183
|
+
// (crates/types/src/config.rs:1272). Any top-level key starting with
|
|
184
|
+
// `_` is documentation-only (`_README`, `_examples`, …) and is removed
|
|
185
|
+
// before the merged config is used by the launcher or handed to the
|
|
186
|
+
// Rust binary. Mutates in place; returns the same object for chaining.
|
|
187
|
+
function stripUnderscoreTopLevelKeys(value) {
|
|
188
|
+
if (isPlainJsonObject(value)) {
|
|
189
|
+
for (const k of Object.keys(value)) {
|
|
190
|
+
if (k.startsWith("_")) delete value[k];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read `<projectDir>/.codeyam/editor.json` and deep-merge an optional
|
|
198
|
+
* sibling `editor.local.json` on top, returning the merged plain object.
|
|
199
|
+
*
|
|
200
|
+
* This is the JS-side equivalent of `merged_config_value` in
|
|
201
|
+
* crates/types/src/config.rs — same merge rules, same underscore-prefix
|
|
202
|
+
* strip. Launcher scripts call this *before* the Rust binary is built
|
|
203
|
+
* (they invoke `cargo build` themselves), so shelling out to
|
|
204
|
+
* `codeyam-editor editor config-show` is not an option.
|
|
205
|
+
*
|
|
206
|
+
* Returns `{}` if the base file is missing (a freshly cloned repo with
|
|
207
|
+
* no `.codeyam/` directory should not throw at require-time). Throws
|
|
208
|
+
* on malformed JSON, naming the offending file path so the user knows
|
|
209
|
+
* which file to fix.
|
|
210
|
+
*/
|
|
211
|
+
function readMergedEditorConfig(projectDir = rootDir) {
|
|
212
|
+
const basePath = path.join(projectDir, ".codeyam", "editor.json");
|
|
213
|
+
if (!fs.existsSync(basePath)) return {};
|
|
214
|
+
|
|
215
|
+
let merged;
|
|
216
|
+
try {
|
|
217
|
+
merged = JSON.parse(fs.readFileSync(basePath, "utf8"));
|
|
218
|
+
} catch (e) {
|
|
219
|
+
throw new Error(`Failed to parse ${basePath}: ${e.message}`);
|
|
220
|
+
}
|
|
221
|
+
if (!isPlainJsonObject(merged)) merged = {};
|
|
222
|
+
|
|
223
|
+
const overlayPath = path.join(projectDir, ".codeyam", "editor.local.json");
|
|
224
|
+
if (fs.existsSync(overlayPath)) {
|
|
225
|
+
let overlay;
|
|
226
|
+
try {
|
|
227
|
+
overlay = JSON.parse(fs.readFileSync(overlayPath, "utf8"));
|
|
228
|
+
} catch (e) {
|
|
229
|
+
throw new Error(`Failed to parse ${overlayPath}: ${e.message}`);
|
|
230
|
+
}
|
|
231
|
+
if (isPlainJsonObject(overlay)) {
|
|
232
|
+
deepMergeJson(merged, overlay);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return stripUnderscoreTopLevelKeys(merged);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Resolve a port for a launcher in priority order:
|
|
241
|
+
* explicit env var → merged-config value → hard fallback.
|
|
242
|
+
*
|
|
243
|
+
* Exported so each launcher's port resolution can be unit-tested
|
|
244
|
+
* without spawning a process. The fallback is the historical hard-coded
|
|
245
|
+
* value (14199 / 5173 / 3000 depending on which slot the caller is
|
|
246
|
+
* resolving) and exists so a fresh checkout with no override file and
|
|
247
|
+
* no env var still works.
|
|
248
|
+
*/
|
|
249
|
+
function resolveLauncherPort(envValue, mergedValue, fallback) {
|
|
250
|
+
if (envValue !== undefined && envValue !== null && envValue !== "") {
|
|
251
|
+
const parsed = parseInt(envValue, 10);
|
|
252
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
253
|
+
}
|
|
254
|
+
if (typeof mergedValue === "number" && Number.isFinite(mergedValue)) {
|
|
255
|
+
return mergedValue;
|
|
256
|
+
}
|
|
257
|
+
return fallback;
|
|
258
|
+
}
|
|
259
|
+
|
|
144
260
|
/** Try to connect to a specific host:port. Returns true if something is listening. */
|
|
145
261
|
function tryConnect(port, host) {
|
|
146
262
|
return new Promise((resolve) => {
|
|
@@ -198,6 +314,10 @@ function clearServerState() {
|
|
|
198
314
|
} catch {}
|
|
199
315
|
}
|
|
200
316
|
|
|
317
|
+
// Default ports across stopServer / restartServer / ensureServer /
|
|
318
|
+
// requestServerShutdown are a backstop for any future caller that
|
|
319
|
+
// forgets to pass an explicit port. Every production call site now
|
|
320
|
+
// resolves the port from `readMergedEditorConfig` + `resolveLauncherPort`.
|
|
201
321
|
async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
|
|
202
322
|
const controller = new AbortController();
|
|
203
323
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -215,6 +335,121 @@ async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
|
|
|
215
335
|
}
|
|
216
336
|
}
|
|
217
337
|
|
|
338
|
+
/**
|
|
339
|
+
* GET /api/codeyam-server-identity from the listener on `port` with a
|
|
340
|
+
* sub-second timeout. Returns the parsed `{projectDir, pid, version,
|
|
341
|
+
* startedAt}` body on a 2xx response, or `null` on any failure
|
|
342
|
+
* (timeout, non-200, parse failure, connection refused).
|
|
343
|
+
*
|
|
344
|
+
* `null` carries two distinct meanings that the caller must
|
|
345
|
+
* disambiguate by also checking `isPortInUse`:
|
|
346
|
+
* - port is free → no listener at all
|
|
347
|
+
* - port is in use → something is listening, but it's not a
|
|
348
|
+
* codeyam-editor server (or it's an old build that predates this
|
|
349
|
+
* endpoint).
|
|
350
|
+
*/
|
|
351
|
+
async function fetchServerIdentity(port, timeoutMs = 500) {
|
|
352
|
+
const controller = new AbortController();
|
|
353
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const resp = await fetch(`http://127.0.0.1:${port}/api/codeyam-server-identity`, {
|
|
357
|
+
method: "GET",
|
|
358
|
+
signal: controller.signal,
|
|
359
|
+
});
|
|
360
|
+
if (!resp.ok) return null;
|
|
361
|
+
const body = await resp.json();
|
|
362
|
+
if (!body || typeof body !== "object" || typeof body.projectDir !== "string") {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return body;
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
} finally {
|
|
369
|
+
clearTimeout(timeout);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Resolve the canonical (symlink-followed) path for `dir`, falling back to
|
|
375
|
+
* `dir` if `fs.realpath` fails (e.g. the path no longer exists). Both
|
|
376
|
+
* sides of the identity check route through this so a `~/work/foo`
|
|
377
|
+
* symlink and a `/Volumes/dev/foo` direct path compare equal.
|
|
378
|
+
*/
|
|
379
|
+
async function canonicalisePath(dir) {
|
|
380
|
+
try {
|
|
381
|
+
return await fs.promises.realpath(dir);
|
|
382
|
+
} catch {
|
|
383
|
+
return dir;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Build the "port held by a non-codeyam-editor process" error string.
|
|
389
|
+
* Lives next to `classifyPortHolder` so the wording is tested without
|
|
390
|
+
* a running server — both launchers (`ensureServer` and `editor.js`'s
|
|
391
|
+
* `--restart`) print this exact message on a "foreign" classification.
|
|
392
|
+
*/
|
|
393
|
+
function formatForeignListenerError(port) {
|
|
394
|
+
return (
|
|
395
|
+
`Port ${port} is in use, but no codeyam-editor server responded ` +
|
|
396
|
+
`at /api/codeyam-server-identity. Free the port or change ` +
|
|
397
|
+
`controlPort in .codeyam/editor.local.json.`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Build the "another project's editor is on this port" error string.
|
|
403
|
+
* `verb` is the action the launcher would have taken ("attach" /
|
|
404
|
+
* "restart"); the wording is otherwise identical so both launcher
|
|
405
|
+
* paths share the same advice.
|
|
406
|
+
*/
|
|
407
|
+
function formatCrossProjectError(port, holder, verb) {
|
|
408
|
+
const pidNote = Number.isInteger(holder.identity && holder.identity.pid)
|
|
409
|
+
? ` (pid ${holder.identity.pid})`
|
|
410
|
+
: "";
|
|
411
|
+
return (
|
|
412
|
+
`Refusing to ${verb} a codeyam-editor server for a different project.\n` +
|
|
413
|
+
` This project: ${holder.wantDir}\n` +
|
|
414
|
+
` Server on port ${port}${pidNote}: ${holder.haveDir}\n` +
|
|
415
|
+
`\n` +
|
|
416
|
+
`Change controlPort in .codeyam/editor.local.json, e.g.:\n` +
|
|
417
|
+
` codeyam-editor editor config-override proxy.controlPort 14399`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Classify whatever is listening on `port` relative to `projectDir`.
|
|
423
|
+
* Returns one of:
|
|
424
|
+
* - `{ kind: "free" }` — nothing on the port
|
|
425
|
+
* - `{ kind: "foreign" }` — port held by a non-editor process
|
|
426
|
+
* - `{ kind: "cross-project", wantDir, haveDir, identity }`
|
|
427
|
+
* — held by another project's editor
|
|
428
|
+
* - `{ kind: "same-project", identity }` — held by THIS project's editor
|
|
429
|
+
*
|
|
430
|
+
* Pure: every effect (network, fs) is bounded; no console output, no
|
|
431
|
+
* process.exit. The launcher decides what to do with each kind. Splitting
|
|
432
|
+
* the policy (what to print, when to exit) from the classification keeps
|
|
433
|
+
* the helper testable without mocking process.exit, and lets `ensureServer`
|
|
434
|
+
* and `editor.js`'s `--restart` block share the same logic with different
|
|
435
|
+
* wording.
|
|
436
|
+
*/
|
|
437
|
+
async function classifyPortHolder(port, projectDir = process.cwd()) {
|
|
438
|
+
if (!(await isPortInUse(port))) {
|
|
439
|
+
return { kind: "free" };
|
|
440
|
+
}
|
|
441
|
+
const identity = await fetchServerIdentity(port);
|
|
442
|
+
if (identity === null) {
|
|
443
|
+
return { kind: "foreign" };
|
|
444
|
+
}
|
|
445
|
+
const wantDir = await canonicalisePath(projectDir);
|
|
446
|
+
const haveDir = await canonicalisePath(identity.projectDir);
|
|
447
|
+
if (wantDir !== haveDir) {
|
|
448
|
+
return { kind: "cross-project", wantDir, haveDir, identity };
|
|
449
|
+
}
|
|
450
|
+
return { kind: "same-project", identity };
|
|
451
|
+
}
|
|
452
|
+
|
|
218
453
|
function tryKillPid(pid) {
|
|
219
454
|
try {
|
|
220
455
|
// On Windows, `process.kill(pid, "SIGINT")` either errors or no-ops
|
|
@@ -306,19 +541,42 @@ async function restartServer(port = 14199) {
|
|
|
306
541
|
return ensureServer(port);
|
|
307
542
|
}
|
|
308
543
|
|
|
309
|
-
/**
|
|
544
|
+
/**
|
|
545
|
+
* Start the Rust backend server if not already running, refusing to attach
|
|
546
|
+
* if the existing listener belongs to a different project (or to a foreign
|
|
547
|
+
* process that doesn't speak the identity endpoint at all). Returns the
|
|
548
|
+
* spawned child on a fresh start, or `null` when we attached to an
|
|
549
|
+
* already-running same-project server. Exits the process non-zero on a
|
|
550
|
+
* mismatch — silently choosing a different port would just relocate the
|
|
551
|
+
* surprise.
|
|
552
|
+
*/
|
|
310
553
|
async function ensureServer(port = 14199) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
554
|
+
const holder = await classifyPortHolder(port);
|
|
555
|
+
switch (holder.kind) {
|
|
556
|
+
case "foreign":
|
|
557
|
+
console.error(formatForeignListenerError(port));
|
|
558
|
+
process.exit(1);
|
|
559
|
+
break;
|
|
560
|
+
case "cross-project":
|
|
561
|
+
console.error(formatCrossProjectError(port, holder, "attach"));
|
|
562
|
+
process.exit(1);
|
|
563
|
+
break;
|
|
564
|
+
case "same-project":
|
|
565
|
+
console.error(`Server already running on port ${port}`);
|
|
566
|
+
return null;
|
|
567
|
+
case "free":
|
|
568
|
+
default:
|
|
569
|
+
break;
|
|
314
570
|
}
|
|
315
571
|
|
|
316
572
|
const binary = ensureBinary();
|
|
573
|
+
// UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is
|
|
574
|
+
// now an explicit dev-override only.
|
|
317
575
|
const child = spawn(binary, ["start", "--no-open", "--port", String(port)], {
|
|
318
576
|
stdio: "inherit",
|
|
319
577
|
cwd: process.cwd(),
|
|
320
578
|
detached: false,
|
|
321
|
-
env:
|
|
579
|
+
env: process.env,
|
|
322
580
|
});
|
|
323
581
|
|
|
324
582
|
const ready = await waitForPort(port);
|
|
@@ -347,10 +605,19 @@ module.exports = {
|
|
|
347
605
|
readServerState,
|
|
348
606
|
clearServerState,
|
|
349
607
|
requestServerShutdown,
|
|
608
|
+
fetchServerIdentity,
|
|
609
|
+
canonicalisePath,
|
|
610
|
+
classifyPortHolder,
|
|
611
|
+
formatForeignListenerError,
|
|
612
|
+
formatCrossProjectError,
|
|
350
613
|
tryKillPid,
|
|
351
614
|
findListeningPids,
|
|
352
615
|
stopServer,
|
|
353
616
|
restartServer,
|
|
354
617
|
ensureServer,
|
|
355
618
|
killChildProcess,
|
|
619
|
+
deepMergeJson,
|
|
620
|
+
stripUnderscoreTopLevelKeys,
|
|
621
|
+
readMergedEditorConfig,
|
|
622
|
+
resolveLauncherPort,
|
|
356
623
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codeyam-editor/codeyam-editor",
|
|
3
|
-
"version": "0.1.0-staging.
|
|
3
|
+
"version": "0.1.0-staging.2b41530",
|
|
4
4
|
"description": "Language-agnostic managed execution sandbox for scenario-driven development",
|
|
5
5
|
"bin": {
|
|
6
6
|
"codeyam-editor": "./npm/cli.js"
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"playwright": "^1.58.2"
|
|
10
10
|
},
|
|
11
11
|
"optionalDependencies": {
|
|
12
|
-
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.0-staging.
|
|
13
|
-
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.0-staging.
|
|
14
|
-
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.0-staging.
|
|
15
|
-
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.0-staging.
|
|
12
|
+
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.0-staging.2b41530",
|
|
13
|
+
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.0-staging.2b41530",
|
|
14
|
+
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.0-staging.2b41530",
|
|
15
|
+
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.0-staging.2b41530"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"codeyam",
|