@indigoai-us/hq-cli 5.73.1 → 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 +21 -0
- package/dist/utils/auth-error.d.ts +16 -0
- package/dist/utils/auth-error.js +39 -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 +17 -4
- package/package.json +1 -1
- 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 +19 -0
- package/src/utils/auth-error.test.ts +40 -0
- package/src/utils/auth-error.ts +42 -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 +63 -0
- package/src/utils/vault-api.ts +17 -4
|
@@ -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,10 @@ 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";
|
|
63
65
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
64
66
|
import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
|
|
65
67
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
@@ -266,6 +268,25 @@ export async function runCli() {
|
|
|
266
268
|
process.stderr.write(`hq: ${err.message}\n`);
|
|
267
269
|
process.exitCode = 1;
|
|
268
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
|
+
}
|
|
269
290
|
else {
|
|
270
291
|
// A full disk / exhausted quota / read-only filesystem is the user's
|
|
271
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,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,6 @@
|
|
|
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';
|
|
3
4
|
import { CompanySelectionError } from './company-selection-error.js';
|
|
4
5
|
export async function vaultApiFetch(opts) {
|
|
5
6
|
const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
|
|
@@ -83,12 +84,21 @@ const COMPANY_UID_PREFIX = 'cmp_';
|
|
|
83
84
|
export function looksLikeCompanyUid(ref) {
|
|
84
85
|
return ref.startsWith(COMPANY_UID_PREFIX);
|
|
85
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
|
+
}
|
|
86
95
|
async function resolveCompanyByUid(token, uid) {
|
|
87
96
|
const res = await vaultApiFetch({
|
|
88
97
|
token,
|
|
89
98
|
path: `/entity/${encodeURIComponent(uid)}`,
|
|
90
99
|
});
|
|
91
100
|
if (!res.ok) {
|
|
101
|
+
raiseIfUnauthorized(res);
|
|
92
102
|
const body = (await res.json().catch(() => ({})));
|
|
93
103
|
throw new Error(`Failed to resolve company '${uid}': ${body.error ?? res.statusText}`);
|
|
94
104
|
}
|
|
@@ -120,11 +130,13 @@ async function resolveSlugInCallerNamespace(token, slug) {
|
|
|
120
130
|
query: { type: 'company', slug },
|
|
121
131
|
});
|
|
122
132
|
if (!res.ok) {
|
|
133
|
+
raiseIfUnauthorized(res);
|
|
123
134
|
// Namespace lookup unavailable (e.g. membership table not configured →
|
|
124
|
-
// 503, or the caller has no person entity).
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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.
|
|
128
140
|
return null;
|
|
129
141
|
}
|
|
130
142
|
const data = (await res.json());
|
|
@@ -152,6 +164,7 @@ async function resolveCompanyUid(token, ref) {
|
|
|
152
164
|
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
153
165
|
});
|
|
154
166
|
if (!res.ok) {
|
|
167
|
+
raiseIfUnauthorized(res);
|
|
155
168
|
const body = (await res.json().catch(() => ({})));
|
|
156
169
|
// Residual true ambiguity: the slug matches multiple live companies and
|
|
157
170
|
// NONE is in the caller's namespace (server returns 409 SlugNotUniqueError
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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) …
|
package/src/commands/outposts.ts
CHANGED
|
@@ -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.
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
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,10 @@ 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";
|
|
65
67
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
66
68
|
import {
|
|
67
69
|
maybeWarnNewVersion,
|
|
@@ -315,6 +317,23 @@ export async function runCli(): Promise<void> {
|
|
|
315
317
|
// prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
|
|
316
318
|
process.stderr.write(`hq: ${(err as Error).message}\n`);
|
|
317
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;
|
|
318
337
|
} else {
|
|
319
338
|
// A full disk / exhausted quota / read-only filesystem is the user's
|
|
320
339
|
// machine, not an HQ code defect. Surface a clear, actionable message and
|
|
@@ -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,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
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -6,6 +6,7 @@ vi.mock('../sentry.js', () => ({
|
|
|
6
6
|
|
|
7
7
|
import { Sentry } from '../sentry.js';
|
|
8
8
|
import { getCompanyUid, getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
|
|
9
|
+
import { isAuthError } from './auth-error.js';
|
|
9
10
|
import { isCompanySelectionError } from './company-selection-error.js';
|
|
10
11
|
|
|
11
12
|
const fetchMock = vi.fn();
|
|
@@ -270,6 +271,68 @@ describe('getCompanyUid company-selection classification (HQ-CLI-7)', () => {
|
|
|
270
271
|
});
|
|
271
272
|
});
|
|
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
|
+
|
|
273
336
|
describe('vaultApiFetch breadcrumb URL sanitization', () => {
|
|
274
337
|
it('redacts query string in request breadcrumb data.url', async () => {
|
|
275
338
|
fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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';
|
|
3
4
|
import { CompanySelectionError } from './company-selection-error.js';
|
|
4
5
|
|
|
5
6
|
export interface VaultApiOptions {
|
|
@@ -107,12 +108,21 @@ export function looksLikeCompanyUid(ref: string): boolean {
|
|
|
107
108
|
return ref.startsWith(COMPANY_UID_PREFIX);
|
|
108
109
|
}
|
|
109
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
|
+
|
|
110
119
|
async function resolveCompanyByUid(token: string, uid: string): Promise<string> {
|
|
111
120
|
const res = await vaultApiFetch({
|
|
112
121
|
token,
|
|
113
122
|
path: `/entity/${encodeURIComponent(uid)}`,
|
|
114
123
|
});
|
|
115
124
|
if (!res.ok) {
|
|
125
|
+
raiseIfUnauthorized(res);
|
|
116
126
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
117
127
|
throw new Error(
|
|
118
128
|
`Failed to resolve company '${uid}': ${body.error ?? res.statusText}`,
|
|
@@ -150,11 +160,13 @@ async function resolveSlugInCallerNamespace(
|
|
|
150
160
|
query: { type: 'company', slug },
|
|
151
161
|
});
|
|
152
162
|
if (!res.ok) {
|
|
163
|
+
raiseIfUnauthorized(res);
|
|
153
164
|
// Namespace lookup unavailable (e.g. membership table not configured →
|
|
154
|
-
// 503, or the caller has no person entity).
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
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.
|
|
158
170
|
return null;
|
|
159
171
|
}
|
|
160
172
|
const data = (await res.json()) as {
|
|
@@ -188,6 +200,7 @@ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
|
|
|
188
200
|
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
189
201
|
});
|
|
190
202
|
if (!res.ok) {
|
|
203
|
+
raiseIfUnauthorized(res);
|
|
191
204
|
const body = (await res.json().catch(() => ({}))) as {
|
|
192
205
|
error?: string;
|
|
193
206
|
uids?: string[];
|