@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.
- package/CHANGELOG.md +46 -0
- package/dist/commands/files.d.ts +41 -0
- package/dist/commands/files.js +283 -79
- 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 +403 -84
- 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);
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
})();
|