@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.
- package/README.md +1 -1
- package/client.js +434 -412
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -110
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/host/cortex/index.js +42 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/tools.js +2 -0
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- package/src/host/miniapp/wxapkg.js +416 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only mini-program (wxapkg) discovery.
|
|
3
|
+
*
|
|
4
|
+
* `scanMiniapps` walks each configured root, groups `.wxapkg` files by package
|
|
5
|
+
* directory, infers appid/version, detects the container format from the first
|
|
6
|
+
* bytes and upserts one source row per package directory.
|
|
7
|
+
*
|
|
8
|
+
* Hard rules honoured here:
|
|
9
|
+
* - never write/move/delete under a scanned root (read-only);
|
|
10
|
+
* - never follow symlinks or descend into `node_modules`;
|
|
11
|
+
* - permission errors are counted per root and never abort the scan;
|
|
12
|
+
* - the event loop is yielded to roughly every 2000 directory entries.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs'
|
|
16
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
17
|
+
import { expandRoots, inferIdentity } from './roots.js'
|
|
18
|
+
import { sourceIdFor } from './store.js'
|
|
19
|
+
|
|
20
|
+
/** Default scan limits (contract §3.3). */
|
|
21
|
+
export const SCAN_LIMITS = { maxDepth: 10, maxFiles: 30000, maxPackageFiles: 200 }
|
|
22
|
+
|
|
23
|
+
/** Package files above this size are reported and skipped. */
|
|
24
|
+
export const MAX_PACKAGE_FILE_BYTES = 300 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
/** Directory names that are never descended into. */
|
|
27
|
+
const SKIP_DIRS = new Set(['node_modules'])
|
|
28
|
+
|
|
29
|
+
/** Yield to the host event loop after roughly this many directory entries. */
|
|
30
|
+
const YIELD_EVERY = 2000
|
|
31
|
+
|
|
32
|
+
const DEFAULT_IO = { existsSync, statSync, readdirSync }
|
|
33
|
+
|
|
34
|
+
/** @param {string} filePath */
|
|
35
|
+
function isWxapkgName(filePath) {
|
|
36
|
+
return basename(filePath).toLowerCase().endsWith('.wxapkg')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {string} value */
|
|
40
|
+
function normalizePathKey(value) {
|
|
41
|
+
const absolute = resolve(value)
|
|
42
|
+
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sniff a package container from its first bytes:
|
|
47
|
+
* `V1MMWX` -> `v1mmwx`, byte0 `0xBE` -> `plain`, anything else -> `unknown`.
|
|
48
|
+
* Missing/unreadable files are `unknown` (never throws).
|
|
49
|
+
* @param {string} filePath
|
|
50
|
+
* @returns {'plain'|'v1mmwx'|'unknown'}
|
|
51
|
+
*/
|
|
52
|
+
export function detectFormat(filePath) {
|
|
53
|
+
let fd = null
|
|
54
|
+
try {
|
|
55
|
+
fd = openSync(filePath, 'r')
|
|
56
|
+
const head = Buffer.alloc(6)
|
|
57
|
+
const read = readSync(fd, head, 0, head.length, 0)
|
|
58
|
+
if (read >= 6 && head.toString('latin1', 0, 6) === 'V1MMWX') return 'v1mmwx'
|
|
59
|
+
if (read >= 1 && head[0] === 0xbe) return 'plain'
|
|
60
|
+
return 'unknown'
|
|
61
|
+
} catch {
|
|
62
|
+
return 'unknown'
|
|
63
|
+
} finally {
|
|
64
|
+
if (fd !== null) {
|
|
65
|
+
try {
|
|
66
|
+
closeSync(fd)
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `__APP__.wxapkg` first, then by file name. */
|
|
75
|
+
function comparePackageFiles(a, b) {
|
|
76
|
+
const left = basename(a.path).toLowerCase()
|
|
77
|
+
const right = basename(b.path).toLowerCase()
|
|
78
|
+
if (left === '__app__.wxapkg') return -1
|
|
79
|
+
if (right === '__app__.wxapkg') return 1
|
|
80
|
+
return left.localeCompare(right)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {string|{path:string,size?:number}} raw */
|
|
84
|
+
function normalizeFileEntry(raw) {
|
|
85
|
+
if (typeof raw === 'string') {
|
|
86
|
+
const path = resolve(raw)
|
|
87
|
+
let size = 0
|
|
88
|
+
try {
|
|
89
|
+
size = statSync(path).size
|
|
90
|
+
} catch {
|
|
91
|
+
/* size stays 0; grouping does not need it */
|
|
92
|
+
}
|
|
93
|
+
return { path, size }
|
|
94
|
+
}
|
|
95
|
+
if (!raw || typeof raw !== 'object' || typeof raw.path !== 'string' || !raw.path.trim()) return null
|
|
96
|
+
const path = resolve(raw.path)
|
|
97
|
+
const size = Number(raw.size)
|
|
98
|
+
return { path, size: Number.isFinite(size) && size >= 0 ? Math.floor(size) : 0 }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Group package files by containing directory and produce `upsertSource`
|
|
103
|
+
* inputs (one per package directory). Format comes from the largest file.
|
|
104
|
+
*
|
|
105
|
+
* @param {Array<string|{path:string,size?:number}>} files
|
|
106
|
+
* @param {import('./roots.js').Root|string} root
|
|
107
|
+
* @returns {Array<Record<string, any>>}
|
|
108
|
+
*/
|
|
109
|
+
export function groupPackages(files, root) {
|
|
110
|
+
/** @type {Map<string, {dir:string, files:{path:string,size:number}[]}>} */
|
|
111
|
+
const groups = new Map()
|
|
112
|
+
for (const raw of Array.isArray(files) ? files : []) {
|
|
113
|
+
const file = normalizeFileEntry(raw)
|
|
114
|
+
if (!file || !isWxapkgName(file.path)) continue
|
|
115
|
+
const dir = dirname(file.path)
|
|
116
|
+
const key = normalizePathKey(dir)
|
|
117
|
+
let group = groups.get(key)
|
|
118
|
+
if (!group) {
|
|
119
|
+
group = { dir, files: [] }
|
|
120
|
+
groups.set(key, group)
|
|
121
|
+
}
|
|
122
|
+
if (!group.files.some((entry) => normalizePathKey(entry.path) === normalizePathKey(file.path))) {
|
|
123
|
+
group.files.push(file)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const out = []
|
|
128
|
+
for (const group of groups.values()) {
|
|
129
|
+
group.files.sort(comparePackageFiles)
|
|
130
|
+
const largest = group.files.reduce((best, file) => (file.size > best.size ? file : best), group.files[0])
|
|
131
|
+
const identity = inferIdentity(largest.path, root)
|
|
132
|
+
out.push({
|
|
133
|
+
id: sourceIdFor(identity.rootPath, identity.packageDir),
|
|
134
|
+
root_path: identity.rootPath,
|
|
135
|
+
root_kind: identity.rootKind,
|
|
136
|
+
appid: identity.appid,
|
|
137
|
+
app_name: '',
|
|
138
|
+
version: identity.version,
|
|
139
|
+
package_dir: identity.packageDir,
|
|
140
|
+
files: group.files.map((file) => ({
|
|
141
|
+
name: basename(file.path),
|
|
142
|
+
path: file.path,
|
|
143
|
+
size: file.size,
|
|
144
|
+
})),
|
|
145
|
+
size_bytes: group.files.reduce((sum, file) => sum + file.size, 0),
|
|
146
|
+
file_count: group.files.length,
|
|
147
|
+
package_hash: '',
|
|
148
|
+
format: detectFormat(largest.path),
|
|
149
|
+
status: 'detected',
|
|
150
|
+
unpacked_path: '',
|
|
151
|
+
error: '',
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
out.sort((a, b) => a.package_dir.localeCompare(b.package_dir))
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @param {any} root @param {{maxDepth:number}} limits */
|
|
159
|
+
function normalizeRoot(root, limits) {
|
|
160
|
+
if (typeof root === 'string') {
|
|
161
|
+
const absolute = resolve(root)
|
|
162
|
+
return {
|
|
163
|
+
kind: 'custom',
|
|
164
|
+
path: absolute,
|
|
165
|
+
label: basename(absolute) || absolute,
|
|
166
|
+
mode: 'recursive',
|
|
167
|
+
maxDepth: limits.maxDepth,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!root || typeof root.path !== 'string' || !root.path.trim()) return null
|
|
171
|
+
const maxDepth = Number(root.maxDepth)
|
|
172
|
+
const declaredDepth =
|
|
173
|
+
Number.isFinite(maxDepth) && maxDepth >= 0 ? Math.floor(maxDepth) : limits.maxDepth
|
|
174
|
+
return {
|
|
175
|
+
kind: typeof root.kind === 'string' && root.kind ? root.kind : 'custom',
|
|
176
|
+
path: resolve(root.path),
|
|
177
|
+
label: typeof root.label === 'string' ? root.label : basename(resolve(root.path)),
|
|
178
|
+
mode: root.mode === 'packages' ? 'packages' : 'recursive',
|
|
179
|
+
maxDepth: Math.min(declaredDepth, limits.maxDepth),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Newest-first source ordering used for the scan result. */
|
|
184
|
+
function compareSources(a, b) {
|
|
185
|
+
const byAppid = String(a.appid ?? '').localeCompare(String(b.appid ?? ''))
|
|
186
|
+
if (byAppid !== 0) return byAppid
|
|
187
|
+
const left = String(a.version ?? '')
|
|
188
|
+
const right = String(b.version ?? '')
|
|
189
|
+
const leftNumeric = /^\d+$/.test(left)
|
|
190
|
+
const rightNumeric = /^\d+$/.test(right)
|
|
191
|
+
if (leftNumeric && rightNumeric && Number(left) !== Number(right)) return Number(right) - Number(left)
|
|
192
|
+
const byVersion = right.localeCompare(left, undefined, { numeric: true })
|
|
193
|
+
if (byVersion !== 0) return byVersion
|
|
194
|
+
return String(a.package_dir ?? '').localeCompare(String(b.package_dir ?? ''))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {string} message @param {string} root @param {string} path */
|
|
198
|
+
function scanError(message, root, path) {
|
|
199
|
+
return { root, path, message }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Scan every root and persist the detected packages.
|
|
204
|
+
*
|
|
205
|
+
* @param {{
|
|
206
|
+
* store: import('./store.js').MiniappStore,
|
|
207
|
+
* roots?: Array<any>,
|
|
208
|
+
* customRoots?: string[],
|
|
209
|
+
* env?: NodeJS.ProcessEnv,
|
|
210
|
+
* limits?: {maxDepth?:number, maxFiles?:number, maxPackageFiles?:number},
|
|
211
|
+
* now?: () => string,
|
|
212
|
+
* io?: {existsSync:Function, statSync:Function, readdirSync:Function}
|
|
213
|
+
* }} [options]
|
|
214
|
+
*/
|
|
215
|
+
export async function scanMiniapps({
|
|
216
|
+
store,
|
|
217
|
+
roots = [],
|
|
218
|
+
customRoots = [],
|
|
219
|
+
env = process.env,
|
|
220
|
+
limits = {},
|
|
221
|
+
now = () => new Date().toISOString(),
|
|
222
|
+
io = DEFAULT_IO,
|
|
223
|
+
} = {}) {
|
|
224
|
+
if (!store || typeof store.upsertSource !== 'function') {
|
|
225
|
+
throw new TypeError('scanMiniapps: a MiniappStore instance is required')
|
|
226
|
+
}
|
|
227
|
+
store.ensure?.()
|
|
228
|
+
|
|
229
|
+
const effectiveLimits = {
|
|
230
|
+
maxDepth: Number.isFinite(Number(limits.maxDepth)) ? Number(limits.maxDepth) : SCAN_LIMITS.maxDepth,
|
|
231
|
+
maxFiles: Number.isFinite(Number(limits.maxFiles)) ? Number(limits.maxFiles) : SCAN_LIMITS.maxFiles,
|
|
232
|
+
maxPackageFiles: Number.isFinite(Number(limits.maxPackageFiles))
|
|
233
|
+
? Number(limits.maxPackageFiles)
|
|
234
|
+
: SCAN_LIMITS.maxPackageFiles,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const explicitRoots = Array.isArray(roots) && roots.length > 0 ? roots : null
|
|
238
|
+
const rootInputs = explicitRoots ?? expandRoots({ customRoots, env })
|
|
239
|
+
const rootList = []
|
|
240
|
+
const seenRoots = new Set()
|
|
241
|
+
for (const rawRoot of rootInputs) {
|
|
242
|
+
const root = normalizeRoot(rawRoot, effectiveLimits)
|
|
243
|
+
if (!root) continue
|
|
244
|
+
const key = normalizePathKey(root.path)
|
|
245
|
+
if (seenRoots.has(key)) continue
|
|
246
|
+
seenRoots.add(key)
|
|
247
|
+
rootList.push(root)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const scannedAt = now()
|
|
251
|
+
/** @type {Array<{root:string,path:string,message:string}>} */
|
|
252
|
+
const errors = []
|
|
253
|
+
/** @type {Array<{kind:string,path:string,exists:boolean,filesFound:number,errors:number}>} */
|
|
254
|
+
const rootStats = []
|
|
255
|
+
/** @type {Map<string, {root:any, files:{path:string,size:number}[]}>} */
|
|
256
|
+
const byPackageDir = new Map()
|
|
257
|
+
|
|
258
|
+
let totalFiles = 0
|
|
259
|
+
let maxFilesReached = false
|
|
260
|
+
let entriesSinceYield = 0
|
|
261
|
+
|
|
262
|
+
const tick = async () => {
|
|
263
|
+
entriesSinceYield += 1
|
|
264
|
+
if (entriesSinceYield >= YIELD_EVERY) {
|
|
265
|
+
entriesSinceYield = 0
|
|
266
|
+
await new Promise((resolveTick) => setImmediate(resolveTick))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const root of rootList) {
|
|
271
|
+
const stat = { kind: root.kind, path: root.path, exists: false, filesFound: 0, errors: 0 }
|
|
272
|
+
rootStats.push(stat)
|
|
273
|
+
|
|
274
|
+
let rootExists = false
|
|
275
|
+
try {
|
|
276
|
+
rootExists = Boolean(io.existsSync(root.path))
|
|
277
|
+
} catch {
|
|
278
|
+
rootExists = false
|
|
279
|
+
}
|
|
280
|
+
if (!rootExists) continue
|
|
281
|
+
stat.exists = true
|
|
282
|
+
|
|
283
|
+
let directory = false
|
|
284
|
+
try {
|
|
285
|
+
directory = Boolean(io.statSync(root.path).isDirectory())
|
|
286
|
+
} catch (err) {
|
|
287
|
+
stat.errors += 1
|
|
288
|
+
errors.push(scanError(String(err && err.code ? err.code : err), root.path, root.path))
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
if (!directory) {
|
|
292
|
+
stat.errors += 1
|
|
293
|
+
errors.push(scanError('not a directory', root.path, root.path))
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const rootFiles = []
|
|
298
|
+
const stack = [{ dir: root.path, depth: 0 }]
|
|
299
|
+
while (stack.length > 0 && !maxFilesReached) {
|
|
300
|
+
const current = stack.pop()
|
|
301
|
+
let entries
|
|
302
|
+
try {
|
|
303
|
+
entries = io.readdirSync(current.dir, { withFileTypes: true })
|
|
304
|
+
} catch (err) {
|
|
305
|
+
stat.errors += 1
|
|
306
|
+
errors.push(
|
|
307
|
+
scanError(
|
|
308
|
+
String(err && err.code ? err.code : (err && err.message) || 'readdir failed'),
|
|
309
|
+
root.path,
|
|
310
|
+
current.dir,
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
await tick()
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const entry of entries) {
|
|
318
|
+
await tick()
|
|
319
|
+
const entryName = String(entry && entry.name ? entry.name : '')
|
|
320
|
+
if (!entryName) continue
|
|
321
|
+
const entryPath = resolve(current.dir, entryName)
|
|
322
|
+
|
|
323
|
+
// Never follow symlinks (files or directories), never walk node_modules.
|
|
324
|
+
if (typeof entry.isSymbolicLink === 'function' && entry.isSymbolicLink()) continue
|
|
325
|
+
|
|
326
|
+
if (typeof entry.isDirectory === 'function' && entry.isDirectory()) {
|
|
327
|
+
if (SKIP_DIRS.has(entryName.toLowerCase())) continue
|
|
328
|
+
if (current.depth + 1 > effectiveLimits.maxDepth) continue
|
|
329
|
+
stack.push({ dir: entryPath, depth: current.depth + 1 })
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (typeof entry.isFile !== 'function' || !entry.isFile()) continue
|
|
334
|
+
if (!entryName.toLowerCase().endsWith('.wxapkg')) continue
|
|
335
|
+
|
|
336
|
+
if (totalFiles >= effectiveLimits.maxFiles) {
|
|
337
|
+
maxFilesReached = true
|
|
338
|
+
errors.push(
|
|
339
|
+
scanError(`maxFiles limit (${effectiveLimits.maxFiles}) reached`, root.path, entryPath),
|
|
340
|
+
)
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let size = 0
|
|
345
|
+
try {
|
|
346
|
+
size = io.statSync(entryPath).size
|
|
347
|
+
} catch (err) {
|
|
348
|
+
stat.errors += 1
|
|
349
|
+
errors.push(
|
|
350
|
+
scanError(String(err && err.code ? err.code : 'stat failed'), root.path, entryPath),
|
|
351
|
+
)
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
totalFiles += 1
|
|
356
|
+
stat.filesFound += 1
|
|
357
|
+
if (size > MAX_PACKAGE_FILE_BYTES) {
|
|
358
|
+
stat.errors += 1
|
|
359
|
+
errors.push(scanError('package file exceeds 300 MB limit', root.path, entryPath))
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
rootFiles.push({ path: entryPath, size })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// One package directory can be reachable from overlapping roots; keep the
|
|
367
|
+
// first root that found it so ids stay stable.
|
|
368
|
+
for (const file of rootFiles) {
|
|
369
|
+
const key = normalizePathKey(dirname(file.path))
|
|
370
|
+
const existing = byPackageDir.get(key)
|
|
371
|
+
if (!existing) {
|
|
372
|
+
byPackageDir.set(key, { root, files: [file] })
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
const fileKey = normalizePathKey(file.path)
|
|
376
|
+
if (!existing.files.some((entry) => normalizePathKey(entry.path) === fileKey)) {
|
|
377
|
+
existing.files.push(file)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let totalPackages = 0
|
|
383
|
+
const grouped = []
|
|
384
|
+
for (const { root, files } of byPackageDir.values()) {
|
|
385
|
+
const sorted = files.slice().sort(comparePackageFiles)
|
|
386
|
+
let kept = sorted
|
|
387
|
+
if (sorted.length > effectiveLimits.maxPackageFiles) {
|
|
388
|
+
kept = sorted.slice(0, Math.max(0, effectiveLimits.maxPackageFiles))
|
|
389
|
+
errors.push(
|
|
390
|
+
scanError(
|
|
391
|
+
`maxPackageFiles limit (${effectiveLimits.maxPackageFiles}) reached`,
|
|
392
|
+
root.path,
|
|
393
|
+
dirname(sorted[0].path),
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
totalPackages += kept.length
|
|
398
|
+
if (kept.length > 0) grouped.push(...groupPackages(kept, root))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let added = 0
|
|
402
|
+
let updated = 0
|
|
403
|
+
let unchanged = 0
|
|
404
|
+
const sources = []
|
|
405
|
+
for (const input of grouped) {
|
|
406
|
+
const result = store.upsertSource({ ...input, detected_at: scannedAt, updated_at: scannedAt })
|
|
407
|
+
if (result.created) added += 1
|
|
408
|
+
else if (result.changed) updated += 1
|
|
409
|
+
else unchanged += 1
|
|
410
|
+
sources.push(result.row)
|
|
411
|
+
}
|
|
412
|
+
sources.sort(compareSources)
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
ok: true,
|
|
416
|
+
scannedAt,
|
|
417
|
+
totalSources: sources.length,
|
|
418
|
+
totalPackages,
|
|
419
|
+
added,
|
|
420
|
+
updated,
|
|
421
|
+
unchanged,
|
|
422
|
+
roots: rootStats,
|
|
423
|
+
errors,
|
|
424
|
+
sources,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only text formatting for the mini-program file viewer.
|
|
3
|
+
*
|
|
4
|
+
* Minified `app-config.json` is a single 70 KB line and `app-service.js` is a
|
|
5
|
+
* multi-megabyte one-liner, so raw previews are unusable. This module adds:
|
|
6
|
+
* - JSON: parse + `JSON.stringify(_, null, 2)` (falls back to raw text),
|
|
7
|
+
* - JS/TS/WXS: a conservative, dependency-free tokenizer/beautifier that
|
|
8
|
+
* inserts line breaks after `{`, `}`, `;` and keeps string / template /
|
|
9
|
+
* comment / simple-regex contents untouched,
|
|
10
|
+
* - language detection by extension.
|
|
11
|
+
*
|
|
12
|
+
* Formatting is display-only: the original bytes on disk are never changed and
|
|
13
|
+
* the formatter never executes the code.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Input cap for formatting (larger files are previewed raw). */
|
|
17
|
+
export const MAX_FORMAT_INPUT_BYTES = 5 * 1024 * 1024
|
|
18
|
+
/** Output cap so a pathological one-liner cannot blow up the response. */
|
|
19
|
+
export const MAX_FORMAT_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
const JS_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx', '.wxs', '.wxml'])
|
|
22
|
+
const JSON_EXTENSIONS = new Set(['.json'])
|
|
23
|
+
|
|
24
|
+
/** @param {string} path */
|
|
25
|
+
export function languageOf(path) {
|
|
26
|
+
const lowered = String(path || '').toLowerCase()
|
|
27
|
+
for (const extension of JS_EXTENSIONS) {
|
|
28
|
+
if (lowered.endsWith(extension)) return extension === '.wxml' ? 'xml' : 'js'
|
|
29
|
+
}
|
|
30
|
+
for (const extension of JSON_EXTENSIONS) {
|
|
31
|
+
if (lowered.endsWith(extension)) return 'json'
|
|
32
|
+
}
|
|
33
|
+
return 'text'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pretty-print JSON when it parses; returns null otherwise. */
|
|
37
|
+
export function formatJsonText(text) {
|
|
38
|
+
const source = String(text || '')
|
|
39
|
+
if (source.trim().length === 0) return null
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(source)
|
|
42
|
+
return `${JSON.stringify(parsed, null, 2)}\n`
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isRegexStart(previousSignificant) {
|
|
49
|
+
if (previousSignificant === '') return true
|
|
50
|
+
return '(,=:[!&|?{};+-*%<>~^'.includes(previousSignificant)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Conservative JS beautifier. It only moves whitespace; every non-whitespace
|
|
55
|
+
* character is preserved in order. States tracked: single/double/template
|
|
56
|
+
* strings, line/block comments and simple regex literals.
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function formatJsText(text) {
|
|
61
|
+
const source = String(text || '')
|
|
62
|
+
if (source.length === 0) return ''
|
|
63
|
+
/** @type {string[]} */
|
|
64
|
+
const chunks = []
|
|
65
|
+
let bytes = 0
|
|
66
|
+
let lastChar = ''
|
|
67
|
+
let indent = 0
|
|
68
|
+
let lineStart = true
|
|
69
|
+
let pendingSpace = false
|
|
70
|
+
let previousSignificant = ''
|
|
71
|
+
let index = 0
|
|
72
|
+
const limit = Math.min(source.length, MAX_FORMAT_INPUT_BYTES)
|
|
73
|
+
|
|
74
|
+
const indentText = () => ' '.repeat(Math.min(indent, 40))
|
|
75
|
+
|
|
76
|
+
const push = (value) => {
|
|
77
|
+
if (bytes + value.length > MAX_FORMAT_OUTPUT_BYTES) return false
|
|
78
|
+
chunks.push(value)
|
|
79
|
+
bytes += value.length
|
|
80
|
+
if (value.length > 0) {
|
|
81
|
+
lastChar = value[value.length - 1]
|
|
82
|
+
lineStart = lastChar === '\n'
|
|
83
|
+
}
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
const newline = () => {
|
|
87
|
+
if (bytes === 0 || lastChar === '\n') return true
|
|
88
|
+
chunks.push('\n')
|
|
89
|
+
bytes += 1
|
|
90
|
+
lastChar = '\n'
|
|
91
|
+
lineStart = true
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
while (index < limit) {
|
|
96
|
+
const char = source[index]
|
|
97
|
+
|
|
98
|
+
if (char === '\n' || char === '\r') {
|
|
99
|
+
index += 1
|
|
100
|
+
pendingSpace = false
|
|
101
|
+
// Preserve the author's line structure; minified files simply have none.
|
|
102
|
+
if (!lineStart) newline()
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
if (char === ' ' || char === '\t') {
|
|
106
|
+
pendingSpace = true
|
|
107
|
+
index += 1
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (lineStart) {
|
|
112
|
+
if (!push(indentText())) break
|
|
113
|
+
lineStart = false
|
|
114
|
+
pendingSpace = false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (char === '/' && source[index + 1] === '/') {
|
|
118
|
+
const end = source.indexOf('\n', index)
|
|
119
|
+
const stop = end < 0 ? limit : end
|
|
120
|
+
if (!push(source.slice(index, stop))) break
|
|
121
|
+
index = stop
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (char === '/' && source[index + 1] === '*') {
|
|
125
|
+
const end = source.indexOf('*/', index + 2)
|
|
126
|
+
const stop = end < 0 ? limit : end + 2
|
|
127
|
+
if (!push(source.slice(index, stop))) break
|
|
128
|
+
index = stop
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
132
|
+
const quote = char
|
|
133
|
+
let stop = index + 1
|
|
134
|
+
while (stop < limit) {
|
|
135
|
+
if (source[stop] === '\\') {
|
|
136
|
+
stop += 2
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (source[stop] === quote) {
|
|
140
|
+
stop += 1
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
stop += 1
|
|
144
|
+
}
|
|
145
|
+
if (!push(source.slice(index, stop))) break
|
|
146
|
+
previousSignificant = quote
|
|
147
|
+
index = stop
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (char === '/' && isRegexStart(previousSignificant)) {
|
|
151
|
+
let stop = index + 1
|
|
152
|
+
let inClass = false
|
|
153
|
+
let closed = false
|
|
154
|
+
while (stop < limit) {
|
|
155
|
+
const current = source[stop]
|
|
156
|
+
if (current === '\\') {
|
|
157
|
+
stop += 2
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
if (current === '[') inClass = true
|
|
161
|
+
else if (current === ']') inClass = false
|
|
162
|
+
else if (current === '/' && !inClass) {
|
|
163
|
+
stop += 1
|
|
164
|
+
closed = true
|
|
165
|
+
break
|
|
166
|
+
} else if (current === '\n') {
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
stop += 1
|
|
170
|
+
}
|
|
171
|
+
if (closed) {
|
|
172
|
+
if (!push(source.slice(index, stop))) break
|
|
173
|
+
previousSignificant = '/'
|
|
174
|
+
index = stop
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
// Not a regex after all; fall through to normal handling.
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (char === '{') {
|
|
181
|
+
let lookahead = index + 1
|
|
182
|
+
while (lookahead < limit && /\s/.test(source[lookahead])) lookahead += 1
|
|
183
|
+
if (source[lookahead] === '}') {
|
|
184
|
+
if (!push('{}')) break
|
|
185
|
+
previousSignificant = '}'
|
|
186
|
+
index = lookahead + 1
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
if (!push('{')) break
|
|
190
|
+
indent += 1
|
|
191
|
+
newline()
|
|
192
|
+
previousSignificant = '{'
|
|
193
|
+
index += 1
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
if (char === '}') {
|
|
197
|
+
indent = Math.max(0, indent - 1)
|
|
198
|
+
if (!lineStart) newline()
|
|
199
|
+
if (!push(`${indentText()}}`)) break
|
|
200
|
+
previousSignificant = '}'
|
|
201
|
+
index += 1
|
|
202
|
+
// `} else`, `},`, `};` should stay on the same line.
|
|
203
|
+
if (source[index] !== ';' && source[index] !== ',' && source[index] !== ')') newline()
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (char === ';') {
|
|
207
|
+
if (!push(';')) break
|
|
208
|
+
newline()
|
|
209
|
+
previousSignificant = ';'
|
|
210
|
+
index += 1
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (char === ',') {
|
|
214
|
+
if (!push(', ')) break
|
|
215
|
+
previousSignificant = ','
|
|
216
|
+
index += 1
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (char === ')') {
|
|
220
|
+
if (!push(')')) break
|
|
221
|
+
previousSignificant = ')'
|
|
222
|
+
index += 1
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (pendingSpace && bytes > 0) {
|
|
227
|
+
const last = lastChar
|
|
228
|
+
if (!/[\s(\[{]/.test(last)) {
|
|
229
|
+
if (!push(' ')) break
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
pendingSpace = false
|
|
233
|
+
if (!push(char)) break
|
|
234
|
+
if (!/\s/.test(char)) previousSignificant = char
|
|
235
|
+
index += 1
|
|
236
|
+
}
|
|
237
|
+
if (bytes > 0 && lastChar !== '\n') {
|
|
238
|
+
chunks.push('\n')
|
|
239
|
+
bytes += 1
|
|
240
|
+
}
|
|
241
|
+
return chunks.join('')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Format one preview chunk according to its file type.
|
|
246
|
+
* @param {{path:string, content:string}} input
|
|
247
|
+
* @returns {{text:string, formatted:boolean, language:'json'|'js'|'xml'|'text'}}
|
|
248
|
+
*/
|
|
249
|
+
export function formatContent(input) {
|
|
250
|
+
const path = String((input && input.path) || '')
|
|
251
|
+
const content = String((input && input.content) || '')
|
|
252
|
+
const language = languageOf(path)
|
|
253
|
+
if (language === 'json') {
|
|
254
|
+
const formatted = formatJsonText(content)
|
|
255
|
+
if (formatted !== null) return { text: formatted, formatted: true, language }
|
|
256
|
+
return { text: content, formatted: false, language }
|
|
257
|
+
}
|
|
258
|
+
if (language === 'js') {
|
|
259
|
+
const formatted = formatJsText(content)
|
|
260
|
+
return { text: formatted, formatted: true, language }
|
|
261
|
+
}
|
|
262
|
+
return { text: content, formatted: false, language }
|
|
263
|
+
}
|