@vobs/cli 1.7.5 → 1.7.8

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.
Files changed (64) hide show
  1. package/dist/cli.cjs +551 -5
  2. package/dist/cli.cjs.map +1 -1
  3. package/dist/cli.js +551 -5
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/dsh.cjs +677 -0
  6. package/dist/commands/dsh.cjs.map +1 -0
  7. package/dist/commands/dsh.d.cts +43 -0
  8. package/dist/commands/dsh.d.ts +43 -0
  9. package/dist/commands/dsh.js +638 -0
  10. package/dist/commands/dsh.js.map +1 -0
  11. package/dist/commands/generate.cjs.map +1 -1
  12. package/dist/commands/generate.js.map +1 -1
  13. package/dist/commands/index.cjs +543 -5
  14. package/dist/commands/index.cjs.map +1 -1
  15. package/dist/commands/index.d.cts +2 -0
  16. package/dist/commands/index.d.ts +2 -0
  17. package/dist/commands/index.js +540 -5
  18. package/dist/commands/index.js.map +1 -1
  19. package/dist/commands/init.cjs +5 -2
  20. package/dist/commands/init.cjs.map +1 -1
  21. package/dist/commands/init.js +5 -2
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/index.cjs +553 -7
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.js +551 -5
  26. package/dist/index.js.map +1 -1
  27. package/dist/repl.cjs +5 -2
  28. package/dist/repl.cjs.map +1 -1
  29. package/dist/repl.js +5 -2
  30. package/dist/repl.js.map +1 -1
  31. package/dist/start.cjs +553 -7
  32. package/dist/start.cjs.map +1 -1
  33. package/dist/start.js +551 -5
  34. package/dist/start.js.map +1 -1
  35. package/dist/templates/dsh-plugin/README.md.hbs +54 -0
  36. package/dist/templates/dsh-plugin/cordis.patch.yml.hbs +7 -0
  37. package/dist/templates/dsh-plugin/package.json.hbs +40 -0
  38. package/dist/templates/dsh-plugin/src/client/index.tsx.hbs +24 -0
  39. package/dist/templates/dsh-plugin/src/client/panel.tsx.hbs +45 -0
  40. package/dist/templates/dsh-plugin/src/client/styles.ts.hbs +65 -0
  41. package/dist/templates/dsh-plugin/src/host/index.ts.hbs +20 -0
  42. package/dist/templates/dsh-plugin/tsconfig.json.hbs +19 -0
  43. package/dist/templates/dsh-plugin/vite.config.ts.hbs +13 -0
  44. package/dist/utils/file.cjs +5 -2
  45. package/dist/utils/file.cjs.map +1 -1
  46. package/dist/utils/file.d.cts +2 -2
  47. package/dist/utils/file.d.ts +2 -2
  48. package/dist/utils/file.js +5 -2
  49. package/dist/utils/file.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/cli.ts +28 -0
  52. package/src/commands/dsh.test.ts +279 -0
  53. package/src/commands/dsh.ts +675 -0
  54. package/src/commands/index.ts +2 -1
  55. package/src/templates/dsh-plugin/README.md.hbs +54 -0
  56. package/src/templates/dsh-plugin/cordis.patch.yml.hbs +7 -0
  57. package/src/templates/dsh-plugin/package.json.hbs +40 -0
  58. package/src/templates/dsh-plugin/src/client/index.tsx.hbs +24 -0
  59. package/src/templates/dsh-plugin/src/client/panel.tsx.hbs +45 -0
  60. package/src/templates/dsh-plugin/src/client/styles.ts.hbs +65 -0
  61. package/src/templates/dsh-plugin/src/host/index.ts.hbs +20 -0
  62. package/src/templates/dsh-plugin/tsconfig.json.hbs +19 -0
  63. package/src/templates/dsh-plugin/vite.config.ts.hbs +13 -0
  64. package/src/utils/file.ts +7 -3
@@ -0,0 +1,675 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { execSync, spawnSync } from 'node:child_process'
4
+ import { createServer, type ServerResponse } from 'node:http'
5
+ import { createRequire } from 'node:module'
6
+ import { isAbsolute, basename, dirname, join, relative, resolve } from 'node:path'
7
+ import * as p from '@clack/prompts'
8
+ import { logger } from '../utils/logger.js'
9
+ import { ensureDir, scaffoldProject } from '../utils/file.js'
10
+ import { installDependencies, type PackageManager } from '../utils/pm.js'
11
+
12
+ export interface DshOptions {
13
+ dir?: string
14
+ pm?: PackageManager
15
+ profile?: string
16
+ spec?: string
17
+ repo?: string
18
+ tag?: string
19
+ subpath?: string
20
+ from?: string
21
+ dry?: boolean
22
+ port?: number
23
+ }
24
+
25
+ /** 平台内置模块:这些 `require` 出现在产物里是正常的。 */
26
+ const PLATFORM_MODULES = ['react', 'react-dom']
27
+
28
+ const vobsVersion = (): string => {
29
+ try {
30
+ const manifest = JSON.parse(
31
+ readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
32
+ ) as { version?: string }
33
+ return manifest.version ?? '1.7.5'
34
+ } catch {
35
+ return '1.7.5'
36
+ }
37
+ }
38
+
39
+ /** 去掉 scope,得到 cordis 行 id 与目录名都可用的标识。 */
40
+ function toRowId(packageName: string): string {
41
+ return packageName.replace(/^@[^/]+\//u, '')
42
+ }
43
+
44
+ /* ------------------------------------------------------------------ init */
45
+
46
+ async function dshInitCommand(target: string | undefined, options: DshOptions): Promise<void> {
47
+ const name = target ?? ((await p.text({
48
+ message: '插件包名(npm 包名,小写):',
49
+ placeholder: 'dsh-plugin-my-panel',
50
+ validate: value => (/^[a-z0-9][a-z0-9._~-]*$/u.test(value) ? undefined : '必须是合法的 npm 包名')
51
+ })) as string)
52
+
53
+ if (p.isCancel(name)) {
54
+ p.cancel('已取消')
55
+ return
56
+ }
57
+
58
+ const packageManager =
59
+ options.pm ??
60
+ ((await p.select({
61
+ message: '包管理器:',
62
+ options: [
63
+ { value: 'pnpm', label: 'pnpm' },
64
+ { value: 'npm', label: 'npm' }
65
+ ]
66
+ })) as PackageManager)
67
+
68
+ if (p.isCancel(packageManager)) {
69
+ p.cancel('已取消')
70
+ return
71
+ }
72
+
73
+ const targetDir = options.dir !== undefined ? resolve(options.dir) : resolve(process.cwd(), name)
74
+ const rowId = toRowId(name)
75
+ const displayName = rowId
76
+ .replace(/^dsh-plugin-/u, '')
77
+ .split('-')
78
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
79
+ .join(' ')
80
+
81
+ logger.step(`创建 DSH 插件项目 ${name} …`)
82
+ await scaffoldProject(
83
+ targetDir,
84
+ { packageName: name, rowId, displayName, vobsVersion: vobsVersion() },
85
+ 'dsh-plugin'
86
+ )
87
+
88
+ // 单独写 .gitignore:模板里的点文件在打包时容易丢,而且这里的规则有一条很关键 ——
89
+ // lib/ 是产物,**必须提交**(DSH 安装时不构建)。
90
+ ensureDir(targetDir)
91
+ writeFileSync(
92
+ resolve(targetDir, '.gitignore'),
93
+ ['node_modules/', '*.log', '*.tsbuildinfo', '', '# lib/ 是构建产物,必须提交:DSH 安装时不会构建你的包。', ''].join('\n'),
94
+ 'utf8'
95
+ )
96
+ logger.success('项目已生成')
97
+
98
+ logger.step('安装依赖 …')
99
+ try {
100
+ installDependencies(targetDir, packageManager)
101
+ logger.success('依赖已安装')
102
+ } catch {
103
+ logger.warn(`依赖安装失败,请手动执行 ${packageManager} install`)
104
+ }
105
+
106
+ logger.step('下一步')
107
+ console.log(`
108
+ cd ${name}
109
+ ${packageManager} run dev # 本地预览,不用装进 DSH
110
+ ${packageManager} run build # 产出 lib/{index,client}.js
111
+
112
+ # 提交产物并打 tag 之后,在 DSH 插件页填:
113
+ github:<owner>/<repo>#v0.1.0&path:/<本包在仓库中的路径>
114
+ `)
115
+ }
116
+
117
+ /* ----------------------------------------------------------------- build */
118
+
119
+ /**
120
+ * 构建 DSH 插件产物。
121
+ *
122
+ * 一个可安装的插件包有**两半**:Host 侧(cordis 插件,ESM)与 Client 侧
123
+ * (浏览器 bundle)。项目的 vite.config 只负责 Client(`dshBundle()` 会把产物
124
+ * 落到 package.json 声明的位置),Host 这一半由本命令补上 ——
125
+ * 否则 `exports["."]` 指向的文件根本不存在,装进 DSH 必然失败。
126
+ */
127
+ async function dshBuildCommand(): Promise<void> {
128
+ const projectDir = process.cwd()
129
+ const configFile = resolve(projectDir, 'vite.config.ts')
130
+ if (!existsSync(configFile)) {
131
+ logger.error('当前目录没有 vite.config.ts,请在插件项目根目录运行。')
132
+ process.exitCode = 1
133
+ return
134
+ }
135
+
136
+ const manifest = readManifest(projectDir)
137
+ if (manifest === undefined) {
138
+ logger.error('读不到 package.json。')
139
+ process.exitCode = 1
140
+ return
141
+ }
142
+
143
+ const hostEntry = resolveExport(manifest, '.')
144
+ if (hostEntry === undefined) {
145
+ logger.error('package.json 的 exports 里没有 ".",DSH 加载不到 Host 侧插件。')
146
+ process.exitCode = 1
147
+ return
148
+ }
149
+
150
+ const { build } = await import('vite')
151
+
152
+ logger.info('构建客户端产物 …')
153
+ await build({ configFile, mode: 'production' })
154
+
155
+ logger.info('构建 Host 产物 …')
156
+ const hostTarget = resolve(projectDir, hostEntry.replace(/^\.\//u, ''))
157
+ const hostEntrySource = resolve(projectDir, 'src', 'host', 'index.ts')
158
+ if (!existsSync(hostEntrySource)) {
159
+ logger.warn(`没有找到 ${hostEntrySource},跳过 Host 构建。`)
160
+ return
161
+ }
162
+ await build({
163
+ configFile: false,
164
+ root: projectDir,
165
+ logLevel: 'warn',
166
+ build: {
167
+ outDir: relative(projectDir, dirname(hostTarget)) || '.',
168
+ emptyOutDir: false,
169
+ target: 'es2022',
170
+ minify: false,
171
+ sourcemap: false,
172
+ lib: {
173
+ entry: hostEntrySource,
174
+ formats: ['es'],
175
+ fileName: () => basename(hostTarget)
176
+ }
177
+ }
178
+ })
179
+
180
+ logger.success('构建完成 → 客户端与 Host 产物都已就位')
181
+ }
182
+
183
+ /* ------------------------------------------------------------------ dev */
184
+
185
+ /** 从 exports 字段里取出一个子路径,兼容字符串与一层条件对象两种写法。 */
186
+ export function resolveExport(manifest: Record<string, unknown>, key: string): string | undefined {
187
+ const exportsField = (manifest.exports ?? {}) as Record<string, unknown>
188
+ const entry = exportsField[key]
189
+ if (typeof entry === 'string') return entry
190
+ if (typeof entry === 'object' && entry !== null) {
191
+ const record = entry as Record<string, unknown>
192
+ for (const condition of ['default', 'import', 'require']) {
193
+ const value = record[condition]
194
+ if (typeof value === 'string') return value
195
+ }
196
+ }
197
+ return undefined
198
+ }
199
+
200
+ /** 生成预览页。纯函数,便于单测。 */
201
+ export function buildPreviewHtml(options: { packageName: string; title?: string }): string {
202
+ // 包名会被嵌进 <script> 里:JSON 序列化不会转义 `/`,所以必须自己把 `<` 干掉,
203
+ // 否则一个含 </script> 的包名就能突破标签。
204
+ const name = JSON.stringify(options.packageName).replace(/</gu, '\\u003c')
205
+ const title = (options.title ?? options.packageName).replace(/[<>&]/gu, '')
206
+ return `<!doctype html>
207
+ <html lang="zh-CN">
208
+ <head>
209
+ <meta charset="utf-8">
210
+ <meta name="viewport" content="width=device-width, initial-scale=1">
211
+ <title>vobs preview · ${title}</title>
212
+ </head>
213
+ <body>
214
+ <div id="root"></div>
215
+
216
+ <!-- 1. 先装垫片:必须在插件 bundle 之前,捕获它的 factory 注册 -->
217
+ <script>
218
+ window.__VOBS_DSH_PREVIEW__ = { factories: new Map() };
219
+ window.__ModuleLoader__ = {
220
+ load: function (entry) { window.__VOBS_DSH_PREVIEW__.factories.set(entry.id, entry.factory); }
221
+ };
222
+ </script>
223
+
224
+ <!-- 2. 插件产物:它只注册 factory,不产生副作用 -->
225
+ <script src="/client.js"></script>
226
+
227
+ <!-- 3. 启动预览外壳 -->
228
+ <script type="module">
229
+ import { startPreview } from '/preview.js';
230
+ window.__VOBS_PREVIEW__ = startPreview({
231
+ container: document.getElementById('root'),
232
+ packageName: ${name}
233
+ });
234
+ </script>
235
+
236
+ <!-- 4. 重建后自动刷新;?static=1 关掉长连接,便于无头截图与 CI 断言 -->
237
+ <script>
238
+ if (!location.search.includes('static')) {
239
+ new EventSource('/events').addEventListener('reload', function () { location.reload(); });
240
+ }
241
+ </script>
242
+ </body>
243
+ </html>
244
+ `
245
+ }
246
+
247
+ /**
248
+ * vite 的构建监听器。只声明本命令用到的两个成员 ——
249
+ * `vite` 没有把 rollup 的 `RollupWatcher` 再导出,为一个 dev 命令引入 rollup 类型不划算。
250
+ */
251
+ interface BuildWatcher {
252
+ on(event: 'event', handler: (event: { code?: string; error?: { message?: string } }) => void): void
253
+ close(): Promise<void>
254
+ }
255
+
256
+ async function dshDevCommand(options: DshOptions): Promise<void> {
257
+ const projectDir = process.cwd()
258
+ const manifest = readManifest(projectDir)
259
+ if (manifest === undefined) {
260
+ logger.error('当前目录没有可读的 package.json。')
261
+ process.exitCode = 1
262
+ return
263
+ }
264
+ if (readManifest(projectDir)?.dsh === undefined) {
265
+ logger.error('这不是一个 DSH 插件项目(package.json 里没有 dsh 字段)。')
266
+ process.exitCode = 1
267
+ return
268
+ }
269
+
270
+ const packageName = typeof manifest.name === 'string' ? manifest.name : 'dsh-plugin'
271
+ const clientEntry = resolveExport(manifest, './client')
272
+ if (clientEntry === undefined) {
273
+ logger.error('package.json 的 exports 里没有 "./client",页面拿不到客户端 bundle。')
274
+ process.exitCode = 1
275
+ return
276
+ }
277
+ const clientFile = resolve(projectDir, clientEntry.replace(/^\.\//u, ''))
278
+
279
+ // 预览运行时从**项目自己**的 node_modules 解析:项目才是声明 @vobs/dsh 的那一方。
280
+ let previewFile: string | undefined
281
+ try {
282
+ const projectRequire = createRequire(join(projectDir, 'package.json'))
283
+ let packageRoot: string | undefined
284
+ try {
285
+ // 首选:包导出了 ./package.json(@vobs/dsh 有)。
286
+ packageRoot = dirname(projectRequire.resolve('@vobs/dsh/package.json'))
287
+ } catch {
288
+ // 回退:解析主入口(dist/index.js 或 dist/index.cjs),再回到包根。
289
+ packageRoot = dirname(dirname(projectRequire.resolve('@vobs/dsh')))
290
+ }
291
+ const candidate = join(packageRoot, 'dist', 'preview.js')
292
+ previewFile = existsSync(candidate) ? candidate : undefined
293
+ } catch {
294
+ previewFile = undefined
295
+ }
296
+ if (previewFile === undefined) {
297
+ logger.error('项目里找不到 @vobs/dsh 的预览运行时。先安装依赖:pnpm add -D @vobs/dsh')
298
+ process.exitCode = 1
299
+ return
300
+ }
301
+
302
+ const port = options.port ?? 5199
303
+ const clients = new Set<ServerResponse>()
304
+ const broadcast = (event: string): void => {
305
+ for (const client of clients) client.write(`event: ${event}\ndata: 1\n\n`)
306
+ }
307
+
308
+ /* 先构建一次,再进监听模式 */
309
+ logger.info('构建产物 …')
310
+ const { build } = await import('vite')
311
+ const configFile = resolve(projectDir, 'vite.config.ts')
312
+ let buildError: string | undefined
313
+
314
+ try {
315
+ const watcher = (await build({
316
+ configFile,
317
+ mode: 'development',
318
+ logLevel: 'warn',
319
+ build: { watch: {} }
320
+ })) as unknown as BuildWatcher
321
+
322
+ watcher.on('event', event => {
323
+ if (event.code === 'END') {
324
+ buildError = undefined
325
+ logger.success('重新构建完成')
326
+ broadcast('reload')
327
+ } else if (event.code === 'ERROR') {
328
+ buildError = event.error?.message ?? '构建失败'
329
+ logger.error(buildError)
330
+ }
331
+ })
332
+ process.on('SIGINT', () => {
333
+ void watcher.close().then(() => process.exit(0))
334
+ })
335
+ } catch (error) {
336
+ logger.error(`构建失败:${error instanceof Error ? error.message : String(error)}`)
337
+ process.exitCode = 1
338
+ return
339
+ }
340
+
341
+ const server = createServer((request, response) => {
342
+ const url = (request.url ?? '/').split('?')[0]
343
+
344
+ if (url === '/events') {
345
+ response.writeHead(200, {
346
+ 'content-type': 'text/event-stream',
347
+ 'cache-control': 'no-store',
348
+ connection: 'keep-alive'
349
+ })
350
+ response.write(': connected\n\n')
351
+ clients.add(response)
352
+ request.on('close', () => clients.delete(response))
353
+ return
354
+ }
355
+
356
+ const send = (body: string | Buffer, type: string): void => {
357
+ response.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' })
358
+ response.end(body)
359
+ }
360
+
361
+ if (url === '/') {
362
+ send(buildPreviewHtml({ packageName }), 'text/html; charset=utf-8')
363
+ return
364
+ }
365
+ if (url === '/preview.js') {
366
+ void readFile(previewFile as string).then(body => send(body, 'text/javascript; charset=utf-8'))
367
+ return
368
+ }
369
+ if (url === '/client.js') {
370
+ if (!existsSync(clientFile)) {
371
+ response.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' })
372
+ response.end(`还没有产物:${clientEntry}\n${buildError ?? ''}`)
373
+ return
374
+ }
375
+ void readFile(clientFile).then(body => send(body, 'text/javascript; charset=utf-8'))
376
+ return
377
+ }
378
+
379
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
380
+ response.end('not found')
381
+ })
382
+
383
+ await new Promise<void>(resolveListen => {
384
+ server.listen(port, '127.0.0.1', resolveListen)
385
+ })
386
+
387
+ logger.success(`预览已就绪:http://127.0.0.1:${port}/`)
388
+ console.log('\n 改代码会自动重建并刷新页面;Ctrl+C 退出。\n')
389
+ }
390
+
391
+ /* ----------------------------------------------------------------- check */
392
+
393
+ interface CheckResult {
394
+ errors: string[]
395
+ warnings: string[]
396
+ passed: string[]
397
+ }
398
+
399
+ /** 读取并解析 package.json。 */
400
+ function readManifest(dir: string): Record<string, unknown> | undefined {
401
+ try {
402
+ return JSON.parse(readFileSync(resolve(dir, 'package.json'), 'utf8')) as Record<string, unknown>
403
+ } catch {
404
+ return undefined
405
+ }
406
+ }
407
+
408
+ /**
409
+ * 装前一致性校验:把「装进 DSH 之后才发现坏」的问题提前到本地。
410
+ *
411
+ * 检查项全部来自 DSH 的客户端插件契约与 pnpm 的 Git 子目录安装约束。
412
+ */
413
+ export function collectIssues(dir: string): CheckResult {
414
+ const result: CheckResult = { errors: [], warnings: [], passed: [] }
415
+ const manifest = readManifest(dir)
416
+
417
+ if (manifest === undefined) {
418
+ result.errors.push('读不到 package.json(JSON 语法错误或文件缺失)')
419
+ return result
420
+ }
421
+
422
+ const name = typeof manifest.name === 'string' ? manifest.name : ''
423
+ if (name === '') result.errors.push('package.json 缺少 name')
424
+ else result.passed.push(`包名 ${name}`)
425
+
426
+ const dsh = (manifest.dsh ?? {}) as Record<string, unknown>
427
+ const bundle = (dsh.bundle ?? {}) as Record<string, unknown>
428
+ const client = (dsh.client ?? {}) as Record<string, unknown>
429
+
430
+ if (typeof bundle.patch !== 'string') {
431
+ result.errors.push('缺少 dsh.bundle.patch —— DSH 不会把这个包当作组合包(bundle)')
432
+ } else {
433
+ const patchPath = resolve(dir, bundle.patch.replace(/^\.\//u, ''))
434
+ if (!existsSync(patchPath)) {
435
+ result.errors.push(`dsh.bundle.patch 指向的 ${bundle.patch} 不存在`)
436
+ } else {
437
+ const text = readFileSync(patchPath, 'utf8')
438
+ if (!text.includes('insert:')) result.errors.push('cordis.patch.yml 里没有 insert: 段落,插件行不会进组合树')
439
+ else if (name !== '' && !text.includes(name)) {
440
+ result.warnings.push(`cordis.patch.yml 里没有出现包名 ${name},确认 insert 的 name 写对了`)
441
+ } else result.passed.push('cordis.patch.yml 会插入本包的行')
442
+ }
443
+ }
444
+
445
+ if (client.platform !== 'web') {
446
+ result.errors.push("dsh.client.platform 必须是 'web' —— 否则 DSH 不会给页面提供客户端 bundle")
447
+ } else result.passed.push('dsh.client.platform = web')
448
+
449
+ const exportsField = (manifest.exports ?? {}) as Record<string, string>
450
+ const hostEntry = exportsField['.']
451
+ const clientEntry = exportsField['./client']
452
+ if (typeof hostEntry !== 'string') result.errors.push('exports["."] 未声明 —— DSH 加载不到 Host 侧插件')
453
+ else if (!existsSync(resolve(dir, hostEntry.replace(/^\.\//u, '')))) {
454
+ result.errors.push(`exports["."] 指向的 ${hostEntry} 不存在,先构建`)
455
+ } else result.passed.push('Host 入口存在')
456
+
457
+ if (typeof clientEntry !== 'string') result.errors.push('exports["./client"] 未声明 —— 页面拿不到客户端 bundle')
458
+
459
+ const scripts = (manifest.scripts ?? {}) as Record<string, string>
460
+ if (scripts.prepare !== undefined || scripts.postinstall !== undefined) {
461
+ result.errors.push('声明了 prepare / postinstall —— DSH 安装时不构建,产物必须预先提交')
462
+ } else result.passed.push('没有安装期生命周期钩子')
463
+
464
+ const dependencyText = JSON.stringify({
465
+ dependencies: manifest.dependencies,
466
+ peerDependencies: manifest.peerDependencies,
467
+ devDependencies: manifest.devDependencies
468
+ })
469
+ if (dependencyText.includes('workspace:')) {
470
+ result.errors.push('依赖里出现了 workspace: 协议 —— GitHub 子目录直装时解析不了')
471
+ } else result.passed.push('没有 workspace: 协议依赖')
472
+
473
+ if (typeof clientEntry === 'string') {
474
+ const clientPath = resolve(dir, clientEntry.replace(/^\.\//u, ''))
475
+ if (!existsSync(clientPath)) {
476
+ result.errors.push(`客户端产物 ${clientEntry} 不存在,先运行构建`)
477
+ } else {
478
+ const code = readFileSync(clientPath, 'utf8')
479
+ const loads = (code.match(/__ModuleLoader__\.load\(/gu) ?? []).length
480
+ if (loads !== 1) result.errors.push(`客户端产物里 __ModuleLoader__.load 出现 ${loads} 次,应为 1 次(先构建)`)
481
+ else result.passed.push('产物形态是 DSH 客户端模块协议')
482
+
483
+ if (code.includes('import(')) result.errors.push('客户端产物里有动态 import(),DSH 只支持自包含 chunk')
484
+
485
+ const requires = [...code.matchAll(/require\((['"])([^'"]+)\1\)/gu)].map(match => match[2] ?? '')
486
+ const foreign = requires.filter(id => !PLATFORM_MODULES.includes(id) && !id.startsWith('@deepseek-ai/'))
487
+ if (foreign.length > 0) {
488
+ result.errors.push(`产物 require 了非平台模块:${[...new Set(foreign)].join(', ')} —— 依赖没有被打进产物`)
489
+ } else if (requires.length > 0) {
490
+ result.passed.push(`产物只 require 平台模块(${[...new Set(requires)].join(', ')})`)
491
+ }
492
+
493
+ if (name !== '' && !code.includes(`"${name}"`) && !code.includes(`'${name}'`)) {
494
+ result.warnings.push(`产物里的模块 id 与包名 ${name} 不一致,检查 dshBundle({ id })`)
495
+ }
496
+ }
497
+ }
498
+
499
+ return result
500
+ }
501
+
502
+ function dshCheckCommand(): void {
503
+ const dir = process.cwd()
504
+ const result = collectIssues(dir)
505
+
506
+ logger.step('DSH 插件装前校验')
507
+ for (const item of result.passed) console.log(` ✔ ${item}`)
508
+ for (const item of result.warnings) console.log(` ⚠ ${item}`)
509
+ for (const item of result.errors) console.log(` ✖ ${item}`)
510
+
511
+ if (result.errors.length > 0) {
512
+ logger.error(`${result.errors.length} 项不通过,装进 DSH 后不会正常工作。`)
513
+ process.exitCode = 1
514
+ return
515
+ }
516
+ logger.success(`全部通过(${result.passed.length} 项${result.warnings.length > 0 ? `,${result.warnings.length} 条警告` : ''})`)
517
+ }
518
+
519
+ /* --------------------------------------------------------------- install */
520
+
521
+ /** 读取 pnpm 版本;拿不到就返回 undefined。 */
522
+ function readPnpmVersion(): string | undefined {
523
+ try {
524
+ return execSync('pnpm --version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
525
+ } catch {
526
+ return undefined
527
+ }
528
+ }
529
+
530
+ const majorOf = (version: string | undefined): number => {
531
+ const major = Number.parseInt((version ?? '').split('.')[0] ?? '', 10)
532
+ return Number.isFinite(major) ? major : 0
533
+ }
534
+
535
+ /** 组装安装 spec。 */
536
+ export function buildInstallSpec(options: DshOptions): { spec?: string; error?: string } {
537
+ if (options.spec !== undefined) return { spec: options.spec }
538
+
539
+ if (options.from !== undefined) {
540
+ const target = resolve(options.from)
541
+ if (!existsSync(target)) return { error: `--from 指向的路径不存在:${target}` }
542
+ return { spec: target }
543
+ }
544
+
545
+ if (options.repo !== undefined) {
546
+ const ref = options.tag !== undefined && options.tag !== '' ? `#${options.tag}` : ''
547
+ const sub = options.subpath !== undefined && options.subpath !== '' ? `${ref === '' ? '#' : '&'}path:${options.subpath}` : ''
548
+ return { spec: `github:${options.repo}${ref}${sub}` }
549
+ }
550
+
551
+ return { error: '需要 --spec、--from 或 --repo 之一来指定安装来源。' }
552
+ }
553
+
554
+ /**
555
+ * 在 Windows 上调用 dsh 的引号策略。
556
+ *
557
+ * `.cmd` 无法被 CreateProcess 直接执行(EINVAL),必须走 shell;而 shell 会把
558
+ * 裸传参数原样拼接,`github:owner/repo#tag&path:/sub` 里的 `&` 就变成命令分隔符 ——
559
+ * 实测会**静默地把 spec 截断成 `#tag`**,然后装上 monorepo 根而不是目标子包。
560
+ * 所以含特殊字符的参数必须自己加引号。
561
+ */
562
+ const CMD_NEEDS_QUOTE = /[\s&|<>^()"']/u
563
+
564
+ function quoteForCmd(part: string): string {
565
+ return CMD_NEEDS_QUOTE.test(part) ? `"${part.replace(/"/gu, '""')}"` : part
566
+ }
567
+
568
+ /** 组装一条给 shell 执行的命令行(仅供 Windows 使用)。 */
569
+ export function buildShellCommand(exe: string, args: readonly string[]): string {
570
+ return [exe, ...args].map(quoteForCmd).join(' ')
571
+ }
572
+
573
+ function spawnDsh(args: string[], dry: boolean): number {
574
+ if (dry) {
575
+ console.log(`\n ${buildShellCommand('dsh', args)}\n`)
576
+ return 0
577
+ }
578
+
579
+ if (process.platform === 'win32') {
580
+ const result = spawnSync(buildShellCommand('dsh', args), { stdio: 'inherit', shell: true })
581
+ return result.status ?? 1
582
+ }
583
+
584
+ const result = spawnSync('dsh', args, { stdio: 'inherit' })
585
+ return result.status ?? 1
586
+ }
587
+
588
+ function dshInstallCommand(options: DshOptions): void {
589
+ const profile = options.profile ?? 'desktop'
590
+ const { spec, error } = buildInstallSpec(options)
591
+
592
+ if (spec === undefined) {
593
+ logger.error(error ?? '未知错误')
594
+ process.exitCode = 1
595
+ return
596
+ }
597
+
598
+ const usesSubpath = spec.includes('path:')
599
+ const pnpmVersion = readPnpmVersion()
600
+
601
+ if (usesSubpath && pnpmVersion !== undefined && majorOf(pnpmVersion) < 11) {
602
+ logger.warn(
603
+ `检测到 pnpm ${pnpmVersion}:11 以下不支持 git 子目录语法(#…&path:),` +
604
+ '会静默装成仓库根而不是你想要的子包。DSH 内置的 pnpm 11 不受影响 —— 建议改用 DSH 插件页安装。'
605
+ )
606
+ }
607
+
608
+ if (usesSubpath && process.platform === 'win32') {
609
+ logger.info('Windows:会通过 cmd /d /s /c 执行,避免 & 被当成命令分隔符。')
610
+ }
611
+
612
+ logger.step(`安装到 profile「${profile}」`)
613
+ console.log(` spec: ${spec}`)
614
+
615
+ if (!isAbsolute(spec) && !spec.startsWith('github:') && !spec.startsWith('git+') && !spec.startsWith('http')) {
616
+ logger.warn('这个 spec 既不是绝对路径也不是 git/URL,确认没有写错。')
617
+ }
618
+
619
+ const status = spawnDsh(['plugin', '--profile', profile, 'add', spec], options.dry === true)
620
+ if (options.dry === true) {
621
+ logger.info('dry run:以上命令没有执行。')
622
+ return
623
+ }
624
+ if (status !== 0) {
625
+ logger.error(`安装失败(退出码 ${status})。诊断日志在 ~/.dsh/profiles/${profile}/.plugin-manager/logs/`)
626
+ process.exitCode = status
627
+ return
628
+ }
629
+ logger.success('安装完成 —— 完全退出 DSH 再打开即可生效')
630
+ }
631
+
632
+ /* ------------------------------------------------------------- dispatcher */
633
+
634
+ const ACTIONS: Record<string, string> = {
635
+ init: '创建一个 DSH 插件项目',
636
+ dev: '本地预览(浏览器里看面板,不用装进 DSH)',
637
+ build: '构建 lib/{index,client}.js',
638
+ check: '装前一致性校验',
639
+ install: '把插件装进 DSH profile'
640
+ }
641
+
642
+ /** `vobs dsh <action> [target]` 的分发入口。 */
643
+ export async function dshCommand(
644
+ action: string | undefined,
645
+ target: string | undefined,
646
+ options: DshOptions
647
+ ): Promise<void> {
648
+ switch (action) {
649
+ case 'init':
650
+ case 'create':
651
+ await dshInitCommand(target, options)
652
+ return
653
+ case 'dev':
654
+ await dshDevCommand(options)
655
+ return
656
+ case 'build':
657
+ await dshBuildCommand()
658
+ return
659
+ case 'check':
660
+ dshCheckCommand()
661
+ return
662
+ case 'install':
663
+ dshInstallCommand(options)
664
+ return
665
+ default:
666
+ // 不带子命令时只列用法(这是最自然的「我该用什么」入口),
667
+ // 真正写错子命令时才报错。
668
+ if (action !== undefined && action !== '') logger.error(`未知子命令: ${action}`)
669
+ console.log('\n 可用子命令:')
670
+ for (const [name, description] of Object.entries(ACTIONS)) {
671
+ console.log(` vobs dsh ${name.padEnd(8)} ${description}`)
672
+ }
673
+ if (action !== undefined && action !== '') process.exitCode = 1
674
+ }
675
+ }
@@ -2,4 +2,5 @@ export { initCommand } from './init.js'
2
2
  export { devCommand } from './dev.js'
3
3
  export { buildCommand } from './build.js'
4
4
  export { generateCommand } from './generate.js'
5
- export { addCommand } from './add.js'
5
+ export { addCommand } from './add.js'
6
+ export { dshCommand, collectIssues, buildInstallSpec, type DshOptions } from './dsh.js'