@indigoai-us/hq-cli 5.48.0 → 5.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,23 @@ jobs:
24
24
  pnpm-version: "10"
25
25
  node-version: ${{ matrix.node-version }}
26
26
  run-build: true
27
- run-lint: false
27
+ run-lint: true
28
28
  run-coverage: true
29
29
  secrets: inherit
30
+
31
+ e2e:
32
+ name: e2e (node 22)
33
+ runs-on: ubuntu-latest
34
+ timeout-minutes: 10
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: pnpm/action-setup@v4
38
+ with:
39
+ version: "10"
40
+ - uses: actions/setup-node@v4
41
+ with:
42
+ node-version: "22"
43
+ cache: pnpm
44
+ - run: pnpm install --frozen-lockfile
45
+ - run: pnpm build
46
+ - run: pnpm test:e2e
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f1986062-db39-5448-a8fa-3bb6be32c58b")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ae9ed929-9821-5f8b-bbbe-8f7b6105f5ad")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
5
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
@@ -246,8 +246,15 @@ export function registerMeetingsCommand(program) {
246
246
  }
247
247
  const where = companyId === "unknown" ? "unattributed (personal)" : companyId;
248
248
  console.log(chalk.green(`\n✓ Meeting ${chalk.cyan(meetingId)} attributed to ${chalk.bold(where)}.`));
249
- if (data && data.appliedToSeries)
249
+ if (typeof data.occurrencesUpdated === "number" && data.occurrencesUpdated > 1) {
250
+ console.log(chalk.dim(` Applied to ${data.occurrencesUpdated} meetings in this recurring series (including past occurrences).`));
251
+ }
252
+ else if (data.appliedToSeries) {
250
253
  console.log(chalk.dim(" Future occurrences of this recurring series will inherit this attribution."));
254
+ }
255
+ if (typeof data.refiledCount === "number" && data.refiledCount > 0) {
256
+ console.log(chalk.dim(` Refiled ${data.refiledCount} transcript${data.refiledCount === 1 ? "" : "s"} into the new company vault.`));
257
+ }
251
258
  console.log();
252
259
  }
253
260
  catch (err) {
@@ -419,4 +426,4 @@ export function registerMeetingsCommand(program) {
419
426
  });
420
427
  }
421
428
  //# sourceMappingURL=meetings.js.map
422
- //# debugId=f1986062-db39-5448-a8fa-3bb6be32c58b
429
+ //# debugId=ae9ed929-9821-5f8b-bbbe-8f7b6105f5ad
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * HQ CLI - Module management, package management, and cloud sync for HQ
4
4
  */
5
5
 
6
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="aeb143c7-7e19-5999-b6cb-790fc79034cc")}catch(e){}}();
6
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="d57c1cc3-6dd3-5c7f-8eef-c7692d41b836")}catch(e){}}();
7
7
  import { Command } from "commander";
8
8
  import { initSentry, Sentry } from "./sentry.js";
9
9
  import { registerAddCommand } from "./commands/add.js";
@@ -45,6 +45,7 @@ import { registerSignalsCommand } from "./commands/signals.js";
45
45
  import { registerReindexCommand } from "./commands/reindex.js";
46
46
  import { registerRescueCommand } from "./commands/rescue.js";
47
47
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
48
+ import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
48
49
  import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
49
50
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
50
51
  import { CLI_VERSION } from "./cli-version.js";
@@ -176,12 +177,25 @@ registerRescueCommand(program);
176
177
  await program.parseAsync();
177
178
  }
178
179
  catch (err) {
179
- Sentry.captureException(err);
180
+ // A full disk / exhausted quota / read-only filesystem is the user's
181
+ // machine, not an HQ code defect. Surface a clear, actionable message and
182
+ // skip Sentry capture so one full disk doesn't flood the tracker with
183
+ // identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
184
+ // to Sentry and still exit 1.
185
+ const envMsg = environmentalFsErrorMessage(err);
186
+ if (envMsg) {
187
+ process.stderr.write(`hq: ${envMsg}\n`);
188
+ }
189
+ else {
190
+ Sentry.captureException(err);
191
+ }
180
192
  process.exitCode = 1;
181
193
  }
182
194
  finally {
195
+ // Release health: finalize the per-run session before the flush.
196
+ Sentry.endSession();
183
197
  await Promise.allSettled([refreshVersionCache(), Sentry.flush(2000)]);
184
198
  }
