@codeyam-editor/codeyam-editor 0.1.0-staging.36d3518

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 ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require("child_process");
4
+ const { ensureBinary, uiDistDir } = require("./utils");
5
+
6
+ const binary = ensureBinary();
7
+
8
+ const child = spawn(binary, process.argv.slice(2), {
9
+ stdio: "inherit",
10
+ cwd: process.cwd(),
11
+ env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() },
12
+ });
13
+
14
+ child.on("exit", (code) => {
15
+ process.exit(code ?? 1);
16
+ });
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require("path");
4
+ /**
5
+ * Start the container monitoring UI.
6
+ *
7
+ * Starts the Rust backend (if not already running) and opens the
8
+ * inline monitoring UI at http://localhost:14199.
9
+ */
10
+
11
+ const { ensureServer, rootDir } = require("./utils");
12
+ const { open } = require("./open");
13
+
14
+ const PORT = 14199;
15
+
16
+ async function main() {
17
+ const serverChild = await ensureServer(PORT);
18
+ const url = `http://localhost:${PORT}`;
19
+ console.error(`Container UI: ${url}`);
20
+ console.error(`Logs: ${path.join(rootDir, ".codeyam", "logs")}`);
21
+ open(url);
22
+ const keepAlive = setInterval(() => {}, 60_000);
23
+
24
+ process.on("SIGINT", () => {
25
+ clearInterval(keepAlive);
26
+ if (serverChild) serverChild.kill("SIGINT");
27
+ process.exit(0);
28
+ });
29
+ }
30
+
31
+ main();
package/npm/editor.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Start the editor.
5
+ *
6
+ * 1. Builds the React UI into ui/dist/ (stable outer shell, no HMR)
7
+ * 2. Starts the Rust backend which:
8
+ * - Serves ui/dist/ as static files on port 14199
9
+ * - Launches the Vite dev server (startCommand) on port 5173 for Live Preview HMR
10
+ * - Provides the API, WebSocket, and screenshot endpoints
11
+ * 3. Waits for the Vite dev server to be ready (so the Live Preview iframe works)
12
+ * 4. Opens http://localhost:14199 in the browser
13
+ */
14
+
15
+ const { execSync } = require("child_process");
16
+ const path = require("path");
17
+ const { ensureServer, restartServer, waitForPort, rootDir } = require("./utils");
18
+ const { open } = require("./open");
19
+
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
+ const shouldRestart = process.argv.includes("--restart");
26
+
27
+ async function main() {
28
+ // Build the UI so the backend can serve it as static files
29
+ console.error("Building UI...");
30
+ try {
31
+ execSync("npx vite build", {
32
+ stdio: "inherit",
33
+ cwd: path.join(rootDir, "ui"),
34
+ });
35
+ } catch {
36
+ console.error("Failed to build UI. Continuing with existing build (if any)...");
37
+ }
38
+
39
+ // 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);
44
+
45
+ // Wait for the actual Vite dev server on the internal port (not the reverse
46
+ // proxy on VITE_PORT, which starts immediately with the Rust binary).
47
+ console.error("Waiting for Vite dev server...");
48
+ const viteReady = await waitForPort(VITE_INTERNAL_PORT, 15000, 300);
49
+ if (!viteReady) {
50
+ console.error(`Warning: Vite dev server not ready on port ${VITE_INTERNAL_PORT}. Live Preview may not work initially.`);
51
+ }
52
+
53
+ const url = `http://localhost:${SERVER_PORT}`;
54
+ console.error(`Editor UI: ${url}`);
55
+ console.error(`Live Preview: http://localhost:${VITE_PORT}`);
56
+ console.error(`Logs: ${path.join(rootDir, ".codeyam", "logs")}`);
57
+ open(url);
58
+ const keepAlive = setInterval(() => {}, 60_000);
59
+
60
+ process.on("SIGINT", () => {
61
+ clearInterval(keepAlive);
62
+ if (serverChild) serverChild.kill("SIGINT");
63
+ process.exit(0);
64
+ });
65
+ }
66
+
67
+ main();
package/npm/open.js ADDED
@@ -0,0 +1,15 @@
1
+ const { spawn } = require("child_process");
2
+
3
+ /** Open a URL in the default browser. */
4
+ function open(url) {
5
+ const cmd =
6
+ process.platform === "darwin"
7
+ ? "open"
8
+ : process.platform === "win32"
9
+ ? "start"
10
+ : "xdg-open";
11
+
12
+ spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
13
+ }
14
+
15
+ module.exports = { open };
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Post-install script for @codeyam-editor/codeyam-editor.
5
+ *
6
+ * Downloads the pre-built platform binary and UI assets from GitHub Releases.
7
+ * Skipped when running in local development (cargo builds from source instead).
8
+ */
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { execSync } = require("child_process");
13
+
14
+ const rootDir = path.resolve(__dirname, "..");
15
+
16
+ const PLATFORM_MAP = {
17
+ "darwin-arm64": "codeyam-editor-darwin-arm64",
18
+ "darwin-x64": "codeyam-editor-darwin-x64",
19
+ "linux-x64": "codeyam-editor-linux-x64",
20
+ };
21
+
22
+ function getArtifactName() {
23
+ const key = `${process.platform}-${process.arch}`;
24
+ const name = PLATFORM_MAP[key];
25
+ if (!name) {
26
+ console.error(
27
+ `codeyam-editor: unsupported platform ${process.platform} ${process.arch}`
28
+ );
29
+ console.error("Supported: macOS (arm64, x64), Linux (x64)");
30
+ process.exit(0); // Don't fail the install — user may be building from source
31
+ }
32
+ return name;
33
+ }
34
+
35
+ /**
36
+ * Determine the GitHub release tag from the installed package version.
37
+ *
38
+ * - "0.1.0-staging.abc1234" -> "staging"
39
+ * - "0.1.2" -> "latest"
40
+ */
41
+ function getReleaseTag() {
42
+ const pkg = JSON.parse(
43
+ fs.readFileSync(path.join(rootDir, "package.json"), "utf8")
44
+ );
45
+ const version = pkg.version || "0.1.0";
46
+ if (version.includes("-staging")) return "staging";
47
+ return "latest";
48
+ }
49
+
50
+ function download() {
51
+ // Skip if a binary already exists (e.g. local dev with cargo build)
52
+ const binPath = path.join(rootDir, "bin", "codeyam-editor");
53
+ if (fs.existsSync(binPath)) {
54
+ return;
55
+ }
56
+
57
+ // Skip if we're in a dev checkout (has Cargo.toml at root)
58
+ if (fs.existsSync(path.join(rootDir, "Cargo.toml"))) {
59
+ return;
60
+ }
61
+
62
+ const artifactName = getArtifactName();
63
+ const tag = getReleaseTag();
64
+ const url = `https://github.com/codeyam-ai/codeyam-editor/releases/download/${tag}/${artifactName}.tar.gz`;
65
+
66
+ console.error(`Downloading codeyam-editor binary (${artifactName})...`);
67
+
68
+ const binDir = path.join(rootDir, "bin");
69
+ const uiDir = path.join(rootDir, "ui");
70
+ fs.mkdirSync(binDir, { recursive: true });
71
+ fs.mkdirSync(uiDir, { recursive: true });
72
+
73
+ try {
74
+ // Download and extract in one step using curl + tar
75
+ execSync(
76
+ `curl -fsSL "${url}" | tar xz -C "${rootDir}"`,
77
+ { stdio: ["ignore", "inherit", "inherit"], timeout: 120000 }
78
+ );
79
+
80
+ // Ensure binary is executable
81
+ if (fs.existsSync(binPath)) {
82
+ fs.chmodSync(binPath, 0o755);
83
+ }
84
+
85
+ console.error("codeyam-editor installed successfully!");
86
+ } catch (err) {
87
+ console.error(`Failed to download codeyam-editor binary from ${url}`);
88
+ console.error(
89
+ "You can build from source instead: cargo build --release"
90
+ );
91
+ // Don't fail the install — user may want to build from source
92
+ }
93
+ }
94
+
95
+ download();
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { chromium } = require("playwright");
6
+
7
+ const LOADING_MARKERS = [
8
+ "Loading scenario...",
9
+ "Loading tests...",
10
+ "Loading scenarios...",
11
+ "disconnected",
12
+ ];
13
+
14
+ function hasLoadingMarkers(text) {
15
+ return LOADING_MARKERS.some((marker) => text.includes(marker));
16
+ }
17
+
18
+ function hasRenderableContent(state) {
19
+ return Boolean(
20
+ state &&
21
+ (state.rootChildCount > 0 ||
22
+ state.rootTextLength > 0 ||
23
+ state.bodyTextLength > 0),
24
+ );
25
+ }
26
+
27
+ const ERROR_PATTERNS = [
28
+ "not found in registry",
29
+ "Component not found",
30
+ "Scenario Error",
31
+ ];
32
+
33
+ function hasErrorPatterns(text) {
34
+ return ERROR_PATTERNS.some((pattern) => text.includes(pattern));
35
+ }
36
+
37
+ function createIssue(kind, message, extra = {}) {
38
+ return {
39
+ kind,
40
+ message,
41
+ url: extra.url ?? null,
42
+ status: extra.status ?? null,
43
+ };
44
+ }
45
+
46
+ function pushIssue(issues, issue) {
47
+ const key = JSON.stringify(issue);
48
+ if (!issues.some((existing) => JSON.stringify(existing) === key)) {
49
+ issues.push(issue);
50
+ }
51
+ }
52
+
53
+ function escapeHtmlAttribute(value) {
54
+ return String(value)
55
+ .replaceAll("&", "&")
56
+ .replaceAll('"', """);
57
+ }
58
+
59
+ function buildIframeHarness(url) {
60
+ const escapedUrl = escapeHtmlAttribute(url);
61
+ return `<!doctype html>
62
+ <html>
63
+ <head>
64
+ <meta charset="utf-8">
65
+ <style>
66
+ html, body {
67
+ margin: 0;
68
+ width: 100%;
69
+ height: 100%;
70
+ overflow: hidden;
71
+ background: #fff;
72
+ }
73
+
74
+ iframe {
75
+ display: block;
76
+ width: 100%;
77
+ height: 100%;
78
+ border: 0;
79
+ background: #fff;
80
+ }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
85
+ </body>
86
+ </html>`;
87
+ }
88
+
89
+ function buildResult({ loaded, hasContent, issues, outputPath, url }) {
90
+ return {
91
+ ok: loaded && hasContent && issues.length === 0,
92
+ loaded,
93
+ hasContent,
94
+ url,
95
+ outputPath: outputPath ?? null,
96
+ issues,
97
+ };
98
+ }
99
+
100
+ function normalizeMockCandidates(url) {
101
+ try {
102
+ const parsed = new URL(url);
103
+ return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname];
104
+ } catch {
105
+ return [url];
106
+ }
107
+ }
108
+
109
+ function findHttpMock(httpMocks, request) {
110
+ const method = request.method().toUpperCase();
111
+ const candidates = normalizeMockCandidates(request.url());
112
+ for (const candidate of candidates) {
113
+ const key = `${method} ${candidate}`;
114
+ if (httpMocks[key]) {
115
+ return httpMocks[key];
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ async function attachHttpMocks(page, httpMocks) {
122
+ if (!httpMocks || Object.keys(httpMocks).length === 0) return;
123
+
124
+ await page.route("**/*", async (route) => {
125
+ const mock = findHttpMock(httpMocks, route.request());
126
+ if (!mock) {
127
+ await route.continue();
128
+ return;
129
+ }
130
+
131
+ const headers = { ...(mock.headers || {}) };
132
+ let body;
133
+ if (mock.body !== undefined) {
134
+ body =
135
+ typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body);
136
+ const hasContentType = Object.keys(headers).some(
137
+ (key) => key.toLowerCase() === "content-type",
138
+ );
139
+ if (!hasContentType) {
140
+ headers["content-type"] = "application/json";
141
+ }
142
+ }
143
+
144
+ await route.fulfill({
145
+ status: mock.status || 200,
146
+ headers,
147
+ body,
148
+ });
149
+ });
150
+
151
+ // Disable the in-page fetch mock by returning an empty active-mocks.json.
152
+ // The HTML injects a script that synchronously loads this file and
153
+ // monkey-patches window.fetch, which would bypass Playwright's route
154
+ // interception. This route is registered AFTER **/* so it takes priority
155
+ // (Playwright uses LIFO ordering for route handlers).
156
+ await page.route("**/active-mocks.json", async (route) => {
157
+ await route.fulfill({
158
+ status: 200,
159
+ headers: { "content-type": "application/json" },
160
+ body: "[]",
161
+ });
162
+ });
163
+ }
164
+
165
+ async function collectContentState(target) {
166
+ return target.evaluate(() => {
167
+ const root = document.getElementById("root");
168
+ return {
169
+ bodyTextLength: document.body.innerText.trim().length,
170
+ rootChildCount: root ? root.childElementCount : 0,
171
+ rootTextLength: root ? (root.textContent || "").trim().length : 0,
172
+ };
173
+ });
174
+ }
175
+
176
+ async function waitForStablePage(page, target, timeoutMs = 10000) {
177
+ const started = Date.now();
178
+ let lastHtml = "";
179
+ let stableCount = 0;
180
+
181
+ while (Date.now() - started < timeoutMs) {
182
+ await page.waitForTimeout(500);
183
+
184
+ const pageState = await target.evaluate(() => ({
185
+ bodyText: document.body.innerText,
186
+ html: document.body.innerHTML,
187
+ }));
188
+
189
+ if (
190
+ !hasLoadingMarkers(pageState.bodyText) &&
191
+ pageState.html === lastHtml
192
+ ) {
193
+ stableCount += 1;
194
+ if (stableCount >= 2) return;
195
+ } else {
196
+ stableCount = 0;
197
+ }
198
+
199
+ lastHtml = pageState.html;
200
+ }
201
+ }
202
+
203
+ async function loadScenarioInIframe(page, url) {
204
+ const responsePromise = page
205
+ .waitForResponse(
206
+ (response) =>
207
+ response.request().resourceType() === "document" &&
208
+ response.url() === url,
209
+ { timeout: 30000 },
210
+ )
211
+ .catch(() => null);
212
+
213
+ await page.setContent(buildIframeHarness(url), { waitUntil: "domcontentloaded" });
214
+
215
+ const frameHandle = await page.waitForSelector("#scenario-frame", {
216
+ state: "attached",
217
+ timeout: 30000,
218
+ });
219
+ const frame = await frameHandle.contentFrame();
220
+ if (!frame) {
221
+ throw new Error("Scenario iframe did not attach");
222
+ }
223
+
224
+ await frame.waitForLoadState("load", { timeout: 30000 });
225
+ const response = await responsePromise;
226
+ return { frame, response };
227
+ }
228
+
229
+ async function runScenarioCheck(config) {
230
+ const { url, outputPath, width, height, httpMocks = {} } = config;
231
+ const issues = [];
232
+ const browser = await chromium.launch();
233
+ const context = await browser.newContext({
234
+ viewport: { width: width || 1440, height: height || 900 },
235
+ });
236
+
237
+ // Context-level init script runs in ALL frames (including cross-origin iframes)
238
+ await context.addInitScript(() => {
239
+ window.__codeyamUnhandledRejections = [];
240
+ window.addEventListener("unhandledrejection", (event) => {
241
+ const reason = event.reason;
242
+ const message =
243
+ reason instanceof Error ? reason.message : String(reason);
244
+ window.__codeyamUnhandledRejections.push(message);
245
+ });
246
+
247
+ // Stub WebSocket during capture to prevent terminal reconnection spam.
248
+ // Returns an object that behaves like a closed WebSocket — the terminal
249
+ // will attempt to reconnect a few times then give up silently.
250
+ window.WebSocket = class StubWebSocket {
251
+ static CONNECTING = 0;
252
+ static OPEN = 1;
253
+ static CLOSING = 2;
254
+ static CLOSED = 3;
255
+ readyState = 3;
256
+ onopen = null;
257
+ onclose = null;
258
+ onerror = null;
259
+ onmessage = null;
260
+ send() {}
261
+ close() {}
262
+ addEventListener() {}
263
+ removeEventListener() {}
264
+ dispatchEvent() { return false; }
265
+ constructor() {
266
+ // Fire onerror then onclose on next tick so the terminal sees a failed connection
267
+ setTimeout(() => {
268
+ if (this.onerror) this.onerror(new Event("error"));
269
+ if (this.onclose) this.onclose(new CloseEvent("close"));
270
+ }, 0);
271
+ }
272
+ };
273
+ });
274
+
275
+ const page = await context.newPage();
276
+ await attachHttpMocks(page, httpMocks);
277
+
278
+ page.on("pageerror", (error) => {
279
+ pushIssue(
280
+ issues,
281
+ createIssue("pageerror", error.message || String(error), {
282
+ url: page.url() || url,
283
+ }),
284
+ );
285
+ });
286
+
287
+ page.on("console", (message) => {
288
+ if (message.type() !== "error") return;
289
+ const text = message.text();
290
+ // Ignore known dev-server WebSocket/HMR errors from Vite proxy
291
+ if (
292
+ text.includes("WebSocket connection to") ||
293
+ text.includes("Unsupported Media Type")
294
+ ) {
295
+ return;
296
+ }
297
+ pushIssue(
298
+ issues,
299
+ createIssue("console", text, {
300
+ url: page.url() || url,
301
+ }),
302
+ );
303
+ });
304
+
305
+ page.on("requestfailed", (request) => {
306
+ pushIssue(
307
+ issues,
308
+ createIssue(
309
+ "requestfailed",
310
+ request.failure()?.errorText || "Request failed",
311
+ { url: request.url() },
312
+ ),
313
+ );
314
+ });
315
+
316
+ let loaded = false;
317
+
318
+ try {
319
+ const { frame, response } = await loadScenarioInIframe(page, url);
320
+ loaded = true;
321
+
322
+ if (response && response.status() >= 400) {
323
+ pushIssue(
324
+ issues,
325
+ createIssue("navigation", `Navigation returned HTTP ${response.status()}`, {
326
+ url: response.url(),
327
+ status: response.status(),
328
+ }),
329
+ );
330
+ }
331
+
332
+ await waitForStablePage(page, frame);
333
+
334
+ const rejectionMessages = await frame.evaluate(
335
+ () => window.__codeyamUnhandledRejections || [],
336
+ );
337
+ for (const message of rejectionMessages) {
338
+ pushIssue(
339
+ issues,
340
+ createIssue("unhandledrejection", message, {
341
+ url: page.url() || url,
342
+ }),
343
+ );
344
+ }
345
+
346
+ const contentState = await collectContentState(frame);
347
+ const hasContent = hasRenderableContent(contentState);
348
+
349
+ if (!hasContent) {
350
+ pushIssue(
351
+ issues,
352
+ createIssue("blank", "Page rendered no visible content", {
353
+ url: page.url() || url,
354
+ }),
355
+ );
356
+ }
357
+
358
+ // Check for known error states in the rendered content
359
+ const bodyText = await frame.evaluate(() => document.body.innerText || "");
360
+ if (hasErrorPatterns(bodyText)) {
361
+ pushIssue(
362
+ issues,
363
+ createIssue("error-state", `Page contains error content: ${bodyText.slice(0, 200)}`, {
364
+ url: page.url() || url,
365
+ }),
366
+ );
367
+ }
368
+
369
+ if (outputPath && loaded) {
370
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
371
+ await page.screenshot({ path: outputPath, fullPage: false });
372
+ }
373
+
374
+ return buildResult({
375
+ loaded,
376
+ hasContent,
377
+ issues,
378
+ outputPath,
379
+ url: frame.url() || url,
380
+ });
381
+ } catch (error) {
382
+ pushIssue(
383
+ issues,
384
+ createIssue("navigation", error.message || String(error), { url }),
385
+ );
386
+
387
+ return buildResult({
388
+ loaded,
389
+ hasContent: false,
390
+ issues,
391
+ outputPath,
392
+ url,
393
+ });
394
+ } finally {
395
+ await browser.close();
396
+ }
397
+ }
398
+
399
+ async function main() {
400
+ const config = JSON.parse(process.argv[2] || "{}");
401
+
402
+ if (!config.url) {
403
+ console.error(
404
+ "Usage: node scenario-check.js '{\"url\":\"...\",\"outputPath\":\"...\",\"width\":1440,\"height\":900}'",
405
+ );
406
+ process.exit(1);
407
+ }
408
+
409
+ const result = await runScenarioCheck(config);
410
+ console.log(JSON.stringify(result));
411
+ }
412
+
413
+ module.exports = {
414
+ attachHttpMocks,
415
+ buildResult,
416
+ buildIframeHarness,
417
+ createIssue,
418
+ findHttpMock,
419
+ hasErrorPatterns,
420
+ hasLoadingMarkers,
421
+ hasRenderableContent,
422
+ loadScenarioInIframe,
423
+ normalizeMockCandidates,
424
+ pushIssue,
425
+ runScenarioCheck,
426
+ waitForStablePage,
427
+ };
428
+
429
+ if (require.main === module) {
430
+ main().catch((error) => {
431
+ console.error(error.message || String(error));
432
+ process.exit(1);
433
+ });
434
+ }
package/npm/utils.js ADDED
@@ -0,0 +1,243 @@
1
+ const { execSync, spawn, spawnSync } = require("child_process");
2
+ const net = require("net");
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+
6
+ const rootDir = path.resolve(__dirname, "..");
7
+ const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
8
+
9
+ /** Find the compiled binary — check npm-installed location first, then dev builds. */
10
+ function findBinary() {
11
+ const candidates = [
12
+ path.join(rootDir, "bin", "codeyam-editor"), // npm install (postinstall)
13
+ path.join(rootDir, "target", "debug", "codeyam-editor"), // local dev (debug)
14
+ path.join(rootDir, "target", "release", "codeyam-editor"), // local dev (release)
15
+ ];
16
+
17
+ for (const candidate of candidates) {
18
+ if (fs.existsSync(candidate)) {
19
+ return candidate;
20
+ }
21
+ }
22
+
23
+ return null;
24
+ }
25
+
26
+ /** Return the binary path — build from source only if no pre-built binary exists. */
27
+ function ensureBinary() {
28
+ const existing = findBinary();
29
+ if (existing) return existing;
30
+
31
+ // No pre-built binary; try building from source (local dev workflow).
32
+ try {
33
+ execSync("cargo --version", { stdio: "ignore" });
34
+ } catch {
35
+ console.error(
36
+ "codeyam-editor binary not found. Try reinstalling:\n" +
37
+ " npm install @codeyam-editor/codeyam-editor\n"
38
+ );
39
+ process.exit(1);
40
+ }
41
+
42
+ console.error("Building codeyam-editor...");
43
+ try {
44
+ execSync("cargo build", { cwd: rootDir, stdio: "inherit" });
45
+ } catch {
46
+ console.error("Failed to build. Is Rust installed? https://rustup.rs/");
47
+ process.exit(1);
48
+ }
49
+
50
+ const binary = findBinary();
51
+ if (!binary) {
52
+ console.error("Binary not found after build.");
53
+ process.exit(1);
54
+ }
55
+ return binary;
56
+ }
57
+
58
+ /** Resolve the UI dist directory for the binary to serve. */
59
+ function uiDistDir() {
60
+ return path.join(rootDir, "ui", "dist");
61
+ }
62
+
63
+ /** Try to connect to a specific host:port. Returns true if something is listening. */
64
+ function tryConnect(port, host) {
65
+ return new Promise((resolve) => {
66
+ const socket = new net.Socket();
67
+ socket.setTimeout(300);
68
+ socket.once("connect", () => { socket.destroy(); resolve(true); });
69
+ socket.once("error", () => { socket.destroy(); resolve(false); });
70
+ socket.once("timeout", () => { socket.destroy(); resolve(false); });
71
+ socket.connect(port, host);
72
+ });
73
+ }
74
+
75
+ /** Check if a port is in use (tries both IPv4 and IPv6). */
76
+ async function isPortInUse(port) {
77
+ return (await tryConnect(port, "127.0.0.1")) || (await tryConnect(port, "::1"));
78
+ }
79
+
80
+ /**
81
+ * Poll a port until something is listening, or timeout.
82
+ * Returns true if the port became active, false on timeout.
83
+ */
84
+ async function waitForPort(port, timeoutMs = 10000, intervalMs = 200) {
85
+ const deadline = Date.now() + timeoutMs;
86
+ while (Date.now() < deadline) {
87
+ if (await isPortInUse(port)) return true;
88
+ await new Promise((r) => setTimeout(r, intervalMs));
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Poll until a port stops listening, or timeout.
95
+ * Returns true if the port became inactive, false on timeout.
96
+ */
97
+ async function waitForPortToClose(port, timeoutMs = 10000, intervalMs = 200) {
98
+ const deadline = Date.now() + timeoutMs;
99
+ while (Date.now() < deadline) {
100
+ if (!(await isPortInUse(port))) return true;
101
+ await new Promise((r) => setTimeout(r, intervalMs));
102
+ }
103
+ return false;
104
+ }
105
+
106
+ function readServerState() {
107
+ try {
108
+ return JSON.parse(fs.readFileSync(serverStatePath, "utf8"));
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function clearServerState() {
115
+ try {
116
+ fs.unlinkSync(serverStatePath);
117
+ } catch {}
118
+ }
119
+
120
+ async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
121
+ const controller = new AbortController();
122
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
123
+
124
+ try {
125
+ const resp = await fetch(`http://127.0.0.1:${port}/api/editor-server/shutdown`, {
126
+ method: "POST",
127
+ signal: controller.signal,
128
+ });
129
+ return resp.ok;
130
+ } catch {
131
+ return false;
132
+ } finally {
133
+ clearTimeout(timeout);
134
+ }
135
+ }
136
+
137
+ function tryKillPid(pid) {
138
+ try {
139
+ process.kill(pid, "SIGINT");
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ function findListeningPids(port) {
147
+ if (process.platform === "win32") return [];
148
+
149
+ const result = spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
150
+ encoding: "utf8",
151
+ });
152
+
153
+ if (result.error) return [];
154
+
155
+ return result.stdout
156
+ .split(/\s+/)
157
+ .map((line) => parseInt(line, 10))
158
+ .filter((pid) => Number.isInteger(pid));
159
+ }
160
+
161
+ /** Stop the Rust backend server if it is running. */
162
+ async function stopServer(port = 14199) {
163
+ if (!(await isPortInUse(port))) {
164
+ clearServerState();
165
+ return false;
166
+ }
167
+
168
+ if (await requestServerShutdown(port)) {
169
+ if (await waitForPortToClose(port, 10000, 200)) {
170
+ clearServerState();
171
+ return true;
172
+ }
173
+ }
174
+
175
+ const state = readServerState();
176
+ if (state && Number.isInteger(state.pid) && tryKillPid(state.pid)) {
177
+ if (await waitForPortToClose(port, 5000, 200)) {
178
+ clearServerState();
179
+ return true;
180
+ }
181
+ }
182
+
183
+ for (const pid of findListeningPids(port)) {
184
+ tryKillPid(pid);
185
+ }
186
+
187
+ if (await waitForPortToClose(port, 5000, 200)) {
188
+ clearServerState();
189
+ return true;
190
+ }
191
+
192
+ throw new Error(`Server on port ${port} did not stop cleanly`);
193
+ }
194
+
195
+ /** Stop any existing server on the port, then start a fresh one. */
196
+ async function restartServer(port = 14199) {
197
+ if (await isPortInUse(port)) {
198
+ console.error(`Stopping existing server on port ${port}...`);
199
+ await stopServer(port);
200
+ }
201
+ return ensureServer(port);
202
+ }
203
+
204
+ /** Start the Rust backend server if not already running. Returns the child process (or null if already running). */
205
+ async function ensureServer(port = 14199) {
206
+ if (await isPortInUse(port)) {
207
+ console.error(`Server already running on port ${port}`);
208
+ return null;
209
+ }
210
+
211
+ const binary = ensureBinary();
212
+ const child = spawn(binary, ["start", "--no-open", "--port", String(port)], {
213
+ stdio: "inherit",
214
+ cwd: process.cwd(),
215
+ detached: false,
216
+ env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() },
217
+ });
218
+
219
+ const ready = await waitForPort(port);
220
+ if (!ready) {
221
+ console.error("Server failed to start within 10 seconds");
222
+ child.kill();
223
+ process.exit(1);
224
+ }
225
+
226
+ return child;
227
+ }
228
+
229
+ module.exports = {
230
+ rootDir,
231
+ serverStatePath,
232
+ findBinary,
233
+ ensureBinary,
234
+ uiDistDir,
235
+ tryConnect,
236
+ isPortInUse,
237
+ waitForPort,
238
+ waitForPortToClose,
239
+ readServerState,
240
+ stopServer,
241
+ restartServer,
242
+ ensureServer,
243
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@codeyam-editor/codeyam-editor",
3
+ "version": "0.1.0-staging.36d3518",
4
+ "description": "Language-agnostic managed execution sandbox for scenario-driven development",
5
+ "bin": {
6
+ "codeyam-editor": "./npm/cli.js"
7
+ },
8
+ "scripts": {
9
+ "postinstall": "node ./npm/postinstall.js"
10
+ },
11
+ "dependencies": {
12
+ "playwright": "^1.58.2"
13
+ },
14
+ "keywords": [
15
+ "codeyam",
16
+ "editor",
17
+ "development",
18
+ "scenarios"
19
+ ],
20
+ "homepage": "https://codeyam.com/",
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=20.0.0"
24
+ },
25
+ "files": [
26
+ "npm/",
27
+ "bin/",
28
+ "ui/"
29
+ ]
30
+ }