mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(plugins): recover managed npm install ledger
This commit is contained in:
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health.
|
||||
- Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear.
|
||||
- Google Meet: grant Meet media permissions through the Playwright browser context when CDP grants do not affect the attached Chrome page, and report in-call microphone/speaker permission problems instead of marking realtime speech ready.
|
||||
- Tlon: expose `groupInviteAllowlist` in the channel config schema and clarify that group invite auto-accept fails closed without an invite allowlist. Thanks @vincentkoc.
|
||||
- Google Chat: update the setup example to use the accepted `groups.<space>.enabled` key instead of the legacy `allow` alias, with a schema regression for the documented group shape. Thanks @vincentkoc.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { readJsonFile, readJsonFileSync } from "../infra/json-files.js";
|
||||
import { resolveDefaultPluginNpmDir, validatePluginId } from "./install-paths.js";
|
||||
import {
|
||||
resolveInstalledPluginIndexStorePath,
|
||||
type InstalledPluginIndexStoreOptions,
|
||||
@@ -30,6 +33,111 @@ function readRecordMap(value: unknown): Record<string, PluginInstallRecord> | nu
|
||||
return records;
|
||||
}
|
||||
|
||||
function readJsonObjectFileSync(filePath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStringRecord(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
const record: Record<string, string> = {};
|
||||
for (const [key, raw] of Object.entries(value).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
record[key] = raw.trim();
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function hasPackagePluginMetadata(manifest: Record<string, unknown>): boolean {
|
||||
const openclaw = manifest.openclaw;
|
||||
if (!isRecord(openclaw)) {
|
||||
return false;
|
||||
}
|
||||
const extensions = openclaw.extensions;
|
||||
return Array.isArray(extensions) && extensions.some((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function readManifestPluginId(packageDir: string): string | undefined {
|
||||
const manifest = readJsonObjectFileSync(path.join(packageDir, "openclaw.plugin.json"));
|
||||
const id = typeof manifest?.id === "string" ? manifest.id.trim() : "";
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
function resolveRecoveredManagedNpmPluginId(params: {
|
||||
packageName: string;
|
||||
packageDir: string;
|
||||
}): string | undefined {
|
||||
const packageManifest = readJsonObjectFileSync(path.join(params.packageDir, "package.json"));
|
||||
if (!packageManifest || !hasPackagePluginMetadata(packageManifest)) {
|
||||
return undefined;
|
||||
}
|
||||
const packageName =
|
||||
typeof packageManifest.name === "string" && packageManifest.name.trim()
|
||||
? packageManifest.name.trim()
|
||||
: params.packageName;
|
||||
const pluginId = readManifestPluginId(params.packageDir) ?? packageName;
|
||||
return validatePluginId(pluginId) ? undefined : pluginId;
|
||||
}
|
||||
|
||||
function buildRecoveredManagedNpmInstallRecords(
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const npmRoot = options.stateDir
|
||||
? path.join(options.stateDir, "npm")
|
||||
: resolveDefaultPluginNpmDir(options.env);
|
||||
const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json"));
|
||||
const dependencies = readStringRecord(rootManifest?.dependencies);
|
||||
const records: Record<string, PluginInstallRecord> = {};
|
||||
for (const [packageName, dependencySpec] of Object.entries(dependencies)) {
|
||||
const packageDir = path.join(npmRoot, "node_modules", packageName);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(packageDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const pluginId = resolveRecoveredManagedNpmPluginId({ packageName, packageDir });
|
||||
if (!pluginId) {
|
||||
continue;
|
||||
}
|
||||
const packageManifest = readJsonObjectFileSync(path.join(packageDir, "package.json"));
|
||||
const version =
|
||||
typeof packageManifest?.version === "string" && packageManifest.version.trim()
|
||||
? packageManifest.version.trim()
|
||||
: undefined;
|
||||
records[pluginId] = {
|
||||
source: "npm",
|
||||
spec: `${packageName}@${dependencySpec}`,
|
||||
installPath: packageDir,
|
||||
...(version ? { version, resolvedName: packageName, resolvedVersion: version } : {}),
|
||||
...(version ? { resolvedSpec: `${packageName}@${version}` } : {}),
|
||||
};
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function mergeRecoveredManagedNpmInstallRecords(
|
||||
persisted: Record<string, PluginInstallRecord> | null,
|
||||
options: InstalledPluginIndexStoreOptions,
|
||||
): Record<string, PluginInstallRecord> {
|
||||
return {
|
||||
...buildRecoveredManagedNpmInstallRecords(options),
|
||||
...persisted,
|
||||
};
|
||||
}
|
||||
|
||||
function extractPluginInstallRecordsFromPersistedInstalledPluginIndex(
|
||||
index: unknown,
|
||||
): Record<string, PluginInstallRecord> | null {
|
||||
@@ -66,11 +174,21 @@ export function readPersistedInstalledPluginIndexInstallRecordsSync(
|
||||
export async function loadInstalledPluginIndexInstallRecords(
|
||||
params: InstalledPluginIndexStoreOptions = {},
|
||||
): Promise<Record<string, PluginInstallRecord>> {
|
||||
return cloneInstallRecords((await readPersistedInstalledPluginIndexInstallRecords(params)) ?? {});
|
||||
return cloneInstallRecords(
|
||||
mergeRecoveredManagedNpmInstallRecords(
|
||||
await readPersistedInstalledPluginIndexInstallRecords(params),
|
||||
params,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadInstalledPluginIndexInstallRecordsSync(
|
||||
params: InstalledPluginIndexStoreOptions = {},
|
||||
): Record<string, PluginInstallRecord> {
|
||||
return cloneInstallRecords(readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {});
|
||||
return cloneInstallRecords(
|
||||
mergeRecoveredManagedNpmInstallRecords(
|
||||
readPersistedInstalledPluginIndexInstallRecordsSync(params),
|
||||
params,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,61 @@ function createPluginCandidate(stateDir: string, pluginId: string): PluginCandid
|
||||
};
|
||||
}
|
||||
|
||||
function writeManagedNpmPlugin(params: {
|
||||
stateDir: string;
|
||||
packageName: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
dependencySpec?: string;
|
||||
}): string {
|
||||
const npmRoot = path.join(params.stateDir, "npm");
|
||||
const rootManifestPath = path.join(npmRoot, "package.json");
|
||||
fs.mkdirSync(npmRoot, { recursive: true });
|
||||
const rootManifest = fs.existsSync(rootManifestPath)
|
||||
? (JSON.parse(fs.readFileSync(rootManifestPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
})
|
||||
: {};
|
||||
fs.writeFileSync(
|
||||
rootManifestPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...rootManifest,
|
||||
private: true,
|
||||
dependencies: {
|
||||
...rootManifest.dependencies,
|
||||
[params.packageName]: params.dependencySpec ?? params.version,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const packageDir = path.join(npmRoot, "node_modules", params.packageName);
|
||||
fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: params.packageName,
|
||||
version: params.version,
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(packageDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: params.pluginId,
|
||||
configSchema: { type: "object" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(packageDir, "dist", "index.js"), "export {};\n", "utf8");
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -164,6 +219,87 @@ describe("plugin index install records store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers managed npm plugin records when the persisted ledger is empty", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const discordDir = writeManagedNpmPlugin({
|
||||
stateDir,
|
||||
packageName: "@openclaw/discord",
|
||||
pluginId: "discord",
|
||||
version: "2026.5.2",
|
||||
});
|
||||
const codexDir = writeManagedNpmPlugin({
|
||||
stateDir,
|
||||
packageName: "@openclaw/codex",
|
||||
pluginId: "codex",
|
||||
version: "2026.5.2",
|
||||
});
|
||||
const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir });
|
||||
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
||||
fs.writeFileSync(indexPath, JSON.stringify({ installRecords: {}, plugins: [] }), "utf8");
|
||||
|
||||
await expect(loadInstalledPluginIndexInstallRecords({ stateDir })).resolves.toMatchObject({
|
||||
codex: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/codex@2026.5.2",
|
||||
installPath: codexDir,
|
||||
version: "2026.5.2",
|
||||
resolvedName: "@openclaw/codex",
|
||||
resolvedVersion: "2026.5.2",
|
||||
resolvedSpec: "@openclaw/codex@2026.5.2",
|
||||
},
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/discord@2026.5.2",
|
||||
installPath: discordDir,
|
||||
version: "2026.5.2",
|
||||
resolvedName: "@openclaw/discord",
|
||||
resolvedVersion: "2026.5.2",
|
||||
resolvedSpec: "@openclaw/discord@2026.5.2",
|
||||
},
|
||||
});
|
||||
expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toMatchObject({
|
||||
codex: {
|
||||
source: "npm",
|
||||
installPath: codexDir,
|
||||
},
|
||||
discord: {
|
||||
source: "npm",
|
||||
installPath: discordDir,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps persisted install record metadata over recovered npm records", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
writeManagedNpmPlugin({
|
||||
stateDir,
|
||||
packageName: "@openclaw/discord",
|
||||
pluginId: "discord",
|
||||
version: "2026.5.2",
|
||||
});
|
||||
const candidate = createPluginCandidate(stateDir, "discord");
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
{
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/discord@beta",
|
||||
installPath: path.join(stateDir, "custom", "discord"),
|
||||
integrity: "sha512-persisted",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [candidate] },
|
||||
);
|
||||
|
||||
await expect(loadInstalledPluginIndexInstallRecords({ stateDir })).resolves.toMatchObject({
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/discord@beta",
|
||||
installPath: path.join(stateDir, "custom", "discord"),
|
||||
integrity: "sha512-persisted",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves git install resolution fields in persisted records", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const candidate = createPluginCandidate(stateDir, "git-demo");
|
||||
|
||||
Reference in New Issue
Block a user