@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 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
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
310
- if (!entity) {
311
- throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
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
- if (!entity.bucketName) {
314
- throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
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
- * 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.
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
- export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED";
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
- super([formatPlanGateDetails(code, details), TEAM_UPGRADE_HINT].join("\n"));
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: {
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.119.10",
3
+ "version": "5.119.12",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {