@sfmc-bds/cli 0.2.0-beta.9 → 0.2.1

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 (128) hide show
  1. package/LICENSE +663 -0
  2. package/README.md +4 -5
  3. package/dist/bds-runtime-version.d.ts +3 -0
  4. package/dist/bds-runtime-version.d.ts.map +1 -0
  5. package/dist/bds-runtime-version.js +15 -0
  6. package/dist/bds-runtime-version.js.map +7 -0
  7. package/dist/command-surface.d.ts.map +1 -1
  8. package/dist/command-surface.js +3 -1
  9. package/dist/command-surface.js.map +2 -2
  10. package/dist/commands.d.ts +57 -4
  11. package/dist/commands.d.ts.map +1 -1
  12. package/dist/commands.js +78 -75
  13. package/dist/commands.js.map +2 -2
  14. package/dist/directory-picker.d.ts +27 -0
  15. package/dist/directory-picker.d.ts.map +1 -0
  16. package/dist/directory-picker.js +112 -0
  17. package/dist/directory-picker.js.map +7 -0
  18. package/dist/i18n/locales/en.d.ts +25 -1
  19. package/dist/i18n/locales/en.d.ts.map +1 -1
  20. package/dist/i18n/locales/en.js +25 -1
  21. package/dist/i18n/locales/en.js.map +2 -2
  22. package/dist/i18n/locales/zh-CN.d.ts.map +1 -1
  23. package/dist/i18n/locales/zh-CN.js +26 -2
  24. package/dist/i18n/locales/zh-CN.js.map +2 -2
  25. package/dist/interactive-prompts.d.ts +1 -2
  26. package/dist/interactive-prompts.d.ts.map +1 -1
  27. package/dist/interactive-prompts.js +12 -23
  28. package/dist/interactive-prompts.js.map +3 -3
  29. package/dist/llbot-launch.d.ts +3 -3
  30. package/dist/llbot-launch.js.map +1 -1
  31. package/dist/log-filter.d.ts +18 -2
  32. package/dist/log-filter.d.ts.map +1 -1
  33. package/dist/log-filter.js +54 -4
  34. package/dist/log-filter.js.map +2 -2
  35. package/dist/log-translator.d.ts +36 -0
  36. package/dist/log-translator.d.ts.map +1 -0
  37. package/dist/log-translator.js +279 -0
  38. package/dist/log-translator.js.map +7 -0
  39. package/dist/logs.d.ts +2 -0
  40. package/dist/logs.d.ts.map +1 -1
  41. package/dist/logs.js +24 -11
  42. package/dist/logs.js.map +2 -2
  43. package/dist/main.js +5 -0
  44. package/dist/main.js.map +2 -2
  45. package/dist/module-commands.d.ts.map +1 -1
  46. package/dist/module-commands.js +34 -13
  47. package/dist/module-commands.js.map +2 -2
  48. package/dist/module-fingerprint.d.ts.map +1 -1
  49. package/dist/module-fingerprint.js +1 -0
  50. package/dist/module-fingerprint.js.map +2 -2
  51. package/dist/module-pack-build.d.ts +14 -4
  52. package/dist/module-pack-build.d.ts.map +1 -1
  53. package/dist/module-pack-build.js +81 -108
  54. package/dist/module-pack-build.js.map +2 -2
  55. package/dist/module-toggle.d.ts +1 -1
  56. package/dist/module-toggle.d.ts.map +1 -1
  57. package/dist/module-toggle.js.map +1 -1
  58. package/dist/pack-lifecycle.d.ts +15 -1
  59. package/dist/pack-lifecycle.d.ts.map +1 -1
  60. package/dist/pack-lifecycle.js +242 -15
  61. package/dist/pack-lifecycle.js.map +2 -2
  62. package/dist/player-session.d.ts +13 -0
  63. package/dist/player-session.d.ts.map +1 -0
  64. package/dist/player-session.js +26 -0
  65. package/dist/player-session.js.map +7 -0
  66. package/dist/registry.d.ts +1 -1
  67. package/dist/registry.d.ts.map +1 -1
  68. package/dist/registry.js +4 -1
  69. package/dist/registry.js.map +2 -2
  70. package/dist/repl-windows/format-display.d.ts.map +1 -1
  71. package/dist/repl-windows/format-display.js +5 -1
  72. package/dist/repl-windows/format-display.js.map +2 -2
  73. package/dist/repl-windows/host.d.ts +6 -3
  74. package/dist/repl-windows/host.d.ts.map +1 -1
  75. package/dist/repl-windows/host.js +12 -6
  76. package/dist/repl-windows/host.js.map +2 -2
  77. package/dist/repl-windows/index.d.ts +1 -0
  78. package/dist/repl-windows/index.d.ts.map +1 -1
  79. package/dist/repl-windows/index.js +3 -0
  80. package/dist/repl-windows/index.js.map +2 -2
  81. package/dist/repl-windows/service-window.d.ts.map +1 -1
  82. package/dist/repl-windows/service-window.js +1 -0
  83. package/dist/repl-windows/service-window.js.map +2 -2
  84. package/dist/repl-windows/sfmc-window.d.ts +7 -0
  85. package/dist/repl-windows/sfmc-window.d.ts.map +1 -0
  86. package/dist/repl-windows/sfmc-window.js +28 -0
  87. package/dist/repl-windows/sfmc-window.js.map +7 -0
  88. package/dist/repl.d.ts.map +1 -1
  89. package/dist/repl.js +38 -13
  90. package/dist/repl.js.map +2 -2
  91. package/dist/runtime.d.ts +2 -2
  92. package/dist/runtime.d.ts.map +1 -1
  93. package/dist/runtime.js +15 -2
  94. package/dist/runtime.js.map +2 -2
  95. package/dist/send-target.d.ts +2 -0
  96. package/dist/send-target.d.ts.map +1 -1
  97. package/dist/send-target.js +6 -0
  98. package/dist/send-target.js.map +2 -2
  99. package/dist/services.d.ts +26 -1
  100. package/dist/services.d.ts.map +1 -1
  101. package/dist/services.js +179 -45
  102. package/dist/services.js.map +2 -2
  103. package/dist/sfmc-modules-root.d.ts.map +1 -1
  104. package/dist/sfmc-modules-root.js +6 -2
  105. package/dist/sfmc-modules-root.js.map +2 -2
  106. package/dist/ui-command.d.ts +6 -0
  107. package/dist/ui-command.d.ts.map +1 -0
  108. package/dist/ui-command.js +68 -0
  109. package/dist/ui-command.js.map +7 -0
  110. package/dist/wizard.d.ts.map +1 -1
  111. package/dist/wizard.js +36 -31
  112. package/dist/wizard.js.map +2 -2
  113. package/dist/world-packs.d.ts +2 -0
  114. package/dist/world-packs.d.ts.map +1 -1
  115. package/dist/world-packs.js +161 -18
  116. package/dist/world-packs.js.map +2 -2
  117. package/package.json +22 -15
  118. package/scripts/module-install/check-modules.mjs +178 -0
  119. package/scripts/module-install/fetch-module.mjs +1088 -0
  120. package/scripts/module-install/lib/catalog.mjs +96 -0
  121. package/scripts/module-install/lib/config-seeding.mjs +128 -0
  122. package/scripts/module-install/lib/io.mjs +37 -0
  123. package/scripts/module-install/lib/link-from.mjs +80 -0
  124. package/scripts/module-install/lib/lock.mjs +78 -0
  125. package/scripts/module-install/lib/npm-resolver.mjs +55 -0
  126. package/scripts/module-install/lib/packages.mjs +136 -0
  127. package/scripts/module-install/lib/paths.mjs +48 -0
  128. package/scripts/module-install/lib/registry-index.mjs +50 -0
