@prisma/cli 3.0.0-beta.2 → 3.0.0-beta.20

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.
Files changed (87) hide show
  1. package/README.md +5 -16
  2. package/dist/adapters/git.js +8 -3
  3. package/dist/adapters/local-state.js +26 -7
  4. package/dist/adapters/mock-api.js +81 -2
  5. package/dist/adapters/token-storage.js +344 -25
  6. package/dist/cli.js +18 -3
  7. package/dist/cli2.js +12 -4
  8. package/dist/commands/agent/index.js +60 -0
  9. package/dist/commands/app/index.js +90 -61
  10. package/dist/commands/auth/index.js +55 -2
  11. package/dist/commands/branch/index.js +2 -27
  12. package/dist/commands/build/index.js +29 -0
  13. package/dist/commands/database/index.js +159 -0
  14. package/dist/commands/env.js +8 -4
  15. package/dist/commands/git/index.js +1 -1
  16. package/dist/commands/project/index.js +1 -1
  17. package/dist/controllers/agent.js +228 -0
  18. package/dist/controllers/app-env-api.js +54 -0
  19. package/dist/controllers/app-env-file.js +181 -0
  20. package/dist/controllers/app-env.js +286 -131
  21. package/dist/controllers/app.js +849 -309
  22. package/dist/controllers/auth.js +255 -11
  23. package/dist/controllers/branch.js +78 -48
  24. package/dist/controllers/build.js +88 -0
  25. package/dist/controllers/database.js +318 -0
  26. package/dist/controllers/project.js +156 -87
  27. package/dist/controllers/select-prompt-port.js +1 -0
  28. package/dist/lib/agent/cli-command.js +17 -0
  29. package/dist/lib/agent/constants.js +12 -0
  30. package/dist/lib/agent/package-manager.js +99 -0
  31. package/dist/lib/agent/setup-status.js +83 -0
  32. package/dist/lib/app/app-interaction.js +5 -0
  33. package/dist/lib/app/{preview-provider.js → app-provider.js} +220 -104
  34. package/dist/lib/app/branch-database-api.js +102 -0
  35. package/dist/lib/app/branch-database-deploy.js +326 -0
  36. package/dist/lib/app/branch-database.js +216 -0
  37. package/dist/lib/app/build-settings.js +93 -0
  38. package/dist/lib/app/build.js +82 -0
  39. package/dist/lib/app/bun-project.js +15 -9
  40. package/dist/lib/app/compute-config.js +145 -0
  41. package/dist/lib/app/deploy-plan.js +59 -0
  42. package/dist/lib/app/{preview-progress.js → deploy-progress.js} +12 -12
  43. package/dist/lib/app/env-config.js +1 -1
  44. package/dist/lib/app/env-file.js +82 -0
  45. package/dist/lib/app/env-vars.js +28 -2
  46. package/dist/lib/app/local-dev.js +8 -49
  47. package/dist/lib/app/production-deploy-gate.js +162 -0
  48. package/dist/lib/app/read-branch.js +30 -0
  49. package/dist/lib/auth/auth-ops.js +38 -23
  50. package/dist/lib/auth/guard.js +6 -2
  51. package/dist/lib/auth/login.js +117 -15
  52. package/dist/lib/database/provider.js +167 -0
  53. package/dist/lib/diagnostics.js +15 -0
  54. package/dist/lib/fs/home-path.js +24 -0
  55. package/dist/lib/git/local-branch.js +53 -0
  56. package/dist/lib/git/local-status.js +57 -0
  57. package/dist/lib/project/interactive-setup.js +2 -1
  58. package/dist/lib/project/local-pin.js +183 -34
  59. package/dist/lib/project/resolution.js +206 -50
  60. package/dist/lib/project/setup.js +64 -18
  61. package/dist/output/patterns.js +1 -1
  62. package/dist/presenters/agent.js +74 -0
  63. package/dist/presenters/app-env.js +149 -14
  64. package/dist/presenters/app.js +187 -26
  65. package/dist/presenters/auth.js +99 -2
  66. package/dist/presenters/branch.js +37 -102
  67. package/dist/presenters/database.js +274 -0
  68. package/dist/presenters/project.js +44 -26
  69. package/dist/presenters/verbose-context.js +64 -0
  70. package/dist/shell/cli-command.js +12 -0
  71. package/dist/shell/command-arguments.js +7 -1
  72. package/dist/shell/command-meta.js +243 -15
  73. package/dist/shell/command-runner.js +60 -21
  74. package/dist/shell/diagnostics-output.js +57 -0
  75. package/dist/shell/errors.js +61 -1
  76. package/dist/shell/help.js +31 -20
  77. package/dist/shell/output.js +10 -1
  78. package/dist/shell/prompt.js +7 -3
  79. package/dist/shell/runtime.js +10 -6
  80. package/dist/shell/ui.js +42 -3
  81. package/dist/shell/update-check.js +247 -0
  82. package/dist/use-cases/auth.js +68 -1
  83. package/dist/use-cases/branch.js +20 -68
  84. package/dist/use-cases/create-cli-gateways.js +2 -17
  85. package/package.json +27 -10
  86. package/dist/lib/app/preview-build.js +0 -284
  87. package/dist/lib/app/preview-interaction.js +0 -5
