@sfmc-bds/cli 0.2.0-beta.0 → 0.2.0-beta.10

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