@indigoai-us/hq-cli 5.289.0 → 5.291.0

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/assets/scaffold/core/scripts/handoff-finalize.sh +670 -0
  3. package/assets/scaffold/core/scripts/handoff-open-steps.sh +81 -0
  4. package/assets/scaffold/core/scripts/handoff-post.sh +187 -0
  5. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  6. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  7. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  8. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  9. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  10. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  11. package/assets/scaffold/core/scripts/qmd-reindex-bg.sh +40 -0
  12. package/dist/command-catalog.generated.d.ts +110 -0
  13. package/dist/command-catalog.generated.js +136 -0
  14. package/dist/commands/cloud.d.ts +26 -14
  15. package/dist/commands/cloud.js +207 -56
  16. package/dist/commands/core-handoff.d.ts +22 -0
  17. package/dist/commands/core-handoff.js +1240 -0
  18. package/dist/commands/core-jobs-native.d.ts +8 -0
  19. package/dist/commands/core-jobs-native.js +624 -0
  20. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  21. package/dist/commands/core-native-jobs-flag.js +7 -0
  22. package/dist/commands/core-ontology.d.ts +6 -1
  23. package/dist/commands/core-ontology.js +42 -7
  24. package/dist/commands/core-policy.js +24 -5
  25. package/dist/commands/core.js +15 -1
  26. package/dist/commands/integrations-core.js +7 -0
  27. package/dist/commands/lanes.js +111 -35
  28. package/dist/commands/members.js +5 -0
  29. package/dist/commands/mesh.js +46 -25
  30. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  31. package/dist/commands/personal-targeted-push-flag.js +7 -0
  32. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  33. package/dist/commands/personal-targeted-push-paths.js +67 -0
  34. package/dist/commands/secrets.js +9 -0
  35. package/dist/commands/skill.d.ts +3 -0
  36. package/dist/commands/skill.js +28 -0
  37. package/dist/fast-core.js +1 -0
  38. package/dist/lib/billing/free-month.d.ts +18 -0
  39. package/dist/lib/billing/free-month.js +31 -0
  40. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  41. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  42. package/dist/lib/billing/plan-lock.d.ts +2 -6
  43. package/dist/lib/billing/plan-lock.js +5 -16
  44. package/dist/lib/checkpoint-ontology/entity-file.d.ts +21 -0
  45. package/dist/lib/checkpoint-ontology/entity-file.js +100 -0
  46. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +34 -2
  47. package/dist/lib/checkpoint-ontology/merge-copies.js +541 -27
  48. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  49. package/dist/lib/core-utils/secret-patterns.js +3 -2
  50. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  51. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  52. package/dist/lib/lanes/autopilot.d.ts +69 -0
  53. package/dist/lib/lanes/autopilot.js +418 -0
  54. package/dist/lib/lanes/mesh-events.js +2 -0
  55. package/dist/lib/lanes/store.d.ts +4 -2
  56. package/dist/lib/lanes/store.js +18 -9
  57. package/dist/lib/mesh/api.js +4 -0
  58. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  59. package/dist/lib/mesh/live/daemon/state.js +34 -2
  60. package/dist/main.js +23 -12
  61. package/dist/utils/plan-gate-error.d.ts +24 -0
  62. package/dist/utils/plan-gate-error.js +219 -9
  63. package/dist/utils/self-update.d.ts +3 -0
  64. package/dist/utils/self-update.js +232 -1
  65. package/package.json +2 -2
@@ -29,13 +29,45 @@ export function readDaemonState(dir) {
29
29
  return null;
30
30
  }
31
31
  }
