@indigoai-us/hq-cli 5.72.0 → 5.73.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.
@@ -73,6 +73,13 @@ export declare function listOutposts(token: string): Promise<OutpostSummary[]>;
73
73
  export declare function getOutpostStatus(token: string, outpostId?: string): Promise<Record<string, unknown>>;
74
74
  export declare function enableCodex(token: string, outpostId?: string): Promise<Record<string, unknown>>;
75
75
  export declare function regenerateLoginUrl(token: string, outpostId?: string): Promise<Record<string, unknown>>;
76
+ /**
77
+ * Hand the box the one-time Claude sign-in code the operator got from the login
78
+ * URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
79
+ * it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
80
+ * terminal-native equivalent of pasting the code into the web console.
81
+ */
82
+ export declare function submitLoginCode(token: string, code: string, outpostId?: string): Promise<Record<string, unknown>>;
76
83
  export declare function destroyOutpost(token: string, outpostId?: string): Promise<Record<string, unknown>>;
77
84
  /** Result of `POST /outpost/exec` — a terminal SSM invocation on the box. */
78
85
  export interface OutpostExecResult {
@@ -116,6 +116,21 @@ export async function regenerateLoginUrl(token, outpostId) {
116
116
  query: outpostId ? { outpostId } : undefined,
117
117
  });
118
118
  }
