failproofai 1.0.2-beta.3 → 1.0.2-beta.5
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 +6 -6
- 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 +3 -3
- 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 +3 -3
- 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 +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -3
- 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 +5 -6
- 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 +3 -4
- 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 +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 +3 -3
- 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 +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- 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 +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- 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 +3 -3
- 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 +2 -2
- 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 +3 -3
- 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 +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 +3 -3
- 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 +3 -3
- 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]__0e32-7c._.js → [externals]__1qzqcio._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__03r-lqq._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__12021ot._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0igxt32._.js → [root-of-the-server]__1p8poxd._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1zkek3z._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
- package/.next/standalone/.next/server/chunks/_12moi5-._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +6 -5
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js} +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.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_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1m84s36._.js → [root-of-the-server]__00-s7h8._.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]__07gm3zl._.js +4 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__08zpry-._.js → [root-of-the-server]__0stn0gx._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{src_hooks_08o7bqo._.js → [root-of-the-server]__15r6r7w._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0jzbk4_._.js → [root-of-the-server]__190won8._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rsd3ll._.js → [root-of-the-server]__1sc84bb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tag8_-._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1db3-8n._.js → _1if9ixi._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_11opd9e._.js → _1kesqs-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_15xtekm._.js → _1km9-gn._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_1cf5ya2._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_15bjxkm._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- 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/0tyt-3a5rtzy8.js +1 -0
- package/.next/standalone/.next/static/chunks/{1mk3vmmc673hu.js → 21r7iw3rmixf5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{10pok-mrqv426.js → 26lfjhb2kzzgb.js} +1 -1
- package/.next/standalone/.next/static/chunks/2_27jraat038x.js +1 -0
- package/.next/standalone/.next/static/chunks/2hwc8e923g5df.js +1 -0
- package/.next/standalone/.next/static/chunks/{3cs_rf5p765v0.js → 33np9nrh1mvj9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1oml7c7env4-g.js → 354-n7p7labpn.js} +1 -1
- package/.next/standalone/.next/static/chunks/36-8i_vbkh85w.js +1 -0
- package/.next/standalone/.next/static/chunks/{2jsomd79g7kn0.js → 391tpdqcas7rq.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1yywl_a87108w.js → 3_b73tc78jwsq.js} +2 -2
- package/.next/standalone/.next/static/chunks/{1za08zjcctyil.js → 3i8rg2fv9nd6e.js} +2 -2
- package/.next/standalone/.next/static/chunks/{2ey_ih6j6cm15.js → 3jqm8ubpv3i-4.js} +1 -1
- package/.next/standalone/.next/static/chunks/turbopack-2x-o099q64i8h.js +1 -0
- package/.next/standalone/SECURITY.md +18 -3
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +7 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +6 -6
- package/.next/standalone/lib/opencode-db.ts +77 -0
- package/.next/standalone/lib/opencode-projects.ts +37 -41
- package/.next/standalone/lib/opencode-sessions.ts +59 -36
- package/.next/standalone/node_modules/@next/env/package.json +1 -1
- package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
- package/.next/standalone/node_modules/next/dist/client/components/segment-cache/cache.js +23 -34
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
- package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
- package/.next/standalone/node_modules/next/package.json +10 -10
- package/.next/standalone/package.json +19 -19
- package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
- package/.next/standalone/sdk/python/uv.lock +23 -23
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +151 -60
- package/dist/cli.mjs +3670 -3005
- package/dist/worker.mjs +189 -164
- package/lib/opencode-db.ts +77 -0
- package/lib/opencode-projects.ts +37 -41
- package/lib/opencode-sessions.ts +59 -36
- package/package.json +19 -19
- package/src/audit/cache.ts +201 -40
- package/src/audit/cli-adapters/claude.ts +111 -1
- package/src/audit/cli-adapters/index.ts +31 -1
- package/src/audit/index.ts +131 -11
- package/src/audit/replay.ts +15 -91
- package/src/hooks/configure-wizard.ts +16 -48
- package/src/hooks/handler.ts +8 -1
- package/src/hooks/manager.ts +72 -3
- package/src/hooks/pack-cli.ts +793 -45
- package/src/hooks/pack-manifest.ts +28 -0
- package/src/hooks/pack-store.ts +8 -25
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1l-khtb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mhmdzs._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +0 -3
- package/.next/standalone/.next/server/chunks/_185gyk9._.js +0 -3
- package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +0 -12
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +0 -5
- package/.next/standalone/.next/static/chunks/01nuy-aqshahr.js +0 -1
- package/.next/standalone/.next/static/chunks/1pl7h8vllvoi2.js +0 -1
- package/.next/standalone/.next/static/chunks/2efnffq2ipgr_.js +0 -1
- package/.next/standalone/.next/static/chunks/2gd07pinz0i7f.js +0 -1
- package/.next/standalone/.next/static/chunks/turbopack-3kjgdgwci4tqf.js +0 -1
- /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_ssgManifest.js +0 -0
package/src/hooks/pack-cli.ts
CHANGED
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
* This layer decides nothing; it formats.
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
10
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { resolve } from "node:path";
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
13
|
+
import { INTEGRATION_TYPES } from "./types";
|
|
14
|
+
import { PACK_VERSION_RE } from "./pack-manifest";
|
|
15
|
+
import { detectInstalledClis } from "./integrations";
|
|
12
16
|
import { parsePackIdentity, parsePackPolicy, readInstalledPacks } from "./pack-manifest";
|
|
13
17
|
import {
|
|
14
18
|
PACK_CHECKSUMS_ASSET,
|
|
@@ -31,6 +35,7 @@ import {
|
|
|
31
35
|
chip,
|
|
32
36
|
emptyState,
|
|
33
37
|
multiSelect,
|
|
38
|
+
promptText,
|
|
34
39
|
note,
|
|
35
40
|
nextStep,
|
|
36
41
|
optsFor,
|
|
@@ -59,7 +64,7 @@ function parseList(rest: string[], flag: string): string[] | undefined {
|
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/** Flags that take a separate value, so it is never mistaken for the source. */
|
|
62
|
-
const VALUE_FLAGS = new Set(["--only", "--policy", "--category"]);
|
|
67
|
+
const VALUE_FLAGS = new Set(["--only", "--policy", "--category", "--cli"]);
|
|
63
68
|
|
|
64
69
|
/** Find the positional source without mistaking a flag's separate value for it. */
|
|
65
70
|
export function packAddSource(rest: string[]): string | undefined {
|
|
@@ -71,14 +76,21 @@ export function packAddSource(rest: string[]): string | undefined {
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
/** Names taken by our own policies, so a selection can be checked before install. */
|
|
74
|
-
function selectionFrom(rest: string[]): {
|
|
79
|
+
function selectionFrom(rest: string[]): {
|
|
80
|
+
only?: string[];
|
|
81
|
+
categories?: string[];
|
|
82
|
+
all?: boolean;
|
|
83
|
+
clis?: string[];
|
|
84
|
+
} {
|
|
75
85
|
// `--policy` reads right for one ("give me this policy"), `--only` for a set.
|
|
76
86
|
// They are the same switch; taking both means neither is the wrong guess.
|
|
77
87
|
const only = parseList(rest, "--policy") ?? parseList(rest, "--only");
|
|
78
88
|
const categories = parseList(rest, "--category");
|
|
89
|
+
const clis = parseList(rest, "--cli");
|
|
79
90
|
return {
|
|
80
91
|
...(only ? { only } : {}),
|
|
81
92
|
...(categories ? { categories } : {}),
|
|
93
|
+
...(clis ? { clis } : {}),
|
|
82
94
|
...(rest.includes("--all") ? { all: true } : {}),
|
|
83
95
|
};
|
|
84
96
|
}
|
|
@@ -333,10 +345,527 @@ export async function runPolicyPicker(
|
|
|
333
345
|
}
|
|
334
346
|
|
|
335
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Write a working starter policy, because the blank file is the hardest step.
|
|
350
|
+
*
|
|
351
|
+
* `publish --help` described the shape in prose, which leaves a newcomer to
|
|
352
|
+
* hand-write their first `customPolicies.add({...})` from a description and
|
|
353
|
+
* find out whether they got it right at publish time. What lands here is a
|
|
354
|
+
* policy that already RUNS and already blocks something real, so the first
|
|
355
|
+
* action is editing a working thing rather than authoring an empty one.
|
|
356
|
+
*/
|
|
357
|
+
/**
|
|
358
|
+
* A policy filename, whatever spelling it arrived in. Discovery takes
|
|
359
|
+
* `.mjs`/`.js`/`.ts` and nothing else, so a name with no extension has to gain
|
|
360
|
+
* one or the file it names can never be found.
|
|
361
|
+
*/
|
|
362
|
+
function withPolicyExtension(name: string): string {
|
|
363
|
+
return /\.(mjs|js|ts)$/.test(name) ? name : `${name}.mjs`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function scaffold(target: string | null): Promise<PackCliResult> {
|
|
367
|
+
// Ask what the pack is called when nothing was named and there is somebody to
|
|
368
|
+
// ask. The answer becomes the FILENAME and the header, so what lands on disk
|
|
369
|
+
// is already theirs rather than a file called `my-policies.mjs` that everybody
|
|
370
|
+
// then has to remember to rename. Skipped entirely for an explicit path or a
|
|
371
|
+
// non-TTY, where there is no question to ask and no one to answer it.
|
|
372
|
+
let chosen = target;
|
|
373
|
+
if (!chosen && process.stdin.isTTY && process.stdout.isTTY) {
|
|
374
|
+
const answer = await promptText({
|
|
375
|
+
message: "What is this pack called?",
|
|
376
|
+
hint: "used for the filename — letters, numbers, dashes",
|
|
377
|
+
defaultValue: "my-policies",
|
|
378
|
+
validate: (v) =>
|
|
379
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim())
|
|
380
|
+
? null
|
|
381
|
+
: "letters, numbers, dots, dashes and underscores",
|
|
382
|
+
stdin: process.stdin,
|
|
383
|
+
stdout: process.stdout,
|
|
384
|
+
});
|
|
385
|
+
if (answer === null) return ok(["Nothing written."]);
|
|
386
|
+
chosen = answer.trim();
|
|
387
|
+
}
|
|
388
|
+
// Both paths land here, because only one of them used to. A name typed at
|
|
389
|
+
// the prompt got `.mjs`; the same name passed as `--init myguards` was taken
|
|
390
|
+
// literally and wrote a file called `myguards` — which discovery skips (it
|
|
391
|
+
// takes .mjs/.js/.ts) and which no ESM loader will import. The starter file
|
|
392
|
+
// was unreachable by every command meant to pick it up.
|
|
393
|
+
const path = resolve(withPolicyExtension(chosen ?? "my-policies"));
|
|
394
|
+
if (existsSync(path)) {
|
|
395
|
+
return fail([`${path} already exists — name a different file, or edit that one.`]);
|
|
396
|
+
}
|
|
397
|
+
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
398
|
+
writeFileSync(
|
|
399
|
+
path,
|
|
400
|
+
`import { customPolicies, allow, deny } from "failproofai";
|
|
401
|
+
|
|
402
|
+
// One file, no relative imports: only this entry is digest-pinned, so a pack
|
|
403
|
+
// that imported siblings could not honestly claim to be verified.
|
|
404
|
+
|
|
405
|
+
customPolicies.add({
|
|
406
|
+
name: "block-force-push",
|
|
407
|
+
description: "Block git push --force on any branch",
|
|
408
|
+
// Groups it in the picker, and is what --category selects on.
|
|
409
|
+
category: "Git",
|
|
410
|
+
// Whether it is ON for somebody who installs with no flags. Defaults to false.
|
|
411
|
+
defaultEnabled: true,
|
|
412
|
+
match: { events: ["PreToolUse"] },
|
|
413
|
+
fn: async (ctx) => {
|
|
414
|
+
if (ctx.toolName !== "Bash") return allow();
|
|
415
|
+
const cmd = String(ctx.toolInput?.command ?? "");
|
|
416
|
+
if (/\\bgit\\s+push\\b[^|;&]*\\s(-f|--force)\\b/.test(cmd)) {
|
|
417
|
+
return deny("Force-push rewrites history someone else may have pulled.");
|
|
418
|
+
}
|
|
419
|
+
return allow();
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
`,
|
|
423
|
+
"utf8",
|
|
424
|
+
);
|
|
425
|
+
return ok([
|
|
426
|
+
`Wrote ${path} — one policy, already working.`,
|
|
427
|
+
"",
|
|
428
|
+
"Try it on this machine before anyone else sees it:",
|
|
429
|
+
` failproofai policies -i -c ${path}`,
|
|
430
|
+
" then ask your agent to force-push — it gets refused",
|
|
431
|
+
"",
|
|
432
|
+
"Then publish it:",
|
|
433
|
+
" failproofai publish --repo <you>/<repo>",
|
|
434
|
+
]);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The GitHub repository this file lives in, from its own git remote.
|
|
439
|
+
*
|
|
440
|
+
* Typing `--repo you/guards` from inside the checkout of `you/guards` is asking
|
|
441
|
+
* somebody to restate what the directory already knows. Read from the ENTRY
|
|
442
|
+
* FILE's directory rather than the process cwd: publishing a policy that lives
|
|
443
|
+
* in another checkout is normal, and the answer must describe the file, not
|
|
444
|
+
* wherever the shell happens to be.
|
|
445
|
+
*/
|
|
446
|
+
function inferRepo(entryPath: string): string | null {
|
|
447
|
+
try {
|
|
448
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
449
|
+
cwd: resolve(entryPath, ".."),
|
|
450
|
+
encoding: "utf8",
|
|
451
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
452
|
+
timeout: 5_000,
|
|
453
|
+
}).trim();
|
|
454
|
+
// Both spellings git hands out: scp-style and https.
|
|
455
|
+
const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
456
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
457
|
+
} catch {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* A tag on HEAD, when there is one and the file is clean.
|
|
464
|
+
*
|
|
465
|
+
* Somebody who tagged `v1.2.0` has SAID what this release is, which a counted
|
|
466
|
+
* number cannot. Absent a tag there is nothing here to infer — the version is
|
|
467
|
+
* counted from what the repository has already published instead.
|
|
468
|
+
*/
|
|
469
|
+
function inferTaggedVersion(entryPath: string): string | null {
|
|
470
|
+
const cwd = resolve(entryPath, "..");
|
|
471
|
+
const git = (args: string[]): string | null => {
|
|
472
|
+
try {
|
|
473
|
+
return execFileSync("git", args, {
|
|
474
|
+
cwd,
|
|
475
|
+
encoding: "utf8",
|
|
476
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
477
|
+
timeout: 5_000,
|
|
478
|
+
}).trim();
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
const tag = git(["describe", "--tags", "--exact-match", "HEAD"]);
|
|
484
|
+
if (!tag || !PACK_VERSION_RE.test(tag)) return null;
|
|
485
|
+
// A tag NAMES a commit, so shipping edited bytes under it publishes something
|
|
486
|
+
// that commit does not contain — and two artifacts would claim one version,
|
|
487
|
+
// which `id|version|sha256` compares in both the audit key and the pack
|
|
488
|
+
// upsert. A counted version names no commit and has no such problem, so this
|
|
489
|
+
// refusal belongs to the tagged path alone.
|
|
490
|
+
if (git(["status", "--porcelain", "--", entryPath])) return null;
|
|
491
|
+
return tag;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The policy file in this directory, when there is exactly one.
|
|
496
|
+
*
|
|
497
|
+
* Naming the path is restating something the directory already answers: a
|
|
498
|
+
* checkout with one policy file in it has no ambiguity to resolve. So the
|
|
499
|
+
* common case is `failproofai publish`, and the argument stays for the cases
|
|
500
|
+
* that genuinely need it.
|
|
501
|
+
*
|
|
502
|
+
* Identified by CONTENT, not by filename. A name convention would either miss
|
|
503
|
+
* `guards.mjs` or match an unrelated `policies.mjs` that configures something
|
|
504
|
+
* else entirely; a file that imports `failproofai` and calls
|
|
505
|
+
* `customPolicies.add` is a policy file whatever it is called.
|
|
506
|
+
*
|
|
507
|
+
* Returns ALL of them. Splitting policies across files is the normal thing to
|
|
508
|
+
* do past about three, and they are one pack — so several files is an answer,
|
|
509
|
+
* not an ambiguity, and they get bundled into the single artifact a pack must
|
|
510
|
+
* be. Naming a path explicitly still publishes exactly that file.
|
|
511
|
+
*
|
|
512
|
+
* Non-recursive on purpose. Walking the tree finds fixtures, examples and
|
|
513
|
+
* anything vendored, and publishing those is the failure this avoids.
|
|
514
|
+
*/
|
|
515
|
+
function findEntry(dir: string): string[] {
|
|
516
|
+
let names: string[];
|
|
517
|
+
try {
|
|
518
|
+
names = readdirSync(dir);
|
|
519
|
+
} catch {
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
const found: string[] = [];
|
|
523
|
+
for (const name of names) {
|
|
524
|
+
if (!/\.(mjs|js|ts)$/.test(name)) continue;
|
|
525
|
+
const full = resolve(dir, name);
|
|
526
|
+
try {
|
|
527
|
+
// Policy entries are small; anything large is not one, and reading it
|
|
528
|
+
// would be the expensive half of this scan.
|
|
529
|
+
if (statSync(full).size > 512 * 1024) continue;
|
|
530
|
+
const text = readFileSync(full, "utf8");
|
|
531
|
+
if (/from\s+["']failproofai["']/.test(text) && /customPolicies\s*\.\s*add\s*\(/.test(text)) {
|
|
532
|
+
found.push(full);
|
|
533
|
+
}
|
|
534
|
+
} catch {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return found.sort();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The version after whatever this repository has already published.
|
|
543
|
+
*
|
|
544
|
+
* A commit SHA names exactly where bytes came from and tells a reader nothing
|
|
545
|
+
* else: it does not order, so `a1b2c3d` and `f9e8d7c` give no clue which came
|
|
546
|
+
* first, and nobody can say "I am on the older one". Counting instead — 1.0.0,
|
|
547
|
+
* then 1.0.1 — costs one API call and answers both.
|
|
548
|
+
*
|
|
549
|
+
* Reads the repository's own releases rather than anything local, because the
|
|
550
|
+
* releases ARE the published record: a machine that has never published from
|
|
551
|
+
* this checkout still computes the right next number, and two people publishing
|
|
552
|
+
* from different clones cannot both mint 1.0.1 without one of them seeing the
|
|
553
|
+
* other's release first.
|
|
554
|
+
*
|
|
555
|
+
* Non-semver tags are ignored rather than parsed heroically — a repo whose
|
|
556
|
+
* releases are named `nightly` has no sequence to continue, and starting a
|
|
557
|
+
* fresh count is more honest than inventing one.
|
|
558
|
+
*/
|
|
559
|
+
async function nextVersion(owner: string, name: string, token: string): Promise<string> {
|
|
560
|
+
const res = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases?per_page=100`, token);
|
|
561
|
+
const tags = Array.isArray(res.json)
|
|
562
|
+
? (res.json as unknown as Array<{ tag_name?: unknown }>)
|
|
563
|
+
.map((r) => (typeof r.tag_name === "string" ? r.tag_name : ""))
|
|
564
|
+
.filter(Boolean)
|
|
565
|
+
: [];
|
|
566
|
+
let best: [number, number, number] | null = null;
|
|
567
|
+
for (const tag of tags) {
|
|
568
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
|
|
569
|
+
if (!m) continue;
|
|
570
|
+
const parsed: [number, number, number] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
571
|
+
if (
|
|
572
|
+
!best ||
|
|
573
|
+
parsed[0] > best[0] ||
|
|
574
|
+
(parsed[0] === best[0] && parsed[1] > best[1]) ||
|
|
575
|
+
(parsed[0] === best[0] && parsed[1] === best[1] && parsed[2] > best[2])
|
|
576
|
+
) {
|
|
577
|
+
best = parsed;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return best ? `${best[0]}.${best[1]}.${best[2] + 1}` : "1.0.0";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The repository, created if it is not there.
|
|
585
|
+
*
|
|
586
|
+
* Creating it was the only step `publish` did not do, and the one that made
|
|
587
|
+
* "one command" untrue — it sent the publisher to a different tool and back.
|
|
588
|
+
*
|
|
589
|
+
* PUBLIC, always. A private repository publishes to nobody, because installs
|
|
590
|
+
* are anonymous HTTPS with no credential to offer, so creating one here would
|
|
591
|
+
* manufacture the exact dead end the caller then warns about. Somebody who
|
|
592
|
+
* wants a private pack makes it themselves and knows why.
|
|
593
|
+
*/
|
|
594
|
+
async function ensureRepo(
|
|
595
|
+
owner: string,
|
|
596
|
+
name: string,
|
|
597
|
+
id: string,
|
|
598
|
+
token: string,
|
|
599
|
+
sourceDir?: string,
|
|
600
|
+
): Promise<{ info: Awaited<ReturnType<typeof gh>>; created: boolean } | { error: string[] }> {
|
|
601
|
+
let info = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
|
|
602
|
+
let created = false;
|
|
603
|
+
if (info.status === 404) {
|
|
604
|
+
// Personal and organisation repositories are different endpoints, and the
|
|
605
|
+
// only way to tell which applies is to ask who the token belongs to.
|
|
606
|
+
const me = await gh(`${GITHUB_API}/user`, token);
|
|
607
|
+
const login = typeof me.json?.login === "string" ? me.json.login : null;
|
|
608
|
+
const endpoint =
|
|
609
|
+
login && login.toLowerCase() === owner.toLowerCase()
|
|
610
|
+
? `${GITHUB_API}/user/repos`
|
|
611
|
+
: `${GITHUB_API}/orgs/${owner}/repos`;
|
|
612
|
+
const made = await gh(endpoint, token, {
|
|
613
|
+
method: "POST",
|
|
614
|
+
contentType: "application/json",
|
|
615
|
+
body: JSON.stringify({
|
|
616
|
+
name,
|
|
617
|
+
private: false,
|
|
618
|
+
description: `failproofai policy pack — ${id}`,
|
|
619
|
+
// NOT auto_init. The author already has the commit that holds these
|
|
620
|
+
// policies; a repository seeded with its own "Initial commit" shares
|
|
621
|
+
// no history with it, so the `git push` that every publish is followed
|
|
622
|
+
// by is rejected as unrelated — and the way out (`pull --allow-
|
|
623
|
+
// unrelated-histories`, or a force-push over the seed) is a worse
|
|
624
|
+
// first five minutes than an empty repository, which accepts the push
|
|
625
|
+
// as-is. Releases do not need a commit to hang from: the tag is
|
|
626
|
+
// created against whatever the release API is given.
|
|
627
|
+
auto_init: false,
|
|
628
|
+
}),
|
|
629
|
+
});
|
|
630
|
+
if (made.status >= 400) {
|
|
631
|
+
return {
|
|
632
|
+
error: [
|
|
633
|
+
`${owner}/${name} does not exist and could not be created: ${ghError(made)}`,
|
|
634
|
+
login
|
|
635
|
+
? `The credential belongs to ${login}. Creating under a different owner needs`
|
|
636
|
+
: "The credential could not be identified. Creating a repository needs",
|
|
637
|
+
" rights to that account or organisation — or make it by hand:",
|
|
638
|
+
` gh repo create ${owner}/${name} --public`,
|
|
639
|
+
],
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
created = true;
|
|
643
|
+
// A repository created with no commits has no default branch, and the
|
|
644
|
+
// release API tags the default branch — so something has to land on it
|
|
645
|
+
// before the release is cut. Pushing the author's own history is the
|
|
646
|
+
// version that leaves a usable repository behind; seeding is the fallback
|
|
647
|
+
// for a pack that is not in a git checkout at all.
|
|
648
|
+
if (!(sourceDir && pushExistingHistory(sourceDir, owner, name, token))) {
|
|
649
|
+
await seedDefaultBranch(owner, name, id, token);
|
|
650
|
+
}
|
|
651
|
+
info = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
|
|
652
|
+
}
|
|
653
|
+
if (info.status >= 400) {
|
|
654
|
+
return { error: [`Could not read ${owner}/${name}: ${ghError(info)}`] };
|
|
655
|
+
}
|
|
656
|
+
return { info, created };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Push the checkout the policies live in to the repository just created for
|
|
661
|
+
* them, and report whether it landed.
|
|
662
|
+
*
|
|
663
|
+
* Why publish pushes at all: it created the repository, so it owns the one
|
|
664
|
+
* moment when pushing cannot conflict with anything. The repository used to be
|
|
665
|
+
* created with `auto_init`, which meant GitHub wrote an "Initial commit" the
|
|
666
|
+
* author did not have — so the `git push` that every publish is followed by was
|
|
667
|
+
* rejected as unrelated history, every time, for everybody. Seeding it and
|
|
668
|
+
* pushing it are the same job; doing only the first left the author to
|
|
669
|
+
* reconcile two histories by hand.
|
|
670
|
+
*
|
|
671
|
+
* It also makes the tag mean something. The release tags the default branch, so
|
|
672
|
+
* against a seeded repository `1.0.0` named a README commit that did not
|
|
673
|
+
* contain a single one of the policies it shipped.
|
|
674
|
+
*
|
|
675
|
+
* The token goes in through GIT_ASKPASS, never in the URL or the argv: a remote
|
|
676
|
+
* with a credential in it is written into `.git/config` in plaintext, and one on
|
|
677
|
+
* a command line is readable from `ps` by every user on the box.
|
|
678
|
+
*/
|
|
679
|
+
export const __pushExistingHistoryForTest = (
|
|
680
|
+
dir: string,
|
|
681
|
+
owner: string,
|
|
682
|
+
name: string,
|
|
683
|
+
token: string,
|
|
684
|
+
): boolean => pushExistingHistory(dir, owner, name, token);
|
|
685
|
+
|
|
686
|
+
function pushExistingHistory(dir: string, owner: string, name: string, token: string): boolean {
|
|
687
|
+
const git = (args: string[], env?: Record<string, string>): string | null => {
|
|
688
|
+
try {
|
|
689
|
+
return execFileSync("git", args, {
|
|
690
|
+
cwd: dir,
|
|
691
|
+
encoding: "utf8",
|
|
692
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
693
|
+
timeout: 60_000,
|
|
694
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
695
|
+
}).trim();
|
|
696
|
+
} catch {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
701
|
+
// No commits yet (`HEAD` unborn) means there is nothing to push and nothing
|
|
702
|
+
// to conflict with later either.
|
|
703
|
+
if (!branch || branch === "HEAD" || !git(["rev-parse", "--verify", "HEAD"])) return false;
|
|
704
|
+
|
|
705
|
+
const askpass = join(mkdtempSync(join(tmpdir(), "fpai-askpass-")), "askpass.sh");
|
|
706
|
+
try {
|
|
707
|
+
writeFileSync(askpass, `#!/bin/sh\ncase "$1" in\n*Username*) echo x-access-token ;;\n*) echo "$FPAI_GH_TOKEN" ;;\nesac\n`, { mode: 0o700 });
|
|
708
|
+
const env = { GIT_ASKPASS: askpass, FPAI_GH_TOKEN: token, GIT_TERMINAL_PROMPT: "0" };
|
|
709
|
+
const url = `${GITHUB_GIT}/${owner}/${name}.git`;
|
|
710
|
+
// Name the remote FIRST when there is not one, so the push can be `-u` and
|
|
711
|
+
// leave the branch tracking it. Pushing to a bare URL and setting the
|
|
712
|
+
// upstream afterwards cannot work: `--set-upstream-to` needs a
|
|
713
|
+
// remote-tracking ref, and a URL push writes none.
|
|
714
|
+
const adopt = !git(["remote", "get-url", "origin"]);
|
|
715
|
+
if (adopt) git(["remote", "add", "origin", url]);
|
|
716
|
+
// An author who already has an `origin` keeps it, and the push goes to the
|
|
717
|
+
// URL directly rather than quietly redirecting their remote.
|
|
718
|
+
const pushed = adopt
|
|
719
|
+
? git(["push", "-u", "origin", `${branch}:${branch}`], env)
|
|
720
|
+
: git(["push", url, `${branch}:${branch}`], env);
|
|
721
|
+
if (pushed === null) {
|
|
722
|
+
// Leave nothing behind that was not there before.
|
|
723
|
+
if (adopt) git(["remote", "remove", "origin"]);
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
return true;
|
|
727
|
+
} finally {
|
|
728
|
+
try {
|
|
729
|
+
rmSync(dirname(askpass), { recursive: true, force: true });
|
|
730
|
+
} catch {
|
|
731
|
+
/* a leftover temp dir is not worth failing a publish over */
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The minimum that gives a repository a default branch: one commit, made
|
|
738
|
+
* through the API. Only for a pack published from somewhere that is not a git
|
|
739
|
+
* checkout — otherwise the author's own history does this job better.
|
|
740
|
+
*/
|
|
741
|
+
async function seedDefaultBranch(owner: string, name: string, id: string, token: string): Promise<void> {
|
|
742
|
+
await gh(`${GITHUB_API}/repos/${owner}/${name}/contents/README.md`, token, {
|
|
743
|
+
method: "PUT",
|
|
744
|
+
contentType: "application/json",
|
|
745
|
+
body: JSON.stringify({
|
|
746
|
+
message: `${id}: create pack repository`,
|
|
747
|
+
content: Buffer.from(
|
|
748
|
+
`# ${name}\n\nA [failproofai](https://github.com/failproofai/failproofai) policy pack.\n\n\`\`\`bash\nfailproofai policies add ${owner}/${name}\n\`\`\`\n`,
|
|
749
|
+
"utf8",
|
|
750
|
+
).toString("base64"),
|
|
751
|
+
}),
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Where `bun` is, if it is anywhere.
|
|
757
|
+
*
|
|
758
|
+
* Bundling needs it, and a CLI installed from npm runs under node — so bun is
|
|
759
|
+
* likely present (it is in `engines`) but never guaranteed. Checked in the
|
|
760
|
+
* places it actually installs to, because `npm i -g bun` puts it in ONE nvm
|
|
761
|
+
* version's bin dir and `nvm use` then drops it off PATH.
|
|
762
|
+
*/
|
|
763
|
+
function findBun(): string | null {
|
|
764
|
+
const candidates = [
|
|
765
|
+
"bun",
|
|
766
|
+
process.env.BUN_INSTALL ? resolve(process.env.BUN_INSTALL, "bin", "bun") : null,
|
|
767
|
+
resolve(homedir(), ".bun", "bin", "bun"),
|
|
768
|
+
"/opt/homebrew/bin/bun",
|
|
769
|
+
"/usr/local/bin/bun",
|
|
770
|
+
].filter((c): c is string => c !== null);
|
|
771
|
+
for (const candidate of candidates) {
|
|
772
|
+
try {
|
|
773
|
+
execFileSync(candidate, ["--version"], { stdio: "ignore", timeout: 5_000 });
|
|
774
|
+
return candidate;
|
|
775
|
+
} catch {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Collapse several policy files, or one that imports its neighbours, into the
|
|
784
|
+
* single artifact a pack has to be.
|
|
785
|
+
*
|
|
786
|
+
* One entry file is a real constraint — only the entry is content-addressed, so
|
|
787
|
+
* a multi-file pack could not honestly claim to be digest-pinned — but that is a
|
|
788
|
+
* constraint on what gets PUBLISHED, not on how anybody writes. Splitting
|
|
789
|
+
* policies across files is the normal thing to do past about three of them, and
|
|
790
|
+
* telling someone to go and configure a bundler first is the tool refusing to do
|
|
791
|
+
* the one mechanical step it already does for its own pack.
|
|
792
|
+
*
|
|
793
|
+
* `--external failproofai` because the loader supplies it: bundling our own
|
|
794
|
+
* module into the artifact would ship a second copy of the registry, and
|
|
795
|
+
* policies would register into an object nothing reads.
|
|
796
|
+
*/
|
|
797
|
+
function bundleEntry(
|
|
798
|
+
sources: string[],
|
|
799
|
+
outDir: string,
|
|
800
|
+
): { path: string } | { error: string[] } {
|
|
801
|
+
const bun = findBun();
|
|
802
|
+
if (!bun) {
|
|
803
|
+
return {
|
|
804
|
+
error: [
|
|
805
|
+
sources.length > 1
|
|
806
|
+
? `${sources.length} policy files here, and bundling them needs bun, which is not installed.`
|
|
807
|
+
: `${sources[0]} imports other files, and bundling needs bun, which is not installed.`,
|
|
808
|
+
"Either install bun (https://bun.sh), bundle it yourself first —",
|
|
809
|
+
" esbuild <entry> --bundle --format=esm --external:failproofai --outfile=pack.mjs",
|
|
810
|
+
"then publish the bundle — or name a single self-contained file.",
|
|
811
|
+
],
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
mkdirSync(outDir, { recursive: true });
|
|
815
|
+
// A generated entry that imports each file for its SIDE EFFECT: every policy
|
|
816
|
+
// file registers by calling `customPolicies.add` at module scope, so importing
|
|
817
|
+
// it is what puts the policies in the registry. Same shape as the generated
|
|
818
|
+
// entry this repo builds its own pack from.
|
|
819
|
+
const entry = resolve(outDir, ".entry.generated.mjs");
|
|
820
|
+
writeFileSync(
|
|
821
|
+
entry,
|
|
822
|
+
sources.map((f) => `import ${JSON.stringify(f.startsWith("/") ? f : resolve(f))};`).join("\n") + "\n",
|
|
823
|
+
"utf8",
|
|
824
|
+
);
|
|
825
|
+
const bundled = resolve(outDir, ".bundled.mjs");
|
|
826
|
+
try {
|
|
827
|
+
execFileSync(
|
|
828
|
+
bun,
|
|
829
|
+
["build", "--target=node", "--format=esm", "--external", "failproofai",
|
|
830
|
+
"--outfile", bundled, entry],
|
|
831
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 },
|
|
832
|
+
);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
const detail = err instanceof Error && "stderr" in err
|
|
835
|
+
? String((err as { stderr?: Buffer }).stderr ?? "").trim().split("\n").slice(0, 6)
|
|
836
|
+
: [];
|
|
837
|
+
return { error: [`Could not bundle those files:`, ...detail.map((l) => ` ${l}`)] };
|
|
838
|
+
}
|
|
839
|
+
return { path: bundled };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The entry path among publish's arguments, and nothing that merely follows a
|
|
844
|
+
* flag.
|
|
845
|
+
*
|
|
846
|
+
* `packAddSource` knows the flags `policies add` takes, not the ones `publish`
|
|
847
|
+
* takes — so `publish --id me/x --version 1.0.0` read `me/x` as the file to
|
|
848
|
+
* publish and failed on ENOENT. It only ever worked because every example wrote
|
|
849
|
+
* the path first.
|
|
850
|
+
*/
|
|
851
|
+
const PUBLISH_VALUE_FLAGS = new Set([
|
|
852
|
+
"--repo", "--version", "--id", "--tag", "--notes", "--out", "--effect", "--entry", "--init",
|
|
853
|
+
]);
|
|
854
|
+
function publishEntryArg(rest: string[]): string | undefined {
|
|
855
|
+
const consumed = new Set<number>();
|
|
856
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
857
|
+
if (PUBLISH_VALUE_FLAGS.has(rest[i])) consumed.add(i + 1);
|
|
858
|
+
}
|
|
859
|
+
return rest.find((a, i) => !a.startsWith("--") && !consumed.has(i));
|
|
860
|
+
}
|
|
861
|
+
|
|
336
862
|
// ── publish ───────────────────────────────────────────────────────────────
|
|
337
863
|
|
|
338
864
|
const GITHUB_API = process.env.FAILPROOFAI_GITHUB_API ?? "https://api.github.com";
|
|
339
865
|
const GITHUB_UPLOADS = process.env.FAILPROOFAI_GITHUB_UPLOADS ?? "https://uploads.github.com";
|
|
866
|
+
/** Where the git remote lives, so the push path can be tested against a real
|
|
867
|
+
* local bare repository rather than only reasoned about. */
|
|
868
|
+
const GITHUB_GIT = process.env.FAILPROOFAI_GITHUB_GIT ?? "https://github.com";
|
|
340
869
|
|
|
341
870
|
/**
|
|
342
871
|
* The credential, from the places a developer already keeps one.
|
|
@@ -423,6 +952,60 @@ function ghError(res: { status: number; json: Record<string, unknown> | null; te
|
|
|
423
952
|
* - a private repository, which publishes to nobody, because installs are
|
|
424
953
|
* anonymous HTTPS with no credential to offer.
|
|
425
954
|
*/
|
|
955
|
+
/**
|
|
956
|
+
* The two things `publish` cannot work out on its own, asked instead of
|
|
957
|
+
* demanded as flags.
|
|
958
|
+
*
|
|
959
|
+
* Everything else it already derives: the policy files by content, the version
|
|
960
|
+
* by counting the repository's own releases, the id from the repository, the
|
|
961
|
+
* credential from the environment. What is left is genuinely a question —
|
|
962
|
+
* WHERE this should live, when the folder has no git remote naming somewhere —
|
|
963
|
+
* and a confirmation, because publishing is public and a version number can
|
|
964
|
+
* never be reused.
|
|
965
|
+
*
|
|
966
|
+
* Only ever on a TTY. A pipe, a CI job or a test gets the old behaviour: flags
|
|
967
|
+
* decide, and a missing `--repo` still means "build the assets and stop"
|
|
968
|
+
* rather than a prompt nobody can answer.
|
|
969
|
+
*/
|
|
970
|
+
async function askWhereToPublish(
|
|
971
|
+
suggestion: string,
|
|
972
|
+
io: { stdin: TTYIn; stdout: TTYOut },
|
|
973
|
+
): Promise<string | null> {
|
|
974
|
+
const answer = await promptText({
|
|
975
|
+
// The suggestion goes in the HINT, not just in `defaultValue`: the hint is
|
|
976
|
+
// what renders as the placeholder, while `defaultValue` is only applied on
|
|
977
|
+
// an empty submit and is never shown. Passing it as the default alone left
|
|
978
|
+
// the one keystroke that finishes this prompt — return — invisible.
|
|
979
|
+
message: "Where should this publish?",
|
|
980
|
+
hint: `${suggestion} · created if it does not exist`,
|
|
981
|
+
defaultValue: suggestion,
|
|
982
|
+
validate: (v) => {
|
|
983
|
+
const t = v.trim();
|
|
984
|
+
if (!/^[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*$/.test(t)) return "owner/repo";
|
|
985
|
+
return null;
|
|
986
|
+
},
|
|
987
|
+
stdin: io.stdin,
|
|
988
|
+
stdout: io.stdout,
|
|
989
|
+
});
|
|
990
|
+
return answer === null ? null : answer.trim();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* The account the credential belongs to, for the default answer above.
|
|
995
|
+
*
|
|
996
|
+
* Best-effort: a failure here costs a nicer default and nothing else, so it
|
|
997
|
+
* never blocks or reports. The prompt still has the folder name to fall back
|
|
998
|
+
* on, which is what the repository would most likely be called anyway.
|
|
999
|
+
*/
|
|
1000
|
+
async function credentialLogin(token: string): Promise<string | null> {
|
|
1001
|
+
try {
|
|
1002
|
+
const me = await gh(`${GITHUB_API}/user`, token);
|
|
1003
|
+
return typeof me.json?.login === "string" ? me.json.login : null;
|
|
1004
|
+
} catch {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
426
1009
|
async function publish(rest: string[]): Promise<PackCliResult> {
|
|
427
1010
|
const flag = (name: string): string | undefined => {
|
|
428
1011
|
const i = rest.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
@@ -430,18 +1013,73 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
430
1013
|
return rest[i].includes("=") ? rest[i].split("=").slice(1).join("=") : rest[i + 1];
|
|
431
1014
|
};
|
|
432
1015
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1016
|
+
if (rest.includes("--init")) {
|
|
1017
|
+
return scaffold(flag("init") ?? publishEntryArg(rest) ?? null);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
let entry = publishEntryArg(rest.filter((a) => a !== "--dry-run")) ?? flag("entry");
|
|
1021
|
+
// Every policy file here, bundled into one artifact below. Named explicitly,
|
|
1022
|
+
// it is that file and only that file.
|
|
1023
|
+
const discovered = entry ? [] : findEntry(process.cwd());
|
|
1024
|
+
if (!entry && discovered.length > 0) entry = discovered[0];
|
|
1025
|
+
let repo = flag("repo") ?? (entry ? inferRepo(entry) : null) ?? undefined;
|
|
1026
|
+
// Nothing names a destination and there is somebody to ask. This is the
|
|
1027
|
+
// whole of "just run `failproofai publish`": the folder is a git repo full
|
|
1028
|
+
// of policies, and the one thing it cannot know is where they should go.
|
|
1029
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1030
|
+
const askingWhere = interactive && !repo && entry && !rest.includes("--dry-run");
|
|
1031
|
+
if (askingWhere) {
|
|
1032
|
+
// What is about to be published, before being asked where to put it. The
|
|
1033
|
+
// question is answerable without it — but "publish" with no arguments
|
|
1034
|
+
// otherwise gives no sign of WHICH files it found, and finding the wrong
|
|
1035
|
+
// ones is the mistake that matters here.
|
|
1036
|
+
const found = discovered.length > 0 ? discovered : [entry!];
|
|
1037
|
+
process.stdout.write(
|
|
1038
|
+
`\n ${found.length} policy ${found.length === 1 ? "file" : "files"}: ` +
|
|
1039
|
+
`${found.map((f) => basename(f)).join(", ")}\n\n`,
|
|
1040
|
+
);
|
|
1041
|
+
const token = githubToken();
|
|
1042
|
+
const login = token ? await credentialLogin(token) : null;
|
|
1043
|
+
const folder = basename(resolve(entry!, ".."));
|
|
1044
|
+
const answered = await askWhereToPublish(
|
|
1045
|
+
`${login ?? "your-account"}/${folder}`,
|
|
1046
|
+
{ stdin: process.stdin as unknown as TTYIn, stdout: process.stdout as unknown as TTYOut },
|
|
1047
|
+
);
|
|
1048
|
+
if (answered === null) return ok(["Nothing was published."]);
|
|
1049
|
+
repo = answered;
|
|
1050
|
+
}
|
|
1051
|
+
// A tag on HEAD is somebody SAYING what this release is, so it wins over a
|
|
1052
|
+
// counted one. Everything else is decided after the repository is known,
|
|
1053
|
+
// because the count comes from what that repository has already published.
|
|
1054
|
+
let version = flag("version") ?? (entry ? inferTaggedVersion(entry) : null) ?? undefined;
|
|
1055
|
+
let versionCounted = false;
|
|
436
1056
|
// The id and the repo are usually the same words, and requiring both is asking
|
|
437
1057
|
// the same question twice. Either one alone answers for the other.
|
|
438
|
-
const id = flag("id") ?? repo;
|
|
439
|
-
const tag = flag("tag") ?? version;
|
|
440
1058
|
const dryRun = rest.includes("--dry-run") || !repo;
|
|
1059
|
+
// A dry run publishes nothing, so the id only has to name the artifact it
|
|
1060
|
+
// writes — and refusing to build one because the folder has no git remote
|
|
1061
|
+
// yet blocked the exact thing a dry run is for: looking at the pack BEFORE
|
|
1062
|
+
// committing to a repository for it. The folder name is what the repository
|
|
1063
|
+
// would be called anyway, and `--id` or `--repo` overrides it the moment
|
|
1064
|
+
// either is known.
|
|
1065
|
+
//
|
|
1066
|
+
// `local/` rather than a guessed account: the owner is genuinely not known
|
|
1067
|
+
// yet, and a plausible-looking one would be baked into a manifest somebody
|
|
1068
|
+
// could upload by hand.
|
|
1069
|
+
const id =
|
|
1070
|
+
flag("id") ?? repo ?? (dryRun && entry ? `local/${basename(resolve(entry, ".."))}` : undefined);
|
|
1071
|
+
const [owner, name] = (repo ?? "/").split("/");
|
|
1072
|
+
if (repo && (!owner || !name || repo.split("/").length !== 2)) {
|
|
1073
|
+
return fail([`--repo must be <owner>/<repo>, got ${JSON.stringify(repo)}`]);
|
|
1074
|
+
}
|
|
441
1075
|
|
|
442
|
-
if (!entry || !
|
|
1076
|
+
if (!entry || !id) {
|
|
443
1077
|
return fail([
|
|
444
|
-
"Usage: failproofai publish <entry.mjs> --repo <owner>/<repo> --version <
|
|
1078
|
+
"Usage: failproofai publish <entry.mjs> [--repo <owner>/<repo>] [--version <v>]",
|
|
1079
|
+
"",
|
|
1080
|
+
"Inside a git checkout with a github remote, both are inferred — the repo",
|
|
1081
|
+
"from the remote, the version from a tag on HEAD or the short commit SHA.",
|
|
1082
|
+
"Outside one, name them:",
|
|
445
1083
|
" [--id <publisher/name>] [--tag <tag>] [--notes <text>]",
|
|
446
1084
|
" [--out <dir>] [--effect enforce|observe] [--dry-run]",
|
|
447
1085
|
"",
|
|
@@ -449,26 +1087,108 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
449
1087
|
]);
|
|
450
1088
|
}
|
|
451
1089
|
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
1090
|
+
// Check an EXPLICIT tag against an explicit version before anything reaches
|
|
1091
|
+
// the network. When the version is counted the tag simply follows it and can
|
|
1092
|
+
// never disagree — but a tag the user typed can, and refusing it here is what
|
|
1093
|
+
// stops a doomed publish from first creating a repository for itself.
|
|
1094
|
+
const explicitTag = flag("tag");
|
|
1095
|
+
if (explicitTag && version && !packTagMatchesVersion(explicitTag, version)) {
|
|
1096
|
+
return fail([
|
|
1097
|
+
`Tag ${explicitTag} does not describe version ${version}, so nobody could install it.`,
|
|
1098
|
+
`A pack is fetched from releases/download/${explicitTag}/… and then reports`,
|
|
1099
|
+
`${version} — which names no release. Use --tag ${version} (a leading v is`,
|
|
1100
|
+
"fine), or build the pack at the version the tag says.",
|
|
1101
|
+
]);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// The version may still be unknown here: with nothing explicit and no tag, it
|
|
1105
|
+
// is one past whatever the repository has already published, which cannot be
|
|
1106
|
+
// known without asking it. So the credential and the repository come first —
|
|
1107
|
+
// the reverse of the old order, where building first avoided authenticating
|
|
1108
|
+
// for a pack that does not load. That trade is still paid: the build runs
|
|
1109
|
+
// immediately after, before anything is created or uploaded.
|
|
1110
|
+
let token: string | null = null;
|
|
1111
|
+
let created = false;
|
|
1112
|
+
let repoInfo: Awaited<ReturnType<typeof gh>> | null = null;
|
|
1113
|
+
if (!dryRun) {
|
|
1114
|
+
token = githubToken();
|
|
1115
|
+
if (!token) {
|
|
1116
|
+
return fail([
|
|
1117
|
+
"No GitHub credential found.",
|
|
1118
|
+
"Set GITHUB_TOKEN (or GH_TOKEN), or sign in once with `gh auth login`.",
|
|
1119
|
+
"It needs write access to releases on that repository, and nothing else.",
|
|
1120
|
+
]);
|
|
1121
|
+
}
|
|
1122
|
+
const ensured = await ensureRepo(owner, name, id, token, resolve(entry, ".."));
|
|
1123
|
+
if ("error" in ensured) return fail(ensured.error);
|
|
1124
|
+
created = ensured.created;
|
|
1125
|
+
repoInfo = ensured.info;
|
|
1126
|
+
if (!version) {
|
|
1127
|
+
version = await nextVersion(owner, name, token);
|
|
1128
|
+
versionCounted = true;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// A dry run has nothing to count against, so it reports the first version. It
|
|
1132
|
+
// says so rather than implying the number is settled.
|
|
1133
|
+
if (!version) {
|
|
1134
|
+
version = "1.0.0";
|
|
1135
|
+
versionCounted = true;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const tag = flag("tag") ?? version;
|
|
1139
|
+
|
|
1140
|
+
// One artifact, however many files it was written across. `build` refuses an
|
|
1141
|
+
// entry with relative imports because only the entry is digest-pinned — so
|
|
1142
|
+
// the fix is to make it ONE file here, not to send the author away to set up
|
|
1143
|
+
// a bundler for a step this tool already performs for its own pack.
|
|
1144
|
+
const outDirEarly = resolve(flag("out") ?? "dist-pack");
|
|
1145
|
+
let entryToBuild = entry;
|
|
1146
|
+
const needsBundle =
|
|
1147
|
+
discovered.length > 1 ||
|
|
1148
|
+
/(?:^|\n)\s*(?:import|export)[^;\n]*from\s+["']\.[^"']*["']/.test(
|
|
1149
|
+
readFileSync(entry, "utf8"),
|
|
1150
|
+
);
|
|
1151
|
+
if (needsBundle) {
|
|
1152
|
+
const sources = discovered.length > 1 ? discovered : [entry];
|
|
1153
|
+
const bundled = bundleEntry(sources, outDirEarly);
|
|
1154
|
+
if ("error" in bundled) return fail(bundled.error);
|
|
1155
|
+
entryToBuild = bundled.path;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const built = await build([entryToBuild, "--id", id, "--version", version, ...outFlagFrom(rest), ...effectFlagFrom(rest)]);
|
|
455
1159
|
if (built.exitCode !== 0) return built;
|
|
456
1160
|
|
|
457
|
-
const outDir =
|
|
1161
|
+
const outDir = outDirEarly;
|
|
1162
|
+
const bundleNote =
|
|
1163
|
+
discovered.length > 1
|
|
1164
|
+
? [`Bundled ${discovered.length} files into one artifact:`,
|
|
1165
|
+
...discovered.map((f) => ` ${f.replace(process.cwd() + "/", "")}`), ""]
|
|
1166
|
+
: [];
|
|
458
1167
|
if (dryRun) {
|
|
459
1168
|
return ok([
|
|
1169
|
+
...bundleNote,
|
|
460
1170
|
...built.lines.slice(0, 4),
|
|
461
1171
|
"",
|
|
462
1172
|
...(repo
|
|
463
1173
|
? ["Dry run — nothing was published.", `Drop --dry-run to release it on ${repo}.`]
|
|
464
1174
|
: [
|
|
465
1175
|
"Nothing was published: name a repository to release it on.",
|
|
466
|
-
|
|
1176
|
+
// Just the repository. It used to spell out the entry file and the
|
|
1177
|
+
// version too — naming ONE of the files it had that moment finished
|
|
1178
|
+
// bundling, and pinning a version it works out by counting the
|
|
1179
|
+
// repository's own releases. Both were wrong the moment they were
|
|
1180
|
+
// printed, and both taught the reader that publishing needs flags
|
|
1181
|
+
// it does not need.
|
|
1182
|
+
" failproofai publish --repo <owner>/<repo>",
|
|
467
1183
|
]),
|
|
468
1184
|
]);
|
|
469
1185
|
}
|
|
470
1186
|
|
|
471
|
-
|
|
1187
|
+
// Narrowed once: every path reaching here passed through the `!dryRun` branch
|
|
1188
|
+
// above, which returns when there is no credential.
|
|
1189
|
+
const auth = token as string;
|
|
1190
|
+
|
|
1191
|
+
if (!packTagMatchesVersion(tag, version)) {
|
|
472
1192
|
return fail([
|
|
473
1193
|
`Tag ${tag} does not describe version ${version}, so nobody could install it.`,
|
|
474
1194
|
`A pack is fetched from releases/download/${tag}/… and then reports ${version} —`,
|
|
@@ -477,41 +1197,20 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
477
1197
|
]);
|
|
478
1198
|
}
|
|
479
1199
|
|
|
480
|
-
const [owner, name] = repo!.split("/");
|
|
481
|
-
if (!owner || !name || repo!.split("/").length !== 2) {
|
|
482
|
-
return fail([`--repo must be <owner>/<repo>, got ${JSON.stringify(repo)}`]);
|
|
483
|
-
}
|
|
484
1200
|
|
|
485
|
-
const token = githubToken();
|
|
486
|
-
if (!token) {
|
|
487
|
-
return fail([
|
|
488
|
-
"No GitHub credential found.",
|
|
489
|
-
"Set GITHUB_TOKEN (or GH_TOKEN), or sign in once with `gh auth login`.",
|
|
490
|
-
"It needs write access to releases on that repository, and nothing else.",
|
|
491
|
-
]);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const repoInfo = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
|
|
495
|
-
if (repoInfo.status === 404) {
|
|
496
|
-
return fail([
|
|
497
|
-
`${repo} does not exist, or that credential cannot see it.`,
|
|
498
|
-
`Create it first: gh repo create ${repo} --public`,
|
|
499
|
-
]);
|
|
500
|
-
}
|
|
501
|
-
if (repoInfo.status >= 400) return fail([`Could not read ${repo}: ${ghError(repoInfo)}`]);
|
|
502
1201
|
// A private repo is not a smaller audience, it is no audience: `pack add`
|
|
503
1202
|
// sends no Authorization header at all, by design, so every install 404s.
|
|
504
|
-
const isPrivate = repoInfo
|
|
1203
|
+
const isPrivate = repoInfo?.json?.private === true;
|
|
505
1204
|
|
|
506
1205
|
// Reuse a release on this tag rather than failing on it. Re-publishing a
|
|
507
1206
|
// corrected artifact under an existing tag is a normal thing to need, and the
|
|
508
1207
|
// digest change is visible to anyone who reinstalls.
|
|
509
1208
|
let releaseId: number | null = null;
|
|
510
|
-
const existing = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/tags/${encodeURIComponent(tag
|
|
1209
|
+
const existing = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/tags/${encodeURIComponent(tag)}`, auth);
|
|
511
1210
|
if (existing.status === 200 && typeof existing.json?.id === "number") {
|
|
512
1211
|
releaseId = existing.json.id;
|
|
513
1212
|
} else {
|
|
514
|
-
const created = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases`,
|
|
1213
|
+
const created = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases`, auth, {
|
|
515
1214
|
method: "POST",
|
|
516
1215
|
contentType: "application/json",
|
|
517
1216
|
body: JSON.stringify({
|
|
@@ -533,7 +1232,7 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
533
1232
|
|
|
534
1233
|
const assets = [PACK_MANIFEST_ASSET, PACK_ENTRY_ASSET, PACK_CHECKSUMS_ASSET];
|
|
535
1234
|
const uploaded: string[] = [];
|
|
536
|
-
const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`,
|
|
1235
|
+
const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`, auth);
|
|
537
1236
|
const already = Array.isArray(listed.json)
|
|
538
1237
|
? (listed.json as unknown as Array<{ id: number; name: string }>)
|
|
539
1238
|
: [];
|
|
@@ -543,12 +1242,12 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
543
1242
|
// append.
|
|
544
1243
|
const prior = already.find((a) => a.name === asset);
|
|
545
1244
|
if (prior) {
|
|
546
|
-
await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/assets/${prior.id}`,
|
|
1245
|
+
await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/assets/${prior.id}`, auth, { method: "DELETE" });
|
|
547
1246
|
}
|
|
548
1247
|
const bytes = readFileSync(resolve(outDir, asset));
|
|
549
1248
|
const upload = await gh(
|
|
550
1249
|
`${GITHUB_UPLOADS}/repos/${owner}/${name}/releases/${releaseId}/assets?name=${encodeURIComponent(asset)}`,
|
|
551
|
-
|
|
1250
|
+
auth,
|
|
552
1251
|
{
|
|
553
1252
|
method: "POST",
|
|
554
1253
|
contentType: asset.endsWith(".json") ? "application/json" : "application/octet-stream",
|
|
@@ -566,7 +1265,10 @@ async function publish(rest: string[]): Promise<PackCliResult> {
|
|
|
566
1265
|
}
|
|
567
1266
|
|
|
568
1267
|
const lines = [
|
|
569
|
-
|
|
1268
|
+
...bundleNote,
|
|
1269
|
+
...(created ? [`Created ${repo} (public).`] : []),
|
|
1270
|
+
`Published ${id}@${version} to ${repo} at tag ${tag}.` +
|
|
1271
|
+
(versionCounted ? " Next publish will be one past it." : ""),
|
|
570
1272
|
` ${assets.length} assets attached`,
|
|
571
1273
|
"",
|
|
572
1274
|
"Anyone can now install it:",
|
|
@@ -601,6 +1303,43 @@ export async function runPublishCommand(rest: string[]): Promise<PackCliResult>
|
|
|
601
1303
|
return publish(rest);
|
|
602
1304
|
}
|
|
603
1305
|
|
|
1306
|
+
/**
|
|
1307
|
+
* Which agents should this pack guard?
|
|
1308
|
+
*
|
|
1309
|
+
* Asked BEFORE the policy list, because it is the coarser question and the
|
|
1310
|
+
* answer changes what the second screen is for. Setup no longer asks it: hooks
|
|
1311
|
+
* go into every supported agent, since hooks alone enforce nothing, so the
|
|
1312
|
+
* decision moved here — where the user is looking at a concrete pack instead of
|
|
1313
|
+
* answering in the abstract before anything is installed.
|
|
1314
|
+
*
|
|
1315
|
+
* Every supported agent is offered and all are pre-ticked. Detected ones are
|
|
1316
|
+
* marked, but an undetected agent is NOT excluded: installing one next week is
|
|
1317
|
+
* exactly the case setup's install-everywhere behaviour exists to cover, and a
|
|
1318
|
+
* pack that silently skipped it would undo that.
|
|
1319
|
+
*
|
|
1320
|
+
* Returns null when cancelled — distinct from picking every agent.
|
|
1321
|
+
*/
|
|
1322
|
+
async function pickClis(
|
|
1323
|
+
io: { stdin: TTYIn; stdout: TTYOut },
|
|
1324
|
+
): Promise<string[] | null> {
|
|
1325
|
+
const detected = new Set(detectInstalledClis());
|
|
1326
|
+
const choices: MultiChoice<string>[] = INTEGRATION_TYPES.map((id) => ({
|
|
1327
|
+
label: id,
|
|
1328
|
+
value: id,
|
|
1329
|
+
checked: true,
|
|
1330
|
+
hint: detected.has(id) ? "installed here" : "",
|
|
1331
|
+
}));
|
|
1332
|
+
return multiSelect<string>({
|
|
1333
|
+
message: "Which agents should this pack guard?",
|
|
1334
|
+
choices,
|
|
1335
|
+
minSelected: 1,
|
|
1336
|
+
summaryNoun: "agents",
|
|
1337
|
+
hint: `space toggles · ctrl+a all · ↵ confirm · ${detected.size} detected on this machine`,
|
|
1338
|
+
stdin: io.stdin,
|
|
1339
|
+
stdout: io.stdout,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
604
1343
|
/**
|
|
605
1344
|
* The pack's own list, defaults pre-ticked, before anything is installed.
|
|
606
1345
|
*
|
|
@@ -683,6 +1422,15 @@ async function add(rest: string[]): Promise<PackCliResult> {
|
|
|
683
1422
|
const io = { stdin: process.stdin as TTYIn, stdout: process.stdout as TTYOut };
|
|
684
1423
|
const chose = !selection.only && !selection.categories && !selection.all;
|
|
685
1424
|
if (chose && io.stdin.isTTY && io.stdout.isTTY) {
|
|
1425
|
+
// Agents first, then policies. Coarse before fine, and the coarse answer is
|
|
1426
|
+
// the one somebody can give without reading thirty-eight descriptions.
|
|
1427
|
+
if (!selection.clis) {
|
|
1428
|
+
const agents = await pickClis(io);
|
|
1429
|
+
if (agents === null) return ok(["Nothing installed."]);
|
|
1430
|
+
// Every agent ticked is the same as not narrowing at all, and writing the
|
|
1431
|
+
// full list would freeze this pack out of any CLI supported later.
|
|
1432
|
+
if (agents.length < INTEGRATION_TYPES.length) selection.clis = agents;
|
|
1433
|
+
}
|
|
686
1434
|
const picked = await pickFromSource(resolvedSource!, io);
|
|
687
1435
|
if (picked === null) return ok(["Nothing installed."]);
|
|
688
1436
|
selection.only = picked;
|