@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.
- package/dist/commands/integrations.d.ts +10 -1
- package/dist/commands/integrations.js +42 -9
- package/dist/commands/outposts.d.ts +7 -6
- package/dist/commands/outposts.js +7 -6
- package/dist/main.js +34 -0
- package/dist/utils/auth-error.d.ts +16 -0
- package/dist/utils/auth-error.js +39 -0
- package/dist/utils/company-selection-error.d.ts +21 -0
- package/dist/utils/company-selection-error.js +44 -0
- package/dist/utils/expected-cli-error.d.ts +15 -0
- package/dist/utils/expected-cli-error.js +29 -0
- package/dist/utils/vault-api.js +21 -7
- package/package.json +2 -2
- package/src/commands/integrations.test.ts +231 -0
- package/src/commands/integrations.ts +44 -1
- package/src/commands/outposts.test.ts +3 -0
- package/src/commands/outposts.ts +7 -6
- package/src/main.ts +31 -0
- package/src/packaging.test.ts +9 -0
- package/src/utils/auth-error.test.ts +40 -0
- package/src/utils/auth-error.ts +42 -0
- package/src/utils/company-selection-error.test.ts +42 -0
- package/src/utils/company-selection-error.ts +45 -0
- package/src/utils/expected-cli-error.test.ts +28 -0
- package/src/utils/expected-cli-error.ts +39 -0
- package/src/utils/vault-api.test.ts +135 -1
- package/src/utils/vault-api.ts +21 -7
|
@@ -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, {}));
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -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).
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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'),
|