failproofai 1.0.2-beta.7 → 1.0.2-beta.8
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +5 -4
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__1lt47k4._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +3 -3
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1jjjg6g._.js → [root-of-the-server]__1-wzs7f._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07gm3zl._.js → [root-of-the-server]__1n2ywfz._.js} +5 -19
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1opxw-7._.js → [root-of-the-server]__1wlvpe5._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0_nsohj._.js → _0l7zv8b._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_1uvml5y._.js → _1h33neq._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +17 -0
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware.js +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
- package/.next/standalone/.next/static/chunks/{3swqstog1cd9u.js → 0i3a6wf-f_yky.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vqddiy9tvlwf.js → 0u2ojcw25c_np.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0icodhlwr9x_e.js → 11a_m8yksthtf.js} +2 -2
- package/.next/standalone/.next/static/chunks/{2-9lvhuto1iwo.js → 1ch5l9t2p16y4.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3jt6n16p2qpex.js → 1n04wzsaes73g.js} +1 -1
- package/.next/standalone/.next/static/chunks/{44oamw33aso19.js → 2iryw37qfqy5y.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1d7o3wnt3h6se.js → 34j7nmidlcuni.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1d6c85605lsgp.js → 3927pu4ss_l4k.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0tvmd0p_1i_g6.js → 3r0udrtpn78-e.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1mfzsja6ftim5.css → 40nr6ehj4p3cw.css} +1 -1
- package/.next/standalone/.next/static/chunks/{1vzyn9h2kvj-i.js → 416zumre3fvfw.js} +1 -1
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +47 -12
- package/dist/cli.mjs +6070 -4131
- package/dist/worker.mjs +6 -3
- package/package.json +9 -9
- package/scripts/build-policy-pack.mjs +28 -4
- package/src/hooks/pack-cli.ts +827 -97
- package/src/hooks/pack-manifest.ts +34 -1
- package/src/hooks/pack-store.ts +208 -16
- package/src/hooks/tui.ts +21 -1
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_ssgManifest.js +0 -0
|
@@ -54,6 +54,24 @@ const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
|
54
54
|
export const PACK_ID_RE = /^[A-Za-z0-9._-]{1,64}\/[A-Za-z0-9._-]{1,64}$/;
|
|
55
55
|
/** A version string, kept loose enough for semver and a tag. */
|
|
56
56
|
export const PACK_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
|
|
57
|
+
/**
|
|
58
|
+
* The git commit a pack was built from — PROVENANCE, and deliberately not the
|
|
59
|
+
* same thing as `sha256`.
|
|
60
|
+
*
|
|
61
|
+
* `sha256` is the digest of the entry artifact: the security pin, re-verified
|
|
62
|
+
* before every import, and the reason a pack cannot change under a machine
|
|
63
|
+
* after it was installed. This answers a different question — WHICH SOURCE
|
|
64
|
+
* produced that artifact — which no digest can, because the digest is of the
|
|
65
|
+
* bundled output and the output names no input.
|
|
66
|
+
*
|
|
67
|
+
* Always optional. A pack published from a directory that is not a git checkout
|
|
68
|
+
* has no commit, and that is a supported way to publish, so every surface that
|
|
69
|
+
* reads this must render its absence rather than treat it as a defect.
|
|
70
|
+
*
|
|
71
|
+
* Abbreviated forms are accepted from 7 characters, which is what `git log`
|
|
72
|
+
* prints and therefore what somebody copying one will have.
|
|
73
|
+
*/
|
|
74
|
+
export const PACK_COMMIT_RE = /^[0-9a-f]{7,40}$/;
|
|
57
75
|
/**
|
|
58
76
|
* A pack policy's own name. `/` is REFUSED, and that is the important character:
|
|
59
77
|
* `normalizePolicyName` passes any name containing one straight through, and
|
|
@@ -72,6 +90,8 @@ export interface InstalledPackRecord {
|
|
|
72
90
|
/** Path to the single entry artifact, relative to `packsDir()`. */
|
|
73
91
|
entry: string;
|
|
74
92
|
sha256: string;
|
|
93
|
+
/** The git commit this pack was built from, when its publisher had one. */
|
|
94
|
+
commit?: string;
|
|
75
95
|
effect?: string;
|
|
76
96
|
policies?: unknown;
|
|
77
97
|
/**
|
|
@@ -106,6 +126,8 @@ export interface ResolvedPack {
|
|
|
106
126
|
/** Absolute, real, verified-to-be-inside-the-root path to the entry module. */
|
|
107
127
|
path: string;
|
|
108
128
|
sha256: string;
|
|
129
|
+
/** The git commit this pack was built from, when its publisher had one. */
|
|
130
|
+
commit?: string;
|
|
109
131
|
effect: PolicyEffect;
|
|
110
132
|
/** The pack's own catalog, in declared order. */
|
|
111
133
|
policies: PolicyCatalogEntry[];
|
|
@@ -152,7 +174,8 @@ export function parsePackIdentity(value: {
|
|
|
152
174
|
id?: unknown;
|
|
153
175
|
version?: unknown;
|
|
154
176
|
effect?: unknown;
|
|
155
|
-
|
|
177
|
+
commit?: unknown;
|
|
178
|
+
}): { id: string; version: string; effect: PolicyEffect; commit?: string } {
|
|
156
179
|
if (typeof value.id !== "string" || !PACK_ID_RE.test(value.id)) {
|
|
157
180
|
throw new Error(`unsafe pack id ${JSON.stringify(value.id)}`);
|
|
158
181
|
}
|
|
@@ -162,10 +185,20 @@ export function parsePackIdentity(value: {
|
|
|
162
185
|
if (value.effect !== undefined && value.effect !== "enforce" && value.effect !== "observe") {
|
|
163
186
|
throw new Error(`unknown effect ${JSON.stringify(value.effect)} for pack ${value.id}`);
|
|
164
187
|
}
|
|
188
|
+
// Malformed provenance is DROPPED, never fatal. It is a label on the artifact,
|
|
189
|
+
// not part of what makes it safe to run — so a publisher who wrote something
|
|
190
|
+
// odd here gets a pack that installs and simply says nothing about its
|
|
191
|
+
// origin, rather than a pack nobody can install. The digest still decides
|
|
192
|
+
// whether the bytes are the ones that were published.
|
|
193
|
+
const commit =
|
|
194
|
+
typeof value.commit === "string" && PACK_COMMIT_RE.test(value.commit.trim().toLowerCase())
|
|
195
|
+
? value.commit.trim().toLowerCase()
|
|
196
|
+
: undefined;
|
|
165
197
|
return {
|
|
166
198
|
id: value.id,
|
|
167
199
|
version: value.version,
|
|
168
200
|
effect: (value.effect as PolicyEffect | undefined) ?? "enforce",
|
|
201
|
+
...(commit ? { commit } : {}),
|
|
169
202
|
};
|
|
170
203
|
}
|
|
171
204
|
|
package/src/hooks/pack-store.ts
CHANGED
|
@@ -17,11 +17,16 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Asset URLs are CONSTRUCTED from an owner, repo and concrete tag. When the
|
|
19
19
|
* user omits a tag, one `releases/latest` redirect resolves it before any asset
|
|
20
|
-
* is fetched or written. There is no
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* is fetched or written. There is no index to poison, and the installed record
|
|
21
|
+
* always names the pinned result — which has to AGREE with the version that
|
|
22
|
+
* pack's manifest declares, or nothing is installed at all
|
|
23
23
|
* (`packTagMatchesVersion`).
|
|
24
24
|
*
|
|
25
|
+
* `resolveTagForCommit` is the ONE exception, and it is opt-in: typing
|
|
26
|
+
* `owner/repo@a1b2c3d` asks a question no URL can answer, so that path — and no
|
|
27
|
+
* other — reads the releases API. It chooses WHICH release; every verification
|
|
28
|
+
* below runs on the result exactly as it would on a typed tag.
|
|
29
|
+
*
|
|
25
30
|
* ## Never on the hook path
|
|
26
31
|
*
|
|
27
32
|
* Everything here touches the network. A hook must never wait on it. This module
|
|
@@ -30,7 +35,7 @@
|
|
|
30
35
|
import { createHash } from "node:crypto";
|
|
31
36
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
32
37
|
import { dirname, resolve } from "node:path";
|
|
33
|
-
import { packsRoot, parsePackIdentity, parsePackPolicy, readInstalledPacks } from "./pack-manifest";
|
|
38
|
+
import { PACK_COMMIT_RE, packsRoot, parsePackIdentity, parsePackPolicy, readInstalledPacks } from "./pack-manifest";
|
|
34
39
|
import type { ResolvedPack } from "./pack-manifest";
|
|
35
40
|
import type { InstalledPackRecord } from "./pack-manifest";
|
|
36
41
|
import type { PolicyCatalogEntry } from "./policy-types";
|
|
@@ -210,6 +215,132 @@ export async function resolveLatestTag(spec: PackSpec): Promise<string> {
|
|
|
210
215
|
return tag;
|
|
211
216
|
}
|
|
212
217
|
|
|
218
|
+
/**
|
|
219
|
+
* The GitHub API, for the ONE lookup on this whole path that needs one.
|
|
220
|
+
*
|
|
221
|
+
* Read per call rather than captured at import, the way `baseUrl()` above is:
|
|
222
|
+
* the value has to be settable by a test that points it at a local server, and
|
|
223
|
+
* a module-level const would freeze whatever the environment happened to hold
|
|
224
|
+
* when this file was first imported.
|
|
225
|
+
*/
|
|
226
|
+
function githubApiBase(): string {
|
|
227
|
+
return (process.env.FAILPROOFAI_GITHUB_API ?? "https://api.github.com").replace(/\/+$/, "");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The release tag published from a given git commit, or null when no release
|
|
232
|
+
* claims it.
|
|
233
|
+
*
|
|
234
|
+
* ## Why this is the only API call in the install path
|
|
235
|
+
*
|
|
236
|
+
* Everything else here CONSTRUCTS its URLs from owner, repo and tag and
|
|
237
|
+
* discovers nothing — that is what leaves no index to poison, and it must stay
|
|
238
|
+
* true for everybody who did not opt in. A commit is the one spelling that
|
|
239
|
+
* cannot be turned into a URL, because the mapping from commit to tag exists
|
|
240
|
+
* only on the server. So this runs when, and only when, the user typed a hex
|
|
241
|
+
* string after the `@` themselves.
|
|
242
|
+
*
|
|
243
|
+
* ## What the answer is allowed to decide
|
|
244
|
+
*
|
|
245
|
+
* WHICH release, and nothing else. The release body is publisher-controlled
|
|
246
|
+
* text on somebody else's repository and can say anything, so it selects a tag
|
|
247
|
+
* and then every downstream check — SHA256SUMS, the digest pin recorded in
|
|
248
|
+
* `installed.json`, `packTagMatchesVersion` — runs exactly as it would for a
|
|
249
|
+
* typed tag. A body that lies picks the wrong release; it cannot make an
|
|
250
|
+
* unverified artifact install.
|
|
251
|
+
*
|
|
252
|
+
* ## Ambiguity is a refusal, not a guess
|
|
253
|
+
*
|
|
254
|
+
* A 7-character prefix colliding across two releases is precisely why git
|
|
255
|
+
* itself refuses to resolve one, and picking either would install code the user
|
|
256
|
+
* did not ask for while reporting success. The matching tags and their full
|
|
257
|
+
* commits go in the message so the next command can be typed from it.
|
|
258
|
+
*/
|
|
259
|
+
export async function resolveTagForCommit(spec: PackSpec, commit: string): Promise<string | null> {
|
|
260
|
+
const prefix = commit.toLowerCase();
|
|
261
|
+
const url = `${githubApiBase()}/repos/${spec.owner}/${spec.repo}/releases?per_page=100`;
|
|
262
|
+
let payload: unknown;
|
|
263
|
+
try {
|
|
264
|
+
// No credential is required — a pack has to be public to install at all,
|
|
265
|
+
// since installs are anonymous HTTPS. A token is taken when one happens to
|
|
266
|
+
// be in the environment purely for the rate limit (60/hour → 5000).
|
|
267
|
+
const token = (process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim();
|
|
268
|
+
const response = await fetch(url, {
|
|
269
|
+
headers: {
|
|
270
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
271
|
+
Accept: "application/vnd.github+json",
|
|
272
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
273
|
+
"User-Agent": "failproofai",
|
|
274
|
+
},
|
|
275
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
276
|
+
redirect: "follow",
|
|
277
|
+
});
|
|
278
|
+
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
|
|
279
|
+
payload = await response.json();
|
|
280
|
+
} catch (err) {
|
|
281
|
+
// Deliberately NOT a fall-through to "treat it as a literal tag". A lookup
|
|
282
|
+
// that could not run has established nothing — the fall-through would then
|
|
283
|
+
// 404 on an asset URL built from a commit, and report a missing SHA256SUMS
|
|
284
|
+
// for a release nobody ever claimed existed. Name the thing that failed.
|
|
285
|
+
throw new Error(
|
|
286
|
+
`could not resolve commit ${commit} in ${spec.owner}/${spec.repo}: ${errText(err)}. ` +
|
|
287
|
+
`Name the release tag instead if you know it.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Imported here rather than at the top of the file: `pack-cli` imports this
|
|
292
|
+
// module, so a static import would close a cycle for the sake of one format
|
|
293
|
+
// reader. The reader has to be the SAME one `publish` writes with, or the two
|
|
294
|
+
// drift silently and a listing and an install disagree about the same body.
|
|
295
|
+
const { parseReleaseBody } = await import("./pack-cli");
|
|
296
|
+
const releases = Array.isArray(payload)
|
|
297
|
+
? (payload as Array<{ tag_name?: unknown; body?: unknown }>)
|
|
298
|
+
: [];
|
|
299
|
+
const matches: Array<{ tag: string; commit: string }> = [];
|
|
300
|
+
for (const release of releases) {
|
|
301
|
+
const tag = typeof release.tag_name === "string" ? release.tag_name : "";
|
|
302
|
+
// A tag from the API becomes part of an asset URL, so it passes the same
|
|
303
|
+
// check a typed one does rather than being trusted for having come back
|
|
304
|
+
// over HTTPS.
|
|
305
|
+
if (!tag || !TAG_RE.test(tag)) continue;
|
|
306
|
+
const facts = parseReleaseBody(typeof release.body === "string" ? release.body : "");
|
|
307
|
+
// The typed string is a PREFIX of the recorded commit, which is the
|
|
308
|
+
// direction git works in and therefore the one a person copying out of
|
|
309
|
+
// `git log` will have.
|
|
310
|
+
if (facts.commit && facts.commit.startsWith(prefix)) matches.push({ tag, commit: facts.commit });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (matches.length > 1) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`commit ${commit} matches ${matches.length} releases of ${spec.owner}/${spec.repo}: ` +
|
|
316
|
+
matches.map((m) => `${m.tag} (${m.commit})`).join(", ") +
|
|
317
|
+
`. Name one of those tags, or type more of the commit.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return matches.length === 1 ? matches[0].tag : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The concrete tag to install, from whatever the user typed after the `@`.
|
|
325
|
+
*
|
|
326
|
+
* Three shapes, in the order they are tried:
|
|
327
|
+
*
|
|
328
|
+
* - nothing — resolved from `releases/latest`, as it always was;
|
|
329
|
+
* - a hex string — looked up as a COMMIT first, because typing one is how a
|
|
330
|
+
* person asks for that; and
|
|
331
|
+
* - anything else — taken literally.
|
|
332
|
+
*
|
|
333
|
+
* A hex string that matches no release falls back to being a literal tag. A
|
|
334
|
+
* repository may genuinely have a tag named `abc1234`, and refusing to install
|
|
335
|
+
* a tag that exists because it also looks like a commit would be a regression
|
|
336
|
+
* for somebody who never asked for commit resolution at all.
|
|
337
|
+
*/
|
|
338
|
+
export async function resolveSpecTag(parsed: PackSpec): Promise<string> {
|
|
339
|
+
if (parsed.tag === null) return resolveLatestTag(parsed);
|
|
340
|
+
if (!PACK_COMMIT_RE.test(parsed.tag)) return parsed.tag;
|
|
341
|
+
return (await resolveTagForCommit(parsed, parsed.tag)) ?? parsed.tag;
|
|
342
|
+
}
|
|
343
|
+
|
|
213
344
|
/**
|
|
214
345
|
* The canonical spelling recorded in `installed.json`.
|
|
215
346
|
*
|
|
@@ -324,6 +455,8 @@ interface FetchedPack {
|
|
|
324
455
|
version: string;
|
|
325
456
|
policies: PolicyCatalogEntry[];
|
|
326
457
|
effect?: PolicyEffect;
|
|
458
|
+
/** The git commit the publisher built this from, when they had one. */
|
|
459
|
+
commit?: string;
|
|
327
460
|
artifact: Buffer;
|
|
328
461
|
artifactDigest: string;
|
|
329
462
|
}
|
|
@@ -350,6 +483,18 @@ export interface PackAddOptions {
|
|
|
350
483
|
only?: string[];
|
|
351
484
|
categories?: string[];
|
|
352
485
|
all?: boolean;
|
|
486
|
+
/**
|
|
487
|
+
* Add to what is already on, rather than replacing it.
|
|
488
|
+
*
|
|
489
|
+
* Set by the CLI FLAGS (`--policy`, `--category`) and deliberately NOT by the
|
|
490
|
+
* interactive picker: the picker's list is the complete answer — unticking
|
|
491
|
+
* everything means enable none — while a flag on an already-installed pack
|
|
492
|
+
* means "also turn these on". Without the distinction, following the pack
|
|
493
|
+
* README's own path (take the defaults, then add a category) silently
|
|
494
|
+
* switched the defaults off. No effect on a first install, where there is
|
|
495
|
+
* nothing to merge with.
|
|
496
|
+
*/
|
|
497
|
+
merge?: boolean;
|
|
353
498
|
/** Agent CLIs this pack should guard. Omitted means all of them. */
|
|
354
499
|
clis?: string[];
|
|
355
500
|
}
|
|
@@ -395,6 +540,8 @@ export interface PackPreview {
|
|
|
395
540
|
id: string;
|
|
396
541
|
version: string;
|
|
397
542
|
effect: PolicyEffect;
|
|
543
|
+
/** The git commit the publisher built this from, when they had one. */
|
|
544
|
+
commit?: string;
|
|
398
545
|
policies: PolicyCatalogEntry[];
|
|
399
546
|
/** The exact source the preview was read from, tag resolved and pinned. */
|
|
400
547
|
source: string;
|
|
@@ -421,10 +568,7 @@ export async function fetchPackPreview(source: string): Promise<PackPreview> {
|
|
|
421
568
|
}
|
|
422
569
|
const parsed = parsePackSpec(source);
|
|
423
570
|
const resolvedFromLatest = parsed.tag === null;
|
|
424
|
-
const spec: PinnedPackSpec =
|
|
425
|
-
parsed.tag !== null
|
|
426
|
-
? { ...parsed, tag: parsed.tag }
|
|
427
|
-
: { ...parsed, tag: await resolveLatestTag(parsed) };
|
|
571
|
+
const spec: PinnedPackSpec = { ...parsed, tag: await resolveSpecTag(parsed) };
|
|
428
572
|
|
|
429
573
|
const checksums = (await fetchBytes(packAssetUrl(spec, PACK_CHECKSUMS_ASSET))).toString("utf8");
|
|
430
574
|
const manifestBytes = await fetchBytes(packAssetUrl(spec, PACK_MANIFEST_ASSET));
|
|
@@ -443,7 +587,9 @@ export async function fetchPackPreview(source: string): Promise<PackPreview> {
|
|
|
443
587
|
throw new Error(`${PACK_MANIFEST_ASSET} is not valid JSON: ${errText(err)}`);
|
|
444
588
|
}
|
|
445
589
|
if (!raw || typeof raw !== "object") throw new Error(`${PACK_MANIFEST_ASSET} is not an object`);
|
|
446
|
-
const value = raw as {
|
|
590
|
+
const value = raw as {
|
|
591
|
+
id?: unknown; version?: unknown; policies?: unknown; effect?: unknown; commit?: unknown;
|
|
592
|
+
};
|
|
447
593
|
const identity = parsePackIdentity(value);
|
|
448
594
|
if (!Array.isArray(value.policies) || value.policies.length === 0) {
|
|
449
595
|
throw new Error("pack manifest declares no policies");
|
|
@@ -452,6 +598,7 @@ export async function fetchPackPreview(source: string): Promise<PackPreview> {
|
|
|
452
598
|
id: identity.id,
|
|
453
599
|
version: identity.version,
|
|
454
600
|
effect: identity.effect,
|
|
601
|
+
...(identity.commit ? { commit: identity.commit } : {}),
|
|
455
602
|
policies: value.policies.map((policy, i) => parsePackPolicy(identity.id, policy, i)),
|
|
456
603
|
source: formatPackSpec(spec),
|
|
457
604
|
resolvedFromLatest,
|
|
@@ -498,7 +645,9 @@ async function fetchPack(spec: PinnedPackSpec): Promise<FetchedPack> {
|
|
|
498
645
|
throw new Error(`${PACK_MANIFEST_ASSET} is not valid JSON: ${errText(err)}`);
|
|
499
646
|
}
|
|
500
647
|
if (!parsed || typeof parsed !== "object") throw new Error(`${PACK_MANIFEST_ASSET} is not an object`);
|
|
501
|
-
const raw = parsed as {
|
|
648
|
+
const raw = parsed as {
|
|
649
|
+
id?: unknown; version?: unknown; policies?: unknown; effect?: unknown; commit?: unknown;
|
|
650
|
+
};
|
|
502
651
|
const identity = parsePackIdentity(raw);
|
|
503
652
|
if (!Array.isArray(raw.policies) || raw.policies.length === 0) {
|
|
504
653
|
throw new Error("pack manifest declares no policies");
|
|
@@ -513,6 +662,7 @@ async function fetchPack(spec: PinnedPackSpec): Promise<FetchedPack> {
|
|
|
513
662
|
version: identity.version,
|
|
514
663
|
policies,
|
|
515
664
|
...(raw.effect !== undefined ? { effect: identity.effect } : {}),
|
|
665
|
+
...(identity.commit ? { commit: identity.commit } : {}),
|
|
516
666
|
artifact,
|
|
517
667
|
artifactDigest,
|
|
518
668
|
};
|
|
@@ -542,7 +692,9 @@ export function slugifyCategory(category: string): string {
|
|
|
542
692
|
*/
|
|
543
693
|
function resolveSelection(
|
|
544
694
|
policies: PolicyCatalogEntry[],
|
|
545
|
-
opts:
|
|
695
|
+
opts:
|
|
696
|
+
| { only?: string[]; categories?: string[]; all?: boolean; merge?: boolean }
|
|
697
|
+
| undefined,
|
|
546
698
|
previous: string[] | null | undefined,
|
|
547
699
|
previouslyInstalled: boolean,
|
|
548
700
|
): { enabled: string[] | null; reason: SelectionReason } {
|
|
@@ -578,8 +730,29 @@ function resolveSelection(
|
|
|
578
730
|
// opposite of what was chosen, reported as "the pack's defaults". Presence of
|
|
579
731
|
// the key is the signal, not its length.
|
|
580
732
|
if (opts?.only !== undefined || opts?.categories?.length) {
|
|
733
|
+
// `merge` separates the two callers that both arrive here with a list.
|
|
734
|
+
//
|
|
735
|
+
// The PICKER's list is the complete answer: what is ticked is what should
|
|
736
|
+
// be on, and unticking everything means enable none — which is why an
|
|
737
|
+
// expressed selection replaces at all, and must keep replacing.
|
|
738
|
+
//
|
|
739
|
+
// A FLAG is an addition. `policies add <pack> --category Git` on a pack
|
|
740
|
+
// that is already installed means "also turn Git on", and replacing there
|
|
741
|
+
// switched off everything the user had. Following the pack README's own
|
|
742
|
+
// path — take the defaults, then add a category — left them with SIX
|
|
743
|
+
// policies on where they had started with ten, silently, from a command
|
|
744
|
+
// whose first word is `add`.
|
|
745
|
+
//
|
|
581
746
|
// Kept in the pack's declared order, which is the order everything else
|
|
582
747
|
// presents them in.
|
|
748
|
+
if (opts.merge && previouslyInstalled) {
|
|
749
|
+
// Already the whole catalog. Adding to everything is everything, and
|
|
750
|
+
// `null` is how "the whole pack" is recorded — materialising it into a
|
|
751
|
+
// list here would freeze out any policy a later version adds.
|
|
752
|
+
if (previous === null) return { enabled: null, reason: "carried" };
|
|
753
|
+
for (const name of previous ?? []) picked.add(name);
|
|
754
|
+
return { enabled: available.filter((n) => picked.has(n)), reason: "added" };
|
|
755
|
+
}
|
|
583
756
|
return { enabled: available.filter((n) => picked.has(n)), reason: "selected" };
|
|
584
757
|
}
|
|
585
758
|
|
|
@@ -592,7 +765,7 @@ function resolveSelection(
|
|
|
592
765
|
return { enabled: policies.filter((p) => p.defaultEnabled).map((p) => p.name), reason: "defaults" };
|
|
593
766
|
}
|
|
594
767
|
|
|
595
|
-
export type SelectionReason = "all" | "selected" | "carried" | "defaults";
|
|
768
|
+
export type SelectionReason = "all" | "selected" | "carried" | "defaults" | "added";
|
|
596
769
|
|
|
597
770
|
/**
|
|
598
771
|
* Import the artifact and check it registers what the manifest promised.
|
|
@@ -669,9 +842,7 @@ export async function addPack(
|
|
|
669
842
|
// Resolve BEFORE anything is written, and pin the concrete result. A tagless
|
|
670
843
|
// source is a convenience for the person typing; what the machine records must
|
|
671
844
|
// always name one release.
|
|
672
|
-
const spec: PinnedPackSpec = parsed
|
|
673
|
-
? { ...parsed, tag: parsed.tag }
|
|
674
|
-
: { ...parsed, tag: await resolveLatestTag(parsed) };
|
|
845
|
+
const spec: PinnedPackSpec = { ...parsed, tag: await resolveSpecTag(parsed) };
|
|
675
846
|
const resolvedFromLatest = parsed.tag === null;
|
|
676
847
|
const fetched = await fetchPack(spec);
|
|
677
848
|
// Checked here, before a byte is written and before the artifact is imported,
|
|
@@ -753,13 +924,34 @@ export async function addPack(
|
|
|
753
924
|
entry: artifactRel,
|
|
754
925
|
sha256: fetched.artifactDigest,
|
|
755
926
|
policies: fetched.policies,
|
|
927
|
+
// Provenance, carried through from the manifest so `policies` and the
|
|
928
|
+
// dashboard can say which source produced what is installed. NOT part of
|
|
929
|
+
// verification — `sha256` above is the pin, and this is a label beside it.
|
|
930
|
+
...(fetched.commit ? { commit: fetched.commit } : {}),
|
|
756
931
|
...(fetched.effect ? { effect: fetched.effect } : {}),
|
|
757
932
|
...(enabled ? { enabled } : {}),
|
|
758
933
|
// Omitted, not written as the full list, when the user did not narrow it.
|
|
759
934
|
// An absent `clis` means "every agent", and it keeps meaning that when a
|
|
760
935
|
// thirteenth CLI is supported — whereas a materialised list of twelve would
|
|
761
936
|
// silently exclude the new one from every pack installed before it existed.
|
|
762
|
-
|
|
937
|
+
//
|
|
938
|
+
// A prior narrowing is CARRIED when this caller named none, for the same
|
|
939
|
+
// reason `enabled` is: `upsertInstalled` replaces the row wholesale, so
|
|
940
|
+
// writing nothing here threw the user's scope away. A pack scoped to Claude
|
|
941
|
+
// silently started guarding all eleven agents on the next `policies add` —
|
|
942
|
+
// the same shape as the bug `merge` fixes, in the widening direction, which
|
|
943
|
+
// is the worse one: it over-enforces, on agents nobody chose.
|
|
944
|
+
//
|
|
945
|
+
// `merge === false` is the ONE caller this must not do it for. The picker
|
|
946
|
+
// says "every agent" by leaving `clis` absent (pack-cli.ts, where a full
|
|
947
|
+
// tick deliberately writes nothing) — so for it, absent is an ANSWER, and
|
|
948
|
+
// carrying a prior narrowing would make widening back to all impossible.
|
|
949
|
+
// Everywhere else absent means no opinion was expressed.
|
|
950
|
+
...(opts?.clis
|
|
951
|
+
? { clis: opts.clis }
|
|
952
|
+
: opts?.merge !== false && prior?.clis
|
|
953
|
+
? { clis: prior.clis }
|
|
954
|
+
: {}),
|
|
763
955
|
};
|
|
764
956
|
|
|
765
957
|
const absorbed = upsertInstalled(record);
|
package/src/hooks/tui.ts
CHANGED
|
@@ -108,6 +108,8 @@ const RADIO_OFF = "○";
|
|
|
108
108
|
export const CHECK_ON = "◼";
|
|
109
109
|
export const CHECK_OFF = "◻";
|
|
110
110
|
export const CARET = "❯";
|
|
111
|
+
/** The return key, as the multi-select hints already spell it. */
|
|
112
|
+
export const CARET_RETURN = "↵";
|
|
111
113
|
const MARK = "\u25AE\u25AE"; // ▮▮ — the brand mark, per the design system
|
|
112
114
|
|
|
113
115
|
// ── color ─────────────────────────────────────────────────────────────────
|
|
@@ -933,6 +935,24 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
|
|
|
933
935
|
});
|
|
934
936
|
}
|
|
935
937
|
|
|
938
|
+
// A default nobody can see is a default nobody uses.
|
|
939
|
+
//
|
|
940
|
+
// `defaultValue` is applied on an empty submit and is otherwise INVISIBLE, so
|
|
941
|
+
// every prompt carrying one had to remember to spell it into its own hint —
|
|
942
|
+
// and the one where it mattered most, "Where should this publish?", spelled
|
|
943
|
+
// out the value while never saying that return was the key that took it.
|
|
944
|
+
// Somebody looking at a prefilled-looking placeholder types the whole thing
|
|
945
|
+
// out again. Owned here so a prompt cannot be added without it.
|
|
946
|
+
//
|
|
947
|
+
// Never for a masked prompt: those hold credentials, and the entire reason
|
|
948
|
+
// the characters are hidden is that the screen is being shared or recorded.
|
|
949
|
+
const hintText = [
|
|
950
|
+
opts.defaultValue && !opts.mask ? `${CARET_RETURN} ${opts.defaultValue}` : "",
|
|
951
|
+
opts.hint ?? "",
|
|
952
|
+
]
|
|
953
|
+
.filter(Boolean)
|
|
954
|
+
.join(" · ");
|
|
955
|
+
|
|
936
956
|
return new Promise((resolve) => {
|
|
937
957
|
let value = "";
|
|
938
958
|
const draw = (error?: string) => {
|
|
@@ -942,7 +962,7 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
|
|
|
942
962
|
// steps aside as soon as there is a real answer to look at. Keeping both
|
|
943
963
|
// on one line put the example and the input side by side, which is the
|
|
944
964
|
// arrangement most likely to make somebody wonder which one is theirs.
|
|
945
|
-
const hint =
|
|
965
|
+
const hint = hintText && value.length === 0 ? ` ${c.dim(hintText)}` : "";
|
|
946
966
|
// Truncate to ONE physical row. `\r\x1b[2K` erases the row the cursor is
|
|
947
967
|
// on and nothing above it — so a line wider than the terminal wraps, the
|
|
948
968
|
// erase reaches only its last row, and every keystroke leaves the earlier
|
|
File without changes
|
|
File without changes
|
|
File without changes
|