@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
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
+
15
+ ## [5.12.3] — 2026-05-12
16
+
17
+ ### Added
18
+
19
+ - **`hq files share <prefix>...` (no `--with`) — browser-launched share-session flow.**
20
+ Mints an encrypted single-use token via `POST /files/{companyUid}/share-session`,
21
+ opens the default browser to `https://hq.{co}.com/share-session/<token>`, and lets
22
+ the issuer batch-pick recipients (members, groups, "Share with All") with per-recipient
23
+ read/write before submitting all grants in one round-trip. Variadic — accepts multiple
24
+ paths in a single invocation: `hq files share path/a/ path/b/ path/c/`.
25
+
26
+ - **`--no-open` flag on `hq files share`** — prints the share-session URL without
27
+ launching a browser. Useful for headless contexts (SSH sessions, CI, paste-into-chat
28
+ workflows). Output includes the URL, paths, and `expiresAt` timestamp.
29
+
30
+ - **`--with @all` for company-wide grants.** Writes a single ACL entry with
31
+ `granteeType: 'company-wide'` covering every active member of the company, replacing
32
+ the legacy `open: true` flag pattern with explicit, individually-revocable grants.
33
+ Works on both `hq files share` and `hq files unshare`. Members added after the grant
34
+ resolve through the company-wide entry automatically at vend-time — no ACL re-write
35
+ needed.
36
+
37
+ ### Fixed
38
+
39
+ - **`hq --version` now prints the correct version.** The embedded version
40
+ constant had drifted from `package.json` since 5.12.1 (the 5.12.2 release
41
+ still reported `5.12.1`).
42
+
43
+ ### Backwards Compatibility
44
+
45
+ - The legacy direct-grant form (`hq files share <prefix> --with <principal> --permission <level>`)
46
+ is unchanged. The browser flow only triggers when `--with` is absent. Scripted
47
+ automation calling the direct-grant form requires no changes.
48
+
49
+ ### Security Notes
50
+
51
+ - Share-session tokens are AES-256-GCM encrypted with the master key and pin the issuer's
52
+ identity, the requested paths, and `maxPermissionByPath` at mint time. The web page
53
+ cannot grant beyond what the issuer had at mint, even if mutated client-side.
54
+ - Default TTL: 15 minutes. Bounded `60s..7d` server-side.
55
+ - Single-use: the submit endpoint claims the token's nonce atomically (DynamoDB
56
+ `attribute_not_exists`); a second submit returns 409.
57
+ - **Treat share-session URLs as live capabilities** — do not paste them into commits,
58
+ thread files, journals, or any persistent surface. The 15-minute TTL is defense
59
+ in depth, not a license to log them.
60
+
3
61
  ## [5.12.2] — 2026-05-10
4
62
 
5
63
  ### Added
@@ -1,3 +1,44 @@
1
1
  import { Command } from "commander";
2
+ /**
3
+ * Parse a human-friendly duration string ("15m", "1h", "24h", "2d") into
4
+ * milliseconds. Returns null on parse failure. Mirrors the parser used in
5
+ * `secrets generate-link` but kept local so files.ts can be tested in
6
+ * isolation without importing the much larger secrets command surface.
7
+ */
8
+ export declare function parseDuration(input: string): number | null;
9
+ /** PRD upper bound on browser-launch share-session expiry. */
10
+ export declare const MAX_SHARE_SESSION_EXPIRY_MS: number;
11
+ export interface ShareSessionResponse {
12
+ url: string;
13
+ token?: string;
14
+ expiresAt: string;
15
+ nonce?: string;
16
+ paths?: string[];
17
+ maxPermissionByPath?: Record<string, string>;
18
+ }
19
+ export interface MintShareSessionParams {
20
+ token: string;
21
+ companyUid: string;
22
+ paths: string[];
23
+ expiresInMs?: number;
24
+ }
25
+ /**
26
+ * POST /files/{companyUid}/share-session — mint a browser-launch share
27
+ * session token. Returns the parsed response. Throws ShareSessionHttpError
28
+ * with a status + actionable message for any non-2xx so callers can render
29
+ * a single consistent error path.
30
+ */
31
+ export declare function mintShareSession(params: MintShareSessionParams): Promise<ShareSessionResponse>;
32
+ export declare class ShareSessionHttpError extends Error {
33
+ readonly status: number;
34
+ readonly path?: string | undefined;
35
+ constructor(status: number, message: string, path?: string | undefined);
36
+ }
37
+ /**
38
+ * Map a ShareSessionHttpError to user-facing copy. Centralizes the
39
+ * status → message mapping so the share command and any future caller
40
+ * stay in sync.
41
+ */
42
+ export declare function formatShareSessionError(err: ShareSessionHttpError): string;
2
43
  export declare function registerFilesCommand(program: Command): void;
