@indigoai-us/hq-cli 5.62.1 → 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/cloud.d.ts +11 -0
- package/dist/commands/cloud.js +40 -2
- package/dist/commands/members.d.ts +12 -1
- package/dist/commands/members.js +79 -12
- 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 +2 -2
- 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/cloud.pull-all.test.ts +53 -0
- package/src/commands/cloud.ts +53 -0
- package/src/commands/members.test.ts +78 -0
- package/src/commands/members.ts +108 -11
- 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
package/src/commands/members.ts
CHANGED
|
@@ -5,7 +5,13 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
|
5
5
|
|
|
6
6
|
const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
7
7
|
const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
|
|
8
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/i;
|
|
9
|
+
/** Fleet agent machine email: agt-…@agents.getindigo.ai */
|
|
10
|
+
const AGENT_EMAIL_PATTERN =
|
|
11
|
+
/^agt-[a-z0-9]+@agents\.getindigo\.ai$/i;
|
|
8
12
|
export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
|
|
13
|
+
/** Roles allowed when inviting an existing fleet agent into a company. */
|
|
14
|
+
export const AGENT_INVITE_ROLES = new Set(["admin", "member"]);
|
|
9
15
|
|
|
10
16
|
export type Role = "owner" | "admin" | "member" | "guest";
|
|
11
17
|
|
|
@@ -100,6 +106,13 @@ export interface InviteResult {
|
|
|
100
106
|
emailSkipped?: boolean;
|
|
101
107
|
/** Resend was attempted but failed — human-readable reason. */
|
|
102
108
|
emailError?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Present when hq-pro treated the target as a fleet agent (guest invite).
|
|
111
|
+
* Always `false` on the agent path — host keeps billing.
|
|
112
|
+
*/
|
|
113
|
+
activationBilled?: boolean;
|
|
114
|
+
/** Host company uid when the server returned an agent guest-invite result. */
|
|
115
|
+
hostCompanyUid?: string;
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
export interface ResendInviteOptions {
|
|
@@ -122,16 +135,29 @@ export interface ResendInviteResult {
|
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
export interface DetectedTarget {
|
|
125
|
-
type: "email" | "person";
|
|
138
|
+
type: "email" | "person" | "agent";
|
|
126
139
|
value: string;
|
|
140
|
+
/** True when the target is a fleet agent (uid or machine email). */
|
|
141
|
+
isAgent?: boolean;
|
|
127
142
|
}
|
|
128
143
|
|
|
129
144
|
export function detectTarget(target: string): DetectedTarget | null {
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
const trimmed = target.trim();
|
|
146
|
+
if (AGENT_UID_PATTERN.test(trimmed)) {
|
|
147
|
+
return { type: "agent", value: trimmed, isAgent: true };
|
|
148
|
+
}
|
|
149
|
+
if (AGENT_EMAIL_PATTERN.test(trimmed)) {
|
|
150
|
+
return {
|
|
151
|
+
type: "email",
|
|
152
|
+
value: trimmed.toLowerCase(),
|
|
153
|
+
isAgent: true,
|
|
154
|
+
};
|
|
132
155
|
}
|
|
133
|
-
if (
|
|
134
|
-
return { type: "
|
|
156
|
+
if (EMAIL_PATTERN.test(trimmed)) {
|
|
157
|
+
return { type: "email", value: trimmed.toLowerCase() };
|
|
158
|
+
}
|
|
159
|
+
if (PERSON_UID_PATTERN.test(trimmed)) {
|
|
160
|
+
return { type: "person", value: trimmed };
|
|
135
161
|
}
|
|
136
162
|
return null;
|
|
137
163
|
}
|
|
@@ -180,11 +206,35 @@ export async function inviteMember(
|
|
|
180
206
|
const detected = detectTarget(options.target);
|
|
181
207
|
if (!detected) {
|
|
182
208
|
throw new Error(
|
|
183
|
-
`Invalid target '${options.target}': must be an email
|
|
209
|
+
`Invalid target '${options.target}': must be an email, personUid (prs_…), agent uid (agt_…), or agent email (agt-…@agents.getindigo.ai)`,
|
|
184
210
|
);
|
|
185
211
|
}
|
|
186
212
|
|
|
187
|
-
|
|
213
|
+
// Fleet agents: same invite command as humans, but only member|admin.
|
|
214
|
+
// Host company keeps billing — server never double-bills on guest invite.
|
|
215
|
+
if (detected.isAgent) {
|
|
216
|
+
if (!AGENT_INVITE_ROLES.has(options.role)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Agents can only be invited as member or admin (got '${options.role}'). Host company keeps billing.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (options.paths) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"--paths / guest role is not valid for agents — invite as member or admin",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (options.groupIds && options.groupIds.length > 0) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"--groups is not supported on agent invites — share secrets after membership with `hq secrets share`",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
options.groupIds &&
|
|
235
|
+
options.groupIds.length > 0 &&
|
|
236
|
+
(detected.type === "person" || detected.type === "agent")
|
|
237
|
+
) {
|
|
188
238
|
throw new Error(
|
|
189
239
|
"--groups is only valid on email-keyed invites (server rejects personUid + groupIds with 400)",
|
|
190
240
|
);
|
|
@@ -236,6 +286,8 @@ export async function inviteMember(
|
|
|
236
286
|
emailSent?: boolean;
|
|
237
287
|
emailSkipped?: boolean;
|
|
238
288
|
emailError?: string;
|
|
289
|
+
activationBilled?: boolean;
|
|
290
|
+
hostCompanyUid?: string;
|
|
239
291
|
};
|
|
240
292
|
if (!data.membership) {
|
|
241
293
|
const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
|
|
@@ -267,6 +319,12 @@ export async function inviteMember(
|
|
|
267
319
|
? { emailSkipped: data.emailSkipped }
|
|
268
320
|
: {}),
|
|
269
321
|
...(data.emailError ? { emailError: data.emailError } : {}),
|
|
322
|
+
...(typeof data.activationBilled === "boolean"
|
|
323
|
+
? { activationBilled: data.activationBilled }
|
|
324
|
+
: {}),
|
|
325
|
+
...(typeof data.hostCompanyUid === "string"
|
|
326
|
+
? { hostCompanyUid: data.hostCompanyUid }
|
|
327
|
+
: {}),
|
|
270
328
|
};
|
|
271
329
|
}
|
|
272
330
|
|
|
@@ -436,9 +494,16 @@ export function resolveRevokeTargetToMembershipKey(
|
|
|
436
494
|
if (arg.includes("#")) return arg;
|
|
437
495
|
const detected = detectTarget(arg);
|
|
438
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
|
+
}
|
|
439
504
|
return `email:${detected.value}#${companyUid}`;
|
|
440
505
|
}
|
|
441
|
-
if (detected?.type === "person") {
|
|
506
|
+
if (detected?.type === "person" || detected?.type === "agent") {
|
|
442
507
|
return `${detected.value}#${companyUid}`;
|
|
443
508
|
}
|
|
444
509
|
return arg;
|
|
@@ -604,11 +669,11 @@ export function registerMembersCommand(program: Command): void {
|
|
|
604
669
|
members
|
|
605
670
|
.command("invite <target>")
|
|
606
671
|
.description(
|
|
607
|
-
"Invite a person to the company
|
|
672
|
+
"Invite a person or fleet agent to the company. People: email or prs_…. Agents: agt_… or agt-…@agents.getindigo.ai (member|admin; host keeps billing, no double charge).",
|
|
608
673
|
)
|
|
609
674
|
.option(
|
|
610
675
|
"--role <role>",
|
|
611
|
-
"Role
|
|
676
|
+
"Role: person → owner|admin|member|guest; agent → member|admin only",
|
|
612
677
|
"member",
|
|
613
678
|
)
|
|
614
679
|
.option(
|
|
@@ -681,17 +746,49 @@ export function registerMembersCommand(program: Command): void {
|
|
|
681
746
|
role: opts.role,
|
|
682
747
|
paths: opts.paths,
|
|
683
748
|
groupIds,
|
|
684
|
-
|
|
749
|
+
// Agents join actively — no Resend email.
|
|
750
|
+
sendEmail: detectTarget(target)?.isAgent ? false : opts.sendEmail,
|
|
685
751
|
companyUid,
|
|
686
752
|
callerUid,
|
|
687
753
|
token,
|
|
688
754
|
});
|
|
689
755
|
|
|
756
|
+
const agentTarget = detectTarget(target)?.isAgent === true;
|
|
690
757
|
console.log(
|
|
691
758
|
chalk.green(
|
|
692
759
|
`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`,
|
|
693
760
|
),
|
|
694
761
|
);
|
|
762
|
+
// Agent path is confirmed only when the server returned the guest-invite
|
|
763
|
+
// envelope (activationBilled present) AND status is active. A pending
|
|
764
|
+
// row means this hq-pro stage has not rolled the agent invite path yet —
|
|
765
|
+
// do NOT claim active join / no-double-bill.
|
|
766
|
+
if (
|
|
767
|
+
agentTarget &&
|
|
768
|
+
result.membership.status === "active" &&
|
|
769
|
+
typeof result.activationBilled === "boolean"
|
|
770
|
+
) {
|
|
771
|
+
console.log(
|
|
772
|
+
chalk.dim(
|
|
773
|
+
`Fleet agent: active membership granted immediately` +
|
|
774
|
+
(result.hostCompanyUid
|
|
775
|
+
? ` (host ${result.hostCompanyUid} keeps billing)`
|
|
776
|
+
: " (host keeps billing, no double charge)") +
|
|
777
|
+
`. Next: share secrets / vault paths as needed (\`hq secrets share\`, \`/new-agent\`).`,
|
|
778
|
+
),
|
|
779
|
+
);
|
|
780
|
+
console.log();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (agentTarget && result.membership.status !== "active") {
|
|
784
|
+
console.log(
|
|
785
|
+
chalk.yellow(
|
|
786
|
+
"⚠ Agent invite landed as a pending membership — this hq-pro stage may not yet have multi-company agent invite. Wait for prod deploy of the agent membership path, then re-invite (or revoke this pending row).",
|
|
787
|
+
),
|
|
788
|
+
);
|
|
789
|
+
console.log();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
695
792
|
console.log();
|
|
696
793
|
if (result.magicLink) {
|
|
697
794
|
// Legacy server schema — magic-link redemption.
|
|
@@ -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
|
+
});
|