@indigoai-us/hq-cli 5.161.0 → 5.163.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 (72) hide show
  1. package/CHANGELOG.md +27 -2
  2. package/dist/command-catalog.generated.d.ts +188 -0
  3. package/dist/command-catalog.generated.js +235 -0
  4. package/dist/command-registration-plan.d.ts +7 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/agent-enroll.d.ts +6 -1
  7. package/dist/commands/agent-enroll.js +30 -3
  8. package/dist/commands/agents.js +15 -22
  9. package/dist/commands/bot.js +56 -6
  10. package/dist/commands/daemon.d.ts +53 -0
  11. package/dist/commands/daemon.js +554 -0
  12. package/dist/commands/lanes.js +10 -1
  13. package/dist/commands/meetings.js +18 -16
  14. package/dist/commands/mesh.js +9 -0
  15. package/dist/lib/daemon/catalog.d.ts +85 -0
  16. package/dist/lib/daemon/catalog.js +274 -0
  17. package/dist/lib/daemon/config.d.ts +26 -0
  18. package/dist/lib/daemon/config.js +85 -0
  19. package/dist/lib/daemon/handoff.d.ts +53 -0
  20. package/dist/lib/daemon/handoff.js +136 -0
  21. package/dist/lib/daemon/install.d.ts +76 -0
  22. package/dist/lib/daemon/install.js +299 -0
  23. package/dist/lib/daemon/legacy.d.ts +35 -0
  24. package/dist/lib/daemon/legacy.js +120 -0
  25. package/dist/lib/daemon/lock.d.ts +47 -0
  26. package/dist/lib/daemon/lock.js +138 -0
  27. package/dist/lib/daemon/machine-type.d.ts +48 -0
  28. package/dist/lib/daemon/machine-type.js +139 -0
  29. package/dist/lib/daemon/migrate.d.ts +61 -0
  30. package/dist/lib/daemon/migrate.js +238 -0
  31. package/dist/lib/daemon/paths.d.ts +22 -0
  32. package/dist/lib/daemon/paths.js +42 -0
  33. package/dist/lib/daemon/remote-control.d.ts +35 -0
  34. package/dist/lib/daemon/remote-control.js +47 -0
  35. package/dist/lib/daemon/requests.d.ts +24 -0
  36. package/dist/lib/daemon/requests.js +63 -0
  37. package/dist/lib/daemon/run.d.ts +41 -0
  38. package/dist/lib/daemon/run.js +319 -0
  39. package/dist/lib/daemon/supervisor.d.ts +87 -0
  40. package/dist/lib/daemon/supervisor.js +523 -0
  41. package/dist/lib/daemon/updater.d.ts +39 -0
  42. package/dist/lib/daemon/updater.js +103 -0
  43. package/dist/lib/flag-registry.d.ts +7 -1
  44. package/dist/lib/flag-registry.js +8 -0
  45. package/dist/lib/lanes/delivery.d.ts +5 -3
  46. package/dist/lib/lanes/delivery.js +14 -6
  47. package/dist/lib/lanes/spawn.d.ts +5 -0
  48. package/dist/lib/lanes/spawn.js +25 -3
  49. package/dist/lib/lanes/types.d.ts +11 -2
  50. package/dist/lib/lanes/types.js +11 -0
  51. package/dist/lib/lanes/watcher.d.ts +2 -2
  52. package/dist/lib/lanes/watcher.js +2 -2
  53. package/dist/lib/mesh/live/backoff.d.ts +2 -2
  54. package/dist/lib/mesh/live/backoff.js +16 -3
  55. package/dist/lib/mesh/live/daemon/run.d.ts +2 -2
  56. package/dist/lib/mesh/live/daemon/run.js +5 -2
  57. package/dist/lib/mesh/live/flush.d.ts +3 -2
  58. package/dist/lib/mesh/live/flush.js +34 -7
  59. package/dist/lib/mesh/live/session-events-client.d.ts +2 -0
  60. package/dist/lib/mesh/live/session-events-client.js +25 -0
  61. package/dist/lib/service-manager/systemd.d.ts +2 -2
  62. package/dist/lib/service-manager/systemd.js +1 -1
  63. package/dist/lib/service-manager/types.d.ts +2 -0
  64. package/dist/main.d.ts +6 -0
  65. package/dist/main.js +53 -13
  66. package/dist/utils/cognito-session.d.ts +4 -0
  67. package/dist/utils/cognito-session.js +11 -10
  68. package/dist/utils/plan-gate-error.d.ts +27 -13
  69. package/dist/utils/plan-gate-error.js +133 -58
  70. package/dist/utils/self-update.d.ts +9 -0
  71. package/dist/utils/self-update.js +15 -2
  72. package/package.json +1 -1
