@indigoai-us/hq-cli 5.76.0 → 5.77.1
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/CHANGELOG.md +7 -0
- package/dist/commands/agents.js +8 -3
- package/dist/commands/cloud.d.ts +7 -0
- package/dist/commands/cloud.js +21 -0
- package/dist/commands/files.d.ts +61 -0
- package/dist/commands/files.js +274 -0
- package/dist/commands/outposts.js +2 -2
- package/dist/main.js +8 -1
- package/dist/utils/billing-gate.d.ts +15 -0
- package/dist/utils/billing-gate.js +35 -0
- package/dist/utils/settle-with-timeout.d.ts +7 -0
- package/dist/utils/settle-with-timeout.js +22 -0
- package/dist/utils/vault-api.js +3 -0
- package/package.json +1 -1
- package/src/commands/agents.test.ts +41 -0
- package/src/commands/agents.ts +7 -3
- package/src/commands/cloud.test.ts +41 -0
- package/src/commands/cloud.ts +33 -0
- package/src/commands/files-recovery.test.ts +361 -0
- package/src/commands/files.ts +410 -0
- package/src/commands/outposts.test.ts +37 -0
- package/src/commands/outposts.ts +2 -2
- package/src/main.ts +12 -1
- package/src/utils/billing-gate.ts +46 -0
- package/src/utils/settle-with-timeout.test.ts +21 -0
- package/src/utils/settle-with-timeout.ts +22 -0
- package/src/utils/vault-api.test.ts +14 -0
- package/src/utils/vault-api.ts +8 -0
|
@@ -363,6 +363,47 @@ describe("hq agents provision (billing gate)", () => {
|
|
|
363
363
|
expect(printed).toContain("https://checkout.stripe.com/card");
|
|
364
364
|
});
|
|
365
365
|
|
|
366
|
+
it("surfaces the DECLINE reason on 402 payment_failed (message-first decoding)", async () => {
|
|
367
|
+
// The payment_failed envelope carries BOTH the generic error AND the
|
|
368
|
+
// friendly decline message — the decode must prefer `message` or the
|
|
369
|
+
// decline copy is lost and the operator sees "add a card" for a card
|
|
370
|
+
// that exists and was declined.
|
|
371
|
+
fetchSpy
|
|
372
|
+
.mockResolvedValueOnce(
|
|
373
|
+
jsonResponse(402, {
|
|
374
|
+
error: "payment required",
|
|
375
|
+
message:
|
|
376
|
+
"Your card was declined. Try a different card or contact your bank.",
|
|
377
|
+
code: "PAYMENT_FAILED",
|
|
378
|
+
billing: {
|
|
379
|
+
status: "payment_failed",
|
|
380
|
+
setup: {
|
|
381
|
+
payerType: "company",
|
|
382
|
+
path: "/v1/billing/checkout/org",
|
|
383
|
+
method: "POST",
|
|
384
|
+
body: { companyUid: "cmp_acme" },
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
)
|
|
389
|
+
.mockResolvedValueOnce(
|
|
390
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/update" }),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const logSpyLocal = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
394
|
+
const errSpyLocal = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
395
|
+
await expect(
|
|
396
|
+
run(["agents", "--company", "acme", "provision", "Ops Bot", "--yes"]),
|
|
397
|
+
).rejects.toThrow("process.exit(1)");
|
|
398
|
+
|
|
399
|
+
const printed = [...errSpyLocal.mock.calls, ...logSpyLocal.mock.calls]
|
|
400
|
+
.map((c) => c.map(String).join(" "))
|
|
401
|
+
.join("\n");
|
|
402
|
+
expect(printed).toContain("declined");
|
|
403
|
+
expect(printed).not.toContain("No card on file");
|
|
404
|
+
expect(printed).toContain("https://checkout.stripe.com/update");
|
|
405
|
+
});
|
|
406
|
+
|
|
366
407
|
it("requires --api-key-env for --auth-mode apiKey (before any charge)", async () => {
|
|
367
408
|
await expect(
|
|
368
409
|
run([
|
package/src/commands/agents.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
AGENT_PRICE_CENTS,
|
|
32
32
|
confirmChargeOrExit,
|
|
33
33
|
parseBillingPayload,
|
|
34
|
-
|
|
34
|
+
surfaceBillingBlocked,
|
|
35
35
|
type BillingErrorPayload,
|
|
36
36
|
} from "../utils/billing-gate.js";
|
|
37
37
|
|
|
@@ -125,7 +125,11 @@ export async function agentsRequest<T>(opts: {
|
|
|
125
125
|
};
|
|
126
126
|
throw new AgentsHttpError(
|
|
127
127
|
res.status,
|
|
128
|
-
|
|
128
|
+
// `message` FIRST: on a payment_failed envelope it carries the friendly
|
|
129
|
+
// decline copy ("Your card was declined…") while `error` is the generic
|
|
130
|
+
// "payment required" — error-first would feed surfaceBillingBlocked the
|
|
131
|
+
// generic string and lose the decline reason (mirrors outpostRequest).
|
|
132
|
+
body.message ?? body.error ?? res.statusText,
|
|
129
133
|
body.code,
|
|
130
134
|
parseBillingPayload(body),
|
|
131
135
|
);
|
|
@@ -592,7 +596,7 @@ export function registerAgentsCommand(program: Command): void {
|
|
|
592
596
|
err.status === 402 &&
|
|
593
597
|
err.billing
|
|
594
598
|
) {
|
|
595
|
-
await
|
|
599
|
+
await surfaceBillingBlocked(token, err.billing, err.message);
|
|
596
600
|
process.exit(1);
|
|
597
601
|
}
|
|
598
602
|
throw err;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { configureSyncLockTimeout } from "./cloud.js";
|
|
4
|
+
|
|
5
|
+
const originalLockTimeout = process.env.HQ_OP_LOCK_TIMEOUT;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
if (originalLockTimeout === undefined) {
|
|
9
|
+
delete process.env.HQ_OP_LOCK_TIMEOUT;
|
|
10
|
+
} else {
|
|
11
|
+
process.env.HQ_OP_LOCK_TIMEOUT = originalLockTimeout;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("configureSyncLockTimeout", () => {
|
|
16
|
+
it("supplies a finite foreground wait when the caller has not configured one", () => {
|
|
17
|
+
delete process.env.HQ_OP_LOCK_TIMEOUT;
|
|
18
|
+
configureSyncLockTimeout(undefined);
|
|
19
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("300");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("honors an explicit zero-second refusal", () => {
|
|
23
|
+
configureSyncLockTimeout("0");
|
|
24
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("0");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("honors a valid inherited lock timeout", () => {
|
|
28
|
+
process.env.HQ_OP_LOCK_TIMEOUT = "17";
|
|
29
|
+
configureSyncLockTimeout(undefined);
|
|
30
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("17");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects an invalid timeout instead of silently waiting forever", () => {
|
|
34
|
+
expect(() => configureSyncLockTimeout("forever")).toThrow("--lock-timeout");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects an invalid inherited timeout instead of restoring an infinite wait", () => {
|
|
38
|
+
process.env.HQ_OP_LOCK_TIMEOUT = "forever";
|
|
39
|
+
expect(() => configureSyncLockTimeout(undefined)).toThrow("--lock-timeout");
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/commands/cloud.ts
CHANGED
|
@@ -97,6 +97,24 @@ function resolveDeletePolicy(): "owned-only" | "currency-gated" | "all" {
|
|
|
97
97
|
interface CommonSyncOptions {
|
|
98
98
|
hqRoot: string;
|
|
99
99
|
company?: string;
|
|
100
|
+
lockTimeout?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS = 300;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bound foreground waits for a watcher/manual sync that currently owns the
|
|
107
|
+
* per-root operation lock. The cloud engine reads this environment value on
|
|
108
|
+
* every lock acquisition. A command-line value wins; otherwise preserve a
|
|
109
|
+
* valid explicit caller environment value and supply a finite CLI default.
|
|
110
|
+
*/
|
|
111
|
+
export function configureSyncLockTimeout(raw: string | undefined): void {
|
|
112
|
+
const value = raw ?? process.env.HQ_OP_LOCK_TIMEOUT ?? String(DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS);
|
|
113
|
+
const seconds = Number(value);
|
|
114
|
+
if (!Number.isInteger(seconds) || seconds < 0) {
|
|
115
|
+
throw new Error("--lock-timeout must be a non-negative integer number of seconds");
|
|
116
|
+
}
|
|
117
|
+
process.env.HQ_OP_LOCK_TIMEOUT = String(seconds);
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -821,6 +839,10 @@ export function registerCloudCommands(program: Command): void {
|
|
|
821
839
|
"--company <slug>",
|
|
822
840
|
"Company slug or UID (defaults to active company in .hq/config.json)",
|
|
823
841
|
)
|
|
842
|
+
.option(
|
|
843
|
+
"--lock-timeout <seconds>",
|
|
844
|
+
"Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
|
|
845
|
+
)
|
|
824
846
|
.option(
|
|
825
847
|
"--message <msg>",
|
|
826
848
|
"Optional message attached to journal entries for these uploads",
|
|
@@ -884,6 +906,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
884
906
|
},
|
|
885
907
|
) => {
|
|
886
908
|
try {
|
|
909
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
887
910
|
assertSingleSelector(options, "push");
|
|
888
911
|
} catch (err) {
|
|
889
912
|
console.error(
|
|
@@ -1118,6 +1141,10 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1118
1141
|
"--company <slug>",
|
|
1119
1142
|
"Company slug or UID (defaults to active company in .hq/config.json)",
|
|
1120
1143
|
)
|
|
1144
|
+
.option(
|
|
1145
|
+
"--lock-timeout <seconds>",
|
|
1146
|
+
"Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
|
|
1147
|
+
)
|
|
1121
1148
|
.option(
|
|
1122
1149
|
"--on-conflict <strategy>",
|
|
1123
1150
|
"Conflict strategy: overwrite | keep | abort (omit for interactive)",
|
|
@@ -1168,6 +1195,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1168
1195
|
},
|
|
1169
1196
|
) => {
|
|
1170
1197
|
try {
|
|
1198
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
1171
1199
|
assertSingleSelector(options, "pull");
|
|
1172
1200
|
} catch (err) {
|
|
1173
1201
|
console.error(
|
|
@@ -1410,6 +1438,10 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1410
1438
|
"--company <slug>",
|
|
1411
1439
|
"Company slug or UID (defaults to active company in .hq/config.json)",
|
|
1412
1440
|
)
|
|
1441
|
+
.option(
|
|
1442
|
+
"--lock-timeout <seconds>",
|
|
1443
|
+
"Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
|
|
1444
|
+
)
|
|
1413
1445
|
.option(
|
|
1414
1446
|
"--message <msg>",
|
|
1415
1447
|
"Optional message attached to journal entries for the push leg",
|
|
@@ -1460,6 +1492,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1460
1492
|
},
|
|
1461
1493
|
) => {
|
|
1462
1494
|
try {
|
|
1495
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
1463
1496
|
assertSingleSelector(options, "now");
|
|
1464
1497
|
if (options.all) {
|
|
1465
1498
|
// `options.personal === false` is Commander's auto-negation
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
type MockInstance,
|
|
9
|
+
} from "vitest";
|
|
10
|
+
|
|
11
|
+
vi.mock("open", () => ({ default: vi.fn(() => Promise.resolve({ pid: 0 })) }));
|
|
12
|
+
|
|
13
|
+
vi.mock("../utils/cognito-session.js", async (importOriginal) => {
|
|
14
|
+
const original = (await importOriginal()) as Record<string, unknown>;
|
|
15
|
+
return {
|
|
16
|
+
...original,
|
|
17
|
+
ensureCognitoToken: vi.fn(async () => "test-token"),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
formatFilesTrashTable,
|
|
23
|
+
formatFilesVersionsTable,
|
|
24
|
+
registerFilesCommand,
|
|
25
|
+
runFilesRestore,
|
|
26
|
+
runFilesTrash,
|
|
27
|
+
runFilesVersions,
|
|
28
|
+
} from "./files.js";
|
|
29
|
+
import { Command } from "commander";
|
|
30
|
+
|
|
31
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
32
|
+
return new Response(JSON.stringify(body), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function membershipResponse(): Response {
|
|
39
|
+
return jsonResponse(200, {
|
|
40
|
+
memberships: [
|
|
41
|
+
{
|
|
42
|
+
membershipKey: "k1",
|
|
43
|
+
companyUid: "cmp_acme",
|
|
44
|
+
role: "member",
|
|
45
|
+
status: "active",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
52
|
+
let logSpy: MockInstance<typeof console.log>;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
56
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function requestedUrl(index: number): URL {
|
|
64
|
+
return new URL(String(fetchSpy.mock.calls[index][0]));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildProgram(): Command {
|
|
68
|
+
const program = new Command();
|
|
69
|
+
program.exitOverride();
|
|
70
|
+
registerFilesCommand(program);
|
|
71
|
+
return program;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("hq files versions", () => {
|
|
75
|
+
it("renders delete markers alongside content versions", () => {
|
|
76
|
+
const table = formatFilesVersionsTable([
|
|
77
|
+
{
|
|
78
|
+
versionId: "marker",
|
|
79
|
+
isLatest: true,
|
|
80
|
+
isDeleteMarker: true,
|
|
81
|
+
lastModified: "2026-07-20T12:00:00.000Z",
|
|
82
|
+
size: 0,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
versionId: "content",
|
|
86
|
+
isLatest: false,
|
|
87
|
+
lastModified: "2026-07-19T12:00:00.000Z",
|
|
88
|
+
size: 1024,
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
expect(table).toContain("delete marker");
|
|
93
|
+
expect(table).toContain("1.0 KiB");
|
|
94
|
+
expect(table).toContain("VERSION");
|
|
95
|
+
expect(table).toContain("LATEST");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("uses the company selector for an exact-key history request", async () => {
|
|
99
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
100
|
+
fetchSpy.mockResolvedValueOnce(
|
|
101
|
+
jsonResponse(200, {
|
|
102
|
+
key: "notes/a.md",
|
|
103
|
+
versions: [],
|
|
104
|
+
computedAt: "2026-07-20T12:00:00.000Z",
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await runFilesVersions({
|
|
109
|
+
key: "notes/a.md",
|
|
110
|
+
personal: false,
|
|
111
|
+
companySlug: undefined,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const url = requestedUrl(1);
|
|
115
|
+
expect(url.pathname).toBe("/v1/files/versions");
|
|
116
|
+
expect(url.searchParams.get("company")).toBe("cmp_acme");
|
|
117
|
+
expect(url.searchParams.get("key")).toBe("notes/a.md");
|
|
118
|
+
expect(url.searchParams.get("personal")).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("uses only the caller-owned personal selector", async () => {
|
|
122
|
+
fetchSpy.mockResolvedValueOnce(
|
|
123
|
+
jsonResponse(200, {
|
|
124
|
+
key: "notes/private.md",
|
|
125
|
+
versions: [],
|
|
126
|
+
computedAt: "2026-07-20T12:00:00.000Z",
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await runFilesVersions({
|
|
131
|
+
key: "notes/private.md",
|
|
132
|
+
personal: true,
|
|
133
|
+
companySlug: undefined,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
137
|
+
const url = requestedUrl(0);
|
|
138
|
+
expect(url.searchParams.get("personal")).toBe("1");
|
|
139
|
+
expect(url.searchParams.get("company")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("hq files restore", () => {
|
|
144
|
+
it("does not call the API when the overwrite confirmation is declined", async () => {
|
|
145
|
+
const confirm = vi.fn(async () => false);
|
|
146
|
+
const result = await runFilesRestore(
|
|
147
|
+
{
|
|
148
|
+
key: "notes/a.md",
|
|
149
|
+
yes: false,
|
|
150
|
+
personal: false,
|
|
151
|
+
companySlug: undefined,
|
|
152
|
+
},
|
|
153
|
+
{ confirm },
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(result).toBeUndefined();
|
|
157
|
+
expect(confirm).toHaveBeenCalledWith(
|
|
158
|
+
expect.stringContaining("overwrites current content"),
|
|
159
|
+
);
|
|
160
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
161
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Aborted"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses CopyObject restore endpoint shape and bypasses confirm with --yes", async () => {
|
|
165
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
166
|
+
fetchSpy.mockResolvedValueOnce(
|
|
167
|
+
jsonResponse(200, {
|
|
168
|
+
key: "notes/a.md",
|
|
169
|
+
wasDeleted: true,
|
|
170
|
+
restoredFromVersionId: "old-version",
|
|
171
|
+
newVersionId: "new-version",
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const confirm = vi.fn(async () => {
|
|
176
|
+
throw new Error("--yes must bypass confirmation");
|
|
177
|
+
});
|
|
178
|
+
await runFilesRestore(
|
|
179
|
+
{
|
|
180
|
+
key: "notes/a.md",
|
|
181
|
+
versionId: "old-version",
|
|
182
|
+
yes: true,
|
|
183
|
+
personal: false,
|
|
184
|
+
companySlug: undefined,
|
|
185
|
+
},
|
|
186
|
+
{ confirm },
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
190
|
+
expect(requestedUrl(1).pathname).toBe("/v1/files/restore");
|
|
191
|
+
expect(fetchSpy.mock.calls[1][1]?.method).toBe("POST");
|
|
192
|
+
expect(JSON.parse(String(fetchSpy.mock.calls[1][1]?.body))).toEqual({
|
|
193
|
+
company: "cmp_acme",
|
|
194
|
+
key: "notes/a.md",
|
|
195
|
+
versionId: "old-version",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("uses the caller-owned personal restore body without a company lookup", async () => {
|
|
200
|
+
fetchSpy.mockResolvedValueOnce(
|
|
201
|
+
jsonResponse(200, {
|
|
202
|
+
key: "notes/private.md",
|
|
203
|
+
wasDeleted: false,
|
|
204
|
+
restoredFromVersionId: "prior-version",
|
|
205
|
+
newVersionId: "copied-version",
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
await runFilesRestore(
|
|
210
|
+
{
|
|
211
|
+
key: "notes/private.md",
|
|
212
|
+
yes: true,
|
|
213
|
+
personal: true,
|
|
214
|
+
companySlug: undefined,
|
|
215
|
+
},
|
|
216
|
+
{ confirm: async () => true },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
220
|
+
expect(requestedUrl(0).pathname).toBe("/v1/files/restore");
|
|
221
|
+
expect(fetchSpy.mock.calls[0][1]?.method).toBe("POST");
|
|
222
|
+
expect(JSON.parse(String(fetchSpy.mock.calls[0][1]?.body))).toEqual({
|
|
223
|
+
personal: true,
|
|
224
|
+
key: "notes/private.md",
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("hq files trash", () => {
|
|
230
|
+
it("formats durable tombstones", () => {
|
|
231
|
+
expect(
|
|
232
|
+
formatFilesTrashTable([
|
|
233
|
+
{
|
|
234
|
+
key: "notes/deleted.md",
|
|
235
|
+
deletedAt: "2026-07-20T12:00:00.000Z",
|
|
236
|
+
deletedBy: "per_owner",
|
|
237
|
+
deletedPrefix: "notes/",
|
|
238
|
+
},
|
|
239
|
+
]),
|
|
240
|
+
).toContain("notes/deleted.md");
|
|
241
|
+
expect(formatFilesTrashTable([])).toBe("Trash is empty.");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("uses company scope with a literal prefix", async () => {
|
|
245
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
246
|
+
fetchSpy.mockResolvedValueOnce(
|
|
247
|
+
jsonResponse(200, {
|
|
248
|
+
companyUid: "cmp_acme",
|
|
249
|
+
tombstones: [],
|
|
250
|
+
truncated: false,
|
|
251
|
+
computedAt: "2026-07-20T12:00:00.000Z",
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
await runFilesTrash({
|
|
256
|
+
prefix: "notes/",
|
|
257
|
+
personal: false,
|
|
258
|
+
companySlug: undefined,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const url = requestedUrl(1);
|
|
262
|
+
expect(url.pathname).toBe("/v1/files/tombstones");
|
|
263
|
+
expect(url.searchParams.get("company")).toBe("cmp_acme");
|
|
264
|
+
expect(url.searchParams.get("prefix")).toBe("notes/");
|
|
265
|
+
expect(url.searchParams.get("personal")).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("uses personal scope with literal prefix and cursor", async () => {
|
|
269
|
+
fetchSpy.mockResolvedValueOnce(
|
|
270
|
+
jsonResponse(200, {
|
|
271
|
+
personal: true,
|
|
272
|
+
tombstones: [],
|
|
273
|
+
cursor: "next",
|
|
274
|
+
truncated: true,
|
|
275
|
+
computedAt: "2026-07-20T12:00:00.000Z",
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
await runFilesTrash({
|
|
280
|
+
prefix: "notes/",
|
|
281
|
+
cursor: "cursor-1",
|
|
282
|
+
personal: true,
|
|
283
|
+
companySlug: undefined,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const url = requestedUrl(0);
|
|
287
|
+
expect(url.pathname).toBe("/v1/files/tombstones");
|
|
288
|
+
expect(url.searchParams.get("personal")).toBe("1");
|
|
289
|
+
expect(url.searchParams.get("prefix")).toBe("notes/");
|
|
290
|
+
expect(url.searchParams.get("cursor")).toBe("cursor-1");
|
|
291
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
292
|
+
expect.stringContaining(
|
|
293
|
+
"hq files trash --personal --prefix 'notes/' --cursor 'next'",
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rejects wildcard trash prefixes without making a request", async () => {
|
|
299
|
+
await expect(
|
|
300
|
+
runFilesTrash({
|
|
301
|
+
prefix: "notes/*",
|
|
302
|
+
personal: true,
|
|
303
|
+
companySlug: undefined,
|
|
304
|
+
}),
|
|
305
|
+
).rejects.toThrow(/literal/);
|
|
306
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("hq files recovery command help", () => {
|
|
311
|
+
it("documents the finalized --version and --prefix option forms", () => {
|
|
312
|
+
const program = buildProgram();
|
|
313
|
+
const files = program.commands.find((command) => command.name() === "files");
|
|
314
|
+
const restore = files?.commands.find((command) => command.name() === "restore");
|
|
315
|
+
const trash = files?.commands.find((command) => command.name() === "trash");
|
|
316
|
+
|
|
317
|
+
expect(files?.helpInformation()).toContain("versions [options] <path>");
|
|
318
|
+
expect(restore?.helpInformation()).toContain("--version <id>");
|
|
319
|
+
expect(trash?.helpInformation()).toContain("--prefix <prefix>");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("passes --version to the restore request", async () => {
|
|
323
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
324
|
+
fetchSpy.mockResolvedValueOnce(
|
|
325
|
+
jsonResponse(200, {
|
|
326
|
+
key: "notes/a.md",
|
|
327
|
+
wasDeleted: false,
|
|
328
|
+
restoredFromVersionId: "old-version",
|
|
329
|
+
newVersionId: "new-version",
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
await buildProgram().parseAsync(
|
|
334
|
+
["files", "restore", "notes/a.md", "--version", "old-version", "--yes"],
|
|
335
|
+
{ from: "user" },
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(JSON.parse(String(fetchSpy.mock.calls[1][1]?.body))).toMatchObject({
|
|
339
|
+
versionId: "old-version",
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("passes --prefix to the trash request", async () => {
|
|
344
|
+
fetchSpy.mockResolvedValueOnce(
|
|
345
|
+
jsonResponse(200, {
|
|
346
|
+
personal: true,
|
|
347
|
+
tombstones: [],
|
|
348
|
+
truncated: false,
|
|
349
|
+
computedAt: "2026-07-20T12:00:00.000Z",
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await buildProgram().parseAsync(
|
|
354
|
+
["files", "trash", "--personal", "--prefix", "notes/"],
|
|
355
|
+
{ from: "user" },
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const url = requestedUrl(0);
|
|
359
|
+
expect(url.searchParams.get("prefix")).toBe("notes/");
|
|
360
|
+
});
|
|
361
|
+
});
|