statelyai 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,8 @@ That writes a `statelyai.json` like:
36
36
  "studioUrl": "https://stately.ai",
37
37
  "defaultXStateVersion": 5,
38
38
  "include": ["src/**/*.ts"],
39
- "exclude": ["**/*.test.*", "**/*.spec.*"]
39
+ "exclude": ["**/*.test.*", "**/*.spec.*"],
40
+ "newMachineDir": "src"
40
41
  }
41
42
  ```
42
43
 
@@ -52,13 +53,15 @@ Preview what `push` would do without updating Studio or local files:
52
53
  statelyai push --dry-run
53
54
  ```
54
55
 
55
- 6. Pull remote changes back into linked local files:
56
+ If a saved `// @statelyai id=...` points to a deleted or inaccessible remote machine, `push` will prompt to relink the file as a new remote machine and replace the local id.
57
+
58
+ 6. Pull remote changes back into linked local files and create new local files for remote-only project machines when `newMachineDir` is configured:
56
59
 
57
60
  ```bash
58
61
  statelyai pull
59
62
  ```
60
63
 
61
- `pull` skips locally modified files unless you pass `--force`.
64
+ `pull` skips locally modified linked files unless you pass `--force`. New remote-only machines are written as `<machine-name>.machine.ts` inside `newMachineDir`.
62
65
 
63
66
  Run it without installing it globally:
64
67
 
package/dist/bin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { u as run } from "./cli-DvtWVXpM.mjs";
2
+ import { d as run } from "./cli-1Nrsh5Xl.mjs";
3
3
 
4
4
  //#region src/bin.ts
5
5
  run();
@@ -12,7 +12,7 @@ import * as crypto from "node:crypto";
12
12
  import * as http from "node:http";
13
13
  import * as https from "node:https";
14
14
  import os from "node:os";
15
- import { createStatelyClient, getStatelyPragma } from "@statelyai/sdk";
15
+ import { StudioApiError, createStatelyClient, getStatelyPragma } from "@statelyai/sdk";
16
16
  import { planSync, pullSync, pushLocalMachineLinks } from "@statelyai/sdk/sync";
17
17
 
18
18
  //#region src/cliHost.ts
@@ -975,7 +975,8 @@ function normalizeProjectConfig(config) {
975
975
  studioUrl: config.studioUrl ?? "https://stately.ai",
976
976
  defaultXStateVersion: Math.max(5, config.defaultXStateVersion ?? 5),
977
977
  include: [...config.include ?? firstSource?.include ?? []],
978
- exclude: config.exclude == null ? [...firstSource?.exclude ?? DEFAULT_SOURCE_EXCLUDES] : [...config.exclude]
978
+ exclude: config.exclude == null ? [...firstSource?.exclude ?? DEFAULT_SOURCE_EXCLUDES] : [...config.exclude],
979
+ ...config.newMachineDir ? { newMachineDir: config.newMachineDir } : {}
979
980
  };
980
981
  }
