@indigoai-us/hq-cli 5.45.0 → 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]="c72d8e20-42a6-5818-b5d3-985e53954430")}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";
@@ -178,7 +179,20 @@ export function registerFilesCommand(program) {
178
179
  console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
179
180
  }
180
181
  else if (res.status === 404) {
181
- console.log(chalk.green(`Grant already absent for '${canonicalPrefix}' / ${principalLabel}`));
182
+ // A 404 means ZERO rows were removed — never report a green success
183
+ // that implies a revoke happened. Distinguish the two causes:
184
+ // ACL_NOT_FOUND — the prefix has no ACL at all (nothing was ever
185
+ // shared here): genuinely, benignly absent.
186
+ // GRANT_NOT_FOUND (or an older server with no code) — the ACL
187
+ // exists but no grant matched this principal:
188
+ // surface it as a warning so a typo'd/stale
189
+ // principal isn't mistaken for a real removal.
190
+ if (body.code === "ACL_NOT_FOUND") {
191
+ console.log(chalk.dim(`Nothing to remove — '${canonicalPrefix}' has no sharing grants.`));
192
+ }
193
+ else {
194
+ console.warn(chalk.yellow(`No matching grant for ${principalLabel} on '${canonicalPrefix}' — nothing was removed.`));
195
+ }
182
196
  return;
183
197
  }
184
198
  else if (res.status >= 500) {
@@ -315,6 +329,25 @@ export function registerFilesCommand(program) {
315
329
  process.exit(1);
316
330
  }
317
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
+ });
318
351
  // Return the `files` Commander group so callers (src/index.ts) can attach
319
352
  // additional subcommands (e.g. `hq files browse`/`hq files cat` from
320
353
  // files-browse.ts) onto the same group without re-creating it.
@@ -459,5 +492,160 @@ async function runShareSession(params) {
459
492
  console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
460
493
  }
461
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
+ }
462
650
  //# sourceMappingURL=files.js.map
463
- //# debugId=c72d8e20-42a6-5818-b5d3-985e53954430
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.0",
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
+ });
@@ -502,3 +502,91 @@ describe("hq files share — direct-grant fork (with --with)", () => {
502
502
  expect(fetchSpy).not.toHaveBeenCalled();
503
503
  });
