dsh-tiddlywiki 0.2.0 → 0.3.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.
package/src/host/wiki.ts CHANGED
@@ -1,287 +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
- }
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 CHANGED
@@ -25,7 +25,7 @@ import { AutoCommitter, GitFace } from './host/git.ts'
25
25
  import { registerRoutes, type WebServerFace } from './host/routes.ts'
26
26
  import { ConfigStore, deepMerge, type PluginConfigShape } from './host/config.ts'
27
27
  import { registerAdminRoutes, ensureLanguage, resolveTwRoot, type AdminDeps } from './host/admin.ts'
28
- import { seedDocNote, seedSidebarLeftCss, DOC_NOTE_TITLE } from './host/seed-notes.ts'
28
+ import { seedDocNote, DOC_NOTE_TITLE } from './host/seed-notes.ts'
29
29
  import { TiddlyWebClient } from './host/tw-api.ts'
30
30
  import { registerTiddlywikiTools, type ToolsDeps } from './host/tools.ts'
31
31
  import { PATH_PREFIX, WikiServer, type WikiServerOptions } from './host/wiki.ts'
@@ -40,9 +40,9 @@ export const inject = ['tools', 'systemPrompt']
40
40
  /** Re-exports for the headless selftest and future consumers. */
41
41
  export { AutoCommitter, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, dshHomePath, defineTool }
42
42
  export { ConfigStore, deepMerge } from './host/config.ts'
43
- export { openInTwEditor } from './host/routes.ts'
43
+ export { openInTwEditor, registerRoutes } from './host/routes.ts'
44
44
  export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, bundledCatalog, ensureLanguage, normalizeThemes } from './host/admin.ts'
45
- export { seedDocNote, seedSidebarLeftCss, DOC_NOTE_TITLE, DOC_NOTE_TAG, DOC_NOTE_TEXT, SIDEBAR_LEFT_CSS_TITLE, SIDEBAR_LEFT_CSS } from './host/seed-notes.ts'
45
+ export { seedDocNote, DOC_NOTE_TITLE, DOC_NOTE_TAG, DOC_NOTE_TEXT } from './host/seed-notes.ts'
46
46
  export type { PluginConfigShape } from './host/config.ts'
47
47
  export type { GitStatusView } from './host/git.ts'
48
48
  export type { Tiddler } from './host/tw-api.ts'
@@ -55,7 +55,7 @@ export interface TiddlywikiConfig {
55
55
  port?: number
56
56
  git?: { autoCommit?: boolean; debounceMs?: number; remote?: string; branch?: string }
57
57
  note?: { tag?: string }
58
- ui?: { showQuickNote?: boolean; sidebarLeftCss?: boolean }
58
+ ui?: { showQuickNote?: boolean; showPanelStatus?: boolean; showSyncButton?: boolean }
59
59
  auth?: { username?: string; password?: string }
60
60
  }
61
61
 
@@ -76,7 +76,7 @@ interface ResolvedConfig {
76
76
  port: number
77
77
  git: { autoCommit: boolean; debounceMs: number; remote: string; branch: string }
78
78
  note: { tag: string }
79
- ui: { showQuickNote: boolean; sidebarLeftCss: boolean }
79
+ ui: { showQuickNote: boolean; showPanelStatus: boolean; showSyncButton: boolean }
80
80
  auth: { username?: string; password?: string }
81
81
  }
82
82
 
@@ -86,7 +86,7 @@ const DEFAULTS: ResolvedConfig = {
86
86
  port: 0,
87
87
  git: { autoCommit: true, debounceMs: 60_000, remote: '', branch: 'main' },
88
88
  note: { tag: 'inbox' },
89
- ui: { showQuickNote: true, sidebarLeftCss: true },
89
+ ui: { showQuickNote: true, showPanelStatus: true, showSyncButton: true },
90
90
  auth: { username: '', password: '' },
91
91
  }
92
92
 
@@ -181,9 +181,10 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
181
181
  const tag = eff().note?.tag
182
182
  return typeof tag === 'string' && tag.trim().length > 0 ? tag : config.note.tag
183
183
  }
184
- const effectiveUi = (): { showQuickNote: boolean; sidebarLeftCss: boolean } => ({
184
+ const effectiveUi = (): { showQuickNote: boolean; showPanelStatus: boolean; showSyncButton: boolean } => ({
185
185
  showQuickNote: eff().ui?.showQuickNote !== false,
186
- sidebarLeftCss: eff().ui?.sidebarLeftCss !== false,
186
+ showPanelStatus: eff().ui?.showPanelStatus !== false,
187
+ showSyncButton: eff().ui?.showSyncButton !== false,
187
188
  })
188
189
 
189
190
  const disposers: Array<() => void> = []
@@ -265,6 +266,9 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
265
266
  wikiPath: () => wikiPath,
266
267
  noteTag: effectiveNoteTag,
267
268
  autoCommit: () => committer?.touch(),
269
+ // After a pull that changed the working tree, restart TW so the server
270
+ // (and the agent's reads) see the pulled content, not the old snapshot.
271
+ restartWiki: async () => { await server.restart() },
268
272
  }
269
273
  disposers.push(...registerTiddlywikiTools(ctx, toolsDeps))
270
274
 
@@ -276,16 +280,13 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
276
280
  // Seeds (run after the effective config is loaded):
277
281
  // - doc note: ONE-SHOT — a fresh wiki gets the plugin guide; deleting
278
282
  // it afterwards survives restarts (see seedDocNote).
279
- // - sidebar-left CSS: PATCH-REPAIR while ui.sidebarLeftCss is on —
280
- // re-seeds when missing (fresh install OR user deleted it).
281
283
  try {
282
284
  const seedClient = client()
283
285
  if (seedClient !== undefined) {
284
286
  await seedDocNote(seedClient)
285
- await seedSidebarLeftCss(seedClient, effectiveUi().sidebarLeftCss)
286
287
  }
287
288
  } catch (err) {
288
- console.warn('[dsh-tiddlywiki] seeding doc note / css:', err)
289
+ console.warn('[dsh-tiddlywiki] seeding doc note:', err)
289
290
  }
290
291
  // Apply the configured UI language (e.g. "zh-Hans"): enable the bundled
291
292
  // language plugin in tiddlywiki.info.languages + restart once so TW loads