@ijfw/install 1.1.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.
@@ -0,0 +1,277 @@
1
+ # IJFW Windows-native installer (F4).
2
+ # PowerShell 5.1+ / PowerShell Core on Windows. No WSL required.
3
+ #
4
+ # Mirrors installer/src/install.js flow:
5
+ # preflight -> resolve target -> clone/pull -> run scripts/install.sh via Git Bash
6
+ # -> merge marketplace into %USERPROFILE%\.claude\settings.json -> summary.
7
+ #
8
+ # Usage:
9
+ # Invoke-Expression (iwr https://raw.githubusercontent.com/TheRealSeanDonahoe/ijfw/main/installer/src/install.ps1).Content
10
+ # or:
11
+ # .\install.ps1 -Dir C:\Users\me\.ijfw -Branch main
12
+
13
+ [CmdletBinding()]
14
+ param(
15
+ [string]$Dir = "",
16
+ [string]$Branch = "main",
17
+ [switch]$NoMarketplace,
18
+ [switch]$Yes,
19
+ [switch]$Purge
20
+ )
21
+
22
+ $ErrorActionPreference = "Stop"
23
+ $DEFAULT_REPO = "https://github.com/TheRealSeanDonahoe/ijfw.git"
24
+
25
+ function Write-Ok($msg) { Write-Host " [ok] $msg" -ForegroundColor Green }
26
+ function Write-Info($msg) { Write-Host " ... $msg" -ForegroundColor Gray }
27
+
28
+ function Test-Command($cmd) {
29
+ try { Get-Command $cmd -ErrorAction Stop | Out-Null; return $true } catch { return $false }
30
+ }
31
+
32
+ function Get-Target {
33
+ if ($Dir) {
34
+ $resolved = Resolve-Path -LiteralPath $Dir -ErrorAction SilentlyContinue
35
+ if ($resolved) { return $resolved.Path } else { return $Dir }
36
+ }
37
+ if ($env:IJFW_HOME) { return $env:IJFW_HOME }
38
+ return Join-Path $env:USERPROFILE ".ijfw"
39
+ }
40
+
41
+ function Invoke-Preflight {
42
+ $issues = @()
43
+ $node = if (Test-Command node) { (node --version) } else { $null }
44
+ if (-not $node -or ([int]($node -replace 'v(\d+)\..*','$1') -lt 18)) {
45
+ $issues += "Node 18+ unlocks IJFW (found $node). Grab it from https://nodejs.org and we'll pick up where you left off."
46
+ }
47
+ if (-not (Test-Command git)) { $issues += "Install Git for Windows (https://git-scm.com) and rerun -- it bundles everything we need." }
48
+ if (-not (Resolve-GitBash)) { $issues += "IJFW needs Git Bash (ships with Git for Windows). Install Git for Windows and rerun -- takes 60 seconds." }
49
+ return $issues
50
+ }
51
+
52
+ # Locate Git Bash explicitly. On Windows the plain `bash` command often
53
+ # resolves to WSL's bash, which fails with 'No such file or directory' when
54
+ # no Linux distro is installed. Git for Windows ships bash.exe alongside
55
+ # git.exe under <git-root>\bin\ (or \usr\bin\), so derive it from git's path.
56
+ function Resolve-GitBash {
57
+ $gitCmd = Get-Command git -ErrorAction SilentlyContinue
58
+ if ($gitCmd) {
59
+ $gitDir = Split-Path -Parent $gitCmd.Source
60
+ $candidates = @(
61
+ (Join-Path $gitDir 'bash.exe'),
62
+ (Join-Path (Split-Path -Parent $gitDir) 'bin\bash.exe'),
63
+ (Join-Path (Split-Path -Parent $gitDir) 'usr\bin\bash.exe')
64
+ )
65
+ foreach ($c in $candidates) { if (Test-Path $c) { return $c } }
66
+ }
67
+ foreach ($c in @(
68
+ 'C:\Program Files\Git\bin\bash.exe',
69
+ 'C:\Program Files\Git\usr\bin\bash.exe',
70
+ 'C:\Program Files (x86)\Git\bin\bash.exe'
71
+ )) { if (Test-Path $c) { return $c } }
72
+ return $null
73
+ }
74
+
75
+ function Invoke-CloneOrPull($target, $branch) {
76
+ if (-not (Test-Path $target)) {
77
+ # Fresh install.
78
+ New-Item -ItemType Directory -Force -Path $target | Out-Null
79
+ & git clone --depth 1 --branch $branch $DEFAULT_REPO $target
80
+ if ($LASTEXITCODE -ne 0) { throw "Could not reach the IJFW repo (exit $LASTEXITCODE). Check your network connection and retry." }
81
+ return "cloned"
82
+ }
83
+
84
+ # Upgrade path.
85
+ $hasGit = Test-Path (Join-Path $target ".git")
86
+ if ($hasGit) {
87
+ & git -C $target remote get-url origin 2>$null | Out-Null
88
+ if ($LASTEXITCODE -eq 0) {
89
+ # fetch + hard checkout avoids ff-only failures from local divergence.
90
+ & git -C $target fetch --depth 1 origin $branch
91
+ if ($LASTEXITCODE -ne 0) { throw "Could not reach the IJFW repo (exit $LASTEXITCODE). Check your network connection and retry." }
92
+ & git -C $target checkout -f FETCH_HEAD
93
+ if ($LASTEXITCODE -ne 0) { throw "Could not reach the IJFW repo (exit $LASTEXITCODE). Check your network connection and retry." }
94
+ return "updated"
95
+ }
96
+ }
97
+
98
+ # Broken repo or no origin: backup user data, re-clone, restore.
99
+ $ts = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
100
+ $backupDir = "$target.bak.$ts"
101
+ Rename-Item -LiteralPath $target -NewName $backupDir
102
+ try {
103
+ & git clone --depth 1 --branch $branch $DEFAULT_REPO $target
104
+ if ($LASTEXITCODE -ne 0) { throw "Could not reach the IJFW repo (exit $LASTEXITCODE). Check your network connection and retry." }
105
+ foreach ($item in @('memory', 'sessions', 'install.log', '.session-counter')) {
106
+ $src = Join-Path $backupDir $item
107
+ if (Test-Path $src) {
108
+ $dst = Join-Path $target $item
109
+ if (Test-Path $dst) { Remove-Item -Recurse -Force -LiteralPath $dst }
110
+ Move-Item -LiteralPath $src -Destination $dst
111
+ }
112
+ }
113
+ Remove-Item -Recurse -Force -LiteralPath $backupDir
114
+ return "updated"
115
+ } catch {
116
+ if (Test-Path $target) { Remove-Item -Recurse -Force -LiteralPath $target }
117
+ Rename-Item -LiteralPath $backupDir -NewName $target
118
+ throw
119
+ }
120
+ }
121
+
122
+ function Invoke-InstallScript($target) {
123
+ $script = Join-Path $target "scripts\install.sh"
124
+ if (-not (Test-Path $script)) { throw "The installer script is not at $script yet. Run the full install from a fresh clone." }
125
+ $gitBash = Resolve-GitBash
126
+ if (-not $gitBash) { throw "IJFW needs Git Bash to complete setup. Install Git for Windows (includes bash.exe) and rerun." }
127
+ Push-Location $target
128
+ try {
129
+ $env:IJFW_NONINTERACTIVE = if ($env:CI -or $Yes) { "1" } else { "" }
130
+ # Let the PS wrapper own the final closer so Merge-Marketplace output
131
+ # lands above it. Bash skips its "Full log" line when this is set.
132
+ $env:IJFW_SKIP_CLOSER = "1"
133
+ & $gitBash "./scripts/install.sh"
134
+ if ($LASTEXITCODE -ne 0) { throw "scripts/install.sh exited $LASTEXITCODE." }
135
+ } finally {
136
+ Pop-Location
137
+ Remove-Item Env:\IJFW_SKIP_CLOSER -ErrorAction SilentlyContinue
138
+ }
139
+ }
140
+
141
+ function ConvertTo-Hashtable($obj) {
142
+ # PS 5.1 compatibility: ConvertFrom-Json's -AsHashtable is PS 7+ only.
143
+ # Walk the PSCustomObject tree manually into hashtables + arrays.
144
+ if ($null -eq $obj) { return $null }
145
+ if ($obj -is [System.Collections.IDictionary]) {
146
+ $h = @{}
147
+ foreach ($k in $obj.Keys) { $h[$k] = ConvertTo-Hashtable $obj[$k] }
148
+ return $h
149
+ }
150
+ if ($obj -is [System.Management.Automation.PSCustomObject]) {
151
+ $h = @{}
152
+ foreach ($p in $obj.PSObject.Properties) { $h[$p.Name] = ConvertTo-Hashtable $p.Value }
153
+ return $h
154
+ }
155
+ if ($obj -is [System.Collections.IEnumerable] -and -not ($obj -is [string])) {
156
+ $out = @()
157
+ foreach ($item in $obj) { $out += ,(ConvertTo-Hashtable $item) }
158
+ return ,$out
159
+ }
160
+ return $obj
161
+ }
162
+
163
+ function ConvertFrom-Jsonc($raw) {
164
+ # State-machine JSONC cleaner: strips // line comments, /* block comments */,
165
+ # and trailing commas before } or ], but only when NOT inside a string.
166
+ # The regex version we shipped earlier mangled files whose string values
167
+ # contained // or /* patterns. This implementation walks the text char by
168
+ # char with a tiny state machine -- no regex false-positives.
169
+ if (-not $raw) { return $raw }
170
+ if ($raw.Length -gt 0 -and [int][char]$raw[0] -eq 0xFEFF) { $raw = $raw.Substring(1) }
171
+
172
+ $sb = New-Object System.Text.StringBuilder
173
+ $len = $raw.Length
174
+ $i = 0
175
+ $inString = $false
176
+ $escape = $false
177
+
178
+ while ($i -lt $len) {
179
+ $ch = $raw[$i]
180
+ if ($inString) {
181
+ [void]$sb.Append($ch)
182
+ if ($escape) { $escape = $false }
183
+ elseif ($ch -eq '\') { $escape = $true }
184
+ elseif ($ch -eq '"') { $inString = $false }
185
+ $i++
186
+ continue
187
+ }
188
+ if ($ch -eq '"') { $inString = $true; [void]$sb.Append($ch); $i++; continue }
189
+ if ($ch -eq '/' -and $i + 1 -lt $len) {
190
+ $next = $raw[$i + 1]
191
+ if ($next -eq '/') {
192
+ while ($i -lt $len -and $raw[$i] -ne "`n") { $i++ }
193
+ continue
194
+ }
195
+ if ($next -eq '*') {
196
+ $i += 2
197
+ while ($i + 1 -lt $len -and -not ($raw[$i] -eq '*' -and $raw[$i + 1] -eq '/')) { $i++ }
198
+ $i += 2
199
+ continue
200
+ }
201
+ }
202
+ [void]$sb.Append($ch)
203
+ $i++
204
+ }
205
+
206
+ # Strip trailing commas. Safe to do as a second regex pass now that strings
207
+ # and comments are out of the way.
208
+ $intermediate = $sb.ToString()
209
+ return ($intermediate -replace ',(\s*[}\]])','$1')
210
+ }
211
+
212
+ function Merge-Marketplace {
213
+ $settingsPath = Join-Path $env:USERPROFILE ".claude\settings.json"
214
+ $settingsDir = Split-Path -Parent $settingsPath
215
+ if (-not (Test-Path $settingsDir)) { New-Item -ItemType Directory -Force -Path $settingsDir | Out-Null }
216
+
217
+ $settings = @{}
218
+ if (Test-Path $settingsPath) {
219
+ $raw = Get-Content -Raw -LiteralPath $settingsPath
220
+ $cleaned = ConvertFrom-Jsonc $raw
221
+ try {
222
+ $parsed = ConvertFrom-Json $cleaned -ErrorAction Stop
223
+ $settings = ConvertTo-Hashtable $parsed
224
+ if ($null -eq $settings) { $settings = @{} }
225
+ } catch {
226
+ # Graceful fallback: back up the unparseable file, surface the manual
227
+ # next step, return without throwing so the rest of the install stands.
228
+ $ts = Get-Date -Format 'yyyyMMdd-HHmmss'
229
+ $backup = "$settingsPath.bak.marketplace.$ts"
230
+ Copy-Item -LiteralPath $settingsPath -Destination $backup -Force
231
+ Write-Host " ==> HEADS UP" -ForegroundColor Yellow -NoNewline
232
+ Write-Host " your Claude settings.json is not valid JSON/JSONC" -ForegroundColor DarkGray
233
+ Write-Host " Backed up to $backup" -ForegroundColor DarkGray
234
+ Write-Host " The two /plugin commands above still complete the install." -ForegroundColor DarkGray
235
+ Write-Host ""
236
+ return $false
237
+ }
238
+ }
239
+ if (-not $settings.ContainsKey('extraKnownMarketplaces')) { $settings['extraKnownMarketplaces'] = @{} }
240
+ $settings.extraKnownMarketplaces['ijfw'] = @{ source = @{ source = 'github'; repo = 'TheRealSeanDonahoe/ijfw' } }
241
+ if (-not $settings.ContainsKey('enabledPlugins')) { $settings['enabledPlugins'] = @{} }
242
+ # Opportunistically clean up the legacy key written by v1.0.0-1.0.2.
243
+ if ($settings.enabledPlugins.ContainsKey('ijfw-core@ijfw')) {
244
+ $settings.enabledPlugins.Remove('ijfw-core@ijfw')
245
+ }
246
+ $settings.enabledPlugins['ijfw@ijfw'] = $true
247
+
248
+ $tmp = "$settingsPath.tmp"
249
+ $settings | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tmp -Encoding UTF8
250
+ Move-Item -Force -LiteralPath $tmp -Destination $settingsPath
251
+ return $true
252
+ }
253
+
254
+ # --- main ---
255
+ $issues = Invoke-Preflight
256
+ if ($issues.Count -gt 0) {
257
+ Write-Host "Preflight:" -ForegroundColor Yellow
258
+ foreach ($i in $issues) { Write-Host " - $i" }
259
+ exit 1
260
+ }
261
+
262
+ $target = Get-Target
263
+
264
+ # scripts/install.sh owns the summary (Live now / Standing by / next step).
265
+ # Keep clone/pull output suppressed so the final banner reads clean.
266
+ $action = Invoke-CloneOrPull $target $Branch | Out-Null
267
+
268
+ Invoke-InstallScript $target
269
+
270
+ if (-not $NoMarketplace) {
271
+ # Best-effort: returns $true on success, prints its own message on fallback.
272
+ [void](Merge-Marketplace)
273
+ }
274
+
275
+ $log = Join-Path $env:USERPROFILE ".ijfw\install.log"
276
+ Write-Host " Full log $log" -ForegroundColor DarkGray
277
+ Write-Host ""