openclaw-channel-dmwork 0.5.21 → 0.6.0-dev.0079778d

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.
@@ -4,7 +4,7 @@
4
4
  * argument arrays to avoid shell-quoting issues.
5
5
  */
6
6
  import { execFileSync, execSync } from "node:child_process";
7
- import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
7
+ import { readFileSync, writeFileSync, copyFileSync, existsSync, rmSync, readdirSync, statSync, renameSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { resolve } from "node:path";
10
10
  /**
@@ -15,6 +15,7 @@ import { resolve } from "node:path";
15
15
  * causes version incompatibility crashes on older OpenClaw servers.
16
16
  */
17
17
  function findGlobalOpenclaw() {
18
+ const isWindows = process.platform === "win32";
18
19
  // Strategy 1: use "which -a" (Unix) or "where" (Windows) to find all openclaw paths
19
20
  // Skip: _npx (npx cache), npx-cache, node_modules (project-local devDependency)
20
21
  for (const cmd of ["which -a openclaw", "where openclaw"]) {
@@ -30,14 +31,42 @@ function findGlobalOpenclaw() {
30
31
  !p.includes("_npx") &&
31
32
  !p.includes("npx-cache") &&
32
33
  !p.includes("node_modules"));
33
- if (paths.length > 0)
34
+ if (paths.length > 0) {
35
+ // On Windows, `where openclaw` may return both the extensionless shim
36
+ // and `openclaw.cmd`. Prefer `.cmd`, otherwise execFileSync may hit
37
+ // ENOENT for the extensionless path.
38
+ if (isWindows) {
39
+ const cmdShim = paths.find((p) => /\.cmd$/i.test(p));
40
+ if (cmdShim)
41
+ return cmdShim;
42
+ }
34
43
  return paths[0];
44
+ }
35
45
  }
36
46
  catch {
37
47
  // command not available on this platform
38
48
  }
39
49
  }
40
- // Strategy 2: check common global install paths
50
+ // Strategy 2 (Windows): npm global prefix + openclaw.cmd
51
+ // npm i -g openclaw 在 Windows 上生成 .ps1 和 .cmd,但 npx 子进程的 PATH
52
+ // 可能不包含 npm 全局目录,导致 where 找不到。直接通过 npm prefix 定位。
53
+ if (isWindows) {
54
+ try {
55
+ const prefix = execSync("npm config get prefix", {
56
+ encoding: "utf-8",
57
+ stdio: ["pipe", "pipe", "pipe"],
58
+ }).trim();
59
+ if (prefix) {
60
+ const cmdPath = resolve(prefix, "openclaw.cmd");
61
+ if (existsSync(cmdPath))
62
+ return cmdPath;
63
+ }
64
+ }
65
+ catch {
66
+ // npm not available
67
+ }
68
+ }
69
+ // Strategy 3: check common global install paths
41
70
  const candidates = [
42
71
  "/opt/homebrew/bin/openclaw",
43
72
  "/usr/local/bin/openclaw",
@@ -52,6 +81,24 @@ function findGlobalOpenclaw() {
52
81
  return "openclaw";
53
82
  }
54
83
  const OPENCLAW = findGlobalOpenclaw();
84
+ const NEEDS_SHELL = process.platform === "win32" && /\.cmd$/i.test(OPENCLAW);
85
+ /**
86
+ * Execute openclaw CLI command. On Windows, .cmd shims require shell: true.
87
+ * All openclaw invocations in this module MUST go through this function.
88
+ *
89
+ * 使用 execFileSync + shell:true 而非手动拼字符串,
90
+ * 让 Node.js 自动处理 cmd.exe 参数转义(包括 JSON 内的引号)。
91
+ */
92
+ function runOpenclaw(args, opts = {}) {
93
+ const shellOpt = NEEDS_SHELL ? { shell: true } : {};
94
+ return execFileSync(OPENCLAW, args, { encoding: "utf-8", ...shellOpt, ...opts });
95
+ }
96
+ /** Get the resolved openclaw binary path */
97
+ export function getOpenClawBin() {
98
+ return OPENCLAW;
99
+ }
100
+ /** Execute openclaw command (exported for quickstart.ts etc.) */
101
+ export { runOpenclaw };
55
102
  /** Expand ~ to home directory */
56
103
  function expandHome(p) {
57
104
  if (p.startsWith("~/"))
@@ -62,14 +109,44 @@ function expandHome(p) {
62
109
  // Config helpers
63
110
  // ---------------------------------------------------------------------------
64
111
  export function getConfigFilePath() {
65
- return execFileSync(OPENCLAW, ["config", "file"], { encoding: "utf-8" }).trim();
112
+ const out = runOpenclaw(["config", "file"]);
113
+ // openclaw may prepend warnings/box-drawing to stdout; extract the actual path
114
+ // The path is typically the last non-empty line containing openclaw.json
115
+ const lines = out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
116
+ const pathLine = lines.find((l) => l.endsWith("openclaw.json")) ?? lines[lines.length - 1];
117
+ return pathLine ?? out.trim();
118
+ }
119
+ /**
120
+ * Strip OpenClaw stdout noise (banner, plugin log lines, timestamps).
121
+ * Old OpenClaw versions mix these into stdout alongside the actual value.
122
+ */
123
+ function stripStdoutNoise(raw) {
124
+ return raw
125
+ .split("\n")
126
+ .filter((line) => {
127
+ const t = line.trim();
128
+ if (!t)
129
+ return false;
130
+ // Banner: 🦞 OpenClaw ...
131
+ if (/^[\u{1F980}\u{1F600}-\u{1FAFF}]/u.test(t))
132
+ return false;
133
+ // Plugin log: [plugins] ..., [dmwork] ...
134
+ if (/^\[[\w-]+\]/.test(t))
135
+ return false;
136
+ // Timestamped log: 17:37:26 [plugins] ...
137
+ if (/^\d{1,2}:\d{2}(:\d{2})?\s*\[/.test(t))
138
+ return false;
139
+ return true;
140
+ })
141
+ .join("\n")
142
+ .trim();
66
143
  }
67
144
  export function configGet(path) {
68
145
  try {
69
- const val = execFileSync(OPENCLAW, ["config", "get", path], {
70
- encoding: "utf-8",
146
+ const raw = runOpenclaw(["config", "get", path], {
71
147
  stdio: ["pipe", "pipe", "pipe"],
72
- }).trim();
148
+ });
149
+ const val = stripStdoutNoise(raw);
73
150
  return val === "" ? null : val;
74
151
  }
75
152
  catch {
@@ -78,8 +155,7 @@ export function configGet(path) {
78
155
  }
79
156
  export function configGetJson(path) {
80
157
  try {
81
- const out = execFileSync(OPENCLAW, ["config", "get", path, "--json"], {
82
- encoding: "utf-8",
158
+ const out = runOpenclaw(["config", "get", path, "--json"], {
83
159
  stdio: ["pipe", "pipe", "pipe"],
84
160
  });
85
161
  const jsonStart = out.indexOf("{");
@@ -89,88 +165,264 @@ export function configGetJson(path) {
89
165
  : Math.max(jsonStart, arrStart);
90
166
  if (start < 0)
91
167
  return null;
92
- return JSON.parse(out.slice(start));
168
+ // Find matching end bracket to avoid trailing log noise breaking JSON.parse
169
+ const openChar = out[start];
170
+ const closeChar = openChar === "{" ? "}" : "]";
171
+ let depth = 0;
172
+ let end = -1;
173
+ for (let i = start; i < out.length; i++) {
174
+ if (out[i] === openChar)
175
+ depth++;
176
+ else if (out[i] === closeChar) {
177
+ depth--;
178
+ if (depth === 0) {
179
+ end = i;
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ if (end < 0)
185
+ return null;
186
+ return JSON.parse(out.slice(start, end + 1));
93
187
  }
94
188
  catch {
95
189
  return null;
96
190
  }
97
191
  }
98
192
  export function configSet(path, value) {
99
- execFileSync(OPENCLAW, ["config", "set", path, value], {
193
+ runOpenclaw(["config", "set", path, value], {
100
194
  stdio: ["pipe", "pipe", "pipe"],
101
195
  });
102
196
  }
103
197
  export function configSetBatch(operations) {
104
198
  const batchJson = JSON.stringify(operations.map((op) => ({ path: op.path, value: op.value })));
105
- execFileSync(OPENCLAW, ["config", "set", "--batch-json", batchJson], {
199
+ runOpenclaw(["config", "set", "--batch-json", batchJson], {
106
200
  stdio: ["pipe", "pipe", "pipe"],
107
201
  });
108
202
  }
109
203
  export function configSetJson(path, value) {
110
- execFileSync(OPENCLAW, ["config", "set", path, JSON.stringify(value), "--strict-json"], {
204
+ runOpenclaw(["config", "set", path, JSON.stringify(value), "--strict-json"], {
111
205
  stdio: ["pipe", "pipe", "pipe"],
112
206
  });
113
207
  }
114
208
  export function configUnset(path) {
115
- execFileSync(OPENCLAW, ["config", "unset", path], {
209
+ runOpenclaw(["config", "unset", path], {
116
210
  stdio: ["pipe", "pipe", "pipe"],
117
211
  });
118
212
  }
119
213
  // ---------------------------------------------------------------------------
120
214
  // Plugin helpers
121
215
  // ---------------------------------------------------------------------------
216
+ /**
217
+ * Check if an error indicates an unsupported CLI option.
218
+ * Checks stderr/stdout/message across different Node versions and shells.
219
+ */
220
+ function isUnsupportedOptionError(err) {
221
+ const sources = [
222
+ err?.stderr?.toString?.(),
223
+ err?.stdout?.toString?.(),
224
+ err?.message,
225
+ String(err),
226
+ ];
227
+ return sources.some((s) => s && (/unknown option|unrecognized option/i.test(s)));
228
+ }
229
+ function isPluginNotInstalledError(err) {
230
+ const sources = [
231
+ err?.stderr?.toString?.(),
232
+ err?.stdout?.toString?.(),
233
+ err?.message,
234
+ String(err),
235
+ ];
236
+ return sources.some((s) => s && (/not installed|no such plugin|plugin not found/i.test(s)));
237
+ }
122
238
  export function pluginsInstall(spec, quiet, force) {
123
- const args = ["plugins", "install", spec, "--dangerously-force-unsafe-install"];
124
- if (force)
125
- args.push("--force");
126
- execFileSync(OPENCLAW, args, {
127
- stdio: quiet ? ["pipe", "pipe", "pipe"] : "inherit",
128
- });
239
+ const baseArgs = ["plugins", "install", spec];
240
+ // 3-layer degradation for old openclaw versions:
241
+ // 1. --force --dangerously-force-unsafe-install (newest openclaw)
242
+ // 2. --force (mid-age openclaw)
243
+ // 3. bare install (oldest openclaw)
244
+ const attempts = force
245
+ ? [
246
+ [...baseArgs, "--force", "--dangerously-force-unsafe-install"],
247
+ [...baseArgs, "--force"],
248
+ baseArgs,
249
+ ]
250
+ : [
251
+ [...baseArgs, "--dangerously-force-unsafe-install"],
252
+ baseArgs,
253
+ ];
254
+ // Always pipe to capture stderr for degradation detection.
255
+ // stdio: "inherit" causes Node to omit stderr from the error object,
256
+ // making isUnsupportedOptionError() unable to detect "unknown option".
257
+ for (let i = 0; i < attempts.length; i++) {
258
+ try {
259
+ const result = runOpenclaw(attempts[i], {
260
+ stdio: ["pipe", "pipe", "pipe"],
261
+ encoding: "utf-8",
262
+ });
263
+ if (!quiet && result)
264
+ process.stdout.write(result);
265
+ return;
266
+ }
267
+ catch (err) {
268
+ if (isUnsupportedOptionError(err) && i < attempts.length - 1) {
269
+ continue; // try next degradation level
270
+ }
271
+ // Final attempt failed: replay captured output, then throw
272
+ if (!quiet) {
273
+ const stdout = err?.stdout?.toString?.();
274
+ const stderr = err?.stderr?.toString?.();
275
+ if (stdout)
276
+ process.stdout.write(stdout);
277
+ if (stderr)
278
+ process.stderr.write(stderr);
279
+ }
280
+ throw err;
281
+ }
282
+ }
129
283
  }
130
284
  export function pluginsUpdate(id, quiet) {
131
- execFileSync(OPENCLAW, ["plugins", "update", id], {
132
- stdio: quiet ? ["pipe", "pipe", "pipe"] : "inherit",
285
+ const result = runOpenclaw(["plugins", "update", id], {
286
+ stdio: ["pipe", "pipe", "pipe"],
287
+ encoding: "utf-8",
133
288
  });
289
+ if (!quiet && result)
290
+ process.stdout.write(result);
134
291
  }
135
292
  export function pluginsUninstall(id, yes) {
136
293
  const args = ["plugins", "uninstall", id];
137
294
  if (yes)
138
295
  args.push("--force");
139
- execFileSync(OPENCLAW, args, { stdio: "inherit" });
296
+ runOpenclaw(args, { stdio: "inherit" });
140
297
  }
141
- export function pluginsInspect(id) {
298
+ /**
299
+ * Inspect a plugin. Returns structured outcome distinguishing:
300
+ * - ok + data: inspect succeeded
301
+ * - unsupported: old OpenClaw without `plugins inspect`
302
+ * - not_found: plugin genuinely not found
303
+ * - error: other failure (config corruption, plugin load crash, etc.)
304
+ */
305
+ export function pluginsInspectDetailed(id) {
142
306
  try {
143
- const out = execFileSync(OPENCLAW, ["plugins", "inspect", id, "--json"], {
307
+ const out = runOpenclaw(["plugins", "inspect", id, "--json"], {
144
308
  encoding: "utf-8",
145
309
  stdio: ["pipe", "pipe", "pipe"],
146
310
  });
147
- // stdout may contain plugin log noise before JSON — find the JSON object
148
311
  const jsonStart = out.indexOf("{");
149
312
  if (jsonStart < 0)
150
- return null;
151
- return JSON.parse(out.slice(jsonStart));
313
+ return { ok: false, data: null, failReason: "error" };
314
+ const data = JSON.parse(out.slice(jsonStart));
315
+ return { ok: true, data, failReason: null };
152
316
  }
153
- catch {
154
- return null;
317
+ catch (err) {
318
+ const sources = [
319
+ err?.stderr?.toString?.(),
320
+ err?.stdout?.toString?.(),
321
+ err?.message,
322
+ String(err),
323
+ ];
324
+ const text = sources.filter(Boolean).join(" ");
325
+ if (/unknown command|unrecognized command/i.test(text)) {
326
+ return { ok: false, data: null, failReason: "unsupported" };
327
+ }
328
+ if (/not found|not installed|no such plugin/i.test(text)) {
329
+ return { ok: false, data: null, failReason: "not_found" };
330
+ }
331
+ return { ok: false, data: null, failReason: "error" };
332
+ }
333
+ }
334
+ /** Backward-compatible wrapper: returns data or null. */
335
+ export function pluginsInspect(id) {
336
+ const outcome = pluginsInspectDetailed(id);
337
+ return outcome.ok ? outcome.data : null;
338
+ }
339
+ /**
340
+ * Resolve plugin install state. Uses `plugins inspect` when available,
341
+ * falls back to config entries + directory + package.json for old OpenClaw
342
+ * versions that don't support `plugins inspect`.
343
+ *
344
+ * Fallback installed = all 3 artifacts present (entries + installs + dir),
345
+ * matching detectScenario()'s healthy definition. Partial presence is NOT
346
+ * considered installed — that's a broken state for doctor --fix to handle.
347
+ */
348
+ export function resolvePluginState(id) {
349
+ // Try inspect first
350
+ const outcome = pluginsInspectDetailed(id);
351
+ if (outcome.ok && outcome.data?.plugin) {
352
+ return {
353
+ installed: true,
354
+ enabled: outcome.data.plugin.enabled,
355
+ version: outcome.data.plugin.version,
356
+ installPath: outcome.data.install?.installPath ?? null,
357
+ source: "inspect",
358
+ inspectFailReason: null,
359
+ };
360
+ }
361
+ // Fallback: check config + filesystem
362
+ const cfg = readConfigFromFile();
363
+ const extDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
364
+ const pluginDir = resolve(extDir, id);
365
+ const hasDir = existsSync(pluginDir);
366
+ const entries = cfg?.plugins?.entries?.[id];
367
+ const installs = cfg?.plugins?.installs?.[id];
368
+ const hasEntry = Boolean(entries);
369
+ const hasInstall = Boolean(installs);
370
+ // Healthy install requires all 3 artifacts, same as detectScenario().
371
+ // Partial presence (e.g. dir exists but no entries/installs) is broken, not installed.
372
+ const installed = hasDir && hasEntry && hasInstall;
373
+ if (!installed) {
374
+ return {
375
+ installed: false, enabled: null, version: null, installPath: null,
376
+ source: "fallback", inspectFailReason: outcome.failReason,
377
+ };
378
+ }
379
+ // Resolve version: installs record > package.json on disk
380
+ let version = installs?.version ?? null;
381
+ if (!version && hasDir) {
382
+ try {
383
+ const pkg = JSON.parse(readFileSync(resolve(pluginDir, "package.json"), "utf-8"));
384
+ version = pkg.version ?? null;
385
+ }
386
+ catch { /* no package.json */ }
155
387
  }
388
+ const enabled = entries?.enabled ?? null;
389
+ const installPath = installs?.installPath ?? (hasDir ? `~/.openclaw/extensions/${id}` : null);
390
+ return { installed, enabled, version, installPath, source: "fallback", inspectFailReason: outcome.failReason };
156
391
  }
157
392
  // ---------------------------------------------------------------------------
158
393
  // Gateway helpers
159
394
  // ---------------------------------------------------------------------------
160
395
  export function gatewayStatus() {
161
396
  try {
162
- const out = execFileSync(OPENCLAW, ["gateway", "status", "--json"], {
397
+ const out = runOpenclaw(["gateway", "status", "--json"], {
163
398
  encoding: "utf-8",
164
399
  stdio: ["pipe", "pipe", "pipe"],
165
400
  });
166
401
  const jsonStart = out.indexOf("{");
167
402
  if (jsonStart < 0)
168
403
  return { running: false };
169
- const data = JSON.parse(out.slice(jsonStart));
170
- // Real structure: { service.runtime.status: "running", health.healthy: true }
404
+ // Find matching } to avoid trailing log noise
405
+ let depth = 0;
406
+ let end = -1;
407
+ for (let i = jsonStart; i < out.length; i++) {
408
+ if (out[i] === "{")
409
+ depth++;
410
+ else if (out[i] === "}") {
411
+ depth--;
412
+ if (depth === 0) {
413
+ end = i;
414
+ break;
415
+ }
416
+ }
417
+ }
418
+ if (end < 0)
419
+ return { running: false };
420
+ const data = JSON.parse(out.slice(jsonStart, end + 1));
171
421
  const runtimeRunning = data.service?.runtime?.status === "running";
172
422
  const healthy = data.health?.healthy === true;
173
- return { running: runtimeRunning || healthy };
423
+ // Fallback: port is busy with an openclaw-gateway process = gateway is running
424
+ const portBusy = data.port?.status === "busy";
425
+ return { running: runtimeRunning || healthy || portBusy };
174
426
  }
175
427
  catch {
176
428
  return { running: false };
@@ -178,7 +430,7 @@ export function gatewayStatus() {
178
430
  }
179
431
  export function gatewayRestart(quiet) {
180
432
  try {
181
- execFileSync(OPENCLAW, ["gateway", "restart"], {
433
+ runOpenclaw(["gateway", "restart"], {
182
434
  stdio: quiet ? ["pipe", "pipe", "pipe"] : "inherit",
183
435
  });
184
436
  return true;
@@ -192,7 +444,7 @@ export function gatewayRestart(quiet) {
192
444
  // ---------------------------------------------------------------------------
193
445
  export function getOpenClawVersion() {
194
446
  try {
195
- const out = execFileSync(OPENCLAW, ["--version"], {
447
+ const out = runOpenclaw(["--version"], {
196
448
  encoding: "utf-8",
197
449
  stdio: ["pipe", "pipe", "pipe"],
198
450
  });
@@ -218,7 +470,7 @@ export function getOpenClawVersion() {
218
470
  */
219
471
  export function saveChannelConfigFromFile() {
220
472
  try {
221
- const configPath = expandHome(getConfigFilePath());
473
+ const configPath = getConfigFilePathSafe();
222
474
  const raw = readFileSync(configPath, "utf-8");
223
475
  const cfg = JSON.parse(raw);
224
476
  return cfg?.channels?.dmwork ?? null;
@@ -233,7 +485,7 @@ export function saveChannelConfigFromFile() {
233
485
  * Creates a .bak backup before writing.
234
486
  */
235
487
  export function restoreChannelConfigToFile(dmworkConfig) {
236
- const configPath = expandHome(getConfigFilePath());
488
+ const configPath = getConfigFilePathSafe();
237
489
  // Backup
238
490
  copyFileSync(configPath, configPath + ".bak");
239
491
  // Read, merge, write
@@ -242,7 +494,7 @@ export function restoreChannelConfigToFile(dmworkConfig) {
242
494
  if (!cfg.channels)
243
495
  cfg.channels = {};
244
496
  cfg.channels.dmwork = dmworkConfig;
245
- writeFileSync(configPath, JSON.stringify(cfg, null, 2), "utf-8");
497
+ writeConfigAtomic(cfg);
246
498
  }
247
499
  /**
248
500
  * Remove channels.dmwork directly from the JSON file.
@@ -254,7 +506,7 @@ export function restoreChannelConfigToFile(dmworkConfig) {
254
506
  * Falls back to the standard default when CLI is unavailable
255
507
  * (e.g. during uninstall when config validation fails).
256
508
  */
257
- function getConfigFilePathSafe() {
509
+ export function getConfigFilePathSafe() {
258
510
  try {
259
511
  return expandHome(getConfigFilePath());
260
512
  }
@@ -266,11 +518,10 @@ export function removeChannelConfigFromFile() {
266
518
  try {
267
519
  const configPath = getConfigFilePathSafe();
268
520
  copyFileSync(configPath, configPath + ".bak");
269
- const raw = readFileSync(configPath, "utf-8");
270
- const cfg = JSON.parse(raw);
271
- if (cfg.channels?.dmwork) {
521
+ const cfg = readConfigFromFile();
522
+ if (cfg?.channels?.dmwork) {
272
523
  delete cfg.channels.dmwork;
273
- writeFileSync(configPath, JSON.stringify(cfg, null, 2), "utf-8");
524
+ writeConfigAtomic(cfg);
274
525
  }
275
526
  }
276
527
  catch {
@@ -311,7 +562,7 @@ export function removeOrphanedBindingsFromFile(channel, validAccountIds) {
311
562
  // Keep only if accountId is in valid list (or no accountId specified)
312
563
  return !b.match.accountId || validAccountIds.includes(b.match.accountId);
313
564
  });
314
- writeFileSync(configPath, JSON.stringify(cfg, null, 2), "utf-8");
565
+ writeConfigAtomic(cfg);
315
566
  }
316
567
  catch {
317
568
  // best effort
@@ -338,7 +589,7 @@ export function cleanupLegacyPlugin() {
338
589
  if (existsSync(legacyDir)) {
339
590
  // Try to uninstall via openclaw CLI first (removes entries/installs/allow)
340
591
  try {
341
- execFileSync(OPENCLAW, ["plugins", "uninstall", LEGACY_PLUGIN_ID, "--force", "--keep-files"], {
592
+ runOpenclaw(["plugins", "uninstall", LEGACY_PLUGIN_ID, "--force", "--keep-files"], {
342
593
  stdio: ["pipe", "pipe", "pipe"],
343
594
  });
344
595
  actions.push(`Unregistered legacy plugin "${LEGACY_PLUGIN_ID}"`);
@@ -348,7 +599,6 @@ export function cleanupLegacyPlugin() {
348
599
  }
349
600
  // Remove legacy directory
350
601
  try {
351
- const { rmSync } = require("node:fs");
352
602
  rmSync(legacyDir, { recursive: true, force: true });
353
603
  actions.push(`Removed legacy directory: ${legacyDir}`);
354
604
  }
@@ -370,7 +620,7 @@ export function cleanupLegacyPlugin() {
370
620
  if (Array.isArray(cfg.plugins?.allow)) {
371
621
  cfg.plugins.allow = cfg.plugins.allow.filter((id) => id !== LEGACY_PLUGIN_ID);
372
622
  }
373
- writeFileSync(configPath, JSON.stringify(cfg, null, 2), "utf-8");
623
+ writeConfigAtomic(cfg);
374
624
  actions.push(`Cleaned legacy entries from openclaw.json`);
375
625
  }
376
626
  }
@@ -379,4 +629,318 @@ export function cleanupLegacyPlugin() {
379
629
  }
380
630
  return actions;
381
631
  }
632
+ /**
633
+ * Clean up stale openclaw-channel-dmwork directory that is not registered
634
+ * in plugins.installs (orphaned from a failed previous install).
635
+ *
636
+ * Only removes the directory if ALL of these are true:
637
+ * 1. The directory exists
638
+ * 2. pluginsInspect returns null (openclaw doesn't recognize it)
639
+ * 3. plugins.installs has no record for openclaw-channel-dmwork
640
+ */
641
+ export function cleanupStalePluginDir() {
642
+ const actions = [];
643
+ const extensionsDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
644
+ const pluginDir = resolve(extensionsDir, "openclaw-channel-dmwork");
645
+ if (!existsSync(pluginDir))
646
+ return actions;
647
+ // Check if openclaw recognizes it
648
+ const inspect = pluginsInspect("openclaw-channel-dmwork");
649
+ if (inspect?.plugin)
650
+ return actions; // recognized, don't touch
651
+ // Check if it's in installs registry
652
+ try {
653
+ const cfg = readConfigFromFile();
654
+ if (cfg?.plugins?.installs?.["openclaw-channel-dmwork"]) {
655
+ return actions; // has install record, might just be inspect anomaly
656
+ }
657
+ }
658
+ catch { /* proceed with cleanup */ }
659
+ // All three conditions met: exists + not recognized + not in registry → stale
660
+ try {
661
+ rmSync(pluginDir, { recursive: true, force: true });
662
+ actions.push(`Removed stale plugin directory: ${pluginDir}`);
663
+ }
664
+ catch {
665
+ actions.push(`Warning: could not remove stale directory: ${pluginDir}`);
666
+ }
667
+ return actions;
668
+ }
669
+ /**
670
+ * Clean up stale openclaw-install-stage directories that belong to DMWork.
671
+ * Only removes directories that:
672
+ * 1. Match .openclaw-install-stage-* pattern
673
+ * 2. Are older than 10 minutes (not a current installation)
674
+ * 3. Contain a package.json with name "openclaw-channel-dmwork"
675
+ */
676
+ export function cleanupStaleStageDirectories() {
677
+ const actions = [];
678
+ const extensionsDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
679
+ try {
680
+ const entries = readdirSync(extensionsDir);
681
+ const now = Date.now();
682
+ const TEN_MINUTES = 10 * 60 * 1000;
683
+ for (const entry of entries) {
684
+ if (!entry.startsWith(".openclaw-install-stage-"))
685
+ continue;
686
+ const stagePath = resolve(extensionsDir, entry);
687
+ try {
688
+ const stat = statSync(stagePath);
689
+ if (!stat.isDirectory())
690
+ continue;
691
+ if (now - stat.mtimeMs < TEN_MINUTES)
692
+ continue; // too recent, skip
693
+ // Check if it's DMWork's stage directory
694
+ const pkgPath = resolve(stagePath, "package", "package.json");
695
+ const altPkgPath = resolve(stagePath, "package.json");
696
+ let isDmwork = false;
697
+ for (const p of [pkgPath, altPkgPath]) {
698
+ try {
699
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
700
+ if (pkg.name === "openclaw-channel-dmwork") {
701
+ isDmwork = true;
702
+ break;
703
+ }
704
+ }
705
+ catch { /* try next */ }
706
+ }
707
+ if (!isDmwork)
708
+ continue; // not ours, don't touch
709
+ rmSync(stagePath, { recursive: true, force: true });
710
+ actions.push(`Removed stale stage directory: ${entry}`);
711
+ }
712
+ catch { /* skip this entry */ }
713
+ }
714
+ }
715
+ catch { /* best effort */ }
716
+ return actions;
717
+ }
718
+ // ---------------------------------------------------------------------------
719
+ // Atomic config write
720
+ // ---------------------------------------------------------------------------
721
+ /**
722
+ * Write openclaw.json atomically: write to .tmp then rename.
723
+ * Prevents gateway watcher from reading half-written/truncated JSON.
724
+ */
725
+ export function writeConfigAtomic(cfg) {
726
+ const configPath = getConfigFilePathSafe();
727
+ const tmpPath = configPath + ".tmp";
728
+ writeFileSync(tmpPath, JSON.stringify(cfg, null, 2), "utf-8");
729
+ renameSync(tmpPath, configPath);
730
+ }
731
+ export function detectScenario() {
732
+ const cfg = readConfigFromFile();
733
+ const extDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
734
+ const hasLegacyDir = existsSync(resolve(extDir, "dmwork"));
735
+ const hasLegacyEntries = Boolean(cfg?.plugins?.entries?.["dmwork"]);
736
+ const hasLegacyInstalls = Boolean(cfg?.plugins?.installs?.["dmwork"]);
737
+ const hasLegacy = hasLegacyDir || hasLegacyEntries || hasLegacyInstalls;
738
+ const hasNewDir = existsSync(resolve(extDir, "openclaw-channel-dmwork"));
739
+ const hasNewEntries = Boolean(cfg?.plugins?.entries?.["openclaw-channel-dmwork"]);
740
+ const hasNewInstalls = Boolean(cfg?.plugins?.installs?.["openclaw-channel-dmwork"]);
741
+ const inspectOk = Boolean(pluginsInspect("openclaw-channel-dmwork")?.plugin);
742
+ const isHealthy = inspectOk || (hasNewDir && hasNewEntries && hasNewInstalls);
743
+ const hasNewPartial = (hasNewDir || hasNewEntries || hasNewInstalls) && !isHealthy;
744
+ const hasDmworkChannel = Boolean(cfg?.channels?.dmwork);
745
+ if (hasLegacy)
746
+ return "legacy";
747
+ if (isHealthy)
748
+ return "update";
749
+ if (hasNewPartial)
750
+ return "broken";
751
+ if (hasDmworkChannel)
752
+ return "deadlock";
753
+ return "fresh";
754
+ }
755
+ export function isHealthyInstall() {
756
+ const cfg = readConfigFromFile();
757
+ const extDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
758
+ const hasNewDir = existsSync(resolve(extDir, "openclaw-channel-dmwork"));
759
+ const hasNewEntries = Boolean(cfg?.plugins?.entries?.["openclaw-channel-dmwork"]);
760
+ const hasNewInstalls = Boolean(cfg?.plugins?.installs?.["openclaw-channel-dmwork"]);
761
+ const inspectOk = Boolean(pluginsInspect("openclaw-channel-dmwork")?.plugin);
762
+ return inspectOk || (hasNewDir && hasNewEntries && hasNewInstalls);
763
+ }
764
+ export function ensurePluginsAllow() {
765
+ try {
766
+ const cfg = readConfigFromFile();
767
+ if (!cfg?.plugins?.allow || !Array.isArray(cfg.plugins.allow))
768
+ return;
769
+ if (cfg.plugins.allow.includes("openclaw-channel-dmwork"))
770
+ return;
771
+ cfg.plugins.allow.push("openclaw-channel-dmwork");
772
+ writeConfigAtomic(cfg);
773
+ }
774
+ catch { /* best effort */ }
775
+ }
776
+ // ---------------------------------------------------------------------------
777
+ // pluginsUpdateCompat
778
+ // ---------------------------------------------------------------------------
779
+ export function pluginsUpdateCompat(id, tag, quiet) {
780
+ try {
781
+ const result = runOpenclaw(["plugins", "update", id], {
782
+ stdio: ["pipe", "pipe", "pipe"],
783
+ encoding: "utf-8",
784
+ });
785
+ if (!quiet && result)
786
+ process.stdout.write(result);
787
+ }
788
+ catch (err) {
789
+ // Only fallback to install when update is unsupported or plugin not installed.
790
+ // Other errors (network, permissions, etc.) should propagate.
791
+ if (isUnsupportedOptionError(err) || isPluginNotInstalledError(err)) {
792
+ pluginsInstall(`${id}@${tag}`, quiet, true);
793
+ return;
794
+ }
795
+ if (!quiet) {
796
+ const stdout = err?.stdout?.toString?.();
797
+ const stderr = err?.stderr?.toString?.();
798
+ if (stdout)
799
+ process.stdout.write(stdout);
800
+ if (stderr)
801
+ process.stderr.write(stderr);
802
+ }
803
+ throw err;
804
+ }
805
+ }
806
+ // ---------------------------------------------------------------------------
807
+ // Legacy migration helpers
808
+ // ---------------------------------------------------------------------------
809
+ export function renameLegacyDir() {
810
+ const extDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
811
+ const legacyDir = resolve(extDir, "dmwork");
812
+ const backupDir = resolve(extDir, ".dmwork-backup");
813
+ if (!existsSync(legacyDir))
814
+ return false;
815
+ try {
816
+ if (existsSync(backupDir))
817
+ rmSync(backupDir, { recursive: true, force: true });
818
+ renameSync(legacyDir, backupDir);
819
+ return true;
820
+ }
821
+ catch {
822
+ return false;
823
+ }
824
+ }
825
+ export function restoreLegacyDir() {
826
+ const extDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
827
+ const legacyDir = resolve(extDir, "dmwork");
828
+ const backupDir = resolve(extDir, ".dmwork-backup");
829
+ if (!existsSync(backupDir))
830
+ return;
831
+ try {
832
+ if (existsSync(legacyDir))
833
+ rmSync(legacyDir, { recursive: true, force: true });
834
+ renameSync(backupDir, legacyDir);
835
+ }
836
+ catch { /* best effort */ }
837
+ }
838
+ export function deleteLegacyBackup() {
839
+ const extDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
840
+ const backupDir = resolve(extDir, ".dmwork-backup");
841
+ if (existsSync(backupDir)) {
842
+ try {
843
+ rmSync(backupDir, { recursive: true, force: true });
844
+ }
845
+ catch { /* best effort */ }
846
+ }
847
+ }
848
+ export function removeLegacyFromConfig() {
849
+ try {
850
+ const cfg = readConfigFromFile();
851
+ if (!cfg)
852
+ return;
853
+ if (cfg.plugins?.entries?.["dmwork"])
854
+ delete cfg.plugins.entries["dmwork"];
855
+ if (cfg.plugins?.installs?.["dmwork"])
856
+ delete cfg.plugins.installs["dmwork"];
857
+ if (Array.isArray(cfg.plugins?.allow)) {
858
+ cfg.plugins.allow = cfg.plugins.allow.filter((id) => id !== "dmwork");
859
+ }
860
+ if (cfg.channels?.dmwork)
861
+ delete cfg.channels.dmwork;
862
+ writeConfigAtomic(cfg);
863
+ }
864
+ catch { /* best effort */ }
865
+ }
866
+ export function saveChannelConfigToDisk() {
867
+ try {
868
+ const backupPath = getConfigFilePathSafe().replace(/openclaw\.json$/, "channels-dmwork-backup.json");
869
+ const cfg = readConfigFromFile();
870
+ const dmwork = cfg?.channels?.dmwork;
871
+ if (dmwork) {
872
+ writeFileSync(backupPath, JSON.stringify(dmwork, null, 2), "utf-8");
873
+ }
874
+ else {
875
+ // No channels.dmwork — remove stale backup to prevent wrong restore
876
+ if (existsSync(backupPath))
877
+ rmSync(backupPath, { force: true });
878
+ }
879
+ }
880
+ catch { /* best effort */ }
881
+ }
882
+ export function restoreChannelConfigFromDisk() {
883
+ try {
884
+ const backupPath = getConfigFilePathSafe().replace(/openclaw\.json$/, "channels-dmwork-backup.json");
885
+ if (!existsSync(backupPath))
886
+ return;
887
+ let dmwork = JSON.parse(readFileSync(backupPath, "utf-8"));
888
+ // Migrate flat config → accounts.default
889
+ if (dmwork.botToken && !dmwork.accounts) {
890
+ dmwork = {
891
+ ...dmwork,
892
+ accounts: { default: { botToken: dmwork.botToken, apiUrl: dmwork.apiUrl } },
893
+ };
894
+ delete dmwork.botToken;
895
+ }
896
+ const cfg = readConfigFromFile();
897
+ if (!cfg)
898
+ return;
899
+ if (!cfg.channels)
900
+ cfg.channels = {};
901
+ cfg.channels.dmwork = dmwork;
902
+ writeConfigAtomic(cfg);
903
+ rmSync(backupPath, { force: true });
904
+ }
905
+ catch { /* best effort */ }
906
+ }
907
+ export function cleanupBrokenInstall() {
908
+ const actions = [];
909
+ const cfg = readConfigFromFile();
910
+ const extDir = getConfigFilePathSafe().replace(/openclaw\.json$/, "extensions");
911
+ const pluginDir = resolve(extDir, "openclaw-channel-dmwork");
912
+ const hasDir = existsSync(pluginDir);
913
+ const hasEntries = Boolean(cfg?.plugins?.entries?.["openclaw-channel-dmwork"]);
914
+ const hasInstalls = Boolean(cfg?.plugins?.installs?.["openclaw-channel-dmwork"]);
915
+ // Use same healthy definition as detectScenario(): inspect OK OR all 3 artifacts present
916
+ const inspectOk = Boolean(pluginsInspect("openclaw-channel-dmwork")?.plugin);
917
+ const isHealthy = inspectOk || (hasDir && hasEntries && hasInstalls);
918
+ if (isHealthy)
919
+ return actions; // Actually healthy, nothing to clean
920
+ // Remove directory if it exists (orphan or partial)
921
+ if (hasDir) {
922
+ try {
923
+ rmSync(pluginDir, { recursive: true, force: true });
924
+ actions.push("Removed broken/orphan plugin directory");
925
+ }
926
+ catch { /* best effort */ }
927
+ }
928
+ // Remove stale config entries
929
+ if (cfg && (hasEntries || hasInstalls)) {
930
+ let changed = false;
931
+ if (hasEntries) {
932
+ delete cfg.plugins.entries["openclaw-channel-dmwork"];
933
+ changed = true;
934
+ }
935
+ if (hasInstalls) {
936
+ delete cfg.plugins.installs["openclaw-channel-dmwork"];
937
+ changed = true;
938
+ }
939
+ if (changed) {
940
+ writeConfigAtomic(cfg);
941
+ actions.push("Cleaned stale config entries");
942
+ }
943
+ }
944
+ return actions;
945
+ }
382
946
  //# sourceMappingURL=openclaw-cli.js.map