@lievclouds/dsh-cortex 0.5.4 → 1.0.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.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/client.js +434 -412
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -110
  7. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  8. package/src/cortex-ui/renderer/App.tsx +39 -0
  9. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  10. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  11. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  12. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  13. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  14. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
  16. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  17. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  18. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  19. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  20. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  21. package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
  22. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  23. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  24. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  25. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  26. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  27. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
  28. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  29. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  30. package/src/host/cortex/index.js +42 -1
  31. package/src/host/cortex/repeater-store.js +411 -0
  32. package/src/host/cortex/repeater.js +15 -2
  33. package/src/host/cortex/tools.js +2 -0
  34. package/src/host/miniapp/analyze.js +1078 -0
  35. package/src/host/miniapp/assets-import.js +294 -0
  36. package/src/host/miniapp/detect.js +426 -0
  37. package/src/host/miniapp/format.js +263 -0
  38. package/src/host/miniapp/roots.js +360 -0
  39. package/src/host/miniapp/routes.js +304 -0
  40. package/src/host/miniapp/service.js +559 -0
  41. package/src/host/miniapp/store.js +756 -0
  42. package/src/host/miniapp/tools.js +335 -0
  43. package/src/host/miniapp/unpack.js +1010 -0
  44. package/src/host/miniapp/wxapkg.js +416 -0
