diff --git a/deno.jsonc b/deno.jsonc index b00ae7a..412804b 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -23,6 +23,7 @@ "tasks": { "fmt": "deno run -A npm:@biomejs/biome check --write --error-on-warnings", "fmt:check": "deno lint && deno check && deno run -A npm:@biomejs/biome check --error-on-warnings", + "kvctl": "deno run -A --env-file=.env packages/kvctl/kvctl.ts", "rust:check": "cargo check", "rust:clippy": "cargo clippy --all-targets -- -D warnings", "wasm:build": "wasm-pack build crates/wasm --target web --out-dir pkg --out-name wasm", @@ -33,6 +34,7 @@ "./crates/wasm", "./packages/web", "./packages/core", - "./packages/core/integration" + "./packages/core/integration", + "./packages/kvctl" ] } diff --git a/deno.lock b/deno.lock index ecfba18..d895489 100644 --- a/deno.lock +++ b/deno.lock @@ -6,7 +6,6 @@ "jsr:@cliffy/internal@1.2.1": "1.2.1", "jsr:@cliffy/table@1.2.1": "1.2.1", "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", - "jsr:@deno/kv-oauth@*": "0.11.0", "jsr:@deno/kv-oauth@0.11": "0.11.0", "jsr:@deno/loader@0.4": "0.4.0", "jsr:@deno/loader@~0.3.10": "0.3.14", @@ -68,17 +67,17 @@ "npm:@lexical/list@0.50": "0.50.0", "npm:@lexical/mark@0.50": "0.50.0", "npm:@lexical/markdown@0.50": "0.50.0", - "npm:@lexical/react@0.50": "0.50.0_react@19.3.0_react-dom@19.3.0__react@19.3.0_yjs@13.6.32", + "npm:@lexical/react@0.50": "0.50.0_react@19.3.0_react-dom@19.3.0_yjs@13.6.32", "npm:@lexical/rich-text@0.50": "0.50.0", "npm:@openrouter/agent@0.11": "0.11.0", "npm:@opentelemetry/api@^1.9.0": "1.9.1", - "npm:@preact/signals@^2.11.2": "2.11.2_preact@10.29.8__preact-render-to-string@6.7.0", - "npm:@preact/signals@^2.5.1": "2.11.2_preact@10.29.8__preact-render-to-string@6.7.0", - "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.8__preact-render-to-string@6.7.0_vite@7.3.6__@types+node@26.5.1", + "npm:@preact/signals@^2.11.2": "2.11.2_preact@10.29.8_preact-render-to-string@6.7.0", + "npm:@preact/signals@^2.5.1": "2.11.2_preact@10.29.8_preact-render-to-string@6.7.0", + "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.8_vite@7.3.6_preact-render-to-string@6.7.0", "npm:@remix-run/node-fetch-server@0.12": "0.12.0", "npm:@resvg/resvg-wasm@2.6.2": "2.6.2", "npm:@tailwindcss/typography@~0.5.20": "0.5.20_tailwindcss@4.3.3", - "npm:@tailwindcss/vite@^4.3.3": "4.3.3_vite@7.3.6__@types+node@26.5.1_@types+node@26.5.1", + "npm:@tailwindcss/vite@^4.3.3": "4.3.3_vite@7.3.6_@types+node@26.5.1", "npm:@types/babel__core@^7.20.5": "7.20.5", "npm:@types/gapi@^0.0.47": "0.0.47", "npm:@types/google.accounts@^0.0.18": "0.0.18", @@ -93,7 +92,7 @@ "npm:husky@^9.1.7": "9.1.7", "npm:jose@^6.2.12": "6.2.12", "npm:lexical@0.50": "0.50.0", - "npm:lucide-preact@^1.45.0": "1.46.0_preact@10.29.8__preact-render-to-string@6.7.0", + "npm:lucide-preact@^1.45.0": "1.46.0_preact@10.29.8_preact-render-to-string@6.7.0", "npm:marked@^18.0.12": "18.0.13", "npm:pino-pretty@^13.1.3": "13.1.3", "npm:pino@^10.3.1": "10.3.1", @@ -102,11 +101,11 @@ "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.7.0", "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.7.0", "npm:preact@^10.29.8": "10.29.8_preact-render-to-string@6.7.0", - "npm:rollup@^4.55.1": "4.63.2", + "npm:rollup@^4.55.1": "4.63.3", "npm:tailwindcss@^4.3.3": "4.3.3", "npm:vite@^7.1.4": "7.3.6_@types+node@26.5.1", "npm:vite@^7.3.6": "7.3.6_@types+node@26.5.1", - "npm:zod@^4.6.2": "4.6.4" + "npm:zod@^4.6.2": "4.6.5" }, "jsr": { "@cliffy/command@1.2.1": { @@ -971,7 +970,7 @@ "@floating-ui/utils" ] }, - "@floating-ui/react-dom@2.1.9_react@19.3.0_react-dom@19.3.0__react@19.3.0": { + "@floating-ui/react-dom@2.1.9_react@19.3.0_react-dom@19.3.0": { "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "dependencies": [ "@floating-ui/dom", @@ -979,7 +978,7 @@ "react-dom" ] }, - "@floating-ui/react@0.27.20_react@19.3.0_react-dom@19.3.0__react@19.3.0": { + "@floating-ui/react@0.27.20_react@19.3.0_react-dom@19.3.0": { "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==", "dependencies": [ "@floating-ui/react-dom", @@ -1080,7 +1079,7 @@ "lexical" ] }, - "@lexical/devtools-core@0.50.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": { + "@lexical/devtools-core@0.50.0_react@19.3.0_react-dom@19.3.0": { "integrity": "sha512-70m994RIZ5sdYjtGEHTo6nIOkUddaWVVl920WGWR0zqNblxYmFuDQAsMgz6nJmA9TNbCbLLufFi5RMPwnBVVOA==", "dependencies": [ "@lexical/html", @@ -1205,7 +1204,7 @@ "lexical" ] }, - "@lexical/react@0.50.0_react@19.3.0_react-dom@19.3.0__react@19.3.0_yjs@13.6.32": { + "@lexical/react@0.50.0_react@19.3.0_react-dom@19.3.0_yjs@13.6.32": { "integrity": "sha512-q9USUeO5tunqPmxDG9D11kydRts5jgXO1WJ62u86WXeHOGjMtSjvVwLY+PfQDhCn0aNYF7XvGaIM3Aa04UxF7A==", "dependencies": [ "@floating-ui/react", @@ -1319,7 +1318,7 @@ "@preact/signals-core@1.14.4": { "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==" }, - "@preact/signals@2.11.2_preact@10.29.8__preact-render-to-string@6.7.0": { + "@preact/signals@2.11.2_preact@10.29.8_preact-render-to-string@6.7.0": { "integrity": "sha512-rVTRTt/T0HIRgbugwS5FigbfF/kfdEFYFtiqxa+lbpqTajepqnR0firuAS2iNdHyejWB7Yc9p9QePkVNBtTAwg==", "dependencies": [ "@preact/signals-core", @@ -1332,7 +1331,7 @@ "@babel/core" ] }, - "@prefresh/core@1.5.11_preact@10.29.8__preact-render-to-string@6.7.0": { + "@prefresh/core@1.5.11_preact@10.29.8_preact-render-to-string@6.7.0": { "integrity": "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ==", "dependencies": [ "preact" @@ -1341,7 +1340,7 @@ "@prefresh/utils@1.2.1": { "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==" }, - "@prefresh/vite@2.4.12_preact@10.29.8__preact-render-to-string@6.7.0_vite@7.3.6__@types+node@26.5.1": { + "@prefresh/vite@2.4.12_preact@10.29.8_vite@7.3.6_preact-render-to-string@6.7.0": { "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", "dependencies": [ "@babel/core", @@ -1366,128 +1365,128 @@ "picomatch@2.3.2" ] }, - "@rollup/rollup-android-arm-eabi@4.63.2": { - "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "@rollup/rollup-android-arm-eabi@4.63.3": { + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", "os": ["android"], "cpu": ["arm"] }, - "@rollup/rollup-android-arm64@4.63.2": { - "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "@rollup/rollup-android-arm64@4.63.3": { + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", "os": ["android"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-arm64@4.63.2": { - "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "@rollup/rollup-darwin-arm64@4.63.3": { + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-x64@4.63.2": { - "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "@rollup/rollup-darwin-x64@4.63.3": { + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", "os": ["darwin"], "cpu": ["x64"] }, - "@rollup/rollup-freebsd-arm64@4.63.2": { - "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "@rollup/rollup-freebsd-arm64@4.63.3": { + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@rollup/rollup-freebsd-x64@4.63.2": { - "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "@rollup/rollup-freebsd-x64@4.63.3": { + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rollup/rollup-linux-arm-gnueabihf@4.63.2": { - "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "@rollup/rollup-linux-arm-gnueabihf@4.63.3": { + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm-musleabihf@4.63.2": { - "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "@rollup/rollup-linux-arm-musleabihf@4.63.3": { + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm64-gnu@4.63.2": { - "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "@rollup/rollup-linux-arm64-gnu@4.63.3": { + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-arm64-musl@4.63.2": { - "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "@rollup/rollup-linux-arm64-musl@4.63.3": { + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-loong64-gnu@4.63.2": { - "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "@rollup/rollup-linux-loong64-gnu@4.63.3": { + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", "os": ["linux"], "cpu": ["loong64"] }, - "@rollup/rollup-linux-loong64-musl@4.63.2": { - "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "@rollup/rollup-linux-loong64-musl@4.63.3": { + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", "os": ["linux"], "cpu": ["loong64"] }, - "@rollup/rollup-linux-ppc64-gnu@4.63.2": { - "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "@rollup/rollup-linux-ppc64-gnu@4.63.3": { + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rollup/rollup-linux-ppc64-musl@4.63.2": { - "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "@rollup/rollup-linux-ppc64-musl@4.63.3": { + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rollup/rollup-linux-riscv64-gnu@4.63.2": { - "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "@rollup/rollup-linux-riscv64-gnu@4.63.3": { + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-riscv64-musl@4.63.2": { - "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "@rollup/rollup-linux-riscv64-musl@4.63.3": { + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-s390x-gnu@4.63.2": { - "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "@rollup/rollup-linux-s390x-gnu@4.63.3": { + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", "os": ["linux"], "cpu": ["s390x"] }, - "@rollup/rollup-linux-x64-gnu@4.63.2": { - "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "@rollup/rollup-linux-x64-gnu@4.63.3": { + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-linux-x64-musl@4.63.2": { - "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "@rollup/rollup-linux-x64-musl@4.63.3": { + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-openbsd-x64@4.63.2": { - "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "@rollup/rollup-openbsd-x64@4.63.3": { + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", "os": ["openbsd"], "cpu": ["x64"] }, - "@rollup/rollup-openharmony-arm64@4.63.2": { - "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "@rollup/rollup-openharmony-arm64@4.63.3": { + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-arm64-msvc@4.63.2": { - "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "@rollup/rollup-win32-arm64-msvc@4.63.3": { + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", "os": ["win32"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-ia32-msvc@4.63.2": { - "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "@rollup/rollup-win32-ia32-msvc@4.63.3": { + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", "os": ["win32"], "cpu": ["ia32"] }, - "@rollup/rollup-win32-x64-gnu@4.63.2": { - "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "@rollup/rollup-win32-x64-gnu@4.63.3": { + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", "os": ["win32"], "cpu": ["x64"] }, - "@rollup/rollup-win32-x64-msvc@4.63.2": { - "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "@rollup/rollup-win32-x64-msvc@4.63.3": { + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", "os": ["win32"], "cpu": ["x64"] }, @@ -1586,7 +1585,7 @@ "tailwindcss" ] }, - "@tailwindcss/vite@4.3.3_vite@7.3.6__@types+node@26.5.1_@types+node@26.5.1": { + "@tailwindcss/vite@4.3.3_vite@7.3.6_@types+node@26.5.1": { "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dependencies": [ "@tailwindcss/node", @@ -1715,8 +1714,8 @@ "@types/trusted-types" ] }, - "electron-to-chromium@1.5.428": { - "integrity": "sha512-1JxbaFJj1bRKurj1uY3l4xxpU9kOUAUjcIgApj0qu1Pao5GhoIWI8iL0BeMYJ2njig1hBx0A7eKD9VGjH9wlHw==" + "electron-to-chromium@1.5.427": { + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==" }, "end-of-stream@1.4.5": { "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", @@ -1724,8 +1723,8 @@ "once" ] }, - "enhanced-resolve@5.25.0": { - "integrity": "sha512-ghq3mhs649mvbarTCAlZn2wRhbfHmzAFiKxoWA14B3VtqnxtZt+wz8BroKXU0tF3GiJsnUldjVncQGZ8qk4rdA==", + "enhanced-resolve@5.25.1": { + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", "dependencies": [ "graceful-fs", "tapable" @@ -2005,7 +2004,7 @@ "yallist" ] }, - "lucide-preact@1.46.0_preact@10.29.8__preact-render-to-string@6.7.0": { + "lucide-preact@1.46.0_preact@10.29.8_preact-render-to-string@6.7.0": { "integrity": "sha512-Ui1rD4iHykfMpSyeFYv8uyOGQnGDDNnSgt+fjP1/ZFarAiMMa8rwiVUK9TJTyREqH2l5WrTLBHCD08ZJDYHccw==", "dependencies": [ "preact" @@ -2162,8 +2161,8 @@ "real-require@1.0.0": { "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==" }, - "rollup@4.63.2": { - "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "rollup@4.63.3": { + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", "dependencies": [ "@types/estree" ], @@ -2300,8 +2299,8 @@ "lib0" ] }, - "zod@4.6.4": { - "integrity": "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA==" + "zod@4.6.5": { + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==" } }, "workspace": { @@ -2329,6 +2328,11 @@ "jsr:@std/assert@^1.0.19" ] }, + "packages/kvctl": { + "dependencies": [ + "jsr:@cliffy/command@^1.2.1" + ] + }, "packages/web": { "dependencies": [ "jsr:@cliffy/command@^1.2.1", diff --git a/packages/core/mod.ts b/packages/core/mod.ts index af462de..354e006 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -16,6 +16,7 @@ export { ReviewPassSchema, ToolNameSchema, } from "@/config/types.ts"; +export { pluralize } from "@/format.ts"; export { measure, measureAsync } from "@/measure.ts"; export { KvAdapter } from "@/persistence/kv_adapter.ts"; export { diff --git a/packages/core/src/format.ts b/packages/core/src/format.ts new file mode 100644 index 0000000..a11cc6f --- /dev/null +++ b/packages/core/src/format.ts @@ -0,0 +1,5 @@ +// Singular for exactly 1, plural otherwise. Pass `plural` for irregular +// forms ("entry" / "entries"). +export function pluralize(count: number, singular: string, plural?: string) { + return count === 1 ? singular : (plural ?? `${singular}s`); +} diff --git a/packages/core/src/format_test.ts b/packages/core/src/format_test.ts new file mode 100644 index 0000000..6084c61 --- /dev/null +++ b/packages/core/src/format_test.ts @@ -0,0 +1,14 @@ +import { assertEquals } from "@std/assert"; +import { pluralize } from "./format.ts"; + +Deno.test("pluralize -- singular for 1, plural otherwise", () => { + assertEquals(pluralize(0, "word"), "words"); + assertEquals(pluralize(1, "word"), "word"); + assertEquals(pluralize(2, "word"), "words"); + assertEquals(pluralize(1234, "word"), "words"); +}); + +Deno.test("pluralize -- irregular form", () => { + assertEquals(pluralize(1, "entry", "entries"), "entry"); + assertEquals(pluralize(3, "entry", "entries"), "entries"); +}); diff --git a/packages/kvctl/commands/explore.ts b/packages/kvctl/commands/explore.ts new file mode 100644 index 0000000..cbf3c04 --- /dev/null +++ b/packages/kvctl/commands/explore.ts @@ -0,0 +1,20 @@ +import { Command } from "@cliffy/command"; +import { pluralize } from "@essayist/core"; +import type { KvctlGlobals } from "@/globals.ts"; +import { withKv } from "@/kv.ts"; +import { pprint } from "@/utils/pprint.ts"; + +export const explore = new Command() + .description("List keys, optionally under a tuple prefix.") + .arguments("[prefix...:string]") + .action(({ target, local }, ...prefix: string[]) => + withKv({ target, local }, async ({ kv }) => { + let n = 0; + for await (const entry of kv.list({ prefix })) { + n++; + pprint([entry.key, entry.value]); + } + // footer goes to stderr so piped stdout stays pure JSON + console.error(`(${n} ${pluralize(n, "entry", "entries")})`); + }), + ); diff --git a/packages/kvctl/commands/grant-role.ts b/packages/kvctl/commands/grant-role.ts new file mode 100644 index 0000000..fdd3dfd --- /dev/null +++ b/packages/kvctl/commands/grant-role.ts @@ -0,0 +1,24 @@ +import { Command, EnumType } from "@cliffy/command"; +import { USER_ROLES } from "@essayist/core"; +import type { KvctlGlobals } from "@/globals.ts"; +import { withKv } from "@/kv.ts"; + +const ROLE = new EnumType([...USER_ROLES]); + +export const grantRole = new Command() + .description("Set a user's site-wide role.") + .type("role", ROLE) + .arguments(" ") + .action(({ target, local }, emailOrId: string, role: "admin" | "writer") => + withKv({ target, local }, async ({ workspaceStore }) => { + let user = await workspaceStore.getUserByEmail(emailOrId); + if (!user && /^[0-9a-f-]{36}$/i.test(emailOrId)) + user = await workspaceStore.getUser(emailOrId); + if (!user) { + console.error(`no user matching "${emailOrId}"`); + Deno.exit(1); + } + const updated = await workspaceStore.setUserRole(user.id, role); + console.log(`granted ${role} to ${updated?.email} (${updated?.id})`); + }), + ); diff --git a/packages/kvctl/commands/list-users.ts b/packages/kvctl/commands/list-users.ts new file mode 100644 index 0000000..1b3e3e8 --- /dev/null +++ b/packages/kvctl/commands/list-users.ts @@ -0,0 +1,20 @@ +import { Command } from "@cliffy/command"; +import type { User } from "@essayist/core"; +import type { KvctlGlobals } from "@/globals.ts"; +import { withKv } from "@/kv.ts"; + +export const listUsers = new Command() + .description("List users.") + .action(({ target, local }) => + withKv({ target, local }, async ({ kv }) => { + let n = 0; + for await (const entry of kv.list({ prefix: ["users"] })) { + const u = entry.value; + console.log( + `${u.id} ${u.email.padEnd(24)} role=${u.role ?? "writer"}`, + ); + n++; + } + console.log(`(${n} users)`); + }), + ); diff --git a/packages/kvctl/commands/seed-config.ts b/packages/kvctl/commands/seed-config.ts new file mode 100644 index 0000000..65ee9c8 --- /dev/null +++ b/packages/kvctl/commands/seed-config.ts @@ -0,0 +1,91 @@ +import { Command } from "@cliffy/command"; +import type { KvctlGlobals } from "@/globals.ts"; +import { withKv } from "@/kv.ts"; + +export const seedConfig = new Command() + .description("Seed default review config.") + .action(({ target, local }) => + withKv({ target, local }, async ({ config }) => { + const poolId = "free-pool"; + await config.saveModelPool({ + id: poolId, + name: "Free pool", + models: [ + "poolside/laguna-s-2.1:free", + "nvidia/nemotron-3.5-lightning:free", + ], + }); + + // Default prompts are generic placeholders. + const systemPromptKey = "system.reviewer"; + const instructionsPromptKey = "instructions.mark"; + const directivePromptKey = "directive.review"; + const prompts = [ + { + key: systemPromptKey, + body: "You are an experienced editor and writing teacher. You review the user's literary work and leave constructive, specific annotations. You never rewrite the work; you only read and mark it.", + }, + { + key: instructionsPromptKey, + body: "Read the relevant files, then place all annotations for a file in a single mark call, passing every mark in the marks array. Each mark must use one of the allowed labels and a concise, actionable comment.", + }, + { + key: directivePromptKey, + body: 'Review the file "{{file}}". Read it, then mark issues using the allowed labels.', + }, + ]; + for (const p of prompts) await config.savePrompt(p); + + const categories = [ + { + id: "thesis", + label: "thesis", + description: "Thesis and argument clarity", + color: "oklch(65% 0.4 260)", + }, + { + id: "evidence", + label: "evidence", + description: "Evidence and support", + color: "oklch(65% 0.4 130)", + }, + { + id: "structure", + label: "structure", + description: "Organization and flow", + color: "oklch(65% 0.4 90)", + }, + { + id: "tone", + label: "tone", + description: "Voice, tone, and register", + color: "oklch(65% 0.4 300)", + }, + { + id: "grammar", + label: "grammar", + description: "Grammar, mechanics, usage", + color: "oklch(65% 0.4 355)", + }, + ] as const; + for (const c of categories) await config.saveCategory(c); + + const reviewPassId = "essay-review"; + await config.saveReviewPass({ + id: reviewPassId, + name: "Essay review", + modelPoolId: poolId, + systemPromptKey, + directivePromptKey, + instructionsPromptKey, + enabledTools: ["read_file", "list_files", "grep", "mark"], + allowedCategoryIds: categories.map((c) => c.id), + maxRounds: 5, + }); + await config.setActiveReviewPass(reviewPassId); + + console.log( + `seeded default config: model pool '${poolId}', ${prompts.length} prompts, ${categories.length} categories, review pass '${reviewPassId}' (active)`, + ); + }), + ); diff --git a/packages/kvctl/commands/sync.ts b/packages/kvctl/commands/sync.ts new file mode 100644 index 0000000..f027e52 --- /dev/null +++ b/packages/kvctl/commands/sync.ts @@ -0,0 +1,62 @@ +import { Command, EnumType } from "@cliffy/command"; +import { ConfigStore, KvAdapter } from "@essayist/core"; +import { + FAMILIES, + FAMILY_ORDER, + type FamilyKey, + type SyncCtx, + warnBrokenRefs, +} from "@/families.ts"; +import type { KvctlGlobals } from "@/globals.ts"; +import { CATEGORIES_EPOCH, LOCAL_KV, resolveTarget } from "@/kv.ts"; + +const FAMILY = new EnumType([...FAMILY_ORDER, "all"]); + +export const sync = new Command() + .description( + "Copy config entities from a source KV into the target. Non-destructive unless --prune. Review passes are checked for dangling references after syncing.", + ) + .type("family", FAMILY) + .arguments("") + .option( + "--from ", + "Source KV path or URL. Defaults to the local playground KV.", + { default: LOCAL_KV }, + ) + .option("--prune", "Also delete target entries missing from the source.", { + default: false, + }) + .action(async ({ target, local, from, prune }, family) => { + const sourcePath = from ?? LOCAL_KV; + const targetPath = resolveTarget({ target, local }); + if (sourcePath === targetPath) { + console.error( + `source and target are both ${sourcePath}; nothing to sync`, + ); + Deno.exit(1); + } + const keys: FamilyKey[] = + family === "all" ? FAMILY_ORDER : [family as FamilyKey]; + const sourceKv = await Deno.openKv(sourcePath); + const targetKv = await Deno.openKv(targetPath); + try { + const ctx: SyncCtx = { + source: new ConfigStore(new KvAdapter(sourceKv)), + target: new ConfigStore(new KvAdapter(targetKv)), + kv: targetKv, + }; + for (const key of keys) { + const { line, changed } = await FAMILIES[key](ctx, prune); + console.log(line); + if (key === "categories" && changed) { + // A running instance watches this key; bump it so the new + // categories are picked up immediately. + await ctx.kv.set(CATEGORIES_EPOCH, Date.now()); + } + } + if (keys.includes("passes") || prune) await warnBrokenRefs(ctx); + } finally { + sourceKv.close(); + targetKv.close(); + } + }); diff --git a/packages/kvctl/commands/wipe.ts b/packages/kvctl/commands/wipe.ts new file mode 100644 index 0000000..9a263e4 --- /dev/null +++ b/packages/kvctl/commands/wipe.ts @@ -0,0 +1,17 @@ +import { Command } from "@cliffy/command"; +import type { KvctlGlobals } from "@/globals.ts"; +import { withKv } from "@/kv.ts"; + +export const wipe = new Command() + .description("Delete keys, optionally under a tuple prefix.") + .arguments("[prefix...:string]") + .action(({ target, local }, ...prefix: string[]) => + withKv({ target, local }, async ({ kv }) => { + let n = 0; + for await (const entry of kv.list({ prefix })) { + await kv.delete(entry.key); + n++; + } + console.log(`deleted ${n} keys`); + }), + ); diff --git a/packages/kvctl/deno.jsonc b/packages/kvctl/deno.jsonc new file mode 100644 index 0000000..bfd3719 --- /dev/null +++ b/packages/kvctl/deno.jsonc @@ -0,0 +1,13 @@ +{ + "exports": "./kvctl.ts", + "imports": { + "@/": "./", + "@cliffy/command": "jsr:@cliffy/command@^1.2.1", + "node:url": "node:url" + }, + "name": "@essayist/kvctl", + "tasks": { + "kvctl": "deno run -A --env-file=../../.env kvctl.ts" + }, + "version": "0.1.0" +} diff --git a/packages/kvctl/families.ts b/packages/kvctl/families.ts new file mode 100644 index 0000000..85e2239 --- /dev/null +++ b/packages/kvctl/families.ts @@ -0,0 +1,211 @@ +// Config families and sync logic for the kvctl sync command. + +import { + CategorySchema, + type ConfigStore, + type ModelPool, + ModelPoolSchema, + PromptSchema, + ReviewPassSchema, +} from "@essayist/core"; + +export type FamilyKey = "pools" | "prompts" | "categories" | "passes"; +export const FAMILY_ORDER: FamilyKey[] = [ + "pools", + "prompts", + "categories", + "passes", +]; + +export interface SyncCtx { + source: ConfigStore; + target: ConfigStore; + /** Target KV handle, for cache epoch bumps. */ + kv: Deno.Kv; +} + +/** Validate one entity against a zod schema; returns an error message or + * null. Structural so kvctl does not need a zod dependency of its own. */ +function check( + schema: { + safeParse(value: unknown): + | { success: true; data: T } + | { + success: false; + error: { issues: { message: string; path: PropertyKey[] }[] }; + }; + }, + label: string, +): (value: unknown) => string | null { + return (value) => { + const result = schema.safeParse(value); + if (result.success) return null; + const detail = result.error.issues + .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("; "); + return `${label}: ${detail}`; + }; +} + +interface CopyArgs { + name: string; + source: T[]; + target: T[]; + keyOf: (entity: T) => string; + check: (entity: T) => string | null; + save: (entity: T) => Promise; + remove: (id: string) => Promise; + prune: boolean; + /** Return a reason to keep a target-only id instead of pruning it. */ + keep?: (id: string) => string | null; +} + +/** Upsert source entries into the target by key, skipping byte-identical + * entries. Prune deletes target entries missing from the source, except + * those `keep` protects. */ +async function copyEntries({ + name, + source, + target, + keyOf, + check: validate, + save, + remove, + prune, + keep, +}: CopyArgs): Promise<{ line: string; changed: boolean }> { + const issues = source.map(validate).filter((s): s is string => s !== null); + if (issues.length > 0) { + throw new Error(`invalid ${name} in source:\n ${issues.join("\n ")}`); + } + const targetJson = new Map( + target.map((entity) => [keyOf(entity), JSON.stringify(entity)] as const), + ); + let added = 0; + let updated = 0; + let unchanged = 0; + for (const entity of source) { + const id = keyOf(entity); + const json = JSON.stringify(entity); + const prev = targetJson.get(id); + if (prev === json) { + unchanged++; + continue; + } + await save(entity); + if (prev === undefined) added++; + else updated++; + } + let pruned = 0; + const kept: string[] = []; + if (prune) { + const sourceIds = new Set(source.map(keyOf)); + for (const [id] of targetJson) { + if (sourceIds.has(id)) continue; + const reason = keep?.(id); + if (reason) { + kept.push(`${id} (${reason})`); + continue; + } + await remove(id); + pruned++; + } + } + const parts = [ + `${name}: ${source.length} in source, ${added} added, ${updated} updated, ${unchanged} unchanged`, + ]; + if (pruned > 0) parts.push(`pruned ${pruned}`); + if (kept.length > 0) parts.push(`kept ${kept.join(", ")}`); + return { line: parts.join("; "), changed: added + updated + pruned > 0 }; +} + +export const FAMILIES: Record< + FamilyKey, + (ctx: SyncCtx, prune: boolean) => Promise<{ line: string; changed: boolean }> +> = { + async pools(ctx, prune) { + return copyEntries({ + name: "model pools", + source: await ctx.source.listModelPools(), + target: await ctx.target.listModelPools(), + keyOf: (e) => e.id, + check: check(ModelPoolSchema, "model pool"), + save: (e: ModelPool) => ctx.target.saveModelPool(e), + remove: (id) => ctx.target.deleteModelPool(id), + prune, + }); + }, + async prompts(ctx, prune) { + return copyEntries({ + name: "prompts", + source: await ctx.source.listPrompts(), + target: await ctx.target.listPrompts(), + keyOf: (e) => e.key, + check: check(PromptSchema, "prompt"), + save: (e) => ctx.target.savePrompt(e), + remove: (id) => ctx.target.deletePrompt(id), + prune, + }); + }, + async categories(ctx, prune) { + return copyEntries({ + name: "categories", + source: await ctx.source.listCategories(), + target: await ctx.target.listCategories(), + keyOf: (e) => e.id, + check: check(CategorySchema, "category"), + save: (e) => ctx.target.saveCategory(e), + remove: (id) => ctx.target.deleteCategory(id), + prune, + }); + }, + async passes(ctx, prune) { + // Keep the pass currently active on the target even when pruning. + const activeId = await ctx.target.getActiveReviewPassId(); + return copyEntries({ + name: "review passes", + source: await ctx.source.listReviewPasses(), + target: await ctx.target.listReviewPasses(), + keyOf: (e) => e.id, + check: check(ReviewPassSchema, "review pass"), + save: (e) => ctx.target.saveReviewPass(e), + remove: (id) => ctx.target.deleteReviewPass(id), + prune, + keep: (id) => (id === activeId ? "active on target" : null), + }); + }, +}; + +/** Warn about target review passes referencing entities the target lacks. */ +export async function warnBrokenRefs(ctx: SyncCtx): Promise { + const [pools, prompts, categories, passes] = await Promise.all([ + ctx.target.listModelPools(), + ctx.target.listPrompts(), + ctx.target.listCategories(), + ctx.target.listReviewPasses(), + ]); + const poolIds = new Set(pools.map((p) => p.id)); + const promptKeys = new Set(prompts.map((p) => p.key)); + const categoryIds = new Set(categories.map((c) => c.id)); + for (const pass of passes) { + const missing: string[] = []; + if (!poolIds.has(pass.modelPoolId)) { + missing.push(`model pool "${pass.modelPoolId}"`); + } + for (const key of [ + pass.systemPromptKey, + pass.directivePromptKey, + ...(pass.instructionsPromptKey ? [pass.instructionsPromptKey] : []), + ]) { + if (!promptKeys.has(key)) missing.push(`prompt "${key}"`); + } + for (const id of pass.allowedCategoryIds) { + if (!categoryIds.has(id)) missing.push(`category "${id}"`); + } + if (missing.length > 0) { + console.error( + `warning: review pass "${pass.id}" references missing ${missing.join(", ")}`, + ); + } + } +} diff --git a/packages/kvctl/globals.ts b/packages/kvctl/globals.ts new file mode 100644 index 0000000..3087a61 --- /dev/null +++ b/packages/kvctl/globals.ts @@ -0,0 +1,6 @@ +// Options shared by all kvctl subcommands, declared on the root command. +export type KvctlGlobals = { + target?: string; + /** Only ever true or absent: --local is a presence flag. */ + local?: true; +}; diff --git a/packages/kvctl/kv.ts b/packages/kvctl/kv.ts new file mode 100644 index 0000000..c821b47 --- /dev/null +++ b/packages/kvctl/kv.ts @@ -0,0 +1,46 @@ +// KV helpers shared by kvctl commands. + +import { fileURLToPath } from "node:url"; +import { ConfigStore, KvAdapter, WorkspaceStore } from "@essayist/core"; +import type { KvctlGlobals } from "@/globals.ts"; + +// The local playground KV, the web dev server's KV. Resolved from this +// module so the default works from any working directory. +export const LOCAL_KV = fileURLToPath( + new URL("../web/local-kv.sqlite3", import.meta.url), +); + +// The categories cache on a running instance watches this key; a bump makes +// every isolate drop its cache instead of waiting out the TTL. +export const CATEGORIES_EPOCH: Deno.KvKey = ["cache_epoch", "categories"]; + +interface KvCtx { + kv: Deno.Kv; + workspaceStore: WorkspaceStore; + config: ConfigStore; +} + +export function resolveTarget(globals: KvctlGlobals): string { + return ( + globals.target ?? + (globals.local ? LOCAL_KV : Deno.env.get("REMOTE_URL")) ?? + LOCAL_KV + ); +} + +export async function withKv( + globals: KvctlGlobals, + fn: (ctx: KvCtx) => Promise, +): Promise { + const kv = await Deno.openKv(resolveTarget(globals)); + const adapter = new KvAdapter(kv); + try { + return await fn({ + kv, + workspaceStore: new WorkspaceStore(adapter), + config: new ConfigStore(adapter), + }); + } finally { + kv.close(); + } +} diff --git a/packages/kvctl/kvctl.ts b/packages/kvctl/kvctl.ts new file mode 100644 index 0000000..d87fbeb --- /dev/null +++ b/packages/kvctl/kvctl.ts @@ -0,0 +1,37 @@ +// KV management CLI for the Essayist web app. +// +// Usage: +// deno task kvctl [args...] [--target ] +// +// For a remote instance, set DENO_KV_ACCESS_TOKEN=ddo_... in .env (loaded via +// --env-file=.env by the kvctl task). Optionally set REMOTE_URL in .env to use +// it as the default target when --target is omitted; pass --local to ignore +// REMOTE_URL and use the local playground KV. Run `deno task kvctl --help` +// for full usage. + +import { Command } from "@cliffy/command"; +import { explore } from "@/commands/explore.ts"; +import { grantRole } from "@/commands/grant-role.ts"; +import { listUsers } from "@/commands/list-users.ts"; +import { seedConfig } from "@/commands/seed-config.ts"; +import { sync } from "@/commands/sync.ts"; +import { wipe } from "@/commands/wipe.ts"; + +await new Command() + .name("kvctl") + .description("KV management CLI for the Essayist web app.") + .option( + "-t, --target ", + "KV target (path or URL). Defaults to REMOTE_URL from .env, then local SQLite.", + { global: true }, + ) + .option("--local", "Use the local KV, overriding REMOTE_URL from .env.", { + global: true, + }) + .command("wipe", wipe) + .command("explore", explore) + .command("grant-role", grantRole) + .command("list-users", listUsers) + .command("seed-config", seedConfig) + .command("sync", sync) + .parse(Deno.args); diff --git a/packages/kvctl/utils/pprint.ts b/packages/kvctl/utils/pprint.ts new file mode 100644 index 0000000..de56f2e --- /dev/null +++ b/packages/kvctl/utils/pprint.ts @@ -0,0 +1,12 @@ +// colored inspect on a TTY; plain JSON when stdout is piped, one document +// per call, so jq can parse the stream +export function pprint(value: T): void { + const body = Deno.stdout.isTerminal() + ? Deno.inspect(value, { + colors: !Deno.env.has("NO_COLOR"), + sorted: true, + compact: true, + }) + : JSON.stringify(value, null, 2); + console.log(`${body}\n`); +} diff --git a/packages/web/components/highlights/ColorSwatch.tsx b/packages/web/components/highlights/ColorSwatch.tsx index 7c17859..e82626f 100644 --- a/packages/web/components/highlights/ColorSwatch.tsx +++ b/packages/web/components/highlights/ColorSwatch.tsx @@ -5,9 +5,8 @@ const EMPTY: ReadonlySet = new Set(); const SWATCH_W = 64; const SWATCH_H = 24; -// One color rendered in the four candidate mark styles: wavy, wavy-inner, -// band, band-inner. Shared by the admin swatches panel and the category -// form preview so both screens show the same renderings. +// One color in the four candidate mark styles: wavy, wavy-inner, band, +// band-inner. export function ColorSwatch({ color }: { color: string }) { const id = "swatch"; const rect: MarkRect = { diff --git a/packages/web/components/ui/Dialog.tsx b/packages/web/components/ui/Dialog.tsx index b088c05..624d5f0 100644 --- a/packages/web/components/ui/Dialog.tsx +++ b/packages/web/components/ui/Dialog.tsx @@ -6,7 +6,7 @@ import Panel from "@/components/ui/Panel.tsx"; interface DialogProps { open: Signal; children: ComponentChildren; - /** Optional side column rendered beside the panel, outside its frame. */ + /** Side column rendered beside the panel, outside its frame. */ aside?: ComponentChildren; } @@ -18,8 +18,8 @@ interface DialogProps { * renders the 0fr state before transitioning to 1fr. * On close: panelOpen flips to false (by close, useEffect, or consumer), * Panel animates closed, then onSettled calls dialog.close(). - * Clicks landing outside the panel (striped frame or backdrop, or the - * aside) close via the Panel's built-in click-outside. */ + * Clicks outside the panel (backdrop or aside) close via the Panel's + * click-outside. */ export default function Dialog({ open, children, aside }: DialogProps) { const ref = useRef(null); const panelOpen = useSignal(false); diff --git a/packages/web/deno.jsonc b/packages/web/deno.jsonc index 796a6ad..739d160 100644 --- a/packages/web/deno.jsonc +++ b/packages/web/deno.jsonc @@ -81,7 +81,6 @@ "dev": "DENO_ENV=development DENO_KV_PATH=./local-kv.sqlite3 vite | pino-pretty --colorize --translateTime SYS:standard --ignore pid,hostname", "favicon": "deno run -A favicon/generate.ts", "favicon:check": "deno run -A favicon/generate.ts --check", - "kvctl": "deno run -A --env-file=.env kvctl.ts", "start": "deno serve -A _fresh/server.js", "update": "deno run -A -r jsr:@fresh/update ." }, diff --git a/packages/web/islands/MarkSwatches.tsx b/packages/web/islands/MarkSwatches.tsx index c3ab34b..56692ef 100644 --- a/packages/web/islands/MarkSwatches.tsx +++ b/packages/web/islands/MarkSwatches.tsx @@ -1,22 +1,15 @@ import { ColorSwatch } from "@/components/highlights/ColorSwatch.tsx"; -import { FALLBACK_COLOR } from "@/editor/markColors.ts"; -import { getCategories } from "@/signals/categories.ts"; +import { swatchEntries } from "@/signals/categoryPreview.ts"; export default function MarkSwatches() { - const entries = [ - ...getCategories().list.value.map((c) => ({ - key: c.id, - label: c.label, - color: c.color ?? FALLBACK_COLOR, - })), - { key: "__ink", label: "(unlabeled)", color: FALLBACK_COLOR }, - ]; return ( -
- {entries.map(({ key, label, color }) => ( -
- {label} - +
+ {swatchEntries.value.map(({ key, label, color }) => ( +
+
{label}
+
+ +
))}
diff --git a/packages/web/islands/admin/AdminConfig.tsx b/packages/web/islands/admin/AdminConfig.tsx index f0f4434..2dad4dc 100644 --- a/packages/web/islands/admin/AdminConfig.tsx +++ b/packages/web/islands/admin/AdminConfig.tsx @@ -6,6 +6,7 @@ import { EntityCard, NewButton } from "@/components/ui/EntityCard.tsx"; import { Field } from "@/components/ui/EntityRows.tsx"; import Tabs, { type TabItem } from "@/components/ui/Tabs.tsx"; import WaveBars from "@/components/ui/WaveBars.tsx"; +import { CategoriesExport } from "@/islands/admin/CategoriesExport.tsx"; import EntityDialog from "@/islands/admin/EntityDialog.tsx"; import { CategoryRow } from "@/islands/admin/rows/CategoryRow.tsx"; import { ModelPoolRow } from "@/islands/admin/rows/ModelPoolRow.tsx"; @@ -166,7 +167,12 @@ export default function AdminConfig() { ); break; case "categories": - side = ; + side = ( +
+ + +
+ ); body = (
+
+
export
+ +
+
+
+
+            {json}
+          
+
+
+
+ ); +} diff --git a/packages/web/islands/admin/forms/CategoryForm.tsx b/packages/web/islands/admin/forms/CategoryForm.tsx index 6f8adf8..f79a6ec 100644 --- a/packages/web/islands/admin/forms/CategoryForm.tsx +++ b/packages/web/islands/admin/forms/CategoryForm.tsx @@ -1,6 +1,6 @@ import type { Category } from "@essayist/core"; import type { Signal } from "@preact/signals"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { ColorSwatch } from "@/components/highlights/ColorSwatch.tsx"; import { FormShell } from "@/components/ui/forms/FormShell.tsx"; import { TextareaRow } from "@/components/ui/forms/TextareaRow.tsx"; @@ -9,6 +9,7 @@ import { CheckboxIcon } from "@/components/ui/icons.tsx"; import Slider from "@/components/ui/Slider.tsx"; import { FALLBACK_COLOR } from "@/editor/markColors.ts"; import { type CategoryInput, getAdminConfig } from "@/signals/admin.ts"; +import { categoryPreview } from "@/signals/categoryPreview.ts"; // Canonical stored form: oklch(% ). const OKLCH_RE = /^oklch\(([\d.]+)%\s+([\d.]+)\s+([\d.]+)\)$/; @@ -42,6 +43,15 @@ export function CategoryForm({ const canonical = `oklch(${Math.round(l)}% ${Number(c.toFixed(2))} ${Math.round(h)})`; const previewColor = colorEnabled ? canonical : FALLBACK_COLOR; + // Render-phase open read subscribes the form; the effect syncs while open + // and clears the palette on close. + const isOpen = open.value; + useEffect(() => { + categoryPreview.value = isOpen + ? { id: entity?.id ?? null, label: label.trim(), color: previewColor } + : null; + }); + async function handleSubmit(e: Event) { e.preventDefault(); if (!label.trim()) return setError("Label is required."); diff --git a/packages/web/kvctl.ts b/packages/web/kvctl.ts deleted file mode 100644 index 34a0b68..0000000 --- a/packages/web/kvctl.ts +++ /dev/null @@ -1,207 +0,0 @@ -// KV management CLI for the Essayist web app. -// -// Usage: -// deno task kvctl [args...] [--target ] -// -// For a remote instance, set DENO_KV_ACCESS_TOKEN=ddo_... in .env (loaded via -// --env-file=.env by the kvctl task). Optionally set REMOTE_URL in .env to use -// it as the default target when --target is omitted. Run `deno task kvctl help` -// for full usage. - -import { Command, EnumType } from "@cliffy/command"; -import { - ConfigStore, - KvAdapter, - USER_ROLES, - type User, - WorkspaceStore, -} from "@essayist/core"; -import { pluralize } from "@/utils/format.ts"; - -const ROLE = new EnumType([...USER_ROLES]); - -interface KvCtx { - kv: Deno.Kv; - workspaceStore: WorkspaceStore; - config: ConfigStore; -} - -async function withKv( - target: string | undefined, - fn: (ctx: KvCtx) => Promise, -): Promise { - const resolved = target ?? Deno.env.get("REMOTE_URL") ?? "./local-kv.sqlite3"; - const kv = await Deno.openKv(resolved); - const adapter = new KvAdapter(kv); - try { - return await fn({ - kv, - workspaceStore: new WorkspaceStore(adapter), - config: new ConfigStore(adapter), - }); - } finally { - kv.close(); - } -} - -// colored inspect on a TTY; plain JSON when stdout is piped, one document -// per entry, so jq can parse the stream -function printEntry(entry: Deno.KvEntry): void { - const tuple = [entry.key, entry.value]; - const body = Deno.stdout.isTerminal() - ? Deno.inspect(tuple, { - colors: !Deno.env.has("NO_COLOR"), - sorted: true, - compact: true, - }) - : JSON.stringify(tuple, null, 2); - console.log(`${body}\n`); -} - -await new Command() - .name("kvctl") - .description("KV management CLI for the Essayist web app.") - .option( - "-t, --target ", - "KV target (path or URL). Defaults to REMOTE_URL from .env, then local SQLite.", - { global: true }, - ) - .command("wipe", "Delete every key.") - .action(({ target }) => - withKv(target, async ({ kv }) => { - let n = 0; - for await (const entry of kv.list({ prefix: [] })) { - await kv.delete(entry.key); - n++; - } - console.log(`deleted ${n} keys`); - }), - ) - .command("explore", "List keys, optionally under a tuple prefix.") - .arguments("[prefix...:string]") - .action(({ target }, ...prefix: string[]) => - withKv(target, async ({ kv }) => { - let n = 0; - for await (const entry of kv.list({ prefix })) { - n++; - printEntry(entry); - } - // footer goes to stderr so piped stdout stays pure JSON - console.error(`(${n} ${pluralize(n, "entry", "entries")})`); - }), - ) - .command("grant-role", "Set a user's site-wide role.") - .type("role", ROLE) - .arguments(" ") - .action(({ target }, emailOrId: string, role: "admin" | "writer") => - withKv(target, async ({ workspaceStore }) => { - let user = await workspaceStore.getUserByEmail(emailOrId); - if (!user && /^[0-9a-f-]{36}$/i.test(emailOrId)) - user = await workspaceStore.getUser(emailOrId); - if (!user) { - console.error(`no user matching "${emailOrId}"`); - Deno.exit(1); - } - const updated = await workspaceStore.setUserRole(user.id, role); - console.log(`granted ${role} to ${updated?.email} (${updated?.id})`); - }), - ) - .command("list-users", "List users.") - .action(({ target }) => - withKv(target, async ({ kv }) => { - let n = 0; - for await (const entry of kv.list({ prefix: ["users"] })) { - const u = entry.value; - console.log(`${u.id} ${u.email} role=${u.role ?? "writer"}`); - n++; - } - console.log(`(${n} users)`); - }), - ) - .command("seed-config", "Seed default review config.") - .action(({ target }) => - withKv(target, async ({ config }) => { - const poolId = "free-pool"; - await config.saveModelPool({ - id: poolId, - name: "Free pool", - models: [ - "poolside/laguna-s-2.1:free", - "nvidia/nemotron-3.5-lightning:free", - ], - }); - - // Default prompts are generic placeholders. - const systemPromptKey = "system.reviewer"; - const instructionsPromptKey = "instructions.mark"; - const directivePromptKey = "directive.review"; - const prompts = [ - { - key: systemPromptKey, - body: "You are an experienced editor and writing teacher. You review the user's literary work and leave constructive, specific annotations. You never rewrite the work; you only read and mark it.", - }, - { - key: instructionsPromptKey, - body: "Read the relevant files, then place all annotations for a file in a single mark call, passing every mark in the marks array. Each mark must use one of the allowed labels and a concise, actionable comment.", - }, - { - key: directivePromptKey, - body: 'Review the file "{{file}}". Read it, then mark issues using the allowed labels.', - }, - ]; - for (const p of prompts) await config.savePrompt(p); - - const categories = [ - { - id: "thesis", - label: "thesis", - description: "Thesis and argument clarity", - color: "oklch(65% 0.4 260)", - }, - { - id: "evidence", - label: "evidence", - description: "Evidence and support", - color: "oklch(65% 0.4 130)", - }, - { - id: "structure", - label: "structure", - description: "Organization and flow", - color: "oklch(65% 0.4 90)", - }, - { - id: "tone", - label: "tone", - description: "Voice, tone, and register", - color: "oklch(65% 0.4 300)", - }, - { - id: "grammar", - label: "grammar", - description: "Grammar, mechanics, usage", - color: "oklch(65% 0.4 355)", - }, - ] as const; - for (const c of categories) await config.saveCategory(c); - - const reviewPassId = "essay-review"; - await config.saveReviewPass({ - id: reviewPassId, - name: "Essay review", - modelPoolId: poolId, - systemPromptKey, - directivePromptKey, - instructionsPromptKey, - enabledTools: ["read_file", "list_files", "grep", "mark"], - allowedCategoryIds: categories.map((c) => c.id), - maxRounds: 5, - }); - await config.setActiveReviewPass(reviewPassId); - - console.log( - `seeded default config: model pool '${poolId}', ${prompts.length} prompts, ${categories.length} categories, review pass '${reviewPassId}' (active)`, - ); - }), - ) - .parse(Deno.args); diff --git a/packages/web/signals/categoryPreview.ts b/packages/web/signals/categoryPreview.ts new file mode 100644 index 0000000..ee8959c --- /dev/null +++ b/packages/web/signals/categoryPreview.ts @@ -0,0 +1,44 @@ +import { computed, signal } from "@preact/signals"; +import { FALLBACK_COLOR } from "@/editor/markColors.ts"; +import { getCategories } from "@/signals/categories.ts"; + +export interface CategoryPreview { + /** Category id when editing, null while creating. */ + id: string | null; + label: string; + color: string; +} + +/** Live values from the open category form; null when closed. */ +export const categoryPreview = signal(null); + +export interface SwatchEntry { + key: string; + label: string; + color: string; +} + +/** Palette rows: one per category (edited row shows the live color), a + * "(new)" row while creating, then the fallback row for unlabeled marks. */ +export const swatchEntries = computed(() => { + const preview = categoryPreview.value; + const entries: SwatchEntry[] = getCategories().list.value.map( + ({ id, label, color }) => ({ + key: id, + label, + color: + preview && id === preview.id + ? preview.color + : (color ?? FALLBACK_COLOR), + }), + ); + if (preview && preview.id === null) { + entries.push({ + key: "__new", + label: preview.label || "(new)", + color: preview.color, + }); + } + entries.push({ key: "__ink", label: "(unlabeled)", color: FALLBACK_COLOR }); + return entries; +}); diff --git a/packages/web/utils/format.ts b/packages/web/utils/format.ts index 6a2b2ff..1243c7f 100644 --- a/packages/web/utils/format.ts +++ b/packages/web/utils/format.ts @@ -1,11 +1,6 @@ +import { pluralize } from "@essayist/core"; import { formatDistance } from "date-fns"; -// Singular for exactly 1, plural otherwise. Pass `plural` for irregular -// forms ("entry" / "entries"). -export function pluralize(count: number, singular: string, plural?: string) { - return count === 1 ? singular : (plural ?? `${singular}s`); -} - // Locale-formatted count with a pluralized noun: "1 word", "1,234 words". export function formatCount(count: number, singular: string, plural?: string) { return `${count.toLocaleString()} ${pluralize(count, singular, plural)}`; diff --git a/packages/web/utils/format_test.ts b/packages/web/utils/format_test.ts index 6188b03..5f72ab3 100644 --- a/packages/web/utils/format_test.ts +++ b/packages/web/utils/format_test.ts @@ -1,22 +1,5 @@ import { assertEquals, assertStringIncludes } from "@std/assert"; -import { - formatCount, - formatDateTime, - formatRelativeTime, - pluralize, -} from "./format.ts"; - -Deno.test("pluralize -- singular for 1, plural otherwise", () => { - assertEquals(pluralize(0, "word"), "words"); - assertEquals(pluralize(1, "word"), "word"); - assertEquals(pluralize(2, "word"), "words"); - assertEquals(pluralize(1234, "word"), "words"); -}); - -Deno.test("pluralize -- irregular form", () => { - assertEquals(pluralize(1, "entry", "entries"), "entry"); - assertEquals(pluralize(3, "entry", "entries"), "entries"); -}); +import { formatCount, formatDateTime, formatRelativeTime } from "./format.ts"; Deno.test("formatCount -- locale number with pluralized noun", () => { assertEquals(formatCount(1, "word"), "1 word");