32
- export function writeDaemonState(dir, state) {
32
+ const DAEMON_STATE_RENAME_RETRY_DELAYS_MS = [20, 50, 100];
33
+ function sleepSync(ms) {
34
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)), 0, 0, ms);
35
+ }
36
+ function isTransientRenameContention(err) {
37
+ return err !== null && typeof err === "object" && "code" in err &&
38
+ (err.code === "EPERM" || err.code === "EBUSY");
39
+ }
40
+ export function writeDaemonState(dir, state, options = {}) {
33
41
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
34
42
  const dest = daemonStatePath(dir);
35
43
  const tmp = path.join(dir, `.state.tmp.${process.pid}.${Date.now()}`);
36
44
  const body = `${JSON.stringify(state, null, 2)}\n`;
37
45
  fs.writeFileSync(tmp, body, { mode: 0o600 });
38
- fs.renameSync(tmp, dest);
46
+ const renameSync = options.renameSync ?? fs.renameSync;
47
+ const waitSync = options.sleepSync ?? sleepSync;
48
+ try {
49
+ for (let attempt = 0;; attempt += 1) {
50
+ try {
51
+ renameSync(tmp, dest);
52
+ break;
53
+ }
54
+ catch (err) {
55
+ const delay = DAEMON_STATE_RENAME_RETRY_DELAYS_MS[attempt];
56
+ if (!isTransientRenameContention(err) || delay === undefined)
57
+ throw err;
58
+ waitSync(delay);
59
+ }
60
+ }
61
+ }
62
+ catch (err) {
63
+ try {
64
+ fs.unlinkSync(tmp);
65
+ }
66
+ catch {
67
+ /* Preserve the rename failure; the destination remains the prior complete state. */
68
+ }
69
+ throw err;
70
+ }
39
71
  try {
40
72
  fs.chmodSync(dest, 0o600);
41
73
  }
package/dist/main.js CHANGED
@@ -36,7 +36,7 @@ import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js
36
36
  import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
37
37
  import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
38
38
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
39
- import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, offerUpgradeLink, } from "./utils/plan-gate-error.js";
39
+ import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
40
40
  import { upgradeToTeam } from "./utils/team-upgrade.js";
41
41
  import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
42
42
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
@@ -344,6 +344,9 @@ function qmdSpawnFailureCaptureContext(err) {
344
344
  }
345
345
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
346
346
  export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
347
+ // A typed plan gate, or an hq-cloud vault error carrying a plan-limit
348
+ // refusal (402 / PLAN_LIMIT_* from a sync, share or skill upload).
349
+ const planGate = planGateErrorFromVaultError(err);
347
350
  // A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
348
351
  // (`hq … | head`, `source <(hq …)`, a parent that exited). That is normal
349
352
  // Unix behavior with no user-facing degradation — exit cleanly (0) and
@@ -416,24 +419,32 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
416
419
  deps.stderr.write(`hq: ${callbackPortBusyMessage(err, DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)}\n`);
417
420
  deps.setExitCode(1);
418
421
  }
