@indigoai-us/hq-cli 5.73.1 → 5.75.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 +10 -7
- package/dist/commands/outposts.js +137 -12
- 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 +137 -0
- package/src/commands/outposts.ts +215 -13
- 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
package/src/commands/outposts.ts
CHANGED
|
@@ -268,6 +268,8 @@ export interface OutpostExecSubmission {
|
|
|
268
268
|
instanceId: string;
|
|
269
269
|
commandId: string;
|
|
270
270
|
outputPrefix: string;
|
|
271
|
+
/** Shell budget applied server-side (AWS-RunShellScript executionTimeout). */
|
|
272
|
+
executionTimeoutSeconds?: number;
|
|
271
273
|
}
|
|
272
274
|
|
|
273
275
|
/** Poll response from `mode: "result"`; streams arrive only when terminal. */
|
|
@@ -300,12 +302,17 @@ export async function submitExec(
|
|
|
300
302
|
token: string,
|
|
301
303
|
command: string,
|
|
302
304
|
outpostId?: string,
|
|
305
|
+
timeoutSeconds?: number,
|
|
303
306
|
): Promise<OutpostExecSubmission> {
|
|
304
307
|
return outpostRequest({
|
|
305
308
|
token,
|
|
306
309
|
path: "/outpost/exec",
|
|
307
310
|
method: "POST",
|
|
308
|
-
body: {
|
|
311
|
+
body: {
|
|
312
|
+
mode: "submit",
|
|
313
|
+
command,
|
|
314
|
+
...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
|
|
315
|
+
},
|
|
309
316
|
query: outpostId ? { outpostId } : undefined,
|
|
310
317
|
});
|
|
311
318
|
}
|
|
@@ -364,14 +371,15 @@ async function waitForExecResult(
|
|
|
364
371
|
* caller's command. `exec` runs over two transports with two different default
|
|
365
372
|
* working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
|
|
366
373
|
* 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
|
-
*
|
|
374
|
+
* printed an unhelpful, transport-dependent directory. Initialize a real root
|
|
375
|
+
* home for the SSM case before resolving the HQ folder: tools run by the caller
|
|
376
|
+
* (notably `gh`) otherwise treat the HQ checkout as their home and can create
|
|
377
|
+
* root-owned machine state inside it. The trailing `|| true` keeps the command
|
|
378
|
+
* running from the default directory when no HQ checkout is present, so exec
|
|
379
|
+
* never fails merely because the box has no HQ folder.
|
|
372
380
|
*/
|
|
373
381
|
export const REMOTE_HQ_DIR_PREFIX =
|
|
374
|
-
'cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
|
|
382
|
+
'export HOME="${HOME:-/root}"; cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
|
|
375
383
|
|
|
376
384
|
/** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
|
|
377
385
|
export function withRemoteHqDir(command: string): string {
|
|
@@ -1249,14 +1257,40 @@ export function registerOutpostsCommand(
|
|
|
1249
1257
|
outposts
|
|
1250
1258
|
.command("exec <command...>")
|
|
1251
1259
|
.description(
|
|
1252
|
-
"Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command)"
|
|
1260
|
+
"Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
|
|
1261
|
+
"Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.",
|
|
1253
1262
|
)
|
|
1254
1263
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1264
|
+
.option(
|
|
1265
|
+
"--async",
|
|
1266
|
+
"Submit via the async transport and wait for completion (bypasses the ~20s sync cap; shell budget defaults to 48h)",
|
|
1267
|
+
)
|
|
1268
|
+
.option(
|
|
1269
|
+
"--detach",
|
|
1270
|
+
"Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)",
|
|
1271
|
+
)
|
|
1272
|
+
.option(
|
|
1273
|
+
"--timeout-seconds <n>",
|
|
1274
|
+
"Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.",
|
|
1275
|
+
(v: string) => {
|
|
1276
|
+
const n = Number(v);
|
|
1277
|
+
if (!Number.isInteger(n)) {
|
|
1278
|
+
throw new Error("--timeout-seconds must be an integer");
|
|
1279
|
+
}
|
|
1280
|
+
return n;
|
|
1281
|
+
},
|
|
1282
|
+
)
|
|
1255
1283
|
.option("--json", "Emit raw JSON")
|
|
1256
1284
|
.action(async function (
|
|
1257
1285
|
this: Command,
|
|
1258
1286
|
commandParts: string[],
|
|
1259
|
-
opts: {
|
|
1287
|
+
opts: {
|
|
1288
|
+
id?: string;
|
|
1289
|
+
json?: boolean;
|
|
1290
|
+
async?: boolean;
|
|
1291
|
+
detach?: boolean;
|
|
1292
|
+
timeoutSeconds?: number;
|
|
1293
|
+
},
|
|
1260
1294
|
) {
|
|
1261
1295
|
try {
|
|
1262
1296
|
const command = joinCommandParts(commandParts);
|
|
@@ -1264,11 +1298,137 @@ export function registerOutpostsCommand(
|
|
|
1264
1298
|
console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
|
|
1265
1299
|
process.exit(1);
|
|
1266
1300
|
}
|
|
1301
|
+
if (opts.async && opts.detach) {
|
|
1302
|
+
console.error(
|
|
1303
|
+
chalk.red("Use either --async (submit + wait) or --detach (submit only), not both."),
|
|
1304
|
+
);
|
|
1305
|
+
process.exit(1);
|
|
1306
|
+
}
|
|
1307
|
+
// --timeout-seconds only applies to the async path; bare use implies --async.
|
|
1308
|
+
const useAsync =
|
|
1309
|
+
Boolean(opts.async) ||
|
|
1310
|
+
Boolean(opts.detach) ||
|
|
1311
|
+
opts.timeoutSeconds !== undefined;
|
|
1312
|
+
if (opts.timeoutSeconds !== undefined) {
|
|
1313
|
+
if (
|
|
1314
|
+
!Number.isInteger(opts.timeoutSeconds) ||
|
|
1315
|
+
opts.timeoutSeconds < 1 ||
|
|
1316
|
+
opts.timeoutSeconds > 172_800
|
|
1317
|
+
) {
|
|
1318
|
+
console.error(
|
|
1319
|
+
chalk.red(
|
|
1320
|
+
"--timeout-seconds must be an integer between 1 and 172800 (48h, the AWS-RunShellScript max)",
|
|
1321
|
+
),
|
|
1322
|
+
);
|
|
1323
|
+
process.exit(1);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1267
1326
|
const token = await ensureCognitoToken();
|
|
1268
1327
|
|
|
1269
1328
|
// Run from the box's HQ folder by default (works over both SSM and SSH).
|
|
1270
1329
|
const remoteCommand = withRemoteHqDir(command);
|
|
1271
1330
|
|
|
1331
|
+
if (useAsync) {
|
|
1332
|
+
try {
|
|
1333
|
+
const submitted = await submitExec(
|
|
1334
|
+
token,
|
|
1335
|
+
remoteCommand,
|
|
1336
|
+
opts.id,
|
|
1337
|
+
opts.timeoutSeconds,
|
|
1338
|
+
);
|
|
1339
|
+
if (opts.detach) {
|
|
1340
|
+
const output = {
|
|
1341
|
+
commandId: submitted.commandId,
|
|
1342
|
+
...(submitted.executionTimeoutSeconds !== undefined
|
|
1343
|
+
? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
|
|
1344
|
+
: opts.timeoutSeconds !== undefined
|
|
1345
|
+
? { executionTimeoutSeconds: opts.timeoutSeconds }
|
|
1346
|
+
: {}),
|
|
1347
|
+
};
|
|
1348
|
+
if (opts.json) {
|
|
1349
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1350
|
+
} else {
|
|
1351
|
+
printKeyValues(output);
|
|
1352
|
+
console.error(
|
|
1353
|
+
chalk.dim(
|
|
1354
|
+
"Submitted. Poll with: hq outposts exec-result --command-id " +
|
|
1355
|
+
submitted.commandId +
|
|
1356
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
1357
|
+
" --wait",
|
|
1358
|
+
),
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// --async (or --timeout-seconds without --detach): wait for terminal.
|
|
1365
|
+
if (!opts.json) {
|
|
1366
|
+
console.error(
|
|
1367
|
+
chalk.dim(
|
|
1368
|
+
`Submitted ${submitted.commandId}; waiting for completion…`,
|
|
1369
|
+
),
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
const result = await waitForExecResult(
|
|
1373
|
+
token,
|
|
1374
|
+
submitted.commandId,
|
|
1375
|
+
opts.id,
|
|
1376
|
+
);
|
|
1377
|
+
if (opts.json) {
|
|
1378
|
+
process.stdout.write(
|
|
1379
|
+
JSON.stringify(
|
|
1380
|
+
{
|
|
1381
|
+
commandId: submitted.commandId,
|
|
1382
|
+
done: result.done,
|
|
1383
|
+
status: result.status,
|
|
1384
|
+
exitCode: result.exitCode ?? null,
|
|
1385
|
+
stdout: result.stdout ?? "",
|
|
1386
|
+
stderr: result.stderr ?? "",
|
|
1387
|
+
truncated: result.truncated ?? false,
|
|
1388
|
+
},
|
|
1389
|
+
null,
|
|
1390
|
+
2,
|
|
1391
|
+
) + "\n",
|
|
1392
|
+
);
|
|
1393
|
+
} else {
|
|
1394
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
1395
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1396
|
+
if (result.truncated) {
|
|
1397
|
+
console.error(
|
|
1398
|
+
chalk.yellow(
|
|
1399
|
+
"(output truncated — redirect to a file on the box for full output)",
|
|
1400
|
+
),
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
if (result.status !== "Success" && result.exitCode === null) {
|
|
1404
|
+
console.error(
|
|
1405
|
+
chalk.yellow(`(command ended with SSM status: ${result.status})`),
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
process.exitCode =
|
|
1410
|
+
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
1411
|
+
return;
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
// Async requires EC2/SSM. Lightsail has no async channel — refuse
|
|
1414
|
+
// rather than silently falling back to a live SSH hold, which is
|
|
1415
|
+
// the exact timeout failure mode --async is meant to escape.
|
|
1416
|
+
if (
|
|
1417
|
+
err instanceof OutpostHttpError &&
|
|
1418
|
+
err.step === "platform-unsupported"
|
|
1419
|
+
) {
|
|
1420
|
+
console.error(
|
|
1421
|
+
chalk.red(
|
|
1422
|
+
"Async exec requires an EC2 Outpost (SSM). This box is Lightsail — " +
|
|
1423
|
+
"re-provision on EC2, or run a short sync command / SSH session instead.",
|
|
1424
|
+
),
|
|
1425
|
+
);
|
|
1426
|
+
process.exit(1);
|
|
1427
|
+
}
|
|
1428
|
+
throw err;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1272
1432
|
try {
|
|
1273
1433
|
const result = await execOutpost(token, remoteCommand, opts.id);
|
|
1274
1434
|
if (opts.json) {
|
|
@@ -1368,13 +1528,26 @@ export function registerOutpostsCommand(
|
|
|
1368
1528
|
|
|
1369
1529
|
outposts
|
|
1370
1530
|
.command("exec-submit <command...>")
|
|
1371
|
-
.description(
|
|
1531
|
+
.description(
|
|
1532
|
+
"Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)",
|
|
1533
|
+
)
|
|
1372
1534
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1535
|
+
.option(
|
|
1536
|
+
"--timeout-seconds <n>",
|
|
1537
|
+
"Shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)",
|
|
1538
|
+
(v: string) => {
|
|
1539
|
+
const n = Number(v);
|
|
1540
|
+
if (!Number.isInteger(n)) {
|
|
1541
|
+
throw new Error("--timeout-seconds must be an integer");
|
|
1542
|
+
}
|
|
1543
|
+
return n;
|
|
1544
|
+
},
|
|
1545
|
+
)
|
|
1373
1546
|
.option("--json", "Emit raw JSON")
|
|
1374
1547
|
.action(async function (
|
|
1375
1548
|
this: Command,
|
|
1376
1549
|
commandParts: string[],
|
|
1377
|
-
opts: { id?: string; json?: boolean },
|
|
1550
|
+
opts: { id?: string; json?: boolean; timeoutSeconds?: number },
|
|
1378
1551
|
) {
|
|
1379
1552
|
try {
|
|
1380
1553
|
const command = joinCommandParts(commandParts);
|
|
@@ -1384,9 +1557,38 @@ export function registerOutpostsCommand(
|
|
|
1384
1557
|
);
|
|
1385
1558
|
process.exit(1);
|
|
1386
1559
|
}
|
|
1560
|
+
if (opts.timeoutSeconds !== undefined) {
|
|
1561
|
+
if (
|
|
1562
|
+
!Number.isInteger(opts.timeoutSeconds) ||
|
|
1563
|
+
opts.timeoutSeconds < 1 ||
|
|
1564
|
+
opts.timeoutSeconds > 172_800
|
|
1565
|
+
) {
|
|
1566
|
+
console.error(
|
|
1567
|
+
chalk.red(
|
|
1568
|
+
"--timeout-seconds must be an integer between 1 and 172800 (48h)",
|
|
1569
|
+
),
|
|
1570
|
+
);
|
|
1571
|
+
process.exit(1);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1387
1574
|
const token = await ensureCognitoToken();
|
|
1388
|
-
|
|
1389
|
-
|
|
1575
|
+
// exec-submit is the raw fire-and-forget path — do NOT wrap with
|
|
1576
|
+
// withRemoteHqDir here (callers that want the HQ cwd use `exec --async`
|
|
1577
|
+
// or prefix their own cd). Matches the existing contract.
|
|
1578
|
+
const submitted = await submitExec(
|
|
1579
|
+
token,
|
|
1580
|
+
command,
|
|
1581
|
+
opts.id,
|
|
1582
|
+
opts.timeoutSeconds,
|
|
1583
|
+
);
|
|
1584
|
+
const output = {
|
|
1585
|
+
commandId: submitted.commandId,
|
|
1586
|
+
...(submitted.executionTimeoutSeconds !== undefined
|
|
1587
|
+
? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
|
|
1588
|
+
: opts.timeoutSeconds !== undefined
|
|
1589
|
+
? { executionTimeoutSeconds: opts.timeoutSeconds }
|
|
1590
|
+
: {}),
|
|
1591
|
+
};
|
|
1390
1592
|
if (opts.json) {
|
|
1391
1593
|
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1392
1594
|
} else {
|
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[];
|