@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.
- package/.github/workflows/ci.yml +26 -13
- package/.github/workflows/publish.yml +26 -101
- package/dist/commands/meetings.js +57 -2
- package/dist/index.js +4 -2
- package/dist/sentry.js +17 -3
- 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 +9 -2
- package/src/commands/meetings.test.ts +227 -0
- package/src/commands/meetings.ts +70 -0
- package/src/index.ts +2 -0
- package/src/sentry.ts +14 -1
- package/src/utils/sentry-identity.ts +45 -0
- package/test/helpers/vault-service-mock.ts +6 -2
- package/vitest.e2e.config.ts +7 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
42
|
+
node-version: "22"
|
|
27
43
|
cache: pnpm
|
|
28
|
-
- run: pnpm install --frozen-lockfile
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
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]="
|
|
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=
|
|
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]="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=
|
|
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]="
|
|
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
|
-
|
|
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=
|
|
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
|
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.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.
|
|
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
|
+
});
|
package/src/commands/meetings.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|