272 lines
8.6 KiB
TypeScript
272 lines
8.6 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { runCommandWithTimeout } from "../process/exec.js";
|
|
import { fileExists } from "./archive.js";
|
|
import { assertCanonicalPathWithinBase } from "./install-safe-path.js";
|
|
|
|
const INSTALL_BASE_CHANGED_ERROR_MESSAGE = "install base directory changed during install";
|
|
const INSTALL_BASE_CHANGED_ABORT_WARNING =
|
|
"Install base directory changed during install; aborting staged publish.";
|
|
const INSTALL_BASE_CHANGED_BACKUP_WARNING =
|
|
"Install base directory changed before backup cleanup; leaving backup in place.";
|
|
|
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
async function sanitizeManifestForNpmInstall(targetDir: string): Promise<void> {
|
|
const manifestPath = path.join(targetDir, "package.json");
|
|
let manifestRaw = "";
|
|
try {
|
|
manifestRaw = await fs.readFile(manifestPath, "utf-8");
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
let manifest: Record<string, unknown>;
|
|
try {
|
|
const parsed = JSON.parse(manifestRaw) as unknown;
|
|
if (!isObjectRecord(parsed)) {
|
|
return;
|
|
}
|
|
manifest = parsed;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const devDependencies = manifest.devDependencies;
|
|
if (!isObjectRecord(devDependencies)) {
|
|
return;
|
|
}
|
|
|
|
const filteredEntries = Object.entries(devDependencies).filter(([, rawSpec]) => {
|
|
const spec = typeof rawSpec === "string" ? rawSpec.trim() : "";
|
|
return !spec.startsWith("workspace:");
|
|
});
|
|
if (filteredEntries.length === Object.keys(devDependencies).length) {
|
|
return;
|
|
}
|
|
|
|
if (filteredEntries.length === 0) {
|
|
delete manifest.devDependencies;
|
|
} else {
|
|
manifest.devDependencies = Object.fromEntries(filteredEntries);
|
|
}
|
|
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
}
|
|
|
|
async function assertInstallBoundaryPaths(params: {
|
|
installBaseDir: string;
|
|
candidatePaths: string[];
|
|
}): Promise<void> {
|
|
for (const candidatePath of params.candidatePaths) {
|
|
await assertCanonicalPathWithinBase({
|
|
baseDir: params.installBaseDir,
|
|
candidatePath,
|
|
boundaryLabel: "install directory",
|
|
});
|
|
}
|
|
}
|
|
|
|
function isRelativePathInsideBase(relativePath: string): boolean {
|
|
return (
|
|
Boolean(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`)
|
|
);
|
|
}
|
|
|
|
function isInstallBaseChangedError(error: unknown): boolean {
|
|
return error instanceof Error && error.message === INSTALL_BASE_CHANGED_ERROR_MESSAGE;
|
|
}
|
|
|
|
async function assertInstallBaseStable(params: {
|
|
installBaseDir: string;
|
|
expectedRealPath: string;
|
|
}): Promise<void> {
|
|
const baseLstat = await fs.lstat(params.installBaseDir);
|
|
if (!baseLstat.isDirectory() || baseLstat.isSymbolicLink()) {
|
|
throw new Error(INSTALL_BASE_CHANGED_ERROR_MESSAGE);
|
|
}
|
|
const currentRealPath = await fs.realpath(params.installBaseDir);
|
|
if (currentRealPath !== params.expectedRealPath) {
|
|
throw new Error(INSTALL_BASE_CHANGED_ERROR_MESSAGE);
|
|
}
|
|
}
|
|
|
|
async function cleanupInstallTempDir(dirPath: string | null): Promise<void> {
|
|
if (!dirPath) {
|
|
return;
|
|
}
|
|
await fs.rm(dirPath, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
|
|
async function resolveInstallPublishTarget(params: {
|
|
installBaseDir: string;
|
|
targetDir: string;
|
|
}): Promise<{ installBaseRealPath: string; canonicalTargetDir: string }> {
|
|
const installBaseResolved = path.resolve(params.installBaseDir);
|
|
const targetResolved = path.resolve(params.targetDir);
|
|
const targetRelativePath = path.relative(installBaseResolved, targetResolved);
|
|
if (!isRelativePathInsideBase(targetRelativePath)) {
|
|
throw new Error("invalid install target path");
|
|
}
|
|
const installBaseRealPath = await fs.realpath(params.installBaseDir);
|
|
return {
|
|
installBaseRealPath,
|
|
canonicalTargetDir: path.join(installBaseRealPath, targetRelativePath),
|
|
};
|
|
}
|
|
|
|
export async function installPackageDir(params: {
|
|
sourceDir: string;
|
|
targetDir: string;
|
|
mode: "install" | "update";
|
|
timeoutMs: number;
|
|
logger?: { info?: (message: string) => void; warn?: (message: string) => void };
|
|
copyErrorPrefix: string;
|
|
hasDeps: boolean;
|
|
depsLogMessage: string;
|
|
afterCopy?: (installedDir: string) => void | Promise<void>;
|
|
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
params.logger?.info?.(`Installing to ${params.targetDir}…`);
|
|
const installBaseDir = path.dirname(params.targetDir);
|
|
await fs.mkdir(installBaseDir, { recursive: true });
|
|
await assertInstallBoundaryPaths({
|
|
installBaseDir,
|
|
candidatePaths: [params.targetDir],
|
|
});
|
|
let installBaseRealPath: string;
|
|
let canonicalTargetDir: string;
|
|
try {
|
|
({ installBaseRealPath, canonicalTargetDir } = await resolveInstallPublishTarget({
|
|
installBaseDir,
|
|
targetDir: params.targetDir,
|
|
}));
|
|
} catch (err) {
|
|
return { ok: false, error: `${params.copyErrorPrefix}: ${String(err)}` };
|
|
}
|
|
|
|
let stageDir: string | null = null;
|
|
let backupDir: string | null = null;
|
|
const fail = async (error: string, cause?: unknown) => {
|
|
const installBaseChanged = isInstallBaseChangedError(cause);
|
|
if (installBaseChanged) {
|
|
params.logger?.warn?.(INSTALL_BASE_CHANGED_ABORT_WARNING);
|
|
} else {
|
|
await restoreBackup();
|
|
if (stageDir) {
|
|
await cleanupInstallTempDir(stageDir);
|
|
stageDir = null;
|
|
}
|
|
}
|
|
return { ok: false as const, error };
|
|
};
|
|
const restoreBackup = async () => {
|
|
if (!backupDir) {
|
|
return;
|
|
}
|
|
await fs.rename(backupDir, canonicalTargetDir).catch(() => undefined);
|
|
backupDir = null;
|
|
};
|
|
|
|
try {
|
|
await assertInstallBoundaryPaths({
|
|
installBaseDir: installBaseRealPath,
|
|
candidatePaths: [canonicalTargetDir],
|
|
});
|
|
stageDir = await fs.mkdtemp(path.join(installBaseRealPath, ".openclaw-install-stage-"));
|
|
await fs.cp(params.sourceDir, stageDir, { recursive: true });
|
|
} catch (err) {
|
|
return await fail(`${params.copyErrorPrefix}: ${String(err)}`, err);
|
|
}
|
|
|
|
try {
|
|
await params.afterCopy?.(stageDir);
|
|
} catch (err) {
|
|
return await fail(`post-copy validation failed: ${String(err)}`, err);
|
|
}
|
|
|
|
if (params.hasDeps) {
|
|
await sanitizeManifestForNpmInstall(stageDir);
|
|
params.logger?.info?.(params.depsLogMessage);
|
|
const npmRes = await runCommandWithTimeout(
|
|
["npm", "install", "--omit=dev", "--omit=peer", "--silent", "--ignore-scripts"],
|
|
{
|
|
timeoutMs: Math.max(params.timeoutMs, 300_000),
|
|
cwd: stageDir,
|
|
},
|
|
);
|
|
if (npmRes.code !== 0) {
|
|
return await fail(`npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`);
|
|
}
|
|
}
|
|
|
|
if (params.mode === "update" && (await fileExists(canonicalTargetDir))) {
|
|
const backupRoot = path.join(installBaseRealPath, ".openclaw-install-backups");
|
|
backupDir = path.join(backupRoot, `${path.basename(canonicalTargetDir)}-${Date.now()}`);
|
|
try {
|
|
await fs.mkdir(backupRoot, { recursive: true });
|
|
await assertInstallBoundaryPaths({
|
|
installBaseDir: installBaseRealPath,
|
|
candidatePaths: [backupDir],
|
|
});
|
|
await assertInstallBaseStable({
|
|
installBaseDir,
|
|
expectedRealPath: installBaseRealPath,
|
|
});
|
|
await fs.rename(canonicalTargetDir, backupDir);
|
|
} catch (err) {
|
|
return await fail(`${params.copyErrorPrefix}: ${String(err)}`, err);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await assertInstallBaseStable({
|
|
installBaseDir,
|
|
expectedRealPath: installBaseRealPath,
|
|
});
|
|
await fs.rename(stageDir, canonicalTargetDir);
|
|
stageDir = null;
|
|
} catch (err) {
|
|
return await fail(`${params.copyErrorPrefix}: ${String(err)}`, err);
|
|
}
|
|
|
|
if (backupDir) {
|
|
try {
|
|
await assertInstallBaseStable({
|
|
installBaseDir,
|
|
expectedRealPath: installBaseRealPath,
|
|
});
|
|
} catch (err) {
|
|
if (isInstallBaseChangedError(err)) {
|
|
params.logger?.warn?.(INSTALL_BASE_CHANGED_BACKUP_WARNING);
|
|
}
|
|
backupDir = null;
|
|
}
|
|
}
|
|
if (backupDir) {
|
|
await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
if (stageDir) {
|
|
await cleanupInstallTempDir(stageDir);
|
|
}
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function installPackageDirWithManifestDeps(params: {
|
|
sourceDir: string;
|
|
targetDir: string;
|
|
mode: "install" | "update";
|
|
timeoutMs: number;
|
|
logger?: { info?: (message: string) => void; warn?: (message: string) => void };
|
|
copyErrorPrefix: string;
|
|
depsLogMessage: string;
|
|
manifestDependencies?: Record<string, unknown>;
|
|
afterCopy?: (installedDir: string) => void | Promise<void>;
|
|
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
return installPackageDir({
|
|
...params,
|
|
hasDeps: Object.keys(params.manifestDependencies ?? {}).length > 0,
|
|
});
|
|
}
|