@indigoai-us/hq-cli 5.72.0 → 5.73.1
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/outposts.d.ts +7 -0
- package/dist/commands/outposts.js +49 -2
- package/dist/main.js +13 -0
- package/dist/utils/company-selection-error.d.ts +21 -0
- package/dist/utils/company-selection-error.js +44 -0
- package/dist/utils/vault-api.js +4 -3
- package/package.json +2 -2
- package/src/commands/outposts.test.ts +32 -0
- package/src/commands/outposts.ts +72 -2
- package/src/main.ts +12 -0
- package/src/packaging.test.ts +9 -0
- package/src/utils/company-selection-error.test.ts +42 -0
- package/src/utils/company-selection-error.ts +45 -0
- package/src/utils/vault-api.test.ts +72 -1
- package/src/utils/vault-api.ts +4 -3
|
@@ -73,6 +73,13 @@ export declare function listOutposts(token: string): Promise<OutpostSummary[]>;
|
|
|
73
73
|
export declare function getOutpostStatus(token: string, outpostId?: string): Promise<Record<string, unknown>>;
|
|
74
74
|
export declare function enableCodex(token: string, outpostId?: string): Promise<Record<string, unknown>>;
|
|
75
75
|
export declare function regenerateLoginUrl(token: string, outpostId?: string): Promise<Record<string, unknown>>;
|
|
76
|
+
/**
|
|
77
|
+
* Hand the box the one-time Claude sign-in code the operator got from the login
|
|
78
|
+
* URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
|
|
79
|
+
* it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
|
|
80
|
+
* terminal-native equivalent of pasting the code into the web console.
|
|
81
|
+
*/
|
|
82
|
+
export declare function submitLoginCode(token: string, code: string, outpostId?: string): Promise<Record<string, unknown>>;
|
|
76
83
|
export declare function destroyOutpost(token: string, outpostId?: string): Promise<Record<string, unknown>>;
|
|
77
84
|
/** Result of `POST /outpost/exec` — a terminal SSM invocation on the box. */
|
|
78
85
|
export interface OutpostExecResult {
|
|
@@ -116,6 +116,21 @@ export async function regenerateLoginUrl(token, outpostId) {
|
|
|
116
116
|
query: outpostId ? { outpostId } : undefined,
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Hand the box the one-time Claude sign-in code the operator got from the login
|
|
121
|
+
* URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
|
|
122
|
+
* it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
|
|
123
|
+
* terminal-native equivalent of pasting the code into the web console.
|
|
124
|
+
*/
|
|
125
|
+
export async function submitLoginCode(token, code, outpostId) {
|
|
126
|
+
return outpostRequest({
|
|
127
|
+
token,
|
|
128
|
+
path: "/outpost/login-code",
|
|
129
|
+
method: "POST",
|
|
130
|
+
body: { code },
|
|
131
|
+
query: outpostId ? { outpostId } : undefined,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
119
134
|
export async function destroyOutpost(token, outpostId) {
|
|
120
135
|
return outpostRequest({
|
|
121
136
|
token,
|
|
@@ -1011,8 +1026,40 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
1011
1026
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1012
1027
|
return;
|
|
1013
1028
|
}
|
|
1014
|
-
console.log(chalk.green("Login-URL regeneration requested. The box mints a fresh URL shortly
|
|
1015
|
-
|
|
1029
|
+
console.log(chalk.green("Login-URL regeneration requested. The box mints a fresh URL shortly."));
|
|
1030
|
+
console.log(chalk.dim("Next: `hq outposts status" +
|
|
1031
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
1032
|
+
"` to read the login URL, open it and sign in, then paste the code back with " +
|
|
1033
|
+
"`hq outposts login-code <code>" +
|
|
1034
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
1035
|
+
"`."));
|
|
1036
|
+
}
|
|
1037
|
+
catch (err) {
|
|
1038
|
+
fail(err);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
outposts
|
|
1042
|
+
.command("login-code <code>")
|
|
1043
|
+
.description("Submit the Claude sign-in code for an Outpost that is awaiting login")
|
|
1044
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1045
|
+
.option("--json", "Emit raw JSON")
|
|
1046
|
+
.action(async function (code, opts) {
|
|
1047
|
+
try {
|
|
1048
|
+
const trimmed = code.trim();
|
|
1049
|
+
if (!trimmed) {
|
|
1050
|
+
console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
const token = await ensureCognitoToken();
|
|
1054
|
+
const result = await submitLoginCode(token, trimmed, opts.id);
|
|
1055
|
+
if (opts.json) {
|
|
1056
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
console.log(chalk.green("Code submitted — the box will finish signing in shortly."));
|
|
1060
|
+
console.log(chalk.dim("Track it: `hq outposts status" +
|
|
1061
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
1062
|
+
"` (it flips to `ready` once Claude auth completes)."));
|
|
1016
1063
|
}
|
|
1017
1064
|
catch (err) {
|
|
1018
1065
|
fail(err);
|
package/dist/main.js
CHANGED
|
@@ -60,6 +60,7 @@ import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
|
60
60
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
61
61
|
import { isEpipe } from "./utils/epipe.js";
|
|
62
62
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
63
|
+
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
63
64
|
import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
|
|
64
65
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
65
66
|
import { CLI_VERSION } from "./cli-version.js";
|
|
@@ -253,6 +254,18 @@ export async function runCli() {
|
|
|
253
254
|
// degradation) and preserve the intended non-zero exit (HQ-CLI-3).
|
|
254
255
|
process.exitCode = 1;
|
|
255
256
|
}
|
|
257
|
+
else if (isCompanySelectionError(err)) {
|
|
258
|
+
// The user has multiple (or zero) active company memberships and ran a
|
|
259
|
+
// command that needs exactly one without `--company`, or a `--company`
|
|
260
|
+
// slug collided across companies. That's an expected, user-actionable
|
|
261
|
+
// disambiguation prompt — the message already tells them exactly how to
|
|
262
|
+
// proceed (re-run with `--company <slug-or-uid>`) — not an hq-cli defect.
|
|
263
|
+
// The CLI can't pick a company for them. Print the actionable message and
|
|
264
|
+
// exit non-zero, but skip Sentry capture so a normal "pick a company"
|
|
265
|
+
// prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
|
|
266
|
+
process.stderr.write(`hq: ${err.message}\n`);
|
|
267
|
+
process.exitCode = 1;
|
|
268
|
+
}
|
|
256
269
|
else {
|
|
257
270
|
// A full disk / exhausted quota / read-only filesystem is the user's
|
|
258
271
|
// machine, not an HQ code defect. Surface a clear, actionable message and
|
|
@@ -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
|
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 { CompanySelectionError } from './company-selection-error.js';
|
|
3
4
|
export async function vaultApiFetch(opts) {
|
|
4
5
|
const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
|
|
5
6
|
if (opts.query) {
|
|
@@ -158,7 +159,7 @@ async function resolveCompanyUid(token, ref) {
|
|
|
158
159
|
// tell them exactly how — re-run with `--company <uid>` — and list the
|
|
159
160
|
// candidates, instead of echoing the generic server message.
|
|
160
161
|
if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
|
|
161
|
-
throw new
|
|
162
|
+
throw new CompanySelectionError(`Company slug '${ref}' matches ${body.uids.length} companies and none ` +
|
|
162
163
|
`is in your namespace. Re-run with --company <uid> to pick one:\n` +
|
|
163
164
|
body.uids.map((u) => ` --company ${u}`).join('\n'));
|
|
164
165
|
}
|
|
@@ -178,13 +179,13 @@ async function resolveCompanyFromMemberships(token) {
|
|
|
178
179
|
const data = (await res.json());
|
|
179
180
|
const active = data.memberships.filter((m) => m.status === 'active');
|
|
180
181
|
if (active.length === 0) {
|
|
181
|
-
throw new
|
|
182
|
+
throw new CompanySelectionError('No active company memberships found. Use --company <slug> to specify.');
|
|
182
183
|
}
|
|
183
184
|
if (active.length === 1) {
|
|
184
185
|
return active[0].companyUid;
|
|
185
186
|
}
|
|
186
187
|
const uids = active.map((m) => m.companyUid);
|
|
187
|
-
throw new
|
|
188
|
+
throw new CompanySelectionError(`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
|
|
188
189
|
`pick one:\n` +
|
|
189
190
|
uids.map((u) => ` --company ${u}`).join('\n'));
|
|
190
191
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.73.1",
|
|
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",
|
|
@@ -171,6 +171,38 @@ describe("hq outposts codex-enable / login", () => {
|
|
|
171
171
|
expect(String(url)).toContain("/outpost/regenerate-login-url");
|
|
172
172
|
expect(init?.method).toBe("POST");
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
it("login-code POSTs /outpost/login-code with the code in the body", async () => {
|
|
176
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
|
|
177
|
+
await run(["outposts", "login-code", "ABC-123"]);
|
|
178
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
179
|
+
expect(String(url)).toContain("/outpost/login-code");
|
|
180
|
+
expect(init?.method).toBe("POST");
|
|
181
|
+
expect(JSON.parse(String(init?.body))).toEqual({ code: "ABC-123" });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("login-code targets a specific box via --id (outpostId query)", async () => {
|
|
185
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
|
|
186
|
+
await run(["outposts", "login-code", "CODE", "--id", "3"]);
|
|
187
|
+
const [url] = fetchSpy.mock.calls[0];
|
|
188
|
+
expect(String(url)).toContain("outpostId=3");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("login-code trims whitespace and refuses an empty code without calling the API", async () => {
|
|
192
|
+
await expect(run(["outposts", "login-code", " "])).rejects.toThrow(
|
|
193
|
+
"process.exit(1)",
|
|
194
|
+
);
|
|
195
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("login-code surfaces an awaiting-login not-found error", async () => {
|
|
199
|
+
fetchSpy.mockResolvedValueOnce(
|
|
200
|
+
jsonResponse(404, { error: true, step: "not-found", message: "no outpost" }),
|
|
201
|
+
);
|
|
202
|
+
await expect(run(["outposts", "login-code", "CODE"])).rejects.toThrow(
|
|
203
|
+
"process.exit(1)",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
174
206
|
});
|
|
175
207
|
|
|
176
208
|
describe("hq outposts destroy", () => {
|
package/src/commands/outposts.ts
CHANGED
|
@@ -182,6 +182,26 @@ export async function regenerateLoginUrl(
|
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Hand the box the one-time Claude sign-in code the operator got from the login
|
|
187
|
+
* URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
|
|
188
|
+
* it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
|
|
189
|
+
* terminal-native equivalent of pasting the code into the web console.
|
|
190
|
+
*/
|
|
191
|
+
export async function submitLoginCode(
|
|
192
|
+
token: string,
|
|
193
|
+
code: string,
|
|
194
|
+
outpostId?: string,
|
|
195
|
+
): Promise<Record<string, unknown>> {
|
|
196
|
+
return outpostRequest({
|
|
197
|
+
token,
|
|
198
|
+
path: "/outpost/login-code",
|
|
199
|
+
method: "POST",
|
|
200
|
+
body: { code },
|
|
201
|
+
query: outpostId ? { outpostId } : undefined,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
export async function destroyOutpost(
|
|
186
206
|
token: string,
|
|
187
207
|
outpostId?: string,
|
|
@@ -1450,8 +1470,58 @@ export function registerOutpostsCommand(
|
|
|
1450
1470
|
}
|
|
1451
1471
|
console.log(
|
|
1452
1472
|
chalk.green(
|
|
1453
|
-
"Login-URL regeneration requested. The box mints a fresh URL shortly
|
|
1454
|
-
|
|
1473
|
+
"Login-URL regeneration requested. The box mints a fresh URL shortly.",
|
|
1474
|
+
),
|
|
1475
|
+
);
|
|
1476
|
+
console.log(
|
|
1477
|
+
chalk.dim(
|
|
1478
|
+
"Next: `hq outposts status" +
|
|
1479
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
1480
|
+
"` to read the login URL, open it and sign in, then paste the code back with " +
|
|
1481
|
+
"`hq outposts login-code <code>" +
|
|
1482
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
1483
|
+
"`.",
|
|
1484
|
+
),
|
|
1485
|
+
);
|
|
1486
|
+
} catch (err) {
|
|
1487
|
+
fail(err);
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
outposts
|
|
1492
|
+
.command("login-code <code>")
|
|
1493
|
+
.description(
|
|
1494
|
+
"Submit the Claude sign-in code for an Outpost that is awaiting login",
|
|
1495
|
+
)
|
|
1496
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1497
|
+
.option("--json", "Emit raw JSON")
|
|
1498
|
+
.action(async function (
|
|
1499
|
+
this: Command,
|
|
1500
|
+
code: string,
|
|
1501
|
+
opts: { id?: string; json?: boolean },
|
|
1502
|
+
) {
|
|
1503
|
+
try {
|
|
1504
|
+
const trimmed = code.trim();
|
|
1505
|
+
if (!trimmed) {
|
|
1506
|
+
console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
|
|
1507
|
+
process.exit(1);
|
|
1508
|
+
}
|
|
1509
|
+
const token = await ensureCognitoToken();
|
|
1510
|
+
const result = await submitLoginCode(token, trimmed, opts.id);
|
|
1511
|
+
if (opts.json) {
|
|
1512
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
console.log(
|
|
1516
|
+
chalk.green(
|
|
1517
|
+
"Code submitted — the box will finish signing in shortly.",
|
|
1518
|
+
),
|
|
1519
|
+
);
|
|
1520
|
+
console.log(
|
|
1521
|
+
chalk.dim(
|
|
1522
|
+
"Track it: `hq outposts status" +
|
|
1523
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
1524
|
+
"` (it flips to `ready` once Claude auth completes).",
|
|
1455
1525
|
),
|
|
1456
1526
|
);
|
|
1457
1527
|
} catch (err) {
|
package/src/main.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
|
62
62
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
63
63
|
import { isEpipe } from "./utils/epipe.js";
|
|
64
64
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
65
|
+
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
65
66
|
import {
|
|
66
67
|
maybeWarnNewVersion,
|
|
67
68
|
refreshVersionCache,
|
|
@@ -303,6 +304,17 @@ export async function runCli(): Promise<void> {
|
|
|
303
304
|
// thrown or captured. Skip Sentry capture (no signal, no user-facing
|
|
304
305
|
// degradation) and preserve the intended non-zero exit (HQ-CLI-3).
|
|
305
306
|
process.exitCode = 1;
|
|
307
|
+
} else if (isCompanySelectionError(err)) {
|
|
308
|
+
// The user has multiple (or zero) active company memberships and ran a
|
|
309
|
+
// command that needs exactly one without `--company`, or a `--company`
|
|
310
|
+
// slug collided across companies. That's an expected, user-actionable
|
|
311
|
+
// disambiguation prompt — the message already tells them exactly how to
|
|
312
|
+
// proceed (re-run with `--company <slug-or-uid>`) — not an hq-cli defect.
|
|
313
|
+
// The CLI can't pick a company for them. Print the actionable message and
|
|
314
|
+
// exit non-zero, but skip Sentry capture so a normal "pick a company"
|
|
315
|
+
// prompt doesn't flood the tracker with unfixable "crashes" (HQ-CLI-7).
|
|
316
|
+
process.stderr.write(`hq: ${(err as Error).message}\n`);
|
|
317
|
+
process.exitCode = 1;
|
|
306
318
|
} else {
|
|
307
319
|
// A full disk / exhausted quota / read-only filesystem is the user's
|
|
308
320
|
// machine, not an HQ code defect. Surface a clear, actionable message and
|
package/src/packaging.test.ts
CHANGED
|
@@ -18,6 +18,8 @@ const pkg = JSON.parse(
|
|
|
18
18
|
readFileSync(resolve(repoRoot, "package.json"), "utf8"),
|
|
19
19
|
) as {
|
|
20
20
|
bin?: Record<string, string> | string;
|
|
21
|
+
dependencies?: Record<string, string>;
|
|
22
|
+
devDependencies?: Record<string, string>;
|
|
21
23
|
scripts?: Record<string, string>;
|
|
22
24
|
};
|
|
23
25
|
|
|
@@ -53,3 +55,10 @@ describe("packaging: bin executable bit", () => {
|
|
|
53
55
|
}
|
|
54
56
|
});
|
|
55
57
|
});
|
|
58
|
+
|
|
59
|
+
describe("packaging: runtime dependencies", () => {
|
|
60
|
+
it("ships the S3 client imported by files-browse", () => {
|
|
61
|
+
expect(pkg.dependencies).toHaveProperty("@aws-sdk/client-s3");
|
|
62
|
+
expect(pkg.devDependencies).not.toHaveProperty("@aws-sdk/client-s3");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
CompanySelectionError,
|
|
4
|
+
isCompanySelectionError,
|
|
5
|
+
} from "./company-selection-error.js";
|
|
6
|
+
|
|
7
|
+
describe("isCompanySelectionError", () => {
|
|
8
|
+
// HQ-CLI-7: the exact error that flooded Sentry when a user with multiple
|
|
9
|
+
// active memberships ran a command with no --company.
|
|
10
|
+
it("classifies a CompanySelectionError as a selection prompt (skip Sentry)", () => {
|
|
11
|
+
const err = new CompanySelectionError(
|
|
12
|
+
"Multiple active companies found. Re-run with --company <slug-or-uid> to pick one:\n --company cmp_a\n --company cmp_b",
|
|
13
|
+
);
|
|
14
|
+
expect(isCompanySelectionError(err)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("preserves the user-facing message verbatim for the top-level handler", () => {
|
|
18
|
+
const msg = "No active company memberships found. Use --company <slug> to specify.";
|
|
19
|
+
expect(new CompanySelectionError(msg).message).toBe(msg);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps instanceof across the transpile target", () => {
|
|
23
|
+
const err = new CompanySelectionError("pick one");
|
|
24
|
+
expect(err).toBeInstanceOf(CompanySelectionError);
|
|
25
|
+
expect(err).toBeInstanceOf(Error);
|
|
26
|
+
expect(err.name).toBe("CompanySelectionError");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// A genuine defect must still reach Sentry — only the disambiguation class is
|
|
30
|
+
// diverted, so real bugs are never silently swallowed.
|
|
31
|
+
it("does NOT match a plain Error (so real faults still report)", () => {
|
|
32
|
+
expect(isCompanySelectionError(new Error("Multiple active companies found."))).toBe(false);
|
|
33
|
+
expect(isCompanySelectionError(new Error("boom"))).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does NOT match non-error values", () => {
|
|
37
|
+
expect(isCompanySelectionError(null)).toBe(false);
|
|
38
|
+
expect(isCompanySelectionError(undefined)).toBe(false);
|
|
39
|
+
expect(isCompanySelectionError("Multiple active companies found.")).toBe(false);
|
|
40
|
+
expect(isCompanySelectionError({ message: "pick one" })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Thrown when the CLI cannot resolve a single company on the user's behalf and
|
|
20
|
+
* the user must re-run with `--company <slug-or-uid>`:
|
|
21
|
+
* - they have multiple active memberships and passed no `--company`,
|
|
22
|
+
* - they have no active membership and passed no `--company`, or
|
|
23
|
+
* - a `--company` slug collides across companies, none in their namespace.
|
|
24
|
+
*
|
|
25
|
+
* The `message` is already user-facing and actionable — the top-level handler
|
|
26
|
+
* prints it verbatim and skips Sentry capture.
|
|
27
|
+
*/
|
|
28
|
+
export class CompanySelectionError extends Error {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "CompanySelectionError";
|
|
32
|
+
// Preserve `instanceof` across the TS→ES5/ES2015 transpile target.
|
|
33
|
+
Object.setPrototypeOf(this, CompanySelectionError.prototype);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True when `err` is a company-selection disambiguation prompt the user must
|
|
39
|
+
* resolve with `--company`. Callers should print `err.message` and SKIP Sentry
|
|
40
|
+
* capture (expected usage, no defect) while preserving a non-zero exit. Genuine
|
|
41
|
+
* faults are plain `Error`s and return `false`, so real bugs still report.
|
|
42
|
+
*/
|
|
43
|
+
export function isCompanySelectionError(err: unknown): boolean {
|
|
44
|
+
return err instanceof CompanySelectionError;
|
|
45
|
+
}
|
|
@@ -5,7 +5,8 @@ vi.mock('../sentry.js', () => ({
|
|
|
5
5
|
}));
|
|
6
6
|
|
|
7
7
|
import { Sentry } from '../sentry.js';
|
|
8
|
-
import { getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
|
|
8
|
+
import { getCompanyUid, getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
|
|
9
|
+
import { isCompanySelectionError } from './company-selection-error.js';
|
|
9
10
|
|
|
10
11
|
const fetchMock = vi.fn();
|
|
11
12
|
const originalFetch = globalThis.fetch;
|
|
@@ -199,6 +200,76 @@ describe('getEntityUid', () => {
|
|
|
199
200
|
});
|
|
200
201
|
});
|
|
201
202
|
|
|
203
|
+
describe('getCompanyUid company-selection classification (HQ-CLI-7)', () => {
|
|
204
|
+
// The exact production scenario: a user with multiple active memberships runs
|
|
205
|
+
// a command with no --company. The prompt is expected, user-actionable guidance
|
|
206
|
+
// — it must be a CompanySelectionError so the top-level handler prints it and
|
|
207
|
+
// SKIPS Sentry capture, not a plain Error that gets shipped as a fatal.
|
|
208
|
+
it('throws a CompanySelectionError (not a plain Error) on multiple active memberships', async () => {
|
|
209
|
+
fetchMock.mockResolvedValueOnce(
|
|
210
|
+
mockResponse(200, {
|
|
211
|
+
memberships: [
|
|
212
|
+
{ companyUid: 'cmp_a', role: 'member', status: 'active', membershipKey: 'k1' },
|
|
213
|
+
{ companyUid: 'cmp_b', role: 'owner', status: 'active', membershipKey: 'k2' },
|
|
214
|
+
{ companyUid: 'cmp_c', role: 'member', status: 'active', membershipKey: 'k3' },
|
|
215
|
+
],
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
|
|
219
|
+
expect(isCompanySelectionError(err)).toBe(true);
|
|
220
|
+
expect((err as Error).message).toMatch(/Multiple active companies found/);
|
|
221
|
+
expect((err as Error).message).toMatch(/--company cmp_a/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('throws a CompanySelectionError when the caller has no active memberships', async () => {
|
|
225
|
+
fetchMock.mockResolvedValueOnce(
|
|
226
|
+
mockResponse(200, {
|
|
227
|
+
memberships: [
|
|
228
|
+
{ companyUid: 'cmp_x', role: 'member', status: 'invited', membershipKey: 'k' },
|
|
229
|
+
],
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
|
|
233
|
+
expect(isCompanySelectionError(err)).toBe(true);
|
|
234
|
+
expect((err as Error).message).toMatch(/No active company memberships/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('resolves silently to the single active membership (no selection error)', async () => {
|
|
238
|
+
fetchMock.mockResolvedValueOnce(
|
|
239
|
+
mockResponse(200, {
|
|
240
|
+
memberships: [
|
|
241
|
+
{ companyUid: 'cmp_only', role: 'member', status: 'active', membershipKey: 'k' },
|
|
242
|
+
],
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
const uid = await getCompanyUid('tok', undefined);
|
|
246
|
+
expect(uid).toBe('cmp_only');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('classifies the slug-collision (409, none in namespace) case as a selection error', async () => {
|
|
250
|
+
fetchMock
|
|
251
|
+
.mockResolvedValueOnce(mockResponse(200, { available: true }))
|
|
252
|
+
.mockResolvedValueOnce(
|
|
253
|
+
mockResponse(409, {
|
|
254
|
+
error: 'Slug "acme" matches 2 live entities',
|
|
255
|
+
uids: ['cmp_one', 'cmp_two'],
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
const err = await getCompanyUid('tok', 'acme').catch((e: unknown) => e);
|
|
259
|
+
expect(isCompanySelectionError(err)).toBe(true);
|
|
260
|
+
expect((err as Error).message).toMatch(/--company cmp_one/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// A genuine fault must NOT be classified as a selection prompt — it still
|
|
264
|
+
// reports to Sentry. Guards against over-broadening the carve-out.
|
|
265
|
+
it('does NOT classify a failed membership fetch as a selection error (still reports)', async () => {
|
|
266
|
+
fetchMock.mockResolvedValueOnce(mockResponse(401, { error: 'unauthorized' }));
|
|
267
|
+
const err = await getCompanyUid('tok', undefined).catch((e: unknown) => e);
|
|
268
|
+
expect(isCompanySelectionError(err)).toBe(false);
|
|
269
|
+
expect((err as Error).message).toMatch(/Failed to fetch memberships/);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
202
273
|
describe('vaultApiFetch breadcrumb URL sanitization', () => {
|
|
203
274
|
it('redacts query string in request breadcrumb data.url', async () => {
|
|
204
275
|
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 { CompanySelectionError } from './company-selection-error.js';
|
|
3
4
|
|
|
4
5
|
export interface VaultApiOptions {
|
|
5
6
|
token: string;
|
|
@@ -197,7 +198,7 @@ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
|
|
|
197
198
|
// tell them exactly how — re-run with `--company <uid>` — and list the
|
|
198
199
|
// candidates, instead of echoing the generic server message.
|
|
199
200
|
if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
|
|
200
|
-
throw new
|
|
201
|
+
throw new CompanySelectionError(
|
|
201
202
|
`Company slug '${ref}' matches ${body.uids.length} companies and none ` +
|
|
202
203
|
`is in your namespace. Re-run with --company <uid> to pick one:\n` +
|
|
203
204
|
body.uids.map((u) => ` --company ${u}`).join('\n'),
|
|
@@ -222,13 +223,13 @@ async function resolveCompanyFromMemberships(token: string): Promise<string> {
|
|
|
222
223
|
const data = (await res.json()) as { memberships: MembershipEntry[] };
|
|
223
224
|
const active = data.memberships.filter((m) => m.status === 'active');
|
|
224
225
|
if (active.length === 0) {
|
|
225
|
-
throw new
|
|
226
|
+
throw new CompanySelectionError('No active company memberships found. Use --company <slug> to specify.');
|
|
226
227
|
}
|
|
227
228
|
if (active.length === 1) {
|
|
228
229
|
return active[0].companyUid;
|
|
229
230
|
}
|
|
230
231
|
const uids = active.map((m) => m.companyUid);
|
|
231
|
-
throw new
|
|
232
|
+
throw new CompanySelectionError(
|
|
232
233
|
`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
|
|
233
234
|
`pick one:\n` +
|
|
234
235
|
uids.map((u) => ` --company ${u}`).join('\n'),
|