@indigoai-us/hq-cli 5.73.0 → 5.74.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.
@@ -34,6 +34,7 @@ vi.mock("../utils/vault-api.js", async (importOriginal) => {
34
34
  });
35
35
 
36
36
  import { vaultApiFetch } from "../utils/vault-api.js";
37
+ import { AuthError, isAuthError } from "../utils/auth-error.js";
37
38
  import {
38
39
  IntegrationsCliError,
39
40
  queuedOutcome,
@@ -258,6 +259,16 @@ describe("hq integrations call", () => {
258
259
  ).rejects.toThrow(/--args must be a JSON object/);
259
260
  expect(vaultApiFetchMock).not.toHaveBeenCalled();
260
261
  });
262
+
263
+ it("marks non-object --args as expected", async () => {
264
+ try {
265
+ await runCli(["integrations", "call", "t", "--provider", "linear", "--args", "[1]"]);
266
+ throw new Error("expected runCli to throw");
267
+ } catch (err) {
268
+ expect(err).toBeInstanceOf(IntegrationsCliError);
269
+ expect((err as IntegrationsCliError).expected).toBe(true);
270
+ }
271
+ });
261
272
  });
262
273
 
263
274
  describe("hq integrations approve", () => {
@@ -282,3 +293,223 @@ describe("hq integrations approve", () => {
282
293
  expect(logged()).toContain("Approved");
283
294
  });
284
295
  });
