@statelyai/sdk 0.5.0 → 0.6.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.mjs CHANGED
@@ -1,12 +1,965 @@
1
1
  #!/usr/bin/env node
2
+ import { createStatelyClient } from "./studio.mjs";
3
+ import "./graphToXStateTS-CvXM8wHL.mjs";
2
4
  import { planSync, pullSync } from "./sync.mjs";
5
+ import fs from "node:fs/promises";
6
+ import * as path$1 from "node:path";
3
7
  import path from "node:path";
4
- import fs from "node:fs";
5
- import { fileURLToPath } from "node:url";
8
+ import { execFile, execFileSync, spawn } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ import fs$1, { promises, watch } from "node:fs";
11
+ import { createInterface } from "node:readline/promises";
12
+ import { Writable } from "node:stream";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
14
  import { Args, Command, Flags, flush, handle, run as run$1 } from "@oclif/core";
7
- import { openEditor } from "@statelyai/editor-sync/cli-host";
15
+ import * as crypto from "node:crypto";
16
+ import * as http from "node:http";
17
+ import * as https from "node:https";
18
+ import os from "node:os";
8
19
 
20
+ //#region src/cliHost.ts
21
+ const DEFAULT_SYNC_MAX_FILES = 150;
22
+ const DEFAULT_SYNC_MAX_BYTES = 2e6;
23
+ const supportedDocumentPattern = /\.(?:c|m)?(?:jsx?|tsx?)$|\.jsonc?$|\.ya?ml$|\.mmd$|\.mermaid$/i;
24
+ var WebSocketClient = class {
25
+ buffer = Buffer.alloc(0);
26
+ listeners = /* @__PURE__ */ new Set();
27
+ constructor(socket) {
28
+ this.socket = socket;
29
+ socket.on("data", (chunk) => {
30
+ const nextChunk = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
31
+ this.buffer = Buffer.concat([this.buffer, nextChunk]);
32
+ this.readFrames();
33
+ });
34
+ }
35
+ onMessage(listener) {
36
+ this.listeners.add(listener);
37
+ }
38
+ send(message) {
39
+ this.socket.write(encodeWebSocketFrame(JSON.stringify(message)));
40
+ }
41
+ close() {
42
+ this.socket.end();
43
+ }
44
+ readFrames() {
45
+ while (this.buffer.length >= 2) {
46
+ const first = this.buffer[0];
47
+ const second = this.buffer[1];
48
+ const opcode = first & 15;
49
+ const masked = (second & 128) !== 0;
50
+ let length = second & 127;
51
+ let offset = 2;
52
+ if (length === 126) {
53
+ if (this.buffer.length < offset + 2) return;
54
+ length = this.buffer.readUInt16BE(offset);
55
+ offset += 2;
56
+ } else if (length === 127) {
57
+ if (this.buffer.length < offset + 8) return;
58
+ const bigLength = this.buffer.readBigUInt64BE(offset);
59
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
60
+ this.socket.destroy(/* @__PURE__ */ new Error("WebSocket frame is too large."));
61
+ return;
62
+ }
63
+ length = Number(bigLength);
64
+ offset += 8;
65
+ }
66
+ const maskLength = masked ? 4 : 0;
67
+ if (this.buffer.length < offset + maskLength + length) return;
68
+ const mask = masked ? this.buffer.subarray(offset, offset + 4) : void 0;
69
+ offset += maskLength;
70
+ const payload = Buffer.from(this.buffer.subarray(offset, offset + length));
71
+ this.buffer = this.buffer.subarray(offset + length);
72
+ if (mask) for (let index = 0; index < payload.length; index++) payload[index] = payload[index] ^ mask[index % 4];
73
+ if (opcode === 8) {
74
+ this.close();
75
+ return;
76
+ }
77
+ if (opcode === 9) {
78
+ this.socket.write(encodeWebSocketFrame(payload, 10));
79
+ continue;
80
+ }
81
+ if (opcode !== 1) continue;
82
+ const message = JSON.parse(payload.toString("utf8"));
83
+ for (const listener of this.listeners) listener(message);
84
+ }
85
+ }
86
+ };
87
+ var RemoteEditorSession = class {
88
+ currentGraph;
89
+ lastPersistedGraph;
90
+ currentSourceLocations;
91
+ currentFormat;
92
+ ready = false;
93
+ pendingMessages = [];
94
+ pendingRetrievals = /* @__PURE__ */ new Map();
95
+ retrieveRequestCount = 0;
96
+ watchedSourceUris = /* @__PURE__ */ new Set();
97
+ selfEditCount = 0;
98
+ watcher;
99
+ constructor(options, sendMessage) {
100
+ this.options = options;
101
+ this.sendMessage = sendMessage;
102
+ this.watcher = watchWorkspace(this.options.rootDir, (uri) => {
103
+ this.handleSavedDocument(uri);
104
+ });
105
+ }
106
+ dispose() {
107
+ for (const pending of this.pendingRetrievals.values()) pending.reject(/* @__PURE__ */ new Error("Editor session disposed before export completed."));
108
+ this.pendingRetrievals.clear();
109
+ this.watcher?.dispose();
110
+ }
111
+ async receiveMessage(msg) {
112
+ this.log("recv", msg.type, msg);
113
+ switch (msg.type) {
114
+ case "@statelyai.ready":
115
+ await this.initializeEditor();
116
+ break;
117
+ case "@statelyai.loaded":
118
+ this.currentGraph = msg.graph;
119
+ this.lastPersistedGraph = msg.graph;
120
+ this.currentSourceLocations = msg.sourceLocations ?? this.currentSourceLocations;
121
+ this.updateSourceLocations(this.currentSourceLocations);
122
+ break;
123
+ case "@statelyai.change":
124
+ this.currentGraph = msg.graph;
125
+ this.currentSourceLocations = msg.sourceLocations ?? this.currentSourceLocations;
126
+ this.updateSourceLocations(this.currentSourceLocations);
127
+ break;
128
+ case "@statelyai.save":
129
+ await this.saveFromEditor(msg);
130
+ break;
131
+ case "@statelyai.retrieved":
132
+ this.resolveRetrievedSource(msg);
133
+ break;
134
+ case "@statelyai.clipboardRead":
135
+ this.postMessage({
136
+ type: "@statelyai.clipboardReadResponse",
137
+ requestId: msg.requestId,
138
+ text: ""
139
+ });
140
+ break;
141
+ case "@statelyai.error":
142
+ this.handleErrorMessage(msg);
143
+ break;
144
+ }
145
+ }
146
+ async initializeEditor() {
147
+ this.ready = true;
148
+ try {
149
+ const update = await this.parseRootDocument();
150
+ this.currentFormat = update.format;
151
+ this.currentSourceLocations = update.sourceLocations;
152
+ this.updateSourceLocations(update.sourceLocations);
153
+ this.postMessage({
154
+ type: "@statelyai.init",
155
+ machine: update.machine,
156
+ format: update.format,
157
+ sourceLocations: update.sourceLocations,
158
+ mode: "editing",
159
+ theme: "light",
160
+ unsavedIndicator: { enabled: true },
161
+ leftPanels: [],
162
+ rightPanels: [],
163
+ activePanels: []
164
+ });
165
+ } catch (error) {
166
+ this.showError(formatError(error, "Failed to initialize the visual editor."));
167
+ return;
168
+ }
169
+ for (const pending of this.pendingMessages) this.sendMessage(pending);
170
+ this.pendingMessages = [];
171
+ }
172
+ async handleSavedDocument(uri) {
173
+ if (uri !== this.options.rootUri && !this.watchedSourceUris.has(uri)) return;
174
+ if (this.selfEditCount > 0) return;
175
+ try {
176
+ const update = await this.parseRootDocument();
177
+ this.currentFormat = update.format;
178
+ this.currentSourceLocations = update.sourceLocations;
179
+ this.updateSourceLocations(update.sourceLocations);
180
+ this.postMessage({
181
+ type: "@statelyai.update",
182
+ machine: update.machine,
183
+ format: update.format,
184
+ sourceLocations: update.sourceLocations
185
+ });
186
+ } catch (error) {
187
+ this.showError(formatError(error, "Failed to read the saved document."));
188
+ }
189
+ }
190
+ async saveFromEditor(msg) {
191
+ const validationErrors = getValidationErrors(msg.validations);
192
+ if (validationErrors.length > 0) {
193
+ const message = formatValidationErrorMessage(validationErrors);
194
+ this.postMessage({
195
+ type: "@statelyai.toast",
196
+ message,
197
+ toastType: "error"
198
+ });
199
+ this.showError(message);
200
+ return;
201
+ }
202
+ this.currentSourceLocations = msg.sourceLocations ?? this.currentSourceLocations;
203
+ this.updateSourceLocations(this.currentSourceLocations);
204
+ try {
205
+ const serialized = await this.getSerializedPayloadForCurrentFormat();
206
+ const documents = await collectSourceDocuments({
207
+ rootFileName: this.options.fileName,
208
+ rootDir: this.options.rootDir,
209
+ extraUris: sourceLocationUris(this.currentSourceLocations)
210
+ });
211
+ const response = await this.postSync("/api/editor-sync/apply", {
212
+ rootUri: this.options.rootUri,
213
+ rootFileName: this.options.fileName,
214
+ rootDir: this.options.rootDir,
215
+ documents,
216
+ machineConfig: msg.machineConfig,
217
+ patches: msg.patches,
218
+ prevGraph: this.lastPersistedGraph ?? this.currentGraph,
219
+ nextGraph: msg.graph,
220
+ sourceLocations: this.currentSourceLocations,
221
+ serialized
222
+ });
223
+ if (response.replacements.length === 0) {
224
+ const message = "Unable to apply Viz changes to the source file automatically.";
225
+ this.postMessage({
226
+ type: "@statelyai.toast",
227
+ message,
228
+ toastType: "error"
229
+ });
230
+ this.showError(message);
231
+ return;
232
+ }
233
+ await this.runWithSelfEdit(async () => {
234
+ await applyFileReplacements(response.replacements, this.options.rootUri);
235
+ });
236
+ this.currentGraph = msg.graph;
237
+ this.lastPersistedGraph = msg.graph;
238
+ } catch (error) {
239
+ const message = formatError(error, "Failed to apply Viz changes.");
240
+ this.postMessage({
241
+ type: "@statelyai.toast",
242
+ message,
243
+ toastType: "error"
244
+ });
245
+ this.showError(message);
246
+ }
247
+ }
248
+ async parseRootDocument() {
249
+ const documents = await collectSourceDocuments({
250
+ rootFileName: this.options.fileName,
251
+ rootDir: this.options.rootDir,
252
+ extraUris: sourceLocationUris(this.currentSourceLocations)
253
+ });
254
+ return this.postSync("/api/editor-sync/parse", {
255
+ rootUri: this.options.rootUri,
256
+ rootFileName: this.options.fileName,
257
+ rootDir: this.options.rootDir,
258
+ documents
259
+ });
260
+ }
261
+ async postSync(pathname, body) {
262
+ const url = new URL(pathname, normalizedBaseUrl(this.options.editorUrl));
263
+ const headers = { "Content-Type": "application/json" };
264
+ if (this.options.apiKey) headers.Authorization = `Bearer ${this.options.apiKey}`;
265
+ const response = await fetchEditorHost(url, {
266
+ method: "POST",
267
+ headers,
268
+ body: JSON.stringify(body)
269
+ });
270
+ const data = await response.json().catch(() => null);
271
+ if (!response.ok) {
272
+ const message = data && typeof data === "object" && "error" in data && typeof data.error === "string" ? data.error : `HTTP ${response.status}`;
273
+ throw new Error(message);
274
+ }
275
+ return data;
276
+ }
277
+ async getSerializedPayloadForCurrentFormat() {
278
+ const format = this.currentFormat;
279
+ if (!format || format === "xstate") return;
280
+ try {
281
+ const serialized = await this.requestSerialized(format);
282
+ return { [format]: serialized };
283
+ } catch {
284
+ return;
285
+ }
286
+ }
287
+ requestSerialized(format) {
288
+ const requestId = `retrieve-${++this.retrieveRequestCount}`;
289
+ return new Promise((resolve, reject) => {
290
+ this.pendingRetrievals.set(requestId, {
291
+ resolve,
292
+ reject
293
+ });
294
+ this.postMessage({
295
+ type: "@statelyai.retrieve",
296
+ requestId,
297
+ format
298
+ });
299
+ });
300
+ }
301
+ resolveRetrievedSource(msg) {
302
+ const pending = this.pendingRetrievals.get(msg.requestId);
303
+ if (!pending) return;
304
+ this.pendingRetrievals.delete(msg.requestId);
305
+ const serialized = typeof msg.data === "string" ? msg.data : JSON.stringify(msg.data, null, 2);
306
+ pending.resolve(serialized);
307
+ }
308
+ handleErrorMessage(msg) {
309
+ if (msg.requestId) {
310
+ const pending = this.pendingRetrievals.get(msg.requestId);
311
+ if (pending) {
312
+ this.pendingRetrievals.delete(msg.requestId);
313
+ pending.reject(new Error(msg.message));
314
+ return;
315
+ }
316
+ }
317
+ this.showError(msg.message);
318
+ }
319
+ updateSourceLocations(sourceLocations) {
320
+ this.watchedSourceUris.clear();
321
+ for (const location of sourceLocations?.states ?? []) if (location.uri !== this.options.rootUri) this.watchedSourceUris.add(location.uri);
322
+ }
323
+ async runWithSelfEdit(operation) {
324
+ this.selfEditCount++;
325
+ try {
326
+ return await operation();
327
+ } finally {
328
+ this.selfEditCount--;
329
+ }
330
+ }
331
+ postMessage(msg) {
332
+ this.log("send", msg.type, msg);
333
+ if (!this.ready) {
334
+ this.pendingMessages.push(msg);
335
+ return;
336
+ }
337
+ this.sendMessage(msg);
338
+ }
339
+ showError(message) {
340
+ console.error(`Stately Editor: ${message}`);
341
+ }
342
+ log(direction, type, payload) {
343
+ if (this.options.debug) console.error(`[${direction}] ${type}`, payload ?? "");
344
+ }
345
+ };
346
+ async function openEditor(options) {
347
+ const fileName = path$1.resolve(options.fileName);
348
+ await fs.access(fileName);
349
+ await assertEditorHostAvailable(options.editorUrl);
350
+ const rootUri = fileNameToUri(fileName);
351
+ const rootDir = path$1.dirname(fileName);
352
+ let activeClient;
353
+ let session;
354
+ const server = http.createServer((request, response) => {
355
+ if (new URL(request.url ?? "/", `http://${request.headers.host}`).pathname !== "/") {
356
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
357
+ response.end("Not found");
358
+ return;
359
+ }
360
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
361
+ response.end(getCliWebviewContent({
362
+ editorUrl: options.editorUrl,
363
+ apiKey: options.apiKey
364
+ }));
365
+ });
366
+ server.on("upgrade", (request, socket) => {
367
+ if (request.url !== "/ws") {
368
+ socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
369
+ return;
370
+ }
371
+ const key = request.headers["sec-websocket-key"];
372
+ if (typeof key !== "string") {
373
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
374
+ return;
375
+ }
376
+ const accept = crypto.createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
377
+ socket.write([
378
+ "HTTP/1.1 101 Switching Protocols",
379
+ "Upgrade: websocket",
380
+ "Connection: Upgrade",
381
+ `Sec-WebSocket-Accept: ${accept}`,
382
+ "\r\n"
383
+ ].join("\r\n"));
384
+ activeClient?.close();
385
+ session?.dispose();
386
+ activeClient = new WebSocketClient(socket);
387
+ session = new RemoteEditorSession({
388
+ ...options,
389
+ fileName,
390
+ rootUri,
391
+ rootDir
392
+ }, (message) => activeClient?.send(message));
393
+ activeClient.onMessage((message) => {
394
+ session?.receiveMessage(message);
395
+ });
396
+ });
397
+ await listen(server, options.port, options.host);
398
+ const address = server.address();
399
+ const port = typeof address === "object" && address ? address.port : options.port;
400
+ const editorPageUrl = `http://${options.host}:${port}`;
401
+ console.log(`Stately visual editor: ${editorPageUrl}`);
402
+ console.log(`Editing ${fileName}`);
403
+ if (options.shouldOpen) openBrowser(editorPageUrl);
404
+ const shutdown = () => {
405
+ session?.dispose();
406
+ activeClient?.close();
407
+ server.close();
408
+ };
409
+ process.once("SIGINT", () => {
410
+ shutdown();
411
+ process.exit(0);
412
+ });
413
+ process.once("SIGTERM", () => {
414
+ shutdown();
415
+ process.exit(0);
416
+ });
417
+ }
418
+ async function collectSourceDocuments(options) {
419
+ const files = new Set([options.rootFileName]);
420
+ const maxFiles = Number.parseInt(process.env.STATELY_SYNC_MAX_FILES ?? "", 10) || DEFAULT_SYNC_MAX_FILES;
421
+ const maxBytes = Number.parseInt(process.env.STATELY_SYNC_MAX_BYTES ?? "", 10) || DEFAULT_SYNC_MAX_BYTES;
422
+ for (const uri of options.extraUris ?? []) files.add(sourceUriToFileName(uri));
423
+ await collectDirectoryFiles(options.rootDir, files, maxFiles);
424
+ const documents = [];
425
+ let totalBytes = 0;
426
+ for (const fileName of files) {
427
+ if (!isSupportedDocument(fileName)) continue;
428
+ const text = await fs.readFile(fileName, "utf8").catch(() => null);
429
+ if (text === null) continue;
430
+ totalBytes += Buffer.byteLength(text);
431
+ if (totalBytes > maxBytes) throw new Error(`Source payload exceeds ${maxBytes} bytes.`);
432
+ documents.push({
433
+ uri: fileNameToUri(fileName),
434
+ fileName,
435
+ text
436
+ });
437
+ }
438
+ return documents;
439
+ }
440
+ async function collectDirectoryFiles(rootDir, files, maxFiles) {
441
+ const queue = [rootDir];
442
+ while (queue.length > 0 && files.size < maxFiles) {
443
+ const dir = queue.shift();
444
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
445
+ for (const entry of entries) {
446
+ if (files.size >= maxFiles) break;
447
+ if (entry.name.startsWith(".") || shouldSkipDirectory(entry.name)) continue;
448
+ const fileName = path$1.join(dir, entry.name);
449
+ if (entry.isDirectory()) queue.push(fileName);
450
+ else if (entry.isFile() && isSupportedDocument(fileName)) files.add(fileName);
451
+ }
452
+ }
453
+ }
454
+ function shouldSkipDirectory(name) {
455
+ return [
456
+ "node_modules",
457
+ "dist",
458
+ "out",
459
+ "build",
460
+ "coverage",
461
+ ".git"
462
+ ].includes(name);
463
+ }
464
+ function isSupportedDocument(fileName) {
465
+ return supportedDocumentPattern.test(fileName);
466
+ }
467
+ async function applyFileReplacements(replacements, defaultUri) {
468
+ const replacementsByUri = /* @__PURE__ */ new Map();
469
+ for (const replacement of replacements) {
470
+ const uri = replacement.uri ?? defaultUri;
471
+ const existing = replacementsByUri.get(uri) ?? [];
472
+ existing.push(replacement);
473
+ replacementsByUri.set(uri, existing);
474
+ }
475
+ for (const [uri, uriReplacements] of replacementsByUri) {
476
+ const fileName = sourceUriToFileName(uri);
477
+ const nextText = applyReplacementsToText(await fs.readFile(fileName, "utf8"), uriReplacements);
478
+ await fs.writeFile(fileName, nextText, "utf8");
479
+ }
480
+ }
481
+ function applyReplacementsToText(text, replacements) {
482
+ let updated = text;
483
+ const sorted = [...replacements].sort(compareReplacementRangesDescending);
484
+ for (const replacement of sorted) {
485
+ const lineStarts = getLineStarts(updated);
486
+ const start = offsetAt(lineStarts, replacement.range.startLine, replacement.range.startChar);
487
+ const end = offsetAt(lineStarts, replacement.range.endLine, replacement.range.endChar);
488
+ updated = `${updated.slice(0, start)}${replacement.newText}${updated.slice(end)}`;
489
+ }
490
+ return updated;
491
+ }
492
+ function watchWorkspace(rootDir, listener) {
493
+ let watcher;
494
+ const timers = /* @__PURE__ */ new Map();
495
+ const queue = (fileName) => {
496
+ const existing = timers.get(fileName);
497
+ if (existing) clearTimeout(existing);
498
+ timers.set(fileName, setTimeout(() => {
499
+ timers.delete(fileName);
500
+ listener(fileNameToUri(fileName));
501
+ }, 75));
502
+ };
503
+ try {
504
+ watcher = watch(rootDir, { recursive: true }, (_event, fileName) => {
505
+ if (fileName) queue(path$1.join(rootDir, fileName.toString()));
506
+ });
507
+ } catch {
508
+ watcher = watch(rootDir, (_event, fileName) => {
509
+ if (fileName) queue(path$1.join(rootDir, fileName.toString()));
510
+ });
511
+ }
512
+ return { dispose() {
513
+ watcher?.close();
514
+ for (const timer of timers.values()) clearTimeout(timer);
515
+ timers.clear();
516
+ } };
517
+ }
518
+ function getCliWebviewContent(options) {
519
+ const baseUrl = normalizedBaseUrl(options.editorUrl);
520
+ const url = new URL(`${baseUrl}/embed`);
521
+ if (options.apiKey) url.searchParams.set("api_key", options.apiKey);
522
+ return `<!DOCTYPE html>
523
+ <html lang="en" style="height:100%;margin:0">
524
+ <head>
525
+ <meta charset="UTF-8">
526
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
527
+ <style>
528
+ body { margin: 0; padding: 0; height: 100vh; overflow: hidden; }
529
+ iframe { border: 0; width: 100%; height: 100%; display: block; }
530
+ </style>
531
+ </head>
532
+ <body>
533
+ <iframe
534
+ id="stately-editor"
535
+ src="${escapeAttribute(url.toString())}"
536
+ allow="clipboard-read; clipboard-write"
537
+ ></iframe>
538
+ <script>
539
+ const iframe = document.getElementById('stately-editor');
540
+ const socket = new WebSocket(
541
+ (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws'
542
+ );
543
+ const pending = [];
544
+
545
+ function sendToCli(message) {
546
+ if (socket.readyState === WebSocket.OPEN) {
547
+ socket.send(JSON.stringify(message));
548
+ return;
549
+ }
550
+ pending.push(message);
551
+ }
552
+
553
+ socket.addEventListener('open', () => {
554
+ while (pending.length > 0) {
555
+ socket.send(JSON.stringify(pending.shift()));
556
+ }
557
+ });
558
+
559
+ socket.addEventListener('message', (event) => {
560
+ iframe.contentWindow?.postMessage(JSON.parse(event.data), '*');
561
+ });
562
+
563
+ window.addEventListener('message', (event) => {
564
+ if (!event.data?.type?.startsWith?.('@statelyai.')) {
565
+ return;
566
+ }
567
+ sendToCli(event.data);
568
+ });
569
+ <\/script>
570
+ </body>
571
+ </html>`;
572
+ }
573
+ function encodeWebSocketFrame(payload, opcode = 1) {
574
+ const data = typeof payload === "string" ? Buffer.from(payload) : payload;
575
+ const headerLength = data.length < 126 ? 2 : data.length <= 65535 ? 4 : 10;
576
+ const frame = Buffer.alloc(headerLength + data.length);
577
+ frame[0] = 128 | opcode;
578
+ if (data.length < 126) {
579
+ frame[1] = data.length;
580
+ data.copy(frame, 2);
581
+ } else if (data.length <= 65535) {
582
+ frame[1] = 126;
583
+ frame.writeUInt16BE(data.length, 2);
584
+ data.copy(frame, 4);
585
+ } else {
586
+ frame[1] = 127;
587
+ frame.writeBigUInt64BE(BigInt(data.length), 2);
588
+ data.copy(frame, 10);
589
+ }
590
+ return frame;
591
+ }
592
+ function listen(server, port, host) {
593
+ return new Promise((resolve) => {
594
+ server.listen(port, host, resolve);
595
+ });
596
+ }
597
+ async function fetchEditorHost(input, init) {
598
+ const url = typeof input === "string" ? new URL(input) : input;
599
+ if (!isLocalVizHost(url.toString())) return fetch(url, init);
600
+ const body = typeof init?.body === "string" ? init.body : init?.body instanceof URLSearchParams ? init.body.toString() : void 0;
601
+ return new Promise((resolve, reject) => {
602
+ const request = https.request(url, {
603
+ method: init?.method ?? "GET",
604
+ headers: init?.headers,
605
+ rejectUnauthorized: false
606
+ }, (response) => {
607
+ const chunks = [];
608
+ response.on("data", (chunk) => {
609
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
610
+ });
611
+ response.on("end", () => {
612
+ const text = Buffer.concat(chunks).toString("utf8");
613
+ resolve({
614
+ ok: typeof response.statusCode === "number" && response.statusCode >= 200 && response.statusCode < 300,
615
+ status: response.statusCode ?? 0,
616
+ async json() {
617
+ return JSON.parse(text);
618
+ }
619
+ });
620
+ });
621
+ });
622
+ request.on("error", reject);
623
+ if (body !== void 0) request.write(body);
624
+ request.end();
625
+ });
626
+ }
627
+ async function assertEditorHostAvailable(editorUrl) {
628
+ const baseUrl = normalizedBaseUrl(editorUrl);
629
+ const embedUrl = new URL("/embed", baseUrl);
630
+ let response;
631
+ try {
632
+ response = await fetchEditorHost(embedUrl, { redirect: "manual" });
633
+ } catch (error) {
634
+ throw new Error(formatEditorHostError(baseUrl, void 0, error));
635
+ }
636
+ if (response.status === 404) throw new Error(formatEditorHostError(baseUrl, response.status));
637
+ }
638
+ function formatEditorHostError(baseUrl, status, cause) {
639
+ const hint = isLocalVizHost(baseUrl) ? " Start the local editor app with `pnpm dev` on https://viz.localhost, or pass `--editor-url https://viz.localhost`." : " Start the editor host, set `STATELY_EDITOR_URL`, or pass `--editor-url <url>`.";
640
+ return `Cannot reach a usable editor host at ${baseUrl}.${status ? ` HTTP ${status} from /embed.` : ""}${cause instanceof Error && cause.message ? ` ${cause.message}` : ""} The CLI loads editor URL defaults from your environment, including \`.env.local\`.${hint}`;
641
+ }
642
+ function isLocalVizHost(value) {
643
+ try {
644
+ const url = new URL(value);
645
+ return url.protocol === "https:" && url.hostname === "viz.localhost";
646
+ } catch {
647
+ return false;
648
+ }
649
+ }
650
+ function openBrowser(url) {
651
+ spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open", process.platform === "win32" ? [
652
+ "/c",
653
+ "start",
654
+ "",
655
+ url
656
+ ] : [url], {
657
+ detached: true,
658
+ stdio: "ignore"
659
+ }).unref();
660
+ }
661
+ function getValidationErrors(validations) {
662
+ return (validations ?? []).filter((validation) => validation.level === "error");
663
+ }
664
+ function formatValidationErrorMessage(errors) {
665
+ const [firstError] = errors;
666
+ if (!firstError) return "Cannot save Viz changes because the graph has validation errors.";
667
+ if (errors.length === 1) return `Cannot save Viz changes: ${firstError.message}`;
668
+ return `Cannot save Viz changes: ${errors.length} validation errors. First: ${firstError.message}`;
669
+ }
670
+ function sourceLocationUris(sourceLocations) {
671
+ return [
672
+ ...sourceLocations?.root ? [sourceLocations.root.uri] : [],
673
+ ...(sourceLocations?.states ?? []).map((state) => state.uri),
674
+ ...(sourceLocations?.staticValues ?? []).map((value) => value.uri)
675
+ ];
676
+ }
677
+ function fileNameToUri(fileName) {
678
+ return pathToFileURL(path$1.resolve(fileName)).toString();
679
+ }
680
+ function sourceUriToFileName(uriOrPath) {
681
+ if (uriOrPath.startsWith("file://")) return fileURLToPath(uriOrPath);
682
+ if (/^[a-z][a-z\d+.-]*:/i.test(uriOrPath)) throw new Error(`Unsupported non-file URI: ${uriOrPath}`);
683
+ return path$1.resolve(uriOrPath);
684
+ }
685
+ function getLineStarts(text) {
686
+ const lineStarts = [0];
687
+ for (let index = 0; index < text.length; index += 1) if (text.charCodeAt(index) === 10) lineStarts.push(index + 1);
688
+ return lineStarts;
689
+ }
690
+ function offsetAt(lineStarts, line, character) {
691
+ const lineStart = lineStarts[line];
692
+ if (lineStart === void 0) return lineStarts[lineStarts.length - 1] + character;
693
+ return lineStart + character;
694
+ }
695
+ function compareReplacementRangesDescending(left, right) {
696
+ if (left.range.startLine !== right.range.startLine) return right.range.startLine - left.range.startLine;
697
+ return right.range.startChar - left.range.startChar;
698
+ }
699
+ function normalizedBaseUrl(value) {
700
+ return value.replace(/\/+$/, "");
701
+ }
702
+ function formatError(error, fallback) {
703
+ if (!(error instanceof Error)) return fallback;
704
+ const details = collectErrorDetails(error);
705
+ return details.length > 0 ? `${error.message} (${details.join(" | ")})` : error.message;
706
+ }
707
+ function collectErrorDetails(error) {
708
+ const details = [];
709
+ const seen = /* @__PURE__ */ new Set();
710
+ let current = error;
711
+ while (current && typeof current === "object" && !seen.has(current)) {
712
+ seen.add(current);
713
+ const code = "code" in current && typeof current.code === "string" ? current.code : void 0;
714
+ const message = "message" in current && typeof current.message === "string" ? current.message : void 0;
715
+ if (code && message) details.push(`${code}: ${message}`);
716
+ else if (code) details.push(code);
717
+ current = "cause" in current ? current.cause : void 0;
718
+ }
719
+ return details;
720
+ }
721
+ function escapeAttribute(value) {
722
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
723
+ }
724
+
725
+ //#endregion
726
+ //#region src/credentials.ts
727
+ const execFile$1 = promisify(execFile);
728
+ const SERVICE_NAME = "statelyai";
729
+ const ACCOUNT_NAME = "api-key";
730
+ const CREDENTIALS_FILE_NAME = "credentials.json";
731
+ function getConfigDir() {
732
+ const override = process.env.STATELYAI_CONFIG_DIR;
733
+ if (override) return path.resolve(override);
734
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", SERVICE_NAME);
735
+ if (process.platform === "win32") return path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), SERVICE_NAME);
736
+ return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), SERVICE_NAME);
737
+ }
738
+ function getCredentialsFilePath() {
739
+ return path.join(getConfigDir(), CREDENTIALS_FILE_NAME);
740
+ }
741
+ function shouldUseFileBackendOnly() {
742
+ return process.env.STATELYAI_CREDENTIALS_BACKEND === "file";
743
+ }
744
+ async function readFileCredentials() {
745
+ try {
746
+ const raw = await promises.readFile(getCredentialsFilePath(), "utf8");
747
+ const parsed = JSON.parse(raw);
748
+ if (typeof parsed.apiKey !== "string" || parsed.apiKey.length === 0) return;
749
+ return {
750
+ apiKey: parsed.apiKey,
751
+ backend: "file",
752
+ location: getCredentialsFilePath()
753
+ };
754
+ } catch {
755
+ return;
756
+ }
757
+ }
758
+ async function writeFileCredentials(apiKey) {
759
+ const configDir = getConfigDir();
760
+ const credentialsPath = getCredentialsFilePath();
761
+ await promises.mkdir(configDir, {
762
+ recursive: true,
763
+ mode: 448
764
+ });
765
+ await promises.writeFile(credentialsPath, `${JSON.stringify({ apiKey }, null, 2)}\n`, {
766
+ encoding: "utf8",
767
+ mode: 384
768
+ });
769
+ await promises.chmod(credentialsPath, 384);
770
+ return {
771
+ apiKey,
772
+ backend: "file",
773
+ location: credentialsPath
774
+ };
775
+ }
776
+ async function deleteFileCredentials() {
777
+ try {
778
+ await promises.unlink(getCredentialsFilePath());
779
+ return true;
780
+ } catch {
781
+ return false;
782
+ }
783
+ }
784
+ async function readMacOSKeychain() {
785
+ if (process.platform !== "darwin") return;
786
+ try {
787
+ const { stdout } = await execFile$1("security", [
788
+ "find-generic-password",
789
+ "-s",
790
+ SERVICE_NAME,
791
+ "-a",
792
+ ACCOUNT_NAME,
793
+ "-w"
794
+ ]);
795
+ const apiKey = stdout.trim();
796
+ if (!apiKey) return;
797
+ return {
798
+ apiKey,
799
+ backend: "macos-keychain",
800
+ location: "macOS Keychain"
801
+ };
802
+ } catch {
803
+ return;
804
+ }
805
+ }
806
+ async function writeMacOSKeychain(apiKey) {
807
+ if (process.platform !== "darwin") return;
808
+ try {
809
+ await execFile$1("security", [
810
+ "add-generic-password",
811
+ "-U",
812
+ "-s",
813
+ SERVICE_NAME,
814
+ "-a",
815
+ ACCOUNT_NAME,
816
+ "-w",
817
+ apiKey
818
+ ]);
819
+ return {
820
+ apiKey,
821
+ backend: "macos-keychain",
822
+ location: "macOS Keychain"
823
+ };
824
+ } catch {
825
+ return;
826
+ }
827
+ }
828
+ async function deleteMacOSKeychain() {
829
+ if (process.platform !== "darwin") return false;
830
+ try {
831
+ await execFile$1("security", [
832
+ "delete-generic-password",
833
+ "-s",
834
+ SERVICE_NAME,
835
+ "-a",
836
+ ACCOUNT_NAME
837
+ ]);
838
+ return true;
839
+ } catch {
840
+ return false;
841
+ }
842
+ }
843
+ async function readLinuxSecretTool() {
844
+ if (process.platform !== "linux") return;
845
+ try {
846
+ const { stdout } = await execFile$1("secret-tool", [
847
+ "lookup",
848
+ "service",
849
+ SERVICE_NAME,
850
+ "account",
851
+ ACCOUNT_NAME
852
+ ]);
853
+ const apiKey = stdout.trim();
854
+ if (!apiKey) return;
855
+ return {
856
+ apiKey,
857
+ backend: "linux-secret-tool",
858
+ location: "Secret Service (secret-tool)"
859
+ };
860
+ } catch {
861
+ return;
862
+ }
863
+ }
864
+ async function writeLinuxSecretTool(apiKey) {
865
+ if (process.platform !== "linux") return;
866
+ try {
867
+ execFileSync("secret-tool", [
868
+ "store",
869
+ "--label",
870
+ "statelyai API key",
871
+ "service",
872
+ SERVICE_NAME,
873
+ "account",
874
+ ACCOUNT_NAME
875
+ ], { input: apiKey });
876
+ return {
877
+ apiKey,
878
+ backend: "linux-secret-tool",
879
+ location: "Secret Service (secret-tool)"
880
+ };
881
+ } catch {
882
+ return;
883
+ }
884
+ }
885
+ async function deleteLinuxSecretTool() {
886
+ if (process.platform !== "linux") return false;
887
+ try {
888
+ await execFile$1("secret-tool", [
889
+ "clear",
890
+ "service",
891
+ SERVICE_NAME,
892
+ "account",
893
+ ACCOUNT_NAME
894
+ ]);
895
+ return true;
896
+ } catch {
897
+ return false;
898
+ }
899
+ }
900
+ async function getStoredApiKey() {
901
+ if (shouldUseFileBackendOnly()) return readFileCredentials();
902
+ return await readMacOSKeychain() ?? await readLinuxSecretTool() ?? await readFileCredentials();
903
+ }
904
+ async function setStoredApiKey(apiKey) {
905
+ if (shouldUseFileBackendOnly()) return writeFileCredentials(apiKey);
906
+ return await writeMacOSKeychain(apiKey) ?? await writeLinuxSecretTool(apiKey) ?? await writeFileCredentials(apiKey);
907
+ }
908
+ async function deleteStoredApiKey() {
909
+ if (shouldUseFileBackendOnly()) {
910
+ const deleted = await deleteFileCredentials();
911
+ return {
912
+ deleted,
913
+ locations: deleted ? [getCredentialsFilePath()] : []
914
+ };
915
+ }
916
+ const locations = [];
917
+ if (await deleteMacOSKeychain()) locations.push("macOS Keychain");
918
+ if (await deleteLinuxSecretTool()) locations.push("Secret Service (secret-tool)");
919
+ if (await deleteFileCredentials()) locations.push(getCredentialsFilePath());
920
+ return {
921
+ deleted: locations.length > 0,
922
+ locations
923
+ };
924
+ }
925
+ function describeCredentialBackend(backend, location) {
926
+ switch (backend) {
927
+ case "macos-keychain": return location;
928
+ case "linux-secret-tool": return location;
929
+ case "file": return `file (${location})`;
930
+ }
931
+ }
932
+
933
+ //#endregion
9
934
  //#region src/cli.ts