119
+ /**
120
+ * Hand the box the one-time Claude sign-in code the operator got from the login
121
+ * URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
122
+ * it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
123
+ * terminal-native equivalent of pasting the code into the web console.
124
+ */
125
+ export async function submitLoginCode(token, code, outpostId) {
126
+ return outpostRequest({
127
+ token,
128
+ path: "/outpost/login-code",
129
+ method: "POST",
130
+ body: { code },
131
+ query: outpostId ? { outpostId } : undefined,
132
+ });
133
+ }
119
134
  export async function destroyOutpost(token, outpostId) {
120
135
  return outpostRequest({
121
136
  token,
@@ -1011,8 +1026,40 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
1011
1026
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1012
1027
  return;
1013
1028
  }
1014
- console.log(chalk.green("Login-URL regeneration requested. The box mints a fresh URL shortly" +
1015
- "check `hq outposts status` to pick it up."));
1029
+ console.log(chalk.green("Login-URL regeneration requested. The box mints a fresh URL shortly."));
1030
+ console.log(chalk.dim("Next: `hq outposts status" +
1031
+ (opts.id ? ` --id ${opts.id}` : "") +
1032
+ "` to read the login URL, open it and sign in, then paste the code back with " +
1033
+ "`hq outposts login-code <code>" +
1034
+ (opts.id ? ` --id ${opts.id}` : "") +
1035
+ "`."));
1036
+ }
1037
+ catch (err) {
1038
+ fail(err);
1039
+ }
1040
+ });
1041
+ outposts
1042
+ .command("login-code <code>")
1043
+ .description("Submit the Claude sign-in code for an Outpost that is awaiting login")
1044
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1045
+ .option("--json", "Emit raw JSON")
1046
+ .action(async function (code, opts) {
1047
+ try {
1048
+ const trimmed = code.trim();
1049
+ if (!trimmed) {
1050
+ console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
1051
+ process.exit(1);
1052
+ }
1053
+ const token = await ensureCognitoToken();
1054
+ const result = await submitLoginCode(token, trimmed, opts.id);
1055
+ if (opts.json) {
1056
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1057
+ return;
1058
+ }
1059
+ console.log(chalk.green("Code submitted — the box will finish signing in shortly."));
1060
+ console.log(chalk.dim("Track it: `hq outposts status" +
1061
+ (opts.id ? ` --id ${opts.id}` : "") +
1062
+ "` (it flips to `ready` once Claude auth completes)."));
1016
1063
  }
1017
1064
  catch (err) {
1018
1065
  fail(err);
package/dist/main.js CHANGED
@@ -60,6 +60,7 @@ import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
60
60
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
61
61
  import { isEpipe } from "./utils/epipe.js";
62
62
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
63
+ import { isCompanySelectionError } from "./utils/company-selection-error.js";
63
64
  import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
64
65
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
65
66
  import { CLI_VERSION } from "./cli-version.js";
@@ -253,6 +254,18 @@ export async function runCli() {
253
254
  // degradation) and preserve the intended non-zero exit (HQ-CLI-3).
254
255
  process.exitCode = 1;
255
256
  }
257
+ else if (isCompanySelectionError(err)) {
258
+ // The user has multiple (or zero) active company memberships and ran a
259
+ // command that needs exactly one without `--company`, or a `--company`
260
+ // slug collided across companies. That's an expected, user-actionable
261
+ // disambiguation prompt — the message already tells them exactly how to
262
+ // proceed (re-run with `--company <slug-or-uid>`) — not an hq-cli defect.
263
+ // The CLI can't pick a company for them. Print the actionable message and
264
+ // exit non-zero, but skip Sentry capture so a normal "pick a company"
265
+ // prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
266
+ process.stderr.write(`hq: ${err.message}\n`);
267
+ process.exitCode = 1;
268
+ }
256
269
  else {
257
270
  // A full disk / exhausted quota / read-only filesystem is the user's
258
271
  // machine, not an HQ code defect. Surface a clear, actionable message and
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Thrown when the CLI cannot resolve a single company on the user's behalf and
3
+ * the user must re-run with `--company <slug-or-uid>`:
4
+ * - they have multiple active memberships and passed no `--company`,
5
+ * - they have no active membership and passed no `--company`, or
6
+ * - a `--company` slug collides across companies, none in their namespace.
7
+ *
8
+ * The `message` is already user-facing and actionable — the top-level handler
9
+ * prints it verbatim and skips Sentry capture.
10
+ */
11
+ export declare class CompanySelectionError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ /**
15
+ * True when `err` is a company-selection disambiguation prompt the user must
16
+ * resolve with `--company`. Callers should print `err.message` and SKIP Sentry
17
+ * capture (expected usage, no defect) while preserving a non-zero exit. Genuine
18
+ * faults are plain `Error`s and return `false`, so real bugs still report.
19
+ */
20
+ export declare function isCompanySelectionError(err: unknown): boolean;
21
+ //# sourceMappingURL=company-selection-error.d.ts.map
@@ -0,0 +1,44 @@
1
+ // src/utils/company-selection-error.ts
2
+ //
3
+ // Classify the "the caller must pick a company with --company" conditions
4
+ // (HQ-CLI-7). These are expected, user-actionable disambiguation prompts —
5
+ // NOT hq-cli defects — so the top-level catch prints the message and exits
6
+ // non-zero but SKIPS Sentry capture, mirroring the EPIPE (HQ-6B),
7
+ // intercepted-process-exit (HQ-CLI-3), and environmental-FS (HQ-CLI-2)
8
+ // carve-outs.
9
+ //
10
+ // HQ-CLI-7: a user with THREE active company memberships ran `hq integrations`
11
+ // with no `--company`. `resolveCompanyFromMemberships` correctly threw
12
+ // "Multiple active companies found. Re-run with --company <slug-or-uid>…" —
13
+ // the message literally tells the user how to proceed — but it propagated to
14
+ // the CLI's top-level handler as a plain Error and was shipped to Sentry as a
15
+ // fatal. The command needs the human to disambiguate; the code cannot pick a
16
+ // company for them, so this is normal usage, not a crash to triage.
17
+ /**
18
+ * Thrown when the CLI cannot resolve a single company on the user's behalf and
19
+ * the user must re-run with `--company <slug-or-uid>`:
20
+ * - they have multiple active memberships and passed no `--company`,
21
+ * - they have no active membership and passed no `--company`, or
22
+ * - a `--company` slug collides across companies, none in their namespace.
23
+ *
24
+ * The `message` is already user-facing and actionable — the top-level handler
25
+ * prints it verbatim and skips Sentry capture.
26
+ */
27
+ export class CompanySelectionError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = "CompanySelectionError";
31
+ // Preserve `instanceof` across the TS→ES5/ES2015 transpile target.
32
+ Object.setPrototypeOf(this, CompanySelectionError.prototype);
33
+ }
34
+ }
35
+ /**
36
+ * True when `err` is a company-selection disambiguation prompt the user must
37
+ * resolve with `--company`. Callers should print `err.message` and SKIP Sentry
38
+ * capture (expected usage, no defect) while preserving a non-zero exit. Genuine
39
+ * faults are plain `Error`s and return `false`, so real bugs still report.
40
+ */
41
+ export function isCompanySelectionError(err) {
42
+ return err instanceof CompanySelectionError;
43
+ }
44
+ //# sourceMappingURL=company-selection-error.js.map
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
2
2
  import { Sentry } from '../sentry.js';
3
+ import { CompanySelectionError } from './company-selection-error.js';
3
4
  export async function vaultApiFetch(opts) {
4
5
  const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
5
6
  if (opts.query) {
@@ -158,7 +159,7 @@ async function resolveCompanyUid(token, ref) {
158
159
  // tell them exactly how — re-run with `--company <uid>` — and list the
159
160
  // candidates, instead of echoing the generic server message.
160
161
  if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
161
- throw new Error(`Company slug '${ref}' matches ${body.uids.length} companies and none ` +
162
+ throw new CompanySelectionError(`Company slug '${ref}' matches ${body.uids.length} companies and none ` +
162
163
  `is in your namespace. Re-run with --company <uid> to pick one:\n` +
163
164
  body.uids.map((u) => ` --company ${u}`).join('\n'));
164
165
  }
@@ -178,13 +179,13 @@ async function resolveCompanyFromMemberships(token) {
178
179
  const data = (await res.json());
179
180
  const active = data.memberships.filter((m) => m.status === 'active');
180
181
  if (active.length === 0) {
181
- throw new Error('No active company memberships found. Use --company <slug> to specify.');
182
+ throw new CompanySelectionError('No active company memberships found. Use --company <slug> to specify.');
182
183
  }
183
184
  if (active.length === 1) {
184
185
  return active[0].companyUid;
185
186
  }
186
187
  const uids = active.map((m) => m.companyUid);
187
- throw new Error(`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
188
+ throw new CompanySelectionError(`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
188
189
  `pick one:\n` +
189
190
  uids.map((u) => ` --company ${u}`).join('\n'));
190
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.72.0",
3
+ "version": "5.73.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "clean": "rm -rf dist"
21
21
  },
22
22
  "dependencies": {
23
+ "@aws-sdk/client-s3": "^3.1049.0",
23
24
  "@indigoai-us/hq-cloud": "^6.14.4",
24
25
  "@indigoai-us/hq-onboarding": "^0.1.0",
25
26
  "@sentry/node": "^10.49.0",
@@ -34,7 +35,6 @@
34
35
  "varlock": "1.0.0"
35
36
  },
36
37
  "devDependencies": {
37
- "@aws-sdk/client-s3": "^3.1049.0",
38
38
  "@eslint/js": "^10.0.1",
39
39
  "@types/better-sqlite3": "^7.6.13",
40
40
  "@types/js-yaml": "^4.0.9",
@@ -171,6 +171,38 @@ describe("hq outposts codex-enable / login", () => {
171
171
  expect(String(url)).toContain("/outpost/regenerate-login-url");
172
172
  expect(init?.method).toBe("POST");
173
173
  });
174
+
175
+ it("login-code POSTs /outpost/login-code with the code in the body", async () => {
176
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
177
+ await run(["outposts", "login-code", "ABC-123"]);
178
+ const [url, init] = fetchSpy.mock.calls[0];
179
+ expect(String(url)).toContain("/outpost/login-code");
180
+ expect(init?.method).toBe("POST");
181
+ expect(JSON.parse(String(init?.body))).toEqual({ code: "ABC-123" });
182
+ });
183
+
184
+ it("login-code targets a specific box via --id (outpostId query)", async () => {
185
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
186
+ await run(["outposts", "login-code", "CODE", "--id", "3"]);
187
+ const [url] = fetchSpy.mock.calls[0];
188
+ expect(String(url)).toContain("outpostId=3");
189
+ });
190
+
191
+ it("login-code trims whitespace and refuses an empty code without calling the API", async () => {
192
+ await expect(run(["outposts", "login-code", " "])).rejects.toThrow(
193
+ "process.exit(1)",
194
+ );
195
+ expect(fetchSpy).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("login-code surfaces an awaiting-login not-found error", async () => {
199
+ fetchSpy.mockResolvedValueOnce(
200
+ jsonResponse(404, { error: true, step: "not-found", message: "no outpost" }),
201
+ );
202
+ await expect(run(["outposts", "login-code", "CODE"])).rejects.toThrow(
203
+ "process.exit(1)",
204
+ );
205
+ });
174
206
  });
175
207
 
176
208
  describe("hq outposts destroy", () => {
@@ -182,6 +182,26 @@ export async function regenerateLoginUrl(
182
182
  });
183
183
  }
184
184
 
185
+ /**
186
+ * Hand the box the one-time Claude sign-in code the operator got from the login
187
+ * URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
188
+ * it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
189
+ * terminal-native equivalent of pasting the code into the web console.
190
+ */
191
+ export async function submitLoginCode(
192
+ token: string,
193
+ code: string,
194
+ outpostId?: string,
195
+ ): Promise<Record<string, unknown>> {
196
+ return outpostRequest({
197
+ token,
198
+ path: "/outpost/login-code",
199
+ method: "POST",
200
+ body: { code },
201
+ query: outpostId ? { outpostId } : undefined,
202
+ });
203
+ }
204
+
185
205
  export async function destroyOutpost(
186
206
  token: string,
187
207
  outpostId?: string,
@@ -1450,8 +1470,58 @@ export function registerOutpostsCommand(
1450
1470
  }
1451
1471
  console.log(
1452
1472
  chalk.green(
1453
- "Login-URL regeneration requested. The box mints a fresh URL shortly" +
1454
- "check `hq outposts status` to pick it up.",
1473
+ "Login-URL regeneration requested. The box mints a fresh URL shortly.",
1474
+ ),
1475
+ );
1476
+ console.log(
1477
+ chalk.dim(
1478
+ "Next: `hq outposts status" +
1479
+ (opts.id ? ` --id ${opts.id}` : "") +
1480
+ "` to read the login URL, open it and sign in, then paste the code back with " +
1481
+ "`hq outposts login-code <code>" +
1482
+ (opts.id ? ` --id ${opts.id}` : "") +
1483
+ "`.",
1484
+ ),
1485
+ );
1486
+ } catch (err) {
1487
+ fail(err);
1488
+ }
1489
+ });
1490
+
1491
+ outposts
1492
+ .command("login-code <code>")
1493
+ .description(
1494
+ "Submit the Claude sign-in code for an Outpost that is awaiting login",
1495
+ )
1496
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1497
+ .option("--json", "Emit raw JSON")
1498
+ .action(async function (
1499
+ this: Command,
1500
+ code: string,
1501
+ opts: { id?: string; json?: boolean },
1502
+ ) {
1503
+ try {
1504
+ const trimmed = code.trim();
1505
+ if (!trimmed) {
1506
+ console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
1507
+ process.exit(1);
1508
+ }
1509
+ const token = await ensureCognitoToken();
1510
+ const result = await submitLoginCode(token, trimmed, opts.id);
1511
+ if (opts.json) {
1512
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1513
+ return;
1514
+ }
1515
+ console.log(
1516
+ chalk.green(
1517
+ "Code submitted — the box will finish signing in shortly.",
1518
+ ),
1519
+ );
1520
+ console.log(
1521
+ chalk.dim(
1522
+ "Track it: `hq outposts status" +
1523
+ (opts.id ? ` --id ${opts.id}` : "") +
1524
+ "` (it flips to `ready` once Claude auth completes).",
1455
1525
  ),
1456
1526
  );
1457
1527
  } catch (err) {
package/src/main.ts CHANGED
@@ -62,6 +62,7 @@ import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
62
62
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
63
63
  import { isEpipe } from "./utils/epipe.js";
64
64
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
65
+ import { isCompanySelectionError } from "./utils/company-selection-error.js";
65
66
  import {
66
67
  maybeWarnNewVersion,
67
68
  refreshVersionCache,
@@ -303,6 +304,17 @@ export async function runCli(): Promise<void> {
303
304
  // thrown or captured. Skip Sentry capture (no signal, no user-facing
304
305
  // degradation) and preserve the intended non-zero exit (HQ-CLI-3).
305
306
  process.exitCode = 1;
307
+ } else if (isCompanySelectionError(err)) {
308
+ // The user has multiple (or zero) active company memberships and ran a
309
+ // command that needs exactly one without `--company`, or a `--company`
310
+ // slug collided across companies. That's an expected, user-actionable
311
+ // disambiguation prompt — the message already tells them exactly how to
312
+ // proceed (re-run with `--company <slug-or-uid>`) — not an hq-cli defect.
313
+ // The CLI can't pick a company for them. Print the actionable message and
314
+ // exit non-zero, but skip Sentry capture so a normal "pick a company"
315
+ // prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
316
+ process.stderr.write(`hq: ${(err as Error).message}\n`);
317
+ process.exitCode = 1;
306
318
  } else {
307
319
  // A full disk / exhausted quota / read-only filesystem is the user's
308
320
  // machine, not an HQ code defect. Surface a clear, actionable message and
@@ -18,6 +18,8 @@ const pkg = JSON.parse(
18
18
  readFileSync(resolve(repoRoot, "package.json"), "utf8"),
19
19
  ) as {
20
20
  bin?: Record<string, string> | string;
21
+ dependencies?: Record<string, string>;
22
+ devDependencies?: Record<string, string>;
21
23
  scripts?: Record<string, string>;
22
24
  };
23
25
 
@@ -53,3 +55,10 @@ describe("packaging: bin executable bit", () => {
53
55
  }
54
56
  });
55
57
  });
58
+
59
+ describe("packaging: runtime dependencies", () => {
60
+ it("ships the S3 client imported by files-browse", () => {
61
+ expect(pkg.dependencies).toHaveProperty("@aws-sdk/client-s3");
62
+ expect(pkg.devDependencies).not.toHaveProperty("@aws-sdk/client-s3");
63
+ });
64
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ CompanySelectionError,
4
+ isCompanySelectionError,
5
+ } from "./company-selection-error.js";
6
+
7
+ describe("isCompanySelectionError", () => {
8
+ // HQ-CLI-7: the exact error that flooded Sentry when a user with multiple
9
+ // active memberships ran a command with no --company.
10
+ it("classifies a CompanySelectionError as a selection prompt (skip Sentry)", () => {
11
+ const err = new CompanySelectionError(
12
+ "Multiple active companies found. Re-run with --company <slug-or-uid> to pick one:\n --company cmp_a\n --company cmp_b",
13
+ );
14
+ expect(isCompanySelectionError(err)).toBe(true);
15
+ });
16
+
17
+ it("preserves the user-facing message verbatim for the top-level handler", () => {
18
+ const msg = "No active company memberships found. Use --company <slug> to specify.";
19
+ expect(new CompanySelectionError(msg).message).toBe(msg);
20
+ });
21
+
22
+ it("keeps instanceof across the transpile target", () => {
23
+ const err = new CompanySelectionError("pick one");
24
+ expect(err).toBeInstanceOf(CompanySelectionError);
25
+ expect(err).toBeInstanceOf(Error);
26
+ expect(err.name).toBe("CompanySelectionError");
27
+ });
28
+
29
+ // A genuine defect must still reach Sentry — only the disambiguation class is
30
+ // diverted, so real bugs are never silently swallowed.
31
+ it("does NOT match a plain Error (so real faults still report)", () => {
32
+ expect(isCompanySelectionError(new Error("Multiple active companies found."))).toBe(false);
33
+ expect(isCompanySelectionError(new Error("boom"))).toBe(false);
34
+ });
35
+
36
+ it("does NOT match non-error values", () => {
37
+ expect(isCompanySelectionError(null)).toBe(false);
38
+ expect(isCompanySelectionError(undefined)).toBe(false);
39
+ expect(isCompanySelectionError("Multiple active companies found.")).toBe(false);
40
+ expect(isCompanySelectionError({ message: "pick one" })).toBe(false);
41
+ });
42
+ });
@@ -0,0 +1,45 @@
1
+ // src/utils/company-selection-error.ts
2
+ //
3
+ // Classify the "the caller must pick a company with --company" conditions
4
+ // (HQ-CLI-7). These are expected, user-actionable disambiguation prompts —
5
+ // NOT hq-cli defects — so the top-level catch prints the message and exits
6
+ // non-zero but SKIPS Sentry capture, mirroring the EPIPE (HQ-6B),
7
+ // intercepted-process-exit (HQ-CLI-3), and environmental-FS (HQ-CLI-2)
8
+ // carve-outs.
9
+ //
10
+ // HQ-CLI-7: a user with THREE active company memberships ran `hq integrations`
11
+ // with no `--company`. `resolveCompanyFromMemberships` correctly threw
12
+ // "Multiple active companies found. Re-run with --company <slug-or-uid>…" —
13
+ // the message literally tells the user how to proceed — but it propagated to
14
+ // the CLI's top-level handler as a plain Error and was shipped to Sentry as a
15
+ // fatal. The command needs the human to disambiguate; the code cannot pick a
16
+ // company for them, so this is normal usage, not a crash to triage.
17
+
18
+ /**
19
+ * Thrown when the CLI cannot resolve a single company on the user's behalf and
20
+ * the user must re-run with `--company <slug-or-uid>`:
21
+ * - they have multiple active memberships and passed no `--company`,
22
+ * - they have no active membership and passed no `--company`, or
23
+ * - a `--company` slug collides across companies, none in their namespace.
24
+ *
25
+ * The `message` is already user-facing and actionable — the top-level handler
26
+ * prints it verbatim and skips Sentry capture.
27
+ */
28
+ export class CompanySelectionError extends Error {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = "CompanySelectionError";
32
+ // Preserve `instanceof` across the TS→ES5/ES2015 transpile target.
33
+ Object.setPrototypeOf(this, CompanySelectionError.prototype);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * True when `err` is a company-selection disambiguation prompt the user must
39
+ * resolve with `--company`. Callers should print `err.message` and SKIP Sentry
40
+ * capture (expected usage, no defect) while preserving a non-zero exit. Genuine
41
+ * faults are plain `Error`s and return `false`, so real bugs still report.
42
+ */
43
+ export function isCompanySelectionError(err: unknown): boolean {
44
+ return err instanceof CompanySelectionError;
45
+ }
@@ -5,7 +5,8 @@ vi.mock('../sentry.js', () => ({
5
5
  }));
6
6
 
7
7
  import { Sentry } from '../sentry.js';
8
- import { getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
8
+ import { getCompanyUid, getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
9
+ import { isCompanySelectionError } from './company-selection-error.js';
9
10
 
10
11
  const fetchMock = vi.fn();
11
12
  const originalFetch = globalThis.fetch;
@@ -199,6 +200,76 @@ describe('getEntityUid', () => {
199
200
  });
200
201
  });
201
202
 
203
+ describe('getCompanyUid company-selection classification (HQ-CLI-7)', () => {
204
+ // The exact production scenario: a user with multiple active memberships runs
205
+ // a command with no --company. The prompt is expected, user-actionable guidance
206
+ // — it must be a CompanySelectionError so the top-level handler prints it and
207
+ // SKIPS Sentry capture, not a plain Error that gets shipped as a fatal.
208
+ it('throws a CompanySelectionError (not a plain Error) on multiple active memberships', async () => {
209
+ fetchMock.mockResolvedValueOnce(
210
+ mockResponse(200, {
211
+ memberships: [
212
+ { companyUid: 'cmp_a', role: 'member', status: 'active', membershipKey: 'k1' },
213
+ { companyUid: 'cmp_b', role: 'owner', status: 'active', membershipKey: 'k2' },
214
+ { companyUid: 'cmp_c', role: 'member', status: 'active', membershipKey: 'k3' },
215
+ ],
216
+ }),
217
+ );
218
+ const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
219
+ expect(isCompanySelectionError(err)).toBe(true);
220
+ expect((err as Error).message).toMatch(/Multiple active companies found/);
221
+ expect((err as Error).message).toMatch(/--company cmp_a/);
222
+ });
223
+
224
+ it('throws a CompanySelectionError when the caller has no active memberships', async () => {
225
+ fetchMock.mockResolvedValueOnce(
226
+ mockResponse(200, {
227
+ memberships: [
228
+ { companyUid: 'cmp_x', role: 'member', status: 'invited', membershipKey: 'k' },
229
+ ],
230
+ }),
231
+ );
232
+ const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
233
+ expect(isCompanySelectionError(err)).toBe(true);
234
+ expect((err as Error).message).toMatch(/No active company memberships/);
235
+ });
236
+
237
+ it('resolves silently to the single active membership (no selection error)', async () => {
238
+ fetchMock.mockResolvedValueOnce(
239
+ mockResponse(200, {
240
+ memberships: [
241
+ { companyUid: 'cmp_only', role: 'member', status: 'active', membershipKey: 'k' },
242
+ ],
243
+ }),
244
+ );
245
+ const uid = await getCompanyUid('tok', undefined);
246
+ expect(uid).toBe('cmp_only');
247
+ });
248
+
249
+ it('classifies the slug-collision (409, none in namespace) case as a selection error', async () => {
250
+ fetchMock
251
+ .mockResolvedValueOnce(mockResponse(200, { available: true }))
252
+ .mockResolvedValueOnce(
253
+ mockResponse(409, {
254
+ error: 'Slug "acme" matches 2 live entities',
255
+ uids: ['cmp_one', 'cmp_two'],
256
+ }),
257
+ );
258
+ const err = await getCompanyUid('tok', 'acme').catch((e: unknown) => e);
259
+ expect(isCompanySelectionError(err)).toBe(true);
260
+ expect((err as Error).message).toMatch(/--company cmp_one/);
261
+ });
262
+
263
+ // A genuine fault must NOT be classified as a selection prompt — it still
264
+ // reports to Sentry. Guards against over-broadening the carve-out.
265
+ it('does NOT classify a failed membership fetch as a selection error (still reports)', async () => {
266
+ fetchMock.mockResolvedValueOnce(mockResponse(401, { error: 'unauthorized' }));
267
+ const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
268
+ expect(isCompanySelectionError(err)).toBe(false);
269
+ expect((err as Error).message).toMatch(/Failed to fetch memberships/);
270
+ });
271
+ });
272
+
202
273
  describe('vaultApiFetch breadcrumb URL sanitization', () => {
203
274
  it('redacts query string in request breadcrumb data.url', async () => {
204
275
  fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
2
2
  import { Sentry } from '../sentry.js';
3
+ import { CompanySelectionError } from './company-selection-error.js';
3
4
 
4
5
  export interface VaultApiOptions {
5
6
  token: string;
@@ -197,7 +198,7 @@ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
197
198
  // tell them exactly how — re-run with `--company <uid>` — and list the
198
199
  // candidates, instead of echoing the generic server message.
199
200
  if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
200
- throw new Error(
201
+ throw new CompanySelectionError(
201
202
  `Company slug '${ref}' matches ${body.uids.length} companies and none ` +
202
203
  `is in your namespace. Re-run with --company <uid> to pick one:\n` +
203
204
  body.uids.map((u) => ` --company ${u}`).join('\n'),
@@ -222,13 +223,13 @@ async function resolveCompanyFromMemberships(token: string): Promise<string> {
222
223
  const data = (await res.json()) as { memberships: MembershipEntry[] };
223
224
  const active = data.memberships.filter((m) => m.status === 'active');
224
225
  if (active.length === 0) {
225
- throw new Error('No active company memberships found. Use --company <slug> to specify.');
226
+ throw new CompanySelectionError('No active company memberships found. Use --company <slug> to specify.');
226
227
  }
227
228
  if (active.length === 1) {
228
229
  return active[0].companyUid;
229
230
  }
230
231
  const uids = active.map((m) => m.companyUid);
231
- throw new Error(
232
+ throw new CompanySelectionError(
232
233
  `Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
233
234
  `pick one:\n` +
234
235
  uids.map((u) => ` --company ${u}`).join('\n'),