@indigoai-us/hq-cli 5.62.2 → 5.64.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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `hq billing` — inspect billing state and mint a card-capture link from the
3
+ * terminal. Backs the paid-provisioning gate (agents & Outposts): a user can
4
+ * check whether a card is on file, and proactively hand out the same Stripe
5
+ * hosted card-capture link that a `402 billing_required` would surface.
6
+ *
7
+ * Targets the hq-pro billing control plane on `DEFAULT_VAULT_API_URL` via the
8
+ * shared `vaultApiFetch` helper — the same routes the console billing surfaces
9
+ * call.
10
+ *
11
+ * Subcommands:
12
+ * hq billing status [--company <slug>] — subscription + card on file
13
+ * hq billing checkout [--company <slug>] [--personal] — mint a card-capture link
14
+ */
15
+
16
+ import { Command } from "commander";
17
+ import chalk from "chalk";
18
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
19
+ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
20
+ import { mintPaymentLink, type BillingSetupAction } from "../utils/billing-gate.js";
21
+
22
+ /** Shape of `GET /v1/billing/summary` we render. */
23
+ interface BillingSummary {
24
+ subscriptionStatus: string | null;
25
+ defaultPaymentMethod: {
26
+ present: boolean;
27
+ brand: string | null;
28
+ last4: string | null;
29
+ };
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ function fail(err: unknown): never {
34
+ console.error(
35
+ chalk.red("Error:"),
36
+ err instanceof Error ? err.message : String(err),
37
+ );
38
+ process.exit(1);
39
+ }
40
+
41
+ async function getBillingSummary(
42
+ token: string,
43
+ companyUid: string,
44
+ ): Promise<BillingSummary> {
45
+ const res = await vaultApiFetch({
46
+ token,
47
+ path: "/v1/billing/summary",
48
+ query: { companyUid },
49
+ });
50
+ if (!res.ok) {
51
+ const body = (await res.json().catch(() => ({}))) as {
52
+ error?: string;
53
+ message?: string;
54
+ };
55
+ throw new Error(body.error ?? body.message ?? res.statusText);
56
+ }
57
+ return (await res.json()) as BillingSummary;
58
+ }
59
+
60
+ export function registerBillingCommand(program: Command): void {
61
+ const billing = program
62
+ .command("billing")
63
+ .description("Inspect billing and mint a card-capture link")
64
+ .option("--company <slug>", "Company slug (resolves to companyUid)");
65
+
66
+ const companyOf = (sub: Command): string | undefined =>
67
+ (sub.opts().company as string | undefined) ??
68
+ (billing.opts().company as string | undefined);
69
+
70
+ billing
71
+ .command("status")
72
+ .description("Show subscription status and whether a card is on file")
73
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
74
+ .option("--json", "Emit raw JSON")
75
+ .action(async function (this: Command, opts: { json?: boolean }) {
76
+ try {
77
+ const token = await ensureCognitoToken();
78
+ const companyUid = await getCompanyUid(token, companyOf(this));
79
+ const summary = await getBillingSummary(token, companyUid);
80
+ if (opts.json) {
81
+ process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
82
+ return;
83
+ }
84
+ const sub = summary.subscriptionStatus ?? "none";
85
+ console.log(`${chalk.bold("Subscription")}: ${sub}`);
86
+ const pm = summary.defaultPaymentMethod;
87
+ if (pm?.present) {
88
+ const detail =
89
+ pm.brand && pm.last4 ? `${pm.brand} ••••${pm.last4}` : "on file";
90
+ console.log(`${chalk.bold("Card")}: ${detail}`);
91
+ } else {
92
+ console.log(
93
+ `${chalk.bold("Card")}: ${chalk.yellow("no card on file")} — ` +
94
+ `run \`hq billing checkout\` to add one before provisioning paid resources.`,
95
+ );
96
+ }
97
+ } catch (err) {
98
+ fail(err);
99
+ }
100
+ });
101
+
102
+ billing
103
+ .command("checkout")
104
+ .description("Mint a Stripe card-capture link (shareable) to add a card")
105
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
106
+ .option(
107
+ "--personal",
108
+ "Mint the link for your personal payer (Outposts) instead of a company",
109
+ )
110
+ .option("--json", "Emit raw JSON")
111
+ .action(async function (
112
+ this: Command,
113
+ opts: { personal?: boolean; json?: boolean },
114
+ ) {
115
+ try {
116
+ const token = await ensureCognitoToken();
117
+ let setup: BillingSetupAction;
118
+ if (opts.personal) {
119
+ setup = {
120
+ payerType: "person",
121
+ path: "/v1/billing/checkout/person",
122
+ method: "POST",
123
+ };
124
+ } else {
125
+ const companyUid = await getCompanyUid(token, companyOf(this));
126
+ setup = {
127
+ payerType: "company",
128
+ path: "/v1/billing/checkout/org",
129
+ method: "POST",
130
+ body: { companyUid },
131
+ };
132
+ }
133
+ const url = await mintPaymentLink(token, setup);
134
+ if (opts.json) {
135
+ process.stdout.write(JSON.stringify({ url }, null, 2) + "\n");
136
+ return;
137
+ }
138
+ console.log(
139
+ "Add a card here (safe to share with whoever owns billing):\n " +
140
+ chalk.cyan(url),
141
+ );
142
+ } catch (err) {
143
+ fail(err);
144
+ }
145
+ });
146
+ }
@@ -1194,6 +1194,24 @@ describe("registerMembersCommand promote", () => {
1194
1194
  // ---------------------------------------------------------------------------
1195
1195
 
1196
1196
  describe("resolveRevokeTargetToMembershipKey", () => {
1197
+ it("wraps fleet agent uids as agt_…#companyUid", () => {
1198
+ expect(
1199
+ resolveRevokeTargetToMembershipKey(
1200
+ "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
1201
+ "cmp_abc",
1202
+ ),
1203
+ ).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_abc");
1204
+ });
1205
+
1206
+ it("maps agent machine emails to agt_…#companyUid (active guest memberships)", () => {
1207
+ expect(
1208
+ resolveRevokeTargetToMembershipKey(
1209
+ "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
1210
+ "cmp_abc",
1211
+ ),
1212
+ ).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_abc");
1213
+ });
1214
+
1197
1215
  // Regression: `hq members revoke alice@example.com` used to send the raw
1198
1216
  // email straight to /membership/revoke, which the server rejects with 404
1199
1217
  // "Invite not found" because it keys on `email:<email>#<companyUid>`. Live
@@ -494,9 +494,16 @@ export function resolveRevokeTargetToMembershipKey(
494
494
  if (arg.includes("#")) return arg;
495
495
  const detected = detectTarget(arg);
496
496
  if (detected?.type === "email") {
497
+ // Active agent guest memberships are personUid-keyed (agt_…#cmp_…), not
498
+ // email-keyed. Map machine emails to that key so revoke works after invite.
499
+ if (detected.isAgent) {
500
+ const local = detected.value.split("@")[0] ?? "";
501
+ const m = local.match(/^agt-(.+)$/i);
502
+ if (m) return `agt_${m[1].toUpperCase()}#${companyUid}`;
503
+ }
497
504
  return `email:${detected.value}#${companyUid}`;
498
505
  }
499
- if (detected?.type === "person") {
506
+ if (detected?.type === "person" || detected?.type === "agent") {
500
507
  return `${detected.value}#${companyUid}`;
501
508
  }
502
509
  return arg;
@@ -27,6 +27,21 @@ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
27
27
  };
28
28
  });
29
29
 
30
+ // Outpost provision reads the cached refresh token to send to the box. Stub the
31
+ // hq-cloud token store so tests don't need a real session on disk.
32
+ vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
33
+ const original =
34
+ await importOriginal<typeof import("@indigoai-us/hq-cloud")>();
35
+ return {
36
+ ...original,
37
+ loadCachedTokens: vi.fn(() => ({
38
+ refreshToken: "rt_test",
39
+ idToken: "id_test",
40
+ accessToken: "at_test",
41
+ })),
42
+ };
43
+ });
44
+
30
45
  import { ensureCognitoToken } from "../utils/cognito-session.js";
31
46
  import { registerOutpostsCommand } from "./outposts.js";
32
47
 
@@ -175,3 +190,167 @@ describe("hq outposts destroy", () => {
175
190
  ).rejects.toThrow("process.exit(1)");
176
191
  });
177
192
  });
193
+
194
+ describe("hq outposts provision (billing gate)", () => {
195
+ it("refuses without --yes, prints the cost, and makes NO API call", async () => {
196
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
197
+ await expect(run(["outposts", "provision"])).rejects.toThrow(
198
+ "process.exit(1)",
199
+ );
200
+ expect(fetchSpy).not.toHaveBeenCalled();
201
+ const printed = errSpy.mock.calls
202
+ .map((c) => c.map(String).join(" "))
203
+ .join("\n");
204
+ expect(printed).toMatch(/\$80\.00\/month/);
205
+ });
206
+
207
+ it("POSTs /outpost/provision with the refresh token when --yes", async () => {
208
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true }));
209
+ await run(["outposts", "provision", "--runtime", "codex", "--yes"]);
210
+ const [url, init] = fetchSpy.mock.calls[0];
211
+ expect(String(url)).toContain("/outpost/provision");
212
+ expect(init?.method).toBe("POST");
213
+ const sent = JSON.parse(init?.body as string);
214
+ expect(sent.refreshToken).toBe("rt_test");
215
+ expect(sent.agentRuntime).toBe("codex");
216
+ });
217
+
218
+ it("mints a card-capture link on 402 billing_required and exits 1", async () => {
219
+ fetchSpy
220
+ .mockResolvedValueOnce(
221
+ jsonResponse(402, {
222
+ error: "payment required",
223
+ billing: {
224
+ status: "billing_required",
225
+ setup: {
226
+ payerType: "person",
227
+ path: "/v1/billing/checkout/person",
228
+ method: "POST",
229
+ },
230
+ },
231
+ }),
232
+ )
233
+ .mockResolvedValueOnce(
234
+ jsonResponse(200, { url: "https://checkout.stripe.com/outpost" }),
235
+ );
236
+
237
+ await expect(
238
+ run(["outposts", "provision", "--yes"]),
239
+ ).rejects.toThrow("process.exit(1)");
240
+
241
+ expect(String(fetchSpy.mock.calls[1][0])).toContain(
242
+ "/v1/billing/checkout/person",
243
+ );
244
+ const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
245
+ expect(printed).toContain("https://checkout.stripe.com/outpost");
246
+ });
247
+ });
248
+
249
+ describe("hq outposts exec", () => {
250
+ afterEach(() => {
251
+ process.exitCode = undefined;
252
+ });
253
+
254
+ it("POSTs /outpost/exec with the joined command and streams stdout", async () => {
255
+ const stdoutSpy = vi
256
+ .spyOn(process.stdout, "write")
257
+ .mockImplementation(() => true);
258
+ fetchSpy.mockResolvedValueOnce(
259
+ jsonResponse(200, {
260
+ ok: true,
261
+ outpostId: "primary",
262
+ instanceId: "i-abc",
263
+ commandId: "cmd-1",
264
+ status: "Success",
265
+ exitCode: 0,
266
+ stdout: "hello world\n",
267
+ stderr: "",
268
+ truncated: false,
269
+ }),
270
+ );
271
+
272
+ await run(["outposts", "exec", "echo", "hello", "world"]);
273
+
274
+ const [url, init] = fetchSpy.mock.calls[0];
275
+ expect(String(url)).toContain("/outpost/exec");
276
+ expect(init?.method).toBe("POST");
277
+ expect(JSON.parse(init?.body as string)).toEqual({ command: "echo hello world" });
278
+ const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
279
+ expect(printed).toContain("hello world\n");
280
+ expect(process.exitCode).toBe(0);
281
+ });
282
+
283
+ it("propagates a non-zero remote exit code", async () => {
284
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
285
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
286
+ fetchSpy.mockResolvedValueOnce(
287
+ jsonResponse(200, {
288
+ ok: true,
289
+ outpostId: "primary",
290
+ instanceId: "i-abc",
291
+ commandId: "cmd-2",
292
+ status: "Failed",
293
+ exitCode: 2,
294
+ stdout: "",
295
+ stderr: "nope\n",
296
+ truncated: false,
297
+ }),
298
+ );
299
+ await run(["outposts", "exec", "exit", "2"]);
300
+ expect(process.exitCode).toBe(2);
301
+ });
302
+
303
+ it("passes --id through as the outpostId query param", async () => {
304
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
305
+ fetchSpy.mockResolvedValueOnce(
306
+ jsonResponse(200, {
307
+ ok: true,
308
+ outpostId: "2",
309
+ instanceId: "i-2",
310
+ commandId: "c",
311
+ status: "Success",
312
+ exitCode: 0,
313
+ stdout: "",
314
+ stderr: "",
315
+ truncated: false,
316
+ }),
317
+ );
318
+ await run(["outposts", "exec", "--id", "2", "uptime"]);
319
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("outpostId=2");
320
+ });
321
+
322
+ it("exits 1 and surfaces the server message on a non-2xx (e.g. not-ready)", async () => {
323
+ fetchSpy.mockResolvedValueOnce(
324
+ jsonResponse(409, {
325
+ error: true,
326
+ step: "not-ready",
327
+ message: 'outpost is "provisioning", not ready — it must be running to exec',
328
+ }),
329
+ );
330
+ await expect(
331
+ run(["outposts", "exec", "echo", "hi"]),
332
+ ).rejects.toThrow("process.exit(1)");
333
+ });
334
+
335
+ it("emits raw JSON with --json", async () => {
336
+ const stdoutSpy = vi
337
+ .spyOn(process.stdout, "write")
338
+ .mockImplementation(() => true);
339
+ fetchSpy.mockResolvedValueOnce(
340
+ jsonResponse(200, {
341
+ ok: true,
342
+ outpostId: "primary",
343
+ instanceId: "i-abc",
344
+ commandId: "cmd-3",
345
+ status: "Success",
346
+ exitCode: 0,
347
+ stdout: "x\n",
348
+ stderr: "",
349
+ truncated: false,
350
+ }),
351
+ );
352
+ await run(["outposts", "exec", "--json", "echo", "x"]);
353
+ const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
354
+ expect(printed).toContain('"commandId": "cmd-3"');
355
+ });
356
+ });
@@ -23,18 +23,34 @@
23
23
 
