forge-openclaw-plugin 0.2.13 → 0.2.15
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
|
@@ -201,7 +201,7 @@ The skill is entity-format-driven. It teaches the agent how to:
|
|
|
201
201
|
- ask only for missing fields
|
|
202
202
|
- capture goals, projects, tasks, values, patterns, behaviors, beliefs, and trigger reports
|
|
203
203
|
|
|
204
|
-
For local use, set the plugin origin to `http://127.0.0.1` or `http://localhost` and the plugin will bring Forge up
|
|
204
|
+
For local use, set the plugin origin to `http://127.0.0.1` or `http://localhost` and the plugin will bring Forge up automatically. If you leave the default localhost setup alone and `4317` is already taken, Forge now moves to the next free local port and remembers that choice for future runs.
|
|
205
205
|
|
|
206
206
|
If you want to manage that plugin-managed local runtime cleanly, use:
|
|
207
207
|
|
|
@@ -217,7 +217,7 @@ These commands only manage the runtime when it was auto-started by the OpenClaw
|
|
|
217
217
|
If the local runtime fails to come up, check the plugin-managed runtime log at:
|
|
218
218
|
|
|
219
219
|
```bash
|
|
220
|
-
~/.openclaw/logs/forge-openclaw-plugin
|
|
220
|
+
~/.openclaw/logs/forge-openclaw-plugin/<host>-<port>.log
|
|
221
221
|
```
|
|
222
222
|
|
|
223
223
|
On clean installs, the plugin now also repairs missing bundled runtime dependencies on first local start before it launches Forge.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -8,18 +9,79 @@ const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
|
8
9
|
const STARTUP_TIMEOUT_MS = 15_000;
|
|
9
10
|
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
|
10
11
|
const HEALTHCHECK_INTERVAL_MS = 250;
|
|
12
|
+
const EXISTING_RUNTIME_GRACE_MS = 3_000;
|
|
13
|
+
const MAX_PORT_SCAN_ATTEMPTS = 20;
|
|
14
|
+
const FORGE_PLUGIN_ID = "forge-openclaw-plugin";
|
|
11
15
|
let managedRuntimeChild = null;
|
|
12
16
|
let managedRuntimeKey = null;
|
|
13
17
|
let managedRuntimeLogPath = null;
|
|
14
18
|
let lastRuntimeExitDetails = null;
|
|
15
19
|
let startupPromise = null;
|
|
20
|
+
let startupRuntimeKey = null;
|
|
16
21
|
const dependencyInstallPromises = new Map();
|
|
17
22
|
function runtimeKey(config) {
|
|
18
23
|
return `${config.origin}:${config.port}`;
|
|
19
24
|
}
|
|
25
|
+
function buildForgeBaseUrl(origin, port) {
|
|
26
|
+
const url = new URL(origin.endsWith("/") ? origin : `${origin}/`);
|
|
27
|
+
url.port = String(port);
|
|
28
|
+
url.pathname = "/";
|
|
29
|
+
url.search = "";
|
|
30
|
+
url.hash = "";
|
|
31
|
+
return url.origin;
|
|
32
|
+
}
|
|
33
|
+
function buildForgeWebAppUrl(origin, port) {
|
|
34
|
+
return `${buildForgeBaseUrl(origin, port)}/forge/`;
|
|
35
|
+
}
|
|
20
36
|
function getRuntimeStatePath(config) {
|
|
21
37
|
const origin = new URL(config.origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
22
|
-
return path.join(homedir(), ".openclaw", "run",
|
|
38
|
+
return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${origin}-${config.port}.json`);
|
|
39
|
+
}
|
|
40
|
+
function getPreferredPortStatePath(origin) {
|
|
41
|
+
const hostname = new URL(origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
42
|
+
return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${hostname}-preferred-port.json`);
|
|
43
|
+
}
|
|
44
|
+
function applyPortToConfig(config, port, portSource) {
|
|
45
|
+
config.port = port;
|
|
46
|
+
config.baseUrl = buildForgeBaseUrl(config.origin, port);
|
|
47
|
+
config.webAppUrl = buildForgeWebAppUrl(config.origin, port);
|
|
48
|
+
config.portSource = portSource;
|
|
49
|
+
}
|
|
50
|
+
async function writePreferredPortState(config, port) {
|
|
51
|
+
const statePath = getPreferredPortStatePath(config.origin);
|
|
52
|
+
await mkdir(path.dirname(statePath), { recursive: true });
|
|
53
|
+
await writeFile(statePath, `${JSON.stringify({ origin: config.origin, port, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
54
|
+
}
|
|
55
|
+
async function isPortAvailable(host, port) {
|
|
56
|
+
return await new Promise((resolve) => {
|
|
57
|
+
const server = net.createServer();
|
|
58
|
+
server.unref();
|
|
59
|
+
server.once("error", (error) => {
|
|
60
|
+
resolve(error.code !== "EADDRINUSE");
|
|
61
|
+
});
|
|
62
|
+
server.listen({ host, port, exclusive: true }, () => {
|
|
63
|
+
server.close(() => resolve(true));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function findAvailableLocalPort(host, startPort) {
|
|
68
|
+
for (let candidate = Math.max(1, startPort), attempts = 0; candidate <= 65_535 && attempts < MAX_PORT_SCAN_ATTEMPTS; candidate += 1, attempts += 1) {
|
|
69
|
+
if (await isPortAvailable(host, candidate)) {
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
async function relocateLocalRuntimePort(config) {
|
|
76
|
+
if (config.portSource === "configured") {
|
|
77
|
+
throw new Error(`Configured Forge port ${config.port} is already in use on ${new URL(config.origin).hostname}. Set a different plugin port or stop the process using it.`);
|
|
78
|
+
}
|
|
79
|
+
const nextPort = await findAvailableLocalPort("127.0.0.1", config.port + 1);
|
|
80
|
+
if (nextPort === null) {
|
|
81
|
+
throw new Error(`Forge could not find a free localhost port after ${config.port}.`);
|
|
82
|
+
}
|
|
83
|
+
applyPortToConfig(config, nextPort, "preferred");
|
|
84
|
+
await writePreferredPortState(config, nextPort);
|
|
23
85
|
}
|
|
24
86
|
async function writeRuntimeState(config, pid) {
|
|
25
87
|
const statePath = getRuntimeStatePath(config);
|
|
@@ -89,7 +151,7 @@ function getCurrentModuleRoot() {
|
|
|
89
151
|
}
|
|
90
152
|
function getRuntimeLogPath(config) {
|
|
91
153
|
const origin = new URL(config.origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
92
|
-
return path.join(homedir(), ".openclaw", "logs",
|
|
154
|
+
return path.join(homedir(), ".openclaw", "logs", FORGE_PLUGIN_ID, `${origin}-${config.port}.log`);
|
|
93
155
|
}
|
|
94
156
|
function openRuntimeLogFile(logPath) {
|
|
95
157
|
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
@@ -210,7 +272,7 @@ async function isForgeHealthy(config, timeoutMs) {
|
|
|
210
272
|
clearTimeout(timeout);
|
|
211
273
|
}
|
|
212
274
|
}
|
|
213
|
-
function spawnManagedRuntime(config, plan) {
|
|
275
|
+
async function spawnManagedRuntime(config, plan) {
|
|
214
276
|
const isPackagedServer = isPackagedServerPlan(plan);
|
|
215
277
|
const args = isPackagedServer ? [plan.entryFile] : [plan.entryFile, path.join(plan.packageRoot, "server", "src", "index.ts")];
|
|
216
278
|
const logPath = getRuntimeLogPath(config);
|
|
@@ -246,9 +308,20 @@ function spawnManagedRuntime(config, plan) {
|
|
|
246
308
|
});
|
|
247
309
|
managedRuntimeChild = child;
|
|
248
310
|
managedRuntimeKey = runtimeKey(config);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
311
|
+
try {
|
|
312
|
+
await writeRuntimeState(config, child.pid);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
managedRuntimeChild = null;
|
|
316
|
+
managedRuntimeKey = null;
|
|
317
|
+
try {
|
|
318
|
+
process.kill(child.pid, "SIGTERM");
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// If the child already exited we still want to surface the state-write failure.
|
|
322
|
+
}
|
|
323
|
+
throw new Error(`Forge local runtime started on ${config.baseUrl}, but the plugin could not persist its state. ${error instanceof Error ? error.message : String(error)}`);
|
|
324
|
+
}
|
|
252
325
|
}
|
|
253
326
|
function formatRuntimeFailure(details, config) {
|
|
254
327
|
if (!details) {
|
|
@@ -283,8 +356,21 @@ export async function ensureForgeRuntimeReady(config) {
|
|
|
283
356
|
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
284
357
|
return;
|
|
285
358
|
}
|
|
359
|
+
const savedState = await readRuntimeState(config);
|
|
360
|
+
if (savedState && !processExists(savedState.pid)) {
|
|
361
|
+
await clearRuntimeState(config);
|
|
362
|
+
}
|
|
363
|
+
else if (savedState && processExists(savedState.pid)) {
|
|
364
|
+
try {
|
|
365
|
+
await waitForRuntime(config, EXISTING_RUNTIME_GRACE_MS, null);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
await stopForgeRuntime(config);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
286
372
|
const key = runtimeKey(config);
|
|
287
|
-
if (startupPromise &&
|
|
373
|
+
if (startupPromise && (startupRuntimeKey === null || startupRuntimeKey === key)) {
|
|
288
374
|
return startupPromise;
|
|
289
375
|
}
|
|
290
376
|
const plan = resolveLaunchPlan();
|
|
@@ -295,13 +381,22 @@ export async function ensureForgeRuntimeReady(config) {
|
|
|
295
381
|
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
296
382
|
return;
|
|
297
383
|
}
|
|
384
|
+
startupRuntimeKey = runtimeKey(config);
|
|
385
|
+
if (!(await isPortAvailable("127.0.0.1", config.port))) {
|
|
386
|
+
await relocateLocalRuntimePort(config);
|
|
387
|
+
startupRuntimeKey = runtimeKey(config);
|
|
388
|
+
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
298
392
|
await ensurePackagedRuntimeDependencies(plan, config);
|
|
299
393
|
if (!managedRuntimeChild || managedRuntimeKey !== key || managedRuntimeChild.killed) {
|
|
300
|
-
spawnManagedRuntime(config, plan);
|
|
394
|
+
await spawnManagedRuntime(config, plan);
|
|
301
395
|
}
|
|
302
396
|
await waitForRuntime(config, STARTUP_TIMEOUT_MS, managedRuntimeChild?.pid ?? null);
|
|
303
397
|
})().finally(() => {
|
|
304
398
|
startupPromise = null;
|
|
399
|
+
startupRuntimeKey = null;
|
|
305
400
|
});
|
|
306
401
|
return startupPromise;
|
|
307
402
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { buildForgeBaseUrl, buildForgeWebAppUrl } from "./api-client.js";
|
|
2
5
|
import { primeForgeRuntime } from "./local-runtime.js";
|
|
3
6
|
import { registerForgePluginCli, registerForgePluginRoutes } from "./routes.js";
|
|
@@ -7,6 +10,7 @@ export const FORGE_PLUGIN_NAME = "Forge";
|
|
|
7
10
|
export const FORGE_PLUGIN_DESCRIPTION = "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.";
|
|
8
11
|
export const DEFAULT_FORGE_ORIGIN = "http://127.0.0.1";
|
|
9
12
|
export const DEFAULT_FORGE_PORT = 4317;
|
|
13
|
+
const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
10
14
|
function normalizeString(value, fallback) {
|
|
11
15
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
|
|
12
16
|
}
|
|
@@ -36,15 +40,46 @@ function normalizeTimeout(value, fallback) {
|
|
|
36
40
|
}
|
|
37
41
|
return Math.min(120_000, Math.max(1000, Math.round(value)));
|
|
38
42
|
}
|
|
43
|
+
function isLocalOrigin(origin) {
|
|
44
|
+
try {
|
|
45
|
+
return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function getPreferredLocalPortPath(origin) {
|
|
52
|
+
const hostname = new URL(origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
53
|
+
return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${hostname}-preferred-port.json`);
|
|
54
|
+
}
|
|
55
|
+
function readPreferredLocalPort(origin) {
|
|
56
|
+
if (!isLocalOrigin(origin)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const preferredPortPath = getPreferredLocalPortPath(origin);
|
|
61
|
+
if (!existsSync(preferredPortPath)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const payload = JSON.parse(readFileSync(preferredPortPath, "utf8"));
|
|
65
|
+
return typeof payload.port === "number" && Number.isFinite(payload.port) ? payload.port : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
39
71
|
export function resolveForgePluginConfig(pluginConfig) {
|
|
40
72
|
const raw = (pluginConfig ?? {});
|
|
41
73
|
const origin = normalizeOrigin(raw.origin, DEFAULT_FORGE_ORIGIN);
|
|
42
|
-
const
|
|
74
|
+
const hasConfiguredPort = typeof raw.port === "number" && Number.isFinite(raw.port);
|
|
75
|
+
const preferredPort = hasConfiguredPort ? null : readPreferredLocalPort(origin);
|
|
76
|
+
const port = normalizePort(hasConfiguredPort ? raw.port : preferredPort ?? DEFAULT_FORGE_PORT, DEFAULT_FORGE_PORT);
|
|
43
77
|
return {
|
|
44
78
|
origin,
|
|
45
79
|
port,
|
|
46
80
|
baseUrl: buildForgeBaseUrl(origin, port),
|
|
47
81
|
webAppUrl: buildForgeWebAppUrl(origin, port),
|
|
82
|
+
portSource: hasConfiguredPort ? "configured" : preferredPort !== null ? "preferred" : "default",
|
|
48
83
|
dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot.trim() : "",
|
|
49
84
|
apiToken: typeof raw.apiToken === "string" ? raw.apiToken.trim() : "",
|
|
50
85
|
actorLabel: normalizeString(raw.actorLabel, "aurel"),
|
|
@@ -69,7 +104,7 @@ export const forgePluginConfigSchema = {
|
|
|
69
104
|
default: DEFAULT_FORGE_PORT,
|
|
70
105
|
minimum: 1,
|
|
71
106
|
maximum: 65535,
|
|
72
|
-
description: "Forge server port. Override this when
|
|
107
|
+
description: "Forge server port. Override this only when you want to pin a specific port. Default localhost installs can move to the next free port automatically if 4317 is already taken."
|
|
73
108
|
},
|
|
74
109
|
dataRoot: {
|
|
75
110
|
type: "string",
|
|
@@ -103,7 +138,7 @@ export const forgePluginConfigSchema = {
|
|
|
103
138
|
},
|
|
104
139
|
port: {
|
|
105
140
|
label: "Forge Port",
|
|
106
|
-
help: "Forge server port. Change this
|
|
141
|
+
help: "Forge server port. Change this only when you want to pin a specific port. Default localhost installs can move to the next free port automatically if 4317 is busy.",
|
|
107
142
|
placeholder: "4317"
|
|
108
143
|
},
|
|
109
144
|
dataRoot: {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED