@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.160] — 2026-10-22
|
|
6
|
+
- The bundled sync engine now uses hq-cloud 6.18.98. When HQ pauses an account because of usage, `hq sync` now shows the server's usage-pause message and a Billing link instead of a generic failure (hq-cloud #1036).
|
|
7
|
+
- `hq billing usage` now shows the amounts on HQ's usage statement, which is what the invoice bills. Before this it re-priced the meters itself, so it could show a different figure than the statement, for example charging the vault fee during the trial when the statement waived it.
|
|
8
|
+
- When HQ pauses an account because of usage (spend limit reached, card needed, or trial cap hit), `hq` now shows the server's own message and a Billing link, with no plan-limit or Upgrade wording and no old plan names (Starter, Workforce, Individual). This covers sync, skills, agent enroll, meetings, members, secrets, integrations and remote databases. `hq billing upgrade` now opens the usage-pricing switch page instead of a Workforce checkout. `hq billing usage` now shows the company's usage view, and tells a member to use `--personal` for their own usage. Plan and trial reads use the server's shared plan status when it is available, and older servers keep working as before. (US-024)
|
|
9
|
+
|
|
10
|
+
- `hq files acl` and `hq access` show a path whose most-specific ACL row has no grants and is not open as locked by that row (`lockedBy` in `--json`), instead of as inheriting a broader `*` grant. An open empty row is not a lock: broader grants still apply. An empty ancestor row, whose open flag the server does not report, reads as locked unless that row is open.
|
|
11
|
+
- `hq billing upgrade` no longer dead-ends on the closed HQ Workforce checkout. When hq-pro answers that Workforce is closed to the company (`workforce_closed_to_new_signups`), the command explains that the company stays on Starter unless it switches to usage pricing, says to add a card first with `hq billing checkout`, and opens the console's usage-switch page for the company (`--no-browser` prints it; `--json` returns `{ url, legacyPlansClosed: true, message }`). The open-checkout line no longer quotes a plan price. The over-limit notice (`hq whoami`) and the plan-limit refusal printed by `hq secrets`, `hq members` and sync now read hq-pro's `legacyPlansClosed` field: while Workforce is closed they offer "Switch to usage pricing" with the switch link and never the free Workforce month or an "Upgrade to HQ Workforce" line. Older servers keep today's wording.
|
|
12
|
+
- `hq-hook` gains the US-021 bridge used by master-hook.sh: `hq-hook bridge-plan`
|
|
13
|
+
lists the registry hooks whose `hook-engine-port-<id>` flag is `shadow` or
|
|
14
|
+
`on`, `hq-hook bridge <id>` runs one hook natively (shadow compares with bash
|
|
15
|
+
and returns the bash result; on returns the native result; a missing port or
|
|
16
|
+
a declined payload falls back to bash), and `hq-hook --self-check` reports the
|
|
17
|
+
engine version and bridge schema. The local kill switch `HQ_HOOK_ENGINE=off`
|
|
18
|
+
or the file `~/.hq/hook-engine.off` forces bash before any flag is read.
|
|
19
|
+
Shadow comparisons are appended to `~/.hq/logs/hook-engine.jsonl` with hook
|
|
20
|
+
ids, hashes, exit codes and timings only.
|
|
21
|
+
- `hq-hook run derive-trigger-facts` and `hq-hook run eval-trigger` are native
|
|
22
|
+
ports of the two policy-trigger helpers (US-008, first slice), byte-identical
|
|
23
|
+
to the bundled shell on the parity corpus, with git facts read in-process.
|
|
24
|
+
- `hq core hook-engine-path` now runs the engine's `--self-check` before
|
|
25
|
+
printing its path, and writes `~/.hq/bin/hq-hook/current` so hooks can find
|
|
26
|
+
the pinned engine without starting Node. A binary that fails its self-check
|
|
27
|
+
exits non-zero and removes that pointer, keeping bash hooks active.
|
|
28
|
+
|
|
5
29
|
## [5.345.159] — 2026-10-22
|
|
6
30
|
- `hq lanes link read` and `wait` now require the selected conductor session to own the requested link, preventing accidental cross-link report consumption while preserving explicit session resume/delegation and child inbox access.
|
|
7
31
|
- `hq lanes resume` now uses the canonical lane ID after resolving a short ID. When low disk space queues a resume, it keeps the requested instruction for later admission and reports the lane as queued rather than started.
|
|
@@ -1393,7 +1393,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1393
1393
|
readonly hidden: false;
|
|
1394
1394
|
}, {
|
|
1395
1395
|
readonly flags: "--company <slug>";
|
|
1396
|
-
readonly description: "Also report this company's
|
|
1396
|
+
readonly description: "Also report this company's billing and usage notice";
|
|
1397
1397
|
readonly hidden: false;
|
|
1398
1398
|
}];
|
|
1399
1399
|
readonly subcommands: readonly [];
|
|
@@ -8478,7 +8478,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8478
8478
|
readonly subcommands: readonly [];
|
|
8479
8479
|
}, {
|
|
8480
8480
|
readonly name: "upgrade";
|
|
8481
|
-
readonly description: "Open
|
|
8481
|
+
readonly description: "Open the switch to usage pricing for a company on an earlier plan (usage-pricing companies have nothing to change)";
|
|
8482
8482
|
readonly aliases: readonly [];
|
|
8483
8483
|
readonly hidden: false;
|
|
8484
8484
|
readonly helpOption: true;
|
|
@@ -8492,11 +8492,11 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8492
8492
|
readonly hidden: false;
|
|
8493
8493
|
}, {
|
|
8494
8494
|
readonly flags: "--no-browser";
|
|
8495
|
-
readonly description: "Print the
|
|
8495
|
+
readonly description: "Print the URL instead of opening it";
|
|
8496
8496
|
readonly hidden: false;
|
|
8497
8497
|
}, {
|
|
8498
8498
|
readonly flags: "--json";
|
|
8499
|
-
readonly description: "Emit the
|
|
8499
|
+
readonly description: "Emit the URL as JSON";
|
|
8500
8500
|
readonly hidden: false;
|
|
8501
8501
|
}];
|
|
8502
8502
|
readonly subcommands: readonly [];
|
|
@@ -1674,7 +1674,7 @@ export const COMMAND_CATALOG = [
|
|
|
1674
1674
|
},
|
|
1675
1675
|
{
|
|
1676
1676
|
"flags": "--company <slug>",
|
|
1677
|
-
"description": "Also report this company's
|
|
1677
|
+
"description": "Also report this company's billing and usage notice",
|
|
1678
1678
|
"hidden": false
|
|
1679
1679
|
}
|
|
1680
1680
|
],
|
|
@@ -10339,7 +10339,7 @@ export const COMMAND_CATALOG = [
|
|
|
10339
10339
|
},
|
|
10340
10340
|
{
|
|
10341
10341
|
"name": "upgrade",
|
|
10342
|
-
"description": "Open
|
|
10342
|
+
"description": "Open the switch to usage pricing for a company on an earlier plan (usage-pricing companies have nothing to change)",
|
|
10343
10343
|
"aliases": [],
|
|
10344
10344
|
"hidden": false,
|
|
10345
10345
|
"helpOption": true,
|
|
@@ -10355,12 +10355,12 @@ export const COMMAND_CATALOG = [
|
|
|
10355
10355
|
},
|
|
10356
10356
|
{
|
|
10357
10357
|
"flags": "--no-browser",
|
|
10358
|
-
"description": "Print the
|
|
10358
|
+
"description": "Print the URL instead of opening it",
|
|
10359
10359
|
"hidden": false
|
|
10360
10360
|
},
|
|
10361
10361
|
{
|
|
10362
10362
|
"flags": "--json",
|
|
10363
|
-
"description": "Emit the
|
|
10363
|
+
"description": "Emit the URL as JSON",
|
|
10364
10364
|
"hidden": false
|
|
10365
10365
|
}
|
|
10366
10366
|
],
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { Command } from "commander";
|
|
17
17
|
import { type CompanyBrowseClientFactory, type FilesBrowseVaultClient, type RunGetResult } from "./files-browse.js";
|
|
18
|
+
import { type AclLock } from "./acl-lock.js";
|
|
18
19
|
export type AccessOutcome = "never-existed" | "local" | "not-synced" | "no-access" | "ambiguous" | "pending-confirmation";
|
|
19
20
|
export interface AccessAclTree {
|
|
20
21
|
prefix: string;
|
|
@@ -145,6 +146,11 @@ export interface RunAccessResult {
|
|
|
145
146
|
requestSentAt?: string;
|
|
146
147
|
alreadyAskedAt?: string;
|
|
147
148
|
requestNote?: string;
|
|
149
|
+
/**
|
|
150
|
+
* Most-specific ACL row covering the path when it has no grants. Broader
|
|
151
|
+
* grants (including a root `*` company-wide grant) do not apply.
|
|
152
|
+
*/
|
|
153
|
+
lockedBy?: AclLock;
|
|
148
154
|
}
|
|
149
155
|
export interface AccessAsResult {
|
|
150
156
|
personUid: string;
|
package/dist/commands/access.js
CHANGED
|
@@ -23,6 +23,7 @@ import { getCompanyUid, vaultApiFetch, resolveCallerUid } from "../utils/vault-a
|
|
|
23
23
|
import { parseCompanySlugFromPath, classifyAclSource, resolveBrowseSession, makeCompanyPresignFactory, runGet, toBucketRelative, } from "./files-browse.js";
|
|
24
24
|
import { listJournals, syncDoctor, } from "@indigoai-us/hq-cloud";
|
|
25
25
|
import { listActiveMembers } from "./members.js";
|
|
26
|
+
import { aclLockedBy } from "./acl-lock.js";
|
|
26
27
|
import { buildDmBody } from "./dm.js";
|
|
27
28
|
import { peekIdToken } from "../utils/id-token.js";
|
|
28
29
|
import { findRecentAccessRequest, formatTimeAgo, recordAccessRequest, } from "../utils/access-requests.js";
|
|
@@ -458,10 +459,23 @@ async function completeTarget(input, deps, located) {
|
|
|
458
459
|
const { resolvedKey, directory, vendDenied, listDenied, verdict, steps } = located;
|
|
459
460
|
const resolvedPath = `companies/${slug}/${resolvedKey}`;
|
|
460
461
|
let noAccess = vendDenied || listDenied;
|
|
462
|
+
// Name the empty fencing row only when the tree denies access. A role
|
|
463
|
+
// bypass or creator grant still reads the same as the acl/resolve fast
|
|
464
|
+
// path, which never fetches the tree.
|
|
465
|
+
const lockRow = verdict.kind === "tree" && aclDenied(verdict.tree) ? aclLockedBy(verdict.tree) : null;
|
|
466
|
+
const lockedBy = lockRow
|
|
467
|
+
? { ...lockRow, path: `companies/${slug}/${toBucketRelative(lockRow.path, slug)}` }
|
|
468
|
+
: undefined;
|
|
469
|
+
if (lockedBy) {
|
|
470
|
+
steps.push(lockedBy.readUnknown
|
|
471
|
+
? `acl: locked by ${lockedBy.path} unless that row is open (it has no grants; broader grants such as * apply only if it is open)`
|
|
472
|
+
: `acl: locked by ${lockedBy.path} (that row has no grants and is not open; broader grants such as * do not apply)`);
|
|
473
|
+
}
|
|
461
474
|
if (verdict.kind === "resolved") {
|
|
462
475
|
steps.push(`acl: ${verdict.permission}`);
|
|
463
476
|
}
|
|
464
|
-
else if (verdict.tree == null ||
|
|
477
|
+
else if (verdict.tree == null ||
|
|
478
|
+
(treeIsEmpty(verdict.tree) && !verdict.tree.coveringAclPath)) {
|
|
465
479
|
steps.push("acl: no record (membership-distributed)");
|
|
466
480
|
}
|
|
467
481
|
else if (aclDenied(verdict.tree)) {
|
|
@@ -687,6 +701,7 @@ async function completeTarget(input, deps, located) {
|
|
|
687
701
|
...(requestSentAt ? { requestSentAt } : {}),
|
|
688
702
|
...(alreadyAskedAt ? { alreadyAskedAt } : {}),
|
|
689
703
|
...(requestNote ? { requestNote } : {}),
|
|
704
|
+
...(lockedBy ? { lockedBy } : {}),
|
|
690
705
|
}, finishCtx);
|
|
691
706
|
return result;
|
|
692
707
|
}
|
|
@@ -834,6 +849,8 @@ function jsonPayload(result) {
|
|
|
834
849
|
payload.requestSentAt = result.requestSentAt;
|
|
835
850
|
if (result.alreadyAskedAt)
|
|
836
851
|
payload.alreadyAskedAt = result.alreadyAskedAt;
|
|
852
|
+
if (result.lockedBy)
|
|
853
|
+
payload.lockedBy = result.lockedBy;
|
|
837
854
|
return payload;
|
|
838
855
|
}
|
|
839
856
|
function emit(input, result, log) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empty-row lock detection for ACL tree responses (feedback dd287b55).
|
|
3
|
+
*
|
|
4
|
+
* The server resolves a path against its most-specific matching FILE_ACL row
|
|
5
|
+
* (`coveringAclPath` on the ACL tree response). Enforcement (files-presign,
|
|
6
|
+
* files-list, content) runs resolveEffectivePermission in hq-pro-core
|
|
7
|
+
* src/tenant-contracts/acl-permission.ts with `unionGrants: true`: grants on
|
|
8
|
+
* every matching row are unioned, except that an EMPTY, NON-OPEN winning row
|
|
9
|
+
* fences the path and grants nothing (only the row's creator and the
|
|
10
|
+
* owner/admin role bypass keep access). An OPEN empty winning row is not a
|
|
11
|
+
* fence: it floors every member at read and broader grants still apply.
|
|
12
|
+
*/
|
|
13
|
+
export interface AclLockTree {
|
|
14
|
+
prefix: string;
|
|
15
|
+
direct: unknown[];
|
|
16
|
+
inherited: Array<{
|
|
17
|
+
sourcePrefix: string;
|
|
18
|
+
}>;
|
|
19
|
+
directRow: {
|
|
20
|
+
open: boolean;
|
|
21
|
+
} | null;
|
|
22
|
+
coveringAclPath?: string | null;
|
|
23
|
+
}
|
|
24
|
+
export interface AclLock {
|
|
25
|
+
/** Path of the empty most-specific row. */
|
|
26
|
+
path: string;
|
|
27
|
+
/**
|
|
28
|
+
* True when the row's `open` flag is unknown. The tree reports `open` only
|
|
29
|
+
* for the exact prefix row (directRow), not for ancestor rows. Such a row is
|
|
30
|
+
* a lock if it is not open; if it is open, broader grants still apply.
|
|
31
|
+
*/
|
|
32
|
+
readUnknown: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The empty, non-open row that fences `tree.prefix`, or null when the
|
|
36
|
+
* most-specific row grants something, is open, or no row covers the path.
|
|
37
|
+
*/
|
|
38
|
+
export declare function aclLockedBy(tree: AclLockTree | null | undefined): AclLock | null;
|
|
39
|
+
/** One-line description of the lock for human output. */
|
|
40
|
+
export declare function describeAclLock(lock: AclLock, display: string): string;
|
|
41
|
+
//# sourceMappingURL=acl-lock.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The empty, non-open row that fences `tree.prefix`, or null when the
|
|
3
|
+
* most-specific row grants something, is open, or no row covers the path.
|
|
4
|
+
*/
|
|
5
|
+
export function aclLockedBy(tree) {
|
|
6
|
+
const covering = tree?.coveringAclPath;
|
|
7
|
+
if (!tree || typeof covering !== "string" || covering === "")
|
|
8
|
+
return null;
|
|
9
|
+
if (covering === tree.prefix) {
|
|
10
|
+
if (!tree.directRow || tree.directRow.open === true)
|
|
11
|
+
return null;
|
|
12
|
+
if (!Array.isArray(tree.direct) || tree.direct.length > 0)
|
|
13
|
+
return null;
|
|
14
|
+
return { path: covering, readUnknown: false };
|
|
15
|
+
}
|
|
16
|
+
const inherited = Array.isArray(tree.inherited) ? tree.inherited : [];
|
|
17
|
+
if (inherited.some((entry) => entry.sourcePrefix === covering))
|
|
18
|
+
return null;
|
|
19
|
+
return { path: covering, readUnknown: true };
|
|
20
|
+
}
|
|
21
|
+
/** One-line description of the lock for human output. */
|
|
22
|
+
export function describeAclLock(lock, display) {
|
|
23
|
+
if (lock.readUnknown) {
|
|
24
|
+
return `Locked by ${display} unless that row is open: it has no grants, so grants on broader prefixes apply only if it is open.`;
|
|
25
|
+
}
|
|
26
|
+
return `Locked by ${display}: that ACL row has no grants and is not open, so grants on broader prefixes do not apply. Only the row's creator and the owner/admin role bypass have access.`;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=acl-lock.js.map
|
|
@@ -60,6 +60,8 @@ export declare function mapEnrollFailure(status: number, body: {
|
|
|
60
60
|
attemptsRemaining?: unknown;
|
|
61
61
|
upgradeUrl?: unknown;
|
|
62
62
|
pricingPlan?: unknown;
|
|
63
|
+
usagePause?: unknown;
|
|
64
|
+
billingState?: unknown;
|
|
63
65
|
}, headers?: {
|
|
64
66
|
retryAfter?: string | null;
|
|
65
67
|
}): EnrollError;
|
|
@@ -21,7 +21,7 @@ import * as path from "node:path";
|
|
|
21
21
|
import { CLI_VERSION } from "../cli-version.js";
|
|
22
22
|
import { DEFAULT_VAULT_API_URL, personTokenCacheFile, } from "../utils/cognito-session.js";
|
|
23
23
|
import { HQ_CLIENT_NAME } from "../utils/vault-api.js";
|
|
24
|
-
import { isUsageOnly, pricingPlanFromBody, usageOnlyCardRequiredLines, } from "../lib/billing/usage-only.js";
|
|
24
|
+
import { isUsageOnly, pricingPlanFromBody, usageOnlyCardRequiredLines, usagePauseFromBody, } from "../lib/billing/usage-only.js";
|
|
25
25
|
import { queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, } from "../utils/cli-telemetry.js";
|
|
26
26
|
import { networkTransportErrorCode } from "../utils/network-transport-error.js";
|
|
27
27
|
import { canOfferTeamUpgrade, formatUpgradeRefusal, isPlanGateError, offerUpgradeLink, planGateErrorFromPayload, } from "../utils/plan-gate-error.js";
|
|
@@ -139,6 +139,11 @@ export function mapEnrollFailure(status, body, headers) {
|
|
|
139
139
|
return new EnrollError(`This agent was revoked; its enrollment code no longer works. An admin must ` +
|
|
140
140
|
`create a new external agent or rotate this one.${suffix}`, status, code);
|
|
141
141
|
}
|
|
142
|
+
// US-024: a usage pause is the server's own answer: its message and link.
|
|
143
|
+
const usagePause = status === 402 ? usagePauseFromBody(body) : null;
|
|
144
|
+
if (usagePause) {
|
|
145
|
+
return new EnrollError(usagePause.message, status, code, usagePause.actionUrl);
|
|
146
|
+
}
|
|
142
147
|
if ((status === 402 || code === "billing_required") &&
|
|
143
148
|
isUsageOnly(pricingPlanFromBody(body))) {
|
|
144
149
|
// Usage pricing (US-012): no paid plan to upgrade to. A company admin adds
|
|
@@ -146,8 +151,8 @@ export function mapEnrollFailure(status, body, headers) {
|
|
|
146
151
|
return new EnrollError(usageOnlyCardRequiredLines("An external agent").join(" ") + suffix, status, code);
|
|
147
152
|
}
|
|
148
153
|
if (status === 402 || code === "billing_required") {
|
|
149
|
-
return new EnrollError(`External agents
|
|
150
|
-
`
|
|
154
|
+
return new EnrollError(`External agents need billing set up for this company. A company admin can ` +
|
|
155
|
+
`add a card with \`hq billing checkout\` or on the console Billing page, then enroll again.${suffix}`, status, code, typeof body.upgradeUrl === "string" ? body.upgradeUrl : undefined);
|
|
151
156
|
}
|
|
152
157
|
if (status === 429 || code === "rate_limited") {
|
|
153
158
|
const retryAfter = headers?.retryAfter?.trim();
|
|
@@ -425,7 +430,7 @@ export function registerAgentEnrollCommand(agent) {
|
|
|
425
430
|
}
|
|
426
431
|
catch (openErr) {
|
|
427
432
|
const message = openErr instanceof Error ? openErr.message : String(openErr);
|
|
428
|
-
console.error(chalk.dim(`Could not open the
|
|
433
|
+
console.error(chalk.dim(`Could not open the billing link automatically: ${message}`));
|
|
429
434
|
}
|
|
430
435
|
}
|
|
431
436
|
}
|
package/dist/commands/agents.js
CHANGED
|
@@ -131,12 +131,6 @@ export function agentCreatePlanLimitFromPayload(status, body) {
|
|
|
131
131
|
: {}),
|
|
132
132
|
};
|
|
133
133
|
}
|
|
134
|
-
function formatServerPrice(amountMinor, currency) {
|
|
135
|
-
return new Intl.NumberFormat("en-US", {
|
|
136
|
-
style: "currency",
|
|
137
|
-
currency,
|
|
138
|
-
}).format(amountMinor / 100);
|
|
139
|
-
}
|
|
140
134
|
async function offerAgentCheckout(url) {
|
|
141
135
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
142
136
|
try {
|
|
@@ -146,7 +140,7 @@ async function offerAgentCheckout(url) {
|
|
|
146
140
|
});
|
|
147
141
|
}
|
|
148
142
|
catch {
|
|
149
|
-
console.error("Could not open the
|
|
143
|
+
console.error("Could not open the billing page automatically. Use the URL above.");
|
|
150
144
|
return false;
|
|
151
145
|
}
|
|
152
146
|
finally {
|
|
@@ -156,7 +150,7 @@ async function offerAgentCheckout(url) {
|
|
|
156
150
|
/** Print a server-priced plan block without minting or rewriting its URL. */
|
|
157
151
|
export async function surfaceAgentCreatePlanLimit(planLimit, opts = {}) {
|
|
158
152
|
if (isUsageOnly(opts.pricingPlan)) {
|
|
159
|
-
// Usage pricing (US-012): no
|
|
153
|
+
// Usage pricing (US-012): no flat plan to buy. The company adds a
|
|
160
154
|
// card and each agent box bills by size from the usage card.
|
|
161
155
|
const card = opts.usageCard ?? BUNDLED_USAGE_CARD;
|
|
162
156
|
for (const line of usageOnlyCardRequiredLines("An agent box", card)) {
|
|
@@ -166,13 +160,13 @@ export async function surfaceAgentCreatePlanLimit(planLimit, opts = {}) {
|
|
|
166
160
|
queueCliLimitNoticeExposure("agents", "refusal", opts.companyUid);
|
|
167
161
|
return;
|
|
168
162
|
}
|
|
169
|
-
|
|
170
|
-
const reason =
|
|
163
|
+
// The server's price was the flat plan's, which is closed: name no plan or price.
|
|
164
|
+
const reason = "Agents are not included on this company's current billing.";
|
|
171
165
|
const argv = opts.json ? [...process.argv, "--json"] : process.argv;
|
|
172
166
|
const interactive = canOfferTeamUpgrade(argv);
|
|
173
167
|
if (!planLimit.checkoutUrl) {
|
|
174
168
|
console.error(reason);
|
|
175
|
-
console.error("Ask a company owner to
|
|
169
|
+
console.error("Ask a company owner to check the company's Billing page before creating an agent.");
|
|
176
170
|
queueCliLimitNoticeExposure("agents", "refusal", opts.companyUid);
|
|
177
171
|
return;
|
|
178
172
|
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* `hq billing sponsor` — the individual-pull half of plan sponsorship, from the
|
|
3
3
|
* terminal ("ask a company to sponsor me", hq-pro #3609).
|
|
4
4
|
*
|
|
5
|
-
* A person who is over a personal limit has exactly three ways out: pay
|
|
6
|
-
* themselves, reduce what they store, or ask a company to
|
|
7
|
-
*
|
|
5
|
+
* A person who is over a personal limit has exactly three ways out: pay for
|
|
6
|
+
* the personal plan themselves, reduce what they store, or ask a company to
|
|
7
|
+
* cover it. This command is the third one, plus the admin side that answers it.
|
|
8
8
|
*
|
|
9
9
|
* hq billing sponsor request <company> — ask that company to cover my plan
|
|
10
10
|
* hq billing sponsor list — the queue (admins), or my own
|
|
@@ -36,7 +36,7 @@ export declare const PLAN_RESOURCE: "plan";
|
|
|
36
36
|
/** What a person is told when hq-pro's plan-sponsorship switch is off. */
|
|
37
37
|
export declare const FEATURE_OFF_MESSAGE = "Asking a company to sponsor your plan isn't available yet.";
|
|
38
38
|
/** What v1 sponsorship covers, stated wherever a person confirms or reads it. */
|
|
39
|
-
export declare const COVERAGE_SENTENCE = "Your company pays your
|
|
39
|
+
export declare const COVERAGE_SENTENCE = "Your company pays your personal plan. Storage above your plan's allowance is still yours.";
|
|
40
40
|
/** What a person is told while an approval waits on the sponsor's card. */
|
|
41
41
|
export declare const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a card. Your limits still apply until then.";
|
|
42
42
|
/** One request row as hq-pro persists and returns it. */
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* `hq billing sponsor` — the individual-pull half of plan sponsorship, from the
|
|
3
3
|
* terminal ("ask a company to sponsor me", hq-pro #3609).
|
|
4
4
|
*
|
|
5
|
-
* A person who is over a personal limit has exactly three ways out: pay
|
|
6
|
-
* themselves, reduce what they store, or ask a company to
|
|
7
|
-
*
|
|
5
|
+
* A person who is over a personal limit has exactly three ways out: pay for
|
|
6
|
+
* the personal plan themselves, reduce what they store, or ask a company to
|
|
7
|
+
* cover it. This command is the third one, plus the admin side that answers it.
|
|
8
8
|
*
|
|
9
9
|
* hq billing sponsor request <company> — ask that company to cover my plan
|
|
10
10
|
* hq billing sponsor list — the queue (admins), or my own
|
|
@@ -41,7 +41,7 @@ export const PLAN_RESOURCE = "plan";
|
|
|
41
41
|
/** What a person is told when hq-pro's plan-sponsorship switch is off. */
|
|
42
42
|
export const FEATURE_OFF_MESSAGE = "Asking a company to sponsor your plan isn't available yet.";
|
|
43
43
|
/** What v1 sponsorship covers, stated wherever a person confirms or reads it. */
|
|
44
|
-
export const COVERAGE_SENTENCE = "Your company pays your
|
|
44
|
+
export const COVERAGE_SENTENCE = "Your company pays your personal plan. Storage above your plan's allowance is still yours.";
|
|
45
45
|
/** What a person is told while an approval waits on the sponsor's card. */
|
|
46
46
|
export const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a card. Your limits still apply until then.";
|
|
47
47
|
/**
|
|
@@ -85,7 +85,7 @@ export function messageForError(status, body) {
|
|
|
85
85
|
if (status === 401)
|
|
86
86
|
return "Your sign-in has expired. Run `hq login` and try again.";
|
|
87
87
|
if (status === 403 && code === "SPONSOR_PLAN_REQUIRED") {
|
|
88
|
-
return "That company
|
|
88
|
+
return "That company can't cover a personal plan on its current billing. Ask an owner of that company to check its Billing page.";
|
|
89
89
|
}
|
|
90
90
|
if (status === 403) {
|
|
91
91
|
return "You're not allowed to do that. Only an owner or admin of the company can answer a sponsorship request.";
|
|
@@ -209,7 +209,7 @@ export function registerBillingSponsorCommands(billing) {
|
|
|
209
209
|
process.stdout.write(JSON.stringify(data.request, null, 2) + "\n");
|
|
210
210
|
return;
|
|
211
211
|
}
|
|
212
|
-
console.log(`Asked ${company} to cover your
|
|
212
|
+
console.log(`Asked ${company} to cover your personal HQ plan.`);
|
|
213
213
|
console.log(COVERAGE_SENTENCE);
|
|
214
214
|
console.log(`An owner or admin there answers next. Check with \`hq billing sponsor list\`.`);
|
|
215
215
|
}
|
|
@@ -276,11 +276,11 @@ export function registerBillingSponsorCommands(billing) {
|
|
|
276
276
|
}
|
|
277
277
|
const where = named ?? "that company";
|
|
278
278
|
if (!state.active) {
|
|
279
|
-
console.log(`No company is covering your
|
|
279
|
+
console.log(`No company is covering your personal HQ plan right now.`);
|
|
280
280
|
console.log(`If you asked ${where}, they haven't answered yet — an owner or admin there answers next.`);
|
|
281
281
|
return;
|
|
282
282
|
}
|
|
283
|
-
console.log(`Your
|
|
283
|
+
console.log(`Your personal HQ plan: ${statusLabel(state.status ?? "active")}`);
|
|
284
284
|
if (state.requestId)
|
|
285
285
|
console.log(` ${chalk.bold(state.requestId)}`);
|
|
286
286
|
console.log(COVERAGE_SENTENCE);
|
|
@@ -328,7 +328,7 @@ export function registerBillingSponsorCommands(billing) {
|
|
|
328
328
|
}
|
|
329
329
|
console.log((await companyIsUsageOnly(result.companyUid))
|
|
330
330
|
? "Approved. Their personal plan is now on the company bill."
|
|
331
|
-
: "Approved. Their
|
|
331
|
+
: "Approved. Their personal plan is now on the company bill.");
|
|
332
332
|
}
|
|
333
333
|
catch (err) {
|
|
334
334
|
fail(err);
|
|
@@ -370,7 +370,7 @@ export function registerBillingSponsorCommands(billing) {
|
|
|
370
370
|
}
|
|
371
371
|
console.log((await companyIsUsageOnly(result.companyUid))
|
|
372
372
|
? "Ended. Their personal plan goes back to them from the next bill."
|
|
373
|
-
: "Ended. Their
|
|
373
|
+
: "Ended. Their personal plan goes back to them from the next bill.");
|
|
374
374
|
}
|
|
375
375
|
catch (err) {
|
|
376
376
|
fail(err);
|
|
@@ -396,7 +396,7 @@ export function registerBillingSponsorCommands(billing) {
|
|
|
396
396
|
console.log(usageOnlySponsorLines()[2]);
|
|
397
397
|
return;
|
|
398
398
|
}
|
|
399
|
-
console.log("Withdrawn. You can pay
|
|
399
|
+
console.log("Withdrawn. You can pay for your plan yourself, reduce what you store, or ask another company.");
|
|
400
400
|
}
|
|
401
401
|
catch (err) {
|
|
402
402
|
fail(err);
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Tenant boundary: the company comes from the caller's own active memberships
|
|
14
14
|
* (`/membership/me`); a slug outside them is refused before any read. The
|
|
15
|
-
*
|
|
15
|
+
* company statement is owner-gated on the server as well. With no cloud
|
|
16
16
|
* company (or `--personal`) it reads the caller's personal scope.
|
|
17
17
|
*
|
|
18
|
-
* Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts)
|
|
19
|
-
* the
|
|
18
|
+
* Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts)
|
|
19
|
+
* from the usage statement, `GET /v1/billing/usage` (hq-pro US-006). Only a
|
|
20
|
+
* server without that route (404) falls back to the usage-limits readings.
|
|
20
21
|
*
|
|
21
22
|
* Only an account on usage pricing gets costs. The plan comes from the
|
|
22
|
-
* server
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* server says nothing about get readings only, with no rate, cost or
|
|
23
|
+
* server's shared plan status (`billingState`, hq-pro #5088) on the billing
|
|
24
|
+
* summary, with the legacy `pricingPlan` / `usageOnly` fields for a server
|
|
25
|
+
* that does not send it. An account on an earlier flat plan and an account
|
|
26
|
+
* the server says nothing about get readings only, with no rate, cost or
|
|
27
27
|
* projected bill.
|
|
28
28
|
*/
|
|
29
29
|
import type { Command } from "commander";
|
|
@@ -34,8 +34,19 @@ export interface MembershipRef {
|
|
|
34
34
|
slug: string;
|
|
35
35
|
role?: string;
|
|
36
36
|
}
|
|
37
|
+
/** One `GET /v1/billing/usage` answer: the HTTP status and the decoded body. */
|
|
38
|
+
export interface UsageStatementResponse {
|
|
39
|
+
status: number;
|
|
40
|
+
body: unknown | null;
|
|
41
|
+
}
|
|
37
42
|
export interface BillingUsageDeps {
|
|
38
43
|
listCompanies(): Promise<MembershipRef[]>;
|
|
44
|
+
/**
|
|
45
|
+
* `GET /v1/billing/usage` for the scope (US-015). Absent, or a 404 from a
|
|
46
|
+
* server without the route, falls back to {@link fetchUsageLimits}.
|
|
47
|
+
*/
|
|
48
|
+
fetchUsageStatement?: (scope: UsageScope) => Promise<UsageStatementResponse>;
|
|
49
|
+
/** Legacy readings for a server without the statement route. */
|
|
39
50
|
fetchUsageLimits: ReadMonthUsageDeps["fetchUsageLimits"];
|
|
40
51
|
readStorageSplit?: (company: MembershipRef, opts: {
|
|
41
52
|
fresh: boolean;
|
|
@@ -77,6 +88,9 @@ export declare function pricingPlanFromSummary(body: unknown): PricingPlan | nul
|
|
|
77
88
|
* only when usage-limits does not say.
|
|
78
89
|
*/
|
|
79
90
|
export declare function resolveOnUsagePricing(limitsPlan: PricingPlan | null, summaryPlan: PricingPlan | null): boolean | null;
|
|
91
|
+
/** Thrown when the company statement is refused to a caller who is not its owner. */
|
|
92
|
+
export declare class UsageStatementForbiddenError extends Error {
|
|
93
|
+
}
|
|
80
94
|
export declare function runBillingUsage(opts: BillingUsageOptions, deps: BillingUsageDeps): Promise<BillingUsageReport>;
|
|
81
95
|
/**
|
|
82
96
|
* `hq billing usage --card`: the card from GET /v1/pricing, or the bundled
|
|
@@ -12,27 +12,27 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Tenant boundary: the company comes from the caller's own active memberships
|
|
14
14
|
* (`/membership/me`); a slug outside them is refused before any read. The
|
|
15
|
-
*
|
|
15
|
+
* company statement is owner-gated on the server as well. With no cloud
|
|
16
16
|
* company (or `--personal`) it reads the caller's personal scope.
|
|
17
17
|
*
|
|
18
|
-
* Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts)
|
|
19
|
-
* the
|
|
18
|
+
* Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts)
|
|
19
|
+
* from the usage statement, `GET /v1/billing/usage` (hq-pro US-006). Only a
|
|
20
|
+
* server without that route (404) falls back to the usage-limits readings.
|
|
20
21
|
*
|
|
21
22
|
* Only an account on usage pricing gets costs. The plan comes from the
|
|
22
|
-
* server
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* server says nothing about get readings only, with no rate, cost or
|
|
23
|
+
* server's shared plan status (`billingState`, hq-pro #5088) on the billing
|
|
24
|
+
* summary, with the legacy `pricingPlan` / `usageOnly` fields for a server
|
|
25
|
+
* that does not send it. An account on an earlier flat plan and an account
|
|
26
|
+
* the server says nothing about get readings only, with no rate, cost or
|
|
27
27
|
* projected bill.
|
|
28
28
|
*/
|
|
29
29
|
import chalk from "chalk";
|
|
30
30
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
31
31
|
import { vaultApiFetch, vaultApiFetchPublic } from "../utils/vault-api.js";
|
|
32
|
-
import { buildReadingsReport, buildReport, formatReport, readMonthUsage, } from "../lib/billing/usage-cost.js";
|
|
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, usageTrialActiveFromBody, } from "../lib/billing/usage-only.js";
|
|
35
|
+
import { fetchCompanyBillingSummary, pricingPlanFromBody, hasBillingStateField, usageTrialActiveFromBody, } from "../lib/billing/usage-only.js";
|
|
36
36
|
export class UsageScopeError extends Error {
|
|
37
37
|
}
|
|
38
38
|
/** Pick the scope. Never reaches outside the caller's own memberships. */
|
|
@@ -67,7 +67,7 @@ function asRecord(value) {
|
|
|
67
67
|
/** The plan on a usage-limits body: `pricingPlan`, else the `usageOnly` / `onUsagePricing` boolean. */
|
|
68
68
|
export function pricingPlanFromUsageLimits(body) {
|
|
69
69
|
const plan = pricingPlanFromBody(body);
|
|
70
|
-
if (plan)
|
|
70
|
+
if (plan || hasBillingStateField(body))
|
|
71
71
|
return plan;
|
|
72
72
|
const rec = asRecord(body);
|
|
73
73
|
const flag = typeof rec?.onUsagePricing === "boolean" ? rec.onUsagePricing : rec?.usageOnly;
|
|
@@ -85,9 +85,12 @@ export function pricingPlanFromSummary(body) {
|
|
|
85
85
|
const rec = asRecord(body);
|
|
86
86
|
if (!rec)
|
|
87
87
|
return null;
|
|
88
|
-
// `plan: "usage"` only means "no
|
|
89
|
-
// so it never turns pricing on.
|
|
90
|
-
|
|
88
|
+
// `plan: "usage"` only means "no flat-plan subscription", not usage pricing,
|
|
89
|
+
// so it never turns pricing on. A server that sends billingState answers
|
|
90
|
+
// from it alone (unknown stays unknown); an older one without pricingPlan
|
|
91
|
+
// shows no cost.
|
|
92
|
+
const plan = pricingPlanFromBody(rec);
|
|
93
|
+
return hasBillingStateField(rec) ? plan : plan ?? "current";
|
|
91
94
|
}
|
|
92
95
|
/**
|
|
93
96
|
* True: price the meters. False: flat plan. Null: unknown, show no cost.
|
|
@@ -101,20 +104,44 @@ export function resolveOnUsagePricing(limitsPlan, summaryPlan) {
|
|
|
101
104
|
return null;
|
|
102
105
|
return plan === "usage-only";
|
|
103
106
|
}
|
|
107
|
+
/** Thrown when the company statement is refused to a caller who is not its owner. */
|
|
108
|
+
export class UsageStatementForbiddenError extends Error {
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Read the statement, or say the caller should use the legacy readings: null
|
|
112
|
+
* means the server has no statement route (404) or no reader was given.
|
|
113
|
+
*/
|
|
114
|
+
async function readStatementBody(scope, deps) {
|
|
115
|
+
if (!deps.fetchUsageStatement)
|
|
116
|
+
return null;
|
|
117
|
+
const res = await deps.fetchUsageStatement(scope);
|
|
118
|
+
if (res.status === 404)
|
|
119
|
+
return null;
|
|
120
|
+
if (res.status === 403 && scope.kind === "company") {
|
|
121
|
+
throw new UsageStatementForbiddenError(`Only the owner of '${scope.companySlug}' can see its usage statement. ` +
|
|
122
|
+
"Run `hq billing usage --personal` for your own usage.");
|
|
123
|
+
}
|
|
124
|
+
return { body: res.status >= 200 && res.status < 300 ? res.body : null };
|
|
125
|
+
}
|
|
104
126
|
export async function runBillingUsage(opts, deps) {
|
|
105
127
|
const memberships = await deps.listCompanies();
|
|
106
128
|
const scope = resolveUsageScope(memberships, opts);
|
|
107
129
|
const membership = scope.kind === "company" ? memberships.find((m) => m.uid === scope.companyUid) : undefined;
|
|
108
130
|
let limitsBody = null;
|
|
131
|
+
const statement = await readStatementBody(scope, deps);
|
|
132
|
+
const usageDeps = {
|
|
133
|
+
fetchUsageLimits: async (s) => (limitsBody = await deps.fetchUsageLimits(s)),
|
|
134
|
+
now: deps.now,
|
|
135
|
+
...(deps.readTrialActive ? { readTrialActive: deps.readTrialActive } : {}),
|
|
136
|
+
...(opts.storageScan !== false && deps.readStorageSplit && membership
|
|
137
|
+
? { readStorageSplit: () => deps.readStorageSplit(membership, { fresh: opts.fresh === true }) }
|
|
138
|
+
: {}),
|
|
139
|
+
};
|
|
140
|
+
const source = statement
|
|
141
|
+
? usageStatementSource(async () => statement.body)
|
|
142
|
+
: usageLimitsSource(usageDeps);
|
|
109
143
|
const [usage, pricing] = await Promise.all([
|
|
110
|
-
readMonthUsage(scope,
|
|
111
|
-
fetchUsageLimits: async (s) => (limitsBody = await deps.fetchUsageLimits(s)),
|
|
112
|
-
now: deps.now,
|
|
113
|
-
...(deps.readTrialActive ? { readTrialActive: deps.readTrialActive } : {}),
|
|
114
|
-
...(opts.storageScan !== false && deps.readStorageSplit && membership
|
|
115
|
-
? { readStorageSplit: () => deps.readStorageSplit(membership, { fresh: opts.fresh === true }) }
|
|
116
|
-
: {}),
|
|
117
|
-
}),
|
|
144
|
+
readMonthUsage(scope, usageDeps, source),
|
|
118
145
|
deps.fetchPricing().catch(() => null),
|
|
119
146
|
]);
|
|
120
147
|
const limitsPlan = pricingPlanFromUsageLimits(limitsBody);
|
|
@@ -142,6 +169,14 @@ function productionDeps() {
|
|
|
142
169
|
const { companiesFromMemberships } = await import("./storage.js");
|
|
143
170
|
return companiesFromMemberships(await res.json());
|
|
144
171
|
},
|
|
172
|
+
async fetchUsageStatement(scope) {
|
|
173
|
+
const res = await vaultApiFetch(scope.kind === "company"
|
|
174
|
+
? { token: await token(), path: "/v1/billing/usage", query: { companyUid: scope.companyUid } }
|
|
175
|
+
: { token: await token(), path: "/v1/billing/usage", query: { scope: "person" } });
|
|
176
|
+
if (!res.ok)
|
|
177
|
+
return { status: res.status, body: null };
|
|
178
|
+
return { status: res.status, body: await res.json().catch(() => null) };
|
|
179
|
+
},
|
|
145
180
|
async fetchUsageLimits(scope) {
|
|
146
181
|
const res = await vaultApiFetch(scope.kind === "company"
|
|
147
182
|
? { token: await token(), path: "/v1/billing/usage-limits", query: { companyUid: scope.companyUid } }
|