@indigoai-us/hq-cli 5.77.14 → 5.78.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 (308) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +42 -2
  33. package/dist/commands/pkg-install.js +39 -2
  34. package/dist/commands/run.js +6 -2
  35. package/dist/commands/secrets.js +120 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2256
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs-update-api-key.test.ts +0 -105
  146. package/src/commands/packs.test.ts +0 -88
  147. package/src/commands/packs.ts +0 -622
  148. package/src/commands/people.test.ts +0 -807
  149. package/src/commands/people.ts +0 -422
  150. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  151. package/src/commands/pkg-install.ts +0 -221
  152. package/src/commands/pkg-list.test.ts +0 -79
  153. package/src/commands/pkg-list.ts +0 -13
  154. package/src/commands/pkg-remove.ts +0 -71
  155. package/src/commands/pkg-update.ts +0 -189
  156. package/src/commands/publish.test.ts +0 -650
  157. package/src/commands/publish.ts +0 -580
  158. package/src/commands/reindex.test.ts +0 -259
  159. package/src/commands/reindex.ts +0 -274
  160. package/src/commands/rescue.test.ts +0 -39
  161. package/src/commands/rescue.ts +0 -210
  162. package/src/commands/run.env-local.test.ts +0 -88
  163. package/src/commands/run.test.ts +0 -45
  164. package/src/commands/run.ts +0 -190
  165. package/src/commands/safe-extract.test.ts +0 -459
  166. package/src/commands/safe-extract.ts +0 -444
  167. package/src/commands/secrets-scope.test.ts +0 -56
  168. package/src/commands/secrets-scope.ts +0 -32
  169. package/src/commands/secrets.parse-destination.test.ts +0 -38
  170. package/src/commands/secrets.test.ts +0 -2050
  171. package/src/commands/secrets.ts +0 -2249
  172. package/src/commands/signals.ts +0 -351
  173. package/src/commands/skill.test.ts +0 -770
  174. package/src/commands/skill.ts +0 -796
  175. package/src/commands/sources.ts +0 -362
  176. package/src/commands/sync-mode.test.ts +0 -366
  177. package/src/commands/sync-mode.ts +0 -387
  178. package/src/commands/sync-narrow.test.ts +0 -633
  179. package/src/commands/sync-narrow.ts +0 -555
  180. package/src/commands/sync.ts +0 -165
  181. package/src/commands/team-sync.ts +0 -628
  182. package/src/commands/update.ts +0 -71
  183. package/src/commands/whoami.ts +0 -83
  184. package/src/commands/workers.test.ts +0 -158
  185. package/src/commands/workers.ts +0 -301
  186. package/src/index.test.ts +0 -32
  187. package/src/index.ts +0 -20
  188. package/src/lib/db/control-plane.test.ts +0 -59
  189. package/src/lib/db/control-plane.ts +0 -113
  190. package/src/lib/db/local.test.ts +0 -81
  191. package/src/lib/db/local.ts +0 -148
  192. package/src/lib/db/migrate.test.ts +0 -133
  193. package/src/lib/db/migrate.ts +0 -137
  194. package/src/lib/db/paths.test.ts +0 -112
  195. package/src/lib/db/paths.ts +0 -128
  196. package/src/lib/db/remote-engine.test.ts +0 -44
  197. package/src/lib/db/remote-engine.ts +0 -148
  198. package/src/lib/db/remote-sql.test.ts +0 -32
  199. package/src/lib/db/remote-sql.ts +0 -62
  200. package/src/lib/db/sql.test.ts +0 -106
  201. package/src/lib/db/sql.ts +0 -192
  202. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  203. package/src/lib/local-tree-diff.test.ts +0 -301
  204. package/src/lib/local-tree-diff.ts +0 -362
  205. package/src/lib/narrow-hint-banner.test.ts +0 -235
  206. package/src/lib/narrow-hint-banner.ts +0 -212
  207. package/src/lib/plan-limit-nag.test.ts +0 -317
  208. package/src/lib/plan-limit-nag.ts +0 -264
  209. package/src/main.ts +0 -371
  210. package/src/node-preflight.test.ts +0 -60
  211. package/src/node-preflight.ts +0 -67
  212. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  213. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  214. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  215. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  216. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  217. package/src/outpost/session-heartbeat.test.ts +0 -459
  218. package/src/outpost/session-heartbeat.ts +0 -877
  219. package/src/packaging.test.ts +0 -109
  220. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  221. package/src/run/discover-schemas.test.ts +0 -153
  222. package/src/run/discover-schemas.ts +0 -79
  223. package/src/run/hq-plugin.test.ts +0 -371
  224. package/src/run/hq-plugin.ts +0 -277
  225. package/src/run/varlock-shape.test.ts +0 -57
  226. package/src/schemas/hq-package.schema.json +0 -155
  227. package/src/sentry-before-send.test.ts +0 -359
  228. package/src/sentry-before-send.ts +0 -221
  229. package/src/sentry-epipe.test.ts +0 -37
  230. package/src/sentry-release.test.ts +0 -54
  231. package/src/sentry.test.ts +0 -24
  232. package/src/sentry.ts +0 -54
  233. package/src/strategies/link.ts +0 -62
  234. package/src/strategies/merge.ts +0 -142
  235. package/src/types.ts +0 -146
  236. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  237. package/src/utils/auth-error.test.ts +0 -40
  238. package/src/utils/auth-error.ts +0 -42
  239. package/src/utils/billing-gate.test.ts +0 -95
  240. package/src/utils/billing-gate.ts +0 -228
  241. package/src/utils/breadcrumb-buffer.ts +0 -18
  242. package/src/utils/cli-telemetry.test.ts +0 -153
  243. package/src/utils/cli-telemetry.ts +0 -61
  244. package/src/utils/cognito-session.machine.test.ts +0 -109
  245. package/src/utils/cognito-session.refresh.test.ts +0 -84
  246. package/src/utils/cognito-session.test.ts +0 -328
  247. package/src/utils/cognito-session.ts +0 -448
  248. package/src/utils/company-selection-error.test.ts +0 -42
  249. package/src/utils/company-selection-error.ts +0 -45
  250. package/src/utils/contribution-table.ts +0 -83
  251. package/src/utils/environmental-error.test.ts +0 -45
  252. package/src/utils/environmental-error.ts +0 -39
  253. package/src/utils/epipe.test.ts +0 -28
  254. package/src/utils/epipe.ts +0 -29
  255. package/src/utils/expected-cli-error.test.ts +0 -28
  256. package/src/utils/expected-cli-error.ts +0 -39
  257. package/src/utils/feedback-diagnostics.test.ts +0 -183
  258. package/src/utils/feedback-diagnostics.ts +0 -123
  259. package/src/utils/feedback-screenshots.test.ts +0 -134
  260. package/src/utils/feedback-screenshots.ts +0 -124
  261. package/src/utils/feedback-versions.test.ts +0 -98
  262. package/src/utils/feedback-versions.ts +0 -68
  263. package/src/utils/git.ts +0 -74
  264. package/src/utils/integrity.ts +0 -59
  265. package/src/utils/intercepted-process-exit.test.ts +0 -37
  266. package/src/utils/intercepted-process-exit.ts +0 -36
  267. package/src/utils/login-provider.test.ts +0 -42
  268. package/src/utils/login-provider.ts +0 -30
  269. package/src/utils/manifest.test.ts +0 -167
  270. package/src/utils/manifest.ts +0 -131
  271. package/src/utils/pack-contributions.test.ts +0 -603
  272. package/src/utils/pack-contributions.ts +0 -450
  273. package/src/utils/people.ts +0 -215
  274. package/src/utils/registry-client.ts +0 -204
  275. package/src/utils/registry.ts +0 -90
  276. package/src/utils/resolve-vault-credential.test.ts +0 -69
  277. package/src/utils/resolve-vault-credential.ts +0 -60
  278. package/src/utils/sandbox-runner-client.test.ts +0 -279
  279. package/src/utils/sandbox-runner-client.ts +0 -260
  280. package/src/utils/secrets-cache.ts +0 -183
  281. package/src/utils/sentry-identity.ts +0 -45
  282. package/src/utils/settle-with-timeout.test.ts +0 -21
  283. package/src/utils/settle-with-timeout.ts +0 -22
  284. package/src/utils/user-yaml-error.test.ts +0 -24
  285. package/src/utils/user-yaml-error.ts +0 -30
  286. package/src/utils/vault-api.test.ts +0 -536
  287. package/src/utils/vault-api.ts +0 -382
  288. package/src/utils/version-check.test.ts +0 -176
  289. package/src/utils/version-check.ts +0 -155
  290. package/src/utils/version-gate.test.ts +0 -1024
  291. package/src/utils/version-gate.ts +0 -702
  292. package/test/commands/db-tenant-isolation.test.ts +0 -94
  293. package/test/commands/db.test.ts +0 -85
  294. package/test/commands/signals.test.ts +0 -207
  295. package/test/commands/sources.test.ts +0 -231
  296. package/test/e2e/smoke-install-mcp.sh +0 -113
  297. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  298. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  299. package/test/fixtures/signals/action_item/sample.md +0 -16
  300. package/test/fixtures/signals/summary/sample.md +0 -12
  301. package/test/fixtures/sources/meetings/sample.md +0 -25
  302. package/test/helpers/cli-runner.ts +0 -150
  303. package/test/helpers/s3-list-mock.ts +0 -79
  304. package/test/helpers/vault-service-mock.ts +0 -295
  305. package/test/sources-signals/smoke.test.ts +0 -228
  306. package/tsconfig.json +0 -20
  307. package/vitest.config.ts +0 -11
  308. package/vitest.e2e.config.ts +0 -7
