@indigoai-us/hq-cli 5.47.17 → 5.49.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.
@@ -5,29 +5,42 @@ on:
5
5
  push:
6
6
  branches: [main]
7
7
 
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
8
12
  jobs:
9
13
  build:
10
- runs-on: ubuntu-latest
14
+ name: build (node ${{ matrix.node-version }})
11
15
  strategy:
12
16
  fail-fast: false
13
17
  matrix:
14
18
  # 20 is the declared engines floor (package.json engines.node >=20),
15
- # 22 is the current dev/publish baseline. Running the suite on 20
16
- # enforces the floor so it stays real, not aspirational.
17
- node-version: [20, 22]
18
- name: build (node ${{ matrix.node-version }})
19
+ # 22 is the current dev/publish baseline.
20
+ node-version: ["20", "22"]
21
+ uses: indigoai-us/.github/.github/workflows/_node-ci.yml@main
22
+ with:
23
+ package-manager: pnpm
24
+ pnpm-version: "10"
25
+ node-version: ${{ matrix.node-version }}
26
+ run-build: true
27
+ run-lint: true
28
+ run-coverage: true
29
+ secrets: inherit
30
+
31
+ e2e:
32
+ name: e2e (node 22)
33
+ runs-on: ubuntu-latest
34
+ timeout-minutes: 10
19
35
  steps:
20
36
  - uses: actions/checkout@v4
21
37
  - uses: pnpm/action-setup@v4
22
38
  with:
23
- version: 10
39
+ version: "10"
24
40
  - uses: actions/setup-node@v4
25
41
  with:
26
- node-version: ${{ matrix.node-version }}
42
+ node-version: "22"
27
43
  cache: pnpm
28
- - run: pnpm install --frozen-lockfile --config.minimumReleaseAge=1440
29
- # generate-dsn.mjs only fatals when GITHUB_JOB=publish, so CI builds with
30
- # an empty BUNDLED_DSN. That's intentional — the DSN is publish-only.
31
- - run: pnpm run build
32
- - run: pnpm run typecheck
33
- - run: pnpm test
44
+ - run: pnpm install --frozen-lockfile
45
+ - run: pnpm build
46
+ - run: pnpm test:e2e
@@ -2,110 +2,35 @@ name: Publish to npm
2
2
  on:
3
3
  push:
4
4
  tags: ["v*"]
5
+ # Manual dry-run: validate the build/pack/smoke pipeline without publishing.
6
+ workflow_dispatch:
7
+ inputs:
8
+ dry_run:
9
+ description: "Dry-run (validate build/pack/smoke; npm publish --dry-run, no Sentry upload)"
10
+ type: boolean
11
+ default: true
5
12
 
6
13
  jobs:
7
14
  publish:
8
- runs-on: ubuntu-latest
9
15
  permissions:
10
16
  contents: read
11
17
  id-token: write
