@indigoai-us/hq-cli 5.12.2 → 5.12.4

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.
@@ -1,9 +1,123 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
+ import open from "open";
3
4
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
5
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
5
6
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
6
7
 
8
+ // ---------------------------------------------------------------------------
9
+ // Pure helpers (exported for unit tests)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Parse a human-friendly duration string ("15m", "1h", "24h", "2d") into
14
+ * milliseconds. Returns null on parse failure. Mirrors the parser used in
15
+ * `secrets generate-link` but kept local so files.ts can be tested in
16
+ * isolation without importing the much larger secrets command surface.
17
+ */
18
+ export function parseDuration(input: string): number | null {
19
+ const match = input.match(/^(\d+)(m|h|d)$/);
20
+ if (!match) return null;
21
+ const value = parseInt(match[1], 10);
22
+ const unit = match[2];
23
+ if (unit === "m") return value * 60 * 1000;
24
+ if (unit === "h") return value * 60 * 60 * 1000;
25
+ if (unit === "d") return value * 24 * 60 * 60 * 1000;
26
+ return null;
27
+ }
28
+
29
+ /** PRD upper bound on browser-launch share-session expiry. */
30
+ export const MAX_SHARE_SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24h
31
+
32
+ export interface ShareSessionResponse {
33
+ url: string;
34
+ token?: string;
35
+ expiresAt: string;
36
+ nonce?: string;
37
+ paths?: string[];
38
+ maxPermissionByPath?: Record<string, string>;
39
+ }
40
+
41
+ export interface MintShareSessionParams {
42
+ token: string;
43
+ companyUid: string;
44
+ paths: string[];
45
+ expiresInMs?: number;
46
+ }
47
+
48
+ /**
49
+ * POST /files/{companyUid}/share-session — mint a browser-launch share
50
+ * session token. Returns the parsed response. Throws ShareSessionHttpError
51
+ * with a status + actionable message for any non-2xx so callers can render
52
+ * a single consistent error path.
53
+ */
54
+ export async function mintShareSession(
55
+ params: MintShareSessionParams,
56
+ ): Promise<ShareSessionResponse> {
57
+ const body: Record<string, unknown> = { paths: params.paths };
58
+ if (params.expiresInMs != null) body.expiresInMs = params.expiresInMs;
59
+
60
+ const res = await vaultApiFetch({
61
+ token: params.token,
62
+ path: `/files/${encodeURIComponent(params.companyUid)}/share-session`,
63
+ method: "POST",
64
+ body,
65
+ });
66
+
67
+ if (!res.ok) {
68
+ const errBody = (await res.json().catch(() => ({}))) as Record<
69
+ string,
70
+ string
71
+ >;
72
+ throw new ShareSessionHttpError(
73
+ res.status,
74
+ errBody.message ?? errBody.error ?? res.statusText,
75
+ errBody.path,
76
+ );
77
+ }
78
+
79
+ return (await res.json()) as ShareSessionResponse;
80
+ }
81
+
82
+ export class ShareSessionHttpError extends Error {
83
+ constructor(
84
+ public readonly status: number,
85
+ message: string,
86
+ public readonly path?: string,
87
+ ) {
88
+ super(message);
89
+ this.name = "ShareSessionHttpError";
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Map a ShareSessionHttpError to user-facing copy. Centralizes the
95
+ * status → message mapping so the share command and any future caller
96
+ * stay in sync.
97
+ */
98
+ export function formatShareSessionError(err: ShareSessionHttpError): string {
99
+ if (err.status === 401) {
100
+ return "Not authenticated — please run `hq login`";
101
+ }
102
+ if (err.status === 403) {
103
+ if (err.path) {
104
+ return `Not authorized to share '${err.path}' — you need read access on every path`;
105
+ }
106
+ return "Not authorized — you need to be a company member with read access on every path";
107
+ }
108
+ if (err.status === 400) {
109
+ return `Invalid request: ${err.message}`;
110
+ }
111
+ if (err.status >= 500) {
112
+ return `Server error: ${err.message}`;
113
+ }
114
+ return err.message || `Request failed (${err.status})`;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Command registration
119
+ // ---------------------------------------------------------------------------
120
+
7
121
  export function registerFilesCommand(program: Command): void {
8
122
  const files = program
9
123
  .command("files")
@@ -11,102 +125,105 @@ export function registerFilesCommand(program: Command): void {
11
125
  .option("--company <slug>", "Company slug (resolves to companyUid)");
12
126
 
13
127
  files
14
- .command("share <prefix>")
15
- .description("Share a file prefix with a person or group")
16
- .requiredOption("--with <principal>", "Email address or group id to share with")
17
- .requiredOption("--permission <level>", "Permission level: read | write")
18
- .action(async (prefix: string, opts: { with: string; permission: string }) => {
19
- try {
20
- const canonicalPrefix = normalizeFilePrefix(prefix);
21
-
22
- if (!["read", "write"].includes(opts.permission)) {
23
- console.error(chalk.red(`Invalid permission '${opts.permission}': must be one of read, write`));
24
- process.exit(1);
25
- }
26
-
27
- const principal = opts.with;
28
- const isEmail = EMAIL_PATTERN.test(principal);
29
- const isGroup = GROUP_ID_PATTERN.test(principal);
30
- if (!isEmail && !isGroup) {
31
- console.error(chalk.red(`Invalid principal '${principal}': must be an email address or a group id matching grp_<alphanumeric>`));
32
- process.exit(1);
33
- }
34
- const granteeType = isEmail ? "email" : "group";
35
- const granteeId = isEmail ? principal.trim().toLowerCase() : principal;
36
-
37
- const token = await ensureCognitoToken();
38
- const companySlug = files.opts().company as string | undefined;
39
- const companyUid = await getCompanyUid(token, companySlug);
128
+ .command("share [paths...]")
129
+ .description(
130
+ "Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.",
131
+ )
132
+ .option(
133
+ "--with <principal>",
134
+ "Email address, group id, or '@all' to share with every active company member",
135
+ )
136
+ .option("--permission <level>", "Permission level (only with --with): read | write")
137
+ .option(
138
+ "--expires <duration>",
139
+ "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.",
140
+ )
141
+ .option("--no-open", "Print the share-session URL but do not launch the browser")
142
+ .action(
143
+ async (
144
+ paths: string[],
145
+ opts: {
146
+ with?: string;
147
+ permission?: string;
148
+ expires?: string;
149
+ open: boolean;
150
+ },
151
+ ) => {
152
+ try {
153
+ if (!paths || paths.length === 0) {
154
+ console.error(
155
+ chalk.red("usage: hq files share <paths...> [--with <principal>]"),
156
+ );
157
+ process.exit(1);
158
+ }
40
159
 
41
- let res = await vaultApiFetch({
42
- token,
43
- path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
44
- method: "POST",
45
- body: { prefix: canonicalPrefix, granteeType, granteeId, permission: opts.permission },
46
- });
160
+ // Fork: --with present → existing direct-grant path. No --with →
161
+ // browser-launch share-session path. Per PRD, the direct-grant
162
+ // path is unchanged from US-001 and operates on a single prefix.
163
+ if (opts.with !== undefined) {
164
+ if (paths.length !== 1) {
165
+ console.error(
166
+ chalk.red(
167
+ "Direct grant (--with) takes exactly one prefix. Pass multiple paths only when minting a share-session URL.",
168
+ ),
169
+ );
170
+ process.exit(1);
171
+ }
172
+ await runDirectGrant({
173
+ prefix: paths[0],
174
+ principal: opts.with,
175
+ permission: opts.permission,
176
+ companySlug: files.opts().company as string | undefined,
177
+ });
178
+ return;
179
+ }
47
180
 
48
- // No ACL row exists yet for this prefix. Auto-create one with this
49
- // grant as its first entry, then report success — saves the caller
50
- // from needing a separate "create" step.
51
- let autoCreated = false;
52
- if (res.status === 404) {
53
- res = await vaultApiFetch({
54
- token,
55
- path: `/files/${encodeURIComponent(companyUid)}/acl`,
56
- method: "POST",
57
- body: {
58
- prefix: canonicalPrefix,
59
- entries: [{ granteeType, granteeId, permission: opts.permission }],
60
- },
181
+ await runShareSession({
182
+ paths,
183
+ expires: opts.expires,
184
+ launchBrowser: opts.open,
185
+ companySlug: files.opts().company as string | undefined,
61
186
  });
62
- autoCreated = res.ok;
63
- }
64
-
65
- if (!res.ok) {
66
- const body = await res.json().catch(() => ({})) as Record<string, string>;
67
- if (res.status === 401) {
68
- console.error(chalk.red("Not authenticated — please run `hq login`"));
69
- } else if (res.status === 403) {
70
- console.error(chalk.red("Not authorized to share this file prefix"));
71
- } else if (res.status === 404) {
72
- console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
73
- } else if (res.status === 409) {
74
- console.error(chalk.red("Concurrent modification — please retry"));
75
- } else if (res.status >= 500) {
76
- console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
77
- } else {
78
- console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
79
- }
187
+ } catch (err) {
188
+ console.error(
189
+ chalk.red("Error:"),
190
+ err instanceof Error ? err.message : String(err),
191
+ );
80
192
  process.exit(1);
81
193
  }
82
-
83
- const data = await res.json() as { acl?: { path?: string; prefix?: string } };
84
- const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
85
- const verb = autoCreated ? "Created ACL and granted" : "Granted";
86
- console.log(chalk.green(`${verb} ${opts.permission} on ${printedPrefix} to ${granteeId}`));
87
- } catch (err) {
88
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
89
- process.exit(1);
90
- }
91
- });
194
+ },
195
+ );
92
196
 
93
197
  files
94
198
  .command("unshare <prefix>")
95
199
  .description("Remove a file access grant")
96
- .requiredOption("--with <principal>", "Email address or group id to remove")
200
+ .requiredOption(
201
+ "--with <principal>",
202
+ "Email address, group id, or '@all' to remove the company-wide grant",
203
+ )
97
204
  .action(async (prefix: string, opts: { with: string }) => {
98
205
  try {
99
206
  const canonicalPrefix = normalizeFilePrefix(prefix);
100
207
 
101
208
  const principal = opts.with;
102
- const isEmail = EMAIL_PATTERN.test(principal);
103
- const isGroup = GROUP_ID_PATTERN.test(principal);
104
- if (!isEmail && !isGroup) {
105
- console.error(chalk.red(`Invalid principal '${principal}': must be an email address or a group id matching grp_<alphanumeric>`));
209
+ const isAll = principal === "@all";
210
+ const isEmail = !isAll && EMAIL_PATTERN.test(principal);
211
+ const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
212
+ if (!isAll && !isEmail && !isGroup) {
213
+ console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`));
106
214
  process.exit(1);
107
215
  }
108
- const granteeType = isEmail ? "email" : "group";
109
- const granteeId = isEmail ? principal.trim().toLowerCase() : principal;
216
+ const granteeType = isAll
217
+ ? "company-wide"
218
+ : isEmail
219
+ ? "email"
220
+ : "group";
221
+ const granteeId = isAll
222
+ ? ""
223
+ : isEmail
224
+ ? principal.trim().toLowerCase()
225
+ : principal;
226
+ const principalLabel = isAll ? "everyone in the company" : granteeId;
110
227
 
111
228
  const token = await ensureCognitoToken();
112
229
  const companySlug = files.opts().company as string | undefined;
@@ -126,7 +243,7 @@ export function registerFilesCommand(program: Command): void {
126
243
  } else if (res.status === 403) {
127
244
  console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
128
245
  } else if (res.status === 404) {
129
- console.log(chalk.green(`Grant already absent for '${canonicalPrefix}' / ${granteeId}`));
246
+ console.log(chalk.green(`Grant already absent for '${canonicalPrefix}' / ${principalLabel}`));
130
247
  return;
131
248
  } else if (res.status >= 500) {
132
249
  console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
@@ -136,7 +253,7 @@ export function registerFilesCommand(program: Command): void {
136
253
  process.exit(1);
137
254
  }
138
255
 
139
- console.log(chalk.green(`Removed grant for ${granteeId} on '${canonicalPrefix}'`));
256
+ console.log(chalk.green(`Removed grant for ${principalLabel} on '${canonicalPrefix}'`));
140
257
  } catch (err) {
141
258
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
142
259
  process.exit(1);
@@ -154,37 +271,23 @@ export function registerFilesCommand(program: Command): void {
154
271
  const companySlug = files.opts().company as string | undefined;
155
272
  const companyUid = await getCompanyUid(token, companySlug);
156
273
 
157
- // Fetch the prefix's own ACL row (creator, open flag, effective
158
- // permission) and the inherited/descendant tree in parallel so the
159
- // user sees every grant that affects this prefix in one shot.
160
- const [aclRes, treeRes] = await Promise.all([
161
- vaultApiFetch({
162
- token,
163
- path: `/files/${encodeURIComponent(companyUid)}/acl`,
164
- query: { prefix: canonicalPrefix },
165
- }),
166
- vaultApiFetch({
167
- token,
168
- path: `/files/${encodeURIComponent(companyUid)}/acl/tree`,
169
- query: { prefix: canonicalPrefix },
170
- }),
171
- ]);
172
-
173
- async function readErrorBody(res: Response): Promise<Record<string, string>> {
174
- return (await res.json().catch(() => ({}))) as Record<string, string>;
175
- }
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
+ });
176
282
 
177
- // Auth/server failures from either call are treated identically — bail
178
- // out with a single message rather than printing a half-rendered view.
179
- for (const res of [aclRes, treeRes]) {
180
- if (res.ok || res.status === 404) continue;
181
- const body = await readErrorBody(res);
182
- if (res.status === 401) {
283
+ if (!treeRes.ok) {
284
+ const body = (await treeRes.json().catch(() => ({}))) as Record<string, string>;
285
+ if (treeRes.status === 401) {
183
286
  console.error(chalk.red("Not authenticated — please run `hq login`"));
184
- } else if (res.status === 403) {
287
+ } else if (treeRes.status === 403) {
185
288
  console.error(chalk.red("Not authorized to view this file prefix's ACL"));
186
- } else if (res.status >= 500) {
187
- 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}`));
188
291
  } else {
189
292
  console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
190
293
  }
@@ -198,60 +301,58 @@ export function registerFilesCommand(program: Command): void {
198
301
  grantedBy: string;
199
302
  grantedAt: string;
200
303
  };
201
- type AclResponse = {
202
- acl: {
203
- itemType: string;
204
- companyUid: string;
205
- // Server returns `path` (the FileAcl field name); older builds
206
- // used `prefix`. Read both so the CLI works against either.
207
- path?: string;
208
- prefix?: string;
209
- creatorUid: string;
210
- open?: boolean;
211
- entries: AclEntry[];
212
- effectivePermission?: string | null;
213
- createdAt: string;
214
- updatedAt: string;
215
- };
216
- };
217
304
  type TreeResponse = {
218
305
  prefix: string;
219
306
  direct: AclEntry[];
220
307
  inherited: Array<AclEntry & { sourcePrefix: string }>;
221
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;
222
316
  };
223
317
 
224
- const acl = aclRes.ok ? (await aclRes.json() as AclResponse).acl : null;
225
- const tree = treeRes.ok ? (await treeRes.json()) as TreeResponse : null;
318
+ const tree = (await treeRes.json()) as TreeResponse;
319
+ const row = tree.directRow;
226
320
 
227
- // No own row AND nothing inherited or granted below — original
228
- // "no ACL record" exit path.
229
- 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) {
230
324
  console.error(chalk.red(`No ACL record exists for '${canonicalPrefix}'`));
231
325
  process.exit(1);
232
326
  }
233
327
 
234
- const aclPrefix = acl?.path ?? acl?.prefix ?? tree?.prefix ?? canonicalPrefix;
235
- const aclStatus = acl?.open ? "open" : "restricted";
328
+ const aclStatus = row?.open ? "open" : "restricted";
236
329
 
237
- console.log(chalk.green(`ACL for ${aclPrefix} (${aclStatus})`));
238
- if (acl) {
239
- console.log(`Creator: ${acl.creatorUid}`);
240
- if (acl.effectivePermission) {
241
- console.log(`Your effective permission: ${acl.effectivePermission}`);
242
- }
330
+ console.log(chalk.green(`ACL for ${tree.prefix} (${aclStatus})`));
331
+ if (row) {
332
+ console.log(`Creator: ${row.creatorUid}`);
243
333
  } else {
244
334
  console.log(chalk.gray(
245
335
  "No direct ACL row — access flows from the inherited/descendant grants below.",
246
336
  ));
247
337
  }
338
+ if (tree.effectivePermission) {
339
+ console.log(`Your effective permission: ${tree.effectivePermission}`);
340
+ }
341
+
342
+ // Display labels for grantee identifiers — `company-wide` entries
343
+ // store `granteeId === ""` on the wire, but a blank cell is confusing
344
+ // in tabular output, so we render a human-readable phrase instead.
345
+ function displayGrantee(e: { granteeType: string; granteeId: string }): string {
346
+ if (e.granteeType === "company-wide") return "Everyone in company";
347
+ return e.granteeId;
348
+ }
248
349
 
249
350
  function printEntryTable(
250
351
  rows: Array<AclEntry & { sourcePrefix?: string }>,
251
352
  showSource: boolean,
252
353
  ): void {
253
354
  const TYPE_W = Math.max(4, ...rows.map((e) => e.granteeType.length));
254
- const GRANTEE_W = Math.max(7, ...rows.map((e) => e.granteeId.length));
355
+ const GRANTEE_W = Math.max(7, ...rows.map((e) => displayGrantee(e).length));
255
356
  const PERM_W = Math.max(10, ...rows.map((e) => e.permission.length));
256
357
  const BY_W = Math.max(10, ...rows.map((e) => e.grantedBy.length));
257
358
  const SRC_W = showSource
@@ -270,7 +371,7 @@ export function registerFilesCommand(program: Command): void {
270
371
  const grantedAt = e.grantedAt.slice(0, 10);
271
372
  const cols = [
272
373
  e.granteeType.padEnd(TYPE_W),
273
- e.granteeId.padEnd(GRANTEE_W),
374
+ displayGrantee(e).padEnd(GRANTEE_W),
274
375
  e.permission.padEnd(PERM_W),
275
376
  e.grantedBy.padEnd(BY_W),
276
377
  grantedAt,
@@ -280,11 +381,11 @@ export function registerFilesCommand(program: Command): void {
280
381
  }
281
382
  }
282
383
 
283
- const directEntries = acl?.entries ?? tree?.direct ?? [];
384
+ const directEntries = tree.direct;
284
385
  if (directEntries.length === 0) {
285
- if (acl?.open) {
386
+ if (row?.open) {
286
387
  console.log(chalk.gray("Open ACL — all active members have read access."));
287
- } else if (acl) {
388
+ } else if (row) {
288
389
  console.log(chalk.gray("No explicit grants on this prefix — only creator has access."));
289
390
  }
290
391
  } else {
@@ -292,12 +393,12 @@ export function registerFilesCommand(program: Command): void {
292
393
  printEntryTable(directEntries, false);
293
394
  }
294
395
 
295
- if (tree && tree.inherited.length > 0) {
396
+ if (tree.inherited.length > 0) {
296
397
  console.log("\nInherited (granted on an ancestor prefix):");
297
398
  printEntryTable(tree.inherited, true);
298
399
  }
299
400
 
300
- if (tree && tree.children.length > 0) {
401
+ if (tree.children.length > 0) {
301
402
  console.log("\nGranted on descendant prefixes (do not affect this prefix's access):");
302
403
  printEntryTable(tree.children, true);
303
404
  }
@@ -307,3 +408,197 @@ export function registerFilesCommand(program: Command): void {
307
408
  }
308
409
  });
309
410
  }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Direct-grant flow (existing US-001 behavior, refactored out of the action
414
+ // closure so the new share-session fork stays readable). Behavior unchanged.
415
+ // ---------------------------------------------------------------------------
416
+
417
+ interface DirectGrantParams {
418
+ prefix: string;
419
+ principal: string;
420
+ permission: string | undefined;
421
+ companySlug: string | undefined;
422
+ }
423
+
424
+ async function runDirectGrant(params: DirectGrantParams): Promise<void> {
425
+ const canonicalPrefix = normalizeFilePrefix(params.prefix);
426
+
427
+ if (!params.permission) {
428
+ console.error(
429
+ chalk.red("--permission is required when --with is set (read | write)"),
430
+ );
431
+ process.exit(1);
432
+ }
433
+ if (!["read", "write"].includes(params.permission)) {
434
+ console.error(
435
+ chalk.red(`Invalid permission '${params.permission}': must be one of read, write`),
436
+ );
437
+ process.exit(1);
438
+ }
439
+
440
+ const principal = params.principal;
441
+ // `@all` is the company-wide sentinel — maps to a single typed
442
+ // 'company-wide' ACL entry whose applicability is computed at resolve
443
+ // time from the active-member list. We send granteeId as the empty
444
+ // string to match the canonical storage form on the server.
445
+ const isAll = principal === "@all";
446
+ const isEmail = !isAll && EMAIL_PATTERN.test(principal);
447
+ const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
448
+ if (!isAll && !isEmail && !isGroup) {
449
+ console.error(
450
+ chalk.red(
451
+ `Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`,
452
+ ),
453
+ );
454
+ process.exit(1);
455
+ }
456
+ const granteeType = isAll ? "company-wide" : isEmail ? "email" : "group";
457
+ const granteeId = isAll
458
+ ? ""
459
+ : isEmail
460
+ ? principal.trim().toLowerCase()
461
+ : principal;
462
+ // Display label used in success messages — `@all` reads better than `""`.
463
+ const principalLabel = isAll ? "everyone in the company" : granteeId;
464
+
465
+ const token = await ensureCognitoToken();
466
+ const companyUid = await getCompanyUid(token, params.companySlug);
467
+
468
+ let res = await vaultApiFetch({
469
+ token,
470
+ path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
471
+ method: "POST",
472
+ body: {
473
+ prefix: canonicalPrefix,
474
+ granteeType,
475
+ granteeId,
476
+ permission: params.permission,
477
+ },
478
+ });
479
+
480
+ // No ACL row exists yet for this prefix. Auto-create one with this
481
+ // grant as its first entry, then report success — saves the caller
482
+ // from needing a separate "create" step.
483
+ let autoCreated = false;
484
+ if (res.status === 404) {
485
+ res = await vaultApiFetch({
486
+ token,
487
+ path: `/files/${encodeURIComponent(companyUid)}/acl`,
488
+ method: "POST",
489
+ body: {
490
+ prefix: canonicalPrefix,
491
+ entries: [{ granteeType, granteeId, permission: params.permission }],
492
+ },
493
+ });
494
+ autoCreated = res.ok;
495
+ }
496
+
497
+ if (!res.ok) {
498
+ const body = (await res.json().catch(() => ({}))) as Record<string, string>;
499
+ if (res.status === 401) {
500
+ console.error(chalk.red("Not authenticated — please run `hq login`"));
501
+ } else if (res.status === 403) {
502
+ console.error(chalk.red("Not authorized to share this file prefix"));
503
+ } else if (res.status === 404) {
504
+ console.error(
505
+ chalk.red("ACL record not found — the prefix may not have an ACL yet"),
506
+ );
507
+ } else if (res.status === 409) {
508
+ console.error(chalk.red("Concurrent modification — please retry"));
509
+ } else if (res.status >= 500) {
510
+ console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
511
+ } else {
512
+ console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
513
+ }
514
+ process.exit(1);
515
+ }
516
+
517
+ const data = (await res.json()) as {
518
+ acl?: { path?: string; prefix?: string };
519
+ };
520
+ const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
521
+ const verb = autoCreated ? "Created ACL and granted" : "Granted";
522
+ console.log(
523
+ chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
524
+ );
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Share-session (browser-launch) flow — net-new for US-006
529
+ // ---------------------------------------------------------------------------
530
+
531
+ interface RunShareSessionParams {
532
+ paths: string[];
533
+ expires: string | undefined;
534
+ launchBrowser: boolean;
535
+ companySlug: string | undefined;
536
+ }
537
+
538
+ async function runShareSession(params: RunShareSessionParams): Promise<void> {
539
+ // Normalize every path through the shared prefix helper so a trailing
540
+ // `/` becomes `/*` consistently with the direct-grant path.
541
+ const normalizedPaths = params.paths.map(normalizeFilePrefix);
542
+
543
+ let expiresInMs: number | undefined;
544
+ if (params.expires !== undefined) {
545
+ const parsed = parseDuration(params.expires);
546
+ if (parsed === null) {
547
+ console.error(
548
+ chalk.red(
549
+ `Invalid duration '${params.expires}'. Use formats like 15m, 1h, 24h.`,
550
+ ),
551
+ );
552
+ process.exit(1);
553
+ }
554
+ if (parsed > MAX_SHARE_SESSION_EXPIRY_MS) {
555
+ console.error(
556
+ chalk.red(
557
+ "Maximum share-session expiry is 24h. For longer sharing, use `hq files share <prefix> --with <principal>`.",
558
+ ),
559
+ );
560
+ process.exit(1);
561
+ }
562
+ expiresInMs = parsed;
563
+ }
564
+
565
+ const token = await ensureCognitoToken();
566
+ const companyUid = await getCompanyUid(token, params.companySlug);
567
+
568
+ let session: ShareSessionResponse;
569
+ try {
570
+ session = await mintShareSession({
571
+ token,
572
+ companyUid,
573
+ paths: normalizedPaths,
574
+ expiresInMs,
575
+ });
576
+ } catch (err) {
577
+ if (err instanceof ShareSessionHttpError) {
578
+ console.error(chalk.red(formatShareSessionError(err)));
579
+ process.exit(1);
580
+ }
581
+ throw err;
582
+ }
583
+
584
+ console.log(chalk.green("Share-session URL generated:"));
585
+ console.log(`\n ${session.url}\n`);
586
+ console.log(chalk.dim(` Paths: ${normalizedPaths.join(", ")}`));
587
+ console.log(chalk.dim(` Expires: ${session.expiresAt}`));
588
+
589
+ if (params.launchBrowser) {
590
+ // Best-effort browser launch — failures (no display, missing handler)
591
+ // shouldn't fail the command since the URL is already printed.
592
+ try {
593
+ await open(session.url);
594
+ } catch (err) {
595
+ console.error(
596
+ chalk.yellow(
597
+ `Couldn't launch browser automatically (${err instanceof Error ? err.message : String(err)}). Copy the URL above.`,
598
+ ),
599
+ );
600
+ }
601
+ } else {
602
+ console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
603
+ }
604
+ }