@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.
- package/dist/commands/files.d.ts +31 -0
- package/dist/commands/files.js +177 -2
- package/package.json +1 -1
- package/src/commands/files-delete.test.ts +268 -0
- package/src/commands/files.ts +275 -0
package/dist/commands/files.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/files.js
CHANGED
|
@@ -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]="
|
|
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=
|
|
651
|
+
//# debugId=5f5242f3-b24f-58b0-a3ee-381c42c755fc
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/commands/files.ts
CHANGED
|
@@ -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
|
+
}
|