diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecdb2bb238b..c970dfd6db6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -547,11 +547,13 @@ jobs: path: dist-runtime-build.tar.zst retention-days: 1 - - name: Upload A2UI bundle artifact + - name: Upload bundled plugin asset artifacts uses: actions/upload-artifact@v7 with: - name: canvas-a2ui-bundle - path: extensions/canvas/src/host/a2ui/ + name: bundled-plugin-assets + path: | + extensions/*/src/host/**/.bundle.hash + extensions/*/src/host/**/*.bundle.js include-hidden-files: true retention-days: 1 diff --git a/docs/refactor/canvas.md b/docs/refactor/canvas.md index 6bab7b3f414..facccf98bef 100644 --- a/docs/refactor/canvas.md +++ b/docs/refactor/canvas.md @@ -50,6 +50,8 @@ Done: - Added plugin-owned hosted media resolvers so Canvas document URLs resolve through the Canvas plugin instead of core importing Canvas document internals. - Added `api.registerNodeCliFeature(...)` so Canvas can declare `openclaw nodes canvas` as a plugin-owned node feature without manually spelling the parent command path. - Removed production `src/**` imports of `extensions/canvas/runtime-api.js`. +- Moved the A2UI bundle source from `apps/shared/OpenClawKit/Tools/CanvasA2UI` to `extensions/canvas/src/host/a2ui-app`. +- Moved A2UI build/copy implementation under `extensions/canvas/scripts` and replaced root build wiring with generic bundled-plugin asset hooks. - Kept top-level `canvasHost` as a legacy read compatibility alias while doctor repairs old configs. - Updated generated plugin inventory to include Canvas. - Added plugin reference docs at `docs/plugins/reference/canvas.md`. @@ -59,7 +61,7 @@ Known remaining core-owned Canvas surfaces: - `src/config/types.gateway.ts` and related schema labels/help retain legacy `canvasHost` read/repair compatibility - Gateway node hello and `nodes.canvasCapability.refresh` still carry `canvasHostUrl`/capability fields because native clients already speak that protocol shape - native app Canvas protocol/client handlers under `apps/` -- build/package output still copies A2UI to `dist/canvas-host/a2ui` for published artifact compatibility +- published artifact output still uses `dist/canvas-host/a2ui` for backwards-compatible runtime lookup, but the copy step is now plugin-owned ## Target shape @@ -69,6 +71,7 @@ Known remaining core-owned Canvas surfaces: - agent tool registration - node invoke command policy - Canvas host and A2UI runtime +- Canvas A2UI bundle source and asset build/copy scripts - Canvas document creation and asset resolution - Canvas CLI implementation - Canvas docs page and plugin inventory entry @@ -83,6 +86,7 @@ Core should own only generic seams: - generic hosted media resolver registration - generic node capability transport plus the existing Canvas protocol fields until native clients have a plugin-generic replacement - generic config plumbing plus the legacy `canvasHost` alias for existing Canvas config +- generic bundled-plugin asset hook discovery Native apps may keep Canvas command handlers as clients of the protocol. They are not the plugin runtime owner. @@ -104,6 +108,7 @@ Before calling the refactor complete: - `rg "canvas-documents" src` is empty. - `rg "registerNodesCanvasCommands|nodes-canvas" src` is empty; the Canvas plugin registers `openclaw nodes canvas` through nested plugin CLI metadata. - `rg "createCanvasHostHandler|handleA2uiHttpRequest" src/gateway` returns no gateway runtime ownership. +- `rg "apps/shared/OpenClawKit/Tools/CanvasA2UI|canvas-a2ui-copy|extensions/canvas/src/host/a2ui" scripts .github package.json` finds only compatibility wrappers or plugin-owned paths. - `pnpm plugins:inventory:check` passes. - `pnpm plugin-sdk:api:check` passes, or generated API baselines are intentionally updated and reviewed. - Targeted Canvas tests pass. @@ -118,7 +123,7 @@ Use targeted local checks while iterating: pnpm test extensions/canvas/src/host/server.test.ts extensions/canvas/src/host/server.state-dir.test.ts extensions/canvas/src/host/file-resolver.test.ts pnpm test src/gateway/server.plugin-node-capability-auth.test.ts src/gateway/server-import-boundary.test.ts pnpm test extensions/canvas/src/config-migration.test.ts src/commands/doctor-legacy-config.migrations.test.ts -pnpm test test/scripts/changed-lanes.test.ts test/scripts/bundle-a2ui.test.ts +pnpm test test/scripts/changed-lanes.test.ts test/scripts/build-all.test.ts test/scripts/bundle-a2ui.test.ts test/scripts/bundled-plugin-assets.test.ts src/scripts/canvas-a2ui-copy.test.ts src/infra/run-node.test.ts pnpm tsgo:extensions pnpm plugins:inventory:check pnpm plugin-sdk:api:check diff --git a/extensions/canvas/package.json b/extensions/canvas/package.json index 4e56787062d..fded840fbaf 100644 --- a/extensions/canvas/package.json +++ b/extensions/canvas/package.json @@ -13,6 +13,10 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "assetScripts": { + "build": "node scripts/bundle-a2ui.mjs", + "copy": "node scripts/copy-a2ui.mjs" + } } } diff --git a/extensions/canvas/scripts/bundle-a2ui.mjs b/extensions/canvas/scripts/bundle-a2ui.mjs new file mode 100644 index 00000000000..1407bf23647 --- /dev/null +++ b/extensions/canvas/scripts/bundle-a2ui.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolvePnpmRunner } from "../../../scripts/pnpm-runner.mjs"; + +const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(pluginDir, "../.."); +const require = createRequire(import.meta.url); +const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash"); +const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js"); +const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app"); +const rootPackageFile = path.join(rootDir, "package.json"); +const lockFile = path.join(rootDir, "pnpm-lock.yaml"); +const repoInputPaths = [rootPackageFile, lockFile, a2uiAppDir]; +const relativeRepoInputPaths = repoInputPaths.map((inputPath) => + normalizePath(path.relative(rootDir, inputPath)), +); + +function fail(message) { + console.error(message); + console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle"); + console.error("If this persists, verify pnpm deps and try again."); + process.exit(1); +} + +async function pathExists(targetPath) { + try { + await fs.stat(targetPath); + return true; + } catch { + return false; + } +} + +function normalizePath(filePath) { + return filePath.split(path.sep).join("/"); +} + +export function isBundleHashInputPath(filePath, repoRoot = rootDir) { + return Boolean(filePath && repoRoot); +} + +export function getLocalRolldownCliCandidates(repoRoot = rootDir) { + return [ + path.join(repoRoot, "node_modules", "rolldown", "bin", "cli.mjs"), + path.join(repoRoot, "node_modules", ".pnpm", "node_modules", "rolldown", "bin", "cli.mjs"), + path.join( + repoRoot, + "node_modules", + ".pnpm", + "rolldown@1.0.0-rc.12", + "node_modules", + "rolldown", + "bin", + "cli.mjs", + ), + ]; +} + +export function getBundleHashRepoInputPaths(repoRoot = rootDir) { + return [ + path.join(repoRoot, "package.json"), + path.join(repoRoot, "pnpm-lock.yaml"), + path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui-app"), + ]; +} + +export function getBundleHashInputPaths(repoRoot = rootDir) { + return getBundleHashRepoInputPaths(repoRoot); +} + +export function compareNormalizedPaths(left, right) { + const normalizedLeft = normalizePath(left); + const normalizedRight = normalizePath(right); + if (normalizedLeft < normalizedRight) { + return -1; + } + if (normalizedLeft > normalizedRight) { + return 1; + } + return 0; +} + +async function walkFiles(entryPath, files) { + if (!isBundleHashInputPath(entryPath)) { + return; + } + const stat = await fs.stat(entryPath); + if (!stat.isDirectory()) { + files.push(entryPath); + return; + } + const entries = await fs.readdir(entryPath); + for (const entry of entries) { + await walkFiles(path.join(entryPath, entry), files); + } +} + +function listTrackedInputFiles() { + const result = spawnSync("git", ["ls-files", "--", ...relativeRepoInputPaths], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + return null; + } + const trackedFiles = result.stdout + .split("\n") + .filter(Boolean) + .map((filePath) => path.join(rootDir, filePath)) + .filter((filePath) => existsSync(filePath)) + .filter((filePath) => isBundleHashInputPath(filePath)); + return trackedFiles; +} + +async function computeHash() { + let files = listTrackedInputFiles(); + if (!files) { + files = []; + for (const inputPath of getBundleHashRepoInputPaths(rootDir)) { + await walkFiles(inputPath, files); + } + } + files = [...new Set(files)].toSorted(compareNormalizedPaths); + + const hash = createHash("sha256"); + for (const filePath of files) { + hash.update(normalizePath(path.relative(rootDir, filePath))); + hash.update("\0"); + hash.update(await fs.readFile(filePath)); + hash.update("\0"); + } + return hash.digest("hex"); +} + +function runStep(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: rootDir, + env: process.env, + stdio: "inherit", + ...options, + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function runPnpm(pnpmArgs) { + const runner = resolvePnpmRunner({ + pnpmArgs, + nodeExecPath: process.execPath, + npmExecPath: process.env.npm_execpath, + comSpec: process.env.ComSpec, + platform: process.platform, + }); + runStep(runner.command, runner.args, { + shell: runner.shell, + windowsVerbatimArguments: runner.windowsVerbatimArguments, + }); +} + +async function main() { + const hasAppDir = await pathExists(a2uiAppDir); + const hasOutputFile = await pathExists(outputFile); + let hasA2uiPackage = true; + try { + require.resolve("@a2ui/lit"); + require.resolve("@a2ui/lit/ui"); + } catch { + hasA2uiPackage = false; + } + if (!hasA2uiPackage || !hasAppDir) { + if (hasOutputFile) { + console.log("A2UI package missing; keeping prebuilt bundle."); + return; + } + if (process.env.OPENCLAW_SPARSE_PROFILE || process.env.OPENCLAW_A2UI_SKIP_MISSING === "1") { + console.error( + "A2UI package missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.", + ); + return; + } + fail(`A2UI package missing and no prebuilt bundle found at: ${outputFile}`); + } + + const currentHash = await computeHash(); + if (await pathExists(hashFile)) { + const previousHash = (await fs.readFile(hashFile, "utf8")).trim(); + if (previousHash === currentHash && hasOutputFile) { + console.log("A2UI bundle up to date; skipping."); + return; + } + } + + const localRolldownCliCandidates = getLocalRolldownCliCandidates(rootDir); + const localRolldownCli = ( + await Promise.all( + localRolldownCliCandidates.map(async (candidate) => + (await pathExists(candidate)) ? candidate : null, + ), + ) + ).find(Boolean); + + if (localRolldownCli) { + runStep(process.execPath, [ + localRolldownCli, + "-c", + path.join(a2uiAppDir, "rolldown.config.mjs"), + ]); + } else { + runPnpm(["-s", "exec", "rolldown", "-c", path.join(a2uiAppDir, "rolldown.config.mjs")]); + } + + await fs.writeFile(hashFile, `${currentHash}\n`, "utf8"); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); + }); +} diff --git a/scripts/canvas-a2ui-copy.ts b/extensions/canvas/scripts/copy-a2ui.mjs similarity index 72% rename from scripts/canvas-a2ui-copy.ts rename to extensions/canvas/scripts/copy-a2ui.mjs index 5f6e3f699ec..441953fd844 100644 --- a/scripts/canvas-a2ui-copy.ts +++ b/extensions/canvas/scripts/copy-a2ui.mjs @@ -1,21 +1,23 @@ +#!/usr/bin/env node + import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(pluginDir, "../.."); function getA2uiPaths(env = process.env) { - const srcDir = - env.OPENCLAW_A2UI_SRC_DIR ?? path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui"); - const outDir = env.OPENCLAW_A2UI_OUT_DIR ?? path.join(repoRoot, "dist", "canvas-host", "a2ui"); + const srcDir = env.OPENCLAW_A2UI_SRC_DIR ?? path.join(pluginDir, "src", "host", "a2ui"); + const outDir = env.OPENCLAW_A2UI_OUT_DIR ?? path.join(rootDir, "dist", "canvas-host", "a2ui"); return { srcDir, outDir }; } -function shouldSkipMissingA2uiAssets(env = process.env): boolean { +function shouldSkipMissingA2uiAssets(env = process.env) { return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE); } -export async function copyA2uiAssets({ srcDir, outDir }: { srcDir: string; outDir: string }) { +export async function copyA2uiAssets({ srcDir, outDir }) { const skipMissing = shouldSkipMissingA2uiAssets(process.env); try { await fs.stat(path.join(srcDir, "index.html")); diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js b/extensions/canvas/src/host/a2ui-app/bootstrap.js similarity index 87% rename from apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js rename to extensions/canvas/src/host/a2ui-app/bootstrap.js index b2d03165aa3..dfebeaed277 100644 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js +++ b/extensions/canvas/src/host/a2ui-app/bootstrap.js @@ -1,10 +1,9 @@ -import { html, css, LitElement, unsafeCSS } from "lit"; -import { repeat } from "lit/directives/repeat.js"; -import { ContextProvider } from "@lit/context"; - import { v0_8 } from "@a2ui/lit"; -import "@a2ui/lit/ui"; +import { ContextProvider } from "@lit/context"; import { themeContext } from "@openclaw/a2ui-theme-context"; +import { html, css, LitElement, unsafeCSS } from "lit"; +import "@a2ui/lit/ui"; +import { repeat } from "lit/directives/repeat.js"; const modalStyles = css` dialog { @@ -97,8 +96,12 @@ const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {} const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? ""); const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)"; -const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)"; -const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)"; +const buttonShadow = isAndroid + ? "0 2px 10px rgba(6, 182, 212, 0.14)" + : "0 10px 25px rgba(6, 182, 212, 0.18)"; +const statusShadow = isAndroid + ? "0 2px 10px rgba(0, 0, 0, 0.18)" + : "0 10px 24px rgba(0, 0, 0, 0.25)"; const statusBlur = isAndroid ? "10px" : "14px"; const openclawTheme = { @@ -125,7 +128,11 @@ const openclawTheme = { MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, Row: emptyClasses(), Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, - Tabs: { container: emptyClasses(), element: emptyClasses(), controls: { all: emptyClasses(), selected: emptyClasses() } }, + Tabs: { + container: emptyClasses(), + element: emptyClasses(), + controls: { all: emptyClasses(), selected: emptyClasses() }, + }, Text: { all: emptyClasses(), h1: emptyClasses(), @@ -235,11 +242,8 @@ class OpenClawA2UIHost extends LitElement { height: 100%; position: relative; box-sizing: border-box; - padding: - var(--openclaw-a2ui-inset-top, 0px) - var(--openclaw-a2ui-inset-right, 0px) - var(--openclaw-a2ui-inset-bottom, 0px) - var(--openclaw-a2ui-inset-left, 0px); + padding: var(--openclaw-a2ui-inset-top, 0px) var(--openclaw-a2ui-inset-right, 0px) + var(--openclaw-a2ui-inset-bottom, 0px) var(--openclaw-a2ui-inset-left, 0px); } #surfaces { @@ -264,7 +268,12 @@ class OpenClawA2UIHost extends LitElement { background: rgba(0, 0, 0, 0.45); border: 1px solid rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.92); - font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif; + font: + 13px/1.2 system-ui, + -apple-system, + BlinkMacSystemFont, + "Roboto", + sans-serif; pointer-events: none; backdrop-filter: blur(${unsafeCSS(statusBlur)}); -webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)}); @@ -285,7 +294,12 @@ class OpenClawA2UIHost extends LitElement { background: rgba(0, 0, 0, 0.45); border: 1px solid rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.92); - font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif; + font: + 13px/1.2 system-ui, + -apple-system, + BlinkMacSystemFont, + "Roboto", + sans-serif; pointer-events: none; backdrop-filter: blur(${unsafeCSS(statusBlur)}); -webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)}); @@ -360,7 +374,10 @@ class OpenClawA2UIHost extends LitElement { } #makeActionId() { - return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`; + return ( + globalThis.crypto?.randomUUID?.() ?? + `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}` + ); } #setToast(text, kind = "ok", timeoutMs = 1400) { @@ -377,8 +394,12 @@ class OpenClawA2UIHost extends LitElement { #handleActionStatus(evt) { const detail = evt?.detail ?? null; - if (!detail || typeof detail.id !== "string") {return;} - if (!this.pendingAction || this.pendingAction.id !== detail.id) {return;} + if (!detail || typeof detail.id !== "string") { + return; + } + if (!this.pendingAction || this.pendingAction.id !== detail.id) { + return; + } if (detail.ok) { this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() }; @@ -421,7 +442,9 @@ class OpenClawA2UIHost extends LitElement { for (const item of ctxItems) { const key = item?.key; const value = item?.value ?? null; - if (!key || !value) {continue;} + if (!key || !value) { + continue; + } if (typeof value.path === "string") { const resolved = sourceNode @@ -474,11 +497,23 @@ class OpenClawA2UIHost extends LitElement { } } catch (e) { const msg = String(e?.message ?? e); - this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg }; + this.pendingAction = { + id: actionId, + name, + phase: "error", + startedAt: Date.now(), + error: msg, + }; this.#setToast(`Failed: ${msg}`, "error", 4500); } } else { - this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" }; + this.pendingAction = { + id: actionId, + name, + phase: "error", + startedAt: Date.now(), + error: "missing native bridge", + }; this.#setToast("Failed: missing native bridge", "error", 4500); } } @@ -525,24 +560,28 @@ class OpenClawA2UIHost extends LitElement { ? `Failed: ${this.pendingAction.name}` : ""; - return html` - ${this.pendingAction && this.pendingAction.phase !== "error" - ? html`
${statusText}
` + return html` ${this.pendingAction && this.pendingAction.phase !== "error" + ? html`
+
+
${statusText}
+
` : ""} ${this.toast - ? html`
${this.toast.text}
` + ? html`
+ ${this.toast.text} +
` : ""}
- ${repeat( - this.surfaces, - ([surfaceId]) => surfaceId, - ([surfaceId, surface]) => html`` - )} -
`; + ${repeat( + this.surfaces, + ([surfaceId]) => surfaceId, + ([surfaceId, surface]) => html``, + )} + `; } } diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs b/extensions/canvas/src/host/a2ui-app/rolldown.config.mjs similarity index 93% rename from apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs rename to extensions/canvas/src/host/a2ui-app/rolldown.config.mjs index e6df498e7ef..ab9cbfa64cc 100644 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs +++ b/extensions/canvas/src/host/a2ui-app/rolldown.config.mjs @@ -1,6 +1,6 @@ -import path from "node:path"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; +import path from "node:path"; import { fileURLToPath } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -8,16 +8,7 @@ const repoRoot = path.resolve(here, "../../../../.."); const require = createRequire(import.meta.url); const uiRoot = path.resolve(repoRoot, "ui"); const fromHere = (p) => path.resolve(here, p); -const outputFile = path.resolve( - here, - "../../../../..", - "extensions", - "canvas", - "src", - "host", - "a2ui", - "a2ui.bundle.js", -); +const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js"); const a2uiLitIndex = require.resolve("@a2ui/lit"); const a2uiLitUi = require.resolve("@a2ui/lit/ui"); diff --git a/extensions/canvas/src/host/a2ui/.bundle.hash b/extensions/canvas/src/host/a2ui/.bundle.hash index e9037560e88..d23f308a998 100644 --- a/extensions/canvas/src/host/a2ui/.bundle.hash +++ b/extensions/canvas/src/host/a2ui/.bundle.hash @@ -1 +1 @@ -8e45568ff64c7d2fd95957f076b635b6df99f115d1ee92e75ea63916566adb48 +992142e47ead0d7fb084464cc70f9752f2a7ffb8921598a94a02adaff0fc683c diff --git a/package.json b/package.json index c4579453f15..634f0ab0181 100644 --- a/package.json +++ b/package.json @@ -1302,10 +1302,10 @@ "audit:seams": "node scripts/audit-seams.mjs", "build": "node scripts/build-all.mjs", "build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts", - "build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm plugins:assets:copy && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true", "build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts", - "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs", + "build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs", "canon:check": "node scripts/canon.mjs check", "canon:check:json": "node scripts/canon.mjs check --json", "canon:enforce": "node scripts/canon.mjs enforce --json", @@ -1458,6 +1458,8 @@ "plugins:boundary-report:ci": "node --import tsx scripts/plugin-boundary-report.ts --summary --fail-on-cross-owner --fail-on-unclassified-unused-reserved --fail-on-eligible-compat", "plugins:boundary-report:json": "node --import tsx scripts/plugin-boundary-report.ts --json", "plugins:boundary-report:summary": "node --import tsx scripts/plugin-boundary-report.ts --summary", + "plugins:assets:build": "node scripts/bundled-plugin-assets.mjs --phase build", + "plugins:assets:copy": "node scripts/bundled-plugin-assets.mjs --phase copy", "plugins:inventory:check": "node scripts/generate-plugin-inventory-doc.mjs --check", "plugins:inventory:gen": "node scripts/generate-plugin-inventory-doc.mjs --write", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 4e943b861f3..377e3684853 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -11,7 +11,7 @@ const nodeBin = process.execPath; const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096; const BUILD_CACHE_VERSION = 2; export const BUILD_ALL_STEPS = [ - { label: "canvas:a2ui:bundle", kind: "pnpm", pnpmArgs: ["canvas:a2ui:bundle"] }, + { label: "plugins:assets:build", kind: "pnpm", pnpmArgs: ["plugins:assets:build"] }, { label: "tsdown", kind: "node", args: ["scripts/tsdown-build.mjs"] }, { label: "check-cli-bootstrap-imports", @@ -53,13 +53,9 @@ export const BUILD_ALL_STEPS = [ args: ["scripts/check-plugin-sdk-exports.mjs"], }, { - label: "canvas-a2ui-copy", - kind: "node", - args: ["--import", "tsx", "scripts/canvas-a2ui-copy.ts"], - cache: { - inputs: ["scripts/canvas-a2ui-copy.ts", "extensions/canvas/src/host/a2ui"], - outputs: ["dist/canvas-host/a2ui/index.html", "dist/canvas-host/a2ui/a2ui.bundle.js"], - }, + label: "plugins:assets:copy", + kind: "pnpm", + pnpmArgs: ["plugins:assets:copy"], }, { label: "copy-hook-metadata", @@ -99,7 +95,7 @@ export const BUILD_ALL_STEPS = [ export const BUILD_ALL_PROFILES = { full: BUILD_ALL_STEPS.map((step) => step.label), ciArtifacts: [ - "canvas:a2ui:bundle", + "plugins:assets:build", "tsdown", "check-cli-bootstrap-imports", "runtime-postbuild", @@ -108,7 +104,7 @@ export const BUILD_ALL_PROFILES = { "build:plugin-sdk:dts", "write-plugin-sdk-entry-dts", "check-plugin-sdk-exports", - "canvas-a2ui-copy", + "plugins:assets:copy", "copy-hook-metadata", "copy-export-html-templates", "write-build-info", diff --git a/scripts/bundle-a2ui.mjs b/scripts/bundle-a2ui.mjs index 46c7699fff8..ae2ea6439ed 100644 --- a/scripts/bundle-a2ui.mjs +++ b/scripts/bundle-a2ui.mjs @@ -1,235 +1,8 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { existsSync } from "node:fs"; -import fs from "node:fs/promises"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { resolvePnpmRunner } from "./pnpm-runner.mjs"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const require = createRequire(import.meta.url); -const hashFile = path.join(rootDir, "extensions", "canvas", "src", "host", "a2ui", ".bundle.hash"); -const outputFile = path.join( - rootDir, - "extensions", - "canvas", - "src", - "host", - "a2ui", - "a2ui.bundle.js", -); -const a2uiAppDir = path.join(rootDir, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"); -const rootPackageFile = path.join(rootDir, "package.json"); -const lockFile = path.join(rootDir, "pnpm-lock.yaml"); -const repoInputPaths = [rootPackageFile, lockFile, a2uiAppDir]; -const relativeRepoInputPaths = repoInputPaths.map((inputPath) => - normalizePath(path.relative(rootDir, inputPath)), -); - -function fail(message) { - console.error(message); - console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle"); - console.error("If this persists, verify pnpm deps and try again."); - process.exit(1); -} - -async function pathExists(targetPath) { - try { - await fs.stat(targetPath); - return true; - } catch { - return false; - } -} - -function normalizePath(filePath) { - return filePath.split(path.sep).join("/"); -} - -export function isBundleHashInputPath(filePath, repoRoot = rootDir) { - return Boolean(filePath && repoRoot); -} - -export function getLocalRolldownCliCandidates(repoRoot = rootDir) { - return [ - path.join(repoRoot, "node_modules", "rolldown", "bin", "cli.mjs"), - path.join(repoRoot, "node_modules", ".pnpm", "node_modules", "rolldown", "bin", "cli.mjs"), - path.join( - repoRoot, - "node_modules", - ".pnpm", - "rolldown@1.0.0-rc.12", - "node_modules", - "rolldown", - "bin", - "cli.mjs", - ), - ]; -} - -export function getBundleHashRepoInputPaths(repoRoot = rootDir) { - return [ - path.join(repoRoot, "package.json"), - path.join(repoRoot, "pnpm-lock.yaml"), - path.join(repoRoot, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"), - ]; -} - -export function getBundleHashInputPaths(repoRoot = rootDir) { - return getBundleHashRepoInputPaths(repoRoot); -} - -export function compareNormalizedPaths(left, right) { - const normalizedLeft = normalizePath(left); - const normalizedRight = normalizePath(right); - if (normalizedLeft < normalizedRight) { - return -1; - } - if (normalizedLeft > normalizedRight) { - return 1; - } - return 0; -} - -async function walkFiles(entryPath, files) { - if (!isBundleHashInputPath(entryPath)) { - return; - } - const stat = await fs.stat(entryPath); - if (!stat.isDirectory()) { - files.push(entryPath); - return; - } - const entries = await fs.readdir(entryPath); - for (const entry of entries) { - await walkFiles(path.join(entryPath, entry), files); - } -} - -function listTrackedInputFiles() { - const result = spawnSync("git", ["ls-files", "--", ...relativeRepoInputPaths], { - cwd: rootDir, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.status !== 0) { - return null; - } - const trackedFiles = result.stdout - .split("\n") - .filter(Boolean) - .map((filePath) => path.join(rootDir, filePath)) - .filter((filePath) => existsSync(filePath)) - .filter((filePath) => isBundleHashInputPath(filePath)); - return trackedFiles; -} - -async function computeHash() { - let files = listTrackedInputFiles(); - if (!files) { - files = []; - for (const inputPath of getBundleHashRepoInputPaths(rootDir)) { - await walkFiles(inputPath, files); - } - } - files = [...new Set(files)].toSorted(compareNormalizedPaths); - - const hash = createHash("sha256"); - for (const filePath of files) { - hash.update(normalizePath(path.relative(rootDir, filePath))); - hash.update("\0"); - hash.update(await fs.readFile(filePath)); - hash.update("\0"); - } - return hash.digest("hex"); -} - -function runStep(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: rootDir, - stdio: "inherit", - env: process.env, - ...options, - }); - if (result.status !== 0) { - process.exit(result.status ?? 1); - } -} - -function runPnpm(pnpmArgs) { - const runner = resolvePnpmRunner({ - pnpmArgs, - nodeExecPath: process.execPath, - npmExecPath: process.env.npm_execpath, - comSpec: process.env.ComSpec, - platform: process.platform, - }); - runStep(runner.command, runner.args, { - shell: runner.shell, - windowsVerbatimArguments: runner.windowsVerbatimArguments, - }); -} - -async function main() { - const hasAppDir = await pathExists(a2uiAppDir); - const hasOutputFile = await pathExists(outputFile); - let hasA2uiPackage = true; - try { - require.resolve("@a2ui/lit"); - require.resolve("@a2ui/lit/ui"); - } catch { - hasA2uiPackage = false; - } - if (!hasA2uiPackage || !hasAppDir) { - if (hasOutputFile) { - console.log("A2UI package missing; keeping prebuilt bundle."); - return; - } - if (process.env.OPENCLAW_SPARSE_PROFILE || process.env.OPENCLAW_A2UI_SKIP_MISSING === "1") { - console.error( - "A2UI package missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.", - ); - return; - } - fail(`A2UI package missing and no prebuilt bundle found at: ${outputFile}`); - } - - const currentHash = await computeHash(); - if (await pathExists(hashFile)) { - const previousHash = (await fs.readFile(hashFile, "utf8")).trim(); - if (previousHash === currentHash && hasOutputFile) { - console.log("A2UI bundle up to date; skipping."); - return; - } - } - - const localRolldownCliCandidates = getLocalRolldownCliCandidates(rootDir); - const localRolldownCli = ( - await Promise.all( - localRolldownCliCandidates.map(async (candidate) => - (await pathExists(candidate)) ? candidate : null, - ), - ) - ).find(Boolean); - - if (localRolldownCli) { - runStep(process.execPath, [ - localRolldownCli, - "-c", - path.join(a2uiAppDir, "rolldown.config.mjs"), - ]); - } else { - runPnpm(["-s", "exec", "rolldown", "-c", path.join(a2uiAppDir, "rolldown.config.mjs")]); - } - - await fs.writeFile(hashFile, `${currentHash}\n`, "utf8"); -} +import { pathToFileURL } from "node:url"; +import { runBundledPluginAssetHooks } from "./bundled-plugin-assets.mjs"; if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main().catch((error) => { - fail(error instanceof Error ? error.message : String(error)); - }); + await runBundledPluginAssetHooks({ phase: "build", plugins: ["canvas"] }); } diff --git a/scripts/bundled-plugin-assets.mjs b/scripts/bundled-plugin-assets.mjs new file mode 100644 index 00000000000..4261a5eac94 --- /dev/null +++ b/scripts/bundled-plugin-assets.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const VALID_PHASES = new Set(["build", "copy"]); + +async function readJsonFile(filePath) { + return JSON.parse(await fs.readFile(filePath, "utf8")); +} + +async function pathExists(filePath) { + try { + await fs.stat(filePath); + return true; + } catch { + return false; + } +} + +function packagePluginAliases(packageName) { + if (typeof packageName !== "string") { + return []; + } + const aliases = [packageName]; + const unscopedName = packageName.split("/").at(-1); + if (unscopedName) { + aliases.push(unscopedName); + if (unscopedName.endsWith("-plugin")) { + aliases.push(unscopedName.slice(0, -"-plugin".length)); + } + } + return aliases; +} + +async function resolvePluginAliases(pluginDir, packageJson) { + const aliases = new Set([path.basename(pluginDir), ...packagePluginAliases(packageJson.name)]); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + if (await pathExists(manifestPath)) { + const manifest = await readJsonFile(manifestPath); + if (typeof manifest.id === "string" && manifest.id) { + aliases.add(manifest.id); + } + } + return aliases; +} + +function resolveAssetCommand(packageJson, phase) { + const assetScripts = packageJson.openclaw?.assetScripts; + if (!assetScripts || typeof assetScripts !== "object") { + return null; + } + const command = assetScripts[phase]; + return typeof command === "string" && command.trim() ? command.trim() : null; +} + +export async function readBundledPluginAssetHooks(options = {}) { + const repoRoot = options.rootDir ?? rootDir; + const phase = options.phase; + if (!VALID_PHASES.has(phase)) { + throw new Error(`Unsupported bundled plugin asset phase: ${String(phase)}`); + } + + const pluginFilters = new Set((options.plugins ?? []).filter(Boolean)); + const extensionsDir = path.join(repoRoot, "extensions"); + let entries; + try { + entries = await fs.readdir(extensionsDir, { withFileTypes: true }); + } catch { + return []; + } + + const hooks = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const pluginDir = path.join(extensionsDir, entry.name); + const packagePath = path.join(pluginDir, "package.json"); + if (!(await pathExists(packagePath))) { + continue; + } + + const packageJson = await readJsonFile(packagePath); + const aliases = await resolvePluginAliases(pluginDir, packageJson); + if (pluginFilters.size > 0 && ![...pluginFilters].some((plugin) => aliases.has(plugin))) { + continue; + } + + const command = resolveAssetCommand(packageJson, phase); + if (!command) { + continue; + } + + hooks.push({ + aliases: [...aliases].toSorted(), + command, + packageName: packageJson.name, + phase, + pluginDir, + pluginId: aliases.has(entry.name) ? entry.name : [...aliases][0], + }); + } + + return hooks.toSorted((left, right) => left.pluginDir.localeCompare(right.pluginDir)); +} + +export async function runBundledPluginAssetHooks(options = {}) { + const phase = options.phase; + const hooks = await readBundledPluginAssetHooks(options); + if (hooks.length === 0) { + const scope = options.plugins?.length ? ` for ${options.plugins.join(", ")}` : ""; + console.log(`No bundled plugin asset ${phase} hooks${scope}; skipping.`); + return; + } + + for (const hook of hooks) { + console.log(`[${hook.pluginId}] ${phase}: ${hook.command}`); + const result = spawnSync(hook.command, { + cwd: hook.pluginDir, + env: process.env, + shell: true, + stdio: "inherit", + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } +} + +export function parseBundledPluginAssetArgs(argv) { + const args = [...argv]; + const plugins = []; + let phase = null; + + while (args.length > 0) { + const arg = args.shift(); + if (arg === "--phase") { + phase = args.shift() ?? null; + continue; + } + if (arg?.startsWith("--phase=")) { + phase = arg.slice("--phase=".length); + continue; + } + if (arg === "--plugin") { + const plugin = args.shift(); + if (plugin) { + plugins.push(plugin); + } + continue; + } + if (arg?.startsWith("--plugin=")) { + plugins.push(arg.slice("--plugin=".length)); + continue; + } + throw new Error(`Unknown bundled plugin asset argument: ${String(arg)}`); + } + + if (!VALID_PHASES.has(phase)) { + throw new Error(`Expected --phase ${[...VALID_PHASES].join("|")}`); + } + + return { phase, plugins }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + await runBundledPluginAssetHooks(parseBundledPluginAssetArgs(process.argv.slice(2))); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index 1b6c71a5365..81a3c66d67f 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -13,7 +13,6 @@ const TOOLING_PATH_RE = const ROOT_GLOBAL_PATH_RE = /^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u; const LEGACY_ROOT_ASSET_PATH_RE = /^assets\//u; -const CANVAS_A2UI_BUNDLE_INPUT_RE = /^apps\/shared\/OpenClawKit\/Tools\/CanvasA2UI\//u; const LIVE_DOCKER_TOOLING_PATH_RE = /^(?:scripts\/test-docker-all\.mjs|scripts\/test-docker-all\.sh|scripts\/lib\/live-docker-auth\.sh|scripts\/test-live-(?:acp-bind|cli-backend|codex-harness|gateway-models|models)-docker\.sh|src\/gateway\/gateway-acp-bind\.live\.test\.ts|src\/gateway\/live-agent-probes\.test\.ts)$/u; const LIVE_DOCKER_PACKAGE_SCRIPT_RE = /^test:docker:live-[\w:-]+$/u; @@ -172,14 +171,6 @@ export function detectChangedLanes(changedPaths, options = {}) { continue; } - if (CANVAS_A2UI_BUNDLE_INPUT_RE.test(changedPath)) { - lanes.core = true; - lanes.coreTests = true; - lanes.tooling = true; - reasons.push(`${changedPath}: CanvasA2UI bundle input`); - continue; - } - if (APP_PATH_RE.test(changedPath)) { lanes.apps = true; reasons.push(`${changedPath}: app surface`); diff --git a/scripts/e2e/lib/parallels-package-common.sh b/scripts/e2e/lib/parallels-package-common.sh index 262f4748250..cffd29f8c71 100644 --- a/scripts/e2e/lib/parallels-package-common.sh +++ b/scripts/e2e/lib/parallels-package-common.sh @@ -48,7 +48,7 @@ parallels_package_write_dist_inventory() { parallels_package_assert_no_generated_drift() { local drift - drift="$(git status --porcelain -- extensions/canvas/src/host/a2ui/.bundle.hash 2>/dev/null || true)" + drift="$(git status --porcelain -- ':(glob)extensions/*/src/host/**/.bundle.hash' 2>/dev/null || true)" if [[ -z "$drift" ]]; then return 0 fi diff --git a/scripts/e2e/parallels/package-artifact.ts b/scripts/e2e/parallels/package-artifact.ts index db194ec6e56..eb5cae6dee2 100644 --- a/scripts/e2e/parallels/package-artifact.ts +++ b/scripts/e2e/parallels/package-artifact.ts @@ -107,7 +107,7 @@ async function ensureCurrentBuildUnlocked(input: { } const drift = run( "git", - ["status", "--porcelain", "--", "extensions/canvas/src/host/a2ui/.bundle.hash"], + ["status", "--porcelain", "--", ":(glob)extensions/*/src/host/**/.bundle.hash"], { quiet: true, }, diff --git a/scripts/pre-commit/filter-staged-files.mjs b/scripts/pre-commit/filter-staged-files.mjs index e8587f8b7b7..2206a0240ce 100644 --- a/scripts/pre-commit/filter-staged-files.mjs +++ b/scripts/pre-commit/filter-staged-files.mjs @@ -22,14 +22,14 @@ if (mode !== "lint" && mode !== "format") { const lintExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); const formatExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".md", ".mdx"]); -const formatIgnoredPaths = new Set(["extensions/canvas/src/host/a2ui/a2ui.bundle.js"]); +const formatIgnoredPathPatterns = [/^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u]; const shouldSelect = (filePath) => { const ext = path.extname(filePath).toLowerCase(); if (mode === "lint") { return lintExts.has(ext); } - if (formatIgnoredPaths.has(filePath)) { + if (formatIgnoredPathPatterns.some((pattern) => pattern.test(filePath))) { return false; } return formatExts.has(ext); diff --git a/scripts/prepush-ci.sh b/scripts/prepush-ci.sh index bc7c121ba9b..cd0796f8bff 100644 --- a/scripts/prepush-ci.sh +++ b/scripts/prepush-ci.sh @@ -54,7 +54,7 @@ run_linux_ci_mirror() { run_step pnpm build:strict-smoke run_step pnpm lint:ui:no-raw-window-open run_protocol_ci_mirror - run_step pnpm canvas:a2ui:bundle + run_step pnpm plugins:assets:build run_step node scripts/run-vitest.mjs run --config test/vitest/vitest.extensions.config.ts --maxWorkers=1 run_step env CI=true node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --maxWorkers=1 diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index ba1aab336b6..444c34c50ab 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -153,8 +153,8 @@ log "==> Killing existing OpenClaw instances" kill_all_openclaw stop_launch_agent -# Bundle Gateway-hosted Canvas A2UI assets. -run_step "bundle canvas a2ui" bash -lc "cd '${ROOT_DIR}' && pnpm canvas:a2ui:bundle" +# Bundle Gateway-hosted plugin assets. +run_step "bundle plugin assets" bash -lc "cd '${ROOT_DIR}' && pnpm plugins:assets:build" # 2) Rebuild into the same path the packager consumes (.build). run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true" diff --git a/scripts/run-node-watch-paths.mjs b/scripts/run-node-watch-paths.mjs index e1ec9f8ea23..c92fef8a3f3 100644 --- a/scripts/run-node-watch-paths.mjs +++ b/scripts/run-node-watch-paths.mjs @@ -9,10 +9,10 @@ export const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.conf export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; export const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); -const ignoredRunNodeRepoPaths = new Set([ - "extensions/canvas/src/host/a2ui/.bundle.hash", - "extensions/canvas/src/host/a2ui/a2ui.bundle.js", -]); +const ignoredRunNodeRepoPathPatterns = [ + /^extensions\/[^/]+\/src\/host\/.+\/\.bundle\.hash$/u, + /^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u, +]; const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; export const normalizeRunNodePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); @@ -41,7 +41,7 @@ const isRestartRelevantExtensionPath = (relativePath) => { const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => { const normalizedPath = normalizeRunNodePath(repoPath).replace(/^\.\/+/, ""); - if (ignoredRunNodeRepoPaths.has(normalizedPath)) { + if (ignoredRunNodeRepoPathPatterns.some((pattern) => pattern.test(normalizedPath))) { return false; } if (runNodeConfigFiles.includes(normalizedPath)) { diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index d0a9764aba6..22589fe73d1 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -374,6 +374,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/blacksmith-testbox-state.mjs", ["test/scripts/blacksmith-testbox-state.test.ts"]], ["scripts/blacksmith-testbox-runner.mjs", ["test/scripts/blacksmith-testbox-runner.test.ts"]], ["scripts/testbox-sync-sanity.mjs", ["test/scripts/testbox-sync-sanity.test.ts"]], + ["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], + ["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], + ["extensions/canvas/scripts/bundle-a2ui.mjs", ["test/scripts/bundle-a2ui.test.ts"]], + ["extensions/canvas/scripts/copy-a2ui.mjs", ["src/scripts/canvas-a2ui-copy.test.ts"]], ]); const TOOLING_TEST_TARGETS = new Map([ ["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]], @@ -494,10 +498,10 @@ const SOURCE_TEST_TARGETS = new Map([ ["src/auto-reply/reply/dispatch-acp-command-bypass.test.ts"], ], ]); -const GENERATED_CHANGED_TEST_TARGETS = new Set([ - "extensions/canvas/src/host/a2ui/.bundle.hash", - "extensions/canvas/src/host/a2ui/a2ui.bundle.js", -]); +const GENERATED_CHANGED_TEST_TARGET_PATTERNS = [ + /^extensions\/[^/]+\/src\/host\/.+\/\.bundle\.hash$/u, + /^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u, +]; const SOURCE_ROOTS_FOR_IMPORT_GRAPH = ["src", "extensions", "packages", "ui/src", "test"]; const IMPORTABLE_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; const IMPORT_SPECIFIER_PATTERN = @@ -939,7 +943,7 @@ function shouldUseBroadChangedTargets(env = process.env) { } function isRoutableChangedTarget(changedPath) { - if (GENERATED_CHANGED_TEST_TARGETS.has(changedPath)) { + if (GENERATED_CHANGED_TEST_TARGET_PATTERNS.some((pattern) => pattern.test(changedPath))) { return false; } if (changedPath.endsWith(".live.test.ts")) { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index ec9c18a1dbe..20088b8225d 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -24,8 +24,8 @@ const ROOT_SRC = "src/index.ts"; const ROOT_TSCONFIG = "tsconfig.json"; const ROOT_PACKAGE = "package.json"; const ROOT_TSDOWN = "tsdown.config.ts"; -const GENERATED_A2UI_BUNDLE = "extensions/canvas/src/host/a2ui/a2ui.bundle.js"; -const GENERATED_A2UI_BUNDLE_HASH = "extensions/canvas/src/host/a2ui/.bundle.hash"; +const GENERATED_PLUGIN_ASSET_BUNDLE = "extensions/demo/src/host/assets/view.bundle.js"; +const GENERATED_PLUGIN_ASSET_BUNDLE_HASH = "extensions/demo/src/host/assets/.bundle.hash"; const DIST_ENTRY = "dist/entry.js"; const BUILD_STAMP = `dist/${BUILD_STAMP_FILE}`; const RUNTIME_POSTBUILD_STAMP = `dist/${RUNTIME_POSTBUILD_STAMP_FILE}`; @@ -1478,7 +1478,7 @@ describe("run-node script", () => { }); }); - it("ignores dirty generated A2UI bundle artifacts when dist is current", async () => { + it("ignores dirty generated plugin bundle artifacts when dist is current", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { files: { @@ -1491,7 +1491,7 @@ describe("run-node script", () => { const requirement = resolveBuildRequirement( createBuildRequirementDeps(tmp, { gitHead: "abc123\n", - gitStatus: ` M ${GENERATED_A2UI_BUNDLE_HASH}\n M ${GENERATED_A2UI_BUNDLE}\n`, + gitStatus: ` M ${GENERATED_PLUGIN_ASSET_BUNDLE_HASH}\n M ${GENERATED_PLUGIN_ASSET_BUNDLE}\n`, }), ); diff --git a/src/scripts/canvas-a2ui-copy.test.ts b/src/scripts/canvas-a2ui-copy.test.ts index c6167fa5526..f31ed29b818 100644 --- a/src/scripts/canvas-a2ui-copy.test.ts +++ b/src/scripts/canvas-a2ui-copy.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { copyA2uiAssets } from "../../scripts/canvas-a2ui-copy.js"; +import { copyA2uiAssets } from "../../extensions/canvas/scripts/copy-a2ui.mjs"; import { withTempDir } from "../test-utils/temp-dir.js"; const ORIGINAL_SKIP_MISSING = process.env.OPENCLAW_A2UI_SKIP_MISSING; diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index 00f598813fa..b610e96b0a2 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -53,7 +53,7 @@ function withBuildCacheFixture( describe("resolveBuildAllStep", () => { it("routes pnpm steps through the npm_execpath pnpm runner on Windows", () => { - const step = BUILD_ALL_STEPS.find((entry) => entry.label === "canvas:a2ui:bundle"); + const step = BUILD_ALL_STEPS.find((entry) => entry.label === "plugins:assets:build"); expect(step).toBeTruthy(); const result = resolveBuildAllStep(step, { @@ -65,7 +65,7 @@ describe("resolveBuildAllStep", () => { expect(result).toEqual({ command: "C:\\Program Files\\nodejs\\node.exe", - args: ["C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "canvas:a2ui:bundle"], + args: ["C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "plugins:assets:build"], options: { stdio: "inherit", env: {}, @@ -129,7 +129,7 @@ describe("resolveBuildAllSteps", () => { it("uses a runtime artifact plus plugin SDK export profile for ci artifacts", () => { expect(resolveBuildAllSteps("ciArtifacts").map((step) => step.label)).toEqual([ - "canvas:a2ui:bundle", + "plugins:assets:build", "tsdown", "check-cli-bootstrap-imports", "runtime-postbuild", @@ -138,7 +138,7 @@ describe("resolveBuildAllSteps", () => { "build:plugin-sdk:dts", "write-plugin-sdk-entry-dts", "check-plugin-sdk-exports", - "canvas-a2ui-copy", + "plugins:assets:copy", "copy-hook-metadata", "copy-export-html-templates", "write-build-info", diff --git a/test/scripts/bundle-a2ui.test.ts b/test/scripts/bundle-a2ui.test.ts index f14d83db58d..e3ee72c8c6d 100644 --- a/test/scripts/bundle-a2ui.test.ts +++ b/test/scripts/bundle-a2ui.test.ts @@ -6,17 +6,17 @@ import { getBundleHashRepoInputPaths, getLocalRolldownCliCandidates, isBundleHashInputPath, -} from "../../scripts/bundle-a2ui.mjs"; +} from "../../extensions/canvas/scripts/bundle-a2ui.mjs"; describe("scripts/bundle-a2ui.mjs", () => { - it("uses package metadata and CanvasA2UI sources as bundle hash inputs", () => { + it("uses package metadata and plugin-owned A2UI sources as bundle hash inputs", () => { const repoRoot = path.resolve("repo-root"); const inputPaths = getBundleHashRepoInputPaths(repoRoot); expect(inputPaths).toContain(path.join(repoRoot, "package.json")); expect(inputPaths).toContain(path.join(repoRoot, "pnpm-lock.yaml")); expect(inputPaths).toContain( - path.join(repoRoot, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"), + path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui-app"), ); expect(inputPaths).not.toContain(path.join(repoRoot, "vendor", "a2ui", "renderers", "lit")); expect(isBundleHashInputPath(path.join(repoRoot, "package.json"), repoRoot)).toBe(true); diff --git a/test/scripts/bundled-plugin-assets.test.ts b/test/scripts/bundled-plugin-assets.test.ts new file mode 100644 index 00000000000..45a4e1c6fc9 --- /dev/null +++ b/test/scripts/bundled-plugin-assets.test.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + parseBundledPluginAssetArgs, + readBundledPluginAssetHooks, +} from "../../scripts/bundled-plugin-assets.mjs"; + +async function withPluginAssetFixture(run: (rootDir: string) => Promise) { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-assets-")); + try { + fs.mkdirSync(path.join(rootDir, "extensions", "canvas"), { recursive: true }); + fs.writeFileSync( + path.join(rootDir, "extensions", "canvas", "package.json"), + JSON.stringify( + { + name: "@openclaw/canvas-plugin", + openclaw: { + assetScripts: { + build: "node scripts/bundle-a2ui.mjs", + copy: "node scripts/copy-a2ui.mjs", + }, + }, + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(rootDir, "extensions", "canvas", "openclaw.plugin.json"), + JSON.stringify({ id: "canvas" }, null, 2), + ); + await run(rootDir); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } +} + +describe("bundled plugin assets", () => { + it("discovers plugin-owned asset scripts by manifest id", async () => { + await withPluginAssetFixture(async (rootDir) => { + const hooks = await readBundledPluginAssetHooks({ + phase: "build", + plugins: ["canvas"], + rootDir, + }); + + expect(hooks).toMatchObject([ + { + command: "node scripts/bundle-a2ui.mjs", + phase: "build", + pluginId: "canvas", + }, + ]); + }); + }); + + it("skips cleanly when a requested plugin is absent", async () => { + await withPluginAssetFixture(async (rootDir) => { + await expect( + readBundledPluginAssetHooks({ phase: "copy", plugins: ["missing"], rootDir }), + ).resolves.toEqual([]); + }); + }); + + it("parses phase and plugin filters", () => { + expect(parseBundledPluginAssetArgs(["--phase", "build", "--plugin=canvas"])).toEqual({ + phase: "build", + plugins: ["canvas"], + }); + }); +}); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index d522bef63a9..c3cd26524cb 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -853,21 +853,19 @@ describe("scripts/changed-lanes", () => { expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all"); }); - it("routes CanvasA2UI bundle changes to core and tooling instead of all lanes", () => { + it("routes A2UI bundle source changes as extension changes", () => { const result = detectChangedLanes([ - "apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js", - "apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs", + "extensions/canvas/src/host/a2ui-app/bootstrap.js", + "extensions/canvas/src/host/a2ui-app/rolldown.config.mjs", ]); const plan = createChangedCheckPlan(result); expect(result.lanes).toMatchObject({ - core: true, - coreTests: true, - tooling: true, + extensions: true, + extensionTests: true, all: false, }); - expect(plan.commands.map((command) => command.args[0])).toContain("lint:scripts"); - expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core"); + expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:extensions"); expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all"); }); @@ -887,9 +885,9 @@ describe("scripts/changed-lanes", () => { expect(plan.commands.map((command) => command.args[0])).not.toContain("test"); }); - it("does not route generated A2UI artifacts as direct Vitest targets", () => { + it("does not route generated plugin bundle artifacts as direct Vitest targets", () => { const result = detectChangedLanes([ - "extensions/canvas/src/host/a2ui/.bundle.hash", + "extensions/demo/src/host/assets/.bundle.hash", "test/scripts/bundle-a2ui.test.ts", ]); const plan = createChangedCheckPlan(result);