@indigoai-us/hq-cli 5.101.0 → 5.101.2

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,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.101.2] — 2026-08-15
6
+
7
+ ### Fixed
8
+
9
+ - Self-update now detects Bun-managed global installs and runs `bun add -g`
10
+ through the owning package manager. Existing pnpm-managed and npm prefix
11
+ installs, including Homebrew prefixes such as `/opt/homebrew`, continue to
12
+ update the copy that is actually running without an unnecessary `sudo`.
13
+
14
+ ## [5.101.1] — 2026-08-14
15
+
16
+ ### Fixed
17
+
18
+ - `hq core rebuild-index threads` no longer crashes with an `ENOENT` stat error
19
+ when a `workspace/threads/T-*.json` entry that the directory scan listed can no
20
+ longer be resolved — removed mid-scan by a concurrent writer (HQ Sync, another
21
+ session, or `archive-old-threads`) or left as a dangling symlink. The renderer
22
+ now stats each file exactly once before sorting (a Schwartzian transform rather
23
+ than statting inside the sort comparator), skips entries that vanished (logging
24
+ a single summary line by basename), and still fails loudly on a genuine stat
25
+ error such as `EACCES`. Both `INDEX.md` and `recent.md` are regenerated as
26
+ before. Sentry 7669694322 (HQ-CLI-P).
27
+
5
28
  ## [5.101.0] — 2026-08-13
6
29
 
7
30
  ### Added
