@indigoai-us/hq-cli 5.101.2 → 5.101.4

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
@@ -1,9 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [5.101.4] — 2026-08-16
4
+
5
+ - No user-facing changes recorded.
6
+
7
+ ## [5.101.3] — 2026-08-15
4
8
 
5
9
  ## [5.101.2] — 2026-08-15
6
10
 
11
+ ### Added
12
+
13
+ - `hq billing upgrade` opens the Stripe-hosted HQ Team checkout for the active
14
+ company. When an HQ API call returns `PLAN_LIMIT_EXCEEDED` (402), interactive
15
+ CLI flows now offer the same upgrade path; noninteractive clients are not
16
+ prompted. (#383, #376)
17
+
7
18
  ### Fixed
8
19
 
9
20
  - Self-update now detects Bun-managed global installs and runs `bun add -g`
@@ -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">;
package/dist/main.js CHANGED
@@ -83,19 +83,47 @@ import { isPackageRootResolutionError, packageRootCaptureContext, } from "./util
83
83
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
84
84
  /** Hard upper bound for non-user-visible release-health finalization. */
85
85
  const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
86
- // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
87
- // the pipe early. This covers the ASYNC path — an 'error' event emitted on the
88
- // stream. The SYNCHRONOUS path (a `write EPIPE` thrown straight out of
89
- // console.log inside a command) is handled in the top-level catch below; both
90
- // share `isEpipe` (HQ-6B).
91
- 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) {
92
107
  if (isEpipe(err)) {
93
- 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;
94
122
  }
95
123
  throw err;
96
- };
97
- process.stdout.on("error", onPipeError);
98
- process.stderr.on("error", onPipeError);
124
+ }
125
+ process.stdout.on("error", (err) => handleStreamError(err));
126
+ process.stderr.on("error", (err) => handleStreamError(err));
99
127
  initSentry();
100
128
  const program = new Command();
101
129
  program
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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.101.2",
3
+ "version": "5.101.4",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {