@indigoai-us/hq-cli 5.101.1 → 5.101.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.101.3] — 2026-08-15
6
+
7
+ ## [5.101.2] — 2026-08-15
8
+
9
+ ### Fixed
10
+
11
+ - Self-update now detects Bun-managed global installs and runs `bun add -g`
12
+ through the owning package manager. Existing pnpm-managed and npm prefix
13
+ installs, including Homebrew prefixes such as `/opt/homebrew`, continue to
14
+ update the copy that is actually running without an unnecessary `sudo`.
15
+
5
16
  ## [5.101.1] — 2026-08-14
6
17
 
7
18
  ### Fixed
@@ -27,6 +27,7 @@ import * as readline from "node:readline";
27
27
  import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
28
28
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
29
29
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
30
+ import { isPlanGateError } from "../utils/plan-gate-error.js";
30
31
  import { confirmChargeOrExit, formatUsd, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
31
32
  /** Reasoning-effort values hq-pro accepts on `runtime-config`. */
32
33
  export const VALID_EFFORTS = new Set([
@@ -381,6 +382,10 @@ function sleep(ms) {
381
382
  // Command registration
382
383
  // ---------------------------------------------------------------------------
383
384
  function fail(err) {
385
+ // Let the CLI boundary render and offer the one shared Team checkout flow.
386
+ // Local exits would otherwise prevent interactive plan-limit remediation.
387
+ if (isPlanGateError(err))
388
+ throw err;
384
389
  if (err instanceof AgentsHttpError) {
385
390
  console.error(chalk.red(err.message));
386
391
  }
@@ -16,6 +16,7 @@ import chalk from "chalk";
16
16
  import { ensureCognitoToken } from "../utils/cognito-session.js";
17
17
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
18
18
  import { mintPaymentLink } from "../utils/billing-gate.js";
19
+ import { upgradeToTeam } from "../utils/team-upgrade.js";
19
20
  function fail(err) {
20
21
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
21
22
  process.exit(1);
@@ -107,5 +108,23 @@ export function registerBillingCommand(program) {
107
108
  fail(err);
108
109
  }
109
110
  });
111
+ billing
112
+ .command("upgrade")
113
+ .description("Open Stripe Checkout to upgrade this company to HQ Team ($500/mo)")
114
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
115
+ .option("--no-browser", "Print the Stripe Checkout URL instead of opening it")
116
+ .option("--json", "Emit the Checkout URL as JSON")
117
+ .action(async function (opts) {
118
+ try {
119
+ await upgradeToTeam({
120
+ company: companyOf(this),
121
+ noBrowser: opts.browser === false,
122
+ json: opts.json,
123
+ });
124
+ }
125
+ catch (err) {
126
+ fail(err);
127
+ }
128
+ });
110
129
  }
111
130
  //# sourceMappingURL=billing.js.map
@@ -439,6 +439,10 @@ function bashSingleQuote(value) {
439
439
  function systemdQuoted(value) {
440
440
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
441
441
  }
442
+ // U60 records this compatible hq-cloud artifact. Outpost launchers must stay
443
+ // reproducible: `@latest` could silently opt a long-lived box into a different
444
+ // sync protocol before its server-side eligibility is evaluated.
445
+ const OUTPOST_HQ_CLOUD_VERSION = "6.15.1";
442
446
  function renderSelfDeploySyncScript(hqRoot) {
443
447
  return `#!/usr/bin/env bash
444
448
  set -u
@@ -448,7 +452,7 @@ HQ_ROOT=${bashSingleQuote(hqRoot)}
448
452
  cd "$HQ_ROOT"
449
453
  while true; do
450
454
  hq auth refresh || echo "[outpost-sync] auth refresh failed, continuing"
451
- npx -y --package=@indigoai-us/hq-cloud@latest hq-sync-runner \\
455
+ npx -y --package=@indigoai-us/hq-cloud@${OUTPOST_HQ_CLOUD_VERSION} hq-sync-runner \\
452
456
  --companies \\
453
457
  --direction both \\
454
458
  --on-conflict keep \\
@@ -36,7 +36,7 @@ export class ControlPlaneDbClient {
36
36
  catch {
37
37
  /* keep */
38
38
  }
39
- const planGate = planGateErrorFromPayload(res.status, payload);
39
+ const planGate = planGateErrorFromPayload(res.status, payload, input.companyUid);
40
40
  if (planGate)
41
41
  throw planGate;
42
42
  const err = new Error(msg);
@@ -63,6 +63,22 @@ export declare class QmdExitError extends Error {
63
63
  export declare class QmdCollectionMissingError extends QmdExitError {
64
64
  name: string;
65
65
  }
66
+ /**
67
+ * qmd's `collection add` exited non-zero because the requested name is ALREADY
68
+ * registered in qmd's YAML config under exactly that name — the idempotent,
69
+ * already-satisfied duplicate (qmd 2.5.3 prints `Collection '<name>' already
70
+ * exists.`). `collectionName` carries the parsed name so a caller can confirm
71
+ * the duplicate is the collection it was adding. Distinct from qmd's DIFFERENT
72
+ * path+pattern duplicate ("A collection already exists for this path and
73
+ * pattern:"), which names ANOTHER collection, carries no quoted-name form, and
74
+ * stays a plain {@link QmdExitError} so it keeps failing loud. HQ-CLI-Q
75
+ * (Sentry 7670823899).
76
+ */
77
+ export declare class QmdCollectionExistsError extends QmdExitError {
78
+ readonly collectionName: string;
79
+ name: string;
80
+ constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, collectionName: string);
81
+ }
66
82
  export type ResolveQmdBinOptions = {
67
83
  env?: Record<string, string | undefined>;
68
84
  isExecutable?: (candidate: string) => boolean;
@@ -241,7 +257,21 @@ export type ReconcileCollectionsOptions = {
241
257
  bin?: string;
242
258
  runner?: QmdProcessRunner;
243
259
  };
244
- /** Register expected collections that qmd does not yet know about. */
260
+ /**
261
+ * Register expected collections that qmd does not yet know about, returning the
262
+ * collections this call actually created (a `collection add` that exited 0).
263
+ *
264
+ * A same-name idempotent duplicate — qmd already owns the exact requested name
265
+ * in its YAML config (`Collection '<name>' already exists.`), while the
266
+ * SQLite-backed `collection list` we derive `missing` from had diverged — is
267
+ * NOT a failure. Reconciliation still issues the `context add`, whose qmd-side
268
+ * resyncConfig re-syncs YAML into SQLite and heals the divergence that provoked
269
+ * the retry, then continues with later collections; such a pre-existing
270
+ * collection is excluded from the returned list. Every other qmd failure still
271
+ * propagates: the DIFFERENT path+pattern duplicate, a same-name duplicate that
272
+ * named a different collection, a missing collection, a `context add` failure,
273
+ * a missing/unbuilt qmd binary. HQ-CLI-Q (Sentry 7670823899).
274
+ */
245
275
  export declare function reconcileCollections(hqRoot: string, options?: ReconcileCollectionsOptions): SearchCollection[];
246
276
  export declare function listRegisteredCollections(hqRoot: string, options?: RunQmdOptions): Set<string>;
247
277
  export {};
@@ -42,6 +42,25 @@ export class QmdExitError extends Error {
42
42
  export class QmdCollectionMissingError extends QmdExitError {
43
43
  name = 'QmdCollectionMissingError';
44
44
  }
45
+ /**
46
+ * qmd's `collection add` exited non-zero because the requested name is ALREADY
47
+ * registered in qmd's YAML config under exactly that name — the idempotent,
48
+ * already-satisfied duplicate (qmd 2.5.3 prints `Collection '<name>' already
49
+ * exists.`). `collectionName` carries the parsed name so a caller can confirm
50
+ * the duplicate is the collection it was adding. Distinct from qmd's DIFFERENT
51
+ * path+pattern duplicate ("A collection already exists for this path and
52
+ * pattern:"), which names ANOTHER collection, carries no quoted-name form, and
53
+ * stays a plain {@link QmdExitError} so it keeps failing loud. HQ-CLI-Q
54
+ * (Sentry 7670823899).
55
+ */
56
+ export class QmdCollectionExistsError extends QmdExitError {
57
+ collectionName;
58
+ name = 'QmdCollectionExistsError';
59
+ constructor(message, args, status, stdout, stderr, collectionName) {
60
+ super(message, args, status, stdout, stderr);
61
+ this.collectionName = collectionName;
62
+ }
63
+ }
45
64
  function isExecutable(candidate) {
46
65
  try {
47
66
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -643,6 +662,18 @@ function finishRunQmd(result, bin, args) {
643
662
  if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
644
663
  throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
645
664
  }
665
+ // qmd's same-name duplicate: `collection add` targeted a name YAML already
666
+ // owns (`Collection '<name>' already exists.`). Reconciliation treats this
667
+ // exact shape as already-satisfied (HQ-CLI-Q). The DIFFERENT path+pattern
668
+ // duplicate ("A collection already exists for this path and pattern:") names
669
+ // another collection and carries no quoted-name form, so it never matches and
670
+ // stays a plain QmdExitError. Colour is off under capture (qmd gates on
671
+ // isTTY) and any surrounding ANSI sits outside this substring, so the plain
672
+ // match is robust either way.
673
+ const alreadyExists = /Collection '([^']+)' already exists\./.exec(detail);
674
+ if (alreadyExists) {
675
+ throw new QmdCollectionExistsError(message, args, normalized.status, normalized.stdout, normalized.stderr, alreadyExists[1]);
676
+ }
646
677
  throw new QmdExitError(message, args, normalized.status, normalized.stdout, normalized.stderr);
647
678
  }
648
679
  /** Run qmd with captured output and typed failures. */
@@ -741,16 +772,41 @@ export function deriveCollections(hqRoot) {
741
772
  }
742
773
  return collections;
743
774
  }
744
- /** Register expected collections that qmd does not yet know about. */
775
+ /**
776
+ * Register expected collections that qmd does not yet know about, returning the
777
+ * collections this call actually created (a `collection add` that exited 0).
778
+ *
779
+ * A same-name idempotent duplicate — qmd already owns the exact requested name
780
+ * in its YAML config (`Collection '<name>' already exists.`), while the
781
+ * SQLite-backed `collection list` we derive `missing` from had diverged — is
782
+ * NOT a failure. Reconciliation still issues the `context add`, whose qmd-side
783
+ * resyncConfig re-syncs YAML into SQLite and heals the divergence that provoked
784
+ * the retry, then continues with later collections; such a pre-existing
785
+ * collection is excluded from the returned list. Every other qmd failure still
786
+ * propagates: the DIFFERENT path+pattern duplicate, a same-name duplicate that
787
+ * named a different collection, a missing collection, a `context add` failure,
788
+ * a missing/unbuilt qmd binary. HQ-CLI-Q (Sentry 7670823899).
789
+ */
745
790
  export function reconcileCollections(hqRoot, options = {}) {
746
791
  const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
747
792
  const registered = listRegisteredCollections(hqRoot, runOptions);
748
793
  const missing = deriveCollections(hqRoot).filter((collection) => !registered.has(collection.name));
794
+ const created = [];
749
795
  for (const collection of missing) {
750
- runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
796
+ try {
797
+ runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
798
+ created.push(collection);
799
+ }
800
+ catch (error) {
801
+ // Only the exact same-name duplicate for THIS collection is already
802
+ // satisfied. Anything else — including a same-name duplicate that names a
803
+ // different collection — stays fatal.
804
+ if (!(error instanceof QmdCollectionExistsError) || error.collectionName !== collection.name)
805
+ throw error;
806
+ }
751
807
  runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
752
808
  }
753
- return missing;
809
+ return created;
754
810
  }
755
811
  export function listRegisteredCollections(hqRoot, options = {}) {
756
812
  const result = runQmd(['collection', 'list'], { ...options, cwd: options.cwd ?? hqRoot });
package/dist/main.d.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  import "./node-preflight.js";
6
6
  import "./node-network-compat.js";
7
7
  import { Sentry } from "./sentry.js";
8
+ export type StreamErrorDependencies = {
9
+ stderr: Pick<typeof process.stderr, "write">;
10
+ exit: (code: number) => void;
11
+ };
12
+ export declare function handleStreamError(err: NodeJS.ErrnoException, deps?: StreamErrorDependencies): void;
8
13
  export declare function runCli(): Promise<void>;
9
14
  export type TopLevelErrorDependencies = {
10
15
  sentry: Pick<typeof Sentry, "captureException">;
@@ -12,5 +17,5 @@ export type TopLevelErrorDependencies = {
12
17
  setExitCode: (code: number) => void;
13
18
  };
14
19
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
15
- export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): void;
20
+ export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): Promise<void>;
16
21
  //# sourceMappingURL=main.d.ts.map
package/dist/main.js CHANGED
@@ -70,7 +70,8 @@ import { isEpipe } from "./utils/epipe.js";
70
70
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
71
71
  import { isAuthError } from "./utils/auth-error.js";
72
72
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
73
- import { formatPlanGateError, isPlanGateError, } from "./utils/plan-gate-error.js";
73
+ import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
74
+ import { upgradeToTeam } from "./utils/team-upgrade.js";
74
75
  import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
75
76
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
76
77
  import { autoUpdateAndReexec } from "./utils/self-update.js";
@@ -82,19 +83,47 @@ import { isPackageRootResolutionError, packageRootCaptureContext, } from "./util
82
83
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
83
84
  /** Hard upper bound for non-user-visible release-health finalization. */
84
85
  const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
85
- // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
86
- // the pipe early. This covers the ASYNC path — an 'error' event emitted on the
87
- // stream. The SYNCHRONOUS path (a `write EPIPE` thrown straight out of
88
- // console.log inside a command) is handled in the top-level catch below; both
89
- // share `isEpipe` (HQ-6B).
90
- const onPipeError = (err) => {
86
+ const defaultStreamErrorDependencies = {
87
+ stderr: process.stderr,
88
+ exit: (code) => process.exit(code),
89
+ };
90
+ // Handle an 'error' event on process.stdout/stderr without turning a benign
91
+ // machine-state condition into a Sentry fatal.
92
+ // - EPIPE: a downstream reader closed the pipe early (`hq … | head`,
93
+ // `source <(hq …)`, a parent that exited) — a clean exit 0, no capture. The
94
+ // SYNCHRONOUS path (a `write EPIPE` thrown straight out of console.log) is
95
+ // handled in the top-level catch below; both share `isEpipe` (HQ-6B).
96
+ // - ENOSPC / EDQUOT / EROFS: a full disk, exhausted quota, or read-only mount
97
+ // (e.g. stdout redirected to a file that fills up) — the user's machine,
98
+ // not an hq-cli defect. Print one actionable line (best-effort; the same
99
+ // disk may reject it) and exit 1 WITHOUT rethrowing, so it never becomes an
100
+ // uncaughtException that ships a fatal (HQ-CLI-R). sentry.ts's
101
+ // path-independent beforeSend also drops it, covering the buffered-write
102
+ // route that never surfaces here.
103
+ // - Anything else: rethrow — an unknown stream failure must stay loud.
104
+ // Exported in dependency-injectable form (mirroring handleTopLevelError) so the
105
+ // process boundary stays unit-testable through the ./main.js import seam.
106
+ export function handleStreamError(err, deps = defaultStreamErrorDependencies) {
91
107
  if (isEpipe(err)) {
92
- process.exit(0);
108
+ deps.exit(0);
109
+ return;
110
+ }
111
+ const envMessage = environmentalFsErrorMessage(err);
112
+ if (envMessage) {
113
+ try {
114
+ deps.stderr.write(`hq: ${envMessage}\n`);
115
+ }
116
+ catch {
117
+ // The same full disk / read-only mount can reject this write too; there
118
+ // is nothing more to surface, but we still exit non-zero below.
119
+ }
120
+ deps.exit(1);
121
+ return;
93
122
  }
94
123
  throw err;
95
- };
96
- process.stdout.on("error", onPipeError);
97
- process.stderr.on("error", onPipeError);
124
+ }
125
+ process.stdout.on("error", (err) => handleStreamError(err));
126
+ process.stderr.on("error", (err) => handleStreamError(err));
98
127
  initSentry();
99
128
  const program = new Command();
100
129
  program
@@ -292,7 +321,7 @@ export async function runCli() {
292
321
  await program.parseAsync();
293
322
  }
294
323
  catch (err) {
295
- handleTopLevelError(err);
324
+ await handleTopLevelError(err);
296
325
  }
297
326
  finally {
298
327
  // Plan-limit nag (US-016): stderr-only, never throws, never touches
@@ -319,7 +348,7 @@ const defaultTopLevelErrorDependencies = {
319
348
  },
320
349
  };
321
350
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
322
- export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
351
+ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
323
352
  // A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
324
353
  // (`hq … | head`, `source <(hq …)`, a parent that exited). That is normal
325
354
  // Unix behavior with no user-facing degradation — exit cleanly (0) and
@@ -366,6 +395,17 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
366
395
  // envelope; do not expose its raw body or retry a rejected creation.
367
396
  deps.stderr.write(`hq: ${formatPlanGateError(err)}\n`);
368
397
  deps.setExitCode(1);
398
+ if (canOfferTeamUpgrade()) {
399
+ try {
400
+ await offerTeamUpgrade(async () => {
401
+ await upgradeToTeam({ companyUid: err.details.companyUid });
402
+ });
403
+ }
404
+ catch (upgradeErr) {
405
+ const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
406
+ deps.stderr.write(`hq: ${message}\n`);
407
+ }
408
+ }
369
409
  }
370
410
  else if (isExpectedUserError(err)) {
371
411
  // HQ-CLI-6: a user-facing, client-caused error (a non-owner running
package/dist/sentry.js CHANGED
@@ -5,6 +5,7 @@ import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
5
5
  import { CLI_VERSION } from "./cli-version.js";
6
6
  import { getCachedSentryUser } from "./utils/sentry-identity.js";
7
7
  import { isEpipe } from "./utils/epipe.js";
8
+ import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
8
9
  /**
9
10
  * Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
10
11
  * reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
@@ -18,6 +19,17 @@ import { isEpipe } from "./utils/epipe.js";
18
19
  export function epipeAwareBeforeSend(event, hint) {
19
20
  if (isEpipe(hint?.originalException))
20
21
  return null;
22
+ // Path-independent belt for environmental filesystem failures (ENOSPC /
23
+ // EDQUOT / EROFS — a full disk, exhausted quota, or read-only mount, e.g.
24
+ // stdout redirected to a file that fills up). These are the user's machine,
25
+ // never an hq-cli defect, and can surface by several routes: a synchronous
26
+ // throw through the top-level boundary, a rethrow from the stdout/stderr
27
+ // 'error' listener, or a buffered-write uncaughtException that never reaches
28
+ // either. handleTopLevelError already prints an actionable message for the
29
+ // boundary route; dropping the event here suppresses the fatal regardless of
30
+ // route, while the CLI still exits non-zero. HQ-CLI-R (Sentry 7671416365).
31
+ if (environmentalFsErrorMessage(hint?.originalException))
32
+ return null;
21
33
  return beforeSend(event, hint);
22
34
  }
23
35
  export function initSentry() {
@@ -9,6 +9,8 @@ export interface PlanGateDetails {
9
9
  used?: number;
10
10
  limit?: number;
11
11
  upgradeUrl?: string;
12
+ /** Best-effort target captured by the shared HTTP client for interactive checkout. */
13
+ companyUid?: string;
12
14
  }
13
15
  export declare class PlanGateError extends Error {
14
16
  readonly code: PlanGateCode;
@@ -17,13 +19,20 @@ export declare class PlanGateError extends Error {
17
19
  }
18
20
  export declare function isPlanGateError(err: unknown): err is PlanGateError;
19
21
  export declare function formatPlanGateError(err: PlanGateError): string;
22
+ export declare function canOfferTeamUpgrade(argv?: readonly string[]): boolean;
23
+ /** Ask once, only on a real interactive terminal. */
24
+ export declare function offerTeamUpgrade(onConfirm: () => Promise<void>, deps?: {
25
+ ask?: (question: string) => Promise<string>;
26
+ argv?: readonly string[];
27
+ interactive?: boolean;
28
+ }): Promise<boolean>;
20
29
  /** Decode an already-read JSON envelope from either shared HTTP client. */
21
- export declare function planGateErrorFromPayload(status: number, body: unknown): PlanGateError | null;
30
+ export declare function planGateErrorFromPayload(status: number, body: unknown, companyUid?: string): PlanGateError | null;
22
31
  /**
23
32
  * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
24
33
  * or non-402 responses retain their existing command-specific handling.
25
34
  */
26
- export declare function planGateErrorFromResponse(response: Response): Promise<{
35
+ export declare function planGateErrorFromResponse(response: Response, companyUid?: string): Promise<{
27
36
  error: PlanGateError | null;
28
37
  response: Response;
29
38
  }>;
@@ -1,3 +1,4 @@
1
+ const TEAM_UPGRADE_HINT = "Run `hq billing upgrade` to move to Team.";
1
2
  export class PlanGateError extends Error {
2
3
  code;
3
4
  details;
@@ -5,7 +6,7 @@ export class PlanGateError extends Error {
5
6
  // Commands with their own expected-error boundary commonly print
6
7
  // `err.message`. Keeping the friendly copy here means those boundaries
7
8
  // retain the same plan-gate voice as main.ts without per-command handling.
8
- super(formatPlanGateDetails(code, details));
9
+ super([formatPlanGateDetails(code, details), TEAM_UPGRADE_HINT].join("\n"));
9
10
  this.code = code;
10
11
  this.details = details;
11
12
  this.name = "PlanGateError";
@@ -40,10 +41,42 @@ function formatPlanGateDetails(code, details) {
40
41
  ].join("\n");
41
42
  }
42
43
  export function formatPlanGateError(err) {
43
- return formatPlanGateDetails(err.code, err.details);
44
+ return [
45
+ formatPlanGateDetails(err.code, err.details),
46
+ TEAM_UPGRADE_HINT,
47
+ ].join("\n");
48
+ }
49
+ export function canOfferTeamUpgrade(argv = process.argv) {
50
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY) &&
51
+ !process.env.CI &&
52
+ !argv.includes("--json");
53
+ }
54
+ /** Ask once, only on a real interactive terminal. */
55
+ export async function offerTeamUpgrade(onConfirm, deps = {}) {
56
+ if (!(deps.interactive ?? canOfferTeamUpgrade(deps.argv)))
57
+ return false;
58
+ if (deps.ask) {
59
+ const answer = await deps.ask("Upgrade to HQ Team ($500/mo) now? [y/N] ");
60
+ if (!/^y(?:es)?$/i.test(answer.trim()))
61
+ return false;
62
+ await onConfirm();
63
+ return true;
64
+ }
65
+ const { createInterface } = await import("node:readline/promises");
66
+ const readline = createInterface({ input: process.stdin, output: process.stderr });
67
+ try {
68
+ const answer = await readline.question("Upgrade to HQ Team ($500/mo) now? [y/N] ");
69
+ if (!/^y(?:es)?$/i.test(answer.trim()))
70
+ return false;
71
+ await onConfirm();
72
+ return true;
73
+ }
74
+ finally {
75
+ readline.close();
76
+ }
44
77
  }
45
78
  /** Decode an already-read JSON envelope from either shared HTTP client. */
46
- export function planGateErrorFromPayload(status, body) {
79
+ export function planGateErrorFromPayload(status, body, companyUid) {
47
80
  if (status !== 402 || !body || typeof body !== "object")
48
81
  return null;
49
82
  const payload = body;
@@ -55,13 +88,14 @@ export function planGateErrorFromPayload(status, body) {
55
88
  ...(typeof payload.used === "number" ? { used: payload.used } : {}),
56
89
  ...(typeof payload.limit === "number" ? { limit: payload.limit } : {}),
57
90
  ...(typeof payload.upgradeUrl === "string" ? { upgradeUrl: payload.upgradeUrl } : {}),
91
+ ...(companyUid ? { companyUid } : {}),
58
92
  });
59
93
  }
60
94
  /**
61
95
  * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
62
96
  * or non-402 responses retain their existing command-specific handling.
63
97
  */
64
- export async function planGateErrorFromResponse(response) {
98
+ export async function planGateErrorFromResponse(response, companyUid) {
65
99
  // Leave every non-402 response untouched for its command-specific handler.
66
100
  // A non-plan 402 is buffered and re-wrapped below so its useful error
67
101
  // payload remains available to the existing command-specific handler.
@@ -82,7 +116,7 @@ export async function planGateErrorFromResponse(response) {
82
116
  // Preserve the original non-JSON 402 body below for its existing handler.
83
117
  }
84
118
  return {
85
- error: planGateErrorFromPayload(response.status, body),
119
+ error: planGateErrorFromPayload(response.status, body, companyUid),
86
120
  response: new Response(buffer, {
87
121
  status: response.status,
88
122
  statusText: response.statusText,
@@ -23,7 +23,7 @@
23
23
  * - `version-check.ts` maintains the cached npm `latest` that the startup
24
24
  * path reads, so the common case costs a small file read, not a fetch.
25
25
  *
26
- * Best-effort by design: registry unreachable, npm/pnpm missing, install
26
+ * Best-effort by design: registry unreachable, npm/pnpm/Bun missing, install
27
27
  * failure, or `hq` not on PATH for the re-exec all degrade to running the
28
28
  * command on the current version. Self-updating must never make a command less
29
29
  * available than it was before.
@@ -56,9 +56,9 @@ export interface SelfUpdateOutcome {
56
56
  }
57
57
  /**
58
58
  * The manager-aware install argv for this layout — same routing as the hard
59
- * gate: a pnpm-managed install must be updated by pnpm (npm would drop a copy
60
- * the pnpm shim never reads), and an npm install goes through the resolved
61
- * prefix so the copy that is actually running is the one replaced.
59
+ * gate: pnpm- and Bun-managed installs must be updated by their owning manager
60
+ * (npm would drop a copy their shims never read), and an npm install goes
61
+ * through the resolved prefix so the copy that is actually running is replaced.
62
62
  */
63
63
  export declare function buildSelfUpdatePlan(install: RunningInstall): {
64
64
  cmd: string;
@@ -23,7 +23,7 @@
23
23
  * - `version-check.ts` maintains the cached npm `latest` that the startup
24
24
  * path reads, so the common case costs a small file read, not a fetch.
25
25
  *
26
- * Best-effort by design: registry unreachable, npm/pnpm missing, install
26
+ * Best-effort by design: registry unreachable, npm/pnpm/Bun missing, install
27
27
  * failure, or `hq` not on PATH for the re-exec all degrade to running the
28
28
  * command on the current version. Self-updating must never make a command less
29
29
  * available than it was before.
@@ -38,7 +38,7 @@ import * as path from "node:path";
38
38
  import semver from "semver";
39
39
  import chalk from "chalk";
40
40
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
41
- import { buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
41
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
42
42
  /**
43
43
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
44
44
  * One update + one re-exec per user invocation, ever.
@@ -68,13 +68,15 @@ async function fetchLatestVersion() {
68
68
  }
69
69
  /**
70
70
  * The manager-aware install argv for this layout — same routing as the hard
71
- * gate: a pnpm-managed install must be updated by pnpm (npm would drop a copy
72
- * the pnpm shim never reads), and an npm install goes through the resolved
73
- * prefix so the copy that is actually running is the one replaced.
71
+ * gate: pnpm- and Bun-managed installs must be updated by their owning manager
72
+ * (npm would drop a copy their shims never read), and an npm install goes
73
+ * through the resolved prefix so the copy that is actually running is replaced.
74
74
  */
75
75
  export function buildSelfUpdatePlan(install) {
76
76
  if (install.manager === "pnpm")
77
77
  return { cmd: "pnpm", args: buildPnpmInstallArgv() };
78
+ if (install.manager === "bun")
79
+ return { cmd: "bun", args: buildBunInstallArgv() };
78
80
  if (install.prefix)
79
81
  return { cmd: "npm", args: buildPrefixedInstallArgv(install.prefix) };
80
82
  return { cmd: "npm", args: ["install", "-g", `${CLI_NAME}@latest`] };
@@ -0,0 +1,17 @@
1
+ export interface TeamUpgradeOptions {
2
+ company?: string;
3
+ companyUid?: string;
4
+ noBrowser?: boolean;
5
+ /** Testable override for environments where browser availability is known. */
6
+ headless?: boolean;
7
+ json?: boolean;
8
+ openUrl?: (url: string) => Promise<unknown>;
9
+ write?: (text: string) => void;
10
+ error?: (text: string) => void;
11
+ }
12
+ /**
13
+ * Start the owner-gated HQ Team Checkout flow. Stripe collects payment details
14
+ * in its hosted browser page; the CLI never receives card data.
15
+ */
16
+ export declare function upgradeToTeam(opts?: TeamUpgradeOptions): Promise<string>;
17
+ //# sourceMappingURL=team-upgrade.d.ts.map
@@ -0,0 +1,63 @@
1
+ import open from "open";
2
+ import { ensureCognitoToken } from "./cognito-session.js";
3
+ import { getCompanyUid, vaultApiFetch } from "./vault-api.js";
4
+ const CHECKOUT_SUCCESS_URL = "https://hq.computer/billing?checkout=success";
5
+ const CHECKOUT_CANCEL_URL = "https://hq.computer/billing?checkout=cancel";
6
+ function isHeadless() {
7
+ if (process.env.CI)
8
+ return true;
9
+ if (process.platform !== "linux")
10
+ return false;
11
+ return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
12
+ }
13
+ function ownerUpgradeError() {
14
+ return Object.assign(new Error("Only a company owner can upgrade. Ask a company owner to upgrade."), { expected: true });
15
+ }
16
+ /**
17
+ * Start the owner-gated HQ Team Checkout flow. Stripe collects payment details
18
+ * in its hosted browser page; the CLI never receives card data.
19
+ */
20
+ export async function upgradeToTeam(opts = {}) {
21
+ const token = await ensureCognitoToken();
22
+ const companyUid = opts.companyUid ?? await getCompanyUid(token, opts.company);
23
+ const response = await vaultApiFetch({
24
+ token,
25
+ path: "/v1/billing/checkout/team",
26
+ method: "POST",
27
+ body: {
28
+ companyUid,
29
+ successUrl: CHECKOUT_SUCCESS_URL,
30
+ cancelUrl: CHECKOUT_CANCEL_URL,
31
+ },
32
+ });
33
+ if (!response.ok) {
34
+ const body = (await response.json().catch(() => ({})));
35
+ if (response.status === 403 || /owner/i.test(`${body.error ?? ""} ${body.message ?? ""}`)) {
36
+ throw ownerUpgradeError();
37
+ }
38
+ throw Object.assign(new Error(`Could not start HQ Team checkout: ${body.error ?? body.message ?? response.statusText}`), { expected: true });
39
+ }
40
+ const { url } = (await response.json());
41
+ if (typeof url !== "string" || !url) {
42
+ throw new Error("Checkout response did not include a hosted checkout URL.");
43
+ }
44
+ const write = opts.write ?? ((text) => process.stdout.write(text));
45
+ if (opts.json) {
46
+ write(`${JSON.stringify({ url })}\n`);
47
+ return url;
48
+ }
49
+ write("Opening HQ Team checkout ($500/mo) in your browser — finish there and you're upgraded.\n");
50
+ const skipBrowser = opts.noBrowser || (opts.headless ?? isHeadless());
51
+ if (skipBrowser) {
52
+ write(`Open this URL to continue: ${url}\n`);
53
+ return url;
54
+ }
55
+ try {
56
+ await (opts.openUrl ?? open)(url);
57
+ }
58
+ catch (err) {
59
+ (opts.error ?? ((text) => process.stderr.write(text)))(`Couldn't launch your browser (${err instanceof Error ? err.message : String(err)}). Open this URL to continue: ${url}\n`);
60
+ }
61
+ return url;
62
+ }
63
+ //# sourceMappingURL=team-upgrade.js.map
@@ -187,7 +187,13 @@ export async function vaultApiFetch(opts) {
187
187
  // A plan gate is a normal, user-actionable denial. Decode it at the one
188
188
  // shared HTTP seam so every resource-creation command reaches main.ts's
189
189
  // friendly renderer without duplicating response parsing or retrying.
190
- const planGate = await planGateErrorFromResponse(response);
190
+ // The failed request usually already carries the target company in its
191
+ // body or path. Preserve it on the typed gate so an interactive checkout
192
+ // upgrades the same company without asking a multi-company caller again.
193
+ const companyUid = typeof opts.body?.companyUid === "string"
194
+ ? opts.body.companyUid
195
+ : /\/(cmp_[^/?]+)/.exec(opts.path)?.[1];
196
+ const planGate = await planGateErrorFromResponse(response, companyUid);
191
197
  if (planGate.error)
192
198
  throw planGate.error;
193
199
  return planGate.response;
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
31
31
  /** Which package manager owns the running global install. */
32
- export type InstallManager = "npm" | "pnpm";
32
+ export type InstallManager = "npm" | "pnpm" | "bun";
33
33
  export interface VersionCheckResponse {
34
34
  clientId: string;
35
35
  currentVersion: string;
@@ -67,13 +67,15 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
67
67
  * Those layouts fall through to the ordinary npm-prefix/manual handling.
68
68
  */
69
69
  export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
70
+ /** Whether the running package lives inside Bun's global install tree. */
71
+ export declare function isBunManagedPackageDir(pkgDir: string): boolean;
70
72
  /**
71
73
  * Where the running CLI is installed and who owns it. Resolved in ONE pass so
72
74
  * the package-root walk (which reads and parses a `package.json` per directory
73
75
  * level) happens once per invocation, and so the manager and the prefix can
74
76
  * never disagree because the filesystem shifted between two separate walks.
75
77
  *
76
- * `prefix` is `null` for a pnpm-managed install even though a prefix-shaped
78
+ * `prefix` is `null` for pnpm- and Bun-managed installs even though a prefix-shaped
77
79
  * string *can* be derived from those paths: `npmPrefixFromPackageDir` would
78
80
  * happily hand back the pnpm store directory, and `npm install -g --prefix
79
81
  * <store>` then unpacks a fresh copy into `<store>/lib/node_modules`, which
@@ -101,6 +103,8 @@ export declare function buildPrefixedInstallArgv(prefix: string): string[];
101
103
  * the new version — which is the whole point of routing here instead of npm.
102
104
  */
103
105
  export declare function buildPnpmInstallArgv(): string[];
106
+ /** Argv for updating a Bun-managed global install. */
107
+ export declare function buildBunInstallArgv(): string[];
104
108
  /**
105
109
  * Filesystem surface used by {@link cleanStalePartialInstall}. Injected so the
106
110
  * cleanup logic is unit-testable without touching a real global prefix.
@@ -209,11 +213,13 @@ export declare const __test__: {
209
213
  CLIENT_ID: string;
210
214
  ENDPOINT_PATH: string;
211
215
  FETCH_TIMEOUT_MS: number;
216
+ buildBunInstallArgv: typeof buildBunInstallArgv;
212
217
  buildPnpmInstallArgv: typeof buildPnpmInstallArgv;
213
218
  buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
214
219
  buildSpawnPlan: typeof buildSpawnPlan;
215
220
  cleanStalePartialInstall: typeof cleanStalePartialInstall;
216
221
  enforceUpdateRequired: typeof enforceUpdateRequired;
222
+ isBunManagedPackageDir: typeof isBunManagedPackageDir;
217
223
  isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
218
224
  npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
219
225
  nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
@@ -125,6 +125,19 @@ export function isPnpmManagedPackageDir(pkgDir) {
125
125
  }
126
126
  return false;
127
127
  }
128
+ /** Whether the running package lives inside Bun's global install tree. */
129
+ export function isBunManagedPackageDir(pkgDir) {
130
+ const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
131
+ const segments = normalized.split("/").filter(Boolean);
132
+ for (let i = 0; i < segments.length - 2; i += 1) {
133
+ if (segments[i] === "install" &&
134
+ segments[i + 1] === "global" &&
135
+ segments[i + 2] === "node_modules") {
136
+ return true;
137
+ }
138
+ }
139
+ return false;
140
+ }
128
141
  export function resolveRunningInstall() {
129
142
  try {
130
143
  const packageRoot = findRunningPackageRoot();
@@ -133,6 +146,9 @@ export function resolveRunningInstall() {
133
146
  if (isPnpmManagedPackageDir(packageRoot)) {
134
147
  return { manager: "pnpm", prefix: null, packageRoot };
135
148
  }
149
+ if (isBunManagedPackageDir(packageRoot)) {
150
+ return { manager: "bun", prefix: null, packageRoot };
151
+ }
136
152
  return {
137
153
  manager: "npm",
138
154
  prefix: npmPrefixFromPackageDir(packageRoot),
@@ -162,6 +178,10 @@ export function buildPrefixedInstallArgv(prefix) {
162
178
  export function buildPnpmInstallArgv() {
163
179
  return ["add", "-g", LATEST_PACKAGE_SPEC];
164
180
  }
181
+ /** Argv for updating a Bun-managed global install. */
182
+ export function buildBunInstallArgv() {
183
+ return ["add", "-g", LATEST_PACKAGE_SPEC];
184
+ }
165
185
  const nodeStaleInstallFs = {
166
186
  readdirSync: (dir) => readdirSync(dir),
167
187
  existsSync,
@@ -338,6 +358,8 @@ function performUpdate(command, runner = runUpdateCommand) {
338
358
  function manualUpdateCommand(install, decision) {
339
359
  if (install.manager === "pnpm")
340
360
  return `pnpm ${buildPnpmInstallArgv().join(" ")}`;
361
+ if (install.manager === "bun")
362
+ return `bun ${buildBunInstallArgv().join(" ")}`;
341
363
  if (install.prefix)
342
364
  return `npm ${buildPrefixedInstallArgv(install.prefix).join(" ")}`;
343
365
  return decision.updateCommand;
@@ -360,7 +382,9 @@ function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
360
382
  console.error(msg);
361
383
  const command = install.manager === "pnpm"
362
384
  ? `pnpm ${buildPnpmInstallArgv().join(" ")}`
363
- : decision.updateCommand;
385
+ : install.manager === "bun"
386
+ ? `bun ${buildBunInstallArgv().join(" ")}`
387
+ : decision.updateCommand;
364
388
  if (command) {
365
389
  console.error(chalk.dim(` Update: ${command}`));
366
390
  }
@@ -382,13 +406,14 @@ function enforceUpdateRequired(decision, deps = {}) {
382
406
  console.error(chalk.dim(` ${decision.message}`));
383
407
  const command = decision.updateCommand;
384
408
  // ONE package-root walk decides both the manager and the prefix. A
385
- // pnpm-managed install must be updated by pnpm; the server's `updateCommand`
386
- // and any npm prefix are both wrong for that layout, so `resolveRunningInstall`
409
+ // A pnpm- or Bun-managed install must be updated by its owning manager; the
410
+ // server's `updateCommand` and any npm prefix are wrong for those layouts, so
411
+ // `resolveRunningInstall`
387
412
  // already reports `prefix: null` there and neither is consulted below.
388
413
  const install = (deps.resolveInstall ?? resolveRunningInstall)();
389
- const isPnpm = install.manager === "pnpm";
414
+ const isManagedOutsideNpm = install.manager !== "npm";
390
415
  const prefix = install.prefix;
391
- if (!isPnpm && !command && !prefix) {
416
+ if (!isManagedOutsideNpm && !command && !prefix) {
392
417
  console.error(chalk.red(" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps."));
393
418
  if (decision.downloadUrl) {
394
419
  console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
@@ -400,10 +425,14 @@ function enforceUpdateRequired(decision, deps = {}) {
400
425
  // the EXACT same command under elevation.
401
426
  let primaryCmd;
402
427
  let primaryArgs;
403
- if (isPnpm) {
428
+ if (install.manager === "pnpm") {
404
429
  primaryCmd = "pnpm";
405
430
  primaryArgs = buildPnpmInstallArgv();
406
431
  }
432
+ else if (install.manager === "bun") {
433
+ primaryCmd = "bun";
434
+ primaryArgs = buildBunInstallArgv();
435
+ }
407
436
  else if (prefix) {
408
437
  primaryCmd = "npm";
409
438
  primaryArgs = buildPrefixedInstallArgv(prefix);
@@ -414,10 +443,10 @@ function enforceUpdateRequired(decision, deps = {}) {
414
443
  primaryArgs = parts.slice(1);
415
444
  }
416
445
  let result;
417
- if (isPnpm) {
418
- console.error(chalk.dim(" Detected a pnpm-managed global install; updating with pnpm"));
419
- console.error(chalk.dim(` Running: pnpm ${primaryArgs.join(" ")}`));
420
- result = performUpdateCommand("pnpm", primaryArgs, runner);
446
+ if (isManagedOutsideNpm) {
447
+ console.error(chalk.dim(` Detected a ${install.manager}-managed global install; updating with ${install.manager}`));
448
+ console.error(chalk.dim(` Running: ${primaryCmd} ${primaryArgs.join(" ")}`));
449
+ result = performUpdateCommand(primaryCmd, primaryArgs, runner);
421
450
  }
422
451
  else if (prefix) {
423
452
  console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
@@ -455,10 +484,10 @@ function enforceUpdateRequired(decision, deps = {}) {
455
484
  // through to the manual path unchanged, while headless boxes with passwordless
456
485
  // sudo self-update cleanly.
457
486
  //
458
- // Never for pnpm: `sudo -n pnpm add -g` installs into ROOT's PNPM_HOME, which
459
- // leaves the user's shim untouched while reporting success the same silent
460
- // no-op this fix exists to remove. A failed pnpm update must surface instead.
461
- if (!result.ok && primaryCmd && !isPnpm) {
487
+ // Never for pnpm or Bun: elevation changes the package manager's global home,
488
+ // leaving the user's shim untouched while reporting success. A failed
489
+ // non-npm update must surface instead.
490
+ if (!result.ok && primaryCmd && !isManagedOutsideNpm) {
462
491
  console.error(chalk.dim(` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`));
463
492
  const sudoResult = performUpdateCommand("sudo", ["-n", primaryCmd, ...primaryArgs], runner);
464
493
  if (sudoResult.ok)
@@ -476,9 +505,9 @@ function enforceUpdateRequired(decision, deps = {}) {
476
505
  const manual = manualUpdateCommand(install, decision) ?? command;
477
506
  console.error(chalk.dim(` Try manually: ${manual}`));
478
507
  // Only informational, and only when we could not run the right manager at
479
- // all: hq-pro's command is npm-shaped, so following it on a pnpm layout is
480
- // what produced the stale-shim loop in the first place.
481
- if (isPnpm && result.code === "ENOENT" && command) {
508
+ // all: hq-pro's command is npm-shaped, so following it on a pnpm or Bun
509
+ // layout would update a different copy and leave the running shim stale.
510
+ if (isManagedOutsideNpm && result.code === "ENOENT" && command) {
482
511
  console.error(chalk.dim(` (hq-pro suggests \`${command}\` — that is for npm-managed installs; use it only if you have switched this install to npm.)`));
483
512
  }
484
513
  process.exit(75);
@@ -524,11 +553,13 @@ export const __test__ = {
524
553
  CLIENT_ID,
525
554
  ENDPOINT_PATH,
526
555
  FETCH_TIMEOUT_MS,
556
+ buildBunInstallArgv,
527
557
  buildPnpmInstallArgv,
528
558
  buildPrefixedInstallArgv,
529
559
  buildSpawnPlan,
530
560
  cleanStalePartialInstall,
531
561
  enforceUpdateRequired,
562
+ isBunManagedPackageDir,
532
563
  isPnpmManagedPackageDir,
533
564
  npmPrefixFromPackageDir,
534
565
  nudgeUpdateRecommended,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.101.1",
3
+ "version": "5.101.3",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {