@statelyai/sdk 0.4.1 → 0.5.1

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.mjs CHANGED
@@ -1,8 +1,651 @@
1
1
  #!/usr/bin/env node
2
2
  import { planSync, pullSync } from "./sync.mjs";
3
+ import fs from "node:fs/promises";
4
+ import * as path$1 from "node:path";
3
5
  import path from "node:path";
6
+ import fs$1, { watch } from "node:fs";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
8
  import { Args, Command, Flags, flush, handle, run as run$1 } from "@oclif/core";
9
+ import * as crypto from "node:crypto";
10
+ import { spawn } from "node:child_process";
11
+ import * as http from "node:http";
5
12
 
13
+ //#region src/cliHost.ts
14
+ const DEFAULT_SYNC_MAX_FILES = 150;
15
+ const DEFAULT_SYNC_MAX_BYTES = 2e6;
16
+ const supportedDocumentPattern = /\.(?:c|m)?(?:jsx?|tsx?)$|\.jsonc?$|\.ya?ml$|\.mmd$|\.mermaid$/i;
17
+ var WebSocketClient = class {
18
+ buffer = Buffer.alloc(0);
19
+ listeners = /* @__PURE__ */ new Set();
20
+ constructor(socket) {
21
+ this.socket = socket;
22
+ socket.on("data", (chunk) => {
23
+ const nextChunk = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
24
+ this.buffer = Buffer.concat([this.buffer, nextChunk]);
25
+ this.readFrames();
26
+ });
27
+ }
28
+ onMessage(listener) {
29
+ this.listeners.add(listener);
30
+ }
31
+ send(message) {
32
+ this.socket.write(encodeWebSocketFrame(JSON.stringify(message)));
33
+ }
34
+ close() {
35
+ this.socket.end();
36
+ }
37
+ readFrames() {
38
+ while (this.buffer.length >= 2) {
39
+ const first = this.buffer[0];
40
+ const second = this.buffer[1];
41
+ const opcode = first & 15;
42
+ const masked = (second & 128) !== 0;
43
+ let length = second & 127;
44
+ let offset = 2;
45
+ if (length === 126) {
46
+ if (this.buffer.length < offset + 2) return;
47
+ length = this.buffer.readUInt16BE(offset);
48
+ offset += 2;
49
+ } else if (length === 127) {
50
+ if (this.buffer.length < offset + 8) return;
51
+ const bigLength = this.buffer.readBigUInt64BE(offset);
52
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
53
+ this.socket.destroy(/* @__PURE__ */ new Error("WebSocket frame is too large."));
54
+ return;
55
+ }
56
+ length = Number(bigLength);
57
+ offset += 8;
58
+ }
59
+ const maskLength = masked ? 4 : 0;
60
+ if (this.buffer.length < offset + maskLength + length) return;
61
+ const mask = masked ? this.buffer.subarray(offset, offset + 4) : void 0;
62
+ offset += maskLength;
63
+ const payload = Buffer.from(this.buffer.subarray(offset, offset + length));
64
+ this.buffer = this.buffer.subarray(offset + length);
65
+ if (mask) for (let index = 0; index < payload.length; index++) payload[index] = payload[index] ^ mask[index % 4];
66
+ if (opcode === 8) {
67
+ this.close();
68
+ return;
69
+ }
70
+ if (opcode === 9) {
71
+ this.socket.write(encodeWebSocketFrame(payload, 10));
72
+ continue;
73
+ }
74
+ if (opcode !== 1) continue;
75
+ const message = JSON.parse(payload.toString("utf8"));
76
+ for (const listener of this.listeners) listener(message);
77
+ }
78
+ }
79
+ };
80
+ var RemoteEditorSession = class {
81
+ currentGraph;
82
+ lastPersistedGraph;
83
+ currentSourceLocations;
84
+ currentFormat;
85
+ ready = false;
86
+ pendingMessages = [];
87
+ pendingRetrievals = /* @__PURE__ */ new Map();
88
+ retrieveRequestCount = 0;
89
+ watchedSourceUris = /* @__PURE__ */ new Set();
90
+ selfEditCount = 0;
91
+ watcher;
92
+ constructor(options, sendMessage) {
93
+ this.options = options;
94
+ this.sendMessage = sendMessage;
95
+ this.watcher = watchWorkspace(this.options.rootDir, (uri) => {
96
+ this.handleSavedDocument(uri);
97
+ });
98
+ }
99
+ dispose() {
100
+ for (const pending of this.pendingRetrievals.values()) pending.reject(/* @__PURE__ */ new Error("Editor session disposed before export completed."));
101
+ this.pendingRetrievals.clear();
102
+ this.watcher?.dispose();
103
+ }
104
+ async receiveMessage(msg) {
105
+ this.log("recv", msg.type, msg);
106
+ switch (msg.type) {
107
+ case "@statelyai.ready":
108
+ await this.initializeEditor();
109
+ break;
110
+ case "@statelyai.loaded":
111
+ this.currentGraph = msg.graph;
112
+ this.lastPersistedGraph = msg.graph;
113
+ this.currentSourceLocations = msg.sourceLocations ?? this.currentSourceLocations;
114
+ this.updateSourceLocations(this.currentSourceLocations);
115
+ break;
116
+ case "@statelyai.change":
117
+ this.currentGraph = msg.graph;
118
+ this.currentSourceLocations = msg.sourceLocations ?? this.currentSourceLocations;
119
+ this.updateSourceLocations(this.currentSourceLocations);
120
+ break;
121
+ case "@statelyai.save":
122
+ await this.saveFromEditor(msg);
123
+ break;
124
+ case "@statelyai.retrieved":
125
+ this.resolveRetrievedSource(msg);
126
+ break;
127
+ case "@statelyai.clipboardRead":
128
+ this.postMessage({
129
+ type: "@statelyai.clipboardReadResponse",
130
+ requestId: msg.requestId,
131
+ text: ""
132
+ });
133
+ break;
134
+ case "@statelyai.error":
135
+ this.handleErrorMessage(msg);
136
+ break;
137
+ }
138
+ }
139
+ async initializeEditor() {
140
+ this.ready = true;
141
+ try {
142
+ const update = await this.parseRootDocument();
143
+ this.currentFormat = update.format;
144
+ this.currentSourceLocations = update.sourceLocations;
145
+ this.updateSourceLocations(update.sourceLocations);
146
+ this.postMessage({
147
+ type: "@statelyai.init",
148
+ machine: update.machine,
149
+ format: update.format,
150
+ sourceLocations: update.sourceLocations,
151
+ mode: "editing",
152
+ theme: "light",
153
+ unsavedIndicator: { enabled: true },
154
+ leftPanels: [],
155
+ rightPanels: [],
156
+ activePanels: []
157
+ });
158
+ } catch (error) {
159
+ this.showError(formatError(error, "Failed to initialize the visual editor."));
160
+ return;
161
+ }
162
+ for (const pending of this.pendingMessages) this.sendMessage(pending);
163
+ this.pendingMessages = [];
164
+ }
165
+ async handleSavedDocument(uri) {
166
+ if (uri !== this.options.rootUri && !this.watchedSourceUris.has(uri)) return;
167
+ if (this.selfEditCount > 0) return;
168
+ try {
169
+ const update = await this.parseRootDocument();
170
+ this.currentFormat = update.format;
171
+ this.currentSourceLocations = update.sourceLocations;
172
+ this.updateSourceLocations(update.sourceLocations);
173
+ this.postMessage({
174
+ type: "@statelyai.update",
175
+ machine: update.machine,
176
+ format: update.format,
177
+ sourceLocations: update.sourceLocations
178
+ });
179
+ } catch (error) {
180
+ this.showError(formatError(error, "Failed to read the saved document."));
181
+ }
182
+ }
183
+ async saveFromEditor(msg) {
184
+ const validationErrors = getValidationErrors(msg.validations);
185
+ if (validationErrors.length > 0) {
186
+ const message = formatValidationErrorMessage(validationErrors);
187
+ this.postMessage({
188
+ type: "@statelyai.toast",
189
+ message,
190
+ toastType: "error"
191
+ });
192
+ this.showError(message);
193
+ return;
194
+ }
195
+ this.currentSourceLocations = msg.sourceLocations ?? this.currentSourceLocations;
196
+ this.updateSourceLocations(this.currentSourceLocations);
197
+ try {
198
+ const serialized = await this.getSerializedPayloadForCurrentFormat();
199
+ const documents = await collectSourceDocuments({
200
+ rootFileName: this.options.fileName,
201
+ rootDir: this.options.rootDir,
202
+ extraUris: sourceLocationUris(this.currentSourceLocations)
203
+ });
204
+ const response = await this.postSync("/api/editor-sync/apply", {
205
+ rootUri: this.options.rootUri,
206
+ rootFileName: this.options.fileName,
207
+ rootDir: this.options.rootDir,
208
+ documents,
209
+ machineConfig: msg.machineConfig,
210
+ patches: msg.patches,
211
+ prevGraph: this.lastPersistedGraph ?? this.currentGraph,
212
+ nextGraph: msg.graph,
213
+ sourceLocations: this.currentSourceLocations,
214
+ serialized
215
+ });
216
+ if (response.replacements.length === 0) {
217
+ const message = "Unable to apply Viz changes to the source file automatically.";
218
+ this.postMessage({
219
+ type: "@statelyai.toast",
220
+ message,
221
+ toastType: "error"
222
+ });
223
+ this.showError(message);
224
+ return;
225
+ }
226
+ await this.runWithSelfEdit(async () => {
227
+ await applyFileReplacements(response.replacements, this.options.rootUri);
228
+ });
229
+ this.currentGraph = msg.graph;
230
+ this.lastPersistedGraph = msg.graph;
231
+ } catch (error) {
232
+ const message = formatError(error, "Failed to apply Viz changes.");
233
+ this.postMessage({
234
+ type: "@statelyai.toast",
235
+ message,
236
+ toastType: "error"
237
+ });
238
+ this.showError(message);
239
+ }
240
+ }
241
+ async parseRootDocument() {
242
+ const documents = await collectSourceDocuments({
243
+ rootFileName: this.options.fileName,
244
+ rootDir: this.options.rootDir,
245
+ extraUris: sourceLocationUris(this.currentSourceLocations)
246
+ });
247
+ return this.postSync("/api/editor-sync/parse", {
248
+ rootUri: this.options.rootUri,
249
+ rootFileName: this.options.fileName,
250
+ rootDir: this.options.rootDir,
251
+ documents
252
+ });
253
+ }
254
+ async postSync(pathname, body) {
255
+ const url = new URL(pathname, normalizedBaseUrl(this.options.editorUrl));
256
+ const headers = { "Content-Type": "application/json" };
257
+ if (this.options.apiKey) headers.Authorization = `Bearer ${this.options.apiKey}`;
258
+ const response = await fetch(url, {
259
+ method: "POST",
260
+ headers,
261
+ body: JSON.stringify(body)
262
+ });
263
+ const data = await response.json().catch(() => null);
264
+ if (!response.ok) {
265
+ const message = data && typeof data === "object" && "error" in data && typeof data.error === "string" ? data.error : `HTTP ${response.status}`;
266
+ throw new Error(message);
267
+ }
268
+ return data;
269
+ }
270
+ async getSerializedPayloadForCurrentFormat() {
271
+ const format = this.currentFormat;
272
+ if (!format || format === "xstate") return;
273
+ try {
274
+ const serialized = await this.requestSerialized(format);
275
+ return { [format]: serialized };
276
+ } catch {
277
+ return;
278
+ }
279
+ }
280
+ requestSerialized(format) {
281
+ const requestId = `retrieve-${++this.retrieveRequestCount}`;
282
+ return new Promise((resolve, reject) => {
283
+ this.pendingRetrievals.set(requestId, {
284
+ resolve,
285
+ reject
286
+ });
287
+ this.postMessage({
288
+ type: "@statelyai.retrieve",
289
+ requestId,
290
+ format
291
+ });
292
+ });
293
+ }
294
+ resolveRetrievedSource(msg) {
295
+ const pending = this.pendingRetrievals.get(msg.requestId);
296
+ if (!pending) return;
297
+ this.pendingRetrievals.delete(msg.requestId);
298
+ const serialized = typeof msg.data === "string" ? msg.data : JSON.stringify(msg.data, null, 2);
299
+ pending.resolve(serialized);
300
+ }
301
+ handleErrorMessage(msg) {
302
+ if (msg.requestId) {
303
+ const pending = this.pendingRetrievals.get(msg.requestId);
304
+ if (pending) {
305
+ this.pendingRetrievals.delete(msg.requestId);
306
+ pending.reject(new Error(msg.message));
307
+ return;
308
+ }
309
+ }
310
+ this.showError(msg.message);
311
+ }
312
+ updateSourceLocations(sourceLocations) {
313
+ this.watchedSourceUris.clear();
314
+ for (const location of sourceLocations?.states ?? []) if (location.uri !== this.options.rootUri) this.watchedSourceUris.add(location.uri);
315
+ }
316
+ async runWithSelfEdit(operation) {
317
+ this.selfEditCount++;
318
+ try {
319
+ return await operation();
320
+ } finally {
321
+ this.selfEditCount--;
322
+ }
323
+ }
324
+ postMessage(msg) {
325
+ this.log("send", msg.type, msg);
326
+ if (!this.ready) {
327
+ this.pendingMessages.push(msg);
328
+ return;
329
+ }
330
+ this.sendMessage(msg);
331
+ }
332
+ showError(message) {
333
+ console.error(`Stately Editor: ${message}`);
334
+ }
335
+ log(direction, type, payload) {
336
+ if (this.options.debug) console.error(`[${direction}] ${type}`, payload ?? "");
337
+ }
338
+ };
339
+ async function openEditor(options) {
340
+ const fileName = path$1.resolve(options.fileName);
341
+ await fs.access(fileName);
342
+ const rootUri = fileNameToUri(fileName);
343
+ const rootDir = path$1.dirname(fileName);
344
+ let activeClient;
345
+ let session;
346
+ const server = http.createServer((request, response) => {
347
+ if (new URL(request.url ?? "/", `http://${request.headers.host}`).pathname !== "/") {
348
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
349
+ response.end("Not found");
350
+ return;
351
+ }
352
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
353
+ response.end(getCliWebviewContent({
354
+ editorUrl: options.editorUrl,
355
+ apiKey: options.apiKey
356
+ }));
357
+ });
358
+ server.on("upgrade", (request, socket) => {
359
+ if (request.url !== "/ws") {
360
+ socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
361
+ return;
362
+ }
363
+ const key = request.headers["sec-websocket-key"];
364
+ if (typeof key !== "string") {
365
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
366
+ return;
367
+ }
368
+ const accept = crypto.createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
369
+ socket.write([
370
+ "HTTP/1.1 101 Switching Protocols",
371
+ "Upgrade: websocket",
372
+ "Connection: Upgrade",
373
+ `Sec-WebSocket-Accept: ${accept}`,
374
+ "\r\n"
375
+ ].join("\r\n"));
376
+ activeClient?.close();
377
+ session?.dispose();
378
+ activeClient = new WebSocketClient(socket);
379
+ session = new RemoteEditorSession({
380
+ ...options,
381
+ fileName,
382
+ rootUri,
383
+ rootDir
384
+ }, (message) => activeClient?.send(message));
385
+ activeClient.onMessage((message) => {
386
+ session?.receiveMessage(message);
387
+ });
388
+ });
389
+ await listen(server, options.port, options.host);
390
+ const address = server.address();
391
+ const port = typeof address === "object" && address ? address.port : options.port;
392
+ const editorPageUrl = `http://${options.host}:${port}`;
393
+ console.log(`Stately visual editor: ${editorPageUrl}`);
394
+ console.log(`Editing ${fileName}`);
395
+ if (options.shouldOpen) openBrowser(editorPageUrl);
396
+ const shutdown = () => {
397
+ session?.dispose();
398
+ activeClient?.close();
399
+ server.close();
400
+ };
401
+ process.once("SIGINT", () => {
402
+ shutdown();
403
+ process.exit(0);
404
+ });
405
+ process.once("SIGTERM", () => {
406
+ shutdown();
407
+ process.exit(0);
408
+ });
409
+ }
410
+ async function collectSourceDocuments(options) {
411
+ const files = new Set([options.rootFileName]);
412
+ const maxFiles = Number.parseInt(process.env.STATELY_SYNC_MAX_FILES ?? "", 10) || DEFAULT_SYNC_MAX_FILES;
413
+ const maxBytes = Number.parseInt(process.env.STATELY_SYNC_MAX_BYTES ?? "", 10) || DEFAULT_SYNC_MAX_BYTES;
414
+ for (const uri of options.extraUris ?? []) files.add(sourceUriToFileName(uri));
415
+ await collectDirectoryFiles(options.rootDir, files, maxFiles);
416
+ const documents = [];
417
+ let totalBytes = 0;
418
+ for (const fileName of files) {
419
+ if (!isSupportedDocument(fileName)) continue;
420
+ const text = await fs.readFile(fileName, "utf8").catch(() => null);
421
+ if (text === null) continue;
422
+ totalBytes += Buffer.byteLength(text);
423
+ if (totalBytes > maxBytes) throw new Error(`Source payload exceeds ${maxBytes} bytes.`);
424
+ documents.push({
425
+ uri: fileNameToUri(fileName),
426
+ fileName,
427
+ text
428
+ });
429
+ }
430
+ return documents;
431
+ }
432
+ async function collectDirectoryFiles(rootDir, files, maxFiles) {
433
+ const queue = [rootDir];
434
+ while (queue.length > 0 && files.size < maxFiles) {
435
+ const dir = queue.shift();
436
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
437
+ for (const entry of entries) {
438
+ if (files.size >= maxFiles) break;
439
+ if (entry.name.startsWith(".") || shouldSkipDirectory(entry.name)) continue;
440
+ const fileName = path$1.join(dir, entry.name);
441
+ if (entry.isDirectory()) queue.push(fileName);
442
+ else if (entry.isFile() && isSupportedDocument(fileName)) files.add(fileName);
443
+ }
444
+ }
445
+ }
446
+ function shouldSkipDirectory(name) {
447
+ return [
448
+ "node_modules",
449
+ "dist",
450
+ "out",
451
+ "build",
452
+ "coverage",
453
+ ".git"
454
+ ].includes(name);
455
+ }
456
+ function isSupportedDocument(fileName) {
457
+ return supportedDocumentPattern.test(fileName);
458
+ }
459
+ async function applyFileReplacements(replacements, defaultUri) {
460
+ const replacementsByUri = /* @__PURE__ */ new Map();
461
+ for (const replacement of replacements) {
462
+ const uri = replacement.uri ?? defaultUri;
463
+ const existing = replacementsByUri.get(uri) ?? [];
464
+ existing.push(replacement);
465
+ replacementsByUri.set(uri, existing);
466
+ }
467
+ for (const [uri, uriReplacements] of replacementsByUri) {
468
+ const fileName = sourceUriToFileName(uri);
469
+ const nextText = applyReplacementsToText(await fs.readFile(fileName, "utf8"), uriReplacements);
470
+ await fs.writeFile(fileName, nextText, "utf8");
471
+ }
472
+ }
473
+ function applyReplacementsToText(text, replacements) {
474
+ let updated = text;
475
+ const sorted = [...replacements].sort(compareReplacementRangesDescending);
476
+ for (const replacement of sorted) {
477
+ const lineStarts = getLineStarts(updated);
478
+ const start = offsetAt(lineStarts, replacement.range.startLine, replacement.range.startChar);
479
+ const end = offsetAt(lineStarts, replacement.range.endLine, replacement.range.endChar);
480
+ updated = `${updated.slice(0, start)}${replacement.newText}${updated.slice(end)}`;
481
+ }
482
+ return updated;
483
+ }
484
+ function watchWorkspace(rootDir, listener) {
485
+ let watcher;
486
+ const timers = /* @__PURE__ */ new Map();
487
+ const queue = (fileName) => {
488
+ const existing = timers.get(fileName);
489
+ if (existing) clearTimeout(existing);
490
+ timers.set(fileName, setTimeout(() => {
491
+ timers.delete(fileName);
492
+ listener(fileNameToUri(fileName));
493
+ }, 75));
494
+ };
495
+ try {
496
+ watcher = watch(rootDir, { recursive: true }, (_event, fileName) => {
497
+ if (fileName) queue(path$1.join(rootDir, fileName.toString()));
498
+ });
499
+ } catch {
500
+ watcher = watch(rootDir, (_event, fileName) => {
501
+ if (fileName) queue(path$1.join(rootDir, fileName.toString()));
502
+ });
503
+ }
504
+ return { dispose() {
505
+ watcher?.close();
506
+ for (const timer of timers.values()) clearTimeout(timer);
507
+ timers.clear();
508
+ } };
509
+ }
510
+ function getCliWebviewContent(options) {
511
+ const baseUrl = normalizedBaseUrl(options.editorUrl);
512
+ const url = new URL(`${baseUrl}/embed`);
513
+ if (options.apiKey) url.searchParams.set("api_key", options.apiKey);
514
+ return `<!DOCTYPE html>
515
+ <html lang="en" style="height:100%;margin:0">
516
+ <head>
517
+ <meta charset="UTF-8">
518
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
519
+ <style>
520
+ body { margin: 0; padding: 0; height: 100vh; overflow: hidden; }
521
+ iframe { border: 0; width: 100%; height: 100%; display: block; }
522
+ </style>
523
+ </head>
524
+ <body>
525
+ <iframe
526
+ id="stately-editor"
527
+ src="${escapeAttribute(url.toString())}"
528
+ allow="clipboard-read; clipboard-write"
529
+ ></iframe>
530
+ <script>
531
+ const iframe = document.getElementById('stately-editor');
532
+ const socket = new WebSocket(
533
+ (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws'
534
+ );
535
+ const pending = [];
536
+
537
+ function sendToCli(message) {
538
+ if (socket.readyState === WebSocket.OPEN) {
539
+ socket.send(JSON.stringify(message));
540
+ return;
541
+ }
542
+ pending.push(message);
543
+ }
544
+
545
+ socket.addEventListener('open', () => {
546
+ while (pending.length > 0) {
547
+ socket.send(JSON.stringify(pending.shift()));
548
+ }
549
+ });
550
+
551
+ socket.addEventListener('message', (event) => {
552
+ iframe.contentWindow?.postMessage(JSON.parse(event.data), '*');
553
+ });
554
+
555
+ window.addEventListener('message', (event) => {
556
+ if (!event.data?.type?.startsWith?.('@statelyai.')) {
557
+ return;
558
+ }
559
+ sendToCli(event.data);
560
+ });
561
+ <\/script>
562
+ </body>
563
+ </html>`;
564
+ }
565
+ function encodeWebSocketFrame(payload, opcode = 1) {
566
+ const data = typeof payload === "string" ? Buffer.from(payload) : payload;
567
+ const headerLength = data.length < 126 ? 2 : data.length <= 65535 ? 4 : 10;
568
+ const frame = Buffer.alloc(headerLength + data.length);
569
+ frame[0] = 128 | opcode;
570
+ if (data.length < 126) {
571
+ frame[1] = data.length;
572
+ data.copy(frame, 2);
573
+ } else if (data.length <= 65535) {
574
+ frame[1] = 126;
575
+ frame.writeUInt16BE(data.length, 2);
576
+ data.copy(frame, 4);
577
+ } else {
578
+ frame[1] = 127;
579
+ frame.writeBigUInt64BE(BigInt(data.length), 2);
580
+ data.copy(frame, 10);
581
+ }
582
+ return frame;
583
+ }
584
+ function listen(server, port, host) {
585
+ return new Promise((resolve) => {
586
+ server.listen(port, host, resolve);
587
+ });
588
+ }
589
+ function openBrowser(url) {
590
+ spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open", process.platform === "win32" ? [
591
+ "/c",
592
+ "start",
593
+ "",
594
+ url
595
+ ] : [url], {
596
+ detached: true,
597
+ stdio: "ignore"
598
+ }).unref();
599
+ }
600
+ function getValidationErrors(validations) {
601
+ return (validations ?? []).filter((validation) => validation.level === "error");
602
+ }
603
+ function formatValidationErrorMessage(errors) {
604
+ const [firstError] = errors;
605
+ if (!firstError) return "Cannot save Viz changes because the graph has validation errors.";
606
+ if (errors.length === 1) return `Cannot save Viz changes: ${firstError.message}`;
607
+ return `Cannot save Viz changes: ${errors.length} validation errors. First: ${firstError.message}`;
608
+ }
609
+ function sourceLocationUris(sourceLocations) {
610
+ return [
611
+ ...sourceLocations?.root ? [sourceLocations.root.uri] : [],
612
+ ...(sourceLocations?.states ?? []).map((state) => state.uri),
613
+ ...(sourceLocations?.staticValues ?? []).map((value) => value.uri)
614
+ ];
615
+ }
616
+ function fileNameToUri(fileName) {
617
+ return pathToFileURL(path$1.resolve(fileName)).toString();
618
+ }
619
+ function sourceUriToFileName(uriOrPath) {
620
+ if (uriOrPath.startsWith("file://")) return fileURLToPath(uriOrPath);
621
+ if (/^[a-z][a-z\d+.-]*:/i.test(uriOrPath)) throw new Error(`Unsupported non-file URI: ${uriOrPath}`);
622
+ return path$1.resolve(uriOrPath);
623
+ }
624
+ function getLineStarts(text) {
625
+ const lineStarts = [0];
626
+ for (let index = 0; index < text.length; index += 1) if (text.charCodeAt(index) === 10) lineStarts.push(index + 1);
627
+ return lineStarts;
628
+ }
629
+ function offsetAt(lineStarts, line, character) {
630
+ const lineStart = lineStarts[line];
631
+ if (lineStart === void 0) return lineStarts[lineStarts.length - 1] + character;
632
+ return lineStart + character;
633
+ }
634
+ function compareReplacementRangesDescending(left, right) {
635
+ if (left.range.startLine !== right.range.startLine) return right.range.startLine - left.range.startLine;
636
+ return right.range.startChar - left.range.startChar;
637
+ }
638
+ function normalizedBaseUrl(value) {
639
+ return value.replace(/\/+$/, "");
640
+ }
641
+ function formatError(error, fallback) {
642
+ return error instanceof Error ? error.message : fallback;
643
+ }
644
+ function escapeAttribute(value) {
645
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
646
+ }
647
+
648
+ //#endregion
6
649
  //#region src/cli.ts
