@indigoai-us/hq-cli 5.45.1 → 5.46.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.
@@ -41,4 +41,35 @@ export declare class ShareSessionHttpError extends Error {
41
41
  */
42
42
  export declare function formatShareSessionError(err: ShareSessionHttpError): string;
43
43
  export declare function registerFilesCommand(program: Command): Command;
44
+ /** Server response shape for POST /v1/files/delete. */
45
+ export interface FilesDeleteResponse {
46
+ prefix: string;
47
+ mode: "exact" | "prefix";
48
+ dryRun: boolean;
49
+ matched: number;
50
+ deleted: number;
51
+ skipped: number;
52
+ tombstoned: number;
53
+ keys: string[];
54
+ keysTruncated: boolean;
55
+ }
56
+ export declare class FilesDeleteHttpError extends Error {
57
+ readonly status: number;
58
+ readonly code?: string | undefined;
59
+ constructor(status: number, message: string, code?: string | undefined);
60
+ }
61
+ /** Map a FilesDeleteHttpError to user-facing copy. */
62
+ export declare function formatFilesDeleteError(err: FilesDeleteHttpError, prefix: string): string;
63
+ /** Injectable yes/no confirmation seam (stubbed in tests). */
64
+ export type ConfirmFn = (message: string) => Promise<boolean>;
65
+ interface RunFilesDeleteParams {
66
+ prefix: string;
67
+ dryRun: boolean;
68
+ yes: boolean;
69
+ companySlug: string | undefined;
70
+ }
71
+ export declare function runFilesDelete(params: RunFilesDeleteParams, deps?: {
72
+ confirm?: ConfirmFn;
73
+ }): Promise<void>;
74
+ export {};
44
75
  //# sourceMappingURL=files.d.ts.map
@@ -1,7 +1,8 @@
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]="8068c3b8-ee9c-5296-9b49-aa10bf38ccbd")}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]="5f5242f3-b24f-58b0-a3ee-381c42c755fc")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import open from "open";
5
+ import * as readline from "node:readline";
5
6
  import { ensureCognitoToken } from "../utils/cognito-session.js";
6
7
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
7
8
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
@@ -328,6 +329,25 @@ export function registerFilesCommand(program) {
328
329
  process.exit(1);
329
330
  }
330
331
  });
332
+ files
333
+ .command("delete <prefix>")
334
+ .description("Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes.")
335
+ .option("--dry-run", "List what WOULD be deleted without deleting anything")
336
+ .option("-y, --yes", "Skip the confirmation prompt (for scripts)")
337
+ .action(async (prefix, opts) => {
338
+ try {
339
+ await runFilesDelete({
340
+ prefix,
341
+ dryRun: opts.dryRun === true,
342
+ yes: opts.yes === true,
343
+ companySlug: files.opts().company,
344
+ });
345
+ }
346
+ catch (err) {
347
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
348
+ process.exit(1);
349
+ }
350
+ });
331
351
  // Return the `files` Commander group so callers (src/index.ts) can attach
332
352
  // additional subcommands (e.g. `hq files browse`/`hq files cat` from
333
353
  // files-browse.ts) onto the same group without re-creating it.
@@ -472,5 +492,160 @@ async function runShareSession(params) {
472
492
  console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
473
493
  }
474
494
  }
