@indigoai-us/hq-cli 5.12.2 → 5.12.3

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);
@@ -246,12 +363,20 @@ export function registerFilesCommand(program: Command): void {
246
363
  ));
247
364
  }
248
365
 
366
+ // Display labels for grantee identifiers — `company-wide` entries
367
+ // store `granteeId === ""` on the wire, but a blank cell is confusing
368
+ // in tabular output, so we render a human-readable phrase instead.
369
+ function displayGrantee(e: { granteeType: string; granteeId: string }): string {
370
+ if (e.granteeType === "company-wide") return "Everyone in company";
371
+ return e.granteeId;
372
+ }
373
+
249
374
  function printEntryTable(
250
375
  rows: Array<AclEntry & { sourcePrefix?: string }>,
251
376
  showSource: boolean,
252
377
  ): void {
253
378
  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));
379
+ const GRANTEE_W = Math.max(7, ...rows.map((e) => displayGrantee(e).length));
255
380
  const PERM_W = Math.max(10, ...rows.map((e) => e.permission.length));
256
381
  const BY_W = Math.max(10, ...rows.map((e) => e.grantedBy.length));
257
382
  const SRC_W = showSource
@@ -270,7 +395,7 @@ export function registerFilesCommand(program: Command): void {
270
395
  const grantedAt = e.grantedAt.slice(0, 10);
271
396
  const cols = [
272
397
  e.granteeType.padEnd(TYPE_W),
273
- e.granteeId.padEnd(GRANTEE_W),
398
+ displayGrantee(e).padEnd(GRANTEE_W),
274
399
  e.permission.padEnd(PERM_W),
275
400
  e.grantedBy.padEnd(BY_W),
276
401
  grantedAt,
@@ -307,3 +432,197 @@ export function registerFilesCommand(program: Command): void {
307
432
  }
308
433
  });
