statelyai 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,20 +7,33 @@ This package contains the `statelyai` command implementation.
7
7
  <!-- happy-path CLI usage and statelyai.json shape derived from packages/cli/src/cli.ts#COMMANDS and packages/cli/src/projectConfig.ts -->
8
8
  ## Happy Path
9
9
 
10
- 1. Get an API key from [Stately API Key settings](https://stately.ai/registry/user/my-settings?tab=API+Key).
11
- 2. Log in once:
10
+ 1. Log in once:
12
11
 
13
12
  ```bash
14
13
  statelyai login
15
14
  ```
16
15
 
17
- 3. Initialize the current repo:
16
+ Automation can set `STATELY_ACCESS_TOKEN` for OAuth, or pass `--api-key` /
17
+ `STATELY_API_KEY` for legacy API-key commands.
18
+
19
+ For a custom OAuth protected resource, discover OAuth from that resource:
20
+
21
+ ```bash
22
+ statelyai login --base-url http://localhost:3000/registry/api/mcp
23
+ ```
24
+
25
+ `statelyai login --auth bearer` uses the same browser OAuth flow explicitly.
26
+ `statelyai login --api-key` keeps the legacy manual API-key path.
27
+ `statelyai open` requires OAuth (`statelyai login` or `STATELY_ACCESS_TOKEN`);
28
+ free accounts open read-only root-level views.
29
+
30
+ 2. Initialize the current repo:
18
31
 
19
32
  ```bash
20
33
  statelyai init
21
34
  ```
22
35
 
23
- 4. Optionally scan the repo and save suggested source globs:
36
+ 3. Optionally scan the repo and save suggested source globs:
24
37
 
25
38
  ```bash
26
39
  statelyai init --scan
@@ -43,7 +56,7 @@ That writes a `statelyai.json` like:
43
56
  }
44
57
  ```
45
58
 
46
- 5. Push local machines:
59
+ 4. Push local machines:
47
60
 
48
61
  ```bash
49
62
  statelyai push
@@ -57,7 +70,7 @@ statelyai push --dry-run
57
70
 
58
71
  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.
59
72
 
60
- 6. Pull remote changes back into linked local files and create new local files for remote-only project machines when `newMachinesDir` is configured:
73
+ 5. Pull remote changes back into linked local files and create new local files for remote-only project machines when `newMachinesDir` is configured:
61
74
 
62
75
  ```bash
63
76
  statelyai pull
package/dist/bin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { p as run } from "./cli-DB0A-O4Y.mjs";
2
+ import { v as run } from "./cli-jiby9qdf.mjs";
3
3
 
4
4
  //#region src/bin.ts
5
5
  run();
@@ -1,5 +1,7 @@
1
+ import * as crypto from "node:crypto";
1
2
  import fs, { promises, watch } from "node:fs";
2
3
  import fsPromises from "node:fs/promises";
4
+ import * as http from "node:http";
3
5
  import { execFile, execFileSync, spawn } from "node:child_process";
4
6
  import * as path$1 from "node:path";
5
7
  import path from "node:path";
@@ -8,10 +10,8 @@ import { Writable } from "node:stream";
8
10
  import { fileURLToPath, pathToFileURL } from "node:url";
9
11
  import { promisify } from "node:util";
10
12
  import { Args, Command, Flags, flush, handle, run } from "@oclif/core";
11
- import * as crypto from "node:crypto";
12
- import * as http from "node:http";
13
13
  import os from "node:os";
14
- import { StudioApiError, createStatelyClient, getStatelyPragma } from "@statelyai/sdk";
14
+ import { StudioApiError, buildAuthorizeUrl, createPkcePair, createStatelyClient, discoverAuthorizationServer, discoverProtectedResource, exchangeCodeForToken, getStatelyPragma, registerOAuthClient } from "@statelyai/sdk";
15
15
  import { planSync, pullSync, pushLocalMachineLinks } from "@statelyai/sdk/sync";
16
16
 
17
17
  //#region src/cliHost.ts
@@ -86,6 +86,7 @@ var RemoteEditorSession = class {
86
86
  lastPersistedGraph;
87
87
  currentSourceLocations;
88
88
  currentFormat;
89
+ editorSyncAccess;
89
90
  ready = false;
90
91
  pendingMessages = [];
91
92
  pendingRetrievals = /* @__PURE__ */ new Map();
@@ -146,13 +147,16 @@ var RemoteEditorSession = class {
146
147
  const update = await this.parseRootDocument();
147
148
  this.currentFormat = update.format;
148
149
  this.currentSourceLocations = update.sourceLocations;
150
+ this.editorSyncAccess = update.access;
149
151
  this.updateSourceLocations(update.sourceLocations);
150
152
  this.postMessage({
151
153
  type: "@statelyai.init",
152
154
  machine: update.machine,
153
155
  format: update.format,
154
156
  sourceLocations: update.sourceLocations,
155
- mode: "editing",
157
+ mode: update.access?.readOnly ? "viewing" : "editing",
158
+ readOnly: update.access?.readOnly,
159
+ capabilities: update.access?.capabilities,
156
160
  theme: "light",
157
161
  unsavedIndicator: { enabled: true },
158
162
  leftPanels: [],
@@ -173,6 +177,7 @@ var RemoteEditorSession = class {
173
177
  const update = await this.parseRootDocument();
174
178
  this.currentFormat = update.format;
175
179
  this.currentSourceLocations = update.sourceLocations;
180
+ this.editorSyncAccess = update.access ?? this.editorSyncAccess;
176
181
  this.updateSourceLocations(update.sourceLocations);
177
182
  this.postMessage({
178
183
  type: "@statelyai.update",
@@ -185,6 +190,16 @@ var RemoteEditorSession = class {
185
190
  }
186
191
  }
187
192
  async saveFromEditor(msg) {
193
+ if (this.editorSyncAccess?.readOnly) {
194
+ const message = "Your Stately plan allows read-only editor sync.";
195
+ this.postMessage({
196
+ type: "@statelyai.toast",
197
+ message,
198
+ toastType: "error"
199
+ });
200
+ this.showError(message);
201
+ return;
202
+ }
188
203
  const validationErrors = getValidationErrors(msg.validations);
189
204
  if (validationErrors.length > 0) {
190
205
  const message = formatValidationErrorMessage(validationErrors);
@@ -701,36 +716,109 @@ function getCredentialsFilePath() {
701
716
  return path.join(getConfigDir(), CREDENTIALS_FILE_NAME);
702
717
  }
703
718
  function shouldUseFileBackendOnly() {
704
- return process.env.STATELYAI_CREDENTIALS_BACKEND === "file";
719
+ return process.env.STATELYAI_CREDENTIALS_BACKEND === "file" || process.env.STATELYAI_CONFIG_DIR !== void 0;
705
720
  }
706
721
  async function readFileCredentials() {
722
+ const storedCredential = await readFileStoredCredential();
723
+ if (storedCredential?.credential?.type !== "api_key") return;
724
+ return {
725
+ apiKey: storedCredential.credential.token,
726
+ backend: storedCredential.backend,
727
+ location: storedCredential.location
728
+ };
729
+ }
730
+ function parseStoredCredential(raw, backend, location) {
707
731
  try {
708
- const raw = await promises.readFile(getCredentialsFilePath(), "utf8");
709
732
  const parsed = JSON.parse(raw);
710
- if (typeof parsed.apiKey !== "string" || parsed.apiKey.length === 0) return;
733
+ if (typeof parsed.apiKey === "string" && parsed.apiKey.length > 0) return {
734
+ credential: {
735
+ type: "api_key",
736
+ token: parsed.apiKey
737
+ },
738
+ backend,
739
+ location
740
+ };
741
+ if (parsed.authMode === "none") return {
742
+ authMode: "none",
743
+ backend,
744
+ location
745
+ };
746
+ if (typeof parsed.credential === "object" && parsed.credential !== null && "type" in parsed.credential) {
747
+ const credential = parsed.credential;
748
+ if (credential.type === "api_key" && typeof credential.token === "string" && credential.token.length > 0) return {
749
+ credential: {
750
+ type: "api_key",
751
+ token: credential.token
752
+ },
753
+ backend,
754
+ location
755
+ };
756
+ if (credential.type === "oauth" && typeof credential.accessToken === "string" && credential.accessToken.length > 0) return {
757
+ credential: {
758
+ type: "oauth",
759
+ accessToken: credential.accessToken,
760
+ ...typeof credential.refreshToken === "string" ? { refreshToken: credential.refreshToken } : {},
761
+ ...typeof credential.expiresAt === "number" ? { expiresAt: credential.expiresAt } : {}
762
+ },
763
+ backend,
764
+ location
765
+ };
766
+ }
767
+ return;
768
+ } catch {
769
+ const token = raw.trim();
770
+ if (!token) return;
711
771
  return {
712
- apiKey: parsed.apiKey,
713
- backend: "file",
714
- location: getCredentialsFilePath()
772
+ credential: {
773
+ type: "api_key",
774
+ token
775
+ },
776
+ backend,
777
+ location
715
778
  };
779
+ }
780
+ }
781
+ async function readFileStoredCredential() {
782
+ try {
783
+ const credentialsPath = getCredentialsFilePath();
784
+ return parseStoredCredential(await promises.readFile(credentialsPath, "utf8"), "file", credentialsPath);
716
785
  } catch {
717
786
  return;
718
787
  }
719
788
  }
720
789
  async function writeFileCredentials(apiKey) {
790
+ const stored = await writeFileStoredCredential({
791
+ type: "api_key",
792
+ token: apiKey
793
+ });
794
+ return {
795
+ apiKey,
796
+ backend: stored.backend,
797
+ location: stored.location
798
+ };
799
+ }
800
+ async function writeFileStoredCredential(credential, authMode) {
721
801
  const configDir = getConfigDir();
722
802
  const credentialsPath = getCredentialsFilePath();
803
+ const payload = authMode === "none" ? {
804
+ version: 1,
805
+ authMode: "none"
806
+ } : {
807
+ version: 1,
808
+ credential
809
+ };
723
810
  await promises.mkdir(configDir, {
724
811
  recursive: true,
725
812
  mode: 448
726
813
  });
727
- await promises.writeFile(credentialsPath, `${JSON.stringify({ apiKey }, null, 2)}\n`, {
814
+ await promises.writeFile(credentialsPath, `${JSON.stringify(payload, null, 2)}\n`, {
728
815
  encoding: "utf8",
729
816
  mode: 384
730
817
  });
731
818
  await promises.chmod(credentialsPath, 384);
732
819
  return {
733
- apiKey,
820
+ ...credential ? { credential } : {},
821
+ ...authMode ? { authMode } : {},
734
822
  backend: "file",
735
823
  location: credentialsPath
736
824
  };
@@ -744,6 +832,15 @@ async function deleteFileCredentials() {
744
832
  }
745
833
  }
746
834
  async function readMacOSKeychain() {
835
+ const storedCredential = await readMacOSStoredCredential();
836
+ if (storedCredential?.credential?.type !== "api_key") return;
837
+ return {
838
+ apiKey: storedCredential.credential.token,
839
+ backend: storedCredential.backend,
840
+ location: storedCredential.location
841
+ };
842
+ }
843
+ async function readMacOSStoredCredential() {
747
844
  if (process.platform !== "darwin") return;
748
845
  try {
749
846
  const { stdout } = await execFile$1("security", [
@@ -754,19 +851,31 @@ async function readMacOSKeychain() {
754
851
  ACCOUNT_NAME,
755
852
  "-w"
756
853
  ]);
757
- const apiKey = stdout.trim();
758
- if (!apiKey) return;
759
- return {
760
- apiKey,
761
- backend: "macos-keychain",
762
- location: "macOS Keychain"
763
- };
854
+ return parseStoredCredential(stdout, "macos-keychain", "macOS Keychain");
764
855
  } catch {
765
856
  return;
766
857
  }
767
858
  }
768
859
  async function writeMacOSKeychain(apiKey) {
860
+ const stored = await writeMacOSStoredCredential({
861
+ type: "api_key",
862
+ token: apiKey
863
+ });
864
+ return stored ? {
865
+ apiKey,
866
+ backend: stored.backend,
867
+ location: stored.location
868
+ } : void 0;
869
+ }
870
+ async function writeMacOSStoredCredential(credential, authMode) {
769
871
  if (process.platform !== "darwin") return;
872
+ const payload = authMode === "none" ? {
873
+ version: 1,
874
+ authMode: "none"
875
+ } : {
876
+ version: 1,
877
+ credential
878
+ };
770
879
  try {
771
880
  await execFile$1("security", [
772
881
  "add-generic-password",
@@ -776,10 +885,11 @@ async function writeMacOSKeychain(apiKey) {
776
885
  "-a",
777
886
  ACCOUNT_NAME,
778
887
  "-w",
779
- apiKey
888
+ JSON.stringify(payload)
780
889
  ]);
781
890
  return {
782
- apiKey,
891
+ ...credential ? { credential } : {},
892
+ ...authMode ? { authMode } : {},
783
893
  backend: "macos-keychain",
784
894
  location: "macOS Keychain"
785
895
  };
@@ -803,6 +913,15 @@ async function deleteMacOSKeychain() {
803
913
  }
804
914
  }
805
915
  async function readLinuxSecretTool() {
916
+ const storedCredential = await readLinuxStoredCredential();
917
+ if (storedCredential?.credential?.type !== "api_key") return;
918
+ return {
919
+ apiKey: storedCredential.credential.token,
920
+ backend: storedCredential.backend,
921
+ location: storedCredential.location
922
+ };
923
+ }
924
+ async function readLinuxStoredCredential() {
806
925
  if (process.platform !== "linux") return;
807
926
  try {
808
927
  const { stdout } = await execFile$1("secret-tool", [
@@ -812,19 +931,31 @@ async function readLinuxSecretTool() {
812
931
  "account",
813
932
  ACCOUNT_NAME
814
933
  ]);
815
- const apiKey = stdout.trim();
816
- if (!apiKey) return;
817
- return {
818
- apiKey,
819
- backend: "linux-secret-tool",
820
- location: "Secret Service (secret-tool)"
821
- };
934
+ return parseStoredCredential(stdout, "linux-secret-tool", "Secret Service (secret-tool)");
822
935
  } catch {
823
936
  return;
824
937
  }
825
938
  }
826
939
  async function writeLinuxSecretTool(apiKey) {
940
+ const stored = await writeLinuxStoredCredential({
941
+ type: "api_key",
942
+ token: apiKey
943
+ });
944
+ return stored ? {
945
+ apiKey,
946
+ backend: stored.backend,
947
+ location: stored.location
948
+ } : void 0;
949
+ }
950
+ async function writeLinuxStoredCredential(credential, authMode) {
827
951
  if (process.platform !== "linux") return;
952
+ const payload = authMode === "none" ? {
953
+ version: 1,
954
+ authMode: "none"
955
+ } : {
956
+ version: 1,
957
+ credential
958
+ };
828
959
  try {
829
960
  execFileSync("secret-tool", [
830
961
  "store",
@@ -834,9 +965,10 @@ async function writeLinuxSecretTool(apiKey) {
834
965
  SERVICE_NAME,
835
966
  "account",
836
967
  ACCOUNT_NAME
837
- ], { input: apiKey });
968
+ ], { input: JSON.stringify(payload) });
838
969
  return {
839
- apiKey,
970
+ ...credential ? { credential } : {},
971
+ ...authMode ? { authMode } : {},
840
972
  backend: "linux-secret-tool",
841
973
  location: "Secret Service (secret-tool)"
842
974
  };
@@ -863,10 +995,22 @@ async function getStoredApiKey() {
863
995
  if (shouldUseFileBackendOnly()) return readFileCredentials();
864
996
  return await readMacOSKeychain() ?? await readLinuxSecretTool() ?? await readFileCredentials();
865
997
  }
998
+ async function getStoredCredential() {
999
+ if (shouldUseFileBackendOnly()) return readFileStoredCredential();
1000
+ return await readMacOSStoredCredential() ?? await readLinuxStoredCredential() ?? await readFileStoredCredential();
1001
+ }
866
1002
  async function setStoredApiKey(apiKey) {
867
1003
  if (shouldUseFileBackendOnly()) return writeFileCredentials(apiKey);
868
1004
  return await writeMacOSKeychain(apiKey) ?? await writeLinuxSecretTool(apiKey) ?? await writeFileCredentials(apiKey);
869
1005
  }
1006
+ async function setStoredCredential(credential) {
1007
+ if (shouldUseFileBackendOnly()) return writeFileStoredCredential(credential);
1008
+ return await writeMacOSStoredCredential(credential) ?? await writeLinuxStoredCredential(credential) ?? await writeFileStoredCredential(credential);
1009
+ }
1010
+ async function setStoredNoAuth() {
1011
+ if (shouldUseFileBackendOnly()) return writeFileStoredCredential(void 0, "none");
1012
+ return await writeMacOSStoredCredential(void 0, "none") ?? await writeLinuxStoredCredential(void 0, "none") ?? await writeFileStoredCredential(void 0, "none");
1013
+ }
870
1014
  async function deleteStoredApiKey() {
871
1015
  if (shouldUseFileBackendOnly()) {
872
1016
  const deleted = await deleteFileCredentials();
@@ -1168,6 +1312,11 @@ function loadLocalEnv() {
1168
1312
  }
1169
1313
  loadLocalEnv();
1170
1314
  function getEnvApiKey() {
1315
+ const accessToken = process.env.STATELY_ACCESS_TOKEN;
1316
+ if (accessToken) return {
1317
+ apiKey: accessToken,
1318
+ variable: "STATELY_ACCESS_TOKEN"
1319
+ };
1171
1320
  const statelyApiKey = process.env.STATELY_API_KEY;
1172
1321
  if (statelyApiKey) return {
1173
1322
  apiKey: statelyApiKey,
@@ -1179,6 +1328,32 @@ function getEnvApiKey() {
1179
1328
  variable: "NEXT_PUBLIC_STATELY_API_KEY"
1180
1329
  };
1181
1330
  }
1331
+ function getEnvCredential() {
1332
+ const accessToken = process.env.STATELY_ACCESS_TOKEN;
1333
+ if (accessToken) return {
1334
+ credential: {
1335
+ type: "oauth",
1336
+ accessToken
1337
+ },
1338
+ variable: "STATELY_ACCESS_TOKEN"
1339
+ };
1340
+ const statelyApiKey = process.env.STATELY_API_KEY;
1341
+ if (statelyApiKey) return {
1342
+ credential: {
1343
+ type: "api_key",
1344
+ token: statelyApiKey
1345
+ },
1346
+ variable: "STATELY_API_KEY"
1347
+ };
1348
+ const publicApiKey = process.env.NEXT_PUBLIC_STATELY_API_KEY;
1349
+ if (publicApiKey) return {
1350
+ credential: {
1351
+ type: "api_key",
1352
+ token: publicApiKey
1353
+ },
1354
+ variable: "NEXT_PUBLIC_STATELY_API_KEY"
1355
+ };
1356
+ }
1182
1357
  async function resolveApiKey(explicitApiKey) {
1183
1358
  if (explicitApiKey) return {
1184
1359
  apiKey: explicitApiKey,
@@ -1199,6 +1374,23 @@ async function resolveApiKey(explicitApiKey) {
1199
1374
  };
1200
1375
  return { source: "missing" };
1201
1376
  }
1377
+ async function resolveOAuthAccessToken() {
1378
+ const envCredential = getEnvCredential();
1379
+ if (envCredential?.credential.type === "oauth") return {
1380
+ apiKey: envCredential.credential.accessToken,
1381
+ source: "env",
1382
+ detail: envCredential.variable
1383
+ };
1384
+ if (envCredential?.credential.type === "api_key") throw new Error("`statelyai open` requires OAuth login. Run `statelyai login` instead of using an API key.");
1385
+ const storedCredential = await getStoredCredential();
1386
+ if (storedCredential?.credential?.type === "oauth") return {
1387
+ apiKey: storedCredential.credential.accessToken,
1388
+ source: "stored",
1389
+ detail: describeCredentialBackend(storedCredential.backend, storedCredential.location)
1390
+ };
1391
+ if (storedCredential?.credential?.type === "api_key") throw new Error("`statelyai open` requires OAuth login. Run `statelyai login` to replace the stored API key.");
1392
+ return { source: "missing" };
1393
+ }
1202
1394
  function getDefaultBaseUrl() {
1203
1395
  return process.env.STATELY_API_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_STATELY_API_URL;
1204
1396
  }
@@ -1208,6 +1400,51 @@ function getDefaultEditorUrl() {
1208
1400
  function getResolvedStudioUrl(baseUrl) {
1209
1401
  return baseUrl ?? getDefaultBaseUrl() ?? "https://stately.ai";
1210
1402
  }
1403
+ function getDefaultOAuthIssuer() {
1404
+ return process.env.STATELY_OAUTH_ISSUER ?? process.env.NEXT_PUBLIC_STATELY_OAUTH_ISSUER ?? "https://stately.ai";
1405
+ }
1406
+ function normalizeLoginResourceUrl(baseUrl) {
1407
+ const raw = baseUrl;
1408
+ const url = new URL(raw);
1409
+ if (url.pathname === "/" || url.pathname === "") url.pathname = "/api/mcp";
1410
+ return url.toString();
1411
+ }
1412
+ function getConfiguredOauthClientId() {
1413
+ return process.env.STATELY_OAUTH_CLIENT_ID ?? process.env.NEXT_PUBLIC_STATELY_OAUTH_CLIENT_ID;
1414
+ }
1415
+ async function discoverOAuthLogin(options = {}) {
1416
+ const resourceUrl = options.baseUrl ? normalizeLoginResourceUrl(options.baseUrl) : getDefaultOAuthIssuer();
1417
+ const protectedResource = options.baseUrl ? await discoverProtectedResource(resourceUrl, { fetch: options.fetch }) : void 0;
1418
+ const issuer = protectedResource ? protectedResource.authorization_servers?.[0] : resourceUrl;
1419
+ 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.`);
1420
+ const authorizationServer = await discoverAuthorizationServer(issuer, { fetch: options.fetch });
1421
+ const authorizationEndpoint = authorizationServer.authorization_endpoint;
1422
+ const tokenEndpoint = authorizationServer.token_endpoint;
1423
+ if (!authorizationEndpoint || !tokenEndpoint) throw new Error(`Authorization server ${issuer} did not advertise authorization and token endpoints.`);
1424
+ const configuredClientId = options.clientId ?? getConfiguredOauthClientId();
1425
+ const scopes = protectedResource?.scopes_supported ?? [];
1426
+ let clientId = configuredClientId;
1427
+ if (!clientId && authorizationServer.registration_endpoint && options.redirectUri) try {
1428
+ clientId = (await registerOAuthClient({
1429
+ registrationEndpoint: authorizationServer.registration_endpoint,
1430
+ redirectUri: options.redirectUri,
1431
+ scopes,
1432
+ fetch: options.fetch
1433
+ })).client_id;
1434
+ } catch (error) {
1435
+ 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 });
1436
+ }
1437
+ 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.`);
1438
+ return {
1439
+ authorizationServer,
1440
+ authorizationEndpoint,
1441
+ clientId,
1442
+ ...protectedResource ? { protectedResource } : {},
1443
+ resourceUrl,
1444
+ scopes,
1445
+ tokenEndpoint
1446
+ };
1447
+ }
1211
1448
  function parseGitHubRemote(remoteUrl) {
1212
1449
  const httpsMatch = remoteUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
1213
1450
  if (httpsMatch) {
@@ -1289,6 +1526,148 @@ async function promptForApiKey() {
1289
1526
  rl.close();
1290
1527
  }
1291
1528
  }
1529
+ async function promptForOAuthCallback() {
1530
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("No interactive terminal available to paste the OAuth callback URL.");
1531
+ const rl = createInterface({
1532
+ input: process.stdin,
1533
+ output: process.stdout,
1534
+ terminal: true
1535
+ });
1536
+ try {
1537
+ return (await rl.question("Paste the callback URL or code: ")).trim();
1538
+ } finally {
1539
+ rl.close();
1540
+ }
1541
+ }
1542
+ function parseOAuthCallback(value) {
1543
+ try {
1544
+ const url = new URL(value);
1545
+ const code = url.searchParams.get("code");
1546
+ const state = url.searchParams.get("state") ?? void 0;
1547
+ if (!code) throw new Error("Callback URL did not include a code parameter.");
1548
+ return {
1549
+ code,
1550
+ state
1551
+ };
1552
+ } catch (error) {
1553
+ if (error instanceof Error && error.message.includes("code parameter")) throw error;
1554
+ if (!value) throw new Error("OAuth code cannot be empty.");
1555
+ return { code: value };
1556
+ }
1557
+ }
1558
+ function createOAuthCredential(tokenResponse, now = Date.now()) {
1559
+ if (!tokenResponse.access_token) throw new Error("Token response did not include an access_token.");
1560
+ const expiresAt = typeof tokenResponse.expires_at === "number" ? tokenResponse.expires_at * 1e3 : typeof tokenResponse.expires_in === "number" ? now + tokenResponse.expires_in * 1e3 : void 0;
1561
+ return {
1562
+ type: "oauth",
1563
+ accessToken: tokenResponse.access_token,
1564
+ ...tokenResponse.refresh_token ? { refreshToken: tokenResponse.refresh_token } : {},
1565
+ ...expiresAt ? { expiresAt } : {}
1566
+ };
1567
+ }
1568
+ async function createLocalOAuthCallbackServer(options) {
1569
+ let resolveCode;
1570
+ let rejectCode;
1571
+ const codePromise = new Promise((resolve, reject) => {
1572
+ resolveCode = resolve;
1573
+ rejectCode = reject;
1574
+ });
1575
+ const server = http.createServer((req, res) => {
1576
+ const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
1577
+ const code = requestUrl.searchParams.get("code");
1578
+ const state = requestUrl.searchParams.get("state");
1579
+ const error = requestUrl.searchParams.get("error");
1580
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1581
+ if (error) {
1582
+ res.statusCode = 400;
1583
+ res.end("<!doctype html><title>Sign in failed</title><p>Sign in failed. Return to the terminal.</p>");
1584
+ rejectCode(new Error(error));
1585
+ return;
1586
+ }
1587
+ if (!code || state !== options.state) {
1588
+ res.statusCode = 400;
1589
+ res.end("<!doctype html><title>Sign in failed</title><p>Invalid sign-in callback. Return to the terminal.</p>");
1590
+ rejectCode(/* @__PURE__ */ new Error("Invalid OAuth callback."));
1591
+ return;
1592
+ }
1593
+ res.end("<!doctype html><title>Signed in</title><p>Signed in. You can close this window.</p>");
1594
+ resolveCode(code);
1595
+ });
1596
+ await new Promise((resolve) => {
1597
+ server.listen(0, "127.0.0.1", resolve);
1598
+ });
1599
+ const address = server.address();
1600
+ if (!address || typeof address === "string") throw new Error("Could not start OAuth callback server.");
1601
+ return {
1602
+ redirectUri: `http://127.0.0.1:${address.port}/callback`,
1603
+ waitForCode: () => codePromise,
1604
+ close: () => new Promise((resolve) => {
1605
+ server.close(() => resolve());
1606
+ })
1607
+ };
1608
+ }
1609
+ async function buildOAuthLoginStart(options) {
1610
+ const [discovery, pkce] = await Promise.all([discoverOAuthLogin({
1611
+ baseUrl: options.baseUrl,
1612
+ clientId: options.clientId,
1613
+ fetch: options.fetch,
1614
+ redirectUri: options.redirectUri
1615
+ }), createPkcePair()]);
1616
+ return {
1617
+ authorizeUrl: buildAuthorizeUrl({
1618
+ authorizationEndpoint: discovery.authorizationEndpoint,
1619
+ clientId: discovery.clientId,
1620
+ redirectUri: options.redirectUri,
1621
+ codeChallenge: pkce.codeChallenge,
1622
+ state: options.state,
1623
+ scopes: discovery.scopes
1624
+ }),
1625
+ codeVerifier: pkce.codeVerifier,
1626
+ discovery
1627
+ };
1628
+ }
1629
+ function formatStoredCredentialSource(storedCredential) {
1630
+ if (storedCredential.credential?.type === "api_key") return `Credential source: stored api_key (${describeCredentialBackend(storedCredential.backend, storedCredential.location)}).`;
1631
+ if (storedCredential.credential?.type === "oauth") return `Credential source: stored oauth (${describeCredentialBackend(storedCredential.backend, storedCredential.location)}).`;
1632
+ if (storedCredential.authMode === "none") return `Credential source: stored no-auth mode (${describeCredentialBackend(storedCredential.backend, storedCredential.location)}).`;
1633
+ return `Credential source: stored unknown (${describeCredentialBackend(storedCredential.backend, storedCredential.location)}).`;
1634
+ }
1635
+ async function runOAuthBrowserLogin(options) {
1636
+ const state = crypto.randomBytes(16).toString("base64url");
1637
+ const callbackServer = await createLocalOAuthCallbackServer({ state });
1638
+ try {
1639
+ const { authorizeUrl, codeVerifier, discovery } = await buildOAuthLoginStart({
1640
+ baseUrl: options.baseUrl,
1641
+ redirectUri: callbackServer.redirectUri,
1642
+ state
1643
+ });
1644
+ options.log(`Opening browser for ${discovery.resourceUrl}`);
1645
+ options.log(authorizeUrl);
1646
+ let openedBrowser = false;
1647
+ try {
1648
+ openBrowser(authorizeUrl);
1649
+ openedBrowser = true;
1650
+ } catch {
1651
+ options.log("Could not open a browser automatically.");
1652
+ }
1653
+ const codePromise = callbackServer.waitForCode();
1654
+ const pastedCodePromise = !openedBrowser && process.stdin.isTTY && process.stdout.isTTY ? promptForOAuthCallback().then((value) => {
1655
+ const callback = parseOAuthCallback(value);
1656
+ if (callback.state && callback.state !== state) throw new Error("OAuth state did not match.");
1657
+ return callback.code;
1658
+ }) : void 0;
1659
+ const code = await (pastedCodePromise ? Promise.race([codePromise, pastedCodePromise]) : codePromise);
1660
+ return setStoredCredential(createOAuthCredential(await exchangeCodeForToken({
1661
+ tokenEndpoint: discovery.tokenEndpoint,
1662
+ code,
1663
+ codeVerifier,
1664
+ redirectUri: callbackServer.redirectUri,
1665
+ clientId: discovery.clientId
1666
+ })));
1667
+ } finally {
1668
+ await callbackServer.close();
1669
+ }
1670
+ }
1292
1671
  async function promptYesNo(question, defaultValue = true) {
1293
1672
  if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("No interactive terminal available.");
1294
1673
  const rl = createInterface({
@@ -1954,7 +2333,7 @@ var OpenCommand = class OpenCommand extends Command {
1954
2333
  }) };
1955
2334
  static flags = {
1956
2335
  help: Flags.help({ char: "h" }),
1957
- "api-key": Flags.string({ description: "Stately API key used when the editor server requires auth" }),
2336
+ "api-key": Flags.string({ description: "Deprecated. `statelyai open` requires OAuth login." }),
1958
2337
  "editor-url": Flags.string({ description: "Base URL for the Stately editor embed" }),
1959
2338
  host: Flags.string({
1960
2339
  description: "Local server host",
@@ -1977,7 +2356,9 @@ var OpenCommand = class OpenCommand extends Command {
1977
2356
  };
1978
2357
  async run() {
1979
2358
  const { args, flags } = await this.parse(OpenCommand);
1980
- const apiKey = (await resolveApiKey(flags["api-key"])).apiKey;
2359
+ if (flags["api-key"]) throw new Error("`statelyai open` requires OAuth login. Run `statelyai login` and retry.");
2360
+ const apiKey = (await resolveOAuthAccessToken()).apiKey;
2361
+ if (!apiKey) throw new Error("`statelyai open` requires OAuth login. Run `statelyai login` and retry.");
1981
2362
  await openEditor({
1982
2363
  fileName: path.resolve(args.file),
1983
2364
  editorUrl: flags["editor-url"] ?? getDefaultEditorUrl(),
@@ -2062,23 +2443,46 @@ var InitCommand = class InitCommand extends Command {
2062
2443
  };
2063
2444
  var LoginCommand = class LoginCommand extends Command {
2064
2445
  static enableJsonFlag = false;
2065
- static summary = "Store a Stately API key for future CLI use.";
2066
- static description = "Stores a Stately API key in the OS credential store when available, with a private file fallback.";
2446
+ static summary = "Store Stately credentials for future CLI use.";
2447
+ static description = "Stores Stately credentials in the OS credential store when available, with a private file fallback.";
2067
2448
  static flags = {
2068
2449
  help: Flags.help({ char: "h" }),
2069
2450
  "api-key": Flags.string({ description: "API key to store without an interactive prompt" }),
2451
+ "base-url": Flags.string({ description: "OAuth protected-resource URL for custom deployments" }),
2070
2452
  stdin: Flags.boolean({
2071
2453
  description: "Read the API key from standard input",
2072
2454
  default: false
2455
+ }),
2456
+ auth: Flags.string({
2457
+ description: "Authentication mode for the target server",
2458
+ options: ["none", "bearer"]
2073
2459
  })
2074
2460
  };
2075
2461
  async run() {
2076
2462
  const { flags } = await this.parse(LoginCommand);
2463
+ if (flags["api-key"] && flags.auth === "bearer") this.error("Pass either --api-key or --auth bearer, not both.");
2464
+ if (flags.auth === "none") {
2465
+ if (flags.stdin || flags["api-key"]) this.error("Pass either --auth none or an API key, not both.");
2466
+ const stored = await setStoredNoAuth();
2467
+ this.log(`Stored no-auth mode in ${describeCredentialBackend(stored.backend, stored.location)}.`);
2468
+ return;
2469
+ }
2470
+ if (!flags["api-key"] && !flags.stdin) {
2471
+ const stored = await runOAuthBrowserLogin({
2472
+ baseUrl: flags["base-url"],
2473
+ log: (message) => this.log(message)
2474
+ });
2475
+ this.log(`Stored OAuth credential in ${describeCredentialBackend(stored.backend, stored.location)}.`);
2476
+ return;
2477
+ }
2077
2478
  if (flags.stdin && flags["api-key"]) this.error("Pass either --api-key or --stdin, not both.");
2078
2479
  if (!flags["api-key"] && !flags.stdin && process.stdin.isTTY && process.stdout.isTTY) this.log(`Get or create an API key at ${STATELY_API_KEY_SETTINGS_URL}`);
2079
2480
  const apiKey = normalizeApiKey(flags["api-key"] ?? (!process.stdin.isTTY || flags.stdin ? await readApiKeyFromStdin() : await promptForApiKey()));
2080
2481
  if (!apiKey) this.error(`API key cannot be empty.\nGet or create an API key at ${STATELY_API_KEY_SETTINGS_URL}`);
2081
- const stored = await setStoredApiKey(apiKey);
2482
+ const stored = flags["api-key"] || flags.stdin ? await setStoredApiKey(apiKey) : await setStoredCredential({
2483
+ type: "api_key",
2484
+ token: apiKey
2485
+ });
2082
2486
  this.log(`Stored API key in ${describeCredentialBackend(stored.backend, stored.location)}.`);
2083
2487
  }
2084
2488
  };
@@ -2102,15 +2506,20 @@ var WhoamiCommand = class extends Command {
2102
2506
  static description = "Reports whether the CLI would use a flag, environment variable, or stored credential.";
2103
2507
  static flags = { help: Flags.help({ char: "h" }) };
2104
2508
  async run() {
2105
- const envApiKey = getEnvApiKey();
2106
- const storedApiKey = await getStoredApiKey();
2107
- if (envApiKey) {
2108
- this.log(`API key source: environment (${envApiKey.variable}).`);
2509
+ const envCredential = getEnvCredential();
2510
+ const storedCredential = await getStoredCredential();
2511
+ const storedApiKey = storedCredential?.credential?.type === "api_key" ? {
2512
+ apiKey: storedCredential.credential.token,
2513
+ backend: storedCredential.backend,
2514
+ location: storedCredential.location
2515
+ } : void 0;
2516
+ if (envCredential) {
2517
+ this.log(`Credential source: environment ${envCredential.credential.type} (${envCredential.variable}).`);
2109
2518
  if (storedApiKey) this.log(`Stored credential also available in ${describeCredentialBackend(storedApiKey.backend, storedApiKey.location)}.`);
2110
2519
  return;
2111
2520
  }
2112
- if (storedApiKey) {
2113
- this.log(`API key source: stored credential (${describeCredentialBackend(storedApiKey.backend, storedApiKey.location)}).`);
2521
+ if (storedCredential) {
2522
+ this.log(formatStoredCredentialSource(storedCredential));
2114
2523
  return;
2115
2524
  }
2116
2525
  this.log(getMissingApiKeyMessage());
@@ -2149,4 +2558,4 @@ function isDirectExecution() {
2149
2558
  if (isDirectExecution()) run$1();
2150
2559
 
2151
2560
  //#endregion
2152
- export { formatMissingNewMachineDirMessage as a, inferInitProjectName as c, resolveApiKey as d, resolveConfiguredPullTargets as f, createStatelyProjectConfig as h, discoverRemoteProjectMachineTargets as i, initProject as l, scanProjectSources as m, classifyPushCandidates as n, formatPlanSummary as o, run$1 as p, discoverLinkedPullTargets as r, getEnvApiKey as s, COMMANDS as t, isFileGitDirty as u };
2561
+ export { resolveOAuthAccessToken as _, discoverOAuthLogin as a, createStatelyProjectConfig as b, formatPlanSummary as c, getEnvCredential as d, inferInitProjectName as f, resolveConfiguredPullTargets as g, resolveApiKey as h, discoverLinkedPullTargets as i, formatStoredCredentialSource as l, isFileGitDirty as m, buildOAuthLoginStart as n, discoverRemoteProjectMachineTargets as o, initProject as p, classifyPushCandidates as r, formatMissingNewMachineDirMessage as s, COMMANDS as t, getEnvApiKey as u, run$1 as v, scanProjectSources as y };
package/dist/index.d.mts CHANGED
@@ -1,8 +1,26 @@
1
1
  import { Command } from "@oclif/core";
2
- import { ConnectedRepo, CreateProjectInput, ProjectData, StudioClient } from "@statelyai/sdk";
2
+ import { ConnectedRepo, CreateProjectInput, OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata, ProjectData, StudioClient } from "@statelyai/sdk";
3
3
  import { SyncPlan } from "@statelyai/sdk/sync";
4
4
  import * as _oclif_core_interfaces0 from "@oclif/core/interfaces";
5
5
 
6
+ //#region src/credentials.d.ts
7
+ type StatelyCredential = {
8
+ type: 'api_key';
9
+ token: string;
10
+ } | {
11
+ type: 'oauth';
12
+ accessToken: string;
13
+ refreshToken?: string;
14
+ expiresAt?: number;
15
+ };
16
+ type CredentialBackend = 'file' | 'linux-secret-tool' | 'macos-keychain';
17
+ type StoredCredential = {
18
+ credential?: StatelyCredential;
19
+ authMode?: 'none';
20
+ backend: CredentialBackend;
21
+ location: string;
22
+ };
23
+ //#endregion
6
24
  //#region src/projectConfig.d.ts
7
25
  interface StatelySourceConfig {
8
26
  include: string[];
@@ -90,10 +108,42 @@ type ApiKeyResolution = {
90
108
  };
91
109
  declare function getEnvApiKey(): {
92
110
  apiKey: string;
93
- variable: 'NEXT_PUBLIC_STATELY_API_KEY' | 'STATELY_API_KEY';
111
+ variable: 'NEXT_PUBLIC_STATELY_API_KEY' | 'STATELY_API_KEY' | 'STATELY_ACCESS_TOKEN';
112
+ } | undefined;
113
+ declare function getEnvCredential(): {
114
+ credential: StatelyCredential;
115
+ variable: 'NEXT_PUBLIC_STATELY_API_KEY' | 'STATELY_API_KEY' | 'STATELY_ACCESS_TOKEN';
94
116
  } | undefined;
95
117
  declare function resolveApiKey(explicitApiKey?: string): Promise<ApiKeyResolution>;
118
+ declare function resolveOAuthAccessToken(): Promise<ApiKeyResolution>;
119
+ interface OAuthLoginDiscovery {
120
+ authorizationServer: OAuthAuthorizationServerMetadata;
121
+ authorizationEndpoint: string;
122
+ clientId: string;
123
+ protectedResource?: OAuthProtectedResourceMetadata;
124
+ resourceUrl: string;
125
+ scopes: string[];
126
+ tokenEndpoint: string;
127
+ }
128
+ declare function discoverOAuthLogin(options?: {
129
+ baseUrl?: string;
130
+ clientId?: string;
131
+ fetch?: typeof fetch;
132
+ redirectUri?: string;
133
+ }): Promise<OAuthLoginDiscovery>;
96
134
  declare function inferInitProjectName(cwd: string, repo?: ConnectedRepo): string;
135
+ declare function buildOAuthLoginStart(options: {
136
+ baseUrl?: string;
137
+ clientId?: string;
138
+ fetch?: typeof fetch;
139
+ redirectUri: string;
140
+ state: string;
141
+ }): Promise<{
142
+ authorizeUrl: string;
143
+ codeVerifier: string;
144
+ discovery: OAuthLoginDiscovery;
145
+ }>;
146
+ declare function formatStoredCredentialSource(storedCredential: StoredCredential): string;
97
147
  declare function formatMissingNewMachineDirMessage(options: {
98
148
  config: Pick<StatelyProjectConfig, 'include' | 'newMachinesDir'>;
99
149
  remoteMachineCount: number;
@@ -230,7 +280,9 @@ declare class LoginCommand extends Command {
230
280
  static flags: {
231
281
  help: _oclif_core_interfaces0.BooleanFlag<void>;
232
282
  'api-key': _oclif_core_interfaces0.OptionFlag<string | undefined, _oclif_core_interfaces0.CustomOptions>;
283
+ 'base-url': _oclif_core_interfaces0.OptionFlag<string | undefined, _oclif_core_interfaces0.CustomOptions>;
233
284
  stdin: _oclif_core_interfaces0.BooleanFlag<boolean>;
285
+ auth: _oclif_core_interfaces0.OptionFlag<string | undefined, _oclif_core_interfaces0.CustomOptions>;
234
286
  };
235
287
  run(): Promise<void>;
236
288
  }
@@ -265,4 +317,4 @@ declare const COMMANDS: {
265
317
  };
266
318
  declare function run(argv?: any, entryUrl?: string): Promise<void>;
267
319
  //#endregion
268
- export { ApiKeyResolution, COMMANDS, InitProjectOptions, InitProjectResult, LinkedPullTarget, PushCandidateClassification, RemoteProjectMachineTarget, ResolvedConfiguredPullTargets, ScanProjectSourcesOptions, classifyPushCandidates, createStatelyProjectConfig, discoverLinkedPullTargets, discoverRemoteProjectMachineTargets, formatMissingNewMachineDirMessage, formatPlanSummary, getEnvApiKey, inferInitProjectName, initProject, isFileGitDirty, resolveApiKey, resolveConfiguredPullTargets, run, scanProjectSources };
320
+ export { ApiKeyResolution, COMMANDS, InitProjectOptions, InitProjectResult, LinkedPullTarget, OAuthLoginDiscovery, PushCandidateClassification, RemoteProjectMachineTarget, ResolvedConfiguredPullTargets, ScanProjectSourcesOptions, buildOAuthLoginStart, classifyPushCandidates, createStatelyProjectConfig, discoverLinkedPullTargets, discoverOAuthLogin, discoverRemoteProjectMachineTargets, formatMissingNewMachineDirMessage, formatPlanSummary, formatStoredCredentialSource, getEnvApiKey, getEnvCredential, inferInitProjectName, initProject, isFileGitDirty, resolveApiKey, resolveConfiguredPullTargets, resolveOAuthAccessToken, run, scanProjectSources };
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { a as formatMissingNewMachineDirMessage, c as inferInitProjectName, d as resolveApiKey, f as resolveConfiguredPullTargets, h as createStatelyProjectConfig, i as discoverRemoteProjectMachineTargets, l as initProject, m as scanProjectSources, n as classifyPushCandidates, o as formatPlanSummary, p as run, r as discoverLinkedPullTargets, s as getEnvApiKey, t as COMMANDS, u as isFileGitDirty } from "./cli-DB0A-O4Y.mjs";
1
+ import { _ as resolveOAuthAccessToken, a as discoverOAuthLogin, b as createStatelyProjectConfig, c as formatPlanSummary, d as getEnvCredential, f as inferInitProjectName, g as resolveConfiguredPullTargets, h as resolveApiKey, i as discoverLinkedPullTargets, l as formatStoredCredentialSource, m as isFileGitDirty, n as buildOAuthLoginStart, o as discoverRemoteProjectMachineTargets, p as initProject, r as classifyPushCandidates, s as formatMissingNewMachineDirMessage, t as COMMANDS, u as getEnvApiKey, v as run, y as scanProjectSources } from "./cli-jiby9qdf.mjs";
2
2
 
3
- export { COMMANDS, classifyPushCandidates, createStatelyProjectConfig, discoverLinkedPullTargets, discoverRemoteProjectMachineTargets, formatMissingNewMachineDirMessage, formatPlanSummary, getEnvApiKey, inferInitProjectName, initProject, isFileGitDirty, resolveApiKey, resolveConfiguredPullTargets, run, scanProjectSources };
3
+ export { COMMANDS, buildOAuthLoginStart, classifyPushCandidates, createStatelyProjectConfig, discoverLinkedPullTargets, discoverOAuthLogin, discoverRemoteProjectMachineTargets, formatMissingNewMachineDirMessage, formatPlanSummary, formatStoredCredentialSource, getEnvApiKey, getEnvCredential, inferInitProjectName, initProject, isFileGitDirty, resolveApiKey, resolveConfiguredPullTargets, resolveOAuthAccessToken, run, scanProjectSources };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statelyai",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
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.11.0"
24
+ "@statelyai/sdk": "0.12.1"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsdown": "0.21.0-beta.2",