@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.
@@ -0,0 +1,39 @@
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
+ /**
21
+ * An error the CLI should surface to the user (clear message, exit 1) but NOT
22
+ * report to Sentry. Carriers set `expected: true`.
23
+ */
24
+ export interface ExpectedUserError extends Error {
25
+ expected: true;
26
+ }
27
+
28
+ /**
29
+ * True when `err` is an Error explicitly marked `expected === true`. A non-null
30
+ * result means the top-level handler should print `err.message` and skip Sentry
31
+ * capture. Anything else (unmarked errors, non-Error values) returns false so
32
+ * genuine faults still reach Sentry.
33
+ */
34
+ export function isExpectedUserError(err: unknown): err is ExpectedUserError {
35
+ return (
36
+ err instanceof Error &&
37
+ (err as { expected?: unknown }).expected === true
38
+ );
39
+ }
@@ -5,7 +5,9 @@ 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 { isAuthError } from './auth-error.js';
10
+ import { isCompanySelectionError } from './company-selection-error.js';
9
11
 
10
12
  const fetchMock = vi.fn();
11
13
  const originalFetch = globalThis.fetch;
@@ -199,6 +201,138 @@ describe('getEntityUid', () => {
199
201
  });
200
202
  });
201
203
 
204
+ describe('getCompanyUid company-selection classification (HQ-CLI-7)', () => {
205
+ // The exact production scenario: a user with multiple active memberships runs
206
+ // a command with no --company. The prompt is expected, user-actionable guidance
207
+ // — it must be a CompanySelectionError so the top-level handler prints it and
208
+ // SKIPS Sentry capture, not a plain Error that gets shipped as a fatal.
209
+ it('throws a CompanySelectionError (not a plain Error) on multiple active memberships', async () => {
210
+ fetchMock.mockResolvedValueOnce(
211
+ mockResponse(200, {
212
+ memberships: [
213
+ { companyUid: 'cmp_a', role: 'member', status: 'active', membershipKey: 'k1' },
214
+ { companyUid: 'cmp_b', role: 'owner', status: 'active', membershipKey: 'k2' },
215
+ { companyUid: 'cmp_c', role: 'member', status: 'active', membershipKey: 'k3' },
216
+ ],
217
+ }),
218
+ );
219
+ const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
220
+ expect(isCompanySelectionError(err)).toBe(true);
221
+ expect((err as Error).message).toMatch(/Multiple active companies found/);
222
+ expect((err as Error).message).toMatch(/--company cmp_a/);
223
+ });
224
+
225
+ it('throws a CompanySelectionError when the caller has no active memberships', async () => {
226
+ fetchMock.mockResolvedValueOnce(
227
+ mockResponse(200, {
228
+ memberships: [
229
+ { companyUid: 'cmp_x', role: 'member', status: 'invited', membershipKey: 'k' },
230
+ ],
231
+ }),
232
+ );
233
+ const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
234
+ expect(isCompanySelectionError(err)).toBe(true);
235
+ expect((err as Error).message).toMatch(/No active company memberships/);
236
+ });
237
+
238
+ it('resolves silently to the single active membership (no selection error)', async () => {
239
+ fetchMock.mockResolvedValueOnce(
240
+ mockResponse(200, {
241
+ memberships: [
242
+ { companyUid: 'cmp_only', role: 'member', status: 'active', membershipKey: 'k' },
243
+ ],
244
+ }),
245
+ );
246
+ const uid = await getCompanyUid('tok', undefined);
247
+ expect(uid).toBe('cmp_only');
248
+ });
249
+
250
+ it('classifies the slug-collision (409, none in namespace) case as a selection error', async () => {
251
+ fetchMock
252
+ .mockResolvedValueOnce(mockResponse(200, { available: true }))
253
+ .mockResolvedValueOnce(
254
+ mockResponse(409, {
255
+ error: 'Slug "acme" matches 2 live entities',
256
+ uids: ['cmp_one', 'cmp_two'],
257
+ }),
258
+ );
259
+ const err = await getCompanyUid('tok', 'acme').catch((e: unknown) => e);
260
+ expect(isCompanySelectionError(err)).toBe(true);
261
+ expect((err as Error).message).toMatch(/--company cmp_one/);
262
+ });
263
+
264
+ // A genuine fault must NOT be classified as a selection prompt — it still
265
+ // reports to Sentry. Guards against over-broadening the carve-out.
266
+ it('does NOT classify a failed membership fetch as a selection error (still reports)', async () => {
267
+ fetchMock.mockResolvedValueOnce(mockResponse(401, { error: 'unauthorized' }));
268
+ const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
269
+ expect(isCompanySelectionError(err)).toBe(false);
270
+ expect((err as Error).message).toMatch(/Failed to fetch memberships/);
271
+ });
272
+ });
273
+
274
+ describe('resolveCompanyUid 401 → AuthError', () => {
275
+ it('short-circuits when check-slug/me returns 401 and does not call global by-slug', async () => {
276
+ fetchMock.mockResolvedValueOnce(mockResponse(401, { error: 'Unauthorized' }));
277
+
278
+ const err = await getEntityUid('tok', { companySlug: 'liverecover' }).catch(
279
+ (e: unknown) => e,
280
+ );
281
+
282
+ expect(isAuthError(err)).toBe(true);
283
+ expect((err as Error).message).toMatch(/hq login/);
284
+ expect(fetchMock).toHaveBeenCalledTimes(1);
285
+ expect(fetchMock.mock.calls[0][0]).toMatch(/\/entity\/check-slug\/me/);
286
+ });
287
+
288
+ it('classifies a 401 from the global by-slug fallback as an AuthError', async () => {
289
+ fetchMock
290
+ .mockResolvedValueOnce(mockResponse(404, { available: true }))
291
+ .mockResolvedValueOnce(mockResponse(401, { error: 'Unauthorized' }));
292
+
293
+ const err = await getCompanyUid('tok', 'liverecover').catch(
294
+ (e: unknown) => e,
295
+ );
296
+
297
+ expect(isAuthError(err)).toBe(true);
298
+ expect((err as Error).message).toMatch(/hq login/);
299
+ expect(fetchMock).toHaveBeenCalledTimes(2);
300
+ });
301
+
302
+ it('keeps a global 409 slug collision on the CompanySelectionError path', async () => {
303
+ fetchMock
304
+ .mockResolvedValueOnce(mockResponse(200, { available: true }))
305
+ .mockResolvedValueOnce(
306
+ mockResponse(409, {
307
+ error: 'Slug "liverecover" matches 2 live entities',
308
+ uids: ['cmp_one', 'cmp_two'],
309
+ }),
310
+ );
311
+
312
+ const err = await getCompanyUid('tok', 'liverecover').catch(
313
+ (e: unknown) => e,
314
+ );
315
+
316
+ expect(isAuthError(err)).toBe(false);
317
+ expect(isCompanySelectionError(err)).toBe(true);
318
+ expect((err as Error).message).toMatch(/--company cmp_one/);
319
+ });
320
+
321
+ it('keeps a global 500 as a plain company-resolution Error', async () => {
322
+ fetchMock
323
+ .mockResolvedValueOnce(mockResponse(200, { available: true }))
324
+ .mockResolvedValueOnce(mockResponse(500, { error: 'Internal Server Error' }));
325
+
326
+ const err = await getCompanyUid('tok', 'liverecover').catch(
327
+ (e: unknown) => e,
328
+ );
329
+
330
+ expect(err).toBeInstanceOf(Error);
331
+ expect(isAuthError(err)).toBe(false);
332
+ expect((err as Error).message).toMatch(/Failed to resolve company slug/);
333
+ });
334
+ });
335
+
202
336
  describe('vaultApiFetch breadcrumb URL sanitization', () => {
203
337
  it('redacts query string in request breadcrumb data.url', async () => {
204
338
  fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
@@ -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
 
4
6
  export interface VaultApiOptions {
5
7
  token: string;
@@ -106,12 +108,21 @@ export function looksLikeCompanyUid(ref: string): boolean {
106
108
  return ref.startsWith(COMPANY_UID_PREFIX);
107
109
  }
108
110
 
111
+ // A 401 from ANY vault resolution call means the caller's HQ session is
112
+ // expired or missing — an expected auth state fixed by `hq login`, not a
113
+ // code defect. Raise a typed AuthError so the top-level handler prints an
114
+ // actionable message and skips Sentry capture (HQ-CLI-8).
115
+ function raiseIfUnauthorized(res: Response): void {
116
+ if (res.status === 401) throw new AuthError();
117
+ }
118
+
109
119
  async function resolveCompanyByUid(token: string, uid: string): Promise<string> {
110
120
  const res = await vaultApiFetch({
111
121
  token,
112
122
  path: `/entity/${encodeURIComponent(uid)}`,
113
123
  });
114
124
  if (!res.ok) {
125
+ raiseIfUnauthorized(res);
115
126
  const body = (await res.json().catch(() => ({}))) as { error?: string };
116
127
  throw new Error(
117
128
  `Failed to resolve company '${uid}': ${body.error ?? res.statusText}`,
@@ -149,11 +160,13 @@ async function resolveSlugInCallerNamespace(
149
160
  query: { type: 'company', slug },
150
161
  });
151
162
  if (!res.ok) {
163
+ raiseIfUnauthorized(res);
152
164
  // Namespace lookup unavailable (e.g. membership table not configured →
153
- // 503, or the caller has no person entity). Signal "couldn't resolve here"
154
- // and let the caller fall back to the global lookup. vaultApiFetch already
155
- // recorded the non-2xx as a Sentry breadcrumb, so this is not a silent
156
- // swallow.
165
+ // 503, or the caller has no person entity). A 401 short-circuits above
166
+ // because the token is bad and the global fallback would only 401 again;
167
+ // other non-2xx statuses signal "couldn't resolve here" and let the caller
168
+ // fall back to the global lookup. vaultApiFetch already recorded the
169
+ // non-2xx as a Sentry breadcrumb, so this is not a silent swallow.
157
170
  return null;
158
171
  }
159
172
  const data = (await res.json()) as {
@@ -187,6 +200,7 @@ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
187
200
  path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
188
201
  });
189
202
  if (!res.ok) {
203
+ raiseIfUnauthorized(res);
190
204
  const body = (await res.json().catch(() => ({}))) as {
191
205
  error?: string;
192
206
  uids?: string[];
@@ -197,7 +211,7 @@ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
197
211
  // tell them exactly how — re-run with `--company <uid>` — and list the
198
212
  // candidates, instead of echoing the generic server message.
199
213
  if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
200
- throw new Error(
214
+ throw new CompanySelectionError(
201
215
  `Company slug '${ref}' matches ${body.uids.length} companies and none ` +
202
216
  `is in your namespace. Re-run with --company <uid> to pick one:\n` +
203
217
  body.uids.map((u) => ` --company ${u}`).join('\n'),
@@ -222,13 +236,13 @@ async function resolveCompanyFromMemberships(token: string): Promise<string> {
222
236
  const data = (await res.json()) as { memberships: MembershipEntry[] };
223
237
  const active = data.memberships.filter((m) => m.status === 'active');
224
238
  if (active.length === 0) {
225
- throw new Error('No active company memberships found. Use --company <slug> to specify.');
239
+ throw new CompanySelectionError('No active company memberships found. Use --company <slug> to specify.');
226
240
  }
227
241
  if (active.length === 1) {
228
242
  return active[0].companyUid;
229
243
  }
230
244
  const uids = active.map((m) => m.companyUid);
231
- throw new Error(
245
+ throw new CompanySelectionError(
232
246
  `Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
233
247
  `pick one:\n` +
234
248
  uids.map((u) => ` --company ${u}`).join('\n'),