package/README.md CHANGED
@@ -57,6 +57,8 @@ Useful next commands:
57
57
  npx prisma-cli app logs
58
58
  npx prisma-cli app open
59
59
  npx prisma-cli project env add DATABASE_URL=postgresql://example --role preview
60
+ npx prisma-cli project env add --file .env --role preview
61
+ npx prisma-cli project env list
60
62
  npx prisma-cli project env list --role preview
61
63
  ```
62
64
 
@@ -73,7 +75,7 @@ The beta package exposes `prisma-cli` so it can coexist with the existing
73
75
  | `auth` | Log in, log out, and inspect the active Prisma account. |
74
76
  | `project` | List projects, show the resolved project, and manage project environment variables. |
75
77
  | `git` | Connect or disconnect a project from a GitHub repository. |
76
- | `branch` | Inspect the Prisma branch that maps to the current work context. |
78
+ | `branch` | List Prisma branches for the resolved project. |
77
79
  | `app` | Build, run, deploy, inspect, open, stream logs, promote, roll back, and remove apps. |
78
80
 
79
81
  Common examples:
@@ -82,7 +84,7 @@ Common examples:
82
84
  npx prisma-cli version
83
85
  npx prisma-cli auth whoami
84
86
  npx prisma-cli project show
85
- npx prisma-cli branch show
87
+ npx prisma-cli branch list
86
88
  npx prisma-cli app deploy --branch feat-login --framework nextjs
87
89
  npx prisma-cli app promote <deployment-id>
88
90
  ```
@@ -96,24 +98,11 @@ npx prisma-cli app promote <deployment-id>
96
98
  - Stable command groups, flags, and error codes for scripts and agents.
97
99
  - Environment variable values are not printed back to the terminal.
98
100
 
99
- ### Agent skills
100
-
101
- For agent-guided Next.js deploys, install the Prisma CLI skill cluster at the
102
- project level. Match the skill ref to the installed CLI version:
103
-
104
- ```bash
105
- npx prisma-cli version
106
- pnpm dlx skills@latest add prisma/prisma-cli/skills#cli-v<cli-version> --all
107
- ```
108
-
109
- The skills teach agents how to prepare a Next.js app, run `app deploy`, verify
110
- the result, and route CLI / Compute feedback to the right Prisma channel.
111
-
112
101
  ---
113
102
 
114
103
  ## Beta notes
115
104
 
116
- - Requires Node.js 20 or newer.
105
+ - Requires Node.js 22.12 or newer.
117
106
  - This is a beta package and may change quickly.
118
107
  - Official beta releases publish as `@prisma/cli`.
119
108
  - The package binary is `prisma-cli`, not `prisma`, during beta.
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  //#region src/adapters/git.ts
4
4
  const execFileAsync = promisify(execFile);
