mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
feat(plugins): record local onboarding installs
Record onboarding plugin install source metadata for npm and local paths, while keeping local path install records portable and preserving uninstall cleanup for relative source paths.
This commit is contained in:
@@ -897,6 +897,13 @@ Official external npm entries should prefer an exact `npmSpec` plus
|
||||
`expectedIntegrity`. Bare package names and dist-tags still work for
|
||||
compatibility, but they surface source-plane warnings so the catalog can move
|
||||
toward pinned, integrity-checked installs without breaking existing plugins.
|
||||
When onboarding installs from a local catalog path, it records a
|
||||
`plugins.installs` entry with `source: "path"` and a workspace-relative
|
||||
`sourcePath` when possible. The absolute operational load path stays in
|
||||
`plugins.load.paths`; the install record avoids duplicating local workstation
|
||||
paths into long-lived config. This keeps local development installs visible to
|
||||
source-plane diagnostics without adding a second raw filesystem-path disclosure
|
||||
surface.
|
||||
|
||||
## Context engine plugins
|
||||
|
||||
|
||||
@@ -53,6 +53,23 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
});
|
||||
|
||||
it("passes npm specs and optional expected integrity to npm installs with progress", async () => {
|
||||
const npmResolution = {
|
||||
name: "@wecom/wecom-openclaw-plugin",
|
||||
version: "1.2.3",
|
||||
resolvedSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
|
||||
integrity: "sha512-wecom",
|
||||
shasum: "deadbeef",
|
||||
resolvedAt: "2026-04-24T00:00:00.000Z",
|
||||
};
|
||||
const installFields = {
|
||||
resolvedName: npmResolution.name,
|
||||
resolvedVersion: npmResolution.version,
|
||||
resolvedSpec: npmResolution.resolvedSpec,
|
||||
integrity: npmResolution.integrity,
|
||||
shasum: npmResolution.shasum,
|
||||
resolvedAt: npmResolution.resolvedAt,
|
||||
};
|
||||
buildNpmResolutionInstallFields.mockReturnValueOnce(installFields);
|
||||
installPluginFromNpmSpec.mockImplementation(async (params) => {
|
||||
params.logger?.info?.("Downloading demo-plugin…");
|
||||
return {
|
||||
@@ -60,10 +77,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
pluginId: "demo-plugin",
|
||||
targetDir: "/tmp/demo-plugin",
|
||||
version: "1.2.3",
|
||||
npmResolution: {
|
||||
resolvedSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
|
||||
integrity: "sha512-wecom",
|
||||
},
|
||||
npmResolution,
|
||||
};
|
||||
});
|
||||
const stop = vi.fn();
|
||||
@@ -95,6 +109,18 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
);
|
||||
expect(update).toHaveBeenCalledWith("Downloading demo-plugin…");
|
||||
expect(stop).toHaveBeenCalledWith("Installed WeCom plugin");
|
||||
expect(buildNpmResolutionInstallFields).toHaveBeenCalledWith(npmResolution);
|
||||
expect(recordPluginInstall).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
pluginId: "demo-plugin",
|
||||
source: "npm",
|
||||
spec: "@wecom/wecom-openclaw-plugin@1.2.3",
|
||||
installPath: "/tmp/demo-plugin",
|
||||
version: "1.2.3",
|
||||
...installFields,
|
||||
}),
|
||||
);
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.status).toBe("installed");
|
||||
});
|
||||
@@ -375,6 +401,143 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records local install source metadata when a local path is selected", async () => {
|
||||
await withTempDir({ prefix: "openclaw-onboarding-install-local-record-" }, async (temp) => {
|
||||
const workspaceDir = path.join(temp, "workspace");
|
||||
const pluginDir = path.join(workspaceDir, "plugins", "demo");
|
||||
await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true });
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: {},
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
label: "Demo Plugin",
|
||||
install: {
|
||||
npmSpec: "@demo/plugin@1.2.3",
|
||||
localPath: "plugins/demo",
|
||||
},
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async () => "local"),
|
||||
} as never,
|
||||
runtime: {} as never,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
const realPluginDir = await fs.realpath(pluginDir);
|
||||
expect(recordPluginInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [realPluginDir],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
pluginId: "demo-plugin",
|
||||
source: "path",
|
||||
sourcePath: "./plugins/demo",
|
||||
spec: "@demo/plugin@1.2.3",
|
||||
},
|
||||
);
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.status).toBe("installed");
|
||||
});
|
||||
});
|
||||
|
||||
it("records local install source metadata when npm install falls back to local", async () => {
|
||||
await withTempDir(
|
||||
{ prefix: "openclaw-onboarding-install-npm-fallback-record-" },
|
||||
async (temp) => {
|
||||
const workspaceDir = path.join(temp, "workspace");
|
||||
const pluginDir = path.join(workspaceDir, "plugins", "demo");
|
||||
await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true });
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
installPluginFromNpmSpec.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: "registry unavailable",
|
||||
});
|
||||
const note = vi.fn(async () => {});
|
||||
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: {},
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
label: "Demo Plugin",
|
||||
install: {
|
||||
npmSpec: "@demo/plugin@1.2.3",
|
||||
localPath: "plugins/demo",
|
||||
},
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async () => "npm"),
|
||||
note,
|
||||
confirm: vi.fn(async () => true),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
} as never,
|
||||
runtime: {} as never,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
const realPluginDir = await fs.realpath(pluginDir);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"Failed to install @demo/plugin@1.2.3: registry unavailable\nReturning to selection.",
|
||||
"Plugin install",
|
||||
);
|
||||
expect(recordPluginInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [realPluginDir],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
pluginId: "demo-plugin",
|
||||
source: "path",
|
||||
sourcePath: "./plugins/demo",
|
||||
spec: "@demo/plugin@1.2.3",
|
||||
},
|
||||
);
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.status).toBe("installed");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("records absolute local catalog paths as workspace-relative source metadata", async () => {
|
||||
await withTempDir({ prefix: "openclaw-onboarding-install-portable-record-" }, async (temp) => {
|
||||
const workspaceDir = path.join(temp, "workspace");
|
||||
const pluginDir = path.join(workspaceDir, "plugins", "demo");
|
||||
await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true });
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
const realPluginDir = await fs.realpath(pluginDir);
|
||||
|
||||
await ensureOnboardingPluginInstalled({
|
||||
cfg: {},
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
label: "Demo Plugin",
|
||||
install: {
|
||||
localPath: realPluginDir,
|
||||
},
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async () => "local"),
|
||||
} as never,
|
||||
runtime: {} as never,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(recordPluginInstall).toHaveBeenCalledWith(expect.anything(), {
|
||||
pluginId: "demo-plugin",
|
||||
source: "path",
|
||||
sourcePath: "./plugins/demo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local installs available when cwd is a git repo but workspaceDir is not", async () => {
|
||||
await withTempDir({ prefix: "openclaw-onboarding-install-cwd-git-" }, async (temp) => {
|
||||
const repoDir = path.join(temp, "repo");
|
||||
|
||||
@@ -116,6 +116,41 @@ function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawCon
|
||||
};
|
||||
}
|
||||
|
||||
function formatPortableLocalPath(localPath: string, workspaceDir?: string): string | undefined {
|
||||
const bases = [workspaceDir, process.cwd()].filter((entry): entry is string => Boolean(entry));
|
||||
for (const base of bases) {
|
||||
const realBase = resolveRealDirectory(base);
|
||||
if (!realBase) {
|
||||
continue;
|
||||
}
|
||||
const relative = path.relative(realBase, localPath);
|
||||
if (
|
||||
relative === "" ||
|
||||
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
|
||||
) {
|
||||
const portable = relative.split(path.sep).join("/");
|
||||
return portable ? `./${portable}` : ".";
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function recordLocalPluginInstall(params: {
|
||||
cfg: OpenClawConfig;
|
||||
entry: OnboardingPluginInstallEntry;
|
||||
localPath: string;
|
||||
npmSpec?: string | null;
|
||||
workspaceDir?: string;
|
||||
}): OpenClawConfig {
|
||||
const sourcePath = formatPortableLocalPath(params.localPath, params.workspaceDir);
|
||||
return recordPluginInstall(params.cfg, {
|
||||
pluginId: params.entry.pluginId,
|
||||
source: "path",
|
||||
...(sourcePath ? { sourcePath } : {}),
|
||||
...(params.npmSpec ? { spec: params.npmSpec } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLocalPath(params: {
|
||||
entry: OnboardingPluginInstallEntry;
|
||||
workspaceDir?: string;
|
||||
@@ -439,6 +474,7 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
};
|
||||
}
|
||||
next = addPluginLoadPath(enableResult.config, localPath);
|
||||
next = recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir });
|
||||
return {
|
||||
cfg: next,
|
||||
installed: true,
|
||||
@@ -554,6 +590,7 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
};
|
||||
}
|
||||
next = addPluginLoadPath(enableResult.config, localPath);
|
||||
next = recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir });
|
||||
return {
|
||||
cfg: next,
|
||||
installed: true,
|
||||
|
||||
@@ -306,6 +306,31 @@ describe("removePluginFromConfig", () => {
|
||||
expect(actions.loadPath).toBe(true);
|
||||
});
|
||||
|
||||
it("removes absolute load path for a workspace-relative install source path", async () => {
|
||||
const tempRoot = path.join(process.cwd(), ".tmp");
|
||||
await fs.mkdir(tempRoot, { recursive: true });
|
||||
const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-uninstall-portable-source-"));
|
||||
try {
|
||||
const pluginDir = path.join(tempDir, "plugins", "demo");
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
const realPluginDir = await fs.realpath(pluginDir);
|
||||
const sourcePath = `./${path.relative(process.cwd(), realPluginDir).split(path.sep).join("/")}`;
|
||||
const config = createPluginConfig({
|
||||
installs: {
|
||||
"my-plugin": createPathInstallRecord(undefined, sourcePath),
|
||||
},
|
||||
loadPaths: [realPluginDir, "/other/path"],
|
||||
});
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
||||
|
||||
expect(result.plugins?.load?.paths).toEqual(["/other/path"]);
|
||||
expect(actions.loadPath).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "clears memory slot when uninstalling active memory plugin",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -84,6 +85,22 @@ export function resolveUninstallChannelConfigKeys(
|
||||
return keys;
|
||||
}
|
||||
|
||||
function loadPathMatchesInstallSourcePath(loadPath: string, sourcePath: string): boolean {
|
||||
if (loadPath === sourcePath) {
|
||||
return true;
|
||||
}
|
||||
return resolveComparablePath(loadPath) === resolveComparablePath(sourcePath);
|
||||
}
|
||||
|
||||
function resolveComparablePath(value: string): string {
|
||||
const resolved = path.resolve(value);
|
||||
try {
|
||||
return realpathSync(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove plugin references from config (pure config mutation).
|
||||
* Returns a new config with the plugin removed from entries, installs, allow, load.paths, slots,
|
||||
@@ -137,8 +154,13 @@ export function removePluginFromConfig(
|
||||
if (installRecord?.source === "path" && installRecord.sourcePath) {
|
||||
const sourcePath = installRecord.sourcePath;
|
||||
const loadPaths = load?.paths;
|
||||
if (Array.isArray(loadPaths) && loadPaths.includes(sourcePath)) {
|
||||
const nextLoadPaths = loadPaths.filter((p) => p !== sourcePath);
|
||||
if (
|
||||
Array.isArray(loadPaths) &&
|
||||
loadPaths.some((p) => loadPathMatchesInstallSourcePath(p, sourcePath))
|
||||
) {
|
||||
const nextLoadPaths = loadPaths.filter(
|
||||
(p) => !loadPathMatchesInstallSourcePath(p, sourcePath),
|
||||
);
|
||||
load = nextLoadPaths.length > 0 ? { ...load, paths: nextLoadPaths } : undefined;
|
||||
actions.loadPath = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user