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 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.entryFile.endsWith(path.join("dist", "server", "index.js"));
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
- child.once("exit", () => {
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
- async function waitForRuntime(config, timeoutMs) {
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(`Forge local runtime did not become healthy at ${config.baseUrl} within ${timeoutMs}ms`);
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,
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.12",
5
+ "version": "0.2.13",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",