12
- steps:
13
- - uses: actions/checkout@v4
14
- - uses: pnpm/action-setup@v4
15
- with:
16
- version: 10
17
- - uses: actions/setup-node@v4
18
- with:
19
- # Node 24 ships npm 11.x, required for npm's trusted-publisher OIDC
20
- # token-exchange flow. See hq-cloud's publish.yml for the full
21
- # rationale (npm 10.x produces masked-404 failures on publish PUT).
22
- node-version: 24
23
- registry-url: https://registry.npmjs.org
24
- cache: pnpm
25
-
26
- - run: node --version && npm --version && pnpm --version
27
- # Install with pnpm (supply-chain policy); npm publish keeps OIDC.
28
- - run: pnpm install --frozen-lockfile --config.minimumReleaseAge=1440
29
-
30
- - name: Build
31
- run: pnpm run build
32
- env:
33
- # generate-dsn.mjs fatals when GITHUB_JOB=publish and this var is
34
- # missing. Set this as a repo secret to bundle a Sentry DSN into the
35
- # published binary.
36
- HQ_CLI_PUBLISH_SENTRY_DSN: ${{ secrets.HQ_CLI_PUBLISH_SENTRY_DSN }}
37
-
38
- # Inject Sentry debug IDs into compiled JS + map files. Must run after
39
- # tsc and BEFORE publish so the installed binary carries the same debugId
40
- # as the artifacts uploaded to Sentry, enabling debugId-based source-map
41
- # resolution for globally-installed CLI users.
42
- - name: Inject Sentry source map debug IDs
43
- run: npx -y @sentry/cli@^2 sourcemaps inject dist/
44
-
45
- # Pre-publish cross-package smoke test (Bug A guard).
46
- #
47
- # @indigoai-us/hq-cli depends on @indigoai-us/hq-cloud via a caret range
48
- # (^5.1.0). The smoke installs a freshly-packed tarball into an isolated
49
- # tmpdir — outside this repo — so transitive @indigoai-us/* deps resolve
50
- # from the npm registry, NOT from a local checkout. Booting `hq --version`
51
- # then exercises the full module-load graph. If hq-cloud removed or
52
- # renamed a symbol hq-cli imports, this catches it BEFORE the publish PUT.
53
- #
54
- # Bypass: set repo-or-workflow variable WORKFLOW_ALLOW_BROKEN_PUBLISH=1.
55
- - name: Pre-publish smoke test
56
- if: ${{ vars.WORKFLOW_ALLOW_BROKEN_PUBLISH != '1' }}
57
- run: bash .github/workflows/scripts/smoke-test-pkg.sh hq
58
-
59
- - name: WARN — pre-publish smoke bypassed
60
- if: ${{ vars.WORKFLOW_ALLOW_BROKEN_PUBLISH == '1' }}
61
- run: |
62
- echo "::warning::WORKFLOW_ALLOW_BROKEN_PUBLISH=1 — pre-publish cross-package smoke test was BYPASSED. This release may ship a DOA package."
63
-
64
- # Trusted-publisher OIDC; no NODE_AUTH_TOKEN. No --provenance (private
65
- # repo limitation since npm 2026-05-10). Pre-release versions (with '-')
66
- # get --tag rc to keep latest dist-tag pointing at stable releases only.
67
- - name: Publish to npm
68
- id: publish
69
- run: |
70
- NAME=$(jq -r .name package.json)
71
- VER=$(jq -r .version package.json)
72
- if npm view "$NAME@$VER" version >/dev/null 2>&1; then
73
- echo "$NAME@$VER already on npm — skipping"
74
- else
75
- if echo "$VER" | grep -q '-'; then
76
- npm publish --access public --tag rc
77
- else
78
- npm publish --access public
79
- fi
80
- echo "published=true" >> "$GITHUB_OUTPUT"
81
- fi
82
-
83
- # Sentry sourcemap upload runs on every tag push regardless of whether
84
- # this run was the one that actually published to npm. The previous
85
- # `if: steps.publish.outputs.published == 'true'` gate made re-runs
86
- # useless when only the Sentry upload failed — the publish step's
87
- # short-circuit (`npm view @ver already published`) zeroed out the
88
- # output, so the upload step was skipped on every re-run. dist/ is
89
- # rebuilt by the Build + Inject steps each run, so the upload is
90
- # always operating on fresh, debug-id-stamped artifacts.
91
- - name: Upload sourcemaps to Sentry
92
- run: |
93
- # Defang paste-time whitespace in SENTRY_AUTH_TOKEN. Sentry's
94
- # auth-header parser returns HTTP 401 "Token string should not
95
- # contain spaces" when the secret has a trailing \n or any
96
- # embedded whitespace — observed on the v5.25.0 release after a
97
- # copy-paste into the GH repo secret carried a literal \n. `tr -d`
98
- # is the cheap structural guard so the workflow doesn't blow up
99
- # on the next dirty paste.
100
- SENTRY_AUTH_TOKEN=$(printf %s "$SENTRY_AUTH_TOKEN" | tr -d '[:space:]')
101
- if [ -z "$SENTRY_AUTH_TOKEN" ]; then
102
- echo "::error::SENTRY_AUTH_TOKEN secret is empty (or all whitespace) after sanitization — cannot upload sourcemaps"
103
- exit 1
104
- fi
105
- export SENTRY_AUTH_TOKEN
106
- VER=$(jq -r .version package.json)
107
- npx -y @sentry/cli@^2 sourcemaps upload --release "hq-cli@$VER" dist/ node_modules/@indigoai-us/hq-cloud/dist/
108
- env:
109
- SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
110
- SENTRY_ORG: indigo-d0
111
- SENTRY_PROJECT: hq-cli
18
+ uses: indigoai-us/.github/.github/workflows/_npm-publish.yml@main
19
+ with:
20
+ package-manager: pnpm
21
+ pnpm-version: "10"
22
+ node-version: "24"
23
+ build-command: "pnpm run build"
24
+ dsn-env-name: "HQ_CLI_PUBLISH_SENTRY_DSN"
25
+ sentry-inject-dir: "dist/"
26
+ smoke-test-arg: "hq"
27
+ sentry-release-prefix: "hq-cli"
28
+ sentry-upload-paths: "dist/ node_modules/@indigoai-us/hq-cloud/dist/"
29
+ sentry-org: "indigo-d0"
30
+ sentry-project: "hq-cli"
31
+ # Prerelease versions (e.g. 5.x.y-rc.0) ship to the rc dist-tag, not latest.
32
+ prerelease-dist-tag: "rc"
33
+ dry-run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
34
+ secrets:
35
+ sentry-dsn: ${{ secrets.HQ_CLI_PUBLISH_SENTRY_DSN }}
36
+ sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -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]="e1c09963-744f-5033-9981-1ab6d37956e9")}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";
@@ -207,6 +207,61 @@ export function registerMeetingsCommand(program) {
207
207
  process.exit(1);
208
208
  }
