dsh-plugin 1.6.6 → 1.6.7

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.
@@ -3,10 +3,10 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否
7
- * 真的包含 package.json 声明的入口文件(main / exports["."].default)。
8
- * git 分发常不提交构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时
9
- * ERR_MODULE_NOT_FOUND 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
6
+ * 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否真的包含
7
+ * package.json 声明的文件(main / exports["."] / dsh.bundle.patch)。git 分发常不提交
8
+ * 构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 ERR_MODULE_NOT_FOUND
9
+ * 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
10
10
  * npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
11
11
  * 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
12
12
  */
@@ -19,7 +19,7 @@ import { pipeline } from 'node:stream/promises'
19
19
  import { promisify } from 'node:util'
20
20
  import { githubRefOf, githubRepoOf } from '../profile/profile.ts'
21
21
  import { githubReleaseTarget } from './release-target.ts'
22
- import { resolvePackageEntry } from './package-entry.ts'
22
+ import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.ts'
23
23
 
24
24
  const execFileAsync = promisify(execFile)
25
25
 
@@ -27,7 +27,8 @@ const execFileAsync = promisify(execFile)
27
27
  * 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
28
28
  const PREFLIGHT_TIMEOUT_MS = 15_000
29
29
 
30
- /** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径;
30
+ /** 预检结果:ok=false 表示分发改 package.json 声明的文件(入口或 bundle 补丁),
31
+ * missing 为该文件在包内的相对路径;
31
32
  * name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
32
33
  * 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
33
34
  * 需要把晦涩的 CLI 报错转成明确的拦截。 */
@@ -57,9 +58,17 @@ export async function preflightTarget(target: string): Promise<PreflightResult>
57
58
  const rootPrefix = rootPackage.slice(0, -'package.json'.length)
58
59
  const meta = await readTarJson(tar, rootPackage)
59
60
  if (meta === null) return { ok: true, missing: null, name: null }
