@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/README.md +5 -3
- package/dist/ijfw.js +28 -28
- package/dist/install.js +1916 -85
- package/dist/uninstall.js +36 -11
- package/package.json +1 -1
- package/src/install.ps1 +51 -22
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
+
writeAtomic(p, stripped);
|
|
228
253
|
return true;
|
|
229
254
|
}
|
|
230
255
|
function removeIjfwSkills(dir) {
|
package/package.json
CHANGED
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
|
-
|
|
34
|
-
|
|
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 += "
|
|
47
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
$
|
|
135
|
-
if (-not $
|
|
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
|
-
& $
|
|
150
|
-
if ($LASTEXITCODE -ne 0) { throw "
|
|
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
|
-
|
|
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
|
-
$
|
|
376
|
+
$pythonAllSucceeded = ($LASTEXITCODE -eq 0)
|
|
352
377
|
} finally {
|
|
353
378
|
Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
|
|
354
379
|
}
|
|
355
|
-
if ($
|
|
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
|
-
|
|
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 {
|