@indigoai-us/hq-cli 5.70.0 → 5.71.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/CHANGELOG.md +20 -0
- package/dist/commands/outposts.d.ts +36 -0
- package/dist/commands/outposts.js +149 -4
- package/package.json +1 -1
- package/src/commands/outposts.test.ts +252 -4
- package/src/commands/outposts.ts +211 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.71.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Async Outpost exec.** `hq outposts` gained three verbs for running commands
|
|
10
|
+
that exceed the synchronous `exec` limits (long-running turns, large payloads
|
|
11
|
+
and outputs), backed by the `mode` flag on `POST /outpost/exec`:
|
|
12
|
+
- `hq outposts exec-stage --file <path>` — upload an input payload to a
|
|
13
|
+
short-lived presigned URL and print `{ key, getUrl }`.
|
|
14
|
+
- `hq outposts exec-submit -- <command>` — submit an asynchronous command and
|
|
15
|
+
print `{ commandId }` immediately (no blocking).
|
|
16
|
+
- `hq outposts exec-result --command-id <id> [--wait]` — fetch the result;
|
|
17
|
+
non-terminal returns `{ done: false }`, `--wait` polls to completion.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- `outposts exec` / `exec-submit` now shell-quote multi-argument commands before
|
|
22
|
+
joining, so `-- bash -c "$script" a b` survives instead of collapsing on a
|
|
23
|
+
naive space-join. A single command string still passes through verbatim.
|
|
24
|
+
|
|
5
25
|
## [5.70.0]
|
|
6
26
|
|
|
7
27
|
### Added
|
|
@@ -95,6 +95,42 @@ export interface OutpostExecResult {
|
|
|
95
95
|
* `step` distinguishes not-ready / platform-unsupported / timeout / ssm.
|
|
96
96
|
*/
|
|
97
97
|
export declare function execOutpost(token: string, command: string, outpostId?: string): Promise<OutpostExecResult>;
|
|
98
|
+
/** Presigned input-upload details from `mode: "stage"`. */
|
|
99
|
+
export interface OutpostExecStage {
|
|
100
|
+
ok: true;
|
|
101
|
+
userId: string;
|
|
102
|
+
outpostId: string;
|
|
103
|
+
key: string;
|
|
104
|
+
putUrl: string;
|
|
105
|
+
getUrl: string;
|
|
106
|
+
expiresInSeconds: number;
|
|
107
|
+
}
|
|
108
|
+
/** Asynchronous SSM command details from `mode: "submit"`. */
|
|
109
|
+
export interface OutpostExecSubmission {
|
|
110
|
+
ok: true;
|
|
111
|
+
userId: string;
|
|
112
|
+
outpostId: string;
|
|
113
|
+
instanceId: string;
|
|
114
|
+
commandId: string;
|
|
115
|
+
outputPrefix: string;
|
|
116
|
+
}
|
|
117
|
+
/** Poll response from `mode: "result"`; streams arrive only when terminal. */
|
|
118
|
+
export interface OutpostExecAsyncResult {
|
|
119
|
+
ok: true;
|
|
120
|
+
userId: string;
|
|
121
|
+
outpostId: string;
|
|
122
|
+
status: string;
|
|
123
|
+
done: boolean;
|
|
124
|
+
exitCode?: number | null;
|
|
125
|
+
stdout?: string;
|
|
126
|
+
stderr?: string;
|
|
127
|
+
truncated?: boolean;
|
|
128
|
+
}
|
|
129
|
+
export declare function stageExecInput(token: string, outpostId?: string): Promise<OutpostExecStage>;
|
|
130
|
+
export declare function submitExec(token: string, command: string, outpostId?: string): Promise<OutpostExecSubmission>;
|
|
131
|
+
export declare function fetchExecResult(token: string, commandId: string, outpostId?: string): Promise<OutpostExecAsyncResult>;
|
|
132
|
+
/** Preserve a single command string; safely join argv when Commander split it. */
|
|
133
|
+
export declare function joinCommandParts(commandParts: string[]): string;
|
|
98
134
|
/**
|
|
99
135
|
* Prefix that best-effort `cd`s into the box's HQ checkout before running the
|
|
100
136
|
* caller's command. `exec` runs over two transports with two different default
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* and status routes that exist. Renaming an Outpost is not a backend capability.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
24
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="839f8f76-584d-5ea8-b86e-a92a55c32a16")}catch(e){}}();
|
|
25
25
|
import chalk from "chalk";
|
|
26
26
|
import { spawnSync } from "node:child_process";
|
|
27
27
|
import * as fs from "node:fs";
|
|
@@ -139,6 +139,63 @@ export async function execOutpost(token, command, outpostId) {
|
|
|
139
139
|
query: outpostId ? { outpostId } : undefined,
|
|
140
140
|
});
|
|
141
141
|
}
|
|
142
|
+
export async function stageExecInput(token, outpostId) {
|
|
143
|
+
return outpostRequest({
|
|
144
|
+
token,
|
|
145
|
+
path: "/outpost/exec",
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: { mode: "stage" },
|
|
148
|
+
query: outpostId ? { outpostId } : undefined,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
export async function submitExec(token, command, outpostId) {
|
|
152
|
+
return outpostRequest({
|
|
153
|
+
token,
|
|
154
|
+
path: "/outpost/exec",
|
|
155
|
+
method: "POST",
|
|
156
|
+
body: { mode: "submit", command },
|
|
157
|
+
query: outpostId ? { outpostId } : undefined,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
export async function fetchExecResult(token, commandId, outpostId) {
|
|
161
|
+
return outpostRequest({
|
|
162
|
+
token,
|
|
163
|
+
path: "/outpost/exec",
|
|
164
|
+
method: "POST",
|
|
165
|
+
body: { mode: "result", commandId },
|
|
166
|
+
query: outpostId ? { outpostId } : undefined,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function shellQuote(part) {
|
|
170
|
+
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
171
|
+
}
|
|
172
|
+
/** Preserve a single command string; safely join argv when Commander split it. */
|
|
173
|
+
export function joinCommandParts(commandParts) {
|
|
174
|
+
if (commandParts.length === 1)
|
|
175
|
+
return commandParts[0];
|
|
176
|
+
return commandParts.map(shellQuote).join(" ");
|
|
177
|
+
}
|
|
178
|
+
const EXEC_RESULT_INITIAL_POLL_MS = 500;
|
|
179
|
+
const EXEC_RESULT_MAX_POLL_MS = 5_000;
|
|
180
|
+
function sleep(ms) {
|
|
181
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
182
|
+
}
|
|
183
|
+
async function waitForExecResult(token, commandId, outpostId) {
|
|
184
|
+
let delayMs = EXEC_RESULT_INITIAL_POLL_MS;
|
|
185
|
+
while (true) {
|
|
186
|
+
try {
|
|
187
|
+
const result = await fetchExecResult(token, commandId, outpostId);
|
|
188
|
+
if (result.done)
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (!(err instanceof OutpostHttpError) || err.status !== 429)
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
await sleep(delayMs);
|
|
196
|
+
delayMs = Math.min(delayMs * 2, EXEC_RESULT_MAX_POLL_MS);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
142
199
|
/**
|
|
143
200
|
* Prefix that best-effort `cd`s into the box's HQ checkout before running the
|
|
144
201
|
* caller's command. `exec` runs over two transports with two different default
|
|
@@ -602,8 +659,8 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
602
659
|
.option("--json", "Emit raw JSON")
|
|
603
660
|
.action(async function (commandParts, opts) {
|
|
604
661
|
try {
|
|
605
|
-
const command = commandParts
|
|
606
|
-
if (!command) {
|
|
662
|
+
const command = joinCommandParts(commandParts);
|
|
663
|
+
if (!command.trim()) {
|
|
607
664
|
console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
|
|
608
665
|
process.exit(1);
|
|
609
666
|
}
|
|
@@ -669,6 +726,94 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
669
726
|
fail(err);
|
|
670
727
|
}
|
|
671
728
|
});
|
|
729
|
+
outposts
|
|
730
|
+
.command("exec-stage")
|
|
731
|
+
.description("Stage a file for an asynchronous Outpost command")
|
|
732
|
+
.requiredOption("--file <path>", "File to upload")
|
|
733
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
734
|
+
.option("--json", "Emit raw JSON")
|
|
735
|
+
.action(async function (opts) {
|
|
736
|
+
try {
|
|
737
|
+
const bytes = fs.readFileSync(opts.file);
|
|
738
|
+
const token = await ensureCognitoToken();
|
|
739
|
+
const staged = await stageExecInput(token, opts.id);
|
|
740
|
+
const upload = await fetch(staged.putUrl, {
|
|
741
|
+
method: "PUT",
|
|
742
|
+
headers: { "Content-Length": String(bytes.byteLength) },
|
|
743
|
+
body: bytes,
|
|
744
|
+
});
|
|
745
|
+
if (!upload.ok) {
|
|
746
|
+
throw new Error(`Could not upload exec input: HTTP ${upload.status} ${upload.statusText}`);
|
|
747
|
+
}
|
|
748
|
+
const output = { key: staged.key, getUrl: staged.getUrl };
|
|
749
|
+
if (opts.json) {
|
|
750
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
printKeyValues(output);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
fail(err);
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
outposts
|
|
761
|
+
.command("exec-submit <command...>")
|
|
762
|
+
.description("Submit an asynchronous shell command to an Outpost")
|
|
763
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
764
|
+
.option("--json", "Emit raw JSON")
|
|
765
|
+
.action(async function (commandParts, opts) {
|
|
766
|
+
try {
|
|
767
|
+
const command = joinCommandParts(commandParts);
|
|
768
|
+
if (!command.trim()) {
|
|
769
|
+
console.error(chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"));
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
const token = await ensureCognitoToken();
|
|
773
|
+
const submitted = await submitExec(token, command, opts.id);
|
|
774
|
+
const output = { commandId: submitted.commandId };
|
|
775
|
+
if (opts.json) {
|
|
776
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
printKeyValues(output);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
fail(err);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
outposts
|
|
787
|
+
.command("exec-result")
|
|
788
|
+
.description("Fetch the result of an asynchronous Outpost command")
|
|
789
|
+
.requiredOption("--command-id <commandId>", "Command id returned by exec-submit")
|
|
790
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
791
|
+
.option("--wait", "Poll until the command reaches a terminal state")
|
|
792
|
+
.option("--json", "Emit raw JSON")
|
|
793
|
+
.action(async function (opts) {
|
|
794
|
+
try {
|
|
795
|
+
const token = await ensureCognitoToken();
|
|
796
|
+
const result = opts.wait
|
|
797
|
+
? await waitForExecResult(token, opts.commandId, opts.id)
|
|
798
|
+
: await fetchExecResult(token, opts.commandId, opts.id);
|
|
799
|
+
const output = {
|
|
800
|
+
done: result.done,
|
|
801
|
+
status: result.status,
|
|
802
|
+
exitCode: result.exitCode ?? null,
|
|
803
|
+
stdout: result.stdout ?? "",
|
|
804
|
+
stderr: result.stderr ?? "",
|
|
805
|
+
};
|
|
806
|
+
if (opts.json) {
|
|
807
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
printKeyValues(output);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
fail(err);
|
|
815
|
+
}
|
|
816
|
+
});
|
|
672
817
|
outposts
|
|
673
818
|
.command("codex-enable")
|
|
674
819
|
.description("Enable (or retry) Codex on an Outpost")
|
|
@@ -741,4 +886,4 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
741
886
|
});
|
|
742
887
|
}
|
|
743
888
|
//# sourceMappingURL=outposts.js.map
|
|
744
|
-
//# debugId=
|
|
889
|
+
//# debugId=839f8f76-584d-5ea8-b86e-a92a55c32a16
|
package/package.json
CHANGED
|
@@ -50,9 +50,18 @@ vi.mock("node:child_process", async (importOriginal) => {
|
|
|
50
50
|
return { ...original, spawnSync: vi.fn() };
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
import { spawnSync } from "node:child_process";
|
|
53
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
54
|
+
import * as fs from "node:fs";
|
|
55
|
+
import * as path from "node:path";
|
|
54
56
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
55
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
fetchExecResult,
|
|
59
|
+
joinCommandParts,
|
|
60
|
+
registerOutpostsCommand,
|
|
61
|
+
stageExecInput,
|
|
62
|
+
submitExec,
|
|
63
|
+
withRemoteHqDir,
|
|
64
|
+
} from "./outposts.js";
|
|
56
65
|
|
|
57
66
|
const mockSpawnSync = vi.mocked(spawnSync);
|
|
58
67
|
|
|
@@ -299,7 +308,7 @@ describe("hq outposts exec", () => {
|
|
|
299
308
|
expect(init?.method).toBe("POST");
|
|
300
309
|
// The command is wrapped so it runs from the box's HQ folder.
|
|
301
310
|
expect(JSON.parse(init?.body as string)).toEqual({
|
|
302
|
-
command: withRemoteHqDir("echo hello world"),
|
|
311
|
+
command: withRemoteHqDir("'echo' 'hello' 'world'"),
|
|
303
312
|
});
|
|
304
313
|
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
305
314
|
expect(printed).toContain("hello world\n");
|
|
@@ -431,7 +440,7 @@ describe("hq outposts exec — Lightsail SSH fallback", () => {
|
|
|
431
440
|
const args = mockSpawnSync.mock.calls[0][1] as string[];
|
|
432
441
|
expect(mockSpawnSync.mock.calls[0][0]).toBe("ssh");
|
|
433
442
|
expect(args).toContain("ec2-user@52.2.2.2");
|
|
434
|
-
expect(args[args.length - 1]).toBe(withRemoteHqDir("uname -a"));
|
|
443
|
+
expect(args[args.length - 1]).toBe(withRemoteHqDir("'uname' '-a'"));
|
|
435
444
|
expect(args).toContain("BatchMode=yes");
|
|
436
445
|
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
437
446
|
expect(printed).toContain("lightsail-out\n");
|
|
@@ -489,3 +498,242 @@ describe("hq outposts exec — Lightsail SSH fallback", () => {
|
|
|
489
498
|
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
490
499
|
});
|
|
491
500
|
});
|
|
501
|
+
|
|
502
|
+
describe("hq outposts asynchronous exec", () => {
|
|
503
|
+
it("sends the stage, submit, and result mode request bodies", async () => {
|
|
504
|
+
fetchSpy
|
|
505
|
+
.mockResolvedValueOnce(
|
|
506
|
+
jsonResponse(200, {
|
|
507
|
+
ok: true,
|
|
508
|
+
userId: "u1",
|
|
509
|
+
outpostId: "2",
|
|
510
|
+
key: "input-key",
|
|
511
|
+
putUrl: "https://upload.invalid/put",
|
|
512
|
+
getUrl: "https://download.invalid/get",
|
|
513
|
+
expiresInSeconds: 300,
|
|
514
|
+
}),
|
|
515
|
+
)
|
|
516
|
+
.mockResolvedValueOnce(
|
|
517
|
+
jsonResponse(200, {
|
|
518
|
+
ok: true,
|
|
519
|
+
userId: "u1",
|
|
520
|
+
outpostId: "2",
|
|
521
|
+
instanceId: "i-2",
|
|
522
|
+
commandId: "cmd-1",
|
|
523
|
+
outputPrefix: "output-prefix",
|
|
524
|
+
}),
|
|
525
|
+
)
|
|
526
|
+
.mockResolvedValueOnce(
|
|
527
|
+
jsonResponse(200, {
|
|
528
|
+
ok: true,
|
|
529
|
+
userId: "u1",
|
|
530
|
+
outpostId: "2",
|
|
531
|
+
status: "InProgress",
|
|
532
|
+
done: false,
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
await stageExecInput("test-token", "2");
|
|
537
|
+
await submitExec("test-token", "cd /tmp && run-worker", "2");
|
|
538
|
+
await fetchExecResult("test-token", "cmd-1", "2");
|
|
539
|
+
|
|
540
|
+
expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([
|
|
541
|
+
expect.stringContaining("/outpost/exec?outpostId=2"),
|
|
542
|
+
expect.stringContaining("/outpost/exec?outpostId=2"),
|
|
543
|
+
expect.stringContaining("/outpost/exec?outpostId=2"),
|
|
544
|
+
]);
|
|
545
|
+
expect(fetchSpy.mock.calls.map(([, init]) => JSON.parse(init?.body as string))).toEqual([
|
|
546
|
+
{ mode: "stage" },
|
|
547
|
+
{ mode: "submit", command: "cd /tmp && run-worker" },
|
|
548
|
+
{ mode: "result", commandId: "cmd-1" },
|
|
549
|
+
]);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("exec-stage PUTs the exact file bytes and prints only key + getUrl JSON", async () => {
|
|
553
|
+
const stdoutSpy = vi
|
|
554
|
+
.spyOn(process.stdout, "write")
|
|
555
|
+
.mockImplementation(() => true);
|
|
556
|
+
const file = path.join(process.cwd(), "package.json");
|
|
557
|
+
const expectedBytes = fs.readFileSync(file);
|
|
558
|
+
fetchSpy
|
|
559
|
+
.mockResolvedValueOnce(
|
|
560
|
+
jsonResponse(200, {
|
|
561
|
+
ok: true,
|
|
562
|
+
userId: "u1",
|
|
563
|
+
outpostId: "2",
|
|
564
|
+
key: "input-key",
|
|
565
|
+
putUrl: "https://upload.invalid/put",
|
|
566
|
+
getUrl: "https://download.invalid/get",
|
|
567
|
+
expiresInSeconds: 300,
|
|
568
|
+
}),
|
|
569
|
+
)
|
|
570
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
571
|
+
|
|
572
|
+
await run(["outposts", "exec-stage", "--id", "2", "--file", file, "--json"]);
|
|
573
|
+
|
|
574
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
575
|
+
const [uploadUrl, uploadInit] = fetchSpy.mock.calls[1];
|
|
576
|
+
expect(String(uploadUrl)).toBe("https://upload.invalid/put");
|
|
577
|
+
expect(uploadInit?.method).toBe("PUT");
|
|
578
|
+
expect(uploadInit?.headers).toEqual({
|
|
579
|
+
"Content-Length": String(expectedBytes.byteLength),
|
|
580
|
+
});
|
|
581
|
+
expect(Buffer.from(uploadInit?.body as Uint8Array)).toEqual(expectedBytes);
|
|
582
|
+
const printed = stdoutSpy.mock.calls.map((call) => String(call[0])).join("");
|
|
583
|
+
expect(JSON.parse(printed)).toEqual({
|
|
584
|
+
key: "input-key",
|
|
585
|
+
getUrl: "https://download.invalid/get",
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("exec-stage exits non-zero when the presigned upload fails", async () => {
|
|
590
|
+
fetchSpy
|
|
591
|
+
.mockResolvedValueOnce(
|
|
592
|
+
jsonResponse(200, {
|
|
593
|
+
ok: true,
|
|
594
|
+
userId: "u1",
|
|
595
|
+
outpostId: "primary",
|
|
596
|
+
key: "input-key",
|
|
597
|
+
putUrl: "https://upload.invalid/put",
|
|
598
|
+
getUrl: "https://download.invalid/get",
|
|
599
|
+
expiresInSeconds: 300,
|
|
600
|
+
}),
|
|
601
|
+
)
|
|
602
|
+
.mockResolvedValueOnce(new Response(null, { status: 503 }));
|
|
603
|
+
|
|
604
|
+
await expect(
|
|
605
|
+
run([
|
|
606
|
+
"outposts",
|
|
607
|
+
"exec-stage",
|
|
608
|
+
"--file",
|
|
609
|
+
path.join(process.cwd(), "package.json"),
|
|
610
|
+
"--json",
|
|
611
|
+
]),
|
|
612
|
+
).rejects.toThrow("process.exit(1)");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("exec-submit shell-quotes multiple argv parts without adding the HQ-directory wrapper", async () => {
|
|
616
|
+
const stdoutSpy = vi
|
|
617
|
+
.spyOn(process.stdout, "write")
|
|
618
|
+
.mockImplementation(() => true);
|
|
619
|
+
const script = 'printf "%s\\n" "$0" "$1" "$2"';
|
|
620
|
+
fetchSpy.mockResolvedValueOnce(
|
|
621
|
+
jsonResponse(200, {
|
|
622
|
+
ok: true,
|
|
623
|
+
userId: "u1",
|
|
624
|
+
outpostId: "primary",
|
|
625
|
+
instanceId: "i-1",
|
|
626
|
+
commandId: "cmd-submit",
|
|
627
|
+
outputPrefix: "output-prefix",
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
await run([
|
|
632
|
+
"outposts",
|
|
633
|
+
"exec-submit",
|
|
634
|
+
"--json",
|
|
635
|
+
"--",
|
|
636
|
+
"bash",
|
|
637
|
+
"-c",
|
|
638
|
+
script,
|
|
639
|
+
"marker",
|
|
640
|
+
"a b",
|
|
641
|
+
"quote's",
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
|
645
|
+
expect(body).toEqual({
|
|
646
|
+
mode: "submit",
|
|
647
|
+
command: joinCommandParts([
|
|
648
|
+
"bash",
|
|
649
|
+
"-c",
|
|
650
|
+
script,
|
|
651
|
+
"marker",
|
|
652
|
+
"a b",
|
|
653
|
+
"quote's",
|
|
654
|
+
]),
|
|
655
|
+
});
|
|
656
|
+
expect(body.command).not.toContain("cd ~ec2-user/hq");
|
|
657
|
+
expect(JSON.parse(String(stdoutSpy.mock.calls[0][0]))).toEqual({
|
|
658
|
+
commandId: "cmd-submit",
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("preserves one command part verbatim and round-trips quoted bash argv", () => {
|
|
663
|
+
const single = " printf 'already formed' ";
|
|
664
|
+
expect(joinCommandParts([single])).toBe(single);
|
|
665
|
+
|
|
666
|
+
const script = 'printf "%s\\n" "$0" "$1" "$2"';
|
|
667
|
+
const command = joinCommandParts([
|
|
668
|
+
"bash",
|
|
669
|
+
"-c",
|
|
670
|
+
script,
|
|
671
|
+
"marker",
|
|
672
|
+
"a b",
|
|
673
|
+
"quote's",
|
|
674
|
+
]);
|
|
675
|
+
expect(execFileSync("bash", ["-c", command], { encoding: "utf8" })).toBe(
|
|
676
|
+
"marker\na b\nquote's\n",
|
|
677
|
+
);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("exec-result --wait retries a 429, polls until done, and prints the result contract", async () => {
|
|
681
|
+
vi.useFakeTimers();
|
|
682
|
+
try {
|
|
683
|
+
const stdoutSpy = vi
|
|
684
|
+
.spyOn(process.stdout, "write")
|
|
685
|
+
.mockImplementation(() => true);
|
|
686
|
+
fetchSpy
|
|
687
|
+
.mockResolvedValueOnce(
|
|
688
|
+
jsonResponse(429, { error: true, step: "rate", message: "slow down" }),
|
|
689
|
+
)
|
|
690
|
+
.mockResolvedValueOnce(
|
|
691
|
+
jsonResponse(200, {
|
|
692
|
+
ok: true,
|
|
693
|
+
userId: "u1",
|
|
694
|
+
outpostId: "2",
|
|
695
|
+
status: "InProgress",
|
|
696
|
+
done: false,
|
|
697
|
+
}),
|
|
698
|
+
)
|
|
699
|
+
.mockResolvedValueOnce(
|
|
700
|
+
jsonResponse(200, {
|
|
701
|
+
ok: true,
|
|
702
|
+
userId: "u1",
|
|
703
|
+
outpostId: "2",
|
|
704
|
+
status: "Success",
|
|
705
|
+
done: true,
|
|
706
|
+
exitCode: 0,
|
|
707
|
+
stdout: "finished\n",
|
|
708
|
+
stderr: "",
|
|
709
|
+
truncated: false,
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const pending = run([
|
|
714
|
+
"outposts",
|
|
715
|
+
"exec-result",
|
|
716
|
+
"--id",
|
|
717
|
+
"2",
|
|
718
|
+
"--command-id",
|
|
719
|
+
"cmd-1",
|
|
720
|
+
"--wait",
|
|
721
|
+
"--json",
|
|
722
|
+
]);
|
|
723
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
724
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
725
|
+
await pending;
|
|
726
|
+
|
|
727
|
+
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
728
|
+
expect(JSON.parse(String(stdoutSpy.mock.calls[0][0]))).toEqual({
|
|
729
|
+
done: true,
|
|
730
|
+
status: "Success",
|
|
731
|
+
exitCode: 0,
|
|
732
|
+
stdout: "finished\n",
|
|
733
|
+
stderr: "",
|
|
734
|
+
});
|
|
735
|
+
} finally {
|
|
736
|
+
vi.useRealTimers();
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
});
|
package/src/commands/outposts.ts
CHANGED
|
@@ -228,6 +228,116 @@ export async function execOutpost(
|
|
|
228
228
|
});
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** Presigned input-upload details from `mode: "stage"`. */
|
|
232
|
+
export interface OutpostExecStage {
|
|
233
|
+
ok: true;
|
|
234
|
+
userId: string;
|
|
235
|
+
outpostId: string;
|
|
236
|
+
key: string;
|
|
237
|
+
putUrl: string;
|
|
238
|
+
getUrl: string;
|
|
239
|
+
expiresInSeconds: number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Asynchronous SSM command details from `mode: "submit"`. */
|
|
243
|
+
export interface OutpostExecSubmission {
|
|
244
|
+
ok: true;
|
|
245
|
+
userId: string;
|
|
246
|
+
outpostId: string;
|
|
247
|
+
instanceId: string;
|
|
248
|
+
commandId: string;
|
|
249
|
+
outputPrefix: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Poll response from `mode: "result"`; streams arrive only when terminal. */
|
|
253
|
+
export interface OutpostExecAsyncResult {
|
|
254
|
+
ok: true;
|
|
255
|
+
userId: string;
|
|
256
|
+
outpostId: string;
|
|
257
|
+
status: string;
|
|
258
|
+
done: boolean;
|
|
259
|
+
exitCode?: number | null;
|
|
260
|
+
stdout?: string;
|
|
261
|
+
stderr?: string;
|
|
262
|
+
truncated?: boolean;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function stageExecInput(
|
|
266
|
+
token: string,
|
|
267
|
+
outpostId?: string,
|
|
268
|
+
): Promise<OutpostExecStage> {
|
|
269
|
+
return outpostRequest({
|
|
270
|
+
token,
|
|
271
|
+
path: "/outpost/exec",
|
|
272
|
+
method: "POST",
|
|
273
|
+
body: { mode: "stage" },
|
|
274
|
+
query: outpostId ? { outpostId } : undefined,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function submitExec(
|
|
279
|
+
token: string,
|
|
280
|
+
command: string,
|
|
281
|
+
outpostId?: string,
|
|
282
|
+
): Promise<OutpostExecSubmission> {
|
|
283
|
+
return outpostRequest({
|
|
284
|
+
token,
|
|
285
|
+
path: "/outpost/exec",
|
|
286
|
+
method: "POST",
|
|
287
|
+
body: { mode: "submit", command },
|
|
288
|
+
query: outpostId ? { outpostId } : undefined,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function fetchExecResult(
|
|
293
|
+
token: string,
|
|
294
|
+
commandId: string,
|
|
295
|
+
outpostId?: string,
|
|
296
|
+
): Promise<OutpostExecAsyncResult> {
|
|
297
|
+
return outpostRequest({
|
|
298
|
+
token,
|
|
299
|
+
path: "/outpost/exec",
|
|
300
|
+
method: "POST",
|
|
301
|
+
body: { mode: "result", commandId },
|
|
302
|
+
query: outpostId ? { outpostId } : undefined,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function shellQuote(part: string): string {
|
|
307
|
+
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Preserve a single command string; safely join argv when Commander split it. */
|
|
311
|
+
export function joinCommandParts(commandParts: string[]): string {
|
|
312
|
+
if (commandParts.length === 1) return commandParts[0];
|
|
313
|
+
return commandParts.map(shellQuote).join(" ");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const EXEC_RESULT_INITIAL_POLL_MS = 500;
|
|
317
|
+
const EXEC_RESULT_MAX_POLL_MS = 5_000;
|
|
318
|
+
|
|
319
|
+
function sleep(ms: number): Promise<void> {
|
|
320
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function waitForExecResult(
|
|
324
|
+
token: string,
|
|
325
|
+
commandId: string,
|
|
326
|
+
outpostId?: string,
|
|
327
|
+
): Promise<OutpostExecAsyncResult> {
|
|
328
|
+
let delayMs = EXEC_RESULT_INITIAL_POLL_MS;
|
|
329
|
+
while (true) {
|
|
330
|
+
try {
|
|
331
|
+
const result = await fetchExecResult(token, commandId, outpostId);
|
|
332
|
+
if (result.done) return result;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (!(err instanceof OutpostHttpError) || err.status !== 429) throw err;
|
|
335
|
+
}
|
|
336
|
+
await sleep(delayMs);
|
|
337
|
+
delayMs = Math.min(delayMs * 2, EXEC_RESULT_MAX_POLL_MS);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
231
341
|
/**
|
|
232
342
|
* Prefix that best-effort `cd`s into the box's HQ checkout before running the
|
|
233
343
|
* caller's command. `exec` runs over two transports with two different default
|
|
@@ -876,8 +986,8 @@ export function registerOutpostsCommand(
|
|
|
876
986
|
opts: { id?: string; json?: boolean },
|
|
877
987
|
) {
|
|
878
988
|
try {
|
|
879
|
-
const command = commandParts
|
|
880
|
-
if (!command) {
|
|
989
|
+
const command = joinCommandParts(commandParts);
|
|
990
|
+
if (!command.trim()) {
|
|
881
991
|
console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
|
|
882
992
|
process.exit(1);
|
|
883
993
|
}
|
|
@@ -948,6 +1058,105 @@ export function registerOutpostsCommand(
|
|
|
948
1058
|
}
|
|
949
1059
|
});
|
|
950
1060
|
|
|
1061
|
+
outposts
|
|
1062
|
+
.command("exec-stage")
|
|
1063
|
+
.description("Stage a file for an asynchronous Outpost command")
|
|
1064
|
+
.requiredOption("--file <path>", "File to upload")
|
|
1065
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1066
|
+
.option("--json", "Emit raw JSON")
|
|
1067
|
+
.action(async function (
|
|
1068
|
+
this: Command,
|
|
1069
|
+
opts: { file: string; id?: string; json?: boolean },
|
|
1070
|
+
) {
|
|
1071
|
+
try {
|
|
1072
|
+
const bytes = fs.readFileSync(opts.file);
|
|
1073
|
+
const token = await ensureCognitoToken();
|
|
1074
|
+
const staged = await stageExecInput(token, opts.id);
|
|
1075
|
+
const upload = await fetch(staged.putUrl, {
|
|
1076
|
+
method: "PUT",
|
|
1077
|
+
headers: { "Content-Length": String(bytes.byteLength) },
|
|
1078
|
+
body: bytes,
|
|
1079
|
+
});
|
|
1080
|
+
if (!upload.ok) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
`Could not upload exec input: HTTP ${upload.status} ${upload.statusText}`,
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
const output = { key: staged.key, getUrl: staged.getUrl };
|
|
1086
|
+
if (opts.json) {
|
|
1087
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1088
|
+
} else {
|
|
1089
|
+
printKeyValues(output);
|
|
1090
|
+
}
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
fail(err);
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
outposts
|
|
1097
|
+
.command("exec-submit <command...>")
|
|
1098
|
+
.description("Submit an asynchronous shell command to an Outpost")
|
|
1099
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1100
|
+
.option("--json", "Emit raw JSON")
|
|
1101
|
+
.action(async function (
|
|
1102
|
+
this: Command,
|
|
1103
|
+
commandParts: string[],
|
|
1104
|
+
opts: { id?: string; json?: boolean },
|
|
1105
|
+
) {
|
|
1106
|
+
try {
|
|
1107
|
+
const command = joinCommandParts(commandParts);
|
|
1108
|
+
if (!command.trim()) {
|
|
1109
|
+
console.error(
|
|
1110
|
+
chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"),
|
|
1111
|
+
);
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}
|
|
1114
|
+
const token = await ensureCognitoToken();
|
|
1115
|
+
const submitted = await submitExec(token, command, opts.id);
|
|
1116
|
+
const output = { commandId: submitted.commandId };
|
|
1117
|
+
if (opts.json) {
|
|
1118
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1119
|
+
} else {
|
|
1120
|
+
printKeyValues(output);
|
|
1121
|
+
}
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
fail(err);
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
outposts
|
|
1128
|
+
.command("exec-result")
|
|
1129
|
+
.description("Fetch the result of an asynchronous Outpost command")
|
|
1130
|
+
.requiredOption("--command-id <commandId>", "Command id returned by exec-submit")
|
|
1131
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1132
|
+
.option("--wait", "Poll until the command reaches a terminal state")
|
|
1133
|
+
.option("--json", "Emit raw JSON")
|
|
1134
|
+
.action(async function (
|
|
1135
|
+
this: Command,
|
|
1136
|
+
opts: { commandId: string; id?: string; wait?: boolean; json?: boolean },
|
|
1137
|
+
) {
|
|
1138
|
+
try {
|
|
1139
|
+
const token = await ensureCognitoToken();
|
|
1140
|
+
const result = opts.wait
|
|
1141
|
+
? await waitForExecResult(token, opts.commandId, opts.id)
|
|
1142
|
+
: await fetchExecResult(token, opts.commandId, opts.id);
|
|
1143
|
+
const output = {
|
|
1144
|
+
done: result.done,
|
|
1145
|
+
status: result.status,
|
|
1146
|
+
exitCode: result.exitCode ?? null,
|
|
1147
|
+
stdout: result.stdout ?? "",
|
|
1148
|
+
stderr: result.stderr ?? "",
|
|
1149
|
+
};
|
|
1150
|
+
if (opts.json) {
|
|
1151
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1152
|
+
} else {
|
|
1153
|
+
printKeyValues(output);
|
|
1154
|
+
}
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
fail(err);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
|
|
951
1160
|
outposts
|
|
952
1161
|
.command("codex-enable")
|
|
953
1162
|
.description("Enable (or retry) Codex on an Outpost")
|