@indigoai-us/hq-cli 5.10.0 → 5.11.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.
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+
3
+ import { getEntityUid, resolveCallerPersonUid } from './vault-api.js';
4
+
5
+ const fetchMock = vi.fn();
6
+ const originalFetch = globalThis.fetch;
7
+
8
+ function mockResponse(status: number, body: unknown): Response {
9
+ return new Response(JSON.stringify(body), {
10
+ status,
11
+ headers: { 'Content-Type': 'application/json' },
12
+ });
13
+ }
14
+
15
+ beforeEach(() => {
16
+ fetchMock.mockReset();
17
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
18
+ });
19
+
20
+ afterEach(() => {
21
+ globalThis.fetch = originalFetch;
22
+ });
23
+
24
+ describe('resolveCallerPersonUid', () => {
25
+ it('returns the canonical person uid (oldest createdAt, uid tie-break)', async () => {
26
+ fetchMock.mockResolvedValueOnce(
27
+ mockResponse(200, {
28
+ entities: [
29
+ { uid: 'prs_b', type: 'person', createdAt: '2026-01-02T00:00:00Z' },
30
+ { uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
31
+ { uid: 'prs_c', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
32
+ ],
33
+ }),
34
+ );
35
+
36
+ const uid = await resolveCallerPersonUid('tok');
37
+ expect(uid).toBe('prs_a');
38
+ });
39
+
40
+ it('throws when the caller has no person entity', async () => {
41
+ fetchMock.mockResolvedValueOnce(mockResponse(200, { entities: [] }));
42
+ await expect(resolveCallerPersonUid('tok')).rejects.toThrow(
43
+ /No person entity/,
44
+ );
45
+ });
46
+
47
+ it('throws when the API returns an error status', async () => {
48
+ fetchMock.mockResolvedValueOnce(mockResponse(401, { error: 'unauth' }));
49
+ await expect(resolveCallerPersonUid('tok')).rejects.toThrow(
50
+ /Failed to fetch person entity/,
51
+ );
52
+ });
53
+
54
+ it('filters out non-person entries before sorting', async () => {
55
+ fetchMock.mockResolvedValueOnce(
56
+ mockResponse(200, {
57
+ entities: [
58
+ { uid: 'cmp_a', type: 'company', createdAt: '2025-01-01T00:00:00Z' },
59
+ { uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
60
+ ],
61
+ }),
62
+ );
63
+ const uid = await resolveCallerPersonUid('tok');
64
+ expect(uid).toBe('prs_a');
65
+ });
66
+ });
67
+
68
+ describe('getEntityUid', () => {
69
+ it('routes to person resolution when personal=true', async () => {
70
+ fetchMock.mockResolvedValueOnce(
71
+ mockResponse(200, {
72
+ entities: [
73
+ { uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
74
+ ],
75
+ }),
76
+ );
77
+ const uid = await getEntityUid('tok', { personal: true });
78
+ expect(uid).toBe('prs_a');
79
+ const url = fetchMock.mock.calls[0][0] as string;
80
+ expect(url).toMatch(/\/entity\/by-type\/person/);
81
+ });
82
+
83
+ it('routes to company-slug resolution when companySlug is set', async () => {
84
+ fetchMock.mockResolvedValueOnce(
85
+ mockResponse(200, { entity: { uid: 'cmp_acme' } }),
86
+ );
87
+ const uid = await getEntityUid('tok', { companySlug: 'acme' });
88
+ expect(uid).toBe('cmp_acme');
89
+ const url = fetchMock.mock.calls[0][0] as string;
90
+ expect(url).toMatch(/\/entity\/by-slug\/company\/acme/);
91
+ });
92
+
93
+ it('falls back to membership lookup when neither personal nor slug is set', async () => {
94
+ fetchMock.mockResolvedValueOnce(
95
+ mockResponse(200, {
96
+ memberships: [
97
+ {
98
+ companyUid: 'cmp_only',
99
+ role: 'member',
100
+ status: 'active',
101
+ membershipKey: 'k',
102
+ },
103
+ ],
104
+ }),
105
+ );
106
+ const uid = await getEntityUid('tok', {});
107
+ expect(uid).toBe('cmp_only');
108
+ const url = fetchMock.mock.calls[0][0] as string;
109
+ expect(url).toMatch(/\/membership\/me/);
110
+ });
111
+ });
@@ -78,3 +78,46 @@ export async function getCompanyUid(
78
78
  }
79
79
  return resolveCompanyFromMemberships(token);
80
80
  }
81
+
82
+ interface PersonEntity {
83
+ uid: string;
84
+ type: string;
85
+ createdAt?: string;
86
+ }
87
+
88
+ // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
89
+ // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
90
+ export async function resolveCallerPersonUid(token: string): Promise<string> {
91
+ const res = await vaultApiFetch({
92
+ token,
93
+ path: '/entity/by-type/person',
94
+ });
95
+ if (!res.ok) {
96
+ throw new Error("Failed to fetch person entity — run `hq login` and try again");
97
+ }
98
+ const data = (await res.json()) as { entities: PersonEntity[] };
99
+ const persons = (data.entities ?? []).filter((e) => e.type === 'person');
100
+ if (persons.length === 0) {
101
+ throw new Error('No person entity found for the caller. Sign in to HQ once to provision one.');
102
+ }
103
+ persons.sort((a, b) => {
104
+ const ac = a.createdAt ?? '';
105
+ const bc = b.createdAt ?? '';
106
+ if (ac !== bc) return ac < bc ? -1 : 1;
107
+ return a.uid < b.uid ? -1 : 1;
108
+ });
109
+ return persons[0].uid;
110
+ }
111
+
112
+ // Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
113
+ // `--personal` → caller's canonical person entity; else `--company <slug>` →
114
+ // resolved company UID; else fallback to single active company membership.
115
+ export async function getEntityUid(
116
+ token: string,
117
+ opts: { personal?: boolean; companySlug?: string },
118
+ ): Promise<string> {
119
+ if (opts.personal) {
120
+ return resolveCallerPersonUid(token);
121
+ }
122
+ return getCompanyUid(token, opts.companySlug);
123
+ }