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/LICENSE +21 -21
- package/README.md +12 -6
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +2173 -1652
- package/lib/client.js +2183 -1662
- package/lib/index.js +2424 -2263
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/editor-popup.ts +121 -121
- package/src/client/index.ts +2 -0
- package/src/client/markdown.ts +137 -0
- package/src/client/note-widget.ts +515 -373
- package/src/client/panel.ts +51 -25
- package/src/client/settings-page.ts +32 -2
- package/src/client/sidebar-entry.ts +148 -148
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +111 -28
- package/src/client/sync-button.ts +188 -0
- package/src/client/toast.ts +22 -22
- package/src/host/admin.ts +408 -408
- package/src/host/config.ts +4 -2
- package/src/host/git.ts +13 -3
- package/src/host/routes.ts +151 -1
- package/src/host/seed-notes.ts +79 -122
- package/src/host/tools.ts +28 -1
- package/src/host/tw-api.ts +157 -157
- package/src/host/wiki.ts +287 -287
- package/src/index.ts +13 -12
- package/src/sdk.ts +198 -198
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,
|
|
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,
|
|
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;
|
|
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;
|
|
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,
|
|
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;
|
|
184
|
+
const effectiveUi = (): { showQuickNote: boolean; showPanelStatus: boolean; showSyncButton: boolean } => ({
|
|
185
185
|
showQuickNote: eff().ui?.showQuickNote !== false,
|
|
186
|
-
|
|
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
|
|
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
|