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), + } +}