@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.
@@ -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]="4e482805-d129-5563-a77b-97b80410154b")}catch(e){}}();
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
- return fetch(url.toString(), {
12
- method: opts.method ?? 'GET',
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=4e482805-d129-5563-a77b-97b80410154b
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.10.1",
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";
@@ -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
+ }