Files
Dirigent/scripts/install-dirigent-openclaw.mjs
zhi 83c03b6934 feat(install): idempotent install/uninstall
- Repeat install = reinstall: auto-detect existing install, uninstall first, then install
- Repeat uninstall = no-op: if no install record found, exit 0 with message
- Uses spawnSync to re-exec uninstall phase during reinstall
2026-03-03 16:39:40 +00:00

353 lines
14 KiB
JavaScript
Executable File

#!/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 path from "node:path";
import os from "node:os";
import { execFileSync, spawnSync } from "node:child_process";
const modeArg = process.argv[2];
if (modeArg !== "--install" && modeArg !== "--uninstall") {
console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall");
process.exit(2);
}
const mode = modeArg === "--install" ? "install" : "uninstall";
const env = process.env;
const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json");
const __dirname = path.dirname(new URL(import.meta.url).pathname);
// Ensure dist/dirigent exists (handles git clone without npm prepare)
const DIST_DIR = path.resolve(__dirname, "..", "dist", "dirigent");
const PLUGIN_SRC_DIR = path.resolve(__dirname, "..", "plugin");
if (mode === "install" && !fs.existsSync(DIST_DIR)) {
console.log("[dirigent] dist/ not found, syncing from plugin/...");
fs.mkdirSync(DIST_DIR, { recursive: true });
for (const f of fs.readdirSync(PLUGIN_SRC_DIR)) {
fs.copyFileSync(path.join(PLUGIN_SRC_DIR, f), path.join(DIST_DIR, f));
}
}
const PLUGIN_PATH = env.PLUGIN_PATH || DIST_DIR;
const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "dirigentway";
const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1";
const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token";
const LIST_MODE = env.LIST_MODE || "human-list";
const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]';
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_JSON = env.CHANNEL_POLICIES_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 LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir());
const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`;
const RECORD_PATH = path.join(STATE_DIR, `dirigent-${ts}.json`);
const PATH_PLUGINS_LOAD = "plugins.load.paths";
const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent";
const PATH_PROVIDERS = "models.providers";
const PATH_PROVIDER_ENTRY = `models.providers.${NO_REPLY_PROVIDER_ID}`;
const PATH_PLUGINS_ALLOW = "plugins.allow";
function runOpenclaw(args, { allowFail = false } = {}) {
function runOpenclaw(args, { allowFail = false } = {}) {
try {
return execFileSync("openclaw", args, { encoding: "utf8" }).trim();
} catch (e) {
if (allowFail) return null;
throw e;
}
}
function getJson(pathKey) {
const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true });
if (out == null || out === "") return undefined;
return JSON.parse(out);
}
function setJson(pathKey, value) {
runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]);
}
function unsetPath(pathKey) {
runOpenclaw(["config", "unset", pathKey], { allowFail: true });
}
function writeRecord(modeName, delta) {
fs.mkdirSync(STATE_DIR, { recursive: true });
const rec = {
mode: modeName,
timestamp: ts,
openclawConfigPath: OPENCLAW_CONFIG_PATH,
backupPath: BACKUP_PATH,
delta, // { added: {...}, replaced: {...}, removed: {...} }
};
fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2));
fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK);
return rec;
}
function readRecord(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function findLatestInstallRecord() {
if (!fs.existsSync(STATE_DIR)) return "";
const files = fs
.readdirSync(STATE_DIR)
.filter((f) => /^dirigent-\d+\.json$/.test(f))
.sort()
.reverse();
for (const f of files) {
const p = path.join(STATE_DIR, f);
try {
const rec = readRecord(p);
if (rec?.mode === "install") return p;
} catch {
// ignore broken records
}
}
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)) {
console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`);
process.exit(1);
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTALL (with auto-reinstall if already installed)
// ═══════════════════════════════════════════════════════════════════════════
if (mode === "install") {
// Check if already installed - if so, uninstall first
const existingRecord = findLatestInstallRecord();
if (existingRecord) {
console.log("[dirigent] existing installation detected, uninstalling first...");
process.env.RECORD_FILE = existingRecord;
// Re-exec ourselves in uninstall mode
const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], {
env: process.env,
stdio: ["inherit", "inherit", "inherit"],
});
if (result.status !== 0) {
console.error("[dirigent] reinstall failed during uninstall phase");
process.exit(1);
}
console.log("[dirigent] previous installation removed, proceeding with fresh install...");
}
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
if (!fs.existsSync(CHANNEL_POLICIES_FILE)) {
fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true });
fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`);
console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`);
}
const delta = { added: {}, replaced: {}, removed: {} };
try {
// ── plugins.load.paths ────────────────────────────────────────────────
const plugins = getJson("plugins") || {};
const oldPaths = clone(plugins.load?.paths) || [];
const newPaths = clone(oldPaths);
const pathIndex = newPaths.indexOf(PLUGIN_PATH);
if (pathIndex === -1) {
newPaths.push(PLUGIN_PATH);
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,
config: {
enabled: true,
discordOnly: true,
listMode: LIST_MODE,
humanList: JSON.parse(HUMAN_LIST_JSON),
agentList: JSON.parse(AGENT_LIST_JSON),
channelPoliciesFile: CHANNEL_POLICIES_FILE,
endSymbols: JSON.parse(END_SYMBOLS_JSON),
schedulingIdentifier: SCHEDULING_IDENTIFIER,
noReplyProvider: NO_REPLY_PROVIDER_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);
// ── models.providers.<providerId> ─────────────────────────────────────
const providers = getJson(PATH_PROVIDERS) || {};
const oldProvider = clone(providers[NO_REPLY_PROVIDER_ID]);
const newProvider = {
baseUrl: NO_REPLY_BASE_URL,
apiKey: NO_REPLY_API_KEY,
api: "openai-completions",
models: [
{
id: NO_REPLY_MODEL_ID,
name: `${NO_REPLY_MODEL_ID} (Custom Provider)`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
};
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);
// ── plugins.allow ─────────────────────────────────────────────────────
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
const oldAllow = clone(allowList);
if (!allowList.includes("dirigent")) {
allowList.push("dirigent");
delta.added[PATH_PLUGINS_ALLOW] = "dirigent";
delta._prev = delta._prev || {};
delta._prev[PATH_PLUGINS_ALLOW] = oldAllow;
setJson(PATH_PLUGINS_ALLOW, allowList);
console.log("[dirigent] added 'dirigent' to plugins.allow");
}
writeRecord("install", delta);
console.log("[dirigent] install ok (config written)");
console.log(`[dirigent] record: ${RECORD_PATH}`);
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
} catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[dirigent] install failed; rollback complete: ${String(e)}`);
process.exit(1);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UNINSTALL
// ═══════════════════════════════════════════════════════════════════════════
else {
const recFile = env.RECORD_FILE || findLatestInstallRecord();
if (!recFile || !fs.existsSync(recFile)) {
console.log("[dirigent] no install record found, nothing to uninstall.");
process.exit(0);
}
fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH);
console.log(`[dirigent] safety backup: ${BACKUP_PATH}`);
const rec = readRecord(recFile);
const delta = rec.delta || { added: {}, replaced: {}, removed: {} };
try {
// ── Handle ADDED entries: remove them ─────────────────────────────────
if (delta.added[PATH_PLUGIN_ENTRY] !== undefined) {
const plugins = getJson("plugins") || {};
plugins.entries = plugins.entries || {};
delete plugins.entries.dirigent;
setJson("plugins", plugins);
console.log("[dirigent] removed plugins.entries.dirigent");
}
if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) {
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}`);
}
if (delta.added[PATH_PLUGINS_LOAD] !== undefined) {
const plugins = getJson("plugins") || {};
const paths = plugins.load?.paths || [];
const idx = paths.indexOf(PLUGIN_PATH);
if (idx !== -1) {
paths.splice(idx, 1);
plugins.load.paths = paths;
setJson("plugins", plugins);
console.log("[dirigent] removed plugin path from plugins.load.paths");
}
}
// ── Handle plugins.allow ──────────────────────────────────────────────
if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) {
const allowList = getJson(PATH_PLUGINS_ALLOW) || [];
const idx = allowList.indexOf("dirigent");
if (idx !== -1) {
allowList.splice(idx, 1);
setJson(PATH_PLUGINS_ALLOW, allowList);
console.log("[dirigent] removed 'dirigent' from plugins.allow");
}
}
// ── 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] record: ${RECORD_PATH}`);
console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart");
} catch (e) {
fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH);
console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`);
process.exit(1);
}
}