495
+ export class FilesDeleteHttpError extends Error {
496
+ status;
497
+ code;
498
+ constructor(status, message, code) {
499
+ super(message);
500
+ this.status = status;
501
+ this.code = code;
502
+ this.name = "FilesDeleteHttpError";
503
+ }
504
+ }
505
+ /** Map a FilesDeleteHttpError to user-facing copy. */
506
+ export function formatFilesDeleteError(err, prefix) {
507
+ if (err.status === 401) {
508
+ return "Not authenticated — please run `hq login`";
509
+ }
510
+ if (err.status === 403) {
511
+ return `Not authorized to delete '${prefix}' — you need write access on it`;
512
+ }
513
+ if (err.status === 400) {
514
+ return `Invalid request: ${err.message}`;
515
+ }
516
+ if (err.status >= 500) {
517
+ return `Server error: ${err.message}`;
518
+ }
519
+ return err.message || `Request failed (${err.status})`;
520
+ }
521
+ function realConfirm(message) {
522
+ const rl = readline.createInterface({
523
+ input: process.stdin,
524
+ output: process.stdout,
525
+ });
526
+ return new Promise((resolve) => {
527
+ rl.question(`${message} [y/N] `, (answer) => {
528
+ rl.close();
529
+ resolve(/^y(es)?$/i.test(answer.trim()));
530
+ });
531
+ });
532
+ }
533
+ /**
534
+ * POST /v1/files/delete. Throws FilesDeleteHttpError on any non-2xx so the one
535
+ * caller renders a single consistent error path.
536
+ */
537
+ async function callDeleteEndpoint(params) {
538
+ const res = await vaultApiFetch({
539
+ token: params.token,
540
+ path: "/v1/files/delete",
541
+ method: "POST",
542
+ body: {
543
+ company: params.companyUid,
544
+ prefix: params.prefix,
545
+ dryRun: params.dryRun,
546
+ },
547
+ });
548
+ if (!res.ok) {
549
+ const body = (await res.json().catch(() => ({})));
550
+ throw new FilesDeleteHttpError(res.status, body.message ?? body.error ?? res.statusText, body.code);
551
+ }
552
+ return (await res.json());
553
+ }
554
+ /** How many would-delete keys to list before truncating the preview. */
555
+ const DELETE_PREVIEW_KEYS = 20;
556
+ function printKeyPreview(resp) {
557
+ const shown = resp.keys.slice(0, DELETE_PREVIEW_KEYS);
558
+ for (const key of shown) {
559
+ console.log(chalk.dim(` ${key}`));
560
+ }
561
+ const hiddenInList = resp.keys.length - shown.length;
562
+ if (hiddenInList > 0) {
563
+ console.log(chalk.dim(` … and ${hiddenInList} more`));
564
+ }
565
+ else if (resp.keysTruncated) {
566
+ console.log(chalk.dim(` … and ${resp.matched - resp.keys.length} more`));
567
+ }
568
+ }
569
+ export async function runFilesDelete(params, deps = {}) {
570
+ const confirm = deps.confirm ?? realConfirm;
571
+ // Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
572
+ // then reject the root/empty prefix CLIENT-side so a typo never reaches the
573
+ // server as a vault-wide delete. The server enforces this too (defense in
574
+ // depth), but failing fast here is clearer and avoids a wasted round-trip.
575
+ const normalized = normalizeFilePrefix(params.prefix);
576
+ if (normalized === "" || normalized === "*" || normalized === "/*") {
577
+ console.error(chalk.red("Refusing to delete the vault root. Pass a bounded prefix (e.g. 'projects/foo/' or 'projects/foo/*') or an exact key."));
578
+ process.exit(1);
579
+ }
580
+ const token = await ensureCognitoToken();
581
+ const companyUid = await getCompanyUid(token, params.companySlug);
582
+ // 1. Always preview first — this is how we print the EXACT key count before
583
+ // deleting anything (and the whole behavior of --dry-run).
584
+ let preview;
585
+ try {
586
+ preview = await callDeleteEndpoint({
587
+ token,
588
+ companyUid,
589
+ prefix: normalized,
590
+ dryRun: true,
591
+ });
592
+ }
593
+ catch (err) {
594
+ if (err instanceof FilesDeleteHttpError) {
595
+ console.error(chalk.red(formatFilesDeleteError(err, normalized)));
596
+ process.exit(1);
597
+ }
598
+ throw err;
599
+ }
600
+ if (preview.matched === 0) {
601
+ console.log(chalk.dim(`Nothing to delete under '${normalized}'.`));
602
+ return;
603
+ }
604
+ const noun = preview.matched === 1 ? "object" : "objects";
605
+ if (params.dryRun) {
606
+ console.log(chalk.green(`[dry-run] Would delete ${preview.matched} ${noun} under '${normalized}':`));
607
+ printKeyPreview(preview);
608
+ if (preview.skipped > 0) {
609
+ console.log(chalk.dim(` (${preview.skipped} more under this prefix you can't delete would be left untouched)`));
610
+ }
611
+ console.log(chalk.dim("Nothing was deleted (--dry-run)."));
612
+ return;
613
+ }
614
+ // 2. Real delete — show what's about to happen, then confirm.
615
+ console.log(chalk.yellow(`About to delete ${preview.matched} ${noun} under '${normalized}':`));
616
+ printKeyPreview(preview);
617
+ if (preview.skipped > 0) {
618
+ console.log(chalk.dim(` (${preview.skipped} more under this prefix you can't delete will be left untouched)`));
619
+ }
620
+ if (!params.yes) {
621
+ const ok = await confirm(`Delete ${preview.matched} ${noun}? This removes them from the shared vault for everyone.`);
622
+ if (!ok) {
623
+ console.log(chalk.dim("Aborted — nothing was deleted."));
624
+ return;
625
+ }
626
+ }
627
+ // 3. Execute the delete.
628
+ let result;
629
+ try {
630
+ result = await callDeleteEndpoint({
631
+ token,
632
+ companyUid,
633
+ prefix: normalized,
634
+ dryRun: false,
635
+ });
636
+ }
637
+ catch (err) {
638
+ if (err instanceof FilesDeleteHttpError) {
639
+ console.error(chalk.red(formatFilesDeleteError(err, normalized)));
640
+ process.exit(1);
641
+ }
642
+ throw err;
643
+ }
644
+ const deletedNoun = result.deleted === 1 ? "object" : "objects";
645
+ console.log(chalk.green(`Deleted ${result.deleted} ${deletedNoun} under '${normalized}'.`));
646
+ if (result.skipped > 0) {
647
+ console.log(chalk.dim(`${result.skipped} object(s) under this prefix you can't delete were left untouched.`));
648
+ }
649
+ }
475
650
  //# sourceMappingURL=files.js.map
