@indigoai-us/hq-cli 5.101.6 → 5.101.7

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.
@@ -1,1301 +1,38 @@
1
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.
2
+ * `hq outposts` — thin adapter over the Outpost command tree, which now lives in
3
+ * @indigoai-us/hq-cloud (`/outposts/cli`).
6
4
  *
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.
5
+ * The wire contract, the command surface, and the box-side helpers all moved out
6
+ * of the CLI so they can be shared — notably so a Next.js app can drive the same
7
+ * `/outpost/*` control plane through `@indigoai-us/hq-cloud/outposts`. All the CLI
8
+ * keeps is the wiring: its own authenticated transport (`vaultApiFetch`, which
9
+ * carries Sentry breadcrumbs, the `hqk_` route-rewrite table, and plan-gate
10
+ * decoding), its token resolution, and its cached-session read.
11
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.
12
+ * `vaultApiFetch` satisfies hq-cloud's `OutpostTransport` structurally — the
13
+ * option bags match field-for-field so it is passed straight through with no
14
+ * adapter shim.
22
15
  */
23
- import chalk from "chalk";
24
- import { spawnSync } from "node:child_process";
25
- import * as fs from "node:fs";
26
- import * as os from "node:os";
27
- import * as path from "node:path";
28
- import * as readline from "node:readline";
29
- import { randomBytes } from "node:crypto";
30
- import * as yaml from "js-yaml";
31
16
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
32
- import { ensureCognitoToken } from "../utils/cognito-session.js";
33
- import { vaultApiFetch } from "../utils/vault-api.js";
34
- import { registerHeartbeatCommand } from "./outposts-heartbeat.js";
35
- import { OUTPOST_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
36
- /** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
37
- export function parseCappedPayload(body) {
38
- if (!body || typeof body !== "object")
39
- return undefined;
40
- const b = body;
41
- if (b.capped !== true)
42
- return undefined;
43
- return {
44
- limit: typeof b.limit === "number" ? b.limit : 0,
45
- outposts: Array.isArray(b.outposts) ? b.outposts : [],
46
- };
47
- }
48
- /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
49
- export class OutpostHttpError extends Error {
50
- status;
51
- step;
52
- /** hq-pro's billing envelope on a `402 billing_required` provision block. */
53
- billing;
54
- /** hq-pro's cap envelope on a `409` provision block. */
55
- capped;
56
- constructor(status, message, step, billing, capped) {
57
- super(message);
58
- this.name = "OutpostHttpError";
59
- this.status = status;
60
- this.step = step;
61
- this.billing = billing;
62
- this.capped = capped;
63
- }
64
- }
65
- /**
66
- * Authenticated JSON round-trip against the outpost control plane. Throws
67
- * `OutpostHttpError` on any non-2xx (never swallows — hq-never-swallow-errors),
68
- * decoding hq-pro's `{ error | message, step }` envelope for the reason. The
69
- * `step` is preserved so callers can recognise the `destroy` route's
70
- * `teardown-incomplete` 409 (which means "retry", not "failed").
71
- */
72
- export async function outpostRequest(opts) {
73
- const res = await vaultApiFetch(opts);
74
- if (!res.ok) {
75
- const body = (await res.json().catch(() => ({})));
76
- const message = typeof body.message === "string"
77
- ? body.message
78
- : typeof body.error === "string"
79
- ? body.error
80
- : res.statusText;
81
- throw new OutpostHttpError(res.status, message, body.step, parseBillingPayload(body), parseCappedPayload(body));
82
- }
83
- return (await res.json());
84
- }
85
- /**
86
- * Provision the caller's Outpost. Sends the cached Cognito refresh token so the
87
- * box can authenticate AS the caller (the same body the console's
88
- * `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
89
- * their per-person cap gets a `409` whose body lists their existing boxes —
90
- * thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
91
- * BEFORE activation billing, so a capped call is never charged). The refresh
92
- * token is sent over HTTPS and NEVER printed.
93
- */
94
- export async function provisionOutpost(token, input) {
95
- return outpostRequest({
96
- token,
97
- path: "/outpost/provision",
98
- method: "POST",
99
- body: {
100
- refreshToken: input.refreshToken,
101
- ...(input.clientIp ? { clientIp: input.clientIp } : {}),
102
- ...(input.diskSizeGb ? { diskSizeGb: input.diskSizeGb } : {}),
103
- ...(input.agentRuntime ? { agentRuntime: input.agentRuntime } : {}),
104
- },
105
- });
106
- }
107
- export async function listOutposts(token) {
108
- const data = await outpostRequest({
109
- token,
110
- path: "/outpost/list",
111
- });
112
- return data.outposts ?? [];
113
- }
114
- export async function getOutpostStatus(token, outpostId) {
115
- return outpostRequest({
116
- token,
117
- path: "/outpost/status",
118
- query: outpostId ? { outpostId } : undefined,
119
- });
120
- }
121
- export async function enableCodex(token, outpostId) {
122
- return outpostRequest({
123
- token,
124
- path: "/outpost/codex/enable",
125
- method: "POST",
126
- query: outpostId ? { outpostId } : undefined,
127
- });
128
- }
129
- export async function regenerateLoginUrl(token, outpostId) {
130
- return outpostRequest({
131
- token,
132
- path: "/outpost/regenerate-login-url",
133
- method: "POST",
134
- query: outpostId ? { outpostId } : undefined,
135
- });
136
- }
137
- /**
138
- * Hand the box the one-time Claude sign-in code the operator got from the login
139
- * URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
140
- * it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
141
- * terminal-native equivalent of pasting the code into the web console.
142
- */
143
- export async function submitLoginCode(token, code, outpostId) {
144
- return outpostRequest({
145
- token,
146
- path: "/outpost/login-code",
147
- method: "POST",
148
- body: { code },
149
- query: outpostId ? { outpostId } : undefined,
150
- });
151
- }
152
- export async function destroyOutpost(token, outpostId) {
153
- return outpostRequest({
154
- token,
155
- path: "/outpost/destroy",
156
- method: "POST",
157
- query: outpostId ? { outpostId } : undefined,
158
- });
159
- }
17
+ import { registerOutpostsCommand as registerOutpostsCommandFromCloud } from "@indigoai-us/hq-cloud/outposts/cli";
18
+ import { DEFAULT_VAULT_API_URL, ensureCognitoToken, } from "../utils/cognito-session.js";
19
+ import { resolveCallerPersonUid, vaultApiFetch } from "../utils/vault-api.js";
160
20
  /**
161
- * Run a one-shot shell command on the caller's Outpost via `POST /outpost/exec`
162
- * (server-brokered SSM — no SSH). Throws `OutpostHttpError` on a non-2xx, whose
163
- * `step` distinguishes not-ready / platform-unsupported / timeout / ssm.
164
- */
165
- export async function execOutpost(token, command, outpostId) {
166
- return outpostRequest({
167
- token,
168
- path: "/outpost/exec",
169
- method: "POST",
170
- body: { command },
171
- query: outpostId ? { outpostId } : undefined,
172
- });
173
- }
174
- export async function stageExecInput(token, outpostId) {
175
- return outpostRequest({
176
- token,
177
- path: "/outpost/exec",
178
- method: "POST",
179
- body: { mode: "stage" },
180
- query: outpostId ? { outpostId } : undefined,
181
- });
182
- }
183
- export async function submitExec(token, command, outpostId, timeoutSeconds) {
184
- return outpostRequest({
185
- token,
186
- path: "/outpost/exec",
187
- method: "POST",
188
- body: {
189
- mode: "submit",
190
- command,
191
- ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
192
- },
193
- query: outpostId ? { outpostId } : undefined,
194
- });
195
- }
196
- export async function fetchExecResult(token, commandId, outpostId) {
197
- return outpostRequest({
198
- token,
199
- path: "/outpost/exec",
200
- method: "POST",
201
- body: { mode: "result", commandId },
202
- query: outpostId ? { outpostId } : undefined,
203
- });
204
- }
205
- function shellQuote(part) {
206
- return `'${part.replace(/'/g, `'\\''`)}'`;
207
- }
208
- /** Preserve a single command string; safely join argv when Commander split it. */
209
- export function joinCommandParts(commandParts) {
210
- if (commandParts.length === 1)
211
- return commandParts[0];
212
- return commandParts.map(shellQuote).join(" ");
213
- }
214
- const EXEC_RESULT_INITIAL_POLL_MS = 500;
215
- const EXEC_RESULT_MAX_POLL_MS = 5_000;
216
- function sleep(ms) {
217
- return new Promise((resolve) => setTimeout(resolve, ms));
218
- }
219
- async function waitForExecResult(token, commandId, outpostId) {
220
- let delayMs = EXEC_RESULT_INITIAL_POLL_MS;
221
- while (true) {
222
- try {
223
- const result = await fetchExecResult(token, commandId, outpostId);
224
- if (result.done)
225
- return result;
226
- }
227
- catch (err) {
228
- if (!(err instanceof OutpostHttpError) || err.status !== 429)
229
- throw err;
230
- }
231
- await sleep(delayMs);
232
- delayMs = Math.min(delayMs * 2, EXEC_RESULT_MAX_POLL_MS);
233
- }
234
- }
235
- /**
236
- * Prefix that best-effort `cd`s into the box's HQ checkout before running the
237
- * caller's command. `exec` runs over two transports with two different default
238
- * working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
239
- * SSH lands in the login user's home — so without this, `hq outposts exec -- pwd`
240
- * printed an unhelpful, transport-dependent directory. Initialize a real root
241
- * home for the SSM case before resolving the HQ folder: tools run by the caller
242
- * (notably `gh`) otherwise treat the HQ checkout as their home and can create
243
- * root-owned machine state inside it. The trailing `|| true` keeps the command
244
- * running from the default directory when no HQ checkout is present, so exec
245
- * never fails merely because the box has no HQ folder.
246
- */
247
- export const REMOTE_HQ_DIR_PREFIX = 'export HOME="${HOME:-/root}"; cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
248
- /** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
249
- export function withRemoteHqDir(command) {
250
- return `${REMOTE_HQ_DIR_PREFIX}; ${command}`;
251
- }
252
- /**
253
- * Fetch SSH connection info + key for the caller's Outpost and open the caller's
254
- * IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
255
- * `OutpostHttpError` on a non-2xx.
256
- */
257
- export async function getOutpostSshAccess(token, outpostId) {
258
- return outpostRequest({
259
- token,
260
- path: "/outpost/ssh-access",
261
- method: "POST",
262
- body: {},
263
- query: outpostId ? { outpostId } : undefined,
264
- });
265
- }
266
- /**
267
- * Run `command` on the box over SSH using vended access details. Writes the
268
- * private key to a locked-down temp file, runs a non-interactive `ssh`, and
269
- * returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
270
- * are always cleaned up; the key is never printed. `exitCode` is `null` only
271
- * when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
272
- */
273
- export function execViaSsh(access, command) {
274
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-outpost-ssh-"));
275
- const keyPath = path.join(dir, `id_${randomBytes(6).toString("hex")}`);
276
- const knownHosts = path.join(dir, "known_hosts");
277
- try {
278
- fs.writeFileSync(keyPath, ensureTrailingNewline(access.privateKey), {
279
- mode: 0o600,
280
- });
281
- const result = spawnSync("ssh", [
282
- "-i",
283
- keyPath,
284
- "-p",
285
- String(access.port),
286
- "-o",
287
- "BatchMode=yes",
288
- "-o",
289
- "StrictHostKeyChecking=accept-new",
290
- "-o",
291
- `UserKnownHostsFile=${knownHosts}`,
292
- "-o",
293
- "ConnectTimeout=15",
294
- "-o",
295
- "LogLevel=ERROR",
296
- `${access.username}@${access.host}`,
297
- command,
298
- ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
299
- if (result.error) {
300
- const hint = result.error.code === "ENOENT"
301
- ? "the `ssh` client is not installed or not on PATH"
302
- : result.error.message;
303
- return { stdout: "", stderr: "", exitCode: null, error: hint };
304
- }
305
- return {
306
- stdout: result.stdout ?? "",
307
- stderr: result.stderr ?? "",
308
- exitCode: result.status,
309
- };
310
- }
311
- finally {
312
- fs.rmSync(dir, { recursive: true, force: true });
313
- }
314
- }
315
- function ensureTrailingNewline(s) {
316
- return s.endsWith("\n") ? s : s + "\n";
317
- }
318
- // ---------------------------------------------------------------------------
319
- // Command registration
320
- // ---------------------------------------------------------------------------
321
- /**
322
- * Explain a `409` per-person cap. hq-pro checks the cap BEFORE activation
323
- * billing, so nothing was charged — worth saying, since the caller just
324
- * confirmed a recurring charge to get here.
325
- */
326
- function surfaceOutpostCapped(capped) {
327
- const owned = capped.outposts.length;
328
- console.error(chalk.yellow(`You're already at your Outpost limit (${owned} of ${capped.limit}). ` +
329
- `No new box was provisioned and you have not been charged.`));
330
- for (const o of capped.outposts) {
331
- const detail = [o.state, o.instanceName, o.region]
332
- .filter(Boolean)
333
- .join(" ");
334
- console.error(` ${o.outpostId} ${detail}`);
335
- }
336
- console.error(chalk.dim("Inspect it: hq outposts status"));
337
- console.error(chalk.dim("Or tear it down first: hq outposts destroy --id <id> --yes"));
338
- }
339
- function fail(err) {
340
- if (err instanceof OutpostHttpError) {
341
- console.error(chalk.red(err.message));
342
- }
343
- else {
344
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
345
- }
346
- process.exit(1);
347
- }
348
- /** Print a top-level object as `key: value`, JSON-ifying nested values. */
349
- function printKeyValues(obj) {
350
- for (const [k, v] of Object.entries(obj)) {
351
- const rendered = v && typeof v === "object" ? JSON.stringify(v) : String(v);
352
- console.log(`${chalk.bold(k)}: ${rendered}`);
353
- }
354
- }
355
- const defaultSelfDeployDependencies = {
356
- spawnSync: (command, args, options) => spawnSync(command, args, options),
357
- readTextFile: (file) => fs.readFileSync(file, "utf8"),
358
- loadCachedTokens: () => loadCachedTokens() ?? undefined,
359
- getUid: () => process.getuid?.(),
360
- isStdinTty: () => process.stdin.isTTY === true,
361
- confirm: async () => {
362
- const rl = readline.createInterface({
363
- input: process.stdin,
364
- output: process.stdout,
365
- });
366
- return new Promise((resolve) => {
367
- rl.question("", (answer) => {
368
- rl.close();
369
- resolve(/^y(es)?$/i.test(answer.trim()));
370
- });
371
- });
372
- },
373
- defaultHqRoot: () => process.env.HQ_ROOT ?? path.join(os.homedir(), "hq"),
374
- invokingUser: () => process.env.SUDO_USER ?? process.env.USER ?? os.userInfo().username,
375
- };
376
- function selfDeployError(message) {
377
- return new Error(`Self-deploy preflight failed: ${message}`);
378
- }
379
- function commandSucceeded(deps, command, args) {
380
- try {
381
- const result = deps.spawnSync(command, args, { encoding: "utf8" });
382
- return !!result && !result.error && result.status === 0;
383
- }
384
- catch {
385
- return false;
386
- }
387
- }
388
- function commandOutput(deps, command, args) {
389
- try {
390
- const result = deps.spawnSync(command, args, { encoding: "utf8" });
391
- if (!result || result.error || result.status !== 0)
392
- return undefined;
393
- return String(result.stdout ?? "").trim();
394
- }
395
- catch {
396
- return undefined;
397
- }
398
- }
399
- function parseOsRelease(source) {
400
- const values = new Map();
401
- for (const line of source.split("\n")) {
402
- const match = /^([A-Z_]+)=(.*)$/.exec(line);
403
- if (!match)
404
- continue;
405
- const [, key, rawValue] = match;
406
- values.set(key, rawValue.replace(/^['"]|['"]$/g, ""));
407
- }
408
- return values;
409
- }
410
- function hqIdentityFromSession(session) {
411
- if (!session.idToken)
412
- return "your cached HQ session";
413
- try {
414
- const payload = session.idToken.split(".")[1];
415
- if (!payload)
416
- return "your cached HQ session";
417
- const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
418
- for (const key of [
419
- "email",
420
- "preferred_username",
421
- "cognito:username",
422
- "username",
423
- "sub",
424
- ]) {
425
- const value = claims[key];
426
- if (typeof value === "string" && value)
427
- return value;
428
- }
429
- }
430
- catch {
431
- // A cache that has a refresh token remains valid for this local setup. The
432
- // identity banner is informational, so never expose a token parse failure.
433
- }
434
- return "your cached HQ session";
435
- }
436
- function bashSingleQuote(value) {
437
- return `'${value.replace(/'/g, "'\\''")}'`;
438
- }
439
- function systemdQuoted(value) {
440
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
441
- }
442
- // U60 records this compatible hq-cloud artifact. Outpost launchers must stay
443
- // reproducible: `@latest` could silently opt a long-lived box into a different
444
- // sync protocol before its server-side eligibility is evaluated.
445
- const OUTPOST_HQ_CLOUD_VERSION = "6.15.1";
446
- function renderSelfDeploySyncScript(hqRoot) {
447
- return `#!/usr/bin/env bash
448
- set -u
449
- # Persistent all-membership sync for a locally self-hosted HQ outpost. The
450
- # watch runner event-pushes local changes and polls remote changes every minute.
451
- HQ_ROOT=${bashSingleQuote(hqRoot)}
452
- cd "$HQ_ROOT"
453
- while true; do
454
- hq auth refresh || echo "[outpost-sync] auth refresh failed, continuing"
455
- npx -y --package=@indigoai-us/hq-cloud@${OUTPOST_HQ_CLOUD_VERSION} hq-sync-runner \\
456
- --companies \\
457
- --direction both \\
458
- --on-conflict keep \\
459
- --hq-root "$HQ_ROOT" \\
460
- --watch \\
461
- --event-push \\
462
- --poll-remote-ms 60000
463
- echo "[outpost-sync] watch runner exited; restarting in 10 seconds"
464
- sleep 10
465
- done
466
- `;
467
- }
468
- function renderSelfDeployService(hqRoot, user) {
469
- return `[Unit]
470
- After=network-online.target
471
- Wants=network-online.target
472
-
473
- [Service]
474
- User=${user}
475
- WorkingDirectory=${systemdQuoted(hqRoot)}
476
- ExecStart=/usr/local/bin/outpost-sync.sh
477
- Restart=always
478
- RestartSec=10s
479
-
480
- [Install]
481
- WantedBy=multi-user.target
482
- `;
483
- }
484
- function runChecked(deps, command, args, description, options) {
485
- let result;
486
- try {
487
- result = deps.spawnSync(command, args, options);
488
- }
489
- catch {
490
- throw new Error(`${description} could not be started.`);
491
- }
492
- if (!result || result.error || result.status !== 0) {
493
- // Do not include child process output here: even though this command never
494
- // passes credentials to children, it keeps the error path secret-safe.
495
- throw new Error(`${description} failed. Resolve the error and try again.`);
496
- }
497
- }
498
- function runPrivileged(deps, command, args, description, options) {
499
- if (deps.getUid() === 0) {
500
- runChecked(deps, command, args, description, options);
501
- return;
502
- }
503
- runChecked(deps, "sudo", [command, ...args], description, options);
504
- }
505
- function selfDeployPreflight(deps) {
506
- let osRelease;
507
- try {
508
- osRelease = deps.readTextFile("/etc/os-release");
509
- }
510
- catch {
511
- throw selfDeployError("could not read /etc/os-release. This command only supports Amazon Linux 2023 x86_64.");
512
- }
513
- const release = parseOsRelease(osRelease);
514
- if (release.get("ID") !== "amzn" ||
515
- !release.get("VERSION_ID")?.startsWith("2023")) {
516
- throw selfDeployError("this command only supports Amazon Linux 2023 x86_64. Use an Amazon Linux 2023 EC2 instance.");
517
- }
518
- const architecture = commandOutput(deps, "uname", ["-m"]);
519
- if (architecture !== "x86_64") {
520
- throw selfDeployError("this command only supports x86_64 EC2 instances. Use an Amazon Linux 2023 x86_64 instance.");
521
- }
522
- if (!commandSucceeded(deps, "systemctl", ["--version"])) {
523
- throw selfDeployError("systemd is required but `systemctl --version` failed. Run this on an Amazon Linux 2023 EC2 host with systemd.");
524
- }
525
- if (deps.getUid() !== 0 && !commandSucceeded(deps, "sudo", ["-n", "true"])) {
526
- throw selfDeployError("root or passwordless sudo is required. Configure passwordless sudo or run this command as root.");
527
- }
528
- const session = deps.loadCachedTokens();
529
- if (!session?.refreshToken) {
530
- throw selfDeployError("no HQ login session was found. Run `hq login`, then re-run this command.");
531
- }
532
- return session;
533
- }
534
- async function selfDeployOutpost(opts, deps) {
535
- const session = selfDeployPreflight(deps);
536
- const hqRoot = opts.hqRoot ?? deps.defaultHqRoot();
537
- const user = deps.invokingUser();
538
- if (!opts.yes) {
539
- console.log(`HQ identity: ${hqIdentityFromSession(session)}`);
540
- console.log(chalk.yellow("This machine will run as a SELF-HOSTED HQ outpost under YOUR identity, continuously syncing ALL your company vaults. " +
541
- "It is NOT registered with or managed by hq-pro (no console entry, no remote management, no metering). " +
542
- "Anyone with root on this box can act as you. Continue? [y/N]"));
543
- if (!deps.isStdinTty()) {
544
- throw new Error("Confirmation requires a TTY. Pass --yes to continue non-interactively.");
545
- }
546
- if (!(await deps.confirm())) {
547
- throw new Error("Self-deploy cancelled.");
548
- }
549
- }
550
- runChecked(deps, "hq", ["rescue", "--hq-root", hqRoot, "--yes"], "HQ kernel rescue", { stdio: "inherit" });
551
- runPrivileged(deps, "tee", ["/usr/local/bin/outpost-sync.sh"], "Writing /usr/local/bin/outpost-sync.sh", {
552
- encoding: "utf8",
553
- input: renderSelfDeploySyncScript(hqRoot),
554
- stdio: ["pipe", "ignore", "pipe"],
555
- });
556
- runPrivileged(deps, "chmod", ["+x", "/usr/local/bin/outpost-sync.sh"], "Making /usr/local/bin/outpost-sync.sh executable");
557
- runPrivileged(deps, "tee", ["/etc/systemd/system/outpost-sync.service"], "Writing /etc/systemd/system/outpost-sync.service", {
558
- encoding: "utf8",
559
- input: renderSelfDeployService(hqRoot, user),
560
- stdio: ["pipe", "ignore", "pipe"],
561
- });
562
- runPrivileged(deps, "systemctl", ["daemon-reload"], "Reloading systemd");
563
- runPrivileged(deps, "systemctl", ["enable", "--now", "outpost-sync.service"], "Enabling outpost-sync.service");
564
- console.log(chalk.green("This box is now a self-hosted HQ outpost and will sync all your company vaults continuously."));
565
- console.log(chalk.dim("Check it with: systemctl status outpost-sync.service"));
566
- console.log(chalk.dim("It is unregistered and unmanaged by hq-pro (no console entry or remote management)."));
567
- }
568
- const defaultReplicaSyncDependencies = {
569
- ...defaultSelfDeployDependencies,
570
- pathExists: (p) => fs.existsSync(p),
571
- mkdirp: (p) => {
572
- fs.mkdirSync(p, { recursive: true });
573
- },
574
- };
575
- function repoNameFromUrl(url) {
576
- const tail = url.split("/").pop() ?? "";
577
- return tail.replace(/\.git$/, "").trim();
578
- }
579
- /**
580
- * A repo `name` becomes a path segment under repos/<visibility>/. A synced
581
- * repos.yaml is operator data, but a malformed or hostile name must never
582
- * escape that directory, so constrain it to a single, non-traversal component.
583
- */
584
- function isSafePathComponent(value) {
585
- return (value.length > 0 &&
586
- !value.includes("/") &&
587
- !value.includes("\\") &&
588
- value !== "." &&
589
- value !== "..");
590
- }
591
- /**
592
- * Parse `personal/data/repos.yaml` into a clone list. Pure + exported for
593
- * tests. Mirrors setup.sh's `clone_repos`: `visibility` defaults to public,
594
- * `name` defaults to the URL basename. Malformed entries are skipped, never
595
- * thrown.
596
- */
597
- export function parseReposManifest(yamlText) {
598
- let doc;
599
- try {
600
- doc = yaml.load(yamlText);
601
- }
602
- catch {
603
- return [];
604
- }
605
- const rawRepos = doc?.repos;
606
- if (!Array.isArray(rawRepos))
607
- return [];
608
- const out = [];
609
- for (const entry of rawRepos) {
610
- if (!entry || typeof entry !== "object")
611
- continue;
612
- const e = entry;
613
- const url = typeof e.url === "string" ? e.url.trim() : "";
614
- if (!url)
615
- continue;
616
- const visibility = e.visibility === "private" ? "private" : "public";
617
- const name = typeof e.name === "string" && e.name.trim()
618
- ? e.name.trim()
619
- : repoNameFromUrl(url);
620
- if (!isSafePathComponent(name))
621
- continue;
622
- out.push({ url, visibility, name });
623
- }
624
- return out;
625
- }
626
- /**
627
- * Best-effort spawn: log a one-line outcome and carry on. NEVER throws and
628
- * NEVER echoes child output — the same secret-safety stance as `runChecked`,
629
- * since these children (auth refresh, secrets exec) touch credentials.
630
- */
631
- function runBestEffort(deps, command, args, description, options) {
632
- let result;
633
- try {
634
- result = deps.spawnSync(command, args, options ?? { stdio: "inherit" });
635
- }
636
- catch {
637
- console.warn(`replica-sync: ${description} could not run — continuing.`);
638
- return false;
639
- }
640
- if (!result || result.error || result.status !== 0) {
641
- console.warn(`replica-sync: ${description} did not complete cleanly — continuing.`);
642
- return false;
643
- }
644
- return true;
645
- }
646
- /**
647
- * Authenticate `gh` for private clones by streaming GITHUB_TOKEN through the
648
- * vault into the child's ENV ONLY, unsetting it in-shell, and piping it to
649
- * `gh auth login --with-token` over stdin. The token never touches argv or
650
- * this process's logs. Mirrors personal/scripts/setup.sh's `configure_gh`.
21
+ * Register the `hq outposts` command group.
22
+ *
23
+ * `selfDeployOverrides` lets a test replace the box-side host-environment probes
24
+ * (spawnSync, /etc/os-release reads, uid checks) without a real EC2 host; it is
25
+ * unused in production.
651
26
  */
652
- function authGitHubViaVault(deps) {
653
- const ghLogin = [
654
- 'TOKEN="$GITHUB_TOKEN"',
655
- "unset GITHUB_TOKEN",
656
- 'printf "%s" "$TOKEN" | gh auth login --with-token',
657
- ].join("\n");
658
- const authed = runBestEffort(deps, "hq", [
659
- "secrets",
660
- "--personal",
661
- "exec",
662
- "--only",
663
- "GITHUB_TOKEN",
664
- "--",
665
- "bash",
666
- "-c",
667
- ghLogin,
668
- ], "GitHub auth via vault");
669
- if (authed) {
670
- // Let plain `git clone https://github.com/...` reuse gh's token so private
671
- // repos don't prompt for a username/password.
672
- runBestEffort(deps, "gh", ["auth", "setup-git"], "gh auth setup-git");
673
- }
674
- }
675
- /** Clone any repos from `personal/data/repos.yaml` that aren't on disk yet. */
676
- function replicateRepos(deps, hqRoot) {
677
- const manifestPath = path.join(hqRoot, "personal", "data", "repos.yaml");
678
- let manifestText;
679
- try {
680
- manifestText = deps.readTextFile(manifestPath);
681
- }
682
- catch {
683
- console.log("replica-sync: no personal/data/repos.yaml — skipping repo clone.");
684
- return;
685
- }
686
- const repos = parseReposManifest(manifestText);
687
- if (repos.length === 0) {
688
- console.log("replica-sync: repos.yaml lists no repos — skipping.");
689
- return;
690
- }
691
- const missing = repos.filter((repo) => !deps.pathExists(path.join(hqRoot, "repos", repo.visibility, repo.name, ".git")));
692
- if (missing.length === 0) {
693
- console.log("replica-sync: all repos already present.");
694
- return;
695
- }
696
- // Private repos need gh auth first; public-only manifests skip the vault call.
697
- if (missing.some((repo) => repo.visibility === "private")) {
698
- authGitHubViaVault(deps);
699
- }
700
- for (const repo of missing) {
701
- deps.mkdirp(path.join(hqRoot, "repos", repo.visibility));
702
- const dest = path.join(hqRoot, "repos", repo.visibility, repo.name);
703
- console.log(`replica-sync: cloning ${repo.visibility}/${repo.name}…`);
704
- runBestEffort(deps, "git", ["clone", "--recurse-submodules", repo.url, dest], `clone ${repo.visibility}/${repo.name}`);
705
- }
706
- }
707
- async function replicaSyncOutpost(opts, deps) {
708
- const hqRoot = opts.hqRoot ?? deps.defaultHqRoot();
709
- // Gate: only replicate on a box that has an HQ session. A box that was never
710
- // signed in has nothing to pull — exit cleanly so the timer stays green.
711
- const session = deps.loadCachedTokens();
712
- if (!session?.refreshToken) {
713
- console.log("replica-sync: no HQ session on this box yet — nothing to replicate.");
714
- return;
715
- }
716
- // Auth refresh gates the run: if the seeded refresh token is dead, every
717
- // authenticated step below (pull, secrets exec) would just cascade 401s.
718
- // Stop the cycle cleanly instead — the timer retries next tick.
719
- if (!runBestEffort(deps, "hq", ["auth", "refresh"], "HQ auth refresh")) {
720
- console.warn("replica-sync: auth refresh failed — skipping this cycle (token likely expired).");
721
- return;
722
- }
723
- runBestEffort(deps, "hq", [
724
- "sync",
725
- "pull",
726
- "--personal",
727
- "--hq-root",
728
- hqRoot,
729
- "--on-conflict",
730
- "keep",
731
- ], "personal vault pull");
732
- // rescue lays down the core kernel and expects companies/ to exist.
733
- deps.mkdirp(path.join(hqRoot, "companies"));
734
- runBestEffort(deps, "hq", ["rescue", "--hq-root", hqRoot, "--yes"], "HQ kernel rescue");
735
- replicateRepos(deps, hqRoot);
736
- console.log("replica-sync: complete.");
737
- }
738
27
  export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
739
- const deps = {
740
- ...defaultReplicaSyncDependencies,
741
- ...selfDeployOverrides,
742
- };
743
- const outposts = program
744
- .command("outposts")
745
- .description("Manage your personal HQ Outposts (EC2 boxes)");
746
- // On-box session heartbeat (systemd). Hidden — operators never run it.
747
- registerHeartbeatCommand(outposts);
748
- outposts
749
- .command("self-deploy", { hidden: true })
750
- .description("Configure this EC2 host as a locally self-hosted HQ outpost")
751
- .option("--yes", "Skip the self-hosting confirmation")
752
- .option("--hq-root <path>", "HQ root to sync", deps.defaultHqRoot())
753
- .action(async (opts) => {
754
- try {
755
- await selfDeployOutpost(opts, deps);
756
- }
757
- catch (err) {
758
- fail(err);
759
- }
760
- });
761
- outposts
762
- .command("replica-sync", { hidden: true })
763
- .description("Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.")
764
- .option("--hq-root <path>", "HQ root to replicate into", deps.defaultHqRoot())
765
- .action(async (opts) => {
766
- // Best-effort background job: never hard-fail the systemd unit on a
767
- // transient step. Swallow, log, and exit 0.
768
- try {
769
- await replicaSyncOutpost(opts, deps);
770
- }
771
- catch (err) {
772
- console.warn(`replica-sync: unexpected error — ${err instanceof Error ? err.message : String(err)}`);
773
- }
774
- });
775
- outposts
776
- .command("provision")
777
- .alias("create")
778
- .description("Provision a new Outpost ($80/month — requires --yes)")
779
- .option("--runtime <runtime>", "Agent runtime: claude | codex (default claude)")
780
- .option("--disk <gb>", "Root disk size in GB (EC2 only)")
781
- .option("--client-ip <ip>", "Your public IP for the box's SSH ingress (optional; server derives it otherwise)")
782
- .option("--yes", "Confirm the $80/month charge (required to provision)")
783
- .action(async function (opts) {
784
- try {
785
- // Reject an unrecognized runtime rather than resolving it to claude:
786
- // `--runtime codx` would otherwise hand back a silently Claude box.
787
- if (opts.runtime !== undefined &&
788
- opts.runtime !== "claude" &&
789
- opts.runtime !== "codex") {
790
- console.error(chalk.red(`Invalid --runtime '${opts.runtime}': must be 'claude' or 'codex'.`));
791
- process.exit(1);
792
- }
793
- const agentRuntime = opts.runtime === "codex"
794
- ? "codex"
795
- : opts.runtime === "claude"
796
- ? "claude"
797
- : undefined;
798
- let diskSizeGb;
799
- if (opts.disk !== undefined) {
800
- diskSizeGb = Number(opts.disk);
801
- if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
802
- console.error(chalk.red(`Invalid --disk '${opts.disk}': must be a positive number of GB.`));
803
- process.exit(1);
804
- }
805
- }
806
- // Paid gate: print the monthly cost and require --yes before any call.
807
- confirmChargeOrExit({
808
- resource: "Outpost",
809
- unitCents: OUTPOST_PRICE_CENTS,
810
- yes: opts.yes,
811
- });
812
- // The box authenticates AS the caller using the cached refresh token —
813
- // the same body the console sends. Never printed.
814
- const refreshToken = loadCachedTokens()?.refreshToken;
815
- if (!refreshToken) {
816
- console.error(chalk.red("No cached session found — run `hq login` first, then re-run."));
817
- process.exit(1);
818
- }
819
- const token = await ensureCognitoToken();
820
- try {
821
- await provisionOutpost(token, {
822
- refreshToken,
823
- ...(opts.clientIp ? { clientIp: opts.clientIp } : {}),
824
- ...(diskSizeGb ? { diskSizeGb } : {}),
825
- ...(agentRuntime ? { agentRuntime } : {}),
826
- });
827
- console.log(chalk.green("Provisioning started for your Outpost."));
828
- console.log(chalk.dim("Track it: hq outposts status"));
829
- }
830
- catch (err) {
831
- // No card on file → surface the shareable payment link, not an opaque 402.
832
- if (err instanceof OutpostHttpError &&
833
- err.status === 402 &&
834
- err.billing) {
835
- await surfaceBillingBlocked(token, err.billing, err.message);
836
- process.exit(1);
837
- }
838
- // Already at the per-person cap → say so and name the box they own,
839
- // not a bare "Conflict".
840
- if (err instanceof OutpostHttpError &&
841
- err.status === 409 &&
842
- err.capped) {
843
- surfaceOutpostCapped(err.capped);
844
- process.exit(1);
845
- }
846
- throw err;
847
- }
848
- }
849
- catch (err) {
850
- fail(err);
851
- }
852
- });
853
- outposts
854
- .command("list")
855
- .description("List every Outpost you own")
856
- .option("--json", "Emit raw JSON")
857
- .action(async function (opts) {
858
- try {
859
- const token = await ensureCognitoToken();
860
- const rows = await listOutposts(token);
861
- if (opts.json) {
862
- process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
863
- return;
864
- }
865
- if (rows.length === 0) {
866
- console.log(chalk.gray("You don't own any Outposts yet."));
867
- return;
868
- }
869
- const idW = Math.max(2, ...rows.map((r) => (r.outpostId ?? "").length));
870
- const stateW = Math.max(5, ...rows.map((r) => (r.state ?? "").length));
871
- const nameW = Math.max(4, ...rows.map((r) => (r.instanceName ?? "").length));
872
- const regionW = Math.max(6, ...rows.map((r) => (r.region ?? "").length));
873
- const rtW = Math.max(7, ...rows.map((r) => (r.agentRuntime ?? "").length));
874
- console.log(chalk.bold([
875
- "ID".padEnd(idW),
876
- "STATE".padEnd(stateW),
877
- "INSTANCE".padEnd(nameW),
878
- "REGION".padEnd(regionW),
879
- "RUNTIME".padEnd(rtW),
880
- "PLATFORM",
881
- ].join(" ")));
882
- for (const r of rows) {
883
- console.log([
884
- (r.outpostId ?? "").padEnd(idW),
885
- (r.state ?? "").padEnd(stateW),
886
- (r.instanceName ?? "").padEnd(nameW),
887
- (r.region ?? "").padEnd(regionW),
888
- (r.agentRuntime ?? "").padEnd(rtW),
889
- r.platform ?? "",
890
- ].join(" "));
891
- }
892
- }
893
- catch (err) {
894
- fail(err);
895
- }
896
- });
897
- outposts
898
- .command("status")
899
- .description("Show live detail for one Outpost")
900
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
901
- .option("--json", "Emit raw JSON")
902
- .action(async function (opts) {
903
- try {
904
- const token = await ensureCognitoToken();
905
- const status = await getOutpostStatus(token, opts.id);
906
- if (opts.json) {
907
- process.stdout.write(JSON.stringify(status, null, 2) + "\n");
908
- return;
909
- }
910
- printKeyValues(status);
911
- }
912
- catch (err) {
913
- fail(err);
914
- }
915
- });
916
- outposts
917
- .command("exec <command...>")
918
- .description("Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
919
- "Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.")
920
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
921
- .option("--async", "Submit via the async transport and wait for completion (bypasses the ~20s sync cap; shell budget defaults to 48h)")
922
- .option("--detach", "Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)")
923
- .option("--timeout-seconds <n>", "Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.", (v) => {
924
- const n = Number(v);
925
- if (!Number.isInteger(n)) {
926
- throw new Error("--timeout-seconds must be an integer");
927
- }
928
- return n;
929
- })
930
- .option("--json", "Emit raw JSON")
931
- .action(async function (commandParts, opts) {
932
- try {
933
- const command = joinCommandParts(commandParts);
934
- if (!command.trim()) {
935
- console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
936
- process.exit(1);
937
- }
938
- if (opts.async && opts.detach) {
939
- console.error(chalk.red("Use either --async (submit + wait) or --detach (submit only), not both."));
940
- process.exit(1);
941
- }
942
- // --timeout-seconds only applies to the async path; bare use implies --async.
943
- const useAsync = Boolean(opts.async) ||
944
- Boolean(opts.detach) ||
945
- opts.timeoutSeconds !== undefined;
946
- if (opts.timeoutSeconds !== undefined) {
947
- if (!Number.isInteger(opts.timeoutSeconds) ||
948
- opts.timeoutSeconds < 1 ||
949
- opts.timeoutSeconds > 172_800) {
950
- console.error(chalk.red("--timeout-seconds must be an integer between 1 and 172800 (48h, the AWS-RunShellScript max)"));
951
- process.exit(1);
952
- }
953
- }
954
- const token = await ensureCognitoToken();
955
- // Run from the box's HQ folder by default (works over both SSM and SSH).
956
- const remoteCommand = withRemoteHqDir(command);
957
- if (useAsync) {
958
- try {
959
- const submitted = await submitExec(token, remoteCommand, opts.id, opts.timeoutSeconds);
960
- if (opts.detach) {
961
- const output = {
962
- commandId: submitted.commandId,
963
- ...(submitted.executionTimeoutSeconds !== undefined
964
- ? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
965
- : opts.timeoutSeconds !== undefined
966
- ? { executionTimeoutSeconds: opts.timeoutSeconds }
967
- : {}),
968
- };
969
- if (opts.json) {
970
- process.stdout.write(JSON.stringify(output) + "\n");
971
- }
972
- else {
973
- printKeyValues(output);
974
- console.error(chalk.dim("Submitted. Poll with: hq outposts exec-result --command-id " +
975
- submitted.commandId +
976
- (opts.id ? ` --id ${opts.id}` : "") +
977
- " --wait"));
978
- }
979
- return;
980
- }
981
- // --async (or --timeout-seconds without --detach): wait for terminal.
982
- if (!opts.json) {
983
- console.error(chalk.dim(`Submitted ${submitted.commandId}; waiting for completion…`));
984
- }
985
- const result = await waitForExecResult(token, submitted.commandId, opts.id);
986
- if (opts.json) {
987
- process.stdout.write(JSON.stringify({
988
- commandId: submitted.commandId,
989
- done: result.done,
990
- status: result.status,
991
- exitCode: result.exitCode ?? null,
992
- stdout: result.stdout ?? "",
993
- stderr: result.stderr ?? "",
994
- truncated: result.truncated ?? false,
995
- }, null, 2) + "\n");
996
- }
997
- else {
998
- if (result.stdout)
999
- process.stdout.write(result.stdout);
1000
- if (result.stderr)
1001
- process.stderr.write(result.stderr);
1002
- if (result.truncated) {
1003
- console.error(chalk.yellow("(output truncated — redirect to a file on the box for full output)"));
1004
- }
1005
- if (result.status !== "Success" && result.exitCode === null) {
1006
- console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
1007
- }
1008
- }
1009
- process.exitCode =
1010
- typeof result.exitCode === "number" ? result.exitCode : 0;
1011
- return;
1012
- }
1013
- catch (err) {
1014
- // Async requires EC2/SSM. Lightsail has no async channel — refuse
1015
- // rather than silently falling back to a live SSH hold, which is
1016
- // the exact timeout failure mode --async is meant to escape.
1017
- if (err instanceof OutpostHttpError &&
1018
- err.step === "platform-unsupported") {
1019
- console.error(chalk.red("Async exec requires an EC2 Outpost (SSM). This box is Lightsail — " +
1020
- "re-provision on EC2, or run a short sync command / SSH session instead."));
1021
- process.exit(1);
1022
- }
1023
- throw err;
1024
- }
1025
- }
1026
- try {
1027
- const result = await execOutpost(token, remoteCommand, opts.id);
1028
- if (opts.json) {
1029
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1030
- }
1031
- else {
1032
- // Stream the remote streams to ours so the command feels local.
1033
- if (result.stdout)
1034
- process.stdout.write(result.stdout);
1035
- if (result.stderr)
1036
- process.stderr.write(result.stderr);
1037
- if (result.truncated) {
1038
- console.error(chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"));
1039
- }
1040
- if (result.status !== "Success" && result.exitCode === null) {
1041
- console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
1042
- }
1043
- }
1044
- // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
1045
- process.exitCode =
1046
- typeof result.exitCode === "number" ? result.exitCode : 0;
1047
- }
1048
- catch (err) {
1049
- // Lightsail boxes have no SSM agent — transparently fall back to SSH.
1050
- if (err instanceof OutpostHttpError &&
1051
- err.step === "platform-unsupported") {
1052
- const access = await getOutpostSshAccess(token, opts.id);
1053
- const ssh = execViaSsh(access, remoteCommand);
1054
- if (ssh.error) {
1055
- console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
1056
- process.exit(1);
1057
- }
1058
- if (opts.json) {
1059
- process.stdout.write(JSON.stringify({
1060
- via: "ssh",
1061
- platform: access.platform,
1062
- host: access.host,
1063
- exitCode: ssh.exitCode,
1064
- stdout: ssh.stdout,
1065
- stderr: ssh.stderr,
1066
- }, null, 2) + "\n");
1067
- }
1068
- else {
1069
- if (ssh.stdout)
1070
- process.stdout.write(ssh.stdout);
1071
- if (ssh.stderr)
1072
- process.stderr.write(ssh.stderr);
1073
- }
1074
- process.exitCode =
1075
- typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
1076
- return;
1077
- }
1078
- throw err;
1079
- }
1080
- }
1081
- catch (err) {
1082
- fail(err);
1083
- }
1084
- });
1085
- outposts
1086
- .command("exec-stage")
1087
- .description("Stage a file for an asynchronous Outpost command")
1088
- .requiredOption("--file <path>", "File to upload")
1089
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1090
- .option("--json", "Emit raw JSON")
1091
- .action(async function (opts) {
1092
- try {
1093
- const bytes = fs.readFileSync(opts.file);
1094
- const token = await ensureCognitoToken();
1095
- const staged = await stageExecInput(token, opts.id);
1096
- const upload = await fetch(staged.putUrl, {
1097
- method: "PUT",
1098
- headers: { "Content-Length": String(bytes.byteLength) },
1099
- body: bytes,
1100
- });
1101
- if (!upload.ok) {
1102
- throw new Error(`Could not upload exec input: HTTP ${upload.status} ${upload.statusText}`);
1103
- }
1104
- const output = { key: staged.key, getUrl: staged.getUrl };
1105
- if (opts.json) {
1106
- process.stdout.write(JSON.stringify(output) + "\n");
1107
- }
1108
- else {
1109
- printKeyValues(output);
1110
- }
1111
- }
1112
- catch (err) {
1113
- fail(err);
1114
- }
1115
- });
1116
- outposts
1117
- .command("exec-submit <command...>")
1118
- .description("Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)")
1119
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1120
- .option("--timeout-seconds <n>", "Shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)", (v) => {
1121
- const n = Number(v);
1122
- if (!Number.isInteger(n)) {
1123
- throw new Error("--timeout-seconds must be an integer");
1124
- }
1125
- return n;
1126
- })
1127
- .option("--json", "Emit raw JSON")
1128
- .action(async function (commandParts, opts) {
1129
- try {
1130
- const command = joinCommandParts(commandParts);
1131
- if (!command.trim()) {
1132
- console.error(chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"));
1133
- process.exit(1);
1134
- }
1135
- if (opts.timeoutSeconds !== undefined) {
1136
- if (!Number.isInteger(opts.timeoutSeconds) ||
1137
- opts.timeoutSeconds < 1 ||
1138
- opts.timeoutSeconds > 172_800) {
1139
- console.error(chalk.red("--timeout-seconds must be an integer between 1 and 172800 (48h)"));
1140
- process.exit(1);
1141
- }
1142
- }
1143
- const token = await ensureCognitoToken();
1144
- // exec-submit is the raw fire-and-forget path — do NOT wrap with
1145
- // withRemoteHqDir here (callers that want the HQ cwd use `exec --async`
1146
- // or prefix their own cd). Matches the existing contract.
1147
- const submitted = await submitExec(token, command, opts.id, opts.timeoutSeconds);
1148
- const output = {
1149
- commandId: submitted.commandId,
1150
- ...(submitted.executionTimeoutSeconds !== undefined
1151
- ? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
1152
- : opts.timeoutSeconds !== undefined
1153
- ? { executionTimeoutSeconds: opts.timeoutSeconds }
1154
- : {}),
1155
- };
1156
- if (opts.json) {
1157
- process.stdout.write(JSON.stringify(output) + "\n");
1158
- }
1159
- else {
1160
- printKeyValues(output);
1161
- }
1162
- }
1163
- catch (err) {
1164
- fail(err);
1165
- }
1166
- });
1167
- outposts
1168
- .command("exec-result")
1169
- .description("Fetch the result of an asynchronous Outpost command")
1170
- .requiredOption("--command-id <commandId>", "Command id returned by exec-submit")
1171
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1172
- .option("--wait", "Poll until the command reaches a terminal state")
1173
- .option("--json", "Emit raw JSON")
1174
- .action(async function (opts) {
1175
- try {
1176
- const token = await ensureCognitoToken();
1177
- const result = opts.wait
1178
- ? await waitForExecResult(token, opts.commandId, opts.id)
1179
- : await fetchExecResult(token, opts.commandId, opts.id);
1180
- const output = {
1181
- done: result.done,
1182
- status: result.status,
1183
- exitCode: result.exitCode ?? null,
1184
- stdout: result.stdout ?? "",
1185
- stderr: result.stderr ?? "",
1186
- };
1187
- if (opts.json) {
1188
- process.stdout.write(JSON.stringify(output) + "\n");
1189
- }
1190
- else {
1191
- printKeyValues(output);
1192
- }
1193
- }
1194
- catch (err) {
1195
- fail(err);
1196
- }
1197
- });
1198
- outposts
1199
- .command("codex-enable")
1200
- .description("Enable (or retry) Codex on an Outpost")
1201
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1202
- .option("--json", "Emit raw JSON")
1203
- .action(async function (opts) {
1204
- try {
1205
- const token = await ensureCognitoToken();
1206
- const result = await enableCodex(token, opts.id);
1207
- if (opts.json) {
1208
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1209
- return;
1210
- }
1211
- console.log(chalk.green("Codex enablement requested for the Outpost."));
1212
- }
1213
- catch (err) {
1214
- fail(err);
1215
- }
1216
- });
1217
- outposts
1218
- .command("login")
1219
- .description("Request a fresh login URL for an Outpost")
1220
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1221
- .option("--json", "Emit raw JSON")
1222
- .action(async function (opts) {
1223
- try {
1224
- const token = await ensureCognitoToken();
1225
- const result = await regenerateLoginUrl(token, opts.id);
1226
- if (opts.json) {
1227
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1228
- return;
1229
- }
1230
- console.log(chalk.green("Login-URL regeneration requested. The box mints a fresh URL shortly."));
1231
- console.log(chalk.dim("Next: `hq outposts status" +
1232
- (opts.id ? ` --id ${opts.id}` : "") +
1233
- "` to read the login URL, open it and sign in, then paste the code back with " +
1234
- "`hq outposts login-code <code>" +
1235
- (opts.id ? ` --id ${opts.id}` : "") +
1236
- "`."));
1237
- }
1238
- catch (err) {
1239
- fail(err);
1240
- }
1241
- });
1242
- outposts
1243
- .command("login-code <code>")
1244
- .description("Submit the Claude sign-in code for an Outpost that is awaiting login")
1245
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1246
- .option("--json", "Emit raw JSON")
1247
- .action(async function (code, opts) {
1248
- try {
1249
- const trimmed = code.trim();
1250
- if (!trimmed) {
1251
- console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
1252
- process.exit(1);
1253
- }
1254
- const token = await ensureCognitoToken();
1255
- const result = await submitLoginCode(token, trimmed, opts.id);
1256
- if (opts.json) {
1257
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1258
- return;
1259
- }
1260
- console.log(chalk.green("Code submitted — the box will finish signing in shortly."));
1261
- console.log(chalk.dim("Track it: `hq outposts status" +
1262
- (opts.id ? ` --id ${opts.id}` : "") +
1263
- "` (it flips to `ready` once Claude auth completes)."));
1264
- }
1265
- catch (err) {
1266
- fail(err);
1267
- }
1268
- });
1269
- outposts
1270
- .command("destroy")
1271
- .description("Tear down (permanently destroy) an Outpost")
1272
- .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1273
- .option("--yes", "Confirm the irreversible teardown (required)")
1274
- .action(async function (opts) {
1275
- const target = opts.id ?? "your primary Outpost";
1276
- if (!opts.yes) {
1277
- console.error(chalk.yellow(`This will permanently destroy ${target} and delete its cloud resources. ` +
1278
- `This cannot be undone.\n` +
1279
- `Re-run with --yes to confirm: hq outposts destroy${opts.id ? ` --id ${opts.id}` : ""} --yes`));
1280
- process.exit(1);
1281
- }
1282
- try {
1283
- const token = await ensureCognitoToken();
1284
- await destroyOutpost(token, opts.id);
1285
- console.log(chalk.green(`Destroyed ${target}.`));
1286
- }
1287
- catch (err) {
1288
- // A 409 teardown-incomplete is not a failure — the gateway's 30s cap
1289
- // fired while the Lambda keeps working. The row is preserved and the
1290
- // operation is idempotent, so tell the caller to retry.
1291
- if (err instanceof OutpostHttpError &&
1292
- (err.status === 409 || err.step === "teardown-incomplete")) {
1293
- console.log(chalk.yellow(`Teardown still in progress for ${target} — this is expected for large boxes. ` +
1294
- `Re-run the same destroy command to finish (it's idempotent).`));
1295
- return;
1296
- }
1297
- fail(err);
1298
- }
28
+ registerOutpostsCommandFromCloud(program, {
29
+ transport: vaultApiFetch,
30
+ getToken: () => ensureCognitoToken(),
31
+ getTokenNonInteractive: () => ensureCognitoToken({ interactive: false }),
32
+ loadCachedTokens: () => loadCachedTokens() ?? undefined,
33
+ resolveCallerPersonUid,
34
+ defaultApiBaseUrl: DEFAULT_VAULT_API_URL,
35
+ host: selfDeployOverrides,
1299
36
  });
1300
37
  }
1301
38
  //# sourceMappingURL=outposts.js.map