traderclaw-cli 1.0.51

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.
@@ -0,0 +1,2029 @@
1
+ import { execSync, spawn } from "child_process";
2
+ import { randomBytes } from "crypto";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
4
+ import { homedir, tmpdir } from "os";
5
+ import { dirname, join } from "path";
6
+ import { resolvePluginPackageRoot } from "./resolve-plugin-root.mjs";
7
+ import { choosePreferredProviderModel } from "./llm-model-preference.mjs";
8
+ import { getLinuxGatewayPersistenceSnapshot } from "./gateway-persistence-linux.mjs";
9
+
10
+ const CONFIG_DIR = join(homedir(), ".openclaw");
11
+ const CONFIG_FILE = join(CONFIG_DIR, "openclaw.json");
12
+
13
+ /** Directory containing solana-traderclaw (openclaw.plugin.json) — works for plugin layout or traderclaw-cli + dependency. */
14
+ const PLUGIN_PACKAGE_ROOT = resolvePluginPackageRoot(import.meta.url);
15
+
16
+ function readPluginPackageVersion() {
17
+ const pkgJsonPath = join(PLUGIN_PACKAGE_ROOT, "package.json");
18
+ if (!existsSync(pkgJsonPath)) return null;
19
+ try {
20
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
21
+ return typeof pkg.version === "string" && pkg.version.trim().length ? pkg.version.trim() : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Spec for `npm install -g` and `openclaw plugins install`.
29
+ * Prefer explicit registry coordinates (`package@version`) so npm never resolves a bare name or `file:`
30
+ * relative to cwd (e.g. /root when TMPDIR or shadow folders break resolution).
31
+ * Local directory install is opt-in for dev/offline: TRADERCLAW_INSTALLER_USE_LOCAL_PACKAGE=1.
32
+ */
33
+ function resolveRegistryPluginInstallSpec(modeConfig) {
34
+ if (process.env.TRADERCLAW_INSTALLER_USE_LOCAL_PACKAGE === "1") {
35
+ const manifest = join(PLUGIN_PACKAGE_ROOT, "openclaw.plugin.json");
36
+ const pkgJson = join(PLUGIN_PACKAGE_ROOT, "package.json");
37
+ if (existsSync(manifest) && existsSync(pkgJson)) {
38
+ return PLUGIN_PACKAGE_ROOT;
39
+ }
40
+ }
41
+ const v = readPluginPackageVersion();
42
+ if (v) return `${modeConfig.pluginPackage}@${v}`;
43
+ return `${modeConfig.pluginPackage}@latest`;
44
+ }
45
+
46
+ /** Empty per-invocation cwd for npm global installs — avoids TMPDIR=/root and stray ./solana-traderclaw shadowing. */
47
+ function getNpmGlobalInstallCwd() {
48
+ if (process.platform === "win32") {
49
+ return mkdtempSync(join(tmpdir(), "tc-npm-"));
50
+ }
51
+ try {
52
+ return mkdtempSync(join("/tmp", "tc-npm-"));
53
+ } catch {
54
+ return mkdtempSync(join(tmpdir(), "tc-npm-"));
55
+ }
56
+ }
57
+
58
+ /** Older `plugins.entries` keys / npm-era ids for the v1 plugin. */
59
+ const LEGACY_TRADER_PLUGIN_IDS = ["traderclaw-v1", "solana-traderclaw-v1", "solana-traderclaw"];
60
+
61
+ function isRecord(value) {
62
+ return !!value && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+
65
+ function getLegacyTraderPluginIds(pluginId) {
66
+ return pluginId === "solana-trader" ? LEGACY_TRADER_PLUGIN_IDS : [];
67
+ }
68
+
69
+ function normalizeTraderPluginEntries(config, pluginId) {
70
+ if (!isRecord(config)) return false;
71
+ if (!isRecord(config.plugins)) config.plugins = {};
72
+ if (!isRecord(config.plugins.entries)) config.plugins.entries = {};
73
+
74
+ const entries = config.plugins.entries;
75
+ const legacyIds = getLegacyTraderPluginIds(pluginId);
76
+ if (legacyIds.length === 0) return false;
77
+
78
+ let touched = false;
79
+ let hasSource = false;
80
+ let enabledSeen = false;
81
+ let enabledValue = false;
82
+ let mergedConfig = {};
83
+
84
+ for (const sourceId of [...legacyIds, pluginId]) {
85
+ const entry = entries[sourceId];
86
+ if (!isRecord(entry)) continue;
87
+ hasSource = true;
88
+ if (typeof entry.enabled === "boolean") {
89
+ enabledSeen = true;
90
+ enabledValue = enabledValue || entry.enabled;
91
+ }
92
+ if (isRecord(entry.config)) {
93
+ mergedConfig = { ...mergedConfig, ...entry.config };
94
+ }
95
+ }
96
+
97
+ if (!hasSource) return false;
98
+
99
+ const canonicalEntry = isRecord(entries[pluginId]) ? entries[pluginId] : {};
100
+ const nextEntry = {
101
+ ...canonicalEntry,
102
+ enabled: typeof canonicalEntry.enabled === "boolean" ? canonicalEntry.enabled : (enabledSeen ? enabledValue : true),
103
+ config: mergedConfig,
104
+ };
105
+
106
+ if (entries[pluginId] !== nextEntry) {
107
+ entries[pluginId] = nextEntry;
108
+ touched = true;
109
+ }
110
+
111
+ for (const legacyId of legacyIds) {
112
+ if (Object.prototype.hasOwnProperty.call(entries, legacyId)) {
113
+ delete entries[legacyId];
114
+ touched = true;
115
+ }
116
+ }
117
+
118
+ return touched;
119
+ }
120
+
121
+ function normalizeTraderAllowlist(config, pluginId) {
122
+ if (!isRecord(config?.plugins)) return false;
123
+ const legacyIds = new Set(getLegacyTraderPluginIds(pluginId));
124
+ if (legacyIds.size === 0 || !Array.isArray(config.plugins.allow)) return false;
125
+
126
+ const nextAllow = [];
127
+ const seen = new Set();
128
+ let touched = false;
129
+
130
+ for (const id of config.plugins.allow) {
131
+ if (typeof id !== "string") {
132
+ touched = true;
133
+ continue;
134
+ }
135
+ const trimmed = id.trim();
136
+ if (!trimmed) {
137
+ touched = true;
138
+ continue;
139
+ }
140
+ if (legacyIds.has(trimmed)) {
141
+ touched = true;
142
+ continue;
143
+ }
144
+ if (seen.has(trimmed)) {
145
+ touched = true;
146
+ continue;
147
+ }
148
+ seen.add(trimmed);
149
+ nextAllow.push(trimmed);
150
+ }
151
+
152
+ if (touched) {
153
+ config.plugins.allow = nextAllow;
154
+ }
155
+ return touched;
156
+ }
157
+
158
+ function stripAnsi(text) {
159
+ if (typeof text !== "string") return text;
160
+ return text
161
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
162
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
163
+ .replace(/\x1b[^[\]]/g, "")
164
+ .replace(/\x1b/g, "");
165
+ }
166
+
167
+ /**
168
+ * Extract and parse the first valid JSON object or array from a string that may contain
169
+ * non-JSON prefix/suffix lines (e.g. progress text OpenClaw prints before the JSON payload).
170
+ */
171
+ function extractJson(raw) {
172
+ if (typeof raw !== "string" || !raw.trim()) return null;
173
+ const cleaned = stripAnsi(raw);
174
+
175
+ try { return JSON.parse(cleaned); } catch {}
176
+
177
+ const objIdx = cleaned.indexOf("{");
178
+ const arrIdx = cleaned.indexOf("[");
179
+ const candidates = [objIdx, arrIdx].filter((i) => i >= 0).sort((a, b) => a - b);
180
+
181
+ for (const start of candidates) {
182
+ const slice = cleaned.slice(start);
183
+ try { return JSON.parse(slice); } catch {}
184
+ const endChar = cleaned[start] === "{" ? "}" : "]";
185
+ const end = cleaned.lastIndexOf(endChar);
186
+ if (end > start) {
187
+ try { return JSON.parse(cleaned.slice(start, end + 1)); } catch {}
188
+ }
189
+ }
190
+
191
+ return null;
192
+ }
193
+
194
+ /** Env vars for every openclaw CLI invocation to suppress colour output. */
195
+ const NO_COLOR_ENV = { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" };
196
+
197
+ /**
198
+ * OpenClaw defaults Telegram to groupPolicy "allowlist" with empty groupAllowFrom, so Doctor warns on
199
+ * every gateway restart and group messages are dropped. Wizard onboarding targets DMs first; set
200
+ * explicit "open" unless the user already configured sender allowlists.
201
+ */
202
+ function ensureTelegramGroupPolicyOpenForWizard(configPath = CONFIG_FILE) {
203
+ if (!existsSync(configPath)) return { changed: false };
204
+ let config = {};
205
+ try {
206
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
207
+ } catch {
208
+ return { changed: false };
209
+ }
210
+ if (!config.channels || typeof config.channels !== "object") return { changed: false };
211
+ const tg = config.channels.telegram;
212
+ if (!tg || typeof tg !== "object") return { changed: false };
213
+
214
+ const hasSenderAllowlist =
215
+ (Array.isArray(tg.groupAllowFrom) && tg.groupAllowFrom.length > 0) ||
216
+ (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0);
217
+ if (hasSenderAllowlist) return { changed: false };
218
+ if (tg.groupPolicy === "open") return { changed: false };
219
+
220
+ tg.groupPolicy = "open";
221
+ ensureAgentsDefaultsSchemaCompat(config);
222
+ mkdirSync(CONFIG_DIR, { recursive: true });
223
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
224
+ return { changed: true };
225
+ }
226
+
227
+ function commandExists(cmd) {
228
+ try {
229
+ execSync(`command -v ${cmd}`, { stdio: "ignore", shell: true });
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ function getCommandOutput(cmd) {
237
+ try {
238
+ return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], shell: true, maxBuffer: 50 * 1024 * 1024, env: NO_COLOR_ENV }).trim();
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ function extractUrls(text = "") {
245
+ const matches = text.match(/https?:\/\/[^\s"')]+/g);
246
+ return matches ? [...new Set(matches)] : [];
247
+ }
248
+
249
+ function shellQuote(value) {
250
+ const raw = String(value ?? "");
251
+ if (raw.length === 0) return "''";
252
+ return `'${raw.replace(/'/g, `'\\''`)}'`;
253
+ }
254
+
255
+ function buildCommandString(cmd, args = []) {
256
+ return [cmd, ...args].map((part) => shellQuote(part)).join(" ");
257
+ }
258
+
259
+ function isPrivilegeError(err) {
260
+ const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
261
+ return (
262
+ text.includes("permission denied")
263
+ || text.includes("eacces")
264
+ || text.includes("access denied")
265
+ || text.includes("operation not permitted")
266
+ || text.includes("must be root")
267
+ || text.includes("requires root")
268
+ || text.includes("sudo")
269
+ || text.includes("authentication is required")
270
+ );
271
+ }
272
+
273
+ function isRootUser() {
274
+ return typeof process.getuid === "function" && process.getuid() === 0;
275
+ }
276
+
277
+ function canUseSudoWithoutPrompt() {
278
+ try {
279
+ execSync("sudo -n true", { stdio: "ignore", shell: true });
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ function tailscalePermissionRemediation() {
287
+ return [
288
+ "Tailscale requires elevated permissions on this host.",
289
+ "Run these commands in your terminal, then click Start Installation again:",
290
+ "1) sudo tailscale set --operator=$USER",
291
+ "2) sudo tailscale up",
292
+ "3) tailscale status",
293
+ ].join("\n");
294
+ }
295
+
296
+ function privilegeRemediationMessage(cmd, args = [], customLines = []) {
297
+ const command = buildCommandString(cmd, args);
298
+ const lines = [
299
+ "This step needs elevated privileges on this host.",
300
+ "Run this command in your terminal, then click Start Installation again:",
301
+ `sudo ${command}`,
302
+ ];
303
+ if (customLines.length > 0) {
304
+ lines.push(...customLines);
305
+ }
306
+ return lines.join("\n");
307
+ }
308
+
309
+ function gatewayTimeoutRemediation() {
310
+ return [
311
+ "Gateway bootstrap timed out waiting for health checks.",
312
+ "Run these commands in terminal, then click Start Installation again:",
313
+ "1) openclaw gateway status --json || true",
314
+ "2) openclaw gateway probe || true",
315
+ "3) openclaw gateway stop || true",
316
+ "4) openclaw gateway install",
317
+ "5) openclaw gateway restart",
318
+ "6) openclaw gateway status --json",
319
+ "7) tailscale funnel --bg 18789",
320
+ "8) tailscale funnel status",
321
+ "If gateway still fails on a low-memory VM, add swap or use a larger staging size (>=2GB RAM recommended).",
322
+ ].join("\n");
323
+ }
324
+
325
+ function gatewayModeUnsetRemediation() {
326
+ return [
327
+ "Gateway start is blocked because gateway.mode is unset.",
328
+ "Run these commands in terminal, then click Start Installation again:",
329
+ "1) cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak.$(date +%s) || true",
330
+ "2) openclaw config set gateway.mode local",
331
+ "3) openclaw config set gateway.bind loopback",
332
+ "4) openclaw gateway restart",
333
+ "5) openclaw gateway status --json",
334
+ ].join("\n");
335
+ }
336
+
337
+ function gatewayConfigValidationRemediation() {
338
+ return [
339
+ "OpenClaw could not load or validate ~/.openclaw/openclaw.json after plugins are enabled (often an Ajv/schema compile error in the OpenClaw CLI, not invalid JSON syntax).",
340
+ "The first `openclaw config validate` in this installer runs before plugins install; validation must be re-run once plugin schemas are registered — that is why this can appear only at gateway.",
341
+ "On the VPS, try in order:",
342
+ "1) openclaw --version",
343
+ "2) npm install -g openclaw@latest",
344
+ "3) openclaw config validate",
345
+ "4) openclaw plugins doctor",
346
+ "5) cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak.$(date +%s) || true",
347
+ "If it still fails, report the OpenClaw version plus output of steps 3–4 to OpenClaw/TraderClaw support (redact secrets).",
348
+ ].join("\n");
349
+ }
350
+
351
+ function isOpenClawConfigSchemaFailure(text) {
352
+ const t = String(text || "").toLowerCase();
353
+ return (
354
+ t.includes("ajv implementation")
355
+ || t.includes("validatejsonschemavalue")
356
+ || (t.includes("failed to read config") && t.includes("ajv"))
357
+ );
358
+ }
359
+
360
+ function runCommandWithEvents(cmd, args = [], opts = {}) {
361
+ return new Promise((resolve, reject) => {
362
+ const { onEvent, ...spawnOpts } = opts;
363
+ const child = spawn(cmd, args, {
364
+ stdio: "pipe",
365
+ shell: true,
366
+ ...spawnOpts,
367
+ });
368
+
369
+ let stdout = "";
370
+ let stderr = "";
371
+ const emitFn = typeof onEvent === "function" ? onEvent : null;
372
+ const emit = (event) => emitFn && emitFn(event);
373
+
374
+ child.stdout?.on("data", (d) => {
375
+ const text = d.toString();
376
+ stdout += text;
377
+ emit({ type: "stdout", text, urls: extractUrls(text) });
378
+ });
379
+
380
+ child.stderr?.on("data", (d) => {
381
+ const text = d.toString();
382
+ stderr += text;
383
+ emit({ type: "stderr", text, urls: extractUrls(text) });
384
+ });
385
+
386
+ child.on("close", (code) => {
387
+ const urls = [...new Set([...extractUrls(stdout), ...extractUrls(stderr)])];
388
+ if (code === 0) resolve({ stdout, stderr, code, urls });
389
+ else {
390
+ const raw = (stderr || "").trim();
391
+ const tailLines = raw.split("\n").filter((l) => l.length > 0).slice(-40).join("\n");
392
+ const stderrPreview = tailLines.length > 8000 ? tailLines.slice(-8000) : tailLines;
393
+ const err = new Error(stderrPreview ? `command failed with exit code ${code}: ${stderrPreview}` : `command failed with exit code ${code}`);
394
+ err.code = code;
395
+ err.stdout = stdout;
396
+ err.stderr = stderr;
397
+ err.urls = urls;
398
+ reject(err);
399
+ }
400
+ });
401
+ child.on("error", reject);
402
+ });
403
+ }
404
+
405
+ async function installOpenClawPlatform() {
406
+ if (commandExists("openclaw")) {
407
+ return { alreadyInstalled: true, version: getCommandOutput("openclaw --version") };
408
+ }
409
+ await runCommandWithEvents("npm", ["install", "-g", "openclaw"]);
410
+ return { alreadyInstalled: false, installed: true, available: commandExists("openclaw") };
411
+ }
412
+
413
+ function isNpmGlobalBinConflict(err, cliName) {
414
+ const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
415
+ return (
416
+ text.includes("eexist")
417
+ && text.includes("/usr/bin/")
418
+ && text.includes(String(cliName || "").toLowerCase())
419
+ );
420
+ }
421
+
422
+ /** True when spec is an on-disk package directory (global npm path or git checkout), not a registry name. */
423
+ function isNpmFilesystemPackageSpec(spec) {
424
+ if (typeof spec !== "string" || !spec.length) return false;
425
+ if (spec.startsWith("/")) return true;
426
+ return process.platform === "win32" && /^[A-Za-z]:[\\/]/.test(spec);
427
+ }
428
+
429
+ /**
430
+ * Args for `npm install -g …`. Use explicit registry for registry specs so npm never treats cwd/temp as `file:solana-traderclaw`.
431
+ * IMPORTANT: run with `{ shell: false }` — `spawn(..., { shell: true })` can drop argv on Unix and npm then mis-resolves the package name.
432
+ */
433
+ function npmGlobalInstallArgs(spec, { force = false } = {}) {
434
+ const args = ["install", "-g"];
435
+ if (force) args.push("--force");
436
+ if (!isNpmFilesystemPackageSpec(spec)) {
437
+ args.push("--registry", "https://registry.npmjs.org/");
438
+ }
439
+ args.push(spec);
440
+ return args;
441
+ }
442
+
443
+ async function installPlugin(modeConfig, onEvent) {
444
+ const spec = resolveRegistryPluginInstallSpec(modeConfig);
445
+ const isLocalPluginRoot =
446
+ typeof spec === "string" &&
447
+ existsSync(join(spec, "openclaw.plugin.json")) &&
448
+ existsSync(join(spec, "package.json"));
449
+ if (isLocalPluginRoot && typeof onEvent === "function") {
450
+ onEvent({
451
+ type: "stdout",
452
+ text: `Installing TraderClaw CLI from local package path (not on npm registry): ${spec}\n`,
453
+ urls: [],
454
+ });
455
+ }
456
+ const npmCwd = getNpmGlobalInstallCwd();
457
+ if (typeof onEvent === "function") {
458
+ onEvent({
459
+ type: "stdout",
460
+ text: `Running npm global install with cwd=${npmCwd}, shell=false, args=${JSON.stringify(npmGlobalInstallArgs(spec))}\n`,
461
+ urls: [],
462
+ });
463
+ }
464
+ const npmOpts = { onEvent, cwd: npmCwd, shell: false };
465
+ try {
466
+ await runCommandWithEvents("npm", npmGlobalInstallArgs(spec), npmOpts);
467
+ return { installed: true, available: commandExists(modeConfig.cliName), forced: false };
468
+ } catch (err) {
469
+ if (!isNpmGlobalBinConflict(err, modeConfig.cliName)) throw err;
470
+ if (typeof onEvent === "function") {
471
+ onEvent({
472
+ type: "stderr",
473
+ text: `Detected existing global binary conflict for '${modeConfig.cliName}'. Retrying npm install with --force.\n`,
474
+ urls: [],
475
+ });
476
+ }
477
+ await runCommandWithEvents("npm", npmGlobalInstallArgs(spec, { force: true }), npmOpts);
478
+ return { installed: true, available: commandExists(modeConfig.cliName), forced: true };
479
+ }
480
+ }
481
+
482
+ function isPluginAlreadyExistsError(err, pluginId) {
483
+ const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
484
+ return text.includes("plugin already exists")
485
+ || text.includes(`/extensions/${String(pluginId || "").toLowerCase()}`);
486
+ }
487
+
488
+ function backupExistingPluginDir(pluginId, onEvent) {
489
+ const pluginDir = join(CONFIG_DIR, "extensions", pluginId);
490
+ if (!existsSync(pluginDir)) return null;
491
+
492
+ const backupPath = `${pluginDir}.bak.${Date.now()}`;
493
+ renameSync(pluginDir, backupPath);
494
+ if (typeof onEvent === "function") {
495
+ onEvent({
496
+ type: "stdout",
497
+ text: `Detected existing plugin directory. Backed up '${pluginDir}' to '${backupPath}' before reinstall.\n`,
498
+ urls: [],
499
+ });
500
+ }
501
+ return { pluginDir, backupPath };
502
+ }
503
+
504
+ async function installAndEnableOpenClawPlugin(modeConfig, onEvent, orchestratorUrl) {
505
+ // `openclaw plugins install` calls writeConfigFile *during* the command. Plugin config schema
506
+ // requires orchestratorUrl — so we must seed it *before* install, not only after.
507
+ // Also merge legacy plugins.entries.* (see LEGACY_TRADER_PLUGIN_IDS) so old configs still validate.
508
+ mkdirSync(CONFIG_DIR, { recursive: true });
509
+ mkdirSync(join(CONFIG_DIR, "extensions"), { recursive: true });
510
+
511
+ seedPluginConfig(modeConfig, orchestratorUrl || "https://api.traderclaw.ai");
512
+
513
+ const pluginInstallSpec = resolveRegistryPluginInstallSpec(modeConfig);
514
+ let recoveredExistingDir = null;
515
+ try {
516
+ await runCommandWithEvents("openclaw", ["plugins", "install", pluginInstallSpec], { onEvent });
517
+ } catch (err) {
518
+ if (!isPluginAlreadyExistsError(err, modeConfig.pluginId)) {
519
+ throw err;
520
+ }
521
+ recoveredExistingDir = backupExistingPluginDir(modeConfig.pluginId, onEvent);
522
+ if (!recoveredExistingDir) {
523
+ throw err;
524
+ }
525
+ await runCommandWithEvents("openclaw", ["plugins", "install", pluginInstallSpec], { onEvent });
526
+ }
527
+
528
+ // Manifest is on disk now; merge orchestrator URL before enable (plugin config schema may require it).
529
+ seedPluginConfig(modeConfig, orchestratorUrl || "https://api.traderclaw.ai");
530
+
531
+ await runCommandWithEvents("openclaw", ["plugins", "enable", modeConfig.pluginId], { onEvent });
532
+
533
+ // Safe to set plugins.allow only after install+enable — registry must know the plugin id.
534
+ mergePluginsAllowlist(modeConfig);
535
+
536
+ const list = await runCommandWithEvents("openclaw", ["plugins", "list"], { onEvent });
537
+ const doctor = await runCommandWithEvents("openclaw", ["plugins", "doctor"], { onEvent });
538
+ const pluginFound = `${list.stdout || ""}\n${list.stderr || ""}`.toLowerCase().includes(modeConfig.pluginId.toLowerCase());
539
+ if (!pluginFound) {
540
+ throw new Error(
541
+ `Plugin '${modeConfig.pluginId}' was not found in 'openclaw plugins list' after install/enable.`,
542
+ );
543
+ }
544
+ return {
545
+ installed: true,
546
+ enabled: true,
547
+ verified: true,
548
+ recoveredExistingDir,
549
+ list: list.stdout || "",
550
+ doctor: doctor.stdout || "",
551
+ };
552
+ }
553
+
554
+ function seedPluginConfig(modeConfig, orchestratorUrl, configPath = CONFIG_FILE) {
555
+ const defaultUrl = orchestratorUrl || "https://api.traderclaw.ai";
556
+
557
+ let config = {};
558
+ try {
559
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
560
+ } catch {
561
+ config = {};
562
+ }
563
+
564
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
565
+ if (!config.plugins.entries || typeof config.plugins.entries !== "object") config.plugins.entries = {};
566
+
567
+ normalizeTraderPluginEntries(config, modeConfig.pluginId);
568
+ normalizeTraderAllowlist(config, modeConfig.pluginId);
569
+
570
+ const entries = config.plugins.entries;
571
+
572
+ const mergeOrchestratorForId = (pluginId) => {
573
+ const existing = entries[pluginId];
574
+ const existingConfig = existing && typeof existing === "object" && existing.config && typeof existing.config === "object"
575
+ ? existing.config
576
+ : {};
577
+ const url = typeof existingConfig.orchestratorUrl === "string" && existingConfig.orchestratorUrl.trim()
578
+ ? existingConfig.orchestratorUrl.trim()
579
+ : defaultUrl;
580
+ entries[pluginId] = {
581
+ enabled: existing && typeof existing.enabled === "boolean" ? existing.enabled : true,
582
+ config: {
583
+ ...existingConfig,
584
+ orchestratorUrl: url,
585
+ },
586
+ };
587
+ };
588
+
589
+ mergeOrchestratorForId(modeConfig.pluginId);
590
+
591
+ // Do not set plugins.allow here: OpenClaw validates allow[] against the plugin registry, and
592
+ // the id is not registered until after `openclaw plugins install`. Pre-seeding allow caused:
593
+ // "plugins.allow: plugin not found: <id>".
594
+ ensureAgentsDefaultsSchemaCompat(config);
595
+
596
+ mkdirSync(CONFIG_DIR, { recursive: true });
597
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
598
+ return configPath;
599
+ }
600
+
601
+ /**
602
+ * Resolve OpenClaw cron job store path (same rules as Gateway: optional cron.store, ~ expansion).
603
+ * @param {Record<string, unknown>} config
604
+ * @returns {string}
605
+ */
606
+ function resolveCronJobsStorePath(config) {
607
+ const raw = config?.cron?.store;
608
+ if (typeof raw === "string" && raw.trim()) {
609
+ let t = raw.trim();
610
+ if (t.startsWith("~")) {
611
+ t =
612
+ t === "~" || t === "~/" ? homedir() : join(homedir(), t.slice(2).replace(/^\/+/, ""));
613
+ }
614
+ if (t.startsWith("/") || (process.platform === "win32" && /^[A-Za-z]:[\\/]/.test(t))) {
615
+ return t;
616
+ }
617
+ return join(CONFIG_DIR, t);
618
+ }
619
+ return join(CONFIG_DIR, "cron", "jobs.json");
620
+ }
621
+
622
+ function cronJobStableId(job) {
623
+ if (!job || typeof job !== "object") return "";
624
+ const id = typeof job.id === "string" ? job.id.trim() : "";
625
+ if (id) return id;
626
+ const legacy = typeof job.jobId === "string" ? job.jobId.trim() : "";
627
+ return legacy;
628
+ }
629
+
630
+ /**
631
+ * Build a cron job record compatible with OpenClaw 2026+ store normalization (see ~/.openclaw/cron/jobs.json).
632
+ * @param {{ id: string, schedule: string, agentId: string, message: string, enabled?: boolean }} def
633
+ */
634
+ function buildOpenClawCronStoreJob(def) {
635
+ const nameFromId = def.id
636
+ .split("-")
637
+ .map((w) => (w.length ? w.charAt(0).toUpperCase() + w.slice(1) : w))
638
+ .join(" ");
639
+ return {
640
+ id: def.id,
641
+ name: nameFromId.length <= 60 ? nameFromId : nameFromId.slice(0, 59) + "…",
642
+ enabled: def.enabled !== false,
643
+ schedule: { kind: "cron", expr: def.schedule },
644
+ sessionTarget: "isolated",
645
+ wakeMode: "now",
646
+ agentId: def.agentId,
647
+ payload: {
648
+ kind: "agentTurn",
649
+ message: def.message,
650
+ lightContext: true,
651
+ },
652
+ // OpenClaw: "none" = no channel post; announce + last = summary to user's last chat (see OpenClaw cron delivery docs)
653
+ delivery: { mode: "announce", channel: "last", bestEffort: true },
654
+ state: {},
655
+ };
656
+ }
657
+
658
+ /**
659
+ * Merge TraderClaw template cron jobs into the Gateway cron store (upsert by job id).
660
+ * Preserves user-defined jobs whose ids are not in the template set.
661
+ * @returns {{ storePath: string, added: number, updated: number, preserved: number, totalManaged: number }}
662
+ */
663
+ function mergeTraderCronJobsIntoStore(storePath, templateJobs) {
664
+ const managedIds = new Set(templateJobs.map((j) => j.id).filter(Boolean));
665
+ let existing = { version: 1, jobs: [] };
666
+ try {
667
+ const raw = readFileSync(storePath, "utf-8");
668
+ const parsed = JSON.parse(raw);
669
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
670
+ const jobs = Array.isArray(parsed.jobs) ? parsed.jobs.filter(Boolean) : [];
671
+ existing = { version: 1, jobs };
672
+ }
673
+ } catch (err) {
674
+ if (err && err.code === "ENOENT") {
675
+ // New store file — only TraderClaw template jobs.
676
+ } else {
677
+ return {
678
+ storePath,
679
+ added: 0,
680
+ updated: 0,
681
+ preserved: 0,
682
+ totalManaged: templateJobs.length,
683
+ error: err?.message || String(err),
684
+ wrote: false,
685
+ };
686
+ }
687
+ }
688
+
689
+ const beforeKeys = new Set();
690
+ for (const j of existing.jobs) {
691
+ const k = cronJobStableId(j);
692
+ if (k) beforeKeys.add(k);
693
+ }
694
+
695
+ const preserved = existing.jobs.filter((j) => !managedIds.has(cronJobStableId(j)));
696
+ const built = templateJobs.map((def) => buildOpenClawCronStoreJob(def));
697
+ const next = { version: 1, jobs: [...preserved, ...built] };
698
+
699
+ let added = 0;
700
+ let updated = 0;
701
+ for (const id of managedIds) {
702
+ if (beforeKeys.has(id)) updated += 1;
703
+ else added += 1;
704
+ }
705
+
706
+ const dir = dirname(storePath);
707
+ mkdirSync(dir, { recursive: true });
708
+ const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
709
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", "utf-8");
710
+ renameSync(tmp, storePath);
711
+
712
+ return {
713
+ storePath,
714
+ added,
715
+ updated,
716
+ preserved: preserved.length,
717
+ totalManaged: templateJobs.length,
718
+ wrote: true,
719
+ };
720
+ }
721
+
722
+ function mergePluginsAllowlist(modeConfig, configPath = CONFIG_FILE) {
723
+ let config = {};
724
+ try {
725
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
726
+ } catch {
727
+ return;
728
+ }
729
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
730
+ normalizeTraderPluginEntries(config, modeConfig.pluginId);
731
+ normalizeTraderAllowlist(config, modeConfig.pluginId);
732
+ const allowSet = new Set(
733
+ Array.isArray(config.plugins.allow) ? config.plugins.allow.filter((id) => typeof id === "string" && id.trim()) : [],
734
+ );
735
+ allowSet.add(modeConfig.pluginId);
736
+ config.plugins.allow = [...allowSet];
737
+ ensureAgentsDefaultsSchemaCompat(config);
738
+ mkdirSync(CONFIG_DIR, { recursive: true });
739
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
740
+ }
741
+
742
+ /**
743
+ * Managed cron jobs with prescriptive tool chains (VPS report 2026-03-24).
744
+ * Schedules are staggered (minutes :00 / :15 / :30 / :45) where possible to avoid pile-ups.
745
+ * @param {string} agentId
746
+ * @returns {Array<{ id: string, schedule: string, agentId: string, message: string, enabled: boolean }>}
747
+ */
748
+ function traderCronPrescriptiveJobs(agentId) {
749
+ return [
750
+ {
751
+ id: "alpha-scan",
752
+ schedule: "0 * * * *",
753
+ agentId,
754
+ message:
755
+ "CRON_JOB: alpha_scan — Call solana_scan_launches to find new token launches from the last hour. For each candidate, call solana_token_snapshot to check price, volume, and age. Filter for: 24h volume above 50000 USD, market cap above 10000 USD. For tokens that pass, call solana_token_holders to check holder distribution (skip if top holder owns more than 30 percent), then solana_token_risk to check for mint/freeze authority (hard skip if present). If a token passes all checks, use solana_trade_precheck and consider trade execution using solana_trade_execute. Log all scanned tokens and results using solana_memory_write. Do not ask questions.",
756
+ enabled: true,
757
+ },
758
+ {
759
+ id: "dead-money-sweep",
760
+ schedule: "0 */2 * * *",
761
+ agentId,
762
+ message:
763
+ "CRON_JOB: dead_money_sweep — Check all open LOCAL_MANAGED positions for dead money. Exit stale positions. Tag as dead_money.",
764
+ enabled: true,
765
+ },
766
+ {
767
+ id: "risk-audit",
768
+ schedule: "30 */2 * * *",
769
+ agentId,
770
+ message:
771
+ "CRON_JOB: portfolio_risk_audit — Portfolio stress tests, exposure checks, correlation analysis, drawdown monitoring. Produce risk report for CTO.",
772
+ enabled: true,
773
+ },
774
+ {
775
+ id: "source-reputation-recalc",
776
+ schedule: "0 */3 * * *",
777
+ agentId,
778
+ message:
779
+ "CRON_JOB: source_reputation_recalc — Analyze which alpha signal sources led to wins vs losses. Update reputation tracking in memory.",
780
+ enabled: true,
781
+ },
782
+ {
783
+ id: "meta-rotation-analysis",
784
+ schedule: "30 */3 * * *",
785
+ agentId,
786
+ message:
787
+ "CRON_JOB: meta_rotation_analysis — Analyze narrative clusters across recent scans and trades. Identify hot vs cooling metas. Write observations to memory.",
788
+ enabled: true,
789
+ },
790
+ {
791
+ id: "strategy-evolution",
792
+ schedule: "0 */4 * * *",
793
+ agentId,
794
+ message:
795
+ "CRON_JOB: strategy_evolution — Review trade journal, compute weight adjustments, update strategy. Only update if sufficient closed trades have accumulated.",
796
+ enabled: true,
797
+ },
798
+ {
799
+ id: "subscription-cleanup",
800
+ schedule: "15 * * * *",
801
+ agentId,
802
+ message:
803
+ "CRON_JOB: subscription_cleanup — Check active Bitquery subscriptions. Unsubscribe from streams no longer needed (sold tokens, closed positions).",
804
+ enabled: true,
805
+ },
806
+ {
807
+ id: "whale-watch",
808
+ schedule: "45 */2 * * *",
809
+ agentId,
810
+ message:
811
+ "CRON_JOB: whale_activity_scan — Scan for large wallet movements, deployer activity, accumulation patterns. Detect smart money consensus and fresh wallet surges.",
812
+ enabled: true,
813
+ },
814
+ {
815
+ id: "daily-performance-report",
816
+ schedule: "0 4 * * *",
817
+ agentId,
818
+ message:
819
+ "CRON_JOB: daily_performance_report — Calculate daily PnL, aggregate win/loss stats, source reputation summary, write comprehensive memory entry.",
820
+ enabled: true,
821
+ },
822
+ ];
823
+ }
824
+
825
+ function configureGatewayScheduling(modeConfig, configPath = CONFIG_FILE) {
826
+ let config = {};
827
+ try {
828
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
829
+ } catch {
830
+ config = {};
831
+ }
832
+
833
+ if (!config.agents || typeof config.agents !== "object") config.agents = {};
834
+
835
+ const isV2 = modeConfig.pluginId === "solana-trader-v2";
836
+
837
+ const heartbeatPrompt =
838
+ "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Execute a full trading cycle: Steps 0 through 10. The cycle is NOT complete until all 10 steps are done including Step 8 (memory write-back), Step 9 (X post), and Step 10 (report). Do not stop early. Do not infer or repeat old tasks from prior chats. Never reply HEARTBEAT_OK. Never end your message with a question.";
839
+
840
+ /** Default periodic wake interval for TraderClaw installs (was 5m; stretched to reduce load). */
841
+ const defaultHeartbeatEvery = "30m";
842
+
843
+ const defaultHeartbeat = {
844
+ every: defaultHeartbeatEvery,
845
+ target: "telegram",
846
+ isolatedSession: true,
847
+ lightContext: true,
848
+ prompt: heartbeatPrompt,
849
+ };
850
+
851
+ const v1Agents = [{ id: "main", default: true, heartbeat: { ...defaultHeartbeat } }];
852
+ const v2Agents = [
853
+ { id: "cto", default: true, heartbeat: { ...defaultHeartbeat } },
854
+ { id: "execution-specialist", heartbeat: { ...defaultHeartbeat } },
855
+ { id: "alpha-signal-analyst", heartbeat: { ...defaultHeartbeat } },
856
+ { id: "onchain-analyst" },
857
+ { id: "social-analyst" },
858
+ { id: "smart-money-tracker" },
859
+ { id: "risk-officer" },
860
+ { id: "strategy-researcher" }
861
+ ];
862
+
863
+ const targetAgents = isV2 ? v2Agents : v1Agents;
864
+
865
+ if (!Array.isArray(config.agents.list)) {
866
+ config.agents.list = [];
867
+ }
868
+ config.agents.list = config.agents.list.filter(a => a && typeof a === "object" && a.id);
869
+
870
+ const existingIds = new Set(config.agents.list.map(a => a.id));
871
+ for (const agent of targetAgents) {
872
+ if (existingIds.has(agent.id)) {
873
+ const existing = config.agents.list.find(a => a.id === agent.id);
874
+ if (agent.heartbeat) {
875
+ existing.heartbeat = agent.heartbeat;
876
+ }
877
+ if (agent.default) {
878
+ existing.default = true;
879
+ }
880
+ } else {
881
+ config.agents.list.push(agent);
882
+ }
883
+ }
884
+
885
+ if (!config.cron || typeof config.cron !== "object") {
886
+ config.cron = {};
887
+ }
888
+ config.cron.enabled = true;
889
+ if (!config.cron.maxConcurrentRuns) config.cron.maxConcurrentRuns = isV2 ? 3 : 2;
890
+ if (!config.cron.sessionRetention) config.cron.sessionRetention = "24h";
891
+
892
+ const mainAgent = isV2 ? "cto" : "main";
893
+
894
+ /** Six prescriptive managed jobs (VPS report); v2 assigns the same set to the CTO agent. */
895
+ const targetJobs = traderCronPrescriptiveJobs(mainAgent);
896
+
897
+ let removedLegacyCronJobs = false;
898
+ if (config.cron && Object.prototype.hasOwnProperty.call(config.cron, "jobs")) {
899
+ // OpenClaw now stores jobs under ~/.openclaw/cron/jobs.json.
900
+ // Keeping cron.jobs in openclaw.json can fail strict config validation.
901
+ delete config.cron.jobs;
902
+ removedLegacyCronJobs = true;
903
+ }
904
+
905
+ if (!config.hooks || typeof config.hooks !== "object") {
906
+ config.hooks = {};
907
+ }
908
+ config.hooks.enabled = true;
909
+ if (!config.hooks.token || config.hooks.token === "shared-secret" || config.hooks.token === "REPLACE_WITH_SECURE_TOKEN") {
910
+ config.hooks.token = "hk_" + randomBytes(24).toString("hex");
911
+ }
912
+
913
+ const alphaAgentId = isV2 ? "alpha-signal-analyst" : "main";
914
+ const onchainAgentId = isV2 ? "onchain-analyst" : "main";
915
+
916
+ const targetMappings = [
917
+ { match: { path: "alpha-signal" }, action: "agent", agentId: alphaAgentId, deliver: true },
918
+ { match: { path: "firehose-alert" }, action: "agent", agentId: onchainAgentId, deliver: true }
919
+ ];
920
+
921
+ if (!Array.isArray(config.hooks.mappings)) {
922
+ config.hooks.mappings = [];
923
+ }
924
+ config.hooks.mappings = config.hooks.mappings.filter(m => m && typeof m === "object");
925
+
926
+ for (const mapping of targetMappings) {
927
+ const existingIdx = config.hooks.mappings.findIndex(m => m?.match?.path === mapping.match.path);
928
+ if (existingIdx >= 0) {
929
+ config.hooks.mappings[existingIdx] = mapping;
930
+ } else {
931
+ config.hooks.mappings.push(mapping);
932
+ }
933
+ }
934
+
935
+ if (!config.channels || typeof config.channels !== "object") config.channels = {};
936
+ if (!config.channels.defaults || typeof config.channels.defaults !== "object") config.channels.defaults = {};
937
+ if (!config.channels.defaults.heartbeat || typeof config.channels.defaults.heartbeat !== "object") {
938
+ config.channels.defaults.heartbeat = {};
939
+ }
940
+ if (config.channels.defaults.heartbeat.showOk === undefined) {
941
+ config.channels.defaults.heartbeat.showOk = true;
942
+ }
943
+
944
+ ensureAgentsDefaultsSchemaCompat(config);
945
+ mkdirSync(CONFIG_DIR, { recursive: true });
946
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
947
+
948
+ const cronStorePath = resolveCronJobsStorePath(config);
949
+ const cronMerge = mergeTraderCronJobsIntoStore(cronStorePath, targetJobs);
950
+
951
+ return {
952
+ configPath,
953
+ agentsConfigured: targetAgents.length,
954
+ cronJobsAdded: cronMerge.added,
955
+ cronJobsUpdated: cronMerge.updated,
956
+ cronJobsTotal: targetJobs.length,
957
+ cronJobsStorePath: cronMerge.storePath,
958
+ cronJobsStoreWriteOk: cronMerge.wrote === true,
959
+ cronJobsStoreError: cronMerge.error,
960
+ removedLegacyCronJobs,
961
+ hooksConfigured: config.hooks.mappings.length,
962
+ isV2,
963
+ };
964
+ }
965
+
966
+ function ensureOpenResponsesEnabled(configPath = CONFIG_FILE) {
967
+ let config = {};
968
+ try {
969
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
970
+ } catch {
971
+ config = {};
972
+ }
973
+
974
+ if (!config.gateway) config.gateway = {};
975
+ if (!config.gateway.http) config.gateway.http = {};
976
+ if (!config.gateway.http.endpoints) config.gateway.http.endpoints = {};
977
+ if (!config.gateway.http.endpoints.responses) config.gateway.http.endpoints.responses = {};
978
+ config.gateway.http.endpoints.responses.enabled = true;
979
+
980
+ ensureAgentsDefaultsSchemaCompat(config);
981
+ mkdirSync(CONFIG_DIR, { recursive: true });
982
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
983
+ return configPath;
984
+ }
985
+
986
+ async function restartGateway() {
987
+ if (!commandExists("openclaw")) return { ran: false };
988
+ try {
989
+ await runCommandWithEvents("openclaw", ["gateway", "restart"]);
990
+ return { ran: true, success: true };
991
+ } catch {
992
+ return { ran: true, success: false };
993
+ }
994
+ }
995
+
996
+ function deployGatewayConfig(modeConfig) {
997
+ const gatewayDir = join(CONFIG_DIR, "gateway");
998
+ mkdirSync(gatewayDir, { recursive: true });
999
+ const destFile = join(gatewayDir, modeConfig.gatewayConfig);
1000
+ const npmRoot = getCommandOutput("npm root -g");
1001
+ if (!npmRoot) return { deployed: false, dest: destFile };
1002
+ const src = join(npmRoot, modeConfig.pluginPackage, "config", modeConfig.gatewayConfig);
1003
+ if (!existsSync(src)) return { deployed: false, dest: destFile };
1004
+ writeFileSync(destFile, readFileSync(src));
1005
+ return { deployed: true, source: src, dest: destFile };
1006
+ }
1007
+
1008
+ function expandHomePath(p) {
1009
+ if (typeof p !== "string" || !p.trim()) return null;
1010
+ let t = p.trim();
1011
+ if (t.startsWith("~")) {
1012
+ t = t === "~" || t === "~/" ? homedir() : join(homedir(), t.slice(2).replace(/^\/+/, ""));
1013
+ }
1014
+ return t;
1015
+ }
1016
+
1017
+ /**
1018
+ * OpenClaw loads HEARTBEAT.md only from the agent workspace root (default ~/.openclaw/workspace).
1019
+ * See https://docs.openclaw.ai/concepts/agent-workspace
1020
+ */
1021
+ export function resolveAgentWorkspaceDir(configPath = CONFIG_FILE) {
1022
+ let config = {};
1023
+ try {
1024
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1025
+ } catch {
1026
+ config = {};
1027
+ }
1028
+ const raw =
1029
+ (typeof config.agents?.defaults?.workspace === "string" && config.agents.defaults.workspace.trim()) ||
1030
+ (typeof config.agent?.workspace === "string" && config.agent.workspace.trim()) ||
1031
+ "";
1032
+ if (raw) {
1033
+ const expanded = expandHomePath(raw);
1034
+ if (expanded) return expanded;
1035
+ }
1036
+ return join(homedir(), ".openclaw", "workspace");
1037
+ }
1038
+
1039
+ /**
1040
+ * Copy skills/solana-trader/HEARTBEAT.md from the globally installed npm package into the workspace root.
1041
+ * Skips overwrite if a non-empty file already exists (user may have customized it).
1042
+ */
1043
+ export function deployWorkspaceHeartbeat(modeConfig) {
1044
+ const npmRoot = getCommandOutput("npm root -g");
1045
+ if (!npmRoot) return { deployed: false, reason: "npm_root_g_failed" };
1046
+ const src = join(npmRoot, modeConfig.pluginPackage, "skills", "solana-trader", "HEARTBEAT.md");
1047
+ if (!existsSync(src)) return { deployed: false, reason: "source_missing", src };
1048
+
1049
+ const workspaceDir = resolveAgentWorkspaceDir(CONFIG_FILE);
1050
+ const dest = join(workspaceDir, "HEARTBEAT.md");
1051
+ mkdirSync(workspaceDir, { recursive: true });
1052
+
1053
+ if (existsSync(dest)) {
1054
+ try {
1055
+ if (statSync(dest).size > 0) {
1056
+ return { deployed: false, skipped: true, reason: "already_exists_nonempty", dest };
1057
+ }
1058
+ } catch {
1059
+ // overwrite empty or unreadable
1060
+ }
1061
+ }
1062
+ writeFileSync(dest, readFileSync(src, "utf-8"), "utf-8");
1063
+ return { deployed: true, skipped: false, source: src, dest };
1064
+ }
1065
+
1066
+ function accessTokenEnvBase(agentId) {
1067
+ return `X_ACCESS_TOKEN_${agentId.toUpperCase().replace(/-/g, "_")}`;
1068
+ }
1069
+
1070
+ function getConsumerKeysFromWizard(wizardOpts = {}) {
1071
+ const w = wizardOpts || {};
1072
+ const ck = (typeof w.xConsumerKey === "string" ? w.xConsumerKey : "").trim() || process.env.X_CONSUMER_KEY || "";
1073
+ const cs = (typeof w.xConsumerSecret === "string" ? w.xConsumerSecret : "").trim() || process.env.X_CONSUMER_SECRET || "";
1074
+ return { consumerKey: ck, consumerSecret: cs };
1075
+ }
1076
+
1077
+ function getAccessPairForAgent(wizardOpts, agentId) {
1078
+ const w = wizardOpts || {};
1079
+ const envBase = accessTokenEnvBase(agentId);
1080
+ let at = "";
1081
+ let ats = "";
1082
+ if (agentId === "main") {
1083
+ at = (typeof w.xAccessTokenMain === "string" ? w.xAccessTokenMain : "").trim() || process.env[envBase] || "";
1084
+ ats = (typeof w.xAccessTokenMainSecret === "string" ? w.xAccessTokenMainSecret : "").trim() || process.env[`${envBase}_SECRET`] || "";
1085
+ } else if (agentId === "cto") {
1086
+ at = (typeof w.xAccessTokenCto === "string" ? w.xAccessTokenCto : "").trim() || process.env[envBase] || "";
1087
+ ats = (typeof w.xAccessTokenCtoSecret === "string" ? w.xAccessTokenCtoSecret : "").trim() || process.env[`${envBase}_SECRET`] || "";
1088
+ } else if (agentId === "intern") {
1089
+ at = (typeof w.xAccessTokenIntern === "string" ? w.xAccessTokenIntern : "").trim() || process.env[envBase] || "";
1090
+ ats = (typeof w.xAccessTokenInternSecret === "string" ? w.xAccessTokenInternSecret : "").trim() || process.env[`${envBase}_SECRET`] || "";
1091
+ } else {
1092
+ at = process.env[envBase] || "";
1093
+ ats = process.env[`${envBase}_SECRET`] || "";
1094
+ }
1095
+ return { at, ats };
1096
+ }
1097
+
1098
+ function seedXConfig(modeConfig, configPath = CONFIG_FILE, wizardOpts = {}) {
1099
+ let config = {};
1100
+ try {
1101
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1102
+ } catch {
1103
+ config = {};
1104
+ }
1105
+
1106
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
1107
+ if (!config.plugins.entries || typeof config.plugins.entries !== "object") config.plugins.entries = {};
1108
+ normalizeTraderPluginEntries(config, modeConfig.pluginId);
1109
+ normalizeTraderAllowlist(config, modeConfig.pluginId);
1110
+
1111
+ const entry = config.plugins.entries[modeConfig.pluginId];
1112
+ if (!entry || typeof entry !== "object") return { skipped: true, reason: "plugin entry not found" };
1113
+ if (!entry.config || typeof entry.config !== "object") entry.config = {};
1114
+
1115
+ const { consumerKey, consumerSecret } = getConsumerKeysFromWizard(wizardOpts);
1116
+
1117
+ if (!consumerKey || !consumerSecret) {
1118
+ return { skipped: true, reason: "X_CONSUMER_KEY and/or X_CONSUMER_SECRET not set" };
1119
+ }
1120
+
1121
+ if (!entry.config.x || typeof entry.config.x !== "object") entry.config.x = {};
1122
+ entry.config.x.consumerKey = consumerKey;
1123
+ entry.config.x.consumerSecret = consumerSecret;
1124
+
1125
+ if (!entry.config.x.profiles || typeof entry.config.x.profiles !== "object") {
1126
+ entry.config.x.profiles = {};
1127
+ }
1128
+
1129
+ const agentIds = modeConfig.pluginId === "solana-trader-v2"
1130
+ ? ["cto", "intern"]
1131
+ : ["main"];
1132
+ let profilesFound = 0;
1133
+
1134
+ for (const agentId of agentIds) {
1135
+ const { at, ats } = getAccessPairForAgent(wizardOpts, agentId);
1136
+ if (at && ats) {
1137
+ entry.config.x.profiles[agentId] = { accessToken: at, accessTokenSecret: ats };
1138
+ profilesFound++;
1139
+ }
1140
+ }
1141
+
1142
+ ensureAgentsDefaultsSchemaCompat(config);
1143
+ mkdirSync(CONFIG_DIR, { recursive: true });
1144
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1145
+ return { configured: true, consumerKey: "***", profilesFound, agentIds };
1146
+ }
1147
+
1148
+ async function verifyXCredentials(consumerKey, consumerSecret, accessToken, accessTokenSecret) {
1149
+ const { createHmac, randomBytes: rb } = await import("crypto");
1150
+ const timestamp = Math.floor(Date.now() / 1000).toString();
1151
+ const nonce = rb(16).toString("hex");
1152
+ const method = "GET";
1153
+ const url = "https://api.x.com/2/users/me";
1154
+ const params = {
1155
+ oauth_consumer_key: consumerKey,
1156
+ oauth_nonce: nonce,
1157
+ oauth_signature_method: "HMAC-SHA1",
1158
+ oauth_timestamp: timestamp,
1159
+ oauth_token: accessToken,
1160
+ oauth_version: "1.0",
1161
+ };
1162
+ const paramStr = Object.keys(params).sort().map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join("&");
1163
+ const baseStr = `${method}&${encodeURIComponent(url)}&${encodeURIComponent(paramStr)}`;
1164
+ const sigKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(accessTokenSecret)}`;
1165
+ const sig = createHmac("sha1", sigKey).update(baseStr).digest("base64");
1166
+ const authHeader = `OAuth ${Object.entries({ ...params, oauth_signature: sig }).map(([k, v]) => `${encodeURIComponent(k)}="${encodeURIComponent(v)}"`).join(", ")}`;
1167
+ const res = await fetch(url, { headers: { Authorization: authHeader }, signal: AbortSignal.timeout(10000) });
1168
+ if (!res.ok) {
1169
+ const body = await res.text().catch(() => "");
1170
+ return { ok: false, status: res.status, error: body };
1171
+ }
1172
+ const data = await res.json();
1173
+ return { ok: true, userId: data?.data?.id, username: data?.data?.username };
1174
+ }
1175
+
1176
+ /** After OAuth verify, persist X user id + handle from GET /2/users/me into plugin config (no user typing). */
1177
+ function persistXProfileIdentities(configPath, modeConfig, identities) {
1178
+ if (!Array.isArray(identities) || identities.length === 0) return { written: 0 };
1179
+ let config = {};
1180
+ try {
1181
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1182
+ } catch {
1183
+ return { written: 0 };
1184
+ }
1185
+ normalizeTraderPluginEntries(config, modeConfig.pluginId);
1186
+ normalizeTraderAllowlist(config, modeConfig.pluginId);
1187
+ const entry = config?.plugins?.entries?.[modeConfig.pluginId];
1188
+ if (!entry?.config?.x?.profiles || typeof entry.config.x.profiles !== "object") return { written: 0 };
1189
+
1190
+ let profilesTouched = 0;
1191
+ for (const row of identities) {
1192
+ const agentId = row?.agentId;
1193
+ const userId = row?.userId;
1194
+ const username = row?.username;
1195
+ if (typeof agentId !== "string" || !agentId.length) continue;
1196
+ const p = entry.config.x.profiles[agentId];
1197
+ if (!p || typeof p !== "object") continue;
1198
+ let touched = false;
1199
+ if (userId != null && String(userId).length > 0) {
1200
+ p.userId = String(userId);
1201
+ touched = true;
1202
+ }
1203
+ if (username != null && String(username).length > 0) {
1204
+ p.username = String(username);
1205
+ touched = true;
1206
+ }
1207
+ if (touched) profilesTouched++;
1208
+ }
1209
+ if (profilesTouched === 0) return { written: 0 };
1210
+ ensureAgentsDefaultsSchemaCompat(config);
1211
+ mkdirSync(CONFIG_DIR, { recursive: true });
1212
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1213
+ return { written: profilesTouched };
1214
+ }
1215
+
1216
+ function listProviderModels(provider) {
1217
+ const cmd = `openclaw models list --all --provider ${shellQuote(provider)} --json`;
1218
+ const raw = getCommandOutput(cmd);
1219
+ if (!raw) return [];
1220
+ const parsed = extractJson(raw);
1221
+ if (!parsed) return [];
1222
+ const models = Array.isArray(parsed?.models) ? parsed.models : [];
1223
+ return models
1224
+ .map((entry) => (entry && typeof entry.key === "string" ? entry.key : ""))
1225
+ .filter((id) => id.startsWith(`${provider}/`));
1226
+ }
1227
+
1228
+ function fallbackModelForProvider(provider) {
1229
+ if (provider === "anthropic") return "anthropic/claude-sonnet-4-6";
1230
+ if (provider === "openai") return "openai/gpt-5.4";
1231
+ if (provider === "openai-codex") return "openai-codex/gpt-5.4";
1232
+ if (provider === "google" || provider === "google-vertex") return "google/gemini-2.5-flash";
1233
+ if (provider === "xai") return "xai/grok-4";
1234
+ if (provider === "deepseek") return "deepseek/deepseek-chat";
1235
+ if (provider === "together") return "together/moonshotai/Kimi-K2.5";
1236
+ if (provider === "groq") return "groq/llama-4-scout-17b-16e-instruct";
1237
+ if (provider === "mistral") return "mistral/mistral-large-latest";
1238
+ if (provider === "perplexity") return "perplexity/sonar-pro";
1239
+ if (provider === "nvidia") return "nvidia/llama-3.3-70b-instruct";
1240
+ if (provider === "minimax") return "minimax/MiniMax-M2.7";
1241
+ if (provider === "moonshot") return "moonshot/kimi-k2";
1242
+ if (provider === "cerebras") return "cerebras/llama-4-scout-17b-16e-instruct";
1243
+ if (provider === "qwen") return "qwen/qwen3-235b-a22b";
1244
+ return `${provider}/default`;
1245
+ }
1246
+
1247
+ function providerEnvKey(provider) {
1248
+ if (provider === "anthropic") return "ANTHROPIC_API_KEY";
1249
+ if (provider === "openai" || provider === "openai-codex") return "OPENAI_API_KEY";
1250
+ if (provider === "openrouter") return "OPENROUTER_API_KEY";
1251
+ if (provider === "groq") return "GROQ_API_KEY";
1252
+ if (provider === "mistral") return "MISTRAL_API_KEY";
1253
+ if (provider === "google" || provider === "google-vertex") return "GEMINI_API_KEY";
1254
+ if (provider === "xai") return "XAI_API_KEY";
1255
+ if (provider === "deepseek") return "DEEPSEEK_API_KEY";
1256
+ if (provider === "together") return "TOGETHER_API_KEY";
1257
+ if (provider === "perplexity") return "PERPLEXITY_API_KEY";
1258
+ if (provider === "nvidia") return "NVIDIA_API_KEY";
1259
+ if (provider === "minimax") return "MINIMAX_API_KEY";
1260
+ if (provider === "moonshot") return "MOONSHOT_API_KEY";
1261
+ if (provider === "cerebras") return "CEREBRAS_API_KEY";
1262
+ if (provider === "qwen") return "DASHSCOPE_API_KEY";
1263
+ return "";
1264
+ }
1265
+
1266
+ function resolveLlmModelSelection(provider, requestedModel) {
1267
+ const availableModels = listProviderModels(provider);
1268
+ const warnings = [];
1269
+
1270
+ if (requestedModel) {
1271
+ if (!requestedModel.startsWith(`${provider}/`)) {
1272
+ warnings.push(`Manual model '${requestedModel}' does not match provider '${provider}'. Using provider default instead.`);
1273
+ } else if (availableModels.length === 0 || availableModels.includes(requestedModel)) {
1274
+ return { model: requestedModel, source: "manual", availableModels, warnings };
1275
+ } else {
1276
+ warnings.push(`Manual model '${requestedModel}' was not found in OpenClaw catalog for '${provider}'. Falling back to provider default.`);
1277
+ }
1278
+ }
1279
+
1280
+ if (availableModels.length > 0) {
1281
+ const chosen = choosePreferredProviderModel(provider, availableModels);
1282
+ if (chosen && availableModels.length > 1) {
1283
+ warnings.push(`Auto-selected '${chosen}' as default model (${availableModels.length} models in catalog).`);
1284
+ }
1285
+ return { model: chosen || availableModels[0], source: "provider_default", availableModels, warnings };
1286
+ }
1287
+
1288
+ warnings.push(`No discoverable model list found for provider '${provider}'. Falling back to '${fallbackModelForProvider(provider)}'.`);
1289
+ return { model: fallbackModelForProvider(provider), source: "fallback_guess", availableModels, warnings };
1290
+ }
1291
+
1292
+ /**
1293
+ * OpenClaw 2026+ expects `agents.defaults.heartbeat` as an object when `defaults` exists; plugin merges
1294
+ * sometimes drop it. We only add `heartbeat: {}` here — do NOT add `model: {}` when `model` is absent:
1295
+ * many schemas require `model.primary` whenever `model` is present; an empty model object caused Ajv
1296
+ * failures after hardening (regression for installs where the plugin stripped `model` but left defaults).
1297
+ */
1298
+ function ensureAgentsDefaultsSchemaCompat(config) {
1299
+ if (!config || typeof config !== "object") return;
1300
+ if (!config.agents || typeof config.agents !== "object") return;
1301
+ if (!config.agents.defaults || typeof config.agents.defaults !== "object") return;
1302
+ if (!config.agents.defaults.heartbeat || typeof config.agents.defaults.heartbeat !== "object") {
1303
+ config.agents.defaults.heartbeat = {};
1304
+ }
1305
+ const m = config.agents.defaults.model;
1306
+ if (m !== undefined && m !== null && (typeof m !== "object" || Array.isArray(m))) {
1307
+ delete config.agents.defaults.model;
1308
+ }
1309
+ }
1310
+
1311
+ /** Re-read config from disk and re-apply defaults shape before gateway/plugin commands that validate the file. */
1312
+ function normalizeOpenClawConfigFileShape(configPath = CONFIG_FILE) {
1313
+ let config = {};
1314
+ try {
1315
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1316
+ } catch {
1317
+ return;
1318
+ }
1319
+ ensureAgentsDefaultsSchemaCompat(config);
1320
+ try {
1321
+ mkdirSync(dirname(configPath), { recursive: true });
1322
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1323
+ } catch {
1324
+ // best effort
1325
+ }
1326
+ }
1327
+
1328
+ function configureOpenClawLlmProvider({ provider, model, credential }, configPath = CONFIG_FILE) {
1329
+ if (!provider || !credential) {
1330
+ throw new Error("LLM provider and credential are required.");
1331
+ }
1332
+ if (!model) {
1333
+ throw new Error("LLM model could not be resolved for the selected provider.");
1334
+ }
1335
+ if (!model.startsWith(`${provider}/`)) {
1336
+ throw new Error(`Selected model '${model}' does not match provider '${provider}'.`);
1337
+ }
1338
+
1339
+ let config = {};
1340
+ try {
1341
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1342
+ } catch {
1343
+ config = {};
1344
+ }
1345
+
1346
+ const envKey = providerEnvKey(provider);
1347
+ if (!envKey) {
1348
+ throw new Error(
1349
+ `Provider '${provider}' is not supported by quick API-key setup in this wizard yet. Use a supported provider.`,
1350
+ );
1351
+ }
1352
+
1353
+ if (!config.env || typeof config.env !== "object") config.env = {};
1354
+ config.env[envKey] = credential;
1355
+
1356
+ // Clean stale/broken provider objects from previous buggy writes.
1357
+ if (config.models && config.models.providers && config.models.providers[provider]) {
1358
+ delete config.models.providers[provider];
1359
+ if (Object.keys(config.models.providers).length === 0) {
1360
+ delete config.models.providers;
1361
+ }
1362
+ if (Object.keys(config.models).length === 0) {
1363
+ delete config.models;
1364
+ }
1365
+ }
1366
+
1367
+ if (!config.agents) config.agents = {};
1368
+ if (!config.agents.defaults) config.agents.defaults = {};
1369
+ ensureAgentsDefaultsSchemaCompat(config);
1370
+ if (!config.agents.defaults.model || typeof config.agents.defaults.model !== "object") {
1371
+ config.agents.defaults.model = {};
1372
+ }
1373
+ config.agents.defaults.model.primary = model;
1374
+
1375
+ mkdirSync(CONFIG_DIR, { recursive: true });
1376
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1377
+ return { configPath, provider, model };
1378
+ }
1379
+
1380
+ function verifyInstallation(modeConfig, apiKey) {
1381
+ const gatewayFile = join(CONFIG_DIR, "gateway", modeConfig.gatewayConfig);
1382
+ let llmConfigured = false;
1383
+ let pluginActive = false;
1384
+ try {
1385
+ const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
1386
+ const primaryModel = config?.agents?.defaults?.model?.primary;
1387
+ llmConfigured = typeof primaryModel === "string" && primaryModel.length > 0;
1388
+ } catch {
1389
+ llmConfigured = false;
1390
+ }
1391
+ if (commandExists("openclaw")) {
1392
+ const pluginList = getCommandOutput("openclaw plugins list") || "";
1393
+ pluginActive = pluginList.toLowerCase().includes(modeConfig.pluginId.toLowerCase());
1394
+ }
1395
+ let heartbeatConfigured = false;
1396
+ let cronConfigured = false;
1397
+ try {
1398
+ const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
1399
+ const agentsList = config?.agents?.list;
1400
+ if (Array.isArray(agentsList)) {
1401
+ heartbeatConfigured = agentsList.some(a => a.heartbeat && a.heartbeat.every);
1402
+ }
1403
+ cronConfigured = config?.cron?.enabled === true;
1404
+ } catch {
1405
+ }
1406
+
1407
+ const persistSnap = getLinuxGatewayPersistenceSnapshot();
1408
+ let persistOk = true;
1409
+ let persistNote = "not Linux / WSL or loginctl unavailable";
1410
+ if (persistSnap.eligible) {
1411
+ persistOk = persistSnap.linger === true;
1412
+ persistNote =
1413
+ persistSnap.linger === true
1414
+ ? "linger enabled"
1415
+ : "run: traderclaw gateway ensure-persistent (or sudo loginctl enable-linger $USER)";
1416
+ }
1417
+
1418
+ const workspaceRoot = resolveAgentWorkspaceDir();
1419
+ const heartbeatInWorkspace = existsSync(join(workspaceRoot, "HEARTBEAT.md"));
1420
+
1421
+ return [
1422
+ { label: "OpenClaw platform", ok: commandExists("openclaw"), note: "not in PATH" },
1423
+ { label: `Trading CLI (${modeConfig.cliName})`, ok: commandExists(modeConfig.cliName), note: "not in PATH" },
1424
+ { label: `OpenClaw plugin (${modeConfig.pluginId})`, ok: pluginActive, note: "not installed/enabled" },
1425
+ { label: "Configuration file", ok: existsSync(CONFIG_FILE), note: "not created" },
1426
+ { label: "LLM provider configured", ok: llmConfigured, note: "missing model provider credential" },
1427
+ { label: "Gateway configuration", ok: existsSync(gatewayFile), note: "not found" },
1428
+ { label: "Heartbeat scheduling", ok: heartbeatConfigured, note: "agent will not wake autonomously" },
1429
+ { label: "Cron jobs configured", ok: cronConfigured, note: "scheduled maintenance jobs missing" },
1430
+ { label: "API key configured", ok: !!apiKey, note: "needs setup" },
1431
+ {
1432
+ label: "Gateway survives SSH (systemd linger)",
1433
+ ok: !persistSnap.eligible || persistOk,
1434
+ note: persistNote,
1435
+ },
1436
+ {
1437
+ label: "HEARTBEAT.md in workspace root",
1438
+ ok: heartbeatInWorkspace,
1439
+ note: heartbeatInWorkspace ? workspaceRoot : `expected ${join(workspaceRoot, "HEARTBEAT.md")}`,
1440
+ },
1441
+ ];
1442
+ }
1443
+
1444
+ function nowIso() {
1445
+ return new Date().toISOString();
1446
+ }
1447
+
1448
+ const URL_REGEX = /https?:\/\/[^\s"')]+/g;
1449
+ function firstUrl(text = "") {
1450
+ const found = text.match(URL_REGEX);
1451
+ return found?.[0] || null;
1452
+ }
1453
+
1454
+ function normalizeLane(input) {
1455
+ return input === "event-driven" ? "event-driven" : "quick-local";
1456
+ }
1457
+
1458
+ export class InstallerStepEngine {
1459
+ constructor(modeConfig, options = {}, hooks = {}) {
1460
+ this.modeConfig = modeConfig;
1461
+ this.options = {
1462
+ lane: normalizeLane(options.lane),
1463
+ llmProvider: options.llmProvider || "",
1464
+ llmModel: options.llmModel || "",
1465
+ llmCredential: options.llmCredential || "",
1466
+ apiKey: options.apiKey || "",
1467
+ orchestratorUrl: options.orchestratorUrl || "https://api.traderclaw.ai",
1468
+ gatewayBaseUrl: options.gatewayBaseUrl || "",
1469
+ gatewayToken: options.gatewayToken || "",
1470
+ enableTelegram: options.enableTelegram === true,
1471
+ telegramToken: options.telegramToken || "",
1472
+ autoInstallDeps: options.autoInstallDeps !== false,
1473
+ skipPreflight: options.skipPreflight === true,
1474
+ skipInstallOpenClaw: options.skipInstallOpenClaw === true,
1475
+ skipInstallPlugin: options.skipInstallPlugin === true,
1476
+ skipTailscale: options.skipTailscale === true,
1477
+ skipGatewayBootstrap: options.skipGatewayBootstrap === true,
1478
+ skipGatewayConfig: options.skipGatewayConfig === true,
1479
+ // Wizard / CLI — must be preserved for seedXConfig
1480
+ xConsumerKey: typeof options.xConsumerKey === "string" ? options.xConsumerKey : "",
1481
+ xConsumerSecret: typeof options.xConsumerSecret === "string" ? options.xConsumerSecret : "",
1482
+ xAccessTokenMain: typeof options.xAccessTokenMain === "string" ? options.xAccessTokenMain : "",
1483
+ xAccessTokenMainSecret: typeof options.xAccessTokenMainSecret === "string" ? options.xAccessTokenMainSecret : "",
1484
+ xAccessTokenCto: typeof options.xAccessTokenCto === "string" ? options.xAccessTokenCto : "",
1485
+ xAccessTokenCtoSecret: typeof options.xAccessTokenCtoSecret === "string" ? options.xAccessTokenCtoSecret : "",
1486
+ xAccessTokenIntern: typeof options.xAccessTokenIntern === "string" ? options.xAccessTokenIntern : "",
1487
+ xAccessTokenInternSecret: typeof options.xAccessTokenInternSecret === "string" ? options.xAccessTokenInternSecret : "",
1488
+ };
1489
+ this.hooks = {
1490
+ onStepEvent: typeof hooks.onStepEvent === "function" ? hooks.onStepEvent : () => {},
1491
+ onLog: typeof hooks.onLog === "function" ? hooks.onLog : () => {},
1492
+ };
1493
+ this.state = {
1494
+ startedAt: null,
1495
+ completedAt: null,
1496
+ status: "idle",
1497
+ errors: [],
1498
+ detected: { funnelUrl: null, tailscaleApprovalUrl: null },
1499
+ stepResults: [],
1500
+ verifyChecks: [],
1501
+ setupHandoff: null,
1502
+ autoRecovery: {
1503
+ gatewayModeRecoveryAttempted: false,
1504
+ gatewayModeRecoverySucceeded: false,
1505
+ backupPath: null,
1506
+ },
1507
+ };
1508
+ }
1509
+
1510
+ async runWithPrivilegeGuidance(stepId, cmd, args = [], customLines = []) {
1511
+ try {
1512
+ return await runCommandWithEvents(cmd, args, {
1513
+ onEvent: (evt) => this.emitLog(stepId, evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1514
+ });
1515
+ } catch (err) {
1516
+ if (isPrivilegeError(err)) {
1517
+ throw new Error(privilegeRemediationMessage(cmd, args, customLines));
1518
+ }
1519
+ throw err;
1520
+ }
1521
+ }
1522
+
1523
+ emitStep(stepId, status, detail = "") {
1524
+ this.hooks.onStepEvent({ at: nowIso(), stepId, status, detail });
1525
+ }
1526
+
1527
+ emitLog(stepId, level, text, urls = []) {
1528
+ const clean = typeof text === "string" ? stripAnsi(text) : text;
1529
+ this.hooks.onLog({ at: nowIso(), stepId, level, text: clean, urls });
1530
+ }
1531
+
1532
+ async runStep(stepId, title, handler) {
1533
+ this.emitStep(stepId, "in_progress", title);
1534
+ const startedAt = nowIso();
1535
+ try {
1536
+ const result = await handler();
1537
+ this.state.stepResults.push({ stepId, title, status: "completed", startedAt, completedAt: nowIso(), result });
1538
+ this.emitStep(stepId, "completed", title);
1539
+ return result;
1540
+ } catch (err) {
1541
+ const detail = stripAnsi(err?.message || String(err));
1542
+ this.state.stepResults.push({ stepId, title, status: "failed", startedAt, completedAt: nowIso(), error: detail });
1543
+ this.state.errors.push({ stepId, error: detail });
1544
+ this.emitStep(stepId, "failed", detail);
1545
+ throw err;
1546
+ }
1547
+ }
1548
+
1549
+ async ensureTailscale() {
1550
+ if (commandExists("tailscale")) return { installed: true, alreadyInstalled: true };
1551
+ if (!this.options.autoInstallDeps) throw new Error("tailscale missing and auto-install disabled");
1552
+
1553
+ if (!isRootUser() && !canUseSudoWithoutPrompt()) {
1554
+ throw new Error(
1555
+ [
1556
+ "Tailscale is not installed and the installer cannot elevate privileges automatically.",
1557
+ "Run this command in your terminal, then click Start Installation again:",
1558
+ "sudo bash -lc 'curl -fsSL https://tailscale.com/install.sh | sh'",
1559
+ ].join("\n"),
1560
+ );
1561
+ }
1562
+
1563
+ try {
1564
+ if (isRootUser()) {
1565
+ await this.runWithPrivilegeGuidance("tailscale", "bash", ["-lc", "curl -fsSL https://tailscale.com/install.sh | sh"]);
1566
+ } else {
1567
+ await this.runWithPrivilegeGuidance("tailscale", "sudo", ["bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh"]);
1568
+ }
1569
+ } catch (err) {
1570
+ const message = `${err?.message || ""} ${err?.stderr || ""}`.toLowerCase();
1571
+ if (message.includes("sudo") || message.includes("password")) {
1572
+ throw new Error(
1573
+ [
1574
+ "Tailscale installation requires terminal sudo approval.",
1575
+ "Run this command in your terminal, then click Start Installation again:",
1576
+ "sudo bash -lc 'curl -fsSL https://tailscale.com/install.sh | sh'",
1577
+ ].join("\n"),
1578
+ );
1579
+ }
1580
+ throw err;
1581
+ }
1582
+
1583
+ return { installed: true, alreadyInstalled: false };
1584
+ }
1585
+
1586
+ async runTailscaleUp() {
1587
+ try {
1588
+ const result = await runCommandWithEvents("tailscale", ["up"], {
1589
+ onEvent: (evt) => {
1590
+ const url = firstUrl(evt.text);
1591
+ if (url && !this.state.detected.tailscaleApprovalUrl) this.state.detected.tailscaleApprovalUrl = url;
1592
+ this.emitLog("tailscale_up", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []);
1593
+ },
1594
+ });
1595
+ return { ok: true, approvalUrl: this.state.detected.tailscaleApprovalUrl, urls: result.urls || [] };
1596
+ } catch (err) {
1597
+ const details = `${err?.stderr || ""}\n${err?.stdout || ""}\n${err?.message || ""}`.toLowerCase();
1598
+ if (
1599
+ details.includes("access denied")
1600
+ || details.includes("checkprefs")
1601
+ || details.includes("prefs write access denied")
1602
+ ) {
1603
+ throw new Error(tailscalePermissionRemediation());
1604
+ }
1605
+ throw err;
1606
+ }
1607
+ }
1608
+
1609
+ async runFunnel() {
1610
+ try {
1611
+ await this.runWithPrivilegeGuidance("funnel", "tailscale", ["funnel", "--bg", "18789"]);
1612
+ } catch (err) {
1613
+ const details = `${err?.stderr || ""}\n${err?.stdout || ""}\n${err?.message || ""}`.toLowerCase();
1614
+ if (details.includes("access denied") || details.includes("operator")) {
1615
+ throw new Error(tailscalePermissionRemediation());
1616
+ }
1617
+ throw err;
1618
+ }
1619
+ const statusOut = getCommandOutput("tailscale funnel status") || "";
1620
+ const funnelUrl = firstUrl(statusOut);
1621
+ if (funnelUrl) this.state.detected.funnelUrl = funnelUrl;
1622
+ this.emitLog("funnel", "info", statusOut);
1623
+ return { funnelUrl };
1624
+ }
1625
+
1626
+ readGatewayStatusSnapshot() {
1627
+ const raw = getCommandOutput("openclaw gateway status --json || true");
1628
+ if (!raw) return null;
1629
+ try {
1630
+ return JSON.parse(raw);
1631
+ } catch {
1632
+ return null;
1633
+ }
1634
+ }
1635
+
1636
+ isGatewayHealthy(statusJson) {
1637
+ if (!statusJson || typeof statusJson !== "object") return false;
1638
+ const serviceStatus = statusJson?.service?.runtime?.status;
1639
+ const rpcOk = statusJson?.rpc?.ok === true;
1640
+ return serviceStatus === "running" && rpcOk;
1641
+ }
1642
+
1643
+ async tryAutoRecoverGatewayMode(stepId) {
1644
+ if (this.state.autoRecovery.gatewayModeRecoveryAttempted) {
1645
+ return { attempted: true, success: false, reason: "already_attempted" };
1646
+ }
1647
+ this.state.autoRecovery.gatewayModeRecoveryAttempted = true;
1648
+
1649
+ let config = {};
1650
+ let rawOriginal = "{}\n";
1651
+ try {
1652
+ rawOriginal = readFileSync(CONFIG_FILE, "utf-8");
1653
+ config = JSON.parse(rawOriginal);
1654
+ } catch {
1655
+ config = {};
1656
+ }
1657
+
1658
+ if (!config.gateway) config.gateway = {};
1659
+ const changed = [];
1660
+ if (!config.gateway.mode) {
1661
+ config.gateway.mode = "local";
1662
+ changed.push("gateway.mode=local");
1663
+ }
1664
+ if (!config.gateway.bind) {
1665
+ config.gateway.bind = "loopback";
1666
+ changed.push("gateway.bind=loopback");
1667
+ }
1668
+ if (!Number.isInteger(config.gateway.port)) {
1669
+ config.gateway.port = 18789;
1670
+ changed.push("gateway.port=18789");
1671
+ }
1672
+
1673
+ if (changed.length === 0) {
1674
+ return { attempted: true, success: false, reason: "no_missing_gateway_defaults" };
1675
+ }
1676
+
1677
+ ensureAgentsDefaultsSchemaCompat(config);
1678
+ mkdirSync(CONFIG_DIR, { recursive: true });
1679
+ const backupPath = `${CONFIG_FILE}.bak.${Date.now()}`;
1680
+ writeFileSync(backupPath, rawOriginal, "utf-8");
1681
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
1682
+ this.state.autoRecovery.backupPath = backupPath;
1683
+ this.emitLog(stepId, "warn", `Auto-recovery: applied ${changed.join(", ")} with backup at ${backupPath}`);
1684
+
1685
+ try {
1686
+ await this.runWithPrivilegeGuidance(stepId, "openclaw", ["gateway", "stop"]);
1687
+ } catch {
1688
+ // best effort stop
1689
+ }
1690
+ await this.runWithPrivilegeGuidance(stepId, "openclaw", ["gateway", "install"]);
1691
+ await this.runWithPrivilegeGuidance(stepId, "openclaw", ["gateway", "restart"]);
1692
+
1693
+ const status = this.readGatewayStatusSnapshot();
1694
+ const healthy = this.isGatewayHealthy(status);
1695
+ if (healthy) {
1696
+ this.state.autoRecovery.gatewayModeRecoverySucceeded = true;
1697
+ this.emitLog(stepId, "info", "Auto-recovery succeeded: gateway is healthy after restart.");
1698
+ return { attempted: true, success: true, backupPath };
1699
+ }
1700
+ return { attempted: true, success: false, backupPath, reason: "gateway_not_healthy_after_recovery" };
1701
+ }
1702
+
1703
+ async runTelegramStep() {
1704
+ if (!this.options.telegramToken) {
1705
+ throw new Error(
1706
+ "Telegram token is required for this installer flow. Add your bot token in the wizard and start again.",
1707
+ );
1708
+ }
1709
+ await runCommandWithEvents("openclaw", ["plugins", "enable", "telegram"]);
1710
+ await runCommandWithEvents("openclaw", ["channels", "add", "--channel", "telegram", "--token", this.options.telegramToken]);
1711
+ await runCommandWithEvents("openclaw", ["channels", "status", "--probe"]);
1712
+ const policy = ensureTelegramGroupPolicyOpenForWizard();
1713
+ if (policy.changed) {
1714
+ this.emitLog(
1715
+ "telegram_required",
1716
+ "info",
1717
+ "Set channels.telegram.groupPolicy=open (no sender allowlist yet) to avoid Doctor allowlist warnings on gateway restart. Tighten groupAllowFrom later if you use groups.",
1718
+ );
1719
+ }
1720
+ return { configured: true };
1721
+ }
1722
+
1723
+ async configureLlmStep() {
1724
+ const provider = String(this.options.llmProvider || "").trim();
1725
+ const requestedModel = String(this.options.llmModel || "").trim();
1726
+ const credential = String(this.options.llmCredential || "").trim();
1727
+ if (!provider || !credential) {
1728
+ throw new Error(
1729
+ "Missing required LLM settings. Select provider and provide credential in the wizard before starting installation.",
1730
+ );
1731
+ }
1732
+ if (!commandExists("openclaw")) {
1733
+ throw new Error("OpenClaw is not available yet. Install step must complete before LLM configuration.");
1734
+ }
1735
+
1736
+ const selection = resolveLlmModelSelection(provider, requestedModel);
1737
+ for (const msg of selection.warnings) {
1738
+ this.emitLog("configure_llm", "warn", msg);
1739
+ }
1740
+ const model = selection.model;
1741
+
1742
+ const saved = configureOpenClawLlmProvider({ provider, model, credential });
1743
+ this.emitLog("configure_llm", "info", `Configured OpenClaw model primary=${model}`);
1744
+
1745
+ await runCommandWithEvents("openclaw", ["config", "validate"], {
1746
+ onEvent: (evt) => this.emitLog("configure_llm", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1747
+ });
1748
+
1749
+ try {
1750
+ await runCommandWithEvents("openclaw", ["models", "status", "--check", "--probe-provider", provider], {
1751
+ onEvent: (evt) => this.emitLog("configure_llm", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1752
+ });
1753
+ } catch (err) {
1754
+ const details = `${err?.stderr || ""}\n${err?.stdout || ""}\n${err?.message || ""}`.trim();
1755
+ throw new Error(
1756
+ `LLM provider validation failed for '${provider}'. Check credential/model and retry.\n${details}`,
1757
+ );
1758
+ }
1759
+
1760
+ return { configured: true, provider, model, configPath: saved.configPath };
1761
+ }
1762
+
1763
+ buildSetupHandoff() {
1764
+ const args = ["setup", "--url", this.options.orchestratorUrl || "https://api.traderclaw.ai"];
1765
+ if (this.options.lane !== "event-driven") {
1766
+ args.push("--skip-gateway-registration");
1767
+ }
1768
+ const gatewayBaseUrl = this.options.gatewayBaseUrl || this.state.detected.funnelUrl || "";
1769
+ if (this.options.lane === "event-driven" && gatewayBaseUrl) {
1770
+ args.push("--gateway-base-url", gatewayBaseUrl);
1771
+ }
1772
+
1773
+ const command = [this.modeConfig.cliName, ...args].join(" ");
1774
+ const docs =
1775
+ "https://docs.traderclaw.ai/docs/installation#troubleshooting-session-expired-auth-errors-or-the-agent-logged-out";
1776
+ return {
1777
+ pending: true,
1778
+ command,
1779
+ title: "Ready to launch your agentic trading desk",
1780
+ message:
1781
+ "Core install is complete. Final setup is intentionally handed off to your VPS shell so sensitive wallet prompts stay private. " +
1782
+ "After setup, if the bot reports wallet proof / session errors: configure TRADERCLAW_WALLET_PRIVATE_KEY for the OpenClaw gateway service (systemd), not only in SSH — see " +
1783
+ docs,
1784
+ hint:
1785
+ "Run the command in terminal, answer setup prompts, then restart gateway. If Telegram startup checks all fail, open the troubleshooting link in the message above.",
1786
+ restartCommand: "openclaw gateway restart",
1787
+ };
1788
+ }
1789
+
1790
+ async runAll() {
1791
+ this.state.status = "running";
1792
+ this.state.startedAt = nowIso();
1793
+ try {
1794
+ if (!this.options.skipPreflight) {
1795
+ await this.runStep("preflight", "Checking prerequisites", async () => {
1796
+ if (!commandExists("node") || !commandExists("npm")) throw new Error("node and npm are required");
1797
+ return { node: true, npm: true, openclaw: commandExists("openclaw"), tailscale: commandExists("tailscale") };
1798
+ });
1799
+ }
1800
+
1801
+ if (!this.options.skipInstallOpenClaw) {
1802
+ await this.runStep("install_openclaw", "Installing OpenClaw platform", async () => installOpenClawPlatform());
1803
+ }
1804
+ await this.runStep("configure_llm", "Configuring required OpenClaw LLM provider", async () => this.configureLlmStep());
1805
+ if (!this.options.skipInstallPlugin) {
1806
+ await this.runStep("install_plugin_package", "Installing TraderClaw CLI package", async () =>
1807
+ installPlugin(
1808
+ this.modeConfig,
1809
+ (evt) => this.emitLog("install_plugin_package", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1810
+ ));
1811
+ await this.runStep(
1812
+ "activate_openclaw_plugin",
1813
+ "Installing and enabling TraderClaw inside OpenClaw",
1814
+ async () =>
1815
+ installAndEnableOpenClawPlugin(
1816
+ this.modeConfig,
1817
+ (evt) => this.emitLog("activate_openclaw_plugin", evt.type === "stderr" ? "warn" : "info", evt.text, evt.urls || []),
1818
+ this.options.orchestratorUrl,
1819
+ ),
1820
+ );
1821
+ }
1822
+ if (!this.options.skipTailscale) {
1823
+ await this.runStep("tailscale_install", "Ensuring Tailscale is installed", async () => this.ensureTailscale());
1824
+ await this.runStep("tailscale_up", "Connecting Tailscale", async () => this.runTailscaleUp());
1825
+ }
1826
+ if (!this.options.skipGatewayBootstrap) {
1827
+ await this.runStep("openclaw_config_validate", "Validating OpenClaw config (with plugins)", async () => {
1828
+ normalizeOpenClawConfigFileShape(CONFIG_FILE);
1829
+ try {
1830
+ await this.runWithPrivilegeGuidance("openclaw_config_validate", "openclaw", ["config", "validate"]);
1831
+ } catch (err) {
1832
+ const blob = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`;
1833
+ if (isOpenClawConfigSchemaFailure(blob)) {
1834
+ throw new Error(gatewayConfigValidationRemediation());
1835
+ }
1836
+ throw err;
1837
+ }
1838
+ return { ok: true };
1839
+ });
1840
+ await this.runStep("gateway_bootstrap", "Starting OpenClaw gateway and Funnel", async () => {
1841
+ try {
1842
+ normalizeOpenClawConfigFileShape(CONFIG_FILE);
1843
+ await this.runWithPrivilegeGuidance("gateway_bootstrap", "openclaw", ["gateway", "install"]);
1844
+ await this.runWithPrivilegeGuidance("gateway_bootstrap", "openclaw", ["gateway", "restart"]);
1845
+ return this.runFunnel();
1846
+ } catch (err) {
1847
+ const text = `${err?.message || ""}\n${err?.stderr || ""}\n${err?.stdout || ""}`.toLowerCase();
1848
+ const gatewayModeUnset = text.includes("gateway.mode=local") && text.includes("current: unset");
1849
+ if (
1850
+ text.includes("gateway restart timed out")
1851
+ || text.includes("timed out after 60s waiting for health checks")
1852
+ || text.includes("waiting for gateway port")
1853
+ || gatewayModeUnset
1854
+ ) {
1855
+ const recovered = await this.tryAutoRecoverGatewayMode("gateway_bootstrap");
1856
+ if (recovered.success) {
1857
+ return this.runFunnel();
1858
+ }
1859
+ if (gatewayModeUnset) {
1860
+ throw new Error(gatewayModeUnsetRemediation());
1861
+ }
1862
+ throw new Error(gatewayTimeoutRemediation());
1863
+ }
1864
+ if (isOpenClawConfigSchemaFailure(text)) {
1865
+ throw new Error(gatewayConfigValidationRemediation());
1866
+ }
1867
+ throw err;
1868
+ }
1869
+ });
1870
+ }
1871
+
1872
+ if (!this.options.skipGatewayBootstrap) {
1873
+ await this.runStep("gateway_persistence", "SSH-safe gateway (systemd user linger)", async () => {
1874
+ const { ensureLinuxGatewayPersistence } = await import("./gateway-persistence-linux.mjs");
1875
+ return ensureLinuxGatewayPersistence({
1876
+ emitLog: (level, text) => this.emitLog("gateway_persistence", level, text),
1877
+ runPrivileged: (cmd, args) => this.runWithPrivilegeGuidance("gateway_persistence", cmd, args),
1878
+ });
1879
+ });
1880
+ }
1881
+
1882
+ await this.runStep("enable_responses", "Enabling /v1/responses endpoint", async () => {
1883
+ const configPath = ensureOpenResponsesEnabled(CONFIG_FILE);
1884
+ const restart = await restartGateway();
1885
+ return { configPath, restart };
1886
+ });
1887
+
1888
+ await this.runStep("gateway_scheduling", "Configuring heartbeat and cron schedules", async () => {
1889
+ const result = configureGatewayScheduling(this.modeConfig, CONFIG_FILE);
1890
+ this.emitLog("gateway_scheduling", "info", `Agents configured: ${result.agentsConfigured}`);
1891
+ if (result.cronJobsStoreWriteOk) {
1892
+ this.emitLog(
1893
+ "gateway_scheduling",
1894
+ "info",
1895
+ `Cron store: ${result.cronJobsStorePath} (${result.cronJobsTotal} TraderClaw jobs; +${result.cronJobsAdded} new, ~${result.cronJobsUpdated} updated).`,
1896
+ );
1897
+ } else if (result.cronJobsStoreError) {
1898
+ this.emitLog(
1899
+ "gateway_scheduling",
1900
+ "warn",
1901
+ `Cron store not updated (${result.cronJobsStorePath}): ${result.cronJobsStoreError}`,
1902
+ );
1903
+ } else {
1904
+ this.emitLog("gateway_scheduling", "warn", "Cron store write did not complete; check permissions and disk space.");
1905
+ }
1906
+ if (result.removedLegacyCronJobs) {
1907
+ this.emitLog("gateway_scheduling", "warn", "Removed legacy 'cron.jobs' from openclaw.json to keep config validation compatible.");
1908
+ }
1909
+ this.emitLog("gateway_scheduling", "info", `Webhook hooks: ${result.hooksConfigured}`);
1910
+ const restart = await restartGateway();
1911
+ return { ...result, restart };
1912
+ });
1913
+
1914
+ await this.runStep("workspace_heartbeat", "Installing HEARTBEAT.md into agent workspace", async () => {
1915
+ const result = deployWorkspaceHeartbeat(this.modeConfig);
1916
+ if (result.deployed) {
1917
+ this.emitLog("workspace_heartbeat", "info", `Installed TraderClaw HEARTBEAT.md → ${result.dest}`);
1918
+ } else if (result.skipped) {
1919
+ this.emitLog(
1920
+ "workspace_heartbeat",
1921
+ "info",
1922
+ `HEARTBEAT.md already present at ${result.dest} — not overwriting (edit or delete to replace).`,
1923
+ );
1924
+ } else {
1925
+ this.emitLog(
1926
+ "workspace_heartbeat",
1927
+ "warn",
1928
+ `Could not install HEARTBEAT.md automatically (${result.reason || "unknown"})${result.src ? `. Expected: ${result.src}` : ""}`,
1929
+ );
1930
+ }
1931
+ return result;
1932
+ });
1933
+
1934
+ await this.runStep("setup_handoff", "Preparing secure setup handoff", async () => {
1935
+ const handoff = this.buildSetupHandoff();
1936
+ this.state.setupHandoff = handoff;
1937
+ this.emitLog("setup_handoff", "info", handoff.title);
1938
+ this.emitLog("setup_handoff", "info", handoff.message);
1939
+ this.emitLog("setup_handoff", "info", `Run in VPS shell: ${handoff.command}`);
1940
+ this.emitLog("setup_handoff", "info", `Then run: ${handoff.restartCommand}`);
1941
+ return handoff;
1942
+ });
1943
+
1944
+ if (!this.options.skipGatewayConfig) {
1945
+ await this.runStep("gateway_config", "Deploying gateway config and restarting", async () => {
1946
+ const deploy = deployGatewayConfig(this.modeConfig);
1947
+ const restart = await restartGateway();
1948
+ return { deploy, restart };
1949
+ });
1950
+ }
1951
+
1952
+ await this.runStep("x_credentials", "Configuring X/Twitter credentials", async () => {
1953
+ const result = seedXConfig(this.modeConfig, CONFIG_FILE, this.options);
1954
+ if (result.skipped) {
1955
+ this.emitLog("x_credentials", "warn", `X setup skipped: ${result.reason}. Set X_CONSUMER_KEY, X_CONSUMER_SECRET, and per-agent X_ACCESS_TOKEN_<AGENT_ID> / X_ACCESS_TOKEN_<AGENT_ID>_SECRET env vars to enable.`);
1956
+ return result;
1957
+ }
1958
+ this.emitLog("x_credentials", "info", `X credentials configured. Profiles found: ${result.profilesFound}/${result.agentIds.length}`);
1959
+ if (result.profilesFound < result.agentIds.length) {
1960
+ const missing = result.agentIds.filter((id) => {
1961
+ const { at, ats } = getAccessPairForAgent(this.options, id);
1962
+ return !at || !ats;
1963
+ });
1964
+ this.emitLog("x_credentials", "warn", `Missing X profiles for: ${missing.join(", ")}. Set tokens in the wizard or X_ACCESS_TOKEN_<AGENT_ID> / X_ACCESS_TOKEN_<AGENT_ID>_SECRET env vars.`);
1965
+ }
1966
+ const { consumerKey, consumerSecret } = getConsumerKeysFromWizard(this.options);
1967
+ const verified = [];
1968
+ const identitiesToPersist = [];
1969
+ for (const agentId of result.agentIds) {
1970
+ const { at, ats } = getAccessPairForAgent(this.options, agentId);
1971
+ if (at && ats) {
1972
+ try {
1973
+ const check = await verifyXCredentials(consumerKey, consumerSecret, at, ats);
1974
+ if (check.ok) {
1975
+ this.emitLog("x_credentials", "info", `Verified X profile '${agentId}': @${check.username} (${check.userId})`);
1976
+ verified.push({ agentId, username: check.username, userId: check.userId });
1977
+ identitiesToPersist.push({ agentId, userId: check.userId, username: check.username });
1978
+ } else {
1979
+ this.emitLog("x_credentials", "warn", `X credential verification failed for '${agentId}': HTTP ${check.status}`);
1980
+ }
1981
+ } catch (err) {
1982
+ this.emitLog("x_credentials", "warn", `X credential verification error for '${agentId}': ${err?.message || String(err)}`);
1983
+ }
1984
+ }
1985
+ }
1986
+ if (identitiesToPersist.length > 0) {
1987
+ const persisted = persistXProfileIdentities(CONFIG_FILE, this.modeConfig, identitiesToPersist);
1988
+ if (persisted.written > 0) {
1989
+ this.emitLog("x_credentials", "info", `Saved X user id and username to openclaw.json for ${persisted.written} profile(s) (from API, not manual entry).`);
1990
+ }
1991
+ }
1992
+ return { ...result, verified };
1993
+ });
1994
+
1995
+ await this.runStep("telegram_required", "Configuring required Telegram channel", async () => this.runTelegramStep());
1996
+ await this.runStep("verify", "Verifying installation", async () => {
1997
+ const checks = verifyInstallation(this.modeConfig, this.options.apiKey);
1998
+ this.state.verifyChecks = checks;
1999
+ return { checks };
2000
+ });
2001
+
2002
+ this.state.status = "completed";
2003
+ this.state.completedAt = nowIso();
2004
+ return this.state;
2005
+ } catch (err) {
2006
+ this.state.status = "failed";
2007
+ this.state.completedAt = nowIso();
2008
+ this.state.errors.push({ stepId: "runtime", error: err?.message || String(err) });
2009
+ return this.state;
2010
+ }
2011
+ }
2012
+ }
2013
+
2014
+ export function assertWizardXCredentials(modeConfig, options = {}) {
2015
+ const t = (s) => (typeof s === "string" ? s.trim() : "");
2016
+ const o = options || {};
2017
+ const need =
2018
+ modeConfig.pluginId === "solana-trader-v2"
2019
+ ? ["xConsumerKey", "xConsumerSecret", "xAccessTokenCto", "xAccessTokenCtoSecret", "xAccessTokenIntern", "xAccessTokenInternSecret"]
2020
+ : ["xConsumerKey", "xConsumerSecret", "xAccessTokenMain", "xAccessTokenMainSecret"];
2021
+ const filled = need.filter((k) => t(o[k])).length;
2022
+ if (filled === 0) return null;
2023
+ if (filled === need.length) return null;
2024
+ return `X/Twitter credentials are optional: leave all ${need.length} fields blank, or fill every field (OAuth app key/secret plus user access token and secret for each profile).`;
2025
+ }
2026
+
2027
+ export function createInstallerStepEngine(modeConfig, options = {}, hooks = {}) {
2028
+ return new InstallerStepEngine(modeConfig, options, hooks);
2029
+ }