@indigoai-us/hq-cli 5.10.1 → 5.12.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/CHANGELOG.md +48 -0
- package/dist/cli-version.d.ts +2 -0
- package/dist/cli-version.js +5 -0
- package/dist/commands/cloud.js +42 -3
- package/dist/commands/feedback.d.ts +16 -0
- package/dist/commands/feedback.js +98 -0
- package/dist/commands/secrets.d.ts +2 -2
- package/dist/commands/secrets.js +33 -25
- package/dist/index.js +12 -3
- package/dist/sentry.js +4 -2
- package/dist/utils/breadcrumb-buffer.d.ts +4 -0
- package/dist/utils/breadcrumb-buffer.js +18 -0
- package/dist/utils/feedback-diagnostics.d.ts +22 -0
- package/dist/utils/feedback-diagnostics.js +95 -0
- package/dist/utils/vault-api.d.ts +5 -0
- package/dist/utils/vault-api.js +55 -4
- package/package.json +2 -1
- package/src/cli-version.ts +1 -0
- package/src/commands/cloud.ts +40 -0
- package/src/commands/feedback.test.ts +369 -0
- package/src/commands/feedback.ts +136 -0
- package/src/commands/secrets.ts +86 -23
- package/src/index.ts +11 -1
- package/src/sentry.ts +2 -0
- package/src/utils/breadcrumb-buffer.ts +18 -0
- package/src/utils/feedback-diagnostics.test.ts +172 -0
- package/src/utils/feedback-diagnostics.ts +115 -0
- package/src/utils/vault-api.test.ts +147 -0
- package/src/utils/vault-api.ts +63 -2
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('../sentry.js', () => ({
|
|
4
|
+
Sentry: { addBreadcrumb: vi.fn() },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { Sentry } from '../sentry.js';
|
|
8
|
+
import { getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
|
|
9
|
+
|
|
10
|
+
const fetchMock = vi.fn();
|
|
11
|
+
const originalFetch = globalThis.fetch;
|
|
12
|
+
|
|
13
|
+
function mockResponse(status: number, body: unknown): Response {
|
|
14
|
+
return new Response(JSON.stringify(body), {
|
|
15
|
+
status,
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
fetchMock.mockReset();
|
|
22
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = originalFetch;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('resolveCallerPersonUid', () => {
|
|
30
|
+
it('returns the canonical person uid (oldest createdAt, uid tie-break)', async () => {
|
|
31
|
+
fetchMock.mockResolvedValueOnce(
|
|
32
|
+
mockResponse(200, {
|
|
33
|
+
entities: [
|
|
34
|
+
{ uid: 'prs_b', type: 'person', createdAt: '2026-01-02T00:00:00Z' },
|
|
35
|
+
{ uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
|
|
36
|
+
{ uid: 'prs_c', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const uid = await resolveCallerPersonUid('tok');
|
|
42
|
+
expect(uid).toBe('prs_a');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('throws when the caller has no person entity', async () => {
|
|
46
|
+
fetchMock.mockResolvedValueOnce(mockResponse(200, { entities: [] }));
|
|
47
|
+
await expect(resolveCallerPersonUid('tok')).rejects.toThrow(
|
|
48
|
+
/No person entity/,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('throws when the API returns an error status', async () => {
|
|
53
|
+
fetchMock.mockResolvedValueOnce(mockResponse(401, { error: 'unauth' }));
|
|
54
|
+
await expect(resolveCallerPersonUid('tok')).rejects.toThrow(
|
|
55
|
+
/Failed to fetch person entity/,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('filters out non-person entries before sorting', async () => {
|
|
60
|
+
fetchMock.mockResolvedValueOnce(
|
|
61
|
+
mockResponse(200, {
|
|
62
|
+
entities: [
|
|
63
|
+
{ uid: 'cmp_a', type: 'company', createdAt: '2025-01-01T00:00:00Z' },
|
|
64
|
+
{ uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
|
|
65
|
+
],
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
const uid = await resolveCallerPersonUid('tok');
|
|
69
|
+
expect(uid).toBe('prs_a');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('getEntityUid', () => {
|
|
74
|
+
it('routes to person resolution when personal=true', async () => {
|
|
75
|
+
fetchMock.mockResolvedValueOnce(
|
|
76
|
+
mockResponse(200, {
|
|
77
|
+
entities: [
|
|
78
|
+
{ uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
|
|
79
|
+
],
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
const uid = await getEntityUid('tok', { personal: true });
|
|
83
|
+
expect(uid).toBe('prs_a');
|
|
84
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
85
|
+
expect(url).toMatch(/\/entity\/by-type\/person/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('routes to company-slug resolution when companySlug is set', async () => {
|
|
89
|
+
fetchMock.mockResolvedValueOnce(
|
|
90
|
+
mockResponse(200, { entity: { uid: 'cmp_acme' } }),
|
|
91
|
+
);
|
|
92
|
+
const uid = await getEntityUid('tok', { companySlug: 'acme' });
|
|
93
|
+
expect(uid).toBe('cmp_acme');
|
|
94
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
95
|
+
expect(url).toMatch(/\/entity\/by-slug\/company\/acme/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('falls back to membership lookup when neither personal nor slug is set', async () => {
|
|
99
|
+
fetchMock.mockResolvedValueOnce(
|
|
100
|
+
mockResponse(200, {
|
|
101
|
+
memberships: [
|
|
102
|
+
{
|
|
103
|
+
companyUid: 'cmp_only',
|
|
104
|
+
role: 'member',
|
|
105
|
+
status: 'active',
|
|
106
|
+
membershipKey: 'k',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const uid = await getEntityUid('tok', {});
|
|
112
|
+
expect(uid).toBe('cmp_only');
|
|
113
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
114
|
+
expect(url).toMatch(/\/membership\/me/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('vaultApiFetch breadcrumb URL sanitization', () => {
|
|
119
|
+
it('redacts query string in request breadcrumb data.url', async () => {
|
|
120
|
+
fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
|
|
121
|
+
const addBreadcrumbMock = vi.mocked(Sentry.addBreadcrumb);
|
|
122
|
+
addBreadcrumbMock.mockClear();
|
|
123
|
+
await vaultApiFetch({ token: 'tok', path: '/v1/foo', query: { path: 'secrets/my-secret', reveal: 'true' } });
|
|
124
|
+
const requestCrumb = addBreadcrumbMock.mock.calls[0][0];
|
|
125
|
+
expect(requestCrumb.data?.url).not.toMatch(/secrets%2F|reveal=true/);
|
|
126
|
+
expect(requestCrumb.data?.url).toContain('?<redacted>');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('omits query delimiter when there is no query string', async () => {
|
|
130
|
+
fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
|
|
131
|
+
const addBreadcrumbMock = vi.mocked(Sentry.addBreadcrumb);
|
|
132
|
+
addBreadcrumbMock.mockClear();
|
|
133
|
+
await vaultApiFetch({ token: 'tok', path: '/v1/bar' });
|
|
134
|
+
const requestCrumb = addBreadcrumbMock.mock.calls[0][0];
|
|
135
|
+
expect(requestCrumb.data?.url).not.toContain('?');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('redacts query string in non-2xx error breadcrumb data.url', async () => {
|
|
139
|
+
fetchMock.mockResolvedValueOnce(mockResponse(401, {}));
|
|
140
|
+
const addBreadcrumbMock = vi.mocked(Sentry.addBreadcrumb);
|
|
141
|
+
addBreadcrumbMock.mockClear();
|
|
142
|
+
await vaultApiFetch({ token: 'tok', path: '/v1/baz', query: { action: 'generate-token' } });
|
|
143
|
+
const errorCrumb = addBreadcrumbMock.mock.calls[1][0];
|
|
144
|
+
expect(errorCrumb.data?.url).not.toContain('generate-token');
|
|
145
|
+
expect(errorCrumb.data?.url).toContain('?<redacted>');
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
|
|
2
|
+
import { Sentry } from '../sentry.js';
|
|
2
3
|
|
|
3
4
|
export interface VaultApiOptions {
|
|
4
5
|
token: string;
|
|
@@ -15,14 +16,31 @@ export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
|
|
|
15
16
|
url.searchParams.set(k, v);
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const method = opts.method ?? 'GET';
|
|
20
|
+
const safeUrl = url.search ? `${url.origin}${url.pathname}?<redacted>` : `${url.origin}${url.pathname}`;
|
|
21
|
+
Sentry.addBreadcrumb({
|
|
22
|
+
category: "http",
|
|
23
|
+
message: `${method} ${opts.path}`,
|
|
24
|
+
level: "info",
|
|
25
|
+
data: { url: safeUrl, method },
|
|
26
|
+
});
|
|
27
|
+
const response = await fetch(url.toString(), {
|
|
28
|
+
method,
|
|
20
29
|
headers: {
|
|
21
30
|
Authorization: `Bearer ${opts.token}`,
|
|
22
31
|
'Content-Type': 'application/json',
|
|
23
32
|
},
|
|
24
33
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
25
34
|
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
Sentry.addBreadcrumb({
|
|
37
|
+
category: "http",
|
|
38
|
+
message: `${method} ${opts.path} → ${response.status}`,
|
|
39
|
+
level: "warning",
|
|
40
|
+
data: { url: safeUrl, status: response.status },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return response;
|
|
26
44
|
}
|
|
27
45
|
|
|
28
46
|
interface MembershipEntry {
|
|
@@ -78,3 +96,46 @@ export async function getCompanyUid(
|
|
|
78
96
|
}
|
|
79
97
|
return resolveCompanyFromMemberships(token);
|
|
80
98
|
}
|
|
99
|
+
|
|
100
|
+
interface PersonEntity {
|
|
101
|
+
uid: string;
|
|
102
|
+
type: string;
|
|
103
|
+
createdAt?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
|
|
107
|
+
// createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
|
|
108
|
+
export async function resolveCallerPersonUid(token: string): Promise<string> {
|
|
109
|
+
const res = await vaultApiFetch({
|
|
110
|
+
token,
|
|
111
|
+
path: '/entity/by-type/person',
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
throw new Error("Failed to fetch person entity — run `hq login` and try again");
|
|
115
|
+
}
|
|
116
|
+
const data = (await res.json()) as { entities: PersonEntity[] };
|
|
117
|
+
const persons = (data.entities ?? []).filter((e) => e.type === 'person');
|
|
118
|
+
if (persons.length === 0) {
|
|
119
|
+
throw new Error('No person entity found for the caller. Sign in to HQ once to provision one.');
|
|
120
|
+
}
|
|
121
|
+
persons.sort((a, b) => {
|
|
122
|
+
const ac = a.createdAt ?? '';
|
|
123
|
+
const bc = b.createdAt ?? '';
|
|
124
|
+
if (ac !== bc) return ac < bc ? -1 : 1;
|
|
125
|
+
return a.uid < b.uid ? -1 : 1;
|
|
126
|
+
});
|
|
127
|
+
return persons[0].uid;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
|
|
131
|
+
// `--personal` → caller's canonical person entity; else `--company <slug>` →
|
|
132
|
+
// resolved company UID; else fallback to single active company membership.
|
|
133
|
+
export async function getEntityUid(
|
|
134
|
+
token: string,
|
|
135
|
+
opts: { personal?: boolean; companySlug?: string },
|
|
136
|
+
): Promise<string> {
|
|
137
|
+
if (opts.personal) {
|
|
138
|
+
return resolveCallerPersonUid(token);
|
|
139
|
+
}
|
|
140
|
+
return getCompanyUid(token, opts.companySlug);
|
|
141
|
+
}
|