@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.
@@ -45,7 +45,16 @@ interface GatewayMessage {
45
45
  };
46
46
  }
47
47
  export declare class IntegrationsCliError extends Error {
48
- constructor(message: string);
48
+ /**
49
+ * True when the error is the caller's request/state/permission (a client 4xx
50
+ * or a local input/usage error) rather than an hq-cli defect. Expected errors
51
+ * are printed to the user but skipped for Sentry capture (HQ-CLI-6). Defaults
52
+ * to false so an unclassified error still reaches Sentry.
53
+ */
54
+ readonly expected: boolean;
55
+ constructor(message: string, opts?: {
56
+ expected?: boolean;
57
+ });
49
58
  }
50
59
  /** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
51
60
  export declare function toolPrefixForProvider(provider: string): string;
@@ -29,12 +29,42 @@ import { randomUUID } from "node:crypto";
29
29
  import chalk from "chalk";
30
30
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
31
31
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
32
+ import { AuthError } from "../utils/auth-error.js";
32
33
  export class IntegrationsCliError extends Error {
33
- constructor(message) {
34
+ /**
35
+ * True when the error is the caller's request/state/permission (a client 4xx
36
+ * or a local input/usage error) rather than an hq-cli defect. Expected errors
37
+ * are printed to the user but skipped for Sentry capture (HQ-CLI-6). Defaults
38
+ * to false so an unclassified error still reaches Sentry.
39
+ */
40
+ expected;
41
+ constructor(message, opts = {}) {
34
42
  super(message);
35
43
  this.name = "IntegrationsCliError";
44
+ this.expected = opts.expected ?? false;
36
45
  }
37
46
  }
