zcode-idea 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +295 -2
- package/bin/zcode-idea.mjs +463 -0
- package/lib/download.mjs +294 -0
- package/lib/errors.mjs +17 -0
- package/lib/installed.mjs +86 -0
- package/lib/launch.mjs +214 -0
- package/lib/log.mjs +36 -0
- package/lib/manifest.mjs +301 -0
- package/lib/plugin-signing-public.pem +3 -0
- package/lib/processes.mjs +194 -0
- package/lib/products.mjs +352 -0
- package/lib/prompt.mjs +37 -0
- package/lib/remote.mjs +356 -0
- package/lib/targets.mjs +415 -0
- package/lib/unzip.mjs +157 -0
- package/package.json +35 -4
package/lib/manifest.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件 zip 的结构校验(下载完成 / 使用 --zip 时、解压之前执行)。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界:
|
|
5
|
+
* - 本模块只校验 zip 自身:sha256/size、条目安全性、主 jar 与 plugin.xml、runtime 完整性;
|
|
6
|
+
* - 远端 manifest 的验签与字段校验在 remote.mjs;
|
|
7
|
+
* - manifest 签名通过后,zip 的 sha256/size 来自受信任的 manifest,本模块负责对账。
|
|
8
|
+
*
|
|
9
|
+
* 安全策略:拒绝绝对路径、`..`、反斜杠、符号链接、不在 zcode-ide/ 下的条目、重复文件条目。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import fsp from "node:fs/promises";
|
|
15
|
+
import yauzl from "yauzl";
|
|
16
|
+
import { InstallError } from "./errors.mjs";
|
|
17
|
+
|
|
18
|
+
export const PACKAGE_PREFIX = "zcode-ide/";
|
|
19
|
+
export const PLUGIN_ID = "dev.zcode.ide";
|
|
20
|
+
|
|
21
|
+
/* -------------------------------------------------------------------------- */
|
|
22
|
+
/* zip 读取工具 */
|
|
23
|
+
/* -------------------------------------------------------------------------- */
|
|
24
|
+
|
|
25
|
+
export function openZip(zipPath) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zip) => {
|
|
28
|
+
if (err) reject(new InstallError(`无法打开插件包 ${zipPath}:${err.message}。文件可能损坏。`));
|
|
29
|
+
else resolve(zip);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function openZipFromBuffer(buffer, label = "jar") {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
yauzl.fromBuffer(buffer, { lazyEntries: true, autoClose: false }, (err, zip) => {
|
|
37
|
+
if (err) reject(new InstallError(`无法读取 ${label}:${err.message}`));
|
|
38
|
+
else resolve(zip);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function openReadStream(zip, entry) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
zip.openReadStream(entry, (err, stream) => {
|
|
46
|
+
if (err) reject(new InstallError(`读取 zip 条目 ${entry.fileName} 失败:${err.message}`));
|
|
47
|
+
else resolve(stream);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readEntryBuffer(zip, entry) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
zip.openReadStream(entry, (err, stream) => {
|
|
55
|
+
if (err) {
|
|
56
|
+
reject(new InstallError(`读取 zip 条目 ${entry.fileName} 失败:${err.message}`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const chunks = [];
|
|
60
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
61
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
62
|
+
stream.on("error", (streamErr) =>
|
|
63
|
+
reject(new InstallError(`读取 zip 条目 ${entry.fileName} 失败:${streamErr.message}`)),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 顺序遍历 zip 条目。handler 抛错会终止遍历。
|
|
71
|
+
* 返回遍历到的条目总数。zip 由调用方负责关闭(遍历结束后仍可 openReadStream)。
|
|
72
|
+
*/
|
|
73
|
+
export function iterateZip(zip, handler) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
let count = 0;
|
|
76
|
+
let settled = false;
|
|
77
|
+
const finish = (fn, value) => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
fn(value);
|
|
81
|
+
};
|
|
82
|
+
zip.on("error", (err) =>
|
|
83
|
+
finish(
|
|
84
|
+
reject,
|
|
85
|
+
new InstallError(`插件包内含不安全条目或已损坏(${err.message})。已中止安装。`),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
zip.on("end", () => finish(resolve, count));
|
|
89
|
+
zip.on("entry", (entry) => {
|
|
90
|
+
const index = count;
|
|
91
|
+
count += 1;
|
|
92
|
+
Promise.resolve()
|
|
93
|
+
.then(() => handler(entry, index))
|
|
94
|
+
.then(() => {
|
|
95
|
+
if (!settled) zip.readEntry();
|
|
96
|
+
})
|
|
97
|
+
.catch((err) => finish(reject, err));
|
|
98
|
+
});
|
|
99
|
+
zip.readEntry();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* -------------------------------------------------------------------------- */
|
|
104
|
+
/* 条目安全校验 */
|
|
105
|
+
/* -------------------------------------------------------------------------- */
|
|
106
|
+
|
|
107
|
+
/** 返回错误原因(中文),安全时返回 null。 */
|
|
108
|
+
export function validateZipEntryName(name) {
|
|
109
|
+
if (typeof name !== "string" || !name) return "条目名为空";
|
|
110
|
+
if (name.includes("\0")) return "条目名包含空字符";
|
|
111
|
+
if (name.includes("\\")) return "条目名包含反斜杠(Windows 路径写法)";
|
|
112
|
+
if (name.startsWith("/")) return "绝对路径";
|
|
113
|
+
if (/^[A-Za-z]:/.test(name)) return "带盘符的绝对路径";
|
|
114
|
+
const segments = name.split("/");
|
|
115
|
+
if (segments.some((segment) => segment === "..")) return "路径包含 ..(zip-slip)";
|
|
116
|
+
if (name !== "zcode-ide" && !name.startsWith(PACKAGE_PREFIX)) {
|
|
117
|
+
return `不在 ${PACKAGE_PREFIX} 目录下`;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** zip 条目是否是符号链接(unix mode 高 16 位中的文件类型位)。 */
|
|
123
|
+
export function isSymlinkEntry(entry) {
|
|
124
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
|
|
125
|
+
return (mode & 0o170000) === 0o120000;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isDirectoryEntry(entry) {
|
|
129
|
+
return entry.fileName.endsWith("/");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* -------------------------------------------------------------------------- */
|
|
133
|
+
/* 校验 */
|
|
134
|
+
/* -------------------------------------------------------------------------- */
|
|
135
|
+
|
|
136
|
+
export async function sha256File(filePath) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const hash = crypto.createHash("sha256");
|
|
139
|
+
const stream = fs.createReadStream(filePath);
|
|
140
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
141
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
142
|
+
stream.on("error", (err) => reject(new InstallError(`读取 ${filePath} 失败:${err.message}`)));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 校验插件 zip。
|
|
148
|
+
*
|
|
149
|
+
* @param {string} zipPath
|
|
150
|
+
* @param {object} [options]
|
|
151
|
+
* @param {string} [options.expectedSha256] 来自已验签 manifest 的 sha256
|
|
152
|
+
* @param {number} [options.expectedSize] 来自已验签 manifest 的 size
|
|
153
|
+
* @param {string} [options.expectedVersion] 来自已验签 manifest 的 pluginVersion
|
|
154
|
+
* @returns {Promise<object>} zip 摘要信息(供解压进度与汇总使用)
|
|
155
|
+
*/
|
|
156
|
+
export async function validatePluginZip(zipPath, options = {}) {
|
|
157
|
+
const { expectedSha256, expectedSize, expectedVersion } = options;
|
|
158
|
+
|
|
159
|
+
let stat;
|
|
160
|
+
try {
|
|
161
|
+
stat = await fsp.stat(zipPath);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
throw new InstallError(`插件包不存在或不可读:${zipPath}(${err.message})`);
|
|
164
|
+
}
|
|
165
|
+
if (!stat.isFile()) {
|
|
166
|
+
throw new InstallError(`插件包不是文件:${zipPath}`);
|
|
167
|
+
}
|
|
168
|
+
if (expectedSize !== undefined && expectedSize !== null && stat.size !== Number(expectedSize)) {
|
|
169
|
+
throw new InstallError(
|
|
170
|
+
`插件包大小校验失败(期望 ${expectedSize} 字节,实际 ${stat.size} 字节):${zipPath}。` +
|
|
171
|
+
"请重新下载,或检查远端产物是否被替换。",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const actualSha256 = await sha256File(zipPath);
|
|
176
|
+
if (
|
|
177
|
+
expectedSha256 &&
|
|
178
|
+
actualSha256.toLowerCase() !== String(expectedSha256).toLowerCase()
|
|
179
|
+
) {
|
|
180
|
+
throw new InstallError(
|
|
181
|
+
`插件包 sha256 校验失败(期望 ${expectedSha256},实际 ${actualSha256}):${zipPath}。` +
|
|
182
|
+
"请重新下载,或检查远端产物是否被篡改。",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const zip = await openZip(zipPath);
|
|
187
|
+
let entryCount = 0;
|
|
188
|
+
let fileCount = 0;
|
|
189
|
+
let totalBytes = 0;
|
|
190
|
+
let jarEntry = null;
|
|
191
|
+
let hasRuntime = false;
|
|
192
|
+
let hasRuntimeIndex = false;
|
|
193
|
+
const seenFiles = new Set();
|
|
194
|
+
let pluginInfo = null;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await iterateZip(zip, async (entry) => {
|
|
198
|
+
entryCount += 1;
|
|
199
|
+
const nameError = validateZipEntryName(entry.fileName);
|
|
200
|
+
if (nameError) {
|
|
201
|
+
throw new InstallError(
|
|
202
|
+
`插件包内含不安全条目「${entry.fileName}」:${nameError}。已中止安装。`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (isSymlinkEntry(entry)) {
|
|
206
|
+
throw new InstallError(`插件包内含符号链接条目「${entry.fileName}」,出于安全考虑拒绝安装。`);
|
|
207
|
+
}
|
|
208
|
+
if (!isDirectoryEntry(entry)) {
|
|
209
|
+
if (seenFiles.has(entry.fileName)) {
|
|
210
|
+
throw new InstallError(`插件包内含重复文件条目「${entry.fileName}」,拒绝解压。`);
|
|
211
|
+
}
|
|
212
|
+
seenFiles.add(entry.fileName);
|
|
213
|
+
fileCount += 1;
|
|
214
|
+
totalBytes += entry.uncompressedSize;
|
|
215
|
+
}
|
|
216
|
+
if (/^zcode-ide\/lib\/zcode-ide-[^/]+\.jar$/i.test(entry.fileName) && !jarEntry) {
|
|
217
|
+
jarEntry = entry;
|
|
218
|
+
}
|
|
219
|
+
if (entry.fileName.startsWith("zcode-ide/runtime/")) hasRuntime = true;
|
|
220
|
+
if (entry.fileName === "zcode-ide/runtime/web/index.html") hasRuntimeIndex = true;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!jarEntry) {
|
|
224
|
+
throw new InstallError(
|
|
225
|
+
"插件包不完整:未找到 zcode-ide/lib/zcode-ide-*.jar。请重新构建插件后发布。",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (hasRuntime && !hasRuntimeIndex) {
|
|
229
|
+
throw new InstallError(
|
|
230
|
+
"插件包含 runtime/ 但缺少 runtime/web/index.html,完整包可能被截断。请重新构建 runtime。",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const jarBuffer = await readEntryBuffer(zip, jarEntry);
|
|
235
|
+
pluginInfo = await inspectPluginJar(jarBuffer, jarEntry.fileName);
|
|
236
|
+
if (!pluginInfo.version) {
|
|
237
|
+
throw new InstallError(
|
|
238
|
+
`主 jar(${jarEntry.fileName})的 META-INF/plugin.xml 缺少 <version>,无法判断插件版本。`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (expectedVersion && pluginInfo.version !== expectedVersion) {
|
|
242
|
+
throw new InstallError(
|
|
243
|
+
`插件版本不一致:manifest=${expectedVersion},主 jar=${pluginInfo.version}。` +
|
|
244
|
+
"请检查远端 manifest 与 zip 是否配套。",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
try {
|
|
249
|
+
zip.close();
|
|
250
|
+
} catch {
|
|
251
|
+
/* 已关闭 */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
zipPath,
|
|
257
|
+
sha256: actualSha256,
|
|
258
|
+
size: stat.size,
|
|
259
|
+
pluginVersion: pluginInfo.version,
|
|
260
|
+
pluginId: pluginInfo.id,
|
|
261
|
+
entryCount,
|
|
262
|
+
fileCount,
|
|
263
|
+
totalBytes,
|
|
264
|
+
jarName: jarEntry.fileName,
|
|
265
|
+
hasRuntime,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 读取主 jar 内的 META-INF/plugin.xml,确认插件 id 与版本。 */
|
|
270
|
+
export async function inspectPluginJar(jarBuffer, jarLabel = "主 jar") {
|
|
271
|
+
const zip = await openZipFromBuffer(jarBuffer, jarLabel);
|
|
272
|
+
let pluginXml = null;
|
|
273
|
+
try {
|
|
274
|
+
await iterateZip(zip, async (entry) => {
|
|
275
|
+
if (entry.fileName === "META-INF/plugin.xml") {
|
|
276
|
+
pluginXml = await readEntryBuffer(zip, entry);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
} finally {
|
|
280
|
+
try {
|
|
281
|
+
zip.close();
|
|
282
|
+
} catch {
|
|
283
|
+
/* 已关闭 */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!pluginXml) {
|
|
288
|
+
throw new InstallError(`${jarLabel} 内缺少 META-INF/plugin.xml,不是有效的 JetBrains 插件。`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const text = pluginXml.toString("utf8");
|
|
292
|
+
const idMatch = /<id>\s*([^<\s]+)\s*<\/id>/.exec(text);
|
|
293
|
+
if (!idMatch || idMatch[1] !== PLUGIN_ID) {
|
|
294
|
+
throw new InstallError(
|
|
295
|
+
`主 jar 的插件 id 不是 ${PLUGIN_ID}(实际:${idMatch ? idMatch[1] : "未找到 <id>"})。` +
|
|
296
|
+
"这可能不是 ZCode IDE 插件包。",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const versionMatch = /<version>\s*([^<\s]+)\s*<\/version>/.exec(text);
|
|
300
|
+
return { id: idMatch[1], version: versionMatch ? versionMatch[1] : "" };
|
|
301
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 关闭 IDE 进程。
|
|
3
|
+
*
|
|
4
|
+
* 只处理所选目标对应的产品进程(同一产品多版本只关一次),
|
|
5
|
+
* 先优雅关闭(Windows: taskkill 不带 /F;macOS/Linux: SIGTERM),
|
|
6
|
+
* 轮询到超时仍存活才强制结束(Windows /F;Unix SIGKILL)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { InstallError } from "./errors.mjs";
|
|
13
|
+
import { info, warn } from "./log.mjs";
|
|
14
|
+
import { getProcessNames, PRODUCT_BY_ID } from "./products.mjs";
|
|
15
|
+
|
|
16
|
+
const POLL_INTERVAL_MS = 500;
|
|
17
|
+
|
|
18
|
+
/** 列出当前运行的进程(只匹配传入的进程名)。 */
|
|
19
|
+
export function listProcesses(names, platform = process.platform) {
|
|
20
|
+
if (!names.length) return [];
|
|
21
|
+
return platform === "win32" ? listWindowsProcesses(names) : listUnixProcesses(names);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function listWindowsProcesses(names) {
|
|
25
|
+
const found = new Map();
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
const result = spawnSync(
|
|
28
|
+
"tasklist",
|
|
29
|
+
["/FI", `IMAGENAME eq ${name}`, "/FO", "CSV", "/NH"],
|
|
30
|
+
{ encoding: "utf8", windowsHide: true },
|
|
31
|
+
);
|
|
32
|
+
if (result.error) {
|
|
33
|
+
throw new InstallError(
|
|
34
|
+
`无法执行 tasklist(${result.error.message})。请在 Windows 上运行,或手动关闭 IDE。`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
for (const line of String(result.stdout || "").split(/\r?\n/)) {
|
|
38
|
+
const match = /^"([^"]+)","(\d+)"/.exec(line.trim());
|
|
39
|
+
if (!match) continue;
|
|
40
|
+
found.set(`${match[1].toLowerCase()}:${match[2]}`, {
|
|
41
|
+
name: match[1],
|
|
42
|
+
pid: Number(match[2]),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...found.values()];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function listUnixProcesses(names) {
|
|
50
|
+
const result = spawnSync("ps", ["-A", "-o", "pid=,comm="], { encoding: "utf8" });
|
|
51
|
+
if (result.error) {
|
|
52
|
+
throw new InstallError(
|
|
53
|
+
`无法执行 ps(${result.error.message})。请手动关闭 IDE 后重试。`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const wanted = new Set(names.map((name) => name.toLowerCase()));
|
|
57
|
+
const found = [];
|
|
58
|
+
for (const line of String(result.stdout || "").split(/\r?\n/)) {
|
|
59
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
|
60
|
+
if (!match) continue;
|
|
61
|
+
const base = path.basename(match[2]).toLowerCase();
|
|
62
|
+
if (!wanted.has(base)) continue;
|
|
63
|
+
found.push({ name: base, pid: Number(match[1]) });
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 关闭所选目标对应的 IDE。
|
|
70
|
+
*
|
|
71
|
+
* @param {object} params
|
|
72
|
+
* @param {Array} params.targets 已选择的目标
|
|
73
|
+
* @param {"win32"|"darwin"|"linux"|string} [params.platform]
|
|
74
|
+
* @param {number} [params.timeoutSec]
|
|
75
|
+
* @param {boolean} [params.dryRun]
|
|
76
|
+
* @param {boolean} [params.assumeYes] 传了 --yes
|
|
77
|
+
* @param {boolean} [params.interactive] 是否交互终端
|
|
78
|
+
* @param {(message: string) => Promise<boolean>} [params.confirm]
|
|
79
|
+
*/
|
|
80
|
+
export async function closeSelectedProcesses({
|
|
81
|
+
targets,
|
|
82
|
+
platform = process.platform,
|
|
83
|
+
timeoutSec = 30,
|
|
84
|
+
dryRun = false,
|
|
85
|
+
assumeYes = false,
|
|
86
|
+
interactive = false,
|
|
87
|
+
confirm = null,
|
|
88
|
+
}) {
|
|
89
|
+
const products = [];
|
|
90
|
+
for (const target of targets) {
|
|
91
|
+
const product = target.productId ? PRODUCT_BY_ID.get(target.productId) : null;
|
|
92
|
+
if (product && !products.some((item) => item.id === product.id)) products.push(product);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!products.length) {
|
|
96
|
+
info("未能从目标识别出 IDE 产品,跳过关闭 IDE。");
|
|
97
|
+
return { running: [], skipped: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const names = [...new Set(products.flatMap((product) => getProcessNames(product, platform)))];
|
|
101
|
+
const running = listProcesses(names, platform);
|
|
102
|
+
const productLabel = products.map((product) => product.name).join("、");
|
|
103
|
+
|
|
104
|
+
if (!running.length) {
|
|
105
|
+
info(`没有检测到运行中的 ${productLabel} 进程,继续安装。`);
|
|
106
|
+
return { running: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
info(`检测到以下 ${productLabel} 进程:`);
|
|
110
|
+
for (const proc of running) info(` - ${proc.name}(pid ${proc.pid})`);
|
|
111
|
+
|
|
112
|
+
if (dryRun) {
|
|
113
|
+
info("dry-run:跳过关闭 IDE(真实执行时会先优雅关闭,超时后再强制结束)。");
|
|
114
|
+
return { running, dryRun: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!assumeYes) {
|
|
118
|
+
if (!interactive || typeof confirm !== "function") {
|
|
119
|
+
warn("非交互环境未传 --yes,跳过关闭 IDE。若安装失败,请先手动退出 IDE 后重试。");
|
|
120
|
+
return { running, skipped: true };
|
|
121
|
+
}
|
|
122
|
+
const confirmed = await confirm(
|
|
123
|
+
`将关闭以上 ${running.length} 个进程(未保存内容可能丢失,IDE 默认失焦时保存),是否继续?`,
|
|
124
|
+
);
|
|
125
|
+
if (!confirmed) {
|
|
126
|
+
throw new InstallError("已取消:请先手动关闭 IDE,再重新运行安装命令。");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const attemptedNames = [...new Set(running.map((proc) => proc.name))];
|
|
131
|
+
for (const name of attemptedNames) sendTerminate(name, platform);
|
|
132
|
+
info(`已请求关闭 ${attemptedNames.join("、")},等待退出(最多 ${timeoutSec} 秒)…`);
|
|
133
|
+
|
|
134
|
+
const isStillRunning = () => {
|
|
135
|
+
const attempted = new Set(attemptedNames.map((name) => name.toLowerCase()));
|
|
136
|
+
return listProcesses(names, platform).filter((proc) => attempted.has(proc.name.toLowerCase()));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
140
|
+
let left = isStillRunning();
|
|
141
|
+
while (left.length > 0 && Date.now() < deadline) {
|
|
142
|
+
await sleep(POLL_INTERVAL_MS);
|
|
143
|
+
left = isStillRunning();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (left.length === 0) {
|
|
147
|
+
info("IDE 已退出。");
|
|
148
|
+
return { running };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const forcedNames = [...new Set(left.map((proc) => proc.name))];
|
|
152
|
+
warn(
|
|
153
|
+
`等待 ${timeoutSec} 秒后仍有 ${left.length} 个进程未退出,强制结束:${forcedNames.join("、")}`,
|
|
154
|
+
);
|
|
155
|
+
for (const name of forcedNames) {
|
|
156
|
+
const pids = left.filter((proc) => proc.name === name).map((proc) => proc.pid);
|
|
157
|
+
sendForceKill(name, platform, pids);
|
|
158
|
+
}
|
|
159
|
+
await sleep(1000);
|
|
160
|
+
|
|
161
|
+
left = isStillRunning();
|
|
162
|
+
if (left.length > 0) {
|
|
163
|
+
throw new InstallError(
|
|
164
|
+
`无法关闭 IDE 进程:${[...new Set(left.map((proc) => proc.name))].join("、")}。` +
|
|
165
|
+
"请手动退出后重试(可用 --timeout 增加等待时间)。",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
info("已强制结束 IDE 进程。");
|
|
169
|
+
return { running };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sendTerminate(name, platform) {
|
|
173
|
+
if (platform === "win32") {
|
|
174
|
+
spawnSync("taskkill", ["/IM", name], { encoding: "utf8", windowsHide: true });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const result = spawnSync("pkill", ["-TERM", "-x", name], { encoding: "utf8" });
|
|
178
|
+
if (result.error) warn(`pkill 不可用(${result.error.message}),请手动关闭 ${name}。`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sendForceKill(name, platform, pids = []) {
|
|
182
|
+
if (platform === "win32") {
|
|
183
|
+
spawnSync("taskkill", ["/F", "/IM", name], { encoding: "utf8", windowsHide: true });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (platform === "darwin" && pids.length > 0) {
|
|
187
|
+
const result = spawnSync("kill", ["-9", ...pids.map((pid) => String(pid))], {
|
|
188
|
+
encoding: "utf8",
|
|
189
|
+
});
|
|
190
|
+
if (!result.error && result.status === 0) return;
|
|
191
|
+
}
|
|
192
|
+
const result = spawnSync("pkill", ["-9", "-x", name], { encoding: "utf8" });
|
|
193
|
+
if (result.error) warn(`pkill 不可用(${result.error.message}),请手动关闭 ${name}。`);
|
|
194
|
+
}
|