failproofai 1.0.0-beta.16 → 1.0.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +5 -5
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -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/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17itmx-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0no2gzh._.js → [root-of-the-server]__1a-my61._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1vwv3db._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0vo2qyt._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0xej-0e._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_10399ge._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_149k9oy._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1_hwp06._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_09tcu2b._.js → _1ifzifk._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1mnhi_r._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1ror7k9._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- 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 +15 -15
- package/.next/standalone/.next/static/chunks/{1_as8528fwqxf.js → 0rsx_d5zvy-dc.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fx3tgb_n0vz5.js → 16_af09907p72.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2okcei5v0-xrx.js → 27nyktroru6xd.js} +1 -1
- package/.next/standalone/.next/static/chunks/{16yzosmvpinbh.js → 2_g_ltvh3jcgh.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3xp43uu8jj8t4.js → 2cgpelmb2zoh-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2f2h7g8sm0bhr.js → 2kjsoq1g8t4qc.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1nsy22eg5q-pi.js → 2q5ygkgs9l1gk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2n6wkcj5q_0yd.js → 2toc5dmy7efqk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2rl5gq4xl8oa2.js → 31fmsp1soxi07.js} +1 -1
- package/.next/standalone/.next/static/chunks/{43kqvfa-2jq0g.js → 3wq4n27ea5hkm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vsiw3ayz3c7_.js → 3yxozwzbd09eg.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-0ud32mdjjcn6y.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/dist/cli.mjs +175 -142
- package/dist/worker.mjs +1 -1
- package/package.json +9 -9
- package/src/hooks/configure-wizard.ts +34 -1
- package/src/hooks/daemon-service.ts +89 -62
- package/src/hooks/fp-reset.ts +16 -0
- package/src/hooks/harness-cli.ts +88 -11
- package/src/hooks/hook-activity-store.ts +29 -1
- package/src/hooks/migrations.ts +40 -15
- package/src/hooks/tui.ts +28 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hxmox-._.js +0 -3
- /package/.next/standalone/.next/static/{kSTfP_Tzl760gYDnYr-JC → d2alqN0WTpN7MpYISLm81}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{kSTfP_Tzl760gYDnYr-JC → d2alqN0WTpN7MpYISLm81}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{kSTfP_Tzl760gYDnYr-JC → d2alqN0WTpN7MpYISLm81}/_ssgManifest.js +0 -0
package/dist/worker.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "failproofai",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.18",
|
|
4
4
|
"description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
|
|
5
5
|
"bin": {
|
|
6
6
|
"failproofai": "./dist/cli.mjs",
|
|
@@ -112,15 +112,15 @@
|
|
|
112
112
|
"sharp": "0.35.0"
|
|
113
113
|
},
|
|
114
114
|
"optionalDependencies": {
|
|
115
|
-
"@failproofai/failproofaid-linux-x64": "1.0.0-beta.
|
|
116
|
-
"@failproofai/failproofaid-linux-arm64": "1.0.0-beta.
|
|
117
|
-
"@failproofai/failproofaid-darwin-x64": "1.0.0-beta.
|
|
118
|
-
"@failproofai/failproofaid-darwin-arm64": "1.0.0-beta.
|
|
115
|
+
"@failproofai/failproofaid-linux-x64": "1.0.0-beta.18",
|
|
116
|
+
"@failproofai/failproofaid-linux-arm64": "1.0.0-beta.18",
|
|
117
|
+
"@failproofai/failproofaid-darwin-x64": "1.0.0-beta.18",
|
|
118
|
+
"@failproofai/failproofaid-darwin-arm64": "1.0.0-beta.18"
|
|
119
119
|
},
|
|
120
120
|
"failproofaidBinaries": {
|
|
121
|
-
"linux-x64": "
|
|
122
|
-
"linux-arm64": "
|
|
123
|
-
"darwin-x64": "
|
|
124
|
-
"darwin-arm64": "
|
|
121
|
+
"linux-x64": "54bf7cc41ee8b5a2ea9430459c1fe9b8b03ece25c18d8bee49441feee93c7057",
|
|
122
|
+
"linux-arm64": "1f5e7c4be2d9c591cf4fd39e5fc122e71f507115cdb2ad1a27c5f30f22a30028",
|
|
123
|
+
"darwin-x64": "ae510969144aaec5e8891cfa5cdf7374c5efbd7554547046efa5913aab5c3ab2",
|
|
124
|
+
"darwin-arm64": "bc8d96e295aa9a68590be6e12d4bf3711749a31371206e63ccc36dd42b275e18"
|
|
125
125
|
}
|
|
126
126
|
}
|
|
@@ -967,6 +967,24 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
967
967
|
// is stated, not prompted), so ← there would sometimes go nowhere.
|
|
968
968
|
let presets: string[] | null = null;
|
|
969
969
|
let clisSel: string[] | null = null;
|
|
970
|
+
/**
|
|
971
|
+
* What the harness step had ticked when ← was last pressed.
|
|
972
|
+
*
|
|
973
|
+
* A SEPARATE variable, because `clisSel` cannot do this job: it is the loop's
|
|
974
|
+
* own condition (`while (clisSel === null)`), so it is null on every entry into
|
|
975
|
+
* the body by definition, and it is assigned only on the line that ends the
|
|
976
|
+
* loop. The restore that read `clisSel` was therefore unreachable — provably
|
|
977
|
+
* dead, with a comment stating the opposite intent.
|
|
978
|
+
*
|
|
979
|
+
* The cost was not cosmetic: deselect a CLI, press ← to fix an earlier answer,
|
|
980
|
+
* come back, and the step showed the detected defaults again. Pressing ↵ then —
|
|
981
|
+
* reasonably, having been told the selection was carried back — re-enabled hook
|
|
982
|
+
* installation for a CLI the user had explicitly turned off.
|
|
983
|
+
*
|
|
984
|
+
* `presets` just above works because it is assigned MID-loop and survives to the
|
|
985
|
+
* next iteration; this mirrors that, filled from the prompt's `onBack`.
|
|
986
|
+
*/
|
|
987
|
+
const carried: { clis: string[] | null } = { clis: null };
|
|
970
988
|
while (clisSel === null) {
|
|
971
989
|
// Re-entering after a ← must show what was picked, not a blank slate.
|
|
972
990
|
// Selection state lives on each choice, so carry it back in.
|
|
@@ -986,7 +1004,16 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
986
1004
|
|
|
987
1005
|
// 3 — Which harnesses? An "Everything available" row protects every supported
|
|
988
1006
|
// CLI (detected + set-up-ahead); when ticked it wins over the individual boxes.
|
|
989
|
-
|
|
1007
|
+
// Read off a HOLDER OBJECT, not a bare `let`, and not through a cast.
|
|
1008
|
+
//
|
|
1009
|
+
// A `let` assigned only inside a callback is narrowed by control-flow analysis
|
|
1010
|
+
// to its initializer, so `priorClis.includes` will not compile — and the
|
|
1011
|
+
// original defeated that with `clisSel as string[] | null`. That cast is
|
|
1012
|
+
// precisely why the dead code type-checked and nobody noticed: it silenced the
|
|
1013
|
+
// compiler making exactly the point the reviewer later made by hand, that the
|
|
1014
|
+
// value could only ever be null. A property read carries the declared type
|
|
1015
|
+
// without suppressing anything.
|
|
1016
|
+
const priorClis = carried.clis;
|
|
990
1017
|
const picked: string[] | typeof BACK | null = await multiSelect<string>({
|
|
991
1018
|
message: "Which harnesses should it protect?",
|
|
992
1019
|
choices: [
|
|
@@ -1006,6 +1033,12 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
1006
1033
|
summaryNoun: "harnesses",
|
|
1007
1034
|
hint: "detected CLIs are pre-selected · space toggles · ctrl+a all · ← back · ↵ confirm",
|
|
1008
1035
|
allowBack: true as const,
|
|
1036
|
+
// `BACK` is a symbol and cannot carry the selection, so the prompt reports
|
|
1037
|
+
// it here instead — otherwise a ← discards what the user had ticked and the
|
|
1038
|
+
// next pass redraws the detected defaults.
|
|
1039
|
+
onBack: (checkedNow) => {
|
|
1040
|
+
carried.clis = checkedNow;
|
|
1041
|
+
},
|
|
1009
1042
|
stdin,
|
|
1010
1043
|
stdout,
|
|
1011
1044
|
});
|
|
@@ -1506,77 +1506,104 @@ export async function ensureDaemonServiceCurrent(): Promise<DaemonUpgradeResult>
|
|
|
1506
1506
|
* to in-process evaluation would trade a visible failure for an invisible one.
|
|
1507
1507
|
* The lines say so.
|
|
1508
1508
|
*/
|
|
1509
|
-
export async function refreshDaemonToCliVersion(
|
|
1510
|
-
|
|
1511
|
-
|
|
1509
|
+
export async function refreshDaemonToCliVersion(
|
|
1510
|
+
/**
|
|
1511
|
+
* Injectable for tests, matching `waitForDaemonRunning`'s shape in this file.
|
|
1512
|
+
* A `vi.spyOn` on the module namespace cannot intercept these — the module
|
|
1513
|
+
* calls its own local bindings — so without a seam a test asserting either
|
|
1514
|
+
* branch passes for the wrong reason: on a machine with no service, "no-op"
|
|
1515
|
+
* and "the spy worked" are indistinguishable.
|
|
1516
|
+
*/
|
|
1517
|
+
deps: {
|
|
1518
|
+
status?: () => DaemonServiceStatus;
|
|
1519
|
+
install?: () => Promise<DaemonInstallResult>;
|
|
1520
|
+
prime?: () => boolean;
|
|
1521
|
+
interactive?: () => boolean;
|
|
1522
|
+
} = {},
|
|
1523
|
+
): Promise<{ ok: boolean; lines: string[] }> {
|
|
1524
|
+
const status = deps.status ?? daemonServiceStatus;
|
|
1525
|
+
const install = deps.install ?? installDaemonService;
|
|
1526
|
+
const prime = deps.prime ?? primeElevation;
|
|
1527
|
+
const interactive =
|
|
1528
|
+
deps.interactive ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
1529
|
+
if (status() === "not-installed") {
|
|
1512
1530
|
return { ok: true, lines: ["No failproofaid service on this machine; nothing to update."] };
|
|
1513
1531
|
}
|
|
1514
1532
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
lines: [
|
|
1560
|
-
...lines,
|
|
1561
|
-
`Could not restart failproofaid without a password. Run this yourself:`,
|
|
1562
|
-
` ${restart ?? "restart the failproofaid service"}`,
|
|
1563
|
-
],
|
|
1564
|
-
};
|
|
1565
|
-
}
|
|
1566
|
-
// Probed, never inferred from the restart command's exit code — see
|
|
1567
|
-
// `waitForDaemonRunning`, which exists because one reading of `is-active`
|
|
1568
|
-
// passes a daemon that died immediately after forking.
|
|
1569
|
-
if (!(await waitForDaemonRunning())) {
|
|
1533
|
+
// `installDaemonService()`, not a binary fetch plus a restart.
|
|
1534
|
+
//
|
|
1535
|
+
// The first version of this did the latter, and it did not work: it called
|
|
1536
|
+
// `ensureFailproofaidBinary()` (which correctly lands
|
|
1537
|
+
// `bin/failproofaid-<this version>`) and then `ensureDaemonServiceCurrent()`,
|
|
1538
|
+
// whose rewrite goes through `upgradedServiceDefinition` — and that PRESERVES
|
|
1539
|
+
// the existing `ExecStart` via `installedExecStart(definition)`, by design,
|
|
1540
|
+
// because its job is to upgrade the unit's SHAPE without changing which binary
|
|
1541
|
+
// runs. So on a machine coming from an older release the new binary was
|
|
1542
|
+
// downloaded, the unit was rewritten, the service was restarted, and the OLD
|
|
1543
|
+
// binary came back up — under a message that said the daemon had been
|
|
1544
|
+
// refreshed. Worse than not having the command, because it reports success.
|
|
1545
|
+
//
|
|
1546
|
+
// `installDaemonService()` is the function that already knows how to make the
|
|
1547
|
+
// service run THIS CLI's version: it resolves the binary for this version,
|
|
1548
|
+
// writes the unit around that path, removes any legacy user unit, reloads,
|
|
1549
|
+
// enables, and waits for the service to reach AND HOLD a running state rather
|
|
1550
|
+
// than trusting the moment a `Type=simple` unit reports active. It is
|
|
1551
|
+
// idempotent, which is what makes it safe to call on an already-installed
|
|
1552
|
+
// machine.
|
|
1553
|
+
// ASK FOR THE PASSWORD, when there is somebody to ask.
|
|
1554
|
+
//
|
|
1555
|
+
// Writing the unit needs root, and `runPrivileged` uses `sudo -n` — which never
|
|
1556
|
+
// prompts. That rule exists for the WIZARD, whose reason is stated where it is
|
|
1557
|
+
// enforced: a password prompt fired from underneath a full-screen TUI is
|
|
1558
|
+
// unreadable at best. It does not transfer to this command, which is plain line
|
|
1559
|
+
// output with nothing to corrupt, and the wizard itself calls `primeElevation()`
|
|
1560
|
+
// for exactly this at exactly this point.
|
|
1561
|
+
//
|
|
1562
|
+
// Without it, `failproofai update` on the machine it exists for — one with a
|
|
1563
|
+
// daemon, upgrading — failed with "sudo credentials were not available" and a
|
|
1564
|
+
// thirty-line unit file to paste by hand. The only working alternatives were
|
|
1565
|
+
// `sudo -v` first (undocumented) or `failproofai config`, an interactive wizard.
|
|
1566
|
+
// That is not an upgrade path.
|
|
1567
|
+
//
|
|
1568
|
+
// Gated on a TTY, not attempted blindly: on a CI runner or a fleet box there is
|
|
1569
|
+
// nobody to type a password, and `sudo -v` there would block on a prompt nothing
|
|
1570
|
+
// will answer. Those runs still fall through to `sudo -n`, fail, and get the
|
|
1571
|
+
// exact commands to run — which is the right outcome for an unattended machine.
|
|
1572
|
+
// `primeElevation()` is itself a no-op when already root or NOPASSWD.
|
|
1573
|
+
if (interactive()) prime();
|
|
1574
|
+
|
|
1575
|
+
const result = await install();
|
|
1576
|
+
if (!result.installed) {
|
|
1570
1577
|
return {
|
|
1571
1578
|
ok: false,
|
|
1572
1579
|
lines: [
|
|
1573
|
-
|
|
1574
|
-
`
|
|
1575
|
-
`
|
|
1580
|
+
`failproofaid ${version} could not be installed: ${result.reason ?? "unknown reason"}`,
|
|
1581
|
+
`The previous daemon is untouched. On a machine configured to require it,`,
|
|
1582
|
+
`enforcement continues; collection and cloud policy may be stale until this`,
|
|
1583
|
+
`succeeds. \`${daemonStatusCommand() ?? "systemctl status"}\` will say more.`,
|
|
1576
1584
|
],
|
|
1577
1585
|
};
|
|
1578
1586
|
}
|
|
1579
|
-
|
|
1587
|
+
// RECORD THE VERSION, or the refresh is invisible to everything that asks.
|
|
1588
|
+
//
|
|
1589
|
+
// `installDaemonService()` deliberately does not write it — only the wizard did
|
|
1590
|
+
// (`setDaemonConfigured(true, cliVersion)`), because that is where "this machine
|
|
1591
|
+
// is now configured, at this version" is decided. So without this line the new
|
|
1592
|
+
// binary runs while `VERSION.daemon` still names the old one, and
|
|
1593
|
+
// `daemonVersionSkew()` reads that file: every later CLI command keeps nudging
|
|
1594
|
+
// about a stale daemon that was just replaced, and the wizard's `daemonMaybeUp`
|
|
1595
|
+
// stays false on the skew it should no longer see — so a later `failproofai
|
|
1596
|
+
// config` would tear down and rebuild a perfectly current service.
|
|
1597
|
+
//
|
|
1598
|
+
// `writeVersionFile` rather than `setDaemonConfigured(true, …)`: that helper also
|
|
1599
|
+
// sets `daemon.configured`, and turning on fail-closed enforcement as a SIDE
|
|
1600
|
+
// EFFECT of an update is not this command's decision to make. An update refreshes
|
|
1601
|
+
// what is installed; it does not change whether the machine requires it.
|
|
1602
|
+
writeVersionFile({ daemon: version });
|
|
1603
|
+
return {
|
|
1604
|
+
ok: true,
|
|
1605
|
+
lines: [`failproofaid ${version} installed, service restarted and holding.`],
|
|
1606
|
+
};
|
|
1580
1607
|
}
|
|
1581
1608
|
|
|
1582
1609
|
export async function uninstallDaemonService(): Promise<void> {
|
package/src/hooks/fp-reset.ts
CHANGED
|
@@ -207,6 +207,22 @@ export function migrateConventionPolicies(): string[] {
|
|
|
207
207
|
// the same rule, one level down.
|
|
208
208
|
if (entry.isDirectory() && statSync(target).isDirectory()) {
|
|
209
209
|
mergeInto(source, target, `${name}/`);
|
|
210
|
+
// AND remove the child once the recursion drained it. Without this, a
|
|
211
|
+
// merged directory leaves an empty husk behind, so the `rmdirSync(from)`
|
|
212
|
+
// below throws ENOTEMPTY into a swallowing `catch` and
|
|
213
|
+
// `custom-policies/` survives the migration it just completed —
|
|
214
|
+
// permanently, because the next run recurses into the same empty child
|
|
215
|
+
// and fails the same way, so it never self-heals.
|
|
216
|
+
//
|
|
217
|
+
// Only reachable when the merge moved EVERYTHING: a genuine leaf
|
|
218
|
+
// collision inside leaves a file here, `rmdirSync` refuses, and the
|
|
219
|
+
// catch is then correct — that husk is the user's remaining source,
|
|
220
|
+
// which is the one thing this function must not delete.
|
|
221
|
+
try {
|
|
222
|
+
rmdirSync(source);
|
|
223
|
+
} catch {
|
|
224
|
+
// Not empty, so something was deliberately left. Keep it.
|
|
225
|
+
}
|
|
210
226
|
}
|
|
211
227
|
}
|
|
212
228
|
};
|
package/src/hooks/harness-cli.ts
CHANGED
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
* running daemon never picks up.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { resolve } from "node:path";
|
|
38
40
|
import { readConfig, updateConfig } from "./fp-config";
|
|
39
41
|
import { configFile } from "./fp-home";
|
|
40
42
|
|
|
@@ -143,6 +145,55 @@ const TAKES_EFFECT_HINT = [
|
|
|
143
145
|
"The daemon picks this up on its own within a few seconds — no restart, no sudo.",
|
|
144
146
|
];
|
|
145
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The daemon's label rule, FOR COMPARISON ONLY.
|
|
150
|
+
*
|
|
151
|
+
* Mirrors `sanitize_label()` in `crates/fpai-collect/src/extra_paths.rs`:
|
|
152
|
+
* lowercase, every non-alphanumeric run collapsed to one `-`, leading and
|
|
153
|
+
* trailing dashes trimmed.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately NOT used to rewrite what is stored. The daemon is the authority on
|
|
156
|
+
* the grammar, and this file's header is explicit that it must not re-implement
|
|
157
|
+
* the parser — "two parsers is how the CLI comes to accept a path the daemon then
|
|
158
|
+
* silently drops". Normalising for a duplicate CHECK is the narrow opposite of
|
|
159
|
+
* that: it makes the CLI's own pre-flight agree with the rule it exists to
|
|
160
|
+
* pre-empt, while the stored string stays exactly what the user typed and the
|
|
161
|
+
* daemon still derives the real label.
|
|
162
|
+
*/
|
|
163
|
+
function comparableLabel(raw: string): string {
|
|
164
|
+
return raw
|
|
165
|
+
.toLowerCase()
|
|
166
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
167
|
+
.replace(/^-+|-+$/g, "");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The daemon's path rule, for comparison only. Mirrors `clean()`: `~/` expanded,
|
|
172
|
+
* trailing slashes trimmed.
|
|
173
|
+
*/
|
|
174
|
+
function comparablePath(raw: string): string {
|
|
175
|
+
const home = homedir();
|
|
176
|
+
const expanded = raw === "~" ? home : raw.startsWith("~/") ? resolve(home, raw.slice(2)) : raw;
|
|
177
|
+
return expanded.replace(/\/+$/, "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What the daemon will call this entry, label given or derived.
|
|
182
|
+
*
|
|
183
|
+
* Mirrors `derive_label()` for the unlabelled case: the folder name with a leading
|
|
184
|
+
* dot stripped, then sanitised. Without this an unlabelled
|
|
185
|
+
* `/mnt/team-a/.claude/projects` and `/mnt/team-b/.claude/projects` both derive
|
|
186
|
+
* "projects" — the CLI's label check was skipped entirely for unlabelled entries,
|
|
187
|
+
* so both were written and the daemon silently dropped the second.
|
|
188
|
+
*/
|
|
189
|
+
function effectiveLabel(entry: string): string {
|
|
190
|
+
const given = labelOf(entry);
|
|
191
|
+
if (given) return comparableLabel(given);
|
|
192
|
+
const path = comparablePath(pathOf(entry));
|
|
193
|
+
const base = path.split("/").filter(Boolean).pop() ?? "";
|
|
194
|
+
return comparableLabel(base.replace(/^\.+/, ""));
|
|
195
|
+
}
|
|
196
|
+
|
|
146
197
|
export function addPath(harness: string, entry: string): HarnessResult {
|
|
147
198
|
if (!(HARNESS_KEYS as readonly string[]).includes(harness)) return unknownHarness(harness);
|
|
148
199
|
const trimmed = entry.trim();
|
|
@@ -163,7 +214,11 @@ export function addPath(harness: string, entry: string): HarnessResult {
|
|
|
163
214
|
// Both are rejected HERE rather than left to the daemon, because the daemon
|
|
164
215
|
// resolves them at startup and drops one — so the CLI would report success
|
|
165
216
|
// for a path that is never captured.
|
|
166
|
-
|
|
217
|
+
// Compared NORMALISED, not raw. `add-path claude a=/srv/x/` then
|
|
218
|
+
// `add-path claude b=/srv/x` differ as strings and are the same path to the
|
|
219
|
+
// daemon, which resolves them at startup and drops one — so the CLI reported
|
|
220
|
+
// success for a path that is never captured.
|
|
221
|
+
const samePath = existing.find((e) => comparablePath(pathOf(e)) === comparablePath(pathOf(trimmed)));
|
|
167
222
|
if (samePath) {
|
|
168
223
|
return fail([
|
|
169
224
|
`${harness} already captures ${pathOf(trimmed)} as ${JSON.stringify(samePath)}.`,
|
|
@@ -173,24 +228,46 @@ export function addPath(harness: string, entry: string): HarnessResult {
|
|
|
173
228
|
]);
|
|
174
229
|
}
|
|
175
230
|
const wanted = labelOf(trimmed);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
231
|
+
// The EFFECTIVE label, normalised, and computed for unlabelled entries too.
|
|
232
|
+
// Three inputs slipped past the old exact-string check on the given label:
|
|
233
|
+
// `"Team Share"` vs `team-share` (the daemon lowercases and substitutes), and two
|
|
234
|
+
// unlabelled paths whose folder name derives the same label — for which the check
|
|
235
|
+
// did not run at all. In every case both entries were written, `harness list`
|
|
236
|
+
// showed both, and only one was captured.
|
|
237
|
+
const effective = effectiveLabel(trimmed);
|
|
238
|
+
const sameLabel = effective
|
|
239
|
+
? existing.find((e) => effectiveLabel(e) === effective)
|
|
240
|
+
: undefined;
|
|
241
|
+
if (sameLabel) {
|
|
242
|
+
return fail([
|
|
243
|
+
`${harness} already uses the label ${JSON.stringify(effective)} for ${pathOf(sameLabel)}.`,
|
|
244
|
+
"",
|
|
245
|
+
"Labels namespace agent ids, so two paths cannot share one — and the daemon",
|
|
246
|
+
"lowercases them and collapses punctuation to `-`, so labels that differ only",
|
|
247
|
+
"in case or spacing collide. An unlabelled path takes its folder name.",
|
|
248
|
+
"",
|
|
249
|
+
"Give this one an explicit label:",
|
|
250
|
+
` failproofai harness add-path ${harness} <label>=${pathOf(trimmed)}`,
|
|
251
|
+
]);
|
|
185
252
|
}
|
|
186
253
|
|
|
187
254
|
writePaths(harness, [...existing, trimmed]);
|
|
188
255
|
return ok([
|
|
189
|
-
|
|
256
|
+
// "configured", NOT "now capturing". The checks above cover what this side can
|
|
257
|
+
// know — the entries already in the file — and there is one rejection it
|
|
258
|
+
// cannot: a path overlapping the harness's own DEFAULT capture root, which the
|
|
259
|
+
// daemon owns and refuses. Claiming capture there was a promise this command
|
|
260
|
+
// is not in a position to make, so it says what it did and where the answer
|
|
261
|
+
// is. Teaching the CLI all thirteen sources' default roots would be the second
|
|
262
|
+
// parser this file exists to avoid.
|
|
263
|
+
`${harness}: configured to also capture ${pathOf(trimmed)}`,
|
|
190
264
|
wanted
|
|
191
265
|
? ` agent ids will be namespaced ${wanted}-*`
|
|
192
266
|
: " a label will be derived from the folder name; `failproofai harness list` shows it",
|
|
193
267
|
` written to ${configFile()}`,
|
|
268
|
+
"",
|
|
269
|
+
" The daemon validates it on the next read and reports what it rejected —",
|
|
270
|
+
" `failproofai harness list` shows what is actually being captured.",
|
|
194
271
|
...TAKES_EFFECT_HINT,
|
|
195
272
|
]);
|
|
196
273
|
}
|
|
@@ -388,6 +388,34 @@ export function getHookActivityHistory(page: number): {
|
|
|
388
388
|
|
|
389
389
|
// ── Internal helpers ──
|
|
390
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Accept the pre-rename spelling of the two cloud-attribution fields.
|
|
393
|
+
*
|
|
394
|
+
* These pages are written by the DAEMON, so a machine that was cloud-connected
|
|
395
|
+
* before `cloudRevision`→`cloudVersion` / `cloudGeneration`→`cloudDeployment` has
|
|
396
|
+
* real rows on disk naming the old keys. Nothing here validates the shape — the
|
|
397
|
+
* line is `JSON.parse`d and cast — so those rows do not error, they simply carry
|
|
398
|
+
* keys nothing reads: every pre-upgrade cloud-decided decision renders as
|
|
399
|
+
* unattributed, which is the exact question these fields were added to answer.
|
|
400
|
+
*
|
|
401
|
+
* The Rust writer takes the same two aliases (`transform.rs`). Renaming a symbol
|
|
402
|
+
* is safe; renaming the name of data an older build already wrote is not.
|
|
403
|
+
*
|
|
404
|
+
* One-directional and non-destructive: the new key wins if both somehow appear,
|
|
405
|
+
* and the old key is left in place rather than deleted, since nothing reads it and
|
|
406
|
+
* removing it would rewrite history to look like it was always current.
|
|
407
|
+
*/
|
|
408
|
+
function withRenamedCloudFields(raw: Record<string, unknown>): HookActivityEntry {
|
|
409
|
+
const entry = raw as HookActivityEntry & Record<string, unknown>;
|
|
410
|
+
if (entry.cloudVersion === undefined && typeof raw.cloudRevision === "number") {
|
|
411
|
+
entry.cloudVersion = raw.cloudRevision;
|
|
412
|
+
}
|
|
413
|
+
if (entry.cloudDeployment === undefined && typeof raw.cloudGeneration === "number") {
|
|
414
|
+
entry.cloudDeployment = raw.cloudGeneration;
|
|
415
|
+
}
|
|
416
|
+
return entry;
|
|
417
|
+
}
|
|
418
|
+
|
|
391
419
|
function readJsonlFile(filePath: string): HookActivityEntry[] {
|
|
392
420
|
const content = readFileSafe(filePath);
|
|
393
421
|
if (!content.trim()) return [];
|
|
@@ -395,7 +423,7 @@ function readJsonlFile(filePath: string): HookActivityEntry[] {
|
|
|
395
423
|
const entries: HookActivityEntry[] = [];
|
|
396
424
|
for (const line of content.trim().split("\n")) {
|
|
397
425
|
try {
|
|
398
|
-
entries.push(JSON.parse(line) as
|
|
426
|
+
entries.push(withRenamedCloudFields(JSON.parse(line) as Record<string, unknown>));
|
|
399
427
|
} catch {
|
|
400
428
|
// Skip malformed lines
|
|
401
429
|
}
|
package/src/hooks/migrations.ts
CHANGED
|
@@ -207,7 +207,44 @@ function appendLedger(entry: LedgerEntry): void {
|
|
|
207
207
|
* such a bug would hurt.
|
|
208
208
|
*/
|
|
209
209
|
const BACKED_UP = [configFile, credentialsFile, globalPolicyConfigFile, versionFile];
|
|
210
|
-
const BACKED_UP_LEGACY = [
|
|
210
|
+
const BACKED_UP_LEGACY = [
|
|
211
|
+
legacy.configToml,
|
|
212
|
+
legacy.credentialsToml,
|
|
213
|
+
legacy.policyConfig,
|
|
214
|
+
// Layout 1's credentials, which the migration DELETES and which nothing
|
|
215
|
+
// regenerates. They were missing here while the carry that reads them was
|
|
216
|
+
// added, so on the layout-1 leg — the one a user upgrading from the published
|
|
217
|
+
// `latest` actually takes — the cloud token was removed with no copy kept. That
|
|
218
|
+
// is precisely the file this backup exists for, and its absence made the backup
|
|
219
|
+
// most incomplete exactly where it mattered most.
|
|
220
|
+
legacy.cloudCredentials,
|
|
221
|
+
legacy.ingestCredentials,
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The files that exist right now and would be backed up, each exactly once.
|
|
226
|
+
*
|
|
227
|
+
* ONE function, because there were two: `backupBeforeMigrating` deduped by path
|
|
228
|
+
* and `describePlan` built the same list again without deduping, so
|
|
229
|
+
* `migrate --dry-run` printed `policies-config.json` TWICE while the backup
|
|
230
|
+
* correctly wrote it once. Seen on a real machine, in the one command whose
|
|
231
|
+
* entire job is to state accurately what is about to happen.
|
|
232
|
+
*
|
|
233
|
+
* The duplicate is not a typo waiting to be spotted, it is structural:
|
|
234
|
+
* `legacy.policyConfig()` and `globalPolicyConfigFile()` are the SAME path,
|
|
235
|
+
* because layout 3 put the policy config back exactly where layout 1 kept it. Any
|
|
236
|
+
* caller that walks both lists sees it twice. So the walk lives here once and
|
|
237
|
+
* nobody walks those arrays directly — which is the same "state it once, derive
|
|
238
|
+
* the rest" move `resettablePaths()` makes over `HOME_CLASSES`.
|
|
239
|
+
*/
|
|
240
|
+
function filesToBackUp(): string[] {
|
|
241
|
+
const seen = new Set<string>();
|
|
242
|
+
for (const at of [...BACKED_UP, ...BACKED_UP_LEGACY]) {
|
|
243
|
+
const path = at();
|
|
244
|
+
if (existsSync(path)) seen.add(path);
|
|
245
|
+
}
|
|
246
|
+
return [...seen];
|
|
247
|
+
}
|
|
211
248
|
|
|
212
249
|
/** Copy the irreplaceable files aside. Returns the basenames it saved. */
|
|
213
250
|
export function backupBeforeMigrating(from: number): string[] {
|
|
@@ -221,16 +258,7 @@ export function backupBeforeMigrating(from: number): string[] {
|
|
|
221
258
|
} catch {
|
|
222
259
|
return [];
|
|
223
260
|
}
|
|
224
|
-
|
|
225
|
-
// the SAME file — layout 3 put the policy config back exactly where layout 1
|
|
226
|
-
// kept it — so listing both copied it twice and printed the name twice in the
|
|
227
|
-
// "Saved first:" line the user actually reads.
|
|
228
|
-
const seen = new Set<string>();
|
|
229
|
-
for (const at of [...BACKED_UP, ...BACKED_UP_LEGACY]) {
|
|
230
|
-
const source = at();
|
|
231
|
-
if (seen.has(source)) continue;
|
|
232
|
-
seen.add(source);
|
|
233
|
-
if (!existsSync(source)) continue;
|
|
261
|
+
for (const source of filesToBackUp()) {
|
|
234
262
|
try {
|
|
235
263
|
copyFileSync(source, resolve(dir, basename(source)));
|
|
236
264
|
saved.push(basename(source));
|
|
@@ -350,10 +378,7 @@ export function describePlan(from: number): string[] {
|
|
|
350
378
|
...chain.map((m) => ` ${m.from} → ${m.to} ${m.describe}`),
|
|
351
379
|
``,
|
|
352
380
|
`These would be copied to ${migrationBackupDir(from)} first:`,
|
|
353
|
-
...
|
|
354
|
-
.map((at) => at())
|
|
355
|
-
.filter((p) => existsSync(p))
|
|
356
|
-
.map((p) => ` ${basename(p)}`),
|
|
381
|
+
...filesToBackUp().map((p) => ` ${basename(p)}`),
|
|
357
382
|
``,
|
|
358
383
|
`Settings, cloud enrolment, policy selection, your own policy files, decision`,
|
|
359
384
|
`history and undelivered events are carried, not removed — see \`HOME_CLASSES\`.`,
|
package/src/hooks/tui.ts
CHANGED
|
@@ -68,6 +68,22 @@ export interface MultiChoice<T> {
|
|
|
68
68
|
export interface MultiSelectOptions<T> {
|
|
69
69
|
/** Offer ← to go back a step. The caller must handle `BACK`. */
|
|
70
70
|
allowBack?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Called with what was ticked at the moment ← was pressed.
|
|
73
|
+
*
|
|
74
|
+
* `BACK` is a symbol, so it cannot carry a value, and the selection lives in a
|
|
75
|
+
* local array here rather than on the caller's choice objects — so a caller had
|
|
76
|
+
* no way to learn what a user had toggled before stepping back. The wizard's
|
|
77
|
+
* harness step needs exactly that: without it, deselecting a CLI and pressing ←
|
|
78
|
+
* discards the deselection, the step is redrawn from the detected defaults, and
|
|
79
|
+
* confirming re-enables hook installation for a CLI the user explicitly turned
|
|
80
|
+
* off.
|
|
81
|
+
*
|
|
82
|
+
* OPTIONAL and additive rather than a change to the return type: `BACK` is
|
|
83
|
+
* shared with `selectOne` and every other caller, and widening that contract to
|
|
84
|
+
* fix one step's state would be a much larger surface than the bug.
|
|
85
|
+
*/
|
|
86
|
+
onBack?: (checkedNow: T[]) => void;
|
|
71
87
|
message: string;
|
|
72
88
|
choices: MultiChoice<T>[];
|
|
73
89
|
minSelected?: number;
|
|
@@ -429,6 +445,8 @@ interface PromptSpec<R> {
|
|
|
429
445
|
warnLine?: () => string | null;
|
|
430
446
|
/** When set, ← resolves `BACK` so the caller can step backwards. */
|
|
431
447
|
allowBack?: boolean;
|
|
448
|
+
/** Called just before ← resolves, so a caller can keep in-progress state. */
|
|
449
|
+
onBack?: () => void;
|
|
432
450
|
footer: string;
|
|
433
451
|
/** Handle non-navigation keys. `{done}` finishes, `"redraw"` repaints. */
|
|
434
452
|
onKey: (key: readline.Key, cursor: number) => { done: R } | "redraw" | undefined;
|
|
@@ -520,6 +538,11 @@ function runPrompt<R>(p: PromptSpec<R>): Promise<R | null> {
|
|
|
520
538
|
} else if (key.name === "left" && p.allowBack) {
|
|
521
539
|
// Only when the caller opted in. A prompt with nowhere to go back TO
|
|
522
540
|
// must not appear to offer it.
|
|
541
|
+
//
|
|
542
|
+
// Reported BEFORE finishing, because `finish` collapses the prompt and
|
|
543
|
+
// resolves — after that the selection is gone and the caller is already
|
|
544
|
+
// running.
|
|
545
|
+
p.onBack?.();
|
|
523
546
|
finish(BACK as unknown as never);
|
|
524
547
|
} else if (key.name === "up") {
|
|
525
548
|
cursor = cursor > 0 ? cursor - 1 : choices.length - 1;
|
|
@@ -648,6 +671,11 @@ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null
|
|
|
648
671
|
},
|
|
649
672
|
warnLine: () => (warn ? c.warn(`Select at least ${minSelected}.`) : null),
|
|
650
673
|
allowBack: opts.allowBack,
|
|
674
|
+
// Reads the SAME `checked` array the prompt is driving, so what the caller
|
|
675
|
+
// learns is exactly what was on screen when ← was pressed.
|
|
676
|
+
onBack: opts.onBack
|
|
677
|
+
? () => opts.onBack?.(choices.filter((_, i) => checked[i]).map((ch) => ch.value))
|
|
678
|
+
: undefined,
|
|
651
679
|
footer:
|
|
652
680
|
opts.hint ??
|
|
653
681
|
(opts.allowBack
|