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.
- package/bin/cli.ts +629 -0
- package/bin/gateway-persistence-linux.mjs +275 -0
- package/bin/installer-step-engine.mjs +2029 -0
- package/bin/llm-model-preference.mjs +229 -0
- package/bin/openclaw-trader.mjs +3148 -0
- package/bin/resolve-plugin-root.mjs +37 -0
- package/bin/traderclaw.cjs +13 -0
- package/package.json +34 -0
- package/scripts/sync-bin.mjs +30 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux VPS: keep OpenClaw gateway running after SSH disconnect (systemd user + loginctl linger)
|
|
3
|
+
* and optionally persist TRADERCLAW_WALLET_PRIVATE_KEY for the gateway via EnvironmentFile + drop-in.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync, spawn } from "child_process";
|
|
7
|
+
import { chmodSync, mkdirSync, writeFileSync } from "fs";
|
|
8
|
+
import { homedir, userInfo } from "os";
|
|
9
|
+
import { basename, join } from "path";
|
|
10
|
+
|
|
11
|
+
const WALLET_ENV_BASENAME = "traderclaw-gateway-wallet.env";
|
|
12
|
+
const DROPIN_NAME = "traderclaw-wallet.conf";
|
|
13
|
+
const DEFAULT_UNIT = "openclaw-gateway.service";
|
|
14
|
+
|
|
15
|
+
function commandExists(cmd) {
|
|
16
|
+
try {
|
|
17
|
+
execSync(`command -v ${cmd}`, { stdio: "ignore", shell: true });
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getCommandOutput(cmd) {
|
|
25
|
+
try {
|
|
26
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], shell: true }).trim();
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWsl() {
|
|
33
|
+
return typeof process.env.WSL_DISTRO_NAME === "string" && process.env.WSL_DISTRO_NAME.length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @returns {{ linux: boolean, wsl: boolean, hasSystemctl: boolean, hasLoginctl: boolean, hasOpenclaw: boolean }}
|
|
38
|
+
*/
|
|
39
|
+
export function linuxGatewayPersistenceContext() {
|
|
40
|
+
const linux = process.platform === "linux";
|
|
41
|
+
return {
|
|
42
|
+
linux,
|
|
43
|
+
wsl: isWsl(),
|
|
44
|
+
hasSystemctl: linux && commandExists("systemctl"),
|
|
45
|
+
hasLoginctl: linux && commandExists("loginctl"),
|
|
46
|
+
hasOpenclaw: commandExists("openclaw"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when we should attempt linger / user systemd helpers (not macOS, not WSL by default).
|
|
52
|
+
*/
|
|
53
|
+
export function isLinuxGatewayPersistenceEligible() {
|
|
54
|
+
const ctx = linuxGatewayPersistenceContext();
|
|
55
|
+
if (!ctx.linux || ctx.wsl) return false;
|
|
56
|
+
if (!ctx.hasSystemctl || !ctx.hasLoginctl) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseLinger(output) {
|
|
61
|
+
if (!output || typeof output !== "string") return null;
|
|
62
|
+
for (const line of output.split("\n")) {
|
|
63
|
+
const m = line.match(/^Linger=(.+)$/);
|
|
64
|
+
if (m) return m[1].trim() === "yes";
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @returns {{ eligible: boolean, linger: boolean | null, username: string }}
|
|
71
|
+
*/
|
|
72
|
+
export function getLinuxGatewayPersistenceSnapshot(username = userInfo().username) {
|
|
73
|
+
if (!isLinuxGatewayPersistenceEligible()) {
|
|
74
|
+
return { eligible: false, linger: null, username };
|
|
75
|
+
}
|
|
76
|
+
const raw = getCommandOutput(`loginctl show-user '${String(username).replace(/'/g, "'\\''")}' -p Linger 2>/dev/null`);
|
|
77
|
+
const linger = parseLinger(raw || "");
|
|
78
|
+
return { eligible: true, linger, username };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse `openclaw gateway status --json` for systemd unit basename.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveGatewayUnitNameFromStatusJson(statusJson) {
|
|
85
|
+
if (!statusJson || typeof statusJson !== "object") return DEFAULT_UNIT;
|
|
86
|
+
const svc = statusJson.service;
|
|
87
|
+
if (svc && typeof svc === "object") {
|
|
88
|
+
const file =
|
|
89
|
+
typeof svc.file === "string"
|
|
90
|
+
? svc.file
|
|
91
|
+
: typeof svc.systemd?.file === "string"
|
|
92
|
+
? svc.systemd.file
|
|
93
|
+
: typeof svc.systemd?.unitPath === "string"
|
|
94
|
+
? svc.systemd.unitPath
|
|
95
|
+
: "";
|
|
96
|
+
if (file && file.endsWith(".service")) {
|
|
97
|
+
const base = basename(file.trim());
|
|
98
|
+
if (base) return base;
|
|
99
|
+
}
|
|
100
|
+
const unit = typeof svc.systemd?.unit === "string" ? svc.systemd.unit.trim() : "";
|
|
101
|
+
if (unit && unit.endsWith(".service")) return unit;
|
|
102
|
+
}
|
|
103
|
+
return DEFAULT_UNIT;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readOpenclawGatewayStatusJson() {
|
|
107
|
+
const raw = getCommandOutput("openclaw gateway status --json 2>/dev/null");
|
|
108
|
+
if (!raw) return null;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(raw);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runSpawn(cmd, args, opts = {}) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const child = spawn(cmd, args, {
|
|
119
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
120
|
+
shell: false,
|
|
121
|
+
...opts,
|
|
122
|
+
});
|
|
123
|
+
let stdout = "";
|
|
124
|
+
let stderr = "";
|
|
125
|
+
child.stdout?.on("data", (d) => {
|
|
126
|
+
stdout += d.toString();
|
|
127
|
+
});
|
|
128
|
+
child.stderr?.on("data", (d) => {
|
|
129
|
+
stderr += d.toString();
|
|
130
|
+
});
|
|
131
|
+
child.on("close", (code) => {
|
|
132
|
+
if (code === 0) resolve({ stdout, stderr, code });
|
|
133
|
+
else {
|
|
134
|
+
const err = new Error(stderr.trim() || `exit ${code}`);
|
|
135
|
+
err.code = code;
|
|
136
|
+
err.stdout = stdout;
|
|
137
|
+
err.stderr = stderr;
|
|
138
|
+
reject(err);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
child.on("error", reject);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {{ runPrivileged?: (cmd: string, args: string[]) => Promise<unknown> }} [options]
|
|
147
|
+
* @returns {Promise<{ skipped?: boolean, reason?: string, linger?: boolean, unitName?: string, unitEnabled?: boolean, errors?: string[] }>}
|
|
148
|
+
*/
|
|
149
|
+
export async function ensureLinuxGatewayPersistence(options = {}) {
|
|
150
|
+
const errors = [];
|
|
151
|
+
const emit = typeof options.emitLog === "function" ? options.emitLog : () => {};
|
|
152
|
+
|
|
153
|
+
if (!isLinuxGatewayPersistenceEligible()) {
|
|
154
|
+
const ctx = linuxGatewayPersistenceContext();
|
|
155
|
+
const reason = ctx.wsl
|
|
156
|
+
? "WSL (skipped; different session model)"
|
|
157
|
+
: !ctx.linux
|
|
158
|
+
? "not Linux"
|
|
159
|
+
: "systemctl/loginctl missing";
|
|
160
|
+
emit("info", `Gateway persistence: skipped (${reason}).`);
|
|
161
|
+
return { skipped: true, reason };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!commandExists("openclaw")) {
|
|
165
|
+
emit("warn", "Gateway persistence: openclaw not in PATH; skipped.");
|
|
166
|
+
return { skipped: true, reason: "openclaw missing" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const statusJson = readOpenclawGatewayStatusJson();
|
|
170
|
+
const unitName = resolveGatewayUnitNameFromStatusJson(statusJson);
|
|
171
|
+
|
|
172
|
+
const snap = getLinuxGatewayPersistenceSnapshot();
|
|
173
|
+
let lingerOk = snap.linger === true;
|
|
174
|
+
|
|
175
|
+
if (!lingerOk) {
|
|
176
|
+
try {
|
|
177
|
+
if (typeof options.runPrivileged === "function") {
|
|
178
|
+
await options.runPrivileged("sudo", ["loginctl", "enable-linger", snap.username]);
|
|
179
|
+
} else {
|
|
180
|
+
await runSpawn("sudo", ["loginctl", "enable-linger", snap.username]);
|
|
181
|
+
}
|
|
182
|
+
const again = getLinuxGatewayPersistenceSnapshot(snap.username);
|
|
183
|
+
lingerOk = again.linger === true;
|
|
184
|
+
if (lingerOk) emit("info", `Enabled systemd user linger for ${snap.username} (gateway survives SSH disconnect).`);
|
|
185
|
+
else errors.push("linger still not yes after loginctl enable-linger");
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const msg = err?.stderr || err?.message || String(err);
|
|
188
|
+
errors.push(`loginctl enable-linger: ${msg}`);
|
|
189
|
+
emit(
|
|
190
|
+
"warn",
|
|
191
|
+
"Could not enable user linger automatically. Run: sudo loginctl enable-linger $USER",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
emit("info", `systemd user linger already enabled for ${snap.username}.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let unitEnabled = false;
|
|
199
|
+
try {
|
|
200
|
+
await runSpawn("systemctl", ["--user", "daemon-reload"]);
|
|
201
|
+
await runSpawn("systemctl", ["--user", "enable", unitName]);
|
|
202
|
+
unitEnabled = true;
|
|
203
|
+
emit("info", `systemd user unit enabled: ${unitName}`);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const msg = err?.stderr || err?.message || String(err);
|
|
206
|
+
errors.push(`systemctl --user enable: ${msg}`);
|
|
207
|
+
emit(
|
|
208
|
+
"warn",
|
|
209
|
+
`Could not enable user unit ${unitName} (${msg.trim()}). If the gateway was installed, try: systemctl --user enable ${unitName}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
skipped: false,
|
|
215
|
+
linger: lingerOk,
|
|
216
|
+
unitName,
|
|
217
|
+
unitEnabled,
|
|
218
|
+
errors: errors.length ? errors : undefined,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Write wallet key to a root-only file and add systemd user drop-in for openclaw-gateway.
|
|
224
|
+
* @returns {{ envPath: string, dropinPath: string, wrote: boolean }}
|
|
225
|
+
*/
|
|
226
|
+
export function writeTraderclawGatewayWalletEnv(privateKeyBase58, home = homedir()) {
|
|
227
|
+
const key = typeof privateKeyBase58 === "string" ? privateKeyBase58.trim() : "";
|
|
228
|
+
if (!key) {
|
|
229
|
+
throw new Error("writeTraderclawGatewayWalletEnv: empty private key");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const configDir = join(home, ".config", "openclaw");
|
|
233
|
+
const envPath = join(configDir, WALLET_ENV_BASENAME);
|
|
234
|
+
mkdirSync(configDir, { recursive: true });
|
|
235
|
+
|
|
236
|
+
const line = `TRADERCLAW_WALLET_PRIVATE_KEY=${key}\n`;
|
|
237
|
+
writeFileSync(envPath, line, { mode: 0o600 });
|
|
238
|
+
try {
|
|
239
|
+
chmodSync(envPath, 0o600);
|
|
240
|
+
} catch {
|
|
241
|
+
// best effort
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const dropinDir = join(home, ".config", "systemd", "user", "openclaw-gateway.service.d");
|
|
245
|
+
mkdirSync(dropinDir, { recursive: true });
|
|
246
|
+
const dropinPath = join(dropinDir, DROPIN_NAME);
|
|
247
|
+
const dropinBody = `[Service]\nEnvironmentFile=${envPath}\n`;
|
|
248
|
+
writeFileSync(dropinPath, dropinBody, "utf-8");
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
execSync("systemctl --user daemon-reload", {
|
|
252
|
+
encoding: "utf-8",
|
|
253
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
254
|
+
});
|
|
255
|
+
} catch {
|
|
256
|
+
// user may not have user systemd in this context
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { envPath, dropinPath, wrote: true };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Printable instructions if automated wallet env is not used.
|
|
264
|
+
*/
|
|
265
|
+
export function formatGatewayWalletEnvInstructions(envPath = join(homedir(), ".config", "openclaw", WALLET_ENV_BASENAME)) {
|
|
266
|
+
const dropinDir = join(homedir(), ".config", "systemd", "user", "openclaw-gateway.service.d");
|
|
267
|
+
return [
|
|
268
|
+
"To pass TRADERCLAW_WALLET_PRIVATE_KEY to the OpenClaw gateway (systemd user unit):",
|
|
269
|
+
`1) Create ${envPath} with mode 600 containing one line: TRADERCLAW_WALLET_PRIVATE_KEY=<your_base58_key>`,
|
|
270
|
+
`2) Create ${join(dropinDir, DROPIN_NAME)} with:`,
|
|
271
|
+
" [Service]",
|
|
272
|
+
` EnvironmentFile=${envPath}`,
|
|
273
|
+
"3) Run: systemctl --user daemon-reload && openclaw gateway restart",
|
|
274
|
+
].join("\n");
|
|
275
|
+
}
|