@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.
@@ -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
- if (EMAIL_PATTERN.test(target)) {
131
- return { type: "email", value: target.trim().toLowerCase() };
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 (PERSON_UID_PATTERN.test(target)) {
134
- return { type: "person", value: target };
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 address or a personUid matching prs_<alphanumeric>`,
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
- if (options.groupIds && options.groupIds.length > 0 && detected.type === "person") {
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 by email or personUid (sends an invitation email by default)",
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 for the invitee: owner, admin, member, or guest",
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
- sendEmail: opts.sendEmail,
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
+ });
@@ -23,18 +23,34 @@
23
23
 
24
24
  import { Command } from "commander";
25
25
  import chalk from "chalk";
26
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
26
27
  import { ensureCognitoToken } from "../utils/cognito-session.js";
27
28
  import { vaultApiFetch } from "../utils/vault-api.js";
29
+ import {
30
+ OUTPOST_PRICE_CENTS,
31
+ confirmChargeOrExit,
32
+ parseBillingPayload,
33
+ surfaceBillingRequired,
34
+ type BillingErrorPayload,
35
+ } from "../utils/billing-gate.js";
28
36
 
29
37
  /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
30
38
  export class OutpostHttpError extends Error {
31
39
  status: number;
32
40
  step?: string;
33
- constructor(status: number, message: string, step?: string) {
41
+ /** hq-pro's billing envelope on a `402 billing_required` provision block. */
42
+ billing?: BillingErrorPayload;
43
+ constructor(
44
+ status: number,
45
+ message: string,
46
+ step?: string,
47
+ billing?: BillingErrorPayload,
48
+ ) {
34
49
  super(message);
35
50
  this.name = "OutpostHttpError";
36
51
  this.status = status;
37
52
  this.step = step;
53
+ this.billing = billing;
38
54
  }
39
55
  }
40
56
 
@@ -61,6 +77,7 @@ export async function outpostRequest<T>(opts: {
61
77
  token: string;
62
78
  path: string;
63
79
  method?: string;
80
+ body?: Record<string, unknown>;
64
81
  query?: Record<string, string>;
65
82
  }): Promise<T> {
66
83
  const res = await vaultApiFetch(opts);
@@ -76,11 +93,45 @@ export async function outpostRequest<T>(opts: {
76
93
  : typeof body.error === "string"
77
94
  ? body.error
78
95
  : res.statusText;
79
- throw new OutpostHttpError(res.status, message, body.step);
96
+ throw new OutpostHttpError(
97
+ res.status,
98
+ message,
99
+ body.step,
100
+ parseBillingPayload(body),
101
+ );
80
102
  }
81
103
  return (await res.json()) as T;
82
104
  }
83
105
 
106
+ /**
107
+ * Provision the caller's Outpost. Sends the cached Cognito refresh token so the
108
+ * box can authenticate AS the caller (the same body the console's
109
+ * `provisionMyOutpost` sends). Idempotent server-side: a caller already at their
110
+ * per-person cap gets their existing box back rather than a duplicate. The
111
+ * refresh token is sent over HTTPS and NEVER printed.
112
+ */
113
+ export async function provisionOutpost(
114
+ token: string,
115
+ input: {
116
+ refreshToken: string;
117
+ clientIp?: string;
118
+ diskSizeGb?: number;
119
+ agentRuntime?: "claude" | "codex";
120
+ },
121
+ ): Promise<Record<string, unknown>> {
122
+ return outpostRequest({
123
+ token,
124
+ path: "/outpost/provision",
125
+ method: "POST",
126
+ body: {
127
+ refreshToken: input.refreshToken,
128
+ ...(input.clientIp ? { clientIp: input.clientIp } : {}),
129
+ ...(input.diskSizeGb ? { diskSizeGb: input.diskSizeGb } : {}),
130
+ ...(input.agentRuntime ? { agentRuntime: input.agentRuntime } : {}),
131
+ },
132
+ });
133
+ }
134
+
84
135
  export async function listOutposts(token: string): Promise<OutpostSummary[]> {
85
136
  const data = await outpostRequest<{ outposts: OutpostSummary[] }>({
86
137
  token,
@@ -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
+ });