@prisma/cli 3.0.0-dev.91.1 → 3.0.0-dev.93.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.
@@ -4,6 +4,18 @@ import path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
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
+ function getAuthContextFilePath(authFilePath) {
15
+ const extension = path.extname(authFilePath);
16
+ if (!extension) return `${authFilePath}.context.json`;
17
+ return `${authFilePath.slice(0, -extension.length)}.context${extension}`;
18
+ }
7
19
  function findLatestValidTokens(allCredentials) {
8
20
  for (let i = allCredentials.length - 1; i >= 0; i -= 1) {
9
21
  const credential = allCredentials[i];
@@ -17,6 +29,18 @@ function findLatestValidTokens(allCredentials) {
17
29
  }
18
30
  return null;
19
31
  }
32
+ function storedCredentialToTokens(credential) {
33
+ if (!credential) return null;
34
+ 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;
35
+ return {
36
+ workspaceId: credential.workspaceId,
37
+ accessToken: credential.token,
38
+ refreshToken: credential.refreshToken
39
+ };
40
+ }
41
+ function findTokensForWorkspace(allCredentials, workspaceId) {
42
+ return storedCredentialToTokens(allCredentials.find((credential) => credential?.workspaceId === workspaceId)) ?? null;
43
+ }
20
44
  function tokensEqual(a, b) {
21
45
  return a?.workspaceId === b?.workspaceId && a?.accessToken === b?.accessToken && a?.refreshToken === b?.refreshToken;
22
46
  }
@@ -36,25 +60,44 @@ function sleep(ms, signal) {
36
60
  }
37
61
  var FileTokenStorage = class {
38
62
  credentialsStore;
63
+ authFilePath;
64
+ contextFilePath;
39
65
  lockFilePath;
40
- constructor(env = process.env, signal) {
66
+ constructor(env = process.env, signal, options = {}) {
41
67
  this.signal = signal;
68
+ this.options = options;
42
69
  const authFilePath = getAuthFilePath(env);
70
+ this.authFilePath = authFilePath;
71
+ this.contextFilePath = getAuthContextFilePath(authFilePath);
43
72
  this.credentialsStore = new CredentialsStore(authFilePath);
44
73
  this.lockFilePath = `${authFilePath}.lock`;
45
74
  }
46
75
  async getTokens() {
47
76
  this.signal?.throwIfAborted();
48
77
  try {
49
- const all = await this.credentialsStore.getCredentials();
50
- this.signal?.throwIfAborted();
51
- return findLatestValidTokens(all);
78
+ const credentials = await this.readCredentialsFromDisk();
79
+ const context = await this.readAuthContext();
80
+ if (context.state.activeWorkspaceId) return findTokensForWorkspace(credentials, context.state.activeWorkspaceId);
81
+ const latest = findLatestValidTokens(credentials);
82
+ if (!latest) return null;
83
+ if (!context.exists) {
84
+ await this.setActiveWorkspaceId(latest.workspaceId);
85
+ return latest;
86
+ }
87
+ return null;
52
88
  } catch (_error) {
53
89
  if (this.signal?.aborted) throw this.signal.reason;
54
90
  return null;
55
91
  }
56
92
  }
57
93
  async setTokens(tokens) {
94
+ if (this.options.lockSetTokens === false) {
95
+ await this.setTokensUnlocked(tokens);
96
+ return;
97
+ }
98
+ await this.withRefreshLock(() => this.setTokensUnlocked(tokens));
99
+ }
100
+ async setTokensUnlocked(tokens) {
58
101
  this.signal?.throwIfAborted();
59
102
  await this.credentialsStore.storeCredentials({
60
103
  workspaceId: tokens.workspaceId,
@@ -62,20 +105,115 @@ var FileTokenStorage = class {
62
105
  refreshToken: tokens.refreshToken
63
106
  });
64
107
  this.signal?.throwIfAborted();
108
+ await this.rememberWorkspaceUnlocked(tokens.workspaceId, {
109
+ id: tokens.workspaceId,
110
+ name: tokens.workspaceId
111
+ });
112
+ await this.maybeActivateWorkspaceId(tokens.workspaceId);
65
113
  }
66
114
  async clearTokens() {
115
+ await this.withRefreshLock(() => this.clearTokensUnlocked());
116
+ }
117
+ async clearTokensUnlocked() {
67
118
  this.signal?.throwIfAborted();
68
- const all = await this.credentialsStore.getCredentials();
119
+ await fs.mkdir(path.dirname(this.authFilePath), { recursive: true });
69
120
  this.signal?.throwIfAborted();
70
- const tokens = findLatestValidTokens(all);
71
- if (!tokens) return;
72
- await this.credentialsStore.deleteCredentials(tokens.workspaceId);
121
+ await fs.writeFile(this.authFilePath, `${JSON.stringify({ tokens: [] }, null, 2)}\n`, {
122
+ encoding: "utf8",
123
+ signal: this.signal
124
+ });
125
+ await this.clearAuthContext();
126
+ await this.credentialsStore.reloadCredentialsFromDisk();
73
127
  this.signal?.throwIfAborted();
74
128
  }
75
129
  async clearTokensIfCurrent(tokens) {
76
130
  this.signal?.throwIfAborted();
77
131
  if (!tokensEqual(await this.getTokens(), tokens)) return;
78
- await this.clearTokens();
132
+ await this.credentialsStore.deleteCredentials(tokens.workspaceId);
133
+ await this.removeRememberedWorkspace(tokens.workspaceId, { preserveActivePointer: true });
134
+ this.signal?.throwIfAborted();
135
+ }
136
+ async listWorkspaces() {
137
+ this.signal?.throwIfAborted();
138
+ const credentials = await this.readCredentialsFromDisk();
139
+ const context = await this.ensureMigratedAuthContext(credentials);
140
+ return credentials.map((credential) => storedCredentialToTokens(credential)).filter((tokens) => tokens !== null).map((tokens) => {
141
+ const cached = context.state.workspaces[tokens.workspaceId];
142
+ const id = typeof cached?.id === "string" && cached.id.trim().length > 0 ? cached.id.trim() : tokens.workspaceId;
143
+ const name = typeof cached?.name === "string" && cached.name.trim().length > 0 ? cached.name.trim() : id;
144
+ const lastSeenAt = typeof cached?.lastSeenAt === "string" && cached.lastSeenAt.trim().length > 0 ? cached.lastSeenAt.trim() : null;
145
+ return {
146
+ id,
147
+ name,
148
+ credentialWorkspaceId: tokens.workspaceId,
149
+ active: context.state.activeWorkspaceId === tokens.workspaceId,
150
+ lastSeenAt
151
+ };
152
+ });
153
+ }
154
+ async useWorkspace(workspaceRef) {
155
+ return this.withRefreshLock(() => this.useWorkspaceUnlocked(workspaceRef));
156
+ }
157
+ async useWorkspaceUnlocked(workspaceRef) {
158
+ const ref = workspaceRef.trim();
159
+ if (!ref) throw new WorkspaceSelectionError("missing");
160
+ const workspaces = await this.listWorkspaces();
161
+ const context = await this.readAuthContext();
162
+ const previous = workspaces.find((workspace) => workspace.credentialWorkspaceId === context.state.activeWorkspaceId) ?? null;
163
+ const matches = workspaces.filter((workspace) => workspaceMatchesRef(workspace, ref));
164
+ if (matches.length === 0) throw new WorkspaceSelectionError("not-found", ref);
165
+ if (matches.length > 1) throw new WorkspaceSelectionError("ambiguous", ref, matches);
166
+ const selected = matches[0];
167
+ await this.setActiveWorkspaceId(selected.credentialWorkspaceId);
168
+ return {
169
+ previous,
170
+ selected
171
+ };
172
+ }
173
+ async logoutWorkspace(workspaceRef) {
174
+ return this.withRefreshLock(() => this.logoutWorkspaceUnlocked(workspaceRef));
175
+ }
176
+ async logoutWorkspaceUnlocked(workspaceRef) {
177
+ const ref = workspaceRef.trim();
178
+ if (!ref) throw new WorkspaceSelectionError("missing");
179
+ const workspaces = await this.listWorkspaces();
180
+ const context = await this.readAuthContext();
181
+ const matches = workspaces.filter((workspace) => workspaceMatchesRef(workspace, ref));
182
+ if (matches.length === 0) throw new WorkspaceSelectionError("not-found", ref);
183
+ if (matches.length > 1) throw new WorkspaceSelectionError("ambiguous", ref, matches);
184
+ const workspace = matches[0];
185
+ const wasActive = context.state.activeWorkspaceId === workspace.credentialWorkspaceId;
186
+ await this.credentialsStore.deleteCredentials(workspace.credentialWorkspaceId);
187
+ this.signal?.throwIfAborted();
188
+ const remainingWorkspaces = workspaces.filter((candidate) => candidate.credentialWorkspaceId !== workspace.credentialWorkspaceId);
189
+ if (remainingWorkspaces.length === 0) {
190
+ await this.clearAuthContext();
191
+ return {
192
+ workspace,
193
+ wasActive,
194
+ activeWorkspace: null
195
+ };
196
+ }
197
+ delete context.state.workspaces[workspace.credentialWorkspaceId];
198
+ if (wasActive) context.state.activeWorkspaceId = null;
199
+ await this.writeAuthContext(context.state);
200
+ return {
201
+ workspace,
202
+ wasActive,
203
+ activeWorkspace: context.state.activeWorkspaceId === null ? null : remainingWorkspaces.find((candidate) => candidate.credentialWorkspaceId === context.state.activeWorkspaceId) ?? null
204
+ };
205
+ }
206
+ async rememberWorkspace(credentialWorkspaceId, workspace) {
207
+ await this.withRefreshLock(() => this.rememberWorkspaceUnlocked(credentialWorkspaceId, workspace));
208
+ }
209
+ async rememberWorkspaceUnlocked(credentialWorkspaceId, workspace) {
210
+ const context = await this.readAuthContext();
211
+ context.state.workspaces[credentialWorkspaceId] = {
212
+ id: workspace.id,
213
+ name: workspace.name,
214
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
215
+ };
216
+ await this.writeAuthContext(context.state);
79
217
  }
80
218
  async withRefreshLock(fn) {
81
219
  const lockId = await this.acquireRefreshLock();
@@ -87,34 +225,48 @@ var FileTokenStorage = class {
87
225
  }
88
226
  async acquireRefreshLock() {
89
227
  const lockId = randomUUID();
228
+ const startedAt = Date.now();
229
+ const retryMs = this.options.lockRetryMs ?? REFRESH_LOCK_RETRY_MS;
230
+ const waitTimeoutMs = this.options.lockWaitTimeoutMs ?? REFRESH_LOCK_WAIT_TIMEOUT_MS;
90
231
  this.signal?.throwIfAborted();
91
232
  await fs.mkdir(path.dirname(this.lockFilePath), { recursive: true });
92
233
  while (true) {
93
234
  this.signal?.throwIfAborted();
94
- let lockFileCreated = false;
235
+ if (await this.tryCreateRefreshLock(lockId)) return lockId;
236
+ if (await this.releaseStaleRefreshLock()) continue;
237
+ this.throwIfRefreshLockWaitTimedOut(startedAt, waitTimeoutMs);
238
+ await sleep(retryMs, this.signal);
239
+ }
240
+ }
241
+ async tryCreateRefreshLock(lockId) {
242
+ let lockFileCreated = false;
243
+ try {
244
+ const handle = await fs.open(this.lockFilePath, "wx");
245
+ lockFileCreated = true;
95
246
  try {
96
- const handle = await fs.open(this.lockFilePath, "wx");
97
- lockFileCreated = true;
98
- try {
99
- this.signal?.throwIfAborted();
100
- await handle.writeFile(lockId, { encoding: "utf8" });
101
- this.signal?.throwIfAborted();
102
- } finally {
103
- await handle.close();
104
- }
105
- return lockId;
106
- } catch (error) {
107
- if (lockFileCreated) await fs.unlink(this.lockFilePath).catch(() => void 0);
108
- if (error.code !== "EEXIST") throw error;
109
- const staleLockId = await this.getStaleRefreshLockId();
110
- if (staleLockId) {
111
- await this.releaseRefreshLock(staleLockId);
112
- continue;
113
- }
114
- await sleep(100, this.signal);
247
+ this.signal?.throwIfAborted();
248
+ await handle.writeFile(lockId, { encoding: "utf8" });
249
+ this.signal?.throwIfAborted();
250
+ } finally {
251
+ await handle.close();
115
252
  }
253
+ return true;
254
+ } catch (error) {
255
+ if (lockFileCreated) await fs.unlink(this.lockFilePath).catch(() => void 0);
256
+ if (error.code === "EEXIST") return false;
257
+ throw error;
116
258
  }
117
259
  }
260
+ async releaseStaleRefreshLock() {
261
+ const staleLockId = await this.getStaleRefreshLockId();
262
+ if (!staleLockId) return false;
263
+ await this.releaseRefreshLock(staleLockId);
264
+ return true;
265
+ }
266
+ throwIfRefreshLockWaitTimedOut(startedAt, waitTimeoutMs) {
267
+ if (Date.now() - startedAt < waitTimeoutMs) return;
268
+ throw new RefreshLockTimeoutError(this.lockFilePath, waitTimeoutMs);
269
+ }
118
270
  async getStaleRefreshLockId() {
119
271
  this.signal?.throwIfAborted();
120
272
  const lockId = await fs.readFile(this.lockFilePath, {
@@ -129,12 +281,130 @@ var FileTokenStorage = class {
129
281
  const stats = await fs.stat(this.lockFilePath).catch(() => null);
130
282
  this.signal?.throwIfAborted();
131
283
  if (!stats) return null;
132
- return Date.now() - stats.mtimeMs > 3e4 ? lockId : null;
284
+ const staleMs = this.options.lockStaleMs ?? REFRESH_LOCK_STALE_MS;
285
+ return Date.now() - stats.mtimeMs > staleMs ? lockId : null;
133
286
  }
134
287
  async releaseRefreshLock(lockId) {
135
288
  if (await fs.readFile(this.lockFilePath, { encoding: "utf8" }).catch(() => null) !== lockId) return;
136
289
  await fs.unlink(this.lockFilePath).catch(() => {});
137
290
  }
291
+ async readCredentialsFromDisk() {
292
+ this.signal?.throwIfAborted();
293
+ await this.credentialsStore.reloadCredentialsFromDisk();
294
+ this.signal?.throwIfAborted();
295
+ return await this.credentialsStore.getCredentials();
296
+ }
297
+ async ensureMigratedAuthContext(credentials) {
298
+ const context = await this.readAuthContext();
299
+ if (context.state.activeWorkspaceId || context.exists) return context;
300
+ const latest = findLatestValidTokens(credentials);
301
+ if (!latest) return context;
302
+ context.state.activeWorkspaceId = latest.workspaceId;
303
+ context.state.workspaces[latest.workspaceId] ??= {
304
+ id: latest.workspaceId,
305
+ name: latest.workspaceId,
306
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
307
+ };
308
+ await this.writeAuthContext(context.state);
309
+ return {
310
+ exists: true,
311
+ state: context.state
312
+ };
313
+ }
314
+ async setActiveWorkspaceId(workspaceId) {
315
+ const context = await this.readAuthContext();
316
+ context.state.activeWorkspaceId = workspaceId;
317
+ context.state.workspaces[workspaceId] ??= {
318
+ id: workspaceId,
319
+ name: workspaceId,
320
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
321
+ };
322
+ await this.writeAuthContext(context.state);
323
+ }
324
+ async maybeActivateWorkspaceId(workspaceId) {
325
+ const context = await this.readAuthContext();
326
+ if (this.options.activateOnSetTokens === false && context.exists && context.state.activeWorkspaceId && context.state.activeWorkspaceId !== workspaceId) return;
327
+ context.state.activeWorkspaceId = workspaceId;
328
+ context.state.workspaces[workspaceId] ??= {
329
+ id: workspaceId,
330
+ name: workspaceId,
331
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
332
+ };
333
+ await this.writeAuthContext(context.state);
334
+ }
335
+ async removeRememberedWorkspace(workspaceId, options) {
336
+ const context = await this.readAuthContext();
337
+ delete context.state.workspaces[workspaceId];
338
+ if (!options.preserveActivePointer && context.state.activeWorkspaceId === workspaceId) context.state.activeWorkspaceId = null;
339
+ await this.writeAuthContext(context.state);
340
+ }
341
+ async readAuthContext() {
342
+ this.signal?.throwIfAborted();
343
+ const raw = await fs.readFile(this.contextFilePath, {
344
+ encoding: "utf8",
345
+ signal: this.signal
346
+ }).catch((error) => {
347
+ if (this.signal?.aborted) throw error;
348
+ if (error.code === "ENOENT") return null;
349
+ throw error;
350
+ });
351
+ if (raw === null) return {
352
+ exists: false,
353
+ state: structuredClone(EMPTY_AUTH_CONTEXT)
354
+ };
355
+ try {
356
+ const parsed = JSON.parse(raw);
357
+ return {
358
+ exists: true,
359
+ state: {
360
+ activeWorkspaceId: typeof parsed.activeWorkspaceId === "string" && parsed.activeWorkspaceId.trim().length > 0 ? parsed.activeWorkspaceId.trim() : null,
361
+ workspaces: parsed.workspaces && typeof parsed.workspaces === "object" && !Array.isArray(parsed.workspaces) ? parsed.workspaces : {}
362
+ }
363
+ };
364
+ } catch {
365
+ return {
366
+ exists: false,
367
+ state: structuredClone(EMPTY_AUTH_CONTEXT)
368
+ };
369
+ }
370
+ }
371
+ async writeAuthContext(state) {
372
+ this.signal?.throwIfAborted();
373
+ await fs.mkdir(path.dirname(this.contextFilePath), { recursive: true });
374
+ this.signal?.throwIfAborted();
375
+ await fs.writeFile(this.contextFilePath, `${JSON.stringify(state, null, 2)}\n`, {
376
+ encoding: "utf8",
377
+ signal: this.signal
378
+ });
379
+ this.signal?.throwIfAborted();
380
+ }
381
+ async clearAuthContext() {
382
+ await fs.unlink(this.contextFilePath).catch((error) => {
383
+ if (error.code === "ENOENT") return;
384
+ throw error;
385
+ });
386
+ }
387
+ };
388
+ var WorkspaceSelectionError = class extends Error {
389
+ constructor(reason, workspaceRef, matches = []) {
390
+ super(reason);
391
+ this.reason = reason;
392
+ this.workspaceRef = workspaceRef;
393
+ this.matches = matches;
394
+ this.name = "WorkspaceSelectionError";
395
+ }
138
396
  };
397
+ var RefreshLockTimeoutError = class extends Error {
398
+ constructor(lockFilePath, waitTimeoutMs) {
399
+ super(`Timed out waiting ${waitTimeoutMs}ms for auth refresh lock at ${lockFilePath}`);
400
+ this.name = "RefreshLockTimeoutError";
401
+ }
402
+ };
403
+ function workspaceMatchesRef(workspace, ref) {
404
+ return workspace.credentialWorkspaceId === ref || workspace.id === ref || stripWorkspacePrefix(workspace.credentialWorkspaceId) === stripWorkspacePrefix(ref) || stripWorkspacePrefix(workspace.id) === stripWorkspacePrefix(ref) || workspace.name.toLowerCase() === ref.toLowerCase();
405
+ }
406
+ function stripWorkspacePrefix(value) {
407
+ return value.startsWith("wksp_") ? value.slice(5) : value;
408
+ }
139
409
  //#endregion
140
- export { FileTokenStorage };
410
+ export { FileTokenStorage, WorkspaceSelectionError };
@@ -1,9 +1,9 @@
1
1
  import { attachCommandDescriptor } from "../../shell/command-meta.js";
2
2
  import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags.js";
3
3
  import { configureRuntimeCommand } from "../../shell/runtime.js";
4
- import { runAuthLogin, runAuthLogout, runAuthWhoAmI } from "../../controllers/auth.js";
4
+ import { runAuthLogin, runAuthLogout, runAuthWhoAmI, runAuthWorkspaceList, runAuthWorkspaceLogout, runAuthWorkspaceSelect, runAuthWorkspaceUse } from "../../controllers/auth.js";
5
5
  import { runCommand } from "../../shell/command-runner.js";
6
- import { renderAuthSuccess } from "../../presenters/auth.js";
6
+ import { renderAuthSuccess, renderAuthWorkspaceList, renderAuthWorkspaceLogout, renderAuthWorkspaceUse, serializeAuthWorkspaceList, serializeAuthWorkspaceLogout, serializeAuthWorkspaceUse } from "../../presenters/auth.js";
7
7
  import { Command, Option } from "commander";
8
8
  //#region src/commands/auth/index.ts
9
9
  function createAuthCommand(runtime) {
@@ -12,6 +12,7 @@ function createAuthCommand(runtime) {
12
12
  auth.addCommand(createAuthLoginCommand(runtime));
13
13
  auth.addCommand(createAuthLogoutCommand(runtime));
14
14
  auth.addCommand(createAuthWhoAmICommand(runtime));
15
+ auth.addCommand(createAuthWorkspaceCommand(runtime));
15
16
  return auth;
16
17
  }
17
18
  function createAuthLoginCommand(runtime) {
@@ -25,8 +26,17 @@ function createAuthLoginCommand(runtime) {
25
26
  }
26
27
  function createAuthLogoutCommand(runtime) {
27
28
  const command = attachCommandDescriptor(configureRuntimeCommand(new Command("logout"), runtime), "auth.logout");
29
+ command.addOption(new Option("--workspace <id-or-name>", "Remove one stored OAuth workspace session"));
28
30
  addGlobalFlags(command);
29
31
  command.action(async (options) => {
32
+ const logoutOptions = options;
33
+ if (logoutOptions.workspace?.trim()) {
34
+ await runCommand(runtime, "auth.workspace.logout", options, (context) => runAuthWorkspaceLogout(context, logoutOptions.workspace), {
35
+ renderHuman: (context, descriptor, result) => renderAuthWorkspaceLogout(context, descriptor, result),
36
+ renderJson: (result) => serializeAuthWorkspaceLogout(result)
37
+ });
38
+ return;
39
+ }
30
40
  await runCommand(runtime, "auth.logout", options, (context) => runAuthLogout(context), { renderHuman: (context, descriptor, result) => renderAuthSuccess(context, descriptor, "auth.logout", result) });
31
41
  });
32
42
  return command;
@@ -39,5 +49,60 @@ function createAuthWhoAmICommand(runtime) {
39
49
  });
40
50
  return command;
41
51
  }
52
+ function createAuthWorkspaceCommand(runtime) {
53
+ const workspace = attachCommandDescriptor(configureRuntimeCommand(new Command("workspace"), runtime), "auth.workspace");
54
+ addCompactGlobalFlags(workspace);
55
+ workspace.addCommand(createAuthWorkspaceListCommand(runtime));
56
+ workspace.addCommand(createAuthWorkspaceUseCommand(runtime));
57
+ workspace.addCommand(createAuthWorkspaceSelectCommand(runtime));
58
+ workspace.addCommand(createAuthWorkspaceLogoutCommand(runtime));
59
+ return workspace;
60
+ }
61
+ function createAuthWorkspaceListCommand(runtime) {
62
+ const command = attachCommandDescriptor(configureRuntimeCommand(new Command("list"), runtime), "auth.workspace.list");
63
+ addGlobalFlags(command);
64
+ command.action(async (options) => {
65
+ await runCommand(runtime, "auth.workspace.list", options, (context) => runAuthWorkspaceList(context), {
66
+ renderHuman: (context, descriptor, result) => renderAuthWorkspaceList(context, descriptor, result),
67
+ renderJson: (result) => serializeAuthWorkspaceList(result)
68
+ });
69
+ });
70
+ return command;
71
+ }
72
+ function createAuthWorkspaceSelectCommand(runtime) {
73
+ const command = attachCommandDescriptor(configureRuntimeCommand(new Command("select"), runtime), "auth.workspace.select");
74
+ addGlobalFlags(command);
75
+ command.action(async (options) => {
76
+ await runCommand(runtime, "auth.workspace.select", options, (context) => runAuthWorkspaceSelect(context), {
77
+ renderHuman: (context, descriptor, result) => renderAuthWorkspaceUse(context, descriptor, result),
78
+ renderJson: (result) => serializeAuthWorkspaceUse(result)
79
+ });
80
+ });
81
+ return command;
82
+ }
83
+ function createAuthWorkspaceLogoutCommand(runtime) {
84
+ const command = attachCommandDescriptor(configureRuntimeCommand(new Command("logout"), runtime), "auth.workspace.logout");
85
+ command.argument("<id-or-name>", "Workspace id or exact name");
86
+ addGlobalFlags(command);
87
+ command.action(async (workspaceRef, options) => {
88
+ await runCommand(runtime, "auth.workspace.logout", options, (context) => runAuthWorkspaceLogout(context, typeof workspaceRef === "string" ? workspaceRef : void 0), {
89
+ renderHuman: (context, descriptor, result) => renderAuthWorkspaceLogout(context, descriptor, result),
90
+ renderJson: (result) => serializeAuthWorkspaceLogout(result)
91
+ });
92
+ });
93
+ return command;
94
+ }
95
+ function createAuthWorkspaceUseCommand(runtime) {
96
+ const command = attachCommandDescriptor(configureRuntimeCommand(new Command("use"), runtime), "auth.workspace.use");
97
+ command.argument("<id-or-name>", "Workspace id or exact name");
98
+ addGlobalFlags(command);
99
+ command.action(async (workspaceRef, options) => {
100
+ await runCommand(runtime, "auth.workspace.use", options, (context) => runAuthWorkspaceUse(context, typeof workspaceRef === "string" ? workspaceRef : void 0), {
101
+ renderHuman: (context, descriptor, result) => renderAuthWorkspaceUse(context, descriptor, result),
102
+ renderJson: (result) => serializeAuthWorkspaceUse(result)
103
+ });
104
+ });
105
+ return command;
106
+ }
42
107
  //#endregion
43
108
  export { createAuthCommand };
@@ -11,7 +11,7 @@ import { renderCommandHeader } from "../shell/ui.js";
11
11
  import { canPrompt } from "../shell/runtime.js";
12
12
  import { LOCAL_RESOLUTION_PIN_RELATIVE_PATH, readLocalResolutionPin } from "../lib/project/local-pin.js";
13
13
  import { formatCommandArgument } from "../shell/command-arguments.js";
14
- import { buildProjectSetupNextActions, inferTargetName, projectNotFoundError, projectResolutionErrorToCliError, resolveDurablePlatformMapping, resolveProjectTarget, sortProjects } from "../lib/project/resolution.js";
14
+ import { buildProjectSetupNextActions, inferTargetName, localProjectWorkspaceMismatchError, projectNotFoundError, projectResolutionErrorToCliError, resolveDurablePlatformMapping, resolveProjectTarget, sortProjects } from "../lib/project/resolution.js";
15
15
  import { bindProjectToDirectory, projectCreateFailedError, projectDirectoryBindingErrorToCliError, projectSetupNameRequiredError, resolveProjectForSetup, toProjectSummary } from "../lib/project/setup.js";
16
16
  import { maybeSetupBranchDatabase } from "../lib/app/branch-database-deploy.js";
17
17
  import { readBunPackageEntrypoint, readBunPackageJson } from "../lib/app/bun-project.js";
@@ -1644,7 +1644,11 @@ async function resolveDeployProjectContext(context, client, provider, explicitPr
1644
1644
  }
1645
1645
  const localPin = options.localPin;
1646
1646
  if (localPin.kind === "present") {
1647
- if (localPin.pin.workspaceId !== workspace.id) throw localResolutionPinStaleError();
1647
+ if (localPin.pin.workspaceId !== workspace.id) throw localProjectWorkspaceMismatchError({
1648
+ pinnedWorkspaceId: localPin.pin.workspaceId,
1649
+ pinnedProjectId: localPin.pin.projectId,
1650
+ activeWorkspace: workspace
1651
+ });
1648
1652
  const project = projects.find((candidate) => candidate.id === localPin.pin.projectId);
1649
1653
  if (!project) throw localResolutionPinStaleError();
1650
1654
  return withRemoteDeployBranch(provider, {
@@ -1,4 +1,6 @@
1
- import { authRequiredError, usageError } from "../shell/errors.js";
1
+ import { SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client.js";
2
+ import { FileTokenStorage, WorkspaceSelectionError } from "../adapters/token-storage.js";
3
+ import { authRequiredError, usageError, workspaceAmbiguousError, workspaceNotAuthenticatedError, workspaceSwitchUnavailableError } from "../shell/errors.js";
2
4
  import { canPrompt } from "../shell/runtime.js";
3
5
  import { performLogin, performLogout, readAuthState } from "../lib/auth/auth-ops.js";
4
6
  import { createAuthUseCases } from "../use-cases/auth.js";
@@ -30,6 +32,40 @@ async function runAuthWhoAmI(context) {
30
32
  else result = await createAuthUseCases(createCliUseCaseGateways(context)).whoami();
31
33
  return createAuthSuccess("auth.whoami", result, result.authenticated ? [] : ["prisma-cli auth login"]);
32
34
  }
35
+ async function runAuthWorkspaceList(context) {
36
+ const result = isRealMode(context) ? await listRealAuthWorkspaces(context) : await createAuthUseCases(createCliUseCaseGateways(context)).listWorkspaces();
37
+ return {
38
+ command: "auth.workspace.list",
39
+ result,
40
+ warnings: [],
41
+ nextSteps: result.workspaces.length === 0 ? ["prisma-cli auth login"] : []
42
+ };
43
+ }
44
+ async function runAuthWorkspaceUse(context, workspaceRef) {
45
+ if (!workspaceRef?.trim()) throw usageError("Workspace required", "auth workspace use needs a workspace id or cached workspace name.", "Pass a workspace from prisma-cli auth workspace list.", ["prisma-cli auth workspace list"], "auth");
46
+ return {
47
+ command: "auth.workspace.use",
48
+ result: isRealMode(context) ? await useRealAuthWorkspace(context, workspaceRef) : await createAuthUseCases(createCliUseCaseGateways(context)).useWorkspace(workspaceRef),
49
+ warnings: [],
50
+ nextSteps: ["prisma-cli auth whoami", "prisma-cli project list"]
51
+ };
52
+ }
53
+ async function runAuthWorkspaceSelect(context) {
54
+ return {
55
+ ...await runAuthWorkspaceUse(context, await selectWorkspaceSession(context)),
56
+ command: "auth.workspace.select"
57
+ };
58
+ }
59
+ async function runAuthWorkspaceLogout(context, workspaceRef) {
60
+ if (!workspaceRef?.trim()) throw usageError("Workspace required", "auth workspace logout needs a workspace id or cached workspace name.", "Pass a workspace from prisma-cli auth workspace list.", ["prisma-cli auth workspace list"], "auth");
61
+ const result = isRealMode(context) ? await logoutRealAuthWorkspace(context, workspaceRef) : await createAuthUseCases(createCliUseCaseGateways(context)).logoutWorkspace(workspaceRef);
62
+ return {
63
+ command: "auth.workspace.logout",
64
+ result,
65
+ warnings: [],
66
+ nextSteps: result.activeWorkspace ? ["prisma-cli auth workspace list"] : ["prisma-cli auth workspace list", "prisma-cli auth workspace use <id>"]
67
+ };
68
+ }
33
69
  async function requireAuthenticatedAuthState(context) {
34
70
  if (isRealMode(context)) {
35
71
  const current = await readAuthState(context.runtime.env, context.runtime.signal);
@@ -44,6 +80,108 @@ async function requireAuthenticatedAuthState(context) {
44
80
  if (!canPrompt(context)) throw authRequiredError();
45
81
  return loginWithSelectionFlow(context, useCases, {});
46
82
  }
83
+ async function listRealAuthWorkspaces(context) {
84
+ const rawServiceToken = context.runtime.env[SERVICE_TOKEN_ENV_VAR];
85
+ const localWorkspaces = await new FileTokenStorage(context.runtime.env, context.runtime.signal).listWorkspaces();
86
+ if (rawServiceToken !== void 0) {
87
+ const authState = await readAuthState(context.runtime.env, context.runtime.signal);
88
+ return {
89
+ authSource: authState.authenticated ? "service_token" : "none",
90
+ activeWorkspace: authState.workspace,
91
+ workspaces: [...authState.workspace ? [{
92
+ ...authState.workspace,
93
+ credentialWorkspaceId: null,
94
+ active: true,
95
+ source: "service_token",
96
+ switchable: false,
97
+ lastSeenAt: null
98
+ }] : [], ...localWorkspaces.map((workspace) => ({
99
+ ...toAuthWorkspace(workspace),
100
+ credentialWorkspaceId: workspace.credentialWorkspaceId,
101
+ active: false,
102
+ source: "oauth",
103
+ switchable: false,
104
+ lastSeenAt: workspace.lastSeenAt
105
+ }))]
106
+ };
107
+ }
108
+ const active = localWorkspaces.find((workspace) => workspace.active) ?? null;
109
+ return {
110
+ authSource: localWorkspaces.length > 0 ? "oauth" : "none",
111
+ activeWorkspace: active ? toAuthWorkspace(active) : null,
112
+ workspaces: localWorkspaces.map((workspace) => ({
113
+ ...toAuthWorkspace(workspace),
114
+ credentialWorkspaceId: workspace.credentialWorkspaceId,
115
+ active: workspace.active,
116
+ source: "oauth",
117
+ switchable: true,
118
+ lastSeenAt: workspace.lastSeenAt
119
+ }))
120
+ };
121
+ }
122
+ async function useRealAuthWorkspace(context, workspaceRef) {
123
+ if (context.runtime.env["PRISMA_SERVICE_TOKEN"] !== void 0) throw workspaceSwitchUnavailableError();
124
+ const storage = new FileTokenStorage(context.runtime.env, context.runtime.signal);
125
+ try {
126
+ const result = await storage.useWorkspace(workspaceRef);
127
+ return {
128
+ previousWorkspace: result.previous ? toAuthWorkspace(result.previous) : null,
129
+ workspace: toAuthWorkspace(result.selected)
130
+ };
131
+ } catch (error) {
132
+ if (error instanceof WorkspaceSelectionError) {
133
+ if (error.reason === "ambiguous") throw workspaceAmbiguousError(error.workspaceRef ?? workspaceRef, error.matches.map((match) => ({
134
+ id: match.id,
135
+ name: match.name,
136
+ credentialWorkspaceId: match.credentialWorkspaceId
137
+ })));
138
+ throw workspaceNotAuthenticatedError(error.workspaceRef ?? workspaceRef);
139
+ }
140
+ throw error;
141
+ }
142
+ }
143
+ async function logoutRealAuthWorkspace(context, workspaceRef) {
144
+ const storage = new FileTokenStorage(context.runtime.env, context.runtime.signal);
145
+ try {
146
+ const result = await storage.logoutWorkspace(workspaceRef);
147
+ return {
148
+ workspace: toAuthWorkspace(result.workspace),
149
+ wasActive: result.wasActive,
150
+ activeWorkspace: result.activeWorkspace ? toAuthWorkspace(result.activeWorkspace) : null
151
+ };
152
+ } catch (error) {
153
+ if (error instanceof WorkspaceSelectionError) {
154
+ if (error.reason === "ambiguous") throw workspaceAmbiguousError(error.workspaceRef ?? workspaceRef, error.matches.map((match) => ({
155
+ id: match.id,
156
+ name: match.name,
157
+ credentialWorkspaceId: match.credentialWorkspaceId
158
+ })));
159
+ throw workspaceNotAuthenticatedError(error.workspaceRef ?? workspaceRef);
160
+ }
161
+ throw error;
162
+ }
163
+ }
164
+ async function selectWorkspaceSession(context) {
165
+ const realMode = isRealMode(context);
166
+ if (realMode && context.runtime.env["PRISMA_SERVICE_TOKEN"] !== void 0) throw workspaceSwitchUnavailableError();
167
+ const workspaces = (realMode ? await listRealAuthWorkspaces(context) : await createAuthUseCases(createCliUseCaseGateways(context)).listWorkspaces()).workspaces.filter((workspace) => workspace.switchable);
168
+ if (workspaces.length === 0) throw usageError("No authenticated workspaces", "There are no local OAuth workspace sessions to select.", "Run prisma-cli auth login and authorize a workspace.", ["prisma-cli auth login"], "auth");
169
+ if (workspaces.length === 1) return workspaces[0].id;
170
+ if (!canPrompt(context)) throw usageError("Interactive workspace selection unavailable", "auth workspace select needs an interactive terminal when more than one workspace is available.", "Run prisma-cli auth workspace use <id-or-name> with a workspace from prisma-cli auth workspace list.", ["prisma-cli auth workspace list"], "auth");
171
+ return (await createSelectPromptPort(context).select({
172
+ message: "Select a workspace",
173
+ choices: workspaces.map((workspace) => ({
174
+ label: `${workspace.name} (${workspace.id})${workspace.active ? " active" : ""}`,
175
+ value: workspace
176
+ }))
177
+ })).id;
178
+ }
179
+ function toAuthWorkspace(workspace) {
180
+ return {
181
+ id: workspace.id,
182
+ name: workspace.name
183
+ };
184
+ }
47
185
  async function loginWithSelectionFlow(context, useCases, options) {
48
186
  const selection = await resolveLoginSelection(useCases, canPrompt(context) ? createSelectPromptPort(context) : null, options);
49
187
  return useCases.login(selection);
@@ -104,4 +242,4 @@ function createAuthSuccess(command, result, nextSteps) {
104
242
  };
105
243
  }
106
244
  //#endregion
107
- export { requireAuthenticatedAuthState, runAuthLogin, runAuthLogout, runAuthWhoAmI };
245
+ export { requireAuthenticatedAuthState, runAuthLogin, runAuthLogout, runAuthWhoAmI, runAuthWorkspaceList, runAuthWorkspaceLogout, runAuthWorkspaceSelect, runAuthWorkspaceUse };
@@ -26,7 +26,7 @@ function workspaceIdFromClaims(claims) {
26
26
  }
27
27
  async function performLogin(env, signal) {
28
28
  await login({
29
- tokenStorage: new FileTokenStorage(env, signal),
29
+ tokenStorage: new FileTokenStorage(env, signal, { activateOnSetTokens: true }),
30
30
  env,
31
31
  signal
32
32
  });
@@ -38,7 +38,8 @@ async function readAuthState(env, signal) {
38
38
  if (serviceToken.length === 0) throw new Error(`${SERVICE_TOKEN_ENV_VAR} is set but empty. Provide a valid token or unset the variable.`);
39
39
  return readServiceTokenAuthState(serviceToken, env, signal);
40
40
  }
41
- const tokens = await new FileTokenStorage(env, signal).getTokens();
41
+ const tokenStorage = new FileTokenStorage(env, signal);
42
+ const tokens = await tokenStorage.getTokens();
42
43
  if (!tokens) return {
43
44
  authenticated: false,
44
45
  provider: null,
@@ -48,15 +49,20 @@ async function readAuthState(env, signal) {
48
49
  };
49
50
  const client = await requireComputeAuth(env, signal);
50
51
  const currentPrincipal = await readCurrentPrincipalAuthState(client, signal);
51
- if (currentPrincipal) return currentPrincipal;
52
+ if (currentPrincipal) {
53
+ if (currentPrincipal.authenticated && currentPrincipal.workspace) await tokenStorage.rememberWorkspace?.(tokens.workspaceId, currentPrincipal.workspace);
54
+ return currentPrincipal;
55
+ }
52
56
  const claims = decodeJwtPayload(tokens.accessToken);
53
- return buildAuthState({
57
+ const authState = await buildAuthState({
54
58
  workspaceIdFromCredential: tokens.workspaceId,
55
59
  claims,
56
60
  env,
57
61
  client,
58
62
  signal
59
63
  });
64
+ if (authState.authenticated && authState.workspace) await tokenStorage.rememberWorkspace?.(tokens.workspaceId, authState.workspace);
65
+ return authState;
60
66
  }
61
67
  async function readServiceTokenAuthState(token, env, signal) {
62
68
  const client = await requireComputeAuth(env, signal);
@@ -22,7 +22,10 @@ async function requireComputeAuth(env = process.env, signal) {
22
22
  token
23
23
  });
24
24
  }
25
- const tokenStorage = new FileTokenStorage(env, signal);
25
+ const tokenStorage = new FileTokenStorage(env, signal, {
26
+ activateOnSetTokens: false,
27
+ lockSetTokens: false
28
+ });
26
29
  if (!await tokenStorage.getTokens()) return null;
27
30
  return createManagementApiSdk({
28
31
  clientId: CLIENT_ID,
@@ -151,7 +151,7 @@ var LoginState = class {
151
151
  output;
152
152
  constructor(options) {
153
153
  this.options = options;
154
- this.tokenStorage = options.tokenStorage ?? new FileTokenStorage(options.env, options.signal);
154
+ this.tokenStorage = options.tokenStorage ?? new FileTokenStorage(options.env, options.signal, { activateOnSetTokens: true });
155
155
  this.sdk = createManagementApiSdk({
156
156
  clientId: options.clientId ?? "cmm3lndn701oo0uefvxzo0ivw",
157
157
  redirectUri: `http://${options.hostname}:${options.port}/auth/callback`,
@@ -138,13 +138,16 @@ function localStateStaleCliError() {
138
138
  nextSteps: ["prisma-cli project list", "prisma-cli project link <id-or-name>"]
139
139
  });
140
140
  }
141
+ function localProjectWorkspaceMismatchError(options) {
142
+ return projectResolutionErrorToCliError(new LocalProjectWorkspaceMismatchError(options));
143
+ }
141
144
  function localProjectWorkspaceMismatchCliError(options) {
142
145
  return new CliError({
143
146
  code: "LOCAL_PROJECT_WORKSPACE_MISMATCH",
144
147
  domain: "project",
145
148
  summary: "Project link uses another workspace",
146
149
  why: `${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} links this directory to project ${options.pinnedProjectId} in workspace ${options.pinnedWorkspaceId}, but your current CLI session is workspace "${options.activeWorkspace.name}" (${options.activeWorkspace.id}).`,
147
- fix: "Sign in to the linked workspace, or relink this directory to a project in the current workspace.",
150
+ fix: "Switch to the linked workspace, or relink this directory to a project in the current workspace.",
148
151
  meta: {
149
152
  pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH,
150
153
  pinnedWorkspaceId: options.pinnedWorkspaceId,
@@ -154,7 +157,7 @@ function localProjectWorkspaceMismatchCliError(options) {
154
157
  },
155
158
  exitCode: 1,
156
159
  nextSteps: [
157
- "prisma-cli auth login",
160
+ `prisma-cli auth workspace use ${options.pinnedWorkspaceId}`,
158
161
  "prisma-cli project list",
159
162
  "prisma-cli project link <id-or-name>"
160
163
  ]
@@ -380,4 +383,4 @@ function toProjectSummary(project) {
380
383
  };
381
384
  }
382
385
  //#endregion
383
- export { buildProjectSetupNextActions, inferTargetName, inspectProjectBinding, projectAmbiguousError, projectNotFoundError, projectResolutionErrorToCliError, resolveDurablePlatformMapping, resolveProjectTarget, sortProjects };
386
+ export { buildProjectSetupNextActions, inferTargetName, inspectProjectBinding, localProjectWorkspaceMismatchError, projectAmbiguousError, projectNotFoundError, projectResolutionErrorToCliError, resolveDurablePlatformMapping, resolveProjectTarget, sortProjects };
@@ -1,4 +1,7 @@
1
+ import { formatDescriptorLabel } from "../shell/command-meta.js";
2
+ import { padDisplay } from "../shell/ui.js";
1
3
  import { renderMutate, renderShow } from "../output/patterns.js";
4
+ import stringWidth from "string-width";
2
5
  //#region src/presenters/auth.ts
3
6
  function renderAuthSuccess(context, descriptor, command, result) {
4
7
  if (command === "auth.login") {
@@ -62,11 +65,105 @@ function renderAuthSuccess(context, descriptor, command, result) {
62
65
  }]
63
66
  }, context.ui);
64
67
  }
68
+ function renderAuthWorkspaceList(context, descriptor, result) {
69
+ const ui = context.ui;
70
+ const rail = ui.dim("│");
71
+ const lines = [
72
+ `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing authenticated workspaces on this machine.")}`,
73
+ "",
74
+ `${rail} ${ui.accent("auth source:")} ${authSourceLabel(result.authSource)}`
75
+ ];
76
+ const hasMixedSources = new Set(result.workspaces.map((workspace) => workspace.source)).size > 1;
77
+ if (result.workspaces.length === 0) {
78
+ lines.push(`${rail} ${ui.dim("No local OAuth workspaces found.")}`);
79
+ return lines;
80
+ }
81
+ const nameWidth = Math.max(4, ...result.workspaces.map((workspace) => stringWidth(workspace.name)));
82
+ const idWidth = Math.max(2, ...result.workspaces.map((workspace) => stringWidth(workspace.id)));
83
+ const sourceWidth = hasMixedSources ? Math.max(6, ...result.workspaces.map((workspace) => stringWidth(workspaceSourceLabel(workspace.source)))) : 0;
84
+ lines.push(rail);
85
+ lines.push(hasMixedSources ? `${rail} ${ui.accent(padDisplay("name", nameWidth))} ${ui.accent(padDisplay("id", idWidth))} ${ui.accent(padDisplay("source", sourceWidth))} ${ui.accent("status")}` : `${rail} ${ui.accent(padDisplay("name", nameWidth))} ${ui.accent(padDisplay("id", idWidth))} ${ui.accent("status")}`);
86
+ for (const workspace of result.workspaces) {
87
+ const status = workspace.active ? "active" : "";
88
+ const source = workspaceSourceLabel(workspace.source);
89
+ lines.push(hasMixedSources ? `${rail} ${padDisplay(workspace.name, nameWidth)} ${padDisplay(workspace.id, idWidth)} ${padDisplay(source, sourceWidth)} ${status}` : `${rail} ${padDisplay(workspace.name, nameWidth)} ${padDisplay(workspace.id, idWidth)} ${status}`);
90
+ }
91
+ return lines;
92
+ }
93
+ function serializeAuthWorkspaceList(result) {
94
+ return {
95
+ context: {
96
+ authSource: result.authSource,
97
+ activeWorkspaceId: result.activeWorkspace?.id ?? null,
98
+ activeWorkspaceName: result.activeWorkspace?.name ?? null
99
+ },
100
+ items: result.workspaces.map((workspace) => ({
101
+ id: workspace.id,
102
+ name: workspace.name,
103
+ status: workspace.active ? "active" : null,
104
+ source: workspace.source,
105
+ switchable: workspace.switchable,
106
+ credentialWorkspaceId: workspace.credentialWorkspaceId,
107
+ lastSeenAt: workspace.lastSeenAt
108
+ })),
109
+ count: result.workspaces.length
110
+ };
111
+ }
112
+ function renderAuthWorkspaceUse(context, descriptor, result) {
113
+ return renderMutate({
114
+ title: "Switching the local CLI workspace.",
115
+ descriptor,
116
+ context: [...result.previousWorkspace ? [{
117
+ key: "previous",
118
+ value: result.previousWorkspace.name
119
+ }] : [], {
120
+ key: "workspace",
121
+ value: result.workspace.name
122
+ }],
123
+ operationDescription: "Applying workspace selection",
124
+ operationCount: 1,
125
+ details: ["Local OAuth workspace selection updated."]
126
+ }, context.ui);
127
+ }
128
+ function serializeAuthWorkspaceUse(result) {
129
+ return result;
130
+ }
131
+ function renderAuthWorkspaceLogout(context, descriptor, result) {
132
+ return renderMutate({
133
+ title: "Removing a local OAuth workspace session.",
134
+ descriptor,
135
+ context: [{
136
+ key: "workspace",
137
+ value: result.workspace.name
138
+ }, ...result.activeWorkspace ? [{
139
+ key: "active",
140
+ value: result.activeWorkspace.name
141
+ }] : [{
142
+ key: "active",
143
+ value: "none",
144
+ tone: "dim"
145
+ }]],
146
+ operationDescription: "Removing workspace session",
147
+ operationCount: 1,
148
+ details: [result.wasActive ? "Removed active workspace session; no replacement workspace was selected." : "Removed workspace session."]
149
+ }, context.ui);
150
+ }
151
+ function serializeAuthWorkspaceLogout(result) {
152
+ return result;
153
+ }
65
154
  function providerLabel(provider) {
66
155
  if (provider === "github") return "GitHub";
67
156
  if (provider === "google") return "Google";
68
157
  return "";
69
158
  }
159
+ function authSourceLabel(source) {
160
+ if (source === "oauth") return "local OAuth";
161
+ if (source === "service_token") return "PRISMA_SERVICE_TOKEN";
162
+ return "none";
163
+ }
164
+ function workspaceSourceLabel(source) {
165
+ return source === "service_token" ? "service token" : "OAuth";
166
+ }
70
167
  function authUserLabel(result) {
71
168
  return result.user?.email ?? credentialUserLabel(result);
72
169
  }
@@ -83,4 +180,4 @@ function credentialUserLabel(result) {
83
180
  return null;
84
181
  }
85
182
  //#endregion
86
- export { renderAuthSuccess };
183
+ export { renderAuthSuccess, renderAuthWorkspaceList, renderAuthWorkspaceLogout, renderAuthWorkspaceUse, serializeAuthWorkspaceList, serializeAuthWorkspaceLogout, serializeAuthWorkspaceUse };
@@ -50,6 +50,65 @@ const DESCRIPTORS = [
50
50
  description: "Show the authenticated user and accessible workspace",
51
51
  examples: ["prisma-cli auth whoami", "prisma-cli auth whoami --json"]
52
52
  },
53
+ {
54
+ id: "auth.workspace",
55
+ path: [
56
+ "prisma",
57
+ "auth",
58
+ "workspace"
59
+ ],
60
+ description: "Manage local authenticated workspaces",
61
+ examples: [
62
+ "prisma-cli auth workspace list",
63
+ "prisma-cli auth workspace select",
64
+ "prisma-cli auth workspace use wksp_123",
65
+ "prisma-cli auth workspace logout wksp_123"
66
+ ]
67
+ },
68
+ {
69
+ id: "auth.workspace.list",
70
+ path: [
71
+ "prisma",
72
+ "auth",
73
+ "workspace",
74
+ "list"
75
+ ],
76
+ description: "List locally authenticated workspaces",
77
+ examples: ["prisma-cli auth workspace list", "prisma-cli auth workspace list --json"]
78
+ },
79
+ {
80
+ id: "auth.workspace.use",
81
+ path: [
82
+ "prisma",
83
+ "auth",
84
+ "workspace",
85
+ "use"
86
+ ],
87
+ description: "Switch the local CLI workspace",
88
+ examples: ["prisma-cli auth workspace use wksp_123", "prisma-cli auth workspace use \"Acme Inc\""]
89
+ },
90
+ {
91
+ id: "auth.workspace.select",
92
+ path: [
93
+ "prisma",
94
+ "auth",
95
+ "workspace",
96
+ "select"
97
+ ],
98
+ description: "Interactively select the local CLI workspace",
99
+ examples: ["prisma-cli auth workspace select"]
100
+ },
101
+ {
102
+ id: "auth.workspace.logout",
103
+ path: [
104
+ "prisma",
105
+ "auth",
106
+ "workspace",
107
+ "logout"
108
+ ],
109
+ description: "Remove one local OAuth workspace session",
110
+ examples: ["prisma-cli auth workspace logout wksp_123", "prisma-cli auth workspace logout \"Acme Inc\""]
111
+ },
53
112
  {
54
113
  id: "project",
55
114
  path: ["prisma", "project"],
@@ -1,4 +1,4 @@
1
- import { CliError, authRequiredError, commandCanceledError } from "./errors.js";
1
+ import { CliError, authConfigInvalidError, authRequiredError, commandCanceledError } from "./errors.js";
2
2
  import { getCommandDescriptor } from "./command-meta.js";
3
3
  import { resolveGlobalFlags } from "./global-flags.js";
4
4
  import { createCommandContext } from "./runtime.js";
@@ -11,6 +11,7 @@ function toCliError(error, runtime) {
11
11
  if (isCancellationError(error) || runtime.signal.aborted) return commandCanceledError();
12
12
  if (error instanceof CliError) return error;
13
13
  if (error instanceof AuthError) return authRequiredError(["prisma-cli auth login"], { debug: error.message });
14
+ if (error instanceof Error && error.message.startsWith("PRISMA_SERVICE_TOKEN is set but empty")) return authConfigInvalidError(error.message);
14
15
  return null;
15
16
  }
16
17
  function isCancellationError(error) {
@@ -56,6 +56,17 @@ function authRequiredError(nextSteps = ["prisma-cli auth login"], options = {})
56
56
  nextSteps
57
57
  });
58
58
  }
59
+ function authConfigInvalidError(message) {
60
+ return new CliError({
61
+ code: "AUTH_CONFIG_INVALID",
62
+ domain: "auth",
63
+ summary: "Authentication configuration is invalid",
64
+ why: message,
65
+ fix: "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.",
66
+ exitCode: 1,
67
+ nextSteps: ["prisma-cli auth login"]
68
+ });
69
+ }
59
70
  function commandCanceledError() {
60
71
  return new CliError({
61
72
  code: "COMMAND_CANCELED",
@@ -70,6 +81,44 @@ function commandCanceledError() {
70
81
  function workspaceRequiredError() {
71
82
  return usageError("Workspace required", "This command needs an active workspace, but the authenticated session does not have one.", "Run prisma-cli auth login and choose a workspace.", ["prisma-cli auth login"], "auth");
72
83
  }
84
+ function workspaceSwitchUnavailableError() {
85
+ return new CliError({
86
+ code: "WORKSPACE_SWITCH_UNAVAILABLE",
87
+ domain: "auth",
88
+ summary: "Workspace switching is unavailable",
89
+ why: "PRISMA_SERVICE_TOKEN is set, so authenticated commands use that token instead of local OAuth workspaces.",
90
+ fix: "Unset PRISMA_SERVICE_TOKEN to switch between local OAuth workspaces, or use a token for the workspace you want.",
91
+ exitCode: 1,
92
+ nextSteps: ["unset PRISMA_SERVICE_TOKEN", "prisma-cli auth workspace list"]
93
+ });
94
+ }
95
+ function workspaceNotAuthenticatedError(workspaceRef) {
96
+ return new CliError({
97
+ code: "WORKSPACE_NOT_AUTHENTICATED",
98
+ domain: "auth",
99
+ summary: "Workspace is not authenticated",
100
+ why: `No stored OAuth session matched "${workspaceRef}".`,
101
+ fix: "Run prisma-cli auth login and authorize that workspace, then switch to it.",
102
+ meta: { workspaceRef },
103
+ exitCode: 1,
104
+ nextSteps: ["prisma-cli auth workspace list", "prisma-cli auth login"]
105
+ });
106
+ }
107
+ function workspaceAmbiguousError(workspaceRef, matches) {
108
+ return new CliError({
109
+ code: "WORKSPACE_AMBIGUOUS",
110
+ domain: "auth",
111
+ summary: "Workspace name is ambiguous",
112
+ why: `Multiple authenticated workspaces matched "${workspaceRef}".`,
113
+ fix: "Run prisma-cli auth workspace list and switch by workspace id.",
114
+ meta: {
115
+ workspaceRef,
116
+ matches
117
+ },
118
+ exitCode: 2,
119
+ nextSteps: ["prisma-cli auth workspace list"]
120
+ });
121
+ }
73
122
  function featureUnavailableError(summary, why, fix, nextSteps = [], domain = "cli") {
74
123
  return new CliError({
75
124
  code: "FEATURE_UNAVAILABLE",
@@ -82,4 +131,4 @@ function featureUnavailableError(summary, why, fix, nextSteps = [], domain = "cl
82
131
  });
83
132
  }
84
133
  //#endregion
85
- export { CliError, authRequiredError, commandCanceledError, featureUnavailableError, usageError, workspaceRequiredError };
134
+ export { CliError, authConfigInvalidError, authRequiredError, commandCanceledError, featureUnavailableError, usageError, workspaceAmbiguousError, workspaceNotAuthenticatedError, workspaceRequiredError, workspaceSwitchUnavailableError };
@@ -1,4 +1,4 @@
1
- import { usageError } from "../shell/errors.js";
1
+ import { authRequiredError, usageError, workspaceAmbiguousError, workspaceNotAuthenticatedError } from "../shell/errors.js";
2
2
  //#region src/use-cases/auth.ts
3
3
  function createAuthUseCases(dependencies) {
4
4
  return {
@@ -15,6 +15,70 @@ function createAuthUseCases(dependencies) {
15
15
  await dependencies.sessionGateway.clearAuthSession();
16
16
  return resolveCurrentAuthState(dependencies);
17
17
  },
18
+ listWorkspaces: async () => {
19
+ const session = await dependencies.sessionGateway.readAuthSession();
20
+ if (!session) return {
21
+ authSource: "none",
22
+ activeWorkspace: null,
23
+ workspaces: []
24
+ };
25
+ const workspaces = dependencies.identityGateway.listUserWorkspaces(session.userId);
26
+ return {
27
+ authSource: "oauth",
28
+ activeWorkspace: workspaces.find((workspace) => workspace.id === session.workspaceId) ?? null,
29
+ workspaces: workspaces.map((workspace) => ({
30
+ ...workspace,
31
+ credentialWorkspaceId: workspace.id,
32
+ active: workspace.id === session.workspaceId,
33
+ source: "oauth",
34
+ switchable: true,
35
+ lastSeenAt: null
36
+ }))
37
+ };
38
+ },
39
+ useWorkspace: async (workspaceRef) => {
40
+ const session = await dependencies.sessionGateway.readAuthSession();
41
+ if (!session) throw authRequiredError(["prisma-cli auth login"]);
42
+ const ref = workspaceRef.trim();
43
+ const matches = dependencies.identityGateway.listUserWorkspaces(session.userId).filter((workspace) => workspaceMatchesRef(workspace, ref));
44
+ if (matches.length === 0) throw workspaceNotAuthenticatedError(workspaceRef);
45
+ if (matches.length > 1) throw workspaceAmbiguousError(workspaceRef, matches.map((workspace) => ({
46
+ id: workspace.id,
47
+ name: workspace.name,
48
+ credentialWorkspaceId: workspace.id
49
+ })));
50
+ const selected = matches[0];
51
+ const previousWorkspace = dependencies.identityGateway.getWorkspace(session.workspaceId) ?? null;
52
+ await dependencies.sessionGateway.writeAuthSession({
53
+ ...session,
54
+ workspaceId: selected.id
55
+ });
56
+ return {
57
+ previousWorkspace,
58
+ workspace: selected
59
+ };
60
+ },
61
+ logoutWorkspace: async (workspaceRef) => {
62
+ const session = await dependencies.sessionGateway.readAuthSession();
63
+ if (!session) throw workspaceNotAuthenticatedError(workspaceRef);
64
+ const ref = workspaceRef.trim();
65
+ const matches = dependencies.identityGateway.listUserWorkspaces(session.userId).filter((workspace) => workspaceMatchesRef(workspace, ref));
66
+ if (matches.length === 0) throw workspaceNotAuthenticatedError(workspaceRef);
67
+ if (matches.length > 1) throw workspaceAmbiguousError(workspaceRef, matches.map((workspace) => ({
68
+ id: workspace.id,
69
+ name: workspace.name,
70
+ credentialWorkspaceId: workspace.id
71
+ })));
72
+ const workspace = matches[0];
73
+ const wasActive = workspace.id === session.workspaceId;
74
+ const activeWorkspace = wasActive ? null : dependencies.identityGateway.getWorkspace(session.workspaceId) ?? null;
75
+ if (wasActive) await dependencies.sessionGateway.clearAuthSession();
76
+ return {
77
+ workspace,
78
+ wasActive,
79
+ activeWorkspace
80
+ };
81
+ },
18
82
  listProviders: async () => dependencies.identityGateway.listProviders(),
19
83
  resolveProvider: async (providerId) => {
20
84
  const provider = dependencies.identityGateway.getProvider(providerId);
@@ -39,6 +103,9 @@ function createAuthUseCases(dependencies) {
39
103
  }
40
104
  };
41
105
  }
106
+ function workspaceMatchesRef(workspace, ref) {
107
+ return workspace.id === ref || workspace.name.toLowerCase() === ref.toLowerCase();
108
+ }
42
109
  async function resolveCurrentAuthState(dependencies) {
43
110
  const session = await dependencies.sessionGateway.readAuthSession();
44
111
  if (!session) return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prisma/cli",
3
- "version": "3.0.0-dev.91.1",
3
+ "version": "3.0.0-dev.93.1",
4
4
  "description": "Command-line interface for the Prisma Developer Platform.",
5
5
  "type": "module",
6
6
  "bin": {