@indigoai-us/hq-cli 5.345.163 → 5.345.164

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 CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.164] — 2026-10-23
6
+ - `hq billing status` and `hq billing usage --card` now show trial usage against the $500 trial cap, the $10 included usage, the spend limit and a prompt to add a card when usage passes the included amount. The plan line says "unavailable right now" instead of guessing Starter, invoice-billed companies read as a flat plan billed by invoice, and a failed read of a scheduled switch is reported.
7
+ - `hq deploy domains` refuses a name that is really an IPv4 address. Inputs such as `1.2`, `0x7f.1` and `1.2.3.4` were rewritten to an address and accepted; a numeric last label is now refused before any request.
8
+ - `hq deploy domains buy`: when the domain is registered but the card charge does not complete, the output now prints the order's idempotency key and says to run the same command again with it to retry the charge. It no longer says that HQ billing follows up, which nothing did.
9
+ - `--company` that matches the folder's link in a different case is accepted, and the link's slug is sent.
10
+ - `hq doctor` now says which cloud call the sync receiver was refused on. `sync.runtime.receiver-permission` breaks its count down by call class (the per-device queue credentials from `POST /v1/sync/subscribe`, the subscribe call itself, the latency metric, or the object pull after a change event) and gives the matching next step, instead of one bare count. A new `sync.runtime.pull-access-denied` check warns when the same vault key was refused on two or more pulls in the hour and names the repeatedly refused keys (relative keys only, at most 10, with the runner's latest reason tag; a key refused once is counted but not named). In a session locked to companies, only keys from those companies are named and the rest are counted, so doctor never prints another company's file names from the machine-wide sync log; a refusal with no company label is shown as `unknown` with no lookup command. The warning gives the `hq files acl` command to compare the stored grant with the key the pull looked up. Before this, every automated client-health report for a refused receiver carried only a count and could not be acted on (feedback_cd56a501 and 27 linked reports).
11
+ - A stale sync journal whose `hq doctor --fix` pull ended offline, timed out, or hit a server 5xx is no longer reported as unrepaired by the client-health hook: doctor JSON leaves those rows out for 15 minutes after the attempt and lists them under `transientRefreshSyncCheckIds`, so the next SessionStart retries the pull instead of filing a report. A pull the service refused (4xx) or that failed for another reason is still reported. The `--fix` progress file records each journal's pull outcome as additive fields (still version 2, so an older CLI sharing the same HQ root keeps reading it, and an outcome value this CLI does not know is dropped rather than failing `--fix`); a journal that has failed transiently for more than 24 hours is reported again.
12
+ - `hq-hook bridge-plan` now also lists bridged helpers: `derive-trigger-facts`
13
+ appears when its flag `hook-engine-port-derive-trigger-facts` is `shadow` or
14
+ `on`. master-hook.sh passes the plan to the policy injector, which then runs
15
+ its fact helper through `hq-hook bridge derive-trigger-facts` instead of
16
+ bash (US-008). The flag defaults to off, the kill switch still forces bash,
17
+ and `hq-hook --self-check` reports the list as `helper_bridge_ports`. The
18
+ engine version is now 0.3.0.
19
+ - `hq-hook run policy-enforcement-gate` is the first hook port master-hook.sh
20
+ can bridge (US-022, flag `hook-engine-port-policy-enforcement-gate`, default
21
+ off). It answers the gate's allow paths without starting a process: no
22
+ session id, no session state, a PostToolUse with no state, an event the gate
23
+ does not handle, and a PreToolUse that is not outbound, matches no gate
24
+ policy and has no requirement to merge. For a Bash PreToolUse it writes the
25
+ same pre-tool pass stamp the bash gate writes. Every other case (outbound
26
+ tools and commands, gate policies, requirement merges, receipts,
27
+ UserPromptSubmit, non-ASCII input, jq not found on PATH, anything it cannot
28
+ parse) exits 75, and
29
+ the bridge runs the bash gate. The port never blocks.
30
+ - HQ Sync now reacts to secure real-time wake notifications on macOS, so shared
31
+ vault changes can start syncing within seconds without frequent polling.
32
+
5
33
  ## [5.345.163] — 2026-10-23
6
34
  - New `hq deploy domains` manages custom domains on a deployed app from the installed CLI, using the same hq-deploy routes as the desktop Deployments panel. `list`, `add <domain>`, `refresh <domain>` and `remove <domain>` take `--app <name or id>`, or use the app linked to the folder (`.hq/project.json`, or the older `hq-deploy.json`; `--path` picks the folder). Domains are checked before any request: hostname labels only (no URL, path, port or `@`), and an internationalized name is sent as punycode. A folder link that names no company needs `--company`, and a `--company` that differs from the link is refused. `add` names the app and company, shows the add-on price from `GET /v1/pricing`, and asks for approval (or `--yes`) before anything is added, then prints the DNS records to create, the guidance for a bare domain, and the billing link when the company has no card. `refresh` exits 2 while the domain is not live, so scripts can poll. `remove` names the app and company, asks for approval (or `--yes`), and says when the billing removal is queued for retry. Member, entitlement and billing errors print plain messages, and every command takes `--json`.
7
35
  - New `hq deploy domains search <query>` and `hq deploy domains buy <domain> --price <usd> --registrant-file <path>` buy a domain for the company through hq-pro's domain purchase routes. `buy` names the company that pays and shows the first-year price and the published renewal and refund terms, and asks for approval (or `--yes`). It sends one idempotency key per order and only the documented registrant fields. Whenever the outcome is not known (a network error, a server error, a pending registration), it prints the key and says to re-run with `--idempotency-key` instead of starting a new order. Buying stays unavailable until the server has registrar credentials; until then the command says nothing was charged.
@@ -28,7 +28,8 @@
28
28
  */
29
29
  import type { Command } from "commander";
30
30
  import { type BillingUsageReport, type ReadMonthUsageDeps, type StorageSplit, type UsageScope } from "../lib/billing/usage-cost.js";
31
- import { type PricingPlan } from "../lib/billing/usage-only.js";
31
+ import { type UsageCard } from "../lib/billing/usage-card.js";
32
+ import { type SharedBillingState, type PricingPlan } from "../lib/billing/usage-only.js";
32
33
  export interface MembershipRef {
33
34
  uid: string;
34
35
  slug: string;
@@ -60,6 +61,11 @@ export interface BillingUsageDeps {
60
61
  * for the plan when usage-limits does not carry it (older server).
61
62
  */
62
63
  fetchBillingSummary?: (scope: UsageScope) => Promise<unknown | null>;
64
+ /**
65
+ * The shared billing state for the scope, read when neither the statement
66
+ * nor the usage-limits body carries one. Null when it is not known.
67
+ */
68
+ readBillingState?: (scope: UsageScope) => Promise<SharedBillingState | null>;
63
69
  now(): Date;
64
70
  }
65
71
  export interface BillingUsageOptions {
@@ -91,7 +97,11 @@ export declare function resolveOnUsagePricing(limitsPlan: PricingPlan | null, su
91
97
  /** Thrown when the company statement is refused to a caller who is not its owner. */
92
98
  export declare class UsageStatementForbiddenError extends Error {
93
99
  }
94
- export declare function runBillingUsage(opts: BillingUsageOptions, deps: BillingUsageDeps): Promise<BillingUsageReport>;
100
+ /** The usage report plus the shared billing state it was read with (US-025). */
101
+ export type BillingUsageResult = BillingUsageReport & {
102
+ billingState?: SharedBillingState | null;
103
+ };
104
+ export declare function runBillingUsage(opts: BillingUsageOptions, deps: BillingUsageDeps): Promise<BillingUsageResult>;
95
105
  /**
96
106
  * `hq billing usage --card`: the card from GET /v1/pricing, or the bundled
97
107
  * card when the feed has none. Needs no sign-in.
@@ -99,6 +109,10 @@ export declare function runBillingUsage(opts: BillingUsageOptions, deps: Billing
99
109
  export declare function renderUsageCard(opts?: {
100
110
  json?: boolean;
101
111
  fetchPricing?: () => Promise<unknown | null>;
112
+ /** The caller's billing state, best effort. Absent or null prints the card alone. */
113
+ readState?: () => Promise<SharedBillingState | null>;
102
114
  }): Promise<string>;
115
+ /** The text output: the report, then the account lines from the billing state. */
116
+ export declare function formatUsageResult(report: BillingUsageResult, card: UsageCard): string;
103
117
  export declare function registerBillingUsageCommand(billing: Command): void;
104
118
  //# sourceMappingURL=billing-usage.d.ts.map
@@ -27,12 +27,13 @@
27
27
  * projected bill.
28
28
  */
29
29
  import chalk from "chalk";
30
- import { ensureCognitoToken } from "../utils/cognito-session.js";
30
+ import { ensureCognitoToken, hasPersonCachedSession } from "../utils/cognito-session.js";
31
31
  import { vaultApiFetch, vaultApiFetchPublic } from "../utils/vault-api.js";
32
32
  import { buildReadingsReport, buildReport, formatReport, readMonthUsage, usageLimitsSource, usageStatementSource, } from "../lib/billing/usage-cost.js";
33
33
  import { ratesFromPricingStatement } from "../lib/billing/usage-rate-card.js";
34
34
  import { fetchUsageCard, formatUsageCardLines } from "../lib/billing/usage-card.js";
35
- import { fetchCompanyBillingSummary, pricingPlanFromBody, hasBillingStateField, usageTrialActiveFromBody, } from "../lib/billing/usage-only.js";
35
+ import { accountStatusLines } from "../lib/billing/billing-status-lines.js";
36
+ import { fetchCompanyBillingSummary, pricingPlanFromBody, hasBillingStateField, readBillingState, usageTrialActiveFromBody, } from "../lib/billing/usage-only.js";
36
37
  export class UsageScopeError extends Error {
37
38
  }
38
39
  /** Pick the scope. Never reaches outside the caller's own memberships. */
@@ -153,9 +154,14 @@ export async function runBillingUsage(opts, deps) {
153
154
  })
154
155
  : null;
155
156
  const onUsagePricing = resolveOnUsagePricing(limitsPlan, pricingPlanFromSummary(summary));
157
+ const billingState = readBillingState(statement?.body) ??
158
+ readBillingState(limitsBody) ??
159
+ readBillingState(summary) ??
160
+ (deps.readBillingState ? await deps.readBillingState(scope).catch(() => null) : undefined);
161
+ const withState = (report) => billingState === undefined ? report : { ...report, billingState };
156
162
  if (onUsagePricing !== true)
157
- return buildReadingsReport(usage, onUsagePricing);
158
- return { ...buildReport(usage, ratesFromPricingStatement(pricing)), onUsagePricing: true };
163
+ return withState(buildReadingsReport(usage, onUsagePricing));
164
+ return withState({ ...buildReport(usage, ratesFromPricingStatement(pricing)), onUsagePricing: true });
159
165
  }