419
- else if (isPlanGateError(err)) {
422
+ else if (planGate) {
420
423
  // Plan denials are expected product limits, never a CLI crash. The
421
424
  // server URL is always shown. Company refusals that the Workforce
422
425
  // checkout satisfies retain the direct Stripe checkout action.
426
+ //
427
+ // A plan-limit refusal (a Starter hard stop) always prints its reason,
428
+ // usage and fix options, even non-interactively: the user can fix it
429
+ // without upgrading, and an agent or script reading stderr needs the
430
+ // reason to act on it. Feature refusals keep the URL-only
431
+ // non-interactive form, because upgrading is their only fix.
423
432
  const interactive = deps.interactive ?? canOfferTeamUpgrade(deps.argv);
424
- const message = formatPlanGateError(err, { urlOnly: !interactive });
433
+ const message = formatPlanGateError(planGate, {
434
+ urlOnly: !interactive && planGate.code !== "PLAN_LIMIT_EXCEEDED",
435
+ });
425
436
  deps.stderr.write(interactive ? `hq: ${message}\n` : `${message}\n`);
426
- const promptExposure = isCliLimitResourceKind(err.details.resource ?? "")
427
- ? queueCliLimitNoticeExposure(err.details.resource, "refusal", err.details.companyUid)
437
+ const promptExposure = isCliLimitResourceKind(planGate.details.resource ?? "")
438
+ ? queueCliLimitNoticeExposure(planGate.details.resource, "refusal", planGate.details.companyUid)
428
439
  : null;
429
440
  deps.setExitCode(1);
430
- if (interactive && err.details.upgradeUrl) {
431
- const companyUid = err.details.companyUid;
441
+ if (interactive && planGate.details.upgradeUrl) {
442
+ const companyUid = planGate.details.companyUid;
432
443
  const isCompanyWorkforceCheckout = typeof companyUid === "string" &&
433
444
  /^cmp_[A-Za-z0-9_-]+$/.test(companyUid) &&
434
- (err.code === "PLAN_REQUIRED" ||
435
- err.code === "MEETING_PLAN_REQUIRED" ||
436
- err.code === "BILLING_REQUIRED");
445
+ (planGate.code === "PLAN_REQUIRED" ||
446
+ planGate.code === "MEETING_PLAN_REQUIRED" ||
447
+ planGate.code === "BILLING_REQUIRED");
437
448
  if (isCompanyWorkforceCheckout && companyUid) {
438
449
  try {
439
450
  const upgraded = await offerTeamUpgrade(async () => {
@@ -450,7 +461,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
450
461
  const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
451
462
  deps.stderr.write(`hq: ${message}\n`);
452
463
  try {
453
- const opened = await offerUpgradeLink(err.details.upgradeUrl, {
464
+ const opened = await offerUpgradeLink(planGate.details.upgradeUrl, {
454
465
  interactive: true,
455
466
  argv: deps.argv,
456
467
  ask: deps.ask,
@@ -467,7 +478,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
467
478
  }
468
479
  else {
469
480
  try {
470
- const opened = await offerUpgradeLink(err.details.upgradeUrl, {
481
+ const opened = await offerUpgradeLink(planGate.details.upgradeUrl, {
471
482
  interactive: true,
472
483
  argv: deps.argv,
473
484
  ask: deps.ask,
@@ -1,8 +1,15 @@
1
+ import { type PlanLimitFreeMonth } from "../lib/billing/free-month.js";
1
2
  export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED" | "MEETING_PLAN_REQUIRED"
2
3
  /** A PERSONAL scope asked for a feature the paid Individual plan includes. */
3
4
  | "PERSONAL_PLAN_REQUIRED"
4
5
  /** External agent enrollment was denied by the company plan. */
5
6
  | "BILLING_REQUIRED";
7
+ /** One over-limit dimension, as hq-pro's hard-stop 402 body names it. */
8
+ export interface PlanGateResourceUsage {
9
+ resource: string;
10
+ used: number;
11
+ limit: number;
12
+ }
6
13
  export interface PlanGateDetails {
7
14
  resource?: string;
8
15
  feature?: string;
@@ -12,6 +19,14 @@ export interface PlanGateDetails {
12
19
  reason?: string;
13
20
  /** Company target from request context or the server's UID-keyed billing URL. */
14
21
  companyUid?: string;
22
+ /** Hard-stop 402: the create action the server refused, e.g. `secrets.create`. */
23
+ blocked?: string;
24
+ /** Hard-stop 402: every over-limit dimension, in server order. */
25
+ resources?: PlanGateResourceUsage[];
26
+ /** Hard-stop 402: how far back each over resource must come, keyed by resource. */
27
+ fixOptions?: Record<string, number>;
28
+ /** Hard-stop 402: the free-month offer, present only when it stands. */
29
+ freeMonth?: PlanLimitFreeMonth;
15
30
  }
16
31
  /** One renderer for server-selected upgrade links across CLI plan refusals. */
17
32
  export declare function formatUpgradeRefusal(reason: string, upgradeUrl: string | undefined, options?: {
@@ -46,6 +61,15 @@ export declare function offerTeamUpgrade(onConfirm: () => Promise<void>, deps?:
46
61
  }): Promise<boolean>;
47
62
  /** Decode an already-read JSON envelope from either shared HTTP client. */
48
63
  export declare function planGateErrorFromPayload(status: number, body: unknown, companyUid?: string): PlanGateError | null;
64
+ /**
65
+ * Recognise a plan-limit refusal carried by an `@indigoai-us/hq-cloud`
66
+ * `VaultClientError` (sync, share, skill upload). Duck-typed so hq-cli keeps
67
+ * working across hq-cloud releases: the error exposes `statusCode`, `code`,
68
+ * `upgradeUrl` and the raw response `body`. The raw body is decoded first so
69
+ * the server's message, fix options and free month survive; a bare code (a
70
+ * personal presign row has no body) still becomes a refusal with its link.
71
+ */
72
+ export declare function planGateErrorFromVaultError(err: unknown): PlanGateError | null;
49
73
  /**
50
74
  * Decode the supported plan-gate envelopes. Malformed, unrelated,
51
75
  * or non-402 responses retain their existing command-specific handling.
@@ -3,17 +3,93 @@
3
3
  * fields on a typed error lets the CLI boundary render one safe, consistent
4
4
  * message instead of every command parsing and printing a server response.
5
5
  */
6
- import { renderPersonalPlanFeatureNotice } from "../lib/plan-limit-nag.js";
6
+ import { PLAN_LIMIT_DIMENSION_LABELS, renderPersonalPlanFeatureNotice, } from "../lib/plan-limit-nag.js";
7
7
  import { withCliLimitEntrySurface } from "../lib/billing/plan-limit-upgrade-url.js";
8
+ import { parseFreeMonth, } from "../lib/billing/free-month.js";
9
+ const GIB = 1024 ** 3;
10
+ const MIB = 1024 ** 2;
11
+ const KIB = 1024;
12
+ function formatStorageBytes(bytes) {
13
+ const round = (value) => String(Math.round(value * 10) / 10);
14
+ if (bytes >= GIB)
15
+ return `${round(bytes / GIB)} GB`;
16
+ if (bytes >= MIB)
17
+ return `${round(bytes / MIB)} MB`;
18
+ if (bytes >= KIB)
19
+ return `${round(bytes / KIB)} KB`;
20
+ return `${bytes} bytes`;
21
+ }
22
+ function formatResourceAmount(resource, amount) {
23
+ return resource === "storageBytes" ? formatStorageBytes(amount) : String(amount);
24
+ }
25
+ function resourceLabel(resource) {
26
+ const label = PLAN_LIMIT_DIMENSION_LABELS[resource] ?? resource;
27
+ return label.charAt(0).toUpperCase() + label.slice(1);
28
+ }
29
+ /** The trim-back action for one resource, phrased as what the user does. */
30
+ function resourceFixAction(resource) {
31
+ switch (resource) {
32
+ case "users":
33
+ return "Remove members";
34
+ case "secrets":
35
+ return "Delete secrets";
36
+ case "integrations":
37
+ return "Disconnect integrations";
38
+ case "storageBytes":
39
+ return "Delete files";
40
+ case "agents":
41
+ return "Deprovision agents";
42
+ case "deployments":
43
+ return "Remove deployments";
44
+ default:
45
+ return `Reduce ${PLAN_LIMIT_DIMENSION_LABELS[resource] ?? resource}`;
46
+ }
47
+ }
48
+ /**
49
+ * Render a plan-limit refusal: the server's sentence, where each over
50
+ * resource stands, then every way out the server offered. Only the fix options
51
+ * the server actually sent are listed, so the CLI never suggests trimming a
52
+ * dimension nobody measured.
53
+ */
54
+ function planLimitRefusalText(head, details) {
55
+ const lines = [head];
56
+ for (const row of details.resources ?? []) {
57
+ lines.push(` ${resourceLabel(row.resource)}: ` +
58
+ `${formatResourceAmount(row.resource, row.used)} of ` +
59
+ `${formatResourceAmount(row.resource, row.limit)} used.`);
60
+ }
61
+ const fixes = [];
62
+ const freeMonthUrl = safeUpgradeUrl(details.freeMonth?.url);
63
+ if (details.freeMonth?.eligible && freeMonthUrl) {
64
+ fixes.push(`Start your free month — add a card, first month free: ${freeMonthUrl}`);
65
+ }
66
+ for (const [resource, target] of Object.entries(details.fixOptions ?? {})) {
67
+ fixes.push(`${resourceFixAction(resource)} to make room (your plan allows ` +
68
+ `${formatResourceAmount(resource, target)}).`);
69
+ }
70
+ if (fixes.length > 0) {
71
+ fixes.push("Upgrade your plan.");
72
+ lines.push(" Ways to sort it:");
73
+ fixes.forEach((fix, index) => lines.push(` ${index + 1}. ${fix}`));
74
+ }
75
+ return lines.join("\n");
76
+ }
8
77
  function planGateReason(code, details) {
78
+ if (code === "PLAN_LIMIT_EXCEEDED") {
79
+ const head = details.reason?.trim()
80
+ ? details.reason.trim()
81
+ : typeof details.resource === "string" &&
82
+ typeof details.used === "number" &&
83
+ typeof details.limit === "number"
84
+ ? `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`
85
+ : details.resources?.length || details.fixOptions || details.freeMonth
86
+ ? "Your plan limit has been reached."
87
+ : undefined;
88
+ if (head)
89
+ return planLimitRefusalText(head, details);
90
+ }
9
91
  if (details.reason?.trim())
10
92
  return details.reason.trim();
11
- if (code === "PLAN_LIMIT_EXCEEDED" &&
12
- typeof details.resource === "string" &&
13
- typeof details.used === "number" &&
14
- typeof details.limit === "number") {
15
- return `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`;
16
- }
17
93
  if (code === "PERSONAL_PLAN_REQUIRED") {
18
94
  return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", null);
19
95
  }
@@ -171,6 +247,17 @@ export function planGateErrorFromPayload(status, body, companyUid) {
171
247
  const code = payload.code;
172
248
  const upgradeUrl = safeUpgradeUrl(payload.upgradeUrl);
173
249
  const targetCompanyUid = companyUid ?? companyUidFromPlanUpgradeUrl(upgradeUrl);
250
+ // Starter hard stops. The canonical body is `{ error: "plan_limit_reached",
251
+ // code: "PLAN_LIMIT_EXCEEDED", blocked, resources, message, fixOptions,
252
+ // freeMonth?, upgradeUrl }`; older servers send the body without `code`, the
253
+ // membership gate's legacy `{ code: "PLAN_LIMIT_EXCEEDED", resource, used,
254
+ // limit }`, or a personal presign row `{ code: "PLAN_LIMIT_REACHED" }`. All
255
+ // of them are the same refusal to the user, so they share one code.
256
+ if (payload.error === PLAN_LIMIT_REACHED_ERROR ||
257
+ code === "PLAN_LIMIT_EXCEEDED" ||
258
+ code === "PLAN_LIMIT_REACHED") {
259
+ return planLimitRefusalFromPayload(payload, upgradeUrl, targetCompanyUid);
260
+ }
174
261
  const planLimit = payload.planLimit !== null &&
175
262
  typeof payload.planLimit === "object" &&
176
263
  !Array.isArray(payload.planLimit)
@@ -184,8 +271,7 @@ export function planGateErrorFromPayload(status, body, companyUid) {
184
271
  return null;
185
272
  }
186
273
  }
187
- else if (code !== "PLAN_LIMIT_EXCEEDED" &&
188
- code !== "PLAN_REQUIRED" &&
274
+ else if (code !== "PLAN_REQUIRED" &&
189
275
  code !== "MEETING_PLAN_REQUIRED") {
190
276
  return null;
191
277
  }
@@ -204,6 +290,130 @@ export function planGateErrorFromPayload(status, body, companyUid) {
204
290
  ...(targetCompanyUid ? { companyUid: targetCompanyUid } : {}),
205
291
  });
206
292
  }
293
+ /** Wire value of `error` on hq-pro's hard-stop 402 body. Never shown to users. */
294
+ const PLAN_LIMIT_REACHED_ERROR = "plan_limit_reached";
295
+ function finiteNonNegative(value) {
296
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
297
+ ? value
298
+ : undefined;
299
+ }
300
+ function parseResources(value) {
301
+ if (!Array.isArray(value))
302
+ return undefined;
303
+ const rows = [];
304
+ for (const raw of value) {
305
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
306
+ continue;
307
+ const row = raw;
308
+ const used = finiteNonNegative(row.used);
309
+ const limit = finiteNonNegative(row.limit);
310
+ if (typeof row.resource !== "string" || used === undefined || limit === undefined) {
311
+ continue;
312
+ }
313
+ rows.push({ resource: row.resource, used, limit });
314
+ }
315
+ return rows.length > 0 ? rows : undefined;
316
+ }
317
+ function parseFixOptions(value) {
318
+ if (!value || typeof value !== "object" || Array.isArray(value))
319
+ return undefined;
320
+ const fix = {};
321
+ for (const [resource, target] of Object.entries(value)) {
322
+ const amount = finiteNonNegative(target);
323
+ if (amount !== undefined)
324
+ fix[resource] = amount;
325
+ }
326
+ return Object.keys(fix).length > 0 ? fix : undefined;
327
+ }
328
+ function planLimitRefusalFromPayload(payload, upgradeUrl, companyUid) {
329
+ // `message` is the customer sentence. A legacy `error` string is prose only
330
+ // when it is not a machine token (`plan_limit_reached`, or the code itself).
331
+ const reason = typeof payload.message === "string" && payload.message.trim()
332
+ ? payload.message
333
+ : typeof payload.error === "string" &&
334
+ payload.error.trim() &&
335
+ payload.error !== PLAN_LIMIT_REACHED_ERROR &&
336
+ payload.error !== payload.code
337
+ ? payload.error
338
+ : undefined;
339
+ const resources = parseResources(payload.resources);
340
+ const fixOptions = parseFixOptions(payload.fixOptions);
341
+ const parsedFreeMonth = parseFreeMonth(payload.freeMonth);
342
+ const freeMonthUrl = safeUpgradeUrl(parsedFreeMonth?.url);
343
+ const freeMonth = parsedFreeMonth
344
+ ? {
345
+ eligible: true,
346
+ ...(parsedFreeMonth.trialDays !== undefined
347
+ ? { trialDays: parsedFreeMonth.trialDays }
348
+ : {}),
349
+ ...(freeMonthUrl ? { url: freeMonthUrl } : {}),
350
+ }
351
+ : undefined;
352
+ const used = finiteNonNegative(payload.used);
353
+ const limit = finiteNonNegative(payload.limit);
354
+ return new PlanGateError("PLAN_LIMIT_EXCEEDED", {
355
+ ...(typeof payload.resource === "string" ? { resource: payload.resource } : {}),
356
+ ...(used !== undefined ? { used } : {}),
357
+ ...(limit !== undefined ? { limit } : {}),
358
+ ...(typeof payload.blocked === "string" ? { blocked: payload.blocked } : {}),
359
+ ...(resources ? { resources } : {}),
360
+ ...(fixOptions ? { fixOptions } : {}),
361
+ ...(freeMonth ? { freeMonth } : {}),
362
+ ...(upgradeUrl ? { upgradeUrl } : {}),
363
+ ...(reason ? { reason } : {}),
364
+ ...(companyUid ? { companyUid } : {}),
365
+ });
366
+ }
367
+ /** Wire codes that mark a vault/sync error as a plan-limit refusal. */
368
+ const PLAN_LIMIT_ERROR_CODES = new Set([
369
+ "PLAN_LIMIT_EXCEEDED",
370
+ "PLAN_LIMIT_REACHED",
371
+ PLAN_LIMIT_REACHED_ERROR,
372
+ ]);
373
+ /**
374
+ * Recognise a plan-limit refusal carried by an `@indigoai-us/hq-cloud`
375
+ * `VaultClientError` (sync, share, skill upload). Duck-typed so hq-cli keeps
376
+ * working across hq-cloud releases: the error exposes `statusCode`, `code`,
377
+ * `upgradeUrl` and the raw response `body`. The raw body is decoded first so
378
+ * the server's message, fix options and free month survive; a bare code (a
379
+ * personal presign row has no body) still becomes a refusal with its link.
380
+ */
381
+ export function planGateErrorFromVaultError(err) {
382
+ if (isPlanGateError(err))
383
+ return err;
384
+ if (!err || typeof err !== "object")
385
+ return null;
386
+ const candidate = err;
387
+ const code = typeof candidate.code === "string" ? candidate.code : undefined;
388
+ const isPlanLimitCode = code !== undefined && PLAN_LIMIT_ERROR_CODES.has(code);
389
+ if (candidate.statusCode !== 402 && !isPlanLimitCode)
390
+ return null;
391
+ if (typeof candidate.body === "string" && candidate.body.trim()) {
392
+ try {
393
+ const decoded = planGateErrorFromPayload(402, JSON.parse(candidate.body));
394
+ if (decoded) {
395
+ if (!decoded.details.upgradeUrl) {
396
+ const upgradeUrl = safeUpgradeUrl(candidate.upgradeUrl);
397
+ if (upgradeUrl) {
398
+ return new PlanGateError(decoded.code, { ...decoded.details, upgradeUrl });
399
+ }
400
+ }
401
+ return decoded;
402
+ }
403
+ }
404
+ catch {
405
+ // Not JSON: fall through to the structured fields on the error itself.
406
+ }
407
+ }
408
+ if (!isPlanLimitCode)
409
+ return null;
410
+ return planLimitRefusalFromPayload({
411
+ code,
412
+ ...(typeof candidate.message === "string"
413
+ ? { error: candidate.message }
414
+ : {}),
415
+ }, safeUpgradeUrl(candidate.upgradeUrl), undefined);
416
+ }
207
417
  /**
208
418
  * Decode the supported plan-gate envelopes. Malformed, unrelated,
209
419
  * or non-402 responses retain their existing command-specific handling.
@@ -56,6 +56,7 @@
56
56
  * Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
57
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
58
58
  */
59
+ import * as fs from "node:fs";
59
60
  import { type RunningInstall, type UpdateResult } from "./version-gate.js";
60
61
  import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
61
62
  /**
@@ -136,6 +137,8 @@ export interface SelfUpdateDeps {
136
137
  currentVersion?: string;
137
138
  fetchLatest?: () => Promise<string | null>;
138
139
  resolveInstall?: () => RunningInstall;
140
+ /** Same-filesystem install rename, injectable for rollback tests. */
141
+ renamePath?: typeof fs.renameSync;
139
142
  runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
140
143
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
141
144
  acquireLock?: () => (() => void) | null;