@linkclaw/clawpool 0.1.0
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/README.md +107 -0
- package/clawpool.ts +583 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# clawpool
|
|
2
|
+
|
|
3
|
+
CLI tool to manage OpenClaw container instances on a local Docker host (Mac Mini + Colima).
|
|
4
|
+
|
|
5
|
+
Each instance runs in its own Docker container with a dedicated Telegram bot token, persistent volume, and auto-assigned port.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
brew install colima docker bun
|
|
11
|
+
|
|
12
|
+
# Start Colima (first time)
|
|
13
|
+
colima start --cpu 8 --memory 12 --disk 100 --vm-type vz
|
|
14
|
+
|
|
15
|
+
# Auto-start on boot
|
|
16
|
+
brew services start colima
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Via npx (no install needed)
|
|
23
|
+
npx @linkclaw/clawpool list
|
|
24
|
+
|
|
25
|
+
# Or install globally
|
|
26
|
+
npm i -g @linkclaw/clawpool
|
|
27
|
+
clawpool list
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
clawpool create <name> -t <telegram_bot_token> [-p <port>] [-e KEY=VAL ...]
|
|
34
|
+
clawpool list [--json]
|
|
35
|
+
clawpool start <name>
|
|
36
|
+
clawpool stop <name>
|
|
37
|
+
clawpool restart <name>
|
|
38
|
+
clawpool delete <name> [--purge]
|
|
39
|
+
clawpool rename <old_name> <new_name>
|
|
40
|
+
clawpool config <name> [-t <new_token>] [-e KEY=VAL ...]
|
|
41
|
+
clawpool logs <name> [-f]
|
|
42
|
+
clawpool status <name>
|
|
43
|
+
clawpool shell <name>
|
|
44
|
+
clawpool image [set <image> | show]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 1. Build or pull the OpenClaw image
|
|
51
|
+
docker build -t openclaw:latest /path/to/apps/agent
|
|
52
|
+
|
|
53
|
+
# 2. Create an instance with a Telegram bot token
|
|
54
|
+
clawpool create alpha -t "123456:AAHxxx..."
|
|
55
|
+
|
|
56
|
+
# 3. Add another instance with extra env vars
|
|
57
|
+
clawpool create beta -t "789012:BBXyyy..." -e "OPENAI_API_KEY=sk-..."
|
|
58
|
+
|
|
59
|
+
# 4. Check running instances
|
|
60
|
+
clawpool list
|
|
61
|
+
|
|
62
|
+
# 5. View logs
|
|
63
|
+
clawpool logs alpha -f
|
|
64
|
+
|
|
65
|
+
# 6. Reconfigure
|
|
66
|
+
clawpool config alpha -t "new_token_here"
|
|
67
|
+
|
|
68
|
+
# 7. Clean up
|
|
69
|
+
clawpool delete beta --purge
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How It Works
|
|
73
|
+
|
|
74
|
+
- **Config**: `~/.clawpool/config.json` stores instance metadata (name, port, token, env vars).
|
|
75
|
+
- **Containers**: Each instance runs as `claw-<name>` with `--restart unless-stopped`.
|
|
76
|
+
- **Volumes**: Data persisted in Docker named volumes (`claw-<name>-data` mounted at `/home/claw`).
|
|
77
|
+
- **Ports**: Auto-assigned starting from 8081, or manually specified with `-p`.
|
|
78
|
+
- **Telegram**: Each instance connects via long-polling (no public IP needed).
|
|
79
|
+
|
|
80
|
+
## Config File
|
|
81
|
+
|
|
82
|
+
Stored at `~/.clawpool/config.json`:
|
|
83
|
+
|
|
84
|
+
```jsonc
|
|
85
|
+
{
|
|
86
|
+
"image": "openclaw:latest", // default image for new instances
|
|
87
|
+
"next_port": 8083, // next auto-assigned port
|
|
88
|
+
"instances": {
|
|
89
|
+
"alpha": {
|
|
90
|
+
"name": "alpha",
|
|
91
|
+
"port": 8081,
|
|
92
|
+
"telegram_bot_token": "123456:AAH...",
|
|
93
|
+
"status": "running",
|
|
94
|
+
"container_id": "a1b2c3...",
|
|
95
|
+
"volume": "claw-alpha-data",
|
|
96
|
+
"env": { "OPENAI_API_KEY": "sk-..." },
|
|
97
|
+
"created_at": "2026-03-19T10:00:00Z"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Environment
|
|
104
|
+
|
|
105
|
+
| Variable | Purpose |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `CLAWPOOL_DIR` | Override config directory (default: `~/.clawpool`) |
|
package/clawpool.ts
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
// ── Config ──────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const CLAWPOOL_DIR = process.env.CLAWPOOL_DIR ?? join(homedir(), ".clawpool");
|
|
10
|
+
const CONFIG_FILE = join(CLAWPOOL_DIR, "config.json");
|
|
11
|
+
const CONTAINER_PREFIX = "claw";
|
|
12
|
+
|
|
13
|
+
interface Instance {
|
|
14
|
+
name: string;
|
|
15
|
+
port: number;
|
|
16
|
+
telegram_bot_token: string;
|
|
17
|
+
status: "running" | "stopped" | "removed";
|
|
18
|
+
container_id: string;
|
|
19
|
+
volume: string;
|
|
20
|
+
env: Record<string, string>;
|
|
21
|
+
created_at: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Config {
|
|
25
|
+
image: string;
|
|
26
|
+
next_port: number;
|
|
27
|
+
instances: Record<string, Instance>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const c = {
|
|
33
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
34
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
35
|
+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
36
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
37
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const die = (msg: string): never => {
|
|
41
|
+
console.error(`${c.red("Error:")} ${msg}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
};
|
|
44
|
+
const info = (msg: string) => console.log(`${c.cyan("==>")} ${msg}`);
|
|
45
|
+
const ok = (msg: string) => console.log(`${c.green("✓")} ${msg}`);
|
|
46
|
+
|
|
47
|
+
const containerName = (name: string) => `${CONTAINER_PREFIX}-${name}`;
|
|
48
|
+
const volumeName = (name: string) => `${CONTAINER_PREFIX}-${name}-data`;
|
|
49
|
+
|
|
50
|
+
function humanTime(iso: string): string {
|
|
51
|
+
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
52
|
+
if (diff < 60) return `${diff}s ago`;
|
|
53
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
54
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
55
|
+
return `${Math.floor(diff / 86400)}d ago`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Config I/O ──────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
async function loadConfig(): Promise<Config> {
|
|
61
|
+
mkdirSync(CLAWPOOL_DIR, { recursive: true });
|
|
62
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
63
|
+
const initial: Config = {
|
|
64
|
+
image: "openclaw:latest",
|
|
65
|
+
next_port: 8081,
|
|
66
|
+
instances: {},
|
|
67
|
+
};
|
|
68
|
+
await Bun.write(CONFIG_FILE, JSON.stringify(initial, null, 2));
|
|
69
|
+
return initial;
|
|
70
|
+
}
|
|
71
|
+
return Bun.file(CONFIG_FILE).json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function saveConfig(config: Config) {
|
|
75
|
+
await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function requireInstance(config: Config, name: string): Instance {
|
|
79
|
+
const inst = config.instances[name];
|
|
80
|
+
if (!inst) die(`instance '${name}' does not exist`);
|
|
81
|
+
return inst;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Docker helpers ──────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
async function docker(...args: string[]): Promise<string> {
|
|
87
|
+
const proc = Bun.spawn(["docker", ...args], {
|
|
88
|
+
stdout: "pipe",
|
|
89
|
+
stderr: "pipe",
|
|
90
|
+
});
|
|
91
|
+
const stdout = await new Response(proc.stdout).text();
|
|
92
|
+
const stderr = await new Response(proc.stderr).text();
|
|
93
|
+
const code = await proc.exited;
|
|
94
|
+
if (code !== 0) throw new Error(stderr.trim() || `docker ${args[0]} failed`);
|
|
95
|
+
return stdout.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function dockerQuiet(...args: string[]): Promise<boolean> {
|
|
99
|
+
const proc = Bun.spawn(["docker", ...args], {
|
|
100
|
+
stdout: "ignore",
|
|
101
|
+
stderr: "ignore",
|
|
102
|
+
});
|
|
103
|
+
return (await proc.exited) === 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function dockerExec(...args: string[]): Promise<void> {
|
|
107
|
+
const proc = Bun.spawn(["docker", ...args], {
|
|
108
|
+
stdout: "inherit",
|
|
109
|
+
stderr: "inherit",
|
|
110
|
+
stdin: "inherit",
|
|
111
|
+
});
|
|
112
|
+
await proc.exited;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function syncStatus(
|
|
116
|
+
config: Config,
|
|
117
|
+
name: string
|
|
118
|
+
): Promise<"running" | "stopped" | "removed"> {
|
|
119
|
+
try {
|
|
120
|
+
const state = await docker(
|
|
121
|
+
"inspect",
|
|
122
|
+
"-f",
|
|
123
|
+
"{{.State.Status}}",
|
|
124
|
+
containerName(name)
|
|
125
|
+
);
|
|
126
|
+
const status =
|
|
127
|
+
state === "running" ? "running" : (("stopped" as const) satisfies string);
|
|
128
|
+
config.instances[name].status = status as "running" | "stopped";
|
|
129
|
+
return status as "running" | "stopped";
|
|
130
|
+
} catch {
|
|
131
|
+
config.instances[name].status = "removed";
|
|
132
|
+
return "removed";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildEnvArgs(inst: Instance): string[] {
|
|
137
|
+
const args = [
|
|
138
|
+
"-e",
|
|
139
|
+
`TELEGRAM_BOT_TOKEN=${inst.telegram_bot_token}`,
|
|
140
|
+
"-e",
|
|
141
|
+
`INSTANCE_NAME=${inst.name}`,
|
|
142
|
+
];
|
|
143
|
+
for (const [k, v] of Object.entries(inst.env)) {
|
|
144
|
+
args.push("-e", `${k}=${v}`);
|
|
145
|
+
}
|
|
146
|
+
return args;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function rebuildContainer(
|
|
150
|
+
config: Config,
|
|
151
|
+
name: string
|
|
152
|
+
): Promise<string> {
|
|
153
|
+
const inst = config.instances[name];
|
|
154
|
+
await dockerQuiet("rm", "-f", containerName(name));
|
|
155
|
+
|
|
156
|
+
const cid = await docker(
|
|
157
|
+
"run",
|
|
158
|
+
"-d",
|
|
159
|
+
"--name",
|
|
160
|
+
containerName(name),
|
|
161
|
+
"--restart",
|
|
162
|
+
"unless-stopped",
|
|
163
|
+
"-p",
|
|
164
|
+
`${inst.port}:8080`,
|
|
165
|
+
"-v",
|
|
166
|
+
`${inst.volume}:/home/claw`,
|
|
167
|
+
...buildEnvArgs(inst),
|
|
168
|
+
config.image,
|
|
169
|
+
"node",
|
|
170
|
+
"dist/index.js",
|
|
171
|
+
"gateway",
|
|
172
|
+
"--allow-unconfigured"
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
inst.container_id = cid;
|
|
176
|
+
inst.status = "running";
|
|
177
|
+
return cid;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Commands ────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
async function cmdCreate(config: Config, args: string[]) {
|
|
183
|
+
let name = "";
|
|
184
|
+
let token = "";
|
|
185
|
+
let port = 0;
|
|
186
|
+
const env: Record<string, string> = {};
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < args.length; i++) {
|
|
189
|
+
switch (args[i]) {
|
|
190
|
+
case "-t":
|
|
191
|
+
case "--token":
|
|
192
|
+
token = args[++i];
|
|
193
|
+
break;
|
|
194
|
+
case "-p":
|
|
195
|
+
case "--port":
|
|
196
|
+
port = parseInt(args[++i]);
|
|
197
|
+
break;
|
|
198
|
+
case "-e":
|
|
199
|
+
case "--env": {
|
|
200
|
+
const kv = args[++i];
|
|
201
|
+
const eq = kv.indexOf("=");
|
|
202
|
+
if (eq === -1) die(`invalid env format: ${kv} (expected KEY=VAL)`);
|
|
203
|
+
env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
if (args[i].startsWith("-")) die(`unknown option: ${args[i]}`);
|
|
208
|
+
if (!name) name = args[i];
|
|
209
|
+
else die(`unexpected argument: ${args[i]}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!name || !token) die("usage: clawpool create <name> -t <token>");
|
|
214
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name))
|
|
215
|
+
die("invalid name: use alphanumerics, dots, hyphens, underscores");
|
|
216
|
+
if (config.instances[name]) die(`instance '${name}' already exists`);
|
|
217
|
+
|
|
218
|
+
if (!port) port = config.next_port;
|
|
219
|
+
|
|
220
|
+
info(`Creating instance '${name}' on port ${port}...`);
|
|
221
|
+
|
|
222
|
+
await docker("volume", "create", volumeName(name));
|
|
223
|
+
|
|
224
|
+
const inst: Instance = {
|
|
225
|
+
name,
|
|
226
|
+
port,
|
|
227
|
+
telegram_bot_token: token,
|
|
228
|
+
status: "running",
|
|
229
|
+
container_id: "",
|
|
230
|
+
volume: volumeName(name),
|
|
231
|
+
env,
|
|
232
|
+
created_at: new Date().toISOString(),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
config.instances[name] = inst;
|
|
236
|
+
if (config.next_port <= port) config.next_port = port + 1;
|
|
237
|
+
|
|
238
|
+
const cid = await rebuildContainer(config, name);
|
|
239
|
+
await saveConfig(config);
|
|
240
|
+
|
|
241
|
+
ok(`Instance '${name}' created (port: ${port}, container: ${cid.slice(0, 12)})`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function cmdList(config: Config, args: string[]) {
|
|
245
|
+
const jsonMode = args.includes("--json");
|
|
246
|
+
const names = Object.keys(config.instances);
|
|
247
|
+
|
|
248
|
+
if (names.length === 0) {
|
|
249
|
+
console.log(
|
|
250
|
+
"No instances. Create one with: clawpool create <name> -t <token>"
|
|
251
|
+
);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const name of names) await syncStatus(config, name);
|
|
256
|
+
await saveConfig(config);
|
|
257
|
+
|
|
258
|
+
if (jsonMode) {
|
|
259
|
+
console.log(JSON.stringify(config.instances, null, 2));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const rows = names.map((name) => {
|
|
264
|
+
const inst = config.instances[name];
|
|
265
|
+
const statusCol =
|
|
266
|
+
inst.status === "running"
|
|
267
|
+
? c.green(inst.status)
|
|
268
|
+
: inst.status === "stopped"
|
|
269
|
+
? c.red(inst.status)
|
|
270
|
+
: c.yellow(inst.status);
|
|
271
|
+
return { name, status: statusCol, port: inst.port, created: humanTime(inst.created_at) };
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
console.log(
|
|
275
|
+
`${"NAME".padEnd(16)} ${"STATUS".padEnd(10)} ${"PORT".padEnd(6)} CREATED`
|
|
276
|
+
);
|
|
277
|
+
console.log(
|
|
278
|
+
`${"----".padEnd(16)} ${"------".padEnd(10)} ${"----".padEnd(6)} -------`
|
|
279
|
+
);
|
|
280
|
+
for (const r of rows) {
|
|
281
|
+
// status has ANSI codes so pad the raw length
|
|
282
|
+
console.log(
|
|
283
|
+
`${r.name.padEnd(16)} ${r.status.padEnd(21)} ${String(r.port).padEnd(6)} ${r.created}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function cmdStart(config: Config, args: string[]) {
|
|
289
|
+
const name = args[0] ?? die("usage: clawpool start <name>");
|
|
290
|
+
requireInstance(config, name);
|
|
291
|
+
info(`Starting '${name}'...`);
|
|
292
|
+
await docker("start", containerName(name));
|
|
293
|
+
config.instances[name].status = "running";
|
|
294
|
+
await saveConfig(config);
|
|
295
|
+
ok(`Instance '${name}' started`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function cmdStop(config: Config, args: string[]) {
|
|
299
|
+
const name = args[0] ?? die("usage: clawpool stop <name>");
|
|
300
|
+
requireInstance(config, name);
|
|
301
|
+
info(`Stopping '${name}'...`);
|
|
302
|
+
await docker("stop", containerName(name));
|
|
303
|
+
config.instances[name].status = "stopped";
|
|
304
|
+
await saveConfig(config);
|
|
305
|
+
ok(`Instance '${name}' stopped`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function cmdRestart(config: Config, args: string[]) {
|
|
309
|
+
const name = args[0] ?? die("usage: clawpool restart <name>");
|
|
310
|
+
requireInstance(config, name);
|
|
311
|
+
info(`Restarting '${name}'...`);
|
|
312
|
+
await docker("restart", containerName(name));
|
|
313
|
+
config.instances[name].status = "running";
|
|
314
|
+
await saveConfig(config);
|
|
315
|
+
ok(`Instance '${name}' restarted`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function cmdDelete(config: Config, args: string[]) {
|
|
319
|
+
let name = "";
|
|
320
|
+
let purge = false;
|
|
321
|
+
for (const a of args) {
|
|
322
|
+
if (a === "--purge") purge = true;
|
|
323
|
+
else if (!a.startsWith("-")) name = a;
|
|
324
|
+
else die(`unknown option: ${a}`);
|
|
325
|
+
}
|
|
326
|
+
if (!name) die("usage: clawpool delete <name> [--purge]");
|
|
327
|
+
requireInstance(config, name);
|
|
328
|
+
|
|
329
|
+
info(`Deleting '${name}'...`);
|
|
330
|
+
await dockerQuiet("rm", "-f", containerName(name));
|
|
331
|
+
|
|
332
|
+
if (purge) {
|
|
333
|
+
await dockerQuiet("volume", "rm", volumeName(name));
|
|
334
|
+
info("Volume purged");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
delete config.instances[name];
|
|
338
|
+
await saveConfig(config);
|
|
339
|
+
ok(`Instance '${name}' deleted`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function cmdRename(config: Config, args: string[]) {
|
|
343
|
+
const oldName = args[0] ?? die("usage: clawpool rename <old> <new>");
|
|
344
|
+
const newName = args[1] ?? die("usage: clawpool rename <old> <new>");
|
|
345
|
+
|
|
346
|
+
requireInstance(config, oldName);
|
|
347
|
+
if (config.instances[newName]) die(`instance '${newName}' already exists`);
|
|
348
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(newName))
|
|
349
|
+
die("invalid name: use alphanumerics, dots, hyphens, underscores");
|
|
350
|
+
|
|
351
|
+
info(`Renaming '${oldName}' → '${newName}'...`);
|
|
352
|
+
|
|
353
|
+
// Copy instance config
|
|
354
|
+
const inst = { ...config.instances[oldName] };
|
|
355
|
+
inst.name = newName;
|
|
356
|
+
inst.volume = volumeName(newName);
|
|
357
|
+
|
|
358
|
+
// Stop old container
|
|
359
|
+
await dockerQuiet("rm", "-f", containerName(oldName));
|
|
360
|
+
|
|
361
|
+
// Migrate volume
|
|
362
|
+
const oldVol = volumeName(oldName);
|
|
363
|
+
const newVol = volumeName(newName);
|
|
364
|
+
await docker("volume", "create", newVol);
|
|
365
|
+
await dockerQuiet(
|
|
366
|
+
"run",
|
|
367
|
+
"--rm",
|
|
368
|
+
"-v",
|
|
369
|
+
`${oldVol}:/from`,
|
|
370
|
+
"-v",
|
|
371
|
+
`${newVol}:/to`,
|
|
372
|
+
"alpine",
|
|
373
|
+
"sh",
|
|
374
|
+
"-c",
|
|
375
|
+
"cp -a /from/. /to/"
|
|
376
|
+
);
|
|
377
|
+
await dockerQuiet("volume", "rm", oldVol);
|
|
378
|
+
|
|
379
|
+
// Update config and rebuild
|
|
380
|
+
delete config.instances[oldName];
|
|
381
|
+
config.instances[newName] = inst;
|
|
382
|
+
await rebuildContainer(config, newName);
|
|
383
|
+
await saveConfig(config);
|
|
384
|
+
|
|
385
|
+
ok(`Renamed '${oldName}' → '${newName}'`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function cmdConfig(config: Config, args: string[]) {
|
|
389
|
+
const name = args[0];
|
|
390
|
+
if (!name || name.startsWith("-"))
|
|
391
|
+
die("usage: clawpool config <name> [-t <token>] [-e KEY=VAL ...]");
|
|
392
|
+
|
|
393
|
+
requireInstance(config, name);
|
|
394
|
+
const inst = config.instances[name];
|
|
395
|
+
let changed = false;
|
|
396
|
+
|
|
397
|
+
for (let i = 1; i < args.length; i++) {
|
|
398
|
+
switch (args[i]) {
|
|
399
|
+
case "-t":
|
|
400
|
+
case "--token":
|
|
401
|
+
inst.telegram_bot_token = args[++i];
|
|
402
|
+
info("Token updated");
|
|
403
|
+
changed = true;
|
|
404
|
+
break;
|
|
405
|
+
case "-e":
|
|
406
|
+
case "--env": {
|
|
407
|
+
const kv = args[++i];
|
|
408
|
+
const eq = kv.indexOf("=");
|
|
409
|
+
if (eq === -1) die(`invalid env format: ${kv}`);
|
|
410
|
+
inst.env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
411
|
+
info(`Env ${kv.slice(0, eq)} set`);
|
|
412
|
+
changed = true;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
default:
|
|
416
|
+
die(`unknown option: ${args[i]}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!changed) die("nothing to change — specify --token or --env");
|
|
421
|
+
|
|
422
|
+
info("Rebuilding container...");
|
|
423
|
+
await rebuildContainer(config, name);
|
|
424
|
+
await saveConfig(config);
|
|
425
|
+
ok(`Instance '${name}' reconfigured and restarted`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function cmdLogs(config: Config, args: string[]) {
|
|
429
|
+
const name = args.find((a): a is string => !a.startsWith("-"));
|
|
430
|
+
if (!name) return die("usage: clawpool logs <name> [-f]");
|
|
431
|
+
requireInstance(config, name);
|
|
432
|
+
const flags = args.filter((a) => a.startsWith("-"));
|
|
433
|
+
await dockerExec("logs", ...flags, containerName(name));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function cmdStatus(config: Config, args: string[]) {
|
|
437
|
+
const name = args[0] ?? die("usage: clawpool status <name>");
|
|
438
|
+
requireInstance(config, name);
|
|
439
|
+
await syncStatus(config, name);
|
|
440
|
+
await saveConfig(config);
|
|
441
|
+
|
|
442
|
+
const inst = config.instances[name];
|
|
443
|
+
const statusCol =
|
|
444
|
+
inst.status === "running"
|
|
445
|
+
? c.green(inst.status)
|
|
446
|
+
: inst.status === "stopped"
|
|
447
|
+
? c.red(inst.status)
|
|
448
|
+
: c.yellow(inst.status);
|
|
449
|
+
|
|
450
|
+
console.log(`${c.bold("Instance:")} ${inst.name}`);
|
|
451
|
+
console.log(`${c.bold("Status:")} ${statusCol}`);
|
|
452
|
+
console.log(`${c.bold("Port:")} ${inst.port}`);
|
|
453
|
+
console.log(`${c.bold("Volume:")} ${inst.volume}`);
|
|
454
|
+
console.log(`${c.bold("Created:")} ${inst.created_at}`);
|
|
455
|
+
console.log(
|
|
456
|
+
`${c.bold("Token:")} ${inst.telegram_bot_token.slice(0, 20)}...`
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const envKeys = Object.keys(inst.env);
|
|
460
|
+
if (envKeys.length > 0) {
|
|
461
|
+
console.log(`${c.bold("Env vars:")} ${envKeys.length}`);
|
|
462
|
+
for (const [k, v] of Object.entries(inst.env)) {
|
|
463
|
+
console.log(` ${k}=${v.slice(0, 20)}...`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (inst.status === "running") {
|
|
468
|
+
console.log("");
|
|
469
|
+
await dockerExec(
|
|
470
|
+
"stats",
|
|
471
|
+
"--no-stream",
|
|
472
|
+
"--format",
|
|
473
|
+
`${c.bold("CPU:")} {{.CPUPerc}} ${c.bold("Mem:")} {{.MemUsage}}`,
|
|
474
|
+
containerName(name)
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function cmdShell(config: Config, args: string[]) {
|
|
480
|
+
const name = args[0] ?? die("usage: clawpool shell <name>");
|
|
481
|
+
requireInstance(config, name);
|
|
482
|
+
await dockerExec("exec", "-it", containerName(name), "/bin/sh");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function cmdImage(config: Config, args: string[]) {
|
|
486
|
+
const sub = args[0] ?? "show";
|
|
487
|
+
if (sub === "set") {
|
|
488
|
+
const img = args[1] ?? die("usage: clawpool image set <image>");
|
|
489
|
+
config.image = img;
|
|
490
|
+
await saveConfig(config);
|
|
491
|
+
ok(`Default image set to '${img}'`);
|
|
492
|
+
} else if (sub === "show") {
|
|
493
|
+
console.log(config.image);
|
|
494
|
+
} else {
|
|
495
|
+
die("usage: clawpool image [set <image> | show]");
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function cmdHelp() {
|
|
500
|
+
console.log(`clawpool — Manage OpenClaw container instances
|
|
501
|
+
|
|
502
|
+
Usage:
|
|
503
|
+
clawpool create <name> -t <telegram_bot_token> [-p <port>] [-e KEY=VAL ...]
|
|
504
|
+
clawpool list [--json]
|
|
505
|
+
clawpool start <name>
|
|
506
|
+
clawpool stop <name>
|
|
507
|
+
clawpool restart <name>
|
|
508
|
+
clawpool delete <name> [--purge]
|
|
509
|
+
clawpool rename <old_name> <new_name>
|
|
510
|
+
clawpool config <name> [-t <new_token>] [-e KEY=VAL ...]
|
|
511
|
+
clawpool logs <name> [-f]
|
|
512
|
+
clawpool status <name>
|
|
513
|
+
clawpool shell <name>
|
|
514
|
+
clawpool image [set <image> | show]
|
|
515
|
+
|
|
516
|
+
Options:
|
|
517
|
+
-t, --token Telegram bot token
|
|
518
|
+
-p, --port Host port (auto-assigned if omitted)
|
|
519
|
+
-e, --env Extra environment variable (repeatable)
|
|
520
|
+
--purge Also remove data volume on delete
|
|
521
|
+
--json Output in JSON format
|
|
522
|
+
|
|
523
|
+
Examples:
|
|
524
|
+
clawpool create alpha -t "123456:AAH..." -e "OPENAI_API_KEY=sk-..."
|
|
525
|
+
clawpool list
|
|
526
|
+
clawpool logs alpha -f
|
|
527
|
+
clawpool config alpha -t "new_token"
|
|
528
|
+
clawpool delete alpha --purge`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Main ────────────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
const config = await loadConfig();
|
|
534
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
535
|
+
|
|
536
|
+
switch (cmd ?? "help") {
|
|
537
|
+
case "create":
|
|
538
|
+
await cmdCreate(config, args);
|
|
539
|
+
break;
|
|
540
|
+
case "list":
|
|
541
|
+
case "ls":
|
|
542
|
+
await cmdList(config, args);
|
|
543
|
+
break;
|
|
544
|
+
case "start":
|
|
545
|
+
await cmdStart(config, args);
|
|
546
|
+
break;
|
|
547
|
+
case "stop":
|
|
548
|
+
await cmdStop(config, args);
|
|
549
|
+
break;
|
|
550
|
+
case "restart":
|
|
551
|
+
await cmdRestart(config, args);
|
|
552
|
+
break;
|
|
553
|
+
case "delete":
|
|
554
|
+
case "rm":
|
|
555
|
+
await cmdDelete(config, args);
|
|
556
|
+
break;
|
|
557
|
+
case "rename":
|
|
558
|
+
await cmdRename(config, args);
|
|
559
|
+
break;
|
|
560
|
+
case "config":
|
|
561
|
+
await cmdConfig(config, args);
|
|
562
|
+
break;
|
|
563
|
+
case "logs":
|
|
564
|
+
await cmdLogs(config, args);
|
|
565
|
+
break;
|
|
566
|
+
case "status":
|
|
567
|
+
await cmdStatus(config, args);
|
|
568
|
+
break;
|
|
569
|
+
case "shell":
|
|
570
|
+
case "sh":
|
|
571
|
+
await cmdShell(config, args);
|
|
572
|
+
break;
|
|
573
|
+
case "image":
|
|
574
|
+
await cmdImage(config, args);
|
|
575
|
+
break;
|
|
576
|
+
case "help":
|
|
577
|
+
case "-h":
|
|
578
|
+
case "--help":
|
|
579
|
+
cmdHelp();
|
|
580
|
+
break;
|
|
581
|
+
default:
|
|
582
|
+
die(`unknown command: ${cmd} (run 'clawpool help' for usage)`);
|
|
583
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@linkclaw/clawpool",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"clawpool": "./clawpool.ts"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"clawpool.ts"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"start": "bun run clawpool.ts"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"bun-types": "latest",
|
|
17
|
+
"typescript": "^5"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/linkclaw-lab/clawpool.git"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"description": "CLI tool to manage OpenClaw container instances with Telegram integration"
|
|
28
|
+
}
|