aid-installer 1.0.0 → 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.
- package/README.md +4 -2
- package/VERSION +1 -1
- package/bin/aid +2444 -193
- package/bin/aid.ps1 +2360 -105
- package/dashboard/home.html +3321 -0
- package/dashboard/index.html +987 -0
- package/dashboard/reader/__init__.py +56 -0
- package/dashboard/reader/derivation.py +892 -0
- package/dashboard/reader/locator.py +228 -0
- package/dashboard/reader/models.py +408 -0
- package/dashboard/reader/parsers.py +2105 -0
- package/dashboard/reader/reader.py +1196 -0
- package/dashboard/server/__init__.py +3 -0
- package/dashboard/server/reader.mjs +3699 -0
- package/dashboard/server/server.mjs +780 -0
- package/dashboard/server/server.py +1004 -0
- package/lib/AidInstallCore.psm1 +446 -43
- package/lib/aid-install-core.sh +405 -48
- package/package.json +5 -2
- package/scripts/postinstall.js +106 -0
- package/scripts/vendor.js +98 -0
package/bin/aid.ps1
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
# aid.ps1 - AID CLI dispatcher (PowerShell side).
|
|
3
3
|
#
|
|
4
4
|
# Purpose:
|
|
5
|
-
# Persistent global command installed at $
|
|
5
|
+
# Persistent global command installed at $AID_CODE_HOME\bin\aid.ps1. Parses
|
|
6
6
|
# subcommands and dispatches to the shared install-core engine located at
|
|
7
|
-
# $
|
|
7
|
+
# $AID_CODE_HOME\lib\AidInstallCore.psm1. Operates on the current working
|
|
8
8
|
# directory (-Target / AID_TARGET overrides).
|
|
9
9
|
#
|
|
10
10
|
# Usage:
|
|
@@ -52,25 +52,91 @@ function script:Exit-Aid {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
# ---------------------------------------------------------------------------
|
|
55
|
-
#
|
|
55
|
+
# AID_CODE_HOME: self-locate the read-only code payload (parent of bin/).
|
|
56
|
+
# NEVER overridden by an env var. Error-out if unresolvable (Q1 fail-safe).
|
|
56
57
|
# ---------------------------------------------------------------------------
|
|
57
58
|
$script:_AidSelfPath = $MyInvocation.MyCommand.Path
|
|
58
|
-
if (-not [string]::IsNullOrEmpty($script:_AidSelfPath)) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# bin/aid.ps1 -> parent of bin/ = AID_HOME
|
|
62
|
-
$script:_AidHome = Split-Path -Parent (Split-Path -Parent $script:_AidSelfPath)
|
|
63
|
-
}
|
|
59
|
+
if (-not [string]::IsNullOrEmpty($script:_AidSelfPath) -and (Test-Path $script:_AidSelfPath -PathType Leaf)) {
|
|
60
|
+
# bin/aid.ps1 -> parent of bin/ = AID_CODE_HOME
|
|
61
|
+
$script:_AidCodeHome = Split-Path -Parent (Split-Path -Parent $script:_AidSelfPath)
|
|
64
62
|
} else {
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
[Console]::Error.WriteLine("ERROR: aid: cannot locate the AID code payload (AID_CODE_HOME unresolved). Re-run the AID bootstrap to repair.")
|
|
64
|
+
script:Exit-Aid 1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Scope derivation: global iff AID_CODE_HOME is not writable by the current
|
|
69
|
+
# user. Mirrors bash: [[ ! -w "$AID_CODE_HOME" && "$(id -u)" -ne 0 ]].
|
|
70
|
+
# No install-time marker -- writability of the code payload is the sole test.
|
|
71
|
+
# AID_STATE_HOME: mutable state home, env-overridable via AID_HOME.
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
$script:_AidCodeHomeWritable = $false
|
|
74
|
+
try {
|
|
75
|
+
$_aidWriteTestFile = Join-Path $script:_AidCodeHome ('.aid-write-test.' + [System.IO.Path]::GetRandomFileName())
|
|
76
|
+
[System.IO.File]::WriteAllText($_aidWriteTestFile, '')
|
|
77
|
+
Remove-Item -LiteralPath $_aidWriteTestFile -Force -ErrorAction SilentlyContinue
|
|
78
|
+
$script:_AidCodeHomeWritable = $true
|
|
79
|
+
} catch {
|
|
80
|
+
$script:_AidCodeHomeWritable = $false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if ($script:_AidCodeHomeWritable) {
|
|
84
|
+
$script:_AidScope = 'user'
|
|
85
|
+
$script:_AidStateHome = if ($env:AID_HOME) { $env:AID_HOME } else { Join-Path $HOME '.aid' }
|
|
86
|
+
} else {
|
|
87
|
+
$script:_AidScope = 'global'
|
|
88
|
+
$script:_AidStateHome = if ($env:AID_HOME) { $env:AID_HOME } else {
|
|
89
|
+
if ($env:AID_SHARED_STATE_HOME) {
|
|
90
|
+
$env:AID_SHARED_STATE_HOME
|
|
91
|
+
} elseif ($env:ProgramData) {
|
|
92
|
+
Join-Path $env:ProgramData 'aid'
|
|
93
|
+
} else {
|
|
94
|
+
Join-Path $HOME '.aid'
|
|
95
|
+
}
|
|
67
96
|
}
|
|
68
97
|
}
|
|
69
98
|
|
|
70
99
|
# ---------------------------------------------------------------------------
|
|
71
|
-
#
|
|
100
|
+
# Test-AidIsProjectDir <Dir>
|
|
101
|
+
# Return $true iff <Dir> has a .aid/ subdirectory AND that subdirectory is NOT
|
|
102
|
+
# the CLI state home. Excludes the state home from "is project" classification
|
|
103
|
+
# so running 'aid' from $HOME (or any dir whose .aid/ == _AidStateHome) does not
|
|
104
|
+
# falsely auto-register or trigger the format gate.
|
|
105
|
+
#
|
|
106
|
+
# Guard: resolves <Dir>\.aid to a canonical path and compares against both
|
|
107
|
+
# Resolve-Path($script:_AidStateHome) and Resolve-Path($HOME\.aid).
|
|
108
|
+
# Mirror of bash _aid_is_project_dir.
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
function script:Test-AidIsProjectDir {
|
|
111
|
+
param([string]$Dir)
|
|
112
|
+
$aidSub = Join-Path $Dir '.aid'
|
|
113
|
+
if (-not (Test-Path $aidSub -PathType Container)) { return $false }
|
|
114
|
+
# Resolve .aid/ to canonical real path (tolerates non-existent intermediate).
|
|
115
|
+
$aidReal = try { (Resolve-Path -LiteralPath $aidSub -ErrorAction Stop).Path } catch { $aidSub }
|
|
116
|
+
# Resolve both state-home candidates.
|
|
117
|
+
$shReal = try { (Resolve-Path -LiteralPath $script:_AidStateHome -ErrorAction Stop).Path } catch { $script:_AidStateHome }
|
|
118
|
+
$hdAid = Join-Path $HOME '.aid'
|
|
119
|
+
$hdReal = try { (Resolve-Path -LiteralPath $hdAid -ErrorAction Stop).Path } catch { $hdAid }
|
|
120
|
+
# Compare using OrdinalIgnoreCase to handle Windows path normalization.
|
|
121
|
+
if ([string]::Equals($aidReal, $shReal, [System.StringComparison]::OrdinalIgnoreCase) -or
|
|
122
|
+
[string]::Equals($aidReal, $hdReal, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
123
|
+
return $false
|
|
124
|
+
}
|
|
125
|
+
return $true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# C1': Per-repo format stamp constant.
|
|
130
|
+
# The current .aid/ layout version. Bumped ONLY on a breaking layout change,
|
|
131
|
+
# never on every CLI release. Defined exactly once; all comparisons read this.
|
|
132
|
+
# Integer must equal the bash AID_SUPPORTED_FORMAT in bin/aid.
|
|
72
133
|
# ---------------------------------------------------------------------------
|
|
73
|
-
|
|
134
|
+
Set-Variable -Name AidSupportedFormat -Value 1 -Option Constant -Scope Script
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Import the shared install core from AID_CODE_HOME\lib\.
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
$script:_CoreModule = Join-Path $script:_AidCodeHome 'lib' | Join-Path -ChildPath 'AidInstallCore.psm1'
|
|
74
140
|
if (-not (Test-Path $script:_CoreModule -PathType Leaf)) {
|
|
75
141
|
[Console]::Error.WriteLine("ERROR: aid: install core not found at $($script:_CoreModule). Re-run the AID bootstrap to repair.")
|
|
76
142
|
script:Exit-Aid 1
|
|
@@ -115,21 +181,57 @@ function script:Show-AidUsage {
|
|
|
115
181
|
Write-Host ' Tools: claude-code, codex, cursor, copilot-cli, antigravity'
|
|
116
182
|
}
|
|
117
183
|
'remove' {
|
|
118
|
-
Write-Host 'aid remove [<tool>[,<tool>...]
|
|
184
|
+
Write-Host 'aid remove [<tool>[,<tool>...]] [-Force] [-Verbose] [-Target <dir>]'
|
|
185
|
+
Write-Host 'aid remove self [-Force] [-DryRun]'
|
|
119
186
|
Write-Host ' Remove tool(s) from the current project (manifest-driven).'
|
|
120
187
|
Write-Host ' No args: remove ALL AID from the project (asks for confirmation).'
|
|
121
|
-
Write-Host ' self: remove the aid CLI
|
|
188
|
+
Write-Host ' self: COMPLETELY remove the aid CLI, channel-aware (asks for confirmation):'
|
|
189
|
+
Write-Host ' npm -> npm uninstall -g | pypi -> pipx uninstall | curl -> rm $AID_CODE_HOME + unwire PATH.'
|
|
190
|
+
Write-Host ' On Windows, elevation is the caller''s responsibility (no sudo).'
|
|
191
|
+
Write-Host ' -DryRun: print the exact command(s) it would run, then exit (no changes).'
|
|
122
192
|
}
|
|
123
193
|
'update' {
|
|
124
|
-
Write-Host 'aid update [<tool>...
|
|
125
|
-
Write-Host '
|
|
194
|
+
Write-Host 'aid update [<tool>...] [-Version <v>] [-FromBundle <path>] [-Force] [-Target <dir>]'
|
|
195
|
+
Write-Host 'aid update self [-FromBundle <path>] [-DryRun]'
|
|
126
196
|
Write-Host ' Update to latest. No args: update all installed tools.'
|
|
127
|
-
Write-Host ' self: update the aid CLI
|
|
197
|
+
Write-Host ' self: COMPLETELY update the aid CLI, channel-aware:'
|
|
198
|
+
Write-Host ' npm -> npm i -g | pypi -> pipx upgrade | curl -> re-bootstrap install.ps1.'
|
|
199
|
+
Write-Host ' On Windows, elevation is the caller''s responsibility (no sudo).'
|
|
200
|
+
Write-Host ' -FromBundle <path>: install the CLI from a local artifact instead of @latest'
|
|
201
|
+
Write-Host ' (npm .tgz | pypi .whl | curl release-staging dir with install.ps1).'
|
|
202
|
+
Write-Host ' -DryRun: print the exact command(s) it would run, then exit (no changes).'
|
|
128
203
|
}
|
|
129
204
|
'version' {
|
|
130
205
|
Write-Host 'aid version'
|
|
131
206
|
Write-Host ' Print the installed aid CLI version and exit 0.'
|
|
132
207
|
}
|
|
208
|
+
'dashboard' {
|
|
209
|
+
Write-Host 'aid dashboard start <node|python> [--remote] [--port <n>]'
|
|
210
|
+
Write-Host 'aid dashboard stop'
|
|
211
|
+
Write-Host ' Start or stop the machine-level pipeline dashboard (serves all registered projects).'
|
|
212
|
+
Write-Host ' <node|python> select the server runtime to launch.'
|
|
213
|
+
Write-Host ' --remote also expose it to authorized users over a private channel (never public);'
|
|
214
|
+
Write-Host ' fails clearly if that mechanism is unavailable -- never binds publicly.'
|
|
215
|
+
Write-Host ' --port <n> listen port on 127.0.0.1 (default 8787).'
|
|
216
|
+
Write-Host " The dashboard binds to 127.0.0.1 only. 'stop' is idempotent and also tears down --remote."
|
|
217
|
+
Write-Host ' Works from any directory (not tied to the current project).'
|
|
218
|
+
}
|
|
219
|
+
'projects' {
|
|
220
|
+
Write-Host 'aid projects [list] [--local|--shared] [--verbose]'
|
|
221
|
+
Write-Host 'aid projects add [<path>] [--local|--shared]'
|
|
222
|
+
Write-Host 'aid projects remove [<path>]'
|
|
223
|
+
Write-Host ' List, register, or unregister AID projects in the registry.'
|
|
224
|
+
Write-Host ' list (default): show all registered projects with state, tools, and tier.'
|
|
225
|
+
Write-Host ' The current directory is marked with "*" in the leading marker column.'
|
|
226
|
+
Write-Host ' Unregistered cwd with .aid/ present is shown as a footnote.'
|
|
227
|
+
Write-Host ' add [path=cwd]: register a project (requires .aid/ to exist); tracking only,'
|
|
228
|
+
Write-Host ' no tools are installed. Idempotent. Prints the tier written.'
|
|
229
|
+
Write-Host ' remove [path=cwd]: unregister a project from the registry; no files removed.'
|
|
230
|
+
Write-Host ' Works on stale/missing/no-aid entries. Idempotent.'
|
|
231
|
+
Write-Host ' --local force user tier for add'
|
|
232
|
+
Write-Host ' --shared force shared tier for add'
|
|
233
|
+
Write-Host ' --verbose print extra detail'
|
|
234
|
+
}
|
|
133
235
|
default {
|
|
134
236
|
Write-Host 'aid - AID CLI'
|
|
135
237
|
Write-Host ''
|
|
@@ -141,9 +243,11 @@ function script:Show-AidUsage {
|
|
|
141
243
|
Write-Host ' aid add <tool>[,...] Add tool(s) to the current project'
|
|
142
244
|
Write-Host ' aid update [<tool>... | self] Update to latest; no arg = all tools'
|
|
143
245
|
Write-Host ' aid remove [<tool>... | self] Remove; no arg = ALL AID from project'
|
|
246
|
+
Write-Host ' aid dashboard start|stop ... Start/stop the local dashboard'
|
|
247
|
+
Write-Host ' aid projects [list|add|remove] List/register/unregister AID projects'
|
|
144
248
|
Write-Host " aid <command> -h | --help Per-command help"
|
|
145
249
|
Write-Host ''
|
|
146
|
-
Write-Host 'Flags: -FromBundle, -Version, -Force, -Target, -Verbose'
|
|
250
|
+
Write-Host 'Flags: -FromBundle, -Version, -Force, -DryRun, -Target, -Verbose'
|
|
147
251
|
Write-Host "Run 'aid <command> -h' for details."
|
|
148
252
|
}
|
|
149
253
|
}
|
|
@@ -165,7 +269,7 @@ function script:Fail-Aid {
|
|
|
165
269
|
# Invoke-AidUpdateCheck
|
|
166
270
|
# Compares installed CLI version against latest GitHub release.
|
|
167
271
|
# Prints ONE notice line when newer is available. Fail-silent.
|
|
168
|
-
# Throttle: re-fetches at most once per 24h; cache in $
|
|
272
|
+
# Throttle: re-fetches at most once per 24h; cache in $HOME\.aid\.update-check (FR10: always per-user).
|
|
169
273
|
# Opt-out: $env:AID_NO_UPDATE_CHECK = '1'
|
|
170
274
|
# Test hook: $env:AID_UPDATE_CHECK_URL overrides the fetch URL (and bypasses throttle).
|
|
171
275
|
function script:Invoke-AidUpdateCheck {
|
|
@@ -173,12 +277,15 @@ function script:Invoke-AidUpdateCheck {
|
|
|
173
277
|
if ($env:AID_NO_UPDATE_CHECK -eq '1') { return }
|
|
174
278
|
|
|
175
279
|
# Read installed version.
|
|
176
|
-
$verFile = Join-Path $script:
|
|
280
|
+
$verFile = Join-Path $script:_AidCodeHome 'VERSION'
|
|
177
281
|
if (-not (Test-Path $verFile -PathType Leaf)) { return }
|
|
178
282
|
$installedVersion = (Get-Content -LiteralPath $verFile -Raw -ErrorAction SilentlyContinue).Trim()
|
|
179
283
|
if (-not $installedVersion) { return }
|
|
180
284
|
|
|
181
|
-
|
|
285
|
+
# FR10: .update-check is always per-user ($HOME/.aid), never AID_STATE_HOME.
|
|
286
|
+
# This ensures a routine version check never writes into /var/lib/aid on a
|
|
287
|
+
# root-owned global install and never triggers elevation.
|
|
288
|
+
$cacheFile = Join-Path $HOME (Join-Path '.aid' '.update-check')
|
|
182
289
|
$throttleSecs = 86400 # 24 hours
|
|
183
290
|
try { $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } catch { return }
|
|
184
291
|
|
|
@@ -259,41 +366,166 @@ function script:Invoke-AidUpdateCheck {
|
|
|
259
366
|
if ($va -gt $vb) { break }
|
|
260
367
|
}
|
|
261
368
|
if ($isLt) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
Write-Host "A newer aid CLI is available: v$latestVersion (you have v$installedVersion). Run: $updateCmd"
|
|
369
|
+
# `aid update self` is now channel-aware and self-contained (it runs the
|
|
370
|
+
# right package manager + applies migrations), so point at it for every
|
|
371
|
+
# channel instead of a per-channel manual command.
|
|
372
|
+
Write-Host "A newer aid CLI is available: v$latestVersion (you have v$installedVersion). Run: aid update self"
|
|
268
373
|
}
|
|
269
374
|
}
|
|
270
375
|
|
|
271
376
|
# Invoke-AidUpdateSelf
|
|
272
|
-
#
|
|
273
|
-
# AID_INSTALL_CHANNEL
|
|
274
|
-
#
|
|
377
|
+
# Channel-aware, self-contained CLI self-update. Returns the exit code (does NOT call Exit-Aid).
|
|
378
|
+
# Reads the channel from AID_INSTALL_CHANNEL (injected by the npm/pypi shims).
|
|
379
|
+
# Honors $script:_SelfFromBundle (a local CLI artifact: npm .tgz / pypi .whl / curl bundle dir)
|
|
380
|
+
# and $script:_SelfDryRun. On Windows there is no sudo -- if a privileged location is not
|
|
381
|
+
# writable, the underlying tool (npm/pipx) will surface its own error; callers elevate their
|
|
382
|
+
# own shell. Dry-run prints "+ <command>" lines and returns 0 without making changes.
|
|
383
|
+
# Callers are responsible for calling Exit-Aid after the update completes.
|
|
275
384
|
function script:Invoke-AidUpdateSelf {
|
|
276
|
-
|
|
385
|
+
# AID_SKIP_SELF_INSTALL: the package manager already (re)installed the CLI
|
|
386
|
+
# (postinstall) and only wants the post-update step to run. Skip the
|
|
387
|
+
# re-install step.
|
|
388
|
+
if ($env:AID_SKIP_SELF_INSTALL -eq '1') { return 0 }
|
|
389
|
+
$channel = $env:AID_INSTALL_CHANNEL
|
|
390
|
+
$bundle = $script:_SelfFromBundle
|
|
391
|
+
$dryRun = $script:_SelfDryRun
|
|
392
|
+
|
|
393
|
+
switch ($channel) {
|
|
277
394
|
'npm' {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
395
|
+
$npmCmd = Get-Command 'npm' -ErrorAction SilentlyContinue
|
|
396
|
+
if (-not $npmCmd) {
|
|
397
|
+
[Console]::Error.WriteLine("ERROR: aid: npm not found; cannot update the npm-channel CLI")
|
|
398
|
+
return 3
|
|
399
|
+
}
|
|
400
|
+
$pkg = if ($bundle) { $bundle } else { 'aid-installer@latest' }
|
|
401
|
+
if ($dryRun) {
|
|
402
|
+
Write-Host "+ npm install -g $pkg"
|
|
403
|
+
return 0
|
|
404
|
+
}
|
|
405
|
+
Write-Host 'Updating the aid CLI (npm channel)...'
|
|
406
|
+
& npm install -g $pkg
|
|
407
|
+
return $LASTEXITCODE
|
|
281
408
|
}
|
|
282
409
|
'pypi' {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
410
|
+
$pipxCmd = Get-Command 'pipx' -ErrorAction SilentlyContinue
|
|
411
|
+
if (-not $pipxCmd) {
|
|
412
|
+
[Console]::Error.WriteLine("ERROR: aid: pipx not found; cannot update the pypi-channel CLI")
|
|
413
|
+
return 3
|
|
414
|
+
}
|
|
415
|
+
if ($dryRun) {
|
|
416
|
+
if ($bundle) {
|
|
417
|
+
Write-Host "+ pipx install --force $bundle"
|
|
418
|
+
} else {
|
|
419
|
+
Write-Host '+ pipx upgrade aid-installer'
|
|
420
|
+
}
|
|
421
|
+
return 0
|
|
422
|
+
}
|
|
423
|
+
Write-Host 'Updating the aid CLI (pypi/pipx channel)...'
|
|
424
|
+
if ($bundle) {
|
|
425
|
+
& pipx install --force $bundle
|
|
426
|
+
} else {
|
|
427
|
+
& pipx upgrade aid-installer
|
|
428
|
+
}
|
|
429
|
+
return $LASTEXITCODE
|
|
286
430
|
}
|
|
287
431
|
}
|
|
432
|
+
|
|
433
|
+
# curl / default channel -- re-bootstrap install.ps1.
|
|
288
434
|
Write-Host 'Updating the aid CLI...'
|
|
435
|
+
if ($bundle) {
|
|
436
|
+
# --from-bundle <dir> on the curl channel: a release-staging dir that
|
|
437
|
+
# carries install.ps1 + the CLI bundle + SHA256SUMS. Run it offline.
|
|
438
|
+
$installScript = Join-Path ($bundle.TrimEnd('/\')) 'install.ps1'
|
|
439
|
+
if (Test-Path $installScript -PathType Leaf) {
|
|
440
|
+
if ($dryRun) {
|
|
441
|
+
$bundleNorm = $bundle.TrimEnd('/\')
|
|
442
|
+
Write-Host "+ `$env:AID_CLI_BUNDLE_BASE='file://$bundleNorm'; `$env:AID_LIB_BASE='file://$bundleNorm'; & '$installScript'"
|
|
443
|
+
return 0
|
|
444
|
+
}
|
|
445
|
+
$bundleNorm = $bundle.TrimEnd('/\')
|
|
446
|
+
$env:AID_CLI_BUNDLE_BASE = "file://$bundleNorm"
|
|
447
|
+
$env:AID_LIB_BASE = "file://$bundleNorm"
|
|
448
|
+
& $installScript
|
|
449
|
+
return $LASTEXITCODE
|
|
450
|
+
}
|
|
451
|
+
[Console]::Error.WriteLine("ERROR: aid: -FromBundle <dir> for the curl channel must contain install.ps1 (got: $bundle)")
|
|
452
|
+
return 2
|
|
453
|
+
}
|
|
289
454
|
$url = $script:_AidInstallUrl
|
|
455
|
+
if ($dryRun) {
|
|
456
|
+
Write-Host "+ irm $url | iex"
|
|
457
|
+
return 0
|
|
458
|
+
}
|
|
290
459
|
try {
|
|
291
460
|
$scriptContent = (Invoke-RestMethod -Uri $url -ErrorAction Stop)
|
|
292
461
|
& ([scriptblock]::Create($scriptContent))
|
|
293
|
-
|
|
462
|
+
return $LASTEXITCODE
|
|
294
463
|
} catch {
|
|
295
464
|
[Console]::Error.WriteLine("ERROR: aid: update self failed: $_")
|
|
296
|
-
|
|
465
|
+
return 3
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
# Invoke-AidUpdateSelfIfStale (FF-3 preamble / CLI-2 / task-079)
|
|
470
|
+
# Self-update-if-needed preamble for the 'aid update [<tool>]' reach.
|
|
471
|
+
# Reuses Invoke-AidUpdateSelf channel logic gated by a skip-if-current check
|
|
472
|
+
# (OQ-6 resolved simplest-correct: compare installed $AID_CODE_HOME/VERSION against
|
|
473
|
+
# the cached .update-check latest; if stale -> call Invoke-AidUpdateSelf; if
|
|
474
|
+
# current or unknown -> silent no-op).
|
|
475
|
+
#
|
|
476
|
+
# Safety notes (no re-bootstrap/loop hazard):
|
|
477
|
+
# - Called only on 'update [<tool>]', not 'update self' or 'add'.
|
|
478
|
+
# - WARN-not-fail: self-update failure is logged; tool-install continues (NFR12).
|
|
479
|
+
function script:Invoke-AidUpdateSelfIfStale {
|
|
480
|
+
param([string]$FromBundle = '')
|
|
481
|
+
|
|
482
|
+
# Offline / explicit bundle install: when the caller supplied a local bundle, do NOT
|
|
483
|
+
# phone the package channel to self-update. The bundle is the source of truth for this
|
|
484
|
+
# install; reaching out to the registry would defeat an air-gapped or pre-release
|
|
485
|
+
# install (and could replace the running CLI behind the user's back). Mirrors bin/aid.
|
|
486
|
+
if ($FromBundle) { return }
|
|
487
|
+
|
|
488
|
+
# Read installed version from the code payload (read-only).
|
|
489
|
+
$verFile = Join-Path $script:_AidCodeHome 'VERSION'
|
|
490
|
+
$installed = ''
|
|
491
|
+
if (Test-Path $verFile -PathType Leaf) {
|
|
492
|
+
$installed = (Get-Content $verFile -Raw).Trim()
|
|
493
|
+
}
|
|
494
|
+
if (-not $installed) { return } # no installed version known -> skip
|
|
495
|
+
|
|
496
|
+
# Read cached latest version from per-user .update-check (FR10: always $HOME/.aid).
|
|
497
|
+
$cacheFile = Join-Path $HOME (Join-Path '.aid' '.update-check')
|
|
498
|
+
$cachedLatest = ''
|
|
499
|
+
if (Test-Path $cacheFile -PathType Leaf) {
|
|
500
|
+
$lines = Get-Content $cacheFile -ErrorAction SilentlyContinue
|
|
501
|
+
if ($lines -and $lines.Count -ge 2) {
|
|
502
|
+
$cachedLatest = ($lines[1]).Trim()
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (-not $cachedLatest) { return } # no cached latest known -> skip (no network call here)
|
|
506
|
+
|
|
507
|
+
# Skip if already current (string equality fast-path).
|
|
508
|
+
if ($installed -eq $cachedLatest) { return }
|
|
509
|
+
|
|
510
|
+
# Only self-update when the installed CLI is strictly OLDER than the cached latest.
|
|
511
|
+
# A newer installed version (e.g. an unreleased dev build) must never be downgraded
|
|
512
|
+
# to "latest". Parse both as [version]; if either is unparseable, skip conservatively.
|
|
513
|
+
# Mirrors the `sort -V` guard in bin/aid (never downgrade).
|
|
514
|
+
$installedVer = $null
|
|
515
|
+
$latestVer = $null
|
|
516
|
+
if (-not ([version]::TryParse($installed, [ref]$installedVer)) -or
|
|
517
|
+
-not ([version]::TryParse($cachedLatest, [ref]$latestVer))) {
|
|
518
|
+
return # unparseable version -> conservatively skip (never risk a downgrade)
|
|
519
|
+
}
|
|
520
|
+
if ($installedVer -ge $latestVer) { return } # installed >= latest -> nothing to do
|
|
521
|
+
|
|
522
|
+
# Stale: call the channel-appropriate self-update logic.
|
|
523
|
+
# WARN-not-fail: failure must not abort the tool-update.
|
|
524
|
+
Write-Host "aid update: CLI is not current (installed: $installed, available: $cachedLatest); self-updating before tool install..."
|
|
525
|
+
try {
|
|
526
|
+
$null = script:Invoke-AidUpdateSelf
|
|
527
|
+
} catch {
|
|
528
|
+
[Console]::Error.WriteLine("WARN: aid: self-update failed (continuing with tool install)")
|
|
297
529
|
}
|
|
298
530
|
}
|
|
299
531
|
|
|
@@ -313,57 +545,1433 @@ function script:Add-AidToPath {
|
|
|
313
545
|
return
|
|
314
546
|
}
|
|
315
547
|
|
|
316
|
-
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
|
317
|
-
if (-not $currentPath) { $currentPath = '' }
|
|
318
|
-
|
|
319
|
-
# Split on ';', filter empty, deduplicate while preserving order.
|
|
320
|
-
$parts = $currentPath -split ';' | Where-Object { $_ -and $_.Trim() }
|
|
321
|
-
if ($parts -contains $BinDir) {
|
|
322
|
-
# Already present - update in-process path and return silently.
|
|
323
|
-
if ($env:Path -notmatch [regex]::Escape($BinDir)) {
|
|
324
|
-
$env:Path = "$BinDir;$($env:Path)"
|
|
548
|
+
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
|
549
|
+
if (-not $currentPath) { $currentPath = '' }
|
|
550
|
+
|
|
551
|
+
# Split on ';', filter empty, deduplicate while preserving order.
|
|
552
|
+
$parts = $currentPath -split ';' | Where-Object { $_ -and $_.Trim() }
|
|
553
|
+
if ($parts -contains $BinDir) {
|
|
554
|
+
# Already present - update in-process path and return silently.
|
|
555
|
+
if ($env:Path -notmatch [regex]::Escape($BinDir)) {
|
|
556
|
+
$env:Path = "$BinDir;$($env:Path)"
|
|
557
|
+
}
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
$newParts = @($BinDir) + @($parts)
|
|
562
|
+
$newPath = $newParts -join ';'
|
|
563
|
+
|
|
564
|
+
# Safety guard: warn if exceeding ~2000 chars (Windows limit is 32767 but
|
|
565
|
+
# practical registry/shell limit is much lower for User PATH).
|
|
566
|
+
$safeLimit = 2000
|
|
567
|
+
if ($newPath.Length -gt $safeLimit) {
|
|
568
|
+
Write-Host "WARN: aid: User PATH would exceed $safeLimit chars. Skipping automatic PATH wiring."
|
|
569
|
+
Write-Host "Add `"$BinDir`" to your PATH manually via System Properties > Environment Variables."
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
|
574
|
+
|
|
575
|
+
# Update in-process immediately so the convenience-chain first action works.
|
|
576
|
+
if ($env:Path -notmatch [regex]::Escape($BinDir)) {
|
|
577
|
+
$env:Path = "$BinDir;$($env:Path)"
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
Write-Host "PATH wiring added (User scope): $BinDir"
|
|
581
|
+
Write-Host "Open a new shell, or the PATH is already active in this session."
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
# Remove-AidFromPath <binDir>
|
|
585
|
+
# Remove binDir from User PATH idempotently.
|
|
586
|
+
function script:Remove-AidFromPath {
|
|
587
|
+
param([string]$BinDir)
|
|
588
|
+
|
|
589
|
+
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
|
590
|
+
if (-not $currentPath) { return }
|
|
591
|
+
|
|
592
|
+
$parts = $currentPath -split ';' | Where-Object { $_ -and $_.Trim() -ne $BinDir }
|
|
593
|
+
$newPath = $parts -join ';'
|
|
594
|
+
|
|
595
|
+
if ($newPath -ne $currentPath) {
|
|
596
|
+
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
|
597
|
+
Write-Host "PATH wiring removed (User scope): $BinDir"
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
# Remote exposure helpers (feature-005 / LC-EXP-P).
|
|
603
|
+
# SEC-1: These helpers invoke ONLY 'tailscale serve' (tailnet-only). The public
|
|
604
|
+
# exposure verb is never used -- a bare grep for it returns nothing
|
|
605
|
+
# anywhere in this file (structural never-public, C1).
|
|
606
|
+
# SEC-6: --remote exposes the CLI home (all registered repos, OQ5/DR-4): a
|
|
607
|
+
# granted tailnet identity sees the full registered-repo list + each
|
|
608
|
+
# repo's home.html/kb.html/api/model. This is the accepted OQ5 trade-off
|
|
609
|
+
# -- a grantee is already a trusted operator of this host. The helpers
|
|
610
|
+
# below, the bind, and the teardown are UNCHANGED; only what the port
|
|
611
|
+
# serves changed (DR-2/task-047). Never-public (C1) and host/user-ACL
|
|
612
|
+
# scoping (C3) hold exactly as before.
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
# Invoke-AidRemoteExpose -Port <n>
|
|
616
|
+
# Bring up tailscale serve (tailnet-only) for a loopback port.
|
|
617
|
+
# stdout (exit 0): two lines: handle (tailscale-serve:<port>) + https URL.
|
|
618
|
+
# stderr: human messages, errors, FR18 ACL-grant guidance.
|
|
619
|
+
# exit: 0=ok 10=mechanism absent 11=non-loopback target 12=serve failed
|
|
620
|
+
function script:Invoke-AidRemoteExpose {
|
|
621
|
+
param([int]$Port)
|
|
622
|
+
|
|
623
|
+
# Step 1: Re-assert the loopback target (belt-and-suspenders, SEC-1).
|
|
624
|
+
if ($Port -le 0) {
|
|
625
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: expose target must be 127.0.0.1 (got: $Port)")
|
|
626
|
+
return 11
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
# Step 2a: availability -- tailscale on PATH?
|
|
630
|
+
if (-not (Get-Command 'tailscale' -ErrorAction SilentlyContinue)) {
|
|
631
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: --remote requested but tailscale is not on PATH; --remote is unavailable")
|
|
632
|
+
return 10
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
# Step 2b: availability -- node logged in and Running?
|
|
636
|
+
$tsStatusOut = ''
|
|
637
|
+
try { $tsStatusOut = (& tailscale status 2>&1) -join "`n" } catch { $tsStatusOut = '' }
|
|
638
|
+
if ($tsStatusOut -match '(?i)(not running|logged out|Stopped|NeedsLogin|NoState|not logged in)') {
|
|
639
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: --remote requested but tailscale is not running or not logged in (tailscale status: $tsStatusOut); --remote is unavailable")
|
|
640
|
+
return 10
|
|
641
|
+
}
|
|
642
|
+
if ([string]::IsNullOrEmpty($tsStatusOut)) {
|
|
643
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: --remote requested but tailscale status returned no output; --remote is unavailable")
|
|
644
|
+
return 10
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
# Step 3: Bring up Serve (tailnet-only; the public exposure verb is never invoked -- SEC-1).
|
|
648
|
+
$serveErr = ''
|
|
649
|
+
$serveRc = 0
|
|
650
|
+
try {
|
|
651
|
+
$serveErr = (& tailscale serve --bg $Port 2>&1) -join "`n"
|
|
652
|
+
$serveRc = $LASTEXITCODE
|
|
653
|
+
} catch {
|
|
654
|
+
$serveErr = "$_"
|
|
655
|
+
$serveRc = 1
|
|
656
|
+
}
|
|
657
|
+
if ($serveRc -ne 0) {
|
|
658
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: tailscale serve failed (rc=${serveRc}): $serveErr")
|
|
659
|
+
# Revert: take down the 443 frontend mapping (if it was partially set).
|
|
660
|
+
try { & tailscale serve --bg --https=443 off 2>&1 | Out-Null } catch {}
|
|
661
|
+
return 12
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
# Step 4: Resolve the private URL from tailscale's Self.DNSName (the MagicDNS name).
|
|
665
|
+
# NOTE: 'tailscale status --json' is PRETTY-PRINTED, so the regex tolerates whitespace
|
|
666
|
+
# after the colon; '--peers=false' isolates Self so a peer DNSName is never picked up.
|
|
667
|
+
# NEVER fall back to the machine hostname/FQDN here -- that is the local/corporate DNS
|
|
668
|
+
# domain, not the tailnet, and would produce a non-working URL + wrong ACL src.
|
|
669
|
+
$tsJson = ''
|
|
670
|
+
$nodeFqdn = ''
|
|
671
|
+
try { $tsJson = (& tailscale status --json --peers=false 2>&1) -join "`n" } catch { $tsJson = '' }
|
|
672
|
+
if ([string]::IsNullOrEmpty($tsJson)) {
|
|
673
|
+
try { $tsJson = (& tailscale status --json 2>&1) -join "`n" } catch { $tsJson = '' }
|
|
674
|
+
}
|
|
675
|
+
if (-not [string]::IsNullOrEmpty($tsJson)) {
|
|
676
|
+
# Scope the parse to the Self object so a peer DNSName can never be selected
|
|
677
|
+
# regardless of JSON ordering. Find "Self" index, then "Peer" index after it;
|
|
678
|
+
# use the substring between them (or from "Self" to end if no "Peer" present).
|
|
679
|
+
$selfIdx = $tsJson.IndexOf('"Self"')
|
|
680
|
+
$selfBlock = $tsJson
|
|
681
|
+
if ($selfIdx -ge 0) {
|
|
682
|
+
$peerIdx = $tsJson.IndexOf('"Peer"', $selfIdx + 1)
|
|
683
|
+
if ($peerIdx -gt $selfIdx) {
|
|
684
|
+
$selfBlock = $tsJson.Substring($selfIdx, $peerIdx - $selfIdx)
|
|
685
|
+
} else {
|
|
686
|
+
$selfBlock = $tsJson.Substring($selfIdx)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if ($selfBlock -match '"DNSName"\s*:\s*"([^"]*)"') {
|
|
690
|
+
$nodeFqdn = $matches[1] -replace '\.$', ''
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if ([string]::IsNullOrEmpty($nodeFqdn)) {
|
|
694
|
+
# Defensive fallback: a *.ts.net host reported by 'tailscale serve status --json'.
|
|
695
|
+
$serveJson = ''
|
|
696
|
+
try { $serveJson = (& tailscale serve status --json 2>&1) -join "`n" } catch { $serveJson = '' }
|
|
697
|
+
if (-not [string]::IsNullOrEmpty($serveJson)) {
|
|
698
|
+
if ($serveJson -match '([a-z0-9-]+(\.[a-z0-9-]+)*\.ts\.net)') {
|
|
699
|
+
$nodeFqdn = $matches[1]
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (-not [string]::IsNullOrEmpty($nodeFqdn)) {
|
|
704
|
+
$privateUrl = "https://${nodeFqdn}/"
|
|
705
|
+
} else {
|
|
706
|
+
# Could not resolve the tailnet MagicDNS name. Do NOT fabricate a public-domain URL.
|
|
707
|
+
$privateUrl = "(unresolved: run 'tailscale status' to find this host's .ts.net name)"
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
# Resolve display values for the ACL-grant guidance. The grant *src* is an identity only
|
|
711
|
+
# you can choose (login/group/tag); a DNS domain is not a valid selector, so AID shows a
|
|
712
|
+
# placeholder. The *dst* is THIS host's tailnet short-name.
|
|
713
|
+
$nodeShort = ($nodeFqdn -split '\.')[0]
|
|
714
|
+
if ([string]::IsNullOrEmpty($nodeShort) -and -not [string]::IsNullOrEmpty($tsJson)) {
|
|
715
|
+
# Same Self-scoping applied to HostName extraction.
|
|
716
|
+
$selfIdx2 = $tsJson.IndexOf('"Self"')
|
|
717
|
+
$selfBlockHn = $tsJson
|
|
718
|
+
if ($selfIdx2 -ge 0) {
|
|
719
|
+
$peerIdx2 = $tsJson.IndexOf('"Peer"', $selfIdx2 + 1)
|
|
720
|
+
if ($peerIdx2 -gt $selfIdx2) {
|
|
721
|
+
$selfBlockHn = $tsJson.Substring($selfIdx2, $peerIdx2 - $selfIdx2)
|
|
722
|
+
} else {
|
|
723
|
+
$selfBlockHn = $tsJson.Substring($selfIdx2)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if ($selfBlockHn -match '"HostName"\s*:\s*"([^"]*)"') {
|
|
727
|
+
$nodeShort = $matches[1].ToLower()
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
# Step 5: Print FR18 ACL-grant guidance to STDERR (informational only).
|
|
732
|
+
$srcPlaceholder = '<you@example.com>'
|
|
733
|
+
$dstPlaceholder = if ($nodeShort) { $nodeShort } else { '<this-host>' }
|
|
734
|
+
|
|
735
|
+
[Console]::Error.WriteLine('')
|
|
736
|
+
[Console]::Error.WriteLine('Remote exposure is UP (tailnet-private). Every device on your tailnet can now reach this host.')
|
|
737
|
+
[Console]::Error.WriteLine('To restrict access to only you, add a deny-by-default ACL grant in the tailnet policy file:')
|
|
738
|
+
[Console]::Error.WriteLine(' https://login.tailscale.com/admin/acls/file')
|
|
739
|
+
[Console]::Error.WriteLine(" {`"grants`":[{`"src`":[`"$srcPlaceholder`"],`"dst`":[`"$dstPlaceholder`"],`"ip`":[`"tcp:443`"]}]}")
|
|
740
|
+
[Console]::Error.WriteLine("Note: granted identities see all registered project paths/names. See 'aid dashboard --help'.")
|
|
741
|
+
[Console]::Error.WriteLine('')
|
|
742
|
+
|
|
743
|
+
# Step 6: Emit handle + URL on stdout, exit 0.
|
|
744
|
+
Write-Output "tailscale-serve:$Port"
|
|
745
|
+
Write-Output $privateUrl
|
|
746
|
+
return 0
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
# Invoke-AidRemoteTeardown -Handle <s>
|
|
750
|
+
# Revert the tailscale serve mapping created by Invoke-AidRemoteExpose.
|
|
751
|
+
# exit: 0=ok/idempotent 13=revert warned
|
|
752
|
+
function script:Invoke-AidRemoteTeardown {
|
|
753
|
+
param([string]$Handle = '')
|
|
754
|
+
|
|
755
|
+
# Step 1: Parse the handle; malformed/empty -> idempotent exit 0.
|
|
756
|
+
if ([string]::IsNullOrEmpty($Handle)) {
|
|
757
|
+
return 0
|
|
758
|
+
}
|
|
759
|
+
if ($Handle -notmatch '^tailscale-serve:([0-9]+)$') {
|
|
760
|
+
# Malformed handle -- nothing to tear down.
|
|
761
|
+
return 0
|
|
762
|
+
}
|
|
763
|
+
# We don't use the port for teardown (we target the HTTPS:443 frontend, not the backend port).
|
|
764
|
+
|
|
765
|
+
# Step 2: If tailscale is gone now -> WARN, exit 0.
|
|
766
|
+
if (-not (Get-Command 'tailscale' -ErrorAction SilentlyContinue)) {
|
|
767
|
+
[Console]::Error.WriteLine("WARN: aid: dashboard: tailscale not found; cannot revert serve mapping (handle: $Handle)")
|
|
768
|
+
return 0
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
# Step 3: Revert the HTTPS:443 frontend mapping (not a backend port off).
|
|
772
|
+
$offErr = ''
|
|
773
|
+
$offRc = 0
|
|
774
|
+
try {
|
|
775
|
+
$offErr = (& tailscale serve --bg --https=443 off 2>&1) -join "`n"
|
|
776
|
+
$offRc = $LASTEXITCODE
|
|
777
|
+
} catch {
|
|
778
|
+
$offErr = "$_"
|
|
779
|
+
$offRc = 1
|
|
780
|
+
}
|
|
781
|
+
if ($offRc -ne 0) {
|
|
782
|
+
# Fallback: check if serve status shows no other mappings; if so, reset.
|
|
783
|
+
$srvStatus = ''
|
|
784
|
+
try { $srvStatus = (& tailscale serve status 2>&1) -join "`n" } catch { $srvStatus = '' }
|
|
785
|
+
$mappingCount = ([regex]::Matches($srvStatus, '(?:https?://|tcp://)') | Measure-Object).Count
|
|
786
|
+
if ($mappingCount -le 1) {
|
|
787
|
+
try { & tailscale serve reset 2>&1 | Out-Null } catch {}
|
|
788
|
+
# After reset, exit 0 -- best effort.
|
|
789
|
+
return 0
|
|
790
|
+
}
|
|
791
|
+
[Console]::Error.WriteLine("WARN: aid: dashboard: tailscale serve --https=443 off failed (rc=${offRc}): $offErr")
|
|
792
|
+
return 13
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
# Step 4: exit 0 on clean revert.
|
|
796
|
+
return 0
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
# ---------------------------------------------------------------------------
|
|
800
|
+
# Dashboard control (aid dashboard start|stop).
|
|
801
|
+
# ---------------------------------------------------------------------------
|
|
802
|
+
function script:Invoke-AidDashboardCtl {
|
|
803
|
+
param([string[]]$DcArgs)
|
|
804
|
+
|
|
805
|
+
$verb = if ($DcArgs -and $DcArgs.Count -gt 0) { $DcArgs[0] } else { '' }
|
|
806
|
+
$rest = [string[]]@(if ($DcArgs -and $DcArgs.Count -gt 1) { $DcArgs[1..($DcArgs.Count - 1)] } else { @() })
|
|
807
|
+
|
|
808
|
+
# Top-level help.
|
|
809
|
+
if ($verb -in @('-h', '--help', '-Help')) {
|
|
810
|
+
script:Show-AidUsage 'dashboard'
|
|
811
|
+
script:Exit-Aid 0
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if ($verb -ne 'start' -and $verb -ne 'stop') {
|
|
815
|
+
if ([string]::IsNullOrEmpty($verb)) {
|
|
816
|
+
[Console]::Error.WriteLine('ERROR: aid: dashboard requires a verb: start or stop (e.g. aid dashboard start python)')
|
|
817
|
+
script:Exit-Aid 2
|
|
818
|
+
}
|
|
819
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: unknown verb '$verb' (expected: start or stop)")
|
|
820
|
+
script:Exit-Aid 2
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
# --- shared arg parsing ---
|
|
824
|
+
$dcVerbose = $false
|
|
825
|
+
$dcPort = 8787
|
|
826
|
+
$dcRemote = $false
|
|
827
|
+
$dcRuntime = ''
|
|
828
|
+
|
|
829
|
+
$idx = 0
|
|
830
|
+
if ($verb -eq 'start') {
|
|
831
|
+
# First positional after verb is runtime (if not a flag).
|
|
832
|
+
if ($rest.Count -gt 0 -and -not $rest[0].StartsWith('-')) {
|
|
833
|
+
$dcRuntime = $rest[0]
|
|
834
|
+
$idx = 1
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
while ($idx -lt $rest.Count) {
|
|
839
|
+
$a = $rest[$idx]
|
|
840
|
+
switch ($a) {
|
|
841
|
+
{ $_ -in @('-h', '--help', '-Help') } {
|
|
842
|
+
script:Show-AidUsage 'dashboard'
|
|
843
|
+
script:Exit-Aid 0
|
|
844
|
+
}
|
|
845
|
+
{ $_ -in @('-Verbose', '--verbose') } { $dcVerbose = $true }
|
|
846
|
+
{ $_ -in @('-Remote', '--remote') } {
|
|
847
|
+
if ($verb -eq 'stop') {
|
|
848
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: unknown flag: $a")
|
|
849
|
+
script:Exit-Aid 2
|
|
850
|
+
}
|
|
851
|
+
$dcRemote = $true
|
|
852
|
+
}
|
|
853
|
+
{ $_ -in @('-Port', '--port') } {
|
|
854
|
+
if ($verb -eq 'stop') {
|
|
855
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: unknown flag: $a")
|
|
856
|
+
script:Exit-Aid 2
|
|
857
|
+
}
|
|
858
|
+
$idx++
|
|
859
|
+
if ($idx -ge $rest.Count) {
|
|
860
|
+
[Console]::Error.WriteLine('ERROR: aid: dashboard: --port requires a value')
|
|
861
|
+
script:Exit-Aid 2
|
|
862
|
+
}
|
|
863
|
+
$portVal = $rest[$idx]
|
|
864
|
+
if ($portVal -notmatch '^\d+$' -or [int]$portVal -lt 1024 -or [int]$portVal -gt 65535) {
|
|
865
|
+
[Console]::Error.WriteLine('ERROR: aid: dashboard: --port must be an integer in 1024..65535')
|
|
866
|
+
script:Exit-Aid 2
|
|
867
|
+
}
|
|
868
|
+
$dcPort = [int]$portVal
|
|
869
|
+
}
|
|
870
|
+
default {
|
|
871
|
+
if ($a.StartsWith('-')) {
|
|
872
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: unknown flag: $a")
|
|
873
|
+
script:Exit-Aid 2
|
|
874
|
+
}
|
|
875
|
+
# Stray positional.
|
|
876
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: unknown flag: $a")
|
|
877
|
+
script:Exit-Aid 2
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
$idx++
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if ($verb -eq 'start') {
|
|
884
|
+
script:Invoke-DcStart -Runtime $dcRuntime -Port $dcPort -Remote $dcRemote -Verbose $dcVerbose
|
|
885
|
+
} else {
|
|
886
|
+
script:Invoke-DcStop -Verbose $dcVerbose
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function script:Invoke-DcStart {
|
|
891
|
+
param([string]$Runtime, [int]$Port, [bool]$Remote, [bool]$Verbose)
|
|
892
|
+
|
|
893
|
+
# Step 1: validate runtime.
|
|
894
|
+
if ([string]::IsNullOrEmpty($Runtime)) {
|
|
895
|
+
[Console]::Error.WriteLine('ERROR: aid: dashboard start requires a runtime: node or python (e.g. aid dashboard start python)')
|
|
896
|
+
script:Exit-Aid 2
|
|
897
|
+
}
|
|
898
|
+
if ($Runtime -ne 'node' -and $Runtime -ne 'python') {
|
|
899
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: unknown runtime '$Runtime' (expected: node or python)")
|
|
900
|
+
script:Exit-Aid 2
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
# pid/log live in the per-user state home (.temp), always writable.
|
|
904
|
+
# FR10 precedent: always per-user $HOME/.aid, never AID_STATE_HOME on global installs.
|
|
905
|
+
$pidFile = Join-Path $HOME (Join-Path '.aid' (Join-Path '.temp' 'dashboard.pid'))
|
|
906
|
+
$logFile = Join-Path $HOME (Join-Path '.aid' (Join-Path '.temp' 'dashboard.log'))
|
|
907
|
+
|
|
908
|
+
# Step 4: already-running guard (stale-record reclaim included).
|
|
909
|
+
if (Test-Path $pidFile -PathType Leaf) {
|
|
910
|
+
$pidContent = Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue
|
|
911
|
+
$existingPid = 0
|
|
912
|
+
$existingPort = 0
|
|
913
|
+
$existingRuntime = ''
|
|
914
|
+
if ($pidContent -match '"pid"\s*:\s*(\d+)') { $existingPid = [int]$matches[1] }
|
|
915
|
+
if ($pidContent -match '"port"\s*:\s*(\d+)') { $existingPort = [int]$matches[1] }
|
|
916
|
+
if ($pidContent -match '"runtime"\s*:\s*"([^"]+)"') { $existingRuntime = $matches[1] }
|
|
917
|
+
$procAlive = $false
|
|
918
|
+
if ($existingPid -gt 0) {
|
|
919
|
+
try { $null = Get-Process -Id $existingPid -ErrorAction Stop; $procAlive = $true } catch {}
|
|
920
|
+
}
|
|
921
|
+
if ($procAlive) {
|
|
922
|
+
Write-Host "aid: dashboard already running (runtime $existingRuntime, http://127.0.0.1:${existingPort}); run 'aid dashboard stop' first."
|
|
923
|
+
script:Exit-Aid 8
|
|
924
|
+
} else {
|
|
925
|
+
# Stale record: reclaim silently (or verbosely).
|
|
926
|
+
if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: reclaiming stale record (pid $existingPid is dead)") }
|
|
927
|
+
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
|
|
928
|
+
if (Test-Path $logFile -PathType Leaf) { Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue }
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
# Step 5: check runtime on PATH.
|
|
933
|
+
if ($Runtime -eq 'python') {
|
|
934
|
+
$interp = 'python3'
|
|
935
|
+
if (-not (Get-Command 'python3' -ErrorAction SilentlyContinue)) {
|
|
936
|
+
[Console]::Error.WriteLine('ERROR: aid: dashboard: python3 not found on PATH (install it, or try: aid dashboard start node)')
|
|
937
|
+
script:Exit-Aid 9
|
|
938
|
+
}
|
|
939
|
+
} else {
|
|
940
|
+
$interp = 'node'
|
|
941
|
+
if (-not (Get-Command 'node' -ErrorAction SilentlyContinue)) {
|
|
942
|
+
[Console]::Error.WriteLine('ERROR: aid: dashboard: node not found on PATH (install it, or try: aid dashboard start python)')
|
|
943
|
+
script:Exit-Aid 9
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
# Step 6: locate the server entry point.
|
|
948
|
+
# <assets> = $AID_CODE_HOME/dashboard (the co-vendored server+reader unit in the install tree).
|
|
949
|
+
$assetsDir = Join-Path $script:_AidCodeHome 'dashboard'
|
|
950
|
+
if ($Runtime -eq 'python') {
|
|
951
|
+
$entryPoint = Join-Path $assetsDir (Join-Path 'server' 'server.py')
|
|
952
|
+
} else {
|
|
953
|
+
$entryPoint = Join-Path $assetsDir (Join-Path 'server' 'server.mjs')
|
|
954
|
+
}
|
|
955
|
+
if (-not (Test-Path $entryPoint -PathType Leaf)) {
|
|
956
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: the dashboard server is missing from the install tree ($Runtime entry-point not found at $entryPoint); run 'aid update' or reinstall aid")
|
|
957
|
+
script:Exit-Aid 7
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
# Ensure log dir exists (per-user state home, always writable).
|
|
961
|
+
$tempDir = Join-Path $HOME (Join-Path '.aid' '.temp')
|
|
962
|
+
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
|
963
|
+
|
|
964
|
+
# Step 7: spawn the server child (detached daemon).
|
|
965
|
+
# SEC-1: literal 127.0.0.1 -- never read from input/config/env.
|
|
966
|
+
# WINDOWS/POWERSHELL: do NOT pass -RedirectStandardOutput/-RedirectStandardError here.
|
|
967
|
+
# Start-Process WITH redirection uses full handle inheritance, so the long-lived server
|
|
968
|
+
# inherits and holds open the caller's stdout/stderr pipe; a caller that captures our output
|
|
969
|
+
# (e.g. `$out = aid dashboard start 2>&1`, as the CI smoke does) then HANGS forever waiting
|
|
970
|
+
# for EOF. Omitting redirection makes Start-Process use ShellExecute, which does NOT inherit
|
|
971
|
+
# the caller's handles (no hang) and fully detaches the daemon -- the Windows analog of the
|
|
972
|
+
# Bash launcher's `setsid`. Trade-off: the server's own stdout/stderr are not file-captured on
|
|
973
|
+
# Windows; readiness is verified by TCP poll (not the log), so start/stop/status are
|
|
974
|
+
# behaviour-identical. (KI: Windows dashboard server log not captured; Bash captures it via
|
|
975
|
+
# `setsid ... >"$log_file" 2>&1`.)
|
|
976
|
+
# The multi-repo server (feature-010) serves every registered repo from the registry
|
|
977
|
+
# under AID_STATE_HOME; export AID_HOME=AID_STATE_HOME so the server resolves the
|
|
978
|
+
# registry via its legacy AID_HOME env var (delivery-008 seam).
|
|
979
|
+
$env:AID_HOME = $script:_AidStateHome
|
|
980
|
+
$spawnArgs = @($entryPoint, '--host', '127.0.0.1', '--port', "$Port")
|
|
981
|
+
$proc = Start-Process -FilePath $interp `
|
|
982
|
+
-ArgumentList $spawnArgs `
|
|
983
|
+
-PassThru `
|
|
984
|
+
-WindowStyle Hidden
|
|
985
|
+
|
|
986
|
+
$childPid = $proc.Id
|
|
987
|
+
|
|
988
|
+
if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: spawned $Runtime server (pid $childPid, port $Port)") }
|
|
989
|
+
|
|
990
|
+
# Step 8: bounded readiness wait (~5s, poll TCP socket).
|
|
991
|
+
$ready = $false
|
|
992
|
+
$attempts = 0
|
|
993
|
+
$maxAttempts = 50 # 50 x 0.1s = 5s
|
|
994
|
+
while ($attempts -lt $maxAttempts) {
|
|
995
|
+
# Check child is still alive.
|
|
996
|
+
$childAlive = $false
|
|
997
|
+
try { $null = Get-Process -Id $childPid -ErrorAction Stop; $childAlive = $true } catch {}
|
|
998
|
+
if (-not $childAlive) {
|
|
999
|
+
# Child exited early.
|
|
1000
|
+
[Console]::Error.WriteLine('ERROR: aid: dashboard: server failed to start (last log lines, if any):')
|
|
1001
|
+
if (Test-Path $logFile -PathType Leaf) {
|
|
1002
|
+
Get-Content -LiteralPath $logFile -Tail 10 -ErrorAction SilentlyContinue | ForEach-Object { [Console]::Error.WriteLine($_) }
|
|
1003
|
+
Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue
|
|
1004
|
+
}
|
|
1005
|
+
script:Exit-Aid 3
|
|
1006
|
+
}
|
|
1007
|
+
# Try TCP connect to 127.0.0.1:<Port>.
|
|
1008
|
+
try {
|
|
1009
|
+
$tcpClient = [System.Net.Sockets.TcpClient]::new()
|
|
1010
|
+
$tcpClient.Connect('127.0.0.1', $Port)
|
|
1011
|
+
$tcpClient.Close()
|
|
1012
|
+
$ready = $true
|
|
1013
|
+
break
|
|
1014
|
+
} catch {}
|
|
1015
|
+
Start-Sleep -Milliseconds 100
|
|
1016
|
+
$attempts++
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
# Check if child is still alive even if not ready (timeout case).
|
|
1020
|
+
if (-not $ready) {
|
|
1021
|
+
$childAlive = $false
|
|
1022
|
+
try { $null = Get-Process -Id $childPid -ErrorAction Stop; $childAlive = $true } catch {}
|
|
1023
|
+
if (-not $childAlive) {
|
|
1024
|
+
[Console]::Error.WriteLine('ERROR: aid: dashboard: server failed to start (last log lines, if any):')
|
|
1025
|
+
if (Test-Path $logFile -PathType Leaf) {
|
|
1026
|
+
Get-Content -LiteralPath $logFile -Tail 10 -ErrorAction SilentlyContinue | ForEach-Object { [Console]::Error.WriteLine($_) }
|
|
1027
|
+
Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue
|
|
1028
|
+
}
|
|
1029
|
+
script:Exit-Aid 3
|
|
1030
|
+
}
|
|
1031
|
+
# Timeout but pid alive: warn and continue.
|
|
1032
|
+
[Console]::Error.WriteLine("WARN: aid: dashboard: server started but not yet responding on :${Port}; check $logFile")
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
# Step 9: write dashboard.pid JSON record (DM-1).
|
|
1036
|
+
$startedAt = [System.DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
1037
|
+
if (-not $startedAt) { $startedAt = 'unknown' }
|
|
1038
|
+
# String.Replace (literal) doubles each backslash for valid JSON on Windows paths.
|
|
1039
|
+
$pidJson = @"
|
|
1040
|
+
{
|
|
1041
|
+
"schema": 1,
|
|
1042
|
+
"pid": $childPid,
|
|
1043
|
+
"runtime": "$Runtime",
|
|
1044
|
+
"port": $Port,
|
|
1045
|
+
"bind": "127.0.0.1",
|
|
1046
|
+
"remote": false,
|
|
1047
|
+
"remote_handle": null,
|
|
1048
|
+
"started_at": "$startedAt",
|
|
1049
|
+
"logfile": "$($logFile.Replace('\', '\\'))"
|
|
1050
|
+
}
|
|
1051
|
+
"@
|
|
1052
|
+
[System.IO.File]::WriteAllText($pidFile, $pidJson)
|
|
1053
|
+
|
|
1054
|
+
# Step 10: --remote: invoke Invoke-AidRemoteExpose; update record on success.
|
|
1055
|
+
if ($Remote) {
|
|
1056
|
+
# Capture all pipeline output; let stderr (guidance + errors) flow to the user.
|
|
1057
|
+
# Invoke-AidRemoteExpose emits handle+URL via Write-Output (strings) and the return
|
|
1058
|
+
# integer via 'return <n>' -- both land on the pipeline. We split by type.
|
|
1059
|
+
$rawOut = @(script:Invoke-AidRemoteExpose -Port $Port)
|
|
1060
|
+
$exposeRc = 0
|
|
1061
|
+
$exposeHandle = ''
|
|
1062
|
+
$exposeUrl = ''
|
|
1063
|
+
$strLines = [System.Collections.Generic.List[string]]::new()
|
|
1064
|
+
foreach ($item in $rawOut) {
|
|
1065
|
+
if ($item -is [int]) { $exposeRc = $item }
|
|
1066
|
+
elseif ($item -ne $null) { $strLines.Add([string]$item) }
|
|
1067
|
+
}
|
|
1068
|
+
if ($exposeRc -ne 0) {
|
|
1069
|
+
# All expose failures (10/11/12) map to user-facing exit 10.
|
|
1070
|
+
# dashboard stays local-only (server remains running).
|
|
1071
|
+
[Console]::Error.WriteLine("ERROR: aid: dashboard: --remote requested but the secure remote-exposure mechanism is not available on this host; the dashboard is NOT exposed. Local server still running at http://127.0.0.1:${Port}.")
|
|
1072
|
+
script:Exit-Aid 10
|
|
1073
|
+
}
|
|
1074
|
+
if ($strLines.Count -ge 1) { $exposeHandle = $strLines[0] }
|
|
1075
|
+
if ($strLines.Count -ge 2) { $exposeUrl = $strLines[1] }
|
|
1076
|
+
# Update the record with remote=true and the handle.
|
|
1077
|
+
$pidJson2 = @"
|
|
1078
|
+
{
|
|
1079
|
+
"schema": 1,
|
|
1080
|
+
"pid": $childPid,
|
|
1081
|
+
"runtime": "$Runtime",
|
|
1082
|
+
"port": $Port,
|
|
1083
|
+
"bind": "127.0.0.1",
|
|
1084
|
+
"remote": true,
|
|
1085
|
+
"remote_handle": "$exposeHandle",
|
|
1086
|
+
"started_at": "$startedAt",
|
|
1087
|
+
"logfile": "$($logFile.Replace('\', '\\'))"
|
|
1088
|
+
}
|
|
1089
|
+
"@
|
|
1090
|
+
[System.IO.File]::WriteAllText($pidFile, $pidJson2)
|
|
1091
|
+
# Step 11 (remote success): print local URL + remote URL.
|
|
1092
|
+
Write-Host "Dashboard ($Runtime) running at http://127.0.0.1:${Port} -- stop with: aid dashboard stop"
|
|
1093
|
+
if ($exposeUrl -like 'https://*') {
|
|
1094
|
+
Write-Host "Remote (private): $exposeUrl"
|
|
1095
|
+
} else {
|
|
1096
|
+
Write-Host "Remote exposure is UP (tailnet-private), but the .ts.net URL could not be auto-detected -- run 'tailscale status' on this host to find it."
|
|
1097
|
+
}
|
|
1098
|
+
script:Exit-Aid 0
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
# Step 11: print success (local-only).
|
|
1102
|
+
Write-Host "Dashboard ($Runtime) running at http://127.0.0.1:${Port} -- stop with: aid dashboard stop"
|
|
1103
|
+
script:Exit-Aid 0
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function script:Invoke-DcStop {
|
|
1107
|
+
param([bool]$Verbose)
|
|
1108
|
+
|
|
1109
|
+
$pidFile = Join-Path $HOME (Join-Path '.aid' (Join-Path '.temp' 'dashboard.pid'))
|
|
1110
|
+
|
|
1111
|
+
# Step 3: read record; absent or stale -> idempotent exit 0.
|
|
1112
|
+
if (-not (Test-Path $pidFile -PathType Leaf)) {
|
|
1113
|
+
Write-Host 'aid: dashboard: not running (nothing to stop).'
|
|
1114
|
+
script:Exit-Aid 0
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
$pidContent = Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue
|
|
1118
|
+
$existingPid = 0
|
|
1119
|
+
$logFile = ''
|
|
1120
|
+
if ($pidContent -match '"pid"\s*:\s*(\d+)') { $existingPid = [int]$matches[1] }
|
|
1121
|
+
if ($pidContent -match '"logfile"\s*:\s*"([^"]+)"') { $logFile = $matches[1] }
|
|
1122
|
+
|
|
1123
|
+
$procAlive = $false
|
|
1124
|
+
if ($existingPid -gt 0) {
|
|
1125
|
+
try { $null = Get-Process -Id $existingPid -ErrorAction Stop; $procAlive = $true } catch {}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (-not $procAlive) {
|
|
1129
|
+
if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: record exists but pid $existingPid is dead; cleaning up.") }
|
|
1130
|
+
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
|
|
1131
|
+
if ($logFile -and (Test-Path $logFile -PathType Leaf)) { Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue }
|
|
1132
|
+
Write-Host 'aid: dashboard: not running (nothing to stop).'
|
|
1133
|
+
script:Exit-Aid 0
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
# Step 4: --remote teardown (if the record says remote=true, call Invoke-AidRemoteTeardown).
|
|
1137
|
+
$existingRemote = ''
|
|
1138
|
+
$existingHandle = ''
|
|
1139
|
+
if ($pidContent -match '"remote"\s*:\s*(true|false)') { $existingRemote = $matches[1] }
|
|
1140
|
+
if ($pidContent -match '"remote_handle"\s*:\s*"([^"]*)"') { $existingHandle = $matches[1] }
|
|
1141
|
+
if ($existingRemote -eq 'true' -and -not [string]::IsNullOrEmpty($existingHandle)) {
|
|
1142
|
+
if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: tearing down remote exposure (handle: $existingHandle)") }
|
|
1143
|
+
$teardownRc = script:Invoke-AidRemoteTeardown -Handle $existingHandle
|
|
1144
|
+
if ($teardownRc -eq 13) {
|
|
1145
|
+
[Console]::Error.WriteLine('WARN: aid: dashboard: remote teardown reported a warning; continuing server shutdown')
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
# Step 5: terminate the process cleanly.
|
|
1150
|
+
if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: sending Stop-Process to pid $existingPid") }
|
|
1151
|
+
try { Stop-Process -Id $existingPid -ErrorAction SilentlyContinue } catch {}
|
|
1152
|
+
|
|
1153
|
+
# Wait up to ~5s for exit.
|
|
1154
|
+
$waited = 0
|
|
1155
|
+
while ($waited -lt 50) {
|
|
1156
|
+
$stillAlive = $false
|
|
1157
|
+
try { $null = Get-Process -Id $existingPid -ErrorAction Stop; $stillAlive = $true } catch {}
|
|
1158
|
+
if (-not $stillAlive) { break }
|
|
1159
|
+
Start-Sleep -Milliseconds 100
|
|
1160
|
+
$waited++
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
# Escalate to -Force if still alive.
|
|
1164
|
+
$stillAlive = $false
|
|
1165
|
+
try { $null = Get-Process -Id $existingPid -ErrorAction Stop; $stillAlive = $true } catch {}
|
|
1166
|
+
if ($stillAlive) {
|
|
1167
|
+
if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: escalating to Stop-Process -Force on pid $existingPid") }
|
|
1168
|
+
try { Stop-Process -Id $existingPid -Force -ErrorAction SilentlyContinue } catch {}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
# Step 6: remove record and logfile, print success.
|
|
1172
|
+
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
|
|
1173
|
+
if ($logFile -and (Test-Path $logFile -PathType Leaf)) { Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue }
|
|
1174
|
+
Write-Host 'aid: dashboard stopped.'
|
|
1175
|
+
script:Exit-Aid 0
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
# ---------------------------------------------------------------------------
|
|
1179
|
+
# C4': Get-AidRepoFormat <Repo>
|
|
1180
|
+
# Read the format_version stamp from <Repo>/.aid/settings.yml.
|
|
1181
|
+
# Greps the FIRST ^format_version: line, replicates the era-a closure strip
|
|
1182
|
+
# logic inline (prefix strip, trim, inline # comment strip, quote-unwrap),
|
|
1183
|
+
# validates as ^\d+$; returns the integer.
|
|
1184
|
+
# Collapses absent/empty/non-integer/malformed/negative to 0 (legacy default).
|
|
1185
|
+
# Never returns a value > sup from a garbled stamp (fail-safe).
|
|
1186
|
+
# Defined here (before dispatch) so it is available to status/bare-aid/update.
|
|
1187
|
+
# ---------------------------------------------------------------------------
|
|
1188
|
+
function script:Get-AidRepoFormat {
|
|
1189
|
+
param([string]$Repo)
|
|
1190
|
+
$settingsFile = Join-Path $Repo (Join-Path '.aid' 'settings.yml')
|
|
1191
|
+
if (-not (Test-Path $settingsFile -PathType Leaf)) { return 0 }
|
|
1192
|
+
# First-match read (parity with duplicate-line policy).
|
|
1193
|
+
$rawLine = $null
|
|
1194
|
+
foreach ($ln in (Get-Content -LiteralPath $settingsFile -Encoding utf8 -ErrorAction SilentlyContinue)) {
|
|
1195
|
+
if ($ln -match '^format_version:') { $rawLine = $ln; break }
|
|
1196
|
+
}
|
|
1197
|
+
if (-not $rawLine) { return 0 }
|
|
1198
|
+
# Replicate the era-a closure strip logic inline (column-0 key variant).
|
|
1199
|
+
# Step 1: strip the "format_version:" prefix.
|
|
1200
|
+
$val = $rawLine -replace '^format_version:', ''
|
|
1201
|
+
# Step 2: strip one optional leading space (the colon-space separator).
|
|
1202
|
+
if ($val.StartsWith(' ')) { $val = $val.Substring(1) }
|
|
1203
|
+
# Step 3: strip inline # comment (first " #" to end of line).
|
|
1204
|
+
$commentIdx = $val.IndexOf(' #')
|
|
1205
|
+
if ($commentIdx -ge 0) { $val = $val.Substring(0, $commentIdx) }
|
|
1206
|
+
# Step 4: quote-unwrap (double then single).
|
|
1207
|
+
$val = $val.Trim('"').Trim("'")
|
|
1208
|
+
# Step 5: full trim (remaining whitespace).
|
|
1209
|
+
$val = $val.Trim()
|
|
1210
|
+
# Step 6: validate non-negative integer; collapse anything else to 0.
|
|
1211
|
+
if ($val -match '^\d+$') { return [int]$val }
|
|
1212
|
+
return 0
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
# ---------------------------------------------------------------------------
|
|
1216
|
+
# C5': Invoke-AidFormatGate <Repo>
|
|
1217
|
+
# 3-way classify <Repo>'s format stamp vs $AidSupportedFormat:
|
|
1218
|
+
# repo > sup -> refuse (stderr, return 1, no .aid/ write)
|
|
1219
|
+
# repo < sup -> warn + offer aid update (stdout, return 0, non-blocking)
|
|
1220
|
+
# repo == sup -> silent (return 0)
|
|
1221
|
+
# AID_NO_MIGRATE=1 suppresses the warn+offer notice only; never the refuse.
|
|
1222
|
+
# Defined here (before dispatch) so it is available to status/bare-aid/update.
|
|
1223
|
+
# ---------------------------------------------------------------------------
|
|
1224
|
+
function script:Invoke-AidFormatGate {
|
|
1225
|
+
param([string]$Repo)
|
|
1226
|
+
$repoFmt = script:Get-AidRepoFormat -Repo $Repo
|
|
1227
|
+
$sup = $script:AidSupportedFormat
|
|
1228
|
+
if ($repoFmt -gt $sup) {
|
|
1229
|
+
[Console]::Error.WriteLine("ERROR: aid: project format $repoFmt is newer than this CLI supports ($sup). Upgrade the aid CLI to operate on this project.")
|
|
1230
|
+
return 1
|
|
1231
|
+
}
|
|
1232
|
+
if ($repoFmt -lt $sup) {
|
|
1233
|
+
$manifestPath = Join-Path $Repo (Join-Path '.aid' '.aid-manifest.json')
|
|
1234
|
+
if ($env:AID_NO_MIGRATE -ne '1' -and (Test-Path $manifestPath -PathType Leaf)) {
|
|
1235
|
+
Write-Host "WARN: aid: this project uses an older format (v${repoFmt}; current: v${sup}). Run: aid update"
|
|
1236
|
+
}
|
|
1237
|
+
return 0
|
|
1238
|
+
}
|
|
1239
|
+
# repo == sup: silent.
|
|
1240
|
+
return 0
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
# ---------------------------------------------------------------------------
|
|
1244
|
+
# Registry helpers (DR-1 / FF-1 / FR29 -- PS twin of Bash registry_register /
|
|
1245
|
+
# registry_unregister). Implements DM-1 schema, DD-3 Move-Item -Force atomic
|
|
1246
|
+
# write, DD-REG-FMT line-scan (no YAML library).
|
|
1247
|
+
# Defined here (before the sentinel try block) so they are available to the
|
|
1248
|
+
# dispatch handlers (status, bare-aid, update-tool) that call them.
|
|
1249
|
+
# ---------------------------------------------------------------------------
|
|
1250
|
+
|
|
1251
|
+
# Read the repos list from registry.yml (line-scan, no YAML parser).
|
|
1252
|
+
# Returns [string[]] of canonical paths; empty array when file is absent.
|
|
1253
|
+
function script:Get-RegistryRepos {
|
|
1254
|
+
param([string]$RegPath)
|
|
1255
|
+
if (-not (Test-Path $RegPath -PathType Leaf)) { return @() }
|
|
1256
|
+
$results = [System.Collections.Generic.List[string]]::new()
|
|
1257
|
+
foreach ($line in (Get-Content -LiteralPath $RegPath -Encoding utf8 -ErrorAction SilentlyContinue)) {
|
|
1258
|
+
if ($line -match '^\s*-\s+(.+\S)\s*$') {
|
|
1259
|
+
$results.Add($Matches[1])
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return $results.ToArray()
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
# Get-RegistryUnion
|
|
1266
|
+
# Return the deduped sort-unique union of the primary tier ($script:_AidStateHome/
|
|
1267
|
+
# registry.yml, which honors the AID_HOME override via the startup scope derivation)
|
|
1268
|
+
# and, when $script:_AidStateHome differs from $HOME/.aid, also the $HOME/.aid/
|
|
1269
|
+
# registry.yml fallback tier. Prunes stale entries quietly: a path is emitted only
|
|
1270
|
+
# if its .aid/ sub-directory still exists. Never writes or mutates any registry file.
|
|
1271
|
+
#
|
|
1272
|
+
# Per-user collapse: when $script:_AidStateHome == $HOME/.aid the two paths are the
|
|
1273
|
+
# same file -- the union degenerates to a single-tier read (no double-read, no elevation).
|
|
1274
|
+
# Mirror of bash _registry_read_union.
|
|
1275
|
+
function script:Get-RegistryUnion {
|
|
1276
|
+
$primaryReg = Join-Path $script:_AidStateHome 'registry.yml'
|
|
1277
|
+
$userDotAid = Join-Path $HOME '.aid'
|
|
1278
|
+
$fallbackReg = Join-Path $userDotAid 'registry.yml'
|
|
1279
|
+
|
|
1280
|
+
$raw = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
|
|
1281
|
+
|
|
1282
|
+
# Primary tier (always read).
|
|
1283
|
+
foreach ($p in (script:Get-RegistryRepos -RegPath $primaryReg)) {
|
|
1284
|
+
if ($p) { [void]$raw.Add($p) }
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
# Fallback tier only when paths differ (global install).
|
|
1288
|
+
$primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
|
|
1289
|
+
$fallbackNorm = [System.IO.Path]::GetFullPath($userDotAid)
|
|
1290
|
+
if ($primaryNorm -ne $fallbackNorm) {
|
|
1291
|
+
foreach ($p in (script:Get-RegistryRepos -RegPath $fallbackReg)) {
|
|
1292
|
+
if ($p) { [void]$raw.Add($p) }
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
# Quiet-prune: emit only paths whose .aid/ still exists.
|
|
1297
|
+
$result = [System.Collections.Generic.List[string]]::new()
|
|
1298
|
+
foreach ($p in ($raw | Sort-Object)) {
|
|
1299
|
+
$aidDir = Join-Path $p '.aid'
|
|
1300
|
+
if (Test-Path $aidDir -PathType Container) {
|
|
1301
|
+
$result.Add($p)
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
return $result.ToArray()
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
# Get-RegistryRawUnion
|
|
1308
|
+
# Like Get-RegistryUnion but WITHOUT the .aid/ quiet-prune.
|
|
1309
|
+
# Returns EVERY registered path including paths whose .aid/ is absent or the
|
|
1310
|
+
# directory does not exist. Used by 'aid projects list' to render no-aid/missing
|
|
1311
|
+
# states. Never writes or mutates any registry file on read.
|
|
1312
|
+
#
|
|
1313
|
+
# Per-user collapse: when AID_STATE_HOME == $HOME/.aid, the single-tier read
|
|
1314
|
+
# preserves registry-file order (no sort), mirroring bash. When paths differ
|
|
1315
|
+
# (global install), the deduped union of primary + fallback is sort-unique.
|
|
1316
|
+
# Mirror of bash _registry_read_raw_union.
|
|
1317
|
+
function script:Get-RegistryRawUnion {
|
|
1318
|
+
$primaryReg = Join-Path $script:_AidStateHome 'registry.yml'
|
|
1319
|
+
$userDotAid = Join-Path $HOME '.aid'
|
|
1320
|
+
$fallbackReg = Join-Path $userDotAid 'registry.yml'
|
|
1321
|
+
|
|
1322
|
+
$primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
|
|
1323
|
+
$fallbackNorm = [System.IO.Path]::GetFullPath($userDotAid)
|
|
1324
|
+
$perUser = ($primaryNorm -eq $fallbackNorm)
|
|
1325
|
+
|
|
1326
|
+
$result = [System.Collections.Generic.List[string]]::new()
|
|
1327
|
+
|
|
1328
|
+
if ($perUser) {
|
|
1329
|
+
# Per-user collapse: single-tier; preserve registry-file order (no sort).
|
|
1330
|
+
foreach ($p in (script:Get-RegistryRepos -RegPath $primaryReg)) {
|
|
1331
|
+
if ($p) { $result.Add($p) }
|
|
1332
|
+
}
|
|
1333
|
+
} else {
|
|
1334
|
+
# Distinct paths: sort-unique union of primary and fallback.
|
|
1335
|
+
# Use SortedSet with Ordinal comparer to match bash `sort -u` (bytewise, uppercase-first).
|
|
1336
|
+
$raw = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal)
|
|
1337
|
+
foreach ($p in (script:Get-RegistryRepos -RegPath $primaryReg)) {
|
|
1338
|
+
if ($p) { [void]$raw.Add($p) }
|
|
1339
|
+
}
|
|
1340
|
+
foreach ($p in (script:Get-RegistryRepos -RegPath $fallbackReg)) {
|
|
1341
|
+
if ($p) { [void]$raw.Add($p) }
|
|
1342
|
+
}
|
|
1343
|
+
foreach ($p in $raw) {
|
|
1344
|
+
$result.Add($p)
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return $result.ToArray()
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
# Resolve-AidTier <CanonPath> [-TierOverride <string>]
|
|
1352
|
+
# Deterministic, non-interactive tier selection for 'aid projects add' (FR6/AC6).
|
|
1353
|
+
# Returns "user" or "shared".
|
|
1354
|
+
#
|
|
1355
|
+
# Auto rule:
|
|
1356
|
+
# - Returns "user" if _AidScope != "global" (per-user install), OR if the
|
|
1357
|
+
# path is under $HOME (any install type).
|
|
1358
|
+
# - Otherwise (global install AND path outside $HOME): returns "shared".
|
|
1359
|
+
#
|
|
1360
|
+
# Override via -TierOverride:
|
|
1361
|
+
# "" no override, use auto rule (default)
|
|
1362
|
+
# "--local" force "user" regardless of install type/path
|
|
1363
|
+
# "--shared" force "shared"; but on a per-user install (AID_STATE_HOME == ~/.aid)
|
|
1364
|
+
# there is no separate shared tier -- returns "user" and prints a
|
|
1365
|
+
# one-line notice to stderr.
|
|
1366
|
+
#
|
|
1367
|
+
# Never prompts; never blocks; always returns normally.
|
|
1368
|
+
# Mirror of bash _aid_resolve_tier.
|
|
1369
|
+
function script:Resolve-AidTier {
|
|
1370
|
+
param(
|
|
1371
|
+
[string]$CanonPath,
|
|
1372
|
+
[string]$TierOverride = ''
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
# Detect per-user install (no separate shared tier).
|
|
1376
|
+
$userDotAid = Join-Path $HOME '.aid'
|
|
1377
|
+
$primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
|
|
1378
|
+
$userNorm = [System.IO.Path]::GetFullPath($userDotAid)
|
|
1379
|
+
$perUser = ($primaryNorm -eq $userNorm)
|
|
1380
|
+
|
|
1381
|
+
# Handle explicit override flags.
|
|
1382
|
+
switch ($TierOverride) {
|
|
1383
|
+
'--local' { return 'user' }
|
|
1384
|
+
'--shared' {
|
|
1385
|
+
if ($perUser) {
|
|
1386
|
+
[Console]::Error.WriteLine('no shared tier under a per-user install; using user tier')
|
|
1387
|
+
return 'user'
|
|
1388
|
+
}
|
|
1389
|
+
return 'shared'
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
# Auto rule: user if per-user install OR path is under $HOME.
|
|
1394
|
+
$inHome = $CanonPath.StartsWith($HOME + [System.IO.Path]::DirectorySeparatorChar) -or
|
|
1395
|
+
$CanonPath -eq $HOME
|
|
1396
|
+
|
|
1397
|
+
if ($script:_AidScope -ne 'global' -or $inHome) {
|
|
1398
|
+
return 'user'
|
|
1399
|
+
}
|
|
1400
|
+
return 'shared'
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
# Get-AidProjectState <Path>
|
|
1404
|
+
# Return the state of an AID project directory:
|
|
1405
|
+
# "missing" -- the directory does not exist
|
|
1406
|
+
# "no-aid" -- directory exists but has no .aid/ subdirectory
|
|
1407
|
+
# "untracked" -- .aid/ exists but no .aid/.aid-manifest.json is present
|
|
1408
|
+
# "vX.Y.Z" -- tracked; semver version string from .aid/.aid-manifest.json
|
|
1409
|
+
# (key "aid_version"), falling back to .aid/.aid-version
|
|
1410
|
+
# Never errors; always returns normally.
|
|
1411
|
+
# Mirror of bash _aid_project_state.
|
|
1412
|
+
function script:Get-AidProjectState {
|
|
1413
|
+
param([string]$Path)
|
|
1414
|
+
if (-not (Test-Path $Path -PathType Container)) { return 'missing' }
|
|
1415
|
+
$aidDir = Join-Path $Path '.aid'
|
|
1416
|
+
if (-not (Test-Path $aidDir -PathType Container)) { return 'no-aid' }
|
|
1417
|
+
$manifest = Join-Path $aidDir '.aid-manifest.json'
|
|
1418
|
+
$verFile = Join-Path $aidDir '.aid-version'
|
|
1419
|
+
if (Test-Path $manifest -PathType Leaf) {
|
|
1420
|
+
$content = Get-Content -LiteralPath $manifest -Raw -Encoding utf8 -ErrorAction SilentlyContinue
|
|
1421
|
+
if ($content -and $content -match '"aid_version"\s*:\s*"([^"]*)"') {
|
|
1422
|
+
$raw = $Matches[1]
|
|
1423
|
+
if ($raw -match '([0-9]+\.[0-9]+\.[0-9]+[^\s]*)') {
|
|
1424
|
+
return $Matches[1]
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
if (Test-Path $verFile -PathType Leaf) {
|
|
1429
|
+
$vfContent = Get-Content -LiteralPath $verFile -Raw -Encoding utf8 -ErrorAction SilentlyContinue
|
|
1430
|
+
if ($vfContent -and $vfContent -match '([0-9]+\.[0-9]+\.[0-9]+[^\s]*)') {
|
|
1431
|
+
return $Matches[1]
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return 'untracked'
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
# Get-AidProjectTools <Path>
|
|
1438
|
+
# Return a comma-separated list of tool names installed in an AID project, as
|
|
1439
|
+
# recorded in <path>/.aid/.aid-manifest.json under the "tools" object.
|
|
1440
|
+
# Returns empty string when the manifest is absent or has no tools.
|
|
1441
|
+
#
|
|
1442
|
+
# Mirrors the canonical bash awk extractor (lib/aid-install-core.sh:~1019):
|
|
1443
|
+
# /"tools"/{found=1} found && /^ "[a-z]/{gsub(/[^a-zA-Z0-9_.-]/,"",$1); if ($1!="") print $1}
|
|
1444
|
+
# Scans the whole file once found=1 -- no break condition -- so ALL tool names
|
|
1445
|
+
# at exactly 4-space indent with a lowercase-initial key are collected.
|
|
1446
|
+
# Does NOT use a closing-brace break (the bash awk doesn't either), avoiding
|
|
1447
|
+
# the premature-exit bug that fires on each tool's own closing " }".
|
|
1448
|
+
# Mirror of bash _aid_project_tools.
|
|
1449
|
+
function script:Get-AidProjectTools {
|
|
1450
|
+
param([string]$Path)
|
|
1451
|
+
$manifest = Join-Path $Path '.aid' '.aid-manifest.json'
|
|
1452
|
+
if (-not (Test-Path $manifest -PathType Leaf)) { return '' }
|
|
1453
|
+
$lines = Get-Content -LiteralPath $manifest -Encoding utf8 -ErrorAction SilentlyContinue
|
|
1454
|
+
if (-not $lines) { return '' }
|
|
1455
|
+
# Mirrors: /"tools"/{found=1} found && /^ "[a-z]/{...}
|
|
1456
|
+
$found = $false
|
|
1457
|
+
$tools = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
|
|
1458
|
+
foreach ($line in $lines) {
|
|
1459
|
+
if ($line -match '"tools"') { $found = $true; continue }
|
|
1460
|
+
if ($found -and $line -match '^ "[a-z]') {
|
|
1461
|
+
# Strip everything except [a-zA-Z0-9_.-] from the key token (mirrors gsub).
|
|
1462
|
+
# The key is the first "word" on the line: characters up to the first non-key char.
|
|
1463
|
+
if ($line -match '^ "([^"]+)"') {
|
|
1464
|
+
$raw = $Matches[1]
|
|
1465
|
+
# gsub(/[^a-zA-Z0-9_.-]/,"",$1) -- keep only identifier chars
|
|
1466
|
+
$toolName = [regex]::Replace($raw, '[^a-zA-Z0-9_.\-]', '')
|
|
1467
|
+
if ($toolName -and -not $tools.Contains($toolName)) {
|
|
1468
|
+
[void]$tools.Add($toolName)
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
# sort -u (bytewise ordinal, matching bash): use SortedSet with Ordinal comparer.
|
|
1474
|
+
$sortedTools = [System.Collections.Generic.SortedSet[string]]::new($tools, [System.StringComparer]::Ordinal)
|
|
1475
|
+
return ($sortedTools -join ',')
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
# Get-WhichTierHolds <Path>
|
|
1479
|
+
# Returns "user" or "shared" based on which registry file contains the path.
|
|
1480
|
+
# Falls back to Resolve-AidTier if the path is not found in either.
|
|
1481
|
+
# Mirror of bash _which_tier_holds.
|
|
1482
|
+
function script:Get-WhichTierHolds {
|
|
1483
|
+
param([string]$Path)
|
|
1484
|
+
$primaryReg = Join-Path $script:_AidStateHome 'registry.yml'
|
|
1485
|
+
$userReg = Join-Path $HOME '.aid' 'registry.yml'
|
|
1486
|
+
|
|
1487
|
+
$primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
|
|
1488
|
+
$userNorm = [System.IO.Path]::GetFullPath((Join-Path $HOME '.aid'))
|
|
1489
|
+
|
|
1490
|
+
if ($primaryNorm -ne $userNorm) {
|
|
1491
|
+
# Global install: check shared/primary first.
|
|
1492
|
+
if ((script:Get-RegistryRepos -RegPath $primaryReg) -contains $Path) {
|
|
1493
|
+
return 'shared'
|
|
1494
|
+
}
|
|
1495
|
+
if ((script:Get-RegistryRepos -RegPath $userReg) -contains $Path) {
|
|
1496
|
+
return 'user'
|
|
1497
|
+
}
|
|
1498
|
+
} else {
|
|
1499
|
+
# Per-user: single file.
|
|
1500
|
+
if ((script:Get-RegistryRepos -RegPath $primaryReg) -contains $Path) {
|
|
1501
|
+
return 'user'
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
# Fallback: derive from tier resolution.
|
|
1505
|
+
return (script:Resolve-AidTier -CanonPath $Path)
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
# Invoke-AidProjectsList [Verbose]
|
|
1509
|
+
# Render the raw union as an aligned table: marker, path, state, tools, tier.
|
|
1510
|
+
# Marks cwd with "*"; footnotes unregistered AID cwd.
|
|
1511
|
+
# Mirror of bash _cmd_projects_list.
|
|
1512
|
+
function script:Invoke-AidProjectsList {
|
|
1513
|
+
param([bool]$Verbose = $false)
|
|
1514
|
+
|
|
1515
|
+
# Canonical cwd.
|
|
1516
|
+
$cwd = (Resolve-Path -LiteralPath '.' -ErrorAction SilentlyContinue).Path
|
|
1517
|
+
if (-not $cwd) { $cwd = (Get-Location).Path }
|
|
1518
|
+
|
|
1519
|
+
$paths = @(script:Get-RegistryRawUnion)
|
|
1520
|
+
|
|
1521
|
+
# Column header.
|
|
1522
|
+
Write-Host ('{0,-2} {1,-45} {2,-10} {3,-20} {4}' -f ' ', 'PATH', 'STATE', 'TOOLS', 'TIER')
|
|
1523
|
+
Write-Host ('{0,-2} {1,-45} {2,-10} {3,-20} {4}' -f '--', '----', '-----', '-----', '----')
|
|
1524
|
+
|
|
1525
|
+
$cwdRegistered = $false
|
|
1526
|
+
foreach ($entry in $paths) {
|
|
1527
|
+
$state = script:Get-AidProjectState -Path $entry
|
|
1528
|
+
$tools = script:Get-AidProjectTools -Path $entry
|
|
1529
|
+
$tier = script:Get-WhichTierHolds -Path $entry
|
|
1530
|
+
$marker = ' '
|
|
1531
|
+
if ($entry -eq $cwd) {
|
|
1532
|
+
$marker = '* '
|
|
1533
|
+
$cwdRegistered = $true
|
|
1534
|
+
}
|
|
1535
|
+
$toolsDisplay = if ($tools) { $tools } else { '-' }
|
|
1536
|
+
Write-Host ('{0,-2} {1,-45} {2,-10} {3,-20} {4}' -f $marker, $entry, $state, $toolsDisplay, $tier)
|
|
1537
|
+
if ($Verbose) {
|
|
1538
|
+
$regSrc = if ($tier -eq 'shared' -and
|
|
1539
|
+
([System.IO.Path]::GetFullPath($script:_AidStateHome) -ne [System.IO.Path]::GetFullPath((Join-Path $HOME '.aid')))) {
|
|
1540
|
+
Join-Path $script:_AidStateHome 'registry.yml'
|
|
1541
|
+
} else {
|
|
1542
|
+
Join-Path $HOME '.aid' 'registry.yml'
|
|
1543
|
+
}
|
|
1544
|
+
Write-Host (' registry: {0}' -f $regSrc)
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if ($paths.Count -eq 0) {
|
|
1549
|
+
Write-Host '(no projects registered)'
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
# Footnote: unregistered AID cwd (only when cwd is a real project, not the state home).
|
|
1553
|
+
if (-not $cwdRegistered -and (script:Test-AidIsProjectDir -Dir $cwd)) {
|
|
1554
|
+
Write-Host ''
|
|
1555
|
+
Write-Host "(here) -- not registered; run 'aid projects add'"
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
# Legend.
|
|
1559
|
+
if ($paths.Count -gt 0) {
|
|
1560
|
+
Write-Host ''
|
|
1561
|
+
Write-Host '* = current directory'
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
# Invoke-AidProjectsAdd [RawPath] [TierOverride] [Verbose]
|
|
1566
|
+
# Register a project path (default: cwd) in the deterministic tier.
|
|
1567
|
+
# Mirror of bash _cmd_projects_add.
|
|
1568
|
+
function script:Invoke-AidProjectsAdd {
|
|
1569
|
+
param(
|
|
1570
|
+
[string]$RawPath = '.',
|
|
1571
|
+
[string]$TierOverride = '',
|
|
1572
|
+
[bool] $Verbose = $false
|
|
1573
|
+
)
|
|
1574
|
+
|
|
1575
|
+
# Canonicalize.
|
|
1576
|
+
$resolvedAdd = Resolve-Path -LiteralPath $RawPath -ErrorAction SilentlyContinue
|
|
1577
|
+
$canon = if ($resolvedAdd) { $resolvedAdd.Path } else { $null }
|
|
1578
|
+
if (-not $canon) {
|
|
1579
|
+
[Console]::Error.WriteLine("ERROR: aid projects add: path does not exist: $RawPath")
|
|
1580
|
+
script:Exit-Aid 2
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
# Require a real AID project (.aid/ present AND not the CLI state home).
|
|
1584
|
+
if (-not (script:Test-AidIsProjectDir -Dir $canon)) {
|
|
1585
|
+
[Console]::Error.WriteLine("ERROR: aid projects add: '$canon' is not an AID project; run 'aid add <tool>' first.")
|
|
1586
|
+
script:Exit-Aid 2
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
# Resolve tier.
|
|
1590
|
+
$tier = script:Resolve-AidTier -CanonPath $canon -TierOverride $TierOverride
|
|
1591
|
+
|
|
1592
|
+
# Register (idempotent). Suppress Registry-Register's own Write-Host output so we
|
|
1593
|
+
# emit a single consolidated message instead; stderr (WARN lines) flows through.
|
|
1594
|
+
script:Registry-Register -Repo $canon -Tier $tier 6>$null
|
|
1595
|
+
Write-Host ("aid projects: '$canon' registered in $tier tier.")
|
|
1596
|
+
if ($Verbose) {
|
|
1597
|
+
$primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
|
|
1598
|
+
$userNorm = [System.IO.Path]::GetFullPath((Join-Path $HOME '.aid'))
|
|
1599
|
+
$regFile = if ($tier -eq 'shared' -and $primaryNorm -ne $userNorm) {
|
|
1600
|
+
Join-Path $script:_AidStateHome 'registry.yml'
|
|
1601
|
+
} else {
|
|
1602
|
+
Join-Path $HOME '.aid' 'registry.yml'
|
|
1603
|
+
}
|
|
1604
|
+
Write-Host ("aid projects: registry file: $regFile")
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
# Invoke-AidProjectsRemove [RawPath] [Verbose]
|
|
1609
|
+
# Unregister a project path (default: cwd); no .aid/ required (repair stale).
|
|
1610
|
+
# Mirror of bash _cmd_projects_remove.
|
|
1611
|
+
function script:Invoke-AidProjectsRemove {
|
|
1612
|
+
param(
|
|
1613
|
+
[string]$RawPath = '.',
|
|
1614
|
+
[bool] $Verbose = $false
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
# Canonicalize without requiring the directory to exist.
|
|
1618
|
+
$resolvedRem = Resolve-Path -LiteralPath $RawPath -ErrorAction SilentlyContinue
|
|
1619
|
+
$canon = if ($resolvedRem) { $resolvedRem.Path } else { $null }
|
|
1620
|
+
if (-not $canon) {
|
|
1621
|
+
# Directory absent (stale entry); use raw path as-is.
|
|
1622
|
+
$canon = $RawPath
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
# Check if registered before unregistering (for idempotency message).
|
|
1626
|
+
$primaryReg = Join-Path $script:_AidStateHome 'registry.yml'
|
|
1627
|
+
$userReg = Join-Path $HOME '.aid' 'registry.yml'
|
|
1628
|
+
$primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
|
|
1629
|
+
$userNorm = [System.IO.Path]::GetFullPath((Join-Path $HOME '.aid'))
|
|
1630
|
+
$found = $false
|
|
1631
|
+
if ((script:Get-RegistryRepos -RegPath $primaryReg) -contains $canon) {
|
|
1632
|
+
$found = $true
|
|
1633
|
+
} elseif ($primaryNorm -ne $userNorm) {
|
|
1634
|
+
if ((script:Get-RegistryRepos -RegPath $userReg) -contains $canon) {
|
|
1635
|
+
$found = $true
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
script:Registry-Unregister -Repo $canon
|
|
1640
|
+
if (-not $found) {
|
|
1641
|
+
Write-Host ("aid projects: '$canon' was not registered (nothing to remove).")
|
|
1642
|
+
} elseif ($Verbose) {
|
|
1643
|
+
Write-Host ("aid projects: removed '$canon' from registry.")
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
# Invoke-AidProjects [Action] [RemArgs]
|
|
1648
|
+
# Orchestrates list/add/remove/help for the project registry.
|
|
1649
|
+
# Mirror of bash _cmd_projects.
|
|
1650
|
+
function script:Invoke-AidProjects {
|
|
1651
|
+
param(
|
|
1652
|
+
[string] $Action = 'list',
|
|
1653
|
+
[string[]]$RemArgs = @()
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
$pathArg = ''
|
|
1657
|
+
$tierOverride = ''
|
|
1658
|
+
$verbose = $false
|
|
1659
|
+
|
|
1660
|
+
# Parse remaining args.
|
|
1661
|
+
$i = 0
|
|
1662
|
+
while ($i -lt $RemArgs.Count) {
|
|
1663
|
+
$a = $RemArgs[$i]
|
|
1664
|
+
switch ($a) {
|
|
1665
|
+
{ $_ -in @('-h', '--help', '-Help') } { $Action = 'help'; break }
|
|
1666
|
+
'--local' { $tierOverride = '--local'; break }
|
|
1667
|
+
'--shared' { $tierOverride = '--shared'; break }
|
|
1668
|
+
'--verbose' { $verbose = $true; $script:_AidVerbose = $true; break }
|
|
1669
|
+
{ $_ -match '^-' } {
|
|
1670
|
+
[Console]::Error.WriteLine("ERROR: aid projects: unknown flag: $a (see 'aid projects -h')")
|
|
1671
|
+
script:Exit-Aid 2
|
|
1672
|
+
break
|
|
1673
|
+
}
|
|
1674
|
+
default {
|
|
1675
|
+
if (-not $pathArg) { $pathArg = $a }
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
$i++
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
$resolvedPath = if ($pathArg) { $pathArg } else { '.' }
|
|
1682
|
+
|
|
1683
|
+
switch ($Action) {
|
|
1684
|
+
'list' { script:Invoke-AidProjectsList -Verbose $verbose }
|
|
1685
|
+
'add' { script:Invoke-AidProjectsAdd -RawPath $resolvedPath -TierOverride $tierOverride -Verbose $verbose }
|
|
1686
|
+
'remove' { script:Invoke-AidProjectsRemove -RawPath $resolvedPath -Verbose $verbose }
|
|
1687
|
+
'help' { script:Show-AidUsage 'projects'; script:Exit-Aid 0 }
|
|
1688
|
+
default {
|
|
1689
|
+
[Console]::Error.WriteLine("ERROR: aid projects: unknown action: $Action (expected: list, add, remove, help)")
|
|
1690
|
+
script:Exit-Aid 2
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
# Invoke-AidCwdClassify <Target>
|
|
1696
|
+
# C-table: classify the cwd repo and perform register-on-encounter.
|
|
1697
|
+
# Called before repo commands (status, update [tool]) when .aid/ exists.
|
|
1698
|
+
# Checks if already registered (union read); if not, picks tier and registers.
|
|
1699
|
+
# Returns always (registration is best-effort; never blocks the host command).
|
|
1700
|
+
# Mirror of bash _aid_cwd_classify.
|
|
1701
|
+
function script:Invoke-AidCwdClassify {
|
|
1702
|
+
param([string]$Target)
|
|
1703
|
+
|
|
1704
|
+
$canonTarget = (Resolve-Path -LiteralPath $Target -ErrorAction SilentlyContinue).Path
|
|
1705
|
+
if (-not $canonTarget) { $canonTarget = $Target }
|
|
1706
|
+
|
|
1707
|
+
# Check if already registered in the union.
|
|
1708
|
+
$isRegistered = $false
|
|
1709
|
+
foreach ($regP in (script:Get-RegistryUnion)) {
|
|
1710
|
+
if ($regP -eq $canonTarget) { $isRegistered = $true; break }
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
if (-not $isRegistered) {
|
|
1714
|
+
# Not registered -- pick tier deterministically via Resolve-AidTier (FR7: no prompt).
|
|
1715
|
+
$regTier = script:Resolve-AidTier -CanonPath $canonTarget
|
|
1716
|
+
try { script:Registry-Register -Repo $canonTarget -Tier $regTier } catch {}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
# Invoke-AidCwdNoAidOffer <Target>
|
|
1721
|
+
# C-table last row: .aid/ absent -- print offer + optional non-git note, exit 0.
|
|
1722
|
+
# No hard refuse (decision #5): missing .aid/ is an offer, not an error.
|
|
1723
|
+
# Mirror of bash _aid_cwd_no_aid_offer.
|
|
1724
|
+
function script:Invoke-AidCwdNoAidOffer {
|
|
1725
|
+
param([string]$Target)
|
|
1726
|
+
|
|
1727
|
+
$canon = (Resolve-Path -LiteralPath $Target -ErrorAction SilentlyContinue).Path
|
|
1728
|
+
if (-not $canon) { $canon = $Target }
|
|
1729
|
+
|
|
1730
|
+
Write-Host 'no AID project here -- set it up? (aid add)'
|
|
1731
|
+
# Non-git note (decision #5): a non-git dir can use AID; .aid/ just won't be
|
|
1732
|
+
# version-controlled if git is absent.
|
|
1733
|
+
$gitOut = $null
|
|
1734
|
+
try { $gitOut = & git -C $canon rev-parse --git-dir 2>&1 } catch {}
|
|
1735
|
+
if ($LASTEXITCODE -ne 0 -or -not $gitOut) {
|
|
1736
|
+
Write-Host "Note: $canon is not a git repository -- .aid/ will not be version-controlled."
|
|
1737
|
+
}
|
|
1738
|
+
script:Exit-Aid 0
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
# Register a canonical repo path in the registry.yml (set-insert, idempotent).
|
|
1742
|
+
# Prints one line on a real change; silent on no-op. Prints WARN on failure; never
|
|
1743
|
+
# throws (host-tool op is never blocked -- NFR10 / DD-3 / CLI-1).
|
|
1744
|
+
#
|
|
1745
|
+
# Tier param: 'user' (default) or 'shared'.
|
|
1746
|
+
#
|
|
1747
|
+
# USER tier (default): primary target is $script:_AidStateHome/registry.yml (honors
|
|
1748
|
+
# AID_HOME override via startup scope derivation). If AID_STATE_HOME is not user-
|
|
1749
|
+
# writable AND is a different path from $HOME/.aid, degrades to $HOME/.aid/registry.yml
|
|
1750
|
+
# with a WARN (fire-and-continue; never blocks the host command). Per-user collapse:
|
|
1751
|
+
# when AID_STATE_HOME == $HOME/.aid the two are the same file -- single-tier, no fallback.
|
|
1752
|
+
#
|
|
1753
|
+
# SHARED tier: writes to $script:_AidStateHome/registry.yml directly (no elevation
|
|
1754
|
+
# wrapper on Windows -- the underlying tool surfaces its own access error; callers
|
|
1755
|
+
# elevate their own shell). If the write fails or the shared dir is not writable,
|
|
1756
|
+
# DEGRADES to skip + WARN + return (matching bash _aid_priv_run-declined contract;
|
|
1757
|
+
# SPEC AC6 / decision #2). Per-user install (AID_STATE_HOME == ~/.aid): shared-tier
|
|
1758
|
+
# argument is treated as user-tier (same file, no elevation needed).
|
|
1759
|
+
# Defined before the sentinel try block so it is available to bare-aid, status, and
|
|
1760
|
+
# Invoke-AidCwdClassify (all of which call it before any later def would be reached).
|
|
1761
|
+
function script:Registry-Register {
|
|
1762
|
+
param([string]$Repo, [string]$Tier = 'user')
|
|
1763
|
+
|
|
1764
|
+
# Per-user collapse: AID_STATE_HOME == $HOME/.aid -> shared-tier is user-tier (same file).
|
|
1765
|
+
$userDotAid = Join-Path $HOME '.aid'
|
|
1766
|
+
$primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
|
|
1767
|
+
$userNorm = [System.IO.Path]::GetFullPath($userDotAid)
|
|
1768
|
+
$perUser = ($primaryNorm -eq $userNorm)
|
|
1769
|
+
|
|
1770
|
+
# Helper: test writability of a directory.
|
|
1771
|
+
$testWritable = {
|
|
1772
|
+
param([string]$dir)
|
|
1773
|
+
if (-not (Test-Path $dir -PathType Container)) { return $false }
|
|
1774
|
+
$probe = Join-Path $dir ('.aid-write-probe.' + [System.IO.Path]::GetRandomFileName())
|
|
1775
|
+
try { [System.IO.File]::WriteAllText($probe, ''); Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue; return $true } catch { return $false }
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
# Helper: write registry content to a file (temp+mv atomic).
|
|
1779
|
+
$writeRegistry = {
|
|
1780
|
+
param([string]$regPath, [string[]]$repos)
|
|
1781
|
+
$dir = Split-Path $regPath -Parent
|
|
1782
|
+
if (-not (Test-Path $dir -PathType Container)) {
|
|
1783
|
+
try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch {}
|
|
1784
|
+
}
|
|
1785
|
+
$tmp = Join-Path $dir ("registry.yml.aid-tmp." + [System.IO.Path]::GetRandomFileName())
|
|
1786
|
+
try {
|
|
1787
|
+
$lns = [System.Collections.Generic.List[string]]::new()
|
|
1788
|
+
$lns.Add("# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit).")
|
|
1789
|
+
$lns.Add("# Holds ONLY the base folders of projects this CLI install manages. Per-project name and")
|
|
1790
|
+
$lns.Add("# description come from .aid/settings.yml; version/tools from the manifest, at render time.")
|
|
1791
|
+
$lns.Add("schema: 1")
|
|
1792
|
+
$lns.Add("projects:")
|
|
1793
|
+
foreach ($p in ($repos | Where-Object { $_ } | Sort-Object -Unique)) { $lns.Add(" - $p") }
|
|
1794
|
+
Set-Content -LiteralPath $tmp -Value $lns.ToArray() -Encoding utf8NoBOM -ErrorAction Stop
|
|
1795
|
+
Move-Item -LiteralPath $tmp -Destination $regPath -Force -ErrorAction Stop
|
|
1796
|
+
return $true
|
|
1797
|
+
} catch {
|
|
1798
|
+
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
|
|
1799
|
+
return $false
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
# ------ SHARED tier -------------------------------------------------------
|
|
1804
|
+
if ($Tier -eq 'shared' -and -not $perUser) {
|
|
1805
|
+
# Shared write: attempt directly (no elevation wrapper on Windows).
|
|
1806
|
+
# If the shared dir is not writable, degrade: skip + WARN + return (0-equivalent).
|
|
1807
|
+
$sharedRegDir = $script:_AidStateHome
|
|
1808
|
+
if (-not (Test-Path $sharedRegDir -PathType Container)) {
|
|
1809
|
+
try { New-Item -ItemType Directory -Path $sharedRegDir -Force | Out-Null } catch {}
|
|
1810
|
+
}
|
|
1811
|
+
if (-not (& $testWritable $sharedRegDir)) {
|
|
1812
|
+
[Console]::Error.WriteLine("WARN: aid: shared registry write declined or unavailable; project not registered in shared tier ($sharedRegDir\registry.yml)")
|
|
1813
|
+
return
|
|
1814
|
+
}
|
|
1815
|
+
$sharedReg = Join-Path $sharedRegDir 'registry.yml'
|
|
1816
|
+
$existing = @(script:Get-RegistryRepos -RegPath $sharedReg)
|
|
1817
|
+
if ($existing -contains $Repo) {
|
|
1818
|
+
if ($script:_AidVerbose) { Write-Host "Registry: $Repo already registered in shared tier (no-op)." }
|
|
1819
|
+
return
|
|
1820
|
+
}
|
|
1821
|
+
$ok = & $writeRegistry $sharedReg ($existing + @($Repo))
|
|
1822
|
+
if (-not $ok) {
|
|
1823
|
+
[Console]::Error.WriteLine("WARN: aid: could not update the shared project registry ($sharedReg): write failed")
|
|
1824
|
+
return
|
|
1825
|
+
}
|
|
1826
|
+
Write-Host "Registered $Repo with the AID CLI (shared registry)."
|
|
1827
|
+
return
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
# ------ USER tier (default) or per-user collapse -------------------------
|
|
1831
|
+
# Primary: $script:_AidStateHome (honors AID_HOME override via startup scope derivation).
|
|
1832
|
+
# Fallback: $HOME/.aid (when AID_STATE_HOME is not writable and is a different path).
|
|
1833
|
+
# Never-elevate: empty probe.
|
|
1834
|
+
if (-not (Test-Path $script:_AidStateHome -PathType Container)) {
|
|
1835
|
+
try { New-Item -ItemType Directory -Path $script:_AidStateHome -Force -ErrorAction SilentlyContinue | Out-Null } catch {}
|
|
1836
|
+
}
|
|
1837
|
+
$writeDir = $null
|
|
1838
|
+
if (& $testWritable $script:_AidStateHome) {
|
|
1839
|
+
$writeDir = $script:_AidStateHome
|
|
1840
|
+
} else {
|
|
1841
|
+
# AID_STATE_HOME not writable; degrade to $HOME/.aid (user fallback).
|
|
1842
|
+
if (-not (Test-Path $userDotAid -PathType Container)) {
|
|
1843
|
+
try { New-Item -ItemType Directory -Path $userDotAid -Force -ErrorAction SilentlyContinue | Out-Null } catch {}
|
|
1844
|
+
}
|
|
1845
|
+
# Degrade is the designed global-install behavior -- silent by default; shown under verbose.
|
|
1846
|
+
if ($script:_AidVerbose) {
|
|
1847
|
+
[Console]::Error.WriteLine("WARN: aid: could not write to state home $($script:_AidStateHome); using $userDotAid\registry.yml")
|
|
325
1848
|
}
|
|
1849
|
+
$writeDir = $userDotAid
|
|
1850
|
+
}
|
|
1851
|
+
$writeReg = Join-Path $writeDir 'registry.yml'
|
|
1852
|
+
$existing = @(script:Get-RegistryRepos -RegPath $writeReg)
|
|
1853
|
+
# Idempotent: already registered -> silent no-op.
|
|
1854
|
+
if ($existing -contains $Repo) {
|
|
1855
|
+
if ($script:_AidVerbose) { Write-Host "Registry: $Repo already registered (no-op)." }
|
|
326
1856
|
return
|
|
327
1857
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
# Safety guard: warn if exceeding ~2000 chars (Windows limit is 32767 but
|
|
333
|
-
# practical registry/shell limit is much lower for User PATH).
|
|
334
|
-
$safeLimit = 2000
|
|
335
|
-
if ($newPath.Length -gt $safeLimit) {
|
|
336
|
-
Write-Host "WARN: aid: User PATH would exceed $safeLimit chars. Skipping automatic PATH wiring."
|
|
337
|
-
Write-Host "Add `"$BinDir`" to your PATH manually via System Properties > Environment Variables."
|
|
1858
|
+
$ok = & $writeRegistry $writeReg ($existing + @($Repo))
|
|
1859
|
+
if (-not $ok) {
|
|
1860
|
+
[Console]::Error.WriteLine("WARN: aid: could not update the machine project registry ($writeReg): write failed")
|
|
338
1861
|
return
|
|
339
1862
|
}
|
|
1863
|
+
Write-Host "Registered $Repo with the AID CLI."
|
|
1864
|
+
}
|
|
340
1865
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
1866
|
+
# Unregister a canonical repo path from the registry.yml (set-remove, idempotent).
|
|
1867
|
+
# Called only when the repo manifest is now gone (last tool removed).
|
|
1868
|
+
# Prints one line on a real change; silent on no-op. Prints WARN on failure; never throws.
|
|
1869
|
+
#
|
|
1870
|
+
# Tier-aware: removes from tier(s) where the entry is found. Scope-aware resolution
|
|
1871
|
+
# mirrors Registry-Register: primary=$script:_AidStateHome, fallback=$HOME/.aid.
|
|
1872
|
+
# Per-user install (AID_STATE_HOME == $HOME/.aid): both paths are the same -- single write.
|
|
1873
|
+
# Defined before the sentinel try block (symmetric with Registry-Register above).
|
|
1874
|
+
function script:Registry-Unregister {
|
|
1875
|
+
param([string]$Repo)
|
|
1876
|
+
|
|
1877
|
+
$userDotAid = Join-Path $HOME '.aid'
|
|
1878
|
+
$primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
|
|
1879
|
+
$userNorm = [System.IO.Path]::GetFullPath($userDotAid)
|
|
1880
|
+
$perUser = ($primaryNorm -eq $userNorm)
|
|
1881
|
+
|
|
1882
|
+
$sharedReg = Join-Path $script:_AidStateHome 'registry.yml'
|
|
1883
|
+
$userReg = Join-Path $userDotAid 'registry.yml'
|
|
1884
|
+
|
|
1885
|
+
# Helper: test writability of a directory.
|
|
1886
|
+
$testW = {
|
|
1887
|
+
param([string]$dir)
|
|
1888
|
+
if (-not (Test-Path $dir -PathType Container)) { return $false }
|
|
1889
|
+
$probe = Join-Path $dir ('.aid-write-probe.' + [System.IO.Path]::GetRandomFileName())
|
|
1890
|
+
try { [System.IO.File]::WriteAllText($probe, ''); Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue; return $true } catch { return $false }
|
|
346
1891
|
}
|
|
347
1892
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
1893
|
+
# Helper: rewrite registry removing $Repo (atomic temp+mv).
|
|
1894
|
+
$rewriteReg = {
|
|
1895
|
+
param([string]$regPath, [string[]]$current)
|
|
1896
|
+
$dir = Split-Path $regPath -Parent
|
|
1897
|
+
$tmp = Join-Path $dir ("registry.yml.aid-tmp." + [System.IO.Path]::GetRandomFileName())
|
|
1898
|
+
try {
|
|
1899
|
+
$remaining = $current | Where-Object { $_ -ne $Repo } | Sort-Object -Unique
|
|
1900
|
+
$lns = [System.Collections.Generic.List[string]]::new()
|
|
1901
|
+
$lns.Add("# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit).")
|
|
1902
|
+
$lns.Add("# Holds ONLY the base folders of projects this CLI install manages. Per-project name and")
|
|
1903
|
+
$lns.Add("# description come from .aid/settings.yml; version/tools from the manifest, at render time.")
|
|
1904
|
+
$lns.Add("schema: 1")
|
|
1905
|
+
$lns.Add("projects:")
|
|
1906
|
+
if ($remaining) { foreach ($p in $remaining) { $lns.Add(" - $p") } }
|
|
1907
|
+
Set-Content -LiteralPath $tmp -Value $lns.ToArray() -Encoding utf8NoBOM -ErrorAction Stop
|
|
1908
|
+
Move-Item -LiteralPath $tmp -Destination $regPath -Force -ErrorAction Stop
|
|
1909
|
+
return $true
|
|
1910
|
+
} catch {
|
|
1911
|
+
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
|
|
1912
|
+
return $false
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
351
1915
|
|
|
352
|
-
|
|
353
|
-
# Remove binDir from User PATH idempotently.
|
|
354
|
-
function script:Remove-AidFromPath {
|
|
355
|
-
param([string]$BinDir)
|
|
1916
|
+
$foundAny = $false
|
|
356
1917
|
|
|
357
|
-
|
|
358
|
-
if (
|
|
1918
|
+
# --- PRIMARY TIER ($AID_STATE_HOME) ---
|
|
1919
|
+
if (& $testW $script:_AidStateHome) {
|
|
1920
|
+
$ex = @(script:Get-RegistryRepos -RegPath $sharedReg)
|
|
1921
|
+
if ($ex -contains $Repo) {
|
|
1922
|
+
$foundAny = $true
|
|
1923
|
+
$ok = & $rewriteReg $sharedReg $ex
|
|
1924
|
+
if (-not $ok) {
|
|
1925
|
+
[Console]::Error.WriteLine("WARN: aid: could not update the machine project registry ($sharedReg): write failed")
|
|
1926
|
+
return
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
} else {
|
|
1930
|
+
# AID_STATE_HOME not writable: check/operate on fallback $HOME/.aid tier.
|
|
1931
|
+
if (-not (Test-Path $userDotAid -PathType Container)) {
|
|
1932
|
+
try { New-Item -ItemType Directory -Path $userDotAid -Force -ErrorAction SilentlyContinue | Out-Null } catch {}
|
|
1933
|
+
}
|
|
1934
|
+
$ex = @(script:Get-RegistryRepos -RegPath $userReg)
|
|
1935
|
+
if ($ex -contains $Repo) {
|
|
1936
|
+
$foundAny = $true
|
|
1937
|
+
# Degrade is the designed global-install behavior -- silent by default; shown under verbose.
|
|
1938
|
+
if ($script:_AidVerbose) {
|
|
1939
|
+
[Console]::Error.WriteLine("WARN: aid: could not write to state home $($script:_AidStateHome); using $userReg")
|
|
1940
|
+
}
|
|
1941
|
+
$ok = & $rewriteReg $userReg $ex
|
|
1942
|
+
if (-not $ok) {
|
|
1943
|
+
[Console]::Error.WriteLine("WARN: aid: could not update the machine project registry ($userReg): write failed")
|
|
1944
|
+
return
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
359
1948
|
|
|
360
|
-
|
|
361
|
-
|
|
1949
|
+
# --- FALLBACK / SECONDARY TIER ($HOME/.aid, global install only) ---
|
|
1950
|
+
# When AID_STATE_HOME is writable and != $HOME/.aid, also check if the entry
|
|
1951
|
+
# exists in $HOME/.aid (e.g. was registered when AID_STATE_HOME was non-writable).
|
|
1952
|
+
if (-not $perUser -and (& $testW $script:_AidStateHome)) {
|
|
1953
|
+
$fbEx = @(script:Get-RegistryRepos -RegPath $userReg)
|
|
1954
|
+
if ($fbEx -contains $Repo) {
|
|
1955
|
+
$foundAny = $true
|
|
1956
|
+
if (-not (Test-Path $userDotAid -PathType Container)) {
|
|
1957
|
+
try { New-Item -ItemType Directory -Path $userDotAid -Force -ErrorAction SilentlyContinue | Out-Null } catch {}
|
|
1958
|
+
}
|
|
1959
|
+
if (& $testW $userDotAid) {
|
|
1960
|
+
$ok = & $rewriteReg $userReg $fbEx
|
|
1961
|
+
if (-not $ok) {
|
|
1962
|
+
[Console]::Error.WriteLine("WARN: aid: could not update the machine project registry ($userReg): write failed")
|
|
1963
|
+
}
|
|
1964
|
+
} else {
|
|
1965
|
+
[Console]::Error.WriteLine("WARN: aid: could not write to registry at $userReg (not writable); unregister skipped")
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
362
1969
|
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
1970
|
+
if (-not $foundAny) {
|
|
1971
|
+
if ($script:_AidVerbose) { Write-Host "Registry: $Repo not in registry (no-op)." }
|
|
1972
|
+
return
|
|
366
1973
|
}
|
|
1974
|
+
Write-Host "Unregistered $Repo from the AID CLI."
|
|
367
1975
|
}
|
|
368
1976
|
|
|
369
1977
|
# ---------------------------------------------------------------------------
|
|
@@ -383,14 +1991,32 @@ $script:_AidVerbose = ($env:AID_VERBOSE -eq '1')
|
|
|
383
1991
|
|
|
384
1992
|
# ---- Bare aid -> dashboard landing screen ----
|
|
385
1993
|
if ($script:_RawArgs.Count -eq 0) {
|
|
1994
|
+
# C-table: if cwd is not an AID project -> offer (no hard refuse, decision #5); exit 0.
|
|
1995
|
+
# Test-AidIsProjectDir excludes the CLI state home from "is project" classification.
|
|
1996
|
+
if (-not (script:Test-AidIsProjectDir -Dir '.')) {
|
|
1997
|
+
script:Invoke-AidCwdNoAidOffer -Target '.'
|
|
1998
|
+
# Invoke-AidCwdNoAidOffer always calls Exit-Aid 0.
|
|
1999
|
+
}
|
|
2000
|
+
# C-table register-on-encounter (best-effort, never blocks bare aid).
|
|
2001
|
+
script:Invoke-AidCwdClassify -Target '.'
|
|
2002
|
+
|
|
386
2003
|
# Block 1 + 2: Header + description.
|
|
387
2004
|
$cliVersion = 'unknown'
|
|
388
|
-
$verFile = Join-Path $script:
|
|
2005
|
+
$verFile = Join-Path $script:_AidCodeHome 'VERSION'
|
|
389
2006
|
if (Test-Path $verFile -PathType Leaf) {
|
|
390
2007
|
$cliVersion = (Get-Content -LiteralPath $verFile -Raw).Trim()
|
|
391
2008
|
}
|
|
392
2009
|
Write-Host "AID v$cliVersion - Agentic Iterative Development"
|
|
393
|
-
Write-Host "Install, update, and manage AID across your
|
|
2010
|
+
Write-Host "Install, update, and manage AID across your projects."
|
|
2011
|
+
|
|
2012
|
+
# C6': format gate for cwd repo (.aid/ is guaranteed present here -- the
|
|
2013
|
+
# non-project case is intercepted above via Invoke-AidCwdNoAidOffer;
|
|
2014
|
+
# register-on-encounter already ran via Invoke-AidCwdClassify).
|
|
2015
|
+
# Test-AidIsProjectDir guards the state-home exclusion (double-check).
|
|
2016
|
+
if (script:Test-AidIsProjectDir -Dir '.') {
|
|
2017
|
+
$gateRc = script:Invoke-AidFormatGate -Repo '.'
|
|
2018
|
+
if ($gateRc -ne 0) { script:Exit-Aid $gateRc }
|
|
2019
|
+
}
|
|
394
2020
|
|
|
395
2021
|
# Block 3: Installed tools for cwd.
|
|
396
2022
|
Write-Host ""
|
|
@@ -418,7 +2044,7 @@ $script:_RemArgs = @($script:_RawArgs | Select-Object -Skip 1)
|
|
|
418
2044
|
# version
|
|
419
2045
|
# ---------------------------------------------------------------------------
|
|
420
2046
|
if ($SUBCMD -eq 'version') {
|
|
421
|
-
$versionFile = Join-Path $script:
|
|
2047
|
+
$versionFile = Join-Path $script:_AidCodeHome 'VERSION'
|
|
422
2048
|
if (Test-Path $versionFile -PathType Leaf) {
|
|
423
2049
|
Write-Host (Get-Content -LiteralPath $versionFile -Raw).Trim()
|
|
424
2050
|
} else {
|
|
@@ -467,49 +2093,481 @@ if ($SUBCMD -eq 'status') {
|
|
|
467
2093
|
if (-not $statusTarget) { $statusTarget = '.' }
|
|
468
2094
|
if ($script:_AidVerbose) { $env:AID_VERBOSE = '1' }
|
|
469
2095
|
|
|
2096
|
+
# C-table: if target is not an AID project -> offer (no hard refuse, decision #5); exit 0.
|
|
2097
|
+
# Test-AidIsProjectDir excludes the CLI state home from "is project" classification.
|
|
2098
|
+
if (-not (script:Test-AidIsProjectDir -Dir $statusTarget)) {
|
|
2099
|
+
script:Invoke-AidCwdNoAidOffer -Target $statusTarget
|
|
2100
|
+
# Invoke-AidCwdNoAidOffer always calls Exit-Aid 0.
|
|
2101
|
+
}
|
|
2102
|
+
# C-table register-on-encounter (best-effort, never blocks status).
|
|
2103
|
+
script:Invoke-AidCwdClassify -Target $statusTarget
|
|
2104
|
+
# C6': format gate for status target (only when target is a real project).
|
|
2105
|
+
$gateRc = script:Invoke-AidFormatGate -Repo $statusTarget
|
|
2106
|
+
if ($gateRc -ne 0) { script:Exit-Aid $gateRc }
|
|
2107
|
+
|
|
470
2108
|
$rc = Get-AidStatus -Target $statusTarget
|
|
471
2109
|
# Update check notice appended after status output (non-blocking).
|
|
472
2110
|
script:Invoke-AidUpdateCheck
|
|
473
2111
|
script:Exit-Aid $rc
|
|
474
2112
|
}
|
|
475
2113
|
|
|
2114
|
+
# ---------------------------------------------------------------------------
|
|
2115
|
+
# Invoke-AidMigrateRepo <repo> (FF-1 / LC-MIG / task-077)
|
|
2116
|
+
# Per-repo migration core -- PS twin of bash _aid_migrate_repo.
|
|
2117
|
+
# Runs DETECT->SETTINGS->ADD->RELOCATE->REGISTER in order.
|
|
2118
|
+
# Each step is WARN-not-fail: a step failure emits WARN and the next step runs.
|
|
2119
|
+
# Always returns 0 (SEC-4 / NFR12). <repo> is a canonical repo base folder.
|
|
2120
|
+
# ---------------------------------------------------------------------------
|
|
2121
|
+
function script:Invoke-AidMigrateRepo {
|
|
2122
|
+
param([string]$Repo)
|
|
2123
|
+
|
|
2124
|
+
# ------------------------------------------------------------------
|
|
2125
|
+
# STEP 0 -- DETECT / QUALIFY (DD-6 / SEC-1) -- read-only.
|
|
2126
|
+
# ------------------------------------------------------------------
|
|
2127
|
+
$aidDir = Join-Path $Repo '.aid'
|
|
2128
|
+
if (-not (Test-Path $aidDir -PathType Container)) { return 0 }
|
|
2129
|
+
|
|
2130
|
+
$settingsPath = Join-Path $aidDir 'settings.yml'
|
|
2131
|
+
$kbDir = Join-Path $aidDir 'knowledge'
|
|
2132
|
+
$dsA = Join-Path $kbDir 'DISCOVERY_STATE.md'
|
|
2133
|
+
$dsB = Join-Path $kbDir 'DISCOVERY-STATE.md'
|
|
2134
|
+
$dsC = Join-Path $kbDir 'STATE.md'
|
|
2135
|
+
|
|
2136
|
+
$era = ''
|
|
2137
|
+
if (Test-Path $settingsPath -PathType Leaf) {
|
|
2138
|
+
$era = 'a'
|
|
2139
|
+
} elseif ((Test-Path $dsA -PathType Leaf) -or (Test-Path $dsB -PathType Leaf) -or (Test-Path $dsC -PathType Leaf)) {
|
|
2140
|
+
$era = 'b'
|
|
2141
|
+
} else {
|
|
2142
|
+
return 0 # bare .aid/ -- not a candidate
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
$repoName = Split-Path $Repo -Leaf
|
|
2146
|
+
$manifest = Join-Path $aidDir '.aid-manifest.json'
|
|
2147
|
+
|
|
2148
|
+
# ------------------------------------------------------------------
|
|
2149
|
+
# STEP 1 -- SETTINGS (DM-1 / task-074 contract)
|
|
2150
|
+
# ------------------------------------------------------------------
|
|
2151
|
+
if ($era -eq 'a') {
|
|
2152
|
+
try {
|
|
2153
|
+
script:Invoke-AidRepairSettingsEraA -SettingsFile $settingsPath -RepoName $repoName
|
|
2154
|
+
} catch {
|
|
2155
|
+
[Console]::Error.WriteLine("WARN: aid migrate: settings repair failed for ${settingsPath}: $_")
|
|
2156
|
+
}
|
|
2157
|
+
} else {
|
|
2158
|
+
try {
|
|
2159
|
+
script:Invoke-AidSynthesizeSettingsEraB -SettingsFile $settingsPath -RepoName $repoName -ManifestPath $manifest
|
|
2160
|
+
} catch {
|
|
2161
|
+
[Console]::Error.WriteLine("WARN: aid migrate: settings synthesis failed for ${settingsPath}: $_")
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
# ------------------------------------------------------------------
|
|
2166
|
+
# STEP 2 -- ADD home.html (FR40 / RC-2) -- copy-when-absent only.
|
|
2167
|
+
# ------------------------------------------------------------------
|
|
2168
|
+
$dashDir = Join-Path $aidDir 'dashboard'
|
|
2169
|
+
$htmlDest = Join-Path $dashDir 'home.html'
|
|
2170
|
+
if (-not (Test-Path $htmlDest -PathType Leaf)) {
|
|
2171
|
+
$htmlSrc = Join-Path $script:_AidCodeHome 'dashboard' | Join-Path -ChildPath 'home.html'
|
|
2172
|
+
if (Test-Path $htmlSrc -PathType Leaf) {
|
|
2173
|
+
try {
|
|
2174
|
+
if (-not (Test-Path $dashDir -PathType Container)) {
|
|
2175
|
+
New-Item -ItemType Directory -Path $dashDir -Force | Out-Null
|
|
2176
|
+
}
|
|
2177
|
+
Copy-Item -LiteralPath $htmlSrc -Destination $htmlDest -ErrorAction Stop
|
|
2178
|
+
} catch {
|
|
2179
|
+
[Console]::Error.WriteLine("WARN: aid migrate: copy home.html failed for ${Repo}: $_")
|
|
2180
|
+
}
|
|
2181
|
+
} else {
|
|
2182
|
+
[Console]::Error.WriteLine("WARN: aid migrate: home.html source not found at ${htmlSrc} (continuing)")
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
# ------------------------------------------------------------------
|
|
2187
|
+
# STEP 3 -- RELOCATE legacy summary (DM-4 / FR31) -- no-clobber mv.
|
|
2188
|
+
# ------------------------------------------------------------------
|
|
2189
|
+
$oldSummary = Join-Path $aidDir 'knowledge' | Join-Path -ChildPath 'knowledge-summary.html'
|
|
2190
|
+
$newSummary = Join-Path $dashDir 'kb.html'
|
|
2191
|
+
if ((Test-Path $oldSummary -PathType Leaf) -and (-not (Test-Path $newSummary -PathType Leaf))) {
|
|
2192
|
+
try {
|
|
2193
|
+
if (-not (Test-Path $dashDir -PathType Container)) {
|
|
2194
|
+
New-Item -ItemType Directory -Path $dashDir -Force | Out-Null
|
|
2195
|
+
}
|
|
2196
|
+
Move-Item -LiteralPath $oldSummary -Destination $newSummary -ErrorAction Stop
|
|
2197
|
+
} catch {
|
|
2198
|
+
[Console]::Error.WriteLine("WARN: aid migrate: relocate legacy summary failed for ${Repo}: $_")
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
# ------------------------------------------------------------------
|
|
2203
|
+
# STEP 4 -- REGISTER (DM-2 / FR28) -- existing idempotent writer.
|
|
2204
|
+
# FR7 never-elevate: resolve tier deterministically; if shared but the shared
|
|
2205
|
+
# dir is not writable, degrade silently to user (mirrors bash _aid_migrate_repo).
|
|
2206
|
+
# ------------------------------------------------------------------
|
|
2207
|
+
try {
|
|
2208
|
+
$_migTier = script:Resolve-AidTier -CanonPath $Repo
|
|
2209
|
+
# Degrade: shared + non-writable shared dir -> user (never-elevate in migrate).
|
|
2210
|
+
if ($_migTier -eq 'shared') {
|
|
2211
|
+
$testW = { param([string]$d)
|
|
2212
|
+
if (-not (Test-Path $d -PathType Container)) { return $false }
|
|
2213
|
+
$probe = Join-Path $d ('.aid-write-probe.' + [System.IO.Path]::GetRandomFileName())
|
|
2214
|
+
try { [System.IO.File]::WriteAllText($probe, ''); Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue; return $true } catch { return $false }
|
|
2215
|
+
}
|
|
2216
|
+
if (-not (& $testW $script:_AidStateHome)) { $_migTier = 'user' }
|
|
2217
|
+
}
|
|
2218
|
+
script:Registry-Register -Repo $Repo -Tier $_migTier
|
|
2219
|
+
} catch {
|
|
2220
|
+
[Console]::Error.WriteLine("WARN: aid migrate: registry_register failed for ${Repo}: $_")
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
return 0
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
# script:Invoke-AidRepairSettingsEraA <SettingsFile> <RepoName>
|
|
2227
|
+
# Era-a: validate/repair REQUIRED keys via targeted edits only.
|
|
2228
|
+
# A valid file -> no write (idempotent).
|
|
2229
|
+
function script:Invoke-AidRepairSettingsEraA {
|
|
2230
|
+
param([string]$SettingsFile, [string]$RepoName)
|
|
2231
|
+
if (-not (Test-Path $SettingsFile -PathType Leaf)) { throw "settings file not found" }
|
|
2232
|
+
|
|
2233
|
+
$lines = [System.Collections.Generic.List[string]](Get-Content -LiteralPath $SettingsFile -Encoding utf8 -ErrorAction Stop)
|
|
2234
|
+
$changed = $false
|
|
2235
|
+
|
|
2236
|
+
# ---- locate section header index ("^<sect>:\s*$") ----
|
|
2237
|
+
$findSection = {
|
|
2238
|
+
param([string]$sect)
|
|
2239
|
+
for ($i = 0; $i -lt $lines.Count; $i++) {
|
|
2240
|
+
if ($lines[$i] -match "^${sect}:\s*$") { return $i }
|
|
2241
|
+
}
|
|
2242
|
+
return -1
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
# ---- locate indented key index inside a section ----
|
|
2246
|
+
$findKeyInSection = {
|
|
2247
|
+
param([int]$sectIdx, [string]$key)
|
|
2248
|
+
for ($i = $sectIdx + 1; $i -lt $lines.Count; $i++) {
|
|
2249
|
+
$ln = $lines[$i]
|
|
2250
|
+
if ($ln -match '^[a-zA-Z_]') { return -1 }
|
|
2251
|
+
if ($ln -match "^\s+${key}:") { return $i }
|
|
2252
|
+
}
|
|
2253
|
+
return -1
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
# ---- get scalar value from " key: value" line ----
|
|
2257
|
+
$getScalarValue = {
|
|
2258
|
+
param([string]$ln, [string]$key)
|
|
2259
|
+
$v = ($ln -replace "^\s+${key}:\s*", '') -replace '\s*#.*$', ''
|
|
2260
|
+
$v = $v.Trim().Trim('"').Trim("'")
|
|
2261
|
+
return $v
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
# ---- insert a line after index ----
|
|
2265
|
+
$insertAfter = {
|
|
2266
|
+
param([int]$idx, [string]$newLine)
|
|
2267
|
+
$lines.Insert($idx + 1, $newLine)
|
|
2268
|
+
$changed = $true
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
# ---- append a block at EOF ----
|
|
2272
|
+
# Prepends a blank line so the new section is visually separated from the
|
|
2273
|
+
# preceding content (matching the template's blank-line-between-sections style).
|
|
2274
|
+
# Idempotency is preserved: on a 2nd run the section exists, so this path is skipped.
|
|
2275
|
+
$appendBlock = {
|
|
2276
|
+
param([string]$block)
|
|
2277
|
+
$lines.Add("")
|
|
2278
|
+
foreach ($bl in ($block -split "`n")) {
|
|
2279
|
+
$lines.Add($bl)
|
|
2280
|
+
}
|
|
2281
|
+
$changed = $true
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
# ---- replace single line (IDIOM-A) ----
|
|
2285
|
+
$replaceLine = {
|
|
2286
|
+
param([int]$idx, [string]$newLine)
|
|
2287
|
+
$lines[$idx] = $newLine
|
|
2288
|
+
$changed = $true
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
# --- C3': format_version ensure-key step (top-of-file column-0 prepend) ---
|
|
2292
|
+
# If a ^format_version: line is present, replace it in-place (IDIOM-A).
|
|
2293
|
+
# If absent, prepend format_version: <sup> at index 0 above project:.
|
|
2294
|
+
$fvIdx = -1
|
|
2295
|
+
for ($fi = 0; $fi -lt $lines.Count; $fi++) {
|
|
2296
|
+
if ($lines[$fi] -match '^format_version:') { $fvIdx = $fi; break }
|
|
2297
|
+
}
|
|
2298
|
+
if ($fvIdx -ge 0) {
|
|
2299
|
+
# Key present: replace with canonical value (IDIOM-A).
|
|
2300
|
+
& $replaceLine $fvIdx "format_version: $($script:AidSupportedFormat)"
|
|
2301
|
+
} else {
|
|
2302
|
+
# Key absent: prepend at index 0 (new top-of-file col-0 insert above project:).
|
|
2303
|
+
$lines.Insert(0, "format_version: $($script:AidSupportedFormat)")
|
|
2304
|
+
$changed = $true
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
# --- project section ---
|
|
2308
|
+
$projIdx = & $findSection 'project'
|
|
2309
|
+
if ($projIdx -eq -1) {
|
|
2310
|
+
& $appendBlock "project:`n name: ${RepoName}`n description: <project-description>`n type: brownfield"
|
|
2311
|
+
$changed = $true
|
|
2312
|
+
} else {
|
|
2313
|
+
$nameIdx = & $findKeyInSection $projIdx 'name'
|
|
2314
|
+
if ($nameIdx -eq -1) {
|
|
2315
|
+
& $insertAfter $projIdx " name: ${RepoName}"; $changed = $true
|
|
2316
|
+
} else {
|
|
2317
|
+
$nv = & $getScalarValue $lines[$nameIdx] 'name'
|
|
2318
|
+
if ([string]::IsNullOrEmpty($nv)) { & $replaceLine $nameIdx " name: ${RepoName}"; $changed = $true }
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
$descIdx = & $findKeyInSection $projIdx 'description'
|
|
2322
|
+
if ($descIdx -eq -1) {
|
|
2323
|
+
$nameIdx2 = & $findKeyInSection $projIdx 'name'
|
|
2324
|
+
$insAfterDesc = if ($nameIdx2 -ne -1) { $nameIdx2 } else { $projIdx }
|
|
2325
|
+
& $insertAfter $insAfterDesc ' description: <project-description>'; $changed = $true
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
$typeIdx = & $findKeyInSection $projIdx 'type'
|
|
2329
|
+
if ($typeIdx -eq -1) {
|
|
2330
|
+
$descIdx2 = & $findKeyInSection $projIdx 'description'
|
|
2331
|
+
$nameIdx3 = & $findKeyInSection $projIdx 'name'
|
|
2332
|
+
$insAfterType = if ($descIdx2 -ne -1) { $descIdx2 } elseif ($nameIdx3 -ne -1) { $nameIdx3 } else { $projIdx }
|
|
2333
|
+
& $insertAfter $insAfterType ' type: brownfield'; $changed = $true
|
|
2334
|
+
} else {
|
|
2335
|
+
$tv = & $getScalarValue $lines[$typeIdx] 'type'
|
|
2336
|
+
if ($tv -ne 'brownfield' -and $tv -ne 'greenfield') {
|
|
2337
|
+
& $replaceLine $typeIdx ' type: brownfield'; $changed = $true
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
# --- tools section ---
|
|
2343
|
+
$toolsIdx = & $findSection 'tools'
|
|
2344
|
+
if ($toolsIdx -eq -1) {
|
|
2345
|
+
& $appendBlock "tools:`n installed: []"; $changed = $true
|
|
2346
|
+
} else {
|
|
2347
|
+
$instIdx = & $findKeyInSection $toolsIdx 'installed'
|
|
2348
|
+
if ($instIdx -eq -1) { & $insertAfter $toolsIdx ' installed: []'; $changed = $true }
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
# --- review section ---
|
|
2352
|
+
$revIdx = & $findSection 'review'
|
|
2353
|
+
if ($revIdx -eq -1) {
|
|
2354
|
+
& $appendBlock "review:`n minimum_grade: A"; $changed = $true
|
|
2355
|
+
} else {
|
|
2356
|
+
$mgIdx = & $findKeyInSection $revIdx 'minimum_grade'
|
|
2357
|
+
if ($mgIdx -eq -1) {
|
|
2358
|
+
& $insertAfter $revIdx ' minimum_grade: A'; $changed = $true
|
|
2359
|
+
} else {
|
|
2360
|
+
$mv = & $getScalarValue $lines[$mgIdx] 'minimum_grade'
|
|
2361
|
+
if ($mv -notmatch '^[A-F][+-]?$') { & $replaceLine $mgIdx ' minimum_grade: A'; $changed = $true }
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
# --- execution section ---
|
|
2366
|
+
$execIdx = & $findSection 'execution'
|
|
2367
|
+
if ($execIdx -eq -1) {
|
|
2368
|
+
& $appendBlock "execution:`n max_parallel_tasks: 5"; $changed = $true
|
|
2369
|
+
} else {
|
|
2370
|
+
$mptIdx = & $findKeyInSection $execIdx 'max_parallel_tasks'
|
|
2371
|
+
if ($mptIdx -eq -1) {
|
|
2372
|
+
& $insertAfter $execIdx ' max_parallel_tasks: 5'; $changed = $true
|
|
2373
|
+
} else {
|
|
2374
|
+
$mv2 = & $getScalarValue $lines[$mptIdx] 'max_parallel_tasks'
|
|
2375
|
+
if ($mv2 -notmatch '^\d+$' -or [int]$mv2 -le 0) {
|
|
2376
|
+
& $replaceLine $mptIdx ' max_parallel_tasks: 5'; $changed = $true
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
# --- traceability section ---
|
|
2382
|
+
$traceIdx = & $findSection 'traceability'
|
|
2383
|
+
if ($traceIdx -eq -1) {
|
|
2384
|
+
& $appendBlock "traceability:`n heartbeat_interval: 1"; $changed = $true
|
|
2385
|
+
} else {
|
|
2386
|
+
$hbIdx = & $findKeyInSection $traceIdx 'heartbeat_interval'
|
|
2387
|
+
if ($hbIdx -eq -1) {
|
|
2388
|
+
& $insertAfter $traceIdx ' heartbeat_interval: 1'; $changed = $true
|
|
2389
|
+
} else {
|
|
2390
|
+
$hv = & $getScalarValue $lines[$hbIdx] 'heartbeat_interval'
|
|
2391
|
+
if ($hv -notmatch '^\d+$') { & $replaceLine $hbIdx ' heartbeat_interval: 1'; $changed = $true }
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
# Write only if changed (idempotent: no edit -> no write).
|
|
2396
|
+
if (-not $changed) { return }
|
|
2397
|
+
|
|
2398
|
+
$sfDir = Split-Path $SettingsFile -Parent
|
|
2399
|
+
$tmp = Join-Path $sfDir ("settings.yml.aid-tmp." + [System.IO.Path]::GetRandomFileName())
|
|
2400
|
+
try {
|
|
2401
|
+
Set-Content -LiteralPath $tmp -Value $lines.ToArray() -Encoding utf8NoBOM -ErrorAction Stop
|
|
2402
|
+
Move-Item -LiteralPath $tmp -Destination $SettingsFile -Force -ErrorAction Stop
|
|
2403
|
+
} catch {
|
|
2404
|
+
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
|
|
2405
|
+
throw
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
# script:Invoke-AidSynthesizeSettingsEraB <SettingsFile> <RepoName> <ManifestPath>
|
|
2410
|
+
# Era-b: write fresh template-derived settings.yml (crash-safe temp+mv).
|
|
2411
|
+
function script:Invoke-AidSynthesizeSettingsEraB {
|
|
2412
|
+
param([string]$SettingsFile, [string]$RepoName, [string]$ManifestPath)
|
|
2413
|
+
|
|
2414
|
+
$toolIds = @(Read-ManifestTools -ManifestPath $ManifestPath)
|
|
2415
|
+
|
|
2416
|
+
$sb = [System.Text.StringBuilder]::new()
|
|
2417
|
+
# C2': format_version stamp is the FIRST line (before project:).
|
|
2418
|
+
[void]$sb.Append("format_version: $($script:AidSupportedFormat)`n")
|
|
2419
|
+
[void]$sb.Append("project:`n")
|
|
2420
|
+
[void]$sb.Append(" name: ${RepoName}`n")
|
|
2421
|
+
[void]$sb.Append(" description: <project-description>`n")
|
|
2422
|
+
[void]$sb.Append(" type: brownfield`n")
|
|
2423
|
+
[void]$sb.Append("`n")
|
|
2424
|
+
[void]$sb.Append("tools:`n")
|
|
2425
|
+
if ($toolIds.Count -eq 0) {
|
|
2426
|
+
[void]$sb.Append(" installed: []`n")
|
|
2427
|
+
} else {
|
|
2428
|
+
[void]$sb.Append(" installed:`n")
|
|
2429
|
+
foreach ($t in $toolIds) { [void]$sb.Append(" - ${t}`n") }
|
|
2430
|
+
}
|
|
2431
|
+
[void]$sb.Append("`n")
|
|
2432
|
+
[void]$sb.Append("review:`n")
|
|
2433
|
+
[void]$sb.Append(" minimum_grade: A`n")
|
|
2434
|
+
[void]$sb.Append("`n")
|
|
2435
|
+
[void]$sb.Append("execution:`n")
|
|
2436
|
+
[void]$sb.Append(" max_parallel_tasks: 5`n")
|
|
2437
|
+
[void]$sb.Append("`n")
|
|
2438
|
+
[void]$sb.Append("traceability:`n")
|
|
2439
|
+
[void]$sb.Append(" heartbeat_interval: 1`n")
|
|
2440
|
+
|
|
2441
|
+
$sfDir = Split-Path $SettingsFile -Parent
|
|
2442
|
+
if (-not (Test-Path $sfDir -PathType Container)) {
|
|
2443
|
+
New-Item -ItemType Directory -Path $sfDir -Force | Out-Null
|
|
2444
|
+
}
|
|
2445
|
+
$tmp = Join-Path $sfDir ("settings.yml.aid-tmp." + [System.IO.Path]::GetRandomFileName())
|
|
2446
|
+
try {
|
|
2447
|
+
# Write as UTF-8 NoBOM; use raw string to control LF line endings.
|
|
2448
|
+
$raw = $sb.ToString()
|
|
2449
|
+
[System.IO.File]::WriteAllText($tmp, $raw, [System.Text.UTF8Encoding]::new($false))
|
|
2450
|
+
Move-Item -LiteralPath $tmp -Destination $SettingsFile -Force -ErrorAction Stop
|
|
2451
|
+
} catch {
|
|
2452
|
+
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
|
|
2453
|
+
throw
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
# Script-scope vars consumed by Invoke-AidUpdateSelf (reset here before each parse).
|
|
2458
|
+
$script:_SelfFromBundle = ''
|
|
2459
|
+
$script:_SelfDryRun = $false
|
|
2460
|
+
|
|
476
2461
|
# ---------------------------------------------------------------------------
|
|
477
2462
|
# update (with 'self' subarg -> update self)
|
|
478
2463
|
# ---------------------------------------------------------------------------
|
|
479
2464
|
if ($SUBCMD -eq 'update') {
|
|
480
2465
|
if ($script:_RemArgs.Count -gt 0 -and $script:_RemArgs[0] -eq 'self') {
|
|
481
2466
|
# Consume any flags after 'self'.
|
|
2467
|
+
$script:_SelfFromBundle = ''
|
|
2468
|
+
$script:_SelfDryRun = $false
|
|
482
2469
|
$remIdx = 1
|
|
483
2470
|
while ($remIdx -lt $script:_RemArgs.Count) {
|
|
484
2471
|
$a = $script:_RemArgs[$remIdx]
|
|
485
2472
|
switch ($a) {
|
|
486
|
-
{ $_ -in @('-Force', '--force', '-y') }
|
|
487
|
-
{ $_ -in @('-
|
|
2473
|
+
{ $_ -in @('-Force', '--force', '-y') } { } # no-op for update self
|
|
2474
|
+
{ $_ -in @('-DryRun', '--dry-run') } { $script:_SelfDryRun = $true }
|
|
2475
|
+
{ $_ -in @('-h', '--help', '-Help') } { script:Show-AidUsage 'update'; script:Exit-Aid 0 }
|
|
2476
|
+
{ $_ -in @('-FromBundle', '--from-bundle') } {
|
|
2477
|
+
$remIdx++
|
|
2478
|
+
if ($remIdx -ge $script:_RemArgs.Count) {
|
|
2479
|
+
script:Fail-Aid '-FromBundle requires a value' 2
|
|
2480
|
+
}
|
|
2481
|
+
$script:_SelfFromBundle = $script:_RemArgs[$remIdx]
|
|
2482
|
+
}
|
|
488
2483
|
default { script:Fail-Aid "unknown flag for 'update self': $a" 2 }
|
|
489
2484
|
}
|
|
490
2485
|
$remIdx++
|
|
491
2486
|
}
|
|
492
|
-
script:Invoke-AidUpdateSelf
|
|
493
|
-
|
|
2487
|
+
$usRc = script:Invoke-AidUpdateSelf
|
|
2488
|
+
if ($usRc -ne 0) { script:Exit-Aid $usRc }
|
|
2489
|
+
# Post-update: registry-driven migration (feature-004).
|
|
2490
|
+
# Iterate Get-RegistryUnion -- NO scan -- with All/Yes/No/Cancel per-repo
|
|
2491
|
+
# consent walk. Unregistered repos are caught lazily by the per-repo stamp.
|
|
2492
|
+
# No .migrated marker is written (removed; stamp in settings.yml is the record).
|
|
2493
|
+
# dry-run: the install step already printed its command; skip migration silently.
|
|
2494
|
+
if (-not $script:_SelfDryRun) {
|
|
2495
|
+
$usAutoYes = ($env:AID_MIGRATE_YES -eq '1')
|
|
2496
|
+
$usRepos = @(script:Get-RegistryUnion)
|
|
2497
|
+
if ($usRepos.Count -eq 0) {
|
|
2498
|
+
Write-Host 'No registered projects to migrate.'
|
|
2499
|
+
} else {
|
|
2500
|
+
# Determine interactive mode: AID_MIGRATE_YES=1 is the explicit opt-in for
|
|
2501
|
+
# auto-yes. Non-interactive without opt-in -> no migration (per SPEC).
|
|
2502
|
+
$usAutoYesFinal = $usAutoYes -or ($env:AID_MIGRATE_YES -eq '1')
|
|
2503
|
+
$usIsInteractive = [Environment]::UserInteractive
|
|
2504
|
+
if (-not $usAutoYesFinal -and -not $usIsInteractive) {
|
|
2505
|
+
Write-Host 'Skipping project migration (non-interactive; set AID_MIGRATE_YES=1 to opt in).'
|
|
2506
|
+
} else {
|
|
2507
|
+
$usMigrateAll = $false
|
|
2508
|
+
$usMigrateCancel = $false
|
|
2509
|
+
foreach ($usRepo in $usRepos) {
|
|
2510
|
+
if ($usMigrateCancel) { break }
|
|
2511
|
+
if ($usMigrateAll -or $usAutoYesFinal) {
|
|
2512
|
+
$usAnswer = 'y'
|
|
2513
|
+
} else {
|
|
2514
|
+
Write-Host -NoNewline "Migrate project $usRepo? [All/Yes/No/Cancel] "
|
|
2515
|
+
try { $usAnswer = Read-Host } catch { $usAnswer = '' }
|
|
2516
|
+
}
|
|
2517
|
+
switch -Regex ($usAnswer) {
|
|
2518
|
+
'^[Aa](ll|LL)?$' {
|
|
2519
|
+
$usMigrateAll = $true
|
|
2520
|
+
try { script:Invoke-AidMigrateRepo -Repo $usRepo } catch {
|
|
2521
|
+
[Console]::Error.WriteLine("WARN: aid: migration failed for ${usRepo}: $_")
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
'^[Yy](es|ES)?$' {
|
|
2525
|
+
try { script:Invoke-AidMigrateRepo -Repo $usRepo } catch {
|
|
2526
|
+
[Console]::Error.WriteLine("WARN: aid: migration failed for ${usRepo}: $_")
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
'^[Cc](ancel|ANCEL)?$' {
|
|
2530
|
+
$usMigrateCancel = $true
|
|
2531
|
+
Write-Host 'Migration cancelled.'
|
|
2532
|
+
}
|
|
2533
|
+
default {
|
|
2534
|
+
Write-Host "Skipped: $usRepo"
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
script:Exit-Aid 0
|
|
494
2542
|
}
|
|
495
2543
|
# Fall through to shared add/update handler below.
|
|
496
2544
|
}
|
|
497
2545
|
|
|
498
2546
|
# ---------------------------------------------------------------------------
|
|
499
2547
|
# remove (with 'self' subarg -> remove self)
|
|
2548
|
+
# Channel-aware, self-contained CLI removal. npm/pypi installs are owned by the
|
|
2549
|
+
# package manager, so removing only $AID_HOME left the wrapper + bin shim behind.
|
|
2550
|
+
# Now each channel does the COMPLETE removal:
|
|
2551
|
+
# npm -> npm uninstall -g aid-installer (package + vendored tree + shim)
|
|
2552
|
+
# pypi -> pipx uninstall aid-installer (venv + entry point)
|
|
2553
|
+
# curl -> Remove-Item $AID_CODE_HOME + unwire PATH
|
|
2554
|
+
# On Windows there is no sudo -- callers elevate their own shell if needed.
|
|
2555
|
+
# Honors -DryRun.
|
|
500
2556
|
# ---------------------------------------------------------------------------
|
|
501
2557
|
if ($SUBCMD -eq 'remove') {
|
|
502
2558
|
if ($script:_RemArgs.Count -gt 0 -and $script:_RemArgs[0] -eq 'self') {
|
|
503
2559
|
# Parse flags after 'self'.
|
|
504
2560
|
$rsForce = $false
|
|
505
2561
|
$rsNoPath = $false
|
|
2562
|
+
$rsDryRun = $false
|
|
506
2563
|
$remIdx = 1
|
|
507
2564
|
while ($remIdx -lt $script:_RemArgs.Count) {
|
|
508
2565
|
$a = $script:_RemArgs[$remIdx]
|
|
509
2566
|
switch ($a) {
|
|
510
|
-
{ $_ -in @('-Force', '--force', '-y') }
|
|
511
|
-
{ $_ -in @('-NoPath', '--no-path', '/nopath') }
|
|
512
|
-
{ $_ -in @('-
|
|
2567
|
+
{ $_ -in @('-Force', '--force', '-y') } { $rsForce = $true }
|
|
2568
|
+
{ $_ -in @('-NoPath', '--no-path', '/nopath') } { $rsNoPath = $true }
|
|
2569
|
+
{ $_ -in @('-DryRun', '--dry-run') } { $rsDryRun = $true }
|
|
2570
|
+
{ $_ -in @('-h', '--help', '-Help') } { script:Show-AidUsage 'remove'; script:Exit-Aid 0 }
|
|
513
2571
|
default { script:Fail-Aid "unknown flag for 'remove self': $a" 2 }
|
|
514
2572
|
}
|
|
515
2573
|
$remIdx++
|
|
@@ -520,15 +2578,23 @@ if ($SUBCMD -eq 'remove') {
|
|
|
520
2578
|
$rsForce = $true
|
|
521
2579
|
}
|
|
522
2580
|
|
|
523
|
-
$
|
|
2581
|
+
$channel = $env:AID_INSTALL_CHANNEL
|
|
2582
|
+
$aidHome = $script:_AidCodeHome
|
|
2583
|
+
|
|
2584
|
+
# Channel-aware description of what will be removed (NFR transparency).
|
|
2585
|
+
$what = switch ($channel) {
|
|
2586
|
+
'npm' { "the npm global package 'aid-installer' (npm uninstall -g)" }
|
|
2587
|
+
'pypi' { "the pipx app 'aid-installer' (pipx uninstall)" }
|
|
2588
|
+
default { "$aidHome and its PATH wiring" }
|
|
2589
|
+
}
|
|
524
2590
|
|
|
525
|
-
if (-not $rsForce) {
|
|
2591
|
+
if (-not $rsForce -and -not $rsDryRun) {
|
|
526
2592
|
# Skip prompt when non-interactive.
|
|
527
2593
|
$isInteractive = [Environment]::UserInteractive -and [Console]::In -ne [System.IO.TextReader]::Null
|
|
528
2594
|
if (-not $isInteractive) {
|
|
529
2595
|
$rsForce = $true
|
|
530
2596
|
} else {
|
|
531
|
-
Write-Host -NoNewline "Remove the aid CLI
|
|
2597
|
+
Write-Host -NoNewline "Remove the aid CLI -- ${what}? [y/N] "
|
|
532
2598
|
$answer = Read-Host
|
|
533
2599
|
if ($answer -notin @('y', 'Y', 'yes', 'YES')) {
|
|
534
2600
|
Write-Host "Aborted."
|
|
@@ -539,22 +2605,62 @@ if ($SUBCMD -eq 'remove') {
|
|
|
539
2605
|
|
|
540
2606
|
$partial = $false
|
|
541
2607
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
2608
|
+
switch ($channel) {
|
|
2609
|
+
'npm' {
|
|
2610
|
+
$npmCmd = Get-Command 'npm' -ErrorAction SilentlyContinue
|
|
2611
|
+
if (-not $npmCmd) {
|
|
2612
|
+
[Console]::Error.WriteLine("ERROR: aid: npm not found; cannot remove the npm-channel CLI")
|
|
2613
|
+
script:Exit-Aid 3
|
|
2614
|
+
}
|
|
2615
|
+
if ($rsDryRun) {
|
|
2616
|
+
Write-Host '+ npm uninstall -g aid-installer'
|
|
2617
|
+
} else {
|
|
2618
|
+
& npm uninstall -g aid-installer
|
|
2619
|
+
if ($LASTEXITCODE -ne 0) { $partial = $true }
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
'pypi' {
|
|
2623
|
+
$pipxCmd = Get-Command 'pipx' -ErrorAction SilentlyContinue
|
|
2624
|
+
if (-not $pipxCmd) {
|
|
2625
|
+
[Console]::Error.WriteLine("ERROR: aid: pipx not found; cannot remove the pypi-channel CLI")
|
|
2626
|
+
script:Exit-Aid 3
|
|
2627
|
+
}
|
|
2628
|
+
if ($rsDryRun) {
|
|
2629
|
+
Write-Host '+ pipx uninstall aid-installer'
|
|
2630
|
+
} else {
|
|
2631
|
+
& pipx uninstall aid-installer
|
|
2632
|
+
if ($LASTEXITCODE -ne 0) { $partial = $true }
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
default {
|
|
2636
|
+
# curl / default channel -- the _AidCodeHome tree + User PATH wiring.
|
|
2637
|
+
if ($rsDryRun) {
|
|
2638
|
+
if (-not $rsNoPath) {
|
|
2639
|
+
Write-Host "+ (unwire $aidHome\bin from your User PATH)"
|
|
2640
|
+
}
|
|
2641
|
+
Write-Host "+ Remove-Item -Recurse -Force $aidHome"
|
|
2642
|
+
} else {
|
|
2643
|
+
# Remove PATH wiring.
|
|
2644
|
+
if (-not $rsNoPath) {
|
|
2645
|
+
$binDir = Join-Path $aidHome 'bin'
|
|
2646
|
+
try { script:Remove-AidFromPath -BinDir $binDir } catch { $partial = $true }
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
# Remove _AidCodeHome directory.
|
|
2650
|
+
if (Test-Path $aidHome -PathType Container) {
|
|
2651
|
+
try {
|
|
2652
|
+
Remove-Item -LiteralPath $aidHome -Recurse -Force -ErrorAction Stop
|
|
2653
|
+
} catch {
|
|
2654
|
+
[Console]::Error.WriteLine("ERROR: aid: failed to remove $aidHome : $_")
|
|
2655
|
+
$partial = $true
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
555
2659
|
}
|
|
556
2660
|
}
|
|
557
2661
|
|
|
2662
|
+
if ($rsDryRun) { script:Exit-Aid 0 }
|
|
2663
|
+
|
|
558
2664
|
if ($partial) {
|
|
559
2665
|
Write-Host "aid CLI partially removed. Check the messages above for what remained."
|
|
560
2666
|
script:Exit-Aid 1
|
|
@@ -566,6 +2672,86 @@ if ($SUBCMD -eq 'remove') {
|
|
|
566
2672
|
# Fall through to shared remove handler below (may be 'remove' with no arg or with tool).
|
|
567
2673
|
}
|
|
568
2674
|
|
|
2675
|
+
|
|
2676
|
+
# ---------------------------------------------------------------------------
|
|
2677
|
+
# dashboard
|
|
2678
|
+
# ---------------------------------------------------------------------------
|
|
2679
|
+
if ($SUBCMD -eq 'dashboard') {
|
|
2680
|
+
script:Invoke-AidDashboardCtl -DcArgs $script:_RemArgs
|
|
2681
|
+
script:Exit-Aid $LASTEXITCODE
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
# ---------------------------------------------------------------------------
|
|
2685
|
+
# __migrate-repo (hidden, callable-core only -- task-077/081)
|
|
2686
|
+
# ---------------------------------------------------------------------------
|
|
2687
|
+
if ($SUBCMD -eq '__migrate-repo') {
|
|
2688
|
+
if ($script:_RemArgs.Count -lt 1) {
|
|
2689
|
+
[Console]::Error.WriteLine("ERROR: aid __migrate-repo requires a <repo> path argument")
|
|
2690
|
+
script:Exit-Aid 2
|
|
2691
|
+
}
|
|
2692
|
+
$_MigTarget = $script:_RemArgs[0]
|
|
2693
|
+
if (-not (Test-Path $_MigTarget -PathType Container)) {
|
|
2694
|
+
[Console]::Error.WriteLine("ERROR: aid __migrate-repo: not a directory: $_MigTarget")
|
|
2695
|
+
script:Exit-Aid 2
|
|
2696
|
+
}
|
|
2697
|
+
$_MigTarget = (Resolve-Path -LiteralPath $_MigTarget).Path
|
|
2698
|
+
script:Invoke-AidMigrateRepo -Repo $_MigTarget
|
|
2699
|
+
script:Exit-Aid 0
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
# ---------------------------------------------------------------------------
|
|
2703
|
+
# projects
|
|
2704
|
+
# ---------------------------------------------------------------------------
|
|
2705
|
+
if ($SUBCMD -eq 'projects') {
|
|
2706
|
+
# Check for -h/--help as first arg before dispatching.
|
|
2707
|
+
if ($script:_RemArgs.Count -gt 0 -and $script:_RemArgs[0] -in @('-h', '--help', '-Help')) {
|
|
2708
|
+
script:Show-AidUsage 'projects'
|
|
2709
|
+
script:Exit-Aid 0
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
# Determine sub-action (first positional or default "list").
|
|
2713
|
+
# Scan through leading flags to find the action word; unknown positionals are
|
|
2714
|
+
# rejected here so errors surface before entering Invoke-AidProjects.
|
|
2715
|
+
$_ProjAction = 'list'
|
|
2716
|
+
$_ProjArgs = [System.Collections.Generic.List[string]]::new()
|
|
2717
|
+
$remIdx = 0
|
|
2718
|
+
while ($remIdx -lt $script:_RemArgs.Count) {
|
|
2719
|
+
$a = $script:_RemArgs[$remIdx]
|
|
2720
|
+
switch ($a) {
|
|
2721
|
+
{ $_ -in @('list', 'add', 'remove', 'help') } {
|
|
2722
|
+
$_ProjAction = $a
|
|
2723
|
+
$remIdx++
|
|
2724
|
+
while ($remIdx -lt $script:_RemArgs.Count) {
|
|
2725
|
+
$_ProjArgs.Add($script:_RemArgs[$remIdx])
|
|
2726
|
+
$remIdx++
|
|
2727
|
+
}
|
|
2728
|
+
break
|
|
2729
|
+
}
|
|
2730
|
+
{ $_ -in @('-h', '--help', '-Help') } {
|
|
2731
|
+
script:Show-AidUsage 'projects'
|
|
2732
|
+
script:Exit-Aid 0
|
|
2733
|
+
break
|
|
2734
|
+
}
|
|
2735
|
+
{ $_ -in @('--local', '--shared', '--verbose') } {
|
|
2736
|
+
$_ProjArgs.Add($a)
|
|
2737
|
+
break
|
|
2738
|
+
}
|
|
2739
|
+
{ $_ -match '^-' } {
|
|
2740
|
+
# Unknown flag: pass through to Invoke-AidProjects for rejection.
|
|
2741
|
+
$_ProjArgs.Add($a)
|
|
2742
|
+
break
|
|
2743
|
+
}
|
|
2744
|
+
default {
|
|
2745
|
+
[Console]::Error.WriteLine("ERROR: aid projects: unknown action: $a (expected: list, add, remove, help)")
|
|
2746
|
+
script:Exit-Aid 2
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
$remIdx++
|
|
2750
|
+
}
|
|
2751
|
+
script:Invoke-AidProjects -Action $_ProjAction -RemArgs $_ProjArgs.ToArray()
|
|
2752
|
+
script:Exit-Aid 0
|
|
2753
|
+
}
|
|
2754
|
+
|
|
569
2755
|
# ---------------------------------------------------------------------------
|
|
570
2756
|
# add / remove / update - validate subcommand
|
|
571
2757
|
# ---------------------------------------------------------------------------
|
|
@@ -640,7 +2826,32 @@ if (-not $_AidTarget) { $_AidTarget = '.' }
|
|
|
640
2826
|
if (-not (Test-Path $_AidTarget -PathType Container)) {
|
|
641
2827
|
script:Fail-Aid "target directory does not exist: $_AidTarget" 2
|
|
642
2828
|
}
|
|
643
|
-
$_AidTarget = (Resolve-Path $_AidTarget).Path
|
|
2829
|
+
$_AidTarget = (Resolve-Path -LiteralPath $_AidTarget).Path
|
|
2830
|
+
|
|
2831
|
+
# ---- C-table pre-check for 'update [tool]': non-project -> offer + exit 0 ----
|
|
2832
|
+
# Must run BEFORE resolve-tools so we never reach exit-6 when no .aid/ exists.
|
|
2833
|
+
# 'add' uses the B-table (checked inside the dispatch case); 'remove' is not in C-table.
|
|
2834
|
+
# Test-AidIsProjectDir excludes the CLI state home from "is project" classification.
|
|
2835
|
+
if ($SUBCMD -eq 'update') {
|
|
2836
|
+
if (-not (script:Test-AidIsProjectDir -Dir $_AidTarget)) {
|
|
2837
|
+
script:Invoke-AidCwdNoAidOffer -Target $_AidTarget
|
|
2838
|
+
# Invoke-AidCwdNoAidOffer always calls Exit-Aid 0.
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
# C6': format gate for the update repo path (only when target is a real project;
|
|
2843
|
+
# an add to a fresh repo with no .aid/ falls through normally).
|
|
2844
|
+
if ($SUBCMD -eq 'update' -and (script:Test-AidIsProjectDir -Dir $_AidTarget)) {
|
|
2845
|
+
$gateRc = script:Invoke-AidFormatGate -Repo $_AidTarget
|
|
2846
|
+
if ($gateRc -ne 0) { script:Exit-Aid $gateRc }
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
# ---- Self-update-if-needed preamble (FF-3 / CLI-2 / task-079) --------------
|
|
2850
|
+
# For 'update [<tool>]' only (not 'add', not 'update self'). Ensures the CLI
|
|
2851
|
+
# is current before the per-repo migration runs (FR38 / OQ-6). WARN-not-fail.
|
|
2852
|
+
if ($SUBCMD -eq 'update') {
|
|
2853
|
+
script:Invoke-AidUpdateSelfIfStale -FromBundle $_AidFromBundle
|
|
2854
|
+
}
|
|
644
2855
|
|
|
645
2856
|
# Strip leading 'v' from version.
|
|
646
2857
|
if ($_AidVersionArg) { $_AidVersionArg = $_AidVersionArg -replace '^v', '' }
|
|
@@ -810,6 +3021,25 @@ try {
|
|
|
810
3021
|
|
|
811
3022
|
switch ($SUBCMD) {
|
|
812
3023
|
{ $_ -in @('add', 'update') } {
|
|
3024
|
+
# B-table (for 'add'): writability pre-check BEFORE any .aid/ is created.
|
|
3025
|
+
# Decision #3: never elevate .aid/ creation -- error if folder is not writable.
|
|
3026
|
+
if ($SUBCMD -eq 'add') {
|
|
3027
|
+
$_aidTargetWritable = $false
|
|
3028
|
+
$_wProbe = Join-Path $_AidTarget ('.aid-write-probe.' + [System.IO.Path]::GetRandomFileName())
|
|
3029
|
+
try { [System.IO.File]::WriteAllText($_wProbe, ''); Remove-Item -LiteralPath $_wProbe -Force -ErrorAction SilentlyContinue; $_aidTargetWritable = $true } catch {}
|
|
3030
|
+
if (-not $_aidTargetWritable) {
|
|
3031
|
+
[Console]::Error.WriteLine("ERROR: aid: add: target directory is not writable: $_AidTarget")
|
|
3032
|
+
[Console]::Error.WriteLine("ERROR: aid: add: AID will not create a root-owned .aid/ -- fix folder permissions and retry.")
|
|
3033
|
+
script:Exit-Aid 1
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
# C-table (for 'update [tool]'): register-on-encounter.
|
|
3038
|
+
# The missing-.aid/ case was already intercepted above (pre-resolve-tools).
|
|
3039
|
+
if ($SUBCMD -eq 'update') {
|
|
3040
|
+
script:Invoke-AidCwdClassify -Target $_AidTarget
|
|
3041
|
+
}
|
|
3042
|
+
|
|
813
3043
|
foreach ($t in $_AidTools) {
|
|
814
3044
|
Write-Host ""
|
|
815
3045
|
script:Prepare-AidToolStaging -Tool $t -Version $_AidVersionArg -Bundle $_AidFromBundle
|
|
@@ -831,6 +3061,27 @@ try {
|
|
|
831
3061
|
script:Exit-Aid 5
|
|
832
3062
|
}
|
|
833
3063
|
Write-Host "Done. AID $($script:_DispResolvedVersion) installed into: $_AidTarget"
|
|
3064
|
+
|
|
3065
|
+
# B-table (for 'add'): tier-aware registration after successful install.
|
|
3066
|
+
# Decision #3 (unwritable) already handled above with error+abort.
|
|
3067
|
+
if ($SUBCMD -eq 'add') {
|
|
3068
|
+
# FR7: deterministic, non-interactive tier selection via Resolve-AidTier.
|
|
3069
|
+
$_btabTier = script:Resolve-AidTier -CanonPath $_AidTarget
|
|
3070
|
+
script:Registry-Register -Repo $_AidTarget -Tier $_btabTier
|
|
3071
|
+
} else {
|
|
3072
|
+
# 'update [tool]': C-table register-on-encounter already ran above.
|
|
3073
|
+
# The post-install register is idempotent; route via user tier.
|
|
3074
|
+
script:Registry-Register -Repo $_AidTarget -Tier 'user'
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
# FF-3 / CLI-2 / task-079: per-repo migration on the 'update' reach only.
|
|
3078
|
+
# Runs on the already-canonicalized $_AidTarget (Resolve-Path above).
|
|
3079
|
+
# The Registry-Register above already ran, so migration step 4 is an
|
|
3080
|
+
# idempotent no-op; steps 1-3 run per FF-1. WARN-not-fail (NFR12):
|
|
3081
|
+
# migration never changes the tool-update exit code.
|
|
3082
|
+
if ($SUBCMD -eq 'update') {
|
|
3083
|
+
script:Invoke-AidMigrateRepo -Repo $_AidTarget
|
|
3084
|
+
}
|
|
834
3085
|
script:Exit-Aid 0
|
|
835
3086
|
}
|
|
836
3087
|
|
|
@@ -851,6 +3102,10 @@ try {
|
|
|
851
3102
|
|
|
852
3103
|
Write-Host ""
|
|
853
3104
|
Write-Host "Uninstall complete."
|
|
3105
|
+
# DR-1 registry side-effect: unregister repo only when manifest is now gone (last tool removed).
|
|
3106
|
+
if (-not (Test-Path $_AidManifest -PathType Leaf)) {
|
|
3107
|
+
script:Registry-Unregister -Repo $_AidTarget
|
|
3108
|
+
}
|
|
854
3109
|
script:Exit-Aid 0
|
|
855
3110
|
}
|
|
856
3111
|
}
|