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.
- package/assets/docker-compose.instance.yml +101 -0
- package/bin/ocdev +8 -0
- package/package.json +21 -0
- package/src/cli.mjs +481 -0
- package/src/lib/constants.mjs +17 -0
- package/src/lib/docker.mjs +151 -0
- package/src/lib/instance.mjs +395 -0
- package/src/lib/ports.mjs +64 -0
|
@@ -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
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
|
+
}
|