@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
package/dist/utils/vault-api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e1ca2b83-1de0-5000-ab66-f573000a579b")}catch(e){}}();
|
|
3
3
|
import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
|
|
4
|
+
import { Sentry } from '../sentry.js';
|
|
4
5
|
export async function vaultApiFetch(opts) {
|
|
5
6
|
const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
|
|
6
7
|
if (opts.query) {
|
|
@@ -8,14 +9,31 @@ export async function vaultApiFetch(opts) {
|
|
|
8
9
|
url.searchParams.set(k, v);
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const method = opts.method ?? 'GET';
|
|
13
|
+
const safeUrl = url.search ? `${url.origin}${url.pathname}?<redacted>` : `${url.origin}${url.pathname}`;
|
|
14
|
+
Sentry.addBreadcrumb({
|
|
15
|
+
category: "http",
|
|
16
|
+
message: `${method} ${opts.path}`,
|
|
17
|
+
level: "info",
|
|
18
|
+
data: { url: safeUrl, method },
|
|
19
|
+
});
|
|
20
|
+
const response = await fetch(url.toString(), {
|
|
21
|
+
method,
|
|
13
22
|
headers: {
|
|
14
23
|
Authorization: `Bearer ${opts.token}`,
|
|
15
24
|
'Content-Type': 'application/json',
|
|
16
25
|
},
|
|
17
26
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
18
27
|
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
Sentry.addBreadcrumb({
|
|
30
|
+
category: "http",
|
|
31
|
+
message: `${method} ${opts.path} → ${response.status}`,
|
|
32
|
+
level: "warning",
|
|
33
|
+
data: { url: safeUrl, status: response.status },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return response;
|
|
19
37
|
}
|
|
20
38
|
async function resolveCompanyUid(token, slug) {
|
|
21
39
|
const res = await vaultApiFetch({
|
|
@@ -54,5 +72,38 @@ export async function getCompanyUid(token, companySlug) {
|
|
|
54
72
|
}
|
|
55
73
|
return resolveCompanyFromMemberships(token);
|
|
56
74
|
}
|
|
75
|
+
// Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
|
|
76
|
+
// createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
|
|
77
|
+
export async function resolveCallerPersonUid(token) {
|
|
78
|
+
const res = await vaultApiFetch({
|
|
79
|
+
token,
|
|
80
|
+
path: '/entity/by-type/person',
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new Error("Failed to fetch person entity — run `hq login` and try again");
|
|
84
|
+
}
|
|
85
|
+
const data = (await res.json());
|
|
86
|
+
const persons = (data.entities ?? []).filter((e) => e.type === 'person');
|
|
87
|
+
if (persons.length === 0) {
|
|
88
|
+
throw new Error('No person entity found for the caller. Sign in to HQ once to provision one.');
|
|
89
|
+
}
|
|
90
|
+
persons.sort((a, b) => {
|
|
91
|
+
const ac = a.createdAt ?? '';
|
|
92
|
+
const bc = b.createdAt ?? '';
|
|
93
|
+
if (ac !== bc)
|
|
94
|
+
return ac < bc ? -1 : 1;
|
|
95
|
+
return a.uid < b.uid ? -1 : 1;
|
|
96
|
+
});
|
|
97
|
+
return persons[0].uid;
|
|
98
|
+
}
|
|
99
|
+
// Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
|
|
100
|
+
// `--personal` → caller's canonical person entity; else `--company <slug>` →
|
|
101
|
+
// resolved company UID; else fallback to single active company membership.
|
|
102
|
+
export async function getEntityUid(token, opts) {
|
|
103
|
+
if (opts.personal) {
|
|
104
|
+
return resolveCallerPersonUid(token);
|
|
105
|
+
}
|
|
106
|
+
return getCompanyUid(token, opts.companySlug);
|
|
107
|
+
}
|
|
57
108
|
//# sourceMappingURL=vault-api.js.map
|
|
58
|
-
//# debugId=
|
|
109
|
+
//# debugId=e1ca2b83-1de0-5000-ab66-f573000a579b
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.12.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"build": "node scripts/generate-dsn.mjs && tsc",
|
|
12
12
|
"typecheck": "tsc --noEmit",
|
|
13
13
|
"test": "vitest run",
|
|
14
|
+
"vitest": "vitest",
|
|
14
15
|
"clean": "rm -rf dist"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLI_VERSION = "5.12.0";
|
package/src/commands/cloud.ts
CHANGED
|
@@ -23,9 +23,11 @@ import {
|
|
|
23
23
|
sync,
|
|
24
24
|
readJournal,
|
|
25
25
|
getJournalPath,
|
|
26
|
+
loadCachedTokens,
|
|
26
27
|
type ConflictStrategy,
|
|
27
28
|
type EntityContext,
|
|
28
29
|
type SyncProgressEvent,
|
|
30
|
+
type UploadAuthor,
|
|
29
31
|
} from "@indigoai-us/hq-cloud";
|
|
30
32
|
|
|
31
33
|
import {
|
|
@@ -146,6 +148,14 @@ export function registerCloudCommands(program: Command): void {
|
|
|
146
148
|
emitJson(event as unknown as Record<string, unknown>)
|
|
147
149
|
: undefined;
|
|
148
150
|
|
|
151
|
+
// Stamp every uploaded object's S3 user metadata with the syncing
|
|
152
|
+
// user's Cognito identity (`Metadata['created-by']`). The hq-console
|
|
153
|
+
// vault UI's CREATED BY column reads this back via HEAD; without it,
|
|
154
|
+
// every row renders `—`. Resolved best-effort from the cached
|
|
155
|
+
// idToken — pre-vended `--creds-from-stdin` paths still get author
|
|
156
|
+
// attribution as long as the caller is logged in locally.
|
|
157
|
+
const author = resolveUploadAuthorFromCache();
|
|
158
|
+
|
|
149
159
|
const result = await share({
|
|
150
160
|
paths: targetPaths,
|
|
151
161
|
company: options.company,
|
|
@@ -155,6 +165,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
155
165
|
entityContext,
|
|
156
166
|
hqRoot: options.hqRoot,
|
|
157
167
|
onEvent,
|
|
168
|
+
...(author ? { author } : {}),
|
|
158
169
|
});
|
|
159
170
|
|
|
160
171
|
if (jsonMode) {
|
|
@@ -343,3 +354,32 @@ async function readAllStdin(): Promise<string> {
|
|
|
343
354
|
}
|
|
344
355
|
return Buffer.concat(chunks).toString("utf8");
|
|
345
356
|
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Resolve the syncing user's `UploadAuthor` (sub + email) from the cached
|
|
360
|
+
* Cognito idToken. Returns `undefined` when no tokens are cached or the
|
|
361
|
+
* token is missing the required claims — share() then skips the metadata
|
|
362
|
+
* stamp gracefully (not an error).
|
|
363
|
+
*
|
|
364
|
+
* We deliberately decode the JWT here instead of verifying it: Cognito
|
|
365
|
+
* already verified at issuance, and we only use the public claims to
|
|
366
|
+
* label the upload's S3 user metadata (no auth decision rides on it).
|
|
367
|
+
*/
|
|
368
|
+
function resolveUploadAuthorFromCache(): UploadAuthor | undefined {
|
|
369
|
+
const tokens = loadCachedTokens();
|
|
370
|
+
if (!tokens?.idToken) return undefined;
|
|
371
|
+
const parts = tokens.idToken.split(".");
|
|
372
|
+
if (parts.length !== 3) return undefined;
|
|
373
|
+
try {
|
|
374
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
375
|
+
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
|
376
|
+
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
377
|
+
const claims = JSON.parse(json) as { sub?: string; email?: string };
|
|
378
|
+
if (claims.sub && claims.email) {
|
|
379
|
+
return { userSub: claims.sub, email: claims.email };
|
|
380
|
+
}
|
|
381
|
+
return undefined;
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
|
|
5
|
+
vi.mock("../utils/cognito-session.js", () => ({
|
|
6
|
+
ensureCognitoToken: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("../utils/vault-api.js", () => ({
|
|
10
|
+
vaultApiFetch: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("../utils/feedback-diagnostics.js", () => ({
|
|
14
|
+
collectDiagnostics: vi.fn(() => ({
|
|
15
|
+
cliVersion: "5.11.0",
|
|
16
|
+
nodeVersion: process.version,
|
|
17
|
+
os: { platform: "linux", release: "5.15.0", arch: "x64" },
|
|
18
|
+
command: ["feedback", "bug"],
|
|
19
|
+
cwd: "/home/user",
|
|
20
|
+
git: { branch: "main", head: "abc1234", dirty: false, remoteUrl: null },
|
|
21
|
+
recentSentryBreadcrumbs: [],
|
|
22
|
+
})),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
26
|
+
const actual = await importOriginal<typeof import("node:fs")>();
|
|
27
|
+
return {
|
|
28
|
+
...actual,
|
|
29
|
+
promises: {
|
|
30
|
+
...actual.promises,
|
|
31
|
+
readFile: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
37
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
38
|
+
import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
|
|
39
|
+
import * as fs from "node:fs";
|
|
40
|
+
import {
|
|
41
|
+
submitFeedback,
|
|
42
|
+
readBodyFile,
|
|
43
|
+
registerFeedbackCommand,
|
|
44
|
+
BODY_MAX_BYTES,
|
|
45
|
+
} from "./feedback.js";
|
|
46
|
+
|
|
47
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
48
|
+
return new Response(JSON.stringify(body), {
|
|
49
|
+
status,
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mockEnsureCognitoToken = vi.mocked(ensureCognitoToken);
|
|
55
|
+
const mockVaultApiFetch = vi.mocked(vaultApiFetch);
|
|
56
|
+
const mockCollectDiagnostics = vi.mocked(collectDiagnostics);
|
|
57
|
+
const mockReadFile = vi.mocked(fs.promises.readFile);
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
mockCollectDiagnostics.mockReturnValue({
|
|
62
|
+
cliVersion: "5.11.0",
|
|
63
|
+
nodeVersion: process.version,
|
|
64
|
+
os: { platform: "linux", release: "5.15.0", arch: "x64" },
|
|
65
|
+
command: ["feedback", "bug"],
|
|
66
|
+
cwd: "/home/user",
|
|
67
|
+
git: { branch: "main", head: "abc1234", dirty: false, remoteUrl: null },
|
|
68
|
+
recentSentryBreadcrumbs: [],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// submitFeedback
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe("submitFeedback", () => {
|
|
77
|
+
it("returns the id from a successful bug submission", async () => {
|
|
78
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
79
|
+
jsonResponse(200, { id: "feedback_abc123" }),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const result = await submitFeedback({
|
|
83
|
+
type: "bug",
|
|
84
|
+
title: "Something broke",
|
|
85
|
+
body: "Details here",
|
|
86
|
+
token: "test-token",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.id).toBe("feedback_abc123");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("sends type=feature for feature requests", async () => {
|
|
93
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
94
|
+
jsonResponse(200, { id: "feedback_feature1" }),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await submitFeedback({
|
|
98
|
+
type: "feature",
|
|
99
|
+
title: "Add dark mode",
|
|
100
|
+
body: "Would be great",
|
|
101
|
+
token: "test-token",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const call = mockVaultApiFetch.mock.calls[0][0];
|
|
105
|
+
expect(call.body).toMatchObject({ type: "feature" });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("sends POST to /v1/feedback with the correct title and body", async () => {
|
|
109
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
110
|
+
jsonResponse(200, { id: "feedback_xyz" }),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await submitFeedback({
|
|
114
|
+
type: "bug",
|
|
115
|
+
title: "My title",
|
|
116
|
+
body: "My body text",
|
|
117
|
+
token: "tok",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const call = mockVaultApiFetch.mock.calls[0][0];
|
|
121
|
+
expect(call.path).toBe("/v1/feedback");
|
|
122
|
+
expect(call.method).toBe("POST");
|
|
123
|
+
expect(call.body).toMatchObject({ title: "My title", body: "My body text" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("includes company when provided", async () => {
|
|
127
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
128
|
+
jsonResponse(200, { id: "feedback_co1" }),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await submitFeedback({
|
|
132
|
+
type: "bug",
|
|
133
|
+
title: "Bug with company",
|
|
134
|
+
body: "Details",
|
|
135
|
+
company: "acme",
|
|
136
|
+
token: "tok",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const call = mockVaultApiFetch.mock.calls[0][0];
|
|
140
|
+
expect((call.body as Record<string, unknown>).company).toBe("acme");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("omits company key when not provided", async () => {
|
|
144
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
145
|
+
jsonResponse(200, { id: "feedback_noco" }),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await submitFeedback({
|
|
149
|
+
type: "bug",
|
|
150
|
+
title: "No company",
|
|
151
|
+
body: "Details",
|
|
152
|
+
token: "tok",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const call = mockVaultApiFetch.mock.calls[0][0];
|
|
156
|
+
expect((call.body as Record<string, unknown>)).not.toHaveProperty("company");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("attaches diagnostics from collectDiagnostics to the request body", async () => {
|
|
160
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
161
|
+
jsonResponse(200, { id: "feedback_diag" }),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await submitFeedback({
|
|
165
|
+
type: "bug",
|
|
166
|
+
title: "With diag",
|
|
167
|
+
body: "Body",
|
|
168
|
+
token: "tok",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(mockCollectDiagnostics).toHaveBeenCalledOnce();
|
|
172
|
+
const call = mockVaultApiFetch.mock.calls[0][0];
|
|
173
|
+
expect((call.body as Record<string, unknown>).diagnostics).toBeDefined();
|
|
174
|
+
expect(
|
|
175
|
+
((call.body as Record<string, unknown>).diagnostics as Record<string, unknown>).cliVersion,
|
|
176
|
+
).toBe("5.11.0");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("throws with the error from the response body on non-2xx", async () => {
|
|
180
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
181
|
+
jsonResponse(400, { error: "title too long" }),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
await expect(
|
|
185
|
+
submitFeedback({
|
|
186
|
+
type: "bug",
|
|
187
|
+
title: "Bad",
|
|
188
|
+
body: "Body",
|
|
189
|
+
token: "tok",
|
|
190
|
+
}),
|
|
191
|
+
).rejects.toThrow("title too long");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("throws with statusText when response has no parseable error body", async () => {
|
|
195
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
196
|
+
new Response("internal error", { status: 500, statusText: "Internal Server Error" }),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
await expect(
|
|
200
|
+
submitFeedback({
|
|
201
|
+
type: "bug",
|
|
202
|
+
title: "Server fail",
|
|
203
|
+
body: "Body",
|
|
204
|
+
token: "tok",
|
|
205
|
+
}),
|
|
206
|
+
).rejects.toThrow(/Internal Server Error/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("throws before fetching when body exceeds 64 KiB", async () => {
|
|
210
|
+
const largeBody = "x".repeat(BODY_MAX_BYTES + 1);
|
|
211
|
+
|
|
212
|
+
await expect(
|
|
213
|
+
submitFeedback({
|
|
214
|
+
type: "bug",
|
|
215
|
+
title: "Big body",
|
|
216
|
+
body: largeBody,
|
|
217
|
+
token: "tok",
|
|
218
|
+
}),
|
|
219
|
+
).rejects.toThrow(/64 KiB limit/);
|
|
220
|
+
|
|
221
|
+
expect(mockVaultApiFetch).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("accepts a body exactly at the 64 KiB limit", async () => {
|
|
225
|
+
const exactBody = "x".repeat(BODY_MAX_BYTES);
|
|
226
|
+
mockVaultApiFetch.mockResolvedValueOnce(
|
|
227
|
+
jsonResponse(200, { id: "feedback_exact" }),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const result = await submitFeedback({
|
|
231
|
+
type: "bug",
|
|
232
|
+
title: "Exact size",
|
|
233
|
+
body: exactBody,
|
|
234
|
+
token: "tok",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(result.id).toBe("feedback_exact");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("throws before fetching when body is empty or whitespace-only", async () => {
|
|
241
|
+
await expect(
|
|
242
|
+
submitFeedback({
|
|
243
|
+
type: "bug",
|
|
244
|
+
title: "Empty",
|
|
245
|
+
body: " \n\t ",
|
|
246
|
+
token: "tok",
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/must not be empty/);
|
|
249
|
+
|
|
250
|
+
expect(mockVaultApiFetch).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// readBodyFile
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
describe("readBodyFile", () => {
|
|
259
|
+
it("reads content from a file path", async () => {
|
|
260
|
+
mockReadFile.mockResolvedValueOnce("file body content" as never);
|
|
261
|
+
|
|
262
|
+
const content = await readBodyFile("/tmp/body.md");
|
|
263
|
+
|
|
264
|
+
expect(mockReadFile).toHaveBeenCalledWith("/tmp/body.md", "utf-8");
|
|
265
|
+
expect(content).toBe("file body content");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('reads content from stdin when path is "-"', async () => {
|
|
269
|
+
const mockStdin = new Readable({
|
|
270
|
+
read() {
|
|
271
|
+
this.push("stdin content");
|
|
272
|
+
this.push(null);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const content = await readBodyFile("-", mockStdin as NodeJS.ReadableStream);
|
|
277
|
+
|
|
278
|
+
expect(content).toBe("stdin content");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// registerFeedbackCommand — action integration
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
describe("registerFeedbackCommand — action integration", () => {
|
|
287
|
+
function makeProgram(): Command {
|
|
288
|
+
const p = new Command();
|
|
289
|
+
p.exitOverride();
|
|
290
|
+
registerFeedbackCommand(p);
|
|
291
|
+
return p;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
mockEnsureCognitoToken.mockResolvedValue("test-token");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("success: prints Submitted: <id> and does not call process.exit", async () => {
|
|
299
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
300
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
301
|
+
mockReadFile.mockResolvedValueOnce("Valid bug report body." as never);
|
|
302
|
+
mockVaultApiFetch.mockResolvedValueOnce(jsonResponse(200, { id: "feedback_abc123" }));
|
|
303
|
+
|
|
304
|
+
await makeProgram().parseAsync([
|
|
305
|
+
"node", "hq", "feedback", "bug",
|
|
306
|
+
"--title", "Test bug",
|
|
307
|
+
"--body-file", "/tmp/body.md",
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
expect(logSpy).toHaveBeenCalledWith("Submitted: feedback_abc123");
|
|
311
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("error: API error routes to chalk error prefix and process.exit(1)", async () => {
|
|
315
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
316
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
317
|
+
mockReadFile.mockResolvedValueOnce("Some body text." as never);
|
|
318
|
+
mockVaultApiFetch.mockResolvedValueOnce(jsonResponse(400, { error: "title too long" }));
|
|
319
|
+
|
|
320
|
+
await makeProgram().parseAsync([
|
|
321
|
+
"node", "hq", "feedback", "bug",
|
|
322
|
+
"--title", "Bad",
|
|
323
|
+
"--body-file", "/tmp/body.md",
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
327
|
+
expect.stringContaining("Error"),
|
|
328
|
+
expect.stringContaining("title too long"),
|
|
329
|
+
);
|
|
330
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("auth error: ensureCognitoToken throws → chalk error prefix and process.exit(1)", async () => {
|
|
334
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
335
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
336
|
+
mockEnsureCognitoToken.mockRejectedValueOnce(new Error("not authenticated"));
|
|
337
|
+
|
|
338
|
+
await makeProgram().parseAsync([
|
|
339
|
+
"node", "hq", "feedback", "feature",
|
|
340
|
+
"--title", "Nice feat",
|
|
341
|
+
"--body-file", "/tmp/body.md",
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
345
|
+
expect.stringContaining("Error"),
|
|
346
|
+
"not authenticated",
|
|
347
|
+
);
|
|
348
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("oversize body: submitFeedback throws 64 KiB error → process.exit(1)", async () => {
|
|
352
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
353
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
354
|
+
const largeBody = "x".repeat(BODY_MAX_BYTES + 1);
|
|
355
|
+
mockReadFile.mockResolvedValueOnce(largeBody as never);
|
|
356
|
+
|
|
357
|
+
await makeProgram().parseAsync([
|
|
358
|
+
"node", "hq", "feedback", "bug",
|
|
359
|
+
"--title", "Big",
|
|
360
|
+
"--body-file", "/tmp/big.md",
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
364
|
+
expect.stringContaining("Error"),
|
|
365
|
+
expect.stringContaining("64 KiB limit"),
|
|
366
|
+
);
|
|
367
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
6
|
+
import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
|
|
7
|
+
|
|
8
|
+
export const BODY_MAX_BYTES = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export interface FeedbackResult {
|
|
11
|
+
id: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FeedbackSubmitOptions {
|
|
15
|
+
type: "bug" | "feature";
|
|
16
|
+
title: string;
|
|
17
|
+
body: string;
|
|
18
|
+
company?: string;
|
|
19
|
+
token: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readBodyFile(
|
|
23
|
+
bodyFile: string,
|
|
24
|
+
stdin?: NodeJS.ReadableStream,
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
if (bodyFile === "-") {
|
|
27
|
+
const stream = stdin ?? process.stdin;
|
|
28
|
+
if ((stream as NodeJS.ReadStream).isTTY) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"--body-file - requires piped stdin (got interactive terminal).",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let data = "";
|
|
35
|
+
stream.setEncoding("utf8");
|
|
36
|
+
stream.on("data", (chunk: string) => {
|
|
37
|
+
data += chunk;
|
|
38
|
+
});
|
|
39
|
+
stream.on("end", () => resolve(data));
|
|
40
|
+
stream.on("error", reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return fs.promises.readFile(bodyFile, "utf-8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function submitFeedback(
|
|
47
|
+
opts: FeedbackSubmitOptions,
|
|
48
|
+
): Promise<FeedbackResult> {
|
|
49
|
+
if (opts.body.trim().length === 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"body must not be empty. Provide at least one non-whitespace character.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const bodyBytes = Buffer.byteLength(opts.body, "utf8");
|
|
56
|
+
if (bodyBytes > BODY_MAX_BYTES) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Body exceeds 64 KiB limit (${bodyBytes} bytes). Reduce the body size before submitting.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const diagnostics = collectDiagnostics();
|
|
63
|
+
|
|
64
|
+
const requestBody: Record<string, unknown> = {
|
|
65
|
+
type: opts.type,
|
|
66
|
+
title: opts.title,
|
|
67
|
+
body: opts.body,
|
|
68
|
+
diagnostics,
|
|
69
|
+
};
|
|
70
|
+
if (opts.company) {
|
|
71
|
+
requestBody.company = opts.company;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const res = await vaultApiFetch({
|
|
75
|
+
token: opts.token,
|
|
76
|
+
path: "/v1/feedback",
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: requestBody,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const data = await res.json().catch(() => ({}));
|
|
83
|
+
const errMsg =
|
|
84
|
+
data &&
|
|
85
|
+
typeof data === "object" &&
|
|
86
|
+
!Array.isArray(data) &&
|
|
87
|
+
typeof (data as { error?: unknown }).error === "string"
|
|
88
|
+
? (data as { error: string }).error
|
|
89
|
+
: res.statusText;
|
|
90
|
+
throw new Error(`Failed to submit feedback: ${errMsg}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const data = (await res.json()) as { id: string };
|
|
94
|
+
return { id: data.id };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function registerSubcommand(feedbackCmd: Command, type: "bug" | "feature"): void {
|
|
98
|
+
feedbackCmd
|
|
99
|
+
.command(type)
|
|
100
|
+
.description(type === "bug" ? "Report a bug" : "Request a feature")
|
|
101
|
+
.requiredOption("--title <text>", "Short title for the report")
|
|
102
|
+
.requiredOption(
|
|
103
|
+
"--body-file <path>",
|
|
104
|
+
"Path to a markdown file with the body; use - to read from stdin",
|
|
105
|
+
)
|
|
106
|
+
.option("--company <slug>", "Company slug to associate with the report")
|
|
107
|
+
.action(async (opts: { title: string; bodyFile: string; company?: string }) => {
|
|
108
|
+
try {
|
|
109
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
110
|
+
const body = await readBodyFile(opts.bodyFile);
|
|
111
|
+
const result = await submitFeedback({
|
|
112
|
+
type,
|
|
113
|
+
title: opts.title,
|
|
114
|
+
body,
|
|
115
|
+
company: opts.company,
|
|
116
|
+
token,
|
|
117
|
+
});
|
|
118
|
+
console.log(`Submitted: ${result.id}`);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error(
|
|
121
|
+
chalk.red("Error:"),
|
|
122
|
+
err instanceof Error ? err.message : String(err),
|
|
123
|
+
);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function registerFeedbackCommand(program: Command): void {
|
|
130
|
+
const feedbackCmd = program
|
|
131
|
+
.command("feedback")
|
|
132
|
+
.description("Submit a bug report or feature request to HQ");
|
|
133
|
+
|
|
134
|
+
registerSubcommand(feedbackCmd, "bug");
|
|
135
|
+
registerSubcommand(feedbackCmd, "feature");
|
|
136
|
+
}
|