981
982
  async function readStatelyProjectConfig(options = {}) {
@@ -1060,6 +1061,9 @@ function matchesGlob(relativePath, pattern) {
1060
1061
  function matchesAny(patterns, relativePath) {
1061
1062
  return (patterns ?? []).some((pattern) => matchesGlob(relativePath, pattern));
1062
1063
  }
1064
+ function isTrackedByStatelyProjectConfig(config, relativePath) {
1065
+ return matchesAny(config.include, relativePath) && !matchesAny(config.exclude ?? DEFAULT_SOURCE_EXCLUDES, relativePath);
1066
+ }
1063
1067
  async function discoverCodeSourceFiles(options = {}) {
1064
1068
  const rootDir = path.resolve(options.cwd ?? process.cwd());
1065
1069
  return (await filterGitIgnoredPaths(rootDir, await walkFiles(rootDir))).filter((relativePath) => isCodeSourceFile(relativePath)).filter((relativePath) => !matchesAny(DEFAULT_SOURCE_EXCLUDES, relativePath)).sort((left, right) => left.localeCompare(right));
@@ -1157,6 +1161,7 @@ async function discoverStatelySourceFiles(options = {}) {
1157
1161
  //#region src/cli.ts
1158
1162
  const execFileAsync = promisify(execFile);
1159
1163
  const STATELY_API_KEY_SETTINGS_URL = "https://stately.ai/registry/user/my-settings?tab=API+Key";
1164
+ const DEFAULT_NEW_MACHINE_FILE_SUFFIX = ".machine.ts";
1160
1165
  function loadLocalEnv() {
1161
1166
  if (typeof process.loadEnvFile !== "function") return;
1162
1167
  const cwdEnvPath = path.join(process.cwd(), ".env.local");
@@ -1310,6 +1315,9 @@ function normalizeApiKey(value) {
1310
1315
  function pluralize(count, singular, plural = `${singular}s`) {
1311
1316
  return count === 1 ? singular : plural;
1312
1317
  }
1318
+ function toMachineFileStem(machineName) {
1319
+ return machineName.trim().replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "machine";
1320
+ }
1313
1321
  function getMissingApiKeyMessage() {
1314
1322
  return `No API key configured. Use \`statelyai login\`, set \`STATELY_API_KEY\`, or pass \`--api-key\`.\nGet or create an API key at ${STATELY_API_KEY_SETTINGS_URL}`;
1315
1323
  }
@@ -1321,6 +1329,22 @@ function buildMachineEditorUrl(studioUrl, projectId, machineId) {
1321
1329
  url.searchParams.set("machineId", machineId);
1322
1330
  return url.toString();
1323
1331
  }
1332
+ function inferNewMachineDirFromSuggestions(suggestions) {
1333
+ if (suggestions.length !== 1) return;
1334
+ const include = suggestions[0]?.include[0];
1335
+ if (!include) return;
1336
+ const globIndex = include.indexOf("**/");
1337
+ if (globIndex >= 0) return include.slice(0, globIndex).replace(/\/$/, "") || void 0;
1338
+ const directory = path.posix.dirname(include);
1339
+ return directory === "." ? void 0 : directory;
1340
+ }
1341
+ function isRelinkableLinkedMachineError(error) {
1342
+ return error instanceof StudioApiError && (error.status === 403 || error.status === 404);
1343
+ }
1344
+ async function fileHasLinkedMachineId(filePath) {
1345
+ const contents = await fsPromises.readFile(filePath, "utf8");
1346
+ return Boolean(getStatelyPragma(contents, filePath)?.id);
1347
+ }
1324
1348
  async function fileExists(filePath) {
1325
1349
  try {
1326
1350
  await fsPromises.access(filePath);
@@ -1393,6 +1417,35 @@ async function discoverLinkedPullTargets(files) {
1393
1417
  }
1394
1418
  return linkedTargets;
1395
1419
  }
1420
+ async function discoverRemoteProjectMachineTargets(options) {
1421
+ const newMachineDir = options.config.newMachineDir?.trim();
1422
+ if (!newMachineDir) return [];
1423
+ const targets = [];
1424
+ for (const machine of options.project.machines) {
1425
+ if (options.linkedMachineIds.has(machine.machineId)) continue;
1426
+ const baseDirectory = path.join(options.rootDir, newMachineDir);
1427
+ const stem = toMachineFileStem(machine.name);
1428
+ let candidateFileName = `${stem}${DEFAULT_NEW_MACHINE_FILE_SUFFIX}`;
1429
+ let attempt = 2;
1430
+ for (;;) {
1431
+ const filePath = path.join(baseDirectory, candidateFileName);
1432
+ const relativePath = path.relative(options.rootDir, filePath).replace(/\\/g, "/");
1433
+ if (!isTrackedByStatelyProjectConfig(options.config, relativePath)) throw new Error(`Generated path ${relativePath} for remote machine ${machine.name} is not covered by include/exclude globs.`);
1434
+ if (!await fileExists(filePath)) {
1435
+ targets.push({
1436
+ machineId: machine.machineId,
1437
+ machineName: machine.name,
1438
+ filePath,
1439
+ relativePath
1440
+ });
1441
+ break;
1442
+ }
1443
+ candidateFileName = `${stem}-${attempt}${DEFAULT_NEW_MACHINE_FILE_SUFFIX}`;
1444
+ attempt += 1;
1445
+ }
1446
+ }
1447
+ return targets;
1448
+ }
1396
1449
  async function initProject(options) {
1397
1450
  const cwd = path.resolve(options.cwd ?? process.cwd());
1398
1451
  const studioUrl = getResolvedStudioUrl(options.baseUrl);
@@ -1602,19 +1655,38 @@ var PullCommand = class PullCommand extends ParsedSyncCommand {
1602
1655
  if (!args.source) {
1603
1656
  if (!apiKey) this.error(getMissingApiKeyMessage());
1604
1657
  this.log("Resolving configured project and linked source files...");
1605
- const { files, config } = await resolveConfiguredProject({
1658
+ const { files, config, configPath } = await resolveConfiguredProject({
1606
1659
  apiKey,
1607
1660
  baseUrl: flags["base-url"],
1608
1661
  configPath: flags.config
1609
1662
  });
1610
1663
  const linkedTargets = await discoverLinkedPullTargets(files);
1611
- if (linkedTargets.length === 0) {
1612
- this.log("No linked local machine files were discovered.");
1664
+ const linkedMachineIds = new Set(linkedTargets.map((linkedTarget) => linkedTarget.machineId));
1665
+ const project = await client.projects.get(config.projectId);
1666
+ const skipped = [];
1667
+ let remoteOnlyTargets = [];
1668
+ try {
1669
+ remoteOnlyTargets = await discoverRemoteProjectMachineTargets({
1670
+ rootDir: path.dirname(configPath),
1671
+ config,
1672
+ project,
1673
+ linkedMachineIds
1674
+ });
1675
+ } catch (error) {
1676
+ skipped.push(error instanceof Error ? error.message : String(error));
1677
+ }
1678
+ if (linkedTargets.length === 0 && remoteOnlyTargets.length === 0) {
1679
+ if (project.machines.length > 0 && !config.newMachineDir) {
1680
+ this.log("No linked local machine files were discovered, and statelyai.json does not set newMachineDir for importing remote-only machines.");
1681
+ return;
1682
+ }
1683
+ this.log("No linked local machine files or remote-only project machines were discovered.");
1613
1684
  return;
1614
1685
  }
1615
- this.log(`Processing ${linkedTargets.length} ${pluralize(linkedTargets.length, "linked file")}...`);
1686
+ if (project.machines.length > linkedTargets.length && !config.newMachineDir) this.log("Remote-only project machines were found, but statelyai.json does not set newMachineDir, so they will be skipped.");
1687
+ this.log(`Processing ${linkedTargets.length} ${pluralize(linkedTargets.length, "linked file")}${remoteOnlyTargets.length > 0 ? ` and ${remoteOnlyTargets.length} ${pluralize(remoteOnlyTargets.length, "new remote machine")}` : ""}...`);
1616
1688
  const pulled = [];
1617
- const skipped = [];
1689
+ const created = [];
1618
1690
  for (const linkedTarget of linkedTargets) {
1619
1691
  const targetPath = linkedTarget.file.filePath;
1620
1692
  if (!flags.force && await isFileGitDirty(targetPath)) {
@@ -1634,7 +1706,23 @@ var PullCommand = class PullCommand extends ParsedSyncCommand {
1634
1706
  skipped.push(`${linkedTarget.file.relativePath}: ${error instanceof Error ? error.message : String(error)}`);
1635
1707
  }
1636
1708
  }
1709
+ for (const remoteTarget of remoteOnlyTargets) {
1710
+ this.log(`Pulling new remote machine ${remoteTarget.machineName} into ${remoteTarget.relativePath}...`);
1711
+ try {
1712
+ await fsPromises.mkdir(path.dirname(remoteTarget.filePath), { recursive: true });
1713
+ const result = await pullSync({
1714
+ source: remoteTarget.machineId,
1715
+ target: remoteTarget.filePath,
1716
+ apiKey,
1717
+ baseUrl: flags["base-url"] ?? config.studioUrl
1718
+ });
1719
+ created.push(`${remoteTarget.relativePath}: ${result.source.locator}`);
1720
+ } catch (error) {
1721
+ skipped.push(`${remoteTarget.relativePath}: ${error instanceof Error ? error.message : String(error)}`);
1722
+ }
1723
+ }
1637
1724
  if (pulled.length > 0) this.log(`Pulled:\n${pulled.join("\n")}`);
1725
+ if (created.length > 0) this.log(`Created:\n${created.join("\n")}`);
1638
1726
  if (skipped.length > 0) this.log(`Skipped:\n${skipped.join("\n")}`);
1639
1727
  return;
1640
1728
  }
@@ -1740,14 +1828,38 @@ var PushCommand = class PushCommand extends Command {
1740
1828
  skipped.push(`${file.relativePath}: unsupported format ${file.source.format}`);
1741
1829
  continue;
1742
1830
  }
1743
- const result = await pushLocalMachineLinks({
1744
- source: file.filePath,
1745
- apiKey: resolvedApiKey.apiKey,
1746
- baseUrl: flags["base-url"] ?? config.studioUrl,
1747
- client,
1748
- project: { projectId: config.projectId },
1749
- xstateVersion: Math.max(5, file.source.xstateVersion ?? config.defaultXStateVersion)
1750
- });
1831
+ let result;
1832
+ try {
1833
+ result = await pushLocalMachineLinks({
1834
+ source: file.filePath,
1835
+ apiKey: resolvedApiKey.apiKey,
1836
+ baseUrl: flags["base-url"] ?? config.studioUrl,
1837
+ client,
1838
+ project: { projectId: config.projectId },
1839
+ xstateVersion: Math.max(5, file.source.xstateVersion ?? config.defaultXStateVersion)
1840
+ });
1841
+ } catch (error) {
1842
+ if (isRelinkableLinkedMachineError(error) && await fileHasLinkedMachineId(file.filePath)) {
1843
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1844
+ skipped.push(`${file.relativePath}: linked remote machine is missing or inaccessible. Re-run interactively to relink it as a new machine.`);
1845
+ continue;
1846
+ }
1847
+ 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)) {
1848
+ skipped.push(`${file.relativePath}: linked remote machine is missing or inaccessible.`);
1849
+ continue;
1850
+ }
1851
+ this.log(`Relinking ${file.relativePath} as a new remote machine...`);
1852
+ result = await pushLocalMachineLinks({
1853
+ source: file.filePath,
1854
+ apiKey: resolvedApiKey.apiKey,
1855
+ baseUrl: flags["base-url"] ?? config.studioUrl,
1856
+ client,
1857
+ project: { projectId: config.projectId },
1858
+ xstateVersion: Math.max(5, file.source.xstateVersion ?? config.defaultXStateVersion),
1859
+ relink: true
1860
+ });
1861
+ } else throw error;
1862
+ }
1751
1863
  if (result.created.length > 0) linked.push(`${file.relativePath}: ${result.created.map(({ machineIndex, machine }) => `${machine.id} [${machineIndex}] ${buildMachineEditorUrl(config.studioUrl, config.projectId, machine.id)}`).join(", ")}`);
1752
1864
  if (result.updated.length > 0) refreshed.push(`${file.relativePath}: ${result.updated.map(({ machineIndex, machine }) => `${machine.id} [${machineIndex}] ${buildMachineEditorUrl(config.studioUrl, config.projectId, machine.id)}`).join(", ")}`);
1753
1865
  for (const entry of result.skipped) skipped.push(`${file.relativePath} [${entry.machineIndex}]: ${entry.reason}`);
@@ -1858,13 +1970,15 @@ var InitCommand = class InitCommand extends Command {
1858
1970
  this.log(initMessage);
1859
1971
  this.log(`Suggested source globs:\n${suggestions.map((source) => `- ${source.include.join(", ")}`).join("\n")}`);
1860
1972
  if (await promptYesNo("Save these source globs to statelyai.json?", true)) {
1973
+ const inferredNewMachineDir = inferNewMachineDirFromSuggestions(suggestions);
1861
1974
  const nextConfig = {
1862
1975
  ...result.config,
1863
1976
  include: suggestions.flatMap((suggestion) => suggestion.include),
1864
- exclude: [...new Set(suggestions.flatMap((suggestion) => suggestion.exclude ?? []))]
1977
+ exclude: [...new Set(suggestions.flatMap((suggestion) => suggestion.exclude ?? []))],
1978
+ ...inferredNewMachineDir ? { newMachineDir: inferredNewMachineDir } : {}
1865
1979
  };
1866
1980
  await fsPromises.writeFile(result.configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
1867
- this.log("Saved scanned source globs to statelyai.json.");
1981
+ 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.");
1868
1982
  } else this.log("Left statelyai.json unchanged. Edit it before running statelyai push.");
1869
1983
  return;
1870
1984
  }
@@ -1960,4 +2074,4 @@ function isDirectExecution() {
1960
2074
  if (isDirectExecution()) run$1();
1961
2075
 
1962
2076
  //#endregion
1963
- export { getEnvApiKey as a, isFileGitDirty as c, scanProjectSources as d, createStatelyProjectConfig as f, formatPlanSummary as i, resolveApiKey as l, classifyPushCandidates as n, inferInitProjectName as o, discoverLinkedPullTargets as r, initProject as s, COMMANDS as t, run$1 as u };
2077
+ export { formatPlanSummary as a, initProject as c, run$1 as d, scanProjectSources as f, discoverRemoteProjectMachineTargets as i, isFileGitDirty as l, classifyPushCandidates as n, getEnvApiKey as o, createStatelyProjectConfig as p, discoverLinkedPullTargets as r, inferInitProjectName as s, COMMANDS as t, resolveApiKey as u };
package/dist/index.d.mts CHANGED
@@ -18,6 +18,7 @@ interface StatelyProjectConfig {
18
18
  defaultXStateVersion: number;
19
19
  include: string[];
20
20
  exclude?: string[];
21
+ newMachineDir?: string;
21
22
  }
22
23
  interface DiscoveredSourceFile {
23
24
  filePath: string;
@@ -55,6 +56,12 @@ interface LinkedPullTarget {
55
56
  file: DiscoveredSourceFile;
56
57
  machineId: string;
57
58
  }
59
+ interface RemoteProjectMachineTarget {
60
+ machineId: string;
61
+ machineName: string;
62
+ filePath: string;
63
+ relativePath: string;
64
+ }
58
65
  interface PushCandidateClassification {
59
66
  machineFiles: DiscoveredSourceFile[];
60
67
  nonMachineFiles: DiscoveredSourceFile[];
@@ -76,6 +83,12 @@ declare function resolveApiKey(explicitApiKey?: string): Promise<ApiKeyResolutio
76
83
  declare function inferInitProjectName(cwd: string, repo?: ConnectedRepo): string;
77
84
  declare function isFileGitDirty(filePath: string): Promise<boolean>;
78
85
  declare function discoverLinkedPullTargets(files: DiscoveredSourceFile[]): Promise<LinkedPullTarget[]>;
86
+ declare function discoverRemoteProjectMachineTargets(options: {
87
+ rootDir: string;
88
+ config: StatelyProjectConfig;
89
+ project: ProjectData;
90
+ linkedMachineIds: Set<string>;
91
+ }): Promise<RemoteProjectMachineTarget[]>;
79
92
  declare function initProject(options: InitProjectOptions): Promise<InitProjectResult>;
80
93
  declare function scanProjectSources(options: ScanProjectSourcesOptions): Promise<Array<Pick<StatelyProjectConfig, 'include' | 'exclude'>>>;
81
94
  declare function classifyPushCandidates(files: DiscoveredSourceFile[]): Promise<PushCandidateClassification>;
@@ -227,4 +240,4 @@ declare const COMMANDS: {
227
240
  };
228
241
  declare function run(argv?: any, entryUrl?: string): Promise<void>;
229
242
  //#endregion
230
- export { ApiKeyResolution, COMMANDS, InitProjectOptions, InitProjectResult, LinkedPullTarget, PushCandidateClassification, ScanProjectSourcesOptions, classifyPushCandidates, createStatelyProjectConfig, discoverLinkedPullTargets, formatPlanSummary, getEnvApiKey, inferInitProjectName, initProject, isFileGitDirty, resolveApiKey, run, scanProjectSources };
243
+ export { ApiKeyResolution, COMMANDS, InitProjectOptions, InitProjectResult, LinkedPullTarget, PushCandidateClassification, RemoteProjectMachineTarget, ScanProjectSourcesOptions, classifyPushCandidates, createStatelyProjectConfig, discoverLinkedPullTargets, discoverRemoteProjectMachineTargets, formatPlanSummary, getEnvApiKey, inferInitProjectName, initProject, isFileGitDirty, resolveApiKey, run, scanProjectSources };
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { a as getEnvApiKey, c as isFileGitDirty, d as scanProjectSources, f as createStatelyProjectConfig, i as formatPlanSummary, l as resolveApiKey, n as classifyPushCandidates, o as inferInitProjectName, r as discoverLinkedPullTargets, s as initProject, t as COMMANDS, u as run } from "./cli-DvtWVXpM.mjs";
1
+ import { a as formatPlanSummary, c as initProject, d as run, f as scanProjectSources, i as discoverRemoteProjectMachineTargets, l as isFileGitDirty, n as classifyPushCandidates, o as getEnvApiKey, p as createStatelyProjectConfig, r as discoverLinkedPullTargets, s as inferInitProjectName, t as COMMANDS, u as resolveApiKey } from "./cli-1Nrsh5Xl.mjs";
2
2
 
3
- export { COMMANDS, classifyPushCandidates, createStatelyProjectConfig, discoverLinkedPullTargets, formatPlanSummary, getEnvApiKey, inferInitProjectName, initProject, isFileGitDirty, resolveApiKey, run, scanProjectSources };
3
+ export { COMMANDS, classifyPushCandidates, createStatelyProjectConfig, discoverLinkedPullTargets, discoverRemoteProjectMachineTargets, formatPlanSummary, getEnvApiKey, inferInitProjectName, initProject, isFileGitDirty, resolveApiKey, run, scanProjectSources };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statelyai",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Command-line tools for Stately",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@oclif/core": "^4.10.3",
24
- "@statelyai/sdk": "0.9.0"
24
+ "@statelyai/sdk": "0.10.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsdown": "0.21.0-beta.2",