@aalis/plugin-package-manager 0.10.1 → 0.11.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/README.md +4 -2
- package/dist/index.d.ts +39 -60
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +251 -158
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +11 -4
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/en.js +11 -4
- package/dist/locales/en.js.map +1 -1
- package/dist/texts.d.ts +26 -12
- package/dist/texts.d.ts.map +1 -1
- package/dist/texts.js +13 -6
- package/dist/texts.js.map +1 -1
- package/package.json +9 -6
package/dist/index.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { createRequire } from 'node:module';
|
|
2
1
|
import { failureText, unmetDependencies } from '@aalis/api-doctor';
|
|
3
2
|
import { hostConfig } from '@aalis/api-host-config';
|
|
4
3
|
import { hostPaths } from '@aalis/api-host-paths';
|
|
5
4
|
import { packageManager, } from '@aalis/api-package-manager';
|
|
6
|
-
import {
|
|
5
|
+
import { declaresPlugin, pluginSource } from '@aalis/api-plugin-source';
|
|
7
6
|
import { processService } from '@aalis/api-process';
|
|
8
|
-
import { appService, definePlugin, logger, optional, pluginsService, provide, services, } from '@aalis/core';
|
|
7
|
+
import { appService, definePlugin, lifecycle, logger, optional, pluginsService, provide, services, } from '@aalis/core';
|
|
9
8
|
import { TextError, textClauses, textOf, toSource } from '@aalis/schema-i18n';
|
|
10
9
|
import { classifyDepSpec, isRegistryDep, isUpgrade } from '@aalis/util-dep-spec';
|
|
11
10
|
import { t } from './texts.js';
|
|
@@ -24,15 +23,16 @@ const INSTALL_TIMEOUT_MS = 600_000;
|
|
|
24
23
|
* 静默失效。环境变量优先于项目根的 `.npmrc`(install-chain 里有真 npm 用例)。
|
|
25
24
|
*/
|
|
26
25
|
const STRICT_PEERS_ENV = { npm_config_legacy_peer_deps: 'false' };
|
|
27
|
-
async function execProc(proc, cmd, args, cwd,
|
|
28
|
-
return (await execProcBoth(proc, cmd, args, cwd,
|
|
26
|
+
async function execProc(proc, cmd, args, cwd, signal) {
|
|
27
|
+
return (await execProcBoth(proc, cmd, args, cwd, QUICK_TIMEOUT_MS, undefined, signal)).stdout;
|
|
29
28
|
}
|
|
30
29
|
/** 同 {@link execProc},但同时给出 stderr——npm 的诊断(含 peer 告警)都写在 stderr。 */
|
|
31
|
-
async function execProcBoth(proc, cmd, args, cwd, timeout = QUICK_TIMEOUT_MS, extraEnv) {
|
|
30
|
+
async function execProcBoth(proc, cmd, args, cwd, timeout = QUICK_TIMEOUT_MS, extraEnv, signal) {
|
|
32
31
|
try {
|
|
33
32
|
const result = await proc.execFile(cmd, args, {
|
|
34
33
|
cwd,
|
|
35
34
|
timeout,
|
|
35
|
+
signal,
|
|
36
36
|
...(extraEnv ? { env: { ...process.env, ...extraEnv } } : {}),
|
|
37
37
|
});
|
|
38
38
|
return { stdout: result.stdout, stderr: result.stderr ?? '' };
|
|
@@ -65,17 +65,6 @@ export function hasWorkspaceProtocol(pkgJson) {
|
|
|
65
65
|
}
|
|
66
66
|
return false;
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
69
|
-
* 包是否声明自己是可加载插件(与两个加载器同一判据:纯 `aalis-plugin` 关键词正向门)。
|
|
70
|
-
*
|
|
71
|
-
* 用途是把「装完没发现新插件」分成两种情形:目标本就不是插件(如 `aalis-interface`
|
|
72
|
-
* 前端包)→ 正常;目标声明了自己是插件却没被发现 → **静默假成功**,必须报失败。
|
|
73
|
-
* 纯函数,便于单测。
|
|
74
|
-
*/
|
|
75
|
-
export function declaresPlugin(pkgJson) {
|
|
76
|
-
const kw = pkgJson?.keywords;
|
|
77
|
-
return Array.isArray(kw) && kw.includes('aalis-plugin');
|
|
78
|
-
}
|
|
79
68
|
/**
|
|
80
69
|
* 包类型是否归市场装卸:插件(判据同 {@link declaresPlugin})与前端界面(`aalis-interface`)
|
|
81
70
|
* 归市场管,返回 `undefined`;其余按类型关键词返回种类码(没有类型关键词的是 other),拒绝时显示 KIND_NAMES 里的名字。
|
|
@@ -110,7 +99,7 @@ const KIND_NAMES = {
|
|
|
110
99
|
/**
|
|
111
100
|
* 找出「卸载 target 会断其服务依赖」的插件:target 的某个实例正在提供或马上就提供的某服务 S 没有别的插件正在提供,且有别的
|
|
112
101
|
* 插件 required 依赖 S(uses 里未包 optional)→ 这些插件会被打断。卸载连同 target 的全部实例一起卸下,所以按同名的每个实例判:算在提供的是
|
|
113
|
-
* active、activating,以及 pending 而只是在等的(按 api-doctor
|
|
102
|
+
* active、activating,以及 pending 而只是在等的(按 api-doctor 的共享判据不缺什么,它要的在启动,启动后就提供);disabled、
|
|
114
103
|
* error 与 pending 而缺服务的不在提供。别的提供者只算 active 与 activating(替代得是确定的)。已禁用(disabled / disposed)
|
|
115
104
|
* 的依赖者不算。available 是当前有提供者的服务名(services.names())。纯函数,便于单测。
|
|
116
105
|
*/
|
|
@@ -135,34 +124,24 @@ export function findServiceDependents(targetName, status, available) {
|
|
|
135
124
|
}
|
|
136
125
|
return [...dependents];
|
|
137
126
|
}
|
|
138
|
-
/** 从项目 node_modules 解析已装包入口并取出定义 name;解析失败返回 undefined。 */
|
|
139
|
-
async function resolveInstalledDefinitionName(root, pkgName) {
|
|
140
|
-
try {
|
|
141
|
-
const req = createRequire(`${root}/package.json`);
|
|
142
|
-
const entry = req.resolve(pkgName);
|
|
143
|
-
const ns = await import(new URL(`file://${entry}`).href);
|
|
144
|
-
return pluginDefinitionOf(ns)?.name;
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
return undefined;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
127
|
/**
|
|
151
|
-
*
|
|
128
|
+
* 交给 npm 的 argv 里的包名(可选 scope,不含版本)与版本,spec = 包名(@版本)?。
|
|
152
129
|
*
|
|
153
|
-
*
|
|
130
|
+
* 两段的首字符**都**必须是字母数字。spec 最终作为一个 argv 传给 npm,而 npm 会:
|
|
154
131
|
* - 把以 `-` 开头的 token 当命令行标志(`--force`、`--ignore-scripts` 都是 npm 上真实存在的可发布包名);
|
|
155
132
|
* - 把 `.` / `..` / `./x` 当本地目录 spec —— **`foo@.` 与 `@a/b@..` 同样是目录 spec**,
|
|
156
133
|
* 只锚名段挡不住;实测这条能让 npm 转去打包宿主工作目录并执行其 prepack/prepare 生命周期脚本。
|
|
157
|
-
* 两段各自锚住首字符,即封死目录 spec
|
|
134
|
+
* 两段各自锚住首字符,即封死目录 spec 与标志注入两条面。版本只含字母数字与 `.` `-` `+`:版本号(可带预发布与
|
|
135
|
+
* 构建元数据)与 dist-tag,`^`、`~`、`>=` 这类带运算符的范围与路径都不收。
|
|
158
136
|
*/
|
|
159
|
-
const
|
|
137
|
+
const PKG_NAME_RE = /^(@[a-z0-9][a-z0-9\-_.]*\/)?[a-z0-9][a-z0-9\-_.]*$/i;
|
|
138
|
+
const VERSION_RE = /^[a-z0-9][a-z0-9.\-+]*$/i;
|
|
160
139
|
/**
|
|
161
|
-
* 校验单个包 spec
|
|
140
|
+
* 校验单个包 spec(包名,可带 `@版本`),合法返回 `undefined`、非法返回可读理由。
|
|
162
141
|
*
|
|
163
142
|
* 放在**服务层**:本服务对外公开,任何插件都能绕过 HTTP 路由直接调用。
|
|
164
143
|
* 这与 `buildUpdateSpecs` 是同一条纪律——路由只做「是不是字符串」的形状检查,安全校验
|
|
165
|
-
*
|
|
144
|
+
* 收在所有调用方的必经之路上。argv 面的包名与版本各只有一个正则,装、卸(本函数)与更新(buildUpdateSpecs)共用;
|
|
166
145
|
* URL 路径段另有一份不含版本段的(webui-server 的 depgraph 端点,见那里的注释)——
|
|
167
146
|
* 两者挡的东西不同,不是同一份的拷贝,别再把它们合并成一条。
|
|
168
147
|
* 纯函数,便于单测。
|
|
@@ -170,33 +149,31 @@ const PKG_SPEC_RE = /^(@[a-z0-9][a-z0-9\-_.]*\/)?[a-z0-9][a-z0-9\-_.]*(@[a-z0-9]
|
|
|
170
149
|
export function validatePackageSpec(spec) {
|
|
171
150
|
if (typeof spec !== 'string' || spec.length === 0)
|
|
172
151
|
return t('emptyName');
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
152
|
+
// 包名里的 @ 只能是开头的 scope:开头之后还有 @,最后一个就是版本的分隔
|
|
153
|
+
const at = spec.lastIndexOf('@');
|
|
154
|
+
const valid = at > 0 ? PKG_NAME_RE.test(spec.slice(0, at)) && VERSION_RE.test(spec.slice(at + 1)) : PKG_NAME_RE.test(spec);
|
|
155
|
+
return valid ? undefined : t('badName', { name: JSON.stringify(spec) });
|
|
176
156
|
}
|
|
177
157
|
// ===== 插件入口 =====
|
|
178
158
|
const uses = {
|
|
179
159
|
proc: processService,
|
|
180
160
|
paths: hostPaths,
|
|
161
|
+
lifecycle,
|
|
181
162
|
logger,
|
|
182
163
|
provide,
|
|
183
164
|
services,
|
|
184
|
-
app:
|
|
185
|
-
plugins:
|
|
165
|
+
app: appService,
|
|
166
|
+
plugins: pluginsService,
|
|
186
167
|
hostConfig: optional(hostConfig),
|
|
187
168
|
source: optional(pluginSource),
|
|
188
169
|
};
|
|
189
170
|
function createService(caps) {
|
|
190
171
|
const log = caps.logger.use;
|
|
191
172
|
const proc = caps.proc.use;
|
|
192
|
-
|
|
193
|
-
if (!caps.app.present)
|
|
194
|
-
throw new TextError(t('noApp'));
|
|
195
|
-
return caps.app.use;
|
|
196
|
-
}
|
|
197
|
-
return createPackageManager({
|
|
173
|
+
const { settled, ...service } = createPackageManager({
|
|
198
174
|
proc,
|
|
199
175
|
log: log,
|
|
176
|
+
signal: caps.lifecycle.use.signal,
|
|
200
177
|
// 装到宿主的安装集:与插件加载器读的是同一份 package.json,否则装了永远不加载
|
|
201
178
|
projectRoot: () => caps.paths.use.app,
|
|
202
179
|
// 经 process 服务的 readExternalFile 读,而非 node:fs:目标(项目根 package.json、
|
|
@@ -210,30 +187,19 @@ function createService(caps) {
|
|
|
210
187
|
return undefined;
|
|
211
188
|
}
|
|
212
189
|
},
|
|
213
|
-
//
|
|
190
|
+
// 宿主不提供插件来源(打包宿主)时视同本次没有新插件
|
|
214
191
|
rescanPlugins: async () => (await (caps.source.present ? caps.source.use.rescan() : undefined)) ?? [],
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
isPluginRegistered: name => caps.plugins.
|
|
219
|
-
pluginStatus: () =>
|
|
192
|
+
// 宿主不提供插件来源时没有经它登记的插件:已登记的同名插件一律不是这次装的包
|
|
193
|
+
loaded: name => caps.source.present && caps.source.use.loaded(name),
|
|
194
|
+
// 与发现驱动跳过声明了已登记插件名的包同一判据:看主实例
|
|
195
|
+
isPluginRegistered: name => caps.plugins.use.getPlugin(name) !== undefined,
|
|
196
|
+
pluginStatus: () => caps.plugins.use.getStatus(),
|
|
220
197
|
availableServices: () => caps.services.use.names(),
|
|
221
|
-
idle:
|
|
222
|
-
if (caps.plugins.present)
|
|
223
|
-
await caps.plugins.use.idle();
|
|
224
|
-
},
|
|
225
|
-
resolveDefinitionName: pkgName => resolveInstalledDefinitionName(caps.paths.use.app, pkgName),
|
|
226
|
-
listPluginInstanceIds: definitionName => (caps.plugins.present ? caps.plugins.use.getStatus() : [])
|
|
227
|
-
.filter(p => p.name === definitionName)
|
|
228
|
-
.map(p => p.instanceId),
|
|
198
|
+
idle: () => caps.plugins.use.idle(),
|
|
229
199
|
// 重启并交付回滚凭据。core 只透传 rollback(不解释形状),由 runtime 的重启策略
|
|
230
200
|
// 在「新实例 ready 前夭折」时消费——触发者与执行者同为父进程,全程内存不落盘。
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
// 多传的对象被静默丢弃 —— 不报错、不告警,只是更新失败后重启起来的是坏版本,
|
|
234
|
-
// 回滚安全网整个不存在。故 peerDependencies 下界必须是 `>=0.10.0`,不能沿用 0.2.0。
|
|
235
|
-
restartApp: rollback => getApp().restart({ rollback }),
|
|
236
|
-
// 彻底卸载:dispose 上下文并从注册表移除(plugins 服务缺席则 no-op)。
|
|
201
|
+
restartApp: rollback => caps.app.use.restart({ rollback }),
|
|
202
|
+
// 彻底卸载:dispose 上下文并从注册表移除。
|
|
237
203
|
// 区别于 disable(仅置禁用态,仍滞留在插件列表里)。
|
|
238
204
|
// 撤销通道 = 市场页能用所依赖的三样:页面本身 / 托管它的服务端 / 装卸能力本身。
|
|
239
205
|
// 取每个服务当前生效的 provider(`services.all` 的首个即是),contextId 就是包名
|
|
@@ -251,8 +217,7 @@ function createService(caps) {
|
|
|
251
217
|
})),
|
|
252
218
|
],
|
|
253
219
|
unloadPlugin: async (name) => {
|
|
254
|
-
|
|
255
|
-
await caps.plugins.use.unload(name);
|
|
220
|
+
await caps.plugins.use.unload(name);
|
|
256
221
|
},
|
|
257
222
|
// 文档没有改动时不保存:没东西要写,也就不该因配置文件的状况报失败
|
|
258
223
|
cleanupConfig: async (instanceIds, removeConfig) => {
|
|
@@ -271,22 +236,16 @@ function createService(caps) {
|
|
|
271
236
|
await doc.save();
|
|
272
237
|
},
|
|
273
238
|
});
|
|
239
|
+
// 停用、重启与停机时中止信号已打断在途的 npm:等它退出、被打断的把 package.json 与锁文件还原之后才算收尾完。超过清理宽限
|
|
240
|
+
// 还没落定时 core 把本实例记为未停下,落定之前不启动新实例,新实例的 npm 不会与旧实例的 npm 或还原同时改这两份文件
|
|
241
|
+
caps.lifecycle.use.onDrain(settled, '等在途的 npm 退出,被打断的还原 package.json 与锁文件');
|
|
242
|
+
return service;
|
|
274
243
|
}
|
|
275
244
|
/** `@scope/foo@1.2.3` → `@scope/foo`(剥掉版本后缀,保留 scope)。 */
|
|
276
245
|
export function stripVersion(spec) {
|
|
277
246
|
const at = spec.lastIndexOf('@');
|
|
278
247
|
return at > 0 ? spec.slice(0, at) : spec;
|
|
279
248
|
}
|
|
280
|
-
/**
|
|
281
|
-
* 合法 npm 包名(可选 scope),**不含**版本后缀。
|
|
282
|
-
*
|
|
283
|
-
* 首字符锚死字母数字:npm 会把 `-` 开头的 token 当命令行标志,把 `.` / `..` / `./x`
|
|
284
|
-
* 当本地目录 spec(`foo@.` 能让 npm 转去打包宿主工作目录并执行其 prepack 脚本)。
|
|
285
|
-
* 版本段单独用 {@link isSafeVersion} 校验,两段各自锚住首字符才封得死。
|
|
286
|
-
*/
|
|
287
|
-
const PKG_NAME_ONLY_RE = /^(@[a-z0-9][a-z0-9\-_.]*\/)?[a-z0-9][a-z0-9\-_.]*$/i;
|
|
288
|
-
/** 精确版本号:只收字母数字打头、不含路径与标志字符的形态。 */
|
|
289
|
-
const VERSION_RE = /^[a-z0-9][a-z0-9.\-+]*$/i;
|
|
290
249
|
/**
|
|
291
250
|
* 读 npm 的隐藏锁 `node_modules/.package-lock.json`,得到「此刻树上装的是哪些版本」。
|
|
292
251
|
*
|
|
@@ -353,7 +312,7 @@ export function buildUpdateSpecs(targets) {
|
|
|
353
312
|
for (const target of targets) {
|
|
354
313
|
const name = typeof target?.name === 'string' ? target.name : '';
|
|
355
314
|
const version = typeof target?.version === 'string' ? target.version : '';
|
|
356
|
-
if (!
|
|
315
|
+
if (!PKG_NAME_RE.test(name))
|
|
357
316
|
return { error: t('badName', { name: JSON.stringify(name) }) };
|
|
358
317
|
if (!VERSION_RE.test(version))
|
|
359
318
|
return { error: t('badVersion', { name, version: JSON.stringify(version) }) };
|
|
@@ -489,7 +448,7 @@ export function findUnmetPeers(output, targetNames) {
|
|
|
489
448
|
* 能力组装层见 createService。
|
|
490
449
|
*/
|
|
491
450
|
export function createPackageManager(deps) {
|
|
492
|
-
const { proc, log } = deps;
|
|
451
|
+
const { proc, log, signal } = deps;
|
|
493
452
|
/**
|
|
494
453
|
* 当前占用的操作描述;`null` = 空闲。
|
|
495
454
|
*
|
|
@@ -504,37 +463,66 @@ export function createPackageManager(deps) {
|
|
|
504
463
|
*/
|
|
505
464
|
let inflight = null;
|
|
506
465
|
/**
|
|
507
|
-
* 进程即将重启(update
|
|
466
|
+
* 进程即将重启(update 装完、重启已受理后置位),此后**永不释放**锁。
|
|
508
467
|
*
|
|
509
468
|
* 重启不是立刻发生的(延迟 500ms + stop + spawn),这段窗口里若放新操作进来,
|
|
510
469
|
* 它会被 `process.exit` 腰斩在半路,留下半装状态。
|
|
511
470
|
*/
|
|
512
471
|
let terminal = false;
|
|
472
|
+
/** 在途操作落定(npm 退出,被打断的已还原两份文件);本激活收尾时等它,见 settled */
|
|
473
|
+
let current = Promise.resolve();
|
|
513
474
|
/**
|
|
514
475
|
* 串行闸:占用中直接**拒绝**而非排队。
|
|
515
476
|
*
|
|
516
477
|
* 拒绝而非排队的决定性理由:update 成功后进程立即重启,排在它后面的操作必然被腰斩。
|
|
517
478
|
* 排队在这里是错的语义——「一次一个」才是这三个操作的真实约束。
|
|
479
|
+
* 本激活已开始关闭时同样拒绝:收尾段里服务还登记着,这时进来的请求不再启动 npm。
|
|
518
480
|
*/
|
|
519
481
|
async function exclusive(what, run) {
|
|
482
|
+
if (signal.aborted)
|
|
483
|
+
return { ok: false, message: t('closing') };
|
|
520
484
|
if (inflight) {
|
|
521
485
|
return { ok: false, message: t('busy', { operation: inflight }) };
|
|
522
486
|
}
|
|
523
487
|
inflight = what;
|
|
488
|
+
const running = run();
|
|
489
|
+
current = running.then(() => { }, () => { });
|
|
524
490
|
try {
|
|
525
|
-
return await
|
|
491
|
+
return await running;
|
|
526
492
|
}
|
|
527
493
|
finally {
|
|
528
494
|
if (!terminal)
|
|
529
495
|
inflight = null;
|
|
530
496
|
}
|
|
531
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* 等 work 落定,本激活一开始关闭就不再等(work 照常往下走)。收尾在等本操作落定(onDrain),而 idle、卸载本插件选中的
|
|
500
|
+
* 提供者都要等这次收尾所在的计划走完:不这样两边互等到清理宽限。等完摘掉监听,不挂在长寿的信号上
|
|
501
|
+
*/
|
|
502
|
+
async function untilClosing(work) {
|
|
503
|
+
if (signal.aborted)
|
|
504
|
+
return;
|
|
505
|
+
let stopWaiting;
|
|
506
|
+
const aborted = new Promise(resolve => {
|
|
507
|
+
stopWaiting = resolve;
|
|
508
|
+
});
|
|
509
|
+
signal.addEventListener('abort', stopWaiting, { once: true });
|
|
510
|
+
try {
|
|
511
|
+
await Promise.race([work, aborted]);
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
signal.removeEventListener('abort', stopWaiting);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
532
517
|
/** 跑一条 npm 命令:失败时整段输出记进日志,抛出的错误只带 {@link describeNpmFailure} 认出的原因与修法 */
|
|
533
518
|
async function npm(args, cwd, timeout, extraEnv) {
|
|
534
519
|
try {
|
|
535
|
-
return (await execProcBoth(proc, 'npm', args, cwd, timeout, extraEnv)).stdout;
|
|
520
|
+
return (await execProcBoth(proc, 'npm', args, cwd, timeout, extraEnv, signal)).stdout;
|
|
536
521
|
}
|
|
537
522
|
catch (err) {
|
|
523
|
+
// 随本激活关闭被中止的不算失败:调用方按中止回执
|
|
524
|
+
if (signal.aborted)
|
|
525
|
+
throw err;
|
|
538
526
|
const output = err instanceof Error ? err.message : String(err);
|
|
539
527
|
log.error(`npm ${args.join(' ')} 失败: ${output}`);
|
|
540
528
|
throw new TextError(describeNpmFailure(output));
|
|
@@ -545,7 +533,7 @@ export function createPackageManager(deps) {
|
|
|
545
533
|
function registry() {
|
|
546
534
|
if (!registryUrl) {
|
|
547
535
|
// 与 install / update 同一 cwd:项目根的 .npmrc 一并生效
|
|
548
|
-
registryUrl = execProc(proc, 'npm', ['config', 'get', 'registry'], deps.projectRoot()).then(out => {
|
|
536
|
+
registryUrl = execProc(proc, 'npm', ['config', 'get', 'registry'], deps.projectRoot(), signal).then(out => {
|
|
549
537
|
const url = out.trim().replace(/\/+$/, '');
|
|
550
538
|
if (!/^https?:\/\/\S+$/i.test(url)) {
|
|
551
539
|
throw new Error(`npm config get registry 的输出不是 http(s) 地址: ${JSON.stringify(out.trim())}`);
|
|
@@ -558,13 +546,17 @@ export function createPackageManager(deps) {
|
|
|
558
546
|
}
|
|
559
547
|
return registryUrl;
|
|
560
548
|
}
|
|
561
|
-
/**
|
|
549
|
+
/**
|
|
550
|
+
* 路径存在性:`test -d|-f <abs>`(不存在 → exit 1 → 抛 → false)。绝对路径,cwd 无关。被本激活中止时照抛,不读成「没有」
|
|
551
|
+
*/
|
|
562
552
|
async function pathExists(absPath, kind) {
|
|
563
553
|
try {
|
|
564
|
-
await proc.execFile('test', [`-${kind}`, absPath], { cwd: deps.projectRoot(), timeout: 10_000 });
|
|
554
|
+
await proc.execFile('test', [`-${kind}`, absPath], { cwd: deps.projectRoot(), timeout: 10_000, signal });
|
|
565
555
|
return true;
|
|
566
556
|
}
|
|
567
|
-
catch {
|
|
557
|
+
catch (err) {
|
|
558
|
+
if (signal.aborted)
|
|
559
|
+
throw err;
|
|
568
560
|
return false;
|
|
569
561
|
}
|
|
570
562
|
}
|
|
@@ -580,19 +572,99 @@ export function createPackageManager(deps) {
|
|
|
580
572
|
return undefined;
|
|
581
573
|
}
|
|
582
574
|
}
|
|
583
|
-
/**
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
575
|
+
/**
|
|
576
|
+
* 操作前的 package.json 与锁文件(RestartRollback 的 restore 形状):被打断时由本插件写回,更新成功时原样交给重启策略作回滚。
|
|
577
|
+
* 原本没有的记为「写回时删掉」:npm install 会新建锁文件,回滚只写回不删的话,留着它会让 postRestore 判定新版仍满足
|
|
578
|
+
* 还原后的范围、`up to date` 什么都不做;用空内容占位也不行(会得到坏 JSON)。readText 不分不存在与读失败:读不到的再看
|
|
579
|
+
* 在不在,在而读不出的不启动 npm,免得记成「原本没有」、还原时把它删掉。取不到快照(在而读不出,或查在不在时被中止)时交回
|
|
580
|
+
* 回执,与 npmOnTree 同一口径:都还没动文件
|
|
581
|
+
*/
|
|
582
|
+
async function snapshotProject(root) {
|
|
583
|
+
const files = [];
|
|
584
|
+
for (const path of [`${root}/package.json`, `${root}/package-lock.json`]) {
|
|
585
|
+
const content = await deps.readText(path);
|
|
586
|
+
if (content !== undefined) {
|
|
587
|
+
files.push({ path, content });
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
// pathExists 只在被中止时抛
|
|
591
|
+
const exists = await pathExists(path, 'f').catch(() => null);
|
|
592
|
+
if (exists === null)
|
|
593
|
+
return t('abortedNoChange');
|
|
594
|
+
if (exists)
|
|
595
|
+
return t('snapshotUnreadable', { path });
|
|
596
|
+
files.push({ path, content: '', deleteIfEmpty: true });
|
|
597
|
+
}
|
|
598
|
+
return files;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* 把 snapshot 写回(npm 已退出,不会在写回之后再写一遍)。经 process 服务:先 tee 到临时文件再 mv 换上,写到一半出错时
|
|
602
|
+
* 目标仍是 npm 留下的那份、不是半截。这几条命令不接中止信号:写回本身就是中止之后的收尾。返回没还原成的那几份与原因
|
|
603
|
+
*/
|
|
604
|
+
async function restoreProject(snapshot, root) {
|
|
605
|
+
const run = (cmd, args, input) => proc.execFile(cmd, args, { cwd: root, timeout: 10_000, ...(input === undefined ? {} : { input }) });
|
|
606
|
+
const failed = [];
|
|
607
|
+
for (const file of snapshot) {
|
|
608
|
+
const tmp = `${file.path}.aalis-restore`;
|
|
609
|
+
try {
|
|
610
|
+
if (file.deleteIfEmpty && file.content === '')
|
|
611
|
+
await run('rm', ['-f', file.path]);
|
|
612
|
+
else {
|
|
613
|
+
await run('tee', [tmp], file.content);
|
|
614
|
+
await run('mv', ['-f', tmp, file.path]);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
failed.push({ path: file.path, error: err instanceof Error ? err.message : String(err) });
|
|
619
|
+
await run('rm', ['-f', tmp]).catch(() => { });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return failed;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* 跑一条会改安装树的 npm(install、uninstall)。本激活关闭时随中止信号打断:等它退出后把 package.json 与锁文件写回
|
|
626
|
+
* snapshot,返回中止的回执(warn 日志同样写明);执行完毕返回 undefined,npm 自己失败照常抛出。开始执行之前已中止的不跑
|
|
627
|
+
*/
|
|
628
|
+
async function npmOnTree(args, root, snapshot, extraEnv) {
|
|
629
|
+
if (signal.aborted)
|
|
630
|
+
return t('abortedNoChange');
|
|
631
|
+
try {
|
|
632
|
+
await npm(args, root, INSTALL_TIMEOUT_MS, extraEnv);
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
if (!signal.aborted)
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
639
|
+
const failed = await restoreProject(snapshot, root);
|
|
640
|
+
const message = failed.length === 0
|
|
641
|
+
? t('interrupted')
|
|
642
|
+
: t('interruptedRestoreFailed', { items: failed.map(f => t('restoreFailedItem', f)) });
|
|
643
|
+
log.warn(`npm ${args.join(' ')} 随本插件停用被中止:${toSource(message)}(安装目录 ${root})`);
|
|
644
|
+
return message;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* 插件名即包名。这个名字被不是从这个包加载的插件占着(宿主直接登记的)时,那些实例、配置与它提供的服务都不是这个包的:
|
|
648
|
+
* 卸这个包只卸包
|
|
649
|
+
*/
|
|
650
|
+
function heldByHost(name) {
|
|
651
|
+
return deps.isPluginRegistered(name) && !deps.loaded(name);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* 卸载 name 会打断的插件({@link findServiceDependents});名字被宿主直接登记的插件占着时卸的只是包,断不了谁。
|
|
655
|
+
* 卸载闸与市场的卸载前预警共用这一份
|
|
656
|
+
*/
|
|
657
|
+
function dependentsOf(name) {
|
|
658
|
+
if (heldByHost(name))
|
|
659
|
+
return [];
|
|
660
|
+
return findServiceDependents(name, deps.pluginStatus(), deps.availableServices());
|
|
591
661
|
}
|
|
592
|
-
/** 注册表里该定义的全部 instanceId
|
|
662
|
+
/** 注册表里该定义的全部 instanceId;一个都没登记(包没加载上)时只整理定义 name 本身。 */
|
|
593
663
|
function instanceIdsFor(defName) {
|
|
594
|
-
const
|
|
595
|
-
|
|
664
|
+
const ids = deps
|
|
665
|
+
.pluginStatus()
|
|
666
|
+
.filter(p => p.name === defName)
|
|
667
|
+
.map(p => p.instanceId);
|
|
596
668
|
return ids.length > 0 ? ids : [defName];
|
|
597
669
|
}
|
|
598
670
|
/**
|
|
@@ -613,18 +685,19 @@ export function createPackageManager(deps) {
|
|
|
613
685
|
/**
|
|
614
686
|
* 装完的统一判定:rescan 出新插件即成功;没出新插件时按目标是否**声明自己是插件**分流。
|
|
615
687
|
* 「声明了插件却没被发现」正是那条静默假成功——必须报失败,否则用户看到 ok 却什么也没装上。
|
|
616
|
-
*
|
|
688
|
+
* 插件名即包名。就位查的是以包名登记的主实例是不是从目标包加载的,不只看名字已登记:名字被宿主直接登记的插件占着时
|
|
689
|
+
* 发现驱动跳过目标包,同样报失败并写明。就位但主实例没有激活时仍回 ok:包确实装上、已进插件列表;
|
|
617
690
|
* message 按重算落定后的状态说明——激活失败(如缺配置,改好配置即可重试)、慢激活转入后台仍在进行、等待 required 依赖。
|
|
618
691
|
*/
|
|
619
692
|
async function settleInstall(npmPkg, installedPkgJsonPath, where) {
|
|
620
693
|
const target = stripVersion(npmPkg);
|
|
621
694
|
// rescan 只当副作用用(让加载器发现新包),**不看返回值**——判据是目标自身是否就位。
|
|
622
695
|
await deps.rescanPlugins();
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
await deps.idle
|
|
626
|
-
const main = deps.pluginStatus
|
|
627
|
-
const settled = (message) => ({ ok: true, message, plugin:
|
|
696
|
+
if (deps.loaded(target)) {
|
|
697
|
+
// 回执按等完(或不再等)时读到的状态写
|
|
698
|
+
await untilClosing(deps.idle());
|
|
699
|
+
const main = deps.pluginStatus().find(p => p.instanceId === target);
|
|
700
|
+
const settled = (message) => ({ ok: true, message, plugin: target });
|
|
628
701
|
switch (main?.state) {
|
|
629
702
|
case 'error':
|
|
630
703
|
return settled(t('installedErrored', { target, error: failureText(main) ?? t('seeLogs') }));
|
|
@@ -636,9 +709,11 @@ export function createPackageManager(deps) {
|
|
|
636
709
|
return settled(t('installedLoaded', { target }));
|
|
637
710
|
}
|
|
638
711
|
const meta = await readJson(installedPkgJsonPath);
|
|
639
|
-
if (declaresPlugin(meta))
|
|
712
|
+
if (!declaresPlugin(meta))
|
|
713
|
+
return { ok: true, message: t('installedNonPlugin', { target }) };
|
|
714
|
+
if (!deps.isPluginRegistered(target))
|
|
640
715
|
return { ok: false, message: t('installedNotLoaded', { where }) };
|
|
641
|
-
return { ok:
|
|
716
|
+
return { ok: false, message: t('installedNameTakenByHost', { target }) };
|
|
642
717
|
}
|
|
643
718
|
/**
|
|
644
719
|
* 安装:写根 `dependencies` —— 加载器**唯一**的发现来源。
|
|
@@ -657,7 +732,16 @@ export function createPackageManager(deps) {
|
|
|
657
732
|
if (hasWorkspaceProtocol(rootPkg)) {
|
|
658
733
|
return { ok: false, message: t('workspaceInstall') };
|
|
659
734
|
}
|
|
660
|
-
|
|
735
|
+
let refused;
|
|
736
|
+
try {
|
|
737
|
+
refused = await installKindRefusal(npmPkg, root);
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
// 查类型的 npm view 被中止:还没动文件
|
|
741
|
+
if (signal.aborted)
|
|
742
|
+
return { ok: false, message: t('abortedNoChange') };
|
|
743
|
+
throw err;
|
|
744
|
+
}
|
|
661
745
|
if (refused)
|
|
662
746
|
return { ok: false, message: refused };
|
|
663
747
|
log.info(`正在安装: ${npmPkg} → 根依赖 + node_modules`);
|
|
@@ -669,7 +753,12 @@ export function createPackageManager(deps) {
|
|
|
669
753
|
//
|
|
670
754
|
// peer 护栏与更新预检相同(STRICT_PEERS_ENV)。代价:本就靠 legacy-peer-deps 容忍既有
|
|
671
755
|
// peer 冲突的项目,市场安装会整体失败,故失败文案要点明原因(见 describeNpmFailure)。
|
|
672
|
-
|
|
756
|
+
const snapshot = await snapshotProject(root);
|
|
757
|
+
if (!Array.isArray(snapshot))
|
|
758
|
+
return { ok: false, message: snapshot };
|
|
759
|
+
const stopped = await npmOnTree(['install', npmPkg, '--no-audit', '--no-fund'], root, snapshot, STRICT_PEERS_ENV);
|
|
760
|
+
if (stopped)
|
|
761
|
+
return { ok: false, message: stopped };
|
|
673
762
|
const bare = stripVersion(npmPkg);
|
|
674
763
|
return settleInstall(npmPkg, `${root}/node_modules/${bare}/package.json`, 'node_modules');
|
|
675
764
|
}
|
|
@@ -680,7 +769,7 @@ export function createPackageManager(deps) {
|
|
|
680
769
|
* 1. `--dry-run` **有副作用**——它不碰 `package.json` 与 `package-lock.json`,却会把
|
|
681
770
|
* `node_modules/.package-lock.json`(npm 的隐藏 lockfile)重写成「目标版本已装」。
|
|
682
771
|
* 紧接着的真装读到它就判定树已就绪,直接 `up to date` 什么都不做:于是声明成了新版、
|
|
683
|
-
* node_modules
|
|
772
|
+
* node_modules 里仍是旧代码,而旧代码能启动 → 重启成功 → 回滚永不触发 → 整次更新
|
|
684
773
|
* 空转却报成功。副本里跑则 live tree 分毫未动,无需事后补救。
|
|
685
774
|
* 2. 用户 `.npmrc` 里的 `legacy-peer-deps=true`(React 生态常见 workaround)会让 npm
|
|
686
775
|
* **完全不报** peer 告警,唯一的护栏静默失效。副本在项目树之外,天然不继承项目级
|
|
@@ -702,16 +791,18 @@ export function createPackageManager(deps) {
|
|
|
702
791
|
try {
|
|
703
792
|
// 直接 cp 现有文件,不重新写内容——与本插件其余文件操作同一路子(走 process 服务的
|
|
704
793
|
// POSIX 命令),也省掉一个只为此处存在的写原语。
|
|
705
|
-
await execProc(proc, 'cp', [`${root}/package.json`, `${tmp.path}/package.json`], tmp.path);
|
|
794
|
+
await execProc(proc, 'cp', [`${root}/package.json`, `${tmp.path}/package.json`], tmp.path, signal);
|
|
706
795
|
const lock = `${root}/package-lock.json`;
|
|
707
796
|
if (await pathExists(lock, 'f')) {
|
|
708
|
-
await execProc(proc, 'cp', [lock, `${tmp.path}/package-lock.json`], tmp.path);
|
|
797
|
+
await execProc(proc, 'cp', [lock, `${tmp.path}/package-lock.json`], tmp.path, signal);
|
|
709
798
|
}
|
|
710
799
|
let output;
|
|
711
800
|
try {
|
|
712
|
-
output = await execProcBoth(proc, 'npm', ['install', ...specs, '--dry-run', '--no-audit', '--no-fund'], tmp.path, INSTALL_TIMEOUT_MS, STRICT_PEERS_ENV);
|
|
801
|
+
output = await execProcBoth(proc, 'npm', ['install', ...specs, '--dry-run', '--no-audit', '--no-fund'], tmp.path, INSTALL_TIMEOUT_MS, STRICT_PEERS_ENV, signal);
|
|
713
802
|
}
|
|
714
803
|
catch (err) {
|
|
804
|
+
if (signal.aborted)
|
|
805
|
+
throw err;
|
|
715
806
|
// 非零退出:npm 判定 peer 无解(ERESOLVE 等)时报冲突要点;别的失败(连不上源等)不是冲突,抛出原因与修法
|
|
716
807
|
const raw = err instanceof Error ? err.message : String(err);
|
|
717
808
|
const conflicts = extractPeerConflicts(raw);
|
|
@@ -736,12 +827,8 @@ export function createPackageManager(deps) {
|
|
|
736
827
|
if (built.error)
|
|
737
828
|
return { ok: false, message: built.error };
|
|
738
829
|
const { specs } = built;
|
|
739
|
-
if (!deps.restartApp)
|
|
740
|
-
return { ok: false, message: t('noRestart') };
|
|
741
830
|
const root = deps.projectRoot();
|
|
742
|
-
//
|
|
743
|
-
// isRegistryDep 闸),不是整个项目的属性。旧写法拿 `pnpm-workspace.yaml` 是否存在
|
|
744
|
-
// 当整体开关,既挡掉了工作区项目里合法的 npm 依赖,也与真正生效的加载器无因果关系。
|
|
831
|
+
// 能不能更新是**每个包自己的来源**说了算(下面的 isRegistryDep 闸),不是整个项目的属性。
|
|
745
832
|
const rootPkgPath = `${root}/package.json`;
|
|
746
833
|
const rootPkgText = await deps.readText(rootPkgPath);
|
|
747
834
|
if (rootPkgText === undefined)
|
|
@@ -796,27 +883,17 @@ export function createPackageManager(deps) {
|
|
|
796
883
|
verdict = await preflightInSandbox(specs, root);
|
|
797
884
|
}
|
|
798
885
|
catch (err) {
|
|
886
|
+
if (signal.aborted)
|
|
887
|
+
return { ok: false, message: t('abortedNoChange') };
|
|
799
888
|
return { ok: false, message: t('preflightFailed', { error: textOf(err) }) };
|
|
800
889
|
}
|
|
801
890
|
if (verdict)
|
|
802
891
|
return reject(verdict);
|
|
803
892
|
// ── 快照:预检过了才有必要 ──
|
|
804
|
-
// lockfile 与 package.json
|
|
805
|
-
const restore =
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const lockPath = `${root}/package-lock.json`;
|
|
809
|
-
const lockText = await deps.readText(lockPath);
|
|
810
|
-
if (lockText !== undefined) {
|
|
811
|
-
restore.push({ path: lockPath, content: lockText });
|
|
812
|
-
}
|
|
813
|
-
else {
|
|
814
|
-
// 原本没有 lockfile(pnpm/yarn 装的工程),而 npm install 会**新建**一个锁到新版。
|
|
815
|
-
// 回滚只能写回文件、不能删除新增文件,留着它会让 postRestore 判定新版仍满足还原后的
|
|
816
|
-
// 范围 → `up to date` → node_modules 纹丝不动。用空内容占位不行(会得到坏 JSON),
|
|
817
|
-
// 故记为「回滚时删掉它」,把工程还原成它原本无锁的样子。
|
|
818
|
-
restore.push({ path: lockPath, content: '', deleteIfEmpty: true });
|
|
819
|
-
}
|
|
893
|
+
// lockfile 与 package.json 必须一起回退,否则还原后的树是「声明旧版、锁定新版」。被打断时本插件按它写回,装完交给重启策略
|
|
894
|
+
const restore = await snapshotProject(root);
|
|
895
|
+
if (!Array.isArray(restore))
|
|
896
|
+
return { ok: false, message: restore };
|
|
820
897
|
// 装之前记下整棵树的实装版本。回滚不能只钉被点名的包:npm 会顺带把**传递依赖**升上去
|
|
821
898
|
// (新版把某个依赖的下界抬高了),而回滚时 `npm install <被点名的包>@<旧版>` 只会
|
|
822
899
|
// `changed 1 package` —— 被顺带升上去的那个仍满足旧版声明的范围,npm 不动它,于是它
|
|
@@ -830,21 +907,20 @@ export function createPackageManager(deps) {
|
|
|
830
907
|
// ── 提交 ──
|
|
831
908
|
// 预检在副本里做,live tree 此刻**未被任何 dry-run 触碰**,可以直接装。
|
|
832
909
|
try {
|
|
833
|
-
await
|
|
910
|
+
const stopped = await npmOnTree(['install', ...specs, '--no-audit', '--no-fund'], root, restore);
|
|
911
|
+
if (stopped)
|
|
912
|
+
return { ok: false, message: stopped };
|
|
834
913
|
}
|
|
835
914
|
catch (err) {
|
|
836
915
|
return { ok: false, message: t('installFailedRetry', { error: textOf(err) }) };
|
|
837
916
|
}
|
|
838
917
|
// ── 重启接管 ──
|
|
839
918
|
// 回滚凭据交给重启策略:只有它能观察到「新实例 ready 前夭折」。插件级失败不在此列
|
|
840
|
-
//
|
|
841
|
-
log.info(`更新完成,正在重启接管: ${specs.join(' ')}`);
|
|
842
|
-
// 置位后锁不再释放:重启是延迟发生的,这段窗口放新操作进来会被 process.exit 腰斩。
|
|
843
|
-
terminal = true;
|
|
919
|
+
// ——插件启动失败会停在 error 态且 WebUI 可见,自动回滚反而会掩盖问题。
|
|
844
920
|
// postRestore 钉**精确旧版**,不用裸 `npm install`。
|
|
845
921
|
// 裸 install 只能按还原后的 package.json 重解析,而声明通常是 caret 范围(`^1.0.0`),
|
|
846
922
|
// 刚崩掉的新版(1.1.0)本身就满足它——于是 npm 又把它装回来,node_modules 分毫未变,
|
|
847
|
-
//
|
|
923
|
+
// 而重启策略照样报告已回滚,并重新启动进程运行旧版本。实测:有锁时正常,无锁时静默失效。
|
|
848
924
|
// `--no-save` 保证 package.json 维持 restore 写回的原样,不被 npm 改写成 `^旧版`。
|
|
849
925
|
// 降级守卫已保证每个 target 都有已装版本(读不到的在那里就被拒了),此处的 undefined
|
|
850
926
|
// 过滤只是让类型收窄,不承担逻辑。
|
|
@@ -865,11 +941,20 @@ export function createPackageManager(deps) {
|
|
|
865
941
|
if (after !== undefined && after !== before)
|
|
866
942
|
pinned.push(`${name}@${before}`);
|
|
867
943
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
944
|
+
try {
|
|
945
|
+
deps.restartApp({
|
|
946
|
+
reason: `marketplace-update:${specs.join(',')}`,
|
|
947
|
+
restore,
|
|
948
|
+
postRestore: { cmd: 'npm', args: ['install', ...pinned, '--no-save', '--no-audit', '--no-fund'], cwd: root },
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
// 宿主没有重启策略(嵌入宿主)或应用已开始停机时 core 同步抛错:包已装好、不会重启,锁照常放开,回执照实说
|
|
953
|
+
return { ok: true, restarting: false, message: t('updatedRestartUnavailable', { specs, error: textOf(err) }) };
|
|
954
|
+
}
|
|
955
|
+
log.info(`更新完成,正在重启接管: ${specs.join(' ')}`);
|
|
956
|
+
// 重启受理之后锁不再释放:重启是延迟发生的,这段窗口放新操作进来会被 process.exit 腰斩。
|
|
957
|
+
terminal = true;
|
|
873
958
|
return { ok: true, restarting: true, message: t('updated', { specs }) };
|
|
874
959
|
}
|
|
875
960
|
return {
|
|
@@ -888,8 +973,9 @@ export function createPackageManager(deps) {
|
|
|
888
973
|
}
|
|
889
974
|
}),
|
|
890
975
|
uninstall: (pluginName, options) => exclusive(t('opUninstall', { name: pluginName }), () => uninstallOne(pluginName, options)),
|
|
891
|
-
serviceDependents:
|
|
976
|
+
serviceDependents: dependentsOf,
|
|
892
977
|
registry,
|
|
978
|
+
settled: () => current,
|
|
893
979
|
};
|
|
894
980
|
/**
|
|
895
981
|
* 卸载:服务层各道闸 + npm uninstall。
|
|
@@ -910,8 +996,8 @@ export function createPackageManager(deps) {
|
|
|
910
996
|
// ── 闸一:类型 ──
|
|
911
997
|
// 市场管的是**插件**。core / runtime / api / schema / util 不是插件,不在本工具职权内。
|
|
912
998
|
// 这不是「权限不够」——owner 权限很高,走 `npm uninstall` 随时能删。这道闸拦的是
|
|
913
|
-
//
|
|
914
|
-
//
|
|
999
|
+
// 「**这个操作会把你用来撤销它的那条通道一起销毁**」:卸掉插件,实例照常运行、市场里点
|
|
1000
|
+
// 一下就能装回来(带内可恢复);卸掉内核或宿主,实例无法启动、市场随之消失,只能开
|
|
915
1001
|
// shell 修(带外)。更新不在此列——它保留实例且失败会自动回滚。
|
|
916
1002
|
// 判据与安装同一份(nonMarketKind)。
|
|
917
1003
|
const kind = nonMarketKind(meta.keywords);
|
|
@@ -936,7 +1022,7 @@ export function createPackageManager(deps) {
|
|
|
936
1022
|
//
|
|
937
1023
|
// 只拦「当前生效的那个」:想换实现的正常路径是「装新的 → 切过去 → 再卸旧的」,
|
|
938
1024
|
// 那时它已不是提供者,本闸自然放行。
|
|
939
|
-
const lifeline = deps.recoveryChannelProviders
|
|
1025
|
+
const lifeline = deps.recoveryChannelProviders();
|
|
940
1026
|
if (lifeline.includes(pluginName)) {
|
|
941
1027
|
return { ok: false, message: t('lifeline', { name: pluginName }) };
|
|
942
1028
|
}
|
|
@@ -956,29 +1042,36 @@ export function createPackageManager(deps) {
|
|
|
956
1042
|
};
|
|
957
1043
|
return { ok: false, message: t('notFromMarket', { name: pluginName, why: why[origin] }) };
|
|
958
1044
|
}
|
|
959
|
-
//
|
|
960
|
-
const
|
|
961
|
-
const instanceIds = instanceIdsFor(
|
|
1045
|
+
// 名字被宿主直接登记的插件占着时只卸包:不卸实例、不过服务依赖者闸、不整理配置
|
|
1046
|
+
const held = heldByHost(pluginName);
|
|
1047
|
+
const instanceIds = held ? [] : instanceIdsFor(pluginName);
|
|
962
1048
|
// ── 闸三:服务依赖者 ──
|
|
963
1049
|
// 某服务没有别的插件正在提供时,卸掉它的提供者会打断 required 该服务的插件(已禁用的不算)。
|
|
964
|
-
// 与市场卸载前的预警同一份判定(serviceDependents
|
|
965
|
-
const dependents =
|
|
1050
|
+
// 与市场卸载前的预警同一份判定(serviceDependents)。
|
|
1051
|
+
const dependents = dependentsOf(pluginName);
|
|
966
1052
|
if (dependents.length > 0) {
|
|
967
1053
|
return { ok: false, message: t('breaksDependents', { dependents }) };
|
|
968
1054
|
}
|
|
969
1055
|
try {
|
|
970
|
-
|
|
971
|
-
|
|
1056
|
+
const snapshot = await snapshotProject(root);
|
|
1057
|
+
if (!Array.isArray(snapshot))
|
|
1058
|
+
return { ok: false, message: snapshot };
|
|
1059
|
+
const stopped = await npmOnTree(['uninstall', pluginName, '--no-audit', '--no-fund'], root, snapshot);
|
|
1060
|
+
if (stopped)
|
|
1061
|
+
return { ok: false, message: stopped };
|
|
1062
|
+
// 从运行时注册表彻底移除(dispose + delete),幂等。卸的可能正是本插件选中的提供者,本激活随之关闭时不再等
|
|
972
1063
|
for (const id of instanceIds)
|
|
973
|
-
await deps.unloadPlugin(id);
|
|
1064
|
+
await untilClosing(deps.unloadPlugin(id));
|
|
974
1065
|
}
|
|
975
1066
|
catch (err) {
|
|
976
1067
|
return { ok: false, message: textOf(err) };
|
|
977
1068
|
}
|
|
978
1069
|
log.info(`${pluginName}: 已从根依赖与 node_modules 移除`);
|
|
1070
|
+
if (held)
|
|
1071
|
+
return { ok: true, message: t('uninstalledNameTakenByHost', { name: pluginName }) };
|
|
979
1072
|
// 包已经卸掉了,配置文件没写成不改变这一点:回执照样 ok,写明原因与残留
|
|
980
1073
|
try {
|
|
981
|
-
await deps.cleanupConfig
|
|
1074
|
+
await deps.cleanupConfig(instanceIds, removeConfig);
|
|
982
1075
|
}
|
|
983
1076
|
catch (err) {
|
|
984
1077
|
return {
|