openclaw-channel-dmwork 0.5.21 → 0.6.0-dev.1c691678

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