@@ -0,0 +1,1088 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * @sfmc-bds/cli — 从 npm / GitHub / 本地 artifact 安装模块到 modules/packages/<id>/
5
+ *
6
+ * 安装成功后会把 sapi/manifest.json 投影写入 modules/catalog.json,
7
+ * 并按 enabledByDefault 更新 modules/module-lock.json。
8
+ *
9
+ * Usage:
10
+ * node packages/cli/scripts/module-install/fetch-module.mjs search
11
+ * sfmc mod install <id> …
12
+ *
13
+ * Sources:
14
+ * npm:@scope/name registry(默认;install <id> → @sfmc-bds/module-<folder>)
15
+ * local:[/abs/path] 本地目录 / .tgz / .zip(无路径默认 cwd)
16
+ * tgz:[/abs/path] 等价 local:,显式声明 .tgz
17
+ * zip:[/abs/path] 等价 local:,显式声明 .zip(强制校验内含 package.json + manifest)
18
+ * dir:/abs/path 本地目录(自动判单包/多包父目录)
19
+ * github:owner/repo[@tag] GitHub Release(兼容旧 Tanya7z/sfmc-modules)
20
+ *
21
+ * 缺省 source:first-party index → 优先 npm: 字段,否则 deprecated github:;
22
+ * 未命中则按 @sfmc-bds/module-<folder> 走 npm。
23
+ *
24
+ * --link: 配合 dir: 或 local:<dir>;把 modules/packages/<id> 链到源目录
25
+ * (win32=junction,POSIX=symlink),仍同步 catalog/lock。
26
+ */
27
+
28
+ import { extractZipFileToDir } from "@sfmc-bds/bds-tools/zipx";
29
+ import { createHash } from "node:crypto";
30
+ import fs, { createReadStream } from "node:fs";
31
+ import fsp from "node:fs/promises";
32
+ import path from "node:path";
33
+ import process from "node:process";
34
+ import { pipeline } from "node:stream/promises";
35
+ import { fileURLToPath } from "node:url";
36
+ import { runCheckModules } from "./check-modules.mjs";
37
+ import { removeCatalogEntry, upsertCatalogEntry } from "./lib/catalog.mjs";
38
+ import { seedModuleConfig } from "./lib/config-seeding.mjs";
39
+ import { exists } from "./lib/io.mjs";
40
+ import { isSchemeFrom, normalizeBarePathFrom, normalizeLinkFrom } from "./lib/link-from.mjs";
41
+ import { removeModuleLock, setModuleLockEnabled } from "./lib/lock.mjs";
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";
46
+
47
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
48
+ const TARGET = PACKAGES_DIR;
49
+
50
+ const DEFAULT_REGISTRY_REPO = "Tanya7z/sfmc-modules";
51
+ const DEFAULT_REGISTRY_TAG = "main";
52
+ const DEFAULT_REGISTRY_INDEX_URL = `https://raw.githubusercontent.com/${DEFAULT_REGISTRY_REPO}/${DEFAULT_REGISTRY_TAG}/index.json`;
53
+ const REGISTRY_CACHE_PATH = path.join(__dirname, ".sfmc-registry-cache.json");
54
+ const REGISTRY_CACHE_TTL_MS = 60 * 60 * 1000;
55
+
56
+ /**
57
+ * @typedef {{ repo: string, tag: string }} RegistryEntry
58
+ * @typedef {Record<string, RegistryEntry>} RegistryIndex
59
+ * @typedef {{ fetchedAt: number, index: RegistryIndex }} RegistryCache
60
+ */
61
+
62
+ function readCache() {
63
+ try {
64
+ return JSON.parse(fs.readFileSync(REGISTRY_CACHE_PATH, "utf8"));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @param {{ fetchedAt: number; index: Record<string, import("./lib/registry-index.mjs").RegistryEntry>; }} cache
72
+ */
73
+ function writeCache(cache) {
74
+ try {
75
+ fs.writeFileSync(REGISTRY_CACHE_PATH, JSON.stringify(cache, null, 2));
76
+ } catch {
77
+ /* best-effort */
78
+ }
79
+ }
80
+
81
+ async function fetchRegistryIndexFresh() {
82
+ const res = await fetch(DEFAULT_REGISTRY_INDEX_URL, { headers: { "User-Agent": "sfmc-fetch-module" } });
83
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${DEFAULT_REGISTRY_INDEX_URL}`);
84
+ return parseRegistryIndex(await res.json());
85
+ }
86
+
87
+ async function resolveRegistryIndex() {
88
+ const cache = readCache();
89
+ if (cache && Date.now() - cache.fetchedAt < REGISTRY_CACHE_TTL_MS) {
90
+ try {
91
+ const fresh = await fetchRegistryIndexFresh();
92
+ writeCache({ fetchedAt: Date.now(), index: fresh });
93
+ return { index: fresh, stale: false };
94
+ } catch {
95
+ return { index: cache.index, stale: false };
96
+ }
97
+ }
98
+ try {
99
+ const fresh = await fetchRegistryIndexFresh();
100
+ writeCache({ fetchedAt: Date.now(), index: fresh });
101
+ return { index: fresh, stale: false };
102
+ } catch (e) {
103
+ const message = e instanceof Error ? e.message : String(e);
104
+ if (cache) {
105
+ console.warn(
106
+ `[fetch-module] registry offline (${message}); using cached index from ${new Date(cache.fetchedAt).toISOString()}`
107
+ );
108
+ return { index: cache.index, stale: true };
109
+ }
110
+ throw new Error(`registry unreachable and no cache: ${message}. Pass --from explicitly to skip the registry.`);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 缺省 source:按 npm →
116
+ * @sfmc-bds /module-<id> 解析(DRY:避免分散在各 sub spec)。
117
+ 若 first-party registry 命中 → 仍用 github:(兼容旧路径,fn 不被禁止)。
118
+ * @param {string | number} id
119
+ */
120
+ async function defaultSourceFor(id) {
121
+ try {
122
+ const { index } = await resolveRegistryIndex();
123
+ const entry = index[id];
124
+ if (entry?.npm) {
125
+ const spec = `${entry.npm}${entry.version ? `@${entry.version}` : ""}`;
126
+ console.log(`[fetch-module] ${id} found in registry → npm:${spec}`);
127
+ return `npm:${spec}`;
128
+ }
129
+ if (entry?.repo && entry?.tag) {
130
+ console.log(`[fetch-module] ${id} found in registry (deprecated github) → github:${entry.repo}@${entry.tag}`);
131
+ return `github:${entry.repo}@${entry.tag}`;
132
+ }
133
+ } catch {
134
+ /* index 离线/失败:继续走 npm */
135
+ }
136
+ const { resolveNpmPackageName } = await import("./lib/npm-resolver.mjs");
137
+ try {
138
+ const pkgName = resolveNpmPackageName(id);
139
+ console.log(`[fetch-module] no --from given; using npm → ${pkgName}`);
140
+ return `npm:${pkgName}`;
141
+ } catch (e) {
142
+ const message = e instanceof Error ? e.message : String(e);
143
+ die(`无法解析 npm 包名: ${message}\n提示:传 --from local:<dir|tgz|zip> 或 --from npm:<scope>/<name>。`);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * 从本地包目录推导 install folder id。
149
+ * @param {string} absDir
150
+ */
151
+ function inferFolderIdFromDir(absDir) {
152
+ try {
153
+ const pkg = JSON.parse(fs.readFileSync(path.join(absDir, "package.json"), "utf8"));
154
+ const fromName = folderFromNpmPackageName(pkg.name);
155
+ if (fromName) return fromName;
156
+ } catch {
157
+ /* ignore */
158
+ }
159
+ try {
160
+ const man = JSON.parse(fs.readFileSync(path.join(absDir, "sapi", "manifest.json"), "utf8"));
161
+ if (typeof man.id === "string") return man.id.replace(/^(feature|core)-/, "");
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * @param {string} msg
170
+ */
171
+ function die(msg, code = 1) {
172
+ console.error(`[fetch-module] ${msg}`);
173
+ process.exit(code);
174
+ }
175
+
176
+ /**
177
+ * @param {string | any[]} args
178
+ */
179
+ function parseArgs(args) {
180
+ const flags = { from: null, sha256: null, link: false };
181
+ /** @type {string[]} */
182
+ const positional = [];
183
+ for (let i = 0; i < args.length; i++) {
184
+ const a = args[i];
185
+ if (a === "--from") flags.from = args[++i];
186
+ else if (a === "--sha256") flags.sha256 = args[++i];
187
+ else if (a === "--link") flags.link = true;
188
+ else if (a.startsWith("--from=")) flags.from = a.slice("--from=".length);
189
+ else if (a.startsWith("--")) die(`unknown flag: ${a}`);
190
+ else positional.push(a);
191
+ }
192
+ return { flags, positional };
193
+ }
194
+
195
+ /**
196
+ * @param {string} id
197
+ */
198
+ async function ensureTarget(id) {
199
+ const dir = path.join(TARGET, id);
200
+ await fsp.rm(dir, { recursive: true, force: true });
201
+ await fsp.mkdir(dir, { recursive: true });
202
+ return dir;
203
+ }
204
+
205
+ /**
206
+ * 移除 packages/<id>(目录 / junction / symlink),不跟随链接删除源内容
207
+ * @param {fs.PathLike} dir
208
+ */
209
+ async function removePackageTarget(dir) {
210
+ try {
211
+ await fsp.lstat(dir);
212
+ } catch (err) {
213
+ // @ts-ignore
214
+ if (err && err.code === "ENOENT") return;
215
+ throw err;
216
+ }
217
+ await fsp.rm(dir, { recursive: true, force: true });
218
+ }
219
+
220
+ /**
221
+ * v3 语义字段读取:纯只读,**不主动写** semantic。
222
+ * 通过 `loadPackageCatalogEntry` → `projectCatalogEntry` 把 manifest.semantic 投影进 catalog。
223
+ * 这里只用于在 install log 里打印一条状态,便于模块作者确认 v3 字段被读到。
224
+ * @param {string} folder
225
+ */
226
+ function readV3SemanticStatus(folder) {
227
+ const manifestPath = path.join(TARGET, folder, "sapi", "manifest.json");
228
+ if (!exists(manifestPath)) return null;
229
+ try {
230
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
231
+ return raw?.schemaVersion === 3 && raw?.semantic && typeof raw.semantic === "object";
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * 把 dest 链到 src(win32=junction,其它=dir symlink)
239
+ * @param {string} srcDir
240
+ * @param {string} destDir
241
+ */
242
+ async function linkPackageDir(srcDir, destDir) {
243
+ const absSrc = path.resolve(srcDir);
244
+ const absDest = path.resolve(destDir);
245
+ if (!exists(absSrc)) die(`local dir not found: ${absSrc}`);
246
+ await fsp.mkdir(path.dirname(absDest), { recursive: true });
247
+ await removePackageTarget(absDest);
248
+ const type = process.platform === "win32" ? "junction" : "dir";
249
+ await fsp.symlink(absSrc, absDest, type);
250
+ return absDest;
251
+ }
252
+
253
+ /**
254
+ * @param {fs.PathLike} file
255
+ */
256
+ async function sha256OfFile(file) {
257
+ const hash = createHash("sha256");
258
+ await pipeline(createReadStream(file), async function* (src) {
259
+ for await (const chunk of src) {
260
+ hash.update(chunk);
261
+ yield chunk;
262
+ }
263
+ });
264
+ return hash.digest("hex");
265
+ }
266
+
267
+ /**
268
+ * @param {string | URL | Request} url
269
+ */
270
+ async function fetchToBuffer(url) {
271
+ const res = await fetch(url, { headers: { "User-Agent": "sfmc-fetch-module" } });
272
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
273
+ return Buffer.from(await res.arrayBuffer());
274
+ }
275
+
276
+ /**
277
+ * 安装落盘后同步 catalog + lock;并对 copy/zip 产物做路径/命名规范化
278
+ * @param {string} folder
279
+ */
280
+ function afterInstall(folder, opts = {}) {
281
+ // @ts-ignore
282
+ if (!opts.skipNormalize) {
283
+ try {
284
+ normalizeInstalledPackage(path.join(TARGET, folder));
285
+ } catch (err) {
286
+ console.warn(`[fetch-module] normalize warn: ${err instanceof Error ? err.message : String(err)}`);
287
+ }
288
+ }
289
+ const moduleRoot = path.join(TARGET, folder);
290
+ const preview = loadPackageCatalogEntry(folder);
291
+ if (!preview) throw new Error(`packages/${folder}: 无法读取 sapi/manifest.json`);
292
+ const seeded = seedModuleConfig({
293
+ moduleRoot,
294
+ projectRoot: ROOT,
295
+ configKey: preview.configKey,
296
+ });
297
+ const targetRel = path.relative(ROOT, seeded.target).replaceAll("\\", "/");
298
+ if (seeded.status === "created") {
299
+ console.log(`[fetch-module] config created: ${targetRel}`);
300
+ } else if (seeded.status === "updated") {
301
+ console.log(`[fetch-module] config updated: added ${seeded.addedPaths.join(", ")}`);
302
+ } else if (seeded.status === "unchanged") {
303
+ console.log(`[fetch-module] config unchanged: ${targetRel}`);
304
+ } else {
305
+ console.warn(`[fetch-module] config defaults missing: ${preview.configKey}`);
306
+ }
307
+
308
+ const entry = upsertCatalogEntry(folder);
309
+ setModuleLockEnabled(entry.id, entry.enabledByDefault !== false);
310
+ console.log(`[fetch-module] catalog+lock: ${entry.id} (enabled=${entry.enabledByDefault !== false})`);
311
+ /* v3 状态透传:只读 semantic 字段已通过 projectCatalogEntry 投影到 catalog。
312
+ * 模块作者想用 v3 时只需在 sapi/manifest.json 写 `"schemaVersion": 3` 与 semantic 块;
313
+ * fetch-module 不会再追问也不会主动注入。 */
314
+ const hasV3 = readV3SemanticStatus(folder);
315
+ if (hasV3) {
316
+ console.log(`[fetch-module] v3 semantic: detected (manifest.schemaVersion=3)`);
317
+ }
318
+ const check = runCheckModules();
319
+ if (!check.ok) {
320
+ throw new Error(`check-modules 未通过: ${check.error}`);
321
+ }
322
+ for (const warning of check.warnings ?? []) {
323
+ console.warn(`[fetch-module] check warning: ${warning}`);
324
+ }
325
+ console.log(`[fetch-module] ${check.summary}`);
326
+ return entry;
327
+ }
328
+
329
+ /**
330
+ * 规范化已安装包:旧
331
+ * @sfmc /sdk →
332
+ * @sfmc-bds /sdk;tsconfig 改为自包含(不依赖主仓 sdk 路径)。
333
+ --link 联调目录跳过,避免改写源仓。
334
+ * @param {fs.PathLike} pkgDir
335
+ */
336
+ function normalizeInstalledPackage(pkgDir) {
337
+ // @ts-ignore
338
+ if (!exists(pkgDir)) return;
339
+ // junction/symlink:不改写源
340
+ try {
341
+ const st = fs.lstatSync(pkgDir);
342
+ if (st.isSymbolicLink()) {
343
+ console.log(`[fetch-module] normalize: skipped (link)`);
344
+ return;
345
+ }
346
+ } catch {
347
+ /* continue */
348
+ }
349
+
350
+ // @ts-ignore
351
+ const pkgJsonPath = path.join(pkgDir, "package.json");
352
+ if (exists(pkgJsonPath)) {
353
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
354
+ let dirty = false;
355
+ if (typeof pkg.name === "string" && pkg.name.startsWith("@sfmc/module-")) {
356
+ pkg.name = pkg.name.replace("@sfmc/module-", "@sfmc-bds/module-");
357
+ dirty = true;
358
+ }
359
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
360
+ const deps = pkg[section];
361
+ if (!deps || typeof deps !== "object") continue;
362
+ if (deps["@sfmc/sdk"] != null) {
363
+ deps["@sfmc-bds/sdk"] = deps["@sfmc/sdk"];
364
+ delete deps["@sfmc/sdk"];
365
+ dirty = true;
366
+ }
367
+ }
368
+ if (pkg.peerDependencies) {
369
+ delete pkg.peerDependencies;
370
+ dirty = true;
371
+ }
372
+ if (dirty) {
373
+ fs.writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
374
+ console.log(`[fetch-module] normalize: package.json → @sfmc-bds`);
375
+ }
376
+ }
377
+
378
+ // @ts-ignore
379
+ const tsconfigPath = path.join(pkgDir, "sapi", "tsconfig.json");
380
+ if (exists(tsconfigPath)) {
381
+ const standalone = {
382
+ compilerOptions: {
383
+ module: "nodenext",
384
+ moduleResolution: "nodenext",
385
+ target: "es2022",
386
+ lib: ["es2022"],
387
+ strict: true,
388
+ noEmit: true,
389
+ rootDir: "./src",
390
+ skipLibCheck: true,
391
+ esModuleInterop: true,
392
+ },
393
+ include: ["src/**/*"],
394
+ };
395
+ fs.writeFileSync(tsconfigPath, `${JSON.stringify(standalone, null, 2)}\n`, "utf8");
396
+ console.log(`[fetch-module] normalize: sapi/tsconfig.json (standalone)`);
397
+ }
398
+
399
+ // @ts-ignore
400
+ const srcDir = path.join(pkgDir, "sapi", "src");
401
+ if (exists(srcDir)) {
402
+ let rewritten = 0;
403
+ const walk = (/** @type {fs.PathLike} */ dir) => {
404
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
405
+ // @ts-ignore
406
+ const full = path.join(dir, e.name);
407
+ if (e.isDirectory()) walk(full);
408
+ else if (e.isFile() && /\.(ts|tsx|js|mjs)$/.test(e.name)) {
409
+ const text = fs.readFileSync(full, "utf8");
410
+ if (!text.includes("@sfmc/sdk")) continue;
411
+ fs.writeFileSync(full, text.replaceAll("@sfmc/sdk", "@sfmc-bds/sdk"), "utf8");
412
+ rewritten++;
413
+ }
414
+ }
415
+ };
416
+ walk(srcDir);
417
+ if (rewritten > 0) {
418
+ console.log(`[fetch-module] normalize: rewrote @sfmc/sdk → @sfmc-bds/sdk in ${rewritten} file(s)`);
419
+ }
420
+ }
421
+ }
422
+
423
+ /**
424
+ * `--from local` 入口分发。`source` 已剥前缀,
425
+ * 实际行为由 `resolveLocalPath` 解析出的路径类型决定:
426
+ * - dir → fromDir 拷贝
427
+ * - .tgz → 解 tarball 到 packages/<id>
428
+ * - .zip → 解 zip + 校验 layout
429
+ * - 缺省 → cwd(被 resolveLocalPath 提前处理)
430
+ * @param {any} id
431
+ * @param {any} source
432
+ * @param {any} flags
433
+ */
434
+ async function fromLocal(id, source, flags) {
435
+ /* 兼容旧调用:source 仍可能含 "local:" 前缀 —— 直接由 resolveLocalPath 接管 */
436
+ const raw = String(source ?? "");
437
+ const tail = raw.startsWith("local:") ? raw.slice("local:".length) : raw;
438
+ const resolved = resolveLocalPath(tail);
439
+ return installLocalArtifact(id, resolved, flags);
440
+ }
441
+
442
+ /**
443
+ * `sfmc mod install <id>` 走包 registry 的主路径,由 pnpm 下载。
444
+ *
445
+ * 入口:临时项目中执行 `pnpm add --prod --ignore-scripts <pkgName>`,再复制包文件。
446
+ * - 落地到 packages/<id>,不进主仓根 node_modules(隔离安装)
447
+ * - 成功后 upsert catalog/lock
448
+ * - 失败时把常见 registry 错误翻译成中文可读提示
449
+ * @param {string} id
450
+ * @param {string} pkgName
451
+ * @param {any} flags
452
+ */
453
+ async function fromRegistry(id, pkgName) {
454
+ const dir = await ensureTarget(id);
455
+ const os = await import("node:os");
456
+ const tmpPrefix = await fsp.mkdtemp(path.join(os.tmpdir(), "sfmc-pnpm-install-"));
457
+ try {
458
+ await installWithPnpm(tmpPrefix, pkgName);
459
+ const installed = findInstalledModule(tmpPrefix, id, pkgName);
460
+ if (!installed) throw new Error(`pnpm add 完成,但 ${pkgName} 中未找到模块 ${id}`);
461
+ await copyDir(installed, dir);
462
+ } catch (error) {
463
+ await removePackageTarget(dir);
464
+ const message = error instanceof Error ? error.message : String(error);
465
+ throw new Error(translatePnpmInstallError(pkgName, message));
466
+ } finally {
467
+ try {
468
+ await fsp.rm(tmpPrefix, { recursive: true, force: true });
469
+ } catch {
470
+ /* best-effort */
471
+ }
472
+ }
473
+ console.log(`[fetch-module] installed ${id} from registry (${pkgName})`);
474
+ console.log(`[fetch-module] target: ${dir}`);
475
+ afterInstall(id);
476
+ }
477
+
478
+ /**
479
+ * 翻译常见 pnpm registry 错误为中文可读 + 下一步动作。
480
+ * @param {string} pkgName
481
+ * @param {string} stderr
482
+ */
483
+ function translatePnpmInstallError(pkgName, stderr) {
484
+ const text = String(stderr || "");
485
+ if (/ERR_PNPM_FETCH_404|404 Not Found|not found/i.test(text)) {
486
+ return `registry 中未找到包: ${pkgName}。请检查 manifest id / scope 拼写,或换 --from local:./<path>。`;
487
+ }
488
+ if (/ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|EACCES|permission/i.test(text)) {
489
+ return `registry 权限错误: ${pkgName}。检查 pnpm registry 登录态或 token 权限。`;
490
+ }
491
+ if (/ERR_PNPM_PEER_DEP_ISSUES|ERESOLVE/i.test(text)) {
492
+ return `依赖冲突: ${pkgName}。请先确认 SDK/宿主版本兼容。`;
493
+ }
494
+ if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ERR_PNPM_FETCH/i.test(text)) {
495
+ return `registry 网络错误: ${pkgName}。检查代理 / 网络;或离线用 --from local:<tgz|dir>。`;
496
+ }
497
+ return `pnpm add ${pkgName} 失败:\n${text.trim()}`;
498
+ }
499
+
500
+ /**
501
+ * `tgz:` 显式前缀;等价 local:<path>,规则同 resolveLocalPath。
502
+ * @param {any} id
503
+ * @param {string | any[]} source
504
+ * @param {any} flags
505
+ */
506
+ async function fromTgz(id, source, flags) {
507
+ const tail = source.slice("tgz:".length);
508
+ const resolved = resolveLocalPath(tail);
509
+ return installLocalArtifact(id, resolved, flags);
510
+ }
511
+
512
+ /**
513
+ * `zip:` 显式前缀;同上但强制校验内部布局。
514
+ * @param {any} id
515
+ * @param {string | any[]} source
516
+ * @param {any} flags
517
+ */
518
+ async function fromZip(id, source, flags) {
519
+ const tail = source.slice("zip:".length);
520
+ const resolved = resolveLocalPath(tail);
521
+ return installLocalArtifact(id, resolved, flags, { kind: "zip" });
522
+ }
523
+
524
+ /**
525
+ * 把 `--from local[:path]` 的 path 段解析为绝对路径:
526
+ * - 空 / undefined / `.` / `./` → process.cwd()
527
+ * - 相对路径 → 相对 cwd 解析
528
+ * - 绝对路径 → 原样
529
+ * 单一权威,避免 local / tgz / zip / dir 之间出现重复解析。
530
+ * @param {string | any[]} tail
531
+ */
532
+ function resolveLocalPath(tail) {
533
+ const p = String(tail ?? "").trim();
534
+ if (!p || p === "." || p === "./") return path.resolve(process.cwd());
535
+ return path.isAbsolute(p) ? path.resolve(p) : path.resolve(process.cwd(), p);
536
+ }
537
+
538
+ /**
539
+ * 按解析出的绝对路径判定形态并安装。
540
+ * kind="zip" 强制校验 layout(zip 仅作为离线分享格式,CLI 必须做完整性检查)。
541
+ * @param {string} id
542
+ * @param {fs.PathLike} absPath
543
+ * @param {{} | undefined} flags
544
+ */
545
+ async function installLocalArtifact(id, absPath, flags, opts = {}) {
546
+ // @ts-ignore
547
+ if (!exists(absPath)) {
548
+ die(`--from local target not found: ${absPath}`);
549
+ }
550
+ const st = fs.lstatSync(absPath);
551
+ if (st.isDirectory()) {
552
+ /* dir: 与 --from dir:<path> 等价,复用 fromDir(统一入口) */
553
+ return fromDir(id, `dir:${absPath}`, flags);
554
+ }
555
+ if (!st.isFile()) {
556
+ die(`--from local must be a directory, .tgz, or .zip: ${absPath}`);
557
+ }
558
+ // @ts-ignore
559
+ const lower = absPath.toLowerCase();
560
+ const isZip = lower.endsWith(".zip");
561
+ const isTgz = lower.endsWith(".tgz") || lower.endsWith(".tar.gz");
562
+ if (!isZip && !isTgz) {
563
+ die(`--from local file must end with .tgz or .zip: ${absPath}`);
564
+ }
565
+
566
+ /* 校验 hash(local 模式下若给了 --sha256) */
567
+ const actual = await sha256OfFile(absPath);
568
+ // @ts-ignore
569
+ if (flags.sha256 && flags.sha256.toLowerCase() !== actual) {
570
+ // @ts-ignore
571
+ die(`SHA-256 mismatch (local): expected ${flags.sha256}, got ${actual}`);
572
+ }
573
+
574
+ const dir = await ensureTarget(id);
575
+ if (isTgz) {
576
+ await extractTgz(absPath, dir, id);
577
+ } else {
578
+ await unzip(absPath, dir);
579
+ }
580
+ /* zip 必须校验内含关键文件(缺则硬错误) */
581
+ // @ts-ignore
582
+ if (isZip || opts.kind === "zip") {
583
+ validateModuleLayout(dir);
584
+ }
585
+ console.log(`[fetch-module] installed ${id} from ${absPath}`);
586
+ console.log(`[fetch-module] sha256: ${actual}`);
587
+ console.log(`[fetch-module] target: ${dir}`);
588
+ afterInstall(id);
589
+ }
590
+
591
+ /**
592
+ * 校验 modules/packages/<id> 落地目录内含 manifest + package.json + entry。
593
+ * zip 离线分享场景必须通过;缺关键文件则清目录并 die。
594
+ * @param {fs.PathLike} pkgDir
595
+ */
596
+ function validateModuleLayout(pkgDir) {
597
+ // @ts-ignore
598
+ const manifestPath = path.join(pkgDir, "sapi", "manifest.json");
599
+ // @ts-ignore
600
+ const pkgJsonPath = path.join(pkgDir, "package.json");
601
+ const missing = [];
602
+ if (!exists(manifestPath)) missing.push("sapi/manifest.json");
603
+ if (!exists(pkgJsonPath)) missing.push("package.json");
604
+ if (missing.length > 0) {
605
+ /* 清掉避免污染 packages/;让用户重试而不是放任脏目录 */
606
+ try {
607
+ fs.rmSync(pkgDir, { recursive: true, force: true });
608
+ } catch {
609
+ /* best-effort */
610
+ }
611
+ die(
612
+ `archive layout invalid: missing ${missing.join(", ")}. ` +
613
+ `zip 仅作离线分享,必须含 package.json + sapi/manifest.json;npm 发布请用 tgz/pack。`
614
+ );
615
+ }
616
+ }
617
+
618
+ /**
619
+ * @param {string} id
620
+ * @param {string} source
621
+ */
622
+ async function fromDir(id, source, flags = {}) {
623
+ const srcDir = path.resolve(source.slice("dir:".length));
624
+ if (!exists(srcDir)) die(`local dir not found: ${srcDir}`);
625
+ const dest = path.join(TARGET, id);
626
+ // @ts-ignore
627
+ if (flags.link) {
628
+ await linkPackageDir(srcDir, dest);
629
+ console.log(`[fetch-module] linked ${id} → ${srcDir}`);
630
+ console.log(`[fetch-module] mode: ${process.platform === "win32" ? "junction" : "symlink"}`);
631
+ console.log(`[fetch-module] target: ${dest}`);
632
+ afterInstall(id, { skipNormalize: true });
633
+ } else {
634
+ const dir = await ensureTarget(id);
635
+ await copyDir(srcDir, dir);
636
+ console.log(`[fetch-module] installed ${id} from dir ${srcDir}`);
637
+ console.log(`[fetch-module] target: ${dir}`);
638
+ afterInstall(id);
639
+ }
640
+ }
641
+
642
+ /**
643
+ * @param {string} id
644
+ * @param {string | any[]} source
645
+ * @param {{ sha256: string; }} flags
646
+ */
647
+ async function fromGithub(id, source, flags) {
648
+ let owner,
649
+ repo,
650
+ tag = "latest";
651
+ const body = source.slice("github:".length);
652
+ const tagIdx = body.lastIndexOf("@");
653
+ if (tagIdx > 0) {
654
+ // @ts-ignore
655
+ tag = body.slice(tagIdx + 1);
656
+ // @ts-ignore
657
+ [owner, repo] = body.slice(0, tagIdx).split("/");
658
+ } else {
659
+ // @ts-ignore
660
+ [owner, repo] = body.split("/");
661
+ }
662
+ if (!owner || !repo) die(`invalid github source: ${source}`);
663
+
664
+ const releasePath = tag === "latest" ? "latest" : `tags/${encodeURIComponent(tag)}`;
665
+ const releaseUrl = `https://api.github.com/repos/${owner}/${repo}/releases/${releasePath}`;
666
+ const relRes = await fetch(releaseUrl, {
667
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "sfmc-fetch-module" },
668
+ });
669
+ if (!relRes.ok) die(`github release ${releaseUrl} → HTTP ${relRes.status}`);
670
+ const rel = await relRes.json();
671
+
672
+ const assetRe = /^sfmc-module-([a-z0-9-]+)-(\d+\.\d+\.\d+)\.zip$/;
673
+ let asset = null;
674
+ for (const a of rel.assets ?? []) {
675
+ const m = assetRe.exec(a.name);
676
+ if (m && m[1] === id) {
677
+ asset = a;
678
+ break;
679
+ }
680
+ }
681
+ if (!asset) die(`module ${id} not found in release ${rel.tag_name ?? tag}`);
682
+
683
+ const versionMatch = assetRe.exec(asset.name);
684
+ // @ts-ignore
685
+ const version = versionMatch[2];
686
+ console.log(`[fetch-module] fetching ${asset.name} (${(asset.size / 1024).toFixed(1)} KB)`);
687
+
688
+ const zipBuf = await fetchToBuffer(asset.browser_download_url);
689
+ const actual = createHash("sha256").update(zipBuf).digest("hex");
690
+
691
+ if (!flags.sha256) {
692
+ const shaUrl = asset.browser_download_url.replace(/\.zip$/, ".sha256");
693
+ try {
694
+ const shaBuf = await fetchToBuffer(shaUrl);
695
+ const text = shaBuf.toString("utf8").trim().split(/\s+/)[0];
696
+ if (/^[a-f0-9]{64}$/.test(text)) flags.sha256 = text;
697
+ } catch {
698
+ /* optional */
699
+ }
700
+ }
701
+
702
+ if (flags.sha256 && flags.sha256.toLowerCase() !== actual) {
703
+ die(`SHA-256 mismatch (github): expected ${flags.sha256}, got ${actual}`);
704
+ }
705
+
706
+ const dir = await ensureTarget(id);
707
+ const stagedZip = path.join(dir, "_staged.zip");
708
+ await fsp.writeFile(stagedZip, zipBuf);
709
+ await unzip(stagedZip, dir);
710
+ await fsp.rm(stagedZip, { force: true });
711
+ console.log(`[fetch-module] installed ${id} v${version} from ${owner}/${repo}@${rel.tag_name ?? tag}`);
712
+ console.log(`[fetch-module] sha256: ${actual}`);
713
+ console.log(`[fetch-module] target: ${dir}`);
714
+ afterInstall(id);
715
+ }
716
+
717
+ /**
718
+ * @param {string | any[] | null} source
719
+ */
720
+ async function listGithub(source) {
721
+ let owner,
722
+ repo,
723
+ tag = "latest";
724
+ // @ts-ignore
725
+ const body = source.slice("github:".length);
726
+ const tagIdx = body.lastIndexOf("@");
727
+ if (tagIdx > 0) {
728
+ // @ts-ignore
729
+ tag = body.slice(tagIdx + 1);
730
+ // @ts-ignore
731
+ [owner, repo] = body.slice(0, tagIdx).split("/");
732
+ } else {
733
+ // @ts-ignore
734
+ [owner, repo] = body.split("/");
735
+ }
736
+ if (!owner || !repo) die(`invalid github source: ${source}`);
737
+ const releasePath = tag === "latest" ? "latest" : `tags/${encodeURIComponent(tag)}`;
738
+ const releaseUrl = `https://api.github.com/repos/${owner}/${repo}/releases/${releasePath}`;
739
+ const relRes = await fetch(releaseUrl, {
740
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "sfmc-fetch-module" },
741
+ });
742
+ if (!relRes.ok) die(`github release ${releaseUrl} → HTTP ${relRes.status}`);
743
+ const rel = await relRes.json();
744
+ console.log(`Release: ${rel.tag_name ?? tag} (${rel.name ?? ""})`);
745
+ const assetRe = /^sfmc-module-([a-z0-9-]+)-(\d+\.\d+\.\d+)\.zip$/;
746
+ for (const a of rel.assets ?? []) {
747
+ const m = assetRe.exec(a.name);
748
+ if (m) console.log(` ${m[1].padEnd(28)} v${m[2].padEnd(8)} ${(a.size / 1024).toFixed(1)} KB`);
749
+ }
750
+ }
751
+
752
+ /**
753
+ * 解压模块包 — 委托 bds-tools/zipx(DRY;防 zip-slip / `\` / 绝对路径)
754
+ * @param {fs.PathLike} zipPath
755
+ * @param {fs.PathLike} dstDir
756
+ */
757
+ async function unzip(zipPath, dstDir) {
758
+ await fsp.mkdir(dstDir, { recursive: true });
759
+ // @ts-ignore
760
+ await extractZipFileToDir(zipPath, dstDir);
761
+ }
762
+
763
+ /**
764
+ * 解 package tarball(.tgz)到 packages/<id>。
765
+ * 委托 pnpm 在临时项目中安装并安全解包,再将包文件复制到模块目录。
766
+ * @param {fs.PathLike} tgzPath
767
+ * @param {string} dstDir
768
+ * @param {string} id
769
+ */
770
+ async function extractTgz(tgzPath, dstDir, id) {
771
+ const os = await import("node:os");
772
+ const tmpPrefix = await fsp.mkdtemp(path.join(os.tmpdir(), "sfmc-tgz-"));
773
+ try {
774
+ await installWithPnpm(tmpPrefix, tgzPath);
775
+ const found = findInstalledModule(tmpPrefix, id);
776
+ if (!found) throw new Error(`pnpm add 已解包,但 tarball 中未找到模块 ${id}`);
777
+ await copyDir(found, dstDir);
778
+ } finally {
779
+ try {
780
+ await fsp.rm(tmpPrefix, { recursive: true, force: true });
781
+ } catch {
782
+ /* best-effort */
783
+ }
784
+ }
785
+ }
786
+
787
+ /**
788
+ * 在临时项目中安装包,只读取 registry/tarball 内容,不执行包的生命周期脚本。
789
+ * @param {string} projectDir
790
+ * @param {string | fs.PathLike} packageSpec
791
+ */
792
+ async function installWithPnpm(projectDir, packageSpec) {
793
+ await fsp.writeFile(
794
+ path.join(projectDir, "package.json"),
795
+ '{"name":"sfmc-module-install-tmp","version":"0.0.0","private":true}\n'
796
+ );
797
+ // @ts-ignore - cross-spawn resolves pnpm.cmd safely on Windows.
798
+ const spawnChild = (await import("cross-spawn")).default;
799
+ await new Promise((resolve, reject) => {
800
+ const proc = spawnChild(
801
+ "pnpm",
802
+ [
803
+ "--dir",
804
+ projectDir,
805
+ "add",
806
+ "--ignore-workspace",
807
+ "--config.auto-install-peers=false",
808
+ "--prod",
809
+ "--ignore-scripts",
810
+ String(packageSpec),
811
+ ],
812
+ { stdio: ["ignore", "ignore", "pipe"] }
813
+ );
814
+ let stderr = "";
815
+ proc.stderr?.on("data", (data) => {
816
+ stderr += data.toString();
817
+ });
818
+ proc.on("close", (code, signal) => {
819
+ if (code === 0) return resolve();
820
+ reject(
821
+ new Error(
822
+ code === null
823
+ ? `pnpm add terminated by ${signal || "unknown signal"}: ${stderr.trim()}`
824
+ : `pnpm add failed (${code}): ${stderr.trim()}`
825
+ )
826
+ );
827
+ });
828
+ proc.on("error", (error) => reject(new Error(`无法启动 pnpm: ${error.message}`)));
829
+ });
830
+ }
831
+
832
+ /**
833
+ * 从 pnpm 临时项目的 node_modules 中解析模块包真实目录。
834
+ * @param {string} projectDir
835
+ * @param {string} id
836
+ * @param {string} [packageSpec]
837
+ */
838
+ function findInstalledModule(projectDir, id, packageSpec) {
839
+ const nodeModules = path.join(projectDir, "node_modules");
840
+ if (!exists(nodeModules)) return null;
841
+ /** @type {string[]} */
842
+ const candidates = [];
843
+ if (packageSpec) {
844
+ const packageName = packageNameFromSpec(String(packageSpec));
845
+ if (packageName) candidates.push(path.join(nodeModules, ...packageName.split("/")));
846
+ }
847
+ for (const entry of fs.readdirSync(nodeModules, { withFileTypes: true })) {
848
+ if (entry.name === ".bin" || entry.name === ".pnpm" || entry.name.startsWith(".")) continue;
849
+ const entryPath = path.join(nodeModules, entry.name);
850
+ if (entry.name.startsWith("@")) {
851
+ for (const scoped of fs.readdirSync(entryPath, { withFileTypes: true })) {
852
+ candidates.push(path.join(entryPath, scoped.name));
853
+ }
854
+ } else {
855
+ candidates.push(entryPath);
856
+ }
857
+ }
858
+ const seen = new Set();
859
+ for (const candidate of candidates) {
860
+ let real;
861
+ try {
862
+ real = fs.realpathSync(candidate);
863
+ } catch {
864
+ continue;
865
+ }
866
+ if (seen.has(real)) continue;
867
+ seen.add(real);
868
+ try {
869
+ const manifestPath = path.join(real, "sapi", "manifest.json");
870
+ const moduleId = exists(manifestPath)
871
+ ? String(JSON.parse(fs.readFileSync(manifestPath, "utf8")).id || "").replace(/^(feature|core)-/, "")
872
+ : "";
873
+ const pkgName = String(JSON.parse(fs.readFileSync(path.join(real, "package.json"), "utf8")).name || "");
874
+ if (moduleId === id || folderFromNpmPackageName(pkgName) === id) return real;
875
+ } catch {
876
+ /* ignore package entries without module metadata */
877
+ }
878
+ }
879
+ return null;
880
+ }
881
+
882
+ /** @param {string} spec */
883
+ function packageNameFromSpec(spec) {
884
+ if (spec.startsWith("@")) {
885
+ const slash = spec.indexOf("/");
886
+ if (slash < 0) return null;
887
+ const versionSeparator = spec.indexOf("@", slash + 1);
888
+ return versionSeparator < 0 ? spec : spec.slice(0, versionSeparator);
889
+ }
890
+ const versionSeparator = spec.indexOf("@");
891
+ return versionSeparator < 0 ? spec : spec.slice(0, versionSeparator);
892
+ }
893
+
894
+ /**
895
+ * @param {fs.PathLike} src
896
+ * @param {fs.PathLike} dst
897
+ */
898
+ async function copyDir(src, dst) {
899
+ await fsp.mkdir(dst, { recursive: true });
900
+ for (const e of await fsp.readdir(src, { withFileTypes: true })) {
901
+ // @ts-ignore
902
+ const sp = path.join(src, e.name);
903
+ // @ts-ignore
904
+ const dp = path.join(dst, e.name);
905
+ if (e.isDirectory()) await copyDir(sp, dp);
906
+ else if (e.isFile()) await fsp.copyFile(sp, dp);
907
+ }
908
+ }
909
+
910
+ /**
911
+ * @param {string} id
912
+ * @param {{ from: any; sha256?: null; link: any; }} flags
913
+ */
914
+ async function installOne(id, flags) {
915
+ let from = flags.from;
916
+ if (!from) {
917
+ from = await defaultSourceFor(id);
918
+ console.log(`[fetch-module] no --from given; using → ${from}`);
919
+ }
920
+ if (flags.link) {
921
+ const norm = normalizeLinkFrom(from, process.cwd());
922
+ if (!norm.ok) die(norm.error);
923
+ // @ts-ignore
924
+ from = norm.from;
925
+ } else if (!isSchemeFrom(from)) {
926
+ /* 约定:--from <路径> → dir:<abs> */
927
+ const bare = normalizeBarePathFrom(from, process.cwd());
928
+ if (!bare.ok) die(bare.error);
929
+ // @ts-ignore
930
+ from = bare.from;
931
+ }
932
+ const perFlags = { ...flags, from };
933
+ if (from.startsWith("local:") || from === "local") return fromLocal(id, from, perFlags);
934
+ if (from.startsWith("tgz:")) return fromTgz(id, from, perFlags);
935
+ if (from.startsWith("zip:")) return fromZip(id, from, perFlags);
936
+ if (from.startsWith("npm:")) return fromRegistry(id, from.slice("npm:".length));
937
+ if (from.startsWith("dir:")) {
938
+ const base = from.slice("dir:".length);
939
+ const candidate = path.join(base, id);
940
+ const src = exists(path.join(base, "sapi", "manifest.json"))
941
+ ? from
942
+ : exists(path.join(candidate, "sapi", "manifest.json"))
943
+ ? `dir:${candidate}`
944
+ : from;
945
+ return fromDir(id, src, perFlags);
946
+ }
947
+ // @ts-ignore
948
+ if (from.startsWith("github:")) return fromGithub(id, from, perFlags);
949
+ die(`unknown source: ${from}`);
950
+ }
951
+
952
+ /**
953
+ * @param {string} id
954
+ */
955
+ async function uninstallOne(id) {
956
+ const dir = path.join(TARGET, id);
957
+ const removed = removeCatalogEntry(id);
958
+ if (removed) removeModuleLock(removed.id);
959
+ else removeModuleLock(id);
960
+ if (exists(dir)) {
961
+ await removePackageTarget(dir);
962
+ console.log(`[fetch-module] uninstalled ${id} (removed ${dir})`);
963
+ } else {
964
+ console.log(`[fetch-module] uninstalled ${id} (no package dir; catalog/lock cleaned)`);
965
+ }
966
+ if (removed) console.log(`[fetch-module] catalog removed: ${removed.id}`);
967
+ }
968
+
969
+ function printHelp() {
970
+ console.log(`@sfmc-bds/cli fetch-module — populate ./modules/packages/<id>/
971
+
972
+ Commands:
973
+ search list first-party registry
974
+ list [--from github:owner/repo@tag] list release assets (default: first-party)
975
+ install <id> [id2 ...] [--from ...] [--link]
976
+ install one or more modules + sync catalog/lock
977
+ uninstall <id> [id2 ...] remove package dir + catalog/lock entries
978
+
979
+ Sources (按优先级):
980
+ npm:@scope/name npm registry(默认;install <id> → @sfmc-bds/module-<id>)
981
+ local:[/abs/path] 本地目录 / .tgz / .zip(无路径默认 cwd)
982
+ tgz:[/abs/path] 等价 local:,显式声明 .tgz
983
+ zip:[/abs/path] 等价 local:,强制校验内含 package.json + manifest
984
+ dir:/abs/path 本地目录(自动判单包/多包父目录)
985
+ github:owner/repo[@tag] GitHub Release(兼容旧 first-party)
986
+
987
+ Flags:
988
+ --link with dir: or local:<dir> — junction (Windows) / symlink (POSIX) into
989
+ modules/packages/<id> instead of copying. Still syncs catalog/lock.
990
+ `);
991
+ }
992
+
993
+ async function main() {
994
+ const [, , verb, ...rest] = process.argv;
995
+ if (!verb) {
996
+ printHelp();
997
+ return;
998
+ }
999
+
1000
+ if (verb === "search") {
1001
+ const { index, stale } = await resolveRegistryIndex();
1002
+ const ids = Object.keys(index).sort();
1003
+ if (stale) console.warn("[fetch-module] registry cache may be stale (offline mode)");
1004
+ console.log(`Registry (${DEFAULT_REGISTRY_REPO}@${DEFAULT_REGISTRY_TAG}) — ${ids.length} modules:`);
1005
+ for (const id of ids) {
1006
+ const e = index[id];
1007
+ const src = e.npm
1008
+ ? `npm:${e.npm}${e.version ? `@${e.version}` : ""}`
1009
+ : e.repo
1010
+ ? `github:${e.repo}@${e.tag}`
1011
+ : "?";
1012
+ console.log(` ${id.padEnd(28)} ${src}`);
1013
+ }
1014
+ return;
1015
+ }
1016
+
1017
+ if (verb === "list") {
1018
+ const { flags } = parseArgs(rest);
1019
+ if (!flags.from) {
1020
+ // @ts-ignore
1021
+ flags.from = `github:${DEFAULT_REGISTRY_REPO}@modules-v0.4.0`;
1022
+ console.log(`[fetch-module] no --from given; listing ${flags.from}`);
1023
+ }
1024
+ // @ts-ignore
1025
+ if (!flags.from.startsWith("github:")) die("--from github:owner/repo[@tag] required");
1026
+ await listGithub(flags.from);
1027
+ return;
1028
+ }
1029
+
1030
+ if (verb === "uninstall") {
1031
+ const { positional } = parseArgs(rest);
1032
+ if (positional.length === 0) die("usage: uninstall <id> [id2 ...]");
1033
+ for (const id of positional) await uninstallOne(id);
1034
+ return;
1035
+ }
1036
+
1037
+ if (verb !== "install") die(`unknown verb: ${verb}`);
1038
+
1039
+ const { flags, positional } = parseArgs(rest);
1040
+ let ids = [...positional];
1041
+ if (ids.length === 0) {
1042
+ const from = flags.from;
1043
+ // @ts-ignore
1044
+ if (!from || (!from.startsWith("local") && !from.startsWith("dir:"))) {
1045
+ die(
1046
+ "usage: install <id> [id2 ...] [--from <source>] [--link]\n或: install --from local|dir:<path> [--link](从包目录推导 id)"
1047
+ );
1048
+ }
1049
+ let absDir;
1050
+ // @ts-ignore
1051
+ if (from === "local" || from.startsWith("local:")) {
1052
+ // @ts-ignore
1053
+ const raw = from === "local" ? "" : from.slice("local:".length);
1054
+ absDir =
1055
+ !raw || raw === "." || raw === "./"
1056
+ ? path.resolve(process.cwd())
1057
+ : path.isAbsolute(raw)
1058
+ ? path.resolve(raw)
1059
+ : path.resolve(process.cwd(), raw);
1060
+ } else {
1061
+ // @ts-ignore
1062
+ absDir = path.resolve(from.slice("dir:".length));
1063
+ }
1064
+ if (!exists(absDir) || !fs.lstatSync(absDir).isDirectory()) {
1065
+ die(`无法从非目录源推导 id: ${absDir}`);
1066
+ }
1067
+ const inferred = inferFolderIdFromDir(absDir);
1068
+ if (!inferred) die(`无法从 ${absDir} 推导模块 id(需要 package.json name 或 sapi/manifest.json id)`);
1069
+ console.log(`[fetch-module] inferred id: ${inferred}`);
1070
+ ids = [inferred];
1071
+ // @ts-ignore
1072
+ if (!flags.from.startsWith("dir:") && (flags.from === "local" || flags.from.startsWith("local:"))) {
1073
+ /* 保持 local;link 时会规范成 dir: */
1074
+ // @ts-ignore
1075
+ } else if (flags.from.startsWith("dir:")) {
1076
+ /* ok */
1077
+ } else {
1078
+ // @ts-ignore
1079
+ flags.from = `dir:${absDir}`;
1080
+ }
1081
+ }
1082
+
1083
+ for (const id of ids) {
1084
+ await installOne(id, flags);
1085
+ }
1086
+ }
1087
+
1088
+ main().catch((e) => die(e?.message ?? String(e)));