mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix: prune stale plugin runtime symlinks
This commit is contained in:
@@ -252,7 +252,7 @@ legacy_runtime_deps_symlink_plugin() {
|
||||
|
||||
legacy_runtime_deps_symlink_target() {
|
||||
local plugin="$1"
|
||||
printf '%s/dist/extensions/%s/node_modules\n' "$(package_root)" "$plugin"
|
||||
printf '%s/@openclaw-upgrade-survivor/%s-runtime-dep\n' "$(dirname "$(package_root)")" "$plugin"
|
||||
}
|
||||
|
||||
legacy_runtime_deps_symlink_source() {
|
||||
@@ -431,6 +431,7 @@ seed_legacy_runtime_deps_symlink() {
|
||||
source_dir="$(legacy_runtime_deps_symlink_source "$plugin")"
|
||||
target_dir="$(legacy_runtime_deps_symlink_target "$plugin")"
|
||||
mkdir -p "$source_dir"
|
||||
mkdir -p "$(dirname "$target_dir")"
|
||||
printf '{"name":"openclaw-upgrade-survivor-legacy-runtime-deps","version":"0.0.0"}\n' \
|
||||
>"$source_dir/package.json"
|
||||
rm -rf "$target_dir"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
@@ -366,13 +367,101 @@ export function collectLegacyPluginRuntimeDepsStateRoots(params = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function isPathInsideRoot(candidate, root) {
|
||||
const relativePath = relative(root, candidate);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function collectLegacyPluginRuntimeDepsSymlinkPaths(roots, params = {}) {
|
||||
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
|
||||
const readDir = params.readdirSync ?? readdirSync;
|
||||
const pathLstat = params.lstatSync ?? lstatSync;
|
||||
const readLink = params.readlinkSync ?? readlinkSync;
|
||||
const pathExists = params.existsSync ?? existsSync;
|
||||
const containingNodeModules = dirname(packageRoot);
|
||||
if (basename(containingNodeModules) !== "node_modules") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedRoots = roots.map((root) => pathResolve(root));
|
||||
const candidates = [];
|
||||
function addCandidate(linkPath) {
|
||||
let linkStat;
|
||||
try {
|
||||
linkStat = pathLstat(linkPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!linkStat.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
let target;
|
||||
try {
|
||||
target = readLink(linkPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!target.includes(LEGACY_PLUGIN_RUNTIME_DEPS_DIR)) {
|
||||
return;
|
||||
}
|
||||
const resolvedTarget = pathResolve(dirname(linkPath), target);
|
||||
const pointsIntoPrunedRoot = normalizedRoots.some((root) =>
|
||||
isPathInsideRoot(resolvedTarget, root),
|
||||
);
|
||||
if (pointsIntoPrunedRoot || !pathExists(resolvedTarget)) {
|
||||
candidates.push(linkPath);
|
||||
}
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readDir(containingNodeModules, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith("@")) {
|
||||
const scopeDir = join(containingNodeModules, entry.name);
|
||||
let scopeEntries;
|
||||
try {
|
||||
scopeEntries = readDir(scopeDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const scopeEntry of scopeEntries) {
|
||||
addCandidate(join(scopeDir, scopeEntry.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
addCandidate(join(containingNodeModules, entry.name));
|
||||
}
|
||||
}
|
||||
return [...new Set(candidates.map((entry) => pathResolve(entry)))].toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
export function pruneLegacyPluginRuntimeDepsState(params = {}) {
|
||||
const pathExists = params.existsSync ?? existsSync;
|
||||
const removePath = params.rmSync ?? rmSync;
|
||||
const log = params.log ?? console;
|
||||
const removed = [];
|
||||
const removedSymlinks = [];
|
||||
const roots = collectLegacyPluginRuntimeDepsStateRoots(params);
|
||||
|
||||
for (const root of collectLegacyPluginRuntimeDepsStateRoots(params)) {
|
||||
for (const linkPath of collectLegacyPluginRuntimeDepsSymlinkPaths(roots, params)) {
|
||||
try {
|
||||
removePath(linkPath, { force: true });
|
||||
removedSymlinks.push(linkPath);
|
||||
} catch (error) {
|
||||
log.warn?.(
|
||||
`[postinstall] could not prune legacy plugin runtime deps symlink ${linkPath}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
if (!pathExists(root)) {
|
||||
continue;
|
||||
}
|
||||
@@ -389,6 +478,11 @@ export function pruneLegacyPluginRuntimeDepsState(params = {}) {
|
||||
if (removed.length > 0) {
|
||||
log.log?.(`[postinstall] pruned legacy plugin runtime deps: ${removed.join(", ")}`);
|
||||
}
|
||||
if (removedSymlinks.length > 0) {
|
||||
log.log?.(
|
||||
`[postinstall] pruned legacy plugin runtime deps symlinks: ${removedSymlinks.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -91,4 +91,41 @@ describe("cleanupLegacyPluginDependencyState", () => {
|
||||
await expect(fs.stat(explicitStageDir)).rejects.toThrow();
|
||||
await expect(fs.stat(path.join(stateDirectory, "plugin-runtime-deps"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("removes dangling global plugin-runtime symlinks that point at legacy runtime deps", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const packageRoot = path.join(tempDir, "prefix", "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
const legacyTarget = path.join(
|
||||
legacyRuntimeRoot,
|
||||
"openclaw-2026.4.29-slack",
|
||||
"node_modules",
|
||||
"@slack",
|
||||
"web-api",
|
||||
);
|
||||
const slackScope = path.join(nodeModulesRoot, "@slack");
|
||||
const slackLink = path.join(slackScope, "web-api");
|
||||
const liveTarget = path.join(tempDir, "live", "@slack", "bolt");
|
||||
const liveLink = path.join(slackScope, "bolt");
|
||||
|
||||
await fs.mkdir(legacyTarget, { recursive: true });
|
||||
await fs.writeFile(path.join(legacyTarget, "package.json"), "{}\n");
|
||||
await fs.mkdir(liveTarget, { recursive: true });
|
||||
await fs.writeFile(path.join(liveTarget, "package.json"), "{}\n");
|
||||
await fs.mkdir(slackScope, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.symlink(legacyTarget, slackLink, "dir");
|
||||
await fs.symlink(liveTarget, liveLink, "dir");
|
||||
|
||||
const result = await cleanupLegacyPluginDependencyState({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toContain(`Removed stale plugin-runtime symlink: ${slackLink}`);
|
||||
await expect(fs.lstat(slackLink)).rejects.toThrow();
|
||||
await expect(fs.lstat(liveLink)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { resolveStateDir } from "../../../config/paths.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js";
|
||||
import { resolveConfigDir, resolveUserPath } from "../../../utils.js";
|
||||
import { removeStalePluginRuntimeSymlinks } from "./plugin-runtime-symlinks.js";
|
||||
|
||||
const LEGACY_DIRECT_CHILD_NAMES = new Set(["plugin-runtime-deps", "bundled-plugin-runtime-deps"]);
|
||||
|
||||
@@ -122,9 +123,22 @@ export async function cleanupLegacyPluginDependencyState(params: {
|
||||
const env = params.env ?? process.env;
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const target of await collectLegacyPluginDependencyTargets(env, {
|
||||
packageRoot: params.packageRoot,
|
||||
})) {
|
||||
const packageRoot =
|
||||
params.packageRoot ??
|
||||
resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const targets = await collectLegacyPluginDependencyTargets(env, {
|
||||
packageRoot,
|
||||
});
|
||||
const staleSymlinks = await removeStalePluginRuntimeSymlinks(packageRoot, {
|
||||
staleRoots: targets,
|
||||
});
|
||||
changes.push(...staleSymlinks.changes);
|
||||
warnings.push(...staleSymlinks.warnings);
|
||||
for (const target of targets) {
|
||||
if (!(await pathExists(target))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
175
src/commands/doctor/shared/plugin-runtime-symlinks.ts
Normal file
175
src/commands/doctor/shared/plugin-runtime-symlinks.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { note } from "../../../terminal/note.js";
|
||||
import { shortenHomePath } from "../../../utils.js";
|
||||
|
||||
const PLUGIN_RUNTIME_DEPS_MARKER = "plugin-runtime-deps";
|
||||
const MAX_REPORTED = 6;
|
||||
|
||||
interface FsLike {
|
||||
readdir(dir: string, options: { withFileTypes: true }): Promise<readonly DirentLike[]>;
|
||||
lstat(file: string): Promise<StatsLike>;
|
||||
readlink(file: string): Promise<string>;
|
||||
stat(file: string): Promise<unknown>;
|
||||
rm(file: string, options: { force: true }): Promise<void>;
|
||||
}
|
||||
|
||||
interface DirentLike {
|
||||
name: string;
|
||||
isDirectory(): boolean;
|
||||
isSymbolicLink(): boolean;
|
||||
}
|
||||
|
||||
interface StatsLike {
|
||||
isSymbolicLink(): boolean;
|
||||
}
|
||||
|
||||
export interface StalePluginRuntimeSymlink {
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly target: string;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeSymlinkOptions {
|
||||
readonly fs?: FsLike;
|
||||
readonly staleRoots?: readonly string[];
|
||||
}
|
||||
|
||||
const DEFAULT_FS: FsLike = {
|
||||
readdir: (dir, options) => fs.readdir(dir, options) as Promise<DirentLike[]>,
|
||||
lstat: (file) => fs.lstat(file),
|
||||
readlink: (file) => fs.readlink(file),
|
||||
stat: (file) => fs.stat(file),
|
||||
rm: (file, options) => fs.rm(file, options),
|
||||
};
|
||||
|
||||
export async function collectStalePluginRuntimeSymlinks(
|
||||
packageRoot: string | null | undefined,
|
||||
options: PluginRuntimeSymlinkOptions = {},
|
||||
): Promise<StalePluginRuntimeSymlink[]> {
|
||||
if (!packageRoot) {
|
||||
return [];
|
||||
}
|
||||
const containingNodeModules = path.dirname(packageRoot);
|
||||
if (path.basename(containingNodeModules) !== "node_modules") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fsApi = options.fs ?? DEFAULT_FS;
|
||||
const staleRoots = uniqueResolvedRoots(options.staleRoots ?? []);
|
||||
const stale: StalePluginRuntimeSymlink[] = [];
|
||||
const entries = await fsApi
|
||||
.readdir(containingNodeModules, { withFileTypes: true })
|
||||
.catch(() => [] as DirentLike[]);
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith("@")) {
|
||||
const scopeDir = path.join(containingNodeModules, entry.name);
|
||||
const scopeEntries = await fsApi
|
||||
.readdir(scopeDir, { withFileTypes: true })
|
||||
.catch(() => [] as DirentLike[]);
|
||||
for (const scopeEntry of scopeEntries) {
|
||||
const fullPath = path.join(scopeDir, scopeEntry.name);
|
||||
const target = await inspectCandidate(fullPath, fsApi, staleRoots);
|
||||
if (target) {
|
||||
stale.push({ name: `${entry.name}/${scopeEntry.name}`, path: fullPath, target });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(containingNodeModules, entry.name);
|
||||
const target = await inspectCandidate(fullPath, fsApi, staleRoots);
|
||||
if (target) {
|
||||
stale.push({ name: entry.name, path: fullPath, target });
|
||||
}
|
||||
}
|
||||
|
||||
return stale.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export async function noteStalePluginRuntimeSymlinks(
|
||||
packageRoot: string | null | undefined,
|
||||
options: PluginRuntimeSymlinkOptions & {
|
||||
readonly noteFn?: (message: string, title?: string) => void;
|
||||
readonly shortenPath?: (value: string) => string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const stale = await collectStalePluginRuntimeSymlinks(packageRoot, options);
|
||||
if (stale.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortenPath = options.shortenPath ?? shortenHomePath;
|
||||
const lines = [
|
||||
"- Plugin-runtime symlinks under the global Node prefix point at pruned",
|
||||
` ${PLUGIN_RUNTIME_DEPS_MARKER} directories from a previous OpenClaw install.`,
|
||||
"- Bundled plugin ESM imports can fail with ERR_MODULE_NOT_FOUND until repaired.",
|
||||
];
|
||||
for (const item of stale.slice(0, MAX_REPORTED)) {
|
||||
lines.push(` - ${item.name} -> ${shortenPath(item.target)}`);
|
||||
}
|
||||
if (stale.length > MAX_REPORTED) {
|
||||
lines.push(` - ...and ${stale.length - MAX_REPORTED} more`);
|
||||
}
|
||||
lines.push("- Repair: run `openclaw doctor --fix` to remove the dangling symlinks.");
|
||||
(options.noteFn ?? note)(lines.join("\n"), "Plugin-runtime symlinks");
|
||||
}
|
||||
|
||||
export async function removeStalePluginRuntimeSymlinks(
|
||||
packageRoot: string | null | undefined,
|
||||
options: PluginRuntimeSymlinkOptions = {},
|
||||
): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const fsApi = options.fs ?? DEFAULT_FS;
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const item of await collectStalePluginRuntimeSymlinks(packageRoot, options)) {
|
||||
try {
|
||||
await fsApi.rm(item.path, { force: true });
|
||||
changes.push(`Removed stale plugin-runtime symlink: ${item.path}`);
|
||||
} catch (error) {
|
||||
warnings.push(`Failed to remove stale plugin-runtime symlink ${item.path}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
function uniqueResolvedRoots(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map((value) => path.resolve(value)))].toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function isPathInsideRoot(candidate: string, root: string): boolean {
|
||||
const relativePath = path.relative(root, candidate);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
async function inspectCandidate(
|
||||
fullPath: string,
|
||||
fsApi: FsLike,
|
||||
staleRoots: readonly string[],
|
||||
): Promise<string | null> {
|
||||
const stat = await fsApi.lstat(fullPath).catch(() => null);
|
||||
if (!stat?.isSymbolicLink()) {
|
||||
return null;
|
||||
}
|
||||
const target = await fsApi.readlink(fullPath).catch(() => null);
|
||||
if (!target || !target.includes(PLUGIN_RUNTIME_DEPS_MARKER)) {
|
||||
return null;
|
||||
}
|
||||
const resolvedTarget = path.isAbsolute(target)
|
||||
? target
|
||||
: path.resolve(path.dirname(fullPath), target);
|
||||
if (staleRoots.some((root) => isPathInsideRoot(resolvedTarget, root))) {
|
||||
return resolvedTarget;
|
||||
}
|
||||
try {
|
||||
await fsApi.stat(resolvedTarget);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === "ENOENT" || code === "ENOTDIR" ? resolvedTarget : null;
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,12 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
|
||||
|
||||
const { maybeRepairUiProtocolFreshness } = await import("../commands/doctor-ui.js");
|
||||
const { noteSourceInstallIssues } = await import("../commands/doctor-install.js");
|
||||
const { noteStalePluginRuntimeSymlinks } =
|
||||
await import("../commands/doctor/shared/plugin-runtime-symlinks.js");
|
||||
const { noteStartupOptimizationHints } = await import("../commands/doctor-platform-notes.js");
|
||||
await maybeRepairUiProtocolFreshness(effectiveRuntime, prompter);
|
||||
noteSourceInstallIssues(root);
|
||||
await noteStalePluginRuntimeSymlinks(root);
|
||||
noteStartupOptimizationHints();
|
||||
|
||||
const { loadAndMaybeMigrateDoctorConfig } = await import("../commands/doctor-config-flow.js");
|
||||
|
||||
@@ -448,6 +448,43 @@ describe("bundled plugin postinstall", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prunes global plugin-runtime symlinks before deleting their legacy targets", async () => {
|
||||
const prefix = await createTempDirAsync("openclaw-packaged-prefix-");
|
||||
const home = await createTempDirAsync("openclaw-packaged-home-");
|
||||
const packageRoot = path.join(prefix, "lib", "node_modules", "openclaw");
|
||||
const nodeModulesRoot = path.dirname(packageRoot);
|
||||
const legacyRuntimeRoot = path.join(home, ".openclaw", "plugin-runtime-deps");
|
||||
const legacyTarget = path.join(
|
||||
legacyRuntimeRoot,
|
||||
"openclaw-2026.4.29-slack",
|
||||
"node_modules",
|
||||
"@slack",
|
||||
"web-api",
|
||||
);
|
||||
const slackScope = path.join(nodeModulesRoot, "@slack");
|
||||
const slackLink = path.join(slackScope, "web-api");
|
||||
|
||||
await fs.mkdir(legacyTarget, { recursive: true });
|
||||
await fs.writeFile(path.join(legacyTarget, "package.json"), "{}\n");
|
||||
await fs.mkdir(slackScope, { recursive: true });
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
await fs.symlink(legacyTarget, slackLink, "dir");
|
||||
|
||||
const log = { log: vi.fn(), warn: vi.fn() };
|
||||
pruneLegacyPluginRuntimeDepsState({
|
||||
env: { HOME: home },
|
||||
packageRoot,
|
||||
log,
|
||||
});
|
||||
|
||||
await expect(fs.lstat(slackLink)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.stat(legacyRuntimeRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
expect(log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[postinstall] pruned legacy plugin runtime deps symlinks:"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy plugin runtime deps cleanup non-fatal", () => {
|
||||
const warn = vi.fn();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user