@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.
- package/CHANGELOG.md +23 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +42 -2
- package/dist/commands/pkg-install.js +39 -2
- package/dist/commands/run.js +6 -2
- package/dist/commands/secrets.js +120 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2256
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs-update-api-key.test.ts +0 -105
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -103
- package/src/commands/pkg-install.ts +0 -221
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -190
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2050
- package/src/commands/secrets.ts +0 -2249
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- package/vitest.e2e.config.ts +0 -7
package/src/commands/outposts.ts
DELETED
|
@@ -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
|
-
}
|