@indigoai-us/hq-cli 5.100.0 → 5.101.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/CHANGELOG.md +11 -0
- package/dist/commands/agents.d.ts +42 -0
- package/dist/commands/agents.js +142 -10
- package/dist/commands/db-provision.js +4 -0
- package/dist/lib/db/control-plane.js +10 -12
- package/dist/main.js +8 -0
- package/dist/utils/plan-gate-error.d.ts +30 -0
- package/dist/utils/plan-gate-error.js +93 -0
- package/dist/utils/vault-api.js +8 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.101.0] — 2026-08-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq agents provision` now offers the authoritative Basic, Power, and Dev box
|
|
10
|
+
sizes during interactive creation and accepts `--size basic|power|dev` for
|
|
11
|
+
automation. The CLI shows HQ Pro's company-specific monthly quote and
|
|
12
|
+
capacity before confirmation, carries the quote version into creation so a
|
|
13
|
+
stale price is refused, and preserves the server default when `--size` is
|
|
14
|
+
omitted.
|
|
15
|
+
|
|
5
16
|
## [5.100.0] — 2026-08-13
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -31,6 +31,8 @@ export declare const VALID_TIERS: Set<string>;
|
|
|
31
31
|
export declare const VALID_PROVIDERS: Set<string>;
|
|
32
32
|
/** Auth modes hq-pro accepts on `POST /v1/agents` (`CodexAuthMode`). */
|
|
33
33
|
export declare const VALID_AUTH_MODES: Set<string>;
|
|
34
|
+
/** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
|
|
35
|
+
export declare const VALID_AGENT_SIZE_KEYS: Set<string>;
|
|
34
36
|
/**
|
|
35
37
|
* Resolve a closed-set option value, or exit(1) with a message naming the
|
|
36
38
|
* offending input and the legal set.
|
|
@@ -132,7 +134,47 @@ export interface ProvisionAgentInput {
|
|
|
132
134
|
idempotencyKey: string;
|
|
133
135
|
title?: string;
|
|
134
136
|
description?: string;
|
|
137
|
+
/** Omitted to preserve hq-pro's existing default. */
|
|
138
|
+
desiredInstanceType?: string;
|
|
139
|
+
/** Server quote assertion; hq-pro re-prices and refuses a stale amount. */
|
|
140
|
+
quotedNetMonthlyCents?: number;
|
|
141
|
+
quoteCatalogVersion?: string;
|
|
135
142
|
}
|
|
143
|
+
export interface AgentCreateSizeOption {
|
|
144
|
+
key: "basic" | "power" | "dev";
|
|
145
|
+
productName: string;
|
|
146
|
+
instanceType: string;
|
|
147
|
+
listCents: number;
|
|
148
|
+
default: boolean;
|
|
149
|
+
selectable: boolean;
|
|
150
|
+
netMonthlyCents: number | null;
|
|
151
|
+
deltaCents: number | null;
|
|
152
|
+
unavailableReason: string | null;
|
|
153
|
+
notBilled: boolean;
|
|
154
|
+
lanes: number;
|
|
155
|
+
workers: number;
|
|
156
|
+
}
|
|
157
|
+
export interface AgentCreateOptionsView {
|
|
158
|
+
defaultInstanceType: string;
|
|
159
|
+
catalogVersion: string;
|
|
160
|
+
options: AgentCreateSizeOption[];
|
|
161
|
+
}
|
|
162
|
+
export type QuotedAgentCreateSizeOption = AgentCreateSizeOption & {
|
|
163
|
+
netMonthlyCents: number;
|
|
164
|
+
deltaCents: number;
|
|
165
|
+
};
|
|
166
|
+
/** Read hq-pro's company-specific creation prices and capacities. */
|
|
167
|
+
export declare function getAgentCreateOptions(token: string, companyUid: string, idempotencyKey?: string): Promise<AgentCreateOptionsView>;
|
|
168
|
+
/** Resolve a requested size from the server response, never from local prices. */
|
|
169
|
+
export declare function requireQuotedCreateSize(view: AgentCreateOptionsView, sizeKey: string): QuotedAgentCreateSizeOption;
|
|
170
|
+
/** Resolve hq-pro's default quote while leaving the POST default implicit. */
|
|
171
|
+
export declare function requireDefaultAgentCreateSize(view: AgentCreateOptionsView): QuotedAgentCreateSizeOption;
|
|
172
|
+
/** Human-facing quote summary sourced entirely from hq-pro. */
|
|
173
|
+
export declare function formatAgentCreateSize(option: AgentCreateSizeOption): string;
|
|
174
|
+
/** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
|
|
175
|
+
export declare function promptForAgentCreateSize(view: AgentCreateOptionsView): Promise<QuotedAgentCreateSizeOption>;
|
|
176
|
+
/** Confirm creation using the server quote, preserving $0 as a real answer. */
|
|
177
|
+
export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean): void;
|
|
136
178
|
export declare function provisionAgent(token: string, input: ProvisionAgentInput): Promise<{
|
|
137
179
|
uid?: string;
|
|
138
180
|
slug?: string;
|
package/dist/commands/agents.js
CHANGED
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import chalk from "chalk";
|
|
25
25
|
import { randomUUID } from "node:crypto";
|
|
26
|
+
import * as readline from "node:readline";
|
|
26
27
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
27
28
|
import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
|
|
28
29
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
29
|
-
import {
|
|
30
|
+
import { confirmChargeOrExit, formatUsd, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
|
|
30
31
|
/** Reasoning-effort values hq-pro accepts on `runtime-config`. */
|
|
31
32
|
export const VALID_EFFORTS = new Set([
|
|
32
33
|
"minimal",
|
|
@@ -41,6 +42,8 @@ export const VALID_TIERS = new Set(["default", "priority"]);
|
|
|
41
42
|
export const VALID_PROVIDERS = new Set(["codex", "grok", "claude"]);
|
|
42
43
|
/** Auth modes hq-pro accepts on `POST /v1/agents` (`CodexAuthMode`). */
|
|
43
44
|
export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
|
|
45
|
+
/** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
|
|
46
|
+
export const VALID_AGENT_SIZE_KEYS = new Set(["basic", "power", "dev"]);
|
|
44
47
|
/**
|
|
45
48
|
* Resolve a closed-set option value, or exit(1) with a message naming the
|
|
46
49
|
* offending input and the legal set.
|
|
@@ -137,6 +140,115 @@ export function slugifyAgentName(name) {
|
|
|
137
140
|
.replace(/[^a-z0-9]+/g, "-")
|
|
138
141
|
.replace(/^-+|-+$/g, "");
|
|
139
142
|
}
|
|
143
|
+
/** Read hq-pro's company-specific creation prices and capacities. */
|
|
144
|
+
export async function getAgentCreateOptions(token, companyUid, idempotencyKey) {
|
|
145
|
+
const raw = await agentsRequest({
|
|
146
|
+
token,
|
|
147
|
+
path: "/v1/agents/provision-options",
|
|
148
|
+
query: {
|
|
149
|
+
companyUid,
|
|
150
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
if (!raw || typeof raw !== "object") {
|
|
154
|
+
throw new Error("HQ Pro returned an invalid agent size catalog.");
|
|
155
|
+
}
|
|
156
|
+
const view = raw;
|
|
157
|
+
if (typeof view.catalogVersion !== "string" || !view.catalogVersion.trim()) {
|
|
158
|
+
throw new Error("HQ Pro did not return the catalog version required to protect this price quote.");
|
|
159
|
+
}
|
|
160
|
+
if (typeof view.defaultInstanceType !== "string" ||
|
|
161
|
+
!Array.isArray(view.options)) {
|
|
162
|
+
throw new Error("HQ Pro returned an invalid agent size catalog.");
|
|
163
|
+
}
|
|
164
|
+
return view;
|
|
165
|
+
}
|
|
166
|
+
/** Resolve a requested size from the server response, never from local prices. */
|
|
167
|
+
export function requireQuotedCreateSize(view, sizeKey) {
|
|
168
|
+
const option = view.options.find((candidate) => candidate.key === sizeKey);
|
|
169
|
+
if (!option) {
|
|
170
|
+
throw new Error(`HQ Pro did not return a quote for agent size ${sizeKey}.`);
|
|
171
|
+
}
|
|
172
|
+
if (!option.selectable ||
|
|
173
|
+
typeof option.productName !== "string" ||
|
|
174
|
+
!option.productName.trim() ||
|
|
175
|
+
typeof option.instanceType !== "string" ||
|
|
176
|
+
!option.instanceType.trim() ||
|
|
177
|
+
typeof option.notBilled !== "boolean" ||
|
|
178
|
+
option.netMonthlyCents === null ||
|
|
179
|
+
!Number.isSafeInteger(option.netMonthlyCents) ||
|
|
180
|
+
option.netMonthlyCents < 0 ||
|
|
181
|
+
option.deltaCents !== option.netMonthlyCents ||
|
|
182
|
+
(option.notBilled && option.netMonthlyCents !== 0) ||
|
|
183
|
+
!Number.isSafeInteger(option.lanes) ||
|
|
184
|
+
option.lanes < 0 ||
|
|
185
|
+
!Number.isSafeInteger(option.workers) ||
|
|
186
|
+
option.workers < 0) {
|
|
187
|
+
throw new Error(`Agent size ${option.productName} cannot be priced right now` +
|
|
188
|
+
(option.unavailableReason ? ` (${option.unavailableReason})` : "."));
|
|
189
|
+
}
|
|
190
|
+
return option;
|
|
191
|
+
}
|
|
192
|
+
/** Resolve hq-pro's default quote while leaving the POST default implicit. */
|
|
193
|
+
export function requireDefaultAgentCreateSize(view) {
|
|
194
|
+
const option = view.options.find((candidate) => candidate.default);
|
|
195
|
+
if (!option || option.instanceType !== view.defaultInstanceType) {
|
|
196
|
+
throw new Error("HQ Pro did not return a valid default agent size quote.");
|
|
197
|
+
}
|
|
198
|
+
return requireQuotedCreateSize(view, option.key);
|
|
199
|
+
}
|
|
200
|
+
/** Human-facing quote summary sourced entirely from hq-pro. */
|
|
201
|
+
export function formatAgentCreateSize(option) {
|
|
202
|
+
const cost = option.netMonthlyCents === null
|
|
203
|
+
? "price unavailable"
|
|
204
|
+
: `${formatUsd(option.netMonthlyCents)}/month`;
|
|
205
|
+
return (`${option.productName}: ${cost}; ` +
|
|
206
|
+
`${option.lanes} lanes; ${option.workers} workers`);
|
|
207
|
+
}
|
|
208
|
+
/** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
|
|
209
|
+
export async function promptForAgentCreateSize(view) {
|
|
210
|
+
for (const option of view.options) {
|
|
211
|
+
console.log(chalk.dim(formatAgentCreateSize(option) +
|
|
212
|
+
(option.selectable && option.netMonthlyCents !== null
|
|
213
|
+
? ""
|
|
214
|
+
: ` — unavailable${option.unavailableReason ? ` (${option.unavailableReason})` : ""}`)));
|
|
215
|
+
}
|
|
216
|
+
const selectable = view.options.filter((option) => option.selectable && option.netMonthlyCents !== null);
|
|
217
|
+
if (selectable.length === 0) {
|
|
218
|
+
throw new Error("HQ Pro could not price any agent size right now.");
|
|
219
|
+
}
|
|
220
|
+
const legal = selectable.map((option) => option.key).join(" | ");
|
|
221
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
222
|
+
try {
|
|
223
|
+
const answer = await new Promise((resolve) => rl.question(`Choose agent size (${legal}): `, resolve));
|
|
224
|
+
const key = canonicalizeOptionValue(answer, new Set(selectable.map((option) => option.key)));
|
|
225
|
+
if (!key) {
|
|
226
|
+
throw new Error(`Invalid agent size '${answer}': choose ${legal}.`);
|
|
227
|
+
}
|
|
228
|
+
return requireQuotedCreateSize(view, key);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
rl.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Confirm creation using the server quote, preserving $0 as a real answer. */
|
|
235
|
+
export function confirmAgentCreateQuoteOrExit(option, yes) {
|
|
236
|
+
if (option.notBilled || option.netMonthlyCents === 0) {
|
|
237
|
+
const message = `${option.productName} is ${formatUsd(0)}/month for this company — ` +
|
|
238
|
+
"there is no per-agent charge.";
|
|
239
|
+
if (!yes) {
|
|
240
|
+
console.error(chalk.yellow(`${message}\nRe-run with --yes to confirm agent provisioning.`));
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
console.log(chalk.dim(`${message} Provisioning…`));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
confirmChargeOrExit({
|
|
247
|
+
resource: "agent",
|
|
248
|
+
unitCents: option.netMonthlyCents,
|
|
249
|
+
yes,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
140
252
|
export async function provisionAgent(token, input) {
|
|
141
253
|
return agentsRequest({
|
|
142
254
|
token,
|
|
@@ -387,7 +499,7 @@ export function registerAgentsCommand(program) {
|
|
|
387
499
|
agents
|
|
388
500
|
.command("provision <name>")
|
|
389
501
|
.alias("new")
|
|
390
|
-
.description("Provision a new cloud agent (
|
|
502
|
+
.description("Provision a new cloud agent (company-specific monthly price shown before creation)")
|
|
391
503
|
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
392
504
|
.option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
|
|
393
505
|
.option("--provider <provider>", "Runtime: codex | grok | claude (default codex). claude is subscription-only")
|
|
@@ -395,7 +507,8 @@ export function registerAgentsCommand(program) {
|
|
|
395
507
|
.option("--api-key-env <VAR>", "Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)")
|
|
396
508
|
.option("--title <title>", "Org-chart job title")
|
|
397
509
|
.option("--description <text>", "Short description / bio")
|
|
398
|
-
.option("--
|
|
510
|
+
.option("--size <size>", "Agent box size: basic | power | dev (omitted keeps the current default)")
|
|
511
|
+
.option("--yes", "Confirm the quoted monthly cost and provision the agent")
|
|
399
512
|
.action(async function (name, opts) {
|
|
400
513
|
try {
|
|
401
514
|
// Both of these previously fell back to their default on an
|
|
@@ -430,12 +543,21 @@ export function registerAgentsCommand(program) {
|
|
|
430
543
|
}
|
|
431
544
|
const token = (await resolveVaultCredential()).token;
|
|
432
545
|
const companyUid = await getCompanyUid(token, companyOf(this));
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
546
|
+
const idempotencyKey = `hq-cli-${randomUUID()}`;
|
|
547
|
+
const sizeKey = parseEnumOption(opts.size, VALID_AGENT_SIZE_KEYS, "--size");
|
|
548
|
+
const shouldChooseInteractively = !sizeKey && process.stdin.isTTY === true;
|
|
549
|
+
const createOptions = await getAgentCreateOptions(token, companyUid, idempotencyKey);
|
|
550
|
+
const quotedSize = sizeKey
|
|
551
|
+
? requireQuotedCreateSize(createOptions, sizeKey)
|
|
552
|
+
: shouldChooseInteractively
|
|
553
|
+
? await promptForAgentCreateSize(createOptions)
|
|
554
|
+
: requireDefaultAgentCreateSize(createOptions);
|
|
555
|
+
if (!shouldChooseInteractively) {
|
|
556
|
+
console.log(chalk.dim(formatAgentCreateSize(quotedSize)));
|
|
557
|
+
}
|
|
558
|
+
// Every path confirms hq-pro's company-specific quote. Omission still
|
|
559
|
+
// leaves the POST size implicit, preserving the server-side default.
|
|
560
|
+
confirmAgentCreateQuoteOrExit(quotedSize, opts.yes);
|
|
439
561
|
const slug = opts.slug ?? slugifyAgentName(name);
|
|
440
562
|
try {
|
|
441
563
|
const result = await provisionAgent(token, {
|
|
@@ -445,9 +567,19 @@ export function registerAgentsCommand(program) {
|
|
|
445
567
|
codexAuthMode: authMode,
|
|
446
568
|
...(provider ? { provider } : {}),
|
|
447
569
|
...(codexApiKey ? { codexApiKey } : {}),
|
|
448
|
-
idempotencyKey
|
|
570
|
+
idempotencyKey,
|
|
449
571
|
...(opts.title ? { title: opts.title } : {}),
|
|
450
572
|
...(opts.description ? { description: opts.description } : {}),
|
|
573
|
+
// Leave hq-pro's current default implicit so a Team setup agent
|
|
574
|
+
// still goes through its atomic entitlement claim. Every current
|
|
575
|
+
// CLI path nevertheless echoes the displayed amount and catalog
|
|
576
|
+
// snapshot, so omitted --size cannot silently accept a price that
|
|
577
|
+
// changed between confirmation and creation.
|
|
578
|
+
...(quotedSize.default
|
|
579
|
+
? {}
|
|
580
|
+
: { desiredInstanceType: quotedSize.instanceType }),
|
|
581
|
+
quotedNetMonthlyCents: quotedSize.netMonthlyCents,
|
|
582
|
+
quoteCatalogVersion: createOptions.catalogVersion,
|
|
451
583
|
});
|
|
452
584
|
const uid = typeof result.uid === "string" ? result.uid : slug;
|
|
453
585
|
console.log(chalk.green(`Provisioning started for agent "${name}".`));
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { ControlPlaneDbClient } from "../lib/db/control-plane.js";
|
|
6
6
|
import { DEFAULT_VAULT_API_URL, ensureCognitoToken, } from "../utils/cognito-session.js";
|
|
7
|
+
import { isPlanGateError } from "../utils/plan-gate-error.js";
|
|
7
8
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
8
9
|
const defaultDeps = () => ({
|
|
9
10
|
async resolveCompany(slug) {
|
|
@@ -59,6 +60,9 @@ export function registerDbProvisionCommand(db, depsFactory = defaultDeps) {
|
|
|
59
60
|
console.log(`idempotent: ${result.idempotent ? "yes" : "no"}`);
|
|
60
61
|
}
|
|
61
62
|
catch (error) {
|
|
63
|
+
// Let main.ts render the shared, non-stacktrace plan-gate message.
|
|
64
|
+
if (isPlanGateError(error))
|
|
65
|
+
throw error;
|
|
62
66
|
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
63
67
|
const status = error.status;
|
|
64
68
|
if (status === 402 || /PLAN_REQUIRED|Team plan|\$500/i.test(msg)) {
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Control-plane client for remote vault DB (US-009).
|
|
3
|
-
* Injectable fetch for tests — never logs response bodies that might hold secrets.
|
|
4
|
-
*/
|
|
1
|
+
import { planGateErrorFromPayload } from "../../utils/plan-gate-error.js";
|
|
5
2
|
function assertNoPostgresUrl(label, text) {
|
|
6
3
|
if (/postgres:\/\//i.test(text) || /postgresql:\/\//i.test(text)) {
|
|
7
4
|
throw new Error(`${label}: control plane returned a connection string (refusing to surface)`);
|
|
@@ -30,21 +27,22 @@ export class ControlPlaneDbClient {
|
|
|
30
27
|
assertNoPostgresUrl("provision", text);
|
|
31
28
|
if (!res.ok) {
|
|
32
29
|
let msg = `provision failed (${res.status})`;
|
|
33
|
-
let
|
|
30
|
+
let payload;
|
|
34
31
|
try {
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
msg =
|
|
38
|
-
if (j.code)
|
|
39
|
-
code = j.code;
|
|
32
|
+
payload = JSON.parse(text);
|
|
33
|
+
if (typeof payload.error === "string")
|
|
34
|
+
msg = payload.error;
|
|
40
35
|
}
|
|
41
36
|
catch {
|
|
42
37
|
/* keep */
|
|
43
38
|
}
|
|
39
|
+
const planGate = planGateErrorFromPayload(res.status, payload);
|
|
40
|
+
if (planGate)
|
|
41
|
+
throw planGate;
|
|
44
42
|
const err = new Error(msg);
|
|
45
43
|
err.status = res.status;
|
|
46
|
-
if (code)
|
|
47
|
-
err.code = code;
|
|
44
|
+
if (typeof payload?.code === "string")
|
|
45
|
+
err.code = payload.code;
|
|
48
46
|
throw err;
|
|
49
47
|
}
|
|
50
48
|
return JSON.parse(text);
|
package/dist/main.js
CHANGED
|
@@ -70,6 +70,7 @@ import { isEpipe } from "./utils/epipe.js";
|
|
|
70
70
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
71
71
|
import { isAuthError } from "./utils/auth-error.js";
|
|
72
72
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
73
|
+
import { formatPlanGateError, isPlanGateError, } from "./utils/plan-gate-error.js";
|
|
73
74
|
import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
74
75
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
75
76
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
@@ -359,6 +360,13 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
|
|
|
359
360
|
deps.stderr.write(`hq: ${err.message}\n`);
|
|
360
361
|
deps.setExitCode(1);
|
|
361
362
|
}
|
|
363
|
+
else if (isPlanGateError(err)) {
|
|
364
|
+
// hq-pro's plan denials are expected product limits, never a CLI crash.
|
|
365
|
+
// The shared vault client has already decoded and typed the small safe
|
|
366
|
+
// envelope; do not expose its raw body or retry a rejected creation.
|
|
367
|
+
deps.stderr.write(`hq: ${formatPlanGateError(err)}\n`);
|
|
368
|
+
deps.setExitCode(1);
|
|
369
|
+
}
|
|
362
370
|
else if (isExpectedUserError(err)) {
|
|
363
371
|
// HQ-CLI-6: a user-facing, client-caused error (a non-owner running
|
|
364
372
|
// `hq integrations approve`, a stale queueId, a bad --args, an unknown
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberate subscription denial returned by hq-pro. Keeping its structured
|
|
3
|
+
* fields on a typed error lets the CLI boundary render one safe, consistent
|
|
4
|
+
* message instead of every command parsing and printing a server response.
|
|
5
|
+
*/
|
|
6
|
+
export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED";
|
|
7
|
+
export interface PlanGateDetails {
|
|
8
|
+
resource?: string;
|
|
9
|
+
used?: number;
|
|
10
|
+
limit?: number;
|
|
11
|
+
upgradeUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class PlanGateError extends Error {
|
|
14
|
+
readonly code: PlanGateCode;
|
|
15
|
+
readonly details: PlanGateDetails;
|
|
16
|
+
constructor(code: PlanGateCode, details: PlanGateDetails);
|
|
17
|
+
}
|
|
18
|
+
export declare function isPlanGateError(err: unknown): err is PlanGateError;
|
|
19
|
+
export declare function formatPlanGateError(err: PlanGateError): string;
|
|
20
|
+
/** Decode an already-read JSON envelope from either shared HTTP client. */
|
|
21
|
+
export declare function planGateErrorFromPayload(status: number, body: unknown): PlanGateError | null;
|
|
22
|
+
/**
|
|
23
|
+
* Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
|
|
24
|
+
* or non-402 responses retain their existing command-specific handling.
|
|
25
|
+
*/
|
|
26
|
+
export declare function planGateErrorFromResponse(response: Response): Promise<{
|
|
27
|
+
error: PlanGateError | null;
|
|
28
|
+
response: Response;
|
|
29
|
+
}>;
|
|
30
|
+
//# sourceMappingURL=plan-gate-error.d.ts.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export class PlanGateError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
details;
|
|
4
|
+
constructor(code, details) {
|
|
5
|
+
// Commands with their own expected-error boundary commonly print
|
|
6
|
+
// `err.message`. Keeping the friendly copy here means those boundaries
|
|
7
|
+
// retain the same plan-gate voice as main.ts without per-command handling.
|
|
8
|
+
super(formatPlanGateDetails(code, details));
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.details = details;
|
|
11
|
+
this.name = "PlanGateError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function isPlanGateError(err) {
|
|
15
|
+
return err instanceof PlanGateError;
|
|
16
|
+
}
|
|
17
|
+
/** Plain, stable CLI copy for plan-gated resource creation. */
|
|
18
|
+
function formatPlanGateDetails(code, details) {
|
|
19
|
+
const existingResourcesNote = "Existing resources will keep working.";
|
|
20
|
+
if (code === "PLAN_LIMIT_EXCEEDED" &&
|
|
21
|
+
typeof details.resource === "string" &&
|
|
22
|
+
typeof details.used === "number" &&
|
|
23
|
+
typeof details.limit === "number") {
|
|
24
|
+
const upgrade = typeof details.upgradeUrl === "string"
|
|
25
|
+
? `Upgrade to HQ Team ($500/mo) to remove limits: ${details.upgradeUrl}`
|
|
26
|
+
: "Upgrade to HQ Team ($500/mo) to remove limits.";
|
|
27
|
+
return [
|
|
28
|
+
`Free plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`,
|
|
29
|
+
upgrade,
|
|
30
|
+
existingResourcesNote,
|
|
31
|
+
].join("\n");
|
|
32
|
+
}
|
|
33
|
+
const upgrade = typeof details.upgradeUrl === "string"
|
|
34
|
+
? `Upgrade to HQ Team ($500/mo) to remove limits: ${details.upgradeUrl}`
|
|
35
|
+
: "Upgrade to HQ Team ($500/mo) to remove limits.";
|
|
36
|
+
return [
|
|
37
|
+
"HQ Team plan required for this feature.",
|
|
38
|
+
upgrade,
|
|
39
|
+
existingResourcesNote,
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
42
|
+
export function formatPlanGateError(err) {
|
|
43
|
+
return formatPlanGateDetails(err.code, err.details);
|
|
44
|
+
}
|
|
45
|
+
/** Decode an already-read JSON envelope from either shared HTTP client. */
|
|
46
|
+
export function planGateErrorFromPayload(status, body) {
|
|
47
|
+
if (status !== 402 || !body || typeof body !== "object")
|
|
48
|
+
return null;
|
|
49
|
+
const payload = body;
|
|
50
|
+
if (payload.code !== "PLAN_LIMIT_EXCEEDED" && payload.code !== "PLAN_REQUIRED") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return new PlanGateError(payload.code, {
|
|
54
|
+
...(typeof payload.resource === "string" ? { resource: payload.resource } : {}),
|
|
55
|
+
...(typeof payload.used === "number" ? { used: payload.used } : {}),
|
|
56
|
+
...(typeof payload.limit === "number" ? { limit: payload.limit } : {}),
|
|
57
|
+
...(typeof payload.upgradeUrl === "string" ? { upgradeUrl: payload.upgradeUrl } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
|
|
62
|
+
* or non-402 responses retain their existing command-specific handling.
|
|
63
|
+
*/
|
|
64
|
+
export async function planGateErrorFromResponse(response) {
|
|
65
|
+
// Leave every non-402 response untouched for its command-specific handler.
|
|
66
|
+
// A non-plan 402 is buffered and re-wrapped below so its useful error
|
|
67
|
+
// payload remains available to the existing command-specific handler.
|
|
68
|
+
if (response.status !== 402)
|
|
69
|
+
return { error: null, response };
|
|
70
|
+
let buffer;
|
|
71
|
+
try {
|
|
72
|
+
buffer = await response.arrayBuffer();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { error: null, response };
|
|
76
|
+
}
|
|
77
|
+
let body = null;
|
|
78
|
+
try {
|
|
79
|
+
body = JSON.parse(new TextDecoder().decode(buffer));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Preserve the original non-JSON 402 body below for its existing handler.
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
error: planGateErrorFromPayload(response.status, body),
|
|
86
|
+
response: new Response(buffer, {
|
|
87
|
+
status: response.status,
|
|
88
|
+
statusText: response.statusText,
|
|
89
|
+
headers: response.headers,
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=plan-gate-error.js.map
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -4,6 +4,7 @@ import { AuthError } from './auth-error.js';
|
|
|
4
4
|
import { CompanySelectionError } from './company-selection-error.js';
|
|
5
5
|
import { recordPlanLimitStatus } from '../lib/plan-limit-nag.js';
|
|
6
6
|
import { networkTransportErrorCode } from './network-transport-error.js';
|
|
7
|
+
import { planGateErrorFromResponse } from './plan-gate-error.js';
|
|
7
8
|
/**
|
|
8
9
|
* Identity / company resolution lookups must never hang forever. These small
|
|
9
10
|
* GETs run BEFORE a command does its real work (e.g. `hq secrets env` resolves
|
|
@@ -183,7 +184,13 @@ export async function vaultApiFetch(opts) {
|
|
|
183
184
|
level: "warning",
|
|
184
185
|
data: { url: safeUrl, status: response.status },
|
|
185
186
|
});
|
|
186
|
-
|
|
187
|
+
// A plan gate is a normal, user-actionable denial. Decode it at the one
|
|
188
|
+
// shared HTTP seam so every resource-creation command reaches main.ts's
|
|
189
|
+
// friendly renderer without duplicating response parsing or retrying.
|
|
190
|
+
const planGate = await planGateErrorFromResponse(response);
|
|
191
|
+
if (planGate.error)
|
|
192
|
+
throw planGate.error;
|
|
193
|
+
return planGate.response;
|
|
187
194
|
}
|
|
188
195
|
return peekPlanLimitStatus(response);
|
|
189
196
|
}
|