diff --git a/.changeset/settings-files-foundation.md b/.changeset/settings-files-foundation.md new file mode 100644 index 000000000..f4e003456 --- /dev/null +++ b/.changeset/settings-files-foundation.md @@ -0,0 +1,14 @@ +--- +'@conciv/core': patch +--- + +Files-based layered settings foundation: a namespaced settings registry in `@conciv/protocol` +(`appearance.scheme`), a `settings` oRPC group (`get`/`set`/`clear`/`applyGlobally`/`history`) +resolving the project layer under `/.conciv/` over the global layer under `~/.conciv/` +over registry defaults. Each layer honors `settings.jsonc` if present, otherwise `settings.json`; +comments are supported in `.jsonc` and survive programmatic writes. Includes per-layer content +revisions for optimistic concurrency, atomic persistence, a cross-process lock on the shared global +file, an append-only history sidecar, and live settings-changed events broadcast to every attached +session. + +Adds dependencies on `c12`, `jsonc-parser`, `write-file-atomic` and `proper-lockfile`. diff --git a/packages/contract/src/contract.ts b/packages/contract/src/contract.ts index 58fe4418a..b710f0b77 100644 --- a/packages/contract/src/contract.ts +++ b/packages/contract/src/contract.ts @@ -23,6 +23,7 @@ import { import {BundlerConfigSchema, ModuleNodeSchema} from '@conciv/protocol/bundler-types' import {SessionCapturesSchema} from '@conciv/protocol/element-capture-types' import {TOOL_ICON_KEYS} from '@conciv/protocol/tool-icon-types' +import {SettingsHistoryEntrySchema, SettingsReadSchema, SettingsScopeSchema} from '@conciv/protocol/settings-types' import {DraftRowSchema, MarkerRowSchema, SessionMetaSchema} from './rows.js' const StreamChunkSchema = z.custom((value) => typeof value === 'object' && value !== null) @@ -36,6 +37,18 @@ export const ChatSendInput = SessionIdInput.extend({ const Ok = z.object({ok: z.literal(true)}) const SendAccepted = z.object({ok: z.literal(true), runId: z.string()}) const NavigationWriteResult = z.object({ok: z.literal(true), applied: z.boolean()}) +const SettingsWriteResult = z.object({ok: z.literal(true), opId: z.string()}) +const settingsWriteErrors = { + UNKNOWN_KEY: {message: 'no registered setting with that key'}, + INVALID_VALUE: {message: 'the value failed the registry schema for this key'}, + REVISION_CONFLICT: { + status: 409, + message: 'the settings file changed since it was read; refetch and retry', + data: z.object({scope: SettingsScopeSchema, revision: z.string()}), + }, + LAYER_UNPARSEABLE: {message: 'the settings file does not parse; fix it before writing'}, + LOCK_TIMEOUT: {message: 'another process is holding the global settings lock'}, +} const notFound = {NOT_FOUND: {message: 'session not found'}} const noBundler = {NO_BUNDLER: {message: 'no bundler bridge'}} const approvalDenied = {APPROVAL_DENIED: {message: 'the call was not approved'}} @@ -115,6 +128,44 @@ export const contract = { get: oc.output(NavigationWriteSchema.nullable()), set: oc.input(NavigationWriteSchema).output(NavigationWriteResult), }, + settings: { + get: oc.output(SettingsReadSchema), + set: oc + .errors(settingsWriteErrors) + .input( + z.object({ + key: z.string(), + value: z.unknown(), + scope: SettingsScopeSchema, + expectedRevision: z.string(), + }), + ) + .output(SettingsWriteResult), + clear: oc + .errors(settingsWriteErrors) + .input(z.object({key: z.string(), scope: SettingsScopeSchema, expectedRevision: z.string()})) + .output(SettingsWriteResult), + applyGlobally: oc + .errors(settingsWriteErrors) + .input( + z.object({ + key: z.string(), + value: z.unknown(), + expectedRevisions: z.object({project: z.string(), global: z.string()}), + }), + ) + .output(SettingsWriteResult), + reset: oc + .errors(settingsWriteErrors) + .input( + z.object({ + key: z.string(), + expectedRevisions: z.object({project: z.string(), global: z.string()}), + }), + ) + .output(SettingsWriteResult), + history: oc.input(z.object({key: z.string()})).output(z.array(SettingsHistoryEntrySchema)), + }, chat: { subscribe: oc.input(SessionIdInput).output(eventIterator(StreamChunkSchema)), send: oc diff --git a/packages/core/package.json b/packages/core/package.json index 278c90182..5d73b2c59 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -87,9 +87,13 @@ "@tanstack/ai-isolate-quickjs": "catalog:", "@tanstack/ai-sandbox": "0.3.0", "@tanstack/ai-sandbox-local-process": "^0.2.1", + "c12": "~3.3.4", "drizzle-orm": "1.0.0-rc.4", "hono": "^4.12.0", + "jsonc-parser": "^3.3.1", "lucide-solid": "^1.18.0", + "proper-lockfile": "^4.1.2", + "write-file-atomic": "^8.0.0", "zod": "^4.4.3" }, "devDependencies": { @@ -102,6 +106,8 @@ "@tanstack/ai-mcp": "catalog:", "@tanstack/ai-sandbox-local-process": "^0.2.1", "@types/node": "^22.19.21", + "@types/proper-lockfile": "^4.1.4", + "@types/write-file-atomic": "^4.0.3", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.8", "get-port": "^7.1.0", diff --git a/packages/core/src/api/rpc/mount.ts b/packages/core/src/api/rpc/mount.ts index ced9d8855..89cee4d56 100644 --- a/packages/core/src/api/rpc/mount.ts +++ b/packages/core/src/api/rpc/mount.ts @@ -8,6 +8,7 @@ import type {CompositeRpcRouter as CompositeRouterOf} from '@conciv/extension/rp import type {ChatDeps} from '../../chat/runtime.js' import type {OpenSourceFrames, OpenSourceStatus} from '../../editor/open-source.js' import type {CoreRuntime, SessionScope} from '../../runtime/scope-types.js' +import type {SettingsService} from '../../settings/service.js' import {runWithSession} from '../../runtime/session-context.js' import type {makeRpcRouter} from './router.js' @@ -16,6 +17,7 @@ export type RpcDeps = { tools: ChatTool[] openFromFrames: (frames: OpenSourceFrames) => Promise runtime: CoreRuntime + settings: SettingsService staleness: () => EngineStaleness askTimeoutMs?: number } diff --git a/packages/core/src/api/rpc/router.ts b/packages/core/src/api/rpc/router.ts index 776dcfe7f..b60ede9fe 100644 --- a/packages/core/src/api/rpc/router.ts +++ b/packages/core/src/api/rpc/router.ts @@ -11,6 +11,8 @@ import {listCommands} from '../../chat/commands.js' import {makeAskGate, requiresApproval} from '../../chat/gate.js' import {rowById} from '../../chat/session-rows.js' import {session} from '../../runtime/session-context.js' +import type {SettingsWriteOutcome} from '../../settings/service.js' +import type {SettingsScope} from '@conciv/protocol/settings-types' import {chatRouter} from './chat.js' import {harnessMetaOf, sessionsRouter} from './sessions.js' import {makeSessionOs, os, type RpcDeps} from './mount.js' @@ -36,6 +38,30 @@ function hasErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } +const SETTINGS_RPC_ACTOR = 'user' + +type SettingsErrors = { + UNKNOWN_KEY: () => Error + INVALID_VALUE: () => Error + REVISION_CONFLICT: (options: {data: {scope: SettingsScope; revision: string}}) => Error + LAYER_UNPARSEABLE: (options: {message: string}) => Error + LOCK_TIMEOUT: () => Error +} + +function settled(outcome: SettingsWriteOutcome, errors: SettingsErrors): {ok: true; opId: string} { + if (outcome.ok) return {ok: true, opId: outcome.opId} + const failure = outcome.failure + if (failure.kind === 'unknown-key') throw errors.UNKNOWN_KEY() + if (failure.kind === 'invalid-value') throw errors.INVALID_VALUE() + if (failure.kind === 'lock-timeout') throw errors.LOCK_TIMEOUT() + if (failure.kind === 'revision-conflict') { + throw errors.REVISION_CONFLICT({data: {scope: failure.scope, revision: failure.revision}}) + } + throw errors.LAYER_UNPARSEABLE({ + message: `the ${failure.scope} settings file does not parse; fix it before writing`, + }) +} + type ApprovalErrors = {APPROVAL_DENIED: (options: {message: string}) => Error} async function approveAskGatedCall(deps: RpcDeps, name: string, input: unknown, errors: ApprovalErrors): Promise { @@ -107,6 +133,22 @@ export function makeRpcRouter(deps: RpcDeps) { get: os.navigation.get.handler(() => engine.navigation.get()), set: os.navigation.set.handler(({input}) => engine.navigation.set(input)), }, + settings: { + get: os.settings.get.handler(() => deps.settings.read()), + set: os.settings.set.handler(async ({input, errors}) => + settled(await deps.settings.set({...input, actor: SETTINGS_RPC_ACTOR}), errors), + ), + clear: os.settings.clear.handler(async ({input, errors}) => + settled(await deps.settings.clear({...input, actor: SETTINGS_RPC_ACTOR}), errors), + ), + applyGlobally: os.settings.applyGlobally.handler(async ({input, errors}) => + settled(await deps.settings.applyGlobally({...input, actor: SETTINGS_RPC_ACTOR}), errors), + ), + reset: os.settings.reset.handler(async ({input, errors}) => + settled(await deps.settings.reset({...input, actor: SETTINGS_RPC_ACTOR}), errors), + ), + history: os.settings.history.handler(({input}) => deps.settings.history(input.key)), + }, registry: { catalog: os.registry.catalog.handler(() => engine.catalog()), call: sessionOs.registry.call.handler(async ({input, context, errors}) => { diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index aaac17050..28006b505 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -59,6 +59,10 @@ import type {CompositeRpcRouter} from './api/rpc/mount.js' import pageServerExtension from '@conciv/extension-page/server' import {PAGE_TOOL_PREFIX} from '@conciv/extension-page/defs' import {logError} from './lib/debug.js' +import {concivHomeDir} from './lib/conciv-home.js' +import {makeSettingsService} from './settings/service.js' +import {SETTINGS_CHANGED_EVENT, settingsRegistry} from '@conciv/protocol/settings-types' +import {EventType} from '@tanstack/ai' import {engineStaleness} from './lib/engine-stamp.js' import type {OpenInEditor} from './editor/open.js' @@ -94,6 +98,8 @@ export type MakeAppOpts = { nativeUrl?: () => string | undefined staleness?: () => EngineStaleness + + globalSettingsDir?: string } export function slug(name: string): string { @@ -315,6 +321,13 @@ export async function makeApp(opts: MakeAppOpts): Promise { openInEditor: opts.openInEditor, }) const {asks, liveRuns, registry, stream} = primitives + const settings = makeSettingsService({ + projectStateDir: concivStateDir(opts.cfg.stateRoot), + globalStateDir: opts.globalSettingsDir ?? concivHomeDir(), + registry: settingsRegistry, + notify: (payload) => + stream.publishAll({type: EventType.CUSTOM, name: SETTINGS_CHANGED_EVENT, value: payload, timestamp: Date.now()}), + }) const rows = {db, harnessKind: harness.id, cwd: opts.cwd} const scopedToolCall: ScopedToolCall = (name, input, request) => runtime.forSession(request.sessionId).tools.call(name, input, {toolCallId: request.toolCallId}) @@ -489,6 +502,7 @@ export async function makeApp(opts: MakeAppOpts): Promise { tools: toolList, openFromFrames: (frames) => openSourceFromFrames(frames, opts.cwd, opts.openInEditor), runtime, + settings, staleness, ...(opts.askTimeoutMs === undefined ? {} : {askTimeoutMs: opts.askTimeoutMs}), }) @@ -531,6 +545,7 @@ export async function makeApp(opts: MakeAppOpts): Promise { const drained = await drainWithDeadline(runControl.drain(), RUN_DRAIN_TIMEOUT_MS) if (!drained) logError('[core] disposed with run(s) still in flight') for (const disposer of disposers) await Promise.resolve(disposer()).catch(() => {}) + settings.dispose() db.$client.close() } diff --git a/packages/core/src/chat/subscribe.ts b/packages/core/src/chat/subscribe.ts index 49d4085bd..67b579faa 100644 --- a/packages/core/src/chat/subscribe.ts +++ b/packages/core/src/chat/subscribe.ts @@ -6,6 +6,7 @@ import type {SessionId} from '@conciv/protocol/chat-types' export type SessionStreams = { publish: (sessionId: SessionId, chunk: StreamChunk) => void + publishAll: (chunk: StreamChunk) => void listen: (sessionId: SessionId, onChunk: (chunk: StreamChunk) => void) => () => void listening: (sessionId: SessionId) => boolean } @@ -16,6 +17,9 @@ export function createSessionStreams(): SessionStreams { publish: (sessionId, chunk) => { for (const listener of bySession.get(sessionId) ?? []) listener(chunk) }, + publishAll: (chunk) => { + for (const listeners of bySession.values()) for (const listener of listeners) listener(chunk) + }, listening: (sessionId) => (bySession.get(sessionId)?.size ?? 0) > 0, listen: (sessionId, onChunk) => { const listeners = bySession.get(sessionId) ?? new Set() diff --git a/packages/core/src/lib/conciv-home.ts b/packages/core/src/lib/conciv-home.ts new file mode 100644 index 000000000..e90c42083 --- /dev/null +++ b/packages/core/src/lib/conciv-home.ts @@ -0,0 +1,7 @@ +import {homedir} from 'node:os' +import {join} from 'node:path' +import {CONCIV_STATE_DIR} from '@conciv/protocol/state-types' + +export function concivHomeDir(): string { + return join(homedir(), CONCIV_STATE_DIR) +} diff --git a/packages/core/src/lib/dev-endpoint.ts b/packages/core/src/lib/dev-endpoint.ts index 8d70140e9..0a700ed18 100644 --- a/packages/core/src/lib/dev-endpoint.ts +++ b/packages/core/src/lib/dev-endpoint.ts @@ -1,8 +1,8 @@ import {randomUUID} from 'node:crypto' import {chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs' -import {homedir} from 'node:os' import {join} from 'node:path' import {z} from 'zod' +import {concivHomeDir} from './conciv-home.js' const FILE_NAME = 'dev-endpoint.json' const FILE_MODE = 0o600 @@ -16,7 +16,7 @@ export const DevEndpointSchema = z.object({ export type DevEndpoint = z.infer export function defaultDevEndpointDir(): string { - return join(homedir(), '.conciv') + return concivHomeDir() } function endpointPath(dir: string): string { diff --git a/packages/core/src/settings/history.ts b/packages/core/src/settings/history.ts new file mode 100644 index 000000000..7f330240b --- /dev/null +++ b/packages/core/src/settings/history.ts @@ -0,0 +1,37 @@ +import {appendFileSync, mkdirSync, readFileSync} from 'node:fs' +import {dirname} from 'node:path' +import {SettingsHistoryEntrySchema, type SettingsHistoryEntry} from '@conciv/protocol/settings-types' +import {logError} from '../lib/debug.js' + +export function appendHistory(path: string, entry: SettingsHistoryEntry): void { + try { + mkdirSync(dirname(path), {recursive: true}) + appendFileSync(path, `${JSON.stringify(entry)}\n`) + } catch (error) { + logError(`[core] the settings history sidecar could not be appended: ${String(error)}`) + } +} + +function parseLine(line: string): SettingsHistoryEntry | null { + try { + const parsed = SettingsHistoryEntrySchema.safeParse(JSON.parse(line)) + return parsed.success ? parsed.data : null + } catch { + return null + } +} + +export function readHistory(path: string, key: string): SettingsHistoryEntry[] { + try { + return readFileSync(path, 'utf8') + .split('\n') + .toReversed() + .flatMap((line) => { + if (line.trim() === '') return [] + const entry = parseLine(line) + return entry !== null && entry.key === key ? [entry] : [] + }) + } catch { + return [] + } +} diff --git a/packages/core/src/settings/layer-store.ts b/packages/core/src/settings/layer-store.ts new file mode 100644 index 000000000..aab69f611 --- /dev/null +++ b/packages/core/src/settings/layer-store.ts @@ -0,0 +1,134 @@ +import {createHash} from 'node:crypto' +import {existsSync, mkdirSync, readFileSync, watch} from 'node:fs' +import {basename, dirname, join} from 'node:path' +import {loadConfig} from 'c12' +import {applyEdits, modify, parse, printParseErrorCode, type ParseError} from 'jsonc-parser' +import writeFileAtomic from 'write-file-atomic' +import {z} from 'zod' +import type {SettingsLayerFormat} from '@conciv/protocol/settings-types' + +export type LayerEdit = {keyPath: readonly string[]; value: unknown} + +export type LayerSnapshot = { + path: string + format: SettingsLayerFormat + text: string + revision: string + data: Record | null + parseError: string | null + warning: string | null +} + +export const JSONC_FILE = 'settings.jsonc' +export const JSON_FILE = 'settings.json' +export const SETTINGS_FILE_NAMES = [JSONC_FILE, JSON_FILE] + +const FORMATTING_OPTIONS = {insertSpaces: true, tabSize: 2, eol: '\n'} +const STRICT_JSON_OPTIONS = {allowTrailingComma: false, disallowComments: true} +const TOLERANT_JSONC_OPTIONS = {allowTrailingComma: true, disallowComments: false} +const LayerObjectSchema = z.record(z.string(), z.unknown()) + +function revisionOf(text: string): string { + return createHash('sha256').update(text).digest('hex').slice(0, 16) +} + +export const EMPTY_REVISION = revisionOf('') + +function readText(path: string): string { + try { + return readFileSync(path, 'utf8') + } catch { + return '' + } +} + +function malformedReason(text: string, format: SettingsLayerFormat): string | null { + const errors: ParseError[] = [] + parse(text, errors, format === 'jsonc' ? TOLERANT_JSONC_OPTIONS : STRICT_JSON_OPTIONS) + const first = errors[0] + if (first === undefined) return null + const line = text.slice(0, first.offset).split('\n').length + return `${printParseErrorCode(first.error)} at line ${line}` +} + +async function loadThroughC12(path: string): Promise | null> { + try { + const loaded = await loadConfig({ + cwd: dirname(path), + name: 'settings', + configFile: basename(path), + rcFile: false, + globalRc: false, + packageJson: false, + dotenv: false, + giget: false, + envName: false, + extend: false, + }) + const object = LayerObjectSchema.safeParse(loaded.config) + return object.success ? object.data : null + } catch { + return null + } +} + +function bothPresentWarning(directory: string): string { + return `both ${JSONC_FILE} and ${JSON_FILE} exist in ${directory}; ${JSONC_FILE} is honored and ${JSON_FILE} is ignored` +} + +export function honoredFile(directory: string): {path: string; format: SettingsLayerFormat; warning: string | null} { + const jsonc = join(directory, JSONC_FILE) + const plain = join(directory, JSON_FILE) + const hasJsonc = existsSync(jsonc) + const hasPlain = existsSync(plain) + if (hasJsonc && hasPlain) return {path: jsonc, format: 'jsonc', warning: bothPresentWarning(directory)} + if (hasJsonc) return {path: jsonc, format: 'jsonc', warning: null} + if (hasPlain) return {path: plain, format: 'json', warning: null} + return {path: jsonc, format: 'absent', warning: null} +} + +export async function readLayer(directory: string): Promise { + const honored = honoredFile(directory) + const text = readText(honored.path) + const revision = revisionOf(text) + const base = {path: honored.path, format: honored.format, text, revision, warning: honored.warning} + if (text.trim() === '') return {...base, data: {}, parseError: null} + const malformed = malformedReason(text, honored.format) + if (malformed !== null) return {...base, data: null, parseError: malformed} + const data = await loadThroughC12(honored.path) + if (data === null) return {...base, data: null, parseError: 'the settings file must hold a JSON object'} + return {...base, data, parseError: null} +} + +export async function writeLayer( + directory: string, + snapshot: LayerSnapshot, + edits: readonly LayerEdit[], +): Promise { + const text = edits.reduce( + (current, edit) => + applyEdits(current, modify(current, [...edit.keyPath], edit.value, {formattingOptions: FORMATTING_OPTIONS})), + snapshot.text.trim() === '' ? '{}\n' : snapshot.text, + ) + mkdirSync(directory, {recursive: true}) + writeFileAtomic.sync(snapshot.path, text, {encoding: 'utf8', fsync: true}) + return readLayer(directory) +} + +export function watchLayers(directories: readonly string[], onChange: () => void, debounceMs: number): () => void { + const timer: {handle: ReturnType | null} = {handle: null} + const schedule = (): void => { + if (timer.handle !== null) clearTimeout(timer.handle) + timer.handle = setTimeout(onChange, debounceMs) + } + const watchers = directories.map((directory) => { + mkdirSync(directory, {recursive: true}) + return watch(directory, (_event, changed) => { + if (changed === null || SETTINGS_FILE_NAMES.includes(changed)) schedule() + }) + }) + return () => { + if (timer.handle !== null) clearTimeout(timer.handle) + for (const watcher of watchers) watcher.close() + } +} diff --git a/packages/core/src/settings/lock.ts b/packages/core/src/settings/lock.ts new file mode 100644 index 000000000..70a61d66f --- /dev/null +++ b/packages/core/src/settings/lock.ts @@ -0,0 +1,25 @@ +import {mkdirSync} from 'node:fs' +import {join} from 'node:path' +import {lock} from 'proper-lockfile' + +const LOCK_FILE = 'settings.lock' +const LOCK_OPTIONS = { + stale: 5_000, + update: 2_000, + realpath: false, + retries: {retries: 12, factor: 1.5, minTimeout: 25, maxTimeout: 500, randomize: true}, +} + +export function isLockContention(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ELOCKED' +} + +export async function withDirectoryLock(directory: string, run: () => Promise): Promise { + mkdirSync(directory, {recursive: true}) + const release = await lock(directory, {...LOCK_OPTIONS, lockfilePath: join(directory, LOCK_FILE)}) + try { + return await run() + } finally { + await release() + } +} diff --git a/packages/core/src/settings/service.ts b/packages/core/src/settings/service.ts new file mode 100644 index 000000000..82ce2614e --- /dev/null +++ b/packages/core/src/settings/service.ts @@ -0,0 +1,364 @@ +import {randomUUID} from 'node:crypto' +import {join} from 'node:path' +import { + SETTINGS_SCOPES, + type SettingsActor, + type SettingsChangedPayload, + type SettingsEntry, + type SettingsHistoryEntry, + type SettingsKeyView, + type SettingsLayerValue, + type SettingsRead, + type SettingsRegistry, + type SettingsScope, + type SettingsSource, +} from '@conciv/protocol/settings-types' +import {appendHistory, readHistory} from './history.js' +import {isLockContention, withDirectoryLock} from './lock.js' +import { + EMPTY_REVISION, + JSONC_FILE, + readLayer, + watchLayers, + writeLayer, + type LayerEdit, + type LayerSnapshot, +} from './layer-store.js' + +const HISTORY_FILE = 'settings-audit.jsonl' +const WATCH_DEBOUNCE_MS = 60 + +export type SettingsWriteFailure = + | {kind: 'unknown-key'} + | {kind: 'invalid-value'} + | {kind: 'revision-conflict'; scope: SettingsScope; revision: string} + | {kind: 'unparseable'; scope: SettingsScope} + | {kind: 'lock-timeout'} + +export type SettingsWriteOutcome = {ok: true; opId: string} | {ok: false; failure: SettingsWriteFailure} + +export type SettingsServiceDeps = { + projectStateDir: string + globalStateDir: string + registry: SettingsRegistry + notify: (payload: SettingsChangedPayload) => void +} + +export type SettingsService = { + read: () => Promise + set: (input: { + key: string + value: unknown + scope: SettingsScope + expectedRevision: string + actor: SettingsActor + }) => Promise + clear: (input: { + key: string + scope: SettingsScope + expectedRevision: string + actor: SettingsActor + }) => Promise + applyGlobally: (input: { + key: string + value: unknown + expectedRevisions: Record + actor: SettingsActor + }) => Promise + reset: (input: { + key: string + expectedRevisions: Record + actor: SettingsActor + }) => Promise + history: (key: string) => SettingsHistoryEntry[] + dispose: () => void +} + +type Change = {scope: SettingsScope; key: string; from: unknown; to: unknown} +type LayerWrite = {scope: SettingsScope; value: unknown} + +function makeSerialQueue(): (task: () => Promise) => Promise { + const chain: {tail: Promise} = {tail: Promise.resolve()} + return (task) => { + const run = chain.tail.then(task, task) + chain.tail = run.catch(() => undefined) + return run + } +} + +function rawValueOf(snapshot: LayerSnapshot, entry: SettingsEntry): unknown { + const namespace = snapshot.data?.[entry.namespace] + if (typeof namespace !== 'object' || namespace === null || Array.isArray(namespace)) return undefined + const values: Record = {...namespace} + return values[entry.name] +} + +function layerValueOf(snapshot: LayerSnapshot, entry: SettingsEntry): SettingsLayerValue { + const raw = rawValueOf(snapshot, entry) + if (raw === undefined) return {state: 'absent', value: undefined} + const parsed = entry.schema.safeParse(raw) + return parsed.success ? {state: 'valid', value: parsed.data} : {state: 'invalid', value: raw} +} + +function sourceOf(project: SettingsLayerValue, global: SettingsLayerValue): SettingsSource { + if (project.state === 'valid') return 'project' + if (global.state === 'valid') return 'global' + return 'default' +} + +function namespaceKeys(snapshot: LayerSnapshot, namespace: string): string[] { + const held = snapshot.data?.[namespace] + if (typeof held !== 'object' || held === null || Array.isArray(held)) return [] + return Object.keys(held) +} + +function planEdits(snapshot: LayerSnapshot, entry: SettingsEntry, value: unknown): LayerEdit[] { + if (value !== undefined) return [{keyPath: [entry.namespace, entry.name], value}] + const remaining = namespaceKeys(snapshot, entry.namespace).filter((name) => name !== entry.name) + if (remaining.length > 0) return [{keyPath: [entry.namespace, entry.name], value: undefined}] + return [{keyPath: [entry.namespace], value: undefined}] +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)] +} + +export function makeSettingsService(deps: SettingsServiceDeps): SettingsService { + const directories: Record = { + project: deps.projectStateDir, + global: deps.globalStateDir, + } + const historyPath = (scope: SettingsScope): string => join(directories[scope], HISTORY_FILE) + const emptySnapshot = (scope: SettingsScope): LayerSnapshot => ({ + path: join(directories[scope], JSONC_FILE), + format: 'absent', + text: '', + revision: EMPTY_REVISION, + data: {}, + parseError: null, + warning: null, + }) + + const layers: Record = { + project: emptySnapshot('project'), + global: emptySnapshot('global'), + } + const enqueue = makeSerialQueue() + + async function refresh(scope: SettingsScope): Promise { + const next = await readLayer(directories[scope]) + const applied = next.parseError === null ? next : {...next, data: layers[scope].data} + layers[scope] = applied + return applied + } + + function viewOf(entry: SettingsEntry): SettingsKeyView { + const project = layerValueOf(layers.project, entry) + const global = layerValueOf(layers.global, entry) + const source = sourceOf(project, global) + const value = source === 'project' ? project.value : source === 'global' ? global.value : entry.fallback + return { + key: entry.key, + namespace: entry.namespace, + label: entry.label, + description: entry.description, + value, + source, + layers: {project, global}, + } + } + + function statusOf(scope: SettingsScope) { + const snapshot = layers[scope] + return { + path: snapshot.path, + format: snapshot.format, + revision: snapshot.revision, + parseError: snapshot.parseError, + warning: snapshot.warning, + } + } + + async function read(): Promise { + for (const scope of SETTINGS_SCOPES) await refresh(scope) + return { + keys: deps.registry.entries.map(viewOf), + layers: {project: statusOf('project'), global: statusOf('global')}, + } + } + + async function guard(scope: SettingsScope, expectedRevision: string): Promise { + const snapshot = await refresh(scope) + if (snapshot.parseError !== null) return {kind: 'unparseable', scope} + if (snapshot.revision !== expectedRevision) return {kind: 'revision-conflict', scope, revision: snapshot.revision} + return null + } + + async function persist( + scope: SettingsScope, + entry: SettingsEntry, + value: unknown, + actor: SettingsActor, + opId: string, + ): Promise { + const snapshot = layers[scope] + const from = rawValueOf(snapshot, entry) + if (from === undefined && value === undefined) return null + layers[scope] = await writeLayer(directories[scope], snapshot, planEdits(snapshot, entry, value)) + const change: Change = {scope, key: entry.key, from, to: value} + appendHistory(historyPath(scope), {ts: Date.now(), actor, opId, ...change}) + return change + } + + function announce(changes: readonly Change[], opId: string): void { + if (changes.length === 0) return + deps.notify({ + opId, + keys: uniqueStrings(changes.map((change) => change.key)), + scopes: SETTINGS_SCOPES.filter((scope) => changes.some((change) => change.scope === scope)), + }) + } + + function validate(key: string, value: unknown): SettingsEntry | SettingsWriteFailure { + const entry = deps.registry.entry(key) + if (entry === undefined) return {kind: 'unknown-key'} + if (value === undefined) return entry + return entry.schema.safeParse(value).success ? entry : {kind: 'invalid-value'} + } + + function isFailure(candidate: SettingsEntry | SettingsWriteFailure): candidate is SettingsWriteFailure { + return 'kind' in candidate + } + + async function underScopeLock(scope: SettingsScope, run: () => Promise): Promise { + if (scope === 'project') return run() + try { + return await withDirectoryLock(directories.global, run) + } catch (error) { + if (isLockContention(error)) return {ok: false, failure: {kind: 'lock-timeout'}} + throw error + } + } + + async function writeOne( + entry: SettingsEntry, + scope: SettingsScope, + value: unknown, + expectedRevision: string, + actor: SettingsActor, + ): Promise { + const blocked = await guard(scope, expectedRevision) + if (blocked !== null) return {ok: false, failure: blocked} + const opId = randomUUID() + const change = await persist(scope, entry, value, actor, opId) + announce(change === null ? [] : [change], opId) + return {ok: true, opId} + } + + function mutate( + key: string, + value: unknown, + scope: SettingsScope, + expectedRevision: string, + actor: SettingsActor, + ): Promise { + return enqueue(async () => { + const entry = validate(key, value) + if (isFailure(entry)) return {ok: false, failure: entry} + const outcome = await underScopeLock(scope, async () => writeOne(entry, scope, value, expectedRevision, actor)) + return outcome + }) + } + + async function writeAcrossLayers( + entry: SettingsEntry, + writes: readonly LayerWrite[], + expectedRevisions: Record, + actor: SettingsActor, + ): Promise { + return underScopeLock('global', async () => { + const blockedGlobal = await guard('global', expectedRevisions.global) + if (blockedGlobal !== null) return {ok: false, failure: blockedGlobal} + const blockedProject = await guard('project', expectedRevisions.project) + if (blockedProject !== null) return {ok: false, failure: blockedProject} + const opId = randomUUID() + const changes: Change[] = [] + for (const write of writes) { + const change = await persist(write.scope, entry, write.value, actor, opId) + if (change !== null) changes.push(change) + } + announce(changes, opId) + return {ok: true, opId} + }) + } + + void enqueue(async () => { + for (const scope of SETTINGS_SCOPES) await refresh(scope) + }) + + return { + read, + set: (input) => mutate(input.key, input.value, input.scope, input.expectedRevision, input.actor), + clear: (input) => mutate(input.key, undefined, input.scope, input.expectedRevision, input.actor), + applyGlobally: (input) => + enqueue(async () => { + const entry = validate(input.key, input.value) + if (isFailure(entry)) return {ok: false, failure: entry} + return writeAcrossLayers( + entry, + [ + {scope: 'global', value: input.value}, + {scope: 'project', value: undefined}, + ], + input.expectedRevisions, + input.actor, + ) + }), + reset: (input) => + enqueue(async () => { + const entry = validate(input.key, undefined) + if (isFailure(entry)) return {ok: false, failure: entry} + return writeAcrossLayers( + entry, + [ + {scope: 'project', value: undefined}, + {scope: 'global', value: undefined}, + ], + input.expectedRevisions, + input.actor, + ) + }), + history: (key) => + SETTINGS_SCOPES.flatMap((scope) => readHistory(historyPath(scope), key)).toSorted((left, right) => + right.ts === left.ts ? 0 : right.ts - left.ts, + ), + dispose: watchLayers( + SETTINGS_SCOPES.map((scope) => directories[scope]), + () => { + void enqueue(async () => { + const changes: Change[] = [] + const moved: SettingsScope[] = [] + for (const scope of SETTINGS_SCOPES) { + const before = layers[scope] + const after = await refresh(scope) + if (after.revision === before.revision) continue + moved.push(scope) + for (const entry of deps.registry.entries) { + const from = rawValueOf(before, entry) + const to = rawValueOf(after, entry) + if (Object.is(from, to)) continue + changes.push({scope, key: entry.key, from, to}) + } + } + if (moved.length === 0) return + const opId = randomUUID() + for (const change of changes) { + appendHistory(historyPath(change.scope), {ts: Date.now(), actor: 'file', opId, ...change}) + } + deps.notify({opId, keys: uniqueStrings(changes.map((change) => change.key)), scopes: moved}) + }) + }, + WATCH_DEBOUNCE_MS, + ), + } +} diff --git a/packages/core/src/start.ts b/packages/core/src/start.ts index 6426dfb99..a34709605 100644 --- a/packages/core/src/start.ts +++ b/packages/core/src/start.ts @@ -34,6 +34,7 @@ export type StartOpts = { harness?: HarnessAdapter nativePageDir?: string devEndpointDir?: string + globalSettingsDir?: string } export type Engine = { @@ -165,6 +166,7 @@ export async function start(opts: StartOpts): Promise { onShutdown: opts.onShutdown, nativePageDir: opts.nativePageDir, nativeUrl, + globalSettingsDir: opts.globalSettingsDir, } const {app, dispose, extensionContexts} = await makeApp(appOpts) diff --git a/packages/core/test/helpers/boot.ts b/packages/core/test/helpers/boot.ts index 8cb273031..c9b5397a1 100644 --- a/packages/core/test/helpers/boot.ts +++ b/packages/core/test/helpers/boot.ts @@ -24,6 +24,7 @@ export type BootOverrides = { bridge?: BundlerBridge firstChunkTimeoutMs?: number askTimeoutMs?: number + globalSettingsDir?: string } function fakeClaudeBinDir(stateRoot: string): string { @@ -73,6 +74,7 @@ export function bootCoreApp(overrides: BootOverrides = {}): BootApp { bridge: overrides.bridge, firstChunkTimeoutMs: overrides.firstChunkTimeoutMs, askTimeoutMs: overrides.askTimeoutMs, + globalSettingsDir: overrides.globalSettingsDir, }) return {fetch: app.fetch, dispose} } diff --git a/packages/core/test/rpc/settings.it.test.ts b/packages/core/test/rpc/settings.it.test.ts new file mode 100644 index 000000000..622eaa6f4 --- /dev/null +++ b/packages/core/test/rpc/settings.it.test.ts @@ -0,0 +1,506 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {afterEach, describe, expect, it} from 'vitest' +import {EventType, type StreamChunk} from '@tanstack/ai' +import {until, type Kit} from '@conciv/harness-testkit' +import { + SETTINGS_CHANGED_EVENT, + SettingsChangedPayloadSchema, + type SettingsChangedPayload, + type SettingsScope, +} from '@conciv/protocol/settings-types' +import {bootKit} from '../helpers/boot.js' + +const SCHEME = 'appearance.scheme' + +type SettingsContext = { + kit: Kit + home: string + projectDir: string + projectFile: string + projectJsonFile: string + globalFile: string +} + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) await cleanup() +}) + +function makeHome(): string { + return mkdtempSync(join(tmpdir(), 'conciv-settings-home-')) +} + +async function bootSettings(home: string = makeHome()): Promise { + const kit = await bootKit({globalSettingsDir: home}) + cleanups.push(() => kit.cleanup()) + const projectDir = join(kit.stateRoot, '.conciv') + return { + kit, + home, + projectDir, + projectFile: join(projectDir, 'settings.jsonc'), + projectJsonFile: join(projectDir, 'settings.json'), + globalFile: join(home, 'settings.jsonc'), + } +} + +function isSettingsChanged(chunk: StreamChunk): boolean { + return chunk.type === EventType.CUSTOM && 'name' in chunk && chunk.name === SETTINGS_CHANGED_EVENT +} + +async function pump(source: AsyncIterable, sink: SettingsChangedPayload[]): Promise { + for await (const chunk of source) { + if (!isSettingsChanged(chunk)) continue + if (!('value' in chunk)) continue + const parsed = SettingsChangedPayloadSchema.safeParse(chunk.value) + if (parsed.success) sink.push(parsed.data) + } +} + +async function watchEvents(kit: Kit): Promise { + const abort = new AbortController() + cleanups.push(async () => abort.abort()) + const sessionId = await kit.session() + const iterator = await kit.rpc.chat.subscribe({sessionId}, {signal: abort.signal}) + const events: SettingsChangedPayload[] = [] + void pump(iterator, events).catch(() => {}) + return events +} + +async function revisionOf(kit: Kit, scope: SettingsScope): Promise { + return (await kit.rpc.settings.get(undefined)).layers[scope].revision +} + +async function setSetting(kit: Kit, value: unknown, scope: SettingsScope, key: string = SCHEME): Promise { + await kit.rpc.settings.set({key, value, scope, expectedRevision: await revisionOf(kit, scope)}) +} + +async function clearSetting(kit: Kit, scope: SettingsScope, key: string = SCHEME): Promise { + await kit.rpc.settings.clear({key, scope, expectedRevision: await revisionOf(kit, scope)}) +} + +async function resetSetting(kit: Kit, key: string = SCHEME): Promise<{ok: true; opId: string}> { + const before = await kit.rpc.settings.get(undefined) + return kit.rpc.settings.reset({ + key, + expectedRevisions: {project: before.layers.project.revision, global: before.layers.global.revision}, + }) +} + +async function schemeView(kit: Kit) { + const read = await kit.rpc.settings.get(undefined) + const view = read.keys.find((entry) => entry.key === SCHEME) + if (!view) throw new Error(`the settings read carried no "${SCHEME}" key`) + return view +} + +function writeFile(path: string, text: string): void { + mkdirSync(join(path, '..'), {recursive: true}) + writeFileSync(path, text) +} + +describe('settings (rpc over the wire, real server, real files in temp dirs)', () => { + it('resolves every registered key to its registry default when no settings file exists', async () => { + const {kit} = await bootSettings() + const view = await schemeView(kit) + expect({value: view.value, source: view.source, layers: view.layers}).toEqual({ + value: 'auto', + source: 'default', + layers: {project: {state: 'absent', value: undefined}, global: {state: 'absent', value: undefined}}, + }) + }) + + it('lets the project layer win over the global layer and reports both raw values', async () => { + const {kit} = await bootSettings() + await setSetting(kit, 'dark', 'global') + await setSetting(kit, 'light', 'project') + const view = await schemeView(kit) + expect({value: view.value, source: view.source}).toEqual({value: 'light', source: 'project'}) + expect(view.layers).toEqual({ + project: {state: 'valid', value: 'light'}, + global: {state: 'valid', value: 'dark'}, + }) + }) + + it('falls through to the global layer, then to the default, as each layer is cleared', async () => { + const {kit} = await bootSettings() + await setSetting(kit, 'dark', 'global') + await setSetting(kit, 'light', 'project') + await clearSetting(kit, 'project') + expect((await schemeView(kit)).source).toBe('global') + expect((await schemeView(kit)).value).toBe('dark') + await clearSetting(kit, 'global') + expect((await schemeView(kit)).source).toBe('default') + expect((await schemeView(kit)).value).toBe('auto') + }) + + it('never writes a default to disk and removes an emptied namespace on clear', async () => { + const {kit, projectFile} = await bootSettings() + await setSetting(kit, 'dark', 'project') + await clearSetting(kit, 'project') + expect(readFileSync(projectFile, 'utf8')).not.toContain('appearance') + }) + + it('preserves keys it does not understand through a programmatic write to a different key', async () => { + const {kit, projectFile} = await bootSettings() + writeFile(projectFile, '{"future": {"flag": 7}, "appearance": {"scheme": "light"}}') + await setSetting(kit, 'dark', 'project') + const onDisk: unknown = JSON.parse(readFileSync(projectFile, 'utf8')) + expect(onDisk).toEqual({future: {flag: 7}, appearance: {scheme: 'dark'}}) + await clearSetting(kit, 'project') + expect(JSON.parse(readFileSync(projectFile, 'utf8'))).toEqual({future: {flag: 7}}) + }) + + it('reports a stored value that fails the registry schema as invalid and resolves past it', async () => { + const {kit, projectFile, globalFile} = await bootSettings() + writeFile(globalFile, '{"appearance": {"scheme": "dark"}}') + writeFile(projectFile, '{"appearance": {"scheme": "neon"}}') + const view = await schemeView(kit) + expect(view.layers.project).toEqual({state: 'invalid', value: 'neon'}) + expect({value: view.value, source: view.source}).toEqual({value: 'dark', source: 'global'}) + }) + + it('serves the last known good values and refuses writes while a settings file does not parse', async () => { + const {kit, projectFile} = await bootSettings() + await setSetting(kit, 'light', 'project') + const revision = await revisionOf(kit, 'project') + writeFile(projectFile, '{ this is not json ') + const read = await kit.rpc.settings.get(undefined) + expect(read.layers.project.parseError).toEqual(expect.any(String)) + expect((await schemeView(kit)).value).toBe('light') + await expect( + kit.rpc.settings.set({key: SCHEME, value: 'dark', scope: 'project', expectedRevision: revision}), + ).rejects.toMatchObject({code: 'LAYER_UNPARSEABLE'}) + expect(readFileSync(projectFile, 'utf8')).toBe('{ this is not json ') + }) + + it('keeps hand written comments and formatting across a set and a clear', async () => { + const {kit, projectFile} = await bootSettings() + writeFile( + projectFile, + '// conciv settings for this project\n{\n /* pinned by hand */\n "appearance": {"scheme": "light"},\n "future": {"flag": 1}\n // trailing note\n}\n', + ) + await setSetting(kit, 'dark', 'project') + const afterSet = readFileSync(projectFile, 'utf8') + expect(afterSet).toContain('// conciv settings for this project') + expect(afterSet).toContain('/* pinned by hand */') + expect(afterSet).toContain('// trailing note') + await clearSetting(kit, 'project') + const afterClear = readFileSync(projectFile, 'utf8') + expect(afterClear).toContain('// conciv settings for this project') + expect(afterClear).toContain('// trailing note') + }) + + it('creates settings.jsonc on the first write when neither settings file exists', async () => { + const {kit, projectFile, projectJsonFile} = await bootSettings() + await setSetting(kit, 'dark', 'project') + expect(existsSync(projectFile)).toBe(true) + expect(existsSync(projectJsonFile)).toBe(false) + expect((await kit.rpc.settings.get(undefined)).layers.project.format).toBe('jsonc') + }) + + it('honors settings.jsonc over settings.json and surfaces a warning when both exist', async () => { + const {kit, projectFile, projectJsonFile} = await bootSettings() + writeFile(projectJsonFile, '{"appearance": {"scheme": "light"}}') + writeFile(projectFile, '{"appearance": {"scheme": "dark"}}') + const read = await kit.rpc.settings.get(undefined) + expect(read.layers.project.format).toBe('jsonc') + expect(read.layers.project.path).toBe(projectFile) + expect(read.layers.project.warning).toEqual(expect.any(String)) + expect((await schemeView(kit)).value).toBe('dark') + }) + + it('reads a plain settings.json when no jsonc file is present', async () => { + const {kit, projectJsonFile} = await bootSettings() + writeFile(projectJsonFile, '{"appearance": {"scheme": "light"}}') + const read = await kit.rpc.settings.get(undefined) + expect(read.layers.project.format).toBe('json') + expect(read.layers.project.warning).toBeNull() + expect((await schemeView(kit)).value).toBe('light') + }) + + it('treats a comment in settings.json as malformed rather than reinterpreting it as jsonc', async () => { + const {kit, projectJsonFile} = await bootSettings() + writeFile(projectJsonFile, '{\n // not allowed here\n "appearance": {"scheme": "dark"}\n}\n') + const read = await kit.rpc.settings.get(undefined) + expect(read.layers.project.format).toBe('json') + expect(read.layers.project.parseError).toEqual(expect.any(String)) + expect((await schemeView(kit)).value).toBe('auto') + await expect( + kit.rpc.settings.set({ + key: SCHEME, + value: 'light', + scope: 'project', + expectedRevision: read.layers.project.revision, + }), + ).rejects.toMatchObject({code: 'LAYER_UNPARSEABLE'}) + }) + + it('keeps hand written comments in settings.jsonc across a set, a clear and an applyGlobally', async () => { + const {kit, projectFile} = await bootSettings() + writeFile( + projectFile, + '// conciv settings for this project\n{\n "appearance": {"scheme": "light"},\n "future": {"flag": 1}\n // trailing note\n}\n', + ) + await setSetting(kit, 'dark', 'project') + expect(readFileSync(projectFile, 'utf8')).toContain('// conciv settings for this project') + expect(readFileSync(projectFile, 'utf8')).toContain('// trailing note') + const before = await kit.rpc.settings.get(undefined) + await kit.rpc.settings.applyGlobally({ + key: SCHEME, + value: 'light', + expectedRevisions: {project: before.layers.project.revision, global: before.layers.global.revision}, + }) + const afterApply = readFileSync(projectFile, 'utf8') + expect(afterApply).toContain('// conciv settings for this project') + expect(afterApply).toContain('// trailing note') + expect(afterApply).not.toContain('appearance') + }) + + it('replaces the settings file atomically, leaving no partial content or temp files behind', async () => { + const {kit, projectFile} = await bootSettings() + const dir = join(projectFile, '..') + const observed: string[] = [] + const reader = {stop: false} + async function readWhileWriting(): Promise { + while (!reader.stop) { + try { + observed.push(readFileSync(projectFile, 'utf8')) + } catch { + observed.push('{}') + } + await new Promise((resolve) => setTimeout(resolve, 1)) + } + } + const reading = readWhileWriting() + for (const value of ['dark', 'light', 'auto', 'dark', 'light']) await setSetting(kit, value, 'project') + reader.stop = true + await reading + for (const text of observed) expect(() => JSON.parse(text)).not.toThrow() + expect(readdirSync(dir).filter((name) => name.includes('.tmp'))).toEqual([]) + }) + + it('emits exactly one settings-changed event for one server write', async () => { + const {kit} = await bootSettings() + const events = await watchEvents(kit) + await setSetting(kit, 'dark', 'project') + await until(() => events.length >= 1) + await new Promise((resolve) => setTimeout(resolve, 400)) + expect(events.filter((event) => event.keys.includes(SCHEME))).toHaveLength(1) + }) + + it('notices an external hand edit, emits the change and records it with the file actor', async () => { + const {kit, projectFile} = await bootSettings() + const events = await watchEvents(kit) + writeFile(projectFile, '{"appearance": {"scheme": "dark"}}') + await until(() => events.length >= 1, {hangGuardMs: 8000}) + expect((await schemeView(kit)).value).toBe('dark') + await until(async () => (await kit.rpc.settings.history({key: SCHEME})).some((entry) => entry.actor === 'file'), { + hangGuardMs: 8000, + }) + const entry = (await kit.rpc.settings.history({key: SCHEME})).find((line) => line.actor === 'file') + expect(entry).toMatchObject({scope: 'project', key: SCHEME, to: 'dark'}) + }) + + it('records a history line per user write, carrying the actor and an operation id', async () => { + const {kit} = await bootSettings() + await setSetting(kit, 'dark', 'project') + await setSetting(kit, 'light', 'project') + await clearSetting(kit, 'project') + const history = await kit.rpc.settings.history({key: SCHEME}) + expect(history.map((entry) => ({actor: entry.actor, scope: entry.scope, from: entry.from, to: entry.to}))).toEqual([ + {actor: 'user', scope: 'project', from: 'light', to: undefined}, + {actor: 'user', scope: 'project', from: 'dark', to: 'light'}, + {actor: 'user', scope: 'project', from: undefined, to: 'dark'}, + ]) + expect(new Set(history.map((entry) => entry.opId)).size).toBe(3) + }) + + it('rejects a write whose expected revision is stale', async () => { + const {kit} = await bootSettings() + const stale = await revisionOf(kit, 'project') + await setSetting(kit, 'dark', 'project') + await expect( + kit.rpc.settings.set({key: SCHEME, value: 'light', scope: 'project', expectedRevision: stale}), + ).rejects.toMatchObject({code: 'REVISION_CONFLICT'}) + expect((await schemeView(kit)).value).toBe('dark') + }) + + it('applies a value to every project in one operation: global written, project override gone', async () => { + const {kit} = await bootSettings() + const events = await watchEvents(kit) + await setSetting(kit, 'light', 'project') + const before = await kit.rpc.settings.get(undefined) + const seen = events.length + const result = await kit.rpc.settings.applyGlobally({ + key: SCHEME, + value: 'dark', + expectedRevisions: {project: before.layers.project.revision, global: before.layers.global.revision}, + }) + const view = await schemeView(kit) + expect({value: view.value, source: view.source, layers: view.layers}).toEqual({ + value: 'dark', + source: 'global', + layers: {project: {state: 'absent', value: undefined}, global: {state: 'valid', value: 'dark'}}, + }) + await until(() => events.length > seen) + await new Promise((resolve) => setTimeout(resolve, 400)) + expect(events.slice(seen)).toHaveLength(1) + const shared = (await kit.rpc.settings.history({key: SCHEME})).filter((entry) => entry.opId === result.opId) + expect(shared.map((entry) => entry.scope).toSorted()).toEqual(['global', 'project']) + }) + + it('resets a key from both layers in one operation, emitting a single event and one history row per layer', async () => { + const {kit} = await bootSettings() + await setSetting(kit, 'light', 'project') + await setSetting(kit, 'dark', 'global') + const events = await watchEvents(kit) + const seen = events.length + const result = await resetSetting(kit) + const view = await schemeView(kit) + expect({value: view.value, source: view.source, layers: view.layers}).toEqual({ + value: 'auto', + source: 'default', + layers: {project: {state: 'absent', value: undefined}, global: {state: 'absent', value: undefined}}, + }) + await until(() => events.length > seen) + await new Promise((resolve) => setTimeout(resolve, 400)) + expect(events.slice(seen)).toHaveLength(1) + const shared = (await kit.rpc.settings.history({key: SCHEME})).filter((entry) => entry.opId === result.opId) + expect( + shared + .map((entry) => ({scope: entry.scope, from: entry.from, to: entry.to})) + .toSorted((a, b) => a.scope.localeCompare(b.scope)), + ).toEqual([ + {scope: 'global', from: 'dark', to: undefined}, + {scope: 'project', from: 'light', to: undefined}, + ]) + }) + + it('no-ops the layer that has no value for the key during a reset, with no history row for it', async () => { + const {kit} = await bootSettings() + await setSetting(kit, 'light', 'project') + const before = await kit.rpc.settings.get(undefined) + const result = await resetSetting(kit) + const view = await schemeView(kit) + expect(view.layers).toEqual({ + project: {state: 'absent', value: undefined}, + global: {state: 'absent', value: undefined}, + }) + expect((await kit.rpc.settings.get(undefined)).layers.global.revision).toBe(before.layers.global.revision) + const shared = (await kit.rpc.settings.history({key: SCHEME})).filter((entry) => entry.opId === result.opId) + expect(shared.map((entry) => entry.scope)).toEqual(['project']) + }) + + it('rejects a reset whose expected revision is stale and leaves both layers untouched', async () => { + const {kit} = await bootSettings() + const staleProject = await revisionOf(kit, 'project') + await setSetting(kit, 'dark', 'project') + const global = await revisionOf(kit, 'global') + await expect( + kit.rpc.settings.reset({key: SCHEME, expectedRevisions: {project: staleProject, global}}), + ).rejects.toMatchObject({code: 'REVISION_CONFLICT'}) + expect((await schemeView(kit)).value).toBe('dark') + }) + + it('refuses a reset while a settings file does not parse, preserving last known good content', async () => { + const {kit, projectFile} = await bootSettings() + await setSetting(kit, 'light', 'project') + writeFile(projectFile, '{ this is not json ') + const read = await kit.rpc.settings.get(undefined) + await expect( + kit.rpc.settings.reset({ + key: SCHEME, + expectedRevisions: {project: read.layers.project.revision, global: read.layers.global.revision}, + }), + ).rejects.toMatchObject({code: 'LAYER_UNPARSEABLE'}) + expect(readFileSync(projectFile, 'utf8')).toBe('{ this is not json ') + }) + + it('keeps hand written comments and formatting in a jsonc layer across a reset', async () => { + const {kit, projectFile} = await bootSettings() + writeFile( + projectFile, + '// conciv settings for this project\n{\n "appearance": {"scheme": "light"},\n "future": {"flag": 1}\n // trailing note\n}\n', + ) + await resetSetting(kit) + const afterReset = readFileSync(projectFile, 'utf8') + expect(afterReset).toContain('// conciv settings for this project') + expect(afterReset).toContain('// trailing note') + expect(afterReset).not.toContain('appearance') + }) + + it('rejects a value the registry schema does not accept and leaves the file untouched', async () => { + const {kit, projectFile} = await bootSettings() + await setSetting(kit, 'dark', 'project') + const before = readFileSync(projectFile, 'utf8') + await expect( + kit.rpc.settings.set({ + key: SCHEME, + value: 'neon', + scope: 'project', + expectedRevision: await revisionOf(kit, 'project'), + }), + ).rejects.toMatchObject({code: 'INVALID_VALUE'}) + expect(readFileSync(projectFile, 'utf8')).toBe(before) + }) + + it('rejects a key with no registry entry', async () => { + const {kit} = await bootSettings() + const revision = await revisionOf(kit, 'project') + await expect( + kit.rpc.settings.set({key: 'nope.nope', value: 'x', scope: 'project', expectedRevision: revision}), + ).rejects.toMatchObject({code: 'UNKNOWN_KEY'}) + await expect( + kit.rpc.settings.clear({key: 'nope.nope', scope: 'project', expectedRevision: revision}), + ).rejects.toMatchObject({code: 'UNKNOWN_KEY'}) + }) + + it('keeps two servers sharing one global settings file from losing each others writes', async () => { + const home = makeHome() + const first = await bootSettings(home) + const second = await bootSettings(home) + writeFile(first.globalFile, '{"future": {"flag": 3}}') + const attempts = await Promise.allSettled([ + setSetting(first.kit, 'dark', 'global'), + setSetting(second.kit, 'light', 'global'), + ]) + expect(attempts.some((attempt) => attempt.status === 'fulfilled')).toBe(true) + const onDisk: unknown = JSON.parse(readFileSync(first.globalFile, 'utf8')) + expect(onDisk).toMatchObject({future: {flag: 3}}) + expect(['dark', 'light']).toContain((await schemeView(first.kit)).layers.global.value) + }) + + it('writes through a symlinked settings file instead of replacing the link', async () => { + const {kit, projectFile} = await bootSettings() + const store = mkdtempSync(join(tmpdir(), 'conciv-settings-store-')) + const real = join(store, 'shared-settings.json') + writeFileSync(real, '{"appearance": {"scheme": "light"}}') + mkdirSync(join(projectFile, '..'), {recursive: true}) + symlinkSync(real, projectFile) + await setSetting(kit, 'dark', 'project') + expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual({appearance: {scheme: 'dark'}}) + expect(statSync(projectFile).isSymbolicLink).toBeDefined() + expect(JSON.parse(readFileSync(projectFile, 'utf8'))).toEqual({appearance: {scheme: 'dark'}}) + }) + + it('preserves the permissions of an existing settings file across a write', async () => { + const {kit, projectFile} = await bootSettings() + writeFile(projectFile, '{"appearance": {"scheme": "light"}}') + chmodSync(projectFile, 0o640) + await setSetting(kit, 'dark', 'project') + expect(statSync(projectFile).mode & 0o777).toBe(0o640) + }) +}) diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 41b3954e9..3fdd7c883 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -89,6 +89,10 @@ "types": "./dist/connect-ports.d.ts", "import": "./dist/connect-ports.js" }, + "./settings-types": { + "types": "./dist/settings-types.d.ts", + "import": "./dist/settings-types.js" + }, "./state-types": { "types": "./dist/state-types.d.ts", "import": "./dist/state-types.js" diff --git a/packages/protocol/src/settings-types.ts b/packages/protocol/src/settings-types.ts new file mode 100644 index 000000000..8dc2868fa --- /dev/null +++ b/packages/protocol/src/settings-types.ts @@ -0,0 +1,150 @@ +import {z} from 'zod' + +export const SETTINGS_CHANGED_EVENT = 'conciv.settings-changed' + +export const SettingsScopeSchema = z.enum(['project', 'global']) +export type SettingsScope = z.infer + +export const SETTINGS_SCOPES = SettingsScopeSchema.options + +export const SettingsSourceSchema = z.enum(['project', 'global', 'default']) +export type SettingsSource = z.infer + +export const SettingsActorSchema = z.enum(['user', 'agent', 'file']) +export type SettingsActor = z.infer + +export const SettingsLayerStateSchema = z.enum(['absent', 'valid', 'invalid']) +export type SettingsLayerState = z.infer + +export const SettingsLayerFormatSchema = z.enum(['jsonc', 'json', 'absent']) +export type SettingsLayerFormat = z.infer + +export type SettingsKeySpec = { + name: string + schema: z.ZodType + fallback: unknown + label: string + description: string +} + +export type SettingsGroupSpec = { + namespace: string + label: string + keys: readonly SettingsKeySpec[] +} + +export type SettingsEntry = { + key: string + namespace: string + name: string + schema: z.ZodType + fallback: unknown + label: string + description: string +} + +export type SettingsRegistry = { + entries: readonly SettingsEntry[] + entry: (key: string) => SettingsEntry | undefined +} + +function entriesOfGroup(group: SettingsGroupSpec): SettingsEntry[] { + const seen: string[] = [] + return group.keys.map((spec) => { + if (seen.includes(spec.name)) { + throw new Error(`settings registry: namespace "${group.namespace}" registers the key "${spec.name}" twice`) + } + seen.push(spec.name) + return { + key: `${group.namespace}.${spec.name}`, + namespace: group.namespace, + name: spec.name, + schema: spec.schema, + fallback: spec.fallback, + label: spec.label, + description: spec.description, + } + }) +} + +export function createSettingsRegistry(groups: readonly SettingsGroupSpec[]): SettingsRegistry { + const namespaces: string[] = [] + const entries: SettingsEntry[] = [] + for (const group of groups) { + if (namespaces.includes(group.namespace)) { + throw new Error(`settings registry: the namespace "${group.namespace}" is registered twice`) + } + namespaces.push(group.namespace) + entries.push(...entriesOfGroup(group)) + } + return { + entries, + entry: (key) => entries.find((candidate) => candidate.key === key), + } +} + +export const APPEARANCE_SETTINGS_GROUP: SettingsGroupSpec = { + namespace: 'appearance', + label: 'Appearance', + keys: [ + { + name: 'scheme', + schema: z.enum(['auto', 'light', 'dark']), + fallback: 'auto', + label: 'Color scheme', + description: 'Whether the widget follows the host page scheme or pins itself to light or dark.', + }, + ], +} + +export const settingsRegistry = createSettingsRegistry([APPEARANCE_SETTINGS_GROUP]) + +export const SettingsLayerValueSchema = z.object({ + state: SettingsLayerStateSchema, + value: z.unknown(), +}) +export type SettingsLayerValue = z.infer + +export const SettingsLayerStatusSchema = z.object({ + path: z.string(), + format: SettingsLayerFormatSchema, + revision: z.string(), + parseError: z.string().nullable(), + warning: z.string().nullable(), +}) +export type SettingsLayerStatus = z.infer + +export const SettingsKeyViewSchema = z.object({ + key: z.string(), + namespace: z.string(), + label: z.string(), + description: z.string(), + value: z.unknown(), + source: SettingsSourceSchema, + layers: z.object({project: SettingsLayerValueSchema, global: SettingsLayerValueSchema}), +}) +export type SettingsKeyView = z.infer + +export const SettingsReadSchema = z.object({ + keys: z.array(SettingsKeyViewSchema), + layers: z.object({project: SettingsLayerStatusSchema, global: SettingsLayerStatusSchema}), +}) +export type SettingsRead = z.infer + +export const SettingsHistoryEntrySchema = z.object({ + ts: z.number(), + actor: SettingsActorSchema, + scope: SettingsScopeSchema, + key: z.string(), + from: z.unknown().optional(), + to: z.unknown().optional(), + opId: z.string(), +}) +export type SettingsHistoryEntry = z.infer + +export const SettingsChangedPayloadSchema = z.object({ + opId: z.string(), + keys: z.array(z.string()), + scopes: z.array(SettingsScopeSchema), +}) +export type SettingsChangedPayload = z.infer diff --git a/packages/protocol/tsdown.config.ts b/packages/protocol/tsdown.config.ts index 4758852ad..3713d24a4 100644 --- a/packages/protocol/tsdown.config.ts +++ b/packages/protocol/tsdown.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ 'src/tool-view-types.ts', 'src/connect-ports.ts', 'src/state-types.ts', + 'src/settings-types.ts', 'src/event-bus.ts', ], format: 'esm', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 581448341..5fbdbf6b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1030,7 +1030,7 @@ importers: version: 4.3.2(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-devtools': specifier: latest - version: 0.10.11(@neodrag/core@3.0.0-next.11)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) + version: 0.10.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) '@tanstack/react-router': specifier: latest version: 1.170.31(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1073,7 +1073,7 @@ importers: version: 0.5.20(tailwindcss@4.3.2) '@tanstack/devtools-vite': specifier: latest - version: 0.8.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 0.8.5(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/router-cli': specifier: ^1.132.0 version: 1.167.18 @@ -1106,7 +1106,7 @@ importers: version: 4.3.2(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-devtools': specifier: latest - version: 0.10.11(@neodrag/core@3.0.0-next.11)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) + version: 0.10.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) '@tanstack/react-router': specifier: latest version: 1.170.31(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1146,7 +1146,7 @@ importers: version: 0.5.20(tailwindcss@4.3.2) '@tanstack/devtools-vite': specifier: latest - version: 0.8.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 0.8.5(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/router-cli': specifier: ^1.132.0 version: 1.167.18 @@ -1713,15 +1713,27 @@ importers: '@tanstack/ai-sandbox-local-process': specifier: ^0.2.1 version: 0.2.1(patch_hash=28ba8577dc4eb723ec2899bf42803c1033d22a6ca3af2951d3d3bd8de5f48521)(@tanstack/ai-sandbox@0.3.0(@tanstack/ai@0.43.1(@opentelemetry/api@1.9.1)(zod@4.4.3))(vitest@4.1.10)(zod@4.4.3)) + c12: + specifier: ~3.3.4 + version: 3.3.4(magicast@0.5.3) drizzle-orm: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4(@electric-sql/pglite@0.3.16)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(zod@4.4.3) hono: specifier: ^4.12.0 version: 4.12.27 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 lucide-solid: specifier: ^1.18.0 version: 1.18.0(solid-js@1.9.14) + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 + write-file-atomic: + specifier: ^8.0.0 + version: 8.0.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -1750,6 +1762,12 @@ importers: '@types/node': specifier: ^22.19.21 version: 22.20.0 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 + '@types/write-file-atomic': + specifier: ^4.0.3 + version: 4.0.3 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -9695,8 +9713,8 @@ packages: peerDependencies: typescript: '>=4.7' - '@tanstack/devtools-bundler-core@0.1.2': - resolution: {integrity: sha512-vU92j/k1oLJ0BEz+lDf/95As8rCmDDkkDk8H6/BUdJ8PtmSyfS2bx4xTbd7QjsTqfIzwcQEBFPUjXYgNKZrRPw==} + '@tanstack/devtools-bundler-core@0.1.3': + resolution: {integrity: sha512-F0tlxIyfFqXkZ1mJP1EjtkiSeJA+ztXY2AYOHf7r4goCIEAOp86N9PFJ/yv8vu1TnmxGS6vKsZCS3Kyls9xQQA==} engines: {node: '>=18'} '@tanstack/devtools-client@0.0.7': @@ -9744,8 +9762,8 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@tanstack/devtools-vite@0.8.4': - resolution: {integrity: sha512-8Rq0Tb9v3atTJtgNVyHWHkZWsnv/QdBCPhSD6Ia+MyfImgtHQMWrMEc7BT3cUVA0M+BH/YXSuBxkiOx5vlGCmQ==} + '@tanstack/devtools-vite@0.8.5': + resolution: {integrity: sha512-xaifCEmiwwzizlbp973oISXNsnmuU/BugLa66gryAZaPJJ/qo1kJd2DxY5X960eHwmyIS3SN0KYrL3/aRhucmA==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -9758,8 +9776,8 @@ packages: peerDependencies: solid-js: '>=1.9.7' - '@tanstack/devtools@0.14.1': - resolution: {integrity: sha512-OURovHbCJNOMHtSHRvJtLMdP32Q7y04m8iGgHmtTAYIpUiqRaRXAZ4Y87odpQKToUOYXCOjXiEREbYJoCB2dQQ==} + '@tanstack/devtools@0.14.2': + resolution: {integrity: sha512-8FVVmDU+x3iEwrl5rtbIhud8gwp9eSXPlhs36SCV59yAtXmheFFvLqLAUXpfIU79sZVBg3LLTy2VlTIXaWbaBw==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -9805,8 +9823,8 @@ packages: '@tanstack/query-core@5.101.2': resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} - '@tanstack/react-devtools@0.10.11': - resolution: {integrity: sha512-kMt61UiKj820XAVoLr0zvRXLa3XACLim8EzoiBOQmVJ/z6MHXCWL4q9TlXsmxHN0H26oI3tmRLArxo6s0kASeA==} + '@tanstack/react-devtools@0.10.12': + resolution: {integrity: sha512-dgoz7TFm97Izo/D34z91PD0h+ufk+eBmoN9OgRHJlj/c7Ol5xpIP7bqLBNByjgt5paRE2eSu5AQHV92Ul+G6iw==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=16.8' @@ -10628,6 +10646,9 @@ packages: '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -10639,6 +10660,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -10651,6 +10675,9 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@types/write-file-atomic@4.0.3': + resolution: {integrity: sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -15911,6 +15938,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -16252,6 +16282,10 @@ packages: retext-smartypants@6.2.0: resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -17794,6 +17828,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@8.0.0: + resolution: {integrity: sha512-dYwyZredl67GyLLIHJnRM3h2PcOmN5SkcgC7eM5DPDEOEl6dLFqVrMg3F1Ea32usj4VSVZtd2H4MtKTNOf6nPg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -20347,9 +20385,9 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.131.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@oxc-parser/binding-wasm32-wasi@0.120.0': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -22753,14 +22791,14 @@ snapshots: '@tanstack/pacer-lite': 0.2.2 typescript: 6.0.3 - '@tanstack/devtools-bundler-core@0.1.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@tanstack/devtools-bundler-core@0.1.3': dependencies: '@tanstack/devtools-client': 0.0.8 '@tanstack/devtools-event-bus': 0.4.3 chalk: 5.6.2 launch-editor: 2.14.1 magic-string: 0.30.21 - oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxc-parser: 0.120.0 picomatch: 4.0.5 transitivePeerDependencies: - '@emnapi/core' @@ -22828,9 +22866,9 @@ snapshots: - bufferutil - utf-8-validate - '@tanstack/devtools-vite@0.8.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/devtools-vite@0.8.5(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@tanstack/devtools-bundler-core': 0.1.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@tanstack/devtools-bundler-core': 0.1.3 '@tanstack/devtools-client': 0.0.8 '@tanstack/devtools-event-bus': 0.4.3 chalk: 5.6.2 @@ -22857,8 +22895,9 @@ snapshots: - csstype - utf-8-validate - '@tanstack/devtools@0.14.1(@neodrag/core@3.0.0-next.11)(csstype@3.2.3)(solid-js@1.9.14)': + '@tanstack/devtools@0.14.2(csstype@3.2.3)(solid-js@1.9.14)': dependencies: + '@neodrag/core': 3.0.0-next.11 '@neodrag/solid': 3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.14) '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.14) '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.14) @@ -22870,7 +22909,6 @@ snapshots: goober: 2.1.19(csstype@3.2.3) solid-js: 1.9.14 transitivePeerDependencies: - - '@neodrag/core' - bufferutil - csstype - utf-8-validate @@ -22922,15 +22960,14 @@ snapshots: '@tanstack/query-core@5.101.2': {} - '@tanstack/react-devtools@0.10.11(@neodrag/core@3.0.0-next.11)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)': + '@tanstack/react-devtools@0.10.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)': dependencies: - '@tanstack/devtools': 0.14.1(@neodrag/core@3.0.0-next.11)(csstype@3.2.3)(solid-js@1.9.14) + '@tanstack/devtools': 0.14.2(csstype@3.2.3)(solid-js@1.9.14) '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: - - '@neodrag/core' - bufferutil - csstype - solid-js @@ -24138,6 +24175,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -24148,6 +24189,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/retry@0.12.5': {} + '@types/trusted-types@2.0.7': {} '@types/unist@2.0.11': {} @@ -24156,6 +24199,10 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} + '@types/write-file-atomic@4.0.3': + dependencies: + '@types/node': 22.20.0 + '@types/ws@8.18.1': dependencies: '@types/node': 22.20.0 @@ -30149,7 +30196,7 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + oxc-parser@0.120.0: dependencies: '@oxc-project/types': 0.120.0 optionalDependencies: @@ -30169,7 +30216,7 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu': 0.120.0 '@oxc-parser/binding-linux-x64-musl': 0.120.0 '@oxc-parser/binding-openharmony-arm64': 0.120.0 - '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@oxc-parser/binding-wasm32-wasi': 0.120.0 '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 '@oxc-parser/binding-win32-x64-msvc': 0.120.0 @@ -30638,6 +30685,12 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} prosemirror-changeset@2.4.1: @@ -31144,6 +31197,8 @@ snapshots: nlcst-to-string: 4.0.0 unist-util-visit: 5.1.0 + retry@0.12.0: {} + reusify@1.1.0: {} roarr@2.15.4: @@ -33191,6 +33246,10 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@8.0.0: + dependencies: + signal-exit: 4.1.0 + ws@8.21.0: {} wsl-utils@0.1.0: