fix(install): delta-tracking uninstall + remove sh script

- Rewrite install/uninstall to use delta-tracking (added/replaced/removed)
- Install records only what the plugin changed:
  * added: plugin新增的配置(卸载时删除)
  * replaced: plugin覆盖的旧配置(卸载时恢复)
- Uninstall only affects plugin-managed keys, preserving user changes elsewhere
- Remove install-dirigent-openclaw.sh to avoid confusion
This commit is contained in:
zhi
2026-03-03 16:34:50 +00:00
parent 2ea4f59a1e
commit d378e27be9
2 changed files with 123 additions and 54 deletions

View File

@@ -1,4 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* Dirigent plugin installer/uninstaller with delta-tracking.
* Tracks what was ADDED/REPLACED/REMOVED, so uninstall only affects plugin-managed keys.
*/
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
@@ -37,6 +41,7 @@ const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]";
const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/dirigent-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir()); const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/dirigent-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir());
const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}"; const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}";
const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]'; const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]';
const SCHEDULING_IDENTIFIER = env.SCHEDULING_IDENTIFIER || "➡️";
const STATE_DIR = (env.STATE_DIR || "~/.openclaw/dirigent-install-records").replace(/^~(?=$|\/)/, os.homedir()); const STATE_DIR = (env.STATE_DIR || "~/.openclaw/dirigent-install-records").replace(/^~(?=$|\/)/, os.homedir());
const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir()); const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir());
@@ -48,6 +53,7 @@ const RECORD_PATH = path.join(STATE_DIR, `dirigent-${ts}.json`);
const PATH_PLUGINS_LOAD = "plugins.load.paths"; const PATH_PLUGINS_LOAD = "plugins.load.paths";
const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent"; const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent";
const PATH_PROVIDERS = "models.providers"; const PATH_PROVIDERS = "models.providers";
const PATH_PROVIDER_ENTRY = `models.providers.${NO_REPLY_PROVIDER_ID}`;
function runOpenclaw(args, { allowFail = false } = {}) { function runOpenclaw(args, { allowFail = false } = {}) {
try { try {
@@ -58,11 +64,10 @@ function runOpenclaw(args, { allowFail = false } = {}) {
} }
} }
function getJson(pathKey) { function getJson(pathKey) {
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
if (out == null || out === "") return { exists: false }; if (out == null || out === "") return undefined;
return { exists: true, value: JSON.parse(out) }; return JSON.parse(out);
} }
function setJson(pathKey, value) { function setJson(pathKey, value) {
@@ -73,18 +78,18 @@ function unsetPath(pathKey) {
runOpenclaw(["config", "unset", pathKey], { allowFail: true }); runOpenclaw(["config", "unset", pathKey], { allowFail: true });
} }
function writeRecord(modeName, before, after) { function writeRecord(modeName, delta) {
fs.mkdirSync(STATE_DIR, { recursive: true }); fs.mkdirSync(STATE_DIR, { recursive: true });
const rec = { const rec = {
mode: modeName, mode: modeName,
timestamp: ts, timestamp: ts,
openclawConfigPath: OPENCLAW_CONFIG_PATH, openclawConfigPath: OPENCLAW_CONFIG_PATH,
backupPath: BACKUP_PATH, backupPath: BACKUP_PATH,
paths: before, delta, // { added: {...}, replaced: {...}, removed: {...} }
applied: after,
}; };
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2)); fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK); fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
return rec;
} }
function readRecord(file) { function readRecord(file) {
@@ -110,14 +115,27 @@ function findLatestInstallRecord() {
return ""; return "";
} }
// Deep clone
function clone(v) {
return JSON.parse(JSON.stringify(v));
}
// Check if two values are deeply equal
function deepEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`); console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`);
process.exit(1); process.exit(1);
} }
// ═══════════════════════════════════════════════════════════════════════════
// INSTALL
// ═══════════════════════════════════════════════════════════════════════════
if (mode === "install") { if (mode === "install") {
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[dirigent] backup: ${BACKUP_PATH}`); console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
@@ -125,21 +143,29 @@ if (mode === "install") {
console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`); console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
} }
const before = { const delta = { added: {}, replaced: {}, removed: {} };
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
};
try { try {
const pluginsNow = getJson("plugins").value || {}; // ── plugins.load.paths ────────────────────────────────────────────────
const plugins = typeof pluginsNow === "object" ? pluginsNow : {}; const plugins = getJson("plugins") || {};
plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {}; const oldPaths = clone(plugins.load?.paths) || [];
const paths = Array.isArray(plugins.load.paths) ? plugins.load.paths : []; const newPaths = clone(oldPaths);
if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH); const pathIndex = newPaths.indexOf(PLUGIN_PATH);
plugins.load.paths = paths; if (pathIndex === -1) {
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; newPaths.push(PLUGIN_PATH);
plugins.entries.dirigent = { delta.added[PATH_PLUGINS_LOAD] = PLUGIN_PATH; // added this path
} else {
// already present, no change
}
// save old paths for potential future rollback of this specific change
delta._prev = delta._prev || {};
delta._prev[PATH_PLUGINS_LOAD] = oldPaths;
plugins.load = plugins.load || {};
plugins.load.paths = newPaths;
// ── plugins.entries.dirigent ──────────────────────────────────────────
const oldEntry = clone(plugins.entries?.dirigent);
const newEntry = {
enabled: true, enabled: true,
config: { config: {
enabled: true, enabled: true,
@@ -149,15 +175,24 @@ if (mode === "install") {
agentList: JSON.parse(AGENT_LIST_JSON), agentList: JSON.parse(AGENT_LIST_JSON),
channelPoliciesFile: CHANNEL_POLICIES_FILE, channelPoliciesFile: CHANNEL_POLICIES_FILE,
endSymbols: JSON.parse(END_SYMBOLS_JSON), endSymbols: JSON.parse(END_SYMBOLS_JSON),
schedulingIdentifier: SCHEDULING_IDENTIFIER,
noReplyProvider: NO_REPLY_PROVIDER_ID, noReplyProvider: NO_REPLY_PROVIDER_ID,
noReplyModel: NO_REPLY_MODEL_ID, noReplyModel: NO_REPLY_MODEL_ID,
}, },
}; };
if (oldEntry === undefined) {
delta.added[PATH_PLUGIN_ENTRY] = newEntry;
} else {
delta.replaced[PATH_PLUGIN_ENTRY] = oldEntry;
}
plugins.entries = plugins.entries || {};
plugins.entries.dirigent = newEntry;
setJson("plugins", plugins); setJson("plugins", plugins);
const providersNow = getJson(PATH_PROVIDERS).value || {}; // ── models.providers.<providerId> ─────────────────────────────────────
const providers = typeof providersNow === "object" ? providersNow : {}; const providers = getJson(PATH_PROVIDERS) || {};
providers[NO_REPLY_PROVIDER_ID] = { const oldProvider = clone(providers[NO_REPLY_PROVIDER_ID]);
const newProvider = {
baseUrl: NO_REPLY_BASE_URL, baseUrl: NO_REPLY_BASE_URL,
apiKey: NO_REPLY_API_KEY, apiKey: NO_REPLY_API_KEY,
api: "openai-completions", api: "openai-completions",
@@ -173,14 +208,15 @@ if (mode === "install") {
}, },
], ],
}; };
if (oldProvider === undefined) {
delta.added[PATH_PROVIDER_ENTRY] = newProvider;
} else {
delta.replaced[PATH_PROVIDER_ENTRY] = oldProvider;
}
providers[NO_REPLY_PROVIDER_ID] = newProvider;
setJson(PATH_PROVIDERS, providers); setJson(PATH_PROVIDERS, providers);
const after = { writeRecord("install", delta);
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD),
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY),
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS),
};
writeRecord("install", before, after);
console.log("[dirigent] install ok (config written)"); console.log("[dirigent] install ok (config written)");
console.log(`[dirigent] record: ${RECORD_PATH}`); console.log(`[dirigent] record: ${RECORD_PATH}`);
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart"); console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
@@ -189,7 +225,12 @@ if (mode === "install") {
console.error(`[dirigent] install failed; rollback complete: ${String(e)}`); console.error(`[dirigent] install failed; rollback complete: ${String(e)}`);
process.exit(1); process.exit(1);
} }
} else { }
// ═══════════════════════════════════════════════════════════════════════════
// UNINSTALL
// ═══════════════════════════════════════════════════════════════════════════
else {
const recFile = env.RECORD_FILE || findLatestInstallRecord(); const recFile = env.RECORD_FILE || findLatestInstallRecord();
if (!recFile || !fs.existsSync(recFile)) { if (!recFile || !fs.existsSync(recFile)) {
console.error("[dirigent] no install record found. set RECORD_FILE=<path> to an install record."); console.error("[dirigent] no install record found. set RECORD_FILE=<path> to an install record.");
@@ -197,37 +238,69 @@ if (mode === "install") {
} }
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[dirigent] backup before uninstall: ${BACKUP_PATH}`); console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
const rec = readRecord(recFile); const rec = readRecord(recFile);
const before = rec.applied || {}; const delta = rec.delta || { added: {}, replaced: {}, removed: {} };
const target = rec.paths || {};
try { try {
const pluginsNow = getJson("plugins").value || {}; // ── Handle ADDED entries: remove them ─────────────────────────────────
const plugins = typeof pluginsNow === "object" ? pluginsNow : {}; if (delta.added[PATH_PLUGIN_ENTRY] !== undefined) {
plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {}; const plugins = getJson("plugins") || {};
plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; plugins.entries = plugins.entries || {};
delete plugins.entries.dirigent;
if (target[PATH_PLUGINS_LOAD]?.exists) plugins.load.paths = target[PATH_PLUGINS_LOAD].value;
else delete plugins.load.paths;
if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.dirigent = target[PATH_PLUGIN_ENTRY].value;
else delete plugins.entries.dirigent;
setJson("plugins", plugins); setJson("plugins", plugins);
console.log("[dirigent] removed plugins.entries.dirigent");
}
if (target[PATH_PROVIDERS]?.exists) setJson(PATH_PROVIDERS, target[PATH_PROVIDERS].value); if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) {
else unsetPath(PATH_PROVIDERS); const providers = getJson(PATH_PROVIDERS) || {};
delete providers[NO_REPLY_PROVIDER_ID];
setJson(PATH_PROVIDERS, providers);
console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`);
}
const after = { if (delta.added[PATH_PLUGINS_LOAD] !== undefined) {
[PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), const plugins = getJson("plugins") || {};
[PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), const paths = plugins.load?.paths || [];
[PATH_PROVIDERS]: getJson(PATH_PROVIDERS), const idx = paths.indexOf(PLUGIN_PATH);
}; if (idx !== -1) {
writeRecord("uninstall", before, after); paths.splice(idx, 1);
plugins.load.paths = paths;
setJson("plugins", plugins);
console.log("[dirigent] removed plugin path from plugins.load.paths");
}
}
// ── Handle REPLACED entries: restore old value ────────────────────────
if (delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) {
const plugins = getJson("plugins") || {};
plugins.entries = plugins.entries || {};
plugins.entries.dirigent = delta.replaced[PATH_PLUGIN_ENTRY];
setJson("plugins", plugins);
console.log("[dirigent] restored previous plugins.entries.dirigent");
}
if (delta.replaced[PATH_PROVIDER_ENTRY] !== undefined) {
const providers = getJson(PATH_PROVIDERS) || {};
providers[NO_REPLY_PROVIDER_ID] = delta.replaced[PATH_PROVIDER_ENTRY];
setJson(PATH_PROVIDERS, providers);
console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`);
}
// Handle plugins.load.paths restoration (if it was replaced, not added)
if (delta._prev?.[PATH_PLUGINS_LOAD] && delta.added[PATH_PLUGINS_LOAD] === undefined) {
const plugins = getJson("plugins") || {};
plugins.load = plugins.load || {};
plugins.load.paths = delta._prev[PATH_PLUGINS_LOAD];
setJson("plugins", plugins);
console.log("[dirigent] restored previous plugins.load.paths");
}
writeRecord("uninstall", delta);
console.log("[dirigent] uninstall ok"); console.log("[dirigent] uninstall ok");
console.log(`[dirigent] record: ${RECORD_PATH}`); console.log(`[dirigent] record: ${RECORD_PATH}`);
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
} catch (e) { } catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`); console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`);

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec node "$SCRIPT_DIR/install-dirigent-openclaw.mjs" "$@"