@@ -23,6 +23,7 @@ export interface SessionEventsPostResult {
23
23
  body: unknown;
24
24
  ok: boolean;
25
25
  retryable: boolean;
26
+ retryAfterMs?: number;
26
27
  networkError?: boolean;
27
28
  }
28
29
  /** Contract §2.3 batch ingest ack (parsed from a 2xx body). */
@@ -51,6 +52,7 @@ export declare function createSessionEventsPoster(opts: {
51
52
  post?: (path: string, body: SessionEventsBatchBody) => Promise<{
52
53
  status: number;
53
54
  body: unknown;
55
+ headers?: Pick<Headers, "get">;
54
56
  }>;
55
57
  }): SessionEventsPoster;
56
58
  /** Resolve API base: HQ_VAULT_API_URL overrides DEFAULT_VAULT_API_URL. */
@@ -15,6 +15,27 @@ export const WRITABLE_CONTEXT_STATUSES = new Set([
15
15
  "bound",
16
16
  "migration_pending",
17
17
  ]);
18
+ /** Parse the Retry-After delta-seconds or HTTP-date form into milliseconds. */
19
+ function parseRetryAfterMs(value, nowMs = Date.now()) {
20
+ if (value === null || !value.trim())
21
+ return undefined;
22
+ const trimmed = value.trim();
23
+ if (/^\d+$/.test(trimmed)) {
24
+ const delayMs = Number(trimmed) * 1_000;
25
+ return Number.isSafeInteger(delayMs) ? delayMs : undefined;
26
+ }
27
+ if (/^[+-]/.test(trimmed))
28
+ return undefined;
29
+ const retryAtMs = Date.parse(trimmed);
30
+ if (!Number.isFinite(retryAtMs))
31
+ return undefined;
32
+ return Math.max(0, retryAtMs - nowMs);
33
+ }
34
+ function retryAfterForStatus(status, headers) {
35
+ if (status !== 429 && status !== 503)
36
+ return undefined;
37
+ return parseRetryAfterMs(headers?.get("retry-after") ?? null);
38
+ }
18
39
  /**
19
40
  * Parse a batch ingest response body. Returns null when the body is not an
20
41
  * object (caller should retain events rather than treating them as posted).
@@ -65,9 +86,11 @@ export function createSessionEventsPoster(opts) {
65
86
  try {
66
87
  if (opts.post) {
67
88
  const res = await opts.post(SESSION_EVENTS_PATH, body);
89
+ const retryAfterMs = retryAfterForStatus(res.status, res.headers);
68
90
  return {
69
91
  status: res.status,
70
92
  body: res.body,
93
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
71
94
  ...classifySessionEventsResponse(res.status),
72
95
  };
73
96
  }
@@ -88,9 +111,11 @@ export function createSessionEventsPoster(opts) {
88
111
  parsed = { raw: text };
89
112
  }
90
113
  }
114
+ const retryAfterMs = retryAfterForStatus(res.status, res.headers);
91
115
  return {
92
116
  status: res.status,
93
117
  body: parsed,
118
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
94
119
  ...classifySessionEventsResponse(res.status),
95
120
  };
96
121
  }
@@ -10,8 +10,8 @@ import { type ServiceHostPaths, type ServiceSpec } from "./types.js";
10
10
  import type { RunCommand } from "./launchd.js";
11
11
  export declare const SYSTEMD_PATH_ENV = "/usr/local/bin:/usr/bin:/bin";
12
12
  export declare function systemdUserDir(home: string): string;
13
- export declare function systemdUnitName(spec: Pick<ServiceSpec, "name">): string;
14
- export declare function systemdUnitPath(home: string, spec: Pick<ServiceSpec, "name">): string;
13
+ export declare function systemdUnitName(spec: Pick<ServiceSpec, "name" | "unitName">): string;
14
+ export declare function systemdUnitPath(home: string, spec: Pick<ServiceSpec, "name" | "unitName">): string;
15
15
  export declare function renderSystemdUserUnit(spec: ServiceSpec, host: ServiceHostPaths): string;
16
16
  export declare function activateSystemd(unitName: string, run: RunCommand): void;
17
17
  export declare function deactivateSystemd(unitName: string, run: RunCommand): void;
@@ -13,7 +13,7 @@ export function systemdUserDir(home) {
13
13
  return path.join(home, ".config", "systemd", "user");
14
14
  }
15
15
  export function systemdUnitName(spec) {
16
- return `hq-agent-${spec.name}.service`;
16
+ return spec.unitName ?? `hq-agent-${spec.name}.service`;
17
17
  }
18
18
  export function systemdUnitPath(home, spec) {
19
19
  return path.join(systemdUserDir(home), systemdUnitName(spec));
@@ -19,6 +19,8 @@ export interface ServiceSpec {
19
19
  env: Record<string, string>;
20
20
  /** Seconds to wait before a restart after exit. */
