@sfmc-bds/cli 0.2.0-beta.13 → 0.2.0-beta.15
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/command-surface.js +1 -1
- package/dist/command-surface.js.map +2 -2
- package/dist/i18n/locales/en.d.ts +10 -0
- package/dist/i18n/locales/en.d.ts.map +1 -1
- package/dist/i18n/locales/en.js +10 -0
- 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 +10 -0
- package/dist/i18n/locales/zh-CN.js.map +2 -2
- package/dist/module-pack-build.d.ts +14 -4
- package/dist/module-pack-build.d.ts.map +1 -1
- package/dist/module-pack-build.js +73 -105
- package/dist/module-pack-build.js.map +2 -2
- package/dist/pack-bundle.test.js +105 -0
- package/dist/pack-bundle.test.js.map +7 -0
- package/dist/pack-lifecycle.d.ts +14 -1
- package/dist/pack-lifecycle.d.ts.map +1 -1
- package/dist/pack-lifecycle.js +222 -11
- package/dist/pack-lifecycle.js.map +2 -2
- package/dist/repl-windows/host.d.ts +6 -3
- package/dist/repl-windows/host.d.ts.map +1 -1
- package/dist/repl-windows/host.js +12 -6
- package/dist/repl-windows/host.js.map +2 -2
- package/dist/repl-windows/host.test.js +72 -0
- package/dist/repl-windows/host.test.js.map +7 -0
- package/dist/repl-windows/index.d.ts +1 -0
- package/dist/repl-windows/index.d.ts.map +1 -1
- package/dist/repl-windows/index.js +3 -0
- package/dist/repl-windows/index.js.map +2 -2
- package/dist/repl-windows/sfmc-window.d.ts +7 -0
- package/dist/repl-windows/sfmc-window.d.ts.map +1 -0
- package/dist/repl-windows/sfmc-window.js +28 -0
- package/dist/repl-windows/sfmc-window.js.map +7 -0
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +21 -7
- package/dist/repl.js.map +2 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +10 -0
- package/dist/runtime.js.map +2 -2
- package/dist/send-target.d.ts +2 -0
- package/dist/send-target.d.ts.map +1 -1
- package/dist/send-target.js +4 -0
- package/dist/send-target.js.map +2 -2
- package/dist/world-packs.d.ts.map +1 -1
- package/dist/world-packs.js +84 -13
- package/dist/world-packs.js.map +3 -3
- package/package.json +4 -4
- package/scripts/module-install/check-modules.mjs +42 -2
- package/scripts/module-install/fetch-module.mjs +70 -46
- package/scripts/module-install/lib/config-seeding.mjs +128 -0
- package/scripts/module-install/test/check-modules-config.test.ts +46 -0
- package/scripts/module-install/test/config-seeding.test.ts +99 -0
|
@@ -25,22 +25,24 @@
|
|
|
25
25
|
* (win32=junction,POSIX=symlink),仍同步 catalog/lock。
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { extractZipFileToDir } from "@sfmc-bds/bds-tools/zipx";
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
28
30
|
import fs, { createReadStream } from "node:fs";
|
|
29
31
|
import fsp from "node:fs/promises";
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
31
|
-
import { pipeline } from "node:stream/promises";
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import process from "node:process";
|
|
34
|
+
import { pipeline } from "node:stream/promises";
|
|
34
35
|
import { fileURLToPath } from "node:url";
|
|
35
|
-
import { extractZipFileToDir } from "@sfmc-bds/bds-tools/zipx";
|
|
36
36
|
import { runCheckModules } from "./check-modules.mjs";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import { PACKAGES_DIR, ROOT } from "./lib/paths.mjs";
|
|
37
|
+
import { removeCatalogEntry, upsertCatalogEntry } from "./lib/catalog.mjs";
|
|
38
|
+
import { seedModuleConfig } from "./lib/config-seeding.mjs";
|
|
40
39
|
import { exists } from "./lib/io.mjs";
|
|
41
|
-
import { parseRegistryIndex } from "./lib/registry-index.mjs";
|
|
42
40
|
import { isSchemeFrom, normalizeBarePathFrom, normalizeLinkFrom } from "./lib/link-from.mjs";
|
|
41
|
+
import { removeModuleLock, setModuleLockEnabled } from "./lib/lock.mjs";
|
|
43
42
|
import { folderFromNpmPackageName } from "./lib/npm-resolver.mjs";
|
|
43
|
+
import { loadPackageCatalogEntry } from "./lib/packages.mjs";
|
|
44
|
+
import { PACKAGES_DIR, ROOT } from "./lib/paths.mjs";
|
|
45
|
+
import { parseRegistryIndex } from "./lib/registry-index.mjs";
|
|
44
46
|
|
|
45
47
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
46
48
|
const TARGET = PACKAGES_DIR;
|
|
@@ -105,9 +107,7 @@ async function resolveRegistryIndex() {
|
|
|
105
107
|
);
|
|
106
108
|
return { index: cache.index, stale: true };
|
|
107
109
|
}
|
|
108
|
-
throw new Error(
|
|
109
|
-
`registry unreachable and no cache: ${message}. Pass --from explicitly to skip the registry.`
|
|
110
|
-
);
|
|
110
|
+
throw new Error(`registry unreachable and no cache: ${message}. Pass --from explicitly to skip the registry.`);
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -126,9 +126,7 @@ async function defaultSourceFor(id) {
|
|
|
126
126
|
return `npm:${entry.npm}`;
|
|
127
127
|
}
|
|
128
128
|
if (entry?.repo && entry?.tag) {
|
|
129
|
-
console.log(
|
|
130
|
-
`[fetch-module] ${id} found in registry (deprecated github) → github:${entry.repo}@${entry.tag}`
|
|
131
|
-
);
|
|
129
|
+
console.log(`[fetch-module] ${id} found in registry (deprecated github) → github:${entry.repo}@${entry.tag}`);
|
|
132
130
|
return `github:${entry.repo}@${entry.tag}`;
|
|
133
131
|
}
|
|
134
132
|
} catch {
|
|
@@ -284,11 +282,28 @@ function afterInstall(folder, opts = {}) {
|
|
|
284
282
|
try {
|
|
285
283
|
normalizeInstalledPackage(path.join(TARGET, folder));
|
|
286
284
|
} catch (err) {
|
|
287
|
-
console.warn(
|
|
288
|
-
`[fetch-module] normalize warn: ${err instanceof Error ? err.message : String(err)}`
|
|
289
|
-
);
|
|
285
|
+
console.warn(`[fetch-module] normalize warn: ${err instanceof Error ? err.message : String(err)}`);
|
|
290
286
|
}
|
|
291
287
|
}
|
|
288
|
+
const moduleRoot = path.join(TARGET, folder);
|
|
289
|
+
const preview = loadPackageCatalogEntry(folder);
|
|
290
|
+
if (!preview) throw new Error(`packages/${folder}: 无法读取 sapi/manifest.json`);
|
|
291
|
+
const seeded = seedModuleConfig({
|
|
292
|
+
moduleRoot,
|
|
293
|
+
projectRoot: ROOT,
|
|
294
|
+
configKey: preview.configKey,
|
|
295
|
+
});
|
|
296
|
+
const targetRel = path.relative(ROOT, seeded.target).replaceAll("\\", "/");
|
|
297
|
+
if (seeded.status === "created") {
|
|
298
|
+
console.log(`[fetch-module] config created: ${targetRel}`);
|
|
299
|
+
} else if (seeded.status === "updated") {
|
|
300
|
+
console.log(`[fetch-module] config updated: added ${seeded.addedPaths.join(", ")}`);
|
|
301
|
+
} else if (seeded.status === "unchanged") {
|
|
302
|
+
console.log(`[fetch-module] config unchanged: ${targetRel}`);
|
|
303
|
+
} else {
|
|
304
|
+
console.warn(`[fetch-module] config defaults missing: ${preview.configKey}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
292
307
|
const entry = upsertCatalogEntry(folder);
|
|
293
308
|
setModuleLockEnabled(entry.id, entry.enabledByDefault !== false);
|
|
294
309
|
console.log(`[fetch-module] catalog+lock: ${entry.id} (enabled=${entry.enabledByDefault !== false})`);
|
|
@@ -303,6 +318,9 @@ function afterInstall(folder, opts = {}) {
|
|
|
303
318
|
if (!check.ok) {
|
|
304
319
|
throw new Error(`check-modules 未通过: ${check.error}`);
|
|
305
320
|
}
|
|
321
|
+
for (const warning of check.warnings ?? []) {
|
|
322
|
+
console.warn(`[fetch-module] check warning: ${warning}`);
|
|
323
|
+
}
|
|
306
324
|
console.log(`[fetch-module] ${check.summary}`);
|
|
307
325
|
return entry;
|
|
308
326
|
}
|
|
@@ -422,7 +440,7 @@ async function fromLocal(id, source, flags) {
|
|
|
422
440
|
|
|
423
441
|
/**
|
|
424
442
|
* `sfmc mod install <id>` 走 npm registry 的主路径。
|
|
425
|
-
*
|
|
443
|
+
*
|
|
426
444
|
* 入口:`npm install --prefix packages/<id> --omit=dev --no-save --no-package-lock <pkgName>`
|
|
427
445
|
* - 落地到 packages/<id>,不进主仓根 node_modules(隔离安装)
|
|
428
446
|
* - 成功后 upsert catalog/lock
|
|
@@ -435,32 +453,34 @@ async function fromNpm(id, pkgName, flags) {
|
|
|
435
453
|
const dir = await ensureTarget(id);
|
|
436
454
|
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
437
455
|
const { spawn: spawnChild } = await import("node:child_process");
|
|
438
|
-
await /** @type {Promise<void>} */
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
stderr
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
456
|
+
await /** @type {Promise<void>} */ (
|
|
457
|
+
new Promise((resolve, reject) => {
|
|
458
|
+
const proc = spawnChild(
|
|
459
|
+
npmCmd,
|
|
460
|
+
[
|
|
461
|
+
"install",
|
|
462
|
+
"--prefix",
|
|
463
|
+
dir,
|
|
464
|
+
"--omit=dev",
|
|
465
|
+
"--no-save",
|
|
466
|
+
"--no-package-lock",
|
|
467
|
+
"--no-audit",
|
|
468
|
+
"--no-fund",
|
|
469
|
+
pkgName,
|
|
470
|
+
],
|
|
471
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
472
|
+
);
|
|
473
|
+
let stderr = "";
|
|
474
|
+
proc.stderr?.on("data", (d) => {
|
|
475
|
+
stderr += d.toString();
|
|
476
|
+
});
|
|
477
|
+
proc.on("exit", (code) => {
|
|
478
|
+
if (code === 0) return resolve();
|
|
479
|
+
reject(new Error(translateNpmInstallError(pkgName, stderr)));
|
|
480
|
+
});
|
|
481
|
+
proc.on("error", (e) => reject(new Error(`spawn npm failed: ${e.message}`)));
|
|
482
|
+
})
|
|
483
|
+
);
|
|
464
484
|
console.log(`[fetch-module] installed ${id} from npm (${pkgName})`);
|
|
465
485
|
console.log(`[fetch-module] target: ${dir}`);
|
|
466
486
|
afterInstall(id);
|
|
@@ -775,7 +795,9 @@ async function extractTgz(tgzPath, dstDir) {
|
|
|
775
795
|
stderr += d.toString();
|
|
776
796
|
});
|
|
777
797
|
// @ts-ignore
|
|
778
|
-
proc.on("exit", (code) =>
|
|
798
|
+
proc.on("exit", (code) =>
|
|
799
|
+
code === 0 ? resolve() : reject(new Error(`npm install tarball exit ${code}: ${stderr}`))
|
|
800
|
+
);
|
|
779
801
|
// @ts-ignore
|
|
780
802
|
proc.on("error", reject);
|
|
781
803
|
});
|
|
@@ -963,7 +985,9 @@ async function main() {
|
|
|
963
985
|
const from = flags.from;
|
|
964
986
|
// @ts-ignore
|
|
965
987
|
if (!from || (!from.startsWith("local") && !from.startsWith("dir:"))) {
|
|
966
|
-
die(
|
|
988
|
+
die(
|
|
989
|
+
"usage: install <id> [id2 ...] [--from <source>] [--link]\n或: install --from local|dir:<path> [--link](从包目录推导 id)"
|
|
990
|
+
);
|
|
967
991
|
}
|
|
968
992
|
let absDir;
|
|
969
993
|
// @ts-ignore
|
|
@@ -990,7 +1014,7 @@ async function main() {
|
|
|
990
1014
|
// @ts-ignore
|
|
991
1015
|
if (!flags.from.startsWith("dir:") && (flags.from === "local" || flags.from.startsWith("local:"))) {
|
|
992
1016
|
/* 保持 local;link 时会规范成 dir: */
|
|
993
|
-
|
|
1017
|
+
// @ts-ignore
|
|
994
1018
|
} else if (flags.from.startsWith("dir:")) {
|
|
995
1019
|
/* ok */
|
|
996
1020
|
} else {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 模块默认配置播种:仅补缺,不覆盖用户已有值。 */
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const CONFIG_KEY_RE = /^[A-Za-z0-9_-]+$/;
|
|
8
|
+
|
|
9
|
+
/** @param {unknown} value */
|
|
10
|
+
function isPlainObject(value) {
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
12
|
+
const proto = Object.getPrototypeOf(value);
|
|
13
|
+
return proto === Object.prototype || proto === null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {string} root @param {string} candidate */
|
|
17
|
+
function assertWithin(root, candidate) {
|
|
18
|
+
const absRoot = path.resolve(root);
|
|
19
|
+
const absCandidate = path.resolve(candidate);
|
|
20
|
+
const rel = path.relative(absRoot, absCandidate);
|
|
21
|
+
if (rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel))) {
|
|
22
|
+
return absCandidate;
|
|
23
|
+
}
|
|
24
|
+
throw new Error("配置路径越界: " + absCandidate);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {string} file @param {string} label */
|
|
28
|
+
function readObject(file, label) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
throw new Error(label + " JSON 解析失败: " + message);
|
|
35
|
+
}
|
|
36
|
+
if (!isPlainObject(parsed)) throw new Error(label + " 顶层必须是 JSON 对象");
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {Record<string, unknown>} current
|
|
42
|
+
* @param {Record<string, unknown>} defaults
|
|
43
|
+
* @param {string} [prefix]
|
|
44
|
+
* @returns {{ value: Record<string, unknown>; addedPaths: string[] }}
|
|
45
|
+
*/
|
|
46
|
+
export function mergeMissing(current, defaults, prefix = "") {
|
|
47
|
+
const value = structuredClone(current);
|
|
48
|
+
/** @type {string[]} */
|
|
49
|
+
const addedPaths = [];
|
|
50
|
+
for (const key of Object.keys(defaults).sort()) {
|
|
51
|
+
const fieldPath = prefix ? prefix + "." + key : key;
|
|
52
|
+
if (!Object.hasOwn(value, key)) {
|
|
53
|
+
value[key] = structuredClone(defaults[key]);
|
|
54
|
+
addedPaths.push(fieldPath);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (isPlainObject(value[key]) && isPlainObject(defaults[key])) {
|
|
58
|
+
const nested = mergeMissing(
|
|
59
|
+
/** @type {Record<string, unknown>} */ (value[key]),
|
|
60
|
+
/** @type {Record<string, unknown>} */ (defaults[key]),
|
|
61
|
+
fieldPath
|
|
62
|
+
);
|
|
63
|
+
value[key] = nested.value;
|
|
64
|
+
addedPaths.push(...nested.addedPaths);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { value, addedPaths: addedPaths.sort() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {{
|
|
72
|
+
* status: "created" | "updated" | "unchanged" | "missing-defaults";
|
|
73
|
+
* target: string;
|
|
74
|
+
* addedPaths: string[];
|
|
75
|
+
* }} SeedResult
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {{ moduleRoot: string; projectRoot: string; configKey: string }} options
|
|
80
|
+
* @returns {SeedResult}
|
|
81
|
+
*/
|
|
82
|
+
export function seedModuleConfig(options) {
|
|
83
|
+
const { moduleRoot, projectRoot, configKey } = options;
|
|
84
|
+
if (!CONFIG_KEY_RE.test(configKey)) throw new Error('invalid configKey "' + configKey + '"');
|
|
85
|
+
|
|
86
|
+
const safeModuleRoot = path.resolve(moduleRoot);
|
|
87
|
+
const safeProjectRoot = path.resolve(projectRoot);
|
|
88
|
+
const source = assertWithin(safeModuleRoot, path.join(safeModuleRoot, "configs-default", configKey + ".json"));
|
|
89
|
+
const configsDir = assertWithin(safeProjectRoot, path.join(safeProjectRoot, "configs"));
|
|
90
|
+
const target = assertWithin(configsDir, path.join(configsDir, configKey + ".json"));
|
|
91
|
+
if (!fs.existsSync(source)) {
|
|
92
|
+
const defaultsDir = path.dirname(source);
|
|
93
|
+
const jsonFiles = fs.existsSync(defaultsDir)
|
|
94
|
+
? fs.readdirSync(defaultsDir).filter((name) => name.toLowerCase().endsWith(".json"))
|
|
95
|
+
: [];
|
|
96
|
+
if (jsonFiles.length > 0) {
|
|
97
|
+
throw new Error("默认配置文件名必须为 configs-default/" + configKey + ".json,实际为 " + jsonFiles.join(", "));
|
|
98
|
+
}
|
|
99
|
+
return { status: "missing-defaults", target, addedPaths: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const defaults = readObject(source, "默认配置 " + source);
|
|
103
|
+
let status = /** @type {SeedResult["status"]} */ ("created");
|
|
104
|
+
let value = defaults;
|
|
105
|
+
let addedPaths = Object.keys(defaults).sort();
|
|
106
|
+
if (fs.existsSync(target)) {
|
|
107
|
+
const current = readObject(target, "用户配置 " + target);
|
|
108
|
+
const merged = mergeMissing(current, defaults);
|
|
109
|
+
value = merged.value;
|
|
110
|
+
addedPaths = merged.addedPaths;
|
|
111
|
+
status = addedPaths.length > 0 ? "updated" : "unchanged";
|
|
112
|
+
}
|
|
113
|
+
if (status === "unchanged") return { status, target, addedPaths };
|
|
114
|
+
|
|
115
|
+
fs.mkdirSync(configsDir, { recursive: true });
|
|
116
|
+
const temp = assertWithin(configsDir, path.join(configsDir, "." + configKey + "." + randomUUID() + ".tmp"));
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
|
|
119
|
+
fs.renameSync(temp, target);
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
122
|
+
fs.rmSync(temp, { force: true });
|
|
123
|
+
} catch {
|
|
124
|
+
/* best-effort */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { status, target, addedPaths };
|
|
128
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { validateDefaultConfig } from "../check-modules.mjs";
|
|
7
|
+
|
|
8
|
+
function moduleFixture(files: Record<string, string> = {}) {
|
|
9
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-check-config-"));
|
|
10
|
+
const dir = path.join(root, "configs-default");
|
|
11
|
+
if (Object.keys(files).length > 0) fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), body);
|
|
13
|
+
return root;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("validateDefaultConfig 接受标准对象配置", () => {
|
|
17
|
+
const root = moduleFixture({ "demo.json": '{"enabled":true}' });
|
|
18
|
+
assert.deepEqual(validateDefaultConfig(root, "demo"), { ok: true });
|
|
19
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("validateDefaultConfig 对缺失默认配置给出警告", () => {
|
|
23
|
+
const root = moduleFixture();
|
|
24
|
+
const result = validateDefaultConfig(root, "demo");
|
|
25
|
+
assert.equal(result.ok, true);
|
|
26
|
+
if (result.ok) assert.match(result.warning ?? "", /未提供默认配置/);
|
|
27
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("validateDefaultConfig 拒绝错名、损坏及非对象配置", () => {
|
|
31
|
+
const wrong = moduleFixture({ "other.json": "{}" });
|
|
32
|
+
assert.deepEqual(validateDefaultConfig(wrong, "demo").ok, false);
|
|
33
|
+
fs.rmSync(wrong, { recursive: true, force: true });
|
|
34
|
+
|
|
35
|
+
const broken = moduleFixture({ "demo.json": "{" });
|
|
36
|
+
const brokenResult = validateDefaultConfig(broken, "demo");
|
|
37
|
+
assert.equal(brokenResult.ok, false);
|
|
38
|
+
if (!brokenResult.ok) assert.match(brokenResult.error, /解析失败/);
|
|
39
|
+
fs.rmSync(broken, { recursive: true, force: true });
|
|
40
|
+
|
|
41
|
+
const array = moduleFixture({ "demo.json": "[]" });
|
|
42
|
+
const arrayResult = validateDefaultConfig(array, "demo");
|
|
43
|
+
assert.equal(arrayResult.ok, false);
|
|
44
|
+
if (!arrayResult.ok) assert.match(arrayResult.error, /顶层必须是 JSON 对象/);
|
|
45
|
+
fs.rmSync(array, { recursive: true, force: true });
|
|
46
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { mergeMissing, seedModuleConfig } from "../lib/config-seeding.mjs";
|
|
7
|
+
|
|
8
|
+
function fixture(defaults?: unknown, current?: unknown) {
|
|
9
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-config-seeding-"));
|
|
10
|
+
const moduleRoot = path.join(root, "module");
|
|
11
|
+
const projectRoot = path.join(root, "project");
|
|
12
|
+
fs.mkdirSync(path.join(moduleRoot, "configs-default"), { recursive: true });
|
|
13
|
+
if (defaults !== undefined) {
|
|
14
|
+
const body = typeof defaults === "string" ? defaults : JSON.stringify(defaults);
|
|
15
|
+
fs.writeFileSync(path.join(moduleRoot, "configs-default", "demo.json"), body);
|
|
16
|
+
}
|
|
17
|
+
if (current !== undefined) {
|
|
18
|
+
fs.mkdirSync(path.join(projectRoot, "configs"), { recursive: true });
|
|
19
|
+
const body = typeof current === "string" ? current : JSON.stringify(current);
|
|
20
|
+
fs.writeFileSync(path.join(projectRoot, "configs", "demo.json"), body);
|
|
21
|
+
}
|
|
22
|
+
return { root, moduleRoot, projectRoot, target: path.join(projectRoot, "configs", "demo.json") };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("mergeMissing 递归补缺且不覆盖或修改输入", () => {
|
|
26
|
+
const current = { enabled: false, nested: { keep: 1 }, list: ["user"], extra: true };
|
|
27
|
+
const defaults = { enabled: true, nested: { keep: 0, added: 2 }, list: ["default"], fresh: "x" };
|
|
28
|
+
const before = structuredClone(current);
|
|
29
|
+
const result = mergeMissing(current, defaults);
|
|
30
|
+
assert.deepEqual(current, before);
|
|
31
|
+
assert.deepEqual(result.value, {
|
|
32
|
+
enabled: false,
|
|
33
|
+
nested: { keep: 1, added: 2 },
|
|
34
|
+
list: ["user"],
|
|
35
|
+
extra: true,
|
|
36
|
+
fresh: "x",
|
|
37
|
+
});
|
|
38
|
+
assert.deepEqual(result.addedPaths, ["fresh", "nested.added"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("seedModuleConfig 创建、补缺与无变化", () => {
|
|
42
|
+
const f = fixture({ enabled: true, nested: { first: 1 } });
|
|
43
|
+
const created = seedModuleConfig({ ...f, configKey: "demo" });
|
|
44
|
+
assert.equal(created.status, "created");
|
|
45
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(f.target, "utf8")), {
|
|
46
|
+
enabled: true,
|
|
47
|
+
nested: { first: 1 },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
fs.writeFileSync(f.target, JSON.stringify({ enabled: false, nested: { first: 9 } }));
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(f.moduleRoot, "configs-default", "demo.json"),
|
|
53
|
+
JSON.stringify({ enabled: true, nested: { first: 1, second: 2 }, list: ["default"] })
|
|
54
|
+
);
|
|
55
|
+
const updated = seedModuleConfig({ ...f, configKey: "demo" });
|
|
56
|
+
assert.equal(updated.status, "updated");
|
|
57
|
+
assert.deepEqual(updated.addedPaths, ["list", "nested.second"]);
|
|
58
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(f.target, "utf8")), {
|
|
59
|
+
enabled: false,
|
|
60
|
+
nested: { first: 9, second: 2 },
|
|
61
|
+
list: ["default"],
|
|
62
|
+
});
|
|
63
|
+
assert.equal(seedModuleConfig({ ...f, configKey: "demo" }).status, "unchanged");
|
|
64
|
+
fs.rmSync(f.root, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("seedModuleConfig 缺失默认文件时保持兼容", () => {
|
|
68
|
+
const f = fixture();
|
|
69
|
+
assert.equal(seedModuleConfig({ ...f, configKey: "demo" }).status, "missing-defaults");
|
|
70
|
+
assert.equal(fs.existsSync(f.target), false);
|
|
71
|
+
fs.rmSync(f.root, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("seedModuleConfig 拒绝与 configKey 不一致的默认文件名", () => {
|
|
75
|
+
const f = fixture();
|
|
76
|
+
fs.writeFileSync(path.join(f.moduleRoot, "configs-default", "other.json"), "{}");
|
|
77
|
+
assert.throws(() => seedModuleConfig({ ...f, configKey: "demo" }), /文件名必须为/);
|
|
78
|
+
fs.rmSync(f.root, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("seedModuleConfig 拒绝损坏 JSON、非对象与非法 configKey", () => {
|
|
82
|
+
const brokenDefault = fixture("{");
|
|
83
|
+
assert.throws(() => seedModuleConfig({ ...brokenDefault, configKey: "demo" }), /默认配置.*解析失败/);
|
|
84
|
+
fs.rmSync(brokenDefault.root, { recursive: true, force: true });
|
|
85
|
+
|
|
86
|
+
const scalar = fixture([]);
|
|
87
|
+
assert.throws(() => seedModuleConfig({ ...scalar, configKey: "demo" }), /顶层必须是 JSON 对象/);
|
|
88
|
+
fs.rmSync(scalar.root, { recursive: true, force: true });
|
|
89
|
+
|
|
90
|
+
const brokenUser = fixture({ ok: true }, "{broken");
|
|
91
|
+
const before = fs.readFileSync(brokenUser.target);
|
|
92
|
+
assert.throws(() => seedModuleConfig({ ...brokenUser, configKey: "demo" }), /用户配置.*解析失败/);
|
|
93
|
+
assert.deepEqual(fs.readFileSync(brokenUser.target), before);
|
|
94
|
+
fs.rmSync(brokenUser.root, { recursive: true, force: true });
|
|
95
|
+
|
|
96
|
+
const invalid = fixture({});
|
|
97
|
+
assert.throws(() => seedModuleConfig({ ...invalid, configKey: "../escape" }), /invalid configKey/);
|
|
98
|
+
fs.rmSync(invalid.root, { recursive: true, force: true });
|
|
99
|
+
});
|