@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.
- package/.github/workflows/ci.yml +18 -1
- package/dist/commands/meetings.js +10 -3
- package/dist/index.js +17 -3
- package/dist/sentry.js +12 -2
- package/dist/utils/environmental-error.d.ts +10 -0
- package/dist/utils/environmental-error.js +40 -0
- package/dist/utils/sentry-identity.d.ts +11 -0
- package/dist/utils/sentry-identity.js +43 -0
- package/e2e/cli.test.ts +93 -0
- package/eslint.config.js +37 -0
- package/package.json +7 -2
- package/src/commands/meetings.test.ts +64 -0
- package/src/commands/meetings.ts +11 -1
- package/src/index.ts +14 -1
- package/src/sentry.ts +9 -0
- package/src/utils/environmental-error.test.ts +45 -0
- package/src/utils/environmental-error.ts +39 -0
- package/src/utils/sentry-identity.ts +45 -0
- package/vitest.e2e.config.ts +7 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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:
|
|
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]="
|
|
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.
|
|
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=
|
|
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]="
|
|
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
|
-
|
|
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=
|
|
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]="
|
|
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=
|
|
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
|
package/e2e/cli.test.ts
ADDED
|
@@ -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
|
+
});
|
package/eslint.config.js
ADDED
|
@@ -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.
|
|
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.
|
|
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
|
});
|
package/src/commands/meetings.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
+
}
|