@sfmc-bds/cli 0.2.4 → 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 +87 -3
  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,507 @@
1
+ // @ts-check
2
+ /**
3
+ * 模块更新编排:根据 pin、索引和本机 SDK 决定升级候选,并按依赖顺序换包。
4
+ * 下载与落盘由调用方注入的 install,本文件不复制第二套安装逻辑。
5
+ */
6
+ import fs from "node:fs";
7
+ import fsp from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { randomBytes } from "node:crypto";
10
+ import { createRequire } from "node:module";
11
+ import { upsertCatalogEntry } from "./catalog.mjs";
12
+ import { readJson } from "./io.mjs";
13
+ import { loadModuleUpdateConfig } from "./module-update-config.mjs";
14
+ import {
15
+ findPinKey,
16
+ markPinError,
17
+ normalizeModuleId,
18
+ packageDirOf,
19
+ readManifestId,
20
+ readPackageName,
21
+ readPackageVersion,
22
+ refreshPinsFromDisk,
23
+ } from "./pins.mjs";
24
+ import { CLI_PKG_DIR, ROOT } from "./paths.mjs";
25
+ import { resolveRegistryIndex } from "./registry-cache.mjs";
26
+ import { compareSemver, isMajorBump, satisfiesSdk } from "./semver.mjs";
27
+
28
+ /**
29
+ * @typedef {{
30
+ * id: string,
31
+ * fromVersion: string | null,
32
+ * toVersion: string | null,
33
+ * spec: string,
34
+ * requires: string[],
35
+ * }} ModuleUpgrade
36
+ * @typedef {{
37
+ * id: string,
38
+ * reason: string,
39
+ * fromVersion: string | null,
40
+ * toVersion: string | null,
41
+ * detail: string,
42
+ * }} ModuleSkip
43
+ * @typedef {{
44
+ * configSkipped: "disabled" | "check-off" | null,
45
+ * applyOnStart: boolean,
46
+ * failMode: "continue" | "abort",
47
+ * upgrades: ModuleUpgrade[],
48
+ * skipped: ModuleSkip[],
49
+ * }} ModuleUpdatePlan
50
+ */
51
+
52
+ /** 这些跳过原因在开服日志里不展开,避免每轮刷屏。 */
53
+ export const QUIET_SKIP_REASONS = new Set(["auto-off", "local-source", "up-to-date"]);
54
+
55
+ /**
56
+ * 读取本机 @sfmc-bds/sdk 版本,用来对照索引里的 sdk 范围。
57
+ * @param {string} [cliPkgDir]
58
+ * @returns {string | null}
59
+ */
60
+ export function readHostSdkVersion(cliPkgDir = CLI_PKG_DIR) {
61
+ try {
62
+ const require = createRequire(path.join(cliPkgDir, "package.json"));
63
+ const pkg = require("@sfmc-bds/sdk/package.json");
64
+ if (pkg && typeof pkg.version === "string") return pkg.version;
65
+ } catch {
66
+ /* 开发仓里可能还没装到 node_modules */
67
+ }
68
+ const bundled = path.join(cliPkgDir, "..", "..", "modules", "sdk", "@sfmc-sdk", "package.json");
69
+ const raw = readJson(bundled, null);
70
+ if (raw && typeof raw.version === "string") return raw.version;
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * 拉取 npm dist-tag 上的版本号。
76
+ * @param {string} packageName
77
+ * @param {string} [tag]
78
+ * @returns {Promise<string | null>}
79
+ */
80
+ export async function fetchNpmDistTag(packageName, tag = "latest") {
81
+ const encoded = packageName.replace("/", "%2f");
82
+ const res = await fetch(`https://registry.npmjs.org/${encoded}`, {
83
+ headers: {
84
+ Accept: "application/vnd.npm.install-v1+json",
85
+ "User-Agent": "sfmc-fetch-module",
86
+ },
87
+ signal: AbortSignal.timeout(8000),
88
+ });
89
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${packageName}`);
90
+ const body = await res.json();
91
+ const version = body?.["dist-tags"]?.[tag];
92
+ return typeof version === "string" ? version : null;
93
+ }
94
+
95
+ /**
96
+ * @returns {Promise<{ index: Record<string, { npm?: string, version?: string, sdk?: string }> | null, offline: boolean }>}
97
+ */
98
+ export async function loadRegistryIndexForUpdate() {
99
+ try {
100
+ const { index } = await resolveRegistryIndex();
101
+ return { index, offline: false };
102
+ } catch {
103
+ return { index: null, offline: true };
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 决定单个模块是升级还是跳过。
109
+ * @param {{
110
+ * auto: boolean,
111
+ * autoLocked: boolean,
112
+ * source: string,
113
+ * installedVersion: string | null,
114
+ * targetVersion: string | null,
115
+ * allowMajor: boolean,
116
+ * sdkRange: string | null,
117
+ * hostSdk: string | null,
118
+ * offline: boolean,
119
+ * targetReason: string | null,
120
+ * }} input
121
+ * @returns {{ action: "upgrade" | "skip", reason: string, detail: string }}
122
+ */
123
+ export function decideModuleUpdate(input) {
124
+ if (input.autoLocked || input.source === "link" || input.source === "local") {
125
+ return { action: "skip", reason: "local-source", detail: "" };
126
+ }
127
+ if (!input.auto) return { action: "skip", reason: "auto-off", detail: "" };
128
+ if (input.offline && !input.targetVersion) {
129
+ return { action: "skip", reason: "registry-offline", detail: "" };
130
+ }
131
+ if (!input.targetVersion) {
132
+ return { action: "skip", reason: input.targetReason || "no-target", detail: "" };
133
+ }
134
+ if (!input.installedVersion) {
135
+ return { action: "skip", reason: "no-installed-version", detail: "" };
136
+ }
137
+ const cmp = compareSemver(input.targetVersion, input.installedVersion);
138
+ if (cmp === null) return { action: "skip", reason: "bad-version", detail: "" };
139
+ if (cmp === 0) return { action: "skip", reason: "up-to-date", detail: "" };
140
+ if (cmp < 0) return { action: "skip", reason: "installed-newer", detail: "" };
141
+ if (isMajorBump(input.installedVersion, input.targetVersion) && !input.allowMajor) {
142
+ return { action: "skip", reason: "major", detail: "" };
143
+ }
144
+ if (input.sdkRange && input.hostSdk && !satisfiesSdk(input.hostSdk, input.sdkRange)) {
145
+ return { action: "skip", reason: "sdk", detail: `${input.hostSdk} / ${input.sdkRange}` };
146
+ }
147
+ return { action: "upgrade", reason: "newer", detail: "" };
148
+ }
149
+
150
+ /**
151
+ * 被依赖的模块排在前面。环路保持原顺序附在末尾。
152
+ * @param {ModuleUpgrade[]} items
153
+ * @returns {ModuleUpgrade[]}
154
+ */
155
+ export function orderUpgrades(items) {
156
+ /** @type {Map<string, ModuleUpgrade>} */
157
+ const byNorm = new Map();
158
+ for (const item of items) byNorm.set(normalizeModuleId(item.id), item);
159
+ /** @type {Map<string, number>} */
160
+ const indegree = new Map();
161
+ /** @type {Map<string, string[]>} */
162
+ const children = new Map();
163
+ for (const item of items) {
164
+ const id = normalizeModuleId(item.id);
165
+ indegree.set(id, 0);
166
+ children.set(id, []);
167
+ }
168
+ for (const item of items) {
169
+ const id = normalizeModuleId(item.id);
170
+ for (const req of item.requires || []) {
171
+ if (!byNorm.has(req) || req === id) continue;
172
+ indegree.set(id, (indegree.get(id) || 0) + 1);
173
+ children.get(req)?.push(id);
174
+ }
175
+ }
176
+ /** @type {string[]} */
177
+ const queue = [];
178
+ for (const item of items) {
179
+ const id = normalizeModuleId(item.id);
180
+ if ((indegree.get(id) || 0) === 0) queue.push(id);
181
+ }
182
+ /** @type {ModuleUpgrade[]} */
183
+ const ordered = [];
184
+ const seen = new Set();
185
+ while (queue.length > 0) {
186
+ const id = queue.shift();
187
+ if (!id || seen.has(id)) continue;
188
+ seen.add(id);
189
+ const item = byNorm.get(id);
190
+ if (item) ordered.push(item);
191
+ for (const child of children.get(id) || []) {
192
+ indegree.set(child, (indegree.get(child) || 0) - 1);
193
+ if ((indegree.get(child) || 0) === 0) queue.push(child);
194
+ }
195
+ }
196
+ for (const item of items) {
197
+ if (!seen.has(normalizeModuleId(item.id))) ordered.push(item);
198
+ }
199
+ return ordered;
200
+ }
201
+
202
+ /**
203
+ * 从 pin.spec 取出 npm 包名。
204
+ * @param {string} spec
205
+ * @param {string | null} packageName
206
+ */
207
+ function npmNameFromPin(spec, packageName) {
208
+ if (packageName) return packageName;
209
+ if (!spec.startsWith("npm:")) return null;
210
+ const body = spec.slice("npm:".length);
211
+ const slash = body.indexOf("/");
212
+ const at = body.indexOf("@", slash >= 0 ? slash + 1 : 0);
213
+ const name = at > 0 ? body.slice(0, at) : body;
214
+ return name || null;
215
+ }
216
+
217
+ /**
218
+ * @param {import("./pins.mjs").ModulePin} pin
219
+ * @param {{ npm?: string, version?: string, sdk?: string } | null | undefined} indexEntry
220
+ * @param {(name: string, tag: string) => Promise<string | null>} fetchDistTag
221
+ * @param {string} distTag
222
+ * @param {boolean} indexOffline
223
+ * @param {string | null} packageName
224
+ */
225
+ async function resolveDesired(pin, indexEntry, fetchDistTag, distTag, indexOffline, packageName) {
226
+ if (!pin.auto || pin.autoLocked || pin.source === "link" || pin.source === "local") {
227
+ return { version: null, spec: null, sdk: null, offline: false, reason: null };
228
+ }
229
+ if (pin.source === "github") {
230
+ return { version: null, spec: null, sdk: null, offline: false, reason: "github-manual" };
231
+ }
232
+ if (pin.source === "npm" && indexEntry?.npm && indexEntry.version) {
233
+ return {
234
+ version: indexEntry.version,
235
+ spec: `npm:${indexEntry.npm}@${indexEntry.version}`,
236
+ sdk: indexEntry.sdk || null,
237
+ offline: false,
238
+ reason: null,
239
+ };
240
+ }
241
+ /* 默认跟随索引的包在索引不可用时不改去拉 npm latest。服主显式打开的包仍走 dist-tag。 */
242
+ const expectsIndex = pin.channel === "index" && pin.autoSetBy !== "user";
243
+ if (expectsIndex && (indexOffline || !indexEntry?.version)) {
244
+ return {
245
+ version: null,
246
+ spec: null,
247
+ sdk: indexEntry?.sdk || null,
248
+ offline: indexOffline,
249
+ reason: indexOffline ? "registry-offline" : "no-target",
250
+ };
251
+ }
252
+ const name = npmNameFromPin(pin.spec, packageName);
253
+ if (!name) return { version: null, spec: null, sdk: indexEntry?.sdk || null, offline: false, reason: "no-target" };
254
+ try {
255
+ const version = await fetchDistTag(name, distTag);
256
+ if (!version) return { version: null, spec: null, sdk: indexEntry?.sdk || null, offline: false, reason: "no-target" };
257
+ return {
258
+ version,
259
+ spec: `npm:${name}@${version}`,
260
+ sdk: indexEntry?.sdk || null,
261
+ offline: false,
262
+ reason: null,
263
+ };
264
+ } catch {
265
+ return { version: null, spec: null, sdk: null, offline: true, reason: "registry-offline" };
266
+ }
267
+ }
268
+
269
+ /**
270
+ * @param {string} root
271
+ * @param {string} folder
272
+ */
273
+ function readRequires(root, folder) {
274
+ const manifestPath = path.join(packageDirOf(root, folder), "sapi", "manifest.json");
275
+ const raw = readJson(manifestPath, null);
276
+ if (!raw || typeof raw !== "object") return [];
277
+ const requires = /** @type {Record<string, unknown>} */ (raw).requires;
278
+ if (!Array.isArray(requires)) return [];
279
+ return requires.map((item) => normalizeModuleId(String(item))).filter(Boolean);
280
+ }
281
+
282
+ /**
283
+ * 生成升级计划。startup 且未开启检查时不访问网络。
284
+ * @param {{
285
+ * root?: string,
286
+ * startup?: boolean,
287
+ * allowMajor?: boolean,
288
+ * ids?: string[],
289
+ * hostSdk?: string | null,
290
+ * fetchIndex?: () => Promise<{ index: Record<string, { npm?: string, version?: string, sdk?: string }> | null, offline: boolean }>,
291
+ * fetchDistTag?: (name: string, tag: string) => Promise<string | null>,
292
+ * }} [options]
293
+ * @returns {Promise<ModuleUpdatePlan>}
294
+ */
295
+ export async function planModuleUpdates(options = {}) {
296
+ const root = options.root ?? ROOT;
297
+ const cfg = loadModuleUpdateConfig(root);
298
+ if (!cfg.enabled) {
299
+ return { configSkipped: "disabled", applyOnStart: false, failMode: cfg.failMode, upgrades: [], skipped: [] };
300
+ }
301
+ if (options.startup && !cfg.checkOnBdsStart) {
302
+ return { configSkipped: "check-off", applyOnStart: false, failMode: cfg.failMode, upgrades: [], skipped: [] };
303
+ }
304
+ const fetchIndex = options.fetchIndex ?? loadRegistryIndexForUpdate;
305
+ const fetchDistTag = options.fetchDistTag ?? fetchNpmDistTag;
306
+ const loaded = await fetchIndex();
307
+ const index = loaded.index;
308
+ const indexOffline = loaded.offline || !index;
309
+ const pins = refreshPinsFromDisk(root, index);
310
+ const hostSdk = options.hostSdk === undefined ? readHostSdkVersion() : options.hostSdk;
311
+ const allowMajor = options.allowMajor === true || cfg.allowMajor;
312
+ const wanted = new Set((options.ids || []).map((id) => normalizeModuleId(id)).filter(Boolean));
313
+ const installedNorm = new Set();
314
+ for (const [folder, pin] of Object.entries(pins.modules)) {
315
+ installedNorm.add(normalizeModuleId(folder));
316
+ if (pin.manifestId) installedNorm.add(normalizeModuleId(pin.manifestId));
317
+ }
318
+
319
+ /** @type {ModuleUpgrade[]} */
320
+ const upgrades = [];
321
+ /** @type {ModuleSkip[]} */
322
+ const skipped = [];
323
+ /** @type {string[]} */
324
+ const missing = [];
325
+ if (wanted.size > 0) {
326
+ for (const id of options.ids || []) {
327
+ if (!findPinKey(pins, id)) missing.push(id);
328
+ }
329
+ }
330
+ for (const id of missing) {
331
+ skipped.push({ id, reason: "missing-module", fromVersion: null, toVersion: null, detail: "" });
332
+ }
333
+
334
+ for (const [folder, pin] of Object.entries(pins.modules)) {
335
+ const manifestId = pin.manifestId || readManifestId(packageDirOf(root, folder));
336
+ const keys = [normalizeModuleId(folder), normalizeModuleId(manifestId)];
337
+ if (wanted.size > 0 && !keys.some((key) => wanted.has(key))) continue;
338
+ const dir = packageDirOf(root, folder);
339
+ if (!fs.existsSync(dir)) continue;
340
+ const installedVersion = readPackageVersion(dir) ?? pin.installedVersion;
341
+ const indexEntry = index ? index[folder] || index[normalizeModuleId(manifestId || "")] || null : null;
342
+ const packageName = readPackageName(dir);
343
+ const desired = await resolveDesired(pin, indexEntry, fetchDistTag, cfg.distTag, indexOffline, packageName);
344
+ const requires = readRequires(root, folder);
345
+ const missingDep = requires.find((req) => req && !installedNorm.has(req));
346
+ if (pin.auto && !pin.autoLocked && missingDep) {
347
+ skipped.push({
348
+ id: folder,
349
+ reason: "missing-dependency",
350
+ fromVersion: installedVersion,
351
+ toVersion: desired.version,
352
+ detail: missingDep,
353
+ });
354
+ continue;
355
+ }
356
+ const decision = decideModuleUpdate({
357
+ auto: pin.auto,
358
+ autoLocked: pin.autoLocked,
359
+ source: pin.source,
360
+ installedVersion,
361
+ targetVersion: desired.version,
362
+ allowMajor,
363
+ sdkRange: desired.sdk,
364
+ hostSdk,
365
+ offline: desired.offline,
366
+ targetReason: desired.reason,
367
+ });
368
+ if (decision.action === "upgrade" && desired.spec && desired.version) {
369
+ upgrades.push({
370
+ id: folder,
371
+ fromVersion: installedVersion,
372
+ toVersion: desired.version,
373
+ spec: desired.spec,
374
+ requires,
375
+ });
376
+ } else {
377
+ skipped.push({
378
+ id: folder,
379
+ reason: decision.reason,
380
+ fromVersion: installedVersion,
381
+ toVersion: desired.version,
382
+ detail: decision.detail,
383
+ });
384
+ }
385
+ }
386
+
387
+ return {
388
+ configSkipped: null,
389
+ applyOnStart: options.startup ? cfg.applyOnBdsStart : true,
390
+ failMode: cfg.failMode,
391
+ upgrades: orderUpgrades(upgrades),
392
+ skipped,
393
+ };
394
+ }
395
+
396
+ /**
397
+ * 把现有包目录移到 modules/_trash,返回备份路径。目录不存在时返回 null。
398
+ * junction 只移动链接本身。
399
+ * @param {string} root
400
+ * @param {string} id
401
+ * @returns {Promise<string | null>}
402
+ */
403
+ export async function backupPackageDir(root, id) {
404
+ const src = packageDirOf(root, id);
405
+ try {
406
+ await fsp.lstat(src);
407
+ } catch {
408
+ return null;
409
+ }
410
+ const trash = path.join(root, "modules", "_trash");
411
+ await fsp.mkdir(trash, { recursive: true });
412
+ const dest = path.join(trash, `${id}-${Date.now()}-${randomBytes(3).toString("hex")}`);
413
+ await fsp.rename(src, dest);
414
+ return dest;
415
+ }
416
+
417
+ /**
418
+ * 删掉失败的新目录,再把备份移回原位。
419
+ * @param {string} root
420
+ * @param {string} id
421
+ * @param {string} backup
422
+ */
423
+ export async function restorePackageDir(root, id, backup) {
424
+ const dest = packageDirOf(root, id);
425
+ await fsp.rm(dest, { recursive: true, force: true });
426
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
427
+ await fsp.rename(backup, dest);
428
+ }
429
+
430
+ /**
431
+ * 还原后按磁盘上的 manifest 重写 catalog 里这一条。
432
+ * @param {string} folder
433
+ */
434
+ export function resyncInstalledCatalog(folder) {
435
+ try {
436
+ upsertCatalogEntry(folder);
437
+ } catch {
438
+ /* 目录暂时读不到 manifest 时保留调用方已经恢复的文件 */
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 按计划换包。单个失败时还原该模块,并根据 failMode 决定是否继续。
444
+ * @param {ModuleUpgrade[]} upgrades
445
+ * @param {{
446
+ * root: string,
447
+ * failMode: "continue" | "abort",
448
+ * install: (id: string, spec: string) => Promise<void>,
449
+ * resync?: (id: string) => void,
450
+ * }} deps
451
+ */
452
+ export async function applyModuleUpgrades(upgrades, deps) {
453
+ /** @type {Set<string>} */
454
+ const failedNorm = new Set();
455
+ /** @type {ModuleUpgrade[]} */
456
+ const applied = [];
457
+ /** @type {ModuleSkip[]} */
458
+ const skipped = [];
459
+ /** @type {{ id: string, message: string }[]} */
460
+ const failed = [];
461
+ for (const item of upgrades) {
462
+ const blocked = (item.requires || []).some((req) => failedNorm.has(req));
463
+ if (blocked) {
464
+ skipped.push({
465
+ id: item.id,
466
+ reason: "dependency-failed",
467
+ fromVersion: item.fromVersion,
468
+ toVersion: item.toVersion,
469
+ detail: "",
470
+ });
471
+ failedNorm.add(normalizeModuleId(item.id));
472
+ if (deps.failMode === "abort") break;
473
+ continue;
474
+ }
475
+ /** @type {string | null} */
476
+ let backup = null;
477
+ try {
478
+ backup = await backupPackageDir(deps.root, item.id);
479
+ await deps.install(item.id, item.spec);
480
+ applied.push(item);
481
+ } catch (error) {
482
+ const message = error instanceof Error ? error.message : String(error);
483
+ if (backup) {
484
+ try {
485
+ await restorePackageDir(deps.root, item.id, backup);
486
+ } catch {
487
+ /* 还原失败时保留回收站里的备份路径在错误信息中 */
488
+ }
489
+ }
490
+ if (deps.resync) {
491
+ try {
492
+ deps.resync(item.id);
493
+ } catch {
494
+ /* catalog 重写失败不掩盖原来的升级错误 */
495
+ }
496
+ }
497
+ markPinError(deps.root, item.id, message);
498
+ failed.push({ id: item.id, message });
499
+ failedNorm.add(normalizeModuleId(item.id));
500
+ if (deps.failMode === "abort") break;
501
+ }
502
+ }
503
+ return { applied, skipped, failed };
504
+ }
505
+
506
+ export { ensureModuleUpdateConfigFile } from "./module-update-config.mjs";
507
+ export { setModulePinAuto } from "./pins.mjs";