@indigoai-us/hq-cli 5.74.0 → 5.76.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.
Files changed (39) hide show
  1. package/dist/commands/mcp-registration.d.ts +4 -5
  2. package/dist/commands/mcp-registration.js +5 -4
  3. package/dist/commands/outposts.d.ts +23 -5
  4. package/dist/commands/outposts.js +207 -14
  5. package/dist/commands/pack-install.d.ts +14 -17
  6. package/dist/commands/pack-install.js +53 -29
  7. package/dist/commands/pkg-install.js +3 -1
  8. package/dist/commands/run.d.ts +2 -0
  9. package/dist/commands/run.js +9 -3
  10. package/dist/commands/secrets.js +189 -87
  11. package/dist/run/hq-plugin.js +94 -31
  12. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  13. package/dist/utils/sandbox-runner-client.js +1 -0
  14. package/dist/utils/secrets-cache.d.ts +4 -5
  15. package/dist/utils/secrets-cache.js +5 -8
  16. package/package.json +3 -2
  17. package/pnpm-workspace.yaml +2 -0
  18. package/src/commands/mcp-registration.ts +9 -9
  19. package/src/commands/outposts.test.ts +252 -24
  20. package/src/commands/outposts.ts +405 -50
  21. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  22. package/src/commands/pack-install.test.ts +5 -1
  23. package/src/commands/pack-install.ts +67 -29
  24. package/src/commands/pkg-install.ts +3 -1
  25. package/src/commands/run.test.ts +45 -0
  26. package/src/commands/run.ts +20 -4
  27. package/src/commands/secrets.test.ts +366 -25
  28. package/src/commands/secrets.ts +222 -96
  29. package/src/run/hq-plugin.test.ts +186 -10
  30. package/src/run/hq-plugin.ts +102 -32
  31. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  32. package/src/utils/pack-contributions.test.ts +90 -31
  33. package/src/utils/sandbox-runner-client.test.ts +28 -0
  34. package/src/utils/sandbox-runner-client.ts +2 -0
  35. package/src/utils/secrets-cache.ts +5 -8
  36. package/test/commands/signals.test.ts +2 -2
  37. package/test/commands/sources.test.ts +2 -2
  38. package/test/helpers/vault-service-mock.ts +76 -17
  39. package/test/sources-signals/smoke.test.ts +2 -2
@@ -41,23 +41,51 @@ import {
41
41
  type BillingErrorPayload,
42
42
  } from "../utils/billing-gate.js";
43
43
 
44
+ /**
45
+ * hq-pro's per-person cap envelope on a `409` provision block. Unlike every
46
+ * other `/outpost/*` failure this body carries NO `message`/`error` field —
47
+ * only the cap facts — so it has to be decoded structurally or the reason
48
+ * degrades to a bare `res.statusText` ("Conflict").
49
+ */
50
+ export interface OutpostCappedPayload {
51
+ limit: number;
52
+ outposts: OutpostSummary[];
53
+ }
54
+
55
+ /** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
56
+ export function parseCappedPayload(
57
+ body: unknown,
58
+ ): OutpostCappedPayload | undefined {
59
+ if (!body || typeof body !== "object") return undefined;
60
+ const b = body as { capped?: unknown; limit?: unknown; outposts?: unknown };
61
+ if (b.capped !== true) return undefined;
62
+ return {
63
+ limit: typeof b.limit === "number" ? b.limit : 0,
64
+ outposts: Array.isArray(b.outposts) ? (b.outposts as OutpostSummary[]) : [],
65
+ };
66
+ }
67
+
44
68
  /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
45
69
  export class OutpostHttpError extends Error {
46
70
  status: number;
47
71
  step?: string;
48
72
  /** hq-pro's billing envelope on a `402 billing_required` provision block. */
49
73
  billing?: BillingErrorPayload;
74
+ /** hq-pro's cap envelope on a `409` provision block. */
75
+ capped?: OutpostCappedPayload;
50
76
  constructor(
51
77
  status: number,
52
78
  message: string,
53
79
  step?: string,
54
80
  billing?: BillingErrorPayload,
81
+ capped?: OutpostCappedPayload,
55
82
  ) {
56
83
  super(message);
57
84
  this.name = "OutpostHttpError";
58
85
  this.status = status;
59
86
  this.step = step;
60
87
  this.billing = billing;
88
+ this.capped = capped;
61
89
  }
