@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.
@@ -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([
@@ -31,7 +31,7 @@ import {
31
31
  AGENT_PRICE_CENTS,
32
32
  confirmChargeOrExit,
33
33
  parseBillingPayload,
34
- surfaceBillingRequired,
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
- body.error ?? body.message ?? res.statusText,
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 surfaceBillingRequired(token, err.billing);
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
+ });
@@ -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
+ });