@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,559 @@
1
+ /**
2
+ * Shared mini-program service layer.
3
+ *
4
+ * Both the HTTP routes and the `cortex_*_miniapp*` agent tools call these
5
+ * functions, so status reporting, locking, workspace paths and asset import
6
+ * behave identically on both transports. The service is transport-agnostic:
7
+ * it returns plain JSON and throws the host `ApiError` type for 4xx cases.
8
+ */
9
+
10
+ import { createHash } from 'node:crypto'
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { spawn } from 'node:child_process'
13
+ import { resolve, sep } from 'node:path'
14
+ import { badRequest, notFound } from '../http.js'
15
+ import { importMiniappAssets } from './assets-import.js'
16
+ import { analyzeUnpacked, buildOverviewText, deriveAppNameFromUnpacked, extractAppNameFromPackage, loadOverview, saveOverview } from './analyze.js'
17
+ import { scanMiniapps } from './detect.js'
18
+ import { miniAppWorkspaceRoot } from './roots.js'
19
+ import { getMiniappStore } from './store.js'
20
+ import { listTree, readManifest, readTextFile, searchFiles, unpackSource } from './unpack.js'
21
+
22
+ /** Running unpack operations, keyed by source id (prevents double work). */
23
+ const unpackLocks = new Set()
24
+
25
+ export function isUnpacking(sourceId) {
26
+ return unpackLocks.has(String(sourceId))
27
+ }
28
+
29
+ /** Deterministic newest-first version order (numeric last segment wins). */
30
+ export function compareVersionsDesc(left, right) {
31
+ const a = String(left || '')
32
+ const b = String(right || '')
33
+ const aNum = /^\d+$/.test(a) ? Number(a) : null
34
+ const bNum = /^\d+$/.test(b) ? Number(b) : null
35
+ if (aNum !== null && bNum !== null && aNum !== bNum) return bNum - aNum
36
+ if (aNum !== null && bNum === null) return -1
37
+ if (aNum === null && bNum !== null) return 1
38
+ return b.localeCompare(a)
39
+ }
40
+
41
+ export function sortSourcesNewest(rows) {
42
+ return [...(Array.isArray(rows) ? rows : [])].sort((left, right) => {
43
+ const byVersion = compareVersionsDesc(left.version, right.version)
44
+ if (byVersion !== 0) return byVersion
45
+ return String(right.updated_at || '').localeCompare(String(left.updated_at || ''))
46
+ })
47
+ }
48
+
49
+ /** Strip heavy internals from a source row for list/detail responses. */
50
+ export function toMiniappSummary(row, { versionCount = 1, isLatest = true, hasAnalysis = false, sourceCount = 1 } = {}) {
51
+ if (!row) return null
52
+ return {
53
+ id: row.id,
54
+ appid: row.appid,
55
+ app_name: row.app_name || '',
56
+ kind: sourceKind(row),
57
+ version: row.version || '',
58
+ root_kind: row.root_kind,
59
+ package_dir: row.package_dir,
60
+ size_bytes: Number(row.size_bytes) || 0,
61
+ file_count: Number(row.file_count) || 0,
62
+ package_hash: row.package_hash || '',
63
+ format: row.format || 'unknown',
64
+ status: row.status || 'detected',
65
+ unpacked_path: row.unpacked_path || '',
66
+ error: row.error || '',
67
+ detected_at: row.detected_at,
68
+ updated_at: row.updated_at,
69
+ version_count: versionCount,
70
+ is_latest: isLatest,
71
+ has_analysis: hasAnalysis,
72
+ source_count: sourceCount,
73
+ }
74
+ }
75
+
76
+ /** Classify a source by its package files (`app` | `plugin` | `library` | `unknown`). */
77
+ export function sourceKind(row) {
78
+ if (!row) return 'unknown'
79
+ if (String(row.appid || '').toLowerCase() === 'publiclib') return 'library'
80
+ const names = (Array.isArray(row.files) ? row.files : []).map((file) =>
81
+ String((file && (file.name || file.path)) || '')
82
+ .replace(/\\/g, '/')
83
+ .toLowerCase(),
84
+ )
85
+ const baseName = (name) => name.split('/').pop() || ''
86
+ if (names.some((name) => baseName(name) === '__app__.wxapkg')) return 'app'
87
+ if (names.some((name) => baseName(name) === '__plugincode__.wxapkg')) return 'plugin'
88
+ if (names.some((name) => name.includes('publiclib'))) return 'library'
89
+ return 'unknown'
90
+ }
91
+
92
+ /** Higher is better when one appid+version exists in several WeChat user caches. */
93
+ function sourceQuality(row) {
94
+ const statusRank =
95
+ row.status === 'unpacked' ? 4 : row.status === 'detected' ? 3 : row.status === 'decrypting' ? 2 : 1
96
+ return [statusRank, Number(row.size_bytes) || 0, Number(row.file_count) || 0, String(row.updated_at || '')]
97
+ }
98
+
99
+ function betterSource(left, right) {
100
+ const a = sourceQuality(left)
101
+ const b = sourceQuality(right)
102
+ for (let index = 0; index < a.length; index += 1) {
103
+ if (a[index] === b[index]) continue
104
+ if (typeof a[index] === 'string') return a[index] > b[index]
105
+ return a[index] > b[index]
106
+ }
107
+ return false
108
+ }
109
+
110
+ export function pickBestSource(rows) {
111
+ let best = null
112
+ for (const row of Array.isArray(rows) ? rows : []) {
113
+ if (!best || betterSource(row, best)) best = row
114
+ }
115
+ return best
116
+ }
117
+
118
+ /**
119
+ * Collapse the same appid+version cached in multiple WeChat user directories.
120
+ * The representative prefers an already-unpacked source, then the largest
121
+ * (most complete) cache, then the newest row.
122
+ */
123
+ export function dedupeSources(rows) {
124
+ const groups = new Map()
125
+ for (const row of Array.isArray(rows) ? rows : []) {
126
+ const key = `${row.appid}\u0000${String(row.version || '')}`
127
+ if (!groups.has(key)) groups.set(key, [])
128
+ groups.get(key).push(row)
129
+ }
130
+ const result = []
131
+ for (const group of groups.values()) {
132
+ const chosen = pickBestSource(group)
133
+ if (!chosen) continue
134
+ result.push({ ...chosen, source_count: group.length })
135
+ }
136
+ result.sort((left, right) => {
137
+ const byVersion = compareVersionsDesc(left.version, right.version)
138
+ if (byVersion !== 0) return byVersion
139
+ return String(left.appid).localeCompare(String(right.appid))
140
+ })
141
+ return result
142
+ }
143
+
144
+ /**
145
+ * Flat list with per-app version counts + latest flags, de-duplicated by
146
+ * appid+version across WeChat user caches.
147
+ * @param {import('./store.js').MiniappStore} store
148
+ * @param {{search?:string, status?:string}} [filters]
149
+ */
150
+ export function listMiniapps(store, filters = {}) {
151
+ const rawRows = store.listSources({
152
+ search: filters.search || '',
153
+ status: filters.status || '',
154
+ })
155
+ const rows = dedupeSources(rawRows)
156
+ const versionsByApp = new Map()
157
+ for (const row of rows) {
158
+ if (!versionsByApp.has(row.appid)) versionsByApp.set(row.appid, new Set())
159
+ versionsByApp.get(row.appid).add(String(row.version || ''))
160
+ }
161
+ // Filter support is still applied against the deduplicated representative.
162
+ const visible = filters.status ? rows.filter((row) => row.status === filters.status) : rows
163
+ const latestVersion = new Map()
164
+ for (const appid of new Set(visible.map((row) => row.appid))) {
165
+ const newest = sortSourcesNewest(visible.filter((row) => row.appid === appid))[0]
166
+ if (newest) latestVersion.set(appid, String(newest.version || ''))
167
+ }
168
+ const miniapps = visible.map((row) => {
169
+ let hasAnalysis = false
170
+ try {
171
+ hasAnalysis = Boolean(store.getAnalysis(row.id))
172
+ } catch {
173
+ hasAnalysis = false
174
+ }
175
+ return toMiniappSummary(row, {
176
+ versionCount: versionsByApp.get(row.appid)?.size || 1,
177
+ isLatest: latestVersion.get(row.appid) === String(row.version || ''),
178
+ hasAnalysis,
179
+ sourceCount: row.source_count || 1,
180
+ })
181
+ })
182
+ const appCount = new Set(miniapps.map((row) => row.appid)).size
183
+ return {
184
+ miniapps,
185
+ total: miniapps.length,
186
+ appCount,
187
+ customRoots: store.customRoots(),
188
+ workspaceRoot: miniAppWorkspaceRoot(),
189
+ lastScanAt: store.getSetting('lastScanAt'),
190
+ }
191
+ }
192
+
193
+ /** Resolve a source by appid (+ optional version). `version` omitted -> newest. */
194
+ export function resolveSource(store, appid, version = '') {
195
+ const targetApp = String(appid || '').trim()
196
+ if (targetApp.length === 0) throw badRequest('appid is required', { field: 'appid' })
197
+ const rows = store.findSources({ appid: targetApp, version: String(version || '').trim() })
198
+ if (!rows || rows.length === 0) return null
199
+ if (version) return pickBestSource(rows)
200
+ const newest = sortSourcesNewest(rows)[0]
201
+ if (!newest) return null
202
+ return pickBestSource(rows.filter((row) => String(row.version || '') === String(newest.version || '')))
203
+ }
204
+
205
+ export function requireSource(store, appid, version = '') {
206
+ const source = resolveSource(store, appid, version)
207
+ if (!source) {
208
+ const label = version ? `${appid}@${version}` : String(appid)
209
+ throw notFound(`miniapp source not found: ${label}`)
210
+ }
211
+ return source
212
+ }
213
+
214
+ export function requireSourceById(store, id) {
215
+ const source = store.getSource(String(id || ''))
216
+ if (!source) throw notFound('miniapp source not found')
217
+ return source
218
+ }
219
+
220
+ /** Skip huge shared libraries during name enrichment (keeps scan fast). */
221
+ const APP_NAME_ENRICH_MAX_BYTES = 80 * 1024 * 1024
222
+
223
+ /**
224
+ * Fill `app_name` for newly detected sources by reading one package per app.
225
+ * Best-effort: decryption failures (e.g. publicLib) simply leave the appid.
226
+ */
227
+ async function enrichAppNames(store, sources) {
228
+ for (const row of Array.isArray(sources) ? sources : []) {
229
+ if (row.app_name) continue
230
+ const files = Array.isArray(row.files) ? row.files : []
231
+ if (files.length === 0) continue
232
+ const preferred =
233
+ files.find((file) => /(?:^|[\\/])__APP__\.wxapkg$/i.test(String(file.name || file.path))) ||
234
+ [...files].sort((left, right) => Number(right.size) - Number(left.size))[0]
235
+ if (!preferred || !preferred.path || Number(preferred.size) > APP_NAME_ENRICH_MAX_BYTES) continue
236
+ try {
237
+ const { appName } = extractAppNameFromPackage(preferred.path, row.appid)
238
+ if (appName) {
239
+ store.updateSource(row.id, { app_name: appName })
240
+ row.app_name = appName
241
+ }
242
+ } catch {
243
+ /* name extraction is best effort and must never fail the scan */
244
+ }
245
+ await new Promise((resolveTick) => setImmediate(resolveTick))
246
+ }
247
+ }
248
+
249
+ /** Scan custom roots (persisting them) and return the summary + current rows. */
250
+ export async function scan(store, { customRoots } = {}) {
251
+ if (Array.isArray(customRoots)) store.setCustomRoots(customRoots)
252
+ const roots = store.customRoots()
253
+ const summary = await scanMiniapps({ store, customRoots: roots })
254
+ await enrichAppNames(store, summary.sources)
255
+ store.setSetting('lastScanAt', summary.scannedAt)
256
+ const listing = listMiniapps(store)
257
+ return {
258
+ ok: true,
259
+ scannedAt: summary.scannedAt,
260
+ totalSources: summary.totalSources,
261
+ totalPackages: summary.totalPackages,
262
+ added: summary.added,
263
+ updated: summary.updated,
264
+ unchanged: summary.unchanged,
265
+ errors: summary.errors,
266
+ roots: summary.roots,
267
+ miniapps: listing.miniapps,
268
+ appCount: listing.appCount,
269
+ customRoots: listing.customRoots,
270
+ workspaceRoot: listing.workspaceRoot,
271
+ }
272
+ }
273
+
274
+ function packageHashOf(source) {
275
+ try {
276
+ const hash = createHash('sha1')
277
+ for (const file of source.files || []) {
278
+ hash.update(readFileSync(file.path))
279
+ }
280
+ return hash.digest('hex')
281
+ } catch {
282
+ return ''
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Unpack one source (idempotent unless `force`). Always reports the true
288
+ * status; a decryption failure degrades to `unsupported` and never throws.
289
+ */
290
+ export async function unpackSourceRecord({ store, source, force = false } = {}) {
291
+ if (!source) throw badRequest('source is required')
292
+ if (isUnpacking(source.id)) {
293
+ throw badRequest('this mini-program is already being unpacked', { code: 'UNPACK_IN_PROGRESS' })
294
+ }
295
+ const manifestPath = source.unpacked_path ? resolve(source.unpacked_path, 'manifest.json') : ''
296
+ if (
297
+ force !== true &&
298
+ source.status === 'unpacked' &&
299
+ source.unpacked_path &&
300
+ existsSync(source.unpacked_path) &&
301
+ (!manifestPath || existsSync(manifestPath))
302
+ ) {
303
+ return {
304
+ ok: true,
305
+ status: 'unpacked',
306
+ unpackedPath: source.unpacked_path,
307
+ manifestPath,
308
+ manifest: readManifest(source.unpacked_path),
309
+ error: '',
310
+ totals: null,
311
+ cached: true,
312
+ }
313
+ }
314
+
315
+ unpackLocks.add(source.id)
316
+ let job = null
317
+ try {
318
+ job = store.createJob({ source_id: source.id, kind: 'unpack', status: 'running', message: 'running' })
319
+ } catch {
320
+ job = null
321
+ }
322
+ try {
323
+ store.updateSource(source.id, { status: 'decrypting', error: '' })
324
+ const result = await unpackSource({ source, workspaceRoot: miniAppWorkspaceRoot() })
325
+ const packageHash = result.ok ? packageHashOf(source) : ''
326
+ store.updateSource(source.id, {
327
+ status: result.status,
328
+ unpacked_path: result.unpackedPath || '',
329
+ error: result.error || '',
330
+ ...(packageHash ? { package_hash: packageHash } : {}),
331
+ })
332
+ if (result.ok) {
333
+ try {
334
+ const { appName } = deriveAppNameFromUnpacked(result.unpackedPath)
335
+ if (appName) store.updateSource(source.id, { app_name: appName })
336
+ } catch {
337
+ /* display-name extraction is best effort */
338
+ }
339
+ }
340
+ if (job) {
341
+ try {
342
+ store.updateJob(job.id, {
343
+ status: result.ok ? 'done' : 'failed',
344
+ progress: 100,
345
+ message: String(result.error || result.status || '').slice(0, 500),
346
+ })
347
+ } catch {
348
+ /* job bookkeeping is best effort */
349
+ }
350
+ }
351
+ return result
352
+ } catch (error) {
353
+ const message = error instanceof Error ? error.message : String(error)
354
+ store.updateSource(source.id, { status: 'failed', error: message.slice(0, 1000) })
355
+ if (job) {
356
+ try {
357
+ store.updateJob(job.id, { status: 'failed', progress: 100, message: message.slice(0, 500) })
358
+ } catch {
359
+ /* ignore */
360
+ }
361
+ }
362
+ throw error
363
+ } finally {
364
+ unpackLocks.delete(source.id)
365
+ }
366
+ }
367
+
368
+ export function extractedCounts(overview) {
369
+ return {
370
+ domains: Array.isArray(overview && overview.domains) ? overview.domains.length : 0,
371
+ endpoints: Array.isArray(overview && overview.endpoints) ? overview.endpoints.length : 0,
372
+ secrets: Array.isArray(overview && overview.secrets) ? overview.secrets.length : 0,
373
+ parameters: Array.isArray(overview && overview.parameters) ? overview.parameters.length : 0,
374
+ }
375
+ }
376
+
377
+ function requireUnpacked(source) {
378
+ if (!source || source.status !== 'unpacked' || !source.unpacked_path) {
379
+ throw badRequest('mini-program is not unpacked yet; call unpack first', {
380
+ status: source ? source.status : 'missing',
381
+ })
382
+ }
383
+ if (!existsSync(source.unpacked_path)) {
384
+ throw badRequest('unpacked workspace is missing; unpack again', { status: source.status })
385
+ }
386
+ return source.unpacked_path
387
+ }
388
+
389
+ /** Analyze (or reuse) one unpacked source and persist the overview. */
390
+ export function analyzeSourceRecord({ store, source, force = false } = {}) {
391
+ const unpackedPath = requireUnpacked(source)
392
+ if (force !== true) {
393
+ const cached = store.getAnalysis(source.id)
394
+ if (cached && typeof cached === 'object' && Object.keys(cached).length > 0) {
395
+ if (cached.appName && cached.appName !== source.app_name) {
396
+ try {
397
+ store.updateSource(source.id, { app_name: cached.appName })
398
+ } catch {
399
+ /* best effort */
400
+ }
401
+ }
402
+ return {
403
+ ok: true,
404
+ analysis: cached,
405
+ overviewText: buildOverviewText(cached),
406
+ counts: extractedCounts(cached),
407
+ cached: true,
408
+ }
409
+ }
410
+ const fromDisk = loadOverview(unpackedPath)
411
+ if (fromDisk) {
412
+ store.saveAnalysis(source.id, fromDisk)
413
+ return {
414
+ ok: true,
415
+ analysis: fromDisk,
416
+ overviewText: buildOverviewText(fromDisk),
417
+ counts: extractedCounts(fromDisk),
418
+ cached: true,
419
+ }
420
+ }
421
+ }
422
+ const manifest = readManifest(unpackedPath)
423
+ const overview = analyzeUnpacked({
424
+ unpackedPath,
425
+ appid: source.appid,
426
+ version: source.version,
427
+ manifest,
428
+ })
429
+ saveOverview(unpackedPath, overview)
430
+ store.saveAnalysis(source.id, overview)
431
+ if (overview.appName) {
432
+ try {
433
+ store.updateSource(source.id, { app_name: overview.appName })
434
+ } catch {
435
+ /* display-name persistence is best effort */
436
+ }
437
+ }
438
+ return {
439
+ ok: true,
440
+ analysis: overview,
441
+ overviewText: buildOverviewText(overview),
442
+ counts: extractedCounts(overview),
443
+ cached: false,
444
+ }
445
+ }
446
+
447
+ /** Ensure an analysis exists (used by the overview/import tools and routes). */
448
+ export function ensureAnalyzed({ store, source, force = false } = {}) {
449
+ const analysis = store.getAnalysis(source.id)
450
+ if (force !== true && analysis && Object.keys(analysis).length > 0) {
451
+ return { ok: true, analysis, overviewText: buildOverviewText(analysis), counts: extractedCounts(analysis), cached: true }
452
+ }
453
+ return analyzeSourceRecord({ store, source, force })
454
+ }
455
+
456
+ /** Detail payload for `GET /miniapps/:id`. */
457
+ export function detailPayload(store, source) {
458
+ const allSources = store.findSources({ appid: source.appid })
459
+ const versionRows = dedupeSources(allSources)
460
+ const versions = versionRows.map((row) => ({
461
+ id: row.id,
462
+ version: row.version,
463
+ status: row.status,
464
+ format: row.format,
465
+ size_bytes: row.size_bytes,
466
+ file_count: row.file_count,
467
+ updated_at: row.updated_at,
468
+ unpacked_path: row.unpacked_path,
469
+ source_count: row.source_count || 1,
470
+ }))
471
+ const sourceCount =
472
+ allSources.filter((row) => String(row.version || '') === String(source.version || '')).length || 1
473
+ const manifest = source.unpacked_path ? readManifest(source.unpacked_path) : null
474
+ let analysis = null
475
+ try {
476
+ analysis = store.getAnalysis(source.id)
477
+ } catch {
478
+ analysis = null
479
+ }
480
+ if (!analysis && source.unpacked_path) analysis = loadOverview(source.unpacked_path)
481
+ const counts = extractedCounts(analysis)
482
+ return {
483
+ miniapp: {
484
+ ...toMiniappSummary(source, { sourceCount }),
485
+ app_name: source.app_name || (analysis && analysis.appName) || '',
486
+ versions,
487
+ },
488
+ manifest,
489
+ analysis,
490
+ overviewText: analysis ? buildOverviewText(analysis) : '',
491
+ extractedCounts: counts,
492
+ }
493
+ }
494
+
495
+ export async function importAssetsForSource({ holder, store, source, projectName, hostPort } = {}) {
496
+ const unpackedPath = requireUnpacked(source)
497
+ if (!unpackedPath) throw badRequest('mini-program is not unpacked yet')
498
+ const analyzed = ensureAnalyzed({ store, source })
499
+ return importMiniappAssets({
500
+ holder,
501
+ source,
502
+ overview: analyzed.analysis,
503
+ projectName,
504
+ hostPort,
505
+ })
506
+ }
507
+
508
+ /* --------------------------- workspace browsing --------------------------- */
509
+
510
+ export function treeForSource(source, relativePath = '') {
511
+ const root = requireUnpacked(source)
512
+ return listTree({ root, path: relativePath })
513
+ }
514
+
515
+ export function fileForSource(
516
+ source,
517
+ relativePath,
518
+ { offset = 0, limit = 200, format = 'auto', maxBytes, find = '', findRegex = false, findMax } = {},
519
+ ) {
520
+ const root = requireUnpacked(source)
521
+ return readTextFile({
522
+ root,
523
+ path: relativePath,
524
+ offset,
525
+ limit,
526
+ format,
527
+ find,
528
+ findRegex,
529
+ ...(maxBytes ? { maxBytes } : {}),
530
+ ...(findMax ? { findMax } : {}),
531
+ })
532
+ }
533
+
534
+ export function searchSource(source, options = {}) {
535
+ const root = requireUnpacked(source)
536
+ return searchFiles({ root, ...options })
537
+ }
538
+
539
+ /** Open the unpacked workspace in the OS file manager (never the cache dir). */
540
+ export function openSourceDirectory(source) {
541
+ const workspaceRoot = resolve(miniAppWorkspaceRoot())
542
+ const target = resolve(requireUnpacked(source))
543
+ if (target !== workspaceRoot && !target.startsWith(`${workspaceRoot}${sep}`)) {
544
+ throw badRequest('refusing to open a directory outside the mini-program workspace')
545
+ }
546
+ const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'
547
+ try {
548
+ const child = spawn(command, [target], { detached: true, stdio: 'ignore' })
549
+ child.unref()
550
+ } catch {
551
+ throw badRequest('failed to open the directory')
552
+ }
553
+ return { ok: true, path: target }
554
+ }
555
+
556
+ /** Singleton store bound to the configured plugin DB (test-friendly). */
557
+ export function miniappStore() {
558
+ return getMiniappStore()
559
+ }