@indigoai-us/hq-cli 5.117.3 → 5.118.1

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.
@@ -2,8 +2,9 @@
2
2
  * `plan-lock` (starter-plan-hard-limits / US-011) — the CLI-side read + render
3
3
  * of the workspace plan lock.
4
4
  *
5
- * Starter (free) workspaces are capped at 5 members and 0 integrations. Going
6
- * over locks the workspace immediately: it becomes read-only until the owner
5
+ * Starter (free) workspaces are capped on four locking dimensions members,
6
+ * integrations, secrets and agents (owner decision 7, 2026-09-17; deployments
7
+ * and storage nag but never lock). Going over locks the workspace immediately: it becomes read-only until the owner
7
8
  * trims back under the caps or upgrades to HQ Workforce. The lock decision is
8
9
  * NOT made here — hq-pro's `src/billing/plan-lock.ts` is the single source of
9
10
  * truth and ships the answer on `GET /membership/me` as a per-company
@@ -15,17 +16,33 @@
15
16
  * nothing here ever infers a lock (or an unlock) from missing data
16
17
  * (hq-absent-field-never-means-constraining-value).
17
18
  */
18
- /** Mirror of hq-pro's `PlanLockReason`. */
19
- export type PlanLockReason = "users" | "integrations";
19
+ /**
20
+ * Mirror of hq-pro's `PlanLockReason`. Owner decision 7 (2026-09-17) fixes the
21
+ * locking set at these four: `deployments` and `storageBytes` are nag-only and
22
+ * never appear here. An unrecognised reason is dropped by `parsePlanLock`, so a
23
+ * server that adds a fifth dimension renders as the generic line rather than as
24
+ * a false claim about members.
25
+ */
26
+ export declare const PLAN_LOCK_REASONS: readonly ["users", "integrations", "secrets", "agents"];
27
+ export type PlanLockReason = (typeof PLAN_LOCK_REASONS)[number];
20
28
  /** Mirror of hq-pro's `PlanLock` wire shape (see src/billing/plan-lock.ts). */
21
29
  export interface PlanLock {
22
30
  locked: boolean;
23
31
  reasons: PlanLockReason[];
24
32
  upgradeUrl: string;
25
- fixOptions: {
26
- removeMembersTo: number;
27
- disconnectIntegrations: boolean;
28
- };
33
+ fixOptions: PlanLockFixOptions;
34
+ }
35
+ /**
36
+ * The remedies hq-pro ships with the lock. The two original fields are always
37
+ * present (defaulted when the server omits them) because shipped copy reads
38
+ * them unconditionally; every dimension added since is optional, so an older
39
+ * server simply leaves it absent and the renderer quotes the Starter cap.
40
+ */
41
+ export interface PlanLockFixOptions {
42
+ removeMembersTo: number;
43
+ disconnectIntegrations: boolean;
44
+ removeSecretsTo?: number;
45
+ deprovisionAgentsTo?: number;
29
46
  }
30
47
  /** Optional member decoration for the notice ("7 of 5 members"). */
