statelyai 0.11.4 → 0.11.6
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/README.md +11 -6
- package/dist/bin.mjs +1 -1
- package/dist/cli-Du92Y4VA.mjs +16826 -0
- package/dist/index.d.mts +202 -1
- package/dist/index.mjs +1 -1
- package/package.json +4 -3
- package/dist/cli-D8HHGEMs.mjs +0 -3073
package/dist/cli-D8HHGEMs.mjs
DELETED
|
@@ -1,3073 +0,0 @@
|
|
|
1
|
-
import { Args, Command, Flags, flush, handle, run } from "@oclif/core";
|
|
2
|
-
import fsPromises from "node:fs/promises";
|
|
3
|
-
import * as path$1 from "node:path";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
|
-
import * as crypto from "node:crypto";
|
|
9
|
-
import fs, { promises, watch } from "node:fs";
|
|
10
|
-
import { createInterface } from "node:readline/promises";
|
|
11
|
-
import { Writable } from "node:stream";
|
|
12
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
-
import * as http from "node:http";
|
|
14
|
-
import * as ts from "typescript";
|
|
15
|
-
import { EditorHostWorkspaceSession, StudioApiError, buildAuthorizeUrl, createPkcePair, createRemoteEditorSyncBackend, createStatelyClient, discoverAuthorizationServer, discoverProtectedResource, exchangeCodeForToken, findStatelyPragmaAttachments, getStatelyPragma, refreshToken, registerOAuthClient } from "@statelyai/sdk";
|
|
16
|
-
import { WebSocket, WebSocketServer } from "ws";
|
|
17
|
-
import os from "node:os";
|
|
18
|
-
import { PullSourceReconciliationError, planSync, pullSync, pushLocalMachineLinks } from "@statelyai/sdk/sync";
|
|
19
|
-
|
|
20
|
-
//#region ../api-contract/dist/verification.mjs
|
|
21
|
-
const count = z.number().int().nonnegative();
|
|
22
|
-
const metric = (value) => z.object({
|
|
23
|
-
value: z.union([value, z.literal("n/a")]),
|
|
24
|
-
detail: z.string()
|
|
25
|
-
});
|
|
26
|
-
const coverageBucket = z.object({
|
|
27
|
-
covered: count,
|
|
28
|
-
total: count,
|
|
29
|
-
uncovered: z.array(z.string())
|
|
30
|
-
}).refine((bucket) => bucket.covered <= bucket.total, "Covered count exceeds total");
|
|
31
|
-
const verificationCoverageSchema = z.object({
|
|
32
|
-
states: coverageBucket,
|
|
33
|
-
transitions: coverageBucket,
|
|
34
|
-
eventCases: coverageBucket,
|
|
35
|
-
percent: z.object({
|
|
36
|
-
states: z.number().min(0).max(100),
|
|
37
|
-
transitions: z.number().min(0).max(100),
|
|
38
|
-
eventCases: z.number().min(0).max(100),
|
|
39
|
-
overall: z.number().min(0).max(100)
|
|
40
|
-
})
|
|
41
|
-
});
|
|
42
|
-
const witnessStep = z.object({
|
|
43
|
-
index: count,
|
|
44
|
-
event: z.object({ type: z.string() }).catchall(z.unknown()),
|
|
45
|
-
expectedState: z.unknown()
|
|
46
|
-
});
|
|
47
|
-
const predicateResult = z.object({
|
|
48
|
-
state: z.string(),
|
|
49
|
-
status: z.enum(["passed", "failed"]),
|
|
50
|
-
signals: z.array(z.string()),
|
|
51
|
-
message: z.string().optional()
|
|
52
|
-
});
|
|
53
|
-
const verificationDivergenceSchema = z.object({
|
|
54
|
-
version: z.literal(1),
|
|
55
|
-
id: z.string(),
|
|
56
|
-
kind: z.enum(["expectation", "negative-probe"]),
|
|
57
|
-
classification: z.enum([
|
|
58
|
-
"unclassified",
|
|
59
|
-
"model-gap",
|
|
60
|
-
"behavior-gap",
|
|
61
|
-
"flake"
|
|
62
|
-
]),
|
|
63
|
-
pathId: z.string().optional(),
|
|
64
|
-
witness: z.object({
|
|
65
|
-
path: z.array(witnessStep),
|
|
66
|
-
stepCount: count
|
|
67
|
-
}),
|
|
68
|
-
minimalWitness: z.object({
|
|
69
|
-
steps: z.array(witnessStep),
|
|
70
|
-
pathLength: count,
|
|
71
|
-
candidatesTried: count,
|
|
72
|
-
reproduced: z.boolean()
|
|
73
|
-
}).optional(),
|
|
74
|
-
expected: z.object({
|
|
75
|
-
state: z.string(),
|
|
76
|
-
predicateResults: z.array(predicateResult),
|
|
77
|
-
refusal: z.object({
|
|
78
|
-
stateUnchanged: z.literal(true),
|
|
79
|
-
noNavigationOrMutation: z.literal(true)
|
|
80
|
-
}).optional()
|
|
81
|
-
}),
|
|
82
|
-
observed: z.object({
|
|
83
|
-
predicateResults: z.array(predicateResult),
|
|
84
|
-
snapshot: z.object({
|
|
85
|
-
url: z.string(),
|
|
86
|
-
title: z.string(),
|
|
87
|
-
visibleText: z.string(),
|
|
88
|
-
domHash: z.string(),
|
|
89
|
-
mutationCount: count.optional(),
|
|
90
|
-
requestCount: count.optional()
|
|
91
|
-
}),
|
|
92
|
-
error: z.string().optional()
|
|
93
|
-
}),
|
|
94
|
-
screenshotPath: z.string(),
|
|
95
|
-
provenance: z.object({
|
|
96
|
-
suite: z.string(),
|
|
97
|
-
modelVersion: z.string(),
|
|
98
|
-
modelHash: z.string(),
|
|
99
|
-
appBaseUrl: z.string(),
|
|
100
|
-
timestamp: z.string(),
|
|
101
|
-
traceFileRef: z.string()
|
|
102
|
-
}),
|
|
103
|
-
weakOracles: z.boolean()
|
|
104
|
-
}).passthrough();
|
|
105
|
-
const verificationPathSchema = z.object({
|
|
106
|
-
type: z.literal("path-result"),
|
|
107
|
-
id: z.string(),
|
|
108
|
-
status: z.enum(["passed", "failed"]),
|
|
109
|
-
witnessedStates: z.array(z.string()),
|
|
110
|
-
witnessedTransitions: z.array(z.string())
|
|
111
|
-
}).passthrough();
|
|
112
|
-
/** MBT version-one run report. Optional path/probe records support older reports. */
|
|
113
|
-
const verificationRunReportSchema = z.object({
|
|
114
|
-
version: z.literal(1),
|
|
115
|
-
suite: z.string(),
|
|
116
|
-
status: z.enum(["passed", "failed"]),
|
|
117
|
-
verdict: z.enum([
|
|
118
|
-
"conformant",
|
|
119
|
-
"divergent",
|
|
120
|
-
"inconclusive"
|
|
121
|
-
]),
|
|
122
|
-
coverage: z.enum(["shortest", "simple"]),
|
|
123
|
-
scenario: z.string().optional(),
|
|
124
|
-
startedAt: z.iso.datetime({ offset: true }),
|
|
125
|
-
completedAt: z.iso.datetime({ offset: true }),
|
|
126
|
-
durationMs: z.number().nonnegative(),
|
|
127
|
-
exitCode: z.number().int(),
|
|
128
|
-
weakOracles: z.boolean(),
|
|
129
|
-
phases: z.array(z.object({
|
|
130
|
-
name: z.enum(["exploration", "verification"]),
|
|
131
|
-
status: z.enum(["completed", "skipped"]),
|
|
132
|
-
aiFree: z.boolean()
|
|
133
|
-
})),
|
|
134
|
-
probePlan: z.object({
|
|
135
|
-
cap: count,
|
|
136
|
-
totalCandidates: count,
|
|
137
|
-
samplingRule: z.string()
|
|
138
|
-
}),
|
|
139
|
-
metrics: z.object({
|
|
140
|
-
fitness: metric(z.number()).optional(),
|
|
141
|
-
precision: metric(z.number()),
|
|
142
|
-
soundnessRatio: metric(z.number()),
|
|
143
|
-
frontierSize: metric(z.number()),
|
|
144
|
-
determinism: metric(z.boolean())
|
|
145
|
-
}).passthrough(),
|
|
146
|
-
modelCoverage: verificationCoverageSchema,
|
|
147
|
-
executedCoverage: verificationCoverageSchema,
|
|
148
|
-
paths: z.array(verificationPathSchema).optional(),
|
|
149
|
-
probes: z.array(z.object({
|
|
150
|
-
type: z.literal("probe-result"),
|
|
151
|
-
id: z.string(),
|
|
152
|
-
state: z.string(),
|
|
153
|
-
event: z.string(),
|
|
154
|
-
status: z.enum(["passed", "failed"])
|
|
155
|
-
})).optional(),
|
|
156
|
-
divergences: z.array(verificationDivergenceSchema),
|
|
157
|
-
ledger: z.array(z.object({
|
|
158
|
-
type: z.literal("divergence"),
|
|
159
|
-
timestamp: z.string(),
|
|
160
|
-
divergence: verificationDivergenceSchema
|
|
161
|
-
}))
|
|
162
|
-
}).passthrough().superRefine((report, context) => {
|
|
163
|
-
if (report.divergences.length > 0 !== (report.verdict === "divergent")) context.addIssue({
|
|
164
|
-
code: "custom",
|
|
165
|
-
path: ["verdict"],
|
|
166
|
-
message: "Verdict must agree with recorded divergences"
|
|
167
|
-
});
|
|
168
|
-
if (report.verdict === "conformant" && (report.status !== "passed" || report.exitCode !== 0 || report.paths?.some((item) => item.status === "failed") || report.probes?.some((item) => item.status === "failed") || report.metrics.determinism.value === false)) context.addIssue({
|
|
169
|
-
code: "custom",
|
|
170
|
-
path: ["verdict"],
|
|
171
|
-
message: "Conformant reports cannot contain failed execution results"
|
|
172
|
-
});
|
|
173
|
-
if (report.status === "passed" && (report.verdict !== "conformant" || report.exitCode !== 0)) context.addIssue({
|
|
174
|
-
code: "custom",
|
|
175
|
-
path: ["status"],
|
|
176
|
-
message: "A passed report requires a conformant verdict and zero exit code"
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
const verificationFailOnSchema = z.enum(["divergent", "inconclusive"]);
|
|
180
|
-
/** References are inert data. The host resolves them inside its execution boundary. */
|
|
181
|
-
const verificationRequestSchema = z.object({
|
|
182
|
-
model: z.discriminatedUnion("kind", [z.object({
|
|
183
|
-
kind: z.literal("source"),
|
|
184
|
-
content: z.string().min(1),
|
|
185
|
-
name: z.string().optional()
|
|
186
|
-
}), z.object({
|
|
187
|
-
kind: z.literal("machine"),
|
|
188
|
-
machineId: z.string().min(1)
|
|
189
|
-
})]),
|
|
190
|
-
bindings: z.object({
|
|
191
|
-
content: z.string(),
|
|
192
|
-
name: z.string().optional()
|
|
193
|
-
}).optional(),
|
|
194
|
-
baseline: z.discriminatedUnion("kind", [z.object({
|
|
195
|
-
kind: z.literal("report"),
|
|
196
|
-
report: verificationRunReportSchema
|
|
197
|
-
}), z.object({
|
|
198
|
-
kind: z.literal("reference"),
|
|
199
|
-
id: z.string().min(1)
|
|
200
|
-
})]).optional()
|
|
201
|
-
});
|
|
202
|
-
const verificationExecutionPolicy = {
|
|
203
|
-
aiFree: true,
|
|
204
|
-
network: "deny",
|
|
205
|
-
env: { MBT_VERIFY_AI_FREE: "1" }
|
|
206
|
-
};
|
|
207
|
-
/** Strict mode fails on divergences and inconclusive runs; default fails on divergences. */
|
|
208
|
-
function verificationExitCode(report, failOn = "divergent") {
|
|
209
|
-
return report.verdict === "divergent" || failOn === "inconclusive" && report.verdict === "inconclusive" ? 1 : 0;
|
|
210
|
-
}
|
|
211
|
-
function verificationWitnessEvents(divergence) {
|
|
212
|
-
return (divergence.minimalWitness?.reproduced ? divergence.minimalWitness.steps : divergence.witness.path).map((step) => step.event.type);
|
|
213
|
-
}
|
|
214
|
-
function formatVerificationSummary(report) {
|
|
215
|
-
const violations = report.divergences.filter((item) => item.kind === "negative-probe").length;
|
|
216
|
-
return [`${report.verdict}: ${report.divergences.length} divergence(s), ${violations} negative-probe violation(s), ${report.executedCoverage.percent.overall}% executed coverage`, ...report.divergences.map((item) => `${item.id}: witness: [${verificationWitnessEvents(item).join(", ")}]`)].join("\n");
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
//#endregion
|
|
220
|
-
//#region src/projectConfig.ts
|
|
221
|
-
const STATELY_CONFIG_FILE = "statelyai.json";
|
|
222
|
-
const STATELY_CONFIG_SCHEMA_URL = "https://stately.ai/schemas/statelyai.json";
|
|
223
|
-
const STATELY_CONFIG_VERSION = "1.0.0";
|
|
224
|
-
/**
|
|
225
|
-
* Base URL of the deployment that hosts the owned resource contract
|
|
226
|
-
* (`/api/v1/*`). Matches the SDK default.
|
|
227
|
-
*/
|
|
228
|
-
const DEFAULT_STATELY_API_BASE_URL = "https://editor.stately.ai";
|
|
229
|
-
/**
|
|
230
|
-
* Hosts of the legacy hosted Studio. `statelyai.json#studioUrl` values that
|
|
231
|
-
* point at them predate the editor-hosted API and must be redirected.
|
|
232
|
-
*/
|
|
233
|
-
const HOSTED_STUDIO_HOSTNAMES = new Set(["stately.ai", "www.stately.ai"]);
|
|
234
|
-
/**
|
|
235
|
-
* Maps a configured `statelyai.json#studioUrl` (or a `--base-url` flag) to the
|
|
236
|
-
* base URL of the editor API that serves our owned contract.
|
|
237
|
-
*
|
|
238
|
-
* Hosted legacy values (`https://stately.ai`, `http://www.stately.ai/`, ...)
|
|
239
|
-
* resolve to {@link DEFAULT_STATELY_API_BASE_URL}. Anything else is used as-is
|
|
240
|
-
* (minus trailing slashes): self-hosted editor deployments already point at the
|
|
241
|
-
* server that hosts `/api/v1`.
|
|
242
|
-
*
|
|
243
|
-
* This is a read-only compatibility shim: the CLI never rewrites
|
|
244
|
-
* `statelyai.json` because of it.
|
|
245
|
-
*/
|
|
246
|
-
function resolveApiBaseUrl(studioUrl) {
|
|
247
|
-
const trimmed = studioUrl?.trim();
|
|
248
|
-
if (!trimmed) return DEFAULT_STATELY_API_BASE_URL;
|
|
249
|
-
const parsed = tryParseUrl(trimmed) ?? tryParseUrl(`https://${trimmed}`);
|
|
250
|
-
if (parsed && HOSTED_STUDIO_HOSTNAMES.has(parsed.hostname.toLowerCase())) return DEFAULT_STATELY_API_BASE_URL;
|
|
251
|
-
return trimmed.replace(/\/+$/, "");
|
|
252
|
-
}
|
|
253
|
-
function tryParseUrl(value) {
|
|
254
|
-
try {
|
|
255
|
-
return new URL(value);
|
|
256
|
-
} catch {
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
const DEFAULT_SOURCE_EXCLUDES = ["**/*.test.*", "**/*.spec.*"];
|
|
261
|
-
const CODE_SOURCE_EXTENSIONS = new Set([
|
|
262
|
-
".ts",
|
|
263
|
-
".tsx",
|
|
264
|
-
".js",
|
|
265
|
-
".jsx",
|
|
266
|
-
".mts",
|
|
267
|
-
".cts",
|
|
268
|
-
".mjs",
|
|
269
|
-
".cjs"
|
|
270
|
-
]);
|
|
271
|
-
const execFileAsync$1 = promisify(execFile);
|
|
272
|
-
const ALWAYS_IGNORED_DIRECTORIES = new Set([
|
|
273
|
-
".git",
|
|
274
|
-
"node_modules",
|
|
275
|
-
"dist",
|
|
276
|
-
"build",
|
|
277
|
-
"out",
|
|
278
|
-
"coverage",
|
|
279
|
-
".next"
|
|
280
|
-
]);
|
|
281
|
-
function createStatelyProjectConfig(options) {
|
|
282
|
-
const defaultXStateVersion = options.defaultXStateVersion ?? 5;
|
|
283
|
-
return {
|
|
284
|
-
$schema: STATELY_CONFIG_SCHEMA_URL,
|
|
285
|
-
version: STATELY_CONFIG_VERSION,
|
|
286
|
-
projectId: options.projectId ?? "",
|
|
287
|
-
studioUrl: options.studioUrl ?? "https://stately.ai",
|
|
288
|
-
defaultXStateVersion,
|
|
289
|
-
include: [],
|
|
290
|
-
exclude: [...DEFAULT_SOURCE_EXCLUDES]
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
async function writeStatelyProjectConfig(configPath, config) {
|
|
294
|
-
const serialized = { ...config };
|
|
295
|
-
if (!config.projectId) {
|
|
296
|
-
delete serialized.projectId;
|
|
297
|
-
delete serialized.studioUrl;
|
|
298
|
-
}
|
|
299
|
-
await fsPromises.writeFile(configPath, `${JSON.stringify(serialized, null, 2)}\n`, "utf8");
|
|
300
|
-
}
|
|
301
|
-
function normalizeProjectConfig(config) {
|
|
302
|
-
const firstSource = config.sources?.[0];
|
|
303
|
-
return {
|
|
304
|
-
$schema: config.$schema ?? STATELY_CONFIG_SCHEMA_URL,
|
|
305
|
-
version: config.version ?? STATELY_CONFIG_VERSION,
|
|
306
|
-
projectId: config.projectId ?? "",
|
|
307
|
-
studioUrl: config.studioUrl ?? "https://stately.ai",
|
|
308
|
-
defaultXStateVersion: Math.max(5, config.defaultXStateVersion ?? 5),
|
|
309
|
-
...config.sourceUpdateStrategy === "preserve" || config.sourceUpdateStrategy === "rewrite" ? { sourceUpdateStrategy: config.sourceUpdateStrategy } : {},
|
|
310
|
-
include: [...config.include ?? firstSource?.include ?? []],
|
|
311
|
-
exclude: config.exclude == null ? [...firstSource?.exclude ?? DEFAULT_SOURCE_EXCLUDES] : [...config.exclude],
|
|
312
|
-
...config.newMachinesDir ? { newMachinesDir: config.newMachinesDir } : {}
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
function getConfigRewriteStatus(config) {
|
|
316
|
-
if (!config.sources) return { needsRewrite: false };
|
|
317
|
-
if (config.sources.length > 1) return {
|
|
318
|
-
needsRewrite: true,
|
|
319
|
-
reason: "multiple-legacy-sources"
|
|
320
|
-
};
|
|
321
|
-
return {
|
|
322
|
-
needsRewrite: true,
|
|
323
|
-
reason: "legacy-sources"
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
async function readStatelyProjectConfig(options = {}) {
|
|
327
|
-
const rootDir = path.resolve(options.cwd ?? process.cwd());
|
|
328
|
-
const configPath = path.resolve(rootDir, options.configPath ?? STATELY_CONFIG_FILE);
|
|
329
|
-
const raw = await fsPromises.readFile(configPath, "utf8");
|
|
330
|
-
const parsed = JSON.parse(raw);
|
|
331
|
-
const config = normalizeProjectConfig(parsed);
|
|
332
|
-
const rewriteStatus = getConfigRewriteStatus(parsed);
|
|
333
|
-
if (options.rewriteIfNeeded && rewriteStatus.reason === "legacy-sources") {
|
|
334
|
-
await writeStatelyProjectConfig(configPath, config);
|
|
335
|
-
return {
|
|
336
|
-
config,
|
|
337
|
-
configPath,
|
|
338
|
-
rootDir,
|
|
339
|
-
rewriteStatus: {
|
|
340
|
-
needsRewrite: false,
|
|
341
|
-
rewritten: true
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
return {
|
|
346
|
-
config,
|
|
347
|
-
configPath,
|
|
348
|
-
rootDir,
|
|
349
|
-
rewriteStatus
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
async function walkFiles(rootDir, currentDir = rootDir) {
|
|
353
|
-
const entries = await fsPromises.readdir(currentDir, { withFileTypes: true });
|
|
354
|
-
const files = [];
|
|
355
|
-
for (const entry of entries) {
|
|
356
|
-
if (ALWAYS_IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
357
|
-
const absolutePath = path.join(currentDir, entry.name);
|
|
358
|
-
if (entry.isDirectory()) {
|
|
359
|
-
files.push(...await walkFiles(rootDir, absolutePath));
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
if (!entry.isFile()) continue;
|
|
363
|
-
files.push(path.relative(rootDir, absolutePath).replace(/\\/g, "/"));
|
|
364
|
-
}
|
|
365
|
-
return files;
|
|
366
|
-
}
|
|
367
|
-
async function filterGitIgnoredPaths(rootDir, relativeFiles) {
|
|
368
|
-
if (relativeFiles.length === 0) return relativeFiles;
|
|
369
|
-
try {
|
|
370
|
-
const { stdout } = await execFileAsync$1("git", ["check-ignore", ...relativeFiles], { cwd: rootDir });
|
|
371
|
-
const ignoredEntries = stdout.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
|
|
372
|
-
if (ignoredEntries.length === 0) return relativeFiles;
|
|
373
|
-
const ignoredPaths = new Set(ignoredEntries);
|
|
374
|
-
return relativeFiles.filter((relativePath) => {
|
|
375
|
-
return !ignoredPaths.has(relativePath);
|
|
376
|
-
});
|
|
377
|
-
} catch {
|
|
378
|
-
return relativeFiles;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
function isCodeSourceFile(relativePath) {
|
|
382
|
-
return CODE_SOURCE_EXTENSIONS.has(path.extname(relativePath).toLowerCase());
|
|
383
|
-
}
|
|
384
|
-
function expandBraces(pattern) {
|
|
385
|
-
const match = pattern.match(/\{([^{}]+)\}/);
|
|
386
|
-
if (!match || match.index == null) return [pattern];
|
|
387
|
-
const [token, inner] = match;
|
|
388
|
-
return inner.split(",").flatMap((variant) => expandBraces(`${pattern.slice(0, match.index)}${variant}${pattern.slice(match.index + token.length)}`));
|
|
389
|
-
}
|
|
390
|
-
function globToRegExp(pattern) {
|
|
391
|
-
let regex = "^";
|
|
392
|
-
for (let index = 0; index < pattern.length; index += 1) {
|
|
393
|
-
const char = pattern[index];
|
|
394
|
-
const next = pattern[index + 1];
|
|
395
|
-
if (char === "*") {
|
|
396
|
-
if (next === "*") {
|
|
397
|
-
const slashAfterGlobstar = pattern[index + 2] === "/";
|
|
398
|
-
regex += slashAfterGlobstar ? "(?:.*/)?" : ".*";
|
|
399
|
-
index += slashAfterGlobstar ? 2 : 1;
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
regex += "[^/]*";
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
if (char === "?") {
|
|
406
|
-
regex += "[^/]";
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
|
-
if ("\\.[]{}()+-^$|".includes(char)) {
|
|
410
|
-
regex += `\\${char}`;
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
regex += char;
|
|
414
|
-
}
|
|
415
|
-
regex += "$";
|
|
416
|
-
return new RegExp(regex);
|
|
417
|
-
}
|
|
418
|
-
function matchesGlob(relativePath, pattern) {
|
|
419
|
-
return expandBraces(pattern).some((expanded) => globToRegExp(expanded).test(relativePath));
|
|
420
|
-
}
|
|
421
|
-
function matchesAny(patterns, relativePath) {
|
|
422
|
-
return (patterns ?? []).some((pattern) => matchesGlob(relativePath, pattern));
|
|
423
|
-
}
|
|
424
|
-
function isTrackedByStatelyProjectConfig(config, relativePath) {
|
|
425
|
-
return matchesAny(config.include, relativePath) && !matchesAny(config.exclude ?? DEFAULT_SOURCE_EXCLUDES, relativePath);
|
|
426
|
-
}
|
|
427
|
-
async function discoverCodeSourceFiles(options = {}) {
|
|
428
|
-
const rootDir = path.resolve(options.cwd ?? process.cwd());
|
|
429
|
-
return (await filterGitIgnoredPaths(rootDir, await walkFiles(rootDir))).filter((relativePath) => isCodeSourceFile(relativePath)).filter((relativePath) => !matchesAny(DEFAULT_SOURCE_EXCLUDES, relativePath)).sort((left, right) => left.localeCompare(right));
|
|
430
|
-
}
|
|
431
|
-
function createSuggestedConfig(include) {
|
|
432
|
-
return {
|
|
433
|
-
include: [include],
|
|
434
|
-
exclude: [...DEFAULT_SOURCE_EXCLUDES]
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
function toExtensionToken(extname) {
|
|
438
|
-
return extname.startsWith(".") ? extname.slice(1) : extname;
|
|
439
|
-
}
|
|
440
|
-
function formatExtensionPattern(extnames) {
|
|
441
|
-
const unique = [...new Set(extnames.map(toExtensionToken))].sort();
|
|
442
|
-
if (unique.length === 1) return `.${unique[0]}`;
|
|
443
|
-
return `.{${unique.join(",")}}`;
|
|
444
|
-
}
|
|
445
|
-
function getSuggestionRoot(relativePath) {
|
|
446
|
-
if (relativePath.startsWith("src/")) return "src/";
|
|
447
|
-
const packageMatch = relativePath.match(/^packages\/([^/]+)\/src\//);
|
|
448
|
-
if (packageMatch) return `packages/${packageMatch[1]}/src/`;
|
|
449
|
-
const appMatch = relativePath.match(/^apps\/([^/]+)\/src\//);
|
|
450
|
-
if (appMatch) return `apps/${appMatch[1]}/src/`;
|
|
451
|
-
return null;
|
|
452
|
-
}
|
|
453
|
-
function getCommonDirectory(relativePaths) {
|
|
454
|
-
const [firstDirectory, ...remainingDirectories] = relativePaths.map((relativePath) => path.posix.dirname(relativePath).split("/"));
|
|
455
|
-
if (!firstDirectory) return;
|
|
456
|
-
const commonSegments = [];
|
|
457
|
-
for (const [index, segment] of firstDirectory.entries()) {
|
|
458
|
-
if (!remainingDirectories.every((directory) => directory[index] === segment)) break;
|
|
459
|
-
commonSegments.push(segment);
|
|
460
|
-
}
|
|
461
|
-
return commonSegments.length > 0 ? commonSegments.join("/") : void 0;
|
|
462
|
-
}
|
|
463
|
-
function suggestGroupedInclude(relativePaths, root) {
|
|
464
|
-
const extPattern = formatExtensionPattern(relativePaths.map((relativePath) => path.posix.extname(relativePath)));
|
|
465
|
-
if (relativePaths.map((relativePath) => path.posix.basename(relativePath, path.posix.extname(relativePath))).every((basename) => basename.toLowerCase().endsWith("machine"))) return `${root}**/*machine${extPattern}`;
|
|
466
|
-
const commonDirectory = getCommonDirectory(relativePaths);
|
|
467
|
-
if (commonDirectory) return `${commonDirectory}/${relativePaths.every((relativePath) => path.posix.dirname(relativePath) === commonDirectory) ? "*" : "**/*"}${extPattern}`;
|
|
468
|
-
return `${root}**/*${extPattern}`;
|
|
469
|
-
}
|
|
470
|
-
function suggestStatelySourceConfigs(relativePaths) {
|
|
471
|
-
const pending = new Set(relativePaths.map((relativePath) => relativePath.replace(/\\/g, "/")).filter(Boolean));
|
|
472
|
-
const suggestions = [];
|
|
473
|
-
const addSuggestion = (include, predicate) => {
|
|
474
|
-
const matched = [...pending].filter(predicate);
|
|
475
|
-
if (matched.length === 0) return;
|
|
476
|
-
suggestions.push(createSuggestedConfig(include));
|
|
477
|
-
for (const matchedPath of matched) pending.delete(matchedPath);
|
|
478
|
-
};
|
|
479
|
-
const groupedRoots = /* @__PURE__ */ new Map();
|
|
480
|
-
for (const relativePath of pending) {
|
|
481
|
-
const root = getSuggestionRoot(relativePath);
|
|
482
|
-
if (!root) continue;
|
|
483
|
-
const bucket = groupedRoots.get(root) ?? [];
|
|
484
|
-
bucket.push(relativePath);
|
|
485
|
-
groupedRoots.set(root, bucket);
|
|
486
|
-
}
|
|
487
|
-
for (const [root, groupedPaths] of [...groupedRoots.entries()].sort((left, right) => left[0].localeCompare(right[0]))) addSuggestion(suggestGroupedInclude(groupedPaths, root), (relativePath) => groupedPaths.includes(relativePath));
|
|
488
|
-
const byDirectory = /* @__PURE__ */ new Map();
|
|
489
|
-
for (const relativePath of pending) {
|
|
490
|
-
const directory = path.posix.dirname(relativePath);
|
|
491
|
-
const key = directory === "." ? relativePath : directory;
|
|
492
|
-
const bucket = byDirectory.get(key) ?? [];
|
|
493
|
-
bucket.push(relativePath);
|
|
494
|
-
byDirectory.set(key, bucket);
|
|
495
|
-
}
|
|
496
|
-
for (const [key, matchedPaths] of [...byDirectory.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
|
|
497
|
-
if (matchedPaths.length > 1 && key !== "." && key !== matchedPaths[0]) {
|
|
498
|
-
addSuggestion(`${key}/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}`, (relativePath) => relativePath.startsWith(`${key}/`));
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
const exactPath = matchedPaths[0];
|
|
502
|
-
addSuggestion(exactPath, (relativePath) => relativePath === exactPath);
|
|
503
|
-
}
|
|
504
|
-
return suggestions;
|
|
505
|
-
}
|
|
506
|
-
async function discoverStatelySourceFiles(options = {}) {
|
|
507
|
-
const { config, rootDir } = options.config ? {
|
|
508
|
-
config: options.config,
|
|
509
|
-
rootDir: path.resolve(options.cwd ?? process.cwd())
|
|
510
|
-
} : await readStatelyProjectConfig(options);
|
|
511
|
-
const relativeFiles = await filterGitIgnoredPaths(rootDir, await walkFiles(rootDir));
|
|
512
|
-
const discovered = /* @__PURE__ */ new Map();
|
|
513
|
-
const source = {
|
|
514
|
-
include: config.include,
|
|
515
|
-
exclude: config.exclude ?? DEFAULT_SOURCE_EXCLUDES,
|
|
516
|
-
format: "xstate",
|
|
517
|
-
xstateVersion: config.defaultXStateVersion
|
|
518
|
-
};
|
|
519
|
-
for (const relativePath of relativeFiles) {
|
|
520
|
-
if (!matchesAny(source.include, relativePath)) continue;
|
|
521
|
-
if (matchesAny(source.exclude, relativePath)) continue;
|
|
522
|
-
const filePath = path.join(rootDir, relativePath);
|
|
523
|
-
if (!discovered.has(filePath)) discovered.set(filePath, {
|
|
524
|
-
filePath,
|
|
525
|
-
relativePath,
|
|
526
|
-
source
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
return [...discovered.values()].sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
//#endregion
|
|
533
|
-
//#region src/verificationJUnit.ts
|
|
534
|
-
function xml(text) {
|
|
535
|
-
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/gu, "�").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
536
|
-
}
|
|
537
|
-
/** One case per recorded path/probe. Unattributed divergences remain separate cases. */
|
|
538
|
-
function formatVerificationJUnit(report) {
|
|
539
|
-
if (!report.paths) throw new Error("JUnit requires path records. This report predates path reporting.");
|
|
540
|
-
const records = [...report.paths, ...report.probes ?? []];
|
|
541
|
-
const counts = /* @__PURE__ */ new Map();
|
|
542
|
-
for (const record of records) counts.set(record.id, (counts.get(record.id) ?? 0) + 1);
|
|
543
|
-
const hasUniqueRecord = (id) => id !== void 0 && counts.get(id) === 1;
|
|
544
|
-
const cases = [];
|
|
545
|
-
let errors = 0;
|
|
546
|
-
const failures = (items) => items.map((item) => `<failure type="${xml(item.kind)}" message="${xml(`${item.id}; witness: [${verificationWitnessEvents(item).join(", ")}]`)}">${xml(item.observed.error ?? `Expected ${item.expected.state}`)}</failure>`).join("");
|
|
547
|
-
for (const record of records) {
|
|
548
|
-
const divergences = report.divergences.filter((item) => item.pathId === record.id && hasUniqueRecord(item.pathId));
|
|
549
|
-
const failedWithoutWitness = record.status === "failed" && !divergences.length;
|
|
550
|
-
if (failedWithoutWitness) errors++;
|
|
551
|
-
cases.push(`<testcase name="${xml(record.id)}" classname="${xml(report.suite)}">${failures(divergences)}${failedWithoutWitness ? "<error type=\"unattributed_path_failure\" message=\"Path failed without a linked divergence; consult the run report.\"/>" : ""}</testcase>`);
|
|
552
|
-
}
|
|
553
|
-
for (const item of report.divergences.filter((item) => !hasUniqueRecord(item.pathId))) cases.push(`<testcase name="${xml(item.id)}" classname="${xml(report.suite)}">${failures([item])}</testcase>`);
|
|
554
|
-
if (report.verdict === "inconclusive" || report.status === "failed" && !errors && !report.divergences.length) {
|
|
555
|
-
errors++;
|
|
556
|
-
cases.push("<testcase name=\"verification incomplete\"><error type=\"inconclusive\" message=\"Verification did not produce a conclusive successful run.\"/></testcase>");
|
|
557
|
-
}
|
|
558
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="${xml(report.suite)}" tests="${cases.length}" failures="${report.divergences.length}" errors="${errors}" time="${report.durationMs / 1e3}">\n${cases.join("\n")}\n</testsuite>\n`;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
//#endregion
|
|
562
|
-
//#region src/verification.ts
|
|
563
|
-
const maxInputBytes = 10 * 1024 * 1024;
|
|
564
|
-
async function readInput(file) {
|
|
565
|
-
if ((await fsPromises.stat(file)).size > maxInputBytes) throw new Error(`Verification input exceeds ${maxInputBytes} bytes: ${file}`);
|
|
566
|
-
return fsPromises.readFile(file, "utf8");
|
|
567
|
-
}
|
|
568
|
-
async function localFile(root, reference) {
|
|
569
|
-
const file = path.resolve(root, reference);
|
|
570
|
-
try {
|
|
571
|
-
if (!(await fsPromises.stat(file)).isFile()) throw new Error(`Not a file: ${reference}`);
|
|
572
|
-
return file;
|
|
573
|
-
} catch (error) {
|
|
574
|
-
if (error.code !== "ENOENT") throw error;
|
|
575
|
-
if (reference.includes("/") || reference.includes("\\") || path.extname(reference) || reference.startsWith(".")) throw new Error(`Verification input not found: ${reference}`);
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
/** Read local source as data; never import or execute model/bindings modules. */
|
|
580
|
-
async function resolveVerificationRequest(options) {
|
|
581
|
-
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
582
|
-
const inputFiles = [];
|
|
583
|
-
let model = options.model;
|
|
584
|
-
if (!model) {
|
|
585
|
-
const project = await readStatelyProjectConfig({
|
|
586
|
-
cwd,
|
|
587
|
-
configPath: options.configPath
|
|
588
|
-
});
|
|
589
|
-
const files = await discoverStatelySourceFiles({
|
|
590
|
-
cwd: project.rootDir,
|
|
591
|
-
config: project.config
|
|
592
|
-
});
|
|
593
|
-
if (files.length !== 1) throw new Error(`Expected one configured model, found ${files.length}. Select one with --model.`);
|
|
594
|
-
model = files[0].filePath;
|
|
595
|
-
}
|
|
596
|
-
const modelFile = await localFile(cwd, model);
|
|
597
|
-
const request = { model: modelFile ? {
|
|
598
|
-
kind: "source",
|
|
599
|
-
content: await readInput(modelFile),
|
|
600
|
-
name: path.basename(modelFile)
|
|
601
|
-
} : {
|
|
602
|
-
kind: "machine",
|
|
603
|
-
machineId: model.replace(/^machine:/, "")
|
|
604
|
-
} };
|
|
605
|
-
if (modelFile) inputFiles.push(modelFile);
|
|
606
|
-
if (options.bindings) {
|
|
607
|
-
const file = path.resolve(cwd, options.bindings);
|
|
608
|
-
request.bindings = {
|
|
609
|
-
content: await readInput(file),
|
|
610
|
-
name: path.basename(file)
|
|
611
|
-
};
|
|
612
|
-
inputFiles.push(file);
|
|
613
|
-
}
|
|
614
|
-
if (options.baseline) {
|
|
615
|
-
const file = await localFile(cwd, options.baseline);
|
|
616
|
-
request.baseline = file ? {
|
|
617
|
-
kind: "report",
|
|
618
|
-
report: verificationRunReportSchema.parse(JSON.parse(await readInput(file)))
|
|
619
|
-
} : {
|
|
620
|
-
kind: "reference",
|
|
621
|
-
id: options.baseline
|
|
622
|
-
};
|
|
623
|
-
if (file) inputFiles.push(file);
|
|
624
|
-
}
|
|
625
|
-
return {
|
|
626
|
-
request: verificationRequestSchema.parse(request),
|
|
627
|
-
inputFiles
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
async function verifyProject(options = {}) {
|
|
631
|
-
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
632
|
-
let report;
|
|
633
|
-
let inputFiles;
|
|
634
|
-
if (options.fromReport) {
|
|
635
|
-
if (options.model || options.bindings || options.baseline || options.configPath) throw new Error("--from-report cannot be combined with model, bindings, baseline, or config inputs.");
|
|
636
|
-
const file = path.resolve(cwd, options.fromReport);
|
|
637
|
-
report = verificationRunReportSchema.parse(JSON.parse(await readInput(file)));
|
|
638
|
-
inputFiles = [file];
|
|
639
|
-
} else {
|
|
640
|
-
if (!options.execute) throw new Error("Live verification is not configured. Use --from-report <file> to process an existing run report, or supply a VerificationExecutor to verifyProject().");
|
|
641
|
-
const resolved = await resolveVerificationRequest(options);
|
|
642
|
-
inputFiles = resolved.inputFiles;
|
|
643
|
-
report = verificationRunReportSchema.parse(await options.execute(resolved.request, verificationExecutionPolicy));
|
|
644
|
-
}
|
|
645
|
-
const format = options.reportFormat ?? "json";
|
|
646
|
-
const output = format === "junit" ? formatVerificationJUnit(report) : `${JSON.stringify(report, null, 2)}\n`;
|
|
647
|
-
const destination = options.report ?? `statelyai-verify-report.${format === "junit" ? "xml" : "json"}`;
|
|
648
|
-
if (destination !== "-") {
|
|
649
|
-
const file = path.resolve(cwd, destination);
|
|
650
|
-
const existing = await fsPromises.realpath(file).catch((error) => {
|
|
651
|
-
if (error.code === "ENOENT") return file;
|
|
652
|
-
throw error;
|
|
653
|
-
});
|
|
654
|
-
for (const input of inputFiles) if (existing === await fsPromises.realpath(input)) throw new Error("Report output cannot overwrite a verification input.");
|
|
655
|
-
await fsPromises.mkdir(path.dirname(file), { recursive: true });
|
|
656
|
-
await fsPromises.writeFile(file, output, "utf8");
|
|
657
|
-
}
|
|
658
|
-
return {
|
|
659
|
-
report,
|
|
660
|
-
output,
|
|
661
|
-
destination,
|
|
662
|
-
summary: `${options.fromReport ? "Saved report: " : ""}${formatVerificationSummary(report)}`,
|
|
663
|
-
exitCode: verificationExitCode(report, options.failOn)
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
//#endregion
|
|
668
|
-
//#region src/verifyCommand.ts
|
|
669
|
-
var VerifyCommand = class VerifyCommand extends Command {
|
|
670
|
-
static enableJsonFlag = false;
|
|
671
|
-
static summary = "Validate a verification report, print its verdict, and export JSON or JUnit.";
|
|
672
|
-
static description = "Saved-report processing works offline. Live execution requires a configured verification adapter.";
|
|
673
|
-
static flags = {
|
|
674
|
-
model: Flags.string({ description: "Model file or registry machine ID (live execution)" }),
|
|
675
|
-
bindings: Flags.string({ description: "Bindings file (live execution)" }),
|
|
676
|
-
baseline: Flags.string({ description: "Baseline report file or reference ID (live execution)" }),
|
|
677
|
-
config: Flags.string({ description: "Path to statelyai.json (live execution)" }),
|
|
678
|
-
"from-report": Flags.string({ description: "Process an existing run report without executing verification" }),
|
|
679
|
-
report: Flags.string({ description: "Output file, or - for stdout; defaults to statelyai-verify-report.json/xml" }),
|
|
680
|
-
"report-format": Flags.string({
|
|
681
|
-
options: ["json", "junit"],
|
|
682
|
-
default: "json"
|
|
683
|
-
}),
|
|
684
|
-
"fail-on": Flags.string({
|
|
685
|
-
options: ["divergent", "inconclusive"],
|
|
686
|
-
default: "divergent",
|
|
687
|
-
description: "inconclusive also fails on divergences"
|
|
688
|
-
})
|
|
689
|
-
};
|
|
690
|
-
async run() {
|
|
691
|
-
const { flags } = await this.parse(VerifyCommand);
|
|
692
|
-
let result;
|
|
693
|
-
try {
|
|
694
|
-
result = await verifyProject({
|
|
695
|
-
model: flags.model,
|
|
696
|
-
bindings: flags.bindings,
|
|
697
|
-
baseline: flags.baseline,
|
|
698
|
-
configPath: flags.config,
|
|
699
|
-
fromReport: flags["from-report"],
|
|
700
|
-
report: flags.report,
|
|
701
|
-
reportFormat: flags["report-format"],
|
|
702
|
-
failOn: flags["fail-on"]
|
|
703
|
-
});
|
|
704
|
-
} catch (error) {
|
|
705
|
-
this.error(error instanceof Error ? error.message : String(error), { exit: 2 });
|
|
706
|
-
}
|
|
707
|
-
this.log(result.destination === "-" ? result.output.trimEnd() : result.summary);
|
|
708
|
-
if (result.exitCode) this.exit(result.exitCode);
|
|
709
|
-
}
|
|
710
|
-
};
|
|
711
|
-
|
|
712
|
-
//#endregion
|
|
713
|
-
//#region src/version.ts
|
|
714
|
-
const CLI_VERSION = "0.11.4";
|
|
715
|
-
|
|
716
|
-
//#endregion
|
|
717
|
-
//#region src/cliHost.ts
|
|
718
|
-
const DEFAULT_SYNC_MAX_FILES = 150;
|
|
719
|
-
const DEFAULT_SYNC_MAX_BYTES = 2e6;
|
|
720
|
-
const supportedDocumentPattern = /\.(?:c|m)?(?:jsx?|tsx?)$|\.jsonc?$|\.ya?ml$|\.mmd$|\.mermaid$/i;
|
|
721
|
-
var WebSocketClient = class {
|
|
722
|
-
listeners = /* @__PURE__ */ new Set();
|
|
723
|
-
constructor(socket) {
|
|
724
|
-
this.socket = socket;
|
|
725
|
-
socket.on("message", (data, isBinary) => {
|
|
726
|
-
if (isBinary) return;
|
|
727
|
-
let message;
|
|
728
|
-
try {
|
|
729
|
-
message = JSON.parse(rawDataToBuffer(data).toString("utf8"));
|
|
730
|
-
} catch {
|
|
731
|
-
socket.close(1007, "Invalid JSON payload");
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
for (const listener of this.listeners) listener(message);
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
|
-
onMessage(listener) {
|
|
738
|
-
this.listeners.add(listener);
|
|
739
|
-
}
|
|
740
|
-
send(message) {
|
|
741
|
-
if (this.socket.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
|
|
742
|
-
}
|
|
743
|
-
close() {
|
|
744
|
-
if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) this.socket.close();
|
|
745
|
-
}
|
|
746
|
-
};
|
|
747
|
-
function rawDataToBuffer(data) {
|
|
748
|
-
if (Array.isArray(data)) return Buffer.concat(data);
|
|
749
|
-
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
750
|
-
return Buffer.from(data);
|
|
751
|
-
}
|
|
752
|
-
var RemoteEditorSession = class {
|
|
753
|
-
session;
|
|
754
|
-
workspaceWatcher;
|
|
755
|
-
constructor(options, sendMessage) {
|
|
756
|
-
this.options = options;
|
|
757
|
-
const send = (message) => {
|
|
758
|
-
this.log("send", message.type, message);
|
|
759
|
-
sendMessage(message);
|
|
760
|
-
};
|
|
761
|
-
const machines = options.machines ?? [{
|
|
762
|
-
id: `${options.rootUri}#0`,
|
|
763
|
-
fileName: options.fileName,
|
|
764
|
-
machineIndex: 0
|
|
765
|
-
}];
|
|
766
|
-
this.workspaceWatcher = createSharedWorkspaceWatcher(options.rootDir);
|
|
767
|
-
this.session = new EditorHostWorkspaceSession({
|
|
768
|
-
machines: machines.map((machine) => ({
|
|
769
|
-
id: machine.id,
|
|
770
|
-
name: machine.name,
|
|
771
|
-
source: {
|
|
772
|
-
rootUri: fileNameToUri(machine.fileName),
|
|
773
|
-
rootFileName: machine.fileName,
|
|
774
|
-
rootDir: options.rootDir,
|
|
775
|
-
machineIndex: machine.machineIndex,
|
|
776
|
-
collectDocuments: (extraUris) => collectSourceDocuments({
|
|
777
|
-
rootFileName: machine.fileName,
|
|
778
|
-
extraUris: [...extraUris ?? []]
|
|
779
|
-
}),
|
|
780
|
-
applyReplacements: async (replacements, rootUri) => {
|
|
781
|
-
await applyFileReplacements(replacements, rootUri);
|
|
782
|
-
return [...new Set(replacements.map((replacement) => replacement.uri ?? rootUri))];
|
|
783
|
-
},
|
|
784
|
-
watchSavedDocuments: (listener) => this.workspaceWatcher.subscribe(listener)
|
|
785
|
-
}
|
|
786
|
-
})),
|
|
787
|
-
currentMachineId: machines[0]?.id,
|
|
788
|
-
workspaceName: path$1.basename(options.rootDir),
|
|
789
|
-
projectWorkspaceOpen: false,
|
|
790
|
-
sourceUpdateStrategy: options.sourceUpdateStrategy ?? "preserve",
|
|
791
|
-
sync: createRemoteEditorSyncBackend({
|
|
792
|
-
editorUrl: options.editorUrl,
|
|
793
|
-
apiKey: options.apiKey,
|
|
794
|
-
fetch: (input, init) => fetchEditorHost(input instanceof URL ? input : new URL(String(input)), init)
|
|
795
|
-
}),
|
|
796
|
-
send,
|
|
797
|
-
apiKey: options.apiKey,
|
|
798
|
-
client: {
|
|
799
|
-
name: "statelyai",
|
|
800
|
-
version: CLI_VERSION
|
|
801
|
-
},
|
|
802
|
-
showError: (message) => console.error(`Stately Editor: ${message}`)
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
receiveMessage(message) {
|
|
806
|
-
this.log("recv", message.type, message);
|
|
807
|
-
return this.session.receive(message);
|
|
808
|
-
}
|
|
809
|
-
dispose() {
|
|
810
|
-
this.session.dispose();
|
|
811
|
-
this.workspaceWatcher.dispose();
|
|
812
|
-
}
|
|
813
|
-
log(direction, type, payload) {
|
|
814
|
-
if (this.options.debug) console.error(`[${direction}] ${type}`, redactDebugPayload(payload) ?? "");
|
|
815
|
-
}
|
|
816
|
-
};
|
|
817
|
-
function redactDebugPayload(payload) {
|
|
818
|
-
if (Array.isArray(payload)) return payload.map(redactDebugPayload);
|
|
819
|
-
if (!payload || typeof payload !== "object") return payload;
|
|
820
|
-
return Object.fromEntries(Object.entries(payload).map(([key, value]) => [key, /^(?:apiKey|accessToken|refreshToken|token|authorization)$/i.test(key) ? "[REDACTED]" : redactDebugPayload(value)]));
|
|
821
|
-
}
|
|
822
|
-
async function openEditor(options) {
|
|
823
|
-
const fileName = path$1.resolve(options.fileName);
|
|
824
|
-
const machines = (options.machines ?? [{
|
|
825
|
-
id: `${fileNameToUri(fileName)}#0`,
|
|
826
|
-
fileName,
|
|
827
|
-
machineIndex: 0
|
|
828
|
-
}]).map((machine) => ({
|
|
829
|
-
...machine,
|
|
830
|
-
fileName: path$1.resolve(machine.fileName)
|
|
831
|
-
}));
|
|
832
|
-
await Promise.all([...new Set(machines.map((machine) => machine.fileName))].map((target) => fsPromises.access(target)));
|
|
833
|
-
await assertEditorHostAvailable(options.editorUrl);
|
|
834
|
-
const rootUri = fileNameToUri(fileName);
|
|
835
|
-
const rootDir = path$1.resolve(options.rootDir ?? path$1.dirname(fileName));
|
|
836
|
-
let activeClient;
|
|
837
|
-
let session;
|
|
838
|
-
const server = http.createServer((request, response) => {
|
|
839
|
-
if (new URL(request.url ?? "/", `http://${request.headers.host}`).pathname !== "/") {
|
|
840
|
-
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
841
|
-
response.end("Not found");
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
845
|
-
response.end(getCliWebviewContent({ editorUrl: options.editorUrl }));
|
|
846
|
-
});
|
|
847
|
-
const webSocketServer = new WebSocketServer({ noServer: true });
|
|
848
|
-
server.on("upgrade", (request, socket, head) => {
|
|
849
|
-
if (request.url !== "/ws") {
|
|
850
|
-
socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
|
|
854
|
-
activeClient?.close();
|
|
855
|
-
session?.dispose();
|
|
856
|
-
activeClient = new WebSocketClient(webSocket);
|
|
857
|
-
session = new RemoteEditorSession({
|
|
858
|
-
...options,
|
|
859
|
-
fileName,
|
|
860
|
-
machines,
|
|
861
|
-
rootUri,
|
|
862
|
-
rootDir
|
|
863
|
-
}, (message) => activeClient?.send(message));
|
|
864
|
-
activeClient.onMessage((message) => {
|
|
865
|
-
session?.receiveMessage(message);
|
|
866
|
-
});
|
|
867
|
-
});
|
|
868
|
-
});
|
|
869
|
-
await listen(server, options.port, options.host);
|
|
870
|
-
const address = server.address();
|
|
871
|
-
const port = typeof address === "object" && address ? address.port : options.port;
|
|
872
|
-
const editorPageUrl = `http://${options.host}:${port}`;
|
|
873
|
-
console.log(`Stately visual editor: ${editorPageUrl}`);
|
|
874
|
-
console.log(`Editing ${machines.length} machine${machines.length === 1 ? "" : "s"} in ${rootDir}`);
|
|
875
|
-
if (options.shouldOpen) openBrowser(editorPageUrl);
|
|
876
|
-
const shutdown = () => {
|
|
877
|
-
session?.dispose();
|
|
878
|
-
activeClient?.close();
|
|
879
|
-
webSocketServer.close();
|
|
880
|
-
server.close();
|
|
881
|
-
};
|
|
882
|
-
process.once("SIGINT", () => {
|
|
883
|
-
shutdown();
|
|
884
|
-
process.exit(0);
|
|
885
|
-
});
|
|
886
|
-
process.once("SIGTERM", () => {
|
|
887
|
-
shutdown();
|
|
888
|
-
process.exit(0);
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
async function collectSourceDocuments(options) {
|
|
892
|
-
const maxFiles = Number.parseInt(process.env.STATELY_SYNC_MAX_FILES ?? "", 10) || DEFAULT_SYNC_MAX_FILES;
|
|
893
|
-
const maxBytes = Number.parseInt(process.env.STATELY_SYNC_MAX_BYTES ?? "", 10) || DEFAULT_SYNC_MAX_BYTES;
|
|
894
|
-
const documents = /* @__PURE__ */ new Map();
|
|
895
|
-
const queue = [];
|
|
896
|
-
let totalBytes = 0;
|
|
897
|
-
for (const fileName of [options.rootFileName, ...(options.extraUris ?? []).map(sourceUriToFileName)]) {
|
|
898
|
-
const document = await openSourceDocument(fileName);
|
|
899
|
-
if (document) addDocument(document);
|
|
900
|
-
}
|
|
901
|
-
let packageDirectory = path$1.dirname(path$1.resolve(options.rootFileName));
|
|
902
|
-
while (true) {
|
|
903
|
-
const packageDocument = await openSourceDocument(path$1.join(packageDirectory, "package.json"));
|
|
904
|
-
if (packageDocument) addDocument(packageDocument);
|
|
905
|
-
const parent = path$1.dirname(packageDirectory);
|
|
906
|
-
if (parent === packageDirectory) break;
|
|
907
|
-
packageDirectory = parent;
|
|
908
|
-
}
|
|
909
|
-
for (let index = 0; index < queue.length; index++) {
|
|
910
|
-
const document = queue[index];
|
|
911
|
-
if (path$1.basename(document.fileName) === "package.json") continue;
|
|
912
|
-
for (const specifier of collectRelativeImportSpecifiers(document.text, document.fileName)) for (const candidate of resolveRelativeImportCandidates(document.fileName, specifier)) {
|
|
913
|
-
if (documents.has(path$1.resolve(candidate))) break;
|
|
914
|
-
const importedDocument = await openSourceDocument(candidate);
|
|
915
|
-
if (!importedDocument) continue;
|
|
916
|
-
addDocument(importedDocument);
|
|
917
|
-
break;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
return [...documents.values()];
|
|
921
|
-
function addDocument(document) {
|
|
922
|
-
const normalizedFileName = path$1.resolve(document.fileName);
|
|
923
|
-
if (documents.has(normalizedFileName)) return;
|
|
924
|
-
if (documents.size >= maxFiles) throw new Error(`Source payload exceeds ${maxFiles} reachable files.`);
|
|
925
|
-
const byteLength = Buffer.byteLength(document.text);
|
|
926
|
-
if (totalBytes + byteLength > maxBytes) throw new Error(`Source payload exceeds ${maxBytes} bytes.`);
|
|
927
|
-
documents.set(normalizedFileName, document);
|
|
928
|
-
queue.push(document);
|
|
929
|
-
totalBytes += byteLength;
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
async function openSourceDocument(fileName) {
|
|
933
|
-
if (!isSupportedDocument(fileName)) return null;
|
|
934
|
-
const text = await fsPromises.readFile(fileName, "utf8").catch(() => null);
|
|
935
|
-
return text === null ? null : {
|
|
936
|
-
uri: fileNameToUri(fileName),
|
|
937
|
-
fileName,
|
|
938
|
-
text
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
function collectRelativeImportSpecifiers(text, fileName) {
|
|
942
|
-
const sourceFile = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, getScriptKind(fileName));
|
|
943
|
-
const specifiers = [];
|
|
944
|
-
for (const statement of sourceFile.statements) {
|
|
945
|
-
const moduleSpecifier = ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement) ? statement.moduleSpecifier : void 0;
|
|
946
|
-
if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text.startsWith(".")) specifiers.push(moduleSpecifier.text);
|
|
947
|
-
}
|
|
948
|
-
return specifiers;
|
|
949
|
-
}
|
|
950
|
-
const importExtensions = [
|
|
951
|
-
".ts",
|
|
952
|
-
".tsx",
|
|
953
|
-
".js",
|
|
954
|
-
".jsx",
|
|
955
|
-
".mjs",
|
|
956
|
-
".cjs",
|
|
957
|
-
".mts",
|
|
958
|
-
".cts"
|
|
959
|
-
];
|
|
960
|
-
function resolveRelativeImportCandidates(fromFileName, specifier) {
|
|
961
|
-
const basePath = path$1.resolve(path$1.dirname(fromFileName), specifier);
|
|
962
|
-
if (path$1.extname(basePath)) return [basePath];
|
|
963
|
-
return [
|
|
964
|
-
basePath,
|
|
965
|
-
...importExtensions.map((extension) => `${basePath}${extension}`),
|
|
966
|
-
...importExtensions.map((extension) => path$1.join(basePath, `index${extension}`))
|
|
967
|
-
];
|
|
968
|
-
}
|
|
969
|
-
function getScriptKind(fileName) {
|
|
970
|
-
if (fileName.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
971
|
-
if (fileName.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
972
|
-
if (/\.(?:c|m)?js$/.test(fileName)) return ts.ScriptKind.JS;
|
|
973
|
-
return ts.ScriptKind.TS;
|
|
974
|
-
}
|
|
975
|
-
function isSupportedDocument(fileName) {
|
|
976
|
-
return supportedDocumentPattern.test(fileName);
|
|
977
|
-
}
|
|
978
|
-
async function applyFileReplacements(replacements, defaultUri) {
|
|
979
|
-
const replacementsByUri = /* @__PURE__ */ new Map();
|
|
980
|
-
for (const replacement of replacements) {
|
|
981
|
-
const uri = replacement.uri ?? defaultUri;
|
|
982
|
-
const existing = replacementsByUri.get(uri) ?? [];
|
|
983
|
-
existing.push(replacement);
|
|
984
|
-
replacementsByUri.set(uri, existing);
|
|
985
|
-
}
|
|
986
|
-
for (const [uri, uriReplacements] of replacementsByUri) {
|
|
987
|
-
const fileName = sourceUriToFileName(uri);
|
|
988
|
-
const nextText = applyReplacementsToText(await fsPromises.readFile(fileName, "utf8"), uriReplacements);
|
|
989
|
-
await fsPromises.writeFile(fileName, nextText, "utf8");
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
function applyReplacementsToText(text, replacements) {
|
|
993
|
-
let updated = text;
|
|
994
|
-
const sorted = [...replacements].sort(compareReplacementRangesDescending);
|
|
995
|
-
for (const replacement of sorted) {
|
|
996
|
-
const lineStarts = getLineStarts(updated);
|
|
997
|
-
const start = offsetAt(lineStarts, replacement.range.startLine, replacement.range.startChar);
|
|
998
|
-
const end = offsetAt(lineStarts, replacement.range.endLine, replacement.range.endChar);
|
|
999
|
-
updated = `${updated.slice(0, start)}${replacement.newText}${updated.slice(end)}`;
|
|
1000
|
-
}
|
|
1001
|
-
return updated;
|
|
1002
|
-
}
|
|
1003
|
-
function watchWorkspace(rootDir, listener) {
|
|
1004
|
-
let watcher;
|
|
1005
|
-
const timers = /* @__PURE__ */ new Map();
|
|
1006
|
-
const queue = (fileName) => {
|
|
1007
|
-
const existing = timers.get(fileName);
|
|
1008
|
-
if (existing) clearTimeout(existing);
|
|
1009
|
-
timers.set(fileName, setTimeout(() => {
|
|
1010
|
-
timers.delete(fileName);
|
|
1011
|
-
listener(fileNameToUri(fileName));
|
|
1012
|
-
}, 75));
|
|
1013
|
-
};
|
|
1014
|
-
try {
|
|
1015
|
-
watcher = watch(rootDir, { recursive: true }, (_event, fileName) => {
|
|
1016
|
-
if (fileName) queue(path$1.join(rootDir, fileName.toString()));
|
|
1017
|
-
});
|
|
1018
|
-
} catch {
|
|
1019
|
-
watcher = watch(rootDir, (_event, fileName) => {
|
|
1020
|
-
if (fileName) queue(path$1.join(rootDir, fileName.toString()));
|
|
1021
|
-
});
|
|
1022
|
-
}
|
|
1023
|
-
return { dispose() {
|
|
1024
|
-
watcher?.close();
|
|
1025
|
-
for (const timer of timers.values()) clearTimeout(timer);
|
|
1026
|
-
timers.clear();
|
|
1027
|
-
} };
|
|
1028
|
-
}
|
|
1029
|
-
function createSharedWorkspaceWatcher(rootDir) {
|
|
1030
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
1031
|
-
const watcher = watchWorkspace(rootDir, (uri) => {
|
|
1032
|
-
for (const listener of listeners) listener(uri);
|
|
1033
|
-
});
|
|
1034
|
-
return {
|
|
1035
|
-
subscribe(listener) {
|
|
1036
|
-
listeners.add(listener);
|
|
1037
|
-
return { dispose: () => listeners.delete(listener) };
|
|
1038
|
-
},
|
|
1039
|
-
dispose() {
|
|
1040
|
-
listeners.clear();
|
|
1041
|
-
watcher.dispose();
|
|
1042
|
-
}
|
|
1043
|
-
};
|
|
1044
|
-
}
|
|
1045
|
-
function getCliWebviewContent(options) {
|
|
1046
|
-
const baseUrl = normalizedBaseUrl(options.editorUrl);
|
|
1047
|
-
const url = new URL(`${baseUrl}/embed`);
|
|
1048
|
-
url.searchParams.set("auth", "message");
|
|
1049
|
-
return `<!DOCTYPE html>
|
|
1050
|
-
<html lang="en" style="height:100%;margin:0">
|
|
1051
|
-
<head>
|
|
1052
|
-
<meta charset="UTF-8">
|
|
1053
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1054
|
-
<style>
|
|
1055
|
-
body { margin: 0; padding: 0; height: 100vh; overflow: hidden; }
|
|
1056
|
-
iframe { border: 0; width: 100%; height: 100%; display: block; }
|
|
1057
|
-
</style>
|
|
1058
|
-
</head>
|
|
1059
|
-
<body>
|
|
1060
|
-
<iframe
|
|
1061
|
-
id="stately-editor"
|
|
1062
|
-
src="${escapeAttribute(url.toString())}"
|
|
1063
|
-
allow="clipboard-read; clipboard-write"
|
|
1064
|
-
></iframe>
|
|
1065
|
-
<script>
|
|
1066
|
-
const iframe = document.getElementById('stately-editor');
|
|
1067
|
-
const socket = new WebSocket(
|
|
1068
|
-
(location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws'
|
|
1069
|
-
);
|
|
1070
|
-
const pending = [];
|
|
1071
|
-
|
|
1072
|
-
function sendToCli(message) {
|
|
1073
|
-
if (socket.readyState === WebSocket.OPEN) {
|
|
1074
|
-
socket.send(JSON.stringify(message));
|
|
1075
|
-
return;
|
|
1076
|
-
}
|
|
1077
|
-
pending.push(message);
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
socket.addEventListener('open', () => {
|
|
1081
|
-
while (pending.length > 0) {
|
|
1082
|
-
socket.send(JSON.stringify(pending.shift()));
|
|
1083
|
-
}
|
|
1084
|
-
});
|
|
1085
|
-
|
|
1086
|
-
socket.addEventListener('message', (event) => {
|
|
1087
|
-
iframe.contentWindow?.postMessage(JSON.parse(event.data), '*');
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
window.addEventListener('message', (event) => {
|
|
1091
|
-
if (!event.data?.type?.startsWith?.('@statelyai.')) {
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
sendToCli(event.data);
|
|
1095
|
-
});
|
|
1096
|
-
<\/script>
|
|
1097
|
-
</body>
|
|
1098
|
-
</html>`;
|
|
1099
|
-
}
|
|
1100
|
-
function listen(server, port, host) {
|
|
1101
|
-
return new Promise((resolve) => {
|
|
1102
|
-
server.listen(port, host, resolve);
|
|
1103
|
-
});
|
|
1104
|
-
}
|
|
1105
|
-
async function fetchEditorHost(input, init) {
|
|
1106
|
-
const url = typeof input === "string" ? new URL(input) : input;
|
|
1107
|
-
return fetch(url, init);
|
|
1108
|
-
}
|
|
1109
|
-
async function assertEditorHostAvailable(editorUrl) {
|
|
1110
|
-
const baseUrl = normalizedBaseUrl(editorUrl);
|
|
1111
|
-
const embedUrl = new URL("/embed", baseUrl);
|
|
1112
|
-
let response;
|
|
1113
|
-
try {
|
|
1114
|
-
response = await fetchEditorHost(embedUrl, { redirect: "manual" });
|
|
1115
|
-
} catch (error) {
|
|
1116
|
-
throw new Error(formatEditorHostError(baseUrl, void 0, error));
|
|
1117
|
-
}
|
|
1118
|
-
if (response.status === 404) throw new Error(formatEditorHostError(baseUrl, response.status));
|
|
1119
|
-
}
|
|
1120
|
-
function formatEditorHostError(baseUrl, status, cause) {
|
|
1121
|
-
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\`. Start the editor host, set \`STATELY_EDITOR_URL\`, or pass \`--editor-url <url>\`.`;
|
|
1122
|
-
}
|
|
1123
|
-
function openBrowser(url) {
|
|
1124
|
-
spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open", process.platform === "win32" ? [
|
|
1125
|
-
"/c",
|
|
1126
|
-
"start",
|
|
1127
|
-
"",
|
|
1128
|
-
url
|
|
1129
|
-
] : [url], {
|
|
1130
|
-
detached: true,
|
|
1131
|
-
stdio: "ignore"
|
|
1132
|
-
}).unref();
|
|
1133
|
-
}
|
|
1134
|
-
function fileNameToUri(fileName) {
|
|
1135
|
-
return pathToFileURL(path$1.resolve(fileName)).toString();
|
|
1136
|
-
}
|
|
1137
|
-
function sourceUriToFileName(uriOrPath) {
|
|
1138
|
-
if (uriOrPath.startsWith("file://")) return fileURLToPath(uriOrPath);
|
|
1139
|
-
if (/^[a-z][a-z\d+.-]*:/i.test(uriOrPath)) throw new Error(`Unsupported non-file URI: ${uriOrPath}`);
|
|
1140
|
-
return path$1.resolve(uriOrPath);
|
|
1141
|
-
}
|
|
1142
|
-
function getLineStarts(text) {
|
|
1143
|
-
const lineStarts = [0];
|
|
1144
|
-
for (let index = 0; index < text.length; index += 1) if (text.charCodeAt(index) === 10) lineStarts.push(index + 1);
|
|
1145
|
-
return lineStarts;
|
|
1146
|
-
}
|
|
1147
|
-
function offsetAt(lineStarts, line, character) {
|
|
1148
|
-
const lineStart = lineStarts[line];
|
|
1149
|
-
if (lineStart === void 0) return lineStarts[lineStarts.length - 1] + character;
|
|
1150
|
-
return lineStart + character;
|
|
1151
|
-
}
|
|
1152
|
-
function compareReplacementRangesDescending(left, right) {
|
|
1153
|
-
if (left.range.startLine !== right.range.startLine) return right.range.startLine - left.range.startLine;
|
|
1154
|
-
return right.range.startChar - left.range.startChar;
|
|
1155
|
-
}
|
|
1156
|
-
function normalizedBaseUrl(value) {
|
|
1157
|
-
return value.replace(/\/+$/, "");
|
|
1158
|
-
}
|
|
1159
|
-
function escapeAttribute(value) {
|
|
1160
|
-
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<").replaceAll(">", ">");
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
//#endregion
|
|
1164
|
-
//#region src/oauthCallbackServer.ts
|
|
1165
|
-
async function createLocalOAuthCallbackServer(options) {
|
|
1166
|
-
let resolveCode;
|
|
1167
|
-
let rejectCode;
|
|
1168
|
-
const codePromise = new Promise((resolve, reject) => {
|
|
1169
|
-
resolveCode = resolve;
|
|
1170
|
-
rejectCode = reject;
|
|
1171
|
-
});
|
|
1172
|
-
const server = http.createServer((req, res) => {
|
|
1173
|
-
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1174
|
-
const code = requestUrl.searchParams.get("code");
|
|
1175
|
-
const state = requestUrl.searchParams.get("state");
|
|
1176
|
-
const error = requestUrl.searchParams.get("error");
|
|
1177
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1178
|
-
res.setHeader("Connection", "close");
|
|
1179
|
-
if (error) {
|
|
1180
|
-
res.statusCode = 400;
|
|
1181
|
-
res.end("<!doctype html><title>Sign in failed</title><p>Sign in failed. Return to the terminal.</p>");
|
|
1182
|
-
rejectCode(new Error(error));
|
|
1183
|
-
return;
|
|
1184
|
-
}
|
|
1185
|
-
if (!code || state !== options.state) {
|
|
1186
|
-
res.statusCode = 400;
|
|
1187
|
-
res.end("<!doctype html><title>Sign in failed</title><p>Invalid sign-in callback. Return to the terminal.</p>");
|
|
1188
|
-
rejectCode(/* @__PURE__ */ new Error("Invalid OAuth callback."));
|
|
1189
|
-
return;
|
|
1190
|
-
}
|
|
1191
|
-
res.end("<!doctype html><title>Signed in</title><p>Signed in. You can close this window.</p>");
|
|
1192
|
-
resolveCode(code);
|
|
1193
|
-
});
|
|
1194
|
-
await new Promise((resolve) => {
|
|
1195
|
-
server.listen(0, "127.0.0.1", resolve);
|
|
1196
|
-
});
|
|
1197
|
-
const address = server.address();
|
|
1198
|
-
if (!address || typeof address === "string") throw new Error("Could not start OAuth callback server.");
|
|
1199
|
-
return {
|
|
1200
|
-
redirectUri: `http://127.0.0.1:${address.port}/callback`,
|
|
1201
|
-
waitForCode: () => codePromise,
|
|
1202
|
-
close: () => new Promise((resolve) => {
|
|
1203
|
-
server.close(() => resolve());
|
|
1204
|
-
})
|
|
1205
|
-
};
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
//#endregion
|
|
1209
|
-
//#region src/credentials.ts
|
|
1210
|
-
const execFile$1 = promisify(execFile);
|
|
1211
|
-
const SERVICE_NAME = "statelyai";
|
|
1212
|
-
const ACCOUNT_NAME = "api-key";
|
|
1213
|
-
const CREDENTIALS_FILE_NAME = "credentials.json";
|
|
1214
|
-
function getConfigDir() {
|
|
1215
|
-
const override = process.env.STATELYAI_CONFIG_DIR;
|
|
1216
|
-
if (override) return path.resolve(override);
|
|
1217
|
-
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", SERVICE_NAME);
|
|
1218
|
-
if (process.platform === "win32") return path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), SERVICE_NAME);
|
|
1219
|
-
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), SERVICE_NAME);
|
|
1220
|
-
}
|
|
1221
|
-
function getCredentialsFilePath() {
|
|
1222
|
-
return path.join(getConfigDir(), CREDENTIALS_FILE_NAME);
|
|
1223
|
-
}
|
|
1224
|
-
function shouldUseFileBackendOnly() {
|
|
1225
|
-
return process.env.STATELYAI_CREDENTIALS_BACKEND === "file" || process.env.STATELYAI_CONFIG_DIR !== void 0;
|
|
1226
|
-
}
|
|
1227
|
-
function parseStoredCredential(raw, backend, location) {
|
|
1228
|
-
try {
|
|
1229
|
-
const parsed = JSON.parse(raw);
|
|
1230
|
-
if (typeof parsed.apiKey === "string" && parsed.apiKey.length > 0) return {
|
|
1231
|
-
credential: {
|
|
1232
|
-
type: "api_key",
|
|
1233
|
-
token: parsed.apiKey
|
|
1234
|
-
},
|
|
1235
|
-
backend,
|
|
1236
|
-
location
|
|
1237
|
-
};
|
|
1238
|
-
if (parsed.authMode === "none") return {
|
|
1239
|
-
authMode: "none",
|
|
1240
|
-
backend,
|
|
1241
|
-
location
|
|
1242
|
-
};
|
|
1243
|
-
if (typeof parsed.credential === "object" && parsed.credential !== null && "type" in parsed.credential) {
|
|
1244
|
-
const credential = parsed.credential;
|
|
1245
|
-
if (credential.type === "api_key" && typeof credential.token === "string" && credential.token.length > 0) return {
|
|
1246
|
-
credential: {
|
|
1247
|
-
type: "api_key",
|
|
1248
|
-
token: credential.token
|
|
1249
|
-
},
|
|
1250
|
-
backend,
|
|
1251
|
-
location
|
|
1252
|
-
};
|
|
1253
|
-
if (credential.type === "oauth" && typeof credential.accessToken === "string" && credential.accessToken.length > 0) return {
|
|
1254
|
-
credential: {
|
|
1255
|
-
type: "oauth",
|
|
1256
|
-
accessToken: credential.accessToken,
|
|
1257
|
-
...typeof credential.refreshToken === "string" ? { refreshToken: credential.refreshToken } : {},
|
|
1258
|
-
...typeof credential.expiresAt === "number" ? { expiresAt: credential.expiresAt } : {},
|
|
1259
|
-
...typeof credential.clientId === "string" ? { clientId: credential.clientId } : {},
|
|
1260
|
-
...typeof credential.tokenEndpoint === "string" ? { tokenEndpoint: credential.tokenEndpoint } : {}
|
|
1261
|
-
},
|
|
1262
|
-
backend,
|
|
1263
|
-
location
|
|
1264
|
-
};
|
|
1265
|
-
}
|
|
1266
|
-
return;
|
|
1267
|
-
} catch {
|
|
1268
|
-
const token = raw.trim();
|
|
1269
|
-
if (!token) return;
|
|
1270
|
-
return {
|
|
1271
|
-
credential: {
|
|
1272
|
-
type: "api_key",
|
|
1273
|
-
token
|
|
1274
|
-
},
|
|
1275
|
-
backend,
|
|
1276
|
-
location
|
|
1277
|
-
};
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
async function readFileStoredCredential() {
|
|
1281
|
-
try {
|
|
1282
|
-
const credentialsPath = getCredentialsFilePath();
|
|
1283
|
-
return parseStoredCredential(await promises.readFile(credentialsPath, "utf8"), "file", credentialsPath);
|
|
1284
|
-
} catch {
|
|
1285
|
-
return;
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
async function writeFileCredentials(apiKey) {
|
|
1289
|
-
const stored = await writeFileStoredCredential({
|
|
1290
|
-
type: "api_key",
|
|
1291
|
-
token: apiKey
|
|
1292
|
-
});
|
|
1293
|
-
return {
|
|
1294
|
-
apiKey,
|
|
1295
|
-
backend: stored.backend,
|
|
1296
|
-
location: stored.location
|
|
1297
|
-
};
|
|
1298
|
-
}
|
|
1299
|
-
async function writeFileStoredCredential(credential) {
|
|
1300
|
-
const configDir = getConfigDir();
|
|
1301
|
-
const credentialsPath = getCredentialsFilePath();
|
|
1302
|
-
const payload = {
|
|
1303
|
-
version: 1,
|
|
1304
|
-
credential
|
|
1305
|
-
};
|
|
1306
|
-
await promises.mkdir(configDir, {
|
|
1307
|
-
recursive: true,
|
|
1308
|
-
mode: 448
|
|
1309
|
-
});
|
|
1310
|
-
await promises.writeFile(credentialsPath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
1311
|
-
encoding: "utf8",
|
|
1312
|
-
mode: 384
|
|
1313
|
-
});
|
|
1314
|
-
await promises.chmod(credentialsPath, 384);
|
|
1315
|
-
return {
|
|
1316
|
-
...credential ? { credential } : {},
|
|
1317
|
-
backend: "file",
|
|
1318
|
-
location: credentialsPath
|
|
1319
|
-
};
|
|
1320
|
-
}
|
|
1321
|
-
async function deleteFileCredentials() {
|
|
1322
|
-
try {
|
|
1323
|
-
await promises.unlink(getCredentialsFilePath());
|
|
1324
|
-
return true;
|
|
1325
|
-
} catch {
|
|
1326
|
-
return false;
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
async function readMacOSStoredCredential() {
|
|
1330
|
-
if (process.platform !== "darwin") return;
|
|
1331
|
-
try {
|
|
1332
|
-
const { stdout } = await execFile$1("security", [
|
|
1333
|
-
"find-generic-password",
|
|
1334
|
-
"-s",
|
|
1335
|
-
SERVICE_NAME,
|
|
1336
|
-
"-a",
|
|
1337
|
-
ACCOUNT_NAME,
|
|
1338
|
-
"-w"
|
|
1339
|
-
]);
|
|
1340
|
-
return parseStoredCredential(stdout, "macos-keychain", "macOS Keychain");
|
|
1341
|
-
} catch {
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
async function writeMacOSKeychain(apiKey) {
|
|
1346
|
-
const stored = await writeMacOSStoredCredential({
|
|
1347
|
-
type: "api_key",
|
|
1348
|
-
token: apiKey
|
|
1349
|
-
});
|
|
1350
|
-
return stored ? {
|
|
1351
|
-
apiKey,
|
|
1352
|
-
backend: stored.backend,
|
|
1353
|
-
location: stored.location
|
|
1354
|
-
} : void 0;
|
|
1355
|
-
}
|
|
1356
|
-
async function writeMacOSStoredCredential(credential) {
|
|
1357
|
-
if (process.platform !== "darwin") return;
|
|
1358
|
-
const payload = {
|
|
1359
|
-
version: 1,
|
|
1360
|
-
credential
|
|
1361
|
-
};
|
|
1362
|
-
try {
|
|
1363
|
-
await execFile$1("security", [
|
|
1364
|
-
"add-generic-password",
|
|
1365
|
-
"-U",
|
|
1366
|
-
"-s",
|
|
1367
|
-
SERVICE_NAME,
|
|
1368
|
-
"-a",
|
|
1369
|
-
ACCOUNT_NAME,
|
|
1370
|
-
"-w",
|
|
1371
|
-
JSON.stringify(payload)
|
|
1372
|
-
]);
|
|
1373
|
-
return {
|
|
1374
|
-
...credential ? { credential } : {},
|
|
1375
|
-
backend: "macos-keychain",
|
|
1376
|
-
location: "macOS Keychain"
|
|
1377
|
-
};
|
|
1378
|
-
} catch {
|
|
1379
|
-
return;
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
async function deleteMacOSKeychain() {
|
|
1383
|
-
if (process.platform !== "darwin") return false;
|
|
1384
|
-
try {
|
|
1385
|
-
await execFile$1("security", [
|
|
1386
|
-
"delete-generic-password",
|
|
1387
|
-
"-s",
|
|
1388
|
-
SERVICE_NAME,
|
|
1389
|
-
"-a",
|
|
1390
|
-
ACCOUNT_NAME
|
|
1391
|
-
]);
|
|
1392
|
-
return true;
|
|
1393
|
-
} catch {
|
|
1394
|
-
return false;
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1397
|
-
async function readLinuxStoredCredential() {
|
|
1398
|
-
if (process.platform !== "linux") return;
|
|
1399
|
-
try {
|
|
1400
|
-
const { stdout } = await execFile$1("secret-tool", [
|
|
1401
|
-
"lookup",
|
|
1402
|
-
"service",
|
|
1403
|
-
SERVICE_NAME,
|
|
1404
|
-
"account",
|
|
1405
|
-
ACCOUNT_NAME
|
|
1406
|
-
]);
|
|
1407
|
-
return parseStoredCredential(stdout, "linux-secret-tool", "Secret Service (secret-tool)");
|
|
1408
|
-
} catch {
|
|
1409
|
-
return;
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
async function writeLinuxSecretTool(apiKey) {
|
|
1413
|
-
const stored = await writeLinuxStoredCredential({
|
|
1414
|
-
type: "api_key",
|
|
1415
|
-
token: apiKey
|
|
1416
|
-
});
|
|
1417
|
-
return stored ? {
|
|
1418
|
-
apiKey,
|
|
1419
|
-
backend: stored.backend,
|
|
1420
|
-
location: stored.location
|
|
1421
|
-
} : void 0;
|
|
1422
|
-
}
|
|
1423
|
-
async function writeLinuxStoredCredential(credential) {
|
|
1424
|
-
if (process.platform !== "linux") return;
|
|
1425
|
-
const payload = {
|
|
1426
|
-
version: 1,
|
|
1427
|
-
credential
|
|
1428
|
-
};
|
|
1429
|
-
try {
|
|
1430
|
-
execFileSync("secret-tool", [
|
|
1431
|
-
"store",
|
|
1432
|
-
"--label",
|
|
1433
|
-
"statelyai API key",
|
|
1434
|
-
"service",
|
|
1435
|
-
SERVICE_NAME,
|
|
1436
|
-
"account",
|
|
1437
|
-
ACCOUNT_NAME
|
|
1438
|
-
], { input: JSON.stringify(payload) });
|
|
1439
|
-
return {
|
|
1440
|
-
...credential ? { credential } : {},
|
|
1441
|
-
backend: "linux-secret-tool",
|
|
1442
|
-
location: "Secret Service (secret-tool)"
|
|
1443
|
-
};
|
|
1444
|
-
} catch {
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
async function deleteLinuxSecretTool() {
|
|
1449
|
-
if (process.platform !== "linux") return false;
|
|
1450
|
-
try {
|
|
1451
|
-
await execFile$1("secret-tool", [
|
|
1452
|
-
"clear",
|
|
1453
|
-
"service",
|
|
1454
|
-
SERVICE_NAME,
|
|
1455
|
-
"account",
|
|
1456
|
-
ACCOUNT_NAME
|
|
1457
|
-
]);
|
|
1458
|
-
return true;
|
|
1459
|
-
} catch {
|
|
1460
|
-
return false;
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
async function getStoredCredential() {
|
|
1464
|
-
if (shouldUseFileBackendOnly()) return readFileStoredCredential();
|
|
1465
|
-
return await readMacOSStoredCredential() ?? await readLinuxStoredCredential() ?? await readFileStoredCredential();
|
|
1466
|
-
}
|
|
1467
|
-
async function setStoredApiKey(apiKey) {
|
|
1468
|
-
if (shouldUseFileBackendOnly()) return writeFileCredentials(apiKey);
|
|
1469
|
-
return await writeMacOSKeychain(apiKey) ?? await writeLinuxSecretTool(apiKey) ?? await writeFileCredentials(apiKey);
|
|
1470
|
-
}
|
|
1471
|
-
async function setStoredCredential(credential) {
|
|
1472
|
-
if (shouldUseFileBackendOnly()) return writeFileStoredCredential(credential);
|
|
1473
|
-
return await writeMacOSStoredCredential(credential) ?? await writeLinuxStoredCredential(credential) ?? await writeFileStoredCredential(credential);
|
|
1474
|
-
}
|
|
1475
|
-
async function deleteStoredApiKey() {
|
|
1476
|
-
if (shouldUseFileBackendOnly()) {
|
|
1477
|
-
const deleted = await deleteFileCredentials();
|
|
1478
|
-
return {
|
|
1479
|
-
deleted,
|
|
1480
|
-
locations: deleted ? [getCredentialsFilePath()] : []
|
|
1481
|
-
};
|
|
1482
|
-
}
|
|
1483
|
-
const locations = [];
|
|
1484
|
-
if (await deleteMacOSKeychain()) locations.push("macOS Keychain");
|
|
1485
|
-
if (await deleteLinuxSecretTool()) locations.push("Secret Service (secret-tool)");
|
|
1486
|
-
if (await deleteFileCredentials()) locations.push(getCredentialsFilePath());
|
|
1487
|
-
return {
|
|
1488
|
-
deleted: locations.length > 0,
|
|
1489
|
-
locations
|
|
1490
|
-
};
|
|
1491
|
-
}
|
|
1492
|
-
function describeCredentialBackend(backend, location) {
|
|
1493
|
-
switch (backend) {
|
|
1494
|
-
case "macos-keychain": return location;
|
|
1495
|
-
case "linux-secret-tool": return location;
|
|
1496
|
-
case "file": return `file (${location})`;
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
//#endregion
|
|
1501
|
-
//#region src/cli.ts
|
|
1502
|
-
const execFileAsync = promisify(execFile);
|
|
1503
|
-
const STATELY_API_KEY_SETTINGS_URL = "https://stately.ai/registry/user/my-settings?tab=API+Key";
|
|
1504
|
-
const DEFAULT_NEW_MACHINE_FILE_SUFFIX = ".machine.ts";
|
|
1505
|
-
const ANSI_RESET = "\x1B[0m";
|
|
1506
|
-
const ANSI_BOLD = "\x1B[1m";
|
|
1507
|
-
const ANSI_DIM = "\x1B[2m";
|
|
1508
|
-
const ANSI_CYAN = "\x1B[36m";
|
|
1509
|
-
const ANSI_GREEN = "\x1B[32m";
|
|
1510
|
-
const ANSI_YELLOW = "\x1B[33m";
|
|
1511
|
-
function loadLocalEnv() {
|
|
1512
|
-
if (typeof process.loadEnvFile !== "function") return;
|
|
1513
|
-
const cwdEnvPath = path.join(process.cwd(), ".env.local");
|
|
1514
|
-
try {
|
|
1515
|
-
process.loadEnvFile(cwdEnvPath);
|
|
1516
|
-
} catch {}
|
|
1517
|
-
}
|
|
1518
|
-
loadLocalEnv();
|
|
1519
|
-
function getEnvApiKey() {
|
|
1520
|
-
const accessToken = process.env.STATELY_ACCESS_TOKEN;
|
|
1521
|
-
if (accessToken) return {
|
|
1522
|
-
apiKey: accessToken,
|
|
1523
|
-
variable: "STATELY_ACCESS_TOKEN"
|
|
1524
|
-
};
|
|
1525
|
-
const statelyApiKey = process.env.STATELY_API_KEY;
|
|
1526
|
-
if (statelyApiKey) return {
|
|
1527
|
-
apiKey: statelyApiKey,
|
|
1528
|
-
variable: "STATELY_API_KEY"
|
|
1529
|
-
};
|
|
1530
|
-
const publicApiKey = process.env.NEXT_PUBLIC_STATELY_API_KEY;
|
|
1531
|
-
if (publicApiKey) return {
|
|
1532
|
-
apiKey: publicApiKey,
|
|
1533
|
-
variable: "NEXT_PUBLIC_STATELY_API_KEY"
|
|
1534
|
-
};
|
|
1535
|
-
}
|
|
1536
|
-
function getEnvCredential() {
|
|
1537
|
-
const accessToken = process.env.STATELY_ACCESS_TOKEN;
|
|
1538
|
-
if (accessToken) return {
|
|
1539
|
-
credential: {
|
|
1540
|
-
type: "oauth",
|
|
1541
|
-
accessToken
|
|
1542
|
-
},
|
|
1543
|
-
variable: "STATELY_ACCESS_TOKEN"
|
|
1544
|
-
};
|
|
1545
|
-
const statelyApiKey = process.env.STATELY_API_KEY;
|
|
1546
|
-
if (statelyApiKey) return {
|
|
1547
|
-
credential: {
|
|
1548
|
-
type: "api_key",
|
|
1549
|
-
token: statelyApiKey
|
|
1550
|
-
},
|
|
1551
|
-
variable: "STATELY_API_KEY"
|
|
1552
|
-
};
|
|
1553
|
-
const publicApiKey = process.env.NEXT_PUBLIC_STATELY_API_KEY;
|
|
1554
|
-
if (publicApiKey) return {
|
|
1555
|
-
credential: {
|
|
1556
|
-
type: "api_key",
|
|
1557
|
-
token: publicApiKey
|
|
1558
|
-
},
|
|
1559
|
-
variable: "NEXT_PUBLIC_STATELY_API_KEY"
|
|
1560
|
-
};
|
|
1561
|
-
}
|
|
1562
|
-
const OAUTH_EXPIRY_SKEW_MS = 6e4;
|
|
1563
|
-
function decodeJwtPayload(token) {
|
|
1564
|
-
const [, payload] = token.split(".");
|
|
1565
|
-
if (!payload) return;
|
|
1566
|
-
try {
|
|
1567
|
-
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1568
|
-
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
1569
|
-
} catch {
|
|
1570
|
-
return;
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
function getOAuthExpiresAt(credential) {
|
|
1574
|
-
if (credential.expiresAt !== void 0) return credential.expiresAt;
|
|
1575
|
-
const exp = decodeJwtPayload(credential.accessToken)?.exp;
|
|
1576
|
-
return typeof exp === "number" ? exp * 1e3 : void 0;
|
|
1577
|
-
}
|
|
1578
|
-
function isTerminalOAuthRefreshError(error) {
|
|
1579
|
-
return error instanceof Error && /Token refresh failed with HTTP (?:400|401|403)\b/.test(error.message);
|
|
1580
|
-
}
|
|
1581
|
-
async function invalidateExpiredOAuthCredential() {
|
|
1582
|
-
await deleteStoredApiKey();
|
|
1583
|
-
throw new Error("Your Stately session expired. Run `npx statelyai login` to continue.");
|
|
1584
|
-
}
|
|
1585
|
-
async function refreshStoredOAuthCredential(credential, options) {
|
|
1586
|
-
if (!credential.refreshToken) return invalidateExpiredOAuthCredential();
|
|
1587
|
-
const claims = decodeJwtPayload(credential.accessToken);
|
|
1588
|
-
const clientId = credential.clientId ?? (typeof claims?.client_id === "string" ? claims.client_id : void 0) ?? getConfiguredOauthClientId();
|
|
1589
|
-
if (!clientId) return invalidateExpiredOAuthCredential();
|
|
1590
|
-
let tokenEndpoint = credential.tokenEndpoint;
|
|
1591
|
-
try {
|
|
1592
|
-
if (!tokenEndpoint) tokenEndpoint = (await discoverAuthorizationServer(typeof claims?.iss === "string" ? claims.iss : getDefaultOAuthIssuer(), { fetch: options.fetch })).token_endpoint;
|
|
1593
|
-
} catch (error) {
|
|
1594
|
-
throw new Error("Could not refresh your expired Stately session. Retry, or run `npx statelyai login`.", { cause: error });
|
|
1595
|
-
}
|
|
1596
|
-
if (!tokenEndpoint) return invalidateExpiredOAuthCredential();
|
|
1597
|
-
try {
|
|
1598
|
-
const refreshedCredential = createOAuthCredential(await refreshToken({
|
|
1599
|
-
clientId,
|
|
1600
|
-
fetch: options.fetch,
|
|
1601
|
-
refreshToken: credential.refreshToken,
|
|
1602
|
-
tokenEndpoint
|
|
1603
|
-
}), {
|
|
1604
|
-
clientId,
|
|
1605
|
-
fallbackRefreshToken: credential.refreshToken,
|
|
1606
|
-
now: options.now,
|
|
1607
|
-
tokenEndpoint
|
|
1608
|
-
});
|
|
1609
|
-
await setStoredCredential(refreshedCredential);
|
|
1610
|
-
return refreshedCredential;
|
|
1611
|
-
} catch (error) {
|
|
1612
|
-
if (isTerminalOAuthRefreshError(error)) return invalidateExpiredOAuthCredential();
|
|
1613
|
-
throw new Error("Could not refresh your expired Stately session. Retry, or run `npx statelyai login`.", { cause: error });
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
async function resolveApiKey(options = {}) {
|
|
1617
|
-
const envApiKey = getEnvApiKey();
|
|
1618
|
-
if (envApiKey) return {
|
|
1619
|
-
apiKey: envApiKey.apiKey,
|
|
1620
|
-
source: "env",
|
|
1621
|
-
detail: envApiKey.variable
|
|
1622
|
-
};
|
|
1623
|
-
const storedCredential = await getStoredCredential();
|
|
1624
|
-
if (storedCredential?.credential) {
|
|
1625
|
-
let credential = storedCredential.credential;
|
|
1626
|
-
const now = options.now ?? Date.now();
|
|
1627
|
-
if (credential.type === "oauth" && (getOAuthExpiresAt(credential) ?? Infinity) <= now + OAUTH_EXPIRY_SKEW_MS) credential = await refreshStoredOAuthCredential(credential, {
|
|
1628
|
-
...options,
|
|
1629
|
-
now
|
|
1630
|
-
});
|
|
1631
|
-
return {
|
|
1632
|
-
apiKey: credential.type === "oauth" ? credential.accessToken : credential.token,
|
|
1633
|
-
source: "stored",
|
|
1634
|
-
detail: describeCredentialBackend(storedCredential.backend, storedCredential.location)
|
|
1635
|
-
};
|
|
1636
|
-
}
|
|
1637
|
-
return { source: "missing" };
|
|
1638
|
-
}
|
|
1639
|
-
function getDefaultBaseUrl() {
|
|
1640
|
-
return process.env.STATELY_API_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_STATELY_API_URL;
|
|
1641
|
-
}
|
|
1642
|
-
function getDefaultEditorUrl() {
|
|
1643
|
-
return process.env.STATELY_EDITOR_URL ?? process.env.NEXT_PUBLIC_BETA_EDITOR_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? "https://editor.stately.ai";
|
|
1644
|
-
}
|
|
1645
|
-
function getResolvedStudioUrl(baseUrl) {
|
|
1646
|
-
return baseUrl ?? getDefaultBaseUrl() ?? "https://stately.ai";
|
|
1647
|
-
}
|
|
1648
|
-
function getDefaultOAuthIssuer() {
|
|
1649
|
-
return process.env.STATELY_OAUTH_ISSUER ?? process.env.NEXT_PUBLIC_STATELY_OAUTH_ISSUER ?? "https://stately.ai";
|
|
1650
|
-
}
|
|
1651
|
-
function normalizeLoginResourceUrl(baseUrl) {
|
|
1652
|
-
const raw = baseUrl;
|
|
1653
|
-
const url = new URL(raw);
|
|
1654
|
-
if (url.pathname === "/" || url.pathname === "") url.pathname = "/api/mcp";
|
|
1655
|
-
return url.toString();
|
|
1656
|
-
}
|
|
1657
|
-
function getConfiguredOauthClientId() {
|
|
1658
|
-
return process.env.STATELY_OAUTH_CLIENT_ID ?? process.env.NEXT_PUBLIC_STATELY_OAUTH_CLIENT_ID;
|
|
1659
|
-
}
|
|
1660
|
-
async function discoverOAuthLogin(options = {}) {
|
|
1661
|
-
const resourceUrl = options.baseUrl ? normalizeLoginResourceUrl(options.baseUrl) : getDefaultOAuthIssuer();
|
|
1662
|
-
const protectedResource = options.baseUrl ? await discoverProtectedResource(resourceUrl, { fetch: options.fetch }) : void 0;
|
|
1663
|
-
const issuer = protectedResource ? protectedResource.authorization_servers?.[0] : resourceUrl;
|
|
1664
|
-
if (!issuer) throw new Error(`No OAuth authorization server advertised by ${resourceUrl}. Pass --base-url for a Stately OAuth-enabled resource or set STATELY_OAUTH_ISSUER.`);
|
|
1665
|
-
const authorizationServer = await discoverAuthorizationServer(issuer, { fetch: options.fetch });
|
|
1666
|
-
const authorizationEndpoint = authorizationServer.authorization_endpoint;
|
|
1667
|
-
const tokenEndpoint = authorizationServer.token_endpoint;
|
|
1668
|
-
if (!authorizationEndpoint || !tokenEndpoint) throw new Error(`Authorization server ${issuer} did not advertise authorization and token endpoints.`);
|
|
1669
|
-
const configuredClientId = options.clientId ?? getConfiguredOauthClientId();
|
|
1670
|
-
const scopes = protectedResource?.scopes_supported ?? [];
|
|
1671
|
-
let clientId = configuredClientId;
|
|
1672
|
-
if (!clientId && authorizationServer.registration_endpoint && options.redirectUri) try {
|
|
1673
|
-
clientId = (await registerOAuthClient({
|
|
1674
|
-
registrationEndpoint: authorizationServer.registration_endpoint,
|
|
1675
|
-
redirectUri: options.redirectUri,
|
|
1676
|
-
scopes,
|
|
1677
|
-
fetch: options.fetch
|
|
1678
|
-
})).client_id;
|
|
1679
|
-
} catch (error) {
|
|
1680
|
-
throw new Error(`OAuth dynamic client registration failed for ${authorizationServer.registration_endpoint}. Enable dynamic client registration on the OAuth authorization server, or set STATELY_OAUTH_CLIENT_ID to a registered OAuth client id.`, { cause: error });
|
|
1681
|
-
}
|
|
1682
|
-
if (!clientId) throw new Error(`Authorization server ${issuer} did not advertise dynamic client registration. Set STATELY_OAUTH_CLIENT_ID to a registered OAuth client id.`);
|
|
1683
|
-
return {
|
|
1684
|
-
authorizationServer,
|
|
1685
|
-
authorizationEndpoint,
|
|
1686
|
-
clientId,
|
|
1687
|
-
...protectedResource ? { protectedResource } : {},
|
|
1688
|
-
resourceUrl,
|
|
1689
|
-
scopes,
|
|
1690
|
-
tokenEndpoint
|
|
1691
|
-
};
|
|
1692
|
-
}
|
|
1693
|
-
function parseGitHubRemote(remoteUrl) {
|
|
1694
|
-
const httpsMatch = remoteUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
1695
|
-
if (httpsMatch) {
|
|
1696
|
-
const [, owner, repo] = httpsMatch;
|
|
1697
|
-
if (!owner || !repo) return null;
|
|
1698
|
-
return {
|
|
1699
|
-
url: `https://github.com/${owner}/${repo}`,
|
|
1700
|
-
owner,
|
|
1701
|
-
repo,
|
|
1702
|
-
branch: "",
|
|
1703
|
-
treeSha: ""
|
|
1704
|
-
};
|
|
1705
|
-
}
|
|
1706
|
-
const sshMatch = remoteUrl.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
1707
|
-
if (sshMatch) {
|
|
1708
|
-
const [, owner, repo] = sshMatch;
|
|
1709
|
-
if (!owner || !repo) return null;
|
|
1710
|
-
return {
|
|
1711
|
-
url: `https://github.com/${owner}/${repo}`,
|
|
1712
|
-
owner,
|
|
1713
|
-
repo,
|
|
1714
|
-
branch: "",
|
|
1715
|
-
treeSha: ""
|
|
1716
|
-
};
|
|
1717
|
-
}
|
|
1718
|
-
return null;
|
|
1719
|
-
}
|
|
1720
|
-
async function inferConnectedRepoFromCwd(cwd) {
|
|
1721
|
-
try {
|
|
1722
|
-
const [{ stdout: remoteStdout }, { stdout: branchStdout }, { stdout: treeShaStdout }] = await Promise.all([
|
|
1723
|
-
execFileAsync("git", [
|
|
1724
|
-
"remote",
|
|
1725
|
-
"get-url",
|
|
1726
|
-
"origin"
|
|
1727
|
-
], { cwd }),
|
|
1728
|
-
execFileAsync("git", ["branch", "--show-current"], { cwd }),
|
|
1729
|
-
execFileAsync("git", ["rev-parse", "HEAD"], { cwd })
|
|
1730
|
-
]);
|
|
1731
|
-
const parsedRemote = parseGitHubRemote(remoteStdout.trim());
|
|
1732
|
-
if (!parsedRemote) return;
|
|
1733
|
-
return {
|
|
1734
|
-
...parsedRemote,
|
|
1735
|
-
branch: branchStdout.trim(),
|
|
1736
|
-
treeSha: treeShaStdout.trim()
|
|
1737
|
-
};
|
|
1738
|
-
} catch {
|
|
1739
|
-
return;
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
function inferInitProjectName(cwd, repo) {
|
|
1743
|
-
if (repo?.repo) return repo.repo;
|
|
1744
|
-
return path.basename(cwd);
|
|
1745
|
-
}
|
|
1746
|
-
async function readApiKeyFromStdin() {
|
|
1747
|
-
const chunks = [];
|
|
1748
|
-
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1749
|
-
return Buffer.concat(chunks).toString("utf8").trim();
|
|
1750
|
-
}
|
|
1751
|
-
async function promptForApiKey() {
|
|
1752
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("No interactive terminal available. Pipe the API key to `statelyai login --stdin`.");
|
|
1753
|
-
const maskedOutput = new Writable({ write(chunk, _encoding, callback) {
|
|
1754
|
-
if (!maskedOutput.muted) process.stdout.write(chunk);
|
|
1755
|
-
callback();
|
|
1756
|
-
} });
|
|
1757
|
-
maskedOutput.muted = false;
|
|
1758
|
-
const rl = createInterface({
|
|
1759
|
-
input: process.stdin,
|
|
1760
|
-
output: maskedOutput,
|
|
1761
|
-
terminal: true
|
|
1762
|
-
});
|
|
1763
|
-
try {
|
|
1764
|
-
process.stdout.write("Enter your Stately API key: ");
|
|
1765
|
-
maskedOutput.muted = true;
|
|
1766
|
-
const apiKey = (await rl.question("")).trim();
|
|
1767
|
-
maskedOutput.muted = false;
|
|
1768
|
-
process.stdout.write("\n");
|
|
1769
|
-
return apiKey;
|
|
1770
|
-
} finally {
|
|
1771
|
-
rl.close();
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
async function promptForOAuthCallback() {
|
|
1775
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("No interactive terminal available to paste the OAuth callback URL.");
|
|
1776
|
-
const rl = createInterface({
|
|
1777
|
-
input: process.stdin,
|
|
1778
|
-
output: process.stdout,
|
|
1779
|
-
terminal: true
|
|
1780
|
-
});
|
|
1781
|
-
try {
|
|
1782
|
-
return (await rl.question("Paste the callback URL or code: ")).trim();
|
|
1783
|
-
} finally {
|
|
1784
|
-
rl.close();
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
function parseOAuthCallback(value) {
|
|
1788
|
-
try {
|
|
1789
|
-
const url = new URL(value);
|
|
1790
|
-
const code = url.searchParams.get("code");
|
|
1791
|
-
const state = url.searchParams.get("state") ?? void 0;
|
|
1792
|
-
if (!code) throw new Error("Callback URL did not include a code parameter.");
|
|
1793
|
-
return {
|
|
1794
|
-
code,
|
|
1795
|
-
state
|
|
1796
|
-
};
|
|
1797
|
-
} catch (error) {
|
|
1798
|
-
if (error instanceof Error && error.message.includes("code parameter")) throw error;
|
|
1799
|
-
if (!value) throw new Error("OAuth code cannot be empty.");
|
|
1800
|
-
return { code: value };
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
function createOAuthCredential(tokenResponse, options = {}) {
|
|
1804
|
-
if (!tokenResponse.access_token) throw new Error("Token response did not include an access_token.");
|
|
1805
|
-
const now = options.now ?? Date.now();
|
|
1806
|
-
const expiresAt = typeof tokenResponse.expires_at === "number" ? tokenResponse.expires_at * 1e3 : typeof tokenResponse.expires_in === "number" ? now + tokenResponse.expires_in * 1e3 : void 0;
|
|
1807
|
-
return {
|
|
1808
|
-
type: "oauth",
|
|
1809
|
-
accessToken: tokenResponse.access_token,
|
|
1810
|
-
...tokenResponse.refresh_token ?? options.fallbackRefreshToken ? { refreshToken: tokenResponse.refresh_token ?? options.fallbackRefreshToken } : {},
|
|
1811
|
-
...expiresAt ? { expiresAt } : {},
|
|
1812
|
-
...options.clientId ? { clientId: options.clientId } : {},
|
|
1813
|
-
...options.tokenEndpoint ? { tokenEndpoint: options.tokenEndpoint } : {}
|
|
1814
|
-
};
|
|
1815
|
-
}
|
|
1816
|
-
async function buildOAuthLoginStart(options) {
|
|
1817
|
-
const [discovery, pkce] = await Promise.all([discoverOAuthLogin({
|
|
1818
|
-
baseUrl: options.baseUrl,
|
|
1819
|
-
clientId: options.clientId,
|
|
1820
|
-
fetch: options.fetch,
|
|
1821
|
-
redirectUri: options.redirectUri
|
|
1822
|
-
}), createPkcePair()]);
|
|
1823
|
-
return {
|
|
1824
|
-
authorizeUrl: buildAuthorizeUrl({
|
|
1825
|
-
authorizationEndpoint: discovery.authorizationEndpoint,
|
|
1826
|
-
clientId: discovery.clientId,
|
|
1827
|
-
redirectUri: options.redirectUri,
|
|
1828
|
-
codeChallenge: pkce.codeChallenge,
|
|
1829
|
-
state: options.state,
|
|
1830
|
-
scopes: discovery.scopes
|
|
1831
|
-
}),
|
|
1832
|
-
codeVerifier: pkce.codeVerifier,
|
|
1833
|
-
discovery
|
|
1834
|
-
};
|
|
1835
|
-
}
|
|
1836
|
-
function formatStoredCredentialSource(storedCredential) {
|
|
1837
|
-
if (storedCredential.credential?.type === "api_key") return `Credential source: stored api_key (${describeCredentialBackend(storedCredential.backend, storedCredential.location)}).`;
|
|
1838
|
-
if (storedCredential.credential?.type === "oauth") return `Credential source: stored oauth (${describeCredentialBackend(storedCredential.backend, storedCredential.location)}).`;
|
|
1839
|
-
if (storedCredential.authMode === "none") return `Credential source: stored no-auth mode (${describeCredentialBackend(storedCredential.backend, storedCredential.location)}).`;
|
|
1840
|
-
return `Credential source: stored unknown (${describeCredentialBackend(storedCredential.backend, storedCredential.location)}).`;
|
|
1841
|
-
}
|
|
1842
|
-
async function runOAuthBrowserLogin(options) {
|
|
1843
|
-
const state = crypto.randomBytes(16).toString("base64url");
|
|
1844
|
-
const callbackServer = await createLocalOAuthCallbackServer({ state });
|
|
1845
|
-
try {
|
|
1846
|
-
const { authorizeUrl, codeVerifier, discovery } = await buildOAuthLoginStart({
|
|
1847
|
-
baseUrl: options.baseUrl,
|
|
1848
|
-
redirectUri: callbackServer.redirectUri,
|
|
1849
|
-
state
|
|
1850
|
-
});
|
|
1851
|
-
options.log(`Opening browser for ${discovery.resourceUrl}`);
|
|
1852
|
-
options.log(authorizeUrl);
|
|
1853
|
-
let openedBrowser = false;
|
|
1854
|
-
try {
|
|
1855
|
-
openBrowser(authorizeUrl);
|
|
1856
|
-
openedBrowser = true;
|
|
1857
|
-
} catch {
|
|
1858
|
-
options.log("Could not open a browser automatically.");
|
|
1859
|
-
}
|
|
1860
|
-
const codePromise = callbackServer.waitForCode();
|
|
1861
|
-
const pastedCodePromise = !openedBrowser && process.stdin.isTTY && process.stdout.isTTY ? promptForOAuthCallback().then((value) => {
|
|
1862
|
-
const callback = parseOAuthCallback(value);
|
|
1863
|
-
if (callback.state && callback.state !== state) throw new Error("OAuth state did not match.");
|
|
1864
|
-
return callback.code;
|
|
1865
|
-
}) : void 0;
|
|
1866
|
-
const code = await (pastedCodePromise ? Promise.race([codePromise, pastedCodePromise]) : codePromise);
|
|
1867
|
-
return setStoredCredential(createOAuthCredential(await exchangeCodeForToken({
|
|
1868
|
-
tokenEndpoint: discovery.tokenEndpoint,
|
|
1869
|
-
code,
|
|
1870
|
-
codeVerifier,
|
|
1871
|
-
redirectUri: callbackServer.redirectUri,
|
|
1872
|
-
clientId: discovery.clientId
|
|
1873
|
-
}), {
|
|
1874
|
-
clientId: discovery.clientId,
|
|
1875
|
-
tokenEndpoint: discovery.tokenEndpoint
|
|
1876
|
-
}));
|
|
1877
|
-
} finally {
|
|
1878
|
-
await callbackServer.close();
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
async function promptYesNo(question, defaultValue = true) {
|
|
1882
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("No interactive terminal available.");
|
|
1883
|
-
const rl = createInterface({
|
|
1884
|
-
input: process.stdin,
|
|
1885
|
-
output: process.stdout,
|
|
1886
|
-
terminal: true
|
|
1887
|
-
});
|
|
1888
|
-
try {
|
|
1889
|
-
const suffix = defaultValue ? " [Y/n] " : " [y/N] ";
|
|
1890
|
-
const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase();
|
|
1891
|
-
if (!answer) return defaultValue;
|
|
1892
|
-
return answer === "y" || answer === "yes";
|
|
1893
|
-
} finally {
|
|
1894
|
-
rl.close();
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
|
-
function normalizeApiKey(value) {
|
|
1898
|
-
const trimmed = value?.trim();
|
|
1899
|
-
return trimmed ? trimmed : void 0;
|
|
1900
|
-
}
|
|
1901
|
-
function pluralize(count, singular, plural = `${singular}s`) {
|
|
1902
|
-
return count === 1 ? singular : plural;
|
|
1903
|
-
}
|
|
1904
|
-
function supportsColor() {
|
|
1905
|
-
return Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null && process.env.CI !== "true";
|
|
1906
|
-
}
|
|
1907
|
-
function colorize(text, ...codes) {
|
|
1908
|
-
if (!supportsColor()) return text;
|
|
1909
|
-
return `${codes.join("")}${text}${ANSI_RESET}`;
|
|
1910
|
-
}
|
|
1911
|
-
function formatSectionLabel(label, tone = "info") {
|
|
1912
|
-
return colorize(label, ANSI_BOLD, tone === "success" ? ANSI_GREEN : tone === "warning" ? ANSI_YELLOW : ANSI_CYAN);
|
|
1913
|
-
}
|
|
1914
|
-
function formatSecondaryText(text) {
|
|
1915
|
-
return colorize(text, ANSI_DIM);
|
|
1916
|
-
}
|
|
1917
|
-
function inferSuggestedNewMachineDir(config) {
|
|
1918
|
-
const include = config.include[0];
|
|
1919
|
-
if (!include) return "src";
|
|
1920
|
-
const groupedRootMatch = include.match(/^(src|packages\/[^/]+\/src|apps\/[^/]+\/src)(?:\/|$)/);
|
|
1921
|
-
if (groupedRootMatch?.[1]) return groupedRootMatch[1];
|
|
1922
|
-
const globIndex = include.indexOf("**/");
|
|
1923
|
-
if (globIndex > 0) return include.slice(0, globIndex).replace(/\/$/, "") || "src";
|
|
1924
|
-
const wildcardIndex = include.indexOf("*");
|
|
1925
|
-
if (wildcardIndex > 0) return path.posix.dirname(include.slice(0, wildcardIndex)) || "src";
|
|
1926
|
-
return path.posix.dirname(include) === "." ? "src" : path.posix.dirname(include);
|
|
1927
|
-
}
|
|
1928
|
-
function formatMissingNewMachineDirMessage(options) {
|
|
1929
|
-
const suggestedDir = inferSuggestedNewMachineDir(options.config);
|
|
1930
|
-
const countLabel = pluralize(options.remoteMachineCount, "remote-only project machine");
|
|
1931
|
-
return [
|
|
1932
|
-
`Found ${options.remoteMachineCount} ${countLabel}, but statelyai.json does not set newMachinesDir, so they will be skipped.`,
|
|
1933
|
-
"To import new machines created in Studio, add this to statelyai.json:",
|
|
1934
|
-
` "newMachinesDir": "${suggestedDir}"`,
|
|
1935
|
-
"Then run `statelyai pull` again."
|
|
1936
|
-
].join("\n");
|
|
1937
|
-
}
|
|
1938
|
-
function toMachineFileStem(machineName) {
|
|
1939
|
-
return (typeof machineName === "string" ? machineName : "").trim().replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "machine";
|
|
1940
|
-
}
|
|
1941
|
-
function buildProjectEditorUrl(studioUrl, projectId) {
|
|
1942
|
-
return `${studioUrl.replace(/\/+$/, "")}/registry/editor/${encodeURIComponent(projectId)}`;
|
|
1943
|
-
}
|
|
1944
|
-
function buildMachineEditorUrl(studioUrl, projectId, machineId) {
|
|
1945
|
-
const url = new URL(buildProjectEditorUrl(studioUrl, projectId));
|
|
1946
|
-
url.searchParams.set("machineId", machineId);
|
|
1947
|
-
return url.toString();
|
|
1948
|
-
}
|
|
1949
|
-
function inferNewMachineDirFromSuggestions(suggestions) {
|
|
1950
|
-
if (suggestions.length !== 1) return;
|
|
1951
|
-
const include = suggestions[0]?.include[0];
|
|
1952
|
-
if (!include) return;
|
|
1953
|
-
const globIndex = include.indexOf("**/");
|
|
1954
|
-
if (globIndex >= 0) return include.slice(0, globIndex).replace(/\/$/, "") || void 0;
|
|
1955
|
-
const directory = path.posix.dirname(include);
|
|
1956
|
-
return directory === "." ? void 0 : directory;
|
|
1957
|
-
}
|
|
1958
|
-
function isRelinkableLinkedMachineError(error) {
|
|
1959
|
-
return error instanceof StudioApiError && (error.status === 403 || error.status === 404);
|
|
1960
|
-
}
|
|
1961
|
-
async function fileHasLinkedMachineId(filePath) {
|
|
1962
|
-
const contents = await fsPromises.readFile(filePath, "utf8");
|
|
1963
|
-
return Boolean(getStatelyPragma(contents, filePath)?.id);
|
|
1964
|
-
}
|
|
1965
|
-
async function fileExists(filePath) {
|
|
1966
|
-
try {
|
|
1967
|
-
await fsPromises.access(filePath);
|
|
1968
|
-
return true;
|
|
1969
|
-
} catch {
|
|
1970
|
-
return false;
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
async function getGitRepoRoot(cwd) {
|
|
1974
|
-
try {
|
|
1975
|
-
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
1976
|
-
const root = stdout.trim();
|
|
1977
|
-
return root ? await fsPromises.realpath(root) : void 0;
|
|
1978
|
-
} catch {
|
|
1979
|
-
return;
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
1982
|
-
async function isFileGitDirty(filePath) {
|
|
1983
|
-
const resolvedFilePath = await fsPromises.realpath(path.resolve(filePath));
|
|
1984
|
-
const repoRoot = await getGitRepoRoot(path.dirname(resolvedFilePath));
|
|
1985
|
-
if (!repoRoot) return false;
|
|
1986
|
-
try {
|
|
1987
|
-
const relativePath = path.relative(repoRoot, resolvedFilePath);
|
|
1988
|
-
const { stdout: untrackedStdout } = await execFileAsync("git", [
|
|
1989
|
-
"ls-files",
|
|
1990
|
-
"--others",
|
|
1991
|
-
"--exclude-standard",
|
|
1992
|
-
"--",
|
|
1993
|
-
relativePath
|
|
1994
|
-
], { cwd: repoRoot });
|
|
1995
|
-
if (untrackedStdout.trim().length > 0) return true;
|
|
1996
|
-
try {
|
|
1997
|
-
await execFileAsync("git", [
|
|
1998
|
-
"diff",
|
|
1999
|
-
"--no-ext-diff",
|
|
2000
|
-
"--quiet",
|
|
2001
|
-
"--",
|
|
2002
|
-
relativePath
|
|
2003
|
-
], { cwd: repoRoot });
|
|
2004
|
-
} catch {
|
|
2005
|
-
return true;
|
|
2006
|
-
}
|
|
2007
|
-
try {
|
|
2008
|
-
await execFileAsync("git", [
|
|
2009
|
-
"diff",
|
|
2010
|
-
"--cached",
|
|
2011
|
-
"--no-ext-diff",
|
|
2012
|
-
"--quiet",
|
|
2013
|
-
"--",
|
|
2014
|
-
relativePath
|
|
2015
|
-
], { cwd: repoRoot });
|
|
2016
|
-
} catch {
|
|
2017
|
-
return true;
|
|
2018
|
-
}
|
|
2019
|
-
return false;
|
|
2020
|
-
} catch {
|
|
2021
|
-
return false;
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
async function discoverLinkedPullTargets(files) {
|
|
2025
|
-
const linkedTargets = [];
|
|
2026
|
-
for (const file of files) {
|
|
2027
|
-
if (!supportsMachineDiscovery(file)) continue;
|
|
2028
|
-
findStatelyPragmaAttachments(await fsPromises.readFile(file.filePath, "utf8"), file.filePath).forEach((attachment, machineIndex) => {
|
|
2029
|
-
if (attachment.pragma?.id) linkedTargets.push({
|
|
2030
|
-
file,
|
|
2031
|
-
machineId: attachment.pragma.id,
|
|
2032
|
-
machineIndex
|
|
2033
|
-
});
|
|
2034
|
-
});
|
|
2035
|
-
}
|
|
2036
|
-
return linkedTargets;
|
|
2037
|
-
}
|
|
2038
|
-
async function discoverRemoteProjectMachineTargets(options) {
|
|
2039
|
-
const newMachineDir = options.config.newMachinesDir?.trim();
|
|
2040
|
-
if (!newMachineDir) return [];
|
|
2041
|
-
const targets = [];
|
|
2042
|
-
const reservedRelativePaths = /* @__PURE__ */ new Set();
|
|
2043
|
-
for (const machine of options.machines) {
|
|
2044
|
-
const candidate = machine;
|
|
2045
|
-
const machineId = typeof candidate.machineId === "string" ? candidate.machineId : typeof candidate.id === "string" ? candidate.id : void 0;
|
|
2046
|
-
if (!machineId || options.linkedMachineIds.has(machineId)) continue;
|
|
2047
|
-
const baseDirectory = path.join(options.rootDir, newMachineDir);
|
|
2048
|
-
let machineName = typeof candidate.name === "string" && candidate.name.trim().length > 0 ? candidate.name : typeof candidate.key === "string" && candidate.key.trim().length > 0 ? candidate.key : void 0;
|
|
2049
|
-
if ((!machineName || machineName === machineId) && options.client?.machines?.get) try {
|
|
2050
|
-
const remoteMachine = await options.client.machines.get(machineId);
|
|
2051
|
-
const fetchedName = typeof remoteMachine?.name === "string" && remoteMachine.name.trim().length > 0 ? remoteMachine.name : void 0;
|
|
2052
|
-
if (fetchedName) machineName = fetchedName;
|
|
2053
|
-
} catch {}
|
|
2054
|
-
machineName ??= machineId;
|
|
2055
|
-
const stem = toMachineFileStem(machineName);
|
|
2056
|
-
let candidateFileName = `${stem}${DEFAULT_NEW_MACHINE_FILE_SUFFIX}`;
|
|
2057
|
-
let attempt = 2;
|
|
2058
|
-
for (;;) {
|
|
2059
|
-
const filePath = path.join(baseDirectory, candidateFileName);
|
|
2060
|
-
const relativePath = path.relative(options.rootDir, filePath).replace(/\\/g, "/");
|
|
2061
|
-
if (!isTrackedByStatelyProjectConfig(options.config, relativePath)) throw new Error(`Generated path ${relativePath} for remote machine ${machineName} is not covered by include/exclude globs.`);
|
|
2062
|
-
if (!reservedRelativePaths.has(relativePath) && !await fileExists(filePath)) {
|
|
2063
|
-
reservedRelativePaths.add(relativePath);
|
|
2064
|
-
targets.push({
|
|
2065
|
-
machineId,
|
|
2066
|
-
machineName,
|
|
2067
|
-
filePath,
|
|
2068
|
-
relativePath
|
|
2069
|
-
});
|
|
2070
|
-
break;
|
|
2071
|
-
}
|
|
2072
|
-
candidateFileName = `${stem}-${attempt}${DEFAULT_NEW_MACHINE_FILE_SUFFIX}`;
|
|
2073
|
-
attempt += 1;
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
return targets;
|
|
2077
|
-
}
|
|
2078
|
-
async function initProject(options) {
|
|
2079
|
-
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
2080
|
-
const studioUrl = getResolvedStudioUrl(options.baseUrl);
|
|
2081
|
-
const apiBaseUrl = resolveApiBaseUrl(studioUrl);
|
|
2082
|
-
const configPath = path.resolve(cwd, options.configPath ?? STATELY_CONFIG_FILE);
|
|
2083
|
-
const defaultXStateVersion = Math.max(5, options.defaultXStateVersion ?? 5);
|
|
2084
|
-
const existingConfig = !options.force ? await readStatelyProjectConfig({
|
|
2085
|
-
cwd,
|
|
2086
|
-
configPath: options.configPath,
|
|
2087
|
-
rewriteIfNeeded: true
|
|
2088
|
-
}).catch(() => void 0) : void 0;
|
|
2089
|
-
if (existingConfig) {
|
|
2090
|
-
if (options.local) return {
|
|
2091
|
-
config: existingConfig.config,
|
|
2092
|
-
configPath: existingConfig.configPath,
|
|
2093
|
-
reusedExistingConfig: true
|
|
2094
|
-
};
|
|
2095
|
-
if (existingConfig.config.projectId) {
|
|
2096
|
-
const client = options.client ?? createStatelyClient({
|
|
2097
|
-
apiKey: options.apiKey,
|
|
2098
|
-
baseUrl: apiBaseUrl
|
|
2099
|
-
});
|
|
2100
|
-
return {
|
|
2101
|
-
config: existingConfig.config,
|
|
2102
|
-
configPath: existingConfig.configPath,
|
|
2103
|
-
project: await client.projects.get(existingConfig.config.projectId),
|
|
2104
|
-
reusedExistingConfig: true
|
|
2105
|
-
};
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
if (options.local) {
|
|
2109
|
-
const config = createStatelyProjectConfig({ defaultXStateVersion });
|
|
2110
|
-
await writeStatelyProjectConfig(configPath, config);
|
|
2111
|
-
return {
|
|
2112
|
-
config,
|
|
2113
|
-
configPath,
|
|
2114
|
-
reusedExistingConfig: false
|
|
2115
|
-
};
|
|
2116
|
-
}
|
|
2117
|
-
const client = options.client ?? createStatelyClient({
|
|
2118
|
-
apiKey: options.apiKey,
|
|
2119
|
-
baseUrl: apiBaseUrl
|
|
2120
|
-
});
|
|
2121
|
-
const inferredRepo = options.project?.repo ?? await inferConnectedRepoFromCwd(cwd);
|
|
2122
|
-
const projectInput = {
|
|
2123
|
-
name: options.project?.name ?? inferInitProjectName(cwd, inferredRepo),
|
|
2124
|
-
visibility: options.project?.visibility ?? "Private",
|
|
2125
|
-
...options.project?.description ? { description: options.project.description } : {},
|
|
2126
|
-
...options.project?.keywords ? { keywords: options.project.keywords } : {},
|
|
2127
|
-
...inferredRepo ? { repo: inferredRepo } : {}
|
|
2128
|
-
};
|
|
2129
|
-
const project = await client.projects.ensure(projectInput);
|
|
2130
|
-
const config = existingConfig ? {
|
|
2131
|
-
...existingConfig.config,
|
|
2132
|
-
projectId: project.projectId,
|
|
2133
|
-
studioUrl
|
|
2134
|
-
} : createStatelyProjectConfig({
|
|
2135
|
-
projectId: project.projectId,
|
|
2136
|
-
studioUrl,
|
|
2137
|
-
defaultXStateVersion
|
|
2138
|
-
});
|
|
2139
|
-
await writeStatelyProjectConfig(configPath, config);
|
|
2140
|
-
return {
|
|
2141
|
-
config,
|
|
2142
|
-
configPath,
|
|
2143
|
-
project,
|
|
2144
|
-
reusedExistingConfig: false
|
|
2145
|
-
};
|
|
2146
|
-
}
|
|
2147
|
-
function inferDiscoveredMachineName(options) {
|
|
2148
|
-
const declaration = options.source.slice(options.attachStart, options.machineStart);
|
|
2149
|
-
const variableName = [...declaration.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)].at(-1)?.[1];
|
|
2150
|
-
if (variableName) return variableName;
|
|
2151
|
-
if (/\bexport\s+default\b/.test(declaration)) return "default";
|
|
2152
|
-
const configuredId = options.source.slice(options.machineStart, options.machineStart + 500).match(/\bid\s*:\s*['"]([^'"]+)['"]/)?.[1];
|
|
2153
|
-
if (configuredId) return configuredId;
|
|
2154
|
-
return `${path.basename(options.relativePath, path.extname(options.relativePath))}#${options.machineIndex + 1}`;
|
|
2155
|
-
}
|
|
2156
|
-
function discoverMachinesInSource(options) {
|
|
2157
|
-
if (!hasXStateDependency(options.source)) return [];
|
|
2158
|
-
return findStatelyPragmaAttachments(options.source, options.filePath).map((attachment, machineIndex) => ({
|
|
2159
|
-
filePath: options.filePath,
|
|
2160
|
-
...attachment.pragma?.id ? { id: attachment.pragma.id } : {},
|
|
2161
|
-
line: options.source.slice(0, attachment.machineStart).split(/\r?\n/).length,
|
|
2162
|
-
machineIndex,
|
|
2163
|
-
name: inferDiscoveredMachineName({
|
|
2164
|
-
attachStart: attachment.attachStart,
|
|
2165
|
-
machineIndex,
|
|
2166
|
-
machineStart: attachment.machineStart,
|
|
2167
|
-
relativePath: options.relativePath,
|
|
2168
|
-
source: options.source
|
|
2169
|
-
}),
|
|
2170
|
-
relativePath: options.relativePath
|
|
2171
|
-
}));
|
|
2172
|
-
}
|
|
2173
|
-
function hasXStateDependency(source) {
|
|
2174
|
-
return /\bfrom\s*['"]xstate['"]/.test(source) || /\bimport\s*['"]xstate['"]/.test(source) || /\brequire\s*\(\s*['"]xstate['"]\s*\)/.test(source);
|
|
2175
|
-
}
|
|
2176
|
-
async function discoverMachinesInFiles(files) {
|
|
2177
|
-
const machines = [];
|
|
2178
|
-
for (const file of files) {
|
|
2179
|
-
const source = await fsPromises.readFile(file.filePath, "utf8");
|
|
2180
|
-
machines.push(...discoverMachinesInSource({
|
|
2181
|
-
filePath: file.filePath,
|
|
2182
|
-
relativePath: file.relativePath,
|
|
2183
|
-
source
|
|
2184
|
-
}));
|
|
2185
|
-
}
|
|
2186
|
-
return machines;
|
|
2187
|
-
}
|
|
2188
|
-
async function scanProjectMachines(options = {}) {
|
|
2189
|
-
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
2190
|
-
return discoverMachinesInFiles((await discoverCodeSourceFiles({ cwd })).map((relativePath) => ({
|
|
2191
|
-
filePath: path.join(cwd, relativePath),
|
|
2192
|
-
relativePath,
|
|
2193
|
-
source: {
|
|
2194
|
-
include: [relativePath],
|
|
2195
|
-
exclude: [],
|
|
2196
|
-
format: "xstate",
|
|
2197
|
-
xstateVersion: 5
|
|
2198
|
-
}
|
|
2199
|
-
})));
|
|
2200
|
-
}
|
|
2201
|
-
function formatDiscoveredMachines(machines) {
|
|
2202
|
-
const fileCount = new Set(machines.map((machine) => machine.relativePath)).size;
|
|
2203
|
-
const lines = [`Found ${machines.length} ${pluralize(machines.length, "machine")} in ${fileCount} ${pluralize(fileCount, "file")}:`];
|
|
2204
|
-
for (const machine of machines) lines.push(` ${machine.name} ${machine.relativePath}:${machine.line}${machine.id ? ` ${machine.id}` : ""}`);
|
|
2205
|
-
return lines.join("\n");
|
|
2206
|
-
}
|
|
2207
|
-
async function scanProjectSources(options) {
|
|
2208
|
-
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
2209
|
-
return suggestStatelySourceConfigs([...new Set((await scanProjectMachines({ cwd })).map((machine) => machine.relativePath))]);
|
|
2210
|
-
}
|
|
2211
|
-
async function classifyPushCandidates(files) {
|
|
2212
|
-
const machineFiles = [];
|
|
2213
|
-
const nonMachineFiles = [];
|
|
2214
|
-
for (const file of files) {
|
|
2215
|
-
const contents = await fsPromises.readFile(file.filePath, "utf8");
|
|
2216
|
-
if (discoverMachinesInSource({
|
|
2217
|
-
filePath: file.filePath,
|
|
2218
|
-
relativePath: file.relativePath,
|
|
2219
|
-
source: contents
|
|
2220
|
-
}).length > 0) {
|
|
2221
|
-
machineFiles.push(file);
|
|
2222
|
-
continue;
|
|
2223
|
-
}
|
|
2224
|
-
nonMachineFiles.push(file);
|
|
2225
|
-
}
|
|
2226
|
-
return {
|
|
2227
|
-
machineFiles,
|
|
2228
|
-
nonMachineFiles
|
|
2229
|
-
};
|
|
2230
|
-
}
|
|
2231
|
-
function supportsMachineDiscovery(file) {
|
|
2232
|
-
return file.source.format === "xstate" || file.source.format === "auto";
|
|
2233
|
-
}
|
|
2234
|
-
async function resolveConfiguredProject(options) {
|
|
2235
|
-
const { config, configPath, rootDir, rewriteStatus } = await readStatelyProjectConfig({
|
|
2236
|
-
cwd: options.cwd,
|
|
2237
|
-
configPath: options.configPath,
|
|
2238
|
-
rewriteIfNeeded: options.rewriteIfNeeded ?? true
|
|
2239
|
-
});
|
|
2240
|
-
if (!config.projectId) throw new Error("This is a local-only project. Run `statelyai init` to connect it to Stately Studio.");
|
|
2241
|
-
return {
|
|
2242
|
-
client: options.client ?? createStatelyClient({
|
|
2243
|
-
apiKey: options.apiKey,
|
|
2244
|
-
baseUrl: resolveApiBaseUrl(options.baseUrl ?? config.studioUrl)
|
|
2245
|
-
}),
|
|
2246
|
-
config,
|
|
2247
|
-
configPath,
|
|
2248
|
-
files: await discoverStatelySourceFiles({
|
|
2249
|
-
cwd: rootDir,
|
|
2250
|
-
config
|
|
2251
|
-
}),
|
|
2252
|
-
rewriteStatus
|
|
2253
|
-
};
|
|
2254
|
-
}
|
|
2255
|
-
async function resolveConfiguredPullTargets(options) {
|
|
2256
|
-
const { client, files, config, configPath, rewriteStatus } = await resolveConfiguredProject(options);
|
|
2257
|
-
const linkedTargets = await discoverLinkedPullTargets(files);
|
|
2258
|
-
const linkedMachineIds = new Set(linkedTargets.map((linkedTarget) => linkedTarget.machineId));
|
|
2259
|
-
const project = await client.projects.get(config.projectId);
|
|
2260
|
-
const machines = await client.projects.listMachines(config.projectId);
|
|
2261
|
-
const skipped = [];
|
|
2262
|
-
let remoteOnlyTargets = [];
|
|
2263
|
-
try {
|
|
2264
|
-
remoteOnlyTargets = await discoverRemoteProjectMachineTargets({
|
|
2265
|
-
rootDir: path.dirname(configPath),
|
|
2266
|
-
config,
|
|
2267
|
-
machines,
|
|
2268
|
-
linkedMachineIds,
|
|
2269
|
-
client
|
|
2270
|
-
});
|
|
2271
|
-
} catch (error) {
|
|
2272
|
-
skipped.push(error instanceof Error ? error.message : String(error));
|
|
2273
|
-
}
|
|
2274
|
-
return {
|
|
2275
|
-
client,
|
|
2276
|
-
config,
|
|
2277
|
-
configPath,
|
|
2278
|
-
linkedTargets,
|
|
2279
|
-
machines,
|
|
2280
|
-
project,
|
|
2281
|
-
remoteOnlyTargets,
|
|
2282
|
-
skipped,
|
|
2283
|
-
rewriteStatus
|
|
2284
|
-
};
|
|
2285
|
-
}
|
|
2286
|
-
async function resolveCredentialStatus() {
|
|
2287
|
-
const envCredential = getEnvCredential();
|
|
2288
|
-
if (envCredential) return {
|
|
2289
|
-
detail: envCredential.variable,
|
|
2290
|
-
source: "environment",
|
|
2291
|
-
type: envCredential.credential.type
|
|
2292
|
-
};
|
|
2293
|
-
const storedCredential = await getStoredCredential();
|
|
2294
|
-
if (storedCredential?.credential) return {
|
|
2295
|
-
detail: describeCredentialBackend(storedCredential.backend, storedCredential.location),
|
|
2296
|
-
source: "stored",
|
|
2297
|
-
type: storedCredential.credential.type
|
|
2298
|
-
};
|
|
2299
|
-
return {
|
|
2300
|
-
detail: "server decides whether authentication is required",
|
|
2301
|
-
source: "none",
|
|
2302
|
-
type: "none"
|
|
2303
|
-
};
|
|
2304
|
-
}
|
|
2305
|
-
function getProjectMachineId(machine) {
|
|
2306
|
-
const candidate = machine;
|
|
2307
|
-
return candidate.machineId || candidate.id;
|
|
2308
|
-
}
|
|
2309
|
-
function formatCredentialStatus(status) {
|
|
2310
|
-
return status.type === "none" ? `Auth: none (${status.detail})` : `Auth: ${status.type} from ${status.source} (${status.detail})`;
|
|
2311
|
-
}
|
|
2312
|
-
async function resolveProjectStatus(options = {}) {
|
|
2313
|
-
const { config, configPath, rootDir } = await readStatelyProjectConfig({
|
|
2314
|
-
cwd: options.cwd,
|
|
2315
|
-
configPath: options.configPath,
|
|
2316
|
-
rewriteIfNeeded: false
|
|
2317
|
-
});
|
|
2318
|
-
const localMachines = await discoverMachinesInFiles((await discoverStatelySourceFiles({
|
|
2319
|
-
cwd: rootDir,
|
|
2320
|
-
config
|
|
2321
|
-
})).filter(supportsMachineDiscovery));
|
|
2322
|
-
const studioUrl = options.baseUrl ?? config.studioUrl;
|
|
2323
|
-
if (!config.projectId) {
|
|
2324
|
-
const machines = localMachines.map((machine) => ({
|
|
2325
|
-
line: machine.line,
|
|
2326
|
-
machineIndex: machine.machineIndex,
|
|
2327
|
-
name: machine.name,
|
|
2328
|
-
path: machine.relativePath,
|
|
2329
|
-
status: "local-only"
|
|
2330
|
-
}));
|
|
2331
|
-
return {
|
|
2332
|
-
auth: {
|
|
2333
|
-
detail: "not required for local projects",
|
|
2334
|
-
source: "none",
|
|
2335
|
-
type: "none"
|
|
2336
|
-
},
|
|
2337
|
-
configPath,
|
|
2338
|
-
counts: {
|
|
2339
|
-
linked: 0,
|
|
2340
|
-
"local-only": machines.length,
|
|
2341
|
-
"missing-remote": 0,
|
|
2342
|
-
"remote-only": 0
|
|
2343
|
-
},
|
|
2344
|
-
machines,
|
|
2345
|
-
mode: "local"
|
|
2346
|
-
};
|
|
2347
|
-
}
|
|
2348
|
-
const apiKey = options.apiKey ?? (options.client ? void 0 : (await resolveApiKey()).apiKey);
|
|
2349
|
-
const client = options.client ?? createStatelyClient({
|
|
2350
|
-
apiKey,
|
|
2351
|
-
baseUrl: resolveApiBaseUrl(studioUrl)
|
|
2352
|
-
});
|
|
2353
|
-
const project = await client.projects.get(config.projectId);
|
|
2354
|
-
const remoteMachines = await client.projects.listMachines(config.projectId);
|
|
2355
|
-
const remoteById = new Map(remoteMachines.flatMap((machine) => {
|
|
2356
|
-
const id = getProjectMachineId(machine);
|
|
2357
|
-
return id ? [[id, machine]] : [];
|
|
2358
|
-
}));
|
|
2359
|
-
const linkedIds = new Set(localMachines.flatMap((machine) => machine.id ? [machine.id] : []));
|
|
2360
|
-
const machines = localMachines.map((machine) => {
|
|
2361
|
-
if (!machine.id) return {
|
|
2362
|
-
line: machine.line,
|
|
2363
|
-
machineIndex: machine.machineIndex,
|
|
2364
|
-
name: machine.name,
|
|
2365
|
-
path: machine.relativePath,
|
|
2366
|
-
status: "local-only"
|
|
2367
|
-
};
|
|
2368
|
-
const remote = remoteById.get(machine.id);
|
|
2369
|
-
return {
|
|
2370
|
-
id: machine.id,
|
|
2371
|
-
line: machine.line,
|
|
2372
|
-
machineIndex: machine.machineIndex,
|
|
2373
|
-
name: remote?.name || machine.name,
|
|
2374
|
-
path: machine.relativePath,
|
|
2375
|
-
status: remote ? "linked" : "missing-remote",
|
|
2376
|
-
url: buildMachineEditorUrl(studioUrl, config.projectId, machine.id)
|
|
2377
|
-
};
|
|
2378
|
-
});
|
|
2379
|
-
for (const remote of remoteMachines) {
|
|
2380
|
-
const id = getProjectMachineId(remote);
|
|
2381
|
-
if (!id || linkedIds.has(id)) continue;
|
|
2382
|
-
machines.push({
|
|
2383
|
-
id,
|
|
2384
|
-
name: remote.name || id,
|
|
2385
|
-
status: "remote-only",
|
|
2386
|
-
url: buildMachineEditorUrl(studioUrl, config.projectId, id)
|
|
2387
|
-
});
|
|
2388
|
-
}
|
|
2389
|
-
machines.sort((left, right) => left.status.localeCompare(right.status) || left.name.localeCompare(right.name));
|
|
2390
|
-
return {
|
|
2391
|
-
auth: await resolveCredentialStatus(),
|
|
2392
|
-
configPath,
|
|
2393
|
-
counts: {
|
|
2394
|
-
linked: machines.filter((machine) => machine.status === "linked").length,
|
|
2395
|
-
"local-only": machines.filter((machine) => machine.status === "local-only").length,
|
|
2396
|
-
"missing-remote": machines.filter((machine) => machine.status === "missing-remote").length,
|
|
2397
|
-
"remote-only": machines.filter((machine) => machine.status === "remote-only").length
|
|
2398
|
-
},
|
|
2399
|
-
machines,
|
|
2400
|
-
mode: "studio",
|
|
2401
|
-
project: {
|
|
2402
|
-
id: config.projectId,
|
|
2403
|
-
...project.name ? { name: project.name } : {},
|
|
2404
|
-
url: buildProjectEditorUrl(studioUrl, config.projectId)
|
|
2405
|
-
},
|
|
2406
|
-
studioUrl
|
|
2407
|
-
};
|
|
2408
|
-
}
|
|
2409
|
-
const baseUrlFlags = {
|
|
2410
|
-
help: Flags.help({ char: "h" }),
|
|
2411
|
-
"base-url": Flags.string({ description: "Base URL for Stately Studio or a self-hosted deployment" })
|
|
2412
|
-
};
|
|
2413
|
-
const sharedFlags = {
|
|
2414
|
-
...baseUrlFlags,
|
|
2415
|
-
"fail-on-changes": Flags.boolean({
|
|
2416
|
-
default: false,
|
|
2417
|
-
description: "Exit with a nonzero code when differences are found"
|
|
2418
|
-
})
|
|
2419
|
-
};
|
|
2420
|
-
function formatChangeList(label, items) {
|
|
2421
|
-
if (items.length === 0) return null;
|
|
2422
|
-
return `${label}: ${items.map((item) => item.id).join(", ")}`;
|
|
2423
|
-
}
|
|
2424
|
-
function formatPlanSummary(plan) {
|
|
2425
|
-
const lines = [
|
|
2426
|
-
`Plan: ${plan.source.locator} -> ${plan.target.locator}`,
|
|
2427
|
-
`Source: ${plan.source.kind} (${plan.source.format})`,
|
|
2428
|
-
`Target: ${plan.target.kind} (${plan.target.format})`,
|
|
2429
|
-
`Has changes: ${plan.summary.hasChanges ? "yes" : "no"}`,
|
|
2430
|
-
`Node changes: ${plan.summary.nodeChanges}`,
|
|
2431
|
-
`Edge changes: ${plan.summary.edgeChanges}`
|
|
2432
|
-
];
|
|
2433
|
-
const nodeSections = [
|
|
2434
|
-
formatChangeList("Added nodes", plan.diff.nodes.added),
|
|
2435
|
-
formatChangeList("Removed nodes", plan.diff.nodes.removed),
|
|
2436
|
-
formatChangeList("Updated nodes", plan.diff.nodes.updated)
|
|
2437
|
-
].filter(Boolean);
|
|
2438
|
-
const edgeSections = [
|
|
2439
|
-
formatChangeList("Added edges", plan.diff.edges.added),
|
|
2440
|
-
formatChangeList("Removed edges", plan.diff.edges.removed),
|
|
2441
|
-
formatChangeList("Updated edges", plan.diff.edges.updated)
|
|
2442
|
-
].filter(Boolean);
|
|
2443
|
-
lines.push(...nodeSections, ...edgeSections);
|
|
2444
|
-
if (plan.warnings.length > 0) lines.push(`Warnings: ${plan.warnings.join("; ")}`);
|
|
2445
|
-
return lines.join("\n");
|
|
2446
|
-
}
|
|
2447
|
-
var BaseSyncCommand = class extends Command {
|
|
2448
|
-
static enableJsonFlag = false;
|
|
2449
|
-
static flags = sharedFlags;
|
|
2450
|
-
};
|
|
2451
|
-
const sharedArgs = {
|
|
2452
|
-
source: Args.string({
|
|
2453
|
-
required: true,
|
|
2454
|
-
description: "Source locator. Supports local file paths, URLs, or machine IDs."
|
|
2455
|
-
}),
|
|
2456
|
-
target: Args.string({
|
|
2457
|
-
required: true,
|
|
2458
|
-
description: "Target locator. Supports local file paths, URLs, or machine IDs."
|
|
2459
|
-
})
|
|
2460
|
-
};
|
|
2461
|
-
var ParsedSyncCommand = class extends BaseSyncCommand {
|
|
2462
|
-
async parseSync(command) {
|
|
2463
|
-
return this.parse(command);
|
|
2464
|
-
}
|
|
2465
|
-
};
|
|
2466
|
-
async function createSemanticDiff(source, target, baseUrl) {
|
|
2467
|
-
return planSync({
|
|
2468
|
-
source,
|
|
2469
|
-
target,
|
|
2470
|
-
apiKey: (await resolveApiKey()).apiKey,
|
|
2471
|
-
baseUrl: resolveApiBaseUrl(baseUrl ?? getDefaultBaseUrl())
|
|
2472
|
-
});
|
|
2473
|
-
}
|
|
2474
|
-
var PlanCommand = class PlanCommand extends ParsedSyncCommand {
|
|
2475
|
-
static hidden = true;
|
|
2476
|
-
static summary = "Plan semantic sync changes between a source and target.";
|
|
2477
|
-
static description = "Resolves the source and target, normalizes both into graph form, and prints a semantic change summary.";
|
|
2478
|
-
static args = sharedArgs;
|
|
2479
|
-
async run() {
|
|
2480
|
-
const { args, flags } = await this.parseSync(PlanCommand);
|
|
2481
|
-
const plan = await createSemanticDiff(args.source, args.target, flags["base-url"]);
|
|
2482
|
-
this.log(formatPlanSummary(plan));
|
|
2483
|
-
this.exit(flags["fail-on-changes"] && plan.summary.hasChanges ? 1 : 0);
|
|
2484
|
-
}
|
|
2485
|
-
};
|
|
2486
|
-
var DiffCommand = class DiffCommand extends ParsedSyncCommand {
|
|
2487
|
-
static summary = "Diff a source and target using semantic graph comparison.";
|
|
2488
|
-
static description = "Like plan, but intended for diff-style usage and exit codes in scripts.";
|
|
2489
|
-
static args = sharedArgs;
|
|
2490
|
-
async run() {
|
|
2491
|
-
const { args, flags } = await this.parseSync(DiffCommand);
|
|
2492
|
-
const plan = await createSemanticDiff(args.source, args.target, flags["base-url"]);
|
|
2493
|
-
this.log(formatPlanSummary(plan));
|
|
2494
|
-
this.exit(flags["fail-on-changes"] && plan.summary.hasChanges ? 1 : 0);
|
|
2495
|
-
}
|
|
2496
|
-
};
|
|
2497
|
-
var PullCommand = class PullCommand extends ParsedSyncCommand {
|
|
2498
|
-
static summary = "Pull a source locator into a local target file.";
|
|
2499
|
-
static description = "Resolves the source, materializes it in the target format inferred from the target file, and writes the result locally. With no arguments, pulls all linked files discovered from statelyai.json.";
|
|
2500
|
-
static flags = {
|
|
2501
|
-
...baseUrlFlags,
|
|
2502
|
-
config: Flags.string({ description: "Path to statelyai.json" }),
|
|
2503
|
-
force: Flags.boolean({
|
|
2504
|
-
description: "Overwrite linked files even when they have local git changes",
|
|
2505
|
-
default: false
|
|
2506
|
-
})
|
|
2507
|
-
};
|
|
2508
|
-
static args = {
|
|
2509
|
-
source: Args.string({
|
|
2510
|
-
required: false,
|
|
2511
|
-
description: "Optional source locator. Supports a local linked file, machine ID, URL, or local file path."
|
|
2512
|
-
}),
|
|
2513
|
-
target: Args.string({
|
|
2514
|
-
required: false,
|
|
2515
|
-
description: "Optional local target path. Defaults to the source file when pulling a linked local file."
|
|
2516
|
-
})
|
|
2517
|
-
};
|
|
2518
|
-
async run() {
|
|
2519
|
-
const { args, flags } = await this.parseSync(PullCommand);
|
|
2520
|
-
const apiKey = (await resolveApiKey()).apiKey;
|
|
2521
|
-
if (!args.source) {
|
|
2522
|
-
this.log(colorize("Resolving configured project and linked source files...", ANSI_CYAN));
|
|
2523
|
-
const { config, linkedTargets, machines, remoteOnlyTargets, skipped, rewriteStatus } = await resolveConfiguredPullTargets({
|
|
2524
|
-
apiKey,
|
|
2525
|
-
baseUrl: flags["base-url"],
|
|
2526
|
-
configPath: flags.config
|
|
2527
|
-
});
|
|
2528
|
-
if (rewriteStatus.reason === "multiple-legacy-sources") this.log("Legacy statelyai.json detected with multiple sources entries. It cannot be rewritten automatically without losing information, so please update it manually.");
|
|
2529
|
-
if (linkedTargets.length === 0 && remoteOnlyTargets.length === 0) {
|
|
2530
|
-
if (machines.length > 0 && !config.newMachinesDir) {
|
|
2531
|
-
this.log(formatSectionLabel("Skipped:", "warning"));
|
|
2532
|
-
this.log(formatMissingNewMachineDirMessage({
|
|
2533
|
-
config,
|
|
2534
|
-
remoteMachineCount: machines.length
|
|
2535
|
-
}));
|
|
2536
|
-
return;
|
|
2537
|
-
}
|
|
2538
|
-
this.log("No linked local machine files or remote-only project machines were discovered.");
|
|
2539
|
-
return;
|
|
2540
|
-
}
|
|
2541
|
-
if (machines.length > linkedTargets.length && !config.newMachinesDir) {
|
|
2542
|
-
this.log(formatSectionLabel("Skipped:", "warning"));
|
|
2543
|
-
this.log(formatMissingNewMachineDirMessage({
|
|
2544
|
-
config,
|
|
2545
|
-
remoteMachineCount: machines.length - linkedTargets.length
|
|
2546
|
-
}));
|
|
2547
|
-
}
|
|
2548
|
-
this.log(colorize(`Processing ${linkedTargets.length} ${pluralize(linkedTargets.length, "linked file")}${remoteOnlyTargets.length > 0 ? ` and ${remoteOnlyTargets.length} ${pluralize(remoteOnlyTargets.length, "new remote machine")}` : ""}...`, ANSI_CYAN));
|
|
2549
|
-
const pulled = [];
|
|
2550
|
-
const created = [];
|
|
2551
|
-
let hadSourceReconciliationFailure = false;
|
|
2552
|
-
const dirtyLinkedFiles = /* @__PURE__ */ new Map();
|
|
2553
|
-
if (!flags.force) await Promise.all([...new Set(linkedTargets.map((target) => target.file.filePath))].map(async (filePath) => {
|
|
2554
|
-
dirtyLinkedFiles.set(filePath, await isFileGitDirty(filePath));
|
|
2555
|
-
}));
|
|
2556
|
-
for (const linkedTarget of linkedTargets) {
|
|
2557
|
-
const targetPath = linkedTarget.file.filePath;
|
|
2558
|
-
if (!flags.force && dirtyLinkedFiles.get(targetPath)) {
|
|
2559
|
-
skipped.push(`${linkedTarget.file.relativePath}: local git changes detected (pass --force to overwrite)`);
|
|
2560
|
-
continue;
|
|
2561
|
-
}
|
|
2562
|
-
this.log(colorize(`Pulling ${linkedTarget.file.relativePath}...`, ANSI_CYAN));
|
|
2563
|
-
try {
|
|
2564
|
-
const result = await pullSync({
|
|
2565
|
-
source: linkedTarget.machineId,
|
|
2566
|
-
target: targetPath,
|
|
2567
|
-
apiKey,
|
|
2568
|
-
baseUrl: resolveApiBaseUrl(flags["base-url"] ?? config.studioUrl),
|
|
2569
|
-
force: flags.force,
|
|
2570
|
-
machineIndex: linkedTarget.machineIndex
|
|
2571
|
-
});
|
|
2572
|
-
pulled.push(`${linkedTarget.file.relativePath}: ${formatSecondaryText(result.source.locator)}`);
|
|
2573
|
-
} catch (error) {
|
|
2574
|
-
if (error instanceof PullSourceReconciliationError) hadSourceReconciliationFailure = true;
|
|
2575
|
-
skipped.push(`${linkedTarget.file.relativePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2576
|
-
}
|
|
2577
|
-
}
|
|
2578
|
-
for (const remoteTarget of remoteOnlyTargets) {
|
|
2579
|
-
this.log(colorize(`Pulling new remote machine ${remoteTarget.machineName} into ${remoteTarget.relativePath}...`, ANSI_CYAN));
|
|
2580
|
-
try {
|
|
2581
|
-
await fsPromises.mkdir(path.dirname(remoteTarget.filePath), { recursive: true });
|
|
2582
|
-
const result = await pullSync({
|
|
2583
|
-
source: remoteTarget.machineId,
|
|
2584
|
-
target: remoteTarget.filePath,
|
|
2585
|
-
apiKey,
|
|
2586
|
-
baseUrl: resolveApiBaseUrl(flags["base-url"] ?? config.studioUrl),
|
|
2587
|
-
force: flags.force
|
|
2588
|
-
});
|
|
2589
|
-
created.push(`${remoteTarget.relativePath}: ${formatSecondaryText(result.source.locator)}`);
|
|
2590
|
-
} catch (error) {
|
|
2591
|
-
if (error instanceof PullSourceReconciliationError) hadSourceReconciliationFailure = true;
|
|
2592
|
-
skipped.push(`${remoteTarget.relativePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
if (pulled.length > 0) this.log(`${formatSectionLabel("Pulled:", "success")}\n${pulled.join("\n")}`);
|
|
2596
|
-
if (created.length > 0) this.log(`${formatSectionLabel("Created:", "success")}\n${created.join("\n")}`);
|
|
2597
|
-
if (skipped.length > 0) this.log(`${formatSectionLabel("Skipped:", "warning")}\n${skipped.join("\n")}`);
|
|
2598
|
-
if (hadSourceReconciliationFailure) this.exit(1);
|
|
2599
|
-
return;
|
|
2600
|
-
}
|
|
2601
|
-
const localCandidate = path.resolve(process.cwd(), args.source);
|
|
2602
|
-
let source = args.source;
|
|
2603
|
-
let target = args.target;
|
|
2604
|
-
if (!target && await fileExists(localCandidate)) {
|
|
2605
|
-
const pragma = getStatelyPragma(await fsPromises.readFile(localCandidate, "utf8"), localCandidate);
|
|
2606
|
-
if (!pragma?.id) this.error(`No @statelyai id found in ${localCandidate}. Pass an explicit source machine ID or URL and target path.`);
|
|
2607
|
-
source = pragma.id;
|
|
2608
|
-
target = localCandidate;
|
|
2609
|
-
}
|
|
2610
|
-
if (!target) this.error("Missing target path. Pass `statelyai pull <machine-id|url> <file>` or `statelyai pull <linked-file>`.");
|
|
2611
|
-
if (!flags.force && await fileExists(target) && await isFileGitDirty(target)) this.error(`Refusing to overwrite locally modified file ${target}. Pass --force to overwrite it.`);
|
|
2612
|
-
this.log(colorize(`Pulling ${source} into ${target}...`, ANSI_CYAN));
|
|
2613
|
-
const result = await pullSync({
|
|
2614
|
-
source,
|
|
2615
|
-
target,
|
|
2616
|
-
apiKey,
|
|
2617
|
-
baseUrl: resolveApiBaseUrl(flags["base-url"] ?? getDefaultBaseUrl()),
|
|
2618
|
-
force: flags.force
|
|
2619
|
-
});
|
|
2620
|
-
this.log(`${formatSectionLabel("Pulled:", "success")} ${formatSecondaryText(result.source.locator)} -> ${result.outputPath}\nTarget: ${result.target.kind} (${result.target.format})`);
|
|
2621
|
-
}
|
|
2622
|
-
};
|
|
2623
|
-
var PushCommand = class PushCommand extends Command {
|
|
2624
|
-
static enableJsonFlag = false;
|
|
2625
|
-
static summary = "Create or update remote machines for local source files.";
|
|
2626
|
-
static description = "Uses statelyai.json source discovery by default, creates remote machines for unlabeled local machine sources, updates already-linked machines, and writes returned @statelyai ids back into source files.";
|
|
2627
|
-
static args = { file: Args.string({
|
|
2628
|
-
required: false,
|
|
2629
|
-
description: "Optional local source file to push instead of scanning statelyai.json."
|
|
2630
|
-
}) };
|
|
2631
|
-
static flags = {
|
|
2632
|
-
help: Flags.help({ char: "h" }),
|
|
2633
|
-
"base-url": Flags.string({ description: "Base URL for Stately Studio or a self-hosted deployment" }),
|
|
2634
|
-
config: Flags.string({ description: "Path to statelyai.json" }),
|
|
2635
|
-
"dry-run": Flags.boolean({
|
|
2636
|
-
description: "Show which matched files contain machines and what push would do without changing remote or local state",
|
|
2637
|
-
default: false
|
|
2638
|
-
})
|
|
2639
|
-
};
|
|
2640
|
-
async run() {
|
|
2641
|
-
const { args, flags } = await this.parse(PushCommand);
|
|
2642
|
-
const resolvedApiKey = flags["dry-run"] ? { source: "missing" } : await resolveApiKey();
|
|
2643
|
-
this.log(colorize("Resolving configured project and source files...", ANSI_CYAN));
|
|
2644
|
-
const { client, config, files, rewriteStatus } = flags["dry-run"] ? await (async () => {
|
|
2645
|
-
const { config, rootDir, rewriteStatus } = await readStatelyProjectConfig({
|
|
2646
|
-
configPath: flags.config,
|
|
2647
|
-
rewriteIfNeeded: false
|
|
2648
|
-
});
|
|
2649
|
-
return {
|
|
2650
|
-
client: void 0,
|
|
2651
|
-
config,
|
|
2652
|
-
files: await discoverStatelySourceFiles({
|
|
2653
|
-
cwd: rootDir,
|
|
2654
|
-
config
|
|
2655
|
-
}),
|
|
2656
|
-
rewriteStatus
|
|
2657
|
-
};
|
|
2658
|
-
})() : await resolveConfiguredProject({
|
|
2659
|
-
apiKey: resolvedApiKey.apiKey,
|
|
2660
|
-
baseUrl: flags["base-url"],
|
|
2661
|
-
configPath: flags.config
|
|
2662
|
-
});
|
|
2663
|
-
if (rewriteStatus.reason === "multiple-legacy-sources") this.log("Legacy statelyai.json detected with multiple sources entries. It cannot be rewritten automatically without losing information, so please update it manually.");
|
|
2664
|
-
const candidateFiles = args.file ? [{
|
|
2665
|
-
filePath: path.resolve(args.file),
|
|
2666
|
-
relativePath: path.relative(process.cwd(), path.resolve(args.file)),
|
|
2667
|
-
source: {
|
|
2668
|
-
include: [args.file],
|
|
2669
|
-
exclude: [],
|
|
2670
|
-
format: "xstate",
|
|
2671
|
-
xstateVersion: config.defaultXStateVersion
|
|
2672
|
-
}
|
|
2673
|
-
}] : files.filter(supportsMachineDiscovery);
|
|
2674
|
-
if (candidateFiles.length === 0) {
|
|
2675
|
-
this.log(colorize("No matching local machine source files were discovered.", ANSI_YELLOW));
|
|
2676
|
-
return;
|
|
2677
|
-
}
|
|
2678
|
-
const { machineFiles } = await classifyPushCandidates(candidateFiles);
|
|
2679
|
-
if (machineFiles.length === 0) {
|
|
2680
|
-
this.log(colorize(`Matched ${candidateFiles.length} ${pluralize(candidateFiles.length, "file")}, but no local machine sources were discovered.`, ANSI_YELLOW));
|
|
2681
|
-
return;
|
|
2682
|
-
}
|
|
2683
|
-
this.log(colorize(`Discovered ${machineFiles.length} ${pluralize(machineFiles.length, "file with a machine")} from ${candidateFiles.length} matched ${pluralize(candidateFiles.length, "file")}.`, ANSI_CYAN));
|
|
2684
|
-
if (flags["dry-run"]) {
|
|
2685
|
-
const wouldLink = [];
|
|
2686
|
-
const wouldUpdate = [];
|
|
2687
|
-
for (const machine of await discoverMachinesInFiles(machineFiles)) {
|
|
2688
|
-
const location = `${machine.relativePath}:${machine.line}`;
|
|
2689
|
-
if (machine.id) wouldUpdate.push(`${machine.name} ${location} ${formatSecondaryText(machine.id)}`);
|
|
2690
|
-
else wouldLink.push(`${machine.name} ${location}`);
|
|
2691
|
-
}
|
|
2692
|
-
if (wouldLink.length > 0) this.log(`${formatSectionLabel("Would link:")}\n${wouldLink.join("\n")}`);
|
|
2693
|
-
if (wouldUpdate.length > 0) this.log(`${formatSectionLabel("Would update:")}\n${wouldUpdate.join("\n")}`);
|
|
2694
|
-
return;
|
|
2695
|
-
}
|
|
2696
|
-
this.log(colorize(`Processing ${machineFiles.length} ${pluralize(machineFiles.length, "source file")}...`, ANSI_CYAN));
|
|
2697
|
-
const linked = [];
|
|
2698
|
-
const refreshed = [];
|
|
2699
|
-
const skipped = [];
|
|
2700
|
-
for (const file of machineFiles) {
|
|
2701
|
-
this.log(colorize(`Pushing ${file.relativePath}...`, ANSI_CYAN));
|
|
2702
|
-
if (!supportsMachineDiscovery(file)) {
|
|
2703
|
-
skipped.push(`${file.relativePath}: unsupported format ${file.source.format}`);
|
|
2704
|
-
continue;
|
|
2705
|
-
}
|
|
2706
|
-
let result;
|
|
2707
|
-
try {
|
|
2708
|
-
result = await pushLocalMachineLinks({
|
|
2709
|
-
source: file.filePath,
|
|
2710
|
-
apiKey: resolvedApiKey.apiKey,
|
|
2711
|
-
baseUrl: resolveApiBaseUrl(flags["base-url"] ?? config.studioUrl),
|
|
2712
|
-
client,
|
|
2713
|
-
project: { projectId: config.projectId },
|
|
2714
|
-
xstateVersion: Math.max(5, file.source.xstateVersion ?? config.defaultXStateVersion)
|
|
2715
|
-
});
|
|
2716
|
-
} catch (error) {
|
|
2717
|
-
if (isRelinkableLinkedMachineError(error) && await fileHasLinkedMachineId(file.filePath)) {
|
|
2718
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2719
|
-
skipped.push(`${file.relativePath}: linked remote machine is missing or inaccessible. Re-run interactively to relink it as a new machine.`);
|
|
2720
|
-
continue;
|
|
2721
|
-
}
|
|
2722
|
-
if (!await promptYesNo(`Linked remote machine for ${file.relativePath} is missing or inaccessible. Push it as a new machine and replace the local @statelyai id?`, true)) {
|
|
2723
|
-
skipped.push(`${file.relativePath}: linked remote machine is missing or inaccessible.`);
|
|
2724
|
-
continue;
|
|
2725
|
-
}
|
|
2726
|
-
this.log(colorize(`Relinking ${file.relativePath} as a new remote machine...`, ANSI_YELLOW));
|
|
2727
|
-
result = await pushLocalMachineLinks({
|
|
2728
|
-
source: file.filePath,
|
|
2729
|
-
apiKey: resolvedApiKey.apiKey,
|
|
2730
|
-
baseUrl: resolveApiBaseUrl(flags["base-url"] ?? config.studioUrl),
|
|
2731
|
-
client,
|
|
2732
|
-
project: { projectId: config.projectId },
|
|
2733
|
-
xstateVersion: Math.max(5, file.source.xstateVersion ?? config.defaultXStateVersion),
|
|
2734
|
-
relink: true
|
|
2735
|
-
});
|
|
2736
|
-
} else throw error;
|
|
2737
|
-
}
|
|
2738
|
-
if (result.created.length > 0) linked.push(`${file.relativePath}: ${result.created.map(({ machineIndex, machine }) => `${formatSecondaryText(`${machine.id} [${machineIndex}]`)} ${formatSecondaryText(buildMachineEditorUrl(config.studioUrl, config.projectId, machine.id))}`).join(", ")}`);
|
|
2739
|
-
if (result.updated.length > 0) refreshed.push(`${file.relativePath}: ${result.updated.map(({ machineIndex, machine }) => `${formatSecondaryText(`${machine.id} [${machineIndex}]`)} ${formatSecondaryText(buildMachineEditorUrl(config.studioUrl, config.projectId, machine.id))}`).join(", ")}`);
|
|
2740
|
-
for (const entry of result.skipped) skipped.push(`${file.relativePath} [${entry.machineIndex}]: ${entry.reason}`);
|
|
2741
|
-
}
|
|
2742
|
-
if (linked.length > 0) this.log(`${formatSectionLabel("Linked:", "success")}\n${linked.join("\n")}`);
|
|
2743
|
-
if (refreshed.length > 0) this.log(`${formatSectionLabel("Updated:", "success")}\n${refreshed.join("\n")}`);
|
|
2744
|
-
if (skipped.length > 0) this.log(`${formatSectionLabel("Skipped:", "warning")}\n${skipped.join("\n")}`);
|
|
2745
|
-
}
|
|
2746
|
-
};
|
|
2747
|
-
var OpenCommand = class OpenCommand extends Command {
|
|
2748
|
-
static enableJsonFlag = false;
|
|
2749
|
-
static summary = "Open local machines in the Stately visual editor.";
|
|
2750
|
-
static description = "Starts a local browser-backed workspace. Pass a file to open all its machines, or omit it to discover machines in the current worktree.";
|
|
2751
|
-
static args = { file: Args.string({
|
|
2752
|
-
required: false,
|
|
2753
|
-
description: "Local source file to edit visually. Defaults to the current worktree."
|
|
2754
|
-
}) };
|
|
2755
|
-
static flags = {
|
|
2756
|
-
help: Flags.help({ char: "h" }),
|
|
2757
|
-
"editor-url": Flags.string({ description: "Base URL for the Stately editor embed" }),
|
|
2758
|
-
host: Flags.string({
|
|
2759
|
-
description: "Local server host",
|
|
2760
|
-
default: "127.0.0.1"
|
|
2761
|
-
}),
|
|
2762
|
-
port: Flags.integer({
|
|
2763
|
-
description: "Local server port. Defaults to a random available port.",
|
|
2764
|
-
default: 0,
|
|
2765
|
-
min: 0
|
|
2766
|
-
}),
|
|
2767
|
-
open: Flags.boolean({
|
|
2768
|
-
description: "Open the local editor URL in the default browser",
|
|
2769
|
-
default: true,
|
|
2770
|
-
allowNo: true
|
|
2771
|
-
}),
|
|
2772
|
-
debug: Flags.boolean({
|
|
2773
|
-
description: "Log editor protocol messages",
|
|
2774
|
-
default: false
|
|
2775
|
-
}),
|
|
2776
|
-
"source-update": Flags.string({
|
|
2777
|
-
description: "Source update strategy: preserve keeps formatting; rewrite regenerates the machine",
|
|
2778
|
-
options: ["preserve", "rewrite"]
|
|
2779
|
-
})
|
|
2780
|
-
};
|
|
2781
|
-
async run() {
|
|
2782
|
-
const { args, flags } = await this.parse(OpenCommand);
|
|
2783
|
-
const apiKey = (await resolveApiKey()).apiKey;
|
|
2784
|
-
const cwd = process.cwd();
|
|
2785
|
-
const discovered = args.file ? await discoverMachinesInFile(path.resolve(args.file)) : await scanProjectMachines({ cwd });
|
|
2786
|
-
if (discovered.length === 0) throw new Error(args.file ? `No machines found in ${path.resolve(args.file)}.` : `No machines found in ${cwd}.`);
|
|
2787
|
-
const rootDir = args.file ? path.dirname(path.resolve(args.file)) : cwd;
|
|
2788
|
-
const machines = discovered.map((machine) => ({
|
|
2789
|
-
id: `${pathToFileURL(machine.filePath).toString()}#${machine.machineIndex}`,
|
|
2790
|
-
name: machine.name,
|
|
2791
|
-
fileName: machine.filePath,
|
|
2792
|
-
machineIndex: machine.machineIndex
|
|
2793
|
-
}));
|
|
2794
|
-
let configuredSourceUpdateStrategy;
|
|
2795
|
-
try {
|
|
2796
|
-
configuredSourceUpdateStrategy = (await readStatelyProjectConfig({ cwd })).config.sourceUpdateStrategy;
|
|
2797
|
-
} catch (error) {
|
|
2798
|
-
if (error.code !== "ENOENT") throw error;
|
|
2799
|
-
}
|
|
2800
|
-
await openEditor({
|
|
2801
|
-
fileName: machines[0].fileName,
|
|
2802
|
-
machines,
|
|
2803
|
-
rootDir,
|
|
2804
|
-
editorUrl: flags["editor-url"] ?? getDefaultEditorUrl(),
|
|
2805
|
-
host: flags.host,
|
|
2806
|
-
port: flags.port,
|
|
2807
|
-
shouldOpen: flags.open,
|
|
2808
|
-
sourceUpdateStrategy: flags["source-update"] ?? configuredSourceUpdateStrategy ?? "preserve",
|
|
2809
|
-
apiKey,
|
|
2810
|
-
debug: flags.debug
|
|
2811
|
-
});
|
|
2812
|
-
}
|
|
2813
|
-
};
|
|
2814
|
-
async function discoverMachinesInFile(filePath) {
|
|
2815
|
-
const source = await fsPromises.readFile(filePath, "utf8");
|
|
2816
|
-
const discovered = discoverMachinesInSource({
|
|
2817
|
-
filePath,
|
|
2818
|
-
relativePath: path.basename(filePath),
|
|
2819
|
-
source
|
|
2820
|
-
});
|
|
2821
|
-
if (discovered.length > 0) return discovered;
|
|
2822
|
-
return [{
|
|
2823
|
-
filePath,
|
|
2824
|
-
line: 1,
|
|
2825
|
-
machineIndex: 0,
|
|
2826
|
-
name: path.basename(filePath),
|
|
2827
|
-
relativePath: path.basename(filePath)
|
|
2828
|
-
}];
|
|
2829
|
-
}
|
|
2830
|
-
var InitCommand = class InitCommand extends Command {
|
|
2831
|
-
static enableJsonFlag = false;
|
|
2832
|
-
static summary = "Create a local or Studio-connected statelyai.json.";
|
|
2833
|
-
static description = "Creates a local configuration or reuses a remote Studio project for the current working directory.";
|
|
2834
|
-
static flags = {
|
|
2835
|
-
help: Flags.help({ char: "h" }),
|
|
2836
|
-
"base-url": Flags.string({ description: "Base URL for Stately Studio or a self-hosted deployment" }),
|
|
2837
|
-
name: Flags.string({ description: "Project name to create remotely" }),
|
|
2838
|
-
visibility: Flags.string({
|
|
2839
|
-
description: "Remote project visibility",
|
|
2840
|
-
options: [
|
|
2841
|
-
"Private",
|
|
2842
|
-
"Public",
|
|
2843
|
-
"Unlisted"
|
|
2844
|
-
],
|
|
2845
|
-
default: "Private"
|
|
2846
|
-
}),
|
|
2847
|
-
force: Flags.boolean({
|
|
2848
|
-
description: "Overwrite an existing statelyai.json file",
|
|
2849
|
-
default: false
|
|
2850
|
-
}),
|
|
2851
|
-
local: Flags.boolean({
|
|
2852
|
-
description: "Create a local-only config without authentication or Studio access",
|
|
2853
|
-
default: false
|
|
2854
|
-
}),
|
|
2855
|
-
scan: Flags.boolean({
|
|
2856
|
-
description: "Scan the repo for machine-bearing files and suggest source globs to save into statelyai.json",
|
|
2857
|
-
default: false
|
|
2858
|
-
})
|
|
2859
|
-
};
|
|
2860
|
-
async run() {
|
|
2861
|
-
const { flags } = await this.parse(InitCommand);
|
|
2862
|
-
const resolvedApiKey = flags.local ? { source: "missing" } : await resolveApiKey();
|
|
2863
|
-
this.log(flags.local ? "Creating local project config..." : "Creating or reusing remote project...");
|
|
2864
|
-
let result;
|
|
2865
|
-
try {
|
|
2866
|
-
result = await initProject({
|
|
2867
|
-
apiKey: resolvedApiKey.apiKey,
|
|
2868
|
-
baseUrl: flags["base-url"],
|
|
2869
|
-
force: flags.force,
|
|
2870
|
-
local: flags.local,
|
|
2871
|
-
project: {
|
|
2872
|
-
...flags.name ? { name: flags.name } : {},
|
|
2873
|
-
visibility: flags.visibility
|
|
2874
|
-
}
|
|
2875
|
-
});
|
|
2876
|
-
} catch (error) {
|
|
2877
|
-
if (resolvedApiKey.source === "missing" && error instanceof StudioApiError && (error.status === 401 || error.status === 403)) this.error("Not logged in. Run `npx statelyai login` and retry. To initialize without an account, run `npx statelyai init --local` (add `--force` to replace an existing config).");
|
|
2878
|
-
throw error;
|
|
2879
|
-
}
|
|
2880
|
-
const initMessage = result.project ? (() => {
|
|
2881
|
-
const projectEditorUrl = buildProjectEditorUrl(result.config.studioUrl, result.project.projectId);
|
|
2882
|
-
return result.reusedExistingConfig ? `Using existing statelyai.json at ${result.configPath} for project ${result.project.projectId}.\nEditor: ${projectEditorUrl}` : `Initialized project ${result.project.projectId} and wrote ${result.configPath}.\nEditor: ${projectEditorUrl}`;
|
|
2883
|
-
})() : result.reusedExistingConfig ? `Using existing local statelyai.json at ${result.configPath}.` : `Initialized local project and wrote ${result.configPath}.`;
|
|
2884
|
-
if (flags.scan) {
|
|
2885
|
-
this.log("Scanning local source files...");
|
|
2886
|
-
const machines = await scanProjectMachines({ cwd: path.dirname(result.configPath) });
|
|
2887
|
-
const suggestions = await scanProjectSources({
|
|
2888
|
-
cwd: path.dirname(result.configPath),
|
|
2889
|
-
defaultXStateVersion: result.config.defaultXStateVersion
|
|
2890
|
-
});
|
|
2891
|
-
if (suggestions.length === 0) {
|
|
2892
|
-
this.log(`${initMessage}\nNo include globs were suggested from files with a machine. Edit statelyai.json before running statelyai push.`);
|
|
2893
|
-
return;
|
|
2894
|
-
}
|
|
2895
|
-
this.log(initMessage);
|
|
2896
|
-
this.log(formatDiscoveredMachines(machines));
|
|
2897
|
-
this.log(`Suggested source globs:\n${suggestions.map((source) => `- ${source.include.join(", ")}`).join("\n")}`);
|
|
2898
|
-
if (await promptYesNo("Save these source globs to statelyai.json?", true)) {
|
|
2899
|
-
const inferredNewMachineDir = inferNewMachineDirFromSuggestions(suggestions);
|
|
2900
|
-
const nextConfig = {
|
|
2901
|
-
...result.config,
|
|
2902
|
-
include: suggestions.flatMap((suggestion) => suggestion.include),
|
|
2903
|
-
exclude: [...new Set(suggestions.flatMap((suggestion) => suggestion.exclude ?? []))],
|
|
2904
|
-
...inferredNewMachineDir ? { newMachinesDir: inferredNewMachineDir } : {}
|
|
2905
|
-
};
|
|
2906
|
-
await writeStatelyProjectConfig(result.configPath, nextConfig);
|
|
2907
|
-
this.log(inferredNewMachineDir ? `Saved scanned source globs to statelyai.json.\nNew remote machines will be created under ${inferredNewMachineDir}/ by default.` : "Saved scanned source globs to statelyai.json.");
|
|
2908
|
-
} else this.log("Left statelyai.json unchanged. Edit it before running statelyai push.");
|
|
2909
|
-
return;
|
|
2910
|
-
}
|
|
2911
|
-
this.log(`${initMessage}\nNo include globs configured yet. Edit statelyai.json before running statelyai push, or rerun with --scan.`);
|
|
2912
|
-
}
|
|
2913
|
-
};
|
|
2914
|
-
var ScanCommand = class ScanCommand extends Command {
|
|
2915
|
-
static enableJsonFlag = false;
|
|
2916
|
-
static summary = "List local state machines without authentication or config.";
|
|
2917
|
-
static description = "Scans source files in the current repository and reports every discovered XState machine.";
|
|
2918
|
-
static flags = {
|
|
2919
|
-
help: Flags.help({ char: "h" }),
|
|
2920
|
-
json: Flags.boolean({
|
|
2921
|
-
description: "Print machine-readable JSON",
|
|
2922
|
-
default: false
|
|
2923
|
-
})
|
|
2924
|
-
};
|
|
2925
|
-
async run() {
|
|
2926
|
-
const { flags } = await this.parse(ScanCommand);
|
|
2927
|
-
const machines = await scanProjectMachines();
|
|
2928
|
-
this.log(flags.json ? JSON.stringify({ machines }, null, 2) : formatDiscoveredMachines(machines));
|
|
2929
|
-
}
|
|
2930
|
-
};
|
|
2931
|
-
var LoginCommand = class LoginCommand extends Command {
|
|
2932
|
-
static enableJsonFlag = false;
|
|
2933
|
-
static summary = "Store Stately credentials for future CLI use.";
|
|
2934
|
-
static description = "Stores Stately credentials in the OS credential store when available, with a private file fallback.";
|
|
2935
|
-
static flags = {
|
|
2936
|
-
help: Flags.help({ char: "h" }),
|
|
2937
|
-
"api-key": Flags.boolean({ description: "Prompt securely for an API key to store" }),
|
|
2938
|
-
"base-url": Flags.string({ description: "OAuth protected-resource URL for custom deployments" }),
|
|
2939
|
-
stdin: Flags.boolean({
|
|
2940
|
-
description: "Read the API key from standard input",
|
|
2941
|
-
default: false
|
|
2942
|
-
})
|
|
2943
|
-
};
|
|
2944
|
-
async run() {
|
|
2945
|
-
const { flags } = await this.parse(LoginCommand);
|
|
2946
|
-
if (!flags["api-key"] && !flags.stdin) {
|
|
2947
|
-
const stored = await runOAuthBrowserLogin({
|
|
2948
|
-
baseUrl: flags["base-url"],
|
|
2949
|
-
log: (message) => this.log(message)
|
|
2950
|
-
});
|
|
2951
|
-
this.log(`Stored OAuth credential in ${describeCredentialBackend(stored.backend, stored.location)}.`);
|
|
2952
|
-
return;
|
|
2953
|
-
}
|
|
2954
|
-
if (flags.stdin && flags["api-key"]) this.error("Pass either --api-key or --stdin, not both.");
|
|
2955
|
-
const apiKey = normalizeApiKey(flags.stdin ? await readApiKeyFromStdin() : await promptForApiKey());
|
|
2956
|
-
if (!apiKey) this.error(`API key cannot be empty.\nGet or create an API key at ${STATELY_API_KEY_SETTINGS_URL}`);
|
|
2957
|
-
const stored = await setStoredApiKey(apiKey);
|
|
2958
|
-
this.log(`Stored API key in ${describeCredentialBackend(stored.backend, stored.location)}.`);
|
|
2959
|
-
}
|
|
2960
|
-
};
|
|
2961
|
-
var LogoutCommand = class extends Command {
|
|
2962
|
-
static enableJsonFlag = false;
|
|
2963
|
-
static summary = "Remove credentials stored by the CLI.";
|
|
2964
|
-
static description = "Deletes locally stored credentials. Environment variables are not changed.";
|
|
2965
|
-
static flags = { help: Flags.help({ char: "h" }) };
|
|
2966
|
-
async run() {
|
|
2967
|
-
const result = await deleteStoredApiKey();
|
|
2968
|
-
if (!result.deleted) {
|
|
2969
|
-
this.log("No stored credential found.");
|
|
2970
|
-
return;
|
|
2971
|
-
}
|
|
2972
|
-
this.log(`Removed stored credential from ${result.locations.join(", ")}.`);
|
|
2973
|
-
}
|
|
2974
|
-
};
|
|
2975
|
-
function formatProjectStatus(result) {
|
|
2976
|
-
const lines = [
|
|
2977
|
-
formatCredentialStatus(result.auth),
|
|
2978
|
-
`Mode: ${result.mode}`,
|
|
2979
|
-
...result.project ? [`Project: ${result.project.name ? `${result.project.name} ` : ""}(${result.project.id})`, `Studio: ${result.project.url}`] : [],
|
|
2980
|
-
`Config: ${result.configPath}`,
|
|
2981
|
-
`Machines: ${result.machines.length} (${result.counts.linked} linked, ${result.counts["local-only"]} local-only, ${result.counts["remote-only"]} remote-only, ${result.counts["missing-remote"]} missing-remote)`
|
|
2982
|
-
];
|
|
2983
|
-
for (const machine of result.machines) {
|
|
2984
|
-
const details = [machine.path ? `${machine.path}${machine.line ? `:${machine.line}` : ""}` : void 0, machine.id].filter(Boolean).join(" ");
|
|
2985
|
-
lines.push(` [${machine.status}] ${machine.name}${details ? ` ${details}` : ""}`);
|
|
2986
|
-
}
|
|
2987
|
-
const next = [];
|
|
2988
|
-
if (result.mode === "local") {
|
|
2989
|
-
lines.push("Next: statelyai init (connect to Studio)");
|
|
2990
|
-
return lines.join("\n");
|
|
2991
|
-
}
|
|
2992
|
-
if (result.counts["local-only"] > 0 || result.counts["missing-remote"] > 0) next.push("statelyai push");
|
|
2993
|
-
if (result.counts["remote-only"] > 0) next.push("statelyai pull");
|
|
2994
|
-
if (next.length > 0) lines.push(`Next: ${next.join(", ")}`);
|
|
2995
|
-
return lines.join("\n");
|
|
2996
|
-
}
|
|
2997
|
-
var StatusCommand = class StatusCommand extends Command {
|
|
2998
|
-
static enableJsonFlag = false;
|
|
2999
|
-
static summary = "Show project, machine, and authentication status.";
|
|
3000
|
-
static description = "Reads statelyai.json and Studio project metadata, then classifies configured machine files as linked, local-only, remote-only, or missing remotely.";
|
|
3001
|
-
static flags = {
|
|
3002
|
-
...baseUrlFlags,
|
|
3003
|
-
auth: Flags.boolean({
|
|
3004
|
-
description: "Show only credential resolution",
|
|
3005
|
-
default: false
|
|
3006
|
-
}),
|
|
3007
|
-
config: Flags.string({ description: "Path to statelyai.json" }),
|
|
3008
|
-
json: Flags.boolean({
|
|
3009
|
-
description: "Print machine-readable JSON",
|
|
3010
|
-
default: false
|
|
3011
|
-
})
|
|
3012
|
-
};
|
|
3013
|
-
async run() {
|
|
3014
|
-
const { flags } = await this.parse(StatusCommand);
|
|
3015
|
-
if (flags.auth) {
|
|
3016
|
-
const auth = await resolveCredentialStatus();
|
|
3017
|
-
this.log(flags.json ? JSON.stringify({ auth }, null, 2) : formatCredentialStatus(auth));
|
|
3018
|
-
return;
|
|
3019
|
-
}
|
|
3020
|
-
const result = await resolveProjectStatus({
|
|
3021
|
-
baseUrl: flags["base-url"],
|
|
3022
|
-
configPath: flags.config
|
|
3023
|
-
});
|
|
3024
|
-
this.log(flags.json ? JSON.stringify(result, null, 2) : formatProjectStatus(result));
|
|
3025
|
-
}
|
|
3026
|
-
};
|
|
3027
|
-
var WhoamiCommand = class extends Command {
|
|
3028
|
-
static hidden = true;
|
|
3029
|
-
static enableJsonFlag = false;
|
|
3030
|
-
static summary = "Show how the CLI would resolve credentials.";
|
|
3031
|
-
static description = "Reports whether the CLI would use an environment variable or stored credential.";
|
|
3032
|
-
static flags = { help: Flags.help({ char: "h" }) };
|
|
3033
|
-
async run() {
|
|
3034
|
-
this.log(formatCredentialStatus(await resolveCredentialStatus()));
|
|
3035
|
-
}
|
|
3036
|
-
};
|
|
3037
|
-
const COMMANDS = {
|
|
3038
|
-
verify: VerifyCommand,
|
|
3039
|
-
plan: PlanCommand,
|
|
3040
|
-
diff: DiffCommand,
|
|
3041
|
-
pull: PullCommand,
|
|
3042
|
-
push: PushCommand,
|
|
3043
|
-
open: OpenCommand,
|
|
3044
|
-
scan: ScanCommand,
|
|
3045
|
-
status: StatusCommand,
|
|
3046
|
-
init: InitCommand,
|
|
3047
|
-
login: LoginCommand,
|
|
3048
|
-
logout: LogoutCommand,
|
|
3049
|
-
whoami: WhoamiCommand
|
|
3050
|
-
};
|
|
3051
|
-
async function run$1(argv = process.argv.slice(2), entryUrl = import.meta.url) {
|
|
3052
|
-
const normalizedArgv = argv.length === 1 && argv[0] === "-h" ? ["--help"] : argv;
|
|
3053
|
-
try {
|
|
3054
|
-
await run(normalizedArgv, entryUrl);
|
|
3055
|
-
await flush();
|
|
3056
|
-
} catch (error) {
|
|
3057
|
-
await handle(error);
|
|
3058
|
-
}
|
|
3059
|
-
}
|
|
3060
|
-
function isDirectExecution() {
|
|
3061
|
-
const entryPath = process.argv[1];
|
|
3062
|
-
if (!entryPath) return false;
|
|
3063
|
-
const modulePath = fileURLToPath(import.meta.url);
|
|
3064
|
-
try {
|
|
3065
|
-
return fs.realpathSync(entryPath) === fs.realpathSync(modulePath);
|
|
3066
|
-
} catch {
|
|
3067
|
-
return path.resolve(entryPath) === modulePath;
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
if (isDirectExecution()) run$1();
|
|
3071
|
-
|
|
3072
|
-
//#endregion
|
|
3073
|
-
export { scanProjectMachines as C, formatVerificationJUnit as D, verifyProject as E, createStatelyProjectConfig as O, run$1 as S, resolveVerificationRequest as T, isFileGitDirty as _, discoverMachinesInSource as a, resolveCredentialStatus as b, formatDiscoveredMachines as c, formatProjectStatus as d, formatStoredCredentialSource as f, initProject as g, inferInitProjectName as h, discoverLinkedPullTargets as i, formatMissingNewMachineDirMessage as l, getEnvCredential as m, buildOAuthLoginStart as n, discoverOAuthLogin as o, getEnvApiKey as p, classifyPushCandidates as r, discoverRemoteProjectMachineTargets as s, COMMANDS as t, formatPlanSummary as u, resolveApiKey as v, scanProjectSources as w, resolveProjectStatus as x, resolveConfiguredPullTargets as y };
|