@indigoai-us/hq-cli 5.11.0 → 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/feedback.d.ts +16 -0
- package/dist/commands/feedback.js +98 -0
- 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.js +22 -4
- package/package.json +2 -1
- package/src/cli-version.ts +1 -0
- package/src/commands/feedback.test.ts +369 -0
- package/src/commands/feedback.ts +136 -0
- 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 +37 -1
- package/src/utils/vault-api.ts +20 -2
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { registerRunCommand } from "./commands/run.js";
|
|
|
28
28
|
import { registerGroupsCommand } from "./commands/groups.js";
|
|
29
29
|
import { registerFilesCommand } from "./commands/files.js";
|
|
30
30
|
import { registerMembersCommand } from "./commands/members.js";
|
|
31
|
+
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
32
|
+
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
31
33
|
|
|
32
34
|
initSentry();
|
|
33
35
|
|
|
@@ -36,7 +38,7 @@ const program = new Command();
|
|
|
36
38
|
program
|
|
37
39
|
.name("hq")
|
|
38
40
|
.description("HQ management CLI — modules, packages, and cloud sync")
|
|
39
|
-
.version("5.
|
|
41
|
+
.version("5.12.0");
|
|
40
42
|
|
|
41
43
|
// Module management subcommand group
|
|
42
44
|
const modulesCmd = program
|
|
@@ -109,8 +111,16 @@ registerMembersCommand(program);
|
|
|
109
111
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
110
112
|
registerOnboardCommand(program);
|
|
111
113
|
|
|
114
|
+
// Feedback (subcommand group — hq feedback bug|feature)
|
|
115
|
+
registerFeedbackCommand(program);
|
|
116
|
+
|
|
112
117
|
(async () => {
|
|
113
118
|
try {
|
|
119
|
+
Sentry.addBreadcrumb({
|
|
120
|
+
category: "command",
|
|
121
|
+
message: sanitizeArgv(process.argv.slice(2)).join(" "),
|
|
122
|
+
level: "info",
|
|
123
|
+
});
|
|
114
124
|
await program.parseAsync();
|
|
115
125
|
} catch (err) {
|
|
116
126
|
Sentry.captureException(err);
|
package/src/sentry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as Sentry from "@sentry/node";
|
|
2
2
|
import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
|
|
3
3
|
import { beforeSend } from "./sentry-before-send.js";
|
|
4
|
+
import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
|
|
4
5
|
|
|
5
6
|
export function initSentry(): void {
|
|
6
7
|
const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
|
|
@@ -13,6 +14,7 @@ export function initSentry(): void {
|
|
|
13
14
|
tags: { repo: "hq-cli" },
|
|
14
15
|
},
|
|
15
16
|
beforeSend,
|
|
17
|
+
beforeBreadcrumb,
|
|
16
18
|
});
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Breadcrumb } from "@sentry/node";
|
|
2
|
+
|
|
3
|
+
const BUFFER_SIZE = 20;
|
|
4
|
+
const _buffer: Breadcrumb[] = [];
|
|
5
|
+
|
|
6
|
+
// Sentry beforeBreadcrumb hook: records every breadcrumb in a ring buffer
|
|
7
|
+
// and returns it unchanged so Sentry still processes it normally.
|
|
8
|
+
export function beforeBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb | null {
|
|
9
|
+
_buffer.push(breadcrumb);
|
|
10
|
+
if (_buffer.length > BUFFER_SIZE) {
|
|
11
|
+
_buffer.shift();
|
|
12
|
+
}
|
|
13
|
+
return breadcrumb;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getRecentBreadcrumbs(): Breadcrumb[] {
|
|
17
|
+
return [..._buffer];
|
|
18
|
+
}
|