@sfmc-bds/cli 0.2.0-beta.14 → 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.
@@ -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 { upsertCatalogEntry, removeCatalogEntry } from "./lib/catalog.mjs";
38
- import { setModuleLockEnabled, removeModuleLock } from "./lib/lock.mjs";
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>} */(new Promise((resolve, reject) => {
439
- const proc = spawnChild(
440
- npmCmd,
441
- [
442
- "install",
443
- "--prefix",
444
- dir,
445
- "--omit=dev",
446
- "--no-save",
447
- "--no-package-lock",
448
- "--no-audit",
449
- "--no-fund",
450
- pkgName,
451
- ],
452
- { stdio: ["ignore", "pipe", "pipe"] }
453
- );
454
- let stderr = "";
455
- proc.stderr?.on("data", (d) => {
456
- stderr += d.toString();
457
- });
458
- proc.on("exit", (code) => {
459
- if (code === 0) return resolve();
460
- reject(new Error(translateNpmInstallError(pkgName, stderr)));
461
- });
462
- proc.on("error", (e) => reject(new Error(`spawn npm failed: ${e.message}`)));
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) => (code === 0 ? resolve() : reject(new Error(`npm install tarball exit ${code}: ${stderr}`))));
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("usage: install <id> [id2 ...] [--from <source>] [--link]\n或: install --from local|dir:<path> [--link](从包目录推导 id)");
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
- // @ts-ignore
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
+ });