504
504
  });
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // hq files unshare — never reports a false "removed" on a zero-row revoke
508
+ // (Ridge / Connor #5, CLI side)
509
+ // ---------------------------------------------------------------------------
510
+
511
+ describe("hq files unshare", () => {
512
+ function mockCompanyResolution() {
513
+ // /membership/me for getCompanyUid
514
+ fetchSpy.mockResolvedValueOnce(
515
+ jsonResponse(200, {
516
+ memberships: [
517
+ { membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
518
+ ],
519
+ }),
520
+ );
521
+ }
522
+
523
+ it("prints a green success ONLY when the server actually removed a grant (200)", async () => {
524
+ mockCompanyResolution();
525
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "fooPath" } }));
526
+
527
+ const program = buildProgram();
528
+ await program.parseAsync(
529
+ ["files", "unshare", "fooPath", "--with", "user@example.com"],
530
+ { from: "user" },
531
+ );
532
+
533
+ const logs = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
534
+ expect(logs).toMatch(/Removed grant for user@example.com on 'fooPath'/);
535
+ });
536
+
537
+ it("#5: does NOT print a green 'removed' success when the revoke matched zero rows (GRANT_NOT_FOUND)", async () => {
538
+ mockCompanyResolution();
539
+ fetchSpy.mockResolvedValueOnce(
540
+ jsonResponse(404, { error: "grant not found", code: "GRANT_NOT_FOUND" }),
541
+ );
542
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
543
+
544
+ const program = buildProgram();
545
+ // Must NOT exit non-zero (idempotent no-op), and must NOT throw.
546
+ await program.parseAsync(
547
+ ["files", "unshare", "fooPath", "--with", "user@example.com"],
548
+ { from: "user" },
549
+ );
550
+
551
+ const logs = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
552
+ const warns = warnSpy.mock.calls.map((c) => String(c[0])).join("\n");
553
+ // The dangerous false-success line must be absent.
554
+ expect(logs).not.toMatch(/Removed grant/);
555
+ // A truthful "nothing was removed" warning is surfaced instead.
556
+ expect(warns).toMatch(/nothing was removed/i);
557
+ });
558
+
559
+ it("treats a prefix with no ACL (ACL_NOT_FOUND) as benignly absent, not a removal", async () => {
560
+ mockCompanyResolution();
561
+ fetchSpy.mockResolvedValueOnce(
562
+ jsonResponse(404, { error: "ACL not found", code: "ACL_NOT_FOUND" }),
563
+ );
564
+
565
+ const program = buildProgram();
566
+ await program.parseAsync(
567
+ ["files", "unshare", "never-shared", "--with", "user@example.com"],
568
+ { from: "user" },
569
+ );
570
+
571
+ const logs = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
572
+ expect(logs).not.toMatch(/Removed grant/);
573
+ expect(logs).toMatch(/has no sharing grants/);
574
+ });
575
+
576
+ it("falls back to a warning for an older server that returns 404 without a code", async () => {
577
+ mockCompanyResolution();
578
+ fetchSpy.mockResolvedValueOnce(jsonResponse(404, { error: "grant not found" }));
579
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
580
+
581
+ const program = buildProgram();
582
+ await program.parseAsync(
583
+ ["files", "unshare", "fooPath", "--with", "user@example.com"],
584
+ { from: "user" },
585
+ );
586
+
587
+ const logs = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
588
+ const warns = warnSpy.mock.calls.map((c) => String(c[0])).join("\n");
589
+ expect(logs).not.toMatch(/Removed grant/);
590
+ expect(warns).toMatch(/nothing was removed/i);
591
+ });
592
+ });
@@ -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";
@@ -243,7 +244,25 @@ export function registerFilesCommand(program: Command): Command {
243
244
  } else if (res.status === 403) {
244
245
  console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
245
246
  } else if (res.status === 404) {
246
- console.log(chalk.green(`Grant already absent for '${canonicalPrefix}' / ${principalLabel}`));
247
+ // A 404 means ZERO rows were removed — never report a green success
248
+ // that implies a revoke happened. Distinguish the two causes:
249
+ // ACL_NOT_FOUND — the prefix has no ACL at all (nothing was ever
250
+ // shared here): genuinely, benignly absent.
251
+ // GRANT_NOT_FOUND (or an older server with no code) — the ACL
252
+ // exists but no grant matched this principal:
253
+ // surface it as a warning so a typo'd/stale
254
+ // principal isn't mistaken for a real removal.
255
+ if (body.code === "ACL_NOT_FOUND") {
256
+ console.log(
257
+ chalk.dim(`Nothing to remove — '${canonicalPrefix}' has no sharing grants.`),
258
+ );
259
+ } else {
260
+ console.warn(
261
+ chalk.yellow(
262
+ `No matching grant for ${principalLabel} on '${canonicalPrefix}' — nothing was removed.`,
263
+ ),
264
+ );
265
+ }
247
266
  return;
248
267
  } else if (res.status >= 500) {
249
268
  console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
@@ -408,6 +427,35 @@ export function registerFilesCommand(program: Command): Command {
408
427
  }
409
428
  });
410
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
+
411
459
  // Return the `files` Commander group so callers (src/index.ts) can attach
412
460
  // additional subcommands (e.g. `hq files browse`/`hq files cat` from
413
461
  // files-browse.ts) onto the same group without re-creating it.
@@ -607,3 +655,248 @@ async function runShareSession(params: RunShareSessionParams): Promise<void> {
607
655
  console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
608
656
  }
609
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
+ }