31
48
  export interface PlanLockMembers {
@@ -47,6 +64,10 @@ export interface PlanLockStatus {
47
64
  }
48
65
  /** Starter member cap quoted when the server did not send `removeMembersTo`. */
49
66
  export declare const STARTER_MEMBER_TARGET = 5;
67
+ /** Starter secret cap quoted when the server did not send `removeSecretsTo`. */
68
+ export declare const STARTER_SECRET_TARGET = 10;
69
+ /** Starter agent cap quoted when the server did not send `deprovisionAgentsTo`. */
70
+ export declare const STARTER_AGENT_TARGET = 0;
50
71
  /** Upgrade destination quoted when the server did not send one. */
51
72
  export declare const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
52
73
  /** The paid plan the lock wall sends owners to. Copy lives in ONE place. */
@@ -78,10 +99,17 @@ export declare function selectMemberUsage(body: unknown): PlanLockMembers | null
78
99
  export declare function fetchPlanLockStatus(token: string, companyRef: string, opts?: {
79
100
  timeoutMs?: number;
80
101
  }): Promise<PlanLockStatus | null>;
102
+ /** Why the workspace locked, one clause per reason. */
103
+ export declare function reasonLabel(reason: PlanLockReason): string;
81
104
  /**
82
105
  * The full WORKSPACE LOCKED block: why it locked, where the workspace stands
83
- * against the cap, and the two fixes. Plain text — colour is applied by the
106
+ * against the cap, and the ways out. Plain text — colour is applied by the
84
107
  * caller so scripts capturing stdout get a clean block.
108
+ *
109
+ * Every line is derived from `lock.reasons`. A workspace locked on secrets is
110
+ * never told it has too many members, and an empty reason list (a server
111
+ * dimension this CLI does not know) renders a generic line rather than a claim
112
+ * about a dimension nobody measured.
85
113
  */
86
114
  export declare function renderPlanLockNotice(status: PlanLockStatus): string;
87
115
  /**
@@ -2,8 +2,9 @@
2
2
  * `plan-lock` (starter-plan-hard-limits / US-011) — the CLI-side read + render
3
3
  * of the workspace plan lock.
4
4
  *
5
- * Starter (free) workspaces are capped at 5 members and 0 integrations. Going
6
- * over locks the workspace immediately: it becomes read-only until the owner
5
+ * Starter (free) workspaces are capped on four locking dimensions members,
6
+ * integrations, secrets and agents (owner decision 7, 2026-09-17; deployments
7
+ * and storage nag but never lock). Going over locks the workspace immediately: it becomes read-only until the owner
7
8
  * trims back under the caps or upgrades to HQ Workforce. The lock decision is
8
9
  * NOT made here — hq-pro's `src/billing/plan-lock.ts` is the single source of
9
10
  * truth and ships the answer on `GET /membership/me` as a per-company
@@ -17,12 +18,35 @@
17
18
  */
18
19
  import chalk from "chalk";
19
20
  import { vaultApiFetch } from "../../utils/vault-api.js";
21
+ /**
22
+ * Mirror of hq-pro's `PlanLockReason`. Owner decision 7 (2026-09-17) fixes the
23
+ * locking set at these four: `deployments` and `storageBytes` are nag-only and
24
+ * never appear here. An unrecognised reason is dropped by `parsePlanLock`, so a
25
+ * server that adds a fifth dimension renders as the generic line rather than as
26
+ * a false claim about members.
27
+ */
28
+ export const PLAN_LOCK_REASONS = [
29
+ "users",
30
+ "integrations",
31
+ "secrets",
32
+ "agents",
33
+ ];
34
+ function isPlanLockReason(value) {
35
+ return PLAN_LOCK_REASONS.includes(value);
36
+ }
20
37
  /** Starter member cap quoted when the server did not send `removeMembersTo`. */
21
38
  export const STARTER_MEMBER_TARGET = 5;
39
+ /** Starter secret cap quoted when the server did not send `removeSecretsTo`. */
40
+ export const STARTER_SECRET_TARGET = 10;
41
+ /** Starter agent cap quoted when the server did not send `deprovisionAgentsTo`. */
42
+ export const STARTER_AGENT_TARGET = 0;
22
43
  /** Upgrade destination quoted when the server did not send one. */
23
44
  export const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
24
45
  /** The paid plan the lock wall sends owners to. Copy lives in ONE place. */
25
46
  export const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
47
+ function finiteNumber(value) {
48
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
49
+ }
26
50
  function asRecord(value) {
27
51
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
28
52
  return null;
@@ -43,25 +67,32 @@ export function parsePlanLock(value) {
43
67
  const reasons = [];
44
68
  if (Array.isArray(rec.reasons)) {
45
69
  for (const reason of rec.reasons) {
46
- if (reason === "users" || reason === "integrations")
70
+ if (isPlanLockReason(reason))
47
71
  reasons.push(reason);
48
72
  }
49
73
  }
50
74
  const fix = asRecord(rec.fixOptions);
51
- const removeMembersTo = typeof fix?.removeMembersTo === "number" &&
52
- Number.isFinite(fix.removeMembersTo)
53
- ? fix.removeMembersTo
54
- : STARTER_MEMBER_TARGET;
75
+ const removeMembersTo = finiteNumber(fix?.removeMembersTo) ?? STARTER_MEMBER_TARGET;
76
+ const removeSecretsTo = finiteNumber(fix?.removeSecretsTo);
77
+ const deprovisionAgentsTo = finiteNumber(fix?.deprovisionAgentsTo);
78
+ const fixOptions = {
79
+ removeMembersTo,
80
+ disconnectIntegrations: fix?.disconnectIntegrations === true,
81
+ };
82
+ // Absent stays absent: an omitted remedy target is UNKNOWN, and the renderer
83
+ // quotes the published Starter cap rather than inventing a server answer.
84
+ if (removeSecretsTo !== null)
85
+ fixOptions.removeSecretsTo = removeSecretsTo;
86
+ if (deprovisionAgentsTo !== null) {
87
+ fixOptions.deprovisionAgentsTo = deprovisionAgentsTo;
88
+ }
55
89
  return {
56
90
  locked: rec.locked,
57
91
  reasons,
58
92
  upgradeUrl: typeof rec.upgradeUrl === "string" && rec.upgradeUrl.trim().length > 0
59
93
  ? rec.upgradeUrl.trim()
60
94
  : DEFAULT_UPGRADE_URL,
61
- fixOptions: {
62
- removeMembersTo,
63
- disconnectIntegrations: fix?.disconnectIntegrations === true,
64
- },
95
+ fixOptions,
65
96
  };
66
97
  }
67
98
  /**
@@ -163,35 +194,98 @@ export async function fetchPlanLockStatus(token, companyRef, opts = {}) {
163
194
  checkedAt: new Date().toISOString(),
164
195
  };
165
196
  }
166
- function reasonLabel(reason) {
167
- return reason === "users"
168
- ? "too many members"
169
- : "integrations are not included on Starter";
197
+ /** Why the workspace locked, one clause per reason. */
198
+ export function reasonLabel(reason) {
199
+ switch (reason) {
200
+ case "users":
201
+ return "too many members";
202
+ case "integrations":
203
+ return "integrations are not included on Starter";
204
+ case "secrets":
205
+ return "too many secrets";
206
+ case "agents":
207
+ return "agents are not included on Starter";
208
+ }
209
+ }
210
+ function secretTarget(lock) {
211
+ return lock.fixOptions.removeSecretsTo ?? STARTER_SECRET_TARGET;
212
+ }
213
+ function agentTarget(lock) {
214
+ return lock.fixOptions.deprovisionAgentsTo ?? STARTER_AGENT_TARGET;
215
+ }
216
+ /** The remedy sentence for one reason — what the owner does to clear it. */
217
+ function reasonRemedy(reason, lock) {
218
+ switch (reason) {
219
+ case "users":
220
+ return `remove members until you are at ${lock.fixOptions.removeMembersTo} or fewer`;
221
+ case "integrations":
222
+ return "disconnect the workspace's integrations";
223
+ case "secrets":
224
+ return `delete secrets until you are at ${secretTarget(lock)} or fewer`;
225
+ case "agents": {
226
+ const target = agentTarget(lock);
227
+ return target > 0
228
+ ? `deprovision agents until you are at ${target} or fewer`
229
+ : "deprovision the workspace's agents";
230
+ }
231
+ }
232
+ }
233
+ /** The short per-reason clause used inside the one-line turn notice. */
234
+ function reasonDetail(reason, status) {
235
+ const { lock, members } = status;
236
+ switch (reason) {
237
+ case "users": {
238
+ const target = lock.fixOptions.removeMembersTo;
239
+ return members
240
+ ? `${members.used} of ${target} members`
241
+ : `over its ${target}-member limit`;
242
+ }
243
+ case "integrations":
244
+ return "integrations are not included on Starter";
245
+ case "secrets":
246
+ return `over its ${secretTarget(status.lock)}-secret limit`;
247
+ case "agents":
248
+ return "agents are not included on Starter";
249
+ }
250
+ }
251
+ function capitalize(text) {
252
+ return text.charAt(0).toUpperCase() + text.slice(1);
170
253
  }
171
254
  /**
172
255
  * The full WORKSPACE LOCKED block: why it locked, where the workspace stands
173
- * against the cap, and the two fixes. Plain text — colour is applied by the
256
+ * against the cap, and the ways out. Plain text — colour is applied by the
174
257
  * caller so scripts capturing stdout get a clean block.
258
+ *
259
+ * Every line is derived from `lock.reasons`. A workspace locked on secrets is
260
+ * never told it has too many members, and an empty reason list (a server
261
+ * dimension this CLI does not know) renders a generic line rather than a claim
262
+ * about a dimension nobody measured.
175
263
  */
176
264
  export function renderPlanLockNotice(status) {
177
265
  const { lock, members, companySlug } = status;
178
- const target = lock.fixOptions.removeMembersTo;
179
266
  const lines = [];
180
267
  lines.push(`WORKSPACE LOCKED — ${companySlug} is over its Starter plan.`);
181
268
  const reasons = lock.reasons.length
182
269
  ? lock.reasons.map(reasonLabel).join("; ")
183
270
  : "over the Starter plan limits";
184
271
  lines.push(` Why: ${reasons}.`);
185
- if (members) {
186
- lines.push(` Members: ${members.used} of ${target}.`);
272
+ if (lock.reasons.includes("users")) {
273
+ const target = lock.fixOptions.removeMembersTo;
274
+ lines.push(members
275
+ ? ` Members: ${members.used} of ${target}.`
276
+ : ` Members allowed on Starter: ${target}.`);
277
+ }
278
+ if (lock.reasons.includes("secrets")) {
279
+ lines.push(` Secrets allowed on Starter: ${secretTarget(lock)}.`);
187
280
  }
188
- else {
189
- lines.push(` Members allowed on Starter: ${target}.`);
281
+ if (lock.reasons.includes("agents")) {
282
+ lines.push(` Agents allowed on Starter: ${agentTarget(lock)}.`);
190
283
  }
191
284
  lines.push(" This workspace is read-only until it is fixed. Two ways out:");
192
- lines.push(` 1. Remove members until you are at ${target} or fewer${lock.fixOptions.disconnectIntegrations
193
- ? ", and disconnect the workspace's integrations"
194
- : ""}.`);
285
+ const remedy = lock.reasons.length
286
+ ? lock.reasons.map((reason) => reasonRemedy(reason, lock)).join(", and ")
287
+ : "come back under the Starter plan limits";
288
+ lines.push(` 1. ${capitalize(remedy)}.`);
195
289
  lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
196
290
  lines.push(` Upgrade: ${lock.upgradeUrl}`);
197
291
  return lines.join("\n");
@@ -201,11 +295,12 @@ export function renderPlanLockNotice(status) {
201
295
  * Kept to a single line on purpose — it repeats each turn.
202
296
  */
203
297
  export function renderPlanLockLine(status) {
204
- const target = status.lock.fixOptions.removeMembersTo;
205
- const count = status.members
206
- ? `${status.members.used} of ${target} members`
207
- : `over its ${target}-member limit`;
208
- return (`Company ${status.companySlug} is locked on Starter (${count}). ` +
298
+ const detail = status.lock.reasons.length
299
+ ? status.lock.reasons
300
+ .map((reason) => reasonDetail(reason, status))
301
+ .join("; ")
302
+ : "over the Starter plan limits";
303
+ return (`Company ${status.companySlug} is locked on Starter (${detail}). ` +
209
304
  `Writes to HQ cloud will fail until fixed: ${status.lock.upgradeUrl}`);
210
305
  }
211
306
  /**
@@ -16,6 +16,14 @@
16
16
  * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1`.
17
17
  */
18
18
  export declare const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
19
+ /**
20
+ * Plain-English name for each plan-limit resource key, so a nag line says which
21
+ * dimension is tight rather than only the wire key. The key itself stays in the
22
+ * copy — hooks and support transcripts match on it — and the label is appended
23
+ * only when it differs. An unknown key (a resource this CLI predates) renders
24
+ * as the bare key rather than a guess.
25
+ */
26
+ export declare const PLAN_LIMIT_DIMENSION_LABELS: Record<string, string>;
19
27
  export interface PlanLimitEntry {
20
28
  used: number;
21
29
  limit: number;
@@ -21,6 +21,25 @@ import * as os from "node:os";
21
21
  import * as path from "node:path";
22
22
  export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
23
23
  const DAY_MS = 24 * 60 * 60 * 1000;
24
+ /**
25
+ * Plain-English name for each plan-limit resource key, so a nag line says which
26
+ * dimension is tight rather than only the wire key. The key itself stays in the
27
+ * copy — hooks and support transcripts match on it — and the label is appended
28
+ * only when it differs. An unknown key (a resource this CLI predates) renders
29
+ * as the bare key rather than a guess.
30
+ */
31
+ export const PLAN_LIMIT_DIMENSION_LABELS = {
32
+ users: "members",
33
+ integrations: "integrations",
34
+ secrets: "secrets",
35
+ agents: "agents",
36
+ deployments: "deployments",
37
+ storageBytes: "storage",
38
+ };
39
+ function dimensionSuffix(key) {
40
+ const label = PLAN_LIMIT_DIMENSION_LABELS[key];
41
+ return label && label !== key ? ` (${label})` : "";
42
+ }
24
43
  /** Module-level last-seen cell — overwritten by each successful parse. */
25
44
  let lastSeen = null;
26
45
  /** Session dedupe for the ≥80% one-line warning. */
@@ -142,7 +161,7 @@ function formatPct(entry) {
142
161
  return `${Math.round((entry.used / entry.limit) * 100)}%`;
143
162
  }
144
163
  function formatEntryLine(key, entry) {
145
- return `${key} at ${entry.used}/${entry.limit} (${formatPct(entry)})`;
164
+ return `${key} at ${entry.used}/${entry.limit} (${formatPct(entry)})${dimensionSuffix(key)}`;
146
165
  }
147
166
  function readShownAt(statePath) {
148
167
  try {
@@ -173,7 +192,7 @@ function withinDayWindow(shownAt, nowMs) {
173
192
  function buildOverBox(overEntries, upgradeUrl) {
174
193
  const title = "⚠ HQ plan limit exceeded";
175
194
  const upgrade = `Upgrade: ${upgradeUrl}`;
176
- const resourceLines = overEntries.map(([key, entry]) => ` ${key}: ${entry.used}/${entry.limit}`);
195
+ const resourceLines = overEntries.map(([key, entry]) => ` ${key}: ${entry.used}/${entry.limit}${dimensionSuffix(key)}`);
177
196
  const contentLines = [title, "", ...resourceLines, "", upgrade];
178
197
  const innerWidth = Math.max(...contentLines.map((l) => l.length), 40);
179
198
  const top = `┌${"─".repeat(innerWidth + 2)}┐`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.117.3",
3
+ "version": "5.118.1",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {