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 +16 -3
- package/bin/forge-memory.mjs +668 -102
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Useful commands:
|
|
|
22
22
|
npx forge-memory configure
|
|
23
23
|
npx forge-memory status
|
|
24
24
|
npx forge-memory doctor
|
|
25
|
+
npx forge-memory doctor --repair
|
|
25
26
|
npx forge-memory ui
|
|
26
27
|
npx forge-memory restart
|
|
27
28
|
npx forge-memory stop
|
|
@@ -38,15 +39,27 @@ show up as a tool result instead of a closed MCP transport.
|
|
|
38
39
|
|
|
39
40
|
`pair-ios` prefers the Iroh QR. Forge starts a Rust Iroh host, prints a QR payload
|
|
40
41
|
with the desktop node id, pairing token, optional relay hint, and ALPN
|
|
41
|
-
`forge-companion/1`, and the iPhone app connects through its native Rust bridge.
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
`forge-companion/1`, and the iPhone app connects through its native Rust bridge. The
|
|
43
|
+
CLI renders a compact QR and saves the same compact payload under
|
|
44
|
+
`~/.forge/pairing/` so you can paste it into the iPhone app if the camera cannot scan.
|
|
45
|
+
Use `--manual-http` only when you intentionally want a LAN, Tailscale, or direct
|
|
46
|
+
HTTP/TCP route. For a real iPhone, pass a phone-reachable URL:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx forge-memory pair-ios --manual-http --public-url https://your-mac.tailnet.ts.net/forge/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Without `--public-url`, manual HTTP may resolve to `127.0.0.1`, which is useful for
|
|
53
|
+
the iOS Simulator but not for a physical phone.
|
|
44
54
|
|
|
45
55
|
The base install stays one command on purpose. The detailed companion transport
|
|
46
56
|
reference lives in the Forge repo at `docs/companion-iroh.md` and in the published
|
|
47
57
|
docs at `https://albertbuchard.github.io/forge/companion-transport.html`.
|
|
48
58
|
|
|
49
59
|
`configure` reruns the full guided flow using the current config as defaults.
|
|
60
|
+
Install and configure run Forge doctor before finishing. `doctor --repair` creates
|
|
61
|
+
missing local folders, starts or restarts the runtime when allowed, and prints concrete
|
|
62
|
+
next steps without deleting Forge data.
|
|
50
63
|
`export` writes a portable backup of the real Forge data folder. `uninstall` removes the Forge Memory runtime manager and cache while keeping the data folder by default; pass `--remove-data` only when you intentionally want the data deleted too.
|
|
51
64
|
|
|
52
65
|
Typical first run:
|
package/bin/forge-memory.mjs
CHANGED
|
@@ -54,7 +54,9 @@ function parseArgs(argv) {
|
|
|
54
54
|
printUrl: false,
|
|
55
55
|
removeData: false,
|
|
56
56
|
removeAdapters: false,
|
|
57
|
-
manualHttp: false
|
|
57
|
+
manualHttp: false,
|
|
58
|
+
repair: false,
|
|
59
|
+
noDoctor: false
|
|
58
60
|
};
|
|
59
61
|
const values = {};
|
|
60
62
|
const positionals = [];
|
|
@@ -79,6 +81,8 @@ function parseArgs(argv) {
|
|
|
79
81
|
else if (arg === "--remove-adapters") flags.removeAdapters = true;
|
|
80
82
|
else if (arg === "--manual-http" || arg === "--no-iroh")
|
|
81
83
|
flags.manualHttp = true;
|
|
84
|
+
else if (arg === "--repair") flags.repair = true;
|
|
85
|
+
else if (arg === "--no-doctor") flags.noDoctor = true;
|
|
82
86
|
else if (arg.startsWith("--output="))
|
|
83
87
|
values.output = arg.slice("--output=".length);
|
|
84
88
|
else if (arg === "--output") values.output = argv[++index];
|
|
@@ -100,6 +104,10 @@ function parseArgs(argv) {
|
|
|
100
104
|
else if (arg.startsWith("--repo="))
|
|
101
105
|
values.repo = arg.slice("--repo=".length);
|
|
102
106
|
else if (arg === "--repo") values.repo = argv[++index];
|
|
107
|
+
else if (arg.startsWith("--public-url="))
|
|
108
|
+
values.publicUrl = arg.slice("--public-url=".length);
|
|
109
|
+
else if (arg === "--public-url" || arg === "--phone-url")
|
|
110
|
+
values.publicUrl = argv[++index];
|
|
103
111
|
else if (arg === "--help" || arg === "-h") flags.help = true;
|
|
104
112
|
else if (arg === "--version" || arg === "-v") flags.version = true;
|
|
105
113
|
else throw new Error(`Unknown option: ${arg}`);
|
|
@@ -239,17 +247,19 @@ async function writeConfig(next, options) {
|
|
|
239
247
|
}
|
|
240
248
|
|
|
241
249
|
function commandExists(command) {
|
|
242
|
-
const result =
|
|
243
|
-
process.platform === "win32"
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
);
|
|
250
|
+
const result =
|
|
251
|
+
process.platform === "win32"
|
|
252
|
+
? spawnSync("where", [command], { stdio: "ignore" })
|
|
253
|
+
: spawnSync("sh", ["-c", `command -v ${shellQuote(command)}`], {
|
|
254
|
+
stdio: "ignore"
|
|
255
|
+
});
|
|
250
256
|
return result.status === 0;
|
|
251
257
|
}
|
|
252
258
|
|
|
259
|
+
function shellQuote(value) {
|
|
260
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
261
|
+
}
|
|
262
|
+
|
|
253
263
|
function runCapture(command, args, timeoutMs = 2_000) {
|
|
254
264
|
const result = spawnSync(command, args, {
|
|
255
265
|
encoding: "utf8",
|
|
@@ -336,6 +346,71 @@ function printBanner() {
|
|
|
336
346
|
console.log("");
|
|
337
347
|
}
|
|
338
348
|
|
|
349
|
+
function progressEnabled(options = {}) {
|
|
350
|
+
return !options.json;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatElapsed(startedAt) {
|
|
354
|
+
const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
|
355
|
+
if (seconds < 60) return `${seconds}s`;
|
|
356
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function clearLine() {
|
|
360
|
+
process.stdout.write("\r\u001b[2K");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function withProgress(title, detail, options, task) {
|
|
364
|
+
if (!progressEnabled(options)) return task();
|
|
365
|
+
const startedAt = Date.now();
|
|
366
|
+
const interactive = process.stdout.isTTY;
|
|
367
|
+
const frames = ["-", "\\", "|", "/"];
|
|
368
|
+
let frameIndex = 0;
|
|
369
|
+
let timer = null;
|
|
370
|
+
const suffix = detail ? color.dim(` ${detail}`) : "";
|
|
371
|
+
|
|
372
|
+
if (interactive) {
|
|
373
|
+
process.stdout.write("\u001b[?25l");
|
|
374
|
+
timer = setInterval(() => {
|
|
375
|
+
clearLine();
|
|
376
|
+
process.stdout.write(
|
|
377
|
+
`${color.cyan(frames[frameIndex % frames.length])} ${title}${suffix} ${color.dim(formatElapsed(startedAt))}`
|
|
378
|
+
);
|
|
379
|
+
frameIndex += 1;
|
|
380
|
+
}, 120);
|
|
381
|
+
} else {
|
|
382
|
+
console.log(`${color.cyan("...")} ${title}${suffix}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const result = await task();
|
|
387
|
+
if (timer) clearInterval(timer);
|
|
388
|
+
if (interactive) {
|
|
389
|
+
clearLine();
|
|
390
|
+
process.stdout.write("\u001b[?25h");
|
|
391
|
+
}
|
|
392
|
+
console.log(
|
|
393
|
+
`${color.green("ok")} ${title} ${color.dim(formatElapsed(startedAt))}`
|
|
394
|
+
);
|
|
395
|
+
return result;
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (timer) clearInterval(timer);
|
|
398
|
+
if (interactive) {
|
|
399
|
+
clearLine();
|
|
400
|
+
process.stdout.write("\u001b[?25h");
|
|
401
|
+
}
|
|
402
|
+
console.log(
|
|
403
|
+
`${color.red("fail")} ${title} ${color.dim(formatElapsed(startedAt))}`
|
|
404
|
+
);
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function printStep(title, detail, options = {}) {
|
|
410
|
+
if (!progressEnabled(options)) return;
|
|
411
|
+
console.log(`${color.cyan("->")} ${title}${detail ? color.dim(` ${detail}`) : ""}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
339
414
|
async function promptLine(question, defaultValue) {
|
|
340
415
|
const suffix = defaultValue ? ` ${color.dim(`[${defaultValue}]`)}` : "";
|
|
341
416
|
const rl = readline.createInterface({
|
|
@@ -693,6 +768,33 @@ async function runCommand(
|
|
|
693
768
|
});
|
|
694
769
|
}
|
|
695
770
|
|
|
771
|
+
async function runLoggedCommand(
|
|
772
|
+
command,
|
|
773
|
+
args,
|
|
774
|
+
{ cwd, dryRun = false, env = process.env, logFile = logPath() } = {}
|
|
775
|
+
) {
|
|
776
|
+
if (dryRun) {
|
|
777
|
+
return { ok: true, dryRun: true, command, args, cwd, logFile };
|
|
778
|
+
}
|
|
779
|
+
await fsp.mkdir(path.dirname(logFile), { recursive: true });
|
|
780
|
+
return await new Promise((resolve) => {
|
|
781
|
+
const out = fs.openSync(logFile, "a");
|
|
782
|
+
const child = spawn(command, args, {
|
|
783
|
+
cwd,
|
|
784
|
+
env,
|
|
785
|
+
stdio: ["ignore", out, out]
|
|
786
|
+
});
|
|
787
|
+
child.once("error", (error) => {
|
|
788
|
+
fs.closeSync(out);
|
|
789
|
+
resolve({ ok: false, error, logFile });
|
|
790
|
+
});
|
|
791
|
+
child.once("exit", (code) => {
|
|
792
|
+
fs.closeSync(out);
|
|
793
|
+
resolve({ ok: code === 0, code, logFile });
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
696
798
|
async function installOpenClawAdapter(config, options) {
|
|
697
799
|
await patchOpenClawConfig(config, options);
|
|
698
800
|
if (!commandExists("openclaw")) {
|
|
@@ -802,13 +904,30 @@ async function health(config, timeoutMs = 1_500) {
|
|
|
802
904
|
} catch (error) {
|
|
803
905
|
return {
|
|
804
906
|
ok: false,
|
|
805
|
-
error:
|
|
907
|
+
error: describeNetworkError(error)
|
|
806
908
|
};
|
|
807
909
|
} finally {
|
|
808
910
|
clearTimeout(timeout);
|
|
809
911
|
}
|
|
810
912
|
}
|
|
811
913
|
|
|
914
|
+
function describeNetworkError(error) {
|
|
915
|
+
if (error instanceof Error) {
|
|
916
|
+
if (error.name === "AbortError") return "request timed out";
|
|
917
|
+
if (error.message === "fetch failed")
|
|
918
|
+
return "connection failed before Forge responded";
|
|
919
|
+
return error.message;
|
|
920
|
+
}
|
|
921
|
+
return String(error);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function describeHealthResult(result) {
|
|
925
|
+
if (result.ok) return "healthy";
|
|
926
|
+
if (result.status) return `HTTP ${result.status}`;
|
|
927
|
+
if (result.error) return result.error;
|
|
928
|
+
return "not reachable";
|
|
929
|
+
}
|
|
930
|
+
|
|
812
931
|
async function readRuntimeState() {
|
|
813
932
|
return readJson(runtimeStatePath(), null);
|
|
814
933
|
}
|
|
@@ -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 = [
|
|
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 =
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
"--
|
|
881
|
-
|
|
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
|
|
1228
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1245
|
-
|
|
1246
|
-
const
|
|
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
|
-
|
|
1578
|
+
payload.transport?.protocol === "iroh"
|
|
1250
1579
|
? "Iroh"
|
|
1251
|
-
:
|
|
1580
|
+
: payload.transportMode === "iroh"
|
|
1252
1581
|
? "Iroh"
|
|
1253
1582
|
: "Manual HTTP";
|
|
1254
|
-
console.log(`${color.cyan(label)}: ${
|
|
1255
|
-
if (
|
|
1256
|
-
console.log(
|
|
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
|
|
1591
|
+
for (const note of pairing.qrPayload?.transport?.notes ?? []) {
|
|
1259
1592
|
console.log(color.dim(note));
|
|
1260
1593
|
}
|
|
1261
1594
|
}
|
|
1262
|
-
|
|
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
|
-
"
|
|
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
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
|
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)
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
|
1359
|
-
|
|
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
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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:
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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