dsh-plugin-cicd 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +402 -2
- package/RELEASING.md +63 -0
- package/client.js +4417 -0
- package/cordis.patch.yml +63 -0
- package/icon.svg +8 -0
- package/index.js +4938 -0
- package/lib/bilibili.mjs +1000 -0
- package/lib/config-store.mjs +159 -0
- package/package.json +62 -3
- package/scripts/configure.mjs +213 -0
- package/scripts/push-via-api.ps1 +111 -0
- package/scripts/verify-bundle.mjs +320 -0
- package/tests/bilibili-check.mjs +450 -0
- package/tests/bump-e2e.mjs +431 -0
- package/tests/client-render.mjs +1483 -0
- package/tests/host-checks.mjs +576 -0
- package/tests/mount-check.mjs +300 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Bundle contract check for a DeepSeek Harness profile bundle.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the expensive failure in this family is never a crash. It is
|
|
6
|
+
* a package that installs cleanly, reports success, and then loads nothing —
|
|
7
|
+
* `dsh.bundle.patch` pointing at a file that was renamed, an `exports` target
|
|
8
|
+
* that no longer exists, a `files` entry that never made it into the tarball.
|
|
9
|
+
* None of those are visible until somebody installs the release. This checker
|
|
10
|
+
* turns them into a red build before the package is published.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node scripts/verify-bundle.mjs [root]
|
|
14
|
+
*
|
|
15
|
+
* `root` defaults to the current directory, so the same file can be pointed at
|
|
16
|
+
* an unpacked release asset to prove the artifact — not the checkout — is sound.
|
|
17
|
+
*
|
|
18
|
+
* Exit 0 when the manifest is internally consistent; exit 1 with one `FAIL` line
|
|
19
|
+
* per hard violation. Warnings are advisory and never fail the run.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFileSync } from 'node:child_process'
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
24
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
25
|
+
|
|
26
|
+
const ROOT = resolve(process.argv[2] ?? process.cwd())
|
|
27
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
28
|
+
|
|
29
|
+
const failures = []
|
|
30
|
+
const warnings = []
|
|
31
|
+
const fail = (message) => failures.push(message)
|
|
32
|
+
const warn = (message) => warnings.push(message)
|
|
33
|
+
|
|
34
|
+
const manifestPath = join(ROOT, 'package.json')
|
|
35
|
+
if (!existsSync(manifestPath)) {
|
|
36
|
+
console.error(`verify-bundle: FAIL — no package.json under ${ROOT}`)
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let manifest
|
|
41
|
+
try {
|
|
42
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`verify-bundle: FAIL — package.json is not valid JSON: ${error.message}`)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
49
|
+
|
|
50
|
+
/** Match one path segment that may contain `*` wildcards against a directory. */
|
|
51
|
+
function matchSegment(dir, segment, rest) {
|
|
52
|
+
const matcher = new RegExp(`^${segment.split('*').map(escapeRe).join('[^/]*')}$`)
|
|
53
|
+
const found = []
|
|
54
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
55
|
+
if (!matcher.test(entry.name)) continue
|
|
56
|
+
const absolute = join(dir, entry.name)
|
|
57
|
+
if (rest.length === 0) {
|
|
58
|
+
if (entry.isFile()) found.push(absolute)
|
|
59
|
+
} else if (entry.isDirectory()) {
|
|
60
|
+
found.push(...matchUnder(absolute, rest))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return found
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve the remainder of a subpath pattern relative to an already-open directory. */
|
|
67
|
+
function matchUnder(dir, segments) {
|
|
68
|
+
const [head, ...rest] = segments
|
|
69
|
+
if (head === undefined || head === '') return []
|
|
70
|
+
if (head.includes('*')) return matchSegment(dir, head, rest)
|
|
71
|
+
const next = join(dir, head)
|
|
72
|
+
if (!existsSync(next)) return []
|
|
73
|
+
if (rest.length === 0) return statSync(next).isFile() ? [next] : []
|
|
74
|
+
return statSync(next).isDirectory() ? matchUnder(next, rest) : []
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Expand one `exports` / `files` entry.
|
|
79
|
+
*
|
|
80
|
+
* Subpath patterns (`./locale/*.json`) are legitimate and common — the loader
|
|
81
|
+
* addresses individual files through them — so a literal `existsSync` on the
|
|
82
|
+
* pattern itself reports a false failure. Everything before the first wildcard
|
|
83
|
+
* is a fixed prefix that must exist; the wildcards must then match real files.
|
|
84
|
+
*/
|
|
85
|
+
function expandEntry(relative) {
|
|
86
|
+
const segments = relative.replace(/^\.\//, '').split('/')
|
|
87
|
+
const fixed = []
|
|
88
|
+
for (const segment of segments) {
|
|
89
|
+
if (segment.includes('*')) break
|
|
90
|
+
fixed.push(segment)
|
|
91
|
+
}
|
|
92
|
+
if (fixed.length === segments.length) return existsSync(join(ROOT, relative)) ? [join(ROOT, relative)] : []
|
|
93
|
+
const base = join(ROOT, ...fixed)
|
|
94
|
+
if (base === ROOT && fixed.length === 0) return []
|
|
95
|
+
if (!existsSync(base) || !statSync(base).isDirectory()) return []
|
|
96
|
+
return matchUnder(base, segments.slice(fixed.length))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolve one workspace-relative path and report it when it is not a real file. */
|
|
100
|
+
function requireFile(relative, source) {
|
|
101
|
+
if (typeof relative !== 'string' || relative === '') {
|
|
102
|
+
fail(`${source}: expected a path, got ${JSON.stringify(relative)}`)
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
const absolute = join(ROOT, relative)
|
|
106
|
+
const matches = expandEntry(relative)
|
|
107
|
+
if (matches.length === 0) {
|
|
108
|
+
fail(
|
|
109
|
+
relative.includes('*')
|
|
110
|
+
? `${source} pattern matches nothing: ${relative}`
|
|
111
|
+
: `${source} points at a missing file: ${relative}`,
|
|
112
|
+
)
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
const first = matches[0]
|
|
116
|
+
if (!statSync(first).isFile()) {
|
|
117
|
+
fail(`${source} points at a directory, not a file: ${relative}`)
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
void absolute
|
|
121
|
+
return first
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 1. Identity. A release is addressed by these two strings, so neither may drift.
|
|
125
|
+
if (typeof manifest.name !== 'string' || manifest.name.trim() === '') {
|
|
126
|
+
fail('package.json: `name` is missing')
|
|
127
|
+
}
|
|
128
|
+
if (typeof manifest.version !== 'string' || !SEMVER.test(manifest.version)) {
|
|
129
|
+
fail(`package.json: \`version\` is missing or not semver: ${JSON.stringify(manifest.version)}`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. The bundle patch is the whole reason a profile composes this package.
|
|
133
|
+
const patchRelative = manifest.dsh?.bundle?.patch
|
|
134
|
+
if (typeof patchRelative !== 'string' || patchRelative === '') {
|
|
135
|
+
fail('package.json: `dsh.bundle.patch` is missing — without it the profile never composes this package')
|
|
136
|
+
} else {
|
|
137
|
+
const patchPath = requireFile(patchRelative, 'dsh.bundle.patch')
|
|
138
|
+
if (patchPath !== null) {
|
|
139
|
+
const text = readFileSync(patchPath, 'utf8')
|
|
140
|
+
if (text.trim() === '') {
|
|
141
|
+
fail(`the bundle patch is empty: ${patchRelative}`)
|
|
142
|
+
} else if (typeof manifest.name === 'string' && !text.includes(manifest.name)) {
|
|
143
|
+
warn(`the bundle patch never names "${manifest.name}"; confirm the row it inserts is the one the profile loads`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3. Every published entry point must exist. `exports` is what the loader reads.
|
|
149
|
+
const exportsField = manifest.exports
|
|
150
|
+
const exportEntries = []
|
|
151
|
+
if (exportsField === undefined) {
|
|
152
|
+
warn('package.json: no `exports` map; the loader reaches the browser half through its `./client` subpath')
|
|
153
|
+
} else if (typeof exportsField === 'string') {
|
|
154
|
+
exportEntries.push(['exports', exportsField])
|
|
155
|
+
} else if (exportsField !== null && typeof exportsField === 'object') {
|
|
156
|
+
for (const [key, value] of Object.entries(exportsField)) {
|
|
157
|
+
if (typeof value === 'string') exportEntries.push([`exports["${key}"]`, value])
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
fail('package.json: `exports` is neither a string nor a map')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const resolvedExports = new Map()
|
|
164
|
+
for (const [source, value] of exportEntries) {
|
|
165
|
+
if (!value.startsWith('./')) continue
|
|
166
|
+
const absolute = requireFile(value, source)
|
|
167
|
+
if (absolute !== null) resolvedExports.set(source, { relative: value, absolute })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 4. `files` is the publish allow-list: an entry that does not exist is a typo,
|
|
171
|
+
// and a real file missing from it is content the release will not carry.
|
|
172
|
+
//
|
|
173
|
+
// That second half is the one that bites. A new local module imported by an
|
|
174
|
+
// entry point is invisible until somebody installs the tarball, at which point
|
|
175
|
+
// the import fails and the plugin never loads — the checkout works, so nothing
|
|
176
|
+
// local contradicts it. So the entry points' relative imports are walked and
|
|
177
|
+
// every one of them must be covered by the allow-list.
|
|
178
|
+
if (manifest.files !== undefined) {
|
|
179
|
+
if (!Array.isArray(manifest.files)) {
|
|
180
|
+
fail('package.json: `files` must be an array')
|
|
181
|
+
} else {
|
|
182
|
+
for (const entry of manifest.files) {
|
|
183
|
+
if (typeof entry !== 'string' || entry === '') {
|
|
184
|
+
fail(`package.json: invalid \`files\` entry: ${JSON.stringify(entry)}`)
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (expandEntry(entry).length === 0) fail(`\`files\` entry resolves to nothing: ${entry}`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
warn('package.json: no `files` allow-list; the published package carries every tracked file')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Walk the relative imports of one file, transitively.
|
|
196
|
+
*
|
|
197
|
+
* Only static `from '...'` forms are matched: those are what an entry point uses
|
|
198
|
+
* to reach its own modules, and a dynamic import would be a finding of its own.
|
|
199
|
+
*/
|
|
200
|
+
function relativeImports(file, seen = new Set()) {
|
|
201
|
+
if (seen.has(file) || !existsSync(file)) return seen
|
|
202
|
+
seen.add(file)
|
|
203
|
+
const text = readFileSync(file, 'utf8')
|
|
204
|
+
// Match the specifier, not the statement. A multi-line `import { … } from '…'`
|
|
205
|
+
// is common, and an expression trying to span the whole statement also spans
|
|
206
|
+
// unrelated code between two of them — which produced a false report about a
|
|
207
|
+
// file nobody imports.
|
|
208
|
+
const pattern = /\bfrom\s*['"](\.[^'"]+)['"]|\bimport\s*\(\s*['"](\.[^'"]+)['"]/g
|
|
209
|
+
for (const match of text.matchAll(pattern)) {
|
|
210
|
+
const specifier = match[1] ?? match[2]
|
|
211
|
+
if (specifier === undefined) continue
|
|
212
|
+
const resolved = resolve(dirname(file), specifier)
|
|
213
|
+
for (const candidate of [resolved, `${resolved}.mjs`, `${resolved}.js`, join(resolved, 'index.mjs')]) {
|
|
214
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
215
|
+
relativeImports(candidate, seen)
|
|
216
|
+
break
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return seen
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Whether a workspace-relative path is carried by the `files` allow-list. */
|
|
224
|
+
function isCarried(relativePath, allowList) {
|
|
225
|
+
if (allowList === null) return true
|
|
226
|
+
for (const entry of allowList) {
|
|
227
|
+
const normalized = entry.replace(/\/+$/, '')
|
|
228
|
+
if (relativePath === normalized || relativePath.startsWith(`${normalized}/`)) return true
|
|
229
|
+
}
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (Array.isArray(manifest.files)) {
|
|
234
|
+
const allowList = manifest.files.filter((entry) => typeof entry === 'string')
|
|
235
|
+
const carried = new Set()
|
|
236
|
+
for (const [source, value] of exportEntries) {
|
|
237
|
+
if (typeof value !== 'string' || !value.startsWith('./')) continue
|
|
238
|
+
// Only JavaScript entry points import anything. `exports["./package.json"]` is
|
|
239
|
+
// an export target too, but it is data — and npm always ships it, listed or not.
|
|
240
|
+
if (!/\.(mjs|js)$/.test(value)) continue
|
|
241
|
+
const absolute = join(ROOT, value)
|
|
242
|
+
if (!existsSync(absolute)) continue
|
|
243
|
+
for (const file of relativeImports(absolute)) {
|
|
244
|
+
// `rel`, not `relative`: the latter is the imported path helper.
|
|
245
|
+
const rel = relative(ROOT, file).split(sep).join('/')
|
|
246
|
+
if (!isCarried(rel, allowList)) carried.add(rel)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (carried.size > 0) {
|
|
250
|
+
fail(`these files are imported by an entry point but are not in \`files\`, so the published package would fail to load: ${[...carried].join(', ')}`)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 5. The browser half is declared in `dsh.client` and delivered by `./client`.
|
|
255
|
+
const clientField = manifest.dsh?.client
|
|
256
|
+
if (clientField !== undefined) {
|
|
257
|
+
const target = exportEntries.find(([source]) => source === 'exports["./client"]')
|
|
258
|
+
if (target === undefined) {
|
|
259
|
+
fail('`dsh.client` is declared but package.json has no `./client` export')
|
|
260
|
+
} else if (!resolvedExports.has('exports["./client"]')) {
|
|
261
|
+
// requireFile already recorded the precise reason.
|
|
262
|
+
} else if (clientField?.platform !== 'web') {
|
|
263
|
+
warn(`dsh.client.platform is ${JSON.stringify(clientField?.platform)}; the loader expects "web"`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 6. Parse every entry point. `--check` parses without executing, which is what
|
|
268
|
+
// this needs: the browser half references `window` and the host half imports
|
|
269
|
+
// the harness, so neither can be imported here.
|
|
270
|
+
const syntaxTargets = new Map()
|
|
271
|
+
for (const [source, value] of exportEntries) {
|
|
272
|
+
if (typeof value === 'string' && value.endsWith('.js') && !value.includes('*')) syntaxTargets.set(value, source)
|
|
273
|
+
}
|
|
274
|
+
if (typeof manifest.main === 'string' && manifest.main.endsWith('.js')) {
|
|
275
|
+
syntaxTargets.set(manifest.main, 'main')
|
|
276
|
+
}
|
|
277
|
+
for (const relative of ['index.js', 'client.js']) {
|
|
278
|
+
if (existsSync(join(ROOT, relative))) syntaxTargets.set(`./${relative}`, 'top-level entry')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let parsed = 0
|
|
282
|
+
for (const [relative, source] of syntaxTargets) {
|
|
283
|
+
const absolute = join(ROOT, relative)
|
|
284
|
+
if (!existsSync(absolute) || !statSync(absolute).isFile()) {
|
|
285
|
+
fail(`${source} points at a missing entry file: ${relative}`)
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
execFileSync(process.execPath, ['--check', absolute], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
290
|
+
parsed += 1
|
|
291
|
+
} catch (error) {
|
|
292
|
+
const detail = (error.stderr ?? '').toString().trim().split('\n').slice(0, 6).join('\n')
|
|
293
|
+
fail(`entry file does not parse: ${relative}${detail === '' ? '' : `\n${detail}`}`)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 7. Advisory presentation. DSH renders these on the plugin page; a missing one
|
|
298
|
+
// is a worse first impression, not a broken install.
|
|
299
|
+
if (typeof manifest.meta?.title !== 'string' || manifest.meta.title === '') {
|
|
300
|
+
warn('package.json: `meta.title` is missing — the plugin list falls back to the package name')
|
|
301
|
+
}
|
|
302
|
+
if (typeof manifest.meta?.description !== 'string' || manifest.meta.description === '') {
|
|
303
|
+
warn('package.json: `meta.description` is missing — the plugin page shows no summary')
|
|
304
|
+
}
|
|
305
|
+
if (typeof manifest.icon === 'string' && manifest.icon !== '' && !existsSync(join(ROOT, manifest.icon))) {
|
|
306
|
+
warn(`package.json: \`icon\` points at a missing file: ${manifest.icon}`)
|
|
307
|
+
}
|
|
308
|
+
if (typeof manifest.engines?.node !== 'string') {
|
|
309
|
+
warn('package.json: `engines.node` is missing; the supported runtime is undocumented')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const identity = `${manifest.name ?? '<unnamed>'}@${manifest.version ?? '<unversioned>'}`
|
|
313
|
+
console.log(`verify-bundle: ${identity} (${ROOT})`)
|
|
314
|
+
for (const warning of warnings) console.log(` warn ${warning}`)
|
|
315
|
+
for (const failure of failures) console.log(` FAIL ${failure}`)
|
|
316
|
+
if (failures.length > 0) {
|
|
317
|
+
console.error(`verify-bundle: ${failures.length} contract violation(s) in ${identity}`)
|
|
318
|
+
process.exit(1)
|
|
319
|
+
}
|
|
320
|
+
console.log(`verify-bundle: OK — ${resolvedExports.size} export target(s) resolved, ${parsed} entry file(s) parsed`)
|