@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.12.3] — 2026-05-12
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`hq files share <prefix>...` (no `--with`) — browser-launched share-session flow.**
|
|
8
|
+
Mints an encrypted single-use token via `POST /files/{companyUid}/share-session`,
|
|
9
|
+
opens the default browser to `https://hq.{co}.com/share-session/<token>`, and lets
|
|
10
|
+
the issuer batch-pick recipients (members, groups, "Share with All") with per-recipient
|
|
11
|
+
read/write before submitting all grants in one round-trip. Variadic — accepts multiple
|
|
12
|
+
paths in a single invocation: `hq files share path/a/ path/b/ path/c/`.
|
|
13
|
+
|
|
14
|
+
- **`--no-open` flag on `hq files share`** — prints the share-session URL without
|
|
15
|
+
launching a browser. Useful for headless contexts (SSH sessions, CI, paste-into-chat
|
|
16
|
+
workflows). Output includes the URL, paths, and `expiresAt` timestamp.
|
|
17
|
+
|
|
18
|
+
- **`--with @all` for company-wide grants.** Writes a single ACL entry with
|
|
19
|
+
`granteeType: 'company-wide'` covering every active member of the company, replacing
|
|
20
|
+
the legacy `open: true` flag pattern with explicit, individually-revocable grants.
|
|
21
|
+
Works on both `hq files share` and `hq files unshare`. Members added after the grant
|
|
22
|
+
resolve through the company-wide entry automatically at vend-time — no ACL re-write
|
|
23
|
+
needed.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`hq --version` now prints the correct version.** The embedded version
|
|
28
|
+
constant had drifted from `package.json` since 5.12.1 (the 5.12.2 release
|
|
29
|
+
still reported `5.12.1`).
|
|
30
|
+
|
|
31
|
+
### Backwards Compatibility
|
|
32
|
+
|
|
33
|
+
- The legacy direct-grant form (`hq files share <prefix> --with <principal> --permission <level>`)
|
|
34
|
+
is unchanged. The browser flow only triggers when `--with` is absent. Scripted
|
|
35
|
+
automation calling the direct-grant form requires no changes.
|
|
36
|
+
|
|
37
|
+
### Security Notes
|
|
38
|
+
|
|
39
|
+
- Share-session tokens are AES-256-GCM encrypted with the master key and pin the issuer's
|
|
40
|
+
identity, the requested paths, and `maxPermissionByPath` at mint time. The web page
|
|
41
|
+
cannot grant beyond what the issuer had at mint, even if mutated client-side.
|
|
42
|
+
- Default TTL: 15 minutes. Bounded `60s..7d` server-side.
|
|
43
|
+
- Single-use: the submit endpoint claims the token's nonce atomically (DynamoDB
|
|
44
|
+
`attribute_not_exists`); a second submit returns 409.
|
|
45
|
+
- **Treat share-session URLs as live capabilities** — do not paste them into commits,
|
|
46
|
+
thread files, journals, or any persistent surface. The 15-minute TTL is defense
|
|
47
|
+
in depth, not a license to log them.
|
|
48
|
+
|
|
3
49
|
## [5.12.2] — 2026-05-10
|
|
4
50
|
|
|
5
51
|
### Added
|
package/dist/commands/files.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/files.js
CHANGED
|
@@ -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]="
|
|
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]="50b160eb-d996-5ac2-8efc-b1791b469f3a")}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
|
|
14
|
-
.description("Share a
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
.
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
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"));
|
|
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);
|
|
71
116
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
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 =
|
|
105
|
-
|
|
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}' / ${
|
|
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 ${
|
|
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));
|
|
@@ -206,9 +263,17 @@ export function registerFilesCommand(program) {
|
|
|
206
263
|
else {
|
|
207
264
|
console.log(chalk.gray("No direct ACL row — access flows from the inherited/descendant grants below."));
|
|
208
265
|
}
|
|
266
|
+
// Display labels for grantee identifiers — `company-wide` entries
|
|
267
|
+
// store `granteeId === ""` on the wire, but a blank cell is confusing
|
|
268
|
+
// in tabular output, so we render a human-readable phrase instead.
|
|
269
|
+
function displayGrantee(e) {
|
|
270
|
+
if (e.granteeType === "company-wide")
|
|
271
|
+
return "Everyone in company";
|
|
272
|
+
return e.granteeId;
|
|
273
|
+
}
|
|
209
274
|
function printEntryTable(rows, showSource) {
|
|
210
275
|
const TYPE_W = Math.max(4, ...rows.map((e) => e.granteeType.length));
|
|
211
|
-
const GRANTEE_W = Math.max(7, ...rows.map((e) => e.
|
|
276
|
+
const GRANTEE_W = Math.max(7, ...rows.map((e) => displayGrantee(e).length));
|
|
212
277
|
const PERM_W = Math.max(10, ...rows.map((e) => e.permission.length));
|
|
213
278
|
const BY_W = Math.max(10, ...rows.map((e) => e.grantedBy.length));
|
|
214
279
|
const SRC_W = showSource
|
|
@@ -228,7 +293,7 @@ export function registerFilesCommand(program) {
|
|
|
228
293
|
const grantedAt = e.grantedAt.slice(0, 10);
|
|
229
294
|
const cols = [
|
|
230
295
|
e.granteeType.padEnd(TYPE_W),
|
|
231
|
-
e.
|
|
296
|
+
displayGrantee(e).padEnd(GRANTEE_W),
|
|
232
297
|
e.permission.padEnd(PERM_W),
|
|
233
298
|
e.grantedBy.padEnd(BY_W),
|
|
234
299
|
grantedAt,
|
|
@@ -266,5 +331,144 @@ export function registerFilesCommand(program) {
|
|
|
266
331
|
}
|
|
267
332
|
});
|
|
268
333
|
}
|
|
334
|
+
async function runDirectGrant(params) {
|
|
335
|
+
const canonicalPrefix = normalizeFilePrefix(params.prefix);
|
|
336
|
+
if (!params.permission) {
|
|
337
|
+
console.error(chalk.red("--permission is required when --with is set (read | write)"));
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
if (!["read", "write"].includes(params.permission)) {
|
|
341
|
+
console.error(chalk.red(`Invalid permission '${params.permission}': must be one of read, write`));
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
const principal = params.principal;
|
|
345
|
+
// `@all` is the company-wide sentinel — maps to a single typed
|
|
346
|
+
// 'company-wide' ACL entry whose applicability is computed at resolve
|
|
347
|
+
// time from the active-member list. We send granteeId as the empty
|
|
348
|
+
// string to match the canonical storage form on the server.
|
|
349
|
+
const isAll = principal === "@all";
|
|
350
|
+
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
351
|
+
const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
|
|
352
|
+
if (!isAll && !isEmail && !isGroup) {
|
|
353
|
+
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`));
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
const granteeType = isAll ? "company-wide" : isEmail ? "email" : "group";
|
|
357
|
+
const granteeId = isAll
|
|
358
|
+
? ""
|
|
359
|
+
: isEmail
|
|
360
|
+
? principal.trim().toLowerCase()
|
|
361
|
+
: principal;
|
|
362
|
+
// Display label used in success messages — `@all` reads better than `""`.
|
|
363
|
+
const principalLabel = isAll ? "everyone in the company" : granteeId;
|
|
364
|
+
const token = await ensureCognitoToken();
|
|
365
|
+
const companyUid = await getCompanyUid(token, params.companySlug);
|
|
366
|
+
let res = await vaultApiFetch({
|
|
367
|
+
token,
|
|
368
|
+
path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
|
|
369
|
+
method: "POST",
|
|
370
|
+
body: {
|
|
371
|
+
prefix: canonicalPrefix,
|
|
372
|
+
granteeType,
|
|
373
|
+
granteeId,
|
|
374
|
+
permission: params.permission,
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
// No ACL row exists yet for this prefix. Auto-create one with this
|
|
378
|
+
// grant as its first entry, then report success — saves the caller
|
|
379
|
+
// from needing a separate "create" step.
|
|
380
|
+
let autoCreated = false;
|
|
381
|
+
if (res.status === 404) {
|
|
382
|
+
res = await vaultApiFetch({
|
|
383
|
+
token,
|
|
384
|
+
path: `/files/${encodeURIComponent(companyUid)}/acl`,
|
|
385
|
+
method: "POST",
|
|
386
|
+
body: {
|
|
387
|
+
prefix: canonicalPrefix,
|
|
388
|
+
entries: [{ granteeType, granteeId, permission: params.permission }],
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
autoCreated = res.ok;
|
|
392
|
+
}
|
|
393
|
+
if (!res.ok) {
|
|
394
|
+
const body = (await res.json().catch(() => ({})));
|
|
395
|
+
if (res.status === 401) {
|
|
396
|
+
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
397
|
+
}
|
|
398
|
+
else if (res.status === 403) {
|
|
399
|
+
console.error(chalk.red("Not authorized to share this file prefix"));
|
|
400
|
+
}
|
|
401
|
+
else if (res.status === 404) {
|
|
402
|
+
console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
|
|
403
|
+
}
|
|
404
|
+
else if (res.status === 409) {
|
|
405
|
+
console.error(chalk.red("Concurrent modification — please retry"));
|
|
406
|
+
}
|
|
407
|
+
else if (res.status >= 500) {
|
|
408
|
+
console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
|
|
412
|
+
}
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
const data = (await res.json());
|
|
416
|
+
const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
|
|
417
|
+
const verb = autoCreated ? "Created ACL and granted" : "Granted";
|
|
418
|
+
console.log(chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`));
|
|
419
|
+
}
|
|
420
|
+
async function runShareSession(params) {
|
|
421
|
+
// Normalize every path through the shared prefix helper so a trailing
|
|
422
|
+
// `/` becomes `/*` consistently with the direct-grant path.
|
|
423
|
+
const normalizedPaths = params.paths.map(normalizeFilePrefix);
|
|
424
|
+
let expiresInMs;
|
|
425
|
+
if (params.expires !== undefined) {
|
|
426
|
+
const parsed = parseDuration(params.expires);
|
|
427
|
+
if (parsed === null) {
|
|
428
|
+
console.error(chalk.red(`Invalid duration '${params.expires}'. Use formats like 15m, 1h, 24h.`));
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
if (parsed > MAX_SHARE_SESSION_EXPIRY_MS) {
|
|
432
|
+
console.error(chalk.red("Maximum share-session expiry is 24h. For longer sharing, use `hq files share <prefix> --with <principal>`."));
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
expiresInMs = parsed;
|
|
436
|
+
}
|
|
437
|
+
const token = await ensureCognitoToken();
|
|
438
|
+
const companyUid = await getCompanyUid(token, params.companySlug);
|
|
439
|
+
let session;
|
|
440
|
+
try {
|
|
441
|
+
session = await mintShareSession({
|
|
442
|
+
token,
|
|
443
|
+
companyUid,
|
|
444
|
+
paths: normalizedPaths,
|
|
445
|
+
expiresInMs,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
if (err instanceof ShareSessionHttpError) {
|
|
450
|
+
console.error(chalk.red(formatShareSessionError(err)));
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
console.log(chalk.green("Share-session URL generated:"));
|
|
456
|
+
console.log(`\n ${session.url}\n`);
|
|
457
|
+
console.log(chalk.dim(` Paths: ${normalizedPaths.join(", ")}`));
|
|
458
|
+
console.log(chalk.dim(` Expires: ${session.expiresAt}`));
|
|
459
|
+
if (params.launchBrowser) {
|
|
460
|
+
// Best-effort browser launch — failures (no display, missing handler)
|
|
461
|
+
// shouldn't fail the command since the URL is already printed.
|
|
462
|
+
try {
|
|
463
|
+
await open(session.url);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
console.error(chalk.yellow(`Couldn't launch browser automatically (${err instanceof Error ? err.message : String(err)}). Copy the URL above.`));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
269
473
|
//# sourceMappingURL=files.js.map
|
|
270
|
-
//# debugId=
|
|
474
|
+
//# debugId=50b160eb-d996-5ac2-8efc-b1791b469f3a
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* HQ CLI - Module management, package management, and cloud sync for HQ
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
!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]="
|
|
6
|
+
!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]="efa927a6-3cad-5122-928e-f7b055b533e8")}catch(e){}}();
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { initSentry, Sentry } from "./sentry.js";
|
|
9
9
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -30,12 +30,14 @@ 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 { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
|
|
33
34
|
initSentry();
|
|
35
|
+
maybeWarnNewVersion();
|
|
34
36
|
const program = new Command();
|
|
35
37
|
program
|
|
36
38
|
.name("hq")
|
|
37
39
|
.description("HQ management CLI — modules, packages, and cloud sync")
|
|
38
|
-
.version("5.12.
|
|
40
|
+
.version("5.12.3");
|
|
39
41
|
// Module management subcommand group
|
|
40
42
|
const modulesCmd = program
|
|
41
43
|
.command("modules")
|
|
@@ -104,8 +106,8 @@ registerFeedbackCommand(program);
|
|
|
104
106
|
process.exitCode = 1;
|
|
105
107
|
}
|
|
106
108
|
finally {
|
|
107
|
-
await Sentry.flush(2000);
|
|
109
|
+
await Promise.allSettled([refreshVersionCache(), Sentry.flush(2000)]);
|
|
108
110
|
}
|
|
109
111
|
})();
|
|
110
112
|
//# sourceMappingURL=index.js.map
|
|
111
|
-
//# debugId=
|
|
113
|
+
//# debugId=efa927a6-3cad-5122-928e-f7b055b533e8
|
|
@@ -0,0 +1,80 @@
|
|
|
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]="99545a82-414c-5fed-a478-ea8e63009643")}catch(e){}}();
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import semver from "semver";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
9
|
+
const PACKAGE_NAME = "@indigoai-us/hq-cli";
|
|
10
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
|
|
11
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const FETCH_TIMEOUT_MS = 3_000;
|
|
13
|
+
function cachePath() {
|
|
14
|
+
return path.join(os.homedir(), ".hq", "version-check.json");
|
|
15
|
+
}
|
|
16
|
+
function isOptedOut() {
|
|
17
|
+
return process.env.HQ_NO_UPDATE_CHECK === "1";
|
|
18
|
+
}
|
|
19
|
+
function readCache() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = fs.readFileSync(cachePath(), "utf-8");
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (typeof parsed.latest !== "string" ||
|
|
24
|
+
typeof parsed.fetchedAt !== "number") {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return { latest: parsed.latest, fetchedAt: parsed.fetchedAt };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function writeCache(entry) {
|
|
34
|
+
try {
|
|
35
|
+
const file = cachePath();
|
|
36
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify(entry));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// best-effort; never break the CLI on cache write failure
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function maybeWarnNewVersion() {
|
|
44
|
+
if (isOptedOut())
|
|
45
|
+
return;
|
|
46
|
+
const entry = readCache();
|
|
47
|
+
if (!entry)
|
|
48
|
+
return;
|
|
49
|
+
if (Date.now() - entry.fetchedAt > CACHE_TTL_MS)
|
|
50
|
+
return;
|
|
51
|
+
const current = semver.valid(CLI_VERSION);
|
|
52
|
+
const latest = semver.valid(entry.latest);
|
|
53
|
+
if (!current || !latest)
|
|
54
|
+
return;
|
|
55
|
+
if (!semver.gt(latest, current))
|
|
56
|
+
return;
|
|
57
|
+
const msg = chalk.yellow(`⚠ A new version of hq is available: ${entry.latest} (current: ${CLI_VERSION}). It's recommended to update.`);
|
|
58
|
+
console.error(msg);
|
|
59
|
+
}
|
|
60
|
+
export async function refreshVersionCache() {
|
|
61
|
+
if (isOptedOut())
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(REGISTRY_URL, {
|
|
65
|
+
headers: { Accept: "application/json" },
|
|
66
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok)
|
|
69
|
+
return;
|
|
70
|
+
const body = (await res.json());
|
|
71
|
+
if (typeof body.version !== "string")
|
|
72
|
+
return;
|
|
73
|
+
writeCache({ latest: body.version, fetchedAt: Date.now() });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// best-effort; offline / registry down / timeout — silent
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=version-check.js.map
|
|
80
|
+
//# debugId=99545a82-414c-5fed-a478-ea8e63009643
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.12.
|
|
3
|
+
"version": "5.12.3",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"chalk": "^5.3.0",
|
|
22
22
|
"commander": "^12.1.0",
|
|
23
23
|
"js-yaml": "^4.1.0",
|
|
24
|
+
"open": "^10.1.0",
|
|
24
25
|
"simple-git": "^3.27.0",
|
|
25
26
|
"semver": "^7.6.3",
|
|
26
27
|
"varlock": "1.0.0"
|