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 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
  }
@@ -869,36 +988,34 @@ async function ensurePackagedRuntimeInstalled() {
869
988
  "utf8"
870
989
  );
871
990
  }
872
- await fsp.mkdir(path.dirname(logPath()), { recursive: true });
873
- const out = fs.openSync(logPath(), "a");
874
- try {
875
- const result = spawnSync(
876
- "npm",
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
- "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
- }
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 response = await fetch(
1228
- new URL("/api/v1/health/pairing-sessions", baseUrl(config)),
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
- if (!response.ok)
1239
- throw new Error(`Pairing request failed with ${response.status}`);
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 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;
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
- pairing.qrPayload.transport?.protocol === "iroh"
1470
+ payload.transport?.protocol === "iroh"
1250
1471
  ? "Iroh"
1251
- : pairing.qrPayload.transportMode === "iroh"
1472
+ : payload.transportMode === "iroh"
1252
1473
  ? "Iroh"
1253
1474
  : "Manual HTTP";
1254
- console.log(`${color.cyan(label)}: ${pairing.qrPayload.apiBaseUrl}`);
1255
- if (transport.recreateCommand) {
1256
- console.log(`${color.dim("recreate:")} ${transport.recreateCommand}`);
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.notes ?? []) {
1483
+ for (const note of pairing.qrPayload?.transport?.notes ?? []) {
1259
1484
  console.log(color.dim(note));
1260
1485
  }
1261
1486
  }
1262
- console.log(JSON.stringify(pairing.qrPayload, null, 2));
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
- "Discovery runs in the background. Forge UI/runtime is always installed.\n"
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 writeConfig(config, {
1283
- dryRun: parsed.flags.dryRun
1284
- });
1285
- const adapterResults = await configureAdapters(config, {
1286
- dryRun: parsed.flags.dryRun
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 startRuntime(config);
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) await startRuntime(config);
1301
- pairing = await createPairing(config, {
1302
- transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh"
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 runDoctor(parsed) {
1359
- const config = await readConfig();
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
- 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) => ({
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: adapter.status
1378
- }))
1379
- ];
1380
- const payload = {
1381
- ok: checks.every((check) => check.ok || ADAPTERS.includes(check.id)),
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
- await startRuntime(config);
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
- console.error(
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-memory",
3
- "version": "0.2.107",
3
+ "version": "0.2.108",
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",