@indigoai-us/hq-cli 5.31.0 → 5.33.0
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/dist/commands/group-grants.d.ts +76 -0
- package/dist/commands/group-grants.js +296 -0
- package/dist/commands/pack-install.d.ts +46 -1
- package/dist/commands/pack-install.js +96 -17
- package/dist/commands/packs.d.ts +22 -0
- package/dist/commands/packs.js +406 -0
- package/dist/commands/pkg-list.js +31 -5
- package/dist/index.js +12 -2
- package/dist/utils/pack-contributions.d.ts +87 -0
- package/dist/utils/pack-contributions.js +239 -0
- package/package.json +2 -2
- package/src/commands/group-grants.test.ts +291 -0
- package/src/commands/group-grants.ts +452 -0
- package/src/commands/pack-install.ts +129 -21
- package/src/commands/packs.ts +524 -0
- package/src/commands/pkg-list.ts +34 -3
- package/src/index.ts +12 -0
- package/src/utils/pack-contributions.test.ts +208 -0
- package/src/utils/pack-contributions.ts +303 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `hq group-grants` — manage cross-company group access grants.
|
|
4
|
+
*
|
|
5
|
+
* A *group grant* lets a group defined in a SOURCE company be granted a role
|
|
6
|
+
* on a TARGET company. The hq-pro API authorizes the operation against the
|
|
7
|
+
* TARGET company (the caller must be able to invite into it). The CLI is a
|
|
8
|
+
* thin client — it passes the right params and surfaces the server's 403 as an
|
|
9
|
+
* actionable cross-tenant permission error. NO authorization is reimplemented
|
|
10
|
+
* here.
|
|
11
|
+
*
|
|
12
|
+
* Backend contract (hq-pro feature/agency-cross-company-group-grants):
|
|
13
|
+
* - POST /group-grants { groupId, sourceCompanyUid, targetCompanyUid, role }
|
|
14
|
+
* - POST /group-grants/revoke { groupId, sourceCompanyUid, targetCompanyUid }
|
|
15
|
+
* - GET /group-grants/outbound?sourceCompanyUid&groupId
|
|
16
|
+
* - GET /group-grants/inbound?companyUid
|
|
17
|
+
*
|
|
18
|
+
* All require a Cognito idToken bearer; 403 (code FORBIDDEN) on authz failure.
|
|
19
|
+
*/
|
|
20
|
+
export declare const VALID_GRANT_ROLES: Set<string>;
|
|
21
|
+
export type GrantRole = "owner" | "admin" | "member" | "guest";
|
|
22
|
+
export interface GroupGrant {
|
|
23
|
+
groupId: string;
|
|
24
|
+
sourceCompanyUid: string;
|
|
25
|
+
targetCompanyUid: string;
|
|
26
|
+
role: string;
|
|
27
|
+
grantedBy?: string;
|
|
28
|
+
grantedAt?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface GrantGroupOptions {
|
|
31
|
+
groupId: string;
|
|
32
|
+
sourceCompanyUid: string;
|
|
33
|
+
targetCompanyUid: string;
|
|
34
|
+
role: string;
|
|
35
|
+
token: string;
|
|
36
|
+
}
|
|
37
|
+
export interface RevokeGroupGrantOptions {
|
|
38
|
+
groupId: string;
|
|
39
|
+
sourceCompanyUid: string;
|
|
40
|
+
targetCompanyUid: string;
|
|
41
|
+
token: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Typed HTTP error mirroring members.ts's `InviteHttpError`. Carries the
|
|
45
|
+
* server status + optional error `code` (e.g. `FORBIDDEN`) so the command
|
|
46
|
+
* layer can render an actionable message and exit non-zero.
|
|
47
|
+
*/
|
|
48
|
+
export declare class GrantHttpError extends Error {
|
|
49
|
+
status: number;
|
|
50
|
+
code?: string | undefined;
|
|
51
|
+
constructor(status: number, message: string, code?: string | undefined);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Map a `GrantHttpError` onto an actionable, human-readable message. The 403
|
|
55
|
+
* branch is the story's key cross-tenant case: authorization is enforced
|
|
56
|
+
* against the TARGET company, so the message names the target the caller
|
|
57
|
+
* lacks rights on.
|
|
58
|
+
*/
|
|
59
|
+
export declare function formatGrantHttpError(status: number, fallback: string, ctx?: {
|
|
60
|
+
targetCompany?: string;
|
|
61
|
+
code?: string;
|
|
62
|
+
}): string;
|
|
63
|
+
/**
|
|
64
|
+
* POST /group-grants — grant a group's access to a target company at a role.
|
|
65
|
+
* Pure-ish: validates inputs, then calls the API client. Throws
|
|
66
|
+
* `GrantHttpError` on non-2xx so the command layer surfaces it.
|
|
67
|
+
*/
|
|
68
|
+
export declare function grantGroup(options: GrantGroupOptions): Promise<GroupGrant>;
|
|
69
|
+
/** POST /group-grants/revoke — remove a group's grant on a target company. */
|
|
70
|
+
export declare function revokeGroupGrant(options: RevokeGroupGrantOptions): Promise<void>;
|
|
71
|
+
/** GET /group-grants/outbound — grants a source company's group(s) hold. */
|
|
72
|
+
export declare function listOutboundGrants(token: string, sourceCompanyUid: string, groupId?: string): Promise<GroupGrant[]>;
|
|
73
|
+
/** GET /group-grants/inbound — grants other companies' groups hold on us. */
|
|
74
|
+
export declare function listInboundGrants(token: string, companyUid: string): Promise<GroupGrant[]>;
|
|
75
|
+
export declare function registerGroupGrantsCommand(program: Command): void;
|
|
76
|
+
//# sourceMappingURL=group-grants.d.ts.map
|
|
@@ -0,0 +1,296 @@
|
|
|
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]="eff1a01a-1996-5c23-aaf4-084319d454fc")}catch(e){}}();
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
|
+
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
6
|
+
import { GROUP_ID_PATTERN } from "./_patterns.js";
|
|
7
|
+
/**
|
|
8
|
+
* `hq group-grants` — manage cross-company group access grants.
|
|
9
|
+
*
|
|
10
|
+
* A *group grant* lets a group defined in a SOURCE company be granted a role
|
|
11
|
+
* on a TARGET company. The hq-pro API authorizes the operation against the
|
|
12
|
+
* TARGET company (the caller must be able to invite into it). The CLI is a
|
|
13
|
+
* thin client — it passes the right params and surfaces the server's 403 as an
|
|
14
|
+
* actionable cross-tenant permission error. NO authorization is reimplemented
|
|
15
|
+
* here.
|
|
16
|
+
*
|
|
17
|
+
* Backend contract (hq-pro feature/agency-cross-company-group-grants):
|
|
18
|
+
* - POST /group-grants { groupId, sourceCompanyUid, targetCompanyUid, role }
|
|
19
|
+
* - POST /group-grants/revoke { groupId, sourceCompanyUid, targetCompanyUid }
|
|
20
|
+
* - GET /group-grants/outbound?sourceCompanyUid&groupId
|
|
21
|
+
* - GET /group-grants/inbound?companyUid
|
|
22
|
+
*
|
|
23
|
+
* All require a Cognito idToken bearer; 403 (code FORBIDDEN) on authz failure.
|
|
24
|
+
*/
|
|
25
|
+
export const VALID_GRANT_ROLES = new Set(["owner", "admin", "member", "guest"]);
|
|
26
|
+
/**
|
|
27
|
+
* Typed HTTP error mirroring members.ts's `InviteHttpError`. Carries the
|
|
28
|
+
* server status + optional error `code` (e.g. `FORBIDDEN`) so the command
|
|
29
|
+
* layer can render an actionable message and exit non-zero.
|
|
30
|
+
*/
|
|
31
|
+
export class GrantHttpError extends Error {
|
|
32
|
+
status;
|
|
33
|
+
code;
|
|
34
|
+
constructor(status, message, code) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.name = "GrantHttpError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Map a `GrantHttpError` onto an actionable, human-readable message. The 403
|
|
43
|
+
* branch is the story's key cross-tenant case: authorization is enforced
|
|
44
|
+
* against the TARGET company, so the message names the target the caller
|
|
45
|
+
* lacks rights on.
|
|
46
|
+
*/
|
|
47
|
+
export function formatGrantHttpError(status, fallback, ctx = {}) {
|
|
48
|
+
if (status === 401)
|
|
49
|
+
return "Not authenticated — please run `hq login`";
|
|
50
|
+
if (status === 403) {
|
|
51
|
+
const target = ctx.targetCompany ? ` '${ctx.targetCompany}'` : "";
|
|
52
|
+
return (`Permission denied: you must be an owner or admin of the target company${target} to grant a group into it. ` +
|
|
53
|
+
"The grant is authorized against the target company — ask an owner/admin there, or have them enable adminCanInvite.");
|
|
54
|
+
}
|
|
55
|
+
if (status === 404) {
|
|
56
|
+
return `Not found: ${fallback}. Check the group id and that both companies exist.`;
|
|
57
|
+
}
|
|
58
|
+
if (status === 409) {
|
|
59
|
+
return `That grant already exists: ${fallback}`;
|
|
60
|
+
}
|
|
61
|
+
if (status >= 500)
|
|
62
|
+
return `Server error: ${fallback}`;
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
function shortDate(iso) {
|
|
66
|
+
return iso.slice(0, 10);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* POST /group-grants — grant a group's access to a target company at a role.
|
|
70
|
+
* Pure-ish: validates inputs, then calls the API client. Throws
|
|
71
|
+
* `GrantHttpError` on non-2xx so the command layer surfaces it.
|
|
72
|
+
*/
|
|
73
|
+
export async function grantGroup(options) {
|
|
74
|
+
if (!GROUP_ID_PATTERN.test(options.groupId)) {
|
|
75
|
+
throw new Error(`Invalid group id '${options.groupId}': must match grp_<alphanumeric, underscore, hyphen>`);
|
|
76
|
+
}
|
|
77
|
+
if (!VALID_GRANT_ROLES.has(options.role)) {
|
|
78
|
+
throw new Error(`Invalid role '${options.role}': must be one of owner, admin, member, guest`);
|
|
79
|
+
}
|
|
80
|
+
if (options.sourceCompanyUid === options.targetCompanyUid) {
|
|
81
|
+
throw new Error("Source and target companies are the same — a group grant must cross company boundaries.");
|
|
82
|
+
}
|
|
83
|
+
const res = await vaultApiFetch({
|
|
84
|
+
token: options.token,
|
|
85
|
+
path: "/group-grants",
|
|
86
|
+
method: "POST",
|
|
87
|
+
body: {
|
|
88
|
+
groupId: options.groupId,
|
|
89
|
+
sourceCompanyUid: options.sourceCompanyUid,
|
|
90
|
+
targetCompanyUid: options.targetCompanyUid,
|
|
91
|
+
role: options.role,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const err = (await res.json().catch(() => ({})));
|
|
96
|
+
throw new GrantHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
97
|
+
}
|
|
98
|
+
const data = (await res.json());
|
|
99
|
+
return (data.grant ?? {
|
|
100
|
+
groupId: options.groupId,
|
|
101
|
+
sourceCompanyUid: options.sourceCompanyUid,
|
|
102
|
+
targetCompanyUid: options.targetCompanyUid,
|
|
103
|
+
role: options.role,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** POST /group-grants/revoke — remove a group's grant on a target company. */
|
|
107
|
+
export async function revokeGroupGrant(options) {
|
|
108
|
+
if (!GROUP_ID_PATTERN.test(options.groupId)) {
|
|
109
|
+
throw new Error(`Invalid group id '${options.groupId}': must match grp_<alphanumeric, underscore, hyphen>`);
|
|
110
|
+
}
|
|
111
|
+
const res = await vaultApiFetch({
|
|
112
|
+
token: options.token,
|
|
113
|
+
path: "/group-grants/revoke",
|
|
114
|
+
method: "POST",
|
|
115
|
+
body: {
|
|
116
|
+
groupId: options.groupId,
|
|
117
|
+
sourceCompanyUid: options.sourceCompanyUid,
|
|
118
|
+
targetCompanyUid: options.targetCompanyUid,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const err = (await res.json().catch(() => ({})));
|
|
123
|
+
throw new GrantHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** GET /group-grants/outbound — grants a source company's group(s) hold. */
|
|
127
|
+
export async function listOutboundGrants(token, sourceCompanyUid, groupId) {
|
|
128
|
+
const query = { sourceCompanyUid };
|
|
129
|
+
if (groupId)
|
|
130
|
+
query.groupId = groupId;
|
|
131
|
+
const res = await vaultApiFetch({
|
|
132
|
+
token,
|
|
133
|
+
path: "/group-grants/outbound",
|
|
134
|
+
query,
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok) {
|
|
137
|
+
const err = (await res.json().catch(() => ({})));
|
|
138
|
+
throw new GrantHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
139
|
+
}
|
|
140
|
+
const data = (await res.json());
|
|
141
|
+
return data.grants ?? [];
|
|
142
|
+
}
|
|
143
|
+
/** GET /group-grants/inbound — grants other companies' groups hold on us. */
|
|
144
|
+
export async function listInboundGrants(token, companyUid) {
|
|
145
|
+
const res = await vaultApiFetch({
|
|
146
|
+
token,
|
|
147
|
+
path: "/group-grants/inbound",
|
|
148
|
+
query: { companyUid },
|
|
149
|
+
});
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
const err = (await res.json().catch(() => ({})));
|
|
152
|
+
throw new GrantHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
153
|
+
}
|
|
154
|
+
const data = (await res.json());
|
|
155
|
+
return data.grants ?? [];
|
|
156
|
+
}
|
|
157
|
+
function printGrantsTable(grants) {
|
|
158
|
+
const idW = Math.max(8, ...grants.map((g) => g.groupId.length));
|
|
159
|
+
const srcW = Math.max(6, ...grants.map((g) => g.sourceCompanyUid.length));
|
|
160
|
+
const tgtW = Math.max(6, ...grants.map((g) => g.targetCompanyUid.length));
|
|
161
|
+
const roleW = Math.max(4, ...grants.map((g) => g.role.length));
|
|
162
|
+
console.log(chalk.bold([
|
|
163
|
+
"GROUP_ID".padEnd(idW),
|
|
164
|
+
"SOURCE".padEnd(srcW),
|
|
165
|
+
"TARGET".padEnd(tgtW),
|
|
166
|
+
"ROLE".padEnd(roleW),
|
|
167
|
+
"GRANTED_AT",
|
|
168
|
+
].join(" ")));
|
|
169
|
+
for (const g of grants) {
|
|
170
|
+
console.log([
|
|
171
|
+
g.groupId.padEnd(idW),
|
|
172
|
+
g.sourceCompanyUid.padEnd(srcW),
|
|
173
|
+
g.targetCompanyUid.padEnd(tgtW),
|
|
174
|
+
g.role.padEnd(roleW),
|
|
175
|
+
g.grantedAt ? shortDate(g.grantedAt) : "",
|
|
176
|
+
].join(" "));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function registerGroupGrantsCommand(program) {
|
|
180
|
+
const grants = program
|
|
181
|
+
.command("group-grants")
|
|
182
|
+
.description("Grant a group's access to another company (cross-company), and revoke or inspect those grants")
|
|
183
|
+
.option("--company <slug>", "Source company slug (the company that owns the group; resolves to sourceCompanyUid)");
|
|
184
|
+
grants
|
|
185
|
+
.command("grant <groupId> <targetCompany>")
|
|
186
|
+
.description("Grant a group (from the source company) access to <targetCompany> at a role")
|
|
187
|
+
.option("--role <role>", "Role the group receives on the target company: owner, admin, member, or guest", "member")
|
|
188
|
+
.action(async (groupId, targetCompany, opts) => {
|
|
189
|
+
try {
|
|
190
|
+
const token = await ensureCognitoToken();
|
|
191
|
+
const sourceSlug = grants.opts().company;
|
|
192
|
+
const sourceCompanyUid = await getCompanyUid(token, sourceSlug);
|
|
193
|
+
const targetCompanyUid = await getCompanyUid(token, targetCompany);
|
|
194
|
+
const grant = await grantGroup({
|
|
195
|
+
groupId,
|
|
196
|
+
sourceCompanyUid,
|
|
197
|
+
targetCompanyUid,
|
|
198
|
+
role: opts.role,
|
|
199
|
+
token,
|
|
200
|
+
});
|
|
201
|
+
console.log(chalk.green(`Granted group '${grant.groupId}' access to '${targetCompany}' as ${grant.role}`));
|
|
202
|
+
console.log(chalk.dim(` source: ${grant.sourceCompanyUid} → target: ${grant.targetCompanyUid}`));
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (err instanceof GrantHttpError) {
|
|
206
|
+
console.error(chalk.red(formatGrantHttpError(err.status, err.message, {
|
|
207
|
+
targetCompany,
|
|
208
|
+
code: err.code,
|
|
209
|
+
})));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
grants
|
|
217
|
+
.command("revoke <groupId> <targetCompany>")
|
|
218
|
+
.description("Revoke a group's grant on <targetCompany>")
|
|
219
|
+
.action(async (groupId, targetCompany) => {
|
|
220
|
+
try {
|
|
221
|
+
const token = await ensureCognitoToken();
|
|
222
|
+
const sourceSlug = grants.opts().company;
|
|
223
|
+
const sourceCompanyUid = await getCompanyUid(token, sourceSlug);
|
|
224
|
+
const targetCompanyUid = await getCompanyUid(token, targetCompany);
|
|
225
|
+
await revokeGroupGrant({
|
|
226
|
+
groupId,
|
|
227
|
+
sourceCompanyUid,
|
|
228
|
+
targetCompanyUid,
|
|
229
|
+
token,
|
|
230
|
+
});
|
|
231
|
+
console.log(chalk.green(`Revoked group '${groupId}' grant on '${targetCompany}'`));
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (err instanceof GrantHttpError) {
|
|
235
|
+
console.error(chalk.red(formatGrantHttpError(err.status, err.message, {
|
|
236
|
+
targetCompany,
|
|
237
|
+
code: err.code,
|
|
238
|
+
})));
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
grants
|
|
246
|
+
.command("outbound")
|
|
247
|
+
.description("List grants the source company's groups hold on other companies")
|
|
248
|
+
.option("--group <groupId>", "Filter to a single group id")
|
|
249
|
+
.action(async (opts) => {
|
|
250
|
+
try {
|
|
251
|
+
const token = await ensureCognitoToken();
|
|
252
|
+
const sourceSlug = grants.opts().company;
|
|
253
|
+
const sourceCompanyUid = await getCompanyUid(token, sourceSlug);
|
|
254
|
+
const list = await listOutboundGrants(token, sourceCompanyUid, opts.group);
|
|
255
|
+
if (list.length === 0) {
|
|
256
|
+
console.log(chalk.gray("No outbound group grants."));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
printGrantsTable(list);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
if (err instanceof GrantHttpError) {
|
|
263
|
+
console.error(chalk.red(formatGrantHttpError(err.status, err.message)));
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
grants
|
|
271
|
+
.command("inbound")
|
|
272
|
+
.description("List grants other companies' groups hold on this company")
|
|
273
|
+
.action(async () => {
|
|
274
|
+
try {
|
|
275
|
+
const token = await ensureCognitoToken();
|
|
276
|
+
const companySlug = grants.opts().company;
|
|
277
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
278
|
+
const list = await listInboundGrants(token, companyUid);
|
|
279
|
+
if (list.length === 0) {
|
|
280
|
+
console.log(chalk.gray("No inbound group grants."));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
printGrantsTable(list);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
if (err instanceof GrantHttpError) {
|
|
287
|
+
console.error(chalk.red(formatGrantHttpError(err.status, err.message)));
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=group-grants.js.map
|
|
296
|
+
//# debugId=eff1a01a-1996-5c23-aaf4-084319d454fc
|
|
@@ -34,12 +34,44 @@
|
|
|
34
34
|
* from each pack's package.yaml; rationale lives in the layout-fix PR.)
|
|
35
35
|
*/
|
|
36
36
|
import type { PackManifest } from '../types.js';
|
|
37
|
+
export type Transport = 'npm' | 'git' | 'local';
|
|
38
|
+
export declare function classify(source: string): Transport;
|
|
39
|
+
/**
|
|
40
|
+
* Parse a git source's '#<...>' fragment into { url, subpath, ref }.
|
|
41
|
+
* Disambiguation: fragment containing '/' is a subpath (optionally with
|
|
42
|
+
* '@<ref>' suffix); fragment without '/' is a ref.
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseGitFragment(source: string): {
|
|
45
|
+
url: string;
|
|
46
|
+
subpath?: string;
|
|
47
|
+
ref?: string;
|
|
48
|
+
};
|
|
37
49
|
/**
|
|
38
50
|
* sourceMatchesPackPattern — exported for the dispatcher in pkg-install.ts
|
|
39
51
|
* so it can decide whether to route to the new content-pack handler or fall
|
|
40
52
|
* back to the legacy registry flow.
|
|
41
53
|
*/
|
|
42
54
|
export declare function sourceMatchesPackPattern(source: string): boolean;
|
|
55
|
+
export interface LatestResult {
|
|
56
|
+
transport: Transport;
|
|
57
|
+
/** Identifier of the currently-installed pack (sha for git, version for npm). */
|
|
58
|
+
current?: string;
|
|
59
|
+
/** Latest available identifier from the remote. */
|
|
60
|
+
latest?: string;
|
|
61
|
+
/** true/false when a comparison was possible; null when undeterminable (local). */
|
|
62
|
+
updateAvailable: boolean | null;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Probe whether a newer version of an already-installed pack is available,
|
|
67
|
+
* WITHOUT fetching or installing. Reuses the same git/npm primitives as the
|
|
68
|
+
* install path. Never throws — network/parse failures return
|
|
69
|
+
* `{ updateAvailable: null, error }` so callers (the menubar) stay resilient.
|
|
70
|
+
*
|
|
71
|
+
* @param source the stamped `source:` from the installed package.yaml
|
|
72
|
+
* @param installedVersion the installed pack's manifest `version` (npm compare)
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveLatest(source: string, installedVersion?: string): LatestResult;
|
|
43
75
|
/**
|
|
44
76
|
* Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
|
|
45
77
|
* v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
|
|
@@ -86,11 +118,24 @@ export declare function stampInstallSource(destDir: string, source: string): voi
|
|
|
86
118
|
* own scan.
|
|
87
119
|
*
|
|
88
120
|
* Exported for tests.
|
|
121
|
+
*
|
|
122
|
+
* `quiet` keeps the script's stdout off our stdout (it routes only stderr
|
|
123
|
+
* through, and sets HQ_SCAN_QUIET=1) so callers emitting machine-readable
|
|
124
|
+
* JSON — e.g. `hq packs uninstall --json` — produce clean output.
|
|
89
125
|
*/
|
|
90
|
-
export declare function runScanPackages(hqRoot: string
|
|
126
|
+
export declare function runScanPackages(hqRoot: string, opts?: {
|
|
127
|
+
quiet?: boolean;
|
|
128
|
+
}): void;
|
|
91
129
|
export interface InstallPackOptions {
|
|
92
130
|
allowHooks?: boolean;
|
|
93
131
|
followBranch?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Route this function's human output to stderr (and silence scan-packages
|
|
134
|
+
* stdout) so a caller emitting machine-readable JSON keeps stdout clean.
|
|
135
|
+
* The fetch tools are already quiet/stderr-only (`npm pack --silent`,
|
|
136
|
+
* `rsync -a`, `git clone` progress -> stderr), so this is sufficient.
|
|
137
|
+
*/
|
|
138
|
+
quiet?: boolean;
|
|
94
139
|
}
|
|
95
140
|
export declare function installPack(source: string, opts?: InstallPackOptions): Promise<void>;
|
|
96
141
|
//# sourceMappingURL=pack-install.d.ts.map
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* from each pack's package.yaml; rationale lives in the layout-fix PR.)
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
!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]="
|
|
37
|
+
!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]="08d95744-5e4f-5938-8a49-4f57a9bf042f")}catch(e){}}();
|
|
38
38
|
import * as fs from 'fs';
|
|
39
39
|
import * as os from 'os';
|
|
40
40
|
import * as path from 'path';
|
|
@@ -45,8 +45,9 @@ import chalk from 'chalk';
|
|
|
45
45
|
import semverSatisfies from 'semver/functions/satisfies.js';
|
|
46
46
|
import semverValid from 'semver/functions/valid.js';
|
|
47
47
|
import semverValidRange from 'semver/ranges/valid.js';
|
|
48
|
+
import semverGt from 'semver/functions/gt.js';
|
|
48
49
|
import { findHqRoot } from '../utils/manifest.js';
|
|
49
|
-
function classify(source) {
|
|
50
|
+
export function classify(source) {
|
|
50
51
|
if (source.startsWith('@'))
|
|
51
52
|
return 'npm';
|
|
52
53
|
if (source.startsWith('http://') ||
|
|
@@ -93,7 +94,7 @@ function expandGithubShorthand(url) {
|
|
|
93
94
|
* Disambiguation: fragment containing '/' is a subpath (optionally with
|
|
94
95
|
* '@<ref>' suffix); fragment without '/' is a ref.
|
|
95
96
|
*/
|
|
96
|
-
function parseGitFragment(source) {
|
|
97
|
+
export function parseGitFragment(source) {
|
|
97
98
|
const hashAt = source.indexOf('#');
|
|
98
99
|
if (hashAt < 0)
|
|
99
100
|
return { url: source };
|
|
@@ -273,6 +274,75 @@ function isNamedRef(url, ref) {
|
|
|
273
274
|
return false;
|
|
274
275
|
}
|
|
275
276
|
}
|
|
277
|
+
/** Extract the ref (sha or named ref) recorded in a stamped git source. */
|
|
278
|
+
function gitRefFromSource(source) {
|
|
279
|
+
const { subpath, ref } = parseGitFragment(source);
|
|
280
|
+
// For 'url#subpath@ref' parseGitFragment returns ref; for 'url#ref' likewise.
|
|
281
|
+
// A bare 'url#subpath' (no @ref) has no ref.
|
|
282
|
+
void subpath;
|
|
283
|
+
return ref;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Probe whether a newer version of an already-installed pack is available,
|
|
287
|
+
* WITHOUT fetching or installing. Reuses the same git/npm primitives as the
|
|
288
|
+
* install path. Never throws — network/parse failures return
|
|
289
|
+
* `{ updateAvailable: null, error }` so callers (the menubar) stay resilient.
|
|
290
|
+
*
|
|
291
|
+
* @param source the stamped `source:` from the installed package.yaml
|
|
292
|
+
* @param installedVersion the installed pack's manifest `version` (npm compare)
|
|
293
|
+
*/
|
|
294
|
+
export function resolveLatest(source, installedVersion) {
|
|
295
|
+
let transport;
|
|
296
|
+
try {
|
|
297
|
+
transport = classify(source);
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
return { transport: 'local', updateAvailable: null, error: e.message };
|
|
301
|
+
}
|
|
302
|
+
if (transport === 'local') {
|
|
303
|
+
return { transport, updateAvailable: null, error: 'local source — re-run to re-sync' };
|
|
304
|
+
}
|
|
305
|
+
if (transport === 'npm') {
|
|
306
|
+
const pkg = stripVersion(source);
|
|
307
|
+
const current = installedVersion ?? (source.lastIndexOf('@') > 0 ? source.slice(source.lastIndexOf('@') + 1) : undefined);
|
|
308
|
+
try {
|
|
309
|
+
const latest = execFileSync('npm', ['view', pkg, 'version'], {
|
|
310
|
+
encoding: 'utf-8',
|
|
311
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
312
|
+
}).trim();
|
|
313
|
+
const updateAvailable = current && latest ? semverGt(latest, current) : null;
|
|
314
|
+
return { transport, current, latest, updateAvailable };
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
return { transport, current, updateAvailable: null, error: `npm view failed: ${e.message}` };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// git
|
|
321
|
+
const parsed = parseGitFragment(source);
|
|
322
|
+
let url;
|
|
323
|
+
try {
|
|
324
|
+
url = expandGithubShorthand(parsed.url);
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
return { transport, updateAvailable: null, error: e.message };
|
|
328
|
+
}
|
|
329
|
+
const current = gitRefFromSource(source);
|
|
330
|
+
// If install followed a named ref (branch/tag), compare that ref's tip;
|
|
331
|
+
// otherwise (default SHA-pin) compare the default branch HEAD.
|
|
332
|
+
const refArg = current && isNamedRef(url, current) ? current : 'HEAD';
|
|
333
|
+
try {
|
|
334
|
+
const out = execFileSync('git', ['ls-remote', url, refArg], {
|
|
335
|
+
encoding: 'utf-8',
|
|
336
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
337
|
+
}).trim();
|
|
338
|
+
const latest = out.split(/\s+/)[0] || undefined;
|
|
339
|
+
const updateAvailable = current && latest ? !latest.startsWith(current) && !current.startsWith(latest) : null;
|
|
340
|
+
return { transport, current, latest, updateAvailable };
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
return { transport, current, updateAvailable: null, error: `git ls-remote failed: ${e.message}` };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
276
346
|
// ---------------------------------------------------------------------------
|
|
277
347
|
// Manifest validation (spec §Validation, 10 checks)
|
|
278
348
|
// ---------------------------------------------------------------------------
|
|
@@ -509,20 +579,26 @@ export function stampInstallSource(destDir, source) {
|
|
|
509
579
|
* own scan.
|
|
510
580
|
*
|
|
511
581
|
* Exported for tests.
|
|
582
|
+
*
|
|
583
|
+
* `quiet` keeps the script's stdout off our stdout (it routes only stderr
|
|
584
|
+
* through, and sets HQ_SCAN_QUIET=1) so callers emitting machine-readable
|
|
585
|
+
* JSON — e.g. `hq packs uninstall --json` — produce clean output.
|
|
512
586
|
*/
|
|
513
|
-
export function runScanPackages(hqRoot) {
|
|
587
|
+
export function runScanPackages(hqRoot, opts = {}) {
|
|
514
588
|
const script = path.join(hqRoot, 'core', 'scripts', 'scan-packages.sh');
|
|
515
589
|
if (!fs.existsSync(script)) {
|
|
516
|
-
|
|
517
|
-
`
|
|
590
|
+
if (!opts.quiet) {
|
|
591
|
+
console.log(chalk.dim(` (core/scripts/scan-packages.sh not present — skipping auto-wire; ` +
|
|
592
|
+
`will run on next session start)`));
|
|
593
|
+
}
|
|
518
594
|
return;
|
|
519
595
|
}
|
|
520
596
|
const r = spawnSync('bash', [script], {
|
|
521
597
|
cwd: hqRoot,
|
|
522
|
-
env: { ...process.env, HQ_ROOT: hqRoot },
|
|
523
|
-
stdio: 'inherit',
|
|
598
|
+
env: { ...process.env, HQ_ROOT: hqRoot, ...(opts.quiet ? { HQ_SCAN_QUIET: '1' } : {}) },
|
|
599
|
+
stdio: opts.quiet ? ['ignore', 'ignore', 'inherit'] : 'inherit',
|
|
524
600
|
});
|
|
525
|
-
if (r.status !== 0) {
|
|
601
|
+
if (r.status !== 0 && !opts.quiet) {
|
|
526
602
|
console.log(chalk.yellow(' scan-packages.sh exited non-zero; see output above.'));
|
|
527
603
|
}
|
|
528
604
|
}
|
|
@@ -530,7 +606,10 @@ export async function installPack(source, opts = {}) {
|
|
|
530
606
|
const transport = classify(source);
|
|
531
607
|
const hqRoot = findHqRoot();
|
|
532
608
|
const hqVersion = readHqVersion(hqRoot);
|
|
533
|
-
|
|
609
|
+
const say = opts.quiet
|
|
610
|
+
? (...a) => console.error(...a)
|
|
611
|
+
: (...a) => console.log(...a);
|
|
612
|
+
say(chalk.dim(`-> transport: ${transport}; source: ${source}`));
|
|
534
613
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-pack-'));
|
|
535
614
|
try {
|
|
536
615
|
let fetched;
|
|
@@ -549,18 +628,18 @@ export async function installPack(source, opts = {}) {
|
|
|
549
628
|
if (pkg.conditional) {
|
|
550
629
|
const allowed = await confirmConditional(pkg, opts.allowHooks ?? false);
|
|
551
630
|
if (!allowed) {
|
|
552
|
-
|
|
631
|
+
say(chalk.red('Install aborted (conditional predicate not approved).'));
|
|
553
632
|
return;
|
|
554
633
|
}
|
|
555
634
|
const ok = evalConditional(pkg.conditional);
|
|
556
635
|
if (!ok) {
|
|
557
|
-
|
|
636
|
+
say(chalk.yellow(`Skipping ${pkg.name}: conditional "${pkg.conditional}" returned non-zero.`));
|
|
558
637
|
return;
|
|
559
638
|
}
|
|
560
639
|
}
|
|
561
640
|
const confirmed = await confirmHooks(pkg, opts.allowHooks ?? false);
|
|
562
641
|
if (!confirmed) {
|
|
563
|
-
|
|
642
|
+
say(chalk.red('Install aborted (hooks denied).'));
|
|
564
643
|
return;
|
|
565
644
|
}
|
|
566
645
|
const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
|
|
@@ -573,9 +652,9 @@ export async function installPack(source, opts = {}) {
|
|
|
573
652
|
// re-runs. Stamping the literal input (not the resolved SHA/version)
|
|
574
653
|
// matches the verbatim equality check in setup.sh.
|
|
575
654
|
stampInstallSource(destDir, source);
|
|
576
|
-
runScanPackages(hqRoot);
|
|
577
|
-
|
|
578
|
-
|
|
655
|
+
runScanPackages(hqRoot, { quiet: opts.quiet });
|
|
656
|
+
say(chalk.green(`\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`));
|
|
657
|
+
say(chalk.dim(` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
|
|
579
658
|
`contribution(s) into host-side paths.`));
|
|
580
659
|
}
|
|
581
660
|
finally {
|
|
@@ -583,4 +662,4 @@ export async function installPack(source, opts = {}) {
|
|
|
583
662
|
}
|
|
584
663
|
}
|
|
585
664
|
//# sourceMappingURL=pack-install.js.map
|
|
586
|
-
//# debugId=
|
|
665
|
+
//# debugId=08d95744-5e4f-5938-8a49-4f57a9bf042f
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq packs` -- lifecycle for CONTENT packs (the ones installed via
|
|
3
|
+
* `hq install <source>` that live in `core/packages/hq-pack-<name>/`).
|
|
4
|
+
*
|
|
5
|
+
* hq packs list Installed packs + curated catalog, with link health.
|
|
6
|
+
* hq packs update Re-install the latest of an installed pack.
|
|
7
|
+
* hq packs uninstall Un-wire + archive a pack (clean, no dangling symlinks).
|
|
8
|
+
*
|
|
9
|
+
* Distinct from `hq packages ...` (the entitlement-gated REGISTRY system tracked
|
|
10
|
+
* in packages/registry.yaml). Content packs have no registry file -- the
|
|
11
|
+
* filesystem under core/packages/ is the source of truth. Today `hq install`
|
|
12
|
+
* is the only clean content-pack op; this adds the rest.
|
|
13
|
+
*
|
|
14
|
+
* Every subcommand supports `--json` for machine consumers (the HQ Sync
|
|
15
|
+
* menubar app). JSON is also the default when stdout is not a TTY, matching
|
|
16
|
+
* the `hq signals` / `hq sources` convention.
|
|
17
|
+
*
|
|
18
|
+
* Spec: knowledge/public/hq-core/package-yaml-spec.md.
|
|
19
|
+
*/
|
|
20
|
+
import { Command } from 'commander';
|
|
21
|
+
export declare function registerPacksCommand(parent: Command): void;
|
|
22
|
+
//# sourceMappingURL=packs.d.ts.map
|