@sfmc-bds/cli 0.2.5 → 0.3.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.
Files changed (41) hide show
  1. package/dist/command-surface.d.ts.map +1 -1
  2. package/dist/command-surface.js +5 -1
  3. package/dist/command-surface.js.map +2 -2
  4. package/dist/commands.d.ts.map +1 -1
  5. package/dist/commands.js +20 -79
  6. package/dist/commands.js.map +2 -2
  7. package/dist/help-text.d.ts.map +1 -1
  8. package/dist/help-text.js +2 -0
  9. package/dist/help-text.js.map +2 -2
  10. package/dist/i18n/locales/en.d.ts +33 -0
  11. package/dist/i18n/locales/en.d.ts.map +1 -1
  12. package/dist/i18n/locales/en.js +33 -0
  13. package/dist/i18n/locales/en.js.map +2 -2
  14. package/dist/i18n/locales/zh-CN.d.ts.map +1 -1
  15. package/dist/i18n/locales/zh-CN.js +33 -0
  16. package/dist/i18n/locales/zh-CN.js.map +2 -2
  17. package/dist/module-commands.d.ts.map +1 -1
  18. package/dist/module-commands.js +8 -0
  19. package/dist/module-commands.js.map +2 -2
  20. package/dist/module-update/index.d.ts +18 -0
  21. package/dist/module-update/index.d.ts.map +1 -0
  22. package/dist/module-update/index.js +223 -0
  23. package/dist/module-update/index.js.map +7 -0
  24. package/dist/repl.d.ts.map +1 -1
  25. package/dist/repl.js +11 -0
  26. package/dist/repl.js.map +2 -2
  27. package/dist/services.d.ts +29 -1
  28. package/dist/services.d.ts.map +1 -1
  29. package/dist/services.js +79 -1
  30. package/dist/services.js.map +2 -2
  31. package/dist/wizard.d.ts.map +1 -1
  32. package/dist/wizard.js +2 -0
  33. package/dist/wizard.js.map +2 -2
  34. package/package.json +1 -1
  35. package/scripts/module-install/fetch-module.mjs +164 -76
  36. package/scripts/module-install/lib/module-update-config.mjs +83 -0
  37. package/scripts/module-install/lib/module-update.mjs +507 -0
  38. package/scripts/module-install/lib/pins.mjs +436 -0
  39. package/scripts/module-install/lib/registry-cache.mjs +83 -0
  40. package/scripts/module-install/lib/semver.mjs +124 -0
  41. package/scripts/module-install/test/module-update.test.mjs +182 -0