309
434
  }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Direct-grant flow (existing US-001 behavior, refactored out of the action
438
+ // closure so the new share-session fork stays readable). Behavior unchanged.
439
+ // ---------------------------------------------------------------------------
440
+
441
+ interface DirectGrantParams {
442
+ prefix: string;
443
+ principal: string;
444
+ permission: string | undefined;
445
+ companySlug: string | undefined;
446
+ }
447
+
448
+ async function runDirectGrant(params: DirectGrantParams): Promise<void> {
449
+ const canonicalPrefix = normalizeFilePrefix(params.prefix);
450
+
451
+ if (!params.permission) {
452
+ console.error(
453
+ chalk.red("--permission is required when --with is set (read | write)"),
454
+ );
455
+ process.exit(1);
456
+ }
457
+ if (!["read", "write"].includes(params.permission)) {
458
+ console.error(
459
+ chalk.red(`Invalid permission '${params.permission}': must be one of read, write`),
460
+ );
461
+ process.exit(1);
462
+ }
463
+
464
+ const principal = params.principal;
465
+ // `@all` is the company-wide sentinel — maps to a single typed
466
+ // 'company-wide' ACL entry whose applicability is computed at resolve
467
+ // time from the active-member list. We send granteeId as the empty
468
+ // string to match the canonical storage form on the server.
469
+ const isAll = principal === "@all";
470
+ const isEmail = !isAll && EMAIL_PATTERN.test(principal);
471
+ const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
472
+ if (!isAll && !isEmail && !isGroup) {
473
+ console.error(
474
+ chalk.red(
475
+ `Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`,
476
+ ),
477
+ );
478
+ process.exit(1);
479
+ }
480
+ const granteeType = isAll ? "company-wide" : isEmail ? "email" : "group";
481
+ const granteeId = isAll
482
+ ? ""
483
+ : isEmail
484
+ ? principal.trim().toLowerCase()
485
+ : principal;
486
+ // Display label used in success messages — `@all` reads better than `""`.
487
+ const principalLabel = isAll ? "everyone in the company" : granteeId;
488
+
489
+ const token = await ensureCognitoToken();
490
+ const companyUid = await getCompanyUid(token, params.companySlug);
491
+
492
+ let res = await vaultApiFetch({
493
+ token,
494
+ path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
495
+ method: "POST",
496
+ body: {
497
+ prefix: canonicalPrefix,
498
+ granteeType,
499
+ granteeId,
500
+ permission: params.permission,
501
+ },
502
+ });
503
+
504
+ // No ACL row exists yet for this prefix. Auto-create one with this
505
+ // grant as its first entry, then report success — saves the caller
506
+ // from needing a separate "create" step.
507
+ let autoCreated = false;
508
+ if (res.status === 404) {
509
+ res = await vaultApiFetch({
510
+ token,
511
+ path: `/files/${encodeURIComponent(companyUid)}/acl`,
512
+ method: "POST",
513
+ body: {
514
+ prefix: canonicalPrefix,
515
+ entries: [{ granteeType, granteeId, permission: params.permission }],
516
+ },
517
+ });
518
+ autoCreated = res.ok;
519
+ }
520
+
521
+ if (!res.ok) {
522
+ const body = (await res.json().catch(() => ({}))) as Record<string, string>;
523
+ if (res.status === 401) {
524
+ console.error(chalk.red("Not authenticated — please run `hq login`"));
525
+ } else if (res.status === 403) {
526
+ console.error(chalk.red("Not authorized to share this file prefix"));
527
+ } else if (res.status === 404) {
528
+ console.error(
529
+ chalk.red("ACL record not found — the prefix may not have an ACL yet"),
530
+ );
531
+ } else if (res.status === 409) {
532
+ console.error(chalk.red("Concurrent modification — please retry"));
533
+ } else if (res.status >= 500) {
534
+ console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
535
+ } else {
536
+ console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
537
+ }
538
+ process.exit(1);
539
+ }
540
+
541
+ const data = (await res.json()) as {
542
+ acl?: { path?: string; prefix?: string };
543
+ };
544
+ const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
545
+ const verb = autoCreated ? "Created ACL and granted" : "Granted";
546
+ console.log(
547
+ chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
548
+ );
549
+ }
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // Share-session (browser-launch) flow — net-new for US-006
553
+ // ---------------------------------------------------------------------------
554
+
555
+ interface RunShareSessionParams {
556
+ paths: string[];
557
+ expires: string | undefined;
558
+ launchBrowser: boolean;
559
+ companySlug: string | undefined;
560
+ }
561
+
562
+ async function runShareSession(params: RunShareSessionParams): Promise<void> {
563
+ // Normalize every path through the shared prefix helper so a trailing
564
+ // `/` becomes `/*` consistently with the direct-grant path.
565
+ const normalizedPaths = params.paths.map(normalizeFilePrefix);
566
+
567
+ let expiresInMs: number | undefined;
568
+ if (params.expires !== undefined) {
569
+ const parsed = parseDuration(params.expires);
570
+ if (parsed === null) {
571
+ console.error(
572
+ chalk.red(
573
+ `Invalid duration '${params.expires}'. Use formats like 15m, 1h, 24h.`,
574
+ ),
575
+ );
576
+ process.exit(1);
577
+ }
578
+ if (parsed > MAX_SHARE_SESSION_EXPIRY_MS) {
579
+ console.error(
580
+ chalk.red(
581
+ "Maximum share-session expiry is 24h. For longer sharing, use `hq files share <prefix> --with <principal>`.",
582
+ ),
583
+ );
584
+ process.exit(1);
585
+ }
586
+ expiresInMs = parsed;
587
+ }
588
+
589
+ const token = await ensureCognitoToken();
590
+ const companyUid = await getCompanyUid(token, params.companySlug);
591
+
592
+ let session: ShareSessionResponse;
593
+ try {
594
+ session = await mintShareSession({
595
+ token,
596
+ companyUid,
597
+ paths: normalizedPaths,
598
+ expiresInMs,
599
+ });
600
+ } catch (err) {
601
+ if (err instanceof ShareSessionHttpError) {
602
+ console.error(chalk.red(formatShareSessionError(err)));
603
+ process.exit(1);
604
+ }
605
+ throw err;
606
+ }
607
+
608
+ console.log(chalk.green("Share-session URL generated:"));
609
+ console.log(`\n ${session.url}\n`);
610
+ console.log(chalk.dim(` Paths: ${normalizedPaths.join(", ")}`));
611
+ console.log(chalk.dim(` Expires: ${session.expiresAt}`));
612
+
613
+ if (params.launchBrowser) {
614
+ // Best-effort browser launch — failures (no display, missing handler)
615
+ // shouldn't fail the command since the URL is already printed.
616
+ try {
617
+ await open(session.url);
618
+ } catch (err) {
619
+ console.error(
620
+ chalk.yellow(
621
+ `Couldn't launch browser automatically (${err instanceof Error ? err.message : String(err)}). Copy the URL above.`,
622
+ ),
623
+ );
624
+ }
625
+ } else {
626
+ console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
627
+ }
628
+ }
package/src/index.ts CHANGED
@@ -30,15 +30,20 @@ import { registerFilesCommand } from "./commands/files.js";
30
30
  import { registerMembersCommand } from "./commands/members.js";
31
31
  import { registerFeedbackCommand } from "./commands/feedback.js";
32
32
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
33
+ import {
34
+ maybeWarnNewVersion,
35
+ refreshVersionCache,
36
+ } from "./utils/version-check.js";
33
37
 
34
38
  initSentry();
39
+ maybeWarnNewVersion();
35
40
 
36
41
  const program = new Command();
37
42
 
38
43
  program
39
44
  .name("hq")
40
45
  .description("HQ management CLI — modules, packages, and cloud sync")
41
- .version("5.12.1");
46
+ .version("5.12.3");
42
47
 
43
48
  // Module management subcommand group
44
49
  const modulesCmd = program
@@ -126,6 +131,6 @@ registerFeedbackCommand(program);
126
131
  Sentry.captureException(err);
127
132
  process.exitCode = 1;
128
133
  } finally {
129
- await Sentry.flush(2000);
134
+ await Promise.allSettled([refreshVersionCache(), Sentry.flush(2000)]);
130
135
  }
131
136
  })();