476
- //# debugId=8068c3b8-ee9c-5296-9b49-aa10bf38ccbd
651
+ //# debugId=5f5242f3-b24f-58b0-a3ee-381c42c755fc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.45.1",
3
+ "version": "5.46.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Unit tests for `hq files delete <prefix>` (files.ts).
3
+ *
4
+ * Coverage (mirrors the server-side regression list from the caller's side):
5
+ * - --dry-run previews the key set and deletes nothing;
6
+ * - a real delete previews the EXACT count, then deletes only after confirm;
7
+ * - --yes bypasses the confirmation prompt;
8
+ * - a declined confirmation deletes nothing;
9
+ * - the root/empty prefix is rejected CLIENT-side before any network call;
10
+ * - a 403 from the server is surfaced as a clear "not authorized" message;
11
+ * - the request body shape is exactly { company, prefix, dryRun }.
12
+ *
13
+ * Mirrors files.test.ts: globalThis.fetch + process.exit are spied; the
14
+ * `__EXIT__:<code>` throw mimics how Commander aborts on a failure path.
15
+ */
16
+
17
+ import {
18
+ afterEach,
19
+ beforeEach,
20
+ describe,
21
+ expect,
22
+ it,
23
+ vi,
24
+ type MockInstance,
25
+ } from "vitest";
26
+
27
+ vi.mock("open", () => ({ default: vi.fn(() => Promise.resolve({ pid: 0 })) }));
28
+
29
+ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
30
+ const original = (await importOriginal()) as Record<string, unknown>;
31
+ return {
32
+ ...original,
33
+ ensureCognitoToken: vi.fn(async () => "test-token"),
34
+ };
35
+ });
36
+
37
+ import { Command } from "commander";
38
+ import {
39
+ registerFilesCommand,
40
+ runFilesDelete,
41
+ FilesDeleteHttpError,
42
+ formatFilesDeleteError,
43
+ type FilesDeleteResponse,
44
+ } from "./files.js";
45
+
46
+ function jsonResponse(status: number, body: unknown): Response {
47
+ return new Response(JSON.stringify(body), {
48
+ status,
49
+ headers: { "Content-Type": "application/json" },
50
+ });
51
+ }
52
+
53
+ /** A membership response so getCompanyUid resolves a single active company. */
54
+ function membershipResponse(): Response {
55
+ return jsonResponse(200, {
56
+ memberships: [
57
+ { membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
58
+ ],
59
+ });
60
+ }
61
+
62
+ function deleteResponse(overrides: Partial<FilesDeleteResponse>): Response {
63
+ return jsonResponse(200, {
64
+ prefix: "reports/*",
65
+ mode: "prefix",
66
+ dryRun: false,
67
+ matched: 0,
68
+ deleted: 0,
69
+ skipped: 0,
70
+ tombstoned: 0,
71
+ keys: [],
72
+ keysTruncated: false,
73
+ ...overrides,
74
+ });
75
+ }
76
+
77
+ let fetchSpy: MockInstance<typeof fetch>;
78
+ let exitSpy: MockInstance<typeof process.exit>;
79
+ let logSpy: MockInstance<typeof console.log>;
80
+ let errSpy: MockInstance<typeof console.error>;
81
+
82
+ beforeEach(() => {
83
+ fetchSpy = vi.spyOn(globalThis, "fetch");
84
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
85
+ throw new Error(`__EXIT__:${code ?? 0}`);
86
+ }) as never);
87
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
88
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
89
+ });
90
+
91
+ afterEach(() => {
92
+ vi.restoreAllMocks();
93
+ });
94
+
95
+ function buildProgram(): Command {
96
+ const program = new Command();
97
+ program.exitOverride();
98
+ registerFilesCommand(program);
99
+ return program;
100
+ }
101
+
102
+ function deleteCalls() {
103
+ return fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/v1/files/delete"));
104
+ }
105
+
106
+ function deleteCallBodies(): Array<{ company: string; prefix: string; dryRun: boolean }> {
107
+ return deleteCalls().map((c) => JSON.parse((c[1]?.body as string) ?? "{}"));
108
+ }
109
+
110
+ function printed(): string {
111
+ return logSpy.mock.calls.map((c) => String(c[0])).join("\n");
112
+ }
113
+ function printedErr(): string {
114
+ return errSpy.mock.calls.map((c) => String(c[0])).join("\n");
115
+ }
116
+
117
+ describe("hq files delete — --dry-run", () => {
118
+ it("previews the key set, sends only a dryRun:true request, deletes nothing", async () => {
119
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
120
+ fetchSpy.mockResolvedValueOnce(
121
+ deleteResponse({
122
+ dryRun: true,
123
+ matched: 2,
124
+ keys: ["reports/a.md", "reports/b.md"],
125
+ }),
126
+ );
127
+
128
+ const program = buildProgram();
129
+ await program.parseAsync(["files", "delete", "reports/", "--dry-run"], { from: "user" });
130
+
131
+ const bodies = deleteCallBodies();
132
+ expect(bodies).toHaveLength(1);
133
+ expect(bodies[0]).toEqual({ company: "cmp_acme", prefix: "reports/*", dryRun: true });
134
+ expect(printed()).toContain("[dry-run]");
135
+ expect(printed()).toContain("reports/a.md");
136
+ expect(exitSpy).not.toHaveBeenCalled();
137
+ });
138
+ });
139
+
140
+ describe("hq files delete — real delete with --yes", () => {
141
+ it("previews then deletes (dryRun:true then dryRun:false), no confirm needed", async () => {
142
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
143
+ fetchSpy.mockResolvedValueOnce(
144
+ deleteResponse({ dryRun: true, matched: 2, keys: ["reports/a.md", "reports/b.md"] }),
145
+ );
146
+ fetchSpy.mockResolvedValueOnce(
147
+ deleteResponse({ matched: 2, deleted: 2, tombstoned: 2, keys: ["reports/a.md", "reports/b.md"] }),
148
+ );
149
+
150
+ const program = buildProgram();
151
+ await program.parseAsync(["files", "delete", "reports/*", "--yes"], { from: "user" });
152
+
153
+ const bodies = deleteCallBodies();
154
+ expect(bodies).toHaveLength(2);
155
+ expect(bodies[0].dryRun).toBe(true); // preview first
156
+ expect(bodies[1].dryRun).toBe(false); // then the real delete
157
+ expect(printed()).toContain("Deleted 2");
158
+ expect(exitSpy).not.toHaveBeenCalled();
159
+ });
160
+ });
161
+
162
+ describe("hq files delete — confirmation gating", () => {
163
+ it("a declined confirmation deletes nothing (no dryRun:false request)", async () => {
164
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
165
+ fetchSpy.mockResolvedValueOnce(
166
+ deleteResponse({ dryRun: true, matched: 3, keys: ["x/a", "x/b", "x/c"] }),
167
+ );
168
+
169
+ await runFilesDelete(
170
+ { prefix: "x/*", dryRun: false, yes: false, companySlug: undefined },
171
+ { confirm: async () => false },
172
+ );
173
+
174
+ const bodies = deleteCallBodies();
175
+ expect(bodies).toHaveLength(1); // only the preview
176
+ expect(bodies[0].dryRun).toBe(true);
177
+ expect(printed()).toContain("Aborted");
178
+ });
179
+
180
+ it("an accepted confirmation proceeds with the delete", async () => {
181
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
182
+ fetchSpy.mockResolvedValueOnce(
183
+ deleteResponse({ dryRun: true, matched: 1, keys: ["x/a"] }),
184
+ );
185
+ fetchSpy.mockResolvedValueOnce(
186
+ deleteResponse({ matched: 1, deleted: 1, tombstoned: 1, keys: ["x/a"] }),
187
+ );
188
+
189
+ await runFilesDelete(
190
+ { prefix: "x/*", dryRun: false, yes: false, companySlug: undefined },
191
+ { confirm: async () => true },
192
+ );
193
+
194
+ const bodies = deleteCallBodies();
195
+ expect(bodies).toHaveLength(2);
196
+ expect(bodies[1].dryRun).toBe(false);
197
+ expect(printed()).toContain("Deleted 1");
198
+ });
199
+
200
+ it("reports skipped keys (peer files the caller can't delete) in the preview", async () => {
201
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
202
+ fetchSpy.mockResolvedValueOnce(
203
+ deleteResponse({ dryRun: true, matched: 1, skipped: 2, keys: ["x/mine.md"] }),
204
+ );
205
+
206
+ await runFilesDelete(
207
+ { prefix: "x/*", dryRun: false, yes: false, companySlug: undefined },
208
+ { confirm: async () => false },
209
+ );
210
+
211
+ expect(printed()).toContain("2 more under this prefix you can't delete");
212
+ });
213
+ });
214
+
215
+ describe("hq files delete — nothing to delete", () => {
216
+ it("prints a no-op message and makes no second request", async () => {
217
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
218
+ fetchSpy.mockResolvedValueOnce(deleteResponse({ dryRun: true, matched: 0, keys: [] }));
219
+
220
+ let confirmCalled = false;
221
+ await runFilesDelete(
222
+ { prefix: "empty/*", dryRun: false, yes: false, companySlug: undefined },
223
+ { confirm: async () => { confirmCalled = true; return true; } },
224
+ );
225
+
226
+ expect(deleteCallBodies()).toHaveLength(1);
227
+ expect(confirmCalled).toBe(false);
228
+ expect(printed()).toContain("Nothing to delete");
229
+ });
230
+ });
231
+
232
+ describe("hq files delete — root/empty prefix rejected client-side", () => {
233
+ for (const prefix of ["", "*", "/", "/*"]) {
234
+ it(`rejects ${JSON.stringify(prefix)} before any network call`, async () => {
235
+ const program = buildProgram();
236
+ await expect(
237
+ program.parseAsync(["files", "delete", prefix], { from: "user" }),
238
+ ).rejects.toThrow(/__EXIT__:1/);
239
+ expect(fetchSpy).not.toHaveBeenCalled();
240
+ expect(printedErr()).toContain("Refusing to delete the vault root");
241
+ });
242
+ }
243
+ });
244
+
245
+ describe("hq files delete — server error mapping", () => {
246
+ it("403 surfaces a clear not-authorized message and exits 1", async () => {
247
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
248
+ fetchSpy.mockResolvedValueOnce(
249
+ jsonResponse(403, { error: "forbidden", code: "FILES_DELETE_FORBIDDEN" }),
250
+ );
251
+
252
+ const program = buildProgram();
253
+ await expect(
254
+ program.parseAsync(["files", "delete", "reports/*"], { from: "user" }),
255
+ ).rejects.toThrow(/__EXIT__:1/);
256
+
257
+ expect(printedErr()).toContain("Not authorized to delete 'reports/*'");
258
+ // Only the preview call happened; no delete was issued.
259
+ expect(deleteCallBodies()).toHaveLength(1);
260
+ });
261
+
262
+ it("formatFilesDeleteError maps statuses to copy", () => {
263
+ expect(formatFilesDeleteError(new FilesDeleteHttpError(401, "x"), "p")).toMatch(/hq login/);
264
+ expect(formatFilesDeleteError(new FilesDeleteHttpError(403, "x"), "p/*")).toMatch(/Not authorized to delete 'p\/\*'/);
265
+ expect(formatFilesDeleteError(new FilesDeleteHttpError(400, "bad"), "p")).toMatch(/Invalid request: bad/);
266
+ expect(formatFilesDeleteError(new FilesDeleteHttpError(500, "boom"), "p")).toMatch(/Server error: boom/);
267
+ });
268
+ });
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import open from "open";
4
+ import * as readline from "node:readline";
4
5
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
6
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
6
7
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
@@ -426,6 +427,35 @@ export function registerFilesCommand(program: Command): Command {
426
427
  }