@@ -0,0 +1,436 @@
1
+ // @ts-check
2
+ /**
3
+ * modules/module-pins.json
4
+ * 记录每个已装模块的来源、已装版本和是否允许自动更新。
5
+ * catalog 仍只投影 manifest,module-lock 仍只记启停。
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { readJson, writeJson } from "./io.mjs";
10
+ import { ROOT } from "./paths.mjs";
11
+ import { readCachedRegistryIndex } from "./registry-cache.mjs";
12
+
13
+ /**
14
+ * @typedef {"npm" | "github" | "link" | "local"} PinSource
15
+ * @typedef {"index" | "dist-tag" | "none"} PinChannel
16
+ * @typedef {"default" | "user"} PinAutoSetBy
17
+ * @typedef {{
18
+ * source: PinSource,
19
+ * channel: PinChannel,
20
+ * spec: string,
21
+ * installedVersion: string | null,
22
+ * manifestId: string | null,
23
+ * auto: boolean,
24
+ * autoLocked: boolean,
25
+ * autoSetBy: PinAutoSetBy,
26
+ * lastCheckedAt: number | null,
27
+ * lastAppliedAt: number | null,
28
+ * lastError: string | null,
29
+ * }} ModulePin
30
+ * @typedef {{ version: number, modules: Record<string, ModulePin> }} ModulePinsFile
31
+ */
32
+
33
+ /**
34
+ * 去掉 feature- / core- 前缀,让目录名和 manifest.id 能对上。
35
+ * @param {string | null | undefined} id
36
+ */
37
+ export function normalizeModuleId(id) {
38
+ return String(id ?? "").replace(/^(feature|core)-/, "");
39
+ }
40
+
41
+ /**
42
+ * @param {string} [root]
43
+ */
44
+ export function pinsFile(root = ROOT) {
45
+ return path.join(root, "modules", "module-pins.json");
46
+ }
47
+
48
+ /**
49
+ * @param {string} root
50
+ * @param {string} folder
51
+ */
52
+ export function packageDirOf(root, folder) {
53
+ return path.join(root, "modules", "packages", folder);
54
+ }
55
+
56
+ /**
57
+ * @param {string} [root]
58
+ * @returns {ModulePinsFile}
59
+ */
60
+ export function readPins(root = ROOT) {
61
+ const raw = readJson(pinsFile(root), null);
62
+ if (!raw || typeof raw !== "object") return { version: 1, modules: {} };
63
+ const modules = /** @type {Record<string, unknown>} */ (raw).modules;
64
+ return {
65
+ version: 1,
66
+ modules: modules && typeof modules === "object" ? /** @type {Record<string, ModulePin>} */ (modules) : {},
67
+ };
68
+ }
69
+
70
+ /**
71
+ * @param {string} root
72
+ * @param {ModulePinsFile} file
73
+ */
74
+ export function writePins(root, file) {
75
+ writeJson(pinsFile(root), {
76
+ version: 1,
77
+ modules: file.modules || {},
78
+ });
79
+ }
80
+
81
+ /**
82
+ * 目录是 junction / symlink 时视为本地开发链接。
83
+ * @param {string} dir
84
+ */
85
+ export function isLinkedPackageDir(dir) {
86
+ try {
87
+ return fs.lstatSync(dir).isSymbolicLink();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * @param {string} dir
95
+ * @returns {string | null}
96
+ */
97
+ export function readPackageVersion(dir) {
98
+ try {
99
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
100
+ return typeof pkg.version === "string" && pkg.version.trim() ? pkg.version.trim() : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * @param {string} dir
108
+ * @returns {string | null}
109
+ */
110
+ export function readPackageName(dir) {
111
+ try {
112
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
113
+ return typeof pkg.name === "string" && pkg.name.trim() ? pkg.name.trim() : null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * @param {string} dir
121
+ * @returns {string | null}
122
+ */
123
+ export function readManifestId(dir) {
124
+ try {
125
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "sapi", "manifest.json"), "utf8"));
126
+ return typeof manifest.id === "string" && manifest.id.trim() ? manifest.id.trim() : null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * npm spec 是否带了精确版本(@scope/name@1.2.3)。
134
+ * @param {string} specBody
135
+ */
136
+ function npmSpecHasVersion(specBody) {
137
+ const slash = specBody.indexOf("/");
138
+ const at = specBody.indexOf("@", slash >= 0 ? slash + 1 : 0);
139
+ return at > 0 && specBody.slice(at + 1).length > 0;
140
+ }
141
+
142
+ /**
143
+ * 从本次安装的 --from 推导 pin 草稿。
144
+ * 带版本的 npm 先按索引钉版处理;链接和本地目录强制关闭自动更新。
145
+ * @param {string} from
146
+ * @param {boolean} link
147
+ * @returns {Pick<ModulePin, "source" | "channel" | "spec" | "auto" | "autoLocked">}
148
+ */
149
+ export function inferPinDraft(from, link) {
150
+ const spec = String(from ?? "");
151
+ if (link) {
152
+ return { source: "link", channel: "none", spec, auto: false, autoLocked: true };
153
+ }
154
+ if (spec.startsWith("npm:")) {
155
+ const body = spec.slice("npm:".length);
156
+ const pinned = npmSpecHasVersion(body);
157
+ return {
158
+ source: "npm",
159
+ channel: pinned ? "index" : "dist-tag",
160
+ spec,
161
+ auto: false,
162
+ autoLocked: false,
163
+ };
164
+ }
165
+ if (spec.startsWith("github:")) {
166
+ return { source: "github", channel: "none", spec, auto: false, autoLocked: false };
167
+ }
168
+ return { source: "local", channel: "none", spec, auto: false, autoLocked: true };
169
+ }
170
+
171
+ /**
172
+ * 按索引重算“默认”自动更新开关。服主改过的 auto 保持不动。
173
+ * @param {ModulePin} pin
174
+ * @param {{ npm?: string, version?: string } | null | undefined} indexEntry
175
+ * @returns {ModulePin}
176
+ */
177
+ export function reconcileDefaultAuto(pin, indexEntry) {
178
+ if (pin.autoLocked || pin.source === "link" || pin.source === "local") {
179
+ return { ...pin, auto: false, autoLocked: true };
180
+ }
181
+ if (pin.autoSetBy === "user") return pin;
182
+ if (pin.source === "npm" && indexEntry?.npm && indexEntry.version) {
183
+ return { ...pin, auto: true, channel: "index" };
184
+ }
185
+ if (pin.source === "github") return { ...pin, auto: false };
186
+ if (pin.channel === "dist-tag") return { ...pin, auto: false };
187
+ return { ...pin, auto: false };
188
+ }
189
+
190
+ /**
191
+ * 升级时若已有启停记录,则不要按 enabledByDefault 重写。
192
+ * @param {boolean} preserveLock
193
+ * @param {boolean} lockHasModule
194
+ */
195
+ export function shouldPreserveLock(preserveLock, lockHasModule) {
196
+ return !!(preserveLock && lockHasModule);
197
+ }
198
+
199
+ /**
200
+ * @param {ModulePinsFile} file
201
+ * @param {string} id
202
+ */
203
+ export function findPinKey(file, id) {
204
+ if (file.modules[id]) return id;
205
+ const want = normalizeModuleId(id);
206
+ for (const [key, pin] of Object.entries(file.modules)) {
207
+ if (normalizeModuleId(key) === want) return key;
208
+ if (pin?.manifestId && normalizeModuleId(pin.manifestId) === want) return key;
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * @param {string} root
215
+ * @returns {string[]}
216
+ */
217
+ function listPackageFolders(root) {
218
+ const dir = path.join(root, "modules", "packages");
219
+ if (!fs.existsSync(dir)) return [];
220
+ /** @type {string[]} */
221
+ const out = [];
222
+ for (const name of fs.readdirSync(dir)) {
223
+ if (name.startsWith(".")) continue;
224
+ const abs = path.join(dir, name);
225
+ try {
226
+ if (fs.statSync(abs).isDirectory()) out.push(name);
227
+ } catch {
228
+ /* 跳过读不到的目录 */
229
+ }
230
+ }
231
+ out.sort((a, b) => a.localeCompare(b));
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * 给还没有 pin 的已装目录补一条记录。不升级。
237
+ * @param {string} root
238
+ * @param {Record<string, { npm?: string, version?: string }> | null} [index]
239
+ * @returns {string[]} 新补上的目录名
240
+ */
241
+ export function backfillModulePins(root, index = null) {
242
+ const file = readPins(root);
243
+ /** @type {string[]} */
244
+ const added = [];
245
+ for (const folder of listPackageFolders(root)) {
246
+ if (file.modules[folder]) continue;
247
+ const dir = packageDirOf(root, folder);
248
+ const linked = isLinkedPackageDir(dir);
249
+ const name = readPackageName(dir);
250
+ const version = readPackageVersion(dir);
251
+ const manifestId = readManifestId(dir);
252
+ /** @type {ModulePin} */
253
+ let pin;
254
+ if (linked) {
255
+ pin = {
256
+ source: "link",
257
+ channel: "none",
258
+ spec: "",
259
+ installedVersion: version,
260
+ manifestId,
261
+ auto: false,
262
+ autoLocked: true,
263
+ autoSetBy: "default",
264
+ lastCheckedAt: null,
265
+ lastAppliedAt: null,
266
+ lastError: null,
267
+ };
268
+ } else if (name) {
269
+ pin = {
270
+ source: "npm",
271
+ channel: "none",
272
+ spec: version ? `npm:${name}@${version}` : `npm:${name}`,
273
+ installedVersion: version,
274
+ manifestId,
275
+ auto: false,
276
+ autoLocked: false,
277
+ autoSetBy: "default",
278
+ lastCheckedAt: null,
279
+ lastAppliedAt: null,
280
+ lastError: null,
281
+ };
282
+ } else {
283
+ pin = {
284
+ source: "local",
285
+ channel: "none",
286
+ spec: "",
287
+ installedVersion: version,
288
+ manifestId,
289
+ auto: false,
290
+ autoLocked: true,
291
+ autoSetBy: "default",
292
+ lastCheckedAt: null,
293
+ lastAppliedAt: null,
294
+ lastError: null,
295
+ };
296
+ }
297
+ const entry = index ? index[folder] || index[normalizeModuleId(manifestId || folder)] : null;
298
+ file.modules[folder] = reconcileDefaultAuto(pin, entry);
299
+ added.push(folder);
300
+ }
301
+ if (added.length > 0) writePins(root, file);
302
+ return added;
303
+ }
304
+
305
+ /**
306
+ * 用磁盘上的 package.json 刷新已装版本,并按索引重算默认 auto。
307
+ * @param {string} root
308
+ * @param {Record<string, { npm?: string, version?: string }> | null} index
309
+ */
310
+ export function refreshPinsFromDisk(root, index) {
311
+ backfillModulePins(root, index);
312
+ const file = readPins(root);
313
+ let changed = false;
314
+ for (const [folder, pin] of Object.entries(file.modules)) {
315
+ const dir = packageDirOf(root, folder);
316
+ if (!fs.existsSync(dir)) continue;
317
+ const version = readPackageVersion(dir);
318
+ const manifestId = readManifestId(dir);
319
+ const entry = index ? index[folder] || index[normalizeModuleId(manifestId || folder)] || index[normalizeModuleId(pin.manifestId || "")] : null;
320
+ let next = { ...pin };
321
+ if (version !== pin.installedVersion) next = { ...next, installedVersion: version };
322
+ if (manifestId && manifestId !== pin.manifestId) next = { ...next, manifestId };
323
+ next = reconcileDefaultAuto(next, entry);
324
+ if (JSON.stringify(next) !== JSON.stringify(pin)) {
325
+ file.modules[folder] = next;
326
+ changed = true;
327
+ }
328
+ }
329
+ if (changed) writePins(root, file);
330
+ return readPins(root);
331
+ }
332
+
333
+ /**
334
+ * 安装或升级成功后写入 pin。已有记录时保留服主设置的 auto。
335
+ * 写入失败只影响后续自动更新,不让本次安装失败。
336
+ * @param {string} root
337
+ * @param {string} folder
338
+ * @param {{ from?: string, link?: boolean }} opts
339
+ */
340
+ export function recordInstallPin(root, folder, opts = {}) {
341
+ const dir = packageDirOf(root, folder);
342
+ const from = String(opts.from ?? "");
343
+ const link = !!opts.link || isLinkedPackageDir(dir);
344
+ const file = readPins(root);
345
+ const prev = file.modules[folder];
346
+ const draft = inferPinDraft(from, link);
347
+ const version = readPackageVersion(dir);
348
+ const manifestId = readManifestId(dir);
349
+ /** @type {ModulePin} */
350
+ const pin = {
351
+ source: from || link ? draft.source : prev?.source || draft.source,
352
+ channel: prev?.channel === "dist-tag" && draft.source === "npm" && !draft.autoLocked ? "dist-tag" : draft.channel,
353
+ spec: from || prev?.spec || draft.spec,
354
+ installedVersion: version,
355
+ manifestId,
356
+ auto: draft.auto,
357
+ autoLocked: draft.autoLocked,
358
+ autoSetBy: "default",
359
+ lastCheckedAt: prev?.lastCheckedAt ?? null,
360
+ lastAppliedAt: Date.now(),
361
+ lastError: null,
362
+ };
363
+ if (!from && !link && prev) {
364
+ pin.source = prev.source;
365
+ pin.channel = prev.channel;
366
+ pin.spec = prev.spec;
367
+ pin.auto = prev.auto;
368
+ pin.autoLocked = prev.autoLocked;
369
+ pin.autoSetBy = prev.autoSetBy;
370
+ } else if (prev && prev.autoSetBy === "user" && !draft.autoLocked) {
371
+ pin.auto = prev.auto;
372
+ pin.autoSetBy = "user";
373
+ }
374
+ if (pin.autoLocked) {
375
+ pin.auto = false;
376
+ } else if (pin.autoSetBy !== "user") {
377
+ const index = readCachedRegistryIndex();
378
+ const entry = index ? index[folder] || index[normalizeModuleId(manifestId || folder)] : null;
379
+ const reconciled = reconcileDefaultAuto(pin, entry);
380
+ pin.auto = reconciled.auto;
381
+ pin.channel = reconciled.channel;
382
+ }
383
+ file.modules[folder] = pin;
384
+ writePins(root, file);
385
+ return pin;
386
+ }
387
+
388
+ /**
389
+ * @param {string} root
390
+ * @param {string} folderOrId
391
+ */
392
+ export function removePin(root, folderOrId) {
393
+ const file = readPins(root);
394
+ const key = findPinKey(file, folderOrId);
395
+ if (!key) return false;
396
+ delete file.modules[key];
397
+ writePins(root, file);
398
+ return true;
399
+ }
400
+
401
+ /**
402
+ * @param {string} root
403
+ * @param {string} id
404
+ * @param {string | null} message
405
+ */
406
+ export function markPinError(root, id, message) {
407
+ const file = readPins(root);
408
+ const key = findPinKey(file, id);
409
+ if (!key) return;
410
+ const pin = file.modules[key];
411
+ if (!pin) return;
412
+ pin.lastCheckedAt = Date.now();
413
+ pin.lastError = message;
414
+ writePins(root, file);
415
+ }
416
+
417
+ /**
418
+ * 打开或关闭某个模块的自动更新。链接和本地目录不能打开。
419
+ * @param {string} root
420
+ * @param {string} id
421
+ * @param {boolean} auto
422
+ * @returns {{ ok: true, code: "ok", id: string, auto: boolean } | { ok: false, code: "missing" | "locked", id?: string }}
423
+ */
424
+ export function setModulePinAuto(root, id, auto) {
425
+ backfillModulePins(root, null);
426
+ const file = readPins(root);
427
+ const key = findPinKey(file, id);
428
+ if (!key || !file.modules[key]) return { ok: false, code: "missing" };
429
+ const pin = file.modules[key];
430
+ if (pin.autoLocked && auto) return { ok: false, code: "locked", id: key };
431
+ pin.auto = !!auto;
432
+ pin.autoSetBy = "user";
433
+ pin.lastError = null;
434
+ writePins(root, file);
435
+ return { ok: true, code: "ok", id: key, auto: !!auto };
436
+ }
@@ -0,0 +1,83 @@
1
+ // @ts-check
2
+ /**
3
+ * 官方模块索引缓存。
4
+ * fetch-module 与模块更新共用同一份缓存文件,避免两套 TTL。
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { parseRegistryIndex } from "./registry-index.mjs";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ /** 与历史 fetch-module 常量一致,供 search 文案引用。 */
14
+ export const DEFAULT_REGISTRY_REPO = "Tanya7z/sfmc-modules";
15
+ export const DEFAULT_REGISTRY_TAG = "main";
16
+ export const DEFAULT_REGISTRY_INDEX_URL = `https://raw.githubusercontent.com/${DEFAULT_REGISTRY_REPO}/${DEFAULT_REGISTRY_TAG}/index.json`;
17
+
18
+ /** 缓存落在 module-install 目录,和原先 fetch-module 旁边的文件是同一个。 */
19
+ const REGISTRY_CACHE_PATH = path.join(__dirname, "..", ".sfmc-registry-cache.json");
20
+ const REGISTRY_CACHE_TTL_MS = 60 * 60 * 1000;
21
+
22
+ function readCache() {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(REGISTRY_CACHE_PATH, "utf8"));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** 只读缓存,安装落盘时用来判断模块是否属于官方索引,不再打一次网络。 */
31
+ export function readCachedRegistryIndex() {
32
+ const cache = readCache();
33
+ return cache?.index ?? null;
34
+ }
35
+
36
+ /**
37
+ * @param {{ fetchedAt: number, index: Record<string, import("./registry-index.mjs").RegistryEntry> }} cache
38
+ */
39
+ function writeCache(cache) {
40
+ try {
41
+ fs.writeFileSync(REGISTRY_CACHE_PATH, JSON.stringify(cache, null, 2));
42
+ } catch {
43
+ /* best-effort */
44
+ }
45
+ }
46
+
47
+ async function fetchRegistryIndexFresh() {
48
+ const res = await fetch(DEFAULT_REGISTRY_INDEX_URL, { headers: { "User-Agent": "sfmc-fetch-module" } });
49
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${DEFAULT_REGISTRY_INDEX_URL}`);
50
+ return parseRegistryIndex(await res.json());
51
+ }
52
+
53
+ /**
54
+ * 解析官方索引。
55
+ * 缓存未过期时仍尝试拉新;失败则用缓存。完全没有缓存且网络失败时抛出。
56
+ * @returns {Promise<{ index: Record<string, import("./registry-index.mjs").RegistryEntry>, stale: boolean }>}
57
+ */
58
+ export async function resolveRegistryIndex() {
59
+ const cache = readCache();
60
+ if (cache && Date.now() - cache.fetchedAt < REGISTRY_CACHE_TTL_MS) {
61
+ try {
62
+ const fresh = await fetchRegistryIndexFresh();
63
+ writeCache({ fetchedAt: Date.now(), index: fresh });
64
+ return { index: fresh, stale: false };
65
+ } catch {
66
+ return { index: cache.index, stale: false };
67
+ }
68
+ }
69
+ try {
70
+ const fresh = await fetchRegistryIndexFresh();
71
+ writeCache({ fetchedAt: Date.now(), index: fresh });
72
+ return { index: fresh, stale: false };
73
+ } catch (e) {
74
+ const message = e instanceof Error ? e.message : String(e);
75
+ if (cache) {
76
+ console.warn(
77
+ `[fetch-module] registry offline (${message}); using cached index from ${new Date(cache.fetchedAt).toISOString()}`
78
+ );
79
+ return { index: cache.index, stale: true };
80
+ }
81
+ throw new Error(`registry unreachable and no cache: ${message}. Pass --from explicitly to skip the registry.`);
82
+ }
83
+ }
@@ -0,0 +1,124 @@
1
+ // @ts-check
2
+ /**
3
+ * 模块版本比较。
4
+ * 只覆盖 x.y.z 与可选预发布号,供自动更新决定能否升级。
5
+ */
6
+
7
+ /**
8
+ * @typedef {{ major: number, minor: number, patch: number, pre: string[] | null }} ParsedSemver
9
+ */
10
+
11
+ /**
12
+ * @param {string | null | undefined} input
13
+ * @returns {ParsedSemver | null}
14
+ */
15
+ export function parseSemver(input) {
16
+ const match = String(input ?? "")
17
+ .trim()
18
+ .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
19
+ if (!match) return null;
20
+ return {
21
+ major: Number(match[1]),
22
+ minor: Number(match[2]),
23
+ patch: Number(match[3]),
24
+ pre: match[4] ? match[4].split(".") : null,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * 预发布标识按 semver 规则比较:数字按数值,其余按字典序。
30
+ * @param {string} a
31
+ * @param {string} b
32
+ */
33
+ function comparePreId(a, b) {
34
+ const aNum = /^\d+$/.test(a);
35
+ const bNum = /^\d+$/.test(b);
36
+ if (aNum && bNum) return Number(a) - Number(b);
37
+ if (aNum) return -1;
38
+ if (bNum) return 1;
39
+ return a < b ? -1 : a > b ? 1 : 0;
40
+ }
41
+
42
+ /**
43
+ * @param {string[] | null} a
44
+ * @param {string[] | null} b
45
+ */
46
+ function comparePre(a, b) {
47
+ if (!a && !b) return 0;
48
+ if (!a) return 1;
49
+ if (!b) return -1;
50
+ const len = Math.max(a.length, b.length);
51
+ for (let i = 0; i < len; i++) {
52
+ const left = a[i];
53
+ const right = b[i];
54
+ if (left === undefined) return -1;
55
+ if (right === undefined) return 1;
56
+ const diff = comparePreId(left, right);
57
+ if (diff !== 0) return diff;
58
+ }
59
+ return 0;
60
+ }
61
+
62
+ /**
63
+ * 比较两个版本。无法解析时返回 null。
64
+ * @param {string | null | undefined} left
65
+ * @param {string | null | undefined} right
66
+ * @returns {-1 | 0 | 1 | null}
67
+ */
68
+ export function compareSemver(left, right) {
69
+ const a = parseSemver(left);
70
+ const b = parseSemver(right);
71
+ if (!a || !b) return null;
72
+ if (a.major !== b.major) return a.major > b.major ? 1 : -1;
73
+ if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
74
+ if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
75
+ const pre = comparePre(a.pre, b.pre);
76
+ return pre > 0 ? 1 : pre < 0 ? -1 : 0;
77
+ }
78
+
79
+ /**
80
+ * 目标版本的主版本号是否高于已装版本。
81
+ * @param {string | null | undefined} installed
82
+ * @param {string | null | undefined} target
83
+ */
84
+ export function isMajorBump(installed, target) {
85
+ const from = parseSemver(installed);
86
+ const to = parseSemver(target);
87
+ if (!from || !to) return false;
88
+ return to.major > from.major;
89
+ }
90
+
91
+ /**
92
+ * 判断本机 SDK 版本是否满足索引里的范围。
93
+ * 支持 *、精确版本、以及 >= > <= < ^ ~ 前缀。
94
+ * @param {string | null | undefined} version
95
+ * @param {string | null | undefined} range
96
+ */
97
+ export function satisfiesSdk(version, range) {
98
+ const raw = String(range ?? "").trim();
99
+ if (!raw || raw === "*") return true;
100
+ const match = raw.match(/^(>=|>|<=|<|\^|~|=)?\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/);
101
+ if (!match) return false;
102
+ const op = match[1] || "=";
103
+ const wanted = match[2];
104
+ const cmp = compareSemver(version, wanted);
105
+ if (cmp === null || !wanted) return false;
106
+ if (op === "=") return cmp === 0;
107
+ if (op === ">=") return cmp >= 0;
108
+ if (op === ">") return cmp > 0;
109
+ if (op === "<=") return cmp <= 0;
110
+ if (op === "<") return cmp < 0;
111
+ const current = parseSemver(version);
112
+ const base = parseSemver(wanted);
113
+ if (!current || !base) return false;
114
+ if (op === "^") {
115
+ if (cmp < 0) return false;
116
+ if (base.major > 0) return current.major === base.major;
117
+ if (base.minor > 0) return current.major === 0 && current.minor === base.minor;
118
+ return current.major === 0 && current.minor === 0 && current.patch === base.patch;
119
+ }
120
+ if (op === "~") {
121
+ return cmp >= 0 && current.major === base.major && current.minor === base.minor;
122
+ }
123
+ return false;
124
+ }