185
199
  })();
186
200
  //# sourceMappingURL=index.js.map
187
- //# debugId=aeb143c7-7e19-5999-b6cb-790fc79034cc
201
+ //# debugId=d57c1cc3-6dd3-5c7f-8eef-c7692d41b836
package/dist/sentry.js CHANGED
@@ -1,10 +1,11 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="80ca2b2d-c649-5e88-9bc6-69a20fa4aa51")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a260ea42-f6d8-52d0-90c6-6e586de5efd6")}catch(e){}}();
3
3
  import * as Sentry from "@sentry/node";
4
4
  import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
5
5
  import { beforeSend } from "./sentry-before-send.js";
6
6
  import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
7
7
  import { CLI_VERSION } from "./cli-version.js";
8
+ import { getCachedSentryUser } from "./utils/sentry-identity.js";
8
9
  export function initSentry() {
9
10
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
10
11
  if (!dsn)
@@ -22,7 +23,16 @@ export function initSentry() {
22
23
  beforeSend,
23
24
  beforeBreadcrumb,
24
25
  });
26
+ // Attribute events to the logged-in HQ identity (best-effort; null when not
27
+ // logged in). A CLI process is one user, so global setUser is correct here.
28
+ const user = getCachedSentryUser();
29
+ if (user)
30
+ Sentry.setUser(user);
31
+ // Release health: one session per CLI run. Ended + flushed in index.ts's
32
+ // finally (covers every exit path). A captured error auto-marks the session
33
+ // "errored", so crash-free numbers per release stay accurate.
34
+ Sentry.startSession();
25
35
  }
26
36
  export { Sentry };
27
37
  //# sourceMappingURL=sentry.js.map
