epismo 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +84 -75
  3. package/npm/bin/epismo.exe +1 -0
  4. package/npm/install.js +82 -0
  5. package/package.json +30 -38
  6. package/dist/analytics.js +0 -44
  7. package/dist/analytics.js.map +0 -1
  8. package/dist/api.js +0 -111
  9. package/dist/api.js.map +0 -1
  10. package/dist/auth.js +0 -658
  11. package/dist/auth.js.map +0 -1
  12. package/dist/bin.js +0 -3
  13. package/dist/bin.js.map +0 -1
  14. package/dist/cases.js +0 -25
  15. package/dist/cases.js.map +0 -1
  16. package/dist/config.js +0 -109
  17. package/dist/config.js.map +0 -1
  18. package/dist/context.js +0 -16
  19. package/dist/context.js.map +0 -1
  20. package/dist/credits.js +0 -31
  21. package/dist/credits.js.map +0 -1
  22. package/dist/errors.js +0 -111
  23. package/dist/errors.js.map +0 -1
  24. package/dist/index.js +0 -36
  25. package/dist/index.js.map +0 -1
  26. package/dist/input.js +0 -211
  27. package/dist/input.js.map +0 -1
  28. package/dist/output.js +0 -10
  29. package/dist/output.js.map +0 -1
  30. package/dist/playbook-program.js +0 -470
  31. package/dist/playbook-program.js.map +0 -1
  32. package/dist/playbooks.js +0 -62
  33. package/dist/playbooks.js.map +0 -1
  34. package/dist/program.js +0 -368
  35. package/dist/program.js.map +0 -1
  36. package/dist/projects.js +0 -54
  37. package/dist/projects.js.map +0 -1
  38. package/dist/records.js +0 -12
  39. package/dist/records.js.map +0 -1
  40. package/dist/suggestions.js +0 -15
  41. package/dist/suggestions.js.map +0 -1
  42. package/dist/token.js +0 -10
  43. package/dist/token.js.map +0 -1
  44. package/dist/update-check.js +0 -121
  45. package/dist/update-check.js.map +0 -1
  46. package/dist/workspace.js +0 -19
  47. package/dist/workspace.js.map +0 -1
  48. package/dist/workspaces.js +0 -61
  49. package/dist/workspaces.js.map +0 -1
