feat(web): open the local settings file
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
|
||||
README.md: 344300c33879918e836b6e208b172343cc472faa
|
||||
README.zh.md: 7e4913c0883c48c23de3408a3b0fe0455160984f
|
||||
README.md: 16d66e3dd876ff58ba1745655b325212acc04ca1
|
||||
README.zh.md: 75c3316bad3005a9e4e895699187a4b16ae8efef
|
||||
|
||||
@@ -26,6 +26,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
|
||||
- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
|
||||
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal.
|
||||
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
|
||||
- **Local configuration surfaces receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser never reconstructs `$DSH_HOME` or submits a filesystem target.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
|
||||
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。
|
||||
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
|
||||
- **本地配置界面会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器绝不重建 `$DSH_HOME`,也不提交文件系统目标。
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
@@ -96,6 +96,11 @@ function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Whether an exclusive file create found an existing document. */
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/** File-backed settings provider (`settings.yaml`/`.json`). */
|
||||
export class SettingsLocal extends Settings {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -139,6 +144,29 @@ export class SettingsLocal extends Settings {
|
||||
return true
|
||||
}
|
||||
|
||||
/** The resolved YAML/JSON document path exposed to local configuration surfaces. */
|
||||
override get documentPath(): string {
|
||||
return this.spec.filename
|
||||
}
|
||||
|
||||
/** Materialize an absent owner-only document, then return its resolved path. */
|
||||
override prepareDocument(): Promise<string> {
|
||||
return this.enqueue(async () => {
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
|
||||
await withFileLock(this.spec.filename, async () => {
|
||||
try {
|
||||
await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 })
|
||||
} catch (error) {
|
||||
if (isEEXIST(error)) return
|
||||
throw error
|
||||
}
|
||||
this.text = ''
|
||||
if (!this.isClosed()) this.publish({})
|
||||
})
|
||||
return this.spec.filename
|
||||
})
|
||||
}
|
||||
|
||||
protected async load(): Promise<Record<string, unknown>> {
|
||||
let text: string
|
||||
try {
|
||||
|
||||
@@ -48,12 +48,37 @@ describe('resolveSpec', () => {
|
||||
describe('boot and reads', () => {
|
||||
it('resolves defaults over an absent file and reports writable', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
|
||||
expect(ctx.settings.writable).toBe(true)
|
||||
expect(ctx.settings.documentPath).toBe(path)
|
||||
})
|
||||
|
||||
it('prepares an absent owner-only document without changing resolved settings', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'nested', 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
|
||||
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
|
||||
expect(await readFile(path, 'utf8')).toBe('')
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
})
|
||||
|
||||
it('preparing an existing document preserves its contents', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const contents = 'ui-theme:\n theme: light\n'
|
||||
await writeFile(path, contents)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
|
||||
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
|
||||
expect(await readFile(path, 'utf8')).toBe(contents)
|
||||
})
|
||||
|
||||
it('reads sections from an existing yaml document', async () => {
|
||||
@@ -77,6 +102,7 @@ describe('boot and reads', () => {
|
||||
it('defaults the file location under the configured harness home', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ dshHome: dir, watch: false })
|
||||
expect(ctx.settings.documentPath).toBe(join(dir, 'settings.yaml'))
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
|
||||
|
||||
@@ -11,6 +11,10 @@ import { SettingsLocal } from '../src/index.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
failTempWrite: false,
|
||||
failDocumentCreate: false,
|
||||
holdDocumentCreate: false,
|
||||
documentCreateStarted: undefined as (() => void) | undefined,
|
||||
continueDocumentCreate: undefined as Promise<void> | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
@@ -18,6 +22,15 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
writeFile: (async (path: unknown, ...rest: never[]) => {
|
||||
if (state.holdDocumentCreate && String(path).endsWith('settings.yaml')) {
|
||||
state.holdDocumentCreate = false
|
||||
state.documentCreateStarted!()
|
||||
await state.continueDocumentCreate!
|
||||
}
|
||||
if (state.failDocumentCreate && String(path).endsWith('settings.yaml')) {
|
||||
state.failDocumentCreate = false
|
||||
throw Object.assign(new Error('ENOSPC: injected document create failure'), { code: 'ENOSPC' })
|
||||
}
|
||||
if (state.failTempWrite && String(path).endsWith('.tmp')) {
|
||||
state.failTempWrite = false
|
||||
throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' })
|
||||
@@ -33,6 +46,10 @@ const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
state.failTempWrite = false
|
||||
state.failDocumentCreate = false
|
||||
state.holdDocumentCreate = false
|
||||
state.documentCreateStarted = undefined
|
||||
state.continueDocumentCreate = undefined
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
})
|
||||
|
||||
@@ -51,6 +68,42 @@ async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Pro
|
||||
}
|
||||
|
||||
describe('writer-lock failure cleanup', () => {
|
||||
it('skips publication when an in-flight document create completes during teardown', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, { path, watch: true, debounceMs: 0 })
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
const settings = ctx.settings
|
||||
let markStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
let releaseCreate!: () => void
|
||||
state.continueDocumentCreate = new Promise<void>((resolve) => { releaseCreate = resolve })
|
||||
state.documentCreateStarted = markStarted
|
||||
state.holdDocumentCreate = true
|
||||
|
||||
const preparing = settings.prepareDocument()
|
||||
await started
|
||||
const disposing = fiber.dispose()
|
||||
await vi.waitFor(() => {
|
||||
expect((settings as unknown as { closed: boolean }).closed).toBe(true)
|
||||
})
|
||||
releaseCreate()
|
||||
await expect(preparing).resolves.toBe(path)
|
||||
await disposing
|
||||
expect(await readFile(path, 'utf8')).toBe('')
|
||||
})
|
||||
|
||||
it('surfaces an exclusive document-create failure and releases the lock', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
state.failDocumentCreate = true
|
||||
await expect(ctx.settings.prepareDocument()).rejects.toThrow(/ENOSPC/)
|
||||
await expect(access(`${path}.lock`)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
|
||||
README.md: 1f1ce07722bfb035746ad5733f90ddabe2d1553b
|
||||
README.zh.md: 0d96a0deda3b9d8f6260a1f223eb86cb87781565
|
||||
README.md: afb4ed5c2b560de1639e773da3447dd79f15c8dc
|
||||
README.zh.md: 8a7cc0c555a8d02c36d60f5a0b321cea7b89e382
|
||||
|
||||
@@ -6,6 +6,8 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
|
||||
|
||||
## Service API
|
||||
|
||||
- `documentPath` — absolute path of the provider's user-editable file when it has one; non-file providers leave it `undefined`. Local configuration surfaces use it as availability metadata, never as a browser-selected open target.
|
||||
- `prepareDocument()` — return that path after making the document ready for a native editor. The base implementation returns `documentPath`; a file provider may materialize an absent document first.
|
||||
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
|
||||
- `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires.
|
||||
- `get(ns)` — resolved value, `undefined` while unregistered.
|
||||
@@ -18,7 +20,7 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
|
||||
|
||||
## Provider contract
|
||||
|
||||
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
|
||||
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, optionally override `documentPath` and `prepareDocument()` for one local user-editable file, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
|
||||
|
||||
## Events
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
## 服务 API
|
||||
|
||||
- `documentPath` — 提供方拥有用户可编辑文件时,该字段是文件的绝对路径;非文件提供方保留 `undefined`。本地配置界面仅将其用作可用性元数据,绝不把它当作由浏览器选定的打开目标。
|
||||
- `prepareDocument()` — 让文档做好供原生编辑器打开的准备后返回该路径。基类实现返回 `documentPath`;文件提供方可先创建缺失的文档。
|
||||
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
|
||||
- `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个 wire 面都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。
|
||||
- `get(ns)` — 解析值;未注册时为 `undefined`。
|
||||
@@ -18,7 +20,7 @@
|
||||
|
||||
## Provider 契约
|
||||
|
||||
子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
|
||||
子类实现 `writable`、`load()`、`persist(ns, section)`,可选择为一个本地用户可编辑文件重写 `documentPath` 与 `prepareDocument()`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -403,6 +403,27 @@ export abstract class Settings extends Service {
|
||||
/** Whether {@link update} may persist through this provider. */
|
||||
abstract readonly writable: boolean
|
||||
|
||||
/**
|
||||
* Absolute path of the provider's user-editable document, when its storage
|
||||
* is one local file. Configuration surfaces use this only as availability
|
||||
* metadata; the guarded open operation resolves the path again Host-side.
|
||||
* Non-file providers leave it undefined and expose no open-document affordance.
|
||||
* @returns the absolute local document path, or undefined for non-file storage.
|
||||
*/
|
||||
get documentPath(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the provider's user-editable document for a native editor. File
|
||||
* providers may materialize an absent document before returning its path;
|
||||
* non-file providers return undefined.
|
||||
* @returns the absolute local document path, or undefined for non-file storage.
|
||||
*/
|
||||
prepareDocument(): Promise<string | undefined> {
|
||||
return Promise.resolve(this.documentPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the provider's current raw document (namespace to raw section).
|
||||
* @returns the detached raw document.
|
||||
|
||||
@@ -58,6 +58,14 @@ async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
|
||||
return { ctx, provider, fiber }
|
||||
}
|
||||
|
||||
describe('provider metadata', () => {
|
||||
it('does not advertise a local document unless the provider overrides it', async () => {
|
||||
const { ctx } = await boot()
|
||||
expect(ctx.settings.documentPath).toBeUndefined()
|
||||
await expect(ctx.settings.prepareDocument()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/** Record every settings/updated emission. */
|
||||
function recordUpdates(ctx: Context) {
|
||||
const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = []
|
||||
|
||||
Reference in New Issue
Block a user