935
+ const execFileAsync = promisify(execFile);
936
+ const STATELY_CONFIG_FILE = "statelyai.json";
937
+ const STATELY_CONFIG_SCHEMA_URL = "https://stately.ai/schemas/statelyai.json";
938
+ const STATELY_CONFIG_VERSION = "1.0.0";
939
+ function getDefaultSources(defaultXStateVersion) {
940
+ return [{
941
+ include: ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"],
942
+ exclude: [
943
+ "**/*.test.*",
944
+ "**/*.spec.*",
945
+ "**/dist/**",
946
+ "**/node_modules/**"
947
+ ],
948
+ format: "xstate",
949
+ xstateVersion: defaultXStateVersion
950
+ }];
951
+ }
952
+ function createStatelyProjectConfig(options) {
953
+ const defaultXStateVersion = options.defaultXStateVersion ?? 5;
954
+ return {
955
+ $schema: STATELY_CONFIG_SCHEMA_URL,
956
+ version: STATELY_CONFIG_VERSION,
957
+ projectId: options.projectId,
958
+ studioUrl: options.studioUrl,
959
+ defaultXStateVersion,
960
+ sources: getDefaultSources(defaultXStateVersion)
961
+ };
962
+ }
10
963
  function loadLocalEnv() {
11
964
  if (typeof process.loadEnvFile !== "function") return;
12
965
  const cwdEnvPath = path.join(process.cwd(), ".env.local");
@@ -15,14 +968,170 @@ function loadLocalEnv() {
15
968
  } catch {}
16
969
  }