@@ -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);
@@ -31,5 +31,24 @@ export declare function projectStatus(root: string, project: string, prdPath: st
31
31
  export declare function basename(file: string): string;
32
32
  export declare function isHidden(name: string): boolean;
33
33
  export declare function mtime(file: string): number;
34
+ /**
35
+ * Sort key for newest-first ordering: the file's modification time in
36
+ * nanoseconds. Stat the file exactly once, before sorting — never from inside a
37
+ * comparator, where a throw aborts `Array.sort` and an mtime that changes
38
+ * mid-sort makes the comparator inconsistent.
39
+ *
40
+ * Returns `undefined` when the entry a directory scan just listed can no longer
41
+ * be resolved: it was removed between the `readdirSync` snapshot and this stat
42
+ * (a time-of-check-to-time-of-use race in a hot, multi-writer directory), or it
43
+ * is a symlink whose target is missing (`statSync` follows symlinks). Callers
44
+ * drop such entries — mirroring how `immediateEntries` and `readJson` already
45
+ * tolerate a vanished file — so the render never crashes on a benign race.
46
+ *
47
+ * Any other stat failure (EACCES, EIO, …) is a genuine fault and is rethrown
48
+ * with the offending file's BASENAME attached, never its absolute path, which
49
+ * must not reach error reporting such as Sentry. The original errno `code` is
50
+ * preserved so upstream error classification is unaffected.
51
+ */
52
+ export declare function sortKeyMtimeNs(file: string): bigint | undefined;
34
53
  export declare function tempDirectory(prefix: string): string;
35
54
  //# sourceMappingURL=shared.d.ts.map
@@ -120,6 +120,38 @@ export function mtime(file) { try {
120
120
  catch {
121
121
  return 0;
122
122
  } }
123
+ /**
124
+ * Sort key for newest-first ordering: the file's modification time in
125
+ * nanoseconds. Stat the file exactly once, before sorting — never from inside a
126
+ * comparator, where a throw aborts `Array.sort` and an mtime that changes
127
+ * mid-sort makes the comparator inconsistent.
128
+ *
129
+ * Returns `undefined` when the entry a directory scan just listed can no longer
130
+ * be resolved: it was removed between the `readdirSync` snapshot and this stat
131
+ * (a time-of-check-to-time-of-use race in a hot, multi-writer directory), or it
132
+ * is a symlink whose target is missing (`statSync` follows symlinks). Callers
133
+ * drop such entries — mirroring how `immediateEntries` and `readJson` already
134
+ * tolerate a vanished file — so the render never crashes on a benign race.
135
+ *
136
+ * Any other stat failure (EACCES, EIO, …) is a genuine fault and is rethrown
137
+ * with the offending file's BASENAME attached, never its absolute path, which
138
+ * must not reach error reporting such as Sentry. The original errno `code` is
139
+ * preserved so upstream error classification is unaffected.
140
+ */
141
+ export function sortKeyMtimeNs(file) {
142
+ try {
143
+ return fs.statSync(file, { bigint: true }).mtimeNs;
144
+ }
145
+ catch (error) {
146
+ const code = error?.code;
147
+ if (code === "ENOENT" || code === "ENOTDIR")
148
+ return undefined;
149
+ const wrapped = new Error(`failed to stat ${basename(file)} (${code ?? "unknown error"})`);
150
+ if (code !== undefined)
151
+ wrapped.code = code;
152
+ throw wrapped;
153
+ }
154
+ }
123
155
  // Kept exported for primitive tests and consumers that need a temp base without
124
156
  // relying on an application-specific fixture location.
125
157
  export function tempDirectory(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); }
@@ -1,19 +1,40 @@
1
1
  import * as fs from "fs";
2
- import { at, log, readJson, sanitize, timestamp, write } from "./shared.js";
2
+ import { at, basename, log, readJson, sanitize, sortKeyMtimeNs, timestamp, write } from "./shared.js";
3
3
  export function renderThreads(context, args = []) {
4
4
  const mode = args[0] ?? "--index";
5
5
  const directory = at(context.root, "workspace/threads");
6
6
  fs.mkdirSync(directory, { recursive: true });
7
7
  // Match `ls -t`: newest filesystem modification time first. `updated_at` is
8
8
  // rendered metadata only, and must not influence the order.
9
+ //
10
+ // Stat every listed file exactly once, BEFORE sorting (a Schwartzian
11
+ // transform), never from inside the comparator. workspace/threads is a hot,
12
+ // multi-writer directory (HQ Sync reconciliation, concurrent agent sessions,
13
+ // and `archive-old-threads` renaming T-*.json out of it), so an entry
14
+ // readdirSync just snapshotted can vanish before it is stat'ed. Statting
15
+ // inside the comparator turned that time-of-check-to-time-of-use window — and
16
+ // any dangling symlink — into an ENOENT that aborted the whole command before
17
+ // either file was written. Precompute the key, drop entries that no longer
18
+ // resolve (as `readJson` already drops unreadable files), then compare only
19
+ // precomputed keys so the comparator can neither throw nor be inconsistent.
20
+ const skipped = [];
9
21
  const files = fs.readdirSync(directory)
10
22
  .filter((name) => /^T-.*\.json$/.test(name) && !name.endsWith(".changeset.json"))
11
23
  .map((name) => `${directory}/${name}`)
12
- .sort((a, b) => {
13
- const aTime = fs.statSync(a, { bigint: true }).mtimeNs;
14
- const bTime = fs.statSync(b, { bigint: true }).mtimeNs;
15
- return bTime > aTime ? 1 : bTime < aTime ? -1 : 0;
16
- });
24
+ .map((file) => ({ file, key: sortKeyMtimeNs(file) }))
25
+ .filter((entry) => {
26
+ if (entry.key === undefined) {
27
+ skipped.push(basename(entry.file));
28
+ return false;
29
+ }
30
+ return true;
31
+ })
32
+ .sort((a, b) => (b.key > a.key ? 1 : b.key < a.key ? -1 : 0))
33
+ .map((entry) => entry.file);
34
+ if (skipped.length > 0) {
35
+ const shown = skipped.slice(0, 10).join(", ");
36
+ log(context, `rebuild-threads-index: skipped ${skipped.length} thread file(s) that vanished during the scan: ${shown}${skipped.length > 10 ? ", …" : ""}`);
37
+ }
17
38
  const rows = files.flatMap((file) => {
18
39
  const data = readJson(file);
19
40
  if (!data)
package/dist/main.d.ts CHANGED
@@ -12,5 +12,5 @@ export type TopLevelErrorDependencies = {
12
12
  setExitCode: (code: number) => void;
13
13
  };
14
14
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
15
- export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): void;
15
+ export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): Promise<void>;
16
16
  //# 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";
@@ -292,7 +293,7 @@ export async function runCli() {
292
293
  await program.parseAsync();
293
294
  }
294
295
  catch (err) {
295
- handleTopLevelError(err);
296
+ await handleTopLevelError(err);
296
297
  }
297
298
  finally {
298
299
  // Plan-limit nag (US-016): stderr-only, never throws, never touches
@@ -319,7 +320,7 @@ const defaultTopLevelErrorDependencies = {
319
320
  },
320
321
  };
321
322
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
322
- export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
323
+ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
323
324
  // A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
324
325
  // (`hq … | head`, `source <(hq …)`, a parent that exited). That is normal
325
326
  // Unix behavior with no user-facing degradation — exit cleanly (0) and
@@ -366,6 +367,17 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
366
367
  // envelope; do not expose its raw body or retry a rejected creation.
367
368
  deps.stderr.write(`hq: ${formatPlanGateError(err)}\n`);
368
369
  deps.setExitCode(1);
370
+ if (canOfferTeamUpgrade()) {
371
+ try {
372
+ await offerTeamUpgrade(async () => {
373
+ await upgradeToTeam({ companyUid: err.details.companyUid });
374
+ });
375
+ }
376
+ catch (upgradeErr) {
377
+ const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
378
+ deps.stderr.write(`hq: ${message}\n`);
379
+ }
380
+ }
369
381
  }
370
382
  else if (isExpectedUserError(err)) {
371
383
  // HQ-CLI-6: a user-facing, client-caused error (a non-owner running
@@ -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.0",
3
+ "version": "5.101.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
31
31
  "@aws-sdk/client-s3": "^3.1049.0",
32
- "@indigoai-us/hq-cloud": "^6.14.50",
32
+ "@indigoai-us/hq-cloud": "~6.15.0",
33
33
  "@indigoai-us/hq-onboarding": "^0.1.0",
34
34
  "@sentry/node": "^10.49.0",
35
35
  "@tobilu/qmd": "2.5.3",