@objectivex666/dsh-settings-search 1.12.0 → 1.13.1
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/README.en.md +9 -7
- package/README.md +9 -7
- package/lib/client.js +598 -91
- package/lib/index.js +448 -11
- package/package.json +5 -4
- package/scripts/ai.test.mjs +311 -2
- package/scripts/check.mjs +28 -0
- package/scripts/log.test.mjs +345 -0
package/lib/index.js
CHANGED
|
@@ -1,22 +1,459 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-settings-search — host half.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* The browser half owns the search UI, but the panel's log actions need the
|
|
5
|
+
* host: a page can neither append to a file nor reveal a folder.
|
|
6
|
+
*
|
|
7
|
+
* - `GET /api/settings-search-log` answers the log tail plus the file and
|
|
8
|
+
* folder paths, so the panel can show the history of earlier runs and name
|
|
9
|
+
* the folder it offers to open;
|
|
10
|
+
* - `POST /api/settings-search-log` appends browser-reported lines, truncates
|
|
11
|
+
* the file, or opens its folder in the OS file manager.
|
|
12
|
+
*
|
|
13
|
+
* Both ride the Host connection's authenticated `/api` channel — the carrier
|
|
14
|
+
* the shipped session-log export uses — so the browser reaches them
|
|
15
|
+
* document-relative (`api/settings-search-log`) and never handles a token.
|
|
16
|
+
*
|
|
17
|
+
* Log files live in `$DSH_HOME/logs/dsh-settings-search/`, beside other
|
|
18
|
+
* plugins' logs, one file per local day named `settings-search-YYYY-MM-DD.log`.
|
|
19
|
+
* A day that would pass {@link MAX_FILE_BYTES} continues in a numbered part
|
|
20
|
+
* (`…-YYYY-MM-DD.1.log`), and the folder as a whole is pruned to
|
|
21
|
+
* {@link MAX_TOTAL_BYTES} by deleting the oldest files — never the one being
|
|
22
|
+
* written.
|
|
11
23
|
*/
|
|
24
|
+
import { spawn } from 'node:child_process'
|
|
25
|
+
import {
|
|
26
|
+
appendFileSync,
|
|
27
|
+
closeSync,
|
|
28
|
+
existsSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
openSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readSync,
|
|
34
|
+
renameSync,
|
|
35
|
+
statSync,
|
|
36
|
+
unlinkSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
} from 'node:fs'
|
|
39
|
+
import { homedir } from 'node:os'
|
|
40
|
+
import { join } from 'node:path'
|
|
12
41
|
|
|
13
42
|
/** Plugin row id used by cordis.patch.yml (also the settings-nav id filter). */
|
|
14
43
|
export const name = 'settings-search'
|
|
15
44
|
|
|
45
|
+
/** The Host connection owns the authenticated `/api` exact-Fetch registry. */
|
|
46
|
+
export const inject = ['connection']
|
|
47
|
+
|
|
48
|
+
/** Absolute registration path; the browser addresses it as `api/settings-search-log`. */
|
|
49
|
+
const ROUTE_PATH = '/api/settings-search-log'
|
|
50
|
+
const LOG_DIR_NAME = 'dsh-settings-search'
|
|
51
|
+
const LOG_FILE_PREFIX = 'settings-search'
|
|
52
|
+
/** One file per local day: `settings-search-YYYY-MM-DD.log`. */
|
|
53
|
+
const LOG_FILE_PATTERN = /^settings-search(-\d{4}-\d{2}-\d{2}(\.[0-9]+)?)?\.log(\.[0-9]+)?$/
|
|
54
|
+
/** A single log file never grows past this; the day continues in a `.N` part. */
|
|
55
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024
|
|
56
|
+
/** The whole log folder stays within this, oldest files pruned first. */
|
|
57
|
+
const MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
|
58
|
+
/** Serve at most this much of the active file to the viewer. */
|
|
59
|
+
const TAIL_BYTES = 256 * 1024
|
|
60
|
+
const MAX_LINE_LENGTH = 1000
|
|
61
|
+
const MAX_LINES_PER_REQUEST = 200
|
|
62
|
+
const MAX_REQUEST_BYTES = 256 * 1024
|
|
63
|
+
/** How long a folder launcher may run before it counts as launched. */
|
|
64
|
+
const LAUNCH_WATCH_MS = 1500
|
|
65
|
+
|
|
66
|
+
/** Local calendar day of one Date, as the log filename's date stamp. */
|
|
67
|
+
const dayStamp = (date) => {
|
|
68
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
69
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Default log folder: DSH's own logs root, beside the other plugins'. */
|
|
73
|
+
const defaultLogDir = () => join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'logs', LOG_DIR_NAME)
|
|
74
|
+
|
|
75
|
+
/** Host environment minus anything credential-shaped, for the folder launcher. */
|
|
76
|
+
const scrubbedEnv = () => {
|
|
77
|
+
const env = {}
|
|
78
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
79
|
+
if (value === undefined) continue
|
|
80
|
+
if (/KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/i.test(key)) continue
|
|
81
|
+
env[key] = value
|
|
82
|
+
}
|
|
83
|
+
return env
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The platform command that reveals one folder in the OS file manager.
|
|
88
|
+
*
|
|
89
|
+
* Windows goes through `cmd /c start`, i.e. the shell's own open verb. Spawning
|
|
90
|
+
* `explorer.exe` directly with `windowsHide` (CREATE_NO_WINDOW) makes Explorer
|
|
91
|
+
* create a window that never becomes visible — measured on this host: the shell
|
|
92
|
+
* window existed with `IsWindowVisible() === false`, which is the "nothing
|
|
93
|
+
* opens" report this replaced. The shell verb also hands the new window the
|
|
94
|
+
* foreground, so it cannot end up hidden behind dsh.
|
|
95
|
+
*
|
|
96
|
+
* @param platform - `process.platform`.
|
|
97
|
+
* @param dir - absolute folder path.
|
|
98
|
+
* @returns the launcher command, its argv, and whether its own window may hide.
|
|
99
|
+
*/
|
|
100
|
+
export const folderLaunchCommand = (platform, dir) => {
|
|
101
|
+
if (platform === 'win32') return { command: 'cmd.exe', args: ['/c', 'start', '', dir], hideWindow: true }
|
|
102
|
+
if (platform === 'darwin') return { command: 'open', args: [dir], hideWindow: false }
|
|
103
|
+
return { command: 'xdg-open', args: [dir], hideWindow: false }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Launch one detached process that outlives dsh.
|
|
108
|
+
* @param command - executable path or PATH name.
|
|
109
|
+
* @param args - argv (never a shell string).
|
|
110
|
+
* @param options - whether the launcher's own window may be hidden.
|
|
111
|
+
* @returns undefined on success, otherwise a readable failure reason.
|
|
112
|
+
*/
|
|
113
|
+
const launchDetached = (command, args, options) => new Promise((resolve) => {
|
|
114
|
+
let settled = false
|
|
115
|
+
let watch = null
|
|
116
|
+
const done = (error) => {
|
|
117
|
+
if (settled) return
|
|
118
|
+
settled = true
|
|
119
|
+
if (watch !== null) clearTimeout(watch)
|
|
120
|
+
child.unref()
|
|
121
|
+
resolve(error)
|
|
122
|
+
}
|
|
123
|
+
let child
|
|
124
|
+
try {
|
|
125
|
+
child = spawn(command, args, {
|
|
126
|
+
detached: true,
|
|
127
|
+
stdio: 'ignore',
|
|
128
|
+
windowsHide: options.hideWindow,
|
|
129
|
+
env: scrubbedEnv(),
|
|
130
|
+
})
|
|
131
|
+
} catch (error) {
|
|
132
|
+
resolve(String(error?.message ?? error))
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
// A launcher still running when the watch window closes has handed the window
|
|
136
|
+
// over and outlives dsh — some Linux file managers stay in the foreground.
|
|
137
|
+
watch = setTimeout(() => done(undefined), LAUNCH_WATCH_MS)
|
|
138
|
+
child.once('error', (error) => done(String(error?.message ?? error)))
|
|
139
|
+
child.once('exit', (code) => done(code === 0 ? undefined : `${command} exited with code ${code}`))
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Open one folder in the OS file manager.
|
|
144
|
+
* @param dir - absolute folder path, created by the caller.
|
|
145
|
+
* @returns the launcher used, and undefined as the failure or a reason for it.
|
|
146
|
+
*/
|
|
147
|
+
const openFolder = async (dir) => {
|
|
148
|
+
const primary = folderLaunchCommand(process.platform, dir)
|
|
149
|
+
const failure = await launchDetached(primary.command, primary.args, { hideWindow: primary.hideWindow })
|
|
150
|
+
if (failure === undefined) return { failure: undefined, command: primary.command }
|
|
151
|
+
// A host without the shell launcher still has explorer.exe, which shows its
|
|
152
|
+
// window normally as long as it is not started with CREATE_NO_WINDOW.
|
|
153
|
+
if (process.platform === 'win32' && primary.command !== 'explorer.exe') {
|
|
154
|
+
return { failure: await launchDetached('explorer.exe', [dir], { hideWindow: false }), command: 'explorer.exe' }
|
|
155
|
+
}
|
|
156
|
+
return { failure, command: primary.command }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create the daily log files behind the panel's log actions.
|
|
161
|
+
*
|
|
162
|
+
* One file per local day (`settings-search-YYYY-MM-DD.log`), continued in a
|
|
163
|
+
* numbered part when a day passes {@link MAX_FILE_BYTES}, with the folder
|
|
164
|
+
* pruned to {@link MAX_TOTAL_BYTES} by deleting its oldest files. The file
|
|
165
|
+
* being written is never pruned: closing the cap around it would drop exactly
|
|
166
|
+
* the entries an operator is watching.
|
|
167
|
+
*
|
|
168
|
+
* @param options - `dir`, `now`, `maxBytes`, `maxTotalBytes`, `tailBytes`, and
|
|
169
|
+
* `io` overrides (the first five are used by tests; `io` lets
|
|
170
|
+
* tests simulate filesystem short reads).
|
|
171
|
+
* @returns the folder, the active file path, and append/read/count/clear/open.
|
|
172
|
+
*/
|
|
173
|
+
export function createLogStore(options = {}) {
|
|
174
|
+
const dir = options.dir ?? process.env.DSH_SETTINGS_SEARCH_LOG_DIR ?? defaultLogDir()
|
|
175
|
+
const now = options.now ?? (() => new Date())
|
|
176
|
+
const maxBytes = options.maxBytes ?? MAX_FILE_BYTES
|
|
177
|
+
const maxTotalBytes = options.maxTotalBytes ?? MAX_TOTAL_BYTES
|
|
178
|
+
const tailBytes = options.tailBytes ?? TAIL_BYTES
|
|
179
|
+
/** File operations used by the synchronous tail read (injectable for tests). */
|
|
180
|
+
const io = options.io ?? { openSync, readSync, closeSync }
|
|
181
|
+
const ensure = () => {
|
|
182
|
+
mkdirSync(dir, { recursive: true })
|
|
183
|
+
return dir
|
|
184
|
+
}
|
|
185
|
+
const fileFor = (stamp, part) => join(dir, part === 0
|
|
186
|
+
? `${LOG_FILE_PREFIX}-${stamp}.log`
|
|
187
|
+
: `${LOG_FILE_PREFIX}-${stamp}.${part}.log`)
|
|
188
|
+
/**
|
|
189
|
+
* Fold the pre-daily `settings-search.log` into today's dated file the first
|
|
190
|
+
* time this store runs, so an upgrade keeps its history under the new name
|
|
191
|
+
* instead of leaving an undated file behind for good.
|
|
192
|
+
*/
|
|
193
|
+
let migrated = false
|
|
194
|
+
const migrateLegacy = () => {
|
|
195
|
+
if (migrated) return
|
|
196
|
+
migrated = true
|
|
197
|
+
try {
|
|
198
|
+
const legacy = join(dir, `${LOG_FILE_PREFIX}.log`)
|
|
199
|
+
const target = fileFor(dayStamp(now()), 0)
|
|
200
|
+
if (existsSync(legacy) && !existsSync(target)) renameSync(legacy, target)
|
|
201
|
+
} catch {
|
|
202
|
+
// The legacy file simply stays where pruning can still reclaim it.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The file this day is currently writing: the newest part that still has
|
|
207
|
+
* room. Resolved per call, so the calendar day rolls over on its own.
|
|
208
|
+
* @returns the active file path.
|
|
209
|
+
*/
|
|
210
|
+
const activeFile = () => {
|
|
211
|
+
migrateLegacy()
|
|
212
|
+
const stamp = dayStamp(now())
|
|
213
|
+
for (let part = 0; part <= 999; part += 1) {
|
|
214
|
+
const candidate = fileFor(stamp, part)
|
|
215
|
+
try {
|
|
216
|
+
if (!existsSync(candidate) || statSync(candidate).size < maxBytes) return candidate
|
|
217
|
+
} catch {
|
|
218
|
+
return candidate
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return fileFor(stamp, 0)
|
|
222
|
+
}
|
|
223
|
+
/** Every file this plugin owns in the folder (current scheme and the pre-daily one). */
|
|
224
|
+
const ownedFiles = () => {
|
|
225
|
+
try {
|
|
226
|
+
return readdirSync(dir).filter((name) => LOG_FILE_PATTERN.test(name)).map((name) => join(dir, name))
|
|
227
|
+
} catch {
|
|
228
|
+
return []
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const statOf = (path) => {
|
|
232
|
+
try {
|
|
233
|
+
const info = statSync(path)
|
|
234
|
+
return { size: info.size, mtimeMs: info.mtimeMs }
|
|
235
|
+
} catch {
|
|
236
|
+
return { size: 0, mtimeMs: 0 }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Keep the folder within its budget by deleting the oldest files.
|
|
241
|
+
* @param active - the file currently being written, never deleted.
|
|
242
|
+
*/
|
|
243
|
+
const prune = (active) => {
|
|
244
|
+
try {
|
|
245
|
+
let total = statOf(active).size
|
|
246
|
+
const candidates = ownedFiles()
|
|
247
|
+
.filter((path) => path !== active)
|
|
248
|
+
.map((path) => ({ path, ...statOf(path) }))
|
|
249
|
+
.sort((left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path))
|
|
250
|
+
for (const entry of candidates) total += entry.size
|
|
251
|
+
for (const entry of candidates) {
|
|
252
|
+
if (total <= maxTotalBytes) break
|
|
253
|
+
try {
|
|
254
|
+
unlinkSync(entry.path)
|
|
255
|
+
total -= entry.size
|
|
256
|
+
} catch {
|
|
257
|
+
// A file another process holds stays until the next prune.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
// Pruning is best effort: a full disk must not stop the log write.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const countLines = (path) => {
|
|
265
|
+
try {
|
|
266
|
+
if (!existsSync(path)) return 0
|
|
267
|
+
const text = readFileSync(path, 'utf8')
|
|
268
|
+
let lines = 0
|
|
269
|
+
for (let i = 0; i < text.length; i += 1) if (text.charCodeAt(i) === 10) lines += 1
|
|
270
|
+
return lines
|
|
271
|
+
} catch {
|
|
272
|
+
return 0
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// The host is a file's only writer, so its entry count is tracked in memory
|
|
276
|
+
// and only read from disk when this process meets a file for the first time.
|
|
277
|
+
let counted = { path: '', lines: 0 }
|
|
278
|
+
const countOf = (path) => {
|
|
279
|
+
if (counted.path !== path) counted = { path, lines: countLines(path) }
|
|
280
|
+
return counted.lines
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
dir,
|
|
284
|
+
/** The file the panel reads, views, and clears right now. */
|
|
285
|
+
get file() {
|
|
286
|
+
return activeFile()
|
|
287
|
+
},
|
|
288
|
+
ensure,
|
|
289
|
+
/**
|
|
290
|
+
* Append browser-reported lines, one entry per line, collapsing any embedded
|
|
291
|
+
* break so one report can never forge a second entry.
|
|
292
|
+
* @param lines - candidate lines.
|
|
293
|
+
* @returns the number of accepted lines.
|
|
294
|
+
*/
|
|
295
|
+
append(lines) {
|
|
296
|
+
const accepted = []
|
|
297
|
+
for (const line of Array.isArray(lines) ? lines.slice(0, MAX_LINES_PER_REQUEST) : []) {
|
|
298
|
+
const text = String(line ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, MAX_LINE_LENGTH)
|
|
299
|
+
if (text !== '') accepted.push(text)
|
|
300
|
+
}
|
|
301
|
+
if (accepted.length === 0) return 0
|
|
302
|
+
ensure()
|
|
303
|
+
const target = activeFile()
|
|
304
|
+
// Resolve the baseline before writing: reading after the append would
|
|
305
|
+
// count this very batch twice.
|
|
306
|
+
const baseline = countOf(target)
|
|
307
|
+
appendFileSync(target, `${accepted.join('\n')}\n`, 'utf8')
|
|
308
|
+
counted = { path: target, lines: baseline + accepted.length }
|
|
309
|
+
prune(target)
|
|
310
|
+
return accepted.length
|
|
311
|
+
},
|
|
312
|
+
/**
|
|
313
|
+
* Number of entries in the file the panel shows — today's log, not just the
|
|
314
|
+
* lines this page has produced.
|
|
315
|
+
* @returns the entry count of the active file.
|
|
316
|
+
*/
|
|
317
|
+
count() {
|
|
318
|
+
return countOf(activeFile())
|
|
319
|
+
},
|
|
320
|
+
/**
|
|
321
|
+
* Read the tail of the active file.
|
|
322
|
+
* @returns the text, the full size, and whether older content was left out;
|
|
323
|
+
* a tail that starts mid-line drops that partial line.
|
|
324
|
+
*/
|
|
325
|
+
read() {
|
|
326
|
+
const file = activeFile()
|
|
327
|
+
try {
|
|
328
|
+
if (!existsSync(file)) return { text: '', size: 0, truncated: false }
|
|
329
|
+
const size = statSync(file).size
|
|
330
|
+
if (size <= tailBytes) return { text: readFileSync(file, 'utf8'), size, truncated: false }
|
|
331
|
+
const buffer = Buffer.alloc(tailBytes)
|
|
332
|
+
let readBytes = 0
|
|
333
|
+
const fd = io.openSync(file, 'r')
|
|
334
|
+
try {
|
|
335
|
+
readBytes = io.readSync(fd, buffer, 0, tailBytes, size - tailBytes)
|
|
336
|
+
} finally {
|
|
337
|
+
io.closeSync(fd)
|
|
338
|
+
}
|
|
339
|
+
// Decode only what the read actually filled: a short read must never
|
|
340
|
+
// expose untouched buffer bytes, even though the buffer is zeroed.
|
|
341
|
+
const tail = buffer.subarray(0, Math.max(0, Math.min(readBytes, tailBytes))).toString('utf8')
|
|
342
|
+
const firstBreak = tail.indexOf('\n')
|
|
343
|
+
return { text: firstBreak === -1 ? tail : tail.slice(firstBreak + 1), size, truncated: true }
|
|
344
|
+
} catch {
|
|
345
|
+
return { text: '', size: 0, truncated: false }
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
/**
|
|
349
|
+
* Empty the log: the active file is truncated and the older daily files are
|
|
350
|
+
* removed, so 清空日志 leaves nothing of this plugin's history behind.
|
|
351
|
+
*/
|
|
352
|
+
clear() {
|
|
353
|
+
try {
|
|
354
|
+
ensure()
|
|
355
|
+
const file = activeFile()
|
|
356
|
+
writeFileSync(file, '', 'utf8')
|
|
357
|
+
for (const path of ownedFiles()) {
|
|
358
|
+
if (path === file) continue
|
|
359
|
+
try {
|
|
360
|
+
unlinkSync(path)
|
|
361
|
+
} catch {
|
|
362
|
+
// A held file is skipped; the next clear retries.
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
counted = { path: file, lines: 0 }
|
|
366
|
+
return true
|
|
367
|
+
} catch {
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
/**
|
|
372
|
+
* Open the log folder in the OS file manager.
|
|
373
|
+
* @returns the launcher used, and undefined as the failure or a reason for it.
|
|
374
|
+
*/
|
|
375
|
+
open() {
|
|
376
|
+
ensure()
|
|
377
|
+
return openFolder(dir)
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Parse one JSON request body within the route's own size budget. */
|
|
383
|
+
const readJsonBody = async (request) => {
|
|
384
|
+
try {
|
|
385
|
+
const text = await request.text()
|
|
386
|
+
if (text === '' || text.length > MAX_REQUEST_BYTES) return null
|
|
387
|
+
const parsed = JSON.parse(text)
|
|
388
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
389
|
+
} catch {
|
|
390
|
+
return null
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Build the exact Fetch route the browser half calls.
|
|
396
|
+
* @param store - log store from {@link createLogStore}.
|
|
397
|
+
* @returns a route for `ctx.connection.fetch.register`.
|
|
398
|
+
*/
|
|
399
|
+
export function createLogRoute(store) {
|
|
400
|
+
const reply = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
401
|
+
status,
|
|
402
|
+
headers: { 'content-type': 'application/json' },
|
|
403
|
+
})
|
|
404
|
+
const target = (extra) => ({ ok: true, dir: store.dir, file: store.file, ...extra })
|
|
405
|
+
return {
|
|
406
|
+
path: ROUTE_PATH,
|
|
407
|
+
methods: ['GET', 'POST'],
|
|
408
|
+
requestBody: 'buffered',
|
|
409
|
+
fetch: async (request) => {
|
|
410
|
+
try {
|
|
411
|
+
if (request.method === 'GET') {
|
|
412
|
+
store.ensure()
|
|
413
|
+
// `?count=1` answers the entry count alone, so the panel can refresh
|
|
414
|
+
// its counter without transferring the file's tail.
|
|
415
|
+
if (new URL(request.url).searchParams.get('count') === '1') {
|
|
416
|
+
return reply(target({ count: store.count() }))
|
|
417
|
+
}
|
|
418
|
+
const { text, size, truncated } = store.read()
|
|
419
|
+
return reply(target({ count: store.count(), size, truncated, text }))
|
|
420
|
+
}
|
|
421
|
+
const payload = await readJsonBody(request)
|
|
422
|
+
if (payload === null) return reply({ ok: false, reason: 'bad-request' }, 400)
|
|
423
|
+
if (payload.action === 'append') {
|
|
424
|
+
return reply(target({ written: store.append(payload.lines), count: store.count() }))
|
|
425
|
+
}
|
|
426
|
+
if (payload.action === 'clear') {
|
|
427
|
+
store.clear()
|
|
428
|
+
return reply(target({ count: store.count() }))
|
|
429
|
+
}
|
|
430
|
+
if (payload.action === 'open') {
|
|
431
|
+
const { failure, command } = await store.open()
|
|
432
|
+
// Record the attempt host-side, so a folder that never appears can be
|
|
433
|
+
// diagnosed from the log file itself.
|
|
434
|
+
store.append([`${new Date().toISOString()} [info] [host] 打开日志文件夹 ${failure === undefined ? `ok (${command})` : `failed (${command}): ${failure}`}`])
|
|
435
|
+
return failure === undefined
|
|
436
|
+
? reply(target({ opened: true, command, count: store.count() }))
|
|
437
|
+
: reply({ ok: false, reason: 'open-failed', detail: failure }, 500)
|
|
438
|
+
}
|
|
439
|
+
return reply({ ok: false, reason: 'unknown-action' }, 400)
|
|
440
|
+
} catch (error) {
|
|
441
|
+
return reply({ ok: false, reason: 'internal', detail: String(error?.message ?? error) }, 500)
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
16
447
|
/**
|
|
17
|
-
* Host half entry point
|
|
18
|
-
*
|
|
448
|
+
* Host half entry point: own the log file and publish its route.
|
|
449
|
+
* @param ctx - host cordis context carrying the connection service.
|
|
19
450
|
*/
|
|
20
|
-
export function apply() {
|
|
21
|
-
|
|
451
|
+
export function apply(ctx) {
|
|
452
|
+
const store = createLogStore()
|
|
453
|
+
try {
|
|
454
|
+
store.ensure()
|
|
455
|
+
} catch {
|
|
456
|
+
// A read-only home still serves reads and reports failures per request.
|
|
457
|
+
}
|
|
458
|
+
ctx.effect(() => ctx.connection.fetch.register(createLogRoute(store)), 'settings-search: log fetch route')
|
|
22
459
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectivex666/dsh-settings-search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.1",
|
|
4
4
|
"description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -45,9 +45,10 @@
|
|
|
45
45
|
"platform": "web",
|
|
46
46
|
"inject": [
|
|
47
47
|
"@deepseek-ai/dsh-client-connection",
|
|
48
|
-
"@deepseek-ai/dsh-client-runtime",
|
|
49
48
|
"@deepseek-ai/dsh-client-locale",
|
|
50
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
50
|
+
"@deepseek-ai/dsh-api-gateway",
|
|
51
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
51
52
|
]
|
|
52
53
|
}
|
|
53
54
|
},
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
"node": ">=18"
|
|
56
57
|
},
|
|
57
58
|
"scripts": {
|
|
58
|
-
"test": "node scripts/check.mjs && node --test scripts/ai.test.mjs"
|
|
59
|
+
"test": "node scripts/check.mjs && node --test scripts/ai.test.mjs scripts/log.test.mjs"
|
|
59
60
|
},
|
|
60
61
|
"publishConfig": {
|
|
61
62
|
"access": "public"
|