@indigoai-us/hq-cli 5.47.16 → 5.48.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.
Files changed (37) hide show
  1. package/.github/workflows/ci.yml +16 -20
  2. package/.github/workflows/publish.yml +26 -101
  3. package/dist/commands/feedback.d.ts +2 -0
  4. package/dist/commands/feedback.js +24 -2
  5. package/dist/commands/files.d.ts +19 -0
  6. package/dist/commands/files.js +37 -3
  7. package/dist/commands/meetings.js +50 -2
  8. package/dist/commands/secrets.js +25 -2
  9. package/dist/run/hq-plugin.js +9 -2
  10. package/dist/sentry-dsn.generated.d.ts +1 -1
  11. package/dist/sentry-dsn.generated.js +1 -1
  12. package/dist/sentry.js +7 -3
  13. package/dist/utils/feedback-diagnostics.d.ts +7 -0
  14. package/dist/utils/feedback-diagnostics.js +4 -2
  15. package/dist/utils/feedback-screenshots.d.ts +23 -0
  16. package/dist/utils/feedback-screenshots.js +98 -0
  17. package/dist/utils/feedback-versions.d.ts +34 -0
  18. package/dist/utils/feedback-versions.js +50 -0
  19. package/package.json +4 -2
  20. package/src/commands/feedback.test.ts +44 -0
  21. package/src/commands/feedback.ts +46 -13
  22. package/src/commands/files-delete.test.ts +132 -0
  23. package/src/commands/files.ts +42 -1
  24. package/src/commands/meetings.test.ts +163 -0
  25. package/src/commands/meetings.ts +60 -0
  26. package/src/commands/secrets.test.ts +80 -0
  27. package/src/commands/secrets.ts +35 -0
  28. package/src/run/hq-plugin.test.ts +39 -0
  29. package/src/run/hq-plugin.ts +7 -0
  30. package/src/sentry.ts +5 -1
  31. package/src/utils/feedback-diagnostics.test.ts +11 -0
  32. package/src/utils/feedback-diagnostics.ts +8 -0
  33. package/src/utils/feedback-screenshots.test.ts +134 -0
  34. package/src/utils/feedback-screenshots.ts +124 -0
  35. package/src/utils/feedback-versions.test.ts +98 -0
  36. package/src/utils/feedback-versions.ts +68 -0
  37. package/test/helpers/vault-service-mock.ts +6 -2