28
- //# debugId=80ca2b2d-c649-5e88-9bc6-69a20fa4aa51
38
+ //# debugId=a260ea42-f6d8-52d0-90c6-6e586de5efd6
@@ -0,0 +1,10 @@
1
+ /**
2
+ * If `err` is an environmental disk/quota/read-only filesystem error, return a
3
+ * short, user-facing message explaining it; otherwise return `null`.
4
+ *
5
+ * A non-null result means the caller should print the message and SKIP Sentry
6
+ * capture — the condition is the user's machine, not a bug HQ can fix. A null
7
+ * result means "this is a normal error; handle it as usual (capture to Sentry)".
8
+ */
9
+ export declare function environmentalFsErrorMessage(err: unknown): string | null;
10
+ //# sourceMappingURL=environmental-error.d.ts.map
@@ -0,0 +1,40 @@
1
+ // src/utils/environmental-error.ts
2
+ //
3
+ // Classify errors that stem from the user's machine/filesystem state rather
4
+ // than an HQ code defect. These are unactionable from our side: a full disk, an
5
+ // exhausted quota, or a read-only filesystem. The CLI surfaces a clear,
6
+ // actionable message and does NOT report them to Sentry — otherwise a single
7
+ // full disk floods the issue tracker with identical, unfixable crash reports.
8
+ //
9
+ // HQ-CLI-2: `hq reindex` hit ENOSPC in the operation-lock temp-file write
10
+ // (`fs.openSync` → "ENOSPC: no space left on device, open") and the raw error
11
+ // propagated uncaught to the CLI's top-level handler, which captured it to
12
+ // Sentry and exited silently — 5 stack-trace crashes in 6 seconds, with no
13
+ // message telling the user their disk was full.
14
+ /**
15
+ * Node errno codes for "the filesystem cannot accept this write" — purely
16
+ * environmental, never a code bug. Mapped to the message shown to the user.
17
+ */
18
+
19
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="03976a9c-d4c3-57dc-bd1d-40c9d025193d")}catch(e){}}();
20
+ const ENVIRONMENTAL_FS_CODES = {
21
+ ENOSPC: "No space left on device. Free up disk space and try again.",
22
+ EDQUOT: "Disk quota exceeded. Free up space (or raise your quota) and try again.",
23
+ EROFS: "The filesystem is read-only, so HQ can't write here. Check the mount/permissions and try again.",
24
+ };
25
+ /**
26
+ * If `err` is an environmental disk/quota/read-only filesystem error, return a
27
+ * short, user-facing message explaining it; otherwise return `null`.
28
+ *
29
+ * A non-null result means the caller should print the message and SKIP Sentry
30
+ * capture — the condition is the user's machine, not a bug HQ can fix. A null
31
+ * result means "this is a normal error; handle it as usual (capture to Sentry)".
32
+ */
33
+ export function environmentalFsErrorMessage(err) {
34
+ const code = err?.code;
35
+ if (typeof code !== "string")
36
+ return null;
37
+ return ENVIRONMENTAL_FS_CODES[code] ?? null;
38
+ }
39
+ //# sourceMappingURL=environmental-error.js.map
40
+ //# debugId=03976a9c-d4c3-57dc-bd1d-40c9d025193d
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Best-effort Sentry user from the cached HQ session, so CLI events are
3
+ * attributed to a user ("users affected" + per-user triage). Returns null when
4
+ * not logged in. A CLI process is single-user, so this is set once at init.
5
+ */
6
+ export declare function getCachedSentryUser(): {
7
+ id?: string;
8
+ email?: string;
9
+ username?: string;
10
+ } | null;
11
+ //# sourceMappingURL=sentry-identity.d.ts.map
@@ -0,0 +1,43 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1ca7cb4e-47e2-5cd0-a400-bbdaafa2b0d5")}catch(e){}}();
3
+ import { loadCachedTokens, isMachineIdentity, loadMachineCreds, } from "@indigoai-us/hq-cloud";
4
+ /** Decode the (untrusted) id-token payload — for attribution/display only. */
5
+ function peekIdToken(idToken) {
6
+ try {
7
+ const payload = idToken.split(".")[1];
8
+ if (!payload)
9
+ return {};
10
+ const pad = payload.length % 4 === 0 ? "" : "=".repeat(4 - (payload.length % 4));
11
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/") + pad;
12
+ const decoded = JSON.parse(Buffer.from(normalized, "base64").toString("utf-8"));
13
+ return { email: decoded.email, sub: decoded.sub };
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ /**
20
+ * Best-effort Sentry user from the cached HQ session, so CLI events are
21
+ * attributed to a user ("users affected" + per-user triage). Returns null when
22
+ * not logged in. A CLI process is single-user, so this is set once at init.
23
+ */
24
+ export function getCachedSentryUser() {
25
+ try {
26
+ if (isMachineIdentity()) {
27
+ const username = loadMachineCreds()?.username;
28
+ return username ? { id: username, username } : null;
29
+ }
30
+ const cached = loadCachedTokens();
31
+ if (!cached)
32
+ return null;
33
+ const { email, sub } = peekIdToken(cached.idToken);
34
+ if (!sub && !email)
35
+ return null;
36
+ return { id: sub, email };
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ //# sourceMappingURL=sentry-identity.js.map
43
+ //# debugId=1ca7cb4e-47e2-5cd0-a400-bbdaafa2b0d5
@@ -0,0 +1,93 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { version } from "../package.json";
7
+
8
+ const cliEntry = path.resolve("dist/index.js");
9
+ const tempDirs: string[] = [];
10
+
11
+ interface CliResult {
12
+ code: number | null;
13
+ stdout: string;
14
+ stderr: string;
15
+ }
16
+
17
+ function runHq(args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
18
+ return new Promise<CliResult>((resolve, reject) => {
19
+ const child = spawn(process.execPath, [cliEntry, ...args], {
20
+ cwd: options.cwd,
21
+ env: {
22
+ ...process.env,
23
+ HQ_NO_UPDATE_CHECK: "1",
24
+ ...options.env,
25
+ },
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ });
28
+
29
+ let stdout = "";
30
+ let stderr = "";
31
+ child.stdout.setEncoding("utf8");
32
+ child.stderr.setEncoding("utf8");
33
+ child.stdout.on("data", (chunk) => {
34
+ stdout += chunk;
35
+ });
36
+ child.stderr.on("data", (chunk) => {
37
+ stderr += chunk;
38
+ });
39
+ child.on("error", reject);
40
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
41
+ });
42
+ }
43
+
44
+ async function makeTempDir(prefix: string) {
45
+ const dir = await mkdtemp(path.join(tmpdir(), prefix));
46
+ tempDirs.push(dir);
47
+ return dir;
48
+ }
49
+
50
+ afterEach(async () => {
51
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
52
+ });
53
+
54
+ describe("built hq CLI", () => {
55
+ it("prints help from the built entrypoint", async () => {
56
+ const result = await runHq(["--help"]);
57
+
58
+ expect(result.code).toBe(0);
59
+ expect(result.stderr).toBe("");
60
+ expect(result.stdout).toContain("Usage: hq [options] [command]");
61
+ expect(result.stdout).toContain("HQ management CLI");
62
+ expect(result.stdout).toContain("whoami");
63
+ });
64
+
65
+ it("prints the package version from the built entrypoint", async () => {
66
+ const result = await runHq(["--version"]);
67
+
68
+ expect(result.code).toBe(0);
69
+ expect(result.stderr).toBe("");
70
+ expect(result.stdout.trim()).toBe(version);
71
+ });
72
+
73
+ it("reports logged-out state without network or credentials", async () => {
74
+ const home = await makeTempDir("hq-cli-e2e-home-");
75
+ const cwd = await makeTempDir("hq-cli-e2e-cwd-");
76
+
77
+ const result = await runHq(["whoami"], {
78
+ cwd,
79
+ env: {
80
+ HOME: home,
81
+ USERPROFILE: home,
82
+ XDG_CONFIG_HOME: path.join(home, ".config"),
83
+ XDG_CACHE_HOME: path.join(home, ".cache"),
84
+ HQ_MACHINE_IDENTITY: undefined,
85
+ HQ_MACHINE_CREDS: undefined,
86
+ },
87
+ });
88
+
89
+ expect(result.code).toBe(0);
90
+ expect(result.stderr).toBe("");
91
+ expect(result.stdout.trim()).toBe("Not logged in. Run 'hq login' to authenticate.");
92
+ });
93
+ });
@@ -0,0 +1,37 @@
1
+ import js from "@eslint/js";
2
+ import tseslint from "typescript-eslint";
3
+
4
+ const sourceFiles = ["src/**/*.ts"];
5
+ const recommended = tseslint
6
+ .config(js.configs.recommended, ...tseslint.configs.recommended)
7
+ .map((config) => ({
8
+ ...config,
9
+ files: sourceFiles,
10
+ }));
11
+
12
+ export default [
13
+ {
14
+ ignores: ["dist/**", "generated/**", "node_modules/**"],
15
+ },
16
+ ...recommended,
17
+ {
18
+ files: sourceFiles,
19
+ rules: {
20
+ "no-undef": "off",
21
+ "no-useless-assignment": "warn",
22
+ "no-useless-escape": "warn",
23
+ "prefer-const": "warn",
24
+ "preserve-caught-error": "off",
25
+ "@typescript-eslint/no-explicit-any": "warn",
26
+ "@typescript-eslint/no-require-imports": "warn",
27
+ "@typescript-eslint/no-unused-vars": [
28
+ "warn",
29
+ {
30
+ argsIgnorePattern: "^_",
31
+ caughtErrorsIgnorePattern: "^_",
32
+ varsIgnorePattern: "^_",
33
+ },
34
+ ],
35
+ },
36
+ },
37
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.48.0",
3
+ "version": "5.50.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -10,13 +10,15 @@
10
10
  "scripts": {
11
11
  "build": "node scripts/generate-dsn.mjs && tsc",
12
12
  "typecheck": "tsc --noEmit",
13
+ "lint": "eslint .",
13
14
  "test": "vitest run",
15
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
14
16
  "coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
15
17
  "vitest": "vitest",
16
18
  "clean": "rm -rf dist"
17
19
  },
18
20
  "dependencies": {
19
- "@indigoai-us/hq-cloud": "^6.11.14",
21
+ "@indigoai-us/hq-cloud": "^6.12.0",
20
22
  "@indigoai-us/hq-onboarding": "^0.1.0",
21
23
  "@sentry/node": "^10.49.0",
22
24
  "chalk": "^5.3.0",
@@ -29,11 +31,14 @@
29
31
  },
30
32
  "devDependencies": {
31
33
  "@aws-sdk/client-s3": "^3.1049.0",
34
+ "@eslint/js": "^10.0.1",
32
35
  "@types/js-yaml": "^4.0.9",
33
36
  "@types/node": "^22.0.0",
34
37
  "@types/semver": "^7.5.8",
35
38
  "@vitest/coverage-v8": "4.1.6",
39
+ "eslint": "^10.5.0",
36
40
  "typescript": "^5.7.0",
41
+ "typescript-eslint": "^8.61.1",
37
42
  "vitest": "^4.1.2"
38
43
  },
39
44
  "repository": {
@@ -160,4 +160,68 @@ describe("meetings set-company", () => {
160
160
 
161
161
  expect(logSpy).toHaveBeenCalledWith(JSON.stringify(body, null, 2));
162
162
  });
163
+
164
+ it("prints recurring series and refiled transcript counts from the response", async () => {
165
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
166
+ jsonRes({
167
+ ok: true,
168
+ meetingId: "meeting-123456",
169
+ companyId: "cmp_acme",
170
+ seriesKey: "series-123",
171
+ appliedToSeries: true,
172
+ occurrencesUpdated: 3,
173
+ refiled: true,
174
+ refiledCount: 2,
175
+ }),
176
+ );
177
+
178
+ const program = buildProgram();
179
+ await program.parseAsync([
180
+ "node",
181
+ "hq",
182
+ "meetings",
183
+ "set-company",
184
+ "meeting-123456",
185
+ "--company",
186
+ "acme",
187
+ ]);
188
+
189
+ expect(logSpy).toHaveBeenCalledWith(
190
+ expect.stringContaining("Applied to 3 meetings in this recurring series"),
191
+ );
192
+ expect(logSpy).toHaveBeenCalledWith(
193
+ expect.stringContaining("Refiled 2 transcripts into the new company vault."),
194
+ );
195
+ });
196
+
197
+ it("does not print multi-meeting or refile copy for a single occurrence without refiles", async () => {
198
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
199
+ jsonRes({
200
+ ok: true,
201
+ meetingId: "meeting-123456",
202
+ companyId: "cmp_acme",
203
+ seriesKey: null,
204
+ appliedToSeries: false,
205
+ occurrencesUpdated: 1,
206
+ refiled: false,
207
+ refiledCount: 0,
208
+ }),
209
+ );
210
+
211
+ const program = buildProgram();
212
+ await program.parseAsync([
213
+ "node",
214
+ "hq",
215
+ "meetings",
216
+ "set-company",
217
+ "meeting-123456",
218
+ "--company",
219
+ "acme",
220
+ ]);
221
+
222
+ const output = logSpy.mock.calls.map(([line]) => String(line)).join("\n");
223
+ expect(output).not.toContain("Applied to 1 meetings");
224
+ expect(output).not.toContain("Refiled 0 transcripts");
225
+ expect(output).not.toContain("Future occurrences of this recurring series will inherit this attribution.");
226
+ });
163
227
  });
@@ -341,6 +341,9 @@ export function registerMeetingsCommand(program: Command): void {
341
341
  if (!res.ok) await handleApiError(res);
342
342
  const data = (await res.json()) as {
343
343
  appliedToSeries?: boolean;
344
+ occurrencesUpdated?: number;
345
+ refiled?: boolean;
346
+ refiledCount?: number;
344
347
  [key: string]: unknown;
345
348
  };
346
349
 
@@ -351,7 +354,14 @@ export function registerMeetingsCommand(program: Command): void {
351
354
 
352
355
  const where = companyId === "unknown" ? "unattributed (personal)" : companyId;
353
356
  console.log(chalk.green(`\n✓ Meeting ${chalk.cyan(meetingId)} attributed to ${chalk.bold(where)}.`));
354
- if (data && data.appliedToSeries) console.log(chalk.dim(" Future occurrences of this recurring series will inherit this attribution."));
357
+ if (typeof data.occurrencesUpdated === "number" && data.occurrencesUpdated > 1) {
358
+ console.log(chalk.dim(` Applied to ${data.occurrencesUpdated} meetings in this recurring series (including past occurrences).`));
359
+ } else if (data.appliedToSeries) {
360
+ console.log(chalk.dim(" Future occurrences of this recurring series will inherit this attribution."));
361
+ }
362
+ if (typeof data.refiledCount === "number" && data.refiledCount > 0) {
363
+ console.log(chalk.dim(` Refiled ${data.refiledCount} transcript${data.refiledCount === 1 ? "" : "s"} into the new company vault.`));
364
+ }
355
365
  console.log();
356
366
  } catch (err) {
357
367
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ import { registerSignalsCommand } from "./commands/signals.js";
45
45
  import { registerReindexCommand } from "./commands/reindex.js";
46
46
  import { registerRescueCommand } from "./commands/rescue.js";
47
47
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
48
+ import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
48
49
  import {
49
50
  maybeWarnNewVersion,
50
51
  refreshVersionCache,
@@ -215,9 +216,21 @@ registerRescueCommand(program);
215
216
  }
216
217
  await program.parseAsync();
217
218
  } catch (err) {
218
- Sentry.captureException(err);
219
+ // A full disk / exhausted quota / read-only filesystem is the user's
220
+ // machine, not an HQ code defect. Surface a clear, actionable message and
221
+ // skip Sentry capture so one full disk doesn't flood the tracker with
222
+ // identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
223
+ // to Sentry and still exit 1.
224
+ const envMsg = environmentalFsErrorMessage(err);
225
+ if (envMsg) {
226
+ process.stderr.write(`hq: ${envMsg}\n`);
227
+ } else {
228
+ Sentry.captureException(err);
229
+ }
219
230
  process.exitCode = 1;
220
231
  } finally {
232
+ // Release health: finalize the per-run session before the flush.
233
+ Sentry.endSession();
221
234
  await Promise.allSettled([refreshVersionCache(), Sentry.flush(2000)]);
222
235
  }
223
236
  })();
package/src/sentry.ts CHANGED
@@ -3,6 +3,7 @@ import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
3
3
  import { beforeSend } from "./sentry-before-send.js";
4
4
  import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
5
5
  import { CLI_VERSION } from "./cli-version.js";
6
+ import { getCachedSentryUser } from "./utils/sentry-identity.js";
6
7
 
7
8
  export function initSentry(): void {
8
9
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
@@ -20,6 +21,14 @@ export function initSentry(): void {
20
21
  beforeSend,
21
22
  beforeBreadcrumb,
22
23
  });
24
+ // Attribute events to the logged-in HQ identity (best-effort; null when not
25
+ // logged in). A CLI process is one user, so global setUser is correct here.
26
+ const user = getCachedSentryUser();
27
+ if (user) Sentry.setUser(user);
28
+ // Release health: one session per CLI run. Ended + flushed in index.ts's
29
+ // finally (covers every exit path). A captured error auto-marks the session
30
+ // "errored", so crash-free numbers per release stay accurate.
31
+ Sentry.startSession();
23
32
  }
