@caddis/cli 0.4.23 → 0.4.25

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 (39) 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/skills/adopt-app/SKILL.md +174 -0
  4. package/bundles/antigravity-plugin/skills/adopt-app/profiles/_template/profile.json +55 -0
  5. package/bundles/antigravity-plugin/skills/adopt-app/profiles/batch-job/profile.json +68 -0
  6. package/bundles/antigravity-plugin/skills/adopt-app/profiles/dotnet-service/profile.json +67 -0
  7. package/bundles/antigravity-plugin/skills/adopt-app/profiles/java-service/profile.json +62 -0
  8. package/bundles/antigravity-plugin/skills/adopt-app/profiles/node-service/profile.json +66 -0
  9. package/bundles/antigravity-plugin/skills/adopt-app/profiles/php-db/profile.json +75 -0
  10. package/bundles/antigravity-plugin/skills/adopt-app/profiles/python-web/profile.json +73 -0
  11. package/bundles/antigravity-plugin/skills/adopt-app/profiles/static-site/profile.json +87 -0
  12. package/bundles/antigravity-plugin/skills/adopt-app/references/config.example.json +18 -0
  13. package/bundles/antigravity-plugin/skills/adopt-app/references/profile-contract.md +207 -0
  14. package/bundles/antigravity-plugin/skills/adopt-app/references/standard.md +304 -0
  15. package/bundles/antigravity-plugin/skills/adopt-app/scripts/AdoptCommon.ps1 +812 -0
  16. package/bundles/antigravity-plugin/skills/adopt-app/scripts/Get-AppInventory.ps1 +236 -0
  17. package/bundles/antigravity-plugin/skills/adopt-app/scripts/New-AdoptionPlan.ps1 +311 -0
  18. package/bundles/antigravity-plugin/skills/adopt-app/scripts/Resolve-AppProfile.ps1 +246 -0
  19. package/bundles/antigravity-plugin/skills/adopt-app/scripts/Test-AppConformance.ps1 +343 -0
  20. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  21. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +174 -0
  22. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/_template/profile.json +55 -0
  23. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/batch-job/profile.json +68 -0
  24. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/dotnet-service/profile.json +67 -0
  25. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/java-service/profile.json +62 -0
  26. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/node-service/profile.json +66 -0
  27. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/php-db/profile.json +75 -0
  28. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/python-web/profile.json +73 -0
  29. package/bundles/codex/.codex/skills/devops/adopt-app/profiles/static-site/profile.json +87 -0
  30. package/bundles/codex/.codex/skills/devops/adopt-app/references/config.example.json +18 -0
  31. package/bundles/codex/.codex/skills/devops/adopt-app/references/profile-contract.md +207 -0
  32. package/bundles/codex/.codex/skills/devops/adopt-app/references/standard.md +304 -0
  33. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/AdoptCommon.ps1 +812 -0
  34. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Get-AppInventory.ps1 +236 -0
  35. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/New-AdoptionPlan.ps1 +311 -0
  36. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Resolve-AppProfile.ps1 +246 -0
  37. package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Test-AppConformance.ps1 +343 -0
  38. package/bundles/manifest.json +3 -3
  39. package/package.json +1 -1
