diff --git a/bds-tools/is-main.test.mjs b/bds-tools/is-main.test.mjs new file mode 100644 index 00000000..f709aebd --- /dev/null +++ b/bds-tools/is-main.test.mjs @@ -0,0 +1,79 @@ +/** + * is-main 契约测试:直接入口 / shim 入口 / 被异名入口 import + * 运行: npm test -w @sfmc-bds/bds-tools(需先 build) + * + * 注:仓库根 .gitignore 忽略名为 test 的路径,故用例放在包根而非 test/。 + */ + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const pkgRoot = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.join(pkgRoot, "dist"); + +/** 与 recovery.js → dist/recovery.js 同构:根目录 shim 与 dist 模块同名 */ +const MODULE_NAME = "_sfmc_is_main_fixture.js"; +const distModule = path.join(distDir, MODULE_NAME); +const rootShim = path.join(pkgRoot, MODULE_NAME); +const alienEntry = path.join(pkgRoot, "_sfmc_alien_entry.js"); + +const moduleSrc = ` +import { isMainModule } from "./is-main.js"; +console.log(isMainModule(import.meta.url) ? "MAIN" : "NOT_MAIN"); +`; + +const shimSrc = ` +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +await import(pathToFileURL(path.join(__dirname, "dist", ${JSON.stringify(MODULE_NAME)})).href); +`; + +const alienSrc = ` +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +await import(pathToFileURL(path.join(__dirname, "dist", ${JSON.stringify(MODULE_NAME)})).href); +`; + +describe("isMainModule", () => { + it("dist 直接作为 argv 入口时为 true", () => { + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(distModule, moduleSrc); + const r = spawnSync(process.execPath, [distModule], { encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), "MAIN"); + }); + + it("同名根目录 shim 动态 import dist 时为 true(recovery.js 契约)", () => { + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(distModule, moduleSrc); + fs.writeFileSync(rootShim, shimSrc); + const r = spawnSync(process.execPath, [rootShim], { encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), "MAIN"); + }); + + it("被异名入口 import 时为 false(check-update 加载 bds-manager)", () => { + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(distModule, moduleSrc); + fs.writeFileSync(alienEntry, alienSrc); + const r = spawnSync(process.execPath, [alienEntry], { encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), "NOT_MAIN"); + }); + + after(() => { + for (const p of [distModule, rootShim, alienEntry]) { + try { + fs.unlinkSync(p); + } catch { + /* ignore */ + } + } + }); +}); diff --git a/bds-tools/package.json b/bds-tools/package.json index 853baa71..ec51949d 100644 --- a/bds-tools/package.json +++ b/bds-tools/package.json @@ -41,6 +41,7 @@ "dev": "tsx src/dist/bds-manager.ts", "typecheck": "tsc --noEmit", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", + "test": "node --test is-main.test.mjs", "update": "node dist/check-update.js", "update:check": "node dist/check-update.js --check-only", "update:force": "node dist/check-update.js --force", diff --git a/bds-tools/recovery.js b/bds-tools/recovery.js index 75422fc6..5130e020 100644 --- a/bds-tools/recovery.js +++ b/bds-tools/recovery.js @@ -1,23 +1,24 @@ /** - * recovery.js — shim, 委托给 src/recovery.ts 编译产物 + * recovery.js — shim, 委托给 dist/recovery.js * * 用法: node recovery.js + * + * 注意:bds-tools 为 ESM("type":"module"),不能再用 createRequire 加载 dist。 */ -import { createRequire } from "node:module"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); -const dist = path.join(__dirname, "dist", "recovery.js"); +const distUrl = pathToFileURL(path.join(__dirname, "dist", "recovery.js")).href; try { - require(dist); + await import(distUrl); } catch (e) { - if (e.code === "MODULE_NOT_FOUND") { + const err = /** @type {NodeJS.ErrnoException} */ (e); + if (err && (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND")) { console.error("[BDSRecovery] dist/ 未找到,请先运行 `npm run build`"); process.exit(2); } throw e; -} \ No newline at end of file +} diff --git a/bds-tools/src/bds-manager.ts b/bds-tools/src/bds-manager.ts index 1d19efaf..3f0ac16c 100644 --- a/bds-tools/src/bds-manager.ts +++ b/bds-tools/src/bds-manager.ts @@ -275,6 +275,8 @@ bdsEvents_enabled(); /** 判断当前是否作为 CLI 主入口运行(被 check-update 等 import 时为 false) */ function isMain(): boolean { + // sfmc supervisor 通过 SFMC_SERVICE 拉起子进程时优先判定(与 check-update 对称) + if (process.env.SFMC_SERVICE === "manager") return true; return isMainModule(import.meta.url); } diff --git a/bds-tools/src/is-main.ts b/bds-tools/src/is-main.ts index 804f6944..fd79b0c4 100644 --- a/bds-tools/src/is-main.ts +++ b/bds-tools/src/is-main.ts @@ -3,11 +3,34 @@ * * package.json 声明 "type": "module" 后,`require` 不存在, * 不能再用 `require.main === module`。 + * + * 契约(与历史 endsWith 行为对齐,满足 LSP): + * 1. process.argv[1] 与本文件 realpath 相同 → true + * 2. 根目录 shim(如 recovery.js → dist/recovery.js)同 stem → true + * 3. 被其他入口 import 时(argv stem 不同)→ false */ +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +function normPath(p: string): string { + return process.platform === "win32" ? p.toLowerCase() : p; +} + +/** 去掉 .js/.ts/.mjs/.cjs 等扩展,得到入口 stem */ +function entryStem(filePath: string): string { + return path.basename(filePath).replace(/\.(c|m)?(js|ts)$/i, ""); +} + +function tryRealpath(filePath: string): string { + try { + return fs.realpathSync.native(filePath); + } catch { + return filePath; + } +} + /** * 判断 metaUrl 对应模块是否为 node 进程主入口。 * 替代 CommonJS 的 `require.main === module`。 @@ -16,11 +39,11 @@ export function isMainModule(metaUrl: string): boolean { const entry = process.argv[1]; if (!entry) return false; try { - const thisFile = path.resolve(fileURLToPath(metaUrl)); - const entryFile = path.resolve(entry); - return process.platform === "win32" - ? thisFile.toLowerCase() === entryFile.toLowerCase() - : thisFile === entryFile; + const thisFile = tryRealpath(path.resolve(fileURLToPath(metaUrl))); + const entryFile = tryRealpath(path.resolve(entry)); + if (normPath(thisFile) === normPath(entryFile)) return true; + // shim 兼容:入口与本模块同 stem(recovery.js 加载 dist/recovery.js) + return normPath(entryStem(thisFile)) === normPath(entryStem(entryFile)); } catch { return false; }