@drakon-systems/multi-clawd 1.2.1 → 1.2.3
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/dist/setup-core.js +19 -0
- package/dist/watchdog-schedule.js +50 -1
- package/package.json +1 -1
- package/scripts/_shared.mjs +18 -14
- package/scripts/cli.mjs +68 -0
- package/scripts/doctor.mjs +6 -1
- package/scripts/setup.mjs +67 -15
package/dist/setup-core.js
CHANGED
|
@@ -138,3 +138,22 @@ export function mergeSetupIntoConfig(existing, plan) {
|
|
|
138
138
|
}
|
|
139
139
|
return { config, changes };
|
|
140
140
|
}
|
|
141
|
+
export function existingAccountDefaults(config, id) {
|
|
142
|
+
const entries = asRecord(asRecord(asRecord(config)?.plugins)?.entries);
|
|
143
|
+
const entryConfig = asRecord(asRecord(entries?.["multi-clawd"])?.config);
|
|
144
|
+
const accounts = Array.isArray(entryConfig?.accounts) ? entryConfig?.accounts : [];
|
|
145
|
+
const acc = accounts.map(asRecord).find((a) => a?.id === id);
|
|
146
|
+
if (!acc)
|
|
147
|
+
return undefined;
|
|
148
|
+
return {
|
|
149
|
+
configDir: typeof acc.configDir === "string" ? acc.configDir : undefined,
|
|
150
|
+
label: typeof acc.label === "string" ? acc.label : undefined,
|
|
151
|
+
hasCredentials: acc.native === true ||
|
|
152
|
+
acc.oauthTokenRef !== undefined ||
|
|
153
|
+
acc.oauthTokenFile !== undefined ||
|
|
154
|
+
typeof acc.configDir === "string",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export function looksLikeSecretRef(id) {
|
|
158
|
+
return id.includes("://");
|
|
159
|
+
}
|
|
@@ -56,7 +56,7 @@ WantedBy=timers.target
|
|
|
56
56
|
return [];
|
|
57
57
|
}
|
|
58
58
|
export function extractWatchdogTarget(unitText) {
|
|
59
|
-
const m = unitText.match(/[^<>\s="']*eviction-watchdog\.mjs/);
|
|
59
|
+
const m = unitText.match(/[^<>\s="']*(?:eviction-watchdog|watchdog-launcher)\.mjs/);
|
|
60
60
|
return m ? m[0] : undefined;
|
|
61
61
|
}
|
|
62
62
|
export function classifyWatchdogUnit(targetPath, exists) {
|
|
@@ -64,3 +64,52 @@ export function classifyWatchdogUnit(targetPath, exists) {
|
|
|
64
64
|
return "absent";
|
|
65
65
|
return exists(targetPath) ? "ok" : "orphaned";
|
|
66
66
|
}
|
|
67
|
+
export function isFragileWatchdogTarget(target) {
|
|
68
|
+
return target.includes("/.openclaw/npm/projects/");
|
|
69
|
+
}
|
|
70
|
+
export function renderWatchdogLauncher() {
|
|
71
|
+
return `#!/usr/bin/env node
|
|
72
|
+
// multi-clawd watchdog launcher — the STABLE target for scheduler units.
|
|
73
|
+
// Installs move on every update (the npm project dir is regenerated), so the
|
|
74
|
+
// unit points HERE; this launcher finds the current install at runtime and
|
|
75
|
+
// runs its eviction watchdog. Managed by the setup wizard / update command.
|
|
76
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
77
|
+
import { homedir } from "node:os";
|
|
78
|
+
import { join } from "node:path";
|
|
79
|
+
import { spawnSync } from "node:child_process";
|
|
80
|
+
|
|
81
|
+
const HOME = homedir();
|
|
82
|
+
|
|
83
|
+
function installDir() {
|
|
84
|
+
const ext = join(HOME, ".openclaw", "extensions", "multi-clawd");
|
|
85
|
+
if (existsSync(join(ext, "openclaw.plugin.json"))) return ext;
|
|
86
|
+
const projects = join(HOME, ".openclaw", "npm", "projects");
|
|
87
|
+
let best;
|
|
88
|
+
let bestM = -1;
|
|
89
|
+
try {
|
|
90
|
+
for (const p of readdirSync(projects)) {
|
|
91
|
+
if (!p.startsWith("drakon-systems-multi-clawd-")) continue;
|
|
92
|
+
const dir = join(projects, p, "node_modules", "@drakon-systems", "multi-clawd");
|
|
93
|
+
const manifest = join(dir, "openclaw.plugin.json");
|
|
94
|
+
if (!existsSync(manifest)) continue;
|
|
95
|
+
const t = statSync(manifest).mtimeMs;
|
|
96
|
+
if (t > bestM) { bestM = t; best = dir; }
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
return best;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const dir = installDir();
|
|
103
|
+
if (!dir) {
|
|
104
|
+
console.error("[watchdog-launcher] no multi-clawd install found — nothing to do");
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
const script = join(dir, "scripts", "eviction-watchdog.mjs");
|
|
108
|
+
if (!existsSync(script)) {
|
|
109
|
+
console.error("[watchdog-launcher] " + script + " missing — nothing to do");
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
const r = spawnSync(process.execPath, [script], { stdio: "inherit" });
|
|
113
|
+
process.exit(r.status ?? 0);
|
|
114
|
+
`;
|
|
115
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
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",
|
package/scripts/_shared.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared helpers for the CLI scripts (cli.mjs, setup.mjs).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Two real footguns shaped this file: run via `npx`, a script's __dirname is
|
|
5
|
+
* the EPHEMERAL npx cache; and the npm install dir itself is regenerated on
|
|
6
|
+
* every update. So nothing durable (scheduler units) may point at either —
|
|
7
|
+
* they point at the stable WATCHDOG_LAUNCHER, which resolves the current
|
|
8
|
+
* install at runtime.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
@@ -37,12 +37,16 @@ export function resolveInstallDir() {
|
|
|
37
37
|
return best;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The stable launcher path scheduler units point at. Lives in the state dir
|
|
42
|
+
* (never moved by installs); its content is rendered by
|
|
43
|
+
* watchdog-schedule.ts#renderWatchdogLauncher and resolves the current
|
|
44
|
+
* install at runtime.
|
|
45
|
+
*/
|
|
46
|
+
export const WATCHDOG_LAUNCHER = join(
|
|
47
|
+
homedir(),
|
|
48
|
+
".openclaw",
|
|
49
|
+
"state",
|
|
50
|
+
"multi-clawd",
|
|
51
|
+
"watchdog-launcher.mjs",
|
|
52
|
+
);
|
package/scripts/cli.mjs
CHANGED
|
@@ -127,6 +127,7 @@ async function update() {
|
|
|
127
127
|
} else {
|
|
128
128
|
console.log(" ⏳ remember: the new version loads on the next gateway restart.");
|
|
129
129
|
}
|
|
130
|
+
await healWatchdogUnit();
|
|
130
131
|
console.log(`\n${BOLD} health check${RESET}`);
|
|
131
132
|
const doc = spawnSync(process.execPath, [join(__dirname, "doctor.mjs")], { stdio: "inherit" });
|
|
132
133
|
if (doc.status !== 0) {
|
|
@@ -136,6 +137,73 @@ async function update() {
|
|
|
136
137
|
console.log(`\n ✅ done — now on v${installedVersion() ?? "?"}`);
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Self-heal the scheduled watchdog after an update: the npm install dir is
|
|
142
|
+
* regenerated on every update, so a unit pointing into it just orphaned.
|
|
143
|
+
* Move any broken-or-fragile unit onto the stable launcher; refresh the
|
|
144
|
+
* launcher's content when a unit already uses it. Never fatal.
|
|
145
|
+
*/
|
|
146
|
+
async function healWatchdogUnit() {
|
|
147
|
+
try {
|
|
148
|
+
const wds = await import(resolve(__dirname, "..", "dist", "watchdog-schedule.js"));
|
|
149
|
+
const { WATCHDOG_LAUNCHER } = await import(join(__dirname, "_shared.mjs"));
|
|
150
|
+
const { existsSync, readdirSync, readFileSync: rf, writeFileSync, mkdirSync } = await import("node:fs");
|
|
151
|
+
const { homedir } = await import("node:os");
|
|
152
|
+
const { dirname: dn } = await import("node:path");
|
|
153
|
+
for (const d of [
|
|
154
|
+
join(homedir(), "Library", "LaunchAgents"),
|
|
155
|
+
join(homedir(), ".config", "systemd", "user"),
|
|
156
|
+
]) {
|
|
157
|
+
let files = [];
|
|
158
|
+
try {
|
|
159
|
+
files = readdirSync(d);
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const f of files) {
|
|
164
|
+
if (!/\.(plist|service|timer)$/.test(f)) continue;
|
|
165
|
+
const file = join(d, f);
|
|
166
|
+
let text;
|
|
167
|
+
try {
|
|
168
|
+
text = rf(file, "utf8");
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const target = wds.extractWatchdogTarget(text);
|
|
173
|
+
if (!target) continue;
|
|
174
|
+
const refreshLauncher = () => {
|
|
175
|
+
mkdirSync(dn(WATCHDOG_LAUNCHER), { recursive: true });
|
|
176
|
+
writeFileSync(WATCHDOG_LAUNCHER, wds.renderWatchdogLauncher());
|
|
177
|
+
};
|
|
178
|
+
if (target === WATCHDOG_LAUNCHER) {
|
|
179
|
+
refreshLauncher();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!existsSync(target) || wds.isFragileWatchdogTarget(target)) {
|
|
183
|
+
refreshLauncher();
|
|
184
|
+
writeFileSync(file, text.split(target).join(WATCHDOG_LAUNCHER));
|
|
185
|
+
if (d.endsWith("LaunchAgents")) {
|
|
186
|
+
try {
|
|
187
|
+
execFileSync("/bin/sh", ["-c", `launchctl unload "${file}" 2>/dev/null; launchctl load "${file}" 2>&1 || true`]);
|
|
188
|
+
} catch {
|
|
189
|
+
/* manual load needed */
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
try {
|
|
193
|
+
execFileSync("systemctl", ["--user", "daemon-reload"]);
|
|
194
|
+
} catch {
|
|
195
|
+
/* manual reload needed */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
console.log(` 🔧 watchdog unit ${f} → stable launcher (survives future updates)`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
/* healing is best-effort; doctor still reports the truth */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
139
207
|
switch (cmd) {
|
|
140
208
|
case "setup":
|
|
141
209
|
runSibling("setup.mjs", rest);
|
package/scripts/doctor.mjs
CHANGED
|
@@ -371,8 +371,13 @@ if (watchdogFound) {
|
|
|
371
371
|
} catch {
|
|
372
372
|
continue;
|
|
373
373
|
}
|
|
374
|
-
const target = text.match(/[^<>\s="']*eviction-watchdog\.mjs/)?.[0];
|
|
374
|
+
const target = text.match(/[^<>\s="']*(?:eviction-watchdog|watchdog-launcher)\.mjs/)?.[0];
|
|
375
375
|
if (target && !existsSync(target)) orphan = { file: join(d, f), target };
|
|
376
|
+
else if (target && target.includes("/.openclaw/npm/projects/")) {
|
|
377
|
+
warn(
|
|
378
|
+
`watchdog unit ${join(d, f)} points INTO the npm install dir — regenerated on every update, so it WILL orphan. Run the setup wizard (or \`update\`) to move it to the stable launcher.`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
376
381
|
}
|
|
377
382
|
}
|
|
378
383
|
if (orphan) {
|
package/scripts/setup.mjs
CHANGED
|
@@ -41,7 +41,7 @@ try {
|
|
|
41
41
|
console.error("setup: built dist/ modules are missing — run `npm run build` first (source checkout) or reinstall the plugin.");
|
|
42
42
|
process.exit(1);
|
|
43
43
|
}
|
|
44
|
-
const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig } = core;
|
|
44
|
+
const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig, existingAccountDefaults, looksLikeSecretRef } = core;
|
|
45
45
|
|
|
46
46
|
// Line-queued prompts: interactive AND pipe-safe. With piped stdin, readline
|
|
47
47
|
// emits every buffered line immediately — a plain question() would capture one
|
|
@@ -128,9 +128,29 @@ if (await yes("Add your MAIN account (the machine's existing `claude` login) to
|
|
|
128
128
|
// ── second account ───────────────────────────────────────────────────────────
|
|
129
129
|
if (await yes("Set up a SECOND Claude account (its own isolated config dir)?")) {
|
|
130
130
|
const id = await ask(" id for the second account:", "claw2");
|
|
131
|
+
// Existing-aware: this account may already be fully configured. Pressing
|
|
132
|
+
// Enter through prompts must NEVER overwrite a working account, so the
|
|
133
|
+
// default here is to keep it exactly as it is.
|
|
134
|
+
const prior = existingAccountDefaults(existing, id);
|
|
135
|
+
if (prior?.hasCredentials) {
|
|
136
|
+
console.log(
|
|
137
|
+
` "${id}" is already configured${prior.label ? ` (${prior.label}` : " ("}${prior.configDir ? `, dir ${prior.configDir})` : ")"}.`,
|
|
138
|
+
);
|
|
139
|
+
if (await yes(" Keep its existing credentials and config unchanged?", true)) {
|
|
140
|
+
accounts.push({ id });
|
|
141
|
+
console.log(" ✅ keeping as-is");
|
|
142
|
+
} else {
|
|
143
|
+
await secondAccountFlow(id, prior);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
await secondAccountFlow(id, prior);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function secondAccountFlow(id, prior) {
|
|
131
151
|
let configDir;
|
|
132
152
|
for (;;) {
|
|
133
|
-
configDir = await ask(" isolated config dir:", `~/.${id}`);
|
|
153
|
+
configDir = await ask(" isolated config dir:", prior?.configDir ?? `~/.${id}`);
|
|
134
154
|
const err = validateSecondConfigDir(configDir);
|
|
135
155
|
if (!err) break;
|
|
136
156
|
console.log(` ✗ ${err}`);
|
|
@@ -158,12 +178,19 @@ if (await yes("Set up a SECOND Claude account (its own isolated config dir)?"))
|
|
|
158
178
|
let refId;
|
|
159
179
|
for (;;) {
|
|
160
180
|
refId = await ask(" secret reference (e.g. op://Vault/Item/field):");
|
|
161
|
-
if (refId)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
181
|
+
if (!refId) {
|
|
182
|
+
if (stdinClosed) {
|
|
183
|
+
console.error("setup: a secret reference is required for token source 1 — aborting (nothing written).");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
console.log(" ✗ the reference is required (it is NOT the token itself — just the pointer to it; answer 3 above for no token)");
|
|
187
|
+
continue;
|
|
165
188
|
}
|
|
166
|
-
|
|
189
|
+
if (!looksLikeSecretRef(refId)) {
|
|
190
|
+
console.log(` ⚠ "${refId}" doesn't look like a secret reference (expected something URI-like, e.g. op://Vault/Item/field)`);
|
|
191
|
+
if (!(await yes(" Use it anyway?", false))) continue;
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
167
194
|
}
|
|
168
195
|
tokenSource = { kind: "ref", ref: { source: "exec", provider, id: refId } };
|
|
169
196
|
} else if (choice === "2") {
|
|
@@ -171,7 +198,7 @@ if (await yes("Set up a SECOND Claude account (its own isolated config dir)?"))
|
|
|
171
198
|
} else {
|
|
172
199
|
tokenSource = { kind: "dir-login" };
|
|
173
200
|
}
|
|
174
|
-
accounts.push(buildSecondAccount({ id, label: await ask(" label:", "Second Claude"), configDir, tokenSource }));
|
|
201
|
+
accounts.push(buildSecondAccount({ id, label: await ask(" label:", prior?.label ?? "Second Claude"), configDir, tokenSource }));
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
if (accounts.length === 0 && state.accountIds.length === 0) {
|
|
@@ -229,10 +256,16 @@ async function watchdogStep() {
|
|
|
229
256
|
console.log(" (auto-scheduling not supported on this platform — see README for manual setup)");
|
|
230
257
|
return;
|
|
231
258
|
}
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
259
|
+
// Units point at the STABLE LAUNCHER, never at an install path: the npm
|
|
260
|
+
// install dir is regenerated on every update (orphaning any unit that
|
|
261
|
+
// points into it), and under npx our own __dirname is an ephemeral cache.
|
|
262
|
+
// The launcher lives in the state dir and finds the install at runtime.
|
|
263
|
+
const { WATCHDOG_LAUNCHER } = await import(resolve(__dirname, "_shared.mjs"));
|
|
264
|
+
const scriptPath = WATCHDOG_LAUNCHER;
|
|
265
|
+
const refreshLauncher = () => {
|
|
266
|
+
mkdirSync(dirname(WATCHDOG_LAUNCHER), { recursive: true });
|
|
267
|
+
writeFileSync(WATCHDOG_LAUNCHER, wds.renderWatchdogLauncher());
|
|
268
|
+
};
|
|
236
269
|
const scanDir =
|
|
237
270
|
platform === "darwin"
|
|
238
271
|
? join(homedir(), "Library", "LaunchAgents")
|
|
@@ -260,19 +293,37 @@ async function watchdogStep() {
|
|
|
260
293
|
/* scan dir missing — treated as absent */
|
|
261
294
|
}
|
|
262
295
|
const state = wds.classifyWatchdogUnit(found?.target, existsSync);
|
|
263
|
-
if (state === "ok") {
|
|
296
|
+
if (state === "ok" && !wds.isFragileWatchdogTarget(found.target)) {
|
|
297
|
+
if (found.target === WATCHDOG_LAUNCHER && !DRY_RUN) refreshLauncher();
|
|
264
298
|
console.log(` ✅ already scheduled and healthy → ${found.target}`);
|
|
265
299
|
return;
|
|
266
300
|
}
|
|
301
|
+
if (state === "ok") {
|
|
302
|
+
// Exists today, but points INTO the npm install — orphans on next update.
|
|
303
|
+
console.log(
|
|
304
|
+
` ⚠ ${found.file}\n points INTO the npm install dir:\n ${found.target}\n That directory is regenerated on every update — the unit will orphan.`,
|
|
305
|
+
);
|
|
306
|
+
if (DRY_RUN) {
|
|
307
|
+
console.log(` dry-run: would move it to the stable launcher ${scriptPath}`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!(await yes(" Move it to the stable launcher (survives every update)?"))) return;
|
|
311
|
+
refreshLauncher();
|
|
312
|
+
writeFileSync(found.file, found.text.split(found.target).join(scriptPath));
|
|
313
|
+
reloadWatchdogUnit(platform, found.file);
|
|
314
|
+
console.log(` ✅ now → ${scriptPath}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
267
317
|
if (state === "orphaned") {
|
|
268
318
|
console.log(
|
|
269
319
|
` ⚠ ${found.file}\n points at a MISSING script: ${found.target}\n (an old install dir — the watchdog has been failing silently)`,
|
|
270
320
|
);
|
|
271
321
|
if (DRY_RUN) {
|
|
272
|
-
console.log(` dry-run: would repoint it at ${scriptPath}`);
|
|
322
|
+
console.log(` dry-run: would repoint it at the stable launcher ${scriptPath}`);
|
|
273
323
|
return;
|
|
274
324
|
}
|
|
275
|
-
if (!(await yes(" Repoint it at
|
|
325
|
+
if (!(await yes(" Repoint it at the stable launcher (survives every update)?"))) return;
|
|
326
|
+
refreshLauncher();
|
|
276
327
|
writeFileSync(found.file, found.text.split(found.target).join(scriptPath));
|
|
277
328
|
reloadWatchdogUnit(platform, found.file);
|
|
278
329
|
console.log(` ✅ repointed → ${scriptPath}`);
|
|
@@ -283,6 +334,7 @@ async function watchdogStep() {
|
|
|
283
334
|
return;
|
|
284
335
|
}
|
|
285
336
|
if (!(await yes(" Not scheduled. Schedule it now (runs every 5 min)?"))) return;
|
|
337
|
+
refreshLauncher();
|
|
286
338
|
const unitFiles = wds.renderWatchdogUnit({ platform, nodePath: process.execPath, scriptPath });
|
|
287
339
|
for (const uf of unitFiles) {
|
|
288
340
|
const abs = join(homedir(), uf.path);
|