@indigoai-us/hq-cli 5.62.2 → 5.63.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 +18 -1
- package/dist/commands/outposts.js +91 -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 +70 -0
- package/src/commands/outposts.ts +128 -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,58 @@ 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
|
+
});
|
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,
|
|
@@ -165,6 +216,81 @@ export function registerOutpostsCommand(program: Command): void {
|
|
|
165
216
|
.command("outposts")
|
|
166
217
|
.description("Manage your personal HQ Outposts (EC2 boxes)");
|
|
167
218
|
|
|
219
|
+
outposts
|
|
220
|
+
.command("provision")
|
|
221
|
+
.alias("create")
|
|
222
|
+
.description("Provision a new Outpost ($80/month — requires --yes)")
|
|
223
|
+
.option("--runtime <runtime>", "Agent runtime: claude | codex (default claude)")
|
|
224
|
+
.option("--disk <gb>", "Root disk size in GB (EC2 only)")
|
|
225
|
+
.option(
|
|
226
|
+
"--client-ip <ip>",
|
|
227
|
+
"Your public IP for the box's SSH ingress (optional; server derives it otherwise)",
|
|
228
|
+
)
|
|
229
|
+
.option("--yes", "Confirm the $80/month charge (required to provision)")
|
|
230
|
+
.action(async function (
|
|
231
|
+
this: Command,
|
|
232
|
+
opts: { runtime?: string; disk?: string; clientIp?: string; yes?: boolean },
|
|
233
|
+
) {
|
|
234
|
+
try {
|
|
235
|
+
const agentRuntime =
|
|
236
|
+
opts.runtime === "codex"
|
|
237
|
+
? "codex"
|
|
238
|
+
: opts.runtime
|
|
239
|
+
? "claude"
|
|
240
|
+
: undefined;
|
|
241
|
+
let diskSizeGb: number | undefined;
|
|
242
|
+
if (opts.disk !== undefined) {
|
|
243
|
+
diskSizeGb = Number(opts.disk);
|
|
244
|
+
if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
|
|
245
|
+
console.error(chalk.red(`Invalid --disk '${opts.disk}': must be a positive number of GB.`));
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Paid gate: print the monthly cost and require --yes before any call.
|
|
251
|
+
confirmChargeOrExit({
|
|
252
|
+
resource: "Outpost",
|
|
253
|
+
unitCents: OUTPOST_PRICE_CENTS,
|
|
254
|
+
yes: opts.yes,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// The box authenticates AS the caller using the cached refresh token —
|
|
258
|
+
// the same body the console sends. Never printed.
|
|
259
|
+
const refreshToken = loadCachedTokens()?.refreshToken;
|
|
260
|
+
if (!refreshToken) {
|
|
261
|
+
console.error(
|
|
262
|
+
chalk.red("No cached session found — run `hq login` first, then re-run."),
|
|
263
|
+
);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const token = await ensureCognitoToken();
|
|
268
|
+
try {
|
|
269
|
+
await provisionOutpost(token, {
|
|
270
|
+
refreshToken,
|
|
271
|
+
...(opts.clientIp ? { clientIp: opts.clientIp } : {}),
|
|
272
|
+
...(diskSizeGb ? { diskSizeGb } : {}),
|
|
273
|
+
...(agentRuntime ? { agentRuntime } : {}),
|
|
274
|
+
});
|
|
275
|
+
console.log(chalk.green("Provisioning started for your Outpost."));
|
|
276
|
+
console.log(chalk.dim("Track it: hq outposts status"));
|
|
277
|
+
} catch (err) {
|
|
278
|
+
// No card on file → surface the shareable payment link, not an opaque 402.
|
|
279
|
+
if (
|
|
280
|
+
err instanceof OutpostHttpError &&
|
|
281
|
+
err.status === 402 &&
|
|
282
|
+
err.billing
|
|
283
|
+
) {
|
|
284
|
+
await surfaceBillingRequired(token, err.billing);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
} catch (err) {
|
|
290
|
+
fail(err);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
168
294
|
outposts
|
|
169
295
|
.command("list")
|
|
170
296
|
.description("List every Outpost you own")
|
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({
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the billing-gate helpers (pure parsers + price formatting).
|
|
3
|
+
* The command-level flows are covered in commands/{agents,outposts,billing}.test.ts;
|
|
4
|
+
* this pins the trust-boundary parsing and the cost string.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
AGENT_PRICE_CENTS,
|
|
10
|
+
OUTPOST_PRICE_CENTS,
|
|
11
|
+
formatUsd,
|
|
12
|
+
isBillingSetupAction,
|
|
13
|
+
parseBillingPayload,
|
|
14
|
+
} from "./billing-gate.js";
|
|
15
|
+
|
|
16
|
+
describe("prices", () => {
|
|
17
|
+
it("match the console's authoritative constants", () => {
|
|
18
|
+
expect(AGENT_PRICE_CENTS).toBe(10_000);
|
|
19
|
+
expect(OUTPOST_PRICE_CENTS).toBe(8_000);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("formatUsd", () => {
|
|
24
|
+
it("renders cents as $X.YZ", () => {
|
|
25
|
+
expect(formatUsd(10_000)).toBe("$100.00");
|
|
26
|
+
expect(formatUsd(8_000)).toBe("$80.00");
|
|
27
|
+
expect(formatUsd(0)).toBe("$0.00");
|
|
28
|
+
});
|
|
29
|
+
it("clamps negatives to zero", () => {
|
|
30
|
+
expect(formatUsd(-500)).toBe("$0.00");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("isBillingSetupAction", () => {
|
|
35
|
+
it("accepts a valid org setup", () => {
|
|
36
|
+
expect(
|
|
37
|
+
isBillingSetupAction({
|
|
38
|
+
payerType: "company",
|
|
39
|
+
path: "/v1/billing/checkout/org",
|
|
40
|
+
method: "POST",
|
|
41
|
+
body: { companyUid: "cmp_x" },
|
|
42
|
+
}),
|
|
43
|
+
).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
it("accepts a valid person setup with no body", () => {
|
|
46
|
+
expect(
|
|
47
|
+
isBillingSetupAction({
|
|
48
|
+
payerType: "person",
|
|
49
|
+
path: "/v1/billing/checkout/person",
|
|
50
|
+
method: "POST",
|
|
51
|
+
}),
|
|
52
|
+
).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it("rejects unknown paths / shapes", () => {
|
|
55
|
+
expect(
|
|
56
|
+
isBillingSetupAction({
|
|
57
|
+
payerType: "company",
|
|
58
|
+
path: "/v1/billing/checkout/evil",
|
|
59
|
+
method: "POST",
|
|
60
|
+
}),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
expect(isBillingSetupAction(null)).toBe(false);
|
|
63
|
+
expect(isBillingSetupAction({ payerType: "company" })).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("parseBillingPayload", () => {
|
|
68
|
+
it("extracts status + setup from a 402 body", () => {
|
|
69
|
+
const parsed = parseBillingPayload({
|
|
70
|
+
error: "payment required",
|
|
71
|
+
billing: {
|
|
72
|
+
status: "billing_required",
|
|
73
|
+
setup: {
|
|
74
|
+
payerType: "company",
|
|
75
|
+
path: "/v1/billing/checkout/org",
|
|
76
|
+
method: "POST",
|
|
77
|
+
body: { companyUid: "cmp_x" },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
expect(parsed?.status).toBe("billing_required");
|
|
82
|
+
expect(parsed?.setup?.path).toBe("/v1/billing/checkout/org");
|
|
83
|
+
});
|
|
84
|
+
it("returns status with no setup when the setup is malformed", () => {
|
|
85
|
+
const parsed = parseBillingPayload({
|
|
86
|
+
billing: { status: "billing_required", setup: { bogus: true } },
|
|
87
|
+
});
|
|
88
|
+
expect(parsed?.status).toBe("billing_required");
|
|
89
|
+
expect(parsed?.setup).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
it("returns undefined for a non-billing error body", () => {
|
|
92
|
+
expect(parseBillingPayload({ error: "forbidden" })).toBeUndefined();
|
|
93
|
+
expect(parseBillingPayload(null)).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|