@@ -0,0 +1,756 @@
1
+ /**
2
+ * Plugin-local SQLite store for the mini-program (wxapkg) feature.
3
+ *
4
+ * The Cortex desktop app owns `~/.cortex/data.db`; this module deliberately
5
+ * keeps its own database (`~/.cortex/plugin.db`) so the schema v5 contract is
6
+ * never touched or migrated. `CORTEX_PLUGIN_DB` overrides the path for tests.
7
+ *
8
+ * Tables (created on `ensure`, all `IF NOT EXISTS`):
9
+ * miniapp_sources one row per package directory (root_path + package_dir)
10
+ * miniapp_jobs unpack/analyze job queue metadata
11
+ * miniapp_analyses cached Overview JSON per source
12
+ * miniapp_settings key/value settings (custom roots, last scan, ...)
13
+ */
14
+
15
+ import { createHash, randomUUID } from 'node:crypto'
16
+ import { mkdirSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { basename, dirname, join, resolve } from 'node:path'
19
+ import { DatabaseSync } from 'node:sqlite'
20
+
21
+ /** Statuses a miniapp source may carry (contract §2). */
22
+ export const MINIAPP_STATUSES = ['detected', 'decrypting', 'unpacked', 'unsupported', 'failed']
23
+
24
+ /** Formats `detectFormat` / unpack may record. */
25
+ export const MINIAPP_FORMATS = ['plain', 'v1mmwx', 'unknown']
26
+
27
+ /** Statuses and unpacked output preserved across an idempotent re-scan. */
28
+ const PRESERVED_STATUSES = new Set(['unpacked', 'unsupported', 'failed'])
29
+
30
+ /** Setting key holding the user's custom scan roots (JSON array). */
31
+ const CUSTOM_ROOTS_KEY = 'customRoots'
32
+
33
+ const SCHEMA_SQL = `
34
+ CREATE TABLE IF NOT EXISTS miniapp_sources (
35
+ id TEXT PRIMARY KEY,
36
+ root_path TEXT NOT NULL,
37
+ root_kind TEXT NOT NULL,
38
+ appid TEXT NOT NULL,
39
+ app_name TEXT NOT NULL DEFAULT '',
40
+ version TEXT NOT NULL DEFAULT '',
41
+ package_dir TEXT NOT NULL,
42
+ files_json TEXT NOT NULL DEFAULT '[]',
43
+ size_bytes INTEGER NOT NULL DEFAULT 0,
44
+ file_count INTEGER NOT NULL DEFAULT 0,
45
+ package_hash TEXT NOT NULL DEFAULT '',
46
+ format TEXT NOT NULL DEFAULT 'unknown',
47
+ status TEXT NOT NULL DEFAULT 'detected',
48
+ unpacked_path TEXT NOT NULL DEFAULT '',
49
+ error TEXT NOT NULL DEFAULT '',
50
+ detected_at TEXT NOT NULL,
51
+ updated_at TEXT NOT NULL,
52
+ UNIQUE (root_path, package_dir)
53
+ );
54
+ CREATE TABLE IF NOT EXISTS miniapp_jobs (
55
+ id TEXT PRIMARY KEY,
56
+ source_id TEXT NOT NULL,
57
+ kind TEXT NOT NULL,
58
+ status TEXT NOT NULL DEFAULT 'queued',
59
+ progress INTEGER NOT NULL DEFAULT 0,
60
+ message TEXT NOT NULL DEFAULT '',
61
+ created_at TEXT NOT NULL,
62
+ updated_at TEXT NOT NULL
63
+ );
64
+ CREATE TABLE IF NOT EXISTS miniapp_analyses (
65
+ source_id TEXT PRIMARY KEY,
66
+ overview_json TEXT NOT NULL DEFAULT '{}',
67
+ updated_at TEXT NOT NULL
68
+ );
69
+ CREATE TABLE IF NOT EXISTS miniapp_settings (
70
+ key TEXT PRIMARY KEY,
71
+ value TEXT NOT NULL,
72
+ updated_at TEXT NOT NULL
73
+ );
74
+ CREATE INDEX IF NOT EXISTS idx_miniapp_sources_appid ON miniapp_sources(appid, version);
75
+ CREATE INDEX IF NOT EXISTS idx_miniapp_jobs_source ON miniapp_jobs(source_id, created_at);
76
+ `
77
+
78
+ /** @param {NodeJS.ProcessEnv} env */
79
+ function homeDir(env) {
80
+ if (env && typeof env.HOME === 'string' && env.HOME.trim()) return env.HOME.trim()
81
+ if (env && typeof env.USERPROFILE === 'string' && env.USERPROFILE.trim()) return env.USERPROFILE.trim()
82
+ return homedir()
83
+ }
84
+
85
+ /**
86
+ * Default `plugin.db` path: `~/.cortex/plugin.db`, overridable via
87
+ * `CORTEX_PLUGIN_DB`. Always absolute (except the `:memory:` test sentinel).
88
+ * @param {NodeJS.ProcessEnv} [env]
89
+ */
90
+ export function defaultPluginDbPath(env = process.env) {
91
+ const override =
92
+ env && typeof env.CORTEX_PLUGIN_DB === 'string' ? env.CORTEX_PLUGIN_DB.trim() : ''
93
+ if (override) return override === ':memory:' ? override : resolve(override)
94
+ return resolve(join(homeDir(env), '.cortex', 'plugin.db'))
95
+ }
96
+
97
+ /**
98
+ * Stable source id: `sha1(resolve(rootPath) + '\0' + resolve(packageDir))`.
99
+ * @param {string} rootPath
100
+ * @param {string} packageDir
101
+ */
102
+ export function sourceIdFor(rootPath, packageDir) {
103
+ const root = resolve(String(rootPath ?? ''))
104
+ const pkg = resolve(String(packageDir ?? ''))
105
+ return createHash('sha1').update(root, 'utf8').update('\0', 'utf8').update(pkg, 'utf8').digest('hex')
106
+ }
107
+
108
+ const nowIso = () => new Date().toISOString()
109
+
110
+ /** @param {unknown} value */
111
+ function toText(value) {
112
+ return typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value)
113
+ }
114
+
115
+ /** @param {unknown} value @param {number} fallback */
116
+ function toInt(value, fallback) {
117
+ const n = Number(value)
118
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback
119
+ }
120
+
121
+ /** @param {unknown} value @param {string} fallback */
122
+ function normalizeStatus(value, fallback) {
123
+ return MINIAPP_STATUSES.includes(value) ? String(value) : fallback
124
+ }
125
+
126
+ /** @param {unknown} value */
127
+ function normalizeFormat(value) {
128
+ return MINIAPP_FORMATS.includes(value) ? String(value) : 'unknown'
129
+ }
130
+
131
+ /** @param {unknown} value @param {string} field */
132
+ function requirePath(value, field) {
133
+ if (typeof value !== 'string' || value.trim() === '') {
134
+ throw new TypeError(`MiniappStore: ${field} is required`)
135
+ }
136
+ return resolve(value)
137
+ }
138
+
139
+ /** @param {string} value */
140
+ function escapeLike(value) {
141
+ return value.replace(/[\\%_]/g, (match) => `\\${match}`)
142
+ }
143
+
144
+ /**
145
+ * Canonical file entries sorted by name: `{name, path, size}`.
146
+ * Accepts the contract shape, bare path strings, or a parsed files array.
147
+ * @param {unknown} files
148
+ */
149
+ function normalizeFiles(files) {
150
+ const input = Array.isArray(files) ? files : []
151
+ /** @type {{name:string,path:string,size:number}[]} */
152
+ const out = []
153
+ for (const item of input) {
154
+ let filePath = ''
155
+ let name = ''
156
+ let size = 0
157
+ if (typeof item === 'string') {
158
+ filePath = resolve(item)
159
+ name = basename(filePath)
160
+ } else if (item && typeof item === 'object') {
161
+ filePath = typeof item.path === 'string' && item.path ? resolve(item.path) : ''
162
+ name = typeof item.name === 'string' && item.name ? item.name : filePath ? basename(filePath) : ''
163
+ size = toInt(item.size, 0)
164
+ }
165
+ if (!filePath && !name) continue
166
+ out.push({ name, path: filePath, size })
167
+ }
168
+ out.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path))
169
+ return out
170
+ }
171
+
172
+ /** @param {string} filesJson */
173
+ function parseFilesJson(filesJson) {
174
+ try {
175
+ const parsed = JSON.parse(filesJson)
176
+ return Array.isArray(parsed) ? parsed : []
177
+ } catch {
178
+ return []
179
+ }
180
+ }
181
+
182
+ /** @param {any} row */
183
+ function rowToSource(row) {
184
+ if (!row) return null
185
+ const source = { ...row }
186
+ source.files = parseFilesJson(source.files_json)
187
+ return source
188
+ }
189
+
190
+ /** @param {any[]} a @param {any[]} b */
191
+ function sameFiles(a, b) {
192
+ const left = Array.isArray(a) ? a : []
193
+ const right = Array.isArray(b) ? b : []
194
+ if (left.length !== right.length) return false
195
+ for (let index = 0; index < left.length; index += 1) {
196
+ const one = left[index] || {}
197
+ const two = right[index] || {}
198
+ if (one.name !== two.name || one.path !== two.path || Number(one.size) !== Number(two.size)) {
199
+ return false
200
+ }
201
+ }
202
+ return true
203
+ }
204
+
205
+ /** Columns compared when deciding whether an upsert changed anything. */
206
+ const CHANGE_COLUMNS = [
207
+ 'root_path',
208
+ 'root_kind',
209
+ 'appid',
210
+ 'app_name',
211
+ 'version',
212
+ 'package_dir',
213
+ 'files_json',
214
+ 'size_bytes',
215
+ 'file_count',
216
+ 'package_hash',
217
+ 'format',
218
+ 'status',
219
+ 'unpacked_path',
220
+ 'error',
221
+ ]
222
+
223
+ /** @param {any} before @param {any} after */
224
+ function contentChanged(before, after) {
225
+ for (const column of CHANGE_COLUMNS) {
226
+ if (before[column] !== after[column]) return true
227
+ }
228
+ return false
229
+ }
230
+
231
+ /** Newest-first version compare used by `listSources` / `findSources`. */
232
+ function compareVersionDesc(a, b) {
233
+ const left = String(a ?? '')
234
+ const right = String(b ?? '')
235
+ const leftNumeric = /^\d+$/.test(left)
236
+ const rightNumeric = /^\d+$/.test(right)
237
+ if (leftNumeric && rightNumeric) {
238
+ const diff = Number(right) - Number(left)
239
+ if (diff !== 0) return diff
240
+ }
241
+ return right.localeCompare(left, undefined, { numeric: true })
242
+ }
243
+
244
+ /** Deterministic source ordering: appid asc, version desc (numeric), dir asc. */
245
+ function compareSources(a, b) {
246
+ const byAppid = String(a.appid ?? '').localeCompare(String(b.appid ?? ''))
247
+ if (byAppid !== 0) return byAppid
248
+ const byVersion = compareVersionDesc(a.version, b.version)
249
+ if (byVersion !== 0) return byVersion
250
+ return String(a.package_dir ?? '').localeCompare(String(b.package_dir ?? ''))
251
+ }
252
+
253
+ const SOURCE_INSERT = `INSERT INTO miniapp_sources
254
+ (id, root_path, root_kind, appid, app_name, version, package_dir, files_json,
255
+ size_bytes, file_count, package_hash, format, status, unpacked_path, error,
256
+ detected_at, updated_at)
257
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
258
+
259
+ const SOURCE_UPDATE = `UPDATE miniapp_sources SET
260
+ root_path = ?, root_kind = ?, appid = ?, app_name = ?, version = ?, package_dir = ?,
261
+ files_json = ?, size_bytes = ?, file_count = ?, package_hash = ?, format = ?,
262
+ status = ?, unpacked_path = ?, error = ?, updated_at = ?
263
+ WHERE id = ?`
264
+
265
+ /**
266
+ * Single-connection wrapper around `plugin.db`. Methods auto-`ensure()` so the
267
+ * host can construct the store lazily; call `close()` on dispose.
268
+ */
269
+ export class MiniappStore {
270
+ /**
271
+ * @param {{dbPath?:string, env?:NodeJS.ProcessEnv}} [options]
272
+ */
273
+ constructor({ dbPath = defaultPluginDbPath(), env = process.env } = {}) {
274
+ /** @type {string} */
275
+ this.dbPath = String(dbPath)
276
+ /** @type {NodeJS.ProcessEnv} */
277
+ this.env = env || process.env
278
+ /** @type {DatabaseSync|null} */
279
+ this.db = null
280
+ }
281
+
282
+ /** Create the directory/file and tables. Idempotent. */
283
+ ensure() {
284
+ if (this.db) return
285
+ if (this.dbPath !== ':memory:') mkdirSync(dirname(this.dbPath), { recursive: true })
286
+ const db = new DatabaseSync(this.dbPath)
287
+ try {
288
+ db.exec('PRAGMA busy_timeout = 5000')
289
+ db.exec('PRAGMA journal_mode = WAL')
290
+ db.exec(SCHEMA_SQL)
291
+ } catch (err) {
292
+ try {
293
+ db.close()
294
+ } catch {
295
+ /* ignore close failure on a half-open db */
296
+ }
297
+ throw err
298
+ }
299
+ this.db = db
300
+ }
301
+
302
+ /** @returns {DatabaseSync} */
303
+ _db() {
304
+ this.ensure()
305
+ if (!this.db) throw new Error('MiniappStore: database is not open')
306
+ return this.db
307
+ }
308
+
309
+ /** Close the connection; later calls reopen through `ensure()`. */
310
+ close() {
311
+ const db = this.db
312
+ this.db = null
313
+ if (db) {
314
+ try {
315
+ db.close()
316
+ } catch {
317
+ /* already closed */
318
+ }
319
+ }
320
+ }
321
+
322
+ /**
323
+ * @param {{search?:string, status?:string, appid?:string}} [filters]
324
+ * @returns {any[]}
325
+ */
326
+ listSources({ search = '', status = '', appid = '' } = {}) {
327
+ const db = this._db()
328
+ const where = []
329
+ const params = []
330
+ const term = String(search ?? '').trim()
331
+ if (term) {
332
+ const like = `%${escapeLike(term)}%`
333
+ where.push(
334
+ "(appid LIKE ? ESCAPE '\\' OR app_name LIKE ? ESCAPE '\\' OR version LIKE ? ESCAPE '\\' " +
335
+ "OR package_dir LIKE ? ESCAPE '\\' OR root_path LIKE ? ESCAPE '\\')",
336
+ )
337
+ params.push(like, like, like, like, like)
338
+ }
339
+ if (status) {
340
+ where.push('status = ?')
341
+ params.push(String(status))
342
+ }
343
+ if (appid) {
344
+ where.push('appid = ?')
345
+ params.push(String(appid))
346
+ }
347
+ let sql = 'SELECT * FROM miniapp_sources'
348
+ if (where.length > 0) sql += ` WHERE ${where.join(' AND ')}`
349
+ const rows = db.prepare(sql).all(...params).map(rowToSource)
350
+ rows.sort(compareSources)
351
+ return rows
352
+ }
353
+
354
+ /** @param {string} id @returns {any|null} */
355
+ getSource(id) {
356
+ const row = this._db().prepare('SELECT * FROM miniapp_sources WHERE id = ?').get(String(id))
357
+ return row ? rowToSource(row) : null
358
+ }
359
+
360
+ /**
361
+ * @param {{appid:string, version?:string}} query
362
+ * @returns {any[]} newest version first
363
+ */
364
+ findSources({ appid, version = '' } = {}) {
365
+ const db = this._db()
366
+ const rows = version
367
+ ? db
368
+ .prepare('SELECT * FROM miniapp_sources WHERE appid = ? AND version = ?')
369
+ .all(String(appid), String(version))
370
+ : db.prepare('SELECT * FROM miniapp_sources WHERE appid = ?').all(String(appid))
371
+ const sources = rows.map(rowToSource)
372
+ sources.sort((a, b) => compareVersionDesc(a.version, b.version))
373
+ return sources
374
+ }
375
+
376
+ /** @param {string} appid @returns {any|null} */
377
+ latestForApp(appid) {
378
+ const rows = this.findSources({ appid })
379
+ return rows.length > 0 ? rows[0] : null
380
+ }
381
+
382
+ /**
383
+ * Insert or reconcile one detected package directory.
384
+ *
385
+ * - identical file list/sizes -> no write, `changed:false`; an existing
386
+ * `unpacked`/`unsupported`/`failed` status and `unpacked_path` are kept;
387
+ * - changed file list/sizes -> reset to `detected` and drop stale output.
388
+ *
389
+ * @param {Record<string, any>} input
390
+ * @returns {{row:any, created:boolean, changed:boolean}}
391
+ */
392
+ upsertSource(input = {}) {
393
+ const db = this._db()
394
+ const rootPath = requirePath(input.root_path, 'root_path')
395
+ const packageDir = requirePath(input.package_dir, 'package_dir')
396
+ const incomingFiles =
397
+ input.files !== undefined ? input.files : parseFilesJson(toText(input.files_json))
398
+ const files = normalizeFiles(incomingFiles)
399
+ const filesJson = JSON.stringify(files)
400
+ const computedBytes = files.reduce((sum, file) => sum + file.size, 0)
401
+ const sizeBytes = input.size_bytes !== undefined ? toInt(input.size_bytes, computedBytes) : computedBytes
402
+ const fileCount = input.file_count !== undefined ? toInt(input.file_count, files.length) : files.length
403
+ const id =
404
+ typeof input.id === 'string' && input.id
405
+ ? input.id
406
+ : sourceIdFor(rootPath, packageDir)
407
+ const now = nowIso()
408
+
409
+ const existingRow = db.prepare('SELECT * FROM miniapp_sources WHERE id = ?').get(id)
410
+ const existing = existingRow ? rowToSource(existingRow) : null
411
+ const format = input.format !== undefined ? normalizeFormat(input.format) : existing ? existing.format : 'unknown'
412
+
413
+ if (!existing) {
414
+ const row = {
415
+ id,
416
+ root_path: rootPath,
417
+ root_kind: toText(input.root_kind) || 'custom',
418
+ appid: toText(input.appid),
419
+ app_name: toText(input.app_name),
420
+ version: toText(input.version),
421
+ package_dir: packageDir,
422
+ files_json: filesJson,
423
+ size_bytes: sizeBytes,
424
+ file_count: fileCount,
425
+ package_hash: toText(input.package_hash),
426
+ format,
427
+ status: normalizeStatus(input.status, 'detected'),
428
+ unpacked_path: toText(input.unpacked_path),
429
+ error: toText(input.error),
430
+ detected_at: toText(input.detected_at) || now,
431
+ updated_at: now,
432
+ }
433
+ db.prepare(SOURCE_INSERT).run(
434
+ row.id,
435
+ row.root_path,
436
+ row.root_kind,
437
+ row.appid,
438
+ row.app_name,
439
+ row.version,
440
+ row.package_dir,
441
+ row.files_json,
442
+ row.size_bytes,
443
+ row.file_count,
444
+ row.package_hash,
445
+ row.format,
446
+ row.status,
447
+ row.unpacked_path,
448
+ row.error,
449
+ row.detected_at,
450
+ row.updated_at,
451
+ )
452
+ return { row: rowToSource(row), created: true, changed: true }
453
+ }
454
+
455
+ const filesChanged =
456
+ !sameFiles(existing.files, files) ||
457
+ sizeBytes !== Number(existing.size_bytes) ||
458
+ fileCount !== Number(existing.file_count)
459
+
460
+ const next = {
461
+ ...existing,
462
+ root_path: rootPath,
463
+ root_kind: input.root_kind !== undefined ? toText(input.root_kind) : existing.root_kind,
464
+ appid: input.appid !== undefined ? toText(input.appid) : existing.appid,
465
+ app_name: input.app_name !== undefined ? toText(input.app_name) : existing.app_name,
466
+ version: input.version !== undefined ? toText(input.version) : existing.version,
467
+ package_dir: packageDir,
468
+ files_json: filesJson,
469
+ files,
470
+ size_bytes: sizeBytes,
471
+ file_count: fileCount,
472
+ format,
473
+ updated_at: now,
474
+ }
475
+
476
+ if (filesChanged) {
477
+ // The package payload changed: previous unpack/analysis output is stale.
478
+ next.status = 'detected'
479
+ next.unpacked_path = ''
480
+ next.package_hash = ''
481
+ next.error = ''
482
+ } else if (PRESERVED_STATUSES.has(existing.status)) {
483
+ next.status = existing.status
484
+ next.unpacked_path = existing.unpacked_path
485
+ next.package_hash = existing.package_hash
486
+ next.error = existing.error
487
+ } else {
488
+ next.status =
489
+ input.status !== undefined ? normalizeStatus(input.status, existing.status) : existing.status
490
+ next.unpacked_path =
491
+ input.unpacked_path !== undefined ? toText(input.unpacked_path) : existing.unpacked_path
492
+ next.package_hash =
493
+ input.package_hash !== undefined ? toText(input.package_hash) : existing.package_hash
494
+ next.error = input.error !== undefined ? toText(input.error) : existing.error
495
+ }
496
+
497
+ const changed = contentChanged(existing, next)
498
+ if (!changed) return { row: existing, created: false, changed: false }
499
+
500
+ db.prepare(SOURCE_UPDATE).run(
501
+ next.root_path,
502
+ next.root_kind,
503
+ next.appid,
504
+ next.app_name,
505
+ next.version,
506
+ next.package_dir,
507
+ next.files_json,
508
+ next.size_bytes,
509
+ next.file_count,
510
+ next.package_hash,
511
+ next.format,
512
+ next.status,
513
+ next.unpacked_path,
514
+ next.error,
515
+ now,
516
+ next.id,
517
+ )
518
+ return { row: rowToSource({ ...next, updated_at: now }), created: false, changed: true }
519
+ }
520
+
521
+ /**
522
+ * Patch a source after unpack/analyze. Unknown keys and invalid statuses are
523
+ * ignored; `updated_at` is always refreshed when something is applied.
524
+ * @param {string} id
525
+ * @param {Record<string, any>} patch
526
+ * @returns {any|null}
527
+ */
528
+ updateSource(id, patch = {}) {
529
+ const db = this._db()
530
+ const existingRow = db.prepare('SELECT * FROM miniapp_sources WHERE id = ?').get(String(id))
531
+ if (!existingRow) return null
532
+ const existing = rowToSource(existingRow)
533
+ const sets = []
534
+ const params = []
535
+ const textFields = ['root_kind', 'appid', 'app_name', 'version', 'package_hash', 'unpacked_path', 'error']
536
+ for (const field of textFields) {
537
+ if (patch[field] !== undefined) {
538
+ sets.push(`${field} = ?`)
539
+ params.push(toText(patch[field]))
540
+ }
541
+ }
542
+ if (patch.status !== undefined) {
543
+ const status = normalizeStatus(patch.status, '')
544
+ if (status) {
545
+ sets.push('status = ?')
546
+ params.push(status)
547
+ }
548
+ }
549
+ if (patch.format !== undefined) {
550
+ sets.push('format = ?')
551
+ params.push(normalizeFormat(patch.format))
552
+ }
553
+ if (patch.files !== undefined) {
554
+ const files = normalizeFiles(patch.files)
555
+ sets.push('files_json = ?')
556
+ params.push(JSON.stringify(files))
557
+ sets.push('file_count = ?')
558
+ params.push(files.length)
559
+ sets.push('size_bytes = ?')
560
+ params.push(files.reduce((sum, file) => sum + file.size, 0))
561
+ } else if (patch.files_json !== undefined) {
562
+ sets.push('files_json = ?')
563
+ params.push(toText(patch.files_json))
564
+ }
565
+ if (patch.size_bytes !== undefined) {
566
+ sets.push('size_bytes = ?')
567
+ params.push(toInt(patch.size_bytes, existing.size_bytes))
568
+ }
569
+ if (patch.file_count !== undefined) {
570
+ sets.push('file_count = ?')
571
+ params.push(toInt(patch.file_count, existing.file_count))
572
+ }
573
+ if (sets.length === 0) return existing
574
+ sets.push('updated_at = ?')
575
+ params.push(nowIso())
576
+ params.push(String(id))
577
+ db.prepare(`UPDATE miniapp_sources SET ${sets.join(', ')} WHERE id = ?`).run(...params)
578
+ return this.getSource(id)
579
+ }
580
+
581
+ /** @param {string} sourceId @returns {object|null} */
582
+ getAnalysis(sourceId) {
583
+ const row = this._db()
584
+ .prepare('SELECT overview_json FROM miniapp_analyses WHERE source_id = ?')
585
+ .get(String(sourceId))
586
+ if (!row) return null
587
+ try {
588
+ const parsed = JSON.parse(row.overview_json)
589
+ return parsed && typeof parsed === 'object' ? parsed : null
590
+ } catch {
591
+ return null
592
+ }
593
+ }
594
+
595
+ /** @param {string} sourceId @param {object} overview */
596
+ saveAnalysis(sourceId, overview) {
597
+ const db = this._db()
598
+ const json = JSON.stringify(overview ?? {})
599
+ db.prepare(
600
+ `INSERT INTO miniapp_analyses (source_id, overview_json, updated_at)
601
+ VALUES (?, ?, ?)
602
+ ON CONFLICT(source_id) DO UPDATE SET overview_json = excluded.overview_json,
603
+ updated_at = excluded.updated_at`,
604
+ ).run(String(sourceId), json, nowIso())
605
+ }
606
+
607
+ /** @param {string} key @param {string|null} [fallback] @returns {string|null} */
608
+ getSetting(key, fallback = null) {
609
+ const row = this._db().prepare('SELECT value FROM miniapp_settings WHERE key = ?').get(String(key))
610
+ return row ? String(row.value) : fallback
611
+ }
612
+
613
+ /** @param {string} key @param {unknown} value @returns {string} */
614
+ setSetting(key, value) {
615
+ const text = value === undefined || value === null ? '' : String(value)
616
+ this._db()
617
+ .prepare(
618
+ `INSERT INTO miniapp_settings (key, value, updated_at) VALUES (?, ?, ?)
619
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
620
+ )
621
+ .run(String(key), text, nowIso())
622
+ return text
623
+ }
624
+
625
+ /** @returns {string[]} */
626
+ customRoots() {
627
+ const raw = this.getSetting(CUSTOM_ROOTS_KEY, '[]')
628
+ try {
629
+ const parsed = JSON.parse(raw ?? '[]')
630
+ if (!Array.isArray(parsed)) return []
631
+ return parsed.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim())
632
+ } catch {
633
+ return []
634
+ }
635
+ }
636
+
637
+ /** @param {string[]} roots @returns {string[]} normalized, de-duplicated */
638
+ setCustomRoots(roots) {
639
+ const list = []
640
+ const seen = new Set()
641
+ for (const entry of Array.isArray(roots) ? roots : []) {
642
+ if (typeof entry !== 'string' || entry.trim() === '') continue
643
+ const absolute = resolve(entry.trim())
644
+ const key = process.platform === 'win32' ? absolute.toLowerCase() : absolute
645
+ if (seen.has(key)) continue
646
+ seen.add(key)
647
+ list.push(absolute)
648
+ }
649
+ this.setSetting(CUSTOM_ROOTS_KEY, JSON.stringify(list))
650
+ return list
651
+ }
652
+
653
+ /**
654
+ * @param {{source_id:string, kind:string, status?:string, message?:string}} input
655
+ * @returns {any}
656
+ */
657
+ createJob({ source_id, kind, status = 'queued', message = '' } = {}) {
658
+ const db = this._db()
659
+ const now = nowIso()
660
+ const job = {
661
+ id: randomUUID(),
662
+ source_id: toText(source_id),
663
+ kind: toText(kind),
664
+ status: toText(status) || 'queued',
665
+ progress: 0,
666
+ message: toText(message),
667
+ created_at: now,
668
+ updated_at: now,
669
+ }
670
+ db.prepare(
671
+ `INSERT INTO miniapp_jobs
672
+ (id, source_id, kind, status, progress, message, created_at, updated_at)
673
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
674
+ ).run(job.id, job.source_id, job.kind, job.status, job.progress, job.message, job.created_at, job.updated_at)
675
+ return job
676
+ }
677
+
678
+ /**
679
+ * @param {string} id
680
+ * @param {{status?:string, progress?:number, message?:string}} patch
681
+ * @returns {any|null}
682
+ */
683
+ updateJob(id, patch = {}) {
684
+ const db = this._db()
685
+ const existing = db.prepare('SELECT * FROM miniapp_jobs WHERE id = ?').get(String(id))
686
+ if (!existing) return null
687
+ const sets = []
688
+ const params = []
689
+ if (patch.status !== undefined) {
690
+ sets.push('status = ?')
691
+ params.push(toText(patch.status) || existing.status)
692
+ }
693
+ if (patch.progress !== undefined) {
694
+ const progress = Math.max(0, Math.min(100, toInt(patch.progress, existing.progress)))
695
+ sets.push('progress = ?')
696
+ params.push(progress)
697
+ }
698
+ if (patch.message !== undefined) {
699
+ sets.push('message = ?')
700
+ params.push(toText(patch.message))
701
+ }
702
+ if (sets.length === 0) return { ...existing }
703
+ sets.push('updated_at = ?')
704
+ params.push(nowIso())
705
+ params.push(String(id))
706
+ db.prepare(`UPDATE miniapp_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...params)
707
+ return this.getJob(id)
708
+ }
709
+
710
+ /** @param {string} id @returns {any|null} */
711
+ getJob(id) {
712
+ const row = this._db().prepare('SELECT * FROM miniapp_jobs WHERE id = ?').get(String(id))
713
+ return row ? { ...row } : null
714
+ }
715
+
716
+ /**
717
+ * @param {{sourceId?:string, limit?:number}} [options]
718
+ * @returns {any[]}
719
+ */
720
+ listJobs({ sourceId, limit = 20 } = {}) {
721
+ const db = this._db()
722
+ const bounded = Math.max(1, Math.min(200, toInt(limit, 20)))
723
+ const rows = sourceId
724
+ ? db
725
+ .prepare('SELECT * FROM miniapp_jobs WHERE source_id = ? ORDER BY created_at DESC, id DESC LIMIT ?')
726
+ .all(String(sourceId), bounded)
727
+ : db.prepare('SELECT * FROM miniapp_jobs ORDER BY created_at DESC, id DESC LIMIT ?').all(bounded)
728
+ return rows.map((row) => ({ ...row }))
729
+ }
730
+ }
731
+
732
+ /** @type {Map<string, MiniappStore>} */
733
+ const stores = new Map()
734
+
735
+ /**
736
+ * Process-wide singleton keyed by database path.
737
+ * @param {{dbPath?:string, env?:NodeJS.ProcessEnv}} [options]
738
+ * @returns {MiniappStore}
739
+ */
740
+ export function getMiniappStore(options = {}) {
741
+ const env = options.env ?? process.env
742
+ const dbPath = options.dbPath ?? defaultPluginDbPath(env)
743
+ let store = stores.get(dbPath)
744
+ if (!store) {
745
+ store = new MiniappStore({ dbPath, env })
746
+ store.ensure()
747
+ stores.set(dbPath, store)
748
+ }
749
+ return store
750
+ }
751
+
752
+ /** Close and forget every singleton (tests only). */
753
+ export function resetMiniappStoreForTests() {
754
+ for (const store of stores.values()) store.close()
755
+ stores.clear()
756
+ }