mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 06:29:32 +00:00
573 lines
19 KiB
TypeScript
573 lines
19 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, win32 } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
resolveAugmentedPluginNpmPackageJson,
|
|
resolveAugmentedPluginNpmManifest,
|
|
resolvePluginNpmCommand,
|
|
withAugmentedPluginNpmManifestForPackage,
|
|
} from "../scripts/lib/plugin-npm-package-manifest.mjs";
|
|
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
cleanupTempDirs(tempDirs);
|
|
});
|
|
|
|
function writeGeneratedChannelMetadata(repoDir: string): void {
|
|
const metadataPath = join(
|
|
repoDir,
|
|
"src",
|
|
"config",
|
|
"bundled-channel-config-metadata.generated.ts",
|
|
);
|
|
mkdirSync(join(repoDir, "src", "config"), { recursive: true });
|
|
writeFileText(
|
|
metadataPath,
|
|
`export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|
{
|
|
pluginId: "twitch",
|
|
channelId: "twitch",
|
|
label: "Twitch",
|
|
description: "Twitch chat integration",
|
|
schema: {
|
|
type: "object",
|
|
required: ["channelName"],
|
|
properties: {
|
|
channelName: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
] as const;
|
|
`,
|
|
);
|
|
}
|
|
|
|
function writeFileText(filePath: string, text: string): void {
|
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
// writeJsonFile intentionally owns JSON formatting only.
|
|
writeFileSync(filePath, text, "utf8");
|
|
}
|
|
|
|
function listNpmPackDryRunFiles(packageDir: string): string[] {
|
|
const invocation = resolvePluginNpmCommand(["pack", "--dry-run", "--json", "--ignore-scripts"]);
|
|
const result = spawnSync(invocation.command, invocation.args, {
|
|
cwd: packageDir,
|
|
encoding: "utf8",
|
|
...(invocation.env ? { env: invocation.env } : {}),
|
|
...(invocation.shell !== undefined ? { shell: invocation.shell } : {}),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
...(invocation.windowsVerbatimArguments !== undefined
|
|
? { windowsVerbatimArguments: invocation.windowsVerbatimArguments }
|
|
: {}),
|
|
});
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(result.stderr.trim() || `npm pack failed with exit ${result.status}`);
|
|
}
|
|
const [packResult] = JSON.parse(result.stdout) as [
|
|
{
|
|
files?: { path?: string }[];
|
|
},
|
|
];
|
|
return (packResult?.files ?? []).flatMap((entry) =>
|
|
typeof entry.path === "string" ? [entry.path] : [],
|
|
);
|
|
}
|
|
|
|
function writePublishablePluginPackage(repoDir: string): string {
|
|
const packageDir = join(repoDir, "extensions", "diffs");
|
|
mkdirSync(packageDir, { recursive: true });
|
|
writeJsonFile(join(packageDir, "package.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
type: "module",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
setupEntry: "./setup-entry.ts",
|
|
compat: {
|
|
pluginApi: ">=2026.4.30",
|
|
},
|
|
release: {
|
|
publishToNpm: true,
|
|
},
|
|
},
|
|
});
|
|
writeJsonFile(join(packageDir, "openclaw.plugin.json"), { id: "diffs" });
|
|
writeFileText(join(packageDir, "README.md"), "# Diffs\n");
|
|
writeFileText(join(packageDir, "SKILL.md"), "# Diffs Skill\n");
|
|
writeFileText(join(packageDir, "skills", "diffs", "SKILL.md"), "# Diffs Skill\n");
|
|
return packageDir;
|
|
}
|
|
|
|
function writeLocalDependencyPackage(
|
|
packageDir: string,
|
|
options: { optionalDependencySpec?: string } = {},
|
|
): void {
|
|
const dependencyDir = join(packageDir, "deps", "local-runtime-dep");
|
|
mkdirSync(dependencyDir, { recursive: true });
|
|
writeJsonFile(join(dependencyDir, "package.json"), {
|
|
name: "local-runtime-dep",
|
|
version: "1.0.0",
|
|
main: "index.js",
|
|
...(options.optionalDependencySpec
|
|
? {
|
|
optionalDependencies: {
|
|
"optional-platform-dep": options.optionalDependencySpec,
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
writeFileText(join(dependencyDir, "index.js"), "module.exports = 1;\n");
|
|
}
|
|
|
|
function writeOptionalPlatformDependencyPackage(packageDir: string): string {
|
|
const dependencyDir = join(packageDir, "deps", "optional-platform-dep");
|
|
mkdirSync(dependencyDir, { recursive: true });
|
|
writeJsonFile(join(dependencyDir, "package.json"), {
|
|
name: "optional-platform-dep",
|
|
version: "1.0.0",
|
|
main: "index.js",
|
|
os: [process.platform === "win32" ? "darwin" : "win32"],
|
|
});
|
|
writeFileText(join(dependencyDir, "index.js"), "module.exports = 2;\n");
|
|
return dependencyDir;
|
|
}
|
|
|
|
describe("plugin npm package manifest staging", () => {
|
|
it("wraps Windows npm.cmd staging through cmd.exe without shell mode", () => {
|
|
const nodeDir = "C:\\Program Files\\nodejs";
|
|
const npmCmdPath = win32.resolve(nodeDir, "npm.cmd");
|
|
|
|
expect(
|
|
resolvePluginNpmCommand(["install", "--package-lock-only"], {
|
|
comSpec: "C:\\Windows\\System32\\cmd.exe",
|
|
env: { PATH: "C:\\bin" },
|
|
execPath: win32.join(nodeDir, "node.exe"),
|
|
existsSync: (candidate: string) => candidate === npmCmdPath,
|
|
platform: "win32",
|
|
}),
|
|
).toEqual({
|
|
command: "C:\\Windows\\System32\\cmd.exe",
|
|
args: [
|
|
"/d",
|
|
"/s",
|
|
"/c",
|
|
'""C:\\Program Files\\nodejs\\npm.cmd" install --package-lock-only"',
|
|
],
|
|
shell: false,
|
|
windowsVerbatimArguments: true,
|
|
});
|
|
});
|
|
|
|
it("rejects bare npm fallback on Windows plugin package staging", () => {
|
|
expect(() =>
|
|
resolvePluginNpmCommand(["install"], {
|
|
execPath: "C:\\nodejs\\node.exe",
|
|
existsSync: () => false,
|
|
platform: "win32",
|
|
}),
|
|
).toThrow("OpenClaw refuses to shell out to bare npm on Windows");
|
|
});
|
|
|
|
it("overlays generated channel configs while packing and restores source manifest", () => {
|
|
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-manifest-");
|
|
const packageDir = join(repoDir, "extensions", "twitch");
|
|
mkdirSync(packageDir, { recursive: true });
|
|
const sourceManifest = {
|
|
id: "twitch",
|
|
channels: ["twitch"],
|
|
configSchema: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {},
|
|
},
|
|
};
|
|
writeJsonFile(join(packageDir, "openclaw.plugin.json"), sourceManifest);
|
|
writeGeneratedChannelMetadata(repoDir);
|
|
|
|
const resolved = resolveAugmentedPluginNpmManifest({
|
|
repoRoot: repoDir,
|
|
packageDir,
|
|
});
|
|
expect(resolved.changed).toBe(true);
|
|
expect(resolved.manifest).toEqual({
|
|
id: "twitch",
|
|
channels: ["twitch"],
|
|
configSchema: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {},
|
|
},
|
|
channelConfigs: {
|
|
twitch: {
|
|
description: "Twitch chat integration",
|
|
label: "Twitch",
|
|
schema: {
|
|
type: "object",
|
|
required: ["channelName"],
|
|
properties: {
|
|
channelName: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const originalText = readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8");
|
|
withAugmentedPluginNpmManifestForPackage({ repoRoot: repoDir, packageDir }, () => {
|
|
const stagedManifest = JSON.parse(
|
|
readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8"),
|
|
);
|
|
expect(stagedManifest.channelConfigs.twitch.description).toBe("Twitch chat integration");
|
|
});
|
|
expect(readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8")).toBe(originalText);
|
|
});
|
|
|
|
it("overlays package-local runtime metadata while packing and restores source package json", () => {
|
|
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-runtime-");
|
|
const packageDir = writePublishablePluginPackage(repoDir);
|
|
writeFileText(join(packageDir, "dist", "index.js"), "export {};\n");
|
|
writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n");
|
|
writeJsonFile(join(packageDir, "npm-shrinkwrap.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
lockfileVersion: 3,
|
|
packages: {
|
|
"": {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = resolveAugmentedPluginNpmPackageJson({
|
|
repoRoot: repoDir,
|
|
packageDir,
|
|
bundleDependencies: true,
|
|
});
|
|
expect(resolved.changed).toBe(true);
|
|
expect(resolved.packageJson).toEqual({
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
type: "module",
|
|
bundledDependencies: [],
|
|
files: [
|
|
"dist/**",
|
|
"openclaw.plugin.json",
|
|
"npm-shrinkwrap.json",
|
|
"README.md",
|
|
"SKILL.md",
|
|
"skills/**",
|
|
],
|
|
peerDependencies: {
|
|
openclaw: ">=2026.4.30",
|
|
},
|
|
peerDependenciesMeta: {
|
|
openclaw: {
|
|
optional: true,
|
|
},
|
|
},
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
setupEntry: "./setup-entry.ts",
|
|
compat: {
|
|
pluginApi: ">=2026.4.30",
|
|
},
|
|
release: {
|
|
publishToNpm: true,
|
|
},
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
runtimeSetupEntry: "./dist/setup-entry.js",
|
|
},
|
|
});
|
|
|
|
const originalText = readFileSync(join(packageDir, "package.json"), "utf8");
|
|
withAugmentedPluginNpmManifestForPackage(
|
|
{ repoRoot: repoDir, packageDir, bundleDependencies: true },
|
|
() => {
|
|
const stagedPackageJson = JSON.parse(
|
|
readFileSync(join(packageDir, "package.json"), "utf8"),
|
|
);
|
|
expect(stagedPackageJson.openclaw.extensions).toEqual(["./index.ts"]);
|
|
expect(stagedPackageJson.openclaw.runtimeExtensions).toEqual(["./dist/index.js"]);
|
|
expect(stagedPackageJson.openclaw.runtimeSetupEntry).toBe("./dist/setup-entry.js");
|
|
expect(stagedPackageJson.bundledDependencies).toEqual([]);
|
|
expect(stagedPackageJson.bundleDependencies).toBeUndefined();
|
|
expect(stagedPackageJson.files).toContain("dist/**");
|
|
expect(stagedPackageJson.files).toContain("npm-shrinkwrap.json");
|
|
expect(stagedPackageJson.files).toContain("skills/**");
|
|
expect(stagedPackageJson.peerDependencies.openclaw).toBe(">=2026.4.30");
|
|
expect(stagedPackageJson.peerDependenciesMeta.openclaw.optional).toBe(true);
|
|
},
|
|
);
|
|
expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText);
|
|
});
|
|
|
|
it("installs and cleans package-local bundled dependencies while packing", () => {
|
|
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-bundled-deps-");
|
|
const packageDir = writePublishablePluginPackage(repoDir);
|
|
writeFileText(join(packageDir, "dist", "index.js"), "export {};\n");
|
|
writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n");
|
|
writeLocalDependencyPackage(packageDir);
|
|
writeJsonFile(join(packageDir, "package.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
type: "module",
|
|
dependencies: {
|
|
"local-runtime-dep": "file:./deps/local-runtime-dep",
|
|
},
|
|
devDependencies: {
|
|
"@openclaw/plugin-sdk": "workspace:*",
|
|
},
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
setupEntry: "./setup-entry.ts",
|
|
compat: {
|
|
pluginApi: ">=2026.4.30",
|
|
},
|
|
release: {
|
|
publishToNpm: true,
|
|
},
|
|
},
|
|
});
|
|
writeJsonFile(join(packageDir, "npm-shrinkwrap.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
lockfileVersion: 3,
|
|
requires: true,
|
|
packages: {
|
|
"": {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
dependencies: {
|
|
"local-runtime-dep": "file:./deps/local-runtime-dep",
|
|
},
|
|
},
|
|
"deps/local-runtime-dep": {
|
|
name: "local-runtime-dep",
|
|
version: "1.0.0",
|
|
},
|
|
"node_modules/local-runtime-dep": {
|
|
resolved: "deps/local-runtime-dep",
|
|
link: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const originalText = readFileSync(join(packageDir, "package.json"), "utf8");
|
|
const nodeModulesPath = join(packageDir, "node_modules");
|
|
expect(existsSync(nodeModulesPath)).toBe(false);
|
|
|
|
withAugmentedPluginNpmManifestForPackage(
|
|
{ repoRoot: repoDir, packageDir, bundleDependencies: true },
|
|
() => {
|
|
const stagedPackageJson = JSON.parse(
|
|
readFileSync(join(packageDir, "package.json"), "utf8"),
|
|
);
|
|
expect(stagedPackageJson.bundledDependencies).toEqual(["local-runtime-dep"]);
|
|
expect(stagedPackageJson.bundleDependencies).toBeUndefined();
|
|
expect(stagedPackageJson.devDependencies).toBeUndefined();
|
|
expect(existsSync(join(nodeModulesPath, "local-runtime-dep", "package.json"))).toBe(true);
|
|
},
|
|
);
|
|
|
|
expect(existsSync(nodeModulesPath)).toBe(false);
|
|
expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText);
|
|
});
|
|
|
|
it("force-installs missing optional bundled dependencies for portable packs", () => {
|
|
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-portable-optional-");
|
|
const packageDir = writePublishablePluginPackage(repoDir);
|
|
writeFileText(join(packageDir, "dist", "index.js"), "export {};\n");
|
|
writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n");
|
|
writeOptionalPlatformDependencyPackage(packageDir);
|
|
writeLocalDependencyPackage(packageDir, {
|
|
optionalDependencySpec: "file:../../deps/optional-platform-dep",
|
|
});
|
|
writeJsonFile(join(packageDir, "package.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
type: "module",
|
|
dependencies: {
|
|
"local-runtime-dep": "file:./deps/local-runtime-dep",
|
|
},
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
setupEntry: "./setup-entry.ts",
|
|
compat: {
|
|
pluginApi: ">=2026.4.30",
|
|
},
|
|
release: {
|
|
publishToNpm: true,
|
|
},
|
|
},
|
|
});
|
|
writeJsonFile(join(packageDir, "npm-shrinkwrap.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
lockfileVersion: 3,
|
|
requires: true,
|
|
packages: {
|
|
"": {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
dependencies: {
|
|
"local-runtime-dep": "file:./deps/local-runtime-dep",
|
|
},
|
|
},
|
|
"deps/local-runtime-dep": {
|
|
name: "local-runtime-dep",
|
|
version: "1.0.0",
|
|
optionalDependencies: {
|
|
"optional-platform-dep": "file:../../deps/optional-platform-dep",
|
|
},
|
|
},
|
|
"deps/optional-platform-dep": {
|
|
version: "1.0.0",
|
|
optional: true,
|
|
os: [process.platform === "win32" ? "darwin" : "win32"],
|
|
},
|
|
"node_modules/local-runtime-dep": {
|
|
resolved: "deps/local-runtime-dep",
|
|
link: true,
|
|
},
|
|
"node_modules/optional-platform-dep": {
|
|
resolved: "deps/optional-platform-dep",
|
|
link: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const nodeModulesPath = join(packageDir, "node_modules");
|
|
withAugmentedPluginNpmManifestForPackage(
|
|
{ repoRoot: repoDir, packageDir, bundleDependencies: true },
|
|
() => {
|
|
expect(existsSync(join(nodeModulesPath, "local-runtime-dep", "package.json"))).toBe(true);
|
|
expect(existsSync(join(nodeModulesPath, "optional-platform-dep", "package.json"))).toBe(
|
|
true,
|
|
);
|
|
},
|
|
);
|
|
|
|
expect(existsSync(nodeModulesPath)).toBe(false);
|
|
});
|
|
|
|
it("honors plugin package opt-out for bundled runtime dependencies", () => {
|
|
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-bundle-opt-out-");
|
|
const packageDir = writePublishablePluginPackage(repoDir);
|
|
writeFileText(join(packageDir, "dist", "index.js"), "export {};\n");
|
|
writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n");
|
|
writeLocalDependencyPackage(packageDir);
|
|
writeJsonFile(join(packageDir, "package.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
type: "module",
|
|
dependencies: {
|
|
"local-runtime-dep": "file:./deps/local-runtime-dep",
|
|
},
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
setupEntry: "./setup-entry.ts",
|
|
compat: {
|
|
pluginApi: ">=2026.4.30",
|
|
},
|
|
release: {
|
|
publishToNpm: true,
|
|
bundleRuntimeDependencies: false,
|
|
},
|
|
},
|
|
});
|
|
writeJsonFile(join(packageDir, "npm-shrinkwrap.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
lockfileVersion: 3,
|
|
requires: true,
|
|
packages: {
|
|
"": {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
dependencies: {
|
|
"local-runtime-dep": "file:./deps/local-runtime-dep",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = resolveAugmentedPluginNpmPackageJson({
|
|
repoRoot: repoDir,
|
|
packageDir,
|
|
bundleDependencies: true,
|
|
});
|
|
expect(resolved.bundleDependencies).toBe(false);
|
|
expect(resolved.packageJson?.bundledDependencies).toBeUndefined();
|
|
expect(resolved.packageJson?.devDependencies).toBeUndefined();
|
|
|
|
const nodeModulesPath = join(packageDir, "node_modules");
|
|
withAugmentedPluginNpmManifestForPackage(
|
|
{ repoRoot: repoDir, packageDir, bundleDependencies: true },
|
|
() => {
|
|
const stagedPackageJson = JSON.parse(
|
|
readFileSync(join(packageDir, "package.json"), "utf8"),
|
|
);
|
|
expect(stagedPackageJson.bundledDependencies).toBeUndefined();
|
|
expect(existsSync(nodeModulesPath)).toBe(false);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("refuses to pack publishable plugins before package-local runtime files exist", () => {
|
|
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-runtime-missing-");
|
|
const packageDir = writePublishablePluginPackage(repoDir);
|
|
|
|
expect(() =>
|
|
resolveAugmentedPluginNpmPackageJson({
|
|
repoRoot: repoDir,
|
|
packageDir,
|
|
}),
|
|
).toThrow(
|
|
"package-local plugin runtime is missing for diffs: ./dist/index.js, ./dist/setup-entry.js",
|
|
);
|
|
});
|
|
|
|
it("refuses package file rules that omit advertised package-local runtime files", () => {
|
|
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-runtime-excluded-");
|
|
const packageDir = writePublishablePluginPackage(repoDir);
|
|
writeFileText(join(packageDir, "dist", "index.js"), "export {};\n");
|
|
writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n");
|
|
writeJsonFile(join(packageDir, "package.json"), {
|
|
name: "@openclaw/diffs",
|
|
version: "2026.5.3",
|
|
type: "module",
|
|
files: ["dist/**", "!dist/setup-entry.js"],
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
setupEntry: "./setup-entry.ts",
|
|
compat: {
|
|
pluginApi: ">=2026.4.30",
|
|
},
|
|
release: {
|
|
publishToNpm: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const packedFiles = listNpmPackDryRunFiles(packageDir);
|
|
expect(packedFiles).toContain("dist/index.js");
|
|
expect(packedFiles).not.toContain("dist/setup-entry.js");
|
|
|
|
expect(() =>
|
|
resolveAugmentedPluginNpmPackageJson({
|
|
repoRoot: repoDir,
|
|
packageDir,
|
|
}),
|
|
).toThrow(
|
|
"package file rule '!dist/setup-entry.js' excludes required package-local runtime file './dist/setup-entry.js' for diffs",
|
|
);
|
|
});
|
|
});
|