209
209
  });
210
+ // ── hq meetings set-company <id> ──────────────────────────────────
211
+ meetings
212
+ .command("set-company <meetingId>")
213
+ .description("Set or change the company a meeting is attributed to (use 'unknown' to clear)")
214
+ .option("--company <slug>", "Target company slug, or 'unknown'/'personal' to clear attribution")
215
+ .option("--no-series", "Apply only to this occurrence, not the whole recurring series")
216
+ .action(async (rawId, cmdOpts) => {
217
+ try {
218
+ const token = await ensureCognitoToken();
219
+ // `--company` is shared with the parent `meetings` command, so commander
220
+ // may bind it to either level depending on position — accept both.
221
+ const target = cmdOpts.company ?? meetings.opts().company;
222
+ if (!target) {
223
+ console.error(chalk.red("Error:"), "a target company is required: --company <slug> (or 'unknown'/'personal' to clear)");
224
+ process.exit(1);
225
+ }
226
+ // The bot record is person-keyed, so no company scope is needed to
227
+ // resolve the meeting id; pass an empty scope.
228
+ const meetingId = await resolveShortId(token, rawId, {});
229
+ const lc = target.toLowerCase();
230
+ const companyId = lc === "unknown" || lc === "personal"
231
+ ? "unknown"
232
+ : await getCompanyUid(token, target);
233
+ const applyToSeries = cmdOpts.series !== false;
234
+ const res = await vaultApiFetch({
235
+ token,
236
+ method: "POST",
237
+ path: `/v1/meetings/${encodeURIComponent(meetingId)}/company`,
238
+ body: { companyId, applyToSeries },
239
+ });
240
+ if (!res.ok)
241
+ await handleApiError(res);
242
+ const data = (await res.json());
243
+ if (meetings.opts().json) {
244
+ console.log(JSON.stringify(data, null, 2));
245
+ return;
246
+ }
247
+ const where = companyId === "unknown" ? "unattributed (personal)" : companyId;
248
+ console.log(chalk.green(`\n✓ Meeting ${chalk.cyan(meetingId)} attributed to ${chalk.bold(where)}.`));
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) {
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
+ }
258
+ console.log();
259
+ }
260
+ catch (err) {
261
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
262
+ process.exit(1);
263
+ }
264
+ });
210
265
  // ── hq meetings search <query> ─────────────────────────────────────
211
266
  meetings
212
267
  .command("search <query>")
@@ -371,4 +426,4 @@ export function registerMeetingsCommand(program) {
371
426
  });
372
427
  }
373
428
  //# sourceMappingURL=meetings.js.map
374
- //# debugId=e1c09963-744f-5033-9981-1ab6d37956e9
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]="60f911e0-ddd5-5a2d-91c7-18ba47457b57")}catch(e){}}();
7
7
  import { Command } from "commander";
8
8
  import { initSentry, Sentry } from "./sentry.js";
9
9
  import { registerAddCommand } from "./commands/add.js";
@@ -180,8 +180,10 @@ registerRescueCommand(program);
180
180
  process.exitCode = 1;
181
181
  }
182
182
  finally {
183
+ // Release health: finalize the per-run session before the flush.
184
+ Sentry.endSession();
183
185
  await Promise.allSettled([refreshVersionCache(), Sentry.flush(2000)]);
184
186
  }
185
187
  })();
186
188
  //# sourceMappingURL=index.js.map