60
- const entry = resolvePackageEntry(meta)
61
- const inTar = await hasTarEntry(tar, `${rootPrefix}${entry}`)
62
- return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null }
61
+ // 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口,
62
+ // 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己,
63
+ // 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。
64
+ const patchTexts: string[] = []
65
+ for (const file of resolveBundlePatchFiles(meta)) {
66
+ const text = await readTarText(tar, `${rootPrefix}${file}`)
67
+ if (text !== null) patchTexts.push(text)
68
+ }
69
+ const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`)
70
+ const missing = missingDistributionFileOfTarList(await tarListing(tar), required)
71
+ return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null }
63
72
  } catch {
64
73
  return { ok: true, missing: null, name: null }
65
74
  } finally {
@@ -125,31 +134,51 @@ export function rootPackageEntryOfTarList(listing: string): string | null {
125
134
  return candidates.length === 1 ? candidates[0] : null
126
135
  }
127
136
 
137
+ /**
138
+ * 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
139
+ * 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
140
+ * 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
141
+ * Exported for a small parser regression test.
142
+ */
143
+ export function missingDistributionFileOfTarList(listing: string | null, required: string[]): string | null {
144
+ if (required.length === 0) return null
145
+ if (listing === null) return required[0]
146
+ const present = new Set(listing.split(/\r?\n/))
147
+ return required.find((file) => !present.has(file)) ?? null
148
+ }
149
+
128
150
  async function rootPackageEntry(tar: string): Promise<string | null> {
151
+ const listing = await tarListing(tar)
152
+ return listing === null ? null : rootPackageEntryOfTarList(listing)
153
+ }
154
+
155
+ /** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */
156
+ async function tarListing(tar: string): Promise<string | null> {
129
157
  try {
130
158
  const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 })
131
- return rootPackageEntryOfTarList(stdout)
159
+ return stdout
132
160
  } catch {
133
161
  return null
134
162
  }
135
163
  }
136
164
 
137
- /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
138
- async function readTarJson(tar: string, entry: string): Promise<Record<string, unknown> | null> {
165
+ /** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */
166
+ async function readTarText(tar: string, entry: string): Promise<string | null> {
139
167
  try {
140
168
  const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 })
141
- return JSON.parse(stdout) as Record<string, unknown>
169
+ return stdout
142
170
  } catch {
143
171
  return null
144
172
  }
145
173
  }
146
174
 
147
- /** 检查 tarball 内是否存在指定 entry。 */
148
- async function hasTarEntry(tar: string, entry: string): Promise<boolean> {
175
+ /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
176
+ async function readTarJson(tar: string, entry: string): Promise<Record<string, unknown> | null> {
177
+ const text = await readTarText(tar, entry)
178
+ if (text === null) return null
149
179
  try {
150
- const { stdout } = await execFileAsync('tar', ['-tzf', tar, entry], { maxBuffer: 1024 * 1024 })
151
- return stdout.split(/\r?\n/).some((line) => line === entry)
180
+ return JSON.parse(text) as Record<string, unknown>
152
181
  } catch {
153
- return false
182
+ return null
154
183
  }
155
184
  }
@@ -8,7 +8,7 @@
8
8
  * 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
9
9
  */
10
10
  import { spawn, spawnSync } from 'node:child_process'
11
- import { resolvePackageEntry } from './package-entry.ts'
11
+ import { requiredDistributionFiles, resolveBundlePatchFiles, type PackageManifest } from './package-entry.ts'
12
12
  import { existsSync, readFileSync, statSync } from 'node:fs'
13
13
  import { dirname, join, resolve } from 'node:path'
14
14
  import type { InstallResult, InstallTask, Invocation, QueueItem } from './install-types.ts'
@@ -625,10 +625,12 @@ function spawnMutation(options: {
625
625
  }
626
626
 
627
627
  /**
628
- * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
629
- * git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会
630
- * ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。
631
- * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。
628
+ * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
629
+ * dsh.bundle.patch)是否真实存在。git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有
630
+ * 报错,但宿主重启加载插件树时会 ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在
631
+ * 登记「待重启」前就把这类残缺包拦下。没有声明过的文件不在这里追究:没声明入口的
632
+ * bundle 包(只挂技能)本来就不需要入口,宿主读的是它的 package.json 与声明的补丁。
633
+ * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失文件的路径。
632
634
  */
633
635
  export function verifyInstalledEntry(profile: string, target: string): { name: string | null; missing: string | null } {
634
636
  let profilePkg: Record<string, unknown> | null = null
@@ -645,21 +647,29 @@ export function verifyInstalledEntry(profile: string, target: string): { name: s
645
647
  : deps[target] !== undefined ? target : Object.keys(deps).find((k) => deps[k] === target)
646
648
  if (!name) return { name: null, missing: null }
647
649
  const pkgDir = join(profileDirectory(profile), 'node_modules', name)
648
- let entry: string | null = null
650
+ let required: string[] = []
649
651
  try {
650
- const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as {
651
- main?: unknown
652
- exports?: unknown
653
- }
654
- entry = resolvePackageEntry(meta)
652
+ const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as PackageManifest
653
+ // 补丁按本包名 mount 自己时入口也是必须的,所以先读已装包里的补丁(读不到就当没有)。
654
+ const patchTexts = resolveBundlePatchFiles(meta).flatMap((file) => {
655
+ try {
656
+ return [readFileSync(join(pkgDir, file), 'utf8')]
657
+ } catch {
658
+ return []
659
+ }
660
+ })
661
+ required = requiredDistributionFiles(meta, patchTexts)
655
662
  } catch {
656
663
  return { name, missing: null }
657
664
  }
658
- try {
659
- return { name, missing: statSync(join(pkgDir, entry)).isFile() ? null : entry }
660
- } catch {
661
- return { name, missing: entry }
665
+ for (const file of required) {
666
+ try {
667
+ if (!statSync(join(pkgDir, file)).isFile()) return { name, missing: file }
668
+ } catch {
669
+ return { name, missing: file }
670
+ }
662
671
  }
672
+ return { name, missing: null }
663
673
  }
664
674
 
665
675
  /** 从 pnpm `Ignored build scripts:` 提示行解析被拦截的构建脚本 key(name@version,逗号分隔)。 */
@@ -749,21 +759,21 @@ export async function runPluginMutation(options: {
749
759
  }
750
760
  }
751
761
  }
752
- // pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
762
+ // pnpm 安装本身成功,但目标包声明分发的文件缺失(git 分发缺构建产物 / 缺声明的补丁)→ 判为失败:
753
763
  // 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。
754
764
  // 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验
755
765
  if (options.action !== 'remove' && result.exitCode === 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
756
766
  const verified = verifyInstalledEntry(options.profile, options.target)
757
767
  if (verified.name !== null && verified.missing !== null) {
758
768
  clearPendingRestart(options.displayTarget ?? options.target)
759
- const detail = `[packaging] ${verified.name}: entry file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`
769
+ const detail = `[packaging] ${verified.name}: declared file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`
760
770
  if (options.task && options.task.status !== 'cancelled') {
761
771
  options.task.status = 'failed'
762
772
  options.task.exitCode = null
763
773
  options.task.progress = 0
764
774
  pushLine(options.task, detail)
765
775
  }
766
- return { exitCode: 1, timedOut: false, error: `entry file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` }
776
+ return { exitCode: 1, timedOut: false, error: `declared file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` }
767
777
  }
768
778
  }
769
779
  // npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因