24
33
 
25
34
  export { Sentry };
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { environmentalFsErrorMessage } from "./environmental-error.js";
3
+
4
+ /** Build a Node-style errno error with a `.code`, as fs.*Sync throws. */
5
+ function errnoError(code: string, message: string): NodeJS.ErrnoException {
6
+ const err = new Error(message) as NodeJS.ErrnoException;
7
+ err.code = code;
8
+ return err;
9
+ }
10
+
11
+ describe("environmentalFsErrorMessage", () => {
12
+ // HQ-CLI-2: the exact error that crashed `hq reindex` and flooded Sentry.
13
+ it("classifies ENOSPC (no space left on device) as environmental", () => {
14
+ const err = errnoError("ENOSPC", "ENOSPC: no space left on device, open '/x'");
15
+ const msg = environmentalFsErrorMessage(err);
16
+ expect(msg).not.toBeNull();
17
+ expect(msg).toMatch(/no space left on device/i);
18
+ });
19
+
20
+ it("classifies EDQUOT (quota exceeded) as environmental", () => {
21
+ const msg = environmentalFsErrorMessage(errnoError("EDQUOT", "EDQUOT: disk quota exceeded"));
22
+ expect(msg).toMatch(/quota/i);
23
+ });
24
+
25
+ it("classifies EROFS (read-only filesystem) as environmental", () => {
26
+ const msg = environmentalFsErrorMessage(errnoError("EROFS", "EROFS: read-only file system"));
27
+ expect(msg).toMatch(/read-only/i);
28
+ });
29
+
30
+ // A genuine code bug must still reach Sentry — only the disk-class codes are
31
+ // diverted, so we never silently swallow real defects.
32
+ it("returns null for a code-bug error (ENOENT) so it is still captured", () => {
33
+ expect(environmentalFsErrorMessage(errnoError("ENOENT", "ENOENT: not found"))).toBeNull();
34
+ });
35
+
36
+ it("returns null for a plain Error with no code", () => {
37
+ expect(environmentalFsErrorMessage(new Error("boom"))).toBeNull();
38
+ });
39
+
40
+ it("returns null for non-error values (null/undefined/string)", () => {
41
+ expect(environmentalFsErrorMessage(null)).toBeNull();
42
+ expect(environmentalFsErrorMessage(undefined)).toBeNull();
43
+ expect(environmentalFsErrorMessage("ENOSPC")).toBeNull();
44
+ });
45
+ });
@@ -0,0 +1,39 @@
1
+ // src/utils/environmental-error.ts
2
+ //
3
+ // Classify errors that stem from the user's machine/filesystem state rather
4
+ // than an HQ code defect. These are unactionable from our side: a full disk, an
5
+ // exhausted quota, or a read-only filesystem. The CLI surfaces a clear,
6
+ // actionable message and does NOT report them to Sentry — otherwise a single
7
+ // full disk floods the issue tracker with identical, unfixable crash reports.
8
+ //
9
+ // HQ-CLI-2: `hq reindex` hit ENOSPC in the operation-lock temp-file write
10
+ // (`fs.openSync` → "ENOSPC: no space left on device, open") and the raw error
11
+ // propagated uncaught to the CLI's top-level handler, which captured it to
12
+ // Sentry and exited silently — 5 stack-trace crashes in 6 seconds, with no
13
+ // message telling the user their disk was full.
14
+
15
+ /**
16
+ * Node errno codes for "the filesystem cannot accept this write" — purely
17
+ * environmental, never a code bug. Mapped to the message shown to the user.
18
+ */
19
+ const ENVIRONMENTAL_FS_CODES: Record<string, string> = {
20
+ ENOSPC: "No space left on device. Free up disk space and try again.",
21
+ EDQUOT:
22
+ "Disk quota exceeded. Free up space (or raise your quota) and try again.",
23
+ EROFS:
24
+ "The filesystem is read-only, so HQ can't write here. Check the mount/permissions and try again.",
25
+ };
26
+
27
+ /**
28
+ * If `err` is an environmental disk/quota/read-only filesystem error, return a
29
+ * short, user-facing message explaining it; otherwise return `null`.
30
+ *
31
+ * A non-null result means the caller should print the message and SKIP Sentry
32
+ * capture — the condition is the user's machine, not a bug HQ can fix. A null
33
+ * result means "this is a normal error; handle it as usual (capture to Sentry)".
34
+ */
35
+ export function environmentalFsErrorMessage(err: unknown): string | null {
36
+ const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
37
+ if (typeof code !== "string") return null;
38
+ return ENVIRONMENTAL_FS_CODES[code] ?? null;
39
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ loadCachedTokens,
3
+ isMachineIdentity,
4
+ loadMachineCreds,
5
+ } from "@indigoai-us/hq-cloud";
6
+
7
+ /** Decode the (untrusted) id-token payload — for attribution/display only. */
8
+ function peekIdToken(idToken: string): { email?: string; sub?: string } {
9
+ try {
10
+ const payload = idToken.split(".")[1];
11
+ if (!payload) return {};
12
+ const pad =
13
+ payload.length % 4 === 0 ? "" : "=".repeat(4 - (payload.length % 4));
14
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/") + pad;
15
+ const decoded = JSON.parse(
16
+ Buffer.from(normalized, "base64").toString("utf-8"),
17
+ ) as { email?: string; sub?: string };
18
+ return { email: decoded.email, sub: decoded.sub };
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Best-effort Sentry user from the cached HQ session, so CLI events are
26
+ * attributed to a user ("users affected" + per-user triage). Returns null when
27
+ * not logged in. A CLI process is single-user, so this is set once at init.
28
+ */
29
+ export function getCachedSentryUser():
30
+ | { id?: string; email?: string; username?: string }
31
+ | null {
32
+ try {
33
+ if (isMachineIdentity()) {
34
+ const username = loadMachineCreds()?.username;
35
+ return username ? { id: username, username } : null;
36
+ }
37
+ const cached = loadCachedTokens();
38
+ if (!cached) return null;
39
+ const { email, sub } = peekIdToken(cached.idToken);
40
+ if (!sub && !email) return null;
41
+ return { id: sub, email };
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["e2e/**/*.test.ts"],
6
+ },
7
+ });