427
428
  });
428
429
 
430
+ files
431
+ .command("delete <prefix>")
432
+ .description(
433
+ "Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes.",
434
+ )
435
+ .option(
436
+ "--dry-run",
437
+ "List what WOULD be deleted without deleting anything",
438
+ )
439
+ .option("-y, --yes", "Skip the confirmation prompt (for scripts)")
440
+ .action(
441
+ async (prefix: string, opts: { dryRun?: boolean; yes?: boolean }) => {
442
+ try {
443
+ await runFilesDelete({
444
+ prefix,
445
+ dryRun: opts.dryRun === true,
446
+ yes: opts.yes === true,
447
+ companySlug: files.opts().company as string | undefined,
448
+ });
449
+ } catch (err) {
450
+ console.error(
451
+ chalk.red("Error:"),
452
+ err instanceof Error ? err.message : String(err),
453
+ );
454
+ process.exit(1);
455
+ }
456
+ },
457
+ );
458
+
429
459
  // Return the `files` Commander group so callers (src/index.ts) can attach
430
460
  // additional subcommands (e.g. `hq files browse`/`hq files cat` from
431
461
  // files-browse.ts) onto the same group without re-creating it.
@@ -625,3 +655,248 @@ async function runShareSession(params: RunShareSessionParams): Promise<void> {
625
655
  console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
626
656
  }
