@sfmc-bds/cli 0.2.0-beta.1 → 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.
- package/dist/argv-parse.d.ts +11 -0
- package/dist/argv-parse.d.ts.map +1 -0
- package/dist/argv-parse.js +28 -0
- package/dist/argv-parse.js.map +7 -0
- package/dist/cli-gate.d.ts +11 -0
- package/dist/cli-gate.d.ts.map +1 -0
- package/dist/cli-gate.js +46 -0
- package/dist/cli-gate.js.map +7 -0
- package/dist/command-palette.d.ts +61 -0
- package/dist/command-palette.d.ts.map +1 -0
- package/dist/command-palette.js +352 -0
- package/dist/command-palette.js.map +7 -0
- package/dist/command-surface.d.ts +85 -0
- package/dist/command-surface.d.ts.map +1 -0
- package/dist/command-surface.js +292 -0
- package/dist/command-surface.js.map +7 -0
- package/dist/commands-reload.js +2 -2
- package/dist/commands-reload.js.map +2 -2
- package/dist/commands.d.ts +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +196 -21
- package/dist/commands.js.map +2 -2
- package/dist/debug-command.d.ts +5 -0
- package/dist/debug-command.d.ts.map +1 -0
- package/dist/debug-command.js +118 -0
- package/dist/debug-command.js.map +7 -0
- package/dist/help-text.d.ts +3 -0
- package/dist/help-text.d.ts.map +1 -0
- package/dist/help-text.js +160 -0
- package/dist/help-text.js.map +7 -0
- package/dist/i18n/locales/en.d.ts +93 -79
- package/dist/i18n/locales/en.d.ts.map +1 -1
- package/dist/i18n/locales/en.js +95 -81
- 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 +129 -109
- package/dist/i18n/locales/zh-CN.js.map +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +2 -2
- package/dist/llbot-launch.d.ts +20 -0
- package/dist/llbot-launch.d.ts.map +1 -0
- package/dist/llbot-launch.js +40 -0
- package/dist/llbot-launch.js.map +7 -0
- package/dist/logs.d.ts +16 -1
- package/dist/logs.d.ts.map +1 -1
- package/dist/logs.js +80 -11
- package/dist/logs.js.map +2 -2
- package/dist/main.js +55 -46
- package/dist/main.js.map +2 -2
- package/dist/module-commands.d.ts +17 -40
- package/dist/module-commands.d.ts.map +1 -1
- package/dist/module-commands.js +129 -85
- package/dist/module-commands.js.map +2 -2
- package/dist/module-pack-build.d.ts +10 -0
- package/dist/module-pack-build.d.ts.map +1 -0
- package/dist/module-pack-build.js +434 -0
- package/dist/module-pack-build.js.map +7 -0
- package/dist/module-toggle.d.ts +33 -0
- package/dist/module-toggle.d.ts.map +1 -0
- package/dist/module-toggle.js +28 -0
- package/dist/module-toggle.js.map +7 -0
- package/dist/node-service-probe.d.ts +18 -0
- package/dist/node-service-probe.d.ts.map +1 -0
- package/dist/node-service-probe.js +97 -0
- package/dist/node-service-probe.js.map +7 -0
- package/dist/pack-lifecycle.d.ts +1 -1
- package/dist/pack-lifecycle.d.ts.map +1 -1
- package/dist/pack-lifecycle.js +14 -4
- package/dist/pack-lifecycle.js.map +2 -2
- package/dist/pack-update/index.d.ts +18 -0
- package/dist/pack-update/index.d.ts.map +1 -0
- package/dist/pack-update/index.js +113 -0
- package/dist/pack-update/index.js.map +7 -0
- package/dist/registry.d.ts +7 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +15 -3
- package/dist/registry.js.map +2 -2
- package/dist/repl-windows/format-display.d.ts +10 -0
- package/dist/repl-windows/format-display.d.ts.map +1 -0
- package/dist/repl-windows/format-display.js +57 -0
- package/dist/repl-windows/format-display.js.map +7 -0
- package/dist/repl-windows/host.d.ts +60 -0
- package/dist/repl-windows/host.d.ts.map +1 -0
- package/dist/repl-windows/host.js +179 -0
- package/dist/repl-windows/host.js.map +7 -0
- package/dist/repl-windows/index.d.ts +9 -0
- package/dist/repl-windows/index.d.ts.map +1 -0
- package/dist/repl-windows/index.js +13 -0
- package/dist/repl-windows/index.js.map +7 -0
- package/dist/repl-windows/logs-filter-window.d.ts +15 -0
- package/dist/repl-windows/logs-filter-window.d.ts.map +1 -0
- package/dist/repl-windows/logs-filter-window.js +77 -0
- package/dist/repl-windows/logs-filter-window.js.map +7 -0
- package/dist/repl-windows/service-window.d.ts +4 -0
- package/dist/repl-windows/service-window.d.ts.map +1 -0
- package/dist/repl-windows/service-window.js +35 -0
- package/dist/repl-windows/service-window.js.map +7 -0
- package/dist/repl-windows/types.d.ts +56 -0
- package/dist/repl-windows/types.d.ts.map +1 -0
- package/dist/repl-windows/types.js +1 -0
- package/dist/repl-windows/types.js.map +7 -0
- package/dist/repl.d.ts +6 -9
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +698 -427
- package/dist/repl.js.map +3 -3
- package/dist/runtime.d.ts +6 -13
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +23 -28
- package/dist/runtime.js.map +2 -2
- package/dist/send-target.d.ts +11 -0
- package/dist/send-target.d.ts.map +1 -0
- package/dist/send-target.js +42 -0
- package/dist/send-target.js.map +7 -0
- package/dist/services.d.ts +50 -4
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +244 -20
- package/dist/services.js.map +2 -2
- package/dist/theme.js +1 -1
- package/dist/theme.js.map +2 -2
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +76 -16
- package/dist/wizard.js.map +2 -2
- package/dist/world-packs.d.ts.map +1 -1
- package/dist/world-packs.js +6 -3
- package/dist/world-packs.js.map +2 -2
- package/package.json +24 -21
- package/scripts/module-install/check-modules.mjs +204 -0
- package/scripts/module-install/fetch-module.mjs +1007 -0
- package/scripts/module-install/install-resolver.test.mjs +48 -0
- package/scripts/module-install/lib/catalog.mjs +96 -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 +142 -0
- package/scripts/module-install/lib/paths.mjs +48 -0
- package/scripts/module-install/lib/registry-index.mjs +50 -0
- package/scripts/module-install/link-local.test.mjs +51 -0
- package/scripts/module-install/local-resolver.test.mjs +51 -0
- package/scripts/module-install/registry-index.test.mjs +28 -0
- package/dist/module-wizard.d.ts +0 -14
- package/dist/module-wizard.d.ts.map +0 -1
- package/dist/module-wizard.js +0 -313
- package/dist/module-wizard.js.map +0 -7
- package/dist/pack-update/bindings.d.ts +0 -12
- package/dist/pack-update/bindings.d.ts.map +0 -1
- package/dist/pack-update/bindings.js +0 -64
- package/dist/pack-update/bindings.js.map +0 -7
- package/dist/pack-update/config.d.ts +0 -22
- package/dist/pack-update/config.d.ts.map +0 -1
- package/dist/pack-update/config.js +0 -141
- package/dist/pack-update/config.js.map +0 -7
- package/dist/pack-update/providers/curseforge.d.ts +0 -23
- package/dist/pack-update/providers/curseforge.d.ts.map +0 -1
- package/dist/pack-update/providers/curseforge.js +0 -229
- package/dist/pack-update/providers/curseforge.js.map +0 -7
- package/dist/pack-update/providers/index.d.ts +0 -16
- package/dist/pack-update/providers/index.d.ts.map +0 -1
- package/dist/pack-update/providers/index.js +0 -37
- package/dist/pack-update/providers/index.js.map +0 -7
- package/dist/pack-update/service.d.ts +0 -21
- package/dist/pack-update/service.d.ts.map +0 -1
- package/dist/pack-update/service.js +0 -572
- package/dist/pack-update/service.js.map +0 -7
- package/dist/pack-update/types.d.ts +0 -126
- package/dist/pack-update/types.d.ts.map +0 -1
- package/dist/pack-update/types.js +0 -8
- package/dist/pack-update/types.js.map +0 -7
- package/dist/pack-update/version-policy.d.ts +0 -81
- package/dist/pack-update/version-policy.d.ts.map +0 -1
- package/dist/pack-update/version-policy.js +0 -210
- package/dist/pack-update/version-policy.js.map +0 -7
- package/dist/remote-agent.d.ts +0 -18
- package/dist/remote-agent.d.ts.map +0 -1
- package/dist/remote-agent.js +0 -196
- 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)));
|