@indigoai-us/hq-cloud 6.15.4 → 6.15.6

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 (150) hide show
  1. package/dist/cli/push-scan-exclusions.test.d.ts +2 -0
  2. package/dist/cli/push-scan-exclusions.test.d.ts.map +1 -0
  3. package/dist/cli/push-scan-exclusions.test.js +87 -0
  4. package/dist/cli/push-scan-exclusions.test.js.map +1 -0
  5. package/dist/cli/rescue-exec-bit-preserve.test.js +8 -0
  6. package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -1
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +21 -1
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +82 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +23 -0
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +9 -0
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/lib/net-errors.d.ts.map +1 -1
  17. package/dist/lib/net-errors.js +4 -0
  18. package/dist/lib/net-errors.js.map +1 -1
  19. package/dist/machine-auth.test.js +7 -1
  20. package/dist/machine-auth.test.js.map +1 -1
  21. package/dist/object-io.d.ts +21 -0
  22. package/dist/object-io.d.ts.map +1 -1
  23. package/dist/object-io.js +229 -77
  24. package/dist/object-io.js.map +1 -1
  25. package/dist/object-io.test.js +211 -1
  26. package/dist/object-io.test.js.map +1 -1
  27. package/dist/outposts/billing.d.ts +41 -0
  28. package/dist/outposts/billing.d.ts.map +1 -0
  29. package/dist/outposts/billing.js +53 -0
  30. package/dist/outposts/billing.js.map +1 -0
  31. package/dist/outposts/cli-billing.d.ts +59 -0
  32. package/dist/outposts/cli-billing.d.ts.map +1 -0
  33. package/dist/outposts/cli-billing.js +93 -0
  34. package/dist/outposts/cli-billing.js.map +1 -0
  35. package/dist/outposts/cli.d.ts +13 -0
  36. package/dist/outposts/cli.d.ts.map +1 -0
  37. package/dist/outposts/cli.js +13 -0
  38. package/dist/outposts/cli.js.map +1 -0
  39. package/dist/outposts/client.d.ts +140 -0
  40. package/dist/outposts/client.d.ts.map +1 -0
  41. package/dist/outposts/client.js +271 -0
  42. package/dist/outposts/client.js.map +1 -0
  43. package/dist/outposts/client.test.d.ts +9 -0
  44. package/dist/outposts/client.test.d.ts.map +1 -0
  45. package/dist/outposts/client.test.js +226 -0
  46. package/dist/outposts/client.test.js.map +1 -0
  47. package/dist/outposts/command.d.ts +74 -0
  48. package/dist/outposts/command.d.ts.map +1 -0
  49. package/dist/outposts/command.js +661 -0
  50. package/dist/outposts/command.js.map +1 -0
  51. package/dist/outposts/command.test.d.ts +18 -0
  52. package/dist/outposts/command.test.d.ts.map +1 -0
  53. package/dist/outposts/command.test.js +933 -0
  54. package/dist/outposts/command.test.js.map +1 -0
  55. package/dist/outposts/errors.d.ts +32 -0
  56. package/dist/outposts/errors.d.ts.map +1 -0
  57. package/dist/outposts/errors.js +72 -0
  58. package/dist/outposts/errors.js.map +1 -0
  59. package/dist/outposts/heartbeat-command.d.ts +106 -0
  60. package/dist/outposts/heartbeat-command.d.ts.map +1 -0
  61. package/dist/outposts/heartbeat-command.js +186 -0
  62. package/dist/outposts/heartbeat-command.js.map +1 -0
  63. package/dist/outposts/host-deps.d.ts +49 -0
  64. package/dist/outposts/host-deps.d.ts.map +1 -0
  65. package/dist/outposts/host-deps.js +93 -0
  66. package/dist/outposts/host-deps.js.map +1 -0
  67. package/dist/outposts/index.d.ts +36 -0
  68. package/dist/outposts/index.d.ts.map +1 -0
  69. package/dist/outposts/index.js +32 -0
  70. package/dist/outposts/index.js.map +1 -0
  71. package/dist/outposts/node.d.ts +23 -0
  72. package/dist/outposts/node.d.ts.map +1 -0
  73. package/dist/outposts/node.js +22 -0
  74. package/dist/outposts/node.js.map +1 -0
  75. package/dist/outposts/remote-command.d.ts +30 -0
  76. package/dist/outposts/remote-command.d.ts.map +1 -0
  77. package/dist/outposts/remote-command.js +39 -0
  78. package/dist/outposts/remote-command.js.map +1 -0
  79. package/dist/outposts/replica-sync.d.ts +40 -0
  80. package/dist/outposts/replica-sync.d.ts.map +1 -0
  81. package/dist/outposts/replica-sync.js +188 -0
  82. package/dist/outposts/replica-sync.js.map +1 -0
  83. package/dist/outposts/replica-sync.test.d.ts +2 -0
  84. package/dist/outposts/replica-sync.test.d.ts.map +1 -0
  85. package/dist/outposts/replica-sync.test.js +224 -0
  86. package/dist/outposts/replica-sync.test.js.map +1 -0
  87. package/dist/outposts/self-deploy.d.ts +47 -0
  88. package/dist/outposts/self-deploy.d.ts.map +1 -0
  89. package/dist/outposts/self-deploy.js +188 -0
  90. package/dist/outposts/self-deploy.js.map +1 -0
  91. package/dist/outposts/self-deploy.test.d.ts +2 -0
  92. package/dist/outposts/self-deploy.test.d.ts.map +1 -0
  93. package/dist/outposts/self-deploy.test.js +186 -0
  94. package/dist/outposts/self-deploy.test.js.map +1 -0
  95. package/dist/outposts/session-heartbeat-bounds.test.d.ts +25 -0
  96. package/dist/outposts/session-heartbeat-bounds.test.d.ts.map +1 -0
  97. package/dist/outposts/session-heartbeat-bounds.test.js +156 -0
  98. package/dist/outposts/session-heartbeat-bounds.test.js.map +1 -0
  99. package/dist/outposts/session-heartbeat-guard.test.d.ts +16 -0
  100. package/dist/outposts/session-heartbeat-guard.test.d.ts.map +1 -0
  101. package/dist/outposts/session-heartbeat-guard.test.js +88 -0
  102. package/dist/outposts/session-heartbeat-guard.test.js.map +1 -0
  103. package/dist/outposts/session-heartbeat-publisher.d.ts +76 -0
  104. package/dist/outposts/session-heartbeat-publisher.d.ts.map +1 -0
  105. package/dist/outposts/session-heartbeat-publisher.js +117 -0
  106. package/dist/outposts/session-heartbeat-publisher.js.map +1 -0
  107. package/dist/outposts/session-heartbeat-publisher.test.d.ts +10 -0
  108. package/dist/outposts/session-heartbeat-publisher.test.d.ts.map +1 -0
  109. package/dist/outposts/session-heartbeat-publisher.test.js +153 -0
  110. package/dist/outposts/session-heartbeat-publisher.test.js.map +1 -0
  111. package/dist/outposts/session-heartbeat-retain-guard.test.d.ts +18 -0
  112. package/dist/outposts/session-heartbeat-retain-guard.test.d.ts.map +1 -0
  113. package/dist/outposts/session-heartbeat-retain-guard.test.js +106 -0
  114. package/dist/outposts/session-heartbeat-retain-guard.test.js.map +1 -0
  115. package/dist/outposts/session-heartbeat.d.ts +210 -0
  116. package/dist/outposts/session-heartbeat.d.ts.map +1 -0
  117. package/dist/outposts/session-heartbeat.js +657 -0
  118. package/dist/outposts/session-heartbeat.js.map +1 -0
  119. package/dist/outposts/session-heartbeat.test.d.ts +11 -0
  120. package/dist/outposts/session-heartbeat.test.d.ts.map +1 -0
  121. package/dist/outposts/session-heartbeat.test.js +370 -0
  122. package/dist/outposts/session-heartbeat.test.js.map +1 -0
  123. package/dist/outposts/ssh.d.ts +29 -0
  124. package/dist/outposts/ssh.d.ts.map +1 -0
  125. package/dist/outposts/ssh.js +66 -0
  126. package/dist/outposts/ssh.js.map +1 -0
  127. package/dist/outposts/transport.d.ts +76 -0
  128. package/dist/outposts/transport.d.ts.map +1 -0
  129. package/dist/outposts/transport.js +91 -0
  130. package/dist/outposts/transport.js.map +1 -0
  131. package/dist/outposts/transport.test.d.ts +10 -0
  132. package/dist/outposts/transport.test.d.ts.map +1 -0
  133. package/dist/outposts/transport.test.js +116 -0
  134. package/dist/outposts/transport.test.js.map +1 -0
  135. package/dist/outposts/types.d.ts +125 -0
  136. package/dist/outposts/types.d.ts.map +1 -0
  137. package/dist/outposts/types.js +13 -0
  138. package/dist/outposts/types.js.map +1 -0
  139. package/dist/personal-vault.d.ts +69 -0
  140. package/dist/personal-vault.d.ts.map +1 -1
  141. package/dist/personal-vault.js +100 -0
  142. package/dist/personal-vault.js.map +1 -1
  143. package/dist/personal-vault.test.js +140 -1
  144. package/dist/personal-vault.test.js.map +1 -1
  145. package/dist/s3.d.ts.map +1 -1
  146. package/dist/s3.js +62 -0
  147. package/dist/s3.js.map +1 -1
  148. package/dist/s3.test.js +126 -0
  149. package/dist/s3.test.js.map +1 -1
  150. package/package.json +39 -3