7
650
  function loadLocalEnv() {
8
651
  if (typeof process.loadEnvFile !== "function") return;
@@ -12,20 +655,23 @@ function loadLocalEnv() {
12
655
  } catch {}
13
656
  }
14
657
  loadLocalEnv();
658
+ function getDefaultApiKey() {
659
+ return process.env.STATELY_API_KEY ?? process.env.NEXT_PUBLIC_STATELY_API_KEY;
660
+ }
661
+ function getDefaultBaseUrl() {
662
+ return process.env.STATELY_API_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_STATELY_API_URL;
663
+ }
664
+ function getDefaultEditorUrl() {
665
+ return process.env.STATELY_EDITOR_URL ?? process.env.NEXT_PUBLIC_BETA_EDITOR_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3001";
666
+ }
15
667
  const sharedFlags = {
16
668
  help: Flags.help({ char: "h" }),
17
669
  "fail-on-changes": Flags.boolean({
18
670
  default: false,
19
671
  description: "Exit with a nonzero code when differences are found"
20
672
  }),
21
- "api-key": Flags.string({
22
- description: "Stately API key used for remote source or target resolution",
23
- default: process.env.STATELY_API_KEY ?? process.env.NEXT_PUBLIC_STATELY_API_KEY
24
- }),
25
- "base-url": Flags.string({
26
- description: "Base URL for Stately Studio or a self-hosted deployment",
27
- default: process.env.STATELY_API_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_STATELY_API_URL
28
- })
673
+ "api-key": Flags.string({ description: "Stately API key used for remote source or target resolution" }),
674
+ "base-url": Flags.string({ description: "Base URL for Stately Studio or a self-hosted deployment" })
29
675
  };
