@caddis/cli 0.4.23 → 0.4.24
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/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/SKILL.md +174 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/_template/profile.json +55 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/batch-job/profile.json +68 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/dotnet-service/profile.json +67 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/java-service/profile.json +62 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/node-service/profile.json +66 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/php-db/profile.json +75 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/python-web/profile.json +73 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/static-site/profile.json +87 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/references/config.example.json +18 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/references/profile-contract.md +207 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/references/standard.md +304 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/AdoptCommon.ps1 +812 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/Get-AppInventory.ps1 +236 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/New-AdoptionPlan.ps1 +311 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/Resolve-AppProfile.ps1 +246 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/Test-AppConformance.ps1 +343 -0
- package/bundles/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Phase 1. Read an existing app and write inventory.json. Changes nothing, runs nothing.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
An app that never went through a project generator is usually the only copy of
|
|
7
|
+
something that is already running. This script only READS: file metadata, the text of
|
|
8
|
+
small manifests and config files, git state, and what the host has registered to run
|
|
9
|
+
the folder. It never executes an entry point, an installer or a build.
|
|
10
|
+
|
|
11
|
+
It also refuses to let a partial answer look like a clean one. Walking a folder over a
|
|
12
|
+
network share can hit a file budget or an access denial. When that happens the script
|
|
13
|
+
reports the size as a MINIMUM, marks the scan partial and exits 1 unless you pass
|
|
14
|
+
-AllowPartial. A truncated scan that exits 0 is how a 140 GB data tree gets called
|
|
15
|
+
"about 200 MB" and committed.
|
|
16
|
+
|
|
17
|
+
File CONTENTS never reach inventory.json. A config file holds credentials and an
|
|
18
|
+
artefact gets copied into tickets and chats, so only the path, the line and the key
|
|
19
|
+
NAME are recorded.
|
|
20
|
+
|
|
21
|
+
.PARAMETER Path
|
|
22
|
+
The app folder. A local path, a UNC share, or a path on the remote box when you pass
|
|
23
|
+
-ComputerName.
|
|
24
|
+
|
|
25
|
+
.PARAMETER ComputerName
|
|
26
|
+
Scan on this box over WinRM. Use it when the app lives on a server: a UNC scan works
|
|
27
|
+
but is slow, and the host facts (services, scheduled tasks, web sites) can only be
|
|
28
|
+
read on the box itself.
|
|
29
|
+
|
|
30
|
+
.EXAMPLE
|
|
31
|
+
./Get-AppInventory.ps1 -Path E:\legacy-app
|
|
32
|
+
.EXAMPLE
|
|
33
|
+
./Get-AppInventory.ps1 -Path G:\apps\batch-engine -ComputerName appserver01 -FileBudget 5000
|
|
34
|
+
#>
|
|
35
|
+
|
|
36
|
+
[CmdletBinding()]
|
|
37
|
+
param(
|
|
38
|
+
[Parameter(Mandatory)][string]$Path,
|
|
39
|
+
[string]$Name,
|
|
40
|
+
[string]$ComputerName,
|
|
41
|
+
[string]$OutDir,
|
|
42
|
+
[int]$FileBudget = 0,
|
|
43
|
+
[int]$ProbeBudget = 4000,
|
|
44
|
+
# Top-level folders to leave unscanned by name. Use it for a tree you already know is
|
|
45
|
+
# bulk data, so the budget is spent on the folders whose contents are still a question.
|
|
46
|
+
# They are recorded as not-scanned, never as empty.
|
|
47
|
+
[string[]]$SkipFolder = @(),
|
|
48
|
+
[switch]$AllowPartial,
|
|
49
|
+
[switch]$SkipHostFacts
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
$ErrorActionPreference = 'Stop'
|
|
53
|
+
. (Join-Path $PSScriptRoot 'AdoptCommon.ps1')
|
|
54
|
+
|
|
55
|
+
if (-not $Name) { $Name = (Split-Path $Path -Leaf) -replace '[^A-Za-z0-9._-]', '-' }
|
|
56
|
+
$dir = Resolve-AdoptOutDir -Name $Name -OutDir $OutDir
|
|
57
|
+
|
|
58
|
+
# The project config supplies the defaults; a parameter always wins. Reading it here is what
|
|
59
|
+
# makes those keys real - a config value nothing consults is a promise the code does not keep.
|
|
60
|
+
$cfg = Get-AdoptConfig
|
|
61
|
+
if ($FileBudget -le 0) { $FileBudget = [int]$cfg.fileBudget }
|
|
62
|
+
if ($SkipFolder.Count -eq 0) { $SkipFolder = @($cfg.skipFolders) }
|
|
63
|
+
|
|
64
|
+
Write-AdoptHeading "Inventory: $Name"
|
|
65
|
+
Write-Host " source : $Path$(if ($ComputerName) { " (on $ComputerName)" })"
|
|
66
|
+
Write-Host " file budget : $FileBudget per top-level folder"
|
|
67
|
+
Write-Host " nothing in the source is written, and nothing in it is executed."
|
|
68
|
+
|
|
69
|
+
$scan = Invoke-AdoptScan -Path $Path -ComputerName $ComputerName -FileBudget $FileBudget `
|
|
70
|
+
-ProbeBudget $ProbeBudget -SkipFolders $SkipFolder -SkipPattern $cfg.skipPattern
|
|
71
|
+
|
|
72
|
+
if (-not $scan.exists) {
|
|
73
|
+
Write-Error "Source not found: $Path$(if ($ComputerName) { " on $ComputerName" }). $($scan.partialReason -join '; ')"
|
|
74
|
+
exit 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
$hostFacts = $null
|
|
78
|
+
if (-not $SkipHostFacts) {
|
|
79
|
+
try { $hostFacts = Get-AdoptRunRegistrations -Path $Path -ComputerName $ComputerName }
|
|
80
|
+
catch { Write-Warning "Could not read host registrations: $($_.Exception.Message)" }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
$kind = 'local'
|
|
84
|
+
if ($ComputerName) { $kind = 'winrm' }
|
|
85
|
+
elseif ($Path -match '^\\\\') { $kind = 'unc' }
|
|
86
|
+
|
|
87
|
+
$inventory = [ordered]@{
|
|
88
|
+
name = $Name
|
|
89
|
+
scannedAt = (Get-Date).ToUniversalTime().ToString('o')
|
|
90
|
+
scannedBy = 'adopt-app/Get-AppInventory.ps1'
|
|
91
|
+
source = [ordered]@{ kind = $kind; path = $Path; computerName = $ComputerName }
|
|
92
|
+
partial = [bool]$scan.partial
|
|
93
|
+
partialReason = @($scan.partialReason)
|
|
94
|
+
size = [ordered]@{
|
|
95
|
+
totalMB = $scan.totalMB
|
|
96
|
+
totalMBIsMinimum = [bool]$scan.partial
|
|
97
|
+
totalFiles = $scan.totalFiles
|
|
98
|
+
folders = @($scan.folders | Sort-Object { -1 * $_.mb })
|
|
99
|
+
}
|
|
100
|
+
git = $scan.git
|
|
101
|
+
# A shallow index of ordinary paths, used only by profile detection in phase 2.
|
|
102
|
+
paths = @($scan.paths)
|
|
103
|
+
manifests = @($scan.manifests)
|
|
104
|
+
entryPoints = @($scan.entryPoints)
|
|
105
|
+
configFiles = @($scan.configFiles)
|
|
106
|
+
secretsSuspected = @($scan.secretFiles)
|
|
107
|
+
certificates = @($scan.certificates)
|
|
108
|
+
excludable = @($scan.excludable)
|
|
109
|
+
reparsePoints = @($scan.reparse)
|
|
110
|
+
tests = [ordered]@{ files = $scan.testFiles; markers = @($scan.testMarkers) }
|
|
111
|
+
ci = @($scan.ciFiles)
|
|
112
|
+
docs = @($scan.docFiles)
|
|
113
|
+
healthSignals = @($scan.healthHits)
|
|
114
|
+
runRegistrations = $hostFacts
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Roll the excludable file list up per top folder. A list of 40,000 paths is unreadable and
|
|
118
|
+
# the decision is taken per folder anyway.
|
|
119
|
+
$byFolder = @{}
|
|
120
|
+
foreach ($e in @($scan.excludable)) {
|
|
121
|
+
$top = ($e.path -split '[\\/]')[0]
|
|
122
|
+
$key = "$top|$($e.category)"
|
|
123
|
+
if (-not $byFolder.ContainsKey($key)) { $byFolder[$key] = @{ folder = $top; category = $e.category; files = 0; bytes = [long]0 } }
|
|
124
|
+
$byFolder[$key].files++
|
|
125
|
+
$byFolder[$key].bytes += [long]$e.bytes
|
|
126
|
+
}
|
|
127
|
+
$inventory.excludableSummary = @($byFolder.Values |
|
|
128
|
+
ForEach-Object { [ordered]@{ folder = $_.folder; category = $_.category; files = $_.files; mb = [math]::Round($_.bytes / 1MB, 2) } } |
|
|
129
|
+
Sort-Object { -1 * $_.mb })
|
|
130
|
+
|
|
131
|
+
$out = Write-AdoptArtifact -Dir $dir -FileName 'inventory.json' -Object $inventory
|
|
132
|
+
|
|
133
|
+
# ---- report ---------------------------------------------------------------------------
|
|
134
|
+
Write-AdoptHeading 'Size'
|
|
135
|
+
if ($inventory.size.totalMBIsMinimum) { Write-Host " total AT LEAST $($inventory.size.totalMB) MB in $($inventory.size.totalFiles)+ files - the scan did not finish" -ForegroundColor Yellow }
|
|
136
|
+
else { Write-Host " total $($inventory.size.totalMB) MB in $($inventory.size.totalFiles) files" }
|
|
137
|
+
foreach ($f in @($inventory.size.folders | Select-Object -First 12)) {
|
|
138
|
+
$flag = ''
|
|
139
|
+
if ($f.skipped) { $flag = ' [NOT SCANNED - you passed -SkipFolder]' }
|
|
140
|
+
elseif ($f.truncated) { $flag = ' [truncated - at least this size]' }
|
|
141
|
+
elseif ($f.denied) { $flag = ' [access denied somewhere inside - at least this size]' }
|
|
142
|
+
Write-Host (" {0,10:N1} MB {1,8} files {2,-12} {3}{4}" -f $f.mb, $f.files, $f.git, $f.path, $flag)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
Write-AdoptHeading 'Git'
|
|
146
|
+
if ($inventory.git.isRepo -and -not (Get-AdoptProp $inventory.git 'gitAvailable' $false)) {
|
|
147
|
+
Write-Host " repo : there is a .git folder, but GIT IS NOT AVAILABLE where the scan ran." -ForegroundColor Red
|
|
148
|
+
Write-Host " Branches, remotes and which folders are tracked are UNKNOWN, not clean." -ForegroundColor Red
|
|
149
|
+
Write-Host " Install git on that box, or scan a local copy, before trusting anything below." -ForegroundColor Red
|
|
150
|
+
}
|
|
151
|
+
elseif ($inventory.git.isRepo) {
|
|
152
|
+
$remotes = @($inventory.git.remotes | ForEach-Object { "$($_.name) -> $($_.url)" })
|
|
153
|
+
Write-Host " repo : yes, $($inventory.git.commits) commits on $($inventory.git.branch)"
|
|
154
|
+
Write-Host " remotes : $(if ($remotes.Count) { $remotes -join '; ' } else { 'NONE - this folder is the only copy' })"
|
|
155
|
+
Write-Host " refs : $($inventory.git.localBranches) local branches, $($inventory.git.remoteBranches) remote-tracking, $($inventory.git.tags) tags"
|
|
156
|
+
Write-Host " dirty : $($inventory.git.dirtyFiles) files"
|
|
157
|
+
if ($null -ne $inventory.git.unpushed -and $inventory.git.unpushed -gt 0) {
|
|
158
|
+
Write-Host " UNPUSHED : $($inventory.git.unpushed) commits ahead of $($inventory.git.upstream)" -ForegroundColor Yellow
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else { Write-Host " repo : NO - there is no history to port; adoption starts with one import commit" -ForegroundColor Yellow }
|
|
162
|
+
|
|
163
|
+
# Neither committed nor ignored. This is the state that turns one careless `git add -A`
|
|
164
|
+
# into gigabytes of runtime data in the history for ever, and no filename pattern finds
|
|
165
|
+
# it - only git can tell an undeclared data tree from a source folder.
|
|
166
|
+
$untracked = @($inventory.size.folders | Where-Object { $_.git -eq 'untracked' -and $_.mb -ge 1 -and $_.path -ne '.git' })
|
|
167
|
+
if ($untracked.Count -gt 0) {
|
|
168
|
+
Write-AdoptHeading 'Neither committed nor ignored'
|
|
169
|
+
Write-Host " These folders are not in the repository AND not in .gitignore. One 'git add -A'" -ForegroundColor Yellow
|
|
170
|
+
Write-Host " commits them permanently. Decide each one: commit it, ignore it, or move it out." -ForegroundColor Yellow
|
|
171
|
+
foreach ($u in $untracked) { Write-Host (" {0,10:N1} MB {1,8} files {2}" -f $u.mb, $u.files, $u.path) -ForegroundColor Yellow }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
$ignoredBig = @($inventory.size.folders | Where-Object { $_.git -eq 'ignored' -and $_.mb -ge 200 })
|
|
175
|
+
if ($ignoredBig.Count -gt 0) {
|
|
176
|
+
Write-AdoptHeading 'Correctly ignored, but the app needs them'
|
|
177
|
+
Write-Host " Excluding these is right. It is only SAFE once something says how to get them back." -ForegroundColor DarkYellow
|
|
178
|
+
foreach ($i in $ignoredBig) { Write-Host (" {0,10:N1} MB {1}" -f $i.mb, $i.path) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
Write-AdoptHeading 'What the repository must not carry'
|
|
182
|
+
if ($inventory.excludableSummary.Count -eq 0) { Write-Host " nothing found" }
|
|
183
|
+
foreach ($e in @($inventory.excludableSummary | Select-Object -First 15)) {
|
|
184
|
+
Write-Host (" {0,10:N1} MB {1,7} files {2,-13} {3}" -f $e.mb, $e.files, $e.category, $e.folder)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
Write-AdoptHeading 'Possible credentials'
|
|
188
|
+
if ($inventory.secretsSuspected.Count -eq 0) { Write-Host " none found by the heuristics - which is not the same as none present" }
|
|
189
|
+
foreach ($s in @($inventory.secretsSuspected | Select-Object -First 25)) {
|
|
190
|
+
# Two traps in one line. A hashtable's PSObject.Properties are Keys/Values/Count and
|
|
191
|
+
# never its entries, so testing them showed a line number for nothing. And under
|
|
192
|
+
# StrictMode, reading a key that is absent THROWS - a name-only record has no 'line',
|
|
193
|
+
# so the whole inventory died after printing most of its report.
|
|
194
|
+
$where = $s.path
|
|
195
|
+
$hasLine = if ($s -is [hashtable]) { $s.ContainsKey('line') } else { @($s.PSObject.Properties.Name) -contains 'line' }
|
|
196
|
+
if ($hasLine -and $s.line) { $where = "$($s.path):$($s.line) key '$($s.key)'" }
|
|
197
|
+
Write-Host " $where ($($s.why))" -ForegroundColor Yellow
|
|
198
|
+
}
|
|
199
|
+
if ($inventory.certificates.Count -gt 0) {
|
|
200
|
+
Write-Host " $($inventory.certificates.Count) key container(s) hold a PUBLIC certificate, not a key - not a finding:" -ForegroundColor DarkGray
|
|
201
|
+
foreach ($c in @($inventory.certificates | Select-Object -First 8)) { Write-Host " $c" -ForegroundColor DarkGray }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if ($inventory.reparsePoints.Count -gt 0) {
|
|
205
|
+
Write-AdoptHeading 'Junctions and symlinks'
|
|
206
|
+
Write-Host " These were NOT followed. A junction inside a tree that git manages is a data-loss" -ForegroundColor Yellow
|
|
207
|
+
Write-Host " hazard: git's recursive remove follows it and deletes the TARGET's contents." -ForegroundColor Yellow
|
|
208
|
+
foreach ($r in @($inventory.reparsePoints | Select-Object -First 15)) { Write-Host " $($r.path)" }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if ($hostFacts) {
|
|
212
|
+
Write-AdoptHeading 'What runs it'
|
|
213
|
+
$any = $false
|
|
214
|
+
foreach ($s in @($hostFacts.services)) { $any = $true; Write-Host " service $($s.name) [$($s.state)/$($s.startMode)] as $($s.account)" }
|
|
215
|
+
foreach ($t in @($hostFacts.scheduledTasks)) { $any = $true; Write-Host " scheduled task $($t.path)$($t.name) [$($t.state)] as $($t.account); last run $($t.lastRun)" }
|
|
216
|
+
foreach ($w in @($hostFacts.webSites)) { $any = $true; Write-Host " web site $($w.name) -> $($w.physicalPath)" }
|
|
217
|
+
if (-not $any) {
|
|
218
|
+
Write-Host " nothing on $(if ($ComputerName) { $ComputerName } else { 'this machine' }) references this folder." -ForegroundColor Yellow
|
|
219
|
+
Write-Host " If the app IS running, it runs somewhere else. Find out where before you adopt it." -ForegroundColor Yellow
|
|
220
|
+
}
|
|
221
|
+
foreach ($e in @($hostFacts.errors)) { Write-Host " (could not check $e)" -ForegroundColor DarkYellow }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
Write-Host ""
|
|
225
|
+
Write-Host " wrote $out"
|
|
226
|
+
|
|
227
|
+
if ($inventory.partial) {
|
|
228
|
+
Write-Host ""
|
|
229
|
+
Write-Host "SCAN INCOMPLETE. Sizes above are MINIMUMS:" -ForegroundColor Red
|
|
230
|
+
foreach ($r in @($inventory.partialReason)) { Write-Host " - $r" -ForegroundColor Red }
|
|
231
|
+
Write-Host "Raise -FileBudget, or accept it with -AllowPartial once you know what is in those folders." -ForegroundColor Red
|
|
232
|
+
if (-not $AllowPartial) { exit 1 }
|
|
233
|
+
Write-Host "Accepted with -AllowPartial; inventory.json records partial=true." -ForegroundColor Yellow
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
exit 0
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Phase 4. Turn the inventory, the profile and the conformance report into an ORDERED
|
|
4
|
+
plan a person can work through. Writes adoption-plan.md and adoption.json.
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
The order is the product. Every step in an adoption has a prerequisite, and doing them
|
|
8
|
+
in the wrong order costs real work:
|
|
9
|
+
|
|
10
|
+
- Create the repository BEFORE settling what must not be in it, and removing it
|
|
11
|
+
later means rewriting history that other people have already cloned.
|
|
12
|
+
- Rotate a credential AFTER publishing, and it was public in between.
|
|
13
|
+
- Wire up CI before anyone has said which branch is the truth, and CI builds the
|
|
14
|
+
wrong branch.
|
|
15
|
+
|
|
16
|
+
So the plan is emitted in dependency order, each step carrying the evidence that put
|
|
17
|
+
it there and the check that says it is done. Steps that cannot start are listed as
|
|
18
|
+
BLOCKED with the reason, rather than left out - a plan that silently omits what it
|
|
19
|
+
cannot do reads like a shorter job.
|
|
20
|
+
|
|
21
|
+
This script writes a document. It changes nothing else, and it deliberately does not
|
|
22
|
+
execute any step.
|
|
23
|
+
|
|
24
|
+
.EXAMPLE
|
|
25
|
+
./New-AdoptionPlan.ps1 -Name legacy-app
|
|
26
|
+
#>
|
|
27
|
+
|
|
28
|
+
[CmdletBinding()]
|
|
29
|
+
param(
|
|
30
|
+
[Parameter(Mandatory)][string]$Name,
|
|
31
|
+
[string]$OutDir,
|
|
32
|
+
# The fleet- or team-specific skill that performs the adoption once this plan is
|
|
33
|
+
# agreed. Named in the handover section so the plan does not end in mid-air.
|
|
34
|
+
[string]$HandoverSkill = ''
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
$ErrorActionPreference = 'Stop'
|
|
38
|
+
. (Join-Path $PSScriptRoot 'AdoptCommon.ps1')
|
|
39
|
+
|
|
40
|
+
$dir = Resolve-AdoptOutDir -Name $Name -OutDir $OutDir
|
|
41
|
+
$inv = Read-AdoptArtifact -Dir $dir -FileName 'inventory.json'
|
|
42
|
+
$prof = Read-AdoptArtifact -Dir $dir -FileName 'profile.json'
|
|
43
|
+
$conf = Read-AdoptArtifact -Dir $dir -FileName 'conformance.json'
|
|
44
|
+
|
|
45
|
+
function Get-ConformanceItem { param([string]$Id) return @($conf.items | Where-Object { $_.id -eq $Id })[0] }
|
|
46
|
+
function Test-ItemPasses { param([string]$Id) $i = Get-ConformanceItem $Id; return ($null -ne $i) -and ($i.verdict -in @('PASS', 'ANSWERED')) }
|
|
47
|
+
|
|
48
|
+
$steps = @()
|
|
49
|
+
function Add-Step {
|
|
50
|
+
param(
|
|
51
|
+
[string]$Id, [string]$Title, [string[]]$Needs = @(), [string]$Why = '',
|
|
52
|
+
[string[]]$Do = @(), [string]$Done = '', [string]$State = 'TODO'
|
|
53
|
+
)
|
|
54
|
+
$script:steps += [ordered]@{ id = $Id; title = $Title; needs = $Needs; why = $Why; actions = $Do; doneWhen = $Done; state = $State }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------------------
|
|
58
|
+
# 1. Answer what only a person can answer.
|
|
59
|
+
$openQuestions = @($conf.items | Where-Object { $_.verdict -eq 'ASK' })
|
|
60
|
+
if ($openQuestions.Count -gt 0) {
|
|
61
|
+
Add-Step 'answers' 'Get the owner''s answers' @() `
|
|
62
|
+
'Every later step depends on at least one of these. They are not observable from files, so no amount of scanning will settle them.' `
|
|
63
|
+
(@($openQuestions | ForEach-Object { "[$($_.id)] $($_.question)" }) + @("Record each: Test-AppConformance.ps1 -Name $Name -Answer <id>=`"...`"")) `
|
|
64
|
+
'Test-AppConformance.ps1 reports no open ASK on a blocking item.' `
|
|
65
|
+
$(if (@($conf.blockingQuestions).Count -gt 0) { 'TODO' } else { 'TODO-NONBLOCKING' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# 2. Decide the content policy, BEFORE any repository exists.
|
|
69
|
+
$so = Get-ConformanceItem 'source-only'
|
|
70
|
+
if ($so -and $so.verdict -eq 'GAP') {
|
|
71
|
+
$lines = @()
|
|
72
|
+
foreach ($f in @($inv.size.folders | Where-Object { $_.git -eq 'untracked' -and $_.path -ne '.git' -and $_.mb -ge 1 })) {
|
|
73
|
+
$lines += "$($f.path) - $($f.mb) MB, neither committed nor ignored: commit it, ignore it, or move it out of the tree"
|
|
74
|
+
}
|
|
75
|
+
foreach ($e in @($inv.excludableSummary | Select-Object -First 10)) {
|
|
76
|
+
$g = @($inv.size.folders | Where-Object { $_.path -eq $e.folder })
|
|
77
|
+
if ($g.Count -gt 0 -and $g[0].git -eq 'tracked') {
|
|
78
|
+
$lines += "$($e.folder) - $($e.mb) MB of $($e.category) is COMMITTED: needs a history rewrite, not a .gitignore line"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
Add-Step 'content-policy' 'Settle what the repository may hold' @('answers') `
|
|
82
|
+
'A .gitignore added later does not remove what is already committed, and a folder that is neither committed nor ignored is one careless command away from being permanent.' `
|
|
83
|
+
$lines `
|
|
84
|
+
'Every folder over the size threshold is classified: source, ignored-and-provisioned, or moved out.'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# 3. Credentials.
|
|
88
|
+
$sec = Get-ConformanceItem 'no-secrets'
|
|
89
|
+
if ($sec -and $sec.verdict -eq 'GAP') {
|
|
90
|
+
Add-Step 'secrets' 'Read every flagged file, then rotate what was real' @('content-policy') `
|
|
91
|
+
'Publishing is irreversible. A credential that reaches a hosted repository must be treated as disclosed, whether or not anyone fetched it.' `
|
|
92
|
+
(@(@($inv.secretsSuspected | Select-Object -First 15 | ForEach-Object {
|
|
93
|
+
$ln = Get-AdoptProp $_ 'line'
|
|
94
|
+
if ($ln) { "$($_.path):$ln - key '$(Get-AdoptProp $_ 'key')'" } else { "$($_.path) - $($_.why)" }
|
|
95
|
+
})) + @('For each: is it real? If yes, ROTATE it and remove it from the tree AND from history.')) `
|
|
96
|
+
'No real credential remains in the tree, and every one that was there has been rotated.'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# 4. The repository.
|
|
100
|
+
if (-not (Test-ItemPasses 'git-repo')) {
|
|
101
|
+
Add-Step 'import' 'Import the folder as a repository' @('content-policy', 'secrets') `
|
|
102
|
+
'There is no history to preserve, so the first commit is a decision about content, not about history.' `
|
|
103
|
+
@('Write .gitignore FIRST, from the content policy above.', 'Stage files BY NAME. Never `git add -A` in a folder this size.', 'One import commit, then push.') `
|
|
104
|
+
'The remote holds the source and nothing the content policy excluded.'
|
|
105
|
+
}
|
|
106
|
+
elseif (-not (Test-ItemPasses 'remote-on-team-host')) {
|
|
107
|
+
$rem = Get-ConformanceItem 'remote-on-team-host'
|
|
108
|
+
Add-Step 'port' 'Move the repository to the team''s host' @('content-policy', 'secrets', 'answers') `
|
|
109
|
+
"Today: $($rem.evidence)" `
|
|
110
|
+
@('Use the port-to-github skill: it stages the source read-only, verifies every branch and tag by SHA, and never pushes back to the source.',
|
|
111
|
+
'Push every ref that exists only on the old host BEFORE converting or deleting it.',
|
|
112
|
+
'Keep the old remote as a second remote until the first deploy from the new one is green.') `
|
|
113
|
+
'Every branch and tag matches by SHA on the new host, and the old one is read-only or archived.'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# 5. The rest, in the order they unblock each other.
|
|
117
|
+
if (-not (Test-ItemPasses 'deps-pinned')) {
|
|
118
|
+
Add-Step 'pin-deps' 'Pin the dependencies' @('import', 'port') `
|
|
119
|
+
'Without a lock file a failed build cannot be told from a dependency that moved underneath it.' `
|
|
120
|
+
@('Commit the lock file the ecosystem produces.', 'Record the exact runtime version the app is built and run with.') `
|
|
121
|
+
'A clean checkout on another machine installs the same versions.'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
$rm = Get-ConformanceItem 'runtime-manifest'
|
|
125
|
+
if ($rm -and $rm.verdict -ne 'PASS') {
|
|
126
|
+
Add-Step 'provision' 'Write down how to rebuild the environment' @('content-policy') `
|
|
127
|
+
"Excluding a runtime from the repository is only safe once something says how to get it back. $($rm.evidence)" `
|
|
128
|
+
@('For each excluded folder: name the component, the EXACT version, and where it is installed from.',
|
|
129
|
+
'Write a provisioning script that FAILS for anything it cannot provision, rather than skipping it.',
|
|
130
|
+
'Copy configuration out of the runtime folders as templates - configuration is app content even when the runtime around it is not.') `
|
|
131
|
+
'A new machine can be brought to a working state from the repository plus the provisioning script.'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (-not (Test-ItemPasses 'tests')) {
|
|
135
|
+
Add-Step 'tests' 'Get a test suite that CI can run' @('pin-deps') `
|
|
136
|
+
'CI that runs no tests only proves the code compiles.' `
|
|
137
|
+
@('Start with the paths that would break silently: the ones with no health signal and no rollback.') `
|
|
138
|
+
'One command runs the suite and returns a non-zero exit code when it fails.'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (-not (Test-ItemPasses 'ci-build')) {
|
|
142
|
+
Add-Step 'ci' 'Add CI that builds and tests every push' @('import', 'port', 'pin-deps') `
|
|
143
|
+
'Until CI exists, "it works" means "it worked on one machine".' `
|
|
144
|
+
@('Build and test on push and on pull request.',
|
|
145
|
+
'Classify every credential in the workflow: it faces ORIGIN, or it names a DEPENDENCY host. There is no third kind.',
|
|
146
|
+
'Never write double-curly-brace text anywhere in a workflow file, including inside comments in a run block.') `
|
|
147
|
+
'A pull request cannot merge while the build or the tests fail.'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
$health = Get-ConformanceItem 'health'
|
|
151
|
+
if ($health -and $health.verdict -ne 'PASS') {
|
|
152
|
+
Add-Step 'health' 'Add a health signal that can fail' @('ci') `
|
|
153
|
+
'A check that cannot fail manufactures confidence. The commonest version returns 200 from a catch-all route while the app is broken.' `
|
|
154
|
+
@('For a service: an endpoint that reports its dependencies and fails when one is down.',
|
|
155
|
+
'For a batch job: a non-zero exit code AND an output artefact whose absence is detectable.',
|
|
156
|
+
'Prove it fails: break the dependency on purpose and watch the check go red.') `
|
|
157
|
+
'The check has been observed FAILING, not only passing.'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (-not (Test-ItemPasses 'ci-deploy')) {
|
|
161
|
+
Add-Step 'deploy' 'Make the deployment reproducible' @('ci', 'health', 'provision') `
|
|
162
|
+
'A manual deploy is a procedure that exists only in somebody''s memory.' `
|
|
163
|
+
@('Build the artefact BEFORE stopping anything. Only a build that produced one earns the right to interrupt the service.',
|
|
164
|
+
'Roll the code and the built artefact back together - restoring one without the other leaves a mixed state that passes every check.',
|
|
165
|
+
'Verify by the artefact CHANGING - a bundle hash, a file time - never by an HTTP 200 or a version endpoint. Both pass in the mixed state.') `
|
|
166
|
+
'A deploy can be run by someone who has never done it, from the written steps alone.'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
$rb = Get-ConformanceItem 'rollback'
|
|
170
|
+
if ($rb -and $rb.verdict -ne 'PASS') {
|
|
171
|
+
Add-Step 'rollback' 'Write and TEST the way back' @('deploy') `
|
|
172
|
+
'A rollback nobody has run is a plan, not a capability.' `
|
|
173
|
+
@('Write the exact command.', 'Run it once, deliberately, on a real release.', 'Note what it cannot undo - a database migration usually cannot.') `
|
|
174
|
+
'The rollback has been executed at least once and the result was verified.'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
$run = Get-ConformanceItem 'run-declared'
|
|
178
|
+
if ($run -and $run.verdict -ne 'PASS') {
|
|
179
|
+
Add-Step 'run-declared' 'Declare how it runs, in the repository' @('deploy') `
|
|
180
|
+
"How this app starts is not currently recorded anywhere version-controlled. $($run.evidence)" `
|
|
181
|
+
@('Commit the service definition or the schedule.', 'Record the account it runs as and why that account.', 'Record the trigger: a port, a timer, a queue, a person.') `
|
|
182
|
+
'Recreating the runtime registration needs no undocumented knowledge.'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
foreach ($small in @(
|
|
186
|
+
@{ id = 'docs'; item = 'docs-readme'; title = 'Write the README'; why = 'The first question a new person asks is what this is and how to run it.'; done = 'A new person can run it locally from the README alone.' },
|
|
187
|
+
@{ id = 'agent-rules'; item = 'agent-rules'; title = 'Add the agent rules file'; why = 'Without it every coding agent re-derives the project rules, and each derives them differently.'; done = 'The file exists and states the rules that are not obvious from the code.' },
|
|
188
|
+
@{ id = 'junctions'; item = 'no-junctions'; title = 'Remove the junctions from the tree'; why = 'git''s recursive remove follows a junction and deletes the TARGET''s contents. This is data loss, not an inconvenience.'; done = 'No reparse point remains inside the tree git manages; shared state is reached by absolute path.' }
|
|
189
|
+
)) {
|
|
190
|
+
if (-not (Test-ItemPasses $small.item)) {
|
|
191
|
+
Add-Step $small.id $small.title @('import', 'port') $small.why @() $small.done
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# ---- mark blocked steps --------------------------------------------------------------
|
|
196
|
+
$presentIds = @($steps | ForEach-Object { $_.id })
|
|
197
|
+
foreach ($s in $steps) {
|
|
198
|
+
$blockers = @($s.needs | Where-Object { $presentIds -contains $_ })
|
|
199
|
+
if ($blockers.Count -gt 0 -and $s.id -ne 'answers') { $s.state = 'BLOCKED' }
|
|
200
|
+
$s.needs = $blockers
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# ---- write ----------------------------------------------------------------------------
|
|
204
|
+
$json = [ordered]@{
|
|
205
|
+
name = $Name; plannedAt = (Get-Date).ToUniversalTime().ToString('o')
|
|
206
|
+
source = $inv.source; components = @($prof.components)
|
|
207
|
+
blockingGaps = @($conf.blockingGaps); blockingQuestions = @($conf.blockingQuestions)
|
|
208
|
+
steps = $steps
|
|
209
|
+
}
|
|
210
|
+
$jsonPath = Write-AdoptArtifact -Dir $dir -FileName 'adoption.json' -Object $json
|
|
211
|
+
|
|
212
|
+
$md = New-Object System.Text.StringBuilder
|
|
213
|
+
[void]$md.AppendLine("# Adopting $Name")
|
|
214
|
+
[void]$md.AppendLine()
|
|
215
|
+
[void]$md.AppendLine("Generated $((Get-Date).ToUniversalTime().ToString('u')) by the adopt-app skill, from a read-only scan. Nothing in the app was executed.")
|
|
216
|
+
[void]$md.AppendLine()
|
|
217
|
+
[void]$md.AppendLine('## What this app is')
|
|
218
|
+
[void]$md.AppendLine()
|
|
219
|
+
# A markdown code span is delimited by a backtick, which is also PowerShell's escape
|
|
220
|
+
# character, so the delimiter is built as a variable rather than typed inside the string.
|
|
221
|
+
$tick = [char]0x60
|
|
222
|
+
$sourceHost = ''
|
|
223
|
+
if ($inv.source.computerName) { $sourceHost = " on $($inv.source.computerName)" }
|
|
224
|
+
[void]$md.AppendLine("- **Source**: $tick$($inv.source.path)$tick$sourceHost ($($inv.source.kind))")
|
|
225
|
+
$sizeWord = if ($inv.size.totalMBIsMinimum) { 'at least ' } else { '' }
|
|
226
|
+
[void]$md.AppendLine("- **Size**: $sizeWord$($inv.size.totalMB) MB in $($inv.size.totalFiles) files")
|
|
227
|
+
if ($inv.git.isRepo) {
|
|
228
|
+
$remoteText = if (@($inv.git.remotes).Count -gt 0) { (@($inv.git.remotes | ForEach-Object { $_.url }) -join ', ') } else { '**none - this folder is the only copy**' }
|
|
229
|
+
[void]$md.AppendLine("- **Git**: $($inv.git.commits) commits, $($inv.git.localBranches) local branches, $($inv.git.tags) tags; remote: $remoteText")
|
|
230
|
+
}
|
|
231
|
+
else { [void]$md.AppendLine('- **Git**: not a repository') }
|
|
232
|
+
[void]$md.AppendLine("- **Components**:")
|
|
233
|
+
foreach ($c in @($prof.components)) {
|
|
234
|
+
$also = if (@($c.alsoMatches).Count -gt 0) { " (also matches $((@($c.alsoMatches)) -join ', ') - confirm which it is)" } else { '' }
|
|
235
|
+
[void]$md.AppendLine(" - $tick$($c.path)$tick -> **$($c.profile)** [$($c.status)]$also")
|
|
236
|
+
}
|
|
237
|
+
if ($inv.partial) {
|
|
238
|
+
[void]$md.AppendLine()
|
|
239
|
+
[void]$md.AppendLine('> **The scan was partial.** Every "nothing found" below means "nothing found so far":')
|
|
240
|
+
foreach ($r in @($inv.partialReason)) { [void]$md.AppendLine("> - $r") }
|
|
241
|
+
}
|
|
242
|
+
[void]$md.AppendLine()
|
|
243
|
+
|
|
244
|
+
[void]$md.AppendLine('## Where it stands')
|
|
245
|
+
[void]$md.AppendLine()
|
|
246
|
+
[void]$md.AppendLine('| | Item | Verdict | Evidence |')
|
|
247
|
+
[void]$md.AppendLine('|---|---|---|---|')
|
|
248
|
+
foreach ($it in @($conf.items)) {
|
|
249
|
+
$b = if ($it.blocking) { '**!**' } else { '' }
|
|
250
|
+
$ev = "$($it.evidence)" -replace '\|', '\|'
|
|
251
|
+
[void]$md.AppendLine("| $b | $($it.title) | $($it.verdict) | $ev |")
|
|
252
|
+
}
|
|
253
|
+
[void]$md.AppendLine()
|
|
254
|
+
[void]$md.AppendLine('`!` marks a blocking item. `ASK` means it cannot be read from the files; a person must answer it.')
|
|
255
|
+
[void]$md.AppendLine()
|
|
256
|
+
|
|
257
|
+
if ($openQuestions.Count -gt 0) {
|
|
258
|
+
[void]$md.AppendLine('## Questions for the owner')
|
|
259
|
+
[void]$md.AppendLine()
|
|
260
|
+
[void]$md.AppendLine('Ask these before starting. Each one changes what the later steps are.')
|
|
261
|
+
[void]$md.AppendLine()
|
|
262
|
+
foreach ($q in $openQuestions) {
|
|
263
|
+
$star = if ($q.blocking) { ' **(blocking)**' } else { '' }
|
|
264
|
+
[void]$md.AppendLine("1. **$($q.id)**$star - $($q.question)")
|
|
265
|
+
}
|
|
266
|
+
[void]$md.AppendLine()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
[void]$md.AppendLine('## The plan, in order')
|
|
270
|
+
[void]$md.AppendLine()
|
|
271
|
+
if ($steps.Count -eq 0) {
|
|
272
|
+
[void]$md.AppendLine('Nothing to do: every item passes.')
|
|
273
|
+
}
|
|
274
|
+
$n = 0
|
|
275
|
+
foreach ($s in $steps) {
|
|
276
|
+
$n++
|
|
277
|
+
$needs = if (@($s.needs).Count -gt 0) { " - needs: $((@($s.needs)) -join ', ')" } else { '' }
|
|
278
|
+
[void]$md.AppendLine("### $n. $($s.title)$needs")
|
|
279
|
+
[void]$md.AppendLine()
|
|
280
|
+
if ($s.why) { [void]$md.AppendLine("$($s.why)"); [void]$md.AppendLine() }
|
|
281
|
+
foreach ($a in @($s.actions)) { [void]$md.AppendLine("- $a") }
|
|
282
|
+
if (@($s.actions).Count -gt 0) { [void]$md.AppendLine() }
|
|
283
|
+
[void]$md.AppendLine("**Done when:** $($s.doneWhen)")
|
|
284
|
+
[void]$md.AppendLine()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
[void]$md.AppendLine('## Handover')
|
|
288
|
+
[void]$md.AppendLine()
|
|
289
|
+
if ($HandoverSkill) {
|
|
290
|
+
[void]$md.AppendLine("Once every blocking item is satisfied, the $tick$HandoverSkill$tick skill performs the adoption: it knows this team's hosts, ports, routing and CI. This plan is its input.")
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
[void]$md.AppendLine('This plan says WHAT must be true, not HOW your platform does it. The team-specific skill that owns hosts, ports, routing and CI performs the steps; give it this file.')
|
|
294
|
+
}
|
|
295
|
+
[void]$md.AppendLine()
|
|
296
|
+
[void]$md.AppendLine('Re-run Test-AppConformance.ps1 after each step. It is the same gate either way, so the plan cannot quietly drift from the standard.')
|
|
297
|
+
|
|
298
|
+
$mdPath = Join-Path $dir 'adoption-plan.md'
|
|
299
|
+
$md.ToString() | Set-Content -LiteralPath $mdPath -Encoding UTF8
|
|
300
|
+
|
|
301
|
+
Write-AdoptHeading "Adoption plan: $Name"
|
|
302
|
+
Write-Host " $($steps.Count) steps, $(@($steps | Where-Object { $_.state -eq 'BLOCKED' }).Count) of them blocked by an earlier one"
|
|
303
|
+
Write-Host " $($openQuestions.Count) open question(s), $(@($conf.blockingQuestions).Count) of them blocking"
|
|
304
|
+
foreach ($s in $steps) {
|
|
305
|
+
$mark = if ($s.state -eq 'BLOCKED') { 'blocked' } else { 'ready ' }
|
|
306
|
+
Write-Host (" {0} {1}" -f $mark, $s.title)
|
|
307
|
+
}
|
|
308
|
+
Write-Host ""
|
|
309
|
+
Write-Host " wrote $mdPath"
|
|
310
|
+
Write-Host " wrote $jsonPath"
|
|
311
|
+
exit 0
|