dsh-tiddlywiki 0.30.47 → 0.30.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "把 TiddlyWiki 5 变成你和 Agent 共用的本地文档中心(纯文本 + git):你在 DSH 界面里阅读、写作、记日记,Agent 用 15 个 tiddlywiki_* 工具读写同一批笔记;每条笔记都是你能读、能改、能审计、能随时带走的文件——不是自动记忆插件,不做后台抓取。可选一键发布到微信公众号草稿箱。 Turn TiddlyWiki 5 into a document center you and your agent share, in plain text under git — a knowledge base you can read, edit, audit and take with you, not an auto-memory plugin. Mounts via the official dsh plugin system; no DSH source changes.",
4
- "version": "0.30.47",
4
+ "version": "0.30.49",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -91,7 +91,7 @@
91
91
  "typecheck": "tsc --noEmit",
92
92
  "smoke:client": "node scripts/smoke-client.mjs",
93
93
  "selftest": "node scripts/selftest.mjs",
94
- "verify:static": "node scripts/verify-send-to-agent-bundle.mjs && node scripts/verify-render-bundle.mjs && node scripts/verify-wechat-publish-bundle.mjs && node scripts/verify-package-contents.mjs && node scripts/verify-version-consistency.mjs && node scripts/verify-constants.mjs && node scripts/verify-line-endings.mjs && node scripts/verify-doc-links.mjs",
94
+ "verify:static": "node scripts/verify-send-to-agent-bundle.mjs && node scripts/verify-render-bundle.mjs && node scripts/verify-wechat-publish-bundle.mjs && node scripts/verify-package-contents.mjs && node scripts/verify-version-consistency.mjs && node scripts/verify-constants.mjs && node scripts/verify-index-barrel.mjs && node scripts/verify-line-endings.mjs && node scripts/verify-doc-links.mjs",
95
95
  "verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs && node scripts/verify-write-policy.mjs && node scripts/verify-write-call-sites.mjs && node scripts/verify-workspace.mjs && node scripts/verify-wiki-registry.mjs && node scripts/verify-wiki-farm.mjs && node scripts/verify-session-scope.mjs && node scripts/verify-skill-install.mjs && node scripts/verify-repo-keying.mjs && node scripts/verify-restart-drain.mjs && node scripts/verify-desktop-node.mjs && node scripts/verify-language-pin.mjs && node scripts/verify-git-multi-sync.mjs && node scripts/verify-client-i18n.mjs && node scripts/verify-mutation-lock.mjs && node scripts/verify-prompt.mjs && node scripts/verify-conflict-config-guards.mjs && node scripts/verify-config-ranges.mjs && node scripts/verify-config-patch.mjs && node scripts/verify-tool-views.mjs && npx tsx scripts/verify-plugin-runtime.mjs && node scripts/verify-wechat-adapters.mjs && node scripts/verify-wechat-docs-seed.mjs && node scripts/verify-wechat-publish.mjs && npx tsx scripts/verify-tw-origin.mjs && node scripts/verify-frame-guards.mjs && npx tsx scripts/verify-ready-policy.mjs && npx tsx scripts/verify-frame-surface.mjs && npx tsx scripts/verify-editor-popup.mjs && npx tsx scripts/verify-status-cache.mjs && npx tsx scripts/verify-wiki-focus.mjs",
96
96
  "verify:e2e": "node scripts/verify-auth.mjs && node scripts/verify-anon-username.mjs && node scripts/verify-tools.mjs && node scripts/verify-audit-fixes.mjs && node scripts/verify-git-resolve.mjs && node scripts/verify-resilience.mjs && node scripts/verify-seed-send-to-agent.mjs && node scripts/verify-seeds-admin.mjs && node scripts/verify-wiki-switch.mjs && node scripts/verify-wiki-farm-boot.mjs",
97
97
  "verify:large": "node scripts/verify-large-wiki.mjs",
