@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.
- package/dist/commands/agents.d.ts +22 -1
- package/dist/commands/agents.js +98 -4
- package/dist/commands/billing.d.ts +17 -0
- package/dist/commands/billing.js +114 -0
- package/dist/commands/members.js +11 -3
- package/dist/commands/outposts.d.ts +39 -1
- package/dist/commands/outposts.js +144 -4
- package/dist/main.js +7 -2
- package/dist/utils/billing-gate.d.ts +77 -0
- package/dist/utils/billing-gate.js +127 -0
- package/package.json +1 -1
- package/src/commands/agents.test.ts +84 -0
- package/src/commands/agents.ts +157 -1
- package/src/commands/billing.test.ts +158 -0
- package/src/commands/billing.ts +146 -0
- package/src/commands/members.test.ts +18 -0
- package/src/commands/members.ts +8 -1
- package/src/commands/outposts.test.ts +179 -0
- package/src/commands/outposts.ts +208 -2
- package/src/main.ts +6 -0
- package/src/utils/billing-gate.test.ts +95 -0
- package/src/utils/billing-gate.ts +182 -0
|
@@ -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
|
package/src/commands/members.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/commands/outposts.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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({
|