296
+
297
+ describe("expected integrations errors", () => {
298
+ const linear = {
299
+ id: "acct_1",
300
+ provider: "factory:linear",
301
+ status: "connected",
302
+ };
303
+ const notion = {
304
+ id: "acct_2",
305
+ provider: "factory:notion",
306
+ status: "connected",
307
+ };
308
+
309
+ it("marks a non-owner 403 approve as expected (skips Sentry) and preserves the message", async () => {
310
+ vaultApiFetchMock
311
+ .mockResolvedValueOnce(connectionsResponse())
312
+ .mockResolvedValueOnce(
313
+ jsonResponse(
314
+ { error: "Only a company owner can approve or reject queued integration writes" },
315
+ 403,
316
+ ),
317
+ );
318
+
319
+ try {
320
+ await runCli(["integrations", "approve", "cq_123", "--provider", "linear"]);
321
+ throw new Error("expected runCli to throw");
322
+ } catch (err) {
323
+ expect(err).toBeInstanceOf(IntegrationsCliError);
324
+ expect((err as IntegrationsCliError).expected).toBe(true);
325
+ expect((err as Error).message).toContain("Only a company owner");
326
+ }
327
+ });
328
+
329
+ it("still captures a genuine server 500 on approve (expected === false)", async () => {
330
+ vaultApiFetchMock
331
+ .mockResolvedValueOnce(connectionsResponse())
332
+ .mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500));
333
+
334
+ try {
335
+ await runCli(["integrations", "approve", "cq_123", "--provider", "linear"]);
336
+ throw new Error("expected runCli to throw");
337
+ } catch (err) {
338
+ expect(err).toBeInstanceOf(IntegrationsCliError);
339
+ expect((err as IntegrationsCliError).expected).toBe(false);
340
+ }
341
+ });
342
+
343
+ it("marks reject 403 as expected too", async () => {
344
+ vaultApiFetchMock
345
+ .mockResolvedValueOnce(connectionsResponse())
346
+ .mockResolvedValueOnce(
347
+ jsonResponse(
348
+ { error: "Only a company owner can approve or reject queued integration writes" },
349
+ 403,
350
+ ),
351
+ );
352
+
353
+ try {
354
+ await runCli(["integrations", "reject", "cq_123", "--provider", "linear"]);
355
+ throw new Error("expected runCli to throw");
356
+ } catch (err) {
357
+ expect(err).toBeInstanceOf(IntegrationsCliError);
358
+ expect((err as IntegrationsCliError).expected).toBe(true);
359
+ }
360
+
361
+ expect(vaultApiFetchMock.mock.calls[1]![0].path).toBe(
362
+ "/v1/integrations/confirm/cq_123/reject",
363
+ );
364
+ });
365
+
366
+ it("marks local connection selection usage errors as expected", () => {
367
+ for (const fn of [
368
+ () => selectConnection([], {}),
369
+ () => selectConnection([linear, notion], {}),
370
+ () => selectConnection([linear], { provider: "jira" }),
371
+ ]) {
372
+ try {
373
+ fn();
374
+ throw new Error("expected selectConnection to throw");
375
+ } catch (err) {
376
+ expect(err).toBeInstanceOf(IntegrationsCliError);
377
+ expect((err as IntegrationsCliError).expected).toBe(true);
378
+ }
379
+ }
380
+ });
381
+ });
382
+
383
+ // HQ-CLI-9: `hq integrations call get_board_info --provider monday …` hit a 401
384
+ // on POST /v1/integrations/mcp while the caller's HQ session was rejected. The
385
+ // old `callGateway` threw a plain, opaque `IntegrationsCliError("Integration
386
+ // gateway request failed (HTTP 401).")` that shipped to Sentry as an
387
+ // unactionable fatal. A gateway 401 is an expired/missing session — the same
388
+ // expected auth state the vault company-resolution paths already raise as
389
+ // `AuthError` (HQ-CLI-8). The fix routes every integration-gateway 401 through
390
+ // that typed AuthError so the user gets one actionable "run `hq login`" message
391
+ // and Sentry is skipped, while genuine 4xx/5xx faults keep their behavior.
392
+ describe("integration gateway 401 → AuthError (HQ-CLI-9)", () => {
393
+ it("throws an actionable AuthError (not an opaque IntegrationsCliError) when callGateway 401s", async () => {
394
+ vaultApiFetchMock
395
+ .mockResolvedValueOnce(connectionsResponse())
396
+ .mockResolvedValueOnce(jsonResponse({ error: "Unauthorized" }, 401));
397
+
398
+ const err = await runCli([
399
+ "integrations",
400
+ "call",
401
+ "get_board_info",
402
+ "--provider",
403
+ "linear",
404
+ "--args",
405
+ "{}",
406
+ ]).then(
407
+ () => {
408
+ throw new Error("expected runCli to throw");
409
+ },
410
+ (e: unknown) => e,
411
+ );
412
+
413
+ expect(isAuthError(err)).toBe(true);
414
+ expect(err).toBeInstanceOf(AuthError);
415
+ expect((err as Error).message).toMatch(/hq login/);
416
+ // The gateway request was actually attempted (connections + gateway call).
417
+ expect(vaultApiFetchMock.mock.calls[1]![0].path).toBe("/v1/integrations/mcp");
418
+ });
419
+
420
+ it("throws an AuthError when the admin (fetchConnections) call 401s", async () => {
421
+ vaultApiFetchMock.mockResolvedValueOnce(jsonResponse({ error: "Unauthorized" }, 401));
422
+
423
+ const err = await runCli(["integrations", "list"]).then(
424
+ () => {
425
+ throw new Error("expected runCli to throw");
426
+ },
427
+ (e: unknown) => e,
428
+ );
429
+
430
+ expect(isAuthError(err)).toBe(true);
431
+ expect((err as Error).message).toMatch(/hq login/);
432
+ });
433
+
434
+ it("throws an AuthError when an approve confirm call 401s", async () => {
435
+ vaultApiFetchMock
436
+ .mockResolvedValueOnce(connectionsResponse())
437
+ .mockResolvedValueOnce(jsonResponse({ error: "Unauthorized" }, 401));
438
+
439
+ const err = await runCli([
440
+ "integrations",
441
+ "approve",
442
+ "cq_123",
443
+ "--provider",
444
+ "linear",
445
+ ]).then(
446
+ () => {
447
+ throw new Error("expected runCli to throw");
448
+ },
449
+ (e: unknown) => e,
450
+ );
451
+
452
+ expect(isAuthError(err)).toBe(true);
453
+ expect((err as Error).message).toMatch(/hq login/);
454
+ });
455
+
456
+ // Scope guards: only a 401 becomes an AuthError. A genuine server 500 still
457
+ // reports (expected === false), and a provider-level JSON-RPC error (HTTP 200
458
+ // with `message.error`) stays an IntegrationsCliError — so real faults and
459
+ // upstream tool errors are never misclassified as an auth state.
460
+ it("keeps a gateway 500 as a reporting IntegrationsCliError (not an AuthError)", async () => {
461
+ vaultApiFetchMock
462
+ .mockResolvedValueOnce(connectionsResponse())
463
+ .mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500));
464
+
465
+ const err = await runCli([
466
+ "integrations",
467
+ "call",
468
+ "get_board_info",
469
+ "--provider",
470
+ "linear",
471
+ "--args",
472
+ "{}",
473
+ ]).then(
474
+ () => {
475
+ throw new Error("expected runCli to throw");
476
+ },
477
+ (e: unknown) => e,
478
+ );
479
+
480
+ expect(isAuthError(err)).toBe(false);
481
+ expect(err).toBeInstanceOf(IntegrationsCliError);
482
+ expect((err as IntegrationsCliError).expected).toBe(false);
483
+ });
484
+
485
+ it("keeps an upstream provider error (HTTP 200 message.error) as an IntegrationsCliError", async () => {
486
+ vaultApiFetchMock
487
+ .mockResolvedValueOnce(connectionsResponse())
488
+ .mockResolvedValueOnce(
489
+ jsonResponse({
490
+ jsonrpc: "2.0",
491
+ id: "x",
492
+ error: { code: -32050, message: "monday rejected the board id" },
493
+ }),
494
+ );
495
+
496
+ const err = await runCli([
497
+ "integrations",
498
+ "call",
499
+ "get_board_info",
500
+ "--provider",
501
+ "linear",
502
+ "--args",
503
+ "{}",
504
+ ]).then(
505
+ () => {
506
+ throw new Error("expected runCli to throw");
507
+ },
508
+ (e: unknown) => e,
509
+ );
510
+
511
+ expect(isAuthError(err)).toBe(false);
512
+ expect(err).toBeInstanceOf(IntegrationsCliError);
513
+ expect((err as Error).message).toMatch(/monday rejected the board id/);
514
+ });
515
+ });
@@ -31,6 +31,7 @@ import { Command } from "commander";
31
31
  import chalk from "chalk";