24
24
  import { Command } from "commander";
25
25
  import chalk from "chalk";
26
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
26
27
  import { ensureCognitoToken } from "../utils/cognito-session.js";
27
28
  import { vaultApiFetch } from "../utils/vault-api.js";
29
+ import {
30
+ OUTPOST_PRICE_CENTS,
31
+ confirmChargeOrExit,
32
+ parseBillingPayload,
33
+ surfaceBillingRequired,
34
+ type BillingErrorPayload,
35
+ } from "../utils/billing-gate.js";
28
36
 
29
37
  /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
30
38
  export class OutpostHttpError extends Error {
31
39
  status: number;
32
40
  step?: string;
33
- constructor(status: number, message: string, step?: string) {
41
+ /** hq-pro's billing envelope on a `402 billing_required` provision block. */
42
+ billing?: BillingErrorPayload;
43
+ constructor(
44
+ status: number,
45
+ message: string,
46
+ step?: string,
47
+ billing?: BillingErrorPayload,
48
+ ) {
34
49
  super(message);
35
50
  this.name = "OutpostHttpError";
36
51
  this.status = status;
37
52
  this.step = step;
53
+ this.billing = billing;
38
54
  }
39
55
  }
40
56
 
@@ -61,6 +77,7 @@ export async function outpostRequest<T>(opts: {
61
77
  token: string;
62
78
  path: string;
63
79
  method?: string;
80
+ body?: Record<string, unknown>;
64
81
  query?: Record<string, string>;
65
82
  }): Promise<T> {
66
83
  const res = await vaultApiFetch(opts);
@@ -76,11 +93,45 @@ export async function outpostRequest<T>(opts: {
76
93
  : typeof body.error === "string"
77
94
  ? body.error
78
95
  : res.statusText;
79
- throw new OutpostHttpError(res.status, message, body.step);
96
+ throw new OutpostHttpError(
97
+ res.status,
98
+ message,
99
+ body.step,
100
+ parseBillingPayload(body),
101
+ );
80
102
  }
81
103
  return (await res.json()) as T;
82
104
  }
83
105
 
106
+ /**
107
+ * Provision the caller's Outpost. Sends the cached Cognito refresh token so the
108
+ * box can authenticate AS the caller (the same body the console's
109
+ * `provisionMyOutpost` sends). Idempotent server-side: a caller already at their
110
+ * per-person cap gets their existing box back rather than a duplicate. The
111
+ * refresh token is sent over HTTPS and NEVER printed.
112
+ */
113
+ export async function provisionOutpost(
114
+ token: string,
115
+ input: {
116
+ refreshToken: string;
117
+ clientIp?: string;
118
+ diskSizeGb?: number;
119
+ agentRuntime?: "claude" | "codex";
120
+ },
121
+ ): Promise<Record<string, unknown>> {
122
+ return outpostRequest({
123
+ token,
124
+ path: "/outpost/provision",
125
+ method: "POST",
126
+ body: {
127
+ refreshToken: input.refreshToken,
128
+ ...(input.clientIp ? { clientIp: input.clientIp } : {}),
129
+ ...(input.diskSizeGb ? { diskSizeGb: input.diskSizeGb } : {}),
130
+ ...(input.agentRuntime ? { agentRuntime: input.agentRuntime } : {}),
131
+ },
132
+ });
133
+ }
134
+
84
135
  export async function listOutposts(token: string): Promise<OutpostSummary[]> {
85
136
  const data = await outpostRequest<{ outposts: OutpostSummary[] }>({
86
137
  token,
@@ -136,6 +187,41 @@ export async function destroyOutpost(
136
187
  });
137
188
  }
138
189
 
190
+ /** Result of `POST /outpost/exec` — a terminal SSM invocation on the box. */
191
+ export interface OutpostExecResult {
192
+ ok: true;
193
+ outpostId: string;
194
+ instanceId: string;
195
+ commandId: string;
196
+ /** SSM invocation status (Success | Failed | Cancelled). */
197
+ status: string;
198
+ /** Remote process exit code, or null when SSM reported none. */
199
+ exitCode: number | null;
200
+ stdout: string;
201
+ stderr: string;
202
+ /** True when SSM clipped stdout/stderr at its inline output limit. */
203
+ truncated: boolean;
204
+ }
205
+
206
+ /**
207
+ * Run a one-shot shell command on the caller's Outpost via `POST /outpost/exec`
208
+ * (server-brokered SSM — no SSH). Throws `OutpostHttpError` on a non-2xx, whose
209
+ * `step` distinguishes not-ready / platform-unsupported / timeout / ssm.
210
+ */
211
+ export async function execOutpost(
212
+ token: string,
213
+ command: string,
214
+ outpostId?: string,
215
+ ): Promise<OutpostExecResult> {
216
+ return outpostRequest({
217
+ token,
218
+ path: "/outpost/exec",
219
+ method: "POST",
220
+ body: { command },
221
+ query: outpostId ? { outpostId } : undefined,
222
+ });
223
+ }
224
+
139
225
  // ---------------------------------------------------------------------------
140
226
  // Command registration
141
227
  // ---------------------------------------------------------------------------
@@ -165,6 +251,81 @@ export function registerOutpostsCommand(program: Command): void {
165
251
  .command("outposts")
166
252
  .description("Manage your personal HQ Outposts (EC2 boxes)");
167
253
 
254
+ outposts
255
+ .command("provision")
256
+ .alias("create")
257
+ .description("Provision a new Outpost ($80/month — requires --yes)")
258
+ .option("--runtime <runtime>", "Agent runtime: claude | codex (default claude)")
259
+ .option("--disk <gb>", "Root disk size in GB (EC2 only)")
260
+ .option(
261
+ "--client-ip <ip>",
262
+ "Your public IP for the box's SSH ingress (optional; server derives it otherwise)",
263
+ )
264
+ .option("--yes", "Confirm the $80/month charge (required to provision)")
265
+ .action(async function (
266
+ this: Command,
267
+ opts: { runtime?: string; disk?: string; clientIp?: string; yes?: boolean },
268
+ ) {
269
+ try {
270
+ const agentRuntime =
271
+ opts.runtime === "codex"
272
+ ? "codex"
273
+ : opts.runtime
274
+ ? "claude"
275
+ : undefined;
276
+ let diskSizeGb: number | undefined;
277
+ if (opts.disk !== undefined) {
278
+ diskSizeGb = Number(opts.disk);
279
+ if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
280
+ console.error(chalk.red(`Invalid --disk '${opts.disk}': must be a positive number of GB.`));
281
+ process.exit(1);
282
+ }
283
+ }
284
+
285
+ // Paid gate: print the monthly cost and require --yes before any call.
286
+ confirmChargeOrExit({
287
+ resource: "Outpost",
288
+ unitCents: OUTPOST_PRICE_CENTS,
289
+ yes: opts.yes,
290
+ });
291
+
292
+ // The box authenticates AS the caller using the cached refresh token —
293
+ // the same body the console sends. Never printed.
294
+ const refreshToken = loadCachedTokens()?.refreshToken;
295
+ if (!refreshToken) {
296
+ console.error(
297
+ chalk.red("No cached session found — run `hq login` first, then re-run."),
298
+ );
299
+ process.exit(1);
300
+ }
301
+
302
+ const token = await ensureCognitoToken();
303
+ try {
304
+ await provisionOutpost(token, {
305
+ refreshToken,
306
+ ...(opts.clientIp ? { clientIp: opts.clientIp } : {}),
307
+ ...(diskSizeGb ? { diskSizeGb } : {}),
308
+ ...(agentRuntime ? { agentRuntime } : {}),
309
+ });
310
+ console.log(chalk.green("Provisioning started for your Outpost."));
311
+ console.log(chalk.dim("Track it: hq outposts status"));
312
+ } catch (err) {
313
+ // No card on file → surface the shareable payment link, not an opaque 402.
314
+ if (
315
+ err instanceof OutpostHttpError &&
316
+ err.status === 402 &&
317
+ err.billing
318
+ ) {
319
+ await surfaceBillingRequired(token, err.billing);
320
+ process.exit(1);
321
+ }
322
+ throw err;
323
+ }
324
+ } catch (err) {
325
+ fail(err);
326
+ }
327
+ });
328
+
168
329
  outposts
169
330
  .command("list")
170
331
  .description("List every Outpost you own")
@@ -243,6 +404,51 @@ export function registerOutpostsCommand(program: Command): void {
243
404
  }
244
405
  });
245
406
 
407
+ outposts
408
+ .command("exec <command...>")
409
+ .description(
410
+ "Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command)",
411
+ )
412
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
413
+ .option("--json", "Emit raw JSON")
414
+ .action(async function (
415
+ this: Command,
416
+ commandParts: string[],
417
+ opts: { id?: string; json?: boolean },
418
+ ) {
419
+ try {
420
+ const command = commandParts.join(" ").trim();
421
+ if (!command) {
422
+ console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
423
+ process.exit(1);
424
+ }
425
+ const token = await ensureCognitoToken();
426
+ const result = await execOutpost(token, command, opts.id);
427
+
428
+ if (opts.json) {
429
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
430
+ } else {
431
+ // Stream the remote streams to ours so the command feels local, then
432
+ // exit with the remote exit code.
433
+ if (result.stdout) process.stdout.write(result.stdout);
434
+ if (result.stderr) process.stderr.write(result.stderr);
435
+ if (result.truncated) {
436
+ console.error(
437
+ chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"),
438
+ );
439
+ }
440
+ if (result.status !== "Success" && result.exitCode === null) {
441
+ console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
442
+ }
443
+ }
444
+ // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
445
+ process.exitCode =
446
+ typeof result.exitCode === "number" ? result.exitCode : 0;
447
+ } catch (err) {
448
+ fail(err);
449
+ }
450
+ });
451
+
246
452
  outposts
247
453
  .command("codex-enable")
248
454
  .description("Enable (or retry) Codex on an Outpost")
package/src/main.ts CHANGED
@@ -56,6 +56,7 @@ import { registerCrmCommand } from "./commands/crm.js";
56
56
  import { registerCompanyCommand } from "./commands/company.js";
57
57
  import { registerAgentsCommand } from "./commands/agents.js";
58
58
  import { registerOutpostsCommand } from "./commands/outposts.js";
59
+ import { registerBillingCommand } from "./commands/billing.js";
59
60
  import { registerDbCommand } from "./commands/db.js";
60
61
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
61
62
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
@@ -258,6 +259,11 @@ registerAgentsCommand(program);
258
259
  // /outpost/* control plane.
259
260
  registerOutpostsCommand(program);
260
261
 
262
+ // Billing (subcommand group — `hq billing …`). Check subscription/card state and
263
+ // mint a shareable Stripe card-capture link — the client side of the paid-
264
+ // provisioning gate for agents & Outposts.
265
+ registerBillingCommand(program);
266
+
261
267
  export async function runCli(): Promise<void> {
262
268
  try {
263
269
  Sentry.addBreadcrumb({