@sfmc-bds/cli 0.3.1 → 0.3.2
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/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +2 -2
- package/dist/i18n/locales/en.d.ts +11 -5
- package/dist/i18n/locales/en.d.ts.map +1 -1
- package/dist/i18n/locales/en.js +11 -5
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/zh-CN.d.ts.map +1 -1
- package/dist/i18n/locales/zh-CN.js +11 -5
- package/dist/i18n/locales/zh-CN.js.map +2 -2
- package/dist/interactive-prompts.d.ts +1 -1
- package/dist/interactive-prompts.d.ts.map +1 -1
- package/dist/interactive-prompts.js +4 -2
- package/dist/interactive-prompts.js.map +2 -2
- package/dist/module-update/index.d.ts +2 -5
- package/dist/module-update/index.d.ts.map +1 -1
- package/dist/module-update/index.js +5 -29
- package/dist/module-update/index.js.map +2 -2
- package/dist/services.d.ts +19 -10
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +17 -10
- package/dist/services.js.map +2 -2
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +213 -142
- package/dist/wizard.js.map +3 -3
- package/package.json +4 -4
- package/scripts/module-install/fetch-module.mjs +4 -2
- package/scripts/module-install/lib/module-update-config.mjs +2 -2
- package/scripts/module-install/lib/module-update.mjs +94 -6
- package/scripts/module-install/lib/pins.mjs +15 -4
- package/scripts/module-install/lib/registry-cache.mjs +5 -1
- package/scripts/module-install/test/module-update.test.mjs +349 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
/**
|
|
3
3
|
* 模块更新编排:根据 pin、索引和本机 SDK 决定升级候选,并按依赖顺序换包。
|
|
4
|
+
* 安装目录若是 symlink / junction,只记 local-source 跳过,不换包。
|
|
4
5
|
* 下载与落盘由调用方注入的 install,本文件不复制第二套安装逻辑。
|
|
5
6
|
*/
|
|
6
7
|
import fs from "node:fs";
|
|
@@ -13,6 +14,7 @@ import { readJson } from "./io.mjs";
|
|
|
13
14
|
import { loadModuleUpdateConfig } from "./module-update-config.mjs";
|
|
14
15
|
import {
|
|
15
16
|
findPinKey,
|
|
17
|
+
isLinkedPackageDir,
|
|
16
18
|
markPinError,
|
|
17
19
|
normalizeModuleId,
|
|
18
20
|
packageDirOf,
|
|
@@ -141,12 +143,41 @@ export function decideModuleUpdate(input) {
|
|
|
141
143
|
if (isMajorBump(input.installedVersion, input.targetVersion) && !input.allowMajor) {
|
|
142
144
|
return { action: "skip", reason: "major", detail: "" };
|
|
143
145
|
}
|
|
146
|
+
/* 范围明确但本机 SDK 读不到时不要升级。空范围和 * 仍走下面的原判断。 */
|
|
147
|
+
if (sdkRangeConstrainsHost(input.sdkRange) && !input.hostSdk) {
|
|
148
|
+
return { action: "skip", reason: "sdk", detail: "本机 SDK 版本未知" };
|
|
149
|
+
}
|
|
144
150
|
if (input.sdkRange && input.hostSdk && !satisfiesSdk(input.hostSdk, input.sdkRange)) {
|
|
145
151
|
return { action: "skip", reason: "sdk", detail: `${input.hostSdk} / ${input.sdkRange}` };
|
|
146
152
|
}
|
|
147
153
|
return { action: "upgrade", reason: "newer", detail: "" };
|
|
148
154
|
}
|
|
149
155
|
|
|
156
|
+
/**
|
|
157
|
+
* 这个 SDK 范围是否要求对照本机版本。
|
|
158
|
+
* 使用场景:decideModuleUpdate 在 hostSdk 为空时,只有非空且不是 * 的范围才跳过。
|
|
159
|
+
* @param {string | null | undefined} sdkRange
|
|
160
|
+
*/
|
|
161
|
+
function sdkRangeConstrainsHost(sdkRange) {
|
|
162
|
+
if (typeof sdkRange !== "string") return false;
|
|
163
|
+
const text = sdkRange.trim();
|
|
164
|
+
return text.length > 0 && text !== "*";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 安装目录是 symlink 或 Windows junction 时返回 local-source 跳过项,否则返回 null。
|
|
169
|
+
* 使用场景:规划阶段不把开发链接放进 upgrades;apply 阶段即使候选被强行传入,也不调用 install 覆盖链接。
|
|
170
|
+
* @param {string} id
|
|
171
|
+
* @param {string} dir
|
|
172
|
+
* @param {string | null} fromVersion
|
|
173
|
+
* @param {string | null} toVersion
|
|
174
|
+
* @returns {ModuleSkip | null}
|
|
175
|
+
*/
|
|
176
|
+
function skipWhenDevLink(id, dir, fromVersion, toVersion) {
|
|
177
|
+
if (!isLinkedPackageDir(dir)) return null;
|
|
178
|
+
return { id, reason: "local-source", fromVersion, toVersion, detail: "" };
|
|
179
|
+
}
|
|
180
|
+
|
|
150
181
|
/**
|
|
151
182
|
* 被依赖的模块排在前面。环路保持原顺序附在末尾。
|
|
152
183
|
* @param {ModuleUpgrade[]} items
|
|
@@ -214,6 +245,24 @@ function npmNameFromPin(spec, packageName) {
|
|
|
214
245
|
return name || null;
|
|
215
246
|
}
|
|
216
247
|
|
|
248
|
+
/**
|
|
249
|
+
* 索引已经在手里时,只记下目标版本供跳过记录展示,不生成安装 spec。
|
|
250
|
+
* 使用场景:自动更新关闭,以及本地链接 / local / autoLocked,都不能为此去打 npm dist-tag。
|
|
251
|
+
* @param {{ npm?: string, version?: string, sdk?: string } | null | undefined} indexEntry
|
|
252
|
+
*/
|
|
253
|
+
function desiredVersionFromHeldIndex(indexEntry) {
|
|
254
|
+
if (indexEntry?.version) {
|
|
255
|
+
return {
|
|
256
|
+
version: indexEntry.version,
|
|
257
|
+
spec: null,
|
|
258
|
+
sdk: indexEntry.sdk || null,
|
|
259
|
+
offline: false,
|
|
260
|
+
reason: null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return { version: null, spec: null, sdk: indexEntry?.sdk || null, offline: false, reason: null };
|
|
264
|
+
}
|
|
265
|
+
|
|
217
266
|
/**
|
|
218
267
|
* @param {import("./pins.mjs").ModulePin} pin
|
|
219
268
|
* @param {{ npm?: string, version?: string, sdk?: string } | null | undefined} indexEntry
|
|
@@ -223,8 +272,9 @@ function npmNameFromPin(spec, packageName) {
|
|
|
223
272
|
* @param {string | null} packageName
|
|
224
273
|
*/
|
|
225
274
|
async function resolveDesired(pin, indexEntry, fetchDistTag, distTag, indexOffline, packageName) {
|
|
226
|
-
|
|
227
|
-
|
|
275
|
+
/* 本地链接、目录和锁定来源不查 dist-tag;索引已在手里时仍把目标版本交给跳过记录。 */
|
|
276
|
+
if (pin.autoLocked || pin.source === "link" || pin.source === "local") {
|
|
277
|
+
return desiredVersionFromHeldIndex(indexEntry);
|
|
228
278
|
}
|
|
229
279
|
if (pin.source === "github") {
|
|
230
280
|
return { version: null, spec: null, sdk: null, offline: false, reason: "github-manual" };
|
|
@@ -232,12 +282,15 @@ async function resolveDesired(pin, indexEntry, fetchDistTag, distTag, indexOffli
|
|
|
232
282
|
if (pin.source === "npm" && indexEntry?.npm && indexEntry.version) {
|
|
233
283
|
return {
|
|
234
284
|
version: indexEntry.version,
|
|
235
|
-
spec
|
|
285
|
+
/* auto 关闭只展示版本,不给出可安装 spec,避免被当成升级。 */
|
|
286
|
+
spec: pin.auto ? `npm:${indexEntry.npm}@${indexEntry.version}` : null,
|
|
236
287
|
sdk: indexEntry.sdk || null,
|
|
237
288
|
offline: false,
|
|
238
289
|
reason: null,
|
|
239
290
|
};
|
|
240
291
|
}
|
|
292
|
+
/* 自动更新关闭且索引里没有目标版本时,不要改去打 dist-tag。 */
|
|
293
|
+
if (!pin.auto) return desiredVersionFromHeldIndex(indexEntry);
|
|
241
294
|
/* 默认跟随索引的包在索引不可用时不改去拉 npm latest。服主显式打开的包仍走 dist-tag。 */
|
|
242
295
|
const expectsIndex = pin.channel === "index" && pin.autoSetBy !== "user";
|
|
243
296
|
if (expectsIndex && (indexOffline || !indexEntry?.version)) {
|
|
@@ -336,9 +389,20 @@ export async function planModuleUpdates(options = {}) {
|
|
|
336
389
|
const keys = [normalizeModuleId(folder), normalizeModuleId(manifestId)];
|
|
337
390
|
if (wanted.size > 0 && !keys.some((key) => wanted.has(key))) continue;
|
|
338
391
|
const dir = packageDirOf(root, folder);
|
|
339
|
-
if (!fs.existsSync(dir)) continue;
|
|
340
392
|
const installedVersion = readPackageVersion(dir) ?? pin.installedVersion;
|
|
341
393
|
const indexEntry = index ? index[folder] || index[normalizeModuleId(manifestId || "")] || null : null;
|
|
394
|
+
/* 目录本身是开发链接时先跳过,避免 pin 写成 npm 后仍去查 dist-tag 并换包。 */
|
|
395
|
+
const linkSkip = skipWhenDevLink(
|
|
396
|
+
folder,
|
|
397
|
+
dir,
|
|
398
|
+
installedVersion,
|
|
399
|
+
desiredVersionFromHeldIndex(indexEntry).version,
|
|
400
|
+
);
|
|
401
|
+
if (linkSkip) {
|
|
402
|
+
skipped.push(linkSkip);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!fs.existsSync(dir)) continue;
|
|
342
406
|
const packageName = readPackageName(dir);
|
|
343
407
|
const desired = await resolveDesired(pin, indexEntry, fetchDistTag, cfg.distTag, indexOffline, packageName);
|
|
344
408
|
const requires = readRequires(root, folder);
|
|
@@ -427,6 +491,20 @@ export async function restorePackageDir(root, id, backup) {
|
|
|
427
491
|
await fsp.rename(backup, dest);
|
|
428
492
|
}
|
|
429
493
|
|
|
494
|
+
/**
|
|
495
|
+
* 删掉 backupPackageDir 这次留下的备份目录。
|
|
496
|
+
* 使用场景:applyModuleUpgrades 在 install 成功后调用;删除失败必须忽略,不能把成功升级记成失败。
|
|
497
|
+
* @param {string | null} backup
|
|
498
|
+
*/
|
|
499
|
+
async function discardUpgradeBackup(backup) {
|
|
500
|
+
if (!backup) return;
|
|
501
|
+
try {
|
|
502
|
+
await fsp.rm(backup, { recursive: true, force: true });
|
|
503
|
+
} catch {
|
|
504
|
+
/* 回收站删不掉时保留目录,不回滚已经装好的包 */
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
430
508
|
/**
|
|
431
509
|
* 还原后按磁盘上的 manifest 重写 catalog 里这一条。
|
|
432
510
|
* @param {string} folder
|
|
@@ -441,6 +519,7 @@ export function resyncInstalledCatalog(folder) {
|
|
|
441
519
|
|
|
442
520
|
/**
|
|
443
521
|
* 按计划换包。单个失败时还原该模块,并根据 failMode 决定是否继续。
|
|
522
|
+
* 安装目录若是 symlink / junction,记 local-source 并跳过,不调用 install。
|
|
444
523
|
* @param {ModuleUpgrade[]} upgrades
|
|
445
524
|
* @param {{
|
|
446
525
|
* root: string,
|
|
@@ -459,6 +538,11 @@ export async function applyModuleUpgrades(upgrades, deps) {
|
|
|
459
538
|
/** @type {{ id: string, message: string }[]} */
|
|
460
539
|
const failed = [];
|
|
461
540
|
for (const item of upgrades) {
|
|
541
|
+
const linkSkip = skipWhenDevLink(item.id, packageDirOf(deps.root, item.id), item.fromVersion, item.toVersion);
|
|
542
|
+
if (linkSkip) {
|
|
543
|
+
skipped.push(linkSkip);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
462
546
|
const blocked = (item.requires || []).some((req) => failedNorm.has(req));
|
|
463
547
|
if (blocked) {
|
|
464
548
|
skipped.push({
|
|
@@ -478,13 +562,17 @@ export async function applyModuleUpgrades(upgrades, deps) {
|
|
|
478
562
|
backup = await backupPackageDir(deps.root, item.id);
|
|
479
563
|
await deps.install(item.id, item.spec);
|
|
480
564
|
applied.push(item);
|
|
565
|
+
/* 这次升级已经落盘,删掉刚才移进 _trash 的那一份备份。 */
|
|
566
|
+
await discardUpgradeBackup(backup);
|
|
481
567
|
} catch (error) {
|
|
482
|
-
const
|
|
568
|
+
const installError = error instanceof Error ? error.message : String(error);
|
|
569
|
+
/** 失败说明。还原失败时在安装错误后面附上备份绝对路径。 */
|
|
570
|
+
let message = installError;
|
|
483
571
|
if (backup) {
|
|
484
572
|
try {
|
|
485
573
|
await restorePackageDir(deps.root, item.id, backup);
|
|
486
574
|
} catch {
|
|
487
|
-
|
|
575
|
+
message = `${installError};还原失败,备份仍在 ${path.resolve(backup)}`;
|
|
488
576
|
}
|
|
489
577
|
}
|
|
490
578
|
if (deps.resync) {
|
|
@@ -79,12 +79,21 @@ export function writePins(root, file) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* 目录是
|
|
82
|
+
* 目录是 symlink 或 Windows junction 时视为本地开发链接。
|
|
83
|
+
* 使用场景:回填 pin,以及更新器在规划和应用前拒绝覆盖开发链接。
|
|
83
84
|
* @param {string} dir
|
|
84
85
|
*/
|
|
85
86
|
export function isLinkedPackageDir(dir) {
|
|
86
87
|
try {
|
|
87
|
-
|
|
88
|
+
if (fs.lstatSync(dir).isSymbolicLink()) return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/* 个别 Windows junction 的 lstat 不标成 symlink,能读到链接目标也算链接。 */
|
|
93
|
+
if (process.platform !== "win32") return false;
|
|
94
|
+
try {
|
|
95
|
+
fs.readlinkSync(dir);
|
|
96
|
+
return true;
|
|
88
97
|
} catch {
|
|
89
98
|
return false;
|
|
90
99
|
}
|
|
@@ -415,7 +424,9 @@ export function markPinError(root, id, message) {
|
|
|
415
424
|
}
|
|
416
425
|
|
|
417
426
|
/**
|
|
418
|
-
*
|
|
427
|
+
* 打开或关闭某个模块的自动更新。
|
|
428
|
+
* 链接、本地目录(autoLocked)以及 GitHub 来源不能打开;关闭仍然允许。
|
|
429
|
+
* GitHub 的 autoLocked 保持 false,计划更新仍走 github-manual。
|
|
419
430
|
* @param {string} root
|
|
420
431
|
* @param {string} id
|
|
421
432
|
* @param {boolean} auto
|
|
@@ -427,7 +438,7 @@ export function setModulePinAuto(root, id, auto) {
|
|
|
427
438
|
const key = findPinKey(file, id);
|
|
428
439
|
if (!key || !file.modules[key]) return { ok: false, code: "missing" };
|
|
429
440
|
const pin = file.modules[key];
|
|
430
|
-
if (pin.autoLocked
|
|
441
|
+
if (auto && (pin.autoLocked || pin.source === "github")) return { ok: false, code: "locked", id: key };
|
|
431
442
|
pin.auto = !!auto;
|
|
432
443
|
pin.autoSetBy = "user";
|
|
433
444
|
pin.lastError = null;
|
|
@@ -45,7 +45,11 @@ function writeCache(cache) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
async function fetchRegistryIndexFresh() {
|
|
48
|
-
|
|
48
|
+
// BDS beforeStart 会等这次索引请求。8 秒超时与 dist-tag 同量级,超时或失败后仍由 resolveRegistryIndex 的 catch 回退缓存。
|
|
49
|
+
const res = await fetch(DEFAULT_REGISTRY_INDEX_URL, {
|
|
50
|
+
headers: { "User-Agent": "sfmc-fetch-module" },
|
|
51
|
+
signal: AbortSignal.timeout(8000),
|
|
52
|
+
});
|
|
49
53
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${DEFAULT_REGISTRY_INDEX_URL}`);
|
|
50
54
|
return parseRegistryIndex(await res.json());
|
|
51
55
|
}
|
|
@@ -8,6 +8,7 @@ import os from "node:os";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import test from "node:test";
|
|
10
10
|
import { compareSemver, isMajorBump, satisfiesSdk } from "../lib/semver.mjs";
|
|
11
|
+
import { DEFAULT_MODULE_UPDATE_CONFIG } from "../lib/module-update-config.mjs";
|
|
11
12
|
import { decideModuleUpdate, orderUpgrades, applyModuleUpgrades, planModuleUpdates } from "../lib/module-update.mjs";
|
|
12
13
|
import {
|
|
13
14
|
inferPinDraft,
|
|
@@ -53,6 +54,13 @@ test("decideModuleUpdate 按来源和版本取舍", () => {
|
|
|
53
54
|
decideModuleUpdate({ ...base, sdkRange: ">=0.3.0", hostSdk: "0.2.5" }).reason,
|
|
54
55
|
"sdk"
|
|
55
56
|
);
|
|
57
|
+
const unknownHost = decideModuleUpdate({ ...base, sdkRange: ">=0.3.0", hostSdk: null });
|
|
58
|
+
assert.equal(unknownHost.action, "skip");
|
|
59
|
+
assert.equal(unknownHost.reason, "sdk");
|
|
60
|
+
assert.match(unknownHost.detail, /未知/);
|
|
61
|
+
assert.equal(decideModuleUpdate({ ...base, sdkRange: null, hostSdk: null }).action, "upgrade");
|
|
62
|
+
assert.equal(decideModuleUpdate({ ...base, sdkRange: "", hostSdk: null }).action, "upgrade");
|
|
63
|
+
assert.equal(decideModuleUpdate({ ...base, sdkRange: "*", hostSdk: null }).action, "upgrade");
|
|
56
64
|
});
|
|
57
65
|
|
|
58
66
|
test("升级顺序让被依赖的模块先走", () => {
|
|
@@ -180,3 +188,344 @@ test("本地 pin 不能打开自动更新", () => {
|
|
|
180
188
|
const off = setModulePinAuto(root, "demo", false);
|
|
181
189
|
assert.equal(off.ok, true);
|
|
182
190
|
});
|
|
191
|
+
|
|
192
|
+
/** 写一份已安装的 chat 目录,版本固定为 1.0.0。使用场景:计划测试需要磁盘上的包和 manifest。 */
|
|
193
|
+
function writeInstalledChat(root) {
|
|
194
|
+
const pkg = path.join(root, "modules", "packages", "chat");
|
|
195
|
+
fs.mkdirSync(path.join(pkg, "sapi"), { recursive: true });
|
|
196
|
+
fs.writeFileSync(path.join(pkg, "package.json"), JSON.stringify({ name: "@sfmc-bds/module-chat", version: "1.0.0" }));
|
|
197
|
+
fs.writeFileSync(
|
|
198
|
+
path.join(pkg, "sapi", "manifest.json"),
|
|
199
|
+
JSON.stringify({ id: "chat", configKey: "chat", requires: [] })
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
test("auto 关闭时不升级,但跳过记录带上索引版本", async () => {
|
|
204
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-auto-off-"));
|
|
205
|
+
writeInstalledChat(root);
|
|
206
|
+
writePins(root, {
|
|
207
|
+
version: 1,
|
|
208
|
+
modules: {
|
|
209
|
+
chat: {
|
|
210
|
+
source: "npm",
|
|
211
|
+
channel: "index",
|
|
212
|
+
spec: "npm:@sfmc-bds/module-chat@1.0.0",
|
|
213
|
+
installedVersion: "1.0.0",
|
|
214
|
+
manifestId: "chat",
|
|
215
|
+
auto: false,
|
|
216
|
+
autoLocked: false,
|
|
217
|
+
autoSetBy: "user",
|
|
218
|
+
lastCheckedAt: null,
|
|
219
|
+
lastAppliedAt: null,
|
|
220
|
+
lastError: null,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
let distTagCalls = 0;
|
|
225
|
+
const plan = await planModuleUpdates({
|
|
226
|
+
root,
|
|
227
|
+
hostSdk: "0.2.5",
|
|
228
|
+
ids: ["chat"],
|
|
229
|
+
fetchIndex: async () => ({
|
|
230
|
+
index: { chat: { npm: "@sfmc-bds/module-chat", version: "1.2.0", sdk: ">=0.2.2" } },
|
|
231
|
+
offline: false,
|
|
232
|
+
}),
|
|
233
|
+
fetchDistTag: async () => {
|
|
234
|
+
distTagCalls += 1;
|
|
235
|
+
return "9.9.9";
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
assert.equal(plan.upgrades.length, 0);
|
|
239
|
+
const skipped = plan.skipped.find((item) => item.id === "chat");
|
|
240
|
+
assert.ok(skipped);
|
|
241
|
+
assert.equal(skipped.reason, "auto-off");
|
|
242
|
+
assert.equal(skipped.toVersion, "1.2.0");
|
|
243
|
+
assert.equal(skipped.fromVersion, "1.0.0");
|
|
244
|
+
assert.equal(distTagCalls, 0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("本地链接不为查版本请求 dist-tag", async () => {
|
|
248
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-local-"));
|
|
249
|
+
writeInstalledChat(root);
|
|
250
|
+
writePins(root, {
|
|
251
|
+
version: 1,
|
|
252
|
+
modules: {
|
|
253
|
+
chat: {
|
|
254
|
+
source: "link",
|
|
255
|
+
channel: "none",
|
|
256
|
+
spec: "",
|
|
257
|
+
installedVersion: "1.0.0",
|
|
258
|
+
manifestId: "chat",
|
|
259
|
+
auto: false,
|
|
260
|
+
autoLocked: true,
|
|
261
|
+
autoSetBy: "default",
|
|
262
|
+
lastCheckedAt: null,
|
|
263
|
+
lastAppliedAt: null,
|
|
264
|
+
lastError: null,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
let distTagCalls = 0;
|
|
269
|
+
const plan = await planModuleUpdates({
|
|
270
|
+
root,
|
|
271
|
+
hostSdk: "0.2.5",
|
|
272
|
+
fetchIndex: async () => ({
|
|
273
|
+
index: { chat: { npm: "@sfmc-bds/module-chat", version: "1.2.0", sdk: ">=0.2.2" } },
|
|
274
|
+
offline: false,
|
|
275
|
+
}),
|
|
276
|
+
fetchDistTag: async () => {
|
|
277
|
+
distTagCalls += 1;
|
|
278
|
+
return "9.9.9";
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
assert.equal(plan.upgrades.length, 0);
|
|
282
|
+
assert.equal(distTagCalls, 0);
|
|
283
|
+
const skipped = plan.skipped.find((item) => item.id === "chat");
|
|
284
|
+
assert.equal(skipped?.reason, "local-source");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("升级成功后删除这次备份", async () => {
|
|
288
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-backup-ok-"));
|
|
289
|
+
const pkg = path.join(root, "modules", "packages", "demo");
|
|
290
|
+
fs.mkdirSync(pkg, { recursive: true });
|
|
291
|
+
fs.writeFileSync(path.join(pkg, "marker.txt"), "old");
|
|
292
|
+
const result = await applyModuleUpgrades(
|
|
293
|
+
[{ id: "demo", fromVersion: "1.0.0", toVersion: "1.1.0", spec: "npm:x@1.1.0", requires: [] }],
|
|
294
|
+
{
|
|
295
|
+
root,
|
|
296
|
+
failMode: "continue",
|
|
297
|
+
install: async (id) => {
|
|
298
|
+
const dir = path.join(root, "modules", "packages", id);
|
|
299
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
300
|
+
fs.writeFileSync(path.join(dir, "marker.txt"), "new");
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
assert.equal(result.failed.length, 0);
|
|
305
|
+
assert.equal(result.applied.length, 1);
|
|
306
|
+
assert.equal(fs.readFileSync(path.join(pkg, "marker.txt"), "utf8"), "new");
|
|
307
|
+
const trash = path.join(root, "modules", "_trash");
|
|
308
|
+
const leftovers = fs.existsSync(trash) ? fs.readdirSync(trash) : [];
|
|
309
|
+
assert.deepEqual(leftovers, []);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("还原失败时错误信息带上备份路径", async () => {
|
|
313
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-restore-fail-"));
|
|
314
|
+
const pkg = path.join(root, "modules", "packages", "demo");
|
|
315
|
+
fs.mkdirSync(pkg, { recursive: true });
|
|
316
|
+
fs.writeFileSync(path.join(pkg, "marker.txt"), "old");
|
|
317
|
+
const result = await applyModuleUpgrades(
|
|
318
|
+
[{ id: "demo", fromVersion: "1.0.0", toVersion: "1.1.0", spec: "npm:x@1.1.0", requires: [] }],
|
|
319
|
+
{
|
|
320
|
+
root,
|
|
321
|
+
failMode: "continue",
|
|
322
|
+
install: async () => {
|
|
323
|
+
fs.rmSync(path.join(root, "modules", "_trash"), { recursive: true, force: true });
|
|
324
|
+
throw new Error("boom");
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
);
|
|
328
|
+
assert.equal(result.failed.length, 1);
|
|
329
|
+
assert.match(result.failed[0].message, /boom/);
|
|
330
|
+
assert.ok(result.failed[0].message.includes(path.join(root, "modules", "_trash")));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("GitHub pin 不能打开自动更新,关闭仍然允许", () => {
|
|
334
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-pin-gh-"));
|
|
335
|
+
fs.mkdirSync(path.join(root, "modules", "packages", "demo"), { recursive: true });
|
|
336
|
+
writePins(root, {
|
|
337
|
+
version: 1,
|
|
338
|
+
modules: {
|
|
339
|
+
demo: {
|
|
340
|
+
source: "github",
|
|
341
|
+
channel: "none",
|
|
342
|
+
spec: "github:owner/repo",
|
|
343
|
+
installedVersion: "1.0.0",
|
|
344
|
+
manifestId: "demo",
|
|
345
|
+
auto: false,
|
|
346
|
+
autoLocked: false,
|
|
347
|
+
autoSetBy: "default",
|
|
348
|
+
lastCheckedAt: null,
|
|
349
|
+
lastAppliedAt: null,
|
|
350
|
+
lastError: null,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
const locked = setModulePinAuto(root, "demo", true);
|
|
355
|
+
assert.equal(locked.ok, false);
|
|
356
|
+
assert.equal(locked.code, "locked");
|
|
357
|
+
assert.equal(locked.id, "demo");
|
|
358
|
+
const off = setModulePinAuto(root, "demo", false);
|
|
359
|
+
assert.equal(off.ok, true);
|
|
360
|
+
if (off.ok) assert.equal(off.auto, false);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 允许自动更新的 npm pin。
|
|
365
|
+
* 使用场景:目录实际是开发链接时,pin 仍写成可升级,确认计划以目录类型为准。
|
|
366
|
+
* @param {string} pkgName
|
|
367
|
+
* @param {string} version
|
|
368
|
+
* @param {string} manifestId
|
|
369
|
+
* @param {"index" | "dist-tag"} channel
|
|
370
|
+
*/
|
|
371
|
+
function openNpmPin(pkgName, version, manifestId, channel) {
|
|
372
|
+
return {
|
|
373
|
+
source: "npm",
|
|
374
|
+
channel,
|
|
375
|
+
spec: channel === "index" ? `npm:${pkgName}@${version}` : `npm:${pkgName}`,
|
|
376
|
+
installedVersion: version,
|
|
377
|
+
manifestId,
|
|
378
|
+
auto: true,
|
|
379
|
+
autoLocked: false,
|
|
380
|
+
autoSetBy: "user",
|
|
381
|
+
lastCheckedAt: null,
|
|
382
|
+
lastAppliedAt: null,
|
|
383
|
+
lastError: null,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 把独立源目录链到 packages/<id>。
|
|
389
|
+
* 使用场景:在临时目录里造 junction / symlink,避免动仓库外的服务器。
|
|
390
|
+
* @param {string} root
|
|
391
|
+
* @param {string} id
|
|
392
|
+
* @param {"junction" | "dir"} type
|
|
393
|
+
*/
|
|
394
|
+
function linkModulePackage(root, id, type) {
|
|
395
|
+
const source = fs.mkdtempSync(path.join(os.tmpdir(), `sfmc-mod-src-${id}-`));
|
|
396
|
+
fs.mkdirSync(path.join(source, "sapi"), { recursive: true });
|
|
397
|
+
fs.writeFileSync(
|
|
398
|
+
path.join(source, "package.json"),
|
|
399
|
+
JSON.stringify({ name: `@sfmc-bds/module-${id}`, version: "1.0.0" })
|
|
400
|
+
);
|
|
401
|
+
fs.writeFileSync(
|
|
402
|
+
path.join(source, "sapi", "manifest.json"),
|
|
403
|
+
JSON.stringify({ id, configKey: id, requires: [] })
|
|
404
|
+
);
|
|
405
|
+
fs.writeFileSync(path.join(source, "marker.txt"), "source");
|
|
406
|
+
const dest = path.join(root, "modules", "packages", id);
|
|
407
|
+
fs.symlinkSync(source, dest, type);
|
|
408
|
+
return dest;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
test("开发链接跳过且 apply 不调用 install,普通目录仍可升级", async () => {
|
|
412
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-devlink-"));
|
|
413
|
+
writeInstalledChat(root);
|
|
414
|
+
/** @type {{ id: string, channel: "index" | "dist-tag" }[]} */
|
|
415
|
+
const links = [{ id: "via-symlink", channel: "dist-tag" }];
|
|
416
|
+
if (process.platform === "win32") links.unshift({ id: "via-junction", channel: "index" });
|
|
417
|
+
for (const link of links) {
|
|
418
|
+
const type = link.id === "via-junction" ? "junction" : "dir";
|
|
419
|
+
linkModulePackage(root, link.id, type);
|
|
420
|
+
}
|
|
421
|
+
writePins(root, {
|
|
422
|
+
version: 1,
|
|
423
|
+
modules: {
|
|
424
|
+
chat: openNpmPin("@sfmc-bds/module-chat", "1.0.0", "chat", "index"),
|
|
425
|
+
...Object.fromEntries(
|
|
426
|
+
links.map((link) => [
|
|
427
|
+
link.id,
|
|
428
|
+
openNpmPin(`@sfmc-bds/module-${link.id}`, "1.0.0", link.id, link.channel),
|
|
429
|
+
])
|
|
430
|
+
),
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
let distTagCalls = 0;
|
|
434
|
+
const plan = await planModuleUpdates({
|
|
435
|
+
root,
|
|
436
|
+
hostSdk: "0.2.5",
|
|
437
|
+
fetchIndex: async () => ({
|
|
438
|
+
index: {
|
|
439
|
+
chat: { npm: "@sfmc-bds/module-chat", version: "1.1.0", sdk: ">=0.2.2" },
|
|
440
|
+
"via-junction": { npm: "@sfmc-bds/module-via-junction", version: "1.1.0", sdk: ">=0.2.2" },
|
|
441
|
+
},
|
|
442
|
+
offline: false,
|
|
443
|
+
}),
|
|
444
|
+
fetchDistTag: async () => {
|
|
445
|
+
distTagCalls += 1;
|
|
446
|
+
return "9.9.9";
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
assert.equal(plan.upgrades.some((item) => item.id === "chat"), true);
|
|
450
|
+
assert.equal(plan.upgrades.some((item) => item.spec.startsWith("npm:@sfmc-bds/module-chat@")), true);
|
|
451
|
+
assert.equal(distTagCalls, 0);
|
|
452
|
+
for (const link of links) {
|
|
453
|
+
assert.equal(plan.upgrades.some((item) => item.id === link.id), false);
|
|
454
|
+
const skipped = plan.skipped.find((item) => item.id === link.id);
|
|
455
|
+
assert.equal(skipped?.reason, "local-source");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let installCalls = 0;
|
|
459
|
+
const applied = await applyModuleUpgrades(
|
|
460
|
+
links.map((link) => ({
|
|
461
|
+
id: link.id,
|
|
462
|
+
fromVersion: "1.0.0",
|
|
463
|
+
toVersion: "1.1.0",
|
|
464
|
+
spec: `npm:@sfmc-bds/module-${link.id}@1.1.0`,
|
|
465
|
+
requires: [],
|
|
466
|
+
})),
|
|
467
|
+
{
|
|
468
|
+
root,
|
|
469
|
+
failMode: "abort",
|
|
470
|
+
install: async () => {
|
|
471
|
+
installCalls += 1;
|
|
472
|
+
throw new Error("must not install over a dev link");
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
);
|
|
476
|
+
assert.equal(installCalls, 0);
|
|
477
|
+
assert.equal(applied.applied.length, 0);
|
|
478
|
+
assert.equal(applied.failed.length, 0);
|
|
479
|
+
const trash = path.join(root, "modules", "_trash");
|
|
480
|
+
assert.equal(fs.existsSync(trash) ? fs.readdirSync(trash).length : 0, 0);
|
|
481
|
+
for (const link of links) {
|
|
482
|
+
assert.equal(applied.skipped.find((item) => item.id === link.id)?.reason, "local-source");
|
|
483
|
+
const dest = path.join(root, "modules", "packages", link.id);
|
|
484
|
+
assert.equal(fs.lstatSync(dest).isSymbolicLink(), true);
|
|
485
|
+
assert.equal(fs.readFileSync(path.join(dest, "marker.txt"), "utf8"), "source");
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("新播种的更新配置在开服时不自动应用,已有文件保持原值", async () => {
|
|
490
|
+
assert.equal(DEFAULT_MODULE_UPDATE_CONFIG.applyOnBdsStart, false);
|
|
491
|
+
const fresh = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-seed-"));
|
|
492
|
+
writeInstalledChat(fresh);
|
|
493
|
+
const seeded = await planModuleUpdates({
|
|
494
|
+
root: fresh,
|
|
495
|
+
startup: true,
|
|
496
|
+
hostSdk: "0.2.5",
|
|
497
|
+
fetchIndex: async () => ({ index: null, offline: true }),
|
|
498
|
+
fetchDistTag: async () => {
|
|
499
|
+
throw new Error("seeded plan should not query dist-tag");
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
assert.equal(seeded.applyOnStart, false);
|
|
503
|
+
const seededFile = path.join(fresh, "configs", "module-update.json");
|
|
504
|
+
const seededBody = JSON.parse(fs.readFileSync(seededFile, "utf8"));
|
|
505
|
+
assert.equal(seededBody.applyOnBdsStart, false);
|
|
506
|
+
|
|
507
|
+
const kept = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-mod-keep-"));
|
|
508
|
+
const keptFile = path.join(kept, "configs", "module-update.json");
|
|
509
|
+
fs.mkdirSync(path.dirname(keptFile), { recursive: true });
|
|
510
|
+
const existing = {
|
|
511
|
+
enabled: true,
|
|
512
|
+
checkOnBdsStart: true,
|
|
513
|
+
applyOnBdsStart: true,
|
|
514
|
+
allowMajor: false,
|
|
515
|
+
failMode: "continue",
|
|
516
|
+
distTag: "latest",
|
|
517
|
+
};
|
|
518
|
+
fs.writeFileSync(keptFile, `${JSON.stringify(existing, null, 2)}\n`);
|
|
519
|
+
const before = fs.readFileSync(keptFile, "utf8");
|
|
520
|
+
const keptPlan = await planModuleUpdates({
|
|
521
|
+
root: kept,
|
|
522
|
+
startup: true,
|
|
523
|
+
hostSdk: "0.2.5",
|
|
524
|
+
fetchIndex: async () => ({ index: null, offline: true }),
|
|
525
|
+
fetchDistTag: async () => {
|
|
526
|
+
throw new Error("existing config plan should not query dist-tag");
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
assert.equal(keptPlan.applyOnStart, true);
|
|
530
|
+
assert.equal(fs.readFileSync(keptFile, "utf8"), before);
|
|
531
|
+
});
|