@ijfw/install 1.2.9 → 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() {
@@ -2519,7 +2516,7 @@ async function main() {
2519
2516
  const ijfwHome = join9(homedir(), ".ijfw");
2520
2517
  const findInTree = (...rel) => {
2521
2518
  const candidates = [join9(root, ...rel), join9(ijfwHome, ...rel)];
2522
- return candidates.find((p) => existsSync3(p)) || null;
2519
+ return candidates.find((p) => existsSync4(p)) || null;
2523
2520
  };
2524
2521
  if (dashSub === "start" || dashSub === "stop" || dashSub === "status") {
2525
2522
  const dashBin = findInTree("mcp-server", "bin", "ijfw-dashboard");
@@ -2530,7 +2527,7 @@ async function main() {
2530
2527
  const serverJs = findInTree("mcp-server", "src", "dashboard-server.js");
2531
2528
  if (dashSub === "start" && serverJs) {
2532
2529
  const { spawn } = await import("node:child_process");
2533
- const child = spawn(process.execPath, [serverJs, "--daemon"], {
2530
+ const child = spawn(process.execPath, [serverJs, "start", "--daemon"], {
2534
2531
  detached: true,
2535
2532
  stdio: "ignore"
2536
2533
  });
@@ -2567,7 +2564,7 @@ async function main() {
2567
2564
  process.exit(1);
2568
2565
  }
2569
2566
  const abs = resolve3(filePath);
2570
- if (!existsSync3(abs)) {
2567
+ if (!existsSync4(abs)) {
2571
2568
  console.error(`File not found: ${abs}`);
2572
2569
  process.exit(1);
2573
2570
  }
@@ -2593,7 +2590,7 @@ async function main() {
2593
2590
  resolve3(__dirname2, "..", "docs", "GUIDE.md"),
2594
2591
  join9(homedir(), ".ijfw", "docs", "GUIDE.md")
2595
2592
  ];
2596
- const guidePath = candidates.find((p) => existsSync3(p));
2593
+ const guidePath = candidates.find((p) => existsSync4(p));
2597
2594
  if (!guidePath) {
2598
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");
2599
2596
  process.exit(1);
@@ -2603,7 +2600,7 @@ async function main() {
2603
2600
  const assetsSrc = join9(dirname2(guidePath), "guide", "assets");
2604
2601
  const outDir = join9(homedir(), ".ijfw", "guide");
2605
2602
  mkdirSync3(join9(outDir, "assets"), { recursive: true });
2606
- if (existsSync3(assetsSrc)) {
2603
+ if (existsSync4(assetsSrc)) {
2607
2604
  for (const f of readdirSync4(assetsSrc)) {
2608
2605
  copyFileSync(join9(assetsSrc, f), join9(outDir, "assets", f));
2609
2606
  }
@@ -2635,7 +2632,10 @@ async function main() {
2635
2632
  }
2636
2633
  const hasLess = spawnSync12("less", ["-V"], { stdio: "ignore" }).status === 0;
2637
2634
  if (hasLess) {
2638
- 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
+ }
2639
2639
  } else {
2640
2640
  process.stdout.write(readFileSync3(guidePath, "utf8"));
2641
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.9",
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
 
@@ -271,7 +278,29 @@ function Provision-Plugin {
271
278
  return
272
279
  }
273
280
  New-Item -ItemType Directory -Force -Path $Dst | Out-Null
274
- 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
+ }
275
304
  }
276
305
 
277
306
  function Merge-PluginsEnabled {
@@ -298,6 +327,9 @@ function Merge-PluginsEnabled {
298
327
  # Strategy: try python3 first (has real YAML support); fall back to sentinel-anchored
299
328
  # regex that appends to a plugins.enabled: block or creates one.
300
329
 
330
+ # Sentinel: only run the regex fallback when Python was unavailable or failed.
331
+ $pythonAllSucceeded = $false
332
+
301
333
  $python = Get-Command python3 -ErrorAction SilentlyContinue
302
334
  if ($python) {
303
335
  $pyScript = @"
@@ -348,14 +380,15 @@ sys.exit(0)
348
380
  [System.IO.File]::WriteAllText($tmp, $pyScript)
349
381
  try {
350
382
  & python3 $tmp $ConfigPath $pluginName
351
- $ok = ($LASTEXITCODE -eq 0)
383
+ $pythonAllSucceeded = ($LASTEXITCODE -eq 0)
352
384
  } finally {
353
385
  Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
354
386
  }
355
- if ($ok) { return $true }
387
+ if ($pythonAllSucceeded) { return $true }
356
388
  }
357
389
 
358
390
  # Fallback: pure PowerShell sentinel-anchored regex approach.
391
+ # Only runs when Python was unavailable or exited non-zero.
359
392
  # If the file has a plugins.enabled: [...] line, splice in the name.
360
393
  # Otherwise append a plugins.enabled block.
361
394
  $enabledRe = [regex]'(?m)^([ \t]+enabled\s*:\s*\[)([^\]]*)\]'
@@ -398,7 +431,10 @@ function Merge-Marketplace {
398
431
  $raw = Get-Content -Raw -LiteralPath $settingsPath
399
432
  $cleaned = ConvertFrom-Jsonc $raw
400
433
  try {
401
- $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
402
438
  $settings = ConvertTo-Hashtable $parsed
403
439
  if ($null -eq $settings) { $settings = @{} }
404
440
  } catch {