dsh-tiddlywiki 0.1.0

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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * WikiServer — the TiddlyWiki 5 child-process lifecycle (design doc §9, D3).
3
+ *
4
+ * Zero-friction rules:
5
+ * - ensure the wiki folder exists (scaffold with `--init server` once)
6
+ * - git bootstrap is NOT this class's job (index.ts owns the GitFace)
7
+ * - auto-detect a free loopback port unless one is pinned in config
8
+ * - spawn `node <tw>/tiddlywiki.js <wiki> --listen host=127.0.0.1 ...`
9
+ * and poll /status until it answers 200
10
+ * - the TW child serves at the ROOT of its own dedicated loopback port (no
11
+ * `path-prefix`): TW's browser frontend builds its API URLs from
12
+ * `$protocol$//$host$/` only, so any path-prefix makes every frontend call
13
+ * ../../status → 404 (verified against tiddlywiki 5.4.1). Namespacing lives
14
+ * on the DSH webserver side (/dsh-tiddlywiki/* routes), never in TW itself.
15
+ * - crash → restart with exponential backoff (1s,2s,4s… cap 30s), reset on
16
+ * a successful readiness
17
+ * - stop() is deterministic: SIGTERM, escalate to SIGKILL after a grace
18
+ * period, and never leave a timer that would respawn during teardown
19
+ *
20
+ * @module dsh-tiddlywiki/host/wiki
21
+ */
22
+ import { spawn, execFile, type ChildProcessByStdio } from 'node:child_process'
23
+ import { existsSync } from 'node:fs'
24
+ import { mkdir } from 'node:fs/promises'
25
+ import { createRequire } from 'node:module'
26
+ import { createServer } from 'node:net'
27
+ import { join, resolve } from 'node:path'
28
+ import type { Readable } from 'node:stream'
29
+
30
+ /** The DSH webserver route prefix (NOT a TW path-prefix; see module header). */
31
+ export const PATH_PREFIX = '/dsh-tiddlywiki'
32
+
33
+ /** How long to wait for the wiki to answer /status. */
34
+ const READY_TIMEOUT_MS = 20_000
35
+
36
+ /** Poll cadence while waiting for readiness. */
37
+ const READY_POLL_MS = 500
38
+
39
+ /** Backoff ceiling for crash restarts. */
40
+ const MAX_RESTART_BACKOFF_MS = 30_000
41
+
42
+ /** SIGTERM → SIGKILL escalation grace. */
43
+ const KILL_GRACE_MS = 3_000
44
+
45
+ /** Ring-buffer cap for the stdout/stderr log. */
46
+ const LOG_BUFFER_LIMIT = 200
47
+
48
+ /** One-shot scaffold timeout for `--init server`. */
49
+ const INIT_TIMEOUT_MS = 30_000
50
+
51
+ export interface WikiServerOptions {
52
+ /** Root that holds one folder per wiki (default $DSH_HOME/tiddlywiki). */
53
+ wikiRoot: string
54
+ /** Wiki folder name under wikiRoot (default "main"). */
55
+ wiki: string
56
+ /** Port; 0 = auto-detect a free loopback port. */
57
+ port: number
58
+ /** Optional Basic Auth (loopback anonymous by default). */
59
+ username?: string
60
+ password?: string
61
+ logBufferLimit?: number
62
+ }
63
+
64
+ export type WikiHealth = 'starting' | 'running' | 'stopped' | 'failed'
65
+
66
+ export interface WikiStatusView {
67
+ status: WikiHealth
68
+ url?: string
69
+ port?: number
70
+ wikiPath: string
71
+ pid?: number
72
+ lastStartedAt?: number
73
+ error?: string
74
+ logs: string[]
75
+ }
76
+
77
+ /** Resolve the absolute entry of the installed `tiddlywiki` package. */
78
+ function resolveTwEntry(): string {
79
+ const require = createRequire(import.meta.url)
80
+ return require.resolve('tiddlywiki/tiddlywiki.js')
81
+ }
82
+
83
+ export class WikiServer {
84
+ private child: ChildProcessByStdio<null, Readable, Readable> | undefined
85
+ private readonly wikiPath: string
86
+ private readonly logs: string[] = []
87
+ private readonly logLimit: number
88
+ private health: WikiHealth = 'stopped'
89
+ private port: number | undefined
90
+ private stopping = false
91
+ private restartTimer: NodeJS.Timeout | undefined
92
+ private restartDelay = 1_000
93
+ private lastStartedAt: number | undefined
94
+ private error: string | undefined
95
+
96
+ constructor(private readonly options: WikiServerOptions) {
97
+ this.wikiPath = resolve(options.wikiRoot, options.wiki)
98
+ this.logLimit = options.logBufferLimit ?? LOG_BUFFER_LIMIT
99
+ }
100
+
101
+ /** Base URL of the TW service, once a port is bound (root, no path prefix). */
102
+ get url(): string | undefined {
103
+ return this.port === undefined ? undefined : `http://127.0.0.1:${this.port}`
104
+ }
105
+
106
+ /** The currently bound port (undefined until first spawn). */
107
+ get currentPort(): number | undefined {
108
+ return this.port
109
+ }
110
+
111
+ private log(line: string): void {
112
+ const ts = new Date().toISOString()
113
+ this.logs.push(`[${ts}] ${line}`)
114
+ if (this.logs.length > this.logLimit) this.logs.splice(0, this.logs.length - this.logLimit)
115
+ }
116
+
117
+ /** Scaffold the wiki folder with `--init server` when it is absent. */
118
+ async ensureWiki(): Promise<void> {
119
+ await mkdir(this.wikiPath, { recursive: true })
120
+ if (existsSync(join(this.wikiPath, 'tiddlywiki.info'))) return
121
+ const tw = resolveTwEntry()
122
+ this.log(`init: ${process.execPath} ${tw} ${this.wikiPath} --init server`)
123
+ await new Promise<void>((resolveP, rejectP) => {
124
+ execFile(process.execPath, [tw, this.wikiPath, '--init', 'server'], { timeout: INIT_TIMEOUT_MS, windowsHide: true }, (err) => {
125
+ if (err) rejectP(err as Error)
126
+ else resolveP()
127
+ })
128
+ })
129
+ }
130
+
131
+ /** Probe a free loopback port. */
132
+ private async findFreePort(): Promise<number> {
133
+ return new Promise<number>((resolveP, rejectP) => {
134
+ const server = createServer()
135
+ server.unref()
136
+ server.once('error', rejectP)
137
+ server.listen(0, '127.0.0.1', () => {
138
+ const address = server.address()
139
+ if (address === null || typeof address === 'string') {
140
+ server.close()
141
+ rejectP(new Error('cannot resolve a free port'))
142
+ return
143
+ }
144
+ const port = address.port
145
+ server.close(() => resolveP(port))
146
+ })
147
+ })
148
+ }
149
+
150
+ /**
151
+ * Start (or restart) the TW child. Resolves once `/status` answers 200 or
152
+ * the readiness deadline passes. Never throws on a crash — the exit handler
153
+ * schedules a self-healing restart unless we are stopping.
154
+ */
155
+ async start(): Promise<WikiStatusView> {
156
+ this.stopping = false
157
+ this.restartDelay = 1_000
158
+ await this.ensureWiki()
159
+ if (this.child !== undefined) return this.status()
160
+ this.health = 'starting'
161
+ // Reuse an existing auto port across restarts (restart() → stop() → start())
162
+ // so a fixed-baseUrl TiddlyWebClient stays valid and iframe src is stable.
163
+ const port = this.options.port > 0 ? this.options.port : ((this.port ?? 0) > 0 ? this.port as number : await this.findFreePort())
164
+ this.port = port
165
+ const tw = resolveTwEntry()
166
+ const args = [tw, this.wikiPath, '--listen', 'host=127.0.0.1', `port=${port}`]
167
+ if (this.options.username) {
168
+ // Locked-down mode for non-loopback exposure: Basic Auth + access lists.
169
+ args.push(`username=${this.options.username}`)
170
+ args.push(`password=${this.options.password ?? ''}`)
171
+ args.push(`readers=${this.options.username}`)
172
+ args.push(`writers=${this.options.username}`)
173
+ }
174
+ // Anonymous loopback mode carries NO auth args: TW's defaults open the
175
+ // wiki to anonymous read/write on the bound (loopback) address. Passing
176
+ // anon-username/readers/writers here was verified to 401 every request
177
+ // ('undefined' is not authorized), so the anonymous branch stays bare.
178
+ this.log(`spawn: ${process.execPath} ${args.join(' ')}`)
179
+ const child = spawn(process.execPath, args, { cwd: this.wikiPath, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
180
+ this.child = child
181
+ child.stdout.on('data', (chunk: Buffer) => this.log(`[out] ${String(chunk).trimEnd()}`))
182
+ child.stderr.on('data', (chunk: Buffer) => this.log(`[err] ${String(chunk).trimEnd()}`))
183
+ child.once('exit', (code, signal) => {
184
+ this.log(`exit code=${code} signal=${signal ?? ''} stopping=${this.stopping}`)
185
+ this.child = undefined
186
+ this.health = 'stopped'
187
+ if (!this.stopping) this.scheduleRestart()
188
+ })
189
+ child.once('error', (err) => {
190
+ this.log(`spawn error: ${err.message}`)
191
+ this.error = err.message
192
+ this.child = undefined
193
+ this.health = 'failed'
194
+ if (!this.stopping) this.scheduleRestart()
195
+ })
196
+ this.lastStartedAt = Date.now()
197
+ await this.waitReady()
198
+ return this.status()
199
+ }
200
+
201
+ /** Poll /status until 200 or the deadline; throws only on deadline/crash. */
202
+ private async waitReady(): Promise<void> {
203
+ const deadline = Date.now() + READY_TIMEOUT_MS
204
+ for (;;) {
205
+ if (this.child === undefined) throw new Error('wiki process exited before ready')
206
+ try {
207
+ const res = await fetch(`${this.url}/status`, { signal: AbortSignal.timeout(2_000) })
208
+ if (res.ok) {
209
+ this.health = 'running'
210
+ this.log('ready: /status 200')
211
+ return
212
+ }
213
+ } catch {
214
+ /* not ready yet */
215
+ }
216
+ if (Date.now() > deadline) {
217
+ this.health = 'failed'
218
+ this.error = 'wiki server did not become ready in time'
219
+ this.log(this.error)
220
+ throw new Error(this.error)
221
+ }
222
+ await new Promise<void>((r) => setTimeout(r, READY_POLL_MS))
223
+ }
224
+ }
225
+
226
+ private scheduleRestart(): void {
227
+ if (this.stopping || this.restartTimer !== undefined) return
228
+ const delay = this.restartDelay
229
+ this.restartDelay = Math.min(this.restartDelay * 2, MAX_RESTART_BACKOFF_MS)
230
+ this.log(`restart scheduled in ${delay}ms`)
231
+ this.health = 'starting'
232
+ this.restartTimer = setTimeout(() => {
233
+ this.restartTimer = undefined
234
+ void this.start().catch((err) => {
235
+ this.health = 'failed'
236
+ this.error = err instanceof Error ? err.message : String(err)
237
+ this.log(`restart failed: ${this.error}`)
238
+ })
239
+ }, delay)
240
+ }
241
+
242
+ /** One-click restart (route /dsh-tiddlywiki/restart, panel retry button). */
243
+ async restart(): Promise<WikiStatusView> {
244
+ await this.stop()
245
+ return this.start()
246
+ }
247
+
248
+ /** Deterministic teardown: cancel timers, SIGTERM, escalate to SIGKILL. */
249
+ async stop(): Promise<void> {
250
+ this.stopping = true
251
+ if (this.restartTimer !== undefined) {
252
+ clearTimeout(this.restartTimer)
253
+ this.restartTimer = undefined
254
+ }
255
+ const child = this.child
256
+ this.child = undefined
257
+ if (child !== undefined && child.exitCode === null && child.signalCode === null) {
258
+ try {
259
+ child.kill('SIGTERM')
260
+ } catch { /* already gone */ }
261
+ await Promise.race([
262
+ new Promise<void>((r) => child.once('exit', () => r())),
263
+ new Promise<void>((r) => {
264
+ setTimeout(() => {
265
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
266
+ r()
267
+ }, KILL_GRACE_MS).unref?.()
268
+ }),
269
+ ])
270
+ }
271
+ this.health = 'stopped'
272
+ }
273
+
274
+ /** Live status view (health, url, git-independent, recent logs). */
275
+ status(): WikiStatusView {
276
+ return {
277
+ status: this.health,
278
+ url: this.url,
279
+ port: this.port,
280
+ wikiPath: this.wikiPath,
281
+ pid: this.child?.pid,
282
+ lastStartedAt: this.lastStartedAt,
283
+ ...(this.error !== undefined ? { error: this.error } : {}),
284
+ logs: [...this.logs],
285
+ }
286
+ }
287
+ }
package/src/index.ts ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * dsh-tiddlywiki — host half.
3
+ *
4
+ * TiddlyWiki 5 as the DSH persistent knowledge base. Wiring:
5
+ * - WikiServer spawns/kills/self-heals the TW 5 child process (loopback, auto
6
+ * port) and scaffolds the wiki folder on first run;
7
+ * - the git face bootstraps the wiki folder as a repository and wires the
8
+ * debounced auto-committer;
9
+ * - `tiddlywiki_*` agent tools + a system-prompt section;
10
+ * - /dsh-tiddlywiki routes when a webServer is present.
11
+ *
12
+ * Export shape follows dsh-taskboard: a function/namespace plugin —
13
+ * `name` / `inject` / `apply`, NO default export. Config arrives as the
14
+ * second apply() argument (Cordis `runtime.callback(ctx, config)`).
15
+ *
16
+ * Extra exports (WikiServer / TiddlyWebClient / GitFace / ...) exist for the
17
+ * headless selftest and future reuse; the loader only reads name/inject/apply.
18
+ *
19
+ * @module dsh-tiddlywiki
20
+ */
21
+ import { watch, type FSWatcher } from 'node:fs'
22
+ import { writeFile } from 'node:fs/promises'
23
+ import { join } from 'node:path'
24
+ import { AutoCommitter, GitFace } from './host/git.ts'
25
+ import { registerRoutes, type WebServerFace } from './host/routes.ts'
26
+ import { ConfigStore, deepMerge, type PluginConfigShape } from './host/config.ts'
27
+ import { registerAdminRoutes, ensureLanguage, resolveTwRoot, type AdminDeps } from './host/admin.ts'
28
+ import { seedDocNote, DOC_NOTE_TITLE } from './host/seed-notes.ts'
29
+ import { TiddlyWebClient } from './host/tw-api.ts'
30
+ import { registerTiddlywikiTools, type ToolsDeps } from './host/tools.ts'
31
+ import { PATH_PREFIX, WikiServer, type WikiServerOptions } from './host/wiki.ts'
32
+ import { dshHomePath, defineTool } from './sdk.ts'
33
+
34
+ /** Cordis plugin name (also the client loader id / profile row id). */
35
+ export const name = 'dsh-tiddlywiki'
36
+
37
+ /** Required host services (tool registry + prompt assembly). */
38
+ export const inject = ['tools', 'systemPrompt']
39
+
40
+ /** Re-exports for the headless selftest and future consumers. */
41
+ export { AutoCommitter, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, dshHomePath, defineTool }
42
+ export { ConfigStore, deepMerge } from './host/config.ts'
43
+ export { openInTwEditor } from './host/routes.ts'
44
+ export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, bundledCatalog, ensureLanguage, normalizeThemes } from './host/admin.ts'
45
+ export { seedDocNote, DOC_NOTE_TITLE, DOC_NOTE_TAG, DOC_NOTE_TEXT } from './host/seed-notes.ts'
46
+ export type { PluginConfigShape } from './host/config.ts'
47
+ export type { GitStatusView } from './host/git.ts'
48
+ export type { Tiddler } from './host/tw-api.ts'
49
+ export type { WikiServerOptions, WikiStatusView } from './host/wiki.ts'
50
+
51
+ /** Plugin config (design doc §13). Defaults are applied in apply(). */
52
+ export interface TiddlywikiConfig {
53
+ wikiRoot?: string
54
+ wiki?: string
55
+ port?: number
56
+ git?: { autoCommit?: boolean; debounceMs?: number; remote?: string; branch?: string }
57
+ note?: { tag?: string }
58
+ auth?: { username?: string; password?: string }
59
+ }
60
+
61
+ /** Structural host context (subset of the dsh host + cordis surfaces). */
62
+ export interface HostCtx {
63
+ tools: { register(tool: unknown): () => void }
64
+ systemPrompt: { section(opts: { name: string; order: number; text: string }): () => void }
65
+ inject<T = unknown>(names: string | string[], callback: (ctx: HostCtx) => T, config?: unknown): unknown
66
+ effect(fn: () => unknown, label?: string): void
67
+ get(name: string): unknown
68
+ [key: string]: unknown
69
+ }
70
+
71
+ /** Resolved plugin config (defaults merged with the `config:` block). */
72
+ interface ResolvedConfig {
73
+ wikiRoot: string
74
+ wiki: string
75
+ port: number
76
+ git: { autoCommit: boolean; debounceMs: number; remote: string; branch: string }
77
+ note: { tag: string }
78
+ auth: { username?: string; password?: string }
79
+ }
80
+
81
+ const DEFAULTS: ResolvedConfig = {
82
+ wikiRoot: '',
83
+ wiki: 'main',
84
+ port: 0,
85
+ git: { autoCommit: true, debounceMs: 60_000, remote: '', branch: 'main' },
86
+ note: { tag: 'inbox' },
87
+ auth: { username: '', password: '' },
88
+ }
89
+
90
+ /** Expand $VAR / ${VAR} / %VAR% from process.env (config uses $DSH_HOME). */
91
+ function expandEnvPath(input: string): string {
92
+ return input
93
+ .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k: string) => process.env[k] ?? '')
94
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, k: string) => process.env[k] ?? '')
95
+ .replace(/%([A-Za-z_][A-Za-z0-9_]*%)/g, (_, k: string) => process.env[k.slice(0, -1)] ?? '')
96
+ }
97
+
98
+ /** Resolve wikiRoot: explicit config (env-expanded) else $DSH_HOME/tiddlywiki. */
99
+ function resolveWikiRoot(config: TiddlywikiConfig): string {
100
+ if (config.wikiRoot !== undefined && config.wikiRoot.trim().length > 0) {
101
+ return expandEnvPath(config.wikiRoot.trim())
102
+ }
103
+ return dshHomePath('tiddlywiki')
104
+ }
105
+
106
+ /** Write the .gitignore for TW transient artifacts (idempotent). */
107
+ async function writeGitignore(wikiPath: string): Promise<void> {
108
+ const lines = [
109
+ '# TiddlyWiki transient artifacts (auto-managed by dsh-tiddlywiki)',
110
+ 'tiddlers/$__temp_*',
111
+ 'tiddlers/$__StoryList*',
112
+ 'tiddlers/$__HistoryList*',
113
+ '*.meta.tmp',
114
+ '',
115
+ ]
116
+ await writeFile(join(wikiPath, '.gitignore'), lines.join('\n'), 'utf8')
117
+ }
118
+
119
+ /** Watch the wiki folders and touch the auto-committer on changes. */
120
+ function watchWiki(wikiPath: string, onChange: () => void): () => void {
121
+ const watchers: FSWatcher[] = []
122
+ for (const dir of [join(wikiPath, 'tiddlers'), wikiPath]) {
123
+ try {
124
+ const watcher = watch(dir, { persistent: false }, () => onChange())
125
+ watchers.push(watcher)
126
+ } catch {
127
+ /* directory may not exist yet; the committer also fires on our writes */
128
+ }
129
+ }
130
+ return () => {
131
+ for (const watcher of watchers) {
132
+ try { watcher.close() } catch { /* already closed */ }
133
+ }
134
+ }
135
+ }
136
+
137
+ /** System-prompt section text (design doc §11 D8). */
138
+ const PROMPT_SECTION_NAME = 'dsh-tiddlywiki'
139
+ const PROMPT_SECTION_ORDER = 100
140
+ const PROMPT_TEXT = `## TiddlyWiki 持久知识库
141
+
142
+ 本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。你可以用工具读写 tiddler:
143
+
144
+ - \`tiddlywiki_search\`(query, tag?)检索;\`tiddlywiki_get\`(title)读全文;\`tiddlywiki_put\`(title, text, tags?, fields?)写/覆盖;\`tiddlywiki_delete\`(title)删除。
145
+ - \`tiddlywiki_git_sync\`(pull|push|sync)做 git 同步。
146
+
147
+ 知识库同步纪律(三条):
148
+ 1. 开工先 pull:\`tiddlywiki_git_sync action=pull\`(rebase + autostash;真冲突会自动 abort 并报冲突文件)。
149
+ 2. 收工 commit + push:\`tiddlywiki_git_sync action=sync\`(pull → commit → push)。
150
+ 3. 插件会自动防抖 commit(默认 60s),手动同步用上面的工具。
151
+
152
+ 把 wiki 当作长期记忆与知识沉淀的地方:会议纪要、决策记录、调研笔记、随手的想法都可存成独立 tiddler(tag 建议用 inbox/meeting/decision 等便于检索)。`
153
+
154
+ /**
155
+ * Mount the host half.
156
+ * @param ctx - the plugin context (tools + systemPrompt injected).
157
+ * @param rawConfig - the plugin row's `config:` block (Cordis second arg).
158
+ */
159
+ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
160
+ const config: ResolvedConfig = {
161
+ wikiRoot: resolveWikiRoot(rawConfig),
162
+ wiki: rawConfig.wiki ?? DEFAULTS.wiki,
163
+ port: rawConfig.port ?? DEFAULTS.port,
164
+ git: { ...DEFAULTS.git, ...(rawConfig.git ?? {}) },
165
+ note: { ...DEFAULTS.note, ...(rawConfig.note ?? {}) },
166
+ auth: { ...DEFAULTS.auth, ...(rawConfig.auth ?? {}) },
167
+ }
168
+ const wikiPath = join(config.wikiRoot, config.wiki)
169
+ const git = new GitFace()
170
+
171
+ // Runtime-editable config (settings page): the cordis `config:` block is the
172
+ // BASE; a config tiddler ($:/plugins/dsh-tiddlywiki/config) written by the
173
+ // settings page overlays it. Effective values come from configStore.get().
174
+ const configStore = new ConfigStore({ note: config.note, git: config.git } satisfies PluginConfigShape)
175
+ const eff = (): PluginConfigShape => configStore.get()
176
+ const effectiveNoteTag = (): string => {
177
+ const tag = eff().note?.tag
178
+ return typeof tag === 'string' && tag.trim().length > 0 ? tag : config.note.tag
179
+ }
180
+
181
+ const disposers: Array<() => void> = []
182
+ const disposeAll = (): void => {
183
+ for (const dispose of disposers.splice(0)) dispose()
184
+ }
185
+
186
+ // System prompt section (independent of the wiki service).
187
+ const disposeSection = ctx.systemPrompt.section({ name: PROMPT_SECTION_NAME, order: PROMPT_SECTION_ORDER, text: PROMPT_TEXT })
188
+ ctx.effect(() => disposeSection, 'dsh-tiddlywiki: prompt section')
189
+
190
+ // TW child server.
191
+ const server = new WikiServer({
192
+ wikiRoot: config.wikiRoot,
193
+ wiki: config.wiki,
194
+ port: config.port,
195
+ username: config.auth.username,
196
+ password: config.auth.password,
197
+ })
198
+
199
+ // Lazy TW client (rebuilt when the port is bound).
200
+ let clientCache: TiddlyWebClient | undefined
201
+ const client = (): TiddlyWebClient | undefined => {
202
+ const port = server.currentPort
203
+ if (port === undefined) return undefined
204
+ clientCache ??= new TiddlyWebClient(`http://127.0.0.1:${port}`)
205
+ return clientCache
206
+ }
207
+
208
+ // Auto-committer + filesystem watcher (created after the wiki dir exists).
209
+ // Reads the EFFECTIVE config so a settings-page git change survives a restart.
210
+ let committer: AutoCommitter | undefined
211
+ let unwatch: (() => void) | undefined
212
+ const setupCommitter = (): void => {
213
+ const g = eff().git ?? {}
214
+ committer = new AutoCommitter({
215
+ git,
216
+ dir: wikiPath,
217
+ enabled: g.autoCommit ?? config.git.autoCommit,
218
+ debounceMs: g.debounceMs ?? config.git.debounceMs,
219
+ message: () => `wiki autocommit ${new Date().toISOString()}`,
220
+ onError: (err) => console.warn('[dsh-tiddlywiki] autocommit:', err),
221
+ })
222
+ unwatch = watchWiki(wikiPath, () => committer?.touch())
223
+ disposers.push(() => {
224
+ committer?.dispose()
225
+ unwatch?.()
226
+ })
227
+ }
228
+
229
+ // Git bootstrap: repo init + initial commit + .gitignore (+ remote/first push).
230
+ const bootstrapGit = async (): Promise<void> => {
231
+ const g = eff().git ?? {}
232
+ const branch = g.branch ?? config.git.branch
233
+ const remote = g.remote ?? config.git.remote
234
+ const isRepo = await git.isRepo(wikiPath)
235
+ if (!isRepo) {
236
+ await git.init(wikiPath, branch)
237
+ await writeGitignore(wikiPath)
238
+ await git.initialCommit(wikiPath)
239
+ } else {
240
+ await writeGitignore(wikiPath)
241
+ }
242
+ if (remote.trim().length > 0) {
243
+ const ensured = await git.ensureRemote(wikiPath, remote.trim())
244
+ if (ensured.ok) {
245
+ const first = await git.firstPush(wikiPath)
246
+ if (!first.ok) console.warn('[dsh-tiddlywiki] first push failed (retry with tiddlywiki_git_sync):', first.message)
247
+ } else {
248
+ console.warn('[dsh-tiddlywiki] git remote setup:', ensured.message)
249
+ }
250
+ }
251
+ }
252
+
253
+ // Tools (works even while the wiki is down; wiki() resolves lazily).
254
+ const toolsDeps: ToolsDeps = {
255
+ wiki: client,
256
+ git,
257
+ wikiPath: () => wikiPath,
258
+ noteTag: effectiveNoteTag,
259
+ autoCommit: () => committer?.touch(),
260
+ }
261
+ disposers.push(...registerTiddlywikiTools(ctx, toolsDeps))
262
+
263
+ // Bring the wiki up, load the override config, then bootstrap git + committer.
264
+ void (async () => {
265
+ try {
266
+ await server.start()
267
+ await configStore.load(client())
268
+ // Seed the built-in doc note (idempotent, create-if-missing) so a fresh
269
+ // wiki gets the plugin guide by default.
270
+ try {
271
+ const seedClient = client()
272
+ if (seedClient !== undefined) await seedDocNote(seedClient)
273
+ } catch (err) {
274
+ console.warn('[dsh-tiddlywiki] seeding doc note:', err)
275
+ }
276
+ // Apply the configured UI language (e.g. "zh-Hans"): enable the bundled
277
+ // language plugin in tiddlywiki.info.languages + restart once so TW loads
278
+ // it at boot (fully offline — official language packs ship in the pkg).
279
+ const uiLang = eff().uiLanguage
280
+ if (typeof uiLang === 'string' && uiLang.trim().length > 0) {
281
+ try {
282
+ const code = uiLang.trim()
283
+ const changed = await ensureLanguage(wikiPath, resolveTwRoot(), code)
284
+ if (changed) await server.restart()
285
+ // Pin the active language tiddler so TW's UI actually switches.
286
+ const langClient = client()
287
+ if (langClient !== undefined) {
288
+ await langClient.put({ title: '$:/language', text: `$:/languages/${code}`, type: 'text/plain', tags: [] }).catch(() => undefined)
289
+ }
290
+ } catch (err) {
291
+ console.warn('[dsh-tiddlywiki] applying uiLanguage:', err)
292
+ }
293
+ }
294
+ await bootstrapGit()
295
+ setupCommitter()
296
+ } catch (err) {
297
+ console.warn('[dsh-tiddlywiki] startup issue (self-healing is armed):', err)
298
+ }
299
+ })()
300
+
301
+ // Routes + settings-panel admin surface (lazy webServer).
302
+ ctx.inject(['webServer'], (webCtx: HostCtx) => {
303
+ const ws = (webCtx as unknown as { webServer: WebServerFace }).webServer
304
+ const disposeRoutes = registerRoutes({ webServer: ws }, {
305
+ server,
306
+ getClient: client,
307
+ git,
308
+ autoCommit: () => committer?.touch(),
309
+ noteDefaults: () => ({ tag: effectiveNoteTag() }),
310
+ getWikiPath: () => wikiPath,
311
+ })
312
+ const adminDeps: AdminDeps = {
313
+ server,
314
+ getClient: client,
315
+ getWikiPath: () => wikiPath,
316
+ twRoot: resolveTwRoot,
317
+ config: configStore,
318
+ }
319
+ const disposeAdmin = registerAdminRoutes({ webServer: ws }, adminDeps)
320
+ return () => {
321
+ disposeRoutes()
322
+ disposeAdmin()
323
+ }
324
+ })
325
+
326
+ // Teardown: everything reversible (R6 — hot reload must not leak).
327
+ ctx.effect(() => () => {
328
+ disposeAll()
329
+ void server.stop()
330
+ }, 'dsh-tiddlywiki: host teardown')
331
+ }