187
- //# debugId=aeb143c7-7e19-5999-b6cb-790fc79034cc
189
+ //# debugId=60f911e0-ddd5-5a2d-91c7-18ba47457b57
package/dist/sentry.js CHANGED
@@ -1,16 +1,21 @@
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]="508ac88d-5683-5d1f-9857-80a8a74e03e2")}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
+ import { CLI_VERSION } from "./cli-version.js";
8
+ import { getCachedSentryUser } from "./utils/sentry-identity.js";
7
9
  export function initSentry() {
8
10
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
9
11
  if (!dsn)
10
12
  return;
11
13
  Sentry.init({
12
14
  dsn,
13
- release: `hq-cli@${process.env.npm_package_version ?? "0.0.0"}`,
15
+ // CLI_VERSION reads package.json at runtime; npm_package_version is only
16
+ // set under `npm run`, so an installed `hq` binary would always report
17
+ // 0.0.0 and never match the uploaded source maps.
18
+ release: `hq-cli@${CLI_VERSION}`,
14
19
  environment: process.env.HQ_CLI_ENV ?? "production",
15
20
  initialScope: {
16
21
  tags: { repo: "hq-cli" },
@@ -18,7 +23,16 @@ export function initSentry() {
18
23
  beforeSend,
19
24
  beforeBreadcrumb,
20
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();
21
35
  }
22
36
  export { Sentry };
23
37
  //# sourceMappingURL=sentry.js.map
24
- //# debugId=508ac88d-5683-5d1f-9857-80a8a74e03e2
38
+ //# debugId=a260ea42-f6d8-52d0-90c6-6e586de5efd6
@@ -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.47.17",
3
+ "version": "5.49.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -10,12 +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",
16
+ "coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
14
17
  "vitest": "vitest",
15
18
  "clean": "rm -rf dist"
16
19
  },
17
20
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^6.11.7",
21
+ "@indigoai-us/hq-cloud": "^6.11.14",
19
22
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
23
  "@sentry/node": "^10.49.0",
21
24
  "chalk": "^5.3.0",
@@ -28,10 +31,14 @@
28
31
  },
29
32
  "devDependencies": {
30
33
  "@aws-sdk/client-s3": "^3.1049.0",
34
+ "@eslint/js": "^10.0.1",
31
35
  "@types/js-yaml": "^4.0.9",
32
36
  "@types/node": "^22.0.0",
33
37
  "@types/semver": "^7.5.8",
38
+ "@vitest/coverage-v8": "4.1.6",
39
+ "eslint": "^10.5.0",
34
40
  "typescript": "^5.7.0",
41
+ "typescript-eslint": "^8.61.1",
35
42
  "vitest": "^4.1.2"
36
43
  },
37
44
  "repository": {
@@ -0,0 +1,227 @@
1
+ import { Command } from "commander";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ vi,
9
+ type MockInstance,
10
+ } from "vitest";
11
+
12
+ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
13
+ const original = (await importOriginal()) as Record<string, unknown>;
14
+ return {
15
+ ...original,
16
+ ensureCognitoToken: vi.fn(async () => "test-token"),
17
+ };
18
+ });
19
+
20
+ vi.mock("../utils/vault-api.js", async (importOriginal) => {
21
+ const original = (await importOriginal()) as Record<string, unknown>;
22
+ return {
23
+ ...original,
24
+ getCompanyUid: vi.fn(async (_token: string, slug: string) => `cmp_${slug}`),
25
+ vaultApiFetch: vi.fn(async () =>
26
+ new Response(JSON.stringify({ appliedToSeries: true }), {
27
+ status: 200,
28
+ headers: { "Content-Type": "application/json" },
29
+ }),
30
+ ),
31
+ };
32
+ });
33
+
34
+ import { registerMeetingsCommand } from "./meetings.js";
35
+ import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
36
+
37
+ let logSpy: MockInstance<typeof console.log>;
38
+ let errSpy: MockInstance<typeof console.error>;
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
43
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ function buildProgram(): Command {
51
+ const program = new Command();
52
+ program.exitOverride();
53
+ program.configureOutput({
54
+ writeOut: () => undefined,
55
+ writeErr: () => undefined,
56
+ });
57
+ registerMeetingsCommand(program);
58
+ return program;
59
+ }
60
+
61
+ function jsonRes(body: unknown, status = 200): Response {
62
+ return new Response(JSON.stringify(body), {
63
+ status,
64
+ headers: { "Content-Type": "application/json" },
65
+ });
66
+ }
67
+
68
+ describe("meetings set-company", () => {
69
+ it("POSTs the resolved company id and applies to the recurring series by default", async () => {
70
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
71
+ jsonRes({ appliedToSeries: true }),
72
+ );
73
+
74
+ const program = buildProgram();
75
+ await program.parseAsync([
76
+ "node",
77
+ "hq",
78
+ "meetings",
79
+ "set-company",
80
+ "meeting-123456",
81
+ "--company",
82
+ "acme",
83
+ ]);
84
+
85
+ expect(getCompanyUid).toHaveBeenCalledWith("test-token", "acme");
86
+ expect(vaultApiFetch).toHaveBeenCalledWith({
87
+ token: "test-token",
88
+ method: "POST",
89
+ path: "/v1/meetings/meeting-123456/company",
90
+ body: { companyId: "cmp_acme", applyToSeries: true },
91
+ });
92
+ expect(errSpy).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("sends unknown without resolving a target company", async () => {
96
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
97
+ jsonRes({ appliedToSeries: true }),
98
+ );
99
+
100
+ const program = buildProgram();
101
+ await program.parseAsync([
102
+ "node",
103
+ "hq",
104
+ "meetings",
105
+ "set-company",
106
+ "meeting-123456",
107
+ "--company",
108
+ "unknown",
109
+ ]);
110
+
111
+ expect(getCompanyUid).not.toHaveBeenCalled();
112
+ expect(vaultApiFetch).toHaveBeenCalledWith({
113
+ token: "test-token",
114
+ method: "POST",
115
+ path: "/v1/meetings/meeting-123456/company",
116
+ body: { companyId: "unknown", applyToSeries: true },
117
+ });
118
+ });
119
+
120
+ it("sends applyToSeries false when --no-series is passed", async () => {
121
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
122
+ jsonRes({ appliedToSeries: false }),
123
+ );
124
+
125
+ const program = buildProgram();
126
+ await program.parseAsync([
127
+ "node",
128
+ "hq",
129
+ "meetings",
130
+ "set-company",
131
+ "meeting-123456",
132
+ "--company",
133
+ "acme",
134
+ "--no-series",
135
+ ]);
136
+
137
+ expect(vaultApiFetch).toHaveBeenCalledWith({
138
+ token: "test-token",
139
+ method: "POST",
140
+ path: "/v1/meetings/meeting-123456/company",
141
+ body: { companyId: "cmp_acme", applyToSeries: false },
142
+ });
143
+ });
144
+
145
+ it("prints the raw response when --json is passed", async () => {
146
+ const body = { meetingId: "meeting-123456", companyId: "cmp_acme" };
147
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes(body));
148
+
149
+ const program = buildProgram();
150
+ await program.parseAsync([
151
+ "node",
152
+ "hq",
153
+ "meetings",
154
+ "--json",
155
+ "set-company",
156
+ "meeting-123456",
157
+ "--company",
158
+ "acme",
159
+ ]);
160
+
161
+ expect(logSpy).toHaveBeenCalledWith(JSON.stringify(body, null, 2));
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
+ });
227
+ });
@@ -299,6 +299,76 @@ export function registerMeetingsCommand(program: Command): void {
299
299
  }
