@ijfw/install 1.6.0 → 1.6.2

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/dist/install.js CHANGED
@@ -292,21 +292,42 @@ function prettyName(targetId) {
292
292
  };
293
293
  return map[targetId] || String(targetId);
294
294
  }
295
+ function backupTimestamp() {
296
+ const d = /* @__PURE__ */ new Date();
297
+ const p = (n) => String(n).padStart(2, "0");
298
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
299
+ }
295
300
  function readJsonOrEmpty(path3) {
296
301
  if (!existsSync3(path3)) return {};
302
+ let raw;
303
+ try {
304
+ raw = readFileSync2(path3, "utf8");
305
+ } catch (err) {
306
+ const msg = err && err.message ? err.message : String(err);
307
+ throw new Error(`cannot read existing config at ${path3} (${msg}) -- fix permissions and re-run.`);
308
+ }
309
+ if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
310
+ if (!raw || raw.trim() === "") return {};
297
311
  try {
298
- const raw = readFileSync2(path3, "utf8");
299
- if (!raw || raw.trim() === "") return {};
300
312
  const parsed = JSON.parse(raw);
301
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
302
- return parsed;
313
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
303
314
  } catch {
304
- return {};
305
315
  }
316
+ const bak = `${path3}.corrupt-${backupTimestamp()}.bak`;
317
+ try {
318
+ copyFileSync(path3, bak);
319
+ } catch (err) {
320
+ const msg = err && err.message ? err.message : String(err);
321
+ throw new Error(
322
+ `existing config at ${path3} is not valid JSON and a safety copy could not be written (${msg}) -- fix or move the file, then re-run.`
323
+ );
324
+ }
325
+ printWarn(`existing config at ${path3} is not valid JSON -- original saved to ${bak}; starting fresh with the IJFW entry only`);
326
+ return {};
306
327
  }