30
676
  function formatChangeList(label, items) {
31
677
  if (items.length === 0) return null;
@@ -82,8 +728,8 @@ var PlanCommand = class PlanCommand extends ParsedSyncCommand {
82
728
  const plan = await planSync({
83
729
  source: args.source,
84
730
  target: args.target,
85
- apiKey: flags["api-key"],
86
- baseUrl: flags["base-url"]
731
+ apiKey: flags["api-key"] ?? getDefaultApiKey(),
732
+ baseUrl: flags["base-url"] ?? getDefaultBaseUrl()
87
733
  });
88
734
  this.log(formatPlanSummary(plan));
89
735
  this.exit(flags["fail-on-changes"] && plan.summary.hasChanges ? 1 : 0);
@@ -98,8 +744,8 @@ var DiffCommand = class DiffCommand extends ParsedSyncCommand {
98
744
  const plan = await planSync({
99
745
  source: args.source,
100
746
  target: args.target,
101
- apiKey: flags["api-key"],
102
- baseUrl: flags["base-url"]
747
+ apiKey: flags["api-key"] ?? getDefaultApiKey(),
748
+ baseUrl: flags["base-url"] ?? getDefaultBaseUrl()
103
749
  });
104
750
  this.log(formatPlanSummary(plan));
105
751
  this.exit(flags["fail-on-changes"] && plan.summary.hasChanges ? 1 : 0);
@@ -114,27 +760,82 @@ var PullCommand = class PullCommand extends ParsedSyncCommand {
114
760
  const result = await pullSync({
115
761
  source: args.source,
116
762
  target: args.target,
117
- apiKey: flags["api-key"],
118
- baseUrl: flags["base-url"]
763
+ apiKey: flags["api-key"] ?? getDefaultApiKey(),
764
+ baseUrl: flags["base-url"] ?? getDefaultBaseUrl()
119
765
  });
120
766
  this.log(`Pulled: ${result.source.locator} -> ${result.outputPath}\nTarget: ${result.target.kind} (${result.target.format})`);
121
767
  }
122
768
  };
769
+ var OpenCommand = class OpenCommand extends Command {
770
+ static enableJsonFlag = false;
771
+ static summary = "Open a local file in the Stately visual editor.";
772
+ static description = "Starts a local browser-backed editor session. Saved file changes update the visual editor, and saved visual edits are written back to the source file.";
773
+ static args = { file: Args.string({
774
+ required: true,
775
+ description: "Local source file to edit visually."
776
+ }) };
777
+ static flags = {
778
+ help: Flags.help({ char: "h" }),
779
+ "api-key": Flags.string({ description: "Stately API key used when the editor server requires auth" }),
780
+ "editor-url": Flags.string({ description: "Base URL for the Stately editor embed" }),
781
+ host: Flags.string({
782
+ description: "Local server host",
783
+ default: "127.0.0.1"
784
+ }),
785
+ port: Flags.integer({
786
+ description: "Local server port. Defaults to a random available port.",
787
+ default: 0,
788
+ min: 0
789
+ }),
790
+ open: Flags.boolean({
791
+ description: "Open the local editor URL in the default browser",
792
+ default: true,
793
+ allowNo: true
794
+ }),
795
+ debug: Flags.boolean({
796
+ description: "Log editor protocol messages",
797
+ default: false
798
+ })
799
+ };
800
+ async run() {
801
+ const { args, flags } = await this.parse(OpenCommand);
802
+ await openEditor({
803
+ fileName: path.resolve(args.file),
804
+ editorUrl: flags["editor-url"] ?? getDefaultEditorUrl(),
805
+ host: flags.host,
806
+ port: flags.port,
807
+ shouldOpen: flags.open,
808
+ apiKey: flags["api-key"] ?? getDefaultApiKey(),
809
+ debug: flags.debug
810
+ });
811
+ }
812
+ };
123
813
  const COMMANDS = {
124
814
  plan: PlanCommand,
125
815
  diff: DiffCommand,
126
- pull: PullCommand
816
+ pull: PullCommand,
817
+ open: OpenCommand
127
818
  };
128
- async function run(argv = process.argv.slice(2)) {
819
+ async function run(argv = process.argv.slice(2), entryUrl = import.meta.url) {
129
820
  const normalizedArgv = argv.length === 1 && argv[0] === "-h" ? ["--help"] : argv;
130
821
  try {
131
- await run$1(normalizedArgv, import.meta.url);
822
+ await run$1(normalizedArgv, entryUrl);
132
823
  await flush();
133
824
  } catch (error) {
134
825
  await handle(error);
135
826
  }
136
827
  }
137
- if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) run();
828
+ function isDirectExecution() {
829
+ const entryPath = process.argv[1];
830
+ if (!entryPath) return false;
831
+ const modulePath = fileURLToPath(import.meta.url);
832
+ try {
833
+ return fs$1.realpathSync(entryPath) === fs$1.realpathSync(modulePath);
834
+ } catch {
835
+ return path.resolve(entryPath) === modulePath;
836
+ }
837
+ }
838
+ if (isDirectExecution()) run();
138
839
 
139
840
  //#endregion
140
841
  export { COMMANDS, formatPlanSummary, run };