mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
// Shared test-only support for packed @openclaw/sdk consumers.
|
|
import { spawn, spawnSync, type SpawnOptionsWithoutStdio } from "node:child_process";
|
|
import { createReadStream } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import { createServer, type Server } from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { resolveNpmRunner } from "../../../scripts/npm-runner.mts";
|
|
import { createPnpmRunnerSpawnSpec } from "../../../scripts/pnpm-runner.mts";
|
|
import { type FileLockOptions, withFileLock } from "../../../src/infra/file-lock.js";
|
|
import { getWindowsSystem32ExePath } from "../../../src/infra/windows-install-roots.js";
|
|
import { createNodeEvalArgs } from "../../../src/test-utils/node-process.js";
|
|
|
|
type CommandResult = {
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
|
|
type PackageManifest = {
|
|
name: string;
|
|
version: string;
|
|
dependencies?: Record<string, string>;
|
|
scripts?: Record<string, string>;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type PackedPackage = {
|
|
manifest: PackageManifest;
|
|
tarball: string;
|
|
};
|
|
|
|
const SDK_PACKAGE_BUILD_LOCK_OPTIONS = {
|
|
retries: {
|
|
retries: 180,
|
|
factor: 1,
|
|
minTimeout: 5_000,
|
|
maxTimeout: 5_000,
|
|
randomize: false,
|
|
},
|
|
stale: 20 * 60_000,
|
|
} satisfies FileLockOptions;
|
|
|
|
type PackedSdkConsumer = {
|
|
root: string;
|
|
run: (script: string) => Promise<void>;
|
|
typecheck: (source: string) => Promise<void>;
|
|
cleanup: () => Promise<void>;
|
|
};
|
|
|
|
function createCommandEnv(): NodeJS.ProcessEnv {
|
|
return {
|
|
...process.env,
|
|
CI: process.env.CI ?? "true",
|
|
npm_config_audit: "false",
|
|
npm_config_fund: "false",
|
|
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
|
|
};
|
|
}
|
|
|
|
export function signalCommandProcess(
|
|
child: ReturnType<typeof spawn>,
|
|
signal: NodeJS.Signals,
|
|
runTaskkill: typeof spawnSync = spawnSync,
|
|
): void {
|
|
if (process.platform === "win32") {
|
|
if (typeof child.pid === "number") {
|
|
const args = ["/PID", String(child.pid), "/T"];
|
|
if (signal === "SIGKILL") {
|
|
args.push("/F");
|
|
}
|
|
const taskkillPath = getWindowsSystem32ExePath("taskkill.exe");
|
|
const result = runTaskkill(taskkillPath, args, { stdio: "ignore", windowsHide: true });
|
|
if (!result.error && result.status === 0) {
|
|
return;
|
|
}
|
|
if (signal !== "SIGKILL") {
|
|
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
});
|
|
if (!forceResult.error && forceResult.status === 0) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
child.kill(signal);
|
|
return;
|
|
}
|
|
if (typeof child.pid === "number") {
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
return;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
child.kill(signal);
|
|
}
|
|
|
|
function runCommand(
|
|
command: string,
|
|
args: string[],
|
|
options: { cwd: string; timeoutMs?: number } & Pick<
|
|
SpawnOptionsWithoutStdio,
|
|
"env" | "shell" | "windowsVerbatimArguments"
|
|
>,
|
|
): Promise<CommandResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const stdout: string[] = [];
|
|
const stderr: string[] = [];
|
|
const child = spawn(command, args, {
|
|
cwd: options.cwd,
|
|
detached: process.platform !== "win32",
|
|
env: options.env ?? createCommandEnv(),
|
|
shell: options.shell,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsVerbatimArguments: options.windowsVerbatimArguments,
|
|
});
|
|
const timer = setTimeout(() => {
|
|
signalCommandProcess(child, "SIGKILL");
|
|
reject(
|
|
new Error(
|
|
`command timed out after ${options.timeoutMs ?? 120_000}ms: ${[command, ...args].join(
|
|
" ",
|
|
)}`,
|
|
),
|
|
);
|
|
}, options.timeoutMs ?? 120_000);
|
|
child.stdout?.setEncoding("utf8");
|
|
child.stderr?.setEncoding("utf8");
|
|
child.stdout?.on("data", (chunk) => stdout.push(String(chunk)));
|
|
child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
|
|
child.once("error", (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
clearTimeout(timer);
|
|
const result = { stdout: stdout.join(""), stderr: stderr.join("") };
|
|
if (code === 0) {
|
|
resolve(result);
|
|
return;
|
|
}
|
|
reject(
|
|
new Error(
|
|
`command failed (${String(code ?? signal)}): ${[command, ...args].join(" ")}\n` +
|
|
`--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function runPnpmCommand(
|
|
args: string[],
|
|
options: { cwd: string; timeoutMs?: number },
|
|
): Promise<CommandResult> {
|
|
const spec = createPnpmRunnerSpawnSpec({
|
|
cwd: options.cwd,
|
|
env: createCommandEnv(),
|
|
pnpmArgs: args,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
return runCommand(spec.command, spec.args, {
|
|
cwd: typeof spec.options.cwd === "string" ? spec.options.cwd : options.cwd,
|
|
env: spec.options.env,
|
|
shell: spec.options.shell,
|
|
timeoutMs: options.timeoutMs,
|
|
windowsVerbatimArguments: spec.options.windowsVerbatimArguments,
|
|
});
|
|
}
|
|
|
|
function runNpmCommand(
|
|
args: string[],
|
|
options: { cwd: string; timeoutMs?: number },
|
|
): Promise<CommandResult> {
|
|
const env = createCommandEnv();
|
|
const runner = resolveNpmRunner({ env, npmArgs: args });
|
|
return runCommand(runner.command, runner.args, {
|
|
cwd: options.cwd,
|
|
env: runner.env ?? env,
|
|
shell: runner.shell,
|
|
timeoutMs: options.timeoutMs,
|
|
windowsVerbatimArguments: runner.windowsVerbatimArguments,
|
|
});
|
|
}
|
|
|
|
function resolveWorkspacePackageRoot(repoRoot: string, packageName: string): string {
|
|
const prefix = "@openclaw/";
|
|
if (!packageName.startsWith(prefix)) {
|
|
throw new Error(`unsupported workspace package name: ${packageName}`);
|
|
}
|
|
return path.join(repoRoot, "packages", packageName.slice(prefix.length));
|
|
}
|
|
|
|
async function readRawPackageManifest(packageRoot: string): Promise<PackageManifest> {
|
|
return JSON.parse(
|
|
await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
|
|
) as PackageManifest;
|
|
}
|
|
|
|
async function normalizeWorkspaceDependencies(
|
|
repoRoot: string,
|
|
dependencies: Record<string, string> | undefined,
|
|
): Promise<Record<string, string> | undefined> {
|
|
if (!dependencies) {
|
|
return undefined;
|
|
}
|
|
const normalized: Record<string, string> = {};
|
|
for (const [name, spec] of Object.entries(dependencies)) {
|
|
normalized[name] =
|
|
name.startsWith("@openclaw/") && spec.startsWith("workspace:")
|
|
? (await readRawPackageManifest(resolveWorkspacePackageRoot(repoRoot, name))).version
|
|
: spec;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function readPackageManifest(packageRoot: string): Promise<PackageManifest> {
|
|
const manifest = await readRawPackageManifest(packageRoot);
|
|
return {
|
|
...manifest,
|
|
dependencies: await normalizeWorkspaceDependencies(
|
|
path.resolve(packageRoot, "..", ".."),
|
|
manifest.dependencies,
|
|
),
|
|
};
|
|
}
|
|
|
|
async function collectWorkspacePackageRoots(params: {
|
|
repoRoot: string;
|
|
entryRoot: string;
|
|
}): Promise<string[]> {
|
|
const orderedRoots: string[] = [];
|
|
const visitedNames = new Set<string>();
|
|
|
|
async function visit(packageRoot: string, expectedName?: string): Promise<void> {
|
|
const manifest = await readRawPackageManifest(packageRoot);
|
|
if (expectedName && manifest.name !== expectedName) {
|
|
throw new Error(
|
|
`workspace dependency ${expectedName} resolved to unexpected package ${manifest.name}`,
|
|
);
|
|
}
|
|
if (visitedNames.has(manifest.name)) {
|
|
return;
|
|
}
|
|
visitedNames.add(manifest.name);
|
|
const workspaceDependencies = Object.entries(manifest.dependencies ?? {})
|
|
.filter(([, spec]) => spec.startsWith("workspace:"))
|
|
.map(([name]) => name)
|
|
.toSorted();
|
|
for (const dependencyName of workspaceDependencies) {
|
|
await visit(resolveWorkspacePackageRoot(params.repoRoot, dependencyName), dependencyName);
|
|
}
|
|
orderedRoots.push(packageRoot);
|
|
}
|
|
|
|
await visit(params.entryRoot);
|
|
return orderedRoots;
|
|
}
|
|
|
|
async function createPackStagingRoot(
|
|
packageRoot: string,
|
|
destinationRoot: string,
|
|
): Promise<string> {
|
|
const manifest = await readPackageManifest(packageRoot);
|
|
const packageSlug = manifest.name.replace(/^@/, "").replace("/", "-");
|
|
const stagingRoot = path.join(destinationRoot, `pack-${packageSlug}`);
|
|
await fs.mkdir(stagingRoot, { recursive: true });
|
|
await fs.writeFile(path.join(stagingRoot, "package.json"), JSON.stringify(manifest, null, 2));
|
|
const files: string[] = Array.isArray(manifest.files) ? (manifest.files as string[]) : [];
|
|
for (const entry of files) {
|
|
if (typeof entry === "string") {
|
|
await fs.cp(path.join(packageRoot, entry), path.join(stagingRoot, entry), {
|
|
recursive: true,
|
|
});
|
|
}
|
|
}
|
|
return stagingRoot;
|
|
}
|
|
|
|
function tarballFileName(manifest: PackageManifest): string {
|
|
return `${manifest.name.replace(/^@/, "").replace("/", "-")}-${manifest.version}.tgz`;
|
|
}
|
|
|
|
function closeServer(server: Server): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
|
|
async function startOpenClawRegistry(packages: PackedPackage[]): Promise<{
|
|
registryUrl: string;
|
|
close: () => Promise<void>;
|
|
}> {
|
|
const byName = new Map(packages.map((pkg) => [pkg.manifest.name, pkg]));
|
|
const byTarball = new Map(packages.map((pkg) => [path.basename(pkg.tarball), pkg]));
|
|
const server = createServer((req, res) => {
|
|
const host = req.headers.host ?? "127.0.0.1";
|
|
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
const decodedPath = decodeURIComponent(url.pathname);
|
|
if (decodedPath.startsWith("/tarballs/")) {
|
|
const pkg = byTarball.get(decodedPath.slice("/tarballs/".length));
|
|
if (!pkg) {
|
|
res.writeHead(404).end();
|
|
return;
|
|
}
|
|
res.writeHead(200, { "content-type": "application/octet-stream" });
|
|
createReadStream(pkg.tarball).pipe(res);
|
|
return;
|
|
}
|
|
const pkg = byName.get(decodedPath.slice(1));
|
|
if (!pkg) {
|
|
res.writeHead(404).end();
|
|
return;
|
|
}
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
name: pkg.manifest.name,
|
|
"dist-tags": { latest: pkg.manifest.version },
|
|
versions: {
|
|
[pkg.manifest.version]: {
|
|
...pkg.manifest,
|
|
dist: {
|
|
tarball: `http://${host}/tarballs/${encodeURIComponent(path.basename(pkg.tarball))}`,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
server.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
await closeServer(server);
|
|
throw new Error("registry server did not bind to a TCP port");
|
|
}
|
|
return {
|
|
registryUrl: `http://127.0.0.1:${address.port}/`,
|
|
close: () => closeServer(server),
|
|
};
|
|
}
|
|
|
|
export async function createPackedSdkConsumer(): Promise<PackedSdkConsumer> {
|
|
const repoRoot = process.cwd();
|
|
const packageRoots = await collectWorkspacePackageRoots({
|
|
repoRoot,
|
|
entryRoot: path.join(repoRoot, "packages", "sdk"),
|
|
});
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sdk-consumer-"));
|
|
try {
|
|
const packedPackages: PackedPackage[] = [];
|
|
const buildLockTarget = path.join(repoRoot, ".artifacts", "sdk-package-e2e-build");
|
|
await fs.mkdir(path.dirname(buildLockTarget), { recursive: true });
|
|
// SDK package builds clean shared dist directories. Keep build, staging,
|
|
// packing, and validation atomic before consumers use private tarball roots.
|
|
await withFileLock(buildLockTarget, SDK_PACKAGE_BUILD_LOCK_OPTIONS, async () => {
|
|
for (const packageRoot of packageRoots) {
|
|
const manifest = await readRawPackageManifest(packageRoot);
|
|
await runPnpmCommand(
|
|
["--filter", manifest.name, manifest.scripts?.prepack ? "prepack" : "build"],
|
|
{ cwd: repoRoot, timeoutMs: 180_000 },
|
|
);
|
|
}
|
|
for (const packageRoot of packageRoots) {
|
|
const stagingRoot = await createPackStagingRoot(packageRoot, root);
|
|
await runNpmCommand(["pack", "--ignore-scripts", "--pack-destination", root], {
|
|
cwd: stagingRoot,
|
|
});
|
|
}
|
|
for (const packageRoot of packageRoots) {
|
|
const manifest = await readPackageManifest(packageRoot);
|
|
const tarball = path.join(root, tarballFileName(manifest));
|
|
await fs.stat(tarball);
|
|
packedPackages.push({ manifest, tarball });
|
|
}
|
|
});
|
|
const sdkTarball =
|
|
packedPackages.find((pkg) => pkg.manifest.name === "@openclaw/sdk")?.tarball ?? "";
|
|
if (!sdkTarball) {
|
|
throw new Error("packed @openclaw/sdk tarball was not created");
|
|
}
|
|
const registry = await startOpenClawRegistry(packedPackages);
|
|
await fs.writeFile(
|
|
path.join(root, "package.json"),
|
|
JSON.stringify({ private: true, type: "module" }),
|
|
);
|
|
await fs.writeFile(path.join(root, ".npmrc"), `@openclaw:registry=${registry.registryUrl}`);
|
|
try {
|
|
await runNpmCommand(["install", "--ignore-scripts", "--no-audit", "--no-fund", sdkTarball], {
|
|
cwd: root,
|
|
});
|
|
} finally {
|
|
await registry.close();
|
|
}
|
|
} catch (error) {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
root,
|
|
run: async (script) => {
|
|
await runCommand(process.execPath, createNodeEvalArgs(script, { evalFlag: "-e" }), {
|
|
cwd: root,
|
|
});
|
|
},
|
|
typecheck: async (source) => {
|
|
const sourcePath = path.join(root, "consumer.ts");
|
|
const tsconfigPath = path.join(root, "tsconfig.json");
|
|
await fs.writeFile(sourcePath, source);
|
|
await fs.writeFile(
|
|
tsconfigPath,
|
|
JSON.stringify({
|
|
compilerOptions: {
|
|
module: "NodeNext",
|
|
moduleResolution: "NodeNext",
|
|
noEmit: true,
|
|
skipLibCheck: true,
|
|
strict: true,
|
|
types: [],
|
|
},
|
|
include: ["consumer.ts"],
|
|
}),
|
|
);
|
|
await runCommand(
|
|
process.execPath,
|
|
[path.join(repoRoot, "scripts", "run-tsgo.mjs"), "-p", tsconfigPath, "--pretty", "false"],
|
|
{ cwd: repoRoot },
|
|
);
|
|
},
|
|
cleanup: () => fs.rm(root, { recursive: true, force: true }),
|
|
};
|
|
}
|