forge-memory 0.2.117 → 0.3.0
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 +20 -17
- package/bin/forge-memory.mjs +471 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,32 +37,35 @@ the wiki tools (`forge_search_wiki`, `forge_get_wiki_page`, and maintenance
|
|
|
37
37
|
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
|
-
`pair-ios` prefers
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
to
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
`pair-ios` prefers Tailscale when it is installed, running, authenticated, and Forge
|
|
41
|
+
is reachable through the Mac's MagicDNS HTTPS URL. That gives the iPhone a normal
|
|
42
|
+
phone-reachable web URL for sync and the embedded Forge WebView. If Tailscale is
|
|
43
|
+
running but Forge is not served yet, the installer asks before configuring
|
|
44
|
+
`tailscale serve` for the local Forge runtime. If Tailscale is unavailable or
|
|
45
|
+
declined, Forge falls back to the Iroh QR: a Rust Iroh host with the desktop node id,
|
|
46
|
+
pairing token, optional relay hint, and ALPN `forge-companion/1`.
|
|
47
|
+
|
|
48
|
+
The CLI renders a short-schema QR to keep the terminal code scannable and saves the
|
|
49
|
+
full manual payload under `~/.forge/pairing/` so you can paste it into the iPhone app
|
|
50
|
+
if the camera cannot scan. Use `--public-url` when you intentionally want to force a
|
|
51
|
+
specific Tailscale, LAN, or fixed/private URL:
|
|
49
52
|
|
|
50
53
|
```bash
|
|
51
|
-
npx forge-memory pair-ios --
|
|
54
|
+
npx forge-memory pair-ios --public-url https://your-mac.tailnet.ts.net/forge/
|
|
52
55
|
```
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
`--manual-http` is still available as an explicit direct HTTP/TCP override. Loopback
|
|
58
|
+
URLs such as `127.0.0.1` and `localhost` are rejected for physical-phone pairing.
|
|
56
59
|
|
|
57
60
|
The base install stays one command on purpose. The detailed companion transport
|
|
58
61
|
reference lives in the Forge repo at `docs/companion-iroh.md` and in the published
|
|
59
62
|
docs at `https://albertbuchard.github.io/forge/companion-transport.html`. Forge
|
|
60
63
|
Memory ships Forge's Iroh host source and lockfile, not native desktop binaries. When
|
|
61
|
-
the
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
the Iroh fallback is selected, the installer checks for Cargo, offers to install the
|
|
65
|
+
minimal Rust toolchain when the platform supports it, builds the local host from the
|
|
66
|
+
bundled source, then creates the QR. If Cargo cannot be installed automatically,
|
|
67
|
+
`install`, `configure`, and `pair-ios` stop with platform-specific steps instead of
|
|
68
|
+
printing a localhost QR that a physical iPhone cannot use.
|
|
66
69
|
|
|
67
70
|
`configure` reruns the full guided flow using the current config as defaults.
|
|
68
71
|
Install and configure run Forge doctor before finishing. `doctor --repair` creates
|
package/bin/forge-memory.mjs
CHANGED
|
@@ -362,6 +362,219 @@ function rustInstallGuidance() {
|
|
|
362
362
|
];
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
function tailscaleInstallPlan() {
|
|
366
|
+
if (process.platform === "darwin") {
|
|
367
|
+
return {
|
|
368
|
+
installable: true,
|
|
369
|
+
autoInstallCommand: commandExists("brew")
|
|
370
|
+
? { command: "brew", args: ["install", "--cask", "tailscale"] }
|
|
371
|
+
: null,
|
|
372
|
+
guidance: [
|
|
373
|
+
"Install Tailscale for macOS from https://tailscale.com/download/mac or with Homebrew: brew install --cask tailscale",
|
|
374
|
+
"Open Tailscale, sign in, and make sure this Mac and the iPhone are in the same tailnet.",
|
|
375
|
+
"Then rerun: npx forge-memory pair-ios"
|
|
376
|
+
]
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (process.platform === "linux") {
|
|
380
|
+
return {
|
|
381
|
+
installable: true,
|
|
382
|
+
autoInstallCommand: commandExists("curl")
|
|
383
|
+
? {
|
|
384
|
+
command: "sh",
|
|
385
|
+
args: ["-c", "curl -fsSL https://tailscale.com/install.sh | sh"]
|
|
386
|
+
}
|
|
387
|
+
: null,
|
|
388
|
+
guidance: [
|
|
389
|
+
"Install Tailscale with your package manager or the official installer: curl -fsSL https://tailscale.com/install.sh | sh",
|
|
390
|
+
"Start and authenticate it: sudo tailscale up",
|
|
391
|
+
"Then rerun: npx forge-memory pair-ios"
|
|
392
|
+
]
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (process.platform === "win32") {
|
|
396
|
+
return {
|
|
397
|
+
installable: true,
|
|
398
|
+
autoInstallCommand: commandExists("winget")
|
|
399
|
+
? {
|
|
400
|
+
command: "winget",
|
|
401
|
+
args: [
|
|
402
|
+
"install",
|
|
403
|
+
"--id",
|
|
404
|
+
"Tailscale.Tailscale",
|
|
405
|
+
"-e",
|
|
406
|
+
"--source",
|
|
407
|
+
"winget",
|
|
408
|
+
"--accept-package-agreements",
|
|
409
|
+
"--accept-source-agreements"
|
|
410
|
+
]
|
|
411
|
+
}
|
|
412
|
+
: null,
|
|
413
|
+
guidance: [
|
|
414
|
+
"Install Tailscale for Windows from https://tailscale.com/download/windows or with winget: winget install Tailscale.Tailscale",
|
|
415
|
+
"Sign in, make sure this PC and the iPhone are in the same tailnet, then rerun: npx forge-memory pair-ios"
|
|
416
|
+
]
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
installable: false,
|
|
421
|
+
autoInstallCommand: null,
|
|
422
|
+
guidance: [
|
|
423
|
+
"Install Tailscale from https://tailscale.com/download if your platform supports it, then rerun: npx forge-memory pair-ios"
|
|
424
|
+
]
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function tailscaleAutodetectDisabled() {
|
|
429
|
+
return ["1", "true", "yes"].includes(
|
|
430
|
+
String(process.env.FORGE_MEMORY_SKIP_TAILSCALE_AUTODETECT ?? "").toLowerCase()
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function parseTailscaleStatus(raw) {
|
|
435
|
+
if (!raw) return { running: false, authenticated: false, dnsName: null };
|
|
436
|
+
try {
|
|
437
|
+
const payload = JSON.parse(raw);
|
|
438
|
+
const self = payload.Self ?? payload.self ?? null;
|
|
439
|
+
const dnsName = String(self?.DNSName ?? self?.dnsName ?? "")
|
|
440
|
+
.trim()
|
|
441
|
+
.replace(/\.$/, "");
|
|
442
|
+
const backendState = String(payload.BackendState ?? payload.backendState ?? "");
|
|
443
|
+
const running = backendState.toLowerCase() === "running";
|
|
444
|
+
return {
|
|
445
|
+
running,
|
|
446
|
+
authenticated: running && Boolean(dnsName),
|
|
447
|
+
dnsName: dnsName || null,
|
|
448
|
+
backendState: backendState || null
|
|
449
|
+
};
|
|
450
|
+
} catch {
|
|
451
|
+
return { running: false, authenticated: false, dnsName: null };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function normalizeForgePublicUiUrl(value) {
|
|
456
|
+
const normalized = normalizePublicPairingUrl(value);
|
|
457
|
+
if (!normalized) return null;
|
|
458
|
+
const url = new URL(normalized);
|
|
459
|
+
if (!url.pathname || url.pathname === "/" || url.pathname.startsWith("/api/")) {
|
|
460
|
+
url.pathname = "/forge/";
|
|
461
|
+
}
|
|
462
|
+
if (!url.pathname.endsWith("/")) url.pathname = `${url.pathname}/`;
|
|
463
|
+
url.search = "";
|
|
464
|
+
url.hash = "";
|
|
465
|
+
return url.toString();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function forgePublicHealthUrl(publicUiUrl) {
|
|
469
|
+
const url = new URL(publicUiUrl);
|
|
470
|
+
url.pathname = "/api/v1/health";
|
|
471
|
+
url.search = "";
|
|
472
|
+
url.hash = "";
|
|
473
|
+
return url;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function publicUrlFallbackMode(publicUrl) {
|
|
477
|
+
try {
|
|
478
|
+
const host = new URL(publicUrl).hostname.toLowerCase();
|
|
479
|
+
return host.endsWith(".ts.net") ? "tailscale" : "fixed-ip";
|
|
480
|
+
} catch {
|
|
481
|
+
return "fixed-ip";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function detectTailscaleState() {
|
|
486
|
+
if (tailscaleAutodetectDisabled()) {
|
|
487
|
+
return {
|
|
488
|
+
installed: false,
|
|
489
|
+
running: false,
|
|
490
|
+
authenticated: false,
|
|
491
|
+
publicUrl: null,
|
|
492
|
+
disabled: true
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (!commandExists("tailscale")) {
|
|
496
|
+
return {
|
|
497
|
+
installed: false,
|
|
498
|
+
running: false,
|
|
499
|
+
authenticated: false,
|
|
500
|
+
publicUrl: null,
|
|
501
|
+
installPlan: tailscaleInstallPlan()
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const status = parseTailscaleStatus(runCapture("tailscale", ["status", "--json"], 4_000));
|
|
505
|
+
const envPublicUrl = normalizeForgePublicUiUrl(process.env.FORGE_MEMORY_TAILSCALE_PUBLIC_URL);
|
|
506
|
+
const publicUrl =
|
|
507
|
+
envPublicUrl ??
|
|
508
|
+
(status.dnsName ? `https://${status.dnsName}/forge/` : null);
|
|
509
|
+
return {
|
|
510
|
+
installed: true,
|
|
511
|
+
running: status.running,
|
|
512
|
+
authenticated: status.authenticated,
|
|
513
|
+
publicUrl,
|
|
514
|
+
backendState: status.backendState,
|
|
515
|
+
dnsName: status.dnsName
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function probePublicForgeUrl(publicUrl, timeoutMs = 4_000) {
|
|
520
|
+
if (!publicUrl) return { ok: false, error: "no public URL candidate" };
|
|
521
|
+
const fakeProbeSequencePath =
|
|
522
|
+
process.env.FORGE_MEMORY_FAKE_TAILSCALE_PUBLIC_PROBE_SEQUENCE;
|
|
523
|
+
if (fakeProbeSequencePath) {
|
|
524
|
+
const source = fs.existsSync(fakeProbeSequencePath)
|
|
525
|
+
? fs.readFileSync(fakeProbeSequencePath, "utf8")
|
|
526
|
+
: "";
|
|
527
|
+
const entries = source
|
|
528
|
+
.split(/\r?\n/)
|
|
529
|
+
.map((entry) => entry.trim())
|
|
530
|
+
.filter(Boolean);
|
|
531
|
+
const current = entries.shift() ?? "fail";
|
|
532
|
+
fs.writeFileSync(fakeProbeSequencePath, entries.length ? `${entries.join("\n")}\n` : "");
|
|
533
|
+
return current === "ok"
|
|
534
|
+
? { ok: true, fake: true }
|
|
535
|
+
: { ok: false, error: current === "fail" ? "fake probe failure" : current };
|
|
536
|
+
}
|
|
537
|
+
if (
|
|
538
|
+
["1", "true", "yes"].includes(
|
|
539
|
+
String(process.env.FORGE_MEMORY_SKIP_TAILSCALE_PUBLIC_PROBE ?? "").toLowerCase()
|
|
540
|
+
)
|
|
541
|
+
) {
|
|
542
|
+
return { ok: true, skipped: true };
|
|
543
|
+
}
|
|
544
|
+
const controller = new AbortController();
|
|
545
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
546
|
+
try {
|
|
547
|
+
const response = await fetch(forgePublicHealthUrl(publicUrl), {
|
|
548
|
+
headers: { accept: "application/json", "x-forge-runtime-probe": "1" },
|
|
549
|
+
signal: controller.signal
|
|
550
|
+
});
|
|
551
|
+
if (!response.ok) return { ok: false, status: response.status };
|
|
552
|
+
const payload = await response.json().catch(() => null);
|
|
553
|
+
if (!isForgeHealthPayload(payload)) {
|
|
554
|
+
return { ok: false, status: response.status, error: "non-Forge health payload" };
|
|
555
|
+
}
|
|
556
|
+
return { ok: true, status: response.status };
|
|
557
|
+
} catch (error) {
|
|
558
|
+
return { ok: false, error: describeNetworkError(error) };
|
|
559
|
+
} finally {
|
|
560
|
+
clearTimeout(timeout);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function configureTailscaleServe(config, flags) {
|
|
565
|
+
const target = `http://127.0.0.1:${config.port || DEFAULT_PORT}`;
|
|
566
|
+
return runCommand("tailscale", ["serve", "--bg", target], {
|
|
567
|
+
dryRun: flags?.dryRun
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function tailscalePreferredMessage() {
|
|
572
|
+
return [
|
|
573
|
+
"Tailscale is preferred when available: it gives the iPhone a normal HTTPS Forge URL, is usually faster and steadier than relayed Iroh, and avoids the custom WebView URL scheme.",
|
|
574
|
+
"Iroh remains the fallback when Tailscale is missing, declined, or unreachable."
|
|
575
|
+
].join(" ");
|
|
576
|
+
}
|
|
577
|
+
|
|
365
578
|
function refreshCargoPath() {
|
|
366
579
|
const cargoBin = path.join(homeDir(), ".cargo", "bin");
|
|
367
580
|
if (fs.existsSync(cargoBin)) {
|
|
@@ -1969,7 +2182,12 @@ async function createPairing(config, options = {}) {
|
|
|
1969
2182
|
cookie: operatorCookie,
|
|
1970
2183
|
...(publicUrl ? { referer: publicUrl } : {})
|
|
1971
2184
|
},
|
|
1972
|
-
body: JSON.stringify({
|
|
2185
|
+
body: JSON.stringify({
|
|
2186
|
+
userId: null,
|
|
2187
|
+
transportMode,
|
|
2188
|
+
fallbackMode: publicUrl ? publicUrlFallbackMode(publicUrl) : "none",
|
|
2189
|
+
publicUrl: publicUrl ?? undefined
|
|
2190
|
+
})
|
|
1973
2191
|
});
|
|
1974
2192
|
} catch (error) {
|
|
1975
2193
|
const healthResult = await health(config, 1_500);
|
|
@@ -2007,6 +2225,188 @@ async function createPairing(config, options = {}) {
|
|
|
2007
2225
|
return pairing;
|
|
2008
2226
|
}
|
|
2009
2227
|
|
|
2228
|
+
async function maybeInstallTailscaleForPairing(parsed) {
|
|
2229
|
+
const plan = tailscaleInstallPlan();
|
|
2230
|
+
if (parsed.flags.json || parsed.flags.dryRun || !process.stdin.isTTY) {
|
|
2231
|
+
return { installed: false, guidance: plan.guidance };
|
|
2232
|
+
}
|
|
2233
|
+
console.log(color.bold("Tailscale pairing"));
|
|
2234
|
+
console.log(tailscalePreferredMessage());
|
|
2235
|
+
console.log(color.dim("Tailscale is not installed or not visible on PATH."));
|
|
2236
|
+
for (const item of plan.guidance) console.log(color.dim(`- ${item}`));
|
|
2237
|
+
if (!plan.installable || !plan.autoInstallCommand) {
|
|
2238
|
+
return { installed: false, guidance: plan.guidance };
|
|
2239
|
+
}
|
|
2240
|
+
const shouldInstall = await promptYesNo(
|
|
2241
|
+
"Install Tailscale now before falling back to Iroh?",
|
|
2242
|
+
true
|
|
2243
|
+
);
|
|
2244
|
+
if (!shouldInstall) return { installed: false, guidance: plan.guidance };
|
|
2245
|
+
const result = await runCommand(plan.autoInstallCommand.command, plan.autoInstallCommand.args);
|
|
2246
|
+
return { installed: result.ok, guidance: plan.guidance, result };
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
async function resolveTailscalePairingOptions(parsed, config) {
|
|
2250
|
+
const state = detectTailscaleState();
|
|
2251
|
+
if (state.disabled) return null;
|
|
2252
|
+
if (!state.installed) {
|
|
2253
|
+
const installAttempt = await maybeInstallTailscaleForPairing(parsed);
|
|
2254
|
+
if (installAttempt.installed) {
|
|
2255
|
+
const installedState = detectTailscaleState();
|
|
2256
|
+
if (installedState.installed && installedState.running && installedState.authenticated) {
|
|
2257
|
+
return resolveTailscalePairingOptions(parsed, config);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return null;
|
|
2261
|
+
}
|
|
2262
|
+
if (!state.running || !state.authenticated || !state.publicUrl) {
|
|
2263
|
+
if (!parsed.flags.json && process.stdin.isTTY) {
|
|
2264
|
+
console.log(color.bold("Tailscale pairing"));
|
|
2265
|
+
console.log(tailscalePreferredMessage());
|
|
2266
|
+
console.log(
|
|
2267
|
+
color.yellow(
|
|
2268
|
+
"Tailscale is installed, but it is not running/authenticated or does not expose a MagicDNS name."
|
|
2269
|
+
)
|
|
2270
|
+
);
|
|
2271
|
+
console.log(color.dim("Open Tailscale, sign in, then rerun npx forge-memory pair-ios. Falling back to Iroh for this pairing."));
|
|
2272
|
+
}
|
|
2273
|
+
return null;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const firstProbe = await probePublicForgeUrl(state.publicUrl);
|
|
2277
|
+
if (firstProbe.ok) {
|
|
2278
|
+
if (!parsed.flags.json) {
|
|
2279
|
+
console.log(color.green(`Using Tailscale for iOS pairing: ${state.publicUrl}`));
|
|
2280
|
+
}
|
|
2281
|
+
return {
|
|
2282
|
+
transportMode: "manual-http",
|
|
2283
|
+
publicUrl: validatePairingOptions({
|
|
2284
|
+
transportMode: "manual-http",
|
|
2285
|
+
publicUrl: state.publicUrl
|
|
2286
|
+
}),
|
|
2287
|
+
source: "tailscale"
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
const canConfigureServe =
|
|
2292
|
+
!parsed.flags.json &&
|
|
2293
|
+
!parsed.flags.dryRun &&
|
|
2294
|
+
(parsed.flags.yes ||
|
|
2295
|
+
(process.stdin.isTTY &&
|
|
2296
|
+
(await promptYesNo(
|
|
2297
|
+
[
|
|
2298
|
+
`Tailscale is running, but Forge is not reachable at ${state.publicUrl}.`,
|
|
2299
|
+
"Serve Forge through Tailscale now?"
|
|
2300
|
+
].join(" "),
|
|
2301
|
+
true
|
|
2302
|
+
))));
|
|
2303
|
+
if (!canConfigureServe) {
|
|
2304
|
+
if (!parsed.flags.json && process.stdin.isTTY) {
|
|
2305
|
+
console.log(
|
|
2306
|
+
color.yellow(
|
|
2307
|
+
`Tailscale is running, but Forge was not reachable at ${state.publicUrl}: ${firstProbe.error ?? firstProbe.status ?? "unknown error"}. Falling back to Iroh.`
|
|
2308
|
+
)
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
return null;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
const serveResult = await configureTailscaleServe(config, parsed.flags);
|
|
2315
|
+
if (!serveResult.ok) {
|
|
2316
|
+
if (!parsed.flags.json) {
|
|
2317
|
+
console.log(
|
|
2318
|
+
color.yellow(
|
|
2319
|
+
"Could not configure Tailscale Serve automatically. Falling back to Iroh."
|
|
2320
|
+
)
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
2325
|
+
const secondProbe = await probePublicForgeUrl(state.publicUrl, 8_000);
|
|
2326
|
+
if (!secondProbe.ok) {
|
|
2327
|
+
if (!parsed.flags.json) {
|
|
2328
|
+
console.log(
|
|
2329
|
+
color.yellow(
|
|
2330
|
+
`Tailscale Serve was configured, but Forge still was not reachable at ${state.publicUrl}: ${secondProbe.error ?? secondProbe.status ?? "unknown error"}. Falling back to Iroh.`
|
|
2331
|
+
)
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
if (!parsed.flags.json) {
|
|
2337
|
+
console.log(color.green(`Using Tailscale for iOS pairing: ${state.publicUrl}`));
|
|
2338
|
+
}
|
|
2339
|
+
return {
|
|
2340
|
+
transportMode: "manual-http",
|
|
2341
|
+
publicUrl: validatePairingOptions({
|
|
2342
|
+
transportMode: "manual-http",
|
|
2343
|
+
publicUrl: state.publicUrl
|
|
2344
|
+
}),
|
|
2345
|
+
source: "tailscale"
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
async function resolveIosPairingOptions(parsed, config = null) {
|
|
2350
|
+
if (parsed.flags.manualHttp) {
|
|
2351
|
+
return {
|
|
2352
|
+
transportMode: "manual-http",
|
|
2353
|
+
publicUrl: validatePairingOptions({
|
|
2354
|
+
transportMode: "manual-http",
|
|
2355
|
+
publicUrl: parsed.values.publicUrl
|
|
2356
|
+
})
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
if (parsed.values.publicUrl) {
|
|
2360
|
+
return {
|
|
2361
|
+
transportMode: "manual-http",
|
|
2362
|
+
publicUrl: validatePairingOptions({
|
|
2363
|
+
transportMode: "manual-http",
|
|
2364
|
+
publicUrl: parsed.values.publicUrl
|
|
2365
|
+
}),
|
|
2366
|
+
source: publicUrlFallbackMode(parsed.values.publicUrl)
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
if (config) {
|
|
2370
|
+
const tailscalePairing = await resolveTailscalePairingOptions(parsed, config);
|
|
2371
|
+
if (tailscalePairing) return tailscalePairing;
|
|
2372
|
+
}
|
|
2373
|
+
if (parsed.flags.yes || parsed.flags.json || !process.stdin.isTTY) {
|
|
2374
|
+
return {
|
|
2375
|
+
transportMode: "iroh",
|
|
2376
|
+
publicUrl: validatePairingOptions({
|
|
2377
|
+
transportMode: "iroh",
|
|
2378
|
+
publicUrl: parsed.values.publicUrl
|
|
2379
|
+
})
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
console.log(color.bold("iOS companion connection"));
|
|
2383
|
+
console.log(color.dim(tailscalePreferredMessage()));
|
|
2384
|
+
console.log(color.dim("Choose Iroh or a fixed/private IP fallback for this pairing."));
|
|
2385
|
+
const choice = (
|
|
2386
|
+
await promptLine("Connection [iroh/ip]", "iroh")
|
|
2387
|
+
).toLowerCase();
|
|
2388
|
+
if (choice === "ip" || choice === "fixed" || choice === "private") {
|
|
2389
|
+
const publicUrl = await promptLine(
|
|
2390
|
+
"Private/fixed Forge URL",
|
|
2391
|
+
parsed.values.publicUrl ?? "http://192.168.1.98:4317/forge/"
|
|
2392
|
+
);
|
|
2393
|
+
return {
|
|
2394
|
+
transportMode: "manual-http",
|
|
2395
|
+
publicUrl: validatePairingOptions({
|
|
2396
|
+
transportMode: "manual-http",
|
|
2397
|
+
publicUrl
|
|
2398
|
+
})
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
return {
|
|
2402
|
+
transportMode: "iroh",
|
|
2403
|
+
publicUrl: validatePairingOptions({
|
|
2404
|
+
transportMode: "iroh",
|
|
2405
|
+
publicUrl: parsed.values.publicUrl
|
|
2406
|
+
})
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2010
2410
|
function assertPairingTransportUsable(pairing, { requestedTransportMode }) {
|
|
2011
2411
|
const payload = pairing?.qrPayload;
|
|
2012
2412
|
if (!payload || requestedTransportMode !== "iroh") {
|
|
@@ -2015,6 +2415,23 @@ function assertPairingTransportUsable(pairing, { requestedTransportMode }) {
|
|
|
2015
2415
|
const resolvedTransportMode = payload.transportMode ?? payload.transport?.protocol;
|
|
2016
2416
|
const resolvedProtocol = payload.transport?.protocol;
|
|
2017
2417
|
if (resolvedTransportMode === "iroh" || resolvedProtocol === "iroh") {
|
|
2418
|
+
const phoneFacingUrls = [
|
|
2419
|
+
payload.apiBaseUrl,
|
|
2420
|
+
payload.uiBaseUrl,
|
|
2421
|
+
payload.transport?.publicBaseUrl
|
|
2422
|
+
].filter(Boolean);
|
|
2423
|
+
const loopbackUrl = phoneFacingUrls.find((url) => isLoopbackPairingUrl(url));
|
|
2424
|
+
if (loopbackUrl) {
|
|
2425
|
+
throw new PairingTransportUnavailableError(
|
|
2426
|
+
[
|
|
2427
|
+
"Forge created an Iroh pairing that exposes a loopback URL as phone-facing pairing data.",
|
|
2428
|
+
`Bad URL: ${loopbackUrl}.`,
|
|
2429
|
+
"A physical iPhone cannot reach localhost on this Mac.",
|
|
2430
|
+
"Use Iroh logical URLs, a selected Tailscale URL, or a selected private/fixed IP URL."
|
|
2431
|
+
].join(" "),
|
|
2432
|
+
{ apiBaseUrl: payload.apiBaseUrl, transportMode: resolvedTransportMode, protocol: resolvedProtocol }
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2018
2435
|
return;
|
|
2019
2436
|
}
|
|
2020
2437
|
const apiBaseUrl = payload.apiBaseUrl ?? "";
|
|
@@ -2039,6 +2456,15 @@ function assertPairingTransportUsable(pairing, { requestedTransportMode }) {
|
|
|
2039
2456
|
|
|
2040
2457
|
function validatePairingOptions({ transportMode, publicUrl }) {
|
|
2041
2458
|
const normalizedPublicUrl = normalizePublicPairingUrl(publicUrl);
|
|
2459
|
+
if (normalizedPublicUrl && isLoopbackPairingUrl(normalizedPublicUrl)) {
|
|
2460
|
+
throw new Error(
|
|
2461
|
+
[
|
|
2462
|
+
`--public-url points at ${normalizedPublicUrl}, which is loopback-only.`,
|
|
2463
|
+
"A physical iPhone cannot reach localhost on this Mac.",
|
|
2464
|
+
"Use Iroh, Tailscale, or a private/fixed IP URL."
|
|
2465
|
+
].join(" ")
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2042
2468
|
if (transportMode !== "manual-http") {
|
|
2043
2469
|
return normalizedPublicUrl;
|
|
2044
2470
|
}
|
|
@@ -2052,15 +2478,6 @@ function validatePairingOptions({ transportMode, publicUrl }) {
|
|
|
2052
2478
|
].join(" ")
|
|
2053
2479
|
);
|
|
2054
2480
|
}
|
|
2055
|
-
if (isLoopbackPairingUrl(normalizedPublicUrl)) {
|
|
2056
|
-
throw new Error(
|
|
2057
|
-
[
|
|
2058
|
-
`Manual HTTP --public-url points at ${normalizedPublicUrl}, which is loopback-only.`,
|
|
2059
|
-
"A physical iPhone cannot reach localhost on this Mac.",
|
|
2060
|
-
"Use a Tailscale or LAN URL, or omit --manual-http and use Iroh pairing."
|
|
2061
|
-
].join(" ")
|
|
2062
|
-
);
|
|
2063
|
-
}
|
|
2064
2481
|
return normalizedPublicUrl;
|
|
2065
2482
|
}
|
|
2066
2483
|
|
|
@@ -2290,19 +2707,6 @@ async function runInstall(parsed, command) {
|
|
|
2290
2707
|
(parsed.flags.yes
|
|
2291
2708
|
? true
|
|
2292
2709
|
: 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
|
-
}
|
|
2306
2710
|
let runtimeResult = null;
|
|
2307
2711
|
if (!parsed.flags.noStart && !parsed.flags.dryRun) {
|
|
2308
2712
|
runtimeResult = await withProgress(
|
|
@@ -2340,6 +2744,22 @@ async function runInstall(parsed, command) {
|
|
|
2340
2744
|
}
|
|
2341
2745
|
}
|
|
2342
2746
|
}
|
|
2747
|
+
const pairingOptions = shouldPair
|
|
2748
|
+
? await resolveIosPairingOptions(parsed, config)
|
|
2749
|
+
: null;
|
|
2750
|
+
let irohTransportResult = null;
|
|
2751
|
+
if (
|
|
2752
|
+
shouldPair &&
|
|
2753
|
+
pairingOptions?.transportMode !== "manual-http" &&
|
|
2754
|
+
!parsed.flags.dryRun
|
|
2755
|
+
) {
|
|
2756
|
+
irohTransportResult = await withProgress(
|
|
2757
|
+
"Preparing Forge Companion Iroh transport",
|
|
2758
|
+
"checking Rust/Cargo and building the local host",
|
|
2759
|
+
parsed.flags,
|
|
2760
|
+
() => ensureIrohTransportPrepared(config, parsed.flags)
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2343
2763
|
let pairing = null;
|
|
2344
2764
|
if (shouldPair && !parsed.flags.dryRun) {
|
|
2345
2765
|
if (!runtimeResult) {
|
|
@@ -2353,12 +2773,12 @@ async function runInstall(parsed, command) {
|
|
|
2353
2773
|
assertRuntimeStartedForPairing(runtimeResult, config);
|
|
2354
2774
|
pairing = await withProgress(
|
|
2355
2775
|
"Creating iOS companion pairing",
|
|
2356
|
-
|
|
2776
|
+
pairingOptions.transportMode === "manual-http" ? "phone-reachable HTTP" : "Iroh QR",
|
|
2357
2777
|
parsed.flags,
|
|
2358
2778
|
() =>
|
|
2359
2779
|
createPairing(config, {
|
|
2360
|
-
transportMode:
|
|
2361
|
-
publicUrl:
|
|
2780
|
+
transportMode: pairingOptions.transportMode,
|
|
2781
|
+
publicUrl: pairingOptions.publicUrl
|
|
2362
2782
|
})
|
|
2363
2783
|
);
|
|
2364
2784
|
if (pairing?.qrPayload && !parsed.flags.json) {
|
|
@@ -2584,12 +3004,15 @@ async function runUi(parsed) {
|
|
|
2584
3004
|
|
|
2585
3005
|
async function runPairIos(parsed) {
|
|
2586
3006
|
const config = await readConfig();
|
|
2587
|
-
const
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
3007
|
+
const explicitPairingOptions =
|
|
3008
|
+
parsed.flags.manualHttp || parsed.values.publicUrl
|
|
3009
|
+
? await resolveIosPairingOptions(parsed, config)
|
|
3010
|
+
: null;
|
|
3011
|
+
const noStartPairingOptions =
|
|
3012
|
+
!explicitPairingOptions && parsed.flags.noStart
|
|
3013
|
+
? await resolveIosPairingOptions(parsed, config)
|
|
3014
|
+
: null;
|
|
3015
|
+
if (noStartPairingOptions?.transportMode === "iroh") {
|
|
2593
3016
|
await withProgress(
|
|
2594
3017
|
"Preparing Forge Companion Iroh transport",
|
|
2595
3018
|
"checking Rust/Cargo and building the local host",
|
|
@@ -2612,6 +3035,20 @@ async function runPairIos(parsed) {
|
|
|
2612
3035
|
config
|
|
2613
3036
|
);
|
|
2614
3037
|
}
|
|
3038
|
+
const pairingOptions =
|
|
3039
|
+
explicitPairingOptions ??
|
|
3040
|
+
noStartPairingOptions ??
|
|
3041
|
+
await resolveIosPairingOptions(parsed, config);
|
|
3042
|
+
const transportMode = pairingOptions.transportMode;
|
|
3043
|
+
const publicUrl = pairingOptions.publicUrl;
|
|
3044
|
+
if (transportMode === "iroh" && noStartPairingOptions?.transportMode !== "iroh") {
|
|
3045
|
+
await withProgress(
|
|
3046
|
+
"Preparing Forge Companion Iroh transport",
|
|
3047
|
+
"checking Rust/Cargo and building the local host",
|
|
3048
|
+
parsed.flags,
|
|
3049
|
+
() => ensureIrohTransportPrepared(config, parsed.flags)
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
2615
3052
|
const pairing = await createPairing(config, {
|
|
2616
3053
|
transportMode,
|
|
2617
3054
|
publicUrl
|
|
@@ -2951,8 +3388,8 @@ Options:
|
|
|
2951
3388
|
--adapters <list> Comma list: openclaw,hermes,codex or none
|
|
2952
3389
|
--skip-adapters Configure UI/runtime only
|
|
2953
3390
|
--skip-pair-ios Do not prompt or create iOS pairing
|
|
2954
|
-
--manual-http
|
|
2955
|
-
--public-url <url> Phone-
|
|
3391
|
+
--manual-http Force direct HTTP/TCP for iOS pairing
|
|
3392
|
+
--public-url <url> Phone-facing Tailscale/LAN/fixed URL for direct pairing; never localhost
|
|
2956
3393
|
--no-start Configure without starting runtime
|
|
2957
3394
|
--no-doctor Skip install-time doctor checks
|
|
2958
3395
|
--repair Let doctor create missing folders and restart unhealthy runtime
|
package/package.json
CHANGED