package/src/index-git.ts CHANGED
@@ -58,8 +58,6 @@ export interface GitLayerDeps {
58
58
  defaultPath: () => string
59
59
  /** The multi-wiki control file (a registry write may be needed here). */
60
60
  registryFile: () => string
61
- /** Teardown-aware (v0.23.5): a reapply must not re-arm after dispose. */
62
- isDisposed: () => boolean
63
61
  }
64
62
 
65
63
  export interface GitLayer {
@@ -0,0 +1,381 @@
1
+ /**
2
+ * dsh-tiddlywiki — host half: the ROUTE + ADMIN assembly stage.
3
+ *
4
+ * Split out of `apply()` (v0.30.48) because it is a self-contained STAGE: the
5
+ * two `Deps` objects below are the whole surface the route table and the
6
+ * settings-panel admin routes consume, and they are built in one place from the
7
+ * plugin closure. Reading the entry point top-to-bottom now shows the lifecycle
8
+ * (farm → prompt/tools → startup task → routes), instead of the lifecycle
9
+ * interrupted by ~120 lines of accessor wiring.
10
+ *
11
+ * WHAT THIS MODULE DELIBERATELY DOES **NOT** OWN
12
+ * ----------------------------------------------
13
+ * - `disposed` stays in the entry point (its owner is the teardown effect and
14
+ * the startup task), so the one guard that needs it is passed IN as a
15
+ * predicate — `isDisposed` — rather than read from a second home.
16
+ * - The mutation lock: `createMutationLock()` is called by the caller and the
17
+ * SAME instance is handed to both `RouteDeps` and `AdminDeps` (v0.30.12: the
18
+ * plugin has exactly one lock, and it must be provably one — see
19
+ * scripts/verify-mutation-lock.mjs). This module never constructs one.
20
+ * - Order is NOT this module's business: `registerXxxRoutes()` below is called
21
+ * by the entry point at the exact position it used to occupy, right after the
22
+ * wiki views and right before the teardown effect. Nothing here reorders the
23
+ * lifecycle.
24
+ *
25
+ * @module dsh-tiddlywiki/index-routes
26
+ */
27
+ import type { IncomingMessage } from 'node:http'
28
+ import {
29
+ registerRoutes,
30
+ type AgentPresetsFace,
31
+ type PermissionPresetsFace,
32
+ type SessionControllerFace,
33
+ type SessionPersistenceFace,
34
+ type SessionsFace,
35
+ type SessionQueryFace,
36
+ type WebServerFace,
37
+ type WorkspaceRegistryFace,
38
+ } from './host/routes.ts'
39
+ import {
40
+ registerAdminRoutes,
41
+ resolveTwRoot,
42
+ type AdminDeps,
43
+ type AdminWikisApplyResult,
44
+ type AdminWikisView,
45
+ } from './host/admin.ts'
46
+ import { ConfigStore, type PluginConfigShape } from './host/config.ts'
47
+ import type { GitFace } from './host/git.ts'
48
+ import { checkAllSeeds, runSeedById, removeSeedById } from './host/seeds.ts'
49
+ import { describePrompt, type PromptConfig } from './host/prompt.ts'
50
+ import type { RepoCommitters } from './host/repo-committers.ts'
51
+ import { normalizeWechatConfig, checkWechatReady, type WechatPublishRunner } from './host/wechat-publish.ts'
52
+ import { tiddlywikiToolSummary } from './host/tools.ts'
53
+ import {
54
+ DEFAULT_WIKI_ID,
55
+ applyWikiAction,
56
+ defaultEntry,
57
+ entryPath,
58
+ singleEntryRegistry,
59
+ writeRegistry,
60
+ type WikiRegistry,
61
+ } from './host/wiki-registry.ts'
62
+ import type { WikiLocation } from './host/wiki-location.ts'
63
+ import { writeLocationState } from './host/wiki-location.ts'
64
+ import { WikiInstance } from './host/wiki-instance.ts'
65
+ import { resolveAgentScope, stoppedWikiFromRequest, stoppedWikiMessage, targetRuntimeFor, type WikiFarm } from './host/wiki-farm.ts'
66
+ import { isSafeSessionId, setSessionScope } from './host/session-scope.ts'
67
+ import type { MutationLock } from './host/mutation-lock.ts'
68
+ import { restartAffectedWikis } from './index-wikis.ts'
69
+ import type { WikiViews } from './index-wikis.ts'
70
+
71
+ export interface RouteStageDeps {
72
+ /** The lazy webServer handle the caller injected. */
73
+ webServer: WebServerFace
74
+ /** The plugin context — host services are resolved from it lazily. */
75
+ get(name: string): unknown
76
+ /** One `GitFace` for the whole plugin. */
77
+ git: GitFace
78
+ /** One commit layer for the whole plugin (keyed by repository). */
79
+ repos: RepoCommitters
80
+ /** The running farm (undefined until the control file has been read). */
81
+ getFarm: () => WikiFarm<WikiInstance> | undefined
82
+ /** The folder the default wiki occupies, running or not. */
83
+ defaultPath: () => string
84
+ /** The `{root,name}` the default wiki currently occupies (running or not). */
85
+ defaultCurrentLocation: () => WikiLocation
86
+ /** What the CONTROL FILE says right now (synthesized when it does not exist). */
87
+ controlRegistryNow: () => WikiRegistry
88
+ /** Cache the registry the settings page just wrote (the control-file cache). */
89
+ setControlRegistry: (registry: WikiRegistry) => void
90
+ /** sessionId → wikiId; read/written by the composer's per-session selector. */
91
+ getSessionScopes: () => Record<string, string>
92
+ setSessionScopes: (next: Record<string, string>) => void
93
+ /** File the session scopes live in (outside every wiki). */
94
+ sessionScopeFile: string
95
+ /** The multi-wiki control file (a registry write goes here). */
96
+ registryFile: string
97
+ /** Pointer file the legacy single-wiki switch persists. */
98
+ locationStateFile: string
99
+ /** The cordis `config:` block, as the base layer of every wiki's config. */
100
+ baseShape: PluginConfigShape
101
+ /** The resolved cordis config (for the per-request fallbacks below). */
102
+ config: { note: { tag: string }; wechat?: unknown; ui: { sendToAgent: { enabled: boolean; token?: string } } }
103
+ /** Wiki location surface: `locationInfo` / switch / reset / the list view. */
104
+ wikiViews: WikiViews
105
+ /** The settings-page save hook: prompt + server tuning + git reapply. */
106
+ onConfigChanged: () => void
107
+ /** The saved prompt config of the TARGETED wiki (used when no draft is sent). */
108
+ effectivePromptFor: (req: IncomingMessage | undefined) => PromptConfig
109
+ /** ONE lock for the whole plugin (v0.30.12) — built by the caller. */
110
+ mutationLock: MutationLock
111
+ /** The WeChat publish runner (optional feature; owns the child process). */
112
+ wechatRunner: WechatPublishRunner
113
+ /** Teardown-aware: start → stop must not be accepted mid-dispose. */
114
+ isDisposed: () => boolean
115
+ }
116
+
117
+ export interface RouteStage {
118
+ /** Release both route tables (the caller returns this from its disposer). */
119
+ dispose: () => void
120
+ }
121
+
122
+ /** Build `RouteDeps` + `AdminDeps` and register both route tables. */
123
+ export function createRouteStage(deps: RouteStageDeps): RouteStage {
124
+ const { webServer: ws, git, repos, wikiViews } = deps
125
+ const { defaultPath, defaultCurrentLocation, locationInfo, switchWikiLocation, resetWikiLocation, buildWikisView } = wikiViews
126
+
127
+ // sessionController is a core host service; read it LAZILY per request (via
128
+ // the plugin root ctx) because it may be registered after webServer, and the
129
+ // agent routes must work whenever a request actually arrives.
130
+ const getSessionController = (): SessionControllerFace | undefined =>
131
+ deps.get('sessionController') as SessionControllerFace | undefined
132
+ const getWorkspaceRegistry = (): WorkspaceRegistryFace | undefined =>
133
+ deps.get('workspaceRegistry') as WorkspaceRegistryFace | undefined
134
+ // agentPresets (工作模式 roster) + sessionPersistence (per-session preset
135
+ // badges) are also core host services — resolve them lazily like the above.
136
+ const getAgentPresets = (): AgentPresetsFace | undefined =>
137
+ deps.get('agentPresets') as AgentPresetsFace | undefined
138
+ const getSessionPersistence = (): SessionPersistenceFace | undefined =>
139
+ deps.get('sessionPersistence') as SessionPersistenceFace | undefined
140
+ // permissionPresets (权限 preset roster for the picker + applying the
141
+ // chosen permission to new sessions) and the `sessions` in-memory store
142
+ // (the created live session handed to permissionPresets.set) are core host
143
+ // services — resolve them lazily like the ones above.
144
+ const getPermissionPresets = (): PermissionPresetsFace | undefined =>
145
+ deps.get('permissionPresets') as PermissionPresetsFace | undefined
146
+ const getSessions = (): SessionsFace | undefined =>
147
+ deps.get('sessions') as SessionsFace | undefined
148
+ // sessionQuery (会话日志查询,供「知识库」Tab 判定「本会话相关笔记」) 也是核心
149
+ // host 服务 —— 可选注入(本部署存在),懒解析。
150
+ const getSessionQuery = (): SessionQueryFace | undefined =>
151
+ deps.get('sessionQuery') as SessionQueryFace | undefined
152
+
153
+ // v0.28.0: the request decides WHICH wiki. `?wiki=<id>` (a running wiki) else
154
+ // the farm's default — resolved by the SHARED helper in host/wiki-farm.ts, so
155
+ // the host wiring and the verification harness cannot disagree about the
156
+ // selector (and an unknown id falls back instead of 404ing). Every accessor
157
+ // falls back to the cordis BASE when nothing is running, so a stopped farm
158
+ // degrades to "base defaults + 503 on writes" instead of throwing.
159
+ const target = (req: IncomingMessage): WikiInstance | undefined => targetRuntimeFor(deps.getFarm(), req)
160
+ /**
161
+ * Why this request cannot be served (v0.29.0): set when `?wiki=<id>` names a
162
+ * REGISTERED but STOPPED knowledge base. `targetRuntimeFor` deliberately
163
+ * returns undefined for that case instead of falling back to the default wiki
164
+ * (an unknown id still falls back — a stale bookmark must not 404), so every
165
+ * route that either 503s on a missing client or has a harmless-looking
166
+ * fallback (config / wiki path / prompt / status) can say WHICH wiki is down
167
+ * and what to do, instead of quietly acting on a different one.
168
+ */
169
+ const targetProblem = (req: IncomingMessage): string | undefined => {
170
+ const entry = stoppedWikiFromRequest(deps.getFarm(), req)
171
+ return entry === undefined ? undefined : stoppedWikiMessage(entry)
172
+ }
173
+ const fallbackUi = WikiInstance.uiDefaultsFrom(deps.config as never)
174
+ const fallbackWechat = normalizeWechatConfig((deps.config as { wechat?: unknown }).wechat)
175
+ /** Used only while nothing runs: base defaults, no wiki tiddler to read. */
176
+ const idleConfig = new ConfigStore(deps.baseShape)
177
+
178
+ const disposeRoutes = registerRoutes({ webServer: ws }, {
179
+ mutationLock: deps.mutationLock,
180
+ server: (req) => target(req)?.server,
181
+ // The roster the GUI selector + the settings page read. It reports what
182
+ // the FARM serves right now (in single mode that is the one synthesized
183
+ // entry); the control file's full candidate list is the admin route's job.
184
+ wikiSummaries: (req) => {
185
+ const registry = deps.getFarm()?.registry
186
+ return {
187
+ mode: registry?.mode ?? 'single',
188
+ defaultId: registry?.defaultId ?? DEFAULT_WIKI_ID,
189
+ items: (registry?.wikis ?? []).map((entry) => {
190
+ const runtime = deps.getFarm()?.runtime(entry.id)
191
+ return {
192
+ id: entry.id,
193
+ label: entry.label,
194
+ status: runtime?.server.status().status ?? 'stopped',
195
+ agentVisible: entry.agentVisible,
196
+ autostart: entry.autostart,
197
+ running: runtime !== undefined,
198
+ path: entryPathOf(deps.getFarm(), entry.id),
199
+ // 每库图标(v0.28.4):侧边栏入口与设置页选择器共用这个来源。
200
+ icon: entry.icon,
201
+ }
202
+ }),
203
+ }
204
+ },
205
+ // The `/tw/<id>/…` form: the proxy resolves the child by NAME (it cannot
206
+ // use `target()`, whose `?wiki=` would be lost inside the iframe).
207
+ serverById: (id) => deps.getFarm()?.runtime(id)?.server,
208
+ wikiIds: () => deps.getFarm()?.registry.wikis.map((entry) => entry.id) ?? [],
209
+ getClient: (req) => target(req)?.client(),
210
+ git,
211
+ autoCommit: (req) => target(req)?.touchAutoCommit(),
212
+ noteDefaults: (req) => ({ tag: target(req)?.noteTag() ?? deps.config.note.tag }),
213
+ uiDefaults: (req) => target(req)?.uiDefaults() ?? fallbackUi,
214
+ langOf: (req) => target(req)?.uiLanguage() ?? (typeof deps.baseShape.uiLanguage === 'string' && deps.baseShape.uiLanguage.trim().length > 0 ? deps.baseShape.uiLanguage.trim() : 'zh'),
215
+ getWikiPath: (req) => target(req)?.path ?? defaultPath(),
216
+ targetProblem,
217
+ // Same helper as the agent tool (one implementation, two callers): a pull
218
+ // can change several knowledge bases that share one repository.
219
+ restartAffected: (_req, dir, changedFiles) => restartAffectedWikis({ farm: deps.getFarm, repoRootOf: (d) => repos.repoRootOf(d) }, dir, changedFiles),
220
+ // The composer's per-session selector (v0.28.0). Reading is a Map lookup;
221
+ // writing persists to session-scope.ts AND starts the wiki on demand — the
222
+ // tools resolve the scope SYNCHRONOUSLY and never start anything, so the
223
+ // selection itself has to bring the wiki up, or the very next tool call
224
+ // would have to refuse.
225
+ sessionScope: {
226
+ get: (sessionId: string) => {
227
+ const scopes = deps.getSessionScopes()
228
+ const resolution = resolveAgentScope(deps.getFarm(), scopes, sessionId)
229
+ const scopeId = scopes[sessionId]
230
+ return {
231
+ ...(scopeId !== undefined ? { scope: scopeId } : {}),
232
+ ...(resolution.entry !== undefined ? { resolved: { id: resolution.entry.id, label: resolution.entry.label } } : {}),
233
+ ...(resolution.reason !== undefined ? { reason: resolution.reason } : {}),
234
+ // v0.29.0: the client needs to know whether "resolved" is a real
235
+ // answer (multi: this session's tools act on THAT wiki, so its cards'
236
+ // links must open it) or just the single install's only wiki (keep
237
+ // the pre-multi DOM, which renders no wiki attribute at all).
238
+ mode: deps.getFarm()?.registry.mode ?? 'single',
239
+ }
240
+ },
241
+ set: async (sessionId: string, wikiId: string | undefined) => {
242
+ if (!isSafeSessionId(sessionId)) throw new Error('会话 id 非法')
243
+ const farm = deps.getFarm()
244
+ if (wikiId !== undefined) {
245
+ const entry = farm?.registry.wikis.find((item) => item.id === wikiId)
246
+ if (entry === undefined) throw new Error(`知识库「${wikiId}」不在清单里`)
247
+ // `agentVisible: false` means "the agent never reaches this one"; a
248
+ // selector that allowed it would contradict the setting silently.
249
+ if (!entry.agentVisible) throw new Error(`知识库「${entry.label}」对 Agent 隐身,不能作为会话作用域`)
250
+ if (farm !== undefined && farm.runtime(entry.id) === undefined) await farm.startEntry(entry)
251
+ }
252
+ await setSessionScope(sessionId, wikiId, deps.sessionScopeFile)
253
+ const next = { ...deps.getSessionScopes() }
254
+ if (wikiId === undefined) delete next[sessionId]
255
+ else next[sessionId] = wikiId
256
+ deps.setSessionScopes(next)
257
+ },
258
+ },
259
+ getSessionController,
260
+ getWorkspaceRegistry,
261
+ getAgentPresets,
262
+ getSessionPersistence,
263
+ getPermissionPresets,
264
+ getSessions,
265
+ getSessionQuery,
266
+ sendToAgentEnabled: (req) => target(req)?.sendToAgent().enabled ?? deps.config.ui.sendToAgent.enabled,
267
+ sendToAgentToken: (req) => target(req)?.sendToAgent().token ?? (deps.config.ui.sendToAgent.token ?? ''),
268
+ // 公众号发布(v0.23.3,可选功能):config 每请求重读(开关/adapter/token
269
+ // 保存即生效,且每个知识库各有一份);就绪探测每次都真跑一次
270
+ // `opencli --version`(几百毫秒),只在按钮预检与每次起任务前发生,
271
+ // 不做缓存以免装完 adapter 还要等 TTL。
272
+ wechatConfig: (req) => target(req)?.wechatConfig() ?? fallbackWechat,
273
+ wechatRunner: () => deps.wechatRunner,
274
+ wechatReady: async (req) => {
275
+ const cfg = target(req)?.wechatConfig() ?? fallbackWechat
276
+ return checkWechatReady({ enabled: cfg.enabled, command: cfg.command })
277
+ },
278
+ })
279
+
280
+ const adminDeps: AdminDeps = {
281
+ mutationLock: deps.mutationLock,
282
+ server: (req) => target(req)?.server,
283
+ getClient: (req) => target(req)?.client(),
284
+ getWikiPath: (req) => target(req)?.path ?? defaultPath(),
285
+ targetProblem,
286
+ twRoot: resolveTwRoot,
287
+ config: (req) => target(req)?.config ?? idleConfig,
288
+ onConfigChanged: deps.onConfigChanged,
289
+ // No draft → the SAVED config of the TARGETED wiki (what is injected right
290
+ // now); with a draft → the settings form's unsaved values (v0.22.7),
291
+ // through the same builder.
292
+ getPrompt: (draft, req) =>
293
+ describePrompt(draft ?? deps.effectivePromptFor(req), tiddlywikiToolSummary()),
294
+ // Runtime wiki location (v0.22.0): read the current folder + how it was
295
+ // decided, switch to another one, or drop back to the cordis default.
296
+ wiki: {
297
+ info: locationInfo,
298
+ switch: switchWikiLocation,
299
+ reset: resetWikiLocation,
300
+ },
301
+ // The knowledge-base LIST (v0.28.0): `info` reports the CONTROL FILE; every
302
+ // action is validated by the pure `applyWikiAction` and persisted BEFORE
303
+ // the farm reconciles — the file is the user's intent, and a reconcile
304
+ // failure is reported per wiki instead of silently discarding the edit.
305
+ wikis: {
306
+ info: async (): Promise<AdminWikisView> => buildWikisView(),
307
+ apply: async (body: unknown): Promise<AdminWikisApplyResult> => {
308
+ if (deps.isDisposed()) return { ok: false, error: '插件正在卸载,已取消修改' }
309
+ // RUNTIME actions first (v0.28.0): `start`/`stop` do not change the list,
310
+ // they change what is running. They live here rather than in the pure
311
+ // `applyWikiAction` because they touch processes, not configuration —
312
+ // and the GUI needs them: opening a wiki's panel must be able to bring
313
+ // a stopped knowledge base up.
314
+ const farm = deps.getFarm()
315
+ const runtimeAction = (body as { action?: unknown } | null)?.action
316
+ if (runtimeAction === 'start' || runtimeAction === 'stop') {
317
+ if (farm === undefined) return { ok: false, error: '插件尚未就绪,请稍后再试' }
318
+ const id = (body as { id?: unknown }).id
319
+ const entry = typeof id === 'string' ? farm.registry.wikis.find((item) => item.id === id.trim().toLowerCase()) : undefined
320
+ if (entry === undefined) return { ok: false, error: `知识库「${String(id)}」不在清单里` }
321
+ const change = { started: [] as string[], stopped: [] as string[], updated: [] as string[], running: farm.runningIds(), errors: [] as Array<{ id: string; message: string }> }
322
+ if (runtimeAction === 'start') {
323
+ if (farm.runtime(entry.id) === undefined) {
324
+ await farm.startEntry(entry)
325
+ change.started.push(entry.id)
326
+ }
327
+ } else {
328
+ await farm.stopEntry(entry.id)
329
+ change.stopped.push(entry.id)
330
+ }
331
+ change.running = farm.runningIds()
332
+ return { ok: true, info: buildWikisView(), change }
333
+ }
334
+ const action = applyWikiAction(deps.controlRegistryNow(), body)
335
+ if (action.registry === undefined) return { ok: false, error: action.error ?? '动作被拒绝' }
336
+ const registry = action.registry
337
+ try {
338
+ await writeRegistry(registry, deps.registryFile)
339
+ } catch (err) {
340
+ return { ok: false, error: `清单写入失败:${err instanceof Error ? err.message : String(err)}` }
341
+ }
342
+ deps.setControlRegistry(registry)
343
+ // Reconcile. multi → the farm runs the whole list. single → it must
344
+ // keep serving the ONE wiki the legacy pointer names, so the
345
+ // synthesized run-registry is used and the pointer is kept in step
346
+ // (otherwise a restart in single mode would land somewhere else).
347
+ const single = defaultEntry(registry)
348
+ const runRegistry = registry.mode === 'multi'
349
+ ? registry
350
+ : singleEntryRegistry(single === undefined ? defaultCurrentLocation() : { root: single.root, name: single.name }, DEFAULT_WIKI_ID, 'single')
351
+ const change = farm === undefined
352
+ ? { started: [], stopped: [], updated: [], running: [], errors: [] }
353
+ : await farm.apply(runRegistry)
354
+ if (registry.mode === 'single' && single !== undefined) {
355
+ await writeLocationState({ root: single.root, name: single.name }, deps.locationStateFile).catch(() => undefined)
356
+ }
357
+ return { ok: true, info: buildWikisView(), change }
358
+ },
359
+ },
360
+ seeds: {
361
+ // Tool summaries feed the GENERATED seed content (the doc note's tool
362
+ // list, v0.22.0) — pass them everywhere the registry can be re-run.
363
+ checkAll: async (c) => checkAllSeeds({ client: c, tools: tiddlywikiToolSummary() }),
364
+ run: async (c, id, force) => runSeedById({ client: c, tools: tiddlywikiToolSummary() }, id, force),
365
+ remove: async (c, id) => removeSeedById({ client: c }, id),
366
+ },
367
+ }
368
+ const disposeAdmin = registerAdminRoutes({ webServer: ws }, adminDeps)
369
+ return {
370
+ dispose: () => {
371
+ disposeRoutes()
372
+ disposeAdmin()
373
+ },
374
+ }
375
+ }
376
+
377
+ /** The folder a registered wiki occupies, running or not. */
378
+ function entryPathOf(farm: WikiFarm<WikiInstance> | undefined, id: string): string {
379
+ const entry = farm?.registry.wikis.find((item) => item.id === id)
380
+ return entry === undefined ? '' : entryPath(entry)
381
+ }