17
970
  loadLocalEnv();
18
- function getDefaultApiKey() {
19
- return process.env.STATELY_API_KEY ?? process.env.NEXT_PUBLIC_STATELY_API_KEY;
971
+ function getEnvApiKey() {
972
+ const statelyApiKey = process.env.STATELY_API_KEY;
973
+ if (statelyApiKey) return {
974
+ apiKey: statelyApiKey,
975
+ variable: "STATELY_API_KEY"
976
+ };
977
+ const publicApiKey = process.env.NEXT_PUBLIC_STATELY_API_KEY;
978
+ if (publicApiKey) return {
979
+ apiKey: publicApiKey,
980
+ variable: "NEXT_PUBLIC_STATELY_API_KEY"
981
+ };
982
+ }
983
+ async function resolveApiKey(explicitApiKey) {
984
+ if (explicitApiKey) return {
985
+ apiKey: explicitApiKey,
986
+ source: "flag",
987
+ detail: "--api-key"
988
+ };
989
+ const envApiKey = getEnvApiKey();
990
+ if (envApiKey) return {
991
+ apiKey: envApiKey.apiKey,
992
+ source: "env",
993
+ detail: envApiKey.variable
994
+ };
995
+ const storedApiKey = await getStoredApiKey();
996
+ if (storedApiKey) return {
997
+ apiKey: storedApiKey.apiKey,
998
+ source: "stored",
999
+ detail: describeCredentialBackend(storedApiKey.backend, storedApiKey.location)
1000
+ };
1001
+ return { source: "missing" };
20
1002
  }
21
1003
  function getDefaultBaseUrl() {
22
1004
  return process.env.STATELY_API_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_STATELY_API_URL;
23
1005
  }
24
1006
  function getDefaultEditorUrl() {
25
- return process.env.STATELY_EDITOR_URL ?? process.env.NEXT_PUBLIC_BETA_EDITOR_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3001";
1007
+ return process.env.STATELY_EDITOR_URL ?? process.env.NEXT_PUBLIC_BETA_EDITOR_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? "https://viz.localhost";
1008
+ }
1009
+ function getResolvedStudioUrl(baseUrl) {
1010
+ return baseUrl ?? getDefaultBaseUrl() ?? "https://stately.ai";
1011
+ }
1012
+ function parseGitHubRemote(remoteUrl) {
1013
+ const httpsMatch = remoteUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
1014
+ if (httpsMatch) {
1015
+ const [, owner, repo] = httpsMatch;
1016
+ if (!owner || !repo) return null;
1017
+ return {
1018
+ url: `https://github.com/${owner}/${repo}`,
1019
+ owner,
1020
+ repo,
1021
+ branch: "",
1022
+ treeSha: ""
1023
+ };
1024
+ }
1025
+ const sshMatch = remoteUrl.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
1026
+ if (sshMatch) {
1027
+ const [, owner, repo] = sshMatch;
1028
+ if (!owner || !repo) return null;
1029
+ return {
1030
+ url: `https://github.com/${owner}/${repo}`,
1031
+ owner,
1032
+ repo,
1033
+ branch: "",
1034
+ treeSha: ""
1035
+ };
1036
+ }
1037
+ return null;
1038
+ }
1039
+ async function inferConnectedRepoFromCwd(cwd) {
1040
+ try {
1041
+ const [{ stdout: remoteStdout }, { stdout: branchStdout }, { stdout: treeShaStdout }] = await Promise.all([
1042
+ execFileAsync("git", [
1043
+ "remote",
1044
+ "get-url",
1045
+ "origin"
1046
+ ], { cwd }),
1047
+ execFileAsync("git", ["branch", "--show-current"], { cwd }),
1048
+ execFileAsync("git", ["rev-parse", "HEAD"], { cwd })
1049
+ ]);
1050
+ const parsedRemote = parseGitHubRemote(remoteStdout.trim());
1051
+ if (!parsedRemote) return;
1052
+ return {
1053
+ ...parsedRemote,
1054
+ branch: branchStdout.trim(),
1055
+ treeSha: treeShaStdout.trim()
1056
+ };
1057
+ } catch {
1058
+ return;
1059
+ }
1060
+ }
1061
+ function inferInitProjectName(cwd, repo) {
1062
+ if (repo?.repo) return repo.repo;
1063
+ return path.basename(cwd);
1064
+ }
1065
+ async function readApiKeyFromStdin() {
1066
+ const chunks = [];
1067
+ for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
1068
+ return Buffer.concat(chunks).toString("utf8").trim();
1069
+ }
1070
+ async function promptForApiKey() {
1071
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("No interactive terminal available. Pass --api-key or pipe the key on stdin.");
1072
+ const maskedOutput = new Writable({ write(chunk, _encoding, callback) {
1073
+ if (!maskedOutput.muted) process.stdout.write(chunk);
1074
+ callback();
1075
+ } });
1076
+ maskedOutput.muted = false;
1077
+ const rl = createInterface({
1078
+ input: process.stdin,
1079
+ output: maskedOutput,
1080
+ terminal: true
1081
+ });
1082
+ try {
1083
+ process.stdout.write("Enter your Stately API key: ");
1084
+ maskedOutput.muted = true;
1085
+ const apiKey = (await rl.question("")).trim();
1086
+ maskedOutput.muted = false;
1087
+ process.stdout.write("\n");
1088
+ return apiKey;
1089
+ } finally {
1090
+ rl.close();
1091
+ }
1092
+ }
1093
+ function normalizeApiKey(value) {
1094
+ const trimmed = value?.trim();
1095
+ return trimmed ? trimmed : void 0;
1096
+ }
1097
+ async function fileExists(filePath) {
1098
+ try {
1099
+ await fs.access(filePath);
1100
+ return true;
1101
+ } catch {
1102
+ return false;
1103
+ }
1104
+ }
1105
+ async function initProject(options) {
1106
+ const cwd = path.resolve(options.cwd ?? process.cwd());
1107
+ const studioUrl = getResolvedStudioUrl(options.baseUrl);
1108
+ const configPath = path.resolve(cwd, options.configPath ?? STATELY_CONFIG_FILE);
1109
+ const defaultXStateVersion = Math.max(5, options.defaultXStateVersion ?? 5);
1110
+ if (!options.force && await fileExists(configPath)) throw new Error(`${configPath} already exists. Pass --force to overwrite it.`);
1111
+ const client = options.client ?? createStatelyClient({
1112
+ apiKey: options.apiKey,
1113
+ baseUrl: studioUrl
1114
+ });
1115
+ const inferredRepo = options.project?.repo ?? await inferConnectedRepoFromCwd(cwd);
1116
+ const projectInput = {
1117
+ name: options.project?.name ?? inferInitProjectName(cwd, inferredRepo),
1118
+ visibility: options.project?.visibility ?? "Private",
1119
+ ...options.project?.description ? { description: options.project.description } : {},
1120
+ ...options.project?.keywords ? { keywords: options.project.keywords } : {},
1121
+ ...inferredRepo ? { repo: inferredRepo } : {}
1122
+ };
1123
+ const project = await client.projects.ensure(projectInput);
1124
+ const config = createStatelyProjectConfig({
1125
+ projectId: project.projectId,
1126
+ studioUrl,
1127
+ defaultXStateVersion
1128
+ });
1129
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
1130
+ return {
1131
+ config,
1132
+ configPath,
1133
+ project
1134
+ };
26
1135
  }
27
1136
  const sharedFlags = {
28
1137
  help: Flags.help({ char: "h" }),
@@ -85,10 +1194,11 @@ var PlanCommand = class PlanCommand extends ParsedSyncCommand {
85
1194
  static args = sharedArgs;
86
1195
  async run() {
87
1196
  const { args, flags } = await this.parseSync(PlanCommand);
1197
+ const apiKey = (await resolveApiKey(flags["api-key"])).apiKey;
88
1198
  const plan = await planSync({
89
1199
  source: args.source,
90
1200
  target: args.target,
91
- apiKey: flags["api-key"] ?? getDefaultApiKey(),
1201
+ apiKey,
92
1202
  baseUrl: flags["base-url"] ?? getDefaultBaseUrl()
93
1203
  });
94
1204
  this.log(formatPlanSummary(plan));
@@ -101,10 +1211,11 @@ var DiffCommand = class DiffCommand extends ParsedSyncCommand {
101
1211
  static args = sharedArgs;
102
1212
  async run() {
103
1213
  const { args, flags } = await this.parseSync(DiffCommand);
1214
+ const apiKey = (await resolveApiKey(flags["api-key"])).apiKey;
104
1215
  const plan = await planSync({
105
1216
  source: args.source,
106
1217
  target: args.target,
107
- apiKey: flags["api-key"] ?? getDefaultApiKey(),
1218
+ apiKey,
108
1219
  baseUrl: flags["base-url"] ?? getDefaultBaseUrl()
109
1220
  });
110
1221
  this.log(formatPlanSummary(plan));
@@ -117,10 +1228,11 @@ var PullCommand = class PullCommand extends ParsedSyncCommand {
117
1228
  static args = sharedArgs;
118
1229
  async run() {
119
1230
  const { args, flags } = await this.parseSync(PullCommand);
1231
+ const apiKey = (await resolveApiKey(flags["api-key"])).apiKey;
120
1232
  const result = await pullSync({
121
1233
  source: args.source,
122
1234
  target: args.target,
123
- apiKey: flags["api-key"] ?? getDefaultApiKey(),
1235
+ apiKey,
124
1236
  baseUrl: flags["base-url"] ?? getDefaultBaseUrl()
125
1237
  });
126
1238
  this.log(`Pulled: ${result.source.locator} -> ${result.outputPath}\nTarget: ${result.target.kind} (${result.target.format})`);
@@ -136,7 +1248,7 @@ var OpenCommand = class OpenCommand extends Command {
136
1248
  }) };
137
1249
  static flags = {
138
1250
  help: Flags.help({ char: "h" }),
139
- "api-key": Flags.string({ description: "Stately API key used by the embedded editor" }),
1251
+ "api-key": Flags.string({ description: "Stately API key used when the editor server requires auth" }),
140
1252
  "editor-url": Flags.string({ description: "Base URL for the Stately editor embed" }),
141
1253
  host: Flags.string({
142
1254
  description: "Local server host",
@@ -159,22 +1271,121 @@ var OpenCommand = class OpenCommand extends Command {
159
1271
  };
160
1272
  async run() {
161
1273
  const { args, flags } = await this.parse(OpenCommand);
1274
+ const apiKey = (await resolveApiKey(flags["api-key"])).apiKey;
162
1275
  await openEditor({
163
1276
  fileName: path.resolve(args.file),
164
1277
  editorUrl: flags["editor-url"] ?? getDefaultEditorUrl(),
165
1278
  host: flags.host,
166
1279
  port: flags.port,
167
1280
  shouldOpen: flags.open,
168
- apiKey: flags["api-key"] ?? getDefaultApiKey(),
1281
+ apiKey,
169
1282
  debug: flags.debug
170
1283
  });
171
1284
  }
172
1285
  };
1286
+ var InitCommand = class InitCommand extends Command {
1287
+ static enableJsonFlag = false;
1288
+ static summary = "Create a Stately project and write statelyai.json.";
1289
+ static description = "Creates or reuses a remote Studio project for the current working directory and writes a local statelyai.json configuration file.";
1290
+ static flags = {
1291
+ help: Flags.help({ char: "h" }),
1292
+ "api-key": Flags.string({ description: "Stately API key used to create the remote project" }),
1293
+ "base-url": Flags.string({ description: "Base URL for Stately Studio or a self-hosted deployment" }),
1294
+ name: Flags.string({ description: "Project name to create remotely" }),
1295
+ visibility: Flags.string({
1296
+ description: "Remote project visibility",
1297
+ options: [
1298
+ "Private",
1299
+ "Public",
1300
+ "Unlisted"
1301
+ ],
1302
+ default: "Private"
1303
+ }),
1304
+ force: Flags.boolean({
1305
+ description: "Overwrite an existing statelyai.json file",
1306
+ default: false
1307
+ })
1308
+ };
1309
+ async run() {
1310
+ const { flags } = await this.parse(InitCommand);
1311
+ const resolvedApiKey = await resolveApiKey(flags["api-key"]);
1312
+ if (!resolvedApiKey.apiKey) this.error("No API key configured. Use `statelyai login`, set `STATELY_API_KEY`, or pass `--api-key`.");
1313
+ const result = await initProject({
1314
+ apiKey: resolvedApiKey.apiKey,
1315
+ baseUrl: flags["base-url"],
1316
+ force: flags.force,
1317
+ project: {
1318
+ ...flags.name ? { name: flags.name } : {},
1319
+ visibility: flags.visibility
1320
+ }
1321
+ });
1322
+ this.log(`Initialized project ${result.project.projectId} and wrote ${result.configPath}.`);
1323
+ }
1324
+ };
1325
+ var LoginCommand = class LoginCommand extends Command {
1326
+ static enableJsonFlag = false;
1327
+ static summary = "Store a Stately API key for future CLI use.";
1328
+ static description = "Stores a Stately API key in the OS credential store when available, with a private file fallback.";
1329
+ static flags = {
1330
+ help: Flags.help({ char: "h" }),
1331
+ "api-key": Flags.string({ description: "API key to store without an interactive prompt" }),
1332
+ stdin: Flags.boolean({
1333
+ description: "Read the API key from standard input",
1334
+ default: false
1335
+ })
1336
+ };
1337
+ async run() {
1338
+ const { flags } = await this.parse(LoginCommand);
1339
+ if (flags.stdin && flags["api-key"]) this.error("Pass either --api-key or --stdin, not both.");
1340
+ const apiKey = normalizeApiKey(flags["api-key"] ?? (!process.stdin.isTTY || flags.stdin ? await readApiKeyFromStdin() : await promptForApiKey()));
1341
+ if (!apiKey) this.error("API key cannot be empty.");
1342
+ const stored = await setStoredApiKey(apiKey);
1343
+ this.log(`Stored API key in ${describeCredentialBackend(stored.backend, stored.location)}.`);
1344
+ }
1345
+ };
1346
+ var LogoutCommand = class extends Command {
1347
+ static enableJsonFlag = false;
1348
+ static summary = "Remove any API key stored by the CLI.";
1349
+ static description = "Deletes the locally stored API key. Environment variables are not changed.";
1350
+ static flags = { help: Flags.help({ char: "h" }) };
1351
+ async run() {
1352
+ const result = await deleteStoredApiKey();
1353
+ if (!result.deleted) {
1354
+ this.log("No stored API key found.");
1355
+ return;
1356
+ }
1357
+ this.log(`Removed stored API key from ${result.locations.join(", ")}.`);
1358
+ }
1359
+ };
1360
+ var AuthStatusCommand = class extends Command {
1361
+ static enableJsonFlag = false;
1362
+ static summary = "Show how the CLI would resolve its API key.";
1363
+ static description = "Reports whether the CLI would use a flag, environment variable, or stored credential.";
1364
+ static flags = { help: Flags.help({ char: "h" }) };
1365
+ async run() {
1366
+ const envApiKey = getEnvApiKey();
1367
+ const storedApiKey = await getStoredApiKey();
1368
+ if (envApiKey) {
1369
+ this.log(`API key source: environment (${envApiKey.variable}).`);
1370
+ if (storedApiKey) this.log(`Stored credential also available in ${describeCredentialBackend(storedApiKey.backend, storedApiKey.location)}.`);
1371
+ return;
1372
+ }
1373
+ if (storedApiKey) {
1374
+ this.log(`API key source: stored credential (${describeCredentialBackend(storedApiKey.backend, storedApiKey.location)}).`);
1375
+ return;
1376
+ }
1377
+ this.log("No API key configured. Use `statelyai login`, set `STATELY_API_KEY`, or pass `--api-key`.");
1378
+ }
1379
+ };
173
1380
  const COMMANDS = {
174
1381
  plan: PlanCommand,
175
1382
  diff: DiffCommand,
176
1383
  pull: PullCommand,
177
- open: OpenCommand
1384
+ open: OpenCommand,
1385
+ init: InitCommand,
1386
+ login: LoginCommand,
1387
+ logout: LogoutCommand,
1388
+ "auth:status": AuthStatusCommand
178
1389
  };
179
1390
  async function run(argv = process.argv.slice(2), entryUrl = import.meta.url) {
180
1391
  const normalizedArgv = argv.length === 1 && argv[0] === "-h" ? ["--help"] : argv;
@@ -190,7 +1401,7 @@ function isDirectExecution() {
190
1401
  if (!entryPath) return false;
191
1402
  const modulePath = fileURLToPath(import.meta.url);
192
1403
  try {
193
- return fs.realpathSync(entryPath) === fs.realpathSync(modulePath);
1404
+ return fs$1.realpathSync(entryPath) === fs$1.realpathSync(modulePath);
194
1405
  } catch {
195
1406
  return path.resolve(entryPath) === modulePath;
196
1407
  }
@@ -198,4 +1409,4 @@ function isDirectExecution() {
198
1409
  if (isDirectExecution()) run();
199
1410
 
200
1411
  //#endregion
201
- export { COMMANDS, formatPlanSummary, run };
1412
+ export { COMMANDS, createStatelyProjectConfig, formatPlanSummary, getEnvApiKey, inferInitProjectName, initProject, resolveApiKey, run };