@modularcloud/cspec 0.2.0 → 0.3.0

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/dist/cli.d.ts CHANGED
@@ -2,5 +2,10 @@ export interface CliIo {
2
2
  stdout: Pick<NodeJS.WriteStream, "write">;
3
3
  stderr: Pick<NodeJS.WriteStream, "write">;
4
4
  }
5
- export declare function runCli(args: string[], io?: CliIo): Promise<void>;
5
+ export interface CliRuntime {
6
+ appRoot?: string;
7
+ openBrowser?: (url: string) => Promise<void> | void;
8
+ workspaceRoot?: string;
9
+ }
10
+ export declare function runCli(args: string[], io?: CliIo, runtime?: CliRuntime): Promise<void>;
6
11
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;IAC1C,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;CAC3C;AAED,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAE,KAA0D,GAAG,OAAO,CAAC,IAAI,CAAC,CA6B1H"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;IAC1C,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACpD,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,wBAAsB,MAAM,CAC1B,IAAI,EAAE,MAAM,EAAE,EACd,EAAE,GAAE,KAA0D,EAC9D,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,IAAI,CAAC,CAiCf"}
package/dist/cli.js CHANGED
@@ -1,7 +1,10 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import { fileURLToPath } from "node:url";
1
5
  import path from "node:path";
