@indigoai-us/hq-cli 5.12.3 → 5.12.5

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.12.4] — 2026-05-12
4
+
5
+ ### Changed
6
+
7
+ - **`hq files acl <prefix>` now uses a single round-trip.** Previously the
8
+ command issued `GET /acl` and `GET /acl/tree` in parallel to gather the
9
+ prefix's own row metadata (creator, open/restricted, effective permission)
10
+ alongside inherited/descendant grants. The server now folds `directRow`
11
+ and `effectivePermission` into the `/acl/tree` response, so the CLI makes
12
+ a single request and dedupes the company-ACL fetch on the server side.
13
+ Output formatting is unchanged.
14
+
3
15
  ## [5.12.3] — 2026-05-12
4
16
 
5
17
  ### Added
@@ -1,5 +1,5 @@
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]="50b160eb-d996-5ac2-8efc-b1791b469f3a")}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]="7a4bfaa9-c578-5cd8-8955-ec965faada13")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import open from "open";
5
5
  import { ensureCognitoToken } from "../utils/cognito-session.js";
@@ -205,64 +205,49 @@ export function registerFilesCommand(program) {
205
205
  const token = await ensureCognitoToken();
206
206
  const companySlug = files.opts().company;
207
207
  const companyUid = await getCompanyUid(token, companySlug);
208
- // Fetch the prefix's own ACL row (creator, open flag, effective
209
- // permission) and the inherited/descendant tree in parallel so the
210
- // user sees every grant that affects this prefix in one shot.
211
- const [aclRes, treeRes] = await Promise.all([
212
- vaultApiFetch({
213
- token,
214
- path: `/files/${encodeURIComponent(companyUid)}/acl`,
215
- query: { prefix: canonicalPrefix },
216
- }),
217
- vaultApiFetch({
218
- token,
219
- path: `/files/${encodeURIComponent(companyUid)}/acl/tree`,
220
- query: { prefix: canonicalPrefix },
221
- }),
222
- ]);
223
- async function readErrorBody(res) {
224
- return (await res.json().catch(() => ({})));
225
- }
226
- // Auth/server failures from either call are treated identically — bail
227
- // out with a single message rather than printing a half-rendered view.
228
- for (const res of [aclRes, treeRes]) {
229
- if (res.ok || res.status === 404)
230
- continue;
231
- const body = await readErrorBody(res);
232
- if (res.status === 401) {
208
+ // `/acl/tree` carries the prefix's own row metadata (directRow) and the
209
+ // caller's effectivePermission alongside direct/inherited/children, so
210
+ // a single request returns everything the "files acl" view needs.
211
+ const treeRes = await vaultApiFetch({
212
+ token,
213
+ path: `/files/${encodeURIComponent(companyUid)}/acl/tree`,
214
+ query: { prefix: canonicalPrefix },
215
+ });
216
+ if (!treeRes.ok) {
217
+ const body = (await treeRes.json().catch(() => ({})));
218
+ if (treeRes.status === 401) {
233
219
  console.error(chalk.red("Not authenticated — please run `hq login`"));
234
220
  }
235
- else if (res.status === 403) {
221
+ else if (treeRes.status === 403) {
236
222
  console.error(chalk.red("Not authorized to view this file prefix's ACL"));
237
223
  }
238
- else if (res.status >= 500) {
239
- console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
224
+ else if (treeRes.status >= 500) {
225
+ console.error(chalk.red(`Server error: ${body.error ?? treeRes.statusText}`));
240
226
  }
241
227
  else {
242
228
  console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
243
229
  }
244
230
  process.exit(1);
245
231
  }
246
- const acl = aclRes.ok ? (await aclRes.json()).acl : null;
247
- const tree = treeRes.ok ? (await treeRes.json()) : null;
248
- // No own row AND nothing inherited or granted below — original
249
- // "no ACL record" exit path.
250
- if (!acl && (!tree || (tree.inherited.length === 0 && tree.children.length === 0))) {
232
+ const tree = (await treeRes.json());
233
+ const row = tree.directRow;
234
+ // No own row AND nothing inherited or granted below — preserve the
235
+ // original "no ACL record" exit path.
236
+ if (!row && tree.inherited.length === 0 && tree.children.length === 0) {
251
237
  console.error(chalk.red(`No ACL record exists for '${canonicalPrefix}'`));
252
238
  process.exit(1);
253
239
  }
254
- const aclPrefix = acl?.path ?? acl?.prefix ?? tree?.prefix ?? canonicalPrefix;
255
- const aclStatus = acl?.open ? "open" : "restricted";
256
- console.log(chalk.green(`ACL for ${aclPrefix} (${aclStatus})`));
257
- if (acl) {
258
- console.log(`Creator: ${acl.creatorUid}`);
259
- if (acl.effectivePermission) {
260
- console.log(`Your effective permission: ${acl.effectivePermission}`);
261
- }
240
+ const aclStatus = row?.open ? "open" : "restricted";
241
+ console.log(chalk.green(`ACL for ${tree.prefix} (${aclStatus})`));
242
+ if (row) {
243
+ console.log(`Creator: ${row.creatorUid}`);
262
244
  }
263
245
  else {
264
246
  console.log(chalk.gray("No direct ACL row — access flows from the inherited/descendant grants below."));
265
247
  }
248
+ if (tree.effectivePermission) {
249
+ console.log(`Your effective permission: ${tree.effectivePermission}`);
250
+ }
266
251
  // Display labels for grantee identifiers — `company-wide` entries
267
252
  // store `granteeId === ""` on the wire, but a blank cell is confusing
268
253
  // in tabular output, so we render a human-readable phrase instead.
@@ -303,12 +288,12 @@ export function registerFilesCommand(program) {
303
288
  console.log(cols.join(" "));
304
289
  }
305
290
  }
306
- const directEntries = acl?.entries ?? tree?.direct ?? [];
291
+ const directEntries = tree.direct;
307
292
  if (directEntries.length === 0) {
308
- if (acl?.open) {
293
+ if (row?.open) {
309
294
  console.log(chalk.gray("Open ACL — all active members have read access."));
310
295
  }
311
- else if (acl) {
296
+ else if (row) {
312
297
  console.log(chalk.gray("No explicit grants on this prefix — only creator has access."));
313
298
  }
314
299
  }
@@ -316,11 +301,11 @@ export function registerFilesCommand(program) {
316
301
  console.log("\nDirect entries (granted on this prefix):");
317
302
  printEntryTable(directEntries, false);
318
303
  }
319
- if (tree && tree.inherited.length > 0) {
304
+ if (tree.inherited.length > 0) {
320
305
  console.log("\nInherited (granted on an ancestor prefix):");
321
306
  printEntryTable(tree.inherited, true);
322
307
  }
323
- if (tree && tree.children.length > 0) {
308
+ if (tree.children.length > 0) {
324
309
  console.log("\nGranted on descendant prefixes (do not affect this prefix's access):");
325
310
  printEntryTable(tree.children, true);
326
311
  }
@@ -471,4 +456,4 @@ async function runShareSession(params) {
471
456
  }
472
457
  }
473
458
  //# sourceMappingURL=files.js.map
474
- //# debugId=50b160eb-d996-5ac2-8efc-b1791b469f3a
459
+ //# debugId=7a4bfaa9-c578-5cd8-8955-ec965faada13
@@ -1,5 +1,5 @@
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]="217ceadb-01cd-5778-9cc0-6f6d895d8d67")}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]="b75c8495-7e0e-53f8-a0d2-c92a993e6492")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import * as readline from "node:readline";
5
5
  import { spawn } from "node:child_process";
@@ -467,7 +467,6 @@ export function registerSecretsCommand(program) {
467
467
  .option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
468
468
  .action(async (name, opts) => {
469
469
  try {
470
- rejectIfPersonal(secrets.opts(), "generate-link");
471
470
  if (!SECRET_NAME_PATTERN.test(name)) {
472
471
  console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
473
472
  process.exit(1);
@@ -713,4 +712,4 @@ export function registerSecretsCommand(program) {
713
712
  });
714
713
  }
715
714
  //# sourceMappingURL=secrets.js.map
716
- //# debugId=217ceadb-01cd-5778-9cc0-6f6d895d8d67
715
+ //# debugId=b75c8495-7e0e-53f8-a0d2-c92a993e6492
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ const program = new Command();
37
37
  program
38
38
  .name("hq")
39
39
  .description("HQ management CLI — modules, packages, and cloud sync")
40
- .version("5.12.3");
40
+ .version("5.12.4");
41
41
  // Module management subcommand group
42
42
  const modulesCmd = program
43
43
  .command("modules")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.12.3",
3
+ "version": "5.12.5",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -271,37 +271,23 @@ export function registerFilesCommand(program: Command): void {
271
271
  const companySlug = files.opts().company as string | undefined;
272
272
  const companyUid = await getCompanyUid(token, companySlug);
273
273
 
274
- // Fetch the prefix's own ACL row (creator, open flag, effective
275
- // permission) and the inherited/descendant tree in parallel so the
276
- // user sees every grant that affects this prefix in one shot.
277
- const [aclRes, treeRes] = await Promise.all([
278
- vaultApiFetch({
279
- token,
280
- path: `/files/${encodeURIComponent(companyUid)}/acl`,
281
- query: { prefix: canonicalPrefix },
282
- }),
283
- vaultApiFetch({
284
- token,
285
- path: `/files/${encodeURIComponent(companyUid)}/acl/tree`,
286
- query: { prefix: canonicalPrefix },
287
- }),
288
- ]);
289
-
290
- async function readErrorBody(res: Response): Promise<Record<string, string>> {
291
- return (await res.json().catch(() => ({}))) as Record<string, string>;
292
- }
274
+ // `/acl/tree` carries the prefix's own row metadata (directRow) and the
275
+ // caller's effectivePermission alongside direct/inherited/children, so
276
+ // a single request returns everything the "files acl" view needs.
277
+ const treeRes = await vaultApiFetch({
278
+ token,
279
+ path: `/files/${encodeURIComponent(companyUid)}/acl/tree`,
280
+ query: { prefix: canonicalPrefix },
281
+ });
293
282
 
294
- // Auth/server failures from either call are treated identically — bail
295
- // out with a single message rather than printing a half-rendered view.
296
- for (const res of [aclRes, treeRes]) {
297
- if (res.ok || res.status === 404) continue;
298
- const body = await readErrorBody(res);
299
- if (res.status === 401) {
283
+ if (!treeRes.ok) {
284
+ const body = (await treeRes.json().catch(() => ({}))) as Record<string, string>;
285
+ if (treeRes.status === 401) {
300
286
  console.error(chalk.red("Not authenticated — please run `hq login`"));
301
- } else if (res.status === 403) {
287
+ } else if (treeRes.status === 403) {
302
288
  console.error(chalk.red("Not authorized to view this file prefix's ACL"));
303
- } else if (res.status >= 500) {
304
- console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
289
+ } else if (treeRes.status >= 500) {
290
+ console.error(chalk.red(`Server error: ${body.error ?? treeRes.statusText}`));
305
291
  } else {
306
292
  console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
307
293
  }
@@ -315,53 +301,43 @@ export function registerFilesCommand(program: Command): void {
315
301
  grantedBy: string;
316
302
  grantedAt: string;
317
303
  };
318
- type AclResponse = {
319
- acl: {
320
- itemType: string;
321
- companyUid: string;
322
- // Server returns `path` (the FileAcl field name); older builds
323
- // used `prefix`. Read both so the CLI works against either.
324
- path?: string;
325
- prefix?: string;
326
- creatorUid: string;
327
- open?: boolean;
328
- entries: AclEntry[];
329
- effectivePermission?: string | null;
330
- createdAt: string;
331
- updatedAt: string;
332
- };
333
- };
334
304
  type TreeResponse = {
335
305
  prefix: string;
336
306
  direct: AclEntry[];
337
307
  inherited: Array<AclEntry & { sourcePrefix: string }>;
338
308
  children: Array<AclEntry & { sourcePrefix: string }>;
309
+ directRow: {
310
+ creatorUid: string;
311
+ open: boolean;
312
+ createdAt: string;
313
+ updatedAt: string;
314
+ } | null;
315
+ effectivePermission: string | null;
339
316
  };
340
317
 
341
- const acl = aclRes.ok ? (await aclRes.json() as AclResponse).acl : null;
342
- const tree = treeRes.ok ? (await treeRes.json()) as TreeResponse : null;
318
+ const tree = (await treeRes.json()) as TreeResponse;
319
+ const row = tree.directRow;
343
320
 
344
- // No own row AND nothing inherited or granted below — original
345
- // "no ACL record" exit path.
346
- if (!acl && (!tree || (tree.inherited.length === 0 && tree.children.length === 0))) {
321
+ // No own row AND nothing inherited or granted below — preserve the
322
+ // original "no ACL record" exit path.
323
+ if (!row && tree.inherited.length === 0 && tree.children.length === 0) {
347
324
  console.error(chalk.red(`No ACL record exists for '${canonicalPrefix}'`));
348
325
  process.exit(1);
349
326
  }
350
327
 
351
- const aclPrefix = acl?.path ?? acl?.prefix ?? tree?.prefix ?? canonicalPrefix;
352
- const aclStatus = acl?.open ? "open" : "restricted";
328
+ const aclStatus = row?.open ? "open" : "restricted";
353
329
 
354
- console.log(chalk.green(`ACL for ${aclPrefix} (${aclStatus})`));
355
- if (acl) {
356
- console.log(`Creator: ${acl.creatorUid}`);
357
- if (acl.effectivePermission) {
358
- console.log(`Your effective permission: ${acl.effectivePermission}`);
359
- }
330
+ console.log(chalk.green(`ACL for ${tree.prefix} (${aclStatus})`));
331
+ if (row) {
332
+ console.log(`Creator: ${row.creatorUid}`);
360
333
  } else {
361
334
  console.log(chalk.gray(
362
335
  "No direct ACL row — access flows from the inherited/descendant grants below.",
363
336
  ));
364
337
  }
338
+ if (tree.effectivePermission) {
339
+ console.log(`Your effective permission: ${tree.effectivePermission}`);
340
+ }
365
341
 
366
342
  // Display labels for grantee identifiers — `company-wide` entries
367
343
  // store `granteeId === ""` on the wire, but a blank cell is confusing
@@ -405,11 +381,11 @@ export function registerFilesCommand(program: Command): void {
405
381
  }
406
382
  }
407
383
 
408
- const directEntries = acl?.entries ?? tree?.direct ?? [];
384
+ const directEntries = tree.direct;
409
385
  if (directEntries.length === 0) {
410
- if (acl?.open) {
386
+ if (row?.open) {
411
387
  console.log(chalk.gray("Open ACL — all active members have read access."));
412
- } else if (acl) {
388
+ } else if (row) {
413
389
  console.log(chalk.gray("No explicit grants on this prefix — only creator has access."));
414
390
  }
415
391
  } else {
@@ -417,12 +393,12 @@ export function registerFilesCommand(program: Command): void {
417
393
  printEntryTable(directEntries, false);
418
394
  }
419
395
 
420
- if (tree && tree.inherited.length > 0) {
396
+ if (tree.inherited.length > 0) {
421
397
  console.log("\nInherited (granted on an ancestor prefix):");
422
398
  printEntryTable(tree.inherited, true);
423
399
  }
424
400
 
425
- if (tree && tree.children.length > 0) {
401
+ if (tree.children.length > 0) {
426
402
  console.log("\nGranted on descendant prefixes (do not affect this prefix's access):");
427
403
  printEntryTable(tree.children, true);
428
404
  }
@@ -0,0 +1,96 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ vi,
8
+ type MockInstance,
9
+ } from "vitest";
10
+
11
+ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
12
+ const original = (await importOriginal()) as Record<string, unknown>;
13
+ return {
14
+ ...original,
15
+ ensureCognitoToken: vi.fn(async () => "test-token"),
16
+ };
17
+ });
18
+
19
+ vi.mock("../utils/vault-api.js", async (importOriginal) => {
20
+ const original = (await importOriginal()) as Record<string, unknown>;
21
+ return {
22
+ ...original,
23
+ getEntityUid: vi.fn(async () => "prs_alice"),
24
+ vaultApiFetch: vi.fn(async () =>
25
+ new Response(
26
+ JSON.stringify({
27
+ url: "https://hq.example/secrets-input/tok_secret",
28
+ expiresAt: "2026-05-12T12:00:00.000Z",
29
+ secretName: "MY_KEY",
30
+ }),
31
+ { status: 200, headers: { "Content-Type": "application/json" } },
32
+ ),
33
+ ),
34
+ };
35
+ });
36
+
37
+ import { Command } from "commander";
38
+ import { registerSecretsCommand } from "./secrets.js";
39
+ import { getEntityUid, vaultApiFetch } from "../utils/vault-api.js";
40
+
41
+ let logSpy: MockInstance<typeof console.log>;
42
+ let errSpy: MockInstance<typeof console.error>;
43
+
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
47
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
48
+ });
49
+
50
+ afterEach(() => {
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ function buildProgram(): Command {
55
+ const program = new Command();
56
+ program.exitOverride();
57
+ program.configureOutput({
58
+ writeOut: () => undefined,
59
+ writeErr: () => undefined,
60
+ });
61
+ registerSecretsCommand(program);
62
+ return program;
63
+ }
64
+
65
+ describe("secrets generate-link", () => {
66
+ it("mints one-time submission links for personal secrets", async () => {
67
+ const program = buildProgram();
68
+
69
+ await program.parseAsync([
70
+ "node",
71
+ "hq",
72
+ "secrets",
73
+ "--personal",
74
+ "generate-link",
75
+ "MY_KEY",
76
+ "--expires",
77
+ "30m",
78
+ ]);
79
+
80
+ expect(getEntityUid).toHaveBeenCalledWith("test-token", {
81
+ personal: true,
82
+ companySlug: undefined,
83
+ });
84
+ expect(vaultApiFetch).toHaveBeenCalledWith({
85
+ token: "test-token",
86
+ path: "/secrets/prs_alice/name/MY_KEY",
87
+ method: "POST",
88
+ body: { expiresInMs: 30 * 60 * 1000 },
89
+ query: { action: "generate-token" },
90
+ });
91
+ expect(logSpy).toHaveBeenCalledWith(
92
+ expect.stringContaining("Secret input link generated"),
93
+ );
94
+ expect(errSpy).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -615,8 +615,6 @@ export function registerSecretsCommand(program: Command): void {
615
615
  .option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
616
616
  .action(async (name: string, opts: { expires: string }) => {
617
617
  try {
618
- rejectIfPersonal(secrets.opts(), "generate-link");
619
-
620
618
  if (!SECRET_NAME_PATTERN.test(name)) {
621
619
  console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
622
620
  process.exit(1);
package/src/index.ts CHANGED
@@ -43,7 +43,7 @@ const program = new Command();
43
43
  program
44
44
  .name("hq")
45
45
  .description("HQ management CLI — modules, packages, and cloud sync")
46
- .version("5.12.3");
46
+ .version("5.12.4");
47
47
 
48
48
  // Module management subcommand group
49
49
  const modulesCmd = program