@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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing gate for paid provisioning (agents & Outposts).
|
|
3
|
+
*
|
|
4
|
+
* hq-pro enforces billing server-side: a provision call for a resource whose
|
|
5
|
+
* payer has no card on file returns `402` with a `billing` payload carrying a
|
|
6
|
+
* `setup` action that names EXACTLY which checkout endpoint to hit. That
|
|
7
|
+
* endpoint returns `{ url }` — a Stripe-hosted card-capture link.
|
|
8
|
+
*
|
|
9
|
+
* This module wires the client-side experience around that contract:
|
|
10
|
+
* 1. `confirmChargeOrExit` — print the monthly cost and require `--yes`
|
|
11
|
+
* BEFORE any paid provisioning call is made.
|
|
12
|
+
* 2. `surfaceBillingRequired` — when the backend answers `402 billing_required`,
|
|
13
|
+
* mint the card-capture link and print a plain, shareable message instead
|
|
14
|
+
* of an opaque failure.
|
|
15
|
+
* 3. `hq billing checkout` (see commands/billing.ts) reuses `mintPaymentLink`
|
|
16
|
+
* to hand out the same link proactively.
|
|
17
|
+
*
|
|
18
|
+
* Prices mirror the console's authoritative constants
|
|
19
|
+
* (hq-console `src/lib/billing-pricing.ts`): amounts are in the smallest
|
|
20
|
+
* currency unit (US cents) to match Stripe's wire shape.
|
|
21
|
+
*/
|
|
22
|
+
/** Per-Agent monthly list price, in cents ($100.00 / agent / month). */
|
|
23
|
+
export declare const AGENT_PRICE_CENTS = 10000;
|
|
24
|
+
/** Per-Outpost monthly list price, in cents ($80.00 / Outpost / month). */
|
|
25
|
+
export declare const OUTPOST_PRICE_CENTS = 8000;
|
|
26
|
+
/** Render a cents amount as a `$X.YZ` USD string. */
|
|
27
|
+
export declare function formatUsd(cents: number): string;
|
|
28
|
+
/**
|
|
29
|
+
* The server-supplied "how to add a card" action on a `402 billing_required`.
|
|
30
|
+
* Mirrors hq-pro `src/billing/activation-billing.ts` `BillingSetupAction` and
|
|
31
|
+
* the console's `src/lib/billing-setup.ts`.
|
|
32
|
+
*/
|
|
33
|
+
export interface BillingSetupAction {
|
|
34
|
+
payerType: "company" | "person";
|
|
35
|
+
path: "/v1/billing/checkout/org" | "/v1/billing/checkout/person";
|
|
36
|
+
method: "POST";
|
|
37
|
+
body?: {
|
|
38
|
+
companyUid?: string;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** The `billing` envelope hq-pro attaches to a billing-blocked provision. */
|
|
42
|
+
export interface BillingErrorPayload {
|
|
43
|
+
status: string;
|
|
44
|
+
setup?: BillingSetupAction;
|
|
45
|
+
}
|
|
46
|
+
/** Narrow an unknown value to a usable `BillingSetupAction`. */
|
|
47
|
+
export declare function isBillingSetupAction(value: unknown): value is BillingSetupAction;
|
|
48
|
+
/** Pull a `BillingErrorPayload` out of a decoded hq-pro error body, if present. */
|
|
49
|
+
export declare function parseBillingPayload(body: unknown): BillingErrorPayload | undefined;
|
|
50
|
+
export type PaidResource = "agent" | "Outpost";
|
|
51
|
+
/**
|
|
52
|
+
* Gate a paid provisioning call behind an explicit, informed `--yes`. Prints the
|
|
53
|
+
* monthly charge in full prose (this is a paid, outward action — Auto-Clarity)
|
|
54
|
+
* and exits non-zero when the caller has not passed `--yes`. Returns normally
|
|
55
|
+
* once approved so the caller proceeds to provision.
|
|
56
|
+
*/
|
|
57
|
+
export declare function confirmChargeOrExit(opts: {
|
|
58
|
+
resource: PaidResource;
|
|
59
|
+
unitCents: number;
|
|
60
|
+
quantity?: number;
|
|
61
|
+
yes?: boolean;
|
|
62
|
+
}): void;
|
|
63
|
+
/**
|
|
64
|
+
* Mint a Stripe-hosted card-capture link for a `BillingSetupAction`. Returns the
|
|
65
|
+
* hosted URL the payer opens to add a card. Throws on a non-2xx (the caller
|
|
66
|
+
* decides how to surface it) — never swallows.
|
|
67
|
+
*/
|
|
68
|
+
export declare function mintPaymentLink(token: string, setup: BillingSetupAction): Promise<string>;
|
|
69
|
+
/**
|
|
70
|
+
* The provision was blocked because the payer has no card on file. Mint the
|
|
71
|
+
* card-capture link the backend pointed us at and print a plain, shareable
|
|
72
|
+
* message so the user can add a card (or hand the link to whoever owns billing)
|
|
73
|
+
* and re-run. Returns the URL, or `null` when the server gave no `setup` action
|
|
74
|
+
* to act on. Never prints tokens or secrets.
|
|
75
|
+
*/
|
|
76
|
+
export declare function surfaceBillingRequired(token: string, billing: BillingErrorPayload): Promise<string | null>;
|
|
77
|
+
//# sourceMappingURL=billing-gate.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing gate for paid provisioning (agents & Outposts).
|
|
3
|
+
*
|
|
4
|
+
* hq-pro enforces billing server-side: a provision call for a resource whose
|
|
5
|
+
* payer has no card on file returns `402` with a `billing` payload carrying a
|
|
6
|
+
* `setup` action that names EXACTLY which checkout endpoint to hit. That
|
|
7
|
+
* endpoint returns `{ url }` — a Stripe-hosted card-capture link.
|
|
8
|
+
*
|
|
9
|
+
* This module wires the client-side experience around that contract:
|
|
10
|
+
* 1. `confirmChargeOrExit` — print the monthly cost and require `--yes`
|
|
11
|
+
* BEFORE any paid provisioning call is made.
|
|
12
|
+
* 2. `surfaceBillingRequired` — when the backend answers `402 billing_required`,
|
|
13
|
+
* mint the card-capture link and print a plain, shareable message instead
|
|
14
|
+
* of an opaque failure.
|
|
15
|
+
* 3. `hq billing checkout` (see commands/billing.ts) reuses `mintPaymentLink`
|
|
16
|
+
* to hand out the same link proactively.
|
|
17
|
+
*
|
|
18
|
+
* Prices mirror the console's authoritative constants
|
|
19
|
+
* (hq-console `src/lib/billing-pricing.ts`): amounts are in the smallest
|
|
20
|
+
* currency unit (US cents) to match Stripe's wire shape.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="89efd1d4-af26-50e1-b94f-72b1b5533e3f")}catch(e){}}();
|
|
24
|
+
import chalk from "chalk";
|
|
25
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
26
|
+
/** Per-Agent monthly list price, in cents ($100.00 / agent / month). */
|
|
27
|
+
export const AGENT_PRICE_CENTS = 10_000;
|
|
28
|
+
/** Per-Outpost monthly list price, in cents ($80.00 / Outpost / month). */
|
|
29
|
+
export const OUTPOST_PRICE_CENTS = 8_000;
|
|
30
|
+
/** Render a cents amount as a `$X.YZ` USD string. */
|
|
31
|
+
export function formatUsd(cents) {
|
|
32
|
+
return `$${(Math.max(0, Math.round(cents)) / 100).toFixed(2)}`;
|
|
33
|
+
}
|
|
34
|
+
/** Narrow an unknown value to a usable `BillingSetupAction`. */
|
|
35
|
+
export function isBillingSetupAction(value) {
|
|
36
|
+
if (!value || typeof value !== "object")
|
|
37
|
+
return false;
|
|
38
|
+
const setup = value;
|
|
39
|
+
const body = setup.body;
|
|
40
|
+
return ((setup.payerType === "company" || setup.payerType === "person") &&
|
|
41
|
+
(setup.path === "/v1/billing/checkout/org" ||
|
|
42
|
+
setup.path === "/v1/billing/checkout/person") &&
|
|
43
|
+
setup.method === "POST" &&
|
|
44
|
+
(body === undefined || (typeof body === "object" && body !== null)));
|
|
45
|
+
}
|
|
46
|
+
/** Pull a `BillingErrorPayload` out of a decoded hq-pro error body, if present. */
|
|
47
|
+
export function parseBillingPayload(body) {
|
|
48
|
+
if (!body || typeof body !== "object")
|
|
49
|
+
return undefined;
|
|
50
|
+
const raw = body.billing;
|
|
51
|
+
if (!raw || typeof raw !== "object")
|
|
52
|
+
return undefined;
|
|
53
|
+
const status = raw.status;
|
|
54
|
+
if (typeof status !== "string")
|
|
55
|
+
return undefined;
|
|
56
|
+
const setup = raw.setup;
|
|
57
|
+
return {
|
|
58
|
+
status,
|
|
59
|
+
...(isBillingSetupAction(setup) ? { setup } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Gate a paid provisioning call behind an explicit, informed `--yes`. Prints the
|
|
64
|
+
* monthly charge in full prose (this is a paid, outward action — Auto-Clarity)
|
|
65
|
+
* and exits non-zero when the caller has not passed `--yes`. Returns normally
|
|
66
|
+
* once approved so the caller proceeds to provision.
|
|
67
|
+
*/
|
|
68
|
+
export function confirmChargeOrExit(opts) {
|
|
69
|
+
const quantity = opts.quantity ?? 1;
|
|
70
|
+
const total = opts.unitCents * quantity;
|
|
71
|
+
const each = quantity > 1 ? ` (${formatUsd(opts.unitCents)} each × ${quantity})` : "";
|
|
72
|
+
const noun = quantity > 1 ? `${opts.resource}s` : opts.resource;
|
|
73
|
+
const article = /^[aeiou]/i.test(opts.resource) ? "an" : "a";
|
|
74
|
+
const subject = quantity > 1 ? `${quantity} ${noun}` : `${article} ${noun}`;
|
|
75
|
+
if (!opts.yes) {
|
|
76
|
+
console.error(chalk.yellow(`Provisioning ${subject} adds a recurring charge of ` +
|
|
77
|
+
`${formatUsd(total)}/month${each} to the payer on file. This is a ` +
|
|
78
|
+
`paid resource.\n` +
|
|
79
|
+
`Re-run with --yes to confirm you want to incur this charge.`));
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
console.log(chalk.dim(`Confirmed: ${formatUsd(total)}/month for ${quantity} ${noun}. Provisioning…`));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Mint a Stripe-hosted card-capture link for a `BillingSetupAction`. Returns the
|
|
86
|
+
* hosted URL the payer opens to add a card. Throws on a non-2xx (the caller
|
|
87
|
+
* decides how to surface it) — never swallows.
|
|
88
|
+
*/
|
|
89
|
+
export async function mintPaymentLink(token, setup) {
|
|
90
|
+
const res = await vaultApiFetch({
|
|
91
|
+
token,
|
|
92
|
+
path: setup.path,
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: setup.body?.companyUid ? { companyUid: setup.body.companyUid } : {},
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
const body = (await res.json().catch(() => ({})));
|
|
98
|
+
throw new Error(`Failed to mint a payment link: ${body.error ?? body.message ?? res.statusText}`);
|
|
99
|
+
}
|
|
100
|
+
const data = (await res.json());
|
|
101
|
+
if (!data.url) {
|
|
102
|
+
throw new Error("Payment link response did not include a checkout URL.");
|
|
103
|
+
}
|
|
104
|
+
return data.url;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The provision was blocked because the payer has no card on file. Mint the
|
|
108
|
+
* card-capture link the backend pointed us at and print a plain, shareable
|
|
109
|
+
* message so the user can add a card (or hand the link to whoever owns billing)
|
|
110
|
+
* and re-run. Returns the URL, or `null` when the server gave no `setup` action
|
|
111
|
+
* to act on. Never prints tokens or secrets.
|
|
112
|
+
*/
|
|
113
|
+
export async function surfaceBillingRequired(token, billing) {
|
|
114
|
+
if (!billing.setup) {
|
|
115
|
+
console.error(chalk.yellow("Provisioning is blocked on billing, but the server didn't return a " +
|
|
116
|
+
"payment link. Add a card in the console billing page, then re-run."));
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const url = await mintPaymentLink(token, billing.setup);
|
|
120
|
+
console.log(chalk.yellow("No card on file — provisioning is blocked until one is added."));
|
|
121
|
+
console.log("Add a card here (safe to share with whoever owns billing):\n " +
|
|
122
|
+
chalk.cyan(url));
|
|
123
|
+
console.log(chalk.dim("Once a card is added, re-run the same command."));
|
|
124
|
+
return url;
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=billing-gate.js.map
|
|
127
|
+
//# debugId=89efd1d4-af26-50e1-b94f-72b1b5533e3f
|
package/package.json
CHANGED
|
@@ -295,3 +295,87 @@ describe("hq agents status", () => {
|
|
|
295
295
|
).rejects.toThrow("process.exit(1)");
|
|
296
296
|
});
|
|
297
297
|
});
|
|
298
|
+
|
|
299
|
+
describe("hq agents provision (billing gate)", () => {
|
|
300
|
+
it("refuses without --yes, prints the cost, and makes NO API call", async () => {
|
|
301
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
302
|
+
await expect(
|
|
303
|
+
run(["agents", "--company", "acme", "provision", "Ops Bot"]),
|
|
304
|
+
).rejects.toThrow("process.exit(1)");
|
|
305
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
306
|
+
const printed = errSpy.mock.calls
|
|
307
|
+
.map((c) => c.map(String).join(" "))
|
|
308
|
+
.join("\n");
|
|
309
|
+
expect(printed).toMatch(/\$100\.00\/month/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("POSTs /v1/agents with slugified name + idempotency key when --yes", async () => {
|
|
313
|
+
fetchSpy.mockResolvedValueOnce(
|
|
314
|
+
jsonResponse(200, { uid: "agt_new", slug: "ops-bot" }),
|
|
315
|
+
);
|
|
316
|
+
await run(["agents", "--company", "acme", "provision", "Ops Bot", "--yes"]);
|
|
317
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
318
|
+
expect(String(url)).toContain("/v1/agents");
|
|
319
|
+
expect(init?.method).toBe("POST");
|
|
320
|
+
const sent = JSON.parse(init?.body as string);
|
|
321
|
+
expect(sent.companyUid).toBe("cmp_acme");
|
|
322
|
+
expect(sent.name).toBe("Ops Bot");
|
|
323
|
+
expect(sent.slug).toBe("ops-bot");
|
|
324
|
+
expect(sent.codexAuthMode).toBe("subscription");
|
|
325
|
+
expect(typeof sent.idempotencyKey).toBe("string");
|
|
326
|
+
expect(sent.idempotencyKey.length).toBeGreaterThan(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("mints a card-capture link on 402 billing_required and exits 1", async () => {
|
|
330
|
+
fetchSpy
|
|
331
|
+
.mockResolvedValueOnce(
|
|
332
|
+
jsonResponse(402, {
|
|
333
|
+
error: "payment required",
|
|
334
|
+
code: "BILLING_REQUIRED",
|
|
335
|
+
billing: {
|
|
336
|
+
status: "billing_required",
|
|
337
|
+
setup: {
|
|
338
|
+
payerType: "company",
|
|
339
|
+
path: "/v1/billing/checkout/org",
|
|
340
|
+
method: "POST",
|
|
341
|
+
body: { companyUid: "cmp_acme" },
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
)
|
|
346
|
+
.mockResolvedValueOnce(
|
|
347
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/card" }),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const logSpyLocal = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
351
|
+
await expect(
|
|
352
|
+
run(["agents", "--company", "acme", "provision", "Ops Bot", "--yes"]),
|
|
353
|
+
).rejects.toThrow("process.exit(1)");
|
|
354
|
+
|
|
355
|
+
// Second call mints the checkout link the 402 pointed at.
|
|
356
|
+
expect(String(fetchSpy.mock.calls[1][0])).toContain(
|
|
357
|
+
"/v1/billing/checkout/org",
|
|
358
|
+
);
|
|
359
|
+
expect(JSON.parse(fetchSpy.mock.calls[1][1]?.body as string)).toEqual({
|
|
360
|
+
companyUid: "cmp_acme",
|
|
361
|
+
});
|
|
362
|
+
const printed = logSpyLocal.mock.calls.map((c) => String(c[0])).join("\n");
|
|
363
|
+
expect(printed).toContain("https://checkout.stripe.com/card");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("requires --api-key-env for --auth-mode apiKey (before any charge)", async () => {
|
|
367
|
+
await expect(
|
|
368
|
+
run([
|
|
369
|
+
"agents",
|
|
370
|
+
"--company",
|
|
371
|
+
"acme",
|
|
372
|
+
"provision",
|
|
373
|
+
"Ops Bot",
|
|
374
|
+
"--auth-mode",
|
|
375
|
+
"apiKey",
|
|
376
|
+
"--yes",
|
|
377
|
+
]),
|
|
378
|
+
).rejects.toThrow("process.exit(1)");
|
|
379
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
380
|
+
});
|
|
381
|
+
});
|
package/src/commands/agents.ts
CHANGED
|
@@ -24,8 +24,16 @@
|
|
|
24
24
|
|
|
25
25
|
import { Command } from "commander";
|
|
26
26
|
import chalk from "chalk";
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
27
28
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
28
29
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
30
|
+
import {
|
|
31
|
+
AGENT_PRICE_CENTS,
|
|
32
|
+
confirmChargeOrExit,
|
|
33
|
+
parseBillingPayload,
|
|
34
|
+
surfaceBillingRequired,
|
|
35
|
+
type BillingErrorPayload,
|
|
36
|
+
} from "../utils/billing-gate.js";
|
|
29
37
|
|
|
30
38
|
/** Reasoning-effort values hq-pro accepts on `runtime-config`. */
|
|
31
39
|
export const VALID_EFFORTS = new Set([
|
|
@@ -47,11 +55,19 @@ export const VALID_TIERS = new Set(["default", "priority"]);
|
|
|
47
55
|
export class AgentsHttpError extends Error {
|
|
48
56
|
status: number;
|
|
49
57
|
code?: string;
|
|
50
|
-
|
|
58
|
+
/** hq-pro's billing envelope on a `402 billing_required` provision block. */
|
|
59
|
+
billing?: BillingErrorPayload;
|
|
60
|
+
constructor(
|
|
61
|
+
status: number,
|
|
62
|
+
message: string,
|
|
63
|
+
code?: string,
|
|
64
|
+
billing?: BillingErrorPayload,
|
|
65
|
+
) {
|
|
51
66
|
super(message);
|
|
52
67
|
this.name = "AgentsHttpError";
|
|
53
68
|
this.status = status;
|
|
54
69
|
this.code = code;
|
|
70
|
+
this.billing = billing;
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
|
|
@@ -111,11 +127,45 @@ export async function agentsRequest<T>(opts: {
|
|
|
111
127
|
res.status,
|
|
112
128
|
body.error ?? body.message ?? res.statusText,
|
|
113
129
|
body.code,
|
|
130
|
+
parseBillingPayload(body),
|
|
114
131
|
);
|
|
115
132
|
}
|
|
116
133
|
return (await res.json()) as T;
|
|
117
134
|
}
|
|
118
135
|
|
|
136
|
+
/** Slugify an agent name into a lowercase, hyphen-separated slug. */
|
|
137
|
+
export function slugifyAgentName(name: string): string {
|
|
138
|
+
return name
|
|
139
|
+
.toLowerCase()
|
|
140
|
+
.trim()
|
|
141
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
142
|
+
.replace(/^-+|-+$/g, "");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ProvisionAgentInput {
|
|
146
|
+
companyUid: string;
|
|
147
|
+
name: string;
|
|
148
|
+
slug: string;
|
|
149
|
+
codexAuthMode: "subscription" | "apiKey";
|
|
150
|
+
provider?: "codex" | "grok";
|
|
151
|
+
codexApiKey?: string;
|
|
152
|
+
idempotencyKey: string;
|
|
153
|
+
title?: string;
|
|
154
|
+
description?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function provisionAgent(
|
|
158
|
+
token: string,
|
|
159
|
+
input: ProvisionAgentInput,
|
|
160
|
+
): Promise<{ uid?: string; slug?: string; [key: string]: unknown }> {
|
|
161
|
+
return agentsRequest({
|
|
162
|
+
token,
|
|
163
|
+
path: "/v1/agents",
|
|
164
|
+
method: "POST",
|
|
165
|
+
body: input as unknown as Record<string, unknown>,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
119
169
|
export async function listAgents(
|
|
120
170
|
token: string,
|
|
121
171
|
companyUid: string,
|
|
@@ -244,6 +294,112 @@ export function registerAgentsCommand(program: Command): void {
|
|
|
244
294
|
(sub.opts().company as string | undefined) ??
|
|
245
295
|
(agents.opts().company as string | undefined);
|
|
246
296
|
|
|
297
|
+
agents
|
|
298
|
+
.command("provision <name>")
|
|
299
|
+
.alias("new")
|
|
300
|
+
.description("Provision a new cloud agent ($100/month — requires --yes)")
|
|
301
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
302
|
+
.option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
|
|
303
|
+
.option("--provider <provider>", "Runtime: codex | grok (default codex)")
|
|
304
|
+
.option(
|
|
305
|
+
"--auth-mode <mode>",
|
|
306
|
+
"Codex auth: subscription | apiKey (default subscription)",
|
|
307
|
+
"subscription",
|
|
308
|
+
)
|
|
309
|
+
.option(
|
|
310
|
+
"--api-key-env <VAR>",
|
|
311
|
+
"Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)",
|
|
312
|
+
)
|
|
313
|
+
.option("--title <title>", "Org-chart job title")
|
|
314
|
+
.option("--description <text>", "Short description / bio")
|
|
315
|
+
.option("--yes", "Confirm the $100/month charge (required to provision)")
|
|
316
|
+
.action(async function (
|
|
317
|
+
this: Command,
|
|
318
|
+
name: string,
|
|
319
|
+
opts: {
|
|
320
|
+
slug?: string;
|
|
321
|
+
provider?: string;
|
|
322
|
+
authMode?: string;
|
|
323
|
+
apiKeyEnv?: string;
|
|
324
|
+
title?: string;
|
|
325
|
+
description?: string;
|
|
326
|
+
yes?: boolean;
|
|
327
|
+
},
|
|
328
|
+
) {
|
|
329
|
+
try {
|
|
330
|
+
const authMode = opts.authMode === "apiKey" ? "apiKey" : "subscription";
|
|
331
|
+
const provider =
|
|
332
|
+
opts.provider === "grok" ? "grok" : opts.provider ? "codex" : undefined;
|
|
333
|
+
|
|
334
|
+
// Resolve the API key from the environment (never from a flag value) when
|
|
335
|
+
// apiKey mode is requested — validated BEFORE the paid gate so we don't
|
|
336
|
+
// charge and then fail.
|
|
337
|
+
let codexApiKey: string | undefined;
|
|
338
|
+
if (authMode === "apiKey") {
|
|
339
|
+
const envVar = opts.apiKeyEnv;
|
|
340
|
+
if (!envVar) {
|
|
341
|
+
console.error(
|
|
342
|
+
chalk.red(
|
|
343
|
+
"--auth-mode apiKey requires --api-key-env <VAR> naming the env var that holds the key.",
|
|
344
|
+
),
|
|
345
|
+
);
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
codexApiKey = process.env[envVar];
|
|
349
|
+
if (!codexApiKey) {
|
|
350
|
+
console.error(
|
|
351
|
+
chalk.red(`Env var ${envVar} is empty or unset — no API key to use.`),
|
|
352
|
+
);
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const token = await ensureCognitoToken();
|
|
358
|
+
const companyUid = await getCompanyUid(token, companyOf(this));
|
|
359
|
+
|
|
360
|
+
// Paid gate: print the monthly cost and require --yes before any call.
|
|
361
|
+
confirmChargeOrExit({
|
|
362
|
+
resource: "agent",
|
|
363
|
+
unitCents: AGENT_PRICE_CENTS,
|
|
364
|
+
yes: opts.yes,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const slug = opts.slug ?? slugifyAgentName(name);
|
|
368
|
+
try {
|
|
369
|
+
const result = await provisionAgent(token, {
|
|
370
|
+
companyUid,
|
|
371
|
+
name,
|
|
372
|
+
slug,
|
|
373
|
+
codexAuthMode: authMode,
|
|
374
|
+
...(provider ? { provider } : {}),
|
|
375
|
+
...(codexApiKey ? { codexApiKey } : {}),
|
|
376
|
+
idempotencyKey: `hq-cli-${randomUUID()}`,
|
|
377
|
+
...(opts.title ? { title: opts.title } : {}),
|
|
378
|
+
...(opts.description ? { description: opts.description } : {}),
|
|
379
|
+
});
|
|
380
|
+
const uid = typeof result.uid === "string" ? result.uid : slug;
|
|
381
|
+
console.log(chalk.green(`Provisioning started for agent "${name}".`));
|
|
382
|
+
console.log(
|
|
383
|
+
chalk.dim(`Track setup: hq agents status ${uid} --company <slug>`),
|
|
384
|
+
);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// No card on file → surface the shareable payment link instead of an
|
|
387
|
+
// opaque 402, then exit non-zero so scripts can react.
|
|
388
|
+
if (
|
|
389
|
+
err instanceof AgentsHttpError &&
|
|
390
|
+
err.status === 402 &&
|
|
391
|
+
err.billing
|
|
392
|
+
) {
|
|
393
|
+
await surfaceBillingRequired(token, err.billing);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
} catch (err) {
|
|
399
|
+
fail(err);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
247
403
|
agents
|
|
248
404
|
.command("list")
|
|
249
405
|
.description("List the company's agents")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq billing` (billing.ts).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors company.test.ts: mock ensureCognitoToken + getCompanyUid, spy on
|
|
5
|
+
* global fetch, drive through a Commander program, assert the request shape and
|
|
6
|
+
* rendered output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import {
|
|
11
|
+
afterEach,
|
|
12
|
+
beforeEach,
|
|
13
|
+
describe,
|
|
14
|
+
expect,
|
|
15
|
+
it,
|
|
16
|
+
vi,
|
|
17
|
+
type MockInstance,
|
|
18
|
+
} from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("../utils/cognito-session.js", async (importOriginal) => {
|
|
21
|
+
const original =
|
|
22
|
+
await importOriginal<typeof import("../utils/cognito-session.js")>();
|
|
23
|
+
return {
|
|
24
|
+
...original,
|
|
25
|
+
ensureCognitoToken: vi.fn(async () => "test-token"),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
vi.mock("../utils/vault-api.js", async (importOriginal) => {
|
|
30
|
+
const original = await importOriginal<typeof import("../utils/vault-api.js")>();
|
|
31
|
+
return {
|
|
32
|
+
...original,
|
|
33
|
+
getCompanyUid: vi.fn(async () => "cmp_acme"),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
38
|
+
import { getCompanyUid } from "../utils/vault-api.js";
|
|
39
|
+
import { registerBillingCommand } from "./billing.js";
|
|
40
|
+
|
|
41
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
42
|
+
return new Response(JSON.stringify(body), {
|
|
43
|
+
status,
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
49
|
+
let logSpy: MockInstance<typeof console.log>;
|
|
50
|
+
let stdoutSpy: MockInstance<typeof process.stdout.write>;
|
|
51
|
+
const mockEnsureCognitoToken = vi.mocked(ensureCognitoToken);
|
|
52
|
+
const mockGetCompanyUid = vi.mocked(getCompanyUid);
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
57
|
+
mockEnsureCognitoToken.mockResolvedValue("test-token");
|
|
58
|
+
mockGetCompanyUid.mockResolvedValue("cmp_acme");
|
|
59
|
+
vi.spyOn(process, "exit").mockImplementation((code?: number) => {
|
|
60
|
+
throw new Error(`process.exit(${code})`);
|
|
61
|
+
});
|
|
62
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
63
|
+
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
64
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.restoreAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function buildProgram(): Command {
|
|
72
|
+
const program = new Command();
|
|
73
|
+
program.name("hq").exitOverride();
|
|
74
|
+
registerBillingCommand(program);
|
|
75
|
+
return program;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function run(args: string[]): Promise<void> {
|
|
79
|
+
await buildProgram().parseAsync(["node", "hq", ...args]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("hq billing status", () => {
|
|
83
|
+
it("GETs /v1/billing/summary and renders card-on-file", async () => {
|
|
84
|
+
fetchSpy.mockResolvedValueOnce(
|
|
85
|
+
jsonResponse(200, {
|
|
86
|
+
subscriptionStatus: "active",
|
|
87
|
+
defaultPaymentMethod: { present: true, brand: "visa", last4: "4242" },
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await run(["billing", "--company", "acme", "status"]);
|
|
92
|
+
|
|
93
|
+
const url = String(fetchSpy.mock.calls[0][0]);
|
|
94
|
+
expect(url).toContain("/v1/billing/summary");
|
|
95
|
+
expect(url).toContain("companyUid=cmp_acme");
|
|
96
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
97
|
+
expect(printed).toMatch(/active/);
|
|
98
|
+
expect(printed).toMatch(/visa/);
|
|
99
|
+
expect(printed).toMatch(/4242/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("flags no card on file", async () => {
|
|
103
|
+
fetchSpy.mockResolvedValueOnce(
|
|
104
|
+
jsonResponse(200, {
|
|
105
|
+
subscriptionStatus: null,
|
|
106
|
+
defaultPaymentMethod: { present: false, brand: null, last4: null },
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
await run(["billing", "--company", "acme", "status"]);
|
|
110
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
111
|
+
expect(printed).toMatch(/no card on file/i);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("hq billing checkout", () => {
|
|
116
|
+
it("POSTs /v1/billing/checkout/org with companyUid and prints the url", async () => {
|
|
117
|
+
fetchSpy.mockResolvedValueOnce(
|
|
118
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/abc" }),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
await run(["billing", "--company", "acme", "checkout"]);
|
|
122
|
+
|
|
123
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
124
|
+
expect(String(url)).toContain("/v1/billing/checkout/org");
|
|
125
|
+
expect(init?.method).toBe("POST");
|
|
126
|
+
expect(JSON.parse(init?.body as string)).toEqual({ companyUid: "cmp_acme" });
|
|
127
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
128
|
+
expect(printed).toContain("https://checkout.stripe.com/abc");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("POSTs /v1/billing/checkout/person with --personal", async () => {
|
|
132
|
+
fetchSpy.mockResolvedValueOnce(
|
|
133
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/xyz" }),
|
|
134
|
+
);
|
|
135
|
+
await run(["billing", "checkout", "--personal"]);
|
|
136
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
137
|
+
expect(String(url)).toContain("/v1/billing/checkout/person");
|
|
138
|
+
expect(init?.method).toBe("POST");
|
|
139
|
+
// Person checkout resolves the payer from the JWT — no companyUid resolution.
|
|
140
|
+
expect(mockGetCompanyUid).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("emits raw JSON with --json", async () => {
|
|
144
|
+
fetchSpy.mockResolvedValueOnce(
|
|
145
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/j" }),
|
|
146
|
+
);
|
|
147
|
+
await run(["billing", "--company", "acme", "checkout", "--json"]);
|
|
148
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
149
|
+
expect(printed).toContain("https://checkout.stripe.com/j");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("exits 1 when the checkout response has no url", async () => {
|
|
153
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
154
|
+
await expect(
|
|
155
|
+
run(["billing", "--company", "acme", "checkout"]),
|
|
156
|
+
).rejects.toThrow("process.exit(1)");
|
|
157
|
+
});
|
|
158
|
+
});
|