@@ -0,0 +1,98 @@
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]="13f46978-c3c8-532d-b0bb-e8febf876587")}catch(e){}}();
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { vaultApiFetch } from "./vault-api.js";
6
+ export const MAX_SCREENSHOTS = 5;
7
+ // Per-image ceiling. Screenshots are PNG/JPEG captures; 10 MB is generous.
8
+ export const MAX_SCREENSHOT_BYTES = 10 * 1024 * 1024;
9
+ // Extension → content type. Must stay in sync with the server's allowed set
10
+ // (hq-pro feedback-screenshots.ts ALLOWED_CONTENT_TYPES).
11
+ const EXT_CONTENT_TYPE = {
12
+ ".png": "image/png",
13
+ ".jpg": "image/jpeg",
14
+ ".jpeg": "image/jpeg",
15
+ ".webp": "image/webp",
16
+ ".gif": "image/gif",
17
+ };
18
+ export function contentTypeForPath(filePath) {
19
+ const ext = path.extname(filePath).toLowerCase();
20
+ const contentType = EXT_CONTENT_TYPE[ext];
21
+ if (!contentType) {
22
+ throw new Error(`unsupported screenshot type: ${filePath} (allowed: ${Object.keys(EXT_CONTENT_TYPE).join(", ")})`);
23
+ }
24
+ return contentType;
25
+ }
26
+ /** Validate + read the given screenshot paths (count, type, existence, size). */
27
+ export function loadScreenshots(paths) {
28
+ if (paths.length > MAX_SCREENSHOTS) {
29
+ throw new Error(`at most ${MAX_SCREENSHOTS} screenshots are allowed (got ${paths.length})`);
30
+ }
31
+ return paths.map((filePath) => {
32
+ const contentType = contentTypeForPath(filePath);
33
+ let bytes;
34
+ try {
35
+ bytes = fs.readFileSync(filePath);
36
+ }
37
+ catch {
38
+ throw new Error(`cannot read screenshot: ${filePath}`);
39
+ }
40
+ if (bytes.byteLength === 0) {
41
+ throw new Error(`screenshot is empty: ${filePath}`);
42
+ }
43
+ if (bytes.byteLength > MAX_SCREENSHOT_BYTES) {
44
+ throw new Error(`screenshot too large: ${filePath} (${bytes.byteLength} bytes, max ${MAX_SCREENSHOT_BYTES})`);
45
+ }
46
+ return { path: filePath, contentType, bytes };
47
+ });
48
+ }
49
+ /**
50
+ * Validate the screenshot paths, request presigned PUT URLs from the feedback
51
+ * endpoint, upload each image direct to S3, and return the object keys to
52
+ * attach to the feedback submission. Returns [] for no screenshots.
53
+ *
54
+ * `fetchImpl` is injectable for tests; defaults to the global fetch.
55
+ */
56
+ export async function uploadScreenshots(opts) {
57
+ if (opts.paths.length === 0)
58
+ return [];
59
+ const inputs = loadScreenshots(opts.paths);
60
+ const res = await vaultApiFetch({
61
+ token: opts.token,
62
+ path: "/v1/feedback/screenshots/presign",
63
+ method: "POST",
64
+ body: { contentTypes: inputs.map((i) => i.contentType) },
65
+ });
66
+ if (!res.ok) {
67
+ const data = await res.json().catch(() => ({}));
68
+ const msg = data &&
69
+ typeof data === "object" &&
70
+ typeof data.error === "string"
71
+ ? data.error
72
+ : res.statusText;
73
+ throw new Error(`Failed to presign screenshots: ${msg}`);
74
+ }
75
+ const parsed = (await res.json());
76
+ if (!parsed ||
77
+ !Array.isArray(parsed.screenshots) ||
78
+ parsed.screenshots.length !== inputs.length) {
79
+ throw new Error("Presign response did not match the requested screenshots");
80
+ }
81
+ const doFetch = opts.fetchImpl ?? fetch;
82
+ const keys = [];
83
+ for (let i = 0; i < inputs.length; i++) {
84
+ const slot = parsed.screenshots[i];
85
+ const put = await doFetch(slot.url, {
86
+ method: "PUT",
87
+ headers: { "Content-Type": slot.contentType },
88
+ body: inputs[i].bytes,
89
+ });
90
+ if (!put.ok) {
91
+ throw new Error(`Failed to upload screenshot ${inputs[i].path}: HTTP ${put.status}`);
92
+ }
93
+ keys.push(slot.key);
94
+ }
95
+ return keys;
96
+ }
97
+ //# sourceMappingURL=feedback-screenshots.js.map
98
+ //# debugId=13f46978-c3c8-532d-b0bb-e8febf876587
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The three HQ component versions captured from the submitter's environment
3
+ * and attached to a feedback submission so triage can see exactly which
4
+ * versions a report came from.
5
+ *
6
+ * - `cli` — this hq-cli build (always known).
7
+ * - `core` — the HQ scaffold version from `core/core.yaml` (`hqVersion`);
8
+ * null when the command runs outside an HQ tree.
9
+ * - `sync` — the installed hq-sync menubar app version, which the app
10
+ * records at `~/.hq/sync-version.json` on startup; null when
11
+ * hq-sync is not installed (e.g. CLI-only / CI environments).
12
+ */
13
+ export interface VersionInfo {
14
+ cli: string;
15
+ core: string | null;
16
+ sync: string | null;
17
+ }
18
+ /**
19
+ * Best-effort read of the hq-core scaffold version (`core/core.yaml`
20
+ * `hqVersion`). Resolves the HQ root from the working directory; returns
21
+ * null when no HQ root / core.yaml is found rather than throwing — version
22
+ * capture must never break a feedback submission.
23
+ */
24
+ export declare function readCoreVersion(): string | null;
25
+ /**
26
+ * Best-effort read of the hq-sync menubar app version. The app writes
27
+ * `{ version, updatedAt }` to `~/.hq/sync-version.json` on startup; the CLI
28
+ * reads it here. Returns null when the file is absent or malformed (hq-sync
29
+ * not installed, or an older build that predates the marker).
30
+ */
31
+ export declare function readSyncVersion(homeDir?: string): string | null;
32
+ /** Collect all three component versions, each independently best-effort. */
33
+ export declare function collectVersions(): VersionInfo;
34
+ //# sourceMappingURL=feedback-versions.d.ts.map
@@ -0,0 +1,50 @@
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]="ea3328d1-5c42-5d60-ad24-f96c11a37f22")}catch(e){}}();
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { CLI_VERSION } from "../cli-version.js";
7
+ import { findHqRoot } from "./manifest.js";
8
+ import { readHqVersion } from "./pack-contributions.js";
9
+ /**
10
+ * Best-effort read of the hq-core scaffold version (`core/core.yaml`
11
+ * `hqVersion`). Resolves the HQ root from the working directory; returns
12
+ * null when no HQ root / core.yaml is found rather than throwing — version
13
+ * capture must never break a feedback submission.
14
+ */
15
+ export function readCoreVersion() {
16
+ try {
17
+ return readHqVersion(findHqRoot());
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /**
24
+ * Best-effort read of the hq-sync menubar app version. The app writes
25
+ * `{ version, updatedAt }` to `~/.hq/sync-version.json` on startup; the CLI
26
+ * reads it here. Returns null when the file is absent or malformed (hq-sync
27
+ * not installed, or an older build that predates the marker).
28
+ */
29
+ export function readSyncVersion(homeDir = os.homedir()) {
30
+ try {
31
+ const raw = fs.readFileSync(path.join(homeDir, ".hq", "sync-version.json"), "utf-8");
32
+ const parsed = JSON.parse(raw);
33
+ return typeof parsed.version === "string" && parsed.version.length > 0
34
+ ? parsed.version
35
+ : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Collect all three component versions, each independently best-effort. */
42
+ export function collectVersions() {
43
+ return {
44
+ cli: CLI_VERSION,
45
+ core: readCoreVersion(),
46
+ sync: readSyncVersion(),
47
+ };
48
+ }
49
+ //# sourceMappingURL=feedback-versions.js.map
50
+ //# debugId=ea3328d1-5c42-5d60-ad24-f96c11a37f22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.47.16",
3
+ "version": "5.48.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -11,11 +11,12 @@
11
11
  "build": "node scripts/generate-dsn.mjs && tsc",
12
12
  "typecheck": "tsc --noEmit",
13
13
  "test": "vitest run",
14
+ "coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
14
15
  "vitest": "vitest",
15
16
  "clean": "rm -rf dist"
16
17
  },
17
18
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^6.11.7",
19
+ "@indigoai-us/hq-cloud": "^6.11.14",
19
20
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
21
  "@sentry/node": "^10.49.0",
21
22
  "chalk": "^5.3.0",
@@ -31,6 +32,7 @@
31
32
  "@types/js-yaml": "^4.0.9",
32
33
  "@types/node": "^22.0.0",
33
34
  "@types/semver": "^7.5.8",
35
+ "@vitest/coverage-v8": "4.1.6",
34
36
  "typescript": "^5.7.0",
35
37
  "vitest": "^4.1.2"
36
38
  },
@@ -156,6 +156,24 @@ describe("submitFeedback", () => {
156
156
  expect((call.body as Record<string, unknown>)).not.toHaveProperty("company");
157
157
  });
158
158
 
159
+ it("includes screenshot keys when provided, omits the key otherwise", async () => {
160
+ mockVaultApiFetch.mockResolvedValueOnce(jsonResponse(200, { id: "feedback_s1" }));
161
+ await submitFeedback({
162
+ type: "bug",
163
+ title: "With shots",
164
+ body: "Details",
165
+ token: "tok",
166
+ screenshots: ["feedback-screenshots/prs_a/sub/0.png"],
167
+ });
168
+ expect((mockVaultApiFetch.mock.calls[0][0].body as Record<string, unknown>).screenshots).toEqual([
169
+ "feedback-screenshots/prs_a/sub/0.png",
170
+ ]);
171
+
172
+ mockVaultApiFetch.mockResolvedValueOnce(jsonResponse(200, { id: "feedback_s2" }));
173
+ await submitFeedback({ type: "bug", title: "No shots", body: "Details", token: "tok", screenshots: [] });
174
+ expect(mockVaultApiFetch.mock.calls[1][0].body).not.toHaveProperty("screenshots");
175
+ });
176
+
159
177
  it("attaches diagnostics from collectDiagnostics to the request body", async () => {
160
178
  mockVaultApiFetch.mockResolvedValueOnce(
161
179
  jsonResponse(200, { id: "feedback_diag" }),
@@ -249,6 +267,32 @@ describe("submitFeedback", () => {
249
267
 
250
268
  expect(mockVaultApiFetch).not.toHaveBeenCalled();
251
269
  });
270
+
271
+ // HQ-AB: an empty/whitespace title (e.g. `--title ""`, or a title the /hq-bug
272
+ // skill derived to nothing) used to slip past Commander's required-flag check
273
+ // and 400 server-side, flooding Sentry with a context-free warning. The local
274
+ // guard now rejects it before any network call.
275
+ it("throws before fetching when title is empty or whitespace-only", async () => {
276
+ await expect(
277
+ submitFeedback({
278
+ type: "bug",
279
+ title: " \n\t ",
280
+ body: "Real body",
281
+ token: "tok",
282
+ }),
283
+ ).rejects.toThrow(/title must not be empty/);
284
+
285
+ await expect(
286
+ submitFeedback({
287
+ type: "feature",
288
+ title: "",
289
+ body: "Real body",
290
+ token: "tok",
291
+ }),
292
+ ).rejects.toThrow(/title must not be empty/);
293
+
294
+ expect(mockVaultApiFetch).not.toHaveBeenCalled();
295
+ });
252
296
  });
253
297
 
254
298
  // ---------------------------------------------------------------------------
@@ -4,6 +4,7 @@ import chalk from "chalk";
4
4
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
5
  import { vaultApiFetch } from "../utils/vault-api.js";
6
6
  import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
7
+ import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
7
8
 
8
9
  export const BODY_MAX_BYTES = 64 * 1024;
9
10
 
@@ -17,6 +18,8 @@ export interface FeedbackSubmitOptions {
17
18
  body: string;
18
19
  company?: string;
19
20
  token: string;
21
+ /** S3 object keys of already-uploaded screenshots (see uploadScreenshots). */
22
+ screenshots?: string[];
20
23
  }
21
24
 
22
25
  export async function readBodyFile(
@@ -46,6 +49,19 @@ export async function readBodyFile(
46
49
  export async function submitFeedback(
47
50
  opts: FeedbackSubmitOptions,
48
51
  ): Promise<FeedbackResult> {
52
+ // Validate the title locally, symmetric with the body check below. Commander's
53
+ // `requiredOption("--title")` only requires the flag to be PRESENT — an empty
54
+ // or whitespace-only value (`--title ""`, or a title the /hq-bug skill derived
55
+ // to nothing) passes the flag check, then the server rejects it with a 400
56
+ // "title (non-empty string) is required" that floods Sentry as a context-free
57
+ // warning (HQ-AB). Catch it here so the caller gets a clear, actionable error
58
+ // and the bad request never reaches the server.
59
+ if (opts.title.trim().length === 0) {
60
+ throw new Error(
61
+ "title must not be empty. Provide a short, non-whitespace title via --title.",
62
+ );
63
+ }
64
+
49
65
  if (opts.body.trim().length === 0) {
50
66
  throw new Error(
51
67
  "body must not be empty. Provide at least one non-whitespace character.",
@@ -70,6 +86,9 @@ export async function submitFeedback(
70
86
  if (opts.company) {
71
87
  requestBody.company = opts.company;
72
88
  }
89
+ if (opts.screenshots && opts.screenshots.length > 0) {
90
+ requestBody.screenshots = opts.screenshots;
91
+ }
73
92
 
74
93
  const res = await vaultApiFetch({
75
94
  token: opts.token,
@@ -104,19 +123,33 @@ function registerSubcommand(feedbackCmd: Command, type: "bug" | "feature"): void
104
123
  "Path to a markdown file with the body; use - to read from stdin",
105
124
  )
106
125
  .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) {
126
+ .option(
127
+ "--screenshot <path>",
128
+ `Attach a screenshot (repeatable, up to ${MAX_SCREENSHOTS}; .png/.jpg/.jpeg/.webp/.gif)`,
129
+ (value: string, prev: string[]) => [...prev, value],
130
+ [] as string[],
131
+ )
132
+ .action(
133
+ async (opts: { title: string; bodyFile: string; company?: string; screenshot: string[] }) => {
134
+ try {
135
+ const token = await ensureCognitoToken({ interactive: false });
136
+ const body = await readBodyFile(opts.bodyFile);
137
+ // Validate + upload screenshots (direct-to-S3 via presigned PUT)
138
+ // before submitting, so the row references uploaded objects.
139
+ const screenshots = await uploadScreenshots({
140
+ paths: opts.screenshot ?? [],
141
+ token,
142
+ });
143
+ const result = await submitFeedback({
144
+ type,
145
+ title: opts.title,
146
+ body,
147
+ company: opts.company,
148
+ token,
149
+ screenshots,
150
+ });
151
+ console.log(`Submitted: ${result.id}`);
152
+ } catch (err) {
120
153
  console.error(
121
154
  chalk.red("Error:"),
122
155
  err instanceof Error ? err.message : String(err),
@@ -38,6 +38,7 @@ import { Command } from "commander";
38
38
  import {
39
39
  registerFilesCommand,
40
40
  runFilesDelete,
41
+ stripRedundantCompanyScope,
41
42
  FilesDeleteHttpError,
42
43
  formatFilesDeleteError,
43
44
  type FilesDeleteResponse,
@@ -242,6 +243,137 @@ describe("hq files delete — root/empty prefix rejected client-side", () => {
242
243
  }
243
244
  });
244
245
 
246
+ // HQ-8F: a caller who pastes an HQ *local* tree path (`companies/<slug>/…`)
247
+ // over-prefixes the bucket-relative vault key; the server rejects it with a 400
248
+ // (INVALID_PREFIX_COMPANIES_SCOPED), the source of the recurring warning. The
249
+ // CLI now strips the redundant scope BEFORE sending, so the local-looking path
250
+ // is gracefully normalized to bucket-relative.
251
+ describe("stripRedundantCompanyScope (HQ-8F, pure)", () => {
252
+ it("strips a leading companies/<slug>/ to the bucket-relative remainder", () => {
253
+ expect(stripRedundantCompanyScope("companies/acme/projects/foo")).toEqual({
254
+ prefix: "projects/foo",
255
+ strippedSlug: "acme",
256
+ });
257
+ expect(stripRedundantCompanyScope("companies/acme/projects/foo/*")).toEqual({
258
+ prefix: "projects/foo/*",
259
+ strippedSlug: "acme",
260
+ });
261
+ });
262
+
263
+ it("returns an EMPTY remainder for the company-root spellings (→ root-reject)", () => {
264
+ expect(stripRedundantCompanyScope("companies/acme")).toEqual({
265
+ prefix: "",
266
+ strippedSlug: "acme",
267
+ });
268
+ expect(stripRedundantCompanyScope("companies/acme/")).toEqual({
269
+ prefix: "",
270
+ strippedSlug: "acme",
271
+ });
272
+ });
273
+
274
+ it("leaves an already bucket-relative prefix untouched (null)", () => {
275
+ expect(stripRedundantCompanyScope("projects/foo/*")).toBeNull();
276
+ expect(stripRedundantCompanyScope("reports/q3/")).toBeNull();
277
+ // A non-scope path that merely starts with the word "companies" is NOT a
278
+ // scope prefix and must be left alone.
279
+ expect(stripRedundantCompanyScope("companiesreport/x")).toBeNull();
280
+ });
281
+ });
282
+
283
+ describe("hq files delete — HQ-8F company-scope normalization", () => {
284
+ it("strips companies/<slug>/ and sends the BUCKET-RELATIVE prefix (no 400)", async () => {
285
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
286
+ fetchSpy.mockResolvedValueOnce(
287
+ deleteResponse({ dryRun: true, matched: 1, keys: ["projects/foo/a.md"] }),
288
+ );
289
+ fetchSpy.mockResolvedValueOnce(
290
+ deleteResponse({ matched: 1, deleted: 1, tombstoned: 1, keys: ["projects/foo/a.md"] }),
291
+ );
292
+
293
+ await runFilesDelete(
294
+ {
295
+ // Trailing slash → also exercises the `/` → `/*` glob normalization.
296
+ prefix: "companies/acme/projects/foo/",
297
+ dryRun: false,
298
+ yes: true,
299
+ companySlug: undefined,
300
+ },
301
+ { confirm: async () => true },
302
+ );
303
+
304
+ const bodies = deleteCallBodies();
305
+ // Both the preview and the delete carry the stripped, bucket-relative prefix.
306
+ expect(bodies.map((b) => b.prefix)).toEqual(["projects/foo/*", "projects/foo/*"]);
307
+ expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
308
+ });
309
+
310
+ it("a bare companies/<slug> strips to empty → root-reject, no network call", async () => {
311
+ const program = buildProgram();
312
+ await expect(
313
+ program.parseAsync(["files", "delete", "companies/acme", "--yes"], {
314
+ from: "user",
315
+ }),
316
+ ).rejects.toThrow(/__EXIT__:1/);
317
+ expect(fetchSpy).not.toHaveBeenCalled();
318
+ expect(printedErr()).toContain("Refusing to delete the vault root");
319
+ });
320
+ });
321
+
322
+ // HQ-CA: the EXACT-KEY sibling of HQ-8F. When the over-prefixed path has no
323
+ // trailing slash and no wildcard (a literal key like
324
+ // `companies/<slug>/notes/foo.md`), the server takes the exact-key branch where
325
+ // `validateObjectKey` — not `validatePrefix` — rejects it with a 400 ("Invalid
326
+ // key: … do not prefix with 'companies/<slug>/'."). The same client-side strip
327
+ // runs UPSTREAM of the server's exact-vs-glob split, so it normalizes the exact
328
+ // key to bucket-relative and the 400 is never emitted. This locks that path,
329
+ // which #117 only exercised via the trailing-slash (glob) spelling.
330
+ describe("hq files delete — HQ-CA exact-key company-scope normalization", () => {
331
+ it("strips companies/<slug>/ from an EXACT key and sends the bucket-relative key (no glob, no 400)", async () => {
332
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
333
+ fetchSpy.mockResolvedValueOnce(
334
+ deleteResponse({
335
+ dryRun: true,
336
+ mode: "exact",
337
+ prefix: "notes/foo.md",
338
+ matched: 1,
339
+ keys: ["notes/foo.md"],
340
+ }),
341
+ );
342
+ fetchSpy.mockResolvedValueOnce(
343
+ deleteResponse({
344
+ mode: "exact",
345
+ prefix: "notes/foo.md",
346
+ matched: 1,
347
+ deleted: 1,
348
+ tombstoned: 1,
349
+ keys: ["notes/foo.md"],
350
+ }),
351
+ );
352
+
353
+ await runFilesDelete(
354
+ {
355
+ // No trailing slash, no wildcard → server takes the EXACT-key branch.
356
+ prefix: "companies/acme/notes/foo.md",
357
+ dryRun: false,
358
+ yes: true,
359
+ companySlug: undefined,
360
+ },
361
+ { confirm: async () => true },
362
+ );
363
+
364
+ const bodies = deleteCallBodies();
365
+ // Preview + delete both carry the stripped, bucket-relative EXACT key —
366
+ // never the `companies/acme/...` over-prefix that 400s, and never glob-ified
367
+ // into a prefix delete.
368
+ expect(bodies.map((b) => b.prefix)).toEqual(["notes/foo.md", "notes/foo.md"]);
369
+ for (const b of bodies) {
370
+ expect(b.prefix.startsWith("companies/")).toBe(false);
371
+ expect(b.prefix).not.toContain("*");
372
+ }
373
+ expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
374
+ });
375
+ });
376
+
245
377
  describe("hq files delete — server error mapping", () => {
246
378
  it("403 surfaces a clear not-authorized message and exits 1", async () => {
247
379
  fetchSpy.mockResolvedValueOnce(membershipResponse());
@@ -782,17 +782,58 @@ function printKeyPreview(resp: FilesDeleteResponse): void {
782
782
  }
783
783
  }
784
784
 
785
+ /**
786
+ * The vault bucket is already company-scoped, so a delete prefix must be
787
+ * BUCKET-RELATIVE (e.g. `projects/foo/*`). A caller who pastes an HQ *local*
788
+ * tree path (`companies/<slug>/projects/foo`) over-prefixes it; the server then
789
+ * rejects it with INVALID_PREFIX_COMPANIES_SCOPED (HTTP 400), the source of the
790
+ * recurring Sentry warning HQ-8F. Strip a redundant leading `companies/<slug>/`
791
+ * so the local-looking path is normalized to the bucket-relative key the vault
792
+ * actually stores. Returns the stripped slug for a one-line notice, or null when
793
+ * there was nothing to strip. Pure → unit-testable.
794
+ *
795
+ * This runs UPSTREAM of the server's exact-vs-glob branch, so it covers both
796
+ * the glob spelling (`companies/<slug>/projects/foo/*` → `validatePrefix`,
797
+ * HQ-8F) and the EXACT-key spelling (`companies/<slug>/notes/foo.md` →
798
+ * `validateObjectKey`, HQ-CA) with the same normalization.
799
+ */
800
+ export function stripRedundantCompanyScope(
801
+ prefix: string,
802
+ ): { prefix: string; strippedSlug: string } | null {
803
+ const m = /^companies\/([^/]+)(?:\/(.*))?$/.exec(prefix);
804
+ if (!m) return null;
805
+ return { prefix: m[2] ?? "", strippedSlug: m[1] };
806
+ }
807
+
785
808
  export async function runFilesDelete(
786
809
  params: RunFilesDeleteParams,
787
810
  deps: { confirm?: ConfirmFn } = {},
788
811
  ): Promise<void> {
789
812
  const confirm = deps.confirm ?? realConfirm;
790
813
 
814
+ // The vault is already company-scoped — a `companies/<slug>/` prefix is the HQ
815
+ // LOCAL tree layout, not a vault key, and the server 400s it (HQ-8F). Strip it
816
+ // here so a pasted local path is gracefully normalized to bucket-relative
817
+ // BEFORE the dry-run/preview (so the operator still sees the exact keys and
818
+ // confirms the right target). If stripping empties the prefix, the root-reject
819
+ // below catches it with a clear message.
820
+ const scope = stripRedundantCompanyScope(params.prefix);
821
+ if (scope) {
822
+ console.error(
823
+ chalk.yellow(
824
+ `Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
825
+ `is already company-scoped; using bucket-relative ` +
826
+ `'${scope.prefix || "(root)"}'.`,
827
+ ),
828
+ );
829
+ }
830
+ const rawPrefix = scope ? scope.prefix : params.prefix;
831
+
791
832
  // Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
792
833
  // then reject the root/empty prefix CLIENT-side so a typo never reaches the
793
834
  // server as a vault-wide delete. The server enforces this too (defense in
794
835
  // depth), but failing fast here is clearer and avoids a wasted round-trip.
795
- const normalized = normalizeFilePrefix(params.prefix);
836
+ const normalized = normalizeFilePrefix(rawPrefix);
796
837
  if (normalized === "" || normalized === "*" || normalized === "/*") {
797
838
  console.error(
798
839
  chalk.red(