@ijfw/install 1.2.8 → 1.2.10

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/ijfw.js CHANGED
@@ -483,7 +483,7 @@ async function run5(ctx) {
483
483
  const res = spawnSync5(
484
484
  "npx",
485
485
  ["--yes", `publint@${ver}`, "--strict"],
486
- { encoding: "utf8", cwd: ctx.repoRoot + "/installer", timeout: 3e4 }
486
+ { encoding: "utf8", cwd: ctx.repoRoot + "/installer", timeout: 9e4 }
487
487
  );
488
488
  const durationMs = Date.now() - t0;
489
489
  const output = (res.stdout || "") + (res.stderr || "");
@@ -727,7 +727,7 @@ __export(pack_smoke_exports, {
727
727
  severity: () => severity10
728
728
  });
729
729
  import { spawnSync as spawnSync10 } from "node:child_process";
730
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, mkdirSync, writeFileSync as writeFileSync2, readdirSync as readdirSync3 } from "node:fs";
730
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, mkdirSync, writeFileSync as writeFileSync2, readdirSync as readdirSync3, existsSync } from "node:fs";
731
731
  import { join as join6, resolve } from "node:path";
732
732
  import { tmpdir as tmpdir2 } from "node:os";
733
733
  async function run10(ctx) {
@@ -803,13 +803,9 @@ async function run10(ctx) {
803
803
  ];
804
804
  let binPath = null;
805
805
  for (const c2 of binCandidates) {
806
- try {
807
- const r = spawnSync10("ls", [c2], { encoding: "utf8" });
808
- if (r.status === 0) {
809
- binPath = c2;
810
- break;
811
- }
812
- } catch {
806
+ if (existsSync(c2)) {
807
+ binPath = c2;
808
+ break;
813
809
  }
814
810
  }
815
811
  if (!binPath) {
@@ -883,7 +879,7 @@ __export(upgrade_smoke_exports, {
883
879
  severity: () => severity11
884
880
  });
885
881
  import { spawnSync as spawnSync11 } from "node:child_process";
886
- import { mkdtempSync as mkdtempSync3, rmSync as rmSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, readFileSync, existsSync } from "node:fs";
882
+ import { mkdtempSync as mkdtempSync3, rmSync as rmSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, readFileSync, existsSync as existsSync2 } from "node:fs";
887
883
  import { join as join7, resolve as resolve2 } from "node:path";
888
884
  import { tmpdir as tmpdir3 } from "node:os";
889
885
  async function run11(ctx) {
@@ -952,8 +948,7 @@ async function run11(ctx) {
952
948
  ];
953
949
  let installerBin = null;
954
950
  for (const c2 of binCandidates) {
955
- const check = spawnSync11("ls", [c2], { encoding: "utf8" });
956
- if (check.status === 0) {
951
+ if (existsSync2(c2)) {
957
952
  installerBin = c2;
958
953
  break;
959
954
  }
@@ -968,7 +963,7 @@ async function run11(ctx) {
968
963
  };
969
964
  }
970
965
  const settingsPath = join7(claudeDir, "settings.json");
971
- if (existsSync(settingsPath)) {
966
+ if (existsSync2(settingsPath)) {
972
967
  let settings;
973
968
  try {
974
969
  settings = JSON.parse(readFileSync(settingsPath, "utf8"));
@@ -993,7 +988,7 @@ async function run11(ctx) {
993
988
  }
994
989
  }
995
990
  const marketplaceSrc = join7(installerDir, "src", "marketplace.js");
996
- if (existsSync(marketplaceSrc)) {
991
+ if (existsSync2(marketplaceSrc)) {
997
992
  const src = readFileSync(marketplaceSrc, "utf8");
998
993
  const registersCorrectKey = src.includes("'ijfw@ijfw'") || src.includes('"ijfw@ijfw"');
999
994
  const registersWrongKey = /enabledPlugins\[['"]ijfw-core@ijfw['"]\]\s*=\s*true/.test(src);
@@ -1049,7 +1044,7 @@ var preflight_exports = {};
1049
1044
  __export(preflight_exports, {
1050
1045
  runPreflightCommand: () => runPreflightCommand
1051
1046
  });
1052
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
1047
+ import { readFileSync as readFileSync2, existsSync as existsSync3 } from "node:fs";
1053
1048
  import { join as join8, dirname } from "node:path";
1054
1049
  import { fileURLToPath } from "node:url";
1055
1050
  function printHelp() {
@@ -1091,7 +1086,7 @@ function loadVersions(repoRoot2) {
1091
1086
  join8(repoRoot2, ".ijfw", "preflight-versions.json")
1092
1087
  ];
1093
1088
  for (const f of candidates) {
1094
- if (existsSync2(f)) {
1089
+ if (existsSync3(f)) {
1095
1090
  try {
1096
1091
  return JSON.parse(readFileSync2(f, "utf8"));
1097
1092
  } catch {
@@ -2419,14 +2414,14 @@ Please report this to https://github.com/markedjs/marked.`, e) {
2419
2414
  // src/ijfw.js
2420
2415
  import { dirname as dirname2, join as join9, resolve as resolve3, basename } from "node:path";
2421
2416
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2422
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, copyFileSync, readdirSync as readdirSync4, rmSync as rmSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
2417
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, copyFileSync, readdirSync as readdirSync4, rmSync as rmSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
2423
2418
  import { homedir, platform as platform2 } from "node:os";
2424
2419
  import { spawnSync as spawnSync12 } from "node:child_process";
2425
2420
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
2426
2421
  function repoRoot() {
2427
2422
  let dir = __dirname2;
2428
2423
  for (let i = 0; i < 6; i++) {
2429
- if (existsSync3(join9(dir, "package.json")) && existsSync3(join9(dir, ".git"))) return dir;
2424
+ if (existsSync4(join9(dir, "package.json")) && existsSync4(join9(dir, ".git"))) return dir;
2430
2425
  dir = resolve3(dir, "..");
2431
2426
  }
2432
2427
  return process.cwd();
@@ -2442,7 +2437,7 @@ COMMANDS
2442
2437
  install Install IJFW into your AI coding agents
2443
2438
  uninstall Remove IJFW from your AI coding agents
2444
2439
  help Open the full IJFW guide (terminal, or --browser for rendered)
2445
- preflight Run 11-gate quality pipeline before publishing
2440
+ preflight Run 12-gate quality pipeline before publishing
2446
2441
  dashboard Start / stop / check the local observability dashboard
2447
2442
  design Manage the visual design companion
2448
2443
  doctor Diagnose IJFW installation health
@@ -2453,19 +2448,21 @@ COMMANDS
2453
2448
  }
2454
2449
  function doctorCheck(cmd, args) {
2455
2450
  const r = spawnSync12(cmd, args, { encoding: "utf8" });
2456
- return r.status === 0 ? r.stdout.split("\n")[0].trim() : "not found";
2451
+ if (r.status === 0) return r.stdout.split("\n")[0].trim();
2452
+ if (r.status === 127 || r.error && r.error.code === "ENOENT") return "not found";
2453
+ return `exit ${r.status} (may be transient)`;
2457
2454
  }
2458
2455
  function findCli() {
2459
2456
  const candidates = [
2460
2457
  join9(repoRoot(), "mcp-server", "src", "cross-orchestrator-cli.js"),
2461
2458
  join9(homedir(), ".ijfw", "mcp-server", "src", "cross-orchestrator-cli.js")
2462
2459
  ];
2463
- return candidates.find((p) => existsSync3(p)) || null;
2460
+ return candidates.find((p) => existsSync4(p)) || null;
2464
2461
  }
2465
2462
  function delegateToCli(argTail) {
2466
2463
  const cli = findCli();
2467
2464
  if (!cli) return false;
2468
- const r = spawnSync12("node", [cli, ...argTail], { stdio: "inherit" });
2465
+ const r = spawnSync12(process.execPath, [cli, ...argTail], { stdio: "inherit" });
2469
2466
  process.exit(r.status ?? 1);
2470
2467
  }
2471
2468
  async function main() {
@@ -2516,16 +2513,21 @@ async function main() {
2516
2513
  case "dashboard": {
2517
2514
  const dashSub = argv[3];
2518
2515
  const root = repoRoot();
2516
+ const ijfwHome = join9(homedir(), ".ijfw");
2517
+ const findInTree = (...rel) => {
2518
+ const candidates = [join9(root, ...rel), join9(ijfwHome, ...rel)];
2519
+ return candidates.find((p) => existsSync4(p)) || null;
2520
+ };
2519
2521
  if (dashSub === "start" || dashSub === "stop" || dashSub === "status") {
2520
- const dashBin = join9(root, "mcp-server", "bin", "ijfw-dashboard");
2521
- if (existsSync3(dashBin)) {
2522
+ const dashBin = findInTree("mcp-server", "bin", "ijfw-dashboard");
2523
+ if (dashBin) {
2522
2524
  const r = spawnSync12("node", [dashBin, dashSub, ...argv.slice(4)], { stdio: "inherit" });
2523
2525
  process.exit(r.status ?? 0);
2524
2526
  } else {
2525
- const serverJs = join9(root, "mcp-server", "src", "dashboard-server.js");
2526
- if (dashSub === "start" && existsSync3(serverJs)) {
2527
+ const serverJs = findInTree("mcp-server", "src", "dashboard-server.js");
2528
+ if (dashSub === "start" && serverJs) {
2527
2529
  const { spawn } = await import("node:child_process");
2528
- const child = spawn(process.execPath, [serverJs, "--daemon"], {
2530
+ const child = spawn(process.execPath, [serverJs, "start", "--daemon"], {
2529
2531
  detached: true,
2530
2532
  stdio: "ignore"
2531
2533
  });
@@ -2533,12 +2535,12 @@ async function main() {
2533
2535
  console.log("Dashboard starting... (check: ijfw dashboard status)");
2534
2536
  process.exit(0);
2535
2537
  }
2536
- console.log("[ijfw] Dashboard bin not found. Run from the IJFW repo root.");
2538
+ console.log("[ijfw] Dashboard not found. Try `ijfw-install` to deploy ~/.ijfw/, or run from the IJFW repo root.");
2537
2539
  process.exit(1);
2538
2540
  }
2539
2541
  } else if (dashSub === "render" || !dashSub) {
2540
- const binJs = join9(root, "scripts", "dashboard", "bin.js");
2541
- if (existsSync3(binJs)) {
2542
+ const binJs = findInTree("scripts", "dashboard", "bin.js");
2543
+ if (binJs) {
2542
2544
  const r = spawnSync12("node", [binJs, ...argv.slice(dashSub ? 4 : 3)], { stdio: "inherit" });
2543
2545
  process.exit(r.status ?? 0);
2544
2546
  } else {
@@ -2562,7 +2564,7 @@ async function main() {
2562
2564
  process.exit(1);
2563
2565
  }
2564
2566
  const abs = resolve3(filePath);
2565
- if (!existsSync3(abs)) {
2567
+ if (!existsSync4(abs)) {
2566
2568
  console.error(`File not found: ${abs}`);
2567
2569
  process.exit(1);
2568
2570
  }
@@ -2588,7 +2590,7 @@ async function main() {
2588
2590
  resolve3(__dirname2, "..", "docs", "GUIDE.md"),
2589
2591
  join9(homedir(), ".ijfw", "docs", "GUIDE.md")
2590
2592
  ];
2591
- const guidePath = candidates.find((p) => existsSync3(p));
2593
+ const guidePath = candidates.find((p) => existsSync4(p));
2592
2594
  if (!guidePath) {
2593
2595
  console.error("[ijfw] Guide not found. Run `ijfw install` to fetch the full guide, or visit https://gitlab.com/therealseandonahoe/ijfw/-/blob/main/docs/GUIDE.md");
2594
2596
  process.exit(1);
@@ -2598,7 +2600,7 @@ async function main() {
2598
2600
  const assetsSrc = join9(dirname2(guidePath), "guide", "assets");
2599
2601
  const outDir = join9(homedir(), ".ijfw", "guide");
2600
2602
  mkdirSync3(join9(outDir, "assets"), { recursive: true });
2601
- if (existsSync3(assetsSrc)) {
2603
+ if (existsSync4(assetsSrc)) {
2602
2604
  for (const f of readdirSync4(assetsSrc)) {
2603
2605
  copyFileSync(join9(assetsSrc, f), join9(outDir, "assets", f));
2604
2606
  }
@@ -2630,7 +2632,10 @@ async function main() {
2630
2632
  }
2631
2633
  const hasLess = spawnSync12("less", ["-V"], { stdio: "ignore" }).status === 0;
2632
2634
  if (hasLess) {
2633
- spawnSync12("less", ["-R", guidePath], { stdio: "inherit" });
2635
+ const lessRes = spawnSync12("less", ["-R", guidePath], { stdio: "inherit" });
2636
+ if (lessRes.status !== 0 && lessRes.status !== null) {
2637
+ process.stdout.write(readFileSync3(guidePath, "utf8"));
2638
+ }
2634
2639
  } else {
2635
2640
  process.stdout.write(readFileSync3(guidePath, "utf8"));
2636
2641
  }
package/dist/install.js CHANGED
@@ -8,9 +8,23 @@ import { homedir as homedir2, platform } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  // src/marketplace.js
11
- import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
11
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
12
+ import { randomBytes } from "node:crypto";
12
13
  import { dirname, join, resolve } from "node:path";
13
14
  import { homedir } from "node:os";
15
+ function atomicWriteJson(path, data) {
16
+ const tmp = `${path}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
17
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
18
+ try {
19
+ renameSync(tmp, path);
20
+ } catch (err) {
21
+ try {
22
+ unlinkSync(tmp);
23
+ } catch {
24
+ }
25
+ throw new Error(`atomic write failed for ${path}: ${err.message}`);
26
+ }
27
+ }
14
28
  function claudeSettingsPath() {
15
29
  return join(homedir(), ".claude", "settings.json");
16
30
  }
@@ -92,9 +106,7 @@ function mergeMarketplace(settingsPath = claudeSettingsPath(), options = {}) {
92
106
  delete settings.enabledPlugins["ijfw-core@ijfw"];
93
107
  }
94
108
  settings.enabledPlugins["ijfw@ijfw"] = true;
95
- const tmp = settingsPath + ".tmp";
96
- writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
97
- renameSync(tmp, settingsPath);
109
+ atomicWriteJson(settingsPath, settings);
98
110
  return settings;
99
111
  }
100
112
 
@@ -178,8 +190,10 @@ function preflight() {
178
190
  return issues;
179
191
  }
180
192
  function hasBin(bin) {
181
- const res = spawnSync(bin, ["--version"], { stdio: "ignore" });
182
- return res.status === 0 || res.status === null ? res.error ? false : true : false;
193
+ const res = spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 3e3 });
194
+ if (res.error && res.error.code === "ENOENT") return false;
195
+ if (res.status === 0) return true;
196
+ return res.error == null;
183
197
  }
184
198
  function findBash() {
185
199
  if (hasBin("bash") && platform() !== "win32") return "bash";
@@ -214,7 +228,7 @@ function resolveTarget(opt) {
214
228
  }
215
229
  function runCheck(cmd, args, opts) {
216
230
  const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
217
- return { status: r.status, stdout: r.stdout || "" };
231
+ return { status: r.status, stdout: r.stdout || "", stderr: r.stderr || "", spawnError: r.error?.code, signal: r.signal };
218
232
  }
219
233
  function cloneOrPull(dir, branch) {
220
234
  if (!existsSync2(dir)) {
@@ -225,12 +239,25 @@ function cloneOrPull(dir, branch) {
225
239
  }
226
240
  const hasGit = existsSync2(join2(dir, ".git"));
227
241
  if (hasGit) {
228
- const { status: remoteStatus, stdout } = runCheck("git", ["-C", dir, "remote", "get-url", "origin"]);
242
+ const { status: remoteStatus, stdout, stderr: remoteStderr, spawnError: remoteSpawnError, signal: remoteSignal } = runCheck("git", ["-C", dir, "remote", "get-url", "origin"]);
243
+ if (remoteSpawnError) console.warn(` git spawn error (${remoteSpawnError}) -- check git is on PATH`);
244
+ else if (remoteSignal) console.warn(` git exited on signal ${remoteSignal}`);
245
+ else if (remoteStatus !== 0 && remoteStderr) console.warn(` git remote get-url: ${remoteStderr.slice(0, 120).trim()}`);
229
246
  if (remoteStatus === 0) {
247
+ const STALE_ORIGINS = [
248
+ "https://github.com/seandonahoe/ijfw.git",
249
+ "https://github.com/seandonahoe/ijfw",
250
+ "https://github.com/seandonahoe/ijfw/",
251
+ "https://github.com/seandonahoe/ijfw.git/"
252
+ ];
230
253
  const currentOrigin = (stdout || "").trim();
231
- if (currentOrigin && currentOrigin !== DEFAULT_REPO) {
232
- console.log(` origin migration: ${currentOrigin} -> ${DEFAULT_REPO}`);
233
- spawnSync("git", ["-C", dir, "remote", "set-url", "origin", DEFAULT_REPO], { stdio: "inherit" });
254
+ if (STALE_ORIGINS.includes(currentOrigin)) {
255
+ const setUrl = spawnSync("git", ["-C", dir, "remote", "set-url", "origin", DEFAULT_REPO], { stdio: "inherit" });
256
+ if (setUrl.status !== 0) {
257
+ console.warn(` [!] origin migration failed -- could not repoint ${currentOrigin} to ${DEFAULT_REPO}`);
258
+ } else {
259
+ console.log(` origin migration: ${currentOrigin} -> ${DEFAULT_REPO}`);
260
+ }
234
261
  }
235
262
  const fetch = spawnSync("git", ["-C", dir, "fetch", "--depth", "1", "origin", branch], { stdio: "inherit" });
236
263
  if (fetch.status !== 0) throw new Error(`IJFW fetch did not complete (exit ${fetch.status}) -- check network access and retry.`);
package/dist/uninstall.js CHANGED
@@ -1,15 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/uninstall.js
4
- import { existsSync as existsSync2, rmSync, cpSync, mkdtempSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, readdirSync } from "node:fs";
4
+ import { existsSync as existsSync2, rmSync, cpSync, mkdtempSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync as renameSync2, unlinkSync as unlinkSync2, readdirSync } from "node:fs";
5
5
  import { resolve as resolve2, join as join2 } from "node:path";
6
6
  import { homedir as homedir2, tmpdir } from "node:os";
7
7
  import { spawnSync } from "node:child_process";
8
8
 
9
9
  // src/marketplace.js
10
- import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
10
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
11
+ import { randomBytes } from "node:crypto";
11
12
  import { dirname, join, resolve } from "node:path";
12
13
  import { homedir } from "node:os";
14
+ function atomicWriteJson(path, data) {
15
+ const tmp = `${path}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
16
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
17
+ try {
18
+ renameSync(tmp, path);
19
+ } catch (err) {
20
+ try {
21
+ unlinkSync(tmp);
22
+ } catch {
23
+ }
24
+ throw new Error(`atomic write failed for ${path}: ${err.message}`);
25
+ }
26
+ }
13
27
  function claudeSettingsPath() {
14
28
  return join(homedir(), ".claude", "settings.json");
15
29
  }
@@ -76,13 +90,24 @@ function unmergeMarketplace(settingsPath = claudeSettingsPath()) {
76
90
  if ("ijfw-core@ijfw" in settings.enabledPlugins) delete settings.enabledPlugins["ijfw-core@ijfw"];
77
91
  if ("ijfw@ijfw" in settings.enabledPlugins) delete settings.enabledPlugins["ijfw@ijfw"];
78
92
  }
79
- const tmp = settingsPath + ".tmp";
80
- writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
81
- renameSync(tmp, settingsPath);
93
+ atomicWriteJson(settingsPath, settings);
82
94
  return settings;
83
95
  }
84
96
 
85
97
  // src/uninstall.js
98
+ function writeAtomic(target, content) {
99
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
100
+ writeFileSync2(tmp, content);
101
+ try {
102
+ renameSync2(tmp, target);
103
+ } catch (err) {
104
+ try {
105
+ unlinkSync2(tmp);
106
+ } catch {
107
+ }
108
+ throw new Error(`atomic write failed for ${target}: ${err.message}`);
109
+ }
110
+ }
86
111
  function parseArgs(argv) {
87
112
  const out = { dir: null, purge: false, noMarketplace: false };
88
113
  for (let i = 2; i < argv.length; i++) {
@@ -128,7 +153,7 @@ function removeTomlSection(p) {
128
153
  if (skip && line.startsWith("[") && !line.startsWith("[mcp_servers.ijfw-memory]")) skip = false;
129
154
  if (!skip) out.push(line);
130
155
  }
131
- writeFileSync2(p, out.join("\n"));
156
+ writeAtomic(p, out.join("\n") + "\n");
132
157
  return true;
133
158
  }
134
159
  function removeJsonMcpEntry(p) {
@@ -144,7 +169,7 @@ function removeJsonMcpEntry(p) {
144
169
  if (doc.mcpServers && doc.mcpServers["ijfw-memory"]) {
145
170
  backupFile(p);
146
171
  delete doc.mcpServers["ijfw-memory"];
147
- writeFileSync2(p, JSON.stringify(doc, null, 2) + "\n");
172
+ writeAtomic(p, JSON.stringify(doc, null, 2) + "\n");
148
173
  changed = true;
149
174
  }
150
175
  return changed;
@@ -162,7 +187,7 @@ function removeCodexHooks(p) {
162
187
  const after = doc.filter((h) => !(h && h._ijfw));
163
188
  if (after.length === before) return false;
164
189
  backupFile(p);
165
- writeFileSync2(p, JSON.stringify(after, null, 2) + "\n");
190
+ writeAtomic(p, JSON.stringify(after, null, 2) + "\n");
166
191
  return true;
167
192
  }
168
193
  if (!doc || typeof doc !== "object" || !doc.hooks) return false;
@@ -180,7 +205,7 @@ function removeCodexHooks(p) {
180
205
  }
181
206
  if (!changed) return false;
182
207
  backupFile(p);
183
- writeFileSync2(p, JSON.stringify(doc, null, 2) + "\n");
208
+ writeAtomic(p, JSON.stringify(doc, null, 2) + "\n");
184
209
  return true;
185
210
  }
186
211
  if (Array.isArray(doc.hooks)) {
@@ -188,7 +213,7 @@ function removeCodexHooks(p) {
188
213
  doc.hooks = doc.hooks.filter((h) => !(h && h._ijfw));
189
214
  if (doc.hooks.length === before) return false;
190
215
  backupFile(p);
191
- writeFileSync2(p, JSON.stringify(doc, null, 2) + "\n");
216
+ writeAtomic(p, JSON.stringify(doc, null, 2) + "\n");
192
217
  return true;
193
218
  }
194
219
  return false;
@@ -224,7 +249,7 @@ import os; os.replace(p + ".tmp", p)
224
249
  );
225
250
  if (stripped === raw) return false;
226
251
  backupFile(p);
227
- writeFileSync2(p, stripped);
252
+ writeAtomic(p, stripped);
228
253
  return true;
229
254
  }
230
255
  function removeIjfwSkills(dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ijfw/install",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "description": "One-command installer for IJFW -- the AI efficiency layer. One install, every AI coding agent, zero config.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/install.ps1 CHANGED
@@ -30,10 +30,17 @@ function Test-Command($cmd) {
30
30
 
31
31
  function Get-Target {
32
32
  if ($Dir) {
33
- $resolved = Resolve-Path -LiteralPath $Dir -ErrorAction SilentlyContinue
34
- if ($resolved) { return $resolved.Path } else { return $Dir }
33
+ # Expand %APPDATA%-style env vars and leading ~ before resolving.
34
+ $path = [System.Environment]::ExpandEnvironmentVariables($Dir)
35
+ if ($path -like '~*') { $path = $path -replace '^~', $env:USERPROFILE }
36
+ $resolved = Resolve-Path -LiteralPath $path -ErrorAction SilentlyContinue
37
+ if ($resolved) { return $resolved.Path } else { return $path }
38
+ }
39
+ if ($env:IJFW_HOME) {
40
+ $path = [System.Environment]::ExpandEnvironmentVariables($env:IJFW_HOME)
41
+ if ($path -like '~*') { $path = $path -replace '^~', $env:USERPROFILE }
42
+ return $path
35
43
  }
36
- if ($env:IJFW_HOME) { return $env:IJFW_HOME }
37
44
  return Join-Path $env:USERPROFILE ".ijfw"
38
45
  }
39
46
 
@@ -83,8 +90,15 @@ function Invoke-CloneOrPull($target, $branch) {
83
90
  # Upgrade path.
84
91
  $hasGit = Test-Path (Join-Path $target ".git")
85
92
  if ($hasGit) {
86
- & git -C $target remote get-url origin 2>$null | Out-Null
93
+ $currentOriginRaw = & git -C $target remote get-url origin 2>$null
87
94
  if ($LASTEXITCODE -eq 0) {
95
+ # Self-heal stale origin URLs across host migrations (1.2.9 parity with install.js).
96
+ # Without this, Windows users on the pre-GitLab origin still 404 on every upgrade.
97
+ $currentOrigin = ($currentOriginRaw | Out-String).Trim()
98
+ if ($currentOrigin -and $currentOrigin -ne $DEFAULT_REPO) {
99
+ Write-Host " origin migration: $currentOrigin -> $DEFAULT_REPO"
100
+ & git -C $target remote set-url origin $DEFAULT_REPO
101
+ }
88
102
  # fetch + hard checkout avoids ff-only failures from local divergence.
89
103
  & git -C $target fetch --depth 1 origin $branch
90
104
  if ($LASTEXITCODE -ne 0) { throw "Could not reach the IJFW repo (exit $LASTEXITCODE). Check your network connection and retry." }
@@ -95,9 +109,12 @@ function Invoke-CloneOrPull($target, $branch) {
95
109
  }
96
110
 
97
111
  # Broken repo or no origin: backup user data, re-clone, restore.
112
+ # Rename-Item -NewName requires a leaf name; Move-Item -Destination accepts an
113
+ # absolute path. Using Rename-Item with an absolute path silently lost user data
114
+ # on Windows pre-1.2.9 because PowerShell rejected the call.
98
115
  $ts = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
99
116
  $backupDir = "$target.bak.$ts"
100
- Rename-Item -LiteralPath $target -NewName $backupDir
117
+ Move-Item -LiteralPath $target -Destination $backupDir -Force
101
118
  try {
102
119
  & git clone --depth 1 --branch $branch $DEFAULT_REPO $target
103
120
  if ($LASTEXITCODE -ne 0) { throw "Could not reach the IJFW repo (exit $LASTEXITCODE). Check your network connection and retry." }
@@ -106,14 +123,14 @@ function Invoke-CloneOrPull($target, $branch) {
106
123
  if (Test-Path $src) {
107
124
  $dst = Join-Path $target $item
108
125
  if (Test-Path $dst) { Remove-Item -Recurse -Force -LiteralPath $dst }
109
- Move-Item -LiteralPath $src -Destination $dst
126
+ Move-Item -LiteralPath $src -Destination $dst -Force
110
127
  }
111
128
  }
112
129
  Remove-Item -Recurse -Force -LiteralPath $backupDir
113
130
  return "updated"
114
131
  } catch {
115
132
  if (Test-Path $target) { Remove-Item -Recurse -Force -LiteralPath $target }
116
- Rename-Item -LiteralPath $backupDir -NewName $target
133
+ Move-Item -LiteralPath $backupDir -Destination $target -Force
117
134
  throw
118
135
  }
119
136
  }
@@ -123,6 +140,13 @@ function Invoke-InstallScript($target) {
123
140
  if (-not (Test-Path $script)) { throw "The installer script is not at $script yet. Run the full install from a fresh clone." }
124
141
  $gitBash = Resolve-GitBash
125
142
  if (-not $gitBash) { throw "IJFW needs Git Bash to complete setup. Install Git for Windows (includes bash.exe) and rerun." }
143
+ # Propagate IJFW_HOME into the bash sub-call so a custom -Dir target lands
144
+ # platform configs / sentinels under the user's chosen tree instead of the
145
+ # default ~/.ijfw. Without this, .\install.ps1 -Dir D:\custom would scribble
146
+ # MCP entries into the user's real ~/.codex / ~/.gemini / ~/.claude pointing
147
+ # at the scratch dir.
148
+ $priorIjfwHome = $env:IJFW_HOME
149
+ $env:IJFW_HOME = $target
126
150
  Push-Location $target
127
151
  try {
128
152
  $env:IJFW_NONINTERACTIVE = if ($env:CI -or $Yes) { "1" } else { "" }
@@ -134,6 +158,7 @@ function Invoke-InstallScript($target) {
134
158
  } finally {
135
159
  Pop-Location
136
160
  Remove-Item Env:\IJFW_SKIP_CLOSER -ErrorAction SilentlyContinue
161
+ if ($priorIjfwHome) { $env:IJFW_HOME = $priorIjfwHome } else { Remove-Item Env:\IJFW_HOME -ErrorAction SilentlyContinue }
137
162
  }
138
163
  }
139
164
 
@@ -253,7 +278,29 @@ function Provision-Plugin {
253
278
  return
254
279
  }
255
280
  New-Item -ItemType Directory -Force -Path $Dst | Out-Null
256
- Copy-Item -Recurse -Force -Path (Join-Path $srcPath "*") -Destination $Dst
281
+ foreach ($srcItem in (Get-ChildItem -LiteralPath $srcPath -Recurse -File)) {
282
+ $rel = $srcItem.FullName.Substring($srcPath.Length).TrimStart('\','/')
283
+ $dstItem = Join-Path $Dst $rel
284
+ $dstDir = Split-Path -Parent $dstItem
285
+ if (-not (Test-Path $dstDir)) { New-Item -ItemType Directory -Force -Path $dstDir | Out-Null }
286
+ $srcMtime = $null
287
+ $dstMtime = $null
288
+ try {
289
+ if (Test-Path $srcItem.FullName) { $srcMtime = (Get-Item $srcItem.FullName -ErrorAction Stop).LastWriteTime }
290
+ if (Test-Path $dstItem) { $dstMtime = (Get-Item $dstItem -ErrorAction Stop).LastWriteTime }
291
+ } catch {
292
+ # File watcher race / lock / OneDrive offline -- treat as new install
293
+ $dstMtime = $null
294
+ }
295
+ if ($null -ne $dstMtime -and $null -ne $srcMtime -and $dstMtime -gt $srcMtime) {
296
+ # User has modified the destination; back it up before overwriting.
297
+ $ts = Get-Date -Format 'yyyyMMdd-HHmmss'
298
+ Copy-Item $dstItem "$dstItem.user-bak.$ts" -Force -ErrorAction SilentlyContinue
299
+ }
300
+ Copy-Item $srcItem.FullName $dstItem -Force
301
+ # Preserve source mtime so next install doesn't mistake our copy for a user edit.
302
+ try { (Get-Item $dstItem -ErrorAction Stop).LastWriteTime = $srcItem.LastWriteTime } catch {}
303
+ }
257
304
  }
258
305
 
259
306
  function Merge-PluginsEnabled {
@@ -280,6 +327,9 @@ function Merge-PluginsEnabled {
280
327
  # Strategy: try python3 first (has real YAML support); fall back to sentinel-anchored
281
328
  # regex that appends to a plugins.enabled: block or creates one.
282
329
 
330
+ # Sentinel: only run the regex fallback when Python was unavailable or failed.
331
+ $pythonAllSucceeded = $false
332
+
283
333
  $python = Get-Command python3 -ErrorAction SilentlyContinue
284
334
  if ($python) {
285
335
  $pyScript = @"
@@ -330,14 +380,15 @@ sys.exit(0)
330
380
  [System.IO.File]::WriteAllText($tmp, $pyScript)
331
381
  try {
332
382
  & python3 $tmp $ConfigPath $pluginName
333
- $ok = ($LASTEXITCODE -eq 0)
383
+ $pythonAllSucceeded = ($LASTEXITCODE -eq 0)
334
384
  } finally {
335
385
  Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
336
386
  }
337
- if ($ok) { return $true }
387
+ if ($pythonAllSucceeded) { return $true }
338
388
  }
339
389
 
340
390
  # Fallback: pure PowerShell sentinel-anchored regex approach.
391
+ # Only runs when Python was unavailable or exited non-zero.
341
392
  # If the file has a plugins.enabled: [...] line, splice in the name.
342
393
  # Otherwise append a plugins.enabled block.
343
394
  $enabledRe = [regex]'(?m)^([ \t]+enabled\s*:\s*\[)([^\]]*)\]'
@@ -380,7 +431,10 @@ function Merge-Marketplace {
380
431
  $raw = Get-Content -Raw -LiteralPath $settingsPath
381
432
  $cleaned = ConvertFrom-Jsonc $raw
382
433
  try {
383
- $parsed = ConvertFrom-Json $cleaned -ErrorAction Stop
434
+ # -Depth is PS 6+; PS 5.1 ignores it safely via splatting.
435
+ $cfjArgs = @{ InputObject = $cleaned; ErrorAction = 'Stop' }
436
+ if ($PSVersionTable.PSVersion.Major -ge 6) { $cfjArgs['Depth'] = 32 }
437
+ $parsed = ConvertFrom-Json @cfjArgs
384
438
  $settings = ConvertTo-Hashtable $parsed
385
439
  if ($null -eq $settings) { $settings = @{} }
386
440
  } catch {