@ijfw/install 1.2.9 → 1.3.0

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/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.3.0",
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
 
@@ -43,8 +50,8 @@ function Invoke-Preflight {
43
50
  if (-not $node -or ([int]($node -replace 'v(\d+)\..*','$1') -lt 18)) {
44
51
  $issues += "Node 18+ unlocks IJFW (found $node). Grab it from https://nodejs.org and we'll pick up where you left off."
45
52
  }
46
- if (-not (Test-Command git)) { $issues += "Install Git for Windows (https://git-scm.com) and rerun -- it bundles everything we need." }
47
- if (-not (Resolve-GitBash)) { $issues += "IJFW needs Git Bash (ships with Git for Windows). Install Git for Windows and rerun -- takes 60 seconds." }
53
+ if (-not (Test-Command git)) { $issues += "IJFW needs git (used to clone the source repo). Install: winget install --id Git.Git -e --source winget" }
54
+ # Bash is no longer required (v1.3.0 -- installer is Node-native).
48
55
  return $issues
49
56
  }
50
57
 
@@ -129,25 +136,18 @@ function Invoke-CloneOrPull($target, $branch) {
129
136
  }
130
137
 
131
138
  function Invoke-InstallScript($target) {
132
- $script = Join-Path $target "scripts\install.sh"
133
- if (-not (Test-Path $script)) { throw "The installer script is not at $script yet. Run the full install from a fresh clone." }
134
- $gitBash = Resolve-GitBash
135
- if (-not $gitBash) { throw "IJFW needs Git Bash to complete setup. Install Git for Windows (includes bash.exe) and rerun." }
136
- # Propagate IJFW_HOME into the bash sub-call so a custom -Dir target lands
137
- # platform configs / sentinels under the user's chosen tree instead of the
138
- # default ~/.ijfw. Without this, .\install.ps1 -Dir D:\custom would scribble
139
- # MCP entries into the user's real ~/.codex / ~/.gemini / ~/.claude pointing
140
- # at the scratch dir.
139
+ # v1.3.0: hand off to the Node-native installer (installer/src/install.js).
140
+ # No more bash dependency. Identical code path on every platform.
141
+ $entry = Join-Path $target "installer\src\install.js"
142
+ if (-not (Test-Path $entry)) { throw "The installer entry is not at $entry. Run the full install from a fresh clone." }
141
143
  $priorIjfwHome = $env:IJFW_HOME
142
144
  $env:IJFW_HOME = $target
143
145
  Push-Location $target
144
146
  try {
145
147
  $env:IJFW_NONINTERACTIVE = if ($env:CI -or $Yes) { "1" } else { "" }
146
- # Let the PS wrapper own the final closer so Merge-Marketplace output
147
- # lands above it. Bash skips its "Full log" line when this is set.
148
148
  $env:IJFW_SKIP_CLOSER = "1"
149
- & $gitBash "./scripts/install.sh"
150
- if ($LASTEXITCODE -ne 0) { throw "scripts/install.sh exited $LASTEXITCODE." }
149
+ & node $entry
150
+ if ($LASTEXITCODE -ne 0) { throw "Node installer exited $LASTEXITCODE." }
151
151
  } finally {
152
152
  Pop-Location
153
153
  Remove-Item Env:\IJFW_SKIP_CLOSER -ErrorAction SilentlyContinue
@@ -271,7 +271,29 @@ function Provision-Plugin {
271
271
  return
272
272
  }
273
273
  New-Item -ItemType Directory -Force -Path $Dst | Out-Null
274
- Copy-Item -Recurse -Force -Path (Join-Path $srcPath "*") -Destination $Dst
274
+ foreach ($srcItem in (Get-ChildItem -LiteralPath $srcPath -Recurse -File)) {
275
+ $rel = $srcItem.FullName.Substring($srcPath.Length).TrimStart('\','/')
276
+ $dstItem = Join-Path $Dst $rel
277
+ $dstDir = Split-Path -Parent $dstItem
278
+ if (-not (Test-Path $dstDir)) { New-Item -ItemType Directory -Force -Path $dstDir | Out-Null }
279
+ $srcMtime = $null
280
+ $dstMtime = $null
281
+ try {
282
+ if (Test-Path $srcItem.FullName) { $srcMtime = (Get-Item $srcItem.FullName -ErrorAction Stop).LastWriteTime }
283
+ if (Test-Path $dstItem) { $dstMtime = (Get-Item $dstItem -ErrorAction Stop).LastWriteTime }
284
+ } catch {
285
+ # File watcher race / lock / OneDrive offline -- treat as new install
286
+ $dstMtime = $null
287
+ }
288
+ if ($null -ne $dstMtime -and $null -ne $srcMtime -and $dstMtime -gt $srcMtime) {
289
+ # User has modified the destination; back it up before overwriting.
290
+ $ts = Get-Date -Format 'yyyyMMdd-HHmmss'
291
+ Copy-Item $dstItem "$dstItem.user-bak.$ts" -Force -ErrorAction SilentlyContinue
292
+ }
293
+ Copy-Item $srcItem.FullName $dstItem -Force
294
+ # Preserve source mtime so next install doesn't mistake our copy for a user edit.
295
+ try { (Get-Item $dstItem -ErrorAction Stop).LastWriteTime = $srcItem.LastWriteTime } catch {}
296
+ }
275
297
  }
276
298
 
277
299
  function Merge-PluginsEnabled {
@@ -298,6 +320,9 @@ function Merge-PluginsEnabled {
298
320
  # Strategy: try python3 first (has real YAML support); fall back to sentinel-anchored
299
321
  # regex that appends to a plugins.enabled: block or creates one.
300
322
 
323
+ # Sentinel: only run the regex fallback when Python was unavailable or failed.
324
+ $pythonAllSucceeded = $false
325
+
301
326
  $python = Get-Command python3 -ErrorAction SilentlyContinue
302
327
  if ($python) {
303
328
  $pyScript = @"
@@ -348,14 +373,15 @@ sys.exit(0)
348
373
  [System.IO.File]::WriteAllText($tmp, $pyScript)
349
374
  try {
350
375
  & python3 $tmp $ConfigPath $pluginName
351
- $ok = ($LASTEXITCODE -eq 0)
376
+ $pythonAllSucceeded = ($LASTEXITCODE -eq 0)
352
377
  } finally {
353
378
  Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
354
379
  }
355
- if ($ok) { return $true }
380
+ if ($pythonAllSucceeded) { return $true }
356
381
  }
357
382
 
358
383
  # Fallback: pure PowerShell sentinel-anchored regex approach.
384
+ # Only runs when Python was unavailable or exited non-zero.
359
385
  # If the file has a plugins.enabled: [...] line, splice in the name.
360
386
  # Otherwise append a plugins.enabled block.
361
387
  $enabledRe = [regex]'(?m)^([ \t]+enabled\s*:\s*\[)([^\]]*)\]'
@@ -398,7 +424,10 @@ function Merge-Marketplace {
398
424
  $raw = Get-Content -Raw -LiteralPath $settingsPath
399
425
  $cleaned = ConvertFrom-Jsonc $raw
400
426
  try {
401
- $parsed = ConvertFrom-Json $cleaned -ErrorAction Stop
427
+ # -Depth is PS 6+; PS 5.1 ignores it safely via splatting.
428
+ $cfjArgs = @{ InputObject = $cleaned; ErrorAction = 'Stop' }
429
+ if ($PSVersionTable.PSVersion.Major -ge 6) { $cfjArgs['Depth'] = 32 }
430
+ $parsed = ConvertFrom-Json @cfjArgs
402
431
  $settings = ConvertTo-Hashtable $parsed
403
432
  if ($null -eq $settings) { $settings = @{} }
404
433
  } catch {