@@ -1,1935 +0,0 @@
1
- /**
2
- * `hq outposts` — manage your personal HQ Outposts (EC2 boxes) from the
3
- * terminal instead of the web console. Targets the hq-pro `/outpost/*`
4
- * control plane on `DEFAULT_VAULT_API_URL` via the shared `vaultApiFetch`
5
- * helper — the same routes the console's outpost panel calls.
6
- *
7
- * Outposts are PERSONAL / caller-scoped: hq-pro keys every `/outpost/*` route
8
- * on the caller's Cognito sub, so there is no `--company`. `--id <outpostId>`
9
- * selects a specific box (passed as the `outpostId` query param); when omitted
10
- * hq-pro targets the caller's primary slot.
11
- *
12
- * Subcommands:
13
- * hq outposts list — every Outpost you own (row summaries)
14
- * hq outposts status [--id] — live detail for one box
15
- * hq outposts codex-enable [--id] — enable / retry Codex on the box
16
- * hq outposts login [--id] — request a fresh login URL
17
- * hq outposts destroy [--id] --yes — tear the box down (destructive; flag-guarded)
18
- *
19
- * NOTE: hq-pro exposes no rename or settings-mutation route for Outposts (the
20
- * web console can't rename them either), so this CLI wraps only the lifecycle
21
- * and status routes that exist. Renaming an Outpost is not a backend capability.
22
- */
23
-
24
- import { Command } from "commander";
25
- import chalk from "chalk";
26
- import { spawnSync } from "node:child_process";
27
- import * as fs from "node:fs";
28
- import * as os from "node:os";
29
- import * as path from "node:path";
30
- import * as readline from "node:readline";
31
- import { randomBytes } from "node:crypto";
32
- import * as yaml from "js-yaml";
33
- import { loadCachedTokens } from "@indigoai-us/hq-cloud";
34
- import { ensureCognitoToken } from "../utils/cognito-session.js";
35
- import { vaultApiFetch } from "../utils/vault-api.js";
36
- import { registerHeartbeatCommand } from "./outposts-heartbeat.js";
37
- import {
38
- OUTPOST_PRICE_CENTS,
39
- confirmChargeOrExit,
40
- parseBillingPayload,
41
- surfaceBillingBlocked,
42
- type BillingErrorPayload,
43
- } from "../utils/billing-gate.js";
44
-
45
- /**
46
- * hq-pro's per-person cap envelope on a `409` provision block. Unlike every
47
- * other `/outpost/*` failure this body carries NO `message`/`error` field —
48
- * only the cap facts — so it has to be decoded structurally or the reason
49
- * degrades to a bare `res.statusText` ("Conflict").
50
- */
51
- export interface OutpostCappedPayload {
52
- limit: number;
53
- outposts: OutpostSummary[];
54
- }
55
-
56
- /** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
57
- export function parseCappedPayload(
58
- body: unknown,
59
- ): OutpostCappedPayload | undefined {
60
- if (!body || typeof body !== "object") return undefined;
61
- const b = body as { capped?: unknown; limit?: unknown; outposts?: unknown };
62
- if (b.capped !== true) return undefined;
63
- return {
64
- limit: typeof b.limit === "number" ? b.limit : 0,
65
- outposts: Array.isArray(b.outposts) ? (b.outposts as OutpostSummary[]) : [],
66
- };
67
- }
68
-
69
- /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
70
- export class OutpostHttpError extends Error {
71
- status: number;
72
- step?: string;
73
- /** hq-pro's billing envelope on a `402 billing_required` provision block. */
74
- billing?: BillingErrorPayload;
75
- /** hq-pro's cap envelope on a `409` provision block. */
76
- capped?: OutpostCappedPayload;
77
- constructor(
78
- status: number,
79
- message: string,
80
- step?: string,
81
- billing?: BillingErrorPayload,
82
- capped?: OutpostCappedPayload,
83
- ) {
84
- super(message);
85
- this.name = "OutpostHttpError";
86
- this.status = status;
87
- this.step = step;
88
- this.billing = billing;
89
- this.capped = capped;
90
- }
91
- }
92
-
93
- /** Row summary from `GET /outpost/list`. */
94
- export interface OutpostSummary {
95
- outpostId: string;
96
- state: string;
97
- instanceName: string;
98
- region: string;
99
- agentRuntime: string;
100
- platform: string;
101
- createdAt: string;
102
- [key: string]: unknown;
103
- }
104
-
105
- /**
106
- * Authenticated JSON round-trip against the outpost control plane. Throws
107
- * `OutpostHttpError` on any non-2xx (never swallows — hq-never-swallow-errors),
108
- * decoding hq-pro's `{ error | message, step }` envelope for the reason. The
109
- * `step` is preserved so callers can recognise the `destroy` route's
110
- * `teardown-incomplete` 409 (which means "retry", not "failed").
111
- */
112
- export async function outpostRequest<T>(opts: {
113
- token: string;
114
- path: string;
115
- method?: string;
116
- body?: Record<string, unknown>;
117
- query?: Record<string, string>;
118
- }): Promise<T> {
119
- const res = await vaultApiFetch(opts);
120
- if (!res.ok) {
121
- const body = (await res.json().catch(() => ({}))) as {
122
- error?: unknown;
123
- message?: string;
124
- step?: string;
125
- };
126
- const message =
127
- typeof body.message === "string"
128
- ? body.message
129
- : typeof body.error === "string"
130
- ? body.error
131
- : res.statusText;
132
- throw new OutpostHttpError(
133
- res.status,
134
- message,
135
- body.step,
136
- parseBillingPayload(body),
137
- parseCappedPayload(body),
138
- );
139
- }
140
- return (await res.json()) as T;
141
- }
142
-
143
- /**
144
- * Provision the caller's Outpost. Sends the cached Cognito refresh token so the
145
- * box can authenticate AS the caller (the same body the console's
146
- * `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
147
- * their per-person cap gets a `409` whose body lists their existing boxes —
148
- * thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
149
- * BEFORE activation billing, so a capped call is never charged). The refresh
150
- * token is sent over HTTPS and NEVER printed.
151
- */
152
- export async function provisionOutpost(
153
- token: string,
154
- input: {
155
- refreshToken: string;
156
- clientIp?: string;
157
- diskSizeGb?: number;
158
- agentRuntime?: "claude" | "codex";
159
- },
160
- ): Promise<Record<string, unknown>> {
161
- return outpostRequest({
162
- token,
163
- path: "/outpost/provision",
164
- method: "POST",
165
- body: {
166
- refreshToken: input.refreshToken,
167
- ...(input.clientIp ? { clientIp: input.clientIp } : {}),
168
- ...(input.diskSizeGb ? { diskSizeGb: input.diskSizeGb } : {}),
169
- ...(input.agentRuntime ? { agentRuntime: input.agentRuntime } : {}),
170
- },
171
- });
172
- }
173
-
174
- export async function listOutposts(token: string): Promise<OutpostSummary[]> {
175
- const data = await outpostRequest<{ outposts: OutpostSummary[] }>({
176
- token,
177
- path: "/outpost/list",
178
- });
179
- return data.outposts ?? [];
180
- }
181
-
182
- export async function getOutpostStatus(
183
- token: string,
184
- outpostId?: string,
185
- ): Promise<Record<string, unknown>> {
186
- return outpostRequest({
187
- token,
188
- path: "/outpost/status",
189
- query: outpostId ? { outpostId } : undefined,
190
- });
191
- }
192
-
193
- export async function enableCodex(
194
- token: string,
195
- outpostId?: string,
196
- ): Promise<Record<string, unknown>> {
197
- return outpostRequest({
198
- token,
199
- path: "/outpost/codex/enable",
200
- method: "POST",
201
- query: outpostId ? { outpostId } : undefined,
202
- });
203
- }
204
-
205
- export async function regenerateLoginUrl(
206
- token: string,
207
- outpostId?: string,
208
- ): Promise<Record<string, unknown>> {
209
- return outpostRequest({
210
- token,
211
- path: "/outpost/regenerate-login-url",
212
- method: "POST",
213
- query: outpostId ? { outpostId } : undefined,
214
- });
215
- }
216
-
217
- /**
218
- * Hand the box the one-time Claude sign-in code the operator got from the login
219
- * URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
220
- * it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
221
- * terminal-native equivalent of pasting the code into the web console.
222
- */
223
- export async function submitLoginCode(
224
- token: string,
225
- code: string,
226
- outpostId?: string,
227
- ): Promise<Record<string, unknown>> {
228
- return outpostRequest({
229
- token,
230
- path: "/outpost/login-code",
231
- method: "POST",
232
- body: { code },
233
- query: outpostId ? { outpostId } : undefined,
234
- });
235
- }
236
-
237
- export async function destroyOutpost(
238
- token: string,
239
- outpostId?: string,
240
- ): Promise<Record<string, unknown>> {
241
- return outpostRequest({
242
- token,
243
- path: "/outpost/destroy",
244
- method: "POST",
245
- query: outpostId ? { outpostId } : undefined,
246
- });
247
- }
248
-
249
- /** Result of `POST /outpost/exec` — a terminal SSM invocation on the box. */
250
- export interface OutpostExecResult {
251
- ok: true;
252
- outpostId: string;
253
- instanceId: string;
254
- commandId: string;
255
- /** SSM invocation status (Success | Failed | Cancelled). */
256
- status: string;
257
- /** Remote process exit code, or null when SSM reported none. */
258
- exitCode: number | null;
259
- stdout: string;
260
- stderr: string;
261
- /** True when SSM clipped stdout/stderr at its inline output limit. */
262
- truncated: boolean;
263
- }
264
-
265
- /**
266
- * Run a one-shot shell command on the caller's Outpost via `POST /outpost/exec`
267
- * (server-brokered SSM — no SSH). Throws `OutpostHttpError` on a non-2xx, whose
268
- * `step` distinguishes not-ready / platform-unsupported / timeout / ssm.
269
- */
270
- export async function execOutpost(
271
- token: string,
272
- command: string,
273
- outpostId?: string,
274
- ): Promise<OutpostExecResult> {
275
- return outpostRequest({
276
- token,
277
- path: "/outpost/exec",
278
- method: "POST",
279
- body: { command },
280
- query: outpostId ? { outpostId } : undefined,
281
- });
282
- }
283
-
284
- /** Presigned input-upload details from `mode: "stage"`. */
285
- export interface OutpostExecStage {
286
- ok: true;
287
- userId: string;
288
- outpostId: string;
289
- key: string;
290
- putUrl: string;
291
- getUrl: string;
292
- expiresInSeconds: number;
293
- }
294
-
295
- /** Asynchronous SSM command details from `mode: "submit"`. */
296
- export interface OutpostExecSubmission {
297
- ok: true;
298
- userId: string;
299
- outpostId: string;
300
- instanceId: string;
301
- commandId: string;
302
- outputPrefix: string;
303
- /** Shell budget applied server-side (AWS-RunShellScript executionTimeout). */
304
- executionTimeoutSeconds?: number;
305
- }
306
-
307
- /** Poll response from `mode: "result"`; streams arrive only when terminal. */
308
- export interface OutpostExecAsyncResult {
309
- ok: true;
310
- userId: string;
311
- outpostId: string;
312
- status: string;
313
- done: boolean;
314
- exitCode?: number | null;
315
- stdout?: string;
316
- stderr?: string;
317
- truncated?: boolean;
318
- }
319
-
320
- export async function stageExecInput(
321
- token: string,
322
- outpostId?: string,
323
- ): Promise<OutpostExecStage> {
324
- return outpostRequest({
325
- token,
326
- path: "/outpost/exec",
327
- method: "POST",
328
- body: { mode: "stage" },
329
- query: outpostId ? { outpostId } : undefined,
330
- });
331
- }
332
-
333
- export async function submitExec(
334
- token: string,
335
- command: string,
336
- outpostId?: string,
337
- timeoutSeconds?: number,
338
- ): Promise<OutpostExecSubmission> {
339
- return outpostRequest({
340
- token,
341
- path: "/outpost/exec",
342
- method: "POST",
343
- body: {
344
- mode: "submit",
345
- command,
346
- ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
347
- },
348
- query: outpostId ? { outpostId } : undefined,
349
- });
350
- }
351
-
352
- export async function fetchExecResult(
353
- token: string,
354
- commandId: string,
355
- outpostId?: string,
356
- ): Promise<OutpostExecAsyncResult> {
357
- return outpostRequest({
358
- token,
359
- path: "/outpost/exec",
360
- method: "POST",
361
- body: { mode: "result", commandId },
362
- query: outpostId ? { outpostId } : undefined,
363
- });
364
- }
365
-
366
- function shellQuote(part: string): string {
367
- return `'${part.replace(/'/g, `'\\''`)}'`;
368
- }
369
-
370
- /** Preserve a single command string; safely join argv when Commander split it. */
371
- export function joinCommandParts(commandParts: string[]): string {
372
- if (commandParts.length === 1) return commandParts[0];
373
- return commandParts.map(shellQuote).join(" ");
374
- }
375
-
376
- const EXEC_RESULT_INITIAL_POLL_MS = 500;
377
- const EXEC_RESULT_MAX_POLL_MS = 5_000;
378
-
379
- function sleep(ms: number): Promise<void> {
380
- return new Promise((resolve) => setTimeout(resolve, ms));
381
- }
382
-
383
- async function waitForExecResult(
384
- token: string,
385
- commandId: string,
386
- outpostId?: string,
387
- ): Promise<OutpostExecAsyncResult> {
388
- let delayMs = EXEC_RESULT_INITIAL_POLL_MS;
389
- while (true) {
390
- try {
391
- const result = await fetchExecResult(token, commandId, outpostId);
392
- if (result.done) return result;
393
- } catch (err) {
394
- if (!(err instanceof OutpostHttpError) || err.status !== 429) throw err;
395
- }
396
- await sleep(delayMs);
397
- delayMs = Math.min(delayMs * 2, EXEC_RESULT_MAX_POLL_MS);
398
- }
399
- }
400
-
401
- /**
402
- * Prefix that best-effort `cd`s into the box's HQ checkout before running the
403
- * caller's command. `exec` runs over two transports with two different default
404
- * working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
405
- * SSH lands in the login user's home — so without this, `hq outposts exec -- pwd`
406
- * printed an unhelpful, transport-dependent directory. Initialize a real root
407
- * home for the SSM case before resolving the HQ folder: tools run by the caller
408
- * (notably `gh`) otherwise treat the HQ checkout as their home and can create
409
- * root-owned machine state inside it. The trailing `|| true` keeps the command
410
- * running from the default directory when no HQ checkout is present, so exec
411
- * never fails merely because the box has no HQ folder.
412
- */
413
- export const REMOTE_HQ_DIR_PREFIX =
414
- 'export HOME="${HOME:-/root}"; cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
415
-
416
- /** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
417
- export function withRemoteHqDir(command: string): string {
418
- return `${REMOTE_HQ_DIR_PREFIX}; ${command}`;
419
- }
420
-
421
- /** SSH connection details vended by `POST /outpost/ssh-access`. */
422
- export interface OutpostSshAccess {
423
- outpostId: string;
424
- platform: "ec2" | "lightsail";
425
- host: string;
426
- port: number;
427
- username: string;
428
- /** PEM private key for the box. SENSITIVE — never printed or logged. */
429
- privateKey: string;
430
- }
431
-
432
- /**
433
- * Fetch SSH connection info + key for the caller's Outpost and open the caller's
434
- * IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
435
- * `OutpostHttpError` on a non-2xx.
436
- */
437
- export async function getOutpostSshAccess(
438
- token: string,
439
- outpostId?: string,
440
- ): Promise<OutpostSshAccess> {
441
- return outpostRequest({
442
- token,
443
- path: "/outpost/ssh-access",
444
- method: "POST",
445
- body: {},
446
- query: outpostId ? { outpostId } : undefined,
447
- });
448
- }
449
-
450
- /**
451
- * Run `command` on the box over SSH using vended access details. Writes the
452
- * private key to a locked-down temp file, runs a non-interactive `ssh`, and
453
- * returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
454
- * are always cleaned up; the key is never printed. `exitCode` is `null` only
455
- * when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
456
- */
457
- export function execViaSsh(
458
- access: OutpostSshAccess,
459
- command: string,
460
- ): { stdout: string; stderr: string; exitCode: number | null; error?: string } {
461
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-outpost-ssh-"));
462
- const keyPath = path.join(dir, `id_${randomBytes(6).toString("hex")}`);
463
- const knownHosts = path.join(dir, "known_hosts");
464
- try {
465
- fs.writeFileSync(keyPath, ensureTrailingNewline(access.privateKey), {
466
- mode: 0o600,
467
- });
468
- const result = spawnSync(
469
- "ssh",
470
- [
471
- "-i",
472
- keyPath,
473
- "-p",
474
- String(access.port),
475
- "-o",
476
- "BatchMode=yes",
477
- "-o",
478
- "StrictHostKeyChecking=accept-new",
479
- "-o",
480
- `UserKnownHostsFile=${knownHosts}`,
481
- "-o",
482
- "ConnectTimeout=15",
483
- "-o",
484
- "LogLevel=ERROR",
485
- `${access.username}@${access.host}`,
486
- command,
487
- ],
488
- { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
489
- );
490
- if (result.error) {
491
- const hint =
492
- (result.error as NodeJS.ErrnoException).code === "ENOENT"
493
- ? "the `ssh` client is not installed or not on PATH"
494
- : result.error.message;
495
- return { stdout: "", stderr: "", exitCode: null, error: hint };
496
- }
497
- return {
498
- stdout: result.stdout ?? "",
499
- stderr: result.stderr ?? "",
500
- exitCode: result.status,
501
- };
502
- } finally {
503
- fs.rmSync(dir, { recursive: true, force: true });
504
- }
505
- }
506
-
507
- function ensureTrailingNewline(s: string): string {
508
- return s.endsWith("\n") ? s : s + "\n";
509
- }
510
-
511
- // ---------------------------------------------------------------------------
512
- // Command registration
513
- // ---------------------------------------------------------------------------
514
-
515
- /**
516
- * Explain a `409` per-person cap. hq-pro checks the cap BEFORE activation
517
- * billing, so nothing was charged — worth saying, since the caller just
518
- * confirmed a recurring charge to get here.
519
- */
520
- function surfaceOutpostCapped(capped: OutpostCappedPayload): void {
521
- const owned = capped.outposts.length;
522
- console.error(
523
- chalk.yellow(
524
- `You're already at your Outpost limit (${owned} of ${capped.limit}). ` +
525
- `No new box was provisioned and you have not been charged.`,
526
- ),
527
- );
528
- for (const o of capped.outposts) {
529
- const detail = [o.state, o.instanceName, o.region]
530
- .filter(Boolean)
531
- .join(" ");
532
- console.error(` ${o.outpostId} ${detail}`);
533
- }
534
- console.error(chalk.dim("Inspect it: hq outposts status"));
535
- console.error(
536
- chalk.dim("Or tear it down first: hq outposts destroy --id <id> --yes"),
537
- );
538
- }
539
-
540
- function fail(err: unknown): never {
541
- if (err instanceof OutpostHttpError) {
542
- console.error(chalk.red(err.message));
543
- } else {
544
- console.error(
545
- chalk.red("Error:"),
546
- err instanceof Error ? err.message : String(err),
547
- );
548
- }
549
- process.exit(1);
550
- }
551
-
552
- /** Print a top-level object as `key: value`, JSON-ifying nested values. */
553
- function printKeyValues(obj: Record<string, unknown>): void {
554
- for (const [k, v] of Object.entries(obj)) {
555
- const rendered = v && typeof v === "object" ? JSON.stringify(v) : String(v);
556
- console.log(`${chalk.bold(k)}: ${rendered}`);
557
- }
558
- }
559
-
560
- // ---------------------------------------------------------------------------
561
- // Hidden local self-deploy command
562
- // ---------------------------------------------------------------------------
563
-
564
- /** The small local-environment surface used by `outposts self-deploy`. */
565
- export interface SelfDeployDependencies {
566
- spawnSync: (
567
- command: string,
568
- args: string[],
569
- options?: Parameters<typeof spawnSync>[2],
570
- ) => ReturnType<typeof spawnSync>;
571
- readTextFile: (file: string) => string;
572
- loadCachedTokens: () =>
573
- { refreshToken?: string; idToken?: string } | undefined;
574
- getUid: () => number | undefined;
575
- isStdinTty: () => boolean;
576
- confirm: () => Promise<boolean>;
577
- defaultHqRoot: () => string;
578
- invokingUser: () => string;
579
- }
580
-
581
- const defaultSelfDeployDependencies: SelfDeployDependencies = {
582
- spawnSync: (command, args, options) => spawnSync(command, args, options),
583
- readTextFile: (file) => fs.readFileSync(file, "utf8"),
584
- loadCachedTokens: () => loadCachedTokens() ?? undefined,
585
- getUid: () => process.getuid?.(),
586
- isStdinTty: () => process.stdin.isTTY === true,
587
- confirm: async () => {
588
- const rl = readline.createInterface({
589
- input: process.stdin,
590
- output: process.stdout,
591
- });
592
- return new Promise((resolve) => {
593
- rl.question("", (answer) => {
594
- rl.close();
595
- resolve(/^y(es)?$/i.test(answer.trim()));
596
- });
597
- });
598
- },
599
- defaultHqRoot: () => process.env.HQ_ROOT ?? path.join(os.homedir(), "hq"),
600
- invokingUser: () =>
601
- process.env.SUDO_USER ?? process.env.USER ?? os.userInfo().username,
602
- };
603
-
604
- function selfDeployError(message: string): Error {
605
- return new Error(`Self-deploy preflight failed: ${message}`);
606
- }
607
-
608
- function commandSucceeded(
609
- deps: SelfDeployDependencies,
610
- command: string,
611
- args: string[],
612
- ): boolean {
613
- try {
614
- const result = deps.spawnSync(command, args, { encoding: "utf8" });
615
- return !!result && !result.error && result.status === 0;
616
- } catch {
617
- return false;
618
- }
619
- }
620
-
621
- function commandOutput(
622
- deps: SelfDeployDependencies,
623
- command: string,
624
- args: string[],
625
- ): string | undefined {
626
- try {
627
- const result = deps.spawnSync(command, args, { encoding: "utf8" });
628
- if (!result || result.error || result.status !== 0) return undefined;
629
- return String(result.stdout ?? "").trim();
630
- } catch {
631
- return undefined;
632
- }
633
- }
634
-
635
- function parseOsRelease(source: string): Map<string, string> {
636
- const values = new Map<string, string>();
637
- for (const line of source.split("\n")) {
638
- const match = /^([A-Z_]+)=(.*)$/.exec(line);
639
- if (!match) continue;
640
- const [, key, rawValue] = match;
641
- values.set(key, rawValue.replace(/^['"]|['"]$/g, ""));
642
- }
643
- return values;
644
- }
645
-
646
- function hqIdentityFromSession(session: {
647
- refreshToken?: string;
648
- idToken?: string;
649
- }): string {
650
- if (!session.idToken) return "your cached HQ session";
651
- try {
652
- const payload = session.idToken.split(".")[1];
653
- if (!payload) return "your cached HQ session";
654
- const claims = JSON.parse(
655
- Buffer.from(payload, "base64url").toString("utf8"),
656
- ) as Record<string, unknown>;
657
- for (const key of [
658
- "email",
659
- "preferred_username",
660
- "cognito:username",
661
- "username",
662
- "sub",
663
- ]) {
664
- const value = claims[key];
665
- if (typeof value === "string" && value) return value;
666
- }
667
- } catch {
668
- // A cache that has a refresh token remains valid for this local setup. The
669
- // identity banner is informational, so never expose a token parse failure.
670
- }
671
- return "your cached HQ session";
672
- }
673
-
674
- function bashSingleQuote(value: string): string {
675
- return `'${value.replace(/'/g, "'\\''")}'`;
676
- }
677
-
678
- function systemdQuoted(value: string): string {
679
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
680
- }
681
-
682
- function renderSelfDeploySyncScript(hqRoot: string): string {
683
- return `#!/usr/bin/env bash
684
- set -u
685
- # Persistent all-membership sync for a locally self-hosted HQ outpost. The
686
- # watch runner event-pushes local changes and polls remote changes every minute.
687
- HQ_ROOT=${bashSingleQuote(hqRoot)}
688
- cd "$HQ_ROOT"
689
- while true; do
690
- hq auth refresh || echo "[outpost-sync] auth refresh failed, continuing"
691
- npx -y --package=@indigoai-us/hq-cloud@latest hq-sync-runner \\
692
- --companies \\
693
- --direction both \\
694
- --on-conflict keep \\
695
- --hq-root "$HQ_ROOT" \\
696
- --watch \\
697
- --event-push \\
698
- --poll-remote-ms 60000
699
- echo "[outpost-sync] watch runner exited; restarting in 10 seconds"
700
- sleep 10
701
- done
702
- `;
703
- }
704
-
705
- function renderSelfDeployService(hqRoot: string, user: string): string {
706
- return `[Unit]
707
- After=network-online.target
708
- Wants=network-online.target
709
-
710
- [Service]
711
- User=${user}
712
- WorkingDirectory=${systemdQuoted(hqRoot)}
713
- ExecStart=/usr/local/bin/outpost-sync.sh
714
- Restart=always
715
- RestartSec=10s
716
-
717
- [Install]
718
- WantedBy=multi-user.target
719
- `;
720
- }
721
-
722
- function runChecked(
723
- deps: SelfDeployDependencies,
724
- command: string,
725
- args: string[],
726
- description: string,
727
- options?: Parameters<typeof spawnSync>[2],
728
- ): void {
729
- let result: ReturnType<typeof spawnSync>;
730
- try {
731
- result = deps.spawnSync(command, args, options);
732
- } catch {
733
- throw new Error(`${description} could not be started.`);
734
- }
735
- if (!result || result.error || result.status !== 0) {
736
- // Do not include child process output here: even though this command never
737
- // passes credentials to children, it keeps the error path secret-safe.
738
- throw new Error(`${description} failed. Resolve the error and try again.`);
739
- }
740
- }
741
-
742
- function runPrivileged(
743
- deps: SelfDeployDependencies,
744
- command: string,
745
- args: string[],
746
- description: string,
747
- options?: Parameters<typeof spawnSync>[2],
748
- ): void {
749
- if (deps.getUid() === 0) {
750
- runChecked(deps, command, args, description, options);
751
- return;
752
- }
753
- runChecked(deps, "sudo", [command, ...args], description, options);
754
- }
755
-
756
- function selfDeployPreflight(deps: SelfDeployDependencies): {
757
- refreshToken?: string;
758
- idToken?: string;
759
- } {
760
- let osRelease: string;
761
- try {
762
- osRelease = deps.readTextFile("/etc/os-release");
763
- } catch {
764
- throw selfDeployError(
765
- "could not read /etc/os-release. This command only supports Amazon Linux 2023 x86_64.",
766
- );
767
- }
768
- const release = parseOsRelease(osRelease);
769
- if (
770
- release.get("ID") !== "amzn" ||
771
- !release.get("VERSION_ID")?.startsWith("2023")
772
- ) {
773
- throw selfDeployError(
774
- "this command only supports Amazon Linux 2023 x86_64. Use an Amazon Linux 2023 EC2 instance.",
775
- );
776
- }
777
-
778
- const architecture = commandOutput(deps, "uname", ["-m"]);
779
- if (architecture !== "x86_64") {
780
- throw selfDeployError(
781
- "this command only supports x86_64 EC2 instances. Use an Amazon Linux 2023 x86_64 instance.",
782
- );
783
- }
784
-
785
- if (!commandSucceeded(deps, "systemctl", ["--version"])) {
786
- throw selfDeployError(
787
- "systemd is required but `systemctl --version` failed. Run this on an Amazon Linux 2023 EC2 host with systemd.",
788
- );
789
- }
790
-
791
- if (deps.getUid() !== 0 && !commandSucceeded(deps, "sudo", ["-n", "true"])) {
792
- throw selfDeployError(
793
- "root or passwordless sudo is required. Configure passwordless sudo or run this command as root.",
794
- );
795
- }
796
-
797
- const session = deps.loadCachedTokens();
798
- if (!session?.refreshToken) {
799
- throw selfDeployError(
800
- "no HQ login session was found. Run `hq login`, then re-run this command.",
801
- );
802
- }
803
- return session;
804
- }
805
-
806
- async function selfDeployOutpost(
807
- opts: { yes?: boolean; hqRoot?: string },
808
- deps: SelfDeployDependencies,
809
- ): Promise<void> {
810
- const session = selfDeployPreflight(deps);
811
- const hqRoot = opts.hqRoot ?? deps.defaultHqRoot();
812
- const user = deps.invokingUser();
813
-
814
- if (!opts.yes) {
815
- console.log(`HQ identity: ${hqIdentityFromSession(session)}`);
816
- console.log(
817
- chalk.yellow(
818
- "This machine will run as a SELF-HOSTED HQ outpost under YOUR identity, continuously syncing ALL your company vaults. " +
819
- "It is NOT registered with or managed by hq-pro (no console entry, no remote management, no metering). " +
820
- "Anyone with root on this box can act as you. Continue? [y/N]",
821
- ),
822
- );
823
- if (!deps.isStdinTty()) {
824
- throw new Error(
825
- "Confirmation requires a TTY. Pass --yes to continue non-interactively.",
826
- );
827
- }
828
- if (!(await deps.confirm())) {
829
- throw new Error("Self-deploy cancelled.");
830
- }
831
- }
832
-
833
- runChecked(
834
- deps,
835
- "hq",
836
- ["rescue", "--hq-root", hqRoot, "--yes"],
837
- "HQ kernel rescue",
838
- { stdio: "inherit" },
839
- );
840
-
841
- runPrivileged(
842
- deps,
843
- "tee",
844
- ["/usr/local/bin/outpost-sync.sh"],
845
- "Writing /usr/local/bin/outpost-sync.sh",
846
- {
847
- encoding: "utf8",
848
- input: renderSelfDeploySyncScript(hqRoot),
849
- stdio: ["pipe", "ignore", "pipe"],
850
- },
851
- );
852
- runPrivileged(
853
- deps,
854
- "chmod",
855
- ["+x", "/usr/local/bin/outpost-sync.sh"],
856
- "Making /usr/local/bin/outpost-sync.sh executable",
857
- );
858
- runPrivileged(
859
- deps,
860
- "tee",
861
- ["/etc/systemd/system/outpost-sync.service"],
862
- "Writing /etc/systemd/system/outpost-sync.service",
863
- {
864
- encoding: "utf8",
865
- input: renderSelfDeployService(hqRoot, user),
866
- stdio: ["pipe", "ignore", "pipe"],
867
- },
868
- );
869
- runPrivileged(deps, "systemctl", ["daemon-reload"], "Reloading systemd");
870
- runPrivileged(
871
- deps,
872
- "systemctl",
873
- ["enable", "--now", "outpost-sync.service"],
874
- "Enabling outpost-sync.service",
875
- );
876
-
877
- console.log(
878
- chalk.green(
879
- "This box is now a self-hosted HQ outpost and will sync all your company vaults continuously.",
880
- ),
881
- );
882
- console.log(
883
- chalk.dim("Check it with: systemctl status outpost-sync.service"),
884
- );
885
- console.log(
886
- chalk.dim(
887
- "It is unregistered and unmanaged by hq-pro (no console entry or remote management).",
888
- ),
889
- );
890
- }
891
-
892
- // ---------------------------------------------------------------------------
893
- // Hidden replica-sync command (box-side full-replica timer target)
894
- // ---------------------------------------------------------------------------
895
- //
896
- // A MANAGED outpost (provisioned via `hq outposts provision`) runs this on a
897
- // systemd timer to make the box a full replica of the operator's HQ:
898
- // 1. hq auth refresh — freshen the seeded Cognito session
899
- // 2. hq sync pull --personal — the personal vault (repos.yaml, policies, …)
900
- // 3. hq rescue — re-lay the core kernel (needs companies/)
901
- // 4. clone repos.yaml — the operator's OWN repos, gh-authed via vault
902
- //
903
- // Unlike `self-deploy` (which is a manual, unmanaged, all-company watcher on a
904
- // box you own), replica-sync is GENERIC: it works for every HQ user off their
905
- // own personal vault — no per-operator setup script. It is deliberately
906
- // best-effort end to end: a repeating timer must never leave the systemd unit
907
- // in `failed` just because one transient step (a mirror blip, a not-yet-signed
908
- // box) didn't complete this cycle.
909
-
910
- /**
911
- * The extra local surface `replica-sync` needs on top of the self-deploy set:
912
- * two filesystem probes for the repos clone. Kept as an extension so the
913
- * `self-deploy` dependency surface stays minimal.
914
- */
915
- export interface ReplicaSyncDependencies extends SelfDeployDependencies {
916
- pathExists: (p: string) => boolean;
917
- mkdirp: (p: string) => void;
918
- }
919
-
920
- const defaultReplicaSyncDependencies: ReplicaSyncDependencies = {
921
- ...defaultSelfDeployDependencies,
922
- pathExists: (p) => fs.existsSync(p),
923
- mkdirp: (p) => {
924
- fs.mkdirSync(p, { recursive: true });
925
- },
926
- };
927
-
928
- /** One repo entry resolved from `personal/data/repos.yaml`. */
929
- export interface ReplicaRepo {
930
- url: string;
931
- visibility: "public" | "private";
932
- name: string;
933
- }
934
-
935
- function repoNameFromUrl(url: string): string {
936
- const tail = url.split("/").pop() ?? "";
937
- return tail.replace(/\.git$/, "").trim();
938
- }
939
-
940
- /**
941
- * A repo `name` becomes a path segment under repos/<visibility>/. A synced
942
- * repos.yaml is operator data, but a malformed or hostile name must never
943
- * escape that directory, so constrain it to a single, non-traversal component.
944
- */
945
- function isSafePathComponent(value: string): boolean {
946
- return (
947
- value.length > 0 &&
948
- !value.includes("/") &&
949
- !value.includes("\\") &&
950
- value !== "." &&
951
- value !== ".."
952
- );
953
- }
954
-
955
- /**
956
- * Parse `personal/data/repos.yaml` into a clone list. Pure + exported for
957
- * tests. Mirrors setup.sh's `clone_repos`: `visibility` defaults to public,
958
- * `name` defaults to the URL basename. Malformed entries are skipped, never
959
- * thrown.
960
- */
961
- export function parseReposManifest(yamlText: string): ReplicaRepo[] {
962
- let doc: unknown;
963
- try {
964
- doc = yaml.load(yamlText);
965
- } catch {
966
- return [];
967
- }
968
- const rawRepos = (doc as { repos?: unknown } | null)?.repos;
969
- if (!Array.isArray(rawRepos)) return [];
970
-
971
- const out: ReplicaRepo[] = [];
972
- for (const entry of rawRepos) {
973
- if (!entry || typeof entry !== "object") continue;
974
- const e = entry as Record<string, unknown>;
975
- const url = typeof e.url === "string" ? e.url.trim() : "";
976
- if (!url) continue;
977
- const visibility = e.visibility === "private" ? "private" : "public";
978
- const name =
979
- typeof e.name === "string" && e.name.trim()
980
- ? e.name.trim()
981
- : repoNameFromUrl(url);
982
- if (!isSafePathComponent(name)) continue;
983
- out.push({ url, visibility, name });
984
- }
985
- return out;
986
- }
987
-
988
- /**
989
- * Best-effort spawn: log a one-line outcome and carry on. NEVER throws and
990
- * NEVER echoes child output — the same secret-safety stance as `runChecked`,
991
- * since these children (auth refresh, secrets exec) touch credentials.
992
- */
993
- function runBestEffort(
994
- deps: ReplicaSyncDependencies,
995
- command: string,
996
- args: string[],
997
- description: string,
998
- options?: Parameters<typeof spawnSync>[2],
999
- ): boolean {
1000
- let result: ReturnType<typeof spawnSync>;
1001
- try {
1002
- result = deps.spawnSync(command, args, options ?? { stdio: "inherit" });
1003
- } catch {
1004
- console.warn(`replica-sync: ${description} could not run — continuing.`);
1005
- return false;
1006
- }
1007
- if (!result || result.error || result.status !== 0) {
1008
- console.warn(
1009
- `replica-sync: ${description} did not complete cleanly — continuing.`,
1010
- );
1011
- return false;
1012
- }
1013
- return true;
1014
- }
1015
-
1016
- /**
1017
- * Authenticate `gh` for private clones by streaming GITHUB_TOKEN through the
1018
- * vault into the child's ENV ONLY, unsetting it in-shell, and piping it to
1019
- * `gh auth login --with-token` over stdin. The token never touches argv or
1020
- * this process's logs. Mirrors personal/scripts/setup.sh's `configure_gh`.
1021
- */
1022
- function authGitHubViaVault(deps: ReplicaSyncDependencies): void {
1023
- const ghLogin = [
1024
- 'TOKEN="$GITHUB_TOKEN"',
1025
- "unset GITHUB_TOKEN",
1026
- 'printf "%s" "$TOKEN" | gh auth login --with-token',
1027
- ].join("\n");
1028
- const authed = runBestEffort(
1029
- deps,
1030
- "hq",
1031
- [
1032
- "secrets",
1033
- "--personal",
1034
- "exec",
1035
- "--only",
1036
- "GITHUB_TOKEN",
1037
- "--",
1038
- "bash",
1039
- "-c",
1040
- ghLogin,
1041
- ],
1042
- "GitHub auth via vault",
1043
- );
1044
- if (authed) {
1045
- // Let plain `git clone https://github.com/...` reuse gh's token so private
1046
- // repos don't prompt for a username/password.
1047
- runBestEffort(deps, "gh", ["auth", "setup-git"], "gh auth setup-git");
1048
- }
1049
- }
1050
-
1051
- /** Clone any repos from `personal/data/repos.yaml` that aren't on disk yet. */
1052
- function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
1053
- const manifestPath = path.join(hqRoot, "personal", "data", "repos.yaml");
1054
- let manifestText: string;
1055
- try {
1056
- manifestText = deps.readTextFile(manifestPath);
1057
- } catch {
1058
- console.log(
1059
- "replica-sync: no personal/data/repos.yaml — skipping repo clone.",
1060
- );
1061
- return;
1062
- }
1063
-
1064
- const repos = parseReposManifest(manifestText);
1065
- if (repos.length === 0) {
1066
- console.log("replica-sync: repos.yaml lists no repos — skipping.");
1067
- return;
1068
- }
1069
-
1070
- const missing = repos.filter(
1071
- (repo) =>
1072
- !deps.pathExists(
1073
- path.join(hqRoot, "repos", repo.visibility, repo.name, ".git"),
1074
- ),
1075
- );
1076
- if (missing.length === 0) {
1077
- console.log("replica-sync: all repos already present.");
1078
- return;
1079
- }
1080
-
1081
- // Private repos need gh auth first; public-only manifests skip the vault call.
1082
- if (missing.some((repo) => repo.visibility === "private")) {
1083
- authGitHubViaVault(deps);
1084
- }
1085
-
1086
- for (const repo of missing) {
1087
- deps.mkdirp(path.join(hqRoot, "repos", repo.visibility));
1088
- const dest = path.join(hqRoot, "repos", repo.visibility, repo.name);
1089
- console.log(`replica-sync: cloning ${repo.visibility}/${repo.name}…`);
1090
- runBestEffort(
1091
- deps,
1092
- "git",
1093
- ["clone", "--recurse-submodules", repo.url, dest],
1094
- `clone ${repo.visibility}/${repo.name}`,
1095
- );
1096
- }
1097
- }
1098
-
1099
- async function replicaSyncOutpost(
1100
- opts: { hqRoot?: string },
1101
- deps: ReplicaSyncDependencies,
1102
- ): Promise<void> {
1103
- const hqRoot = opts.hqRoot ?? deps.defaultHqRoot();
1104
-
1105
- // Gate: only replicate on a box that has an HQ session. A box that was never
1106
- // signed in has nothing to pull — exit cleanly so the timer stays green.
1107
- const session = deps.loadCachedTokens();
1108
- if (!session?.refreshToken) {
1109
- console.log(
1110
- "replica-sync: no HQ session on this box yet — nothing to replicate.",
1111
- );
1112
- return;
1113
- }
1114
-
1115
- // Auth refresh gates the run: if the seeded refresh token is dead, every
1116
- // authenticated step below (pull, secrets exec) would just cascade 401s.
1117
- // Stop the cycle cleanly instead — the timer retries next tick.
1118
- if (!runBestEffort(deps, "hq", ["auth", "refresh"], "HQ auth refresh")) {
1119
- console.warn(
1120
- "replica-sync: auth refresh failed — skipping this cycle (token likely expired).",
1121
- );
1122
- return;
1123
- }
1124
- runBestEffort(
1125
- deps,
1126
- "hq",
1127
- [
1128
- "sync",
1129
- "pull",
1130
- "--personal",
1131
- "--hq-root",
1132
- hqRoot,
1133
- "--on-conflict",
1134
- "keep",
1135
- ],
1136
- "personal vault pull",
1137
- );
1138
-
1139
- // rescue lays down the core kernel and expects companies/ to exist.
1140
- deps.mkdirp(path.join(hqRoot, "companies"));
1141
- runBestEffort(
1142
- deps,
1143
- "hq",
1144
- ["rescue", "--hq-root", hqRoot, "--yes"],
1145
- "HQ kernel rescue",
1146
- );
1147
-
1148
- replicateRepos(deps, hqRoot);
1149
- console.log("replica-sync: complete.");
1150
- }
1151
-
1152
- export function registerOutpostsCommand(
1153
- program: Command,
1154
- selfDeployOverrides: Partial<ReplicaSyncDependencies> = {},
1155
- ): void {
1156
- const deps: ReplicaSyncDependencies = {
1157
- ...defaultReplicaSyncDependencies,
1158
- ...selfDeployOverrides,
1159
- };
1160
- const outposts = program
1161
- .command("outposts")
1162
- .description("Manage your personal HQ Outposts (EC2 boxes)");
1163
-
1164
- // On-box session heartbeat (systemd). Hidden — operators never run it.
1165
- registerHeartbeatCommand(outposts);
1166
-
1167
- outposts
1168
- .command("self-deploy", { hidden: true })
1169
- .description("Configure this EC2 host as a locally self-hosted HQ outpost")
1170
- .option("--yes", "Skip the self-hosting confirmation")
1171
- .option("--hq-root <path>", "HQ root to sync", deps.defaultHqRoot())
1172
- .action(async (opts: { yes?: boolean; hqRoot?: string }) => {
1173
- try {
1174
- await selfDeployOutpost(opts, deps);
1175
- } catch (err) {
1176
- fail(err);
1177
- }
1178
- });
1179
-
1180
- outposts
1181
- .command("replica-sync", { hidden: true })
1182
- .description(
1183
- "Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.",
1184
- )
1185
- .option(
1186
- "--hq-root <path>",
1187
- "HQ root to replicate into",
1188
- deps.defaultHqRoot(),
1189
- )
1190
- .action(async (opts: { hqRoot?: string }) => {
1191
- // Best-effort background job: never hard-fail the systemd unit on a
1192
- // transient step. Swallow, log, and exit 0.
1193
- try {
1194
- await replicaSyncOutpost(opts, deps);
1195
- } catch (err) {
1196
- console.warn(
1197
- `replica-sync: unexpected error — ${err instanceof Error ? err.message : String(err)}`,
1198
- );
1199
- }
1200
- });
1201
-
1202
- outposts
1203
- .command("provision")
1204
- .alias("create")
1205
- .description("Provision a new Outpost ($80/month — requires --yes)")
1206
- .option(
1207
- "--runtime <runtime>",
1208
- "Agent runtime: claude | codex (default claude)",
1209
- )
1210
- .option("--disk <gb>", "Root disk size in GB (EC2 only)")
1211
- .option(
1212
- "--client-ip <ip>",
1213
- "Your public IP for the box's SSH ingress (optional; server derives it otherwise)",
1214
- )
1215
- .option("--yes", "Confirm the $80/month charge (required to provision)")
1216
- .action(async function (
1217
- this: Command,
1218
- opts: {
1219
- runtime?: string;
1220
- disk?: string;
1221
- clientIp?: string;
1222
- yes?: boolean;
1223
- },
1224
- ) {
1225
- try {
1226
- // Reject an unrecognized runtime rather than resolving it to claude:
1227
- // `--runtime codx` would otherwise hand back a silently Claude box.
1228
- if (
1229
- opts.runtime !== undefined &&
1230
- opts.runtime !== "claude" &&
1231
- opts.runtime !== "codex"
1232
- ) {
1233
- console.error(
1234
- chalk.red(
1235
- `Invalid --runtime '${opts.runtime}': must be 'claude' or 'codex'.`,
1236
- ),
1237
- );
1238
- process.exit(1);
1239
- }
1240
- const agentRuntime =
1241
- opts.runtime === "codex"
1242
- ? "codex"
1243
- : opts.runtime === "claude"
1244
- ? "claude"
1245
- : undefined;
1246
- let diskSizeGb: number | undefined;
1247
- if (opts.disk !== undefined) {
1248
- diskSizeGb = Number(opts.disk);
1249
- if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
1250
- console.error(
1251
- chalk.red(
1252
- `Invalid --disk '${opts.disk}': must be a positive number of GB.`,
1253
- ),
1254
- );
1255
- process.exit(1);
1256
- }
1257
- }
1258
-
1259
- // Paid gate: print the monthly cost and require --yes before any call.
1260
- confirmChargeOrExit({
1261
- resource: "Outpost",
1262
- unitCents: OUTPOST_PRICE_CENTS,
1263
- yes: opts.yes,
1264
- });
1265
-
1266
- // The box authenticates AS the caller using the cached refresh token —
1267
- // the same body the console sends. Never printed.
1268
- const refreshToken = loadCachedTokens()?.refreshToken;
1269
- if (!refreshToken) {
1270
- console.error(
1271
- chalk.red(
1272
- "No cached session found — run `hq login` first, then re-run.",
1273
- ),
1274
- );
1275
- process.exit(1);
1276
- }
1277
-
1278
- const token = await ensureCognitoToken();
1279
- try {
1280
- await provisionOutpost(token, {
1281
- refreshToken,
1282
- ...(opts.clientIp ? { clientIp: opts.clientIp } : {}),
1283
- ...(diskSizeGb ? { diskSizeGb } : {}),
1284
- ...(agentRuntime ? { agentRuntime } : {}),
1285
- });
1286
- console.log(chalk.green("Provisioning started for your Outpost."));
1287
- console.log(chalk.dim("Track it: hq outposts status"));
1288
- } catch (err) {
1289
- // No card on file → surface the shareable payment link, not an opaque 402.
1290
- if (
1291
- err instanceof OutpostHttpError &&
1292
- err.status === 402 &&
1293
- err.billing
1294
- ) {
1295
- await surfaceBillingBlocked(token, err.billing, err.message);
1296
- process.exit(1);
1297
- }
1298
- // Already at the per-person cap → say so and name the box they own,
1299
- // not a bare "Conflict".
1300
- if (
1301
- err instanceof OutpostHttpError &&
1302
- err.status === 409 &&
1303
- err.capped
1304
- ) {
1305
- surfaceOutpostCapped(err.capped);
1306
- process.exit(1);
1307
- }
1308
- throw err;
1309
- }
1310
- } catch (err) {
1311
- fail(err);
1312
- }
1313
- });
1314
-
1315
- outposts
1316
- .command("list")
1317
- .description("List every Outpost you own")
1318
- .option("--json", "Emit raw JSON")
1319
- .action(async function (this: Command, opts: { json?: boolean }) {
1320
- try {
1321
- const token = await ensureCognitoToken();
1322
- const rows = await listOutposts(token);
1323
- if (opts.json) {
1324
- process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
1325
- return;
1326
- }
1327
- if (rows.length === 0) {
1328
- console.log(chalk.gray("You don't own any Outposts yet."));
1329
- return;
1330
- }
1331
- const idW = Math.max(2, ...rows.map((r) => (r.outpostId ?? "").length));
1332
- const stateW = Math.max(5, ...rows.map((r) => (r.state ?? "").length));
1333
- const nameW = Math.max(
1334
- 4,
1335
- ...rows.map((r) => (r.instanceName ?? "").length),
1336
- );
1337
- const regionW = Math.max(
1338
- 6,
1339
- ...rows.map((r) => (r.region ?? "").length),
1340
- );
1341
- const rtW = Math.max(
1342
- 7,
1343
- ...rows.map((r) => (r.agentRuntime ?? "").length),
1344
- );
1345
- console.log(
1346
- chalk.bold(
1347
- [
1348
- "ID".padEnd(idW),
1349
- "STATE".padEnd(stateW),
1350
- "INSTANCE".padEnd(nameW),
1351
- "REGION".padEnd(regionW),
1352
- "RUNTIME".padEnd(rtW),
1353
- "PLATFORM",
1354
- ].join(" "),
1355
- ),
1356
- );
1357
- for (const r of rows) {
1358
- console.log(
1359
- [
1360
- (r.outpostId ?? "").padEnd(idW),
1361
- (r.state ?? "").padEnd(stateW),
1362
- (r.instanceName ?? "").padEnd(nameW),
1363
- (r.region ?? "").padEnd(regionW),
1364
- (r.agentRuntime ?? "").padEnd(rtW),
1365
- r.platform ?? "",
1366
- ].join(" "),
1367
- );
1368
- }
1369
- } catch (err) {
1370
- fail(err);
1371
- }
1372
- });
1373
-
1374
- outposts
1375
- .command("status")
1376
- .description("Show live detail for one Outpost")
1377
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1378
- .option("--json", "Emit raw JSON")
1379
- .action(async function (
1380
- this: Command,
1381
- opts: { id?: string; json?: boolean },
1382
- ) {
1383
- try {
1384
- const token = await ensureCognitoToken();
1385
- const status = await getOutpostStatus(token, opts.id);
1386
- if (opts.json) {
1387
- process.stdout.write(JSON.stringify(status, null, 2) + "\n");
1388
- return;
1389
- }
1390
- printKeyValues(status);
1391
- } catch (err) {
1392
- fail(err);
1393
- }
1394
- });
1395
-
1396
- outposts
1397
- .command("exec <command...>")
1398
- .description(
1399
- "Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
1400
- "Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.",
1401
- )
1402
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1403
- .option(
1404
- "--async",
1405
- "Submit via the async transport and wait for completion (bypasses the ~20s sync cap; shell budget defaults to 48h)",
1406
- )
1407
- .option(
1408
- "--detach",
1409
- "Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)",
1410
- )
1411
- .option(
1412
- "--timeout-seconds <n>",
1413
- "Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.",
1414
- (v: string) => {
1415
- const n = Number(v);
1416
- if (!Number.isInteger(n)) {
1417
- throw new Error("--timeout-seconds must be an integer");
1418
- }
1419
- return n;
1420
- },
1421
- )
1422
- .option("--json", "Emit raw JSON")
1423
- .action(async function (
1424
- this: Command,
1425
- commandParts: string[],
1426
- opts: {
1427
- id?: string;
1428
- json?: boolean;
1429
- async?: boolean;
1430
- detach?: boolean;
1431
- timeoutSeconds?: number;
1432
- },
1433
- ) {
1434
- try {
1435
- const command = joinCommandParts(commandParts);
1436
- if (!command.trim()) {
1437
- console.error(
1438
- chalk.red("No command given. Usage: hq outposts exec -- <command>"),
1439
- );
1440
- process.exit(1);
1441
- }
1442
- if (opts.async && opts.detach) {
1443
- console.error(
1444
- chalk.red("Use either --async (submit + wait) or --detach (submit only), not both."),
1445
- );
1446
- process.exit(1);
1447
- }
1448
- // --timeout-seconds only applies to the async path; bare use implies --async.
1449
- const useAsync =
1450
- Boolean(opts.async) ||
1451
- Boolean(opts.detach) ||
1452
- opts.timeoutSeconds !== undefined;
1453
- if (opts.timeoutSeconds !== undefined) {
1454
- if (
1455
- !Number.isInteger(opts.timeoutSeconds) ||
1456
- opts.timeoutSeconds < 1 ||
1457
- opts.timeoutSeconds > 172_800
1458
- ) {
1459
- console.error(
1460
- chalk.red(
1461
- "--timeout-seconds must be an integer between 1 and 172800 (48h, the AWS-RunShellScript max)",
1462
- ),
1463
- );
1464
- process.exit(1);
1465
- }
1466
- }
1467
- const token = await ensureCognitoToken();
1468
-
1469
- // Run from the box's HQ folder by default (works over both SSM and SSH).
1470
- const remoteCommand = withRemoteHqDir(command);
1471
-
1472
- if (useAsync) {
1473
- try {
1474
- const submitted = await submitExec(
1475
- token,
1476
- remoteCommand,
1477
- opts.id,
1478
- opts.timeoutSeconds,
1479
- );
1480
- if (opts.detach) {
1481
- const output = {
1482
- commandId: submitted.commandId,
1483
- ...(submitted.executionTimeoutSeconds !== undefined
1484
- ? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
1485
- : opts.timeoutSeconds !== undefined
1486
- ? { executionTimeoutSeconds: opts.timeoutSeconds }
1487
- : {}),
1488
- };
1489
- if (opts.json) {
1490
- process.stdout.write(JSON.stringify(output) + "\n");
1491
- } else {
1492
- printKeyValues(output);
1493
- console.error(
1494
- chalk.dim(
1495
- "Submitted. Poll with: hq outposts exec-result --command-id " +
1496
- submitted.commandId +
1497
- (opts.id ? ` --id ${opts.id}` : "") +
1498
- " --wait",
1499
- ),
1500
- );
1501
- }
1502
- return;
1503
- }
1504
-
1505
- // --async (or --timeout-seconds without --detach): wait for terminal.
1506
- if (!opts.json) {
1507
- console.error(
1508
- chalk.dim(
1509
- `Submitted ${submitted.commandId}; waiting for completion…`,
1510
- ),
1511
- );
1512
- }
1513
- const result = await waitForExecResult(
1514
- token,
1515
- submitted.commandId,
1516
- opts.id,
1517
- );
1518
- if (opts.json) {
1519
- process.stdout.write(
1520
- JSON.stringify(
1521
- {
1522
- commandId: submitted.commandId,
1523
- done: result.done,
1524
- status: result.status,
1525
- exitCode: result.exitCode ?? null,
1526
- stdout: result.stdout ?? "",
1527
- stderr: result.stderr ?? "",
1528
- truncated: result.truncated ?? false,
1529
- },
1530
- null,
1531
- 2,
1532
- ) + "\n",
1533
- );
1534
- } else {
1535
- if (result.stdout) process.stdout.write(result.stdout);
1536
- if (result.stderr) process.stderr.write(result.stderr);
1537
- if (result.truncated) {
1538
- console.error(
1539
- chalk.yellow(
1540
- "(output truncated — redirect to a file on the box for full output)",
1541
- ),
1542
- );
1543
- }
1544
- if (result.status !== "Success" && result.exitCode === null) {
1545
- console.error(
1546
- chalk.yellow(`(command ended with SSM status: ${result.status})`),
1547
- );
1548
- }
1549
- }
1550
- process.exitCode =
1551
- typeof result.exitCode === "number" ? result.exitCode : 0;
1552
- return;
1553
- } catch (err) {
1554
- // Async requires EC2/SSM. Lightsail has no async channel — refuse
1555
- // rather than silently falling back to a live SSH hold, which is
1556
- // the exact timeout failure mode --async is meant to escape.
1557
- if (
1558
- err instanceof OutpostHttpError &&
1559
- err.step === "platform-unsupported"
1560
- ) {
1561
- console.error(
1562
- chalk.red(
1563
- "Async exec requires an EC2 Outpost (SSM). This box is Lightsail — " +
1564
- "re-provision on EC2, or run a short sync command / SSH session instead.",
1565
- ),
1566
- );
1567
- process.exit(1);
1568
- }
1569
- throw err;
1570
- }
1571
- }
1572
-
1573
- try {
1574
- const result = await execOutpost(token, remoteCommand, opts.id);
1575
- if (opts.json) {
1576
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1577
- } else {
1578
- // Stream the remote streams to ours so the command feels local.
1579
- if (result.stdout) process.stdout.write(result.stdout);
1580
- if (result.stderr) process.stderr.write(result.stderr);
1581
- if (result.truncated) {
1582
- console.error(
1583
- chalk.yellow(
1584
- "(output truncated by SSM's inline limit — redirect to a file on the box for full output)",
1585
- ),
1586
- );
1587
- }
1588
- if (result.status !== "Success" && result.exitCode === null) {
1589
- console.error(
1590
- chalk.yellow(
1591
- `(command ended with SSM status: ${result.status})`,
1592
- ),
1593
- );
1594
- }
1595
- }
1596
- // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
1597
- process.exitCode =
1598
- typeof result.exitCode === "number" ? result.exitCode : 0;
1599
- } catch (err) {
1600
- // Lightsail boxes have no SSM agent — transparently fall back to SSH.
1601
- if (
1602
- err instanceof OutpostHttpError &&
1603
- err.step === "platform-unsupported"
1604
- ) {
1605
- const access = await getOutpostSshAccess(token, opts.id);
1606
- const ssh = execViaSsh(access, remoteCommand);
1607
- if (ssh.error) {
1608
- console.error(
1609
- chalk.red(`Could not run the command over SSH: ${ssh.error}`),
1610
- );
1611
- process.exit(1);
1612
- }
1613
- if (opts.json) {
1614
- process.stdout.write(
1615
- JSON.stringify(
1616
- {
1617
- via: "ssh",
1618
- platform: access.platform,
1619
- host: access.host,
1620
- exitCode: ssh.exitCode,
1621
- stdout: ssh.stdout,
1622
- stderr: ssh.stderr,
1623
- },
1624
- null,
1625
- 2,
1626
- ) + "\n",
1627
- );
1628
- } else {
1629
- if (ssh.stdout) process.stdout.write(ssh.stdout);
1630
- if (ssh.stderr) process.stderr.write(ssh.stderr);
1631
- }
1632
- process.exitCode =
1633
- typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
1634
- return;
1635
- }
1636
- throw err;
1637
- }
1638
- } catch (err) {
1639
- fail(err);
1640
- }
1641
- });
1642
-
1643
- outposts
1644
- .command("exec-stage")
1645
- .description("Stage a file for an asynchronous Outpost command")
1646
- .requiredOption("--file <path>", "File to upload")
1647
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1648
- .option("--json", "Emit raw JSON")
1649
- .action(async function (
1650
- this: Command,
1651
- opts: { file: string; id?: string; json?: boolean },
1652
- ) {
1653
- try {
1654
- const bytes = fs.readFileSync(opts.file);
1655
- const token = await ensureCognitoToken();
1656
- const staged = await stageExecInput(token, opts.id);
1657
- const upload = await fetch(staged.putUrl, {
1658
- method: "PUT",
1659
- headers: { "Content-Length": String(bytes.byteLength) },
1660
- body: bytes,
1661
- });
1662
- if (!upload.ok) {
1663
- throw new Error(
1664
- `Could not upload exec input: HTTP ${upload.status} ${upload.statusText}`,
1665
- );
1666
- }
1667
- const output = { key: staged.key, getUrl: staged.getUrl };
1668
- if (opts.json) {
1669
- process.stdout.write(JSON.stringify(output) + "\n");
1670
- } else {
1671
- printKeyValues(output);
1672
- }
1673
- } catch (err) {
1674
- fail(err);
1675
- }
1676
- });
1677
-
1678
- outposts
1679
- .command("exec-submit <command...>")
1680
- .description(
1681
- "Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)",
1682
- )
1683
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1684
- .option(
1685
- "--timeout-seconds <n>",
1686
- "Shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)",
1687
- (v: string) => {
1688
- const n = Number(v);
1689
- if (!Number.isInteger(n)) {
1690
- throw new Error("--timeout-seconds must be an integer");
1691
- }
1692
- return n;
1693
- },
1694
- )
1695
- .option("--json", "Emit raw JSON")
1696
- .action(async function (
1697
- this: Command,
1698
- commandParts: string[],
1699
- opts: { id?: string; json?: boolean; timeoutSeconds?: number },
1700
- ) {
1701
- try {
1702
- const command = joinCommandParts(commandParts);
1703
- if (!command.trim()) {
1704
- console.error(
1705
- chalk.red(
1706
- "No command given. Usage: hq outposts exec-submit -- <command>",
1707
- ),
1708
- );
1709
- process.exit(1);
1710
- }
1711
- if (opts.timeoutSeconds !== undefined) {
1712
- if (
1713
- !Number.isInteger(opts.timeoutSeconds) ||
1714
- opts.timeoutSeconds < 1 ||
1715
- opts.timeoutSeconds > 172_800
1716
- ) {
1717
- console.error(
1718
- chalk.red(
1719
- "--timeout-seconds must be an integer between 1 and 172800 (48h)",
1720
- ),
1721
- );
1722
- process.exit(1);
1723
- }
1724
- }
1725
- const token = await ensureCognitoToken();
1726
- // exec-submit is the raw fire-and-forget path — do NOT wrap with
1727
- // withRemoteHqDir here (callers that want the HQ cwd use `exec --async`
1728
- // or prefix their own cd). Matches the existing contract.
1729
- const submitted = await submitExec(
1730
- token,
1731
- command,
1732
- opts.id,
1733
- opts.timeoutSeconds,
1734
- );
1735
- const output = {
1736
- commandId: submitted.commandId,
1737
- ...(submitted.executionTimeoutSeconds !== undefined
1738
- ? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
1739
- : opts.timeoutSeconds !== undefined
1740
- ? { executionTimeoutSeconds: opts.timeoutSeconds }
1741
- : {}),
1742
- };
1743
- if (opts.json) {
1744
- process.stdout.write(JSON.stringify(output) + "\n");
1745
- } else {
1746
- printKeyValues(output);
1747
- }
1748
- } catch (err) {
1749
- fail(err);
1750
- }
1751
- });
1752
-
1753
- outposts
1754
- .command("exec-result")
1755
- .description("Fetch the result of an asynchronous Outpost command")
1756
- .requiredOption(
1757
- "--command-id <commandId>",
1758
- "Command id returned by exec-submit",
1759
- )
1760
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1761
- .option("--wait", "Poll until the command reaches a terminal state")
1762
- .option("--json", "Emit raw JSON")
1763
- .action(async function (
1764
- this: Command,
1765
- opts: { commandId: string; id?: string; wait?: boolean; json?: boolean },
1766
- ) {
1767
- try {
1768
- const token = await ensureCognitoToken();
1769
- const result = opts.wait
1770
- ? await waitForExecResult(token, opts.commandId, opts.id)
1771
- : await fetchExecResult(token, opts.commandId, opts.id);
1772
- const output = {
1773
- done: result.done,
1774
- status: result.status,
1775
- exitCode: result.exitCode ?? null,
1776
- stdout: result.stdout ?? "",
1777
- stderr: result.stderr ?? "",
1778
- };
1779
- if (opts.json) {
1780
- process.stdout.write(JSON.stringify(output) + "\n");
1781
- } else {
1782
- printKeyValues(output);
1783
- }
1784
- } catch (err) {
1785
- fail(err);
1786
- }
1787
- });
1788
-
1789
- outposts
1790
- .command("codex-enable")
1791
- .description("Enable (or retry) Codex on an Outpost")
1792
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1793
- .option("--json", "Emit raw JSON")
1794
- .action(async function (
1795
- this: Command,
1796
- opts: { id?: string; json?: boolean },
1797
- ) {
1798
- try {
1799
- const token = await ensureCognitoToken();
1800
- const result = await enableCodex(token, opts.id);
1801
- if (opts.json) {
1802
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1803
- return;
1804
- }
1805
- console.log(chalk.green("Codex enablement requested for the Outpost."));
1806
- } catch (err) {
1807
- fail(err);
1808
- }
1809
- });
1810
-
1811
- outposts
1812
- .command("login")
1813
- .description("Request a fresh login URL for an Outpost")
1814
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1815
- .option("--json", "Emit raw JSON")
1816
- .action(async function (
1817
- this: Command,
1818
- opts: { id?: string; json?: boolean },
1819
- ) {
1820
- try {
1821
- const token = await ensureCognitoToken();
1822
- const result = await regenerateLoginUrl(token, opts.id);
1823
- if (opts.json) {
1824
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1825
- return;
1826
- }
1827
- console.log(
1828
- chalk.green(
1829
- "Login-URL regeneration requested. The box mints a fresh URL shortly.",
1830
- ),
1831
- );
1832
- console.log(
1833
- chalk.dim(
1834
- "Next: `hq outposts status" +
1835
- (opts.id ? ` --id ${opts.id}` : "") +
1836
- "` to read the login URL, open it and sign in, then paste the code back with " +
1837
- "`hq outposts login-code <code>" +
1838
- (opts.id ? ` --id ${opts.id}` : "") +
1839
- "`.",
1840
- ),
1841
- );
1842
- } catch (err) {
1843
- fail(err);
1844
- }
1845
- });
1846
-
1847
- outposts
1848
- .command("login-code <code>")
1849
- .description(
1850
- "Submit the Claude sign-in code for an Outpost that is awaiting login",
1851
- )
1852
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1853
- .option("--json", "Emit raw JSON")
1854
- .action(async function (
1855
- this: Command,
1856
- code: string,
1857
- opts: { id?: string; json?: boolean },
1858
- ) {
1859
- try {
1860
- const trimmed = code.trim();
1861
- if (!trimmed) {
1862
- console.error(
1863
- chalk.red(
1864
- "Provide the sign-in code: hq outposts login-code <code>",
1865
- ),
1866
- );
1867
- process.exit(1);
1868
- }
1869
- const token = await ensureCognitoToken();
1870
- const result = await submitLoginCode(token, trimmed, opts.id);
1871
- if (opts.json) {
1872
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1873
- return;
1874
- }
1875
- console.log(
1876
- chalk.green(
1877
- "Code submitted — the box will finish signing in shortly.",
1878
- ),
1879
- );
1880
- console.log(
1881
- chalk.dim(
1882
- "Track it: `hq outposts status" +
1883
- (opts.id ? ` --id ${opts.id}` : "") +
1884
- "` (it flips to `ready` once Claude auth completes).",
1885
- ),
1886
- );
1887
- } catch (err) {
1888
- fail(err);
1889
- }
1890
- });
1891
-
1892
- outposts
1893
- .command("destroy")
1894
- .description("Tear down (permanently destroy) an Outpost")
1895
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1896
- .option("--yes", "Confirm the irreversible teardown (required)")
1897
- .action(async function (
1898
- this: Command,
1899
- opts: { id?: string; yes?: boolean },
1900
- ) {
1901
- const target = opts.id ?? "your primary Outpost";
1902
- if (!opts.yes) {
1903
- console.error(
1904
- chalk.yellow(
1905
- `This will permanently destroy ${target} and delete its cloud resources. ` +
1906
- `This cannot be undone.\n` +
1907
- `Re-run with --yes to confirm: hq outposts destroy${opts.id ? ` --id ${opts.id}` : ""} --yes`,
1908
- ),
1909
- );
1910
- process.exit(1);
1911
- }
1912
- try {
1913
- const token = await ensureCognitoToken();
1914
- await destroyOutpost(token, opts.id);
1915
- console.log(chalk.green(`Destroyed ${target}.`));
1916
- } catch (err) {
1917
- // A 409 teardown-incomplete is not a failure — the gateway's 30s cap
1918
- // fired while the Lambda keeps working. The row is preserved and the
1919
- // operation is idempotent, so tell the caller to retry.
1920
- if (
1921
- err instanceof OutpostHttpError &&
1922
- (err.status === 409 || err.step === "teardown-incomplete")
1923
- ) {
1924
- console.log(
1925
- chalk.yellow(
1926
- `Teardown still in progress for ${target} — this is expected for large boxes. ` +
1927
- `Re-run the same destroy command to finish (it's idempotent).`,
1928
- ),
1929
- );
1930
- return;
1931
- }
1932
- fail(err);
1933
- }
1934
- });
1935
- }