@asteroidai/cli 1.9.11 → 1.9.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteroidai/cli",
3
- "version": "1.9.11",
3
+ "version": "1.9.27",
4
4
  "description": "The asteroid command-line interface for the Asteroid platform.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/account.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import { UsageError } from "./args";
2
2
  import {
3
3
  API_KEY_OVERRIDES,
4
+ type Auth,
4
5
  AuthError,
6
+ isStaffLogin,
7
+ NotMemberError,
5
8
  orgFromToken,
6
9
  rememberOrg,
7
10
  resolveAuth,
11
+ staffHint,
8
12
  UNSET_API_KEY,
9
13
  } from "./auth/credentials";
10
- import { CredentialStore } from "./auth/store";
14
+ import { CredentialStore, type StaffToken } from "./auth/store";
11
15
  import {
12
16
  authorizeDevice,
13
17
  pollDevice,
@@ -15,14 +19,16 @@ import {
15
19
  type WorkosOrg,
16
20
  } from "./auth/workos";
17
21
  import {
22
+ type Config,
18
23
  findOrg,
19
24
  loadConfig,
20
25
  mergeOrgs,
26
+ type Org,
21
27
  orgLabel,
22
28
  requestedOrg,
23
29
  saveConfig,
24
30
  } from "./config";
25
- import { send } from "./http";
31
+ import { ApiError, send } from "./http";
26
32
  import { clean, render } from "./output";
27
33
  import type { Deps } from "./run";
28
34
 
@@ -128,11 +134,21 @@ export async function orgs(
128
134
  deps.fetch,
129
135
  );
130
136
  const list = loadConfig(deps.env).orgs ?? [];
137
+ const staff = (await new CredentialStore(deps.env).staffTokens()).filter(
138
+ (held) => held.expiresAt > Date.now(),
139
+ );
131
140
  const active = auth.org?.workosId;
132
141
  if (json) {
133
142
  deps.stdout(
134
143
  `${JSON.stringify(
135
- list.map((org) => ({ ...org, active: org.workosId === active })),
144
+ [
145
+ ...list.map((org) => ({ ...org, active: org.workosId === active })),
146
+ ...staff.map((held) => ({
147
+ ...held.org,
148
+ active: held.org.workosId === active,
149
+ staff: { reason: held.reason, expiresAt: held.expiresAt },
150
+ })),
151
+ ],
136
152
  null,
137
153
  2,
138
154
  )}\n`,
@@ -141,17 +157,259 @@ export async function orgs(
141
157
  }
142
158
  for (const org of list)
143
159
  deps.stdout(`${org.workosId === active ? "*" : " "} ${orgLabel(org)}\n`);
160
+ for (const held of staff)
161
+ deps.stdout(
162
+ `${held.org.workosId === active ? "*" : " "} ${orgLabel(held.org)} staff access, ${timeLeft(held.expiresAt)} left\n`,
163
+ );
164
+ return 0;
165
+ }
166
+
167
+ function timeLeft(expiresAt: number): string {
168
+ const minutes = Math.max(0, Math.round((expiresAt - Date.now()) / 60_000));
169
+ return minutes >= 60
170
+ ? `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`
171
+ : `${minutes}m`;
172
+ }
173
+
174
+ function isMember(config: Config, org: Org): boolean {
175
+ return (config.orgs ?? []).some((o) => o.workosId === org.workosId);
176
+ }
177
+
178
+ /** The org whose sign-in calls the mint: the default, or else any of yours. */
179
+ function homeOrg(config: Config): Org {
180
+ const org =
181
+ config.org && isMember(config, config.org) ? config.org : config.orgs?.[0];
182
+ if (!org)
183
+ throw new AuthError(
184
+ "staff access needs a sign-in to one of your own organizations. Run `asteroid login`.",
185
+ );
186
+ return org;
187
+ }
188
+
189
+ const STAFF_TOKEN_PATH = "/users/v2/staff-tokens";
190
+
191
+ interface MintedStaffToken {
192
+ token: string;
193
+ grantId: string;
194
+ organizationId: string;
195
+ organizationName: string;
196
+ workosOrganizationId?: string;
197
+ reason: string;
198
+ expiresAt: string;
199
+ }
200
+
201
+ async function mintStaffToken(
202
+ deps: Deps,
203
+ config: Config,
204
+ ref: string,
205
+ reason: string,
206
+ ): Promise<StaffToken> {
207
+ const home = await resolveAuth(
208
+ deps.env,
209
+ config,
210
+ homeOrg(config).workosId,
211
+ deps.fetch,
212
+ );
213
+ let minted: MintedStaffToken;
214
+ try {
215
+ const response = await send(
216
+ deps.env.apiUrl,
217
+ home,
218
+ {
219
+ method: "POST",
220
+ path: STAFF_TOKEN_PATH,
221
+ query: new URLSearchParams(),
222
+ json: { organization: ref, reason },
223
+ },
224
+ false,
225
+ deps.fetch,
226
+ );
227
+ minted = response.json as MintedStaffToken;
228
+ } catch (error) {
229
+ if (error instanceof ApiError && error.status === 403)
230
+ throw new AuthError(
231
+ `you are not a member of ${clean(ref)}, and staff access is for Asteroid staff only.`,
232
+ );
233
+ if (error instanceof ApiError && error.status === 404)
234
+ throw new UsageError(
235
+ `unknown organization ${clean(ref)}. Pass its id or its WorkOS id (org_...).`,
236
+ );
237
+ throw error;
238
+ }
239
+ return {
240
+ token: minted.token,
241
+ grantId: minted.grantId,
242
+ org: {
243
+ workosId: minted.workosOrganizationId ?? minted.organizationId,
244
+ id: minted.organizationId,
245
+ name: minted.organizationName,
246
+ },
247
+ reason: minted.reason,
248
+ expiresAt: Date.parse(minted.expiresAt),
249
+ };
250
+ }
251
+
252
+ async function revokeStaffToken(
253
+ deps: Deps,
254
+ home: Auth,
255
+ held: StaffToken,
256
+ ): Promise<boolean> {
257
+ try {
258
+ await send(
259
+ deps.env.apiUrl,
260
+ home,
261
+ {
262
+ method: "DELETE",
263
+ path: `${STAFF_TOKEN_PATH}/${encodeURIComponent(held.grantId)}`,
264
+ query: new URLSearchParams(),
265
+ },
266
+ false,
267
+ deps.fetch,
268
+ );
269
+ return true;
270
+ } catch (error) {
271
+ // Gone already: revoked, or never recorded. Either way it no longer works.
272
+ if (error instanceof ApiError && error.status === 404) return true;
273
+ deps.stderr(
274
+ `Could not revoke staff access to ${orgLabel(held.org)}: ${clean(error instanceof Error ? error.message : String(error))}. It ends at ${new Date(held.expiresAt).toISOString()}.\n`,
275
+ );
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /** `use --exit`: revoke every staff token now and go back to your own org. */
281
+ async function exitStaff(deps: Deps): Promise<number> {
282
+ const store = new CredentialStore(deps.env);
283
+ const held = await store.staffTokens();
284
+ const config = loadConfig(deps.env);
285
+ if (held.length === 0) {
286
+ deps.stderr("No staff access to leave.\n");
287
+ return 0;
288
+ }
289
+ const home = await resolveAuth(
290
+ deps.env,
291
+ config,
292
+ homeOrg(config).workosId,
293
+ deps.fetch,
294
+ );
295
+ const left: StaffToken[] = [];
296
+ const kept: StaffToken[] = [];
297
+ for (const token of held) {
298
+ // Keep a token whose grant is still live, so a retry can revoke it.
299
+ const ended =
300
+ token.expiresAt <= Date.now() ||
301
+ (await revokeStaffToken(deps, home, token));
302
+ if (!ended) {
303
+ kept.push(token);
304
+ continue;
305
+ }
306
+ await store.dropStaffToken(token.grantId);
307
+ left.push(token);
308
+ }
309
+ const back = held.find((t) => t.from)?.from ?? homeOrg(config);
310
+ saveConfig(deps.env, { ...loadConfig(deps.env), org: back });
311
+ if (left.length > 0)
312
+ deps.stderr(
313
+ `Left staff access to ${left.map((t) => orgLabel(t.org)).join(", ")}.\n`,
314
+ );
315
+ if (kept.length > 0)
316
+ deps.stderr(
317
+ `Still holding staff access to ${kept.map((t) => orgLabel(t.org)).join(", ")}. Run \`asteroid use --exit\` again to retry.\n`,
318
+ );
319
+ deps.stderr(`Now using ${orgLabel(back)}.\n`);
320
+ return kept.length === 0 ? 0 : 1;
321
+ }
322
+
323
+ async function askReason(deps: Deps, org: string): Promise<string | undefined> {
324
+ if (!deps.ui) return undefined;
325
+ const reason = await deps.ui.text(
326
+ `You are not a member of ${clean(org)}. Why do you need staff access? This is audited.`,
327
+ );
328
+ return reason?.trim() || undefined;
329
+ }
330
+
331
+ /** The staff path of `use`: mint a token for an org you are not a member of. */
332
+ async function useAsStaff(
333
+ deps: Deps,
334
+ config: Config,
335
+ ref: string,
336
+ reason: string | undefined,
337
+ staff: boolean,
338
+ ): Promise<number> {
339
+ const store = new CredentialStore(deps.env);
340
+ const held = await store.staffToken(ref);
341
+ if (held && held.expiresAt - 30_000 > Date.now() && !reason) {
342
+ saveConfig(deps.env, { ...config, org: held.org });
343
+ deps.stderr(
344
+ `Now using ${orgLabel(held.org)} with staff access, ${timeLeft(held.expiresAt)} left.\n`,
345
+ );
346
+ return 0;
347
+ }
348
+ if (!reason && staff) reason = await askReason(deps, ref);
349
+ if (!reason)
350
+ throw new UsageError(
351
+ `you are not a member of ${clean(ref)}.${staffHint(ref, staff)}${staff ? " The reason is audited." : ""}`,
352
+ );
353
+ // The mint takes an id, not a name. A held token knows the id.
354
+ const minted = await mintStaffToken(
355
+ deps,
356
+ config,
357
+ held?.org.id ?? ref,
358
+ reason,
359
+ );
360
+ const previous = config.org
361
+ ? isMember(config, config.org)
362
+ ? config.org
363
+ : (await store.staffToken(config.org.workosId))?.from
364
+ : undefined;
365
+ try {
366
+ await store.saveStaffToken(
367
+ { ...minted, from: previous ?? held?.from },
368
+ held?.grantId,
369
+ );
370
+ } catch (error) {
371
+ // Nothing holds the token, so nothing could revoke it later. End it now.
372
+ const home = await resolveAuth(
373
+ deps.env,
374
+ config,
375
+ homeOrg(config).workosId,
376
+ deps.fetch,
377
+ );
378
+ await revokeStaffToken(deps, home, minted);
379
+ throw error;
380
+ }
381
+ saveConfig(deps.env, { ...loadConfig(deps.env), org: minted.org });
382
+ deps.stderr(
383
+ `Staff access to ${orgLabel(minted.org)} for ${timeLeft(minted.expiresAt)}, until ${new Date(minted.expiresAt).toLocaleTimeString()}. ` +
384
+ `Reason: ${clean(minted.reason)}. This is audited. Run \`asteroid use --exit\` to leave.\n`,
385
+ );
144
386
  return 0;
145
387
  }
146
388
 
389
+ export interface UseOptions {
390
+ reason?: string;
391
+ exit?: boolean;
392
+ }
393
+
147
394
  export async function use(
148
395
  deps: Deps,
149
396
  ref: string | undefined,
397
+ options: UseOptions = {},
150
398
  ): Promise<number> {
151
399
  if (deps.env.apiKey)
152
400
  throw new UsageError(
153
401
  `${API_KEY_OVERRIDES} \`use\` only switches the login's organization. ${UNSET_API_KEY}`,
154
402
  );
403
+ if (options.exit) {
404
+ if (ref || options.reason)
405
+ throw new UsageError("usage: asteroid use --exit");
406
+ return exitStaff(deps);
407
+ }
408
+ // `--reason --exit` must not mint a grant whose reason is "--exit".
409
+ if (options.reason?.startsWith("-"))
410
+ throw new UsageError(
411
+ `--reason needs a value, not ${clean(options.reason)}. Give the reason without a leading -.`,
412
+ );
155
413
  const config = loadConfig(deps.env);
156
414
  if (!ref && deps.ui && (config.orgs?.length ?? 0) > 0) {
157
415
  ref = await deps.ui.select(
@@ -164,12 +422,22 @@ export async function use(
164
422
  if (!ref) return 1;
165
423
  }
166
424
  if (!ref) throw new UsageError("usage: asteroid use <org>");
425
+ const login = await new CredentialStore(deps.env).readLogin();
426
+ if (!login) throw new AuthError("not signed in. Run `asteroid login`.");
427
+ const staff = isStaffLogin(login);
167
428
  const org = findOrg(ref, config.orgs ?? []);
168
- if (!org)
169
- throw new UsageError(
170
- `unknown organization ${ref}. Run \`asteroid orgs\` to list yours.`,
171
- );
172
- const auth = await resolveAuth(deps.env, config, org.workosId, deps.fetch);
429
+ // Not one of yours: an id or name the sign-in does not list. A bare WorkOS id
430
+ // may still be yours, so the refresh decides.
431
+ if (!org || (!isMember(config, org) && options.reason))
432
+ return useAsStaff(deps, config, ref, options.reason, staff);
433
+ let auth: Auth;
434
+ try {
435
+ auth = await resolveAuth(deps.env, config, org.workosId, deps.fetch);
436
+ } catch (error) {
437
+ if (error instanceof NotMemberError)
438
+ return useAsStaff(deps, config, ref, options.reason, staff);
439
+ throw error;
440
+ }
173
441
  const updated = loadConfig(deps.env);
174
442
  updated.org =
175
443
  updated.orgs?.find((o) => o.workosId === org.workosId) ?? auth.org ?? org;
@@ -207,6 +475,7 @@ export async function whoami(
207
475
  );
208
476
  return 0;
209
477
  }
478
+ const grant = body.staffGrant as { reason?: string } | undefined;
210
479
  const person = body.person as { email?: string } | undefined;
211
480
  const orgId = body.organizationId as string | undefined;
212
481
  const organization = auth.org ? orgLabel(auth.org) : (orgId ?? "none");
@@ -215,9 +484,17 @@ export async function whoami(
215
484
  as: person?.email ?? body.id,
216
485
  kind: body.kind,
217
486
  credential: body.credential,
218
- source: auth.kind === "api-key" ? "ASTEROID_API_KEY" : "asteroid login",
487
+ source:
488
+ auth.kind === "api-key"
489
+ ? "ASTEROID_API_KEY"
490
+ : auth.staff
491
+ ? "asteroid use (staff access)"
492
+ : "asteroid login",
219
493
  organization,
220
- ...(minutes === undefined ? {} : { tokenExpiresIn: `${minutes}m` }),
494
+ ...(grant ? { staffReason: grant.reason } : {}),
495
+ ...(auth.expiresAt === undefined
496
+ ? {}
497
+ : { tokenExpiresIn: timeLeft(auth.expiresAt) }),
221
498
  })}\n`,
222
499
  );
223
500
  return 0;
@@ -7,11 +7,15 @@ import {
7
7
  orgLabel,
8
8
  saveConfig,
9
9
  } from "../config";
10
- import { CredentialStore } from "./store";
10
+ import { clean } from "../output";
11
+ import { CredentialStore, type Login, type StaffToken } from "./store";
11
12
  import { refresh, tokenClaims, WorkosError } from "./workos";
12
13
 
13
14
  export class AuthError extends Error {}
14
15
 
16
+ /** The sign-in is fine, but the person is not a member of the org. */
17
+ export class NotMemberError extends AuthError {}
18
+
15
19
  export const API_KEY_OVERRIDES =
16
20
  "ASTEROID_API_KEY is set, so the CLI uses that key and ignores `asteroid login`.";
17
21
  export const UNSET_API_KEY =
@@ -22,6 +26,30 @@ export interface Auth {
22
26
  headers: Record<string, string>;
23
27
  org?: Org;
24
28
  expiresAt?: number;
29
+ /** Set when the token is a staff token from `asteroid use <org> --reason`. */
30
+ staff?: { grantId: string; reason: string };
31
+ }
32
+
33
+ /** An Asteroid sign-in. It only decides which hints to show: the server checks the admin list. */
34
+ export function isStaffLogin(login: Login | undefined): boolean {
35
+ return login?.email?.toLowerCase().endsWith("@asteroid.ai") ?? false;
36
+ }
37
+
38
+ /** The `--reason` hint, for staff only. Empty for everyone else. */
39
+ export function staffHint(ref: string, staff: boolean): string {
40
+ return staff
41
+ ? ` Asteroid staff: run \`asteroid use ${clean(ref)} --reason <why>\`.`
42
+ : "";
43
+ }
44
+
45
+ function staffAuth(held: StaffToken): Auth {
46
+ return {
47
+ kind: "bearer",
48
+ headers: { Authorization: `Bearer ${held.token}` },
49
+ org: held.org,
50
+ expiresAt: held.expiresAt,
51
+ staff: { grantId: held.grantId, reason: held.reason },
52
+ };
25
53
  }
26
54
 
27
55
  export function orgFromToken(token: string): Org | undefined {
@@ -55,7 +83,7 @@ function bearer(token: string, expiresAt: number): Auth {
55
83
  const SSO_OR_MFA =
56
84
  /^(sso_required|mfa_.*|organization_authentication_methods_required)$/;
57
85
 
58
- function refreshError(error: WorkosError, org: Org): AuthError {
86
+ function refreshError(error: WorkosError, org: Org, login: Login): AuthError {
59
87
  if (error.code === "invalid_grant") {
60
88
  return new AuthError("your sign-in has expired. Run `asteroid login`.");
61
89
  }
@@ -65,8 +93,8 @@ function refreshError(error: WorkosError, org: Org): AuthError {
65
93
  );
66
94
  }
67
95
  if (error.code === "organization_membership_not_found") {
68
- return new AuthError(
69
- `you are not a member of ${orgLabel(org)}. Staff access to other organizations is not available yet.`,
96
+ return new NotMemberError(
97
+ `you are not a member of ${orgLabel(org)}.${staffHint(org.id ?? org.workosId, isStaffLogin(login))}`,
70
98
  );
71
99
  }
72
100
  return new AuthError(
@@ -84,16 +112,26 @@ export async function resolveAuth(
84
112
  return { kind: "api-key", headers: { "X-Api-Key": env.apiKey } };
85
113
 
86
114
  const store = new CredentialStore(env);
87
- if (!(await store.readLogin())) {
115
+ const login = await store.readLogin();
116
+ if (!login) {
88
117
  throw new AuthError(
89
118
  "not signed in. Run `asteroid login`, or set ASTEROID_API_KEY.",
90
119
  );
91
120
  }
92
121
 
122
+ const target = orgRef ?? config.org?.workosId;
123
+ const held = target ? await store.staffToken(target) : undefined;
124
+ if (held) {
125
+ if (held.expiresAt - 30_000 > Date.now()) return staffAuth(held);
126
+ throw new AuthError(
127
+ `your staff access to ${orgLabel(held.org)} has expired.${staffHint(held.org.id ?? held.org.workosId, true)}`,
128
+ );
129
+ }
130
+
93
131
  const org = orgRef ? findOrg(orgRef, config.orgs ?? []) : config.org;
94
132
  if (orgRef && !org) {
95
133
  throw new AuthError(
96
- `unknown organization ${orgRef}. Run \`asteroid orgs\` to list yours.`,
134
+ `unknown organization ${clean(orgRef)}. Run \`asteroid orgs\` to list yours.${staffHint(orgRef, isStaffLogin(login))}`,
97
135
  );
98
136
  }
99
137
  if (!org) {
@@ -134,7 +172,7 @@ export async function resolveAuth(
134
172
  rememberOrg(env, config, auth.org);
135
173
  return auth;
136
174
  } catch (error) {
137
- if (error instanceof WorkosError) throw refreshError(error, org);
175
+ if (error instanceof WorkosError) throw refreshError(error, org, login);
138
176
  throw error;
139
177
  }
140
178
  });
package/src/auth/store.ts CHANGED
@@ -2,12 +2,13 @@ import { mkdirSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import lockfile from "proper-lockfile";
4
4
 
5
- import { type Env, readJson, writePrivateJson } from "../config";
5
+ import { type Env, type Org, readJson, writePrivateJson } from "../config";
6
6
  import { WorkosError } from "./workos";
7
7
 
8
8
  const SERVICE = "asteroid-cli";
9
9
  const LOCK_STALE_MS = 30_000;
10
10
  const ACCOUNT = "refresh-token";
11
+ const STAFF_ACCOUNT = "staff-tokens";
11
12
 
12
13
  export interface Login {
13
14
  refreshToken: string;
@@ -19,17 +20,30 @@ export interface CachedToken {
19
20
  expiresAt: number;
20
21
  }
21
22
 
23
+ /** A staff token for one org that is not yours. It lasts 8 h and is audited. */
24
+ export interface StaffToken {
25
+ token: string;
26
+ grantId: string;
27
+ org: Org;
28
+ reason: string;
29
+ expiresAt: number;
30
+ /** The default org before `asteroid use` took staff access. */
31
+ from?: Org;
32
+ }
33
+
34
+ type StaffTokens = Record<string, StaffToken>;
35
+
22
36
  interface Keyring {
23
37
  get(): string | null;
24
38
  set(value: string): void;
25
39
  delete(): void;
26
40
  }
27
41
 
28
- async function keyring(): Promise<Keyring | undefined> {
42
+ async function keyring(account = ACCOUNT): Promise<Keyring | undefined> {
29
43
  if (process.env.ASTEROID_NO_KEYCHAIN) return undefined;
30
44
  try {
31
45
  const { Entry } = await import("@napi-rs/keyring");
32
- const entry = new Entry(SERVICE, ACCOUNT);
46
+ const entry = new Entry(SERVICE, account);
33
47
  entry.getPassword();
34
48
  return {
35
49
  get: () => entry.getPassword() ?? null,
@@ -72,6 +86,10 @@ export class CredentialStore {
72
86
  return join(this.env.configDir, "tokens.json");
73
87
  }
74
88
 
89
+ private get staffFile() {
90
+ return join(this.env.configDir, "staff-tokens.json");
91
+ }
92
+
75
93
  async readLogin(): Promise<Login | undefined> {
76
94
  const ring = await keyring();
77
95
  const raw = ring ? tryGet(ring).value : null;
@@ -102,23 +120,113 @@ export class CredentialStore {
102
120
  const cleared = current.ok && (!current.value || trySet(ring!, null));
103
121
  rmSync(this.loginFile, { force: true });
104
122
  rmSync(this.tokenFile, { force: true });
105
- return cleared;
123
+ const staffCleared = await this.withStaffLock(() =>
124
+ this.writeStaffTokens({}),
125
+ );
126
+ return staffCleared && cleared;
127
+ }
128
+
129
+ /** Every staff token held, expired ones included. */
130
+ async staffTokens(): Promise<StaffToken[]> {
131
+ const ring = await keyring(STAFF_ACCOUNT);
132
+ const raw = ring ? tryGet(ring).value : null;
133
+ const tokens = raw
134
+ ? (JSON.parse(raw) as StaffTokens)
135
+ : readJson<StaffTokens>(this.staffFile, {});
136
+ return Object.values(tokens);
137
+ }
138
+
139
+ /** The staff token for an org, matched by WorkOS id, id or name. */
140
+ async staffToken(ref: string): Promise<StaffToken | undefined> {
141
+ const lower = ref.toLowerCase();
142
+ return (await this.staffTokens()).find(
143
+ ({ org }) =>
144
+ org.workosId.toLowerCase() === lower ||
145
+ org.id?.toLowerCase() === lower ||
146
+ org.name?.toLowerCase() === lower,
147
+ );
148
+ }
149
+
150
+ /** Saves a staff token and drops expired ones. `replaces` is dropped too. */
151
+ async saveStaffToken(token: StaffToken, replaces?: string) {
152
+ await this.withStaffLock(async () => {
153
+ const tokens: StaffTokens = {};
154
+ for (const held of await this.staffTokens())
155
+ if (held.expiresAt > Date.now() && held.grantId !== replaces)
156
+ tokens[held.org.workosId] = held;
157
+ tokens[token.org.workosId] = token;
158
+ if (!(await this.writeStaffTokens(tokens)))
159
+ throw new WorkosError(
160
+ "keychain",
161
+ "could not save the staff token in the OS keychain. Unlock it and try again.",
162
+ );
163
+ });
164
+ }
165
+
166
+ async dropStaffToken(grantId: string) {
167
+ await this.withStaffLock(async () => {
168
+ const tokens: StaffTokens = {};
169
+ for (const held of await this.staffTokens())
170
+ if (held.grantId !== grantId) tokens[held.org.workosId] = held;
171
+ if (!(await this.writeStaffTokens(tokens)))
172
+ throw new WorkosError(
173
+ "keychain",
174
+ "could not remove the staff token from the OS keychain. Unlock it and try again.",
175
+ );
176
+ });
177
+ }
178
+
179
+ private async writeStaffTokens(tokens: StaffTokens): Promise<boolean> {
180
+ const empty = Object.keys(tokens).length === 0;
181
+ const ring = await keyring(STAFF_ACCOUNT);
182
+ if (ring && trySet(ring, empty ? null : JSON.stringify(tokens))) {
183
+ rmSync(this.staffFile, { force: true });
184
+ return true;
185
+ }
186
+ const stale = ring ? tryGet(ring) : { ok: true, value: null };
187
+ if (!stale.ok || (stale.value && !trySet(ring!, null))) return false;
188
+ if (empty) rmSync(this.staffFile, { force: true });
189
+ else writePrivateJson(this.staffFile, tokens);
190
+ return true;
106
191
  }
107
192
 
108
193
  async withRefreshLock<T>(work: () => Promise<T>): Promise<T> {
194
+ return this.withLock(
195
+ "refresh",
196
+ "another asteroid command is refreshing the sign-in.",
197
+ work,
198
+ );
199
+ }
200
+
201
+ /** Two commands at once must not lose a grant they could still revoke. */
202
+ private async withStaffLock<T>(work: () => Promise<T>): Promise<T> {
203
+ return this.withLock(
204
+ "staff",
205
+ "another asteroid command is saving staff access.",
206
+ work,
207
+ );
208
+ }
209
+
210
+ private async withLock<T>(
211
+ name: string,
212
+ busy: string,
213
+ work: () => Promise<T>,
214
+ ): Promise<T> {
109
215
  mkdirSync(this.env.configDir, { recursive: true, mode: 0o700 });
110
- const lockfilePath = join(this.env.configDir, "refresh.lock");
216
+ const lockfilePath = join(this.env.configDir, `${name}.lock`);
111
217
  let release: () => Promise<void>;
112
218
  try {
113
- release = await lockfile.lock(this.env.configDir, {
219
+ // Each lock names its own target: proper-lockfile tracks locks by target.
220
+ release = await lockfile.lock(join(this.env.configDir, name), {
114
221
  lockfilePath,
222
+ realpath: false,
115
223
  stale: LOCK_STALE_MS,
116
224
  retries: { retries: 60, minTimeout: 100, maxTimeout: 500 },
117
225
  });
118
226
  } catch {
119
227
  throw new WorkosError(
120
228
  "locked",
121
- `another asteroid command is refreshing the sign-in. Try again, or delete ${lockfilePath} if none is running.`,
229
+ `${busy} Try again, or delete ${lockfilePath} if none is running.`,
122
230
  );
123
231
  }
124
232
  try {