@indigoai-us/hq-cli 5.290.0 → 5.292.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 (83) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/assets/scaffold/core/scripts/handoff-finalize.sh +18 -0
  3. package/assets/scaffold/core/scripts/handoff-post.sh +71 -6
  4. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  5. package/assets/scaffold/core/scripts/hq-delegate-bundle.sh +307 -0
  6. package/assets/scaffold/core/scripts/hq-delegate-grant.sh +226 -0
  7. package/assets/scaffold/core/scripts/hq-delegate-publish.sh +91 -0
  8. package/assets/scaffold/core/scripts/hq-delegate-send.sh +259 -0
  9. package/assets/scaffold/core/scripts/hq-delegate-verify.sh +213 -0
  10. package/assets/scaffold/core/scripts/hq-job-notify.sh +389 -0
  11. package/assets/scaffold/core/scripts/hq-job-probe.sh +835 -0
  12. package/assets/scaffold/core/scripts/hq-job-remediate.sh +359 -0
  13. package/assets/scaffold/core/scripts/hq-job-run.sh +1025 -0
  14. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  15. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  16. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  17. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  18. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  19. package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +830 -0
  20. package/dist/command-catalog.generated.d.ts +165 -1
  21. package/dist/command-catalog.generated.js +205 -1
  22. package/dist/commands/cloud.d.ts +26 -14
  23. package/dist/commands/cloud.js +207 -56
  24. package/dist/commands/core-delegate-w1b.d.ts +8 -0
  25. package/dist/commands/core-delegate-w1b.js +1049 -0
  26. package/dist/commands/core-delegate.d.ts +28 -0
  27. package/dist/commands/core-delegate.js +15 -13
  28. package/dist/commands/core-handoff.js +152 -23
  29. package/dist/commands/core-jobs-native.d.ts +23 -0
  30. package/dist/commands/core-jobs-native.js +1331 -0
  31. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  32. package/dist/commands/core-native-jobs-flag.js +7 -0
  33. package/dist/commands/core-ontology.d.ts +22 -4
  34. package/dist/commands/core-ontology.js +90 -8
  35. package/dist/commands/core-policy.js +24 -5
  36. package/dist/commands/core.js +17 -0
  37. package/dist/commands/integrations-core.js +7 -0
  38. package/dist/commands/lanes.js +111 -35
  39. package/dist/commands/members.js +5 -0
  40. package/dist/commands/mesh.js +46 -25
  41. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  42. package/dist/commands/personal-targeted-push-flag.js +7 -0
  43. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  44. package/dist/commands/personal-targeted-push-paths.js +67 -0
  45. package/dist/commands/secrets.js +9 -0
  46. package/dist/commands/skill.d.ts +3 -0
  47. package/dist/commands/skill.js +28 -0
  48. package/dist/fast-core.js +1 -0
  49. package/dist/lib/billing/free-month.d.ts +18 -0
  50. package/dist/lib/billing/free-month.js +31 -0
  51. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  52. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  53. package/dist/lib/billing/plan-lock.d.ts +2 -6
  54. package/dist/lib/billing/plan-lock.js +5 -16
  55. package/dist/lib/checkpoint-ontology/drop-aliases.d.ts +41 -0
  56. package/dist/lib/checkpoint-ontology/drop-aliases.js +76 -0
  57. package/dist/lib/checkpoint-ontology/entity-file.d.ts +6 -3
  58. package/dist/lib/checkpoint-ontology/entity-file.js +12 -6
  59. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +27 -8
  60. package/dist/lib/checkpoint-ontology/merge-copies.js +64 -97
  61. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  62. package/dist/lib/core-utils/secret-patterns.js +3 -2
  63. package/dist/lib/daemon/flag.d.ts +2 -0
  64. package/dist/lib/daemon/flag.js +2 -0
  65. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  66. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  67. package/dist/lib/lanes/autopilot.d.ts +69 -0
  68. package/dist/lib/lanes/autopilot.js +418 -0
  69. package/dist/lib/lanes/mesh-events.js +2 -0
  70. package/dist/lib/lanes/store.js +11 -6
  71. package/dist/lib/mesh/api.js +4 -0
  72. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  73. package/dist/lib/mesh/live/daemon/state.js +34 -2
  74. package/dist/main.js +23 -12
  75. package/dist/utils/plan-gate-error.d.ts +24 -0
  76. package/dist/utils/plan-gate-error.js +219 -9
  77. package/dist/utils/scaffold-sibling-references.js +4 -4
  78. package/dist/utils/self-update.d.ts +6 -0
  79. package/dist/utils/self-update.js +28 -6
  80. package/dist/utils/staged-install.d.ts +34 -0
  81. package/dist/utils/staged-install.js +296 -0
  82. package/package.json +2 -2
  83. package/scripts/mesh-daemon-if-stale.mjs +4 -0
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.
@@ -42,7 +42,7 @@ function shellLocationAliases(contents) {
42
42
  const scriptRoot = SCRIPT_ROOT_TOKENS.exec(value);
43
43
  if (scriptRoot) {
44
44
  const suffix = value.slice(scriptRoot.index + scriptRoot[0].length)
45
- .match(/^\/([^"'`\s;|&)]+)/)?.[1] ?? ".";
45
+ .match(/^\/([^"'`\s;|&)}]+)/)?.[1] ?? ".";
46
46
  aliases.set(name, { kind: "asset", baseDirectory: path.posix.normalize(suffix) });
47
47
  continue;
48
48
  }
@@ -116,15 +116,15 @@ export function scanScaffoldSiblingReferences(scaffoldRoot) {
116
116
  (/\b(?:source|exec|bash|sh|node)\b/.test(line) || /^\s*\.\s+/.test(line)));
117
117
  for (const line of commandLines) {
118
118
  SCRIPT_ROOT_TOKENS.lastIndex = 0;
119
- for (const match of line.matchAll(/(?:\$\{SCRIPT_DIR\}|\$SCRIPT_DIR)\/([^"'`\s;|&)]+)/g)) {
119
+ for (const match of line.matchAll(/(?:\$\{SCRIPT_DIR\}|\$SCRIPT_DIR)\/([^"'`\s;|&)}]+)/g)) {
120
120
  recordRelative(sourceFile, match[1]);
121
121
  }
122
- const dirnamePath = new RegExp(String.raw `\$\(\s*dirname\s+["']?${BASH_SOURCE_TOKEN}["']?\s*\)(\/[^"'\x60\s;|&)]+)`, "g");
122
+ const dirnamePath = new RegExp(String.raw `\$\(\s*dirname\s+["']?${BASH_SOURCE_TOKEN}["']?\s*\)(\/[^"'\x60\s;|&)}]+)`, "g");
123
123
  for (const match of line.matchAll(dirnamePath))
124
124
  recordRelative(sourceFile, match[1]);
125
125
  for (const [alias, location] of aliases) {
126
126
  const escapedAlias = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
127
- const aliasPath = new RegExp(String.raw `(?:\$\{${escapedAlias}\}|\$${escapedAlias})\/([^"'\x60\s;|&)]+)`, "g");
127
+ const aliasPath = new RegExp(String.raw `(?:\$\{${escapedAlias}\}|\$${escapedAlias})\/([^"'\x60\s;|&)}]+)`, "g");
128
128
  for (const match of line.matchAll(aliasPath)) {
129
129
  if (location.kind === "hq-root") {
130
130
  const reference = match[0];
@@ -161,6 +161,12 @@ export interface SelfUpdateDeps {
161
161
  interactive?: boolean;
162
162
  /** Best-effort daemon kick after the CLI install converges (tests). */
163
163
  restartDaemon?: () => RestartDaemonResult | void;
164
+ /**
165
+ * Whether `cli.staged-self-update` is on: build the new version beside the
166
+ * running package and swap it in, instead of moving the running package
167
+ * aside for the whole install. Read only when an npm global install is due.
168
+ */
169
+ stagedSelfUpdate?: () => Promise<boolean>;
164
170
  }
165
171
  /**
166
172
  * Startup path: the running CLI is behind npm `latest`, so update in place and
@@ -64,6 +64,7 @@ import semver from "semver";
64
64
  import chalk from "chalk";
65
65
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
66
66
  import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, derivePnpmHome, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
67
+ import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
67
68
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
68
69
  import { markLatestIneffective } from "./version-check.js";
69
70
  import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
@@ -414,12 +415,15 @@ async function updateAndReexec(argv, flavor, known, deps) {
414
415
  console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
415
416
  return { action: "skipped", latest };
416
417
  }
418
+ const staged = canStageInstall(install) && (await readStagedSelfUpdateFlag(deps, env));
417
419
  const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
418
420
  if (!releaseLock)
419
421
  return { action: "skipped", latest };
420
422
  const renamePath = deps.renamePath ?? fs.renameSync;
421
- const installSnapshot = snapshotInstall(install, renamePath);
422
- if (!installSnapshot) {
423
+ // The staged install keeps the running package in place until the swap and
424
+ // restores it itself, so it takes no snapshot.
425
+ const installSnapshot = staged ? null : snapshotInstall(install, renamePath);
426
+ if (!staged && !installSnapshot) {
423
427
  console.error(chalk.dim(" No backup could be made for this hq-cli install; proceeding without rollback protection."));
424
428
  }
425
429
  let result;
@@ -440,10 +444,15 @@ async function updateAndReexec(argv, flavor, known, deps) {
440
444
  // Forward env only when PNPM_HOME had to be injected, so the common path
441
445
  // spawns with the inherited environment and keeps the two-argument runner
442
446
  // call it has always made.
443
- result =
444
- updateEnv === undefined
445
- ? runner(plan.cmd, plan.args)
446
- : runner(plan.cmd, plan.args, updateEnv);
447
+ if (staged) {
448
+ result = stagedNpmInstall(install, latestValid, { run: runner, env, rename: renamePath });
449
+ }
450
+ else if (updateEnv === undefined) {
451
+ result = runner(plan.cmd, plan.args);
452
+ }
453
+ else {
454
+ result = runner(plan.cmd, plan.args, updateEnv);
455
+ }
447
456
  if (!result.ok && installSnapshot) {
448
457
  restoredAfterFailure = restoreInstall(installSnapshot, renamePath);
449
458
  if (restoredAfterFailure)
@@ -537,6 +546,19 @@ async function updateAndReexec(argv, flavor, known, deps) {
537
546
  console.error(chalk.dim(`✓ hq-cli updated to ${latest}.`));
538
547
  return { action: "reexec", reexecStatus: status, latest };
539
548
  }
549
+ async function readStagedSelfUpdateFlag(deps, env) {
550
+ try {
551
+ if (deps.stagedSelfUpdate)
552
+ return await deps.stagedSelfUpdate();
553
+ // Loaded here so the startup path pays for the flag client only when an
554
+ // update is actually due.
555
+ const { readDaemonFlag, STAGED_SELF_UPDATE_FLAG } = await import("../lib/daemon/flag.js");
556
+ return await readDaemonFlag(STAGED_SELF_UPDATE_FLAG, { env });
557
+ }
558
+ catch {
559
+ return false;
560
+ }
561
+ }
540
562
  /**
541
563
  * Startup path: the running CLI is behind npm `latest`, so update in place and
542
564
  * re-run the user's command on the new version. `latest` is normally the cached
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Staged npm self-update: install the new hq-cli beside the running package,
3
+ * check that it starts, then swap it in with two renames.
4
+ *
5
+ * The in-place update moves the live package aside for the whole
6
+ * `npm install -g`, so anything that starts `hq` during the install finds no
7
+ * CLI on disk ("hq: command not found", MODULE_NOT_FOUND; outposts primary and
8
+ * 2, 5.290.0 -> 5.291.0). Here the live package stays in place until the new
9
+ * one is complete and has answered `--version`.
10
+ *
11
+ * Only a plain npm global layout is staged: `<prefix>/lib/node_modules/<pkg>`
12
+ * as a real directory, with bin links in `<prefix>/bin`. pnpm, Bun, Windows
13
+ * and linked checkouts keep the in-place update.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import { type RunningInstall, type UpdateResult } from "./version-gate.js";
17
+ /** Set for the staging install; hq-cli's own postinstall skips itself on it. */
18
+ export declare const STAGED_INSTALL_ENV = "HQ_STAGED_INSTALL";
19
+ export interface StagedInstallDeps {
20
+ /** Runs the package manager. Defaults to {@link runUpdateCommand}. */
21
+ run?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
22
+ env?: NodeJS.ProcessEnv;
23
+ /** The version the staged package's `hq --version` reports, or null. */
24
+ probeVersion?: (packageRoot: string) => string | null;
25
+ /** The package's postinstall, run from the live path after the swap. */
26
+ runPostinstall?: (packageRoot: string) => void;
27
+ rename?: typeof fs.renameSync;
28
+ /** Removes the staging and backup directories. */
29
+ remove?: (target: string) => void;
30
+ platform?: NodeJS.Platform;
31
+ }
32
+ export declare function canStageInstall(install: RunningInstall, platform?: NodeJS.Platform): boolean;
33
+ export declare function stagedNpmInstall(install: RunningInstall, version: string, deps?: StagedInstallDeps): UpdateResult;
34
+ //# sourceMappingURL=staged-install.d.ts.map