@@ -0,0 +1,661 @@
1
+ /**
2
+ * `hq outposts` — the full command tree for managing personal HQ Outposts from a
3
+ * terminal instead of the web console.
4
+ *
5
+ * Targets the hq-pro `/outpost/*` control plane — the same routes the console's
6
+ * outpost panel calls — through an injected transport, so the host CLI keeps
7
+ * ownership of auth, telemetry, and error decoration while the command surface
8
+ * itself lives here and is shared by every consumer.
9
+ *
10
+ * Outposts are PERSONAL / caller-scoped: hq-pro keys every `/outpost/*` route on
11
+ * the caller's Cognito sub, so there is no `--company`. `--id <outpostId>` selects
12
+ * a specific box; when omitted hq-pro targets the caller's primary slot.
13
+ *
14
+ * Subcommands:
15
+ * outposts provision — provision a box (paid; requires --yes)
16
+ * outposts list — every Outpost you own (row summaries)
17
+ * outposts status [--id] — live detail for one box
18
+ * outposts exec <command...> — run a command on the box (sync/async/detach)
19
+ * outposts exec-stage — presign an input upload for an async command
20
+ * outposts exec-submit <cmd...> — fire-and-forget async submit
21
+ * outposts exec-result — poll an async command
22
+ * outposts codex-enable [--id] — enable / retry Codex on the box
23
+ * outposts login [--id] — request a fresh login URL
24
+ * outposts login-code <code> — hand the box its Claude sign-in code
25
+ * outposts destroy [--id] --yes — tear the box down (destructive; flag-guarded)
26
+ * outposts heartbeat — hidden; box-side systemd loop
27
+ * outposts self-deploy — hidden; make THIS host an unmanaged outpost
28
+ * outposts replica-sync — hidden; box-side full-replica timer target
29
+ *
30
+ * NOTE: hq-pro exposes no rename or settings-mutation route for Outposts (the web
31
+ * console can't rename them either), so this wraps only the lifecycle and status
32
+ * routes that exist. Renaming an Outpost is not a backend capability.
33
+ */
34
+ import chalk from "chalk";
35
+ import * as fs from "node:fs";
36
+ import { OUTPOST_PRICE_CENTS } from "./billing.js";
37
+ import { confirmChargeOrExit, surfaceBillingBlocked, } from "./cli-billing.js";
38
+ import { EXEC_MAX_TIMEOUT_SECONDS, OutpostsClient, } from "./client.js";
39
+ import { OutpostHttpError } from "./errors.js";
40
+ import { registerHeartbeatCommand } from "./heartbeat-command.js";
41
+ import { defaultReplicaSyncDependencies, } from "./host-deps.js";
42
+ import { joinCommandParts, withRemoteHqDir } from "./remote-command.js";
43
+ import { replicaSyncOutpost } from "./replica-sync.js";
44
+ import { selfDeployOutpost } from "./self-deploy.js";
45
+ import { execViaSsh } from "./ssh.js";
46
+ /**
47
+ * Explain a `409` per-person cap.
48
+ *
49
+ * hq-pro checks the cap BEFORE activation billing, so nothing was charged — worth
50
+ * saying, since the caller just confirmed a recurring charge to get here.
51
+ */
52
+ function surfaceOutpostCapped(capped) {
53
+ const owned = capped.outposts.length;
54
+ console.error(chalk.yellow(`You're already at your Outpost limit (${owned} of ${capped.limit}). ` +
55
+ `No new box was provisioned and you have not been charged.`));
56
+ for (const o of capped.outposts) {
57
+ const detail = [o.state, o.instanceName, o.region].filter(Boolean).join(" ");
58
+ console.error(` ${o.outpostId} ${detail}`);
59
+ }
60
+ console.error(chalk.dim("Inspect it: hq outposts status"));
61
+ console.error(chalk.dim("Or tear it down first: hq outposts destroy --id <id> --yes"));
62
+ }
63
+ function fail(err) {
64
+ if (err instanceof OutpostHttpError) {
65
+ console.error(chalk.red(err.message));
66
+ }
67
+ else {
68
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
69
+ }
70
+ process.exit(1);
71
+ }
72
+ /** Print a top-level object as `key: value`, JSON-ifying nested values. */
73
+ function printKeyValues(obj) {
74
+ for (const [k, v] of Object.entries(obj)) {
75
+ const rendered = v && typeof v === "object" ? JSON.stringify(v) : String(v);
76
+ console.log(`${chalk.bold(k)}: ${rendered}`);
77
+ }
78
+ }
79
+ /** Reject a `--timeout-seconds` outside the AWS-RunShellScript range. */
80
+ function assertExecTimeoutInRange(timeoutSeconds, hint) {
81
+ if (!Number.isInteger(timeoutSeconds) ||
82
+ timeoutSeconds < 1 ||
83
+ timeoutSeconds > EXEC_MAX_TIMEOUT_SECONDS) {
84
+ console.error(chalk.red(hint));
85
+ process.exit(1);
86
+ }
87
+ }
88
+ /**
89
+ * The local process exit code for a remote exec result.
90
+ *
91
+ * SSM does not always report a numeric `exitCode` even when it terminates a
92
+ * command with a non-success status (`Failed`, `Cancelled`). Falling back to `0`
93
+ * there tells automation the command SUCCEEDED — so `hq outposts exec` in a
94
+ * script silently swallows a remote failure. Fall back to `1` whenever the SSM
95
+ * status is not `Success`, and to `0` only for a genuine success with no code.
96
+ */
97
+ function execExitCode(result) {
98
+ if (typeof result.exitCode === "number")
99
+ return result.exitCode;
100
+ return result.status === "Success" ? 0 : 1;
101
+ }
102
+ function parseIntegerOption(flag) {
103
+ return (v) => {
104
+ const n = Number(v);
105
+ if (!Number.isInteger(n)) {
106
+ throw new Error(`${flag} must be an integer`);
107
+ }
108
+ return n;
109
+ };
110
+ }
111
+ export function registerOutpostsCommand(program, deps) {
112
+ const host = {
113
+ ...defaultReplicaSyncDependencies,
114
+ ...deps.host,
115
+ };
116
+ const client = new OutpostsClient({
117
+ transport: deps.transport,
118
+ token: deps.getToken,
119
+ ...(deps.baseUrl ? { baseUrl: deps.baseUrl } : {}),
120
+ });
121
+ const billingContext = {
122
+ transport: deps.transport,
123
+ token: deps.getToken,
124
+ ...(deps.baseUrl ? { baseUrl: deps.baseUrl } : {}),
125
+ };
126
+ const outposts = program
127
+ .command("outposts")
128
+ .description("Manage your personal HQ Outposts (EC2 boxes)");
129
+ // On-box session heartbeat (systemd). Hidden — operators never run it.
130
+ registerHeartbeatCommand(outposts, {
131
+ getJwt: async () => {
132
+ if (deps.getTokenNonInteractive)
133
+ return deps.getTokenNonInteractive();
134
+ return typeof deps.getToken === "function"
135
+ ? deps.getToken()
136
+ : deps.getToken;
137
+ },
138
+ resolveCallerPersonUid: deps.resolveCallerPersonUid,
139
+ defaultApiBaseUrl: deps.defaultApiBaseUrl,
140
+ });
141
+ outposts
142
+ .command("self-deploy", { hidden: true })
143
+ .description("Configure this EC2 host as a locally self-hosted HQ outpost")
144
+ .option("--yes", "Skip the self-hosting confirmation")
145
+ .option("--hq-root <path>", "HQ root to sync", host.defaultHqRoot())
146
+ .action(async (opts) => {
147
+ try {
148
+ await selfDeployOutpost(opts, host);
149
+ }
150
+ catch (err) {
151
+ fail(err);
152
+ }
153
+ });
154
+ outposts
155
+ .command("replica-sync", { hidden: true })
156
+ .description("Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.")
157
+ .option("--hq-root <path>", "HQ root to replicate into", host.defaultHqRoot())
158
+ .action(async (opts) => {
159
+ // Best-effort background job: never hard-fail the systemd unit on a
160
+ // transient step. Swallow, log, and exit 0.
161
+ try {
162
+ await replicaSyncOutpost(opts, host);
163
+ }
164
+ catch (err) {
165
+ console.warn(`replica-sync: unexpected error — ${err instanceof Error ? err.message : String(err)}`);
166
+ }
167
+ });
168
+ outposts
169
+ .command("provision")
170
+ .alias("create")
171
+ .description("Provision a new Outpost ($80/month — requires --yes)")
172
+ .option("--runtime <runtime>", "Agent runtime: claude | codex (default claude)")
173
+ .option("--disk <gb>", "Root disk size in GB (EC2 only)")
174
+ .option("--client-ip <ip>", "Your public IP for the box's SSH ingress (optional; server derives it otherwise)")
175
+ .option("--yes", "Confirm the $80/month charge (required to provision)")
176
+ .action(async function (opts) {
177
+ try {
178
+ // Reject an unrecognized runtime rather than resolving it to claude:
179
+ // `--runtime codx` would otherwise hand back a silently Claude box.
180
+ if (opts.runtime !== undefined &&
181
+ opts.runtime !== "claude" &&
182
+ opts.runtime !== "codex") {
183
+ console.error(chalk.red(`Invalid --runtime '${opts.runtime}': must be 'claude' or 'codex'.`));
184
+ process.exit(1);
185
+ }
186
+ const agentRuntime = opts.runtime === "codex"
187
+ ? "codex"
188
+ : opts.runtime === "claude"
189
+ ? "claude"
190
+ : undefined;
191
+ let diskSizeGb;
192
+ if (opts.disk !== undefined) {
193
+ diskSizeGb = Number(opts.disk);
194
+ if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
195
+ console.error(chalk.red(`Invalid --disk '${opts.disk}': must be a positive number of GB.`));
196
+ process.exit(1);
197
+ }
198
+ }
199
+ // Paid gate: print the monthly cost and require --yes before any call.
200
+ confirmChargeOrExit({
201
+ resource: "Outpost",
202
+ unitCents: OUTPOST_PRICE_CENTS,
203
+ yes: opts.yes,
204
+ });
205
+ // The box authenticates AS the caller using the cached refresh token —
206
+ // the same body the console sends. Never printed.
207
+ const refreshToken = deps.loadCachedTokens()?.refreshToken;
208
+ if (!refreshToken) {
209
+ console.error(chalk.red("No cached session found — run `hq login` first, then re-run."));
210
+ process.exit(1);
211
+ }
212
+ try {
213
+ await client.provision({
214
+ refreshToken,
215
+ ...(opts.clientIp ? { clientIp: opts.clientIp } : {}),
216
+ ...(diskSizeGb ? { diskSizeGb } : {}),
217
+ ...(agentRuntime ? { agentRuntime } : {}),
218
+ });
219
+ console.log(chalk.green("Provisioning started for your Outpost."));
220
+ console.log(chalk.dim("Track it: hq outposts status"));
221
+ }
222
+ catch (err) {
223
+ // No card on file → surface the shareable payment link, not an opaque 402.
224
+ if (err instanceof OutpostHttpError &&
225
+ err.status === 402 &&
226
+ err.billing) {
227
+ await surfaceBillingBlocked(billingContext, err.billing, err.message);
228
+ process.exit(1);
229
+ }
230
+ // Already at the per-person cap → say so and name the box they own, not
231
+ // a bare "Conflict".
232
+ if (err instanceof OutpostHttpError &&
233
+ err.status === 409 &&
234
+ err.capped) {
235
+ surfaceOutpostCapped(err.capped);
236
+ process.exit(1);
237
+ }
238
+ throw err;
239
+ }
240
+ }
241
+ catch (err) {
242
+ fail(err);
243
+ }
244
+ });
245
+ outposts
246
+ .command("list")
247
+ .description("List every Outpost you own")
248
+ .option("--json", "Emit raw JSON")
249
+ .action(async function (opts) {
250
+ try {
251
+ const rows = await client.list();
252
+ if (opts.json) {
253
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
254
+ return;
255
+ }
256
+ if (rows.length === 0) {
257
+ console.log(chalk.gray("You don't own any Outposts yet."));
258
+ return;
259
+ }
260
+ const idW = Math.max(2, ...rows.map((r) => (r.outpostId ?? "").length));
261
+ const stateW = Math.max(5, ...rows.map((r) => (r.state ?? "").length));
262
+ const nameW = Math.max(4, ...rows.map((r) => (r.instanceName ?? "").length));
263
+ const regionW = Math.max(6, ...rows.map((r) => (r.region ?? "").length));
264
+ const rtW = Math.max(7, ...rows.map((r) => (r.agentRuntime ?? "").length));
265
+ console.log(chalk.bold([
266
+ "ID".padEnd(idW),
267
+ "STATE".padEnd(stateW),
268
+ "INSTANCE".padEnd(nameW),
269
+ "REGION".padEnd(regionW),
270
+ "RUNTIME".padEnd(rtW),
271
+ "PLATFORM",
272
+ ].join(" ")));
273
+ for (const r of rows) {
274
+ console.log([
275
+ (r.outpostId ?? "").padEnd(idW),
276
+ (r.state ?? "").padEnd(stateW),
277
+ (r.instanceName ?? "").padEnd(nameW),
278
+ (r.region ?? "").padEnd(regionW),
279
+ (r.agentRuntime ?? "").padEnd(rtW),
280
+ r.platform ?? "",
281
+ ].join(" "));
282
+ }
283
+ }
284
+ catch (err) {
285
+ fail(err);
286
+ }
287
+ });
288
+ outposts
289
+ .command("status")
290
+ .description("Show live detail for one Outpost")
291
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
292
+ .option("--json", "Emit raw JSON")
293
+ .action(async function (opts) {
294
+ try {
295
+ const status = await client.status(opts.id);
296
+ if (opts.json) {
297
+ process.stdout.write(JSON.stringify(status, null, 2) + "\n");
298
+ return;
299
+ }
300
+ printKeyValues(status);
301
+ }
302
+ catch (err) {
303
+ fail(err);
304
+ }
305
+ });
306
+ outposts
307
+ .command("exec <command...>")
308
+ .description("Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
309
+ "Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.")
310
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
311
+ .option("--async", "Submit via the async transport and wait for completion (bypasses the ~20s sync cap; shell budget defaults to 48h)")
312
+ .option("--detach", "Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)")
313
+ .option("--timeout-seconds <n>", "Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.", parseIntegerOption("--timeout-seconds"))
314
+ .option("--json", "Emit raw JSON")
315
+ .action(async function (commandParts, opts) {
316
+ try {
317
+ const command = joinCommandParts(commandParts);
318
+ if (!command.trim()) {
319
+ console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
320
+ process.exit(1);
321
+ }
322
+ if (opts.async && opts.detach) {
323
+ console.error(chalk.red("Use either --async (submit + wait) or --detach (submit only), not both."));
324
+ process.exit(1);
325
+ }
326
+ // --timeout-seconds only applies to the async path; bare use implies --async.
327
+ const useAsync = Boolean(opts.async) ||
328
+ Boolean(opts.detach) ||
329
+ opts.timeoutSeconds !== undefined;
330
+ if (opts.timeoutSeconds !== undefined) {
331
+ assertExecTimeoutInRange(opts.timeoutSeconds, "--timeout-seconds must be an integer between 1 and 172800 (48h, the AWS-RunShellScript max)");
332
+ }
333
+ // Run from the box's HQ folder by default (works over both SSM and SSH).
334
+ const remoteCommand = withRemoteHqDir(command);
335
+ if (useAsync) {
336
+ try {
337
+ const submitted = await client.submitExec(remoteCommand, opts.id, opts.timeoutSeconds);
338
+ if (opts.detach) {
339
+ const output = {
340
+ commandId: submitted.commandId,
341
+ ...(submitted.executionTimeoutSeconds !== undefined
342
+ ? {
343
+ executionTimeoutSeconds: submitted.executionTimeoutSeconds,
344
+ }
345
+ : opts.timeoutSeconds !== undefined
346
+ ? { executionTimeoutSeconds: opts.timeoutSeconds }
347
+ : {}),
348
+ };
349
+ if (opts.json) {
350
+ process.stdout.write(JSON.stringify(output) + "\n");
351
+ }
352
+ else {
353
+ printKeyValues(output);
354
+ console.error(chalk.dim("Submitted. Poll with: hq outposts exec-result --command-id " +
355
+ submitted.commandId +
356
+ (opts.id ? ` --id ${opts.id}` : "") +
357
+ " --wait"));
358
+ }
359
+ return;
360
+ }
361
+ // --async (or --timeout-seconds without --detach): wait for terminal.
362
+ if (!opts.json) {
363
+ console.error(chalk.dim(`Submitted ${submitted.commandId}; waiting for completion…`));
364
+ }
365
+ const result = await client.waitForExecResult(submitted.commandId, opts.id);
366
+ if (opts.json) {
367
+ process.stdout.write(JSON.stringify({
368
+ commandId: submitted.commandId,
369
+ done: result.done,
370
+ status: result.status,
371
+ exitCode: result.exitCode ?? null,
372
+ stdout: result.stdout ?? "",
373
+ stderr: result.stderr ?? "",
374
+ truncated: result.truncated ?? false,
375
+ }, null, 2) + "\n");
376
+ }
377
+ else {
378
+ if (result.stdout)
379
+ process.stdout.write(result.stdout);
380
+ if (result.stderr)
381
+ process.stderr.write(result.stderr);
382
+ if (result.truncated) {
383
+ console.error(chalk.yellow("(output truncated — redirect to a file on the box for full output)"));
384
+ }
385
+ if (result.status !== "Success" && result.exitCode === null) {
386
+ console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
387
+ }
388
+ }
389
+ process.exitCode = execExitCode(result);
390
+ return;
391
+ }
392
+ catch (err) {
393
+ // Async requires EC2/SSM. Lightsail has no async channel — refuse
394
+ // rather than silently falling back to a live SSH hold, which is the
395
+ // exact timeout failure mode --async is meant to escape.
396
+ if (err instanceof OutpostHttpError &&
397
+ err.step === "platform-unsupported") {
398
+ console.error(chalk.red("Async exec requires an EC2 Outpost (SSM). This box is Lightsail — " +
399
+ "re-provision on EC2, or run a short sync command / SSH session instead."));
400
+ process.exit(1);
401
+ }
402
+ throw err;
403
+ }
404
+ }
405
+ try {
406
+ const result = await client.exec(remoteCommand, opts.id);
407
+ if (opts.json) {
408
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
409
+ }
410
+ else {
411
+ // Stream the remote streams to ours so the command feels local.
412
+ if (result.stdout)
413
+ process.stdout.write(result.stdout);
414
+ if (result.stderr)
415
+ process.stderr.write(result.stderr);
416
+ if (result.truncated) {
417
+ console.error(chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"));
418
+ }
419
+ if (result.status !== "Success" && result.exitCode === null) {
420
+ console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
421
+ }
422
+ }
423
+ // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
424
+ process.exitCode = execExitCode(result);
425
+ }
426
+ catch (err) {
427
+ // Lightsail boxes have no SSM agent — transparently fall back to SSH.
428
+ if (err instanceof OutpostHttpError &&
429
+ err.step === "platform-unsupported") {
430
+ const access = await client.sshAccess(opts.id);
431
+ const ssh = execViaSsh(access, remoteCommand);
432
+ if (ssh.error) {
433
+ console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
434
+ process.exit(1);
435
+ }
436
+ if (opts.json) {
437
+ process.stdout.write(JSON.stringify({
438
+ via: "ssh",
439
+ platform: access.platform,
440
+ host: access.host,
441
+ exitCode: ssh.exitCode,
442
+ stdout: ssh.stdout,
443
+ stderr: ssh.stderr,
444
+ }, null, 2) + "\n");
445
+ }
446
+ else {
447
+ if (ssh.stdout)
448
+ process.stdout.write(ssh.stdout);
449
+ if (ssh.stderr)
450
+ process.stderr.write(ssh.stderr);
451
+ }
452
+ process.exitCode =
453
+ typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
454
+ return;
455
+ }
456
+ throw err;
457
+ }
458
+ }
459
+ catch (err) {
460
+ fail(err);
461
+ }
462
+ });
463
+ outposts
464
+ .command("exec-stage")
465
+ .description("Stage a file for an asynchronous Outpost command")
466
+ .requiredOption("--file <path>", "File to upload")
467
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
468
+ .option("--json", "Emit raw JSON")
469
+ .action(async function (opts) {
470
+ try {
471
+ const bytes = fs.readFileSync(opts.file);
472
+ const staged = await client.stageExecInput(opts.id);
473
+ const upload = await fetch(staged.putUrl, {
474
+ method: "PUT",
475
+ headers: { "Content-Length": String(bytes.byteLength) },
476
+ body: bytes,
477
+ });
478
+ if (!upload.ok) {
479
+ throw new Error(`Could not upload exec input: HTTP ${upload.status} ${upload.statusText}`);
480
+ }
481
+ const output = { key: staged.key, getUrl: staged.getUrl };
482
+ if (opts.json) {
483
+ process.stdout.write(JSON.stringify(output) + "\n");
484
+ }
485
+ else {
486
+ printKeyValues(output);
487
+ }
488
+ }
489
+ catch (err) {
490
+ fail(err);
491
+ }
492
+ });
493
+ outposts
494
+ .command("exec-submit <command...>")
495
+ .description("Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)")
496
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
497
+ .option("--timeout-seconds <n>", "Shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)", parseIntegerOption("--timeout-seconds"))
498
+ .option("--json", "Emit raw JSON")
499
+ .action(async function (commandParts, opts) {
500
+ try {
501
+ const command = joinCommandParts(commandParts);
502
+ if (!command.trim()) {
503
+ console.error(chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"));
504
+ process.exit(1);
505
+ }
506
+ if (opts.timeoutSeconds !== undefined) {
507
+ assertExecTimeoutInRange(opts.timeoutSeconds, "--timeout-seconds must be an integer between 1 and 172800 (48h)");
508
+ }
509
+ // exec-submit is the raw fire-and-forget path — do NOT wrap with
510
+ // withRemoteHqDir here (callers that want the HQ cwd use `exec --async` or
511
+ // prefix their own cd). Matches the existing contract.
512
+ const submitted = await client.submitExec(command, opts.id, opts.timeoutSeconds);
513
+ const output = {
514
+ commandId: submitted.commandId,
515
+ ...(submitted.executionTimeoutSeconds !== undefined
516
+ ? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
517
+ : opts.timeoutSeconds !== undefined
518
+ ? { executionTimeoutSeconds: opts.timeoutSeconds }
519
+ : {}),
520
+ };
521
+ if (opts.json) {
522
+ process.stdout.write(JSON.stringify(output) + "\n");
523
+ }
524
+ else {
525
+ printKeyValues(output);
526
+ }
527
+ }
528
+ catch (err) {
529
+ fail(err);
530
+ }
531
+ });
532
+ outposts
533
+ .command("exec-result")
534
+ .description("Fetch the result of an asynchronous Outpost command")
535
+ .requiredOption("--command-id <commandId>", "Command id returned by exec-submit")
536
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
537
+ .option("--wait", "Poll until the command reaches a terminal state")
538
+ .option("--json", "Emit raw JSON")
539
+ .action(async function (opts) {
540
+ try {
541
+ const result = opts.wait
542
+ ? await client.waitForExecResult(opts.commandId, opts.id)
543
+ : await client.fetchExecResult(opts.commandId, opts.id);
544
+ const output = {
545
+ done: result.done,
546
+ status: result.status,
547
+ exitCode: result.exitCode ?? null,
548
+ stdout: result.stdout ?? "",
549
+ stderr: result.stderr ?? "",
550
+ };
551
+ if (opts.json) {
552
+ process.stdout.write(JSON.stringify(output) + "\n");
553
+ }
554
+ else {
555
+ printKeyValues(output);
556
+ }
557
+ }
558
+ catch (err) {
559
+ fail(err);
560
+ }
561
+ });
562
+ outposts
563
+ .command("codex-enable")
564
+ .description("Enable (or retry) Codex on an Outpost")
565
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
566
+ .option("--json", "Emit raw JSON")
567
+ .action(async function (opts) {
568
+ try {
569
+ const result = await client.enableCodex(opts.id);
570
+ if (opts.json) {
571
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
572
+ return;
573
+ }
574
+ console.log(chalk.green("Codex enablement requested for the Outpost."));
575
+ }
576
+ catch (err) {
577
+ fail(err);
578
+ }
579
+ });
580
+ outposts
581
+ .command("login")
582
+ .description("Request a fresh login URL for an Outpost")
583
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
584
+ .option("--json", "Emit raw JSON")
585
+ .action(async function (opts) {
586
+ try {
587
+ const result = await client.regenerateLoginUrl(opts.id);
588
+ if (opts.json) {
589
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
590
+ return;
591
+ }
592
+ console.log(chalk.green("Login-URL regeneration requested. The box mints a fresh URL shortly."));
593
+ console.log(chalk.dim("Next: `hq outposts status" +
594
+ (opts.id ? ` --id ${opts.id}` : "") +
595
+ "` to read the login URL, open it and sign in, then paste the code back with " +
596
+ "`hq outposts login-code <code>" +
597
+ (opts.id ? ` --id ${opts.id}` : "") +
598
+ "`."));
599
+ }
600
+ catch (err) {
601
+ fail(err);
602
+ }
603
+ });
604
+ outposts
605
+ .command("login-code <code>")
606
+ .description("Submit the Claude sign-in code for an Outpost that is awaiting login")
607
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
608
+ .option("--json", "Emit raw JSON")
609
+ .action(async function (code, opts) {
610
+ try {
611
+ const trimmed = code.trim();
612
+ if (!trimmed) {
613
+ console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
614
+ process.exit(1);
615
+ }
616
+ const result = await client.submitLoginCode(trimmed, opts.id);
617
+ if (opts.json) {
618
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
619
+ return;
620
+ }
621
+ console.log(chalk.green("Code submitted — the box will finish signing in shortly."));
622
+ console.log(chalk.dim("Track it: `hq outposts status" +
623
+ (opts.id ? ` --id ${opts.id}` : "") +
624
+ "` (it flips to `ready` once Claude auth completes)."));
625
+ }
626
+ catch (err) {
627
+ fail(err);
628
+ }
629
+ });
630
+ outposts
631
+ .command("destroy")
632
+ .description("Tear down (permanently destroy) an Outpost")
633
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
634
+ .option("--yes", "Confirm the irreversible teardown (required)")
635
+ .action(async function (opts) {
636
+ const target = opts.id ?? "your primary Outpost";
637
+ if (!opts.yes) {
638
+ console.error(chalk.yellow(`This will permanently destroy ${target} and delete its cloud resources. ` +
639
+ `This cannot be undone.\n` +
640
+ `Re-run with --yes to confirm: hq outposts destroy${opts.id ? ` --id ${opts.id}` : ""} --yes`));
641
+ process.exit(1);
642
+ }
643
+ try {
644
+ await client.destroy(opts.id);
645
+ console.log(chalk.green(`Destroyed ${target}.`));
646
+ }
647
+ catch (err) {
648
+ // A 409 teardown-incomplete is not a failure — the gateway's 30s cap fired
649
+ // while the Lambda keeps working. The row is preserved and the operation is
650
+ // idempotent, so tell the caller to retry.
651
+ if (err instanceof OutpostHttpError &&
652
+ (err.status === 409 || err.step === "teardown-incomplete")) {
653
+ console.log(chalk.yellow(`Teardown still in progress for ${target} — this is expected for large boxes. ` +
654
+ `Re-run the same destroy command to finish (it's idempotent).`));
655
+ return;
656
+ }
657
+ fail(err);
658
+ }
659
+ });
660
+ }
661
+ //# sourceMappingURL=command.js.map