@caddis/cli 0.4.24 → 0.4.26

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.
Files changed (40) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  3. package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/scripts/AdoptCommon.ps1 +20 -2
  4. package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/scripts/Get-AppInventory.ps1 +14 -4
  5. package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/scripts/New-AdoptionPlan.ps1 +2 -4
  6. package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/scripts/Test-AppConformance.ps1 +4 -2
  7. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  8. package/bundles/antigravity-plugin-extras/skills/_registry.md +0 -1
  9. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +174 -0
  10. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/_template/profile.json +55 -0
  11. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/batch-job/profile.json +68 -0
  12. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/dotnet-service/profile.json +67 -0
  13. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/java-service/profile.json +62 -0
  14. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/node-service/profile.json +66 -0
  15. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/php-db/profile.json +75 -0
  16. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/python-web/profile.json +73 -0
  17. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/static-site/profile.json +87 -0
  18. package/bundles/codex/.codex/skills/devops/adopt-app/references/config.example.json +18 -0
  19. package/bundles/codex/.codex/skills/devops/adopt-app/references/profile-contract.md +207 -0
  20. package/bundles/codex/.codex/skills/devops/adopt-app/references/standard.md +304 -0
  21. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/AdoptCommon.ps1 +830 -0
  22. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Get-AppInventory.ps1 +246 -0
  23. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/New-AdoptionPlan.ps1 +309 -0
  24. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Resolve-AppProfile.ps1 +246 -0
  25. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Test-AppConformance.ps1 +345 -0
  26. package/bundles/manifest.json +3 -3
  27. package/package.json +1 -1
  28. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/SKILL.md +0 -0
  29. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/profiles/_template/profile.json +0 -0
  30. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/profiles/batch-job/profile.json +0 -0
  31. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/profiles/dotnet-service/profile.json +0 -0
  32. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/profiles/java-service/profile.json +0 -0
  33. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/profiles/node-service/profile.json +0 -0
  34. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/profiles/php-db/profile.json +0 -0
  35. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/profiles/python-web/profile.json +0 -0
  36. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/profiles/static-site/profile.json +0 -0
  37. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/references/config.example.json +0 -0
  38. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/references/profile-contract.md +0 -0
  39. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/references/standard.md +0 -0
  40. /package/bundles/{antigravity-plugin-extras → antigravity-plugin}/skills/adopt-app/scripts/Resolve-AppProfile.ps1 +0 -0