21
21
  restartSec: number;
22
+ /** systemd unit file name; defaults to `hq-agent-<name>.service`. */
23
+ unitName?: string;
22
24
  }
23
25
  export interface ServiceHostPaths {
24
26
  home: string;
package/dist/main.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import "./node-preflight.js";
6
6
  import "./node-network-compat.js";
7
7
  import { Sentry } from "./sentry.js";
8
+ import { upgradeToTeam } from "./utils/team-upgrade.js";
8
9
  /**
9
10
  * Total time one CLI invocation may await foreground network work. This spans
10
11
  * the hard version decision, pre-action observability, and release-health
@@ -21,6 +22,11 @@ export type TopLevelErrorDependencies = {
21
22
  sentry: Pick<typeof Sentry, "captureException">;
22
23
  stderr: Pick<typeof process.stderr, "write">;
23
24
  setExitCode: (code: number) => void;
25
+ interactive?: boolean;
26
+ argv?: readonly string[];
27
+ ask?: (question: string) => Promise<string>;
28
+ openUrl?: (url: string) => Promise<void>;
29
+ upgradeToTeam?: typeof upgradeToTeam;
24
30
  };
25
31
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
26
32
  export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): Promise<void>;
package/dist/main.js CHANGED
@@ -35,7 +35,7 @@ import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js
35
35
  import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
36
36
  import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
37
37
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
38
- import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
38
+ import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, offerUpgradeLink, } from "./utils/plan-gate-error.js";
39
39
  import { upgradeToTeam } from "./utils/team-upgrade.js";
40
40
  import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
41
41
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
@@ -379,20 +379,60 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
379
379
  deps.setExitCode(1);
380
380
  }
381
381
  else if (isPlanGateError(err)) {
382
- // hq-pro's plan denials are expected product limits, never a CLI crash.
383
- // The shared vault client has already decoded and typed the small safe
384
- // envelope; do not expose its raw body or retry a rejected creation.
385
- deps.stderr.write(`hq: ${formatPlanGateError(err)}\n`);
382
+ // Plan denials are expected product limits, never a CLI crash. The
383
+ // server URL is always shown. Company refusals that the Workforce
384
+ // checkout satisfies retain the direct Stripe checkout action.
385
+ const interactive = deps.interactive ?? canOfferTeamUpgrade(deps.argv);
386
+ const message = formatPlanGateError(err, { urlOnly: !interactive });
387
+ deps.stderr.write(interactive ? `hq: ${message}\n` : `${message}\n`);
386
388
  deps.setExitCode(1);
387
- if (canOfferTeamUpgrade()) {
388
- try {
389
- await offerTeamUpgrade(async () => {
390
- await upgradeToTeam({ companyUid: err.details.companyUid });
391
- });
389
+ if (interactive && err.details.upgradeUrl) {
390
+ const companyUid = err.details.companyUid;
391
+ const isCompanyWorkforceCheckout = typeof companyUid === "string" &&
392
+ /^cmp_[A-Za-z0-9_-]+$/.test(companyUid) &&
393
+ (err.code === "PLAN_REQUIRED" ||
394
+ err.code === "MEETING_PLAN_REQUIRED" ||
395
+ err.code === "BILLING_REQUIRED");
396
+ if (isCompanyWorkforceCheckout && companyUid) {
397
+ try {
398
+ await offerTeamUpgrade(async () => {
399
+ await (deps.upgradeToTeam ?? upgradeToTeam)({ companyUid });
400
+ }, {
401
+ interactive: true,
402
+ argv: deps.argv,
403
+ ask: deps.ask,
404
+ });
405
+ }
406
+ catch (upgradeErr) {
407
+ const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
408
+ deps.stderr.write(`hq: ${message}\n`);
409
+ try {
410
+ await offerUpgradeLink(err.details.upgradeUrl, {
411
+ interactive: true,
412
+ argv: deps.argv,
413
+ ask: deps.ask,
414
+ openUrl: deps.openUrl,
415
+ });
416
+ }
417
+ catch (linkErr) {
418
+ const linkMessage = linkErr instanceof Error ? linkErr.message : String(linkErr);
419
+ deps.stderr.write(`hq: ${linkMessage}\n`);
420
+ }
421
+ }
392
422
  }
393
- catch (upgradeErr) {
394
- const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
395
- deps.stderr.write(`hq: ${message}\n`);
423
+ else {
424
+ try {
425
+ await offerUpgradeLink(err.details.upgradeUrl, {
426
+ interactive: true,
427
+ argv: deps.argv,
428
+ ask: deps.ask,
429
+ openUrl: deps.openUrl,
430
+ });
431
+ }
432
+ catch (upgradeErr) {
433
+ const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
434
+ deps.stderr.write(`hq: ${message}\n`);
435
+ }
396
436
  }
397
437
  }
398
438
  }
@@ -81,6 +81,10 @@ export declare const DEFAULT_VAULT_API_URL: string;
81
81
  */
82
82
  export declare function resolveDefaultHqRoot(opts?: {
83
83
  onMissing?: "throw" | "fallback";
84
+ /** Defaults to process.env, process.cwd() and os.homedir(). */
85
+ env?: NodeJS.ProcessEnv;
86
+ cwd?: string;
87
+ home?: string;
84
88
  }): string;
85
89
  export declare const DEFAULT_HQ_ROOT: string;
86
90
  /**
@@ -102,22 +102,24 @@ export const DEFAULT_VAULT_API_URL = process.env.HQ_VAULT_API_URL ?? "https://hq
102
102
  * Re-importable as a function for command-time resolution and tests.
103
103
  */
104
104
  export function resolveDefaultHqRoot(opts = {}) {
105
+ const env = opts.env ?? process.env;
106
+ const home = opts.home ?? os.homedir();
105
107
  // Tier 1: explicit override.
106
- if (process.env.HQ_ROOT)
107
- return path.resolve(process.env.HQ_ROOT);
108
+ if (env.HQ_ROOT)
109
+ return path.resolve(env.HQ_ROOT);
108
110
  // Tier 2: working-tree walk-up — operate on the HQ tree you're inside.
109
- let cur = path.resolve(process.cwd());
111
+ let cur = path.resolve(opts.cwd ?? process.cwd());
110
112
  while (cur !== path.dirname(cur)) {
111
113
  if (isHqRoot(cur))
112
114
  return cur;
113
115
  cur = path.dirname(cur);
114
116
  }
115
117
  // Tiers 3–4: the installer/menubar's canonical "where is HQ" records.
116
- const fromConfig = readHqRootFromHqConfig(path.join(os.homedir(), ".hq"));
118
+ const fromConfig = readHqRootFromHqConfig(path.join(home, ".hq"));
117
119
  if (fromConfig)
118
120
  return path.resolve(fromConfig);
119
121
  // Tier 5: discover a valid HQ root in the well-known locations.
120
- const discovered = discoverHqRootViaCoreYaml();
122
+ const discovered = discoverHqRootViaCoreYaml(home);
121
123
  if (discovered)
122
124
  return discovered;
123
125
  // Tier 6: nothing resolved.
@@ -127,7 +129,7 @@ export function resolveDefaultHqRoot(opts = {}) {
127
129
  "or install HQ via the installer/menubar so ~/.hq/menubar.json records " +
128
130
  "its location.");
129
131
  }
130
- return path.join(os.homedir(), "hq");
132
+ return path.join(home, "hq");
131
133
  }
132
134
  /**
133
135
  * Read the configured HQ root from the installer/menubar records under
@@ -156,8 +158,7 @@ function readJsonStringField(file, field) {
156
158
  }
157
159
  }
158
160
  /** Well-known parent locations an HQ tree commonly lives in (mirrors hq-sync). */
159
- function hqDiscoveryCandidates() {
160
- const home = os.homedir();
161
+ function hqDiscoveryCandidates(home = os.homedir()) {
161
162
  return [
162
163
  path.join(home, "HQ"),
163
164
  path.join(home, "hq"),
@@ -172,8 +173,8 @@ function hqDiscoveryCandidates() {
172
173
  * signature. First match wins; returns undefined if none qualify. Cheap — a
173
174
  * few stats plus one small YAML parse on a hit.
174
175
  */
175
- function discoverHqRootViaCoreYaml() {
176
- return hqDiscoveryCandidates().find(isValidHqRootSignature);
176
+ function discoverHqRootViaCoreYaml(home) {
177
+ return hqDiscoveryCandidates(home).find(isValidHqRootSignature);
177
178
  }
178
179
  /**
179
180
  * True iff `dir` carries a valid hq-core `core.yaml` — canonical
@@ -1,30 +1,44 @@
1
- export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED"
2
- /**
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.
7
- */
8
- | "PERSONAL_PLAN_REQUIRED";
1
+ export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED" | "MEETING_PLAN_REQUIRED"
2
+ /** A PERSONAL scope asked for a feature the paid Individual plan includes. */
3
+ | "PERSONAL_PLAN_REQUIRED"
4
+ /** External agent enrollment was denied by the company plan. */
5
+ | "BILLING_REQUIRED";
9
6
  export interface PlanGateDetails {
10
7
  resource?: string;
11
- /** The refused feature, on a `PERSONAL_PLAN_REQUIRED` denial. */
12
8
  feature?: string;
13
9
  used?: number;
14
10
  limit?: number;
15
11
  upgradeUrl?: string;
16
- /** Best-effort target captured by the shared HTTP client for interactive checkout. */
12
+ reason?: string;
13
+ /** Company target from request context or the server's UID-keyed billing URL. */
17
14
  companyUid?: string;
18
15
  }
16
+ /** One renderer for server-selected upgrade links across CLI plan refusals. */
17
+ export declare function formatUpgradeRefusal(reason: string, upgradeUrl: string | undefined, options?: {
18
+ urlOnly?: boolean;
19
+ }): string;
20
+ /** Recover a company UID only from the server's UID-keyed billing callback. */
21
+ export declare function companyUidFromPlanUpgradeUrl(value: unknown): string | undefined;
19
22
  export declare class PlanGateError extends Error {
20
23
  readonly code: PlanGateCode;
21
24
  readonly details: PlanGateDetails;
22
25
  constructor(code: PlanGateCode, details: PlanGateDetails);
23
26
  }
24
27
  export declare function isPlanGateError(err: unknown): err is PlanGateError;
25
- export declare function formatPlanGateError(err: PlanGateError): string;
28
+ /** Plain, stable CLI copy for plan-gated resource creation. */
29
+ export declare function formatPlanGateError(err: PlanGateError, options?: {
30
+ urlOnly?: boolean;
31
+ }): string;
26
32
  export declare function canOfferTeamUpgrade(argv?: readonly string[]): boolean;
27
- /** Ask once, only on a real interactive terminal. */
33
+ export interface UpgradeLinkOfferDependencies {
34
+ ask?: (question: string) => Promise<string>;
35
+ openUrl?: (url: string) => Promise<void>;
36
+ argv?: readonly string[];
37
+ interactive?: boolean;
38
+ }
39
+ /** Ask before opening the exact URL returned by the server. */
40
+ export declare function offerUpgradeLink(url: string, deps?: UpgradeLinkOfferDependencies): Promise<boolean>;
41
+ /** Legacy checkout callback retained for callers outside the shared refusal paths. */
28
42
  export declare function offerTeamUpgrade(onConfirm: () => Promise<void>, deps?: {
29
43
  ask?: (question: string) => Promise<string>;
30
44
  argv?: readonly string[];
@@ -33,7 +47,7 @@ export declare function offerTeamUpgrade(onConfirm: () => Promise<void>, deps?:
33
47
  /** Decode an already-read JSON envelope from either shared HTTP client. */
34
48
  export declare function planGateErrorFromPayload(status: number, body: unknown, companyUid?: string): PlanGateError | null;
35
49
  /**
36
- * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
50
+ * Decode the supported plan-gate envelopes. Malformed, unrelated,
37
51
  * or non-402 responses retain their existing command-specific handling.
38
52
  */
39
53
  export declare function planGateErrorFromResponse(response: Response, companyUid?: string): Promise<{
@@ -4,19 +4,74 @@
4
4
  * message instead of every command parsing and printing a server response.
5
5
  */
6
6
  import { renderPersonalPlanFeatureNotice } from "../lib/plan-limit-nag.js";
7
- const TEAM_UPGRADE_HINT = "Run `hq billing upgrade` to move to HQ Workforce.";
7
+ function planGateReason(code, details) {
8
+ if (details.reason?.trim())
9
+ return details.reason.trim();
10
+ if (code === "PLAN_LIMIT_EXCEEDED" &&
11
+ typeof details.resource === "string" &&
12
+ typeof details.used === "number" &&
13
+ typeof details.limit === "number") {
14
+ return `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`;
15
+ }
16
+ if (code === "PERSONAL_PLAN_REQUIRED") {
17
+ return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", null);
18
+ }
19
+ if (code === "MEETING_PLAN_REQUIRED") {
20
+ return "HQ Workforce plan required to invite a meeting bot.";
21
+ }
22
+ if (code === "BILLING_REQUIRED") {
23
+ return "Upgrade the company plan before enrolling an external agent.";
24
+ }
25
+ return "HQ Workforce plan required for this feature.";
26
+ }
27
+ /** One renderer for server-selected upgrade links across CLI plan refusals. */
28
+ export function formatUpgradeRefusal(reason, upgradeUrl, options = {}) {
29
+ const safeUrl = safeUpgradeUrl(upgradeUrl);
30
+ if (options.urlOnly && safeUrl)
31
+ return safeUrl;
32
+ if (!safeUrl)
33
+ return reason;
34
+ return `${reason}\nUpgrade: ${safeUrl}`;
35
+ }
36
+ function safeUpgradeUrl(value) {
37
+ if (typeof value !== "string" ||
38
+ value.trim() !== value ||
39
+ !/^https:\/\//i.test(value)) {
40
+ return undefined;
41
+ }
42
+ try {
43
+ const url = new URL(value);
44
+ if (url.protocol !== "https:" ||
45
+ !url.hostname ||
46
+ url.username ||
47
+ url.password) {
48
+ return undefined;
49
+ }
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ return value;
55
+ }
56
+ /** Recover a company UID only from the server's UID-keyed billing callback. */
57
+ export function companyUidFromPlanUpgradeUrl(value) {
58
+ const safeUrl = safeUpgradeUrl(value);
59
+ if (!safeUrl)
60
+ return undefined;
61
+ const callbackUrl = new URL(safeUrl).searchParams.get("callbackUrl");
62
+ if (!callbackUrl)
63
+ return undefined;
64
+ const match = /^\/api\/companies\/(cmp_[A-Za-z0-9_-]+)\/billing\/upgrade\/?$/.exec(callbackUrl);
65
+ return match?.[1];
66
+ }
67
+ function formatPlanGateErrorText(code, details) {
68
+ return formatUpgradeRefusal(planGateReason(code, details), details.upgradeUrl);
69
+ }
8
70
  export class PlanGateError extends Error {
9
71
  code;
10
72
  details;
11
73
  constructor(code, details) {
12
- // Commands with their own expected-error boundary commonly print
13
- // `err.message`. Keeping the friendly copy here means those boundaries
14
- // retain the same plan-gate voice as main.ts without per-command handling.
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"));
74
+ super(formatPlanGateErrorText(code, details));
20
75
  this.code = code;
21
76
  this.details = details;
22
77
  this.name = "PlanGateError";
@@ -26,56 +81,52 @@ export function isPlanGateError(err) {
26
81
  return err instanceof PlanGateError;
27
82
  }
28
83
  /** Plain, stable CLI copy for plan-gated resource creation. */
29
- function formatPlanGateDetails(code, details) {
30
- const existingResourcesNote = "Existing resources will keep working.";
31
- if (code === "PLAN_LIMIT_EXCEEDED" &&
32
- typeof details.resource === "string" &&
33
- typeof details.used === "number" &&
34
- typeof details.limit === "number") {
35
- const upgrade = typeof details.upgradeUrl === "string"
36
- ? `Upgrade to HQ Workforce ($500/mo) to remove limits: ${details.upgradeUrl}`
37
- : "Upgrade to HQ Workforce ($500/mo) to remove limits.";
38
- return [
39
- `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`,
40
- upgrade,
41
- existingResourcesNote,
42
- ].join("\n");
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
- }
50
- const upgrade = typeof details.upgradeUrl === "string"
51
- ? `Upgrade to HQ Workforce ($500/mo) to remove limits: ${details.upgradeUrl}`
52
- : "Upgrade to HQ Workforce ($500/mo) to remove limits.";
53
- return [
54
- "HQ Workforce plan required for this feature.",
55
- upgrade,
56
- existingResourcesNote,
57
- ].join("\n");
58
- }
59
- export function formatPlanGateError(err) {
60
- if (err.code === "PERSONAL_PLAN_REQUIRED") {
61
- return formatPlanGateDetails(err.code, err.details);
62
- }
63
- return [
64
- formatPlanGateDetails(err.code, err.details),
65
- TEAM_UPGRADE_HINT,
66
- ].join("\n");
84
+ export function formatPlanGateError(err, options = {}) {
85
+ return formatUpgradeRefusal(planGateReason(err.code, err.details), err.details.upgradeUrl, options);
67
86
  }
68
87
  export function canOfferTeamUpgrade(argv = process.argv) {
69
88
  return Boolean(process.stdin.isTTY && process.stderr.isTTY) &&
70
89
  !process.env.CI &&
71
90
  !argv.includes("--json");
72
91
  }
73
- /** Ask once, only on a real interactive terminal. */
92
+ /** Ask before opening the exact URL returned by the server. */
93
+ export async function offerUpgradeLink(url, deps = {}) {
94
+ const safeUrl = safeUpgradeUrl(url);
95
+ if (!safeUrl)
96
+ return false;
97
+ if (!(deps.interactive ?? canOfferTeamUpgrade(deps.argv)))
98
+ return false;
99
+ let answer;
100
+ if (deps.ask) {
101
+ answer = await deps.ask("Open upgrade link now? [y/N] ");
102
+ }
103
+ else {
104
+ const { createInterface } = await import("node:readline/promises");
105
+ const readline = createInterface({ input: process.stdin, output: process.stderr });
106
+ try {
107
+ answer = await readline.question("Open upgrade link now? [y/N] ");
108
+ }
109
+ finally {
110
+ readline.close();
111
+ }
112
+ }
113
+ if (!/^y(?:es)?$/i.test(answer.trim()))
114
+ return false;
115
+ if (deps.openUrl) {
116
+ await deps.openUrl(safeUrl);
117
+ }
118
+ else {
119
+ const { default: open } = await import("open");
120
+ await open(safeUrl);
121
+ }
122
+ return true;
123
+ }
124
+ /** Legacy checkout callback retained for callers outside the shared refusal paths. */
74
125
  export async function offerTeamUpgrade(onConfirm, deps = {}) {
75
126
  if (!(deps.interactive ?? canOfferTeamUpgrade(deps.argv)))
76
127
  return false;
77
128
  if (deps.ask) {
78
- const answer = await deps.ask("Upgrade to HQ Workforce ($500/mo) now? [y/N] ");
129
+ const answer = await deps.ask("Upgrade now? (opens checkout) [y/N] ");
79
130
  if (!/^y(?:es)?$/i.test(answer.trim()))
80
131
  return false;
81
132
  await onConfirm();
@@ -84,7 +135,7 @@ export async function offerTeamUpgrade(onConfirm, deps = {}) {
84
135
  const { createInterface } = await import("node:readline/promises");
85
136
  const readline = createInterface({ input: process.stdin, output: process.stderr });
86
137
  try {
87
- const answer = await readline.question("Upgrade to HQ Workforce ($500/mo) now? [y/N] ");
138
+ const answer = await readline.question("Upgrade now? (opens checkout) [y/N] ");
88
139
  if (!/^y(?:es)?$/i.test(answer.trim()))
89
140
  return false;
90
141
  await onConfirm();
@@ -106,28 +157,52 @@ export function planGateErrorFromPayload(status, body, companyUid) {
106
157
  // through to the company path below (defensive consumer).
107
158
  if (payload.error === "plan_feature_unavailable" &&
108
159
  payload.scope === "personal") {
160
+ const upgradeUrl = safeUpgradeUrl(payload.upgradeUrl);
109
161
  return new PlanGateError("PERSONAL_PLAN_REQUIRED", {
110
162
  ...(typeof payload.feature === "string"
111
163
  ? { feature: payload.feature }
112
164
  : {}),
113
- ...(typeof payload.upgradeUrl === "string"
114
- ? { upgradeUrl: payload.upgradeUrl }
115
- : {}),
165
+ ...(upgradeUrl ? { upgradeUrl } : {}),
116
166
  });
117
167
  }
118
- if (payload.code !== "PLAN_LIMIT_EXCEEDED" && payload.code !== "PLAN_REQUIRED") {
168
+ const code = payload.code;
169
+ const upgradeUrl = safeUpgradeUrl(payload.upgradeUrl);
170
+ const targetCompanyUid = companyUid ?? companyUidFromPlanUpgradeUrl(upgradeUrl);
171
+ const planLimit = payload.planLimit !== null &&
172
+ typeof payload.planLimit === "object" &&
173
+ !Array.isArray(payload.planLimit)
174
+ ? payload.planLimit
175
+ : undefined;
176
+ const isTeamPlanBillingRequired = code === "BILLING_REQUIRED" && planLimit?.requiredPlan === "team";
177
+ if (code === "BILLING_REQUIRED") {
178
+ if (!isTeamPlanBillingRequired ||
179
+ !targetCompanyUid ||
180
+ !/^cmp_[A-Za-z0-9_-]+$/.test(targetCompanyUid)) {
181
+ return null;
182
+ }
183
+ }
184
+ else if (code !== "PLAN_LIMIT_EXCEEDED" &&
185
+ code !== "PLAN_REQUIRED" &&
186
+ code !== "MEETING_PLAN_REQUIRED") {
119
187
  return null;
120
188
  }
121
- return new PlanGateError(payload.code, {
189
+ const reason = typeof payload.message === "string"
190
+ ? payload.message
191
+ : typeof payload.error === "string" && payload.error !== payload.code
192
+ ? payload.error
193
+ : undefined;
194
+ return new PlanGateError(code, {
122
195
  ...(typeof payload.resource === "string" ? { resource: payload.resource } : {}),
196
+ ...(typeof payload.feature === "string" ? { feature: payload.feature } : {}),
123
197
  ...(typeof payload.used === "number" ? { used: payload.used } : {}),
124
198
  ...(typeof payload.limit === "number" ? { limit: payload.limit } : {}),
125
- ...(typeof payload.upgradeUrl === "string" ? { upgradeUrl: payload.upgradeUrl } : {}),
126
- ...(companyUid ? { companyUid } : {}),
199
+ ...(upgradeUrl ? { upgradeUrl } : {}),
200
+ ...(reason ? { reason } : {}),
201
+ ...(targetCompanyUid ? { companyUid: targetCompanyUid } : {}),
127
202
  });
128
203
  }
129
204
  /**
130
- * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
205
+ * Decode the supported plan-gate envelopes. Malformed, unrelated,
131
206
  * or non-402 responses retain their existing command-specific handling.
132
207
  */
133
208
  export async function planGateErrorFromResponse(response, companyUid) {
@@ -83,6 +83,8 @@ export type SelfUpdateAction =
83
83
  | "update-ineffective"
84
84
  /** Updated and the command re-ran on the new version; exit with `reexecStatus`. */
85
85
  | "reexec"
86
+ /** Updated with no re-exec (the `hq daemon` updater restarts the daemon instead). */
87
+ | "updated"
86
88
  /**
87
89
  * Unattended caller: replacing the CLI in place is unsafe here, so nothing
88
90
  * was attempted and the command runs on the current version. An attended
@@ -171,4 +173,11 @@ export declare function autoUpdateAndReexec(argv: readonly string[], latest: str
171
173
  * are preserved.
172
174
  */
173
175
  export declare function selfUpdateAndReexec(argv: readonly string[], deps?: SelfUpdateDeps): Promise<SelfUpdateOutcome>;
176
+ /**
177
+ * `hq daemon` updater service: update to npm latest with nobody at a terminal
178
+ * and no re-exec. It runs as its own short-lived child of the daemon, so no
179
+ * user command is waiting on the package being replaced; after an "updated"
180
+ * outcome the daemon restarts itself onto the new version.
181
+ */
182
+ export declare function daemonSelfUpdate(deps?: SelfUpdateDeps): Promise<SelfUpdateOutcome>;
174
183
  //# sourceMappingURL=self-update.d.ts.map
@@ -292,6 +292,10 @@ async function updateAndReexec(argv, flavor, known, deps) {
292
292
  catch {
293
293
  // Defensive only; the default helper already absorbs manager failures.
294
294
  }
295
+ if (!flavor.reexec) {
296
+ console.error(chalk.dim(`✓ hq-cli updated to ${latest}.`));
297
+ return { action: "updated", latest };
298
+ }
295
299
  const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
296
300
  const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
297
301
  if (status === null) {
@@ -309,7 +313,7 @@ async function updateAndReexec(argv, flavor, known, deps) {
309
313
  * this resolve it from the registry.
310
314
  */
311
315
  export async function autoUpdateAndReexec(argv, latest, deps = {}) {
312
- return updateAndReexec(argv, { noun: "command", verbose: false, lock: true, onlyWhenAttended: true }, latest, deps);
316
+ return updateAndReexec(argv, { noun: "command", verbose: false, lock: true, onlyWhenAttended: true, reexec: true }, latest, deps);
313
317
  }
314
318
  /**
315
319
  * Rescue path: update to npm latest and re-exec the rescue on the new version.
@@ -318,6 +322,15 @@ export async function autoUpdateAndReexec(argv, latest, deps = {}) {
318
322
  * are preserved.
319
323
  */
320
324
  export async function selfUpdateAndReexec(argv, deps = {}) {
321
- return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true, onlyWhenAttended: false }, null, deps);
325
+ return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true, onlyWhenAttended: false, reexec: true }, null, deps);
326
+ }
327
+ /**
328
+ * `hq daemon` updater service: update to npm latest with nobody at a terminal
329
+ * and no re-exec. It runs as its own short-lived child of the daemon, so no
330
+ * user command is waiting on the package being replaced; after an "updated"
331
+ * outcome the daemon restarts itself onto the new version.
332
+ */
333
+ export async function daemonSelfUpdate(deps = {}) {
334
+ return updateAndReexec([], { noun: "daemon update", verbose: false, lock: true, onlyWhenAttended: false, reexec: false }, null, deps);
322
335
  }
323
336
  //# sourceMappingURL=self-update.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.161.0",
3
+ "version": "5.163.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {