refactor: move canvas asset build into plugin

This commit is contained in:
Peter Steinberger
2026-05-06 23:09:50 +01:00
parent b76291450b
commit 9dbb54ebd4
26 changed files with 634 additions and 349 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -13,6 +13,10 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"assetScripts": {
"build": "node scripts/bundle-a2ui.mjs",
"copy": "node scripts/copy-a2ui.mjs"
}
}
}

View File

@@ -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));
});
}

View File

@@ -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"));

View File

@@ -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`<div class="status"><div class="spinner"></div><div>${statusText}</div></div>`
return html` ${this.pendingAction && this.pendingAction.phase !== "error"
? html`<div class="status">
<div class="spinner"></div>
<div>${statusText}</div>
</div>`
: ""}
${this.toast
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">${this.toast.text}</div>`
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">
${this.toast.text}
</div>`
: ""}
<section id="surfaces">
${repeat(
this.surfaces,
([surfaceId]) => surfaceId,
([surfaceId, surface]) => html`<a2ui-surface
.surfaceId=${surfaceId}
.surface=${surface}
.processor=${this.#processor}
></a2ui-surface>`
)}
</section>`;
${repeat(
this.surfaces,
([surfaceId]) => surfaceId,
([surfaceId, surface]) => html`<a2ui-surface
.surfaceId=${surfaceId}
.surface=${surface}
.processor=${this.#processor}
></a2ui-surface>`,
)}
</section>`;
}
}

View File

@@ -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");

View File

@@ -1 +1 @@
8e45568ff64c7d2fd95957f076b635b6df99f115d1ee92e75ea63916566adb48
992142e47ead0d7fb084464cc70f9752f2a7ffb8921598a94a02adaff0fc683c

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"] });
}

View File

@@ -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);
}
}

View File

@@ -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`);

View File

@@ -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

View File

@@ -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,
},

View File

@@ -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);

View File

@@ -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

View File

@@ -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"

View File

@@ -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)) {

View File

@@ -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")) {

View File

@@ -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`,
}),
);

View File

@@ -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;

View File

@@ -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",

View File

@@ -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);

View File

@@ -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<void>) {
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"],
});
});
});

View File

@@ -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);