47
+ /**
48
+ * A client 4xx is the caller's request/state/permission (bad params, stale
49
+ * queueId, a non-owner approving) — expected and user-facing, not a bug. A 5xx
50
+ * (or a 2xx protocol violation) is a genuine server/unknown fault worth a Sentry
51
+ * crash report.
52
+ */
53
+ function isClientError(status) {
54
+ return status >= 400 && status < 500;
55
+ }
56
+ // A 401 from ANY integration-gateway vault call means the caller's HQ session
57
+ // is expired or missing — an expected auth state fixed by `hq login`, not an
58
+ // hq-cli defect. Raise the same typed AuthError the vault company-resolution
59
+ // paths use (HQ-CLI-8) so the top-level handler prints one actionable message
60
+ // and skips Sentry, instead of surfacing the opaque, unactionable
61
+ // "Integration gateway request failed (HTTP 401)" that shipped as a fatal from
62
+ // `callGateway` (HQ-CLI-9). Non-401 statuses keep their existing behavior:
63
+ // other 4xx stay expected client errors, 5xx still report.
64
+ function raiseIfUnauthorized(res) {
65
+ if (res.status === 401)
66
+ throw new AuthError();
67
+ }
38
68
  /** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
39
69
  export function toolPrefixForProvider(provider) {
40
70
  return provider
@@ -50,8 +80,9 @@ export async function fetchConnections(token, companyUid) {
50
80
  query: { companyUid },
51
81
  });
52
82
  if (!res.ok) {
83
+ raiseIfUnauthorized(res);
53
84
  const body = (await res.json().catch(() => ({})));
54
- throw new IntegrationsCliError(body.error ?? `Failed to list integrations (HTTP ${res.status})`);
85
+ throw new IntegrationsCliError(body.error ?? `Failed to list integrations (HTTP ${res.status})`, { expected: isClientError(res.status) });
55
86
  }
56
87
  const data = (await res.json());
57
88
  return data.connections ?? [];
@@ -66,7 +97,7 @@ export function selectConnection(connections, opts) {
66
97
  if (opts.connection) {
67
98
  const match = connections.find((c) => c.id === opts.connection);
68
99
  if (!match) {
69
- throw new IntegrationsCliError(`No connection '${opts.connection}'. Run \`hq integrations list\` to see connected apps.`);
100
+ throw new IntegrationsCliError(`No connection '${opts.connection}'. Run \`hq integrations list\` to see connected apps.`, { expected: true });
70
101
  }
71
102
  return match;
72
103
  }
@@ -81,17 +112,17 @@ export function selectConnection(connections, opts) {
81
112
  .map((c) => c.provider.replace(/^factory:/, ""))
82
113
  .join(", ");
83
114
  throw new IntegrationsCliError(`No connected app matches '${opts.provider}'.` +
84
- (available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps on the console Integrations page."));
115
+ (available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps on the console Integrations page."), { expected: true });
85
116
  }
86
117
  return match;
87
118
  }
88
119
  if (active.length === 1)
89
120
  return active[0];
90
121
  if (active.length === 0) {
91
- throw new IntegrationsCliError("No connected apps yet. Connect one on the console Integrations page, then retry.");
122
+ throw new IntegrationsCliError("No connected apps yet. Connect one on the console Integrations page, then retry.", { expected: true });
92
123
  }
93
124
  throw new IntegrationsCliError(`Multiple apps are connected — pick one with --provider:\n` +
94
- active.map((c) => ` --provider ${c.provider.replace(/^factory:/, "")}`).join("\n"));
125
+ active.map((c) => ` --provider ${c.provider.replace(/^factory:/, "")}`).join("\n"), { expected: true });
95
126
  }
96
127
  export async function callGateway(token, params) {
97
128
  const res = await vaultApiFetch({
@@ -107,7 +138,8 @@ export async function callGateway(token, params) {
107
138
  });
108
139
  const message = (await res.json().catch(() => null));
109
140
  if (!res.ok || !message) {
110
- throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`);
141
+ raiseIfUnauthorized(res);
142
+ throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status) });
111
143
  }
112
144
  if (message.error) {
113
145
  throw new IntegrationsCliError(message.error.message ?? "Integration gateway returned an error.");
@@ -232,7 +264,7 @@ export function registerIntegrationsCommand(program) {
232
264
  parsedArgs = raw;
233
265
  }
234
266
  catch {
235
- throw new IntegrationsCliError(`--args must be a JSON object, e.g. --args '{"assignee":"me"}'`);
267
+ throw new IntegrationsCliError(`--args must be a JSON object, e.g. --args '{"assignee":"me"}'`, { expected: true });
236
268
  }
237
269
  const token = await ensureCognitoIdToken();
238
270
  const companyUid = await getCompanyUid(token, opts.company);
@@ -288,7 +320,8 @@ export function registerIntegrationsCommand(program) {
288
320
  });
289
321
  const body = (await res.json().catch(() => ({})));
290
322
  if (!res.ok) {
291
- throw new IntegrationsCliError(body.error ?? `${decision} failed (HTTP ${res.status})`);
323
+ raiseIfUnauthorized(res);
324
+ throw new IntegrationsCliError(body.error ?? `${decision} failed (HTTP ${res.status})`, { expected: isClientError(res.status) });
292
325
  }
293
326
  if (opts.json) {
294
327
  printJson(body);
@@ -143,13 +143,14 @@ export declare function joinCommandParts(commandParts: string[]): string;
143
143
  * caller's command. `exec` runs over two transports with two different default
144
144
  * working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
145
145
  * SSH lands in the login user's home — so without this, `hq outposts exec -- pwd`
146
- * printed an unhelpful, transport-dependent directory. We resolve the HQ folder
147
- * in-shell: `$HOME/hq` for the login/SSH user, falling back to ec2-user's home
148
- * for the SSM/root path. The trailing `|| true` keeps the command running from
149
- * the default directory when no HQ checkout is present, so exec never fails
150
- * merely because the box has no HQ folder.
146
+ * printed an unhelpful, transport-dependent directory. Initialize a real root
147
+ * home for the SSM case before resolving the HQ folder: tools run by the caller
148
+ * (notably `gh`) otherwise treat the HQ checkout as their home and can create
149
+ * root-owned machine state inside it. The trailing `|| true` keeps the command
150
+ * running from the default directory when no HQ checkout is present, so exec
151
+ * never fails merely because the box has no HQ folder.
151
152
  */
152
- export declare const REMOTE_HQ_DIR_PREFIX = "cd \"$HOME/hq\" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true";
153
+ export declare const REMOTE_HQ_DIR_PREFIX = "export HOME=\"${HOME:-/root}\"; cd \"$HOME/hq\" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true";
153
154
  /** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
154
155
  export declare function withRemoteHqDir(command: string): string;
155
156
  /** SSH connection details vended by `POST /outpost/ssh-access`. */
