@indigoai-us/hq-cli 5.76.0 → 5.77.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/agents.js +8 -3
- package/dist/commands/files.d.ts +61 -0
- package/dist/commands/files.js +274 -0
- package/dist/commands/outposts.js +2 -2
- package/dist/utils/billing-gate.d.ts +15 -0
- package/dist/utils/billing-gate.js +35 -0
- package/package.json +1 -1
- package/src/commands/agents.test.ts +41 -0
- package/src/commands/agents.ts +7 -3
- 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/utils/billing-gate.ts +46 -0
|
@@ -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
|
+
});
|