@indigoai-us/hq-cli 5.74.0 → 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/outposts.d.ts +3 -1
- package/dist/commands/outposts.js +130 -6
- package/package.json +1 -1
- package/src/commands/outposts.test.ts +134 -0
- package/src/commands/outposts.ts +208 -7
|
@@ -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;
|
|
@@ -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
|
}
|
|
@@ -835,8 +839,18 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
835
839
|
});
|
|
836
840
|
outposts
|
|
837
841
|
.command("exec <command...>")
|
|
838
|
-
.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.")
|
|
839
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
|
+
})
|
|
840
854
|
.option("--json", "Emit raw JSON")
|
|
841
855
|
.action(async function (commandParts, opts) {
|
|
842
856
|
try {
|
|
@@ -845,9 +859,94 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
845
859
|
console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
|
|
846
860
|
process.exit(1);
|
|
847
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
|
+
}
|
|
848
878
|
const token = await ensureCognitoToken();
|
|
849
879
|
// Run from the box's HQ folder by default (works over both SSM and SSH).
|
|
850
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
|
+
}
|
|
851
950
|
try {
|
|
852
951
|
const result = await execOutpost(token, remoteCommand, opts.id);
|
|
853
952
|
if (opts.json) {
|
|
@@ -940,8 +1039,15 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
940
1039
|
});
|
|
941
1040
|
outposts
|
|
942
1041
|
.command("exec-submit <command...>")
|
|
943
|
-
.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)")
|
|
944
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
|
+
})
|
|
945
1051
|
.option("--json", "Emit raw JSON")
|
|
946
1052
|
.action(async function (commandParts, opts) {
|
|
947
1053
|
try {
|
|
@@ -950,9 +1056,27 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
950
1056
|
console.error(chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"));
|
|
951
1057
|
process.exit(1);
|
|
952
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
|
+
}
|
|
953
1067
|
const token = await ensureCognitoToken();
|
|
954
|
-
|
|
955
|
-
|
|
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
|
+
};
|
|
956
1080
|
if (opts.json) {
|
|
957
1081
|
process.stdout.write(JSON.stringify(output) + "\n");
|
|
958
1082
|
}
|
package/package.json
CHANGED
|
@@ -534,6 +534,140 @@ describe("hq outposts exec — Lightsail SSH fallback", () => {
|
|
|
534
534
|
});
|
|
535
535
|
});
|
|
536
536
|
|
|
537
|
+
|
|
538
|
+
describe("hq outposts exec --async / --detach", () => {
|
|
539
|
+
afterEach(() => {
|
|
540
|
+
process.exitCode = undefined;
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("--detach submits with HQ-dir wrapper and prints commandId without waiting", async () => {
|
|
544
|
+
const stdoutSpy = vi
|
|
545
|
+
.spyOn(process.stdout, "write")
|
|
546
|
+
.mockImplementation(() => true);
|
|
547
|
+
fetchSpy.mockResolvedValueOnce(
|
|
548
|
+
jsonResponse(200, {
|
|
549
|
+
ok: true,
|
|
550
|
+
userId: "u",
|
|
551
|
+
outpostId: "3",
|
|
552
|
+
instanceId: "i-3",
|
|
553
|
+
commandId: "cmd-detach",
|
|
554
|
+
outputPrefix: "outpost-exec/u/3/n/out",
|
|
555
|
+
executionTimeoutSeconds: 172800,
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
await run(["outposts", "exec", "--detach", "--id", "3", "--json", "sleep", "999"]);
|
|
560
|
+
|
|
561
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
562
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
563
|
+
expect(String(url)).toContain("/outpost/exec");
|
|
564
|
+
expect(String(url)).toContain("outpostId=3");
|
|
565
|
+
expect(JSON.parse(init?.body as string)).toEqual({
|
|
566
|
+
mode: "submit",
|
|
567
|
+
command: withRemoteHqDir("'sleep' '999'"),
|
|
568
|
+
});
|
|
569
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
570
|
+
expect(printed).toContain('"commandId":"cmd-detach"');
|
|
571
|
+
// No second poll.
|
|
572
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("--async submits then polls until done and streams stdout", async () => {
|
|
576
|
+
const stdoutSpy = vi
|
|
577
|
+
.spyOn(process.stdout, "write")
|
|
578
|
+
.mockImplementation(() => true);
|
|
579
|
+
fetchSpy
|
|
580
|
+
.mockResolvedValueOnce(
|
|
581
|
+
jsonResponse(200, {
|
|
582
|
+
ok: true,
|
|
583
|
+
userId: "u",
|
|
584
|
+
outpostId: "primary",
|
|
585
|
+
instanceId: "i",
|
|
586
|
+
commandId: "cmd-async",
|
|
587
|
+
outputPrefix: "p",
|
|
588
|
+
executionTimeoutSeconds: 7200,
|
|
589
|
+
}),
|
|
590
|
+
)
|
|
591
|
+
.mockResolvedValueOnce(
|
|
592
|
+
jsonResponse(200, {
|
|
593
|
+
ok: true,
|
|
594
|
+
userId: "u",
|
|
595
|
+
outpostId: "primary",
|
|
596
|
+
status: "Success",
|
|
597
|
+
done: true,
|
|
598
|
+
exitCode: 0,
|
|
599
|
+
stdout: "finished\n",
|
|
600
|
+
stderr: "",
|
|
601
|
+
}),
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
await run([
|
|
605
|
+
"outposts",
|
|
606
|
+
"exec",
|
|
607
|
+
"--async",
|
|
608
|
+
"--timeout-seconds",
|
|
609
|
+
"7200",
|
|
610
|
+
"echo",
|
|
611
|
+
"finished",
|
|
612
|
+
]);
|
|
613
|
+
|
|
614
|
+
// submit + one result poll (terminal on first poll)
|
|
615
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
616
|
+
expect(JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)).toEqual({
|
|
617
|
+
mode: "submit",
|
|
618
|
+
command: withRemoteHqDir("'echo' 'finished'"),
|
|
619
|
+
timeoutSeconds: 7200,
|
|
620
|
+
});
|
|
621
|
+
expect(JSON.parse(fetchSpy.mock.calls[1][1]?.body as string)).toEqual({
|
|
622
|
+
mode: "result",
|
|
623
|
+
commandId: "cmd-async",
|
|
624
|
+
});
|
|
625
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
626
|
+
expect(printed).toContain("finished\n");
|
|
627
|
+
expect(process.exitCode).toBe(0);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("refuses --async and --detach together", async () => {
|
|
631
|
+
await expect(
|
|
632
|
+
run(["outposts", "exec", "--async", "--detach", "true"]),
|
|
633
|
+
).rejects.toThrow("process.exit(1)");
|
|
634
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("exec-submit forwards --timeout-seconds", async () => {
|
|
638
|
+
const stdoutSpy = vi
|
|
639
|
+
.spyOn(process.stdout, "write")
|
|
640
|
+
.mockImplementation(() => true);
|
|
641
|
+
fetchSpy.mockResolvedValueOnce(
|
|
642
|
+
jsonResponse(200, {
|
|
643
|
+
ok: true,
|
|
644
|
+
commandId: "cmd-sub",
|
|
645
|
+
executionTimeoutSeconds: 3600,
|
|
646
|
+
userId: "u",
|
|
647
|
+
outpostId: "primary",
|
|
648
|
+
instanceId: "i",
|
|
649
|
+
outputPrefix: "p",
|
|
650
|
+
}),
|
|
651
|
+
);
|
|
652
|
+
await run([
|
|
653
|
+
"outposts",
|
|
654
|
+
"exec-submit",
|
|
655
|
+
"--timeout-seconds",
|
|
656
|
+
"3600",
|
|
657
|
+
"--json",
|
|
658
|
+
"sleep",
|
|
659
|
+
"10",
|
|
660
|
+
]);
|
|
661
|
+
expect(JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)).toEqual({
|
|
662
|
+
mode: "submit",
|
|
663
|
+
command: "'sleep' '10'",
|
|
664
|
+
timeoutSeconds: 3600,
|
|
665
|
+
});
|
|
666
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
667
|
+
expect(printed).toContain("cmd-sub");
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
537
671
|
describe("hq outposts asynchronous exec", () => {
|
|
538
672
|
it("sends the stage, submit, and result mode request bodies", async () => {
|
|
539
673
|
fetchSpy
|
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
|
}
|
|
@@ -1250,14 +1257,40 @@ export function registerOutpostsCommand(
|
|
|
1250
1257
|
outposts
|
|
1251
1258
|
.command("exec <command...>")
|
|
1252
1259
|
.description(
|
|
1253
|
-
"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.",
|
|
1254
1262
|
)
|
|
1255
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
|
+
)
|
|
1256
1283
|
.option("--json", "Emit raw JSON")
|
|
1257
1284
|
.action(async function (
|
|
1258
1285
|
this: Command,
|
|
1259
1286
|
commandParts: string[],
|
|
1260
|
-
opts: {
|
|
1287
|
+
opts: {
|
|
1288
|
+
id?: string;
|
|
1289
|
+
json?: boolean;
|
|
1290
|
+
async?: boolean;
|
|
1291
|
+
detach?: boolean;
|
|
1292
|
+
timeoutSeconds?: number;
|
|
1293
|
+
},
|
|
1261
1294
|
) {
|
|
1262
1295
|
try {
|
|
1263
1296
|
const command = joinCommandParts(commandParts);
|
|
@@ -1265,11 +1298,137 @@ export function registerOutpostsCommand(
|
|
|
1265
1298
|
console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
|
|
1266
1299
|
process.exit(1);
|
|
1267
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
|
+
}
|
|
1268
1326
|
const token = await ensureCognitoToken();
|
|
1269
1327
|
|
|
1270
1328
|
// Run from the box's HQ folder by default (works over both SSM and SSH).
|
|
1271
1329
|
const remoteCommand = withRemoteHqDir(command);
|
|
1272
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
|
+
|
|
1273
1432
|
try {
|
|
1274
1433
|
const result = await execOutpost(token, remoteCommand, opts.id);
|
|
1275
1434
|
if (opts.json) {
|
|
@@ -1369,13 +1528,26 @@ export function registerOutpostsCommand(
|
|
|
1369
1528
|
|
|
1370
1529
|
outposts
|
|
1371
1530
|
.command("exec-submit <command...>")
|
|
1372
|
-
.description(
|
|
1531
|
+
.description(
|
|
1532
|
+
"Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)",
|
|
1533
|
+
)
|
|
1373
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
|
+
)
|
|
1374
1546
|
.option("--json", "Emit raw JSON")
|
|
1375
1547
|
.action(async function (
|
|
1376
1548
|
this: Command,
|
|
1377
1549
|
commandParts: string[],
|
|
1378
|
-
opts: { id?: string; json?: boolean },
|
|
1550
|
+
opts: { id?: string; json?: boolean; timeoutSeconds?: number },
|
|
1379
1551
|
) {
|
|
1380
1552
|
try {
|
|
1381
1553
|
const command = joinCommandParts(commandParts);
|
|
@@ -1385,9 +1557,38 @@ export function registerOutpostsCommand(
|
|
|
1385
1557
|
);
|
|
1386
1558
|
process.exit(1);
|
|
1387
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
|
+
}
|
|
1388
1574
|
const token = await ensureCognitoToken();
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
+
};
|
|
1391
1592
|
if (opts.json) {
|
|
1392
1593
|
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1393
1594
|
} else {
|