diff --git a/package.json b/package.json index 88dfb39..bab42a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.4.1", + "version": "0.4.2-alpha.0", "pnpm": { "onlyBuiltDependencies": [ "@discordjs/opus", @@ -180,5 +180,5 @@ "zustand": "^5.0.11", "zx": "^8.8.5" }, - "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" -} \ No newline at end of file + "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800" +} diff --git a/scripts/bundle-openclaw.mjs b/scripts/bundle-openclaw.mjs index d96a712..db25a67 100644 --- a/scripts/bundle-openclaw.mjs +++ b/scripts/bundle-openclaw.mjs @@ -18,6 +18,7 @@ import 'zx/globals'; import { EXTRA_BUNDLED_PACKAGES } from './openclaw-bundle-config.mjs'; +import { patchExtensionOpenClawSelfImports } from './openclaw-self-import-patch.mjs'; const ROOT = path.resolve(__dirname, '..'); const OUTPUT = path.join(ROOT, 'build', 'openclaw'); @@ -973,6 +974,11 @@ function patchBundledRuntime(outputDir) { patchBrokenModules(outputNodeModules); patchBundledRuntime(OUTPUT); +const openclawSelfImportPatch = patchExtensionOpenClawSelfImports(OUTPUT); +if (openclawSelfImportPatch.specifiersPatched > 0) { + echo` 🩹 Rewrote ${openclawSelfImportPatch.specifiersPatched} OpenClaw plugin-sdk self-import(s) in ${openclawSelfImportPatch.filesPatched} extension file(s)`; +} + // 8. Verify the bundle const entryExists = fs.existsSync(path.join(OUTPUT, 'openclaw.mjs')); const distExists = fs.existsSync(path.join(OUTPUT, 'dist', 'entry.js')); diff --git a/scripts/openclaw-self-import-patch.mjs b/scripts/openclaw-self-import-patch.mjs new file mode 100644 index 0000000..b0719ec --- /dev/null +++ b/scripts/openclaw-self-import-patch.mjs @@ -0,0 +1,127 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const OPENCLAW_PLUGIN_SDK_PREFIX = 'openclaw/plugin-sdk/'; + +const OPENCLAW_PLUGIN_SDK_SPECIFIER_RE = /(["'])openclaw\/plugin-sdk\/([^"'\r\n]+)\1/g; + +function assertSafePluginSdkSubpath(subpath) { + if (!subpath || subpath.startsWith('/') || subpath.startsWith('\\')) { + throw new Error(`Invalid OpenClaw plugin-sdk import subpath: ${JSON.stringify(subpath)}`); + } + + const parts = subpath.split(/[\\/]+/); + if (parts.some((part) => !part || part === '.' || part === '..')) { + throw new Error(`Invalid OpenClaw plugin-sdk import subpath: ${JSON.stringify(subpath)}`); + } +} + +export function toImportSpecifier(relativePath) { + const normalized = relativePath.split(path.sep).join('/'); + if (normalized.startsWith('./') || normalized.startsWith('../')) { + return normalized; + } + return `./${normalized}`; +} + +export function resolvePluginSdkTarget(distDir, subpath) { + assertSafePluginSdkSubpath(subpath); + + const targetSubpath = subpath.endsWith('.js') ? subpath : `${subpath}.js`; + return path.join(distDir, 'plugin-sdk', ...targetSubpath.split(/[\\/]+/)); +} + +export function rewriteOpenClawPluginSdkSpecifiers(content, options) { + const { filePath, distDir } = options; + let replacements = 0; + + const nextContent = content.replace( + OPENCLAW_PLUGIN_SDK_SPECIFIER_RE, + (match, quote, subpath) => { + const target = resolvePluginSdkTarget(distDir, subpath); + if (!fs.existsSync(target)) { + throw new Error( + `Cannot rewrite ${match} in ${filePath}: missing bundled SDK target ${target}`, + ); + } + + replacements++; + const relativePath = path.relative(path.dirname(filePath), target); + return `${quote}${toImportSpecifier(relativePath)}${quote}`; + }, + ); + + return { + content: nextContent, + replacements, + }; +} + +function listJavaScriptFiles(rootDir) { + const files = []; + const stack = [rootDir]; + + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(fullPath); + } + } + } + + return files; +} + +export function patchExtensionOpenClawSelfImports(outputDir) { + const distDir = path.join(outputDir, 'dist'); + const extensionsDir = path.join(distDir, 'extensions'); + if (!fs.existsSync(extensionsDir)) { + return { + filesScanned: 0, + filesPatched: 0, + specifiersPatched: 0, + }; + } + + let filesScanned = 0; + let filesPatched = 0; + let specifiersPatched = 0; + + for (const filePath of listJavaScriptFiles(extensionsDir)) { + filesScanned++; + const content = fs.readFileSync(filePath, 'utf8'); + if (!content.includes(OPENCLAW_PLUGIN_SDK_PREFIX)) { + continue; + } + + const result = rewriteOpenClawPluginSdkSpecifiers(content, { + filePath, + distDir, + }); + + if (result.replacements > 0 && result.content !== content) { + fs.writeFileSync(filePath, result.content, 'utf8'); + filesPatched++; + specifiersPatched += result.replacements; + } + } + + return { + filesScanned, + filesPatched, + specifiersPatched, + }; +} diff --git a/tests/unit/openclaw-self-import-patch.test.ts b/tests/unit/openclaw-self-import-patch.test.ts new file mode 100644 index 0000000..b1ab117 --- /dev/null +++ b/tests/unit/openclaw-self-import-patch.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + patchExtensionOpenClawSelfImports, + rewriteOpenClawPluginSdkSpecifiers, + toImportSpecifier, +} from '../../scripts/openclaw-self-import-patch.mjs'; + +const tempRoots: string[] = []; + +async function createTempOpenClawBundle(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'clawx-openclaw-self-import-')); + tempRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('openclaw self-import bundle patch', () => { + it('converts OpenClaw plugin-sdk package specifiers to bundled relative paths', async () => { + const root = await createTempOpenClawBundle(); + const distDir = path.join(root, 'dist'); + const pluginSdkDir = path.join(distDir, 'plugin-sdk'); + const extensionDir = path.join(distDir, 'extensions', 'codex'); + await mkdir(pluginSdkDir, { recursive: true }); + await mkdir(extensionDir, { recursive: true }); + await writeFile(path.join(pluginSdkDir, 'provider-model-shared.js'), 'export const ok = true;\n'); + + const promptOverlayPath = path.join(extensionDir, 'prompt-overlay.js'); + await writeFile( + promptOverlayPath, + [ + 'import { ok } from "openclaw/plugin-sdk/provider-model-shared";', + "export { ok };", + '', + ].join('\n'), + ); + + const result = patchExtensionOpenClawSelfImports(root); + + expect(result).toMatchObject({ + filesPatched: 1, + specifiersPatched: 1, + }); + await expect(readFile(promptOverlayPath, 'utf8')).resolves.toContain( + 'from "../../plugin-sdk/provider-model-shared.js"', + ); + }); + + it('leaves extension files without OpenClaw self-imports untouched', async () => { + const root = await createTempOpenClawBundle(); + const extensionDir = path.join(root, 'dist', 'extensions', 'telegram'); + await mkdir(extensionDir, { recursive: true }); + + const filePath = path.join(extensionDir, 'runtime.js'); + const source = 'export const runtime = true;\n'; + await writeFile(filePath, source); + + const result = patchExtensionOpenClawSelfImports(root); + + expect(result.filesScanned).toBe(1); + expect(result.filesPatched).toBe(0); + expect(result.specifiersPatched).toBe(0); + await expect(readFile(filePath, 'utf8')).resolves.toBe(source); + }); + + it('throws when the bundled plugin-sdk target is missing', () => { + const root = path.join(tmpdir(), 'missing-target-openclaw-bundle'); + const distDir = path.join(root, 'dist'); + const filePath = path.join(distDir, 'extensions', 'codex', 'prompt-overlay.js'); + + expect(() => rewriteOpenClawPluginSdkSpecifiers( + 'import "openclaw/plugin-sdk/provider-model-shared";', + { filePath, distDir }, + )).toThrow(/missing bundled SDK target/); + }); + + it('formats same-directory import paths with an explicit relative prefix', () => { + expect(toImportSpecifier('provider-model-shared.js')).toBe('./provider-model-shared.js'); + expect(toImportSpecifier('../plugin-sdk/provider-model-shared.js')).toBe( + '../plugin-sdk/provider-model-shared.js', + ); + }); + + it('returns an empty patch summary when the extensions directory is absent', async () => { + const root = await createTempOpenClawBundle(); + await mkdir(path.join(root, 'dist', 'plugin-sdk'), { recursive: true }); + + const result = patchExtensionOpenClawSelfImports(root); + + expect(existsSync(path.join(root, 'dist', 'extensions'))).toBe(false); + expect(result).toEqual({ + filesScanned: 0, + filesPatched: 0, + specifiersPatched: 0, + }); + }); +});