Files
mnote/scripts/env_tools.ps1
T

594 lines
18 KiB
PowerShell
Raw Normal View History

2026-02-01 08:47:40 +08:00
# -*- coding: utf-8 -*-
<#
说明:
- 本脚本用于在本仓库内整理与汇总各处的 env 文件。
- 默认不会在控制台打印任何 env 的内容,避免泄露敏感信息。
用法示例:
pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/env_tools.ps1 -CleanupSupabase -Merge -OutFile .env.all
#>
[CmdletBinding()]
param(
[switch]$CleanupSupabase,
# 兼容旧参数:-Merge 等价于 -MergeRaw
[switch]$Merge,
[switch]$MergeRaw,
[switch]$MergeUnique,
[switch]$Report,
[switch]$ListApplied,
# 合并优先级:默认生产优先;如需本地开发优先,传 -DevFirst
[switch]$DevFirst,
[switch]$AppliedOnly,
[switch]$IncludeLegacy,
[string]$RawOutFile = ".env.all.raw",
[string]$OutFile = ".env.all",
[string]$ConflictReportFile = "env.all.conflicts.md",
[string]$AppliedReportFile = "env.applied.md"
)
$ErrorActionPreference = "Stop"
function New-Utf8NoBomEncoding {
return New-Object System.Text.UTF8Encoding($false)
}
function Read-TextUtf8 {
param([Parameter(Mandatory = $true)][string]$Path)
return [System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::UTF8)
}
function Write-TextUtf8NoBom {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Text
)
$encoding = New-Utf8NoBomEncoding
[System.IO.File]::WriteAllText($Path, $Text, $encoding)
}
function Get-RepoRoot {
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
}
function Get-EnvFiles {
param([Parameter(Mandatory = $true)][string]$Root)
$excludePattern = "\\(node_modules|\.git|dist|build|\.next|out|coverage|pnpm-store|artifacts)\\"
$envFiles =
Get-ChildItem -Path $Root -Recurse -Force -File |
Where-Object {
$n = $_.Name
($n -eq ".env" -or $n -like ".env.*" -or $n -like "*.env" -or $n -like "*.env.*" -or $n -like "*.env.example" -or $n -eq ".env.example" -or $n -like ".env.*.example") -and
($_.FullName -notmatch $excludePattern)
} |
Sort-Object FullName
return $envFiles
}
function Get-EnvFileKind {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$FullName
)
$rel = $FullName.Substring($Root.Length).TrimStart("\","/")
$name = [System.IO.Path]::GetFileName($FullName)
$lowerName = $name.ToLowerInvariant()
if ($rel -match "^(?i)cankao[/\\\\]") { return "legacy" }
if ($rel -match "(?i)[/\\\\]test[/\\\\]") { return "legacy" }
if ($lowerName.EndsWith(".example")) { return "template" }
if ($lowerName.EndsWith(".sample")) { return "template" }
if ($lowerName.EndsWith(".mock")) { return "template" }
if ($lowerName.EndsWith(".library")) { return "template" }
if ($lowerName -eq "test.env") { return "template" }
return "runtime"
}
function Get-AppliedEnvFiles {
param([Parameter(Mandatory = $true)][string]$Root)
# 说明:这里的[已应用]指默认启动链路/配置文件明确加载的 env 文件。
$candidates = @(
# 统一入口:仓库根目录的 .env.all(生产优先,且要求 Key 唯一)
(Join-Path $Root ".env.all")
)
$applied = New-Object System.Collections.Generic.List[string]
foreach ($p in ($candidates | Where-Object { Test-Path -LiteralPath $_ })) {
$applied.Add((Resolve-Path -LiteralPath $p).Path) | Out-Null
}
# Docker(运行中)推断:从容器的 compose working_dir 与 compose 文件解析 env_file
$dockerApplied = Get-AppliedEnvFilesFromDocker -Root $Root
foreach ($p in $dockerApplied) {
if (Test-Path -LiteralPath $p) {
$applied.Add((Resolve-Path -LiteralPath $p).Path) | Out-Null
}
}
return $applied | Select-Object -Unique
}
function Get-AppliedEnvFilesFromDocker {
param([Parameter(Mandatory = $true)][string]$Root)
$result = New-Object System.Collections.Generic.List[string]
$dockerOk = $false
try {
$null = & docker ps --format "{{.ID}}" 2>$null
$dockerOk = $true
} catch {
$dockerOk = $false
}
if (-not $dockerOk) {
return @()
}
$ids = @()
try {
$ids = & docker ps --format "{{.ID}}" 2>$null
} catch {
return @()
}
$composeFiles = New-Object System.Collections.Generic.HashSet[string]
$workingDirs = New-Object System.Collections.Generic.HashSet[string]
foreach ($id in $ids) {
$workdir = ""
$cfg = ""
try { $workdir = (& docker inspect -f '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' $id 2>$null) } catch {}
try { $cfg = (& docker inspect -f '{{ index .Config.Labels "com.docker.compose.project.config_files" }}' $id 2>$null) } catch {}
if ($workdir -and $workdir -ne "<no value>" -and ($workdir -like "$Root*")) {
$workingDirs.Add($workdir) | Out-Null
}
if ($cfg -and $cfg -ne "<no value>") {
foreach ($p in ($cfg -split "[;,]" | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
if ($p -like "$Root*") {
$composeFiles.Add($p) | Out-Null
}
}
}
}
foreach ($wd in $workingDirs) {
$p = Join-Path $wd ".env"
if (Test-Path -LiteralPath $p) {
$result.Add($p) | Out-Null
}
}
foreach ($cf in $composeFiles) {
foreach ($p in (Get-EnvFilesFromCompose -ComposeFile $cf)) {
if ($p -like "$Root*") {
$result.Add($p) | Out-Null
}
}
}
return $result | Select-Object -Unique
}
function Get-EnvFilesFromCompose {
param([Parameter(Mandatory = $true)][string]$ComposeFile)
if (-not (Test-Path -LiteralPath $ComposeFile)) {
return @()
}
$dir = Split-Path -Parent $ComposeFile
$lines = Get-Content -LiteralPath $ComposeFile
$out = New-Object System.Collections.Generic.List[string]
$inEnvFile = $false
$envIndent = 0
foreach ($line in $lines) {
if ($line -match "^(\\s*)env_file\\s*:\\s*$") {
$inEnvFile = $true
$envIndent = $matches[1].Length
continue
}
if ($inEnvFile) {
# 退出条件:遇到更浅的缩进且不是空行/注释
if ($line -match "^(\\s*)(\\S.*)$") {
$indent = $matches[1].Length
$payload = $matches[2]
if ($indent -le $envIndent -and $payload -notmatch "^-") {
$inEnvFile = $false
}
}
}
if (-not $inEnvFile) { continue }
if ($line -match "^\\s*-\\s*(.+?)\\s*$") {
$raw = $matches[1].Trim()
$raw = $raw.Trim('"').Trim("'")
if (-not $raw) { continue }
$resolved = if ([System.IO.Path]::IsPathRooted($raw)) { $raw } else { Join-Path $dir $raw }
try {
# 归一化 .. 等相对段,避免 Test-Path / -LiteralPath 因路径包含 .. 而误判
$resolved = [System.IO.Path]::GetFullPath($resolved)
} catch {}
$out.Add($resolved) | Out-Null
}
}
return $out | Select-Object -Unique
}
function Remove-LinesContaining {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Pattern
)
$text = Read-TextUtf8 -Path $Path
$lines = $text -split "(\r?\n)", 0
# 上面的 split 会保留分隔符(换行)作为独立元素;便于最大限度保留原始换行风格。
$out = New-Object System.Text.StringBuilder
for ($i = 0; $i -lt $lines.Count; $i++) {
$chunk = $lines[$i]
if ($chunk -match "^\r?\n$") {
[void]$out.Append($chunk)
continue
}
if ($chunk -match $Pattern) {
continue
}
[void]$out.Append($chunk)
}
Write-TextUtf8NoBom -Path $Path -Text $out.ToString()
}
function Get-MergeOrderScore {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$FullName
)
$rel = $FullName.Substring($Root.Length).TrimStart("\","/")
$name = [System.IO.Path]::GetFileName($FullName)
# 目录组优先级:越大越[后覆盖](优先级更高)
$group = 0
if ($rel -match "^(?i)wolai-backend[/\\\\]") { $group = 60 }
elseif ($rel -match "^(?i)services[/\\\\]") { $group = 30 }
elseif ($rel -match "^(?i)cankao[/\\\\]") { $group = -50 }
elseif ($rel -match "(?i)[/\\\\]test[/\\\\]") { $group = -40 }
else { $group = 0 }
# 文件名优先级:越大越[后覆盖]
$fileScore = 0
if ($DevFirst) {
# 开发优先:.env.local 覆盖 .envproduction 文件仍保持最高
if ($name -match "(?i)\\.production\\.local$") { $fileScore = 50 }
elseif ($name -match "(?i)\\.production$") { $fileScore = 40 }
elseif ($name -match "(?i)\\.local$") { $fileScore = 30 }
elseif ($name -eq ".env") { $fileScore = 20 }
else { $fileScore = 0 }
} else {
# 生产优先(默认):.env 覆盖 .env.localproduction 文件最高
if ($name -match "(?i)\\.production\\.local$") { $fileScore = 50 }
elseif ($name -match "(?i)\\.production$") { $fileScore = 40 }
elseif ($name -eq ".env") { $fileScore = 30 }
elseif ($name -match "(?i)\\.local$") { $fileScore = 20 }
else { $fileScore = 0 }
}
# template/legacy 默认更弱(更靠前),避免覆盖运行时真实值
$kind = Get-EnvFileKind -Root $Root -FullName $FullName
$kindScore = 0
if ($kind -eq "template") { $kindScore = -1000 }
elseif ($kind -eq "legacy") { $kindScore = -2000 }
return ($kindScore + ($group * 100) + $fileScore)
}
function Merge-EnvFilesRaw {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$OutPath,
[Parameter(Mandatory = $true)][System.IO.FileInfo[]]$Files
)
$sb = New-Object System.Text.StringBuilder
$now = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
[void]$sb.AppendLine("# 自动生成(UTF-8)。此文件为[原样拼接版],可能包含重复 Key。")
[void]$sb.AppendLine("# 生成时间:$now")
[void]$sb.AppendLine("# 重新生成:pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/env_tools.ps1 -MergeRaw -RawOutFile `"$($OutPath | Split-Path -Leaf)`"")
foreach ($f in $files) {
$rel = $f.FullName.Substring($Root.Length).TrimStart("\","/")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("# ===== $rel =====")
try {
$content = Read-TextUtf8 -Path $f.FullName
} catch {
# 若遇到非 UTF-8 文件,仍尽量以系统默认读取(但写回统一为 UTF-8)。
$content = [System.IO.File]::ReadAllText($f.FullName)
}
$content = $content.TrimEnd("`r", "`n")
if ($content.Length -gt 0) {
[void]$sb.AppendLine($content)
}
[void]$sb.AppendLine("")
}
Write-TextUtf8NoBom -Path $OutPath -Text $sb.ToString()
}
function Parse-EnvAssignments {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$FullName
)
try {
$content = Read-TextUtf8 -Path $FullName
} catch {
$content = [System.IO.File]::ReadAllText($FullName)
}
$rel = $FullName.Substring($Root.Length).TrimStart("\","/")
$lines = $content -split "\r?\n"
$out = @()
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($null -eq $line) { continue }
if ($line -match "^\s*#") { continue }
if ($line -match "^\s*$") { continue }
if ($line -match "^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$") {
$key = $matches[1]
$rhs = $matches[2]
$out += [pscustomobject]@{
Key = $key
Rhs = $rhs
Source = $rel
Line = ($i + 1)
}
}
}
return $out
}
function Merge-EnvUnique {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$OutPath,
[Parameter(Mandatory = $true)][System.IO.FileInfo[]]$Files,
[Parameter(Mandatory = $true)][string]$ConflictReportPath
)
# 排序:分数越大,越靠后覆盖
$ordered = $Files | Sort-Object { Get-MergeOrderScore -Root $Root -FullName $_.FullName }, FullName
$final = @{}
$conflicts = @{}
foreach ($f in $ordered) {
$assignments = Parse-EnvAssignments -Root $Root -FullName $f.FullName
foreach ($a in $assignments) {
if ($final.ContainsKey($a.Key)) {
if (-not $conflicts.ContainsKey($a.Key)) {
$conflicts[$a.Key] = @($final[$a.Key].Source)
}
$conflicts[$a.Key] += $a.Source
}
$final[$a.Key] = $a
}
}
$now = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine("# 自动生成(UTF-8)。此文件为[去重版]:每个 Key 只保留一个定义。")
[void]$sb.AppendLine("# 生成时间:$now")
[void]$sb.AppendLine("# 合并规则:按目录/文件优先级排序,越靠后越覆盖(template/legacy 默认不会覆盖 runtime)。")
[void]$sb.AppendLine("# 冲突报告:$([System.IO.Path]::GetFileName($ConflictReportPath))(不包含任何值)")
[void]$sb.AppendLine("")
foreach ($k in ($final.Keys | Sort-Object)) {
$a = $final[$k]
[void]$sb.AppendLine("# from: $($a.Source)")
[void]$sb.AppendLine("$($a.Key)=$($a.Rhs)")
[void]$sb.AppendLine("")
}
Write-TextUtf8NoBom -Path $OutPath -Text $sb.ToString()
# 写冲突报告(不输出值)
$rep = New-Object System.Text.StringBuilder
[void]$rep.AppendLine("# env key 冲突报告(不含任何值)")
[void]$rep.AppendLine("# 生成时间:$now")
[void]$rep.AppendLine("")
if ($conflicts.Keys.Count -eq 0) {
[void]$rep.AppendLine("未发现重复 Key。")
} else {
foreach ($k in ($conflicts.Keys | Sort-Object)) {
$chosen = $final[$k].Source
$allSources = ($conflicts[$k] + @($chosen)) | Select-Object -Unique
$others = $allSources | Where-Object { $_ -ne $chosen }
[void]$rep.AppendLine("- $k")
[void]$rep.AppendLine(" - 采用:$chosen")
if ($others.Count -gt 0) {
[void]$rep.AppendLine(" - 其它:$($others -join ', ')")
}
}
}
Write-TextUtf8NoBom -Path $ConflictReportPath -Text $rep.ToString()
}
$root = Get-RepoRoot
if ($Merge -and -not $MergeRaw) {
$MergeRaw = $true
}
$appliedPaths = Get-AppliedEnvFiles -Root $root
if ($ListApplied) {
foreach ($p in ($appliedPaths | Sort-Object)) {
$rel = $p.Substring($root.Length).TrimStart("\","/")
Write-Output $rel
}
return
}
$appliedOnlyDefault = $true
if (-not $PSBoundParameters.ContainsKey("AppliedOnly")) {
$AppliedOnly = $appliedOnlyDefault
}
$allEnvFiles = Get-EnvFiles -Root $root
$appliedSet = @{}
foreach ($p in $appliedPaths) { $appliedSet[$p.ToLowerInvariant()] = $true }
$selected =
if ($AppliedOnly) {
$allEnvFiles | Where-Object { $appliedSet.ContainsKey($_.FullName.ToLowerInvariant()) }
} else {
$allEnvFiles
}
if (-not $IncludeLegacy) {
$selected =
$selected | Where-Object {
(Get-EnvFileKind -Root $root -FullName $_.FullName) -ne "legacy"
}
}
$rawOutFull = Join-Path $root $RawOutFile
$outFull = Join-Path $root $OutFile
$conflictFull = Join-Path $root $ConflictReportFile
$appliedReportFull = Join-Path $root $AppliedReportFile
$root = Get-RepoRoot
if ($CleanupSupabase) {
$filesWithSupabase =
$allEnvFiles |
Where-Object {
try {
(Read-TextUtf8 -Path $_.FullName) -match "(?i)supabase"
} catch {
([System.IO.File]::ReadAllText($_.FullName)) -match "(?i)supabase"
}
}
foreach ($f in $filesWithSupabase) {
Remove-LinesContaining -Path $f.FullName -Pattern "(?i)supabase"
}
}
if ($MergeRaw) {
$files = $selected | Where-Object { $_.FullName -ne $rawOutFull -and $_.FullName -ne $outFull }
Merge-EnvFilesRaw -Root $root -OutPath $rawOutFull -Files $files
}
if ($MergeUnique) {
$files = $selected | Where-Object { $_.FullName -ne $rawOutFull -and $_.FullName -ne $outFull }
Merge-EnvUnique -Root $root -OutPath $outFull -Files $files -ConflictReportPath $conflictFull
}
if ($Report -or $MergeRaw -or $MergeUnique) {
# 写[已应用/未应用]报告(不包含值)
$now = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$sb2 = New-Object System.Text.StringBuilder
[void]$sb2.AppendLine("# env 应用情况报告(不含任何值)")
[void]$sb2.AppendLine("# 生成时间:$now")
[void]$sb2.AppendLine("")
[void]$sb2.AppendLine("## 默认链路明确加载的 env 文件(Applied")
if ($appliedPaths.Count -eq 0) {
[void]$sb2.AppendLine("- (未发现)")
} else {
foreach ($p in $appliedPaths) {
$rel = $p.Substring($root.Length).TrimStart("\","/")
[void]$sb2.AppendLine("- $rel")
}
}
[void]$sb2.AppendLine("")
[void]$sb2.AppendLine("## 扫描到的 env 文件(Found")
$generatedEnvFull = @(
$outFull.ToLowerInvariant(),
$rawOutFull.ToLowerInvariant()
)
foreach ($f in $allEnvFiles) {
$rel = $f.FullName.Substring($root.Length).TrimStart("\","/")
$kind = Get-EnvFileKind -Root $root -FullName $f.FullName
$tag = ""
if ($generatedEnvFull -contains $f.FullName.ToLowerInvariant()) {
$tag = "generated"
}
if ($tag -ne "") {
[void]$sb2.AppendLine("- $rel ($kind, $tag)")
} else {
[void]$sb2.AppendLine("- $rel ($kind)")
}
}
[void]$sb2.AppendLine("")
[void]$sb2.AppendLine("## 默认链路未使用的文件(按类型分组)")
$notApplied = $allEnvFiles | Where-Object {
(-not $appliedSet.ContainsKey($_.FullName.ToLowerInvariant())) -and
(-not ($generatedEnvFull -contains $_.FullName.ToLowerInvariant()))
}
$notAppliedRuntime = $notApplied | Where-Object { (Get-EnvFileKind -Root $root -FullName $_.FullName) -eq "runtime" }
$notAppliedTemplate = $notApplied | Where-Object { (Get-EnvFileKind -Root $root -FullName $_.FullName) -eq "template" }
$notAppliedLegacy = $notApplied | Where-Object { (Get-EnvFileKind -Root $root -FullName $_.FullName) -eq "legacy" }
[void]$sb2.AppendLine("")
[void]$sb2.AppendLine("### runtime(可能是历史遗留/需确认是否仍在用)")
if ($notAppliedRuntime.Count -eq 0) {
[void]$sb2.AppendLine("- (无)")
} else {
foreach ($f in $notAppliedRuntime) {
$rel = $f.FullName.Substring($root.Length).TrimStart("\","/")
[void]$sb2.AppendLine("- $rel")
}
}
[void]$sb2.AppendLine("")
[void]$sb2.AppendLine("### template(示例/样例文件)")
if ($notAppliedTemplate.Count -eq 0) {
[void]$sb2.AppendLine("- (无)")
} else {
foreach ($f in $notAppliedTemplate) {
$rel = $f.FullName.Substring($root.Length).TrimStart("\","/")
[void]$sb2.AppendLine("- $rel")
}
}
[void]$sb2.AppendLine("")
[void]$sb2.AppendLine("### legacy(参考资料/测试夹带)")
if ($notAppliedLegacy.Count -eq 0) {
[void]$sb2.AppendLine("- (无)")
} else {
foreach ($f in $notAppliedLegacy) {
$rel = $f.FullName.Substring($root.Length).TrimStart("\","/")
[void]$sb2.AppendLine("- $rel")
}
}
Write-TextUtf8NoBom -Path $appliedReportFull -Text $sb2.ToString()
}