From ca1a64c32d28a9c447940d3b3bcc14260bbcb5ab Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Mon, 13 Jul 2026 02:35:07 +0300 Subject: [PATCH] feat(framework): persist dashboard preferences in the-framework.json (#410) The dashboard's Global options (Autopilot, Technical, Vanilla, Eco + its section drops) now persist daemon-side in the same the-framework.json as the project list, read and written over Telefunc (onPreferences / savePreferences). No more localStorage, and the toggles survive a restart. The registry file becomes an object { projects, preferences }; older bare-array files still read and migrate on the next write. The preferences store is threaded through the Telefunc request context, so the public relay (which never wires one) leaves reads at the empty default and writes reported as not enabled. Closes #410 --- .changeset/dashboard-preferences.md | 5 + .../components/ChoicePanel.tsx | 9 +- .../components/StartRunForm.tsx | 37 ++-- packages/framework-dashboard/lib/autopilot.ts | 15 -- .../framework-dashboard/lib/preferences.ts | 67 +++++++ .../server/preferences.telefunc.ts | 7 + .../framework/src/dashboard-rpc/context.ts | 14 ++ packages/framework/src/dashboard-rpc/index.ts | 1 + .../preferences.telefunc.test.ts | 17 ++ .../src/dashboard-rpc/preferences.telefunc.ts | 27 +++ .../framework/src/dashboard-rpc/register.ts | 6 +- packages/framework/src/dashboard/server.ts | 10 +- .../framework/src/dashboard/telefunc-serve.ts | 6 + packages/framework/src/index.ts | 7 + packages/framework/src/registry.test.ts | 62 ++++++- packages/framework/src/registry.ts | 174 ++++++++++++++---- 16 files changed, 388 insertions(+), 76 deletions(-) create mode 100644 .changeset/dashboard-preferences.md delete mode 100644 packages/framework-dashboard/lib/autopilot.ts create mode 100644 packages/framework-dashboard/lib/preferences.ts create mode 100644 packages/framework-dashboard/server/preferences.telefunc.ts create mode 100644 packages/framework/src/dashboard-rpc/preferences.telefunc.test.ts create mode 100644 packages/framework/src/dashboard-rpc/preferences.telefunc.ts diff --git a/.changeset/dashboard-preferences.md b/.changeset/dashboard-preferences.md new file mode 100644 index 0000000..b47cffb --- /dev/null +++ b/.changeset/dashboard-preferences.md @@ -0,0 +1,5 @@ +--- +'@gemstack/framework': minor +--- + +Persist the dashboard's Global options (Autopilot, Technical, Vanilla, Eco) in the same `the-framework.json` as the project list, read and written daemon-side over Telefunc (`onPreferences` / `savePreferences`), so they survive restarts without localStorage (#410). The registry file becomes an object `{ projects, preferences }`; older bare-array files still read and are migrated on the next write. diff --git a/packages/framework-dashboard/components/ChoicePanel.tsx b/packages/framework-dashboard/components/ChoicePanel.tsx index 260ed9c..558462b 100644 --- a/packages/framework-dashboard/components/ChoicePanel.tsx +++ b/packages/framework-dashboard/components/ChoicePanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import type { ChoiceRequest } from '@gemstack/framework' import { sendChoice } from '../server/control.telefunc.js' -import { autopilotOn, setAutopilot } from '../lib/autopilot.js' +import { usePreferences, updatePreferences, autopilotEnabled } from '../lib/preferences.js' import { Button } from './ui/button.js' import { cn } from '../lib/utils.js' @@ -26,7 +26,7 @@ export function ChoicePanel({ const [checked, setChecked] = useState>( () => new Set(choice.multi ? choice.options.filter(o => o.default).map(o => o.id) : []), ) - const [autopilot, setAutopilotState] = useState(autopilotOn) + const autopilot = autopilotEnabled(usePreferences()) const [secondsLeft, setSecondsLeft] = useState(null) const [cancelled, setCancelled] = useState(false) @@ -95,10 +95,7 @@ export function ChoicePanel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [autopilot, cancelled, busy]) - const toggleAutopilot = (on: boolean) => { - setAutopilotState(on) - setAutopilot(on) // keep the Start form's Global option in lockstep (#433) - } + const toggleAutopilot = (on: boolean) => updatePreferences({ autopilot: on }) // shared with the Start form (#410) return (
diff --git a/packages/framework-dashboard/components/StartRunForm.tsx b/packages/framework-dashboard/components/StartRunForm.tsx index 8869f26..8716b0a 100644 --- a/packages/framework-dashboard/components/StartRunForm.tsx +++ b/packages/framework-dashboard/components/StartRunForm.tsx @@ -8,7 +8,7 @@ import { } from '@gemstack/framework/client' import { sendStart } from '../server/control.telefunc.js' import { onProjects } from '../server/projects.telefunc.js' -import { autopilotOn, setAutopilot } from '../lib/autopilot.js' +import { usePreferences, updatePreferences, autopilotEnabled } from '../lib/preferences.js' import { Button } from './ui/button.js' import { cn } from '../lib/utils.js' @@ -33,13 +33,15 @@ export function StartRunForm({ projectId }: { projectId: string }) { const [error, setError] = useState(null) const [note, setNote] = useState(null) - const [autopilot, setAutopilotState] = useState(autopilotOn) - const [technical, setTechnical] = useState(false) - const [vanilla, setVanilla] = useState(false) - const [eco, setEco] = useState(false) - const [ecoPlanning, setEcoPlanning] = useState(false) - const [ecoResearch, setEcoResearch] = useState(false) - const [ecoMaintenance, setEcoMaintenance] = useState(false) + // The Global options persist daemon-side (#410), shared with the choice-gate countdown. + const preferences = usePreferences() + const autopilot = autopilotEnabled(preferences) + const technical = preferences.technical ?? false + const vanilla = preferences.vanilla ?? false + const eco = preferences.eco ?? false + const ecoPlanning = preferences.ecoPlanning ?? false + const ecoResearch = preferences.ecoResearch ?? false + const ecoMaintenance = preferences.ecoMaintenance ?? false // Context selector (#439/#314): the agent can reach every registered repo, so ticking a // subset narrows its focus — the picked paths become one `Context:` line in the system @@ -127,11 +129,6 @@ export function StartRunForm({ projectId }: { projectId: string }) { } } - const toggleAutopilot = (on: boolean) => { - setAutopilotState(on) - setAutopilot(on) // keep the choice-gate countdown in lockstep (#433) - } - return (
Start a run
@@ -155,29 +152,29 @@ export function StartRunForm({ projectId }: { projectId: string }) {
{eco && !ecoDisabled && (
)} diff --git a/packages/framework-dashboard/lib/autopilot.ts b/packages/framework-dashboard/lib/autopilot.ts deleted file mode 100644 index 25684ba..0000000 --- a/packages/framework-dashboard/lib/autopilot.ts +++ /dev/null @@ -1,15 +0,0 @@ -// The autopilot preference (#433), shared by the Start form's Global options and the -// choice-gate countdown so the two stay in lockstep, as the old page.ts did. Persisted in -// localStorage under the same `framework:autopilot` key; default on (the demo default). -// Guarded for prerender, where `window`/`localStorage` do not exist. -const KEY = 'framework:autopilot' - -export function autopilotOn(): boolean { - if (typeof localStorage === 'undefined') return true - return localStorage.getItem(KEY) !== '0' // absent (null) or anything but '0' -> on -} - -export function setAutopilot(on: boolean): void { - if (typeof localStorage === 'undefined') return - localStorage.setItem(KEY, on ? '1' : '0') -} diff --git a/packages/framework-dashboard/lib/preferences.ts b/packages/framework-dashboard/lib/preferences.ts new file mode 100644 index 0000000..b6266e1 --- /dev/null +++ b/packages/framework-dashboard/lib/preferences.ts @@ -0,0 +1,67 @@ +import { useEffect, useSyncExternalStore } from 'react' +import type { Preferences } from '@gemstack/framework' +import { onPreferences, savePreferences } from '../server/preferences.telefunc.js' + +// The dashboard's Global options (#410), owned by the daemon and persisted in the same +// `the-framework.json` as the project list — no more localStorage. Loaded once over Telefunc +// and cached in this module so every component (the Start form's toggles + the choice-gate +// countdown) reads one shared value and stays in lockstep: an update writes through to the +// cache, notifies subscribers, and persists daemon-side. Prerender has no daemon, so the +// server snapshot is the empty default and the real values load on the client. + +const EMPTY: Preferences = {} +let cache: Preferences | null = null +let loading: Promise | null = null +const listeners = new Set<() => void>() + +function notify(): void { + for (const listener of listeners) listener() +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function snapshot(): Preferences { + return cache ?? EMPTY +} + +function ensureLoaded(): void { + if (cache || loading) return + loading = onPreferences() + .then(preferences => { + cache = preferences + }) + .catch(() => { + cache = {} + }) + .finally(() => { + loading = null + notify() + }) +} + +/** + * Merge a patch into the shared preferences, persist it daemon-side, and notify every + * subscriber so the Start form and the choice gate stay in lockstep. The write-through keeps + * the UI responsive; the `savePreferences` round-trip is best-effort (a failed save is not + * worth surfacing over a checkbox toggle). + */ +export function updatePreferences(patch: Partial): void { + cache = { ...(cache ?? {}), ...patch } + notify() + void savePreferences(cache).catch(() => {}) +} + +/** The shared user preferences, loaded once from the daemon and kept in sync across components. */ +export function usePreferences(): Preferences { + const preferences = useSyncExternalStore(subscribe, snapshot, () => EMPTY) + useEffect(ensureLoaded, []) + return preferences +} + +/** Autopilot defaults on (the demo default), matching the old localStorage semantics. */ +export function autopilotEnabled(preferences: Preferences): boolean { + return preferences.autopilot ?? true +} diff --git a/packages/framework-dashboard/server/preferences.telefunc.ts b/packages/framework-dashboard/server/preferences.telefunc.ts new file mode 100644 index 0000000..7bf259e --- /dev/null +++ b/packages/framework-dashboard/server/preferences.telefunc.ts @@ -0,0 +1,7 @@ +// Re-export shim (#410): the user-preferences telefunctions live in @gemstack/framework so the +// daemon serves them in-process, reading/writing the same `the-framework.json` as the registry. +// Keeping this file at `server/preferences.telefunc.ts` means the client bakes the RPC key +// `/server/preferences.telefunc.ts` — the exact key the daemon registers the impls under (see +// framework's dashboard-rpc/register.ts). The telefunc Vite transform turns these named +// re-exports into client RPC stubs. +export { onPreferences, savePreferences } from '@gemstack/framework/dashboard-rpc' diff --git a/packages/framework/src/dashboard-rpc/context.ts b/packages/framework/src/dashboard-rpc/context.ts index aaa4cde..88a10f1 100644 --- a/packages/framework/src/dashboard-rpc/context.ts +++ b/packages/framework/src/dashboard-rpc/context.ts @@ -1,6 +1,7 @@ import { getContext } from 'telefunc' import { defaultProjectsProvider, type ProjectsProvider } from '../dashboard/projects.js' import type { EventsSource } from '../dashboard/telefunc-serve.js' +import type { PreferencesStore } from '../registry.js' /** * The {@link ProjectsProvider} a telefunction should read a project id against (#427). @@ -31,3 +32,16 @@ export function contextEventsSource(): EventsSource | undefined { return undefined } } + +/** + * The user-preferences store on the context, or undefined (#410). The daemon/foreground wire + * the real registry file; a public host (the relay) leaves it unset, so the preferences RPCs + * degrade to a read-only default / a no-op write on a shared host. + */ +export function contextPreferences(): PreferencesStore | undefined { + try { + return getContext<{ preferences?: PreferencesStore }>()?.preferences + } catch { + return undefined + } +} diff --git a/packages/framework/src/dashboard-rpc/index.ts b/packages/framework/src/dashboard-rpc/index.ts index 68f850a..f0c9b4c 100644 --- a/packages/framework/src/dashboard-rpc/index.ts +++ b/packages/framework/src/dashboard-rpc/index.ts @@ -6,4 +6,5 @@ export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview } from './read export { sendStop, sendChoice, sendStart } from './control.telefunc.js' export { onEvents } from './events.telefunc.js' export { onProjects, sendAddProject } from './projects.telefunc.js' +export { onPreferences, savePreferences, type SavePreferencesResult } from './preferences.telefunc.js' export { registerDashboardTelefunctions, DASHBOARD_TELEFUNC_KEYS } from './register.js' diff --git a/packages/framework/src/dashboard-rpc/preferences.telefunc.test.ts b/packages/framework/src/dashboard-rpc/preferences.telefunc.test.ts new file mode 100644 index 0000000..f580400 --- /dev/null +++ b/packages/framework/src/dashboard-rpc/preferences.telefunc.test.ts @@ -0,0 +1,17 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { onPreferences, savePreferences } from './preferences.telefunc.js' + +// Outside a Telefunc `serve({ context })` there is no preferences store on the context — the +// same situation as the public relay, which never wires one. The RPCs must degrade safely: a +// read falls back to the empty default, and a write reports it is not enabled rather than +// touching the host's home file. + +test('onPreferences with no store returns the empty default', async () => { + assert.deepEqual(await onPreferences(), {}) +}) + +test('savePreferences with no store is a not-enabled no-op', async () => { + const result = await savePreferences({ autopilot: false }) + assert.deepEqual(result, { ok: false, error: 'preferences are not enabled on this server' }) +}) diff --git a/packages/framework/src/dashboard-rpc/preferences.telefunc.ts b/packages/framework/src/dashboard-rpc/preferences.telefunc.ts new file mode 100644 index 0000000..a3f05ac --- /dev/null +++ b/packages/framework/src/dashboard-rpc/preferences.telefunc.ts @@ -0,0 +1,27 @@ +import { contextPreferences } from './context.js' +import type { Preferences } from '../registry.js' + +// The user-preferences surface behind the new dashboard (#410): the Global options (Autopilot, +// Technical, Vanilla, Eco + its section drops) the Start form and choice gate share. Persisted +// daemon-side in the same `the-framework.json` as the project list, so they survive restarts +// with no localStorage. The store is threaded through the Telefunc request context by the +// daemon (the real registry file); a public host (the relay) leaves it unwired, so reads fall +// back to defaults and writes report they are not enabled. + +/** The outcome of a {@link savePreferences} write. */ +export type SavePreferencesResult = { ok: true } | { ok: false; error: string } + +/** The user's stored dashboard preferences, or `{}` on a host that has none / does not store them. */ +export async function onPreferences(): Promise { + const store = contextPreferences() + if (!store) return {} + return store.read().catch(() => ({})) +} + +/** Persist the dashboard preferences (sanitized in the store). No-op-safe on a public host. */ +export async function savePreferences(preferences: Preferences): Promise { + const store = contextPreferences() + if (!store) return { ok: false, error: 'preferences are not enabled on this server' } + await store.save(preferences) + return { ok: true } +} diff --git a/packages/framework/src/dashboard-rpc/register.ts b/packages/framework/src/dashboard-rpc/register.ts index 7d9d3ea..ef83346 100644 --- a/packages/framework/src/dashboard-rpc/register.ts +++ b/packages/framework/src/dashboard-rpc/register.ts @@ -3,6 +3,7 @@ import { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview } from './read import { sendStop, sendChoice, sendStart } from './control.telefunc.js' import { onEvents } from './events.telefunc.js' import { onProjects, sendAddProject } from './projects.telefunc.js' +import { onPreferences, savePreferences } from './preferences.telefunc.js' // The client bakes each RPC key from the dashboard's source path (relative to its Vite // root, keeping the `.ts` extension) as `":"`. Since the @@ -14,6 +15,7 @@ export const DASHBOARD_TELEFUNC_KEYS = { control: '/server/control.telefunc.ts', events: '/server/events.telefunc.ts', projects: '/server/projects.telefunc.ts', + preferences: '/server/preferences.telefunc.ts', } as const let registered = false @@ -28,7 +30,7 @@ export function registerDashboardTelefunctions(appRootDir: string = process.cwd( registered = true const reg = (fn: (...args: never[]) => unknown, name: string, key: string): void => __decorateTelefunction(fn as never, name, key, appRootDir) - const { reads, control, events, projects } = DASHBOARD_TELEFUNC_KEYS + const { reads, control, events, projects, preferences } = DASHBOARD_TELEFUNC_KEYS reg(onRuns, 'onRuns', reads) reg(onRun, 'onRun', reads) reg(onDocs, 'onDocs', reads) @@ -41,4 +43,6 @@ export function registerDashboardTelefunctions(appRootDir: string = process.cwd( reg(onEvents, 'onEvents', events) reg(onProjects, 'onProjects', projects) reg(sendAddProject, 'sendAddProject', projects) + reg(onPreferences, 'onPreferences', preferences) + reg(savePreferences, 'savePreferences', preferences) } diff --git a/packages/framework/src/dashboard/server.ts b/packages/framework/src/dashboard/server.ts index 1ecf33c..c1edddf 100644 --- a/packages/framework/src/dashboard/server.ts +++ b/packages/framework/src/dashboard/server.ts @@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import type { AddressInfo } from 'node:net' import type { EcoOptions } from '../system-prompt.js' import type { ProjectsProvider } from './projects.js' +import { registryPreferencesStore, type PreferencesStore } from '../registry.js' import { serveClientBundle } from './static.js' import { makeTelefuncMount } from './telefunc-serve.js' @@ -36,6 +37,13 @@ export interface DashboardOptions { * repo under a directory) and register it (the daemon does). Omit to disable adding. */ onAddProject?: (path: string, directory: boolean) => Promise | AddProjectResult + /** + * The user-preferences store (#410): the `onPreferences` / `savePreferences` telefunctions + * read/write it through the request context. Defaults to the real registry file (the daemon + * and per-run foreground dashboard both want it); the public relay serves its own mount and + * never wires one, so preferences stay inert there. + */ + preferences?: PreferencesStore /** * Serve the new dashboard bundle (#405) from this directory — the prerendered Vike SPA * (`index.html` + `assets/**`). The daemon also mounts the dashboard's Telefunc surface @@ -108,7 +116,7 @@ export function startDashboard(opts: DashboardOptions = {}): Promise // registry, byte-identical to the daemon default; the per-run dashboard passes a // single-project provider, the relay an empty one. const telefuncMount = clientBundleDir - ? makeTelefuncMount(opts.onStart, opts.projects, undefined, opts.onAddProject) + ? makeTelefuncMount(opts.onStart, opts.projects, undefined, opts.onAddProject, opts.preferences ?? registryPreferencesStore()) : undefined const server = createServer((req, res) => { diff --git a/packages/framework/src/dashboard/telefunc-serve.ts b/packages/framework/src/dashboard/telefunc-serve.ts index 6352ba9..75514ed 100644 --- a/packages/framework/src/dashboard/telefunc-serve.ts +++ b/packages/framework/src/dashboard/telefunc-serve.ts @@ -4,6 +4,7 @@ import { Telefunc } from 'telefunc/node' import { registerDashboardTelefunctions } from '../dashboard-rpc/register.js' import type { ProjectsProvider } from './projects.js' import type { FrameworkEvent } from '../events.js' +import type { PreferencesStore } from '../registry.js' import { isSameOriginRequest, type AddProjectResult, type StartRunKind, type StartRunOptions, type StartRunResult } from './server.js' /** Wired by the daemon so `sendStart` can reach the daemon's own `startRun` closure. */ @@ -33,6 +34,9 @@ export interface DashboardContext { addProject?: AddProjectHandler projects?: ProjectsProvider eventsSource?: EventsSource + /** The user-preferences store (#410). The daemon/foreground wire the real registry file; + * a public host (the relay) leaves it unset so `onPreferences`/`savePreferences` are inert. */ + preferences?: PreferencesStore } let instance: Telefunc | undefined @@ -62,6 +66,7 @@ export function makeTelefuncMount( projects?: ProjectsProvider, eventsSource?: EventsSource, addProject?: AddProjectHandler, + preferences?: PreferencesStore, ): (req: IncomingMessage, res: ServerResponse) => Promise { return async (req, res) => { if (!isSameOriginRequest(req)) { @@ -75,6 +80,7 @@ export function makeTelefuncMount( ...(addProject ? { addProject } : {}), ...(projects ? { projects } : {}), ...(eventsSource ? { eventsSource } : {}), + ...(preferences ? { preferences } : {}), } // Never let a telefunc failure become an unhandled rejection that kills the daemon: // telefunc 0.2.22 throws on a bare `GET /_telefunc` (it passes the request as a body, diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 7e65d84..913721b 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -165,8 +165,15 @@ export { listProjects, addProject, removeProject, + readRegistry, + readPreferences, + writePreferences, + registryPreferencesStore, REGISTRY_FILE, type ProjectRecord, + type Registry, + type Preferences, + type PreferencesStore, type RegistryFs, } from './registry.js' export { diff --git a/packages/framework/src/registry.test.ts b/packages/framework/src/registry.test.ts index 3234e3d..2b6443c 100644 --- a/packages/framework/src/registry.test.ts +++ b/packages/framework/src/registry.test.ts @@ -5,8 +5,12 @@ import { addProject, listProjects, projectId, + readPreferences, + readRegistry, + registryPreferencesStore, registryPath, removeProject, + writePreferences, REGISTRY_FILE, type ProjectRecord, type RegistryFs, @@ -96,7 +100,7 @@ test('addProject appends a record and writes pretty JSON that parses back', asyn const record = await addProject('/repos/app-a', APP_A.addedAt, fs, ENV) assert.deepEqual(record, APP_A) assert.deepEqual(fs.dirs, ['/home/u']) // the single dotfile's parent is $HOME itself - assert.deepEqual(JSON.parse(fs.files.get(FILE)!), [APP_A]) + assert.deepEqual(JSON.parse(fs.files.get(FILE)!), { projects: [APP_A], preferences: {} }) await addProject('/repos/app-b', APP_B.addedAt, fs, ENV) assert.deepEqual(await listProjects(fs, ENV), [APP_A, APP_B]) @@ -109,7 +113,7 @@ test('addProject is idempotent by resolved path and keeps the original addedAt', const again = await addProject(variant, '2027-01-01T00:00:00.000Z', fs, ENV) assert.deepEqual(again, APP_A) // existing record, addedAt untouched } - assert.deepEqual(JSON.parse(fs.files.get(FILE)!), [APP_A]) + assert.deepEqual(JSON.parse(fs.files.get(FILE)!), { projects: [APP_A], preferences: {} }) }) test('addProject normalizes the stored path to an absolute one', async () => { @@ -135,3 +139,57 @@ test('removeProject on an empty / missing registry is false', async () => { assert.equal(await removeProject(APP_A.id, memFs(), ENV), false) assert.equal(await removeProject(APP_A.id, memFs({ [FILE]: '[]' }), ENV), false) }) + +// Preferences (#410): stored in the same file next to the project list. + +test('readRegistry reads a legacy bare-array file as { projects, preferences: {} }', async () => { + const raw = JSON.stringify([APP_A, APP_B]) + assert.deepEqual(await readRegistry(memFs({ [FILE]: raw }), ENV), { + projects: [APP_A, APP_B], + preferences: {}, + }) +}) + +test('readRegistry reads the object form with preferences and drops unknown/non-boolean fields', async () => { + const raw = JSON.stringify({ + projects: [APP_A], + preferences: { autopilot: false, eco: true, ecoPlanning: 'yes', bogus: 1 }, + }) + assert.deepEqual(await readRegistry(memFs({ [FILE]: raw }), ENV), { + projects: [APP_A], + preferences: { autopilot: false, eco: true }, // ecoPlanning (non-boolean) + bogus dropped + }) +}) + +test('readPreferences on a missing / legacy file is {}', async () => { + assert.deepEqual(await readPreferences(memFs(), ENV), {}) + assert.deepEqual(await readPreferences(memFs({ [FILE]: JSON.stringify([APP_A]) }), ENV), {}) +}) + +test('writePreferences persists sanitized prefs and preserves the project list', async () => { + const fs = memFs({ [FILE]: JSON.stringify([APP_A, APP_B]) }) + await writePreferences({ autopilot: false, technical: true, bogus: 3 } as never, fs, ENV) + assert.deepEqual(JSON.parse(fs.files.get(FILE)!), { + projects: [APP_A, APP_B], + preferences: { autopilot: false, technical: true }, + }) + // The project list still reads back unchanged. + assert.deepEqual(await listProjects(fs, ENV), [APP_A, APP_B]) +}) + +test('addProject preserves existing preferences', async () => { + const fs = memFs({ [FILE]: JSON.stringify({ projects: [APP_A], preferences: { autopilot: false } }) }) + await addProject('/repos/app-b', APP_B.addedAt, fs, ENV) + assert.deepEqual(JSON.parse(fs.files.get(FILE)!), { + projects: [APP_A, APP_B], + preferences: { autopilot: false }, + }) +}) + +test('registryPreferencesStore round-trips through the same file', async () => { + const fs = memFs() + const store = registryPreferencesStore(fs, ENV) + assert.deepEqual(await store.read(), {}) + await store.save({ vanilla: true }) + assert.deepEqual(await store.read(), { vanilla: true }) +}) diff --git a/packages/framework/src/registry.ts b/packages/framework/src/registry.ts index 12e187c..ee0f489 100644 --- a/packages/framework/src/registry.ts +++ b/packages/framework/src/registry.ts @@ -4,8 +4,8 @@ import { basename, dirname, join, resolve } from 'node:path' * The multi-project registry (#390): the list of projects the user has * installed The Framework into, kept as a single JSON file `.bashrc`-style — * `$HOME/.the-framework.json` — so it is the user's responsibility to re-create - * per machine. Stores only `{id, path, addedAt}` per project; the daemon and - * UI over it are separate concerns. + * per machine. The same file also holds the user's dashboard preferences (#410), + * so the daemon owns one user file and the UI never needs localStorage. */ /** One registered project. */ @@ -18,6 +18,39 @@ export interface ProjectRecord { addedAt: string } +/** + * The dashboard's Global options (#410), persisted next to the project list so they + * survive restarts without localStorage — the daemon reads/writes them, the SPA reads + * them over Telefunc. Flat booleans mirroring the Start form's toggles; every field is + * optional and absent means off (Autopilot still defaults on in the UI). + */ +export interface Preferences { + autopilot?: boolean + technical?: boolean + vanilla?: boolean + eco?: boolean + ecoPlanning?: boolean + ecoResearch?: boolean + ecoMaintenance?: boolean +} + +/** + * The persisted registry file shape (#410): the project list plus the user preferences. + * Older installs wrote a bare `ProjectRecord[]`; {@link readRegistry} still reads those and + * the next write migrates the file to this object form. + */ +export interface Registry { + projects: ProjectRecord[] + preferences: Preferences +} + +/** A read/write handle for the user preferences, threaded through the dashboard's Telefunc + * context so a public host (the relay) can leave it unwired. */ +export interface PreferencesStore { + read(): Promise + save(preferences: Preferences): Promise +} + /** The registry file name: a single file under `$XDG_CONFIG_HOME` (dotted under `$HOME`). */ export const REGISTRY_FILE = 'the-framework.json' @@ -86,24 +119,11 @@ function isRecord(value: unknown): value is ProjectRecord { return typeof record.id === 'string' && typeof record.path === 'string' && typeof record.addedAt === 'string' } -/** - * Read the registry. Forgiving: a missing / unreadable / malformed / non-array - * file yields `[]`, never throws. Deduped by resolved path, first wins. - */ -export async function listProjects( - fs: RegistryFs = nodeRegistryFs(), - env: NodeJS.ProcessEnv = process.env, -): Promise { - let parsed: unknown - try { - parsed = JSON.parse(await fs.read(registryPath(env))) - } catch { - return [] - } - if (!Array.isArray(parsed)) return [] +/** Keep well-formed records, deduped by resolved path (first wins). */ +function dedupeProjects(values: unknown[]): ProjectRecord[] { const seen = new Set() const projects: ProjectRecord[] = [] - for (const value of parsed) { + for (const value of values) { if (!isRecord(value)) continue const key = resolve(value.path) if (seen.has(key)) continue @@ -113,10 +133,74 @@ export async function listProjects( return projects } +const PREFERENCE_KEYS = [ + 'autopilot', + 'technical', + 'vanilla', + 'eco', + 'ecoPlanning', + 'ecoResearch', + 'ecoMaintenance', +] as const + +/** Keep only the known boolean preference fields, so a hand-edited or browser-supplied + * object never lands junk (or non-booleans) in the user's home file. */ +function sanitizePreferences(value: unknown): Preferences { + if (typeof value !== 'object' || value === null) return {} + const input = value as Record + const preferences: Preferences = {} + for (const key of PREFERENCE_KEYS) { + if (typeof input[key] === 'boolean') preferences[key] = input[key] as boolean + } + return preferences +} + +/** + * Read the whole registry. Forgiving: a missing / unreadable / malformed file yields an + * empty registry, never throws. Accepts both the current object form and the legacy bare + * `ProjectRecord[]` (pre-#410), so old installs keep working; projects are deduped by + * resolved path and unknown preference fields are dropped. + */ +export async function readRegistry( + fs: RegistryFs = nodeRegistryFs(), + env: NodeJS.ProcessEnv = process.env, +): Promise { + let parsed: unknown + try { + parsed = JSON.parse(await fs.read(registryPath(env))) + } catch { + return { projects: [], preferences: {} } + } + // Legacy format: a bare array of project records. Migrated to the object form on next write. + if (Array.isArray(parsed)) return { projects: dedupeProjects(parsed), preferences: {} } + if (typeof parsed !== 'object' || parsed === null) return { projects: [], preferences: {} } + const obj = parsed as Record + const projects = Array.isArray(obj.projects) ? dedupeProjects(obj.projects) : [] + return { projects, preferences: sanitizePreferences(obj.preferences) } +} + +/** Write the registry back as pretty object-form JSON, creating the parent dir. */ +async function writeRegistry(registry: Registry, fs: RegistryFs, env: NodeJS.ProcessEnv): Promise { + const file = registryPath(env) + await fs.mkdir(dirname(file)) + await fs.write(file, JSON.stringify(registry, null, 2)) +} + +/** + * Read the registry's project list. Forgiving: a missing / unreadable / malformed + * file yields `[]`, never throws. Deduped by resolved path, first wins. + */ +export async function listProjects( + fs: RegistryFs = nodeRegistryFs(), + env: NodeJS.ProcessEnv = process.env, +): Promise { + return (await readRegistry(fs, env)).projects +} + /** * Register a project. Idempotent by resolved path: when the path is already * registered, the existing record is returned untouched (addedAt survives); - * otherwise the new record is appended and the file written back. + * otherwise the new record is appended and the file written back (preferences preserved). */ export async function addProject( path: string, @@ -125,30 +209,58 @@ export async function addProject( env: NodeJS.ProcessEnv = process.env, ): Promise { const absolute = resolve(path) - const projects = await listProjects(fs, env) - const existing = projects.find(project => resolve(project.path) === absolute) + const registry = await readRegistry(fs, env) + const existing = registry.projects.find(project => resolve(project.path) === absolute) if (existing) return existing const record: ProjectRecord = { id: projectId(absolute), path: absolute, addedAt } - projects.push(record) - const file = registryPath(env) - await fs.mkdir(dirname(file)) - await fs.write(file, JSON.stringify(projects, null, 2)) + registry.projects.push(record) + await writeRegistry(registry, fs, env) return record } /** - * Drop the project whose id matches and write the list back. Returns whether a - * record was removed; an empty/missing registry is a no-write `false`. + * Drop the project whose id matches and write the list back (preferences preserved). + * Returns whether a record was removed; an empty/missing registry is a no-write `false`. */ export async function removeProject( id: string, fs: RegistryFs = nodeRegistryFs(), env: NodeJS.ProcessEnv = process.env, ): Promise { - const projects = await listProjects(fs, env) - const remaining = projects.filter(project => project.id !== id) - if (remaining.length === projects.length) return false - await fs.write(registryPath(env), JSON.stringify(remaining, null, 2)) + const registry = await readRegistry(fs, env) + const remaining = registry.projects.filter(project => project.id !== id) + if (remaining.length === registry.projects.length) return false + await writeRegistry({ projects: remaining, preferences: registry.preferences }, fs, env) return true } + +/** The user's dashboard preferences (#410), or `{}` when none are stored. */ +export async function readPreferences( + fs: RegistryFs = nodeRegistryFs(), + env: NodeJS.ProcessEnv = process.env, +): Promise { + return (await readRegistry(fs, env)).preferences +} + +/** Persist the dashboard preferences (#410), sanitized, preserving the project list. */ +export async function writePreferences( + preferences: Preferences, + fs: RegistryFs = nodeRegistryFs(), + env: NodeJS.ProcessEnv = process.env, +): Promise { + const registry = await readRegistry(fs, env) + await writeRegistry({ projects: registry.projects, preferences: sanitizePreferences(preferences) }, fs, env) +} + +/** A {@link PreferencesStore} bound to the real registry file, wired by the daemon so the + * dashboard's preferences RPCs read/write the user's home file. */ +export function registryPreferencesStore( + fs: RegistryFs = nodeRegistryFs(), + env: NodeJS.ProcessEnv = process.env, +): PreferencesStore { + return { + read: () => readPreferences(fs, env), + save: preferences => writePreferences(preferences, fs, env), + } +}