# -*- 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 "" -and ($workdir -like "$Root*")) { $workingDirs.Add($workdir) | Out-Null } if ($cfg -and $cfg -ne "") { 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 覆盖 .env;production 文件仍保持最高 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.local;production 文件最高 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() }