300
300
  });
301
301
 
302
+ // ── hq meetings set-company <id> ──────────────────────────────────
303
+
304
+ meetings
305
+ .command("set-company <meetingId>")
306
+ .description("Set or change the company a meeting is attributed to (use 'unknown' to clear)")
307
+ .option("--company <slug>", "Target company slug, or 'unknown'/'personal' to clear attribution")
308
+ .option("--no-series", "Apply only to this occurrence, not the whole recurring series")
309
+ .action(async (rawId: string, cmdOpts: { company?: string; series?: boolean }) => {
310
+ try {
311
+ const token = await ensureCognitoToken();
312
+ // `--company` is shared with the parent `meetings` command, so commander
313
+ // may bind it to either level depending on position — accept both.
314
+ const target = cmdOpts.company ?? (meetings.opts().company as string | undefined);
315
+ if (!target) {
316
+ console.error(
317
+ chalk.red("Error:"),
318
+ "a target company is required: --company <slug> (or 'unknown'/'personal' to clear)",
319
+ );
320
+ process.exit(1);
321
+ }
322
+
323
+ // The bot record is person-keyed, so no company scope is needed to
324
+ // resolve the meeting id; pass an empty scope.
325
+ const meetingId = await resolveShortId(token, rawId, {});
326
+
327
+ const lc = target.toLowerCase();
328
+ const companyId =
329
+ lc === "unknown" || lc === "personal"
330
+ ? "unknown"
331
+ : await getCompanyUid(token, target);
332
+
333
+ const applyToSeries = cmdOpts.series !== false;
334
+
335
+ const res = await vaultApiFetch({
336
+ token,
337
+ method: "POST",
338
+ path: `/v1/meetings/${encodeURIComponent(meetingId)}/company`,
339
+ body: { companyId, applyToSeries },
340
+ });
341
+ if (!res.ok) await handleApiError(res);
342
+ const data = (await res.json()) as {
343
+ appliedToSeries?: boolean;
344
+ occurrencesUpdated?: number;
345
+ refiled?: boolean;
346
+ refiledCount?: number;
347
+ [key: string]: unknown;
348
+ };
349
+
350
+ if (meetings.opts().json) {
351
+ console.log(JSON.stringify(data, null, 2));
352
+ return;
353
+ }
354
+
355
+ const where = companyId === "unknown" ? "unattributed (personal)" : companyId;
356
+ console.log(chalk.green(`\n✓ Meeting ${chalk.cyan(meetingId)} attributed to ${chalk.bold(where)}.`));
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
+ }
365
+ console.log();
366
+ } catch (err) {
367
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
368
+ process.exit(1);
369
+ }
370
+ });
371
+
302
372
  // ── hq meetings search <query> ─────────────────────────────────────