3
44
  //# sourceMappingURL=files.d.ts.map
@@ -1,86 +1,133 @@
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]="a754383e-f129-5052-a8d8-59ea590b4f3d")}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
+ import open from "open";
4
5
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
6
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
6
7
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Pure helpers (exported for unit tests)
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Parse a human-friendly duration string ("15m", "1h", "24h", "2d") into
13
+ * milliseconds. Returns null on parse failure. Mirrors the parser used in
14
+ * `secrets generate-link` but kept local so files.ts can be tested in
15
+ * isolation without importing the much larger secrets command surface.
16
+ */
17
+ export function parseDuration(input) {
18
+ const match = input.match(/^(\d+)(m|h|d)$/);
19
+ if (!match)
20
+ return null;
21
+ const value = parseInt(match[1], 10);
22
+ const unit = match[2];
23
+ if (unit === "m")
24
+ return value * 60 * 1000;
25
+ if (unit === "h")
26
+ return value * 60 * 60 * 1000;
27
+ if (unit === "d")
28
+ return value * 24 * 60 * 60 * 1000;
29
+ return null;
30
+ }
31
+ /** PRD upper bound on browser-launch share-session expiry. */
32
+ export const MAX_SHARE_SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24h
33
+ /**
34
+ * POST /files/{companyUid}/share-session — mint a browser-launch share
35
+ * session token. Returns the parsed response. Throws ShareSessionHttpError
36
+ * with a status + actionable message for any non-2xx so callers can render
37
+ * a single consistent error path.
38
+ */
39
+ export async function mintShareSession(params) {
40
+ const body = { paths: params.paths };
41
+ if (params.expiresInMs != null)
42
+ body.expiresInMs = params.expiresInMs;
43
+ const res = await vaultApiFetch({
44
+ token: params.token,
45
+ path: `/files/${encodeURIComponent(params.companyUid)}/share-session`,
46
+ method: "POST",
47
+ body,
48
+ });
49
+ if (!res.ok) {
50
+ const errBody = (await res.json().catch(() => ({})));
51
+ throw new ShareSessionHttpError(res.status, errBody.message ?? errBody.error ?? res.statusText, errBody.path);
52
+ }
53
+ return (await res.json());
54
+ }
55
+ export class ShareSessionHttpError extends Error {
56
+ status;
57
+ path;
58
+ constructor(status, message, path) {
59
+ super(message);
60
+ this.status = status;
61
+ this.path = path;
62
+ this.name = "ShareSessionHttpError";
63
+ }
64
+ }
65
+ /**
66
+ * Map a ShareSessionHttpError to user-facing copy. Centralizes the
67
+ * status → message mapping so the share command and any future caller
68
+ * stay in sync.
69
+ */
70
+ export function formatShareSessionError(err) {
71
+ if (err.status === 401) {
72
+ return "Not authenticated — please run `hq login`";
73
+ }
74
+ if (err.status === 403) {
75
+ if (err.path) {
76
+ return `Not authorized to share '${err.path}' — you need read access on every path`;
77
+ }
78
+ return "Not authorized — you need to be a company member with read access on every path";
79
+ }
80
+ if (err.status === 400) {
81
+ return `Invalid request: ${err.message}`;
82
+ }
83
+ if (err.status >= 500) {
84
+ return `Server error: ${err.message}`;
85
+ }
86
+ return err.message || `Request failed (${err.status})`;
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // Command registration
90
+ // ---------------------------------------------------------------------------
7
91
  export function registerFilesCommand(program) {
8
92
  const files = program
9
93
  .command("files")
10
94
  .description("Manage file access controls in HQ vault")
11
95
  .option("--company <slug>", "Company slug (resolves to companyUid)");
12
96
  files
13
- .command("share <prefix>")
14
- .description("Share a file prefix with a person or group")
15
- .requiredOption("--with <principal>", "Email address or group id to share with")
16
- .requiredOption("--permission <level>", "Permission level: read | write")
17
- .action(async (prefix, opts) => {
97
+ .command("share [paths...]")
98
+ .description("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.")
99
+ .option("--with <principal>", "Email address, group id, or '@all' to share with every active company member")
100
+ .option("--permission <level>", "Permission level (only with --with): read | write")
101
+ .option("--expires <duration>", "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.")
102
+ .option("--no-open", "Print the share-session URL but do not launch the browser")
103
+ .action(async (paths, opts) => {
18
104
  try {
19
- const canonicalPrefix = normalizeFilePrefix(prefix);
20
- if (!["read", "write"].includes(opts.permission)) {
21
- console.error(chalk.red(`Invalid permission '${opts.permission}': must be one of read, write`));
105
+ if (!paths || paths.length === 0) {
106
+ console.error(chalk.red("usage: hq files share <paths...> [--with <principal>]"));
22
107
  process.exit(1);
23
108
  }
24
- const principal = opts.with;
25
- const isEmail = EMAIL_PATTERN.test(principal);
26
- const isGroup = GROUP_ID_PATTERN.test(principal);
27
- if (!isEmail && !isGroup) {
28
- console.error(chalk.red(`Invalid principal '${principal}': must be an email address or a group id matching grp_<alphanumeric>`));
29
- process.exit(1);
30
- }
31
- const granteeType = isEmail ? "email" : "group";
32
- const granteeId = isEmail ? principal.trim().toLowerCase() : principal;
33
- const token = await ensureCognitoToken();
34
- const companySlug = files.opts().company;
35
- const companyUid = await getCompanyUid(token, companySlug);
36
- let res = await vaultApiFetch({
37
- token,
38
- path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
39
- method: "POST",
40
- body: { prefix: canonicalPrefix, granteeType, granteeId, permission: opts.permission },
41
- });
42
- // No ACL row exists yet for this prefix. Auto-create one with this
43
- // grant as its first entry, then report success — saves the caller
44
- // from needing a separate "create" step.
45
- let autoCreated = false;
46
- if (res.status === 404) {
47
- res = await vaultApiFetch({
48
- token,
49
- path: `/files/${encodeURIComponent(companyUid)}/acl`,
50
- method: "POST",
51
- body: {
52
- prefix: canonicalPrefix,
53
- entries: [{ granteeType, granteeId, permission: opts.permission }],
54
- },
55
- });
56
- autoCreated = res.ok;
57
- }
58
- if (!res.ok) {
59
- const body = await res.json().catch(() => ({}));
60
- if (res.status === 401) {
61
- console.error(chalk.red("Not authenticated — please run `hq login`"));
62
- }
63
- else if (res.status === 403) {
64
- console.error(chalk.red("Not authorized to share this file prefix"));
65
- }
66
- else if (res.status === 404) {
67
- console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
68
- }
69
- else if (res.status === 409) {
70
- console.error(chalk.red("Concurrent modification — please retry"));
71
- }
72
- else if (res.status >= 500) {
73
- console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
74
- }
75
- else {
76
- console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
109
+ // Fork: --with present → existing direct-grant path. No --with
110
+ // browser-launch share-session path. Per PRD, the direct-grant
111
+ // path is unchanged from US-001 and operates on a single prefix.
112
+ if (opts.with !== undefined) {
113
+ if (paths.length !== 1) {
114
+ console.error(chalk.red("Direct grant (--with) takes exactly one prefix. Pass multiple paths only when minting a share-session URL."));
115
+ process.exit(1);
77
116
  }
78
- process.exit(1);
117
+ await runDirectGrant({
118
+ prefix: paths[0],
119
+ principal: opts.with,
120
+ permission: opts.permission,
121
+ companySlug: files.opts().company,
122
+ });
123
+ return;
79
124
  }
80
- const data = await res.json();
81
- const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
82
- const verb = autoCreated ? "Created ACL and granted" : "Granted";
83
- console.log(chalk.green(`${verb} ${opts.permission} on ${printedPrefix} to ${granteeId}`));
125
+ await runShareSession({
126
+ paths,
127
+ expires: opts.expires,
128
+ launchBrowser: opts.open,
129
+ companySlug: files.opts().company,
130
+ });
84
131
  }
85
132
  catch (err) {
86
133
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -90,19 +137,29 @@ export function registerFilesCommand(program) {
90
137
  files
91
138
  .command("unshare <prefix>")
92
139
  .description("Remove a file access grant")
93
- .requiredOption("--with <principal>", "Email address or group id to remove")
140
+ .requiredOption("--with <principal>", "Email address, group id, or '@all' to remove the company-wide grant")
94
141
  .action(async (prefix, opts) => {
95
142
  try {
96
143
  const canonicalPrefix = normalizeFilePrefix(prefix);
97
144
  const principal = opts.with;
98
- const isEmail = EMAIL_PATTERN.test(principal);
99
- const isGroup = GROUP_ID_PATTERN.test(principal);
100
- if (!isEmail && !isGroup) {
101
- console.error(chalk.red(`Invalid principal '${principal}': must be an email address or a group id matching grp_<alphanumeric>`));
145
+ const isAll = principal === "@all";
146
+ const isEmail = !isAll && EMAIL_PATTERN.test(principal);
147
+ const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
148
+ if (!isAll && !isEmail && !isGroup) {
149
+ console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`));
102
150
  process.exit(1);
103
151
  }
104
- const granteeType = isEmail ? "email" : "group";
105
- const granteeId = isEmail ? principal.trim().toLowerCase() : principal;
152
+ const granteeType = isAll
153
+ ? "company-wide"
154
+ : isEmail
155
+ ? "email"
156
+ : "group";
157
+ const granteeId = isAll
158
+ ? ""
159
+ : isEmail
160
+ ? principal.trim().toLowerCase()
161
+ : principal;
162
+ const principalLabel = isAll ? "everyone in the company" : granteeId;
106
163
  const token = await ensureCognitoToken();
107
164
  const companySlug = files.opts().company;
108
165
  const companyUid = await getCompanyUid(token, companySlug);
@@ -121,7 +178,7 @@ export function registerFilesCommand(program) {
121
178
  console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
122
179
  }
123
180
  else if (res.status === 404) {
124
- console.log(chalk.green(`Grant already absent for '${canonicalPrefix}' / ${granteeId}`));
181
+ console.log(chalk.green(`Grant already absent for '${canonicalPrefix}' / ${principalLabel}`));
125
182
  return;
126
183
  }
127
184
  else if (res.status >= 500) {
@@ -132,7 +189,7 @@ export function registerFilesCommand(program) {
132
189
  }
133
190
  process.exit(1);
134
191
  }
135
- console.log(chalk.green(`Removed grant for ${granteeId} on '${canonicalPrefix}'`));
192
+ console.log(chalk.green(`Removed grant for ${principalLabel} on '${canonicalPrefix}'`));
136
193
  }
137
194
  catch (err) {
138
195
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -148,67 +205,60 @@ export function registerFilesCommand(program) {
148
205
  const token = await ensureCognitoToken();
149
206
  const companySlug = files.opts().company;
150
207
  const companyUid = await getCompanyUid(token, companySlug);
151
- // Fetch the prefix's own ACL row (creator, open flag, effective
152
- // permission) and the inherited/descendant tree in parallel so the
153
- // user sees every grant that affects this prefix in one shot.
154
- const [aclRes, treeRes] = await Promise.all([
155
- vaultApiFetch({
156
- token,
157
- path: `/files/${encodeURIComponent(companyUid)}/acl`,
158
- query: { prefix: canonicalPrefix },
159
- }),
160
- vaultApiFetch({
161
- token,
162
- path: `/files/${encodeURIComponent(companyUid)}/acl/tree`,
163
- query: { prefix: canonicalPrefix },
164
- }),
165
- ]);
166
- async function readErrorBody(res) {
167
- return (await res.json().catch(() => ({})));
168
- }
169
- // Auth/server failures from either call are treated identically — bail
170
- // out with a single message rather than printing a half-rendered view.
171
- for (const res of [aclRes, treeRes]) {
172
- if (res.ok || res.status === 404)
173
- continue;
174
- const body = await readErrorBody(res);
175
- 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) {
176
219
  console.error(chalk.red("Not authenticated — please run `hq login`"));
177
220
  }
178
- else if (res.status === 403) {
221
+ else if (treeRes.status === 403) {
179
222
  console.error(chalk.red("Not authorized to view this file prefix's ACL"));
180
223
  }
181
- else if (res.status >= 500) {
182
- 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}`));
183
226
  }
184
227
  else {
185
228
  console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
186
229
  }
187
230
  process.exit(1);
188
231
  }
189
- const acl = aclRes.ok ? (await aclRes.json()).acl : null;
190
- const tree = treeRes.ok ? (await treeRes.json()) : null;
191
- // No own row AND nothing inherited or granted below — original
192
- // "no ACL record" exit path.
193
- 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) {
194
237
  console.error(chalk.red(`No ACL record exists for '${canonicalPrefix}'`));
195
238
  process.exit(1);
196
239
  }
197
- const aclPrefix = acl?.path ?? acl?.prefix ?? tree?.prefix ?? canonicalPrefix;
198
- const aclStatus = acl?.open ? "open" : "restricted";
199
- console.log(chalk.green(`ACL for ${aclPrefix} (${aclStatus})`));
200
- if (acl) {
201
- console.log(`Creator: ${acl.creatorUid}`);
202
- if (acl.effectivePermission) {
203
- console.log(`Your effective permission: ${acl.effectivePermission}`);
204
- }
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}`);
205
244
  }
206
245
  else {
207
246
  console.log(chalk.gray("No direct ACL row — access flows from the inherited/descendant grants below."));
208
247
  }
248
+ if (tree.effectivePermission) {
249
+ console.log(`Your effective permission: ${tree.effectivePermission}`);
250
+ }
251
+ // Display labels for grantee identifiers — `company-wide` entries
252
+ // store `granteeId === ""` on the wire, but a blank cell is confusing
253
+ // in tabular output, so we render a human-readable phrase instead.
254
+ function displayGrantee(e) {
255
+ if (e.granteeType === "company-wide")
256
+ return "Everyone in company";
257
+ return e.granteeId;
258
+ }
209
259
  function printEntryTable(rows, showSource) {
210
260
  const TYPE_W = Math.max(4, ...rows.map((e) => e.granteeType.length));
211
- const GRANTEE_W = Math.max(7, ...rows.map((e) => e.granteeId.length));
261
+ const GRANTEE_W = Math.max(7, ...rows.map((e) => displayGrantee(e).length));
212
262
  const PERM_W = Math.max(10, ...rows.map((e) => e.permission.length));
213
263
  const BY_W = Math.max(10, ...rows.map((e) => e.grantedBy.length));
214
264
  const SRC_W = showSource
@@ -228,7 +278,7 @@ export function registerFilesCommand(program) {
228
278
  const grantedAt = e.grantedAt.slice(0, 10);
229
279
  const cols = [
230
280
  e.granteeType.padEnd(TYPE_W),
231
- e.granteeId.padEnd(GRANTEE_W),
281
+ displayGrantee(e).padEnd(GRANTEE_W),
232
282
  e.permission.padEnd(PERM_W),
233
283
  e.grantedBy.padEnd(BY_W),
234
284
  grantedAt,
@@ -238,12 +288,12 @@ export function registerFilesCommand(program) {
238
288
  console.log(cols.join(" "));
239
289
  }
240
290
  }
241
- const directEntries = acl?.entries ?? tree?.direct ?? [];
291
+ const directEntries = tree.direct;
242
292
  if (directEntries.length === 0) {
243
- if (acl?.open) {
293
+ if (row?.open) {
244
294
  console.log(chalk.gray("Open ACL — all active members have read access."));
245
295
  }
246
- else if (acl) {
296
+ else if (row) {
247
297
  console.log(chalk.gray("No explicit grants on this prefix — only creator has access."));
248
298
  }
249
299
  }
@@ -251,11 +301,11 @@ export function registerFilesCommand(program) {
251
301
  console.log("\nDirect entries (granted on this prefix):");
252
302
  printEntryTable(directEntries, false);
253
303
  }
254
- if (tree && tree.inherited.length > 0) {
304
+ if (tree.inherited.length > 0) {
255
305
  console.log("\nInherited (granted on an ancestor prefix):");
256
306
  printEntryTable(tree.inherited, true);
257
307
  }
258
- if (tree && tree.children.length > 0) {
308
+ if (tree.children.length > 0) {
259
309
  console.log("\nGranted on descendant prefixes (do not affect this prefix's access):");
260
310
  printEntryTable(tree.children, true);
261
311
  }
@@ -266,5 +316,144 @@ export function registerFilesCommand(program) {
266
316
  }
267
317
  });
268
318
  }
319
+ async function runDirectGrant(params) {
320
+ const canonicalPrefix = normalizeFilePrefix(params.prefix);
321
+ if (!params.permission) {
322
+ console.error(chalk.red("--permission is required when --with is set (read | write)"));
323
+ process.exit(1);
324
+ }
325
+ if (!["read", "write"].includes(params.permission)) {
326
+ console.error(chalk.red(`Invalid permission '${params.permission}': must be one of read, write`));
327
+ process.exit(1);
328
+ }
329
+ const principal = params.principal;
330
+ // `@all` is the company-wide sentinel — maps to a single typed
331
+ // 'company-wide' ACL entry whose applicability is computed at resolve
332
+ // time from the active-member list. We send granteeId as the empty
333
+ // string to match the canonical storage form on the server.
334
+ const isAll = principal === "@all";
335
+ const isEmail = !isAll && EMAIL_PATTERN.test(principal);
336
+ const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
337
+ if (!isAll && !isEmail && !isGroup) {
338
+ console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`));
339
+ process.exit(1);
340
+ }
341
+ const granteeType = isAll ? "company-wide" : isEmail ? "email" : "group";
342
+ const granteeId = isAll
343
+ ? ""
344
+ : isEmail
345
+ ? principal.trim().toLowerCase()
346
+ : principal;
347
+ // Display label used in success messages — `@all` reads better than `""`.
348
+ const principalLabel = isAll ? "everyone in the company" : granteeId;
349
+ const token = await ensureCognitoToken();
350
+ const companyUid = await getCompanyUid(token, params.companySlug);
351
+ let res = await vaultApiFetch({
352
+ token,
353
+ path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
354
+ method: "POST",
355
+ body: {
356
+ prefix: canonicalPrefix,
357
+ granteeType,
358
+ granteeId,
359
+ permission: params.permission,
360
+ },
361
+ });
362
+ // No ACL row exists yet for this prefix. Auto-create one with this
363
+ // grant as its first entry, then report success — saves the caller
364
+ // from needing a separate "create" step.
365
+ let autoCreated = false;
366
+ if (res.status === 404) {
367
+ res = await vaultApiFetch({
368
+ token,
369
+ path: `/files/${encodeURIComponent(companyUid)}/acl`,
370
+ method: "POST",
371
+ body: {
372
+ prefix: canonicalPrefix,
373
+ entries: [{ granteeType, granteeId, permission: params.permission }],
374
+ },
375
+ });
376
+ autoCreated = res.ok;
377
+ }
378
+ if (!res.ok) {
379
+ const body = (await res.json().catch(() => ({})));
380
+ if (res.status === 401) {
381
+ console.error(chalk.red("Not authenticated — please run `hq login`"));
382
+ }
383
+ else if (res.status === 403) {
384
+ console.error(chalk.red("Not authorized to share this file prefix"));
385
+ }
386
+ else if (res.status === 404) {
387
+ console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
388
+ }
389
+ else if (res.status === 409) {
390
+ console.error(chalk.red("Concurrent modification — please retry"));
391
+ }
392
+ else if (res.status >= 500) {
393
+ console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
394
+ }
395
+ else {
396
+ console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
397
+ }
398
+ process.exit(1);
399
+ }
400
+ const data = (await res.json());
401
+ const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
402
+ const verb = autoCreated ? "Created ACL and granted" : "Granted";
403
+ console.log(chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`));
404
+ }
405
+ async function runShareSession(params) {
406
+ // Normalize every path through the shared prefix helper so a trailing
407
+ // `/` becomes `/*` consistently with the direct-grant path.
408
+ const normalizedPaths = params.paths.map(normalizeFilePrefix);
409
+ let expiresInMs;
410
+ if (params.expires !== undefined) {
411
+ const parsed = parseDuration(params.expires);
412
+ if (parsed === null) {
413
+ console.error(chalk.red(`Invalid duration '${params.expires}'. Use formats like 15m, 1h, 24h.`));
414
+ process.exit(1);
415
+ }
416
+ if (parsed > MAX_SHARE_SESSION_EXPIRY_MS) {
417
+ console.error(chalk.red("Maximum share-session expiry is 24h. For longer sharing, use `hq files share <prefix> --with <principal>`."));
418
+ process.exit(1);
419
+ }
420
+ expiresInMs = parsed;
421
+ }
422
+ const token = await ensureCognitoToken();
423
+ const companyUid = await getCompanyUid(token, params.companySlug);
424
+ let session;
425
+ try {
426
+ session = await mintShareSession({
427
+ token,
428
+ companyUid,
429
+ paths: normalizedPaths,
430
+ expiresInMs,
431
+ });
432
+ }
433
+ catch (err) {
434
+ if (err instanceof ShareSessionHttpError) {
435
+ console.error(chalk.red(formatShareSessionError(err)));
436
+ process.exit(1);
437
+ }
438
+ throw err;
439
+ }
440
+ console.log(chalk.green("Share-session URL generated:"));
441
+ console.log(`\n ${session.url}\n`);
442
+ console.log(chalk.dim(` Paths: ${normalizedPaths.join(", ")}`));
443
+ console.log(chalk.dim(` Expires: ${session.expiresAt}`));
444
+ if (params.launchBrowser) {
445
+ // Best-effort browser launch — failures (no display, missing handler)
446
+ // shouldn't fail the command since the URL is already printed.
447
+ try {
448
+ await open(session.url);
449
+ }
450
+ catch (err) {
451
+ console.error(chalk.yellow(`Couldn't launch browser automatically (${err instanceof Error ? err.message : String(err)}). Copy the URL above.`));
452
+ }
453
+ }
454
+ else {
455
+ console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
456
+ }
457
+ }
269
458
  //# sourceMappingURL=files.js.map
270
- //# debugId=a754383e-f129-5052-a8d8-59ea590b4f3d
459
+ //# debugId=7a4bfaa9-c578-5cd8-8955-ec965faada13