forge-memory 0.2.114 → 0.2.116

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
@@ -38,11 +38,12 @@ tools). It also exposes `forge_memory_mcp_diagnostics` so adapter startup issues
38
38
  show up as a tool result instead of a closed MCP transport.
39
39
 
40
40
  `pair-ios` prefers the Iroh QR. Forge starts a Rust Iroh host, prints a QR payload
41
- with the desktop node id, pairing token, optional relay hint, and ALPN
42
- `forge-companion/1`, and the iPhone app connects through its native Rust bridge. The
43
- CLI renders a short-schema QR to keep the terminal code scannable and saves the
44
- full manual payload under `~/.forge/pairing/` so you can paste it into the iPhone
45
- app if the camera cannot scan.
41
+ with the desktop node id, pairing token, optional relay hint, ALPN
42
+ `forge-companion/1`, and the request URL as a direct fallback when it is
43
+ phone-reachable. The iPhone app connects through its native Rust bridge first and can
44
+ retry through URLSession when that bridge times out. The CLI renders a short-schema QR
45
+ to keep the terminal code scannable and saves the full manual payload under
46
+ `~/.forge/pairing/` so you can paste it into the iPhone app if the camera cannot scan.
46
47
  Use `--manual-http` only when you intentionally want a LAN, Tailscale, or direct
47
48
  HTTP/TCP route. For a real iPhone, pass a phone-reachable URL:
48
49
 
@@ -56,10 +57,12 @@ the iOS Simulator but not for a physical phone.
56
57
  The base install stays one command on purpose. The detailed companion transport
57
58
  reference lives in the Forge repo at `docs/companion-iroh.md` and in the published
58
59
  docs at `https://albertbuchard.github.io/forge/companion-transport.html`. Forge
59
- Memory ships prebuilt Iroh host binaries for common desktop platforms and a bundled
60
- Rust source fallback for other machines. If neither a prebuilt host nor Cargo is
61
- available, `pair-ios` stops with transport-specific repair guidance instead of
62
- printing a localhost QR that a physical iPhone cannot use.
60
+ Memory ships Forge's Iroh host source and lockfile, not native desktop binaries. When
61
+ the default iOS pairing flow is selected, the installer checks for Cargo, offers to
62
+ install the minimal Rust toolchain when the platform supports it, builds the local
63
+ host from the bundled source, then creates the QR. If Cargo cannot be installed
64
+ automatically, `install`, `configure`, and `pair-ios` stop with platform-specific
65
+ steps instead of printing a localhost QR that a physical iPhone cannot use.
63
66
 
64
67
  `configure` reruns the full guided flow using the current config as defaults.
65
68
  Install and configure run Forge doctor before finishing. `doctor --repair` creates
@@ -277,6 +277,231 @@ function runCapture(command, args, timeoutMs = 2_000) {
277
277
  return `${result.stdout}${result.stderr}`.trim();
278
278
  }
279
279
 