303
373
 
304
374
  meetings
package/src/index.ts CHANGED
@@ -218,6 +218,8 @@ registerRescueCommand(program);
218
218
  Sentry.captureException(err);
219
219
  process.exitCode = 1;
220
220
  } finally {
221
+ // Release health: finalize the per-run session before the flush.
222
+ Sentry.endSession();
221
223
  await Promise.allSettled([refreshVersionCache(), Sentry.flush(2000)]);
222
224
  }
223
225
  })();
package/src/sentry.ts CHANGED
@@ -2,13 +2,18 @@ import * as Sentry from "@sentry/node";
2
2
  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
+ import { CLI_VERSION } from "./cli-version.js";
6
+ import { getCachedSentryUser } from "./utils/sentry-identity.js";
5
7
 
6
8
  export function initSentry(): void {
7
9
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
8
10
  if (!dsn) return;
9
11
  Sentry.init({
10
12
  dsn,
11
- release: `hq-cli@${process.env.npm_package_version ?? "0.0.0"}`,
13
+ // CLI_VERSION reads package.json at runtime; npm_package_version is only
14
+ // set under `npm run`, so an installed `hq` binary would always report
15
+ // 0.0.0 and never match the uploaded source maps.
16
+ release: `hq-cli@${CLI_VERSION}`,
12
17
  environment: process.env.HQ_CLI_ENV ?? "production",
13
18
  initialScope: {
14
19
  tags: { repo: "hq-cli" },
@@ -16,6 +21,14 @@ export function initSentry(): void {
16
21
  beforeSend,
17
22
  beforeBreadcrumb,
18
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();
19
32
  }
20
33
 
21
34
  export { Sentry };
@@ -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
+ }
@@ -126,11 +126,15 @@ export function mockVaultService(opts: MockVaultOptions): () => void {
126
126
  // 404s, which the reader normalizes to NoSuchKey (the not-found path).
127
127
  if (method === "POST" && /\/v1\/files\/presign$/.test(url)) {
128
128
  const body = init?.body ? JSON.parse(init.body.toString()) : {};
129
- const keys = (body.keys ?? []) as Array<{ key: string }>;
129
+ const keys = (body.keys ?? []) as Array<{ key: string; op?: string }>;
130
+ // Echo the requested op and omit `error` on success. hq-cloud >=6.11.x
131
+ // validates each presign result with a strict schema (op ∈ get|put|delete;
132
+ // error must be a string when present), so the older `error: null` +
133
+ // missing-op shape no longer passes.
130
134
  const results = keys.map((k) => ({
131
135
  key: k.key,
136
+ op: k.op ?? "get",
132
137
  url: `${PRESIGN_URL_HOST}/obj?key=${encodeURIComponent(k.key)}`,
133
- error: null,
134
138
  }));
135
139
  return json({
136
140
  results,
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["e2e/**/*.test.ts"],
6
+ },
7
+ });