@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/lib/index.js CHANGED
@@ -1,22 +1,459 @@
1
1
  /**
2
2
  * dsh-settings-search — host half.
3
3
  *
4
- * This plugin is client-only: every feature lives in the browser half
5
- * (`./client` export, `lib/client.js`). This host half exists so the package
6
- * is a *valid* Cordis plugin row when installed as a DSH profile bundle
7
- * (`dsh plugin add @objectivex666/dsh-settings-search`): the profile
8
- * composition loads the package's main entry as a plugin, and the bundle's
9
- * `dsh.client` declaration tells the web app to fetch and mount the browser
10
- * half. A no-op host is all that is required here.
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. Nothing to do on the host — the search UI lives in
18
- * the browser and needs no host services or RPC.
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
- // client-only plugin; intentionally empty on the host
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.12.0",
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"