@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 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]="ff8d1d83-feab-5f6f-bf48-e4fde1a8d449")}catch(e){}}();
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.join(" ").trim();
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=ff8d1d83-feab-5f6f-bf48-e4fde1a8d449
889
+ //# debugId=839f8f76-584d-5ea8-b86e-a92a55c32a16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.70.0",
3
+ "version": "5.71.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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 { registerOutpostsCommand, withRemoteHqDir } from "./outposts.js";
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
+ });
@@ -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.join(" ").trim();
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")