307
328
  function mergeJson(dst, serverJs, ts) {
308
329
  mkdirSync2(dirname3(dst), { recursive: true });
309
- requireBackup(dst, ts);
330
+ requireBackup(dst, ts || backupTimestamp());
310
331
  const doc = readJsonOrEmpty(dst);
311
332
  if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
312
333
  const isWin = IS_WIN;
@@ -334,7 +355,7 @@ function mergeJson(dst, serverJs, ts) {
334
355
  }
335
356
  function mergeToml(dst, serverJs, ts) {
336
357
  mkdirSync2(dirname3(dst), { recursive: true });
337
- requireBackup(dst, ts);
358
+ requireBackup(dst, ts || backupTimestamp());
338
359
  let text = "";
339
360
  try {
340
361
  text = existsSync3(dst) ? readFileSync2(dst, "utf8") : "";
@@ -385,32 +406,6 @@ function stripTomlSection(text, sectionName) {
385
406
  }
386
407
  return out.join("\n");
387
408
  }
388
- function mergeYamlMcp(dst, serverJs, ts) {
389
- mkdirSync2(dirname3(dst), { recursive: true });
390
- if (ts) backup(dst, ts);
391
- let text = "";
392
- try {
393
- text = existsSync3(dst) ? readFileSync2(dst, "utf8") : "";
394
- } catch {
395
- text = "";
396
- }
397
- text = stripSentinelBlock(text, "# IJFW-MCP-BEGIN ijfw-memory", "# IJFW-MCP-END ijfw-memory");
398
- if (!/^mcp_servers:/m.test(text)) {
399
- if (text && !text.endsWith("\n")) text += "\n";
400
- text += "\nmcp_servers:\n";
401
- }
402
- const escaped = String(serverJs).replace(/"/g, '\\"');
403
- let block = "";
404
- if (!text.endsWith("\n")) block += "\n";
405
- block += "# IJFW-MCP-BEGIN ijfw-memory\n";
406
- block += " ijfw-memory:\n";
407
- block += ' command: "node"\n';
408
- block += ` args: ["${escaped}"]
409
- `;
410
- block += " enabled: true\n";
411
- block += "# IJFW-MCP-END ijfw-memory\n";
412
- writeAtomic(dst, text + block, { mode: 384 });
413
- }
414
409
  function stripSentinelBlock(text, beginMark, endMark) {
415
410
  const lines = text.split("\n");
416
411
  const out = [];
@@ -500,7 +495,7 @@ function isIndentedEnabledLine(line) {
500
495
  }
501
496
  function opencodeMerge(dst, serverJs, ts) {
502
497
  mkdirSync2(dirname3(dst), { recursive: true });
503
- if (ts) backup(dst, ts);
498
+ backup(dst, ts || backupTimestamp());
504
499
  const doc = readJsonOrEmpty(dst);
505
500
  if (!doc.mcp || typeof doc.mcp !== "object") doc.mcp = {};
506
501
  doc.mcp["ijfw-memory"] = { type: "local", command: ["node", serverJs] };
@@ -508,7 +503,7 @@ function opencodeMerge(dst, serverJs, ts) {
508
503
  }
509
504
  function openclawMerge(dst, serverJs, ts) {
510
505
  mkdirSync2(dirname3(dst), { recursive: true });
511
- if (ts) backup(dst, ts);
506
+ backup(dst, ts || backupTimestamp());
512
507
  const doc = readJsonOrEmpty(dst);
513
508
  if (!doc.mcp || typeof doc.mcp !== "object") doc.mcp = {};
514
509
  if (!doc.mcp.servers || typeof doc.mcp.servers !== "object") doc.mcp.servers = {};
@@ -554,7 +549,7 @@ function clineMerge(serverJs, home, ts) {
554
549
  if (!userDir) userDir = osDefault;
555
550
  const dst = join3(userDir, "globalStorage", ext, "settings", "cline_mcp_settings.json");
556
551
  mkdirSync2(dirname3(dst), { recursive: true });
557
- if (ts) backup(dst, ts);
552
+ backup(dst, ts || backupTimestamp());
558
553
  const doc = readJsonOrEmpty(dst);
559
554
  if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
560
555
  doc.mcpServers["ijfw-memory"] = {
@@ -633,6 +628,9 @@ function ensureDir(p) {
633
628
  } catch {
634
629
  }
635
630
  }
631
+ function stripBom(s) {
632
+ return s && s.charCodeAt(0) === 65279 ? s.slice(1) : s;
633
+ }
636
634
  function copyIfAbsent(src, dst) {
637
635
  if (!existsSync4(src)) return false;
638
636
  if (existsSync4(dst)) return false;
@@ -716,13 +714,18 @@ async function installClaude(ctx) {
716
714
  "known_marketplaces.json"
717
715
  );
718
716
  ensureDir(join4(ctx.home, ".claude", "plugins"));
719
- backup(claudeSettings, ctx.ts);
717
+ const settingsBak = requireBackup(claudeSettings, ctx.ts);
720
718
  let settings = {};
721
719
  if (existsSync4(claudeSettings)) {
722
720
  try {
723
- settings = JSON.parse(readFileSync3(claudeSettings, "utf8") || "{}");
721
+ settings = JSON.parse(stripBom(readFileSync3(claudeSettings, "utf8")) || "{}");
724
722
  } catch {
725
- settings = {};
723
+ ctx.log.warn("~/.claude/settings.json could not be parsed as JSON -- IJFW will not modify it.");
724
+ if (settingsBak) {
725
+ ctx.log.warn(`A copy of the current file was preserved at ${settingsBak}.`);
726
+ }
727
+ ctx.log.warn("Fix the JSON syntax error and re-run `ijfw install`.");
728
+ return { status: "noop" };
726
729
  }
727
730
  }
728
731
  if (!settings || typeof settings !== "object") settings = {};
@@ -736,7 +739,7 @@ async function installClaude(ctx) {
736
739
  let mp = {};
737
740
  if (existsSync4(claudeMarketplaces)) {
738
741
  try {
739
- mp = JSON.parse(readFileSync3(claudeMarketplaces, "utf8") || "{}");
742
+ mp = JSON.parse(stripBom(readFileSync3(claudeMarketplaces, "utf8")) || "{}");
740
743
  } catch {
741
744
  mp = {};
742
745
  }
@@ -785,7 +788,7 @@ async function installCodex(ctx) {
785
788
  }
786
789
  const configToml = join4(ctx.home, ".codex", "config.toml");
787
790
  ensureDir(dirname4(configToml));
788
- mergeToml(configToml, ctx.serverJsNative);
791
+ mergeToml(configToml, ctx.serverJsNative, ctx.ts);
789
792
  const hooksDst = join4(ctx.home, ".codex", "hooks.json");
790
793
  const hooksSrc = join4(ctx.repoRoot, "codex", ".codex", "hooks.json");
791
794
  const hooksBase = join4(ctx.home, ".codex", "hooks");
@@ -914,18 +917,41 @@ async function installGemini(ctx) {
914
917
  }
915
918
  const dst = join4(ctx.home, ".gemini", "settings.json");
916
919
  ensureDir(dirname4(dst));
917
- mergeJson(dst, ctx.serverJsNative);
920
+ mergeJson(dst, ctx.serverJsNative, ctx.ts);
918
921
  const extDst = join4(ctx.home, ".gemini", "extensions", "ijfw");
919
922
  const extSrc = join4(ctx.repoRoot, "gemini", "extensions", "ijfw");
920
923
  for (const sub of ["hooks", "skills", "commands", "agents", "policies"]) {
921
924
  ensureDir(join4(extDst, sub));
922
925
  }
923
- for (const rel of [
924
- "gemini-extension.json",
925
- "IJFW.md",
926
- "hooks/hooks.json",
927
- "policies/ijfw.toml"
928
- ]) {
926
+ for (const rel of ["gemini-extension.json", "hooks/hooks.json"]) {
927
+ const srcFile = join4(extSrc, rel);
928
+ if (!existsSync4(srcFile)) continue;
929
+ let desired = "";
930
+ try {
931
+ desired = readFileSync3(srcFile, "utf8");
932
+ } catch {
933
+ continue;
934
+ }
935
+ desired = desired.split("{{extensionPath}}").join(extDst);
936
+ const dstFile = join4(extDst, rel);
937
+ let current = null;
938
+ try {
939
+ current = existsSync4(dstFile) ? readFileSync3(dstFile, "utf8") : null;
940
+ } catch {
941
+ current = null;
942
+ }
943
+ if (current === desired) continue;
944
+ if (current !== null) {
945
+ try {
946
+ copyFileSync2(dstFile, `${dstFile}.bak.${ctx.ts}`);
947
+ } catch {
948
+ }
949
+ ctx.log.note(`Updated ${rel} (previous copy backed up to ${rel}.bak.${ctx.ts})`);
950
+ }
951
+ ensureDir(dirname4(dstFile));
952
+ writeAtomic(dstFile, desired);
953
+ }
954
+ for (const rel of ["IJFW.md", "policies/ijfw.toml"]) {
929
955
  const dstFile = join4(extDst, rel);
930
956
  if (!existsSync4(dstFile)) {
931
957
  ensureDir(dirname4(dstFile));
@@ -1051,6 +1077,101 @@ function renderWaylandPluginToml(ctx) {
1051
1077
  ""
1052
1078
  ].join("\n");
1053
1079
  }
1080
+ function stripSentinelLines(text, beginMark, endMark) {
1081
+ const lines = text.split("\n");
1082
+ const out = [];
1083
+ let skip = false;
1084
+ for (const line of lines) {
1085
+ if (line === beginMark) {
1086
+ skip = true;
1087
+ continue;
1088
+ }
1089
+ if (line === endMark) {
1090
+ skip = false;
1091
+ continue;
1092
+ }
1093
+ if (skip) continue;
1094
+ out.push(line);
1095
+ }
1096
+ return out.join("\n");
1097
+ }
1098
+ function hermesMergeYamlMcp(ctx, dst, serverJs) {
1099
+ ensureDir(dirname4(dst));
1100
+ requireBackup(dst, ctx.ts);
1101
+ let text = "";
1102
+ try {
1103
+ text = existsSync4(dst) ? stripBom(readFileSync3(dst, "utf8")) : "";
1104
+ } catch {
1105
+ text = "";
1106
+ }
1107
+ text = stripSentinelLines(text, HERMES_MCP_BEGIN, HERMES_MCP_END);
1108
+ const escaped = String(serverJs).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1109
+ const block = [
1110
+ HERMES_MCP_BEGIN,
1111
+ " ijfw-memory:",
1112
+ ' command: "node"',
1113
+ ` args: ["${escaped}"]`,
1114
+ " enabled: true",
1115
+ HERMES_MCP_END
1116
+ ];
1117
+ const lines = text.split("\n");
1118
+ let anchorIdx = -1;
1119
+ for (let i = 0; i < lines.length; i++) {
1120
+ const line = lines[i];
1121
+ if (line.startsWith("mcp_servers:")) {
1122
+ const rest = line.slice("mcp_servers:".length).trim();
1123
+ if (rest === "" || rest.startsWith("#")) {
1124
+ anchorIdx = i;
1125
+ break;
1126
+ }
1127
+ const beforeComment = rest.split("#")[0].replace(/\s/g, "");
1128
+ if (beforeComment === "{}") {
1129
+ lines[i] = "mcp_servers:";
1130
+ anchorIdx = i;
1131
+ break;
1132
+ }
1133
+ }
1134
+ }
1135
+ let merged;
1136
+ if (anchorIdx >= 0) {
1137
+ lines.splice(anchorIdx + 1, 0, ...block);
1138
+ merged = lines.join("\n");
1139
+ } else if (/^mcp_servers:/m.test(text)) {
1140
+ ctx.log.warn("Hermes config.yaml uses an inline mcp_servers map -- cannot merge safely.");
1141
+ ctx.log.warn('Add an "ijfw-memory" entry to mcp_servers manually (command: node, args: [server.js path]).');
1142
+ return false;
1143
+ } else {
1144
+ const prefix = text.trim() === "" ? "" : (text.endsWith("\n") ? text : `${text}
1145
+ `) + "\n";
1146
+ merged = `${prefix}mcp_servers:
1147
+ ${block.join("\n")}`;
1148
+ }
1149
+ if (!merged.endsWith("\n")) merged += "\n";
1150
+ writeAtomic(dst, merged, { mode: 384 });
1151
+ return true;
1152
+ }
1153
+ function hermesInlineEnabledHas(dst, pluginName) {
1154
+ let text = "";
1155
+ try {
1156
+ text = existsSync4(dst) ? readFileSync3(dst, "utf8") : "";
1157
+ } catch {
1158
+ return false;
1159
+ }
1160
+ const esc = pluginName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1161
+ const nameRe = new RegExp(`[\\[,]\\s*["']?${esc}["']?\\s*[,\\]]`);
1162
+ let inPlugins = false;
1163
+ for (const line of text.split("\n")) {
1164
+ if (/^plugins:\s*$/.test(line)) {
1165
+ inPlugins = true;
1166
+ continue;
1167
+ }
1168
+ if (inPlugins && /^\S/.test(line) && line.trim() !== "") inPlugins = false;
1169
+ if (inPlugins && /^\s+enabled:\s*\[.+\]\s*$/.test(line) && nameRe.test(line)) {
1170
+ return true;
1171
+ }
1172
+ }
1173
+ return false;
1174
+ }
1054
1175
  async function installHermes(ctx) {
1055
1176
  if (ctx.ijfwCustomDir) {
1056
1177
  return customDirNoop(
@@ -1062,7 +1183,7 @@ async function installHermes(ctx) {
1062
1183
  }
1063
1184
  const dst = join4(ctx.home, ".hermes", "config.yaml");
1064
1185
  ensureDir(dirname4(dst));
1065
- mergeYamlMcp(dst, ctx.serverJsNative);
1186
+ hermesMergeYamlMcp(ctx, dst, ctx.serverJsNative);
1066
1187
  ensureDir(join4(ctx.home, ".hermes"));
1067
1188
  copyIfAbsent(
1068
1189
  join4(ctx.repoRoot, "hermes", "HERMES.md"),
@@ -1087,40 +1208,44 @@ async function installHermes(ctx) {
1087
1208
  }
1088
1209
  if (readdirErr) {
1089
1210
  ctx.log.warn(`Hermes plugin tree readdir failed: ${readdirErr.message || readdirErr}`);
1090
- }
1091
- const srcNames = new Set(entries.filter((n) => n !== "__pycache__"));
1092
- let dstEntries = [];
1093
- try {
1094
- dstEntries = readdirSync(pluginDst);
1095
- } catch {
1096
- }
1097
- for (const name of dstEntries) {
1098
- if (name === "__pycache__") continue;
1099
- if (!srcNames.has(name)) {
1100
- try {
1101
- rmSync(join4(pluginDst, name), { recursive: true, force: true });
1102
- } catch (err) {
1103
- ctx.log.warn(`Hermes plugin: could not remove stale ${name}: ${err.message || err}`);
1211
+ ctx.log.warn("Leaving the installed Hermes plugin tree untouched.");
1212
+ } else {
1213
+ const srcNames = new Set(entries.filter((n) => n !== "__pycache__"));
1214
+ let dstEntries = [];
1215
+ try {
1216
+ dstEntries = readdirSync(pluginDst);
1217
+ } catch {
1218
+ }
1219
+ for (const name of dstEntries) {
1220
+ if (name === "__pycache__") continue;
1221
+ if (!srcNames.has(name)) {
1222
+ try {
1223
+ rmSync(join4(pluginDst, name), { recursive: true, force: true });
1224
+ } catch (err) {
1225
+ ctx.log.warn(`Hermes plugin: could not remove stale ${name}: ${err.message || err}`);
1226
+ }
1104
1227
  }
1105
1228
  }
1106
- }
1107
- for (const name of entries) {
1108
- if (name === "__pycache__") continue;
1109
- const src = join4(pluginSrc, name);
1110
- const dstEntry = join4(pluginDst, name);
1111
- try {
1112
- const st = statSync2(src);
1113
- if (st.isDirectory()) {
1114
- cpSync(src, dstEntry, { recursive: true, force: true });
1115
- } else if (st.isFile()) {
1116
- copyFileSync2(src, dstEntry);
1229
+ for (const name of entries) {
1230
+ if (name === "__pycache__") continue;
1231
+ const src = join4(pluginSrc, name);
1232
+ const dstEntry = join4(pluginDst, name);
1233
+ try {
1234
+ const st = statSync2(src);
1235
+ if (st.isDirectory()) {
1236
+ cpSync(src, dstEntry, { recursive: true, force: true });
1237
+ } else if (st.isFile()) {
1238
+ copyFileSync2(src, dstEntry);
1239
+ }
1240
+ } catch {
1117
1241
  }
1118
- } catch {
1119
1242
  }
1120
1243
  }
1121
1244
  }
1122
- mergeYamlPluginsEnabled(dst, "ijfw");
1123
- mergeYamlHook(dst, "plugins/ijfw/hooks/pre_tool_use_extension_check.py", ctx.ts);
1245
+ if (!hermesInlineEnabledHas(dst, "ijfw")) {
1246
+ mergeYamlPluginsEnabled(dst, "ijfw");
1247
+ }
1248
+ mergeYamlHook(dst, "plugins/ijfw/hooks/pre_tool_use_extension_check.py");
1124
1249
  ctx.log.ok("Installed Hermes bundle: MCP + HERMES.md + skills + plugin + tier-2 hook");
1125
1250
  return { status: "ok" };
1126
1251
  }
@@ -1148,13 +1273,13 @@ async function installCursor(ctx) {
1148
1273
  }
1149
1274
  const dst = join4(cwd, ".cursor", "mcp.json");
1150
1275
  ensureDir(dirname4(dst));
1151
- mergeJson(dst, ctx.serverJsNative);
1276
+ mergeJson(dst, ctx.serverJsNative, ctx.ts);
1152
1277
  const rulesDir = join4(cwd, ".cursor", "rules");
1153
1278
  ensureDir(rulesDir);
1154
1279
  const ruleSrc = join4(ctx.repoRoot, "cursor", ".cursor", "rules", "ijfw.mdc");
1155
1280
  if (existsSync4(ruleSrc)) {
1156
1281
  try {
1157
- copyFileSync2(ruleSrc, join4(rulesDir, "ijfw.mdc"));
1282
+ installHook(ruleSrc, join4(rulesDir, "ijfw.mdc"), ctx.ts);
1158
1283
  } catch {
1159
1284
  }
1160
1285
  }
@@ -1172,7 +1297,7 @@ async function installWindsurf(ctx) {
1172
1297
  }
1173
1298
  const dst = join4(ctx.home, ".codeium", "windsurf", "mcp_config.json");
1174
1299
  ensureDir(dirname4(dst));
1175
- mergeJson(dst, ctx.serverJsNative);
1300
+ mergeJson(dst, ctx.serverJsNative, ctx.ts);
1176
1301
  const cwd = ctx.cwd || process.cwd();
1177
1302
  if (!guardProjectWrite(cwd, ctx.home, {
1178
1303
  platformLabel: "Windsurf project rules (.windsurfrules)",
@@ -1198,9 +1323,12 @@ async function installWindsurf(ctx) {
1198
1323
  }
1199
1324
  return { status: "ok" };
1200
1325
  }
1326
+ var HERMES_MCP_BEGIN, HERMES_MCP_END;
1201
1327
  var init_install_targets_1_7 = __esm({
1202
1328
  "src/install-targets-1-7.js"() {
1203
1329
  init_install_helpers();
1330
+ HERMES_MCP_BEGIN = "# IJFW-MCP-BEGIN ijfw-memory";
1331
+ HERMES_MCP_END = "# IJFW-MCP-END ijfw-memory";
1204
1332
  }
1205
1333
  });
1206
1334
 
@@ -1230,6 +1358,30 @@ function commandExists(name) {
1230
1358
  const r = spawnSync(probeCmd, [name], { stdio: "ignore" });
1231
1359
  return r.status === 0;
1232
1360
  }
1361
+ function vscodeMcpMerge(dst, serverJs, ts) {
1362
+ ensureDir2(path.dirname(dst));
1363
+ requireBackup(dst, ts);
1364
+ let doc = {};
1365
+ try {
1366
+ if (fs.existsSync(dst)) {
1367
+ let raw = fs.readFileSync(dst, "utf8");
1368
+ if (raw && raw.charCodeAt(0) === 65279) raw = raw.slice(1);
1369
+ if (raw.trim() !== "") {
1370
+ const parsed = JSON.parse(raw);
1371
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) doc = parsed;
1372
+ }
1373
+ }
1374
+ } catch {
1375
+ doc = {};
1376
+ }
1377
+ if (!doc.servers || typeof doc.servers !== "object") doc.servers = {};
1378
+ if (doc.mcpServers && typeof doc.mcpServers === "object") {
1379
+ delete doc.mcpServers["ijfw-memory"];
1380
+ if (Object.keys(doc.mcpServers).length === 0) delete doc.mcpServers;
1381
+ }
1382
+ doc.servers["ijfw-memory"] = { type: "stdio", command: "node", args: [serverJs] };
1383
+ writeAtomic(dst, JSON.stringify(doc, null, 2) + "\n", { mode: 384 });
1384
+ }
1233
1385
  function installCopilot(ctx) {
1234
1386
  if (ctx.ijfwCustomDir) {
1235
1387
  printInfo("Custom-dir install -- skipping Copilot project writes.");
@@ -1251,7 +1403,7 @@ function installCopilot(ctx) {
1251
1403
  }
1252
1404
  const dst = path.join(cwd, ".vscode", "mcp.json");
1253
1405
  ensureDir2(path.dirname(dst));
1254
- mergeJson(dst, ctx.serverJsNative || ctx.serverJs);
1406
+ vscodeMcpMerge(dst, ctx.serverJsNative || ctx.serverJs, ctx.ts);
1255
1407
  const rulesDst = path.join(cwd, ".github", "copilot-instructions.md");
1256
1408
  const rulesSrc = path.join(ctx.repoRoot, "copilot", "copilot-instructions.md");
1257
1409
  const wroteRules = copyIfMissing(rulesSrc, rulesDst);
@@ -1270,7 +1422,7 @@ function installOpencode(ctx) {
1270
1422
  }
1271
1423
  const dst = path.join(ctx.home, ".config", "opencode", "opencode.json");
1272
1424
  ensureDir2(path.dirname(dst));
1273
- opencodeMerge(dst, ctx.serverJsNative || ctx.serverJs);
1425
+ opencodeMerge(dst, ctx.serverJsNative || ctx.serverJs, ctx.ts);
1274
1426
  printOk(`Merged MCP into ${dst} (opencode mcp.local schema)`);
1275
1427
  return { status: "ok" };
1276
1428
  }
@@ -1282,7 +1434,7 @@ function installQwen(ctx) {
1282
1434
  }
1283
1435
  const dst = path.join(ctx.home, ".qwen", "settings.json");
1284
1436
  ensureDir2(path.dirname(dst));
1285
- mergeJson(dst, ctx.serverJsNative || ctx.serverJs);
1437
+ mergeJson(dst, ctx.serverJsNative || ctx.serverJs, ctx.ts);
1286
1438
  printOk(`Merged MCP into ${dst}`);
1287
1439
  return { status: "ok" };
1288
1440
  }
@@ -1292,7 +1444,7 @@ function installCline(ctx) {
1292
1444
  printOk("Cline: real platform config left untouched.");
1293
1445
  return { status: "noop" };
1294
1446
  }
1295
- const dst = clineMerge(ctx.serverJsNative || ctx.serverJs, ctx.home);
1447
+ const dst = clineMerge(ctx.serverJsNative || ctx.serverJs, ctx.home, ctx.ts);
1296
1448
  printOk(`Merged MCP into ${dst} (cline globalStorage schema)`);
1297
1449
  return { status: "ok" };
1298
1450
  }
@@ -1304,7 +1456,7 @@ function installKimi(ctx) {
1304
1456
  }
1305
1457
  const dst = path.join(ctx.home, ".kimi", "mcp.json");
1306
1458
  ensureDir2(path.dirname(dst));
1307
- mergeJson(dst, ctx.serverJsNative || ctx.serverJs);
1459
+ mergeJson(dst, ctx.serverJsNative || ctx.serverJs, ctx.ts);
1308
1460
  printOk(`Merged MCP into ${dst}`);
1309
1461
  return { status: "ok" };
1310
1462
  }
@@ -1331,7 +1483,7 @@ function installOpenclaw(ctx) {
1331
1483
  }
1332
1484
  }
1333
1485
  ensureDir2(path.dirname(dst));
1334
- openclawMerge(dst, serverJs);
1486
+ openclawMerge(dst, serverJs, ctx.ts);
1335
1487
  if (cliRegistered) {
1336
1488
  printOk(`Registered ijfw-memory via 'openclaw mcp set' AND file-write merge (${dst})`);
1337
1489
  } else {
@@ -1363,10 +1515,10 @@ function installAntigravity(ctx) {
1363
1515
  const serverJs = ctx.serverJsNative || ctx.serverJs;
1364
1516
  const ideDst = path.join(ctx.home, ".gemini", "antigravity", "mcp_config.json");
1365
1517
  ensureDir2(path.dirname(ideDst));
1366
- mergeJson(ideDst, serverJs);
1518
+ mergeJson(ideDst, serverJs, ctx.ts);
1367
1519
  const cliDst = path.join(ctx.home, ".gemini", "config", "mcp_config.json");
1368
1520
  ensureDir2(path.dirname(cliDst));
1369
- mergeJson(cliDst, serverJs);
1521
+ mergeJson(cliDst, serverJs, ctx.ts);
1370
1522
  printOk(`Merged MCP into ${ideDst} + ${cliDst} (Antigravity IDE + CLI)`);
1371
1523
  return { status: "ok" };
1372
1524
  }
@@ -1388,6 +1540,152 @@ var init_install_targets_8_14 = __esm({
1388
1540
  }
1389
1541
  });
1390
1542
 
1543
+ // src/install-ledger.js
1544
+ var install_ledger_exports = {};
1545
+ __export(install_ledger_exports, {
1546
+ INSTALL_PLAN: () => INSTALL_PLAN,
1547
+ PLATFORM_OWNED_DIRS: () => PLATFORM_OWNED_DIRS,
1548
+ allOwnedDirs: () => allOwnedDirs,
1549
+ isEmptyDir: () => isEmptyDir,
1550
+ ledgerPath: () => ledgerPath,
1551
+ readLedger: () => readLedger,
1552
+ renderPlan: () => renderPlan,
1553
+ snapshotPreExistingDirs: () => snapshotPreExistingDirs,
1554
+ writeLedger: () => writeLedger
1555
+ });
1556
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync2 } from "node:fs";
1557
+ import { join as join5 } from "node:path";
1558
+ function ledgerPath(ijfwHome) {
1559
+ return join5(ijfwHome, "install-ledger.json");
1560
+ }
1561
+ function allOwnedDirs() {
1562
+ const set = /* @__PURE__ */ new Set();
1563
+ for (const dirs of Object.values(PLATFORM_OWNED_DIRS)) {
1564
+ for (const d of dirs) set.add(d);
1565
+ }
1566
+ return [...set];
1567
+ }
1568
+ function snapshotPreExistingDirs(home) {
1569
+ const pre = [];
1570
+ for (const rel of allOwnedDirs()) {
1571
+ if (existsSync5(join5(home, rel))) pre.push(rel);
1572
+ }
1573
+ return pre;
1574
+ }
1575
+ function writeLedger({ home, ijfwHome, preExisting }) {
1576
+ const preSet = new Set(preExisting || []);
1577
+ const owned = allOwnedDirs();
1578
+ const created = [];
1579
+ for (const rel of owned) {
1580
+ if (!preSet.has(rel) && existsSync5(join5(home, rel))) created.push(rel);
1581
+ }
1582
+ const prev = readLedger(ijfwHome).createdDirs.filter(
1583
+ (rel) => owned.includes(rel) && existsSync5(join5(home, rel))
1584
+ );
1585
+ const ledger = { version: 1, createdDirs: [.../* @__PURE__ */ new Set([...prev, ...created])] };
1586
+ try {
1587
+ mkdirSync4(ijfwHome, { recursive: true, mode: 448 });
1588
+ writeFileSync4(ledgerPath(ijfwHome), JSON.stringify(ledger, null, 2) + "\n", { mode: 384 });
1589
+ } catch {
1590
+ }
1591
+ return ledger;
1592
+ }
1593
+ function readLedger(ijfwHome) {
1594
+ try {
1595
+ const raw = readFileSync4(ledgerPath(ijfwHome), "utf8");
1596
+ const doc = JSON.parse(raw);
1597
+ if (doc && Array.isArray(doc.createdDirs)) return doc;
1598
+ } catch {
1599
+ }
1600
+ return { version: 1, createdDirs: [] };
1601
+ }
1602
+ function isEmptyDir(p) {
1603
+ try {
1604
+ return existsSync5(p) && readdirSync2(p).length === 0;
1605
+ } catch {
1606
+ return false;
1607
+ }
1608
+ }
1609
+ function renderPlan(targetList) {
1610
+ const lines = [];
1611
+ lines.push("IJFW install plan (dry run) -- nothing will be written.");
1612
+ lines.push(" legend: [m]=merge into existing file [c]=create [./]=project-scoped");
1613
+ lines.push("");
1614
+ const emit = (title, rows) => {
1615
+ if (!rows || rows.length === 0) return;
1616
+ lines.push(title);
1617
+ for (const [path3, kind, note] of rows) lines.push(` [${kind}] ${path3} -- ${note}`);
1618
+ };
1619
+ emit(" shared:", INSTALL_PLAN.shared);
1620
+ for (const t of targetList) {
1621
+ if (INSTALL_PLAN[t]) emit(` ${t}:`, INSTALL_PLAN[t]);
1622
+ }
1623
+ lines.push("");
1624
+ lines.push(" Run without --dry-run to apply. Use `ijfw-uninstall --purge` to reverse.");
1625
+ return lines.join("\n");
1626
+ }
1627
+ var PLATFORM_OWNED_DIRS, INSTALL_PLAN;
1628
+ var init_install_ledger = __esm({
1629
+ "src/install-ledger.js"() {
1630
+ PLATFORM_OWNED_DIRS = {
1631
+ codex: [".codex"],
1632
+ gemini: [".gemini"],
1633
+ hermes: [".hermes"],
1634
+ wayland: [".wayland"],
1635
+ openclaw: [".openclaw"],
1636
+ qwen: [".qwen"],
1637
+ kimi: [".kimi"],
1638
+ opencode: [".config/opencode"],
1639
+ pi: [".pi"]
1640
+ };
1641
+ INSTALL_PLAN = {
1642
+ shared: [
1643
+ ["~/.ijfw/", "c", "IJFW home (server symlink, scripts, index, logs, settings, ledger)"]
1644
+ ],
1645
+ claude: [
1646
+ ["~/.claude/settings.json", "m", "mcpServers.ijfw-memory + enabledPlugins + marketplace"],
1647
+ ["~/.claude/plugins/known_marketplaces.json", "m", "ijfw marketplace entry"]
1648
+ ],
1649
+ codex: [
1650
+ ["~/.codex/config.toml", "m", "[mcp_servers.ijfw-memory]"],
1651
+ ["~/.codex/hooks.json", "m", "IJFW hook entries"],
1652
+ ["~/.codex/hooks/*.sh", "c", "lifecycle hook scripts"],
1653
+ ["~/.codex/IJFW.md", "c", "context file"],
1654
+ ["~/.codex/skills/, commands/", "c", "skills + command aliases"]
1655
+ ],
1656
+ gemini: [
1657
+ ["~/.gemini/settings.json", "m", "mcpServers.ijfw-memory"],
1658
+ ["~/.gemini/extensions/ijfw/", "c", "extension (hooks, skills, commands, agents)"]
1659
+ ],
1660
+ hermes: [
1661
+ ["~/.hermes/config.yaml", "m", "mcp_servers.ijfw-memory + plugin + hook"],
1662
+ ["~/.hermes/HERMES.md, skills/, plugins/ijfw/", "c", "context + skills + plugin tree"]
1663
+ ],
1664
+ wayland: [
1665
+ ["~/.wayland/plugins/ijfw/", "c", "plugin.toml (MCP + hooks)"],
1666
+ ["~/.wayland/WAYLAND.md, skills/", "c", "context + skills"]
1667
+ ],
1668
+ openclaw: [["~/.openclaw/openclaw.json", "m", "mcp.servers.ijfw-memory"]],
1669
+ qwen: [["~/.qwen/settings.json", "m", "mcpServers.ijfw-memory"]],
1670
+ kimi: [["~/.kimi/mcp.json", "m", "mcpServers.ijfw-memory"]],
1671
+ opencode: [["~/.config/opencode/opencode.json", "m", "mcp.ijfw-memory"]],
1672
+ cline: [["VS Code globalStorage <Code|VSCodium>/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", "m", "mcpServers.ijfw-memory"]],
1673
+ antigravity: [
1674
+ ["~/.gemini/antigravity/mcp_config.json", "m", "mcpServers.ijfw-memory (IDE)"],
1675
+ ["~/.gemini/config/mcp_config.json", "m", "mcpServers.ijfw-memory (CLI agy)"]
1676
+ ],
1677
+ pi: [["~/.pi/agent/AGENTS.md", "c", "context file (rules-only, no MCP)"]],
1678
+ cursor: [["./.cursor/mcp.json + rules/ijfw.mdc", "mc", "project-scoped MCP + rule"]],
1679
+ windsurf: [
1680
+ ["~/.codeium/windsurf/mcp_config.json", "m", "mcpServers.ijfw-memory"],
1681
+ ["./.windsurfrules", "c", "project-scoped rules"]
1682
+ ],
1683
+ copilot: [["./.vscode/mcp.json + .github/copilot-instructions.md", "mc", "project-scoped"]],
1684
+ aider: [["~/.aider.conf.yml + ~/CONVENTIONS.md", "c", "home-level rule files"]]
1685
+ };
1686
+ }
1687
+ });
1688
+
1391
1689
  // src/install-flow.js
1392
1690
  var install_flow_exports = {};
1393
1691
  __export(install_flow_exports, {
@@ -1927,6 +2225,7 @@ async function runInstall({
1927
2225
  });
1928
2226
  }
1929
2227
  pruneBackups({ home });
2228
+ const preExistingDirs = snapshotPreExistingDirs(home);
1930
2229
  const live = [];
1931
2230
  const standby = [];
1932
2231
  const failed = [];
@@ -1981,6 +2280,7 @@ async function runInstall({
1981
2280
  standby.push(display);
1982
2281
  }
1983
2282
  }
2283
+ writeLedger({ home, ijfwHome: resolvedIjfwHome, preExisting: preExistingDirs });
1984
2284
  printSummary({
1985
2285
  live,
1986
2286
  standby,
@@ -1996,6 +2296,7 @@ var init_install_flow = __esm({
1996
2296
  init_install_helpers();
1997
2297
  init_install_targets_1_7();
1998
2298
  init_install_targets_8_14();
2299
+ init_install_ledger();
1999
2300
  CANONICAL_ORDER = [
2000
2301
  "claude",
2001
2302
  "codex",
@@ -2038,8 +2339,8 @@ var init_install_flow = __esm({
2038
2339
 
2039
2340
  // src/install.js
2040
2341
  import { spawnSync as spawnSync2 } from "node:child_process";
2041
- import { existsSync as existsSync5, rmSync as rmSync2, mkdirSync as mkdirSync4, realpathSync as realpathSync2, renameSync as renameSync3, readdirSync as readdirSync2, cpSync as cpSync2 } from "node:fs";
2042
- import { resolve as resolve4, join as join5, dirname as dirname5 } from "node:path";
2342
+ import { existsSync as existsSync6, rmSync as rmSync2, mkdirSync as mkdirSync5, realpathSync as realpathSync2, renameSync as renameSync3, readdirSync as readdirSync3, cpSync as cpSync2 } from "node:fs";
2343
+ import { resolve as resolve4, join as join6, dirname as dirname5, basename as basename2 } from "node:path";
2043
2344
  import { homedir as homedir3, platform as platform2 } from "node:os";
2044
2345
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2045
2346
 
@@ -2201,16 +2502,17 @@ function triggerColdScan(projectRoot, options = {}) {
2201
2502
  var DEFAULT_REPO = "https://github.com/FerroxLabs/ijfw.git";
2202
2503
  var DEFAULT_BRANCH = "main";
2203
2504
  function parseArgs(argv) {
2204
- const out = { yes: false, dir: null, noMarketplace: false, branch: DEFAULT_BRANCH, branchExplicit: false, purge: false };
2505
+ const out = { yes: false, dir: null, noMarketplace: false, branch: DEFAULT_BRANCH, branchExplicit: false, purge: false, dryRun: false };
2205
2506
  for (let i = 2; i < argv.length; i++) {
2206
2507
  const a = argv[i];
2207
2508
  if (a === "--yes" || a === "-y") out.yes = true;
2208
- else if (a === "--dir") out.dir = argv[++i];
2509
+ else if (a === "--dir") out.dir = requireFlagValue("--dir", "a path", argv[++i]);
2209
2510
  else if (a === "--no-marketplace") out.noMarketplace = true;
2210
2511
  else if (a === "--branch") {
2211
- out.branch = argv[++i];
2512
+ out.branch = requireFlagValue("--branch", "a name", argv[++i]);
2212
2513
  out.branchExplicit = true;
2213
2514
  } else if (a === "--purge") out.purge = true;
2515
+ else if (a === "--dry-run" || a === "--print-plan") out.dryRun = true;
2214
2516
  else if (a === "--help" || a === "-h") {
2215
2517
  printHelp();
2216
2518
  process.exit(0);
@@ -2218,6 +2520,13 @@ function parseArgs(argv) {
2218
2520
  }
2219
2521
  return out;
2220
2522
  }
2523
+ function requireFlagValue(flag, what, value) {
2524
+ if (value == null || value.startsWith("-")) {
2525
+ console.error(`${flag} requires ${what} argument`);
2526
+ process.exit(1);
2527
+ }
2528
+ return value;
2529
+ }
2221
2530
  function skipNetwork() {
2222
2531
  return process.env.IJFW_SKIP_NETWORK === "1";
2223
2532
  }
@@ -2257,10 +2566,11 @@ function resolveBranchOrTag({ branch, branchExplicit, _tagLookup, _logger } = {}
2257
2566
  }
2258
2567
  function printHelp() {
2259
2568
  console.log(`ijfw-install -- IJFW installer
2260
- Usage: npx @ijfw/install [--dir <path>] [--branch <name>] [--no-marketplace] [--yes]
2569
+ Usage: npx @ijfw/install [--dir <path>] [--branch <name>] [--no-marketplace] [--yes] [--dry-run]
2261
2570
  --dir install location (default: $IJFW_HOME or ~/.ijfw)
2262
2571
  --branch git branch or tag (default: latest released tag)
2263
2572
  --no-marketplace skip merging ~/.claude/settings.json
2573
+ --dry-run print every file/dir the install would touch, write nothing
2264
2574
  --yes non-interactive
2265
2575
  `);
2266
2576
  }
@@ -2291,15 +2601,15 @@ function findBash() {
2291
2601
  const whereGit = spawnSync2("where", ["git"], { encoding: "utf8" });
2292
2602
  if (whereGit.status === 0) {
2293
2603
  const gitPath = (whereGit.stdout || "").split(/\r?\n/)[0].trim();
2294
- if (gitPath && existsSync5(gitPath)) {
2604
+ if (gitPath && existsSync6(gitPath)) {
2295
2605
  const gitDir = dirname5(gitPath);
2296
2606
  const gitRoot = dirname5(gitDir);
2297
2607
  const candidates = [
2298
- join5(gitDir, "bash.exe"),
2299
- join5(gitRoot, "bin", "bash.exe"),
2300
- join5(gitRoot, "usr", "bin", "bash.exe")
2608
+ join6(gitDir, "bash.exe"),
2609
+ join6(gitRoot, "bin", "bash.exe"),
2610
+ join6(gitRoot, "usr", "bin", "bash.exe")
2301
2611
  ];
2302
- for (const c of candidates) if (existsSync5(c)) return c;
2612
+ for (const c of candidates) if (existsSync6(c)) return c;
2303
2613
  }
2304
2614
  }
2305
2615
  for (const c of [
@@ -2307,35 +2617,45 @@ function findBash() {
2307
2617
  "C:\\Program Files\\Git\\usr\\bin\\bash.exe",
2308
2618
  "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
2309
2619
  "C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe"
2310
- ]) if (existsSync5(c)) return c;
2620
+ ]) if (existsSync6(c)) return c;
2311
2621
  if (hasBin2("bash")) return "bash";
2312
2622
  return null;
2313
2623
  }
2314
2624
  function resolveTarget(opt) {
2315
2625
  if (opt.dir) return resolve4(opt.dir);
2316
2626
  if (process.env.IJFW_HOME) return resolve4(process.env.IJFW_HOME);
2317
- return join5(homedir3(), ".ijfw");
2627
+ return join6(homedir3(), ".ijfw");
2318
2628
  }
2319
2629
  function runCheck(cmd, args, opts) {
2320
2630
  const r = spawnSync2(cmd, args, { encoding: "utf8", ...opts });
2321
2631
  return { status: r.status, stdout: r.stdout || "", stderr: r.stderr || "", spawnError: r.error?.code, signal: r.signal };
2322
2632
  }
2633
+ function looksLikeIjfwInstall(dir) {
2634
+ try {
2635
+ if (basename2(resolve4(dir)) === ".ijfw") return true;
2636
+ if (existsSync6(join6(dir, "install-ledger.json"))) return true;
2637
+ if (existsSync6(join6(dir, "install-method"))) return true;
2638
+ if (existsSync6(join6(dir, "mcp-server", "src", "server.js")) && existsSync6(join6(dir, "claude"))) return true;
2639
+ } catch {
2640
+ }
2641
+ return false;
2642
+ }
2323
2643
  function cloneOrPull(dir, branch) {
2324
2644
  if (skipNetwork()) {
2325
- if (existsSync5(dir)) {
2645
+ if (existsSync6(dir)) {
2326
2646
  return "skipped-network";
2327
2647
  }
2328
2648
  throw new Error(
2329
2649
  `IJFW_SKIP_NETWORK=1 set but cloneOrPull needs network: target directory ${dir} does not exist. Pre-seed the directory before setting IJFW_SKIP_NETWORK, or unset the env var.`
2330
2650
  );
2331
2651
  }
2332
- if (!existsSync5(dir)) {
2333
- mkdirSync4(dir, { recursive: true });
2652
+ if (!existsSync6(dir)) {
2653
+ mkdirSync5(dir, { recursive: true });
2334
2654
  const r = spawnSync2("git", ["clone", "--depth", "1", "--branch", branch, DEFAULT_REPO, dir], { stdio: "inherit" });
2335
2655
  if (r.status !== 0) throw new Error(`IJFW repo fetch did not complete (exit ${r.status}) -- check network access and retry.`);
2336
2656
  return "cloned";
2337
2657
  }
2338
- const hasGit = existsSync5(join5(dir, ".git"));
2658
+ const hasGit = existsSync6(join6(dir, ".git"));
2339
2659
  if (hasGit) {
2340
2660
  const { status: remoteStatus, stdout, stderr: remoteStderr, spawnError: remoteSpawnError, signal: remoteSignal } = runCheck("git", ["-C", dir, "remote", "get-url", "origin"]);
2341
2661
  if (remoteSpawnError) console.warn(` git spawn error (${remoteSpawnError}) -- check git is on PATH`);
@@ -2359,6 +2679,13 @@ function cloneOrPull(dir, branch) {
2359
2679
  console.log(` origin migration: ${currentOrigin} -> ${DEFAULT_REPO}`);
2360
2680
  }
2361
2681
  }
2682
+ const CANONICAL_PATTERN = /^https:\/\/github\.com\/ferroxlabs\/ijfw(\.git)?\/?$/i;
2683
+ const isIjfwOrigin = CANONICAL_PATTERN.test(currentOrigin) || STALE_PATTERNS.some((re) => re.test(currentOrigin));
2684
+ if (!isIjfwOrigin && !looksLikeIjfwInstall(dir)) {
2685
+ throw new Error(
2686
+ `Refusing to update ${dir}: it is a git checkout of "${currentOrigin}", not an IJFW install. Check your --dir / IJFW_HOME setting, or remove the directory and retry.`
2687
+ );
2688
+ }
2362
2689
  const fetch = spawnSync2("git", ["-C", dir, "fetch", "--depth", "1", "origin", branch], { stdio: "inherit" });
2363
2690
  if (fetch.status !== 0) throw new Error(`IJFW fetch did not complete (exit ${fetch.status}) -- check network access and retry.`);
2364
2691
  const co = spawnSync2("git", ["-C", dir, "checkout", "-f", "FETCH_HEAD"], { stdio: "inherit" });
@@ -2385,32 +2712,53 @@ function cloneOrPull(dir, branch) {
2385
2712
  ".ijfw"
2386
2713
  // internal — recall counter, indexes, layout version
2387
2714
  ];
2715
+ if (!looksLikeIjfwInstall(dir)) {
2716
+ let entries = null;
2717
+ try {
2718
+ entries = readdirSync3(dir);
2719
+ } catch {
2720
+ }
2721
+ if (entries && entries.length === 0) {
2722
+ const r = spawnSync2("git", ["clone", "--depth", "1", "--branch", branch, DEFAULT_REPO, dir], { stdio: "inherit" });
2723
+ if (r.status !== 0) throw new Error(`IJFW repo fetch did not complete (exit ${r.status}) -- check network access and retry.`);
2724
+ return "cloned";
2725
+ }
2726
+ throw new Error(
2727
+ `Refusing to replace ${dir}: it exists but does not look like an IJFW install (no install ledger, install-method file, or IJFW checkout markers). Check your --dir / IJFW_HOME setting, or move the directory aside and retry.`
2728
+ );
2729
+ }
2388
2730
  const backupDir = dir + ".bak." + Date.now();
2389
2731
  renameSync3(dir, backupDir);
2390
2732
  try {
2391
2733
  const r = spawnSync2("git", ["clone", "--depth", "1", "--branch", branch, DEFAULT_REPO, dir], { stdio: "inherit" });
2392
2734
  if (r.status !== 0) throw new Error(`IJFW repo fetch did not complete (exit ${r.status}) -- check network access and retry.`);
2393
- let restoredCount = 0;
2735
+ const restoredItems = [];
2394
2736
  for (const item of RESTORE_ALLOWLIST) {
2395
- const src = join5(backupDir, item);
2396
- if (existsSync5(src)) {
2397
- const dst = join5(dir, item);
2398
- if (existsSync5(dst)) rmSync2(dst, { recursive: true, force: true });
2737
+ const src = join6(backupDir, item);
2738
+ if (existsSync6(src)) {
2739
+ const dst = join6(dir, item);
2399
2740
  try {
2741
+ if (existsSync6(dst)) rmSync2(dst, { recursive: true, force: true });
2400
2742
  cpSync2(src, dst, { recursive: true, dereference: false });
2401
- rmSync2(src, { recursive: true, force: true });
2402
- restoredCount++;
2743
+ restoredItems.push(item);
2403
2744
  } catch (cpErr) {
2404
2745
  const msg = cpErr && cpErr.message ? cpErr.message : String(cpErr);
2405
2746
  throw new Error(
2406
- `IJFW restore: cpSync failed for "${item}" (${msg}). Your data is still intact under: ${backupDir}. Move it back into ${dir} manually after diagnosing the copy failure.`
2747
+ `IJFW restore: copy failed for "${item}" (${msg}). Your data is still intact under: ${backupDir}. The previous state of ${dir} will be restored from it.`
2407
2748
  );
2408
2749
  }
2409
2750
  }
2410
2751
  }
2752
+ const restoredCount = restoredItems.length;
2753
+ for (const item of restoredItems) {
2754
+ try {
2755
+ rmSync2(join6(backupDir, item), { recursive: true, force: true });
2756
+ } catch {
2757
+ }
2758
+ }
2411
2759
  let backupResidual = [];
2412
2760
  try {
2413
- backupResidual = readdirSync2(backupDir);
2761
+ backupResidual = readdirSync3(backupDir);
2414
2762
  } catch {
2415
2763
  }
2416
2764
  if (backupResidual.length === 0) {
@@ -2422,13 +2770,20 @@ function cloneOrPull(dir, branch) {
2422
2770
  }
2423
2771
  return "updated";
2424
2772
  } catch (err) {
2425
- if (existsSync5(dir)) rmSync2(dir, { recursive: true, force: true });
2426
- renameSync3(backupDir, dir);
2773
+ try {
2774
+ if (existsSync6(dir)) rmSync2(dir, { recursive: true, force: true });
2775
+ renameSync3(backupDir, dir);
2776
+ } catch (rollbackErr) {
2777
+ const msg = rollbackErr && rollbackErr.message ? rollbackErr.message : String(rollbackErr);
2778
+ console.error(
2779
+ ` [!] rollback failed (${msg}). Your original data is preserved at: ${backupDir}. Move it back to ${dir} manually.`
2780
+ );
2781
+ }
2427
2782
  throw err;
2428
2783
  }
2429
2784
  }
2430
2785
  async function runInstallScript(dir) {
2431
- const canonicalDir = join5(homedir3(), ".ijfw");
2786
+ const canonicalDir = join6(homedir3(), ".ijfw");
2432
2787
  const isCustomDir = resolve4(dir) !== canonicalDir;
2433
2788
  const { runInstall: runInstall2 } = await Promise.resolve().then(() => (init_install_flow(), install_flow_exports));
2434
2789
  await runInstall2({
@@ -2449,9 +2804,25 @@ async function main() {
2449
2804
  process.exit(1);
2450
2805
  }
2451
2806
  const target = resolveTarget(opts);
2452
- const createdThisRun = !existsSync5(target);
2807
+ if (opts.dryRun) {
2808
+ const { CANONICAL_ORDER: CANONICAL_ORDER2 } = await Promise.resolve().then(() => (init_install_flow(), install_flow_exports));
2809
+ const { renderPlan: renderPlan2 } = await Promise.resolve().then(() => (init_install_ledger(), install_ledger_exports));
2810
+ console.log(`IJFW install target: ${target}`);
2811
+ console.log("");
2812
+ console.log(renderPlan2(CANONICAL_ORDER2));
2813
+ process.exit(0);
2814
+ }
2815
+ const createdThisRun = !existsSync6(target);
2816
+ let platformConfigPhase = false;
2453
2817
  const sigint = () => {
2454
- if (createdThisRun && existsSync5(target)) {
2818
+ if (platformConfigPhase) {
2819
+ console.warn(
2820
+ `
2821
+ [!] install interrupted while platform configs were being written. Some platform configs may already reference ${target} -- the partial install was kept so they keep working. Rerun \`npx -p @ijfw/install ijfw-install\` to complete it, or \`ijfw-uninstall\` to remove IJFW from all platform configs.`
2822
+ );
2823
+ process.exit(130);
2824
+ }
2825
+ if (createdThisRun && existsSync6(target)) {
2455
2826
  try {
2456
2827
  rmSync2(target, { recursive: true, force: true });
2457
2828
  } catch (err) {
@@ -2470,9 +2841,10 @@ async function main() {
2470
2841
  console.log(` version: ${ref}`);
2471
2842
  const action = cloneOrPull(target, ref);
2472
2843
  console.log(` repo ${action}`);
2844
+ platformConfigPhase = true;
2473
2845
  await runInstallScript(target);
2474
2846
  console.log(" platform configs applied");
2475
- const canonicalDir = join5(homedir3(), ".ijfw");
2847
+ const canonicalDir = join6(homedir3(), ".ijfw");
2476
2848
  const isCustomDir = process.env.IJFW_CUSTOM_DIR === "1" || resolve4(target) !== canonicalDir;
2477
2849
  if (!opts.noMarketplace && !isCustomDir) {
2478
2850
  const settingsPath = claudeSettingsPath();
@@ -2510,5 +2882,6 @@ if (isDirectRun()) {
2510
2882
  }
2511
2883
  export {
2512
2884
  findBash,
2885
+ looksLikeIjfwInstall,
2513
2886
  resolveBranchOrTag
2514
2887
  };