Fix upgrade 3.13 (#488)
This commit is contained in:
committed by
GitHub
Unverified
parent
67ffe09dfc
commit
61291ff83f
@@ -1,6 +1,6 @@
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { existsSync, readFileSync, cpSync, mkdirSync, rmSync } from 'fs';
|
import { existsSync, readFileSync, cpSync, mkdirSync, rmSync, readdirSync, realpathSync } from 'fs';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getAllSettings } from '../utils/store';
|
import { getAllSettings } from '../utils/store';
|
||||||
@@ -30,11 +30,11 @@ export interface GatewayLaunchContext {
|
|||||||
|
|
||||||
// ── Auto-upgrade bundled plugins on startup ──────────────────────
|
// ── Auto-upgrade bundled plugins on startup ──────────────────────
|
||||||
|
|
||||||
const CHANNEL_PLUGIN_MAP: Record<string, string> = {
|
const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> = {
|
||||||
dingtalk: 'dingtalk',
|
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
|
||||||
wecom: 'wecom',
|
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
|
||||||
feishu: 'feishu-openclaw-plugin',
|
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
|
||||||
qqbot: 'qqbot',
|
qqbot: { dirName: 'qqbot', npmName: '@sliverp/qqbot' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function readPluginVersion(pkgJsonPath: string): string | null {
|
function readPluginVersion(pkgJsonPath: string): string | null {
|
||||||
@@ -47,7 +47,113 @@ function readPluginVersion(pkgJsonPath: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPluginCandidateSources(pluginDirName: string): string[] {
|
/** Walk up from a path until we find a parent named node_modules. */
|
||||||
|
function findParentNodeModules(startPath: string): string | null {
|
||||||
|
let dir = startPath;
|
||||||
|
while (dir !== path.dirname(dir)) {
|
||||||
|
if (path.basename(dir) === 'node_modules') return dir;
|
||||||
|
dir = path.dirname(dir);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List packages inside a node_modules dir (handles @scoped packages). */
|
||||||
|
function listPackagesInDir(nodeModulesDir: string): Array<{ name: string; fullPath: string }> {
|
||||||
|
const result: Array<{ name: string; fullPath: string }> = [];
|
||||||
|
if (!existsSync(nodeModulesDir)) return result;
|
||||||
|
const SKIP = new Set(['.bin', '.package-lock.json', '.modules.yaml', '.pnpm']);
|
||||||
|
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
||||||
|
if (SKIP.has(entry.name)) continue;
|
||||||
|
const entryPath = join(nodeModulesDir, entry.name);
|
||||||
|
if (entry.name.startsWith('@')) {
|
||||||
|
try {
|
||||||
|
for (const sub of readdirSync(entryPath)) {
|
||||||
|
result.push({ name: `${entry.name}/${sub}`, fullPath: join(entryPath, sub) });
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
result.push({ name: entry.name, fullPath: entryPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a plugin from a pnpm node_modules location, including its
|
||||||
|
* transitive runtime dependencies (replicates bundle-openclaw-plugins.mjs
|
||||||
|
* logic).
|
||||||
|
*/
|
||||||
|
function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, npmName: string): void {
|
||||||
|
let realPath: string;
|
||||||
|
try {
|
||||||
|
realPath = realpathSync(npmPkgPath);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Cannot resolve real path for ${npmPkgPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Copy plugin package itself
|
||||||
|
rmSync(targetDir, { recursive: true, force: true });
|
||||||
|
mkdirSync(targetDir, { recursive: true });
|
||||||
|
cpSync(realPath, targetDir, { recursive: true, dereference: true });
|
||||||
|
|
||||||
|
// 2. Collect transitive deps from pnpm virtual store
|
||||||
|
const rootVirtualNM = findParentNodeModules(realPath);
|
||||||
|
if (!rootVirtualNM) {
|
||||||
|
logger.warn(`[plugin] Cannot find virtual store node_modules for ${npmName}, plugin may lack deps`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read peer deps to skip (they're provided by the host gateway)
|
||||||
|
const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']);
|
||||||
|
try {
|
||||||
|
const pluginPkg = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8'));
|
||||||
|
for (const peer of Object.keys(pluginPkg.peerDependencies || {})) {
|
||||||
|
SKIP_PACKAGES.add(peer);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
const collected = new Map<string, string>(); // realPath → packageName
|
||||||
|
const queue: Array<{ nodeModulesDir: string; skipPkg: string }> = [
|
||||||
|
{ nodeModulesDir: rootVirtualNM, skipPkg: npmName },
|
||||||
|
];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { nodeModulesDir, skipPkg } = queue.shift()!;
|
||||||
|
for (const { name, fullPath } of listPackagesInDir(nodeModulesDir)) {
|
||||||
|
if (name === skipPkg) continue;
|
||||||
|
if (SKIP_PACKAGES.has(name) || name.startsWith('@types/')) continue;
|
||||||
|
let depRealPath: string;
|
||||||
|
try {
|
||||||
|
depRealPath = realpathSync(fullPath);
|
||||||
|
} catch { continue; }
|
||||||
|
if (collected.has(depRealPath)) continue;
|
||||||
|
collected.set(depRealPath, name);
|
||||||
|
const depVirtualNM = findParentNodeModules(depRealPath);
|
||||||
|
if (depVirtualNM && depVirtualNM !== nodeModulesDir) {
|
||||||
|
queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Copy flattened deps into targetDir/node_modules/
|
||||||
|
const outputNM = join(targetDir, 'node_modules');
|
||||||
|
mkdirSync(outputNM, { recursive: true });
|
||||||
|
const copiedNames = new Set<string>();
|
||||||
|
for (const [depRealPath, pkgName] of collected) {
|
||||||
|
if (copiedNames.has(pkgName)) continue;
|
||||||
|
copiedNames.add(pkgName);
|
||||||
|
const dest = join(outputNM, pkgName);
|
||||||
|
try {
|
||||||
|
mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
cpSync(depRealPath, dest, { recursive: true, dereference: true });
|
||||||
|
} catch { /* skip individual dep failures */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[plugin] Copied ${copiedNames.size} deps for ${npmName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBundledPluginSources(pluginDirName: string): string[] {
|
||||||
return app.isPackaged
|
return app.isPackaged
|
||||||
? [
|
? [
|
||||||
join(process.resourcesPath, 'openclaw-plugins', pluginDirName),
|
join(process.resourcesPath, 'openclaw-plugins', pluginDirName),
|
||||||
@@ -62,33 +168,54 @@ function buildPluginCandidateSources(pluginDirName: string): string[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-upgrade all configured channel plugins before Gateway start.
|
* Auto-upgrade all configured channel plugins before Gateway start.
|
||||||
* Compares the installed version in ~/.openclaw/extensions/ with the
|
* - Packaged mode: uses bundled plugins from resources/ (includes deps)
|
||||||
* bundled version and overwrites if the bundled version is newer.
|
* - Dev mode: falls back to node_modules/ with pnpm-aware dep collection
|
||||||
*/
|
*/
|
||||||
function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
|
function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
|
||||||
for (const channelType of configuredChannels) {
|
for (const channelType of configuredChannels) {
|
||||||
const pluginDirName = CHANNEL_PLUGIN_MAP[channelType];
|
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
|
||||||
if (!pluginDirName) continue;
|
if (!pluginInfo) continue;
|
||||||
|
const { dirName, npmName } = pluginInfo;
|
||||||
|
|
||||||
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
|
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
|
||||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||||
if (!existsSync(targetManifest)) continue; // not installed, nothing to upgrade
|
if (!existsSync(targetManifest)) continue; // not installed, nothing to upgrade
|
||||||
|
|
||||||
const sources = buildPluginCandidateSources(pluginDirName);
|
|
||||||
const sourceDir = sources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
|
||||||
if (!sourceDir) continue; // no bundled source available
|
|
||||||
|
|
||||||
const installedVersion = readPluginVersion(join(targetDir, 'package.json'));
|
const installedVersion = readPluginVersion(join(targetDir, 'package.json'));
|
||||||
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
|
|
||||||
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) continue;
|
|
||||||
|
|
||||||
logger.info(`[plugin] Auto-upgrading ${channelType} plugin: ${installedVersion} → ${sourceVersion}`);
|
// Try bundled sources first (packaged mode or if bundle-plugins was run)
|
||||||
try {
|
const bundledSources = buildBundledPluginSources(dirName);
|
||||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
const bundledDir = bundledSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||||
rmSync(targetDir, { recursive: true, force: true });
|
|
||||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
if (bundledDir) {
|
||||||
} catch (err) {
|
const sourceVersion = readPluginVersion(join(bundledDir, 'package.json'));
|
||||||
logger.warn(`[plugin] Failed to auto-upgrade ${channelType} plugin:`, err);
|
if (sourceVersion && installedVersion && sourceVersion !== installedVersion) {
|
||||||
|
logger.info(`[plugin] Auto-upgrading ${channelType} plugin: ${installedVersion} → ${sourceVersion} (bundled)`);
|
||||||
|
try {
|
||||||
|
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||||
|
rmSync(targetDir, { recursive: true, force: true });
|
||||||
|
cpSync(bundledDir, targetDir, { recursive: true, dereference: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[plugin] Failed to auto-upgrade ${channelType} plugin:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev mode fallback: copy from node_modules/ with pnpm dep resolution
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
|
||||||
|
if (!existsSync(join(npmPkgPath, 'openclaw.plugin.json'))) continue;
|
||||||
|
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
|
||||||
|
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) continue;
|
||||||
|
|
||||||
|
logger.info(`[plugin] Auto-upgrading ${channelType} plugin: ${installedVersion} → ${sourceVersion} (dev/node_modules)`);
|
||||||
|
try {
|
||||||
|
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||||
|
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[plugin] Failed to auto-upgrade ${channelType} plugin from node_modules:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1055,20 +1055,30 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
|||||||
modified = true;
|
modified = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Disable bare 'feishu' when openclaw-lark is present ────────
|
// ── Remove bare 'feishu' when openclaw-lark is present ─────────
|
||||||
// The Gateway binary automatically adds bare 'feishu' to plugins
|
// The Gateway binary automatically adds bare 'feishu' to plugins.allow
|
||||||
// config because the openclaw-lark plugin registers the 'feishu'
|
// because the openclaw-lark plugin registers the 'feishu' channel.
|
||||||
// channel. We can't DELETE it (triggers config-change → restart →
|
// However, there's no plugin with id='feishu', so Gateway validation
|
||||||
// Gateway re-adds it → loop). Instead, disable it so it doesn't
|
// fails with "plugin not found: feishu". Remove it from allow[] and
|
||||||
// conflict with openclaw-lark.
|
// disable the entries.feishu entry to prevent Gateway from re-adding it.
|
||||||
const allowArr = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : [];
|
const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : [];
|
||||||
const hasNewFeishu = allowArr.includes(NEW_FEISHU_ID) || !!pEntries?.[NEW_FEISHU_ID];
|
const hasNewFeishu = allowArr2.includes(NEW_FEISHU_ID) || !!pEntries?.[NEW_FEISHU_ID];
|
||||||
if (hasNewFeishu && pEntries?.feishu) {
|
if (hasNewFeishu) {
|
||||||
if (pEntries.feishu.enabled !== false) {
|
// Remove bare 'feishu' from plugins.allow
|
||||||
pEntries.feishu.enabled = false;
|
const bareFeishuIdx = allowArr2.indexOf('feishu');
|
||||||
console.log(`[sanitize] Disabled bare plugins.entries.feishu (openclaw-lark is configured)`);
|
if (bareFeishuIdx !== -1) {
|
||||||
|
allowArr2.splice(bareFeishuIdx, 1);
|
||||||
|
console.log('[sanitize] Removed bare "feishu" from plugins.allow (openclaw-lark is configured)');
|
||||||
modified = true;
|
modified = true;
|
||||||
}
|
}
|
||||||
|
// Disable bare 'feishu' in plugins.entries so Gateway won't re-add it
|
||||||
|
if (pEntries?.feishu) {
|
||||||
|
if (pEntries.feishu.enabled !== false) {
|
||||||
|
pEntries.feishu.enabled = false;
|
||||||
|
console.log('[sanitize] Disabled bare plugins.entries.feishu (openclaw-lark is configured)');
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawx",
|
"name": "clawx",
|
||||||
"version": "0.2.3-beta.0",
|
"version": "0.2.3-beta.2",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@discordjs/opus",
|
"@discordjs/opus",
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const MODULE_PATCHES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function patchBrokenModules(nodeModulesDir) {
|
function patchBrokenModules(nodeModulesDir) {
|
||||||
const { writeFileSync } = require('fs');
|
const { writeFileSync, readFileSync } = require('fs');
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [rel, content] of Object.entries(MODULE_PATCHES)) {
|
for (const [rel, content] of Object.entries(MODULE_PATCHES)) {
|
||||||
const target = join(nodeModulesDir, rel);
|
const target = join(nodeModulesDir, rel);
|
||||||
@@ -171,6 +171,34 @@ function patchBrokenModules(nodeModulesDir) {
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https-proxy-agent@8.x only defines exports.import (ESM) with no CJS
|
||||||
|
// fallback. The openclaw Gateway loads it via require(), which triggers
|
||||||
|
// ERR_PACKAGE_PATH_NOT_EXPORTED. Patch exports to add CJS conditions.
|
||||||
|
const hpaPkgPath = join(nodeModulesDir, 'https-proxy-agent', 'package.json');
|
||||||
|
if (existsSync(hpaPkgPath)) {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(hpaPkgPath, 'utf8');
|
||||||
|
const pkg = JSON.parse(raw);
|
||||||
|
const exp = pkg.exports;
|
||||||
|
// Only patch if exports exists and lacks a CJS 'require' condition
|
||||||
|
if (exp && exp.import && !exp.require && !exp['.']) {
|
||||||
|
pkg.exports = {
|
||||||
|
'.': {
|
||||||
|
import: exp.import,
|
||||||
|
require: exp.import, // ESM dist works for CJS too via Node.js interop
|
||||||
|
default: typeof exp.import === 'string' ? exp.import : exp.import.default,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(hpaPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
||||||
|
count++;
|
||||||
|
console.log('[after-pack] 🩹 Patched https-proxy-agent exports for CJS compatibility');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[after-pack] ⚠️ Failed to patch https-proxy-agent:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
console.log(`[after-pack] 🩹 Patched ${count} broken module(s) in ${nodeModulesDir}`);
|
console.log(`[after-pack] 🩹 Patched ${count} broken module(s) in ${nodeModulesDir}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ describe('agent config lifecycle', () => {
|
|||||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||||
const { deleteAgentConfig } = await import('@electron/utils/agent-config');
|
const { deleteAgentConfig } = await import('@electron/utils/agent-config');
|
||||||
|
|
||||||
const snapshot = await deleteAgentConfig('test2');
|
const { snapshot } = await deleteAgentConfig('test2');
|
||||||
|
|
||||||
expect(snapshot.agents.map((agent) => agent.id)).toEqual(['main', 'test3']);
|
expect(snapshot.agents.map((agent) => agent.id)).toEqual(['main', 'test3']);
|
||||||
expect(snapshot.channelOwners.feishu).toBe('main');
|
expect(snapshot.channelOwners.feishu).toBe('main');
|
||||||
@@ -175,7 +175,9 @@ describe('agent config lifecycle', () => {
|
|||||||
]);
|
]);
|
||||||
expect(config.bindings).toEqual([]);
|
expect(config.bindings).toEqual([]);
|
||||||
await expect(access(test2RuntimeDir)).rejects.toThrow();
|
await expect(access(test2RuntimeDir)).rejects.toThrow();
|
||||||
await expect(access(test2WorkspaceDir)).rejects.toThrow();
|
// Workspace deletion is intentionally deferred by `deleteAgentConfig` to avoid
|
||||||
|
// ENOENT errors during Gateway restart, so it should still exist here.
|
||||||
|
await expect(access(test2WorkspaceDir)).resolves.toBeUndefined();
|
||||||
|
|
||||||
infoSpy.mockRestore();
|
infoSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user