@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
|
@@ -45,7 +45,16 @@ interface GatewayMessage {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
export declare class IntegrationsCliError extends Error {
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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.
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
package/dist/utils/vault-api.js
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
|
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).
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|