From 05aac396aaa746a40422d334e15aafc04451d787 Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Tue, 15 Sep 2026 18:00:05 +0100 Subject: [PATCH 01/10] feat(web): live palette preview while tuning category colors --- .../web/components/highlights/ColorSwatch.tsx | 5 +-- packages/web/components/ui/Dialog.tsx | 6 +-- packages/web/islands/MarkSwatches.tsx | 13 +----- .../web/islands/admin/forms/CategoryForm.tsx | 12 ++++- packages/web/signals/categoryPreview.ts | 44 +++++++++++++++++++ 5 files changed, 62 insertions(+), 18 deletions(-) create mode 100644 packages/web/signals/categoryPreview.ts 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/islands/MarkSwatches.tsx b/packages/web/islands/MarkSwatches.tsx index c3ab34b..7268173 100644 --- a/packages/web/islands/MarkSwatches.tsx +++ b/packages/web/islands/MarkSwatches.tsx @@ -1,19 +1,10 @@ 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 }) => ( + {swatchEntries.value.map(({ key, label, color }) => (
{label} 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/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; +}); From 25be130bdaf30abe15af3b9bbd438980d886159f Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Tue, 15 Sep 2026 18:00:10 +0100 Subject: [PATCH 02/10] chore: normalize deno.lock peer resolution --- deno.lock | 151 +++++++++++++++++++++++++++--------------------------- 1 file changed, 75 insertions(+), 76 deletions(-) diff --git a/deno.lock b/deno.lock index ecfba18..8fd064f 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": { From cb8863ed99734797f2f60ca988bd18c82b717687 Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Tue, 15 Sep 2026 18:37:32 +0100 Subject: [PATCH 03/10] feat(web): ruled swatch and export cards in categories aside --- packages/web/islands/MarkSwatches.tsx | 10 ++++--- packages/web/islands/admin/AdminConfig.tsx | 8 +++++- .../web/islands/admin/CategoriesExport.tsx | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 packages/web/islands/admin/CategoriesExport.tsx diff --git a/packages/web/islands/MarkSwatches.tsx b/packages/web/islands/MarkSwatches.tsx index 7268173..56692ef 100644 --- a/packages/web/islands/MarkSwatches.tsx +++ b/packages/web/islands/MarkSwatches.tsx @@ -3,11 +3,13 @@ import { swatchEntries } from "@/signals/categoryPreview.ts"; export default function MarkSwatches() { return ( -
+
{swatchEntries.value.map(({ key, label, color }) => ( -
- {label} - +
+
{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}
+          
+
+
+
+ ); +} From 23ca8095a697ef8b652ccc5ae8d3d55f240b1c13 Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Tue, 15 Sep 2026 18:37:33 +0100 Subject: [PATCH 04/10] feat(kvctl): sync config entities from local playground KV --- packages/web/kvctl.ts | 271 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 269 insertions(+), 2 deletions(-) diff --git a/packages/web/kvctl.ts b/packages/web/kvctl.ts index 34a0b68..e508db7 100644 --- a/packages/web/kvctl.ts +++ b/packages/web/kvctl.ts @@ -10,15 +10,38 @@ import { Command, EnumType } from "@cliffy/command"; import { + CategorySchema, ConfigStore, KvAdapter, + type ModelPool, + ModelPoolSchema, + PromptSchema, + ReviewPassSchema, USER_ROLES, type User, WorkspaceStore, } from "@essayist/core"; import { pluralize } from "@/utils/format.ts"; +// The local playground KV: default sync source, and the default target when +// no remote is configured. +const LOCAL_KV = "./local-kv.sqlite3"; + +// The categories cache on a running instance watches this key; a bump makes +// every isolate drop its cache instead of waiting out the TTL. +const CATEGORIES_EPOCH: Deno.KvKey = ["cache_epoch", "categories"]; + const ROLE = new EnumType([...USER_ROLES]); +const FAMILY = new EnumType([ + "pools", + "prompts", + "categories", + "passes", + "all", +]); + +type FamilyKey = "pools" | "prompts" | "categories" | "passes"; +const FAMILY_ORDER: FamilyKey[] = ["pools", "prompts", "categories", "passes"]; interface KvCtx { kv: Deno.Kv; @@ -26,12 +49,15 @@ interface KvCtx { config: ConfigStore; } +function resolveTarget(target: string | undefined): string { + return target ?? Deno.env.get("REMOTE_URL") ?? LOCAL_KV; +} + 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 kv = await Deno.openKv(resolveTarget(target)); const adapter = new KvAdapter(kv); try { return await fn({ @@ -58,6 +84,199 @@ function printEntry(entry: Deno.KvEntry): void { console.log(`${body}\n`); } +/** 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 SyncCtx { + source: ConfigStore; + target: ConfigStore; + /** Target KV handle, for cache epoch bumps. */ + kv: Deno.Kv; +} + +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 }; +} + +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. */ +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(", ")}`, + ); + } + } +} + await new Command() .name("kvctl") .description("KV management CLI for the Essayist web app.") @@ -204,4 +423,52 @@ await new Command() ); }), ) + .command( + "sync", + "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, from, prune }, family) => { + const sourcePath = from ?? LOCAL_KV; + const targetPath = resolveTarget(target); + 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(); + } + }) .parse(Deno.args); From a8d00aa2d7fcffec1b34a2f138fccf57a5a07923 Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Tue, 15 Sep 2026 19:26:16 +0100 Subject: [PATCH 05/10] refactor(kvctl): move into a dedicated workspace package Move kvctl out of packages/web into the new @essayist/kvctl package with its own deno.jsonc. Add a root deno task so it stays runnable as `deno task kvctl` and drop the task from packages/web. Move pluralize and its tests into core and re-export it from @essayist/core so web and kvctl share one implementation. No behavior changes. --- deno.jsonc | 4 +++- deno.lock | 5 +++++ packages/core/mod.ts | 1 + packages/core/src/format.ts | 5 +++++ packages/core/src/format_test.ts | 14 ++++++++++++++ packages/kvctl/deno.jsonc | 13 +++++++++++++ packages/{web => kvctl}/kvctl.ts | 13 ++++++++----- packages/web/deno.jsonc | 1 - packages/web/utils/format.ts | 7 +------ packages/web/utils/format_test.ts | 19 +------------------ 10 files changed, 51 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/format.ts create mode 100644 packages/core/src/format_test.ts create mode 100644 packages/kvctl/deno.jsonc rename packages/{web => kvctl}/kvctl.ts (98%) 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 8fd064f..d895489 100644 --- a/deno.lock +++ b/deno.lock @@ -2328,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/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/web/kvctl.ts b/packages/kvctl/kvctl.ts similarity index 98% rename from packages/web/kvctl.ts rename to packages/kvctl/kvctl.ts index e508db7..e976852 100644 --- a/packages/web/kvctl.ts +++ b/packages/kvctl/kvctl.ts @@ -5,9 +5,10 @@ // // 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` +// it as the default target when --target is omitted. Run `deno task kvctl --help` // for full usage. +import { fileURLToPath } from "node:url"; import { Command, EnumType } from "@cliffy/command"; import { CategorySchema, @@ -16,16 +17,18 @@ import { type ModelPool, ModelPoolSchema, PromptSchema, + pluralize, ReviewPassSchema, USER_ROLES, type User, WorkspaceStore, } from "@essayist/core"; -import { pluralize } from "@/utils/format.ts"; -// The local playground KV: default sync source, and the default target when -// no remote is configured. -const LOCAL_KV = "./local-kv.sqlite3"; +// The local playground KV, the web dev server's KV. Resolved from this +// module so the default works from any working directory. +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. 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/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"); From 1e745e3524b7aa329354244a705117c131b8bb5a Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Wed, 16 Sep 2026 09:14:29 +0100 Subject: [PATCH 06/10] refactor(kvctl): one module per command Commands live in commands/ and are assembled declaratively in kvctl.ts; kv helpers and sync logic stay in kv.ts and families.ts. --- packages/kvctl/commands/explore.ts | 18 + packages/kvctl/commands/grant-role.ts | 23 ++ packages/kvctl/commands/list-users.ts | 17 + packages/kvctl/commands/seed-config.ts | 90 +++++ packages/kvctl/commands/sync.ts | 67 ++++ packages/kvctl/commands/wipe.ts | 15 + packages/kvctl/families.ts | 211 +++++++++++ packages/kvctl/kv.ts | 55 +++ packages/kvctl/kvctl.ts | 470 +------------------------ 9 files changed, 509 insertions(+), 457 deletions(-) create mode 100644 packages/kvctl/commands/explore.ts create mode 100644 packages/kvctl/commands/grant-role.ts create mode 100644 packages/kvctl/commands/list-users.ts create mode 100644 packages/kvctl/commands/seed-config.ts create mode 100644 packages/kvctl/commands/sync.ts create mode 100644 packages/kvctl/commands/wipe.ts create mode 100644 packages/kvctl/families.ts create mode 100644 packages/kvctl/kv.ts diff --git a/packages/kvctl/commands/explore.ts b/packages/kvctl/commands/explore.ts new file mode 100644 index 0000000..d21e943 --- /dev/null +++ b/packages/kvctl/commands/explore.ts @@ -0,0 +1,18 @@ +import { Command } from "@cliffy/command"; +import { pluralize } from "@essayist/core"; +import { printEntry, withKv } from "@/kv.ts"; + +export const explore = new Command<{ target?: string }>() + .description("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")})`); + }), + ); diff --git a/packages/kvctl/commands/grant-role.ts b/packages/kvctl/commands/grant-role.ts new file mode 100644 index 0000000..ef7165a --- /dev/null +++ b/packages/kvctl/commands/grant-role.ts @@ -0,0 +1,23 @@ +import { Command, EnumType } from "@cliffy/command"; +import { USER_ROLES } from "@essayist/core"; +import { withKv } from "@/kv.ts"; + +const ROLE = new EnumType([...USER_ROLES]); + +export const grantRole = new Command<{ target?: string }>() + .description("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})`); + }), + ); diff --git a/packages/kvctl/commands/list-users.ts b/packages/kvctl/commands/list-users.ts new file mode 100644 index 0000000..2faf7cf --- /dev/null +++ b/packages/kvctl/commands/list-users.ts @@ -0,0 +1,17 @@ +import { Command } from "@cliffy/command"; +import type { User } from "@essayist/core"; +import { withKv } from "@/kv.ts"; + +export const listUsers = new Command<{ target?: string }>() + .description("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)`); + }), + ); diff --git a/packages/kvctl/commands/seed-config.ts b/packages/kvctl/commands/seed-config.ts new file mode 100644 index 0000000..a611bfe --- /dev/null +++ b/packages/kvctl/commands/seed-config.ts @@ -0,0 +1,90 @@ +import { Command } from "@cliffy/command"; +import { withKv } from "@/kv.ts"; + +export const seedConfig = new Command<{ target?: string }>() + .description("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)`, + ); + }), + ); diff --git a/packages/kvctl/commands/sync.ts b/packages/kvctl/commands/sync.ts new file mode 100644 index 0000000..ba50ade --- /dev/null +++ b/packages/kvctl/commands/sync.ts @@ -0,0 +1,67 @@ +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 { CATEGORIES_EPOCH, LOCAL_KV, resolveTarget } from "@/kv.ts"; + +const FAMILY = new EnumType([ + "pools", + "prompts", + "categories", + "passes", + "all", +]); + +export const sync = new Command<{ target?: string }>() + .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, from, prune }, family) => { + const sourcePath = from ?? LOCAL_KV; + const targetPath = resolveTarget(target); + 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..9ac26a9 --- /dev/null +++ b/packages/kvctl/commands/wipe.ts @@ -0,0 +1,15 @@ +import { Command } from "@cliffy/command"; +import { withKv } from "@/kv.ts"; + +export const wipe = new Command<{ target?: string }>() + .description("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`); + }), + ); 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/kv.ts b/packages/kvctl/kv.ts new file mode 100644 index 0000000..00bbf28 --- /dev/null +++ b/packages/kvctl/kv.ts @@ -0,0 +1,55 @@ +// KV helpers shared by kvctl commands. + +import { fileURLToPath } from "node:url"; +import { ConfigStore, KvAdapter, WorkspaceStore } from "@essayist/core"; + +// 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(target: string | undefined): string { + return target ?? Deno.env.get("REMOTE_URL") ?? LOCAL_KV; +} + +export async function withKv( + target: string | undefined, + fn: (ctx: KvCtx) => Promise, +): Promise { + const kv = await Deno.openKv(resolveTarget(target)); + 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 +export 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`); +} diff --git a/packages/kvctl/kvctl.ts b/packages/kvctl/kvctl.ts index e976852..00657de 100644 --- a/packages/kvctl/kvctl.ts +++ b/packages/kvctl/kvctl.ts @@ -8,277 +8,13 @@ // it as the default target when --target is omitted. Run `deno task kvctl --help` // for full usage. -import { fileURLToPath } from "node:url"; -import { Command, EnumType } from "@cliffy/command"; -import { - CategorySchema, - ConfigStore, - KvAdapter, - type ModelPool, - ModelPoolSchema, - PromptSchema, - pluralize, - ReviewPassSchema, - USER_ROLES, - type User, - WorkspaceStore, -} from "@essayist/core"; - -// The local playground KV, the web dev server's KV. Resolved from this -// module so the default works from any working directory. -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. -const CATEGORIES_EPOCH: Deno.KvKey = ["cache_epoch", "categories"]; - -const ROLE = new EnumType([...USER_ROLES]); -const FAMILY = new EnumType([ - "pools", - "prompts", - "categories", - "passes", - "all", -]); - -type FamilyKey = "pools" | "prompts" | "categories" | "passes"; -const FAMILY_ORDER: FamilyKey[] = ["pools", "prompts", "categories", "passes"]; - -interface KvCtx { - kv: Deno.Kv; - workspaceStore: WorkspaceStore; - config: ConfigStore; -} - -function resolveTarget(target: string | undefined): string { - return target ?? Deno.env.get("REMOTE_URL") ?? LOCAL_KV; -} - -async function withKv( - target: string | undefined, - fn: (ctx: KvCtx) => Promise, -): Promise { - const kv = await Deno.openKv(resolveTarget(target)); - 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`); -} - -/** 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 SyncCtx { - source: ConfigStore; - target: ConfigStore; - /** Target KV handle, for cache epoch bumps. */ - kv: Deno.Kv; -} - -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 }; -} - -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. */ -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(", ")}`, - ); - } - } -} +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") @@ -288,190 +24,10 @@ await new Command() "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)`, - ); - }), - ) - .command( - "sync", - "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, from, prune }, family) => { - const sourcePath = from ?? LOCAL_KV; - const targetPath = resolveTarget(target); - 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(); - } - }) + .command("wipe", wipe) + .command("explore", explore) + .command("grant-role", grantRole) + .command("list-users", listUsers) + .command("seed-config", seedConfig) + .command("sync", sync) .parse(Deno.args); From e29fd5cd15e64785e84fc7c7bd0a258651b19995 Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Wed, 16 Sep 2026 09:48:54 +0100 Subject: [PATCH 07/10] refactor(kvctl): extract pprint into utils Generalize printEntry into a generic pprint(value) and move it from kv.ts to utils/pprint.ts. --- packages/kvctl/commands/explore.ts | 5 +++-- packages/kvctl/kv.ts | 14 -------------- packages/kvctl/utils/pprint.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 16 deletions(-) create mode 100644 packages/kvctl/utils/pprint.ts diff --git a/packages/kvctl/commands/explore.ts b/packages/kvctl/commands/explore.ts index d21e943..6c79bcd 100644 --- a/packages/kvctl/commands/explore.ts +++ b/packages/kvctl/commands/explore.ts @@ -1,6 +1,7 @@ import { Command } from "@cliffy/command"; import { pluralize } from "@essayist/core"; -import { printEntry, withKv } from "@/kv.ts"; +import { withKv } from "@/kv.ts"; +import { pprint } from "@/utils/pprint.ts"; export const explore = new Command<{ target?: string }>() .description("List keys, optionally under a tuple prefix.") @@ -10,7 +11,7 @@ export const explore = new Command<{ target?: string }>() let n = 0; for await (const entry of kv.list({ prefix })) { n++; - printEntry(entry); + 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/kv.ts b/packages/kvctl/kv.ts index 00bbf28..d76f031 100644 --- a/packages/kvctl/kv.ts +++ b/packages/kvctl/kv.ts @@ -39,17 +39,3 @@ export async function withKv( kv.close(); } } - -// colored inspect on a TTY; plain JSON when stdout is piped, one document -// per entry, so jq can parse the stream -export 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`); -} 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`); +} From c97e4fdf9c91bf5e0a0c50078b0907a4b78c3de6 Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Wed, 16 Sep 2026 09:56:35 +0100 Subject: [PATCH 08/10] feat(kvctl): add global --local option Forces the default local playground KV over REMOTE_URL; an explicit --target still wins. Shared option types live in globals.ts. --- packages/kvctl/commands/explore.ts | 7 ++++--- packages/kvctl/commands/grant-role.ts | 7 ++++--- packages/kvctl/commands/list-users.ts | 7 ++++--- packages/kvctl/commands/seed-config.ts | 7 ++++--- packages/kvctl/commands/sync.ts | 7 ++++--- packages/kvctl/commands/wipe.ts | 7 ++++--- packages/kvctl/globals.ts | 6 ++++++ packages/kvctl/kv.ts | 13 +++++++++---- packages/kvctl/kvctl.ts | 6 +++++- 9 files changed, 44 insertions(+), 23 deletions(-) create mode 100644 packages/kvctl/globals.ts diff --git a/packages/kvctl/commands/explore.ts b/packages/kvctl/commands/explore.ts index 6c79bcd..cbf3c04 100644 --- a/packages/kvctl/commands/explore.ts +++ b/packages/kvctl/commands/explore.ts @@ -1,13 +1,14 @@ 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<{ target?: string }>() +export const explore = new Command() .description("List keys, optionally under a tuple prefix.") .arguments("[prefix...:string]") - .action(({ target }, ...prefix: string[]) => - withKv(target, async ({ kv }) => { + .action(({ target, local }, ...prefix: string[]) => + withKv({ target, local }, async ({ kv }) => { let n = 0; for await (const entry of kv.list({ prefix })) { n++; diff --git a/packages/kvctl/commands/grant-role.ts b/packages/kvctl/commands/grant-role.ts index ef7165a..fdd3dfd 100644 --- a/packages/kvctl/commands/grant-role.ts +++ b/packages/kvctl/commands/grant-role.ts @@ -1,15 +1,16 @@ 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<{ target?: string }>() +export const grantRole = new Command() .description("Set a user's site-wide role.") .type("role", ROLE) .arguments(" ") - .action(({ target }, emailOrId: string, role: "admin" | "writer") => - withKv(target, async ({ workspaceStore }) => { + .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); diff --git a/packages/kvctl/commands/list-users.ts b/packages/kvctl/commands/list-users.ts index 2faf7cf..449da7d 100644 --- a/packages/kvctl/commands/list-users.ts +++ b/packages/kvctl/commands/list-users.ts @@ -1,11 +1,12 @@ 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<{ target?: string }>() +export const listUsers = new Command() .description("List users.") - .action(({ target }) => - withKv(target, async ({ kv }) => { + .action(({ target, local }) => + withKv({ target, local }, async ({ kv }) => { let n = 0; for await (const entry of kv.list({ prefix: ["users"] })) { const u = entry.value; diff --git a/packages/kvctl/commands/seed-config.ts b/packages/kvctl/commands/seed-config.ts index a611bfe..65ee9c8 100644 --- a/packages/kvctl/commands/seed-config.ts +++ b/packages/kvctl/commands/seed-config.ts @@ -1,10 +1,11 @@ import { Command } from "@cliffy/command"; +import type { KvctlGlobals } from "@/globals.ts"; import { withKv } from "@/kv.ts"; -export const seedConfig = new Command<{ target?: string }>() +export const seedConfig = new Command() .description("Seed default review config.") - .action(({ target }) => - withKv(target, async ({ config }) => { + .action(({ target, local }) => + withKv({ target, local }, async ({ config }) => { const poolId = "free-pool"; await config.saveModelPool({ id: poolId, diff --git a/packages/kvctl/commands/sync.ts b/packages/kvctl/commands/sync.ts index ba50ade..22753d7 100644 --- a/packages/kvctl/commands/sync.ts +++ b/packages/kvctl/commands/sync.ts @@ -7,6 +7,7 @@ import { 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([ @@ -17,7 +18,7 @@ const FAMILY = new EnumType([ "all", ]); -export const sync = new Command<{ target?: string }>() +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.", ) @@ -31,9 +32,9 @@ export const sync = new Command<{ target?: string }>() .option("--prune", "Also delete target entries missing from the source.", { default: false, }) - .action(async ({ target, from, prune }, family) => { + .action(async ({ target, local, from, prune }, family) => { const sourcePath = from ?? LOCAL_KV; - const targetPath = resolveTarget(target); + const targetPath = resolveTarget({ target, local }); if (sourcePath === targetPath) { console.error( `source and target are both ${sourcePath}; nothing to sync`, diff --git a/packages/kvctl/commands/wipe.ts b/packages/kvctl/commands/wipe.ts index 9ac26a9..6ba4e21 100644 --- a/packages/kvctl/commands/wipe.ts +++ b/packages/kvctl/commands/wipe.ts @@ -1,10 +1,11 @@ import { Command } from "@cliffy/command"; +import type { KvctlGlobals } from "@/globals.ts"; import { withKv } from "@/kv.ts"; -export const wipe = new Command<{ target?: string }>() +export const wipe = new Command() .description("Delete every key.") - .action(({ target }) => - withKv(target, async ({ kv }) => { + .action(({ target, local }) => + withKv({ target, local }, async ({ kv }) => { let n = 0; for await (const entry of kv.list({ prefix: [] })) { await kv.delete(entry.key); 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 index d76f031..c821b47 100644 --- a/packages/kvctl/kv.ts +++ b/packages/kvctl/kv.ts @@ -2,6 +2,7 @@ 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. @@ -19,15 +20,19 @@ interface KvCtx { config: ConfigStore; } -export function resolveTarget(target: string | undefined): string { - return target ?? Deno.env.get("REMOTE_URL") ?? LOCAL_KV; +export function resolveTarget(globals: KvctlGlobals): string { + return ( + globals.target ?? + (globals.local ? LOCAL_KV : Deno.env.get("REMOTE_URL")) ?? + LOCAL_KV + ); } export async function withKv( - target: string | undefined, + globals: KvctlGlobals, fn: (ctx: KvCtx) => Promise, ): Promise { - const kv = await Deno.openKv(resolveTarget(target)); + const kv = await Deno.openKv(resolveTarget(globals)); const adapter = new KvAdapter(kv); try { return await fn({ diff --git a/packages/kvctl/kvctl.ts b/packages/kvctl/kvctl.ts index 00657de..d87fbeb 100644 --- a/packages/kvctl/kvctl.ts +++ b/packages/kvctl/kvctl.ts @@ -5,7 +5,8 @@ // // 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` +// 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"; @@ -24,6 +25,9 @@ await new Command() "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) From e7c5ece860e79da82fcf08f544855e08651440f6 Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Wed, 16 Sep 2026 10:02:13 +0100 Subject: [PATCH 09/10] feat(kvctl): add optional prefix to wipe Scopes deletion to keys under the prefix; a bare wipe still deletes every key. Also align the list-users email column. --- packages/kvctl/commands/list-users.ts | 4 +++- packages/kvctl/commands/wipe.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/kvctl/commands/list-users.ts b/packages/kvctl/commands/list-users.ts index 449da7d..1b3e3e8 100644 --- a/packages/kvctl/commands/list-users.ts +++ b/packages/kvctl/commands/list-users.ts @@ -10,7 +10,9 @@ export const listUsers = new Command() 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"}`); + console.log( + `${u.id} ${u.email.padEnd(24)} role=${u.role ?? "writer"}`, + ); n++; } console.log(`(${n} users)`); diff --git a/packages/kvctl/commands/wipe.ts b/packages/kvctl/commands/wipe.ts index 6ba4e21..9a263e4 100644 --- a/packages/kvctl/commands/wipe.ts +++ b/packages/kvctl/commands/wipe.ts @@ -3,11 +3,12 @@ import type { KvctlGlobals } from "@/globals.ts"; import { withKv } from "@/kv.ts"; export const wipe = new Command() - .description("Delete every key.") - .action(({ target, local }) => + .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: [] })) { + for await (const entry of kv.list({ prefix })) { await kv.delete(entry.key); n++; } From d006a66b83265a7b8242251095b6667d66bb8c3b Mon Sep 17 00:00:00 2001 From: Dima Budaragin Date: Wed, 16 Sep 2026 10:15:06 +0100 Subject: [PATCH 10/10] refactor(kvctl): derive sync family enum from FAMILY_ORDER --- packages/kvctl/commands/sync.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/kvctl/commands/sync.ts b/packages/kvctl/commands/sync.ts index 22753d7..f027e52 100644 --- a/packages/kvctl/commands/sync.ts +++ b/packages/kvctl/commands/sync.ts @@ -10,13 +10,7 @@ import { import type { KvctlGlobals } from "@/globals.ts"; import { CATEGORIES_EPOCH, LOCAL_KV, resolveTarget } from "@/kv.ts"; -const FAMILY = new EnumType([ - "pools", - "prompts", - "categories", - "passes", - "all", -]); +const FAMILY = new EnumType([...FAMILY_ORDER, "all"]); export const sync = new Command() .description(