
























@@ -0,0 +1,175 @@
1+import fs from "node:fs/promises";
2+import path from "node:path";
3+import { note } from "../../../terminal/note.js";
4+import { shortenHomePath } from "../../../utils.js";
5+6+const PLUGIN_RUNTIME_DEPS_MARKER = "plugin-runtime-deps";
7+const MAX_REPORTED = 6;
8+9+interface FsLike {
10+readdir(dir: string, options: { withFileTypes: true }): Promise<readonly DirentLike[]>;
11+lstat(file: string): Promise<StatsLike>;
12+readlink(file: string): Promise<string>;
13+stat(file: string): Promise<unknown>;
14+rm(file: string, options: { force: true }): Promise<void>;
15+}
16+17+interface DirentLike {
18+name: string;
19+isDirectory(): boolean;
20+isSymbolicLink(): boolean;
21+}
22+23+interface StatsLike {
24+isSymbolicLink(): boolean;
25+}
26+27+export interface StalePluginRuntimeSymlink {
28+readonly name: string;
29+readonly path: string;
30+readonly target: string;
31+}
32+33+export interface PluginRuntimeSymlinkOptions {
34+readonly fs?: FsLike;
35+readonly staleRoots?: readonly string[];
36+}
37+38+const DEFAULT_FS: FsLike = {
39+readdir: (dir, options) => fs.readdir(dir, options) as Promise<DirentLike[]>,
40+lstat: (file) => fs.lstat(file),
41+readlink: (file) => fs.readlink(file),
42+stat: (file) => fs.stat(file),
43+rm: (file, options) => fs.rm(file, options),
44+};
45+46+export async function collectStalePluginRuntimeSymlinks(
47+packageRoot: string | null | undefined,
48+options: PluginRuntimeSymlinkOptions = {},
49+): Promise<StalePluginRuntimeSymlink[]> {
50+if (!packageRoot) {
51+return [];
52+}
53+const containingNodeModules = path.dirname(packageRoot);
54+if (path.basename(containingNodeModules) !== "node_modules") {
55+return [];
56+}
57+58+const fsApi = options.fs ?? DEFAULT_FS;
59+const staleRoots = uniqueResolvedRoots(options.staleRoots ?? []);
60+const stale: StalePluginRuntimeSymlink[] = [];
61+const entries = await fsApi
62+.readdir(containingNodeModules, { withFileTypes: true })
63+.catch(() => [] as DirentLike[]);
64+for (const entry of entries) {
65+if (entry.isDirectory() && entry.name.startsWith("@")) {
66+const scopeDir = path.join(containingNodeModules, entry.name);
67+const scopeEntries = await fsApi
68+.readdir(scopeDir, { withFileTypes: true })
69+.catch(() => [] as DirentLike[]);
70+for (const scopeEntry of scopeEntries) {
71+const fullPath = path.join(scopeDir, scopeEntry.name);
72+const target = await inspectCandidate(fullPath, fsApi, staleRoots);
73+if (target) {
74+stale.push({ name: `${entry.name}/${scopeEntry.name}`, path: fullPath, target });
75+}
76+}
77+continue;
78+}
79+if (!entry.isSymbolicLink()) {
80+continue;
81+}
82+const fullPath = path.join(containingNodeModules, entry.name);
83+const target = await inspectCandidate(fullPath, fsApi, staleRoots);
84+if (target) {
85+stale.push({ name: entry.name, path: fullPath, target });
86+}
87+}
88+89+return stale.toSorted((left, right) => left.name.localeCompare(right.name));
90+}
91+92+export async function noteStalePluginRuntimeSymlinks(
93+packageRoot: string | null | undefined,
94+options: PluginRuntimeSymlinkOptions & {
95+readonly noteFn?: (message: string, title?: string) => void;
96+readonly shortenPath?: (value: string) => string;
97+} = {},
98+): Promise<void> {
99+const stale = await collectStalePluginRuntimeSymlinks(packageRoot, options);
100+if (stale.length === 0) {
101+return;
102+}
103+104+const shortenPath = options.shortenPath ?? shortenHomePath;
105+const lines = [
106+"- Plugin-runtime symlinks under the global Node prefix point at pruned",
107+` ${PLUGIN_RUNTIME_DEPS_MARKER} directories from a previous OpenClaw install.`,
108+"- Bundled plugin ESM imports can fail with ERR_MODULE_NOT_FOUND until repaired.",
109+];
110+for (const item of stale.slice(0, MAX_REPORTED)) {
111+lines.push(` - ${item.name} -> ${shortenPath(item.target)}`);
112+}
113+if (stale.length > MAX_REPORTED) {
114+lines.push(` - ...and ${stale.length - MAX_REPORTED} more`);
115+}
116+lines.push("- Repair: run `openclaw doctor --fix` to remove the dangling symlinks.");
117+(options.noteFn ?? note)(lines.join("\n"), "Plugin-runtime symlinks");
118+}
119+120+export async function removeStalePluginRuntimeSymlinks(
121+packageRoot: string | null | undefined,
122+options: PluginRuntimeSymlinkOptions = {},
123+): Promise<{ changes: string[]; warnings: string[] }> {
124+const fsApi = options.fs ?? DEFAULT_FS;
125+const changes: string[] = [];
126+const warnings: string[] = [];
127+for (const item of await collectStalePluginRuntimeSymlinks(packageRoot, options)) {
128+try {
129+await fsApi.rm(item.path, { force: true });
130+changes.push(`Removed stale plugin-runtime symlink: ${item.path}`);
131+} catch (error) {
132+warnings.push(`Failed to remove stale plugin-runtime symlink ${item.path}: ${String(error)}`);
133+}
134+}
135+return { changes, warnings };
136+}
137+138+function uniqueResolvedRoots(values: readonly string[]): string[] {
139+return [...new Set(values.map((value) => path.resolve(value)))].toSorted((left, right) =>
140+left.localeCompare(right),
141+);
142+}
143+144+function isPathInsideRoot(candidate: string, root: string): boolean {
145+const relativePath = path.relative(root, candidate);
146+return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
147+}
148+149+async function inspectCandidate(
150+fullPath: string,
151+fsApi: FsLike,
152+staleRoots: readonly string[],
153+): Promise<string | null> {
154+const stat = await fsApi.lstat(fullPath).catch(() => null);
155+if (!stat?.isSymbolicLink()) {
156+return null;
157+}
158+const target = await fsApi.readlink(fullPath).catch(() => null);
159+if (!target || !target.includes(PLUGIN_RUNTIME_DEPS_MARKER)) {
160+return null;
161+}
162+const resolvedTarget = path.isAbsolute(target)
163+ ? target
164+ : path.resolve(path.dirname(fullPath), target);
165+if (staleRoots.some((root) => isPathInsideRoot(resolvedTarget, root))) {
166+return resolvedTarget;
167+}
168+try {
169+await fsApi.stat(resolvedTarget);
170+return null;
171+} catch (error) {
172+const code = (error as NodeJS.ErrnoException | undefined)?.code;
173+return code === "ENOENT" || code === "ENOTDIR" ? resolvedTarget : null;
174+}
175+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。