160
166
  // ── Production deps ─────────────────────────────────────────────────────────
161
167
  function productionDeps() {
@@ -223,6 +229,9 @@ function productionDeps() {
223
229
  return null;
224
230
  return res.json().catch(() => null);
225
231
  },
232
+ async readBillingState(scope) {
233
+ return readBillingState(await this.fetchBillingSummary(scope));
234
+ },
226
235
  now: () => new Date(),
227
236
  };
228
237
  }
@@ -232,12 +241,44 @@ function productionDeps() {
232
241
  */
233
242
  export async function renderUsageCard(opts = {}) {
234
243
  const { card, source } = await fetchUsageCard(opts.fetchPricing ? { fetchPricing: opts.fetchPricing } : {});
235
- if (opts.json)
236
- return JSON.stringify({ source, card }, null, 2);
244
+ const state = opts.readState ? await opts.readState().catch(() => null) : null;
245
+ if (opts.json) {
246
+ return JSON.stringify({ source, card, ...(opts.readState ? { billingState: state } : {}) }, null, 2);
247
+ }
248
+ const account = accountStatusLines(state, card);
237
249
  const from = source === "feed"
238
250
  ? "from GET /v1/pricing"
239
251
  : "bundled copy; the pricing feed has no usage card yet";
240
- return [`HQ usage pricing (card ${card.version}, ${from})`, "", ...formatUsageCardLines(card)].join("\n");
252
+ return [
253
+ `HQ usage pricing (card ${card.version}, ${from})`,
254
+ "",
255
+ ...formatUsageCardLines(card),
256
+ ...(account.length > 0 ? ["", "Your account:", ...account.map((l) => ` ${l}`)] : []),
257
+ ].join("\n");
258
+ }
259
+ /**
260
+ * The billing state for `--card`, only when a sign-in is already cached: the
261
+ * card needs no sign-in, so this never opens a browser login. Any failure is
262
+ * null and the card prints alone.
263
+ */
264
+ async function cachedSessionBillingState(company) {
265
+ if (!hasPersonCachedSession())
266
+ return null;
267
+ const token = await ensureCognitoToken({ interactive: false });
268
+ const res = await vaultApiFetch({ token, path: "/membership/me" });
269
+ if (!res.ok)
270
+ return null;
271
+ const { companiesFromMemberships } = await import("./storage.js");
272
+ const scope = resolveUsageScope(companiesFromMemberships(await res.json()), { company });
273
+ if (scope.kind === "company")
274
+ return readBillingState(await fetchCompanyBillingSummary(token, scope.companyUid));
275
+ const person = await vaultApiFetch({ token, path: "/v1/billing/person/summary" });
276
+ return person.ok ? readBillingState(await person.json().catch(() => null)) : null;
277
+ }
278
+ /** The text output: the report, then the account lines from the billing state. */
279
+ export function formatUsageResult(report, card) {
280
+ const account = accountStatusLines(report.billingState ?? null, card);
281
+ return account.length > 0 ? [formatReport(report), "", ...account].join("\n") : formatReport(report);
241
282
  }
242
283
  export function registerBillingUsageCommand(billing) {
243
284
  billing
@@ -252,12 +293,25 @@ export function registerBillingUsageCommand(billing) {
252
293
  .action(async function (opts) {
253
294
  try {
254
295
  if (opts.card) {
255
- console.log(await renderUsageCard({ json: opts.json === true }));
296
+ const cardCompany = opts.company ?? this.parent?.opts().company;
297
+ console.log(await renderUsageCard({
298
+ json: opts.json === true,
299
+ readState: () => cachedSessionBillingState(cardCompany),
300
+ }));
256
301
  return;
257
302
  }
258
303
  const parentCompany = this.parent?.opts().company ?? undefined;
259
304
  const report = await runBillingUsage({ ...opts, company: opts.company ?? parentCompany }, productionDeps());
260
- console.log(opts.json ? JSON.stringify(report, null, 2) : formatReport(report));
305
+ if (opts.json) {
306
+ console.log(JSON.stringify(report, null, 2));
307
+ }
308
+ else if (report.billingState) {
309
+ const { card } = await fetchUsageCard();
310
+ console.log(formatUsageResult(report, card));
311
+ }
312
+ else {
313
+ console.log(formatReport(report));
314
+ }
261
315
  }
262
316
  catch (err) {
263
317
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -25,6 +25,17 @@
25
25
  * pricing: the flat-plan checkout is closed, so the CLI never opens it.
26
26
  */
27
27
  import { Command } from "commander";
28
+ /** Shape of `GET /v1/billing/summary` we render. */
29
+ interface BillingSummary {
30
+ subscriptionStatus: string | null;
31
+ defaultPaymentMethod: {
32
+ present: boolean;
33
+ brand: string | null;
34
+ last4: string | null;
35
+ };
36
+ databases?: DatabaseBilling | null;
37
+ [key: string]: unknown;
38
+ }
28
39
  interface DatabaseBilling {
29
40
  period: string;
30
41
  activeApps: number;
@@ -37,6 +48,29 @@ interface DatabaseBilling {
37
48
  }>;
38
49
  }
39
50
  export declare function formatDatabaseBilling(db: DatabaseBilling | null | undefined): string[];
51
+ /**
52
+ * The plan a person can act on, in words. The summary's `billingStatus.plan`
53
+ * is the Stripe webhook's label and says "usage" for ANY non-Workforce
54
+ * subscription (a metered usage subscription on a company that still prices
55
+ * as a flat plan reads "usage" there, hq-pro summary.ts). The plan the server
56
+ * actually prices by is `billingState.kind` (hq-pro #5088) or, on an older
57
+ * server, `pricingPlan`; `billingStatus.plan` only separates Workforce from
58
+ * the rest. DEV-5179: a reporter read "usage" off the status JSON while
59
+ * `hq billing usage` said "flat plan"; both were right about different fields.
60
+ */
61
+ export declare function describePlan(summary: BillingSummary): string;
62
+ export declare const PLAN_UNAVAILABLE = "unavailable right now";
63
+ /**
64
+ * A scheduled switch to usage pricing (hq-pro US-014 `GET
65
+ * /v1/billing/usage-switch`), or null. The route is owner-only and absent on
66
+ * older servers. A refusal (403, 404) reads as "nothing scheduled"; any other
67
+ * failure is "unreadable" so the status view can say so instead of implying
68
+ * no switch. The status view never fails on it.
69
+ */
70
+ export declare function fetchScheduledUsageSwitch(token: string, companyUid: string): Promise<{
71
+ switchDate: string;
72
+ phase: "scheduled" | "preview";
73
+ } | "unreadable" | null>;
40
74
  export declare function registerBillingCommand(program: Command): void;
41
75
  export {};
42
76
  //# sourceMappingURL=billing.d.ts.map
@@ -30,8 +30,9 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
30
30
  import { mintPaymentLink } from "../utils/billing-gate.js";
31
31
  import { openUsageSwitch } from "../utils/team-upgrade.js";
32
32
  import { registerBillingSponsorCommands } from "./billing-sponsor.js";
33
- import { fetchCompanyPricingPlan, isUsageOnly, usageOnlyUpgradeLines, } from "../lib/billing/usage-only.js";
33
+ import { fetchCompanyPricingPlan, isUsageOnly, pricingPlanFromBody, readBillingState, usageOnlyUpgradeLines, } from "../lib/billing/usage-only.js";
34
34
  import { fetchUsageCard } from "../lib/billing/usage-card.js";
35
+ import { accountStatusLines } from "../lib/billing/billing-status-lines.js";
35
36
  import { registerBillingUsageCommand } from "./billing-usage.js";
36
37
  const CHARGED_ON = {
37
38
  team: "the Team subscription",
@@ -62,6 +63,94 @@ export function formatDatabaseBilling(db) {
62
63
  }
63
64
  return lines;
64
65
  }
66
+ function asRecord(value) {
67
+ return value !== null && typeof value === "object" && !Array.isArray(value)
68
+ ? value
69
+ : null;
70
+ }
71
+ /**
72
+ * The plan a person can act on, in words. The summary's `billingStatus.plan`
73
+ * is the Stripe webhook's label and says "usage" for ANY non-Workforce
74
+ * subscription (a metered usage subscription on a company that still prices
75
+ * as a flat plan reads "usage" there, hq-pro summary.ts). The plan the server
76
+ * actually prices by is `billingState.kind` (hq-pro #5088) or, on an older
77
+ * server, `pricingPlan`; `billingStatus.plan` only separates Workforce from
78
+ * the rest. DEV-5179: a reporter read "usage" off the status JSON while
79
+ * `hq billing usage` said "flat plan"; both were right about different fields.
80
+ */
81
+ export function describePlan(summary) {
82
+ const state = readBillingState(summary);
83
+ if (state) {
84
+ switch (state.kind) {
85
+ case "usage":
86
+ return "usage pricing";
87
+ case "usage-trial":
88
+ return state.trial
89
+ ? `usage pricing (trial, ${state.trial.daysLeft} days left)`
90
+ : "usage pricing (trial)";
91
+ case "legacy-payer":
92
+ // legacy-payer also covers invoice-billed companies, which have no
93
+ // Workforce subscription behind them.
94
+ return asRecord(summary.billingStatus)?.plan === "team"
95
+ ? "Workforce (flat plan)"
96
+ : "flat plan (billed by invoice)";
97
+ case "legacy-free":
98
+ return "Starter (free)";
99
+ case "enterprise":
100
+ return "Enterprise";
101
+ case "comped":
102
+ return "comped (not billed)";
103
+ }
104
+ }
105
+ if (pricingPlanFromBody(summary) === "usage-only")
106
+ return "usage pricing";
107
+ if (pricingPlanFromBody(summary) === "current") {
108
+ // An older server says "flat plan" but not which one: only a Workforce
109
+ // subscription is visible here, so anything else is named no further.
110
+ return asRecord(summary.billingStatus)?.plan === "team" ? "Workforce (flat plan)" : "flat plan";
111
+ }
112
+ // The server did not say which plan prices this company. Naming one here
113
+ // would guess, and a guess of "Starter (free)" told a paying company it was
114
+ // on the free plan.
115
+ return PLAN_UNAVAILABLE;
116
+ }
117
+ export const PLAN_UNAVAILABLE = "unavailable right now";
118
+ /** True when the plan description already says the company prices by usage. */
119
+ function isUsagePricingLabel(label) {
120
+ return label.startsWith("usage pricing");
121
+ }
122
+ /**
123
+ * A scheduled switch to usage pricing (hq-pro US-014 `GET
124
+ * /v1/billing/usage-switch`), or null. The route is owner-only and absent on
125
+ * older servers. A refusal (403, 404) reads as "nothing scheduled"; any other
126
+ * failure is "unreadable" so the status view can say so instead of implying
127
+ * no switch. The status view never fails on it.
128
+ */
129
+ export async function fetchScheduledUsageSwitch(token, companyUid) {
130
+ try {
131
+ const res = await vaultApiFetch({
132
+ token,
133
+ path: "/v1/billing/usage-switch",
134
+ query: { companyUid },
135
+ });
136
+ if (res.status === 403 || res.status === 404)
137
+ return null;
138
+ if (!res.ok)
139
+ return "unreadable";
140
+ const body = asRecord(await res.json());
141
+ const phase = body?.phase;
142
+ const switchDate = body?.switchDate;
143
+ if ((phase === "scheduled" || phase === "preview") &&
144
+ typeof switchDate === "string" &&
145
+ Number.isFinite(Date.parse(switchDate))) {
146
+ return { switchDate, phase };
147
+ }
148
+ return null;
149
+ }
150
+ catch {
151
+ return "unreadable";
152
+ }
153
+ }
65
154
  function fail(err) {
66
155
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
67
156
  process.exit(1);
@@ -101,6 +190,18 @@ export function registerBillingCommand(program) {
101
190
  }
102
191
  const sub = summary.subscriptionStatus ?? "none";
103
192
  console.log(`${chalk.bold("Subscription")}: ${sub}`);
193
+ const planLabel = describePlan(summary);
194
+ console.log(`${chalk.bold("Plan")}: ${planLabel}`);
195
+ if (!isUsagePricingLabel(planLabel) && planLabel !== PLAN_UNAVAILABLE) {
196
+ const scheduled = await fetchScheduledUsageSwitch(token, companyUid);
197
+ if (scheduled === "unreadable") {
198
+ console.log(" Switch to usage pricing: could not be read right now.");
199
+ }
200
+ else if (scheduled) {
201
+ console.log(` Switch to usage pricing scheduled for ${scheduled.switchDate.slice(0, 10)}` +
202
+ (scheduled.phase === "preview" ? " (preview window open)." : "."));
203
+ }
204
+ }
104
205
  const pm = summary.defaultPaymentMethod;
105
206
  if (pm?.present) {
106
207
  const detail = pm.brand && pm.last4 ? `${pm.brand} ••••${pm.last4}` : "on file";
@@ -110,6 +211,12 @@ export function registerBillingCommand(program) {
110
211
  console.log(`${chalk.bold("Card")}: ${chalk.yellow("no card on file")} — ` +
111
212
  `run \`hq billing checkout\` to add one before provisioning paid resources.`);
112
213
  }
214
+ const state = readBillingState(summary);
215
+ if (state && (state.kind === "usage" || state.kind === "usage-trial")) {
216
+ const { card } = await fetchUsageCard();
217
+ for (const line of accountStatusLines(state, card))
218
+ console.log(line);
219
+ }
113
220
  for (const line of formatDatabaseBilling(summary.databases))
114
221
  console.log(line);
115
222
  }
@@ -17,6 +17,8 @@ export declare const deployDomainsPrompt: {
17
17
  };
18
18
  /** The sentence `buy` adds when the order's outcome is not known. */
19
19
  export declare function resumeOrderSentence(idempotencyKey: string): string;
20
+ /** Shown when hq-pro registered the domain but could not capture the card charge. */
21
+ export declare function retryChargeSentence(idempotencyKey: string): string;
20
22
  interface Binding {
21
23
  appId: string;
22
24
  orgSlug?: string;
@@ -117,6 +117,10 @@ const SETTLED_PURCHASE_CODES = new Set([
117
117
  export function resumeOrderSentence(idempotencyKey) {
118
118
  return `The order may still be in progress. Run the same command again with --idempotency-key ${idempotencyKey} to finish or check it. Do not start a new order.`;
119
119
  }
120
+ /** Shown when hq-pro registered the domain but could not capture the card charge. */
121
+ export function retryChargeSentence(idempotencyKey) {
122
+ return `Run the same command again with --idempotency-key ${idempotencyKey} to retry the charge. Do not start a new order.`;
123
+ }
120
124
  /** The registrant fields hq-pro accepts; nothing else from the file is sent. */
121
125
  const REGISTRANT_FIELDS = [
122
126
  "firstName", "lastName", "companyName", "address1", "address2",
@@ -312,16 +316,18 @@ async function resolveTarget(token, options) {
312
316
  if (!binding.orgSlug && options.company === undefined) {
313
317
  throw new DeployDomainsError("The folder's link does not name a company. Pass --company <slug>, or re-link the folder.", "COMPANY_REQUIRED");
314
318
  }
315
- if (binding.orgSlug && options.company !== undefined && options.company !== binding.orgSlug) {
319
+ if (binding.orgSlug && options.company !== undefined && options.company.toLowerCase() !== binding.orgSlug) {
316
320
  throw new DeployDomainsError(`This folder is linked to an app in '${binding.orgSlug}', not '${displayText(options.company)}'. Drop --company, or pass --app <name or id>.`, "COMPANY_MISMATCH");
317
321
  }
318
322
  return {
319
323
  appId: binding.appId,
320
- orgSlug: resolveSlug(options.company, binding.orgSlug),
324
+ // A matching --company may differ only in case; the link's slug is canonical.
325
+ orgSlug: binding.orgSlug ?? resolveSlug(options.company, undefined),
321
326
  appLabel: binding.appId,
322
327
  };
323
328
  }
324
329
  const HOSTNAME_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
330
+ const ALL_DIGITS = /^\d+$/;
325
331
  /**
326
332
  * Normalize a domain to the ASCII form the server stores, using the same
327
333
  * hostname rule hq-deploy's validateAppHostname enforces: at least two
@@ -344,6 +350,13 @@ export function normalizeDomain(raw) {
344
350
  if (labels.length < 2 || labels.some((label) => label.length > 63 || !HOSTNAME_LABEL.test(label))) {
345
351
  throw invalid();
346
352
  }
353
+ // domainToASCII applies WHATWG IPv4 parsing ("1.2" becomes "1.0.0.2",
354
+ // "0x7f.1" becomes "127.0.0.1"), and a name it cannot parse as one comes
355
+ // back empty. No real top-level domain is numeric, so a numeric last label
356
+ // after the conversion is an address.
357
+ if (ALL_DIGITS.test(labels[labels.length - 1])) {
358
+ throw invalid();
359
+ }
347
360
  return domain;
348
361
  }
349
362
  function domainRoute(appId, domain, suffix = "") {
@@ -727,8 +740,10 @@ async function buyAction(domainArg, options) {
727
740
  }
728
741
  else if (body.paymentCaptured === false) {
729
742
  // hq-pro registered the name, but capturing the card authorization failed.
743
+ // A retry with the same key retrieves the same authorization, replays the
744
+ // registration for this order and captures again (hq-pro domain-purchase.ts).
730
745
  console.log(`Registered ${displayText(domain)}.${expires} The card was authorized for ${usd(expectedPriceUsd)}, ` +
731
- `but the charge has not completed yet. HQ billing follows up on it. Do not buy the domain again.`);
746
+ `but the charge did not complete. ${retryChargeSentence(idempotencyKey)}`);
732
747
  }
733
748
  else {
734
749
  console.log(`Bought ${displayText(domain)} for ${usd(expectedPriceUsd)}.${expires}`);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The account lines `hq billing status`, `hq billing usage` and
3
+ * `hq billing usage --card` print from hq-pro's shared `billingState` (US-025).
4
+ * One helper so every surface shows the same trial usage, included usage,
5
+ * spend limit and card prompt for the same state.
6
+ */
7
+ import { type UsageCard } from "./usage-card.js";
8
+ import type { SharedBillingState } from "./usage-only.js";
9
+ /**
10
+ * Lines for one billing state. Legacy, enterprise and comped accounts get no
11
+ * lines. The member view carries no money figures, so it gets none either.
12
+ */
13
+ export declare function accountStatusLines(state: SharedBillingState | null, card?: UsageCard): string[];
14
+ //# sourceMappingURL=billing-status-lines.d.ts.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The account lines `hq billing status`, `hq billing usage` and
3
+ * `hq billing usage --card` print from hq-pro's shared `billingState` (US-025).
4
+ * One helper so every surface shows the same trial usage, included usage,
5
+ * spend limit and card prompt for the same state.
6
+ */
7
+ import { BUNDLED_USAGE_CARD, formatCardUsd } from "./usage-card.js";
8
+ function usd(cents) {
9
+ return `$${(cents / 100).toFixed(2)}`;
10
+ }
11
+ function onUsagePricing(state) {
12
+ return state.kind === "usage" || state.kind === "usage-trial";
13
+ }
14
+ /**
15
+ * Lines for one billing state. Legacy, enterprise and comped accounts get no
16
+ * lines. The member view carries no money figures, so it gets none either.
17
+ */
18
+ export function accountStatusLines(state, card = BUNDLED_USAGE_CARD) {
19
+ if (!state || !onUsagePricing(state))
20
+ return [];
21
+ const lines = [];
22
+ if (state.kind === "usage-trial" && state.trial) {
23
+ lines.push(`Trial: ${state.trial.daysLeft} days left (ends ${state.trial.endsAt.slice(0, 10)})`);
24
+ if (state.view === "full" && state.trial.usedCents !== null) {
25
+ const capCents = state.trial.capCents ?? card.trial.capUsd * 100;
26
+ lines.push(`Trial usage: ${usd(state.trial.usedCents)} of the ${usd(capCents)} trial cap`);
27
+ }
28
+ }
29
+ if (state.view !== "full")
30
+ return lines;
31
+ lines.push(`Included usage: ${formatCardUsd(card.includedUsd)} each month`);
32
+ if (state.spendLimit) {
33
+ lines.push(state.spendLimit.cents === null
34
+ ? "Spend limit: none set"
35
+ : `Spend limit: ${usd(state.spendLimit.cents)} a month`);
36
+ }
37
+ if (state.cardPrompt) {
38
+ lines.push(`Add a card: usage is past the ${formatCardUsd(card.includedUsd)} included this month. ` +
39
+ "Run `hq billing checkout` to keep working past it.");
40
+ }
41
+ return lines;
42
+ }
43
+ //# sourceMappingURL=billing-status-lines.js.map
@@ -35,10 +35,28 @@ export interface SharedBillingState {
35
35
  kind: BillingStateKind;
36
36
  scope: "company" | "person";
37
37
  view: "full" | "member";
38
+ /**
39
+ * The usage trial, or null. `capCents` is the trial's usage cap and
40
+ * `usedCents` what the trial has used so far; both are null when the server
41
+ * does not say (member view, older server).
42
+ */
38
43
  trial: {
39
44
  daysLeft: number;
40
45
  endsAt: string;
46
+ capCents: number | null;
47
+ usedCents: number | null;
48
+ } | null;
49
+ /**
50
+ * The monthly spend limit. `cents: null` means no limit is set. The whole
51
+ * field is null off usage pricing and in the member view.
52
+ */
53
+ spendLimit: {
54
+ cents: number | null;
41
55
  } | null;
56
+ /** Whether a card is on file. Null when the server does not say. */
57
+ cardOnFile: boolean | null;
58
+ /** Usage passed the included allowance with no card on file. */
59
+ cardPrompt: boolean;
42
60
  /** A pause the server is applying now, or null. */
43
61
  paused: {
44
62
  reason: string;
@@ -65,7 +65,21 @@ function readTrial(value) {
65
65
  !Number.isFinite(Date.parse(trial.endsAt))) {
66
66
  return undefined;
67
67
  }
68
- return { daysLeft: trial.daysLeft, endsAt: trial.endsAt };
68
+ return {
69
+ daysLeft: trial.daysLeft,
70
+ endsAt: trial.endsAt,
71
+ capCents: centsOrNull(trial.capCents),
72
+ usedCents: centsOrNull(trial.usedCents),
73
+ };
74
+ }
75
+ function centsOrNull(value) {
76
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
77
+ }
78
+ function readSpendLimit(value) {
79
+ const limit = asRecord(value);
80
+ if (!limit)
81
+ return null;
82
+ return { cents: centsOrNull(limit.cents) };
69
83
  }
70
84
  function readPaused(value) {
71
85
  const paused = asRecord(value);
@@ -102,6 +116,9 @@ export function readBillingState(body) {
102
116
  scope: state.scope,
103
117
  view: state.view,
104
118
  trial,
119
+ spendLimit: readSpendLimit(state.spendLimit),
120
+ cardOnFile: typeof state.cardOnFile === "boolean" ? state.cardOnFile : null,
121
+ cardPrompt: state.cardPrompt === true,
105
122
  paused: readPaused(state.paused),
106
123
  actions: {
107
124
  addCard: actions?.addCard === true,
@@ -1,13 +1,47 @@
1
+ import { readSessionCompanyLock } from "../../session-company-lock.js";
1
2
  import type { CheckResult } from "../types.js";
2
3
  export declare const SYNC_RUNTIME_WINDOW_MS: number;
3
4
  /** Ignore timing and restart signals until a newly started receiver has settled. */
4
5
  export declare const SYNC_RUNTIME_RESTART_WARMUP_MS: number;
6
+ /** Most refused vault keys named by `sync.runtime.pull-access-denied`. */
7
+ export declare const PULL_DENIED_KEY_CAP = 10;
8
+ /** A key refused on at least this many pulls in the hour is a standing condition, not a one-off. */
9
+ export declare const PULL_DENIED_MIN_REPEATS = 2;
5
10
  export interface SyncRuntimeOptions {
6
11
  now: number;
7
12
  conflictCount?: number;
13
+ /**
14
+ * Company scopes whose refused vault keys may be named. `undefined` means
15
+ * the session holds no company lock, so every scope may be named. Keys in
16
+ * any other scope (including `personal` when it is not listed) are counted
17
+ * but never named, so `hq doctor` in a session locked to company A cannot
18
+ * print company B's file names from the machine-wide sync log.
19
+ */
20
+ nameableCompanies?: readonly string[];
8
21
  }
22
+ /**
23
+ * Which cloud call a receiver permission refusal came from. Closed vocabulary
24
+ * derived from the hq-cloud receiver's event names, so the breakdown can be
25
+ * reported without the error prose.
26
+ *
27
+ * - `subscribe`: `POST /v1/sync/subscribe` itself answered 403 while the
28
+ * receiver re-vended its queue credentials.
29
+ * - `sqs-receive` / `sqs-visibility` / `sqs-delete`: the per-device queue
30
+ * credentials vended by subscribe were refused by SQS. One bad vend
31
+ * repeats on every poll and lease heartbeat until the runner restarts.
32
+ * - `cloudwatch-metric`: the latency metric publisher was refused.
33
+ * - `targeted-pull`: the object pull after a change event failed; the
34
+ * refused keys are named by `sync.runtime.pull-access-denied`.
35
+ * - `runner-error`: a runner `error` record outside the receiver.
36
+ */
37
+ export type ReceiverRefusalCall = "subscribe" | "sqs-receive" | "sqs-visibility" | "sqs-delete" | "cloudwatch-metric" | "targeted-pull" | "runner-error" | "other";
9
38
  /** Classify the last hour of sync log lines into privacy-safe results. */
10
39
  export declare function analyzeSyncRuntimeLog(lines: readonly string[], options: SyncRuntimeOptions): CheckResult[];
40
+ /**
41
+ * Company scopes this session may see named: `undefined` (all) when there is
42
+ * no session or no company lock; nothing when the lock cannot be verified.
43
+ */
44
+ export declare function sessionNameableCompanies(env: NodeJS.ProcessEnv, readLock?: typeof readSessionCompanyLock): readonly string[] | undefined;
11
45
  /** Read the machine's sync logs and client health file. */
12
46
  export declare function defaultSyncRuntimeResults(now: Date, hqHome?: string, env?: NodeJS.ProcessEnv): CheckResult[];
13
47
  //# sourceMappingURL=sync-runtime.d.ts.map