62
90
  }
63
91
 
@@ -105,6 +133,7 @@ export async function outpostRequest<T>(opts: {
105
133
  message,
106
134
  body.step,
107
135
  parseBillingPayload(body),
136
+ parseCappedPayload(body),
108
137
  );
109
138
  }
110
139
  return (await res.json()) as T;
@@ -113,9 +142,11 @@ export async function outpostRequest<T>(opts: {
113
142
  /**
114
143
  * Provision the caller's Outpost. Sends the cached Cognito refresh token so the
115
144
  * box can authenticate AS the caller (the same body the console's
116
- * `provisionMyOutpost` sends). Idempotent server-side: a caller already at their
117
- * per-person cap gets their existing box back rather than a duplicate. The
118
- * refresh token is sent over HTTPS and NEVER printed.
145
+ * `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
146
+ * their per-person cap gets a `409` whose body lists their existing boxes
147
+ * thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
148
+ * BEFORE activation billing, so a capped call is never charged). The refresh
149
+ * token is sent over HTTPS and NEVER printed.
119
150
  */
120
151
  export async function provisionOutpost(
121
152
  token: string,
@@ -268,6 +299,8 @@ export interface OutpostExecSubmission {
268
299
  instanceId: string;
269
300
  commandId: string;
270
301
  outputPrefix: string;
302
+ /** Shell budget applied server-side (AWS-RunShellScript executionTimeout). */
303
+ executionTimeoutSeconds?: number;
271
304
  }
272
305
 
273
306
  /** Poll response from `mode: "result"`; streams arrive only when terminal. */
@@ -300,12 +333,17 @@ export async function submitExec(
300
333
  token: string,
301
334
  command: string,
302
335
  outpostId?: string,
336
+ timeoutSeconds?: number,
303
337
  ): Promise<OutpostExecSubmission> {
304
338
  return outpostRequest({
305
339
  token,
306
340
  path: "/outpost/exec",
307
341
  method: "POST",
308
- body: { mode: "submit", command },
342
+ body: {
343
+ mode: "submit",
344
+ command,
345
+ ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
346
+ },
309
347
  query: outpostId ? { outpostId } : undefined,
310
348
  });
311
349
  }
@@ -473,6 +511,31 @@ function ensureTrailingNewline(s: string): string {
473
511
  // Command registration
474
512
  // ---------------------------------------------------------------------------
475
513
 
514
+ /**
515
+ * Explain a `409` per-person cap. hq-pro checks the cap BEFORE activation
516
+ * billing, so nothing was charged — worth saying, since the caller just
517
+ * confirmed a recurring charge to get here.
518
+ */
519
+ function surfaceOutpostCapped(capped: OutpostCappedPayload): void {
520
+ const owned = capped.outposts.length;
521
+ console.error(
522
+ chalk.yellow(
523
+ `You're already at your Outpost limit (${owned} of ${capped.limit}). ` +
524
+ `No new box was provisioned and you have not been charged.`,
525
+ ),
526
+ );
527
+ for (const o of capped.outposts) {
528
+ const detail = [o.state, o.instanceName, o.region]
529
+ .filter(Boolean)
530
+ .join(" ");
531
+ console.error(` ${o.outpostId} ${detail}`);
532
+ }
533
+ console.error(chalk.dim("Inspect it: hq outposts status"));
534
+ console.error(
535
+ chalk.dim("Or tear it down first: hq outposts destroy --id <id> --yes"),
536
+ );
537
+ }
538
+
476
539
  function fail(err: unknown): never {
477
540
  if (err instanceof OutpostHttpError) {
478
541
  console.error(chalk.red(err.message));
@@ -506,8 +569,7 @@ export interface SelfDeployDependencies {
506
569
  ) => ReturnType<typeof spawnSync>;
507
570
  readTextFile: (file: string) => string;
508
571
  loadCachedTokens: () =>
509
- | { refreshToken?: string; idToken?: string }
510
- | undefined;
572
+ { refreshToken?: string; idToken?: string } | undefined;
511
573
  getUid: () => number | undefined;
512
574
  isStdinTty: () => boolean;
513
575
  confirm: () => Promise<boolean>;
@@ -588,10 +650,9 @@ function hqIdentityFromSession(session: {
588
650
  try {
589
651
  const payload = session.idToken.split(".")[1];
590
652
  if (!payload) return "your cached HQ session";
591
- const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<
592
- string,
593
- unknown
594
- >;
653
+ const claims = JSON.parse(
654
+ Buffer.from(payload, "base64url").toString("utf8"),
655
+ ) as Record<string, unknown>;
595
656
  for (const key of [
596
657
  "email",
597
658
  "preferred_username",
@@ -691,9 +752,10 @@ function runPrivileged(
691
752
  runChecked(deps, "sudo", [command, ...args], description, options);
692
753
  }
693
754
 
694
- function selfDeployPreflight(
695
- deps: SelfDeployDependencies,
696
- ): { refreshToken?: string; idToken?: string } {
755
+ function selfDeployPreflight(deps: SelfDeployDependencies): {
756
+ refreshToken?: string;
757
+ idToken?: string;
758
+ } {
697
759
  let osRelease: string;
698
760
  try {
699
761
  osRelease = deps.readTextFile("/etc/os-release");
@@ -733,7 +795,9 @@ function selfDeployPreflight(
733
795
 
734
796
  const session = deps.loadCachedTokens();
735
797
  if (!session?.refreshToken) {
736
- throw selfDeployError("no HQ login session was found. Run `hq login`, then re-run this command.");
798
+ throw selfDeployError(
799
+ "no HQ login session was found. Run `hq login`, then re-run this command.",
800
+ );
737
801
  }
738
802
  return session;
739
803
  }
@@ -756,7 +820,9 @@ async function selfDeployOutpost(
756
820
  ),
757
821
  );
758
822
  if (!deps.isStdinTty()) {
759
- throw new Error("Confirmation requires a TTY. Pass --yes to continue non-interactively.");
823
+ throw new Error(
824
+ "Confirmation requires a TTY. Pass --yes to continue non-interactively.",
825
+ );
760
826
  }
761
827
  if (!(await deps.confirm())) {
762
828
  throw new Error("Self-deploy cancelled.");
@@ -799,12 +865,7 @@ async function selfDeployOutpost(
799
865
  stdio: ["pipe", "ignore", "pipe"],
800
866
  },
801
867
  );
802
- runPrivileged(
803
- deps,
804
- "systemctl",
805
- ["daemon-reload"],
806
- "Reloading systemd",
807
- );
868
+ runPrivileged(deps, "systemctl", ["daemon-reload"], "Reloading systemd");
808
869
  runPrivileged(
809
870
  deps,
810
871
  "systemctl",
@@ -812,9 +873,19 @@ async function selfDeployOutpost(
812
873
  "Enabling outpost-sync.service",
813
874
  );
814
875
 
815
- console.log(chalk.green("This box is now a self-hosted HQ outpost and will sync all your company vaults continuously."));
816
- console.log(chalk.dim("Check it with: systemctl status outpost-sync.service"));
817
- console.log(chalk.dim("It is unregistered and unmanaged by hq-pro (no console entry or remote management)."));
876
+ console.log(
877
+ chalk.green(
878
+ "This box is now a self-hosted HQ outpost and will sync all your company vaults continuously.",
879
+ ),
880
+ );
881
+ console.log(
882
+ chalk.dim("Check it with: systemctl status outpost-sync.service"),
883
+ );
884
+ console.log(
885
+ chalk.dim(
886
+ "It is unregistered and unmanaged by hq-pro (no console entry or remote management).",
887
+ ),
888
+ );
818
889
  }
819
890
 
820
891
  // ---------------------------------------------------------------------------
@@ -933,7 +1004,9 @@ function runBestEffort(
933
1004
  return false;
934
1005
  }
935
1006
  if (!result || result.error || result.status !== 0) {
936
- console.warn(`replica-sync: ${description} did not complete cleanly — continuing.`);
1007
+ console.warn(
1008
+ `replica-sync: ${description} did not complete cleanly — continuing.`,
1009
+ );
937
1010
  return false;
938
1011
  }
939
1012
  return true;
@@ -954,7 +1027,17 @@ function authGitHubViaVault(deps: ReplicaSyncDependencies): void {
954
1027
  const authed = runBestEffort(
955
1028
  deps,
956
1029
  "hq",
957
- ["secrets", "--personal", "exec", "--only", "GITHUB_TOKEN", "--", "bash", "-c", ghLogin],
1030
+ [
1031
+ "secrets",
1032
+ "--personal",
1033
+ "exec",
1034
+ "--only",
1035
+ "GITHUB_TOKEN",
1036
+ "--",
1037
+ "bash",
1038
+ "-c",
1039
+ ghLogin,
1040
+ ],
958
1041
  "GitHub auth via vault",
959
1042
  );
960
1043
  if (authed) {
@@ -971,7 +1054,9 @@ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
971
1054
  try {
972
1055
  manifestText = deps.readTextFile(manifestPath);
973
1056
  } catch {
974
- console.log("replica-sync: no personal/data/repos.yaml — skipping repo clone.");
1057
+ console.log(
1058
+ "replica-sync: no personal/data/repos.yaml — skipping repo clone.",
1059
+ );
975
1060
  return;
976
1061
  }
977
1062
 
@@ -983,7 +1068,9 @@ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
983
1068
 
984
1069
  const missing = repos.filter(
985
1070
  (repo) =>
986
- !deps.pathExists(path.join(hqRoot, "repos", repo.visibility, repo.name, ".git")),
1071
+ !deps.pathExists(
1072
+ path.join(hqRoot, "repos", repo.visibility, repo.name, ".git"),
1073
+ ),
987
1074
  );
988
1075
  if (missing.length === 0) {
989
1076
  console.log("replica-sync: all repos already present.");
@@ -1018,7 +1105,9 @@ async function replicaSyncOutpost(
1018
1105
  // signed in has nothing to pull — exit cleanly so the timer stays green.
1019
1106
  const session = deps.loadCachedTokens();
1020
1107
  if (!session?.refreshToken) {
1021
- console.log("replica-sync: no HQ session on this box yet — nothing to replicate.");
1108
+ console.log(
1109
+ "replica-sync: no HQ session on this box yet — nothing to replicate.",
1110
+ );
1022
1111
  return;
1023
1112
  }
1024
1113
 
@@ -1034,7 +1123,15 @@ async function replicaSyncOutpost(
1034
1123
  runBestEffort(
1035
1124
  deps,
1036
1125
  "hq",
1037
- ["sync", "pull", "--personal", "--hq-root", hqRoot, "--on-conflict", "keep"],
1126
+ [
1127
+ "sync",
1128
+ "pull",
1129
+ "--personal",
1130
+ "--hq-root",
1131
+ hqRoot,
1132
+ "--on-conflict",
1133
+ "keep",
1134
+ ],
1038
1135
  "personal vault pull",
1039
1136
  );
1040
1137
 
@@ -1081,7 +1178,11 @@ export function registerOutpostsCommand(
1081
1178
  .description(
1082
1179
  "Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.",
1083
1180
  )
1084
- .option("--hq-root <path>", "HQ root to replicate into", deps.defaultHqRoot())
1181
+ .option(
1182
+ "--hq-root <path>",
1183
+ "HQ root to replicate into",
1184
+ deps.defaultHqRoot(),
1185
+ )
1085
1186
  .action(async (opts: { hqRoot?: string }) => {
1086
1187
  // Best-effort background job: never hard-fail the systemd unit on a
1087
1188
  // transient step. Swallow, log, and exit 0.
@@ -1098,7 +1199,10 @@ export function registerOutpostsCommand(
1098
1199
  .command("provision")
1099
1200
  .alias("create")
1100
1201
  .description("Provision a new Outpost ($80/month — requires --yes)")
1101
- .option("--runtime <runtime>", "Agent runtime: claude | codex (default claude)")
1202
+ .option(
1203
+ "--runtime <runtime>",
1204
+ "Agent runtime: claude | codex (default claude)",
1205
+ )
1102
1206
  .option("--disk <gb>", "Root disk size in GB (EC2 only)")
1103
1207
  .option(
1104
1208
  "--client-ip <ip>",
@@ -1107,20 +1211,43 @@ export function registerOutpostsCommand(
1107
1211
  .option("--yes", "Confirm the $80/month charge (required to provision)")
1108
1212
  .action(async function (
1109
1213
  this: Command,
1110
- opts: { runtime?: string; disk?: string; clientIp?: string; yes?: boolean },
1214
+ opts: {
1215
+ runtime?: string;
1216
+ disk?: string;
1217
+ clientIp?: string;
1218
+ yes?: boolean;
1219
+ },
1111
1220
  ) {
1112
1221
  try {
1222
+ // Reject an unrecognized runtime rather than resolving it to claude:
1223
+ // `--runtime codx` would otherwise hand back a silently Claude box.
1224
+ if (
1225
+ opts.runtime !== undefined &&
1226
+ opts.runtime !== "claude" &&
1227
+ opts.runtime !== "codex"
1228
+ ) {
1229
+ console.error(
1230
+ chalk.red(
1231
+ `Invalid --runtime '${opts.runtime}': must be 'claude' or 'codex'.`,
1232
+ ),
1233
+ );
1234
+ process.exit(1);
1235
+ }
1113
1236
  const agentRuntime =
1114
1237
  opts.runtime === "codex"
1115
1238
  ? "codex"
1116
- : opts.runtime
1239
+ : opts.runtime === "claude"
1117
1240
  ? "claude"
1118
1241
  : undefined;
1119
1242
  let diskSizeGb: number | undefined;
1120
1243
  if (opts.disk !== undefined) {
1121
1244
  diskSizeGb = Number(opts.disk);
1122
1245
  if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
1123
- console.error(chalk.red(`Invalid --disk '${opts.disk}': must be a positive number of GB.`));
1246
+ console.error(
1247
+ chalk.red(
1248
+ `Invalid --disk '${opts.disk}': must be a positive number of GB.`,
1249
+ ),
1250
+ );
1124
1251
  process.exit(1);
1125
1252
  }
1126
1253
  }
@@ -1137,7 +1264,9 @@ export function registerOutpostsCommand(
1137
1264
  const refreshToken = loadCachedTokens()?.refreshToken;
1138
1265
  if (!refreshToken) {
1139
1266
  console.error(
1140
- chalk.red("No cached session found — run `hq login` first, then re-run."),
1267
+ chalk.red(
1268
+ "No cached session found — run `hq login` first, then re-run.",
1269
+ ),
1141
1270
  );
1142
1271
  process.exit(1);
1143
1272
  }
@@ -1162,6 +1291,16 @@ export function registerOutpostsCommand(
1162
1291
  await surfaceBillingRequired(token, err.billing);
1163
1292
  process.exit(1);
1164
1293
  }
1294
+ // Already at the per-person cap → say so and name the box they own,
1295
+ // not a bare "Conflict".
1296
+ if (
1297
+ err instanceof OutpostHttpError &&
1298
+ err.status === 409 &&
1299
+ err.capped
1300
+ ) {
1301
+ surfaceOutpostCapped(err.capped);
1302
+ process.exit(1);
1303
+ }
1165
1304
  throw err;
1166
1305
  }
1167
1306
  } catch (err) {
@@ -1191,7 +1330,10 @@ export function registerOutpostsCommand(
1191
1330
  4,
1192
1331
  ...rows.map((r) => (r.instanceName ?? "").length),
1193
1332
  );
1194
- const regionW = Math.max(6, ...rows.map((r) => (r.region ?? "").length));
1333
+ const regionW = Math.max(
1334
+ 6,
1335
+ ...rows.map((r) => (r.region ?? "").length),
1336
+ );
1195
1337
  const rtW = Math.max(
1196
1338
  7,
1197
1339
  ...rows.map((r) => (r.agentRuntime ?? "").length),
@@ -1250,26 +1392,180 @@ export function registerOutpostsCommand(
1250
1392
  outposts
1251
1393
  .command("exec <command...>")
1252
1394
  .description(
1253
- "Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command)",
1395
+ "Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
1396
+ "Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.",
1254
1397
  )
1255
1398
  .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1399
+ .option(
1400
+ "--async",
1401
+ "Submit via the async transport and wait for completion (bypasses the ~20s sync cap; shell budget defaults to 48h)",
1402
+ )
1403
+ .option(
1404
+ "--detach",
1405
+ "Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)",
1406
+ )
1407
+ .option(
1408
+ "--timeout-seconds <n>",
1409
+ "Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.",
1410
+ (v: string) => {
1411
+ const n = Number(v);
1412
+ if (!Number.isInteger(n)) {
1413
+ throw new Error("--timeout-seconds must be an integer");
1414
+ }
1415
+ return n;
1416
+ },
1417
+ )
1256
1418
  .option("--json", "Emit raw JSON")
1257
1419
  .action(async function (
1258
1420
  this: Command,
1259
1421
  commandParts: string[],
1260
- opts: { id?: string; json?: boolean },
1422
+ opts: {
1423
+ id?: string;
1424
+ json?: boolean;
1425
+ async?: boolean;
1426
+ detach?: boolean;
1427
+ timeoutSeconds?: number;
1428
+ },
1261
1429
  ) {
1262
1430
  try {
1263
1431
  const command = joinCommandParts(commandParts);
1264
1432
  if (!command.trim()) {
1265
- console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
1433
+ console.error(
1434
+ chalk.red("No command given. Usage: hq outposts exec -- <command>"),
1435
+ );
1436
+ process.exit(1);
1437
+ }
1438
+ if (opts.async && opts.detach) {
1439
+ console.error(
1440
+ chalk.red("Use either --async (submit + wait) or --detach (submit only), not both."),
1441
+ );
1266
1442
  process.exit(1);
1267
1443
  }
1444
+ // --timeout-seconds only applies to the async path; bare use implies --async.
1445
+ const useAsync =
1446
+ Boolean(opts.async) ||
1447
+ Boolean(opts.detach) ||
1448
+ opts.timeoutSeconds !== undefined;
1449
+ if (opts.timeoutSeconds !== undefined) {
1450
+ if (
1451
+ !Number.isInteger(opts.timeoutSeconds) ||
1452
+ opts.timeoutSeconds < 1 ||
1453
+ opts.timeoutSeconds > 172_800
1454
+ ) {
1455
+ console.error(
1456
+ chalk.red(
1457
+ "--timeout-seconds must be an integer between 1 and 172800 (48h, the AWS-RunShellScript max)",
1458
+ ),
1459
+ );
1460
+ process.exit(1);
1461
+ }
1462
+ }
1268
1463
  const token = await ensureCognitoToken();
1269
1464
 
1270
1465
  // Run from the box's HQ folder by default (works over both SSM and SSH).
1271
1466
  const remoteCommand = withRemoteHqDir(command);
1272
1467
 
1468
+ if (useAsync) {
1469
+ try {
1470
+ const submitted = await submitExec(
1471
+ token,
1472
+ remoteCommand,
1473
+ opts.id,
1474
+ opts.timeoutSeconds,
1475
+ );
1476
+ if (opts.detach) {
1477
+ const output = {
1478
+ commandId: submitted.commandId,
1479
+ ...(submitted.executionTimeoutSeconds !== undefined
1480
+ ? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
1481
+ : opts.timeoutSeconds !== undefined
1482
+ ? { executionTimeoutSeconds: opts.timeoutSeconds }
1483
+ : {}),
1484
+ };
1485
+ if (opts.json) {
1486
+ process.stdout.write(JSON.stringify(output) + "\n");
1487
+ } else {
1488
+ printKeyValues(output);
1489
+ console.error(
1490
+ chalk.dim(
1491
+ "Submitted. Poll with: hq outposts exec-result --command-id " +
1492
+ submitted.commandId +
1493
+ (opts.id ? ` --id ${opts.id}` : "") +
1494
+ " --wait",
1495
+ ),
1496
+ );
1497
+ }
1498
+ return;
1499
+ }
1500
+
1501
+ // --async (or --timeout-seconds without --detach): wait for terminal.
1502
+ if (!opts.json) {
1503
+ console.error(
1504
+ chalk.dim(
1505
+ `Submitted ${submitted.commandId}; waiting for completion…`,
1506
+ ),
1507
+ );
1508
+ }
1509
+ const result = await waitForExecResult(
1510
+ token,
1511
+ submitted.commandId,
1512
+ opts.id,
1513
+ );
1514
+ if (opts.json) {
1515
+ process.stdout.write(
1516
+ JSON.stringify(
1517
+ {
1518
+ commandId: submitted.commandId,
1519
+ done: result.done,
1520
+ status: result.status,
1521
+ exitCode: result.exitCode ?? null,
1522
+ stdout: result.stdout ?? "",
1523
+ stderr: result.stderr ?? "",
1524
+ truncated: result.truncated ?? false,
1525
+ },
1526
+ null,
1527
+ 2,
1528
+ ) + "\n",
1529
+ );
1530
+ } else {
1531
+ if (result.stdout) process.stdout.write(result.stdout);
1532
+ if (result.stderr) process.stderr.write(result.stderr);
1533
+ if (result.truncated) {
1534
+ console.error(
1535
+ chalk.yellow(
1536
+ "(output truncated — redirect to a file on the box for full output)",
1537
+ ),
1538
+ );
1539
+ }
1540
+ if (result.status !== "Success" && result.exitCode === null) {
1541
+ console.error(
1542
+ chalk.yellow(`(command ended with SSM status: ${result.status})`),
1543
+ );
1544
+ }
1545
+ }
1546
+ process.exitCode =
1547
+ typeof result.exitCode === "number" ? result.exitCode : 0;
1548
+ return;
1549
+ } catch (err) {
1550
+ // Async requires EC2/SSM. Lightsail has no async channel — refuse
1551
+ // rather than silently falling back to a live SSH hold, which is
1552
+ // the exact timeout failure mode --async is meant to escape.
1553
+ if (
1554
+ err instanceof OutpostHttpError &&
1555
+ err.step === "platform-unsupported"
1556
+ ) {
1557
+ console.error(
1558
+ chalk.red(
1559
+ "Async exec requires an EC2 Outpost (SSM). This box is Lightsail — " +
1560
+ "re-provision on EC2, or run a short sync command / SSH session instead.",
1561
+ ),
1562
+ );
1563
+ process.exit(1);
1564
+ }
1565
+ throw err;
1566
+ }
1567
+ }
1568
+
1273
1569
  try {
1274
1570
  const result = await execOutpost(token, remoteCommand, opts.id);
1275
1571
  if (opts.json) {
@@ -1280,11 +1576,17 @@ export function registerOutpostsCommand(
1280
1576
  if (result.stderr) process.stderr.write(result.stderr);
1281
1577
  if (result.truncated) {
1282
1578
  console.error(
1283
- chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"),
1579
+ chalk.yellow(
1580
+ "(output truncated by SSM's inline limit — redirect to a file on the box for full output)",
1581
+ ),
1284
1582
  );
1285
1583
  }
1286
1584
  if (result.status !== "Success" && result.exitCode === null) {
1287
- console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
1585
+ console.error(
1586
+ chalk.yellow(
1587
+ `(command ended with SSM status: ${result.status})`,
1588
+ ),
1589
+ );
1288
1590
  }
1289
1591
  }
1290
1592
  // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
@@ -1299,7 +1601,9 @@ export function registerOutpostsCommand(
1299
1601
  const access = await getOutpostSshAccess(token, opts.id);
1300
1602
  const ssh = execViaSsh(access, remoteCommand);
1301
1603
  if (ssh.error) {
1302
- console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
1604
+ console.error(
1605
+ chalk.red(`Could not run the command over SSH: ${ssh.error}`),
1606
+ );
1303
1607
  process.exit(1);
1304
1608
  }
1305
1609
  if (opts.json) {
@@ -1369,25 +1673,69 @@ export function registerOutpostsCommand(
1369
1673
 
1370
1674
  outposts
1371
1675
  .command("exec-submit <command...>")
1372
- .description("Submit an asynchronous shell command to an Outpost")
1676
+ .description(
1677
+ "Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)",
1678
+ )
1373
1679
  .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1680
+ .option(
1681
+ "--timeout-seconds <n>",
1682
+ "Shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)",
1683
+ (v: string) => {
1684
+ const n = Number(v);
1685
+ if (!Number.isInteger(n)) {
1686
+ throw new Error("--timeout-seconds must be an integer");
1687
+ }
1688
+ return n;
1689
+ },
1690
+ )
1374
1691
  .option("--json", "Emit raw JSON")
1375
1692
  .action(async function (
1376
1693
  this: Command,
1377
1694
  commandParts: string[],
1378
- opts: { id?: string; json?: boolean },
1695
+ opts: { id?: string; json?: boolean; timeoutSeconds?: number },
1379
1696
  ) {
1380
1697
  try {
1381
1698
  const command = joinCommandParts(commandParts);
1382
1699
  if (!command.trim()) {
1383
1700
  console.error(
1384
- chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"),
1701
+ chalk.red(
1702
+ "No command given. Usage: hq outposts exec-submit -- <command>",
1703
+ ),
1385
1704
  );
1386
1705
  process.exit(1);
1387
1706
  }
1707
+ if (opts.timeoutSeconds !== undefined) {
1708
+ if (
1709
+ !Number.isInteger(opts.timeoutSeconds) ||
1710
+ opts.timeoutSeconds < 1 ||
1711
+ opts.timeoutSeconds > 172_800
1712
+ ) {
1713
+ console.error(
1714
+ chalk.red(
1715
+ "--timeout-seconds must be an integer between 1 and 172800 (48h)",
1716
+ ),
1717
+ );
1718
+ process.exit(1);
1719
+ }
1720
+ }
1388
1721
  const token = await ensureCognitoToken();
1389
- const submitted = await submitExec(token, command, opts.id);
1390
- const output = { commandId: submitted.commandId };
1722
+ // exec-submit is the raw fire-and-forget path — do NOT wrap with
1723
+ // withRemoteHqDir here (callers that want the HQ cwd use `exec --async`
1724
+ // or prefix their own cd). Matches the existing contract.
1725
+ const submitted = await submitExec(
1726
+ token,
1727
+ command,
1728
+ opts.id,
1729
+ opts.timeoutSeconds,
1730
+ );
1731
+ const output = {
1732
+ commandId: submitted.commandId,
1733
+ ...(submitted.executionTimeoutSeconds !== undefined
1734
+ ? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
1735
+ : opts.timeoutSeconds !== undefined
1736
+ ? { executionTimeoutSeconds: opts.timeoutSeconds }
1737
+ : {}),
1738
+ };
1391
1739
  if (opts.json) {
1392
1740
  process.stdout.write(JSON.stringify(output) + "\n");
1393
1741
  } else {
@@ -1401,7 +1749,10 @@ export function registerOutpostsCommand(
1401
1749
  outposts
1402
1750
  .command("exec-result")
1403
1751
  .description("Fetch the result of an asynchronous Outpost command")
1404
- .requiredOption("--command-id <commandId>", "Command id returned by exec-submit")
1752
+ .requiredOption(
1753
+ "--command-id <commandId>",
1754
+ "Command id returned by exec-submit",
1755
+ )
1405
1756
  .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1406
1757
  .option("--wait", "Poll until the command reaches a terminal state")
1407
1758
  .option("--json", "Emit raw JSON")
@@ -1504,7 +1855,11 @@ export function registerOutpostsCommand(
1504
1855
  try {
1505
1856
  const trimmed = code.trim();
1506
1857
  if (!trimmed) {
1507
- console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
1858
+ console.error(
1859
+ chalk.red(
1860
+ "Provide the sign-in code: hq outposts login-code <code>",
1861
+ ),
1862
+ );
1508
1863
  process.exit(1);
1509
1864
  }
1510
1865
  const token = await ensureCognitoToken();