2
- import { formatError } from "./errors.js";
3
- import { assertFresh, loadWorkspace, writeOutputs } from "./workspace.js";
4
- export async function runCli(args, io = { stdout: process.stdout, stderr: process.stderr }) {
6
+ import { assertFresh, discoverFiles, formatError, loadConfig, loadWorkspace, writeOutputs } from "@modularcloud/cspec-core";
7
+ export async function runCli(args, io = { stdout: process.stdout, stderr: process.stderr }, runtime = {}) {
5
8
  const [command = "build", ...rest] = args;
6
9
  try {
7
10
  if (command === "build") {
@@ -21,8 +24,12 @@ export async function runCli(args, io = { stdout: process.stdout, stderr: proces
21
24
  io.stdout.write(formatIds(workspace, rest));
22
25
  return;
23
26
  }
27
+ if (command === "editor" || command === "edit") {
28
+ await launchEditor(rest, io, runtime);
29
+ return;
30
+ }
24
31
  if (command === "--help" || command === "-h") {
25
- io.stdout.write("Usage: cspec <build|check|ids> [--tree|--json]\n");
32
+ io.stdout.write("Usage: cspec <build|check|ids|editor> [options]\n");
26
33
  return;
27
34
  }
28
35
  throw new Error(`Unknown command "${command}".`);
@@ -32,6 +39,322 @@ export async function runCli(args, io = { stdout: process.stdout, stderr: proces
32
39
  process.exitCode = 1;
33
40
  }
34
41
  }
42
+ async function launchEditor(args, io, runtime) {
43
+ const options = parseEditorOptions(args);
44
+ if (options.help) {
45
+ io.stdout.write([
46
+ "Usage: cspec editor [--host <host>] [--port <port>] [--no-open]",
47
+ "",
48
+ "Launches the bundled cspec hybrid editor.",
49
+ "",
50
+ "Options:",
51
+ " --host <host> Hostname to bind. Defaults to 127.0.0.1.",
52
+ " --port <port> Port to bind. Defaults to 5173 and retries upward.",
53
+ " --no-open Print the URL without opening a browser.",
54
+ ""
55
+ ].join("\n"));
56
+ return;
57
+ }
58
+ const appRoot = runtime.appRoot ?? path.join(path.dirname(fileURLToPath(import.meta.url)), "editor");
59
+ if (!fs.existsSync(path.join(appRoot, "index.html"))) {
60
+ throw new Error("Bundled editor assets are missing. Rebuild the cspec package before running `cspec editor`.");
61
+ }
62
+ const workspaceRoot = path.resolve(runtime.workspaceRoot ?? process.cwd());
63
+ let editorOrigin = "";
64
+ const server = http.createServer((request, response) => {
65
+ if (isEditorApiRequest(request)) {
66
+ void serveEditorApi(workspaceRoot, editorOrigin, request, response);
67
+ return;
68
+ }
69
+ serveEditorAsset(appRoot, request, response);
70
+ });
71
+ const port = await listen(server, options.host, options.port);
72
+ const url = `http://${urlHost(options.host)}:${port}/`;
73
+ editorOrigin = url.slice(0, -1);
74
+ io.stdout.write(`cspec editor running at ${url}\n`);
75
+ io.stdout.write("Press Ctrl+C to stop.\n");
76
+ if (options.open) {
77
+ await (runtime.openBrowser ?? openBrowser)(url);
78
+ }
79
+ }
80
+ function isEditorApiRequest(request) {
81
+ return new URL(request.url ?? "/", "http://localhost").pathname.startsWith("/__cspec/");
82
+ }
83
+ async function serveEditorApi(workspaceRoot, editorOrigin, request, response) {
84
+ response.setHeader("Cache-Control", "no-store");
85
+ const method = request.method ?? "GET";
86
+ const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
87
+ if (!isSameOriginRequest(request, editorOrigin)) {
88
+ writeJson(response, 403, { ok: false, error: "Forbidden" });
89
+ return;
90
+ }
91
+ try {
92
+ if (method === "GET" && pathname === "/__cspec/workspace") {
93
+ const config = await loadConfig(workspaceRoot);
94
+ const files = discoverFiles(workspaceRoot, config.specs).map((file) => ({
95
+ file: normalizeSlashes(path.relative(workspaceRoot, file)),
96
+ source: fs.readFileSync(file, "utf8")
97
+ }));
98
+ writeJson(response, 200, {
99
+ ok: true,
100
+ root: workspaceRoot,
101
+ files
102
+ });
103
+ return;
104
+ }
105
+ if (method === "POST" && pathname === "/__cspec/files") {
106
+ const body = await readJsonBody(request);
107
+ const file = objectString(body, "file");
108
+ const source = objectString(body, "source");
109
+ if (!file.endsWith(".mdx"))
110
+ throw new Error("Only .mdx source files can be saved through this endpoint.");
111
+ const target = workspacePath(workspaceRoot, file);
112
+ fs.mkdirSync(path.dirname(target), { recursive: true });
113
+ fs.writeFileSync(target, source);
114
+ writeJson(response, 200, { ok: true, file });
115
+ return;
116
+ }
117
+ if (method === "POST" && pathname === "/__cspec/outputs") {
118
+ const body = await readJsonBody(request);
119
+ const outputs = objectArray(body, "outputs");
120
+ for (const output of outputs) {
121
+ const file = objectString(output, "file");
122
+ const content = objectString(output, "content");
123
+ if (!file.endsWith(".cspec.ts") && !file.endsWith(".md")) {
124
+ throw new Error(`Refusing to write unsupported output "${file}".`);
125
+ }
126
+ const target = workspacePath(workspaceRoot, file);
127
+ fs.mkdirSync(path.dirname(target), { recursive: true });
128
+ fs.writeFileSync(target, content);
129
+ }
130
+ writeJson(response, 200, { ok: true, count: outputs.length });
131
+ return;
132
+ }
133
+ writeJson(response, 404, { ok: false, error: "Not found" });
134
+ }
135
+ catch (error) {
136
+ const message = error instanceof Error ? error.message : String(error);
137
+ writeJson(response, 400, { ok: false, error: message });
138
+ }
139
+ }
140
+ function isSameOriginRequest(request, editorOrigin) {
141
+ const origin = request.headers.origin;
142
+ return !origin || !editorOrigin || origin === editorOrigin;
143
+ }
144
+ async function readJsonBody(request) {
145
+ let body = "";
146
+ for await (const chunk of request) {
147
+ body += chunk;
148
+ if (body.length > 20 * 1024 * 1024)
149
+ throw new Error("Request body is too large.");
150
+ }
151
+ try {
152
+ return JSON.parse(body);
153
+ }
154
+ catch {
155
+ throw new Error("Request body must be valid JSON.");
156
+ }
157
+ }
158
+ function objectString(value, key) {
159
+ if (!isRecord(value) || typeof value[key] !== "string") {
160
+ throw new Error(`Missing string field "${key}".`);
161
+ }
162
+ return value[key];
163
+ }
164
+ function objectArray(value, key) {
165
+ if (!isRecord(value) || !Array.isArray(value[key])) {
166
+ throw new Error(`Missing array field "${key}".`);
167
+ }
168
+ return value[key];
169
+ }
170
+ function isRecord(value) {
171
+ return typeof value === "object" && value !== null;
172
+ }
173
+ function workspacePath(workspaceRoot, file) {
174
+ if (path.isAbsolute(file) || file.includes("\0"))
175
+ throw new Error(`Invalid workspace path "${file}".`);
176
+ const target = path.resolve(workspaceRoot, file);
177
+ if (!isInside(workspaceRoot, target))
178
+ throw new Error(`Path escapes workspace: ${file}`);
179
+ return target;
180
+ }
181
+ function normalizeSlashes(value) {
182
+ return value.split(path.sep).join("/");
183
+ }
184
+ function writeJson(response, status, value) {
185
+ response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
186
+ response.end(JSON.stringify(value));
187
+ }
188
+ function parseEditorOptions(args) {
189
+ const options = {
190
+ help: false,
191
+ host: "127.0.0.1",
192
+ open: true,
193
+ port: 5173
194
+ };
195
+ for (let index = 0; index < args.length; index += 1) {
196
+ const arg = args[index];
197
+ if (arg === "--help" || arg === "-h") {
198
+ options.help = true;
199
+ continue;
200
+ }
201
+ if (arg === "--no-open") {
202
+ options.open = false;
203
+ continue;
204
+ }
205
+ if (arg === "--open") {
206
+ options.open = true;
207
+ continue;
208
+ }
209
+ if (arg === "--host") {
210
+ options.host = requiredValue(args, index, arg);
211
+ index += 1;
212
+ continue;
213
+ }
214
+ if (arg?.startsWith("--host=")) {
215
+ options.host = arg.slice("--host=".length);
216
+ continue;
217
+ }
218
+ if (arg === "--port") {
219
+ options.port = parsePort(requiredValue(args, index, arg));
220
+ index += 1;
221
+ continue;
222
+ }
223
+ if (arg?.startsWith("--port=")) {
224
+ options.port = parsePort(arg.slice("--port=".length));
225
+ continue;
226
+ }
227
+ throw new Error(`Unknown editor option "${arg}".`);
228
+ }
229
+ return options;
230
+ }
231
+ function requiredValue(args, index, name) {
232
+ const value = args[index + 1];
233
+ if (!value || value.startsWith("-"))
234
+ throw new Error(`${name} requires a value.`);
235
+ return value;
236
+ }
237
+ function parsePort(value) {
238
+ const port = Number(value);
239
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
240
+ throw new Error(`Invalid port "${value}".`);
241
+ }
242
+ return port;
243
+ }
244
+ async function listen(server, host, startPort) {
245
+ let port = startPort;
246
+ while (port <= 65535) {
247
+ try {
248
+ await new Promise((resolve, reject) => {
249
+ const onError = (error) => {
250
+ server.off("listening", onListening);
251
+ reject(error);
252
+ };
253
+ const onListening = () => {
254
+ server.off("error", onError);
255
+ resolve();
256
+ };
257
+ server.once("error", onError);
258
+ server.once("listening", onListening);
259
+ server.listen(port, host);
260
+ });
261
+ const address = server.address();
262
+ return typeof address === "object" && address ? address.port : port;
263
+ }
264
+ catch (error) {
265
+ const err = error;
266
+ if (startPort !== 0 && err.code === "EADDRINUSE") {
267
+ port += 1;
268
+ continue;
269
+ }
270
+ throw error;
271
+ }
272
+ }
273
+ throw new Error(`No available port found at or above ${startPort}.`);
274
+ }
275
+ function serveEditorAsset(appRoot, request, response) {
276
+ const method = request.method ?? "GET";
277
+ if (method !== "GET" && method !== "HEAD") {
278
+ response.writeHead(405, { Allow: "GET, HEAD" });
279
+ response.end();
280
+ return;
281
+ }
282
+ let pathname;
283
+ try {
284
+ pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
285
+ }
286
+ catch {
287
+ response.writeHead(400);
288
+ response.end("Bad request");
289
+ return;
290
+ }
291
+ const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
292
+ const filePath = path.resolve(appRoot, relative);
293
+ if (!isInside(appRoot, filePath)) {
294
+ response.writeHead(403);
295
+ response.end("Forbidden");
296
+ return;
297
+ }
298
+ fs.readFile(filePath, (error, content) => {
299
+ if (error) {
300
+ if (error.code === "ENOENT" && !path.extname(pathname)) {
301
+ serveIndex(appRoot, response, method);
302
+ return;
303
+ }
304
+ response.writeHead(error.code === "ENOENT" ? 404 : 500);
305
+ response.end(error.code === "ENOENT" ? "Not found" : "Server error");
306
+ return;
307
+ }
308
+ response.writeHead(200, { "Content-Type": contentType(filePath) });
309
+ response.end(method === "HEAD" ? undefined : content);
310
+ });
311
+ }
312
+ function serveIndex(appRoot, response, method) {
313
+ fs.readFile(path.join(appRoot, "index.html"), (error, content) => {
314
+ if (error) {
315
+ response.writeHead(500);
316
+ response.end("Server error");
317
+ return;
318
+ }
319
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
320
+ response.end(method === "HEAD" ? undefined : content);
321
+ });
322
+ }
323
+ function isInside(root, filePath) {
324
+ const relative = path.relative(path.resolve(root), filePath);
325
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
326
+ }
327
+ function contentType(file) {
328
+ const type = {
329
+ ".css": "text/css; charset=utf-8",
330
+ ".html": "text/html; charset=utf-8",
331
+ ".ico": "image/x-icon",
332
+ ".js": "text/javascript; charset=utf-8",
333
+ ".json": "application/json; charset=utf-8",
334
+ ".map": "application/json; charset=utf-8",
335
+ ".png": "image/png",
336
+ ".svg": "image/svg+xml",
337
+ ".wasm": "application/wasm"
338
+ }[path.extname(file)];
339
+ return type ?? "application/octet-stream";
340
+ }
341
+ function urlHost(host) {
342
+ if (host === "0.0.0.0" || host === "::")
343
+ return "127.0.0.1";
344
+ return host.includes(":") ? `[${host}]` : host;
345
+ }
346
+ function openBrowser(url) {
347
+ const platform = process.platform;
348
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
349
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
350
+ const child = spawn(command, args, {
351
+ detached: true,
352
+ stdio: "ignore",
353
+ windowsHide: true
354
+ });
355
+ child.on("error", () => { });
356
+ child.unref();
357
+ }
35
358
  function formatIds(workspace, args) {
36
359
  if (args.includes("--json")) {
37
360
  return `${JSON.stringify({
@@ -0,0 +1 @@
1
+ :root{color:#1f2933;background:#f4f6f8;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}body{margin:0}button,input{font:inherit}.app{display:grid;grid-template-columns:260px minmax(0,1fr);height:100vh;overflow:hidden}.sidebar{display:grid;grid-template-rows:auto auto minmax(0,1fr);border-right:1px solid #d9e1e8;background:#fff;min-width:0}.sidebarHeader,.toolbar,.tabs,.statusbar,.paneHeader{display:flex;align-items:center}.sidebarHeader{justify-content:space-between;padding:10px;border-bottom:1px solid #e5ebf0}.iconButton{display:grid;place-items:center;width:32px;height:32px;border:1px solid #ccd7e0;border-radius:6px;background:#fff;color:#31475c;cursor:pointer}.filter{display:flex;align-items:center;gap:8px;margin:10px;padding:8px 9px;border:1px solid #d8e1e8;border-radius:6px;background:#f8fafc}.filter input{min-width:0;width:100%;border:0;outline:0;background:transparent}.fileTree{overflow:auto;padding:4px 8px 10px}.fileItem{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:8px;width:100%;min-height:34px;border:0;border-radius:6px;background:transparent;color:#2a3a48;cursor:pointer;text-align:left}.fileItem span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fileItem.active{background:#e8f1ff;color:#17345f}.dot{width:8px;height:8px;border-radius:50%;background:#e49b23}.errorIcon{color:#c24141}.workspace{display:grid;grid-template-rows:auto minmax(0,1fr) 190px auto;min-width:0;min-height:0}.toolbar{justify-content:space-between;gap:16px;height:50px;padding:0 14px;border-bottom:1px solid #d9e1e8;background:#fff}.titleCluster,.toolbarActions{display:flex;align-items:center;gap:8px;min-width:0}.titleCluster strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toolbarActions button,.tabs button{display:inline-flex;align-items:center;gap:7px;min-height:32px;border:1px solid #ccd7e0;border-radius:6px;background:#fff;color:#263947;cursor:pointer}.toolbarActions button{padding:0 10px}.editorGrid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(300px,.75fr);min-width:0;min-height:0}.editorPane,.previewPane{min-width:0;min-height:0;overflow:hidden}.editorPane{border-right:1px solid #d9e1e8;background:#fff}.cmHost{height:100%}.previewPane{display:grid;grid-template-rows:auto minmax(0,1fr);background:#fbfcfd}.paneHeader{gap:8px;height:38px;padding:0 12px;border-bottom:1px solid #e3e9ef;color:#435566;font-size:13px;font-weight:700}.previewPane pre,.buildOutput{margin:0;padding:14px;overflow:auto;white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:13px;line-height:1.5}.emptyState{display:flex;align-items:center;gap:8px;padding:14px;color:#8a4a18}.bottomPanel{display:grid;grid-template-rows:auto minmax(0,1fr);border-top:1px solid #d9e1e8;background:#fff;min-height:0}.tabs{gap:6px;padding:8px 10px;border-bottom:1px solid #e3e9ef}.tabs button{padding:0 9px}.tabs button.selected{background:#eef5ff;border-color:#9dbbe0;color:#17345f}.panelList,.referenceGrid{overflow:auto;padding:8px 10px}.panelList button,.referenceGrid button{display:grid;grid-template-columns:auto minmax(0,1fr) minmax(0,1.25fr);align-items:center;gap:9px;width:100%;min-height:32px;border:0;border-radius:6px;background:transparent;text-align:left;cursor:pointer}.panelList button:hover,.referenceGrid button:hover{background:#f1f5f9}.panelList p{margin:8px 2px;color:#536575}.referenceGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:4px 10px}.referenceGrid button{grid-template-columns:auto minmax(0,1fr)}.referenceGrid small{grid-column:2;color:#697b8d}.statusbar{gap:14px;min-height:28px;padding:0 12px;border-top:1px solid #d9e1e8;background:#263947;color:#ecf3f8;font-size:12px}@media(max-width:900px){.app{grid-template-columns:210px minmax(0,1fr)}.editorGrid{grid-template-columns:1fr}.previewPane{display:none}.toolbar{align-items:stretch;flex-direction:column;height:auto;padding:8px}.toolbarActions{flex-wrap:wrap}}