failproofai 1.0.7 → 1.0.8-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +4 -4
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +4 -4
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +8 -7
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +9 -7
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +6 -6
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +11 -10
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +8 -7
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -6
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +7 -6
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +52 -8
- package/.next/standalone/.next/server/app/settings/page.js +12 -7
- 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]__1msfs-h._.js → [externals]__19_pzeq._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1_bftcl._.js → [externals]__20kzpkf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +22 -0
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +21 -21
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +16 -16
- package/.next/standalone/.next/server/chunks/{_1c3k-8x._.js → _1q5i8mb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/lib_16xa545._.js +3 -0
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +5 -0
- package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +3 -0
- package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +4 -2
- package/.next/standalone/.next/server/chunks/ssr/[externals]__0ohnuzs._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01bmjsj._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__059yza8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06pflha._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0eip4_k._.js +22 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qcb0mg._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0yrsbd_._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12e7nhs._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13d-wb6._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0bd3mje._.js → [root-of-the-server]__14o3ek1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19evfi8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1dinjii._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0cxe_2_._.js → [root-of-the-server]__1mf3zp6._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qiz0e4._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_166t73i._.js → _0o4xkpl._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0uyu3jf._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_1-i_gzc._.js → _1-7sqrb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_1es2j7i._.js → _1gb0ifp._.js} +5 -5
- package/.next/standalone/.next/server/chunks/ssr/{_1_qswah._.js → _1q46vxx._.js} +2 -2
- 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 +13 -13
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +3 -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_02tf1h4._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0aiy-os._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_next_dist_0drixxt._.js → node_modules_next_dist_0w6mzq5._.js} +4 -4
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_18_d8l1._.js +151 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0-q0umm._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9d._.js +12 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0g194sy._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +4 -2
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1kx9e0d._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- 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 +67 -23
- package/.next/standalone/.next/static/chunks/078gnymqoh3r4.js +1 -0
- package/.next/standalone/.next/static/chunks/{2mdh397ghgnvv.js → 0ahfwmbkpgfw4.js} +1 -1
- package/.next/standalone/.next/static/chunks/0bhidk90e-07f.css +2 -0
- package/.next/standalone/.next/static/chunks/1bu2-nv59ed6i.js +1 -0
- package/.next/standalone/.next/static/chunks/{0o6qlkgubtoex.js → 1v_tp3hm8wyhe.js} +1 -1
- package/.next/standalone/.next/static/chunks/2a405_e1o26ol.js +1 -0
- package/.next/standalone/.next/static/chunks/2qv4hshejedtx.css +1 -0
- package/.next/standalone/.next/static/chunks/2vo7qbdbvnc0o.js +69 -0
- package/.next/standalone/.next/static/chunks/{13i7-9is-vhys.js → 2z7i-yg59w4pn.js} +1 -1
- package/.next/standalone/.next/static/chunks/36uhh9el_oz8e.js +1 -0
- package/.next/standalone/.next/static/chunks/{0__8a7m868fvf.js → 3bgot5v6f6s3c.js} +1 -1
- package/.next/standalone/.next/static/chunks/3o3f1ibfci0p7.js +6 -0
- package/.next/standalone/PROBE-FOLLOWUP.md +186 -0
- package/.next/standalone/app/actions/get-jev-config.ts +604 -0
- package/.next/standalone/app/actions/pack-actions.ts +12 -0
- package/.next/standalone/app/actions/update-jev-config.ts +569 -0
- package/.next/standalone/app/components/jev-notices.tsx +96 -0
- package/.next/standalone/app/policies/hooks-client.tsx +14 -3
- package/.next/standalone/app/settings/jev-panel.tsx +630 -0
- package/.next/standalone/app/settings/page.tsx +20 -1
- package/.next/standalone/app/settings/settings-client.tsx +27 -1
- package/.next/standalone/app/settings/settings.css +79 -0
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +22 -4
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +14 -1
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +17 -3
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +3 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +22 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +169 -0
- package/.next/standalone/fp-cloud-cli/skill/references/commands.md +1 -1
- package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +65 -0
- package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +61 -0
- package/.next/standalone/package.json +10 -10
- package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
- package/.next/standalone/sdk/typescript/scripts/release.mjs +30 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +3 -2
- package/bin/failproofai.mjs +148 -4
- package/dist/cli.mjs +18537 -9492
- package/dist/index.js +19 -1
- package/dist/worker.mjs +10421 -3770
- package/package.json +10 -10
- package/pi-extension/index.ts +11 -0
- package/scripts/build-policy-pack.mjs +53 -2
- package/src/audit/features.ts +3 -2
- package/src/hooks/builtin-policies.ts +195 -8
- package/src/hooks/cloud-connection.ts +190 -1
- package/src/hooks/cloud-enrollment-cli.ts +57 -8
- package/src/hooks/cloud-introspect.ts +6 -0
- package/src/hooks/cloud-managed-policies.ts +22 -0
- package/src/hooks/configure-wizard.ts +26 -6
- package/src/hooks/custom-hooks-loader.ts +98 -10
- package/src/hooks/custom-hooks-registry.ts +45 -1
- package/src/hooks/effective-reviewers.ts +243 -0
- package/src/hooks/first-run-gate.ts +5 -0
- package/src/hooks/flush-cli.ts +35 -8
- package/src/hooks/fp-config.ts +266 -1
- package/src/hooks/fp-home.ts +23 -0
- package/src/hooks/fp-reset.ts +22 -4
- package/src/hooks/handler.ts +303 -7
- package/src/hooks/hook-activity-store.ts +118 -6
- package/src/hooks/hook-telemetry.ts +41 -0
- package/src/hooks/jev-activity.ts +385 -0
- package/src/hooks/jev-cli.ts +2172 -0
- package/src/hooks/jev-cloud-connection.ts +392 -0
- package/src/hooks/loader-utils.ts +6 -0
- package/src/hooks/manager.ts +45 -7
- package/src/hooks/pack-cli.ts +568 -37
- package/src/hooks/pack-failclosed.ts +3 -0
- package/src/hooks/pack-manifest.ts +489 -7
- package/src/hooks/pack-store.ts +194 -11
- package/src/hooks/policy-authority.ts +386 -0
- package/src/hooks/policy-catalog.ts +206 -0
- package/src/hooks/policy-evaluator.ts +963 -796
- package/src/hooks/policy-registry.ts +27 -1
- package/src/hooks/policy-reviewability.ts +258 -0
- package/src/hooks/policy-types.ts +128 -0
- package/src/hooks/semantic/combine.ts +581 -0
- package/src/hooks/semantic/compile.ts +176 -0
- package/src/hooks/semantic/decide.ts +408 -0
- package/src/hooks/semantic/envelope.ts +1255 -0
- package/src/hooks/semantic/evaluator.ts +556 -0
- package/src/hooks/semantic/facts.ts +298 -0
- package/src/hooks/semantic/intent.ts +1190 -0
- package/src/hooks/semantic/jev-client.ts +1155 -0
- package/src/hooks/semantic/jev-config.ts +1142 -0
- package/src/hooks/semantic/jev-review.ts +389 -0
- package/src/hooks/semantic/jev-stats.ts +289 -0
- package/src/hooks/semantic/jev-throttle.ts +427 -0
- package/src/hooks/semantic/pack-policies.ts +294 -0
- package/src/hooks/semantic/policies.ts +597 -0
- package/src/hooks/semantic/precondition-names.ts +60 -0
- package/src/hooks/semantic/preconditions.ts +58 -0
- package/src/hooks/semantic/redact.ts +2937 -0
- package/src/hooks/semantic/session-root.ts +116 -0
- package/src/hooks/semantic/types.ts +156 -0
- package/src/hooks/semver-precedence.ts +128 -0
- package/src/hooks/tui.ts +4 -0
- package/src/hooks/types.ts +1 -1
- package/src/hooks/worker-server.ts +121 -27
- package/src/index.ts +6 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cuho4x._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1_r2rbg._.js +0 -24
- package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-_ki57._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cg-bgc._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cpu_mj._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u3w0ll._.js +0 -22
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17d_ffl._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19d9tgz._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1phc187._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_06imw3p._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/_0h_douw._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +0 -3
- package/.next/standalone/.next/static/chunks/094xgi4owxaqf.js +0 -1
- package/.next/standalone/.next/static/chunks/1rz20_pz828f3.js +0 -6
- package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +0 -1
- package/.next/standalone/.next/static/chunks/2klitrtzpaoe0.js +0 -1
- package/.next/standalone/.next/static/chunks/2rshywgeqsyzk.css +0 -2
- package/.next/standalone/.next/static/chunks/3pzx4chkhko9k.js +0 -1
- package/.next/standalone/.next/static/chunks/3rh5o7e16irrm.js +0 -69
- package/.next/standalone/.next/static/chunks/43ufqrz8qo3h-.js +0 -1
- /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → O_b5R1axf5cb4NxIXq2kS}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → O_b5R1axf5cb4NxIXq2kS}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → O_b5R1axf5cb4NxIXq2kS}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,2172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `failproofai jev` — configure the customer's own Jev endpoint and key (BYOK),
|
|
3
|
+
* the single opt-in to the two-tier hook evaluator.
|
|
4
|
+
*
|
|
5
|
+
* jev --url <url> [--token <token>]
|
|
6
|
+
* the one-shot form: the same thing as `setup`, with the provider
|
|
7
|
+
* read off the URL's host
|
|
8
|
+
* jev setup write ~/.failproofai/jev.json at 0600 from flags + a key
|
|
9
|
+
* jev status what is configured, whether it is being used, how much of the
|
|
10
|
+
* enabled policy set Jev is allowed to clear, and how Jev has
|
|
11
|
+
* been doing (fallbacks, latency, clears) — never the key
|
|
12
|
+
* jev test one tiny live request: latency and the Jev version that answered
|
|
13
|
+
* jev models what `GET <base>/models` says this endpoint serves
|
|
14
|
+
* jev remove delete the file; hooks go back to the regex engine unchanged
|
|
15
|
+
*
|
|
16
|
+
* # `--url` is a BASE, and the model has to exist
|
|
17
|
+
*
|
|
18
|
+
* Two contract mistakes cost a customer on a LiteLLM proxy real time, and both
|
|
19
|
+
* were things this command already knew enough to say:
|
|
20
|
+
*
|
|
21
|
+
* - `--url https://…/typesafe/v1/models` was an ENDPOINT given where a BASE
|
|
22
|
+
* belongs. It saved without complaint, `/systemone` was appended to it, and the
|
|
23
|
+
* only signal was `http-404: Not Found` from `jev test`. Setup now refuses a URL
|
|
24
|
+
* whose path ends in `/models`, `/chat/completions`, `/completions`,
|
|
25
|
+
* `/embeddings` or `/systemone` and names the base it implies
|
|
26
|
+
* (`endpointGivenAsBase`).
|
|
27
|
+
* - the model then still did not exist: `custom`'s default is `jev-1.13.0` and
|
|
28
|
+
* that proxy serves `jev-latest` and `jev-preview`. Setup now reads
|
|
29
|
+
* `GET <base>/models` before it writes, and refuses a model the list does not
|
|
30
|
+
* carry, naming the ones it does. When the list cannot be read — no key yet, a
|
|
31
|
+
* proxy that serves no list, Cloudflare, an unreachable host — setup proceeds
|
|
32
|
+
* exactly as it did before: this is a check where one is possible, never a
|
|
33
|
+
* dependency on a model-list endpoint existing.
|
|
34
|
+
*
|
|
35
|
+
* # The provider is read off the URL
|
|
36
|
+
*
|
|
37
|
+
* `--url` names the endpoint, and its HOST says which provider that is
|
|
38
|
+
* (`PROVIDER_BY_HOST`); anything else is `custom` with that URL as its base.
|
|
39
|
+
* `--provider` still overrides the inference, but a provider that contradicts
|
|
40
|
+
* the URL's host — `--provider openrouter` against `api.typesafe.ai` — is
|
|
41
|
+
* refused rather than guessed at, because the two spellings disagree about
|
|
42
|
+
* where the key is about to be sent. `--url` is otherwise exactly
|
|
43
|
+
* `--base-url`: it is validated by the same `validateBaseUrl` the loader uses
|
|
44
|
+
* and refused in the same words, and when it names the provider's own API it
|
|
45
|
+
* clears the override rather than writing it back.
|
|
46
|
+
*
|
|
47
|
+
* The provider NAME is validated before any of that, whatever else is on the
|
|
48
|
+
* command line: a spelling that is not a provider — `OpenRouter`, a typo, a
|
|
49
|
+
* pasted key — is refused, never replaced by the one the host implies. Two
|
|
50
|
+
* combinations the URL settles rather than saves: `--provider custom` against
|
|
51
|
+
* `api.cloudflare.com`, which `custom` cannot speak (Workers AI is per-account
|
|
52
|
+
* and wraps its answers), and `--account-id` for any provider but cloudflare,
|
|
53
|
+
* which is the only one whose route reads it. And a URL that keeps the provider
|
|
54
|
+
* but leaves its API base — `https://api.typesafe.ai/v2` — is saved with a
|
|
55
|
+
* warning naming the endpoint requests will actually go to, because nothing
|
|
56
|
+
* else would say so until the fallback counts did.
|
|
57
|
+
*
|
|
58
|
+
* # The key is never echoed
|
|
59
|
+
*
|
|
60
|
+
* `--token <value>` exists because one command that configures Jev is worth
|
|
61
|
+
* having, but it is not the recommended spelling and says so at the end of
|
|
62
|
+
* every run that uses it: a command-line argument lands in shell history and
|
|
63
|
+
* is readable from `ps` — from `/proc` — by anything running as this user.
|
|
64
|
+
* The key otherwise arrives on stdin (`--key-stdin`), at a masked prompt when
|
|
65
|
+
* stdin is a terminal, or — for people who keep keys off disk — from
|
|
66
|
+
* `FAILPROOFAI_JEV_API_KEY` at run time (`--key-from-env`). `--key-stdin` on a
|
|
67
|
+
* terminal uses the masked prompt too, because a cooked-mode read would echo
|
|
68
|
+
* each character as it is typed. No output of this module, human or `--json`,
|
|
69
|
+
* contains the key; provider error text is scrubbed of it in `jev-client.ts`,
|
|
70
|
+
* and `jev test` scrubs whatever error it prints once more. Nothing here
|
|
71
|
+
* repeats a value it could not use (a stray argument, an unknown provider, an
|
|
72
|
+
* unreadable timeout, a model id shaped like a key, an option that is not one,
|
|
73
|
+
* or an unknown subcommand that is not shaped like one): a key pasted into the
|
|
74
|
+
* wrong place on the command line is already in shell history, and does not
|
|
75
|
+
* also need to be on the screen.
|
|
76
|
+
*
|
|
77
|
+
* # A stored key stays with its host
|
|
78
|
+
*
|
|
79
|
+
* Re-running `setup` for the same provider keeps the stored key. When the new
|
|
80
|
+
* `--base-url` moves requests to a different origin — other than the
|
|
81
|
+
* provider's own API — the key is asked for again, exactly as on a provider
|
|
82
|
+
* switch: for `custom` (and any override) the URL is what picks the gateway,
|
|
83
|
+
* and a key issued for one gateway must not be sent to another unasked.
|
|
84
|
+
*
|
|
85
|
+
* A file that was open to other users is the loader's "someone else may have
|
|
86
|
+
* chosen the endpoint" case, and re-saving it at 0600 must not undo that: its
|
|
87
|
+
* stored key is carried only to the provider's own API. Any other endpoint it
|
|
88
|
+
* names — kept or passed again with `--base-url` — needs the key again, given
|
|
89
|
+
* explicitly (not at a bare prompt that would not say where it goes). `jev
|
|
90
|
+
* status` shows such a file's endpoint next to its `chmod 600` hint.
|
|
91
|
+
*
|
|
92
|
+
* # No restart
|
|
93
|
+
*
|
|
94
|
+
* Hooks read the file on every event (see `loadJevConfig`), so a setup, a mode
|
|
95
|
+
* switch or a remove applies on the next tool call, daemon or not.
|
|
96
|
+
*/
|
|
97
|
+
import { chmodSync, existsSync, statSync, unlinkSync } from "node:fs";
|
|
98
|
+
import { dirname } from "node:path";
|
|
99
|
+
import { writeJsonAtomically } from "../../lib/atomic-write";
|
|
100
|
+
import {
|
|
101
|
+
DEFAULT_JEV_MODE,
|
|
102
|
+
JEV_API_KEY_ENV,
|
|
103
|
+
JEV_CLOUD_PROVIDER,
|
|
104
|
+
JEV_CONFIG_DEFAULT_TIMEOUT_MS,
|
|
105
|
+
JEV_PROVIDER_KINDS,
|
|
106
|
+
MAX_JEV_TIMEOUT_MS,
|
|
107
|
+
endpointGivenAsBase,
|
|
108
|
+
inspectJevConfig,
|
|
109
|
+
jevCloudBaseUrl,
|
|
110
|
+
jevConfigPath,
|
|
111
|
+
providerForUrl,
|
|
112
|
+
providerHostConflict,
|
|
113
|
+
readJevConfigFileForUpdate,
|
|
114
|
+
validateApiKey,
|
|
115
|
+
validateBaseUrl,
|
|
116
|
+
validateJevConfig,
|
|
117
|
+
type EndpointGivenAsBase,
|
|
118
|
+
type JevConfig,
|
|
119
|
+
type JevConfigInspection,
|
|
120
|
+
type JevProviderKind,
|
|
121
|
+
} from "./semantic/jev-config";
|
|
122
|
+
import {
|
|
123
|
+
JEV_MODEL_LIST_TIMEOUT_MS,
|
|
124
|
+
JEV_PROVIDER_DEFAULTS,
|
|
125
|
+
JevError,
|
|
126
|
+
displayEndpoint,
|
|
127
|
+
endpointAsBaseReason,
|
|
128
|
+
jevModelsUrl,
|
|
129
|
+
jevRoute,
|
|
130
|
+
listDescribesSystemOne,
|
|
131
|
+
modelListHasModel,
|
|
132
|
+
modelsUrlForBase,
|
|
133
|
+
readAnswers,
|
|
134
|
+
readJevModelList,
|
|
135
|
+
scrubSecret,
|
|
136
|
+
transportForConfig,
|
|
137
|
+
type JevModelListRead,
|
|
138
|
+
type JevModelListResult,
|
|
139
|
+
} from "./semantic/jev-client";
|
|
140
|
+
import {
|
|
141
|
+
reviewableProblem,
|
|
142
|
+
reviewableSummary,
|
|
143
|
+
surveyReviewableCoverage,
|
|
144
|
+
type ReviewableCoverage,
|
|
145
|
+
} from "./policy-reviewability";
|
|
146
|
+
import { jevStats, type JevStats } from "./semantic/jev-stats";
|
|
147
|
+
import { readCredentials, readJevCloudCredential, type JevCloudCredential } from "./fp-config";
|
|
148
|
+
import type { JevRequest } from "./semantic/types";
|
|
149
|
+
import { TOKEN_ON_ARGV, emptyState, nextStep, note, optsFor, rows, rule, stack, title, warning, type RenderOpts } from "./tui";
|
|
150
|
+
|
|
151
|
+
export interface JevCliResult {
|
|
152
|
+
lines: string[];
|
|
153
|
+
exitCode: number;
|
|
154
|
+
/** `--json`: printed verbatim instead of `lines`. */
|
|
155
|
+
json?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `GET <base>/models`, as `setup` and `models` reach it. */
|
|
159
|
+
export type JevModelListReader = (modelsUrl: string, apiKey: string | null) => Promise<JevModelListResult>;
|
|
160
|
+
|
|
161
|
+
export interface JevCliDeps {
|
|
162
|
+
/** Everything piped on stdin (for `--key-stdin` off a terminal). */
|
|
163
|
+
readStdin?: () => Promise<string>;
|
|
164
|
+
/** A masked one-line prompt; null when cancelled. */
|
|
165
|
+
promptKey?: () => Promise<string | null>;
|
|
166
|
+
stdinIsTTY?: boolean;
|
|
167
|
+
/** Budget for `jev test`'s single request. */
|
|
168
|
+
testTimeoutMs?: number;
|
|
169
|
+
/**
|
|
170
|
+
* How `<base>/models` is read. The default reaches the network, which `setup`
|
|
171
|
+
* did not used to do at all — so it is injectable, and a unit test that is not
|
|
172
|
+
* about the list injects a reader that reads nothing rather than calling out to
|
|
173
|
+
* a provider from a test run.
|
|
174
|
+
*/
|
|
175
|
+
readModelList?: JevModelListReader;
|
|
176
|
+
render?: RenderOpts;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The real reader, on its own budget: see `JEV_MODEL_LIST_TIMEOUT_MS`. */
|
|
180
|
+
const liveModelListReader: JevModelListReader = (url, apiKey) => readJevModelList(url, apiKey, AbortSignal.timeout(JEV_MODEL_LIST_TIMEOUT_MS));
|
|
181
|
+
|
|
182
|
+
const ok = (lines: string[], json?: string): JevCliResult => ({ lines, exitCode: 0, ...(json !== undefined ? { json } : {}) });
|
|
183
|
+
const fail = (lines: string[], json?: string): JevCliResult => ({ lines, exitCode: 1, ...(json !== undefined ? { json } : {}) });
|
|
184
|
+
|
|
185
|
+
export const JEV_USAGE = [
|
|
186
|
+
"Usage:",
|
|
187
|
+
" failproofai jev --url <url> [--key-stdin | --token <token>] [options]",
|
|
188
|
+
" failproofai jev setup --provider <kind> [--key-stdin | --key-from-env] [options]",
|
|
189
|
+
" failproofai jev setup --provider failproofai [--mode off|shadow|enforce] (FailproofAI Cloud: no key, no URL)",
|
|
190
|
+
" failproofai jev status [--json]",
|
|
191
|
+
" failproofai jev test [--json]",
|
|
192
|
+
" failproofai jev models [--provider <kind>] [--url <base>] [--json]",
|
|
193
|
+
" failproofai jev remove",
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
/** One live `jev test` request: small, harmless, and with an answer that is obviously right. */
|
|
197
|
+
export const JEV_TEST_QUESTION_ID = "jev_test";
|
|
198
|
+
const DEFAULT_TEST_TIMEOUT_MS = 15_000;
|
|
199
|
+
/** Validation-only placeholder for a config whose key comes from the environment. Never written. */
|
|
200
|
+
const ENV_KEY_STAND_IN = "env-key-stand-in";
|
|
201
|
+
|
|
202
|
+
// ── Argument parsing ─────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Every subcommand's answer to a stray argument. Never the argument itself: the
|
|
206
|
+
* likeliest one is a key pasted in the wrong place, which is already in shell
|
|
207
|
+
* history and does not also need to be on the screen.
|
|
208
|
+
*/
|
|
209
|
+
const STRAY_ARGUMENT = "Unexpected argument (not repeated here, in case it is a key). The key goes on stdin: --key-stdin.";
|
|
210
|
+
|
|
211
|
+
/** What a mistyped subcommand looks like, and no key does: short, lower-case letters and dashes. */
|
|
212
|
+
const SUBCOMMAND_SHAPE = /^[a-z][a-z-]{0,20}$/;
|
|
213
|
+
|
|
214
|
+
const VALUE_FLAGS = new Set(["--provider", "--url", "--token", "--base-url", "--account-id", "--model", "--timeout-ms", "--mode"]);
|
|
215
|
+
const BOOL_FLAGS = new Set(["--key-stdin", "--key-from-env", "--json"]);
|
|
216
|
+
|
|
217
|
+
/** What an option name looks like, and no key does — the same idea as `SUBCOMMAND_SHAPE`. */
|
|
218
|
+
const OPTION_SHAPE = /^--?[a-z][a-z0-9-]{0,30}$/;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* An unknown option, named only when it is shaped like an option name. A
|
|
222
|
+
* mistyped `--token` is the one place a key is most likely to arrive glued to
|
|
223
|
+
* an option (`-token=<key>`, `--tokn=<key>`), so what is echoed is the name
|
|
224
|
+
* that failed to parse and never the value behind it.
|
|
225
|
+
*/
|
|
226
|
+
function unknownOption(arg: string): string {
|
|
227
|
+
return OPTION_SHAPE.test(arg) ? `Unknown option: ${arg}` : "Unknown option (not repeated here, in case it carries a key).";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface Parsed {
|
|
231
|
+
values: Map<string, string>;
|
|
232
|
+
bools: Set<string>;
|
|
233
|
+
positionals: string[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseFlags(argv: string[], allowed: Set<string>): Parsed | string {
|
|
237
|
+
const values = new Map<string, string>();
|
|
238
|
+
const bools = new Set<string>();
|
|
239
|
+
const positionals: string[] = [];
|
|
240
|
+
for (let i = 0; i < argv.length; i++) {
|
|
241
|
+
let arg = argv[i];
|
|
242
|
+
let inline: string | undefined;
|
|
243
|
+
// Split on `=` for a single dash too (`-token=<key>`): a value must never
|
|
244
|
+
// survive into the "unknown option" message below.
|
|
245
|
+
if (arg.startsWith("-") && arg.includes("=")) {
|
|
246
|
+
inline = arg.slice(arg.indexOf("=") + 1);
|
|
247
|
+
arg = arg.slice(0, arg.indexOf("="));
|
|
248
|
+
}
|
|
249
|
+
if (!arg.startsWith("-")) {
|
|
250
|
+
positionals.push(arg);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (!allowed.has(arg)) return unknownOption(arg);
|
|
254
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
255
|
+
const v = inline ?? argv[++i];
|
|
256
|
+
if (v === undefined || (inline === undefined && v.startsWith("--"))) return `Missing value after ${arg}`;
|
|
257
|
+
values.set(arg, v);
|
|
258
|
+
} else if (BOOL_FLAGS.has(arg)) {
|
|
259
|
+
if (inline !== undefined) return `${arg} takes no value`;
|
|
260
|
+
bools.add(arg);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { values, bools, positionals };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Rendering helpers ────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
function modeLine(mode: NonNullable<JevConfig["mode"]>): string {
|
|
269
|
+
if (mode === "off") return "off — Jev is not asked at all; the regex policies decide alone";
|
|
270
|
+
return mode === "enforce"
|
|
271
|
+
? "enforce — Jev's verdicts apply: it may clear a reviewable policy's deny and add its own"
|
|
272
|
+
: "shadow — Jev is asked and logged; the regex result is what is enforced";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The name a provider is SHOWN by. Only the Cloud route differs from its kind:
|
|
277
|
+
* `failproofai` is the value in the file, and "FailproofAI Cloud" is what the
|
|
278
|
+
* person connected to.
|
|
279
|
+
*/
|
|
280
|
+
export function providerLabel(provider: JevProviderKind): string {
|
|
281
|
+
return provider === JEV_CLOUD_PROVIDER ? "FailproofAI Cloud" : provider;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Where requests go, as `jev status` shows it. The Cloud route shows its HOST
|
|
286
|
+
* only: the path under it is the Cloud's own routing, the same on every
|
|
287
|
+
* deployment, and the host is the one fact that says which Cloud this machine
|
|
288
|
+
* is talking to.
|
|
289
|
+
*/
|
|
290
|
+
function shownEndpoint(provider: JevProviderKind, endpoint: string): string {
|
|
291
|
+
if (provider !== JEV_CLOUD_PROVIDER) return displayEndpoint(endpoint);
|
|
292
|
+
try {
|
|
293
|
+
return new URL(endpoint).host;
|
|
294
|
+
} catch {
|
|
295
|
+
return "(invalid URL)";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** What `jev status` says about where the key comes from. */
|
|
300
|
+
const CLOUD_KEY_SOURCE = "FailproofAI Cloud connection";
|
|
301
|
+
|
|
302
|
+
/** Whether this machine holds a usable Jev credential, read owner-only. Never the key. */
|
|
303
|
+
function cloudCredentialPresent(): boolean {
|
|
304
|
+
return cloudConnection().keyCarriesJev;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Whether a refused or missing jev.json is fixed from the Cloud connection: it
|
|
309
|
+
* names the Cloud, or it names no provider (absent, not JSON) on a machine whose
|
|
310
|
+
* key carries Jev — which is what `config --token` suggests for the same file.
|
|
311
|
+
*/
|
|
312
|
+
function cloudRoute(raw: Record<string, unknown> | null | undefined): boolean {
|
|
313
|
+
return raw?.provider === JEV_CLOUD_PROVIDER || (raw?.provider === undefined && cloudCredentialPresent());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The two facts `status --json` reports about the FailproofAI Cloud connection,
|
|
318
|
+
* read owner-only: whether this machine is connected at all, and whether the
|
|
319
|
+
* key it connected with carries Jev. Never the key.
|
|
320
|
+
*/
|
|
321
|
+
function cloudConnection(): { cloudConnected: boolean; keyCarriesJev: boolean } {
|
|
322
|
+
try {
|
|
323
|
+
const read = readJevCloudCredential();
|
|
324
|
+
if (read.status === "ok") return { cloudConnected: true, keyCarriesJev: true };
|
|
325
|
+
if (read.status === "absent") return { cloudConnected: read.connected, keyCarriesJev: false };
|
|
326
|
+
// Refused (a loose or unreadable credentials.json): no key is usable, but a
|
|
327
|
+
// connection on record is still a connection — as the dashboard reads it.
|
|
328
|
+
const creds = readCredentials();
|
|
329
|
+
return { cloudConnected: Boolean(creds.cloud?.url ?? creds.ingest?.url), keyCarriesJev: false };
|
|
330
|
+
} catch {
|
|
331
|
+
return { cloudConnected: false, keyCarriesJev: false };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The one line `status` titles a connected machine whose key has no Jev with. */
|
|
336
|
+
const KEY_LACKS_JEV_TITLE = "off — no Jev key is stored for this machine's FailproofAI Cloud connection";
|
|
337
|
+
|
|
338
|
+
/** The `key` row for a Cloud file, saying which of the three states the connection is in. */
|
|
339
|
+
function cloudKeyRow(): string {
|
|
340
|
+
const c = cloudConnection();
|
|
341
|
+
if (c.keyCarriesJev) return CLOUD_KEY_SOURCE;
|
|
342
|
+
return c.cloudConnected
|
|
343
|
+
? `${CLOUD_KEY_SOURCE} — connected, no Jev key stored for it`
|
|
344
|
+
: `${CLOUD_KEY_SOURCE} — this machine is not connected`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** `0600`, or null when unknown. */
|
|
348
|
+
function octal(mode: number | null): string | null {
|
|
349
|
+
return mode === null ? null : mode.toString(8).padStart(4, "0");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function permissions(mode: number | null): string {
|
|
353
|
+
if (mode === null) return "unknown";
|
|
354
|
+
return (mode & 0o077) === 0 ? `${octal(mode)} (owner-only)` : `${octal(mode)} (too open)`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function describeError(err: unknown): { code: string; message: string } {
|
|
358
|
+
if (err instanceof JevError) return { code: err.code, message: err.message };
|
|
359
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) return { code: "timeout", message: "no answer in time" };
|
|
360
|
+
return { code: "error", message: err instanceof Error ? err.message : String(err) };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function scrubbed(e: { code: string; message: string }, key: string): { code: string; message: string } {
|
|
364
|
+
return { code: e.code, message: scrubSecret(e.message, key) };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* What the person should do about a failed FailproofAI Cloud request, by cause,
|
|
369
|
+
* or null where the generic advice already fits. The remedies differ from a
|
|
370
|
+
* BYOK provider's because the key and the budget are the Cloud connection's and
|
|
371
|
+
* the org's, not anything `jev setup` holds.
|
|
372
|
+
*/
|
|
373
|
+
function cloudRemedy(code: string, message = ""): string | null {
|
|
374
|
+
if (code === "http-401" || code === "http-403") {
|
|
375
|
+
return "FailproofAI Cloud refused this machine's key: it was revoked, or it does not carry jev:evaluate. Reconnect with a key that does (the \"machine\" preset on the Keys page): failproofai config --token <key>";
|
|
376
|
+
}
|
|
377
|
+
if (code === "out-of-credits") {
|
|
378
|
+
return "Your FailproofAI Cloud org has used its plan allowance (HTTP 402). Until it resets or the plan changes, hooks fall back to regex.";
|
|
379
|
+
}
|
|
380
|
+
if (code === "http-429") {
|
|
381
|
+
// Two limits answer 429 (contract decision 18): the per-minute ones, and
|
|
382
|
+
// the org's daily Jev cap, whose body says `daily_limit_reached` and whose
|
|
383
|
+
// Retry-After runs to the next UTC midnight. Only the words tell them
|
|
384
|
+
// apart, and "right now … at most 60 s" is wrong for the second: the
|
|
385
|
+
// machine still asks again after at most 60 s, and keeps being refused.
|
|
386
|
+
if (/\bdaily_limit_reached\b/.test(message)) {
|
|
387
|
+
return "Daily Jev limit for this org reached; resets at 00:00 UTC. Until then hooks fall back to regex; the machine asks FailproofAI Cloud again at most once a minute (Retry-After, capped at 60 s).";
|
|
388
|
+
}
|
|
389
|
+
return "FailproofAI Cloud is rate-limiting Jev for this org right now. Hooks fall back to regex, and send it nothing more until the wait it asked for (Retry-After, at most 60 s) is over.";
|
|
390
|
+
}
|
|
391
|
+
if (code === "http-404") return "This FailproofAI Cloud does not serve Jev (its server predates the Jev route). Hooks fall back to regex until it does.";
|
|
392
|
+
if (code === "http-503") {
|
|
393
|
+
return (
|
|
394
|
+
"This FailproofAI Cloud cannot serve Jev for your org: its deployment has no model gateway, the org is not " +
|
|
395
|
+
"provisioned yet, or the gateway is down. Ask your FailproofAI Cloud admin. Hooks fall back to regex and ask " +
|
|
396
|
+
"again at most once a minute."
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
// `request_rejected`: TypeSafe refused this request itself, typically dense
|
|
400
|
+
// text over Jev's token budget. The same call gets the same answer.
|
|
401
|
+
if (code === "http-422") return "Jev refused this call's request, usually because it held dense text (base64, hex, minified code) over Jev's token budget. That call falls back to regex every time; this is not an outage.";
|
|
402
|
+
if (code === "model-mismatch") return "FailproofAI Cloud answered with a model outside the Jev 1.13 family, so hooks would fall back to regex. This is the server's to fix.";
|
|
403
|
+
// The generic advice for these names `--base-url`, which this route refuses:
|
|
404
|
+
// its endpoint is the Cloud this machine connected to, changed only by
|
|
405
|
+
// reconnecting.
|
|
406
|
+
if (/^http-3(?:\d\d|xx)$/.test(code)) {
|
|
407
|
+
return "FailproofAI Cloud answered with a redirect, and Jev requests never follow one. The URL this machine connected to is probably not the one that serves the API (a sign-in page, or an old address). Reconnect with the right one: failproofai config --token <key> --url <url>";
|
|
408
|
+
}
|
|
409
|
+
if (code === "network") {
|
|
410
|
+
return "FailproofAI Cloud could not be reached. Check your network; if the Cloud's address has changed, reconnect: failproofai config --token <key> --url <url>";
|
|
411
|
+
}
|
|
412
|
+
if (code === "config") return "The config is not usable. Rebuild it from this machine's FailproofAI Cloud connection: failproofai jev setup --provider failproofai";
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** What the person should do about a failed request, by cause. */
|
|
417
|
+
function remedy(code: string, provider?: JevProviderKind, message?: string): string {
|
|
418
|
+
if (provider === JEV_CLOUD_PROVIDER) {
|
|
419
|
+
const cloud = cloudRemedy(code, message);
|
|
420
|
+
if (cloud) return cloud;
|
|
421
|
+
}
|
|
422
|
+
if (code === "http-401" || code === "http-403") return "The provider refused the key. Re-run `failproofai jev setup` with the right one.";
|
|
423
|
+
if (code === "out-of-credits") return "The account is out of credits (HTTP 402). Top it up with the provider; until then hooks fall back to regex.";
|
|
424
|
+
// The message from `httpFailureMessage` has already said what a 404 there
|
|
425
|
+
// means; this is the part that is an instruction rather than a diagnosis.
|
|
426
|
+
if (code === "http-404") {
|
|
427
|
+
return "Set the base URL to the provider's version root, without the endpoint path: `failproofai jev setup --base-url <base>`. `failproofai jev models` says what a base serves.";
|
|
428
|
+
}
|
|
429
|
+
if (code === "http-429") return "The provider rate-limited this key. Hooks fall back to regex whenever that happens.";
|
|
430
|
+
if (code.startsWith("http-5")) return "The provider had a server error. Hooks fall back to regex whenever that happens; try again shortly.";
|
|
431
|
+
if (code === "timeout") return "No answer in time. Check the endpoint and your network.";
|
|
432
|
+
if (code === "network") return "The endpoint could not be reached. Check the base URL and your network.";
|
|
433
|
+
if (/^http-3(?:\d\d|xx)$/.test(code)) {
|
|
434
|
+
return "The endpoint answered with a redirect, and Jev requests never follow one (the answer must come from the URL you configured). Set --base-url to the final URL.";
|
|
435
|
+
}
|
|
436
|
+
if (code === "model-mismatch") {
|
|
437
|
+
return "A Jev version the thresholds were not calibrated for answered (or a custom endpoint did not say which model answered), so hooks would fall back to regex. Pin a jev-1.13 model with --model.";
|
|
438
|
+
}
|
|
439
|
+
if (code === "config") return "The config is not usable. Re-run `failproofai jev setup`.";
|
|
440
|
+
return "Hooks would fall back to regex for this reason.";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The `jev status` activity block, from `jevStats()` (null when it could not be read). */
|
|
444
|
+
export function jevStatsLines(stats: JevStats | null, opts: RenderOpts = {}): string[] {
|
|
445
|
+
const hours = stats ? Math.round(stats.windowMs / 3_600_000) : 24;
|
|
446
|
+
const heading = rule(`last ${hours} hours`, opts);
|
|
447
|
+
if (!stats) return stack(heading, note("Activity could not be read.", opts));
|
|
448
|
+
if (stats.total === 0) return stack(heading, note(`No Jev evaluations recorded in the last ${hours} hours.`, opts));
|
|
449
|
+
const pct = (x: number) => `${(x * 100).toFixed(1)}%`;
|
|
450
|
+
const reasons = Object.entries(stats.fallbackReasons)
|
|
451
|
+
.sort((a, b) => b[1] - a[1])
|
|
452
|
+
.map(([r, n]) => `${r} ×${n}`)
|
|
453
|
+
.join(", ");
|
|
454
|
+
const byPolicy = (counts: Record<string, number> | undefined) =>
|
|
455
|
+
Object.entries(counts ?? {})
|
|
456
|
+
.sort((a, b) => b[1] - a[1])
|
|
457
|
+
.map(([p, n]) => `${p} ×${n}`)
|
|
458
|
+
.join(", ");
|
|
459
|
+
const clears = byPolicy(stats.clearsByPolicy);
|
|
460
|
+
// `clearsByPolicy` counts clears that CHANGED an outcome, which only enforce
|
|
461
|
+
// mode can do; in shadow mode every clear Jev would have made is in
|
|
462
|
+
// `shadowClearsByPolicy` instead and this one is empty. Printing the first
|
|
463
|
+
// alone would tell a shadow user "cleared nothing" — the one number they
|
|
464
|
+
// turned shadow mode on to watch. The field is optional because the stats
|
|
465
|
+
// module T1 builds against does not have it yet (T8 adds it).
|
|
466
|
+
const shadowClears = byPolicy((stats as { shadowClearsByPolicy?: Record<string, number> }).shadowClearsByPolicy);
|
|
467
|
+
const ms = (v: number | null) => (v === null ? "—" : `${Math.round(v)} ms`);
|
|
468
|
+
return stack(
|
|
469
|
+
heading,
|
|
470
|
+
rows(
|
|
471
|
+
[
|
|
472
|
+
["evaluations", String(stats.total)],
|
|
473
|
+
["fell back to regex", `${pct(stats.fallbackRate)}${reasons ? ` (${reasons})` : ""}`],
|
|
474
|
+
["latency", `p50 ${ms(stats.latencyP50Ms)} · p95 ${ms(stats.latencyP95Ms)}`],
|
|
475
|
+
["cleared", clears || "nothing"],
|
|
476
|
+
...(shadowClears ? ([["would have cleared (shadow)", shadowClears]] as Array<[string, string]>) : []),
|
|
477
|
+
],
|
|
478
|
+
opts,
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── setup ────────────────────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
/** The origin requests go to for this provider and base URL (the provider's own API when none), or null. */
|
|
486
|
+
function originOf(provider: JevProviderKind, baseUrl: unknown): string | null {
|
|
487
|
+
const base = typeof baseUrl === "string" ? baseUrl : JEV_PROVIDER_DEFAULTS[provider].baseUrl;
|
|
488
|
+
if (!base) return null;
|
|
489
|
+
try {
|
|
490
|
+
const origin = new URL(base.trim()).origin;
|
|
491
|
+
return origin === "null" ? null : origin;
|
|
492
|
+
} catch {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Whether a same-provider re-run's base URL sends requests to a new host, and
|
|
499
|
+
* which. Not a move: the same origin, an unusable URL (validation reports it),
|
|
500
|
+
* or the provider's own API, which is where its key belongs anyway.
|
|
501
|
+
*/
|
|
502
|
+
function movedHost(provider: JevProviderKind, before: unknown, after: unknown): { from: string | null; to: string } | null {
|
|
503
|
+
const to = originOf(provider, after);
|
|
504
|
+
if (to === null) return null;
|
|
505
|
+
const from = originOf(provider, before);
|
|
506
|
+
if (to === from || to === originOf(provider, undefined)) return null;
|
|
507
|
+
return { from, to };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The account id a Cloudflare URL already carries, or null.
|
|
512
|
+
*
|
|
513
|
+
* Cloudflare's run endpoint IS per-account — `…/client/v4/accounts/<id>/ai/run`
|
|
514
|
+
* — so the id is in the canonical URL, and asking for it again as a flag asks
|
|
515
|
+
* for something the person has already typed. The first real attempt to
|
|
516
|
+
* configure Cloudflare pasted exactly that URL and was refused with "pass the
|
|
517
|
+
* account id as well", with the id visible in the string being rejected.
|
|
518
|
+
*
|
|
519
|
+
* Read from the path only, never from a query or fragment, and only on a host
|
|
520
|
+
* this build already recognises as Cloudflare's, so nothing else can inject one.
|
|
521
|
+
*/
|
|
522
|
+
export function accountIdFromUrl(url: string): string | null {
|
|
523
|
+
try {
|
|
524
|
+
const parsed = new URL(url);
|
|
525
|
+
if (providerForUrl(url) !== "cloudflare") return null;
|
|
526
|
+
const m = /\/accounts\/([0-9a-f]{32})(?:\/|$)/i.exec(parsed.pathname);
|
|
527
|
+
return m ? m[1].toLowerCase() : null;
|
|
528
|
+
} catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Whether a validated URL is exactly where this provider's requests already go. */
|
|
534
|
+
function isProviderDefaultUrl(kind: JevProviderKind, url: string): boolean {
|
|
535
|
+
const base = JEV_PROVIDER_DEFAULTS[kind].baseUrl;
|
|
536
|
+
if (!base) return false;
|
|
537
|
+
const normalized = validateBaseUrl(base);
|
|
538
|
+
return normalized.ok && normalized.value === url;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* A saved `baseUrl` that keeps the provider but leaves its API base — `--url
|
|
543
|
+
* https://api.typesafe.ai/v2`, or a v1 path this provider does not serve.
|
|
544
|
+
* Nothing refuses it: it is a valid https URL, on the right host, for a
|
|
545
|
+
* provider that exists. But a provider serves its API at one base, so requests
|
|
546
|
+
* would go to a path it does not answer and every evaluation would fall back to
|
|
547
|
+
* regex — which is worth saying while the person is still looking at the
|
|
548
|
+
* screen. Null when there is nothing to say: no override at all; `custom`,
|
|
549
|
+
* whose URL IS the address; another host, which may well be the customer's own
|
|
550
|
+
* proxy, whose layout this cannot know; or an override that lands on the
|
|
551
|
+
* provider's own endpoint anyway (the full endpoint path given as the base,
|
|
552
|
+
* which works).
|
|
553
|
+
*/
|
|
554
|
+
function offProviderBase(cfg: JevConfig, endpoint: string): { endpoint: string; api: string } | null {
|
|
555
|
+
if (cfg.baseUrl === undefined || cfg.provider === "custom") return null;
|
|
556
|
+
const apiBase = JEV_PROVIDER_DEFAULTS[cfg.provider].baseUrl;
|
|
557
|
+
if (!apiBase) return null;
|
|
558
|
+
try {
|
|
559
|
+
if (new URL(cfg.baseUrl).host !== new URL(apiBase).host) return null;
|
|
560
|
+
const api = jevRoute({ ...cfg, baseUrl: undefined }).endpoint;
|
|
561
|
+
return api === endpoint ? null : { endpoint: displayEndpoint(endpoint), api: displayEndpoint(api) };
|
|
562
|
+
} catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* The refusal for a URL that is an endpoint where a base belongs.
|
|
569
|
+
*
|
|
570
|
+
* Both URLs go through `displayEndpoint`, which replaces a query string with
|
|
571
|
+
* `?…`: a proxy base may legitimately carry `?api-version=` — or a token — and a
|
|
572
|
+
* refusal is not a reason to print one. That does make the suggested command a
|
|
573
|
+
* shape rather than something to paste, which is the right trade: the person has
|
|
574
|
+
* their own URL, and the point of the line is which segment to drop.
|
|
575
|
+
*/
|
|
576
|
+
function endpointAsBaseRefusal(flag: string, given: string, found: EndpointGivenAsBase): string[] {
|
|
577
|
+
return [
|
|
578
|
+
`Not saved: ${flag} names an endpoint, not an API base — its path ends in ${found.suffix}.`,
|
|
579
|
+
endpointAsBaseReason(given),
|
|
580
|
+
"Give the base it sits under:",
|
|
581
|
+
` failproofai jev ${flag} ${displayEndpoint(found.base)}`,
|
|
582
|
+
"",
|
|
583
|
+
"Nothing was written.",
|
|
584
|
+
];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** At most this many names in one refusal or one rendered list; the rest are counted. */
|
|
588
|
+
const MAX_SHOWN_MODELS = 20;
|
|
589
|
+
|
|
590
|
+
/** Model names for one line, bounded — the list came from a provider and may be long. */
|
|
591
|
+
function shownModels(models: string[]): string {
|
|
592
|
+
if (models.length <= MAX_SHOWN_MODELS) return models.join(", ");
|
|
593
|
+
return `${models.slice(0, MAX_SHOWN_MODELS).join(", ")} … and ${models.length - MAX_SHOWN_MODELS} more`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The list this config's endpoint serves, when it both could be read AND is
|
|
598
|
+
* authoritative about `<base>/systemone` AND does not carry the configured model
|
|
599
|
+
* — i.e. the one case that is worth refusing. Null in every other case, so a
|
|
600
|
+
* caller's "carry on unchanged" path is the default rather than the exception.
|
|
601
|
+
*
|
|
602
|
+
* `apiKey` is passed separately because a `--key-from-env` config in a shell with
|
|
603
|
+
* no variable set holds a stand-in, which must not be sent anywhere; the public
|
|
604
|
+
* lists (Vercel's, OpenRouter's) answer without a key anyway.
|
|
605
|
+
*/
|
|
606
|
+
async function modelAbsentFromList(
|
|
607
|
+
cfg: JevConfig,
|
|
608
|
+
model: string,
|
|
609
|
+
apiKey: string | null,
|
|
610
|
+
read: JevModelListReader,
|
|
611
|
+
): Promise<{ list: JevModelListRead; url: string } | null> {
|
|
612
|
+
let url: string | null;
|
|
613
|
+
try {
|
|
614
|
+
url = jevModelsUrl(cfg);
|
|
615
|
+
} catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
if (url === null) return null;
|
|
619
|
+
let list: JevModelListResult;
|
|
620
|
+
try {
|
|
621
|
+
list = await read(url, apiKey);
|
|
622
|
+
} catch {
|
|
623
|
+
// A reader that throws is a reader that read nothing, which is the case this
|
|
624
|
+
// whole check is required to survive.
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
if (!list.ok || !listDescribesSystemOne(list) || modelListHasModel(list, model)) return null;
|
|
628
|
+
return { list, url };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function readAllStdin(): Promise<string> {
|
|
632
|
+
const chunks: Buffer[] = [];
|
|
633
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
634
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function maskedPrompt(): Promise<string | null> {
|
|
638
|
+
const { promptText } = await import("./tui");
|
|
639
|
+
return promptText({ message: "Jev API key", mask: true, validate: (v) => validateApiKey(v) });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** What a run that put the key in argv ends with, saved or refused: the key is in history either way. */
|
|
643
|
+
const TOKEN_HISTORY_WARNING = [
|
|
644
|
+
TOKEN_ON_ARGV,
|
|
645
|
+
"Prefer piping the key in — `failproofai jev --url <url> --key-stdin < key-file` — and rotate this one if it matters.",
|
|
646
|
+
];
|
|
647
|
+
|
|
648
|
+
async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise<JevCliResult> {
|
|
649
|
+
const r = await setupRun(argv, deps, opts);
|
|
650
|
+
// A save says it in its own place; every refusal says it here.
|
|
651
|
+
const tokenGiven = argv.some((a, i) => a.startsWith("--token=") || (a === "--token" && i + 1 < argv.length));
|
|
652
|
+
return r.exitCode === 0 || !tokenGiven ? r : { ...r, lines: [...r.lines, "", ...TOKEN_HISTORY_WARNING] };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function setupRun(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise<JevCliResult> {
|
|
656
|
+
const parsed = parseFlags(argv, new Set([...VALUE_FLAGS, "--key-stdin", "--key-from-env"]));
|
|
657
|
+
if (typeof parsed === "string") return fail([parsed, "", ...JEV_USAGE]);
|
|
658
|
+
if (parsed.positionals.length > 0) return fail([STRAY_ARGUMENT, "", ...JEV_USAGE]);
|
|
659
|
+
const { values, bools } = parsed;
|
|
660
|
+
if (bools.has("--key-stdin") && bools.has("--key-from-env")) return fail(["Use one of --key-stdin and --key-from-env, not both."]);
|
|
661
|
+
// One source for the key, so no run has to decide which of two the person
|
|
662
|
+
// meant. The message names the recommended one first.
|
|
663
|
+
if (values.has("--token") && (bools.has("--key-stdin") || bools.has("--key-from-env"))) {
|
|
664
|
+
return fail([
|
|
665
|
+
`Use one of --key-stdin, --key-from-env and --token, not more than one.`,
|
|
666
|
+
"",
|
|
667
|
+
" failproofai jev --url <url> --key-stdin < key-file",
|
|
668
|
+
"Nothing was written.",
|
|
669
|
+
]);
|
|
670
|
+
}
|
|
671
|
+
if (values.has("--url") && values.has("--base-url")) {
|
|
672
|
+
return fail(["--url and --base-url are the same thing; give one of them.", "", "Nothing was written."]);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// The provider NAME is checked before anything else on the command line is
|
|
676
|
+
// used. With `--url` present, an unknown or miscapitalized spelling used to
|
|
677
|
+
// be dropped on the floor and replaced by the provider the URL's host
|
|
678
|
+
// implies: the run wrote a config for a provider nobody had named and exited
|
|
679
|
+
// 0, and neither this refusal nor the contradiction refusal below could be
|
|
680
|
+
// reached that way. The value is still never echoed — the likeliest wrong
|
|
681
|
+
// thing after `--provider` is a pasted key.
|
|
682
|
+
const namedProvider = values.get("--provider");
|
|
683
|
+
if (namedProvider !== undefined && !(JEV_PROVIDER_KINDS as readonly string[]).includes(namedProvider)) {
|
|
684
|
+
return fail([
|
|
685
|
+
"Unknown provider (not repeated here, in case it is a key).",
|
|
686
|
+
`Providers: ${JEV_PROVIDER_KINDS.join(", ")} — exactly as spelled here, lower-case.`,
|
|
687
|
+
"",
|
|
688
|
+
"Nothing was written.",
|
|
689
|
+
]);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// FailproofAI Cloud has a setup of its own: its endpoint and key come from the
|
|
693
|
+
// machine's connection, so none of the flags below that choose them apply.
|
|
694
|
+
// Reached by naming it, or by re-running setup (a mode switch, say) over a
|
|
695
|
+
// Cloud file without naming another provider or a URL — never by a URL: a
|
|
696
|
+
// `--url` is a BYOK endpoint whatever host it names (`PROVIDER_BY_HOST`
|
|
697
|
+
// deliberately has no Cloud entry).
|
|
698
|
+
if (
|
|
699
|
+
namedProvider === JEV_CLOUD_PROVIDER ||
|
|
700
|
+
(namedProvider === undefined &&
|
|
701
|
+
!values.has("--url") &&
|
|
702
|
+
!values.has("--base-url") &&
|
|
703
|
+
readJevConfigFileForUpdate()?.raw.provider === JEV_CLOUD_PROVIDER)
|
|
704
|
+
) {
|
|
705
|
+
return cloudSetup(values, bools, opts);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// `--url` is `--base-url` that also picks the provider. It is validated here,
|
|
709
|
+
// by the loader's own `validateBaseUrl`, so a URL is refused in the same
|
|
710
|
+
// words whichever way it arrived — and so the host below is a parsed host,
|
|
711
|
+
// never a value that was echoed before it was known to be a URL.
|
|
712
|
+
let urlProvider: { kind: JevProviderKind; host: string } | null = null;
|
|
713
|
+
const urlArg = values.get("--url");
|
|
714
|
+
if (urlArg !== undefined) {
|
|
715
|
+
const checkedUrl = validateBaseUrl(urlArg);
|
|
716
|
+
if (!checkedUrl.ok) return fail([`Not saved: ${checkedUrl.problem}.`, "", "Nothing was written."]);
|
|
717
|
+
const normalized = checkedUrl.value;
|
|
718
|
+
urlProvider = { kind: providerForUrl(normalized), host: new URL(normalized).host };
|
|
719
|
+
// Checked above, so this is a provider kind or nothing: a spelling that is
|
|
720
|
+
// not one is refused there rather than quietly becoming the host's.
|
|
721
|
+
const explicit = (values.get("--provider") as JevProviderKind | undefined) ?? null;
|
|
722
|
+
// An explicit provider wins — except where it disagrees with the host about
|
|
723
|
+
// which gateway this is, which is a disagreement about where the key goes.
|
|
724
|
+
// `custom` is not a disagreement: it is the "treat this URL as itself" ask.
|
|
725
|
+
// The rule is `providerHostConflict`'s, shared with `--base-url` below and
|
|
726
|
+
// the dashboard's save; only the advice here is about `--url`.
|
|
727
|
+
const conflict = explicit === null ? null : providerHostConflict(explicit, normalized);
|
|
728
|
+
if (conflict !== null) {
|
|
729
|
+
return fail([
|
|
730
|
+
explicit === "custom" ? `Not saved: ${conflict}.` : `--provider ${explicit} and --url disagree: ${conflict}.`,
|
|
731
|
+
explicit === "custom"
|
|
732
|
+
? "Use the provider that speaks it: --provider cloudflare --account-id <32 hex characters> — or drop --provider, since the URL already says cloudflare."
|
|
733
|
+
: "Drop --provider to take the provider from the URL, or give the URL that provider's own endpoint.",
|
|
734
|
+
"",
|
|
735
|
+
"Nothing was written.",
|
|
736
|
+
]);
|
|
737
|
+
}
|
|
738
|
+
const kind = explicit ?? urlProvider.kind;
|
|
739
|
+
if (explicit === null) values.set("--provider", kind);
|
|
740
|
+
// Cloudflare's endpoint IS per-account, so the run URL copied out of the
|
|
741
|
+
// dashboard already names the account. Read it from there rather than
|
|
742
|
+
// demanding a value that is sitting in the string being read — the first
|
|
743
|
+
// person to configure Cloudflare pasted exactly that URL and was told to
|
|
744
|
+
// "pass the account id as well", with the id visible in the refusal.
|
|
745
|
+
//
|
|
746
|
+
// Set into `values` here, while the URL is still in scope, because the
|
|
747
|
+
// block below deletes `--url` once it has moved the address to
|
|
748
|
+
// `--base-url`. Everything downstream then treats it as a flag that was
|
|
749
|
+
// given, including the 32-hex validation.
|
|
750
|
+
if (kind === "cloudflare") {
|
|
751
|
+
const fromUrl = accountIdFromUrl(normalized);
|
|
752
|
+
const given = values.get("--account-id");
|
|
753
|
+
if (fromUrl !== null && given === undefined) {
|
|
754
|
+
values.set("--account-id", fromUrl);
|
|
755
|
+
} else if (fromUrl !== null && given !== undefined && given.toLowerCase() !== fromUrl) {
|
|
756
|
+
// Preferring either one silently would send every request to an account
|
|
757
|
+
// the person did not name on the line they are looking at.
|
|
758
|
+
return fail([
|
|
759
|
+
"Not saved: --account-id and the account id in --url are different.",
|
|
760
|
+
` --url names ${fromUrl}`,
|
|
761
|
+
` --account-id names ${given.toLowerCase()}`,
|
|
762
|
+
"Drop one of them.",
|
|
763
|
+
"",
|
|
764
|
+
"Nothing was written.",
|
|
765
|
+
]);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// The provider's own API is where its key belongs anyway, so a URL naming
|
|
769
|
+
// it clears the override instead of writing the same address into the file
|
|
770
|
+
// (`custom` has no API of its own — its URL is the whole address).
|
|
771
|
+
values.set("--base-url", kind !== "custom" && isProviderDefaultUrl(kind, normalized) ? "default" : normalized);
|
|
772
|
+
values.delete("--url");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// An endpoint given where a base belongs, whichever flag carried it — `--url`
|
|
776
|
+
// has already become `--base-url` above, so the flag NAMED here is the one the
|
|
777
|
+
// person typed. Before the key is asked for, and before anything is written:
|
|
778
|
+
// the fix is one segment, and nothing should be stored or prompted for first.
|
|
779
|
+
const baseArg = values.get("--base-url");
|
|
780
|
+
if (baseArg !== undefined && baseArg !== "default") {
|
|
781
|
+
const checkedBase = validateBaseUrl(baseArg);
|
|
782
|
+
// An unusable URL is refused by validation below, in its own words; this
|
|
783
|
+
// check only has something to say about one that parses.
|
|
784
|
+
if (checkedBase.ok) {
|
|
785
|
+
const asEndpoint = endpointGivenAsBase(checkedBase.value);
|
|
786
|
+
if (asEndpoint) return fail(endpointAsBaseRefusal(urlArg !== undefined ? "--url" : "--base-url", checkedBase.value, asEndpoint));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const existingFile = readJevConfigFileForUpdate();
|
|
791
|
+
const existing = existingFile?.raw ?? null;
|
|
792
|
+
const providerArg = values.get("--provider");
|
|
793
|
+
const provider = providerArg ?? (typeof existing?.provider === "string" ? existing.provider : undefined);
|
|
794
|
+
if (!provider) {
|
|
795
|
+
return fail([
|
|
796
|
+
"--provider is required the first time.",
|
|
797
|
+
`Providers: ${JEV_PROVIDER_KINDS.join(", ")}`,
|
|
798
|
+
"",
|
|
799
|
+
" failproofai jev setup --provider cloudflare --account-id <id> --key-stdin < key-file",
|
|
800
|
+
]);
|
|
801
|
+
}
|
|
802
|
+
if (!(JEV_PROVIDER_KINDS as readonly string[]).includes(provider)) {
|
|
803
|
+
return fail(["Unknown provider (not repeated here, in case it is a key).", `Providers: ${JEV_PROVIDER_KINDS.join(", ")}`]);
|
|
804
|
+
}
|
|
805
|
+
const kind = provider as JevProviderKind;
|
|
806
|
+
// The same rule for a `--base-url` typed as such (a `--url` was checked above,
|
|
807
|
+
// with advice about `--url`), so the flag and the dashboard cannot save a
|
|
808
|
+
// pair `--url` refuses.
|
|
809
|
+
const typedBase = urlArg === undefined ? values.get("--base-url") : undefined;
|
|
810
|
+
const checkedTyped = typedBase === undefined || typedBase === "default" ? null : validateBaseUrl(typedBase);
|
|
811
|
+
const hostConflict = checkedTyped?.ok ? providerHostConflict(kind, checkedTyped.value) : null;
|
|
812
|
+
if (checkedTyped?.ok && hostConflict !== null) {
|
|
813
|
+
return fail([
|
|
814
|
+
`Not saved: ${hostConflict}.`,
|
|
815
|
+
kind === "custom"
|
|
816
|
+
? "Use the provider that speaks it: --provider cloudflare --account-id <32 hex characters>."
|
|
817
|
+
: `Give ${kind}'s own endpoint, or the provider this URL belongs to: --provider ${providerForUrl(checkedTyped.value)}.`,
|
|
818
|
+
"",
|
|
819
|
+
"Nothing was written.",
|
|
820
|
+
]);
|
|
821
|
+
}
|
|
822
|
+
const sameProvider = existing !== null && existing.provider === provider;
|
|
823
|
+
|
|
824
|
+
// Same provider: update in place, keeping the key and every field not named
|
|
825
|
+
// (including ones a newer failproofai wrote). A different provider starts
|
|
826
|
+
// over: a key, model or URL for one gateway means nothing to another. Mode
|
|
827
|
+
// and timeout are provider-neutral, so they carry across.
|
|
828
|
+
const next: Record<string, unknown> = sameProvider ? { ...existing } : { provider };
|
|
829
|
+
if (!sameProvider && existing) {
|
|
830
|
+
if (existing.mode !== undefined) next.mode = existing.mode;
|
|
831
|
+
if (existing.timeoutMs !== undefined) next.timeoutMs = existing.timeoutMs;
|
|
832
|
+
}
|
|
833
|
+
next.provider = provider;
|
|
834
|
+
|
|
835
|
+
const setOrClear = (flag: string, field: string) => {
|
|
836
|
+
const v = values.get(flag);
|
|
837
|
+
if (v === undefined) return;
|
|
838
|
+
if (v === "default") delete next[field];
|
|
839
|
+
else next[field] = v;
|
|
840
|
+
};
|
|
841
|
+
setOrClear("--base-url", "baseUrl");
|
|
842
|
+
setOrClear("--model", "model");
|
|
843
|
+
if (values.has("--account-id")) {
|
|
844
|
+
// Only Cloudflare's endpoint is per-account, and `validateJevConfig` keeps
|
|
845
|
+
// `accountId` for no other provider: writing it for one of those would put
|
|
846
|
+
// a field in the file that nothing reads, while `jev status` showed an
|
|
847
|
+
// endpoint that does not contain it. Refused rather than written and
|
|
848
|
+
// ignored. (The value is never echoed: it is 32 hex characters, or a
|
|
849
|
+
// pasted key.)
|
|
850
|
+
if (kind !== "cloudflare") {
|
|
851
|
+
return fail([
|
|
852
|
+
`Not saved: --account-id is Cloudflare's, and provider ${kind} has no use for one — nothing would read it.`,
|
|
853
|
+
kind === "custom"
|
|
854
|
+
? "A custom endpoint's URL is the whole address: if the account belongs in it, it goes in --url."
|
|
855
|
+
: "Drop --account-id, or ask for Cloudflare: --provider cloudflare with its own API URL.",
|
|
856
|
+
"",
|
|
857
|
+
"Nothing was written.",
|
|
858
|
+
]);
|
|
859
|
+
}
|
|
860
|
+
next.accountId = values.get("--account-id");
|
|
861
|
+
}
|
|
862
|
+
if (values.has("--mode")) next.mode = values.get("--mode");
|
|
863
|
+
if (values.has("--timeout-ms")) {
|
|
864
|
+
const raw = values.get("--timeout-ms") as string;
|
|
865
|
+
const n = Number(raw);
|
|
866
|
+
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(n)) return fail(["Could not read --timeout-ms. Give a whole number of milliseconds."]);
|
|
867
|
+
next.timeoutMs = n;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Cloudflare's endpoint is per-account, so a URL that turns out to be
|
|
871
|
+
// Cloudflare's needs one. Named here rather than left to validation, which
|
|
872
|
+
// knows the field and not the flag — and this run never said "cloudflare",
|
|
873
|
+
// the URL did. Only when Cloudflare is what is being saved: a URL on that
|
|
874
|
+
// host with another provider asked for is settled above, and nothing here
|
|
875
|
+
// demands an id the file would not keep.
|
|
876
|
+
if (kind === "cloudflare" && urlProvider?.kind === "cloudflare" && typeof next.accountId !== "string") {
|
|
877
|
+
// Reached only when the URL did not carry one either — a bare
|
|
878
|
+
// `/client/v4`, say. The URL form is named as well as the flag, because it
|
|
879
|
+
// is the address the dashboard hands out.
|
|
880
|
+
return fail([
|
|
881
|
+
`Not saved: ${urlProvider.host} is Cloudflare Workers AI, whose endpoint is per-account.`,
|
|
882
|
+
"Pass the account id as well: --account-id <32 hex characters>,",
|
|
883
|
+
"or give the full run URL, which already contains it:",
|
|
884
|
+
" https://api.cloudflare.com/client/v4/accounts/<id>/ai/run",
|
|
885
|
+
"",
|
|
886
|
+
"Nothing was written.",
|
|
887
|
+
]);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// A key stored for one host is not carried to another (see the header). A
|
|
891
|
+
// file that was open to other users is the loader's "someone else may have
|
|
892
|
+
// chosen the endpoint" case, so the URL it names is measured against the
|
|
893
|
+
// provider's own API, not trusted as the host the key already went to —
|
|
894
|
+
// whether it is carried over or passed again with --base-url.
|
|
895
|
+
const untrustedFile = sameProvider && existingFile?.tooOpen === true;
|
|
896
|
+
const hostMove =
|
|
897
|
+
sameProvider && (values.has("--base-url") || untrustedFile)
|
|
898
|
+
? movedHost(kind, untrustedFile ? undefined : existing?.baseUrl, next.baseUrl)
|
|
899
|
+
: null;
|
|
900
|
+
if (hostMove) delete next.apiKey;
|
|
901
|
+
|
|
902
|
+
// The key. A config that takes it from the environment stores none, and
|
|
903
|
+
// stays that way across a re-run for the same provider.
|
|
904
|
+
let keyNote: string;
|
|
905
|
+
let keyFromEnv = false;
|
|
906
|
+
let tokenOnCommandLine = false;
|
|
907
|
+
const envKey = process.env[JEV_API_KEY_ENV];
|
|
908
|
+
const envKeyNote = () =>
|
|
909
|
+
envKey
|
|
910
|
+
? `not stored — read from ${JEV_API_KEY_ENV} when a hook runs`
|
|
911
|
+
: `not stored — ${JEV_API_KEY_ENV} is not set in this shell, and Jev stays off wherever it is not`;
|
|
912
|
+
if (bools.has("--key-from-env")) {
|
|
913
|
+
delete next.apiKey;
|
|
914
|
+
keyFromEnv = true;
|
|
915
|
+
keyNote = envKeyNote();
|
|
916
|
+
} else if (bools.has("--key-stdin")) {
|
|
917
|
+
const tty = deps.stdinIsTTY ?? Boolean(process.stdin.isTTY);
|
|
918
|
+
// On a terminal a cooked-mode read echoes each keystroke, so a person
|
|
919
|
+
// typing or pasting there gets the masked prompt instead.
|
|
920
|
+
const raw = tty ? await (deps.promptKey ?? maskedPrompt)() : await (deps.readStdin ?? readAllStdin)();
|
|
921
|
+
if (raw === null) return fail(["Cancelled; nothing was written."]);
|
|
922
|
+
const key = raw.trim();
|
|
923
|
+
const bad = validateApiKey(key);
|
|
924
|
+
if (bad) return fail([`Not saved: ${bad}.`]);
|
|
925
|
+
next.apiKey = key;
|
|
926
|
+
keyNote = "set from stdin";
|
|
927
|
+
} else if (values.has("--token")) {
|
|
928
|
+
// Trimmed like the stdin key, and validated by the same rule, so the same
|
|
929
|
+
// paste is accepted or refused identically whichever way it arrived. The
|
|
930
|
+
// refusal quotes the rule, never the value.
|
|
931
|
+
const key = (values.get("--token") as string).trim();
|
|
932
|
+
const bad = validateApiKey(key);
|
|
933
|
+
if (bad) return fail([`Not saved: ${bad}.`]);
|
|
934
|
+
next.apiKey = key;
|
|
935
|
+
tokenOnCommandLine = true;
|
|
936
|
+
keyNote = "set from --token — this shell's history has it too";
|
|
937
|
+
} else if (typeof next.apiKey === "string") {
|
|
938
|
+
keyNote = "kept from the existing config";
|
|
939
|
+
} else if (sameProvider && !hostMove) {
|
|
940
|
+
keyFromEnv = true;
|
|
941
|
+
keyNote = envKeyNote();
|
|
942
|
+
} else {
|
|
943
|
+
const tty = deps.stdinIsTTY ?? Boolean(process.stdin.isTTY);
|
|
944
|
+
if (hostMove && untrustedFile) {
|
|
945
|
+
// Not even at a terminal: a bare "Jev API key" prompt would not say where
|
|
946
|
+
// the key is going, and here that is exactly the question.
|
|
947
|
+
const perms = octal(existingFile?.mode ?? null) ?? "unknown";
|
|
948
|
+
const named = values.has("--base-url");
|
|
949
|
+
return fail([
|
|
950
|
+
named
|
|
951
|
+
? `${jevConfigPath()} was open to other users (${perms}), so its stored key is not carried to ${hostMove.to}.`
|
|
952
|
+
: `${jevConfigPath()} was open to other users (${perms}), so the endpoint it names — ${hostMove.to} — may not be one you chose, and its stored key is not carried there.`,
|
|
953
|
+
"If that endpoint is yours, give the key for it again:",
|
|
954
|
+
` failproofai jev setup${named ? " --base-url <url>" : ""} --key-stdin < key-file`,
|
|
955
|
+
"",
|
|
956
|
+
kind === "custom"
|
|
957
|
+
? "If it is not, give your own --base-url along with its key."
|
|
958
|
+
: "If it is not, send requests back to the provider's own API, which keeps the stored key: failproofai jev setup --base-url default",
|
|
959
|
+
"Nothing was written.",
|
|
960
|
+
]);
|
|
961
|
+
}
|
|
962
|
+
if (!tty && hostMove) {
|
|
963
|
+
return fail([
|
|
964
|
+
`The new base URL sends requests to ${hostMove.to}${hostMove.from ? `, not ${hostMove.from}` : ""}.`,
|
|
965
|
+
"A stored key is not carried to a different host. Give the key for that endpoint again:",
|
|
966
|
+
" failproofai jev setup --base-url <url> --key-stdin < key-file",
|
|
967
|
+
"",
|
|
968
|
+
`Or store no key and supply ${JEV_API_KEY_ENV} per session (--key-from-env).`,
|
|
969
|
+
"Nothing was written.",
|
|
970
|
+
]);
|
|
971
|
+
}
|
|
972
|
+
if (!tty) {
|
|
973
|
+
return fail([
|
|
974
|
+
"No key given, and there is no terminal to ask on.",
|
|
975
|
+
"",
|
|
976
|
+
"Pass it on stdin, which keeps it out of `ps` and shell history:",
|
|
977
|
+
` failproofai jev setup --provider ${provider} --key-stdin < key-file`,
|
|
978
|
+
"",
|
|
979
|
+
`Or store no key and supply ${JEV_API_KEY_ENV} per session (--key-from-env).`,
|
|
980
|
+
]);
|
|
981
|
+
}
|
|
982
|
+
const raw = await (deps.promptKey ?? maskedPrompt)();
|
|
983
|
+
if (raw === null) return fail(["Cancelled; nothing was written."]);
|
|
984
|
+
const key = raw.trim();
|
|
985
|
+
const bad = validateApiKey(key);
|
|
986
|
+
if (bad) return fail([`Not saved: ${bad}.`]);
|
|
987
|
+
next.apiKey = key;
|
|
988
|
+
keyNote = hostMove ? `set at the prompt, for ${hostMove.to}` : "set at the prompt";
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// With the key in the environment the FILE is what is being checked, so a
|
|
992
|
+
// stand-in fills the key when the variable is unset here. A variable that is
|
|
993
|
+
// set but malformed is checked for real, and refused.
|
|
994
|
+
const checked = validateJevConfig(next, keyFromEnv ? envKey || ENV_KEY_STAND_IN : null);
|
|
995
|
+
if (!checked.ok) return fail([`Not saved: ${checked.problem}.`, "", "Nothing was written."]);
|
|
996
|
+
const cfg = checked.value;
|
|
997
|
+
|
|
998
|
+
// The model, against what the endpoint says it serves. This is the second half
|
|
999
|
+
// of the LiteLLM-proxy failure: the URL was fixed and the default model still
|
|
1000
|
+
// did not exist there, which nothing said until `jev test` returned a 404 and
|
|
1001
|
+
// the person curled the endpoint by hand.
|
|
1002
|
+
//
|
|
1003
|
+
// Never a hard requirement: a list that cannot be read (no key yet, a proxy
|
|
1004
|
+
// that serves none, Cloudflare, an unreachable host, a shape nobody knows) and
|
|
1005
|
+
// a list that is not authoritative about `/systemone` both fall through to the
|
|
1006
|
+
// write, so this can only ever turn a silent misconfiguration into a refusal —
|
|
1007
|
+
// it can never turn a working setup into a failing one.
|
|
1008
|
+
const routeForCheck = ((): ReturnType<typeof jevRoute> | null => {
|
|
1009
|
+
try {
|
|
1010
|
+
return jevRoute(cfg);
|
|
1011
|
+
} catch {
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
})();
|
|
1015
|
+
if (routeForCheck !== null) {
|
|
1016
|
+
// The stand-in is not a key and must not be sent; the public lists answer
|
|
1017
|
+
// without one, so the read is still worth making.
|
|
1018
|
+
const listKey = keyFromEnv && !envKey ? null : cfg.apiKey;
|
|
1019
|
+
const absent = await modelAbsentFromList(cfg, routeForCheck.model, listKey, deps.readModelList ?? liveModelListReader);
|
|
1020
|
+
if (absent) {
|
|
1021
|
+
return fail([
|
|
1022
|
+
`Not saved: ${displayEndpoint(absent.url)} does not list ${routeForCheck.model}, so every request would fail there.`,
|
|
1023
|
+
` it serves: ${shownModels(absent.list.models)}`,
|
|
1024
|
+
routeForCheck.modelIsDefault
|
|
1025
|
+
? `${routeForCheck.model} is this build's default for provider ${provider}, and this endpoint names Jev differently — so --model is not optional here. Add it to the command you just ran:`
|
|
1026
|
+
: "Pick one of the names above:",
|
|
1027
|
+
` --model ${absent.list.models[0]}`,
|
|
1028
|
+
"",
|
|
1029
|
+
"Nothing was written.",
|
|
1030
|
+
]);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const path = jevConfigPath();
|
|
1035
|
+
try {
|
|
1036
|
+
writeJsonAtomically(path, next, { mode: 0o600, dirMode: 0o700 });
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
return fail([`Could not write ${path}: ${(err as NodeJS.ErrnoException).code ?? "error"}.`]);
|
|
1039
|
+
}
|
|
1040
|
+
let fileMode: number | null = null;
|
|
1041
|
+
try {
|
|
1042
|
+
fileMode = statSync(path).mode & 0o777;
|
|
1043
|
+
} catch {
|
|
1044
|
+
// Reported as unknown below.
|
|
1045
|
+
}
|
|
1046
|
+
// `writeJsonAtomically` creates the directory at 0700, but leaves a
|
|
1047
|
+
// pre-existing one alone — and older code paths create ~/.failproofai at the
|
|
1048
|
+
// umask, which on a umask-002 machine is group-writable. A directory others
|
|
1049
|
+
// can write into defeats the file's 0600 (they replace the file and the key
|
|
1050
|
+
// goes to their endpoint), so the one command that puts a key there takes
|
|
1051
|
+
// that away. Exactly the write bits the loader refuses, and no more: read
|
|
1052
|
+
// bits give nobody that power, and config.json in the same directory is
|
|
1053
|
+
// world-readable by design.
|
|
1054
|
+
const dir = dirname(path);
|
|
1055
|
+
let tightenedDir: { path: string; to: string } | null = null;
|
|
1056
|
+
if (process.platform !== "win32") {
|
|
1057
|
+
try {
|
|
1058
|
+
const before = statSync(dir).mode & 0o777;
|
|
1059
|
+
if ((before & 0o022) !== 0) {
|
|
1060
|
+
const after = before & ~0o022;
|
|
1061
|
+
chmodSync(dir, after);
|
|
1062
|
+
tightenedDir = { path: dir, to: after.toString(8).padStart(4, "0") };
|
|
1063
|
+
}
|
|
1064
|
+
} catch {
|
|
1065
|
+
// Not fatal: the loader reports a directory it will not read from.
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const route = jevRoute(cfg);
|
|
1070
|
+
const offBase = offProviderBase(cfg, route.endpoint);
|
|
1071
|
+
return ok(
|
|
1072
|
+
stack(
|
|
1073
|
+
title("failproofai jev setup", `saved · ${provider} · ${cfg.mode ?? DEFAULT_JEV_MODE}`, opts),
|
|
1074
|
+
rows(
|
|
1075
|
+
[
|
|
1076
|
+
["provider", provider],
|
|
1077
|
+
["endpoint", displayEndpoint(route.endpoint)],
|
|
1078
|
+
["model", route.modelIsDefault ? `${route.model} (provider default)` : route.model],
|
|
1079
|
+
["mode", modeLine(cfg.mode ?? DEFAULT_JEV_MODE)],
|
|
1080
|
+
["timeout", `${cfg.timeoutMs} ms`],
|
|
1081
|
+
["key", keyNote],
|
|
1082
|
+
["config", path],
|
|
1083
|
+
["permissions", permissions(fileMode)],
|
|
1084
|
+
],
|
|
1085
|
+
opts,
|
|
1086
|
+
),
|
|
1087
|
+
// Saved, and saved as asked — but saying only "saved" would leave someone
|
|
1088
|
+
// to discover from the fallback counts that the URL they gave is not an
|
|
1089
|
+
// API base. So the file is written and what it will do is named.
|
|
1090
|
+
offBase
|
|
1091
|
+
? warning(
|
|
1092
|
+
[
|
|
1093
|
+
`Saved as given, but ${provider}'s API is at ${offBase.api}, and this URL sends requests to ${offBase.endpoint} instead.`,
|
|
1094
|
+
`Unless something of yours answers Jev requests at that path, every evaluation will fail there and hooks will fall back to regex — \`failproofai jev test\` says which it is in one request. To send requests back to ${provider}'s own API: failproofai jev setup --base-url default`,
|
|
1095
|
+
],
|
|
1096
|
+
opts,
|
|
1097
|
+
)
|
|
1098
|
+
: null,
|
|
1099
|
+
tightenedDir
|
|
1100
|
+
? note(
|
|
1101
|
+
`${tightenedDir.path} was writable by other users, who could have replaced this file whatever its own permissions were; it is now ${tightenedDir.to}.`,
|
|
1102
|
+
opts,
|
|
1103
|
+
)
|
|
1104
|
+
: null,
|
|
1105
|
+
// Said once, after the key is safely stored: the file is 0600, but the
|
|
1106
|
+
// command line it came on is not — it is in this shell's history file and
|
|
1107
|
+
// was readable from /proc by anything running as this user while the
|
|
1108
|
+
// process lived.
|
|
1109
|
+
tokenOnCommandLine ? warning(TOKEN_HISTORY_WARNING, opts) : null,
|
|
1110
|
+
note("Hooks read this file on every tool call — no restart. Without it they run the regex policies exactly as before.", opts),
|
|
1111
|
+
// Switched off, `jev test` only answers "not run — switched off": a next
|
|
1112
|
+
// step that leads nowhere is worse than none.
|
|
1113
|
+
(cfg.mode ?? DEFAULT_JEV_MODE) === "off" ? null : nextStep("failproofai jev test", "Check it with one live request:", opts),
|
|
1114
|
+
),
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ── setup: FailproofAI Cloud ─────────────────────────────────────────────────
|
|
1119
|
+
|
|
1120
|
+
/** How `inspectJevConfig` opens a refusal that is about credentials.json. */
|
|
1121
|
+
const CLOUD_CREDENTIAL_REFUSED = "the FailproofAI Cloud credential was refused";
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* The next step for a refused config: one ladder, so `status` and `test` never
|
|
1125
|
+
* disagree about the same inspection.
|
|
1126
|
+
*/
|
|
1127
|
+
function refusedNextStep(inspection: Extract<JevConfigInspection, { status: "refused" }>, cloudFile: boolean): { cmd: string; lead: string } {
|
|
1128
|
+
// The Cloud route's key file is credentials.json, and a refusal can be about
|
|
1129
|
+
// THAT file: the key it holds was put there by `config --token`, not by
|
|
1130
|
+
// anything `jev setup` could re-ask for.
|
|
1131
|
+
if (cloudFile && inspection.problem.startsWith(CLOUD_CREDENTIAL_REFUSED)) {
|
|
1132
|
+
if (inspection.reason !== "too-open") {
|
|
1133
|
+
return { cmd: "failproofai config --token <key>", lead: "credentials.json could not be read. Reconnect, which rewrites it at 0600:" };
|
|
1134
|
+
}
|
|
1135
|
+
const m = inspection.credentialsMode ?? 0;
|
|
1136
|
+
const risks = [
|
|
1137
|
+
...((m & 0o044) !== 0 ? ["other users can read it, and with it spend this machine's FailproofAI Cloud key"] : []),
|
|
1138
|
+
...((m & 0o022) !== 0 ? ["other users could change it"] : []),
|
|
1139
|
+
];
|
|
1140
|
+
const risk = risks.length > 0 ? risks.join(", and ") : "other users can write where it lives, so they could put their own in its place";
|
|
1141
|
+
return {
|
|
1142
|
+
cmd: inspection.fix ?? "failproofai config --token <key>",
|
|
1143
|
+
lead: `credentials.json holds the key Jev sends to FailproofAI Cloud, and ${risk}. Make it owner-only (or reconnect, which rewrites it at 0600):`,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
if (inspection.reason === "too-open") {
|
|
1147
|
+
// Read bits alone expose the key in it; only write bits (or a loose
|
|
1148
|
+
// directory, where the file's own mode is owner-only) let others change it.
|
|
1149
|
+
const m = inspection.mode ?? 0;
|
|
1150
|
+
const readOnly = (m & 0o044) !== 0 && (m & 0o022) === 0;
|
|
1151
|
+
return {
|
|
1152
|
+
// Either the file or the directory it sits in; `fix` says which.
|
|
1153
|
+
cmd: inspection.fix ?? `chmod 600 ${inspection.path}`,
|
|
1154
|
+
lead: readOnly
|
|
1155
|
+
? inspection.keyless
|
|
1156
|
+
? "Make it owner-only:"
|
|
1157
|
+
: "Other users can read this file, and with it any key it holds. Make it owner-only (and rotate the key if it matters):"
|
|
1158
|
+
: cloudFile
|
|
1159
|
+
? "Other users could change this file, so check that endpoint is one you chose. Then make it owner-only (or rebuild it from this machine's connection: `failproofai jev setup --provider failproofai`):"
|
|
1160
|
+
: "Other users could change this file, so check that endpoint is one you chose. Then make it owner-only (or re-run `failproofai jev setup`, which asks for the key again unless the endpoint is the provider's own):",
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
if (cloudFile) return { cmd: "failproofai jev setup --provider failproofai", lead: "Rewrite it from this machine's FailproofAI Cloud connection:" };
|
|
1164
|
+
return { cmd: "failproofai jev setup --provider <kind> --key-stdin", lead: "Write a valid one:" };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* `jev setup --provider failproofai`, and any setup run over a Cloud file.
|
|
1169
|
+
*
|
|
1170
|
+
* The Cloud route has nothing to choose but its mode and timeout: the endpoint
|
|
1171
|
+
* is `<Cloud base>/enforcement/v1/jev` on the origin the machine connected to,
|
|
1172
|
+
* the key is the connection's own (`credentials.json`, never this file) and the
|
|
1173
|
+
* model is pinned by the server. So every flag that would choose one of those is
|
|
1174
|
+
* refused — naming it would be asking for something this route cannot honour —
|
|
1175
|
+
* and the file is rebuilt from the connection. An `apiKey` a hand-edited file
|
|
1176
|
+
* carries is dropped, which is also what makes such a file valid again.
|
|
1177
|
+
*
|
|
1178
|
+
* A mode or timeout change on an existing Cloud file does not need the machine
|
|
1179
|
+
* to be connected: it changes nothing about where a key goes. Creating one,
|
|
1180
|
+
* or rebuilding its endpoint, does.
|
|
1181
|
+
*/
|
|
1182
|
+
async function cloudSetup(values: Map<string, string>, bools: Set<string>, opts: RenderOpts): Promise<JevCliResult> {
|
|
1183
|
+
const refusedFlag = (["--token", "--url", "--base-url", "--account-id", "--model"] as const).find((f) => values.has(f)) ??
|
|
1184
|
+
(["--key-stdin", "--key-from-env"] as const).find((f) => bools.has(f));
|
|
1185
|
+
if (refusedFlag !== undefined) {
|
|
1186
|
+
const why =
|
|
1187
|
+
refusedFlag === "--model"
|
|
1188
|
+
? `FailproofAI Cloud runs ${JEV_PROVIDER_DEFAULTS.failproofai.model}, pinned on the server; there is no model to choose.`
|
|
1189
|
+
: refusedFlag === "--account-id"
|
|
1190
|
+
? "--account-id is Cloudflare's; FailproofAI Cloud has no use for one."
|
|
1191
|
+
: refusedFlag === "--url" || refusedFlag === "--base-url"
|
|
1192
|
+
? "FailproofAI Cloud's endpoint is the one this machine connected to; to change it, reconnect: failproofai config --token <key> --url <url>"
|
|
1193
|
+
: "FailproofAI Cloud's key is this machine's connection key, kept in credentials.json and never in jev.json; to change it, reconnect: failproofai config --token <key>";
|
|
1194
|
+
return fail([`Not saved: ${refusedFlag} does not apply to provider failproofai. ${why}`, "", "Nothing was written."]);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const existing = readJevConfigFileForUpdate();
|
|
1198
|
+
const sameProvider = existing?.raw.provider === JEV_CLOUD_PROVIDER;
|
|
1199
|
+
const credential = readJevCloudCredential();
|
|
1200
|
+
if (credential.status === "refused") {
|
|
1201
|
+
return fail([`Not saved: ${credential.problem}.`, "", "Nothing was written."]);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const next: Record<string, unknown> = sameProvider ? { ...existing!.raw } : { provider: JEV_CLOUD_PROVIDER };
|
|
1205
|
+
// Never a key in a Cloud file, and nothing only another provider reads.
|
|
1206
|
+
delete next.apiKey;
|
|
1207
|
+
delete next.accountId;
|
|
1208
|
+
if (!sameProvider && existing?.raw.timeoutMs !== undefined) next.timeoutMs = existing.raw.timeoutMs;
|
|
1209
|
+
// Shadow unless told otherwise, as `config --token` starts it: Jev on a new
|
|
1210
|
+
// route is logged before it is allowed to clear anything. A mode already in a
|
|
1211
|
+
// Cloud file is kept.
|
|
1212
|
+
const mode = values.get("--mode") ?? (sameProvider && typeof next.mode === "string" ? next.mode : "shadow");
|
|
1213
|
+
next.mode = mode;
|
|
1214
|
+
if (values.has("--timeout-ms")) {
|
|
1215
|
+
const rawTimeout = values.get("--timeout-ms") as string;
|
|
1216
|
+
const n = Number(rawTimeout);
|
|
1217
|
+
if (!/^\d+$/.test(rawTimeout) || !Number.isSafeInteger(n)) return fail(["Could not read --timeout-ms. Give a whole number of milliseconds."]);
|
|
1218
|
+
next.timeoutMs = n;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
let keySource: JevCloudCredential | null = null;
|
|
1222
|
+
if (credential.status === "ok") {
|
|
1223
|
+
keySource = credential.credential;
|
|
1224
|
+
// The endpoint, from the connection: kept when it is already on the
|
|
1225
|
+
// credential's origin, else rebuilt — under the policy connection's base
|
|
1226
|
+
// when that is on the same origin, so a path prefix survives.
|
|
1227
|
+
const onOrigin = (u: unknown) => {
|
|
1228
|
+
try {
|
|
1229
|
+
return typeof u === "string" && new URL(u).origin === new URL(credential.credential.url).origin;
|
|
1230
|
+
} catch {
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
if (!onOrigin(next.baseUrl)) {
|
|
1235
|
+
const policyBase = readCredentials().cloud?.url;
|
|
1236
|
+
next.baseUrl = jevCloudBaseUrl(onOrigin(policyBase) ? (policyBase as string) : credential.credential.url);
|
|
1237
|
+
}
|
|
1238
|
+
} else if (!sameProvider || typeof next.baseUrl !== "string") {
|
|
1239
|
+
return fail([
|
|
1240
|
+
...(credential.connected
|
|
1241
|
+
? [
|
|
1242
|
+
// Its key may well carry jev:evaluate: an older failproofai connected
|
|
1243
|
+
// without storing a Jev key, and the same key fixes that.
|
|
1244
|
+
"Not saved: this machine is connected to FailproofAI Cloud, but no Jev key is stored for that connection.",
|
|
1245
|
+
"Reconnect with this machine's key to re-check what it carries (an older failproofai connected without storing its Jev key; " +
|
|
1246
|
+
"a key without jev:evaluate needs the \"machine\" preset on the dashboard's Keys page):",
|
|
1247
|
+
]
|
|
1248
|
+
: [
|
|
1249
|
+
"Not saved: this machine is not connected to FailproofAI Cloud with a key that carries jev:evaluate.",
|
|
1250
|
+
"Connect it with one that does (the \"machine\" preset) — that also turns Jev on, in shadow mode, when there is no jev.json yet:",
|
|
1251
|
+
]),
|
|
1252
|
+
" failproofai config --token <key>",
|
|
1253
|
+
"",
|
|
1254
|
+
"Nothing was written.",
|
|
1255
|
+
]);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// The loader's own rules, with the connection's key or — for a mode switch
|
|
1259
|
+
// on a machine that is not connected right now — a stand-in on the file's own
|
|
1260
|
+
// origin, which is never written or sent.
|
|
1261
|
+
let standInOrigin = "";
|
|
1262
|
+
try {
|
|
1263
|
+
standInOrigin = new URL(String(next.baseUrl)).origin;
|
|
1264
|
+
} catch {
|
|
1265
|
+
standInOrigin = "";
|
|
1266
|
+
}
|
|
1267
|
+
const checked = validateJevConfig(next, null, keySource ?? { url: standInOrigin, key: ENV_KEY_STAND_IN });
|
|
1268
|
+
if (!checked.ok) return fail([`Not saved: ${checked.problem}.`, "", "Nothing was written."]);
|
|
1269
|
+
|
|
1270
|
+
const path = jevConfigPath();
|
|
1271
|
+
try {
|
|
1272
|
+
writeJsonAtomically(path, next, { mode: 0o600, dirMode: 0o700 });
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
return fail([`Could not write ${path}: ${(err as NodeJS.ErrnoException).code ?? "error"}.`]);
|
|
1275
|
+
}
|
|
1276
|
+
if (process.platform !== "win32") {
|
|
1277
|
+
try {
|
|
1278
|
+
const dir = dirname(path);
|
|
1279
|
+
const before = statSync(dir).mode & 0o777;
|
|
1280
|
+
if ((before & 0o022) !== 0) chmodSync(dir, before & ~0o022);
|
|
1281
|
+
} catch {
|
|
1282
|
+
// Not fatal: the loader reports a directory it will not read from.
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
let fileMode: number | null = null;
|
|
1286
|
+
try {
|
|
1287
|
+
fileMode = statSync(path).mode & 0o777;
|
|
1288
|
+
} catch {
|
|
1289
|
+
// Reported as unknown below.
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const cfg = checked.value;
|
|
1293
|
+
const route = jevRoute(cfg);
|
|
1294
|
+
const shownMode = cfg.mode ?? DEFAULT_JEV_MODE;
|
|
1295
|
+
return ok(
|
|
1296
|
+
stack(
|
|
1297
|
+
title("failproofai jev setup", `saved · FailproofAI Cloud · ${shownMode}`, opts),
|
|
1298
|
+
rows(
|
|
1299
|
+
[
|
|
1300
|
+
["provider", providerLabel(JEV_CLOUD_PROVIDER)],
|
|
1301
|
+
["endpoint", shownEndpoint(JEV_CLOUD_PROVIDER, route.endpoint)],
|
|
1302
|
+
["model", `${route.model} (pinned by FailproofAI Cloud)`],
|
|
1303
|
+
["mode", modeLine(shownMode)],
|
|
1304
|
+
["timeout", `${cfg.timeoutMs} ms`],
|
|
1305
|
+
// Which of the three states the connection is in — the same row
|
|
1306
|
+
// `status` shows. "Not connected" on a machine that IS connected, with
|
|
1307
|
+
// a key that lacks Jev, sent its owner to reconnect with that same key.
|
|
1308
|
+
["key", keySource ? CLOUD_KEY_SOURCE : cloudKeyRow()],
|
|
1309
|
+
["config", path],
|
|
1310
|
+
["permissions", permissions(fileMode)],
|
|
1311
|
+
],
|
|
1312
|
+
opts,
|
|
1313
|
+
),
|
|
1314
|
+
note("Hooks read this file on every tool call — no restart. Calls are charged to your FailproofAI Cloud org's plan.", opts),
|
|
1315
|
+
// With no usable key, `jev test` only answers "not run": the step that
|
|
1316
|
+
// helps is the connection. Switched off, there is no step to take.
|
|
1317
|
+
shownMode === "off"
|
|
1318
|
+
? null
|
|
1319
|
+
: keySource
|
|
1320
|
+
? nextStep("failproofai jev test", "Check it with one live request:", opts)
|
|
1321
|
+
: nextStep(
|
|
1322
|
+
"failproofai config --token <key>",
|
|
1323
|
+
"Jev stays off until this machine is connected with a key that carries jev:evaluate (the \"machine\" preset on the dashboard's Keys page):",
|
|
1324
|
+
opts,
|
|
1325
|
+
),
|
|
1326
|
+
),
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// ── status ───────────────────────────────────────────────────────────────────
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Where a file would send requests, for display, whether or not it loads. Only
|
|
1334
|
+
* the routing fields are used — the key and model do not decide where requests
|
|
1335
|
+
* go — and shadow mode lets a loopback http URL through validation. Null when
|
|
1336
|
+
* it names nothing recognisable.
|
|
1337
|
+
*/
|
|
1338
|
+
/**
|
|
1339
|
+
* The route a config with no stored key would take, for display. The key does
|
|
1340
|
+
* not decide where requests go, so a stand-in fills it; null when the routing
|
|
1341
|
+
* fields do not make a usable route (`inspectJevConfig` has already validated
|
|
1342
|
+
* them, so that is a belt-and-braces null).
|
|
1343
|
+
*/
|
|
1344
|
+
function routeForRouting(routing: Omit<JevConfig, "apiKey">): ReturnType<typeof jevRoute> | null {
|
|
1345
|
+
try {
|
|
1346
|
+
return jevRoute({ ...routing, apiKey: ENV_KEY_STAND_IN, ...displayOnlyCloudOrigin(routing) });
|
|
1347
|
+
} catch {
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* For DISPLAY only, like the stand-in key beside it: a FailproofAI Cloud route
|
|
1354
|
+
* is built only against the origin of the credential it was validated with
|
|
1355
|
+
* (`validateLoadedJevConfig`), and a config shown here has no credential behind
|
|
1356
|
+
* it, so the file's own origin stands in. Nothing built with it is ever sent.
|
|
1357
|
+
*/
|
|
1358
|
+
function displayOnlyCloudOrigin(routing: { provider?: unknown; baseUrl?: unknown }): { credentialOrigin?: string } {
|
|
1359
|
+
if (routing.provider !== JEV_CLOUD_PROVIDER || typeof routing.baseUrl !== "string") return {};
|
|
1360
|
+
try {
|
|
1361
|
+
return { credentialOrigin: new URL(routing.baseUrl).origin };
|
|
1362
|
+
} catch {
|
|
1363
|
+
return {};
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function namedEndpoint(raw: Record<string, unknown> | null): string | null {
|
|
1368
|
+
if (!raw || typeof raw.provider !== "string" || !(JEV_PROVIDER_KINDS as readonly string[]).includes(raw.provider)) return null;
|
|
1369
|
+
const routing: Record<string, unknown> = { provider: raw.provider, apiKey: ENV_KEY_STAND_IN, mode: "shadow", ...displayOnlyCloudOrigin(raw) };
|
|
1370
|
+
if (raw.baseUrl !== undefined) routing.baseUrl = raw.baseUrl;
|
|
1371
|
+
if (raw.accountId !== undefined) routing.accountId = raw.accountId;
|
|
1372
|
+
try {
|
|
1373
|
+
return displayEndpoint(jevRoute(routing as unknown as JevConfig).endpoint);
|
|
1374
|
+
} catch {
|
|
1375
|
+
return typeof raw.baseUrl === "string" ? displayEndpoint(raw.baseUrl) : null;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* What Jev may clear here, or null when it could not be worked out. `status`
|
|
1381
|
+
* answers "is this thing working", and a settings read that threw would replace
|
|
1382
|
+
* that answer with a stack trace — the survey reads three files it does not
|
|
1383
|
+
* own, and none of them is worth the whole command.
|
|
1384
|
+
*/
|
|
1385
|
+
function safeCoverage(): ReviewableCoverage | null {
|
|
1386
|
+
try {
|
|
1387
|
+
return surveyReviewableCoverage();
|
|
1388
|
+
} catch {
|
|
1389
|
+
return null;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
async function status(argv: string[], opts: RenderOpts): Promise<JevCliResult> {
|
|
1394
|
+
const parsed = parseFlags(argv, new Set(["--json"]));
|
|
1395
|
+
if (typeof parsed === "string") return fail([parsed, "", ...JEV_USAGE]);
|
|
1396
|
+
if (parsed.positionals.length > 0) return fail([STRAY_ARGUMENT, "", ...JEV_USAGE]);
|
|
1397
|
+
const asJson = parsed.bools.has("--json");
|
|
1398
|
+
|
|
1399
|
+
const inspection = inspectJevConfig();
|
|
1400
|
+
let stats: JevStats | null = null;
|
|
1401
|
+
try {
|
|
1402
|
+
stats = await jevStats();
|
|
1403
|
+
} catch {
|
|
1404
|
+
stats = null;
|
|
1405
|
+
}
|
|
1406
|
+
const legacy = process.env.FAILPROOFAI_EVALUATOR === "legacy";
|
|
1407
|
+
// Only where a clear could actually happen. A file that is absent, refused or
|
|
1408
|
+
// keyless means hooks run the regex policies whatever any policy's authority
|
|
1409
|
+
// says, and an authority count there would answer a question nobody is in a
|
|
1410
|
+
// position to ask yet.
|
|
1411
|
+
const coverage = inspection.status === "ok" ? safeCoverage() : null;
|
|
1412
|
+
const coverageProblem = coverage ? reviewableProblem(coverage) : null;
|
|
1413
|
+
|
|
1414
|
+
if (asJson) {
|
|
1415
|
+
const base: Record<string, unknown> = { path: inspection.path, status: inspection.status, legacyOverride: legacy, stats };
|
|
1416
|
+
// The absent text says whether this machine's key already carries Jev.
|
|
1417
|
+
if (inspection.status === "absent") Object.assign(base, cloudConnection());
|
|
1418
|
+
if (inspection.status === "refused") {
|
|
1419
|
+
Object.assign(base, { reason: inspection.reason, problem: inspection.problem, permissions: octal(inspection.mode) });
|
|
1420
|
+
if (inspection.fix) base.fix = inspection.fix;
|
|
1421
|
+
// `permissions` is jev.json's; a refusal about credentials.json says its own.
|
|
1422
|
+
if (inspection.credentialsMode !== undefined) base.credentialsPermissions = octal(inspection.credentialsMode);
|
|
1423
|
+
// Known whatever the file says, or whether it parses at all.
|
|
1424
|
+
Object.assign(base, cloudConnection());
|
|
1425
|
+
if (readJevConfigFileForUpdate()?.raw?.provider === JEV_CLOUD_PROVIDER) {
|
|
1426
|
+
Object.assign(base, {
|
|
1427
|
+
provider: JEV_CLOUD_PROVIDER,
|
|
1428
|
+
providerLabel: providerLabel(JEV_CLOUD_PROVIDER),
|
|
1429
|
+
keySource: "cloud",
|
|
1430
|
+
keySourceLabel: CLOUD_KEY_SOURCE,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
if (inspection.reason === "too-open") {
|
|
1434
|
+
const named = namedEndpoint(readJevConfigFileForUpdate()?.raw ?? null);
|
|
1435
|
+
if (named) base.endpoint = named;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
if (inspection.status === "key-missing") {
|
|
1439
|
+
// Configured, just not usable in THIS environment: a provisioning check
|
|
1440
|
+
// must be able to tell that from a file it should rewrite, so the routing
|
|
1441
|
+
// is reported exactly as for `ok`, with `keySource: "env"` and a reason.
|
|
1442
|
+
const route = routeForRouting(inspection.routing);
|
|
1443
|
+
Object.assign(base, {
|
|
1444
|
+
permissions: octal(inspection.mode),
|
|
1445
|
+
provider: inspection.routing.provider,
|
|
1446
|
+
providerLabel: providerLabel(inspection.routing.provider),
|
|
1447
|
+
...(route ? { endpoint: displayEndpoint(route.endpoint), model: route.model, modelIsDefault: route.modelIsDefault } : {}),
|
|
1448
|
+
mode: inspection.routing.mode,
|
|
1449
|
+
timeoutMs: inspection.routing.timeoutMs,
|
|
1450
|
+
keySource: "env",
|
|
1451
|
+
keyEnvVar: JEV_API_KEY_ENV,
|
|
1452
|
+
reason: "no-env-key",
|
|
1453
|
+
problem: inspection.problem,
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
if (inspection.status === "off" || inspection.status === "not-connected" || inspection.status === "key-lacks-jev") {
|
|
1457
|
+
// Configured and deliberately (off) or circumstantially (not connected,
|
|
1458
|
+
// or connected with a key that has no Jev) not running. The routing is
|
|
1459
|
+
// reported exactly as for `ok`, so a provisioning check can tell any of
|
|
1460
|
+
// them from a file it should rewrite.
|
|
1461
|
+
const r = inspection.routing;
|
|
1462
|
+
const route = routeForRouting(r);
|
|
1463
|
+
const cloud = r.provider === JEV_CLOUD_PROVIDER;
|
|
1464
|
+
Object.assign(base, {
|
|
1465
|
+
permissions: octal(inspection.mode),
|
|
1466
|
+
provider: r.provider,
|
|
1467
|
+
providerLabel: providerLabel(r.provider),
|
|
1468
|
+
...(route ? { endpoint: shownEndpoint(r.provider, route.endpoint), model: route.model, modelIsDefault: route.modelIsDefault } : {}),
|
|
1469
|
+
mode: r.mode,
|
|
1470
|
+
timeoutMs: r.timeoutMs,
|
|
1471
|
+
// Read from the credentials file rather than inferred from the status:
|
|
1472
|
+
// `off` is decided before any key is looked for, so it says nothing
|
|
1473
|
+
// about the connection either way.
|
|
1474
|
+
...(cloud ? { keySource: "cloud", keySourceLabel: CLOUD_KEY_SOURCE, ...cloudConnection() } : {}),
|
|
1475
|
+
...(inspection.status === "off" ? { reason: "switched-off" } : { reason: inspection.status, problem: inspection.problem }),
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
if (inspection.status === "ok") {
|
|
1479
|
+
const { config: cfg } = inspection;
|
|
1480
|
+
const route = jevRoute(cfg);
|
|
1481
|
+
Object.assign(base, {
|
|
1482
|
+
permissions: octal(inspection.mode),
|
|
1483
|
+
provider: cfg.provider,
|
|
1484
|
+
providerLabel: providerLabel(cfg.provider),
|
|
1485
|
+
endpoint: shownEndpoint(cfg.provider, route.endpoint),
|
|
1486
|
+
model: route.model,
|
|
1487
|
+
modelIsDefault: route.modelIsDefault,
|
|
1488
|
+
mode: cfg.mode,
|
|
1489
|
+
timeoutMs: cfg.timeoutMs,
|
|
1490
|
+
keySource: inspection.keySource,
|
|
1491
|
+
...(inspection.keySource === "cloud" ? { keySourceLabel: CLOUD_KEY_SOURCE, cloudConnected: true, keyCarriesJev: true } : {}),
|
|
1492
|
+
// How much of this machine's policy set Jev is allowed to clear, and
|
|
1493
|
+
// why it is none when it is none. A provisioning check that turns Jev
|
|
1494
|
+
// on has no other way to find out that the half it turned on cannot
|
|
1495
|
+
// fire — the call counts look identical either way.
|
|
1496
|
+
...(coverage
|
|
1497
|
+
? {
|
|
1498
|
+
reviewablePolicies: {
|
|
1499
|
+
enabled: coverage.enabled,
|
|
1500
|
+
reviewable: coverage.reviewable,
|
|
1501
|
+
customPolicyFiles: coverage.customFiles,
|
|
1502
|
+
problem: coverageProblem,
|
|
1503
|
+
},
|
|
1504
|
+
}
|
|
1505
|
+
: {}),
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
const json = JSON.stringify(base, null, 2);
|
|
1509
|
+
return inspection.status === "refused" ? fail([], json) : ok([], json);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const legacyNote = legacy
|
|
1513
|
+
? warning(
|
|
1514
|
+
[
|
|
1515
|
+
"FAILPROOFAI_EVALUATOR=legacy is set in this shell: sessions started from it skip Jev when their hooks evaluate in-process.",
|
|
1516
|
+
"The daemon does not see this shell's environment.",
|
|
1517
|
+
],
|
|
1518
|
+
opts,
|
|
1519
|
+
)
|
|
1520
|
+
: null;
|
|
1521
|
+
|
|
1522
|
+
if (inspection.status === "absent") {
|
|
1523
|
+
return ok(
|
|
1524
|
+
stack(
|
|
1525
|
+
title("failproofai jev status", "off", opts),
|
|
1526
|
+
emptyState(
|
|
1527
|
+
{
|
|
1528
|
+
what: `Jev is off: there is no ${inspection.path}. Hooks run the regex policies exactly as before.`,
|
|
1529
|
+
hint: "Turn it on with your own Jev endpoint and key:",
|
|
1530
|
+
cmd: "failproofai jev setup --provider <typesafe|openrouter|vercel|cloudflare|custom> --key-stdin",
|
|
1531
|
+
},
|
|
1532
|
+
opts,
|
|
1533
|
+
),
|
|
1534
|
+
note(
|
|
1535
|
+
cloudCredentialPresent()
|
|
1536
|
+
? "Or through FailproofAI Cloud, on your org's plan — this machine's key already carries jev:evaluate: failproofai jev setup --provider failproofai"
|
|
1537
|
+
: "Or through FailproofAI Cloud, on your org's plan, with no key of your own: connect with a key that carries jev:evaluate (the \"machine\" preset) — failproofai config --token <key>",
|
|
1538
|
+
opts,
|
|
1539
|
+
),
|
|
1540
|
+
jevStatsLines(stats, opts),
|
|
1541
|
+
),
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (inspection.status === "key-missing") {
|
|
1546
|
+
// Not a refusal: this is the file `setup --key-from-env` writes, doing what
|
|
1547
|
+
// it was asked to. Saying "write a valid one" here would tell its owner to
|
|
1548
|
+
// undo the one choice they made, so it reads like `absent` — off HERE —
|
|
1549
|
+
// and the next step is the variable, not a rewrite.
|
|
1550
|
+
const route = routeForRouting(inspection.routing);
|
|
1551
|
+
return ok(
|
|
1552
|
+
stack(
|
|
1553
|
+
title("failproofai jev status", "off in this shell", opts),
|
|
1554
|
+
note(
|
|
1555
|
+
`Jev is off in this shell: ${inspection.path} stores no key and takes it from ${JEV_API_KEY_ENV}, which is not set here. ` +
|
|
1556
|
+
"The config is fine; hooks run the regex policies wherever the variable is unset — including under the daemon, which does not see a shell's environment.",
|
|
1557
|
+
opts,
|
|
1558
|
+
),
|
|
1559
|
+
rows(
|
|
1560
|
+
[
|
|
1561
|
+
["provider", inspection.routing.provider],
|
|
1562
|
+
...((route
|
|
1563
|
+
? [
|
|
1564
|
+
["endpoint", displayEndpoint(route.endpoint)],
|
|
1565
|
+
["model", route.modelIsDefault ? `${route.model} (provider default)` : route.model],
|
|
1566
|
+
]
|
|
1567
|
+
: []) as Array<[string, string]>),
|
|
1568
|
+
["mode", modeLine(inspection.routing.mode ?? DEFAULT_JEV_MODE)],
|
|
1569
|
+
["config", inspection.path],
|
|
1570
|
+
["permissions", permissions(inspection.mode)],
|
|
1571
|
+
["key", `from ${JEV_API_KEY_ENV} — not set in this shell`],
|
|
1572
|
+
],
|
|
1573
|
+
opts,
|
|
1574
|
+
),
|
|
1575
|
+
nextStep(`failproofai jev setup --key-stdin < key-file`, `Set ${JEV_API_KEY_ENV} for this shell, or store the key in the file instead:`, opts),
|
|
1576
|
+
legacyNote,
|
|
1577
|
+
jevStatsLines(stats, opts),
|
|
1578
|
+
),
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
if (inspection.status === "off") {
|
|
1583
|
+
// Switched off, not broken and not absent: the file is kept so switching
|
|
1584
|
+
// back on needs no key and no endpoint re-typed. Said as "off" first,
|
|
1585
|
+
// because that is the whole answer to "is Jev running?".
|
|
1586
|
+
const r = inspection.routing;
|
|
1587
|
+
const route = routeForRouting(r);
|
|
1588
|
+
return ok(
|
|
1589
|
+
stack(
|
|
1590
|
+
title("failproofai jev status", "off (switched off)", opts),
|
|
1591
|
+
note(`Jev is switched off in ${inspection.path}: it is not asked about any tool call, and hooks run the regex policies exactly as before.`, opts),
|
|
1592
|
+
rows(
|
|
1593
|
+
[
|
|
1594
|
+
["provider", providerLabel(r.provider)],
|
|
1595
|
+
...((route ? [["endpoint", shownEndpoint(r.provider, route.endpoint)]] : []) as Array<[string, string]>),
|
|
1596
|
+
["mode", modeLine("off")],
|
|
1597
|
+
["config", inspection.path],
|
|
1598
|
+
...((r.provider === JEV_CLOUD_PROVIDER ? [["key", cloudKeyRow()]] : []) as Array<[string, string]>),
|
|
1599
|
+
],
|
|
1600
|
+
opts,
|
|
1601
|
+
),
|
|
1602
|
+
nextStep("failproofai jev setup --mode shadow", "Switch it back on (shadow logs Jev and keeps enforcing regex; enforce lets it clear), here or from the dashboard:", opts),
|
|
1603
|
+
legacyNote,
|
|
1604
|
+
jevStatsLines(stats, opts),
|
|
1605
|
+
),
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
if (inspection.status === "not-connected") {
|
|
1610
|
+
// The Cloud route's `key-missing`: nothing is wrong with the file, and the
|
|
1611
|
+
// remedy is the connection, not a rewrite.
|
|
1612
|
+
const r = inspection.routing;
|
|
1613
|
+
const route = routeForRouting(r);
|
|
1614
|
+
return ok(
|
|
1615
|
+
stack(
|
|
1616
|
+
title("failproofai jev status", "off — this machine is not connected to FailproofAI Cloud", opts),
|
|
1617
|
+
note(
|
|
1618
|
+
`Jev is off: ${inspection.path} sends Jev requests through FailproofAI Cloud, and this machine holds no FailproofAI Cloud key that carries jev:evaluate. ` +
|
|
1619
|
+
"Hooks run the regex policies exactly as before.",
|
|
1620
|
+
opts,
|
|
1621
|
+
),
|
|
1622
|
+
rows(
|
|
1623
|
+
[
|
|
1624
|
+
["provider", providerLabel(r.provider)],
|
|
1625
|
+
...((route ? [["endpoint", shownEndpoint(r.provider, route.endpoint)]] : []) as Array<[string, string]>),
|
|
1626
|
+
["mode", modeLine(r.mode ?? DEFAULT_JEV_MODE)],
|
|
1627
|
+
["config", inspection.path],
|
|
1628
|
+
["key", `${CLOUD_KEY_SOURCE} — not connected`],
|
|
1629
|
+
],
|
|
1630
|
+
opts,
|
|
1631
|
+
),
|
|
1632
|
+
nextStep(
|
|
1633
|
+
"failproofai config --token <key>",
|
|
1634
|
+
"Connect with a key that carries jev:evaluate (the \"machine\" preset on the dashboard's Keys page):",
|
|
1635
|
+
opts,
|
|
1636
|
+
),
|
|
1637
|
+
nextStep("failproofai jev remove", "Or switch Jev off:", opts),
|
|
1638
|
+
legacyNote,
|
|
1639
|
+
jevStatsLines(stats, opts),
|
|
1640
|
+
),
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
if (inspection.status === "key-lacks-jev") {
|
|
1645
|
+
// Connected, so "not connected" would be false and send its owner to
|
|
1646
|
+
// reconnect with the same key. The remedy is a key WITH Jev.
|
|
1647
|
+
const r = inspection.routing;
|
|
1648
|
+
const route = routeForRouting(r);
|
|
1649
|
+
return ok(
|
|
1650
|
+
stack(
|
|
1651
|
+
title("failproofai jev status", KEY_LACKS_JEV_TITLE, opts),
|
|
1652
|
+
note(
|
|
1653
|
+
`Jev is off: ${inspection.path} sends Jev requests through FailproofAI Cloud, and no Jev key is stored for this machine's connection: its key lacks jev:evaluate, or the last connect could not confirm that. ` +
|
|
1654
|
+
"Hooks run the regex policies exactly as before.",
|
|
1655
|
+
opts,
|
|
1656
|
+
),
|
|
1657
|
+
rows(
|
|
1658
|
+
[
|
|
1659
|
+
["provider", providerLabel(r.provider)],
|
|
1660
|
+
...((route ? [["endpoint", shownEndpoint(r.provider, route.endpoint)]] : []) as Array<[string, string]>),
|
|
1661
|
+
["mode", modeLine(r.mode ?? DEFAULT_JEV_MODE)],
|
|
1662
|
+
["config", inspection.path],
|
|
1663
|
+
["key", `${CLOUD_KEY_SOURCE} — connected, no Jev key stored for it`],
|
|
1664
|
+
],
|
|
1665
|
+
opts,
|
|
1666
|
+
),
|
|
1667
|
+
nextStep(
|
|
1668
|
+
"failproofai config --token <key>",
|
|
1669
|
+
"Reconnect with this machine's key to re-check what it carries (a key without jev:evaluate needs the \"machine\" preset on the dashboard's Keys page):",
|
|
1670
|
+
opts,
|
|
1671
|
+
),
|
|
1672
|
+
nextStep("failproofai jev setup --mode off", "Or keep Jev off for good:", opts),
|
|
1673
|
+
legacyNote,
|
|
1674
|
+
jevStatsLines(stats, opts),
|
|
1675
|
+
),
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
if (inspection.status === "refused") {
|
|
1680
|
+
// A file other users could change may name an endpoint its owner never
|
|
1681
|
+
// chose, and `chmod 600` would start trusting it with the key — so say
|
|
1682
|
+
// where it points before suggesting that.
|
|
1683
|
+
const raw = readJevConfigFileForUpdate()?.raw ?? null;
|
|
1684
|
+
const named = inspection.reason === "too-open" ? namedEndpoint(raw) : null;
|
|
1685
|
+
const next = refusedNextStep(inspection, cloudRoute(raw));
|
|
1686
|
+
return fail(
|
|
1687
|
+
stack(
|
|
1688
|
+
title("failproofai jev status", "off (config refused)", opts),
|
|
1689
|
+
warning([`Jev is off: ${inspection.path} was refused — ${inspection.problem}.`, "Hooks run the regex policies exactly as before."], opts),
|
|
1690
|
+
named ? rows([["endpoint it names", named]], opts) : null,
|
|
1691
|
+
nextStep(next.cmd, next.lead, opts),
|
|
1692
|
+
legacyNote,
|
|
1693
|
+
jevStatsLines(stats, opts),
|
|
1694
|
+
),
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const { config: cfg } = inspection;
|
|
1699
|
+
const route = jevRoute(cfg);
|
|
1700
|
+
const mode = cfg.mode ?? DEFAULT_JEV_MODE;
|
|
1701
|
+
return ok(
|
|
1702
|
+
stack(
|
|
1703
|
+
title("failproofai jev status", legacy ? "on (legacy override in this shell)" : `on · ${mode}`, opts),
|
|
1704
|
+
rows(
|
|
1705
|
+
[
|
|
1706
|
+
["provider", providerLabel(cfg.provider)],
|
|
1707
|
+
["endpoint", shownEndpoint(cfg.provider, route.endpoint)],
|
|
1708
|
+
["model", route.modelIsDefault ? `${route.model} (provider default)` : route.model],
|
|
1709
|
+
["mode", modeLine(mode)],
|
|
1710
|
+
["timeout", `${cfg.timeoutMs} ms`],
|
|
1711
|
+
["config", inspection.path],
|
|
1712
|
+
["permissions", permissions(inspection.mode)],
|
|
1713
|
+
[
|
|
1714
|
+
"key",
|
|
1715
|
+
inspection.keySource === "cloud"
|
|
1716
|
+
? CLOUD_KEY_SOURCE
|
|
1717
|
+
: inspection.keySource === "file"
|
|
1718
|
+
? "set in the config file"
|
|
1719
|
+
: `from ${JEV_API_KEY_ENV} (this shell only; the daemon does not see it)`,
|
|
1720
|
+
],
|
|
1721
|
+
],
|
|
1722
|
+
opts,
|
|
1723
|
+
),
|
|
1724
|
+
coverage ? note(reviewableSummary(coverage), opts) : null,
|
|
1725
|
+
coverageProblem ? warning([coverageProblem], opts) : null,
|
|
1726
|
+
legacyNote,
|
|
1727
|
+
jevStatsLines(stats, opts),
|
|
1728
|
+
),
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// ── test ─────────────────────────────────────────────────────────────────────
|
|
1733
|
+
|
|
1734
|
+
export function jevTestRequest(model: string): JevRequest {
|
|
1735
|
+
return {
|
|
1736
|
+
model,
|
|
1737
|
+
state: { word: "blue" },
|
|
1738
|
+
questions: {
|
|
1739
|
+
[JEV_TEST_QUESTION_ID]: {
|
|
1740
|
+
type: "noul",
|
|
1741
|
+
instructions: "Is state.word the name of a colour?",
|
|
1742
|
+
criteria: { true: "It names a colour", false: "It does not name a colour" },
|
|
1743
|
+
},
|
|
1744
|
+
},
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
async function test(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise<JevCliResult> {
|
|
1749
|
+
const parsed = parseFlags(argv, new Set(["--json"]));
|
|
1750
|
+
if (typeof parsed === "string") return fail([parsed, "", ...JEV_USAGE]);
|
|
1751
|
+
if (parsed.positionals.length > 0) return fail([STRAY_ARGUMENT, "", ...JEV_USAGE]);
|
|
1752
|
+
const asJson = parsed.bools.has("--json");
|
|
1753
|
+
|
|
1754
|
+
const inspection = inspectJevConfig();
|
|
1755
|
+
if (inspection.status !== "ok") {
|
|
1756
|
+
const why =
|
|
1757
|
+
inspection.status === "absent"
|
|
1758
|
+
? `There is no ${inspection.path}; nothing to test.`
|
|
1759
|
+
: inspection.status === "key-missing"
|
|
1760
|
+
? `${inspection.path} takes its key from ${JEV_API_KEY_ENV}, which is not set in this shell, so there is no key to test with.`
|
|
1761
|
+
: inspection.status === "off"
|
|
1762
|
+
? `Jev is switched off in ${inspection.path} (mode off), so nothing is sent.`
|
|
1763
|
+
: inspection.status === "not-connected"
|
|
1764
|
+
? `${inspection.path} sends Jev requests through FailproofAI Cloud, and this machine is not connected to it with a key that carries jev:evaluate.`
|
|
1765
|
+
: inspection.status === "key-lacks-jev"
|
|
1766
|
+
? `${inspection.path} sends Jev requests through FailproofAI Cloud, and no Jev key is stored for this machine's connection.`
|
|
1767
|
+
: `${inspection.path} was refused — ${inspection.problem}.`;
|
|
1768
|
+
const code =
|
|
1769
|
+
inspection.status === "absent"
|
|
1770
|
+
? "not-configured"
|
|
1771
|
+
: inspection.status === "key-missing"
|
|
1772
|
+
? "no-env-key"
|
|
1773
|
+
: inspection.status === "off"
|
|
1774
|
+
? "switched-off"
|
|
1775
|
+
: inspection.status === "not-connected" || inspection.status === "key-lacks-jev"
|
|
1776
|
+
? inspection.status
|
|
1777
|
+
: "config";
|
|
1778
|
+
const refusedNext =
|
|
1779
|
+
inspection.status === "refused"
|
|
1780
|
+
? refusedNextStep(inspection, cloudRoute(readJevConfigFileForUpdate()?.raw))
|
|
1781
|
+
: inspection.status === "absent" && cloudCredentialPresent()
|
|
1782
|
+
? { cmd: "failproofai jev setup --provider failproofai", lead: "This machine's FailproofAI Cloud key already carries jev:evaluate. Turn it on:" }
|
|
1783
|
+
: null;
|
|
1784
|
+
const fixCmd = refusedNext
|
|
1785
|
+
? refusedNext.cmd
|
|
1786
|
+
: inspection.status === "key-missing"
|
|
1787
|
+
? "failproofai jev setup --key-stdin < key-file"
|
|
1788
|
+
: inspection.status === "off"
|
|
1789
|
+
? "failproofai jev setup --mode shadow"
|
|
1790
|
+
: inspection.status === "not-connected" || inspection.status === "key-lacks-jev"
|
|
1791
|
+
? "failproofai config --token <key>"
|
|
1792
|
+
: "failproofai jev setup --provider <kind> --key-stdin";
|
|
1793
|
+
const fixLead = refusedNext
|
|
1794
|
+
? refusedNext.lead
|
|
1795
|
+
: inspection.status === "key-missing"
|
|
1796
|
+
? `Set ${JEV_API_KEY_ENV} for this shell, or store the key in the file:`
|
|
1797
|
+
: inspection.status === "off"
|
|
1798
|
+
? "Switch it back on:"
|
|
1799
|
+
: inspection.status === "not-connected"
|
|
1800
|
+
? "Connect with a key that carries jev:evaluate:"
|
|
1801
|
+
: inspection.status === "key-lacks-jev"
|
|
1802
|
+
? "Reconnect with this machine's key to re-check what it carries (a key without jev:evaluate needs the \"machine\" preset on the dashboard's Keys page):"
|
|
1803
|
+
: undefined;
|
|
1804
|
+
const json = asJson ? JSON.stringify({ ok: false, error: { code, message: why } }, null, 2) : undefined;
|
|
1805
|
+
return fail(stack(title("failproofai jev test", "not run", opts), note(why, opts), nextStep(fixCmd, fixLead, opts)), json);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
const cfg = inspection.config;
|
|
1809
|
+
let route: ReturnType<typeof jevRoute>;
|
|
1810
|
+
let built: ReturnType<typeof transportForConfig>;
|
|
1811
|
+
try {
|
|
1812
|
+
route = jevRoute(cfg);
|
|
1813
|
+
built = transportForConfig(cfg);
|
|
1814
|
+
} catch (err) {
|
|
1815
|
+
const e = scrubbed(describeError(err), cfg.apiKey);
|
|
1816
|
+
return fail([`Not run: ${e.message}`], asJson ? JSON.stringify({ ok: false, error: e }, null, 2) : undefined);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
const request = jevTestRequest(built.model);
|
|
1820
|
+
// `validateJevConfig` always fills this in; the fallback is the same constant
|
|
1821
|
+
// it fills it with, imported rather than copied — a copy of it here was left
|
|
1822
|
+
// behind at 1500 ms when the default became 3000.
|
|
1823
|
+
const budget = cfg.timeoutMs ?? JEV_CONFIG_DEFAULT_TIMEOUT_MS;
|
|
1824
|
+
const started = performance.now();
|
|
1825
|
+
try {
|
|
1826
|
+
const response = await built.transport(request, AbortSignal.timeout(deps.testTimeoutMs ?? DEFAULT_TEST_TIMEOUT_MS));
|
|
1827
|
+
const answers = readAnswers(request, response);
|
|
1828
|
+
const latencyMs = Math.round(performance.now() - started);
|
|
1829
|
+
const verified = response.modelUnverified !== true;
|
|
1830
|
+
const within = latencyMs <= budget;
|
|
1831
|
+
const p = answers[JEV_TEST_QUESTION_ID];
|
|
1832
|
+
const inputTokens = typeof response.usage?.input_tokens === "number" ? response.usage.input_tokens : null;
|
|
1833
|
+
// "ok" means a hook would use this answer: in time, and right about "blue".
|
|
1834
|
+
const problem = !within ? "over-timeout" : p < 0.5 ? "unexpected-answer" : null;
|
|
1835
|
+
const done = problem ? fail : ok;
|
|
1836
|
+
if (asJson) {
|
|
1837
|
+
return done(
|
|
1838
|
+
[],
|
|
1839
|
+
JSON.stringify(
|
|
1840
|
+
{
|
|
1841
|
+
ok: problem === null,
|
|
1842
|
+
...(problem ? { problem } : {}),
|
|
1843
|
+
provider: cfg.provider,
|
|
1844
|
+
endpoint: displayEndpoint(route.endpoint),
|
|
1845
|
+
model: built.model,
|
|
1846
|
+
reportedModel: verified ? response.model : null,
|
|
1847
|
+
modelVerified: verified,
|
|
1848
|
+
latencyMs,
|
|
1849
|
+
timeoutMs: budget,
|
|
1850
|
+
withinTimeout: within,
|
|
1851
|
+
answer: p,
|
|
1852
|
+
inputTokens,
|
|
1853
|
+
},
|
|
1854
|
+
null,
|
|
1855
|
+
2,
|
|
1856
|
+
),
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
const status =
|
|
1860
|
+
problem === "over-timeout" ? `over timeout · ${latencyMs} ms` : problem ? `wrong answer · p = ${p.toFixed(3)}` : `ok · ${latencyMs} ms`;
|
|
1861
|
+
return done(
|
|
1862
|
+
stack(
|
|
1863
|
+
title("failproofai jev test", status, opts),
|
|
1864
|
+
rows(
|
|
1865
|
+
[
|
|
1866
|
+
["provider", cfg.provider],
|
|
1867
|
+
["endpoint", displayEndpoint(route.endpoint)],
|
|
1868
|
+
["model asked", built.model],
|
|
1869
|
+
[
|
|
1870
|
+
"answered by",
|
|
1871
|
+
verified ? `${response.model} (Jev 1.13 family — verified)` : `not reported — ${cfg.provider} names Jev only by an alias (modelVerified: false)`,
|
|
1872
|
+
],
|
|
1873
|
+
["latency", `${latencyMs} ms — ${within ? `within the ${budget} ms timeout` : `OVER the ${budget} ms timeout: hooks would fall back to regex`}`],
|
|
1874
|
+
["answer", `p = ${p.toFixed(3)} that "blue" names a colour${p >= 0.5 ? "" : " (expected high)"}`],
|
|
1875
|
+
...(inputTokens !== null ? ([["input tokens", String(inputTokens)]] as Array<[string, string]>) : []),
|
|
1876
|
+
],
|
|
1877
|
+
opts,
|
|
1878
|
+
),
|
|
1879
|
+
note(
|
|
1880
|
+
"One request, sent directly: the hook path's cache and rate limit were not involved, and a fresh process pays DNS and TLS setup that the daemon's warm worker does not.",
|
|
1881
|
+
opts,
|
|
1882
|
+
),
|
|
1883
|
+
problem === "over-timeout"
|
|
1884
|
+
? nextStep("failproofai jev setup --timeout-ms <n>", `Give hooks longer (up to ${MAX_JEV_TIMEOUT_MS} ms), or use a faster route:`, opts)
|
|
1885
|
+
: null,
|
|
1886
|
+
),
|
|
1887
|
+
);
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
const latencyMs = Math.round(performance.now() - started);
|
|
1890
|
+
// jev-client scrubs provider text already; this is the last line of that
|
|
1891
|
+
// defence, for any error text that reached here some other way.
|
|
1892
|
+
const e = scrubbed(describeError(err), cfg.apiKey);
|
|
1893
|
+
// An answer after the hook budget is a `timeout` to every hook, whatever it
|
|
1894
|
+
// finally said (a Cloud route maps its own upstream timeout to 502).
|
|
1895
|
+
const late = latencyMs > budget;
|
|
1896
|
+
if (asJson) return fail([], JSON.stringify({ ok: false, provider: cfg.provider, latencyMs, timeoutMs: budget, withinTimeout: !late, error: e }, null, 2));
|
|
1897
|
+
return fail(
|
|
1898
|
+
stack(
|
|
1899
|
+
title("failproofai jev test", `failed · ${e.code}`, opts),
|
|
1900
|
+
rows(
|
|
1901
|
+
[
|
|
1902
|
+
["provider", cfg.provider],
|
|
1903
|
+
["endpoint", displayEndpoint(route.endpoint)],
|
|
1904
|
+
["model asked", built.model],
|
|
1905
|
+
["error", `${e.code}: ${e.message}`],
|
|
1906
|
+
["after", late ? `${latencyMs} ms — OVER the ${budget} ms timeout: hooks would fall back to regex (timeout)` : `${latencyMs} ms`],
|
|
1907
|
+
],
|
|
1908
|
+
opts,
|
|
1909
|
+
),
|
|
1910
|
+
note(
|
|
1911
|
+
late
|
|
1912
|
+
? `Hooks stop waiting after ${budget} ms, so they record this as \`timeout\`, not ${e.code}: the provider or FailproofAI Cloud is slow or failing upstream.`
|
|
1913
|
+
: remedy(e.code, cfg.provider, e.message),
|
|
1914
|
+
opts,
|
|
1915
|
+
),
|
|
1916
|
+
),
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// ── models ───────────────────────────────────────────────────────────────────
|
|
1922
|
+
|
|
1923
|
+
/** What a shape means, which decides what the names in it can be used for. */
|
|
1924
|
+
function shapeMeans(shape: JevModelListRead["shape"]): string {
|
|
1925
|
+
return shape === "typesafe"
|
|
1926
|
+
? "TypeSafe's own inventory — the aliases <base>/systemone answers to; versioned ids such as jev-1.13.0 are accepted without being listed"
|
|
1927
|
+
: "an OpenAI-shaped gateway catalog — it lists what the gateway serves, not what <base>/systemone answers to, so it is shown and never used to refuse a model";
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* `failproofai jev models` — what `GET <base>/models` says an endpoint serves.
|
|
1932
|
+
*
|
|
1933
|
+
* The question a person asks after `jev test` fails, and the one the LiteLLM-proxy
|
|
1934
|
+
* customer answered by curling the endpoint by hand. With no flags it asks the
|
|
1935
|
+
* configured route; `--provider` asks a provider's own API and `--url` any base.
|
|
1936
|
+
*
|
|
1937
|
+
* The stored key is sent only to the origin it was stored for. `--url` takes a URL
|
|
1938
|
+
* off the command line, and a key issued for one gateway must not go to another
|
|
1939
|
+
* because a diagnostic named it — the same rule `setup` applies when a new
|
|
1940
|
+
* `--base-url` moves the host. Vercel's and OpenRouter's lists are public, so an
|
|
1941
|
+
* unkeyed read is often still an answer, and the output says which it was.
|
|
1942
|
+
*/
|
|
1943
|
+
async function models(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise<JevCliResult> {
|
|
1944
|
+
// The same test the launcher uses to print `json` instead of the lines, so a
|
|
1945
|
+
// refusal made before the flags parse is JSON as well: every `--json` outcome
|
|
1946
|
+
// is an object, as `jev test --json`'s are.
|
|
1947
|
+
const asJson = argv.includes("--json");
|
|
1948
|
+
const refuse = (code: string, lines: string[]) =>
|
|
1949
|
+
fail(lines, asJson ? JSON.stringify({ ok: false, error: { code, message: lines.join("\n") } }, null, 2) : undefined);
|
|
1950
|
+
const parsed = parseFlags(argv, new Set(["--json", "--provider", "--url"]));
|
|
1951
|
+
if (typeof parsed === "string") return refuse("usage", [parsed, "", ...JEV_USAGE]);
|
|
1952
|
+
if (parsed.positionals.length > 0) return refuse("usage", [STRAY_ARGUMENT, "", ...JEV_USAGE]);
|
|
1953
|
+
const { values } = parsed;
|
|
1954
|
+
|
|
1955
|
+
// Checked before anything else uses it, and never echoed — the same rule
|
|
1956
|
+
// `setup` follows, for the same reason: the likeliest wrong value is a key.
|
|
1957
|
+
const named = values.get("--provider");
|
|
1958
|
+
if (named !== undefined && !(JEV_PROVIDER_KINDS as readonly string[]).includes(named)) {
|
|
1959
|
+
return refuse("unknown-provider", [
|
|
1960
|
+
"Unknown provider (not repeated here, in case it is a key).",
|
|
1961
|
+
`Providers: ${JEV_PROVIDER_KINDS.join(", ")} — exactly as spelled here, lower-case.`,
|
|
1962
|
+
]);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// The configured route, for the default base, for the key, and for the model to
|
|
1966
|
+
// mark. `key-missing` counts: the file's routing is sound, it just has no key
|
|
1967
|
+
// here, and a public list answers anyway.
|
|
1968
|
+
const inspection = inspectJevConfig();
|
|
1969
|
+
const routing: Omit<JevConfig, "apiKey"> | null =
|
|
1970
|
+
inspection.status === "ok"
|
|
1971
|
+
? inspection.config
|
|
1972
|
+
: inspection.status === "key-missing" ||
|
|
1973
|
+
inspection.status === "off" ||
|
|
1974
|
+
inspection.status === "not-connected" ||
|
|
1975
|
+
inspection.status === "key-lacks-jev"
|
|
1976
|
+
? inspection.routing
|
|
1977
|
+
: null;
|
|
1978
|
+
const storedKey = inspection.status === "ok" ? inspection.config.apiKey : null;
|
|
1979
|
+
|
|
1980
|
+
let provider: JevProviderKind;
|
|
1981
|
+
let base: string | null;
|
|
1982
|
+
const urlArg = values.get("--url");
|
|
1983
|
+
if (urlArg !== undefined) {
|
|
1984
|
+
const checked = validateBaseUrl(urlArg);
|
|
1985
|
+
if (!checked.ok) return refuse("bad-url", [`Cannot read a model list: ${checked.problem}.`]);
|
|
1986
|
+
const asEndpoint = endpointGivenAsBase(checked.value);
|
|
1987
|
+
if (asEndpoint) return refuse("endpoint-as-base", endpointAsBaseRefusal("--url", checked.value, asEndpoint));
|
|
1988
|
+
base = checked.value;
|
|
1989
|
+
provider = (named as JevProviderKind | undefined) ?? providerForUrl(base);
|
|
1990
|
+
} else if (named !== undefined) {
|
|
1991
|
+
provider = named as JevProviderKind;
|
|
1992
|
+
base = JEV_PROVIDER_DEFAULTS[provider].baseUrl;
|
|
1993
|
+
if (base === null && provider === JEV_CLOUD_PROVIDER) {
|
|
1994
|
+
return refuse("no-model-list", [
|
|
1995
|
+
`FailproofAI Cloud serves no model list: it runs ${JEV_PROVIDER_DEFAULTS.failproofai.model}, pinned on the server, and every answer is checked against the Jev 1.13 family.`,
|
|
1996
|
+
]);
|
|
1997
|
+
}
|
|
1998
|
+
if (base === null) {
|
|
1999
|
+
return refuse("no-api", ["Provider custom has no API of its own — its URL is the whole address.", " failproofai jev models --url <base>"]);
|
|
2000
|
+
}
|
|
2001
|
+
} else if (routing !== null) {
|
|
2002
|
+
provider = routing.provider;
|
|
2003
|
+
base = routing.baseUrl ?? JEV_PROVIDER_DEFAULTS[routing.provider].baseUrl;
|
|
2004
|
+
} else {
|
|
2005
|
+
return refuse("not-configured", [
|
|
2006
|
+
`Jev is not configured here (no ${inspection.path}), so there is no endpoint to ask.`,
|
|
2007
|
+
"Name one:",
|
|
2008
|
+
" failproofai jev models --provider vercel",
|
|
2009
|
+
" failproofai jev models --url https://your-proxy.example.com/typesafe/v1",
|
|
2010
|
+
]);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// Cloudflare is the one route with no `<base>/models`: Workers AI runs models at
|
|
2014
|
+
// `/accounts/<id>/ai/run` and keeps its inventory behind a different API, so
|
|
2015
|
+
// there is nothing here to read rather than something that failed to read.
|
|
2016
|
+
// FailproofAI Cloud serves no list either: it pins the model server-side
|
|
2017
|
+
// (`jev-1.13.0`), so there is nothing to choose and nothing to read.
|
|
2018
|
+
const modelsUrl = provider === "cloudflare" || provider === JEV_CLOUD_PROVIDER || base === null ? null : modelsUrlForBase(base);
|
|
2019
|
+
if (modelsUrl === null) {
|
|
2020
|
+
return refuse("no-model-list", [
|
|
2021
|
+
provider === "cloudflare"
|
|
2022
|
+
? "Cloudflare Workers AI serves no <base>/models: its models are listed through the Cloudflare API, not through the Jev base URL."
|
|
2023
|
+
: provider === JEV_CLOUD_PROVIDER
|
|
2024
|
+
? `FailproofAI Cloud serves no model list: it runs ${JEV_PROVIDER_DEFAULTS.failproofai.model}, pinned on the server, and every answer is checked against the Jev 1.13 family.`
|
|
2025
|
+
: "That base URL names no endpoint a model list could be read from.",
|
|
2026
|
+
]);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
const askOrigin = originOf(provider, base);
|
|
2030
|
+
const configuredOrigin = routing !== null ? originOf(routing.provider, routing.baseUrl) : null;
|
|
2031
|
+
const sameOrigin = askOrigin !== null && askOrigin === configuredOrigin;
|
|
2032
|
+
const apiKey = sameOrigin ? storedKey : null;
|
|
2033
|
+
// Only meaningful for the route that is actually configured: marking a name on
|
|
2034
|
+
// some other host as "configured" would be saying something untrue.
|
|
2035
|
+
const configuredModel = sameOrigin && routing !== null ? (routing.model ?? JEV_PROVIDER_DEFAULTS[routing.provider].model) : null;
|
|
2036
|
+
|
|
2037
|
+
let list: JevModelListResult;
|
|
2038
|
+
try {
|
|
2039
|
+
list = await (deps.readModelList ?? liveModelListReader)(modelsUrl, apiKey);
|
|
2040
|
+
} catch (err) {
|
|
2041
|
+
list = { ok: false, reason: scrubSecret(err instanceof Error ? err.message : String(err), apiKey ?? "") };
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const shown = displayEndpoint(modelsUrl);
|
|
2045
|
+
const keyNote = apiKey
|
|
2046
|
+
? "the configured key for this origin"
|
|
2047
|
+
: sameOrigin
|
|
2048
|
+
? `none stored here — set ${JEV_API_KEY_ENV}, or store one with \`failproofai jev setup --key-stdin\``
|
|
2049
|
+
: storedKey !== null
|
|
2050
|
+
? "none sent: a stored key belongs to the endpoint it was stored for"
|
|
2051
|
+
: "none: nothing is configured for this endpoint, and some lists are public";
|
|
2052
|
+
|
|
2053
|
+
if (!list.ok) {
|
|
2054
|
+
if (asJson) {
|
|
2055
|
+
return fail([], JSON.stringify({ ok: false, provider, endpoint: shown, error: { code: "model-list-unread", message: list.reason } }, null, 2));
|
|
2056
|
+
}
|
|
2057
|
+
return fail(
|
|
2058
|
+
stack(
|
|
2059
|
+
title("failproofai jev models", "not read", opts),
|
|
2060
|
+
rows(
|
|
2061
|
+
[
|
|
2062
|
+
["provider", provider],
|
|
2063
|
+
["endpoint", shown],
|
|
2064
|
+
["key", keyNote],
|
|
2065
|
+
["reason", list.reason],
|
|
2066
|
+
],
|
|
2067
|
+
opts,
|
|
2068
|
+
),
|
|
2069
|
+
note(
|
|
2070
|
+
"Not every endpoint serves a model list — a proxy may expose only /systemone — and nothing depends on one: setup and hooks work without it.",
|
|
2071
|
+
opts,
|
|
2072
|
+
),
|
|
2073
|
+
),
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
if (asJson) {
|
|
2078
|
+
return ok(
|
|
2079
|
+
[],
|
|
2080
|
+
JSON.stringify({ ok: true, provider, endpoint: shown, shape: list.shape, models: list.models, configuredModel, keySent: apiKey !== null }, null, 2),
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
const listed = list.models.slice(0, MAX_SHOWN_MODELS);
|
|
2084
|
+
return ok(
|
|
2085
|
+
stack(
|
|
2086
|
+
title("failproofai jev models", `${list.models.length} · ${provider}`, opts),
|
|
2087
|
+
rows(
|
|
2088
|
+
[
|
|
2089
|
+
["provider", provider],
|
|
2090
|
+
["endpoint", shown],
|
|
2091
|
+
["key", keyNote],
|
|
2092
|
+
["list", shapeMeans(list.shape)],
|
|
2093
|
+
],
|
|
2094
|
+
opts,
|
|
2095
|
+
),
|
|
2096
|
+
rows(
|
|
2097
|
+
listed.map((name) => [name, name === configuredModel ? "configured" : ""] as [string, string]),
|
|
2098
|
+
opts,
|
|
2099
|
+
),
|
|
2100
|
+
list.models.length > listed.length ? note(`… and ${list.models.length - listed.length} more.`, opts) : null,
|
|
2101
|
+
configuredModel !== null && !modelListHasModel(list, configuredModel) && listDescribesSystemOne(list)
|
|
2102
|
+
? warning(
|
|
2103
|
+
[
|
|
2104
|
+
`The configured model ${configuredModel} is not one of these, so every evaluation would fail at this endpoint and hooks would fall back to regex.`,
|
|
2105
|
+
`Point it at one of them: failproofai jev setup --model ${list.models[0]}`,
|
|
2106
|
+
],
|
|
2107
|
+
opts,
|
|
2108
|
+
)
|
|
2109
|
+
: null,
|
|
2110
|
+
),
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// ── remove ───────────────────────────────────────────────────────────────────
|
|
2115
|
+
|
|
2116
|
+
function remove(argv: string[], opts: RenderOpts): JevCliResult {
|
|
2117
|
+
const parsed = parseFlags(argv, new Set());
|
|
2118
|
+
if (typeof parsed === "string") return fail([parsed, "", ...JEV_USAGE]);
|
|
2119
|
+
if (parsed.positionals.length > 0) return fail([STRAY_ARGUMENT, "", ...JEV_USAGE]);
|
|
2120
|
+
const path = jevConfigPath();
|
|
2121
|
+
if (!existsSync(path)) {
|
|
2122
|
+
return ok(stack(title("failproofai jev remove", "nothing to do", opts), note(`There is no ${path}; Jev is already off.`, opts)));
|
|
2123
|
+
}
|
|
2124
|
+
try {
|
|
2125
|
+
unlinkSync(path);
|
|
2126
|
+
} catch (err) {
|
|
2127
|
+
return fail([`Could not remove ${path}: ${(err as NodeJS.ErrnoException).code ?? "error"}.`]);
|
|
2128
|
+
}
|
|
2129
|
+
return ok(
|
|
2130
|
+
stack(
|
|
2131
|
+
title("failproofai jev remove", "off", opts),
|
|
2132
|
+
note(`Removed ${path}. Jev is off; hooks run the regex policies exactly as before, from the next tool call.`, opts),
|
|
2133
|
+
process.env[JEV_API_KEY_ENV] ? note(`${JEV_API_KEY_ENV} is still set in this shell. Without the file it does nothing.`, opts) : null,
|
|
2134
|
+
),
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// ── dispatch ─────────────────────────────────────────────────────────────────
|
|
2139
|
+
|
|
2140
|
+
/** Dispatch for `failproofai jev <sub> ...`. Never prints; the caller does. */
|
|
2141
|
+
export async function runJevCommand(argv: string[], deps: JevCliDeps = {}): Promise<JevCliResult> {
|
|
2142
|
+
const opts = deps.render ?? optsFor(process.stdout);
|
|
2143
|
+
const [sub, ...rest] = argv;
|
|
2144
|
+
// `failproofai jev --url <url> --token <token>` — the one-shot form. An argv
|
|
2145
|
+
// that opens with an option is `setup` with that option: nothing else here
|
|
2146
|
+
// takes one in the subcommand slot, and the two spellings then parse through
|
|
2147
|
+
// exactly the same code.
|
|
2148
|
+
if (sub !== undefined && sub.startsWith("-")) return setup(argv, deps, opts);
|
|
2149
|
+
switch (sub) {
|
|
2150
|
+
case "setup":
|
|
2151
|
+
return setup(rest, deps, opts);
|
|
2152
|
+
case "status":
|
|
2153
|
+
return status(rest, opts);
|
|
2154
|
+
case "test":
|
|
2155
|
+
return test(rest, deps, opts);
|
|
2156
|
+
case "models":
|
|
2157
|
+
return models(rest, deps, opts);
|
|
2158
|
+
case "remove":
|
|
2159
|
+
return remove(rest, opts);
|
|
2160
|
+
default: {
|
|
2161
|
+
// The subcommand slot takes a pasted key as readily as any flag does
|
|
2162
|
+
// (`failproofai jev <key>`, having forgotten `setup --key-stdin`), so it
|
|
2163
|
+
// is repeated only when it is shaped like a subcommand — which no key is.
|
|
2164
|
+
const nameLike = sub !== undefined && SUBCOMMAND_SHAPE.test(sub);
|
|
2165
|
+
return fail([
|
|
2166
|
+
!sub ? "A subcommand is required." : nameLike ? `Unknown subcommand: ${sub}` : "Unknown subcommand (not repeated here, in case it is a key).",
|
|
2167
|
+
"",
|
|
2168
|
+
...JEV_USAGE,
|
|
2169
|
+
]);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
}
|