280
+ function binaryNameForPlatform() {
281
+ return process.platform === "win32"
282
+ ? "forge-companion-iroh.exe"
283
+ : "forge-companion-iroh";
284
+ }
285
+
286
+ function candidateIrohRoots(config) {
287
+ const roots = [];
288
+ if (config.mode === "dev" && config.repo) {
289
+ roots.push(config.repo);
290
+ roots.push(path.join(config.repo, "openclaw-plugin", "dist"));
291
+ }
292
+ const pluginRoot = resolveOpenClawPluginRoot();
293
+ if (pluginRoot) {
294
+ roots.push(pluginRoot);
295
+ roots.push(path.join(pluginRoot, "dist"));
296
+ }
297
+ return [...new Set(roots.map((entry) => path.resolve(entry)))];
298
+ }
299
+
300
+ function candidateIrohBinariesForInstall(config) {
301
+ const binaryName = binaryNameForPlatform();
302
+ const platformKey = `${process.platform}-${process.arch}`;
303
+ const explicitBin = process.env.FORGE_COMPANION_IROH_BIN?.trim();
304
+ return [
305
+ ...(explicitBin ? [explicitBin] : []),
306
+ ...candidateIrohRoots(config).flatMap((root) => [
307
+ path.join(root, "companion-iroh", "target", "release", binaryName),
308
+ path.join(root, "companion-iroh", "target", "debug", binaryName),
309
+ path.join(root, "companion-iroh-src", "target", "release", binaryName),
310
+ path.join(root, "companion-iroh-src", "target", "debug", binaryName),
311
+ path.join(root, "companion-iroh", platformKey, binaryName),
312
+ path.join(root, "companion-iroh", binaryName)
313
+ ])
314
+ ];
315
+ }
316
+
317
+ function findIrohBinaryForInstall(config) {
318
+ return candidateIrohBinariesForInstall(config).find((candidate) =>
319
+ fs.existsSync(candidate)
320
+ );
321
+ }
322
+
323
+ function candidateIrohManifestsForInstall(config) {
324
+ return candidateIrohRoots(config).flatMap((root) => [
325
+ path.join(root, "companion-iroh", "Cargo.toml"),
326
+ path.join(root, "companion-iroh-src", "Cargo.toml")
327
+ ]);
328
+ }
329
+
330
+ function findIrohManifestForInstall(config) {
331
+ return candidateIrohManifestsForInstall(config).find((candidate) =>
332
+ fs.existsSync(candidate)
333
+ );
334
+ }
335
+
336
+ function rustInstallGuidance() {
337
+ if (process.platform === "darwin") {
338
+ return [
339
+ "Install Apple's command line tools first if prompted: xcode-select --install",
340
+ "Then install Rust with the official minimal installer:",
341
+ "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal",
342
+ "Restart the terminal or run: source ~/.cargo/env"
343
+ ];
344
+ }
345
+ if (process.platform === "linux") {
346
+ return [
347
+ "Install build tools with your system package manager, for example: sudo apt-get install -y build-essential pkg-config libssl-dev",
348
+ "Then install Rust with the official minimal installer:",
349
+ "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal",
350
+ "Restart the terminal or run: source ~/.cargo/env"
351
+ ];
352
+ }
353
+ if (process.platform === "win32") {
354
+ return [
355
+ "Install Rustup for Windows:",
356
+ "winget install Rustlang.Rustup",
357
+ "Then reopen PowerShell and rerun: npx forge-memory install"
358
+ ];
359
+ }
360
+ return [
361
+ "Install Rust/Cargo from https://rustup.rs, then rerun: npx forge-memory install"
362
+ ];
363
+ }
364
+
365
+ function refreshCargoPath() {
366
+ const cargoBin = path.join(homeDir(), ".cargo", "bin");
367
+ if (fs.existsSync(cargoBin)) {
368
+ const current = process.env.PATH ?? "";
369
+ if (!current.split(path.delimiter).includes(cargoBin)) {
370
+ process.env.PATH = `${cargoBin}${path.delimiter}${current}`;
371
+ }
372
+ }
373
+ }
374
+
375
+ async function maybeInstallRustToolchain(flags) {
376
+ refreshCargoPath();
377
+ if (commandExists("cargo")) {
378
+ return { ok: true, installed: false };
379
+ }
380
+ const guidance = rustInstallGuidance();
381
+ const canUseRustupScript =
382
+ (process.platform === "darwin" || process.platform === "linux") &&
383
+ commandExists("curl");
384
+ const canUseWinget = process.platform === "win32" && commandExists("winget");
385
+ if (!canUseRustupScript && !canUseWinget) {
386
+ return {
387
+ ok: false,
388
+ installed: false,
389
+ guidance:
390
+ process.platform === "darwin" || process.platform === "linux"
391
+ ? ["Install curl first, then install Rust/Cargo.", ...guidance]
392
+ : guidance
393
+ };
394
+ }
395
+ if (flags?.json || flags?.dryRun) {
396
+ return { ok: false, installed: false, guidance };
397
+ }
398
+ const shouldInstall = flags?.yes
399
+ ? true
400
+ : await promptYesNo(
401
+ "Forge Companion Iroh needs Rust/Cargo to build the local transport host. Install the minimal Rust toolchain now?",
402
+ true
403
+ );
404
+ if (!shouldInstall) {
405
+ return { ok: false, installed: false, guidance };
406
+ }
407
+ console.log(color.cyan("Installing minimal Rust toolchain..."));
408
+ const result = canUseWinget
409
+ ? await runCommand("winget", [
410
+ "install",
411
+ "--id",
412
+ "Rustlang.Rustup",
413
+ "-e",
414
+ "--source",
415
+ "winget",
416
+ "--accept-package-agreements",
417
+ "--accept-source-agreements"
418
+ ])
419
+ : await runCommand("sh", [
420
+ "-c",
421
+ "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal"
422
+ ]);
423
+ refreshCargoPath();
424
+ return {
425
+ ok: result.ok && commandExists("cargo"),
426
+ installed: result.ok,
427
+ guidance
428
+ };
429
+ }
430
+
431
+ async function ensureIrohTransportPrepared(config, flags = {}) {
432
+ const existingBinary = findIrohBinaryForInstall(config);
433
+ if (existingBinary) {
434
+ return { ok: true, built: false, binary: existingBinary };
435
+ }
436
+
437
+ if (config.mode !== "dev") {
438
+ await ensurePackagedRuntimeInstalled();
439
+ }
440
+
441
+ const manifestPath = findIrohManifestForInstall(config);
442
+ if (!manifestPath) {
443
+ throw new Error(
444
+ [
445
+ "Forge could not find the bundled companion Iroh source.",
446
+ "Run npx forge-memory doctor --repair so Forge Memory refreshes the packaged runtime.",
447
+ `Runtime log: ${logPath()}.`
448
+ ].join(" ")
449
+ );
450
+ }
451
+
452
+ const rust = await maybeInstallRustToolchain(flags);
453
+ if (!rust.ok) {
454
+ throw new Error(
455
+ [
456
+ "Forge Companion Iroh is source-built on this machine, but Rust/Cargo is not installed yet.",
457
+ "Install steps:",
458
+ ...rustInstallGuidance().map((entry) => `- ${entry}`),
459
+ "Then rerun: npx forge-memory install",
460
+ "For a temporary direct network fallback, use: npx forge-memory pair-ios --manual-http --public-url <phone-reachable Forge URL>"
461
+ ].join("\n")
462
+ );
463
+ }
464
+
465
+ const result = await runLoggedCommand(
466
+ "cargo",
467
+ [
468
+ "build",
469
+ "--release",
470
+ "--manifest-path",
471
+ manifestPath,
472
+ "--bin",
473
+ "forge-companion-iroh"
474
+ ],
475
+ {
476
+ cwd: path.dirname(manifestPath),
477
+ dryRun: flags.dryRun,
478
+ env: process.env,
479
+ logFile: logPath()
480
+ }
481
+ );
482
+ if (!result.ok) {
483
+ throw new Error(
484
+ [
485
+ "Forge could not build the local companion Iroh transport host from source.",
486
+ `Manifest: ${manifestPath}`,
487
+ `Log: ${logPath()}`,
488
+ "Install or repair Rust/Cargo, then rerun: npx forge-memory install"
489
+ ].join(" ")
490
+ );
491
+ }
492
+ const binary = findIrohBinaryForInstall(config);
493
+ if (!binary && !flags.dryRun) {
494
+ throw new Error(
495
+ [
496
+ "Cargo finished, but Forge could not find the built companion Iroh binary.",
497
+ `Manifest: ${manifestPath}`,
498
+ `Expected one of: ${candidateIrohBinariesForInstall(config).join(", ")}`
499
+ ].join(" ")
500
+ );
501
+ }
502
+ return { ok: true, built: true, binary: binary ?? null, manifestPath };
503
+ }
504
+
280
505
  function detectOpenClaw() {
281
506
  const installed =
282
507
  commandExists("openclaw") ||
@@ -606,6 +831,59 @@ function findForgeRepo(start = process.cwd()) {
606
831
  }
607
832
  }
