@asteroidai/cli 1.9.12 → 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/src/program.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Command, CommanderError } from "commander";
1
+ import { Command, CommanderError, Option as Flag } from "commander";
2
2
 
3
3
  import { type Parsed, UsageError } from "./args";
4
4
  import { type BuiltinCommand, GLOBAL_FLAGS } from "./reference";
@@ -146,6 +146,8 @@ export function defineCommands(
146
146
  for (const builtin of options.builtins ?? []) {
147
147
  const leaf = root.command(builtin.command).description(builtin.description);
148
148
  if (builtin.arg) leaf.argument(`[${builtin.arg}]`);
149
+ for (const [flags, text] of builtin.hiddenOptions ?? [])
150
+ leaf.addOption(new Flag(flags, text).hideHelp());
149
151
  leaf.action((...args: unknown[]) => {
150
152
  const cmd = args.at(-1) as Command;
151
153
  onInvocation({
package/src/reference.ts CHANGED
@@ -6,6 +6,8 @@ export interface BuiltinCommand {
6
6
  description: string;
7
7
  args: number;
8
8
  arg?: string;
9
+ /** Flags left out of `--help` and the reference: staff only. */
10
+ hiddenOptions?: [string, string][];
9
11
  }
10
12
 
11
13
  export const BUILTINS: BuiltinCommand[] = [
@@ -36,6 +38,16 @@ export const BUILTINS: BuiltinCommand[] = [
36
38
  description:
37
39
  "Make an organization, by id or name, the default for later commands. Without one, pick from a list.",
38
40
  args: 1,
41
+ hiddenOptions: [
42
+ [
43
+ "--reason <why>",
44
+ "Staff only. Why you need access to an organization you are not a member of, such as a ticket id. It is audited. Asked for in a terminal.",
45
+ ],
46
+ [
47
+ "--exit",
48
+ "Staff only. Revoke every staff token now and go back to your own organization.",
49
+ ],
50
+ ],
39
51
  },
40
52
  {
41
53
  command: "complete",
package/src/run.test.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { beforeEach, describe, expect, it } from "vitest";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  import { CredentialStore } from "./auth/store";
7
+ import { WorkosError } from "./auth/workos";
7
8
  import { ALL_COMMANDS } from "./commands";
8
9
  import { readEnv, saveConfig } from "./config";
9
10
  import { renderReference } from "./reference";
@@ -420,6 +421,225 @@ describe("workflow pull with a filter", () => {
420
421
  });
421
422
  });
422
423
 
424
+ describe("staff access", () => {
425
+ const own = { workosId: "org_OWN", id: "uuid-own", name: "Asteroid" };
426
+ const signIn = jwt({ exp: Date.now() / 1000 + 300, org_id: "org_OWN" });
427
+ const minted = {
428
+ token: "staff-token-1",
429
+ grantId: "grant-1",
430
+ organizationId: "uuid-acme",
431
+ organizationName: "Acme",
432
+ workosOrganizationId: "org_ACME",
433
+ reason: "SUP-412",
434
+ expiresAt: new Date(Date.now() + 8 * 3600_000).toISOString(),
435
+ };
436
+
437
+ async function signedIn(
438
+ respond: (call: Call) => Response,
439
+ email = "ed@asteroid.ai",
440
+ ) {
441
+ const h = harness({}, respond);
442
+ h.deps.env.workosApiUrl = "https://workos.test";
443
+ const store = new CredentialStore(h.deps.env);
444
+ await store.writeLogin({ refreshToken: "r1", email });
445
+ store.cacheToken("org_OWN", {
446
+ accessToken: signIn,
447
+ expiresAt: Date.now() + 300_000,
448
+ });
449
+ saveConfig(h.deps.env, { org: own, orgs: [own] });
450
+ return { ...h, store };
451
+ }
452
+
453
+ const auth = (call: Call) =>
454
+ (call.init.headers as Record<string, string>).Authorization;
455
+ const config = (dir: string) =>
456
+ JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
457
+
458
+ it("mints a token with the sign-in, then acts in the org with it", async () => {
459
+ const h = await signedIn((call) => {
460
+ if (call.url.endsWith("/users/v2/staff-tokens")) return json(minted, 201);
461
+ return json({ id: "w1", name: "New" }, 201);
462
+ });
463
+
464
+ expect(await run(["use", "uuid-acme", "--reason", "SUP-412"], h.deps)).toBe(
465
+ 0,
466
+ );
467
+ const mint = h.calls[0];
468
+ expect(mint.init.method).toBe("POST");
469
+ expect(auth(mint)).toBe(`Bearer ${signIn}`);
470
+ expect(JSON.parse(String(mint.init.body))).toEqual({
471
+ organization: "uuid-acme",
472
+ reason: "SUP-412",
473
+ });
474
+ expect(h.err.join("")).toContain("Staff access to Acme (uuid-acme)");
475
+ expect(h.err.join("")).toContain("audited");
476
+ expect(config(h.configDir).org).toEqual({
477
+ workosId: "org_ACME",
478
+ id: "uuid-acme",
479
+ name: "Acme",
480
+ });
481
+ expect(config(h.configDir).orgs).toEqual([own]);
482
+
483
+ expect(await run(["workflow", "create", "--name", "New"], h.deps)).toBe(0);
484
+ expect(auth(h.calls.at(-1)!)).toBe("Bearer staff-token-1");
485
+ expect(h.err.join("")).toContain(
486
+ "Acting in Acme (uuid-acme) with staff access (SUP-412).",
487
+ );
488
+ });
489
+
490
+ it("asks for a reason before it mints anything", async () => {
491
+ const h = await signedIn(() => json(minted, 201));
492
+ expect(await run(["use", "uuid-acme"], h.deps)).toBe(2);
493
+ expect(h.err.join("")).toContain("--reason <why>");
494
+ expect(h.calls).toHaveLength(0);
495
+ });
496
+
497
+ it("shows a customer no staff hint", async () => {
498
+ const h = await signedIn(
499
+ (call) =>
500
+ json(
501
+ {
502
+ error: "organization_membership_not_found",
503
+ error_description: "not a member",
504
+ },
505
+ 400,
506
+ ),
507
+ "pat@customer.com",
508
+ );
509
+ expect(await run(["use", "uuid-acme"], h.deps)).toBe(2);
510
+ expect(await run(["use", "org_ACME"], h.deps)).toBe(2);
511
+ expect(await run(["workflow", "list", "--org", "nope"], h.deps)).toBe(1);
512
+ const err = h.err.join("");
513
+ expect(err).toContain("you are not a member of org_ACME.");
514
+ expect(err).toContain("unknown organization nope.");
515
+ expect(err).not.toMatch(/staff|--reason|audited/);
516
+ expect(h.calls.some((c) => c.url.includes("staff-tokens"))).toBe(false);
517
+ });
518
+
519
+ it("takes the staff path when the refresh says you are not a member", async () => {
520
+ const h = await signedIn((call) => {
521
+ if (call.url.includes("workos"))
522
+ return json(
523
+ {
524
+ error: "organization_membership_not_found",
525
+ error_description: "not a member",
526
+ },
527
+ 400,
528
+ );
529
+ return json(minted, 201);
530
+ });
531
+ expect(await run(["use", "org_ACME"], h.deps)).toBe(2);
532
+ expect(h.err.join("")).toContain("you are not a member of org_ACME");
533
+
534
+ expect(await run(["use", "org_ACME", "--reason", "SUP-412"], h.deps)).toBe(
535
+ 0,
536
+ );
537
+ expect(h.calls.at(-1)!.url).toContain("/users/v2/staff-tokens");
538
+ });
539
+
540
+ it("tells a non-staff person that staff access is for staff", async () => {
541
+ const h = await signedIn(() => json({ error: "forbidden" }, 403));
542
+ expect(await run(["use", "uuid-acme", "--reason", "x"], h.deps)).toBe(1);
543
+ expect(h.err.join("")).toContain("staff access is for Asteroid staff only");
544
+ expect(await h.store.staffTokens()).toEqual([]);
545
+ });
546
+
547
+ it("uses a held staff token for one command with --org", async () => {
548
+ const h = await signedIn(() => json({ items: [] }));
549
+ await h.store.saveStaffToken({
550
+ ...minted,
551
+ org: { workosId: "org_ACME", id: "uuid-acme", name: "Acme" },
552
+ expiresAt: Date.now() + 3600_000,
553
+ });
554
+ expect(await run(["workflow", "list", "--org", "Acme"], h.deps)).toBe(0);
555
+ expect(auth(h.calls[0])).toBe("Bearer staff-token-1");
556
+ expect(await run(["workflow", "list"], h.deps)).toBe(0);
557
+ expect(auth(h.calls[1])).toBe(`Bearer ${signIn}`);
558
+ });
559
+
560
+ // resolveAuth treats a token within 30 s of its expiry as expired.
561
+ it("refuses a staff token in its last 30 s and says how to renew it", async () => {
562
+ const h = await signedIn(() => json({ items: [] }));
563
+ await h.store.saveStaffToken({
564
+ ...minted,
565
+ org: { workosId: "org_ACME", id: "uuid-acme", name: "Acme" },
566
+ expiresAt: Date.now() + 10_000,
567
+ });
568
+ expect(await run(["workflow", "list", "--org", "org_ACME"], h.deps)).toBe(
569
+ 1,
570
+ );
571
+ expect(h.err.join("")).toContain("has expired");
572
+ expect(h.calls).toHaveLength(0);
573
+ });
574
+
575
+ it("revokes every staff token on --exit and goes back to your org", async () => {
576
+ const h = await signedIn((call) => {
577
+ if (call.url.endsWith("/users/v2/staff-tokens")) return json(minted, 201);
578
+ return new Response(null, { status: 204 });
579
+ });
580
+ expect(await run(["use", "uuid-acme", "--reason", "SUP-412"], h.deps)).toBe(
581
+ 0,
582
+ );
583
+
584
+ expect(await run(["use", "--exit"], h.deps)).toBe(0);
585
+ const revoke = h.calls.at(-1)!;
586
+ expect(revoke.init.method).toBe("DELETE");
587
+ expect(revoke.url).toBe(
588
+ "https://odyssey.asteroid.ai/users/v2/staff-tokens/grant-1",
589
+ );
590
+ expect(auth(revoke)).toBe(`Bearer ${signIn}`);
591
+ expect(await h.store.staffTokens()).toEqual([]);
592
+ expect(config(h.configDir).org).toEqual(own);
593
+ expect(h.err.join("")).toContain("Now using Asteroid (uuid-own)");
594
+ });
595
+
596
+ it("keeps a staff token on --exit when the revoke fails", async () => {
597
+ const h = await signedIn(() => json({ message: "down" }, 500));
598
+ await h.store.saveStaffToken({
599
+ ...minted,
600
+ org: { workosId: "org_ACME", id: "uuid-acme", name: "Acme" },
601
+ expiresAt: Date.now() + 3600_000,
602
+ });
603
+ expect(await run(["use", "--exit"], h.deps)).toBe(1);
604
+ expect(await h.store.staffTokens()).toHaveLength(1);
605
+ expect(h.err.join("")).toContain("Run `asteroid use --exit` again");
606
+ });
607
+
608
+ it("revokes a minted token it could not save", async () => {
609
+ const h = await signedIn((call) => {
610
+ if (call.url.endsWith("/users/v2/staff-tokens")) return json(minted, 201);
611
+ return new Response(null, { status: 204 });
612
+ });
613
+ const save = vi
614
+ .spyOn(CredentialStore.prototype, "saveStaffToken")
615
+ .mockRejectedValue(new WorkosError("keychain", "keychain locked"));
616
+ try {
617
+ expect(
618
+ await run(["use", "uuid-acme", "--reason", "SUP-412"], h.deps),
619
+ ).toBe(1);
620
+ } finally {
621
+ save.mockRestore();
622
+ }
623
+ const revoke = h.calls.at(-1)!;
624
+ expect(revoke.init.method).toBe("DELETE");
625
+ expect(revoke.url).toContain("/users/v2/staff-tokens/grant-1");
626
+ });
627
+
628
+ it("refuses an option as the reason", async () => {
629
+ const h = await signedIn(() => json(minted, 201));
630
+ expect(await run(["use", "uuid-acme", "--reason", "--exit"], h.deps)).toBe(
631
+ 2,
632
+ );
633
+ expect(h.calls).toHaveLength(0);
634
+ });
635
+
636
+ it("rejects an org with --exit", async () => {
637
+ const h = await signedIn(() => json({}));
638
+ expect(await run(["use", "uuid-acme", "--exit"], h.deps)).toBe(2);
639
+ expect(h.calls).toHaveLength(0);
640
+ });
641
+ });
642
+
423
643
  describe("reference", () => {
424
644
  it("matches the generated agent-docs page", () => {
425
645
  const page = readFileSync(
package/src/run.ts CHANGED
@@ -62,7 +62,10 @@ async function runBuiltin(
62
62
  "run `asteroid complete <shell>` to print a completion script",
63
63
  );
64
64
  case "use":
65
- return use(deps, parsed.positionals[0]);
65
+ return use(deps, parsed.positionals[0], {
66
+ reason: parsed.flags.get("reason")?.[0],
67
+ exit: parsed.flags.has("exit"),
68
+ });
66
69
  default:
67
70
  return whoami(deps, parsed.org, parsed.json);
68
71
  }
@@ -103,7 +106,10 @@ function announce(command: CliCommand, auth: Auth, deps: Deps) {
103
106
  : auth.org
104
107
  ? orgLabel(auth.org)
105
108
  : "your default organization";
106
- deps.stderr(`Acting in ${org}.\n`);
109
+ const staff = auth.staff
110
+ ? ` with staff access (${clean(auth.staff.reason)})`
111
+ : "";
112
+ deps.stderr(`Acting in ${org}${staff}.\n`);
107
113
  }
108
114
 
109
115
  async function runCommand(
package/src/ui.ts CHANGED
@@ -7,6 +7,7 @@ export interface Choice {
7
7
 
8
8
  export interface Ui {
9
9
  select(message: string, choices: Choice[]): Promise<string | undefined>;
10
+ text(message: string): Promise<string | undefined>;
10
11
  wait<T>(message: string, done: string, work: Promise<T>): Promise<T>;
11
12
  }
12
13
 
@@ -15,6 +16,10 @@ export const clackUi: Ui = {
15
16
  const value = await clack.select({ message, options: choices });
16
17
  return clack.isCancel(value) ? undefined : value;
17
18
  },
19
+ async text(message) {
20
+ const value = await clack.text({ message });
21
+ return clack.isCancel(value) ? undefined : value;
22
+ },
18
23
  async wait(message, done, work) {
19
24
  const spinner = clack.spinner();
20
25
  spinner.start(message);