@sfmc-bds/cli 0.2.0-beta.8 → 0.2.0

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