diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index b39d02f..a65f652 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,7 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Invoke-BakNRetEntryScript', 'Invoke-BakNRetBackupListEditor', 'Set-BakNRetBackupListDirection', 'Save-BakNRetConfigFile', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 7437a9e..0458bac 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -166,7 +166,10 @@ . (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetBackupListEditor.ps1') +. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetEntryScript.ps1') + Export-ModuleMember -Function @( + 'Invoke-BakNRetEntryScript', 'Invoke-BakNRetBackupListEditor', 'Set-BakNRetBackupListDirection', 'Save-BakNRetConfigFile', diff --git a/BakNRet/Public/Invoke-BakNRetEntryScript.ps1 b/BakNRet/Public/Invoke-BakNRetEntryScript.ps1 new file mode 100644 index 0000000..cb11197 --- /dev/null +++ b/BakNRet/Public/Invoke-BakNRetEntryScript.ps1 @@ -0,0 +1,56 @@ +function Invoke-BakNRetEntryScript { + <# + .SYNOPSIS + 在**子进程**里跑另一个入口脚本,并把它的退出码带回来。 + + .DESCRIPTION + 为什么要起子进程(而不是 `& $target`):入口脚本结尾都是 `exit`,同进程调用会把调用方 + 一起带走;而且实测子脚本的 `exit` **不会**把退出码传给父脚本的 `$LASTEXITCODE` —— + `&` 转发会让"失败"变成"成功",而计划任务正是靠退出码判断成败。 + + `-Capture` 决定子进程的输出怎么走,两种场合各有正确解: + + * **交互式**(默认):让子进程直接继承控制台最自然 —— 输出实时、顺序正确、还能接键盘。 + * **`-Quiet`**:调用方要拿输出(管道 / 日志 / 测试),必须显式重定向再转发 —— + 父进程的 stdout 常常是管道,而 .NET 起的进程默认只继承控制台、**不继承管道**, + 不重定向的话子进程的输出到不了调用方(实测踩到)。 + + 转发用**异步读**:同步先读完 stdout 再读 stderr,会在子进程写满另一个管道时死锁。 + 代价是 stdout/stderr 的相对顺序不再保留。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [string[]]$ArgumentList = @(), + [switch]$Capture + ) + + $hostExe = (Get-Process -Id $PID).Path + $allArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $Path) + @($ArgumentList) + + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $hostExe + # 5.1 上 ProcessStartInfo.ArgumentList 不存在,所以自己引号化 + $startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $allArguments + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = [bool]$Capture + $startInfo.RedirectStandardError = [bool]$Capture + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + [void]$process.Start() + + if ($Capture) { + $standardOutputTask = $process.StandardOutput.ReadToEndAsync() + $standardErrorTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $standardOutput = $standardOutputTask.Result + $standardError = $standardErrorTask.Result + if ($standardOutput) { Write-Host -NoNewline $standardOutput } + if ($standardError) { [Console]::Error.Write($standardError) } + } + else { + $process.WaitForExit() + } + + return $process.ExitCode +} diff --git a/Edit-Config.ps1 b/Edit-Config.ps1 new file mode 100644 index 0000000..40f28b3 --- /dev/null +++ b/Edit-Config.ps1 @@ -0,0 +1,86 @@ +<# +.SYNOPSIS + 配置管理入口:改清单 / 改设置 / 改名录。 + +.DESCRIPTION + 三个界面共用一个底色(ADR-0013):**外科式改写**(只动被编辑的那一处,注释与其它行一字节不动)、 + **校验通过才落盘**(先写临时文件、用模块自己的读取器验、过了才原子替换)、**保存前留时间戳备份** + (logs\config-backups\)。 + + 目前只有**清单**界面做完了;设置(BackupConfig.psd1)与名录(SoftwareCatalog.psd1)分别是 + 第二轮与第三轮的工作 —— 现在选它们会明确说"还没做"并返回非零,而不是假装成功。 + + 进不了交互界面时明确失败(退出码 2)而不是挂起:TUI 的异常不该改退出码,但"根本没法交互" + 是个必须让调用方知道的状态。 + +.EXAMPLE + .\Edit-Config.ps1 -Target List + +.EXAMPLE + .\Edit-Config.ps1 -Target List -ListPath .\BackupList.txt -InputScript @('Enter','Down','Enter') +#> +[CmdletBinding()] +param( + [ValidateSet('List', 'Settings', 'Catalog')][string]$Target, + [string]$ListPath, + [string[]]$InputScript +) + +$ErrorActionPreference = 'Stop' + +$savedVerbosePreference = $VerbosePreference +$VerbosePreference = 'SilentlyContinue' +Import-Module (Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1') -Force +$VerbosePreference = $savedVerbosePreference + +if (-not $ListPath) { $ListPath = Join-Path $PSScriptRoot 'BackupList.txt' } + +function Invoke-BakNRetListEditorEntry { + param([string]$Path, [string[]]$Script) + $driver = New-BakNRetInputDriver -Script $Script -NonInteractive:$([bool]$Script) + $result = Invoke-BakNRetBackupListEditor -Path $Path -Driver $driver + if ($result.Saved) { + Write-Host ("已保存:{0}" -f $Path) -ForegroundColor Green + Write-Host ("原文件备份:{0}" -f $result.BackupPath) -ForegroundColor DarkGray + return 0 + } + if ($result.Error) { + Write-Host $result.Error -ForegroundColor Yellow + return 1 + } + Write-Host '没有改动。' -ForegroundColor DarkGray + return 0 +} + +if ($Target -eq 'List') { + exit (Invoke-BakNRetListEditorEntry -Path $ListPath -Script $InputScript) +} + +if ($Target) { + Write-Host ("{0} 的编辑器还没做:清单已完成;设置与名录分别是第二轮与第三轮的工作。" -f $Target) -ForegroundColor Yellow + exit 1 +} + +# 不带 -Target:进配置菜单 +if ([Console]::IsOutputRedirected -and -not $InputScript) { + Write-Host '没有可用的控制台,也没有给出 -InputScript:无法进入交互界面。' -ForegroundColor Red + Write-Host '要非交互执行,请指定目标,例如:-Target List' -ForegroundColor Red + exit 2 +} + +$driver = New-BakNRetInputDriver -Script $InputScript -NonInteractive:$([bool]$InputScript) +$items = @( + [pscustomobject]@{ Key = 'List'; Text = '清单 BackupList.txt —— 改方向,改完校验' } + [pscustomobject]@{ Key = 'Settings'; Text = '设置 BackupConfig.psd1 —— 还没做(第二轮)' } + [pscustomobject]@{ Key = 'Catalog'; Text = '名录 SoftwareCatalog.psd1 —— 还没做(第三轮)' } +) + +$picked = Invoke-BakNRetMenu -Title '配置' -Items $items -Driver $driver -MultiSelect $false -Hint '↑↓ 选择 回车 进入 Esc 返回' +if ($picked.Action -ne 'confirm') { exit 0 } + +if ($picked.Chosen.Key -eq 'List') { + exit (Invoke-BakNRetListEditorEntry -Path $ListPath -Script $InputScript) +} + +Write-Host ("{0} 的编辑器还没做。" -f $picked.Chosen.Key) -ForegroundColor Yellow +exit 1 diff --git a/Manage-Backup.ps1 b/Manage-Backup.ps1 new file mode 100644 index 0000000..843147c --- /dev/null +++ b/Manage-Backup.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS + BakNRet 主入口:不带参数时进 TUI 菜单;带 -Action 时按动作执行。 + +.DESCRIPTION + 不带参数 = 交互式界面(TUI 主菜单)。带 `-Action Backup|Restore|Config` = 直接做那个动作, + 可以配 `-Quiet` 走无头。 + + 为什么动作仍然是**子进程**(而不是把编排搬进模块):一份编排、两个前端是终局目标(ADR-0012), + 但那要求先把编排从入口脚本里下沉进模块 —— 那是另一大块工作。先让 TUI 能用起来,等下沉做完 + 再把这里的子进程调用换成进程内直调,界面层不受影响。 + + -Quiet:调用方要拿输出(管道 / 日志 / 计划任务),所以捕获并转发子进程的输出。 + 不加 -Quiet 时子进程继承控制台:输出实时、顺序正确、还能接键盘。 + + 退出码:动作的退出码**原样传出**(计划任务靠它)。只有两种情况由本脚本自己给码: + 既没有控制台又没有 -InputScript(无法进交互界面)→ 2;在菜单里按 Esc 退出 → 0。 + 绝不挂起:进不了界面就明确失败(ADR-0010)。 + +.EXAMPLE + .\Manage-Backup.ps1 # 进 TUI 菜单 +.EXAMPLE + .\Manage-Backup.ps1 -Action Backup -Quiet -DryRun +#> +[CmdletBinding()] +param( + [ValidateSet('Backup', 'Restore', 'Config')][string]$Action, + [switch]$Quiet, + [string[]]$InputScript, + [Parameter(ValueFromRemainingArguments = $true)]$Rest +) + +$ErrorActionPreference = 'Stop' + +$savedVerbosePreference = $VerbosePreference +$VerbosePreference = 'SilentlyContinue' +Import-Module (Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1') -Force +$VerbosePreference = $savedVerbosePreference + +# -Verbose / -Debug 是通用参数,**不会**落进 $Rest,所以要显式转发(垫片那一轮踩过这个坑) +$forwardExtra = @($Rest) + @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' }) + @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' }) + +$entryFor = @{ + 'Backup' = 'Backup-Data.ps1' + 'Restore' = 'Restore-Data.ps1' + 'Config' = 'Edit-Config.ps1' +} + +if ($Action) { + $target = Join-Path $PSScriptRoot $entryFor[$Action] + if ($Quiet) { + $exitCode = Invoke-BakNRetEntryScript -Path $target -ArgumentList $forwardExtra -Capture + } + else { + $exitCode = Invoke-BakNRetEntryScript -Path $target -ArgumentList $forwardExtra + } + exit $exitCode +} + +# 不带 -Action:进 TUI 主菜单。进不去就明确失败,绝不挂起。 +if ([Console]::IsOutputRedirected -and -not $InputScript) { + Write-Host '没有可用的控制台,也没有给出 -InputScript:无法进入交互界面。' -ForegroundColor Red + Write-Host '要非交互执行,请指定动作,例如:-Action Backup -Quiet' -ForegroundColor Red + exit 2 +} + +$driver = New-BakNRetInputDriver -Script $InputScript -NonInteractive:$([bool]$InputScript) +$items = @( + [pscustomobject]@{ Key = 'Backup'; Text = '备份  把清单里该备份的条目打包' } + [pscustomobject]@{ Key = 'Restore'; Text = '恢复  从归档恢复(含干跑与校验)' } + [pscustomobject]@{ Key = 'Config'; Text = '配置  清单 / 设置 / 名录' } +) + +$picked = Invoke-BakNRetMenu -Title 'BakNRet' -Items $items -Driver $driver -MultiSelect $false -Hint '↑↓ 选择 回车 进入 Esc 退出' +if ($picked.Action -ne 'confirm') { exit 0 } + +$target = Join-Path $PSScriptRoot $entryFor[$picked.Chosen.Key] +exit (Invoke-BakNRetEntryScript -Path $target -ArgumentList $forwardExtra) diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 9aa340c..70c047a 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1703,6 +1703,39 @@ Test-Case '源码里不得出现"左边空的赋值"(用脚本生成代码时 Assert-Equal 0 $offenders.Count ('这些行看起来是赋值、左边却是空的:' + ($offenders -join ' / ')) } +Test-Case '主入口:-Quiet 不画界面、菜单能渲染、进不去界面时明确失败(退出码 2)' { + $repoRoot = Split-Path -Parent $PSScriptRoot + $probe = Join-Path $env:TEMP ("bnr-entry-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $probe -Force | Out-Null + try { + # 输出检查走**文件重定向**(子 pwsh 自己把输出写文件),不走管道捕获 —— + # 与 E2E 套件同一个理由:沙箱下给子进程建管道可能失败,而写文件不会。 + $quietOut = Join-Path $probe 'quiet.txt' + & pwsh -NoProfile -File (Join-Path $repoRoot 'Manage-Backup.ps1') -Action Backup -Quiet -DryRun *> $quietOut + Assert-Equal 0 $LASTEXITCODE '-Quiet 干跑应当退出 0' + $quietText = Get-Content -LiteralPath $quietOut -Raw -Encoding UTF8 + Assert-True ($quietText -match '日志文件') '-Quiet 下子进程的输出应当被转发出来' + Assert-True ($quietText -notmatch '↑↓') '-Quiet 下不该出现任何 TUI 提示' + + # 菜单:给按键序列就能驱动(Esc 直接退出,退出码 0),并且真的画了界面 + $menuOut = Join-Path $probe 'menu.txt' + & pwsh -NoProfile -File (Join-Path $repoRoot 'Manage-Backup.ps1') -InputScript Esc *> $menuOut + Assert-Equal 0 $LASTEXITCODE '菜单按 Esc 应当退出 0' + $menuText = Get-Content -LiteralPath $menuOut -Raw -Encoding UTF8 + Assert-True ($menuText -match '↑↓ 选择') '菜单应当渲染出提示行' + Assert-True ($menuText -match '备份') '菜单应当列出动作' + + # 没有控制台也没有按键序列:明确失败,绝不挂起 + $noConsoleOut = Join-Path $probe 'noconsole.txt' + & pwsh -NoProfile -File (Join-Path $repoRoot 'Manage-Backup.ps1') *> $noConsoleOut + Assert-Equal 2 $LASTEXITCODE '进不了交互界面应当退出 2' + $ncText = Get-Content -LiteralPath $noConsoleOut -Raw -Encoding UTF8 + Assert-True ($ncText -match '-Action') '报错里应当告诉你怎么非交互执行' + } finally { + Remove-Item -LiteralPath $probe -Recurse -Force -ErrorAction SilentlyContinue + } +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue