@drakon-systems/multi-clawd 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/account-env.js +35 -0
- package/dist/alerts.js +28 -0
- package/dist/catalog-source.js +34 -0
- package/dist/chain-audit.js +202 -0
- package/dist/degrade.js +32 -0
- package/dist/exec-policy.js +15 -0
- package/dist/health.js +83 -0
- package/dist/index.js +508 -0
- package/dist/login-health.js +91 -0
- package/dist/models.js +69 -0
- package/dist/setup-core.js +140 -0
- package/dist/shim-core.js +218 -0
- package/dist/shim.js +151 -0
- package/dist/sticky.js +24 -0
- package/dist/token-resolution.js +62 -0
- package/dist/watchdog-core.js +53 -0
- package/openclaw.plugin.json +134 -0
- package/package.json +65 -0
- package/scripts/doctor.mjs +340 -0
- package/scripts/eviction-watchdog.mjs +178 -0
- package/scripts/setup.mjs +231 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* multi-clawd eviction watchdog (v0.3, turn-safe) — mitigation for
|
|
4
|
+
* openclaw#107408 using a turn-safe lane-guard pattern.
|
|
5
|
+
*
|
|
6
|
+
* On OpenClaw <= 2026.7.1, core's scoped harness activation can silently drop
|
|
7
|
+
* plugin-registered CLI backends; affected turns fail with
|
|
8
|
+
* `Unknown CLI backend: <id>`. A gateway restart always restores them — but a
|
|
9
|
+
* blind restart can kill a live user turn, so this watchdog:
|
|
10
|
+
*
|
|
11
|
+
* 1. Detects in-flight work two ways (either defers the restart):
|
|
12
|
+
* a. any agent session transcript written within the last 180s
|
|
13
|
+
* (newest .jsonl mtime under ~/.openclaw/agents/<agent>/sessions/)
|
|
14
|
+
* b. opt-in background-worker pidfiles: any live pid recorded in
|
|
15
|
+
* $MULTI_CLAWD_WORKER_PID_DIR (skipped when unset/missing)
|
|
16
|
+
* 2. Persists the pending eviction and re-evaluates each tick; after
|
|
17
|
+
* MAX_DEFER (15 min) it restarts anyway — the backends are already
|
|
18
|
+
* broken, so endless deferral protects nothing.
|
|
19
|
+
* 3. Applies a 10-min restart cooldown on top of once-per-eviction dedupe.
|
|
20
|
+
* 4. Never restarts on missing evidence, and never restarts silently:
|
|
21
|
+
* every restart is spooled as an operator alert the plugin delivers via
|
|
22
|
+
* the agent's next heartbeat.
|
|
23
|
+
*
|
|
24
|
+
* Run every ~5 min (launchd/systemd). Remove once openclaw#107596 ships.
|
|
25
|
+
* Env: MULTI_CLAWD_WATCHDOG_STATE, MULTI_CLAWD_WORKER_PID_DIR,
|
|
26
|
+
* MULTI_CLAWD_WATCHDOG_DRY=1
|
|
27
|
+
*/
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import {
|
|
30
|
+
appendFileSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
readdirSync,
|
|
35
|
+
statSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from "node:fs";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { dirname, join, resolve } from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
const HOME = homedir();
|
|
43
|
+
const STATE_DIR = join(HOME, ".openclaw", "state", "multi-clawd");
|
|
44
|
+
const stateFile = process.env.MULTI_CLAWD_WATCHDOG_STATE ?? join(STATE_DIR, "watchdog.json");
|
|
45
|
+
const spoolFile = join(STATE_DIR, "alerts-spool.jsonl");
|
|
46
|
+
const SIGNATURE = /Unknown CLI backend: /;
|
|
47
|
+
const INFLIGHT_GRACE_MS = 180 * 1000;
|
|
48
|
+
const MAX_DEFER_MS = 15 * 60 * 1000;
|
|
49
|
+
const RESTART_COOLDOWN_MS = 10 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
const coreDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist");
|
|
52
|
+
const { decideWatchdogAction } = await import(join(coreDir, "watchdog-core.js"));
|
|
53
|
+
|
|
54
|
+
function readState() {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(stateFile, "utf8"));
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeState(state) {
|
|
63
|
+
mkdirSync(dirname(stateFile), { recursive: true });
|
|
64
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function spoolAlert(severity, text) {
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dirname(spoolFile), { recursive: true });
|
|
70
|
+
appendFileSync(
|
|
71
|
+
spoolFile,
|
|
72
|
+
JSON.stringify({ key: `watchdog:${Date.now()}`, severity, text, at: Date.now() }) + "\n",
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
/* alerting must never block the watchdog */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── observations (failure of any = do nothing) ──────────────────────────────
|
|
80
|
+
let evictionTimestamp;
|
|
81
|
+
try {
|
|
82
|
+
const log = execFileSync("openclaw", ["logs", "--plain"], {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
85
|
+
});
|
|
86
|
+
const hits = log.split("\n").filter((l) => SIGNATURE.test(l));
|
|
87
|
+
if (hits.length > 0) evictionTimestamp = hits[hits.length - 1].slice(0, 30);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`[watchdog] could not read gateway logs: ${String(err)} — doing nothing`);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function newestTranscriptMtime() {
|
|
94
|
+
const agentsDir = join(HOME, ".openclaw", "agents");
|
|
95
|
+
let newest = 0;
|
|
96
|
+
try {
|
|
97
|
+
for (const agent of readdirSync(agentsDir)) {
|
|
98
|
+
const sessions = join(agentsDir, agent, "sessions");
|
|
99
|
+
if (!existsSync(sessions)) continue;
|
|
100
|
+
for (const f of readdirSync(sessions)) {
|
|
101
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
102
|
+
const m = statSync(join(sessions, f)).mtimeMs;
|
|
103
|
+
if (m > newest) newest = m;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
/* unreadable agents dir → no signal */
|
|
108
|
+
}
|
|
109
|
+
return newest;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function liveWorkerPids() {
|
|
113
|
+
const dir = process.env.MULTI_CLAWD_WORKER_PID_DIR;
|
|
114
|
+
if (!dir || !existsSync(dir)) return 0;
|
|
115
|
+
let live = 0;
|
|
116
|
+
try {
|
|
117
|
+
for (const f of readdirSync(dir)) {
|
|
118
|
+
if (!f.endsWith(".pid")) continue;
|
|
119
|
+
const pid = Number(readFileSync(join(dir, f), "utf8").trim());
|
|
120
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
live++;
|
|
124
|
+
} catch {
|
|
125
|
+
/* dead pid */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
/* unreadable dir → no signal */
|
|
130
|
+
}
|
|
131
|
+
return live;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
const transcriptFresh = now - newestTranscriptMtime() < INFLIGHT_GRACE_MS;
|
|
136
|
+
const workers = liveWorkerPids();
|
|
137
|
+
const inFlight = transcriptFresh || workers > 0;
|
|
138
|
+
|
|
139
|
+
const decision = decideWatchdogAction({
|
|
140
|
+
evictionTimestamp,
|
|
141
|
+
state: readState(),
|
|
142
|
+
inFlight,
|
|
143
|
+
nowMs: now,
|
|
144
|
+
maxDeferMs: MAX_DEFER_MS,
|
|
145
|
+
restartCooldownMs: RESTART_COOLDOWN_MS,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (decision.action === "none") {
|
|
149
|
+
console.log(`[watchdog] ${decision.reason}`);
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (decision.action === "defer") {
|
|
154
|
+
console.log(
|
|
155
|
+
`[watchdog] eviction pending — ${decision.reason} (transcriptFresh=${transcriptFresh}, workers=${workers})`,
|
|
156
|
+
);
|
|
157
|
+
writeState(decision.nextState);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(`[watchdog] restarting gateway: ${decision.reason}`);
|
|
162
|
+
if (process.env.MULTI_CLAWD_WATCHDOG_DRY === "1") {
|
|
163
|
+
console.log("[watchdog] dry run — not restarting");
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
execFileSync("openclaw", ["gateway", "restart"], { encoding: "utf8", timeout: 120000 });
|
|
168
|
+
writeState(decision.nextState);
|
|
169
|
+
spoolAlert(
|
|
170
|
+
"info",
|
|
171
|
+
`eviction watchdog restarted the gateway (${decision.reason}) — backends restored`,
|
|
172
|
+
);
|
|
173
|
+
console.log("[watchdog] gateway restarted; backends restored");
|
|
174
|
+
} catch (err) {
|
|
175
|
+
spoolAlert("error", "eviction watchdog FAILED to restart the gateway — backends may be down");
|
|
176
|
+
console.error(`[watchdog] restart failed: ${String(err)}`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* multi-clawd interactive setup wizard.
|
|
4
|
+
*
|
|
5
|
+
* npm run setup (from a source checkout)
|
|
6
|
+
* node scripts/setup.mjs (from an installed copy)
|
|
7
|
+
* ... --dry-run (show what would change, write nothing)
|
|
8
|
+
*
|
|
9
|
+
* Walks a user through the standard multi-account shape and merges the result
|
|
10
|
+
* into ~/.openclaw/openclaw.json non-destructively (backup first, accounts
|
|
11
|
+
* merged by id, an existing pool is never overwritten, re-runs are no-ops):
|
|
12
|
+
*
|
|
13
|
+
* - main account (claw1): your existing `claude` login in the DEFAULT
|
|
14
|
+
* config dir (~/.claude) — nothing to set up, it is used as-is.
|
|
15
|
+
* - second account (claw2): its own ISOLATED config dir (e.g. ~/.claw2),
|
|
16
|
+
* a fully separate Claude "app", so the two logins can never touch each
|
|
17
|
+
* other. Its token comes from a secret manager ref (preferred), a token
|
|
18
|
+
* file, or the dir's own stored login.
|
|
19
|
+
* - pool (clawd): one backend id fronting both accounts with near-limit
|
|
20
|
+
* rotation — route chains at clawd/<model>.
|
|
21
|
+
*
|
|
22
|
+
* The wizard never sees or stores a token value. All scaffolding logic is
|
|
23
|
+
* pure and unit-tested in src/setup-core.ts; this file owns prompts and IO.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join, dirname, resolve } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
import readline from "node:readline/promises";
|
|
31
|
+
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const DRY_RUN = process.argv.includes("--dry-run");
|
|
34
|
+
const CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
|
|
35
|
+
|
|
36
|
+
let core;
|
|
37
|
+
try {
|
|
38
|
+
core = await import(resolve(__dirname, "..", "dist", "setup-core.js"));
|
|
39
|
+
} catch {
|
|
40
|
+
console.error("setup: dist/setup-core.js is missing — run `npm run build` first (source checkout) or reinstall the plugin.");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig } = core;
|
|
44
|
+
|
|
45
|
+
// Line-queued prompts: interactive AND pipe-safe. With piped stdin, readline
|
|
46
|
+
// emits every buffered line immediately — a plain question() would capture one
|
|
47
|
+
// and drop the rest, hanging later prompts. Queue them all; EOF yields "" so
|
|
48
|
+
// remaining prompts fall back to their defaults.
|
|
49
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
50
|
+
const pendingLines = [];
|
|
51
|
+
const waiters = [];
|
|
52
|
+
let stdinClosed = false;
|
|
53
|
+
rl.on("line", (l) => {
|
|
54
|
+
const w = waiters.shift();
|
|
55
|
+
if (w) w(l);
|
|
56
|
+
else pendingLines.push(l);
|
|
57
|
+
});
|
|
58
|
+
rl.on("close", () => {
|
|
59
|
+
stdinClosed = true;
|
|
60
|
+
for (const w of waiters.splice(0)) w("");
|
|
61
|
+
});
|
|
62
|
+
const readAnswer = (prompt) => {
|
|
63
|
+
process.stdout.write(prompt);
|
|
64
|
+
if (pendingLines.length > 0) {
|
|
65
|
+
const l = pendingLines.shift();
|
|
66
|
+
process.stdout.write(`${l}\n`);
|
|
67
|
+
return Promise.resolve(l);
|
|
68
|
+
}
|
|
69
|
+
if (stdinClosed) {
|
|
70
|
+
process.stdout.write("\n");
|
|
71
|
+
return Promise.resolve("");
|
|
72
|
+
}
|
|
73
|
+
return new Promise((r) => waiters.push(r));
|
|
74
|
+
};
|
|
75
|
+
const ask = async (q, dflt) => {
|
|
76
|
+
const a = (await readAnswer(dflt !== undefined ? `${q} [${dflt}] ` : `${q} `)).trim();
|
|
77
|
+
return a || dflt || "";
|
|
78
|
+
};
|
|
79
|
+
const yes = async (q, dflt = true) => {
|
|
80
|
+
const a = (await readAnswer(`${q} ${dflt ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
81
|
+
if (!a) return dflt;
|
|
82
|
+
return a.startsWith("y");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
console.log(`
|
|
86
|
+
multi-clawd setup — two Claude accounts, one failover pool
|
|
87
|
+
==========================================================
|
|
88
|
+
How it works:
|
|
89
|
+
• Your MAIN account is the \`claude\` login already on this machine
|
|
90
|
+
(default config dir ~/.claude). The wizard leaves it exactly as-is.
|
|
91
|
+
• A SECOND account lives in its own isolated config dir (a separate
|
|
92
|
+
Claude "app") — the two logins can never overwrite each other.
|
|
93
|
+
• A POOL (one backend id, e.g. "clawd") fronts both: each launch runs on
|
|
94
|
+
the first account that is not nearly maxed out.
|
|
95
|
+
This wizard edits ~/.openclaw/openclaw.json (backup taken first). It never
|
|
96
|
+
sees, stores, or prints a token value.${DRY_RUN ? "\n (dry-run: nothing will be written)" : ""}
|
|
97
|
+
`);
|
|
98
|
+
|
|
99
|
+
// ── preflight ────────────────────────────────────────────────────────────────
|
|
100
|
+
try {
|
|
101
|
+
execFileSync("claude", ["--version"], { stdio: "pipe" });
|
|
102
|
+
} catch {
|
|
103
|
+
console.error("preflight: the `claude` CLI is not on PATH. Install Claude Code first: npm install -g @anthropic-ai/claude-code");
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
let existingRaw = "{}";
|
|
107
|
+
if (existsSync(CONFIG_PATH)) existingRaw = readFileSync(CONFIG_PATH, "utf8");
|
|
108
|
+
let existing;
|
|
109
|
+
try {
|
|
110
|
+
existing = JSON.parse(existingRaw);
|
|
111
|
+
} catch {
|
|
112
|
+
console.error(`preflight: ${CONFIG_PATH} exists but is not valid JSON — fix it before running setup.`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const state = planFromExisting(existing);
|
|
116
|
+
if (state.accountIds.length > 0) {
|
|
117
|
+
console.log(`Found existing multi-clawd accounts: ${state.accountIds.join(", ")} — re-running is safe (merge by id, no duplicates).\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── main account ─────────────────────────────────────────────────────────────
|
|
121
|
+
const accounts = [];
|
|
122
|
+
if (await yes("Add your MAIN account (the machine's existing `claude` login) to the pool?")) {
|
|
123
|
+
const id = await ask(" id for the main account:", "claw1");
|
|
124
|
+
accounts.push(buildMainAccount({ id, label: await ask(" label:", "Main Claude") }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── second account ───────────────────────────────────────────────────────────
|
|
128
|
+
if (await yes("Set up a SECOND Claude account (its own isolated config dir)?")) {
|
|
129
|
+
const id = await ask(" id for the second account:", "claw2");
|
|
130
|
+
let configDir;
|
|
131
|
+
for (;;) {
|
|
132
|
+
configDir = await ask(" isolated config dir:", `~/.${id}`);
|
|
133
|
+
const err = validateSecondConfigDir(configDir);
|
|
134
|
+
if (!err) break;
|
|
135
|
+
console.log(` ✗ ${err}`);
|
|
136
|
+
}
|
|
137
|
+
console.log(`
|
|
138
|
+
Now log the SECOND account in (you, in your own terminal — the wizard
|
|
139
|
+
cannot and must not do this for you):
|
|
140
|
+
|
|
141
|
+
CLAUDE_CONFIG_DIR=${configDir} claude setup-token
|
|
142
|
+
|
|
143
|
+
Sign in as the SECOND account (not your main one!) and note where you put
|
|
144
|
+
the printed setup-token. Options for where the plugin reads it from:`);
|
|
145
|
+
console.log(`
|
|
146
|
+
1) secret manager ref (RECOMMENDED — no plaintext on disk)
|
|
147
|
+
e.g. 1Password: store the token as an item field, then give the
|
|
148
|
+
reference like op://Vault/Item/field
|
|
149
|
+
2) token file
|
|
150
|
+
e.g. save it to ${configDir}/oauth-token and chmod 600 it
|
|
151
|
+
3) none — rely on the login stored inside ${configDir} itself
|
|
152
|
+
`);
|
|
153
|
+
const choice = await ask(" token source (1/2/3):", "1");
|
|
154
|
+
let tokenSource;
|
|
155
|
+
if (choice === "1") {
|
|
156
|
+
const provider = await ask(" gateway secret provider name:", "onepassword");
|
|
157
|
+
let refId;
|
|
158
|
+
for (;;) {
|
|
159
|
+
refId = await ask(" secret reference (e.g. op://Vault/Item/field):");
|
|
160
|
+
if (refId) break;
|
|
161
|
+
if (stdinClosed) {
|
|
162
|
+
console.error("setup: a secret reference is required for token source 1 — aborting (nothing written).");
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
console.log(" ✗ the reference is required (it is NOT the token itself — just the pointer to it)");
|
|
166
|
+
}
|
|
167
|
+
tokenSource = { kind: "ref", ref: { source: "exec", provider, id: refId } };
|
|
168
|
+
} else if (choice === "2") {
|
|
169
|
+
tokenSource = { kind: "file", path: await ask(" token file path:", `${configDir}/oauth-token`) };
|
|
170
|
+
} else {
|
|
171
|
+
tokenSource = { kind: "dir-login" };
|
|
172
|
+
}
|
|
173
|
+
accounts.push(buildSecondAccount({ id, label: await ask(" label:", "Second Claude"), configDir, tokenSource }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (accounts.length === 0 && state.accountIds.length === 0) {
|
|
177
|
+
console.log("Nothing to set up — no accounts chosen. Bye.");
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── pool ─────────────────────────────────────────────────────────────────────
|
|
182
|
+
const poolMemberIds = [...new Set([...accounts.map((a) => a.id), ...state.accountIds])];
|
|
183
|
+
let pool;
|
|
184
|
+
const modelRungs = [];
|
|
185
|
+
if (state.hasPool) {
|
|
186
|
+
console.log("A pool already exists — leaving it untouched.");
|
|
187
|
+
} else if (poolMemberIds.length >= 1 && (await yes(`Create the failover pool over [${poolMemberIds.join(", ")}]?`))) {
|
|
188
|
+
pool = buildPool(poolMemberIds, { id: await ask(" pool id:", "clawd") });
|
|
189
|
+
modelRungs.push(`${pool.id}/claude-fable-5`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── merge + write ────────────────────────────────────────────────────────────
|
|
193
|
+
const { config, changes } = mergeSetupIntoConfig(existing, { accounts, pool, modelRungs });
|
|
194
|
+
console.log("\nPlanned changes:");
|
|
195
|
+
if (changes.length === 0) console.log(" (none — config already matches)");
|
|
196
|
+
for (const c of changes) console.log(` • ${c}`);
|
|
197
|
+
|
|
198
|
+
if (DRY_RUN || changes.length === 0) {
|
|
199
|
+
console.log(DRY_RUN ? "\ndry-run: nothing written." : "");
|
|
200
|
+
rl.close();
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!(await yes(`\nWrite these to ${CONFIG_PATH}? (backup taken first)`))) {
|
|
205
|
+
console.log("Aborted — nothing written.");
|
|
206
|
+
rl.close();
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
210
|
+
if (existsSync(CONFIG_PATH)) {
|
|
211
|
+
const backup = `${CONFIG_PATH}.bak-setup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
212
|
+
copyFileSync(CONFIG_PATH, backup);
|
|
213
|
+
console.log(`backup: ${backup}`);
|
|
214
|
+
}
|
|
215
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
216
|
+
console.log(`wrote ${CONFIG_PATH}`);
|
|
217
|
+
|
|
218
|
+
// ── next steps ───────────────────────────────────────────────────────────────
|
|
219
|
+
console.log(`
|
|
220
|
+
Done. Finish with:
|
|
221
|
+
|
|
222
|
+
openclaw gateway restart
|
|
223
|
+
node ${join(__dirname, "doctor.mjs")} # expect READY
|
|
224
|
+
|
|
225
|
+
If the plugin was installed BEFORE these config keys existed, the gateway may
|
|
226
|
+
refuse the config against the old manifest — run the doctor's preflight for
|
|
227
|
+
the strip → force-install → re-add plan:
|
|
228
|
+
|
|
229
|
+
node ${join(__dirname, "doctor.mjs")} --preflight
|
|
230
|
+
`);
|
|
231
|
+
rl.close();
|