failproofai 0.0.10-beta.8 → 0.0.10
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 +7 -7
- 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 +4 -4
- 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 +4 -4
- package/.next/standalone/.next/server/app/_not-found/page/next-font-manifest.json +6 -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 +2 -2
- 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 -30
- package/.next/standalone/.next/server/app/_not-found.rsc +21 -26
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +21 -26
- 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 +10 -11
- 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 +2 -2
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +21 -21
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +21 -21
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -11
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +3 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +4 -4
- package/.next/standalone/.next/server/app/page/next-font-manifest.json +6 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +2 -2
- 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 +4 -4
- package/.next/standalone/.next/server/app/policies/page/next-font-manifest.json +6 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +4 -4
- package/.next/standalone/.next/server/app/project/[name]/page/next-font-manifest.json +6 -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 +3 -3
- 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 +4 -4
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/next-font-manifest.json +6 -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 +3 -3
- 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 +4 -4
- package/.next/standalone/.next/server/app/projects/page/next-font-manifest.json +6 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +3 -3
- 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/chunks/{[root-of-the-server]__0fjhqi9._.js → [root-of-the-server]__044xt9.._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d_ob4n._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0fe7_q_._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0370~qj._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0mup1hi._.js → [root-of-the-server]__0609ezh._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07_-mkc._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e9o9ri._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0og6yo7._.js → [root-of-the-server]__0l6swv1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0logebz._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mi5ejy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0joez.y._.js → [root-of-the-server]__0podumr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rkxer-._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rl2kwi._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0vg0uey._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ye1w50._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +32 -7
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__10._f0s._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0xb8ngh._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +7 -7
- package/.next/standalone/.next/server/next-font-manifest.js +1 -1
- package/.next/standalone/.next/server/next-font-manifest.json +21 -2
- package/.next/standalone/.next/server/pages/404.html +1 -30
- 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 +9 -9
- package/.next/standalone/.next/static/chunks/{0k2yq8zevk9bl.js → 0j171xiqge4rv.js} +1 -1
- package/.next/standalone/.next/static/chunks/0kqar56yl~41o.js +6 -0
- package/.next/standalone/.next/static/chunks/{07i9r0t6n4cpy.js → 0lt8ko3lw.5yt.js} +1 -1
- package/.next/standalone/.next/static/chunks/0ml1.ck_5t36i.js +1 -0
- package/.next/standalone/.next/static/chunks/{0km4.rc8uvt_t.js → 0pkl..xgo-qox.js} +1 -1
- package/.next/standalone/.next/static/chunks/{12simlrcfk3g2.js → 0rnqmir4cd5p9.js} +2 -2
- package/.next/standalone/.next/static/chunks/{0bi2r.m~yokoo.js → 0w6fzf.07a24u.js} +1 -1
- package/.next/standalone/.next/static/chunks/0xbo5nl6w4lka.js +1 -0
- package/.next/standalone/.next/static/chunks/12l2t63hkyo2q.js +1 -0
- package/.next/standalone/.next/static/chunks/{0tyw4u3~2isbh.js → 12pt~2f.c1sha.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0by8zx1no6kt4.js → 14lii11wmo450.js} +1 -1
- package/.next/standalone/.next/static/chunks/179yytvmam0ug.js +1 -0
- package/.next/standalone/.next/static/chunks/17rm86uz2nd5a.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-0o7k.hakttp4k.js → turbopack-05z7a19q43zfq.js} +1 -1
- package/.next/standalone/.next/static/media/4fa387ec64143e14-s.0q3udbd2bu5yp.woff2 +0 -0
- package/.next/standalone/.next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2 +0 -0
- package/.next/standalone/.next/static/media/bbc41e54d2fcbd21-s.0gw~uztddq1df.woff2 +0 -0
- package/.next/standalone/.opencode/plugins/failproofai.mjs +75 -15
- package/.next/standalone/app/actions/get-hooks-config.ts +25 -1
- package/.next/standalone/app/components/cli-badge.tsx +1 -1
- package/.next/standalone/app/globals.css +68 -111
- package/.next/standalone/app/layout.tsx +16 -56
- package/.next/standalone/app/policies/hooks-client.tsx +228 -44
- package/.next/standalone/components/navbar.tsx +16 -15
- package/.next/standalone/components/ui/button.tsx +4 -4
- package/.next/standalone/lib/gemini-projects.ts +64 -24
- package/.next/standalone/lib/opencode-projects.ts +9 -7
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/pi-extension/index.ts +113 -12
- package/.next/standalone/readme-arch-hq.gif +0 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +54 -241
- package/dist/cli.mjs +195 -75
- package/lib/gemini-projects.ts +64 -24
- package/lib/opencode-projects.ts +9 -7
- package/package.json +2 -2
- package/pi-extension/index.ts +113 -12
- package/scripts/launch.ts +6 -22
- package/scripts/parse-script-args.ts +1 -11
- package/scripts/translate-docs/config.ts +0 -1
- package/src/hooks/handler.ts +63 -6
- package/src/hooks/integrations.ts +31 -6
- package/src/hooks/policy-evaluator.ts +34 -2
- package/src/hooks/types.ts +52 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01hj~sd._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r6nu-._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04dywib._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06sb2gn._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09jpajs._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0jm6jnh._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t2k4c5._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u1i~9~._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/_02_tcps._.js +0 -32
- package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/_11rg2a_._.js +0 -3
- package/.next/standalone/.next/static/chunks/0gq8kvc3blri~.js +0 -1
- package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +0 -1
- package/.next/standalone/.next/static/chunks/0s41ggdsb2alw.js +0 -3
- package/.next/standalone/.next/static/chunks/0t_7i~pqwbcww.js +0 -6
- package/.next/standalone/.next/static/chunks/0xr8w5io1-kb9.css +0 -1
- package/.next/standalone/.next/static/chunks/164g0yuhpb2pi.js +0 -1
- package/.next/standalone/components/logo.tsx +0 -36
- package/.next/standalone/components/theme-toggle.tsx +0 -37
- package/.next/standalone/contexts/ThemeContext.tsx +0 -69
- package/.next/standalone/failproofai-hq.gif +0 -0
- package/.next/standalone/public/exospheresmall-dark.png +0 -0
- package/.next/standalone/public/exospheresmall.png +0 -0
- /package/.next/standalone/.next/static/{0PSH56_4bbPBaHiyPkthl → dAuQps6jUwCz9X1Q5FFOO}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{0PSH56_4bbPBaHiyPkthl → dAuQps6jUwCz9X1Q5FFOO}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{0PSH56_4bbPBaHiyPkthl → dAuQps6jUwCz9X1Q5FFOO}/_ssgManifest.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useCallback, useRef, useTransition } from "react";
|
|
3
|
+
import React, { useState, useEffect, useCallback, useMemo, useRef, useTransition } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import Link from "next/link";
|
|
6
6
|
import {
|
|
@@ -21,6 +21,7 @@ import { getHookActivityAction, searchHookActivityAction } from "@/app/actions/g
|
|
|
21
21
|
import type { HookActivityPayload } from "@/app/actions/get-hook-activity";
|
|
22
22
|
import { getHooksConfigAction } from "@/app/actions/get-hooks-config";
|
|
23
23
|
import type { HooksConfigPayload, PolicyInfo, CustomPolicyInfo } from "@/app/actions/get-hooks-config";
|
|
24
|
+
import type { IntegrationType } from "@/src/hooks/types";
|
|
24
25
|
import { togglePolicyAction } from "@/app/actions/update-hooks-config";
|
|
25
26
|
import { installHooksWebAction, removeHooksWebAction } from "@/app/actions/install-hooks-web";
|
|
26
27
|
import { updatePolicyParamsAction } from "@/app/actions/update-policy-params";
|
|
@@ -950,23 +951,66 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
|
|
|
950
951
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
951
952
|
const [hooksWarning, setHooksWarning] = useState<string | null>(null);
|
|
952
953
|
const [configuringPolicy, setConfiguringPolicy] = useState<PolicyInfo | null>(null);
|
|
954
|
+
const [checkedClis, setCheckedClis] = useState<Set<IntegrationType>>(() => new Set());
|
|
955
|
+
const cliCheckboxesInitializedRef = useRef(false);
|
|
953
956
|
|
|
954
957
|
const reload = useCallback(async () => {
|
|
955
958
|
try {
|
|
956
959
|
const data = await getHooksConfigAction();
|
|
957
960
|
setConfig(data);
|
|
958
|
-
onHooksInstallChange?.(data.
|
|
961
|
+
onHooksInstallChange?.(data.clis.some((c) => c.installed));
|
|
959
962
|
} catch {
|
|
960
963
|
// Non-critical
|
|
961
964
|
}
|
|
962
965
|
}, [onHooksInstallChange]);
|
|
963
966
|
|
|
964
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
965
967
|
useEffect(() => { reload(); }, [reload]);
|
|
966
968
|
|
|
969
|
+
// Sync the checkbox set with payload. On first load only, pre-check
|
|
970
|
+
// detected-but-not-installed CLIs so a fresh user lands ready for one-click
|
|
971
|
+
// install. After that, sync strictly to `installed` so unchecking a still-
|
|
972
|
+
// detected CLI and clicking Apply doesn't re-tick the box on reload.
|
|
973
|
+
useEffect(() => {
|
|
974
|
+
if (!config) return;
|
|
975
|
+
if (!cliCheckboxesInitializedRef.current) {
|
|
976
|
+
cliCheckboxesInitializedRef.current = true;
|
|
977
|
+
setCheckedClis(new Set(config.clis.filter((c) => c.installed || c.detected).map((c) => c.id)));
|
|
978
|
+
} else {
|
|
979
|
+
setCheckedClis(new Set(config.clis.filter((c) => c.installed).map((c) => c.id)));
|
|
980
|
+
}
|
|
981
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
982
|
+
}, [config?.clis]);
|
|
983
|
+
|
|
984
|
+
const installedCliSet = useMemo(
|
|
985
|
+
() => new Set((config?.clis ?? []).filter((c) => c.installed).map((c) => c.id)),
|
|
986
|
+
[config?.clis],
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
const pendingChanges = useMemo(() => {
|
|
990
|
+
const toInstall: IntegrationType[] = [];
|
|
991
|
+
const toRemove: IntegrationType[] = [];
|
|
992
|
+
for (const cli of config?.clis ?? []) {
|
|
993
|
+
const isChecked = checkedClis.has(cli.id);
|
|
994
|
+
if (isChecked && !installedCliSet.has(cli.id)) toInstall.push(cli.id);
|
|
995
|
+
if (!isChecked && installedCliSet.has(cli.id)) toRemove.push(cli.id);
|
|
996
|
+
}
|
|
997
|
+
return { toInstall, toRemove };
|
|
998
|
+
}, [config?.clis, checkedClis, installedCliSet]);
|
|
999
|
+
|
|
1000
|
+
const hasPendingChanges = pendingChanges.toInstall.length > 0 || pendingChanges.toRemove.length > 0;
|
|
1001
|
+
|
|
1002
|
+
const toggleCli = (id: IntegrationType) => {
|
|
1003
|
+
setCheckedClis((prev) => {
|
|
1004
|
+
const next = new Set(prev);
|
|
1005
|
+
if (next.has(id)) next.delete(id);
|
|
1006
|
+
else next.add(id);
|
|
1007
|
+
return next;
|
|
1008
|
+
});
|
|
1009
|
+
};
|
|
1010
|
+
|
|
967
1011
|
const handleToggle = (name: string, currentlyEnabled: boolean) => {
|
|
968
1012
|
if (!config) return;
|
|
969
|
-
const installed = config.
|
|
1013
|
+
const installed = config.clis.some((c) => c.installed);
|
|
970
1014
|
if (!installed) {
|
|
971
1015
|
setHooksWarning("Policies are not installed. Install policies to continue.");
|
|
972
1016
|
return;
|
|
@@ -995,26 +1039,39 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
|
|
|
995
1039
|
});
|
|
996
1040
|
};
|
|
997
1041
|
|
|
998
|
-
const
|
|
1042
|
+
const handleApply = () => {
|
|
1043
|
+
const { toInstall, toRemove } = pendingChanges;
|
|
1044
|
+
if (toInstall.length === 0 && toRemove.length === 0) return;
|
|
999
1045
|
startTransition(async () => {
|
|
1000
1046
|
try {
|
|
1001
1047
|
setActionError(null);
|
|
1002
|
-
await installHooksWebAction("user");
|
|
1003
|
-
await
|
|
1048
|
+
if (toInstall.length > 0) await installHooksWebAction("user", toInstall);
|
|
1049
|
+
if (toRemove.length > 0) await removeHooksWebAction("user", toRemove);
|
|
1004
1050
|
} catch (e) {
|
|
1005
|
-
setActionError(e instanceof Error ? e.message : "Failed to
|
|
1051
|
+
setActionError(e instanceof Error ? e.message : "Failed to apply changes.");
|
|
1052
|
+
} finally {
|
|
1053
|
+
// Always resync so a partial-success batch (install OK, remove failed)
|
|
1054
|
+
// doesn't leave the UI showing stale install state on the next click.
|
|
1055
|
+
await reload();
|
|
1006
1056
|
}
|
|
1007
1057
|
});
|
|
1008
1058
|
};
|
|
1009
1059
|
|
|
1010
|
-
const
|
|
1060
|
+
const handleReinstall = () => {
|
|
1061
|
+
// Reinstall acts on the intersection of checked × installed. Detected-but-
|
|
1062
|
+
// not-installed CLIs are pre-checked as a one-click install hint, so a raw
|
|
1063
|
+
// Array.from(checkedClis) would silently install brand-new CLIs from the
|
|
1064
|
+
// Reinstall button. Use Apply for first-time installs.
|
|
1065
|
+
const targets = Array.from(installedCliSet).filter((id) => checkedClis.has(id));
|
|
1066
|
+
if (targets.length === 0) return;
|
|
1011
1067
|
startTransition(async () => {
|
|
1012
1068
|
try {
|
|
1013
1069
|
setActionError(null);
|
|
1014
|
-
await
|
|
1015
|
-
await reload();
|
|
1070
|
+
await installHooksWebAction("user", targets);
|
|
1016
1071
|
} catch (e) {
|
|
1017
|
-
setActionError(e instanceof Error ? e.message : "Failed to
|
|
1072
|
+
setActionError(e instanceof Error ? e.message : "Failed to reinstall.");
|
|
1073
|
+
} finally {
|
|
1074
|
+
await reload();
|
|
1018
1075
|
}
|
|
1019
1076
|
});
|
|
1020
1077
|
};
|
|
@@ -1042,7 +1099,9 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
|
|
|
1042
1099
|
);
|
|
1043
1100
|
}
|
|
1044
1101
|
|
|
1045
|
-
const installed = config.
|
|
1102
|
+
const installed = config.clis.some((c) => c.installed);
|
|
1103
|
+
const installedCount = installedCliSet.size;
|
|
1104
|
+
const checkedCount = checkedClis.size;
|
|
1046
1105
|
|
|
1047
1106
|
// Group policies by category
|
|
1048
1107
|
const categories = Array.from(new Set(config.policies.map((p) => p.category)));
|
|
@@ -1057,45 +1116,170 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
|
|
|
1057
1116
|
/>
|
|
1058
1117
|
)}
|
|
1059
1118
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
|
1060
|
-
{/*
|
|
1061
|
-
<div className="flex items-center justify-between
|
|
1062
|
-
<div className="flex items-center gap-
|
|
1063
|
-
<
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1119
|
+
{/* CLI control panel — header */}
|
|
1120
|
+
<div className="flex items-center justify-between gap-4 px-5 py-3.5 border-b border-border/60 bg-muted/10">
|
|
1121
|
+
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
|
1122
|
+
<div className="flex items-center gap-2">
|
|
1123
|
+
<span
|
|
1124
|
+
className={`h-2 w-2 rounded-full transition-shadow ${
|
|
1125
|
+
installed
|
|
1126
|
+
? "bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.6)]"
|
|
1127
|
+
: "bg-muted-foreground/40"
|
|
1128
|
+
}`}
|
|
1129
|
+
aria-hidden
|
|
1130
|
+
/>
|
|
1131
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] font-mono text-foreground/90">
|
|
1132
|
+
Integrations
|
|
1133
|
+
</span>
|
|
1134
|
+
</div>
|
|
1135
|
+
<span className="text-[0.65rem] font-mono text-muted-foreground/40">·</span>
|
|
1136
|
+
<div className="text-[0.65rem] uppercase tracking-[0.2em] font-mono text-muted-foreground">
|
|
1137
|
+
<span className="text-foreground tabular-nums">
|
|
1138
|
+
{installedCount.toString().padStart(2, "0")}
|
|
1072
1139
|
</span>
|
|
1140
|
+
<span className="opacity-50"> / 0{config.clis.length} active</span>
|
|
1141
|
+
</div>
|
|
1142
|
+
{hasPendingChanges && (
|
|
1143
|
+
<>
|
|
1144
|
+
<span className="text-[0.65rem] font-mono text-muted-foreground/40">·</span>
|
|
1145
|
+
<span className="inline-flex items-center gap-1.5 text-[0.65rem] uppercase tracking-[0.2em] font-mono text-pink-400">
|
|
1146
|
+
<span className="h-1 w-1 rounded-full bg-pink-400 animate-pulse" />
|
|
1147
|
+
{pendingChanges.toInstall.length + pendingChanges.toRemove.length} pending
|
|
1148
|
+
</span>
|
|
1149
|
+
</>
|
|
1073
1150
|
)}
|
|
1074
1151
|
</div>
|
|
1075
1152
|
<div className="flex items-center gap-2 shrink-0">
|
|
1076
|
-
{installed && (
|
|
1077
|
-
<Button
|
|
1078
|
-
variant="outline"
|
|
1079
|
-
size="sm"
|
|
1080
|
-
onClick={handleRemove}
|
|
1081
|
-
disabled={isPending}
|
|
1082
|
-
className="text-xs h-7 px-3"
|
|
1083
|
-
>
|
|
1084
|
-
Remove
|
|
1085
|
-
</Button>
|
|
1086
|
-
)}
|
|
1087
1153
|
<Button
|
|
1088
|
-
variant=
|
|
1154
|
+
variant="outline"
|
|
1089
1155
|
size="sm"
|
|
1090
|
-
onClick={
|
|
1091
|
-
disabled={isPending}
|
|
1092
|
-
className="text-xs h-7 px-3"
|
|
1156
|
+
onClick={handleReinstall}
|
|
1157
|
+
disabled={isPending || checkedCount === 0}
|
|
1158
|
+
className="text-xs h-7 px-3 font-mono"
|
|
1093
1159
|
>
|
|
1094
|
-
|
|
1160
|
+
Reinstall
|
|
1161
|
+
</Button>
|
|
1162
|
+
<Button
|
|
1163
|
+
variant="default"
|
|
1164
|
+
size="sm"
|
|
1165
|
+
onClick={handleApply}
|
|
1166
|
+
disabled={isPending || !hasPendingChanges}
|
|
1167
|
+
className="text-xs h-7 px-3 font-mono"
|
|
1168
|
+
>
|
|
1169
|
+
Apply changes
|
|
1095
1170
|
</Button>
|
|
1096
1171
|
</div>
|
|
1097
1172
|
</div>
|
|
1098
1173
|
|
|
1174
|
+
{/* CLI rows */}
|
|
1175
|
+
<div className="divide-y divide-border/30 border-b border-border/60">
|
|
1176
|
+
{config.clis.map((cli, i) => {
|
|
1177
|
+
const isChecked = checkedClis.has(cli.id);
|
|
1178
|
+
const isInstalled = installedCliSet.has(cli.id);
|
|
1179
|
+
const willChange = isChecked !== isInstalled;
|
|
1180
|
+
const badge = getCliBadgeClasses(cli.id);
|
|
1181
|
+
const accentClass =
|
|
1182
|
+
badge.split(" ").find((c) => c.startsWith("text-")) ?? "text-foreground";
|
|
1183
|
+
|
|
1184
|
+
return (
|
|
1185
|
+
<label
|
|
1186
|
+
key={cli.id}
|
|
1187
|
+
className={`group relative flex items-center gap-3 pl-6 pr-5 py-3 cursor-pointer transition-colors hover:bg-muted/15 ${
|
|
1188
|
+
willChange ? "bg-pink-500/[0.04]" : ""
|
|
1189
|
+
}`}
|
|
1190
|
+
>
|
|
1191
|
+
{/* Brand-colored accent rail */}
|
|
1192
|
+
<div
|
|
1193
|
+
className={`absolute left-0 top-0 bottom-0 w-[3px] ${accentClass} bg-current transition-opacity ${
|
|
1194
|
+
isChecked ? "opacity-100" : "opacity-25 group-hover:opacity-60"
|
|
1195
|
+
}`}
|
|
1196
|
+
aria-hidden
|
|
1197
|
+
/>
|
|
1198
|
+
|
|
1199
|
+
{/* Slot index — 01..07 */}
|
|
1200
|
+
<div className="font-mono text-[0.65rem] text-muted-foreground/40 tabular-nums w-6 select-none">
|
|
1201
|
+
{(i + 1).toString().padStart(2, "0")}
|
|
1202
|
+
</div>
|
|
1203
|
+
|
|
1204
|
+
{/* Custom checkbox */}
|
|
1205
|
+
<div className="relative shrink-0">
|
|
1206
|
+
<input
|
|
1207
|
+
type="checkbox"
|
|
1208
|
+
checked={isChecked}
|
|
1209
|
+
onChange={() => toggleCli(cli.id)}
|
|
1210
|
+
disabled={isPending}
|
|
1211
|
+
className="sr-only peer"
|
|
1212
|
+
aria-label={`Toggle ${cli.label}`}
|
|
1213
|
+
/>
|
|
1214
|
+
<div
|
|
1215
|
+
className={`h-[18px] w-[18px] rounded-sm border transition-all peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40 ${
|
|
1216
|
+
isChecked
|
|
1217
|
+
? "bg-primary border-primary"
|
|
1218
|
+
: "bg-background/50 border-border group-hover:border-foreground/40"
|
|
1219
|
+
}`}
|
|
1220
|
+
>
|
|
1221
|
+
{isChecked && (
|
|
1222
|
+
<svg
|
|
1223
|
+
viewBox="0 0 18 18"
|
|
1224
|
+
className="h-full w-full text-primary-foreground"
|
|
1225
|
+
fill="none"
|
|
1226
|
+
aria-hidden
|
|
1227
|
+
>
|
|
1228
|
+
<path
|
|
1229
|
+
d="M4 9.5L7.5 12.5L14 6"
|
|
1230
|
+
stroke="currentColor"
|
|
1231
|
+
strokeWidth="2"
|
|
1232
|
+
strokeLinecap="round"
|
|
1233
|
+
strokeLinejoin="round"
|
|
1234
|
+
/>
|
|
1235
|
+
</svg>
|
|
1236
|
+
)}
|
|
1237
|
+
</div>
|
|
1238
|
+
</div>
|
|
1239
|
+
|
|
1240
|
+
{/* CLI label */}
|
|
1241
|
+
<div className="min-w-[150px] shrink-0">
|
|
1242
|
+
<div className="text-sm font-medium text-foreground">{cli.label}</div>
|
|
1243
|
+
</div>
|
|
1244
|
+
|
|
1245
|
+
{/* Status + diff pills */}
|
|
1246
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
1247
|
+
{isInstalled ? (
|
|
1248
|
+
<span
|
|
1249
|
+
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-sm text-[0.6rem] uppercase tracking-[0.15em] font-mono border ${badge}`}
|
|
1250
|
+
>
|
|
1251
|
+
<span className="h-1 w-1 rounded-full bg-current shadow-[0_0_4px_currentColor]" />
|
|
1252
|
+
Active
|
|
1253
|
+
</span>
|
|
1254
|
+
) : cli.detected ? (
|
|
1255
|
+
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-sm text-[0.6rem] uppercase tracking-[0.15em] font-mono border border-border/60 text-muted-foreground/80 bg-muted/20">
|
|
1256
|
+
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
|
|
1257
|
+
Detected
|
|
1258
|
+
</span>
|
|
1259
|
+
) : (
|
|
1260
|
+
<span className="inline-flex items-center px-2 py-0.5 rounded-sm text-[0.6rem] uppercase tracking-[0.15em] font-mono border border-border/30 text-muted-foreground/40">
|
|
1261
|
+
Inactive
|
|
1262
|
+
</span>
|
|
1263
|
+
)}
|
|
1264
|
+
{willChange && (
|
|
1265
|
+
<span className="inline-flex items-center px-2 py-0.5 rounded-sm text-[0.6rem] uppercase tracking-[0.15em] font-mono border border-pink-500/40 text-pink-400 bg-pink-500/10">
|
|
1266
|
+
{isChecked ? "+ install" : "− remove"}
|
|
1267
|
+
</span>
|
|
1268
|
+
)}
|
|
1269
|
+
</div>
|
|
1270
|
+
|
|
1271
|
+
{/* Settings path — right-aligned, monospace */}
|
|
1272
|
+
<div
|
|
1273
|
+
className="ml-auto hidden lg:block text-[0.7rem] font-mono text-muted-foreground/50 truncate max-w-[420px] group-hover:text-muted-foreground/80 transition-colors"
|
|
1274
|
+
title={cli.settingsPath}
|
|
1275
|
+
>
|
|
1276
|
+
{cli.settingsPath}
|
|
1277
|
+
</div>
|
|
1278
|
+
</label>
|
|
1279
|
+
);
|
|
1280
|
+
})}
|
|
1281
|
+
</div>
|
|
1282
|
+
|
|
1099
1283
|
{/* Policy summary */}
|
|
1100
1284
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/40 bg-muted/5">
|
|
1101
1285
|
<span className="text-xs text-muted-foreground">
|
|
@@ -1106,7 +1290,7 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
|
|
|
1106
1290
|
</span>
|
|
1107
1291
|
{installed && (
|
|
1108
1292
|
<span className="text-[0.65rem] text-muted-foreground/60">
|
|
1109
|
-
· active
|
|
1293
|
+
· shared across {installedCount} active CLI{installedCount === 1 ? "" : "s"}
|
|
1110
1294
|
</span>
|
|
1111
1295
|
)}
|
|
1112
1296
|
</div>
|
|
@@ -1120,7 +1304,7 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
|
|
|
1120
1304
|
<ErrorToast
|
|
1121
1305
|
message={hooksWarning}
|
|
1122
1306
|
onDismiss={() => setHooksWarning(null)}
|
|
1123
|
-
onInstall={
|
|
1307
|
+
onInstall={handleApply}
|
|
1124
1308
|
isPending={isPending}
|
|
1125
1309
|
/>
|
|
1126
1310
|
)}
|
|
@@ -1306,7 +1490,7 @@ export default function HooksClient({ initialTab = "activity" }: { initialTab?:
|
|
|
1306
1490
|
useEffect(() => {
|
|
1307
1491
|
getHooksConfigAction()
|
|
1308
1492
|
.then((cfg) => {
|
|
1309
|
-
setHooksInstalled(cfg.
|
|
1493
|
+
setHooksInstalled(cfg.clis.some((c) => c.installed));
|
|
1310
1494
|
setPolicyCounts({
|
|
1311
1495
|
enabled: cfg.enabledPolicies.length,
|
|
1312
1496
|
total: cfg.policies.length + (cfg.customPolicies?.length ?? 0),
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
/** Top navigation bar
|
|
1
|
+
/** Top navigation bar — wordmark, primary nav, refresh + reach-developers controls. */
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
4
|
import React from "react";
|
|
5
5
|
import Link from "next/link";
|
|
6
6
|
import { usePathname } from "next/navigation";
|
|
7
7
|
import { FolderOpen, Shield } from "lucide-react";
|
|
8
|
-
import { Logo } from "@/components/logo";
|
|
9
|
-
import { ThemeToggle } from "@/components/theme-toggle";
|
|
10
8
|
import { ReachDevelopers } from "@/components/reach-developers";
|
|
11
9
|
import { RefreshButton } from "@/app/components/refresh-button";
|
|
12
10
|
|
|
@@ -15,11 +13,13 @@ const NAV_LINKS = [
|
|
|
15
13
|
{ href: "/projects", label: "Projects", icon: FolderOpen },
|
|
16
14
|
];
|
|
17
15
|
|
|
16
|
+
const WORDMARK_SRC = "https://d2wq11aau0arks.cloudfront.net/failproof/logo-wordmark.png";
|
|
17
|
+
|
|
18
18
|
export const Navbar: React.FC<{ disabledPages?: string[] }> = ({ disabledPages = [] }) => {
|
|
19
19
|
const pathname = usePathname();
|
|
20
20
|
|
|
21
21
|
return (
|
|
22
|
-
<header className="relative z-50 border-b border-border bg-card/
|
|
22
|
+
<header className="relative z-50 border-b border-border bg-card/60 backdrop-blur supports-[backdrop-filter]:bg-card/60">
|
|
23
23
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
24
24
|
<div className="flex items-center justify-between h-16">
|
|
25
25
|
<div className="flex items-center gap-3">
|
|
@@ -27,15 +27,19 @@ export const Navbar: React.FC<{ disabledPages?: string[] }> = ({ disabledPages =
|
|
|
27
27
|
href="https://github.com/exospherehost/failproofai"
|
|
28
28
|
target="_blank"
|
|
29
29
|
rel="noopener noreferrer"
|
|
30
|
-
className="flex items-center
|
|
30
|
+
className="flex items-center hover:opacity-80 transition-opacity"
|
|
31
|
+
aria-label="failproof ai · GitHub"
|
|
31
32
|
>
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
34
|
+
<img
|
|
35
|
+
src={WORDMARK_SRC}
|
|
36
|
+
alt="failproof ai"
|
|
37
|
+
style={{ height: 24, width: "auto" }}
|
|
38
|
+
className="select-none"
|
|
39
|
+
/>
|
|
36
40
|
</a>
|
|
37
41
|
{process.env.NEXT_PUBLIC_APP_VERSION && (
|
|
38
|
-
<span className="text-[0.6rem]
|
|
42
|
+
<span className="font-mono text-[0.6rem] leading-none text-muted-foreground/70 border border-border/60 rounded px-1.5 py-0.5 select-none tracking-wider uppercase">
|
|
39
43
|
v{process.env.NEXT_PUBLIC_APP_VERSION}
|
|
40
44
|
</span>
|
|
41
45
|
)}
|
|
@@ -63,10 +67,8 @@ export const Navbar: React.FC<{ disabledPages?: string[] }> = ({ disabledPages =
|
|
|
63
67
|
<Icon className={`w-4 h-4 ${active ? "text-primary" : ""}`} />
|
|
64
68
|
{label}
|
|
65
69
|
<span
|
|
66
|
-
className={`absolute inset-x-1 bottom-0 h-[2px]
|
|
67
|
-
active
|
|
68
|
-
? "bg-primary"
|
|
69
|
-
: "bg-transparent group-hover:bg-muted"
|
|
70
|
+
className={`absolute inset-x-1 bottom-0 h-[2px] transition-all ${
|
|
71
|
+
active ? "bg-primary" : "bg-transparent"
|
|
70
72
|
}`}
|
|
71
73
|
/>
|
|
72
74
|
</Link>
|
|
@@ -78,7 +80,6 @@ export const Navbar: React.FC<{ disabledPages?: string[] }> = ({ disabledPages =
|
|
|
78
80
|
<RefreshButton />
|
|
79
81
|
<div className="w-px h-6 bg-border mx-1" />
|
|
80
82
|
<ReachDevelopers />
|
|
81
|
-
<ThemeToggle />
|
|
82
83
|
</div>
|
|
83
84
|
</div>
|
|
84
85
|
</div>
|
|
@@ -16,12 +16,12 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
16
16
|
return (
|
|
17
17
|
<button
|
|
18
18
|
className={cn(
|
|
19
|
-
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-
|
|
19
|
+
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-[background-color,color,box-shadow,border-color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none",
|
|
20
20
|
{
|
|
21
|
-
"bg-primary text-primary-foreground hover:bg-primary/90":
|
|
21
|
+
"bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-[0_0_0_1px_var(--primary)]":
|
|
22
22
|
variant === "default",
|
|
23
|
-
"
|
|
24
|
-
"border border-border bg-
|
|
23
|
+
"text-foreground hover:bg-muted": variant === "ghost",
|
|
24
|
+
"border border-border bg-transparent hover:border-primary/60 hover:bg-card":
|
|
25
25
|
variant === "outline",
|
|
26
26
|
"h-10 py-2 px-4": size === "default",
|
|
27
27
|
"h-10 w-10": size === "icon",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* missing `~/.gemini/` returns `[]`, malformed JSONL falls open without
|
|
22
22
|
* surfacing the session.
|
|
23
23
|
*/
|
|
24
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
24
|
+
import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import type { ProjectFolder, SessionFile } from "./projects";
|
|
@@ -34,6 +34,13 @@ import { logWarn } from "./logger";
|
|
|
34
34
|
/** Filename pattern for a Gemini session JSONL:
|
|
35
35
|
* `session-<ISO-timestamp-with-dashes>-<8-hex-uuid-prefix>.jsonl`. */
|
|
36
36
|
const SESSION_FILE_RE = /^session-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2})-([0-9a-f]{8})\.jsonl$/i;
|
|
37
|
+
/** Full UUID — the filename only embeds the first 8 hex chars; the rest is on
|
|
38
|
+
* the JSONL metadata header line. The session detail route requires a full
|
|
39
|
+
* UUID, so links built from the truncated filename prefix 404. */
|
|
40
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
41
|
+
/** Metadata header sits on line 1 and is well under 1 KB; 4 KB covers it
|
|
42
|
+
* comfortably without slurping a multi-MB transcript. */
|
|
43
|
+
const FIRST_LINE_CHUNK_BYTES = 4 * 1024;
|
|
37
44
|
|
|
38
45
|
/** Override for tests. Defaults to the live Gemini session-state root. */
|
|
39
46
|
function getGeminiTmpRoot(): string {
|
|
@@ -46,6 +53,10 @@ interface GeminiSessionMeta {
|
|
|
46
53
|
sessionFilename: string;
|
|
47
54
|
cwd: string;
|
|
48
55
|
fileMtime: Date;
|
|
56
|
+
/** Full UUID parsed from the JSONL metadata header (line 1). Undefined when
|
|
57
|
+
* the header is missing, malformed, or carries a non-UUID `sessionId`;
|
|
58
|
+
* callers fall through to rendering an un-linked row. */
|
|
59
|
+
sessionId?: string;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
async function safeReaddir(dir: string) {
|
|
@@ -64,6 +75,40 @@ async function statMtime(path: string): Promise<Date | null> {
|
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
/** Read the first newline-delimited line of `filePath` without slurping the
|
|
79
|
+
* rest. Mirrors `lib/codex-projects.ts`'s readFirstLine; the Gemini metadata
|
|
80
|
+
* header is always on line 1. */
|
|
81
|
+
async function readFirstLine(filePath: string): Promise<string | null> {
|
|
82
|
+
let fh: Awaited<ReturnType<typeof open>> | null = null;
|
|
83
|
+
try {
|
|
84
|
+
fh = await open(filePath, "r");
|
|
85
|
+
const buf = Buffer.alloc(FIRST_LINE_CHUNK_BYTES);
|
|
86
|
+
const { bytesRead } = await fh.read(buf, 0, FIRST_LINE_CHUNK_BYTES, 0);
|
|
87
|
+
if (bytesRead === 0) return null;
|
|
88
|
+
const slice = buf.subarray(0, bytesRead);
|
|
89
|
+
const nl = slice.indexOf(0x0a); // '\n'
|
|
90
|
+
const end = nl === -1 ? bytesRead : nl;
|
|
91
|
+
return slice.subarray(0, end).toString("utf-8");
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
} finally {
|
|
95
|
+
if (fh) await fh.close().catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Extract a full-UUID `sessionId` from a JSONL metadata header line. Returns
|
|
100
|
+
* undefined on parse failure, missing field, or a non-UUID value. */
|
|
101
|
+
function extractFullSessionId(line: string | null): string | undefined {
|
|
102
|
+
if (!line) return undefined;
|
|
103
|
+
try {
|
|
104
|
+
const meta = JSON.parse(line) as { sessionId?: unknown };
|
|
105
|
+
if (typeof meta.sessionId !== "string") return undefined;
|
|
106
|
+
return UUID_RE.test(meta.sessionId) ? meta.sessionId : undefined;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
/** Read `.project_root` to recover the absolute cwd for a basename folder.
|
|
68
113
|
* Returns null if missing or empty (caller treats the folder as un-mappable). */
|
|
69
114
|
async function readProjectRoot(projectDir: string): Promise<string | null> {
|
|
@@ -101,7 +146,8 @@ async function scanGeminiSessions(): Promise<GeminiSessionMeta[]> {
|
|
|
101
146
|
const filePath = join(chatsDir, f.name);
|
|
102
147
|
const mtime = await statMtime(filePath);
|
|
103
148
|
if (!mtime) continue;
|
|
104
|
-
|
|
149
|
+
const sessionId = extractFullSessionId(await readFirstLine(filePath));
|
|
150
|
+
out.push({ filePath, sessionFilename: f.name, cwd, fileMtime: mtime, sessionId });
|
|
105
151
|
}
|
|
106
152
|
}),
|
|
107
153
|
16,
|
|
@@ -140,17 +186,14 @@ export async function getGeminiProjects(): Promise<ProjectFolder[]> {
|
|
|
140
186
|
export async function getGeminiSessionsForCwd(cwd: string): Promise<SessionFile[]> {
|
|
141
187
|
const sessions = await scanGeminiSessions();
|
|
142
188
|
const matches = sessions.filter((s) => s.cwd === cwd);
|
|
143
|
-
const files: SessionFile[] = matches.map((s) => {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
cli: "gemini",
|
|
152
|
-
};
|
|
153
|
-
});
|
|
189
|
+
const files: SessionFile[] = matches.map((s) => ({
|
|
190
|
+
name: s.sessionFilename,
|
|
191
|
+
path: s.filePath,
|
|
192
|
+
lastModified: s.fileMtime,
|
|
193
|
+
lastModifiedFormatted: formatDate(s.fileMtime),
|
|
194
|
+
sessionId: s.sessionId,
|
|
195
|
+
cli: "gemini",
|
|
196
|
+
}));
|
|
154
197
|
files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
155
198
|
return files;
|
|
156
199
|
}
|
|
@@ -180,17 +223,14 @@ export async function getGeminiSessionsByEncodedName(name: string): Promise<Gemi
|
|
|
180
223
|
if (uniqueCwds.length !== 1) {
|
|
181
224
|
return { cwd: null, sessions: [] };
|
|
182
225
|
}
|
|
183
|
-
const sessions = matches.map((s) => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
cli: "gemini" as const,
|
|
192
|
-
};
|
|
193
|
-
});
|
|
226
|
+
const sessions = matches.map((s) => ({
|
|
227
|
+
name: s.sessionFilename,
|
|
228
|
+
path: s.filePath,
|
|
229
|
+
lastModified: s.fileMtime,
|
|
230
|
+
lastModifiedFormatted: formatDate(s.fileMtime),
|
|
231
|
+
sessionId: s.sessionId,
|
|
232
|
+
cli: "gemini" as const,
|
|
233
|
+
}));
|
|
194
234
|
sessions.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
195
235
|
return { cwd: uniqueCwds[0], sessions };
|
|
196
236
|
}
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
* https://opencode.ai/docs/plugins/ (plugin model context)
|
|
23
23
|
*/
|
|
24
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
-
import { basename } from "node:path";
|
|
26
25
|
import { encodeFolderName } from "./paths";
|
|
27
26
|
import type { ProjectFolder, SessionFile } from "./projects";
|
|
28
27
|
import { runtimeCache } from "./runtime-cache";
|
|
@@ -91,10 +90,11 @@ function readProjectRows(): OpenCodeProjectRow[] | null {
|
|
|
91
90
|
|
|
92
91
|
/**
|
|
93
92
|
* Group sessions by `project_id` and produce one ProjectFolder per project.
|
|
94
|
-
* The folder name
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
93
|
+
* The folder name is `encodeFolderName(worktree)` (matches every other CLI's
|
|
94
|
+
* URL-slug encoding so the dashboard's `/project/[name]` route resolves), or
|
|
95
|
+
* the project_id when no worktree is recorded. `lastModified` is the max
|
|
96
|
+
* session `time_updated` for that project (or the project's own time_updated
|
|
97
|
+
* if no sessions exist yet).
|
|
98
98
|
*/
|
|
99
99
|
export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
|
|
100
100
|
const sessions = readSessionRows();
|
|
@@ -122,13 +122,15 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
|
|
|
122
122
|
|
|
123
123
|
// Emit one ProjectFolder per project that has at least one session OR a
|
|
124
124
|
// project row (covers projects opencode knows about but hasn't run yet).
|
|
125
|
+
// `name` is the dashboard's URL slug — must be `encodeFolderName(cwd)` to
|
|
126
|
+
// match every other CLI (and the resolver in `getOpenCodeSessionsByEncodedName`).
|
|
125
127
|
const seen = new Set<string>();
|
|
126
128
|
const out: ProjectFolder[] = [];
|
|
127
129
|
for (const [projectId, group] of groups) {
|
|
128
130
|
seen.add(projectId);
|
|
129
131
|
const proj = projectMap.get(projectId);
|
|
130
132
|
const worktree = proj?.worktree ?? group.rows[0]?.directory ?? null;
|
|
131
|
-
const name =
|
|
133
|
+
const name = worktree ? encodeFolderName(worktree) : projectId;
|
|
132
134
|
const path = worktree ?? "";
|
|
133
135
|
const lastModified = new Date(Math.max(group.latest, proj?.time_updated ?? 0));
|
|
134
136
|
out.push({
|
|
@@ -143,7 +145,7 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
|
|
|
143
145
|
for (const p of projects ?? []) {
|
|
144
146
|
if (seen.has(p.id)) continue;
|
|
145
147
|
const worktree = p.worktree ?? "";
|
|
146
|
-
const name =
|
|
148
|
+
const name = worktree ? encodeFolderName(worktree) : p.id;
|
|
147
149
|
const lastModified = new Date(p.time_updated);
|
|
148
150
|
out.push({
|
|
149
151
|
name,
|