32
32
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
33
33
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
34
+ import { AuthError } from "../utils/auth-error.js";
34
35
 
35
36
  interface AdminConnection {
36
37
  id: string;
@@ -50,12 +51,43 @@ interface GatewayMessage {
50
51
  }
51
52
 
52
53
  export class IntegrationsCliError extends Error {
53
- constructor(message: string) {
54
+ /**
55
+ * True when the error is the caller's request/state/permission (a client 4xx
56
+ * or a local input/usage error) rather than an hq-cli defect. Expected errors
57
+ * are printed to the user but skipped for Sentry capture (HQ-CLI-6). Defaults
58
+ * to false so an unclassified error still reaches Sentry.
59
+ */
60
+ readonly expected: boolean;
61
+
62
+ constructor(message: string, opts: { expected?: boolean } = {}) {
54
63
  super(message);
55
64
  this.name = "IntegrationsCliError";
65
+ this.expected = opts.expected ?? false;
56
66
  }
57
67
  }
58
68
 
69
+ /**
70
+ * A client 4xx is the caller's request/state/permission (bad params, stale
71
+ * queueId, a non-owner approving) — expected and user-facing, not a bug. A 5xx
72
+ * (or a 2xx protocol violation) is a genuine server/unknown fault worth a Sentry
73
+ * crash report.
74
+ */
75
+ function isClientError(status: number): boolean {
76
+ return status >= 400 && status < 500;
77
+ }
78
+
79
+ // A 401 from ANY integration-gateway vault call means the caller's HQ session
80
+ // is expired or missing — an expected auth state fixed by `hq login`, not an
81
+ // hq-cli defect. Raise the same typed AuthError the vault company-resolution
82
+ // paths use (HQ-CLI-8) so the top-level handler prints one actionable message
83
+ // and skips Sentry, instead of surfacing the opaque, unactionable
84
+ // "Integration gateway request failed (HTTP 401)" that shipped as a fatal from
85
+ // `callGateway` (HQ-CLI-9). Non-401 statuses keep their existing behavior:
86
+ // other 4xx stay expected client errors, 5xx still report.
87
+ function raiseIfUnauthorized(res: Response): void {
88
+ if (res.status === 401) throw new AuthError();
89
+ }
90
+
59
91
  /** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
60
92
  export function toolPrefixForProvider(provider: string): string {
61
93
  return provider
@@ -75,9 +107,11 @@ export async function fetchConnections(
75
107
  query: { companyUid },
76
108
  });
77
109
  if (!res.ok) {
110
+ raiseIfUnauthorized(res);
78
111
  const body = (await res.json().catch(() => ({}))) as { error?: string };
79
112
  throw new IntegrationsCliError(
80
113
  body.error ?? `Failed to list integrations (HTTP ${res.status})`,
114
+ { expected: isClientError(res.status) },
81
115
  );
82
116
  }
83
117
  const data = (await res.json()) as { connections?: AdminConnection[] };
@@ -99,6 +133,7 @@ export function selectConnection(
99
133
  if (!match) {
100
134
  throw new IntegrationsCliError(
101
135
  `No connection '${opts.connection}'. Run \`hq integrations list\` to see connected apps.`,
136
+ { expected: true },
102
137
  );
103
138
  }
104
139
  return match;
@@ -116,6 +151,7 @@ export function selectConnection(
116
151
  throw new IntegrationsCliError(
117
152
  `No connected app matches '${opts.provider}'.` +
118
153
  (available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps on the console Integrations page."),
154
+ { expected: true },
119
155
  );
120
156
  }
121
157
  return match;
@@ -124,11 +160,13 @@ export function selectConnection(
124
160
  if (active.length === 0) {
125
161
  throw new IntegrationsCliError(
126
162
  "No connected apps yet. Connect one on the console Integrations page, then retry.",
163
+ { expected: true },
127
164
  );
128
165
  }
129
166
  throw new IntegrationsCliError(
130
167
  `Multiple apps are connected — pick one with --provider:\n` +
131
168
  active.map((c) => ` --provider ${c.provider.replace(/^factory:/, "")}`).join("\n"),
169
+ { expected: true },
132
170
  );
133
171
  }
134
172
 
@@ -149,8 +187,10 @@ export async function callGateway(
149
187
  });
150
188
  const message = (await res.json().catch(() => null)) as GatewayMessage | null;
151
189
  if (!res.ok || !message) {
190
+ raiseIfUnauthorized(res);
152
191
  throw new IntegrationsCliError(
153
192
  `Integration gateway request failed (HTTP ${res.status}).`,
193
+ { expected: isClientError(res.status) },
154
194
  );
155
195
  }
156
196
  if (message.error) {
@@ -331,6 +371,7 @@ export function registerIntegrationsCommand(program: Command): void {
331
371
  } catch {
332
372
  throw new IntegrationsCliError(
333
373
  `--args must be a JSON object, e.g. --args '{"assignee":"me"}'`,
374
+ { expected: true },
334
375
  );
335
376
  }
336
377
  const token = await ensureCognitoIdToken();
@@ -416,8 +457,10 @@ export function registerIntegrationsCommand(program: Command): void {
416
457
  error?: string;
417
458
  };
418
459
  if (!res.ok) {
460
+ raiseIfUnauthorized(res);
419
461
  throw new IntegrationsCliError(
420
462
  body.error ?? `${decision} failed (HTTP ${res.status})`,
463
+ { expected: isClientError(res.status) },
421
464
  );
422
465
  }
423
466
  if (opts.json) {
@@ -305,6 +305,9 @@ describe("hq outposts exec", () => {
305
305
 
306
306
  it("wraps the command to run from the box's HQ folder, then runs it", () => {
307
307
  const wrapped = withRemoteHqDir("pwd");
308
+ // SSM may invoke the command as root with HOME unset. Seed a real home so
309
+ // tools such as gh do not write machine state into the HQ checkout.
310
+ expect(wrapped).toContain('export HOME="${HOME:-/root}"');
308
311
  // cd into $HOME/hq (SSH/login user) …
309
312
  expect(wrapped).toContain('cd "$HOME/hq"');
310
313
  // … falling back to ec2-user's home for the SSM/root path (no $HOME) …
@@ -364,14 +364,15 @@ async function waitForExecResult(
364
364
  * caller's command. `exec` runs over two transports with two different default
365
365
  * working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
366
366
  * SSH lands in the login user's home — so without this, `hq outposts exec -- pwd`
367
- * printed an unhelpful, transport-dependent directory. We resolve the HQ folder
368
- * in-shell: `$HOME/hq` for the login/SSH user, falling back to ec2-user's home
369
- * for the SSM/root path. The trailing `|| true` keeps the command running from
370
- * the default directory when no HQ checkout is present, so exec never fails
371
- * merely because the box has no HQ folder.
367
+ * printed an unhelpful, transport-dependent directory. Initialize a real root
368
+ * home for the SSM case before resolving the HQ folder: tools run by the caller
369
+ * (notably `gh`) otherwise treat the HQ checkout as their home and can create
370
+ * root-owned machine state inside it. The trailing `|| true` keeps the command
371
+ * running from the default directory when no HQ checkout is present, so exec
372
+ * never fails merely because the box has no HQ folder.
372
373
  */
373
374
  export const REMOTE_HQ_DIR_PREFIX =
374
- 'cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
375
+ 'export HOME="${HOME:-/root}"; cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
375
376
 
376
377
  /** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
377
378
  export function withRemoteHqDir(command: string): string {
package/src/main.ts CHANGED
@@ -60,8 +60,11 @@ import { registerBillingCommand } from "./commands/billing.js";
60
60
  import { registerDbCommand } from "./commands/db.js";
61
61
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
62
62
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
63
+ import { isExpectedUserError } from "./utils/expected-cli-error.js";
63
64
  import { isEpipe } from "./utils/epipe.js";
64
65
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
66
+ import { isAuthError } from "./utils/auth-error.js";
67
+ import { isCompanySelectionError } from "./utils/company-selection-error.js";
65
68
  import {
66
69
  maybeWarnNewVersion,
67
70
  refreshVersionCache,
@@ -303,6 +306,34 @@ export async function runCli(): Promise<void> {
303
306
  // thrown or captured. Skip Sentry capture (no signal, no user-facing
304
307
  // degradation) and preserve the intended non-zero exit (HQ-CLI-3).
305
308
  process.exitCode = 1;
309
+ } else if (isCompanySelectionError(err)) {
310
+ // The user has multiple (or zero) active company memberships and ran a
311
+ // command that needs exactly one without `--company`, or a `--company`
312
+ // slug collided across companies. That's an expected, user-actionable
313
+ // disambiguation prompt — the message already tells them exactly how to
314
+ // proceed (re-run with `--company <slug-or-uid>`) — not an hq-cli defect.
315
+ // The CLI can't pick a company for them. Print the actionable message and
316
+ // exit non-zero, but skip Sentry capture so a normal "pick a company"
317
+ // prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
318
+ process.stderr.write(`hq: ${(err as Error).message}\n`);
319
+ process.exitCode = 1;
320
+ } else if (isAuthError(err)) {
321
+ // HQ-CLI-8: the vault API returned 401 Unauthorized — the caller's HQ
322
+ // session is expired or missing. That's an expected auth state the user
323
+ // fixes with `hq login`, not an hq-cli defect. Print the actionable
324
+ // message and skip Sentry so an expired login doesn't flood the tracker
325
+ // with identical, unfixable "crashes".
326
+ process.stderr.write(`hq: ${(err as Error).message}\n`);
327
+ process.exitCode = 1;
328
+ } else if (isExpectedUserError(err)) {
329
+ // HQ-CLI-6: a user-facing, client-caused error (a non-owner running
330
+ // `hq integrations approve`, a stale queueId, a bad --args, an unknown
331
+ // connection) is the caller's request/state/permission, not an hq-cli
332
+ // defect. Print the actionable message and skip Sentry so a correctly-
333
+ // denied 4xx doesn't flood the tracker with identical, unfixable crash
334
+ // reports. Genuine server (5xx) / unknown failures still capture below.
335
+ process.stderr.write(`hq: ${err.message}\n`);
336
+ process.exitCode = 1;
306
337
  } else {
307
338
  // A full disk / exhausted quota / read-only filesystem is the user's
308
339
  // 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,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AuthError, isAuthError } from "./auth-error.js";
3
+
4
+ describe("isAuthError", () => {
5
+ // HQ-CLI-8: an expired HQ session surfaced as a vault 401 during company
6
+ // slug resolution. The user fixes it with `hq login`; it should skip Sentry.
7
+ it("classifies an AuthError as an expected auth state (skip Sentry)", () => {
8
+ expect(isAuthError(new AuthError())).toBe(true);
9
+ });
10
+
11
+ it("uses an actionable default message", () => {
12
+ expect(new AuthError().message).toMatch(/hq login/);
13
+ });
14
+
15
+ it("preserves a custom user-facing message verbatim", () => {
16
+ const msg = "Sign in again before continuing.";
17
+ expect(new AuthError(msg).message).toBe(msg);
18
+ });
19
+
20
+ it("keeps instanceof across the transpile target", () => {
21
+ const err = new AuthError();
22
+ expect(err).toBeInstanceOf(AuthError);
23
+ expect(err).toBeInstanceOf(Error);
24
+ expect(err.name).toBe("AuthError");
25
+ });
26
+
27
+ // A genuine defect must still reach Sentry — only the typed auth class is
28
+ // diverted, so real bugs are never silently swallowed.
29
+ it("does NOT match a plain Error (so real faults still report)", () => {
30
+ expect(isAuthError(new Error("Unauthorized"))).toBe(false);
31
+ expect(isAuthError(new Error("Your HQ session has expired. Run `hq login`."))).toBe(false);
32
+ });
33
+
34
+ it("does NOT match non-error values", () => {
35
+ expect(isAuthError(null)).toBe(false);
36
+ expect(isAuthError(undefined)).toBe(false);
37
+ expect(isAuthError("Unauthorized")).toBe(false);
38
+ expect(isAuthError({ message: "Unauthorized" })).toBe(false);
39
+ });
40
+ });
@@ -0,0 +1,42 @@
1
+ // src/utils/auth-error.ts
2
+ //
3
+ // Classify expired or missing HQ session conditions surfaced by the vault API
4
+ // (HQ-CLI-8). These are expected, user-actionable auth states — NOT hq-cli
5
+ // defects — so the top-level catch prints the message and exits non-zero but
6
+ // SKIPS Sentry capture, mirroring the company-selection (HQ-CLI-7),
7
+ // expected-user-error (HQ-CLI-6), and environmental-FS (HQ-CLI-2) carve-outs.
8
+ //
9
+ // HQ-CLI-8: a user ran `hq integrations list --company liverecover --json`
10
+ // with an expired HQ session. Company-slug resolution tried the caller-scoped
11
+ // `/entity/check-slug/me` lookup and the global `/entity/by-slug/company/...`
12
+ // fallback; both returned 401 Unauthorized. The plain Error that bubbled up
13
+ // looked like a company-resolution defect and was shipped to Sentry as a fatal.
14
+ // A 401 from vault resolution means the caller needs to run `hq login`; the
15
+ // code cannot repair an expired token, so this is normal auth state, not a
16
+ // crash to triage.
17
+
18
+ /**
19
+ * Thrown when the vault API reports the caller's HQ session is expired or
20
+ * missing. The `message` is user-facing and actionable; the top-level handler
21
+ * prints it verbatim and skips Sentry capture.
22
+ */
23
+ export class AuthError extends Error {
24
+ constructor(
25
+ message = "Your HQ session has expired or you're not signed in. Run `hq login` and try again.",
26
+ ) {
27
+ super(message);
28
+ this.name = "AuthError";
29
+ // Preserve `instanceof` across the TS→ES5/ES2015 transpile target.
30
+ Object.setPrototypeOf(this, AuthError.prototype);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * True when `err` is an expected auth-state failure the user must resolve with
36
+ * `hq login`. Callers should print `err.message` and SKIP Sentry capture while
37
+ * preserving a non-zero exit. Genuine faults are plain `Error`s and return
38
+ * `false`, so real bugs still report.
39
+ */
40
+ export function isAuthError(err: unknown): boolean {
41
+ return err instanceof AuthError;
42
+ }
@@ -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
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isExpectedUserError } from "./expected-cli-error.js";
3
+
4
+ describe("isExpectedUserError", () => {
5
+ it("matches an Error explicitly marked expected", () => {
6
+ expect(isExpectedUserError(Object.assign(new Error("usage"), { expected: true }))).toBe(
7
+ true,
8
+ );
9
+ });
10
+
11
+ it("does NOT match Errors marked expected false or unmarked", () => {
12
+ expect(isExpectedUserError(Object.assign(new Error("boom"), { expected: false }))).toBe(
13
+ false,
14
+ );
15
+ expect(isExpectedUserError(new Error("boom"))).toBe(false);
16
+ });
17
+
18
+ it("does NOT match a plain object carrying expected true", () => {
19
+ expect(isExpectedUserError({ message: "usage", expected: true })).toBe(false);
20
+ });
21
+
22
+ it("does NOT match non-error values", () => {
23
+ expect(isExpectedUserError(null)).toBe(false);
24
+ expect(isExpectedUserError(undefined)).toBe(false);
25
+ expect(isExpectedUserError(42)).toBe(false);
26
+ expect(isExpectedUserError("usage")).toBe(false);
27
+ });
28
+ });