608
833
 
834
+ function resolveRuntimeStorageRoot(healthResult) {
835
+ const storageRoot = healthResult?.payload?.runtime?.storageRoot;
836
+ return typeof storageRoot === "string" && storageRoot.trim()
837
+ ? path.resolve(storageRoot)
838
+ : null;
839
+ }
840
+
841
+ function pathsMatch(left, right) {
842
+ return path.resolve(left) === path.resolve(right);
843
+ }
844
+
845
+ async function resolveInstallRuntimeTarget({
846
+ origin,
847
+ requestedPort,
848
+ requestedWebPort,
849
+ dataRoot,
850
+ dataRootWasExplicit
851
+ }) {
852
+ if (requestedPort !== 0) {
853
+ const desiredConfig = { origin, port: requestedPort };
854
+ const desiredHealth = await health(desiredConfig);
855
+ if (isHealthyForgeRuntime(desiredHealth)) {
856
+ const liveDataRoot = resolveRuntimeStorageRoot(desiredHealth);
857
+ if (liveDataRoot && !pathsMatch(liveDataRoot, dataRoot)) {
858
+ if (dataRootWasExplicit) {
859
+ throw new Error(
860
+ [
861
+ `A healthy Forge runtime is already running at ${baseUrl(desiredConfig)}, but it uses a different data folder.`,
862
+ `Live data folder: ${liveDataRoot}.`,
863
+ `Requested data folder: ${path.resolve(dataRoot)}.`,
864
+ "Stop or restart that runtime before switching data folders. Your data folder is unchanged."
865
+ ].join(" ")
866
+ );
867
+ }
868
+ dataRoot = liveDataRoot;
869
+ }
870
+ return {
871
+ port: requestedPort,
872
+ webPort: requestedWebPort,
873
+ dataRoot: path.resolve(dataRoot),
874
+ adoptedExistingRuntime: true
875
+ };
876
+ }
877
+ }
878
+
879
+ return {
880
+ port: await findFreePort(requestedPort),
881
+ webPort: await findFreePort(requestedWebPort),
882
+ dataRoot: path.resolve(dataRoot),
883
+ adoptedExistingRuntime: false
884
+ };
885
+ }
886
+
609
887
  async function buildInstallConfig(parsed, currentConfig, discovery, command) {
610
888
  const repo = parsed.values.repo
611
889
  ? path.resolve(parsed.values.repo)
@@ -634,22 +912,25 @@ async function buildInstallConfig(parsed, currentConfig, discovery, command) {
634
912
  const dataRoot = parsed.flags.yes
635
913
  ? dataRootDefault
636
914
  : await promptLine("Forge data folder", dataRootDefault);
637
- const portInput = parsed.values.port ?? currentConfig.port;
638
- const port = await findFreePort(normalizePort(portInput, DEFAULT_PORT));
639
- const webPort = await findFreePort(
640
- normalizePort(
915
+ const origin = parsed.values.origin ?? currentConfig.origin ?? DEFAULT_ORIGIN;
916
+ const runtimeTarget = await resolveInstallRuntimeTarget({
917
+ origin,
918
+ requestedPort: normalizePort(parsed.values.port ?? currentConfig.port, DEFAULT_PORT),
919
+ requestedWebPort: normalizePort(
641
920
  parsed.values.webPort ?? currentConfig.webPort,
642
921
  DEFAULT_WEB_PORT
643
- )
644
- );
922
+ ),
923
+ dataRoot,
924
+ dataRootWasExplicit: typeof parsed.values.dataRoot === "string"
925
+ });
645
926
 
646
927
  return {
647
928
  version: VERSION,
648
929
  mode: parsed.flags.dev ? "dev" : mode,
649
- origin: parsed.values.origin ?? currentConfig.origin ?? DEFAULT_ORIGIN,
650
- port,
651
- webPort,
652
- dataRoot: path.resolve(dataRoot),
930
+ origin,
931
+ port: runtimeTarget.port,
932
+ webPort: runtimeTarget.webPort,
933
+ dataRoot: runtimeTarget.dataRoot,
653
934
  adapters,
654
935
  repo,
655
936
  command
@@ -1623,9 +1904,8 @@ class PairingTransportUnavailableError extends Error {
1623
1904
  this.code = "pairing_transport_unavailable";
1624
1905
  this.detail = detail;
1625
1906
  this.guidance = [
1626
- "Run npx forge-memory doctor --repair so Forge Memory refreshes the packaged runtime and companion transport files.",
1627
- "Then rerun npx forge-memory pair-ios.",
1628
- "On unsupported platforms, install Rust/Cargo so Forge can build the bundled companion Iroh source fallback.",
1907
+ "Run npx forge-memory install or npx forge-memory configure so the installer can prepare the Iroh transport.",
1908
+ "If Rust/Cargo is missing, the installer will guide you through installing the minimal Rust toolchain and building Forge's bundled Iroh host source.",
1629
1909
  "For an explicit Tailscale or LAN fallback, rerun with npx forge-memory pair-ios --manual-http --public-url <phone-reachable Forge URL>.",
1630
1910
  "Do not scan a QR whose API URL is 127.0.0.1 on a physical iPhone; that address only works in the iOS Simulator."
1631
1911
  ];
@@ -2004,6 +2284,25 @@ async function runInstall(parsed, command) {
2004
2284
  dryRun: parsed.flags.dryRun
2005
2285
  })
2006
2286
  );
2287
+ const shouldPair =
2288
+ parsed.flags.pairIos ||
2289
+ (!parsed.flags.skipPairIos &&
2290
+ (parsed.flags.yes
2291
+ ? true
2292
+ : await promptYesNo("Pair the iOS companion now?", true)));
2293
+ let irohTransportResult = null;
2294
+ if (
2295
+ shouldPair &&
2296
+ !parsed.flags.manualHttp &&
2297
+ !parsed.flags.dryRun
2298
+ ) {
2299
+ irohTransportResult = await withProgress(
2300
+ "Preparing Forge Companion Iroh transport",
2301
+ "checking Rust/Cargo and building the local host",
2302
+ parsed.flags,
2303
+ () => ensureIrohTransportPrepared(config, parsed.flags)
2304
+ );
2305
+ }
2007
2306
  let runtimeResult = null;
2008
2307
  if (!parsed.flags.noStart && !parsed.flags.dryRun) {
2009
2308
  runtimeResult = await withProgress(
@@ -2041,12 +2340,6 @@ async function runInstall(parsed, command) {
2041
2340
  }
2042
2341
  }
2043
2342
  }
2044
- const shouldPair =
2045
- parsed.flags.pairIos ||
2046
- (!parsed.flags.skipPairIos &&
2047
- (parsed.flags.yes
2048
- ? true
2049
- : await promptYesNo("Pair the iOS companion now?", true)));
2050
2343
  let pairing = null;
2051
2344
  if (shouldPair && !parsed.flags.dryRun) {
2052
2345
  if (!runtimeResult) {
@@ -2085,6 +2378,7 @@ async function runInstall(parsed, command) {
2085
2378
  adapterResults,
2086
2379
  runtimeResult,
2087
2380
  doctorResult,
2381
+ irohTransportResult,
2088
2382
  pairing: Boolean(pairing)
2089
2383
  };
2090
2384
  if (parsed.flags.json) console.log(JSON.stringify(summary, null, 2));
@@ -2295,6 +2589,14 @@ async function runPairIos(parsed) {
2295
2589
  transportMode,
2296
2590
  publicUrl: parsed.values.publicUrl
2297
2591
  });
2592
+ if (transportMode === "iroh") {
2593
+ await withProgress(
2594
+ "Preparing Forge Companion Iroh transport",
2595
+ "checking Rust/Cargo and building the local host",
2596
+ parsed.flags,
2597
+ () => ensureIrohTransportPrepared(config, parsed.flags)
2598
+ );
2599
+ }
2298
2600
  if (!parsed.flags.noStart) {
2299
2601
  const runtimeResult = await withProgress(
2300
2602
  "Starting Forge runtime for iOS pairing",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-memory",
3
- "version": "0.2.114",
3
+ "version": "0.2.116",
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",