@indigoai-us/hq-cli 5.345.159 → 5.345.160
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 +24 -0
- package/dist/command-catalog.generated.d.ts +4 -4
- package/dist/command-catalog.generated.js +4 -4
- package/dist/commands/access.d.ts +6 -0
- package/dist/commands/access.js +18 -1
- package/dist/commands/acl-lock.d.ts +41 -0
- package/dist/commands/acl-lock.js +28 -0
- package/dist/commands/agent-enroll.d.ts +2 -0
- package/dist/commands/agent-enroll.js +9 -4
- package/dist/commands/agents.js +5 -11
- package/dist/commands/billing-sponsor.d.ts +4 -4
- package/dist/commands/billing-sponsor.js +11 -11
- package/dist/commands/billing-usage.d.ts +22 -8
- package/dist/commands/billing-usage.js +57 -22
- package/dist/commands/billing.d.ts +7 -7
- package/dist/commands/billing.js +13 -13
- package/dist/commands/cloud-provision.js +2 -2
- package/dist/commands/cloud.js +7 -7
- package/dist/commands/db-remote-deps.js +12 -1
- package/dist/commands/files.js +19 -7
- package/dist/commands/meetings.d.ts +4 -4
- package/dist/commands/meetings.js +17 -8
- package/dist/commands/skill.js +6 -6
- package/dist/commands/whoami.js +1 -1
- package/dist/hook-engine-path.js +13 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +982 -426
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/billing/plan-limit-refusals.d.ts +13 -3
- package/dist/lib/billing/plan-limit-refusals.js +26 -3
- package/dist/lib/billing/plan-limit-upgrade-url.d.ts +8 -0
- package/dist/lib/billing/plan-limit-upgrade-url.js +18 -0
- package/dist/lib/billing/plan-lock.d.ts +22 -6
- package/dist/lib/billing/plan-lock.js +58 -30
- package/dist/lib/billing/usage-cost.d.ts +24 -2
- package/dist/lib/billing/usage-cost.js +103 -3
- package/dist/lib/billing/usage-only.d.ts +37 -1
- package/dist/lib/billing/usage-only.js +89 -4
- package/dist/lib/billing/usage-pause.d.ts +27 -0
- package/dist/lib/billing/usage-pause.js +50 -0
- package/dist/lib/db/control-plane.d.ts +5 -0
- package/dist/lib/db/control-plane.js +10 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/hook-engine/resolve.d.ts +14 -0
- package/dist/lib/hook-engine/resolve.js +55 -1
- package/dist/lib/plan-limit-nag.d.ts +0 -16
- package/dist/lib/plan-limit-nag.js +17 -18
- package/dist/main.d.ts +0 -2
- package/dist/main.js +19 -57
- package/dist/utils/plan-gate-error.d.ts +30 -14
- package/dist/utils/plan-gate-error.js +60 -46
- package/dist/utils/team-upgrade.d.ts +19 -6
- package/dist/utils/team-upgrade.js +37 -39
- package/package.json +8 -8
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.
|
|
1
|
+
{"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.98","bundleSha256":"70fe5497fffdbb678370515f8b7df63438d7a59a95467102307459e78f9f4715","formatVersion":1}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
* stops re-sending paused keys. The `path`/`paths`/`count` reads below consume
|
|
19
19
|
* that event; the `error`-message parsing stays for runners older than
|
|
20
20
|
* 6.18.17.
|
|
21
|
+
*
|
|
22
|
+
* US-024: the server's sentence wins. A `plan-limit` event carries the 402
|
|
23
|
+
* body's `message` (for a usage pause, the `usagePause` message hq-pro writes
|
|
24
|
+
* for the person who hit it), and a runner that forwards `usagePause` gives
|
|
25
|
+
* its message and `actionUrl` directly. The CLI adds no plan name and labels
|
|
26
|
+
* the link `Billing:`, never "Upgrade".
|
|
21
27
|
*/
|
|
22
28
|
import { type SyncProgressEvent } from "@indigoai-us/hq-cloud";
|
|
23
29
|
/** What one push leg was refused. `null` from the tracker means nothing was. */
|
|
@@ -29,10 +35,12 @@ export interface PlanLimitRefusalSummary {
|
|
|
29
35
|
count: number;
|
|
30
36
|
/** Vault-relative paths the runner named, de-duplicated, in report order. */
|
|
31
37
|
paths: string[];
|
|
32
|
-
/** Server sentence
|
|
38
|
+
/** Server sentence (usage pause message, event message, or a refused file's error). */
|
|
33
39
|
message?: string;
|
|
34
|
-
/** Server-selected (or company billing fallback)
|
|
40
|
+
/** Server-selected (or company billing fallback) billing link. */
|
|
35
41
|
upgradeUrl?: string;
|
|
42
|
+
/** True when the server said this is a usage pause (`usagePause`). */
|
|
43
|
+
usagePause?: boolean;
|
|
36
44
|
}
|
|
37
45
|
export interface PlanLimitRefusalTracker {
|
|
38
46
|
observe(event: SyncProgressEvent): void;
|
|
@@ -57,9 +65,11 @@ export declare function createPlanLimitRefusalTracker(opts?: {
|
|
|
57
65
|
export declare function trackingProgressRecorder(tracker: PlanLimitRefusalTracker, company: string | null): (event: SyncProgressEvent) => void;
|
|
58
66
|
/** Add a later leg's refusals to an earlier total. */
|
|
59
67
|
export declare function mergePlanLimitRefusals(a: PlanLimitRefusalSummary | null | undefined, b: PlanLimitRefusalSummary | null | undefined): PlanLimitRefusalSummary | null;
|
|
68
|
+
/** The sentence when the server sent none. Names no plan. */
|
|
69
|
+
export declare const REFUSAL_FALLBACK_SENTENCE = "New files are paused for this account. Open its Billing page to see why.";
|
|
60
70
|
/**
|
|
61
71
|
* Plain lines describing a refusal: what was not uploaded, why, and the
|
|
62
|
-
*
|
|
72
|
+
* billing link. Colour and the leading status line are the caller's.
|
|
63
73
|
*/
|
|
64
74
|
export declare function renderPlanLimitRefusalLines(summary: PlanLimitRefusalSummary, indent?: string): string[];
|
|
65
75
|
/** "1 new file" / "3 new files", or "new files" when only a notice said so. */
|
|
@@ -18,9 +18,16 @@
|
|
|
18
18
|
* stops re-sending paused keys. The `path`/`paths`/`count` reads below consume
|
|
19
19
|
* that event; the `error`-message parsing stays for runners older than
|
|
20
20
|
* 6.18.17.
|
|
21
|
+
*
|
|
22
|
+
* US-024: the server's sentence wins. A `plan-limit` event carries the 402
|
|
23
|
+
* body's `message` (for a usage pause, the `usagePause` message hq-pro writes
|
|
24
|
+
* for the person who hit it), and a runner that forwards `usagePause` gives
|
|
25
|
+
* its message and `actionUrl` directly. The CLI adds no plan name and labels
|
|
26
|
+
* the link `Billing:`, never "Upgrade".
|
|
21
27
|
*/
|
|
22
28
|
import { createSyncProgressRecorder, } from "@indigoai-us/hq-cloud";
|
|
23
29
|
import { planLimitUpgradeFallbackUrl } from "./plan-limit-upgrade-url.js";
|
|
30
|
+
import { usagePauseFromBody } from "./usage-pause.js";
|
|
24
31
|
/** Upgrade destination when neither the event nor the error named one. */
|
|
25
32
|
const FALLBACK_UPGRADE_URL = "https://hq.computer/billing";
|
|
26
33
|
const PLAN_LIMIT_CODE_TOKEN = /\bcode=(?:PLAN_LIMIT_EXCEEDED|PLAN_LIMIT_REACHED|plan_limit_reached)\b/;
|
|
@@ -72,6 +79,7 @@ export function createPlanLimitRefusalTracker(opts = {}) {
|
|
|
72
79
|
let reportedCount = 0;
|
|
73
80
|
let message;
|
|
74
81
|
let upgradeUrl;
|
|
82
|
+
let usagePaused = false;
|
|
75
83
|
const addPath = (value) => {
|
|
76
84
|
if (typeof value === "string" && value && !paths.includes(value)) {
|
|
77
85
|
paths.push(value);
|
|
@@ -82,7 +90,17 @@ export function createPlanLimitRefusalTracker(opts = {}) {
|
|
|
82
90
|
const record = event;
|
|
83
91
|
if (event.type === "plan-limit") {
|
|
84
92
|
noticeSeen = true;
|
|
93
|
+
const pause = usagePauseFromBody(record);
|
|
94
|
+
if (pause) {
|
|
95
|
+
usagePaused = true;
|
|
96
|
+
message = pause.message;
|
|
97
|
+
if (pause.actionUrl)
|
|
98
|
+
upgradeUrl = pause.actionUrl;
|
|
99
|
+
}
|
|
85
100
|
upgradeUrl ??= safeHttpsUrl(record.upgradeUrl);
|
|
101
|
+
if (typeof record.message === "string" && record.message.trim()) {
|
|
102
|
+
message ??= record.message.trim();
|
|
103
|
+
}
|
|
86
104
|
addPath(record.path);
|
|
87
105
|
if (Array.isArray(record.paths))
|
|
88
106
|
record.paths.forEach(addPath);
|
|
@@ -111,6 +129,7 @@ export function createPlanLimitRefusalTracker(opts = {}) {
|
|
|
111
129
|
paths: [...paths],
|
|
112
130
|
...(message ? { message } : {}),
|
|
113
131
|
...(url ? { upgradeUrl: url } : {}),
|
|
132
|
+
...(usagePaused ? { usagePause: true } : {}),
|
|
114
133
|
};
|
|
115
134
|
},
|
|
116
135
|
};
|
|
@@ -141,22 +160,26 @@ export function mergePlanLimitRefusals(a, b) {
|
|
|
141
160
|
paths.push(p);
|
|
142
161
|
const message = a.message ?? b.message;
|
|
143
162
|
const upgradeUrl = a.upgradeUrl ?? b.upgradeUrl;
|
|
163
|
+
const usagePause = a.usagePause === true || b.usagePause === true;
|
|
144
164
|
return {
|
|
145
165
|
count: a.count + b.count,
|
|
146
166
|
paths,
|
|
147
167
|
...(message ? { message } : {}),
|
|
148
168
|
...(upgradeUrl ? { upgradeUrl } : {}),
|
|
169
|
+
...(usagePause ? { usagePause } : {}),
|
|
149
170
|
};
|
|
150
171
|
}
|
|
151
172
|
/** How many named paths to print before summarising the rest. */
|
|
152
173
|
const MAX_LISTED_PATHS = 20;
|
|
174
|
+
/** The sentence when the server sent none. Names no plan. */
|
|
175
|
+
export const REFUSAL_FALLBACK_SENTENCE = "New files are paused for this account. Open its Billing page to see why.";
|
|
153
176
|
/**
|
|
154
177
|
* Plain lines describing a refusal: what was not uploaded, why, and the
|
|
155
|
-
*
|
|
178
|
+
* billing link. Colour and the leading status line are the caller's.
|
|
156
179
|
*/
|
|
157
180
|
export function renderPlanLimitRefusalLines(summary, indent = " ") {
|
|
158
181
|
const lines = [];
|
|
159
|
-
lines.push(`${indent}${summary.message ??
|
|
182
|
+
lines.push(`${indent}${summary.message ?? REFUSAL_FALLBACK_SENTENCE}`);
|
|
160
183
|
if (summary.paths.length > 0) {
|
|
161
184
|
lines.push(`${indent}Not uploaded:`);
|
|
162
185
|
for (const p of summary.paths.slice(0, MAX_LISTED_PATHS)) {
|
|
@@ -170,7 +193,7 @@ export function renderPlanLimitRefusalLines(summary, indent = " ") {
|
|
|
170
193
|
lines.push(`${indent}New files stay on this machine and were not uploaded.`);
|
|
171
194
|
}
|
|
172
195
|
if (summary.upgradeUrl)
|
|
173
|
-
lines.push(`${indent}
|
|
196
|
+
lines.push(`${indent}Billing: ${summary.upgradeUrl}`);
|
|
174
197
|
return lines;
|
|
175
198
|
}
|
|
176
199
|
/** "1 new file" / "3 new files", or "new files" when only a notice said so. */
|
|
@@ -3,6 +3,14 @@ export interface PlanLimitUpgradeUrlChoices {
|
|
|
3
3
|
nestedUrl?: string | null;
|
|
4
4
|
legacyUrl: string;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* The console's self-serve switch to usage pricing for one company. While HQ
|
|
8
|
+
* Workforce is closed to companies not already on it (hq-pro
|
|
9
|
+
* `billing.legacy-plans-closed`, owner decision 2026-10-19) this is where an
|
|
10
|
+
* upgrade leads; the Workforce checkout refuses. Null when no console origin
|
|
11
|
+
* can be derived; without a slug the generic billing page.
|
|
12
|
+
*/
|
|
13
|
+
export declare function usageSwitchPageUrl(companySlug?: string): string | null;
|
|
6
14
|
/** Resolve and attribute the final selected company billing destination. */
|
|
7
15
|
export declare function selectPlanLimitUpgradeUrl(companySlug: string | undefined, choices: PlanLimitUpgradeUrlChoices): string;
|
|
8
16
|
export declare function withCliLimitEntrySurface(value: string): string;
|
|
@@ -11,6 +11,24 @@ function companyBillingUrl(companySlug) {
|
|
|
11
11
|
: "/billing";
|
|
12
12
|
return `${origin}${route}`;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The console's self-serve switch to usage pricing for one company. While HQ
|
|
16
|
+
* Workforce is closed to companies not already on it (hq-pro
|
|
17
|
+
* `billing.legacy-plans-closed`, owner decision 2026-10-19) this is where an
|
|
18
|
+
* upgrade leads; the Workforce checkout refuses. Null when no console origin
|
|
19
|
+
* can be derived; without a slug the generic billing page.
|
|
20
|
+
*/
|
|
21
|
+
export function usageSwitchPageUrl(companySlug) {
|
|
22
|
+
const controlPlaneUrl = process.env.HQ_VAULT_API_URL?.trim() || DEFAULT_VAULT_API_URL;
|
|
23
|
+
const origin = consoleOrigin(controlPlaneUrl);
|
|
24
|
+
if (!origin)
|
|
25
|
+
return null;
|
|
26
|
+
const slug = companySlug?.trim();
|
|
27
|
+
const route = slug
|
|
28
|
+
? `/companies/${encodeURIComponent(slug)}/billing/usage-switch?entrySurface=cli_limit`
|
|
29
|
+
: "/billing";
|
|
30
|
+
return `${origin}${route}`;
|
|
31
|
+
}
|
|
14
32
|
/** Resolve and attribute the final selected company billing destination. */
|
|
15
33
|
export function selectPlanLimitUpgradeUrl(companySlug, choices) {
|
|
16
34
|
const topLevelUrl = choices.topLevelUrl?.trim() || undefined;
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
* Copy rule (US-036): no string a customer reads may contain "locked" or
|
|
18
18
|
* "read-only". `plan-lock.test.ts` scans every rendered string for both.
|
|
19
19
|
*
|
|
20
|
+
* Copy rule (US-024): the flat plans are closed, so nothing here offers the
|
|
21
|
+
* Workforce plan, its price or the free month, and nothing says "Upgrade".
|
|
22
|
+
* The plan is named (Starter) only when hq-pro said the company is on an
|
|
23
|
+
* earlier plan (`billingState` or `pricingPlan: "current"`); an unknown plan
|
|
24
|
+
* gets copy that names no plan, and the way out besides trimming is the
|
|
25
|
+
* switch to usage pricing or the company's Billing page.
|
|
26
|
+
*
|
|
20
27
|
* An absent field is UNKNOWN, so nothing here ever infers over-limit from
|
|
21
28
|
* missing data (hq-absent-field-never-means-constraining-value).
|
|
22
29
|
*/
|
|
@@ -58,8 +65,18 @@ export interface PlanLimitStatus {
|
|
|
58
65
|
* which keeps the legacy copy.
|
|
59
66
|
*/
|
|
60
67
|
pricingPlan?: PricingPlan;
|
|
68
|
+
/**
|
|
69
|
+
* True when hq-pro says HQ Workforce is closed to this company (hq-flags
|
|
70
|
+
* `billing.legacy-plans-closed`, owner decision 2026-10-19). `upgradeUrl`
|
|
71
|
+
* is then the console's usage-pricing switch page, the free month never
|
|
72
|
+
* stands, and the renderers offer the switch instead of a Workforce
|
|
73
|
+
* upgrade. Absent keeps the legacy copy (older servers).
|
|
74
|
+
*/
|
|
75
|
+
legacyPlansClosed?: boolean;
|
|
61
76
|
checkedAt: string;
|
|
62
77
|
}
|
|
78
|
+
/** The way out while HQ Workforce is closed. Copy lives in ONE place. */
|
|
79
|
+
export declare const USAGE_SWITCH_LABEL = "Switch to usage pricing";
|
|
63
80
|
/** Starter member cap quoted when the server did not send a users.limit. */
|
|
64
81
|
export declare const STARTER_MEMBER_TARGET = 5;
|
|
65
82
|
/**
|
|
@@ -71,10 +88,8 @@ export declare const STARTER_INTEGRATION_TARGET = 3;
|
|
|
71
88
|
export declare const STARTER_SECRET_TARGET = 10;
|
|
72
89
|
/** Starter agent cap quoted when the server did not send an agents.limit. */
|
|
73
90
|
export declare const STARTER_AGENT_TARGET = 0;
|
|
74
|
-
/**
|
|
91
|
+
/** Billing destination quoted when the server did not send one. */
|
|
75
92
|
export declare const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
|
|
76
|
-
/** The paid plan the nag sends owners to. Copy lives in ONE place. */
|
|
77
|
-
export declare const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
|
|
78
93
|
/**
|
|
79
94
|
* Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
|
|
80
95
|
* `/membership/me` body. No matching row is UNKNOWN. Does not read `planLock`.
|
|
@@ -133,9 +148,10 @@ export declare function renderPlanLimitNotice(status: PlanLimitStatus, opts?: {
|
|
|
133
148
|
*/
|
|
134
149
|
export declare function renderPlanLimitLine(status: PlanLimitStatus): string;
|
|
135
150
|
/**
|
|
136
|
-
* The `Plan: …` orientation line. Starter only
|
|
137
|
-
*
|
|
138
|
-
*
|
|
151
|
+
* The `Plan: …` orientation line. Starter only, and only when hq-pro said the
|
|
152
|
+
* company is on an earlier plan: a paid or enterprise workspace gets no line,
|
|
153
|
+
* and an UNKNOWN pricing plan names no plan. A usage-pricing company gets the
|
|
154
|
+
* usage line instead.
|
|
139
155
|
*/
|
|
140
156
|
export declare function renderPlanLine(status: PlanLimitStatus): string | null;
|
|
141
157
|
/**
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
* Copy rule (US-036): no string a customer reads may contain "locked" or
|
|
18
18
|
* "read-only". `plan-lock.test.ts` scans every rendered string for both.
|
|
19
19
|
*
|
|
20
|
+
* Copy rule (US-024): the flat plans are closed, so nothing here offers the
|
|
21
|
+
* Workforce plan, its price or the free month, and nothing says "Upgrade".
|
|
22
|
+
* The plan is named (Starter) only when hq-pro said the company is on an
|
|
23
|
+
* earlier plan (`billingState` or `pricingPlan: "current"`); an unknown plan
|
|
24
|
+
* gets copy that names no plan, and the way out besides trimming is the
|
|
25
|
+
* switch to usage pricing or the company's Billing page.
|
|
26
|
+
*
|
|
20
27
|
* An absent field is UNKNOWN, so nothing here ever infers over-limit from
|
|
21
28
|
* missing data (hq-absent-field-never-means-constraining-value).
|
|
22
29
|
*/
|
|
@@ -24,7 +31,7 @@ import chalk from "chalk";
|
|
|
24
31
|
import { diagnosticsEnabled } from "../diagnostics.js";
|
|
25
32
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
26
33
|
import { parseFreeMonth } from "./free-month.js";
|
|
27
|
-
import { selectPlanLimitUpgradeUrl } from "./plan-limit-upgrade-url.js";
|
|
34
|
+
import { selectPlanLimitUpgradeUrl, usageSwitchPageUrl, } from "./plan-limit-upgrade-url.js";
|
|
28
35
|
import { fetchCompanyPricingPlan, isUsageOnly, pricingPlanFromBody, pricingPlanFromMembership, usageOnlyPlanLine, usageOnlyUpgradeLines, } from "./usage-only.js";
|
|
29
36
|
// The free-month parser lives in a leaf module so the plan-gate 402 decoder can
|
|
30
37
|
// share it without importing the HTTP layer; re-exported so existing callers
|
|
@@ -41,6 +48,8 @@ export const PLAN_LIMIT_REASONS = [
|
|
|
41
48
|
"secrets",
|
|
42
49
|
"agents",
|
|
43
50
|
];
|
|
51
|
+
/** The way out while HQ Workforce is closed. Copy lives in ONE place. */
|
|
52
|
+
export const USAGE_SWITCH_LABEL = "Switch to usage pricing";
|
|
44
53
|
/** Starter member cap quoted when the server did not send a users.limit. */
|
|
45
54
|
export const STARTER_MEMBER_TARGET = 5;
|
|
46
55
|
/**
|
|
@@ -52,10 +61,8 @@ export const STARTER_INTEGRATION_TARGET = 3;
|
|
|
52
61
|
export const STARTER_SECRET_TARGET = 10;
|
|
53
62
|
/** Starter agent cap quoted when the server did not send an agents.limit. */
|
|
54
63
|
export const STARTER_AGENT_TARGET = 0;
|
|
55
|
-
/**
|
|
64
|
+
/** Billing destination quoted when the server did not send one. */
|
|
56
65
|
export const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
|
|
57
|
-
/** The paid plan the nag sends owners to. Copy lives in ONE place. */
|
|
58
|
-
export const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
|
|
59
66
|
function finiteNumber(value) {
|
|
60
67
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
61
68
|
}
|
|
@@ -157,6 +164,17 @@ function selectUpgradeUrl(body, companySlug) {
|
|
|
157
164
|
const nestedUrl = typeof nested?.upgradeUrl === "string" && nested.upgradeUrl.trim().length > 0
|
|
158
165
|
? nested.upgradeUrl.trim()
|
|
159
166
|
: undefined;
|
|
167
|
+
// While HQ Workforce is closed the way out is the usage-pricing switch, not
|
|
168
|
+
// the company billing page the generated URL would land on. Build it from
|
|
169
|
+
// the slug when known; otherwise keep the server's own switch URL.
|
|
170
|
+
if (rec?.legacyPlansClosed === true) {
|
|
171
|
+
const slug = companySlug?.trim();
|
|
172
|
+
return (slug ? usageSwitchPageUrl(slug) : undefined) ??
|
|
173
|
+
topLevelUrl ??
|
|
174
|
+
nestedUrl ??
|
|
175
|
+
usageSwitchPageUrl(undefined) ??
|
|
176
|
+
DEFAULT_UPGRADE_URL;
|
|
177
|
+
}
|
|
160
178
|
return selectPlanLimitUpgradeUrl(companySlug, {
|
|
161
179
|
topLevelUrl,
|
|
162
180
|
nestedUrl,
|
|
@@ -210,6 +228,7 @@ function statusFromUsage(companySlug, companyUid, body, upgradeCompanySlug) {
|
|
|
210
228
|
upgradeUrl: selectUpgradeUrl(body, upgradeCompanySlug),
|
|
211
229
|
freeMonth: parseFreeMonth(rec?.freeMonth),
|
|
212
230
|
plan: selectPlan(body) ?? undefined,
|
|
231
|
+
...(rec?.legacyPlansClosed === true ? { legacyPlansClosed: true } : {}),
|
|
213
232
|
checkedAt: new Date().toISOString(),
|
|
214
233
|
}, pricingPlan);
|
|
215
234
|
}
|
|
@@ -295,6 +314,10 @@ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
|
|
|
295
314
|
return null;
|
|
296
315
|
}
|
|
297
316
|
}
|
|
317
|
+
/** True when hq-pro said the company is on an earlier (flat) plan. */
|
|
318
|
+
function knownEarlierPlan(status) {
|
|
319
|
+
return status.pricingPlan === "current";
|
|
320
|
+
}
|
|
298
321
|
/** Why the workspace is over, one clause per reason. */
|
|
299
322
|
export function reasonLabel(reason) {
|
|
300
323
|
switch (reason) {
|
|
@@ -305,7 +328,7 @@ export function reasonLabel(reason) {
|
|
|
305
328
|
case "secrets":
|
|
306
329
|
return "too many secrets";
|
|
307
330
|
case "agents":
|
|
308
|
-
return "agents are not included
|
|
331
|
+
return "agents are not included";
|
|
309
332
|
}
|
|
310
333
|
}
|
|
311
334
|
function usageTarget(reason, status) {
|
|
@@ -357,7 +380,7 @@ function reasonDetail(reason, status) {
|
|
|
357
380
|
? `${row.used} of ${target} secrets`
|
|
358
381
|
: `over its ${target}-secret limit`;
|
|
359
382
|
case "agents":
|
|
360
|
-
return "agents are not included
|
|
383
|
+
return "agents are not included";
|
|
361
384
|
}
|
|
362
385
|
}
|
|
363
386
|
function usageLine(reason, status) {
|
|
@@ -368,20 +391,20 @@ function usageLine(reason, status) {
|
|
|
368
391
|
const members = row ?? status.members;
|
|
369
392
|
return members
|
|
370
393
|
? ` Members: ${members.used} of ${target}.`
|
|
371
|
-
: ` Members allowed
|
|
394
|
+
: ` Members allowed: ${target}.`;
|
|
372
395
|
}
|
|
373
396
|
case "integrations":
|
|
374
397
|
return row
|
|
375
398
|
? ` Integrations: ${row.used} of ${target}.`
|
|
376
|
-
: ` Integrations allowed
|
|
399
|
+
: ` Integrations allowed: ${target}.`;
|
|
377
400
|
case "secrets":
|
|
378
401
|
return row
|
|
379
402
|
? ` Secrets: ${row.used} of ${target}.`
|
|
380
|
-
: ` Secrets allowed
|
|
403
|
+
: ` Secrets allowed: ${target}.`;
|
|
381
404
|
case "agents":
|
|
382
405
|
return row
|
|
383
406
|
? ` Agents: ${row.used} of ${target}.`
|
|
384
|
-
: ` Agents allowed
|
|
407
|
+
: ` Agents allowed: ${target}.`;
|
|
385
408
|
}
|
|
386
409
|
}
|
|
387
410
|
/**
|
|
@@ -409,15 +432,17 @@ function capitalize(text) {
|
|
|
409
432
|
* about a dimension nobody measured.
|
|
410
433
|
*/
|
|
411
434
|
export function renderPlanLimitNotice(status, opts = {}) {
|
|
412
|
-
const { companySlug
|
|
435
|
+
const { companySlug } = status;
|
|
413
436
|
const lines = [];
|
|
414
437
|
if (isUsageOnly(status.pricingPlan)) {
|
|
415
438
|
return [`${companySlug}:`, ...usageOnlyUpgradeLines().map((l) => ` ${l}`)].join("\n");
|
|
416
439
|
}
|
|
417
|
-
lines.push(
|
|
440
|
+
lines.push(knownEarlierPlan(status)
|
|
441
|
+
? `HQ Starter — ${companySlug} is over its Starter limits.`
|
|
442
|
+
: `${companySlug} is over its limits.`);
|
|
418
443
|
const reasons = status.reasons.length
|
|
419
444
|
? status.reasons.map(reasonLabel).join("; ")
|
|
420
|
-
: "over
|
|
445
|
+
: "over its limits";
|
|
421
446
|
lines.push(` Why: ${reasons}.`);
|
|
422
447
|
for (const reason of status.reasons) {
|
|
423
448
|
lines.push(usageLine(reason, status));
|
|
@@ -426,21 +451,20 @@ export function renderPlanLimitNotice(status, opts = {}) {
|
|
|
426
451
|
lines.push(HARD_STOP_SENTENCE_LINES[1]);
|
|
427
452
|
const remedy = status.reasons.length
|
|
428
453
|
? status.reasons.map((reason) => reasonRemedy(reason, status)).join(", and ")
|
|
429
|
-
: "come back under
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
454
|
+
: "come back under its limits";
|
|
455
|
+
// The flat plans are closed: no Workforce offer and no free month (it led
|
|
456
|
+
// to the closed checkout). The second way out is the switch to usage
|
|
457
|
+
// pricing when the server said the company is on an earlier plan or that
|
|
458
|
+
// the flat plans are closed to it; otherwise the company's Billing page.
|
|
459
|
+
lines.push(" Two ways to sort it:");
|
|
460
|
+
lines.push(` 1. ${capitalize(remedy)}.`);
|
|
461
|
+
if (status.legacyPlansClosed || knownEarlierPlan(status)) {
|
|
462
|
+
lines.push(` 2. ${USAGE_SWITCH_LABEL}: no plan fee, and these limits stop applying.`);
|
|
437
463
|
}
|
|
438
464
|
else {
|
|
439
|
-
lines.push("
|
|
440
|
-
lines.push(` 1. ${capitalize(remedy)}.`);
|
|
441
|
-
lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
|
|
465
|
+
lines.push(" 2. Open the company's Billing page.");
|
|
442
466
|
}
|
|
443
|
-
lines.push(`
|
|
467
|
+
lines.push(` ${status.legacyPlansClosed ? "Switch" : "Billing"}: ${status.upgradeUrl}`);
|
|
444
468
|
if (opts.inviteTeammateEnabled === true &&
|
|
445
469
|
status.plan === "free" &&
|
|
446
470
|
status.over &&
|
|
@@ -459,18 +483,22 @@ export function renderPlanLimitLine(status) {
|
|
|
459
483
|
}
|
|
460
484
|
const detail = status.reasons.length
|
|
461
485
|
? status.reasons.map((reason) => reasonDetail(reason, status)).join("; ")
|
|
462
|
-
: "over
|
|
463
|
-
|
|
486
|
+
: "over its limits";
|
|
487
|
+
const limits = knownEarlierPlan(status) ? "its Starter limits" : "its limits";
|
|
488
|
+
return (`Company ${status.companySlug} is over ${limits} (${detail}). ` +
|
|
464
489
|
`New files and new secrets are paused until this is sorted: ${status.upgradeUrl}`);
|
|
465
490
|
}
|
|
466
491
|
/**
|
|
467
|
-
* The `Plan: …` orientation line. Starter only
|
|
468
|
-
*
|
|
469
|
-
*
|
|
492
|
+
* The `Plan: …` orientation line. Starter only, and only when hq-pro said the
|
|
493
|
+
* company is on an earlier plan: a paid or enterprise workspace gets no line,
|
|
494
|
+
* and an UNKNOWN pricing plan names no plan. A usage-pricing company gets the
|
|
495
|
+
* usage line instead.
|
|
470
496
|
*/
|
|
471
497
|
export function renderPlanLine(status) {
|
|
472
498
|
if (isUsageOnly(status.pricingPlan))
|
|
473
499
|
return usageOnlyPlanLine();
|
|
500
|
+
if (!knownEarlierPlan(status))
|
|
501
|
+
return status.over ? "Plan: over its limits" : null;
|
|
474
502
|
if (status.over)
|
|
475
503
|
return "Plan: Starter — over its limits";
|
|
476
504
|
if (status.plan !== "free")
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* - optionally, the live cloud file-history listing `hq storage status`
|
|
14
14
|
* uses, to split storage into current files and old versions and to see a
|
|
15
15
|
* prune right away (Storage Lens, behind usage-limits, updates daily).
|
|
16
|
+
* With the usage statement, the listing is a separate current-storage
|
|
17
|
+
* breakdown only; the statement's month-average storage meter is billed.
|
|
16
18
|
* usage-limits also answers `vaults` when the server counts them; without it
|
|
17
19
|
* the vault meter is `not-available`.
|
|
18
20
|
* The call-count meters (syncs, gateway calls, HQ MCP calls, agent runs) have
|
|
@@ -45,6 +47,16 @@ export interface MeterReading {
|
|
|
45
47
|
/** In billed units (GB for storage). Null unless measured. */
|
|
46
48
|
value: number | null;
|
|
47
49
|
state: MeterState;
|
|
50
|
+
/**
|
|
51
|
+
* The server's own priced amounts for this line (usage statement only).
|
|
52
|
+
* When present they are the bill: the statement is what is invoiced to the
|
|
53
|
+
* cent, so the line is never re-priced from the rate card.
|
|
54
|
+
*/
|
|
55
|
+
accrued?: {
|
|
56
|
+
monthToDateUsd: number;
|
|
57
|
+
projectedUsd: number;
|
|
58
|
+
waived?: boolean;
|
|
59
|
+
};
|
|
48
60
|
}
|
|
49
61
|
export interface StorageSplit {
|
|
50
62
|
/** Bytes in current files. */
|
|
@@ -60,7 +72,7 @@ export interface StorageSplit {
|
|
|
60
72
|
export interface MonthUsage {
|
|
61
73
|
scope: UsageScope;
|
|
62
74
|
/** Which source answered; lets a reader tell interim numbers from the rollup. */
|
|
63
|
-
source: "usage-limits" | "daily-rollup";
|
|
75
|
+
source: "usage-statement" | "usage-limits" | "daily-rollup";
|
|
64
76
|
/** Month-to-date window, UTC. */
|
|
65
77
|
period: {
|
|
66
78
|
start: string;
|
|
@@ -71,6 +83,8 @@ export interface MonthUsage {
|
|
|
71
83
|
storage?: StorageSplit;
|
|
72
84
|
/** True when the account's usage trial is running (meters waived in trial bill $0). Absent = unknown. */
|
|
73
85
|
trialActive?: boolean;
|
|
86
|
+
/** The included usage the usage statement applied. Absent: use the rate card's. */
|
|
87
|
+
allowanceUsd?: number;
|
|
74
88
|
}
|
|
75
89
|
/** One way to read a month of usage. The rollup becomes a second implementation. */
|
|
76
90
|
export interface MonthUsageSource {
|
|
@@ -119,6 +133,14 @@ export declare function meterReadingsFromDailyRows(rows: readonly UsageDailyRowL
|
|
|
119
133
|
* {@link readMonthUsage} once that route ships; the company scope only.
|
|
120
134
|
*/
|
|
121
135
|
export declare function dailyRollupSource(fetchRows: (scope: UsageScope, from: string, to: string) => Promise<readonly UsageDailyRowLike[] | null>): MonthUsageSource;
|
|
136
|
+
export declare function meterReadingsFromStatement(body: unknown): MeterReading[] | null;
|
|
137
|
+
/**
|
|
138
|
+
* The usage statement as a source (`GET /v1/billing/usage`, US-015). The
|
|
139
|
+
* company statement is owner-gated on the server; a person statement is the
|
|
140
|
+
* caller's own. `fetchStatement` resolves the decoded body, or null when the
|
|
141
|
+
* server did not answer.
|
|
142
|
+
*/
|
|
143
|
+
export declare function usageStatementSource(fetchStatement: (scope: UsageScope) => Promise<unknown | null>): MonthUsageSource;
|
|
122
144
|
/**
|
|
123
145
|
* The one function every caller uses to read a month of usage. Switch the
|
|
124
146
|
* default source here when the US-005 rollup has a read route.
|
|
@@ -151,7 +173,7 @@ export interface UsageBill {
|
|
|
151
173
|
daysElapsed: number;
|
|
152
174
|
daysInMonth: number;
|
|
153
175
|
}
|
|
154
|
-
export declare function computeBill(usage: MonthUsage, rates?: readonly UsageMeterRate[],
|
|
176
|
+
export declare function computeBill(usage: MonthUsage, rates?: readonly UsageMeterRate[], cardAllowanceUsd?: number): UsageBill;
|
|
155
177
|
export type FixId = "prune-old-versions" | "remove-unused-secrets" | "exclude-folders" | "turn-sync-off";
|
|
156
178
|
/**
|
|
157
179
|
* A fix is a pair of command lists. `preview` only reads (dry runs, listings);
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* - optionally, the live cloud file-history listing `hq storage status`
|
|
14
14
|
* uses, to split storage into current files and old versions and to see a
|
|
15
15
|
* prune right away (Storage Lens, behind usage-limits, updates daily).
|
|
16
|
+
* With the usage statement, the listing is a separate current-storage
|
|
17
|
+
* breakdown only; the statement's month-average storage meter is billed.
|
|
16
18
|
* usage-limits also answers `vaults` when the server counts them; without it
|
|
17
19
|
* the vault meter is `not-available`.
|
|
18
20
|
* The call-count meters (syncs, gateway calls, HQ MCP calls, agent runs) have
|
|
@@ -145,6 +147,83 @@ export function dailyRollupSource(fetchRows) {
|
|
|
145
147
|
},
|
|
146
148
|
};
|
|
147
149
|
}
|
|
150
|
+
/** Statement line key (hq-pro `UsageStatementLine.key`) -> rate-card key. */
|
|
151
|
+
const STATEMENT_KEYS = new Set(BUNDLED_USAGE_METER_RATES.map(({ key }) => key));
|
|
152
|
+
/**
|
|
153
|
+
* Meter readings from a `GET /v1/billing/usage` statement body (hq-pro
|
|
154
|
+
* src/billing/usage-statement.ts, US-006). `usage` is the natural amount:
|
|
155
|
+
* counters are the month's total, gauges the mean daily level. Storage comes
|
|
156
|
+
* in bytes and is converted with the statement's own `storageBytesPerGb`.
|
|
157
|
+
* A line that is unavailable, not billed yet, or missing is `not-available`,
|
|
158
|
+
* never zero. Null when the body is not a statement.
|
|
159
|
+
*/
|
|
160
|
+
function centsField(v) {
|
|
161
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
162
|
+
}
|
|
163
|
+
export function meterReadingsFromStatement(body) {
|
|
164
|
+
const rec = body !== null && typeof body === "object" && !Array.isArray(body)
|
|
165
|
+
? body
|
|
166
|
+
: null;
|
|
167
|
+
if (!rec || !Array.isArray(rec.lines))
|
|
168
|
+
return null;
|
|
169
|
+
const bytesPerGb = typeof rec.storageBytesPerGb === "number" && rec.storageBytesPerGb > 0
|
|
170
|
+
? rec.storageBytesPerGb
|
|
171
|
+
: BYTES_PER_GB;
|
|
172
|
+
const byKey = new Map();
|
|
173
|
+
for (const raw of rec.lines) {
|
|
174
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
175
|
+
continue;
|
|
176
|
+
const line = raw;
|
|
177
|
+
if (typeof line.key !== "string" || !STATEMENT_KEYS.has(line.key))
|
|
178
|
+
continue;
|
|
179
|
+
const key = line.key;
|
|
180
|
+
const measured = line.status === "measured" || line.status === "partial";
|
|
181
|
+
const usage = line.usage;
|
|
182
|
+
if (!measured || typeof usage !== "number" || !Number.isFinite(usage) || usage < 0) {
|
|
183
|
+
byKey.set(key, { key, value: null, state: "not-available" });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const reading = { key, value: key === "storage" ? usage / bytesPerGb : usage, state: "measured" };
|
|
187
|
+
const cost = centsField(line.costCents);
|
|
188
|
+
const projected = centsField(line.projectedCostCents);
|
|
189
|
+
if (cost !== null && projected !== null) {
|
|
190
|
+
reading.accrued = {
|
|
191
|
+
monthToDateUsd: cost / 100,
|
|
192
|
+
projectedUsd: projected / 100,
|
|
193
|
+
...(line.waived === true ? { waived: true } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
byKey.set(key, reading);
|
|
197
|
+
}
|
|
198
|
+
return BUNDLED_USAGE_METER_RATES.map(({ key }) => byKey.get(key) ?? { key, value: null, state: "not-available" });
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The usage statement as a source (`GET /v1/billing/usage`, US-015). The
|
|
202
|
+
* company statement is owner-gated on the server; a person statement is the
|
|
203
|
+
* caller's own. `fetchStatement` resolves the decoded body, or null when the
|
|
204
|
+
* server did not answer.
|
|
205
|
+
*/
|
|
206
|
+
export function usageStatementSource(fetchStatement) {
|
|
207
|
+
return {
|
|
208
|
+
name: "usage-statement",
|
|
209
|
+
async read(scope, now) {
|
|
210
|
+
const { start, end } = monthWindow(now);
|
|
211
|
+
const body = await fetchStatement(scope);
|
|
212
|
+
const meters = meterReadingsFromStatement(body);
|
|
213
|
+
if (meters === null) {
|
|
214
|
+
throw new Error("Could not read usage from HQ right now. Try again, or run `hq login` if you are signed out.");
|
|
215
|
+
}
|
|
216
|
+
const allowanceCents = centsField(body.allowanceCents);
|
|
217
|
+
return {
|
|
218
|
+
scope,
|
|
219
|
+
source: "usage-statement",
|
|
220
|
+
period: { start: start.toISOString(), end: end.toISOString(), asOf: now.toISOString() },
|
|
221
|
+
meters,
|
|
222
|
+
...(allowanceCents !== null ? { allowanceUsd: allowanceCents / 100 } : {}),
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
148
227
|
/**
|
|
149
228
|
* The one function every caller uses to read a month of usage. Switch the
|
|
150
229
|
* default source here when the US-005 rollup has a read route.
|
|
@@ -159,8 +238,15 @@ export async function readMonthUsage(scope, deps, source = usageLimitsSource(dep
|
|
|
159
238
|
const split = await deps.readStorageSplit(scope).catch(() => null);
|
|
160
239
|
if (!split)
|
|
161
240
|
return base;
|
|
162
|
-
//
|
|
163
|
-
//
|
|
241
|
+
// An accrued source (the usage statement, the daily rollup) bills storage as
|
|
242
|
+
// the month's average. That meter is authoritative: the live listing is a
|
|
243
|
+
// point-in-time figure, kept only as the separate `storage` breakdown that
|
|
244
|
+
// explains what is taking space. It never replaces or re-prices the meter.
|
|
245
|
+
if (base.source !== "usage-limits")
|
|
246
|
+
return { ...base, storage: split };
|
|
247
|
+
// usage-limits storage is itself a point-in-time reading (Storage Lens,
|
|
248
|
+
// updated daily). The live listing measures the same thing more freshly, so
|
|
249
|
+
// a prune shows up in the very next read.
|
|
164
250
|
const liveGb = (split.currentBytes + split.oldVersionBytes) / BYTES_PER_GB;
|
|
165
251
|
return {
|
|
166
252
|
...base,
|
|
@@ -169,7 +255,8 @@ export async function readMonthUsage(scope, deps, source = usageLimitsSource(dep
|
|
|
169
255
|
};
|
|
170
256
|
}
|
|
171
257
|
const round2 = (n) => Math.round(n * 100) / 100;
|
|
172
|
-
export function computeBill(usage, rates = BUNDLED_USAGE_METER_RATES,
|
|
258
|
+
export function computeBill(usage, rates = BUNDLED_USAGE_METER_RATES, cardAllowanceUsd = USAGE_MONTHLY_ALLOWANCE_USD) {
|
|
259
|
+
const allowanceUsd = usage.allowanceUsd ?? cardAllowanceUsd;
|
|
173
260
|
const asOf = new Date(usage.period.asOf);
|
|
174
261
|
const { start, end } = monthWindow(asOf);
|
|
175
262
|
const dayMs = 86_400_000;
|
|
@@ -187,6 +274,19 @@ export function computeBill(usage, rates = BUNDLED_USAGE_METER_RATES, allowanceU
|
|
|
187
274
|
incomplete = true;
|
|
188
275
|
return { ...pick(rate), value: null, state: reading.state, monthToDateUsd: null, projectedUsd: null };
|
|
189
276
|
}
|
|
277
|
+
if (reading.accrued) {
|
|
278
|
+
const { monthToDateUsd: m, projectedUsd: p, waived } = reading.accrued;
|
|
279
|
+
mtd += m;
|
|
280
|
+
projected += p;
|
|
281
|
+
return {
|
|
282
|
+
...pick(rate),
|
|
283
|
+
value: reading.value,
|
|
284
|
+
state: "measured",
|
|
285
|
+
monthToDateUsd: round2(m),
|
|
286
|
+
projectedUsd: round2(p),
|
|
287
|
+
...(waived ? { waivedInTrial: true } : {}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
190
290
|
if (rate.waivedInTrial && usage.trialActive === true) {
|
|
191
291
|
return { ...pick(rate), value: reading.value, state: "measured", monthToDateUsd: 0, projectedUsd: 0, waivedInTrial: true };
|
|
192
292
|
}
|