@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 +58 -0
- package/dist/commands/files.d.ts +41 -0
- package/dist/commands/files.js +314 -125
- package/dist/index.js +6 -4
- package/dist/utils/version-check.d.ts +3 -0
- package/dist/utils/version-check.js +80 -0
- package/package.json +2 -1
- package/src/commands/files.test.ts +504 -0
- package/src/commands/files.ts +441 -146
- package/src/index.ts +7 -2
- package/src/utils/version-check.test.ts +146 -0
- package/src/utils/version-check.ts +83 -0
package/src/commands/files.ts
CHANGED
|
@@ -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
|
|
15
|
-
.description(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
path
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
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 =
|
|
109
|
-
|
|
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}' / ${
|
|
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 ${
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 (
|
|
287
|
+
} else if (treeRes.status === 403) {
|
|
185
288
|
console.error(chalk.red("Not authorized to view this file prefix's ACL"));
|
|
186
|
-
} else if (
|
|
187
|
-
console.error(chalk.red(`Server error: ${body.error ??
|
|
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
|
|
225
|
-
const
|
|
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 —
|
|
228
|
-
// "no ACL record" exit path.
|
|
229
|
-
if (!
|
|
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
|
|
235
|
-
const aclStatus = acl?.open ? "open" : "restricted";
|
|
328
|
+
const aclStatus = row?.open ? "open" : "restricted";
|
|
236
329
|
|
|
237
|
-
console.log(chalk.green(`ACL for ${
|
|
238
|
-
if (
|
|
239
|
-
console.log(`Creator: ${
|
|
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.
|
|
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.
|
|
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 =
|
|
384
|
+
const directEntries = tree.direct;
|
|
284
385
|
if (directEntries.length === 0) {
|
|
285
|
-
if (
|
|
386
|
+
if (row?.open) {
|
|
286
387
|
console.log(chalk.gray("Open ACL — all active members have read access."));
|
|
287
|
-
} else if (
|
|
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
|
|
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
|
|
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
|
+
}
|