@@ -0,0 +1,830 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Shared helpers for the adopt-app skill. Dot-source this file.
4
+
5
+ .DESCRIPTION
6
+ Two rules shape everything here.
7
+
8
+ 1. NOTHING IN THIS SKILL EXECUTES THE APP. An app that was never bootstrapped is
9
+ usually the only copy of something that is running in production. We read files
10
+ and file metadata. We never run an entry point, an installer, a migration or a
11
+ build, and we never write inside the source folder.
12
+
13
+ 2. A PARTIAL ANSWER MUST NOT READ AS A CLEAN ANSWER. The scan walks trees that can
14
+ be hundreds of gigabytes over a network share. When it runs out of budget or is
15
+ denied access it says so, reports a MINIMUM size rather than a wrong total, and
16
+ the caller fails unless the operator accepted the partial scan.
17
+
18
+ The scan itself is a single scriptblock so that one implementation serves both a
19
+ local folder and a remote box over WinRM. That block must stay Windows PowerShell
20
+ 5.1 compatible: no ternary, no null-coalescing, no `Join-Path` with three arguments,
21
+ no `-AsHashtable`. It is ASCII only.
22
+ #>
23
+
24
+ Set-StrictMode -Version 2.0
25
+
26
+ # Every key here is READ by something. An earlier version also carried `contentPolicy` and a
27
+ # `blockingItems` list that no script ever consulted, and the list had drifted out of step
28
+ # with the real blocking set - so a project could set it, see no effect, and never find out.
29
+ # Config that does nothing is worse than no config: it is a promise the code does not keep.
30
+ $script:AdoptDefaultConfig = [ordered]@{
31
+ # Read by Resolve-AdoptOutDir.
32
+ outDir = '.caddis/adopt'
33
+ # Read by Get-AppInventory.ps1 when -FileBudget is not passed.
34
+ fileBudget = 20000
35
+ # Read by Get-AppInventory.ps1 when -SkipFolder is not passed.
36
+ skipFolders = @()
37
+ # Default for Invoke-AdoptScan -SkipPattern: folders whose contents are never classified.
38
+ skipPattern = '(?i)[\\/](\.git|node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.tox|dist|build|target|bin|obj|vendor|packages|\.terraform|\.gradle|\.next|\.nuxt|coverage)([\\/]|$)'
39
+ # Read by Test-AppConformance.ps1: the regex that says what "the team's host" means.
40
+ # Empty means unknown, and the remote check then says so instead of guessing.
41
+ expectedRemotePattern = ''
42
+ # Read by Test-AppConformance.ps1: a folder neither committed nor ignored that is at
43
+ # least this big is a finding.
44
+ untrackedThresholdMB = 10
45
+ }
46
+
47
+ # Files whose NAME alone says "this is a credential". Matching the name is enough to flag.
48
+ $script:AdoptSecretNamePattern = '(?i)(^|[\\/])(\.env(\.[a-z0-9_-]+)?|.*\.pem|.*\.key|.*\.pfx|.*\.p12|.*\.jks|.*\.keystore|id_rsa|id_ed25519|.*\.ppk|credentials|\.npmrc|\.pypirc|\.netrc|secrets?\.(json|ya?ml|ini|toml|txt)|.*[._-]secrets?\.(json|ya?ml|ini|toml)|appsettings\.(Production|Prod)\.json)$'
49
+
50
+ # A file whose whole purpose is to show the SHAPE of a secret file. `.env.example` is
51
+ # committed on purpose in most repositories; flagging it teaches the reader to skim.
52
+ $script:AdoptSecretNameExemptPattern = '(?i)[._-](example|sample|template|dist|placeholder|default)$'
53
+
54
+ # A container that may hold a private key or may hold a public certificate. Which one it
55
+ # is decides whether it is a finding, and only the content says.
56
+ $script:AdoptKeyContainerPattern = '(?i)\.(pem|key)$'
57
+
58
+ # Lines whose SHAPE says "this is a credential value". Matched ONE LINE AT A TIME, because
59
+ # a whole-file regex cannot tell code from the prose in a comment about code.
60
+ #
61
+ # Precision is not a nicety here. The first version flagged three lines in a scripts folder
62
+ # and all three were noise: `$Token = Read-Host -AsSecureString ...`, and two sentences in
63
+ # comments that happened to contain "token=True". A check that cries wolf on every script
64
+ # gets switched off, which is the same failure as a check that cannot fire - approached
65
+ # from the other side.
66
+ # The value is captured as ONE token - a quoted string or a run of non-delimiter
67
+ # characters - never as "the rest of the line". A greedy `.*$` swallowed the remainder of
68
+ # every statement it matched, so `Token = '?'; Verdict = 'NO CHECKOUT' }` arrived as a
69
+ # 30-character "credential" and passed every length test.
70
+ $script:AdoptValueToken = '(?<val>"[^"\r\n]*"|''[^''\r\n]*''|[^\s,;)\]}]+)'
71
+
72
+ $script:AdoptSecretContentPatterns = @(
73
+ '(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|connection[_-]?string)\b\s*[:=]{1,2}>?\s*' + $script:AdoptValueToken
74
+ # SimpleSAMLphp and friends write a user list as 'user:password' => array(...). The key
75
+ # must itself carry the `user:pass` shape; without that colon this matched EVERY PHP
76
+ # array literal, and a vendored ITSM tree produced findings like key 'css_classes'.
77
+ '(?i)''[^'':\r\n]{2,40}:(?<val>[^''\r\n]{4,60})''\s*=>\s*(array|\[)'
78
+ '(?<val>-----BEGIN [A-Z ]*PRIVATE KEY-----)'
79
+ '(?i)\b(?<val>gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,})\b'
80
+ '(?i)(server|data source)\s*=\s*[^;]+;[^\r\n]*\b(password|pwd)\s*=\s*' + $script:AdoptValueToken
81
+ )
82
+
83
+ # A line that starts a comment. Prose about a credential is not a credential.
84
+ $script:AdoptCommentLinePattern = '^\s*(#|//|--|;|\*|<#|/\*|"""|'''''')'
85
+
86
+ # A value that is NOT a literal secret: a variable, an environment lookup, a prompt, a
87
+ # boolean, or a placeholder somebody left for a real value.
88
+ $script:AdoptNonLiteralValuePattern = '(?i)^\s*(\$|%|@|\{|<|\(|`|\\|env[:.]|os\.environ|getenv|process\.env|secrets\.|vault|Read-Host|Get-Credential|ConvertTo-SecureString|true|false|null|none|nil|""|''''|\[|-|\.\.\.|xxx+|changeme|your[-_]|replace[-_]?me|example|placeholder|todo|fixme|\*{3,})' +
89
+ # A TYPE, not a value. `password: string` in an interface and `secret: string` in a
90
+ # function signature are declarations; a scan that reads them as credentials reports a
91
+ # finding on every typed codebase.
92
+ '|(?i)^\s*(string|number|boolean|int|integer|bool|str|text|varchar|char|any|unknown|object|array|bytes|float|double|decimal|uuid|date|datetime)\s*[;,)\]}]?\s*$'
93
+
94
+ # A value that REFERS to a secret rather than being one. `${{ secrets.X }}`, `%VAR%`,
95
+ # `{token}` and a regex fragment are all references, wherever they appear in the value.
96
+ #
97
+ # A value containing `(` is a call - `readFileSync(...)`, `this.secrets.resolve(...)` - and
98
+ # a dotted identifier is a lookup - `config.ITOBFF_DB_PASSWORD`. Both READ a credential;
99
+ # neither IS one.
100
+ $script:AdoptReferenceValuePattern = '(\$\{|\{\{|\\[sdwbn]|%[A-Za-z_][A-Za-z0-9_]*%|\{[A-Za-z_][A-Za-z0-9_]*\}|\(|^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_])'
101
+
102
+ # Below this length a value cannot be a real credential, and above the noise floor it is
103
+ # almost always a flag, an index or a single-letter test fixture.
104
+ $script:AdoptMinSecretLength = 6
105
+
106
+ # Content that belongs on a machine, not in a repository. Category drives the advice, so
107
+ # keep them separate rather than folding them into one "junk" bucket.
108
+ $script:AdoptExcludablePatterns = @(
109
+ @{ category = 'dependency'; pattern = '(?i)(^|[\\/])(node_modules|vendor|\.venv|venv|site-packages|packages|bower_components|\.gradle|\.m2)([\\/]|$)' }
110
+ @{ category = 'runtime'; pattern = '(?i)(^|[\\/])(tools|runtimes?|jdk[0-9._-]*|jre[0-9._-]*|php[0-9._-]*|python[0-9._-]*|node-v[0-9]|mariadb[0-9._-]*|mysql[0-9._-]*|apache[0-9._-]*|nginx[0-9._-]*|tomcat[0-9._-]*)([\\/]|$)' }
111
+ @{ category = 'build-output'; pattern = '(?i)(^|[\\/])(dist|build|out|target|bin|obj|\.next|\.nuxt|publish)([\\/]|$)' }
112
+ @{ category = 'cache'; pattern = '(?i)(^|[\\/])(__pycache__|\.mypy_cache|\.ruff_cache|\.pytest_cache|\.tox|\.cache|\.parcel-cache|\.eslintcache|htmlcov|coverage)([\\/]|$)' }
113
+ @{ category = 'db-data'; pattern = '(?i)(\.mdf|\.ldf|\.ibd|\.frm|\.myd|\.myi|\.sqlite3?|\.db3|[\\/]data[\\/].*\.(ib_logfile[0-9]+|ibdata[0-9]+))$' }
114
+ @{ category = 'archive'; pattern = '(?i)\.(zip|7z|rar|tar|tgz|gz|bz2|xz|iso|msi|exe|dll|so|dylib|jar|war|ear|nupkg|whl)$' }
115
+ @{ category = 'data-export'; pattern = '(?i)\.(csv|tsv|parquet|avro|xlsx?|bak|dmp|dump|sql\.gz|pdf|afp|ps)$' }
116
+ @{ category = 'media'; pattern = '(?i)\.(mp4|mov|avi|mkv|wav|mp3|psd|ai|sketch)$' }
117
+ @{ category = 'log'; pattern = '(?i)([\\/]logs?[\\/]|\.log(\.[0-9]+)?$)' }
118
+ )
119
+
120
+ # --------------------------------------------------------------------------------------
121
+ # The scan. Runs locally or inside Invoke-Command. Windows PowerShell 5.1 compatible.
122
+ # --------------------------------------------------------------------------------------
123
+ $script:AdoptScanBlock = {
124
+ param($Path, $FileBudget, $SkipPattern, $SecretNamePattern, $SecretContentPatterns, $ExcludableSpecs, $MaxProbeBytes, $CommentLinePattern, $NonLiteralValuePattern, $ProbeBudget, $SkipFolders, $ReferenceValuePattern, $MinSecretLength, $SecretNameExemptPattern, $KeyContainerPattern, $DetectPathCap)
125
+
126
+ $ErrorActionPreference = 'Continue'
127
+
128
+ $result = @{
129
+ path = $Path
130
+ exists = $false
131
+ partial = $false
132
+ partialReason = @()
133
+ folders = @()
134
+ manifests = @()
135
+ entryPoints = @()
136
+ configFiles = @()
137
+ secretFiles = @()
138
+ certificates = @()
139
+ paths = @()
140
+ excludable = @()
141
+ reparse = @()
142
+ testFiles = 0
143
+ testMarkers = @()
144
+ ciFiles = @()
145
+ docFiles = @()
146
+ healthHits = @()
147
+ totalFiles = 0
148
+ totalMB = 0.0
149
+ trackedKnown = $false
150
+ probed = 0
151
+ git = @{ isRepo = $false }
152
+ }
153
+ $probeCandidates = New-Object System.Collections.ArrayList
154
+ $keyContainers = New-Object System.Collections.ArrayList
155
+ $detectPaths = New-Object System.Collections.ArrayList
156
+
157
+ # Files whose CONTENT is worth reading. A credential hides in application source at
158
+ # least as often as in a config file: the first version of this scan probed only
159
+ # manifests and config, and walked straight past an API key assigned in settings.py.
160
+ $textExtensions = @('.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.php', '.rb', '.go',
161
+ '.java', '.kt', '.scala', '.cs', '.vb', '.ps1', '.psm1', '.psd1', '.sh', '.bash', '.bat',
162
+ '.cmd', '.sql', '.json', '.yml', '.yaml', '.xml', '.ini', '.cfg', '.conf', '.config',
163
+ '.toml', '.env', '.properties', '.txt', '.md', '.tf', '.tfvars', '.gradle', '.pl', '.r',
164
+ '.aspx', '.jsp', '.html', '.htm', '.css', '.scss', '.tpl', '.twig', '.erb', '.j2')
165
+
166
+ if (-not (Test-Path -LiteralPath $Path)) {
167
+ $result.partialReason += "path not found: $Path"
168
+ return $result
169
+ }
170
+ $result.exists = $true
171
+ $root = (Resolve-Path -LiteralPath $Path).ProviderPath.TrimEnd('\')
172
+
173
+ # ---- git state, read only -------------------------------------------------------
174
+ # Is git even here? On a remote box it often is not, and without this test every git
175
+ # call returns nothing, every exit code is non-zero, and the folder classifier reported
176
+ # ALL TWELVE folders of a fully tracked repository as "neither committed nor ignored" -
177
+ # the most alarming verdict it has, produced entirely by a missing executable.
178
+ #
179
+ # `git --version` is not enough. git also refuses a repository whose files belong to
180
+ # another account - "detected dubious ownership" - which is the normal case for a folder
181
+ # on a server reached over WinRM. Every command then exits non-zero, and the scan
182
+ # reported a repository with no remotes, no commits and no tracked folders at all.
183
+ # `-c safe.directory=*` is scoped to these read-only commands and changes nothing on disk.
184
+ $gitAvailable = $false
185
+ $gitError = ''
186
+ & git --version 2>&1 | Out-Null
187
+ if ($LASTEXITCODE -ne 0) { $gitError = 'git is not installed where the scan ran' }
188
+ else {
189
+ $probe = & git -c safe.directory=* -C $root rev-parse --is-inside-work-tree 2>&1
190
+ if ($LASTEXITCODE -eq 0) { $gitAvailable = $true }
191
+ else { $gitError = "git cannot read this repository: $(@($probe) -join ' ')" }
192
+ }
193
+
194
+ if (Test-Path -LiteralPath (Join-Path $root '.git')) {
195
+ # Every field is initialised and the record is attached NOW, so a caller never reads
196
+ # a key that is missing because one git call did not run.
197
+ $g = @{ isRepo = $true; gitAvailable = $gitAvailable; gitError = $gitError; commits = $null; branch = $null
198
+ localBranches = 0; remoteBranches = 0; tags = 0; dirtyFiles = $null
199
+ remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null }
200
+ $result.git = $g
201
+ if (-not $gitAvailable) {
202
+ $result.partial = $true
203
+ $result.partialReason += "$gitError - so branches, remotes and which folders are tracked could not be read"
204
+ }
205
+ }
206
+ if ((Test-Path -LiteralPath (Join-Path $root '.git')) -and $gitAvailable) {
207
+ $runGit = {
208
+ param($argLine)
209
+ $out = & git -c safe.directory=* -C $root -c core.longpaths=true @argLine 2>&1
210
+ if ($LASTEXITCODE -ne 0) { return $null }
211
+ return $out
212
+ }
213
+ $g.remotes = @()
214
+ $r = & $runGit @('remote', '-v')
215
+ if ($r) { foreach ($line in @($r)) { if ("$line" -match '^(\S+)\s+(\S+)\s+\(fetch\)') { $g.remotes += @{ name = $Matches[1]; url = $Matches[2] } } } }
216
+ $b = & $runGit @('rev-parse', '--abbrev-ref', 'HEAD'); if ($b) { $g.branch = "$b" }
217
+ $lb = & $runGit @('for-each-ref', '--format=%(refname:short)', 'refs/heads'); $g.localBranches = @($lb | Where-Object { "$_" -ne '' }).Count
218
+ $rb = & $runGit @('for-each-ref', '--format=%(refname:short)', 'refs/remotes'); $g.remoteBranches = @($rb | Where-Object { "$_" -ne '' }).Count
219
+ $tg = & $runGit @('tag', '--list'); $g.tags = @($tg | Where-Object { "$_" -ne '' }).Count
220
+ $st = & $runGit @('status', '--porcelain'); $g.dirtyFiles = @($st | Where-Object { "$_" -ne '' }).Count
221
+ $lc = & $runGit @('log', '-1', '--format=%H|%an|%aI|%s')
222
+ if ($lc) {
223
+ $parts = "$lc" -split '\|', 4
224
+ $g.lastCommit = @{ sha = $parts[0]; author = $parts[1]; date = $parts[2]; subject = $parts[3] }
225
+ }
226
+ $cc = & $runGit @('rev-list', '--count', 'HEAD'); if ($cc) { $g.commits = [int]"$cc" }
227
+ $up = & $runGit @('rev-parse', '--abbrev-ref', '@{upstream}')
228
+ if ($up) {
229
+ $g.upstream = "$up"
230
+ $ahead = & $runGit @('rev-list', '--count', "$up..HEAD")
231
+ if ($ahead) { $g.unpushed = [int]"$ahead" }
232
+ }
233
+ else { $g.upstream = $null; $g.unpushed = $null }
234
+ }
235
+
236
+ # ---- which files git actually tracks ---------------------------------------------
237
+ # PER FILE, not per folder. A folder is routinely BOTH: `reports/` carried 40 committed
238
+ # markdown documents and a gigabyte of correctly-ignored run output, and attributing the
239
+ # folder's whole size to its folder-level state reported "1018 MB of data exports are
240
+ # committed" when the real figure was 0.15 MB. That is not a rounding error, it is a
241
+ # different finding - and it would have sent someone to rewrite history for nothing.
242
+ $trackedSet = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
243
+ $trackedKnown = $false
244
+ if ($result.git.isRepo -and $gitAvailable) {
245
+ $ls = @(& git -c safe.directory=* -C $root ls-files 2>&1)
246
+ if ($LASTEXITCODE -eq 0) {
247
+ $trackedKnown = $true
248
+ foreach ($f in $ls) { [void]$trackedSet.Add(("$f" -replace '/', '\')) }
249
+ }
250
+ }
251
+
252
+ # ---- one bounded walk per top-level folder --------------------------------------
253
+ $tops = @()
254
+ try { $tops = @(Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop | Where-Object { $_.PSIsContainer }) }
255
+ catch { $result.partial = $true; $result.partialReason += "cannot list $root : $($_.Exception.Message)" }
256
+
257
+ $rootFiles = @()
258
+ try { $rootFiles = @(Get-ChildItem -LiteralPath $root -Force -File -ErrorAction Stop) }
259
+ catch { $result.partial = $true; $result.partialReason += "cannot list files in $root : $($_.Exception.Message)" }
260
+
261
+ # Lock files belong here too. Without them the "dependencies are pinned" check had no
262
+ # evidence it could ever find, so it reported a GAP for every npm project in the world
263
+ # - a check that cannot pass is as useless as one that cannot fail.
264
+ $manifestNames = @('pyproject.toml', 'requirements.txt', 'setup.py', 'setup.cfg', 'Pipfile',
265
+ 'package.json', 'composer.json', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile',
266
+ 'go.mod', 'Cargo.toml', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Makefile',
267
+ 'poetry.lock', 'Pipfile.lock', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
268
+ 'composer.lock', 'Gemfile.lock', 'Cargo.lock', 'go.sum')
269
+ $entryNames = @('main.py', 'app.py', 'wsgi.py', 'asgi.py', 'manage.py', '__main__.py', 'server.js',
270
+ 'index.js', 'server.ts', 'index.php', 'Program.cs', 'Application.java', 'Procfile', 'run.sh', 'start.sh')
271
+ $configGlobs = '(?i)^(\.env.*|.*\.ini|.*\.cfg|.*\.conf|.*\.toml|.*\.ya?ml|appsettings.*\.json|web\.config|settings\.json|config\.json)$'
272
+ $docNames = '(?i)^(readme(\..+)?|agents\.md|claude\.md|contributing\.md|changelog\.md)$'
273
+ $testMarkerNames = '(?i)^(pytest\.ini|tox\.ini|conftest\.py|jest\.config\.[cm]?[jt]s|vitest\.config\.[cm]?[jt]s|phpunit\.xml(\.dist)?|karma\.conf\.js|\.mocharc\..+)$'
274
+
275
+ $classify = {
276
+ param($file, $rel)
277
+
278
+ if ($manifestNames -contains $file.Name) { $result.manifests += @{ path = $rel; name = $file.Name } }
279
+ if ($entryNames -contains $file.Name) { $result.entryPoints += @{ path = $rel; why = "named $($file.Name)" } }
280
+ if ($file.Extension -match '(?i)^\.(ps1|bat|cmd|sh)$' -and $rel -notmatch '[\\/]') {
281
+ $result.entryPoints += @{ path = $rel; why = 'top-level script' }
282
+ }
283
+ if ($file.Name -match $testMarkerNames) { $result.testMarkers += $rel }
284
+ if ($file.Name -match $docNames) { $result.docFiles += $rel }
285
+ if ($rel -match '(?i)^\.(github|gitea|forgejo)[\\/]workflows[\\/]' -or
286
+ $file.Name -match '(?i)^(\.gitlab-ci\.yml|Jenkinsfile|azure-pipelines\.ya?ml|\.travis\.yml|appveyor\.yml|bitbucket-pipelines\.yml)$') {
287
+ $result.ciFiles += $rel
288
+ }
289
+ if ($rel -match '(?i)(^|[\\/])(tests?|spec|__tests__)[\\/]' -or $file.Name -match '(?i)(^test_.*\.py$|_test\.py$|\.test\.[jt]sx?$|\.spec\.[jt]sx?$|Test\.php$|Tests?\.cs$)') {
290
+ $result.testFiles++
291
+ }
292
+ if ($file.Name -match $configGlobs -and $file.Length -lt 512KB) { $result.configFiles += $rel }
293
+ $baseName = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
294
+ if ($rel -match $SecretNamePattern -and $baseName -notmatch $SecretNameExemptPattern -and $file.Name -notmatch $SecretNameExemptPattern) {
295
+ if ($file.Name -match $KeyContainerPattern) {
296
+ # A .pem is a container, not a verdict. A public certificate chain is not a
297
+ # finding; a private key is. Deciding by extension reported four public CA
298
+ # certificates as credentials, which is how a real one gets skimmed past.
299
+ [void]$keyContainers.Add($rel)
300
+ }
301
+ else { $result.secretFiles += @{ path = $rel; why = 'name looks like a credential file' } }
302
+ }
303
+
304
+ # A shallow index of ordinary file paths, for profile detection. Without it a rule
305
+ # can only see manifests and config: a front end whose evidence is `index.html`
306
+ # matched nothing at all, and the component was silently missing from the plan.
307
+ # Shallow paths are ALWAYS kept. A single global cap is consumed by whichever tree
308
+ # the walk happens to reach first: on a real app it filled up before reaching the
309
+ # front end at all, so the front end was missing from the plan and nothing said so.
310
+ $depth = @($rel -split '[\\/]').Count
311
+ if ($depth -le 2) { [void]$detectPaths.Add($rel) }
312
+ elseif ($depth -le 4 -and $detectPaths.Count -lt $DetectPathCap) { [void]$detectPaths.Add($rel) }
313
+
314
+ $ext = "$($file.Extension)".ToLowerInvariant()
315
+ if (($textExtensions -contains $ext -or $file.Name -match '(?i)^(\.env.*|dockerfile|makefile|procfile)$') -and $file.Length -le $MaxProbeBytes) {
316
+ [void]$probeCandidates.Add($rel)
317
+ }
318
+
319
+ foreach ($spec in $ExcludableSpecs) {
320
+ if ($rel -match $spec.pattern) {
321
+ $result.excludable += @{ path = $rel; category = $spec.category; bytes = $file.Length; tracked = $trackedSet.Contains($rel) }
322
+ break
323
+ }
324
+ }
325
+ }
326
+
327
+ $walk = {
328
+ param($dir, $label)
329
+
330
+ $seen = 0
331
+ $bytes = [long]0
332
+ $truncated = $false
333
+ $denied = $false
334
+ $stack = New-Object System.Collections.Stack
335
+ $stack.Push($dir)
336
+
337
+ while ($stack.Count -gt 0) {
338
+ $cur = $stack.Pop()
339
+ $kids = $null
340
+ try { $kids = @(Get-ChildItem -LiteralPath $cur -Force -ErrorAction Stop) }
341
+ catch { $denied = $true; continue }
342
+
343
+ foreach ($k in $kids) {
344
+ # Never follow a reparse point. A junction inside a tree a tool manages is a
345
+ # live data-loss hazard, so record it and do not descend into it.
346
+ if ($k.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
347
+ $rp = $k.FullName
348
+ if ($rp.Length -gt $root.Length) { $rp = $rp.Substring($root.Length + 1) }
349
+ $result.reparse += @{ path = $rp; kind = "$($k.Attributes)" }
350
+ continue
351
+ }
352
+ if ($k.PSIsContainer) {
353
+ $stack.Push($k.FullName)
354
+ continue
355
+ }
356
+
357
+ $seen++
358
+ $bytes += $k.Length
359
+ $rel = $k.FullName
360
+ if ($rel.Length -gt $root.Length) { $rel = $rel.Substring($root.Length + 1) }
361
+
362
+ if ($rel -notmatch $SkipPattern) { & $classify $k $rel }
363
+ else {
364
+ foreach ($spec in $ExcludableSpecs) {
365
+ if ($rel -match $spec.pattern) { $result.excludable += @{ path = $rel; category = $spec.category; bytes = $k.Length; tracked = $trackedSet.Contains($rel) }; break }
366
+ }
367
+ }
368
+
369
+ if ($seen -ge $FileBudget) { $truncated = $true; break }
370
+ }
371
+ if ($truncated) { break }
372
+ }
373
+
374
+ return @{ label = $label; files = $seen; bytes = $bytes; truncated = $truncated; denied = $denied }
375
+ }
376
+
377
+ foreach ($f in $rootFiles) {
378
+ $result.totalFiles++
379
+ $result.totalMB += [math]::Round($f.Length / 1MB, 4)
380
+ & $classify $f $f.Name
381
+ }
382
+
383
+ foreach ($t in $tops) {
384
+ if ($t.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
385
+ $result.reparse += @{ path = $t.Name; kind = "$($t.Attributes)" }
386
+ continue
387
+ }
388
+ if ($SkipFolders -contains $t.Name) {
389
+ $result.folders += @{ path = $t.Name; files = 0; mb = 0; truncated = $false; denied = $false; skipped = $true; git = 'not-scanned' }
390
+ continue
391
+ }
392
+ $w = & $walk $t.FullName $t.Name
393
+
394
+ # Three states, and only git can tell them apart. The dangerous one is UNTRACKED:
395
+ # a folder that is neither committed nor ignored is one `git add -A` away from
396
+ # being in the repository for ever. A real app had 7.6 GB sitting in that state.
397
+ $gitState = 'no-repo'
398
+ if ($result.git.isRepo -and -not $gitAvailable) { $gitState = 'unknown' }
399
+ elseif ($result.git.isRepo) {
400
+ & git -c safe.directory=* -C $root check-ignore -q -- "$($t.Name)/" 2>&1 | Out-Null
401
+ if ($LASTEXITCODE -eq 0) { $gitState = 'ignored' }
402
+ else {
403
+ # Collect into an array, never `| Select-Object -First 1`. Select stops the
404
+ # pipeline early, which leaves $LASTEXITCODE at 1 even though git succeeded,
405
+ # so the exit-code test below failed for EVERY folder and a fully tracked
406
+ # repository was reported as entirely untracked. A wrong verdict that looks
407
+ # like a finding is worse than no verdict.
408
+ $tracked = @(& git -c safe.directory=* -C $root ls-files -- "$($t.Name)/" 2>&1)
409
+ if ($LASTEXITCODE -eq 0 -and $tracked.Count -gt 0 -and "$($tracked[0])" -ne '') { $gitState = 'tracked' }
410
+ else { $gitState = 'untracked' }
411
+ }
412
+ }
413
+
414
+ $entry = @{
415
+ path = $t.Name
416
+ files = $w.files
417
+ mb = [math]::Round($w.bytes / 1MB, 2)
418
+ truncated = $w.truncated
419
+ denied = $w.denied
420
+ skipped = $false
421
+ git = $gitState
422
+ }
423
+ $result.folders += $entry
424
+ $result.totalFiles += $w.files
425
+ $result.totalMB += $entry.mb
426
+ if ($w.truncated) {
427
+ $result.partial = $true
428
+ $result.partialReason += "$($t.Name): stopped after $($w.files) files (budget $FileBudget); its size is AT LEAST $($entry.mb) MB"
429
+ }
430
+ if ($w.denied) {
431
+ $result.partial = $true
432
+ $result.partialReason += "$($t.Name): access denied to at least one subfolder; its size is AT LEAST $($entry.mb) MB"
433
+ }
434
+ }
435
+ $result.totalMB = [math]::Round($result.totalMB, 2)
436
+ $result.trackedKnown = $trackedKnown
437
+ $result.paths = @($detectPaths)
438
+ if ($detectPaths.Count -ge $DetectPathCap) {
439
+ $result.partial = $true
440
+ $result.partialReason += "path index: reached the cap of $DetectPathCap shallow paths, so profile detection sees only part of the tree"
441
+ }
442
+
443
+ # ---- key containers: private key, or public certificate? ------------------------
444
+ foreach ($rel in @($keyContainers)) {
445
+ $full = Join-Path $root $rel
446
+ if (-not (Test-Path -LiteralPath $full)) { continue }
447
+ $fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
448
+ if (-not $fi) { continue }
449
+ # An empty file holds nothing, and saying "unreadable - check it by hand" about a
450
+ # zero-byte placeholder sends someone to look at nothing.
451
+ if ($fi.Length -eq 0) { continue }
452
+ if ($fi.Length -gt $MaxProbeBytes) {
453
+ $result.secretFiles += @{ path = $rel; why = 'key container, too large to read - check it by hand' }
454
+ continue
455
+ }
456
+ $body = $null
457
+ try { $body = Get-Content -LiteralPath $full -Raw -ErrorAction Stop } catch { }
458
+ if ($null -eq $body) {
459
+ $result.secretFiles += @{ path = $rel; why = 'key container, unreadable - check it by hand' }
460
+ }
461
+ elseif ($body -match 'PRIVATE KEY') {
462
+ $result.secretFiles += @{ path = $rel; why = 'holds a PRIVATE KEY' }
463
+ }
464
+ else {
465
+ $result.certificates += $rel
466
+ }
467
+ }
468
+
469
+ # ---- content probes -------------------------------------------------------------
470
+ $probe = @($probeCandidates | Sort-Object -Unique)
471
+ if ($probe.Count -gt $ProbeBudget) {
472
+ $result.partial = $true
473
+ $result.partialReason += "content probe: $($probe.Count) text files found, only the first $ProbeBudget were read for credentials and health signals"
474
+ $probe = @($probe | Select-Object -First $ProbeBudget)
475
+ }
476
+ $result.probed = $probe.Count
477
+
478
+ foreach ($rel in $probe) {
479
+ $full = Join-Path $root $rel
480
+ if (-not (Test-Path -LiteralPath $full)) { continue }
481
+ $fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
482
+ if (-not $fi -or $fi.Length -gt $MaxProbeBytes) { continue }
483
+ $lines = $null
484
+ try { $lines = @(Get-Content -LiteralPath $full -ErrorAction Stop) } catch { continue }
485
+ if ($lines.Count -eq 0) { continue }
486
+
487
+ $found = $false
488
+ for ($i = 0; $i -lt $lines.Count -and -not $found; $i++) {
489
+ $line = "$($lines[$i])"
490
+ if ($line -match $CommentLinePattern) { continue }
491
+ foreach ($p in $SecretContentPatterns) {
492
+ $m = [regex]::Match($line, $p)
493
+ if (-not $m.Success) { continue }
494
+ $val = "$($m.Groups['val'].Value)".Trim().Trim('"', "'")
495
+ if ($val.Length -lt $MinSecretLength) { continue }
496
+ if ($val -match $NonLiteralValuePattern) { continue }
497
+ if ($val -match $ReferenceValuePattern) { continue }
498
+ # The opening of a template reference sits BEFORE the key, not inside the
499
+ # value: `{{SECRET:bff-user}}` yields the value `bff-user`, which passes
500
+ # every test above. Look back at the real text - with backslashes removed,
501
+ # because in a regex literal the same reference is written `\{\{SECRET:...`
502
+ # and a look-back that keeps the escaping sees `{\{` and misses it. That is
503
+ # exactly how 20 files of migration scripts were reported as credentials.
504
+ $lookBack = 0
505
+ if ($m.Index -gt 6) { $lookBack = $m.Index - 6 }
506
+ $before = $line.Substring($lookBack, $m.Index - $lookBack + 1).Replace('\', '')
507
+ if ($before -match '(\{\{|\$\{|%)') { continue }
508
+ # A value that ends in a backslash is part of a regular expression.
509
+ if ($val.EndsWith('\')) { continue }
510
+ # Three or more words is a sentence, not a credential. A quoted string of
511
+ # prose - "LocalMachine\My store, exportable=false policy observed" - is
512
+ # captured whole by the quoted alternative and passes every other test.
513
+ if (@($val -split '\s+' | Where-Object { $_ -ne '' }).Count -ge 3) { continue }
514
+ # The VALUE never leaves the machine in the report: only the file, the line
515
+ # and the key name. A report is a file, and a file gets copied around.
516
+ $keyName = ($m.Value -split '[:=]', 2)[0].Trim()
517
+ if ($keyName.Length -gt 60) { $keyName = $keyName.Substring(0, 60) }
518
+ $result.secretFiles += @{ path = $rel; why = 'value looks like a credential'; line = ($i + 1); key = $keyName }
519
+ $found = $true
520
+ break
521
+ }
522
+ }
523
+
524
+ foreach ($line in $lines) {
525
+ if ("$line" -match '(?i)(/health\b|/healthz\b|/api/health\b|healthcheck)') {
526
+ $result.healthHits += @{ path = $rel; signal = 'health route or check' }
527
+ break
528
+ }
529
+ }
530
+ }
531
+
532
+ return $result
533
+ }
534
+
535
+ # --------------------------------------------------------------------------------------
536
+ function Get-AdoptConfig {
537
+ param([string]$RepoRoot = (Get-Location).Path)
538
+
539
+ $cfg = [ordered]@{}
540
+ foreach ($k in $script:AdoptDefaultConfig.Keys) { $cfg[$k] = $script:AdoptDefaultConfig[$k] }
541
+
542
+ $path = $env:ADOPT_APP_CONFIG
543
+ if (-not $path) { $path = Join-Path $RepoRoot '.caddis/adopt-app.json' }
544
+ if (Test-Path -LiteralPath $path) {
545
+ $json = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
546
+ foreach ($p in $json.PSObject.Properties) { $cfg[$p.Name] = $p.Value }
547
+ $cfg['_configPath'] = (Resolve-Path -LiteralPath $path).ProviderPath
548
+ }
549
+ else { $cfg['_configPath'] = $null }
550
+ return $cfg
551
+ }
552
+
553
+ function Resolve-AdoptOutDir {
554
+ param([Parameter(Mandatory)][string]$Name, [string]$OutDir, [string]$RepoRoot = (Get-Location).Path)
555
+
556
+ if (-not $OutDir) {
557
+ $cfg = Get-AdoptConfig -RepoRoot $RepoRoot
558
+ $OutDir = Join-Path $RepoRoot $cfg.outDir
559
+ }
560
+ $dir = Join-Path $OutDir $Name
561
+ if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
562
+ return (Resolve-Path -LiteralPath $dir).ProviderPath
563
+ }
564
+
565
+ function Write-AdoptArtifact {
566
+ param([Parameter(Mandatory)][string]$Dir, [Parameter(Mandatory)][string]$FileName, [Parameter(Mandatory)]$Object)
567
+ $p = Join-Path $Dir $FileName
568
+ $Object | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $p -Encoding UTF8
569
+ return $p
570
+ }
571
+
572
+ function Read-AdoptArtifact {
573
+ param([Parameter(Mandatory)][string]$Dir, [Parameter(Mandatory)][string]$FileName)
574
+ $p = Join-Path $Dir $FileName
575
+ if (-not (Test-Path -LiteralPath $p)) {
576
+ throw "No $FileName in $Dir. Run the earlier phase first (see the skill's phase table)."
577
+ }
578
+ return Get-Content -LiteralPath $p -Raw | ConvertFrom-Json
579
+ }
580
+
581
+ function Invoke-AdoptScan {
582
+ param(
583
+ [Parameter(Mandatory)][string]$Path,
584
+ [string]$ComputerName,
585
+ [int]$FileBudget = 20000,
586
+ [string]$SkipPattern = $script:AdoptDefaultConfig.skipPattern,
587
+ [int]$MaxProbeBytes = 262144,
588
+ [int]$ProbeBudget = 4000,
589
+ [int]$DetectPathCap = 20000,
590
+ [string[]]$SkipFolders = @()
591
+ )
592
+
593
+ # NOT $args: that is PowerShell's automatic argument array, and writing to it inside a
594
+ # function is how a splat silently becomes the wrong list.
595
+ $scanArgs = @($Path, $FileBudget, $SkipPattern, $script:AdoptSecretNamePattern,
596
+ $script:AdoptSecretContentPatterns, $script:AdoptExcludablePatterns, $MaxProbeBytes,
597
+ $script:AdoptCommentLinePattern, $script:AdoptNonLiteralValuePattern, $ProbeBudget, $SkipFolders, $script:AdoptReferenceValuePattern, $script:AdoptMinSecretLength, $script:AdoptSecretNameExemptPattern, $script:AdoptKeyContainerPattern, $DetectPathCap)
598
+
599
+ if ($ComputerName) {
600
+ $session = $null
601
+ try {
602
+ $session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
603
+ return Invoke-Command -Session $session -ScriptBlock $script:AdoptScanBlock -ArgumentList $scanArgs
604
+ }
605
+ finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
606
+ }
607
+ return & $script:AdoptScanBlock @scanArgs
608
+ }
609
+
610
+ # --------------------------------------------------------------------------------------
611
+ # Profiles. Same contract as the fleet copy this skill was extracted from, so a profile
612
+ # written for one is readable by the other.
613
+ # --------------------------------------------------------------------------------------
614
+ $script:AdoptProfileRequiredKeys = @('name', 'status', 'description', 'detect', 'runtimes', 'build', 'routing', 'config', 'data', 'ci', 'deploy')
615
+
616
+ function Get-AdoptProfileDir {
617
+ param([string]$ProfilesDir)
618
+ $p = $ProfilesDir
619
+ if (-not $p) { $p = Join-Path $PSScriptRoot '..\profiles' }
620
+ if (-not (Test-Path -LiteralPath $p)) {
621
+ throw "No profiles directory at '$p'. A profile describes one KIND of component; without any, nothing can be detected. Pass -ProfilesDir, or copy the skill's profiles/ folder next to scripts/."
622
+ }
623
+ return (Resolve-Path -LiteralPath $p).ProviderPath
624
+ }
625
+
626
+ function Get-AdoptProfiles {
627
+ param([string]$ProfilesDir, [switch]$IncludeTemplate)
628
+ $dir = Get-AdoptProfileDir -ProfilesDir $ProfilesDir
629
+ $out = @()
630
+ foreach ($d in @(Get-ChildItem -LiteralPath $dir -Directory | Sort-Object Name)) {
631
+ if ($d.Name -eq '_template' -and -not $IncludeTemplate) { continue }
632
+ $f = Join-Path $d.FullName 'profile.json'
633
+ if (-not (Test-Path -LiteralPath $f)) { continue }
634
+ $p = Get-Content -LiteralPath $f -Raw | ConvertFrom-Json
635
+ $p | Add-Member -NotePropertyName _dir -NotePropertyValue $d.FullName -Force
636
+ $out += $p
637
+ }
638
+ return $out
639
+ }
640
+
641
+ function Test-AdoptProfileContract {
642
+ param([Parameter(Mandatory)]$Spec)
643
+ $problems = @()
644
+ $names = @($Spec.PSObject.Properties.Name)
645
+ foreach ($k in $script:AdoptProfileRequiredKeys) { if ($names -notcontains $k) { $problems += "missing '$k'" } }
646
+ if ($names -notcontains 'services' -and $names -notcontains 'schedule') { $problems += "needs 'services' or 'schedule'" }
647
+ if ($names -contains 'status' -and $Spec.status -notin @('proven', 'draft', 'placeholder')) {
648
+ $problems += "status '$($Spec.status)' is not proven|draft|placeholder"
649
+ }
650
+ if ($names -contains 'status' -and $Spec.status -ne 'placeholder' -and "$($Spec.name)" -match '[<>]') {
651
+ $problems += "name still holds a placeholder"
652
+ }
653
+ return , $problems
654
+ }
655
+
656
+ # Matches one profile's detect rules against a FILE LIST (so it works for a remote scan
657
+ # without a second round trip). Returns 0 for no match, or a score: more matched rule
658
+ # kinds means a more specific match.
659
+ function Test-AdoptDetect {
660
+ param([Parameter(Mandatory)]$Detect, [Parameter(Mandatory)][string[]]$RelativePaths, [hashtable]$FileText = @{})
661
+
662
+ $has = {
663
+ param($glob)
664
+ $rx = '(?i)^' + [regex]::Escape($glob).Replace('\*\*', '.*').Replace('\*', '[^\\/]*').Replace('/', '[\\/]') + '$'
665
+ foreach ($p in $RelativePaths) { if ($p -match $rx) { return $true } }
666
+ return $false
667
+ }
668
+ $contains = {
669
+ param($spec)
670
+ foreach ($g in @($spec.file)) {
671
+ $rx = '(?i)^' + [regex]::Escape($g).Replace('\*\*', '.*').Replace('\*', '[^\\/]*').Replace('/', '[\\/]') + '$'
672
+ foreach ($k in $FileText.Keys) {
673
+ if ($k -match $rx -and "$($FileText[$k])" -match $spec.pattern) { return $true }
674
+ }
675
+ }
676
+ return $false
677
+ }
678
+
679
+ $names = @($Detect.PSObject.Properties.Name)
680
+ $score = 0
681
+ if ($names -contains 'allFiles') { foreach ($f in @($Detect.allFiles)) { if (-not (& $has $f)) { return 0 } }; $score++ }
682
+ if ($names -contains 'anyFiles') { if (-not (@($Detect.anyFiles) | Where-Object { & $has $_ })) { return 0 }; $score++ }
683
+ if ($names -contains 'noneFiles') { if (@($Detect.noneFiles) | Where-Object { & $has $_ }) { return 0 } }
684
+ if ($names -contains 'fileContains') { foreach ($c in @($Detect.fileContains)) { if (-not (& $contains $c)) { return 0 } }; $score++ }
685
+ if ($names -contains 'noneContain') { foreach ($c in @($Detect.noneContain)) { if (& $contains $c) { return 0 } } }
686
+ return $score
687
+ }
688
+
689
+ # Fetches the text of a few small files, local or remote, for profile detection.
690
+ #
691
+ # File bodies are NEVER written into an artefact by this skill. A config file holds
692
+ # credentials, and an artefact is a file that gets copied into a ticket, a chat and a
693
+ # report. Detection reads the text in memory and keeps only the verdict.
694
+ function Get-AdoptFileText {
695
+ param(
696
+ [Parameter(Mandatory)][string]$Path,
697
+ [string]$ComputerName,
698
+ [Parameter(Mandatory)][string[]]$RelativePaths,
699
+ [int]$MaxBytes = 262144
700
+ )
701
+
702
+ $block = {
703
+ param($root, $rels, $maxBytes)
704
+ $out = @{}
705
+ foreach ($rel in $rels) {
706
+ $full = Join-Path $root $rel
707
+ if (-not (Test-Path -LiteralPath $full)) { continue }
708
+ $fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
709
+ if (-not $fi -or $fi.Length -gt $maxBytes) { continue }
710
+ try { $out[$rel] = Get-Content -LiteralPath $full -Raw -ErrorAction Stop } catch { }
711
+ }
712
+ return $out
713
+ }
714
+
715
+ if ($ComputerName) {
716
+ $session = $null
717
+ try {
718
+ $session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
719
+ return Invoke-Command -Session $session -ScriptBlock $block -ArgumentList @($Path, $RelativePaths, $MaxBytes)
720
+ }
721
+ finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
722
+ }
723
+ return & $block $Path $RelativePaths $MaxBytes
724
+ }
725
+
726
+ # What is registered on a host to RUN this folder. An app nobody bootstrapped is usually
727
+ # started by something nobody wrote down: a service, a scheduled task, a web site.
728
+ function Get-AdoptRunRegistrations {
729
+ param([Parameter(Mandatory)][string]$Path, [string]$ComputerName)
730
+
731
+ $block = {
732
+ param($needle)
733
+ $out = @{ services = @(); scheduledTasks = @(); webSites = @(); checked = @(); errors = @() }
734
+
735
+ try {
736
+ $svcs = Get-CimInstance Win32_Service -ErrorAction Stop |
737
+ Where-Object { "$($_.PathName)" -like "*$needle*" }
738
+ foreach ($s in $svcs) {
739
+ $out.services += @{ name = $s.Name; state = $s.State; startMode = $s.StartMode; account = $s.StartName; path = $s.PathName }
740
+ }
741
+ $out.checked += 'services'
742
+ }
743
+ catch { $out.errors += "services: $($_.Exception.Message)" }
744
+
745
+ try {
746
+ $tasks = Get-ScheduledTask -ErrorAction Stop
747
+ foreach ($t in $tasks) {
748
+ # Not every action is an Exec action. A ComHandler or SendEmail action has
749
+ # no Execute property at all, and reading it under StrictMode throws - which
750
+ # killed the whole scheduled-task check on a box that had one.
751
+ $hits = @($t.Actions | Where-Object {
752
+ $props = @($_.PSObject.Properties.Name)
753
+ $exec = ''; $argus = ''; $wd = ''
754
+ if ($props -contains 'Execute') { $exec = "$($_.Execute)" }
755
+ if ($props -contains 'Arguments') { $argus = "$($_.Arguments)" }
756
+ if ($props -contains 'WorkingDirectory') { $wd = "$($_.WorkingDirectory)" }
757
+ $exec -like "*$needle*" -or $argus -like "*$needle*" -or $wd -like "*$needle*"
758
+ })
759
+ if ($hits.Count -eq 0) { continue }
760
+ $info = $null
761
+ try { $info = $t | Get-ScheduledTaskInfo -ErrorAction Stop } catch { }
762
+ $lastRun = $null
763
+ if ($info) { $lastRun = "$($info.LastRunTime)" }
764
+ $out.scheduledTasks += @{
765
+ name = $t.TaskName
766
+ path = $t.TaskPath
767
+ state = "$($t.State)"
768
+ account = "$($t.Principal.UserId)"
769
+ action = ("$($hits[0].Execute) $($hits[0].Arguments)").Trim()
770
+ lastRun = $lastRun
771
+ }
772
+ }
773
+ $out.checked += 'scheduledTasks'
774
+ }
775
+ catch { $out.errors += "scheduledTasks: $($_.Exception.Message)" }
776
+
777
+ try {
778
+ Import-Module WebAdministration -ErrorAction Stop
779
+ foreach ($s in @(Get-Website)) {
780
+ if ("$($s.physicalPath)" -like "*$needle*") {
781
+ $out.webSites += @{ name = $s.Name; state = "$($s.State)"; physicalPath = "$($s.physicalPath)" }
782
+ }
783
+ }
784
+ foreach ($a in @(Get-WebApplication)) {
785
+ if ("$($a.PhysicalPath)" -like "*$needle*") {
786
+ $out.webSites += @{ name = "$($a.Path)"; state = 'application'; physicalPath = "$($a.PhysicalPath)" }
787
+ }
788
+ }
789
+ $out.checked += 'webSites'
790
+ }
791
+ catch { $out.errors += "webSites: $($_.Exception.Message)" }
792
+
793
+ return $out
794
+ }
795
+
796
+ # Match on the last path segment: a service command line names a local drive path, and
797
+ # the caller may have handed us a UNC path to the same folder.
798
+ $needle = Split-Path $Path -Leaf
799
+
800
+ if ($ComputerName) {
801
+ $session = $null
802
+ try {
803
+ $session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
804
+ return Invoke-Command -Session $session -ScriptBlock $block -ArgumentList @($needle)
805
+ }
806
+ finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
807
+ }
808
+ return & $block $needle
809
+ }
810
+
811
+ # Reads a key that may not be there. Under StrictMode a missing property throws, and these
812
+ # records are deliberately heterogeneous: a credential found by NAME has no line number, a
813
+ # credential found by VALUE does. Reading it directly killed two scripts mid-report.
814
+ function Get-AdoptProp {
815
+ param($Object, [Parameter(Mandatory)][string]$Name, $Default = $null)
816
+ if ($null -eq $Object) { return $Default }
817
+ if ($Object -is [hashtable]) {
818
+ if ($Object.ContainsKey($Name)) { return $Object[$Name] }
819
+ return $Default
820
+ }
821
+ if (@($Object.PSObject.Properties.Name) -contains $Name) { return $Object.$Name }
822
+ return $Default
823
+ }
824
+
825
+ function Write-AdoptHeading {
826
+ param([Parameter(Mandatory)][string]$Text)
827
+ Write-Host ""
828
+ Write-Host $Text -ForegroundColor Cyan
829
+ Write-Host ('-' * $Text.Length) -ForegroundColor DarkCyan
830
+ }