@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
|
@@ -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);
|
|
@@ -120,6 +120,8 @@ export interface OutpostExecSubmission {
|
|
|
120
120
|
instanceId: string;
|
|
121
121
|
commandId: string;
|
|
122
122
|
outputPrefix: string;
|
|
123
|
+
/** Shell budget applied server-side (AWS-RunShellScript executionTimeout). */
|
|
124
|
+
executionTimeoutSeconds?: number;
|
|
123
125
|
}
|
|
124
126
|
/** Poll response from `mode: "result"`; streams arrive only when terminal. */
|
|
125
127
|
export interface OutpostExecAsyncResult {
|
|
@@ -134,7 +136,7 @@ export interface OutpostExecAsyncResult {
|
|
|
134
136
|
truncated?: boolean;
|
|
135
137
|
}
|
|
136
138
|
export declare function stageExecInput(token: string, outpostId?: string): Promise<OutpostExecStage>;
|
|
137
|
-
export declare function submitExec(token: string, command: string, outpostId?: string): Promise<OutpostExecSubmission>;
|
|
139
|
+
export declare function submitExec(token: string, command: string, outpostId?: string, timeoutSeconds?: number): Promise<OutpostExecSubmission>;
|
|
138
140
|
export declare function fetchExecResult(token: string, commandId: string, outpostId?: string): Promise<OutpostExecAsyncResult>;
|
|
139
141
|
/** Preserve a single command string; safely join argv when Commander split it. */
|
|
140
142
|
export declare function joinCommandParts(commandParts: string[]): string;
|
|
@@ -143,13 +145,14 @@ export declare function joinCommandParts(commandParts: string[]): string;
|
|
|
143
145
|
* caller's command. `exec` runs over two transports with two different default
|
|
144
146
|
* working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
|
|
145
147
|
* 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
|
-
*
|
|
148
|
+
* printed an unhelpful, transport-dependent directory. Initialize a real root
|
|
149
|
+
* home for the SSM case before resolving the HQ folder: tools run by the caller
|
|
150
|
+
* (notably `gh`) otherwise treat the HQ checkout as their home and can create
|
|
151
|
+
* root-owned machine state inside it. The trailing `|| true` keeps the command
|
|
152
|
+
* running from the default directory when no HQ checkout is present, so exec
|
|
153
|
+
* never fails merely because the box has no HQ folder.
|
|
151
154
|
*/
|
|
152
|
-
export declare const REMOTE_HQ_DIR_PREFIX = "cd \"$HOME/hq\" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true";
|
|
155
|
+
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
156
|
/** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
|
|
154
157
|
export declare function withRemoteHqDir(command: string): string;
|
|
155
158
|
/** SSH connection details vended by `POST /outpost/ssh-access`. */
|
|
@@ -162,12 +162,16 @@ export async function stageExecInput(token, outpostId) {
|
|
|
162
162
|
query: outpostId ? { outpostId } : undefined,
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
|
-
export async function submitExec(token, command, outpostId) {
|
|
165
|
+
export async function submitExec(token, command, outpostId, timeoutSeconds) {
|
|
166
166
|
return outpostRequest({
|
|
167
167
|
token,
|
|
168
168
|
path: "/outpost/exec",
|
|
169
169
|
method: "POST",
|
|
170
|
-
body: {
|
|
170
|
+
body: {
|
|
171
|
+
mode: "submit",
|
|
172
|
+
command,
|
|
173
|
+
...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
|
|
174
|
+
},
|
|
171
175
|
query: outpostId ? { outpostId } : undefined,
|
|
172
176
|
});
|
|
173
177
|
}
|
|
@@ -215,13 +219,14 @@ async function waitForExecResult(token, commandId, outpostId) {
|
|
|
215
219
|
* caller's command. `exec` runs over two transports with two different default
|
|
216
220
|
* working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
|
|
217
221
|
* 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
|
-
*
|
|
222
|
+
* printed an unhelpful, transport-dependent directory. Initialize a real root
|
|
223
|
+
* home for the SSM case before resolving the HQ folder: tools run by the caller
|
|
224
|
+
* (notably `gh`) otherwise treat the HQ checkout as their home and can create
|
|
225
|
+
* root-owned machine state inside it. The trailing `|| true` keeps the command
|
|
226
|
+
* running from the default directory when no HQ checkout is present, so exec
|
|
227
|
+
* never fails merely because the box has no HQ folder.
|
|
223
228
|
*/
|
|
224
|
-
export const REMOTE_HQ_DIR_PREFIX = 'cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
|
|
229
|
+
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
230
|
/** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
|
|
226
231
|
export function withRemoteHqDir(command) {
|
|
227
232
|
return `${REMOTE_HQ_DIR_PREFIX}; ${command}`;
|
|
@@ -834,8 +839,18 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
834
839
|
});
|
|
835
840
|
outposts
|
|
836
841
|
.command("exec <command...>")
|
|
837
|
-
.description("Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command)"
|
|
842
|
+
.description("Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
|
|
843
|
+
"Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.")
|
|
838
844
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
845
|
+
.option("--async", "Submit via the async transport and wait for completion (bypasses the ~20s sync cap; shell budget defaults to 48h)")
|
|
846
|
+
.option("--detach", "Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)")
|
|
847
|
+
.option("--timeout-seconds <n>", "Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.", (v) => {
|
|
848
|
+
const n = Number(v);
|
|
849
|
+
if (!Number.isInteger(n)) {
|
|
850
|
+
throw new Error("--timeout-seconds must be an integer");
|
|
851
|
+
}
|
|
852
|
+
return n;
|
|
853
|
+
})
|
|
839
854
|
.option("--json", "Emit raw JSON")
|
|
840
855
|
.action(async function (commandParts, opts) {
|
|
841
856
|
try {
|
|
@@ -844,9 +859,94 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
844
859
|
console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
|
|
845
860
|
process.exit(1);
|
|
846
861
|
}
|
|
862
|
+
if (opts.async && opts.detach) {
|
|
863
|
+
console.error(chalk.red("Use either --async (submit + wait) or --detach (submit only), not both."));
|
|
864
|
+
process.exit(1);
|
|
865
|
+
}
|
|
866
|
+
// --timeout-seconds only applies to the async path; bare use implies --async.
|
|
867
|
+
const useAsync = Boolean(opts.async) ||
|
|
868
|
+
Boolean(opts.detach) ||
|
|
869
|
+
opts.timeoutSeconds !== undefined;
|
|
870
|
+
if (opts.timeoutSeconds !== undefined) {
|
|
871
|
+
if (!Number.isInteger(opts.timeoutSeconds) ||
|
|
872
|
+
opts.timeoutSeconds < 1 ||
|
|
873
|
+
opts.timeoutSeconds > 172_800) {
|
|
874
|
+
console.error(chalk.red("--timeout-seconds must be an integer between 1 and 172800 (48h, the AWS-RunShellScript max)"));
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
847
878
|
const token = await ensureCognitoToken();
|
|
848
879
|
// Run from the box's HQ folder by default (works over both SSM and SSH).
|
|
849
880
|
const remoteCommand = withRemoteHqDir(command);
|
|
881
|
+
if (useAsync) {
|
|
882
|
+
try {
|
|
883
|
+
const submitted = await submitExec(token, remoteCommand, opts.id, opts.timeoutSeconds);
|
|
884
|
+
if (opts.detach) {
|
|
885
|
+
const output = {
|
|
886
|
+
commandId: submitted.commandId,
|
|
887
|
+
...(submitted.executionTimeoutSeconds !== undefined
|
|
888
|
+
? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
|
|
889
|
+
: opts.timeoutSeconds !== undefined
|
|
890
|
+
? { executionTimeoutSeconds: opts.timeoutSeconds }
|
|
891
|
+
: {}),
|
|
892
|
+
};
|
|
893
|
+
if (opts.json) {
|
|
894
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
printKeyValues(output);
|
|
898
|
+
console.error(chalk.dim("Submitted. Poll with: hq outposts exec-result --command-id " +
|
|
899
|
+
submitted.commandId +
|
|
900
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
901
|
+
" --wait"));
|
|
902
|
+
}
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
// --async (or --timeout-seconds without --detach): wait for terminal.
|
|
906
|
+
if (!opts.json) {
|
|
907
|
+
console.error(chalk.dim(`Submitted ${submitted.commandId}; waiting for completion…`));
|
|
908
|
+
}
|
|
909
|
+
const result = await waitForExecResult(token, submitted.commandId, opts.id);
|
|
910
|
+
if (opts.json) {
|
|
911
|
+
process.stdout.write(JSON.stringify({
|
|
912
|
+
commandId: submitted.commandId,
|
|
913
|
+
done: result.done,
|
|
914
|
+
status: result.status,
|
|
915
|
+
exitCode: result.exitCode ?? null,
|
|
916
|
+
stdout: result.stdout ?? "",
|
|
917
|
+
stderr: result.stderr ?? "",
|
|
918
|
+
truncated: result.truncated ?? false,
|
|
919
|
+
}, null, 2) + "\n");
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
if (result.stdout)
|
|
923
|
+
process.stdout.write(result.stdout);
|
|
924
|
+
if (result.stderr)
|
|
925
|
+
process.stderr.write(result.stderr);
|
|
926
|
+
if (result.truncated) {
|
|
927
|
+
console.error(chalk.yellow("(output truncated — redirect to a file on the box for full output)"));
|
|
928
|
+
}
|
|
929
|
+
if (result.status !== "Success" && result.exitCode === null) {
|
|
930
|
+
console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
process.exitCode =
|
|
934
|
+
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
// Async requires EC2/SSM. Lightsail has no async channel — refuse
|
|
939
|
+
// rather than silently falling back to a live SSH hold, which is
|
|
940
|
+
// the exact timeout failure mode --async is meant to escape.
|
|
941
|
+
if (err instanceof OutpostHttpError &&
|
|
942
|
+
err.step === "platform-unsupported") {
|
|
943
|
+
console.error(chalk.red("Async exec requires an EC2 Outpost (SSM). This box is Lightsail — " +
|
|
944
|
+
"re-provision on EC2, or run a short sync command / SSH session instead."));
|
|
945
|
+
process.exit(1);
|
|
946
|
+
}
|
|
947
|
+
throw err;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
850
950
|
try {
|
|
851
951
|
const result = await execOutpost(token, remoteCommand, opts.id);
|
|
852
952
|
if (opts.json) {
|
|
@@ -939,8 +1039,15 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
939
1039
|
});
|
|
940
1040
|
outposts
|
|
941
1041
|
.command("exec-submit <command...>")
|
|
942
|
-
.description("Submit an asynchronous shell command to an Outpost")
|
|
1042
|
+
.description("Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)")
|
|
943
1043
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1044
|
+
.option("--timeout-seconds <n>", "Shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)", (v) => {
|
|
1045
|
+
const n = Number(v);
|
|
1046
|
+
if (!Number.isInteger(n)) {
|
|
1047
|
+
throw new Error("--timeout-seconds must be an integer");
|
|
1048
|
+
}
|
|
1049
|
+
return n;
|
|
1050
|
+
})
|
|
944
1051
|
.option("--json", "Emit raw JSON")
|
|
945
1052
|
.action(async function (commandParts, opts) {
|
|
946
1053
|
try {
|
|
@@ -949,9 +1056,27 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
949
1056
|
console.error(chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"));
|
|
950
1057
|
process.exit(1);
|
|
951
1058
|
}
|
|
1059
|
+
if (opts.timeoutSeconds !== undefined) {
|
|
1060
|
+
if (!Number.isInteger(opts.timeoutSeconds) ||
|
|
1061
|
+
opts.timeoutSeconds < 1 ||
|
|
1062
|
+
opts.timeoutSeconds > 172_800) {
|
|
1063
|
+
console.error(chalk.red("--timeout-seconds must be an integer between 1 and 172800 (48h)"));
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
952
1067
|
const token = await ensureCognitoToken();
|
|
953
|
-
|
|
954
|
-
|
|
1068
|
+
// exec-submit is the raw fire-and-forget path — do NOT wrap with
|
|
1069
|
+
// withRemoteHqDir here (callers that want the HQ cwd use `exec --async`
|
|
1070
|
+
// or prefix their own cd). Matches the existing contract.
|
|
1071
|
+
const submitted = await submitExec(token, command, opts.id, opts.timeoutSeconds);
|
|
1072
|
+
const output = {
|
|
1073
|
+
commandId: submitted.commandId,
|
|
1074
|
+
...(submitted.executionTimeoutSeconds !== undefined
|
|
1075
|
+
? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
|
|
1076
|
+
: opts.timeoutSeconds !== undefined
|
|
1077
|
+
? { executionTimeoutSeconds: opts.timeoutSeconds }
|
|
1078
|
+
: {}),
|
|
1079
|
+
};
|
|
955
1080
|
if (opts.json) {
|
|
956
1081
|
process.stdout.write(JSON.stringify(output) + "\n");
|
|
957
1082
|
}
|
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
|