@drakon-systems/multi-clawd 1.0.1 → 1.2.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/README.md +14 -7
- package/dist/update-core.js +30 -0
- package/dist/watchdog-schedule.js +66 -0
- package/package.json +4 -1
- package/scripts/cli.mjs +187 -0
- package/scripts/doctor.mjs +37 -2
- package/scripts/setup.mjs +138 -17
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ Pool every Claude Max account you own into a single failover chain —
|
|
|
10
10
|
same model, next account, full harness on every hop.
|
|
11
11
|
|
|
12
12
|
[](https://docs.openclaw.ai/plugins)
|
|
13
|
-
[](https://www.npmjs.com/package/@drakon-systems/multi-clawd)
|
|
14
|
+
[](CHANGELOG.md)
|
|
14
15
|
[](LICENSE)
|
|
15
16
|
[](tsconfig.json)
|
|
16
17
|
|
|
@@ -112,7 +113,7 @@ Code backend runs.
|
|
|
112
113
|
|
|
113
114
|
## Install
|
|
114
115
|
|
|
115
|
-
**From npm (recommended
|
|
116
|
+
**From npm (recommended):**
|
|
116
117
|
|
|
117
118
|
```bash
|
|
118
119
|
openclaw plugins install @drakon-systems/multi-clawd --pin
|
|
@@ -123,8 +124,9 @@ The gateway pulls the prebuilt package — no clone, no build step, nothing to
|
|
|
123
124
|
keep in sync. `--pin` records the exact resolved version, so an upgrade is a
|
|
124
125
|
deliberate `@latest`, never a surprise. `openclaw` itself is a *peer*
|
|
125
126
|
dependency (the host provides it), so the install stays lean. Confirm with
|
|
126
|
-
`openclaw plugins list` (expect `multi-clawd` enabled)
|
|
127
|
-
|
|
127
|
+
`openclaw plugins list` (expect `multi-clawd` enabled) — it also shows the
|
|
128
|
+
install path; run the doctor from there:
|
|
129
|
+
`node <install-path>/scripts/doctor.mjs`.
|
|
128
130
|
|
|
129
131
|
**From ClawHub (alternative registry):**
|
|
130
132
|
|
|
@@ -163,10 +165,15 @@ second Claude subscription you own.
|
|
|
163
165
|
**Upgrading:**
|
|
164
166
|
|
|
165
167
|
```bash
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
openclaw gateway restart
|
|
168
|
+
npx @drakon-systems/multi-clawd update
|
|
169
|
+
```
|
|
169
170
|
|
|
171
|
+
One command: checks the registry, installs the new version with the right
|
|
172
|
+
flags, offers the gateway restart, and finishes with a doctor health check.
|
|
173
|
+
(`npm i -g @drakon-systems/multi-clawd` once, and it's just `multi-clawd
|
|
174
|
+
update` — with `multi-clawd setup` and `multi-clawd doctor` alongside.)
|
|
175
|
+
|
|
176
|
+
```bash
|
|
170
177
|
# From source:
|
|
171
178
|
cd multi-clawd && git pull && npm install && npm run build && npm run doctor
|
|
172
179
|
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function compareVersions(a, b) {
|
|
2
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
3
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
4
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
5
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
6
|
+
if (d !== 0)
|
|
7
|
+
return d;
|
|
8
|
+
}
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
11
|
+
export function decideUpdateAction(opts) {
|
|
12
|
+
if (opts.installed === undefined)
|
|
13
|
+
return "install";
|
|
14
|
+
if (opts.latest === undefined)
|
|
15
|
+
return "unknown";
|
|
16
|
+
return compareVersions(opts.installed, opts.latest) < 0 ? "update" : "up-to-date";
|
|
17
|
+
}
|
|
18
|
+
export function formatUpdateBanner(opts) {
|
|
19
|
+
const action = decideUpdateAction(opts);
|
|
20
|
+
switch (action) {
|
|
21
|
+
case "install":
|
|
22
|
+
return `not installed — latest is v${opts.latest}`;
|
|
23
|
+
case "update":
|
|
24
|
+
return `update available: v${opts.installed} → v${opts.latest}`;
|
|
25
|
+
case "up-to-date":
|
|
26
|
+
return `up to date (v${opts.installed})`;
|
|
27
|
+
case "unknown":
|
|
28
|
+
return `installed v${opts.installed} — could not reach the registry to check for updates`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const WATCHDOG_LAUNCHD_LABEL = "com.multi-clawd.eviction-watchdog";
|
|
2
|
+
export const WATCHDOG_SYSTEMD_NAME = "multi-clawd-eviction-watchdog";
|
|
3
|
+
export function renderWatchdogUnit(opts) {
|
|
4
|
+
if (opts.platform === "darwin") {
|
|
5
|
+
return [
|
|
6
|
+
{
|
|
7
|
+
path: `Library/LaunchAgents/${WATCHDOG_LAUNCHD_LABEL}.plist`,
|
|
8
|
+
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
9
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
10
|
+
<plist version="1.0">
|
|
11
|
+
<dict>
|
|
12
|
+
<key>Label</key>
|
|
13
|
+
<string>${WATCHDOG_LAUNCHD_LABEL}</string>
|
|
14
|
+
<key>ProgramArguments</key>
|
|
15
|
+
<array>
|
|
16
|
+
<string>${opts.nodePath}</string>
|
|
17
|
+
<string>${opts.scriptPath}</string>
|
|
18
|
+
</array>
|
|
19
|
+
<key>StartInterval</key>
|
|
20
|
+
<integer>300</integer>
|
|
21
|
+
<key>RunAtLoad</key>
|
|
22
|
+
<true/>
|
|
23
|
+
</dict>
|
|
24
|
+
</plist>
|
|
25
|
+
`,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
if (opts.platform === "linux") {
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
path: `.config/systemd/user/${WATCHDOG_SYSTEMD_NAME}.service`,
|
|
33
|
+
content: `[Unit]
|
|
34
|
+
Description=multi-clawd eviction watchdog (turn-safe; openclaw#107408 mitigation)
|
|
35
|
+
|
|
36
|
+
[Service]
|
|
37
|
+
Type=oneshot
|
|
38
|
+
ExecStart=${opts.nodePath} ${opts.scriptPath}
|
|
39
|
+
`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
path: `.config/systemd/user/${WATCHDOG_SYSTEMD_NAME}.timer`,
|
|
43
|
+
content: `[Unit]
|
|
44
|
+
Description=Run the multi-clawd eviction watchdog every 5 minutes
|
|
45
|
+
|
|
46
|
+
[Timer]
|
|
47
|
+
OnBootSec=2min
|
|
48
|
+
OnUnitActiveSec=5min
|
|
49
|
+
|
|
50
|
+
[Install]
|
|
51
|
+
WantedBy=timers.target
|
|
52
|
+
`,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
export function extractWatchdogTarget(unitText) {
|
|
59
|
+
const m = unitText.match(/[^<>\s="']*eviction-watchdog\.mjs/);
|
|
60
|
+
return m ? m[0] : undefined;
|
|
61
|
+
}
|
|
62
|
+
export function classifyWatchdogUnit(targetPath, exists) {
|
|
63
|
+
if (targetPath === undefined)
|
|
64
|
+
return "absent";
|
|
65
|
+
return exists(targetPath) ? "ok" : "orphaned";
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Multi-account Claude Code failover for OpenClaw — register additional Claude (Max/Pro) logins as first-class CLI backends and keep the full skills/MCP harness across every account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
37
|
"main": "./dist/index.js",
|
|
38
|
+
"bin": {
|
|
39
|
+
"multi-clawd": "./scripts/cli.mjs"
|
|
40
|
+
},
|
|
38
41
|
"publishConfig": {
|
|
39
42
|
"access": "public"
|
|
40
43
|
},
|
package/scripts/cli.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* multi-clawd CLI — the friendly front door.
|
|
4
|
+
*
|
|
5
|
+
* npx @drakon-systems/multi-clawd setup guided setup wizard
|
|
6
|
+
* npx @drakon-systems/multi-clawd update update to the latest version
|
|
7
|
+
* npx @drakon-systems/multi-clawd doctor health check
|
|
8
|
+
* npx @drakon-systems/multi-clawd version versions (CLI + installed plugin)
|
|
9
|
+
*
|
|
10
|
+
* (Installed globally via `npm i -g @drakon-systems/multi-clawd`, the same
|
|
11
|
+
* commands are just `multi-clawd setup` / `multi-clawd update` / …)
|
|
12
|
+
*
|
|
13
|
+
* `update` wraps the whole upgrade dance — registry version check, the
|
|
14
|
+
* openclaw install with the right flags, gateway restart, doctor — so nobody
|
|
15
|
+
* has to remember `--pin --force`.
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import readline from "node:readline/promises";
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const PKG = "@drakon-systems/multi-clawd";
|
|
26
|
+
const HOME = homedir();
|
|
27
|
+
const BOLD = process.stdout.isTTY ? "\x1b[1m" : "";
|
|
28
|
+
const DIM = process.stdout.isTTY ? "\x1b[2m" : "";
|
|
29
|
+
const RESET = process.stdout.isTTY ? "\x1b[0m" : "";
|
|
30
|
+
|
|
31
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
32
|
+
|
|
33
|
+
function usage() {
|
|
34
|
+
console.log(`
|
|
35
|
+
${BOLD}🦞 multi-clawd${RESET} — multi-account Claude failover for OpenClaw
|
|
36
|
+
|
|
37
|
+
${BOLD}setup${RESET} guided setup wizard (accounts, pool, watchdog)
|
|
38
|
+
${BOLD}update${RESET} update the plugin to the latest version
|
|
39
|
+
${BOLD}doctor${RESET} health check (add --probe for a live turn)
|
|
40
|
+
${BOLD}version${RESET} show CLI + installed plugin versions
|
|
41
|
+
|
|
42
|
+
Run via npx (${DIM}npx ${PKG} <command>${RESET}) or install globally
|
|
43
|
+
(${DIM}npm i -g ${PKG}${RESET}) for a bare ${DIM}multi-clawd <command>${RESET}.
|
|
44
|
+
`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Same resolution as the doctor: path install wins, else newest npm-project install. */
|
|
48
|
+
function resolveInstallDir() {
|
|
49
|
+
const extDir = join(HOME, ".openclaw", "extensions", "multi-clawd");
|
|
50
|
+
if (existsSync(join(extDir, "openclaw.plugin.json"))) return extDir;
|
|
51
|
+
const projects = join(HOME, ".openclaw", "npm", "projects");
|
|
52
|
+
let best;
|
|
53
|
+
let bestM = -1;
|
|
54
|
+
try {
|
|
55
|
+
for (const p of readdirSync(projects)) {
|
|
56
|
+
if (!p.startsWith("drakon-systems-multi-clawd-")) continue;
|
|
57
|
+
const dir = join(projects, p, "node_modules", "@drakon-systems", "multi-clawd");
|
|
58
|
+
const manifest = join(dir, "openclaw.plugin.json");
|
|
59
|
+
if (!existsSync(manifest)) continue;
|
|
60
|
+
const m = statSync(manifest).mtimeMs;
|
|
61
|
+
if (m > bestM) {
|
|
62
|
+
bestM = m;
|
|
63
|
+
best = dir;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
/* no npm projects dir */
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function installedVersion() {
|
|
73
|
+
const dir = resolveInstallDir();
|
|
74
|
+
if (!dir) return undefined;
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function latestVersion() {
|
|
83
|
+
try {
|
|
84
|
+
return execFileSync("npm", ["view", PKG, "version"], { encoding: "utf8", timeout: 15000 })
|
|
85
|
+
.trim();
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function haveOpenclaw() {
|
|
92
|
+
try {
|
|
93
|
+
execFileSync("openclaw", ["--version"], { stdio: "pipe", timeout: 15000 });
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runSibling(script, args) {
|
|
101
|
+
const r = spawnSync(process.execPath, [join(__dirname, script), ...args], { stdio: "inherit" });
|
|
102
|
+
process.exit(r.status ?? 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function askYes(question, dflt = true) {
|
|
106
|
+
if (!process.stdin.isTTY) return dflt;
|
|
107
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
108
|
+
const a = (await rl.question(`${question} ${dflt ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
109
|
+
rl.close();
|
|
110
|
+
if (!a) return dflt;
|
|
111
|
+
return a.startsWith("y");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function update() {
|
|
115
|
+
let uc;
|
|
116
|
+
try {
|
|
117
|
+
uc = await import(resolve(__dirname, "..", "dist", "update-core.js"));
|
|
118
|
+
} catch {
|
|
119
|
+
console.error("update: built dist/ is missing — reinstall the package.");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
console.log(`\n${BOLD}🦞 multi-clawd update${RESET}\n`);
|
|
123
|
+
if (!haveOpenclaw()) {
|
|
124
|
+
console.error(" ❌ the `openclaw` CLI is not on PATH — install OpenClaw first.");
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
const installed = installedVersion();
|
|
128
|
+
process.stdout.write(` checking registry… `);
|
|
129
|
+
const latest = latestVersion();
|
|
130
|
+
console.log(latest ? `latest is v${latest}` : "unreachable");
|
|
131
|
+
const banner = uc.formatUpdateBanner({ installed, latest });
|
|
132
|
+
const action = uc.decideUpdateAction({ installed, latest });
|
|
133
|
+
console.log(` ${action === "up-to-date" ? "✅" : action === "unknown" ? "⚠️ " : "⬆️ "} ${banner}\n`);
|
|
134
|
+
if (action === "up-to-date") return;
|
|
135
|
+
if (action === "unknown") {
|
|
136
|
+
console.log(" Check your network and try again.");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
if (!(await askYes(` ${action === "install" ? "Install" : "Update"} now?`))) return;
|
|
140
|
+
|
|
141
|
+
console.log(`\n ${DIM}openclaw plugins install ${PKG} --pin --force${RESET}`);
|
|
142
|
+
const inst = spawnSync("openclaw", ["plugins", "install", PKG, "--pin", "--force"], {
|
|
143
|
+
stdio: "inherit",
|
|
144
|
+
});
|
|
145
|
+
if (inst.status !== 0) {
|
|
146
|
+
console.error("\n ❌ install failed — see output above.");
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
if (await askYes("\n Restart the gateway to load it? (briefly interrupts running turns)")) {
|
|
150
|
+
const r = spawnSync("openclaw", ["gateway", "restart"], { stdio: "inherit" });
|
|
151
|
+
if (r.status !== 0) console.log(" ⚠ restart failed — run `openclaw gateway restart` yourself.");
|
|
152
|
+
} else {
|
|
153
|
+
console.log(" ⏳ remember: the new version loads on the next gateway restart.");
|
|
154
|
+
}
|
|
155
|
+
console.log(`\n${BOLD} health check${RESET}`);
|
|
156
|
+
const doc = spawnSync(process.execPath, [join(__dirname, "doctor.mjs")], { stdio: "inherit" });
|
|
157
|
+
if (doc.status !== 0) {
|
|
158
|
+
console.log(`\n ⚠ doctor found problems — if it flagged the watchdog, run ${BOLD}multi-clawd setup${RESET} to repair it.`);
|
|
159
|
+
process.exit(doc.status ?? 1);
|
|
160
|
+
}
|
|
161
|
+
console.log(`\n ✅ done — now on v${installedVersion() ?? "?"}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
switch (cmd) {
|
|
165
|
+
case "setup":
|
|
166
|
+
runSibling("setup.mjs", rest);
|
|
167
|
+
break;
|
|
168
|
+
case "doctor":
|
|
169
|
+
runSibling("doctor.mjs", rest);
|
|
170
|
+
break;
|
|
171
|
+
case "update":
|
|
172
|
+
await update();
|
|
173
|
+
break;
|
|
174
|
+
case "version":
|
|
175
|
+
case "--version":
|
|
176
|
+
case "-v": {
|
|
177
|
+
const cliVersion = JSON.parse(
|
|
178
|
+
readFileSync(resolve(__dirname, "..", "package.json"), "utf8"),
|
|
179
|
+
).version;
|
|
180
|
+
console.log(`cli: v${cliVersion}`);
|
|
181
|
+
console.log(`installed plugin: ${installedVersion() ? `v${installedVersion()}` : "(not installed)"}`);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
usage();
|
|
186
|
+
process.exit(cmd === undefined || cmd === "help" || cmd === "--help" ? 0 : 1);
|
|
187
|
+
}
|
package/scripts/doctor.mjs
CHANGED
|
@@ -344,8 +344,43 @@ try {
|
|
|
344
344
|
} catch {
|
|
345
345
|
/* not systemd */
|
|
346
346
|
}
|
|
347
|
-
if (watchdogFound)
|
|
348
|
-
|
|
347
|
+
if (watchdogFound) {
|
|
348
|
+
// "Scheduled" is not enough: the unit points at a script INSIDE an install
|
|
349
|
+
// dir, and installs move (path→registry migration, uninstall/reinstall).
|
|
350
|
+
// An orphaned unit fires every tick against a missing file — silently.
|
|
351
|
+
// Deliberately self-contained (no dist import): the check must work even
|
|
352
|
+
// when the install itself is the thing that went missing.
|
|
353
|
+
let orphan;
|
|
354
|
+
for (const d of [
|
|
355
|
+
join(HOME, "Library", "LaunchAgents"),
|
|
356
|
+
join(HOME, ".config", "systemd", "user"),
|
|
357
|
+
]) {
|
|
358
|
+
let files = [];
|
|
359
|
+
try {
|
|
360
|
+
files = readdirSync(d);
|
|
361
|
+
} catch {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
for (const f of files) {
|
|
365
|
+
// Only real unit files — launchd loads *.plist, systemd *.service/*.timer;
|
|
366
|
+
// backups like *.plist.bak-... are inert and must not be flagged.
|
|
367
|
+
if (!/\.(plist|service|timer)$/.test(f)) continue;
|
|
368
|
+
let text;
|
|
369
|
+
try {
|
|
370
|
+
text = readFileSync(join(d, f), "utf8");
|
|
371
|
+
} catch {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const target = text.match(/[^<>\s="']*eviction-watchdog\.mjs/)?.[0];
|
|
375
|
+
if (target && !existsSync(target)) orphan = { file: join(d, f), target };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (orphan) {
|
|
379
|
+
bad(
|
|
380
|
+
`watchdog unit ${orphan.file} points at a MISSING script (${orphan.target}) — it fails silently every tick. Repoint it at ${join(EXT_DIR, "scripts", "eviction-watchdog.mjs")} or run the setup wizard to repair.`,
|
|
381
|
+
);
|
|
382
|
+
} else ok("watchdog scheduled");
|
|
383
|
+
} else warn("no watchdog found (needed until openclaw#107596 ships — see README)");
|
|
349
384
|
|
|
350
385
|
// ── 9. optional live probe ──────────────────────────────────────────────────
|
|
351
386
|
if (args.has("--probe")) {
|
package/scripts/setup.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* The wizard never sees or stores a token value. All scaffolding logic is
|
|
23
23
|
* pure and unit-tested in src/setup-core.ts; this file owns prompts and IO.
|
|
24
24
|
*/
|
|
25
|
-
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
25
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { join, dirname, resolve } from "node:path";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
@@ -33,11 +33,12 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
33
33
|
const DRY_RUN = process.argv.includes("--dry-run");
|
|
34
34
|
const CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
|
|
35
35
|
|
|
36
|
-
let core;
|
|
36
|
+
let core, wds;
|
|
37
37
|
try {
|
|
38
38
|
core = await import(resolve(__dirname, "..", "dist", "setup-core.js"));
|
|
39
|
+
wds = await import(resolve(__dirname, "..", "dist", "watchdog-schedule.js"));
|
|
39
40
|
} catch {
|
|
40
|
-
console.error("setup: dist/
|
|
41
|
+
console.error("setup: built dist/ modules are missing — run `npm run build` first (source checkout) or reinstall the plugin.");
|
|
41
42
|
process.exit(1);
|
|
42
43
|
}
|
|
43
44
|
const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig } = core;
|
|
@@ -195,25 +196,145 @@ console.log("\nPlanned changes:");
|
|
|
195
196
|
if (changes.length === 0) console.log(" (none — config already matches)");
|
|
196
197
|
for (const c of changes) console.log(` • ${c}`);
|
|
197
198
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
let wroteConfig = false;
|
|
200
|
+
if (DRY_RUN) {
|
|
201
|
+
if (changes.length > 0) console.log("\ndry-run: config not written.");
|
|
202
|
+
} else if (changes.length > 0) {
|
|
203
|
+
if (!(await yes(`\nWrite these to ${CONFIG_PATH}? (backup taken first)`))) {
|
|
204
|
+
console.log("Aborted — nothing written.");
|
|
205
|
+
rl.close();
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
209
|
+
if (existsSync(CONFIG_PATH)) {
|
|
210
|
+
const backup = `${CONFIG_PATH}.bak-setup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
211
|
+
copyFileSync(CONFIG_PATH, backup);
|
|
212
|
+
console.log(`backup: ${backup}`);
|
|
213
|
+
}
|
|
214
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
215
|
+
console.log(`wrote ${CONFIG_PATH}`);
|
|
216
|
+
wroteConfig = true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── eviction watchdog: create, or repair an orphaned unit ────────────────────
|
|
220
|
+
// The unit points at a script INSIDE an install dir, and installs move
|
|
221
|
+
// (path→registry migration, uninstall/reinstall) — an orphaned unit fires
|
|
222
|
+
// every 5 min against a missing file, silently. The wizard owns this now.
|
|
223
|
+
await watchdogStep();
|
|
224
|
+
|
|
225
|
+
async function watchdogStep() {
|
|
226
|
+
const platform = process.platform;
|
|
227
|
+
console.log("\nEviction watchdog (openclaw#107408 mitigation — see README):");
|
|
228
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
229
|
+
console.log(" (auto-scheduling not supported on this platform — see README for manual setup)");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const scriptPath = join(__dirname, "eviction-watchdog.mjs");
|
|
233
|
+
const scanDir =
|
|
234
|
+
platform === "darwin"
|
|
235
|
+
? join(homedir(), "Library", "LaunchAgents")
|
|
236
|
+
: join(homedir(), ".config", "systemd", "user");
|
|
237
|
+
let found;
|
|
238
|
+
try {
|
|
239
|
+
for (const f of readdirSync(scanDir)) {
|
|
240
|
+
// Only real unit files — backups like *.plist.bak-... are inert; never
|
|
241
|
+
// detect (or "repair") one of those instead of the live unit.
|
|
242
|
+
if (!/\.(plist|service|timer)$/.test(f)) continue;
|
|
243
|
+
const p = join(scanDir, f);
|
|
244
|
+
let text;
|
|
245
|
+
try {
|
|
246
|
+
text = readFileSync(p, "utf8");
|
|
247
|
+
} catch {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const target = wds.extractWatchdogTarget(text);
|
|
251
|
+
if (target) {
|
|
252
|
+
found = { file: p, target, text };
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
/* scan dir missing — treated as absent */
|
|
258
|
+
}
|
|
259
|
+
const state = wds.classifyWatchdogUnit(found?.target, existsSync);
|
|
260
|
+
if (state === "ok") {
|
|
261
|
+
console.log(` ✅ already scheduled and healthy → ${found.target}`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (state === "orphaned") {
|
|
265
|
+
console.log(
|
|
266
|
+
` ⚠ ${found.file}\n points at a MISSING script: ${found.target}\n (an old install dir — the watchdog has been failing silently)`,
|
|
267
|
+
);
|
|
268
|
+
if (DRY_RUN) {
|
|
269
|
+
console.log(` dry-run: would repoint it at ${scriptPath}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!(await yes(" Repoint it at this install?"))) return;
|
|
273
|
+
writeFileSync(found.file, found.text.split(found.target).join(scriptPath));
|
|
274
|
+
reloadWatchdogUnit(platform, found.file);
|
|
275
|
+
console.log(` ✅ repointed → ${scriptPath}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (DRY_RUN) {
|
|
279
|
+
console.log(` dry-run: not scheduled — would offer to schedule it → ${scriptPath}`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (!(await yes(" Not scheduled. Schedule it now (runs every 5 min)?"))) return;
|
|
283
|
+
const unitFiles = wds.renderWatchdogUnit({ platform, nodePath: process.execPath, scriptPath });
|
|
284
|
+
for (const uf of unitFiles) {
|
|
285
|
+
const abs = join(homedir(), uf.path);
|
|
286
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
287
|
+
writeFileSync(abs, uf.content);
|
|
288
|
+
console.log(` wrote ${abs}`);
|
|
289
|
+
}
|
|
290
|
+
if (platform === "darwin") {
|
|
291
|
+
reloadWatchdogUnit(platform, join(homedir(), unitFiles[0].path));
|
|
292
|
+
} else {
|
|
293
|
+
try {
|
|
294
|
+
execFileSync("systemctl", ["--user", "daemon-reload"]);
|
|
295
|
+
execFileSync("systemctl", ["--user", "enable", "--now", `${wds.WATCHDOG_SYSTEMD_NAME}.timer`]);
|
|
296
|
+
console.log(" ✅ timer enabled");
|
|
297
|
+
} catch {
|
|
298
|
+
console.log(
|
|
299
|
+
` ⚠ units written but enabling failed — run: systemctl --user enable --now ${wds.WATCHDOG_SYSTEMD_NAME}.timer`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
202
303
|
}
|
|
203
304
|
|
|
204
|
-
|
|
205
|
-
|
|
305
|
+
function reloadWatchdogUnit(platform, file) {
|
|
306
|
+
if (platform === "darwin") {
|
|
307
|
+
try {
|
|
308
|
+
execFileSync("launchctl", ["unload", file], { stdio: "ignore" });
|
|
309
|
+
} catch {
|
|
310
|
+
/* was not loaded */
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
// launchctl can print "Load failed" to stderr and still exit 0 — merge
|
|
314
|
+
// the streams and check the text, not just the exit code.
|
|
315
|
+
const out = execFileSync("/bin/sh", ["-c", `launchctl load "${file}" 2>&1 || true`], {
|
|
316
|
+
encoding: "utf8",
|
|
317
|
+
});
|
|
318
|
+
if (/load failed|bootstrap failed/i.test(out)) throw new Error(out.trim());
|
|
319
|
+
console.log(" ✅ launchd agent (re)loaded");
|
|
320
|
+
} catch {
|
|
321
|
+
console.log(` ⚠ plist written but load failed — run: launchctl load ${file}`);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
try {
|
|
325
|
+
execFileSync("systemctl", ["--user", "daemon-reload"]);
|
|
326
|
+
console.log(" ✅ systemd reloaded");
|
|
327
|
+
} catch {
|
|
328
|
+
console.log(" ⚠ run: systemctl --user daemon-reload");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (DRY_RUN || !wroteConfig) {
|
|
334
|
+
console.log(DRY_RUN ? "\ndry-run: nothing written." : "");
|
|
206
335
|
rl.close();
|
|
207
336
|
process.exit(0);
|
|
208
337
|
}
|
|
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
338
|
|
|
218
339
|
// ── next steps ───────────────────────────────────────────────────────────────
|
|
219
340
|
console.log(`
|