openclaw-dev 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.
@@ -0,0 +1,101 @@
1
+ services:
2
+ openclaw-init:
3
+ image: ${OPENCLAW_IMAGE:?Set OPENCLAW_IMAGE in the stack env file}
4
+ user: "0:0"
5
+ entrypoint:
6
+ [
7
+ "sh",
8
+ "-lc",
9
+ "mkdir -p /home/node/.openclaw/workspace && chown -R 1000:1000 /home/node",
10
+ ]
11
+ volumes:
12
+ - openclaw-home:/home/node
13
+ - openclaw-state:/home/node/.openclaw
14
+ - openclaw-workspace:/home/node/.openclaw/workspace
15
+ restart: "no"
16
+
17
+ openclaw-gateway:
18
+ image: ${OPENCLAW_IMAGE:?Set OPENCLAW_IMAGE in the stack env file}
19
+ environment:
20
+ HOME: /home/node
21
+ TERM: xterm-256color
22
+ TZ: ${OPENCLAW_TZ:-UTC}
23
+ OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:?Set OPENCLAW_GATEWAY_TOKEN in the stack env file}
24
+ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
25
+ CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
26
+ CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
27
+ CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
28
+ volumes:
29
+ - openclaw-home:/home/node
30
+ - openclaw-state:/home/node/.openclaw
31
+ - openclaw-workspace:/home/node/.openclaw/workspace
32
+ ports:
33
+ - "${OPENCLAW_GATEWAY_PORT:-18789}:18789"
34
+ - "${OPENCLAW_BRIDGE_PORT:-18790}:18790"
35
+ init: true
36
+ restart: unless-stopped
37
+ stop_grace_period: 30s
38
+ depends_on:
39
+ openclaw-init:
40
+ condition: service_completed_successfully
41
+ command:
42
+ [
43
+ "node",
44
+ "dist/index.js",
45
+ "gateway",
46
+ "--allow-unconfigured",
47
+ "--bind",
48
+ "${OPENCLAW_GATEWAY_BIND:-lan}",
49
+ "--port",
50
+ "18789",
51
+ ]
52
+ healthcheck:
53
+ test:
54
+ [
55
+ "CMD",
56
+ "node",
57
+ "-e",
58
+ "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
59
+ ]
60
+ interval: 30s
61
+ timeout: 5s
62
+ retries: 5
63
+ start_period: 20s
64
+
65
+ openclaw-cli:
66
+ image: ${OPENCLAW_IMAGE:?Set OPENCLAW_IMAGE in the stack env file}
67
+ network_mode: "service:openclaw-gateway"
68
+ cap_drop:
69
+ - NET_RAW
70
+ - NET_ADMIN
71
+ security_opt:
72
+ - no-new-privileges:true
73
+ environment:
74
+ HOME: /home/node
75
+ TERM: xterm-256color
76
+ TZ: ${OPENCLAW_TZ:-UTC}
77
+ BROWSER: echo
78
+ OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:?Set OPENCLAW_GATEWAY_TOKEN in the stack env file}
79
+ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
80
+ CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
81
+ CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
82
+ CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
83
+ volumes:
84
+ - openclaw-home:/home/node
85
+ - openclaw-state:/home/node/.openclaw
86
+ - openclaw-workspace:/home/node/.openclaw/workspace
87
+ stdin_open: true
88
+ tty: true
89
+ init: true
90
+ entrypoint: ["node", "dist/index.js"]
91
+ depends_on:
92
+ openclaw-gateway:
93
+ condition: service_healthy
94
+
95
+ volumes:
96
+ openclaw-home:
97
+ name: ${OPENCLAW_HOME_VOLUME:?Set OPENCLAW_HOME_VOLUME}
98
+ openclaw-state:
99
+ name: ${OPENCLAW_STATE_VOLUME:?Set OPENCLAW_STATE_VOLUME}
100
+ openclaw-workspace:
101
+ name: ${OPENCLAW_WORKSPACE_VOLUME:?Set OPENCLAW_WORKSPACE_VOLUME}
package/bin/ocdev ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli.mjs";
4
+
5
+ main(process.argv.slice(2)).catch((error) => {
6
+ console.error(error?.message || String(error));
7
+ process.exitCode = 1;
8
+ });
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "openclaw-dev",
3
+ "version": "0.1.0",
4
+ "description": "CLI launcher for OpenClaw developer instances",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/iAladdin/openclaw-developer-images.git"
9
+ },
10
+ "bin": {
11
+ "ocdev": "bin/ocdev"
12
+ },
13
+ "files": [
14
+ "assets",
15
+ "bin",
16
+ "src"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22.16.0"
20
+ }
21
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,481 @@
1
+ import {
2
+ assertDockerAvailable,
3
+ collectComposeDiagnostics,
4
+ isComposeServiceRunning,
5
+ runDockerCompose,
6
+ runDockerComposeExec
7
+ } from "./lib/docker.mjs";
8
+ import {
9
+ buildConfigFromArgs,
10
+ defaultInstanceName,
11
+ hasFlag,
12
+ readEnvFile,
13
+ readFlag,
14
+ readPositional,
15
+ resolveInstancesRoot,
16
+ stackPaths,
17
+ writeStackFiles
18
+ } from "./lib/instance.mjs";
19
+ import { resolvePortPlan } from "./lib/ports.mjs";
20
+
21
+ export async function main(argv, { cwd = process.cwd() } = {}) {
22
+ const command = argv[0] || "help";
23
+
24
+ if (command === "help" || hasFlag(argv, "-h", "--help")) {
25
+ printHelp(cwd);
26
+ return;
27
+ }
28
+
29
+ if (command === "up") {
30
+ await up(argv, { cwd });
31
+ return;
32
+ }
33
+
34
+ if (command === "down") {
35
+ await down(argv, { cwd });
36
+ return;
37
+ }
38
+
39
+ if (command === "logs") {
40
+ await logs(argv, { cwd });
41
+ return;
42
+ }
43
+
44
+ if (command === "token") {
45
+ await token(argv, { cwd });
46
+ return;
47
+ }
48
+
49
+ if (command === "approve") {
50
+ await approve(argv, { cwd });
51
+ return;
52
+ }
53
+
54
+ if (command === "exec") {
55
+ await execInContainer(argv, { cwd });
56
+ return;
57
+ }
58
+
59
+ if (command === "claw") {
60
+ await claw(argv, { cwd });
61
+ return;
62
+ }
63
+
64
+ throw new Error(`Unknown command: ${command}\n\n${helpText(cwd)}`);
65
+ }
66
+
67
+ async function up(argv, { cwd }) {
68
+ await assertDockerAvailable();
69
+
70
+ const requestedInstance = readFlag(argv, "--name") || readPositional(argv, 1) || defaultInstanceName(cwd);
71
+ const instancesRoot = resolveInstancesRoot();
72
+ const paths = stackPaths({ rootDir: instancesRoot, instance: requestedInstance });
73
+ const existingEnv = await readEnvFile(paths.envPath);
74
+ const hasExplicitPortSelection = Boolean(readFlag(argv, "--gateway-port") || readFlag(argv, "--bridge-port"));
75
+ const preserveExistingPorts =
76
+ !hasExplicitPortSelection &&
77
+ Object.keys(existingEnv).length > 0 &&
78
+ (await isComposeServiceRunning({
79
+ envFile: paths.envPath,
80
+ composeFile: paths.composePath,
81
+ service: "openclaw-gateway"
82
+ }));
83
+ const config = buildConfigFromArgs({
84
+ cwd,
85
+ argv,
86
+ defaults: {
87
+ instance: requestedInstance,
88
+ existingEnv
89
+ }
90
+ });
91
+ const portPlan = await resolvePortPlan({
92
+ gatewayPort: config.gatewayPort,
93
+ bridgePort: config.bridgePort,
94
+ preserveExistingPorts
95
+ });
96
+ config.gatewayPort = portPlan.gatewayPort;
97
+ config.bridgePort = portPlan.bridgePort;
98
+
99
+ const writtenPaths = await writeStackFiles({
100
+ rootDir: instancesRoot,
101
+ config,
102
+ refreshTemplate: hasFlag(argv, "--refresh-template")
103
+ });
104
+
105
+ try {
106
+ await startComposeWithRetries({
107
+ argv,
108
+ config,
109
+ writtenPaths
110
+ });
111
+ } catch (error) {
112
+ const diagnostics = await collectComposeDiagnostics({
113
+ envFile: writtenPaths.envPath,
114
+ composeFile: writtenPaths.composePath
115
+ });
116
+ const details = [
117
+ "OpenClaw developer instance failed to start.",
118
+ `Instance: ${config.instance}`,
119
+ `State directory: ${writtenPaths.stackDir}`,
120
+ "",
121
+ "Managed file policy:",
122
+ "- `.env` managed keys are refreshed on `ocdev up`; extra keys are preserved.",
123
+ "- `docker-compose.instance.yml` and `README.md` are only refreshed when you pass `--refresh-template`.",
124
+ ""
125
+ ];
126
+
127
+ if (portPlan.shifted) {
128
+ details.push(
129
+ `Ports ${portPlan.originalGatewayPort}/${portPlan.originalBridgePort} were unavailable, so ocdev shifted to ${config.gatewayPort}/${config.bridgePort}.`,
130
+ ""
131
+ );
132
+ }
133
+
134
+ const composeOutput = `${error.stdout || ""}${error.stderr || ""}`.trim();
135
+ if (composeOutput) {
136
+ details.push("Compose output:", composeOutput, "");
137
+ }
138
+
139
+ if (diagnostics) {
140
+ details.push("Diagnostics:", diagnostics);
141
+ }
142
+
143
+ throw new Error(details.join("\n"));
144
+ }
145
+
146
+ console.log("OpenClaw developer instance is starting.");
147
+ console.log(`Instance: ${config.instance}`);
148
+ console.log(`Profile: ${config.profile}`);
149
+ console.log(`Image: ${config.image}`);
150
+ console.log(`Control UI: http://127.0.0.1:${config.gatewayPort}`);
151
+ console.log(`State directory: ${writtenPaths.stackDir}`);
152
+ if (portPlan.shifted) {
153
+ console.log(
154
+ `Ports ${portPlan.originalGatewayPort}/${portPlan.originalBridgePort} were busy, so ocdev shifted to ${config.gatewayPort}/${config.bridgePort}.`
155
+ );
156
+ }
157
+ console.log("");
158
+ console.log("Managed file policy:");
159
+ console.log("- `.env` managed keys are refreshed on `ocdev up`; extra keys are preserved.");
160
+ console.log("- `docker-compose.instance.yml` and `README.md` are only refreshed when you pass `--refresh-template`.");
161
+ console.log("");
162
+ console.log("Next steps:");
163
+ console.log(`- View logs: ocdev logs ${config.instance}`);
164
+ console.log(`- Show token: ocdev token ${config.instance}`);
165
+ console.log(`- Approve the first browser device: ocdev approve ${config.instance}`);
166
+ console.log(`- Run OpenClaw CLI inside the instance: ocdev claw --name ${config.instance} devices list`);
167
+ }
168
+
169
+ async function startComposeWithRetries({ argv, config, writtenPaths }) {
170
+ const explicitPorts = Boolean(readFlag(argv, "--gateway-port") || readFlag(argv, "--bridge-port"));
171
+ let currentPaths = writtenPaths;
172
+
173
+ for (let attempt = 0; attempt < 3; attempt += 1) {
174
+ try {
175
+ await runDockerCompose({
176
+ envFile: currentPaths.envPath,
177
+ composeFile: currentPaths.composePath,
178
+ args: ["up", "-d", "--wait"],
179
+ capture: true
180
+ });
181
+ return;
182
+ } catch (error) {
183
+ if (!shouldRetryPortAllocation(error, explicitPorts) || attempt === 2) {
184
+ throw error;
185
+ }
186
+
187
+ await safeComposeDown(currentPaths);
188
+
189
+ const retryPlan = await resolvePortPlan({
190
+ gatewayPort: config.gatewayPort + 1,
191
+ bridgePort: config.bridgePort + 1
192
+ });
193
+ config.gatewayPort = retryPlan.gatewayPort;
194
+ config.bridgePort = retryPlan.bridgePort;
195
+ currentPaths = await writeStackFiles({
196
+ rootDir: resolveInstancesRoot(),
197
+ config,
198
+ refreshTemplate: hasFlag(argv, "--refresh-template")
199
+ });
200
+ }
201
+ }
202
+ }
203
+
204
+ async function down(argv, { cwd }) {
205
+ await assertDockerAvailable();
206
+
207
+ const instance = readFlag(argv, "--name") || readPositional(argv, 1) || defaultInstanceName(cwd);
208
+ const paths = stackPaths({ rootDir: resolveInstancesRoot(), instance });
209
+ const args = ["down"];
210
+
211
+ if (hasFlag(argv, "-v", "--volumes")) {
212
+ args.push("-v");
213
+ }
214
+
215
+ await ensureStackExists(paths);
216
+ await runDockerCompose({
217
+ envFile: paths.envPath,
218
+ composeFile: paths.composePath,
219
+ args
220
+ });
221
+ }
222
+
223
+ async function logs(argv, { cwd }) {
224
+ await assertDockerAvailable();
225
+
226
+ const instance = readFlag(argv, "--name") || readPositional(argv, 1) || defaultInstanceName(cwd);
227
+ const paths = stackPaths({ rootDir: resolveInstancesRoot(), instance });
228
+ const service = readFlag(argv, "--service") || "openclaw-gateway";
229
+
230
+ await ensureStackExists(paths);
231
+ await runDockerCompose({
232
+ envFile: paths.envPath,
233
+ composeFile: paths.composePath,
234
+ args: ["logs", "-f", service]
235
+ });
236
+ }
237
+
238
+ async function token(argv, { cwd }) {
239
+ const instance = readFlag(argv, "--name") || readPositional(argv, 1) || defaultInstanceName(cwd);
240
+ const paths = stackPaths({ rootDir: resolveInstancesRoot(), instance });
241
+
242
+ await ensureStackExists(paths);
243
+
244
+ const env = await readEnvFile(paths.envPath);
245
+ if (!env.OPENCLAW_GATEWAY_TOKEN) {
246
+ throw new Error(`No OPENCLAW_GATEWAY_TOKEN found in ${paths.envPath}`);
247
+ }
248
+
249
+ console.log(env.OPENCLAW_GATEWAY_TOKEN);
250
+ }
251
+
252
+ async function approve(argv, { cwd }) {
253
+ await assertDockerAvailable();
254
+
255
+ const { instance, requestId } = resolveApproveRequest(argv, { cwd });
256
+ const paths = stackPaths({ rootDir: resolveInstancesRoot(), instance });
257
+
258
+ await ensureStackExists(paths);
259
+
260
+ if (requestId && hasFlag(argv, "--latest")) {
261
+ throw new Error("Use either a specific request ID or `--latest`, not both.");
262
+ }
263
+
264
+ const commandArgs = ["openclaw", "devices", "approve"];
265
+ if (requestId) {
266
+ commandArgs.push(requestId);
267
+ } else {
268
+ commandArgs.push("--latest");
269
+ }
270
+
271
+ try {
272
+ const result = await runInstanceExec({
273
+ paths,
274
+ service: "openclaw-gateway",
275
+ commandArgs,
276
+ tty: false,
277
+ capture: true
278
+ });
279
+ const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
280
+ if (output) {
281
+ console.log(output);
282
+ }
283
+ } catch (error) {
284
+ const output = `${error?.stdout || ""}\n${error?.stderr || ""}\n${error?.message || ""}`.trim();
285
+ if (/No pending device pairing requests to approve/i.test(output)) {
286
+ throw new Error(
287
+ `No pending device pairing requests found for instance ${instance}. Open the Control UI first, trigger a browser connection, then run \`ocdev approve ${instance}\` again.`
288
+ );
289
+ }
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ async function execInContainer(argv, { cwd }) {
295
+ await assertDockerAvailable();
296
+
297
+ const { instance, commandArgs } = resolveExecRequest(argv, { cwd });
298
+ const paths = stackPaths({ rootDir: resolveInstancesRoot(), instance });
299
+ const service = readFlag(argv, "--service") || "openclaw-gateway";
300
+
301
+ await ensureStackExists(paths);
302
+ await runInstanceExec({
303
+ paths,
304
+ service,
305
+ commandArgs,
306
+ tty: shouldUseTty(argv)
307
+ });
308
+ }
309
+
310
+ async function claw(argv, { cwd }) {
311
+ await assertDockerAvailable();
312
+
313
+ const { instance, commandArgs } = resolveClawRequest(argv, { cwd });
314
+ const paths = stackPaths({ rootDir: resolveInstancesRoot(), instance });
315
+
316
+ await ensureStackExists(paths);
317
+ await runInstanceExec({
318
+ paths,
319
+ service: "openclaw-gateway",
320
+ commandArgs: ["openclaw", ...commandArgs],
321
+ tty: shouldUseTty(argv)
322
+ });
323
+ }
324
+
325
+ async function ensureStackExists(paths) {
326
+ const env = await readEnvFile(paths.envPath);
327
+ if (!Object.keys(env).length) {
328
+ throw new Error(
329
+ `No stack metadata found for instance ${paths.instance}. Run \`ocdev up --name ${paths.instance}\` first.`
330
+ );
331
+ }
332
+ }
333
+
334
+ async function safeComposeDown(paths) {
335
+ try {
336
+ await runDockerCompose({
337
+ envFile: paths.envPath,
338
+ composeFile: paths.composePath,
339
+ args: ["down"],
340
+ capture: true
341
+ });
342
+ } catch {
343
+ // Best-effort cleanup before a retry.
344
+ }
345
+ }
346
+
347
+ function shouldRetryPortAllocation(error, explicitPorts) {
348
+ if (explicitPorts) {
349
+ return false;
350
+ }
351
+
352
+ const output = `${error?.stdout || ""}\n${error?.stderr || ""}\n${error?.message || ""}`;
353
+ return /port is already allocated|Bind for .* failed: port is already allocated/i.test(output);
354
+ }
355
+
356
+ async function runInstanceExec({
357
+ paths,
358
+ service,
359
+ commandArgs,
360
+ tty,
361
+ capture = false
362
+ }) {
363
+ return runDockerComposeExec({
364
+ envFile: paths.envPath,
365
+ composeFile: paths.composePath,
366
+ service,
367
+ commandArgs,
368
+ tty,
369
+ capture
370
+ });
371
+ }
372
+
373
+ function shouldUseTty(argv) {
374
+ return !hasFlag(argv, "-T", "--no-tty") && process.stdin.isTTY && process.stdout.isTTY;
375
+ }
376
+
377
+ function splitCommandArgv(argv) {
378
+ const separatorIndex = argv.indexOf("--");
379
+ if (separatorIndex === -1) {
380
+ return {
381
+ before: argv,
382
+ after: [],
383
+ hasSeparator: false
384
+ };
385
+ }
386
+
387
+ return {
388
+ before: argv.slice(0, separatorIndex),
389
+ after: argv.slice(separatorIndex + 1),
390
+ hasSeparator: true
391
+ };
392
+ }
393
+
394
+ export function resolveExecRequest(argv, { cwd }) {
395
+ const { before, after, hasSeparator } = splitCommandArgv(argv);
396
+ if (!hasSeparator || !after.length) {
397
+ throw new Error(
398
+ `Usage: ocdev exec [instance] [--service <service>] [-T|--no-tty] -- <command...>\n\n${helpText(cwd)}`
399
+ );
400
+ }
401
+
402
+ return {
403
+ instance: readFlag(before, "--name") || readPositional(before, 1) || defaultInstanceName(cwd),
404
+ commandArgs: after
405
+ };
406
+ }
407
+
408
+ export function resolveApproveRequest(argv, { cwd }) {
409
+ const namedInstance = readFlag(argv, "--name");
410
+ const requestFlag = readFlag(argv, "--request-id");
411
+ const positionalRequestId = namedInstance ? readPositional(argv, 1) : readPositional(argv, 2);
412
+
413
+ if (requestFlag && positionalRequestId) {
414
+ throw new Error("Use either a positional request ID or `--request-id`, not both.");
415
+ }
416
+
417
+ return {
418
+ instance: namedInstance || readPositional(argv, 1) || defaultInstanceName(cwd),
419
+ requestId: requestFlag || positionalRequestId || null
420
+ };
421
+ }
422
+
423
+ export function resolveClawRequest(argv, { cwd }) {
424
+ const { before, after, hasSeparator } = splitCommandArgv(argv);
425
+ if (hasSeparator) {
426
+ if (!after.length) {
427
+ throw new Error(`Usage: ocdev claw [instance] -- <openclaw args...>\n\n${helpText(cwd)}`);
428
+ }
429
+
430
+ return {
431
+ instance: readFlag(before, "--name") || readPositional(before, 1) || defaultInstanceName(cwd),
432
+ commandArgs: after
433
+ };
434
+ }
435
+
436
+ const namedInstance = readFlag(argv, "--name");
437
+ const commandArgs = [];
438
+ for (let index = 1; index < argv.length; index += 1) {
439
+ const token = argv[index];
440
+ if (token === "--name") {
441
+ index += 1;
442
+ continue;
443
+ }
444
+ commandArgs.push(token);
445
+ }
446
+
447
+ if (!commandArgs.length) {
448
+ throw new Error(`Usage: ocdev claw [--name <instance>] <openclaw args...>\n\n${helpText(cwd)}`);
449
+ }
450
+
451
+ return {
452
+ instance: namedInstance || defaultInstanceName(cwd),
453
+ commandArgs
454
+ };
455
+ }
456
+
457
+ function printHelp(cwd) {
458
+ console.log(helpText(cwd));
459
+ }
460
+
461
+ export function helpText(cwd) {
462
+ const defaultName = defaultInstanceName(cwd);
463
+
464
+ return [
465
+ "ocdev - OpenClaw developer instance launcher",
466
+ "",
467
+ "Usage:",
468
+ " ocdev up [--name <instance>] [--profile <profile>] [--image <image>] [--gateway-port <port>] [--bridge-port <port>] [--timezone <iana-tz>] [--refresh-template]",
469
+ " ocdev down [instance] [--volumes]",
470
+ " ocdev logs [instance] [--service <service>]",
471
+ " ocdev token [instance]",
472
+ " ocdev approve [instance] [requestId|--latest] [--request-id <id>]",
473
+ " ocdev exec [instance] [--service <service>] [-T|--no-tty] -- <command...>",
474
+ " ocdev claw [--name <instance>] <openclaw args...>",
475
+ " ocdev claw [instance] -- <openclaw args...>",
476
+ " ocdev help",
477
+ "",
478
+ `Defaults: instance=${defaultName}, profile=node-python`,
479
+ `State root: ${resolveInstancesRoot()}`
480
+ ].join("\n");
481
+ }
@@ -0,0 +1,17 @@
1
+ export const DEFAULT_PROFILE = "node-python";
2
+ export const DEFAULT_GATEWAY_PORT = 18789;
3
+ export const DEFAULT_BRIDGE_PORT_OFFSET = 1;
4
+ export const DEFAULT_IMAGE_REGISTRY = "ghcr.io/ialaddin/openclaw-developer-images";
5
+
6
+ export const PROFILE_ALIASES = new Map([
7
+ ["python-node", "node-python"]
8
+ ]);
9
+
10
+ export const SUPPORTED_PROFILES = new Set([
11
+ "node",
12
+ "python",
13
+ "go",
14
+ "go-python",
15
+ "node-python",
16
+ "rust-cpp"
17
+ ]);
@@ -0,0 +1,151 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export async function assertDockerAvailable() {
4
+ await runCommand("docker", ["version"], { stdio: "ignore" }).catch(() => {
5
+ throw new Error("Docker is required but `docker version` failed. Start Docker Desktop or install Docker first.");
6
+ });
7
+
8
+ await runCommand("docker", ["compose", "version"], { stdio: "ignore" }).catch(() => {
9
+ throw new Error("Docker Compose v2 is required but `docker compose version` failed.");
10
+ });
11
+ }
12
+
13
+ export async function runDockerCompose({
14
+ envFile,
15
+ composeFile,
16
+ args,
17
+ stdio = "inherit",
18
+ capture = false
19
+ }) {
20
+ return runCommand("docker", ["compose", "--env-file", envFile, "-f", composeFile, ...args], {
21
+ stdio,
22
+ capture
23
+ });
24
+ }
25
+
26
+ export async function runDockerComposeExec({
27
+ envFile,
28
+ composeFile,
29
+ service,
30
+ commandArgs,
31
+ tty = process.stdin.isTTY && process.stdout.isTTY,
32
+ stdio = "inherit",
33
+ capture = false
34
+ }) {
35
+ const args = ["compose", "--env-file", envFile, "-f", composeFile, "exec"];
36
+ if (!tty) {
37
+ args.push("-T");
38
+ }
39
+ args.push(service, ...commandArgs);
40
+
41
+ return runCommand("docker", args, {
42
+ stdio,
43
+ capture
44
+ });
45
+ }
46
+
47
+ export async function isComposeServiceRunning({
48
+ envFile,
49
+ composeFile,
50
+ service
51
+ }) {
52
+ try {
53
+ const result = await runDockerCompose({
54
+ envFile,
55
+ composeFile,
56
+ args: ["ps", "--services", "--status", "running"],
57
+ capture: true
58
+ });
59
+
60
+ return result.stdout
61
+ .split(/\r?\n/)
62
+ .map((line) => line.trim())
63
+ .filter(Boolean)
64
+ .includes(service);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ export async function collectComposeDiagnostics({
71
+ envFile,
72
+ composeFile,
73
+ service = "openclaw-gateway"
74
+ }) {
75
+ const sections = [];
76
+
77
+ const psOutput = await tryComposeCommand({
78
+ envFile,
79
+ composeFile,
80
+ args: ["ps", "--all"],
81
+ label: "docker compose ps --all"
82
+ });
83
+ const logsOutput = await tryComposeCommand({
84
+ envFile,
85
+ composeFile,
86
+ args: ["logs", "--tail", "80", service],
87
+ label: `docker compose logs --tail 80 ${service}`
88
+ });
89
+
90
+ if (psOutput) {
91
+ sections.push(psOutput);
92
+ }
93
+ if (logsOutput) {
94
+ sections.push(logsOutput);
95
+ }
96
+
97
+ return sections.join("\n\n");
98
+ }
99
+
100
+ function runCommand(command, args, options = {}) {
101
+ return new Promise((resolve, reject) => {
102
+ const capture = Boolean(options.capture);
103
+ const child = spawn(command, args, {
104
+ stdio: capture ? "pipe" : options.stdio ?? "pipe"
105
+ });
106
+ let stdout = "";
107
+ let stderr = "";
108
+
109
+ if (child.stdout) {
110
+ child.stdout.on("data", (chunk) => {
111
+ stdout += chunk.toString();
112
+ });
113
+ }
114
+
115
+ if (child.stderr) {
116
+ child.stderr.on("data", (chunk) => {
117
+ stderr += chunk.toString();
118
+ });
119
+ }
120
+
121
+ child.on("error", reject);
122
+ child.on("exit", (code) => {
123
+ if (code === 0) {
124
+ resolve({ code: 0, stdout, stderr });
125
+ return;
126
+ }
127
+
128
+ const error = new Error(`${command} ${args.join(" ")} exited with code ${code ?? 1}`);
129
+ error.code = code ?? 1;
130
+ error.stdout = stdout;
131
+ error.stderr = stderr;
132
+ reject(error);
133
+ });
134
+ });
135
+ }
136
+
137
+ async function tryComposeCommand({ envFile, composeFile, args, label }) {
138
+ try {
139
+ const result = await runDockerCompose({
140
+ envFile,
141
+ composeFile,
142
+ args,
143
+ capture: true
144
+ });
145
+ const output = `${result.stdout}${result.stderr}`.trim();
146
+ return output ? `${label}\n${output}` : null;
147
+ } catch (error) {
148
+ const output = `${error.stdout || ""}${error.stderr || ""}`.trim();
149
+ return output ? `${label}\n${output}` : null;
150
+ }
151
+ }
@@ -0,0 +1,395 @@
1
+ import crypto from "node:crypto";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ DEFAULT_BRIDGE_PORT_OFFSET,
8
+ DEFAULT_GATEWAY_PORT,
9
+ DEFAULT_IMAGE_REGISTRY,
10
+ DEFAULT_PROFILE,
11
+ PROFILE_ALIASES,
12
+ SUPPORTED_PROFILES
13
+ } from "./constants.mjs";
14
+
15
+ const COMPOSE_TEMPLATE_PATH = new URL("../../assets/docker-compose.instance.yml", import.meta.url);
16
+ const FLAGS_WITH_VALUES = new Set([
17
+ "--name",
18
+ "--profile",
19
+ "--image",
20
+ "--gateway-port",
21
+ "--bridge-port",
22
+ "--timezone",
23
+ "--service",
24
+ "--request-id"
25
+ ]);
26
+ const MANAGED_ENV_KEYS = [
27
+ "COMPOSE_PROJECT_NAME",
28
+ "OPENCLAW_INSTANCE",
29
+ "OPENCLAW_PROFILE",
30
+ "OPENCLAW_IMAGE",
31
+ "OPENCLAW_GATEWAY_PORT",
32
+ "OPENCLAW_BRIDGE_PORT",
33
+ "OPENCLAW_GATEWAY_BIND",
34
+ "OPENCLAW_GATEWAY_TOKEN",
35
+ "OPENCLAW_TZ",
36
+ "OPENCLAW_HOME_VOLUME",
37
+ "OPENCLAW_STATE_VOLUME",
38
+ "OPENCLAW_WORKSPACE_VOLUME"
39
+ ];
40
+ const MANAGED_ENV_HEADER = [
41
+ "# Managed by ocdev.",
42
+ "# Managed keys may be refreshed when you run `ocdev up` again.",
43
+ "# Extra keys are preserved, but comments and ordering are not guaranteed."
44
+ ].join("\n");
45
+
46
+ export function sanitizeInstanceName(rawValue) {
47
+ const sanitized = `${rawValue || ""}`
48
+ .trim()
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, "-")
51
+ .replace(/^-+|-+$/g, "")
52
+ .slice(0, 40);
53
+
54
+ if (!sanitized) {
55
+ throw new Error("Instance name must contain at least one ASCII letter or number.");
56
+ }
57
+
58
+ return sanitized;
59
+ }
60
+
61
+ export function defaultInstanceName(cwd = process.cwd()) {
62
+ return sanitizeInstanceName(path.basename(cwd) || "default");
63
+ }
64
+
65
+ export function resolveManagerHome({
66
+ env = process.env,
67
+ platform = process.platform,
68
+ homeDir = os.homedir()
69
+ } = {}) {
70
+ if (env.OPENCLAW_DEV_HOME?.trim()) {
71
+ return path.resolve(env.OPENCLAW_DEV_HOME.trim());
72
+ }
73
+
74
+ if (platform === "darwin") {
75
+ return path.join(homeDir, "Library", "Application Support", "openclaw-dev");
76
+ }
77
+
78
+ if (platform === "win32" && env.APPDATA?.trim()) {
79
+ return path.join(env.APPDATA.trim(), "openclaw-dev");
80
+ }
81
+
82
+ if (env.XDG_STATE_HOME?.trim()) {
83
+ return path.join(env.XDG_STATE_HOME.trim(), "openclaw-dev");
84
+ }
85
+
86
+ return path.join(homeDir, ".local", "state", "openclaw-dev");
87
+ }
88
+
89
+ export function resolveInstancesRoot(options) {
90
+ return path.join(resolveManagerHome(options), "instances");
91
+ }
92
+
93
+ export function normalizeProfileId(rawProfile = DEFAULT_PROFILE) {
94
+ const normalized = `${rawProfile || ""}`.trim().toLowerCase();
95
+ const aliasResolved = PROFILE_ALIASES.get(normalized) || normalized || DEFAULT_PROFILE;
96
+
97
+ if (!SUPPORTED_PROFILES.has(aliasResolved)) {
98
+ throw new Error(
99
+ `Unsupported profile: ${rawProfile}. Supported profiles: ${[...SUPPORTED_PROFILES].join(", ")}`
100
+ );
101
+ }
102
+
103
+ return aliasResolved;
104
+ }
105
+
106
+ export function defaultImageForProfile(profile) {
107
+ return `${DEFAULT_IMAGE_REGISTRY}:main-${normalizeProfileId(profile)}`;
108
+ }
109
+
110
+ export function detectTimezone() {
111
+ return process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
112
+ }
113
+
114
+ export function buildInstanceConfig({
115
+ instance,
116
+ profile,
117
+ image,
118
+ gatewayPort = DEFAULT_GATEWAY_PORT,
119
+ bridgePort,
120
+ timezone = "UTC"
121
+ }) {
122
+ const sanitizedInstance = sanitizeInstanceName(instance);
123
+ const composeProjectName = `openclaw-${sanitizedInstance}`;
124
+ const resolvedBridgePort = bridgePort ?? gatewayPort + DEFAULT_BRIDGE_PORT_OFFSET;
125
+ const prefix = `openclaw_${sanitizedInstance}`;
126
+
127
+ return {
128
+ instance: sanitizedInstance,
129
+ profile: normalizeProfileId(profile),
130
+ image: image || defaultImageForProfile(profile),
131
+ composeProjectName,
132
+ gatewayPort,
133
+ bridgePort: resolvedBridgePort,
134
+ timezone,
135
+ gatewayToken: crypto.randomBytes(32).toString("hex"),
136
+ volumes: {
137
+ home: `${prefix}_home`,
138
+ state: `${prefix}_state`,
139
+ workspace: `${prefix}_workspace`
140
+ }
141
+ };
142
+ }
143
+
144
+ export function renderInstanceEnv(config, existingEntries = {}) {
145
+ return renderMergedEnvFile({
146
+ existingEntries,
147
+ managedEntries: buildManagedEnvEntries(config)
148
+ });
149
+ }
150
+
151
+ export function stackPaths({ rootDir = resolveInstancesRoot(), instance }) {
152
+ const sanitizedInstance = sanitizeInstanceName(instance);
153
+ const stackDir = path.join(rootDir, sanitizedInstance);
154
+
155
+ return {
156
+ instance: sanitizedInstance,
157
+ stackDir,
158
+ envPath: path.join(stackDir, ".env"),
159
+ composePath: path.join(stackDir, "docker-compose.instance.yml"),
160
+ readmePath: path.join(stackDir, "README.md")
161
+ };
162
+ }
163
+
164
+ export function readFlag(argv, flag) {
165
+ const index = argv.indexOf(flag);
166
+ return index === -1 ? null : argv[index + 1] ?? null;
167
+ }
168
+
169
+ export function hasFlag(argv, ...flags) {
170
+ return flags.some((flag) => argv.includes(flag));
171
+ }
172
+
173
+ export function readPositional(argv, offset = 0) {
174
+ const positionals = [];
175
+
176
+ for (let index = 0; index < argv.length; index += 1) {
177
+ const token = argv[index];
178
+ if (!token) {
179
+ continue;
180
+ }
181
+
182
+ if (token.startsWith("-")) {
183
+ if (FLAGS_WITH_VALUES.has(token)) {
184
+ index += 1;
185
+ }
186
+ continue;
187
+ }
188
+
189
+ positionals.push(token);
190
+ }
191
+
192
+ return positionals[offset] ?? null;
193
+ }
194
+
195
+ export function numberFlag(argv, flag, fallbackValue) {
196
+ const value = readFlag(argv, flag);
197
+ if (!value) {
198
+ return fallbackValue;
199
+ }
200
+
201
+ const parsed = Number.parseInt(value, 10);
202
+ if (!Number.isFinite(parsed) || parsed <= 0) {
203
+ throw new Error(`${flag} must be a positive integer.`);
204
+ }
205
+
206
+ return parsed;
207
+ }
208
+
209
+ export async function readEnvFile(filePath) {
210
+ try {
211
+ const raw = await readFile(filePath, "utf8");
212
+ return parseEnvContent(raw);
213
+ } catch {
214
+ return {};
215
+ }
216
+ }
217
+
218
+ export function buildConfigFromArgs({ cwd = process.cwd(), argv, defaults = {} }) {
219
+ const instance = sanitizeInstanceName(
220
+ readFlag(argv, "--name") || readPositional(argv, 1) || defaults.instance || defaultInstanceName(cwd)
221
+ );
222
+ const existingEnv = defaults.existingEnv || {};
223
+ const profile = normalizeProfileId(readFlag(argv, "--profile") || existingEnv.OPENCLAW_PROFILE || DEFAULT_PROFILE);
224
+ const gatewayPort = numberFlag(
225
+ argv,
226
+ "--gateway-port",
227
+ Number.parseInt(existingEnv.OPENCLAW_GATEWAY_PORT || "", 10) || DEFAULT_GATEWAY_PORT
228
+ );
229
+ const bridgePort = numberFlag(
230
+ argv,
231
+ "--bridge-port",
232
+ Number.parseInt(existingEnv.OPENCLAW_BRIDGE_PORT || "", 10) || gatewayPort + DEFAULT_BRIDGE_PORT_OFFSET
233
+ );
234
+ const timezone = readFlag(argv, "--timezone") || existingEnv.OPENCLAW_TZ || detectTimezone();
235
+ const image = readFlag(argv, "--image") || existingEnv.OPENCLAW_IMAGE || defaultImageForProfile(profile);
236
+
237
+ const config = buildInstanceConfig({
238
+ instance,
239
+ profile,
240
+ image,
241
+ gatewayPort,
242
+ bridgePort,
243
+ timezone
244
+ });
245
+
246
+ config.gatewayToken = existingEnv.OPENCLAW_GATEWAY_TOKEN || config.gatewayToken;
247
+ config.composeProjectName = existingEnv.COMPOSE_PROJECT_NAME || config.composeProjectName;
248
+ config.volumes = {
249
+ home: existingEnv.OPENCLAW_HOME_VOLUME || config.volumes.home,
250
+ state: existingEnv.OPENCLAW_STATE_VOLUME || config.volumes.state,
251
+ workspace: existingEnv.OPENCLAW_WORKSPACE_VOLUME || config.volumes.workspace
252
+ };
253
+
254
+ return config;
255
+ }
256
+
257
+ export async function loadComposeTemplate() {
258
+ return readFile(COMPOSE_TEMPLATE_PATH, "utf8");
259
+ }
260
+
261
+ export async function writeStackFiles({
262
+ rootDir = resolveInstancesRoot(),
263
+ cwd,
264
+ config,
265
+ refreshTemplate = false
266
+ }) {
267
+ const effectiveRootDir = cwd || rootDir;
268
+ const paths = stackPaths({ rootDir: effectiveRootDir, instance: config.instance });
269
+ const composeTemplate = await loadComposeTemplate();
270
+ const existingEnv = await readEnvFile(paths.envPath);
271
+
272
+ await mkdir(paths.stackDir, { recursive: true });
273
+ await writeFile(paths.envPath, renderInstanceEnv(config, existingEnv), "utf8");
274
+
275
+ if (refreshTemplate || !(await fileExists(paths.composePath))) {
276
+ await writeFile(paths.composePath, composeTemplate, "utf8");
277
+ }
278
+
279
+ if (refreshTemplate || !(await fileExists(paths.readmePath))) {
280
+ await writeFile(paths.readmePath, renderStackReadme(config, paths), "utf8");
281
+ }
282
+
283
+ return paths;
284
+ }
285
+
286
+ export function buildManagedEnvEntries(config) {
287
+ return {
288
+ COMPOSE_PROJECT_NAME: config.composeProjectName,
289
+ OPENCLAW_INSTANCE: config.instance,
290
+ OPENCLAW_PROFILE: config.profile,
291
+ OPENCLAW_IMAGE: config.image,
292
+ OPENCLAW_GATEWAY_PORT: `${config.gatewayPort}`,
293
+ OPENCLAW_BRIDGE_PORT: `${config.bridgePort}`,
294
+ OPENCLAW_GATEWAY_BIND: "lan",
295
+ OPENCLAW_GATEWAY_TOKEN: config.gatewayToken,
296
+ OPENCLAW_TZ: config.timezone,
297
+ OPENCLAW_HOME_VOLUME: config.volumes.home,
298
+ OPENCLAW_STATE_VOLUME: config.volumes.state,
299
+ OPENCLAW_WORKSPACE_VOLUME: config.volumes.workspace
300
+ };
301
+ }
302
+
303
+ function renderMergedEnvFile({ existingEntries = {}, managedEntries }) {
304
+ const extraEntries = {};
305
+ for (const [key, value] of Object.entries(existingEntries)) {
306
+ if (!MANAGED_ENV_KEYS.includes(key)) {
307
+ extraEntries[key] = value;
308
+ }
309
+ }
310
+
311
+ const lines = [MANAGED_ENV_HEADER, ""];
312
+
313
+ for (const key of MANAGED_ENV_KEYS) {
314
+ lines.push(`${key}=${managedEntries[key]}`);
315
+ }
316
+
317
+ const extraKeys = Object.keys(extraEntries).sort();
318
+ if (extraKeys.length) {
319
+ lines.push("", "# Preserved extra keys");
320
+ for (const key of extraKeys) {
321
+ lines.push(`${key}=${extraEntries[key]}`);
322
+ }
323
+ }
324
+
325
+ return `${lines.join("\n")}\n`;
326
+ }
327
+
328
+ function renderStackReadme(config, paths) {
329
+ return [
330
+ `# ${config.instance}`,
331
+ "",
332
+ "Managed by `ocdev`.",
333
+ "",
334
+ `State directory: \`${shellPath(paths.stackDir)}\``,
335
+ "Live profile, image, token, and port values are stored in `.env`.",
336
+ "",
337
+ "Managed file policy:",
338
+ "- `.env` managed keys may be refreshed on `ocdev up`; extra keys are preserved.",
339
+ "- `docker-compose.instance.yml` and `README.md` are only created once unless you explicitly refresh them later.",
340
+ "",
341
+ "Recommended `ocdev` commands:",
342
+ `- \`ocdev token ${config.instance}\``,
343
+ `- \`ocdev approve ${config.instance}\``,
344
+ `- \`ocdev logs ${config.instance}\``,
345
+ `- \`ocdev claw --name ${config.instance} devices list\``,
346
+ `- \`ocdev down ${config.instance}\``,
347
+ "",
348
+ "Low-level Docker Compose commands:",
349
+ `- \`docker compose --env-file ${shellQuote(paths.envPath)} -f ${shellQuote(paths.composePath)} up -d\``,
350
+ `- \`docker compose --env-file ${shellQuote(paths.envPath)} -f ${shellQuote(paths.composePath)} logs -f openclaw-gateway\``,
351
+ `- \`docker compose --env-file ${shellQuote(paths.envPath)} -f ${shellQuote(paths.composePath)} down\``,
352
+ ""
353
+ ].join("\n");
354
+ }
355
+
356
+ function parseEnvContent(raw) {
357
+ const entries = {};
358
+
359
+ for (const line of raw.split(/\r?\n/)) {
360
+ if (!line || line.startsWith("#")) {
361
+ continue;
362
+ }
363
+
364
+ const separatorIndex = line.indexOf("=");
365
+ if (separatorIndex === -1) {
366
+ continue;
367
+ }
368
+
369
+ const key = line.slice(0, separatorIndex).trim();
370
+ const value = line.slice(separatorIndex + 1).trim();
371
+ if (key) {
372
+ entries[key] = value;
373
+ }
374
+ }
375
+
376
+ return entries;
377
+ }
378
+
379
+ async function fileExists(filePath) {
380
+ try {
381
+ await access(filePath);
382
+ return true;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+
388
+ function shellPath(filePath) {
389
+ return filePath.replace(/\\/g, "/");
390
+ }
391
+
392
+ function shellQuote(filePath) {
393
+ const normalized = shellPath(filePath);
394
+ return `'${normalized.replace(/'/g, `'\\''`)}'`;
395
+ }
@@ -0,0 +1,64 @@
1
+ import net from "node:net";
2
+
3
+ export async function resolvePortPlan({
4
+ gatewayPort,
5
+ bridgePort,
6
+ preserveExistingPorts = false
7
+ }) {
8
+ if (preserveExistingPorts) {
9
+ return {
10
+ gatewayPort,
11
+ bridgePort,
12
+ shifted: false,
13
+ originalGatewayPort: gatewayPort,
14
+ originalBridgePort: bridgePort
15
+ };
16
+ }
17
+
18
+ const requestedBridgePort = bridgePort ?? gatewayPort + 1;
19
+ const delta = requestedBridgePort - gatewayPort;
20
+ let currentGateway = gatewayPort;
21
+ let currentBridge = requestedBridgePort;
22
+
23
+ while (!(await arePortsAvailable(currentGateway, currentBridge))) {
24
+ currentGateway += 1;
25
+ currentBridge += delta === 0 ? 1 : delta;
26
+ }
27
+
28
+ return {
29
+ gatewayPort: currentGateway,
30
+ bridgePort: currentBridge,
31
+ shifted: currentGateway !== gatewayPort || currentBridge !== requestedBridgePort,
32
+ originalGatewayPort: gatewayPort,
33
+ originalBridgePort: requestedBridgePort
34
+ };
35
+ }
36
+
37
+ async function arePortsAvailable(gatewayPort, bridgePort) {
38
+ if (gatewayPort === bridgePort) {
39
+ return false;
40
+ }
41
+
42
+ const gatewayAvailable = await isPortAvailable(gatewayPort);
43
+ if (!gatewayAvailable) {
44
+ return false;
45
+ }
46
+
47
+ return isPortAvailable(bridgePort);
48
+ }
49
+
50
+ function isPortAvailable(port) {
51
+ return new Promise((resolve) => {
52
+ const server = net.createServer();
53
+
54
+ server.once("error", () => {
55
+ resolve(false);
56
+ });
57
+
58
+ server.once("listening", () => {
59
+ server.close(() => resolve(true));
60
+ });
61
+
62
+ server.listen(port, "0.0.0.0");
63
+ });
64
+ }