5
- async function readGitOriginRemote(cwd) {
5
+ async function readGitOriginRemote(cwd, signal) {
6
6
  try {
7
7
  const { stdout } = await execFileAsync("git", [
8
8
  "config",
@@ -10,14 +10,19 @@ async function readGitOriginRemote(cwd) {
10
10
  "remote.origin.url"
11
11
  ], {
12
12
  cwd,
13
- timeout: 5e3
13
+ timeout: 5e3,
14
+ signal
14
15
  });
15
16
  const remote = stdout.trim();
16
17
  return remote.length > 0 ? remote : null;
17
- } catch {
18
+ } catch (error) {
19
+ if (signal?.aborted || isAbortError(error)) throw error;
18
20
  return null;
19
21
  }
20
22
  }
23
+ function isAbortError(error) {
24
+ return error instanceof Error && error.name === "AbortError";
25
+ }
21
26
  function parseGitHubRepositoryUrl(value) {
22
27
  const input = value.trim();
23
28
  const shorthand = input.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
@@ -1,5 +1,5 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
1
  import path from "node:path";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  //#region src/adapters/local-state.ts
4
4
  const DEFAULT_STATE = {
5
5
  auth: null,
@@ -12,7 +12,8 @@ const DEFAULT_STATE = {
12
12
  app: {
13
13
  selectedByProject: {},
14
14
  knownLiveDeploymentByProject: {}
15
- }
15
+ },
16
+ agent: { setupPromptDismissedAt: null }
16
17
  };
17
18
  const DEFAULT_STATE_FILE_NAME = "state.json";
18
19
  function resolveLocalStateFilePath(stateDir) {
@@ -20,12 +21,17 @@ function resolveLocalStateFilePath(stateDir) {
20
21
  }
21
22
  var LocalStateStore = class {
22
23
  stateFilePath;
23
- constructor(stateDir) {
24
+ constructor(stateDir, signal) {
25
+ this.signal = signal;
24
26
  this.stateFilePath = resolveLocalStateFilePath(stateDir);
25
27
  }
26
28
  async read() {
29
+ this.signal?.throwIfAborted();
27
30
  try {
28
- const raw = await readFile(this.stateFilePath, "utf8");
31
+ const raw = await readFile(this.stateFilePath, {
32
+ encoding: "utf8",
33
+ signal: this.signal
34
+ });
29
35
  const parsed = JSON.parse(raw);
30
36
  return {
31
37
  auth: parsed.auth ?? structuredClone(DEFAULT_STATE.auth),
@@ -38,7 +44,8 @@ var LocalStateStore = class {
38
44
  app: {
39
45
  selectedByProject: parsed.app?.selectedByProject ?? {},
40
46
  knownLiveDeploymentByProject: parsed.app?.knownLiveDeploymentByProject ?? {}
41
- }
47
+ },
48
+ agent: { setupPromptDismissedAt: parsed.agent?.setupPromptDismissedAt ?? null }
42
49
  };
43
50
  } catch (error) {
44
51
  if (error.code === "ENOENT") return structuredClone(DEFAULT_STATE);
@@ -46,8 +53,11 @@ var LocalStateStore = class {
46
53
  }
47
54
  }
48
55
  async write(state) {
56
+ this.signal?.throwIfAborted();
49
57
  await mkdir(path.dirname(this.stateFilePath), { recursive: true });
50
- await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
58
+ this.signal?.throwIfAborted();
59
+ await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8" });
60
+ this.signal?.throwIfAborted();
51
61
  }
52
62
  async setAuthSession(session) {
53
63
  const state = await this.read();
@@ -131,6 +141,15 @@ var LocalStateStore = class {
131
141
  await this.write(state);
132
142
  return state;
133
143
  }
144
+ async readAgentSetupPromptDismissedAt() {
145
+ return (await this.read()).agent.setupPromptDismissedAt;
146
+ }
147
+ async setAgentSetupPromptDismissedAt(dismissedAt) {
148
+ const state = await this.read();
149
+ state.agent.setupPromptDismissedAt = dismissedAt;
150
+ await this.write(state);
151
+ return state;
152
+ }
134
153
  };
135
154
  //#endregion
136
- export { LocalStateStore };
155
+ export { LocalStateStore, resolveLocalStateFilePath };
@@ -5,8 +5,12 @@ var MockApi = class MockApi {
5
5
  constructor(data) {
6
6
  this.data = data;
7
7
  }
8
- static async load(fixturePath) {
9
- const raw = await readFile(fixturePath, "utf8");
8
+ static async load(fixturePath, signal) {
9
+ signal?.throwIfAborted();
10
+ const raw = await readFile(fixturePath, {
11
+ encoding: "utf8",
12
+ signal
13
+ });
10
14
  return new MockApi(JSON.parse(raw));
11
15
  }
12
16
  listProviders() {
@@ -52,6 +56,81 @@ var MockApi = class MockApi {
52
56
  getDeployment(deploymentId) {
53
57
  return this.data.deployments.find((deployment) => deployment.id === deploymentId);
54
58
  }
59
+ listDatabasesForProject(projectId, branchName) {
60
+ return (this.data.databases ?? []).filter((database) => database.projectId === projectId && (!branchName || database.branchName === branchName));
61
+ }
62
+ getDatabase(databaseId) {
63
+ return (this.data.databases ?? []).find((database) => database.id === databaseId);
64
+ }
65
+ createDatabase(input) {
66
+ this.data.databases ??= [];
67
+ this.data.databaseConnections ??= [];
68
+ const database = {
69
+ id: `db_${this.data.databases.length + 1e3}`,
70
+ projectId: input.projectId,
71
+ branchId: input.branchName ? this.getBranchForProject(input.projectId, input.branchName)?.id ?? null : null,
72
+ branchName: input.branchName ?? null,
73
+ name: input.name,
74
+ region: input.region ?? null,
75
+ status: "ready",
76
+ isDefault: false,
77
+ createdAt: "2026-06-09T00:00:00.000Z"
78
+ };
79
+ const connectionString = `postgresql://${database.id}.example.prisma.io/postgres`;
80
+ const connection = {
81
+ id: `conn_${this.data.databaseConnections.length + 1e3}`,
82
+ databaseId: database.id,
83
+ name: "primary",
84
+ createdAt: "2026-06-09T00:00:00.000Z",
85
+ connectionString
86
+ };
87
+ this.data.databases.push(database);
88
+ this.data.databaseConnections.push(connection);
89
+ return {
90
+ database,
91
+ connection,
92
+ connectionString
93
+ };
94
+ }
95
+ removeDatabase(databaseId) {
96
+ this.data.databases ??= [];
97
+ this.data.databaseConnections ??= [];
98
+ const database = this.getDatabase(databaseId);
99
+ if (!database) return;
100
+ this.data.databases = this.data.databases.filter((candidate) => candidate.id !== databaseId);
101
+ this.data.databaseConnections = this.data.databaseConnections.filter((connection) => connection.databaseId !== databaseId);
102
+ return database;
103
+ }
104
+ listDatabaseConnections(databaseId) {
105
+ return (this.data.databaseConnections ?? []).filter((connection) => connection.databaseId === databaseId);
106
+ }
107
+ getDatabaseConnection(connectionId) {
108
+ return (this.data.databaseConnections ?? []).find((connection) => connection.id === connectionId);
109
+ }
110
+ createDatabaseConnection(input) {
111
+ if (!this.getDatabase(input.databaseId)) return;
112
+ this.data.databaseConnections ??= [];
113
+ const connectionString = `postgresql://${input.databaseId}-${this.data.databaseConnections.length + 1}.example.prisma.io/postgres`;
114
+ const connection = {
115
+ id: `conn_${this.data.databaseConnections.length + 1e3}`,
116
+ databaseId: input.databaseId,
117
+ name: input.name,
118
+ createdAt: "2026-06-09T00:00:00.000Z",
119
+ connectionString
120
+ };
121
+ this.data.databaseConnections.push(connection);
122
+ return {
123
+ connection,
124
+ connectionString
125
+ };
126
+ }
127
+ removeDatabaseConnection(connectionId) {
128
+ this.data.databaseConnections ??= [];
129
+ const connection = this.getDatabaseConnection(connectionId);
130
+ if (!connection) return;
131
+ this.data.databaseConnections = this.data.databaseConnections.filter((candidate) => candidate.id !== connectionId);
132
+ return connection;
133
+ }
55
134
  };
56
135
  //#endregion
57
136
  export { MockApi };
@@ -1,9 +1,22 @@
1
1
  import { getAuthFilePath } from "../lib/auth/client.js";
2
- import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { CredentialsStore } from "@prisma/credentials-store";
3
+ import fs from "node:fs/promises";
5
4
  import { randomUUID } from "node:crypto";
5
+ import { CredentialsStore } from "@prisma/credentials-store";
6
6
  //#region src/adapters/token-storage.ts
7
+ const REFRESH_LOCK_RETRY_MS = 100;
8
+ const REFRESH_LOCK_STALE_MS = 3e4;
9
+ const REFRESH_LOCK_WAIT_TIMEOUT_MS = 25e3;
10
+ const EMPTY_AUTH_CONTEXT = {
11
+ activeWorkspaceId: null,
12
+ workspaces: {}
13
+ };
14
+ const UNKNOWN_WORKSPACE_NAME = "Unknown workspace";
15
+ function getAuthContextFilePath(authFilePath) {
16
+ const extension = path.extname(authFilePath);
17
+ if (!extension) return `${authFilePath}.context.json`;
18
+ return `${authFilePath.slice(0, -extension.length)}.context${extension}`;
19
+ }
7
20
  function findLatestValidTokens(allCredentials) {
8
21
  for (let i = allCredentials.length - 1; i >= 0; i -= 1) {
9
22
  const credential = allCredentials[i];
@@ -17,42 +30,195 @@ function findLatestValidTokens(allCredentials) {
17
30
  }
18
31
  return null;
19
32
  }
33
+ function storedCredentialToTokens(credential) {
34
+ if (!credential) return null;
35
+ if (typeof credential.workspaceId !== "string" || credential.workspaceId.length === 0 || typeof credential.token !== "string" || credential.token.length === 0 || typeof credential.refreshToken !== "string" || credential.refreshToken.length === 0) return null;
36
+ return {
37
+ workspaceId: credential.workspaceId,
38
+ accessToken: credential.token,
39
+ refreshToken: credential.refreshToken
40
+ };
41
+ }
42
+ function findTokensForWorkspace(allCredentials, workspaceId) {
43
+ return storedCredentialToTokens(allCredentials.find((credential) => credential?.workspaceId === workspaceId)) ?? null;
44
+ }
20
45
  function tokensEqual(a, b) {
21
46
  return a?.workspaceId === b?.workspaceId && a?.accessToken === b?.accessToken && a?.refreshToken === b?.refreshToken;
22
47
  }
23
- function sleep(ms) {
24
- return new Promise((resolve) => setTimeout(resolve, ms));
48
+ function sleep(ms, signal) {
49
+ signal?.throwIfAborted();
50
+ return new Promise((resolve, reject) => {
51
+ const onAbort = () => {
52
+ clearTimeout(timeout);
53
+ reject(signal?.reason);
54
+ };
55
+ const timeout = setTimeout(() => {
56
+ signal?.removeEventListener("abort", onAbort);
57
+ resolve();
58
+ }, ms);
59
+ signal?.addEventListener("abort", onAbort, { once: true });
60
+ });
25
61
  }
26
62
  var FileTokenStorage = class {
27
63
  credentialsStore;
64
+ authFilePath;
65
+ contextFilePath;
28
66
  lockFilePath;
29
- constructor(env = process.env) {
67
+ constructor(env = process.env, signal, options = {}) {
68
+ this.signal = signal;
69
+ this.options = options;
30
70
  const authFilePath = getAuthFilePath(env);
71
+ this.authFilePath = authFilePath;
72
+ this.contextFilePath = getAuthContextFilePath(authFilePath);
31
73
  this.credentialsStore = new CredentialsStore(authFilePath);
32
74
  this.lockFilePath = `${authFilePath}.lock`;
33
75
  }
34
76
  async getTokens() {
77
+ this.signal?.throwIfAborted();
35
78
  try {
36
- return findLatestValidTokens(await this.credentialsStore.getCredentials());
37
- } catch {
79
+ const credentials = await this.readCredentialsFromDisk();
80
+ const context = await this.readAuthContext();
81
+ if (context.state.activeWorkspaceId) return findTokensForWorkspace(credentials, context.state.activeWorkspaceId);
82
+ const latest = findLatestValidTokens(credentials);
83
+ if (!latest) return null;
84
+ if (!context.exists) {
85
+ await this.setActiveWorkspaceId(latest.workspaceId);
86
+ return latest;
87
+ }
88
+ return null;
89
+ } catch (_error) {
90
+ if (this.signal?.aborted) throw this.signal.reason;
38
91
  return null;
39
92
  }
40
93
  }
41
94
  async setTokens(tokens) {
95
+ if (this.options.lockSetTokens === false) {
96
+ await this.setTokensUnlocked(tokens);
97
+ return;
98
+ }
99
+ await this.withRefreshLock(() => this.setTokensUnlocked(tokens));
100
+ }
101
+ async setTokensUnlocked(tokens) {
102
+ this.signal?.throwIfAborted();
42
103
  await this.credentialsStore.storeCredentials({
43
104
  workspaceId: tokens.workspaceId,
44
105
  token: tokens.accessToken,
45
106
  refreshToken: tokens.refreshToken
46
107
  });
108
+ this.signal?.throwIfAborted();
109
+ await this.rememberWorkspaceUnlocked(tokens.workspaceId, {
110
+ id: tokens.workspaceId,
111
+ name: tokens.workspaceId
112
+ });
113
+ await this.maybeActivateWorkspaceId(tokens.workspaceId);
47
114
  }
48
115
  async clearTokens() {
49
- const tokens = findLatestValidTokens(await this.credentialsStore.getCredentials());
50
- if (!tokens) return;
51
- await this.credentialsStore.deleteCredentials(tokens.workspaceId);
116
+ await this.withRefreshLock(() => this.clearTokensUnlocked());
117
+ }
118
+ async clearTokensUnlocked() {
119
+ this.signal?.throwIfAborted();
120
+ await fs.mkdir(path.dirname(this.authFilePath), { recursive: true });
121
+ this.signal?.throwIfAborted();
122
+ await fs.writeFile(this.authFilePath, `${JSON.stringify({ tokens: [] }, null, 2)}\n`, {
123
+ encoding: "utf8",
124
+ signal: this.signal
125
+ });
126
+ await this.clearAuthContext();
127
+ await this.credentialsStore.reloadCredentialsFromDisk();
128
+ this.signal?.throwIfAborted();
52
129
  }
53
130
  async clearTokensIfCurrent(tokens) {
131
+ this.signal?.throwIfAborted();
54
132
  if (!tokensEqual(await this.getTokens(), tokens)) return;
55
- await this.clearTokens();
133
+ await this.credentialsStore.deleteCredentials(tokens.workspaceId);
134
+ await this.removeRememberedWorkspace(tokens.workspaceId, { preserveActivePointer: true });
135
+ this.signal?.throwIfAborted();
136
+ }
137
+ async listWorkspaces() {
138
+ this.signal?.throwIfAborted();
139
+ const credentials = await this.readCredentialsFromDisk();
140
+ const context = await this.ensureMigratedAuthContext(credentials);
141
+ return credentials.map((credential) => storedCredentialToTokens(credential)).filter((tokens) => tokens !== null).map((tokens) => {
142
+ const cached = context.state.workspaces[tokens.workspaceId];
143
+ const id = typeof cached?.id === "string" && cached.id.trim().length > 0 ? cached.id.trim() : tokens.workspaceId;
144
+ const name = typeof cached?.name === "string" && cached.name.trim().length > 0 ? workspaceDisplayName(cached.name.trim(), tokens.workspaceId) : UNKNOWN_WORKSPACE_NAME;
145
+ const lastSeenAt = typeof cached?.lastSeenAt === "string" && cached.lastSeenAt.trim().length > 0 ? cached.lastSeenAt.trim() : null;
146
+ return {
147
+ id,
148
+ name,
149
+ credentialWorkspaceId: tokens.workspaceId,
150
+ active: context.state.activeWorkspaceId === tokens.workspaceId,
151
+ lastSeenAt
152
+ };
153
+ });
154
+ }
155
+ async listWorkspaceTokens() {
156
+ this.signal?.throwIfAborted();
157
+ return (await this.readCredentialsFromDisk()).map((credential) => storedCredentialToTokens(credential)).filter((tokens) => tokens !== null);
158
+ }
159
+ async useWorkspace(workspaceRef) {
160
+ return this.withRefreshLock(() => this.useWorkspaceUnlocked(workspaceRef));
161
+ }
162
+ async useWorkspaceUnlocked(workspaceRef) {
163
+ const ref = workspaceRef.trim();
164
+ if (!ref) throw new WorkspaceSelectionError("missing");
165
+ const workspaces = await this.listWorkspaces();
166
+ const context = await this.readAuthContext();
167
+ const previous = workspaces.find((workspace) => workspace.credentialWorkspaceId === context.state.activeWorkspaceId) ?? null;
168
+ const matches = workspaces.filter((workspace) => workspaceMatchesRef(workspace, ref));
169
+ if (matches.length === 0) throw new WorkspaceSelectionError("not-found", ref);
170
+ if (matches.length > 1) throw new WorkspaceSelectionError("ambiguous", ref, matches);
171
+ const selected = matches[0];
172
+ await this.setActiveWorkspaceId(selected.credentialWorkspaceId);
173
+ return {
174
+ previous,
175
+ selected
176
+ };
177
+ }
178
+ async logoutWorkspace(workspaceRef) {
179
+ return this.withRefreshLock(() => this.logoutWorkspaceUnlocked(workspaceRef));
180
+ }
181
+ async logoutWorkspaceUnlocked(workspaceRef) {
182
+ const ref = workspaceRef.trim();
183
+ if (!ref) throw new WorkspaceSelectionError("missing");
184
+ const workspaces = await this.listWorkspaces();
185
+ const context = await this.readAuthContext();
186
+ const matches = workspaces.filter((workspace) => workspaceMatchesRef(workspace, ref));
187
+ if (matches.length === 0) throw new WorkspaceSelectionError("not-found", ref);
188
+ if (matches.length > 1) throw new WorkspaceSelectionError("ambiguous", ref, matches);
189
+ const workspace = matches[0];
190
+ const wasActive = context.state.activeWorkspaceId === workspace.credentialWorkspaceId;
191
+ await this.credentialsStore.deleteCredentials(workspace.credentialWorkspaceId);
192
+ this.signal?.throwIfAborted();
193
+ const remainingWorkspaces = workspaces.filter((candidate) => candidate.credentialWorkspaceId !== workspace.credentialWorkspaceId);
194
+ if (remainingWorkspaces.length === 0) {
195
+ await this.clearAuthContext();
196
+ return {
197
+ workspace,
198
+ wasActive,
199
+ activeWorkspace: null
200
+ };
201
+ }
202
+ delete context.state.workspaces[workspace.credentialWorkspaceId];
203
+ if (wasActive) context.state.activeWorkspaceId = null;
204
+ await this.writeAuthContext(context.state);
205
+ return {
206
+ workspace,
207
+ wasActive,
208
+ activeWorkspace: context.state.activeWorkspaceId === null ? null : remainingWorkspaces.find((candidate) => candidate.credentialWorkspaceId === context.state.activeWorkspaceId) ?? null
209
+ };
210
+ }
211
+ async rememberWorkspace(credentialWorkspaceId, workspace) {
212
+ await this.withRefreshLock(() => this.rememberWorkspaceUnlocked(credentialWorkspaceId, workspace));
213
+ }
214
+ async rememberWorkspaceUnlocked(credentialWorkspaceId, workspace) {
215
+ const context = await this.readAuthContext();
216
+ context.state.workspaces[credentialWorkspaceId] = {
217
+ id: workspace.id,
218
+ name: workspace.name,
219
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
220
+ };
221
+ await this.writeAuthContext(context.state);
56
222
  }
57
223
  async withRefreshLock(fn) {
58
224
  const lockId = await this.acquireRefreshLock();
@@ -64,36 +230,189 @@ var FileTokenStorage = class {
64
230
  }
65
231
  async acquireRefreshLock() {
66
232
  const lockId = randomUUID();
233
+ const startedAt = Date.now();
234
+ const retryMs = this.options.lockRetryMs ?? REFRESH_LOCK_RETRY_MS;
235
+ const waitTimeoutMs = this.options.lockWaitTimeoutMs ?? REFRESH_LOCK_WAIT_TIMEOUT_MS;
236
+ this.signal?.throwIfAborted();
67
237
  await fs.mkdir(path.dirname(this.lockFilePath), { recursive: true });
68
- while (true) try {
238
+ while (true) {
239
+ this.signal?.throwIfAborted();
240
+ if (await this.tryCreateRefreshLock(lockId)) return lockId;
241
+ if (await this.releaseStaleRefreshLock()) continue;
242
+ this.throwIfRefreshLockWaitTimedOut(startedAt, waitTimeoutMs);
243
+ await sleep(retryMs, this.signal);
244
+ }
245
+ }
246
+ async tryCreateRefreshLock(lockId) {
247
+ let lockFileCreated = false;
248
+ try {
69
249
  const handle = await fs.open(this.lockFilePath, "wx");
250
+ lockFileCreated = true;
70
251
  try {
71
- await handle.writeFile(lockId, "utf8");
252
+ this.signal?.throwIfAborted();
253
+ await handle.writeFile(lockId, { encoding: "utf8" });
254
+ this.signal?.throwIfAborted();
72
255
  } finally {
73
256
  await handle.close();
74
257
  }
75
- return lockId;
258
+ return true;
76
259
  } catch (error) {
77
- if (error.code !== "EEXIST") throw error;
78
- const staleLockId = await this.getStaleRefreshLockId();
79
- if (staleLockId) {
80
- await this.releaseRefreshLock(staleLockId);
81
- continue;
82
- }
83
- await sleep(100);
260
+ if (lockFileCreated) await fs.unlink(this.lockFilePath).catch(() => void 0);
261
+ if (error.code === "EEXIST") return false;
262
+ throw error;
84
263
  }
85
264
  }
265
+ async releaseStaleRefreshLock() {
266
+ const staleLockId = await this.getStaleRefreshLockId();
267
+ if (!staleLockId) return false;
268
+ await this.releaseRefreshLock(staleLockId);
269
+ return true;
270
+ }
271
+ throwIfRefreshLockWaitTimedOut(startedAt, waitTimeoutMs) {
272
+ if (Date.now() - startedAt < waitTimeoutMs) return;
273
+ throw new RefreshLockTimeoutError(this.lockFilePath, waitTimeoutMs);
274
+ }
86
275
  async getStaleRefreshLockId() {
87
- const lockId = await fs.readFile(this.lockFilePath, "utf8").catch(() => null);
276
+ this.signal?.throwIfAborted();
277
+ const lockId = await fs.readFile(this.lockFilePath, {
278
+ encoding: "utf8",
279
+ signal: this.signal
280
+ }).catch((error) => {
281
+ if (this.signal?.aborted) throw error;
282
+ return null;
283
+ });
88
284
  if (lockId === null) return null;
285
+ this.signal?.throwIfAborted();
89
286
  const stats = await fs.stat(this.lockFilePath).catch(() => null);
287
+ this.signal?.throwIfAborted();
90
288
  if (!stats) return null;
91
- return Date.now() - stats.mtimeMs > 3e4 ? lockId : null;
289
+ const staleMs = this.options.lockStaleMs ?? REFRESH_LOCK_STALE_MS;
290
+ return Date.now() - stats.mtimeMs > staleMs ? lockId : null;
92
291
  }
93
292
  async releaseRefreshLock(lockId) {
94
- if (await fs.readFile(this.lockFilePath, "utf8").catch(() => null) !== lockId) return;
293
+ if (await fs.readFile(this.lockFilePath, { encoding: "utf8" }).catch(() => null) !== lockId) return;
95
294
  await fs.unlink(this.lockFilePath).catch(() => {});
96
295
  }
296
+ async readCredentialsFromDisk() {
297
+ this.signal?.throwIfAborted();
298
+ await this.credentialsStore.reloadCredentialsFromDisk();
299
+ this.signal?.throwIfAborted();
300
+ return await this.credentialsStore.getCredentials();
301
+ }
302
+ async ensureMigratedAuthContext(credentials) {
303
+ const context = await this.readAuthContext();
304
+ if (context.state.activeWorkspaceId || context.exists) return context;
305
+ const latest = findLatestValidTokens(credentials);
306
+ if (!latest) return context;
307
+ context.state.activeWorkspaceId = latest.workspaceId;
308
+ context.state.workspaces[latest.workspaceId] ??= {
309
+ id: latest.workspaceId,
310
+ name: latest.workspaceId,
311
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
312
+ };
313
+ await this.writeAuthContext(context.state);
314
+ return {
315
+ exists: true,
316
+ state: context.state
317
+ };
318
+ }
319
+ async setActiveWorkspaceId(workspaceId) {
320
+ const context = await this.readAuthContext();
321
+ context.state.activeWorkspaceId = workspaceId;
322
+ context.state.workspaces[workspaceId] ??= {
323
+ id: workspaceId,
324
+ name: workspaceId,
325
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
326
+ };
327
+ await this.writeAuthContext(context.state);
328
+ }
329
+ async maybeActivateWorkspaceId(workspaceId) {
330
+ const context = await this.readAuthContext();
331
+ if (this.options.activateOnSetTokens === false && context.exists && context.state.activeWorkspaceId && context.state.activeWorkspaceId !== workspaceId) return;
332
+ context.state.activeWorkspaceId = workspaceId;
333
+ context.state.workspaces[workspaceId] ??= {
334
+ id: workspaceId,
335
+ name: workspaceId,
336
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
337
+ };
338
+ await this.writeAuthContext(context.state);
339
+ }
340
+ async removeRememberedWorkspace(workspaceId, options) {
341
+ const context = await this.readAuthContext();
342
+ delete context.state.workspaces[workspaceId];
343
+ if (!options.preserveActivePointer && context.state.activeWorkspaceId === workspaceId) context.state.activeWorkspaceId = null;
344
+ await this.writeAuthContext(context.state);
345
+ }
346
+ async readAuthContext() {
347
+ this.signal?.throwIfAborted();
348
+ const raw = await fs.readFile(this.contextFilePath, {
349
+ encoding: "utf8",
350
+ signal: this.signal
351
+ }).catch((error) => {
352
+ if (this.signal?.aborted) throw error;
353
+ if (error.code === "ENOENT") return null;
354
+ throw error;
355
+ });
356
+ if (raw === null) return {
357
+ exists: false,
358
+ state: structuredClone(EMPTY_AUTH_CONTEXT)
359
+ };
360
+ try {
361
+ const parsed = JSON.parse(raw);
362
+ return {
363
+ exists: true,
364
+ state: {
365
+ activeWorkspaceId: typeof parsed.activeWorkspaceId === "string" && parsed.activeWorkspaceId.trim().length > 0 ? parsed.activeWorkspaceId.trim() : null,
366
+ workspaces: parsed.workspaces && typeof parsed.workspaces === "object" && !Array.isArray(parsed.workspaces) ? parsed.workspaces : {}
367
+ }
368
+ };
369
+ } catch {
370
+ return {
371
+ exists: false,
372
+ state: structuredClone(EMPTY_AUTH_CONTEXT)
373
+ };
374
+ }
375
+ }
376
+ async writeAuthContext(state) {
377
+ this.signal?.throwIfAborted();
378
+ await fs.mkdir(path.dirname(this.contextFilePath), { recursive: true });
379
+ this.signal?.throwIfAborted();
380
+ await fs.writeFile(this.contextFilePath, `${JSON.stringify(state, null, 2)}\n`, {
381
+ encoding: "utf8",
382
+ signal: this.signal
383
+ });
384
+ this.signal?.throwIfAborted();
385
+ }
386
+ async clearAuthContext() {
387
+ await fs.unlink(this.contextFilePath).catch((error) => {
388
+ if (error.code === "ENOENT") return;
389
+ throw error;
390
+ });
391
+ }
392
+ };
393
+ var WorkspaceSelectionError = class extends Error {
394
+ constructor(reason, workspaceRef, matches = []) {
395
+ super(reason);
396
+ this.reason = reason;
397
+ this.workspaceRef = workspaceRef;
398
+ this.matches = matches;
399
+ this.name = "WorkspaceSelectionError";
400
+ }
401
+ };
402
+ var RefreshLockTimeoutError = class extends Error {
403
+ constructor(lockFilePath, waitTimeoutMs) {
404
+ super(`Timed out waiting ${waitTimeoutMs}ms for auth refresh lock at ${lockFilePath}`);
405
+ this.name = "RefreshLockTimeoutError";
406
+ }
97
407
  };
408
+ function workspaceMatchesRef(workspace, ref) {
409
+ return workspace.credentialWorkspaceId === ref || workspace.id === ref || stripWorkspacePrefix(workspace.credentialWorkspaceId) === stripWorkspacePrefix(ref) || stripWorkspacePrefix(workspace.id) === stripWorkspacePrefix(ref) || workspace.name.toLowerCase() === ref.toLowerCase();
410
+ }
411
+ function stripWorkspacePrefix(value) {
412
+ return value.startsWith("wksp_") ? value.slice(5) : value;
413
+ }
414
+ function workspaceDisplayName(name, credentialWorkspaceId) {
415
+ return name === credentialWorkspaceId ? UNKNOWN_WORKSPACE_NAME : name;
416
+ }
98
417
  //#endregion
99
- export { FileTokenStorage };
418
+ export { FileTokenStorage, WorkspaceSelectionError };