forge-memory 0.2.107 → 0.2.109

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
@@ -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. Use
42
- `--manual-http` only when you intentionally want a LAN, Tailscale, or direct HTTP/TCP
43
- route.
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:
@@ -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 = spawnSync(
243
- process.platform === "win32" ? "where" : "command",
244
- process.platform === "win32" ? [command] : ["-v", command],
245
- {
246
- shell: process.platform !== "win32",
247
- stdio: "ignore"
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: error instanceof Error ? error.message : String(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
  }
@@ -832,8 +951,8 @@ async function waitForHealth(config, timeoutMs = 30_000) {
832
951
  return health(config);
833
952
  }
834
953
 
835
- function resolveOpenClawPluginRoot() {
836
- const candidates = [require];
954
+ function resolveOpenClawPluginRoot(options = {}) {
955
+ const candidates = [];
837
956
  const installedRuntimePackageJson = path.join(
838
957
  runtimeInstallRoot(),
839
958
  "package.json"
@@ -841,6 +960,9 @@ function resolveOpenClawPluginRoot() {
841
960
  if (fs.existsSync(installedRuntimePackageJson)) {
842
961
  candidates.push(createRequire(installedRuntimePackageJson));
843
962
  }
963
+ if (!options.installedOnly) {
964
+ candidates.push(require);
965
+ }
844
966
 
845
967
  for (const candidateRequire of candidates) {
846
968
  try {
@@ -856,8 +978,10 @@ function resolveOpenClawPluginRoot() {
856
978
  return null;
857
979
  }
858
980
 
859
- async function ensurePackagedRuntimeInstalled() {
860
- const existing = resolveOpenClawPluginRoot();
981
+ async function ensurePackagedRuntimeInstalled(options = {}) {
982
+ const existing = options.forceInstall
983
+ ? null
984
+ : resolveOpenClawPluginRoot();
861
985
  if (existing) return existing;
862
986
  const installRoot = runtimeInstallRoot();
863
987
  await fsp.mkdir(installRoot, { recursive: true });
@@ -869,40 +993,89 @@ async function ensurePackagedRuntimeInstalled() {
869
993
  "utf8"
870
994
  );
871
995
  }
872
- await fsp.mkdir(path.dirname(logPath()), { recursive: true });
873
- const out = fs.openSync(logPath(), "a");
874
- try {
875
- const result = spawnSync(
876
- "npm",
996
+ const result = await runLoggedCommand(
997
+ "npm",
998
+ [
999
+ "install",
1000
+ `${RUNTIME_PACKAGE}@${RUNTIME_PACKAGE_VERSION}`,
1001
+ "--omit=dev",
1002
+ "--ignore-scripts",
1003
+ "--silent"
1004
+ ],
1005
+ {
1006
+ cwd: installRoot,
1007
+ env: process.env,
1008
+ logFile: logPath()
1009
+ }
1010
+ );
1011
+ if (!result.ok) {
1012
+ throw new Error(
877
1013
  [
878
- "install",
879
- `${RUNTIME_PACKAGE}@${RUNTIME_PACKAGE_VERSION}`,
880
- "--omit=dev",
881
- "--ignore-scripts",
882
- "--silent"
883
- ],
884
- {
885
- cwd: installRoot,
886
- stdio: ["ignore", out, out],
887
- env: process.env
888
- }
1014
+ `Failed to install ${RUNTIME_PACKAGE}@${RUNTIME_PACKAGE_VERSION}.`,
1015
+ `Log: ${logPath()}`,
1016
+ "Run npx forge-memory doctor --repair after fixing network or npm access."
1017
+ ].join(" ")
889
1018
  );
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
1019
  }
898
- const installed = resolveOpenClawPluginRoot();
1020
+ const installed = resolveOpenClawPluginRoot({ installedOnly: true });
899
1021
  if (!installed)
900
1022
  throw new Error(
901
- `${RUNTIME_PACKAGE} installed but its runtime entry could not be resolved.`
1023
+ `${RUNTIME_PACKAGE} installed but its runtime entry could not be resolved. Log: ${logPath()}`
902
1024
  );
1025
+ const entry = path.join(installed, "server", "index.js");
1026
+ if (!fs.existsSync(entry)) {
1027
+ throw new Error(
1028
+ `${RUNTIME_PACKAGE} installed but ${entry} is missing. Log: ${logPath()}`
1029
+ );
1030
+ }
903
1031
  return installed;
904
1032
  }
905
1033
 
1034
+ async function rotateRuntimeLog(reason) {
1035
+ if (!fs.existsSync(logPath())) return null;
1036
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1037
+ const backupPath = `${logPath()}.${reason}-${stamp}.log`;
1038
+ await fsp.mkdir(path.dirname(backupPath), { recursive: true });
1039
+ await fsp.copyFile(logPath(), backupPath);
1040
+ return backupPath;
1041
+ }
1042
+
1043
+ async function repairPackagedRuntimeCache(config) {
1044
+ if (config.mode === "dev") {
1045
+ const result = await startRuntime(config);
1046
+ return {
1047
+ ok: result.ok,
1048
+ mode: "dev",
1049
+ dataRoot: config.dataRoot,
1050
+ health: result.health ?? { ok: result.ok }
1051
+ };
1052
+ }
1053
+
1054
+ await stopRuntime();
1055
+ const rotatedLogPath = await rotateRuntimeLog("repair");
1056
+ await fsp.rm(runtimeStatePath(), { force: true });
1057
+ await fsp.rm(runtimeInstallRoot(), { recursive: true, force: true });
1058
+ const pluginRoot = await ensurePackagedRuntimeInstalled({ forceInstall: true });
1059
+ const result = await startRuntime(config);
1060
+ const record = {
1061
+ repairedAt: new Date().toISOString(),
1062
+ ok: result.ok,
1063
+ mode: config.mode,
1064
+ runtimePackage: RUNTIME_PACKAGE,
1065
+ runtimePackageVersion: RUNTIME_PACKAGE_VERSION,
1066
+ pluginRoot,
1067
+ dataRoot: config.dataRoot,
1068
+ dataPreserved: true,
1069
+ rotatedLogPath,
1070
+ health: result.health ?? { ok: result.ok }
1071
+ };
1072
+ const stamp = record.repairedAt.replace(/[:.]/g, "-");
1073
+ const repairRecordPath = path.join(forgeHome(), "run", `runtime-repair-${stamp}.json`);
1074
+ await fsp.mkdir(path.dirname(repairRecordPath), { recursive: true });
1075
+ await fsp.writeFile(repairRecordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
1076
+ return { ...record, repairRecordPath };
1077
+ }
1078
+
906
1079
  async function startRuntime(config) {
907
1080
  const existing = await readRuntimeState();
908
1081
  if (existing?.pid && processExists(existing.pid)) {
@@ -1003,6 +1176,18 @@ async function startRuntime(config) {
1003
1176
  return { ok: result.ok, started: true, state, health: result };
1004
1177
  }
1005
1178
 
1179
+ function assertRuntimeStartedForPairing(result, config) {
1180
+ if (result?.ok) return;
1181
+ throw new Error(
1182
+ [
1183
+ `Forge runtime did not become healthy at ${baseUrl(config)}, so iOS pairing was not started.`,
1184
+ `Health check: ${describeHealthResult(result?.health ?? { ok: false })}.`,
1185
+ `Run npx forge-memory doctor --repair and inspect ${logPath()}.`,
1186
+ `Your data folder is unchanged.`
1187
+ ].join(" ")
1188
+ );
1189
+ }
1190
+
1006
1191
  async function stopRuntime() {
1007
1192
  const state = await readRuntimeState();
1008
1193
  if (!state?.children?.length)
@@ -1222,72 +1407,300 @@ async function uninstallForgeMemory(parsed) {
1222
1407
  };
1223
1408
  }
1224
1409
 
1410
+ function normalizePublicPairingUrl(value) {
1411
+ if (!value?.trim()) return null;
1412
+ try {
1413
+ const url = new URL(value.trim());
1414
+ return url.toString();
1415
+ } catch {
1416
+ throw new Error(
1417
+ `Invalid --public-url value: ${value}. Use a full URL such as https://your-mac.tailnet.ts.net/forge/`
1418
+ );
1419
+ }
1420
+ }
1421
+
1225
1422
  async function createPairing(config, options = {}) {
1226
1423
  const transportMode = options.transportMode ?? "iroh";
1227
- const response = await fetch(
1228
- new URL("/api/v1/health/pairing-sessions", baseUrl(config)),
1229
- {
1424
+ const publicUrl = validatePairingOptions({ transportMode, publicUrl: options.publicUrl });
1425
+ const pairingUrl = new URL("/api/v1/health/pairing-sessions", baseUrl(config));
1426
+ let response;
1427
+ try {
1428
+ response = await fetch(pairingUrl, {
1230
1429
  method: "POST",
1231
1430
  headers: {
1232
1431
  "content-type": "application/json",
1233
- accept: "application/json"
1432
+ accept: "application/json",
1433
+ ...(publicUrl ? { referer: publicUrl } : {})
1234
1434
  },
1235
1435
  body: JSON.stringify({ userId: null, transportMode })
1436
+ });
1437
+ } catch (error) {
1438
+ const healthResult = await health(config, 1_500);
1439
+ const manualHttpHint =
1440
+ transportMode === "manual-http" && !publicUrl
1441
+ ? " 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/"
1442
+ : "";
1443
+ throw new Error(
1444
+ [
1445
+ `Could not create iOS pairing because Forge did not respond at ${pairingUrl}.`,
1446
+ `Network: ${describeNetworkError(error)}.`,
1447
+ `Health check: ${describeHealthResult(healthResult)}.`,
1448
+ `Run npx forge-memory doctor --repair, then npx forge-memory pair-ios again.`,
1449
+ `Runtime log: ${logPath()}.${manualHttpHint}`
1450
+ ].join(" ")
1451
+ );
1452
+ }
1453
+ if (!response.ok) {
1454
+ const body = await response.text().catch(() => "");
1455
+ throw new Error(
1456
+ [
1457
+ `Could not create iOS pairing at ${pairingUrl}: Forge returned HTTP ${response.status}.`,
1458
+ body ? `Response: ${body.slice(0, 500)}` : "",
1459
+ `Run npx forge-memory doctor --repair and inspect ${logPath()}.`
1460
+ ]
1461
+ .filter(Boolean)
1462
+ .join(" ")
1463
+ );
1464
+ }
1465
+ return response.json();
1466
+ }
1467
+
1468
+ function validatePairingOptions({ transportMode, publicUrl }) {
1469
+ const normalizedPublicUrl = normalizePublicPairingUrl(publicUrl);
1470
+ if (transportMode !== "manual-http") {
1471
+ return normalizedPublicUrl;
1472
+ }
1473
+ if (!normalizedPublicUrl) {
1474
+ throw new Error(
1475
+ [
1476
+ "Manual HTTP pairing for a physical iPhone requires --public-url.",
1477
+ "Use a phone-reachable Tailscale or LAN Forge URL, for example:",
1478
+ "npx forge-memory pair-ios --manual-http --public-url https://your-mac.tailnet.ts.net/forge/",
1479
+ "For normal pairing, omit --manual-http and use the default Iroh transport."
1480
+ ].join(" ")
1481
+ );
1482
+ }
1483
+ if (isLoopbackPairingUrl(normalizedPublicUrl)) {
1484
+ throw new Error(
1485
+ [
1486
+ `Manual HTTP --public-url points at ${normalizedPublicUrl}, which is loopback-only.`,
1487
+ "A physical iPhone cannot reach localhost on this Mac.",
1488
+ "Use a Tailscale or LAN URL, or omit --manual-http and use Iroh pairing."
1489
+ ].join(" ")
1490
+ );
1491
+ }
1492
+ return normalizedPublicUrl;
1493
+ }
1494
+
1495
+ function compactPairingPayload(payload) {
1496
+ const transport = payload.transport
1497
+ ? {
1498
+ protocol: payload.transport.protocol,
1499
+ provider: payload.transport.provider,
1500
+ status: payload.transport.status,
1501
+ publicBaseUrl: payload.transport.publicBaseUrl,
1502
+ localBaseUrl: payload.transport.localBaseUrl,
1503
+ nodeId: payload.transport.nodeId,
1504
+ relay: payload.transport.relay,
1505
+ alpn: payload.transport.alpn,
1506
+ agent: payload.transport.agent,
1507
+ pairPayload: payload.transport.pairPayload,
1508
+ lastError: payload.transport.lastError,
1509
+ notes: []
1510
+ }
1511
+ : undefined;
1512
+ return compactObject({
1513
+ kind: payload.kind,
1514
+ apiBaseUrl: payload.apiBaseUrl,
1515
+ uiBaseUrl: payload.uiBaseUrl,
1516
+ transportMode: payload.transportMode,
1517
+ transport,
1518
+ sessionId: payload.sessionId,
1519
+ pairingToken: payload.pairingToken,
1520
+ expiresAt: payload.expiresAt,
1521
+ capabilities: payload.capabilities
1522
+ });
1523
+ }
1524
+
1525
+ function compactObject(value) {
1526
+ if (Array.isArray(value)) {
1527
+ const compacted = value.map((entry) => compactObject(entry)).filter((entry) => entry !== undefined);
1528
+ return compacted.length ? compacted : undefined;
1529
+ }
1530
+ if (!value || typeof value !== "object") {
1531
+ return value ?? undefined;
1532
+ }
1533
+ const output = {};
1534
+ for (const [key, entry] of Object.entries(value)) {
1535
+ const compacted = compactObject(entry);
1536
+ if (compacted !== undefined && !(Array.isArray(compacted) && compacted.length === 0)) {
1537
+ output[key] = compacted;
1236
1538
  }
1539
+ }
1540
+ return Object.keys(output).length ? output : undefined;
1541
+ }
1542
+
1543
+ async function writePairingPayloadFile(payload) {
1544
+ const pairingDir = path.join(forgeHome(), "pairing");
1545
+ await fsp.mkdir(pairingDir, { recursive: true });
1546
+ const filePath = path.join(
1547
+ pairingDir,
1548
+ `forge-companion-${payload.sessionId}.json`
1237
1549
  );
1238
- if (!response.ok)
1239
- throw new Error(`Pairing request failed with ${response.status}`);
1240
- return response.json();
1550
+ await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
1551
+ return filePath;
1552
+ }
1553
+
1554
+ function isLoopbackPairingUrl(value) {
1555
+ try {
1556
+ const host = new URL(value).hostname.toLowerCase();
1557
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
1558
+ } catch {
1559
+ return false;
1560
+ }
1241
1561
  }
1242
1562
 
1243
- function printPairing(pairing) {
1244
- console.log("\nScan this QR in Forge Companion:\n");
1245
- qrcode.generate(JSON.stringify(pairing.qrPayload), { small: true });
1246
- const transport = pairing.qrPayload?.transport;
1563
+ async function printPairing(pairing) {
1564
+ const payload = compactPairingPayload(pairing.qrPayload);
1565
+ const payloadText = JSON.stringify(payload);
1566
+ const terminalColumns = process.stdout.columns ?? 120;
1567
+ if (terminalColumns >= 72 && payloadText.length <= 2_950) {
1568
+ console.log("\nScan this compact QR in Forge Companion:\n");
1569
+ qrcode.generate(payloadText, { small: true });
1570
+ } else {
1571
+ console.log("");
1572
+ console.log(color.yellow("QR skipped because the terminal is too narrow or the payload is too large to scan reliably."));
1573
+ console.log("Use Manual connection in the iPhone app and paste the saved payload below.");
1574
+ }
1575
+ const transport = payload.transport;
1247
1576
  if (transport?.provider) {
1248
1577
  const label =
1249
- pairing.qrPayload.transport?.protocol === "iroh"
1578
+ payload.transport?.protocol === "iroh"
1250
1579
  ? "Iroh"
1251
- : pairing.qrPayload.transportMode === "iroh"
1580
+ : payload.transportMode === "iroh"
1252
1581
  ? "Iroh"
1253
1582
  : "Manual HTTP";
1254
- console.log(`${color.cyan(label)}: ${pairing.qrPayload.apiBaseUrl}`);
1255
- if (transport.recreateCommand) {
1256
- console.log(`${color.dim("recreate:")} ${transport.recreateCommand}`);
1583
+ console.log(`${color.cyan(label)}: ${payload.apiBaseUrl}`);
1584
+ if (label === "Manual HTTP" && isLoopbackPairingUrl(payload.apiBaseUrl)) {
1585
+ console.log(
1586
+ color.yellow(
1587
+ "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."
1588
+ )
1589
+ );
1257
1590
  }
1258
- for (const note of transport.notes ?? []) {
1591
+ for (const note of pairing.qrPayload?.transport?.notes ?? []) {
1259
1592
  console.log(color.dim(note));
1260
1593
  }
1261
1594
  }
1262
- console.log(JSON.stringify(pairing.qrPayload, null, 2));
1595
+ try {
1596
+ const filePath = await writePairingPayloadFile(payload);
1597
+ console.log("");
1598
+ console.log(color.bold("If the QR is too large or the camera will not scan:"));
1599
+ console.log("1. Open Manual connection in the iPhone app.");
1600
+ console.log("2. Tap Paste pairing payload.");
1601
+ console.log(`3. Paste the payload saved at: ${filePath}`);
1602
+ console.log(color.dim(` cat ${filePath}`));
1603
+ console.log("");
1604
+ console.log(color.dim(`Compact payload bytes: ${payloadText.length}`));
1605
+ } catch (error) {
1606
+ console.log(
1607
+ color.yellow(
1608
+ `Could not save pairing payload file: ${error instanceof Error ? error.message : String(error)}`
1609
+ )
1610
+ );
1611
+ console.log(payloadText);
1612
+ }
1263
1613
  }
1264
1614
 
1265
1615
  async function runInstall(parsed, command) {
1266
1616
  const currentConfig = await readConfig();
1267
- const discovery = discover();
1268
1617
  if (!parsed.flags.yes) {
1269
1618
  printBanner();
1270
1619
  console.log(
1271
1620
  color.dim(
1272
- "Discovery runs in the background. Forge UI/runtime is always installed.\n"
1621
+ "Forge UI/runtime is always installed. Host adapter discovery runs first.\n"
1273
1622
  )
1274
1623
  );
1275
1624
  }
1625
+ const discovery = await withProgress(
1626
+ "Looking for host adapters",
1627
+ "OpenClaw, Hermes, and Codex",
1628
+ parsed.flags,
1629
+ async () => discover()
1630
+ );
1276
1631
  const config = await buildInstallConfig(
1277
1632
  parsed,
1278
1633
  currentConfig,
1279
1634
  discovery,
1280
1635
  command
1281
1636
  );
1282
- const writeResult = await writeConfig(config, {
1283
- dryRun: parsed.flags.dryRun
1284
- });
1285
- const adapterResults = await configureAdapters(config, {
1286
- dryRun: parsed.flags.dryRun
1287
- });
1637
+ const writeResult = await withProgress(
1638
+ "Saving Forge settings",
1639
+ configPath(),
1640
+ parsed.flags,
1641
+ () =>
1642
+ writeConfig(config, {
1643
+ dryRun: parsed.flags.dryRun
1644
+ })
1645
+ );
1646
+ await withProgress(
1647
+ "Preparing Forge data folder",
1648
+ config.dataRoot,
1649
+ parsed.flags,
1650
+ async () => {
1651
+ if (!parsed.flags.dryRun) {
1652
+ await fsp.mkdir(config.dataRoot, { recursive: true });
1653
+ }
1654
+ return { ok: true, dataRoot: config.dataRoot };
1655
+ }
1656
+ );
1657
+ const adapterResults = await withProgress(
1658
+ config.adapters.length
1659
+ ? "Configuring selected host adapters"
1660
+ : "Skipping host adapter configuration",
1661
+ config.adapters.length ? config.adapters.join(", ") : "none selected",
1662
+ parsed.flags,
1663
+ () =>
1664
+ configureAdapters(config, {
1665
+ dryRun: parsed.flags.dryRun
1666
+ })
1667
+ );
1288
1668
  let runtimeResult = null;
1289
1669
  if (!parsed.flags.noStart && !parsed.flags.dryRun) {
1290
- runtimeResult = await startRuntime(config);
1670
+ runtimeResult = await withProgress(
1671
+ config.mode === "dev"
1672
+ ? "Starting source-backed Forge runtime"
1673
+ : "Installing and starting Forge runtime",
1674
+ `logs: ${logPath()}`,
1675
+ parsed.flags,
1676
+ () => startRuntime(config)
1677
+ );
1678
+ } else if (parsed.flags.noStart) {
1679
+ printStep(
1680
+ "Runtime start skipped",
1681
+ "run npx forge-memory ui or npx forge-memory restart later",
1682
+ parsed.flags
1683
+ );
1684
+ }
1685
+ let doctorResult = null;
1686
+ if (!parsed.flags.noDoctor) {
1687
+ doctorResult = await withProgress(
1688
+ "Running Forge doctor",
1689
+ parsed.flags.noStart ? "offline checks" : "health and repair checks",
1690
+ parsed.flags,
1691
+ () =>
1692
+ runDoctorChecks(parsed, config, {
1693
+ repair: true,
1694
+ noStart: parsed.flags.noStart,
1695
+ dryRun: parsed.flags.dryRun
1696
+ })
1697
+ );
1698
+ if (!doctorResult.ok && !parsed.flags.json && !parsed.flags.dryRun) {
1699
+ console.log(color.yellow("Forge doctor found follow-up work:"));
1700
+ for (const check of doctorResult.checks.filter((entry) => !entry.ok)) {
1701
+ console.log(`- ${check.id}: ${check.guidance}`);
1702
+ }
1703
+ }
1291
1704
  }
1292
1705
  const shouldPair =
1293
1706
  parsed.flags.pairIos ||
@@ -1297,13 +1710,34 @@ async function runInstall(parsed, command) {
1297
1710
  : await promptYesNo("Pair the iOS companion now?", true)));
1298
1711
  let pairing = null;
1299
1712
  if (shouldPair && !parsed.flags.dryRun) {
1300
- if (!runtimeResult) await startRuntime(config);
1301
- pairing = await createPairing(config, {
1302
- transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh"
1303
- });
1713
+ if (!runtimeResult) {
1714
+ runtimeResult = await withProgress(
1715
+ "Starting Forge runtime for iOS pairing",
1716
+ `logs: ${logPath()}`,
1717
+ parsed.flags,
1718
+ () => startRuntime(config)
1719
+ );
1720
+ }
1721
+ assertRuntimeStartedForPairing(runtimeResult, config);
1722
+ pairing = await withProgress(
1723
+ "Creating iOS companion pairing",
1724
+ parsed.flags.manualHttp ? "manual HTTP" : "Iroh QR",
1725
+ parsed.flags,
1726
+ () =>
1727
+ createPairing(config, {
1728
+ transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh",
1729
+ publicUrl: parsed.values.publicUrl
1730
+ })
1731
+ );
1304
1732
  if (pairing?.qrPayload && !parsed.flags.json) {
1305
- printPairing(pairing);
1733
+ await printPairing(pairing);
1306
1734
  }
1735
+ } else if (parsed.flags.skipPairIos) {
1736
+ printStep(
1737
+ "iOS pairing skipped",
1738
+ "run npx forge-memory pair-ios when you want the QR",
1739
+ parsed.flags
1740
+ );
1307
1741
  }
1308
1742
  const summary = {
1309
1743
  ok: true,
@@ -1311,13 +1745,23 @@ async function runInstall(parsed, command) {
1311
1745
  writeResult,
1312
1746
  adapterResults,
1313
1747
  runtimeResult,
1748
+ doctorResult,
1314
1749
  pairing: Boolean(pairing)
1315
1750
  };
1316
1751
  if (parsed.flags.json) console.log(JSON.stringify(summary, null, 2));
1317
1752
  else {
1318
- console.log(color.green("Forge Memory configured."));
1753
+ console.log(color.green("Forge Memory configured and checked."));
1319
1754
  console.log(`UI: ${webUrl(config)}`);
1320
1755
  console.log(`Data: ${config.dataRoot}`);
1756
+ console.log(
1757
+ `Doctor: ${
1758
+ parsed.flags.dryRun
1759
+ ? color.yellow("preview only")
1760
+ : doctorResult?.ok === false
1761
+ ? color.yellow("needs attention")
1762
+ : color.green("passed")
1763
+ }`
1764
+ );
1321
1765
  if (parsed.flags.dryRun)
1322
1766
  console.log(
1323
1767
  color.yellow("Dry run only; no files or adapter installs were changed.")
@@ -1355,39 +1799,114 @@ async function runStatus(parsed) {
1355
1799
  }
1356
1800
  }
1357
1801
 
1358
- async function runDoctor(parsed) {
1359
- const config = await readConfig();
1802
+ async function doctorCheckRuntime(config, options) {
1803
+ let result = await health(config);
1804
+ let repaired = false;
1805
+ let repairRecordPath = null;
1806
+ if (!result.ok && options.repair && !options.noStart && !options.dryRun) {
1807
+ const repair = await repairPackagedRuntimeCache(config);
1808
+ repairRecordPath = repair.repairRecordPath ?? null;
1809
+ result = await health(config, 3_000);
1810
+ repaired = result.ok;
1811
+ }
1812
+ return {
1813
+ id: "runtime",
1814
+ ok: result.ok,
1815
+ detail: baseUrl(config),
1816
+ repaired,
1817
+ repairRecordPath,
1818
+ guidance: result.ok
1819
+ ? "Forge API is reachable."
1820
+ : `Run npx forge-memory doctor --repair, then inspect ${logPath()} if the runtime still does not start. Repair reinstalls only the owned runtime cache and preserves the data folder.`
1821
+ };
1822
+ }
1823
+
1824
+ async function runDoctorChecks(parsed, config, options = {}) {
1360
1825
  const discovery = discover();
1361
- const checks = [
1362
- {
1363
- id: "node",
1364
- ok: Number(process.versions.node.split(".")[0]) >= 22,
1365
- detail: process.versions.node
1366
- },
1367
- { id: "config", ok: fs.existsSync(configPath()), detail: configPath() },
1368
- {
1369
- id: "dataRoot",
1370
- ok: fs.existsSync(config.dataRoot),
1371
- detail: config.dataRoot
1372
- },
1373
- { id: "runtime", ok: (await health(config)).ok, detail: baseUrl(config) },
1374
- ...discovery.adapters.map((adapter) => ({
1826
+ const checks = [];
1827
+
1828
+ checks.push({
1829
+ id: "node",
1830
+ ok: Number(process.versions.node.split(".")[0]) >= 22,
1831
+ detail: process.versions.node,
1832
+ guidance:
1833
+ "Forge Memory requires Node 22 or newer. Install a current Node release, then rerun npx forge-memory configure."
1834
+ });
1835
+
1836
+ const configExists = fs.existsSync(configPath());
1837
+ checks.push({
1838
+ id: "config",
1839
+ ok: configExists,
1840
+ detail: configPath(),
1841
+ guidance:
1842
+ "Run npx forge-memory configure to create the runtime manager config."
1843
+ });
1844
+
1845
+ let dataRootExists = fs.existsSync(config.dataRoot);
1846
+ let dataRootRepaired = false;
1847
+ if (!dataRootExists && options.repair && !options.dryRun) {
1848
+ await fsp.mkdir(config.dataRoot, { recursive: true });
1849
+ dataRootExists = true;
1850
+ dataRootRepaired = true;
1851
+ }
1852
+ checks.push({
1853
+ id: "dataRoot",
1854
+ ok: dataRootExists,
1855
+ detail: config.dataRoot,
1856
+ repaired: dataRootRepaired,
1857
+ guidance:
1858
+ "Forge data is preserved here. Doctor can create the folder, but it will not delete existing data."
1859
+ });
1860
+
1861
+ checks.push(await doctorCheckRuntime(config, options));
1862
+
1863
+ for (const adapter of discovery.adapters) {
1864
+ const selected = config.adapters.includes(adapter.id);
1865
+ checks.push({
1375
1866
  id: adapter.id,
1376
- ok: adapter.installed,
1377
- detail: adapter.status
1378
- }))
1379
- ];
1380
- const payload = {
1381
- ok: checks.every((check) => check.ok || ADAPTERS.includes(check.id)),
1867
+ ok: selected ? adapter.installed : true,
1868
+ detail: selected
1869
+ ? adapter.status
1870
+ : adapter.installed
1871
+ ? `${adapter.status}; not selected`
1872
+ : "not selected",
1873
+ selected,
1874
+ guidance: selected
1875
+ ? adapter.hint
1876
+ : `${adapter.label} is not selected for this Forge install.`
1877
+ });
1878
+ }
1879
+
1880
+ return {
1881
+ ok: checks.every((check) => check.ok),
1382
1882
  checks
1383
1883
  };
1884
+ }
1885
+
1886
+ async function runDoctor(parsed) {
1887
+ const config = await readConfig();
1888
+ const payload = await withProgress(
1889
+ "Checking Forge Memory install",
1890
+ parsed.flags.repair ? "repair enabled" : "read-only",
1891
+ parsed.flags,
1892
+ () =>
1893
+ runDoctorChecks(parsed, config, {
1894
+ repair: parsed.flags.repair,
1895
+ noStart: parsed.flags.noStart,
1896
+ dryRun: parsed.flags.dryRun
1897
+ })
1898
+ );
1384
1899
  if (parsed.flags.json) console.log(JSON.stringify(payload, null, 2));
1385
1900
  else {
1386
1901
  console.log(color.bold("Forge Memory Doctor"));
1387
- for (const check of checks) {
1902
+ for (const check of payload.checks) {
1903
+ const repaired = check.repaired ? color.cyan(" repaired") : "";
1388
1904
  console.log(
1389
- `${check.ok ? color.green("ok") : color.yellow("warn")} ${check.id}: ${check.detail}`
1905
+ `${check.ok ? color.green("ok") : color.yellow("warn")} ${check.id}: ${check.detail}${repaired}`
1390
1906
  );
1907
+ if (!check.ok && check.guidance) {
1908
+ console.log(color.dim(` ${check.guidance}`));
1909
+ }
1391
1910
  }
1392
1911
  }
1393
1912
  }
@@ -1408,15 +1927,35 @@ async function runUi(parsed) {
1408
1927
 
1409
1928
  async function runPairIos(parsed) {
1410
1929
  const config = await readConfig();
1411
- await startRuntime(config);
1930
+ const transportMode = parsed.flags.manualHttp ? "manual-http" : "iroh";
1931
+ const publicUrl = validatePairingOptions({
1932
+ transportMode,
1933
+ publicUrl: parsed.values.publicUrl
1934
+ });
1935
+ if (!parsed.flags.noStart) {
1936
+ const runtimeResult = await withProgress(
1937
+ "Starting Forge runtime for iOS pairing",
1938
+ `logs: ${logPath()}`,
1939
+ parsed.flags,
1940
+ () => startRuntime(config)
1941
+ );
1942
+ assertRuntimeStartedForPairing(runtimeResult, config);
1943
+ } else {
1944
+ const currentHealth = await health(config, 3_000);
1945
+ assertRuntimeStartedForPairing(
1946
+ { ok: currentHealth.ok, started: false, health: currentHealth },
1947
+ config
1948
+ );
1949
+ }
1412
1950
  const pairing = await createPairing(config, {
1413
- transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh"
1951
+ transportMode,
1952
+ publicUrl
1414
1953
  });
1415
1954
  if (parsed.flags.json) {
1416
1955
  console.log(JSON.stringify(pairing, null, 2));
1417
1956
  return;
1418
1957
  }
1419
- printPairing(pairing);
1958
+ await printPairing(pairing);
1420
1959
  }
1421
1960
 
1422
1961
  async function runLogs() {
@@ -1748,7 +2287,10 @@ Options:
1748
2287
  --skip-adapters Configure UI/runtime only
1749
2288
  --skip-pair-ios Do not prompt or create iOS pairing
1750
2289
  --manual-http Use direct HTTP/TCP for iOS pairing instead of the default Iroh transport
2290
+ --public-url <url> Phone-reachable URL for manual HTTP pairing, such as a Tailscale or LAN Forge URL
1751
2291
  --no-start Configure without starting runtime
2292
+ --no-doctor Skip install-time doctor checks
2293
+ --repair Let doctor create missing folders and restart unhealthy runtime
1752
2294
  --output <path> Export destination for forge-memory export
1753
2295
  --remove-adapters During uninstall, remove host adapter config entries
1754
2296
  --remove-data During uninstall, delete the Forge data folder too
@@ -1834,9 +2376,33 @@ async function main() {
1834
2376
  }
1835
2377
  }
1836
2378
 
2379
+ function printFatalError(error, { json = false } = {}) {
2380
+ const message = error instanceof Error ? error.message : String(error);
2381
+ const payload = {
2382
+ ok: false,
2383
+ error: message,
2384
+ guidance: [
2385
+ "Run npx forge-memory doctor --repair to check and repair the local install.",
2386
+ "Run npx forge-memory logs to inspect the runtime log.",
2387
+ "Forge Memory repair never deletes your data folder."
2388
+ ],
2389
+ logPath: logPath()
2390
+ };
2391
+ if (json) {
2392
+ console.error(JSON.stringify(payload, null, 2));
2393
+ return;
2394
+ }
2395
+ console.error(color.red("Forge Memory could not finish this step."));
2396
+ console.error(color.red(message));
2397
+ console.error("");
2398
+ console.error(color.cyan("Next steps:"));
2399
+ for (const item of payload.guidance) {
2400
+ console.error(`- ${item}`);
2401
+ }
2402
+ console.error(`- Runtime log: ${payload.logPath}`);
2403
+ }
2404
+
1837
2405
  main().catch((error) => {
1838
- console.error(
1839
- color.red(error instanceof Error ? error.message : String(error))
1840
- );
2406
+ printFatalError(error, { json: process.argv.includes("--json") });
1841
2407
  process.exitCode = 1;
1842
2408
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-memory",
3
- "version": "0.2.107",
3
+ "version": "0.2.109",
4
4
  "description": "Guided Forge installer and local runtime manager for the Forge UI, OpenClaw, Hermes, Codex, and iOS pairing.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",