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/remote.mjs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件分发源:解析 base URL、拉取远端 manifest + 签名、验证 Ed25519 签名、
|
|
3
|
+
* 校验 manifest 字段与 archivePath,并提供版本比较。
|
|
4
|
+
*
|
|
5
|
+
* 安全模型:
|
|
6
|
+
* - 信任根是 npm 包内置的公钥 `lib/plugin-signing-public.pem`(与仓库
|
|
7
|
+
* `src/main/resources/runtime-signing-public.pem` 同一把);
|
|
8
|
+
* - 签名对象是 manifest.json 的**原始字节**,签名文件是 base64 文本;
|
|
9
|
+
* - manifest 通过后才采信其中的 sha256/size/version,用于校验 zip。
|
|
10
|
+
*
|
|
11
|
+
* 默认源是当前唯一可用的七牛测试域名(HTTP),因此无需 --allow-http;
|
|
12
|
+
* 用户显式指定其他 HTTP 源时必须加 --allow-http,HTTPS 直接允许。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { InstallError, UsageError } from "./errors.mjs";
|
|
21
|
+
import { warn } from "./log.mjs";
|
|
22
|
+
|
|
23
|
+
/** 七牛测试域名(HTTP)。生产环境请用 --source / ZCODE_IDE_PLUGIN_BASE_URL 指定 HTTPS 域名。 */
|
|
24
|
+
export const DEFAULT_PLUGIN_BASE_URL =
|
|
25
|
+
"http://tnbe4cmeo.hn-bkt.clouddn.com/zcode-ide/plugin/stable";
|
|
26
|
+
|
|
27
|
+
export const BUNDLED_PUBLIC_KEY_PATH = path.resolve(
|
|
28
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
29
|
+
"plugin-signing-public.pem",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const HTTP_HEADERS = {
|
|
33
|
+
"User-Agent": "zcode-idea-installer",
|
|
34
|
+
"Cache-Control": "no-cache",
|
|
35
|
+
};
|
|
36
|
+
const RETRY_BACKOFF_MS = 500;
|
|
37
|
+
|
|
38
|
+
/* -------------------------------------------------------------------------- */
|
|
39
|
+
/* 源解析 */
|
|
40
|
+
/* -------------------------------------------------------------------------- */
|
|
41
|
+
|
|
42
|
+
function stripTrailingSlashes(value) {
|
|
43
|
+
return String(value).replace(/\/+$/, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 解析插件源。
|
|
48
|
+
* 优先级:--source > ZCODE_IDE_PLUGIN_BASE_URL > DEFAULT_PLUGIN_BASE_URL。
|
|
49
|
+
*
|
|
50
|
+
* @returns {{baseUrl: string, isDefault: boolean, isHttp: boolean}}
|
|
51
|
+
*/
|
|
52
|
+
export function resolvePluginSource({ source, env = process.env, allowHttp = false } = {}) {
|
|
53
|
+
const fromEnv = env.ZCODE_IDE_PLUGIN_BASE_URL?.trim();
|
|
54
|
+
const explicit = typeof source === "string" && source.trim() ? source.trim() : null;
|
|
55
|
+
const raw = explicit ?? (fromEnv ? fromEnv : DEFAULT_PLUGIN_BASE_URL);
|
|
56
|
+
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new UsageError(`插件源不是合法 URL:${raw}`);
|
|
62
|
+
}
|
|
63
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
64
|
+
throw new UsageError(`插件源只支持 http/https:${raw}`);
|
|
65
|
+
}
|
|
66
|
+
if (url.search || url.hash) {
|
|
67
|
+
throw new UsageError(`插件源不能带查询参数或锚点:${raw}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const baseUrl = stripTrailingSlashes(raw);
|
|
71
|
+
const isDefault = baseUrl === DEFAULT_PLUGIN_BASE_URL;
|
|
72
|
+
const isHttp = url.protocol === "http:";
|
|
73
|
+
|
|
74
|
+
if (isHttp && !isDefault && !allowHttp) {
|
|
75
|
+
throw new UsageError(
|
|
76
|
+
`插件源是 HTTP(${baseUrl}):生产环境必须使用 HTTPS;` +
|
|
77
|
+
"测试域名联调请显式加 --allow-http。",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { baseUrl, isDefault, isHttp };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* -------------------------------------------------------------------------- */
|
|
85
|
+
/* 验签 */
|
|
86
|
+
/* -------------------------------------------------------------------------- */
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 校验 manifest.sig(base64 Ed25519 签名)是否匹配 manifest 原始字节。
|
|
90
|
+
* 失败抛 InstallError,调用方必须中止安装。
|
|
91
|
+
*/
|
|
92
|
+
export function verifyManifestSignature(manifestBytes, sigBytes, options = {}) {
|
|
93
|
+
let publicKeyPem = options.publicKeyPem;
|
|
94
|
+
if (!publicKeyPem) {
|
|
95
|
+
const keyPath = options.publicKeyPath ?? BUNDLED_PUBLIC_KEY_PATH;
|
|
96
|
+
try {
|
|
97
|
+
publicKeyPem = fs.readFileSync(keyPath, "utf8");
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new InstallError(`无法读取内置插件公钥 ${keyPath}:${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const signatureText = Buffer.from(sigBytes).toString("utf8").trim();
|
|
104
|
+
const signature = Buffer.from(signatureText, "base64");
|
|
105
|
+
if (signature.length !== 64) {
|
|
106
|
+
throw new InstallError(
|
|
107
|
+
`manifest.sig 解码后长度 ${signature.length},不是 Ed25519 的 64 字节签名。已中止安装。`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let publicKey;
|
|
112
|
+
try {
|
|
113
|
+
publicKey = createPublicKey(publicKeyPem);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
throw new InstallError(`内置插件公钥无法解析:${err.message}`);
|
|
116
|
+
}
|
|
117
|
+
if (publicKey.asymmetricKeyType !== "ed25519") {
|
|
118
|
+
throw new InstallError("内置插件公钥不是 Ed25519 公钥,拒绝继续。");
|
|
119
|
+
}
|
|
120
|
+
if (!verify(null, manifestBytes, publicKey, signature)) {
|
|
121
|
+
throw new InstallError(
|
|
122
|
+
"manifest.sig 验签失败:清单可能被篡改或签名不匹配。已中止安装。",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* -------------------------------------------------------------------------- */
|
|
128
|
+
/* manifest 校验 */
|
|
129
|
+
/* -------------------------------------------------------------------------- */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 校验 archivePath:必须是相对 CDN 根的安全对象键。
|
|
133
|
+
* 拒绝绝对 URL、以 / 开头、`..`、反斜杠、查询参数/锚点、空路径段。
|
|
134
|
+
*/
|
|
135
|
+
export function validateArchivePath(archivePath) {
|
|
136
|
+
if (typeof archivePath !== "string" || !archivePath) {
|
|
137
|
+
throw new InstallError("远端 manifest 的 archivePath 为空");
|
|
138
|
+
}
|
|
139
|
+
if (archivePath.includes("\0")) {
|
|
140
|
+
throw new InstallError("远端 manifest 的 archivePath 包含空字符");
|
|
141
|
+
}
|
|
142
|
+
if (archivePath.includes("\\")) {
|
|
143
|
+
throw new InstallError(`远端 manifest 的 archivePath 不能包含反斜杠:${archivePath}`);
|
|
144
|
+
}
|
|
145
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(archivePath)) {
|
|
146
|
+
throw new InstallError(`远端 manifest 的 archivePath 不能是绝对 URL:${archivePath}`);
|
|
147
|
+
}
|
|
148
|
+
if (archivePath.startsWith("/")) {
|
|
149
|
+
throw new InstallError(`远端 manifest 的 archivePath 不能以 / 开头:${archivePath}`);
|
|
150
|
+
}
|
|
151
|
+
if (/[?#]/.test(archivePath)) {
|
|
152
|
+
throw new InstallError(
|
|
153
|
+
`远端 manifest 的 archivePath 不能包含查询参数或锚点:${archivePath}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (/%2e|%2f|%5c/i.test(archivePath)) {
|
|
157
|
+
throw new InstallError(
|
|
158
|
+
`远端 manifest 的 archivePath 不能包含百分号编码的路径分隔符或点号:${archivePath}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const segments = archivePath.split("/");
|
|
162
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
163
|
+
throw new InstallError(`远端 manifest 的 archivePath 含有非法路径段:${archivePath}`);
|
|
164
|
+
}
|
|
165
|
+
if (path.posix.normalize(archivePath) !== archivePath) {
|
|
166
|
+
throw new InstallError(`远端 manifest 的 archivePath 不是规范化的相对路径:${archivePath}`);
|
|
167
|
+
}
|
|
168
|
+
return archivePath;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 校验远端 manifest 的字段;返回规范化后的对象。 */
|
|
172
|
+
export function validatePluginManifest(value) {
|
|
173
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
174
|
+
throw new InstallError("远端 manifest 不是 JSON 对象");
|
|
175
|
+
}
|
|
176
|
+
if (value.schemaVersion !== 1) {
|
|
177
|
+
throw new InstallError(
|
|
178
|
+
`远端 manifest 的 schemaVersion 不受支持:${value.schemaVersion}(期望 1)`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
for (const field of ["channel", "pluginVersion", "archivePath", "sha256"]) {
|
|
182
|
+
if (typeof value[field] !== "string" || !value[field].trim()) {
|
|
183
|
+
throw new InstallError(`远端 manifest 缺少字段 ${field}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
validateArchivePath(value.archivePath);
|
|
187
|
+
if (!/^[0-9a-f]{64}$/i.test(value.sha256)) {
|
|
188
|
+
throw new InstallError("远端 manifest 的 sha256 格式不正确(期望 64 位十六进制)");
|
|
189
|
+
}
|
|
190
|
+
if (!Number.isInteger(value.size) || value.size <= 0) {
|
|
191
|
+
throw new InstallError(`远端 manifest 的 size 不合法:${value.size}`);
|
|
192
|
+
}
|
|
193
|
+
if (value.builtAt !== undefined && typeof value.builtAt !== "string") {
|
|
194
|
+
throw new InstallError("远端 manifest 的 builtAt 必须是字符串");
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
schemaVersion: 1,
|
|
198
|
+
channel: value.channel,
|
|
199
|
+
pluginVersion: value.pluginVersion.trim(),
|
|
200
|
+
archivePath: value.archivePath,
|
|
201
|
+
sha256: value.sha256.toLowerCase(),
|
|
202
|
+
size: value.size,
|
|
203
|
+
builtAt: value.builtAt ?? "",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 计算插件包的下载地址。
|
|
209
|
+
*
|
|
210
|
+
* archivePath 是相对 CDN 根的对象键(与 runtime manifest 一致,例如
|
|
211
|
+
* `zcode-ide/plugin/stable/zcode-ide-0.1.0.zip`)。若它正好以 source 的路径前缀开头,
|
|
212
|
+
* 就从域名根解析;否则按“相对 manifest 目录”解析,兼容只写文件名的旧清单。
|
|
213
|
+
*/
|
|
214
|
+
export function archiveUrlFor(baseUrl, archivePath) {
|
|
215
|
+
validateArchivePath(archivePath);
|
|
216
|
+
const base = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
|
|
217
|
+
const basePath = base.pathname.replace(/^\/+|\/+$/g, "");
|
|
218
|
+
if (basePath && (archivePath === basePath || archivePath.startsWith(`${basePath}/`))) {
|
|
219
|
+
return new URL(`/${archivePath}`, base.origin).toString();
|
|
220
|
+
}
|
|
221
|
+
return new URL(archivePath, base).toString();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* -------------------------------------------------------------------------- */
|
|
225
|
+
/* 版本比较 */
|
|
226
|
+
/* -------------------------------------------------------------------------- */
|
|
227
|
+
|
|
228
|
+
function tokenizeVersion(value) {
|
|
229
|
+
return String(value ?? "")
|
|
230
|
+
.trim()
|
|
231
|
+
.replace(/^v/i, "")
|
|
232
|
+
.match(/\d+|[a-zA-Z]+/g) ?? [];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 比较两个插件版本:a > b 返回 1,a === b 返回 0,a < b 返回 -1。
|
|
237
|
+
* 数字段按数值比较;预发布后缀(如 -beta)视为小于正式版。
|
|
238
|
+
*/
|
|
239
|
+
export function comparePluginVersions(a, b) {
|
|
240
|
+
const left = tokenizeVersion(a);
|
|
241
|
+
const right = tokenizeVersion(b);
|
|
242
|
+
const length = Math.max(left.length, right.length);
|
|
243
|
+
for (let index = 0; index < length; index += 1) {
|
|
244
|
+
const x = left[index];
|
|
245
|
+
const y = right[index];
|
|
246
|
+
if (x === undefined) return /^\d+$/.test(y) ? -1 : 1;
|
|
247
|
+
if (y === undefined) return /^\d+$/.test(x) ? 1 : -1;
|
|
248
|
+
const xNumeric = /^\d+$/.test(x);
|
|
249
|
+
const yNumeric = /^\d+$/.test(y);
|
|
250
|
+
if (xNumeric && yNumeric) {
|
|
251
|
+
const diff = Number(x) - Number(y);
|
|
252
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (xNumeric !== yNumeric) return xNumeric ? 1 : -1;
|
|
256
|
+
const diff = x.localeCompare(y, "en");
|
|
257
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
258
|
+
}
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* -------------------------------------------------------------------------- */
|
|
263
|
+
/* 网络 */
|
|
264
|
+
/* -------------------------------------------------------------------------- */
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 带重试的小文件下载(manifest / sig)。网络错误与 408/429/5xx 会重试;
|
|
268
|
+
* 其他 4xx 立即失败(例如 404 说明源地址或发布状态不对)。
|
|
269
|
+
*/
|
|
270
|
+
export async function fetchBufferWithRetry(
|
|
271
|
+
url,
|
|
272
|
+
{ retries = 3, timeoutMs = 60_000, label = "资源" } = {},
|
|
273
|
+
) {
|
|
274
|
+
let lastError = null;
|
|
275
|
+
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
276
|
+
try {
|
|
277
|
+
const response = await fetch(url, {
|
|
278
|
+
headers: HTTP_HEADERS,
|
|
279
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
280
|
+
});
|
|
281
|
+
if (response.ok) return Buffer.from(await response.arrayBuffer());
|
|
282
|
+
|
|
283
|
+
const retriable =
|
|
284
|
+
response.status === 408 || response.status === 429 || response.status >= 500;
|
|
285
|
+
const message = `获取 ${label} 失败:HTTP ${response.status} ${response.statusText}(${url})`;
|
|
286
|
+
if (!retriable) {
|
|
287
|
+
throw new InstallError(
|
|
288
|
+
`${message}。请确认插件源地址与发布状态(--source / ZCODE_IDE_PLUGIN_BASE_URL)。`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
lastError = new Error(message);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
if (err instanceof InstallError) throw err;
|
|
294
|
+
lastError = err;
|
|
295
|
+
}
|
|
296
|
+
if (attempt < retries) {
|
|
297
|
+
const delay = RETRY_BACKOFF_MS * attempt;
|
|
298
|
+
warn(
|
|
299
|
+
`获取 ${label} 失败(第 ${attempt}/${retries} 次):${lastError?.message ?? "未知错误"};` +
|
|
300
|
+
`${delay}ms 后重试…`,
|
|
301
|
+
);
|
|
302
|
+
await sleep(delay);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw new InstallError(
|
|
306
|
+
`获取 ${label} 失败(已重试 ${retries} 次):${lastError?.message ?? "未知错误"}。` +
|
|
307
|
+
"请检查网络,或用 --zip <本地 zip> 离线安装。",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 拉取并校验远端 manifest(json + sig)。
|
|
313
|
+
* 验签失败会抛错,调用方不得继续。
|
|
314
|
+
*/
|
|
315
|
+
export async function fetchManifestBundle({
|
|
316
|
+
baseUrl,
|
|
317
|
+
retries = 3,
|
|
318
|
+
timeoutMs = 60_000,
|
|
319
|
+
publicKeyPem,
|
|
320
|
+
publicKeyPath,
|
|
321
|
+
} = {}) {
|
|
322
|
+
if (!baseUrl) throw new InstallError("缺少插件源地址");
|
|
323
|
+
const base = stripTrailingSlashes(baseUrl);
|
|
324
|
+
const manifestUrl = `${base}/manifest.json`;
|
|
325
|
+
const sigUrl = `${base}/manifest.sig`;
|
|
326
|
+
|
|
327
|
+
const manifestBytes = await fetchBufferWithRetry(manifestUrl, {
|
|
328
|
+
retries,
|
|
329
|
+
timeoutMs,
|
|
330
|
+
label: "远端 manifest",
|
|
331
|
+
});
|
|
332
|
+
const sigBytes = await fetchBufferWithRetry(sigUrl, {
|
|
333
|
+
retries,
|
|
334
|
+
timeoutMs,
|
|
335
|
+
label: "manifest 签名",
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
verifyManifestSignature(manifestBytes, sigBytes, { publicKeyPem, publicKeyPath });
|
|
339
|
+
|
|
340
|
+
let parsed;
|
|
341
|
+
try {
|
|
342
|
+
parsed = JSON.parse(manifestBytes.toString("utf8"));
|
|
343
|
+
} catch (err) {
|
|
344
|
+
throw new InstallError(`远端 manifest 不是合法 JSON:${err.message}`);
|
|
345
|
+
}
|
|
346
|
+
const manifest = validatePluginManifest(parsed);
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
manifest,
|
|
350
|
+
manifestBytes,
|
|
351
|
+
sigBytes,
|
|
352
|
+
manifestUrl,
|
|
353
|
+
sigUrl,
|
|
354
|
+
archiveUrl: archiveUrlFor(base, manifest.archivePath),
|
|
355
|
+
};
|
|
356
|
+
}
|