package/dist/auth.js DELETED
@@ -1,658 +0,0 @@
1
- import { createServer } from "node:http";
2
- import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
3
- import { spawn } from "node:child_process";
4
- import { once } from "node:events";
5
- import { createInterface } from "node:readline/promises";
6
- import { stdin as input, stderr as output } from "node:process";
7
- import { clearCredentials, readCliConfig, readCredentials, resolveApiUrl, resolveWebUrl, writeCliConfig, writeCredentials } from "./config.js";
8
- import { requestJson } from "./api.js";
9
- import { CliError } from "./errors.js";
10
- import { printWarning } from "./output.js";
11
- import { resolveWorkspaceSelection } from "./workspace.js";
12
- import { analytics } from "./analytics.js";
13
- const CLI_CLIENT_ID = "epismo-cli";
14
- const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
15
- // #8: common hint strings — defined once so wording is consistent everywhere
16
- const HINT_RUN_LOGIN = "Set EPISMO_TOKEN or run `epismo login`.";
17
- const HINT_RUN_LOGIN_BROWSER = "Run `epismo login` in an interactive terminal, or use a pre-issued EPISMO_TOKEN.";
18
- const REFRESH_RACE_RETRY_DELAYS_MS = [100, 200, 400, 800];
19
- // #9: browser login progress warning codes — defined once so wording is consistent everywhere
20
- const WARNING_BROWSER_OPENING = "BROWSER_OPENING";
21
- const WARNING_BROWSER_FALLBACK_URL = "BROWSER_FALLBACK_URL";
22
- const WARNING_BROWSER_WAITING = "BROWSER_WAITING";
23
- const WARNING_BROWSER_AUTHORIZED = "BROWSER_AUTHORIZED";
24
- const WARNING_OTP_SENT = "OTP_SENT";
25
- export function hasEnvTokenAuthentication() {
26
- return Boolean(process.env.EPISMO_TOKEN?.trim());
27
- }
28
- async function prompt(question) {
29
- if (input.isTTY !== true || output.isTTY !== true) {
30
- throw new CliError({
31
- code: "NON_INTERACTIVE_INPUT_REQUIRED",
32
- message: "This login requires interactive input, but no TTY is available.",
33
- hint: "Run the command in a terminal, or use a pre-issued EPISMO_TOKEN."
34
- });
35
- }
36
- const readline = createInterface({ input, output });
37
- try {
38
- return (await readline.question(question)).trim();
39
- }
40
- finally {
41
- readline.close();
42
- }
43
- }
44
- function generatePkce() {
45
- const verifier = randomBytes(32).toString("base64url");
46
- const challenge = createHash("sha256").update(verifier).digest("base64url");
47
- return { verifier, challenge };
48
- }
49
- function generateState() {
50
- return randomBytes(32).toString("base64url");
51
- }
52
- async function openBrowser(url) {
53
- const platform = process.platform;
54
- const command = platform === "darwin"
55
- ? { file: "open", args: [url] }
56
- : platform === "win32"
57
- ? { file: "cmd", args: ["/c", "start", "", url] }
58
- : { file: "xdg-open", args: [url] };
59
- await new Promise((resolve, reject) => {
60
- const child = spawn(command.file, command.args, { detached: true, stdio: "ignore" });
61
- child.once("error", (error) => {
62
- reject(new CliError({
63
- code: "BROWSER_OPEN_FAILED",
64
- message: error.message,
65
- hint: `Open this URL manually in your browser: ${url}`
66
- }));
67
- });
68
- child.once("spawn", () => {
69
- child.unref();
70
- resolve();
71
- });
72
- });
73
- }
74
- function respondCallbackHtml(res, message) {
75
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
76
- res.end(`<html><body><p>${message}</p></body></html>`);
77
- }
78
- // Constant-time comparison so the callback state cannot be probed via timing.
79
- function isExpectedState(received, expected) {
80
- if (received === null) {
81
- return false;
82
- }
83
- const a = Buffer.from(received);
84
- const b = Buffer.from(expected);
85
- return a.length === b.length && timingSafeEqual(a, b);
86
- }
87
- async function startOAuthCallbackServer(expectedState) {
88
- const server = createServer();
89
- server.listen(0, "127.0.0.1");
90
- await once(server, "listening");
91
- const address = server.address();
92
- if (!address || typeof address === "string") {
93
- server.close();
94
- throw new CliError({
95
- code: "OAUTH_CALLBACK_FAILED",
96
- message: "Failed to bind a local callback port for browser login."
97
- });
98
- }
99
- const redirectUri = `http://127.0.0.1:${address.port}/callback`;
100
- let resolveCallback;
101
- let rejectCallback;
102
- const callbackPromise = new Promise((resolve, reject) => {
103
- resolveCallback = resolve;
104
- rejectCallback = reject;
105
- });
106
- let timeoutId;
107
- let settled = false;
108
- const finish = (settle) => {
109
- if (settled) {
110
- return;
111
- }
112
- settled = true;
113
- clearTimeout(timeoutId);
114
- server.close();
115
- settle();
116
- };
117
- // Attach handlers immediately — before the browser is opened — so a fast
118
- // callback can never arrive in the window between listen() and the listener
119
- // being registered. Requests that land before waitForCallback() simply settle
120
- // the promise it returns.
121
- server.on("request", (req, res) => {
122
- const url = new URL(req.url ?? "/", redirectUri);
123
- if (url.pathname !== "/callback") {
124
- res.writeHead(404);
125
- res.end();
126
- return;
127
- }
128
- const code = url.searchParams.get("code");
129
- const error = url.searchParams.get("error");
130
- const state = url.searchParams.get("state");
131
- const workspaceId = url.searchParams.get("workspace_id") ?? undefined;
132
- if (!isExpectedState(state, expectedState)) {
133
- respondCallbackHtml(res, "Authorization failed. Please close this tab and try again.");
134
- finish(() => rejectCallback(new CliError({
135
- code: "INVALID_OAUTH_STATE",
136
- message: "OAuth callback state did not match the login request.",
137
- retryable: true,
138
- hint: HINT_RUN_LOGIN_BROWSER
139
- })));
140
- return;
141
- }
142
- if (error) {
143
- respondCallbackHtml(res, "Authorization denied. You may close this tab.");
144
- finish(() => rejectCallback(new CliError({
145
- code: "AUTHORIZATION_DENIED",
146
- message: `Authorization denied: ${error}`,
147
- retryable: false
148
- })));
149
- return;
150
- }
151
- if (!code) {
152
- respondCallbackHtml(res, "Authorization failed. Please close this tab and try again.");
153
- finish(() => rejectCallback(new CliError({
154
- code: "AUTHORIZATION_CODE_MISSING",
155
- message: "No authorization code received from the browser callback.",
156
- retryable: true
157
- })));
158
- return;
159
- }
160
- respondCallbackHtml(res, "Authorization complete. Return to your terminal to continue.");
161
- finish(() => resolveCallback({ code, workspaceId }));
162
- });
163
- server.on("error", (err) => {
164
- finish(() => rejectCallback(err instanceof Error
165
- ? new CliError({
166
- code: "OAUTH_CALLBACK_FAILED",
167
- message: err.message,
168
- retryable: true
169
- })
170
- : err));
171
- });
172
- const waitForCallback = () => {
173
- if (settled || timeoutId !== undefined) {
174
- return callbackPromise;
175
- }
176
- timeoutId = setTimeout(() => {
177
- finish(() => rejectCallback(new CliError({
178
- code: "AUTHORIZATION_TIMED_OUT",
179
- message: "Authorization timed out after 5 minutes.",
180
- retryable: true,
181
- hint: HINT_RUN_LOGIN_BROWSER
182
- })));
183
- }, 5 * 60 * 1000);
184
- return callbackPromise;
185
- };
186
- return { redirectUri, waitForCallback };
187
- }
188
- async function postOAuthToken(apiUrl, body) {
189
- return requestJson(apiUrl, "/oauth/token", {
190
- method: "POST",
191
- body
192
- });
193
- }
194
- async function resolveLoginMethod(apiUrl, email) {
195
- const response = await requestJson(apiUrl, "/v1/login-options", {
196
- method: "POST",
197
- body: { email }
198
- });
199
- if (response.method !== "sso" && response.method !== "otp") {
200
- throw new CliError({
201
- code: "LOGIN_METHOD_INVALID",
202
- message: "The server returned an unsupported login method.",
203
- retryable: true
204
- });
205
- }
206
- return response.method;
207
- }
208
- async function loginWithOtp(apiUrl, email) {
209
- if (input.isTTY !== true || output.isTTY !== true) {
210
- throw new CliError({
211
- code: "NON_INTERACTIVE_INPUT_REQUIRED",
212
- message: "Email-code login requires an interactive terminal.",
213
- hint: "Run the command in a terminal, or use a pre-issued EPISMO_TOKEN."
214
- });
215
- }
216
- const issued = await requestJson(apiUrl, "/v1/otp-tokens", {
217
- method: "POST",
218
- body: { email }
219
- });
220
- const otpId = issued.otpId?.trim();
221
- if (!otpId) {
222
- throw new CliError({
223
- code: "OTP_ID_MISSING",
224
- message: "Failed to obtain an OTP id from the server.",
225
- retryable: true
226
- });
227
- }
228
- printWarning({
229
- warning: { code: WARNING_OTP_SENT, message: `A sign-in code was sent to ${email}.` }
230
- });
231
- const otp = await prompt("Code: ");
232
- if (!otp) {
233
- throw new CliError({
234
- code: "OTP_CODE_REQUIRED",
235
- message: "A sign-in code is required.",
236
- hint: `Run \`epismo login --email ${email}\` to request a new code.`
237
- });
238
- }
239
- const tokenResponse = await postOAuthToken(apiUrl, {
240
- grant_type: "otp",
241
- otp_id: otpId,
242
- otp,
243
- client_id: CLI_CLIENT_ID
244
- });
245
- return toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
246
- }
247
- async function fetchUserInfo(apiUrl, accessToken) {
248
- return requestJson(apiUrl, "/oauth/userinfo", {
249
- authToken: accessToken
250
- });
251
- }
252
- export async function resolvePersonalAccountId(auth) {
253
- const cached = auth.accountId?.trim();
254
- if (cached)
255
- return cached;
256
- const userInfo = await fetchUserInfo(resolveApiUrl(), auth.accessToken);
257
- const accountId = userInfo.account_id?.trim();
258
- if (!accountId) {
259
- throw new CliError({
260
- code: "ACCOUNT_ID_UNAVAILABLE",
261
- message: "The authenticated user does not have a personal Account ID.",
262
- hint: "Pass --owner-id explicitly, or run `epismo login` again."
263
- });
264
- }
265
- return accountId;
266
- }
267
- function sleep(ms) {
268
- return new Promise((resolve) => setTimeout(resolve, ms));
269
- }
270
- function shouldRefreshCredentials(credentials) {
271
- const expiresAt = new Date(credentials.expiresAt).getTime();
272
- return !Number.isFinite(expiresAt) || expiresAt - Date.now() < TOKEN_REFRESH_THRESHOLD_MS;
273
- }
274
- async function fetchWorkspaces(apiUrl, accessToken) {
275
- const response = await requestJson(apiUrl, "/v1/workspaces", {
276
- authToken: accessToken
277
- });
278
- return response.workspaces;
279
- }
280
- function toSavedWorkspace(workspace) {
281
- if (!workspace) {
282
- return undefined;
283
- }
284
- return {
285
- id: workspace.id,
286
- handle: workspace.handle,
287
- accountId: workspace.accountId,
288
- role: workspace.role
289
- };
290
- }
291
- export function buildCliAuthorizationUrl(input) {
292
- const authorizeUrl = new URL(`${input.webUrl}/oauth/authorize`);
293
- authorizeUrl.searchParams.set("response_type", "code");
294
- authorizeUrl.searchParams.set("client_id", CLI_CLIENT_ID);
295
- authorizeUrl.searchParams.set("redirect_uri", input.redirectUri);
296
- authorizeUrl.searchParams.set("scope", "read write offline_access");
297
- authorizeUrl.searchParams.set("code_challenge", input.challenge);
298
- authorizeUrl.searchParams.set("code_challenge_method", "S256");
299
- authorizeUrl.searchParams.set("state", input.state);
300
- const email = input.email?.trim();
301
- if (email)
302
- authorizeUrl.searchParams.set("login_hint", email);
303
- return authorizeUrl;
304
- }
305
- async function loginWithBrowser(apiUrl, webUrl, email) {
306
- if (output.isTTY !== true) {
307
- throw new CliError({
308
- code: "NON_INTERACTIVE_BROWSER_LOGIN",
309
- message: "Browser login requires an interactive terminal.",
310
- hint: HINT_RUN_LOGIN_BROWSER
311
- });
312
- }
313
- const { verifier, challenge } = generatePkce();
314
- const state = generateState();
315
- const { redirectUri, waitForCallback } = await startOAuthCallbackServer(state);
316
- const authorizeUrl = buildCliAuthorizationUrl({ webUrl, redirectUri, challenge, state, email });
317
- const authorizeUrlString = authorizeUrl.toString();
318
- printWarning({
319
- warning: { code: WARNING_BROWSER_OPENING, message: "Opening browser for authorization..." }
320
- });
321
- printWarning({
322
- warning: {
323
- code: WARNING_BROWSER_FALLBACK_URL,
324
- message: `If the browser does not open, visit: ${authorizeUrlString}`
325
- }
326
- });
327
- try {
328
- await openBrowser(authorizeUrlString);
329
- }
330
- catch (cause) {
331
- if (!(cause instanceof CliError) || cause.code !== "BROWSER_OPEN_FAILED") {
332
- throw cause;
333
- }
334
- // The callback server is already listening and the URL was printed above,
335
- // so manual browser navigation can complete the same authorization flow.
336
- }
337
- printWarning({
338
- warning: {
339
- code: WARNING_BROWSER_WAITING,
340
- message: "Waiting for authorization in your browser (times out in 5 minutes)..."
341
- }
342
- });
343
- const { code, workspaceId } = await waitForCallback();
344
- printWarning({
345
- warning: {
346
- code: WARNING_BROWSER_AUTHORIZED,
347
- message: "Authorization received. Completing sign-in..."
348
- }
349
- });
350
- const tokenResponse = await postOAuthToken(apiUrl, {
351
- grant_type: "authorization_code",
352
- code,
353
- client_id: CLI_CLIENT_ID,
354
- redirect_uri: redirectUri,
355
- code_verifier: verifier
356
- });
357
- const verified = toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
358
- return { ...verified, workspaceId };
359
- }
360
- // Compare and store canonical lowercased addresses so the same account typed
361
- // with different casing does not trigger an unnecessary browser login.
362
- export function normalizeEmail(email) {
363
- return email.trim().toLowerCase();
364
- }
365
- function toVerifiedLogin(tokenResponse, userInfo) {
366
- const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
367
- return {
368
- credentials: {
369
- accessToken: tokenResponse.access_token,
370
- refreshToken: tokenResponse.refresh_token,
371
- expiresAt,
372
- clientId: CLI_CLIENT_ID,
373
- userId: userInfo.sub,
374
- accountId: userInfo.account_id
375
- },
376
- email: userInfo.email ? normalizeEmail(userInfo.email) : undefined
377
- };
378
- }
379
- // A workspace_id chosen on the browser consent screen that matches the caller's own
380
- // user id is their personal account, not an org workspace — nothing to look up or
381
- // adopt as a default. Otherwise, resolve it against the accessible workspace list so
382
- // the CLI can adopt the same workspace the user just authorized in the browser.
383
- async function resolveLoginWorkspace(apiUrl, accessToken, userId, workspaceId) {
384
- if (workspaceId === userId) {
385
- return undefined;
386
- }
387
- const workspaces = await fetchWorkspaces(apiUrl, accessToken);
388
- return toSavedWorkspace(workspaces.find((workspace) => workspace.id === workspaceId));
389
- }
390
- // Persist tokens, remember the verified email, and identify.
391
- async function persistLogin(credentials, config, lastLoginEmail) {
392
- await writeCredentials(credentials);
393
- await writeCliConfig({
394
- ...config,
395
- lastLoginEmail: lastLoginEmail ?? config.lastLoginEmail
396
- });
397
- // auth.signed_in is emitted server-side by the API at /oauth/token.
398
- analytics.identify({ userId: credentials.userId }, config.defaultWorkspace?.id);
399
- }
400
- function toLoggedIn(credentials, lastLoginEmail, config, alreadyLoggedIn) {
401
- // The default-workspace nudge only applies to a fresh sign-in; the
402
- // already-logged-in case carries its own hint in the CLI layer.
403
- const hint = !alreadyLoggedIn && !config.defaultWorkspace
404
- ? "No default workspace set. Run `epismo workspace use --workspace-id <id>` to set a default workspace."
405
- : undefined;
406
- return {
407
- kind: "logged_in",
408
- alreadyLoggedIn,
409
- userId: credentials.userId,
410
- expiresAt: credentials.expiresAt,
411
- lastLoginEmail: lastLoginEmail ?? config.lastLoginEmail,
412
- ...(hint ? { hint } : {})
413
- };
414
- }
415
- // Without an email, Web browser OAuth owns login-method selection. With an
416
- // email, the API performs the same domain discovery: SSO continues in the
417
- // browser while OTP completes interactively here in the terminal.
418
- export async function login(options) {
419
- const apiUrl = resolveApiUrl();
420
- const webUrl = resolveWebUrl();
421
- const config = await readCliConfig();
422
- const requestedEmail = options.email?.trim() ? normalizeEmail(options.email) : undefined;
423
- // Reuse a fresh credential only when the requested account matches.
424
- const existing = await readCredentials();
425
- if (existing && !shouldRefreshCredentials(existing)) {
426
- const sameAccount = !requestedEmail ||
427
- (!!config.lastLoginEmail && requestedEmail === normalizeEmail(config.lastLoginEmail));
428
- if (sameAccount) {
429
- return toLoggedIn(existing, config.lastLoginEmail, config, true);
430
- }
431
- }
432
- const verified = requestedEmail && (await resolveLoginMethod(apiUrl, requestedEmail)) === "otp"
433
- ? await loginWithOtp(apiUrl, requestedEmail)
434
- : await loginWithBrowser(apiUrl, webUrl, requestedEmail);
435
- const lastLoginEmail = verified.email ?? config.lastLoginEmail;
436
- const { credentials } = verified;
437
- // Adopt the workspace chosen on the browser consent screen (if any) as the local
438
- // default, so a fresh browser login into an org workspace doesn't also require a
439
- // separate `epismo workspace use`.
440
- const selectedWorkspace = verified.workspaceId
441
- ? await resolveLoginWorkspace(apiUrl, credentials.accessToken, credentials.userId, verified.workspaceId)
442
- : undefined;
443
- const updatedConfig = selectedWorkspace
444
- ? { ...config, defaultWorkspace: selectedWorkspace }
445
- : config;
446
- await persistLogin(credentials, updatedConfig, lastLoginEmail);
447
- return toLoggedIn(credentials, lastLoginEmail, updatedConfig, false);
448
- }
449
- export async function logout() {
450
- const credentials = await readCredentials();
451
- const hadCredentials = Boolean(credentials?.accessToken);
452
- if (credentials?.refreshToken) {
453
- try {
454
- await requestJson(resolveApiUrl(), "/oauth/revoke", {
455
- method: "POST",
456
- authToken: credentials.accessToken,
457
- body: { token: credentials.refreshToken, client_id: credentials.clientId }
458
- });
459
- }
460
- catch {
461
- // Clear local state even when server-side revocation fails.
462
- }
463
- }
464
- await clearCredentials();
465
- // auth.signed_out is emitted server-side by the API at /oauth/revoke.
466
- return { cleared: true, hadCredentials };
467
- }
468
- async function refreshAccessToken(credentials, apiUrl) {
469
- const tokenResponse = await postOAuthToken(apiUrl, {
470
- grant_type: "refresh_token",
471
- refresh_token: credentials.refreshToken,
472
- client_id: credentials.clientId
473
- });
474
- const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
475
- const updated = {
476
- ...credentials,
477
- accessToken: tokenResponse.access_token,
478
- refreshToken: tokenResponse.refresh_token,
479
- expiresAt
480
- };
481
- await writeCredentials(updated);
482
- return updated;
483
- }
484
- async function readCredentialsRefreshedByAnotherProcess(original) {
485
- for (const delayMs of REFRESH_RACE_RETRY_DELAYS_MS) {
486
- const latest = await readCredentials();
487
- if (latest?.userId === original.userId &&
488
- latest.accessToken !== original.accessToken &&
489
- !shouldRefreshCredentials(latest)) {
490
- return latest;
491
- }
492
- await sleep(delayMs);
493
- }
494
- return undefined;
495
- }
496
- export async function resolveAuthentication() {
497
- const envToken = process.env.EPISMO_TOKEN?.trim();
498
- if (envToken) {
499
- // EPISMO_TOKEN is a pre-issued token (CI/CD). Skip the /oauth/userinfo
500
- // round-trip on every command: userId here is only consumed by the no-op
501
- // analytics.identify(), and `whoami` fetches identity itself when needed.
502
- return {
503
- mode: "oauth",
504
- userId: "",
505
- accessToken: envToken
506
- };
507
- }
508
- const credentials = await readCredentials();
509
- if (!credentials?.accessToken || !credentials.userId) {
510
- throw new CliError({
511
- code: "NOT_AUTHENTICATED",
512
- message: "Not authenticated.",
513
- hint: HINT_RUN_LOGIN
514
- });
515
- }
516
- const apiUrl = resolveApiUrl();
517
- if (shouldRefreshCredentials(credentials)) {
518
- if (!credentials.refreshToken) {
519
- throw new CliError({
520
- code: "TOKEN_EXPIRED",
521
- message: "Token expired and no refresh token is available.",
522
- hint: HINT_RUN_LOGIN
523
- });
524
- }
525
- try {
526
- const refreshed = await refreshAccessToken(credentials, apiUrl);
527
- return {
528
- mode: "oauth",
529
- userId: refreshed.userId,
530
- accessToken: refreshed.accessToken,
531
- expiresAt: refreshed.expiresAt,
532
- accountId: refreshed.accountId
533
- };
534
- }
535
- catch (refreshError) {
536
- const refreshedByAnotherProcess = await readCredentialsRefreshedByAnotherProcess(credentials);
537
- if (refreshedByAnotherProcess) {
538
- return {
539
- mode: "oauth",
540
- userId: refreshedByAnotherProcess.userId,
541
- accessToken: refreshedByAnotherProcess.accessToken,
542
- expiresAt: refreshedByAnotherProcess.expiresAt,
543
- accountId: refreshedByAnotherProcess.accountId
544
- };
545
- }
546
- const isRetryable = refreshError instanceof CliError ? refreshError.retryable : true;
547
- throw new CliError({
548
- code: "TOKEN_REFRESH_FAILED",
549
- message: isRetryable
550
- ? "Token refresh failed due to a network or server error."
551
- : "Session expired. Please log in again.",
552
- retryable: isRetryable,
553
- hint: isRetryable
554
- ? `Check your network connection and try again, or run \`epismo login\`.`
555
- : HINT_RUN_LOGIN
556
- });
557
- }
558
- }
559
- return {
560
- mode: "oauth",
561
- userId: credentials.userId,
562
- accessToken: credentials.accessToken,
563
- expiresAt: credentials.expiresAt,
564
- accountId: credentials.accountId
565
- };
566
- }
567
- export async function whoami(options) {
568
- const workspace = await resolveWorkspaceSelection();
569
- const auth = options.auth ?? (await resolveAuthentication());
570
- const apiUrl = resolveApiUrl();
571
- const [user, workspaces] = await Promise.all([
572
- fetchUserInfo(apiUrl, auth.accessToken),
573
- fetchWorkspaces(apiUrl, auth.accessToken)
574
- ]);
575
- return {
576
- auth: {
577
- mode: auth.mode,
578
- expiresAt: auth.expiresAt
579
- },
580
- user: {
581
- id: user.sub,
582
- email: user.email,
583
- // Free-text display name, not unique and not addressable.
584
- username: user.username,
585
- // Public identifier: the <handle> in pb:<handle>/<alias>. Null until
586
- // the user claims one.
587
- handle: user.handle ?? null,
588
- // Immutable; the default --owner-id for `epismo playbook create`.
589
- accountId: user.account_id ?? null
590
- },
591
- defaultWorkspaceId: workspace.defaultWorkspace?.id ?? null,
592
- defaultWorkspaceApplied: workspace.envTokenActive === false,
593
- effectiveWorkspaceId: workspace.effectiveWorkspaceId ?? null,
594
- workspaces
595
- };
596
- }
597
- export async function listAvailableWorkspaces() {
598
- const workspaceSelection = await resolveWorkspaceSelection();
599
- const auth = await resolveAuthentication();
600
- const workspaces = await fetchWorkspaces(resolveApiUrl(), auth.accessToken);
601
- const defaultWorkspaceId = workspaceSelection.defaultWorkspace?.id ?? null;
602
- return {
603
- workspaces: workspaces.map((item) => ({
604
- ...item,
605
- isDefault: item.id === defaultWorkspaceId
606
- }))
607
- };
608
- }
609
- export async function getCurrentWorkspace() {
610
- const config = await readCliConfig();
611
- const currentWorkspace = config.defaultWorkspace;
612
- return {
613
- currentWorkspace: currentWorkspace
614
- ? {
615
- ...currentWorkspace,
616
- isDefault: true,
617
- source: "local-config"
618
- }
619
- : null
620
- };
621
- }
622
- export async function useWorkspace(options) {
623
- const requestedWorkspaceId = options.workspaceId?.trim();
624
- if (!requestedWorkspaceId) {
625
- throw new CliError({
626
- code: "WORKSPACE_ID_REQUIRED",
627
- message: "--workspace-id is required.",
628
- hint: "Run `epismo workspace use --workspace-id <workspace-id>`."
629
- });
630
- }
631
- const config = await readCliConfig();
632
- const listed = await listAvailableWorkspaces();
633
- const matched = listed.workspaces.find((workspace) => workspace.id === requestedWorkspaceId);
634
- if (!matched) {
635
- throw new CliError({
636
- code: "WORKSPACE_NOT_FOUND",
637
- message: `Workspace not found or not accessible: ${requestedWorkspaceId}`,
638
- hint: "Run `epismo workspace list` to see accessible workspaces."
639
- });
640
- }
641
- await writeCliConfig({
642
- ...config,
643
- defaultWorkspace: toSavedWorkspace(matched)
644
- });
645
- return {
646
- workspace: { ...matched, isDefault: true }
647
- };
648
- }
649
- export async function clearWorkspace() {
650
- const config = await readCliConfig();
651
- const previousWorkspace = config.defaultWorkspace ?? null;
652
- await writeCliConfig({
653
- ...config,
654
- defaultWorkspace: undefined
655
- });
656
- return { cleared: true, previousWorkspace };
657
- }
658
- //# sourceMappingURL=auth.js.map