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,86 @@
1
+ /**
2
+ * Extensible plugin config (design doc §13, config panel).
3
+ *
4
+ * Two layers:
5
+ * base — the cordis `config:` block (profile composition, defaults);
6
+ * overrides — a user-editable config tiddler ($:/plugins/dsh-tiddlywiki/config,
7
+ * a JSON string) written by the settings page.
8
+ * The tiddler overlays the base (tiddler wins), so future config fields just
9
+ * extend the shape — no schema, no @deepseek-ai dependency, and the config
10
+ * travels with the wiki's git history.
11
+ *
12
+ * @module dsh-tiddlywiki/host/config
13
+ */
14
+ import type { TiddlyWebClient } from './tw-api.ts'
15
+
16
+ /** Config tiddler (JSON string) where the settings page stores overrides. */
17
+ export const CONFIG_TIDDLER = '$:/plugins/dsh-tiddlywiki/config'
18
+
19
+ /** Extensible, loose plugin config shape (future fields just appear here). */
20
+ export interface PluginConfigShape {
21
+ note?: { tag?: string }
22
+ git?: { autoCommit?: boolean; debounceMs?: number; remote?: string; branch?: string }
23
+ uiLanguage?: string
24
+ [key: string]: unknown
25
+ }
26
+
27
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
28
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
29
+ }
30
+
31
+ /** Deep-merge: `over` wins; nested plain objects merge recursively. */
32
+ export function deepMerge(base: Record<string, unknown>, over: Record<string, unknown>): Record<string, unknown> {
33
+ const out: Record<string, unknown> = { ...base }
34
+ for (const [key, value] of Object.entries(over)) {
35
+ if (value === undefined) continue
36
+ if (isPlainObject(value) && isPlainObject(out[key])) {
37
+ out[key] = deepMerge(out[key] as Record<string, unknown>, value)
38
+ } else {
39
+ out[key] = value
40
+ }
41
+ }
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * Runtime config store: caches the override tiddler and exposes the effective
47
+ * (merged) config. `load` runs at startup and after every write/restart.
48
+ */
49
+ export class ConfigStore {
50
+ private overrides: PluginConfigShape = {}
51
+
52
+ constructor(private readonly base: PluginConfigShape) {}
53
+
54
+ /** Effective config = cordis base overlaid with the user override tiddler. */
55
+ get(): PluginConfigShape {
56
+ return deepMerge(this.base, this.overrides) as PluginConfigShape
57
+ }
58
+
59
+ /** Reload the override tiddler (no-op when the wiki is unavailable). */
60
+ async load(client: TiddlyWebClient | undefined): Promise<void> {
61
+ this.overrides = {}
62
+ if (client === undefined) return
63
+ try {
64
+ const tiddler = await client.get(CONFIG_TIDDLER)
65
+ if (tiddler !== undefined && typeof tiddler.text === 'string') {
66
+ const parsed = JSON.parse(tiddler.text) as unknown
67
+ if (isPlainObject(parsed)) this.overrides = parsed as PluginConfigShape
68
+ }
69
+ } catch {
70
+ // Wiki not ready or config tiddler unreadable → keep empty overrides.
71
+ this.overrides = {}
72
+ }
73
+ }
74
+
75
+ /** Merge a patch into the overrides and persist the tiddler. */
76
+ async set(client: TiddlyWebClient, patch: PluginConfigShape): Promise<PluginConfigShape> {
77
+ this.overrides = deepMerge(this.overrides, patch) as PluginConfigShape
78
+ await client.put({
79
+ title: CONFIG_TIDDLER,
80
+ text: JSON.stringify(this.overrides, null, 2),
81
+ type: 'application/json',
82
+ tags: [],
83
+ })
84
+ return this.get()
85
+ }
86
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Git face (design doc §7, D11) — the ONLY place dsh-tiddlywiki shells out to
3
+ * git. The wiki folder itself is the repository; the folder is pure text
4
+ * (FileSystemAdaptor writes one file per tiddler), so git is a natural sync /
5
+ * backup channel.
6
+ *
7
+ * Sync model is the single-thread alternating one:
8
+ * 1. start of work: `git pull --rebase --autostash`
9
+ * 2. end of work: `git add -A && git commit && git push`
10
+ * 3. auto-commit: debounced 60s commit after wiki writes (AutoCommitter)
11
+ *
12
+ * Conflict policy (user-confirmed, no complex handling): a rebase conflict
13
+ * (only reachable by "forgot to pull before writing") → `git rebase --abort`
14
+ * + report the unmerged files. Never auto-merge data.
15
+ *
16
+ * @module dsh-tiddlywiki/host/git
17
+ */
18
+ import { execFile } from 'node:child_process'
19
+ import { promisify } from 'node:util'
20
+
21
+ const execFileP = promisify(execFile)
22
+
23
+ /** Timeout for quick read-only queries. */
24
+ const QUICK_TIMEOUT_MS = 5_000
25
+
26
+ /** Timeout for structural/network operations. */
27
+ const HEAVY_TIMEOUT_MS = 60_000
28
+
29
+ export interface ExecResult { ok: boolean; stdout: string; stderr: string }
30
+ export type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>
31
+
32
+ /** Default exec layer: run `git <args>` under a cwd with a timeout. */
33
+ const defaultExec: ExecFn = async (args, options) => {
34
+ try {
35
+ const { stdout, stderr } = await execFileP('git', args, {
36
+ cwd: options.cwd,
37
+ timeout: options.timeout ?? QUICK_TIMEOUT_MS,
38
+ windowsHide: true,
39
+ encoding: 'utf8',
40
+ maxBuffer: 32 * 1024 * 1024,
41
+ })
42
+ return { ok: true, stdout, stderr }
43
+ } catch (err) {
44
+ const e = err as { stdout?: string; stderr?: string; message?: string }
45
+ return { ok: false, stdout: e.stdout ?? '', stderr: e.stderr ?? String(e.message ?? err) }
46
+ }
47
+ }
48
+
49
+ export interface GitStatusView {
50
+ exists: boolean
51
+ branch: string
52
+ dirty: boolean
53
+ dirtyFiles: string[]
54
+ remote: string
55
+ lastCommit?: string
56
+ ahead?: number
57
+ behind?: number
58
+ }
59
+
60
+ export interface GitActionResult { ok: boolean; message: string; conflictFiles?: string[] }
61
+
62
+ function parseCount(line: string, re: RegExp): number | undefined {
63
+ const m = line.match(re)
64
+ return m === null ? undefined : Number(m[1])
65
+ }
66
+
67
+ export class GitFace {
68
+ constructor(private readonly exec: ExecFn = defaultExec) {}
69
+
70
+ async isRepo(dir: string): Promise<boolean> {
71
+ const r = await this.exec(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 2_000 })
72
+ return r.ok && r.stdout.trim() === 'true'
73
+ }
74
+
75
+ async init(dir: string, branch = 'main'): Promise<boolean> {
76
+ const r = await this.exec(['init', '-b', branch], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
77
+ return r.ok
78
+ }
79
+
80
+ /** Initial commit for a fresh repo (tolerates an empty index). */
81
+ async initialCommit(dir: string): Promise<boolean> {
82
+ await this.exec(['add', '-A'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
83
+ const r = await this.exec([...identity(), 'commit', '-m', 'chore(dsh-tiddlywiki): initial commit'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
84
+ return r.ok || /nothing to commit/.test(r.stderr + r.stdout)
85
+ }
86
+
87
+ /**
88
+ * Stage everything and commit; a local identity is always provided so the
89
+ * plugin never depends on the machine's global git config. Returns whether
90
+ * a commit actually happened.
91
+ */
92
+ async commit(dir: string, message: string): Promise<{ committed: boolean; message: string }> {
93
+ await this.exec(['add', '-A'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
94
+ const staged = await this.exec(['diff', '--cached', '--quiet'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
95
+ // `diff --cached --quiet` exits 0 when nothing is staged → nothing to commit.
96
+ if (staged.ok) return { committed: false, message: 'nothing to commit' }
97
+ const r = await this.exec([...identity(), 'commit', '-m', message], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
98
+ return r.ok
99
+ ? { committed: true, message }
100
+ : { committed: false, message: `commit failed: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 500)}` }
101
+ }
102
+
103
+ async status(dir: string): Promise<GitStatusView> {
104
+ const empty: GitStatusView = { exists: false, branch: '', dirty: false, dirtyFiles: [], remote: '' }
105
+ const r = await this.exec(['status', '--porcelain', '-b'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
106
+ if (!r.ok) return empty
107
+ const lines = r.stdout.split('\n').filter((l) => l.length > 0)
108
+ const branchLine = lines.find((l) => l.startsWith('## '))
109
+ const branch = branchLine === undefined ? '' : branchLine.slice(3).split('...')[0] ?? ''
110
+ const ahead = branchLine === undefined ? undefined : parseCount(branchLine, /ahead (\d+)/)
111
+ const behind = branchLine === undefined ? undefined : parseCount(branchLine, /behind (\d+)/)
112
+ const dirty = lines.some((l) => !l.startsWith('## '))
113
+ const dirtyFiles = lines.filter((l) => !l.startsWith('## ')).map((l) => l.slice(3).trim()).filter(Boolean)
114
+ const remoteR = await this.exec(['remote', '-v'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
115
+ const remote = remoteR.ok ? remoteR.stdout.split('\n').map((l) => l.trim()).find(Boolean) ?? '' : ''
116
+ const lastR = await this.exec(['log', '-1', '--format=%h %s'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
117
+ const lastCommit = lastR.ok && lastR.stdout.trim().length > 0 ? lastR.stdout.trim() : undefined
118
+ return { exists: true, branch, dirty, dirtyFiles, remote, ...(lastCommit !== undefined ? { lastCommit } : {}), ...(ahead !== undefined ? { ahead } : {}), ...(behind !== undefined ? { behind } : {}) }
119
+ }
120
+
121
+ /** `git pull --rebase --autostash`; on conflict: abort + report files. */
122
+ async pull(dir: string): Promise<GitActionResult> {
123
+ const r = await this.exec(['pull', '--rebase', '--autostash'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
124
+ if (r.ok) return { ok: true, message: r.stdout.trim() || 'pull ok' }
125
+ const conflictFiles = await this.unmergedFiles(dir)
126
+ await this.exec(['rebase', '--abort'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
127
+ const reason = (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
128
+ return { ok: false, message: conflictFiles.length > 0 ? `conflict in ${conflictFiles.join(', ')} (rebase aborted): ${reason}` : `pull failed: ${reason}`, ...(conflictFiles.length > 0 ? { conflictFiles } : {}) }
129
+ }
130
+
131
+ async push(dir: string): Promise<GitActionResult> {
132
+ const r = await this.exec(['push'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
133
+ return r.ok
134
+ ? { ok: true, message: r.stdout.trim() || 'push ok' }
135
+ : { ok: false, message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500) }
136
+ }
137
+
138
+ /** First push with upstream tracking (called once after a remote is set). */
139
+ async firstPush(dir: string): Promise<GitActionResult> {
140
+ const branch = (await this.status(dir)).branch || 'main'
141
+ const r = await this.exec(['push', '-u', 'origin', branch], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
142
+ return r.ok
143
+ ? { ok: true, message: `pushed ${branch} to origin` }
144
+ : { ok: false, message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500) }
145
+ }
146
+
147
+ /** Ensure `origin` points at `url` (add or set-url). */
148
+ async ensureRemote(dir: string, url: string): Promise<GitActionResult> {
149
+ const cur = await this.exec(['remote', 'get-url', 'origin'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
150
+ if (cur.ok) {
151
+ if (cur.stdout.trim() === url) return { ok: true, message: 'remote origin already set' }
152
+ const set = await this.exec(['remote', 'set-url', 'origin', url], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
153
+ return set.ok ? { ok: true, message: `remote origin → ${url}` } : { ok: false, message: set.stderr.trim() || 'remote set-url failed' }
154
+ }
155
+ const add = await this.exec(['remote', 'add', 'origin', url], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
156
+ return add.ok ? { ok: true, message: `remote origin → ${url}` } : { ok: false, message: add.stderr.trim() || 'remote add failed' }
157
+ }
158
+
159
+ private async unmergedFiles(dir: string): Promise<string[]> {
160
+ const r = await this.exec(['diff', '--name-only', '--diff-filter=U'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
161
+ return r.ok ? r.stdout.split('\n').map((l) => l.trim()).filter(Boolean) : []
162
+ }
163
+ }
164
+
165
+ /** Always-on local identity so commits never depend on global git config. */
166
+ function identity(): string[] {
167
+ return ['-c', 'user.name=dsh-tiddlywiki', '-c', 'user.email=dsh-tiddlywiki@local']
168
+ }
169
+
170
+ export interface AutoCommitterOptions {
171
+ git: GitFace
172
+ dir: string
173
+ enabled: boolean
174
+ debounceMs: number
175
+ message: () => string
176
+ onError?: (err: unknown) => void
177
+ onCommit?: (info: { committed: boolean; message: string }) => void
178
+ }
179
+
180
+ /**
181
+ * Debounced auto-committer: every wiki write calls `touch()`; the commit
182
+ * fires once writes settle for `debounceMs`. Disable with git.autoCommit.
183
+ */
184
+ export class AutoCommitter {
185
+ private timer: NodeJS.Timeout | undefined
186
+ private disposed = false
187
+
188
+ constructor(private readonly options: AutoCommitterOptions) {}
189
+
190
+ touch(): void {
191
+ if (!this.options.enabled || this.disposed) return
192
+ if (this.timer !== undefined) clearTimeout(this.timer)
193
+ this.timer = setTimeout(() => { void this.flush() }, this.options.debounceMs)
194
+ }
195
+
196
+ /** Run a commit now (also cancels the pending debounce). */
197
+ async flush(): Promise<void> {
198
+ if (this.timer !== undefined) {
199
+ clearTimeout(this.timer)
200
+ this.timer = undefined
201
+ }
202
+ if (!this.options.enabled || this.disposed) return
203
+ try {
204
+ const result = await this.options.git.commit(this.options.dir, this.options.message())
205
+ this.options.onCommit?.(result)
206
+ } catch (err) {
207
+ this.options.onError?.(err)
208
+ }
209
+ }
210
+
211
+ dispose(): void {
212
+ this.disposed = true
213
+ if (this.timer !== undefined) {
214
+ clearTimeout(this.timer)
215
+ this.timer = undefined
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * DSH webserver routes for dsh-tiddlywiki (design doc §10).
3
+ *
4
+ * | route | method | purpose |
5
+ * |---------------------------|--------|------------------------------------------|
6
+ * | /dsh-tiddlywiki/status | GET | panel health (service / url / git / tag) |
7
+ * | /dsh-tiddlywiki/note | POST | quick-note → independent tiddler |
8
+ * | /dsh-tiddlywiki/restart | POST | one-click retry/restart of the TW child |
9
+ * | /dsh-tiddlywiki/api/* | any | passthrough to the TW service (JSON) |
10
+ *
11
+ * Matching is exact-over-prefix, so the exact routes win and the `/api`
12
+ * prefix catches the rest. Client calls are same-origin (the DSH web server),
13
+ * so no CORS is involved.
14
+ *
15
+ * @module dsh-tiddlywiki/host/routes
16
+ */
17
+ import type { IncomingMessage, ServerResponse } from 'node:http'
18
+ import type { TiddlyWebClient } from './tw-api.ts'
19
+ import type { WikiServer } from './wiki.ts'
20
+ import type { GitFace } from './git.ts'
21
+ import { PATH_PREFIX } from './wiki.ts'
22
+
23
+ export const ROUTE_PREFIX = PATH_PREFIX
24
+
25
+ /** Max JSON body for note/restart. */
26
+ const MAX_BODY_BYTES = 2 * 1024 * 1024
27
+
28
+ /** Max passthrough body (tiddler content can be large). */
29
+ const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
30
+
31
+ /** Structural webserver face (a subset of dsh-host-webserver). */
32
+ export interface WebServerFace {
33
+ register(route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }): () => void
34
+ }
35
+
36
+ export interface RouteDeps {
37
+ server: WikiServer
38
+ getClient: () => TiddlyWebClient | undefined
39
+ git: GitFace
40
+ autoCommit: () => void
41
+ noteDefaults: () => { tag: string }
42
+ getWikiPath: () => string
43
+ }
44
+
45
+ function readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string> {
46
+ return new Promise((resolveP, rejectP) => {
47
+ let size = 0
48
+ const chunks: Buffer[] = []
49
+ req.on('data', (chunk: Buffer) => {
50
+ size += chunk.length
51
+ if (size > limit) {
52
+ rejectP(new Error('body too large'))
53
+ req.destroy()
54
+ return
55
+ }
56
+ chunks.push(chunk)
57
+ })
58
+ req.on('end', () => resolveP(Buffer.concat(chunks).toString('utf8')))
59
+ req.on('error', rejectP)
60
+ })
61
+ }
62
+
63
+ function json(res: ServerResponse, payload: unknown, status = 200): void {
64
+ const body = JSON.stringify(payload)
65
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
66
+ res.end(body)
67
+ }
68
+
69
+ function pad(n: number): string {
70
+ return n < 10 ? `0${n}` : String(n)
71
+ }
72
+
73
+ /** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
74
+ function timestampTitle(date = new Date()): string {
75
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
76
+ }
77
+
78
+ /**
79
+ * Open a tiddler in TW's NATIVE editor: save the tiddler (when text is
80
+ * non-empty), reuse or create a DRAFT tiddler carrying `draft.of`/`draft.title`
81
+ * (TW's story view renders drafts with the EditTemplate — list.js:
82
+ * `isDraft && editTemplate`), and return the draft title so the client can
83
+ * navigate the panel iframe to `#<draftTitle>`.
84
+ */
85
+ export async function openInTwEditor(
86
+ client: TiddlyWebClient,
87
+ title: string,
88
+ text: string,
89
+ tag: string,
90
+ ): Promise<{ title: string; draftTitle: string }> {
91
+ if (text.trim().length > 0) {
92
+ await client.put({ title, text, tags: [tag] })
93
+ }
94
+ // Draft content: the provided text, else the existing tiddler's content.
95
+ let draftText = text
96
+ if (draftText.trim().length === 0) {
97
+ const existing = await client.get(title)
98
+ draftText = existing?.text ?? ''
99
+ }
100
+ // Reuse an existing draft for this title (mirrors wiki.findDraft).
101
+ let draftTitle: string | undefined
102
+ try {
103
+ const items = await client.list(undefined, true)
104
+ for (const item of items) {
105
+ if (item['draft.of'] === title && typeof item.title === 'string') {
106
+ draftTitle = item.title
107
+ break
108
+ }
109
+ }
110
+ } catch {
111
+ /* fall back to a fresh draft */
112
+ }
113
+ if (draftTitle === undefined) draftTitle = `Draft of "${title}" ${Date.now()}`
114
+ await client.put({ title: draftTitle, text: draftText, 'draft.of': title, 'draft.title': title, type: 'text/vnd.tiddlywiki' })
115
+ return { title, draftTitle }
116
+ }
117
+
118
+ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDeps): () => void {
119
+ const handleStatus = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
120
+ const view = deps.server.status()
121
+ let gitSummary: GitStatusViewPublic | null = null
122
+ try {
123
+ gitSummary = await deps.git.status(deps.getWikiPath())
124
+ } catch {
125
+ gitSummary = null
126
+ }
127
+ json(res, { ok: true, ...view, git: gitSummary, note: { tag: deps.noteDefaults().tag } })
128
+ }
129
+
130
+ const handleNote = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
131
+ try {
132
+ const body = JSON.parse(await readBody(req)) as { title?: unknown; tag?: unknown; text?: unknown }
133
+ const text = typeof body.text === 'string' && body.text.trim().length > 0 ? body.text.trim() : null
134
+ if (text === null) {
135
+ json(res, { ok: false, error: 'text is required' }, 400)
136
+ return
137
+ }
138
+ const client = deps.getClient()
139
+ if (client === undefined) {
140
+ json(res, { ok: false, error: 'wiki service is not running' }, 503)
141
+ return
142
+ }
143
+ const title = typeof body.title === 'string' && body.title.trim().length > 0 ? body.title.trim() : timestampTitle()
144
+ const tag = typeof body.tag === 'string' && body.tag.trim().length > 0 ? body.tag.trim() : deps.noteDefaults().tag
145
+ await client.put({ title, text, tags: [tag] })
146
+ deps.autoCommit()
147
+ json(res, { ok: true, title, tag, text })
148
+ } catch (err) {
149
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
150
+ }
151
+ }
152
+
153
+ const handleEdit = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
154
+ try {
155
+ const body = JSON.parse(await readBody(req)) as { title?: unknown; tag?: unknown; text?: unknown }
156
+ const client = deps.getClient()
157
+ if (client === undefined) {
158
+ json(res, { ok: false, error: 'wiki service is not running' }, 503)
159
+ return
160
+ }
161
+ const title = typeof body.title === 'string' && body.title.trim().length > 0 ? body.title.trim() : timestampTitle()
162
+ const tag = typeof body.tag === 'string' && body.tag.trim().length > 0 ? body.tag.trim() : deps.noteDefaults().tag
163
+ const text = typeof body.text === 'string' ? body.text : ''
164
+ const result = await openInTwEditor(client, title, text, tag)
165
+ deps.autoCommit()
166
+ json(res, { ok: true, ...result, twUrl: deps.server.url })
167
+ } catch (err) {
168
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
169
+ }
170
+ }
171
+
172
+ const handleRestart = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
173
+ try {
174
+ await deps.server.restart()
175
+ json(res, { ok: true, status: deps.server.status().status })
176
+ } catch (err) {
177
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
178
+ }
179
+ }
180
+
181
+ /** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
182
+ const handleApiProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
183
+ const client = deps.getClient()
184
+ if (client === undefined) {
185
+ json(res, { ok: false, error: 'wiki service is not running' }, 503)
186
+ return
187
+ }
188
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
189
+ const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
190
+ try {
191
+ const headers: Record<string, string> = {}
192
+ const ct = req.headers['content-type']
193
+ if (typeof ct === 'string') headers['content-type'] = ct
194
+ const method = (req.method ?? 'GET').toUpperCase()
195
+ // TW's CSRF gate requires X-Requested-With on writes; forward it through.
196
+ if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
197
+ const init: RequestInit = { method, headers, signal: AbortSignal.timeout(15_000) }
198
+ if (method === 'PUT' || method === 'POST') init.body = await readBody(req, MAX_PROXY_BODY_BYTES)
199
+ const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
200
+ const data = await upstream.text()
201
+ res.writeHead(upstream.status, {
202
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json; charset=utf-8',
203
+ 'cache-control': 'no-store',
204
+ })
205
+ res.end(data)
206
+ } catch (err) {
207
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 502)
208
+ }
209
+ }
210
+
211
+ const disposers = [
212
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: (req, res) => { void handleStatus(req, res) } }),
213
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (req, res) => { void handleNote(req, res) } }),
214
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: (req, res) => { void handleEdit(req, res) } }),
215
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
216
+ ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (req, res) => { void handleApiProxy(req, res) } }),
217
+ ]
218
+ return () => {
219
+ for (const dispose of disposers) dispose()
220
+ }
221
+ }
222
+
223
+ /** Public shape of the git status summary sent to the panel. */
224
+ export interface GitStatusViewPublic {
225
+ exists: boolean
226
+ branch: string
227
+ dirty: boolean
228
+ dirtyFiles: string[]
229
+ remote: string
230
+ lastCommit?: string
231
+ ahead?: number
232
+ behind?: number
233
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Built-in doc note for the plugin (design doc §14): a short user-facing
3
+ * "how to use dsh-tiddlywiki" note that is seeded into the wiki on first run.
4
+ *
5
+ * Idempotent seed (create-if-missing): on every plugin start we check whether
6
+ * the note tiddler exists and only write it when it is absent — deleting it
7
+ * and restarting dsh web recreates it, but editing it never gets overwritten.
8
+ *
9
+ * @module dsh-tiddlywiki/host/seed-notes
10
+ */
11
+ import type { TiddlyWebClient } from './tw-api.ts'
12
+
13
+ /** Note tiddler title (a normal, searchable note — not a system tiddler). */
14
+ export const DOC_NOTE_TITLE = 'dsh-tiddlywiki 插件说明'
15
+
16
+ /** Tag that makes the note easy to find via `tiddlywiki_search tag=docs`. */
17
+ export const DOC_NOTE_TAG = 'docs'
18
+
19
+ /** The note body, TiddlyWiki wiki-text. */
20
+ export const DOC_NOTE_TEXT = `! dsh-tiddlywiki 插件说明
21
+
22
+ 本插件把 **TiddlyWiki 5** 作为 DSH 的持久知识库(wiki 文件夹本身就是一个 git 仓库,随内容自动提交/同步)。
23
+
24
+ !! 它能做什么
25
+
26
+ * **5 个 agent 工具**:\`tiddlywiki_search\`(检索)/ \`tiddlywiki_get\`(读)/ \`tiddlywiki_put\`(写)/ \`tiddlywiki_delete\`(删)/ \`tiddlywiki_git_sync\`(git 同步)。
27
+ * **TW 编辑器面板**:侧边栏「TiddlyWiki」按钮 → 在界面中央打开完整版 TW 编辑器。
28
+ * **快速笔记**:右下角悬浮「📝 快速笔记」写随手记,\`Ctrl+Enter\` 保存;点「✏️ 在 TW 中编辑」会弹出独立小窗用 TW 原生编辑器编辑。
29
+ * **git 同步**:写入自动防抖 commit(默认 60 秒);手动 \`tiddlywiki_git_sync action=sync\` 做 pull → commit → push。
30
+ * **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置。
31
+
32
+ !! 知识库纪律(三条)
33
+
34
+ 1. 开工先 \`tiddlywiki_git_sync action=pull\`(rebase + autostash,真冲突会自动 abort 并报文件)。
35
+ 2. 收工 \`tiddlywiki_git_sync action=sync\`。
36
+ 3. 插件自动 commit 兜底,手动 sync 用于需要主动推送的场合。
37
+
38
+ !! 主题与语言
39
+
40
+ * **主题**分两层:每行一个「☑ 加载」(多选 = TW 里可用的主题,依赖链自动带上)和「◉ 活动」(单选 = 当前视觉主题)。应用后自动重启 TW。
41
+ * **语言**:设置页勾选 \`zh-Hans\`(简体)并应用,TW 界面即切换为中文。
42
+
43
+ !! 说明
44
+
45
+ * 本笔记由插件在首次启动时自动写入 wiki(幂等:不存在才写)。删除后重启 dsh web 会重建;手动编辑过的内容不会被覆盖。
46
+ * 更多细节见插件仓库 README。`
47
+
48
+ /**
49
+ * Seed the doc note when it is absent. Returns whether a note was written.
50
+ * Never throws (missing wiki or note already present → no-op / false).
51
+ */
52
+ export async function seedDocNote(client: TiddlyWebClient): Promise<boolean> {
53
+ const existing = await client.get(DOC_NOTE_TITLE).catch(() => undefined)
54
+ if (existing !== undefined) return false
55
+ await client.put({
56
+ title: DOC_NOTE_TITLE,
57
+ text: DOC_NOTE_TEXT,
58
+ type: 'text/vnd.tiddlywiki',
59
+ tags: [DOC_NOTE_TAG],
60
+ })
61
+ return true
62
+ }