@@ -0,0 +1,812 @@
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
+ probed = 0
150
+ git = @{ isRepo = $false }
151
+ }
152
+ $probeCandidates = New-Object System.Collections.ArrayList
153
+ $keyContainers = New-Object System.Collections.ArrayList
154
+ $detectPaths = New-Object System.Collections.ArrayList
155
+
156
+ # Files whose CONTENT is worth reading. A credential hides in application source at
157
+ # least as often as in a config file: the first version of this scan probed only
158
+ # manifests and config, and walked straight past an API key assigned in settings.py.
159
+ $textExtensions = @('.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.php', '.rb', '.go',
160
+ '.java', '.kt', '.scala', '.cs', '.vb', '.ps1', '.psm1', '.psd1', '.sh', '.bash', '.bat',
161
+ '.cmd', '.sql', '.json', '.yml', '.yaml', '.xml', '.ini', '.cfg', '.conf', '.config',
162
+ '.toml', '.env', '.properties', '.txt', '.md', '.tf', '.tfvars', '.gradle', '.pl', '.r',
163
+ '.aspx', '.jsp', '.html', '.htm', '.css', '.scss', '.tpl', '.twig', '.erb', '.j2')
164
+
165
+ if (-not (Test-Path -LiteralPath $Path)) {
166
+ $result.partialReason += "path not found: $Path"
167
+ return $result
168
+ }
169
+ $result.exists = $true
170
+ $root = (Resolve-Path -LiteralPath $Path).ProviderPath.TrimEnd('\')
171
+
172
+ # ---- git state, read only -------------------------------------------------------
173
+ # Is git even here? On a remote box it often is not, and without this test every git
174
+ # call returns nothing, every exit code is non-zero, and the folder classifier reported
175
+ # ALL TWELVE folders of a fully tracked repository as "neither committed nor ignored" -
176
+ # the most alarming verdict it has, produced entirely by a missing executable.
177
+ #
178
+ # `git --version` is not enough. git also refuses a repository whose files belong to
179
+ # another account - "detected dubious ownership" - which is the normal case for a folder
180
+ # on a server reached over WinRM. Every command then exits non-zero, and the scan
181
+ # reported a repository with no remotes, no commits and no tracked folders at all.
182
+ # `-c safe.directory=*` is scoped to these read-only commands and changes nothing on disk.
183
+ $gitAvailable = $false
184
+ $gitError = ''
185
+ & git --version 2>&1 | Out-Null
186
+ if ($LASTEXITCODE -ne 0) { $gitError = 'git is not installed where the scan ran' }
187
+ else {
188
+ $probe = & git -c safe.directory=* -C $root rev-parse --is-inside-work-tree 2>&1
189
+ if ($LASTEXITCODE -eq 0) { $gitAvailable = $true }
190
+ else { $gitError = "git cannot read this repository: $(@($probe) -join ' ')" }
191
+ }
192
+
193
+ if (Test-Path -LiteralPath (Join-Path $root '.git')) {
194
+ # Every field is initialised and the record is attached NOW, so a caller never reads
195
+ # a key that is missing because one git call did not run.
196
+ $g = @{ isRepo = $true; gitAvailable = $gitAvailable; gitError = $gitError; commits = $null; branch = $null
197
+ localBranches = 0; remoteBranches = 0; tags = 0; dirtyFiles = $null
198
+ remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null }
199
+ $result.git = $g
200
+ if (-not $gitAvailable) {
201
+ $result.partial = $true
202
+ $result.partialReason += "$gitError - so branches, remotes and which folders are tracked could not be read"
203
+ }
204
+ }
205
+ if ((Test-Path -LiteralPath (Join-Path $root '.git')) -and $gitAvailable) {
206
+ $runGit = {
207
+ param($argLine)
208
+ $out = & git -c safe.directory=* -C $root -c core.longpaths=true @argLine 2>&1
209
+ if ($LASTEXITCODE -ne 0) { return $null }
210
+ return $out
211
+ }
212
+ $g.remotes = @()
213
+ $r = & $runGit @('remote', '-v')
214
+ if ($r) { foreach ($line in @($r)) { if ("$line" -match '^(\S+)\s+(\S+)\s+\(fetch\)') { $g.remotes += @{ name = $Matches[1]; url = $Matches[2] } } } }
215
+ $b = & $runGit @('rev-parse', '--abbrev-ref', 'HEAD'); if ($b) { $g.branch = "$b" }
216
+ $lb = & $runGit @('for-each-ref', '--format=%(refname:short)', 'refs/heads'); $g.localBranches = @($lb | Where-Object { "$_" -ne '' }).Count
217
+ $rb = & $runGit @('for-each-ref', '--format=%(refname:short)', 'refs/remotes'); $g.remoteBranches = @($rb | Where-Object { "$_" -ne '' }).Count
218
+ $tg = & $runGit @('tag', '--list'); $g.tags = @($tg | Where-Object { "$_" -ne '' }).Count
219
+ $st = & $runGit @('status', '--porcelain'); $g.dirtyFiles = @($st | Where-Object { "$_" -ne '' }).Count
220
+ $lc = & $runGit @('log', '-1', '--format=%H|%an|%aI|%s')
221
+ if ($lc) {
222
+ $parts = "$lc" -split '\|', 4
223
+ $g.lastCommit = @{ sha = $parts[0]; author = $parts[1]; date = $parts[2]; subject = $parts[3] }
224
+ }
225
+ $cc = & $runGit @('rev-list', '--count', 'HEAD'); if ($cc) { $g.commits = [int]"$cc" }
226
+ $up = & $runGit @('rev-parse', '--abbrev-ref', '@{upstream}')
227
+ if ($up) {
228
+ $g.upstream = "$up"
229
+ $ahead = & $runGit @('rev-list', '--count', "$up..HEAD")
230
+ if ($ahead) { $g.unpushed = [int]"$ahead" }
231
+ }
232
+ else { $g.upstream = $null; $g.unpushed = $null }
233
+ }
234
+
235
+ # ---- one bounded walk per top-level folder --------------------------------------
236
+ $tops = @()
237
+ try { $tops = @(Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop | Where-Object { $_.PSIsContainer }) }
238
+ catch { $result.partial = $true; $result.partialReason += "cannot list $root : $($_.Exception.Message)" }
239
+
240
+ $rootFiles = @()
241
+ try { $rootFiles = @(Get-ChildItem -LiteralPath $root -Force -File -ErrorAction Stop) }
242
+ catch { $result.partial = $true; $result.partialReason += "cannot list files in $root : $($_.Exception.Message)" }
243
+
244
+ # Lock files belong here too. Without them the "dependencies are pinned" check had no
245
+ # evidence it could ever find, so it reported a GAP for every npm project in the world
246
+ # - a check that cannot pass is as useless as one that cannot fail.
247
+ $manifestNames = @('pyproject.toml', 'requirements.txt', 'setup.py', 'setup.cfg', 'Pipfile',
248
+ 'package.json', 'composer.json', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile',
249
+ 'go.mod', 'Cargo.toml', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Makefile',
250
+ 'poetry.lock', 'Pipfile.lock', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
251
+ 'composer.lock', 'Gemfile.lock', 'Cargo.lock', 'go.sum')
252
+ $entryNames = @('main.py', 'app.py', 'wsgi.py', 'asgi.py', 'manage.py', '__main__.py', 'server.js',
253
+ 'index.js', 'server.ts', 'index.php', 'Program.cs', 'Application.java', 'Procfile', 'run.sh', 'start.sh')
254
+ $configGlobs = '(?i)^(\.env.*|.*\.ini|.*\.cfg|.*\.conf|.*\.toml|.*\.ya?ml|appsettings.*\.json|web\.config|settings\.json|config\.json)$'
255
+ $docNames = '(?i)^(readme(\..+)?|agents\.md|claude\.md|contributing\.md|changelog\.md)$'
256
+ $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\..+)$'
257
+
258
+ $classify = {
259
+ param($file, $rel)
260
+
261
+ if ($manifestNames -contains $file.Name) { $result.manifests += @{ path = $rel; name = $file.Name } }
262
+ if ($entryNames -contains $file.Name) { $result.entryPoints += @{ path = $rel; why = "named $($file.Name)" } }
263
+ if ($file.Extension -match '(?i)^\.(ps1|bat|cmd|sh)$' -and $rel -notmatch '[\\/]') {
264
+ $result.entryPoints += @{ path = $rel; why = 'top-level script' }
265
+ }
266
+ if ($file.Name -match $testMarkerNames) { $result.testMarkers += $rel }
267
+ if ($file.Name -match $docNames) { $result.docFiles += $rel }
268
+ if ($rel -match '(?i)^\.(github|gitea|forgejo)[\\/]workflows[\\/]' -or
269
+ $file.Name -match '(?i)^(\.gitlab-ci\.yml|Jenkinsfile|azure-pipelines\.ya?ml|\.travis\.yml|appveyor\.yml|bitbucket-pipelines\.yml)$') {
270
+ $result.ciFiles += $rel
271
+ }
272
+ 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$)') {
273
+ $result.testFiles++
274
+ }
275
+ if ($file.Name -match $configGlobs -and $file.Length -lt 512KB) { $result.configFiles += $rel }
276
+ $baseName = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
277
+ if ($rel -match $SecretNamePattern -and $baseName -notmatch $SecretNameExemptPattern -and $file.Name -notmatch $SecretNameExemptPattern) {
278
+ if ($file.Name -match $KeyContainerPattern) {
279
+ # A .pem is a container, not a verdict. A public certificate chain is not a
280
+ # finding; a private key is. Deciding by extension reported four public CA
281
+ # certificates as credentials, which is how a real one gets skimmed past.
282
+ [void]$keyContainers.Add($rel)
283
+ }
284
+ else { $result.secretFiles += @{ path = $rel; why = 'name looks like a credential file' } }
285
+ }
286
+
287
+ # A shallow index of ordinary file paths, for profile detection. Without it a rule
288
+ # can only see manifests and config: a front end whose evidence is `index.html`
289
+ # matched nothing at all, and the component was silently missing from the plan.
290
+ # Shallow paths are ALWAYS kept. A single global cap is consumed by whichever tree
291
+ # the walk happens to reach first: on a real app it filled up before reaching the
292
+ # front end at all, so the front end was missing from the plan and nothing said so.
293
+ $depth = @($rel -split '[\\/]').Count
294
+ if ($depth -le 2) { [void]$detectPaths.Add($rel) }
295
+ elseif ($depth -le 4 -and $detectPaths.Count -lt $DetectPathCap) { [void]$detectPaths.Add($rel) }
296
+
297
+ $ext = "$($file.Extension)".ToLowerInvariant()
298
+ if (($textExtensions -contains $ext -or $file.Name -match '(?i)^(\.env.*|dockerfile|makefile|procfile)$') -and $file.Length -le $MaxProbeBytes) {
299
+ [void]$probeCandidates.Add($rel)
300
+ }
301
+
302
+ foreach ($spec in $ExcludableSpecs) {
303
+ if ($rel -match $spec.pattern) {
304
+ $result.excludable += @{ path = $rel; category = $spec.category; bytes = $file.Length }
305
+ break
306
+ }
307
+ }
308
+ }
309
+
310
+ $walk = {
311
+ param($dir, $label)
312
+
313
+ $seen = 0
314
+ $bytes = [long]0
315
+ $truncated = $false
316
+ $denied = $false
317
+ $stack = New-Object System.Collections.Stack
318
+ $stack.Push($dir)
319
+
320
+ while ($stack.Count -gt 0) {
321
+ $cur = $stack.Pop()
322
+ $kids = $null
323
+ try { $kids = @(Get-ChildItem -LiteralPath $cur -Force -ErrorAction Stop) }
324
+ catch { $denied = $true; continue }
325
+
326
+ foreach ($k in $kids) {
327
+ # Never follow a reparse point. A junction inside a tree a tool manages is a
328
+ # live data-loss hazard, so record it and do not descend into it.
329
+ if ($k.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
330
+ $rp = $k.FullName
331
+ if ($rp.Length -gt $root.Length) { $rp = $rp.Substring($root.Length + 1) }
332
+ $result.reparse += @{ path = $rp; kind = "$($k.Attributes)" }
333
+ continue
334
+ }
335
+ if ($k.PSIsContainer) {
336
+ $stack.Push($k.FullName)
337
+ continue
338
+ }
339
+
340
+ $seen++
341
+ $bytes += $k.Length
342
+ $rel = $k.FullName
343
+ if ($rel.Length -gt $root.Length) { $rel = $rel.Substring($root.Length + 1) }
344
+
345
+ if ($rel -notmatch $SkipPattern) { & $classify $k $rel }
346
+ else {
347
+ foreach ($spec in $ExcludableSpecs) {
348
+ if ($rel -match $spec.pattern) { $result.excludable += @{ path = $rel; category = $spec.category; bytes = $k.Length }; break }
349
+ }
350
+ }
351
+
352
+ if ($seen -ge $FileBudget) { $truncated = $true; break }
353
+ }
354
+ if ($truncated) { break }
355
+ }
356
+
357
+ return @{ label = $label; files = $seen; bytes = $bytes; truncated = $truncated; denied = $denied }
358
+ }
359
+
360
+ foreach ($f in $rootFiles) {
361
+ $result.totalFiles++
362
+ $result.totalMB += [math]::Round($f.Length / 1MB, 4)
363
+ & $classify $f $f.Name
364
+ }
365
+
366
+ foreach ($t in $tops) {
367
+ if ($t.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
368
+ $result.reparse += @{ path = $t.Name; kind = "$($t.Attributes)" }
369
+ continue
370
+ }
371
+ if ($SkipFolders -contains $t.Name) {
372
+ $result.folders += @{ path = $t.Name; files = 0; mb = 0; truncated = $false; denied = $false; skipped = $true; git = 'not-scanned' }
373
+ continue
374
+ }
375
+ $w = & $walk $t.FullName $t.Name
376
+
377
+ # Three states, and only git can tell them apart. The dangerous one is UNTRACKED:
378
+ # a folder that is neither committed nor ignored is one `git add -A` away from
379
+ # being in the repository for ever. A real app had 7.6 GB sitting in that state.
380
+ $gitState = 'no-repo'
381
+ if ($result.git.isRepo -and -not $gitAvailable) { $gitState = 'unknown' }
382
+ elseif ($result.git.isRepo) {
383
+ & git -c safe.directory=* -C $root check-ignore -q -- "$($t.Name)/" 2>&1 | Out-Null
384
+ if ($LASTEXITCODE -eq 0) { $gitState = 'ignored' }
385
+ else {
386
+ # Collect into an array, never `| Select-Object -First 1`. Select stops the
387
+ # pipeline early, which leaves $LASTEXITCODE at 1 even though git succeeded,
388
+ # so the exit-code test below failed for EVERY folder and a fully tracked
389
+ # repository was reported as entirely untracked. A wrong verdict that looks
390
+ # like a finding is worse than no verdict.
391
+ $tracked = @(& git -c safe.directory=* -C $root ls-files -- "$($t.Name)/" 2>&1)
392
+ if ($LASTEXITCODE -eq 0 -and $tracked.Count -gt 0 -and "$($tracked[0])" -ne '') { $gitState = 'tracked' }
393
+ else { $gitState = 'untracked' }
394
+ }
395
+ }
396
+
397
+ $entry = @{
398
+ path = $t.Name
399
+ files = $w.files
400
+ mb = [math]::Round($w.bytes / 1MB, 2)
401
+ truncated = $w.truncated
402
+ denied = $w.denied
403
+ skipped = $false
404
+ git = $gitState
405
+ }
406
+ $result.folders += $entry
407
+ $result.totalFiles += $w.files
408
+ $result.totalMB += $entry.mb
409
+ if ($w.truncated) {
410
+ $result.partial = $true
411
+ $result.partialReason += "$($t.Name): stopped after $($w.files) files (budget $FileBudget); its size is AT LEAST $($entry.mb) MB"
412
+ }
413
+ if ($w.denied) {
414
+ $result.partial = $true
415
+ $result.partialReason += "$($t.Name): access denied to at least one subfolder; its size is AT LEAST $($entry.mb) MB"
416
+ }
417
+ }
418
+ $result.totalMB = [math]::Round($result.totalMB, 2)
419
+ $result.paths = @($detectPaths)
420
+ if ($detectPaths.Count -ge $DetectPathCap) {
421
+ $result.partial = $true
422
+ $result.partialReason += "path index: reached the cap of $DetectPathCap shallow paths, so profile detection sees only part of the tree"
423
+ }
424
+
425
+ # ---- key containers: private key, or public certificate? ------------------------
426
+ foreach ($rel in @($keyContainers)) {
427
+ $full = Join-Path $root $rel
428
+ if (-not (Test-Path -LiteralPath $full)) { continue }
429
+ $fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
430
+ if (-not $fi) { continue }
431
+ # An empty file holds nothing, and saying "unreadable - check it by hand" about a
432
+ # zero-byte placeholder sends someone to look at nothing.
433
+ if ($fi.Length -eq 0) { continue }
434
+ if ($fi.Length -gt $MaxProbeBytes) {
435
+ $result.secretFiles += @{ path = $rel; why = 'key container, too large to read - check it by hand' }
436
+ continue
437
+ }
438
+ $body = $null
439
+ try { $body = Get-Content -LiteralPath $full -Raw -ErrorAction Stop } catch { }
440
+ if ($null -eq $body) {
441
+ $result.secretFiles += @{ path = $rel; why = 'key container, unreadable - check it by hand' }
442
+ }
443
+ elseif ($body -match 'PRIVATE KEY') {
444
+ $result.secretFiles += @{ path = $rel; why = 'holds a PRIVATE KEY' }
445
+ }
446
+ else {
447
+ $result.certificates += $rel
448
+ }
449
+ }
450
+
451
+ # ---- content probes -------------------------------------------------------------
452
+ $probe = @($probeCandidates | Sort-Object -Unique)
453
+ if ($probe.Count -gt $ProbeBudget) {
454
+ $result.partial = $true
455
+ $result.partialReason += "content probe: $($probe.Count) text files found, only the first $ProbeBudget were read for credentials and health signals"
456
+ $probe = @($probe | Select-Object -First $ProbeBudget)
457
+ }
458
+ $result.probed = $probe.Count
459
+
460
+ foreach ($rel in $probe) {
461
+ $full = Join-Path $root $rel
462
+ if (-not (Test-Path -LiteralPath $full)) { continue }
463
+ $fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
464
+ if (-not $fi -or $fi.Length -gt $MaxProbeBytes) { continue }
465
+ $lines = $null
466
+ try { $lines = @(Get-Content -LiteralPath $full -ErrorAction Stop) } catch { continue }
467
+ if ($lines.Count -eq 0) { continue }
468
+
469
+ $found = $false
470
+ for ($i = 0; $i -lt $lines.Count -and -not $found; $i++) {
471
+ $line = "$($lines[$i])"
472
+ if ($line -match $CommentLinePattern) { continue }
473
+ foreach ($p in $SecretContentPatterns) {
474
+ $m = [regex]::Match($line, $p)
475
+ if (-not $m.Success) { continue }
476
+ $val = "$($m.Groups['val'].Value)".Trim().Trim('"', "'")
477
+ if ($val.Length -lt $MinSecretLength) { continue }
478
+ if ($val -match $NonLiteralValuePattern) { continue }
479
+ if ($val -match $ReferenceValuePattern) { continue }
480
+ # The opening of a template reference sits BEFORE the key, not inside the
481
+ # value: `{{SECRET:bff-user}}` yields the value `bff-user`, which passes
482
+ # every test above. Look back at the real text - with backslashes removed,
483
+ # because in a regex literal the same reference is written `\{\{SECRET:...`
484
+ # and a look-back that keeps the escaping sees `{\{` and misses it. That is
485
+ # exactly how 20 files of migration scripts were reported as credentials.
486
+ $lookBack = 0
487
+ if ($m.Index -gt 6) { $lookBack = $m.Index - 6 }
488
+ $before = $line.Substring($lookBack, $m.Index - $lookBack + 1).Replace('\', '')
489
+ if ($before -match '(\{\{|\$\{|%)') { continue }
490
+ # A value that ends in a backslash is part of a regular expression.
491
+ if ($val.EndsWith('\')) { continue }
492
+ # Three or more words is a sentence, not a credential. A quoted string of
493
+ # prose - "LocalMachine\My store, exportable=false policy observed" - is
494
+ # captured whole by the quoted alternative and passes every other test.
495
+ if (@($val -split '\s+' | Where-Object { $_ -ne '' }).Count -ge 3) { continue }
496
+ # The VALUE never leaves the machine in the report: only the file, the line
497
+ # and the key name. A report is a file, and a file gets copied around.
498
+ $keyName = ($m.Value -split '[:=]', 2)[0].Trim()
499
+ if ($keyName.Length -gt 60) { $keyName = $keyName.Substring(0, 60) }
500
+ $result.secretFiles += @{ path = $rel; why = 'value looks like a credential'; line = ($i + 1); key = $keyName }
501
+ $found = $true
502
+ break
503
+ }
504
+ }
505
+
506
+ foreach ($line in $lines) {
507
+ if ("$line" -match '(?i)(/health\b|/healthz\b|/api/health\b|healthcheck)') {
508
+ $result.healthHits += @{ path = $rel; signal = 'health route or check' }
509
+ break
510
+ }
511
+ }
512
+ }
513
+
514
+ return $result
515
+ }
516
+
517
+ # --------------------------------------------------------------------------------------
518
+ function Get-AdoptConfig {
519
+ param([string]$RepoRoot = (Get-Location).Path)
520
+
521
+ $cfg = [ordered]@{}
522
+ foreach ($k in $script:AdoptDefaultConfig.Keys) { $cfg[$k] = $script:AdoptDefaultConfig[$k] }
523
+
524
+ $path = $env:ADOPT_APP_CONFIG
525
+ if (-not $path) { $path = Join-Path $RepoRoot '.caddis/adopt-app.json' }
526
+ if (Test-Path -LiteralPath $path) {
527
+ $json = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
528
+ foreach ($p in $json.PSObject.Properties) { $cfg[$p.Name] = $p.Value }
529
+ $cfg['_configPath'] = (Resolve-Path -LiteralPath $path).ProviderPath
530
+ }
531
+ else { $cfg['_configPath'] = $null }
532
+ return $cfg
533
+ }
534
+
535
+ function Resolve-AdoptOutDir {
536
+ param([Parameter(Mandatory)][string]$Name, [string]$OutDir, [string]$RepoRoot = (Get-Location).Path)
537
+
538
+ if (-not $OutDir) {
539
+ $cfg = Get-AdoptConfig -RepoRoot $RepoRoot
540
+ $OutDir = Join-Path $RepoRoot $cfg.outDir
541
+ }
542
+ $dir = Join-Path $OutDir $Name
543
+ if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
544
+ return (Resolve-Path -LiteralPath $dir).ProviderPath
545
+ }
546
+
547
+ function Write-AdoptArtifact {
548
+ param([Parameter(Mandatory)][string]$Dir, [Parameter(Mandatory)][string]$FileName, [Parameter(Mandatory)]$Object)
549
+ $p = Join-Path $Dir $FileName
550
+ $Object | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $p -Encoding UTF8
551
+ return $p
552
+ }
553
+
554
+ function Read-AdoptArtifact {
555
+ param([Parameter(Mandatory)][string]$Dir, [Parameter(Mandatory)][string]$FileName)
556
+ $p = Join-Path $Dir $FileName
557
+ if (-not (Test-Path -LiteralPath $p)) {
558
+ throw "No $FileName in $Dir. Run the earlier phase first (see the skill's phase table)."
559
+ }
560
+ return Get-Content -LiteralPath $p -Raw | ConvertFrom-Json
561
+ }
562
+
563
+ function Invoke-AdoptScan {
564
+ param(
565
+ [Parameter(Mandatory)][string]$Path,
566
+ [string]$ComputerName,
567
+ [int]$FileBudget = 20000,
568
+ [string]$SkipPattern = $script:AdoptDefaultConfig.skipPattern,
569
+ [int]$MaxProbeBytes = 262144,
570
+ [int]$ProbeBudget = 4000,
571
+ [int]$DetectPathCap = 20000,
572
+ [string[]]$SkipFolders = @()
573
+ )
574
+
575
+ # NOT $args: that is PowerShell's automatic argument array, and writing to it inside a
576
+ # function is how a splat silently becomes the wrong list.
577
+ $scanArgs = @($Path, $FileBudget, $SkipPattern, $script:AdoptSecretNamePattern,
578
+ $script:AdoptSecretContentPatterns, $script:AdoptExcludablePatterns, $MaxProbeBytes,
579
+ $script:AdoptCommentLinePattern, $script:AdoptNonLiteralValuePattern, $ProbeBudget, $SkipFolders, $script:AdoptReferenceValuePattern, $script:AdoptMinSecretLength, $script:AdoptSecretNameExemptPattern, $script:AdoptKeyContainerPattern, $DetectPathCap)
580
+
581
+ if ($ComputerName) {
582
+ $session = $null
583
+ try {
584
+ $session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
585
+ return Invoke-Command -Session $session -ScriptBlock $script:AdoptScanBlock -ArgumentList $scanArgs
586
+ }
587
+ finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
588
+ }
589
+ return & $script:AdoptScanBlock @scanArgs
590
+ }
591
+
592
+ # --------------------------------------------------------------------------------------
593
+ # Profiles. Same contract as the fleet copy this skill was extracted from, so a profile
594
+ # written for one is readable by the other.
595
+ # --------------------------------------------------------------------------------------
596
+ $script:AdoptProfileRequiredKeys = @('name', 'status', 'description', 'detect', 'runtimes', 'build', 'routing', 'config', 'data', 'ci', 'deploy')
597
+
598
+ function Get-AdoptProfileDir {
599
+ param([string]$ProfilesDir)
600
+ $p = $ProfilesDir
601
+ if (-not $p) { $p = Join-Path $PSScriptRoot '..\profiles' }
602
+ if (-not (Test-Path -LiteralPath $p)) {
603
+ 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/."
604
+ }
605
+ return (Resolve-Path -LiteralPath $p).ProviderPath
606
+ }
607
+
608
+ function Get-AdoptProfiles {
609
+ param([string]$ProfilesDir, [switch]$IncludeTemplate)
610
+ $dir = Get-AdoptProfileDir -ProfilesDir $ProfilesDir
611
+ $out = @()
612
+ foreach ($d in @(Get-ChildItem -LiteralPath $dir -Directory | Sort-Object Name)) {
613
+ if ($d.Name -eq '_template' -and -not $IncludeTemplate) { continue }
614
+ $f = Join-Path $d.FullName 'profile.json'
615
+ if (-not (Test-Path -LiteralPath $f)) { continue }
616
+ $p = Get-Content -LiteralPath $f -Raw | ConvertFrom-Json
617
+ $p | Add-Member -NotePropertyName _dir -NotePropertyValue $d.FullName -Force
618
+ $out += $p
619
+ }
620
+ return $out
621
+ }
622
+
623
+ function Test-AdoptProfileContract {
624
+ param([Parameter(Mandatory)]$Spec)
625
+ $problems = @()
626
+ $names = @($Spec.PSObject.Properties.Name)
627
+ foreach ($k in $script:AdoptProfileRequiredKeys) { if ($names -notcontains $k) { $problems += "missing '$k'" } }
628
+ if ($names -notcontains 'services' -and $names -notcontains 'schedule') { $problems += "needs 'services' or 'schedule'" }
629
+ if ($names -contains 'status' -and $Spec.status -notin @('proven', 'draft', 'placeholder')) {
630
+ $problems += "status '$($Spec.status)' is not proven|draft|placeholder"
631
+ }
632
+ if ($names -contains 'status' -and $Spec.status -ne 'placeholder' -and "$($Spec.name)" -match '[<>]') {
633
+ $problems += "name still holds a placeholder"
634
+ }
635
+ return , $problems
636
+ }
637
+
638
+ # Matches one profile's detect rules against a FILE LIST (so it works for a remote scan
639
+ # without a second round trip). Returns 0 for no match, or a score: more matched rule
640
+ # kinds means a more specific match.
641
+ function Test-AdoptDetect {
642
+ param([Parameter(Mandatory)]$Detect, [Parameter(Mandatory)][string[]]$RelativePaths, [hashtable]$FileText = @{})
643
+
644
+ $has = {
645
+ param($glob)
646
+ $rx = '(?i)^' + [regex]::Escape($glob).Replace('\*\*', '.*').Replace('\*', '[^\\/]*').Replace('/', '[\\/]') + '$'
647
+ foreach ($p in $RelativePaths) { if ($p -match $rx) { return $true } }
648
+ return $false
649
+ }
650
+ $contains = {
651
+ param($spec)
652
+ foreach ($g in @($spec.file)) {
653
+ $rx = '(?i)^' + [regex]::Escape($g).Replace('\*\*', '.*').Replace('\*', '[^\\/]*').Replace('/', '[\\/]') + '$'
654
+ foreach ($k in $FileText.Keys) {
655
+ if ($k -match $rx -and "$($FileText[$k])" -match $spec.pattern) { return $true }
656
+ }
657
+ }
658
+ return $false
659
+ }
660
+
661
+ $names = @($Detect.PSObject.Properties.Name)
662
+ $score = 0
663
+ if ($names -contains 'allFiles') { foreach ($f in @($Detect.allFiles)) { if (-not (& $has $f)) { return 0 } }; $score++ }
664
+ if ($names -contains 'anyFiles') { if (-not (@($Detect.anyFiles) | Where-Object { & $has $_ })) { return 0 }; $score++ }
665
+ if ($names -contains 'noneFiles') { if (@($Detect.noneFiles) | Where-Object { & $has $_ }) { return 0 } }
666
+ if ($names -contains 'fileContains') { foreach ($c in @($Detect.fileContains)) { if (-not (& $contains $c)) { return 0 } }; $score++ }
667
+ if ($names -contains 'noneContain') { foreach ($c in @($Detect.noneContain)) { if (& $contains $c) { return 0 } } }
668
+ return $score
669
+ }
670
+
671
+ # Fetches the text of a few small files, local or remote, for profile detection.
672
+ #
673
+ # File bodies are NEVER written into an artefact by this skill. A config file holds
674
+ # credentials, and an artefact is a file that gets copied into a ticket, a chat and a
675
+ # report. Detection reads the text in memory and keeps only the verdict.
676
+ function Get-AdoptFileText {
677
+ param(
678
+ [Parameter(Mandatory)][string]$Path,
679
+ [string]$ComputerName,
680
+ [Parameter(Mandatory)][string[]]$RelativePaths,
681
+ [int]$MaxBytes = 262144
682
+ )
683
+
684
+ $block = {
685
+ param($root, $rels, $maxBytes)
686
+ $out = @{}
687
+ foreach ($rel in $rels) {
688
+ $full = Join-Path $root $rel
689
+ if (-not (Test-Path -LiteralPath $full)) { continue }
690
+ $fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
691
+ if (-not $fi -or $fi.Length -gt $maxBytes) { continue }
692
+ try { $out[$rel] = Get-Content -LiteralPath $full -Raw -ErrorAction Stop } catch { }
693
+ }
694
+ return $out
695
+ }
696
+
697
+ if ($ComputerName) {
698
+ $session = $null
699
+ try {
700
+ $session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
701
+ return Invoke-Command -Session $session -ScriptBlock $block -ArgumentList @($Path, $RelativePaths, $MaxBytes)
702
+ }
703
+ finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
704
+ }
705
+ return & $block $Path $RelativePaths $MaxBytes
706
+ }
707
+
708
+ # What is registered on a host to RUN this folder. An app nobody bootstrapped is usually
709
+ # started by something nobody wrote down: a service, a scheduled task, a web site.
710
+ function Get-AdoptRunRegistrations {
711
+ param([Parameter(Mandatory)][string]$Path, [string]$ComputerName)
712
+
713
+ $block = {
714
+ param($needle)
715
+ $out = @{ services = @(); scheduledTasks = @(); webSites = @(); checked = @(); errors = @() }
716
+
717
+ try {
718
+ $svcs = Get-CimInstance Win32_Service -ErrorAction Stop |
719
+ Where-Object { "$($_.PathName)" -like "*$needle*" }
720
+ foreach ($s in $svcs) {
721
+ $out.services += @{ name = $s.Name; state = $s.State; startMode = $s.StartMode; account = $s.StartName; path = $s.PathName }
722
+ }
723
+ $out.checked += 'services'
724
+ }
725
+ catch { $out.errors += "services: $($_.Exception.Message)" }
726
+
727
+ try {
728
+ $tasks = Get-ScheduledTask -ErrorAction Stop
729
+ foreach ($t in $tasks) {
730
+ # Not every action is an Exec action. A ComHandler or SendEmail action has
731
+ # no Execute property at all, and reading it under StrictMode throws - which
732
+ # killed the whole scheduled-task check on a box that had one.
733
+ $hits = @($t.Actions | Where-Object {
734
+ $props = @($_.PSObject.Properties.Name)
735
+ $exec = ''; $argus = ''; $wd = ''
736
+ if ($props -contains 'Execute') { $exec = "$($_.Execute)" }
737
+ if ($props -contains 'Arguments') { $argus = "$($_.Arguments)" }
738
+ if ($props -contains 'WorkingDirectory') { $wd = "$($_.WorkingDirectory)" }
739
+ $exec -like "*$needle*" -or $argus -like "*$needle*" -or $wd -like "*$needle*"
740
+ })
741
+ if ($hits.Count -eq 0) { continue }
742
+ $info = $null
743
+ try { $info = $t | Get-ScheduledTaskInfo -ErrorAction Stop } catch { }
744
+ $lastRun = $null
745
+ if ($info) { $lastRun = "$($info.LastRunTime)" }
746
+ $out.scheduledTasks += @{
747
+ name = $t.TaskName
748
+ path = $t.TaskPath
749
+ state = "$($t.State)"
750
+ account = "$($t.Principal.UserId)"
751
+ action = ("$($hits[0].Execute) $($hits[0].Arguments)").Trim()
752
+ lastRun = $lastRun
753
+ }
754
+ }
755
+ $out.checked += 'scheduledTasks'
756
+ }
757
+ catch { $out.errors += "scheduledTasks: $($_.Exception.Message)" }
758
+
759
+ try {
760
+ Import-Module WebAdministration -ErrorAction Stop
761
+ foreach ($s in @(Get-Website)) {
762
+ if ("$($s.physicalPath)" -like "*$needle*") {
763
+ $out.webSites += @{ name = $s.Name; state = "$($s.State)"; physicalPath = "$($s.physicalPath)" }
764
+ }
765
+ }
766
+ foreach ($a in @(Get-WebApplication)) {
767
+ if ("$($a.PhysicalPath)" -like "*$needle*") {
768
+ $out.webSites += @{ name = "$($a.Path)"; state = 'application'; physicalPath = "$($a.PhysicalPath)" }
769
+ }
770
+ }
771
+ $out.checked += 'webSites'
772
+ }
773
+ catch { $out.errors += "webSites: $($_.Exception.Message)" }
774
+
775
+ return $out
776
+ }
777
+
778
+ # Match on the last path segment: a service command line names a local drive path, and
779
+ # the caller may have handed us a UNC path to the same folder.
780
+ $needle = Split-Path $Path -Leaf
781
+
782
+ if ($ComputerName) {
783
+ $session = $null
784
+ try {
785
+ $session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
786
+ return Invoke-Command -Session $session -ScriptBlock $block -ArgumentList @($needle)
787
+ }
788
+ finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
789
+ }
790
+ return & $block $needle
791
+ }
792
+
793
+ # Reads a key that may not be there. Under StrictMode a missing property throws, and these
794
+ # records are deliberately heterogeneous: a credential found by NAME has no line number, a
795
+ # credential found by VALUE does. Reading it directly killed two scripts mid-report.
796
+ function Get-AdoptProp {
797
+ param($Object, [Parameter(Mandatory)][string]$Name, $Default = $null)
798
+ if ($null -eq $Object) { return $Default }
799
+ if ($Object -is [hashtable]) {
800
+ if ($Object.ContainsKey($Name)) { return $Object[$Name] }
801
+ return $Default
802
+ }
803
+ if (@($Object.PSObject.Properties.Name) -contains $Name) { return $Object.$Name }
804
+ return $Default
805
+ }
806
+
807
+ function Write-AdoptHeading {
808
+ param([Parameter(Mandatory)][string]$Text)
809
+ Write-Host ""
810
+ Write-Host $Text -ForegroundColor Cyan
811
+ Write-Host ('-' * $Text.Length) -ForegroundColor DarkCyan
812
+ }