diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 54a7a5d451f..adebfcacaed 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -1,7 +1,16 @@ #!/usr/bin/env -S node --import tsx -import { execSync } from "node:child_process"; -import { readdirSync, readFileSync } from "node:fs"; +import { execFileSync, execSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { @@ -167,6 +176,77 @@ function runPackDry(): PackResult[] { return JSON.parse(raw) as PackResult[]; } +function runPack(packDestination: string): PackResult[] { + const raw = execFileSync( + "npm", + ["pack", "--json", "--ignore-scripts", "--pack-destination", packDestination], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 1024 * 1024 * 100, + }, + ); + return JSON.parse(raw) as PackResult[]; +} + +function resolvePackedTarballPath(packDestination: string, results: PackResult[]): string { + const filenames = results + .map((entry) => entry.filename) + .filter((filename): filename is string => typeof filename === "string" && filename.length > 0); + if (filenames.length !== 1) { + throw new Error( + `release-check: npm pack produced ${filenames.length} tarballs; expected exactly one.`, + ); + } + return resolve(packDestination, filenames[0]); +} + +function linkRootNodeModules(packageRoot: string): void { + const rootNodeModules = resolve("node_modules"); + if (!existsSync(rootNodeModules)) { + return; + } + symlinkSync( + rootNodeModules, + join(packageRoot, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); +} + +function runPackedBundledChannelEntrySmoke(): void { + const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-")); + try { + const packDir = join(tmpRoot, "pack"); + const extractDir = join(tmpRoot, "extract"); + mkdirSync(packDir); + mkdirSync(extractDir); + + const packResults = runPack(packDir); + const tarballPath = resolvePackedTarballPath(packDir, packResults); + execFileSync("tar", ["-xzf", tarballPath, "-C", extractDir], { stdio: "inherit" }); + + const packageRoot = join(extractDir, "package"); + linkRootNodeModules(packageRoot); + execFileSync( + process.execPath, + [ + resolve("scripts/test-built-bundled-channel-entry-smoke.mjs"), + "--package-root", + packageRoot, + ], + { + stdio: "inherit", + env: { + ...process.env, + OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", + }, + }, + ); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } +} + export function collectMissingPackPaths(paths: Iterable): string[] { const available = new Set(paths); return requiredPathGroups @@ -444,7 +524,9 @@ async function main() { process.exit(1); } - console.log("release-check: npm pack contents look OK."); + runPackedBundledChannelEntrySmoke(); + + console.log("release-check: npm pack contents and bundled channel entrypoints look OK."); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/scripts/test-built-bundled-channel-entry-smoke.mjs b/scripts/test-built-bundled-channel-entry-smoke.mjs index 603a0e55abf..b5439bddfdc 100644 --- a/scripts/test-built-bundled-channel-entry-smoke.mjs +++ b/scripts/test-built-bundled-channel-entry-smoke.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -37,10 +38,98 @@ function installProcessWarningFilter() { installProcessWarningFilter(); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +process.env.OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK ??= "1"; -async function importBuiltModule(relativePath) { - return import(pathToFileURL(path.join(repoRoot, relativePath)).href); +function parseArgs(argv) { + let packageRoot = process.env.OPENCLAW_BUNDLED_CHANNEL_SMOKE_ROOT; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--package-root") { + packageRoot = argv[index + 1]; + index += 1; + continue; + } + if (arg?.startsWith("--package-root=")) { + packageRoot = arg.slice("--package-root=".length); + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + return { + packageRoot: path.resolve( + packageRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), + ), + }; +} + +const { packageRoot } = parseArgs(process.argv.slice(2)); +const distExtensionsRoot = path.join(packageRoot, "dist", "extensions"); + +async function importBuiltModule(absolutePath) { + return import(pathToFileURL(absolutePath).href); +} + +function readJson(pathname) { + return JSON.parse(fs.readFileSync(pathname, "utf8")); +} + +function extensionEntryToDistFilename(entry) { + return entry.replace(/^\.\//u, "").replace(/\.[^.]+$/u, ".js"); +} + +function collectBundledChannelEntryFiles() { + const files = []; + for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + const extensionRoot = path.join(distExtensionsRoot, dirent.name); + const packageJsonPath = path.join(extensionRoot, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const packageJson = readJson(packageJsonPath); + if (!packageJson.openclaw?.channel) { + continue; + } + + const extensionEntries = + Array.isArray(packageJson.openclaw.extensions) && packageJson.openclaw.extensions.length > 0 + ? packageJson.openclaw.extensions + : ["./index.ts"]; + for (const entry of extensionEntries) { + if (typeof entry !== "string" || entry.trim().length === 0) { + continue; + } + files.push({ + id: dirent.name, + kind: "channel", + path: path.join(extensionRoot, extensionEntryToDistFilename(entry)), + }); + } + + const setupEntry = packageJson.openclaw.setupEntry; + if (typeof setupEntry === "string" && setupEntry.trim().length > 0) { + files.push({ + id: dirent.name, + kind: "setup", + path: path.join(extensionRoot, extensionEntryToDistFilename(setupEntry)), + }); + } + + const channelEntryPath = path.join(extensionRoot, "channel-entry.js"); + if (fs.existsSync(channelEntryPath)) { + files.push({ + id: dirent.name, + kind: "channel", + path: channelEntryPath, + }); + } + } + + return files.toSorted((left, right) => + `${left.id}:${left.kind}:${left.path}`.localeCompare(`${right.id}:${right.kind}:${right.path}`), + ); } function assertSecretContractShape(secrets, context) { @@ -56,38 +145,78 @@ function assertSecretContractShape(secrets, context) { ); } -const telegramSetupEntry = (await importBuiltModule("dist/extensions/telegram/setup-entry.js")) - .default; -assert.equal( - telegramSetupEntry.kind, - "bundled-channel-setup-entry", - "telegram setup entry kind mismatch", -); -const telegramSetupPlugin = telegramSetupEntry.loadSetupPlugin(); -assert.equal(telegramSetupPlugin?.id, "telegram", "telegram setup plugin failed to load"); -assertSecretContractShape( - telegramSetupEntry.loadSetupSecrets?.(), - "telegram setup entry packaged secrets", -); +function assertEntryFileExists(entry) { + assert.ok( + fs.existsSync(entry.path), + `${entry.id} ${entry.kind} entry missing from packed dist: ${entry.path}`, + ); +} -const telegramEntry = (await importBuiltModule("dist/extensions/telegram/index.js")).default; -assert.equal(telegramEntry.kind, "bundled-channel-entry", "telegram entry kind mismatch"); -const telegramPlugin = telegramEntry.loadChannelPlugin(); -assert.equal(telegramPlugin?.id, "telegram", "telegram channel plugin failed to load"); -assertSecretContractShape( - telegramEntry.loadChannelSecrets?.(), - "telegram channel packaged secrets", -); +async function smokeChannelEntry(entryFile) { + assertEntryFileExists(entryFile); + const entry = (await importBuiltModule(entryFile.path)).default; + assert.equal(entry.kind, "bundled-channel-entry", `${entryFile.id} channel entry kind mismatch`); + assert.equal( + typeof entry.loadChannelPlugin, + "function", + `${entryFile.id} channel entry missing loadChannelPlugin`, + ); + const plugin = entry.loadChannelPlugin(); + assert.equal(plugin?.id, entryFile.id, `${entryFile.id} channel plugin failed to load`); + if (entry.loadChannelSecrets) { + assertSecretContractShape( + entry.loadChannelSecrets(), + `${entryFile.id} channel entry packaged secrets`, + ); + } +} -const slackSetupEntry = (await importBuiltModule("dist/extensions/slack/setup-entry.js")).default; -assert.equal( - slackSetupEntry.kind, - "bundled-channel-setup-entry", - "slack setup entry kind mismatch", -); -assertSecretContractShape( - slackSetupEntry.loadSetupSecrets?.(), - "slack setup entry packaged secrets", -); +async function smokeSetupEntry(entryFile) { + assertEntryFileExists(entryFile); + const entry = (await importBuiltModule(entryFile.path)).default; + if (entry?.kind !== "bundled-channel-setup-entry") { + return false; + } + assert.equal( + entry.kind, + "bundled-channel-setup-entry", + `${entryFile.id} setup entry kind mismatch`, + ); + assert.equal( + typeof entry.loadSetupPlugin, + "function", + `${entryFile.id} setup entry missing loadSetupPlugin`, + ); + const plugin = entry.loadSetupPlugin(); + assert.equal(plugin?.id, entryFile.id, `${entryFile.id} setup plugin failed to load`); + if (entry.loadSetupSecrets) { + assertSecretContractShape( + entry.loadSetupSecrets(), + `${entryFile.id} setup entry packaged secrets`, + ); + } + return true; +} -process.stdout.write("[build-smoke] bundled channel entry smoke passed\n"); +const entryFiles = collectBundledChannelEntryFiles(); +let channelCount = 0; +let setupCount = 0; +let legacySetupCount = 0; + +for (const entryFile of entryFiles) { + if (entryFile.kind === "channel") { + await smokeChannelEntry(entryFile); + channelCount += 1; + continue; + } + if (await smokeSetupEntry(entryFile)) { + setupCount += 1; + } else { + legacySetupCount += 1; + } +} + +assert.ok(channelCount > 0, "no bundled channel entries found"); +process.stdout.write( + `[build-smoke] bundled channel entry smoke passed packageRoot=${packageRoot} channel=${channelCount} setup=${setupCount} legacySetup=${legacySetupCount}\n`, +); diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index ee2faf02c7f..042cf847cb8 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -14,6 +14,7 @@ afterEach(() => { } vi.resetModules(); vi.doUnmock("jiti"); + vi.unstubAllEnvs(); }); describe("loadBundledEntryExportSync", () => { @@ -84,4 +85,39 @@ describe("loadBundledEntryExportSync", () => { platformSpy.mockRestore(); } }); + + it("can disable source-tree fallback for dist bundled entry checks", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-entry-contract-")); + tempDirs.push(tempRoot); + + fs.writeFileSync(path.join(tempRoot, "package.json"), '{"name":"openclaw"}\n', "utf8"); + const pluginRoot = path.join(tempRoot, "dist", "extensions", "telegram"); + const sourceRoot = path.join(tempRoot, "extensions", "telegram", "src"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.mkdirSync(sourceRoot, { recursive: true }); + + const importerPath = path.join(pluginRoot, "index.js"); + fs.writeFileSync(importerPath, "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(sourceRoot, "secret-contract.ts"), + "export const sentinel = 42;\n", + "utf8", + ); + + expect( + loadBundledEntryExportSync(pathToFileURL(importerPath).href, { + specifier: "./src/secret-contract.js", + exportName: "sentinel", + }), + ).toBe(42); + + vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", "1"); + + expect(() => + loadBundledEntryExportSync(pathToFileURL(importerPath).href, { + specifier: "./src/secret-contract.js", + exportName: "sentinel", + }), + ).toThrow(`resolved "${path.join(pluginRoot, "src", "secret-contract.js")}"`); + }); }); diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 8e4323f2667..6e0d9fb3a42 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -67,6 +67,11 @@ export type BundledChannelSetupEntryContract = { const nodeRequire = createRequire(import.meta.url); const jitiLoaders = new Map>(); const loadedModuleExports = new Map(); +const disableBundledEntrySourceFallbackEnv = "OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK"; + +function isTruthyEnvFlag(value: string | undefined): boolean { + return value !== undefined && !/^(?:0|false)$/iu.test(value.trim()); +} function resolveSpecifierCandidates(modulePath: string): string[] { const ext = normalizeLowercaseStringOrEmpty(path.extname(modulePath)); @@ -140,6 +145,9 @@ function resolveBundledEntryModuleCandidates( if (!importerPath.startsWith(distExtensionsRoot)) { return candidates; } + if (isTruthyEnvFlag(process.env[disableBundledEntrySourceFallbackEnv])) { + return candidates; + } const pluginDirName = path.basename(importerDir); const sourcePluginRoot = path.join(packageRoot, "extensions", pluginDirName);