627
657
  }
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // Scoped delete flow — `hq files delete <prefix>`
661
+ //
662
+ // Talks to the bounded server-side delete endpoint (POST /v1/files/delete).
663
+ // The server is the authority on scope + authorization + the actual S3 delete;
664
+ // this command is a careful front door:
665
+ // 1. normalize + reject the root/empty prefix CLIENT-side (defense in depth);
666
+ // 2. ALWAYS run a dry-run first to fetch + print the exact key count;
667
+ // 3. for a real delete, require confirmation unless --yes;
668
+ // 4. NEVER infer scope from local filesystem state.
669
+ // ---------------------------------------------------------------------------
670
+
671
+ /** Server response shape for POST /v1/files/delete. */
672
+ export interface FilesDeleteResponse {
673
+ prefix: string;
674
+ mode: "exact" | "prefix";
675
+ dryRun: boolean;
676
+ matched: number;
677
+ deleted: number;
678
+ skipped: number;
679
+ tombstoned: number;
680
+ keys: string[];
681
+ keysTruncated: boolean;
682
+ }
683
+
684
+ export class FilesDeleteHttpError extends Error {
685
+ constructor(
686
+ public readonly status: number,
687
+ message: string,
688
+ public readonly code?: string,
689
+ ) {
690
+ super(message);
691
+ this.name = "FilesDeleteHttpError";
692
+ }
693
+ }
694
+
695
+ /** Map a FilesDeleteHttpError to user-facing copy. */
696
+ export function formatFilesDeleteError(
697
+ err: FilesDeleteHttpError,
698
+ prefix: string,
699
+ ): string {
700
+ if (err.status === 401) {
701
+ return "Not authenticated — please run `hq login`";
702
+ }
703
+ if (err.status === 403) {
704
+ return `Not authorized to delete '${prefix}' — you need write access on it`;
705
+ }
706
+ if (err.status === 400) {
707
+ return `Invalid request: ${err.message}`;
708
+ }
709
+ if (err.status >= 500) {
710
+ return `Server error: ${err.message}`;
711
+ }
712
+ return err.message || `Request failed (${err.status})`;
713
+ }
714
+
715
+ /** Injectable yes/no confirmation seam (stubbed in tests). */
716
+ export type ConfirmFn = (message: string) => Promise<boolean>;
717
+
718
+ function realConfirm(message: string): Promise<boolean> {
719
+ const rl = readline.createInterface({
720
+ input: process.stdin,
721
+ output: process.stdout,
722
+ });
723
+ return new Promise((resolve) => {
724
+ rl.question(`${message} [y/N] `, (answer) => {
725
+ rl.close();
726
+ resolve(/^y(es)?$/i.test(answer.trim()));
727
+ });
728
+ });
729
+ }
730
+
731
+ /**
732
+ * POST /v1/files/delete. Throws FilesDeleteHttpError on any non-2xx so the one
733
+ * caller renders a single consistent error path.
734
+ */
735
+ async function callDeleteEndpoint(params: {
736
+ token: string;
737
+ companyUid: string;
738
+ prefix: string;
739
+ dryRun: boolean;
740
+ }): Promise<FilesDeleteResponse> {
741
+ const res = await vaultApiFetch({
742
+ token: params.token,
743
+ path: "/v1/files/delete",
744
+ method: "POST",
745
+ body: {
746
+ company: params.companyUid,
747
+ prefix: params.prefix,
748
+ dryRun: params.dryRun,
749
+ },
750
+ });
751
+ if (!res.ok) {
752
+ const body = (await res.json().catch(() => ({}))) as Record<string, string>;
753
+ throw new FilesDeleteHttpError(
754
+ res.status,
755
+ body.message ?? body.error ?? res.statusText,
756
+ body.code,
757
+ );
758
+ }
759
+ return (await res.json()) as FilesDeleteResponse;
760
+ }
761
+
762
+ interface RunFilesDeleteParams {
763
+ prefix: string;
764
+ dryRun: boolean;
765
+ yes: boolean;
766
+ companySlug: string | undefined;
767
+ }
768
+
769
+ /** How many would-delete keys to list before truncating the preview. */
770
+ const DELETE_PREVIEW_KEYS = 20;
771
+
772
+ function printKeyPreview(resp: FilesDeleteResponse): void {
773
+ const shown = resp.keys.slice(0, DELETE_PREVIEW_KEYS);
774
+ for (const key of shown) {
775
+ console.log(chalk.dim(` ${key}`));
776
+ }
777
+ const hiddenInList = resp.keys.length - shown.length;
778
+ if (hiddenInList > 0) {
779
+ console.log(chalk.dim(` … and ${hiddenInList} more`));
780
+ } else if (resp.keysTruncated) {
781
+ console.log(chalk.dim(` … and ${resp.matched - resp.keys.length} more`));
782
+ }
783
+ }
784
+
785
+ export async function runFilesDelete(
786
+ params: RunFilesDeleteParams,
787
+ deps: { confirm?: ConfirmFn } = {},
788
+ ): Promise<void> {
789
+ const confirm = deps.confirm ?? realConfirm;
790
+
791
+ // Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
792
+ // then reject the root/empty prefix CLIENT-side so a typo never reaches the
793
+ // server as a vault-wide delete. The server enforces this too (defense in
794
+ // depth), but failing fast here is clearer and avoids a wasted round-trip.
795
+ const normalized = normalizeFilePrefix(params.prefix);
796
+ if (normalized === "" || normalized === "*" || normalized === "/*") {
797
+ console.error(
798
+ chalk.red(
799
+ "Refusing to delete the vault root. Pass a bounded prefix (e.g. 'projects/foo/' or 'projects/foo/*') or an exact key.",
800
+ ),
801
+ );
802
+ process.exit(1);
803
+ }
804
+
805
+ const token = await ensureCognitoToken();
806
+ const companyUid = await getCompanyUid(token, params.companySlug);
807
+
808
+ // 1. Always preview first — this is how we print the EXACT key count before
809
+ // deleting anything (and the whole behavior of --dry-run).
810
+ let preview: FilesDeleteResponse;
811
+ try {
812
+ preview = await callDeleteEndpoint({
813
+ token,
814
+ companyUid,
815
+ prefix: normalized,
816
+ dryRun: true,
817
+ });
818
+ } catch (err) {
819
+ if (err instanceof FilesDeleteHttpError) {
820
+ console.error(chalk.red(formatFilesDeleteError(err, normalized)));
821
+ process.exit(1);
822
+ }
823
+ throw err;
824
+ }
825
+
826
+ if (preview.matched === 0) {
827
+ console.log(chalk.dim(`Nothing to delete under '${normalized}'.`));
828
+ return;
829
+ }
830
+
831
+ const noun = preview.matched === 1 ? "object" : "objects";
832
+
833
+ if (params.dryRun) {
834
+ console.log(
835
+ chalk.green(
836
+ `[dry-run] Would delete ${preview.matched} ${noun} under '${normalized}':`,
837
+ ),
838
+ );
839
+ printKeyPreview(preview);
840
+ if (preview.skipped > 0) {
841
+ console.log(
842
+ chalk.dim(
843
+ ` (${preview.skipped} more under this prefix you can't delete would be left untouched)`,
844
+ ),
845
+ );
846
+ }
847
+ console.log(chalk.dim("Nothing was deleted (--dry-run)."));
848
+ return;
849
+ }
850
+
851
+ // 2. Real delete — show what's about to happen, then confirm.
852
+ console.log(
853
+ chalk.yellow(`About to delete ${preview.matched} ${noun} under '${normalized}':`),
854
+ );
855
+ printKeyPreview(preview);
856
+ if (preview.skipped > 0) {
857
+ console.log(
858
+ chalk.dim(
859
+ ` (${preview.skipped} more under this prefix you can't delete will be left untouched)`,
860
+ ),
861
+ );
862
+ }
863
+
864
+ if (!params.yes) {
865
+ const ok = await confirm(
866
+ `Delete ${preview.matched} ${noun}? This removes them from the shared vault for everyone.`,
867
+ );
868
+ if (!ok) {
869
+ console.log(chalk.dim("Aborted — nothing was deleted."));
870
+ return;
871
+ }
872
+ }
873
+
874
+ // 3. Execute the delete.
875
+ let result: FilesDeleteResponse;
876
+ try {
877
+ result = await callDeleteEndpoint({
878
+ token,
879
+ companyUid,
880
+ prefix: normalized,
881
+ dryRun: false,
882
+ });
883
+ } catch (err) {
884
+ if (err instanceof FilesDeleteHttpError) {
885
+ console.error(chalk.red(formatFilesDeleteError(err, normalized)));
886
+ process.exit(1);
887
+ }
888
+ throw err;
889
+ }
890
+
891
+ const deletedNoun = result.deleted === 1 ? "object" : "objects";
892
+ console.log(
893
+ chalk.green(`Deleted ${result.deleted} ${deletedNoun} under '${normalized}'.`),
894
+ );
895
+ if (result.skipped > 0) {
896
+ console.log(
897
+ chalk.dim(
898
+ `${result.skipped} object(s) under this prefix you can't delete were left untouched.`,
899
+ ),
900
+ );
901
+ }
902
+ }