failproofai 1.0.2-beta.7 → 1.0.2-beta.9
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 +5 -5
- 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/build-manifest.json +2 -2
- 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/build-manifest.json +2 -2
- 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 +3 -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 +4 -3
- 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 +2 -2
- 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 +4 -3
- 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 +4 -4
- 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 +4 -4
- 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/build-manifest.json +2 -2
- 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/build-manifest.json +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/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +6 -5
- 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/build-manifest.json +2 -2
- 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/build-manifest.json +2 -2
- 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/build-manifest.json +2 -2
- 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/build-manifest.json +2 -2
- 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]__0-9ti7z._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0sg9-3p._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__0u3wrvq._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zlqo2t._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1anxb5k._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p-qi2t._.js +5 -0
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +27 -0
- package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
- package/.next/standalone/.next/server/chunks/{_19uwam-._.js → _1-akg6w._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -7
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js} +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +3 -0
- package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +10 -0
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.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]__0n_lxhg._.js +5 -0
- 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_7n3xe._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1uvml5y._.js → _0b6xubz._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07gm3zl._.js → _1es2j7i._.js} +7 -21
- package/.next/standalone/.next/server/chunks/ssr/{_0_nsohj._.js → _1pj-r7q._.js} +2 -2
- 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 +2 -2
- 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_1cv9_c4._.js +10 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.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 +5 -5
- 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 → 06zoac_7ffkus.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0icodhlwr9x_e.js → 0bshgqm5waivd.js} +2 -2
- package/.next/standalone/.next/static/chunks/{2-9lvhuto1iwo.js → 13grq7pifzvu5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3i8rg2fv9nd6e.js → 16pmxmg9x48ob.js} +1 -1
- package/.next/standalone/.next/static/chunks/{44oamw33aso19.js → 1da7-a7ek66uy.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1d6c85605lsgp.js → 1fg7drro5kle-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0tvmd0p_1i_g6.js → 1opyu56l4cdfk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1d7o3wnt3h6se.js → 1p-o1i0lyt9un.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vqddiy9tvlwf.js → 2mfojtsy214sf.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3jt6n16p2qpex.js → 32jj4qy40hln_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1vzyn9h2kvj-i.js → 3ww5ik9eo_1b_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1mfzsja6ftim5.css → 40nr6ehj4p3cw.css} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-1gehh2qpjyj5_.js} +1 -1
- package/.next/standalone/app/actions/get-hooks-config.ts +8 -1
- package/.next/standalone/app/actions/pack-actions.ts +12 -1
- package/.next/standalone/app/policies/hooks-client.tsx +41 -4
- package/.next/standalone/fp-cloud-cli/tests/test_pagination.py +204 -0
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +57 -13
- package/dist/cli.mjs +23076 -20000
- package/dist/worker.mjs +1252 -542
- package/package.json +9 -9
- package/scripts/build-policy-pack.mjs +28 -4
- package/src/audit/index.ts +24 -4
- package/src/hooks/builtin-policies.ts +933 -8
- package/src/hooks/custom-hooks-loader.ts +98 -8
- package/src/hooks/handler.ts +26 -14
- package/src/hooks/manager.ts +36 -2
- package/src/hooks/pack-cli.ts +1224 -121
- package/src/hooks/pack-failclosed.ts +100 -4
- package/src/hooks/pack-manifest.ts +63 -1
- package/src/hooks/pack-param-key.ts +36 -0
- package/src/hooks/pack-store.ts +309 -25
- package/src/hooks/policy-catalog.ts +9 -1
- package/src/hooks/policy-evaluator.ts +77 -1
- package/src/hooks/tui.ts +21 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tg4pu6._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mgrjnx._.js +0 -10
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1opxw-7._.js +0 -5
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_ssgManifest.js +0 -0
package/src/hooks/pack-cli.ts
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
13
|
import { INTEGRATION_TYPES } from "./types";
|
|
14
|
-
import { PACK_VERSION_RE } from "./pack-manifest";
|
|
14
|
+
import { PACK_COMMIT_RE, PACK_VERSION_RE } from "./pack-manifest";
|
|
15
15
|
import { detectInstalledClis } from "./integrations";
|
|
16
16
|
import { parsePackIdentity, parsePackPolicy, readInstalledPacks } from "./pack-manifest";
|
|
17
17
|
import {
|
|
18
|
+
AmbiguousPackId,
|
|
18
19
|
PACK_CHECKSUMS_ASSET,
|
|
19
20
|
PACK_ENTRY_ASSET,
|
|
20
21
|
PACK_MANIFEST_ASSET,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
checkPackArtifact,
|
|
23
24
|
fetchPackPreview,
|
|
24
25
|
packTagMatchesVersion,
|
|
26
|
+
parsePackSpec,
|
|
25
27
|
removePack,
|
|
26
28
|
setPackPolicyEnabled,
|
|
27
29
|
slugifyCategory,
|
|
@@ -48,9 +50,27 @@ import {
|
|
|
48
50
|
export interface PackCliResult {
|
|
49
51
|
lines: string[];
|
|
50
52
|
exitCode: number;
|
|
53
|
+
/**
|
|
54
|
+
* What a successful `build` produced, for a caller that has to describe it
|
|
55
|
+
* somewhere other than the terminal — today only `publish`, writing the
|
|
56
|
+
* release body. Carried on the RESULT rather than recomputed by the caller,
|
|
57
|
+
* because recomputing means re-deriving `defaultEnabled` from a second copy
|
|
58
|
+
* of the same rule, and the two copies drift.
|
|
59
|
+
*/
|
|
60
|
+
meta?: PackBuildMeta;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
|
|
63
|
+
export interface PackBuildMeta {
|
|
64
|
+
policies: number;
|
|
65
|
+
defaultOn: number;
|
|
66
|
+
commit?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ok = (lines: string[], meta?: PackBuildMeta): PackCliResult => ({
|
|
70
|
+
lines,
|
|
71
|
+
exitCode: 0,
|
|
72
|
+
...(meta ? { meta } : {}),
|
|
73
|
+
});
|
|
54
74
|
const fail = (lines: string[]): PackCliResult => ({ lines, exitCode: 1 });
|
|
55
75
|
|
|
56
76
|
function parseList(rest: string[], flag: string): string[] | undefined {
|
|
@@ -159,7 +179,24 @@ function parseCliList(rest: string[]): { clis?: string[] } | { error: string[] }
|
|
|
159
179
|
}
|
|
160
180
|
}
|
|
161
181
|
const names = values.map((v) => v.trim()).filter(Boolean);
|
|
162
|
-
|
|
182
|
+
// `--cli` with nothing after it is a typo, not a scope.
|
|
183
|
+
//
|
|
184
|
+
// It used to become `clis: []`, which `installed.json` stores verbatim and
|
|
185
|
+
// `handler.ts` reads as "guard NO agent" (`pack.clis && !includes(cli)`) —
|
|
186
|
+
// so `policies add <pack> --cli` exited 0, printed "enabled (1/3, the pack's
|
|
187
|
+
// defaults)", and installed a pack that enforced nowhere. `--cli` is last on
|
|
188
|
+
// the line, or followed by the source or another flag, more easily than any
|
|
189
|
+
// other flag: `looksLikeCliName` stops at anything with a `-` or a `/`.
|
|
190
|
+
// Refused the same way `--policy` and `--category` refuse an empty list, and
|
|
191
|
+
// before anything is fetched.
|
|
192
|
+
if (names.length === 0) {
|
|
193
|
+
return {
|
|
194
|
+
error: [
|
|
195
|
+
"--cli needs at least one agent name, comma-separated",
|
|
196
|
+
`--cli takes any of: ${INTEGRATION_TYPES.join(", ")}`,
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
163
200
|
|
|
164
201
|
const known = new Set<string>(INTEGRATION_TYPES);
|
|
165
202
|
const unknown = names.filter((n) => !known.has(n));
|
|
@@ -195,6 +232,7 @@ function selectionFrom(rest: string[]): {
|
|
|
195
232
|
categories?: string[];
|
|
196
233
|
all?: boolean;
|
|
197
234
|
clis?: string[];
|
|
235
|
+
merge?: boolean;
|
|
198
236
|
/** Present when the selection itself is unusable — see `parseCliList`. */
|
|
199
237
|
error?: string[];
|
|
200
238
|
} {
|
|
@@ -209,6 +247,11 @@ function selectionFrom(rest: string[]): {
|
|
|
209
247
|
...(categories ? { categories } : {}),
|
|
210
248
|
...(parsedClis.clis ? { clis: parsedClis.clis } : {}),
|
|
211
249
|
...(rest.includes("--all") ? { all: true } : {}),
|
|
250
|
+
// A FLAG adds. The command's first word is `add`, and on an
|
|
251
|
+
// already-installed pack `--category Git` means "also turn Git on" rather
|
|
252
|
+
// than "make Git the only thing on". The interactive picker overrides this
|
|
253
|
+
// back to false, because its list is the complete answer.
|
|
254
|
+
merge: true,
|
|
212
255
|
};
|
|
213
256
|
}
|
|
214
257
|
|
|
@@ -249,6 +292,7 @@ async function build(rest: string[]): Promise<PackCliResult> {
|
|
|
249
292
|
const entry = packAddSource(rest) ?? flag("entry");
|
|
250
293
|
const id = flag("id");
|
|
251
294
|
const version = flag("version");
|
|
295
|
+
const commit = flag("commit");
|
|
252
296
|
const effect = flag("effect") ?? "enforce";
|
|
253
297
|
const outDir = resolve(flag("out") ?? "dist-pack");
|
|
254
298
|
|
|
@@ -258,9 +302,9 @@ async function build(rest: string[]): Promise<PackCliResult> {
|
|
|
258
302
|
" [--out <dir>] [--effect enforce|observe]",
|
|
259
303
|
]);
|
|
260
304
|
}
|
|
261
|
-
let identity: { id: string; version: string; effect: PolicyEffect };
|
|
305
|
+
let identity: { id: string; version: string; effect: PolicyEffect; commit?: string };
|
|
262
306
|
try {
|
|
263
|
-
identity = parsePackIdentity({ id, version, effect });
|
|
307
|
+
identity = parsePackIdentity({ id, version, effect, commit });
|
|
264
308
|
} catch (err) {
|
|
265
309
|
return fail([err instanceof Error ? err.message : String(err)]);
|
|
266
310
|
}
|
|
@@ -338,9 +382,19 @@ async function build(rest: string[]): Promise<PackCliResult> {
|
|
|
338
382
|
}
|
|
339
383
|
}
|
|
340
384
|
|
|
385
|
+
// `commit` is omitted entirely rather than written as null when there is none.
|
|
386
|
+
// The manifest is hashed and the hash is the pin, so every byte in here is
|
|
387
|
+
// part of what a machine verifies — a field carrying "there was nothing to
|
|
388
|
+
// say" earns none of that cost. Readers already treat absence as ordinary.
|
|
341
389
|
const manifest =
|
|
342
390
|
JSON.stringify(
|
|
343
|
-
{
|
|
391
|
+
{
|
|
392
|
+
id: identity.id,
|
|
393
|
+
version: identity.version,
|
|
394
|
+
effect: identity.effect,
|
|
395
|
+
...(identity.commit ? { commit: identity.commit } : {}),
|
|
396
|
+
policies,
|
|
397
|
+
},
|
|
344
398
|
null,
|
|
345
399
|
2,
|
|
346
400
|
) + "\n";
|
|
@@ -365,7 +419,50 @@ async function build(rest: string[]): Promise<PackCliResult> {
|
|
|
365
419
|
"",
|
|
366
420
|
`Publish: attach all three to a GitHub release tagged ${identity.version}, then anyone runs:`,
|
|
367
421
|
` failproofai policies add <owner>/<repo>`,
|
|
368
|
-
]);
|
|
422
|
+
], { policies: policies.length, defaultOn: on, ...(identity.commit ? { commit: identity.commit } : {}) });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The release body, and the format `--releases` reads back out of it.
|
|
427
|
+
*
|
|
428
|
+
* Two audiences, one string. A person opening the releases page sees what the
|
|
429
|
+
* release contains; `policies show --releases` parses the same lines rather
|
|
430
|
+
* than downloading a manifest per release. Keep them parseable — the reader is
|
|
431
|
+
* `parseReleaseBody` directly below, and the two have to move together.
|
|
432
|
+
*
|
|
433
|
+
* Nothing here is TRUSTED. It is publisher-controlled text on somebody else's
|
|
434
|
+
* repository, so it decides what a listing displays and never what a machine
|
|
435
|
+
* installs: the manifest inside the digest-pinned assets remains the only
|
|
436
|
+
* source for that.
|
|
437
|
+
*/
|
|
438
|
+
export function releaseBody(id: string, version: string, meta?: PackBuildMeta): string {
|
|
439
|
+
const lines = [`${id}@${version}`];
|
|
440
|
+
if (meta) {
|
|
441
|
+
lines.push("", `${meta.policies} policies, ${meta.defaultOn} on by default`);
|
|
442
|
+
if (meta.commit) lines.push(`commit ${meta.commit}`);
|
|
443
|
+
}
|
|
444
|
+
return lines.join("\n") + "\n";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export interface ReleaseBodyFacts {
|
|
448
|
+
policies?: number;
|
|
449
|
+
defaultOn?: number;
|
|
450
|
+
commit?: string;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Read back what {@link releaseBody} wrote. Absent facts stay absent — a
|
|
454
|
+
* release published before this format, or by hand, simply says less. */
|
|
455
|
+
export function parseReleaseBody(body: string | null | undefined): ReleaseBodyFacts {
|
|
456
|
+
const text = typeof body === "string" ? body : "";
|
|
457
|
+
const facts: ReleaseBodyFacts = {};
|
|
458
|
+
const counts = /(\d+)\s+policies,\s*(\d+)\s+on by default/.exec(text);
|
|
459
|
+
if (counts) {
|
|
460
|
+
facts.policies = Number(counts[1]);
|
|
461
|
+
facts.defaultOn = Number(counts[2]);
|
|
462
|
+
}
|
|
463
|
+
const commit = /(?:^|\n)\s*commit\s+([0-9a-f]{7,40})\b/i.exec(text);
|
|
464
|
+
if (commit) facts.commit = commit[1].toLowerCase();
|
|
465
|
+
return facts;
|
|
369
466
|
}
|
|
370
467
|
|
|
371
468
|
/**
|
|
@@ -513,7 +610,7 @@ async function scaffold(target: string | null): Promise<PackCliResult> {
|
|
|
513
610
|
if (!chosen && process.stdin.isTTY && process.stdout.isTTY) {
|
|
514
611
|
const answer = await promptText({
|
|
515
612
|
message: "What is this pack called?",
|
|
516
|
-
hint: "used for the filename
|
|
613
|
+
hint: "used for the filename",
|
|
517
614
|
defaultValue: "my-policies",
|
|
518
615
|
validate: (v) =>
|
|
519
616
|
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim())
|
|
@@ -600,34 +697,294 @@ function inferRepo(entryPath: string): string | null {
|
|
|
600
697
|
}
|
|
601
698
|
|
|
602
699
|
/**
|
|
603
|
-
*
|
|
700
|
+
* The commit the policies being published are sitting at.
|
|
701
|
+
*
|
|
702
|
+
* This DECIDES the version and gates the publish — `versionForPublish` reads
|
|
703
|
+
* both fields. It used to be a label that did neither, which is why the reads
|
|
704
|
+
* below are stricter than they look. A pack published from a directory that is
|
|
705
|
+
* not a git checkout has no commit; absence is ordinary and returns null the
|
|
706
|
+
* same way `inferRepo` does, and the refusal is spelled out one caller up.
|
|
604
707
|
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
708
|
+
* Read from the ENTRY FILE's directory rather than the process cwd, for the
|
|
709
|
+
* reason `inferRepo` gives: a policy that lives in another checkout is normal,
|
|
710
|
+
* and the answer has to describe the file rather than wherever the shell is.
|
|
711
|
+
*
|
|
712
|
+
* `dirty` still travels beyond the version: with `--version` given, a dirty
|
|
713
|
+
* tree publishes anyway and the recorded `commit` is then an approximation.
|
|
714
|
+
*
|
|
715
|
+
* `outDir` is the directory the build is about to write into, and everything
|
|
716
|
+
* under it is left OUT of the dirty read — see {@link skipOutDir} for the
|
|
717
|
+
* self-inflicted refusal that costs.
|
|
608
718
|
*/
|
|
609
|
-
function
|
|
719
|
+
function inferCommit(
|
|
720
|
+
entryPath: string,
|
|
721
|
+
outDir?: string,
|
|
722
|
+
sources?: string[],
|
|
723
|
+
): Provenance | null {
|
|
610
724
|
const cwd = resolve(entryPath, "..");
|
|
725
|
+
// Two readers over one runner: `raw` for output whose LEADING bytes carry
|
|
726
|
+
// meaning (a porcelain status column is a space), `git` for the rest.
|
|
727
|
+
const raw = (args: string[]): string | null => {
|
|
728
|
+
try {
|
|
729
|
+
return execFileSync("git", args, {
|
|
730
|
+
cwd,
|
|
731
|
+
encoding: "utf8",
|
|
732
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
733
|
+
timeout: 5_000,
|
|
734
|
+
});
|
|
735
|
+
} catch {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
};
|
|
611
739
|
const git = (args: string[]): string | null => {
|
|
740
|
+
const out = raw(args);
|
|
741
|
+
return out === null ? null : out.trim();
|
|
742
|
+
};
|
|
743
|
+
const sha = git(["rev-parse", "HEAD"]);
|
|
744
|
+
if (!sha || !/^[0-9a-f]{40}$/.test(sha)) return null;
|
|
745
|
+
// `git()` answers "" for a clean tree and null when it could not run the
|
|
746
|
+
// command at all, and the `Boolean()` this used to be read BOTH as clean.
|
|
747
|
+
// Harmless while `dirty` was a label; now that it decides the version, an
|
|
748
|
+
// unreadable status minted a commit-named version for bytes nobody had
|
|
749
|
+
// checked against that commit — the one claim this scheme exists to refuse.
|
|
750
|
+
// Reachable, not theoretical: `rev-parse` never touches the index, so a repo
|
|
751
|
+
// whose index is unreadable answers the first question and fails the second,
|
|
752
|
+
// and `status` walks the whole worktree so it is also the one that hits the
|
|
753
|
+
// 5s timeout. Unknown therefore falls on the dirty side: refusing a tree we
|
|
754
|
+
// cannot vouch for costs one `--version`, and the alternative is publishing
|
|
755
|
+
// one we could not read.
|
|
756
|
+
const status = git(["status", "--porcelain", ...skipOutDir(git, entryPath, outDir)]);
|
|
757
|
+
// The sources are checked BY NAME as well as the tree as a whole. A clean
|
|
758
|
+
// tree does not mean the bytes being bundled are in HEAD: `.gitignore` hides
|
|
759
|
+
// a source from the tree read entirely, and the artifact would then carry a
|
|
760
|
+
// commit that does not contain it.
|
|
761
|
+
const sourceState = sources ? sourcesInHead(git, raw, sources) : ({ ok: true } as const);
|
|
762
|
+
return {
|
|
763
|
+
sha,
|
|
764
|
+
dirty: status !== "" || !sourceState.ok,
|
|
765
|
+
...(sourceState.ok ? {} : { unpublishable: sourceState }),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* `git status --porcelain -z`, parsed.
|
|
771
|
+
*
|
|
772
|
+
* The format is NOT one record per NUL field. Each entry is `XY <path>`, and a
|
|
773
|
+
* rename or copy is followed by its ORIGINAL path as a separate NUL field
|
|
774
|
+
* carrying NO status prefix:
|
|
775
|
+
*
|
|
776
|
+
* R b.mjs\0a.mjs\0
|
|
777
|
+
*
|
|
778
|
+
* Splitting on NUL and slicing three characters off every field therefore turns
|
|
779
|
+
* `a.mjs` into `js` — a path that resolves to nothing, is in nobody's file set,
|
|
780
|
+
* and made `publish` refuse a policy file its author had simply renamed. The
|
|
781
|
+
* original has to be CONSUMED by the entry that produced it.
|
|
782
|
+
*/
|
|
783
|
+
function porcelainEntries(status: string): Array<{ code: string; path: string; from?: string }> {
|
|
784
|
+
const fields = status.split("\0");
|
|
785
|
+
const out: Array<{ code: string; path: string; from?: string }> = [];
|
|
786
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
787
|
+
const entry = fields[i];
|
|
788
|
+
if (!entry || entry.length < 4) continue;
|
|
789
|
+
const code = entry.slice(0, 2);
|
|
790
|
+
const path = entry.slice(3);
|
|
791
|
+
if (code.startsWith("R") || code.startsWith("C")) {
|
|
792
|
+
const from = fields[i + 1];
|
|
793
|
+
i += 1;
|
|
794
|
+
out.push({ code, path, ...(from ? { from } : {}) });
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
out.push({ code, path });
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** What a checkout says about the bytes being published. `unpublishable` names
|
|
803
|
+
* the sources that are not in HEAD, so the refusal can list them. */
|
|
804
|
+
export interface Provenance {
|
|
805
|
+
sha: string;
|
|
806
|
+
dirty: boolean;
|
|
807
|
+
unpublishable?: { offending: string[]; why: "missing" | "modified" };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Whether every file about to be BUNDLED is in HEAD and unmodified.
|
|
812
|
+
*
|
|
813
|
+
* The whole-tree read above answers "is this checkout clean", which is a
|
|
814
|
+
* different question and misses three ways a source reaches the artifact
|
|
815
|
+
* without reaching the commit:
|
|
816
|
+
*
|
|
817
|
+
* - **Ignored files.** `git status --porcelain` omits anything `.gitignore`
|
|
818
|
+
* matches, even with `--untracked-files=all`. A policy file the author
|
|
819
|
+
* ignored therefore read as clean, got bundled, and the manifest recorded
|
|
820
|
+
* HEAD as the commit it came from — bytes that commit does not contain.
|
|
821
|
+
* - **Untracked files in an otherwise clean tree**, for the same reason once
|
|
822
|
+
* the ignore rules are wide.
|
|
823
|
+
* - **A tag.** `inferTaggedVersion` checked only the ENTRY, while `publish`
|
|
824
|
+
* bundles every discovered policy file. A tagged commit, a clean entry and a
|
|
825
|
+
* modified sibling published that sibling under a tag naming a commit
|
|
826
|
+
* without it.
|
|
827
|
+
*
|
|
828
|
+
* One check for all of them, run against the actual source LIST, because that
|
|
829
|
+
* is the set whose bytes end up in the artifact — and provenance is a claim
|
|
830
|
+
* about those bytes and nothing else.
|
|
831
|
+
*
|
|
832
|
+
* `-z` rather than line splitting: git C-quotes a path containing a newline or
|
|
833
|
+
* a quote, so the parsed path is escape sequences that resolve to no file, and
|
|
834
|
+
* a legal filename containing ` -> ` is truncated by the rename split. NUL
|
|
835
|
+
* separation has neither problem — and a rename arrives as two NUL-separated
|
|
836
|
+
* fields rather than one arrow-joined line — which is what
|
|
837
|
+
* {@link porcelainEntries} reads.
|
|
838
|
+
*/
|
|
839
|
+
/**
|
|
840
|
+
* A path spelled the way git spells it: symlinks resolved.
|
|
841
|
+
*
|
|
842
|
+
* `rev-parse --show-toplevel` answers with the RESOLVED root, so a source named
|
|
843
|
+
* through a symlinked directory — `publish ~/policies/guards.mjs`, where
|
|
844
|
+
* `~/policies` links into a checkout — measures as outside the repository
|
|
845
|
+
* against it, and a committed, untouched file was reported as never committed
|
|
846
|
+
* with `git add -f` as the advice, which does nothing for it.
|
|
847
|
+
*
|
|
848
|
+
* It cuts the other way too, which is why this resolves the SOURCES rather than
|
|
849
|
+
* only un-resolving the root: a policy file that is itself a symlink out of the
|
|
850
|
+
* repository has a blob in HEAD holding the link's TARGET NAME, while the bytes
|
|
851
|
+
* bundled are the target's. Asking about the link's own path called that
|
|
852
|
+
* publishable and recorded a commit containing none of those bytes.
|
|
853
|
+
*
|
|
854
|
+
* Falls back to the path as given when it cannot be resolved, so a source that
|
|
855
|
+
* has since been deleted is still reported rather than throwing here.
|
|
856
|
+
*/
|
|
857
|
+
function realOrSelf(path: string): string {
|
|
858
|
+
try {
|
|
859
|
+
return realpathSync(path);
|
|
860
|
+
} catch {
|
|
861
|
+
return path;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function sourcesInHead(
|
|
866
|
+
git: (args: string[]) => string | null,
|
|
867
|
+
raw: (args: string[]) => string | null,
|
|
868
|
+
sources: string[],
|
|
869
|
+
): { ok: true } | { ok: false; offending: string[]; why: "missing" | "modified" } {
|
|
870
|
+
const paths = sources.map((f) => realOrSelf(resolve(f)));
|
|
871
|
+
// Present in the commit at all. `cat-file -e HEAD:<path>` is the cheapest
|
|
872
|
+
// question that distinguishes "tracked and committed" from "tracked but
|
|
873
|
+
// added only to the index", which `ls-files` would answer yes to.
|
|
874
|
+
const shown = git(["rev-parse", "--show-toplevel"]);
|
|
875
|
+
const top = shown === null ? null : realOrSelf(resolve(shown));
|
|
876
|
+
const missing: string[] = [];
|
|
877
|
+
for (const path of paths) {
|
|
878
|
+
const rel = top ? relative(top, path) : null;
|
|
879
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
880
|
+
missing.push(path);
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (git(["cat-file", "-e", `HEAD:${rel}`]) === null) missing.push(path);
|
|
884
|
+
}
|
|
885
|
+
if (missing.length > 0) return { ok: false, offending: missing, why: "missing" };
|
|
886
|
+
|
|
887
|
+
// `--ignored=matching` so an ignored source is REPORTED rather than omitted,
|
|
888
|
+
// which is the whole point of asking about the sources by name.
|
|
889
|
+
const status = raw([
|
|
890
|
+
"status", "--porcelain", "-z", "--untracked-files=all", "--ignored=matching",
|
|
891
|
+
"--", ...paths,
|
|
892
|
+
]);
|
|
893
|
+
if (status === null) return { ok: false, offending: paths, why: "modified" };
|
|
894
|
+
// Both halves of a rename count: either one being listed means the set on
|
|
895
|
+
// disk is not the set HEAD holds.
|
|
896
|
+
const dirty = porcelainEntries(status).flatMap((entry) =>
|
|
897
|
+
[entry.path, entry.from].filter(Boolean).map((path) => resolve(top ?? ".", path as string)),
|
|
898
|
+
);
|
|
899
|
+
if (dirty.length > 0) return { ok: false, offending: dirty, why: "modified" };
|
|
900
|
+
return { ok: true };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* The build output directory, spelled as a pathspec `git status` skips.
|
|
905
|
+
*
|
|
906
|
+
* `publish` writes its three assets to `dist-pack` under the cwd unless told
|
|
907
|
+
* otherwise, so the documented `cd my-policies && failproofai publish` leaves
|
|
908
|
+
* untracked build output INSIDE the checkout it just read. Without this, the
|
|
909
|
+
* next publish of an unchanged, fully committed tree reads that output as
|
|
910
|
+
* uncommitted changes and refuses — the command breaking its own second run,
|
|
911
|
+
* over a directory it wrote itself, with a remedy (`git add -A`) that commits
|
|
912
|
+
* build output into the pack repository.
|
|
913
|
+
*
|
|
914
|
+
* Narrow on purpose, because hiding a change from this read is hiding the one
|
|
915
|
+
* thing the commit version claims. Nothing is skipped when the output lands
|
|
916
|
+
* outside the repository, which cannot dirty it anyway, and nothing is skipped
|
|
917
|
+
* when the output directory CONTAINS the entry file — `--out .` in a folder of
|
|
918
|
+
* policies is a directory full of source, where skipping it would conceal
|
|
919
|
+
* exactly what the check exists to catch.
|
|
920
|
+
*/
|
|
921
|
+
function skipOutDir(
|
|
922
|
+
git: (args: string[]) => string | null,
|
|
923
|
+
entryPath: string,
|
|
924
|
+
outDir: string | undefined,
|
|
925
|
+
): string[] {
|
|
926
|
+
if (!outDir) return [];
|
|
927
|
+
const out = resolve(outDir);
|
|
928
|
+
if (resolve(entryPath).startsWith(out + sep)) return [];
|
|
929
|
+
const top = git(["rev-parse", "--show-toplevel"]);
|
|
930
|
+
if (!top) return [];
|
|
931
|
+
const rel = relative(resolve(top), out);
|
|
932
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return [];
|
|
933
|
+
// `top` on the exclusion is what makes it mean the directory it names: `rel`
|
|
934
|
+
// is measured from the repository root, while a pathspec is read relative to
|
|
935
|
+
// the directory git runs in — the ENTRY's folder. Without it, publishing
|
|
936
|
+
// `guards/policies.mjs` would exclude `guards/dist-pack` and leave the real
|
|
937
|
+
// `dist-pack/` in the read, so the self-inflicted refusal comes straight
|
|
938
|
+
// back for anyone whose policies sit in a subdirectory.
|
|
939
|
+
//
|
|
940
|
+
// The leading `:(top)` states the whole-repository scope rather than leaning
|
|
941
|
+
// on git's rule that a list of exclusions alone applies to all paths, and
|
|
942
|
+
// `literal` stops a directory named with a glob character from taking its
|
|
943
|
+
// neighbours out of the read with it.
|
|
944
|
+
return ["--", ":(top)", `:(exclude,literal,top)${rel.split(sep).join("/")}`];
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* A tag on HEAD, when there is one and the file is clean.
|
|
949
|
+
*
|
|
950
|
+
* Somebody who tagged `v1.2.0` has SAID what this release is, which a derived
|
|
951
|
+
* name cannot — so this wins over the commit version. Absent a tag there is
|
|
952
|
+
* nothing here to infer, and the commit the tree sits at is used instead.
|
|
953
|
+
*/
|
|
954
|
+
function inferTaggedVersion(entryPath: string, sources?: string[]): string | null {
|
|
955
|
+
const cwd = resolve(entryPath, "..");
|
|
956
|
+
const raw = (args: string[]): string | null => {
|
|
612
957
|
try {
|
|
613
958
|
return execFileSync("git", args, {
|
|
614
959
|
cwd,
|
|
615
960
|
encoding: "utf8",
|
|
616
961
|
stdio: ["ignore", "pipe", "ignore"],
|
|
617
962
|
timeout: 5_000,
|
|
618
|
-
})
|
|
963
|
+
});
|
|
619
964
|
} catch {
|
|
620
965
|
return null;
|
|
621
966
|
}
|
|
622
967
|
};
|
|
968
|
+
const git = (args: string[]): string | null => {
|
|
969
|
+
const out = raw(args);
|
|
970
|
+
return out === null ? null : out.trim();
|
|
971
|
+
};
|
|
623
972
|
const tag = git(["describe", "--tags", "--exact-match", "HEAD"]);
|
|
624
973
|
if (!tag || !PACK_VERSION_RE.test(tag)) return null;
|
|
625
974
|
// A tag NAMES a commit, so shipping edited bytes under it publishes something
|
|
626
975
|
// that commit does not contain — and two artifacts would claim one version,
|
|
627
976
|
// which `id|version|sha256` compares in both the audit key and the pack
|
|
628
|
-
// upsert.
|
|
629
|
-
//
|
|
630
|
-
|
|
977
|
+
// upsert. Compared against "" rather than tested for truthiness for the reason
|
|
978
|
+
// `inferCommit` gives: null is "could not read the tree", and reading that as
|
|
979
|
+
// clean is how a dirty tree slips out under a tag that does not describe it.
|
|
980
|
+
// Every source that will be BUNDLED, not just the entry. `publish` discovers
|
|
981
|
+
// and bundles each policy file in the directory, so a tagged commit with a
|
|
982
|
+
// clean entry and a modified sibling published that sibling under a tag
|
|
983
|
+
// naming a commit without it. Same check the sha path uses, so the two
|
|
984
|
+
// cannot disagree about what "this commit contains these bytes" means.
|
|
985
|
+
const covered = sourcesInHead(git, raw, sources ?? [entryPath]);
|
|
986
|
+
if (!covered.ok) return null;
|
|
987
|
+
if (git(["status", "--porcelain", "--", entryPath]) !== "") return null;
|
|
631
988
|
return tag;
|
|
632
989
|
}
|
|
633
990
|
|
|
@@ -679,45 +1036,425 @@ function findEntry(dir: string): string[] {
|
|
|
679
1036
|
}
|
|
680
1037
|
|
|
681
1038
|
/**
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
* then 1.0.1 — costs one API call and answers both.
|
|
688
|
-
*
|
|
689
|
-
* Reads the repository's own releases rather than anything local, because the
|
|
690
|
-
* releases ARE the published record: a machine that has never published from
|
|
691
|
-
* this checkout still computes the right next number, and two people publishing
|
|
692
|
-
* from different clones cannot both mint 1.0.1 without one of them seeing the
|
|
693
|
-
* other's release first.
|
|
694
|
-
*
|
|
695
|
-
* Non-semver tags are ignored rather than parsed heroically — a repo whose
|
|
696
|
-
* releases are named `nightly` has no sequence to continue, and starting a
|
|
697
|
-
* fresh count is more honest than inventing one.
|
|
1039
|
+
* Every relative specifier a source file names, in the four spellings a bundler
|
|
1040
|
+
* follows. Anchored to a line start for the two import FORMS, because that is
|
|
1041
|
+
* where a real one is: a `"./x.mjs"` inside a policy's own deny message is a
|
|
1042
|
+
* string, not an edge of the module graph, and the file it happens to spell
|
|
1043
|
+
* should not be dragged into a provenance check it has nothing to do with.
|
|
698
1044
|
*/
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1045
|
+
const LOCAL_IMPORT_RES: readonly RegExp[] = [
|
|
1046
|
+
/(?:^|\n)\s*(?:import|export)[^;\n]*?from\s*["'](\.[^"'\n]*)["']/g,
|
|
1047
|
+
/(?:^|\n)\s*import\s*["'](\.[^"'\n]*)["']/g,
|
|
1048
|
+
/\bimport\s*\(\s*["'](\.[^"'\n]*)["']\s*\)/g,
|
|
1049
|
+
/\brequire\s*\(\s*["'](\.[^"'\n]*)["']\s*\)/g,
|
|
1050
|
+
];
|
|
1051
|
+
|
|
1052
|
+
/** What a specifier without an extension can name on disk, in resolution order. */
|
|
1053
|
+
const LOCAL_IMPORT_EXTENSIONS: readonly string[] = [
|
|
1054
|
+
"", ".mjs", ".js", ".ts", ".mts", ".cts", ".cjs", ".jsx", ".tsx", ".json",
|
|
1055
|
+
"/index.mjs", "/index.js", "/index.ts",
|
|
1056
|
+
];
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* The policy files plus everything they IMPORT.
|
|
1060
|
+
*
|
|
1061
|
+
* A file reaches the artifact by being imported, not only by being discovered.
|
|
1062
|
+
* `findEntry` recognises policy files — ones that import failproofai and call
|
|
1063
|
+
* `customPolicies.add` — while `bundleEntry` inlines whatever those files pull
|
|
1064
|
+
* in, so a plain `./patterns.mjs` of shared matchers is in nobody's source list
|
|
1065
|
+
* and in every byte of the bundle. Ignored, it is the exact bug
|
|
1066
|
+
* {@link sourcesInHead} exists to close with a different file on the end of it:
|
|
1067
|
+
* `git status` reports a clean tree, the policy file itself is committed, and
|
|
1068
|
+
* the pack ships bytes HEAD does not contain under a version naming HEAD.
|
|
1069
|
+
*
|
|
1070
|
+
* Only files that EXIST are added, so a specifier this resolves differently
|
|
1071
|
+
* from the bundler adds nothing rather than inventing a path to refuse over.
|
|
1072
|
+
* Static and CommonJS spellings only — a computed `import(name)` is not
|
|
1073
|
+
* followed here, and is not bundled either.
|
|
1074
|
+
*/
|
|
1075
|
+
function withLocalImports(sources: string[]): string[] {
|
|
1076
|
+
const seen = new Set(sources.map((f) => resolve(f)));
|
|
1077
|
+
const queue = [...seen];
|
|
1078
|
+
// A pack is a handful of files; the cap is there so a cycle or a vendored
|
|
1079
|
+
// tree cannot turn a publish into a filesystem walk.
|
|
1080
|
+
while (queue.length > 0 && seen.size < 500) {
|
|
1081
|
+
const file = queue.shift() as string;
|
|
1082
|
+
let text: string;
|
|
1083
|
+
try {
|
|
1084
|
+
if (statSync(file).size > 512 * 1024) continue;
|
|
1085
|
+
text = readFileSync(file, "utf8");
|
|
1086
|
+
} catch {
|
|
1087
|
+
continue;
|
|
718
1088
|
}
|
|
1089
|
+
for (const re of LOCAL_IMPORT_RES) {
|
|
1090
|
+
for (const match of text.matchAll(re)) {
|
|
1091
|
+
const base = resolve(dirname(file), match[1]);
|
|
1092
|
+
for (const ext of LOCAL_IMPORT_EXTENSIONS) {
|
|
1093
|
+
const candidate = base + ext;
|
|
1094
|
+
if (seen.has(candidate)) break;
|
|
1095
|
+
try {
|
|
1096
|
+
if (!statSync(candidate).isFile()) continue;
|
|
1097
|
+
} catch {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
seen.add(candidate);
|
|
1101
|
+
queue.push(candidate);
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
return [...seen];
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Make the git claim TRUE instead of refusing it, where doing that is safe.
|
|
1112
|
+
*
|
|
1113
|
+
* The version names a commit, so publishing needs one — and the first version
|
|
1114
|
+
* of this simply refused when there was not one, handing the user two commands
|
|
1115
|
+
* to run and asking them to start again. That is a burden the tool can carry:
|
|
1116
|
+
* it already knows the directory, and it already knows which files it is about
|
|
1117
|
+
* to bundle.
|
|
1118
|
+
*
|
|
1119
|
+
* Two situations, and they are NOT the same risk, which is why only one of them
|
|
1120
|
+
* is fully automatic:
|
|
1121
|
+
*
|
|
1122
|
+
* - **No checkout at all.** `git init` + commit everything. There is no history
|
|
1123
|
+
* to disturb, no branch to confuse and no unrelated work to sweep up: the
|
|
1124
|
+
* directory is inert until this runs. Safe. An initialised checkout that has
|
|
1125
|
+
* never been committed to reaches this case too — `rev-parse HEAD` answers
|
|
1126
|
+
* nothing for an unborn HEAD — and it is safe on the SAME terms only at that
|
|
1127
|
+
* checkout's root. Below the root the directory is not inert at all, so that
|
|
1128
|
+
* is refused rather than settled; see the branch itself for what it cost.
|
|
1129
|
+
*
|
|
1130
|
+
*
|
|
1131
|
+
* - **A checkout with uncommitted changes.** `git add -A` here is NOT safe. It
|
|
1132
|
+
* sweeps up whatever else is in the tree — a half-finished edit in a sibling
|
|
1133
|
+
* file, a scratch `.env`, a debugging change nobody had decided on — and
|
|
1134
|
+
* "publish committed my unrelated work" is a far worse surprise than being
|
|
1135
|
+
* asked to commit. So only the POLICY FILES are committed, the ones publish
|
|
1136
|
+
* found and is about to bundle, and anything else dirty stops the run with
|
|
1137
|
+
* those files named. Committing the artifact's own inputs is defensible;
|
|
1138
|
+
* committing the rest of somebody's desk is not.
|
|
1139
|
+
*
|
|
1140
|
+
* TTY only. In CI a commit made here exists on the runner and nowhere else, so
|
|
1141
|
+
* the version would name provenance nobody can resolve — `--version` is the
|
|
1142
|
+
* answer there, and the refusal says so.
|
|
1143
|
+
*
|
|
1144
|
+
* Returns null when there is nothing to do or nothing safe to do, leaving
|
|
1145
|
+
* {@link versionForPublish} to refuse with its own message.
|
|
1146
|
+
*/
|
|
1147
|
+
function settleGitState(
|
|
1148
|
+
entryPath: string,
|
|
1149
|
+
provenance: { sha: string; dirty: boolean } | null,
|
|
1150
|
+
packFiles: string[],
|
|
1151
|
+
id: string,
|
|
1152
|
+
outDir: string | undefined,
|
|
1153
|
+
): { provenance: { sha: string; dirty: boolean }; lines: string[] } | { error: string[] } | null {
|
|
1154
|
+
const cwd = resolve(entryPath, "..");
|
|
1155
|
+
const raw = (args: string[]): string | null => {
|
|
1156
|
+
try {
|
|
1157
|
+
return execFileSync("git", args, {
|
|
1158
|
+
cwd,
|
|
1159
|
+
encoding: "utf8",
|
|
1160
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1161
|
+
timeout: 30_000,
|
|
1162
|
+
});
|
|
1163
|
+
} catch {
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
const git = (args: string[]): string | null => {
|
|
1168
|
+
const out = raw(args);
|
|
1169
|
+
return out === null ? null : out.trim();
|
|
1170
|
+
};
|
|
1171
|
+
// A commit needs an identity, and a machine that has never configured one
|
|
1172
|
+
// fails INSIDE `git commit` with a wall of advice about --global. Checked
|
|
1173
|
+
// first so the refusal names the two commands rather than quoting git at
|
|
1174
|
+
// somebody who did not run git.
|
|
1175
|
+
const identity = (): string[] | null => {
|
|
1176
|
+
if (git(["config", "user.email"]) && git(["config", "user.name"])) return null;
|
|
1177
|
+
return [
|
|
1178
|
+
"git has no name and email configured on this machine, so nothing can be committed.",
|
|
1179
|
+
' git config --global user.name "You"',
|
|
1180
|
+
' git config --global user.email "you@example.com"',
|
|
1181
|
+
"Then publish again, or name the version yourself: --version <version>.",
|
|
1182
|
+
];
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
if (provenance === null) {
|
|
1186
|
+
const problem = identity();
|
|
1187
|
+
if (problem) return { error: problem };
|
|
1188
|
+
// No commit is not the same fact as no repository. `inferCommit` reads
|
|
1189
|
+
// `rev-parse HEAD`, and a checkout that has been initialised but never
|
|
1190
|
+
// committed to answers nothing — so this branch is also where an UNBORN
|
|
1191
|
+
// HEAD lands, and `git init` there is being run inside somebody's existing
|
|
1192
|
+
// work tree.
|
|
1193
|
+
//
|
|
1194
|
+
// `--show-prefix` is git's own answer to "where am I in this work tree":
|
|
1195
|
+
// null outside one, "" at its root, `policies/` below it. Asked of git
|
|
1196
|
+
// rather than compared as paths because `cwd` is built from the entry
|
|
1197
|
+
// argument and can carry symlinks that `--show-toplevel` resolves away,
|
|
1198
|
+
// and a spelling difference would refuse a publish that is perfectly fine.
|
|
1199
|
+
const prefix = git(["rev-parse", "--show-prefix"]);
|
|
1200
|
+
if (prefix !== null && prefix !== "") {
|
|
1201
|
+
// `git init` here created a NESTED repository inside the parent's work
|
|
1202
|
+
// tree and committed into that. The pack was then versioned by a commit
|
|
1203
|
+
// living in a repository the author will never push — the parent shows
|
|
1204
|
+
// only an untracked `policies/` — so the version named provenance nobody
|
|
1205
|
+
// could resolve, which is the one thing this whole path exists to avoid.
|
|
1206
|
+
// Not settled automatically either: everything in an unborn checkout is
|
|
1207
|
+
// untracked, so the only add that would work is one sweeping the parent's
|
|
1208
|
+
// whole work tree, which is exactly the surprise the dirty branch below
|
|
1209
|
+
// refuses to hand anyone.
|
|
1210
|
+
return { error: [
|
|
1211
|
+
"The version names a commit, and this folder is inside a git checkout that has no commits yet.",
|
|
1212
|
+
` ${prefix.replace(/\/$/, "")} — inside a repository whose first commit has not been made`,
|
|
1213
|
+
"Make that first commit yourself, then publish again —",
|
|
1214
|
+
"or name the version yourself: --version <version>.",
|
|
1215
|
+
] };
|
|
1216
|
+
}
|
|
1217
|
+
// Only when there is genuinely no repository. Re-initialising one that
|
|
1218
|
+
// exists is a no-op git tolerates, but the line reported below would then
|
|
1219
|
+
// claim to have started something that was already there.
|
|
1220
|
+
if (prefix === null && git(["init", "-q"]) === null) return null;
|
|
1221
|
+
// `-A` minus the build output. A first commit that swept in `dist-pack/`
|
|
1222
|
+
// would put the artifact inside the very commit it is supposed to name.
|
|
1223
|
+
//
|
|
1224
|
+
// No `--` of its own: `skipOutDir` returns one ALREADY, along with the
|
|
1225
|
+
// `:(top)` scope it needs. Adding a second separator made git read it as a
|
|
1226
|
+
// literal filename, the add failed, and the commit then failed on an empty
|
|
1227
|
+
// index — reported as "could not make the first commit", which is true and
|
|
1228
|
+
// says nothing about why.
|
|
1229
|
+
git(["add", "-A", ...skipOutDir(git, entryPath, outDir)]);
|
|
1230
|
+
// Counted from the INDEX, not from the policy files. The first commit takes
|
|
1231
|
+
// everything here — a README, a .gitignore, whatever else the author has
|
|
1232
|
+
// beside their policies — and reporting the number of policy files instead
|
|
1233
|
+
// understated what had just been committed on their behalf.
|
|
1234
|
+
const staged = (git(["diff", "--cached", "--name-only"]) ?? "").split("\n").filter(Boolean).length;
|
|
1235
|
+
if (git(["commit", "-q", "-m", `publish ${id}`]) === null) {
|
|
1236
|
+
return { error: [
|
|
1237
|
+
`Could not make the first commit in ${cwd}.`,
|
|
1238
|
+
"Commit by hand, or name the version yourself: --version <version>.",
|
|
1239
|
+
] };
|
|
1240
|
+
}
|
|
1241
|
+
const made = inferCommit(entryPath, outDir, packFiles);
|
|
1242
|
+
if (!made) return null;
|
|
1243
|
+
const files = `${staged} file${staged === 1 ? "" : "s"}`;
|
|
1244
|
+
return {
|
|
1245
|
+
provenance: made,
|
|
1246
|
+
lines: [
|
|
1247
|
+
prefix === null
|
|
1248
|
+
? `Started a git repository here and committed ${files}.`
|
|
1249
|
+
: `Made this repository's first commit — ${files}.`,
|
|
1250
|
+
],
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (!provenance.dirty) return null;
|
|
1255
|
+
|
|
1256
|
+
// Which paths are actually dirty, as git sees them — relative to the REPO
|
|
1257
|
+
// ROOT, not to the entry's directory, which are different whenever the
|
|
1258
|
+
// policies live in a subdirectory.
|
|
1259
|
+
const root = git(["rev-parse", "--show-toplevel"]);
|
|
1260
|
+
// The SAME exclusion `inferCommit` applied when it decided the tree was
|
|
1261
|
+
// dirty. Without it this would try to commit the assets the last run wrote —
|
|
1262
|
+
// which are gitignored in every pack this tool scaffolds, and are the build
|
|
1263
|
+
// output either way.
|
|
1264
|
+
// UNTRIMMED. A porcelain line is `XY <path>`, and for an unstaged edit the X
|
|
1265
|
+
// column is a SPACE — so trimming the output eats the first line's leading
|
|
1266
|
+
// space, and `slice(3)` then eats the first character of its path too. It
|
|
1267
|
+
// reported `ests.mjs`, decided that was not one of the policy files, and
|
|
1268
|
+
// refused the publish over a file that did not exist. Only the first line is
|
|
1269
|
+
// affected, which is exactly the kind of wrongness that survives a casual
|
|
1270
|
+
// test with two dirty files in it.
|
|
1271
|
+
//
|
|
1272
|
+
// `--untracked-files=all` because the default COLLAPSES a wholly untracked
|
|
1273
|
+
// directory into one line naming the directory: a new `policies/` folder
|
|
1274
|
+
// inside an existing checkout reads as `?? policies/` and never mentions the
|
|
1275
|
+
// files in it. That path matches no policy file, so it landed in `foreign`
|
|
1276
|
+
// and publish refused a brand new folder of policies by naming the folder
|
|
1277
|
+
// itself as somebody else's work — the bootstrap case this whole function
|
|
1278
|
+
// exists for. Only reproducible below the repository root; an untracked file
|
|
1279
|
+
// AT the root is reported individually, which is why every fixture that
|
|
1280
|
+
// publishes from the root missed it. The exclusion still applies, so the
|
|
1281
|
+
// expansion does not drag the build output back into the read.
|
|
1282
|
+
// `-z`, not line splitting.
|
|
1283
|
+
//
|
|
1284
|
+
// The line parser this replaces was wrong twice over on paths git is entitled
|
|
1285
|
+
// to hand back. Git C-QUOTES a path containing a newline or a quote, so the
|
|
1286
|
+
// value parsed out was escape sequences resolving to no file on disk — and a
|
|
1287
|
+
// legal policy file named `guards -> final.mjs` was truncated to `final.mjs`
|
|
1288
|
+
// by the rename split. Either way the path matched none of the pack's files,
|
|
1289
|
+
// landed in `foreign`, and publish refused to commit its OWN source over a
|
|
1290
|
+
// filename it had mangled itself.
|
|
1291
|
+
//
|
|
1292
|
+
// NUL separation has neither problem: no quoting is applied, and a rename
|
|
1293
|
+
// arrives as two separate entries rather than one arrow-joined line — so the
|
|
1294
|
+
// old path is listed in its own right, which is correct here, since a rename
|
|
1295
|
+
// leaves the tree differing from HEAD at both ends.
|
|
1296
|
+
const status = raw([
|
|
1297
|
+
"status",
|
|
1298
|
+
"--porcelain",
|
|
1299
|
+
"-z",
|
|
1300
|
+
"--untracked-files=all",
|
|
1301
|
+
...skipOutDir(git, entryPath, outDir),
|
|
1302
|
+
]);
|
|
1303
|
+
if (root === null || status === null) return null;
|
|
1304
|
+
const entries = porcelainEntries(status);
|
|
1305
|
+
// The path that EXISTS is the one to stage and the one to judge. A rename's
|
|
1306
|
+
// original is staged too — otherwise the deletion half is left uncommitted
|
|
1307
|
+
// and the tree is still dirty afterwards — but it is NOT judged foreign on
|
|
1308
|
+
// its own: renaming one of the pack's own files is the pack's business, and
|
|
1309
|
+
// treating the vacated name as somebody else's file made publish refuse a
|
|
1310
|
+
// policy file its author had merely renamed.
|
|
1311
|
+
const dirty = entries.map((entry) => resolve(root, entry.path));
|
|
1312
|
+
const renamedFrom = entries
|
|
1313
|
+
.filter((entry) => entry.from)
|
|
1314
|
+
.map((entry) => resolve(root, entry.from as string));
|
|
1315
|
+
if (dirty.length === 0) return null;
|
|
1316
|
+
|
|
1317
|
+
const mine = new Set(packFiles.map((f) => resolve(f)));
|
|
1318
|
+
const foreign = dirty.filter((path) => !mine.has(path));
|
|
1319
|
+
// Staged alongside, so a rename lands whole.
|
|
1320
|
+
const toStage = [...dirty, ...renamedFrom];
|
|
1321
|
+
if (foreign.length > 0) {
|
|
1322
|
+
return { error: [
|
|
1323
|
+
`The version names a commit, and this tree has uncommitted changes outside the policy files.`,
|
|
1324
|
+
...foreign.slice(0, 8).map((path) => ` ${relative(root, path)}`),
|
|
1325
|
+
...(foreign.length > 8 ? [` …and ${foreign.length - 8} more`] : []),
|
|
1326
|
+
"Those are not this command's to commit. Commit or stash them, then publish again —",
|
|
1327
|
+
"or name the version yourself: --version <version>.",
|
|
1328
|
+
] };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const problem = identity();
|
|
1332
|
+
if (problem) return { error: problem };
|
|
1333
|
+
git(["add", "--", ...toStage]);
|
|
1334
|
+
if (git(["commit", "-q", "-m", `publish ${id}`]) === null) {
|
|
1335
|
+
return { error: [
|
|
1336
|
+
"Could not commit the policy files.",
|
|
1337
|
+
"Commit by hand, or name the version yourself: --version <version>.",
|
|
1338
|
+
] };
|
|
719
1339
|
}
|
|
720
|
-
|
|
1340
|
+
const made = inferCommit(entryPath, outDir, packFiles);
|
|
1341
|
+
if (!made || made.dirty) return null;
|
|
1342
|
+
return {
|
|
1343
|
+
provenance: made,
|
|
1344
|
+
lines: [`Committed ${dirty.length} changed policy file${dirty.length === 1 ? "" : "s"}.`],
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* How many characters of a commit a version names.
|
|
1350
|
+
*
|
|
1351
|
+
* Twelve, not git's default seven. Seven collides in a repository with enough
|
|
1352
|
+
* objects — git itself lengthens the abbreviation as a repo grows — and a
|
|
1353
|
+
* version that stops being unique is worse than a long one, because two
|
|
1354
|
+
* artifacts would claim the same name. Twelve is short enough to read in a
|
|
1355
|
+
* listing and long enough that no real pack repository will reach it.
|
|
1356
|
+
*
|
|
1357
|
+
* The FULL sha is still recorded as `commit` in the manifest, so nothing is
|
|
1358
|
+
* lost by abbreviating the version.
|
|
1359
|
+
*/
|
|
1360
|
+
export const VERSION_SHA_LENGTH = 12;
|
|
1361
|
+
|
|
1362
|
+
/** The version a commit names. */
|
|
1363
|
+
export function versionFromCommit(sha: string): string {
|
|
1364
|
+
return sha.trim().toLowerCase().slice(0, VERSION_SHA_LENGTH);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* The version this publish should carry: the commit it was built from.
|
|
1369
|
+
*
|
|
1370
|
+
* A version answers ONE question here — which source produced these bytes —
|
|
1371
|
+
* and the commit answers it exactly, with nothing to decide and nothing to
|
|
1372
|
+
* count. Nothing is read from the repository's releases: the version is a
|
|
1373
|
+
* property of the tree in front of you, so a fresh clone, an air-gapped
|
|
1374
|
+
* machine and a second publisher all compute the same answer for the same
|
|
1375
|
+
* source, and none of them has to ask GitHub what happened before.
|
|
1376
|
+
*
|
|
1377
|
+
* The costs are real and are the reason this is an explicit choice rather than
|
|
1378
|
+
* a default. A sha does not ORDER — `a1b2c3d` and `f9e8d7c` give no clue which
|
|
1379
|
+
* came first — so "am I on the newest?" is a question only the release list can
|
|
1380
|
+
* answer. `policies show <source> --releases` is where it is answered, newest
|
|
1381
|
+
* first, which is why that surface exists.
|
|
1382
|
+
*
|
|
1383
|
+
* What it REFUSES rather than approximates, all for the same reason: the
|
|
1384
|
+
* version claims to name a commit, so it must not be minted where that claim
|
|
1385
|
+
* would be false.
|
|
1386
|
+
*
|
|
1387
|
+
* - **No git.** There is no commit to name. Publishing from a directory that
|
|
1388
|
+
* is not a checkout used to work; under this scheme it cannot, and saying so
|
|
1389
|
+
* beats inventing a number that names nothing.
|
|
1390
|
+
* - **A dirty tree.** The bytes being published are not the bytes in that
|
|
1391
|
+
* commit, so the version — and `commit` in the manifest beside it — would
|
|
1392
|
+
* both point at source that does not contain them.
|
|
1393
|
+
* - **A sha that is not one.** No caller in the publish path produces one, so
|
|
1394
|
+
* this is a guard rather than a case; see the comment on it below.
|
|
1395
|
+
*
|
|
1396
|
+
* `--version` overrides all of it, and every refusal names it for that reason.
|
|
1397
|
+
*/
|
|
1398
|
+
export function versionForPublish(
|
|
1399
|
+
provenance: Provenance | null,
|
|
1400
|
+
): { version: string } | { error: string[] } {
|
|
1401
|
+
if (!provenance) {
|
|
1402
|
+
return {
|
|
1403
|
+
error: [
|
|
1404
|
+
"This pack is versioned by the commit it is built from, and this directory is not a git checkout.",
|
|
1405
|
+
" git init && git add -A && git commit -m \"first policies\"",
|
|
1406
|
+
"Then publish again. To publish without git, name the version yourself: --version <version>.",
|
|
1407
|
+
],
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
// A source that is not in HEAD at all — ignored by `.gitignore`, or never
|
|
1411
|
+
// committed — gets its own refusal. The generic "this tree has uncommitted
|
|
1412
|
+
// changes" is wrong for it and its remedy (`git add -A && git commit`) does
|
|
1413
|
+
// not work: an ignored file stays ignored, so the advice would loop.
|
|
1414
|
+
if (provenance.unpublishable) {
|
|
1415
|
+
const { offending, why } = provenance.unpublishable;
|
|
1416
|
+
return {
|
|
1417
|
+
error: [
|
|
1418
|
+
why === "missing"
|
|
1419
|
+
? "The version names a commit, and these policy files are not in it — ignored, or never committed:"
|
|
1420
|
+
: "The version names a commit, and these policy files differ from it:",
|
|
1421
|
+
...offending.slice(0, 8).map((path) => ` ${path}`),
|
|
1422
|
+
...(offending.length > 8 ? [` …and ${offending.length - 8} more`] : []),
|
|
1423
|
+
why === "missing"
|
|
1424
|
+
? "They would be bundled into the artifact while the commit it names does not contain them."
|
|
1425
|
+
: "They would be bundled as they are now, not as that commit holds them.",
|
|
1426
|
+
why === "missing"
|
|
1427
|
+
? "Commit them — `git add -f` if they are ignored — or name the version yourself: --version <version>."
|
|
1428
|
+
: "Commit them, or name the version yourself: --version <version>.",
|
|
1429
|
+
],
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
if (provenance.dirty) {
|
|
1433
|
+
return {
|
|
1434
|
+
error: [
|
|
1435
|
+
`The version names commit ${versionFromCommit(provenance.sha)}, and this tree has uncommitted changes —`,
|
|
1436
|
+
"so the bytes about to be published are not the bytes in that commit.",
|
|
1437
|
+
" git add -A && git commit -m \"...\"",
|
|
1438
|
+
"Then publish again, or name the version yourself: --version <version>.",
|
|
1439
|
+
],
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
// Same refusal as the other two, from the other direction: a version that is
|
|
1443
|
+
// not an abbreviated sha names no commit either. `inferCommit` only ever hands
|
|
1444
|
+
// over forty lower-case hex, so nothing in the publish path reaches this —
|
|
1445
|
+
// which is exactly why it is worth stating. Truncating a non-sha silently
|
|
1446
|
+
// yields a `version` the manifest validator rejects much later, or an EMPTY
|
|
1447
|
+
// one, and the publish then fails somewhere that says nothing about the sha.
|
|
1448
|
+
const sha = provenance.sha.trim().toLowerCase();
|
|
1449
|
+
if (!PACK_COMMIT_RE.test(sha)) {
|
|
1450
|
+
return {
|
|
1451
|
+
error: [
|
|
1452
|
+
`The version names the commit it was built from, and ${JSON.stringify(provenance.sha.slice(0, 64))} is not one.`,
|
|
1453
|
+
"Name the version yourself instead: --version <version>.",
|
|
1454
|
+
],
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
return { version: versionFromCommit(sha) };
|
|
721
1458
|
}
|
|
722
1459
|
|
|
723
1460
|
/**
|
|
@@ -990,6 +1727,7 @@ function bundleEntry(
|
|
|
990
1727
|
*/
|
|
991
1728
|
const PUBLISH_VALUE_FLAGS = new Set([
|
|
992
1729
|
"--repo", "--version", "--id", "--tag", "--notes", "--out", "--effect", "--entry", "--init",
|
|
1730
|
+
"--commit",
|
|
993
1731
|
]);
|
|
994
1732
|
function publishEntryArg(rest: string[]): string | undefined {
|
|
995
1733
|
const consumed = new Set<number>();
|
|
@@ -1001,6 +1739,19 @@ function publishEntryArg(rest: string[]): string | undefined {
|
|
|
1001
1739
|
|
|
1002
1740
|
// ── publish ───────────────────────────────────────────────────────────────
|
|
1003
1741
|
|
|
1742
|
+
/**
|
|
1743
|
+
* The three attachments a release needs before anybody can install it. One list,
|
|
1744
|
+
* because `publish` uploads exactly these and `--releases` marks a release
|
|
1745
|
+
* `incomplete` for missing any of them — two hand-written copies of the same
|
|
1746
|
+
* three names would let the listing call a release installable that the
|
|
1747
|
+
* installer then 404s on.
|
|
1748
|
+
*/
|
|
1749
|
+
const INSTALLABLE_PACK_ASSETS: readonly string[] = [
|
|
1750
|
+
PACK_MANIFEST_ASSET,
|
|
1751
|
+
PACK_ENTRY_ASSET,
|
|
1752
|
+
PACK_CHECKSUMS_ASSET,
|
|
1753
|
+
];
|
|
1754
|
+
|
|
1004
1755
|
const GITHUB_API = process.env.FAILPROOFAI_GITHUB_API ?? "https://api.github.com";
|
|
1005
1756
|
const GITHUB_UPLOADS = process.env.FAILPROOFAI_GITHUB_UPLOADS ?? "https://uploads.github.com";
|
|
1006
1757
|
/** Where the git remote lives, so the push path can be tested against a real
|
|
@@ -1034,15 +1785,22 @@ function githubToken(): string | null {
|
|
|
1034
1785
|
}
|
|
1035
1786
|
}
|
|
1036
1787
|
|
|
1788
|
+
/**
|
|
1789
|
+
* `token` is nullable because ONE caller has no credential to offer: listing a
|
|
1790
|
+
* public repository's releases is a read anybody can do, and requiring
|
|
1791
|
+
* `gh auth login` to look at what a pack has published would be a worse answer
|
|
1792
|
+
* than the 60-per-hour anonymous rate limit. Every WRITE still passes a token —
|
|
1793
|
+
* they are unreachable without one, since `publish` fails before it gets here.
|
|
1794
|
+
*/
|
|
1037
1795
|
async function gh(
|
|
1038
1796
|
url: string,
|
|
1039
|
-
token: string,
|
|
1797
|
+
token: string | null,
|
|
1040
1798
|
init: { method?: string; body?: BodyInit; contentType?: string } = {},
|
|
1041
1799
|
): Promise<{ status: number; json: Record<string, unknown> | null; text: string }> {
|
|
1042
1800
|
const response = await fetch(url, {
|
|
1043
1801
|
method: init.method ?? "GET",
|
|
1044
1802
|
headers: {
|
|
1045
|
-
Authorization: `Bearer ${token}
|
|
1803
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
1046
1804
|
Accept: "application/vnd.github+json",
|
|
1047
1805
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
1048
1806
|
"User-Agent": "failproofai",
|
|
@@ -1086,22 +1844,25 @@ function ghError(res: { status: number; json: Record<string, unknown> | null; te
|
|
|
1086
1844
|
* and stop. That is what `pack build` now resolves to, so the old command keeps
|
|
1087
1845
|
* working and means the same thing.
|
|
1088
1846
|
*
|
|
1089
|
-
* Two failures the manual flow let through silently, both refused
|
|
1847
|
+
* Two failures the manual flow let through silently, both refused BEFORE
|
|
1848
|
+
* anything is created or uploaded:
|
|
1090
1849
|
* - a tag that does not describe the manifest version, which installs and then
|
|
1091
1850
|
* reports a version matching no URL;
|
|
1092
|
-
* -
|
|
1093
|
-
* anonymous HTTPS with no credential to offer.
|
|
1851
|
+
* - an existing private repository, which publishes to nobody, because
|
|
1852
|
+
* installs are anonymous HTTPS with no credential to offer. `--allow-private`
|
|
1853
|
+
* is for an author who genuinely wants that and will distribute the artifact
|
|
1854
|
+
* some other way; a repository publish CREATES is public by construction, so
|
|
1855
|
+
* the refusal only ever fires on reuse.
|
|
1094
1856
|
*/
|
|
1095
1857
|
/**
|
|
1096
1858
|
* The two things `publish` cannot work out on its own, asked instead of
|
|
1097
1859
|
* demanded as flags.
|
|
1098
1860
|
*
|
|
1099
1861
|
* Everything else it already derives: the policy files by content, the version
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
* never be reused.
|
|
1862
|
+
* from the commit the tree sits at, the id from the repository, the credential
|
|
1863
|
+
* from the environment. What is left is genuinely a question — WHERE this
|
|
1864
|
+
* should live, when the folder has no git remote naming somewhere — and a
|
|
1865
|
+
* confirmation, because publishing is public and cannot be taken back.
|
|
1105
1866
|
*
|
|
1106
1867
|
* Only ever on a TTY. A pipe, a CI job or a test gets the old behaviour: flags
|
|
1107
1868
|
* decide, and a missing `--repo` still means "build the assets and stop"
|
|
@@ -1112,12 +1873,16 @@ async function askWhereToPublish(
|
|
|
1112
1873
|
io: { stdin: TTYIn; stdout: TTYOut },
|
|
1113
1874
|
): Promise<string | null> {
|
|
1114
1875
|
const answer = await promptText({
|
|
1115
|
-
// The suggestion
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1876
|
+
// The suggestion is NOT repeated in the hint. `promptText` renders a
|
|
1877
|
+
// `defaultValue` as `↵ <value>` ahead of whatever hint it is given, so
|
|
1878
|
+
// spelling it here too printed it twice — and the half that says which key
|
|
1879
|
+
// takes it belongs to every prompt with a default, not to this one.
|
|
1119
1880
|
message: "Where should this publish?",
|
|
1120
|
-
|
|
1881
|
+
// Kept short deliberately: `promptText` truncates to ONE physical row, and
|
|
1882
|
+
// the whole line is the message plus `↵ <owner>/<repo>` plus this. At 80
|
|
1883
|
+
// columns the longer spelling — "created if it does not exist" — pushed
|
|
1884
|
+
// itself off the end, so the fact was written and never read.
|
|
1885
|
+
hint: "created if missing",
|
|
1121
1886
|
defaultValue: suggestion,
|
|
1122
1887
|
validate: (v) => {
|
|
1123
1888
|
const t = v.trim();
|
|
@@ -1188,14 +1953,27 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1188
1953
|
if (answered === null) return ok(["Nothing was published."]);
|
|
1189
1954
|
repo = answered;
|
|
1190
1955
|
}
|
|
1191
|
-
// A tag on HEAD is somebody SAYING what this release is, so it wins over
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
|
|
1195
|
-
|
|
1956
|
+
// A tag on HEAD is somebody SAYING what this release is, so it wins over the
|
|
1957
|
+
// sha, which only reports one. Both are properties of the tree, so neither
|
|
1958
|
+
// waits on the repository being known — see the block below the usage text.
|
|
1959
|
+
// The set that will actually be BUNDLED — every discovered policy file, not
|
|
1960
|
+
// just the entry, and everything those files IMPORT, which the bundler inlines
|
|
1961
|
+
// and discovery never names. Both the tag path and the sha path are handed it,
|
|
1962
|
+
// so neither can accept a version for bytes the commit does not hold.
|
|
1963
|
+
const bundledSources = withLocalImports(
|
|
1964
|
+
discovered.length > 0 ? discovered : entry ? [entry] : [],
|
|
1965
|
+
);
|
|
1966
|
+
let version =
|
|
1967
|
+
flag("version") ?? (entry ? inferTaggedVersion(entry, bundledSources) : null) ?? undefined;
|
|
1968
|
+
let versionFromSha = false;
|
|
1196
1969
|
// The id and the repo are usually the same words, and requiring both is asking
|
|
1197
1970
|
// the same question twice. Either one alone answers for the other.
|
|
1198
1971
|
const dryRun = rest.includes("--dry-run") || !repo;
|
|
1972
|
+
// The way past the private-repository refusal below, for somebody who really
|
|
1973
|
+
// does want a pack nobody can `policies add` and will distribute the assets
|
|
1974
|
+
// another way. It buys a publish, never a working install, so the success
|
|
1975
|
+
// message still says so.
|
|
1976
|
+
const allowPrivate = rest.includes("--allow-private");
|
|
1199
1977
|
// A dry run publishes nothing, so the id only has to name the artifact it
|
|
1200
1978
|
// writes — and refusing to build one because the folder has no git remote
|
|
1201
1979
|
// yet blocked the exact thing a dry run is for: looking at the pack BEFORE
|
|
@@ -1222,15 +2000,53 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1222
2000
|
"Outside one, name them:",
|
|
1223
2001
|
" [--id <publisher/name>] [--tag <tag>] [--notes <text>]",
|
|
1224
2002
|
" [--out <dir>] [--effect enforce|observe] [--dry-run]",
|
|
2003
|
+
" [--allow-private] (a private repo installs for nobody; this says so)",
|
|
1225
2004
|
"",
|
|
1226
2005
|
"With no --repo it writes the three release assets and stops.",
|
|
1227
2006
|
]);
|
|
1228
2007
|
}
|
|
1229
2008
|
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
2009
|
+
// The version is a property of the TREE, not of the repository's history, so
|
|
2010
|
+
// it is settled here — before the credential, before the repo, and identically
|
|
2011
|
+
// for a dry run. Nothing is counted and nothing is asked of GitHub.
|
|
2012
|
+
//
|
|
2013
|
+
// Provenance is read ONCE, because it decides the version AND is recorded in
|
|
2014
|
+
// the manifest beside it. Reading it twice is how the two come to disagree.
|
|
2015
|
+
//
|
|
2016
|
+
// The output directory is settled first only so the dirty read can skip it:
|
|
2017
|
+
// it is where the LAST run's assets are sitting, and inside the checkout by
|
|
2018
|
+
// default. See `skipOutDir`.
|
|
2019
|
+
const outDirEarly = resolve(flag("out") ?? "dist-pack");
|
|
2020
|
+
let provenance = entry ? inferCommit(entry, outDirEarly, bundledSources) : null;
|
|
2021
|
+
const gitLines: string[] = [];
|
|
2022
|
+
if (!version && entry && interactive) {
|
|
2023
|
+
// Carry the git work rather than handing it back. TTY only: a commit made
|
|
2024
|
+
// in CI exists on the runner and nowhere else, so the version would name
|
|
2025
|
+
// provenance nobody can resolve, and `--version` is the answer there.
|
|
2026
|
+
const settled = settleGitState(
|
|
2027
|
+
entry,
|
|
2028
|
+
provenance,
|
|
2029
|
+
discovered.length > 0 ? discovered : [entry],
|
|
2030
|
+
id,
|
|
2031
|
+
outDirEarly,
|
|
2032
|
+
);
|
|
2033
|
+
if (settled && "error" in settled) return fail(settled.error);
|
|
2034
|
+
if (settled) {
|
|
2035
|
+
provenance = settled.provenance;
|
|
2036
|
+
gitLines.push(...settled.lines);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
if (!version) {
|
|
2040
|
+
const resolved = versionForPublish(provenance);
|
|
2041
|
+
if ("error" in resolved) return fail(resolved.error);
|
|
2042
|
+
version = resolved.version;
|
|
2043
|
+
versionFromSha = true;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// Check an EXPLICIT tag against the version before anything reaches the
|
|
2047
|
+
// network. A tag the user typed can disagree with the version, and refusing
|
|
2048
|
+
// it here is what stops a doomed publish from first creating a repository
|
|
2049
|
+
// for itself.
|
|
1234
2050
|
const explicitTag = flag("tag");
|
|
1235
2051
|
if (explicitTag && version && !packTagMatchesVersion(explicitTag, version)) {
|
|
1236
2052
|
return fail([
|
|
@@ -1241,12 +2057,9 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1241
2057
|
]);
|
|
1242
2058
|
}
|
|
1243
2059
|
|
|
1244
|
-
// The version
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
// the reverse of the old order, where building first avoided authenticating
|
|
1248
|
-
// for a pack that does not load. That trade is still paid: the build runs
|
|
1249
|
-
// immediately after, before anything is created or uploaded.
|
|
2060
|
+
// The version is already known, so the credential and the repository are
|
|
2061
|
+
// needed only to publish. The build still runs immediately after, before
|
|
2062
|
+
// anything is created or uploaded.
|
|
1250
2063
|
let token: string | null = null;
|
|
1251
2064
|
let created = false;
|
|
1252
2065
|
let repoInfo: Awaited<ReturnType<typeof gh>> | null = null;
|
|
@@ -1263,25 +2076,34 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1263
2076
|
if ("error" in ensured) return fail(ensured.error);
|
|
1264
2077
|
created = ensured.created;
|
|
1265
2078
|
repoInfo = ensured.info;
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
2079
|
+
// A private repository is not a smaller audience, it is no audience:
|
|
2080
|
+
// `fetchBytes` in pack-store.ts sends no Authorization header at all, by
|
|
2081
|
+
// design, so every install 404s. This used to upload all three assets, exit
|
|
2082
|
+
// 0, print `policies add <repo>` and merely append a warning underneath —
|
|
2083
|
+
// reporting success for a release nobody could install and leaving assets
|
|
2084
|
+
// behind that advertise that dead route. Refuse HERE, before the release
|
|
2085
|
+
// exists and before anything is built, so there is nothing to clean up.
|
|
2086
|
+
// A repository this command creates is made public, so this only ever
|
|
2087
|
+
// fires on reuse of one that was already private.
|
|
2088
|
+
if (repoInfo.json?.private === true && !allowPrivate) {
|
|
2089
|
+
return fail([
|
|
2090
|
+
`${repo} is PRIVATE, so nothing was published.`,
|
|
2091
|
+
"Installing a pack fetches its release assets over anonymous HTTPS with no",
|
|
2092
|
+
"credential to offer, so every install would 404 — including your own from",
|
|
2093
|
+
"another machine. Publishing would only have attached three assets",
|
|
2094
|
+
"advertising a route nobody can take.",
|
|
2095
|
+
`Make it public and re-run: gh repo edit ${repo} --visibility public`,
|
|
2096
|
+
"Or, to keep it private and hand the pack over some other way, publish it",
|
|
2097
|
+
"with --allow-private.",
|
|
2098
|
+
]);
|
|
1269
2099
|
}
|
|
1270
2100
|
}
|
|
1271
|
-
// A dry run has nothing to count against, so it reports the first version. It
|
|
1272
|
-
// says so rather than implying the number is settled.
|
|
1273
|
-
if (!version) {
|
|
1274
|
-
version = "1.0.0";
|
|
1275
|
-
versionCounted = true;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
2101
|
const tag = flag("tag") ?? version;
|
|
1279
2102
|
|
|
1280
2103
|
// One artifact, however many files it was written across. `build` refuses an
|
|
1281
2104
|
// entry with relative imports because only the entry is digest-pinned — so
|
|
1282
2105
|
// the fix is to make it ONE file here, not to send the author away to set up
|
|
1283
2106
|
// a bundler for a step this tool already performs for its own pack.
|
|
1284
|
-
const outDirEarly = resolve(flag("out") ?? "dist-pack");
|
|
1285
2107
|
let entryToBuild = entry;
|
|
1286
2108
|
const needsBundle =
|
|
1287
2109
|
discovered.length > 1 ||
|
|
@@ -1295,7 +2117,19 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1295
2117
|
entryToBuild = bundled.path;
|
|
1296
2118
|
}
|
|
1297
2119
|
|
|
1298
|
-
const built = await build([
|
|
2120
|
+
const built = await build([
|
|
2121
|
+
entryToBuild,
|
|
2122
|
+
"--id", id,
|
|
2123
|
+
"--version", version,
|
|
2124
|
+
// NOT when the tree is dirty, even though `--version` let the publish
|
|
2125
|
+
// through. `commit` claims these bytes came from that commit, and on a
|
|
2126
|
+
// dirty tree they did not — recording it anyway put the exact false claim
|
|
2127
|
+
// the dirty refusal exists to prevent through the door right next to it,
|
|
2128
|
+
// reachable by taking the escape hatch that refusal recommends.
|
|
2129
|
+
...(provenance && !provenance.dirty ? ["--commit", provenance.sha] : []),
|
|
2130
|
+
...outFlagFrom(rest),
|
|
2131
|
+
...effectFlagFrom(rest),
|
|
2132
|
+
]);
|
|
1299
2133
|
if (built.exitCode !== 0) return built;
|
|
1300
2134
|
|
|
1301
2135
|
const outDir = outDirEarly;
|
|
@@ -1306,6 +2140,8 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1306
2140
|
: [];
|
|
1307
2141
|
if (dryRun) {
|
|
1308
2142
|
return ok([
|
|
2143
|
+
...gitLines,
|
|
2144
|
+
...(gitLines.length ? [""] : []),
|
|
1309
2145
|
...bundleNote,
|
|
1310
2146
|
...built.lines.slice(0, 4),
|
|
1311
2147
|
"",
|
|
@@ -1315,10 +2151,9 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1315
2151
|
"Nothing was published: name a repository to release it on.",
|
|
1316
2152
|
// Just the repository. It used to spell out the entry file and the
|
|
1317
2153
|
// version too — naming ONE of the files it had that moment finished
|
|
1318
|
-
// bundling, and pinning a version it works out
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
// it does not need.
|
|
2154
|
+
// bundling, and pinning a version it works out for itself. Both
|
|
2155
|
+
// were wrong the moment they were printed, and both taught the
|
|
2156
|
+
// reader that publishing needs flags it does not need.
|
|
1322
2157
|
" failproofai publish --repo <owner>/<repo>",
|
|
1323
2158
|
]),
|
|
1324
2159
|
]);
|
|
@@ -1338,8 +2173,9 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1338
2173
|
}
|
|
1339
2174
|
|
|
1340
2175
|
|
|
1341
|
-
//
|
|
1342
|
-
//
|
|
2176
|
+
// Only ever true when --allow-private was passed — the refusal above returns
|
|
2177
|
+
// otherwise — so this is the warning that goes with a publish the author
|
|
2178
|
+
// asked for anyway, not a discovery made too late to act on.
|
|
1343
2179
|
const isPrivate = repoInfo?.json?.private === true;
|
|
1344
2180
|
|
|
1345
2181
|
// Reuse a release on this tag rather than failing on it. Re-publishing a
|
|
@@ -1356,7 +2192,15 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1356
2192
|
body: JSON.stringify({
|
|
1357
2193
|
tag_name: tag,
|
|
1358
2194
|
name: `${id} ${version}`,
|
|
1359
|
-
body
|
|
2195
|
+
// The body is what `policies show --releases` reads. Putting the counts
|
|
2196
|
+
// and the commit HERE rather than in the manifest is what makes that
|
|
2197
|
+
// command ONE request: the release list returns bodies inline, while
|
|
2198
|
+
// the counts otherwise cost a manifest download per release and scale
|
|
2199
|
+
// with history. It doubles as what a human sees on the releases page,
|
|
2200
|
+
// which is the second reason to write it rather than a marker nobody
|
|
2201
|
+
// reads. `--notes` still wins outright — an author who wrote release
|
|
2202
|
+
// notes gets their release notes.
|
|
2203
|
+
body: flag("notes") ?? releaseBody(id, version, built.meta),
|
|
1360
2204
|
// A prerelease is invisible to `releases/latest`, which is how a tagless
|
|
1361
2205
|
// `policies add owner/repo` resolves a version — so publishing one would
|
|
1362
2206
|
// make the pack installable only by people who already knew its tag.
|
|
@@ -1370,7 +2214,7 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1370
2214
|
releaseId = created.json.id;
|
|
1371
2215
|
}
|
|
1372
2216
|
|
|
1373
|
-
const assets =
|
|
2217
|
+
const assets = INSTALLABLE_PACK_ASSETS;
|
|
1374
2218
|
const uploaded: string[] = [];
|
|
1375
2219
|
const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`, auth);
|
|
1376
2220
|
const already = Array.isArray(listed.json)
|
|
@@ -1405,24 +2249,35 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
1405
2249
|
}
|
|
1406
2250
|
|
|
1407
2251
|
const lines = [
|
|
2252
|
+
// What was done to the user's own directory, before what was done to
|
|
2253
|
+
// GitHub. A side effect on somebody's working tree is never silent, even
|
|
2254
|
+
// when it is the side effect they wanted.
|
|
2255
|
+
...gitLines,
|
|
2256
|
+
...(gitLines.length ? [""] : []),
|
|
1408
2257
|
...bundleNote,
|
|
1409
2258
|
...(created ? [`Created ${repo} (public).`] : []),
|
|
1410
2259
|
`Published ${id}@${version} to ${repo} at tag ${tag}.` +
|
|
1411
|
-
(
|
|
2260
|
+
(versionFromSha ? " That names the commit it was built from." : ""),
|
|
1412
2261
|
` ${assets.length} assets attached`,
|
|
1413
2262
|
"",
|
|
1414
|
-
|
|
1415
|
-
`
|
|
1416
|
-
|
|
2263
|
+
// The install lines are the whole point of the success message, and on a
|
|
2264
|
+
// private repository they are a lie — `policies add` 404s there. Print the
|
|
2265
|
+
// warning INSTEAD of them, never underneath them: a reader who copies the
|
|
2266
|
+
// first command they see must not be copying one that cannot work.
|
|
2267
|
+
...(isPrivate
|
|
2268
|
+
? [
|
|
2269
|
+
`WARNING: ${repo} is PRIVATE, so nobody can install this — you asked for`,
|
|
2270
|
+
"that with --allow-private. Installs are anonymous HTTPS with no credential",
|
|
2271
|
+
"to offer, so every `failproofai policies add` will 404. Distribute the",
|
|
2272
|
+
"three assets yourself, or make it public:",
|
|
2273
|
+
` gh repo edit ${repo} --visibility public`,
|
|
2274
|
+
]
|
|
2275
|
+
: [
|
|
2276
|
+
"Anyone can now install it:",
|
|
2277
|
+
` failproofai policies add ${repo}`,
|
|
2278
|
+
` failproofai policies show ${repo} (look first, without running it)`,
|
|
2279
|
+
]),
|
|
1417
2280
|
];
|
|
1418
|
-
if (isPrivate) {
|
|
1419
|
-
lines.push(
|
|
1420
|
-
"",
|
|
1421
|
-
`WARNING: ${repo} is PRIVATE, so nobody can install this.`,
|
|
1422
|
-
"Installs are anonymous HTTPS with no credential to offer — every one will 404.",
|
|
1423
|
-
`Make it public: gh repo edit ${repo} --visibility public`,
|
|
1424
|
-
);
|
|
1425
|
-
}
|
|
1426
2281
|
return ok(lines);
|
|
1427
2282
|
}
|
|
1428
2283
|
|
|
@@ -1575,6 +2430,10 @@ async function add(rest: string[]): Promise<PackCliResult> {
|
|
|
1575
2430
|
const picked = await pickFromSource(resolvedSource!, io);
|
|
1576
2431
|
if (picked === null) return ok(["Nothing installed."]);
|
|
1577
2432
|
selection.only = picked;
|
|
2433
|
+
// The picker REPLACES. What is ticked is what should be on, so a flag's
|
|
2434
|
+
// additive reading must not leak into it — otherwise unticking something
|
|
2435
|
+
// here could never turn it off.
|
|
2436
|
+
selection.merge = false;
|
|
1578
2437
|
// An empty pick is a real answer — install the pack, enable none of it —
|
|
1579
2438
|
// but `{only: []}` is indistinguishable from "no selection" downstream, so
|
|
1580
2439
|
// it is carried as an explicit empty list the resolver can see.
|
|
@@ -1585,7 +2444,18 @@ async function add(rest: string[]): Promise<PackCliResult> {
|
|
|
1585
2444
|
const result = await addPack(resolvedSource!, selection);
|
|
1586
2445
|
const lines = [
|
|
1587
2446
|
...(result.replaced?.length
|
|
1588
|
-
? [
|
|
2447
|
+
? [
|
|
2448
|
+
// What was OBSERVED, and then the inference — not the inference
|
|
2449
|
+
// stated as fact. Identical artifact bytes are the only evidence
|
|
2450
|
+
// there is, and they cannot tell a renamed pack from a second pack
|
|
2451
|
+
// one repository builds from the same source. Absorbing is the
|
|
2452
|
+
// right default (a publisher renaming a set must not reset everyone
|
|
2453
|
+
// who narrowed it), but the record that goes away is one the user
|
|
2454
|
+
// installed on purpose, so the line has to say what to do when the
|
|
2455
|
+
// guess was wrong instead of asserting it was right.
|
|
2456
|
+
`Replaced ${result.replaced.join(", ")} — same artifact, so it was taken as this pack renamed, and your selection was carried over.`,
|
|
2457
|
+
`If ${result.replaced.length > 1 ? "those are separate packs" : "that is a separate pack"}, re-add ${result.replaced.join(", ")} to restore ${result.replaced.length > 1 ? "them" : "it"}.`,
|
|
2458
|
+
]
|
|
1589
2459
|
: []),
|
|
1590
2460
|
result.resolvedFromLatest
|
|
1591
2461
|
? `Installed ${result.id}@${result.version} from ${result.source} (newest release; pinned to ${result.tag})`
|
|
@@ -1599,6 +2469,10 @@ async function add(rest: string[]): Promise<PackCliResult> {
|
|
|
1599
2469
|
defaults: "the pack's defaults",
|
|
1600
2470
|
selected: "your selection",
|
|
1601
2471
|
carried: "your existing selection",
|
|
2472
|
+
// Named differently from `selected` on purpose: it is the one outcome
|
|
2473
|
+
// where the set is LARGER than what was asked for, and a reader who does
|
|
2474
|
+
// not see that said out loud will read the number as their whole answer.
|
|
2475
|
+
added: "what you added, plus what was already on",
|
|
1602
2476
|
all: "everything in the pack",
|
|
1603
2477
|
}[result.selection];
|
|
1604
2478
|
// `summarise([])` is the empty string, which would print a line ending in a
|
|
@@ -1634,9 +2508,29 @@ async function add(rest: string[]): Promise<PackCliResult> {
|
|
|
1634
2508
|
function remove(rest: string[]): PackCliResult {
|
|
1635
2509
|
const id = rest[0];
|
|
1636
2510
|
if (!id) return fail(["Usage: failproofai policies remove <publisher/name>"]);
|
|
1637
|
-
|
|
2511
|
+
// Reported with the id the MACHINE holds, not the spelling that was typed:
|
|
2512
|
+
// `remove FAILPROOFAI/POLICIES` succeeding and echoing that back teaches a
|
|
2513
|
+
// name nothing else in the product uses.
|
|
2514
|
+
let removedId: string | null;
|
|
2515
|
+
try {
|
|
2516
|
+
removedId = removePack(id);
|
|
2517
|
+
} catch (err) {
|
|
2518
|
+
// Two installed packs answer to one loosely-typed name. Refused rather
|
|
2519
|
+
// than guessed at — the loose match exists so a remembered name works, and
|
|
2520
|
+
// uninstalling something the user did not name is a worse outcome than
|
|
2521
|
+
// asking them to be exact.
|
|
2522
|
+
if (err instanceof AmbiguousPackId) {
|
|
2523
|
+
return fail([
|
|
2524
|
+
`${id} matches ${err.candidates.length} installed packs:`,
|
|
2525
|
+
...err.candidates.map((candidate) => ` ${candidate}`),
|
|
2526
|
+
"Name one of them exactly.",
|
|
2527
|
+
]);
|
|
2528
|
+
}
|
|
2529
|
+
throw err;
|
|
2530
|
+
}
|
|
2531
|
+
if (!removedId) return fail([`No installed pack with id ${id}`]);
|
|
1638
2532
|
return ok([
|
|
1639
|
-
`Removed ${
|
|
2533
|
+
`Removed ${removedId}. Its policies stop enforcing now; re-add it any time with the same command.`,
|
|
1640
2534
|
]);
|
|
1641
2535
|
}
|
|
1642
2536
|
|
|
@@ -1649,6 +2543,204 @@ function remove(rest: string[]): PackCliResult {
|
|
|
1649
2543
|
* downloading or importing the entry artifact, so looking at a stranger's pack
|
|
1650
2544
|
* cannot run a stranger's code.
|
|
1651
2545
|
*/
|
|
2546
|
+
/**
|
|
2547
|
+
* `failproofai policies show <owner>/<repo> --releases` — every release a pack
|
|
2548
|
+
* has published, and which one is on this machine.
|
|
2549
|
+
*
|
|
2550
|
+
* ONE request. The obvious implementation downloads each release's manifest to
|
|
2551
|
+
* count its policies, which costs a request per release and gets slower the
|
|
2552
|
+
* longer a pack has existed; instead `publish` writes those counts into the
|
|
2553
|
+
* release BODY, and `GET /releases` returns bodies inline. See `releaseBody`.
|
|
2554
|
+
*
|
|
2555
|
+
* A LISTING, and never an install path. Installs construct their URLs from
|
|
2556
|
+
* owner/repo/tag and discover nothing, which is what leaves no index to poison
|
|
2557
|
+
* — this reads the API because "what else is there?" is a question that has no
|
|
2558
|
+
* answer without one, and the worst a wrong answer here can do is print a row.
|
|
2559
|
+
* Nothing on this screen is trusted: the counts and the commit are
|
|
2560
|
+
* publisher-controlled text, shown as claims, while what a machine actually
|
|
2561
|
+
* installs still comes from the digest-pinned manifest.
|
|
2562
|
+
*/
|
|
2563
|
+
async function listReleases(source: string): Promise<PackCliResult> {
|
|
2564
|
+
const opts = optsFor(process.stdout);
|
|
2565
|
+
let spec;
|
|
2566
|
+
try {
|
|
2567
|
+
spec = parsePackSpec(source);
|
|
2568
|
+
} catch (err) {
|
|
2569
|
+
return fail([err instanceof Error ? err.message : String(err)]);
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// Best-effort: a token raises the rate limit from 60/hour to 5000 and lets a
|
|
2573
|
+
// private repo answer at all. Its absence is not an error.
|
|
2574
|
+
const res = await gh(
|
|
2575
|
+
`${GITHUB_API}/repos/${spec.owner}/${spec.repo}/releases?per_page=100`,
|
|
2576
|
+
githubToken(),
|
|
2577
|
+
);
|
|
2578
|
+
if (res.status === 404) {
|
|
2579
|
+
return fail([
|
|
2580
|
+
`No repository at ${spec.owner}/${spec.repo}, or it is private and this machine has no credential for it.`,
|
|
2581
|
+
"A pack has to be public to install anyway — installs are anonymous HTTPS with no credential to offer.",
|
|
2582
|
+
]);
|
|
2583
|
+
}
|
|
2584
|
+
if (res.status === 403 && /rate limit/i.test(res.text)) {
|
|
2585
|
+
return fail([
|
|
2586
|
+
"GitHub rate-limited this listing.",
|
|
2587
|
+
"Sign in once with `gh auth login` (or set GITHUB_TOKEN) to raise the limit from 60 requests an hour to 5000.",
|
|
2588
|
+
]);
|
|
2589
|
+
}
|
|
2590
|
+
if (res.status >= 400 || !Array.isArray(res.json)) {
|
|
2591
|
+
return fail([`Could not list releases for ${spec.owner}/${spec.repo}: ${ghError(res)}`]);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
const raw = res.json as unknown as Array<{
|
|
2595
|
+
tag_name?: unknown; body?: unknown; published_at?: unknown; created_at?: unknown;
|
|
2596
|
+
prerelease?: unknown; draft?: unknown; assets?: unknown;
|
|
2597
|
+
}>;
|
|
2598
|
+
// Sorted HERE, by when each was PUBLISHED, rather than trusting the order the
|
|
2599
|
+
// API returned.
|
|
2600
|
+
//
|
|
2601
|
+
// GitHub orders this endpoint by `created_at`, and `created_at` on a release
|
|
2602
|
+
// is the date of the COMMIT its tag points at — not the moment the release
|
|
2603
|
+
// was made. Two releases cut from one commit therefore tie, and the tie broke
|
|
2604
|
+
// backwards: the pack repository's own listing put a release from 14:38 above
|
|
2605
|
+
// the one from 15:27, and the install hint below offered the older of the
|
|
2606
|
+
// two as the thing to install.
|
|
2607
|
+
//
|
|
2608
|
+
// That was survivable while versions sorted by themselves and is not now. A
|
|
2609
|
+
// sha carries no order, so this list is the ONLY place the question "which of
|
|
2610
|
+
// these is newest?" gets answered, and `publish --help` sends people here to
|
|
2611
|
+
// ask it.
|
|
2612
|
+
const when = (r: { published_at?: unknown; created_at?: unknown }): number => {
|
|
2613
|
+
for (const value of [r.published_at, r.created_at]) {
|
|
2614
|
+
if (typeof value !== "string") continue;
|
|
2615
|
+
const parsed = Date.parse(value);
|
|
2616
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
2617
|
+
}
|
|
2618
|
+
// A release carrying no usable date sinks rather than floats: it cannot be
|
|
2619
|
+
// shown as the newest thing on a claim it has not made.
|
|
2620
|
+
return Number.NEGATIVE_INFINITY;
|
|
2621
|
+
};
|
|
2622
|
+
const releases = [...raw].sort((a, b) => when(b) - when(a));
|
|
2623
|
+
if (releases.length === 0) {
|
|
2624
|
+
return ok(
|
|
2625
|
+
stack(
|
|
2626
|
+
title(`${spec.owner}/${spec.repo}`, "no releases", opts),
|
|
2627
|
+
emptyState(
|
|
2628
|
+
{
|
|
2629
|
+
what: "This repository has published no releases, so there is nothing to install.",
|
|
2630
|
+
hint: "Its author publishes one with:",
|
|
2631
|
+
cmd: "failproofai publish",
|
|
2632
|
+
},
|
|
2633
|
+
opts,
|
|
2634
|
+
),
|
|
2635
|
+
),
|
|
2636
|
+
);
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// Which of them is on THIS machine — the question somebody runs this to
|
|
2640
|
+
// answer. Read by pack id rather than by source string: a pack installed as
|
|
2641
|
+
// `github:o/r@v1` and one installed from the URL are the same pack, and
|
|
2642
|
+
// comparing the spellings would say they are not.
|
|
2643
|
+
const installedVersions = new Map<string, string>();
|
|
2644
|
+
try {
|
|
2645
|
+
for (const pack of readInstalledPacks().packs) installedVersions.set(pack.id.toLowerCase(), pack.version);
|
|
2646
|
+
} catch {
|
|
2647
|
+
/* an unreadable manifest costs the marker, not the listing */
|
|
2648
|
+
}
|
|
2649
|
+
const here = installedVersions.get(`${spec.owner}/${spec.repo}`.toLowerCase());
|
|
2650
|
+
|
|
2651
|
+
const rows: Array<string[] | { section: string }> = [];
|
|
2652
|
+
// The newest INSTALLABLE tag for the hint below, picked in this same pass —
|
|
2653
|
+
// GitHub returns releases newest-first. It used to be a second `find` over the
|
|
2654
|
+
// raw list, which disagreed with the loop twice: a release whose `tag_name`
|
|
2655
|
+
// was an empty string is skipped below but passed that `typeof === "string"`
|
|
2656
|
+
// test, so the hint read `policies add owner/repo@`, and a release carrying
|
|
2657
|
+
// none of the three assets was offered as the thing to install.
|
|
2658
|
+
let newest: string | undefined;
|
|
2659
|
+
for (const release of releases) {
|
|
2660
|
+
const tag = typeof release.tag_name === "string" ? release.tag_name : "";
|
|
2661
|
+
if (!tag) continue;
|
|
2662
|
+
const facts = parseReleaseBody(typeof release.body === "string" ? release.body : "");
|
|
2663
|
+
const when = typeof release.published_at === "string" ? release.published_at
|
|
2664
|
+
: typeof release.created_at === "string" ? release.created_at
|
|
2665
|
+
: "";
|
|
2666
|
+
// By NAME, not by count. Counting said "three attachments" where the claim
|
|
2667
|
+
// is "these three attachments", so any repository that ships three binaries
|
|
2668
|
+
// per release — most of them — read as installable, and one that attaches
|
|
2669
|
+
// nothing at all read as fine. Both then sent somebody to an install that
|
|
2670
|
+
// 404s on an asset that was never there.
|
|
2671
|
+
const attached = Array.isArray(release.assets)
|
|
2672
|
+
? new Set(
|
|
2673
|
+
(release.assets as Array<{ name?: unknown }>)
|
|
2674
|
+
.map((a) => (a && typeof a.name === "string" ? a.name : ""))
|
|
2675
|
+
.filter(Boolean),
|
|
2676
|
+
)
|
|
2677
|
+
: new Set<string>();
|
|
2678
|
+
const installable = INSTALLABLE_PACK_ASSETS.every((name) => attached.has(name));
|
|
2679
|
+
const flags = [
|
|
2680
|
+
release.draft === true ? "draft" : "",
|
|
2681
|
+
release.prerelease === true ? "prerelease" : "",
|
|
2682
|
+
installable ? "" : "incomplete",
|
|
2683
|
+
here && packTagMatchesVersion(tag, here) ? "installed" : "",
|
|
2684
|
+
].filter(Boolean).join(" · ");
|
|
2685
|
+
if (newest === undefined && installable && release.draft !== true) newest = tag;
|
|
2686
|
+
rows.push([
|
|
2687
|
+
tag,
|
|
2688
|
+
when ? relativeAge(when) : "—",
|
|
2689
|
+
facts.commit ? facts.commit.slice(0, 7) : "—",
|
|
2690
|
+
facts.policies === undefined ? "—" : String(facts.policies),
|
|
2691
|
+
facts.defaultOn === undefined ? "—" : String(facts.defaultOn),
|
|
2692
|
+
flags,
|
|
2693
|
+
]);
|
|
2694
|
+
}
|
|
2695
|
+
return ok(
|
|
2696
|
+
stack(
|
|
2697
|
+
title(`${spec.owner}/${spec.repo}`, `${rows.length} release${rows.length === 1 ? "" : "s"}`, opts),
|
|
2698
|
+
table(
|
|
2699
|
+
{ head: ["version", "published", "commit", "policies", "default", ""], rows },
|
|
2700
|
+
opts,
|
|
2701
|
+
),
|
|
2702
|
+
// `—` is load-bearing: it means "this release did not say", which is what
|
|
2703
|
+
// a release published before this format did, and what anybody else's
|
|
2704
|
+
// hand-made release does. Filling those in would cost a manifest download
|
|
2705
|
+
// each, and a listing that silently costs a hundred downloads is worse
|
|
2706
|
+
// than one with gaps in it.
|
|
2707
|
+
note("— means the release did not record it.", opts),
|
|
2708
|
+
nextStep(
|
|
2709
|
+
`failproofai policies add ${spec.owner}/${spec.repo}@${newest ?? "<tag>"}`,
|
|
2710
|
+
"Install a particular one with:",
|
|
2711
|
+
opts,
|
|
2712
|
+
),
|
|
2713
|
+
),
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
/** `2 hours ago`, `6 days ago`. Coarse on purpose — the question is "how stale",
|
|
2718
|
+
* and a timestamp to the second answers a question nobody asked. */
|
|
2719
|
+
function relativeAge(iso: string): string {
|
|
2720
|
+
const then = Date.parse(iso);
|
|
2721
|
+
if (!Number.isFinite(then)) return "—";
|
|
2722
|
+
const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
2723
|
+
// Each pair is "divide by this, and the result is measured in THAT" — so the
|
|
2724
|
+
// label belongs to the unit arrived at, never the one left behind. Written
|
|
2725
|
+
// the other way round it was off by a whole unit across the entire range: a
|
|
2726
|
+
// release from yesterday read `1 hour ago`, one from six months ago read
|
|
2727
|
+
// `5 weeks ago`, and 90 seconds read `just now`. The column exists to answer
|
|
2728
|
+
// "how stale is this pack", so an answer two units too fresh is worse than no
|
|
2729
|
+
// column at all.
|
|
2730
|
+
const units: Array<[number, string]> = [
|
|
2731
|
+
[60, "minute"], [60, "hour"], [24, "day"], [7, "week"], [4.35, "month"], [12, "year"],
|
|
2732
|
+
];
|
|
2733
|
+
let value = seconds;
|
|
2734
|
+
let label = "second";
|
|
2735
|
+
for (const [size, next] of units) {
|
|
2736
|
+
if (value < size) break;
|
|
2737
|
+
value = Math.floor(value / size);
|
|
2738
|
+
label = next;
|
|
2739
|
+
}
|
|
2740
|
+
if (label === "second" && value < 60) return value < 10 ? "just now" : `${value} seconds ago`;
|
|
2741
|
+
return `${value} ${label}${value === 1 ? "" : "s"} ago`;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
1652
2744
|
async function listRemote(source: string): Promise<PackCliResult> {
|
|
1653
2745
|
const opts = optsFor(process.stdout);
|
|
1654
2746
|
let preview;
|
|
@@ -1836,8 +2928,19 @@ export async function runPackCommand(argv: string[]): Promise<PackCliResult> {
|
|
|
1836
2928
|
case "list":
|
|
1837
2929
|
case undefined: {
|
|
1838
2930
|
// `pack list` is what is installed here; `pack list <source>` is what a
|
|
1839
|
-
// pack out there contains
|
|
2931
|
+
// pack out there contains; `--releases` is what it has published over
|
|
2932
|
+
// time. All three are "show me", which is why they are one word with a
|
|
2933
|
+
// flag rather than a third subcommand nobody would find.
|
|
1840
2934
|
const source = packAddSource(rest);
|
|
2935
|
+
if (rest.includes("--releases")) {
|
|
2936
|
+
if (!source) {
|
|
2937
|
+
return fail([
|
|
2938
|
+
"Usage: failproofai policies show <owner>/<repo> --releases",
|
|
2939
|
+
"`--releases` reports what a pack out there has published; run `failproofai policies` for what is installed here.",
|
|
2940
|
+
]);
|
|
2941
|
+
}
|
|
2942
|
+
return listReleases(source);
|
|
2943
|
+
}
|
|
1841
2944
|
return source ? listRemote(source) : list();
|
|
1842
2945
|
}
|
|
1843
2946
|
default:
|