forge-memory 0.2.107 → 0.2.108
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 +16 -3
- package/bin/forge-memory.mjs +542 -98
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Useful commands:
|
|
|
22
22
|
npx forge-memory configure
|
|
23
23
|
npx forge-memory status
|
|
24
24
|
npx forge-memory doctor
|
|
25
|
+
npx forge-memory doctor --repair
|
|
25
26
|
npx forge-memory ui
|
|
26
27
|
npx forge-memory restart
|
|
27
28
|
npx forge-memory stop
|
|
@@ -38,15 +39,27 @@ show up as a tool result instead of a closed MCP transport.
|
|
|
38
39
|
|
|
39
40
|
`pair-ios` prefers the Iroh QR. Forge starts a Rust Iroh host, prints a QR payload
|
|
40
41
|
with the desktop node id, pairing token, optional relay hint, and ALPN
|
|
41
|
-
`forge-companion/1`, and the iPhone app connects through its native Rust bridge.
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
`forge-companion/1`, and the iPhone app connects through its native Rust bridge. The
|
|
43
|
+
CLI renders a compact QR and saves the same compact payload under
|
|
44
|
+
`~/.forge/pairing/` so you can paste it into the iPhone app if the camera cannot scan.
|
|
45
|
+
Use `--manual-http` only when you intentionally want a LAN, Tailscale, or direct
|
|
46
|
+
HTTP/TCP route. For a real iPhone, pass a phone-reachable URL:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx forge-memory pair-ios --manual-http --public-url https://your-mac.tailnet.ts.net/forge/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Without `--public-url`, manual HTTP may resolve to `127.0.0.1`, which is useful for
|
|
53
|
+
the iOS Simulator but not for a physical phone.
|
|
44
54
|
|
|
45
55
|
The base install stays one command on purpose. The detailed companion transport
|
|
46
56
|
reference lives in the Forge repo at `docs/companion-iroh.md` and in the published
|
|
47
57
|
docs at `https://albertbuchard.github.io/forge/companion-transport.html`.
|
|
48
58
|
|
|
49
59
|
`configure` reruns the full guided flow using the current config as defaults.
|
|
60
|
+
Install and configure run Forge doctor before finishing. `doctor --repair` creates
|
|
61
|
+
missing local folders, starts or restarts the runtime when allowed, and prints concrete
|
|
62
|
+
next steps without deleting Forge data.
|
|
50
63
|
`export` writes a portable backup of the real Forge data folder. `uninstall` removes the Forge Memory runtime manager and cache while keeping the data folder by default; pass `--remove-data` only when you intentionally want the data deleted too.
|
|
51
64
|
|
|
52
65
|
Typical first run:
|
package/bin/forge-memory.mjs
CHANGED
|
@@ -54,7 +54,9 @@ function parseArgs(argv) {
|
|
|
54
54
|
printUrl: false,
|
|
55
55
|
removeData: false,
|
|
56
56
|
removeAdapters: false,
|
|
57
|
-
manualHttp: false
|
|
57
|
+
manualHttp: false,
|
|
58
|
+
repair: false,
|
|
59
|
+
noDoctor: false
|
|
58
60
|
};
|
|
59
61
|
const values = {};
|
|
60
62
|
const positionals = [];
|
|
@@ -79,6 +81,8 @@ function parseArgs(argv) {
|
|
|
79
81
|
else if (arg === "--remove-adapters") flags.removeAdapters = true;
|
|
80
82
|
else if (arg === "--manual-http" || arg === "--no-iroh")
|
|
81
83
|
flags.manualHttp = true;
|
|
84
|
+
else if (arg === "--repair") flags.repair = true;
|
|
85
|
+
else if (arg === "--no-doctor") flags.noDoctor = true;
|
|
82
86
|
else if (arg.startsWith("--output="))
|
|
83
87
|
values.output = arg.slice("--output=".length);
|
|
84
88
|
else if (arg === "--output") values.output = argv[++index];
|
|
@@ -100,6 +104,10 @@ function parseArgs(argv) {
|
|
|
100
104
|
else if (arg.startsWith("--repo="))
|
|
101
105
|
values.repo = arg.slice("--repo=".length);
|
|
102
106
|
else if (arg === "--repo") values.repo = argv[++index];
|
|
107
|
+
else if (arg.startsWith("--public-url="))
|
|
108
|
+
values.publicUrl = arg.slice("--public-url=".length);
|
|
109
|
+
else if (arg === "--public-url" || arg === "--phone-url")
|
|
110
|
+
values.publicUrl = argv[++index];
|
|
103
111
|
else if (arg === "--help" || arg === "-h") flags.help = true;
|
|
104
112
|
else if (arg === "--version" || arg === "-v") flags.version = true;
|
|
105
113
|
else throw new Error(`Unknown option: ${arg}`);
|
|
@@ -239,17 +247,19 @@ async function writeConfig(next, options) {
|
|
|
239
247
|
}
|
|
240
248
|
|
|
241
249
|
function commandExists(command) {
|
|
242
|
-
const result =
|
|
243
|
-
process.platform === "win32"
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
);
|
|
250
|
+
const result =
|
|
251
|
+
process.platform === "win32"
|
|
252
|
+
? spawnSync("where", [command], { stdio: "ignore" })
|
|
253
|
+
: spawnSync("sh", ["-c", `command -v ${shellQuote(command)}`], {
|
|
254
|
+
stdio: "ignore"
|
|
255
|
+
});
|
|
250
256
|
return result.status === 0;
|
|
251
257
|
}
|
|
252
258
|
|
|
259
|
+
function shellQuote(value) {
|
|
260
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
261
|
+
}
|
|
262
|
+
|
|
253
263
|
function runCapture(command, args, timeoutMs = 2_000) {
|
|
254
264
|
const result = spawnSync(command, args, {
|
|
255
265
|
encoding: "utf8",
|
|
@@ -336,6 +346,71 @@ function printBanner() {
|
|
|
336
346
|
console.log("");
|
|
337
347
|
}
|
|
338
348
|
|
|
349
|
+
function progressEnabled(options = {}) {
|
|
350
|
+
return !options.json;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatElapsed(startedAt) {
|
|
354
|
+
const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
|
355
|
+
if (seconds < 60) return `${seconds}s`;
|
|
356
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function clearLine() {
|
|
360
|
+
process.stdout.write("\r\u001b[2K");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function withProgress(title, detail, options, task) {
|
|
364
|
+
if (!progressEnabled(options)) return task();
|
|
365
|
+
const startedAt = Date.now();
|
|
366
|
+
const interactive = process.stdout.isTTY;
|
|
367
|
+
const frames = ["-", "\\", "|", "/"];
|
|
368
|
+
let frameIndex = 0;
|
|
369
|
+
let timer = null;
|
|
370
|
+
const suffix = detail ? color.dim(` ${detail}`) : "";
|
|
371
|
+
|
|
372
|
+
if (interactive) {
|
|
373
|
+
process.stdout.write("\u001b[?25l");
|
|
374
|
+
timer = setInterval(() => {
|
|
375
|
+
clearLine();
|
|
376
|
+
process.stdout.write(
|
|
377
|
+
`${color.cyan(frames[frameIndex % frames.length])} ${title}${suffix} ${color.dim(formatElapsed(startedAt))}`
|
|
378
|
+
);
|
|
379
|
+
frameIndex += 1;
|
|
380
|
+
}, 120);
|
|
381
|
+
} else {
|
|
382
|
+
console.log(`${color.cyan("...")} ${title}${suffix}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const result = await task();
|
|
387
|
+
if (timer) clearInterval(timer);
|
|
388
|
+
if (interactive) {
|
|
389
|
+
clearLine();
|
|
390
|
+
process.stdout.write("\u001b[?25h");
|
|
391
|
+
}
|
|
392
|
+
console.log(
|
|
393
|
+
`${color.green("ok")} ${title} ${color.dim(formatElapsed(startedAt))}`
|
|
394
|
+
);
|
|
395
|
+
return result;
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (timer) clearInterval(timer);
|
|
398
|
+
if (interactive) {
|
|
399
|
+
clearLine();
|
|
400
|
+
process.stdout.write("\u001b[?25h");
|
|
401
|
+
}
|
|
402
|
+
console.log(
|
|
403
|
+
`${color.red("fail")} ${title} ${color.dim(formatElapsed(startedAt))}`
|
|
404
|
+
);
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function printStep(title, detail, options = {}) {
|
|
410
|
+
if (!progressEnabled(options)) return;
|
|
411
|
+
console.log(`${color.cyan("->")} ${title}${detail ? color.dim(` ${detail}`) : ""}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
339
414
|
async function promptLine(question, defaultValue) {
|
|
340
415
|
const suffix = defaultValue ? ` ${color.dim(`[${defaultValue}]`)}` : "";
|
|
341
416
|
const rl = readline.createInterface({
|
|
@@ -693,6 +768,33 @@ async function runCommand(
|
|
|
693
768
|
});
|
|
694
769
|
}
|
|
695
770
|
|
|
771
|
+
async function runLoggedCommand(
|
|
772
|
+
command,
|
|
773
|
+
args,
|
|
774
|
+
{ cwd, dryRun = false, env = process.env, logFile = logPath() } = {}
|
|
775
|
+
) {
|
|
776
|
+
if (dryRun) {
|
|
777
|
+
return { ok: true, dryRun: true, command, args, cwd, logFile };
|
|
778
|
+
}
|
|
779
|
+
await fsp.mkdir(path.dirname(logFile), { recursive: true });
|
|
780
|
+
return await new Promise((resolve) => {
|
|
781
|
+
const out = fs.openSync(logFile, "a");
|
|
782
|
+
const child = spawn(command, args, {
|
|
783
|
+
cwd,
|
|
784
|
+
env,
|
|
785
|
+
stdio: ["ignore", out, out]
|
|
786
|
+
});
|
|
787
|
+
child.once("error", (error) => {
|
|
788
|
+
fs.closeSync(out);
|
|
789
|
+
resolve({ ok: false, error, logFile });
|
|
790
|
+
});
|
|
791
|
+
child.once("exit", (code) => {
|
|
792
|
+
fs.closeSync(out);
|
|
793
|
+
resolve({ ok: code === 0, code, logFile });
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
696
798
|
async function installOpenClawAdapter(config, options) {
|
|
697
799
|
await patchOpenClawConfig(config, options);
|
|
698
800
|
if (!commandExists("openclaw")) {
|
|
@@ -802,13 +904,30 @@ async function health(config, timeoutMs = 1_500) {
|
|
|
802
904
|
} catch (error) {
|
|
803
905
|
return {
|
|
804
906
|
ok: false,
|
|
805
|
-
error:
|
|
907
|
+
error: describeNetworkError(error)
|
|
806
908
|
};
|
|
807
909
|
} finally {
|
|
808
910
|
clearTimeout(timeout);
|
|
809
911
|
}
|
|
810
912
|
}
|
|
811
913
|
|
|
914
|
+
function describeNetworkError(error) {
|
|
915
|
+
if (error instanceof Error) {
|
|
916
|
+
if (error.name === "AbortError") return "request timed out";
|
|
917
|
+
if (error.message === "fetch failed")
|
|
918
|
+
return "connection failed before Forge responded";
|
|
919
|
+
return error.message;
|
|
920
|
+
}
|
|
921
|
+
return String(error);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function describeHealthResult(result) {
|
|
925
|
+
if (result.ok) return "healthy";
|
|
926
|
+
if (result.status) return `HTTP ${result.status}`;
|
|
927
|
+
if (result.error) return result.error;
|
|
928
|
+
return "not reachable";
|
|
929
|
+
}
|
|
930
|
+
|
|
812
931
|
async function readRuntimeState() {
|
|
813
932
|
return readJson(runtimeStatePath(), null);
|
|
814
933
|
}
|
|
@@ -869,36 +988,34 @@ async function ensurePackagedRuntimeInstalled() {
|
|
|
869
988
|
"utf8"
|
|
870
989
|
);
|
|
871
990
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
991
|
+
const result = await runLoggedCommand(
|
|
992
|
+
"npm",
|
|
993
|
+
[
|
|
994
|
+
"install",
|
|
995
|
+
`${RUNTIME_PACKAGE}@${RUNTIME_PACKAGE_VERSION}`,
|
|
996
|
+
"--omit=dev",
|
|
997
|
+
"--ignore-scripts",
|
|
998
|
+
"--silent"
|
|
999
|
+
],
|
|
1000
|
+
{
|
|
1001
|
+
cwd: installRoot,
|
|
1002
|
+
env: process.env,
|
|
1003
|
+
logFile: logPath()
|
|
1004
|
+
}
|
|
1005
|
+
);
|
|
1006
|
+
if (!result.ok) {
|
|
1007
|
+
throw new Error(
|
|
877
1008
|
[
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
"--
|
|
881
|
-
|
|
882
|
-
"--silent"
|
|
883
|
-
],
|
|
884
|
-
{
|
|
885
|
-
cwd: installRoot,
|
|
886
|
-
stdio: ["ignore", out, out],
|
|
887
|
-
env: process.env
|
|
888
|
-
}
|
|
1009
|
+
`Failed to install ${RUNTIME_PACKAGE}@${RUNTIME_PACKAGE_VERSION}.`,
|
|
1010
|
+
`Log: ${logPath()}`,
|
|
1011
|
+
"Run npx forge-memory doctor --repair after fixing network or npm access."
|
|
1012
|
+
].join(" ")
|
|
889
1013
|
);
|
|
890
|
-
if (result.status !== 0) {
|
|
891
|
-
throw new Error(
|
|
892
|
-
`Failed to install ${RUNTIME_PACKAGE}@${RUNTIME_PACKAGE_VERSION}. Check ${logPath()}.`
|
|
893
|
-
);
|
|
894
|
-
}
|
|
895
|
-
} finally {
|
|
896
|
-
fs.closeSync(out);
|
|
897
1014
|
}
|
|
898
1015
|
const installed = resolveOpenClawPluginRoot();
|
|
899
1016
|
if (!installed)
|
|
900
1017
|
throw new Error(
|
|
901
|
-
`${RUNTIME_PACKAGE} installed but its runtime entry could not be resolved
|
|
1018
|
+
`${RUNTIME_PACKAGE} installed but its runtime entry could not be resolved. Log: ${logPath()}`
|
|
902
1019
|
);
|
|
903
1020
|
return installed;
|
|
904
1021
|
}
|
|
@@ -1003,6 +1120,18 @@ async function startRuntime(config) {
|
|
|
1003
1120
|
return { ok: result.ok, started: true, state, health: result };
|
|
1004
1121
|
}
|
|
1005
1122
|
|
|
1123
|
+
function assertRuntimeStartedForPairing(result, config) {
|
|
1124
|
+
if (result?.ok) return;
|
|
1125
|
+
throw new Error(
|
|
1126
|
+
[
|
|
1127
|
+
`Forge runtime did not become healthy at ${baseUrl(config)}, so iOS pairing was not started.`,
|
|
1128
|
+
`Health check: ${describeHealthResult(result?.health ?? { ok: false })}.`,
|
|
1129
|
+
`Run npx forge-memory doctor --repair and inspect ${logPath()}.`,
|
|
1130
|
+
`Your data folder is unchanged.`
|
|
1131
|
+
].join(" ")
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1006
1135
|
async function stopRuntime() {
|
|
1007
1136
|
const state = await readRuntimeState();
|
|
1008
1137
|
if (!state?.children?.length)
|
|
@@ -1222,72 +1351,248 @@ async function uninstallForgeMemory(parsed) {
|
|
|
1222
1351
|
};
|
|
1223
1352
|
}
|
|
1224
1353
|
|
|
1354
|
+
function normalizePublicPairingUrl(value) {
|
|
1355
|
+
if (!value?.trim()) return null;
|
|
1356
|
+
try {
|
|
1357
|
+
const url = new URL(value.trim());
|
|
1358
|
+
return url.toString();
|
|
1359
|
+
} catch {
|
|
1360
|
+
throw new Error(
|
|
1361
|
+
`Invalid --public-url value: ${value}. Use a full URL such as https://your-mac.tailnet.ts.net/forge/`
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1225
1366
|
async function createPairing(config, options = {}) {
|
|
1226
1367
|
const transportMode = options.transportMode ?? "iroh";
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1368
|
+
const publicUrl = normalizePublicPairingUrl(options.publicUrl);
|
|
1369
|
+
const pairingUrl = new URL("/api/v1/health/pairing-sessions", baseUrl(config));
|
|
1370
|
+
let response;
|
|
1371
|
+
try {
|
|
1372
|
+
response = await fetch(pairingUrl, {
|
|
1230
1373
|
method: "POST",
|
|
1231
1374
|
headers: {
|
|
1232
1375
|
"content-type": "application/json",
|
|
1233
|
-
accept: "application/json"
|
|
1376
|
+
accept: "application/json",
|
|
1377
|
+
...(publicUrl ? { referer: publicUrl } : {})
|
|
1234
1378
|
},
|
|
1235
1379
|
body: JSON.stringify({ userId: null, transportMode })
|
|
1236
|
-
}
|
|
1237
|
-
)
|
|
1238
|
-
|
|
1239
|
-
|
|
1380
|
+
});
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
const healthResult = await health(config, 1_500);
|
|
1383
|
+
const manualHttpHint =
|
|
1384
|
+
transportMode === "manual-http" && !publicUrl
|
|
1385
|
+
? " For a physical iPhone using manual HTTP, rerun with --public-url set to your Tailscale or LAN Forge URL, for example: npx forge-memory pair-ios --manual-http --public-url https://your-mac.tailnet.ts.net/forge/"
|
|
1386
|
+
: "";
|
|
1387
|
+
throw new Error(
|
|
1388
|
+
[
|
|
1389
|
+
`Could not create iOS pairing because Forge did not respond at ${pairingUrl}.`,
|
|
1390
|
+
`Network: ${describeNetworkError(error)}.`,
|
|
1391
|
+
`Health check: ${describeHealthResult(healthResult)}.`,
|
|
1392
|
+
`Run npx forge-memory doctor --repair, then npx forge-memory pair-ios again.`,
|
|
1393
|
+
`Runtime log: ${logPath()}.${manualHttpHint}`
|
|
1394
|
+
].join(" ")
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
if (!response.ok) {
|
|
1398
|
+
const body = await response.text().catch(() => "");
|
|
1399
|
+
throw new Error(
|
|
1400
|
+
[
|
|
1401
|
+
`Could not create iOS pairing at ${pairingUrl}: Forge returned HTTP ${response.status}.`,
|
|
1402
|
+
body ? `Response: ${body.slice(0, 500)}` : "",
|
|
1403
|
+
`Run npx forge-memory doctor --repair and inspect ${logPath()}.`
|
|
1404
|
+
]
|
|
1405
|
+
.filter(Boolean)
|
|
1406
|
+
.join(" ")
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1240
1409
|
return response.json();
|
|
1241
1410
|
}
|
|
1242
1411
|
|
|
1243
|
-
function
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1412
|
+
function compactPairingPayload(payload) {
|
|
1413
|
+
const transport = payload.transport
|
|
1414
|
+
? {
|
|
1415
|
+
protocol: payload.transport.protocol,
|
|
1416
|
+
provider: payload.transport.provider,
|
|
1417
|
+
status: payload.transport.status,
|
|
1418
|
+
publicBaseUrl: payload.transport.publicBaseUrl,
|
|
1419
|
+
localBaseUrl: payload.transport.localBaseUrl,
|
|
1420
|
+
nodeId: payload.transport.nodeId,
|
|
1421
|
+
relay: payload.transport.relay,
|
|
1422
|
+
alpn: payload.transport.alpn,
|
|
1423
|
+
agent: payload.transport.agent,
|
|
1424
|
+
pairPayload: payload.transport.pairPayload,
|
|
1425
|
+
lastError: payload.transport.lastError,
|
|
1426
|
+
notes: []
|
|
1427
|
+
}
|
|
1428
|
+
: undefined;
|
|
1429
|
+
return {
|
|
1430
|
+
kind: payload.kind,
|
|
1431
|
+
apiBaseUrl: payload.apiBaseUrl,
|
|
1432
|
+
uiBaseUrl: payload.uiBaseUrl,
|
|
1433
|
+
transportMode: payload.transportMode,
|
|
1434
|
+
transport,
|
|
1435
|
+
sessionId: payload.sessionId,
|
|
1436
|
+
pairingToken: payload.pairingToken,
|
|
1437
|
+
expiresAt: payload.expiresAt,
|
|
1438
|
+
capabilities: payload.capabilities
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function writePairingPayloadFile(payload) {
|
|
1443
|
+
const pairingDir = path.join(forgeHome(), "pairing");
|
|
1444
|
+
await fsp.mkdir(pairingDir, { recursive: true });
|
|
1445
|
+
const filePath = path.join(
|
|
1446
|
+
pairingDir,
|
|
1447
|
+
`forge-companion-${payload.sessionId}.json`
|
|
1448
|
+
);
|
|
1449
|
+
await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
1450
|
+
return filePath;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function isLoopbackPairingUrl(value) {
|
|
1454
|
+
try {
|
|
1455
|
+
const host = new URL(value).hostname.toLowerCase();
|
|
1456
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
1457
|
+
} catch {
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
async function printPairing(pairing) {
|
|
1463
|
+
const payload = compactPairingPayload(pairing.qrPayload);
|
|
1464
|
+
const payloadText = JSON.stringify(payload);
|
|
1465
|
+
console.log("\nScan this compact QR in Forge Companion:\n");
|
|
1466
|
+
qrcode.generate(payloadText, { small: true });
|
|
1467
|
+
const transport = payload.transport;
|
|
1247
1468
|
if (transport?.provider) {
|
|
1248
1469
|
const label =
|
|
1249
|
-
|
|
1470
|
+
payload.transport?.protocol === "iroh"
|
|
1250
1471
|
? "Iroh"
|
|
1251
|
-
:
|
|
1472
|
+
: payload.transportMode === "iroh"
|
|
1252
1473
|
? "Iroh"
|
|
1253
1474
|
: "Manual HTTP";
|
|
1254
|
-
console.log(`${color.cyan(label)}: ${
|
|
1255
|
-
if (
|
|
1256
|
-
console.log(
|
|
1475
|
+
console.log(`${color.cyan(label)}: ${payload.apiBaseUrl}`);
|
|
1476
|
+
if (label === "Manual HTTP" && isLoopbackPairingUrl(payload.apiBaseUrl)) {
|
|
1477
|
+
console.log(
|
|
1478
|
+
color.yellow(
|
|
1479
|
+
"Manual HTTP points at this machine's loopback address. That is only useful for the iOS Simulator; a real iPhone needs Iroh, Tailscale, or a LAN URL passed with --public-url."
|
|
1480
|
+
)
|
|
1481
|
+
);
|
|
1257
1482
|
}
|
|
1258
|
-
for (const note of transport
|
|
1483
|
+
for (const note of pairing.qrPayload?.transport?.notes ?? []) {
|
|
1259
1484
|
console.log(color.dim(note));
|
|
1260
1485
|
}
|
|
1261
1486
|
}
|
|
1262
|
-
|
|
1487
|
+
try {
|
|
1488
|
+
const filePath = await writePairingPayloadFile(payload);
|
|
1489
|
+
console.log("");
|
|
1490
|
+
console.log(color.bold("If the QR is too large or the camera will not scan:"));
|
|
1491
|
+
console.log("1. Open Manual connection in the iPhone app.");
|
|
1492
|
+
console.log("2. Tap Paste pairing payload.");
|
|
1493
|
+
console.log(`3. Paste the payload saved at: ${filePath}`);
|
|
1494
|
+
console.log(color.dim(` cat ${filePath}`));
|
|
1495
|
+
console.log("");
|
|
1496
|
+
console.log(color.dim(`Compact payload bytes: ${payloadText.length}`));
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
console.log(
|
|
1499
|
+
color.yellow(
|
|
1500
|
+
`Could not save pairing payload file: ${error instanceof Error ? error.message : String(error)}`
|
|
1501
|
+
)
|
|
1502
|
+
);
|
|
1503
|
+
console.log(payloadText);
|
|
1504
|
+
}
|
|
1263
1505
|
}
|
|
1264
1506
|
|
|
1265
1507
|
async function runInstall(parsed, command) {
|
|
1266
1508
|
const currentConfig = await readConfig();
|
|
1267
|
-
const discovery = discover();
|
|
1268
1509
|
if (!parsed.flags.yes) {
|
|
1269
1510
|
printBanner();
|
|
1270
1511
|
console.log(
|
|
1271
1512
|
color.dim(
|
|
1272
|
-
"
|
|
1513
|
+
"Forge UI/runtime is always installed. Host adapter discovery runs first.\n"
|
|
1273
1514
|
)
|
|
1274
1515
|
);
|
|
1275
1516
|
}
|
|
1517
|
+
const discovery = await withProgress(
|
|
1518
|
+
"Looking for host adapters",
|
|
1519
|
+
"OpenClaw, Hermes, and Codex",
|
|
1520
|
+
parsed.flags,
|
|
1521
|
+
async () => discover()
|
|
1522
|
+
);
|
|
1276
1523
|
const config = await buildInstallConfig(
|
|
1277
1524
|
parsed,
|
|
1278
1525
|
currentConfig,
|
|
1279
1526
|
discovery,
|
|
1280
1527
|
command
|
|
1281
1528
|
);
|
|
1282
|
-
const writeResult = await
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1529
|
+
const writeResult = await withProgress(
|
|
1530
|
+
"Saving Forge settings",
|
|
1531
|
+
configPath(),
|
|
1532
|
+
parsed.flags,
|
|
1533
|
+
() =>
|
|
1534
|
+
writeConfig(config, {
|
|
1535
|
+
dryRun: parsed.flags.dryRun
|
|
1536
|
+
})
|
|
1537
|
+
);
|
|
1538
|
+
await withProgress(
|
|
1539
|
+
"Preparing Forge data folder",
|
|
1540
|
+
config.dataRoot,
|
|
1541
|
+
parsed.flags,
|
|
1542
|
+
async () => {
|
|
1543
|
+
if (!parsed.flags.dryRun) {
|
|
1544
|
+
await fsp.mkdir(config.dataRoot, { recursive: true });
|
|
1545
|
+
}
|
|
1546
|
+
return { ok: true, dataRoot: config.dataRoot };
|
|
1547
|
+
}
|
|
1548
|
+
);
|
|
1549
|
+
const adapterResults = await withProgress(
|
|
1550
|
+
config.adapters.length
|
|
1551
|
+
? "Configuring selected host adapters"
|
|
1552
|
+
: "Skipping host adapter configuration",
|
|
1553
|
+
config.adapters.length ? config.adapters.join(", ") : "none selected",
|
|
1554
|
+
parsed.flags,
|
|
1555
|
+
() =>
|
|
1556
|
+
configureAdapters(config, {
|
|
1557
|
+
dryRun: parsed.flags.dryRun
|
|
1558
|
+
})
|
|
1559
|
+
);
|
|
1288
1560
|
let runtimeResult = null;
|
|
1289
1561
|
if (!parsed.flags.noStart && !parsed.flags.dryRun) {
|
|
1290
|
-
runtimeResult = await
|
|
1562
|
+
runtimeResult = await withProgress(
|
|
1563
|
+
config.mode === "dev"
|
|
1564
|
+
? "Starting source-backed Forge runtime"
|
|
1565
|
+
: "Installing and starting Forge runtime",
|
|
1566
|
+
`logs: ${logPath()}`,
|
|
1567
|
+
parsed.flags,
|
|
1568
|
+
() => startRuntime(config)
|
|
1569
|
+
);
|
|
1570
|
+
} else if (parsed.flags.noStart) {
|
|
1571
|
+
printStep(
|
|
1572
|
+
"Runtime start skipped",
|
|
1573
|
+
"run npx forge-memory ui or npx forge-memory restart later",
|
|
1574
|
+
parsed.flags
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
let doctorResult = null;
|
|
1578
|
+
if (!parsed.flags.noDoctor) {
|
|
1579
|
+
doctorResult = await withProgress(
|
|
1580
|
+
"Running Forge doctor",
|
|
1581
|
+
parsed.flags.noStart ? "offline checks" : "health and repair checks",
|
|
1582
|
+
parsed.flags,
|
|
1583
|
+
() =>
|
|
1584
|
+
runDoctorChecks(parsed, config, {
|
|
1585
|
+
repair: true,
|
|
1586
|
+
noStart: parsed.flags.noStart,
|
|
1587
|
+
dryRun: parsed.flags.dryRun
|
|
1588
|
+
})
|
|
1589
|
+
);
|
|
1590
|
+
if (!doctorResult.ok && !parsed.flags.json && !parsed.flags.dryRun) {
|
|
1591
|
+
console.log(color.yellow("Forge doctor found follow-up work:"));
|
|
1592
|
+
for (const check of doctorResult.checks.filter((entry) => !entry.ok)) {
|
|
1593
|
+
console.log(`- ${check.id}: ${check.guidance}`);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1291
1596
|
}
|
|
1292
1597
|
const shouldPair =
|
|
1293
1598
|
parsed.flags.pairIos ||
|
|
@@ -1297,13 +1602,34 @@ async function runInstall(parsed, command) {
|
|
|
1297
1602
|
: await promptYesNo("Pair the iOS companion now?", true)));
|
|
1298
1603
|
let pairing = null;
|
|
1299
1604
|
if (shouldPair && !parsed.flags.dryRun) {
|
|
1300
|
-
if (!runtimeResult)
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1605
|
+
if (!runtimeResult) {
|
|
1606
|
+
runtimeResult = await withProgress(
|
|
1607
|
+
"Starting Forge runtime for iOS pairing",
|
|
1608
|
+
`logs: ${logPath()}`,
|
|
1609
|
+
parsed.flags,
|
|
1610
|
+
() => startRuntime(config)
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
assertRuntimeStartedForPairing(runtimeResult, config);
|
|
1614
|
+
pairing = await withProgress(
|
|
1615
|
+
"Creating iOS companion pairing",
|
|
1616
|
+
parsed.flags.manualHttp ? "manual HTTP" : "Iroh QR",
|
|
1617
|
+
parsed.flags,
|
|
1618
|
+
() =>
|
|
1619
|
+
createPairing(config, {
|
|
1620
|
+
transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh",
|
|
1621
|
+
publicUrl: parsed.values.publicUrl
|
|
1622
|
+
})
|
|
1623
|
+
);
|
|
1304
1624
|
if (pairing?.qrPayload && !parsed.flags.json) {
|
|
1305
|
-
printPairing(pairing);
|
|
1625
|
+
await printPairing(pairing);
|
|
1306
1626
|
}
|
|
1627
|
+
} else if (parsed.flags.skipPairIos) {
|
|
1628
|
+
printStep(
|
|
1629
|
+
"iOS pairing skipped",
|
|
1630
|
+
"run npx forge-memory pair-ios when you want the QR",
|
|
1631
|
+
parsed.flags
|
|
1632
|
+
);
|
|
1307
1633
|
}
|
|
1308
1634
|
const summary = {
|
|
1309
1635
|
ok: true,
|
|
@@ -1311,13 +1637,23 @@ async function runInstall(parsed, command) {
|
|
|
1311
1637
|
writeResult,
|
|
1312
1638
|
adapterResults,
|
|
1313
1639
|
runtimeResult,
|
|
1640
|
+
doctorResult,
|
|
1314
1641
|
pairing: Boolean(pairing)
|
|
1315
1642
|
};
|
|
1316
1643
|
if (parsed.flags.json) console.log(JSON.stringify(summary, null, 2));
|
|
1317
1644
|
else {
|
|
1318
|
-
console.log(color.green("Forge Memory configured."));
|
|
1645
|
+
console.log(color.green("Forge Memory configured and checked."));
|
|
1319
1646
|
console.log(`UI: ${webUrl(config)}`);
|
|
1320
1647
|
console.log(`Data: ${config.dataRoot}`);
|
|
1648
|
+
console.log(
|
|
1649
|
+
`Doctor: ${
|
|
1650
|
+
parsed.flags.dryRun
|
|
1651
|
+
? color.yellow("preview only")
|
|
1652
|
+
: doctorResult?.ok === false
|
|
1653
|
+
? color.yellow("needs attention")
|
|
1654
|
+
: color.green("passed")
|
|
1655
|
+
}`
|
|
1656
|
+
);
|
|
1321
1657
|
if (parsed.flags.dryRun)
|
|
1322
1658
|
console.log(
|
|
1323
1659
|
color.yellow("Dry run only; no files or adapter installs were changed.")
|
|
@@ -1355,39 +1691,111 @@ async function runStatus(parsed) {
|
|
|
1355
1691
|
}
|
|
1356
1692
|
}
|
|
1357
1693
|
|
|
1358
|
-
async function
|
|
1359
|
-
|
|
1694
|
+
async function doctorCheckRuntime(config, options) {
|
|
1695
|
+
let result = await health(config);
|
|
1696
|
+
let repaired = false;
|
|
1697
|
+
if (!result.ok && options.repair && !options.noStart && !options.dryRun) {
|
|
1698
|
+
await startRuntime(config);
|
|
1699
|
+
result = await health(config, 3_000);
|
|
1700
|
+
repaired = result.ok;
|
|
1701
|
+
}
|
|
1702
|
+
return {
|
|
1703
|
+
id: "runtime",
|
|
1704
|
+
ok: result.ok,
|
|
1705
|
+
detail: baseUrl(config),
|
|
1706
|
+
repaired,
|
|
1707
|
+
guidance: result.ok
|
|
1708
|
+
? "Forge API is reachable."
|
|
1709
|
+
: `Run npx forge-memory doctor --repair, then inspect ${logPath()} if the runtime still does not start.`
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
async function runDoctorChecks(parsed, config, options = {}) {
|
|
1360
1714
|
const discovery = discover();
|
|
1361
|
-
const checks = [
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1715
|
+
const checks = [];
|
|
1716
|
+
|
|
1717
|
+
checks.push({
|
|
1718
|
+
id: "node",
|
|
1719
|
+
ok: Number(process.versions.node.split(".")[0]) >= 22,
|
|
1720
|
+
detail: process.versions.node,
|
|
1721
|
+
guidance:
|
|
1722
|
+
"Forge Memory requires Node 22 or newer. Install a current Node release, then rerun npx forge-memory configure."
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
const configExists = fs.existsSync(configPath());
|
|
1726
|
+
checks.push({
|
|
1727
|
+
id: "config",
|
|
1728
|
+
ok: configExists,
|
|
1729
|
+
detail: configPath(),
|
|
1730
|
+
guidance:
|
|
1731
|
+
"Run npx forge-memory configure to create the runtime manager config."
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
let dataRootExists = fs.existsSync(config.dataRoot);
|
|
1735
|
+
let dataRootRepaired = false;
|
|
1736
|
+
if (!dataRootExists && options.repair && !options.dryRun) {
|
|
1737
|
+
await fsp.mkdir(config.dataRoot, { recursive: true });
|
|
1738
|
+
dataRootExists = true;
|
|
1739
|
+
dataRootRepaired = true;
|
|
1740
|
+
}
|
|
1741
|
+
checks.push({
|
|
1742
|
+
id: "dataRoot",
|
|
1743
|
+
ok: dataRootExists,
|
|
1744
|
+
detail: config.dataRoot,
|
|
1745
|
+
repaired: dataRootRepaired,
|
|
1746
|
+
guidance:
|
|
1747
|
+
"Forge data is preserved here. Doctor can create the folder, but it will not delete existing data."
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
checks.push(await doctorCheckRuntime(config, options));
|
|
1751
|
+
|
|
1752
|
+
for (const adapter of discovery.adapters) {
|
|
1753
|
+
const selected = config.adapters.includes(adapter.id);
|
|
1754
|
+
checks.push({
|
|
1375
1755
|
id: adapter.id,
|
|
1376
|
-
ok: adapter.installed,
|
|
1377
|
-
detail:
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1756
|
+
ok: selected ? adapter.installed : true,
|
|
1757
|
+
detail: selected
|
|
1758
|
+
? adapter.status
|
|
1759
|
+
: adapter.installed
|
|
1760
|
+
? `${adapter.status}; not selected`
|
|
1761
|
+
: "not selected",
|
|
1762
|
+
selected,
|
|
1763
|
+
guidance: selected
|
|
1764
|
+
? adapter.hint
|
|
1765
|
+
: `${adapter.label} is not selected for this Forge install.`
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
return {
|
|
1770
|
+
ok: checks.every((check) => check.ok),
|
|
1382
1771
|
checks
|
|
1383
1772
|
};
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
async function runDoctor(parsed) {
|
|
1776
|
+
const config = await readConfig();
|
|
1777
|
+
const payload = await withProgress(
|
|
1778
|
+
"Checking Forge Memory install",
|
|
1779
|
+
parsed.flags.repair ? "repair enabled" : "read-only",
|
|
1780
|
+
parsed.flags,
|
|
1781
|
+
() =>
|
|
1782
|
+
runDoctorChecks(parsed, config, {
|
|
1783
|
+
repair: parsed.flags.repair,
|
|
1784
|
+
noStart: parsed.flags.noStart,
|
|
1785
|
+
dryRun: parsed.flags.dryRun
|
|
1786
|
+
})
|
|
1787
|
+
);
|
|
1384
1788
|
if (parsed.flags.json) console.log(JSON.stringify(payload, null, 2));
|
|
1385
1789
|
else {
|
|
1386
1790
|
console.log(color.bold("Forge Memory Doctor"));
|
|
1387
|
-
for (const check of checks) {
|
|
1791
|
+
for (const check of payload.checks) {
|
|
1792
|
+
const repaired = check.repaired ? color.cyan(" repaired") : "";
|
|
1388
1793
|
console.log(
|
|
1389
|
-
`${check.ok ? color.green("ok") : color.yellow("warn")} ${check.id}: ${check.detail}`
|
|
1794
|
+
`${check.ok ? color.green("ok") : color.yellow("warn")} ${check.id}: ${check.detail}${repaired}`
|
|
1390
1795
|
);
|
|
1796
|
+
if (!check.ok && check.guidance) {
|
|
1797
|
+
console.log(color.dim(` ${check.guidance}`));
|
|
1798
|
+
}
|
|
1391
1799
|
}
|
|
1392
1800
|
}
|
|
1393
1801
|
}
|
|
@@ -1408,15 +1816,24 @@ async function runUi(parsed) {
|
|
|
1408
1816
|
|
|
1409
1817
|
async function runPairIos(parsed) {
|
|
1410
1818
|
const config = await readConfig();
|
|
1411
|
-
|
|
1819
|
+
if (!parsed.flags.noStart) {
|
|
1820
|
+
const runtimeResult = await withProgress(
|
|
1821
|
+
"Starting Forge runtime for iOS pairing",
|
|
1822
|
+
`logs: ${logPath()}`,
|
|
1823
|
+
parsed.flags,
|
|
1824
|
+
() => startRuntime(config)
|
|
1825
|
+
);
|
|
1826
|
+
assertRuntimeStartedForPairing(runtimeResult, config);
|
|
1827
|
+
}
|
|
1412
1828
|
const pairing = await createPairing(config, {
|
|
1413
|
-
transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh"
|
|
1829
|
+
transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh",
|
|
1830
|
+
publicUrl: parsed.values.publicUrl
|
|
1414
1831
|
});
|
|
1415
1832
|
if (parsed.flags.json) {
|
|
1416
1833
|
console.log(JSON.stringify(pairing, null, 2));
|
|
1417
1834
|
return;
|
|
1418
1835
|
}
|
|
1419
|
-
printPairing(pairing);
|
|
1836
|
+
await printPairing(pairing);
|
|
1420
1837
|
}
|
|
1421
1838
|
|
|
1422
1839
|
async function runLogs() {
|
|
@@ -1748,7 +2165,10 @@ Options:
|
|
|
1748
2165
|
--skip-adapters Configure UI/runtime only
|
|
1749
2166
|
--skip-pair-ios Do not prompt or create iOS pairing
|
|
1750
2167
|
--manual-http Use direct HTTP/TCP for iOS pairing instead of the default Iroh transport
|
|
2168
|
+
--public-url <url> Phone-reachable URL for manual HTTP pairing, such as a Tailscale or LAN Forge URL
|
|
1751
2169
|
--no-start Configure without starting runtime
|
|
2170
|
+
--no-doctor Skip install-time doctor checks
|
|
2171
|
+
--repair Let doctor create missing folders and restart unhealthy runtime
|
|
1752
2172
|
--output <path> Export destination for forge-memory export
|
|
1753
2173
|
--remove-adapters During uninstall, remove host adapter config entries
|
|
1754
2174
|
--remove-data During uninstall, delete the Forge data folder too
|
|
@@ -1834,9 +2254,33 @@ async function main() {
|
|
|
1834
2254
|
}
|
|
1835
2255
|
}
|
|
1836
2256
|
|
|
2257
|
+
function printFatalError(error, { json = false } = {}) {
|
|
2258
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2259
|
+
const payload = {
|
|
2260
|
+
ok: false,
|
|
2261
|
+
error: message,
|
|
2262
|
+
guidance: [
|
|
2263
|
+
"Run npx forge-memory doctor --repair to check and repair the local install.",
|
|
2264
|
+
"Run npx forge-memory logs to inspect the runtime log.",
|
|
2265
|
+
"Forge Memory repair never deletes your data folder."
|
|
2266
|
+
],
|
|
2267
|
+
logPath: logPath()
|
|
2268
|
+
};
|
|
2269
|
+
if (json) {
|
|
2270
|
+
console.error(JSON.stringify(payload, null, 2));
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
console.error(color.red("Forge Memory could not finish this step."));
|
|
2274
|
+
console.error(color.red(message));
|
|
2275
|
+
console.error("");
|
|
2276
|
+
console.error(color.cyan("Next steps:"));
|
|
2277
|
+
for (const item of payload.guidance) {
|
|
2278
|
+
console.error(`- ${item}`);
|
|
2279
|
+
}
|
|
2280
|
+
console.error(`- Runtime log: ${payload.logPath}`);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
1837
2283
|
main().catch((error) => {
|
|
1838
|
-
|
|
1839
|
-
color.red(error instanceof Error ? error.message : String(error))
|
|
1840
|
-
);
|
|
2284
|
+
printFatalError(error, { json: process.argv.includes("--json") });
|
|
1841
2285
|
process.exitCode = 1;
|
|
1842
2286
|
});
|
package/package.json
CHANGED