@indigoai-us/hq-cli 5.14.1 → 5.15.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 +21 -0
- package/.github/workflows/publish.yml +86 -0
- package/.github/workflows/scripts/smoke-test-pkg.sh +97 -0
- package/dist/commands/cloud-provision.js +16 -3
- package/dist/commands/meetings.d.ts +3 -0
- package/dist/commands/meetings.js +374 -0
- package/dist/index.js +5 -2
- package/package.json +3 -2
- package/src/commands/cloud-provision.test.ts +16 -0
- package/src/commands/cloud-provision.ts +13 -1
- package/src/commands/meetings.ts +487 -0
- package/src/index.ts +4 -0
- package/tsconfig.json +12 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
branches: [main]
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: 22
|
|
16
|
+
- run: npm ci
|
|
17
|
+
# generate-dsn.mjs only fatals when GITHUB_JOB=publish, so CI builds with
|
|
18
|
+
# an empty BUNDLED_DSN. That's intentional — the DSN is publish-only.
|
|
19
|
+
- run: npm run build --if-present
|
|
20
|
+
- run: npm run typecheck --if-present
|
|
21
|
+
- run: npm test --if-present
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ["v*"]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
publish:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
id-token: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-node@v4
|
|
15
|
+
with:
|
|
16
|
+
# Node 24 ships npm 11.x, required for npm's trusted-publisher OIDC
|
|
17
|
+
# token-exchange flow. See hq-cloud's publish.yml for the full
|
|
18
|
+
# rationale (npm 10.x produces masked-404 failures on publish PUT).
|
|
19
|
+
node-version: 24
|
|
20
|
+
registry-url: https://registry.npmjs.org
|
|
21
|
+
|
|
22
|
+
- run: node --version && npm --version
|
|
23
|
+
- run: npm ci
|
|
24
|
+
|
|
25
|
+
- name: Build
|
|
26
|
+
run: npm run build --if-present
|
|
27
|
+
env:
|
|
28
|
+
# generate-dsn.mjs fatals when GITHUB_JOB=publish and this var is
|
|
29
|
+
# missing. Set this as a repo secret to bundle a Sentry DSN into the
|
|
30
|
+
# published binary.
|
|
31
|
+
HQ_CLI_PUBLISH_SENTRY_DSN: ${{ secrets.HQ_CLI_PUBLISH_SENTRY_DSN }}
|
|
32
|
+
|
|
33
|
+
# Inject Sentry debug IDs into compiled JS + map files. Must run after
|
|
34
|
+
# tsc and BEFORE publish so the installed binary carries the same debugId
|
|
35
|
+
# as the artifacts uploaded to Sentry, enabling debugId-based source-map
|
|
36
|
+
# resolution for globally-installed CLI users.
|
|
37
|
+
- name: Inject Sentry source map debug IDs
|
|
38
|
+
run: npx -y @sentry/cli@^2 sourcemaps inject dist/
|
|
39
|
+
|
|
40
|
+
# Pre-publish cross-package smoke test (Bug A guard).
|
|
41
|
+
#
|
|
42
|
+
# @indigoai-us/hq-cli depends on @indigoai-us/hq-cloud via a caret range
|
|
43
|
+
# (^5.1.0). The smoke installs a freshly-packed tarball into an isolated
|
|
44
|
+
# tmpdir — outside this repo — so transitive @indigoai-us/* deps resolve
|
|
45
|
+
# from the npm registry, NOT from a local checkout. Booting `hq --version`
|
|
46
|
+
# then exercises the full module-load graph. If hq-cloud removed or
|
|
47
|
+
# renamed a symbol hq-cli imports, this catches it BEFORE the publish PUT.
|
|
48
|
+
#
|
|
49
|
+
# Bypass: set repo-or-workflow variable WORKFLOW_ALLOW_BROKEN_PUBLISH=1.
|
|
50
|
+
- name: Pre-publish smoke test
|
|
51
|
+
if: ${{ vars.WORKFLOW_ALLOW_BROKEN_PUBLISH != '1' }}
|
|
52
|
+
run: bash .github/workflows/scripts/smoke-test-pkg.sh hq
|
|
53
|
+
|
|
54
|
+
- name: WARN — pre-publish smoke bypassed
|
|
55
|
+
if: ${{ vars.WORKFLOW_ALLOW_BROKEN_PUBLISH == '1' }}
|
|
56
|
+
run: |
|
|
57
|
+
echo "::warning::WORKFLOW_ALLOW_BROKEN_PUBLISH=1 — pre-publish cross-package smoke test was BYPASSED. This release may ship a DOA package."
|
|
58
|
+
|
|
59
|
+
# Trusted-publisher OIDC; no NODE_AUTH_TOKEN. No --provenance (private
|
|
60
|
+
# repo limitation since npm 2026-05-10). Pre-release versions (with '-')
|
|
61
|
+
# get --tag rc to keep latest dist-tag pointing at stable releases only.
|
|
62
|
+
- name: Publish to npm
|
|
63
|
+
id: publish
|
|
64
|
+
run: |
|
|
65
|
+
NAME=$(jq -r .name package.json)
|
|
66
|
+
VER=$(jq -r .version package.json)
|
|
67
|
+
if npm view "$NAME@$VER" version >/dev/null 2>&1; then
|
|
68
|
+
echo "$NAME@$VER already on npm — skipping"
|
|
69
|
+
else
|
|
70
|
+
if echo "$VER" | grep -q '-'; then
|
|
71
|
+
npm publish --access public --tag rc
|
|
72
|
+
else
|
|
73
|
+
npm publish --access public
|
|
74
|
+
fi
|
|
75
|
+
echo "published=true" >> "$GITHUB_OUTPUT"
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
- name: Upload sourcemaps to Sentry
|
|
79
|
+
if: steps.publish.outputs.published == 'true'
|
|
80
|
+
run: |
|
|
81
|
+
VER=$(jq -r .version package.json)
|
|
82
|
+
npx -y @sentry/cli@^2 sourcemaps upload --release "hq-cli@$VER" dist/
|
|
83
|
+
env:
|
|
84
|
+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
85
|
+
SENTRY_ORG: indigo-d0
|
|
86
|
+
SENTRY_PROJECT: hq
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# smoke-test-pkg.sh — pre-publish cross-package import smoke test (Bug A guard).
|
|
3
|
+
#
|
|
4
|
+
# Single-package adaptation of the same script used in the legacy hq monorepo.
|
|
5
|
+
# The monorepo version operated on `packages/$PKG/package.json`; this version
|
|
6
|
+
# reads `./package.json` because each extracted repo is a single package at
|
|
7
|
+
# its root.
|
|
8
|
+
#
|
|
9
|
+
# Install the package in an isolated tmpdir — using a freshly-packed tarball
|
|
10
|
+
# when the package.json version is new, or fetching the existing name@version
|
|
11
|
+
# from npm when it is already published — then boot the binary with
|
|
12
|
+
# `--version`. The boot exercises the full module-load graph and the
|
|
13
|
+
# transitive `@indigoai-us/*` resolution path a customer's `npm install`
|
|
14
|
+
# would take. If the boot fails the script exits non-zero and the workflow
|
|
15
|
+
# aborts before any further publish steps run.
|
|
16
|
+
#
|
|
17
|
+
# Why we don't skip when the consumer's version is already on npm:
|
|
18
|
+
# @indigoai-us/hq-cli depends on @indigoai-us/hq-cloud via a caret range
|
|
19
|
+
# (^5.1.0). A producer bump (published from indigoai-us/hq-cloud) re-resolves
|
|
20
|
+
# this consumer's transitive deps on the next fresh install, so an already-
|
|
21
|
+
# published consumer is NOT actually "locked." If the new producer drops or
|
|
22
|
+
# renames a symbol the published consumer imports, `hq --version` crashes
|
|
23
|
+
# on every fresh install starting the moment the producer publishes.
|
|
24
|
+
#
|
|
25
|
+
# Usage:
|
|
26
|
+
# smoke-test-pkg.sh <bin-name>
|
|
27
|
+
# Example:
|
|
28
|
+
# smoke-test-pkg.sh hq
|
|
29
|
+
|
|
30
|
+
set -uo pipefail
|
|
31
|
+
|
|
32
|
+
BIN=${1:?usage: smoke-test-pkg.sh <bin-name>}
|
|
33
|
+
|
|
34
|
+
PKG_JSON="package.json"
|
|
35
|
+
if [ ! -f "$PKG_JSON" ]; then
|
|
36
|
+
echo "::error::smoke-test-pkg.sh: $PKG_JSON not found (cwd: $(pwd))"
|
|
37
|
+
exit 2
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
NAME=$(jq -r .name "$PKG_JSON")
|
|
41
|
+
VER=$(jq -r .version "$PKG_JSON")
|
|
42
|
+
|
|
43
|
+
# Pick the install target. Already on npm → install by name@version so the
|
|
44
|
+
# smoke matches what a customer would actually receive. Not yet on npm →
|
|
45
|
+
# pack the local repo; the next publish step will push that exact byte-for-
|
|
46
|
+
# byte content. Either way, `npm install` happens OUTSIDE this repo so
|
|
47
|
+
# transitive @indigoai-us/* deps resolve from the registry.
|
|
48
|
+
INSTALL_SOURCE=""
|
|
49
|
+
if npm view "$NAME@$VER" version >/dev/null 2>&1; then
|
|
50
|
+
INSTALL_TARGET="$NAME@$VER"
|
|
51
|
+
INSTALL_SOURCE="npm registry (consumer version already published)"
|
|
52
|
+
else
|
|
53
|
+
TARBALL_RELATIVE=$(npm pack --silent --pack-destination /tmp)
|
|
54
|
+
if [ -z "$TARBALL_RELATIVE" ]; then
|
|
55
|
+
echo "::error::npm pack produced no tarball for $NAME"
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
INSTALL_TARGET="/tmp/$TARBALL_RELATIVE"
|
|
59
|
+
if [ ! -f "$INSTALL_TARGET" ]; then
|
|
60
|
+
echo "::error::expected tarball at $INSTALL_TARGET not found"
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
INSTALL_SOURCE="packed tarball ($TARBALL_RELATIVE)"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
echo "::group::smoke test: $NAME@$VER via $INSTALL_SOURCE"
|
|
67
|
+
|
|
68
|
+
SMOKE=$(mktemp -d)
|
|
69
|
+
pushd "$SMOKE" >/dev/null
|
|
70
|
+
|
|
71
|
+
printf '%s' '{"name":"smoke","version":"0.0.0","private":true}' > package.json
|
|
72
|
+
|
|
73
|
+
if ! npm install --no-audit --no-fund --no-package-lock "$INSTALL_TARGET" >/tmp/smoke-install.log 2>&1; then
|
|
74
|
+
echo "::error::pre-publish smoke install FAILED for $NAME@$VER:"
|
|
75
|
+
tail -50 /tmp/smoke-install.log
|
|
76
|
+
popd >/dev/null
|
|
77
|
+
echo "::endgroup::"
|
|
78
|
+
exit 1
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
set +e
|
|
82
|
+
OUT=$("./node_modules/.bin/$BIN" --version 2>&1)
|
|
83
|
+
CODE=$?
|
|
84
|
+
set -e
|
|
85
|
+
|
|
86
|
+
popd >/dev/null
|
|
87
|
+
|
|
88
|
+
if [ "$CODE" -ne 0 ]; then
|
|
89
|
+
echo "::error::pre-publish smoke FAILED for $NAME@$VER (exit $CODE):"
|
|
90
|
+
echo "::error::$OUT"
|
|
91
|
+
echo "::error::Likely cause: a cross-package symbol was changed (added, removed, renamed) in another @indigoai-us/* package without keeping this consumer's import in sync. Bump this consumer (or revert the producer change) and re-tag, OR set the repo/workflow variable WORKFLOW_ALLOW_BROKEN_PUBLISH=1 to override (audited)."
|
|
92
|
+
echo "::endgroup::"
|
|
93
|
+
exit 1
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
echo "✓ smoke ok: $NAME@$VER → $BIN --version → $OUT (via $INSTALL_SOURCE)"
|
|
97
|
+
echo "::endgroup::"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* `initial_sync.ok=false`). Manifest + config may have been written.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
!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]="
|
|
31
|
+
!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]="3c9e69f3-8137-5db1-8692-e38774558f61")}catch(e){}}();
|
|
32
32
|
import chalk from "chalk";
|
|
33
33
|
import * as fs from "node:fs";
|
|
34
34
|
import * as path from "node:path";
|
|
@@ -164,7 +164,20 @@ export function ensureManifestEntryForProvision(hqRoot, slug) {
|
|
|
164
164
|
if (path.basename(expected) !== slug)
|
|
165
165
|
return;
|
|
166
166
|
const dir = companyDirPath(hqRoot, slug);
|
|
167
|
-
|
|
167
|
+
// Must be an actual directory, not a stray file. fs.existsSync returns true
|
|
168
|
+
// for regular files too — without this guard, auto-insert would fire on a
|
|
169
|
+
// file at `companies/<slug>`, provisionCompany would then create the vault
|
|
170
|
+
// entity and patch manifest.yaml before writeCompanyConfig's `mkdir -p .hq`
|
|
171
|
+
// exploded with ENOTDIR. statSync swallows the not-found case so a missing
|
|
172
|
+
// path is treated the same as before (no auto-insert).
|
|
173
|
+
let dirStat;
|
|
174
|
+
try {
|
|
175
|
+
dirStat = fs.statSync(dir);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!dirStat.isDirectory())
|
|
168
181
|
return;
|
|
169
182
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
170
183
|
let parsed;
|
|
@@ -536,4 +549,4 @@ export function registerCloudProvisionCommands(program) {
|
|
|
536
549
|
});
|
|
537
550
|
}
|
|
538
551
|
//# sourceMappingURL=cloud-provision.js.map
|
|
539
|
-
//# debugId=
|
|
552
|
+
//# debugId=3c9e69f3-8137-5db1-8692-e38774558f61
|
|
@@ -0,0 +1,374 @@
|
|
|
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]="8237a835-5842-52be-bb27-c4ffb7944d89")}catch(e){}}();
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
6
|
+
function formatDuration(seconds) {
|
|
7
|
+
const h = Math.floor(seconds / 3600);
|
|
8
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
9
|
+
const s = Math.floor(seconds % 60);
|
|
10
|
+
if (h > 0)
|
|
11
|
+
return `${h}h ${m}m`;
|
|
12
|
+
if (m > 0)
|
|
13
|
+
return `${m}m ${s}s`;
|
|
14
|
+
return `${s}s`;
|
|
15
|
+
}
|
|
16
|
+
function formatTimestamp(ts) {
|
|
17
|
+
const m = Math.floor(ts / 60);
|
|
18
|
+
const s = Math.floor(ts % 60);
|
|
19
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
function statusBadge(status) {
|
|
22
|
+
switch (status) {
|
|
23
|
+
case "completed":
|
|
24
|
+
return chalk.green(status);
|
|
25
|
+
case "recording":
|
|
26
|
+
return chalk.red(status);
|
|
27
|
+
case "processing":
|
|
28
|
+
return chalk.yellow(status);
|
|
29
|
+
case "failed":
|
|
30
|
+
return chalk.red(status);
|
|
31
|
+
default:
|
|
32
|
+
return chalk.dim(status);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function resolveShortId(token, prefix, query) {
|
|
36
|
+
if (prefix.includes("-") && prefix.length > 8)
|
|
37
|
+
return prefix;
|
|
38
|
+
const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
return prefix;
|
|
41
|
+
const data = (await res.json());
|
|
42
|
+
const matches = data.meetings.filter((m) => m.meetingId.startsWith(prefix));
|
|
43
|
+
if (matches.length === 1)
|
|
44
|
+
return matches[0].meetingId;
|
|
45
|
+
if (matches.length > 1) {
|
|
46
|
+
console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.length} meetings. Use a longer prefix.`));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
return prefix;
|
|
50
|
+
}
|
|
51
|
+
async function handleApiError(res) {
|
|
52
|
+
const body = (await res.json().catch(() => ({})));
|
|
53
|
+
if (res.status === 401) {
|
|
54
|
+
console.error(chalk.red("Not authenticated — run `hq login` first"));
|
|
55
|
+
}
|
|
56
|
+
else if (res.status === 403) {
|
|
57
|
+
console.error(chalk.red(body.error ?? "Not authorized"));
|
|
58
|
+
}
|
|
59
|
+
else if (res.status === 404) {
|
|
60
|
+
console.error(chalk.red(body.error ?? "Not found"));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.error(chalk.red(`API error (${res.status}): ${body.error ?? res.statusText}`));
|
|
64
|
+
}
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
function printMeetingTable(meetings) {
|
|
68
|
+
if (meetings.length === 0) {
|
|
69
|
+
console.log(chalk.dim(" No meetings found."));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const ID_W = 8;
|
|
73
|
+
const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => m.title.length)));
|
|
74
|
+
const DATE_W = 16;
|
|
75
|
+
const DUR_W = 8;
|
|
76
|
+
const STATUS_W = 12;
|
|
77
|
+
const PARTS_W = 6;
|
|
78
|
+
const FLAGS_W = 5;
|
|
79
|
+
console.log(chalk.bold([
|
|
80
|
+
"ID".padEnd(ID_W),
|
|
81
|
+
"TITLE".padEnd(TITLE_W),
|
|
82
|
+
"DATE".padEnd(DATE_W),
|
|
83
|
+
"DUR".padEnd(DUR_W),
|
|
84
|
+
"STATUS".padEnd(STATUS_W),
|
|
85
|
+
"PARTS".padEnd(PARTS_W),
|
|
86
|
+
"FLAGS",
|
|
87
|
+
].join(" ")));
|
|
88
|
+
for (const m of meetings) {
|
|
89
|
+
const id = m.meetingId.slice(0, 8);
|
|
90
|
+
const title = m.title.length > TITLE_W ? m.title.slice(0, TITLE_W - 1) + "…" : m.title;
|
|
91
|
+
const date = new Date(m.startTime).toLocaleDateString("en-US", {
|
|
92
|
+
month: "short",
|
|
93
|
+
day: "numeric",
|
|
94
|
+
hour: "2-digit",
|
|
95
|
+
minute: "2-digit",
|
|
96
|
+
});
|
|
97
|
+
const dur = formatDuration(m.duration);
|
|
98
|
+
const flags = [
|
|
99
|
+
m.hasTranscript ? "T" : "",
|
|
100
|
+
m.hasNotes ? "N" : "",
|
|
101
|
+
].filter(Boolean).join("") || "-";
|
|
102
|
+
console.log([
|
|
103
|
+
chalk.cyan(id.padEnd(ID_W)),
|
|
104
|
+
title.padEnd(TITLE_W),
|
|
105
|
+
chalk.dim(date.padEnd(DATE_W)),
|
|
106
|
+
dur.padEnd(DUR_W),
|
|
107
|
+
statusBadge(m.status).padEnd(STATUS_W + 10), // chalk adds escape chars
|
|
108
|
+
String(m.participantCount).padEnd(PARTS_W),
|
|
109
|
+
flags,
|
|
110
|
+
].join(" "));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function registerMeetingsCommand(program) {
|
|
114
|
+
const meetings = program
|
|
115
|
+
.command("meetings")
|
|
116
|
+
.description("View and search meeting recordings, transcripts, and notes")
|
|
117
|
+
.option("--company <slug>", "Company slug (for multi-company users)")
|
|
118
|
+
.option("--json", "Output raw JSON instead of formatted text");
|
|
119
|
+
// ── hq meetings list ──────────────────────────────────────────────
|
|
120
|
+
meetings
|
|
121
|
+
.command("list")
|
|
122
|
+
.description("List recorded meetings (newest first)")
|
|
123
|
+
.option("--limit <n>", "Number of meetings to return (default: 20)")
|
|
124
|
+
.option("--next <token>", "Pagination token from a previous response")
|
|
125
|
+
.action(async (opts) => {
|
|
126
|
+
try {
|
|
127
|
+
const token = await ensureCognitoToken();
|
|
128
|
+
const query = {};
|
|
129
|
+
const companySlug = meetings.opts().company;
|
|
130
|
+
if (opts.limit)
|
|
131
|
+
query.limit = opts.limit;
|
|
132
|
+
if (opts.next)
|
|
133
|
+
query.nextToken = opts.next;
|
|
134
|
+
if (companySlug)
|
|
135
|
+
query.companyId = companySlug;
|
|
136
|
+
const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
|
|
137
|
+
if (!res.ok)
|
|
138
|
+
await handleApiError(res);
|
|
139
|
+
const data = (await res.json());
|
|
140
|
+
if (meetings.opts().json) {
|
|
141
|
+
console.log(JSON.stringify(data, null, 2));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(chalk.bold(`\nMeetings (${data.meetings.length}):\n`));
|
|
145
|
+
printMeetingTable(data.meetings);
|
|
146
|
+
if (data.nextToken) {
|
|
147
|
+
console.log(chalk.dim(`\n More results available. Run with --next ${data.nextToken}`));
|
|
148
|
+
}
|
|
149
|
+
console.log();
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
// ── hq meetings get <id> ──────────────────────────────────────────
|
|
157
|
+
meetings
|
|
158
|
+
.command("get <meetingId>")
|
|
159
|
+
.description("Show meeting details")
|
|
160
|
+
.action(async (rawId) => {
|
|
161
|
+
try {
|
|
162
|
+
const token = await ensureCognitoToken();
|
|
163
|
+
const query = {};
|
|
164
|
+
const companySlug = meetings.opts().company;
|
|
165
|
+
if (companySlug)
|
|
166
|
+
query.companyId = companySlug;
|
|
167
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
168
|
+
const res = await vaultApiFetch({
|
|
169
|
+
token,
|
|
170
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
171
|
+
query,
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok)
|
|
174
|
+
await handleApiError(res);
|
|
175
|
+
const detail = (await res.json());
|
|
176
|
+
if (meetings.opts().json) {
|
|
177
|
+
console.log(JSON.stringify(detail, null, 2));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.log(chalk.bold(`\n${detail.title}\n`));
|
|
181
|
+
console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
|
|
182
|
+
console.log(` Status: ${statusBadge(detail.status)}`);
|
|
183
|
+
console.log(` Date: ${new Date(detail.startTime).toLocaleString()}`);
|
|
184
|
+
console.log(` Duration: ${formatDuration(detail.duration)}`);
|
|
185
|
+
console.log(` Source: ${detail.sourceApp} (${detail.botProvider})`);
|
|
186
|
+
console.log(` Shared: ${detail.isShared ? "yes" : "no"}`);
|
|
187
|
+
if (detail.participants.length > 0) {
|
|
188
|
+
console.log(chalk.bold("\n Participants:"));
|
|
189
|
+
for (const p of detail.participants) {
|
|
190
|
+
const name = p.name ?? p.email;
|
|
191
|
+
const role = p.role === "organizer" ? chalk.yellow(" (organizer)") : "";
|
|
192
|
+
console.log(` - ${name}${role}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const flags = [];
|
|
196
|
+
if (detail.hasTranscript)
|
|
197
|
+
flags.push("transcript");
|
|
198
|
+
if (detail.hasNotes)
|
|
199
|
+
flags.push("notes");
|
|
200
|
+
if (flags.length > 0) {
|
|
201
|
+
console.log(chalk.dim(`\n Available: ${flags.join(", ")}. Use \`hq meetings transcript ${detail.meetingId.slice(0, 8)}\` or \`hq meetings notes ${detail.meetingId.slice(0, 8)}\`.`));
|
|
202
|
+
}
|
|
203
|
+
console.log();
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
// ── hq meetings search <query> ─────────────────────────────────────
|
|
211
|
+
meetings
|
|
212
|
+
.command("search <query>")
|
|
213
|
+
.description("Search meetings by title or participant name")
|
|
214
|
+
.action(async (query) => {
|
|
215
|
+
try {
|
|
216
|
+
const token = await ensureCognitoToken();
|
|
217
|
+
const params = { q: query };
|
|
218
|
+
const companySlug = meetings.opts().company;
|
|
219
|
+
if (companySlug)
|
|
220
|
+
params.companyId = companySlug;
|
|
221
|
+
const res = await vaultApiFetch({
|
|
222
|
+
token,
|
|
223
|
+
path: "/v1/meetings/search",
|
|
224
|
+
query: params,
|
|
225
|
+
});
|
|
226
|
+
if (!res.ok)
|
|
227
|
+
await handleApiError(res);
|
|
228
|
+
const data = (await res.json());
|
|
229
|
+
if (meetings.opts().json) {
|
|
230
|
+
console.log(JSON.stringify(data, null, 2));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
console.log(chalk.bold(`\nSearch results for "${data.query}" (${data.results.length}):\n`));
|
|
234
|
+
printMeetingTable(data.results);
|
|
235
|
+
console.log();
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
// ── hq meetings transcript <id> ────────────────────────────────────
|
|
243
|
+
meetings
|
|
244
|
+
.command("transcript <meetingId>")
|
|
245
|
+
.description("Print the meeting transcript")
|
|
246
|
+
.action(async (rawId) => {
|
|
247
|
+
try {
|
|
248
|
+
const token = await ensureCognitoToken();
|
|
249
|
+
const query = {};
|
|
250
|
+
const companySlug = meetings.opts().company;
|
|
251
|
+
if (companySlug)
|
|
252
|
+
query.companyId = companySlug;
|
|
253
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
254
|
+
const res = await vaultApiFetch({
|
|
255
|
+
token,
|
|
256
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
257
|
+
query,
|
|
258
|
+
});
|
|
259
|
+
if (!res.ok)
|
|
260
|
+
await handleApiError(res);
|
|
261
|
+
const detail = (await res.json());
|
|
262
|
+
if (!detail.documentUrl) {
|
|
263
|
+
console.error(chalk.red("No document URL available for this meeting."));
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
const docRes = await fetch(detail.documentUrl);
|
|
267
|
+
if (!docRes.ok) {
|
|
268
|
+
console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
const doc = (await docRes.json());
|
|
272
|
+
if (meetings.opts().json) {
|
|
273
|
+
console.log(JSON.stringify(doc.transcript, null, 2));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!doc.transcript || doc.transcript.length === 0) {
|
|
277
|
+
console.log(chalk.yellow("No transcript available for this meeting."));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
console.log(chalk.bold(`\nTranscript: ${doc.title}\n`));
|
|
281
|
+
for (const seg of doc.transcript) {
|
|
282
|
+
const time = formatTimestamp(seg.startTime);
|
|
283
|
+
console.log(`${chalk.dim(time)} ${chalk.cyan(seg.speaker)}`);
|
|
284
|
+
console.log(` ${seg.text}\n`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
// ── hq meetings notes <id> ─────────────────────────────────────────
|
|
293
|
+
meetings
|
|
294
|
+
.command("notes <meetingId>")
|
|
295
|
+
.description("Print AI-generated meeting notes")
|
|
296
|
+
.action(async (rawId) => {
|
|
297
|
+
try {
|
|
298
|
+
const token = await ensureCognitoToken();
|
|
299
|
+
const query = {};
|
|
300
|
+
const companySlug = meetings.opts().company;
|
|
301
|
+
if (companySlug)
|
|
302
|
+
query.companyId = companySlug;
|
|
303
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
304
|
+
const res = await vaultApiFetch({
|
|
305
|
+
token,
|
|
306
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
307
|
+
query,
|
|
308
|
+
});
|
|
309
|
+
if (!res.ok)
|
|
310
|
+
await handleApiError(res);
|
|
311
|
+
const detail = (await res.json());
|
|
312
|
+
if (!detail.documentUrl) {
|
|
313
|
+
console.error(chalk.red("No document URL available for this meeting."));
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
const docRes = await fetch(detail.documentUrl);
|
|
317
|
+
if (!docRes.ok) {
|
|
318
|
+
console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
const doc = (await docRes.json());
|
|
322
|
+
if (meetings.opts().json) {
|
|
323
|
+
console.log(JSON.stringify(doc.notes, null, 2));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!doc.notes) {
|
|
327
|
+
console.log(chalk.yellow("No notes available for this meeting."));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const notes = doc.notes;
|
|
331
|
+
console.log(chalk.bold(`\nMeeting Notes: ${doc.title}\n`));
|
|
332
|
+
console.log(chalk.bold("Summary"));
|
|
333
|
+
console.log(` ${notes.summary}\n`);
|
|
334
|
+
if (notes.keyPoints.length > 0) {
|
|
335
|
+
console.log(chalk.bold("Key Points"));
|
|
336
|
+
for (const point of notes.keyPoints) {
|
|
337
|
+
console.log(` • ${point}`);
|
|
338
|
+
}
|
|
339
|
+
console.log();
|
|
340
|
+
}
|
|
341
|
+
if (notes.decisions.length > 0) {
|
|
342
|
+
console.log(chalk.bold("Decisions"));
|
|
343
|
+
for (const d of notes.decisions) {
|
|
344
|
+
console.log(` ✓ ${d}`);
|
|
345
|
+
}
|
|
346
|
+
console.log();
|
|
347
|
+
}
|
|
348
|
+
if (notes.actionItems.length > 0) {
|
|
349
|
+
console.log(chalk.bold("Action Items"));
|
|
350
|
+
for (const item of notes.actionItems) {
|
|
351
|
+
const assignee = item.assignee ? chalk.dim(` → ${item.assignee}`) : "";
|
|
352
|
+
console.log(` □ ${item.task}${assignee}`);
|
|
353
|
+
}
|
|
354
|
+
console.log();
|
|
355
|
+
}
|
|
356
|
+
if (notes.participantContributions.length > 0) {
|
|
357
|
+
console.log(chalk.bold("Participant Contributions"));
|
|
358
|
+
for (const p of notes.participantContributions) {
|
|
359
|
+
console.log(` ${p.name} (${p.speakingTimePercent}%)`);
|
|
360
|
+
console.log(` ${chalk.dim(p.topicsSummary)}`);
|
|
361
|
+
}
|
|
362
|
+
console.log();
|
|
363
|
+
}
|
|
364
|
+
console.log(chalk.dim(`Generated by ${notes.model} at ${notes.generatedAt}`));
|
|
365
|
+
console.log();
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=meetings.js.map
|
|
374
|
+
//# debugId=8237a835-5842-52be-bb27-c4ffb7944d89
|
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]="15889dc3-5d7e-5fb9-8df3-33d5aebdc945")}catch(e){}}();
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { initSentry, Sentry } from "./sentry.js";
|
|
9
9
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -29,6 +29,7 @@ import { registerGroupsCommand } from "./commands/groups.js";
|
|
|
29
29
|
import { registerFilesCommand } from "./commands/files.js";
|
|
30
30
|
import { registerMembersCommand } from "./commands/members.js";
|
|
31
31
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
32
|
+
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
32
33
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
33
34
|
import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
|
|
34
35
|
import { CLI_VERSION } from "./cli-version.js";
|
|
@@ -102,6 +103,8 @@ registerMembersCommand(program);
|
|
|
102
103
|
registerOnboardCommand(program);
|
|
103
104
|
// Feedback (subcommand group — hq feedback bug|feature)
|
|
104
105
|
registerFeedbackCommand(program);
|
|
106
|
+
// Meetings (subcommand group — hq meetings list|get|search|transcript|notes)
|
|
107
|
+
registerMeetingsCommand(program);
|
|
105
108
|
(async () => {
|
|
106
109
|
try {
|
|
107
110
|
Sentry.addBreadcrumb({
|
|
@@ -120,4 +123,4 @@ registerFeedbackCommand(program);
|
|
|
120
123
|
}
|
|
121
124
|
})();
|
|
122
125
|
//# sourceMappingURL=index.js.map
|
|
123
|
-
//# debugId=
|
|
126
|
+
//# debugId=15889dc3-5d7e-5fb9-8df3-33d5aebdc945
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.15.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/js-yaml": "^4.0.9",
|
|
31
31
|
"@types/node": "^22.0.0",
|
|
32
|
+
"@types/semver": "^7.5.8",
|
|
32
33
|
"typescript": "^5.7.0",
|
|
33
|
-
"
|
|
34
|
+
"vitest": "^4.1.2"
|
|
34
35
|
},
|
|
35
36
|
"repository": {
|
|
36
37
|
"type": "git",
|
|
@@ -236,6 +236,22 @@ describe("ensureManifestEntryForProvision", () => {
|
|
|
236
236
|
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrow();
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
it("does NOT insert when companies/<slug> exists as a regular file (not a dir)", () => {
|
|
240
|
+
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
241
|
+
// Create a regular file at companies/indigo (not a directory). Without
|
|
242
|
+
// the isDirectory() guard, existsSync would return true and the helper
|
|
243
|
+
// would auto-insert — then provisionCompany would create the vault
|
|
244
|
+
// entity + patch manifest BEFORE writeCompanyConfig's mkdir failed with
|
|
245
|
+
// ENOTDIR. Verify no mutation.
|
|
246
|
+
const companiesDir = path.join(tmpRoot, "companies");
|
|
247
|
+
fs.mkdirSync(companiesDir, { recursive: true });
|
|
248
|
+
fs.writeFileSync(path.join(companiesDir, "indigo"), "not a directory");
|
|
249
|
+
const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
250
|
+
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
251
|
+
const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
252
|
+
expect(after).toBe(before);
|
|
253
|
+
});
|
|
254
|
+
|
|
239
255
|
it("does NOT insert when manifest file is missing entirely", () => {
|
|
240
256
|
seedCompanyDir(tmpRoot, "indigo");
|
|
241
257
|
expect(() =>
|
|
@@ -302,7 +302,19 @@ export function ensureManifestEntryForProvision(
|
|
|
302
302
|
if (expectedParent !== companiesDir) return;
|
|
303
303
|
if (path.basename(expected) !== slug) return;
|
|
304
304
|
const dir = companyDirPath(hqRoot, slug);
|
|
305
|
-
|
|
305
|
+
// Must be an actual directory, not a stray file. fs.existsSync returns true
|
|
306
|
+
// for regular files too — without this guard, auto-insert would fire on a
|
|
307
|
+
// file at `companies/<slug>`, provisionCompany would then create the vault
|
|
308
|
+
// entity and patch manifest.yaml before writeCompanyConfig's `mkdir -p .hq`
|
|
309
|
+
// exploded with ENOTDIR. statSync swallows the not-found case so a missing
|
|
310
|
+
// path is treated the same as before (no auto-insert).
|
|
311
|
+
let dirStat: fs.Stats;
|
|
312
|
+
try {
|
|
313
|
+
dirStat = fs.statSync(dir);
|
|
314
|
+
} catch {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (!dirStat.isDirectory()) return;
|
|
306
318
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
307
319
|
let parsed: unknown;
|
|
308
320
|
try {
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
5
|
+
|
|
6
|
+
interface MeetingListItem {
|
|
7
|
+
meetingId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
startTime: string;
|
|
10
|
+
endTime: string;
|
|
11
|
+
duration: number;
|
|
12
|
+
participantCount: number;
|
|
13
|
+
status: string;
|
|
14
|
+
hasTranscript: boolean;
|
|
15
|
+
hasNotes: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MeetingDetail {
|
|
19
|
+
meetingId: string;
|
|
20
|
+
title: string;
|
|
21
|
+
startTime: string;
|
|
22
|
+
endTime: string;
|
|
23
|
+
duration: number;
|
|
24
|
+
participants: Array<{ email: string; name: string | null; role: string }>;
|
|
25
|
+
calendarEventId: string | null;
|
|
26
|
+
botProvider: string;
|
|
27
|
+
sourceApp: string;
|
|
28
|
+
companyId: string;
|
|
29
|
+
status: string;
|
|
30
|
+
recallBotId: string | null;
|
|
31
|
+
isShared: boolean;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
documentUrl: string;
|
|
35
|
+
hasTranscript: boolean;
|
|
36
|
+
hasNotes: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface TranscriptSegment {
|
|
40
|
+
speaker: string;
|
|
41
|
+
text: string;
|
|
42
|
+
startTime: number;
|
|
43
|
+
endTime: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MeetingNotes {
|
|
47
|
+
summary: string;
|
|
48
|
+
keyPoints: string[];
|
|
49
|
+
decisions: string[];
|
|
50
|
+
actionItems: Array<{ task: string; assignee: string | null }>;
|
|
51
|
+
participantContributions: Array<{
|
|
52
|
+
name: string;
|
|
53
|
+
speakingTimePercent: number;
|
|
54
|
+
topicsSummary: string;
|
|
55
|
+
}>;
|
|
56
|
+
model: string;
|
|
57
|
+
generatedAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface MeetingDocument {
|
|
61
|
+
meetingId: string;
|
|
62
|
+
title: string;
|
|
63
|
+
startTime: string;
|
|
64
|
+
endTime: string;
|
|
65
|
+
duration: number;
|
|
66
|
+
participants: Array<{ email: string; name: string | null; role: string }>;
|
|
67
|
+
status: string;
|
|
68
|
+
transcript: TranscriptSegment[] | null;
|
|
69
|
+
notes: MeetingNotes | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatDuration(seconds: number): string {
|
|
73
|
+
const h = Math.floor(seconds / 3600);
|
|
74
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
75
|
+
const s = Math.floor(seconds % 60);
|
|
76
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
77
|
+
if (m > 0) return `${m}m ${s}s`;
|
|
78
|
+
return `${s}s`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatTimestamp(ts: number): string {
|
|
82
|
+
const m = Math.floor(ts / 60);
|
|
83
|
+
const s = Math.floor(ts % 60);
|
|
84
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function statusBadge(status: string): string {
|
|
88
|
+
switch (status) {
|
|
89
|
+
case "completed":
|
|
90
|
+
return chalk.green(status);
|
|
91
|
+
case "recording":
|
|
92
|
+
return chalk.red(status);
|
|
93
|
+
case "processing":
|
|
94
|
+
return chalk.yellow(status);
|
|
95
|
+
case "failed":
|
|
96
|
+
return chalk.red(status);
|
|
97
|
+
default:
|
|
98
|
+
return chalk.dim(status);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function resolveShortId(
|
|
103
|
+
token: string,
|
|
104
|
+
prefix: string,
|
|
105
|
+
query: Record<string, string>,
|
|
106
|
+
): Promise<string> {
|
|
107
|
+
if (prefix.includes("-") && prefix.length > 8) return prefix;
|
|
108
|
+
const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
|
|
109
|
+
if (!res.ok) return prefix;
|
|
110
|
+
const data = (await res.json()) as { meetings: MeetingListItem[] };
|
|
111
|
+
const matches = data.meetings.filter((m) => m.meetingId.startsWith(prefix));
|
|
112
|
+
if (matches.length === 1) return matches[0].meetingId;
|
|
113
|
+
if (matches.length > 1) {
|
|
114
|
+
console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.length} meetings. Use a longer prefix.`));
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
return prefix;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function handleApiError(res: Response): Promise<never> {
|
|
121
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
122
|
+
if (res.status === 401) {
|
|
123
|
+
console.error(chalk.red("Not authenticated — run `hq login` first"));
|
|
124
|
+
} else if (res.status === 403) {
|
|
125
|
+
console.error(chalk.red(body.error ?? "Not authorized"));
|
|
126
|
+
} else if (res.status === 404) {
|
|
127
|
+
console.error(chalk.red(body.error ?? "Not found"));
|
|
128
|
+
} else {
|
|
129
|
+
console.error(chalk.red(`API error (${res.status}): ${body.error ?? res.statusText}`));
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function printMeetingTable(meetings: MeetingListItem[]): void {
|
|
135
|
+
if (meetings.length === 0) {
|
|
136
|
+
console.log(chalk.dim(" No meetings found."));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const ID_W = 8;
|
|
141
|
+
const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => m.title.length)));
|
|
142
|
+
const DATE_W = 16;
|
|
143
|
+
const DUR_W = 8;
|
|
144
|
+
const STATUS_W = 12;
|
|
145
|
+
const PARTS_W = 6;
|
|
146
|
+
const FLAGS_W = 5;
|
|
147
|
+
|
|
148
|
+
console.log(
|
|
149
|
+
chalk.bold(
|
|
150
|
+
[
|
|
151
|
+
"ID".padEnd(ID_W),
|
|
152
|
+
"TITLE".padEnd(TITLE_W),
|
|
153
|
+
"DATE".padEnd(DATE_W),
|
|
154
|
+
"DUR".padEnd(DUR_W),
|
|
155
|
+
"STATUS".padEnd(STATUS_W),
|
|
156
|
+
"PARTS".padEnd(PARTS_W),
|
|
157
|
+
"FLAGS",
|
|
158
|
+
].join(" "),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
for (const m of meetings) {
|
|
163
|
+
const id = m.meetingId.slice(0, 8);
|
|
164
|
+
const title = m.title.length > TITLE_W ? m.title.slice(0, TITLE_W - 1) + "…" : m.title;
|
|
165
|
+
const date = new Date(m.startTime).toLocaleDateString("en-US", {
|
|
166
|
+
month: "short",
|
|
167
|
+
day: "numeric",
|
|
168
|
+
hour: "2-digit",
|
|
169
|
+
minute: "2-digit",
|
|
170
|
+
});
|
|
171
|
+
const dur = formatDuration(m.duration);
|
|
172
|
+
const flags = [
|
|
173
|
+
m.hasTranscript ? "T" : "",
|
|
174
|
+
m.hasNotes ? "N" : "",
|
|
175
|
+
].filter(Boolean).join("") || "-";
|
|
176
|
+
|
|
177
|
+
console.log(
|
|
178
|
+
[
|
|
179
|
+
chalk.cyan(id.padEnd(ID_W)),
|
|
180
|
+
title.padEnd(TITLE_W),
|
|
181
|
+
chalk.dim(date.padEnd(DATE_W)),
|
|
182
|
+
dur.padEnd(DUR_W),
|
|
183
|
+
statusBadge(m.status).padEnd(STATUS_W + 10), // chalk adds escape chars
|
|
184
|
+
String(m.participantCount).padEnd(PARTS_W),
|
|
185
|
+
flags,
|
|
186
|
+
].join(" "),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function registerMeetingsCommand(program: Command): void {
|
|
192
|
+
const meetings = program
|
|
193
|
+
.command("meetings")
|
|
194
|
+
.description("View and search meeting recordings, transcripts, and notes")
|
|
195
|
+
.option("--company <slug>", "Company slug (for multi-company users)")
|
|
196
|
+
.option("--json", "Output raw JSON instead of formatted text");
|
|
197
|
+
|
|
198
|
+
// ── hq meetings list ──────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
meetings
|
|
201
|
+
.command("list")
|
|
202
|
+
.description("List recorded meetings (newest first)")
|
|
203
|
+
.option("--limit <n>", "Number of meetings to return (default: 20)")
|
|
204
|
+
.option("--next <token>", "Pagination token from a previous response")
|
|
205
|
+
.action(async (opts: { limit?: string; next?: string }) => {
|
|
206
|
+
try {
|
|
207
|
+
const token = await ensureCognitoToken();
|
|
208
|
+
const query: Record<string, string> = {};
|
|
209
|
+
const companySlug = meetings.opts().company as string | undefined;
|
|
210
|
+
|
|
211
|
+
if (opts.limit) query.limit = opts.limit;
|
|
212
|
+
if (opts.next) query.nextToken = opts.next;
|
|
213
|
+
if (companySlug) query.companyId = companySlug;
|
|
214
|
+
|
|
215
|
+
const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
|
|
216
|
+
if (!res.ok) await handleApiError(res);
|
|
217
|
+
|
|
218
|
+
const data = (await res.json()) as {
|
|
219
|
+
meetings: MeetingListItem[];
|
|
220
|
+
nextToken?: string;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (meetings.opts().json) {
|
|
224
|
+
console.log(JSON.stringify(data, null, 2));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log(chalk.bold(`\nMeetings (${data.meetings.length}):\n`));
|
|
229
|
+
printMeetingTable(data.meetings);
|
|
230
|
+
|
|
231
|
+
if (data.nextToken) {
|
|
232
|
+
console.log(
|
|
233
|
+
chalk.dim(`\n More results available. Run with --next ${data.nextToken}`),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
console.log();
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── hq meetings get <id> ──────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
meetings
|
|
246
|
+
.command("get <meetingId>")
|
|
247
|
+
.description("Show meeting details")
|
|
248
|
+
.action(async (rawId: string) => {
|
|
249
|
+
try {
|
|
250
|
+
const token = await ensureCognitoToken();
|
|
251
|
+
const query: Record<string, string> = {};
|
|
252
|
+
const companySlug = meetings.opts().company as string | undefined;
|
|
253
|
+
if (companySlug) query.companyId = companySlug;
|
|
254
|
+
|
|
255
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
256
|
+
const res = await vaultApiFetch({
|
|
257
|
+
token,
|
|
258
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
259
|
+
query,
|
|
260
|
+
});
|
|
261
|
+
if (!res.ok) await handleApiError(res);
|
|
262
|
+
|
|
263
|
+
const detail = (await res.json()) as MeetingDetail;
|
|
264
|
+
|
|
265
|
+
if (meetings.opts().json) {
|
|
266
|
+
console.log(JSON.stringify(detail, null, 2));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log(chalk.bold(`\n${detail.title}\n`));
|
|
271
|
+
console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
|
|
272
|
+
console.log(` Status: ${statusBadge(detail.status)}`);
|
|
273
|
+
console.log(` Date: ${new Date(detail.startTime).toLocaleString()}`);
|
|
274
|
+
console.log(` Duration: ${formatDuration(detail.duration)}`);
|
|
275
|
+
console.log(` Source: ${detail.sourceApp} (${detail.botProvider})`);
|
|
276
|
+
console.log(` Shared: ${detail.isShared ? "yes" : "no"}`);
|
|
277
|
+
|
|
278
|
+
if (detail.participants.length > 0) {
|
|
279
|
+
console.log(chalk.bold("\n Participants:"));
|
|
280
|
+
for (const p of detail.participants) {
|
|
281
|
+
const name = p.name ?? p.email;
|
|
282
|
+
const role = p.role === "organizer" ? chalk.yellow(" (organizer)") : "";
|
|
283
|
+
console.log(` - ${name}${role}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const flags = [];
|
|
288
|
+
if (detail.hasTranscript) flags.push("transcript");
|
|
289
|
+
if (detail.hasNotes) flags.push("notes");
|
|
290
|
+
if (flags.length > 0) {
|
|
291
|
+
console.log(
|
|
292
|
+
chalk.dim(`\n Available: ${flags.join(", ")}. Use \`hq meetings transcript ${detail.meetingId.slice(0, 8)}\` or \`hq meetings notes ${detail.meetingId.slice(0, 8)}\`.`),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
console.log();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ── hq meetings search <query> ─────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
meetings
|
|
305
|
+
.command("search <query>")
|
|
306
|
+
.description("Search meetings by title or participant name")
|
|
307
|
+
.action(async (query: string) => {
|
|
308
|
+
try {
|
|
309
|
+
const token = await ensureCognitoToken();
|
|
310
|
+
const params: Record<string, string> = { q: query };
|
|
311
|
+
const companySlug = meetings.opts().company as string | undefined;
|
|
312
|
+
if (companySlug) params.companyId = companySlug;
|
|
313
|
+
|
|
314
|
+
const res = await vaultApiFetch({
|
|
315
|
+
token,
|
|
316
|
+
path: "/v1/meetings/search",
|
|
317
|
+
query: params,
|
|
318
|
+
});
|
|
319
|
+
if (!res.ok) await handleApiError(res);
|
|
320
|
+
|
|
321
|
+
const data = (await res.json()) as {
|
|
322
|
+
results: MeetingListItem[];
|
|
323
|
+
query: string;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
if (meetings.opts().json) {
|
|
327
|
+
console.log(JSON.stringify(data, null, 2));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
console.log(chalk.bold(`\nSearch results for "${data.query}" (${data.results.length}):\n`));
|
|
332
|
+
printMeetingTable(data.results);
|
|
333
|
+
console.log();
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ── hq meetings transcript <id> ────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
meetings
|
|
343
|
+
.command("transcript <meetingId>")
|
|
344
|
+
.description("Print the meeting transcript")
|
|
345
|
+
.action(async (rawId: string) => {
|
|
346
|
+
try {
|
|
347
|
+
const token = await ensureCognitoToken();
|
|
348
|
+
const query: Record<string, string> = {};
|
|
349
|
+
const companySlug = meetings.opts().company as string | undefined;
|
|
350
|
+
if (companySlug) query.companyId = companySlug;
|
|
351
|
+
|
|
352
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
353
|
+
const res = await vaultApiFetch({
|
|
354
|
+
token,
|
|
355
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
356
|
+
query,
|
|
357
|
+
});
|
|
358
|
+
if (!res.ok) await handleApiError(res);
|
|
359
|
+
|
|
360
|
+
const detail = (await res.json()) as MeetingDetail;
|
|
361
|
+
if (!detail.documentUrl) {
|
|
362
|
+
console.error(chalk.red("No document URL available for this meeting."));
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const docRes = await fetch(detail.documentUrl);
|
|
367
|
+
if (!docRes.ok) {
|
|
368
|
+
console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const doc = (await docRes.json()) as MeetingDocument;
|
|
373
|
+
|
|
374
|
+
if (meetings.opts().json) {
|
|
375
|
+
console.log(JSON.stringify(doc.transcript, null, 2));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!doc.transcript || doc.transcript.length === 0) {
|
|
380
|
+
console.log(chalk.yellow("No transcript available for this meeting."));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
console.log(chalk.bold(`\nTranscript: ${doc.title}\n`));
|
|
385
|
+
for (const seg of doc.transcript) {
|
|
386
|
+
const time = formatTimestamp(seg.startTime);
|
|
387
|
+
console.log(`${chalk.dim(time)} ${chalk.cyan(seg.speaker)}`);
|
|
388
|
+
console.log(` ${seg.text}\n`);
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── hq meetings notes <id> ─────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
meetings
|
|
399
|
+
.command("notes <meetingId>")
|
|
400
|
+
.description("Print AI-generated meeting notes")
|
|
401
|
+
.action(async (rawId: string) => {
|
|
402
|
+
try {
|
|
403
|
+
const token = await ensureCognitoToken();
|
|
404
|
+
const query: Record<string, string> = {};
|
|
405
|
+
const companySlug = meetings.opts().company as string | undefined;
|
|
406
|
+
if (companySlug) query.companyId = companySlug;
|
|
407
|
+
|
|
408
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
409
|
+
const res = await vaultApiFetch({
|
|
410
|
+
token,
|
|
411
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
412
|
+
query,
|
|
413
|
+
});
|
|
414
|
+
if (!res.ok) await handleApiError(res);
|
|
415
|
+
|
|
416
|
+
const detail = (await res.json()) as MeetingDetail;
|
|
417
|
+
if (!detail.documentUrl) {
|
|
418
|
+
console.error(chalk.red("No document URL available for this meeting."));
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const docRes = await fetch(detail.documentUrl);
|
|
423
|
+
if (!docRes.ok) {
|
|
424
|
+
console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const doc = (await docRes.json()) as MeetingDocument;
|
|
429
|
+
|
|
430
|
+
if (meetings.opts().json) {
|
|
431
|
+
console.log(JSON.stringify(doc.notes, null, 2));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!doc.notes) {
|
|
436
|
+
console.log(chalk.yellow("No notes available for this meeting."));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const notes = doc.notes;
|
|
441
|
+
console.log(chalk.bold(`\nMeeting Notes: ${doc.title}\n`));
|
|
442
|
+
|
|
443
|
+
console.log(chalk.bold("Summary"));
|
|
444
|
+
console.log(` ${notes.summary}\n`);
|
|
445
|
+
|
|
446
|
+
if (notes.keyPoints.length > 0) {
|
|
447
|
+
console.log(chalk.bold("Key Points"));
|
|
448
|
+
for (const point of notes.keyPoints) {
|
|
449
|
+
console.log(` • ${point}`);
|
|
450
|
+
}
|
|
451
|
+
console.log();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (notes.decisions.length > 0) {
|
|
455
|
+
console.log(chalk.bold("Decisions"));
|
|
456
|
+
for (const d of notes.decisions) {
|
|
457
|
+
console.log(` ✓ ${d}`);
|
|
458
|
+
}
|
|
459
|
+
console.log();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (notes.actionItems.length > 0) {
|
|
463
|
+
console.log(chalk.bold("Action Items"));
|
|
464
|
+
for (const item of notes.actionItems) {
|
|
465
|
+
const assignee = item.assignee ? chalk.dim(` → ${item.assignee}`) : "";
|
|
466
|
+
console.log(` □ ${item.task}${assignee}`);
|
|
467
|
+
}
|
|
468
|
+
console.log();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (notes.participantContributions.length > 0) {
|
|
472
|
+
console.log(chalk.bold("Participant Contributions"));
|
|
473
|
+
for (const p of notes.participantContributions) {
|
|
474
|
+
console.log(` ${p.name} (${p.speakingTimePercent}%)`);
|
|
475
|
+
console.log(` ${chalk.dim(p.topicsSummary)}`);
|
|
476
|
+
}
|
|
477
|
+
console.log();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
console.log(chalk.dim(`Generated by ${notes.model} at ${notes.generatedAt}`));
|
|
481
|
+
console.log();
|
|
482
|
+
} catch (err) {
|
|
483
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { registerGroupsCommand } from "./commands/groups.js";
|
|
|
29
29
|
import { registerFilesCommand } from "./commands/files.js";
|
|
30
30
|
import { registerMembersCommand } from "./commands/members.js";
|
|
31
31
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
32
|
+
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
32
33
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
33
34
|
import {
|
|
34
35
|
maybeWarnNewVersion,
|
|
@@ -130,6 +131,9 @@ registerOnboardCommand(program);
|
|
|
130
131
|
// Feedback (subcommand group — hq feedback bug|feature)
|
|
131
132
|
registerFeedbackCommand(program);
|
|
132
133
|
|
|
134
|
+
// Meetings (subcommand group — hq meetings list|get|search|transcript|notes)
|
|
135
|
+
registerMeetingsCommand(program);
|
|
136
|
+
|
|
133
137
|
(async () => {
|
|
134
138
|
try {
|
|
135
139
|
Sentry.addBreadcrumb({
|
package/tsconfig.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
-
"extends": "../../tsconfig.base.json",
|
|
3
2
|
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
4
15
|
"outDir": "dist",
|
|
5
16
|
"rootDir": "src"
|
|
6
17
|
},
|