Files
mnote/scripts/env_tools.ps1
T

594 lines
18 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()
}