@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.
- package/LICENSE +663 -0
- package/README.md +4 -5
- package/dist/bds-runtime-version.d.ts +3 -0
- package/dist/bds-runtime-version.d.ts.map +1 -0
- package/dist/bds-runtime-version.js +15 -0
- package/dist/bds-runtime-version.js.map +7 -0
- package/dist/command-surface.d.ts.map +1 -1
- package/dist/command-surface.js +3 -1
- package/dist/command-surface.js.map +2 -2
- package/dist/commands.d.ts +57 -4
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +78 -75
- package/dist/commands.js.map +2 -2
- package/dist/directory-picker.d.ts +27 -0
- package/dist/directory-picker.d.ts.map +1 -0
- package/dist/directory-picker.js +112 -0
- package/dist/directory-picker.js.map +7 -0
- package/dist/i18n/locales/en.d.ts +25 -1
- package/dist/i18n/locales/en.d.ts.map +1 -1
- package/dist/i18n/locales/en.js +25 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/zh-CN.d.ts.map +1 -1
- package/dist/i18n/locales/zh-CN.js +26 -2
- package/dist/i18n/locales/zh-CN.js.map +2 -2
- package/dist/interactive-prompts.d.ts +1 -2
- package/dist/interactive-prompts.d.ts.map +1 -1
- package/dist/interactive-prompts.js +12 -23
- package/dist/interactive-prompts.js.map +3 -3
- package/dist/llbot-launch.d.ts +3 -3
- package/dist/llbot-launch.js.map +1 -1
- package/dist/log-filter.d.ts +18 -2
- package/dist/log-filter.d.ts.map +1 -1
- package/dist/log-filter.js +54 -4
- package/dist/log-filter.js.map +2 -2
- package/dist/log-translator.d.ts +36 -0
- package/dist/log-translator.d.ts.map +1 -0
- package/dist/log-translator.js +279 -0
- package/dist/log-translator.js.map +7 -0
- package/dist/logs.d.ts +2 -0
- package/dist/logs.d.ts.map +1 -1
- package/dist/logs.js +24 -11
- package/dist/logs.js.map +2 -2
- package/dist/main.js +5 -0
- package/dist/main.js.map +2 -2
- package/dist/module-commands.d.ts.map +1 -1
- package/dist/module-commands.js +34 -13
- package/dist/module-commands.js.map +2 -2
- package/dist/module-fingerprint.d.ts.map +1 -1
- package/dist/module-fingerprint.js +1 -0
- package/dist/module-fingerprint.js.map +2 -2
- package/dist/module-pack-build.d.ts +14 -4
- package/dist/module-pack-build.d.ts.map +1 -1
- package/dist/module-pack-build.js +81 -108
- package/dist/module-pack-build.js.map +2 -2
- package/dist/module-toggle.d.ts +1 -1
- package/dist/module-toggle.d.ts.map +1 -1
- package/dist/module-toggle.js.map +1 -1
- package/dist/pack-lifecycle.d.ts +15 -1
- package/dist/pack-lifecycle.d.ts.map +1 -1
- package/dist/pack-lifecycle.js +242 -15
- package/dist/pack-lifecycle.js.map +2 -2
- package/dist/player-session.d.ts +13 -0
- package/dist/player-session.d.ts.map +1 -0
- package/dist/player-session.js +26 -0
- package/dist/player-session.js.map +7 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -1
- package/dist/registry.js.map +2 -2
- package/dist/repl-windows/format-display.d.ts.map +1 -1
- package/dist/repl-windows/format-display.js +5 -1
- package/dist/repl-windows/format-display.js.map +2 -2
- package/dist/repl-windows/host.d.ts +6 -3
- package/dist/repl-windows/host.d.ts.map +1 -1
- package/dist/repl-windows/host.js +12 -6
- package/dist/repl-windows/host.js.map +2 -2
- package/dist/repl-windows/index.d.ts +1 -0
- package/dist/repl-windows/index.d.ts.map +1 -1
- package/dist/repl-windows/index.js +3 -0
- package/dist/repl-windows/index.js.map +2 -2
- package/dist/repl-windows/service-window.d.ts.map +1 -1
- package/dist/repl-windows/service-window.js +1 -0
- package/dist/repl-windows/service-window.js.map +2 -2
- package/dist/repl-windows/sfmc-window.d.ts +7 -0
- package/dist/repl-windows/sfmc-window.d.ts.map +1 -0
- package/dist/repl-windows/sfmc-window.js +28 -0
- package/dist/repl-windows/sfmc-window.js.map +7 -0
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +38 -13
- package/dist/repl.js.map +2 -2
- package/dist/runtime.d.ts +2 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +15 -2
- package/dist/runtime.js.map +2 -2
- package/dist/send-target.d.ts +2 -0
- package/dist/send-target.d.ts.map +1 -1
- package/dist/send-target.js +6 -0
- package/dist/send-target.js.map +2 -2
- package/dist/services.d.ts +26 -1
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +179 -45
- package/dist/services.js.map +2 -2
- package/dist/sfmc-modules-root.d.ts.map +1 -1
- package/dist/sfmc-modules-root.js +6 -2
- package/dist/sfmc-modules-root.js.map +2 -2
- package/dist/ui-command.d.ts +6 -0
- package/dist/ui-command.d.ts.map +1 -0
- package/dist/ui-command.js +68 -0
- package/dist/ui-command.js.map +7 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +36 -31
- package/dist/wizard.js.map +2 -2
- package/dist/world-packs.d.ts +2 -0
- package/dist/world-packs.d.ts.map +1 -1
- package/dist/world-packs.js +161 -18
- package/dist/world-packs.js.map +2 -2
- package/package.json +22 -15
- package/scripts/module-install/check-modules.mjs +178 -0
- package/scripts/module-install/fetch-module.mjs +1088 -0
- package/scripts/module-install/lib/catalog.mjs +96 -0
- package/scripts/module-install/lib/config-seeding.mjs +128 -0
- package/scripts/module-install/lib/io.mjs +37 -0
- package/scripts/module-install/lib/link-from.mjs +80 -0
- package/scripts/module-install/lib/lock.mjs +78 -0
- package/scripts/module-install/lib/npm-resolver.mjs +55 -0
- package/scripts/module-install/lib/packages.mjs +136 -0
- package/scripts/module-install/lib/paths.mjs +48 -0
- 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)));
|