Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions scripts/canvas-a2ui-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,78 @@ export function shouldSkipMissingA2uiAssets(env = process.env): boolean {
return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE);
}

export async function copyA2uiAssets({ srcDir, outDir }: { srcDir: string; outDir: string }) {
function normalizeRelativePath(value: string): string {
return value.replace(/\\/g, "/");
}

async function assertSafeRequiredAsset(srcDir: string, entryName: string) {
const entryPath = path.join(srcDir, entryName);
const stats = await fs.lstat(entryPath);
if (!stats.isFile() || stats.isSymbolicLink()) {
throw new Error(`Invalid A2UI asset path: ${normalizeRelativePath(entryPath)}`);
}
}

async function copyDirectoryContentsSafe(params: {
srcDir: string;
outDir: string;
rootDir: string;
warn: (message: string) => void;
}) {
const entries = await fs.readdir(params.srcDir, { withFileTypes: true });
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
const sourcePath = path.join(params.srcDir, entry.name);
const destinationPath = path.join(params.outDir, entry.name);

if (entry.isSymbolicLink()) {
const relativePath = normalizeRelativePath(path.relative(params.rootDir, sourcePath));
params.warn(`[canvas-a2ui-copy] skipping symlinked asset: ${relativePath}`);
continue;
}
if (entry.isDirectory()) {
await fs.mkdir(destinationPath, { recursive: true });
await copyDirectoryContentsSafe({
srcDir: sourcePath,
outDir: destinationPath,
rootDir: params.rootDir,
warn: params.warn,
});
continue;
}
if (entry.isFile()) {
await fs.copyFile(sourcePath, destinationPath);
}
}
}

export async function copyA2uiAssets(
{
srcDir,
outDir,
}: {
srcDir: string;
outDir: string;
},
params: { warn?: (message: string) => void } = {},
) {
const skipMissing = shouldSkipMissingA2uiAssets(process.env);
const warn = params.warn ?? console.warn;
try {
await fs.stat(path.join(srcDir, "index.html"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
await assertSafeRequiredAsset(srcDir, "index.html");
await assertSafeRequiredAsset(srcDir, "a2ui.bundle.js");
} catch (err) {
const message = 'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
if (skipMissing) {
console.warn(
warn(
`${message} Skipping copy because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.`,
);
return;
}
throw new Error(message, { cause: err });
}
await fs.mkdir(path.dirname(outDir), { recursive: true });
await fs.cp(srcDir, outDir, { recursive: true });
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
await copyDirectoryContentsSafe({ srcDir, outDir, rootDir: srcDir, warn });
}

async function main() {
Expand Down
21 changes: 21 additions & 0 deletions scripts/postinstall-bundled-plugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,21 @@ function collectRuntimeDeps(packageJson) {
};
}

function collectRootRuntimeDeps(params = {}) {
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
const packageJsonPath = join(packageRoot, "package.json");
const pathExists = params.existsSync ?? existsSync;
const readJsonFile = params.readJson ?? readJson;
if (!pathExists(packageJsonPath)) {
return new Map();
}
try {
return new Map(Object.entries(collectRuntimeDeps(readJsonFile(packageJsonPath))));
} catch {
return new Map();
}
}

export function discoverBundledPluginRuntimeDeps(params = {}) {
const extensionsDir = params.extensionsDir ?? DEFAULT_EXTENSIONS_DIR;
const pathExists = params.existsSync ?? existsSync;
Expand Down Expand Up @@ -671,7 +686,13 @@ export function runBundledPluginPostinstall(params = {}) {
const runtimeDeps =
params.runtimeDeps ??
discoverBundledPluginRuntimeDeps({ extensionsDir, existsSync: pathExists });
const rootRuntimeDeps = collectRootRuntimeDeps({
packageRoot,
existsSync: pathExists,
readJson: params.readJson ?? readJson,
});
const missingSpecs = runtimeDeps
.filter((dep) => rootRuntimeDeps.has(dep.name))
.filter((dep) =>
runtimeDepNeedsInstall({
dep,
Expand Down
4 changes: 1 addition & 3 deletions scripts/test-install-sh-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,8 @@ process.stdout.write(last.filename);
}

prepare_update_host_access() {
local host_os
host_os="$(uname -s)"
UPDATE_DOCKER_HOST_ARGS=()
if [[ "$host_os" == "Linux" ]]; then
if [[ -n "$UPDATE_HOST_ALIAS" && "$UPDATE_HOST_ALIAS" != "127.0.0.1" && "$UPDATE_HOST_ALIAS" != "localhost" ]]; then
UPDATE_DOCKER_HOST_ARGS=(--add-host "${UPDATE_HOST_ALIAS}:host-gateway")
fi
}
Expand Down
31 changes: 30 additions & 1 deletion src/scripts/canvas-a2ui-copy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyA2uiAssets } from "../../scripts/canvas-a2ui-copy.js";
import { withTempDir } from "../test-utils/temp-dir.js";

Expand Down Expand Up @@ -66,4 +66,33 @@ describe("canvas a2ui copy", () => {
await expect(fs.stat(path.join(outDir, "a2ui.bundle.js"))).resolves.toBeTruthy();
});
});

it("cleans stale output and skips symlinked source entries", async () => {
await withA2uiFixture(async (dir) => {
const srcDir = path.join(dir, "src");
const outDir = path.join(dir, "dist");
const warn = vi.fn();
await fs.mkdir(srcDir, { recursive: true });
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
await fs.writeFile(path.join(dir, "package.json"), "{}", "utf8");
await fs.writeFile(path.join(outDir, "stale.txt"), "stale", "utf8");
await fs.symlink(path.join(dir, "package.json"), path.join(srcDir, "test-link.txt"));

await copyA2uiAssets({ srcDir, outDir }, { warn });

await expect(fs.stat(path.join(outDir, "index.html"))).resolves.toBeTruthy();
await expect(fs.stat(path.join(outDir, "a2ui.bundle.js"))).resolves.toBeTruthy();
await expect(fs.stat(path.join(outDir, "stale.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(fs.stat(path.join(outDir, "test-link.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(warn).toHaveBeenCalledWith(
"[canvas-a2ui-copy] skipping symlinked asset: test-link.txt",
);
});
});
});
86 changes: 79 additions & 7 deletions test/scripts/postinstall-bundled-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ async function writePluginPackage(
}
}

async function writeRootPackageJson(
packageRoot: string,
packageJson: Record<string, unknown> = { name: "@qverisai/qverisbot" },
) {
await fs.writeFile(
path.join(packageRoot, "package.json"),
`${JSON.stringify(packageJson, null, 2)}\n`,
);
}

describe("bundled plugin postinstall", () => {
function createNpmInstallArgs(...packages: string[]) {
return [
Expand Down Expand Up @@ -99,9 +109,15 @@ describe("bundled plugin postinstall", () => {
});
});

it("installs bundled plugin deps outside of source checkouts", async () => {
it("installs only root-mirrored bundled plugin deps outside of source checkouts", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writeRootPackageJson(packageRoot, {
name: "@qverisai/qverisbot",
dependencies: {
acpx: "0.4.1",
},
});
await writePluginPackage(extensionsDir, "acpx", {
dependencies: {
acpx: "0.4.1",
Expand Down Expand Up @@ -341,6 +357,12 @@ describe("bundled plugin postinstall", () => {
it("runs nested local installs with sanitized env when the sentinel package is missing", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writeRootPackageJson(packageRoot, {
name: "@qverisai/qverisbot",
dependencies: {
acpx: "0.4.1",
},
});
await writePluginPackage(extensionsDir, "acpx", {
dependencies: {
acpx: "0.4.1",
Expand Down Expand Up @@ -394,6 +416,12 @@ describe("bundled plugin postinstall", () => {
it("reinstalls bundled runtime deps when optional native children are missing", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writeRootPackageJson(packageRoot, {
name: "@qverisai/qverisbot",
dependencies: {
"@snazzah/davey": "0.1.11",
},
});
await writePluginPackage(extensionsDir, "discord", {
dependencies: {
"@snazzah/davey": "0.1.11",
Expand Down Expand Up @@ -519,6 +547,12 @@ describe("bundled plugin postinstall", () => {
it("installs missing bundled plugin runtime deps during global installs", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writeRootPackageJson(packageRoot, {
name: "@qverisai/qverisbot",
dependencies: {
"@slack/web-api": "7.11.0",
},
});
await writePluginPackage(extensionsDir, "slack", {
dependencies: {
"@slack/web-api": "7.11.0",
Expand All @@ -540,17 +574,23 @@ describe("bundled plugin postinstall", () => {
},
extensionsDir,
packageRoot,
npmRunner: createBareNpmRunner(["@slack/web-api@7.11.0", "grammy@1.38.4"]),
npmRunner: createBareNpmRunner(["@slack/web-api@7.11.0"]),
spawnSync,
log: { log: vi.fn(), warn: vi.fn() },
});

expectNpmInstallSpawn(spawnSync, packageRoot, ["@slack/web-api@7.11.0", "grammy@1.38.4"]);
expectNpmInstallSpawn(spawnSync, packageRoot, ["@slack/web-api@7.11.0"]);
});

it("installs only missing bundled plugin runtime deps", async () => {
it("installs only missing root-mirrored bundled plugin runtime deps", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writeRootPackageJson(packageRoot, {
name: "@qverisai/qverisbot",
dependencies: {
"@slack/web-api": "7.11.0",
},
});
await writePluginPackage(extensionsDir, "slack", {
dependencies: {
"@slack/web-api": "7.11.0",
Expand All @@ -576,17 +616,23 @@ describe("bundled plugin postinstall", () => {
},
extensionsDir,
packageRoot,
npmRunner: createBareNpmRunner(["grammy@1.38.4"]),
npmRunner: createBareNpmRunner([]),
spawnSync,
log: { log: vi.fn(), warn: vi.fn() },
});

expectNpmInstallSpawn(spawnSync, packageRoot, ["grammy@1.38.4"]);
expect(spawnSync).not.toHaveBeenCalled();
});

it("installs bundled plugin deps when npm location is global", async () => {
it("installs mirrored bundled plugin deps when npm location is global", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writeRootPackageJson(packageRoot, {
name: "@qverisai/qverisbot",
dependencies: {
grammy: "1.38.4",
},
});
await writePluginPackage(extensionsDir, "telegram", {
dependencies: {
grammy: "1.38.4",
Expand All @@ -610,6 +656,32 @@ describe("bundled plugin postinstall", () => {
expectNpmInstallSpawn(spawnSync, packageRoot, ["grammy@1.38.4"]);
});

it("skips non-mirrored bundled plugin deps during installed-package postinstall", async () => {
const extensionsDir = await createExtensionsDir();
const packageRoot = path.dirname(path.dirname(extensionsDir));
await writeRootPackageJson(packageRoot, { name: "@qverisai/qverisbot" });
await writePluginPackage(extensionsDir, "telegram", {
dependencies: {
grammy: "1.38.4",
},
});
const spawnSync = vi.fn();

runBundledPluginPostinstall({
env: {
npm_config_location: "global",
npm_config_prefix: "/opt/homebrew",
HOME: "/tmp/home",
},
extensionsDir,
packageRoot,
spawnSync,
log: { log: vi.fn(), warn: vi.fn() },
});

expect(spawnSync).not.toHaveBeenCalled();
});

it("prunes only bundled plugin package node_modules in source checkouts", async () => {
const packageRoot = await createTempDirAsync("openclaw-source-prune-");
const extensionsDir = path.join(packageRoot, "extensions");
Expand Down
11 changes: 11 additions & 0 deletions test/scripts/test-install-sh-docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ describe("test-install-sh-docker", () => {
expect(script).toContain('echo "==> Skip update smoke (${UPDATE_SKIP_REASON})"');
expect(script).toContain('if [[ -n "$BASELINE_TAG_URL" ]]; then');
});

it("always injects a Docker host mapping for non-local update host aliases", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");

expect(script).toContain(
'if [[ -n "$UPDATE_HOST_ALIAS" && "$UPDATE_HOST_ALIAS" != "127.0.0.1" && "$UPDATE_HOST_ALIAS" != "localhost" ]]; then',
);
expect(script).toContain(
'UPDATE_DOCKER_HOST_ARGS=(--add-host "${UPDATE_HOST_ALIAS}:host-gateway")',
);
});
});

describe("install-sh smoke runner", () => {
Expand Down
Loading