@indigoai-us/hq-cli 5.119.10 → 5.119.12
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 +26 -0
- package/dist/commands/files-browse.js +30 -11
- package/dist/lib/plan-limit-nag.d.ts +22 -0
- package/dist/lib/plan-limit-nag.js +36 -0
- package/dist/utils/plan-gate-error.d.ts +8 -4
- package/dist/utils/plan-gate-error.js +36 -1
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.119.12] — 2026-09-18
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq files cat … --company prs_<person-uid>` now reads the file instead of
|
|
10
|
+
refusing with "No company found for slug". Reading a DM attachment needs the
|
|
11
|
+
sender's vault as the scope, and `cat` resolved that value as a company slug
|
|
12
|
+
of its own, after the same fix landed on the presign path.
|
|
13
|
+
|
|
14
|
+
## [5.119.11] — 2026-09-18
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- On a personal account, My Telemetry is part of the paid Individual plan
|
|
19
|
+
($50/mo). When the server withholds it, the CLI now prints a short notice
|
|
20
|
+
naming the plan and where to upgrade, instead of the raw refusal. Company
|
|
21
|
+
plans are unaffected, and the notice has nothing to do with the plan-limit
|
|
22
|
+
nags — it is a plan fact, printed once, where it happened.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `hq files … --company prs_<person-uid>` now reaches that person's vault
|
|
27
|
+
instead of failing with "Company slug ... was not found". This is how a DM
|
|
28
|
+
recipient reads a file attached to a message: the attachment lives in the
|
|
29
|
+
sender's personal vault, so naming the sender is the only scope that works.
|
|
30
|
+
|
|
5
31
|
## [5.119.10] — 2026-09-18
|
|
6
32
|
|
|
7
33
|
### Added
|
|
@@ -43,7 +43,7 @@ import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/clie
|
|
|
43
43
|
import { VaultClient, grantPathToPrefix, } from "@indigoai-us/hq-cloud";
|
|
44
44
|
import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
45
45
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
46
|
-
import { getCompanyUid, looksLikeCompanyUid, vaultApiFetch, } from "../utils/vault-api.js";
|
|
46
|
+
import { getCompanyUid, looksLikeCompanyUid, looksLikePersonVaultUid, vaultApiFetch, } from "../utils/vault-api.js";
|
|
47
47
|
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
48
48
|
// ── Pure helpers ────────────────────────────────────────────────────────────
|
|
49
49
|
/**
|
|
@@ -306,18 +306,37 @@ export async function runCat(input) {
|
|
|
306
306
|
}
|
|
307
307
|
else {
|
|
308
308
|
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
309
|
+
if (looksLikePersonVaultUid(slug)) {
|
|
310
|
+
// A `prs_` scope names a PERSON'S vault, which is where a DM attachment
|
|
311
|
+
// lives (`chat/attachments/dm/<uidA>--<uidB>/…`). There is no company
|
|
312
|
+
// entity to look up — `findInMyNamespace("company", …)` can only ever
|
|
313
|
+
// find a company, so it refuses the one scope the server accepts
|
|
314
|
+
// (hq-pro #3571 authorizes either member of the pair; hq-cli #644 made
|
|
315
|
+
// the presign path carry the ref). Go straight to the presign path and
|
|
316
|
+
// let the vault API's per-key gate decide.
|
|
317
|
+
//
|
|
318
|
+
// The key is already bucket-relative: a person vault has no
|
|
319
|
+
// `companies/<slug>/` anchor to strip. `Bucket` is inert on this path —
|
|
320
|
+
// the browse-vend company client presigns by companyUid + key and never
|
|
321
|
+
// reads it — so the scope ref is carried through for error messages.
|
|
322
|
+
bucket = slug;
|
|
323
|
+
s3Key = key;
|
|
324
|
+
s3 = requireCompanyClient(input.companyClient)({ companyUid: slug });
|
|
312
325
|
}
|
|
313
|
-
|
|
314
|
-
|
|
326
|
+
else {
|
|
327
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
328
|
+
if (!entity) {
|
|
329
|
+
throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
|
|
330
|
+
}
|
|
331
|
+
if (!entity.bucketName) {
|
|
332
|
+
throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
|
|
333
|
+
}
|
|
334
|
+
bucket = entity.bucketName;
|
|
335
|
+
// Translate the anchored CLI key into the company-relative bucket key.
|
|
336
|
+
s3Key = toBucketRelative(key, slug);
|
|
337
|
+
// COMPANY mode (HQ-59): GetObject → presign GET. No STS vend, no direct S3.
|
|
338
|
+
s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
|
|
315
339
|
}
|
|
316
|
-
bucket = entity.bucketName;
|
|
317
|
-
// Translate the anchored CLI key into the company-relative bucket key.
|
|
318
|
-
s3Key = toBucketRelative(key, slug);
|
|
319
|
-
// COMPANY mode (HQ-59): GetObject → presign GET. No STS vend, no direct S3.
|
|
320
|
-
s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
|
|
321
340
|
}
|
|
322
341
|
// HQ-CLI-5: when writing to --out, create the parent directory BEFORE issuing
|
|
323
342
|
// the presigned GET, so no synchronous filesystem call sits between receiving
|
|
@@ -83,6 +83,28 @@ export declare function recordPlanLimitStatus(body: unknown, opts?: {
|
|
|
83
83
|
* never claims the workspace has stopped working.
|
|
84
84
|
*/
|
|
85
85
|
export declare function buildOverLine(overEntries: Array<[string, PlanLimitEntry]>, upgradeUrl: string): string;
|
|
86
|
+
/**
|
|
87
|
+
* My Telemetry is an Individual-plan feature on a personal scope (owner
|
|
88
|
+
* decision 9 / us-044-individual-plan-design.md §11). When hq-pro refuses it,
|
|
89
|
+
* the CLI prints this notice INSTEAD of telemetry output.
|
|
90
|
+
*
|
|
91
|
+
* It lives beside the nag copy so plan wording stays in one file, but it is
|
|
92
|
+
* NOT part of the nag model: no persistence, no throttle, no episode state. It
|
|
93
|
+
* is rendered once, on the spot, by whoever received the refusal. The refusal
|
|
94
|
+
* is a plan fact, so it neither counts toward the nag bands nor is silenced by
|
|
95
|
+
* the nag cadence.
|
|
96
|
+
*
|
|
97
|
+
* Copy names the plan and its price only — no Outposts, no bot pricing (owner
|
|
98
|
+
* decisions 10 and 11).
|
|
99
|
+
*/
|
|
100
|
+
export declare const INDIVIDUAL_PLAN_NAME = "Individual";
|
|
101
|
+
export declare const INDIVIDUAL_PRICE_LABEL = "$50/mo";
|
|
102
|
+
/**
|
|
103
|
+
* The notice a personal feature refusal prints. `upgradeUrl` is the server's
|
|
104
|
+
* own checkout target; an older hq-pro sends none, and the line then names the
|
|
105
|
+
* plan without a link rather than inventing one.
|
|
106
|
+
*/
|
|
107
|
+
export declare function renderPersonalPlanFeatureNotice(feature: string, upgradeUrl?: string | null): string;
|
|
86
108
|
/**
|
|
87
109
|
* One line per crossing.
|
|
88
110
|
*
|
|
@@ -326,6 +326,42 @@ export function buildOverLine(overEntries, upgradeUrl) {
|
|
|
326
326
|
return (`⚠ HQ Starter: ${facts}. New files and new secrets are paused. ` +
|
|
327
327
|
`Upgrade: ${upgradeUrl}`);
|
|
328
328
|
}
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Individual-plan feature notice (US-054)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
/**
|
|
333
|
+
* My Telemetry is an Individual-plan feature on a personal scope (owner
|
|
334
|
+
* decision 9 / us-044-individual-plan-design.md §11). When hq-pro refuses it,
|
|
335
|
+
* the CLI prints this notice INSTEAD of telemetry output.
|
|
336
|
+
*
|
|
337
|
+
* It lives beside the nag copy so plan wording stays in one file, but it is
|
|
338
|
+
* NOT part of the nag model: no persistence, no throttle, no episode state. It
|
|
339
|
+
* is rendered once, on the spot, by whoever received the refusal. The refusal
|
|
340
|
+
* is a plan fact, so it neither counts toward the nag bands nor is silenced by
|
|
341
|
+
* the nag cadence.
|
|
342
|
+
*
|
|
343
|
+
* Copy names the plan and its price only — no Outposts, no bot pricing (owner
|
|
344
|
+
* decisions 10 and 11).
|
|
345
|
+
*/
|
|
346
|
+
export const INDIVIDUAL_PLAN_NAME = "Individual";
|
|
347
|
+
export const INDIVIDUAL_PRICE_LABEL = "$50/mo";
|
|
348
|
+
/** Human names for the features a personal scope can be refused. */
|
|
349
|
+
const PERSONAL_FEATURE_LABELS = {
|
|
350
|
+
telemetry: "My Telemetry",
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* The notice a personal feature refusal prints. `upgradeUrl` is the server's
|
|
354
|
+
* own checkout target; an older hq-pro sends none, and the line then names the
|
|
355
|
+
* plan without a link rather than inventing one.
|
|
356
|
+
*/
|
|
357
|
+
export function renderPersonalPlanFeatureNotice(feature, upgradeUrl) {
|
|
358
|
+
const label = PERSONAL_FEATURE_LABELS[feature] ?? feature;
|
|
359
|
+
const headline = `${label} is included with the HQ ${INDIVIDUAL_PLAN_NAME} plan (${INDIVIDUAL_PRICE_LABEL}).`;
|
|
360
|
+
const action = typeof upgradeUrl === "string" && upgradeUrl.trim().length > 0
|
|
361
|
+
? `Upgrade: ${upgradeUrl.trim()}`
|
|
362
|
+
: "Upgrade in your HQ billing settings.";
|
|
363
|
+
return `${headline} Your personal account is on the free tier. ${action}`;
|
|
364
|
+
}
|
|
329
365
|
/** `"{resource}:{band}"` — the persisted key, matching the server's own. */
|
|
330
366
|
function crossingKey(crossing) {
|
|
331
367
|
return `${crossing.resource}:${crossing.band}`;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED"
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* US-054: a PERSONAL scope asked for a feature the paid Individual plan
|
|
4
|
+
* includes and the free tier does not. A different plan, a different price
|
|
5
|
+
* and a different remedy from the company codes above, so it is its own
|
|
6
|
+
* code rather than a reuse of `PLAN_REQUIRED` with company copy.
|
|
5
7
|
*/
|
|
6
|
-
|
|
8
|
+
| "PERSONAL_PLAN_REQUIRED";
|
|
7
9
|
export interface PlanGateDetails {
|
|
8
10
|
resource?: string;
|
|
11
|
+
/** The refused feature, on a `PERSONAL_PLAN_REQUIRED` denial. */
|
|
12
|
+
feature?: string;
|
|
9
13
|
used?: number;
|
|
10
14
|
limit?: number;
|
|
11
15
|
upgradeUrl?: string;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberate subscription denial returned by hq-pro. Keeping its structured
|
|
3
|
+
* fields on a typed error lets the CLI boundary render one safe, consistent
|
|
4
|
+
* message instead of every command parsing and printing a server response.
|
|
5
|
+
*/
|
|
6
|
+
import { renderPersonalPlanFeatureNotice } from "../lib/plan-limit-nag.js";
|
|
1
7
|
const TEAM_UPGRADE_HINT = "Run `hq billing upgrade` to move to HQ Workforce.";
|
|
2
8
|
export class PlanGateError extends Error {
|
|
3
9
|
code;
|
|
@@ -6,7 +12,11 @@ export class PlanGateError extends Error {
|
|
|
6
12
|
// Commands with their own expected-error boundary commonly print
|
|
7
13
|
// `err.message`. Keeping the friendly copy here means those boundaries
|
|
8
14
|
// retain the same plan-gate voice as main.ts without per-command handling.
|
|
9
|
-
|
|
15
|
+
// The HQ Workforce hint is company advice; a personal denial ends at its
|
|
16
|
+
// own notice, which already names the Individual plan and its checkout.
|
|
17
|
+
super(code === "PERSONAL_PLAN_REQUIRED"
|
|
18
|
+
? formatPlanGateDetails(code, details)
|
|
19
|
+
: [formatPlanGateDetails(code, details), TEAM_UPGRADE_HINT].join("\n"));
|
|
10
20
|
this.code = code;
|
|
11
21
|
this.details = details;
|
|
12
22
|
this.name = "PlanGateError";
|
|
@@ -31,6 +41,12 @@ function formatPlanGateDetails(code, details) {
|
|
|
31
41
|
existingResourcesNote,
|
|
32
42
|
].join("\n");
|
|
33
43
|
}
|
|
44
|
+
// A personal scope is not a workspace: it has no members to trim, no $500
|
|
45
|
+
// tier to buy, and nothing of its own is paused. One line, the plan's own
|
|
46
|
+
// copy, and no company remedy.
|
|
47
|
+
if (code === "PERSONAL_PLAN_REQUIRED") {
|
|
48
|
+
return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", details.upgradeUrl ?? null);
|
|
49
|
+
}
|
|
34
50
|
const upgrade = typeof details.upgradeUrl === "string"
|
|
35
51
|
? `Upgrade to HQ Workforce ($500/mo) to remove limits: ${details.upgradeUrl}`
|
|
36
52
|
: "Upgrade to HQ Workforce ($500/mo) to remove limits.";
|
|
@@ -41,6 +57,9 @@ function formatPlanGateDetails(code, details) {
|
|
|
41
57
|
].join("\n");
|
|
42
58
|
}
|
|
43
59
|
export function formatPlanGateError(err) {
|
|
60
|
+
if (err.code === "PERSONAL_PLAN_REQUIRED") {
|
|
61
|
+
return formatPlanGateDetails(err.code, err.details);
|
|
62
|
+
}
|
|
44
63
|
return [
|
|
45
64
|
formatPlanGateDetails(err.code, err.details),
|
|
46
65
|
TEAM_UPGRADE_HINT,
|
|
@@ -80,6 +99,22 @@ export function planGateErrorFromPayload(status, body, companyUid) {
|
|
|
80
99
|
if (status !== 402 || !body || typeof body !== "object")
|
|
81
100
|
return null;
|
|
82
101
|
const payload = body;
|
|
102
|
+
// US-054: the personal feature denial. hq-pro identifies it with
|
|
103
|
+
// `error: "plan_feature_unavailable"` plus `scope: "personal"`; both the
|
|
104
|
+
// `scope` and `requiredPlan` fields are OPTIONAL on the wire, so an hq-pro
|
|
105
|
+
// that predates US-054 sends a company-shaped denial and keeps falling
|
|
106
|
+
// through to the company path below (defensive consumer).
|
|
107
|
+
if (payload.error === "plan_feature_unavailable" &&
|
|
108
|
+
payload.scope === "personal") {
|
|
109
|
+
return new PlanGateError("PERSONAL_PLAN_REQUIRED", {
|
|
110
|
+
...(typeof payload.feature === "string"
|
|
111
|
+
? { feature: payload.feature }
|
|
112
|
+
: {}),
|
|
113
|
+
...(typeof payload.upgradeUrl === "string"
|
|
114
|
+
? { upgradeUrl: payload.upgradeUrl }
|
|
115
|
+
: {}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
83
118
|
if (payload.code !== "PLAN_LIMIT_EXCEEDED" && payload.code !== "PLAN_REQUIRED") {
|
|
84
119
|
return null;
|
|
85
120
|
}
|
|
@@ -59,6 +59,7 @@ export declare function vaultApiFetchPublic(opts: {
|
|
|
59
59
|
query?: Record<string, string>;
|
|
60
60
|
}): Promise<Response>;
|
|
61
61
|
export declare function looksLikeCompanyUid(ref: string): boolean;
|
|
62
|
+
export declare function looksLikePersonVaultUid(ref: string): boolean;
|
|
62
63
|
export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
|
|
63
64
|
export declare function resolveCallerPersonUid(token: string, baseUrl?: string): Promise<string>;
|
|
64
65
|
export declare function getEntityUid(token: string, opts: {
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -285,6 +285,24 @@ const COMPANY_UID_PREFIX = 'cmp_';
|
|
|
285
285
|
export function looksLikeCompanyUid(ref) {
|
|
286
286
|
return ref.startsWith(COMPANY_UID_PREFIX);
|
|
287
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* A `--company` value beginning with `prs_` names a PERSON'S vault, not a
|
|
290
|
+
* company. It is the only way to reach a DM attachment: the file lives in the
|
|
291
|
+
* SENDER's personal vault under
|
|
292
|
+
* `chat/attachments/dm/<uidA>--<uidB>/…`, and the vault API authorizes either
|
|
293
|
+
* member of that pair to `get` it (hq-pro #3571). The recipient has no
|
|
294
|
+
* personal vault of their own (so `--personal` cannot work) and the key is not
|
|
295
|
+
* in any company bucket (so a slug 404s on the object), which left the read
|
|
296
|
+
* with no expressible scope at all.
|
|
297
|
+
*
|
|
298
|
+
* Routed straight through as the scope uid: person entities have no slug
|
|
299
|
+
* namespace, so there is nothing to resolve, and the server's own per-key gate
|
|
300
|
+
* — not the CLI — decides whether the caller may read it.
|
|
301
|
+
*/
|
|
302
|
+
const PERSON_VAULT_UID_PREFIX = 'prs_';
|
|
303
|
+
export function looksLikePersonVaultUid(ref) {
|
|
304
|
+
return ref.startsWith(PERSON_VAULT_UID_PREFIX);
|
|
305
|
+
}
|
|
288
306
|
// A 401 from ANY vault resolution call means the caller's HQ session is
|
|
289
307
|
// expired or missing — an expected auth state fixed by `hq login`, not a
|
|
290
308
|
// code defect. Raise a typed AuthError so the top-level handler prints an
|
|
@@ -351,6 +369,12 @@ async function resolveCompanyUid(token, ref) {
|
|
|
351
369
|
if (looksLikeCompanyUid(ref)) {
|
|
352
370
|
return resolveCompanyByUid(token, ref);
|
|
353
371
|
}
|
|
372
|
+
// A `prs_` ref names a person's vault (DM attachments). There is no slug to
|
|
373
|
+
// resolve and no company lookup that could succeed; pass it through as the
|
|
374
|
+
// scope and let the vault API's per-key authorization answer.
|
|
375
|
+
if (looksLikePersonVaultUid(ref)) {
|
|
376
|
+
return ref;
|
|
377
|
+
}
|
|
354
378
|
// PRIMARY PATH — caller-scoped slug resolution. Resolves the slug to the
|
|
355
379
|
// caller's OWN company (unique within their namespace by the invariant
|
|
356
380
|
// above), making a stranger's same-slug company invisible.
|