forge-openclaw-plugin 0.2.12 → 0.2.13
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 +10 -0
- package/dist/openclaw/local-runtime.js +142 -9
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -214,6 +214,16 @@ openclaw forge status
|
|
|
214
214
|
|
|
215
215
|
These commands only manage the runtime when it was auto-started by the OpenClaw plugin. If Forge was started manually some other way, they tell you that instead of killing unrelated processes.
|
|
216
216
|
|
|
217
|
+
If the local runtime fails to come up, check the plugin-managed runtime log at:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
~/.openclaw/logs/forge-openclaw-plugin/127.0.0.1-4317.log
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
On clean installs, the plugin now also repairs missing bundled runtime dependencies on first local start before it launches Forge.
|
|
224
|
+
|
|
225
|
+
The startup error now points at that log file when the child process exits before Forge becomes healthy.
|
|
226
|
+
|
|
217
227
|
## Publishing and listing
|
|
218
228
|
|
|
219
229
|
The reliable publication path for the Forge plugin is:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -10,7 +10,10 @@ const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
|
|
10
10
|
const HEALTHCHECK_INTERVAL_MS = 250;
|
|
11
11
|
let managedRuntimeChild = null;
|
|
12
12
|
let managedRuntimeKey = null;
|
|
13
|
+
let managedRuntimeLogPath = null;
|
|
14
|
+
let lastRuntimeExitDetails = null;
|
|
13
15
|
let startupPromise = null;
|
|
16
|
+
const dependencyInstallPromises = new Map();
|
|
14
17
|
function runtimeKey(config) {
|
|
15
18
|
return `${config.origin}:${config.port}`;
|
|
16
19
|
}
|
|
@@ -26,7 +29,8 @@ async function writeRuntimeState(config, pid) {
|
|
|
26
29
|
origin: config.origin,
|
|
27
30
|
port: config.port,
|
|
28
31
|
baseUrl: config.baseUrl,
|
|
29
|
-
startedAt: new Date().toISOString()
|
|
32
|
+
startedAt: new Date().toISOString(),
|
|
33
|
+
logPath: managedRuntimeLogPath
|
|
30
34
|
};
|
|
31
35
|
await writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
32
36
|
}
|
|
@@ -45,7 +49,8 @@ async function readRuntimeState(config) {
|
|
|
45
49
|
origin: typeof parsed.origin === "string" ? parsed.origin : config.origin,
|
|
46
50
|
port: typeof parsed.port === "number" ? parsed.port : config.port,
|
|
47
51
|
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : config.baseUrl,
|
|
48
|
-
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : new Date(0).toISOString()
|
|
52
|
+
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : new Date(0).toISOString(),
|
|
53
|
+
logPath: typeof parsed.logPath === "string" ? parsed.logPath : null
|
|
49
54
|
};
|
|
50
55
|
}
|
|
51
56
|
catch {
|
|
@@ -82,6 +87,86 @@ function isLocalOrigin(origin) {
|
|
|
82
87
|
function getCurrentModuleRoot() {
|
|
83
88
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
84
89
|
}
|
|
90
|
+
function getRuntimeLogPath(config) {
|
|
91
|
+
const origin = new URL(config.origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
92
|
+
return path.join(homedir(), ".openclaw", "logs", "forge-openclaw-plugin", `${origin}-${config.port}.log`);
|
|
93
|
+
}
|
|
94
|
+
function openRuntimeLogFile(logPath) {
|
|
95
|
+
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
96
|
+
return openSync(logPath, "a");
|
|
97
|
+
}
|
|
98
|
+
function isPackagedServerPlan(plan) {
|
|
99
|
+
return plan.entryFile.endsWith(path.join("dist", "server", "index.js"));
|
|
100
|
+
}
|
|
101
|
+
function getNpmInvocation() {
|
|
102
|
+
const binDir = path.dirname(process.execPath);
|
|
103
|
+
const npmCli = process.platform === "win32" ? path.join(binDir, "npm.cmd") : path.join(binDir, "npm");
|
|
104
|
+
if (existsSync(npmCli)) {
|
|
105
|
+
return {
|
|
106
|
+
command: process.execPath,
|
|
107
|
+
args: [npmCli]
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
command: "npm",
|
|
112
|
+
args: []
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function getMissingRuntimeDependencies(packageRoot) {
|
|
116
|
+
const packageJsonPath = path.join(packageRoot, "package.json");
|
|
117
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
118
|
+
const dependencyNames = Object.keys(packageJson.dependencies ?? {});
|
|
119
|
+
return dependencyNames.filter((dependencyName) => !existsSync(path.join(packageRoot, "node_modules", dependencyName, "package.json")));
|
|
120
|
+
}
|
|
121
|
+
async function installMissingRuntimeDependencies(packageRoot, logPath) {
|
|
122
|
+
const { command, args } = getNpmInvocation();
|
|
123
|
+
const logFd = openRuntimeLogFile(logPath);
|
|
124
|
+
try {
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
const child = spawn(command, [...args, "install", "--omit=dev", "--silent", "--ignore-scripts"], {
|
|
127
|
+
cwd: packageRoot,
|
|
128
|
+
env: process.env,
|
|
129
|
+
stdio: ["ignore", logFd, logFd]
|
|
130
|
+
});
|
|
131
|
+
child.once("error", reject);
|
|
132
|
+
child.once("exit", (code, signal) => {
|
|
133
|
+
if (code === 0) {
|
|
134
|
+
resolve();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
reject(new Error(`npm dependency install exited with ${signal ? `signal ${signal}` : `code ${code ?? "unknown"}`}`));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
closeSync(logFd);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function ensurePackagedRuntimeDependencies(plan, config) {
|
|
146
|
+
if (!isPackagedServerPlan(plan)) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const missingDependencies = await getMissingRuntimeDependencies(plan.packageRoot);
|
|
150
|
+
if (missingDependencies.length === 0) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const logPath = getRuntimeLogPath(config);
|
|
154
|
+
managedRuntimeLogPath = logPath;
|
|
155
|
+
const installKey = plan.packageRoot;
|
|
156
|
+
const existingInstall = dependencyInstallPromises.get(installKey);
|
|
157
|
+
if (existingInstall) {
|
|
158
|
+
return existingInstall;
|
|
159
|
+
}
|
|
160
|
+
const installPromise = installMissingRuntimeDependencies(plan.packageRoot, logPath)
|
|
161
|
+
.catch((error) => {
|
|
162
|
+
throw new Error(`Forge runtime dependencies are missing (${missingDependencies.join(", ")}) and automatic install failed. Check logs at ${logPath}. Cause: ${error instanceof Error ? error.message : String(error)}`);
|
|
163
|
+
})
|
|
164
|
+
.finally(() => {
|
|
165
|
+
dependencyInstallPromises.delete(installKey);
|
|
166
|
+
});
|
|
167
|
+
dependencyInstallPromises.set(installKey, installPromise);
|
|
168
|
+
return installPromise;
|
|
169
|
+
}
|
|
85
170
|
function resolveLaunchPlan() {
|
|
86
171
|
const moduleRoot = getCurrentModuleRoot();
|
|
87
172
|
// Published or linked plugin package runtime.
|
|
@@ -126,8 +211,10 @@ async function isForgeHealthy(config, timeoutMs) {
|
|
|
126
211
|
}
|
|
127
212
|
}
|
|
128
213
|
function spawnManagedRuntime(config, plan) {
|
|
129
|
-
const isPackagedServer = plan
|
|
214
|
+
const isPackagedServer = isPackagedServerPlan(plan);
|
|
130
215
|
const args = isPackagedServer ? [plan.entryFile] : [plan.entryFile, path.join(plan.packageRoot, "server", "src", "index.ts")];
|
|
216
|
+
const logPath = getRuntimeLogPath(config);
|
|
217
|
+
const logFd = openRuntimeLogFile(logPath);
|
|
131
218
|
const child = spawn(process.execPath, args, {
|
|
132
219
|
cwd: plan.packageRoot,
|
|
133
220
|
env: {
|
|
@@ -137,11 +224,20 @@ function spawnManagedRuntime(config, plan) {
|
|
|
137
224
|
FORGE_BASE_PATH: "/forge/",
|
|
138
225
|
...(config.dataRoot ? { FORGE_DATA_ROOT: config.dataRoot } : {})
|
|
139
226
|
},
|
|
140
|
-
stdio: "ignore",
|
|
227
|
+
stdio: ["ignore", logFd, logFd],
|
|
141
228
|
detached: true
|
|
142
229
|
});
|
|
230
|
+
closeSync(logFd);
|
|
143
231
|
child.unref();
|
|
144
|
-
|
|
232
|
+
managedRuntimeLogPath = logPath;
|
|
233
|
+
lastRuntimeExitDetails = null;
|
|
234
|
+
child.once("exit", (code, signal) => {
|
|
235
|
+
lastRuntimeExitDetails = {
|
|
236
|
+
pid: child.pid ?? -1,
|
|
237
|
+
code,
|
|
238
|
+
signal,
|
|
239
|
+
logPath
|
|
240
|
+
};
|
|
145
241
|
if (managedRuntimeChild === child) {
|
|
146
242
|
managedRuntimeChild = null;
|
|
147
243
|
managedRuntimeKey = null;
|
|
@@ -154,15 +250,31 @@ function spawnManagedRuntime(config, plan) {
|
|
|
154
250
|
// State tracking is best effort. Runtime health checks remain authoritative.
|
|
155
251
|
});
|
|
156
252
|
}
|
|
157
|
-
|
|
253
|
+
function formatRuntimeFailure(details, config) {
|
|
254
|
+
if (!details) {
|
|
255
|
+
return `Forge local runtime did not become healthy at ${config.baseUrl} within ${STARTUP_TIMEOUT_MS}ms`;
|
|
256
|
+
}
|
|
257
|
+
const suffix = details.logPath ? ` Check logs at ${details.logPath}.` : "";
|
|
258
|
+
if (details.signal) {
|
|
259
|
+
return `Forge local runtime exited before becoming healthy at ${config.baseUrl} (signal ${details.signal}).${suffix}`;
|
|
260
|
+
}
|
|
261
|
+
if (typeof details.code === "number") {
|
|
262
|
+
return `Forge local runtime exited before becoming healthy at ${config.baseUrl} (code ${details.code}).${suffix}`;
|
|
263
|
+
}
|
|
264
|
+
return `Forge local runtime exited before becoming healthy at ${config.baseUrl}.${suffix}`;
|
|
265
|
+
}
|
|
266
|
+
async function waitForRuntime(config, timeoutMs, expectedPid) {
|
|
158
267
|
const deadline = Date.now() + timeoutMs;
|
|
159
268
|
while (Date.now() < deadline) {
|
|
160
269
|
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
161
270
|
return;
|
|
162
271
|
}
|
|
272
|
+
if (expectedPid !== null && lastRuntimeExitDetails?.pid === expectedPid) {
|
|
273
|
+
throw new Error(formatRuntimeFailure(lastRuntimeExitDetails, config));
|
|
274
|
+
}
|
|
163
275
|
await new Promise((resolve) => setTimeout(resolve, HEALTHCHECK_INTERVAL_MS));
|
|
164
276
|
}
|
|
165
|
-
throw new Error(
|
|
277
|
+
throw new Error(formatRuntimeFailure(lastRuntimeExitDetails, config));
|
|
166
278
|
}
|
|
167
279
|
export async function ensureForgeRuntimeReady(config) {
|
|
168
280
|
if (!isLocalOrigin(config.origin)) {
|
|
@@ -183,10 +295,11 @@ export async function ensureForgeRuntimeReady(config) {
|
|
|
183
295
|
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
184
296
|
return;
|
|
185
297
|
}
|
|
298
|
+
await ensurePackagedRuntimeDependencies(plan, config);
|
|
186
299
|
if (!managedRuntimeChild || managedRuntimeKey !== key || managedRuntimeChild.killed) {
|
|
187
300
|
spawnManagedRuntime(config, plan);
|
|
188
301
|
}
|
|
189
|
-
await waitForRuntime(config, STARTUP_TIMEOUT_MS);
|
|
302
|
+
await waitForRuntime(config, STARTUP_TIMEOUT_MS, managedRuntimeChild?.pid ?? null);
|
|
190
303
|
})().finally(() => {
|
|
191
304
|
startupPromise = null;
|
|
192
305
|
});
|
|
@@ -204,6 +317,16 @@ export async function startForgeRuntime(config) {
|
|
|
204
317
|
};
|
|
205
318
|
}
|
|
206
319
|
const existingState = await readRuntimeState(config);
|
|
320
|
+
if (!existingState && (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))) {
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
started: false,
|
|
324
|
+
managed: false,
|
|
325
|
+
message: `Forge is already running on ${config.baseUrl}, but it does not look like a plugin-managed runtime.`,
|
|
326
|
+
pid: null,
|
|
327
|
+
baseUrl: config.baseUrl
|
|
328
|
+
};
|
|
329
|
+
}
|
|
207
330
|
if (existingState && processExists(existingState.pid) && (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))) {
|
|
208
331
|
return {
|
|
209
332
|
ok: true,
|
|
@@ -216,6 +339,16 @@ export async function startForgeRuntime(config) {
|
|
|
216
339
|
}
|
|
217
340
|
await ensureForgeRuntimeReady(config);
|
|
218
341
|
const state = await readRuntimeState(config);
|
|
342
|
+
if (!state && (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))) {
|
|
343
|
+
return {
|
|
344
|
+
ok: true,
|
|
345
|
+
started: false,
|
|
346
|
+
managed: false,
|
|
347
|
+
message: `Forge is healthy on ${config.baseUrl}, but it does not look like a plugin-managed runtime.`,
|
|
348
|
+
pid: null,
|
|
349
|
+
baseUrl: config.baseUrl
|
|
350
|
+
};
|
|
351
|
+
}
|
|
219
352
|
return {
|
|
220
353
|
ok: true,
|
|
221
354
|
started: true,
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED