@indigoai-us/hq-cli 5.47.17 → 5.48.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,25 @@ 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
- steps:
20
- - uses: actions/checkout@v4
21
- - uses: pnpm/action-setup@v4
22
- with:
23
- version: 10
24
- - uses: actions/setup-node@v4
25
- with:
26
- node-version: ${{ matrix.node-version }}
27
- 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
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: false
28
+ run-coverage: true
29
+ secrets: inherit
@@ -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]="f1986062-db39-5448-a8fa-3bb6be32c58b")}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,54 @@ 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 (data && data.appliedToSeries)
250
+ console.log(chalk.dim(" Future occurrences of this recurring series will inherit this attribution."));
251
+ console.log();
252
+ }
253
+ catch (err) {
254
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
255
+ process.exit(1);
256
+ }
257
+ });
210
258
  // ── hq meetings search <query> ─────────────────────────────────────
211
259
  meetings
212
260
  .command("search <query>")
@@ -371,4 +419,4 @@ export function registerMeetingsCommand(program) {
371
419
  });
372
420
  }
373
421
  //# sourceMappingURL=meetings.js.map
374
- //# debugId=e1c09963-744f-5033-9981-1ab6d37956e9
422
+ //# debugId=f1986062-db39-5448-a8fa-3bb6be32c58b
package/dist/sentry.js CHANGED
@@ -1,16 +1,20 @@
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]="80ca2b2d-c649-5e88-9bc6-69a20fa4aa51")}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";
7
8
  export function initSentry() {
8
9
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
9
10
  if (!dsn)
10
11
  return;
11
12
  Sentry.init({
12
13
  dsn,
13
- release: `hq-cli@${process.env.npm_package_version ?? "0.0.0"}`,
14
+ // CLI_VERSION reads package.json at runtime; npm_package_version is only
15
+ // set under `npm run`, so an installed `hq` binary would always report
16
+ // 0.0.0 and never match the uploaded source maps.
17
+ release: `hq-cli@${CLI_VERSION}`,
14
18
  environment: process.env.HQ_CLI_ENV ?? "production",
15
19
  initialScope: {
16
20
  tags: { repo: "hq-cli" },
@@ -21,4 +25,4 @@ export function initSentry() {
21
25
  }
22
26
  export { Sentry };
23
27
  //# sourceMappingURL=sentry.js.map
24
- //# debugId=508ac88d-5683-5d1f-9857-80a8a74e03e2
28
+ //# debugId=80ca2b2d-c649-5e88-9bc6-69a20fa4aa51
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.48.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -11,11 +11,12 @@
11
11
  "build": "node scripts/generate-dsn.mjs && tsc",
12
12
  "typecheck": "tsc --noEmit",
13
13
  "test": "vitest run",
14
+ "coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
14
15
  "vitest": "vitest",
15
16
  "clean": "rm -rf dist"
16
17
  },
17
18
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^6.11.7",
19
+ "@indigoai-us/hq-cloud": "^6.11.14",
19
20
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
21
  "@sentry/node": "^10.49.0",
21
22
  "chalk": "^5.3.0",
@@ -31,6 +32,7 @@
31
32
  "@types/js-yaml": "^4.0.9",
32
33
  "@types/node": "^22.0.0",
33
34
  "@types/semver": "^7.5.8",
35
+ "@vitest/coverage-v8": "4.1.6",
34
36
  "typescript": "^5.7.0",
35
37
  "vitest": "^4.1.2"
36
38
  },
@@ -0,0 +1,163 @@
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
+ });
@@ -299,6 +299,66 @@ 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
+ [key: string]: unknown;
345
+ };
346
+
347
+ if (meetings.opts().json) {
348
+ console.log(JSON.stringify(data, null, 2));
349
+ return;
350
+ }
351
+
352
+ const where = companyId === "unknown" ? "unattributed (personal)" : companyId;
353
+ 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."));
355
+ console.log();
356
+ } catch (err) {
357
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
358
+ process.exit(1);
359
+ }
360
+ });
361
+
302
362
  // ── hq meetings search <query> ─────────────────────────────────────
303
363
 
304
364
  meetings
package/src/sentry.ts CHANGED
@@ -2,13 +2,17 @@ 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";
5
6
 
6
7
  export function initSentry(): void {
7
8
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
8
9
  if (!dsn) return;
9
10
  Sentry.init({
10
11
  dsn,
11
- release: `hq-cli@${process.env.npm_package_version ?? "0.0.0"}`,
12
+ // CLI_VERSION reads package.json at runtime; npm_package_version is only
13
+ // set under `npm run`, so an installed `hq` binary would always report
14
+ // 0.0.0 and never match the uploaded source maps.
15
+ release: `hq-cli@${CLI_VERSION}`,
12
16
  environment: process.env.HQ_CLI_ENV ?? "production",
13
17
  initialScope: {
14
18
  tags: { repo: "hq-cli" },
@@ -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,