forge-memory 0.2.108 → 0.2.110
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/bin/forge-memory.mjs +435 -59
- package/package.json +1 -1
package/bin/forge-memory.mjs
CHANGED
|
@@ -179,6 +179,14 @@ function webUrl(config) {
|
|
|
179
179
|
return `${baseUrl(config)}/forge/`;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
function localHostHeader(config) {
|
|
183
|
+
return `127.0.0.1:${config.port || DEFAULT_PORT}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function forgeApiUrl(config, pathname) {
|
|
187
|
+
return new URL(pathname, baseUrl(config));
|
|
188
|
+
}
|
|
189
|
+
|
|
182
190
|
async function readJson(filePath, fallback = null) {
|
|
183
191
|
try {
|
|
184
192
|
return JSON.parse(await fsp.readFile(filePath, "utf8"));
|
|
@@ -734,13 +742,8 @@ async function patchCodexConfig(config, options) {
|
|
|
734
742
|
`FORGE_DATA_ROOT = "${config.dataRoot.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`,
|
|
735
743
|
""
|
|
736
744
|
].join("\n");
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
if (pattern.test(source)) {
|
|
740
|
-
source = source.replace(pattern, `\n${block}`.trimEnd());
|
|
741
|
-
} else {
|
|
742
|
-
source = `${source.trimEnd()}\n\n${block}`.trimStart();
|
|
743
|
-
}
|
|
745
|
+
const cleaned = stripCodexForgeMcpConfig(source).trimEnd();
|
|
746
|
+
source = `${cleaned ? `${cleaned}\n\n` : ""}${block}`;
|
|
744
747
|
if (!options.dryRun) {
|
|
745
748
|
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
746
749
|
await backupIfExists(filePath);
|
|
@@ -753,6 +756,44 @@ async function patchCodexConfig(config, options) {
|
|
|
753
756
|
return { filePath };
|
|
754
757
|
}
|
|
755
758
|
|
|
759
|
+
function stripCodexForgeMcpConfig(source) {
|
|
760
|
+
const orphanForgeLines = new Set([
|
|
761
|
+
'command = "npx"',
|
|
762
|
+
'args = ["forge-memory", "mcp"]',
|
|
763
|
+
"FORGE_ORIGIN",
|
|
764
|
+
"FORGE_PORT",
|
|
765
|
+
"FORGE_ACTOR_LABEL",
|
|
766
|
+
"FORGE_TIMEOUT_MS",
|
|
767
|
+
"FORGE_DATA_ROOT"
|
|
768
|
+
]);
|
|
769
|
+
const lines = source.split(/\r?\n/);
|
|
770
|
+
const kept = [];
|
|
771
|
+
let skippingForgeTable = false;
|
|
772
|
+
let currentTable = null;
|
|
773
|
+
for (const line of lines) {
|
|
774
|
+
const trimmed = line.trim();
|
|
775
|
+
const tableMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
|
776
|
+
if (tableMatch) {
|
|
777
|
+
currentTable = tableMatch[1];
|
|
778
|
+
skippingForgeTable = currentTable === "mcp_servers.forge" ||
|
|
779
|
+
currentTable.startsWith("mcp_servers.forge.");
|
|
780
|
+
if (skippingForgeTable) continue;
|
|
781
|
+
}
|
|
782
|
+
if (skippingForgeTable) continue;
|
|
783
|
+
const isGlobalOrphanForgeLine =
|
|
784
|
+
currentTable === null &&
|
|
785
|
+
(orphanForgeLines.has(trimmed) ||
|
|
786
|
+
Array.from(orphanForgeLines).some((prefix) =>
|
|
787
|
+
trimmed.startsWith(`${prefix} =`)
|
|
788
|
+
));
|
|
789
|
+
if (isGlobalOrphanForgeLine) {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
kept.push(line);
|
|
793
|
+
}
|
|
794
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
795
|
+
}
|
|
796
|
+
|
|
756
797
|
async function runCommand(
|
|
757
798
|
command,
|
|
758
799
|
args,
|
|
@@ -895,12 +936,28 @@ async function health(config, timeoutMs = 1_500) {
|
|
|
895
936
|
const controller = new AbortController();
|
|
896
937
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
897
938
|
try {
|
|
898
|
-
const response = await fetch(
|
|
899
|
-
headers: { accept: "application/json" },
|
|
939
|
+
const response = await fetch(forgeApiUrl(config, "/api/v1/health"), {
|
|
940
|
+
headers: { accept: "application/json", "x-forge-runtime-probe": "1" },
|
|
900
941
|
signal: controller.signal
|
|
901
942
|
});
|
|
902
943
|
if (!response.ok) return { ok: false, status: response.status };
|
|
903
|
-
|
|
944
|
+
const text = await response.text();
|
|
945
|
+
let payload;
|
|
946
|
+
try {
|
|
947
|
+
payload = JSON.parse(text);
|
|
948
|
+
} catch {
|
|
949
|
+
return {
|
|
950
|
+
ok: true,
|
|
951
|
+
status: response.status,
|
|
952
|
+
error: "HTTP health endpoint returned non-JSON content",
|
|
953
|
+
forge: false
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
ok: true,
|
|
958
|
+
payload,
|
|
959
|
+
forge: isForgeHealthPayload(payload)
|
|
960
|
+
};
|
|
904
961
|
} catch (error) {
|
|
905
962
|
return {
|
|
906
963
|
ok: false,
|
|
@@ -911,6 +968,11 @@ async function health(config, timeoutMs = 1_500) {
|
|
|
911
968
|
}
|
|
912
969
|
}
|
|
913
970
|
|
|
971
|
+
function isForgeHealthPayload(payload) {
|
|
972
|
+
if (!payload || typeof payload !== "object") return false;
|
|
973
|
+
return payload.app === "forge" && payload.backend === "forge-node-runtime";
|
|
974
|
+
}
|
|
975
|
+
|
|
914
976
|
function describeNetworkError(error) {
|
|
915
977
|
if (error instanceof Error) {
|
|
916
978
|
if (error.name === "AbortError") return "request timed out";
|
|
@@ -922,12 +984,17 @@ function describeNetworkError(error) {
|
|
|
922
984
|
}
|
|
923
985
|
|
|
924
986
|
function describeHealthResult(result) {
|
|
987
|
+
if (result.ok && result.forge === false) return "HTTP 200 from a non-Forge service";
|
|
925
988
|
if (result.ok) return "healthy";
|
|
926
989
|
if (result.status) return `HTTP ${result.status}`;
|
|
927
990
|
if (result.error) return result.error;
|
|
928
991
|
return "not reachable";
|
|
929
992
|
}
|
|
930
993
|
|
|
994
|
+
function isHealthyForgeRuntime(result) {
|
|
995
|
+
return Boolean(result?.ok && result.forge !== false);
|
|
996
|
+
}
|
|
997
|
+
|
|
931
998
|
async function readRuntimeState() {
|
|
932
999
|
return readJson(runtimeStatePath(), null);
|
|
933
1000
|
}
|
|
@@ -951,8 +1018,8 @@ async function waitForHealth(config, timeoutMs = 30_000) {
|
|
|
951
1018
|
return health(config);
|
|
952
1019
|
}
|
|
953
1020
|
|
|
954
|
-
function resolveOpenClawPluginRoot() {
|
|
955
|
-
const candidates = [
|
|
1021
|
+
function resolveOpenClawPluginRoot(options = {}) {
|
|
1022
|
+
const candidates = [];
|
|
956
1023
|
const installedRuntimePackageJson = path.join(
|
|
957
1024
|
runtimeInstallRoot(),
|
|
958
1025
|
"package.json"
|
|
@@ -960,6 +1027,9 @@ function resolveOpenClawPluginRoot() {
|
|
|
960
1027
|
if (fs.existsSync(installedRuntimePackageJson)) {
|
|
961
1028
|
candidates.push(createRequire(installedRuntimePackageJson));
|
|
962
1029
|
}
|
|
1030
|
+
if (!options.installedOnly) {
|
|
1031
|
+
candidates.push(require);
|
|
1032
|
+
}
|
|
963
1033
|
|
|
964
1034
|
for (const candidateRequire of candidates) {
|
|
965
1035
|
try {
|
|
@@ -975,8 +1045,10 @@ function resolveOpenClawPluginRoot() {
|
|
|
975
1045
|
return null;
|
|
976
1046
|
}
|
|
977
1047
|
|
|
978
|
-
async function ensurePackagedRuntimeInstalled() {
|
|
979
|
-
const existing =
|
|
1048
|
+
async function ensurePackagedRuntimeInstalled(options = {}) {
|
|
1049
|
+
const existing = options.forceInstall
|
|
1050
|
+
? null
|
|
1051
|
+
: resolveOpenClawPluginRoot();
|
|
980
1052
|
if (existing) return existing;
|
|
981
1053
|
const installRoot = runtimeInstallRoot();
|
|
982
1054
|
await fsp.mkdir(installRoot, { recursive: true });
|
|
@@ -1012,19 +1084,108 @@ async function ensurePackagedRuntimeInstalled() {
|
|
|
1012
1084
|
].join(" ")
|
|
1013
1085
|
);
|
|
1014
1086
|
}
|
|
1015
|
-
const installed = resolveOpenClawPluginRoot();
|
|
1087
|
+
const installed = resolveOpenClawPluginRoot({ installedOnly: true });
|
|
1016
1088
|
if (!installed)
|
|
1017
1089
|
throw new Error(
|
|
1018
1090
|
`${RUNTIME_PACKAGE} installed but its runtime entry could not be resolved. Log: ${logPath()}`
|
|
1019
1091
|
);
|
|
1092
|
+
const entry = path.join(installed, "server", "index.js");
|
|
1093
|
+
if (!fs.existsSync(entry)) {
|
|
1094
|
+
throw new Error(
|
|
1095
|
+
`${RUNTIME_PACKAGE} installed but ${entry} is missing. Log: ${logPath()}`
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1020
1098
|
return installed;
|
|
1021
1099
|
}
|
|
1022
1100
|
|
|
1101
|
+
async function rotateRuntimeLog(reason) {
|
|
1102
|
+
if (!fs.existsSync(logPath())) return null;
|
|
1103
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1104
|
+
const backupPath = `${logPath()}.${reason}-${stamp}.log`;
|
|
1105
|
+
await fsp.mkdir(path.dirname(backupPath), { recursive: true });
|
|
1106
|
+
await fsp.copyFile(logPath(), backupPath);
|
|
1107
|
+
return backupPath;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function repairPackagedRuntimeCache(config) {
|
|
1111
|
+
if (config.mode === "dev") {
|
|
1112
|
+
const result = await startRuntime(config);
|
|
1113
|
+
return {
|
|
1114
|
+
ok: result.ok,
|
|
1115
|
+
mode: "dev",
|
|
1116
|
+
dataRoot: config.dataRoot,
|
|
1117
|
+
health: result.health ?? { ok: result.ok }
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
await stopRuntime();
|
|
1122
|
+
const rotatedLogPath = await rotateRuntimeLog("repair");
|
|
1123
|
+
await fsp.rm(runtimeStatePath(), { force: true });
|
|
1124
|
+
await fsp.rm(runtimeInstallRoot(), { recursive: true, force: true });
|
|
1125
|
+
const pluginRoot = await ensurePackagedRuntimeInstalled({ forceInstall: true });
|
|
1126
|
+
const result = await startRuntime(config);
|
|
1127
|
+
const record = {
|
|
1128
|
+
repairedAt: new Date().toISOString(),
|
|
1129
|
+
ok: result.ok,
|
|
1130
|
+
mode: config.mode,
|
|
1131
|
+
runtimePackage: RUNTIME_PACKAGE,
|
|
1132
|
+
runtimePackageVersion: RUNTIME_PACKAGE_VERSION,
|
|
1133
|
+
pluginRoot,
|
|
1134
|
+
dataRoot: config.dataRoot,
|
|
1135
|
+
dataPreserved: true,
|
|
1136
|
+
rotatedLogPath,
|
|
1137
|
+
health: result.health ?? { ok: result.ok }
|
|
1138
|
+
};
|
|
1139
|
+
const stamp = record.repairedAt.replace(/[:.]/g, "-");
|
|
1140
|
+
const repairRecordPath = path.join(forgeHome(), "run", `runtime-repair-${stamp}.json`);
|
|
1141
|
+
await fsp.mkdir(path.dirname(repairRecordPath), { recursive: true });
|
|
1142
|
+
await fsp.writeFile(repairRecordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
1143
|
+
return { ...record, repairRecordPath };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1023
1146
|
async function startRuntime(config) {
|
|
1147
|
+
const current = await health(config);
|
|
1148
|
+
if (isHealthyForgeRuntime(current)) {
|
|
1149
|
+
const existing = await readRuntimeState();
|
|
1150
|
+
return {
|
|
1151
|
+
ok: true,
|
|
1152
|
+
started: false,
|
|
1153
|
+
adopted: true,
|
|
1154
|
+
state: existing,
|
|
1155
|
+
health: current
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
if (current.ok && current.forge === false) {
|
|
1159
|
+
return {
|
|
1160
|
+
ok: false,
|
|
1161
|
+
started: false,
|
|
1162
|
+
state: await readRuntimeState(),
|
|
1163
|
+
health: current,
|
|
1164
|
+
portConflict: true,
|
|
1165
|
+
message: `Port ${config.port || DEFAULT_PORT} is already serving a non-Forge HTTP service.`
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
if (!(await isPortAvailable(config.port || DEFAULT_PORT))) {
|
|
1169
|
+
return {
|
|
1170
|
+
ok: false,
|
|
1171
|
+
started: false,
|
|
1172
|
+
state: await readRuntimeState(),
|
|
1173
|
+
health: current,
|
|
1174
|
+
portConflict: true,
|
|
1175
|
+
message: `Port ${config.port || DEFAULT_PORT} is already in use.`
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1024
1178
|
const existing = await readRuntimeState();
|
|
1025
1179
|
if (existing?.pid && processExists(existing.pid)) {
|
|
1026
|
-
const
|
|
1027
|
-
if (
|
|
1180
|
+
const existingHealth = await health(config);
|
|
1181
|
+
if (isHealthyForgeRuntime(existingHealth))
|
|
1182
|
+
return {
|
|
1183
|
+
ok: true,
|
|
1184
|
+
started: false,
|
|
1185
|
+
adopted: true,
|
|
1186
|
+
state: existing,
|
|
1187
|
+
health: existingHealth
|
|
1188
|
+
};
|
|
1028
1189
|
}
|
|
1029
1190
|
|
|
1030
1191
|
await fsp.mkdir(path.dirname(logPath()), { recursive: true });
|
|
@@ -1122,6 +1283,18 @@ async function startRuntime(config) {
|
|
|
1122
1283
|
|
|
1123
1284
|
function assertRuntimeStartedForPairing(result, config) {
|
|
1124
1285
|
if (result?.ok) return;
|
|
1286
|
+
if (result?.portConflict) {
|
|
1287
|
+
throw new Error(
|
|
1288
|
+
[
|
|
1289
|
+
`Forge runtime did not start because ${baseUrl(config)} is already in use by another service.`,
|
|
1290
|
+
`Health check: ${describeHealthResult(result?.health ?? { ok: false })}.`,
|
|
1291
|
+
"Run npx forge-memory status to inspect the configured runtime.",
|
|
1292
|
+
"If Forge Memory owns the running process, run npx forge-memory stop; otherwise stop the conflicting process or choose another --port.",
|
|
1293
|
+
`Runtime log: ${logPath()}.`,
|
|
1294
|
+
"Your data folder is unchanged."
|
|
1295
|
+
].join(" ")
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1125
1298
|
throw new Error(
|
|
1126
1299
|
[
|
|
1127
1300
|
`Forge runtime did not become healthy at ${baseUrl(config)}, so iOS pairing was not started.`,
|
|
@@ -1132,22 +1305,38 @@ function assertRuntimeStartedForPairing(result, config) {
|
|
|
1132
1305
|
);
|
|
1133
1306
|
}
|
|
1134
1307
|
|
|
1135
|
-
async function stopRuntime() {
|
|
1308
|
+
async function stopRuntime(config = null) {
|
|
1309
|
+
const effectiveConfig = config ?? (await readConfig());
|
|
1136
1310
|
const state = await readRuntimeState();
|
|
1137
|
-
if (!state?.children?.length)
|
|
1138
|
-
return {
|
|
1139
|
-
ok: true,
|
|
1140
|
-
stopped: false,
|
|
1141
|
-
message: "No forge-memory runtime state found."
|
|
1142
|
-
};
|
|
1143
1311
|
const stopped = [];
|
|
1144
|
-
for (const child of state
|
|
1312
|
+
for (const child of state?.children ?? []) {
|
|
1145
1313
|
if (!child?.pid || !processExists(child.pid)) continue;
|
|
1146
1314
|
process.kill(child.pid, "SIGTERM");
|
|
1147
1315
|
stopped.push(child.pid);
|
|
1148
1316
|
}
|
|
1317
|
+
const current = await health(effectiveConfig);
|
|
1318
|
+
const runtimePid = Number(current?.payload?.runtime?.pid);
|
|
1319
|
+
if (
|
|
1320
|
+
isHealthyForgeRuntime(current) &&
|
|
1321
|
+
Number.isInteger(runtimePid) &&
|
|
1322
|
+
runtimePid > 0 &&
|
|
1323
|
+
!stopped.includes(runtimePid) &&
|
|
1324
|
+
processExists(runtimePid)
|
|
1325
|
+
) {
|
|
1326
|
+
process.kill(runtimePid, "SIGTERM");
|
|
1327
|
+
stopped.push(runtimePid);
|
|
1328
|
+
}
|
|
1149
1329
|
await fsp.rm(runtimeStatePath(), { force: true });
|
|
1150
|
-
|
|
1330
|
+
if (stopped.length === 0) {
|
|
1331
|
+
return {
|
|
1332
|
+
ok: true,
|
|
1333
|
+
stopped: false,
|
|
1334
|
+
message: state?.children?.length
|
|
1335
|
+
? "No recorded Forge Memory runtime processes were alive."
|
|
1336
|
+
: "No Forge Memory runtime state or live Forge runtime was found."
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
return { ok: true, stopped: true, pids: stopped };
|
|
1151
1340
|
}
|
|
1152
1341
|
|
|
1153
1342
|
async function exportForgeData(parsed) {
|
|
@@ -1274,14 +1463,9 @@ async function removeCodexAdapterConfig() {
|
|
|
1274
1463
|
const filePath = path.join(homeDir(), ".codex", "config.toml");
|
|
1275
1464
|
if (!fs.existsSync(filePath)) return { filePath, changed: false };
|
|
1276
1465
|
const source = await fsp.readFile(filePath, "utf8");
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
if (!pattern.test(source)) return { filePath, changed: false };
|
|
1466
|
+
const next = stripCodexForgeMcpConfig(source);
|
|
1467
|
+
if (next === source.trimEnd()) return { filePath, changed: false };
|
|
1280
1468
|
await backupIfExists(filePath);
|
|
1281
|
-
const next = source
|
|
1282
|
-
.replace(pattern, "\n")
|
|
1283
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
1284
|
-
.trimEnd();
|
|
1285
1469
|
await fsp.writeFile(filePath, next ? `${next}\n` : "", "utf8");
|
|
1286
1470
|
return { filePath, changed: true };
|
|
1287
1471
|
}
|
|
@@ -1363,10 +1547,100 @@ function normalizePublicPairingUrl(value) {
|
|
|
1363
1547
|
}
|
|
1364
1548
|
}
|
|
1365
1549
|
|
|
1550
|
+
function readSetCookieHeader(headers) {
|
|
1551
|
+
if (typeof headers.getSetCookie === "function") {
|
|
1552
|
+
const values = headers.getSetCookie();
|
|
1553
|
+
if (values.length > 0) return values[0];
|
|
1554
|
+
}
|
|
1555
|
+
return headers.get("set-cookie");
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function cookiePairFromSetCookie(value) {
|
|
1559
|
+
if (!value) return null;
|
|
1560
|
+
return value.split(";")[0]?.trim() || null;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
class PairingAuthError extends Error {
|
|
1564
|
+
constructor(message, detail = {}) {
|
|
1565
|
+
super(message);
|
|
1566
|
+
this.name = "PairingAuthError";
|
|
1567
|
+
this.code = "pairing_auth_failed";
|
|
1568
|
+
this.detail = detail;
|
|
1569
|
+
this.guidance = [
|
|
1570
|
+
"Forge is reachable, but Forge Memory could not create a local operator session for iOS pairing.",
|
|
1571
|
+
"Run npx forge-memory doctor to confirm the runtime is healthy.",
|
|
1572
|
+
"Then rerun npx forge-memory pair-ios."
|
|
1573
|
+
];
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
class PairingRequestError extends Error {
|
|
1578
|
+
constructor(message, detail = {}) {
|
|
1579
|
+
super(message);
|
|
1580
|
+
this.name = "PairingRequestError";
|
|
1581
|
+
this.code = "pairing_request_failed";
|
|
1582
|
+
this.detail = detail;
|
|
1583
|
+
this.guidance =
|
|
1584
|
+
detail.status === 401
|
|
1585
|
+
? [
|
|
1586
|
+
"Forge is reachable, but the pairing request was not authenticated.",
|
|
1587
|
+
"Forge Memory should bootstrap a local operator session before pairing; rerun npx forge-memory pair-ios after updating.",
|
|
1588
|
+
"Run npx forge-memory doctor only if runtime health also fails."
|
|
1589
|
+
]
|
|
1590
|
+
: [
|
|
1591
|
+
"Forge is reachable, but it rejected the iOS pairing request.",
|
|
1592
|
+
"Run npx forge-memory doctor to confirm runtime health, then inspect the response above."
|
|
1593
|
+
];
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async function bootstrapLocalOperatorSession(config) {
|
|
1598
|
+
const sessionUrl = forgeApiUrl(config, "/api/v1/auth/operator-session");
|
|
1599
|
+
let response;
|
|
1600
|
+
try {
|
|
1601
|
+
response = await fetch(sessionUrl, {
|
|
1602
|
+
method: "GET",
|
|
1603
|
+
headers: {
|
|
1604
|
+
accept: "application/json",
|
|
1605
|
+
host: localHostHeader(config)
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
throw new PairingAuthError(
|
|
1610
|
+
[
|
|
1611
|
+
`Could not create a local operator session at ${sessionUrl}.`,
|
|
1612
|
+
`Network: ${describeNetworkError(error)}.`
|
|
1613
|
+
].join(" "),
|
|
1614
|
+
{ url: sessionUrl.toString() }
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
if (!response.ok) {
|
|
1618
|
+
const body = await response.text().catch(() => "");
|
|
1619
|
+
throw new PairingAuthError(
|
|
1620
|
+
[
|
|
1621
|
+
`Could not create a local operator session at ${sessionUrl}: Forge returned HTTP ${response.status}.`,
|
|
1622
|
+
body ? `Response: ${body.slice(0, 500)}` : ""
|
|
1623
|
+
]
|
|
1624
|
+
.filter(Boolean)
|
|
1625
|
+
.join(" "),
|
|
1626
|
+
{ url: sessionUrl.toString(), status: response.status }
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
const cookie = cookiePairFromSetCookie(readSetCookieHeader(response.headers));
|
|
1630
|
+
if (!cookie) {
|
|
1631
|
+
throw new PairingAuthError(
|
|
1632
|
+
`Could not create a local operator session at ${sessionUrl}: Forge did not return a session cookie.`,
|
|
1633
|
+
{ url: sessionUrl.toString(), status: response.status }
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
return cookie;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1366
1639
|
async function createPairing(config, options = {}) {
|
|
1367
1640
|
const transportMode = options.transportMode ?? "iroh";
|
|
1368
|
-
const publicUrl =
|
|
1369
|
-
const pairingUrl =
|
|
1641
|
+
const publicUrl = validatePairingOptions({ transportMode, publicUrl: options.publicUrl });
|
|
1642
|
+
const pairingUrl = forgeApiUrl(config, "/api/v1/health/pairing-sessions");
|
|
1643
|
+
const operatorCookie = await bootstrapLocalOperatorSession(config);
|
|
1370
1644
|
let response;
|
|
1371
1645
|
try {
|
|
1372
1646
|
response = await fetch(pairingUrl, {
|
|
@@ -1374,6 +1648,7 @@ async function createPairing(config, options = {}) {
|
|
|
1374
1648
|
headers: {
|
|
1375
1649
|
"content-type": "application/json",
|
|
1376
1650
|
accept: "application/json",
|
|
1651
|
+
cookie: operatorCookie,
|
|
1377
1652
|
...(publicUrl ? { referer: publicUrl } : {})
|
|
1378
1653
|
},
|
|
1379
1654
|
body: JSON.stringify({ userId: null, transportMode })
|
|
@@ -1396,19 +1671,49 @@ async function createPairing(config, options = {}) {
|
|
|
1396
1671
|
}
|
|
1397
1672
|
if (!response.ok) {
|
|
1398
1673
|
const body = await response.text().catch(() => "");
|
|
1399
|
-
throw new
|
|
1674
|
+
throw new PairingRequestError(
|
|
1400
1675
|
[
|
|
1401
1676
|
`Could not create iOS pairing at ${pairingUrl}: Forge returned HTTP ${response.status}.`,
|
|
1402
1677
|
body ? `Response: ${body.slice(0, 500)}` : "",
|
|
1403
|
-
|
|
1678
|
+
response.status === 401
|
|
1679
|
+
? "Forge Memory did not obtain or pass a valid local operator session cookie."
|
|
1680
|
+
: "Inspect the response and rerun npx forge-memory doctor if runtime health is uncertain."
|
|
1404
1681
|
]
|
|
1405
1682
|
.filter(Boolean)
|
|
1406
|
-
.join(" ")
|
|
1683
|
+
.join(" "),
|
|
1684
|
+
{ url: pairingUrl.toString(), status: response.status }
|
|
1407
1685
|
);
|
|
1408
1686
|
}
|
|
1409
1687
|
return response.json();
|
|
1410
1688
|
}
|
|
1411
1689
|
|
|
1690
|
+
function validatePairingOptions({ transportMode, publicUrl }) {
|
|
1691
|
+
const normalizedPublicUrl = normalizePublicPairingUrl(publicUrl);
|
|
1692
|
+
if (transportMode !== "manual-http") {
|
|
1693
|
+
return normalizedPublicUrl;
|
|
1694
|
+
}
|
|
1695
|
+
if (!normalizedPublicUrl) {
|
|
1696
|
+
throw new Error(
|
|
1697
|
+
[
|
|
1698
|
+
"Manual HTTP pairing for a physical iPhone requires --public-url.",
|
|
1699
|
+
"Use a phone-reachable Tailscale or LAN Forge URL, for example:",
|
|
1700
|
+
"npx forge-memory pair-ios --manual-http --public-url https://your-mac.tailnet.ts.net/forge/",
|
|
1701
|
+
"For normal pairing, omit --manual-http and use the default Iroh transport."
|
|
1702
|
+
].join(" ")
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
if (isLoopbackPairingUrl(normalizedPublicUrl)) {
|
|
1706
|
+
throw new Error(
|
|
1707
|
+
[
|
|
1708
|
+
`Manual HTTP --public-url points at ${normalizedPublicUrl}, which is loopback-only.`,
|
|
1709
|
+
"A physical iPhone cannot reach localhost on this Mac.",
|
|
1710
|
+
"Use a Tailscale or LAN URL, or omit --manual-http and use Iroh pairing."
|
|
1711
|
+
].join(" ")
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
return normalizedPublicUrl;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1412
1717
|
function compactPairingPayload(payload) {
|
|
1413
1718
|
const transport = payload.transport
|
|
1414
1719
|
? {
|
|
@@ -1426,7 +1731,7 @@ function compactPairingPayload(payload) {
|
|
|
1426
1731
|
notes: []
|
|
1427
1732
|
}
|
|
1428
1733
|
: undefined;
|
|
1429
|
-
return {
|
|
1734
|
+
return compactObject({
|
|
1430
1735
|
kind: payload.kind,
|
|
1431
1736
|
apiBaseUrl: payload.apiBaseUrl,
|
|
1432
1737
|
uiBaseUrl: payload.uiBaseUrl,
|
|
@@ -1436,7 +1741,25 @@ function compactPairingPayload(payload) {
|
|
|
1436
1741
|
pairingToken: payload.pairingToken,
|
|
1437
1742
|
expiresAt: payload.expiresAt,
|
|
1438
1743
|
capabilities: payload.capabilities
|
|
1439
|
-
};
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function compactObject(value) {
|
|
1748
|
+
if (Array.isArray(value)) {
|
|
1749
|
+
const compacted = value.map((entry) => compactObject(entry)).filter((entry) => entry !== undefined);
|
|
1750
|
+
return compacted.length ? compacted : undefined;
|
|
1751
|
+
}
|
|
1752
|
+
if (!value || typeof value !== "object") {
|
|
1753
|
+
return value ?? undefined;
|
|
1754
|
+
}
|
|
1755
|
+
const output = {};
|
|
1756
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1757
|
+
const compacted = compactObject(entry);
|
|
1758
|
+
if (compacted !== undefined && !(Array.isArray(compacted) && compacted.length === 0)) {
|
|
1759
|
+
output[key] = compacted;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
return Object.keys(output).length ? output : undefined;
|
|
1440
1763
|
}
|
|
1441
1764
|
|
|
1442
1765
|
async function writePairingPayloadFile(payload) {
|
|
@@ -1462,8 +1785,15 @@ function isLoopbackPairingUrl(value) {
|
|
|
1462
1785
|
async function printPairing(pairing) {
|
|
1463
1786
|
const payload = compactPairingPayload(pairing.qrPayload);
|
|
1464
1787
|
const payloadText = JSON.stringify(payload);
|
|
1465
|
-
|
|
1466
|
-
|
|
1788
|
+
const terminalColumns = process.stdout.columns ?? 120;
|
|
1789
|
+
if (terminalColumns >= 72 && payloadText.length <= 2_950) {
|
|
1790
|
+
console.log("\nScan this compact QR in Forge Companion:\n");
|
|
1791
|
+
qrcode.generate(payloadText, { small: true });
|
|
1792
|
+
} else {
|
|
1793
|
+
console.log("");
|
|
1794
|
+
console.log(color.yellow("QR skipped because the terminal is too narrow or the payload is too large to scan reliably."));
|
|
1795
|
+
console.log("Use Manual connection in the iPhone app and paste the saved payload below.");
|
|
1796
|
+
}
|
|
1467
1797
|
const transport = payload.transport;
|
|
1468
1798
|
if (transport?.provider) {
|
|
1469
1799
|
const label =
|
|
@@ -1665,25 +1995,34 @@ async function runStatus(parsed) {
|
|
|
1665
1995
|
const config = await readConfig();
|
|
1666
1996
|
const state = await readRuntimeState();
|
|
1667
1997
|
const currentHealth = await health(config);
|
|
1998
|
+
const stateExists = fs.existsSync(runtimeStatePath());
|
|
1999
|
+
const running = isHealthyForgeRuntime(currentHealth);
|
|
1668
2000
|
const payload = {
|
|
1669
|
-
ok:
|
|
1670
|
-
running
|
|
2001
|
+
ok: running,
|
|
2002
|
+
running,
|
|
1671
2003
|
mode: config.mode,
|
|
1672
2004
|
baseUrl: baseUrl(config),
|
|
1673
2005
|
webUrl: webUrl(config),
|
|
1674
2006
|
dataRoot: config.dataRoot,
|
|
1675
2007
|
adapters: config.adapters,
|
|
2008
|
+
health: currentHealth,
|
|
2009
|
+
runtimeStatePath: runtimeStatePath(),
|
|
2010
|
+
runtimeStateExists: stateExists,
|
|
2011
|
+
adoptedRuntime: running && !stateExists,
|
|
1676
2012
|
state
|
|
1677
2013
|
};
|
|
1678
2014
|
if (parsed.flags.json) console.log(JSON.stringify(payload, null, 2));
|
|
1679
2015
|
else {
|
|
1680
2016
|
console.log(`${color.bold("Forge Memory Status")}`);
|
|
1681
2017
|
console.log(
|
|
1682
|
-
`Runtime: ${
|
|
2018
|
+
`Runtime: ${running ? color.green("healthy") : color.yellow(describeHealthResult(currentHealth))}`
|
|
1683
2019
|
);
|
|
1684
2020
|
console.log(`Mode: ${config.mode}`);
|
|
1685
2021
|
console.log(`UI: ${webUrl(config)}`);
|
|
1686
2022
|
console.log(`Data: ${config.dataRoot}`);
|
|
2023
|
+
console.log(
|
|
2024
|
+
`Runtime state: ${stateExists ? runtimeStatePath() : color.yellow("missing; healthy runtimes will be adopted")}`
|
|
2025
|
+
);
|
|
1687
2026
|
console.log(
|
|
1688
2027
|
`Adapters: ${config.adapters.length ? config.adapters.join(", ") : "none configured"}`
|
|
1689
2028
|
);
|
|
@@ -1694,19 +2033,37 @@ async function runStatus(parsed) {
|
|
|
1694
2033
|
async function doctorCheckRuntime(config, options) {
|
|
1695
2034
|
let result = await health(config);
|
|
1696
2035
|
let repaired = false;
|
|
1697
|
-
|
|
1698
|
-
|
|
2036
|
+
let repairRecordPath = null;
|
|
2037
|
+
if (
|
|
2038
|
+
!isHealthyForgeRuntime(result) &&
|
|
2039
|
+
!(result.ok && result.forge === false) &&
|
|
2040
|
+
options.repair &&
|
|
2041
|
+
!options.noStart &&
|
|
2042
|
+
!options.dryRun
|
|
2043
|
+
) {
|
|
2044
|
+
const repair = await repairPackagedRuntimeCache(config);
|
|
2045
|
+
repairRecordPath = repair.repairRecordPath ?? null;
|
|
1699
2046
|
result = await health(config, 3_000);
|
|
1700
|
-
repaired = result
|
|
2047
|
+
repaired = isHealthyForgeRuntime(result);
|
|
1701
2048
|
}
|
|
2049
|
+
const ok = isHealthyForgeRuntime(result);
|
|
1702
2050
|
return {
|
|
1703
2051
|
id: "runtime",
|
|
1704
|
-
ok
|
|
1705
|
-
detail:
|
|
2052
|
+
ok,
|
|
2053
|
+
detail:
|
|
2054
|
+
result.ok && result.forge === false
|
|
2055
|
+
? `${baseUrl(config)} (non-Forge service responded)`
|
|
2056
|
+
: baseUrl(config),
|
|
1706
2057
|
repaired,
|
|
1707
|
-
|
|
2058
|
+
repairRecordPath,
|
|
2059
|
+
health: result,
|
|
2060
|
+
statePath: runtimeStatePath(),
|
|
2061
|
+
stateExists: fs.existsSync(runtimeStatePath()),
|
|
2062
|
+
guidance: ok
|
|
1708
2063
|
? "Forge API is reachable."
|
|
1709
|
-
:
|
|
2064
|
+
: result.ok && result.forge === false
|
|
2065
|
+
? `Port ${config.port || DEFAULT_PORT} responded, but not with Forge runtime health. Stop the conflicting process or choose another --port.`
|
|
2066
|
+
: `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.`
|
|
1710
2067
|
};
|
|
1711
2068
|
}
|
|
1712
2069
|
|
|
@@ -1816,6 +2173,11 @@ async function runUi(parsed) {
|
|
|
1816
2173
|
|
|
1817
2174
|
async function runPairIos(parsed) {
|
|
1818
2175
|
const config = await readConfig();
|
|
2176
|
+
const transportMode = parsed.flags.manualHttp ? "manual-http" : "iroh";
|
|
2177
|
+
const publicUrl = validatePairingOptions({
|
|
2178
|
+
transportMode,
|
|
2179
|
+
publicUrl: parsed.values.publicUrl
|
|
2180
|
+
});
|
|
1819
2181
|
if (!parsed.flags.noStart) {
|
|
1820
2182
|
const runtimeResult = await withProgress(
|
|
1821
2183
|
"Starting Forge runtime for iOS pairing",
|
|
@@ -1824,10 +2186,16 @@ async function runPairIos(parsed) {
|
|
|
1824
2186
|
() => startRuntime(config)
|
|
1825
2187
|
);
|
|
1826
2188
|
assertRuntimeStartedForPairing(runtimeResult, config);
|
|
2189
|
+
} else {
|
|
2190
|
+
const currentHealth = await health(config, 3_000);
|
|
2191
|
+
assertRuntimeStartedForPairing(
|
|
2192
|
+
{ ok: currentHealth.ok, started: false, health: currentHealth },
|
|
2193
|
+
config
|
|
2194
|
+
);
|
|
1827
2195
|
}
|
|
1828
2196
|
const pairing = await createPairing(config, {
|
|
1829
|
-
transportMode
|
|
1830
|
-
publicUrl
|
|
2197
|
+
transportMode,
|
|
2198
|
+
publicUrl
|
|
1831
2199
|
});
|
|
1832
2200
|
if (parsed.flags.json) {
|
|
1833
2201
|
console.log(JSON.stringify(pairing, null, 2));
|
|
@@ -2256,14 +2624,22 @@ async function main() {
|
|
|
2256
2624
|
|
|
2257
2625
|
function printFatalError(error, { json = false } = {}) {
|
|
2258
2626
|
const message = error instanceof Error ? error.message : String(error);
|
|
2627
|
+
const guidance =
|
|
2628
|
+
error && typeof error === "object" && Array.isArray(error.guidance)
|
|
2629
|
+
? error.guidance
|
|
2630
|
+
: [
|
|
2631
|
+
"Run npx forge-memory doctor --repair to check and repair the local install.",
|
|
2632
|
+
"Run npx forge-memory logs to inspect the runtime log.",
|
|
2633
|
+
"Forge Memory repair never deletes your data folder."
|
|
2634
|
+
];
|
|
2259
2635
|
const payload = {
|
|
2260
2636
|
ok: false,
|
|
2637
|
+
code:
|
|
2638
|
+
error && typeof error === "object" && typeof error.code === "string"
|
|
2639
|
+
? error.code
|
|
2640
|
+
: "forge_memory_failed",
|
|
2261
2641
|
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
|
-
],
|
|
2642
|
+
guidance,
|
|
2267
2643
|
logPath: logPath()
|
|
2268
2644
|
};
|
|
2269
2645
|
if (json) {
|
package/package.json
CHANGED