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.
@@ -0,0 +1,294 @@
1
+ /**
2
+ * 插件包下载与本地缓存。
3
+ *
4
+ * 缓存位置:`${ZCODE_DATA_BASE_DIR:-~/.zcode}/ide/plugin-cache/<sha256>.zip`
5
+ * - 命中且 sha256/size 一致 → 直接复用;
6
+ * - 未命中 → 下载到 `<sha256>.zip.part`,校验后原子改名为正式缓存;
7
+ * - 下载失败重试 3 次(退避 0.5s/1s),最终失败给出可操作提示(可用 --zip 离线)。
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import fsp from "node:fs/promises";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { Readable, Transform } from "node:stream";
15
+ import { pipeline } from "node:stream/promises";
16
+ import { setTimeout as sleep } from "node:timers/promises";
17
+ import { InstallError } from "./errors.mjs";
18
+ import { formatBytes, info, warn } from "./log.mjs";
19
+ import { sha256File } from "./manifest.mjs";
20
+
21
+ const DOWNLOAD_TIMEOUT_MS = 60 * 60_000;
22
+ const RETRY_BACKOFF_MS = 500;
23
+ // 只有 sha256 命名的文件才是缓存产物;其它 *.zip 可能是用户手工放入的文件,不能碰。
24
+ const CACHE_FILE_PATTERN = /^([0-9a-f]{64})\.zip(\.part)?$/i;
25
+
26
+ /** 插件缓存目录。 */
27
+ export function getPluginCacheDir(env = process.env) {
28
+ const override = env.ZCODE_DATA_BASE_DIR?.trim();
29
+ const root = override ? path.resolve(override) : path.join(os.homedir(), ".zcode");
30
+ return path.join(root, "ide", "plugin-cache");
31
+ }
32
+
33
+ export function getCachedArchivePath(sha256, env = process.env) {
34
+ return path.join(getPluginCacheDir(env), `${sha256}.zip`);
35
+ }
36
+
37
+ /** 仅按大小判断缓存是否可能存在(dry-run 提示用,不做 sha 校验)。 */
38
+ export function isCachePresent(sha256, size, env = process.env) {
39
+ try {
40
+ const stat = fs.statSync(getCachedArchivePath(sha256, env));
41
+ return stat.isFile() && stat.size === size;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 清理插件包缓存。
49
+ *
50
+ * 只处理缓存目录下以 sha256 命名的 `<64位十六进制>.zip` 与下载残留
51
+ * `<64位十六进制>.zip.part`:
52
+ * - `keep` 中的 sha256(大小写不敏感)对应的 zip 保留;
53
+ * - 其余缓存 zip 与全部缓存 `.part` 删除(`keep` 为空表示全部清理);
54
+ * - 其它文件与子目录(包括非 sha256 命名的 `*.zip`)一律不动;
55
+ * - 单个文件删除失败只记入 `failed`,不中断整体清理。
56
+ *
57
+ * 目录不存在时返回空结果,不抛错;本函数不打印日志,由调用方决定输出。
58
+ *
59
+ * @param {object} [params]
60
+ * @param {NodeJS.ProcessEnv} [params.env]
61
+ * @param {string[]} [params.keep] 需要保留的 sha256 列表
62
+ * @param {boolean} [params.dryRun] 只扫描不删除(CLI dry-run 提示用)
63
+ * @returns {Promise<{
64
+ * removed: Array<{path: string, size: number}>,
65
+ * freedBytes: number,
66
+ * keptCount: number,
67
+ * failed: Array<{path: string, error: string}>,
68
+ * }>}
69
+ */
70
+ export async function prunePluginCache({ env = process.env, keep = [], dryRun = false } = {}) {
71
+ const keepSet = new Set(
72
+ (Array.isArray(keep) ? keep : [])
73
+ .map((value) => String(value ?? "").trim().toLowerCase())
74
+ .filter(Boolean),
75
+ );
76
+ const cacheDir = getPluginCacheDir(env);
77
+
78
+ let entries;
79
+ try {
80
+ entries = await fsp.readdir(cacheDir, { withFileTypes: true });
81
+ } catch (err) {
82
+ if (err?.code === "ENOENT") {
83
+ return { removed: [], freedBytes: 0, keptCount: 0, failed: [] };
84
+ }
85
+ throw new InstallError(`无法读取插件缓存目录 ${cacheDir}:${err.message}`);
86
+ }
87
+
88
+ const result = { removed: [], freedBytes: 0, keptCount: 0, failed: [] };
89
+ const candidates = entries
90
+ .filter((entry) => entry.isFile())
91
+ .map((entry) => entry.name)
92
+ .map((name) => ({ name, match: CACHE_FILE_PATTERN.exec(name) }))
93
+ .filter((item) => item.match)
94
+ .sort((a, b) => a.name.localeCompare(b.name));
95
+
96
+ for (const { name, match } of candidates) {
97
+ const sha256 = match[1].toLowerCase();
98
+ const isPart = Boolean(match[2]);
99
+ // 只有正式 zip 能按 sha256 保留;.part 是下载残留,一律清掉。
100
+ if (!isPart && keepSet.has(sha256)) {
101
+ result.keptCount += 1;
102
+ continue;
103
+ }
104
+
105
+ const filePath = path.join(cacheDir, name);
106
+ let size = 0;
107
+ try {
108
+ size = (await fsp.stat(filePath)).size;
109
+ } catch {
110
+ // 读不到大小不影响删除,removed 里按 0 记录。
111
+ }
112
+
113
+ if (dryRun) {
114
+ result.removed.push({ path: filePath, size });
115
+ result.freedBytes += size;
116
+ continue;
117
+ }
118
+
119
+ try {
120
+ await fsp.rm(filePath, { force: true });
121
+ result.removed.push({ path: filePath, size });
122
+ result.freedBytes += size;
123
+ } catch (err) {
124
+ result.failed.push({ path: filePath, error: err?.message ?? String(err) });
125
+ }
126
+ }
127
+
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * 确保插件包存在且通过 sha256/size 校验。
133
+ *
134
+ * @param {object} params
135
+ * @param {string} params.archiveUrl
136
+ * @param {string} params.sha256
137
+ * @param {number} params.size
138
+ * @param {number} [params.retries]
139
+ * @param {NodeJS.ProcessEnv} [params.env]
140
+ * @returns {Promise<{zipPath: string, fromCache: boolean}>}
141
+ */
142
+ export async function ensurePluginArchive({
143
+ archiveUrl,
144
+ sha256,
145
+ size,
146
+ retries = 3,
147
+ env = process.env,
148
+ }) {
149
+ if (!archiveUrl) throw new InstallError("缺少插件包下载地址");
150
+ if (!/^[0-9a-f]{64}$/i.test(String(sha256 ?? ""))) {
151
+ throw new InstallError("插件包 sha256 不合法,拒绝下载");
152
+ }
153
+ if (!Number.isInteger(size) || size <= 0) {
154
+ throw new InstallError(`插件包大小不合法:${size}`);
155
+ }
156
+
157
+ const cacheDir = getPluginCacheDir(env);
158
+ try {
159
+ await fsp.mkdir(cacheDir, { recursive: true });
160
+ } catch (err) {
161
+ throw new InstallError(`无法创建插件缓存目录 ${cacheDir}:${err.message}`);
162
+ }
163
+
164
+ const finalPath = path.join(cacheDir, `${sha256}.zip`);
165
+ const partPath = `${finalPath}.part`;
166
+
167
+ if (isCachePresent(sha256, size, env)) {
168
+ const cachedSha256 = await sha256File(finalPath).catch(() => null);
169
+ if (cachedSha256 === sha256.toLowerCase()) {
170
+ info(`使用本地缓存插件包:${finalPath}`);
171
+ return { zipPath: finalPath, fromCache: true };
172
+ }
173
+ warn(`本地缓存 sha256 不一致,删除后重新下载:${finalPath}`);
174
+ await fsp.rm(finalPath, { force: true }).catch(() => {});
175
+ } else if (fs.existsSync(finalPath)) {
176
+ warn(`本地缓存大小不一致,删除后重新下载:${finalPath}`);
177
+ await fsp.rm(finalPath, { force: true }).catch(() => {});
178
+ }
179
+
180
+ let lastError = null;
181
+ for (let attempt = 1; attempt <= retries; attempt += 1) {
182
+ try {
183
+ const received = await downloadToFile(archiveUrl, partPath, size);
184
+ if (received !== size) {
185
+ throw new Error(`下载大小不一致:期望 ${size} 字节,实际 ${received} 字节`);
186
+ }
187
+ const actualSha256 = await sha256File(partPath);
188
+ if (actualSha256 !== sha256.toLowerCase()) {
189
+ throw new Error(`下载后 sha256 校验失败:期望 ${sha256},实际 ${actualSha256}`);
190
+ }
191
+ await fsp.rename(partPath, finalPath);
192
+ info(`插件包下载并校验完成:${finalPath}`);
193
+ return { zipPath: finalPath, fromCache: false };
194
+ } catch (err) {
195
+ lastError = err;
196
+ await fsp.rm(partPath, { force: true }).catch(() => {});
197
+ if (attempt < retries) {
198
+ const delay = RETRY_BACKOFF_MS * attempt;
199
+ warn(
200
+ `下载失败(第 ${attempt}/${retries} 次):${err?.message ?? "未知错误"};` +
201
+ `${delay}ms 后重试…`,
202
+ );
203
+ await sleep(delay);
204
+ }
205
+ }
206
+ }
207
+
208
+ throw new InstallError(
209
+ `下载插件包失败(已重试 ${retries} 次):${lastError?.message ?? "未知错误"}。` +
210
+ "请检查网络或稍后重试;离线环境可用 --zip <本地 zip> 安装。",
211
+ );
212
+ }
213
+
214
+ async function downloadToFile(archiveUrl, destination, expectedSize) {
215
+ const response = await fetch(archiveUrl, {
216
+ headers: {
217
+ "User-Agent": "zcode-idea-installer",
218
+ Accept: "application/zip, application/octet-stream",
219
+ },
220
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
221
+ });
222
+ if (!response.ok) {
223
+ if (response.status === 404) {
224
+ throw new Error(`插件包不存在(HTTP 404):${archiveUrl}`);
225
+ }
226
+ throw new Error(`下载失败:HTTP ${response.status} ${response.statusText}`);
227
+ }
228
+ if (!response.body) throw new Error("下载响应没有响应体");
229
+
230
+ const headerSize = Number(response.headers.get("content-length")) || 0;
231
+ const total = expectedSize > 0 ? expectedSize : headerSize;
232
+ const progress = createDownloadProgress(total);
233
+ const startedAt = Date.now();
234
+ let received = 0;
235
+
236
+ const meter = new Transform({
237
+ transform(chunk, _encoding, callback) {
238
+ received += chunk.length;
239
+ progress.update(received, startedAt);
240
+ callback(null, chunk);
241
+ },
242
+ });
243
+
244
+ try {
245
+ await pipeline(
246
+ Readable.fromWeb(response.body),
247
+ meter,
248
+ fs.createWriteStream(destination),
249
+ );
250
+ } finally {
251
+ progress.finish();
252
+ }
253
+ return received;
254
+ }
255
+
256
+ function createDownloadProgress(totalBytes) {
257
+ const tty = Boolean(process.stdout.isTTY);
258
+ let lastBucket = -1;
259
+ let lastPrintAt = 0;
260
+ let printedTty = false;
261
+
262
+ return {
263
+ update(received, startedAt) {
264
+ const percent = totalBytes > 0 ? Math.min(100, Math.floor((received / totalBytes) * 100)) : 0;
265
+ const elapsedSeconds = Math.max(0.001, (Date.now() - startedAt) / 1000);
266
+ const speed = received / elapsedSeconds;
267
+
268
+ if (tty) {
269
+ const now = Date.now();
270
+ if (percent < 100 && now - lastPrintAt < 100) return;
271
+ lastPrintAt = now;
272
+ printedTty = true;
273
+ process.stdout.write(
274
+ `\r[zcode-idea] 下载中 ${String(percent).padStart(3, " ")}%|` +
275
+ `${formatBytes(received)}/${formatBytes(totalBytes)}|` +
276
+ `${formatBytes(speed)}/s `,
277
+ );
278
+ return;
279
+ }
280
+
281
+ const bucket = Math.floor(percent / 10);
282
+ if (bucket > lastBucket) {
283
+ lastBucket = bucket;
284
+ info(
285
+ `下载进度 ${percent}%(${formatBytes(received)}/${formatBytes(totalBytes)},` +
286
+ `${formatBytes(speed)}/s)`,
287
+ );
288
+ }
289
+ },
290
+ finish() {
291
+ if (tty && printedTty) process.stdout.write("\n");
292
+ },
293
+ };
294
+ }
package/lib/errors.mjs ADDED
@@ -0,0 +1,17 @@
1
+ /** 用法错误(参数不合法等),CLI 以退出码 2 结束。 */
2
+ export class UsageError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "UsageError";
6
+ this.exitCode = 2;
7
+ }
8
+ }
9
+
10
+ /** 安装/校验失败,CLI 以退出码 1 结束。 */
11
+ export class InstallError extends Error {
12
+ constructor(message, options) {
13
+ super(message, options);
14
+ this.name = "InstallError";
15
+ this.exitCode = 1;
16
+ }
17
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 读取目标 plugins 目录中已安装的 ZCode 插件版本。
3
+ *
4
+ * 版本来源:`<pluginsDir>/zcode-ide/lib/zcode-ide-*.jar` 内的 META-INF/plugin.xml。
5
+ * 读取失败不抛错(避免因为一个损坏的旧安装阻断整个流程),由调用方按未安装处理并提示。
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import fsp from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { iterateZip, openZip, readEntryBuffer } from "./manifest.mjs";
12
+
13
+ /**
14
+ * @param {string} pluginsDir
15
+ * @returns {Promise<{installed: boolean, version: string|null, jarPath: string|null, error: string|null}>}
16
+ */
17
+ export async function readInstalledPlugin(pluginsDir) {
18
+ const pluginDir = path.join(pluginsDir, "zcode-ide");
19
+ if (!fs.existsSync(pluginDir)) {
20
+ return { installed: false, version: null, jarPath: null, error: null };
21
+ }
22
+
23
+ const libDir = path.join(pluginDir, "lib");
24
+ let entries;
25
+ try {
26
+ entries = await fsp.readdir(libDir);
27
+ } catch (err) {
28
+ return {
29
+ installed: true,
30
+ version: null,
31
+ jarPath: null,
32
+ error: `无法读取 lib 目录:${err.message}`,
33
+ };
34
+ }
35
+
36
+ const jarName = entries.find((name) => /^zcode-ide-.*\.jar$/i.test(name));
37
+ if (!jarName) {
38
+ return {
39
+ installed: true,
40
+ version: null,
41
+ jarPath: null,
42
+ error: "lib 下没有 zcode-ide-*.jar",
43
+ };
44
+ }
45
+
46
+ const jarPath = path.join(libDir, jarName);
47
+ let zip;
48
+ try {
49
+ zip = await openZip(jarPath);
50
+ } catch (err) {
51
+ return { installed: true, version: null, jarPath, error: err.message };
52
+ }
53
+
54
+ try {
55
+ let pluginXml = null;
56
+ await iterateZip(zip, async (entry) => {
57
+ if (entry.fileName === "META-INF/plugin.xml") {
58
+ pluginXml = await readEntryBuffer(zip, entry);
59
+ }
60
+ });
61
+ if (!pluginXml) {
62
+ return {
63
+ installed: true,
64
+ version: null,
65
+ jarPath,
66
+ error: "jar 内缺少 META-INF/plugin.xml",
67
+ };
68
+ }
69
+ const text = pluginXml.toString("utf8");
70
+ const version = /<version>\s*([^<\s]+)\s*<\/version>/.exec(text)?.[1] ?? null;
71
+ return {
72
+ installed: true,
73
+ version,
74
+ jarPath,
75
+ error: version ? null : "plugin.xml 缺少 <version>",
76
+ };
77
+ } catch (err) {
78
+ return { installed: true, version: null, jarPath, error: err.message };
79
+ } finally {
80
+ try {
81
+ zip.close();
82
+ } catch {
83
+ /* 已关闭 */
84
+ }
85
+ }
86
+ }
package/lib/launch.mjs ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * 查找并启动 IDE 可执行文件。
3
+ *
4
+ * 搜索顺序按平台覆盖:JetBrains 安装目录、LOCALAPPDATA\Programs、Toolbox、
5
+ * /Applications(macOS)、/opt 与 /snap(Linux);优先选择路径中包含目标版本的候选。
6
+ * 找不到只提示“请手动打开”,不算安装失败。
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { info, warn } from "./log.mjs";
14
+ import { inferProductFromName, PRODUCT_BY_ID, PRODUCTS } from "./products.mjs";
15
+
16
+ /**
17
+ * @returns {string | null} 可执行文件的绝对路径
18
+ */
19
+ export function findLaunchCommand(target, options = {}) {
20
+ const platform = options.platform ?? process.platform;
21
+ const env = options.env ?? process.env;
22
+ const home = options.home ?? os.homedir();
23
+
24
+ const product =
25
+ (target.productId ? PRODUCT_BY_ID.get(target.productId) : null) ??
26
+ PRODUCTS.find((item) => item.name === target.productName) ??
27
+ null;
28
+ if (!product) return null;
29
+
30
+ let candidates = [];
31
+ if (platform === "win32") candidates = windowsCandidates(product, target, env, home);
32
+ else if (platform === "darwin") candidates = macCandidates(product, target, env, home);
33
+ else candidates = linuxCandidates(product, target, env, home);
34
+
35
+ const existing = [...new Set(candidates)].filter((candidate) => isFile(candidate));
36
+ if (!existing.length) return null;
37
+
38
+ const version = target.version || "";
39
+ existing.sort((a, b) => scoreCandidate(b, version) - scoreCandidate(a, version));
40
+ return existing[0];
41
+ }
42
+
43
+ function windowsCandidates(product, target, env, home) {
44
+ const programFiles = env.PROGRAMFILES || "C:\\Program Files";
45
+ const localAppData = env.LOCALAPPDATA || path.join(home, "AppData", "Local");
46
+ const homes = [];
47
+
48
+ for (const dir of listDirs(path.join(programFiles, "JetBrains"))) {
49
+ if (matchesProductName(dir.name, product)) homes.push(dir.path);
50
+ }
51
+ for (const dir of listDirs(path.join(localAppData, "Programs"))) {
52
+ if (matchesProductName(dir.name, product)) homes.push(dir.path);
53
+ }
54
+ for (const versionDir of toolboxVersionHomes(
55
+ path.join(localAppData, "JetBrains", "Toolbox", "apps"),
56
+ product,
57
+ )) {
58
+ homes.push(versionDir);
59
+ }
60
+ if (product.id === "androidStudio") {
61
+ homes.push(path.join(programFiles, "Android", "Android Studio"));
62
+ }
63
+
64
+ // --target 指向 IDE 安装目录下的 plugins 时,父目录就是 IDE home
65
+ homes.push(path.dirname(target.pluginsDir));
66
+
67
+ return homes.flatMap((homeDir) => product.winExes.map((exe) => path.join(homeDir, "bin", exe)));
68
+ }
69
+
70
+ function macCandidates(product, target, env, home) {
71
+ const candidates = [];
72
+ for (const root of ["/Applications", path.join(home, "Applications")]) {
73
+ for (const app of product.macApps) {
74
+ for (const bin of product.macBins) {
75
+ candidates.push(path.join(root, app, "Contents", "MacOS", bin));
76
+ }
77
+ }
78
+ }
79
+
80
+ // --target 指向 *.app/Contents/plugins 时直接定位该 app
81
+ const segments = target.pluginsDir.split(/[\\/]+/).filter(Boolean);
82
+ const appIndex = segments.findIndex((segment) => /\.app$/i.test(segment));
83
+ if (appIndex >= 0) {
84
+ const prefix = segments.slice(0, appIndex + 1).join(path.sep);
85
+ const appRoot = prefix.startsWith(path.sep) ? prefix : `${path.sep}${prefix}`;
86
+ for (const bin of product.macBins) {
87
+ candidates.push(path.join(appRoot, "Contents", "MacOS", bin));
88
+ }
89
+ }
90
+
91
+ const appSupport = path.join(home, "Library", "Application Support");
92
+ for (const versionDir of toolboxVersionHomes(
93
+ path.join(appSupport, "JetBrains", "Toolbox", "apps"),
94
+ product,
95
+ )) {
96
+ for (const app of product.macApps) {
97
+ for (const bin of product.macBins) {
98
+ candidates.push(path.join(versionDir, app, "Contents", "MacOS", bin));
99
+ }
100
+ }
101
+ for (const bin of product.macBins) {
102
+ candidates.push(path.join(versionDir, "Contents", "MacOS", bin));
103
+ }
104
+ }
105
+ return candidates;
106
+ }
107
+
108
+ function linuxCandidates(product, target, env, home) {
109
+ const homes = [];
110
+ for (const dir of listDirs("/opt")) {
111
+ if (matchesProductName(dir.name, product)) homes.push(dir.path);
112
+ }
113
+ homes.push(path.dirname(target.pluginsDir));
114
+
115
+ for (const versionDir of toolboxVersionHomes(
116
+ path.join(home, ".local", "share", "JetBrains", "Toolbox", "apps"),
117
+ product,
118
+ )) {
119
+ homes.push(versionDir);
120
+ }
121
+ for (const snapDir of listDirs("/snap")) {
122
+ if (!matchesProductName(snapDir.name, product)) continue;
123
+ homes.push(path.join(snapDir.path, "current"));
124
+ }
125
+
126
+ return homes.flatMap((homeDir) =>
127
+ product.linuxScripts.map((script) => path.join(homeDir, "bin", script)),
128
+ );
129
+ }
130
+
131
+ function toolboxVersionHomes(appsRoot, product) {
132
+ const result = [];
133
+ for (const productDir of listDirs(appsRoot)) {
134
+ const keyMatched = product.toolboxKeys.some(
135
+ (key) => key.toLowerCase() === productDir.name.toLowerCase(),
136
+ );
137
+ if (!keyMatched && !matchesProductName(productDir.name, product)) continue;
138
+ for (const channelDir of listDirs(productDir.path)) {
139
+ for (const versionDir of listDirs(channelDir.path)) result.push(versionDir.path);
140
+ }
141
+ }
142
+ return result;
143
+ }
144
+
145
+ function matchesProductName(name, product) {
146
+ const inferred = inferProductFromName(name);
147
+ if (inferred?.product.id === product.id) return true;
148
+ const lower = name.toLowerCase();
149
+ if (product.toolboxKeys.some((key) => key.toLowerCase() === lower)) return true;
150
+ return product.macApps.some((app) => app.replace(/\.app$/i, "").toLowerCase() === lower);
151
+ }
152
+
153
+ function scoreCandidate(candidate, version) {
154
+ if (!version) return 0;
155
+ return candidate.toLowerCase().includes(version.toLowerCase()) ? 1 : 0;
156
+ }
157
+
158
+ function listDirs(dir) {
159
+ try {
160
+ return fs
161
+ .readdirSync(dir, { withFileTypes: true })
162
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
163
+ .map((entry) => ({ name: entry.name, path: path.join(dir, entry.name) }))
164
+ .filter((entry) => isDirectory(entry.path));
165
+ } catch {
166
+ return [];
167
+ }
168
+ }
169
+
170
+ function isDirectory(target) {
171
+ try {
172
+ return fs.statSync(target).isDirectory();
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ function isFile(target) {
179
+ try {
180
+ return fs.statSync(target).isFile();
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * 以 detached 方式启动 IDE,不阻塞安装器。
188
+ * @returns {Promise<boolean>} 是否成功拉起
189
+ */
190
+ export function launchDetached(exe) {
191
+ return new Promise((resolve) => {
192
+ let settled = false;
193
+ const finish = (ok) => {
194
+ if (settled) return;
195
+ settled = true;
196
+ resolve(ok);
197
+ };
198
+ try {
199
+ const child = spawn(exe, [], { detached: true, stdio: "ignore", windowsHide: false });
200
+ child.once("error", (err) => {
201
+ warn(`启动失败:${exe} — ${err.message}。请手动打开 IDE。`);
202
+ finish(false);
203
+ });
204
+ child.once("spawn", () => {
205
+ child.unref();
206
+ info(`已启动:${exe}`);
207
+ finish(true);
208
+ });
209
+ } catch (err) {
210
+ warn(`启动失败:${exe} — ${err.message}。请手动打开 IDE。`);
211
+ finish(false);
212
+ }
213
+ });
214
+ }
package/lib/log.mjs ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 控制台输出统一走这里,保证每行都带 [zcode-idea] 前缀。
3
+ * 进度条在 TTY 下用 \r 原地刷新,非 TTY(CI/管道)降级为每 10% 一行。
4
+ */
5
+
6
+ const PREFIX = "[zcode-idea]";
7
+
8
+ export function info(message) {
9
+ console.log(`${PREFIX} ${message}`);
10
+ }
11
+
12
+ export function warn(message) {
13
+ console.warn(`${PREFIX} 警告:${message}`);
14
+ }
15
+
16
+ export function error(message) {
17
+ console.error(`${PREFIX} 错误:${message}`);
18
+ }
19
+
20
+ /** 交互能力判定:stdin 与 stdout 都是 TTY 才算交互环境。 */
21
+ export function isInteractive() {
22
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
23
+ }
24
+
25
+ export function formatBytes(bytes) {
26
+ const n = Number(bytes) || 0;
27
+ if (n < 1024) return `${n} B`;
28
+ const units = ["KB", "MB", "GB", "TB"];
29
+ let value = n / 1024;
30
+ let index = 0;
31
+ while (value >= 1024 && index < units.length - 1) {
32
+ value /= 1024;
33
+ index += 1;
34
+ }
35
+ return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[index]}`;
36
+ }