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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.bundle.js +1417 -0
- package/lib/client.js +1427 -0
- package/lib/index.js +2153 -0
- package/lib/index.js.map +1 -0
- package/package.json +63 -0
- package/src/client/editor-popup.ts +121 -0
- package/src/client/index.ts +76 -0
- package/src/client/note-widget.ts +210 -0
- package/src/client/panel.ts +304 -0
- package/src/client/settings-page.ts +397 -0
- package/src/client/sidebar-entry.ts +148 -0
- package/src/client/state.ts +39 -0
- package/src/client/styles.ts +235 -0
- package/src/client/toast.ts +22 -0
- package/src/host/admin.ts +408 -0
- package/src/host/config.ts +86 -0
- package/src/host/git.ts +218 -0
- package/src/host/routes.ts +233 -0
- package/src/host/seed-notes.ts +62 -0
- package/src/host/tools.ts +254 -0
- package/src/host/tw-api.ts +157 -0
- package/src/host/wiki.ts +287 -0
- package/src/index.ts +331 -0
- package/src/sdk.ts +198 -0
package/src/host/wiki.ts
ADDED
|
@@ -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
|
+
}
|