dsh-plugin-manager-companion 0.1.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/LICENSE +21 -0
- package/README.en.md +144 -0
- package/README.md +142 -0
- package/cordis.patch.yml +9 -0
- package/dist/about.d.ts +77 -0
- package/dist/about.js +179 -0
- package/dist/cli.d.ts +226 -0
- package/dist/cli.js +856 -0
- package/dist/client/AboutPage.d.ts +75 -0
- package/dist/client/ConsolePage.d.ts +79 -0
- package/dist/client/KindsPage.d.ts +21 -0
- package/dist/client/MarketplacePage.d.ts +36 -0
- package/dist/client/OfficialSlots.d.ts +35 -0
- package/dist/client/UpgradeRow.d.ts +108 -0
- package/dist/client/index.d.ts +26 -0
- package/dist/client/locales.d.ts +475 -0
- package/dist/client/pmSelect.d.ts +38 -0
- package/dist/client/shared.d.ts +928 -0
- package/dist/client/upgradeView.d.ts +278 -0
- package/dist/client/wire.d.ts +401 -0
- package/dist/client.js +9194 -0
- package/dist/diagnostics.d.ts +332 -0
- package/dist/diagnostics.js +2631 -0
- package/dist/envManager.d.ts +1047 -0
- package/dist/envManager.js +3214 -0
- package/dist/fix.d.ts +60 -0
- package/dist/fix.js +168 -0
- package/dist/guard.d.ts +133 -0
- package/dist/guard.js +232 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +1150 -0
- package/dist/installSession.d.ts +111 -0
- package/dist/installSession.js +150 -0
- package/dist/kinds.d.ts +464 -0
- package/dist/kinds.js +1029 -0
- package/dist/marketView.d.ts +261 -0
- package/dist/marketView.js +406 -0
- package/dist/marketplace.d.ts +248 -0
- package/dist/marketplace.js +500 -0
- package/dist/match.d.ts +67 -0
- package/dist/match.js +203 -0
- package/dist/net.d.ts +108 -0
- package/dist/net.js +163 -0
- package/dist/official.d.ts +145 -0
- package/dist/official.js +205 -0
- package/dist/paths.d.ts +108 -0
- package/dist/paths.js +236 -0
- package/dist/presets.d.ts +299 -0
- package/dist/presets.js +578 -0
- package/dist/qualityGate.d.ts +66 -0
- package/dist/qualityGate.js +247 -0
- package/dist/rank.d.ts +88 -0
- package/dist/rank.js +164 -0
- package/dist/registry.d.ts +295 -0
- package/dist/registry.js +686 -0
- package/dist/rest.d.ts +122 -0
- package/dist/rest.js +219 -0
- package/dist/scan.d.ts +134 -0
- package/dist/scan.js +396 -0
- package/dist/settings.d.ts +447 -0
- package/dist/settings.js +263 -0
- package/dist/tags.d.ts +119 -0
- package/dist/tags.js +166 -0
- package/dist/tools.d.ts +131 -0
- package/dist/tools.js +377 -0
- package/dist/types.d.ts +651 -0
- package/dist/types.js +13 -0
- package/dist/upgrade.d.ts +428 -0
- package/dist/upgrade.js +1100 -0
- package/dist/upgradeView.d.ts +313 -0
- package/dist/upgradeView.js +273 -0
- package/docs/images/readme/01-console-health.png +0 -0
- package/docs/images/readme/02-console-envs.png +0 -0
- package/docs/images/readme/03-marketplace.png +0 -0
- package/docs/images/readme/04-official-plugin-page.png +0 -0
- package/package.json +104 -0
|
@@ -0,0 +1,3214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 环境管理引擎 — 列出/启停/创建/重命名/删除本地 DSH 环境,跨环境写插件,备份导出/差异/恢复。
|
|
3
|
+
*
|
|
4
|
+
* 归属:A 类·重写(旧仓库 src/profiles.ts 与 src/index.ts 的 createProfile /
|
|
5
|
+
* renameProfile / removeProfile / copyPlugins / backupExport / backupDiff /
|
|
6
|
+
* backupRestore 仅作意图参考,未复制代码)。
|
|
7
|
+
* 旧实现参考:旧 src/profiles.ts(进程扫描、终端窗口、端口探测的意图)、
|
|
8
|
+
* 旧 src/index.ts:227-1210(环境生命周期与备份四分类差异的意图)。
|
|
9
|
+
* 官方复用:@deepseek-ai/dsh-app-boot(initProfile / PROFILE_TEMPLATES /
|
|
10
|
+
* DEFAULT_PROFILE_BUNDLES / readProfileManifest / writeProfileBundles)、
|
|
11
|
+
* @deepseek-ai/dsh-plugin-manager/operations(runPluginCommand —— 跨环境 pnpm 通道)、
|
|
12
|
+
* @deepseek-ai/dsh-atomic-write(withFileLock)、./paths.ts(路径、manifest、互斥队列)、
|
|
13
|
+
* ./official.ts(当前环境事实)。
|
|
14
|
+
* 前提检查:旧实现的三条前提都已消失或已证伪 ——
|
|
15
|
+
* 1. 「必须自己拼 bundle 模板 / 自己调 pnpm」:官方 PROFILE_TEMPLATES 与
|
|
16
|
+
* runPluginCommand 已覆盖,本模块不写 bundle 清单、不调 pnpm 二进制;
|
|
17
|
+
* 2. 「当前环境靠 argv 猜」(旧 issue #1):官方 profileContext 是权威事实,
|
|
18
|
+
* 本模块只读它,拿不到就如实报告「未知」;
|
|
19
|
+
* 3. 「pkill -f 'dsh --profile' 收尾」:会误杀命令行里恰好出现同一字符串的无关
|
|
20
|
+
* 进程。本模块只按 pid 精确 kill,且在 kill 前用 /proc(或 ps)复核该 pid
|
|
21
|
+
* 的命令行仍属于同名环境。
|
|
22
|
+
*
|
|
23
|
+
* 同一套引擎、作用域可切换:当前环境的写操作走官方 pluginManager 服务,其它环境走
|
|
24
|
+
* 官方 operations —— 差别只是传给 runPluginCommand 的 profile 参数,不是两套实现。
|
|
25
|
+
*/
|
|
26
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
27
|
+
import { accessSync, appendFileSync, closeSync, constants, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, } from 'node:fs';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { request as httpRequest } from 'node:http';
|
|
31
|
+
import { connect, createServer } from 'node:net';
|
|
32
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
33
|
+
import { withFileLock } from '@deepseek-ai/dsh-atomic-write';
|
|
34
|
+
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, writeProfileBundles, } from '@deepseek-ai/dsh-app-boot';
|
|
35
|
+
import { probeOfficialCapabilities } from "./official.js";
|
|
36
|
+
import { OUR_PACKAGE_NAME, detectCurrentEnvironmentName, dshHome, enqueueMutation, environmentDir, isBuiltinEnvironment, isSafeEnvironmentName, profilesRoot, readEnvironmentManifest, sameEnvironment, } from "./paths.js";
|
|
37
|
+
// ── 常量 ──────────────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* 进程扫描结果的缓存有效期。
|
|
40
|
+
*
|
|
41
|
+
* 一次页面加载会连续触发列表与详情,每次都做一次全表扫描(Windows 上是
|
|
42
|
+
* powershell CIM 查询,可达数秒)。运行状态变化频率低,3s 内共享一份扫描对读
|
|
43
|
+
* 路径不可感知;启停的判定用 scanRunsNow 拿即时事实。
|
|
44
|
+
*/
|
|
45
|
+
export const SCAN_RUNS_TTL_MS = 3_000;
|
|
46
|
+
/** 启动后等待端口就绪的上限。官方 web 面冷启动要解析整棵插件树,给足 30s。 */
|
|
47
|
+
export const START_READY_TIMEOUT_MS = 30_000;
|
|
48
|
+
/** 收到 SIGTERM 后等待进程退出的上限。 */
|
|
49
|
+
export const STOP_TIMEOUT_MS = 5_000;
|
|
50
|
+
/** 自动选端口时的起点(官方 web 默认端口的上方)。 */
|
|
51
|
+
export const DEFAULT_WEB_PORT = 3090;
|
|
52
|
+
/** 就绪轮询间隔。 */
|
|
53
|
+
const READY_POLL_MS = 250;
|
|
54
|
+
/** 单次 HTTP 就绪探测的超时。 */
|
|
55
|
+
const READY_HTTP_TIMEOUT_MS = 2_000;
|
|
56
|
+
/**
|
|
57
|
+
* 官方 web 层的判定依据之一:提供 web 服务的那个官方包。
|
|
58
|
+
*
|
|
59
|
+
* 这是一个官方包名,不是我们的 bundle/模板名单(模板与层栈一律从官方
|
|
60
|
+
* PROFILE_TEMPLATES 派生)。证据:packages/bundle/web-app/package.json 依赖
|
|
61
|
+
* @deepseek-ai/dsh-host-webserver;官方另外四个 app bundle(base / headless /
|
|
62
|
+
* acp-app / sdk-app / sdk-minimal)都没有这个依赖。
|
|
63
|
+
*/
|
|
64
|
+
const WEB_SERVER_PACKAGE = '@deepseek-ai/dsh-host-webserver';
|
|
65
|
+
/**
|
|
66
|
+
* 视为「实例已应答」的 HTTP 状态。
|
|
67
|
+
*
|
|
68
|
+
* 官方 browser-auth:未授权回 401、带 token 的根请求回 303 换 cookie、已授权回 200。
|
|
69
|
+
* 404 是「这条路由还没注册上」,不是就绪 —— 实测 TCP 刚可连接时 GET / 正是 404。
|
|
70
|
+
*/
|
|
71
|
+
const READY_HTTP_STATUSES = [200, 303, 401];
|
|
72
|
+
/**
|
|
73
|
+
* 创建环境时的默认模板名。
|
|
74
|
+
*
|
|
75
|
+
* 官方 DEFAULT_PROFILE_BUNDLES 只有 @deepseek-ai/dsh-base(官方语义:故意最小,没有
|
|
76
|
+
* 任何 app),用它建出来的环境**必然没有 web 服务**:实测 startEnvironment 拉起后
|
|
77
|
+
* 干等 30s 报 timeout。官方 PROFILE_TEMPLATES.web 才是「建一个能访问的环境」的语义。
|
|
78
|
+
* 取名字时按官方键校验,官方常量里没有这个键就 fail loud(不悄悄退化成 base-only)。
|
|
79
|
+
*/
|
|
80
|
+
export const DEFAULT_ENVIRONMENT_TEMPLATE = 'web';
|
|
81
|
+
/** 官方 pnpm 通道的默认输出上限与锁等待上限(与官方 CLI 同量级)。 */
|
|
82
|
+
const OPERATION_OUTPUT_BYTES = 64 * 1024;
|
|
83
|
+
const OPERATION_LOCK_WAIT_MS = 120_000;
|
|
84
|
+
/** 带稳定错误码的环境操作异常。 */
|
|
85
|
+
export class EnvironmentError extends Error {
|
|
86
|
+
code;
|
|
87
|
+
/**
|
|
88
|
+
* @param code - 稳定错误码,见 EnvironmentErrorCode。
|
|
89
|
+
* @param message - 面向用户的原因说明。
|
|
90
|
+
*/
|
|
91
|
+
constructor(code, message) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.code = code;
|
|
94
|
+
this.name = 'EnvironmentError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function failure(code, output) {
|
|
98
|
+
return { ok: false, code, output };
|
|
99
|
+
}
|
|
100
|
+
function success(output) {
|
|
101
|
+
return { ok: true, output };
|
|
102
|
+
}
|
|
103
|
+
function messageOf(error) {
|
|
104
|
+
return error instanceof Error ? error.message : String(error);
|
|
105
|
+
}
|
|
106
|
+
let runCache = null;
|
|
107
|
+
/** 丢弃进程扫描缓存。启停成功后调用,让下一次读取立刻看到变化。 */
|
|
108
|
+
export function resetRunCache() {
|
|
109
|
+
runCache = null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 扫描进程表,找出每个环境的运行实例与端口(带缓存)。
|
|
113
|
+
*
|
|
114
|
+
* @param options - 缓存与注入选项。
|
|
115
|
+
* @returns 环境名到运行实例列表的映射;未运行的环境不出现在 map 里。
|
|
116
|
+
*/
|
|
117
|
+
export function scanRuns(options = {}) {
|
|
118
|
+
const now = options.now?.() ?? Date.now();
|
|
119
|
+
const ttl = options.ttlMs ?? SCAN_RUNS_TTL_MS;
|
|
120
|
+
return processFacts(options).runs;
|
|
121
|
+
}
|
|
122
|
+
/** 读一次进程事实(不经缓存,含可读性)。 */
|
|
123
|
+
function readProcessFacts(options) {
|
|
124
|
+
const read = options.reader !== undefined
|
|
125
|
+
? { lines: options.reader() }
|
|
126
|
+
: defaultProcessLines();
|
|
127
|
+
if (read.reason !== undefined)
|
|
128
|
+
return { runs: new Map(), readable: false, reason: read.reason };
|
|
129
|
+
const out = new Map();
|
|
130
|
+
for (const line of parseProcessLines(read.lines)) {
|
|
131
|
+
const run = parseRun(line);
|
|
132
|
+
if (run === null)
|
|
133
|
+
continue;
|
|
134
|
+
const entry = { pid: line.pid, port: run.port, command: line.command };
|
|
135
|
+
const list = out.get(run.name);
|
|
136
|
+
if (list === undefined)
|
|
137
|
+
out.set(run.name, [entry]);
|
|
138
|
+
else
|
|
139
|
+
list.push(entry);
|
|
140
|
+
}
|
|
141
|
+
return { runs: out, readable: true };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 立即读取进程事实(含「读不到」这个状态),并写入缓存。
|
|
145
|
+
*
|
|
146
|
+
* @param options - 注入选项。
|
|
147
|
+
* @returns 事实与可读性。
|
|
148
|
+
*/
|
|
149
|
+
export function processFactsNow(options = {}) {
|
|
150
|
+
return readProcessFacts(options);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 读取进程事实(带缓存)。
|
|
154
|
+
*
|
|
155
|
+
* @param options - 缓存与注入选项。
|
|
156
|
+
* @returns 事实与可读性。
|
|
157
|
+
*/
|
|
158
|
+
export function processFacts(options = {}) {
|
|
159
|
+
const now = options.now?.() ?? Date.now();
|
|
160
|
+
const ttl = options.ttlMs ?? SCAN_RUNS_TTL_MS;
|
|
161
|
+
if (options.fresh !== true && runCache !== null && now - runCache.at < ttl)
|
|
162
|
+
return runCache.facts;
|
|
163
|
+
const facts = readProcessFacts(options);
|
|
164
|
+
runCache = { at: now, facts };
|
|
165
|
+
return facts;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 立即扫描进程表,不经缓存(只要实例映射;可读性请用 processFactsNow)。
|
|
169
|
+
*
|
|
170
|
+
* @param options - 注入选项。
|
|
171
|
+
* @returns 环境名到运行实例列表的映射。
|
|
172
|
+
*/
|
|
173
|
+
export function scanRunsNow(options = {}) {
|
|
174
|
+
return new Map(readProcessFacts(options).runs);
|
|
175
|
+
}
|
|
176
|
+
/** 把进程表输出切成 pid + 命令行。 */
|
|
177
|
+
function parseProcessLines(lines) {
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
const match = /^\s*(\d+)\s+(.*\S)\s*$/.exec(line);
|
|
181
|
+
if (match === null)
|
|
182
|
+
continue;
|
|
183
|
+
const pid = Number(match[1]);
|
|
184
|
+
const command = match[2] ?? '';
|
|
185
|
+
if (Number.isInteger(pid) && pid > 0 && command.length > 0)
|
|
186
|
+
out.push({ pid, command });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* 把命令行切成 argv 片段,尊重引号。
|
|
192
|
+
*
|
|
193
|
+
* 为什么必须尊重引号:Windows 会给含空格的参数加引号,而官方安装包默认就在
|
|
194
|
+
* `C:\Program Files\nodejs`。按空白切词会把 `"C:\Program Files\…\bin.js"` 切成两段,
|
|
195
|
+
* isDshEntry 拿到 `bin.js"` 就不匹配 → 正在运行的实例被判成「没在运行」(审计 W-03,
|
|
196
|
+
* 已在真 win32 Node 上复现)。后果最重的一条是删除/改名前的「正在运行」护栏失效。
|
|
197
|
+
*
|
|
198
|
+
* 规则取 Windows 与 POSIX 的共同子集:引号内的空白不切分、引号本身剥掉、反斜杠不转义
|
|
199
|
+
* (cmd 不用反斜杠转义引号;ps 输出里也少见转义)。
|
|
200
|
+
*
|
|
201
|
+
* @param command - 命令行原文。
|
|
202
|
+
* @returns argv 片段。
|
|
203
|
+
*/
|
|
204
|
+
function tokenizeCommandLine(command) {
|
|
205
|
+
const tokens = [];
|
|
206
|
+
let current = '';
|
|
207
|
+
let quote = null;
|
|
208
|
+
for (const char of command) {
|
|
209
|
+
if (quote !== null) {
|
|
210
|
+
if (char === quote)
|
|
211
|
+
quote = null;
|
|
212
|
+
else
|
|
213
|
+
current += char;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (char === '"' || char === "'") {
|
|
217
|
+
quote = char;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (/\s/.test(char)) {
|
|
221
|
+
if (current.length > 0) {
|
|
222
|
+
tokens.push(current);
|
|
223
|
+
current = '';
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
current += char;
|
|
228
|
+
}
|
|
229
|
+
if (current.length > 0)
|
|
230
|
+
tokens.push(current);
|
|
231
|
+
return tokens;
|
|
232
|
+
}
|
|
233
|
+
/** 一次性诊断命令:命令行里出现这些就不是常驻实例。 */
|
|
234
|
+
const ONE_SHOT_FLAGS = new Set(['--help', '-h', '--version', '-v', '--dump-config']);
|
|
235
|
+
/**
|
|
236
|
+
* 判定一个 argv 片段是否为 dsh 的启动入口。
|
|
237
|
+
*
|
|
238
|
+
* 两种真实形态(本机实测 + 官方 apps/cli/src/args.ts):
|
|
239
|
+
* - PATH shim:/…/bin/dsh web …
|
|
240
|
+
* - 入口脚本:node /…/node_modules/@deepseek-ai/dsh/lib/bin.js web …
|
|
241
|
+
* (源码启动则是 …/deepseek-harness/apps/cli/src/bin.ts)
|
|
242
|
+
* 入口形态必须先正确,任意 node /srv/app/bin.js web 不能被当成实例 —— 否则 stop
|
|
243
|
+
* 会对着一个无关进程发 SIGTERM。
|
|
244
|
+
*
|
|
245
|
+
* @param token - 命令行中的一个片段。
|
|
246
|
+
* @returns 是否像 dsh 入口。
|
|
247
|
+
*/
|
|
248
|
+
function isDshEntry(token) {
|
|
249
|
+
// 引号已在分词阶段剥掉;这里再兜一次首尾引号,防止不平衡引号留下的残片。
|
|
250
|
+
const unquoted = token.replace(/^["']|["']$/g, '');
|
|
251
|
+
const normalized = unquoted.split('\\').join('/');
|
|
252
|
+
const base = normalized.slice(normalized.lastIndexOf('/') + 1);
|
|
253
|
+
if (/^dsh(?:\.(?:cmd|exe|ps1|sh))?$/i.test(base))
|
|
254
|
+
return true;
|
|
255
|
+
if (!/^bin\.(?:js|ts|cjs|mjs)$/i.test(base))
|
|
256
|
+
return false;
|
|
257
|
+
return /dsh|deepseek-harness/i.test(normalized);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* 从入口之后的参数里取环境名。
|
|
261
|
+
*
|
|
262
|
+
* --profile NAME 优先(官方显式形式);否则取第一个确实是本机 profile 目录的
|
|
263
|
+
* 位置参数(dsh web 缩写形式)。存在性检查同时挡住把其它 flag 的取值(如
|
|
264
|
+
* --patch foo.yml)当成环境名。
|
|
265
|
+
*
|
|
266
|
+
* @param rest - 入口之后的参数。
|
|
267
|
+
* @returns 环境名;判不出时 null。
|
|
268
|
+
*/
|
|
269
|
+
function profileNameOf(rest) {
|
|
270
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
271
|
+
const token = rest[index] ?? '';
|
|
272
|
+
if (token === '--profile')
|
|
273
|
+
return rest[index + 1] ?? null;
|
|
274
|
+
if (token.startsWith('--profile='))
|
|
275
|
+
return token.slice('--profile='.length);
|
|
276
|
+
}
|
|
277
|
+
for (const token of rest) {
|
|
278
|
+
if (token.startsWith('-'))
|
|
279
|
+
continue;
|
|
280
|
+
if (!isSafeEnvironmentName(token))
|
|
281
|
+
continue;
|
|
282
|
+
if (existsSync(environmentDir(token)))
|
|
283
|
+
return token;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
/** 取 --port N / --port=N。 */
|
|
288
|
+
function portOf(tokens) {
|
|
289
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
290
|
+
const token = tokens[index] ?? '';
|
|
291
|
+
const inline = /^--port=(\d+)$/.exec(token);
|
|
292
|
+
if (inline !== null)
|
|
293
|
+
return Number(inline[1]);
|
|
294
|
+
if (token === '--port') {
|
|
295
|
+
const value = tokens[index + 1] ?? '';
|
|
296
|
+
if (/^\d+$/.test(value))
|
|
297
|
+
return Number(value);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 取某个环境在这份进程扫描结果里的运行实例。
|
|
304
|
+
*
|
|
305
|
+
* map 的键来自命令行文本(大小写由那个进程自己决定),所以不能逐字查找:在大小写不
|
|
306
|
+
* 敏感的文件系统上 `DEMO` 与 `demo` 是同一个环境(审计 W-01 的同族面)。判据统一走
|
|
307
|
+
* paths.ts 的 sameEnvironment,别在各处自己写比较。
|
|
308
|
+
*
|
|
309
|
+
* @param runs - scanRuns / scanRunsNow 的结果。
|
|
310
|
+
* @param name - 环境名(调用方给的原始大小写)。
|
|
311
|
+
* @returns 运行实例列表;没有则空数组。
|
|
312
|
+
*/
|
|
313
|
+
function runsForName(runs, name) {
|
|
314
|
+
const direct = runs.get(name);
|
|
315
|
+
if (direct !== undefined)
|
|
316
|
+
return direct;
|
|
317
|
+
for (const [key, value] of runs) {
|
|
318
|
+
if (sameEnvironment(key, name))
|
|
319
|
+
return value;
|
|
320
|
+
}
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 解析一行进程表。
|
|
325
|
+
*
|
|
326
|
+
* @param line - pid 与命令行。
|
|
327
|
+
* @returns 环境名与端口;不属于任何环境实例时 null。
|
|
328
|
+
*/
|
|
329
|
+
function parseRun(line) {
|
|
330
|
+
const tokens = tokenizeCommandLine(line.command);
|
|
331
|
+
let entry = -1;
|
|
332
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
333
|
+
if (isDshEntry(tokens[index] ?? '')) {
|
|
334
|
+
entry = index;
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (entry < 0)
|
|
339
|
+
return null;
|
|
340
|
+
const rest = tokens.slice(entry + 1);
|
|
341
|
+
// dsh plugin --profile X add … 是一次性包操作:把它当实例会让 stop 在 pnpm 写
|
|
342
|
+
// manifest 的中途杀掉它。诊断类命令同理。
|
|
343
|
+
if (rest[0] === 'plugin')
|
|
344
|
+
return null;
|
|
345
|
+
for (const token of rest)
|
|
346
|
+
if (ONE_SHOT_FLAGS.has(token))
|
|
347
|
+
return null;
|
|
348
|
+
const name = profileNameOf(rest);
|
|
349
|
+
if (name === null || !isSafeEnvironmentName(name))
|
|
350
|
+
return null;
|
|
351
|
+
if (!existsSync(environmentDir(name)))
|
|
352
|
+
return null;
|
|
353
|
+
return { name, port: portOf(rest) };
|
|
354
|
+
}
|
|
355
|
+
/** Linux 快速路径:直接读 /proc,省掉一次 fork+exec(旧仓库实测 14.5ms 降到 2.5ms)。 */
|
|
356
|
+
function procProcessLines() {
|
|
357
|
+
let entries;
|
|
358
|
+
try {
|
|
359
|
+
entries = readdirSync('/proc');
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
const lines = [];
|
|
365
|
+
for (const entry of entries) {
|
|
366
|
+
if (!/^\d+$/.test(entry))
|
|
367
|
+
continue;
|
|
368
|
+
let raw;
|
|
369
|
+
try {
|
|
370
|
+
raw = readFileSync('/proc/' + entry + '/cmdline', 'utf8');
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// 进程在扫描中退出(ENOENT)或不可读(EACCES):ps 也看不到它。
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (raw.length === 0)
|
|
377
|
+
continue;
|
|
378
|
+
lines.push(entry + '\t' + raw.split('\0').join(' ').trim());
|
|
379
|
+
}
|
|
380
|
+
return lines;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* POSIX 降级路径:ps -eo pid=,args=。
|
|
384
|
+
*
|
|
385
|
+
* 与旧行为的差别(审计 W-19):失败**不再折叠成空表**——空表和「这台机器上没有实例」
|
|
386
|
+
* 在旧实现里完全不可区分,而调用方据此会说「未运行」。官方 process-inspector 的先例是
|
|
387
|
+
* 对不支持的平台直接 throw:失败必须能被区分。
|
|
388
|
+
*/
|
|
389
|
+
function psProcessLines() {
|
|
390
|
+
try {
|
|
391
|
+
return {
|
|
392
|
+
lines: execFileSync('ps', ['-eo', 'pid=,args='], {
|
|
393
|
+
encoding: 'utf8', timeout: 15_000, maxBuffer: 16 * 1024 * 1024,
|
|
394
|
+
}).split('\n'),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
return { lines: [], reason: 'ps 不可用(' + messageOf(error) + ')' };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Windows 路径:powershell CIM 查询进程表。
|
|
403
|
+
*
|
|
404
|
+
* 两处与旧行为不同:① 不再用 `-match 'dsh'` 预筛(审计 W-05:命令行里没写 dsh 的包装进程
|
|
405
|
+
* 会被漏掉,而真正的判据是 parseRun;代价是输出更大、更慢,正确性优先);
|
|
406
|
+
* ② 失败带上原因(W-19),不返回空表。
|
|
407
|
+
*/
|
|
408
|
+
function windowsProcessLines() {
|
|
409
|
+
try {
|
|
410
|
+
const script = [
|
|
411
|
+
'Get-CimInstance Win32_Process',
|
|
412
|
+
'| ForEach-Object { $_.ProcessId.ToString() + [char]9 + $_.CommandLine }',
|
|
413
|
+
].join(' ');
|
|
414
|
+
return {
|
|
415
|
+
lines: execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
416
|
+
encoding: 'utf8', timeout: 15_000, maxBuffer: 16 * 1024 * 1024,
|
|
417
|
+
stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
|
|
418
|
+
}).split(/\r?\n/),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
return { lines: [], reason: 'powershell CIM 不可用(' + messageOf(error) + '):无法读取进程表' };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* 默认进程表读取器。
|
|
427
|
+
*
|
|
428
|
+
* Linux 优先 /proc;没有 /proc 的平台(macOS、受限容器)退到 ps;Windows 走
|
|
429
|
+
* powershell。三条路产出的都是同一套 pid 加命令行文本,解析逻辑只有一份。
|
|
430
|
+
*
|
|
431
|
+
* @returns 进程表行。
|
|
432
|
+
*/
|
|
433
|
+
function defaultProcessLines() {
|
|
434
|
+
if (process.platform === 'win32')
|
|
435
|
+
return windowsProcessLines();
|
|
436
|
+
const proc = procProcessLines();
|
|
437
|
+
// proc 返回 null 只表示「这台机器没有 /proc」,那是**降级**信号,不是失败;
|
|
438
|
+
// 真正的失败(ps 也没有)由 psProcessLines 的 reason 带出来。
|
|
439
|
+
return proc === null ? psProcessLines() : { lines: proc };
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* 读某个 pid 的命令行。
|
|
443
|
+
*
|
|
444
|
+
* kill 前的复核用它:pid 可能已被回收,必须重新证明它仍是同名环境的实例。读不
|
|
445
|
+
* 到时返回 null —— 调用方必须据此放弃 kill,而不是照杀不误。
|
|
446
|
+
*
|
|
447
|
+
* @param pid - 目标进程。
|
|
448
|
+
* @returns 命令行原文;不可读时为 null。
|
|
449
|
+
*/
|
|
450
|
+
export function readProcessCommand(pid) {
|
|
451
|
+
if (process.platform !== 'win32') {
|
|
452
|
+
try {
|
|
453
|
+
const raw = readFileSync('/proc/' + pid + '/cmdline', 'utf8');
|
|
454
|
+
const joined = raw.split('\0').join(' ').trim();
|
|
455
|
+
if (joined.length > 0)
|
|
456
|
+
return joined;
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// 没有 /proc 或进程已退出:下面用 ps 再试一次。
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
const out = execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 5_000 });
|
|
463
|
+
const line = out.trim();
|
|
464
|
+
return line.length > 0 ? line : null;
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
|
|
472
|
+
'(Get-CimInstance Win32_Process -Filter "ProcessId=' + pid + '").CommandLine'], {
|
|
473
|
+
encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
|
|
474
|
+
});
|
|
475
|
+
const line = out.trim();
|
|
476
|
+
return line.length > 0 ? line : null;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* 廉价存活探针:signal 0 只在进程消失时抛 ESRCH;EPERM 说明进程还在(别人持有)。
|
|
484
|
+
*
|
|
485
|
+
* @param pid - 目标进程。
|
|
486
|
+
* @returns 是否存活。
|
|
487
|
+
*/
|
|
488
|
+
export function pidAlive(pid) {
|
|
489
|
+
try {
|
|
490
|
+
process.kill(pid, 0);
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
return error.code === 'EPERM';
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* 当前正在运行的环境名。
|
|
499
|
+
*
|
|
500
|
+
* 官方事实优先:ctx.profileContext.name(dsh 以 profile 方式启动时存在)。没有
|
|
501
|
+
* ctx 时退到 argv 解析 —— 拿不到就返回 null,调用方必须把它当「未知」,不做任何
|
|
502
|
+
* 破坏性推断。
|
|
503
|
+
*
|
|
504
|
+
* @param ctx - host 上下文;省略时走 argv 兜底。
|
|
505
|
+
* @param capabilities - 已探测的官方能力(避免重复探测)。
|
|
506
|
+
* @returns 环境名;未知时 null。
|
|
507
|
+
*/
|
|
508
|
+
function currentEnvironmentName(ctx, capabilities) {
|
|
509
|
+
if (capabilities !== undefined)
|
|
510
|
+
return capabilities.environmentName;
|
|
511
|
+
if (ctx !== undefined) {
|
|
512
|
+
try {
|
|
513
|
+
return probeOfficialCapabilities(ctx).environmentName;
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// 探针只读可选服务;宿主异常时报「未知」,不猜。
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return detectCurrentEnvironmentName();
|
|
521
|
+
}
|
|
522
|
+
/** 解析一次操作的「当前环境」。 */
|
|
523
|
+
function resolveCurrent(options) {
|
|
524
|
+
return options.current !== undefined
|
|
525
|
+
? options.current
|
|
526
|
+
: currentEnvironmentName(options.ctx, options.capabilities);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* 当前环境名(UI 与其它模块复用)。
|
|
530
|
+
*
|
|
531
|
+
* @param ctx - host 上下文。
|
|
532
|
+
* @returns 环境名;未知时 null。
|
|
533
|
+
*/
|
|
534
|
+
export function currentEnvironment(ctx) {
|
|
535
|
+
return currentEnvironmentName(ctx);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* 列出 $DSH_HOME/profiles 下的环境及其只读事实。
|
|
539
|
+
*
|
|
540
|
+
* @param ctx - host 上下文;用于取官方认定的「当前环境」。
|
|
541
|
+
* @param options - 注入选项。
|
|
542
|
+
* @returns 按名称排序的环境列表。
|
|
543
|
+
*/
|
|
544
|
+
export function listEnvironments(ctx, options = {}) {
|
|
545
|
+
const current = options.current !== undefined
|
|
546
|
+
? options.current
|
|
547
|
+
: currentEnvironmentName(ctx ?? options.ctx, options.capabilities);
|
|
548
|
+
// 进程事实与 manifest 是两个来源(见 types.ts 的注释):可读性要一并带出去,
|
|
549
|
+
// 否则界面会把「读不到」渲染成「未运行」——那正是审计 W-19 的形态。
|
|
550
|
+
const facts = options.facts
|
|
551
|
+
?? (options.runs === undefined ? processFacts() : { runs: options.runs, readable: true });
|
|
552
|
+
const out = [];
|
|
553
|
+
let entries;
|
|
554
|
+
try {
|
|
555
|
+
entries = readdirSync(profilesRoot());
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// profiles 根还不存在:没有环境,不是错误。
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
for (const name of entries) {
|
|
562
|
+
// node_modules 是安装时的共享回退位置,不是环境。
|
|
563
|
+
if (name === 'node_modules' || !isSafeEnvironmentName(name))
|
|
564
|
+
continue;
|
|
565
|
+
const dir = environmentDir(name);
|
|
566
|
+
if (!isDirectory(dir))
|
|
567
|
+
continue;
|
|
568
|
+
if (!existsSync(join(dir, 'package.json')))
|
|
569
|
+
continue;
|
|
570
|
+
const manifest = readEnvironmentManifest(dir);
|
|
571
|
+
out.push({
|
|
572
|
+
name,
|
|
573
|
+
dir,
|
|
574
|
+
// 这台机器上是否同一个目录(win32/darwin 大小写不敏感),不是字符串是否逐字相等。
|
|
575
|
+
current: sameEnvironment(name, current),
|
|
576
|
+
builtin: isBuiltinEnvironment(name),
|
|
577
|
+
bundles: manifest.bundles,
|
|
578
|
+
// 读不懂 manifest 时**不能**让调用方把空数组当事实:把未知按字段如实带出去。
|
|
579
|
+
...(manifest.unknownFields === undefined ? {} : {
|
|
580
|
+
unknownFields: manifest.unknownFields,
|
|
581
|
+
...(manifest.unknownReason === undefined ? {} : { unknownReason: manifest.unknownReason }),
|
|
582
|
+
// 层栈是界面上唯一直接渲染的派生字段,给它一个派生谓词(等价于 includes('bundles'))。
|
|
583
|
+
bundlesKnown: !manifest.unknownFields.includes('bundles'),
|
|
584
|
+
}),
|
|
585
|
+
dependencies: manifest.dependencies,
|
|
586
|
+
runs: runsForName(facts.runs, name),
|
|
587
|
+
// 进程事实读不到时**不能**让调用方把空数组当「未运行」:把未知如实带出去。
|
|
588
|
+
...(facts.readable ? {} : {
|
|
589
|
+
runsKnown: false,
|
|
590
|
+
...(facts.reason === undefined ? {} : { runsUnknownReason: facts.reason }),
|
|
591
|
+
}),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return out.sort((left, right) => left.name.localeCompare(right.name));
|
|
595
|
+
}
|
|
596
|
+
function isDirectory(path) {
|
|
597
|
+
try {
|
|
598
|
+
return statSync(path).isDirectory();
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* 可用的环境模板。
|
|
606
|
+
*
|
|
607
|
+
* @returns 官方模板名与它们的 bundle 清单(本仓库不维护任何 bundle 名单)。
|
|
608
|
+
*/
|
|
609
|
+
export function environmentTemplates() {
|
|
610
|
+
return Object.entries(PROFILE_TEMPLATES).map(([name, template]) => ({ name, bundles: [...template.bundles] }));
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* 官方 web 模板相对官方默认层栈多出来的那几层 —— 也就是「能提供 web 面」的包。
|
|
614
|
+
*
|
|
615
|
+
* 派生自官方常量(PROFILE_TEMPLATES / DEFAULT_PROFILE_BUNDLES):官方改了模板,
|
|
616
|
+
* 这里跟着变,不需要改代码,也不需要我们维护一份会漂移的名字表。
|
|
617
|
+
*
|
|
618
|
+
* @returns 官方 web app 层的包名;官方常量里没有该模板时返回空数组。
|
|
619
|
+
*/
|
|
620
|
+
export function officialWebAppBundles() {
|
|
621
|
+
const template = PROFILE_TEMPLATES[DEFAULT_ENVIRONMENT_TEMPLATE];
|
|
622
|
+
if (template === undefined)
|
|
623
|
+
return [];
|
|
624
|
+
return template.bundles.filter((name) => !DEFAULT_PROFILE_BUNDLES.includes(name));
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* 判断一个环境的层栈里有没有能提供 web 服务的层。
|
|
628
|
+
*
|
|
629
|
+
* 两条官方事实,先静态后动态:
|
|
630
|
+
* 1. 层名出现在官方 web 模板的 app 层里(官方常量派生,覆盖官方模板建的层栈);
|
|
631
|
+
* 2. 逐层用官方 resolveBundleDir 找到包目录、读它的 manifest:依赖或 peer 里出现
|
|
632
|
+
* WEB_SERVER_PACKAGE 的层就是 web 层(这条能认出官方模板之外的 web bundle)。
|
|
633
|
+
*
|
|
634
|
+
* 只有「每一层都能解析、且都不满足上面两条」才敢说 absent —— 任何一层的事实拿不到
|
|
635
|
+
* 就返回 unknown,调用方据此降级为「无法预判,仍按就绪探测等待」,绝不预判成 absent
|
|
636
|
+
* 去拒绝一个可能能跑的环境。
|
|
637
|
+
*
|
|
638
|
+
* @param dir - 环境目录。
|
|
639
|
+
* @param bundles - 该环境的 bundle 层栈。
|
|
640
|
+
* @param installAnchor - 官方安装锚点;省略时用该环境自己的 manifest 作解析锚点(官方第二锚点)。
|
|
641
|
+
* @returns 判定结果。
|
|
642
|
+
*/
|
|
643
|
+
export function environmentWebLayer(dir, bundles, installAnchor) {
|
|
644
|
+
// 层栈读不出来(manifest 损坏):没有任何可依据的事实。
|
|
645
|
+
if (bundles.length === 0)
|
|
646
|
+
return 'unknown';
|
|
647
|
+
const webApps = officialWebAppBundles();
|
|
648
|
+
if (bundles.some((name) => webApps.includes(name)))
|
|
649
|
+
return 'present';
|
|
650
|
+
const anchor = installAnchor ?? join(dir, 'package.json');
|
|
651
|
+
let allResolved = true;
|
|
652
|
+
for (const name of bundles) {
|
|
653
|
+
let bundleDir;
|
|
654
|
+
try {
|
|
655
|
+
bundleDir = resolveBundleDir(OUR_PACKAGE_NAME, name, anchor, dir);
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// 这一层装没装、装的是什么都读不到:不能拿它当证据。
|
|
659
|
+
allResolved = false;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const manifest = readProfileManifest(OUR_PACKAGE_NAME, bundleDir);
|
|
664
|
+
const declared = { ...manifest.dependencies, ...manifest.peerDependencies };
|
|
665
|
+
if (Object.keys(declared).includes(WEB_SERVER_PACKAGE))
|
|
666
|
+
return 'present';
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
allResolved = false;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return allResolved ? 'absent' : 'unknown';
|
|
673
|
+
}
|
|
674
|
+
// ── 创建 / 重命名 / 删除 ──────────────────────────────────────────────────
|
|
675
|
+
/** Windows 保留设备名:这些名字当目录会在写入时报原始 EINVAL。 */
|
|
676
|
+
const WINDOWS_RESERVED = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
|
677
|
+
/**
|
|
678
|
+
* 环境名的完整校验(含平台的额外约束)。
|
|
679
|
+
*
|
|
680
|
+
* @param name - 待校验的环境名。
|
|
681
|
+
* @returns 拒绝原因;合法时为 null。
|
|
682
|
+
*/
|
|
683
|
+
export function environmentNameProblem(name) {
|
|
684
|
+
if (!isSafeEnvironmentName(name)) {
|
|
685
|
+
return '环境名不合法:' + JSON.stringify(name) + '(只允许字母、数字、.、_、-,且不能是 . 或 ..)';
|
|
686
|
+
}
|
|
687
|
+
if (process.platform === 'win32') {
|
|
688
|
+
const base = name.replace(/\.+$/, '');
|
|
689
|
+
if (WINDOWS_RESERVED.test(base) || /[\s.]$/.test(name))
|
|
690
|
+
return name + ' 是 Windows 保留名,无法作为目录';
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* 创建一个环境(骨架由官方 initProfile 写:manifest、空 patch 层、pnpm 设置)。
|
|
696
|
+
*
|
|
697
|
+
* 模板只接受官方 PROFILE_TEMPLATES 的名字;不存在的模板名是配置错误,直接失败,
|
|
698
|
+
* 而不是悄悄换成别的 bundle 列表。
|
|
699
|
+
*
|
|
700
|
+
* 省略模板时用 DEFAULT_ENVIRONMENT_TEMPLATE(官方 web 模板),**不是**官方
|
|
701
|
+
* DEFAULT_PROFILE_BUNDLES:后者只有 base、没有任何 app,建出来的环境必然起不来
|
|
702
|
+
* (实测 startEnvironment 干等 30s 超时)。
|
|
703
|
+
*
|
|
704
|
+
* @param name - 新环境名。
|
|
705
|
+
* @param template - 官方模板名;省略时用官方 web 模板。
|
|
706
|
+
* @returns 操作结果。
|
|
707
|
+
*/
|
|
708
|
+
export async function createEnvironment(name, template) {
|
|
709
|
+
const problem = environmentNameProblem(name);
|
|
710
|
+
if (problem !== null)
|
|
711
|
+
return failure('invalid-name', problem);
|
|
712
|
+
if (isBuiltinEnvironment(name))
|
|
713
|
+
return failure('builtin', name + ' 是官方内置环境,不能重新创建');
|
|
714
|
+
const wanted = template === undefined || template.length === 0 ? DEFAULT_ENVIRONMENT_TEMPLATE : template;
|
|
715
|
+
const selected = PROFILE_TEMPLATES[wanted];
|
|
716
|
+
if (selected === undefined) {
|
|
717
|
+
return failure('unknown-template', '未知模板 ' + JSON.stringify(wanted) + ';可用模板:'
|
|
718
|
+
+ Object.keys(PROFILE_TEMPLATES).join(', '));
|
|
719
|
+
}
|
|
720
|
+
const bundles = [...selected.bundles];
|
|
721
|
+
const dir = environmentDir(name);
|
|
722
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
723
|
+
return failure('already-exists', '环境已存在:' + name + '(' + dir + ')');
|
|
724
|
+
}
|
|
725
|
+
return enqueueMutation(async () => {
|
|
726
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
727
|
+
return failure('already-exists', '环境已存在:' + name + '(' + dir + ')');
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
// 官方两参签名(0.1.6 起去掉了 patchReload);已存在的文件不会被覆盖。
|
|
731
|
+
initProfile(dir, bundles);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
return failure('io-failed', '创建环境失败 ' + name + '(' + dir + '):' + messageOf(error));
|
|
735
|
+
}
|
|
736
|
+
// 目录不写:环境卡片每一行都渲染 dir(ConsolePage 的环境行),这里是①(重复屏幕已有信息)。
|
|
737
|
+
// 层栈名字必须留:卡片只显示「N 个组合包」计数,名字在别处看不见。
|
|
738
|
+
return success('已创建环境 ' + name + '\n组合层:\n ' + bundles.join('\n '));
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* 重命名一个环境目录。拒绝内置环境、当前环境与运行中的环境。
|
|
743
|
+
*
|
|
744
|
+
* @param from - 原名。
|
|
745
|
+
* @param to - 新名。
|
|
746
|
+
* @param options - 当前环境事实。
|
|
747
|
+
* @returns 操作结果。
|
|
748
|
+
*/
|
|
749
|
+
export async function renameEnvironment(from, to, options = {}) {
|
|
750
|
+
for (const name of [from, to]) {
|
|
751
|
+
const problem = environmentNameProblem(name);
|
|
752
|
+
if (problem !== null)
|
|
753
|
+
return failure('invalid-name', problem);
|
|
754
|
+
if (isBuiltinEnvironment(name))
|
|
755
|
+
return failure('builtin', name + ' 是官方内置环境,不能重命名');
|
|
756
|
+
}
|
|
757
|
+
const sourceDir = environmentDir(from);
|
|
758
|
+
const targetDir = environmentDir(to);
|
|
759
|
+
if (!existsSync(join(sourceDir, 'package.json')))
|
|
760
|
+
return failure('not-found', '环境不存在:' + from);
|
|
761
|
+
// 源与目标在这台机器上是同一个目录(win32/darwin 大小写不敏感)→ 先说清楚,别落到含糊的 already-exists。
|
|
762
|
+
if (sameEnvironment(from, to)) {
|
|
763
|
+
return failure('invalid-name', from + ' 与 ' + to + ' 在这台机器上是同一个环境(文件系统大小写不敏感),无需重命名');
|
|
764
|
+
}
|
|
765
|
+
if (existsSync(targetDir))
|
|
766
|
+
return failure('already-exists', '目标环境已存在:' + to);
|
|
767
|
+
const current = resolveCurrent(options);
|
|
768
|
+
if (sameEnvironment(current, from) || sameEnvironment(current, to)) {
|
|
769
|
+
return failure('current', current + ' 是当前正在运行的环境,不能重命名');
|
|
770
|
+
}
|
|
771
|
+
// 改名不可逆:不拿 3s 陈旧缓存当依据,用即时读取,并要求事实**可读**。
|
|
772
|
+
const facts = processFacts({ fresh: true });
|
|
773
|
+
if (!facts.readable) {
|
|
774
|
+
return failure('facts-unavailable', from + ' 的运行状态未知:进程事实读不到('
|
|
775
|
+
+ String(facts.reason) + ')。拒绝在未知状态下重命名环境。');
|
|
776
|
+
}
|
|
777
|
+
const busy = runsForName(facts.runs, from);
|
|
778
|
+
if (busy.length > 0) {
|
|
779
|
+
return failure('running', from + ' 正在运行(pid ' + busy.map((run) => run.pid).join(', ') + '),请先停止再重命名');
|
|
780
|
+
}
|
|
781
|
+
return enqueueMutation(async () => {
|
|
782
|
+
try {
|
|
783
|
+
await retryFs(() => renameSync(sourceDir, targetDir));
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
return failure('io-failed', '重命名失败 ' + from + ' -> ' + to + ':' + messageOf(error));
|
|
787
|
+
}
|
|
788
|
+
resetRunCache();
|
|
789
|
+
return success('已重命名 ' + from + ' -> ' + to);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* 删除一个环境目录。拒绝内置环境、当前环境与运行中的环境。
|
|
794
|
+
*
|
|
795
|
+
* @param name - 环境名。
|
|
796
|
+
* @param options - 当前环境事实。
|
|
797
|
+
* @returns 操作结果。
|
|
798
|
+
*/
|
|
799
|
+
export async function removeEnvironment(name, options = {}) {
|
|
800
|
+
const problem = environmentNameProblem(name);
|
|
801
|
+
if (problem !== null)
|
|
802
|
+
return failure('invalid-name', problem);
|
|
803
|
+
if (isBuiltinEnvironment(name))
|
|
804
|
+
return failure('builtin', name + ' 是官方内置环境,不能删除');
|
|
805
|
+
const dir = environmentDir(name);
|
|
806
|
+
if (!existsSync(dir))
|
|
807
|
+
return failure('not-found', '环境不存在:' + name);
|
|
808
|
+
const current = resolveCurrent(options);
|
|
809
|
+
// 用 sameEnvironment 而不是 ===:大小写不敏感的文件系统上(Windows/macOS)`WEB` 与 `web`
|
|
810
|
+
// 是同一个目录,逐字比较会让护栏被非规范大小写绕过——那一步的后果是不可逆的。
|
|
811
|
+
if (sameEnvironment(current, name)) {
|
|
812
|
+
return failure('current', name + ' 是当前正在运行的环境,不能删除(要删请先停止本进程)');
|
|
813
|
+
}
|
|
814
|
+
// 删除不可逆:不拿 3s 陈旧缓存当依据,用即时读取,并要求事实**可读**。
|
|
815
|
+
const facts = processFacts({ fresh: true });
|
|
816
|
+
if (!facts.readable) {
|
|
817
|
+
return failure('facts-unavailable', name + ' 的运行状态未知:进程事实读不到('
|
|
818
|
+
+ String(facts.reason) + ')。拒绝在未知状态下删除环境。');
|
|
819
|
+
}
|
|
820
|
+
const busy = runsForName(facts.runs, name);
|
|
821
|
+
if (busy.length > 0) {
|
|
822
|
+
return failure('running', name + ' 正在运行(pid ' + busy.map((run) => run.pid).join(', ') + '),请先停止再删除');
|
|
823
|
+
}
|
|
824
|
+
return enqueueMutation(async () => {
|
|
825
|
+
try {
|
|
826
|
+
await retryFs(() => rmSync(dir, { recursive: true, force: true }));
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
return failure('io-failed', '删除失败 ' + name + ':' + messageOf(error));
|
|
830
|
+
}
|
|
831
|
+
resetRunCache();
|
|
832
|
+
return success('已删除环境 ' + name);
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* 启动一个环境的实例。
|
|
837
|
+
*
|
|
838
|
+
* 四道关,越靠前越便宜:
|
|
839
|
+
* 1. 环境存在、不在运行中;
|
|
840
|
+
* 2. **web 层预检**:层栈里没有任何 web 服务层就立刻拒绝(实测 base-only 环境要干等
|
|
841
|
+
* 30s 才超时,用户拿到的是「端口未就绪」这种没法行动的信息);
|
|
842
|
+
* 3. 选空闲端口,按 mode 在终端窗口或后台启动;
|
|
843
|
+
* 4. 等**官方 HTTP 端点应答**(不是等 TCP 可连接 —— 实测 TCP 通了那一刻 GET / 还是
|
|
844
|
+
* 404,1000ms 后才是 401,提前报成功会把不可用的 url 交出去)。
|
|
845
|
+
*
|
|
846
|
+
* @param name - 环境名。
|
|
847
|
+
* @param options - 启动选项。
|
|
848
|
+
* @returns 操作结果;成功时 output 含实际启动方式与可用地址(带 token,见 startedMessage)。
|
|
849
|
+
*/
|
|
850
|
+
export async function startEnvironment(name, options = {}) {
|
|
851
|
+
const problem = environmentNameProblem(name);
|
|
852
|
+
if (problem !== null)
|
|
853
|
+
return failure('invalid-name', problem);
|
|
854
|
+
const dir = environmentDir(name);
|
|
855
|
+
if (!existsSync(join(dir, 'package.json')))
|
|
856
|
+
return failure('not-found', '环境不存在:' + name);
|
|
857
|
+
const facts = processFacts();
|
|
858
|
+
// 不可读时**不**假装「没在运行」:照常允许启动(启动不是破坏性操作),但把事实如实带出去。
|
|
859
|
+
const factsNote = facts.readable ? '' : '\n注意:进程表不可读(' + String(facts.reason) + '),本次未做重复实例检查';
|
|
860
|
+
const running = runsForName(facts.runs, name);
|
|
861
|
+
if (running.length > 0) {
|
|
862
|
+
const ports = running.map((run) => run.port).filter((port) => port !== null);
|
|
863
|
+
return failure('running', name + ' 已经在运行'
|
|
864
|
+
+ (ports.length > 0 ? '(端口 ' + ports.join(', ') + ')' : '(pid ' + running.map((run) => run.pid).join(', ') + ')')
|
|
865
|
+
+ ',请先停止它');
|
|
866
|
+
}
|
|
867
|
+
// 层栈事实来自该环境自己的 manifest;web 层判定见 environmentWebLayer。
|
|
868
|
+
const layers = readEnvironmentManifest(dir).bundles;
|
|
869
|
+
const installAnchor = options.installAnchor ?? profileContextOf(options.ctx)?.installAnchor;
|
|
870
|
+
if (environmentWebLayer(dir, layers, installAnchor) === 'absent') {
|
|
871
|
+
return failure('no-web-layer', noWebLayerMessage(name, layers));
|
|
872
|
+
}
|
|
873
|
+
const start = options.portStart ?? DEFAULT_WEB_PORT;
|
|
874
|
+
const port = options.port ?? await findFreePort(start);
|
|
875
|
+
if (port === null)
|
|
876
|
+
return failure('io-failed', '从 ' + String(start) + ' 起的 200 个端口内没有空闲端口');
|
|
877
|
+
// 显式端口是调用方指定的:已经被监听就根本不该进入「等就绪」流程 —— HTTP 探针
|
|
878
|
+
// 无法判断应答来自谁,会把别人的实例当成我们刚启动的那个报 ok。
|
|
879
|
+
if (options.port !== undefined && await tcpListening(port)) {
|
|
880
|
+
// 中段是"为什么不发起启动"的原因(copy-dev 复核修正:删了用户不知道缘由),必须留。
|
|
881
|
+
return failure('port-in-use', '端口 ' + String(port) + ' 已经被监听:无法确认它会由本次启动的实例接管,'
|
|
882
|
+
+ '所以不发起启动。请换一个端口,或先停掉占用它的进程。');
|
|
883
|
+
}
|
|
884
|
+
const spec = launchSpec(name, dir, port, options);
|
|
885
|
+
let outcome = await (options.launch ?? defaultLaunch)(spec);
|
|
886
|
+
if (!outcome.ok)
|
|
887
|
+
return failure('launch-failed', outcome.detail);
|
|
888
|
+
let status = await waitForReady(port, options);
|
|
889
|
+
let retryNote = '';
|
|
890
|
+
if (status === null && outcome.mode === 'terminal') {
|
|
891
|
+
// 审计 W-09:终端窗口内的失败是**异步**的(窗口里的报错、没有 wt、无桌面会话),
|
|
892
|
+
// spawn 不抛,所以第一段就绪失败不能当作结论。但也不能在「第一次其实起来了、只是慢」
|
|
893
|
+
// 时再起一个(同端口会打架)—— 只在端口根本没人监听时才回退后台重试。
|
|
894
|
+
if (!await tcpListening(port)) {
|
|
895
|
+
// 走同一个启动器(可注入),只是换成后台形态 —— 这样调用方/测试只需要注入一次。
|
|
896
|
+
const retrySpec = backgroundSpec({ ...spec, mode: 'background' });
|
|
897
|
+
const fallback = await (options.launch ?? defaultLaunch)(retrySpec);
|
|
898
|
+
retryNote = '\n终端窗口尝试 ' + String(options.readyTimeoutMs ?? START_READY_TIMEOUT_MS)
|
|
899
|
+
+ 'ms 未就绪,已改为后台启动重试';
|
|
900
|
+
if (!fallback.ok)
|
|
901
|
+
return failure('launch-failed', fallback.detail + retryNote);
|
|
902
|
+
outcome = fallback;
|
|
903
|
+
status = await waitForReady(port, options);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (status === null) {
|
|
907
|
+
return failure('timeout', startTimeoutMessage(name, port, spec, outcome, options) + retryNote + factsNote);
|
|
908
|
+
}
|
|
909
|
+
// 新实例立刻可见:丢弃陈旧缓存。
|
|
910
|
+
resetRunCache();
|
|
911
|
+
// 回退过一次的话,成功文案也要说清楚(用户需要知道第一次为什么没成)。
|
|
912
|
+
return success(startedMessage(name, port, status, spec, outcome) + retryNote + factsNote);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* 没有 web 层时的可操作拒绝文案。
|
|
916
|
+
*
|
|
917
|
+
* 两个可选动作都是用户能立刻执行的,且包名来自官方常量(officialWebAppBundles),
|
|
918
|
+
* 不是我们抄的名字表。
|
|
919
|
+
*
|
|
920
|
+
* @param name - 环境名。
|
|
921
|
+
* @param layers - 该环境当前的 bundle 层栈。
|
|
922
|
+
* @returns 面向用户的说明。
|
|
923
|
+
*/
|
|
924
|
+
function noWebLayerMessage(name, layers) {
|
|
925
|
+
const suggestion = officialWebAppBundles();
|
|
926
|
+
return name + ' 的组合层里没有任何能提供 web 服务的层,启动它不会得到可访问的网页。\n'
|
|
927
|
+
+ '当前组合层:' + (layers.length === 0 ? '(空)' : layers.join(', ')) + '\n'
|
|
928
|
+
+ '请二选一:\n'
|
|
929
|
+
+ ' 1. 在官方插件页给它启用 web 层('
|
|
930
|
+
+ (suggestion.length === 0 ? '官方 web 模板里的 app 层' : suggestion.join(', ')) + ');\n'
|
|
931
|
+
+ ' 2. 用官方 web 模板重建一个环境。\n'
|
|
932
|
+
+ '本次没有发起启动';
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* 启动成功文案:启动方式、可用地址、日志都如实说明。
|
|
936
|
+
*
|
|
937
|
+
* 关键:**不带 token 的地址不是可点开的入口**。官方 browser-auth 会 401,正文让用户
|
|
938
|
+
* 「reopen the URL printed by dsh web」。所以只有从官方输出里读到带 token 的地址时才
|
|
939
|
+
* 把它作为可用地址给出;否则明确告诉用户去哪里拿。
|
|
940
|
+
*
|
|
941
|
+
* @param name - 环境名。
|
|
942
|
+
* @param port - 实例端口。
|
|
943
|
+
* @param status - 就绪时的 HTTP 状态码。
|
|
944
|
+
* @param spec - 启动描述。
|
|
945
|
+
* @param outcome - 启动结果。
|
|
946
|
+
* @returns 面向用户的多行说明。
|
|
947
|
+
*/
|
|
948
|
+
function startedMessage(name, port, status, spec, outcome) {
|
|
949
|
+
// 回执(已启动 X)**必须留**:卡片上的运行标记是异步的(进程表扫描 + 3s 缓存,甚至可能显示"未知"),
|
|
950
|
+
// 不能替代"你刚点的这一下成功了"这个即时事实(copy-dev 复核结论)。启动方式与降级原因同样必须留。
|
|
951
|
+
const lines = ['已启动 ' + name
|
|
952
|
+
+ '(启动方式:' + (outcome.mode === 'terminal' ? '终端窗口 ' + (outcome.terminal ?? '') : '后台')
|
|
953
|
+
+ (outcome.reason === undefined ? '' : ';' + outcome.reason)
|
|
954
|
+
+ ')'
|
|
955
|
+
+ ' 官方 HTTP 已应答:GET / -> ' + String(status)];
|
|
956
|
+
const authenticated = outcome.logPath === undefined ? null : authenticatedUrlFromLog(outcome.logPath);
|
|
957
|
+
if (authenticated !== null) {
|
|
958
|
+
lines.push('可用地址:' + authenticated);
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
lines.push('注意:http://127.0.0.1:' + String(port) + '/ 不带 token 会被官方 browser-auth 拒绝(401)。');
|
|
962
|
+
lines.push(outcome.mode === 'terminal'
|
|
963
|
+
? '可用地址只在刚打开的终端窗口里由 dsh 打印(形如 dsh web: http://127.0.0.1:' + String(port) + '/?token=...),请从那里复制。'
|
|
964
|
+
: '官方输出里没有读到带 token 的地址,请查看日志');
|
|
965
|
+
}
|
|
966
|
+
if (outcome.logPath !== undefined)
|
|
967
|
+
lines.push('日志:' + outcome.logPath);
|
|
968
|
+
lines.push('命令:' + spec.display);
|
|
969
|
+
return lines.join('\n');
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* 启动超时文案:带上官方输出的尾巴。
|
|
973
|
+
*
|
|
974
|
+
* 不再是「请看启动窗口的输出」——终端模式下等于把用户推到看不见的窗口,后台模式下
|
|
975
|
+
* 更没有窗口可看。日志尾巴是我们手上最具体的事实。
|
|
976
|
+
*
|
|
977
|
+
* @param name - 环境名。
|
|
978
|
+
* @param port - 实例端口。
|
|
979
|
+
* @param spec - 启动描述。
|
|
980
|
+
* @param outcome - 启动结果。
|
|
981
|
+
* @param options - 启动选项(取超时上限)。
|
|
982
|
+
* @returns 面向用户的多行说明。
|
|
983
|
+
*/
|
|
984
|
+
function startTimeoutMessage(name, port, spec, outcome, options) {
|
|
985
|
+
const lines = [name + ' 已启动,但 ' + String(options.readyTimeoutMs ?? START_READY_TIMEOUT_MS)
|
|
986
|
+
+ 'ms 内端口 ' + String(port) + ' 没有给出官方 web 应答(就绪判据:GET / 返回 200/303/401)。'
|
|
987
|
+
+ (outcome.reason === undefined ? '' : '\n启动方式:' + (outcome.mode === 'terminal' ? '终端窗口' : '后台') + '(' + outcome.reason + ')')];
|
|
988
|
+
if (outcome.logPath === undefined) {
|
|
989
|
+
lines.push('请看刚打开的终端窗口里 dsh 的输出');
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
const tail = tailOfLog(outcome.logPath, 20);
|
|
993
|
+
lines.push('日志:' + outcome.logPath);
|
|
994
|
+
// 独立复验 N-04:系统本地化文本按控制台代码页写入,按 utf8 读会出替换字符;
|
|
995
|
+
// 那就如实标注,别让乱码冒充可读信息。
|
|
996
|
+
if (tail.undecodable)
|
|
997
|
+
lines.push('(尾部包含按控制台代码页写入的系统文本,无法按 UTF-8 解码,下面以替换字符显示)');
|
|
998
|
+
lines.push(tail.lines.length === 0 ? '(日志还是空的)' : tail.lines.join('\n'));
|
|
999
|
+
}
|
|
1000
|
+
lines.push('命令:' + spec.display);
|
|
1001
|
+
return lines.join('\n');
|
|
1002
|
+
}
|
|
1003
|
+
/** 组装一次启动:入口、参数与展示用命令行。 */
|
|
1004
|
+
function launchSpec(name, dir, port, options) {
|
|
1005
|
+
const entryPoint = dshEntryPoint();
|
|
1006
|
+
const args = [...entryPoint.args, '--profile', name, '--port', String(port), ...options.extraArgs ?? []];
|
|
1007
|
+
return {
|
|
1008
|
+
profile: name,
|
|
1009
|
+
port,
|
|
1010
|
+
mode: options.mode ?? 'terminal',
|
|
1011
|
+
command: entryPoint.command,
|
|
1012
|
+
args,
|
|
1013
|
+
entry: entryPoint.entry,
|
|
1014
|
+
shell: entryPoint.shell,
|
|
1015
|
+
dir,
|
|
1016
|
+
display: [entryPoint.command, ...args].join(' '),
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* 被启动实例的入口。
|
|
1021
|
+
*
|
|
1022
|
+
* 优先复用本进程自己所属的安装:process.argv[1] 就是当前 dsh 的入口脚本,node 是
|
|
1023
|
+
* process.execPath —— 同一个安装、同一个版本,不依赖 PATH(旧实现靠 PATH 找 dsh,
|
|
1024
|
+
* 在 nvm 未加载的终端里会失败)。
|
|
1025
|
+
*
|
|
1026
|
+
* @returns 命令、前置参数与入口脚本路径。
|
|
1027
|
+
*/
|
|
1028
|
+
/**
|
|
1029
|
+
* 被启动实例的入口(可注入 argv/platform,供测试)。
|
|
1030
|
+
*
|
|
1031
|
+
* @param argv - 进程参数;默认 process.argv。
|
|
1032
|
+
* @param platform - 平台;默认 process.platform。
|
|
1033
|
+
* @returns 命令、前置参数、入口脚本与是否需要 shell。
|
|
1034
|
+
*/
|
|
1035
|
+
export function dshEntryPoint(argv = process.argv, platform = process.platform) {
|
|
1036
|
+
const entry = argv[1];
|
|
1037
|
+
if (entry !== undefined && /(?:^|[\\/])bin\.(?:js|cjs|mjs)$/.test(entry)
|
|
1038
|
+
&& /[\\/]@deepseek-ai[\\/]dsh(?:[\\/]|$)/.test(entry)) {
|
|
1039
|
+
return { command: process.execPath, args: [entry], entry, shell: false };
|
|
1040
|
+
}
|
|
1041
|
+
if (platform === 'win32') {
|
|
1042
|
+
// Windows 的 PATH shim 是 .cmd 批处理:必须交给 cmd.exe 执行(W-07)。
|
|
1043
|
+
return { command: 'dsh.cmd', args: [], entry: null, shell: true };
|
|
1044
|
+
}
|
|
1045
|
+
return { command: 'dsh', args: [], entry: null, shell: false };
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* 等到官方 HTTP 端点应答为止。
|
|
1049
|
+
*
|
|
1050
|
+
* 判据只有一条:GET / 返回 READY_HTTP_STATUSES 里的状态。
|
|
1051
|
+
*
|
|
1052
|
+
* 为什么不看「TCP 可连接」——现场证据(本机实测,官方 web 模板环境):
|
|
1053
|
+
* 首个 HTTP 应答 628ms,状态码 404(连接已通、路由还没注册);
|
|
1054
|
+
* 首个官方就绪应答 838ms,状态码 401;
|
|
1055
|
+
* 404 窗口约 210ms:这 210ms 里 TCP 判据会报 ok,并把一个 404 的地址交给用户。
|
|
1056
|
+
* 就绪报出的时刻由实例决定,判据本身不拖慢启动:同一台机器上单次探测 0.6ms 拿到 401。
|
|
1057
|
+
*
|
|
1058
|
+
* @param port - 实例端口。
|
|
1059
|
+
* @param options - 启动选项(注入探测/时钟/等待与超时上限)。
|
|
1060
|
+
* @returns 就绪时的状态码;超时返回 null。
|
|
1061
|
+
*/
|
|
1062
|
+
async function waitForReady(port, options) {
|
|
1063
|
+
const probe = options.probe ?? httpStatus;
|
|
1064
|
+
const now = options.now ?? Date.now;
|
|
1065
|
+
const sleep = options.sleep ?? ((ms) => new Promise((done) => { setTimeout(done, ms); }));
|
|
1066
|
+
const deadline = now() + (options.readyTimeoutMs ?? START_READY_TIMEOUT_MS);
|
|
1067
|
+
for (;;) {
|
|
1068
|
+
const status = await probe(port);
|
|
1069
|
+
if (status !== null && READY_HTTP_STATUSES.includes(status))
|
|
1070
|
+
return status;
|
|
1071
|
+
const left = deadline - now();
|
|
1072
|
+
if (left <= 0)
|
|
1073
|
+
return null;
|
|
1074
|
+
await sleep(Math.min(READY_POLL_MS, left));
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* 对 GET / 发一次真实 HTTP 请求,返回状态码。
|
|
1079
|
+
*
|
|
1080
|
+
* 用 node:http 直接请求,不起探测服务、不跟随重定向(303 本身就是「已就绪」的证据)。
|
|
1081
|
+
*
|
|
1082
|
+
* @param port - 实例端口。
|
|
1083
|
+
* @returns 状态码;没有应答(连接被拒、超时)时 null。
|
|
1084
|
+
*/
|
|
1085
|
+
function httpStatus(port) {
|
|
1086
|
+
return new Promise((done) => {
|
|
1087
|
+
const request = httpRequest({ host: '127.0.0.1', port, path: '/', method: 'GET', timeout: READY_HTTP_TIMEOUT_MS }, (response) => {
|
|
1088
|
+
const status = response.statusCode ?? null;
|
|
1089
|
+
response.resume();
|
|
1090
|
+
done(status);
|
|
1091
|
+
});
|
|
1092
|
+
// 被防火墙静默丢弃的连接会让 promise 永远不落地,就绪循环要给它上限。
|
|
1093
|
+
request.once('timeout', () => { request.destroy(); done(null); });
|
|
1094
|
+
request.once('error', () => done(null));
|
|
1095
|
+
request.end();
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* 端口是否已经被监听。
|
|
1100
|
+
*
|
|
1101
|
+
* 只在调用方显式指定端口时用于启动前的归属检查:TCP 一连上就说明有人占着它。
|
|
1102
|
+
* 这里刻意不做 HTTP 判断 —— 要回答的是「这个端口是不是空的」,不是「它是否已可用」。
|
|
1103
|
+
*
|
|
1104
|
+
* @param port - 目标端口。
|
|
1105
|
+
* @returns 已被监听时 true。
|
|
1106
|
+
*/
|
|
1107
|
+
function tcpListening(port) {
|
|
1108
|
+
return new Promise((done) => {
|
|
1109
|
+
const socket = connect(port, '127.0.0.1');
|
|
1110
|
+
const settle = (listening) => { socket.destroy(); done(listening); };
|
|
1111
|
+
// 防火墙静默丢弃的 SYN 会让 promise 永远不落地,给这次归属检查一个上限。
|
|
1112
|
+
socket.setTimeout(1_000, () => settle(false));
|
|
1113
|
+
socket.once('connect', () => settle(true));
|
|
1114
|
+
socket.once('error', () => settle(false));
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* 后台启动的日志路径。
|
|
1119
|
+
*
|
|
1120
|
+
* 与官方 operations 的日志同构(<环境>/.plugin-manager/logs/...),放在环境目录里
|
|
1121
|
+
* 而不是 /tmp:环境删了日志跟着走,用户找得到。
|
|
1122
|
+
*
|
|
1123
|
+
* @param dir - 环境目录。
|
|
1124
|
+
* @param kind - 日志类别(start)。
|
|
1125
|
+
* @returns 日志文件的绝对路径。
|
|
1126
|
+
*/
|
|
1127
|
+
function operationLogPath(dir, kind) {
|
|
1128
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1129
|
+
return join(dir, '.plugin-manager', 'logs', kind + '-' + stamp + '.log');
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* 从官方启动输出里取带 token 的可用地址。
|
|
1133
|
+
*
|
|
1134
|
+
* 官方 web-app 在 Loader 落定后打印 dsh web: http://127.0.0.1:<port>/?token=<token>,
|
|
1135
|
+
* 那是官方的就绪信号,token 就是本实例的入口凭据。后台模式这份输出由我们持有,所以
|
|
1136
|
+
* 能把真正点得开的地址交给用户;终端模式输出在窗口里,我们不去截它。
|
|
1137
|
+
*
|
|
1138
|
+
* 只取回环地址:官方同时会打印 LAN 地址,跨机地址不该出现在本机页面上。
|
|
1139
|
+
*
|
|
1140
|
+
* @param logPath - 捕获到的官方输出。
|
|
1141
|
+
* @returns 带 token 的回环地址;没读到 null。
|
|
1142
|
+
*/
|
|
1143
|
+
function authenticatedUrlFromLog(logPath) {
|
|
1144
|
+
try {
|
|
1145
|
+
const text = readFileSync(logPath, 'utf8');
|
|
1146
|
+
const match = /https?:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/.exec(text);
|
|
1147
|
+
return match === null ? null : match[0];
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* 读日志尾部若干行,给失败文案用。
|
|
1155
|
+
*
|
|
1156
|
+
* 三件事:
|
|
1157
|
+
* 1. 单行截断(启动日志里可能有很长的堆栈,REST 响应不该被它撑爆);
|
|
1158
|
+
* 2. token 脱敏(token 只允许出现在那份「仅本用户可读」的启动日志与 startEnvironment 的
|
|
1159
|
+
* 成功返回里)—— 注意「仅本用户可读」在 Linux 上靠 0600,在 Windows 上权限位不生效、
|
|
1160
|
+
* 只受目录 ACL 保护(审计 W-12),所以这是**说辞**不是保证;失败文案必须脱敏正是因为如此;
|
|
1161
|
+
* 3. **编码如实**(独立复验 N-04):Node 自己写的是 UTF-8,但 cmd/powershell 的本地化报错按
|
|
1162
|
+
* 控制台代码页写入,按 utf8 读会得到替换字符。这里去掉控制字符,并把「是否含无法解码
|
|
1163
|
+
* 片段」交给调用方标注 —— 不让乱码冒充可读信息。
|
|
1164
|
+
*
|
|
1165
|
+
* @param logPath - 日志文件。
|
|
1166
|
+
* @param lines - 取最后多少行。
|
|
1167
|
+
* @returns 尾部行与「是否含无法解码的片段」;读不到时为空。
|
|
1168
|
+
*/
|
|
1169
|
+
function tailOfLog(logPath, lines) {
|
|
1170
|
+
try {
|
|
1171
|
+
const text = readFileSync(logPath, 'utf8');
|
|
1172
|
+
const picked = text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-lines)
|
|
1173
|
+
.map((line) => sanitizeLogLine(line));
|
|
1174
|
+
return { lines: picked, undecodable: picked.some((line) => line.includes(DECODE_REPLACEMENT)) };
|
|
1175
|
+
}
|
|
1176
|
+
catch {
|
|
1177
|
+
return { lines: [], undecodable: false };
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
/** UTF-8 解码失败的替换字符(cmd/powershell 按控制台代码页写下的本地化文本会变成它)。 */
|
|
1181
|
+
const DECODE_REPLACEMENT = '\uFFFD';
|
|
1182
|
+
/**
|
|
1183
|
+
* 清理一行日志:去掉不该进用户文案的控制字符、截断超长行、token 脱敏。
|
|
1184
|
+
*
|
|
1185
|
+
* @param line - 日志原文一行。
|
|
1186
|
+
* @returns 可安全展示的一行。
|
|
1187
|
+
*/
|
|
1188
|
+
function sanitizeLogLine(line) {
|
|
1189
|
+
const cleaned = line.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
|
|
1190
|
+
const capped = cleaned.length > 500 ? cleaned.slice(0, 500) + '…' : cleaned;
|
|
1191
|
+
return capped.replace(/token=[A-Za-z0-9_-]+/g, 'token=***');
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* 后台启动用的参数:补 --no-open。
|
|
1195
|
+
*
|
|
1196
|
+
* 后台模式没有终端窗口可看,官方默认行为会在那台机器的桌面上弹出浏览器(实测弹过)。
|
|
1197
|
+
* 带 token 的地址我们交给用户在页面上自己点。
|
|
1198
|
+
*
|
|
1199
|
+
* @param spec - 启动描述。
|
|
1200
|
+
* @returns 加了 --no-open 的启动描述(已有则原样返回)。
|
|
1201
|
+
*/
|
|
1202
|
+
function backgroundSpec(spec) {
|
|
1203
|
+
if (spec.args.includes('--no-open'))
|
|
1204
|
+
return spec;
|
|
1205
|
+
const args = [...spec.args, '--no-open'];
|
|
1206
|
+
return { ...spec, args, display: [spec.command, ...args].join(' ') };
|
|
1207
|
+
}
|
|
1208
|
+
/** 从 start 起找一个空闲端口。 */
|
|
1209
|
+
async function findFreePort(start, span = 200) {
|
|
1210
|
+
for (let port = start; port < start + span; port += 1) {
|
|
1211
|
+
const free = await new Promise((done) => {
|
|
1212
|
+
const server = createServer();
|
|
1213
|
+
server.once('error', () => done(false));
|
|
1214
|
+
server.listen(port, '127.0.0.1', () => server.close(() => done(true)));
|
|
1215
|
+
});
|
|
1216
|
+
if (free)
|
|
1217
|
+
return port;
|
|
1218
|
+
}
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* 起一个 detached 子进程,并在短窗口内探测**异步**启动失败。
|
|
1223
|
+
*
|
|
1224
|
+
* 为什么需要探测:spawn 对「找不到可执行文件」是异步报错的,同步 try/catch 抓不到。
|
|
1225
|
+
* 审计 W-09 指出:终端模式原来的 ok 判定只看同步异常,于是「找不到终端」也会被报成
|
|
1226
|
+
* 「已在终端窗口启动」。这里统一成一处可等待的判定。
|
|
1227
|
+
*
|
|
1228
|
+
* @param command - 可执行文件。
|
|
1229
|
+
* @param args - 参数(argv 直传,不经 shell)。
|
|
1230
|
+
* @param extra - 追加的 spawn 选项(如日志 fd、shell)。
|
|
1231
|
+
* @returns 失败说明;成功(或 500ms 内没有报错)时 null。
|
|
1232
|
+
*/
|
|
1233
|
+
async function spawnDetached(command, args, extra = {}) {
|
|
1234
|
+
let child = null;
|
|
1235
|
+
try {
|
|
1236
|
+
child = spawn(command, [...args], {
|
|
1237
|
+
cwd: process.cwd(), detached: true, stdio: 'ignore', windowsHide: true, ...extra,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
return { failure: messageOf(error), child: null };
|
|
1242
|
+
}
|
|
1243
|
+
let failure = null;
|
|
1244
|
+
const started = child;
|
|
1245
|
+
await new Promise((settle) => {
|
|
1246
|
+
const timer = setTimeout(settle, 500);
|
|
1247
|
+
timer.unref();
|
|
1248
|
+
started.once('spawn', () => { clearTimeout(timer); settle(); });
|
|
1249
|
+
started.once('error', (error) => { failure = messageOf(error); clearTimeout(timer); settle(); });
|
|
1250
|
+
});
|
|
1251
|
+
started.unref();
|
|
1252
|
+
return { failure, child: started };
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* 默认启动器:终端窗口优先,没有可用终端就降级为后台。
|
|
1256
|
+
*
|
|
1257
|
+
* @param spec - 启动描述。
|
|
1258
|
+
* @returns 启动结果;mode 字段如实反映**实际**采用的启动方式。
|
|
1259
|
+
*/
|
|
1260
|
+
async function defaultLaunch(spec) {
|
|
1261
|
+
if (spec.mode === 'terminal') {
|
|
1262
|
+
const terminal = await openInTerminal(spec);
|
|
1263
|
+
if (terminal !== null) {
|
|
1264
|
+
return {
|
|
1265
|
+
ok: true, mode: 'terminal', terminal,
|
|
1266
|
+
detail: '已在 ' + terminal + ' 终端窗口中启动(窗口内的启动结果尚未验证)—— 关闭该窗口即停止实例',
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
return await spawnBackground(backgroundSpec(spec), '没有可用终端,已降级为后台启动');
|
|
1270
|
+
}
|
|
1271
|
+
return await spawnBackground(backgroundSpec(spec));
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* 后台启动(detached + unref)。
|
|
1275
|
+
*
|
|
1276
|
+
* 官方输出重定向到环境目录下的日志:不是管道,而是把文件描述符 dup 给子进程 ——
|
|
1277
|
+
* 宿主进程退出后实例照旧写自己的日志,不会因为管道断开收到 EPIPE。日志里同时有官方
|
|
1278
|
+
* 打印的带 token 地址,所以后台模式能给出真正点得开的 URL。
|
|
1279
|
+
*
|
|
1280
|
+
* @param spec - 启动描述。
|
|
1281
|
+
* @param note - 附加说明(降级原因)。
|
|
1282
|
+
* @returns 启动结果。
|
|
1283
|
+
*/
|
|
1284
|
+
async function spawnBackground(spec, note) {
|
|
1285
|
+
const logPath = operationLogPath(spec.dir, 'start');
|
|
1286
|
+
let failure = null;
|
|
1287
|
+
try {
|
|
1288
|
+
// Linux:目录 0700 / 文件 0600;Windows:权限位**不生效**(审计 W-12),
|
|
1289
|
+
// 只受继承的目录 ACL 保护。所以这里按「尽力而为」写权限,不把它当安全承诺。
|
|
1290
|
+
mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 });
|
|
1291
|
+
const fd = openSync(logPath, 'a', 0o600);
|
|
1292
|
+
try {
|
|
1293
|
+
// Windows 的 .cmd shim 显式经 cmd.exe 执行(W-07 / N-01):不再用 shell:true ——
|
|
1294
|
+
// 那会触发 Node DEP0190(args 只拼接、不转义),参数先过白名单校验。
|
|
1295
|
+
const invocation = spec.shell ? windowsShimInvocation(spec) : { command: spec.command, args: spec.args };
|
|
1296
|
+
const { failure: spawnFailure } = await spawnDetached(invocation.command, invocation.args, {
|
|
1297
|
+
stdio: ['ignore', fd, fd],
|
|
1298
|
+
});
|
|
1299
|
+
failure = spawnFailure;
|
|
1300
|
+
}
|
|
1301
|
+
finally {
|
|
1302
|
+
closeSync(fd);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
catch (error) {
|
|
1306
|
+
failure = messageOf(error);
|
|
1307
|
+
}
|
|
1308
|
+
if (failure !== null) {
|
|
1309
|
+
return {
|
|
1310
|
+
ok: false, mode: 'background', logPath,
|
|
1311
|
+
// R2(§12.9):原因与命令各占一行,不把命令塞进括号里当从句(那会变成同一行两个冒号)。
|
|
1312
|
+
detail: '无法启动:' + failure + '\n命令:' + spec.display,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
ok: true, mode: 'background', logPath,
|
|
1317
|
+
detail: '已在后台启动' + (note === undefined ? '' : '(' + note + ')'),
|
|
1318
|
+
...note === undefined ? {} : { reason: note },
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* 经 cmd.exe 执行 .cmd/.bat 时,参数里允许出现的字符(保守白名单)。
|
|
1323
|
+
*
|
|
1324
|
+
* 独立复验 N-01:原来用 spawn(..., { shell: true }) 会触发 Node **DEP0190**(args 只拼接、
|
|
1325
|
+
* 不转义),含空格的参数没有引号保护;真机实测 `dsh2.cmd` 收到的是
|
|
1326
|
+
* `[--patch C:\Program Files\x.yml --port 3599]` 这样被拆开的 argv。
|
|
1327
|
+
* 现在改成显式 `cmd.exe /d /s /c <cmd> <args...>`(不再声明 shell,因此不再有那条警告),
|
|
1328
|
+
* 并在拼之前**校验**每个参数:不满足白名单就大声失败,而不是交给 cmd 静默拆错。
|
|
1329
|
+
* 这条回退路径的真实参数只有 `--profile <合法名>` / `--port <数字>` / `--no-open`;
|
|
1330
|
+
* 将来若有人传含空格或元字符的 extraArgs,会在这里拿到明确报错(而不是被悄悄转错)。
|
|
1331
|
+
*/
|
|
1332
|
+
const CMD_SAFE_ARG = /^[A-Za-z0-9_@%+=:,./\\-]+$/;
|
|
1333
|
+
/**
|
|
1334
|
+
* .cmd/.bat shim 的启动形态(显式 cmd、argv 直传、参数先校验)。
|
|
1335
|
+
*
|
|
1336
|
+
* @param spec - 启动描述(shell 为 true 时才有意义)。
|
|
1337
|
+
* @returns 命令与参数。
|
|
1338
|
+
* @throws 参数含 cmd 不安全字符时(绝不静默交给 cmd 拆错)。
|
|
1339
|
+
*/
|
|
1340
|
+
export function windowsShimInvocation(spec) {
|
|
1341
|
+
const tokens = [spec.command, ...spec.args];
|
|
1342
|
+
const unsafe = tokens.filter((token) => !CMD_SAFE_ARG.test(token));
|
|
1343
|
+
if (unsafe.length > 0) {
|
|
1344
|
+
throw new Error('Windows 下经 cmd 启动时参数含不安全字符(会被 cmd 重新拆词):' + unsafe.join(', '));
|
|
1345
|
+
}
|
|
1346
|
+
return { command: process.env.ComSpec ?? 'cmd.exe', args: ['/d', '/s', '/c', ...tokens] };
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Windows 可见终端窗口的启动形态(Windows Terminal,官方 open-in-app 目录的 Win 终端项就是 wt:
|
|
1350
|
+
* packages/host/open-in-app/src/catalog.ts:361)。
|
|
1351
|
+
*
|
|
1352
|
+
* 为什么不用 `cmd /c start "" cmd /k <命令行>`:那条串是裸拼接的展示文本,交给 cmd 会**重新
|
|
1353
|
+
* 分词**;官方安装包默认在 `C:\Program Files\nodejs`,含空格时新窗口里只有「找不到命令」,
|
|
1354
|
+
* 而失败要等 30s 就绪超时才暴露(审计 W-08)。wt 收的是 argv,程序与每个参数各自成段,
|
|
1355
|
+
* 不再经过 shell 分词。
|
|
1356
|
+
*
|
|
1357
|
+
* @param spec - 启动描述。
|
|
1358
|
+
* @returns 要执行的命令与参数(argv 形态)。
|
|
1359
|
+
*/
|
|
1360
|
+
export function windowsTerminalInvocation(spec) {
|
|
1361
|
+
if (spec.shell) {
|
|
1362
|
+
// .cmd shim:经 cmd 执行,走同一份参数校验(与后台模式同源)。
|
|
1363
|
+
const shim = windowsShimInvocation(spec);
|
|
1364
|
+
return { command: 'wt', args: ['-d', spec.dir, shim.command, ...shim.args] };
|
|
1365
|
+
}
|
|
1366
|
+
return { command: 'wt', args: ['-d', spec.dir, spec.command, ...spec.args] };
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* 在可见终端窗口里启动。
|
|
1370
|
+
*
|
|
1371
|
+
* 窗口让实例一直在用户眼前(关掉窗口就停掉实例),也是旧实现里用户最认可的交互。
|
|
1372
|
+
* POSIX 下优先切到 $TERMINAL;Windows 上只用官方目录项形态(Windows Terminal,wt)。
|
|
1373
|
+
* 找不到可用终端、或启动**异步失败**(wt 不存在、无桌面会话)时返回 null,由调用方
|
|
1374
|
+
* 降级为后台并如实说明 —— 不再出现「已启动」而其实窗口里是报错。
|
|
1375
|
+
*
|
|
1376
|
+
* @param spec - 启动描述。
|
|
1377
|
+
* @returns 终端名;没有可用终端时 null。
|
|
1378
|
+
*/
|
|
1379
|
+
async function openInTerminal(spec) {
|
|
1380
|
+
const line = shellLine(spec);
|
|
1381
|
+
if (process.platform === 'darwin') {
|
|
1382
|
+
const { failure } = await spawnDetached('osascript', ['-e', 'tell application "Terminal" to do script "' + line.split('"').join('\\"') + '"']);
|
|
1383
|
+
return failure === null ? 'Terminal.app' : null;
|
|
1384
|
+
}
|
|
1385
|
+
if (process.platform === 'win32') {
|
|
1386
|
+
// 只用官方目录项形态(Windows Terminal,官方 open-in-app 目录里 Win 的终端项就是 wt):
|
|
1387
|
+
// packages/host/open-in-app/src/catalog.ts:361。程序与参数走 argv,不经过 shell 二次分词,
|
|
1388
|
+
// 所以 `C:\Program Files\nodejs` 这类含空格的路径天然安全(审计 W-08 的根因就是裸拼接命令行)。
|
|
1389
|
+
// 没有 wt(或启动失败)→ 返回 null,由调用方降级为后台并如实说明;不再自造 cmd /c start 命令行。
|
|
1390
|
+
const invocation = windowsTerminalInvocation(spec);
|
|
1391
|
+
const { failure } = await spawnDetached(invocation.command, invocation.args);
|
|
1392
|
+
return failure === null ? 'wt' : null;
|
|
1393
|
+
}
|
|
1394
|
+
const configured = process.env.TERMINAL?.trim() ?? '';
|
|
1395
|
+
const candidates = [
|
|
1396
|
+
...configured.length > 0 ? [configured.split(/\s+/)[0] ?? ''] : [],
|
|
1397
|
+
'x-terminal-emulator', 'gnome-terminal', 'konsole', 'xterm', 'kitty', 'alacritty', 'wezterm',
|
|
1398
|
+
];
|
|
1399
|
+
for (const candidate of candidates) {
|
|
1400
|
+
if (candidate.length === 0)
|
|
1401
|
+
continue;
|
|
1402
|
+
const resolved = whichSync(candidate);
|
|
1403
|
+
if (resolved === null)
|
|
1404
|
+
continue;
|
|
1405
|
+
const { failure } = await spawnDetached(resolved, terminalArgv(basename(resolved), line));
|
|
1406
|
+
if (failure === null)
|
|
1407
|
+
return basename(resolved);
|
|
1408
|
+
// 换下一个模拟器。
|
|
1409
|
+
}
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
/** 终端模拟器各自的「执行一条命令并保留窗口」参数。 */
|
|
1413
|
+
function terminalArgv(bin, line) {
|
|
1414
|
+
const body = ['bash', '-c', line + '; echo; read -r -p "Press Enter to close..."'];
|
|
1415
|
+
switch (bin) {
|
|
1416
|
+
case 'gnome-terminal': return ['--', ...body];
|
|
1417
|
+
case 'wezterm': return ['start', '--', ...body];
|
|
1418
|
+
case 'konsole':
|
|
1419
|
+
case 'x-terminal-emulator':
|
|
1420
|
+
case 'xterm':
|
|
1421
|
+
case 'alacritty': return ['-e', ...body];
|
|
1422
|
+
default: return body;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* 终端里的命令行:把 node 与 dsh 所在目录前置到 PATH。
|
|
1427
|
+
*
|
|
1428
|
+
* 新开的终端可能没加载 nvm(node/dsh 找不到),也可能没有 pnpm —— 而环境里的官方
|
|
1429
|
+
* 插件管理器要调 pnpm。这里只保证我们已经知道的两个目录在前。
|
|
1430
|
+
*
|
|
1431
|
+
* @param spec - 启动描述。
|
|
1432
|
+
* @returns 可直接交给 shell 的一行命令。
|
|
1433
|
+
*/
|
|
1434
|
+
function shellLine(spec) {
|
|
1435
|
+
const dirs = [dirname(process.execPath)];
|
|
1436
|
+
if (spec.entry !== null)
|
|
1437
|
+
dirs.push(dirname(spec.entry));
|
|
1438
|
+
const unique = [...new Set(dirs)];
|
|
1439
|
+
const path = unique.join(delimiter) + delimiter + '$PATH';
|
|
1440
|
+
return 'export PATH=' + JSON.stringify(path) + '; ' + [spec.command, ...spec.args].map(shellQuote).join(' ');
|
|
1441
|
+
}
|
|
1442
|
+
function shellQuote(part) {
|
|
1443
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(part) ? part : JSON.stringify(part);
|
|
1444
|
+
}
|
|
1445
|
+
/** 在 PATH 里找一个可执行文件。 */
|
|
1446
|
+
function whichSync(bin) {
|
|
1447
|
+
if (bin.includes('/') || bin.includes('\\')) {
|
|
1448
|
+
try {
|
|
1449
|
+
accessSync(bin, constants.X_OK);
|
|
1450
|
+
return bin;
|
|
1451
|
+
}
|
|
1452
|
+
catch {
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
|
|
1457
|
+
if (dir.length === 0)
|
|
1458
|
+
continue;
|
|
1459
|
+
const candidate = join(dir, bin);
|
|
1460
|
+
try {
|
|
1461
|
+
accessSync(candidate, constants.X_OK);
|
|
1462
|
+
return candidate;
|
|
1463
|
+
}
|
|
1464
|
+
catch {
|
|
1465
|
+
// 继续找下一个目录。
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* 终止一个实例进程。**平台语义不同,必须如实区分。**
|
|
1472
|
+
*
|
|
1473
|
+
* POSIX:给那一个 pid 发 SIGTERM —— 可被对端 handler 处理,是「请退出」。
|
|
1474
|
+
* Windows:Node 的 SIGTERM 等价于强制结束**那一个** pid(子进程收不到 handler),而且
|
|
1475
|
+
* **不覆盖进程树**:审计 W-02 实测杀掉 cmd 包装进程后,真正跑着 bin.js 的孙进程仍然
|
|
1476
|
+
* 活着,而 stop 会报「已停止」。所以 Windows 走进程树终止:
|
|
1477
|
+
* `taskkill /PID <pid> /T /F` —— 与官方同形实现
|
|
1478
|
+
* packages/subprocess/subprocess-local/src/spawn.ts:113-122(taskkillProcessTree)一致;
|
|
1479
|
+
* 这里按同形自己调系统命令,不依赖那个包(subprocess-local 的内部面不对外导出,
|
|
1480
|
+
* 对外只有 ctx.subprocess 服务,而它只提供 spawn/spawnTerminal/resolveExecutable,
|
|
1481
|
+
* 没有进程枚举与终止树的方法)。
|
|
1482
|
+
*
|
|
1483
|
+
* @param pid - 目标 pid(调用方已复核过命令行仍属于同名环境)。
|
|
1484
|
+
* @returns 实际采用的方式,写进结果文案用(不假装优雅停止)。
|
|
1485
|
+
*/
|
|
1486
|
+
function terminateInstance(pid) {
|
|
1487
|
+
if (process.platform === 'win32') {
|
|
1488
|
+
// 与官方同形:结果刻意不判成败 —— 进程树可能刚好自己退出、taskkill 也可能不在 PATH;
|
|
1489
|
+
// 真正的判据是下面的存活轮询,那才是事实。
|
|
1490
|
+
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1491
|
+
return 'taskkill';
|
|
1492
|
+
}
|
|
1493
|
+
process.kill(pid, 'SIGTERM');
|
|
1494
|
+
return 'sigterm';
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* 停止一个环境的实例 —— 只按 pid 精确 kill。
|
|
1498
|
+
*
|
|
1499
|
+
* 绝不 pkill -f "dsh --profile NAME":那会连带杀掉命令行里恰好出现同一字符串的
|
|
1500
|
+
* 无关进程,也会杀掉同名的 pnpm 与一次性命令。这里的流程是:扫描,取同名环境的
|
|
1501
|
+
* pid,逐个用 readProcessCommand 复核该 pid 仍然是同一环境的实例,再终止(POSIX
|
|
1502
|
+
* SIGTERM;Windows taskkill /T /F 结束进程树,见 terminateInstance),最后轮询存活。
|
|
1503
|
+
* 结果文案如实说明是哪种终止方式 —— Windows 上不存在「优雅停止」这回事。
|
|
1504
|
+
*
|
|
1505
|
+
* @param name - 环境名。
|
|
1506
|
+
* @param options - 停止选项。
|
|
1507
|
+
* @returns 操作结果。
|
|
1508
|
+
*/
|
|
1509
|
+
export async function stopEnvironment(name, options = {}) {
|
|
1510
|
+
const problem = environmentNameProblem(name);
|
|
1511
|
+
if (problem !== null)
|
|
1512
|
+
return failure('invalid-name', problem);
|
|
1513
|
+
const dir = environmentDir(name);
|
|
1514
|
+
if (!existsSync(dir))
|
|
1515
|
+
return failure('not-found', '环境不存在:' + name);
|
|
1516
|
+
const current = resolveCurrent(options);
|
|
1517
|
+
// 同上:这里是"别把自己杀掉"的护栏,被绕过等于杀死正在服务本页面的进程。
|
|
1518
|
+
if (sameEnvironment(current, name)) {
|
|
1519
|
+
return failure('current', name + ' 是当前正在运行的环境:在这里停止它等于结束本进程,请在它的终端里停止');
|
|
1520
|
+
}
|
|
1521
|
+
// 缓存先看(Windows 全表扫描可达数秒);缓存里没有时用即时扫描复核一次,免得把
|
|
1522
|
+
// 刚起来的实例判成没起、或把刚停的实例当成还在跑。
|
|
1523
|
+
let factsUpd = processFacts();
|
|
1524
|
+
let runs = runsForName(factsUpd.runs, name);
|
|
1525
|
+
if (runs.length === 0 && factsUpd.readable) {
|
|
1526
|
+
factsUpd = processFacts({ fresh: true });
|
|
1527
|
+
runs = runsForName(factsUpd.runs, name);
|
|
1528
|
+
}
|
|
1529
|
+
if (!factsUpd.readable) {
|
|
1530
|
+
return failure('facts-unavailable', name + ' 的运行状态未知:进程事实读不到('
|
|
1531
|
+
+ String(factsUpd.reason) + ')。拒绝在未知状态下停止实例。');
|
|
1532
|
+
}
|
|
1533
|
+
if (runs.length === 0)
|
|
1534
|
+
return failure('not-running', name + ' 没有运行中的实例');
|
|
1535
|
+
const killed = [];
|
|
1536
|
+
const skipped = [];
|
|
1537
|
+
/** 本批实际用过的终止方式(决定文案说的是 SIGTERM 还是 taskkill)。 */
|
|
1538
|
+
const modes = new Set();
|
|
1539
|
+
for (const run of runs) {
|
|
1540
|
+
if (run.pid === process.pid || run.pid === process.ppid) {
|
|
1541
|
+
skipped.push('pid ' + String(run.pid) + '(本进程/父进程)');
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
const command = (options.readCommand ?? readProcessCommand)(run.pid);
|
|
1545
|
+
if (command === null) {
|
|
1546
|
+
skipped.push('pid ' + String(run.pid) + '(已退出)');
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
const parsed = parseRun({ pid: run.pid, command });
|
|
1550
|
+
if (parsed === null || !sameEnvironment(parsed.name, name)) {
|
|
1551
|
+
skipped.push('pid ' + String(run.pid) + '(命令行已不属于 ' + name + ',拒绝 kill)');
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
try {
|
|
1555
|
+
modes.add(terminateInstance(run.pid));
|
|
1556
|
+
killed.push(run.pid);
|
|
1557
|
+
}
|
|
1558
|
+
catch (error) {
|
|
1559
|
+
skipped.push('pid ' + String(run.pid) + '(' + messageOf(error) + ')');
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
if (killed.length === 0) {
|
|
1563
|
+
resetRunCache();
|
|
1564
|
+
return skipped.length > 0
|
|
1565
|
+
? failure('not-running', name + ' 没有可停止的实例:' + skipped.join(';'))
|
|
1566
|
+
: failure('not-running', name + ' 没有运行中的实例');
|
|
1567
|
+
}
|
|
1568
|
+
const stillAlive = await waitForExit(killed, options);
|
|
1569
|
+
resetRunCache();
|
|
1570
|
+
const forced = modes.has('taskkill');
|
|
1571
|
+
const lines = ['已停止 ' + name + '(pid ' + killed.join(', ') + ')'
|
|
1572
|
+
+ (forced ? '\n终止方式:Windows 上是 taskkill /T /F 强制结束进程树(含子进程),不是优雅停止' : '')];
|
|
1573
|
+
if (skipped.length > 0)
|
|
1574
|
+
lines.push('未处理:' + skipped.join(';'));
|
|
1575
|
+
if (stillAlive.length > 0) {
|
|
1576
|
+
lines.push('仍在运行:pid ' + stillAlive.join(', ') + '('
|
|
1577
|
+
+ (forced ? 'taskkill /T /F 之后' : '已发 SIGTERM,')
|
|
1578
|
+
+ String(options.timeoutMs ?? STOP_TIMEOUT_MS) + 'ms 内仍在)');
|
|
1579
|
+
return failure('kill-timeout', lines.join('\n'));
|
|
1580
|
+
}
|
|
1581
|
+
return success(lines.join('\n'));
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* 轮询等待进程退出。
|
|
1585
|
+
*
|
|
1586
|
+
* @param pids - 已收到 SIGTERM 的进程。
|
|
1587
|
+
* @param options - 时钟与上限。
|
|
1588
|
+
* @returns 仍在运行的 pid。
|
|
1589
|
+
*/
|
|
1590
|
+
async function waitForExit(pids, options) {
|
|
1591
|
+
const now = options.now ?? Date.now;
|
|
1592
|
+
const sleep = options.sleep ?? ((ms) => new Promise((done) => { setTimeout(done, ms); }));
|
|
1593
|
+
const deadline = now() + (options.timeoutMs ?? STOP_TIMEOUT_MS);
|
|
1594
|
+
for (;;) {
|
|
1595
|
+
const alive = pids.filter((pid) => pidAlive(pid));
|
|
1596
|
+
if (alive.length === 0)
|
|
1597
|
+
return [];
|
|
1598
|
+
const left = deadline - now();
|
|
1599
|
+
if (left <= 0)
|
|
1600
|
+
return alive;
|
|
1601
|
+
await sleep(Math.min(200, left));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
function profileContextOf(ctx) {
|
|
1605
|
+
if (ctx === undefined)
|
|
1606
|
+
return undefined;
|
|
1607
|
+
return ctx.get('profileContext');
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* 组装官方 operations 的调用参数。
|
|
1611
|
+
*
|
|
1612
|
+
* installAnchor 只认官方事实(ctx.profileContext.installAnchor,或调用方显式覆盖)。
|
|
1613
|
+
* 拿不到就抛确定性错误 —— 猜一个路径去写别人的环境是数据损坏级别的错误,宁可拒绝。
|
|
1614
|
+
*
|
|
1615
|
+
* @param profile - 目标环境名。
|
|
1616
|
+
* @param dir - 目标环境目录。
|
|
1617
|
+
* @param cwd - 相对 spec 的锚点目录。
|
|
1618
|
+
* @param options - 共享选项。
|
|
1619
|
+
* @returns 官方 operations 的 context。
|
|
1620
|
+
* @throws {EnvironmentError} installAnchor 不可得时(code 为 no-profile-context)。
|
|
1621
|
+
*/
|
|
1622
|
+
function operationContext(profile, dir, cwd, options) {
|
|
1623
|
+
const context = profileContextOf(options.ctx);
|
|
1624
|
+
const installAnchor = options.installAnchor ?? context?.installAnchor;
|
|
1625
|
+
if (installAnchor === undefined || installAnchor.length === 0) {
|
|
1626
|
+
throw new EnvironmentError('no-profile-context', '这个进程不是以某个环境启动的,'
|
|
1627
|
+
+ '所以无法定位该环境的安装位置,跨环境的包操作做不了。');
|
|
1628
|
+
}
|
|
1629
|
+
return {
|
|
1630
|
+
profile,
|
|
1631
|
+
dir,
|
|
1632
|
+
installAnchor,
|
|
1633
|
+
cwd,
|
|
1634
|
+
home: context?.home ?? dshHome(),
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* 取官方 operations 模块。
|
|
1639
|
+
*
|
|
1640
|
+
* @param options - 共享选项(可注入运行器)。
|
|
1641
|
+
* @returns 运行器。
|
|
1642
|
+
* @throws {EnvironmentError} 官方子路径不可用时(code 为 official-unavailable),不静默降级。
|
|
1643
|
+
*/
|
|
1644
|
+
async function officialRunner(options) {
|
|
1645
|
+
if (options.runCommand !== undefined)
|
|
1646
|
+
return options.runCommand;
|
|
1647
|
+
try {
|
|
1648
|
+
const module = await import('@deepseek-ai/dsh-plugin-manager/operations');
|
|
1649
|
+
return module.runPluginCommand;
|
|
1650
|
+
}
|
|
1651
|
+
catch (error) {
|
|
1652
|
+
throw new EnvironmentError('official-unavailable', '官方 @deepseek-ai/dsh-plugin-manager/operations 不可用:' + messageOf(error));
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* 运行一次官方包操作。
|
|
1657
|
+
*
|
|
1658
|
+
* @param runner - 官方运行器。
|
|
1659
|
+
* @param context - profile 参数化的调用参数。
|
|
1660
|
+
* @param args - pnpm 参数。
|
|
1661
|
+
* @param options - 共享选项。
|
|
1662
|
+
* @returns 官方结果。
|
|
1663
|
+
*/
|
|
1664
|
+
async function runPackageOperation(runner, context, args, options) {
|
|
1665
|
+
return runner(context, args, {
|
|
1666
|
+
// 服务侧调用:官方会剥掉环境里的凭据并捕获输出(而不是把终端交给 pnpm)。
|
|
1667
|
+
execution: 'service',
|
|
1668
|
+
outputBytes: options.outputBytes ?? OPERATION_OUTPUT_BYTES,
|
|
1669
|
+
lockWaitMs: options.lockWaitMs ?? OPERATION_LOCK_WAIT_MS,
|
|
1670
|
+
...options.onOutput === undefined ? {} : { onOutput: options.onOutput },
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* 修复安装:把当前 profile **已声明**的依赖真正装进 node_modules。
|
|
1675
|
+
*
|
|
1676
|
+
* 为什么不能走 add:官方 inspect 把「已声明」当作「已安装」(already-installed 是官方
|
|
1677
|
+
* PluginInspectProblem 闭集里的取值),于是对「声明了但没装」的包走 add 必被拒绝,
|
|
1678
|
+
* 修复输出「拒绝安装:already-installed」——与诊断结论直接矛盾,用户点多少次都不会成功
|
|
1679
|
+
* (write-auditor 真机验证发现,见 docs/private/write-path-audit.md §3·P3)。
|
|
1680
|
+
*
|
|
1681
|
+
* 官方 `dsh plugin --profile X install` 就是把参数转发给 pnpm 的官方通道(apps/cli/src/plugin.ts
|
|
1682
|
+
* 也走同一个 runPluginCommand),修复安装用它。
|
|
1683
|
+
*
|
|
1684
|
+
* @param target - 包名(只用于文案;官方 install 按 package.json 全量收敛)。
|
|
1685
|
+
* @param options - 共享选项。
|
|
1686
|
+
* @returns 结果;官方通道不可用或 pnpm 非零退出时如实报失败,不静默降级。
|
|
1687
|
+
*/
|
|
1688
|
+
export async function repairDependencies(target, options = {}) {
|
|
1689
|
+
const context = profileContextOf(options.ctx);
|
|
1690
|
+
const profile = context?.name;
|
|
1691
|
+
if (profile === undefined || profile.length === 0) {
|
|
1692
|
+
return failure('no-profile-context', '读不到当前是哪个环境,无法修复安装');
|
|
1693
|
+
}
|
|
1694
|
+
const dir = environmentDir(profile);
|
|
1695
|
+
if (!existsSync(join(dir, 'package.json')))
|
|
1696
|
+
return failure('not-found', '环境不存在:' + profile);
|
|
1697
|
+
try {
|
|
1698
|
+
const runner = await officialRunner(options);
|
|
1699
|
+
const result = await runPackageOperation(runner, operationContext(profile, dir, context?.cwd ?? dir, options), ['install'], options);
|
|
1700
|
+
if (result.exitCode !== 0) {
|
|
1701
|
+
const tail = result.output.trim().split(/\r?\n/).filter((line) => line.length > 0).slice(-8).join('\n');
|
|
1702
|
+
return failure('package-operation-failed', '修复安装失败(官方 install 退出码 ' + String(result.exitCode) + '):' + (tail === '' ? result.output.slice(-600) : '\n' + tail));
|
|
1703
|
+
}
|
|
1704
|
+
return success('已修复安装 ' + target + '(环境 ' + profile + ')');
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
if (error instanceof EnvironmentError)
|
|
1708
|
+
return failure(error.code, messageOf(error));
|
|
1709
|
+
return failure('io-failed', messageOf(error));
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
/** 拆出 link: / file: / 相对 / 绝对路径这类本地来源。 */
|
|
1713
|
+
function localTarget(spec) {
|
|
1714
|
+
const match = /^(?<prefix>link:|file:)?(?<path>\.{1,2}(?:[\\/].*)?|[\\/].*|[A-Za-z]:[\\/].*)$/.exec(spec);
|
|
1715
|
+
const groups = match?.groups;
|
|
1716
|
+
if (groups?.path === undefined)
|
|
1717
|
+
return null;
|
|
1718
|
+
return { prefix: groups.prefix ?? '', path: groups.path };
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* 把 package.json 里记录的 spec 解析成可以在别处重装的 spec。
|
|
1722
|
+
*
|
|
1723
|
+
* 本地来源(link:/file:/相对/绝对路径)在 package.json 里是相对本环境目录记录的;
|
|
1724
|
+
* 跨环境重装必须解析成绝对路径,否则会指向新环境的邻居目录。本地路径已经不存在时
|
|
1725
|
+
* 返回 null —— 那是「不可恢复」,不是「缺失」。
|
|
1726
|
+
*
|
|
1727
|
+
* @param spec - manifest 里记录的依赖值。
|
|
1728
|
+
* @param baseDir - 记录该 spec 的环境目录。
|
|
1729
|
+
* @returns 解析结果;本地来源已消失时 null。
|
|
1730
|
+
*/
|
|
1731
|
+
function resolveInstallSpec(spec, baseDir) {
|
|
1732
|
+
const local = localTarget(spec);
|
|
1733
|
+
if (local === null)
|
|
1734
|
+
return { spec, localPath: null };
|
|
1735
|
+
const absolute = isAbsolute(local.path) ? local.path : resolve(baseDir, local.path);
|
|
1736
|
+
if (!existsSync(absolute))
|
|
1737
|
+
return null;
|
|
1738
|
+
return { spec: local.prefix + absolute, localPath: absolute };
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* 把源环境里已装的插件装到目标环境。
|
|
1742
|
+
*
|
|
1743
|
+
* 与当前环境的写操作走官方 pluginManager 服务不同,这里是同一套官方 pnpm 通道加换
|
|
1744
|
+
* 一个 profile 参数:runPluginCommand({ profile, dir, installAnchor, cwd, home },
|
|
1745
|
+
* ['add', spec])。整批只占一次进程内互斥(分批会让并发的安装/删除插进条目之间,
|
|
1746
|
+
* 互相覆盖 manifest)。
|
|
1747
|
+
*
|
|
1748
|
+
* @param from - 源环境名。
|
|
1749
|
+
* @param to - 目标环境名。
|
|
1750
|
+
* @param names - 要复制的包名(按源 manifest 里记录的来源重装)。
|
|
1751
|
+
* @param options - 共享选项。
|
|
1752
|
+
* @returns 操作结果。
|
|
1753
|
+
*/
|
|
1754
|
+
export async function copyPlugins(from, to, names, options = {}) {
|
|
1755
|
+
for (const name of [from, to]) {
|
|
1756
|
+
const problem = environmentNameProblem(name);
|
|
1757
|
+
if (problem !== null)
|
|
1758
|
+
return failure('invalid-name', problem);
|
|
1759
|
+
}
|
|
1760
|
+
const fromDir = environmentDir(from);
|
|
1761
|
+
const toDir = environmentDir(to);
|
|
1762
|
+
if (!existsSync(join(fromDir, 'package.json')))
|
|
1763
|
+
return failure('not-found', '源环境不存在:' + from);
|
|
1764
|
+
if (!existsSync(join(toDir, 'package.json')))
|
|
1765
|
+
return failure('not-found', '目标环境不存在:' + to);
|
|
1766
|
+
const selected = [...new Set(names)];
|
|
1767
|
+
if (selected.length === 0)
|
|
1768
|
+
return failure('empty-selection', '没有选中任何插件');
|
|
1769
|
+
const recorded = recordedDependencies(fromDir);
|
|
1770
|
+
let context;
|
|
1771
|
+
let runner;
|
|
1772
|
+
try {
|
|
1773
|
+
// 先取 context 与 runner:能力缺失必须在动手之前失败。
|
|
1774
|
+
context = operationContext(to, toDir, fromDir, options);
|
|
1775
|
+
runner = await officialRunner(options);
|
|
1776
|
+
}
|
|
1777
|
+
catch (error) {
|
|
1778
|
+
return failure(error instanceof EnvironmentError ? error.code : 'official-unavailable', messageOf(error));
|
|
1779
|
+
}
|
|
1780
|
+
return enqueueMutation(async () => {
|
|
1781
|
+
const outputs = [];
|
|
1782
|
+
let ok = true;
|
|
1783
|
+
for (const name of selected) {
|
|
1784
|
+
const raw = recorded[name];
|
|
1785
|
+
const source = typeof raw === 'string' && raw.length > 0 ? raw : name;
|
|
1786
|
+
const resolved = resolveInstallSpec(source, fromDir);
|
|
1787
|
+
if (resolved === null) {
|
|
1788
|
+
// R2(§12.9):一行只说一件事——结论一行,原因另起一行缩进。
|
|
1789
|
+
outputs.push('# ' + name + ' -> ' + to + ':跳过\n 本地来源已不存在:' + source);
|
|
1790
|
+
ok = false;
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
let result;
|
|
1794
|
+
try {
|
|
1795
|
+
result = await runPackageOperation(runner, context, ['add', resolved.spec], options);
|
|
1796
|
+
}
|
|
1797
|
+
catch (error) {
|
|
1798
|
+
// 官方通道在 pnpm 成功之后的对账阶段也可能抛(例如依赖无法解析成 bundle):
|
|
1799
|
+
// 一个条目失败不能带走整批,如实记账并继续。
|
|
1800
|
+
outputs.push('# ' + name + ' -> ' + to + ':失败\n' + messageOf(error));
|
|
1801
|
+
ok = false;
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
outputs.push('# ' + name + ' -> ' + to + ':' + (result.exitCode === 0 ? 'ok' : '失败')
|
|
1805
|
+
+ '\n' + result.output.trim());
|
|
1806
|
+
if (result.exitCode !== 0)
|
|
1807
|
+
ok = false;
|
|
1808
|
+
}
|
|
1809
|
+
return ok
|
|
1810
|
+
? { ok: true, output: outputs.join('\n\n') }
|
|
1811
|
+
: { ok: false, code: 'package-operation-failed', output: outputs.join('\n\n') };
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* 读 manifest 里 包名到来源 spec 的记录。
|
|
1816
|
+
*
|
|
1817
|
+
* @param dir - 环境目录。
|
|
1818
|
+
* @returns 原始 dependencies 对象;没有时为空对象。
|
|
1819
|
+
*/
|
|
1820
|
+
function recordedDependencies(dir) {
|
|
1821
|
+
const raw = readEnvironmentManifest(dir).raw;
|
|
1822
|
+
const dependencies = raw['dependencies'];
|
|
1823
|
+
return typeof dependencies === 'object' && dependencies !== null ? dependencies : {};
|
|
1824
|
+
}
|
|
1825
|
+
// ── 试装引擎(DESIGN §5.2 / §5.4)────────────────────────────────────────
|
|
1826
|
+
/** 测试环境名的后缀。一个真实环境对应一个测试环境(保真需要,§5.4 命名与归属)。 */
|
|
1827
|
+
export const TRIAL_ENVIRONMENT_SUFFIX = '-dpmc';
|
|
1828
|
+
/**
|
|
1829
|
+
* 某个真实环境对应的测试环境名。
|
|
1830
|
+
*
|
|
1831
|
+
* @param realName - 真实环境名。
|
|
1832
|
+
* @returns 测试环境名。
|
|
1833
|
+
*/
|
|
1834
|
+
export function trialEnvironmentName(realName) {
|
|
1835
|
+
return realName + TRIAL_ENVIRONMENT_SUFFIX;
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* 是不是我们创建的测试环境(只看名字形态)。
|
|
1839
|
+
*
|
|
1840
|
+
* 删除路径先用它筛,再核对归属(§5.4 清理):不靠台账,台账会过期。
|
|
1841
|
+
*
|
|
1842
|
+
* @param name - 环境名。
|
|
1843
|
+
* @returns 是否形如测试环境。
|
|
1844
|
+
*/
|
|
1845
|
+
export function isTrialEnvironmentName(name) {
|
|
1846
|
+
return name.endsWith(TRIAL_ENVIRONMENT_SUFFIX) && name.length > TRIAL_ENVIRONMENT_SUFFIX.length;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* 测试环境名对应的真实环境名;不是测试环境时 null。
|
|
1850
|
+
*
|
|
1851
|
+
* @param name - 环境名。
|
|
1852
|
+
* @returns 真实环境名或 null。
|
|
1853
|
+
*/
|
|
1854
|
+
export function trialEnvironmentOwner(name) {
|
|
1855
|
+
return isTrialEnvironmentName(name) ? name.slice(0, -TRIAL_ENVIRONMENT_SUFFIX.length) : null;
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* 文件内容 hash。
|
|
1859
|
+
*
|
|
1860
|
+
* @param path - 文件路径。
|
|
1861
|
+
* @returns 16 位 hex;读不到时 null。
|
|
1862
|
+
*/
|
|
1863
|
+
function fileHash(path) {
|
|
1864
|
+
try {
|
|
1865
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex').slice(0, 16);
|
|
1866
|
+
}
|
|
1867
|
+
catch {
|
|
1868
|
+
return null;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* 算一个环境当前的指纹(读盘)。
|
|
1873
|
+
*
|
|
1874
|
+
* @param name - 环境名。
|
|
1875
|
+
* @param options - 官方层栈来源(可注入;拿不到时退回 manifest 并如实标注口径)。
|
|
1876
|
+
* @returns 指纹。
|
|
1877
|
+
*/
|
|
1878
|
+
export async function environmentFingerprint(name, options = {}) {
|
|
1879
|
+
const dir = environmentDir(name);
|
|
1880
|
+
const manifest = readEnvironmentManifest(dir);
|
|
1881
|
+
let bundles = manifest.bundles;
|
|
1882
|
+
let bundlesSource = 'manifest';
|
|
1883
|
+
if (options.listBundles !== undefined) {
|
|
1884
|
+
try {
|
|
1885
|
+
bundles = await options.listBundles();
|
|
1886
|
+
bundlesSource = 'official';
|
|
1887
|
+
}
|
|
1888
|
+
catch {
|
|
1889
|
+
// 官方事实拿不到:退回 manifest,但口径标签会说明它不是官方投影。
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
const tuple = {
|
|
1893
|
+
manifestHash: fileHash(join(dir, 'package.json')),
|
|
1894
|
+
lockfileHash: fileHash(join(dir, 'pnpm-lock.yaml')),
|
|
1895
|
+
patchHash: fileHash(join(dir, 'cordis.patch.yml')),
|
|
1896
|
+
bundles: [...bundles],
|
|
1897
|
+
bundlesSource,
|
|
1898
|
+
dependencies: [...manifest.dependencies].sort(),
|
|
1899
|
+
};
|
|
1900
|
+
const hash = createHash('sha256').update(JSON.stringify(tuple)).digest('hex').slice(0, 16);
|
|
1901
|
+
return { ...tuple, hash };
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* 两份指纹是否同一环境状态。
|
|
1905
|
+
*
|
|
1906
|
+
* @param a - 之一。
|
|
1907
|
+
* @param b - 之二。
|
|
1908
|
+
* @returns 是否一致。
|
|
1909
|
+
*/
|
|
1910
|
+
export function sameFingerprint(a, b) {
|
|
1911
|
+
return a.hash === b.hash;
|
|
1912
|
+
}
|
|
1913
|
+
/** 官方 headless 在「没给任务」时的 stderr 特征(§5.2 实测)。 */
|
|
1914
|
+
const BOOT_TASK_REQUIRED = /a task is required/;
|
|
1915
|
+
/** 官方 Loader 挂载失败的特征。 */
|
|
1916
|
+
const BOOT_TREE_FAILED = /plugin tree failed to load/;
|
|
1917
|
+
/**
|
|
1918
|
+
* 官方启动器「层解析不到」的特征(浅快照缺依赖时的真实形态,真机实测)。
|
|
1919
|
+
*
|
|
1920
|
+
* 它与 Loader 失败**不同形**:这一条发生在挂载之前(profile-boot 解析 bundle 时 throw),
|
|
1921
|
+
* 所以 stderr 里既没有 plugin tree failed to load、也没有缺任务提示 —— 旧判据会把它归成
|
|
1922
|
+
* 「无法判定」,于是升级分支永远不会触发(等于装饰)。两种失败可区分,所以这里单独认它。
|
|
1923
|
+
*/
|
|
1924
|
+
const BOOT_UNRESOLVED_LAYER = /cannot resolve profile bundle/;
|
|
1925
|
+
/**
|
|
1926
|
+
* 一行是不是"根因链上的噪声":栈帧、抛错处的源码行、Node 自己的警告前缀。
|
|
1927
|
+
*
|
|
1928
|
+
* 为什么必须滤:官方失败输出里既有错误消息,也有整段堆栈。堆栈里出现的
|
|
1929
|
+
* `throw new Error(\`${binName}: ${stage}: …`)` 这类**源码行**同样含 "Error" 字样,
|
|
1930
|
+
* 早先的过滤器会把它当成第一条原因——用户看到的"根因"是一行源码,读不出任何信息。
|
|
1931
|
+
*
|
|
1932
|
+
* @param line - stderr 里的一行(已 trim)。
|
|
1933
|
+
* @returns 是噪声行时 true。
|
|
1934
|
+
*/
|
|
1935
|
+
function isChainNoise(line) {
|
|
1936
|
+
if (/^at\s/.test(line))
|
|
1937
|
+
return true; // 栈帧:at fn (file:line:col)
|
|
1938
|
+
if (/^throw\s/.test(line))
|
|
1939
|
+
return true; // 抛错处源码
|
|
1940
|
+
if (/^\^+$/.test(line))
|
|
1941
|
+
return true; // Node 代码帧里的插入符标记行
|
|
1942
|
+
if (/^\(node:\d+\)/.test(line))
|
|
1943
|
+
return true; // Node 运行期警告
|
|
1944
|
+
if (/^node:internal\//.test(line))
|
|
1945
|
+
return true;
|
|
1946
|
+
return false;
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* 从 stderr 文本判定挂载结果(纯函数,可注入文本测试)。
|
|
1950
|
+
*
|
|
1951
|
+
* 判据(§5.2 实测):健康环境 stderr 只有一行 dsh: a task is required…,而**退出码仍是 1**,
|
|
1952
|
+
* 所以退出码不能用作判据;失败是 plugin tree failed to load + cause 链。两者都没有 → 无法判定。
|
|
1953
|
+
*
|
|
1954
|
+
* @param stderr - 子进程的 stderr 文本。
|
|
1955
|
+
* @returns 三态判定。
|
|
1956
|
+
*/
|
|
1957
|
+
export function judgeBootStderr(stderr) {
|
|
1958
|
+
const lines = stderr.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1959
|
+
if (lines.some((line) => BOOT_TREE_FAILED.test(line))) {
|
|
1960
|
+
const chain = lines
|
|
1961
|
+
.filter((line) => /Error|\[cause\]|Cannot find|has been registered/.test(line))
|
|
1962
|
+
// 再滤一层:栈帧与"抛错处源码行"也含 "Error",但它们不是原因(实测这条把
|
|
1963
|
+
// `throw new Error(\`${binName}: …`)` 当成了第一行原因,用户读到的是一行源码)。
|
|
1964
|
+
.filter((line) => !isChainNoise(line))
|
|
1965
|
+
.slice(0, 8);
|
|
1966
|
+
const reason = chain[0] ?? lines[0] ?? '未知启动失败';
|
|
1967
|
+
return { kind: 'failed', reason, chain };
|
|
1968
|
+
}
|
|
1969
|
+
// 层解析不到:明确的失败(不是「判不出来」)。升级 full 的判据靠它。
|
|
1970
|
+
if (lines.some((line) => BOOT_UNRESOLVED_LAYER.test(line))) {
|
|
1971
|
+
// 真机实测的 stderr 长这样(逐字见 tests 的用例):先是 Node 的代码帧(file 路径 /
|
|
1972
|
+
// throw 那一行源码 / 插入符),然后是真正的消息行 Error: dsh: cannot resolve profile bundle "X" …,
|
|
1973
|
+
// 最后是栈帧。旧实现取"第一行含该短语的行",于是用户读到的"为什么快照起不来"是一行**源码模板**
|
|
1974
|
+
// ——里面还是 ${…} 占位符,连是哪个包都看不出来。这里先滤噪声,再在干净行里找消息。
|
|
1975
|
+
const clean = lines.filter((line) => !isChainNoise(line));
|
|
1976
|
+
const hit = clean.find((line) => BOOT_UNRESOLVED_LAYER.test(line))
|
|
1977
|
+
?? clean[0]
|
|
1978
|
+
?? lines.find((line) => BOOT_UNRESOLVED_LAYER.test(line))
|
|
1979
|
+
?? '';
|
|
1980
|
+
return { kind: 'failed', reason: hit, chain: clean.slice(0, 4) };
|
|
1981
|
+
}
|
|
1982
|
+
if (lines.some((line) => BOOT_TASK_REQUIRED.test(line)))
|
|
1983
|
+
return { kind: 'mounted' };
|
|
1984
|
+
return {
|
|
1985
|
+
kind: 'undetermined',
|
|
1986
|
+
reason: lines.length === 0
|
|
1987
|
+
? '子进程没有输出任何 stderr 文本'
|
|
1988
|
+
: '输出里没有可识别的官方特征:' + lines[0],
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* 官方 web app 的就绪行(**stdout**,console.log)。
|
|
1993
|
+
*
|
|
1994
|
+
* 为什么它是可信的挂载凭证:官方在 `connectionCtx.get('loader')?.await()`(Loader settle)
|
|
1995
|
+
* **之后**才打印这一行,注释逐字写着它是给 supervisor 的就绪信号。实测形如:
|
|
1996
|
+
* `dsh web: http://127.0.0.1:46141/?token=…`。
|
|
1997
|
+
*
|
|
1998
|
+
* 结尾要求一个空白(`(?=\\s)`):流式读取不保证按行对齐,半行 URL(`…:461`)不能当成就绪——
|
|
1999
|
+
* 官方走 console.log,行尾一定有换行,所以这个要求不会漏掉真信号。
|
|
2000
|
+
*/
|
|
2001
|
+
const BOOT_READY_LINE = /dsh web:\s*(https?:\/\/\S+)(?=\s)/;
|
|
2002
|
+
/**
|
|
2003
|
+
* 服务形态的启动参数(官方自己的 e2e 与发布脚本同款:`dsh web --no-open --host 127.0.0.1 --port 0`)。
|
|
2004
|
+
*
|
|
2005
|
+
* `--port 0` 由 OS 分配端口,因此**永不与 GUI 抢 3080**;官方 CLI 只解析启动器自己的标志
|
|
2006
|
+
* (--profile / --patch / dump-config),其余参数原样交给树。
|
|
2007
|
+
*/
|
|
2008
|
+
const SERVICE_MODE_ARGS = ['--port', '0', '--no-open'];
|
|
2009
|
+
/**
|
|
2010
|
+
* 一次验证启动的超时上限。
|
|
2011
|
+
*
|
|
2012
|
+
* 官方 smoke 用 90s 是在等一个真实服务;我们只等**就绪行**(实测报文 <1s),
|
|
2013
|
+
* 所以收在 15s:既是 20 倍余量,也把"判不出来"的等待从 30s 砍到 15s。
|
|
2014
|
+
*/
|
|
2015
|
+
export const VERIFY_TIMEOUT_MS = 15_000;
|
|
2016
|
+
/**
|
|
2017
|
+
* 验证启动的参数:**按层栈决定形态**(未知参数不能盲加——headless 类环境不认 --port)。
|
|
2018
|
+
*
|
|
2019
|
+
* @param prefixArgs - 启动器入口自己的参数(`dshEntryPoint().args`)。
|
|
2020
|
+
* @param name - 环境名。
|
|
2021
|
+
* @param webLayer - 该环境的 web 层判定(environmentWebLayer)。
|
|
2022
|
+
* @returns 完整参数表。
|
|
2023
|
+
*/
|
|
2024
|
+
export function verificationArgs(prefixArgs, name, webLayer) {
|
|
2025
|
+
const base = [...prefixArgs, '--profile', name];
|
|
2026
|
+
return webLayer === 'present' ? [...base, ...SERVICE_MODE_ARGS] : base;
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* 从一次启动的原始信号给判定(纯函数,可注入文本测试)。
|
|
2030
|
+
*
|
|
2031
|
+
* 两类环境各用各的就绪信号,**谁先出现算谁**(谁先出现由启动器决定,见 defaultHeadlessRun):
|
|
2032
|
+
* · 服务形态:stdout 的官方就绪行 → mounted;
|
|
2033
|
+
* · headless 形态:stderr 的 `dsh: a task is required…` → mounted(走 judgeBootStderr)。
|
|
2034
|
+
* 失败一律读 stderr(`plugin tree failed to load` / `cannot resolve profile bundle` + cause 链);
|
|
2035
|
+
* 什么信号都没有 → undetermined(**不许当通过**)。
|
|
2036
|
+
*
|
|
2037
|
+
* @param signals - 原始信号。
|
|
2038
|
+
* @returns 三态判定。
|
|
2039
|
+
*/
|
|
2040
|
+
export function judgeBootSignals(signals) {
|
|
2041
|
+
if (typeof signals.readyUrl === 'string' && signals.readyUrl.length > 0)
|
|
2042
|
+
return { kind: 'mounted' };
|
|
2043
|
+
return judgeBootStderr(signals.stderr);
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* 按受控对照四步给结论(§5.2)。
|
|
2047
|
+
*
|
|
2048
|
+
* 判定顺序刻意如此:先看基线,再看候选 —— 基线失败时不许赖候选包。
|
|
2049
|
+
*
|
|
2050
|
+
* @param baseline - 物化快照后的挂载判定;没做基线启动时 null。
|
|
2051
|
+
* @param candidate - 装完候选包后的挂载判定;没走到这一步 null。
|
|
2052
|
+
* @param cannotTrialReason - 提前失败的原因(如无法下载候选包);给了就只报「无法试装」。
|
|
2053
|
+
* @returns 结论。
|
|
2054
|
+
*/
|
|
2055
|
+
export function judgeTrialOutcome(baseline, candidate, cannotTrialReason) {
|
|
2056
|
+
if (cannotTrialReason !== undefined)
|
|
2057
|
+
return 'cannot-trial';
|
|
2058
|
+
if (baseline !== null && baseline.kind === 'failed')
|
|
2059
|
+
return 'baseline-broken';
|
|
2060
|
+
if (baseline !== null && baseline.kind === 'undetermined')
|
|
2061
|
+
return 'cannot-trial';
|
|
2062
|
+
if (candidate === null)
|
|
2063
|
+
return 'cannot-trial';
|
|
2064
|
+
if (candidate.kind === 'failed')
|
|
2065
|
+
return 'candidate-broken';
|
|
2066
|
+
if (candidate.kind === 'undetermined')
|
|
2067
|
+
return 'cannot-trial';
|
|
2068
|
+
return 'passed';
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* 自动判定快照深度:层栈是否**全部由安装锚点提供**。
|
|
2072
|
+
*
|
|
2073
|
+
* 判据复用同一套事实(与指纹的 bundlesSource 同源:都是「层从哪里来」),不另起一套:
|
|
2074
|
+
* · 某一层出现在 <dir>/node_modules 里 → 它是 profile 自己装的(第三方 bundle),
|
|
2075
|
+
* 浅快照(只复制清单)复现不出来 → full;
|
|
2076
|
+
* · 否则试官方解析 resolveBundleDir:能从安装锚点解析到 → 浅快照够用 → shallow。
|
|
2077
|
+
* 拿不到锚点、或解析失败 → 一律 full(写多不写少)。
|
|
2078
|
+
*
|
|
2079
|
+
* @param dir - 真实环境目录。
|
|
2080
|
+
* @param bundles - 该环境的层栈。
|
|
2081
|
+
* @param installAnchor - 官方安装锚点(package.json 路径)。
|
|
2082
|
+
* @returns 深度。
|
|
2083
|
+
*/
|
|
2084
|
+
export function snapshotDepthFor(dir, bundles, installAnchor) {
|
|
2085
|
+
if (installAnchor === undefined || installAnchor.length === 0)
|
|
2086
|
+
return 'full';
|
|
2087
|
+
for (const name of bundles) {
|
|
2088
|
+
if (existsSync(join(dir, 'node_modules', name, 'package.json')))
|
|
2089
|
+
return 'full';
|
|
2090
|
+
try {
|
|
2091
|
+
resolveBundleDir(OUR_PACKAGE_NAME, name, installAnchor, dir);
|
|
2092
|
+
}
|
|
2093
|
+
catch {
|
|
2094
|
+
return 'full';
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
return 'shallow';
|
|
2098
|
+
}
|
|
2099
|
+
/** 浅快照复制的文件(§5.2 实测:新建 profile 只有前三个,没有 node_modules)。 */
|
|
2100
|
+
const SNAPSHOT_FILES = ['package.json', 'cordis.yml', 'cordis.patch.yml', 'pnpm-workspace.yaml', 'pnpm-lock.yaml'];
|
|
2101
|
+
/**
|
|
2102
|
+
* 物化前必须清掉的**残留** —— 也就是**只有物化过程自己会造出来**的那两项:
|
|
2103
|
+
* · `node_modules`:上一次完整快照装的依赖(浅快照本不该有依赖,留着会让金丝雀假通过);
|
|
2104
|
+
* · `pnpm-lock.yaml`:上一次完整快照生成的锁文件(源环境没有它时,留着会把快照钉在旧状态)。
|
|
2105
|
+
*
|
|
2106
|
+
* 刻意**不动** `package.json` / `cordis.yml` / `cordis.patch.yml` / `pnpm-workspace.yaml`:
|
|
2107
|
+
* 它们是官方 initProfile 建出来的**profile 骨架**(本机实测:新建 profile 里就是
|
|
2108
|
+
* package.json + cordis.patch.yml + pnpm-workspace.yaml 三个),而且 pnpm-workspace.yaml 带着
|
|
2109
|
+
* `nodeLinker: hoisted` / `autoInstallPeers: false` 这类**会改变 pnpm 行为**的设置——
|
|
2110
|
+
* 删掉它会让我们在测试环境里用另一套安装语义去验证(那才是真的失真)。
|
|
2111
|
+
* 源环境有这些文件时,复制那一步会把它们覆盖成源环境的版本。
|
|
2112
|
+
*/
|
|
2113
|
+
const SNAPSHOT_STALE_ENTRIES = ['node_modules', 'pnpm-lock.yaml'];
|
|
2114
|
+
/**
|
|
2115
|
+
* 把真实环境物化成测试环境(§5.4 同步原则:不订阅变化,用时即时物化)。
|
|
2116
|
+
*
|
|
2117
|
+
* 浅快照只复制清单文件;full 深度再走**官方 runPluginCommand** 装依赖(绝不自己调 pnpm 二进制)。
|
|
2118
|
+
* 官方通道不可用或失败时如实抛出 —— 调用方据此报「无法试装」,不许静默当成功。
|
|
2119
|
+
*
|
|
2120
|
+
* @param sourceName - 真实环境名。
|
|
2121
|
+
* @param targetName - 测试环境名(应等于 trialEnvironmentName(sourceName))。
|
|
2122
|
+
* @param options - 深度、层栈来源与官方通道注入。
|
|
2123
|
+
* @returns 物化结果。
|
|
2124
|
+
*/
|
|
2125
|
+
export async function materializeSnapshot(sourceName, targetName, options = {}) {
|
|
2126
|
+
const sourceDir = environmentDir(sourceName);
|
|
2127
|
+
const targetDir = environmentDir(targetName);
|
|
2128
|
+
if (!existsSync(join(targetDir, 'package.json'))) {
|
|
2129
|
+
throw new EnvironmentError('not-found', '测试环境不存在:' + targetName + '(先 createTrialEnvironment)');
|
|
2130
|
+
}
|
|
2131
|
+
const manifest = readEnvironmentManifest(sourceDir);
|
|
2132
|
+
const bundles = options.bundlesOf === undefined ? manifest.bundles : await options.bundlesOf(sourceName);
|
|
2133
|
+
const anchor = options.installAnchor ?? profileContextOf(options.ctx)?.installAnchor;
|
|
2134
|
+
const depth = options.depth ?? snapshotDepthFor(sourceDir, bundles, anchor);
|
|
2135
|
+
// 物化前先清掉**上一次物化留下的东西**:测试环境是被复用的(§5.4 一个真实环境一个测试环境),
|
|
2136
|
+
// 上一次的完整快照会在里面留下 node_modules —— 不清的话这次"浅快照"其实带着旧依赖,
|
|
2137
|
+
// 于是本该抓出来的依赖缺失被残留掩盖(金丝雀假通过)。删不掉就**如实报无法试装**,绝不静默沿用。
|
|
2138
|
+
const cleared = [];
|
|
2139
|
+
for (const stale of SNAPSHOT_STALE_ENTRIES) {
|
|
2140
|
+
const path = join(targetDir, stale);
|
|
2141
|
+
if (!existsSync(path))
|
|
2142
|
+
continue;
|
|
2143
|
+
try {
|
|
2144
|
+
rmSync(path, { recursive: true, force: true });
|
|
2145
|
+
}
|
|
2146
|
+
catch (error) {
|
|
2147
|
+
throw new EnvironmentError('snapshot-not-shallow', '试装环境里还留着上一次试装的依赖文件,这次删不掉(' + messageOf(error)
|
|
2148
|
+
+ '):带着旧依赖验证会得出错误的结论,所以这次不做试装');
|
|
2149
|
+
}
|
|
2150
|
+
if (existsSync(path)) {
|
|
2151
|
+
throw new EnvironmentError('snapshot-not-shallow', '试装环境里还留着上一次试装的依赖文件,删完之后仍然在:'
|
|
2152
|
+
+ '带着旧依赖验证会得出错误的结论,所以这次不做试装。');
|
|
2153
|
+
}
|
|
2154
|
+
cleared.push(stale);
|
|
2155
|
+
}
|
|
2156
|
+
const copied = [];
|
|
2157
|
+
for (const file of SNAPSHOT_FILES) {
|
|
2158
|
+
const from = join(sourceDir, file);
|
|
2159
|
+
if (!existsSync(from))
|
|
2160
|
+
continue;
|
|
2161
|
+
copyFileSync(from, join(targetDir, file));
|
|
2162
|
+
copied.push(file);
|
|
2163
|
+
}
|
|
2164
|
+
const clearedNote = cleared.length === 0 ? '' : ';并清掉了上一次物化留下的 ' + cleared.join('、');
|
|
2165
|
+
if (depth === 'shallow') {
|
|
2166
|
+
return {
|
|
2167
|
+
depth, copied, cleared, installed: false,
|
|
2168
|
+
output: '已建立轻量环境副本(复制 ' + (copied.length === 0 ? '无' : copied.join(', ')) + clearedNote
|
|
2169
|
+
+ '):轻量副本不含依赖,组合层全部由 dsh 安装位置提供',
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
let runner;
|
|
2173
|
+
let context;
|
|
2174
|
+
try {
|
|
2175
|
+
context = operationContext(targetName, targetDir, targetDir, options);
|
|
2176
|
+
runner = await officialRunner(options);
|
|
2177
|
+
}
|
|
2178
|
+
catch (error) {
|
|
2179
|
+
throw new EnvironmentError(error instanceof EnvironmentError ? error.code : 'official-unavailable', '这次试装需要官方安装通道,但它不可用:' + messageOf(error));
|
|
2180
|
+
}
|
|
2181
|
+
const result = await runPackageOperation(runner, context, ['install', '--prefer-offline'], options);
|
|
2182
|
+
if (result.exitCode !== 0) {
|
|
2183
|
+
throw new EnvironmentError('package-operation-failed', '完整环境副本的官方安装失败(exitCode=' + String(result.exitCode) + '):' + result.output.trim().slice(-500));
|
|
2184
|
+
}
|
|
2185
|
+
return {
|
|
2186
|
+
depth, copied, cleared, installed: true,
|
|
2187
|
+
output: '已建立完整环境副本(复制 ' + (copied.length === 0 ? '无' : copied.join(', ')) + clearedNote
|
|
2188
|
+
+ ';官方 install --prefer-offline 成功)',
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* 建立/复用某个真实环境的测试环境(§5.4:一个真实环境一个测试环境)。
|
|
2193
|
+
*
|
|
2194
|
+
* @param realName - 真实环境名。
|
|
2195
|
+
* @param options - 物化选项。
|
|
2196
|
+
* @returns 结果(output 含快照深度与文件清单)。
|
|
2197
|
+
*/
|
|
2198
|
+
export async function createTrialEnvironment(realName, options = {}) {
|
|
2199
|
+
const problem = environmentNameProblem(realName);
|
|
2200
|
+
if (problem !== null)
|
|
2201
|
+
return failure('invalid-name', problem);
|
|
2202
|
+
if (isTrialEnvironmentName(realName)) {
|
|
2203
|
+
return failure('invalid-name', realName + ' 本身就是测试环境名,不能再建一层');
|
|
2204
|
+
}
|
|
2205
|
+
const target = trialEnvironmentName(realName);
|
|
2206
|
+
const exists = existsSync(join(environmentDir(target), 'package.json'));
|
|
2207
|
+
const created = exists ? success('测试环境已存在:' + target) : await createEnvironment(target, 'headless');
|
|
2208
|
+
if (!created.ok)
|
|
2209
|
+
return created;
|
|
2210
|
+
if (options.materialize === false)
|
|
2211
|
+
return success(created.output + '\n(未建立环境副本)');
|
|
2212
|
+
try {
|
|
2213
|
+
const snapshot = await materializeSnapshot(realName, target, options.snapshot ?? {});
|
|
2214
|
+
return success(created.output + '\n' + snapshot.output);
|
|
2215
|
+
}
|
|
2216
|
+
catch (error) {
|
|
2217
|
+
return failure(error instanceof EnvironmentError ? error.code : 'io-failed', '测试环境已建立,但建立环境副本失败:' + messageOf(error));
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* 删除一个测试环境(§5.4:**删除必须安全**)。
|
|
2222
|
+
*
|
|
2223
|
+
* 三重纪律:
|
|
2224
|
+
* 1. 只删形如 <真实名>-dpmc 的环境(裸后缀不算:没有归属的目录不能进删除路径);
|
|
2225
|
+
* 2. 运行中先拒 —— 不做「先停后删」的隐式动作,停是用户的显式决定;
|
|
2226
|
+
* 3. 进程事实不可读 → 拒绝(**未知状态下绝不动磁盘**,与 stop/remove 同一条纪律)。
|
|
2227
|
+
* 孤儿(真实环境已改名或删除)**可以删**:归属核对的是「名字形态 + 这是我们建的测试环境」,
|
|
2228
|
+
* 不是「真实环境还在」。
|
|
2229
|
+
*
|
|
2230
|
+
* @param name - 测试环境名。
|
|
2231
|
+
* @param options - 当前环境事实与进程事实注入。
|
|
2232
|
+
* @returns 操作结果。
|
|
2233
|
+
*/
|
|
2234
|
+
export async function removeTrialEnvironment(name, options = {}) {
|
|
2235
|
+
const problem = environmentNameProblem(name);
|
|
2236
|
+
if (problem !== null)
|
|
2237
|
+
return failure('invalid-name', problem);
|
|
2238
|
+
if (!isTrialEnvironmentName(name)) {
|
|
2239
|
+
return failure('invalid-name', name + ' 不是测试环境名(必须是 <真实环境名>' + TRIAL_ENVIRONMENT_SUFFIX + ')');
|
|
2240
|
+
}
|
|
2241
|
+
const dir = environmentDir(name);
|
|
2242
|
+
if (!existsSync(dir))
|
|
2243
|
+
return failure('not-found', '测试环境不存在:' + name);
|
|
2244
|
+
const current = resolveCurrent(options);
|
|
2245
|
+
if (sameEnvironment(current, name)) {
|
|
2246
|
+
return failure('current', name + ' 是当前正在运行的环境,不能删除(要删请先停止本进程)');
|
|
2247
|
+
}
|
|
2248
|
+
const facts = options.facts ?? processFacts({ fresh: true });
|
|
2249
|
+
if (!facts.readable) {
|
|
2250
|
+
return failure('facts-unavailable', name + ' 的运行状态未知:进程事实读不到('
|
|
2251
|
+
+ String(facts.reason) + ')。拒绝在未知状态下删除测试环境。');
|
|
2252
|
+
}
|
|
2253
|
+
const busy = runsForName(facts.runs, name);
|
|
2254
|
+
if (busy.length > 0) {
|
|
2255
|
+
return failure('running', name + ' 正在运行(pid ' + busy.map((run) => run.pid).join(', ')
|
|
2256
|
+
+ '),请先停止再删除测试环境');
|
|
2257
|
+
}
|
|
2258
|
+
return enqueueMutation(async () => {
|
|
2259
|
+
try {
|
|
2260
|
+
await retryFs(() => rmSync(dir, { recursive: true, force: true }));
|
|
2261
|
+
}
|
|
2262
|
+
catch (error) {
|
|
2263
|
+
return failure('io-failed', '删除测试环境失败 ' + name + ':' + messageOf(error));
|
|
2264
|
+
}
|
|
2265
|
+
resetRunCache();
|
|
2266
|
+
return success('已删除测试环境 ' + name);
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
/** 默认保留天数(§5.3:默认开 / 14 天,可关可配)。 */
|
|
2270
|
+
export const DEFAULT_TRIAL_RETENTION_DAYS = 14;
|
|
2271
|
+
/**
|
|
2272
|
+
* 算一份测试环境清理计划(纯函数,便于正反用例测试)。
|
|
2273
|
+
*
|
|
2274
|
+
* 只按「到没到保留期」与「是否在跑」两个事实判;运行中的永远保留(删除安全优先)。
|
|
2275
|
+
* `retainDays: null` = 用户关掉了自动清理:什么都不删,但仍把候选列出来给界面显示。
|
|
2276
|
+
*
|
|
2277
|
+
* @param candidates - 候选(读盘事实)。
|
|
2278
|
+
* @param options - 时间与保留期。
|
|
2279
|
+
* @returns 计划。
|
|
2280
|
+
*/
|
|
2281
|
+
export function planTrialCleanup(candidates, options = {}) {
|
|
2282
|
+
const now = options.now ?? Date.now();
|
|
2283
|
+
const retainDays = options.retainDays === undefined ? DEFAULT_TRIAL_RETENTION_DAYS : options.retainDays;
|
|
2284
|
+
const remove = [];
|
|
2285
|
+
const keep = [];
|
|
2286
|
+
for (const candidate of candidates) {
|
|
2287
|
+
if (candidate.running) {
|
|
2288
|
+
keep.push({ name: candidate.name, reason: '正在运行:不删(先让用户停)' });
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
if (retainDays === null) {
|
|
2292
|
+
keep.push({ name: candidate.name, reason: '自动清理已关闭' });
|
|
2293
|
+
continue;
|
|
2294
|
+
}
|
|
2295
|
+
const ageDays = (now - candidate.modifiedAt) / 86_400_000;
|
|
2296
|
+
if (ageDays < retainDays) {
|
|
2297
|
+
keep.push({ name: candidate.name, reason: '未到保留期(' + ageDays.toFixed(1) + ' 天 < ' + String(retainDays) + ' 天)' });
|
|
2298
|
+
continue;
|
|
2299
|
+
}
|
|
2300
|
+
remove.push({ name: candidate.name, reason: '超过保留期 ' + String(retainDays) + ' 天(' + ageDays.toFixed(1) + ' 天)' });
|
|
2301
|
+
}
|
|
2302
|
+
return { remove, keep };
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* 列出所有测试环境候选(读盘;进程事实不可读时按「未知」处理 → 全部保留)。
|
|
2306
|
+
*
|
|
2307
|
+
* @param options - 进程事实注入(测试)。
|
|
2308
|
+
* @returns 候选列表与进程事实的可读性。
|
|
2309
|
+
*/
|
|
2310
|
+
export function listTrialEnvironments(options = {}) {
|
|
2311
|
+
const facts = options.facts ?? processFacts();
|
|
2312
|
+
const candidates = [];
|
|
2313
|
+
let names;
|
|
2314
|
+
try {
|
|
2315
|
+
names = readdirSync(profilesRoot());
|
|
2316
|
+
}
|
|
2317
|
+
catch {
|
|
2318
|
+
return { candidates, factsReadable: facts.readable, ...facts.reason === undefined ? {} : { reason: facts.reason } };
|
|
2319
|
+
}
|
|
2320
|
+
for (const name of names) {
|
|
2321
|
+
if (!isTrialEnvironmentName(name))
|
|
2322
|
+
continue;
|
|
2323
|
+
const dir = environmentDir(name);
|
|
2324
|
+
let modifiedAt = 0;
|
|
2325
|
+
try {
|
|
2326
|
+
modifiedAt = statSync(dir).mtimeMs;
|
|
2327
|
+
}
|
|
2328
|
+
catch {
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
candidates.push({
|
|
2332
|
+
name,
|
|
2333
|
+
owner: trialEnvironmentOwner(name) ?? '',
|
|
2334
|
+
modifiedAt,
|
|
2335
|
+
// 事实不可读时不能声称「没在跑」:按最保守处理,标成 running 让清理计划保留它。
|
|
2336
|
+
running: facts.readable ? runsForName(facts.runs, name).length > 0 : true,
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
return { candidates, factsReadable: facts.readable, ...facts.reason === undefined ? {} : { reason: facts.reason } };
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* 执行清理(§5.4:删除动作**记日志**)。
|
|
2343
|
+
*
|
|
2344
|
+
* @param options - 计划选项 + 日志回调。
|
|
2345
|
+
* @returns 结果(删了哪些、留了哪些)。
|
|
2346
|
+
*/
|
|
2347
|
+
export async function cleanupTrialEnvironments(options = {}) {
|
|
2348
|
+
const listed = listTrialEnvironments(options);
|
|
2349
|
+
const plan = planTrialCleanup(listed.candidates, options);
|
|
2350
|
+
const logLine = (line) => {
|
|
2351
|
+
options.log?.(line);
|
|
2352
|
+
try {
|
|
2353
|
+
appendFileSync(join(dshHome(), 'dpmc-trial-cleanup.log'), new Date().toISOString() + ' ' + line + '\n', { mode: 0o600 });
|
|
2354
|
+
}
|
|
2355
|
+
catch {
|
|
2356
|
+
// 日志失败不阻断清理:删除本身已受三重纪律保护。
|
|
2357
|
+
}
|
|
2358
|
+
};
|
|
2359
|
+
const removed = [];
|
|
2360
|
+
const failures = [];
|
|
2361
|
+
for (const entry of plan.remove) {
|
|
2362
|
+
const result = await removeTrialEnvironment(entry.name, { facts: options.facts });
|
|
2363
|
+
if (result.ok) {
|
|
2364
|
+
removed.push(entry.name);
|
|
2365
|
+
logLine('removed ' + entry.name + ':' + entry.reason);
|
|
2366
|
+
}
|
|
2367
|
+
else {
|
|
2368
|
+
failures.push(entry.name + '(' + String(result.code) + ')');
|
|
2369
|
+
logLine('kept ' + entry.name + ':删除被拒(' + String(result.code) + ')');
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
// 整块留(copy-dev 复核结论):计数与「保留明细」里的原因是用户判断"为什么留着"的唯一依据。
|
|
2373
|
+
const lines = ['测试环境清理:删除 ' + String(removed.length) + ' 个'
|
|
2374
|
+
+ (removed.length === 0 ? '' : '(' + removed.join(', ') + ')')
|
|
2375
|
+
+ ',保留 ' + String(plan.keep.length + failures.length) + ' 个'];
|
|
2376
|
+
if (failures.length > 0)
|
|
2377
|
+
lines.push('删除被拒:' + failures.join(';'));
|
|
2378
|
+
lines.push('保留明细:' + plan.keep.map((entry) => entry.name + '(' + entry.reason + ')').join(';'));
|
|
2379
|
+
return { ok: failures.length === 0, output: lines.join('\n'), removed };
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* 算当前构建的指纹。
|
|
2383
|
+
*
|
|
2384
|
+
* @returns 构建指纹。
|
|
2385
|
+
*/
|
|
2386
|
+
export function buildIdentity() {
|
|
2387
|
+
let artifactMd5 = null;
|
|
2388
|
+
let artifactMtime = null;
|
|
2389
|
+
let here = null;
|
|
2390
|
+
try {
|
|
2391
|
+
here = fileURLToPath(import.meta.url);
|
|
2392
|
+
artifactMd5 = createHash('md5').update(readFileSync(here)).digest('hex');
|
|
2393
|
+
artifactMtime = new Date(statSync(here).mtimeMs).toISOString();
|
|
2394
|
+
}
|
|
2395
|
+
catch {
|
|
2396
|
+
// 读不到自己的产物(极少见):如实 null。
|
|
2397
|
+
}
|
|
2398
|
+
let gitHead = null;
|
|
2399
|
+
if (here !== null) {
|
|
2400
|
+
let dir = dirname(here);
|
|
2401
|
+
for (let depth = 0; depth < 6 && gitHead === null; depth += 1) {
|
|
2402
|
+
try {
|
|
2403
|
+
const head = readFileSync(join(dir, '.git', 'HEAD'), 'utf8').trim();
|
|
2404
|
+
const ref = /^ref:\s*(.+)$/.exec(head);
|
|
2405
|
+
gitHead = ref === null
|
|
2406
|
+
? head.slice(0, 40)
|
|
2407
|
+
: readFileSync(join(dir, '.git', ref[1]), 'utf8').trim().slice(0, 40);
|
|
2408
|
+
}
|
|
2409
|
+
catch {
|
|
2410
|
+
dir = dirname(dir);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
return { artifactMd5, artifactMtime, gitHead };
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* 无头验证一个环境(§5.2 的形态,不得偏离)。
|
|
2418
|
+
*
|
|
2419
|
+
* 形态:`<dsh> --profile <name>`,**不给任何任务文本**(给了就是真跑一轮 agent,花用户的钱),
|
|
2420
|
+
* stdin=/dev/null,捕获 stderr;判定只读 stderr 特征(退出码不参与)。
|
|
2421
|
+
*
|
|
2422
|
+
* @param name - 环境名。
|
|
2423
|
+
* @param options - 超时与启动器注入。
|
|
2424
|
+
* @returns 验证结果。
|
|
2425
|
+
*/
|
|
2426
|
+
export async function runHeadlessVerification(name, options = {}) {
|
|
2427
|
+
const build = buildIdentity();
|
|
2428
|
+
const started = Date.now();
|
|
2429
|
+
const run = options.run ?? defaultHeadlessRun(options.timeoutMs ?? VERIFY_TIMEOUT_MS);
|
|
2430
|
+
let signals = { stderr: '', exitCode: null, stdout: '', readyUrl: null };
|
|
2431
|
+
try {
|
|
2432
|
+
const result = await run(name);
|
|
2433
|
+
signals = {
|
|
2434
|
+
stderr: result.stderr,
|
|
2435
|
+
exitCode: result.exitCode,
|
|
2436
|
+
stdout: result.stdout ?? '',
|
|
2437
|
+
readyUrl: result.readyUrl ?? null,
|
|
2438
|
+
killedAfterReady: result.killedAfterReady ?? false,
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
catch (error) {
|
|
2442
|
+
return {
|
|
2443
|
+
verdict: { kind: 'undetermined', reason: '启动失败:' + messageOf(error) },
|
|
2444
|
+
elapsedMs: Date.now() - started, stderr: '', stdout: '', readyUrl: null, killedAfterReady: false,
|
|
2445
|
+
exitCode: null, build,
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
return {
|
|
2449
|
+
verdict: judgeBootSignals(signals),
|
|
2450
|
+
elapsedMs: Date.now() - started,
|
|
2451
|
+
stderr: signals.stderr.slice(-8192),
|
|
2452
|
+
stdout: (signals.stdout ?? '').slice(-8192),
|
|
2453
|
+
readyUrl: signals.readyUrl ?? null,
|
|
2454
|
+
killedAfterReady: signals.killedAfterReady ?? false,
|
|
2455
|
+
exitCode: signals.exitCode,
|
|
2456
|
+
build,
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
/** 失败特征:出现在 stderr 里的这两条,谁先出现算谁(就绪行之后才出现的失败不算挂载失败)。 */
|
|
2460
|
+
const BOOT_FAILURE_HINT = /plugin tree failed to load|cannot resolve profile bundle/;
|
|
2461
|
+
/**
|
|
2462
|
+
* 造一个启动信号收集器。
|
|
2463
|
+
*
|
|
2464
|
+
* 为什么把它抽出来:就绪行的识别是"跨 chunk"和"顺序"两件事,真机跑一次证明不了边界,
|
|
2465
|
+
* 而这两条边界恰恰是最容易写错的(先来的失败被后来的就绪行盖掉、半行就绪行被漏掉)。
|
|
2466
|
+
*
|
|
2467
|
+
* @returns 收集器。
|
|
2468
|
+
*/
|
|
2469
|
+
export function createBootSignalCollector() {
|
|
2470
|
+
let stderrText = '';
|
|
2471
|
+
let stdoutText = '';
|
|
2472
|
+
let readyUrl = null;
|
|
2473
|
+
return {
|
|
2474
|
+
pushStderr(chunk) { stderrText += chunk; },
|
|
2475
|
+
pushStdout(chunk) {
|
|
2476
|
+
stdoutText += chunk;
|
|
2477
|
+
if (readyUrl !== null)
|
|
2478
|
+
return false;
|
|
2479
|
+
if (BOOT_FAILURE_HINT.test(stderrText))
|
|
2480
|
+
return false;
|
|
2481
|
+
const hit = BOOT_READY_LINE.exec(stdoutText);
|
|
2482
|
+
if (hit === null)
|
|
2483
|
+
return false;
|
|
2484
|
+
readyUrl = hit[1] ?? 'http://(就绪行里没带地址)';
|
|
2485
|
+
return true;
|
|
2486
|
+
},
|
|
2487
|
+
get readyUrl() { return readyUrl; },
|
|
2488
|
+
get stderr() { return stderrText; },
|
|
2489
|
+
get stdout() { return stdoutText; },
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* 默认启动器:真起一个实例,按层栈读**对应的就绪信号**。
|
|
2494
|
+
*
|
|
2495
|
+
* 两类环境(§5.2,2026-09-19 真机改定):
|
|
2496
|
+
* · **含 web 层** → 服务形态(`--port 0 --no-open`):官方 CLI 只解析启动器自己的标志,
|
|
2497
|
+
* 其余参数原样交给树;`--port 0` 让 OS 分配端口,永不与 GUI 抢 3080。就绪 = **stdout** 的
|
|
2498
|
+
* `dsh web: http://…`(Loader settle 之后才打印),读到即判 mounted 并**立刻杀子进程**
|
|
2499
|
+
* (服务形态不会自己退,等下去只会超时)。
|
|
2500
|
+
* · **headless 类** → 缺任务形态(只有 `--profile`):真挂载整棵树后以"缺任务"收场,
|
|
2501
|
+
* 就绪 = stderr 的 `dsh: a task is required…`(原判据不变)。
|
|
2502
|
+
* 两种形态都**绝不传任务文本**(§5.2)。
|
|
2503
|
+
*
|
|
2504
|
+
* @param timeoutMs - 超时上限(超时杀掉 → 无法判定,不许当成功)。
|
|
2505
|
+
* @returns 启动函数。
|
|
2506
|
+
*/
|
|
2507
|
+
function defaultHeadlessRun(timeoutMs) {
|
|
2508
|
+
return async (name) => {
|
|
2509
|
+
const dir = environmentDir(name);
|
|
2510
|
+
const webLayer = environmentWebLayer(dir, readEnvironmentManifest(dir).bundles);
|
|
2511
|
+
const entryPoint = dshEntryPoint();
|
|
2512
|
+
const args = verificationArgs(entryPoint.args, name, webLayer);
|
|
2513
|
+
const invocation = entryPoint.shell
|
|
2514
|
+
? windowsShimInvocation({ ...emptyLaunchSpec(name), command: entryPoint.command, args, shell: true })
|
|
2515
|
+
: { command: entryPoint.command, args };
|
|
2516
|
+
return await new Promise((done, fail) => {
|
|
2517
|
+
const child = spawn(invocation.command, [...invocation.args], {
|
|
2518
|
+
// stdout 也要读:服务形态的就绪行走 console.log(stdout),不是 stderr。
|
|
2519
|
+
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
|
2520
|
+
});
|
|
2521
|
+
const collector = createBootSignalCollector();
|
|
2522
|
+
let killedAfterReady = false;
|
|
2523
|
+
let settled = false;
|
|
2524
|
+
let timer;
|
|
2525
|
+
const finish = (exitCode) => {
|
|
2526
|
+
if (settled)
|
|
2527
|
+
return;
|
|
2528
|
+
settled = true;
|
|
2529
|
+
if (timer !== undefined)
|
|
2530
|
+
clearTimeout(timer);
|
|
2531
|
+
done({
|
|
2532
|
+
stderr: collector.stderr, stdout: collector.stdout,
|
|
2533
|
+
readyUrl: collector.readyUrl, killedAfterReady, exitCode,
|
|
2534
|
+
});
|
|
2535
|
+
};
|
|
2536
|
+
timer = setTimeout(() => {
|
|
2537
|
+
try {
|
|
2538
|
+
child.kill('SIGKILL');
|
|
2539
|
+
}
|
|
2540
|
+
catch { /* 已退出 */ }
|
|
2541
|
+
finish(null);
|
|
2542
|
+
}, timeoutMs);
|
|
2543
|
+
child.stdout?.on('data', (chunk) => {
|
|
2544
|
+
if (settled)
|
|
2545
|
+
return;
|
|
2546
|
+
if (!collector.pushStdout(chunk.toString('utf8')))
|
|
2547
|
+
return;
|
|
2548
|
+
// 读到官方就绪行:树已经 settle、服务已经起来 —— 立刻收工(服务形态不会自己退)。
|
|
2549
|
+
killedAfterReady = true;
|
|
2550
|
+
try {
|
|
2551
|
+
child.kill('SIGKILL');
|
|
2552
|
+
}
|
|
2553
|
+
catch { /* 已退出 */ }
|
|
2554
|
+
finish(null);
|
|
2555
|
+
});
|
|
2556
|
+
child.stderr?.on('data', (chunk) => { collector.pushStderr(chunk.toString('utf8')); });
|
|
2557
|
+
child.once('error', (error) => {
|
|
2558
|
+
if (settled)
|
|
2559
|
+
return;
|
|
2560
|
+
settled = true;
|
|
2561
|
+
if (timer !== undefined)
|
|
2562
|
+
clearTimeout(timer);
|
|
2563
|
+
fail(error);
|
|
2564
|
+
});
|
|
2565
|
+
child.once('close', (code) => { finish(code); });
|
|
2566
|
+
});
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
/** 一个只用来满足启动器签名的空壳(defaultHeadlessRun 只用到 name/命令)。 */
|
|
2570
|
+
function emptyLaunchSpec(name) {
|
|
2571
|
+
return {
|
|
2572
|
+
profile: name, port: 0, mode: 'background', command: '', args: [], entry: null, shell: false,
|
|
2573
|
+
dir: environmentDir(name), display: '',
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* 读测试环境当前的依赖名(读不到时返回空数组——守卫会据此如实报"没有出现候选")。
|
|
2578
|
+
*
|
|
2579
|
+
* @param target - 测试环境名。
|
|
2580
|
+
* @returns 依赖名列表。
|
|
2581
|
+
*/
|
|
2582
|
+
function trialDependencies(target) {
|
|
2583
|
+
try {
|
|
2584
|
+
const manifest = readProfileManifest(OUR_PACKAGE_NAME, environmentDir(target));
|
|
2585
|
+
return Object.keys(manifest.dependencies ?? {});
|
|
2586
|
+
}
|
|
2587
|
+
catch {
|
|
2588
|
+
return [];
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* 从候选 spec 认出**包名**(守卫要在依赖清单里对上号)。
|
|
2593
|
+
*
|
|
2594
|
+
* 三种形态:路径 spec(`link:` / `file:` / 裸相对路径)读目标目录的 package.json;
|
|
2595
|
+
* registry spec(`name` 或 `name@version`,含 scope)取名字部分;读不到就 undefined
|
|
2596
|
+
* (守卫会如实报"没有出现候选",不猜)。
|
|
2597
|
+
*
|
|
2598
|
+
* @param spec - 候选包 spec。
|
|
2599
|
+
* @returns 包名;认不出来时 undefined。
|
|
2600
|
+
*/
|
|
2601
|
+
function candidateName(spec) {
|
|
2602
|
+
const bare = spec.replace(/^(?:link:|file:|workspace:)/, '');
|
|
2603
|
+
const looksLikePath = spec.startsWith('link:') || spec.startsWith('file:') || spec.startsWith('.') || bare.startsWith('/');
|
|
2604
|
+
if (looksLikePath) {
|
|
2605
|
+
const dir = isAbsolute(bare) ? bare : resolve(process.cwd(), bare);
|
|
2606
|
+
try {
|
|
2607
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
2608
|
+
return typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : undefined;
|
|
2609
|
+
}
|
|
2610
|
+
catch {
|
|
2611
|
+
return undefined;
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
const at = bare.lastIndexOf('@');
|
|
2615
|
+
return at > 0 ? bare.slice(0, at) : bare;
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* 把候选从**测试环境**的依赖里摘掉,使它成为"新装",从而被官方 reconcile 真正写进层栈。
|
|
2619
|
+
*
|
|
2620
|
+
* 为什么必须有这一步(真机缺陷,task-84 阻断级):官方 reconcile **跳过 `beforeDeps` 里已有的依赖**
|
|
2621
|
+
* (lib/types/operations.js 的 reconcile:`if (beforeDeps.has(name)) continue`)。而试装快照是从
|
|
2622
|
+
* **源环境**物化的,`SNAPSHOT_FILES` 含 `package.json`——当候选已经在源环境里时(gatedInstall
|
|
2623
|
+
* 的顺序是"先 installBundle 落进源环境、再试装";升级场景更是天然如此),候选在物化那一刻
|
|
2624
|
+
* **就已经在测试环境的 dependencies 里**。于是试装那次 add 无事可做(pnpm 日志里没有候选那一行),
|
|
2625
|
+
* reconcile 跳过它 → 它进不了 `dsh.profile.bundles` → 挂载期不加载它 → 旧代码照样启动成功。
|
|
2626
|
+
*
|
|
2627
|
+
* 后果是阻断级的:试装开 + block(两个默认档)时**任何安装都被拦下**,而 `candidate-broken`
|
|
2628
|
+
* 变得不可达——质量门第二步从"验证"退化成"永远拦下"。
|
|
2629
|
+
*
|
|
2630
|
+
* 所以"让候选成为新装"是试装成立的前提,不是可选的优化。走的是**同一条官方通道** `remove`
|
|
2631
|
+
* (绝不自己调 pnpm);卸不掉也不阻断——那时层栈事实会如实反映"它没进层栈",由守卫报 cannot-trial。
|
|
2632
|
+
*
|
|
2633
|
+
* @param runner - 官方运行器。
|
|
2634
|
+
* @param context - 官方 operations 调用参数(测试环境)。
|
|
2635
|
+
* @param options - 输出与取消策略。
|
|
2636
|
+
* @param target - 测试环境名。
|
|
2637
|
+
* @param spec - 候选 spec。
|
|
2638
|
+
* @returns 是否真的卸掉了,以及面向用户的说明。
|
|
2639
|
+
*/
|
|
2640
|
+
async function detachCandidate(runner, context, options, target, spec) {
|
|
2641
|
+
const name = candidateName(spec);
|
|
2642
|
+
if (name === undefined) {
|
|
2643
|
+
return { removed: false, note: '认不出候选包的包名,本次按直接安装处理' };
|
|
2644
|
+
}
|
|
2645
|
+
if (trialDependencies(target).includes(name)) {
|
|
2646
|
+
const removal = await runPackageOperation(runner, context, ['remove', name], options);
|
|
2647
|
+
if (removal.exitCode === 0) {
|
|
2648
|
+
return { removed: true, note: '已先把 ' + name + ' 从测试环境移除,再重新安装(否则重复安装不会生效)' };
|
|
2649
|
+
}
|
|
2650
|
+
return {
|
|
2651
|
+
removed: false,
|
|
2652
|
+
note: '移除旧版本没有成功(退出码 ' + String(removal.exitCode) + '),候选包可能仍是重复安装的状态',
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
return { removed: false, note: '候选包本来就不在测试环境里,直接安装即可' };
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* 候选装完之后,核对它是否**真的进入了组合层栈**(`dsh.profile.bundles`)。
|
|
2659
|
+
*
|
|
2660
|
+
* 为什么必须有这条守卫(真机缺陷,task-80):官方 `runProfilePnpm` 的 reconcile 只在
|
|
2661
|
+
* `activateNewBundles !== false` 时执行,且会**跳过 `beforeDeps` 里已有的依赖**
|
|
2662
|
+
* (packages/boot/plugin-manager/src/operations.ts:160 与 :82-93)。测试环境被复用、
|
|
2663
|
+
* 或上一轮回滚留下残留时,候选已在 `dependencies` 里 → 永远进不了层栈 →
|
|
2664
|
+
* 挂载期从不加载它 → 坏候选也会被判 `passed`(假通过)。
|
|
2665
|
+
*
|
|
2666
|
+
* 所以"装上了"不等于"验证到了":这里以**盘上的层栈事实**为准,没进层栈就不许算通过。
|
|
2667
|
+
*
|
|
2668
|
+
* 与 {@link detachCandidate} 的分工:那一步负责**让正常路径走通**(把候选变成新装),
|
|
2669
|
+
* 这一条负责**兜住任何仍然没进层栈的情况**(摘不掉、候选不声明 dsh.bundle、测试环境有残留…)。
|
|
2670
|
+
* 两者都要有:只修顺序会漏掉"候选本来就不声明 bundle"这类,只留守卫则正常安装全被误拦。
|
|
2671
|
+
*
|
|
2672
|
+
* @param target - 测试环境名。
|
|
2673
|
+
* @param beforeDeps - 装候选包之前的依赖名集合。
|
|
2674
|
+
* @param spec - 候选包 spec(用于在依赖里认出它)。
|
|
2675
|
+
* @param detached - 是否已先卸包(卸过就不该再判成"既有的")。
|
|
2676
|
+
* @returns 核对结果;不通过时带原因。
|
|
2677
|
+
*/
|
|
2678
|
+
function trialActivation(target, beforeDeps, spec, detached) {
|
|
2679
|
+
const candidate = candidateName(spec);
|
|
2680
|
+
let manifest;
|
|
2681
|
+
try {
|
|
2682
|
+
manifest = readProfileManifest(OUR_PACKAGE_NAME, environmentDir(target));
|
|
2683
|
+
}
|
|
2684
|
+
catch (error) {
|
|
2685
|
+
return {
|
|
2686
|
+
ok: false,
|
|
2687
|
+
detail: '读不到测试环境的 package.json(' + messageOf(error) + ')',
|
|
2688
|
+
fact: { name: candidate, bundles: [], activated: false, removedFirst: detached.removed, detachNote: detached.note },
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
2692
|
+
const deps = Object.keys(manifest.dependencies ?? {});
|
|
2693
|
+
const fact = (activated) => ({ name: candidate, bundles: [...bundles], activated, removedFirst: detached.removed, detachNote: detached.note });
|
|
2694
|
+
const added = deps.filter((name) => !beforeDeps.includes(name));
|
|
2695
|
+
const suspects = added.length > 0
|
|
2696
|
+
? added
|
|
2697
|
+
: candidate === undefined ? [] : deps.filter((name) => name === candidate);
|
|
2698
|
+
if (suspects.length === 0) {
|
|
2699
|
+
return {
|
|
2700
|
+
ok: false,
|
|
2701
|
+
detail: '这次试装没有验证到任何东西:装完之后,测试环境里找不到候选包',
|
|
2702
|
+
fact: fact(false),
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
const active = suspects.filter((name) => bundles.includes(name));
|
|
2706
|
+
if (active.length > 0)
|
|
2707
|
+
return { ok: true, detail: '', fact: fact(true) };
|
|
2708
|
+
const stale = suspects.every((name) => beforeDeps.includes(name));
|
|
2709
|
+
// 面向用户的说法(DESIGN §12.6):不出现 dsh.profile.bundles / reconcile / node_modules 这些
|
|
2710
|
+
// 实现词汇,也不出现字面 **(web 上是可见星号,§3.6 陷阱 #1)。
|
|
2711
|
+
// 技术细节(层栈快照、判据)仍在 fact 里,界面要展开时读得到。
|
|
2712
|
+
return {
|
|
2713
|
+
ok: false,
|
|
2714
|
+
detail: '这次试装没有验证到新版本:候选包装上了,但环境的启动列表里没有它,'
|
|
2715
|
+
+ '所以启动验证根本没有加载它。\n'
|
|
2716
|
+
+ (stale
|
|
2717
|
+
? '原因:它本来就已经装在这个环境里,重复安装不会让它被重新加载'
|
|
2718
|
+
+ (detached.removed ? '(本次已先把它移除,但仍没被重新加载。)' : '')
|
|
2719
|
+
: '原因:它没有声明自己是一个组合包,所以不会被写进启动列表'),
|
|
2720
|
+
fact: fact(false),
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* 受控对照四步(§5.2):物化快照 → 基线启动 → 装候选包 → 二次启动。
|
|
2725
|
+
*
|
|
2726
|
+
* 缺一步结论就站不住,所以:基线失败一律报 baseline-broken(**不赖候选包**);
|
|
2727
|
+
* 装不上候选包报 cannot-trial(**不算通过**);只有基线好、装完也好的才是 passed。
|
|
2728
|
+
* 装候选包走**官方 runPluginCommand**(绝不自己调 pnpm);allowNetwork=false 时加 --offline,
|
|
2729
|
+
* 冷包失败如实报「无法试装」。
|
|
2730
|
+
*
|
|
2731
|
+
* @param spec - 候选包 spec。
|
|
2732
|
+
* @param realName - 真实环境名(测试环境由它派生)。
|
|
2733
|
+
* @param options - 四步选项与注入。
|
|
2734
|
+
* @returns 结果(含证据与构建指纹)。
|
|
2735
|
+
*/
|
|
2736
|
+
export async function runTrialInstall(spec, realName, options = {}) {
|
|
2737
|
+
const now = options.now ?? Date.now;
|
|
2738
|
+
const started = now();
|
|
2739
|
+
const build = buildIdentity();
|
|
2740
|
+
const sourceFingerprint = await environmentFingerprint(realName, {
|
|
2741
|
+
...options.listBundles === undefined ? {} : { listBundles: options.listBundles },
|
|
2742
|
+
});
|
|
2743
|
+
const target = trialEnvironmentName(realName);
|
|
2744
|
+
const done = (conclusion, output, extra = {}) => ({
|
|
2745
|
+
conclusion, output, build, sourceFingerprint, sourceFingerprintAfter: null,
|
|
2746
|
+
changedDuringTrial: false, baseline: null, candidate: null, elapsedMs: now() - started,
|
|
2747
|
+
depth: 'shallow', escalated: false, ...extra,
|
|
2748
|
+
});
|
|
2749
|
+
const verify = options.verify ?? ((name) => runHeadlessVerification(name));
|
|
2750
|
+
/** 只把物化需要的字段传下去(避免把 baseline/verify 等选项混进快照选项)。 */
|
|
2751
|
+
const snapshotOptions = (depth) => ({
|
|
2752
|
+
depth,
|
|
2753
|
+
...options.ctx === undefined ? {} : { ctx: options.ctx },
|
|
2754
|
+
...options.installAnchor === undefined ? {} : { installAnchor: options.installAnchor },
|
|
2755
|
+
...options.runCommand === undefined ? {} : { runCommand: options.runCommand },
|
|
2756
|
+
...options.bundlesOf === undefined ? {} : { bundlesOf: options.bundlesOf },
|
|
2757
|
+
});
|
|
2758
|
+
const requested = options.depth ?? 'auto';
|
|
2759
|
+
let depth = requested === 'full' ? 'full' : 'shallow';
|
|
2760
|
+
let escalated = false;
|
|
2761
|
+
let escalationReason;
|
|
2762
|
+
const materialize = async (which) => await createTrialEnvironment(realName, { snapshot: snapshotOptions(which) });
|
|
2763
|
+
let materialized = await materialize(depth);
|
|
2764
|
+
if (!materialized.ok) {
|
|
2765
|
+
return done('cannot-trial', '无法试装:测试环境没有物化成功(' + String(materialized.code) + ')。\n'
|
|
2766
|
+
+ materialized.output + '\n这不等于通过。', { depth });
|
|
2767
|
+
}
|
|
2768
|
+
let baseline = null;
|
|
2769
|
+
if (options.baseline !== false) {
|
|
2770
|
+
let verified = await verify(target);
|
|
2771
|
+
baseline = verified.verdict;
|
|
2772
|
+
// 以启动为判据的升级:**只对明确失败**升级;undetermined 是判不出来,不许悄悄换成 full。
|
|
2773
|
+
if (baseline.kind === 'failed' && requested === 'auto') {
|
|
2774
|
+
escalated = true;
|
|
2775
|
+
escalationReason = baseline.reason;
|
|
2776
|
+
depth = 'full';
|
|
2777
|
+
materialized = await materialize(depth);
|
|
2778
|
+
if (!materialized.ok) {
|
|
2779
|
+
return done('cannot-trial', '无法试装:轻量副本基线失败后改用完整副本,但重新建立失败('
|
|
2780
|
+
+ String(materialized.code) + ')。\n' + materialized.output + '\n这不等于通过。', { depth, escalated, escalationReason });
|
|
2781
|
+
}
|
|
2782
|
+
verified = await verify(target);
|
|
2783
|
+
baseline = verified.verdict;
|
|
2784
|
+
}
|
|
2785
|
+
if (baseline.kind !== 'mounted') {
|
|
2786
|
+
// 三种形态各说各的话:判不出来 ≠ 基线坏了(下面这两句以前共用一条"基线起不来",是错误归因)。
|
|
2787
|
+
const head = baseline.kind === 'undetermined'
|
|
2788
|
+
? '这次验证没有给出判定(既没启动成功,也没报启动失败)—— 这不是 ' + spec + ' 的问题,试装无法判断它。\n'
|
|
2789
|
+
: escalated
|
|
2790
|
+
? '环境副本的基线本身就起不来(轻量副本与完整副本都试过,两次都没起来)—— 这不是 ' + spec + ' 的问题。\n'
|
|
2791
|
+
+ '轻量副本为什么不给力:' + String(escalationReason) + '\n'
|
|
2792
|
+
: '环境副本的基线本身就起不来 —— 这不是 ' + spec + ' 的问题,试装无法判断它。\n';
|
|
2793
|
+
const detail = baseline.kind === 'failed'
|
|
2794
|
+
? '根因:' + baseline.reason + '\n' + baseline.chain.join('\n')
|
|
2795
|
+
: '判不出来:' + baseline.reason;
|
|
2796
|
+
// failed → baseline-broken;undetermined → cannot-trial(判不出来就是无法试装,不许算通过)。
|
|
2797
|
+
return done(judgeTrialOutcome(baseline, null), head + detail + '\n'
|
|
2798
|
+
// R3(§12.9):shallow / full 是内部深度代号,说成"轻量副本 / 完整副本"。
|
|
2799
|
+
+ '实际深度:' + depthLabel(depth) + (escalated ? '(由轻量副本升级)' : '') + '\n'
|
|
2800
|
+
+ '构建:' + describeBuild(build), { baseline, depth, escalated, escalationReason });
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
let runner;
|
|
2804
|
+
let context;
|
|
2805
|
+
try {
|
|
2806
|
+
context = operationContext(target, environmentDir(target), environmentDir(target), options);
|
|
2807
|
+
runner = await officialRunner(options);
|
|
2808
|
+
}
|
|
2809
|
+
catch (error) {
|
|
2810
|
+
return done('cannot-trial', '无法试装:官方安装通道不可用(' + messageOf(error) + ')。这不等于通过。', { baseline });
|
|
2811
|
+
}
|
|
2812
|
+
// 装之前记下依赖清单:reconcile 会跳过"既有的"依赖,这份事实是下面那条守卫的判据。
|
|
2813
|
+
const beforeDeps = trialDependencies(target);
|
|
2814
|
+
// 让候选成为"新装"(task-84 阻断级修复):候选已在测试环境依赖里时先卸掉它,否则官方
|
|
2815
|
+
// reconcile 跳过既有依赖 → 进不了层栈 → 挂载期不加载它 → 这次试装什么都没验证到。
|
|
2816
|
+
const detached = await detachCandidate(runner, context, options, target, spec);
|
|
2817
|
+
const installArgs = options.allowNetwork === false ? ['add', '--offline', spec] : ['add', spec];
|
|
2818
|
+
const installed = await runPackageOperation(runner, context, installArgs, options);
|
|
2819
|
+
if (installed.exitCode !== 0) {
|
|
2820
|
+
const reason = options.allowNetwork === false
|
|
2821
|
+
? '已禁用联网,且本地 store 里没有这个包'
|
|
2822
|
+
: '官方安装失败(exitCode=' + String(installed.exitCode) + ')';
|
|
2823
|
+
return done('cannot-trial', '无法试装:' + reason + '。\n'
|
|
2824
|
+
+ installed.output.trim().slice(-800) + '\n这不等于通过。', { baseline, detached: detached.note });
|
|
2825
|
+
}
|
|
2826
|
+
// 兜底守卫(task-80):装上了不等于验证到了。候选没进层栈 → 这次试装什么都没验证到,
|
|
2827
|
+
// 一律 cannot-trial,**任何情况下都不许 passed**(假通过的整类问题在这里被掐断)。
|
|
2828
|
+
// 正常顺序下(上面刚把候选摘成"新装")这条不该触发——它兜的是摘不掉、候选不声明
|
|
2829
|
+
// dsh.bundle、测试环境有残留这些真实情况。
|
|
2830
|
+
const activation = trialActivation(target, beforeDeps, spec, detached);
|
|
2831
|
+
if (!activation.ok) {
|
|
2832
|
+
return done('cannot-trial', '无法试装:' + activation.detail + '\n这不等于通过。', { baseline, depth, escalated, escalationReason, activation: activation.fact });
|
|
2833
|
+
}
|
|
2834
|
+
const after = await verify(target);
|
|
2835
|
+
const conclusion = judgeTrialOutcome(baseline, after.verdict);
|
|
2836
|
+
const sourceFingerprintAfter = await environmentFingerprint(realName, {
|
|
2837
|
+
...options.listBundles === undefined ? {} : { listBundles: options.listBundles },
|
|
2838
|
+
});
|
|
2839
|
+
const changedDuringTrial = !sameFingerprint(sourceFingerprint, sourceFingerprintAfter);
|
|
2840
|
+
const lines = [describeConclusion(conclusion, spec, target)
|
|
2841
|
+
+ '\n基线启动:' + (baseline === null ? '未做' : baseline.kind) + '|候选启动:' + after.verdict.kind
|
|
2842
|
+
+ '(验证耗时 ' + String(after.elapsedMs) + 'ms)'];
|
|
2843
|
+
if (after.verdict.kind === 'failed')
|
|
2844
|
+
lines.push('根因链:\n' + after.verdict.chain.join('\n'));
|
|
2845
|
+
if (after.verdict.kind === 'undetermined')
|
|
2846
|
+
lines.push('判不出来:' + after.verdict.reason);
|
|
2847
|
+
// R2 + R3(§12.9):深度一行;升级原因另起一行,且不用 shallow / 浅快照 这类内部代号。
|
|
2848
|
+
lines.push('实际深度:' + depthLabel(depth));
|
|
2849
|
+
if (escalated)
|
|
2850
|
+
lines.push('(从轻量副本升级,因为基线失败 —— ' + String(escalationReason) + ')');
|
|
2851
|
+
lines.push('源环境指纹:' + sourceFingerprint.hash + '(试装前)');
|
|
2852
|
+
if (changedDuringTrial) {
|
|
2853
|
+
lines.push('注意:试装期间 ' + realName + ' 的环境又变过(指纹 ' + sourceFingerprintAfter.hash
|
|
2854
|
+
+ '),这个结论可能不适用');
|
|
2855
|
+
}
|
|
2856
|
+
lines.push('构建:' + describeBuild(build));
|
|
2857
|
+
return done(conclusion, lines.join('\n'), {
|
|
2858
|
+
baseline, candidate: after.verdict, sourceFingerprintAfter, changedDuringTrial,
|
|
2859
|
+
depth, escalated, detached: detached.note, activation: activation.fact,
|
|
2860
|
+
...escalationReason === undefined ? {} : { escalationReason },
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2863
|
+
/** 构建指纹的一句话描述(放进结果里,事后能对上产物)。 */
|
|
2864
|
+
function describeBuild(build) {
|
|
2865
|
+
return (build.artifactMd5 === null ? 'md5 不可读' : 'md5=' + build.artifactMd5.slice(0, 12))
|
|
2866
|
+
+ (build.artifactMtime === null ? '' : ' mtime=' + build.artifactMtime)
|
|
2867
|
+
+ (build.gitHead === null ? '(读不到 git HEAD)' : ' head=' + build.gitHead.slice(0, 12));
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* 快照深度 → 用户能读的标签(§12.9 R3:shallow / full 是内部代号)。
|
|
2871
|
+
*
|
|
2872
|
+
* 为什么必须有这个映射:代号表里那六个词都是**中文**,而 shallow / full 是**拉丁词**——
|
|
2873
|
+
* 它们不会触发中文清单,于是"实际深度:full"这种行会从护栏下溜过去(task-89 实测发现)。
|
|
2874
|
+
* 说明:`depth` 这个值本身仍留在 `result.depth` 字段里(机器可读,供排查与断言),
|
|
2875
|
+
* 只有**上屏那一句**换成人话。
|
|
2876
|
+
*
|
|
2877
|
+
* @param depth - 快照深度。
|
|
2878
|
+
* @returns 面向用户的标签。
|
|
2879
|
+
*/
|
|
2880
|
+
function depthLabel(depth) {
|
|
2881
|
+
return depth === 'shallow' ? '轻量副本' : '完整副本';
|
|
2882
|
+
}
|
|
2883
|
+
/** 三种结论各自的措辞(§5.2:各有措辞、不得混)。 */
|
|
2884
|
+
function describeConclusion(conclusion, spec, target) {
|
|
2885
|
+
switch (conclusion) {
|
|
2886
|
+
case 'passed':
|
|
2887
|
+
return '试装通过:' + spec + ' 装进 ' + target + ' 后仍能正常启动';
|
|
2888
|
+
case 'baseline-broken':
|
|
2889
|
+
return '环境副本的基线就起不来:这不是 ' + spec + ' 的问题';
|
|
2890
|
+
case 'candidate-broken':
|
|
2891
|
+
return '候选包导致启动失败:' + spec + ' 装进 ' + target + ' 之后树起不来';
|
|
2892
|
+
default:
|
|
2893
|
+
return '无法试装:这次没有得到有效判定(不等于通过)';
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
// ── 备份:导出 / 差异 / 恢复 ──────────────────────────────────────────────
|
|
2897
|
+
/**
|
|
2898
|
+
* 备份文档格式标识(运行期常量)。
|
|
2899
|
+
*
|
|
2900
|
+
* 类型声明在 types.ts 的 BackupFormat(单一事实来源);这里用类型断言把运行期值
|
|
2901
|
+
* 绑到那个字面量上 —— 改一处漏另一处会编译失败。
|
|
2902
|
+
*/
|
|
2903
|
+
export const BACKUP_FORMAT = 'dsh-plugin-manager-companion/environment-backup';
|
|
2904
|
+
/**
|
|
2905
|
+
* 导出环境备份。
|
|
2906
|
+
*
|
|
2907
|
+
* @param name - 环境名。
|
|
2908
|
+
* @returns 备份文档。
|
|
2909
|
+
* @throws {EnvironmentError} 名称不合法或环境不存在时(code 为 invalid-name / not-found)。
|
|
2910
|
+
*/
|
|
2911
|
+
export function backupExport(name) {
|
|
2912
|
+
const problem = environmentNameProblem(name);
|
|
2913
|
+
if (problem !== null)
|
|
2914
|
+
throw new EnvironmentError('invalid-name', problem);
|
|
2915
|
+
const dir = environmentDir(name);
|
|
2916
|
+
if (!existsSync(join(dir, 'package.json')))
|
|
2917
|
+
throw new EnvironmentError('not-found', '环境不存在:' + name);
|
|
2918
|
+
const manifest = readEnvironmentManifest(dir);
|
|
2919
|
+
const dependencies = {};
|
|
2920
|
+
for (const [key, value] of Object.entries(recordedDependencies(dir))) {
|
|
2921
|
+
if (typeof value === 'string' && value.length > 0)
|
|
2922
|
+
dependencies[key] = value;
|
|
2923
|
+
}
|
|
2924
|
+
return {
|
|
2925
|
+
format: BACKUP_FORMAT,
|
|
2926
|
+
version: 1,
|
|
2927
|
+
exportedAt: new Date().toISOString(),
|
|
2928
|
+
environment: name,
|
|
2929
|
+
bundles: [...manifest.bundles],
|
|
2930
|
+
dependencies,
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
/**
|
|
2934
|
+
* 校验一份来自外部的备份文档。
|
|
2935
|
+
*
|
|
2936
|
+
* 备份经文件或网络进入本进程,是持久化边界:形状必须在这里挡住,而不是让后面的
|
|
2937
|
+
* 循环抛 TypeError。
|
|
2938
|
+
*
|
|
2939
|
+
* @param value - 待校验的文档。
|
|
2940
|
+
* @returns 通过时为 null,否则为原因。
|
|
2941
|
+
*/
|
|
2942
|
+
function backupProblem(value) {
|
|
2943
|
+
if (typeof value !== 'object' || value === null)
|
|
2944
|
+
return '备份不是对象';
|
|
2945
|
+
const backup = value;
|
|
2946
|
+
if (backup.format !== BACKUP_FORMAT)
|
|
2947
|
+
return '备份格式标识不匹配(期望 ' + BACKUP_FORMAT + ')';
|
|
2948
|
+
if (backup.version !== 1)
|
|
2949
|
+
return '备份版本不支持:' + String(backup.version);
|
|
2950
|
+
if (typeof backup.environment !== 'string' || !isSafeEnvironmentName(backup.environment)) {
|
|
2951
|
+
return '备份里的环境名不合法:' + JSON.stringify(backup.environment);
|
|
2952
|
+
}
|
|
2953
|
+
if (!Array.isArray(backup.bundles) || backup.bundles.some((item) => typeof item !== 'string')) {
|
|
2954
|
+
return '备份的 bundles 不是字符串数组';
|
|
2955
|
+
}
|
|
2956
|
+
if (typeof backup.dependencies !== 'object' || backup.dependencies === null)
|
|
2957
|
+
return '备份的 dependencies 不是对象';
|
|
2958
|
+
for (const [key, spec] of Object.entries(backup.dependencies)) {
|
|
2959
|
+
if (/[\\/:]/.test(key))
|
|
2960
|
+
return '依赖名不合法:' + JSON.stringify(key);
|
|
2961
|
+
if (typeof spec !== 'string')
|
|
2962
|
+
return '依赖 ' + key + ' 的来源不是字符串';
|
|
2963
|
+
}
|
|
2964
|
+
return null;
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* 对比备份与目标环境,分四类:缺失、已装、目标环境不存在、不可恢复。
|
|
2968
|
+
*
|
|
2969
|
+
* 恢复前必须先跑这一遍:差异既是用户确认的依据,也是恢复的输入(只装 missing)。
|
|
2970
|
+
*
|
|
2971
|
+
* @param backup - 备份文档。
|
|
2972
|
+
* @param target - 目标环境名。
|
|
2973
|
+
* @returns 差异。
|
|
2974
|
+
* @throws {EnvironmentError} 备份结构不合法(code 为 unsafe-backup)或目标名不合法时。
|
|
2975
|
+
*/
|
|
2976
|
+
export function backupDiff(backup, target) {
|
|
2977
|
+
const unsafe = backupProblem(backup);
|
|
2978
|
+
if (unsafe !== null)
|
|
2979
|
+
throw new EnvironmentError('unsafe-backup', unsafe);
|
|
2980
|
+
const problem = environmentNameProblem(target);
|
|
2981
|
+
if (problem !== null)
|
|
2982
|
+
throw new EnvironmentError('invalid-name', problem);
|
|
2983
|
+
const targetDir = environmentDir(target);
|
|
2984
|
+
if (!existsSync(join(targetDir, 'package.json'))) {
|
|
2985
|
+
return { ok: false, missing: [], already: [], missingProfiles: [target], unrestorable: [], bundlesMissing: [] };
|
|
2986
|
+
}
|
|
2987
|
+
const current = readEnvironmentManifest(targetDir);
|
|
2988
|
+
// readEnvironmentManifest 的 dependencies 已经是名字数组(不是对象)。
|
|
2989
|
+
const installed = new Set(current.dependencies);
|
|
2990
|
+
// 本地来源的相对路径记录在源环境目录里;源环境已不在时退到目标目录,免得把
|
|
2991
|
+
// 「环境被删了」误判成「本地包还在」。
|
|
2992
|
+
const backupDir = environmentDir(backup.environment);
|
|
2993
|
+
const baseDir = existsSync(backupDir) ? backupDir : targetDir;
|
|
2994
|
+
const missing = [];
|
|
2995
|
+
const already = [];
|
|
2996
|
+
const unrestorable = [];
|
|
2997
|
+
for (const [name, spec] of Object.entries(backup.dependencies)) {
|
|
2998
|
+
if (installed.has(name)) {
|
|
2999
|
+
already.push(name);
|
|
3000
|
+
continue;
|
|
3001
|
+
}
|
|
3002
|
+
const resolved = resolveInstallSpec(spec, baseDir);
|
|
3003
|
+
if (resolved === null) {
|
|
3004
|
+
unrestorable.push(name + '(本地来源已不存在:' + spec + ')');
|
|
3005
|
+
continue;
|
|
3006
|
+
}
|
|
3007
|
+
missing.push({ name, source: resolved.spec });
|
|
3008
|
+
}
|
|
3009
|
+
const bundlesMissing = backup.bundles.filter((bundle) => !current.bundles.includes(bundle));
|
|
3010
|
+
return { ok: unrestorable.length === 0, missing, already, missingProfiles: [], unrestorable, bundlesMissing };
|
|
3011
|
+
}
|
|
3012
|
+
/**
|
|
3013
|
+
* 按备份恢复一个环境。
|
|
3014
|
+
*
|
|
3015
|
+
* 三步:先算差异(缺失/已装/目标不存在/不可恢复),再用官方 operations 逐条重装
|
|
3016
|
+
* 缺失依赖,最后在官方文件锁下补回备份的 bundle 层栈。整批只占一次进程内互斥。
|
|
3017
|
+
*
|
|
3018
|
+
* 锁的用法按官方意图:装包由 runPluginCommand 自己持锁(我们再套一层会自锁 —— 同一
|
|
3019
|
+
* 把 package.json.lock);bundle 层栈的读-改-写由我们用 withFileLock 独占,避免与
|
|
3020
|
+
* 并发的安装互相覆盖。
|
|
3021
|
+
*
|
|
3022
|
+
* @param backup - 备份文档。
|
|
3023
|
+
* @param target - 目标环境名。
|
|
3024
|
+
* @param options - 恢复选项。
|
|
3025
|
+
* @returns 操作结果。
|
|
3026
|
+
*/
|
|
3027
|
+
export async function backupRestore(backup, target, options = {}) {
|
|
3028
|
+
let diff;
|
|
3029
|
+
try {
|
|
3030
|
+
diff = backupDiff(backup, target);
|
|
3031
|
+
}
|
|
3032
|
+
catch (error) {
|
|
3033
|
+
return failure(error instanceof EnvironmentError ? error.code : 'unsafe-backup', messageOf(error));
|
|
3034
|
+
}
|
|
3035
|
+
if (diff.missingProfiles.length > 0) {
|
|
3036
|
+
// 整句括注删(copy-dev 复核):前半是立场句(②),后半「在环境列表里创建」是把同屏控件写进句子
|
|
3037
|
+
// (DESIGN §12.3.2 指路式引导)。「环境不存在」这个事实本身已经够用户决定下一步。
|
|
3038
|
+
return failure('not-found', '目标环境不存在:' + diff.missingProfiles.join(', '));
|
|
3039
|
+
}
|
|
3040
|
+
const plan = describeDiff(diff);
|
|
3041
|
+
if (diff.missing.length === 0 && diff.bundlesMissing.length === 0) {
|
|
3042
|
+
// 结论句删:紧随其后的 plan 第一行就是「待重装 0 项:(无)」,同一件事不必说两遍。
|
|
3043
|
+
return diff.unrestorable.length === 0
|
|
3044
|
+
? success(plan)
|
|
3045
|
+
: failure('unrestorable', plan);
|
|
3046
|
+
}
|
|
3047
|
+
if (options.dryRun === true)
|
|
3048
|
+
return success('(演练)将执行:\n' + plan);
|
|
3049
|
+
const targetDir = environmentDir(target);
|
|
3050
|
+
let context;
|
|
3051
|
+
let runner;
|
|
3052
|
+
try {
|
|
3053
|
+
context = operationContext(target, targetDir, targetDir, options);
|
|
3054
|
+
runner = await officialRunner(options);
|
|
3055
|
+
}
|
|
3056
|
+
catch (error) {
|
|
3057
|
+
return failure(error instanceof EnvironmentError ? error.code : 'official-unavailable', messageOf(error));
|
|
3058
|
+
}
|
|
3059
|
+
return enqueueMutation(async () => {
|
|
3060
|
+
const outputs = [];
|
|
3061
|
+
let ok = diff.unrestorable.length === 0;
|
|
3062
|
+
for (const entry of diff.missing) {
|
|
3063
|
+
let result;
|
|
3064
|
+
try {
|
|
3065
|
+
result = await runPackageOperation(runner, context, ['add', entry.source], options);
|
|
3066
|
+
}
|
|
3067
|
+
catch (error) {
|
|
3068
|
+
// 同 copyPlugins:单个条目失败不带走整批。
|
|
3069
|
+
outputs.push('# ' + entry.name + ':失败\n' + messageOf(error));
|
|
3070
|
+
ok = false;
|
|
3071
|
+
continue;
|
|
3072
|
+
}
|
|
3073
|
+
outputs.push('# ' + entry.name + ':' + (result.exitCode === 0 ? '已恢复' : '失败')
|
|
3074
|
+
+ '\n' + result.output.trim());
|
|
3075
|
+
if (result.exitCode !== 0)
|
|
3076
|
+
ok = false;
|
|
3077
|
+
}
|
|
3078
|
+
let skippedBundles = [];
|
|
3079
|
+
if (diff.bundlesMissing.length > 0) {
|
|
3080
|
+
try {
|
|
3081
|
+
const restored = await restoreBundles(targetDir, backup.bundles, context.installAnchor);
|
|
3082
|
+
skippedBundles = restored.skipped;
|
|
3083
|
+
outputs.push(restored.written === null
|
|
3084
|
+
? '# 组合层:没有可补回的层'
|
|
3085
|
+
: '# 组合层:已补回 -> ' + restored.written);
|
|
3086
|
+
if (skippedBundles.length > 0) {
|
|
3087
|
+
outputs.push('# 组合层:未补回 ' + skippedBundles.join(', ')
|
|
3088
|
+
+ '(目标环境既找不到它,它也没声明自己是一个组合包;照写会让环境下次启动失败)');
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
catch (error) {
|
|
3092
|
+
outputs.push('# 组合层:失败 ' + messageOf(error));
|
|
3093
|
+
ok = false;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
if (skippedBundles.length > 0)
|
|
3097
|
+
ok = false;
|
|
3098
|
+
if (diff.unrestorable.length > 0)
|
|
3099
|
+
outputs.push('不可恢复:\n ' + diff.unrestorable.join('\n '));
|
|
3100
|
+
if (ok)
|
|
3101
|
+
return { ok: true, output: outputs.join('\n\n') };
|
|
3102
|
+
return {
|
|
3103
|
+
ok: false,
|
|
3104
|
+
code: skippedBundles.length > 0 ? 'unrestorable' : 'package-operation-failed',
|
|
3105
|
+
output: outputs.join('\n\n'),
|
|
3106
|
+
};
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* 把差异渲染成给用户看的计划文本。
|
|
3111
|
+
*
|
|
3112
|
+
* @param diff - 差异。
|
|
3113
|
+
* @returns 多行说明。
|
|
3114
|
+
*/
|
|
3115
|
+
function describeDiff(diff) {
|
|
3116
|
+
const lines = ['待重装 ' + String(diff.missing.length) + ' 项:'
|
|
3117
|
+
+ (diff.missing.length === 0 ? '(无)' : '\n ' + diff.missing.map((entry) => entry.name + ' <- ' + entry.source).join('\n '))];
|
|
3118
|
+
lines.push('已装 ' + String(diff.already.length) + ' 项'
|
|
3119
|
+
+ (diff.already.length > 0 ? ':' + diff.already.join(', ') : ''));
|
|
3120
|
+
if (diff.bundlesMissing.length > 0)
|
|
3121
|
+
lines.push('待补回的组合层:' + diff.bundlesMissing.join(', '));
|
|
3122
|
+
if (diff.unrestorable.length > 0)
|
|
3123
|
+
lines.push('不可恢复:\n ' + diff.unrestorable.join('\n '));
|
|
3124
|
+
return lines.join('\n');
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* 把备份的 bundle 层栈并回目标环境(官方 writeProfileBundles,全程持文件锁)。
|
|
3128
|
+
*
|
|
3129
|
+
* 顺序以备份为准(层栈顺序决定 patch 应用顺序),目标环境多出来的 bundle 追加在
|
|
3130
|
+
* 末尾 —— 恢复是补回,不是裁剪用户现在的组合。
|
|
3131
|
+
*
|
|
3132
|
+
* 只写回「现在确实能启用」的层:用官方 bundleManifest 判定(能从安装锚点或环境
|
|
3133
|
+
* 目录解析出来,且声明了 dsh.bundle.patch)。备份里那些已经解析不到的层如果照写,
|
|
3134
|
+
* profile 下次启动会直接失败(官方 loadProfile 对「列了 bundle 却没有 dsh.bundle」
|
|
3135
|
+
* 是 fail loud),所以宁可少写并如实报告。
|
|
3136
|
+
*
|
|
3137
|
+
* 这里不用官方 sanitizeProfile:那是给「profile 起不来」的急救路径,会把用户的
|
|
3138
|
+
* cordis.patch.yml 移走;日常恢复不该动用户的补丁层。
|
|
3139
|
+
*
|
|
3140
|
+
* @param dir - 目标环境目录。
|
|
3141
|
+
* @param wanted - 备份里的 bundle 层栈。
|
|
3142
|
+
* @param installAnchor - 官方安装锚点(官方 bundle 解析的第一锚点)。
|
|
3143
|
+
* @returns 写回的层栈与跳过的 bundle。
|
|
3144
|
+
*/
|
|
3145
|
+
async function restoreBundles(dir, wanted, installAnchor) {
|
|
3146
|
+
const installable = await officialBundlePredicate(installAnchor);
|
|
3147
|
+
return withFileLock(join(dir, 'package.json'), async () => {
|
|
3148
|
+
const manifest = readProfileManifest(OUR_PACKAGE_NAME, dir);
|
|
3149
|
+
const current = manifest.dsh?.profile?.bundles ?? [];
|
|
3150
|
+
const skipped = [];
|
|
3151
|
+
const additions = [];
|
|
3152
|
+
for (const name of wanted) {
|
|
3153
|
+
if (current.includes(name) || additions.includes(name))
|
|
3154
|
+
continue;
|
|
3155
|
+
if (installable !== null && installable(name, dir))
|
|
3156
|
+
additions.push(name);
|
|
3157
|
+
else
|
|
3158
|
+
skipped.push(name);
|
|
3159
|
+
}
|
|
3160
|
+
const desired = [...wanted.filter((name) => current.includes(name) || additions.includes(name)),
|
|
3161
|
+
...current.filter((name) => !wanted.includes(name))];
|
|
3162
|
+
if (additions.length === 0) {
|
|
3163
|
+
return { written: null, skipped };
|
|
3164
|
+
}
|
|
3165
|
+
writeProfileBundles(dir, manifest, desired);
|
|
3166
|
+
return { written: desired.join(' -> '), skipped };
|
|
3167
|
+
}, { waitMs: OPERATION_LOCK_WAIT_MS });
|
|
3168
|
+
}
|
|
3169
|
+
/**
|
|
3170
|
+
* 取官方 bundle 判定器。
|
|
3171
|
+
*
|
|
3172
|
+
* @param installAnchor - 官方安装锚点。
|
|
3173
|
+
* @returns 判定器;官方子路径不可用时 null(此时不写回任何新层,只报告)。
|
|
3174
|
+
*/
|
|
3175
|
+
async function officialBundlePredicate(installAnchor) {
|
|
3176
|
+
try {
|
|
3177
|
+
const module = await import('@deepseek-ai/dsh-plugin-manager/operations');
|
|
3178
|
+
return (name, dir) => {
|
|
3179
|
+
try {
|
|
3180
|
+
return module.bundleManifest(name, dir, installAnchor) !== undefined;
|
|
3181
|
+
}
|
|
3182
|
+
catch {
|
|
3183
|
+
// 解析不出来:这个层现在启用会让 profile 起不来。
|
|
3184
|
+
return false;
|
|
3185
|
+
}
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
catch {
|
|
3189
|
+
return null;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* 目录类文件操作的重试。
|
|
3194
|
+
*
|
|
3195
|
+
* @param operation - 要执行的文件操作。
|
|
3196
|
+
* @param attempts - 尝试次数上限。
|
|
3197
|
+
*/
|
|
3198
|
+
async function retryFs(operation, attempts = 5) {
|
|
3199
|
+
const sleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
|
|
3200
|
+
for (let attempt = 0;; attempt += 1) {
|
|
3201
|
+
try {
|
|
3202
|
+
operation();
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
catch (error) {
|
|
3206
|
+
const code = error.code ?? '';
|
|
3207
|
+
// Windows 上杀毒/索引会短暂占用目录,这几类错误重试有意义。
|
|
3208
|
+
const retryable = code === 'EBUSY' || code === 'EPERM' || code === 'EACCES' || code === 'ENOTEMPTY';
|
|
3209
|
+
if (!retryable || attempt >= attempts - 1)
|
|
3210
|
+
throw error;
|
|
3211
|
+
await sleep(50 * (attempt + 1));
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
}
|