@@ -215,13 +215,14 @@ async function waitForExecResult(token, commandId, outpostId) {
215
215
  * caller's command. `exec` runs over two transports with two different default
216
216
  * working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
217
217
  * SSH lands in the login user's home — so without this, `hq outposts exec -- pwd`
218
- * printed an unhelpful, transport-dependent directory. We resolve the HQ folder
219
- * in-shell: `$HOME/hq` for the login/SSH user, falling back to ec2-user's home
220
- * for the SSM/root path. The trailing `|| true` keeps the command running from
221
- * the default directory when no HQ checkout is present, so exec never fails
222
- * merely because the box has no HQ folder.
218
+ * printed an unhelpful, transport-dependent directory. Initialize a real root
219
+ * home for the SSM case before resolving the HQ folder: tools run by the caller
220
+ * (notably `gh`) otherwise treat the HQ checkout as their home and can create
221
+ * root-owned machine state inside it. The trailing `|| true` keeps the command
222
+ * running from the default directory when no HQ checkout is present, so exec
223
+ * never fails merely because the box has no HQ folder.
223
224
  */
224
- export const REMOTE_HQ_DIR_PREFIX = 'cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
225
+ export const REMOTE_HQ_DIR_PREFIX = 'export HOME="${HOME:-/root}"; cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
225
226
  /** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
226
227
  export function withRemoteHqDir(command) {
227
228
  return `${REMOTE_HQ_DIR_PREFIX}; ${command}`;
package/dist/main.js CHANGED
@@ -58,8 +58,11 @@ import { registerBillingCommand } from "./commands/billing.js";
58
58
  import { registerDbCommand } from "./commands/db.js";
59
59
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
60
60
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
61
+ import { isExpectedUserError } from "./utils/expected-cli-error.js";
61
62
  import { isEpipe } from "./utils/epipe.js";
62
63
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
64
+ import { isAuthError } from "./utils/auth-error.js";
65
+ import { isCompanySelectionError } from "./utils/company-selection-error.js";
63
66
  import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
64
67
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
65
68
  import { CLI_VERSION } from "./cli-version.js";
@@ -253,6 +256,37 @@ export async function runCli() {
253
256
  // degradation) and preserve the intended non-zero exit (HQ-CLI-3).
254
257
  process.exitCode = 1;
255
258
  }
259
+ else if (isCompanySelectionError(err)) {
260
+ // The user has multiple (or zero) active company memberships and ran a
261
+ // command that needs exactly one without `--company`, or a `--company`
262
+ // slug collided across companies. That's an expected, user-actionable
263
+ // disambiguation prompt — the message already tells them exactly how to
264
+ // proceed (re-run with `--company <slug-or-uid>`) — not an hq-cli defect.
265
+ // The CLI can't pick a company for them. Print the actionable message and
266
+ // exit non-zero, but skip Sentry capture so a normal "pick a company"
267
+ // prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
268
+ process.stderr.write(`hq: ${err.message}\n`);
269
+ process.exitCode = 1;
270
+ }
271
+ else if (isAuthError(err)) {
272
+ // HQ-CLI-8: the vault API returned 401 Unauthorized — the caller's HQ
273
+ // session is expired or missing. That's an expected auth state the user
274
+ // fixes with `hq login`, not an hq-cli defect. Print the actionable
275
+ // message and skip Sentry so an expired login doesn't flood the tracker
276
+ // with identical, unfixable "crashes".
277
+ process.stderr.write(`hq: ${err.message}\n`);
278
+ process.exitCode = 1;
279
+ }
280
+ else if (isExpectedUserError(err)) {
281
+ // HQ-CLI-6: a user-facing, client-caused error (a non-owner running
282
+ // `hq integrations approve`, a stale queueId, a bad --args, an unknown
283
+ // connection) is the caller's request/state/permission, not an hq-cli
284
+ // defect. Print the actionable message and skip Sentry so a correctly-
285
+ // denied 4xx doesn't flood the tracker with identical, unfixable crash
286
+ // reports. Genuine server (5xx) / unknown failures still capture below.
287
+ process.stderr.write(`hq: ${err.message}\n`);
288
+ process.exitCode = 1;
289
+ }
256
290
  else {
257
291
  // A full disk / exhausted quota / read-only filesystem is the user's
258
292
  // machine, not an HQ code defect. Surface a clear, actionable message and
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Thrown when the vault API reports the caller's HQ session is expired or
3
+ * missing. The `message` is user-facing and actionable; the top-level handler
4
+ * prints it verbatim and skips Sentry capture.
5
+ */
6
+ export declare class AuthError extends Error {
7
+ constructor(message?: string);
8
+ }
9
+ /**
10
+ * True when `err` is an expected auth-state failure the user must resolve with
11
+ * `hq login`. Callers should print `err.message` and SKIP Sentry capture while
12
+ * preserving a non-zero exit. Genuine faults are plain `Error`s and return
13
+ * `false`, so real bugs still report.
14
+ */
15
+ export declare function isAuthError(err: unknown): boolean;
16
+ //# sourceMappingURL=auth-error.d.ts.map
@@ -0,0 +1,39 @@
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
+ * Thrown when the vault API reports the caller's HQ session is expired or
19
+ * missing. The `message` is user-facing and actionable; the top-level handler
20
+ * prints it verbatim and skips Sentry capture.
21
+ */
22
+ export class AuthError extends Error {
23
+ constructor(message = "Your HQ session has expired or you're not signed in. Run `hq login` and try again.") {
24
+ super(message);
25
+ this.name = "AuthError";
26
+ // Preserve `instanceof` across the TS→ES5/ES2015 transpile target.
27
+ Object.setPrototypeOf(this, AuthError.prototype);
28
+ }
29
+ }
30
+ /**
31
+ * True when `err` is an expected auth-state failure the user must resolve with
32
+ * `hq login`. Callers should print `err.message` and SKIP Sentry capture while
33
+ * preserving a non-zero exit. Genuine faults are plain `Error`s and return
34
+ * `false`, so real bugs still report.
35
+ */
36
+ export function isAuthError(err) {
37
+ return err instanceof AuthError;
38
+ }
39
+ //# sourceMappingURL=auth-error.js.map
@@ -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
@@ -0,0 +1,15 @@
1
+ /**
2
+ * An error the CLI should surface to the user (clear message, exit 1) but NOT
3
+ * report to Sentry. Carriers set `expected: true`.
4
+ */
5
+ export interface ExpectedUserError extends Error {
6
+ expected: true;
7
+ }
8
+ /**
9
+ * True when `err` is an Error explicitly marked `expected === true`. A non-null
10
+ * result means the top-level handler should print `err.message` and skip Sentry
11
+ * capture. Anything else (unmarked errors, non-Error values) returns false so
12
+ * genuine faults still reach Sentry.
13
+ */
14
+ export declare function isExpectedUserError(err: unknown): err is ExpectedUserError;
15
+ //# sourceMappingURL=expected-cli-error.d.ts.map
@@ -0,0 +1,29 @@
1
+ // src/utils/expected-cli-error.ts
2
+ //
3
+ // Classify errors that are the CALLER's request/state/permission rather than an
4
+ // hq-cli code defect: a bad flag, malformed input, a correctly-denied client
5
+ // 4xx (e.g. a non-owner running `hq integrations approve`). These are
6
+ // user-facing and actionable — the CLI prints a clear message and does NOT
7
+ // report them to Sentry, otherwise a correctly-enforced authorization denial
8
+ // floods the tracker with identical, unfixable crash reports.
9
+ //
10
+ // This is the caller-side analog of hq-pro's `expectedDenialResponse`, and a
11
+ // sibling of `environmental-error.ts` (HQ-CLI-2) and
12
+ // `intercepted-process-exit.ts` (HQ-CLI-3): errors that are NOT hq-cli defects
13
+ // are surfaced to the user but skipped for Sentry capture.
14
+ //
15
+ // HQ-CLI-6: `hq integrations approve|reject` by a non-owner got a correct 403
16
+ // ("Only a company owner can approve or reject queued integration writes"); the
17
+ // thrown error propagated to the top-level handler, which captured it to Sentry
18
+ // as an error-level crash and printed nothing to the user.
19
+ /**
20
+ * True when `err` is an Error explicitly marked `expected === true`. A non-null
21
+ * result means the top-level handler should print `err.message` and skip Sentry
22
+ * capture. Anything else (unmarked errors, non-Error values) returns false so
23
+ * genuine faults still reach Sentry.
24
+ */
25
+ export function isExpectedUserError(err) {
26
+ return (err instanceof Error &&
27
+ err.expected === true);
28
+ }
29
+ //# sourceMappingURL=expected-cli-error.js.map
@@ -1,5 +1,7 @@
1
1
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
2
2
  import { Sentry } from '../sentry.js';
3
+ import { AuthError } from './auth-error.js';
4
+ import { CompanySelectionError } from './company-selection-error.js';
3
5
  export async function vaultApiFetch(opts) {
4
6
  const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
5
7
  if (opts.query) {
@@ -82,12 +84,21 @@ const COMPANY_UID_PREFIX = 'cmp_';
82
84
  export function looksLikeCompanyUid(ref) {
83
85
  return ref.startsWith(COMPANY_UID_PREFIX);
84
86
  }
87
+ // A 401 from ANY vault resolution call means the caller's HQ session is
88
+ // expired or missing — an expected auth state fixed by `hq login`, not a
89
+ // code defect. Raise a typed AuthError so the top-level handler prints an
90
+ // actionable message and skips Sentry capture (HQ-CLI-8).
91
+ function raiseIfUnauthorized(res) {
92
+ if (res.status === 401)
93
+ throw new AuthError();
94
+ }
85
95
  async function resolveCompanyByUid(token, uid) {
86
96
  const res = await vaultApiFetch({
87
97
  token,
88
98
  path: `/entity/${encodeURIComponent(uid)}`,
89
99
  });
90
100
  if (!res.ok) {
101
+ raiseIfUnauthorized(res);
91
102
  const body = (await res.json().catch(() => ({})));
92
103
  throw new Error(`Failed to resolve company '${uid}': ${body.error ?? res.statusText}`);
93
104
  }
@@ -119,11 +130,13 @@ async function resolveSlugInCallerNamespace(token, slug) {
119
130
  query: { type: 'company', slug },
120
131
  });
121
132
  if (!res.ok) {
133
+ raiseIfUnauthorized(res);
122
134
  // Namespace lookup unavailable (e.g. membership table not configured →
123
- // 503, or the caller has no person entity). Signal "couldn't resolve here"
124
- // and let the caller fall back to the global lookup. vaultApiFetch already
125
- // recorded the non-2xx as a Sentry breadcrumb, so this is not a silent
126
- // swallow.
135
+ // 503, or the caller has no person entity). A 401 short-circuits above
136
+ // because the token is bad and the global fallback would only 401 again;
137
+ // other non-2xx statuses signal "couldn't resolve here" and let the caller
138
+ // fall back to the global lookup. vaultApiFetch already recorded the
139
+ // non-2xx as a Sentry breadcrumb, so this is not a silent swallow.
127
140
  return null;
128
141
  }
129
142
  const data = (await res.json());
@@ -151,6 +164,7 @@ async function resolveCompanyUid(token, ref) {
151
164
  path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
152
165
  });
153
166
  if (!res.ok) {
167
+ raiseIfUnauthorized(res);
154
168
  const body = (await res.json().catch(() => ({})));
155
169
  // Residual true ambiguity: the slug matches multiple live companies and
156
170
  // NONE is in the caller's namespace (server returns 409 SlugNotUniqueError
@@ -158,7 +172,7 @@ async function resolveCompanyUid(token, ref) {
158
172
  // tell them exactly how — re-run with `--company <uid>` — and list the
159
173
  // candidates, instead of echoing the generic server message.
160
174
  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 ` +
175
+ throw new CompanySelectionError(`Company slug '${ref}' matches ${body.uids.length} companies and none ` +
162
176
  `is in your namespace. Re-run with --company <uid> to pick one:\n` +
163
177
  body.uids.map((u) => ` --company ${u}`).join('\n'));
164
178
  }
@@ -178,13 +192,13 @@ async function resolveCompanyFromMemberships(token) {
178
192
  const data = (await res.json());
179
193
  const active = data.memberships.filter((m) => m.status === 'active');
180
194
  if (active.length === 0) {
181
- throw new Error('No active company memberships found. Use --company <slug> to specify.');
195
+ throw new CompanySelectionError('No active company memberships found. Use --company <slug> to specify.');
182
196
  }
183
197
  if (active.length === 1) {
184
198
  return active[0].companyUid;
185
199
  }
186
200
  const uids = active.map((m) => m.companyUid);
187
- throw new Error(`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
201
+ throw new CompanySelectionError(`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
188
202
  `pick one:\n` +
189
203
  uids.map((u) => ` --company ${u}`).join('\n'));
190
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.73.0",
3
+ "version": "5.74.0",
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",