@indigoai-us/hq-cli 5.15.0 → 5.17.0-sources-rc.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 +8 -4
- package/.github/workflows/publish.yml +8 -3
- package/dist/cli-version.d.ts +1 -0
- package/dist/cli-version.js +3 -2
- package/dist/commands/cloud-provision.d.ts +25 -0
- package/dist/commands/cloud-provision.js +75 -9
- package/dist/commands/groups.js +6 -6
- package/dist/commands/meetings.js +8 -8
- package/dist/commands/members.d.ts +4 -2
- package/dist/commands/members.js +30 -14
- package/dist/commands/signals.d.ts +24 -0
- package/dist/commands/signals.js +240 -0
- package/dist/commands/sources.d.ts +22 -0
- package/dist/commands/sources.js +250 -0
- package/dist/index.js +8 -2
- package/dist/utils/cognito-session.d.ts +10 -1
- package/dist/utils/cognito-session.js +18 -3
- package/package.json +5 -4
- package/scripts/smoke-sources-signals.sh +103 -0
- package/src/cli-version.ts +5 -1
- package/src/commands/cloud-provision.test.ts +27 -2
- package/src/commands/cloud-provision.ts +122 -7
- package/src/commands/groups.ts +4 -4
- package/src/commands/meetings.ts +6 -6
- package/src/commands/members.test.ts +56 -0
- package/src/commands/members.ts +44 -13
- package/src/commands/signals.ts +345 -0
- package/src/commands/sources.ts +356 -0
- package/src/index.ts +8 -0
- package/src/utils/cognito-session.test.ts +24 -1
- package/src/utils/cognito-session.ts +18 -0
- package/test/commands/signals.test.ts +200 -0
- package/test/commands/sources.test.ts +225 -0
- package/test/fixtures/signals/action_item/sample.md +16 -0
- package/test/fixtures/signals/summary/sample.md +12 -0
- package/test/fixtures/sources/meetings/sample.md +25 -0
- package/test/helpers/cli-runner.ts +150 -0
- package/test/helpers/s3-list-mock.ts +79 -0
- package/test/helpers/vault-service-mock.ts +160 -0
- package/test/sources-signals/smoke.test.ts +226 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# smoke-sources-signals.sh — end-to-end verification of `hq sources` /
|
|
4
|
+
# `hq signals` against a real vault bucket. Owned by US-008 of the
|
|
5
|
+
# hq-cli-sources-signals PRD.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# # 1. Make sure you have a working Cognito session OR export a token:
|
|
9
|
+
# hq whoami # interactive Cognito login if needed
|
|
10
|
+
# # or: export HQ_ACCESS_TOKEN="$(...)" # service-account JWT bypass
|
|
11
|
+
#
|
|
12
|
+
# # 2. Run the smoke against an entity slug you have access to (default: indigo):
|
|
13
|
+
# ./scripts/smoke-sources-signals.sh # uses ENTITY=indigo
|
|
14
|
+
# ENTITY=personal ./scripts/smoke-sources-signals.sh
|
|
15
|
+
#
|
|
16
|
+
# Behavior:
|
|
17
|
+
# Runs four CLI invocations, each asserted to exit 0 AND produce parseable
|
|
18
|
+
# JSON (where --format json is used). Stdout from each invocation is
|
|
19
|
+
# appended to a temp file; the script prints a summary table at the end.
|
|
20
|
+
#
|
|
21
|
+
# Exit codes:
|
|
22
|
+
# 0 all four invocations succeeded and produced valid JSON
|
|
23
|
+
# 1 one or more invocations failed (script aborts on first failure with set -e)
|
|
24
|
+
#
|
|
25
|
+
# Dependencies:
|
|
26
|
+
# - hq (built locally via `pnpm --filter @indigoai-us/hq-cli build`, or
|
|
27
|
+
# `npx @indigoai-us/hq-cli@rc ...` against a published rc)
|
|
28
|
+
# - jq (for JSON validation)
|
|
29
|
+
#
|
|
30
|
+
|
|
31
|
+
set -euo pipefail
|
|
32
|
+
|
|
33
|
+
ENTITY="${ENTITY:-indigo}"
|
|
34
|
+
LIMIT="${LIMIT:-5}"
|
|
35
|
+
HQ_BIN="${HQ_BIN:-hq}"
|
|
36
|
+
|
|
37
|
+
# Resolve workspace-local journal location for capturing output. The
|
|
38
|
+
# operator running this script may redirect the script's stdout to a journal
|
|
39
|
+
# file under companies/indigo/projects/hq-cli-sources-signals/journal/.
|
|
40
|
+
SMOKE_TMP="$(mktemp -d -t hq-smoke-XXXXXX)"
|
|
41
|
+
trap 'rm -rf "$SMOKE_TMP"' EXIT
|
|
42
|
+
|
|
43
|
+
echo "==> hq sources/signals smoke for entity=$ENTITY limit=$LIMIT"
|
|
44
|
+
echo " binary: $HQ_BIN"
|
|
45
|
+
echo " tmpdir: $SMOKE_TMP"
|
|
46
|
+
echo
|
|
47
|
+
|
|
48
|
+
require_cmd() {
|
|
49
|
+
if ! command -v "$1" >/dev/null 2>&1; then
|
|
50
|
+
echo "ERROR: required command '$1' not found in PATH" >&2
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
require_cmd "$HQ_BIN"
|
|
56
|
+
require_cmd jq
|
|
57
|
+
|
|
58
|
+
# --- 1. entities -----------------------------------------------------------
|
|
59
|
+
echo "[1/4] hq sources entities"
|
|
60
|
+
"$HQ_BIN" sources entities --format json > "$SMOKE_TMP/entities.json"
|
|
61
|
+
jq -e 'type == "array"' "$SMOKE_TMP/entities.json" > /dev/null
|
|
62
|
+
ENTITY_COUNT=$(jq 'length' "$SMOKE_TMP/entities.json")
|
|
63
|
+
echo " ok — $ENTITY_COUNT entity/entities visible"
|
|
64
|
+
echo
|
|
65
|
+
|
|
66
|
+
# --- 2. sources channels (no auth required) --------------------------------
|
|
67
|
+
echo "[2/4] hq sources channels"
|
|
68
|
+
"$HQ_BIN" sources channels > "$SMOKE_TMP/channels.txt"
|
|
69
|
+
grep -qx 'meeting' "$SMOKE_TMP/channels.txt"
|
|
70
|
+
grep -qx 'email' "$SMOKE_TMP/channels.txt"
|
|
71
|
+
grep -qx 'slack' "$SMOKE_TMP/channels.txt"
|
|
72
|
+
grep -qx 'linear' "$SMOKE_TMP/channels.txt"
|
|
73
|
+
grep -qx 'notion' "$SMOKE_TMP/channels.txt"
|
|
74
|
+
echo " ok — all five canonical channels present"
|
|
75
|
+
echo
|
|
76
|
+
|
|
77
|
+
# --- 3. sources list --type meeting (the headline acceptance criterion) ---
|
|
78
|
+
echo "[3/4] hq sources list --entity $ENTITY --type meeting --limit $LIMIT --format json"
|
|
79
|
+
"$HQ_BIN" sources list \
|
|
80
|
+
--entity "$ENTITY" \
|
|
81
|
+
--type meeting \
|
|
82
|
+
--limit "$LIMIT" \
|
|
83
|
+
--format json > "$SMOKE_TMP/sources-list.json"
|
|
84
|
+
jq -e '.entries | type == "array"' "$SMOKE_TMP/sources-list.json" > /dev/null
|
|
85
|
+
SOURCE_COUNT=$(jq '.entries | length' "$SMOKE_TMP/sources-list.json")
|
|
86
|
+
echo " ok — $SOURCE_COUNT meeting source(s) listed"
|
|
87
|
+
echo
|
|
88
|
+
|
|
89
|
+
# --- 4. signals list --type action_item (the other headline) --------------
|
|
90
|
+
echo "[4/4] hq signals list --entity $ENTITY --type action_item --limit $LIMIT --format json"
|
|
91
|
+
"$HQ_BIN" signals list \
|
|
92
|
+
--entity "$ENTITY" \
|
|
93
|
+
--type action_item \
|
|
94
|
+
--limit "$LIMIT" \
|
|
95
|
+
--format json > "$SMOKE_TMP/signals-list.json"
|
|
96
|
+
jq -e '.entries | type == "array"' "$SMOKE_TMP/signals-list.json" > /dev/null
|
|
97
|
+
SIGNAL_COUNT=$(jq '.entries | length' "$SMOKE_TMP/signals-list.json")
|
|
98
|
+
echo " ok — $SIGNAL_COUNT action_item signal(s) listed"
|
|
99
|
+
echo
|
|
100
|
+
|
|
101
|
+
echo "==> SMOKE PASSED"
|
|
102
|
+
echo " entities=$ENTITY_COUNT meeting_sources=$SOURCE_COUNT action_item_signals=$SIGNAL_COUNT"
|
|
103
|
+
echo " raw outputs preserved in $SMOKE_TMP until process exit"
|
package/src/cli-version.ts
CHANGED
|
@@ -4,6 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
const pkgPath = path.resolve(here, "..", "package.json");
|
|
7
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
|
|
7
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
|
|
8
|
+
name: string;
|
|
9
|
+
version: string;
|
|
10
|
+
};
|
|
8
11
|
|
|
12
|
+
export const CLI_NAME: string = pkg.name;
|
|
9
13
|
export const CLI_VERSION: string = pkg.version;
|
|
@@ -582,6 +582,20 @@ describe("provisionCompany", () => {
|
|
|
582
582
|
listMyPersonEntities: vi.fn().mockResolvedValue([
|
|
583
583
|
{ uid: "prs_01H", type: "person", slug: "test-user", name: "Test User" },
|
|
584
584
|
]),
|
|
585
|
+
// Default to "slug available in caller's namespace" so happy-path
|
|
586
|
+
// tests fall through to createCompanyEntity. Reuse-path tests
|
|
587
|
+
// override with {available: false, conflictingCompanyUid: ...}
|
|
588
|
+
// and supply a getCompanyByUid that returns the existing entity.
|
|
589
|
+
checkSlugInMyNamespace: vi
|
|
590
|
+
.fn()
|
|
591
|
+
.mockResolvedValue({ available: true }),
|
|
592
|
+
getCompanyByUid: vi
|
|
593
|
+
.fn()
|
|
594
|
+
.mockRejectedValue(
|
|
595
|
+
new Error(
|
|
596
|
+
"getCompanyByUid called without an explicit per-test mock — happy path should never hit it",
|
|
597
|
+
),
|
|
598
|
+
),
|
|
585
599
|
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
586
600
|
createCompanyEntity: vi.fn(),
|
|
587
601
|
...overrides,
|
|
@@ -716,7 +730,13 @@ describe("provisionCompany", () => {
|
|
|
716
730
|
kmsKeyId: null,
|
|
717
731
|
};
|
|
718
732
|
const vaultClient = makeVaultClient({
|
|
719
|
-
|
|
733
|
+
// Reuse path under the per-user-namespace model: checkSlugInMyNamespace
|
|
734
|
+
// reports `available: false` with the existing entity's uid, and
|
|
735
|
+
// getCompanyByUid materializes the full entity for downstream use.
|
|
736
|
+
checkSlugInMyNamespace: vi
|
|
737
|
+
.fn()
|
|
738
|
+
.mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
|
|
739
|
+
getCompanyByUid: vi.fn().mockResolvedValue(entity),
|
|
720
740
|
createCompanyEntity: vi.fn(),
|
|
721
741
|
});
|
|
722
742
|
const result = await provisionCompany({
|
|
@@ -731,6 +751,7 @@ describe("provisionCompany", () => {
|
|
|
731
751
|
expect(result.created_entity).toBe(false);
|
|
732
752
|
expect(result.kms_key_id).toBeNull();
|
|
733
753
|
expect(vaultClient.createCompanyEntity).not.toHaveBeenCalled();
|
|
754
|
+
expect(vaultClient.getCompanyByUid).toHaveBeenCalledWith(entity.uid);
|
|
734
755
|
});
|
|
735
756
|
|
|
736
757
|
it("throws code 1 when entity has no bucketName (incomplete provisioning)", async () => {
|
|
@@ -743,7 +764,11 @@ describe("provisionCompany", () => {
|
|
|
743
764
|
// bucketName intentionally absent
|
|
744
765
|
};
|
|
745
766
|
const vaultClient = makeVaultClient({
|
|
746
|
-
|
|
767
|
+
// Same reuse-path mock shape as the idempotent-path test above.
|
|
768
|
+
checkSlugInMyNamespace: vi
|
|
769
|
+
.fn()
|
|
770
|
+
.mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
|
|
771
|
+
getCompanyByUid: vi.fn().mockResolvedValue(entity),
|
|
747
772
|
});
|
|
748
773
|
try {
|
|
749
774
|
await provisionCompany({
|
|
@@ -133,7 +133,32 @@ export interface VaultClient {
|
|
|
133
133
|
* entity.
|
|
134
134
|
*/
|
|
135
135
|
listMyPersonEntities(): Promise<VaultEntity[]>;
|
|
136
|
+
/**
|
|
137
|
+
* Legacy global-uniqueness lookup. Under the per-user-namespace model
|
|
138
|
+
* (hq-pro 2026-05-15) this can return any tenant's entity when more
|
|
139
|
+
* than one user holds the same slug, OR `null` when the caller doesn't
|
|
140
|
+
* have it but a different user does. Kept on the interface for any
|
|
141
|
+
* remaining callers, but `provisionCompany` now uses
|
|
142
|
+
* `checkSlugInMyNamespace` instead — same-slug-different-owner is
|
|
143
|
+
* legitimate and should NOT trigger reuse of the stranger's entity.
|
|
144
|
+
*/
|
|
136
145
|
findCompanyBySlug(slug: string): Promise<VaultEntity | null>;
|
|
146
|
+
/**
|
|
147
|
+
* Caller-scoped slug availability check via
|
|
148
|
+
* `GET /entity/check-slug/me?type=company&slug=...`. Returns
|
|
149
|
+
* `{available: true}` when the caller's namespace
|
|
150
|
+
* (owned ∪ active-member-of, soft-deleted excluded) doesn't hold the
|
|
151
|
+
* slug, or `{available: false, conflictingCompanyUid}` when it does
|
|
152
|
+
* — `provisionCompany` reuses the `conflictingCompanyUid` as the
|
|
153
|
+
* idempotent entity instead of creating a duplicate.
|
|
154
|
+
*/
|
|
155
|
+
checkSlugInMyNamespace(slug: string): Promise<{
|
|
156
|
+
available: boolean;
|
|
157
|
+
conflictingCompanyUid?: string;
|
|
158
|
+
}>;
|
|
159
|
+
/** Fetch a company entity by uid. Used to materialize the entity
|
|
160
|
+
* after `checkSlugInMyNamespace` reports a same-namespace collision. */
|
|
161
|
+
getCompanyByUid(uid: string): Promise<VaultEntity>;
|
|
137
162
|
createCompanyEntity(input: {
|
|
138
163
|
slug: string;
|
|
139
164
|
name: string;
|
|
@@ -475,6 +500,45 @@ export function createDefaultVaultClient(
|
|
|
475
500
|
}
|
|
476
501
|
return data.entity;
|
|
477
502
|
},
|
|
503
|
+
async checkSlugInMyNamespace(slug: string): Promise<{
|
|
504
|
+
available: boolean;
|
|
505
|
+
conflictingCompanyUid?: string;
|
|
506
|
+
}> {
|
|
507
|
+
const url = `${apiUrl.replace(/\/$/, "")}/entity/check-slug/me?type=company&slug=${encodeURIComponent(
|
|
508
|
+
slug,
|
|
509
|
+
)}`;
|
|
510
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
511
|
+
if (!res.ok) {
|
|
512
|
+
const body = await safeBody(res);
|
|
513
|
+
throw new ProvisionError(
|
|
514
|
+
1,
|
|
515
|
+
`Vault GET /entity/check-slug/me failed: ${res.status} ${res.statusText} — ${body}`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return (await res.json()) as {
|
|
519
|
+
available: boolean;
|
|
520
|
+
conflictingCompanyUid?: string;
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
async getCompanyByUid(uid: string): Promise<VaultEntity> {
|
|
524
|
+
const url = `${apiUrl.replace(/\/$/, "")}/entity/${encodeURIComponent(uid)}`;
|
|
525
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
526
|
+
if (!res.ok) {
|
|
527
|
+
const body = await safeBody(res);
|
|
528
|
+
throw new ProvisionError(
|
|
529
|
+
1,
|
|
530
|
+
`Vault GET /entity/${uid} failed: ${res.status} ${res.statusText} — ${body}`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const data = (await res.json()) as { entity?: VaultEntity };
|
|
534
|
+
if (!data.entity) {
|
|
535
|
+
throw new ProvisionError(
|
|
536
|
+
1,
|
|
537
|
+
`Vault GET /entity/${uid} returned 200 with no entity body`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
return data.entity;
|
|
541
|
+
},
|
|
478
542
|
async createCompanyEntity(input: {
|
|
479
543
|
slug: string;
|
|
480
544
|
name: string;
|
|
@@ -494,9 +558,15 @@ export function createDefaultVaultClient(
|
|
|
494
558
|
});
|
|
495
559
|
if (!res.ok) {
|
|
496
560
|
const text = await safeBody(res);
|
|
497
|
-
// 409
|
|
498
|
-
//
|
|
499
|
-
//
|
|
561
|
+
// 409 SLUG_IN_USE_FOR_PERSON: the caller already has the slug
|
|
562
|
+
// in their namespace (owned ∪ active-member-of). Under the
|
|
563
|
+
// per-user-namespace model this is the new same-user-collision
|
|
564
|
+
// signal — distinct from the legacy global EntityAlreadyExists.
|
|
565
|
+
// The CLI normally reaches `createCompanyEntity` only after
|
|
566
|
+
// `checkSlugInMyNamespace` reported `available: true`, so a
|
|
567
|
+
// 409 here means a race between the pre-check and the POST.
|
|
568
|
+
// Surface the response body verbatim so the caller can see the
|
|
569
|
+
// `code` + `conflictingCompanyUid` and resolve / retry.
|
|
500
570
|
throw new ProvisionError(
|
|
501
571
|
1,
|
|
502
572
|
`Vault POST /entity failed: ${res.status} ${res.statusText} — ${text}`,
|
|
@@ -600,12 +670,57 @@ export async function provisionCompany(
|
|
|
600
670
|
}
|
|
601
671
|
log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
|
|
602
672
|
|
|
603
|
-
|
|
673
|
+
// Per-user-namespace-aware reuse-or-create. Replaces the legacy
|
|
674
|
+
// global `findCompanyBySlug` lookup, which under the per-user model
|
|
675
|
+
// (hq-pro 2026-05-15) returns ANY tenant's entity when more than one
|
|
676
|
+
// user holds the same slug, OR null when a different user has it —
|
|
677
|
+
// both wrong for the CLI's "reuse mine, or create" intent.
|
|
678
|
+
//
|
|
679
|
+
// `--owner` override: `options.ownerUid`, when set, lets a caller
|
|
680
|
+
// create the entity under a DIFFERENT person's ownership (e.g. an
|
|
681
|
+
// admin provisioning on behalf of someone). `/entity/check-slug/me`
|
|
682
|
+
// answers about the CALLER's namespace, not the target owner's, so
|
|
683
|
+
// the pre-check is meaningless in that case. Codex P2 on PR 7
|
|
684
|
+
// flagged this. The gate: only run the namespace check when the
|
|
685
|
+
// owner is the caller (or defaulted to the caller — i.e. no
|
|
686
|
+
// --owner supplied). On override, fall through to
|
|
687
|
+
// `createCompanyEntity` and let the server's authoritative 409
|
|
688
|
+
// (which IS scoped to the target's namespace, per the
|
|
689
|
+
// callerIsOwner gate on POST /entity in hq-pro PR 67) surface any
|
|
690
|
+
// real conflict.
|
|
691
|
+
//
|
|
692
|
+
// `callerIsOwner` is `true` whenever `options.ownerUid` is unset
|
|
693
|
+
// (defaults to caller server-side) OR — when set — happens to
|
|
694
|
+
// match the caller's own person UID(s) from `listMyPersonEntities`.
|
|
695
|
+
const callerOwnedUids = new Set(persons.map((p) => p.uid));
|
|
696
|
+
const callerIsOwner =
|
|
697
|
+
!options.ownerUid || callerOwnedUids.has(options.ownerUid);
|
|
698
|
+
|
|
699
|
+
let entity: VaultEntity;
|
|
604
700
|
let createdEntity = false;
|
|
605
|
-
if (
|
|
606
|
-
|
|
701
|
+
if (callerIsOwner) {
|
|
702
|
+
const slugCheck = await vaultClient.checkSlugInMyNamespace(options.slug);
|
|
703
|
+
if (!slugCheck.available && slugCheck.conflictingCompanyUid) {
|
|
704
|
+
log(
|
|
705
|
+
`reusing existing vault entity uid=${slugCheck.conflictingCompanyUid} (slug already in caller's namespace)`,
|
|
706
|
+
);
|
|
707
|
+
entity = await vaultClient.getCompanyByUid(
|
|
708
|
+
slugCheck.conflictingCompanyUid,
|
|
709
|
+
);
|
|
710
|
+
} else {
|
|
711
|
+
log(`slug available in caller's namespace — creating vault entity`);
|
|
712
|
+
entity = await vaultClient.createCompanyEntity({
|
|
713
|
+
slug: options.slug,
|
|
714
|
+
name: options.name ?? options.slug,
|
|
715
|
+
ownerUid: options.ownerUid,
|
|
716
|
+
});
|
|
717
|
+
createdEntity = true;
|
|
718
|
+
log(`created vault entity uid=${entity.uid}`);
|
|
719
|
+
}
|
|
607
720
|
} else {
|
|
608
|
-
log(
|
|
721
|
+
log(
|
|
722
|
+
`--owner ${options.ownerUid} differs from caller's person(s); skipping namespace pre-check (server authoritatively gates per-target-namespace)`,
|
|
723
|
+
);
|
|
609
724
|
entity = await vaultClient.createCompanyEntity({
|
|
610
725
|
slug: options.slug,
|
|
611
726
|
name: options.name ?? options.slug,
|
package/src/commands/groups.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
66
66
|
if (res.status === 401) {
|
|
67
67
|
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
68
68
|
} else if (res.status === 403) {
|
|
69
|
-
console.error(chalk.red("Not authorized — owner
|
|
69
|
+
console.error(chalk.red("Not authorized — owner role required"));
|
|
70
70
|
} else if (res.status === 409) {
|
|
71
71
|
console.error(chalk.red(`Group already exists: ${groupId}`));
|
|
72
72
|
} else if (res.status >= 500) {
|
|
@@ -110,7 +110,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
110
110
|
if (res.status === 401) {
|
|
111
111
|
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
112
112
|
} else if (res.status === 403) {
|
|
113
|
-
console.error(chalk.red("Not authorized — owner
|
|
113
|
+
console.error(chalk.red("Not authorized — owner role required"));
|
|
114
114
|
} else if (res.status === 404) {
|
|
115
115
|
console.error(chalk.red(`Group not found: ${groupId}`));
|
|
116
116
|
} else if (res.status >= 500) {
|
|
@@ -160,7 +160,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
160
160
|
if (res.status === 401) {
|
|
161
161
|
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
162
162
|
} else if (res.status === 403) {
|
|
163
|
-
console.error(chalk.red("Not authorized — owner
|
|
163
|
+
console.error(chalk.red("Not authorized — owner role or group creator required"));
|
|
164
164
|
} else if (res.status === 404) {
|
|
165
165
|
// Server provides a helpful message (email not found vs group not found)
|
|
166
166
|
console.error(chalk.red(err.error ?? `Not found`));
|
|
@@ -215,7 +215,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
215
215
|
if (res.status === 401) {
|
|
216
216
|
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
217
217
|
} else if (res.status === 403) {
|
|
218
|
-
console.error(chalk.red("Not authorized — owner
|
|
218
|
+
console.error(chalk.red("Not authorized — owner role or group creator required"));
|
|
219
219
|
} else if (res.status === 404) {
|
|
220
220
|
console.error(chalk.red(err.error ?? `Not found`));
|
|
221
221
|
} else if (res.status >= 500) {
|
package/src/commands/meetings.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
-
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
4
|
+
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
5
5
|
|
|
6
6
|
interface MeetingListItem {
|
|
7
7
|
meetingId: string;
|
|
@@ -210,7 +210,7 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
210
210
|
|
|
211
211
|
if (opts.limit) query.limit = opts.limit;
|
|
212
212
|
if (opts.next) query.nextToken = opts.next;
|
|
213
|
-
if (companySlug) query.companyId = companySlug;
|
|
213
|
+
if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
|
|
214
214
|
|
|
215
215
|
const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
|
|
216
216
|
if (!res.ok) await handleApiError(res);
|
|
@@ -250,7 +250,7 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
250
250
|
const token = await ensureCognitoToken();
|
|
251
251
|
const query: Record<string, string> = {};
|
|
252
252
|
const companySlug = meetings.opts().company as string | undefined;
|
|
253
|
-
if (companySlug) query.companyId = companySlug;
|
|
253
|
+
if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
|
|
254
254
|
|
|
255
255
|
const meetingId = await resolveShortId(token, rawId, query);
|
|
256
256
|
const res = await vaultApiFetch({
|
|
@@ -309,7 +309,7 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
309
309
|
const token = await ensureCognitoToken();
|
|
310
310
|
const params: Record<string, string> = { q: query };
|
|
311
311
|
const companySlug = meetings.opts().company as string | undefined;
|
|
312
|
-
if (companySlug) params.companyId = companySlug;
|
|
312
|
+
if (companySlug) params.companyId = await getCompanyUid(token, companySlug);
|
|
313
313
|
|
|
314
314
|
const res = await vaultApiFetch({
|
|
315
315
|
token,
|
|
@@ -347,7 +347,7 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
347
347
|
const token = await ensureCognitoToken();
|
|
348
348
|
const query: Record<string, string> = {};
|
|
349
349
|
const companySlug = meetings.opts().company as string | undefined;
|
|
350
|
-
if (companySlug) query.companyId = companySlug;
|
|
350
|
+
if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
|
|
351
351
|
|
|
352
352
|
const meetingId = await resolveShortId(token, rawId, query);
|
|
353
353
|
const res = await vaultApiFetch({
|
|
@@ -403,7 +403,7 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
403
403
|
const token = await ensureCognitoToken();
|
|
404
404
|
const query: Record<string, string> = {};
|
|
405
405
|
const companySlug = meetings.opts().company as string | undefined;
|
|
406
|
-
if (companySlug) query.companyId = companySlug;
|
|
406
|
+
if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
|
|
407
407
|
|
|
408
408
|
const meetingId = await resolveShortId(token, rawId, query);
|
|
409
409
|
const res = await vaultApiFetch({
|
|
@@ -222,6 +222,47 @@ describe("inviteMember", () => {
|
|
|
222
222
|
}),
|
|
223
223
|
).rejects.toBeInstanceOf(InviteHttpError);
|
|
224
224
|
});
|
|
225
|
+
|
|
226
|
+
it("resolves the token when it is nested on the membership row", async () => {
|
|
227
|
+
fetchSpy.mockResolvedValueOnce(
|
|
228
|
+
jsonResponse(200, {
|
|
229
|
+
membership: {
|
|
230
|
+
role: "admin",
|
|
231
|
+
status: "pending",
|
|
232
|
+
inviteToken: "tok_nested",
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const result = await inviteMember({
|
|
238
|
+
target: "alice@example.com",
|
|
239
|
+
role: "admin",
|
|
240
|
+
companyUid: "cmp_acme",
|
|
241
|
+
callerUid: "prs_admin",
|
|
242
|
+
token: "test-token",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(result.inviteToken).toBe("tok_nested");
|
|
246
|
+
expect(result.magicLink).toBe("hq://accept/tok_nested");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("throws instead of emitting hq://accept/undefined when no token is present", async () => {
|
|
250
|
+
fetchSpy.mockResolvedValueOnce(
|
|
251
|
+
jsonResponse(200, {
|
|
252
|
+
membership: { role: "admin", status: "pending" },
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await expect(
|
|
257
|
+
inviteMember({
|
|
258
|
+
target: "alice@example.com",
|
|
259
|
+
role: "admin",
|
|
260
|
+
companyUid: "cmp_acme",
|
|
261
|
+
callerUid: "prs_admin",
|
|
262
|
+
token: "test-token",
|
|
263
|
+
}),
|
|
264
|
+
).rejects.toThrow(/did not include an invite token/);
|
|
265
|
+
});
|
|
225
266
|
});
|
|
226
267
|
|
|
227
268
|
// ---------------------------------------------------------------------------
|
|
@@ -257,6 +298,21 @@ describe("listPendingInvites", () => {
|
|
|
257
298
|
InviteHttpError,
|
|
258
299
|
);
|
|
259
300
|
});
|
|
301
|
+
|
|
302
|
+
it("returns [] for an empty invites array", async () => {
|
|
303
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { invites: [] }));
|
|
304
|
+
await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("returns [] when invites is null", async () => {
|
|
308
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { invites: null }));
|
|
309
|
+
await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("returns [] when the invites key is missing", async () => {
|
|
313
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
314
|
+
await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
|
|
315
|
+
});
|
|
260
316
|
});
|
|
261
317
|
|
|
262
318
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface InviteOptions {
|
|
|
41
41
|
export interface InviteResult {
|
|
42
42
|
inviteToken: string;
|
|
43
43
|
magicLink: string;
|
|
44
|
-
membership: { role: string; status: string };
|
|
44
|
+
membership: { role: string; status: string; inviteToken?: string };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface DetectedTarget {
|
|
@@ -132,30 +132,53 @@ export async function inviteMember(
|
|
|
132
132
|
throw new InviteHttpError(
|
|
133
133
|
res.status,
|
|
134
134
|
err.message ?? err.error ?? res.statusText,
|
|
135
|
+
err.code,
|
|
135
136
|
);
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
const data = (await res.json()) as {
|
|
139
|
-
membership
|
|
140
|
-
inviteToken
|
|
140
|
+
membership?: { role: string; status: string; inviteToken?: string };
|
|
141
|
+
inviteToken?: string;
|
|
141
142
|
};
|
|
143
|
+
// The token may arrive at the response root OR nested on the membership row,
|
|
144
|
+
// depending on vault-service version. Resolve from either; never emit
|
|
145
|
+
// `hq://accept/undefined` (a broken link that looks like success).
|
|
146
|
+
const inviteToken = data.inviteToken ?? data.membership?.inviteToken;
|
|
147
|
+
if (!inviteToken) {
|
|
148
|
+
const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Invite was created but the server response did not include an invite token (response keys: ${keys}). ` +
|
|
151
|
+
"Run `hq members list` to retrieve the pending invite, or upgrade hq.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
142
154
|
return {
|
|
143
|
-
inviteToken
|
|
144
|
-
magicLink: `hq://accept/${
|
|
145
|
-
membership: data.membership,
|
|
155
|
+
inviteToken,
|
|
156
|
+
magicLink: `hq://accept/${inviteToken}`,
|
|
157
|
+
membership: data.membership ?? { role: options.role, status: "pending" },
|
|
146
158
|
};
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
export class InviteHttpError extends Error {
|
|
150
|
-
constructor(
|
|
162
|
+
constructor(
|
|
163
|
+
public status: number,
|
|
164
|
+
message: string,
|
|
165
|
+
public code?: string,
|
|
166
|
+
) {
|
|
151
167
|
super(message);
|
|
152
168
|
this.name = "InviteHttpError";
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
171
|
|
|
156
|
-
export function formatInviteHttpError(
|
|
172
|
+
export function formatInviteHttpError(
|
|
173
|
+
status: number,
|
|
174
|
+
fallback: string,
|
|
175
|
+
code?: string,
|
|
176
|
+
): string {
|
|
157
177
|
if (status === 401) return "Not authenticated — please run `hq login`";
|
|
158
178
|
if (status === 403) {
|
|
179
|
+
if (code === "ADMIN_ROLE_TARGET_RESTRICTED") {
|
|
180
|
+
return "Admin can only invite at role=member; ask an owner to invite at this role";
|
|
181
|
+
}
|
|
159
182
|
return "Not authorized — only admins and owners can invite members";
|
|
160
183
|
}
|
|
161
184
|
if (status === 409) {
|
|
@@ -178,10 +201,11 @@ export async function listPendingInvites(
|
|
|
178
201
|
throw new InviteHttpError(
|
|
179
202
|
res.status,
|
|
180
203
|
err.message ?? err.error ?? res.statusText,
|
|
204
|
+
err.code,
|
|
181
205
|
);
|
|
182
206
|
}
|
|
183
|
-
const data = (await res.json()) as { invites
|
|
184
|
-
return data
|
|
207
|
+
const data = (await res.json()) as { invites?: PendingInvite[] | null };
|
|
208
|
+
return data?.invites ?? [];
|
|
185
209
|
}
|
|
186
210
|
|
|
187
211
|
export async function revokeInvite(
|
|
@@ -200,6 +224,7 @@ export async function revokeInvite(
|
|
|
200
224
|
throw new InviteHttpError(
|
|
201
225
|
res.status,
|
|
202
226
|
err.message ?? err.error ?? res.statusText,
|
|
227
|
+
err.code,
|
|
203
228
|
);
|
|
204
229
|
}
|
|
205
230
|
}
|
|
@@ -260,7 +285,11 @@ export function registerMembersCommand(program: Command): void {
|
|
|
260
285
|
);
|
|
261
286
|
} catch (err) {
|
|
262
287
|
if (err instanceof InviteHttpError) {
|
|
263
|
-
console.error(
|
|
288
|
+
console.error(
|
|
289
|
+
chalk.red(
|
|
290
|
+
formatInviteHttpError(err.status, err.message, err.code),
|
|
291
|
+
),
|
|
292
|
+
);
|
|
264
293
|
process.exit(1);
|
|
265
294
|
}
|
|
266
295
|
console.error(
|
|
@@ -350,10 +379,12 @@ export function registerMembersCommand(program: Command): void {
|
|
|
350
379
|
if (err instanceof InviteHttpError) {
|
|
351
380
|
const msg =
|
|
352
381
|
err.status === 403
|
|
353
|
-
?
|
|
382
|
+
? err.code === "ADMIN_ROLE_TARGET_RESTRICTED"
|
|
383
|
+
? "Admin can only revoke role=member; ask an owner to revoke this membership"
|
|
384
|
+
: "Not authorized — only admins and owners can revoke invites"
|
|
354
385
|
: err.status === 404
|
|
355
386
|
? "Invite not found — it may have already been accepted or revoked"
|
|
356
|
-
: formatInviteHttpError(err.status, err.message);
|
|
387
|
+
: formatInviteHttpError(err.status, err.message, err.code);
|
|
357
388
|
console.error(chalk.red(msg));
|
|
358
389
|
process.exit(1);
|
|
359
390
|
}
|