feat(tui): 主入口 Manage-Backup 与配置入口 Edit-Config(第一轮第六块·完成)
Manage-Backup.ps1:不带参数进 TUI 主菜单(备份 / 恢复 / 配置);带 -Action 直接做那个动作,可配 -Quiet 走无头。退出码原样传出(计划任务靠它);只有两种情况由它自己给码:进不了交互界面(2)与菜单里按 Esc(0)。 动作目前仍是**子进程**:一份编排、两个前端是终局目标(ADR-0012),但那要求先把编排从入口脚本下沉进模块。先让 TUI 能用起来,等下沉做完把子进程调用换成进程内直调,界面层不受影响。 -Quiet 与交互两条路的输出处理**故意不同**:-Quiet 时调用方要拿输出(管道 / 日志 / 计划任务),所以捕获并转发;交互时让子进程继承控制台,输出实时、顺序正确、还能接键盘。这套转发收进模块函数 Invoke-BakNRetEntryScript(垫片与主入口共用同一处逻辑)。 Edit-Config.ps1:把已做好的清单编辑器挂上(外科式改写 + 校验通过才落盘 + 时间戳备份);设置与名录两个界面明确报"还没做"并返回非零,而不是假装成功。 判据 7 条。过程中夹具又踩了"& 不传退出码"这个事实(我在垫片那层修过,却在测试夹具这一层又犯)—— 所以断言里改用 -File 调入口;输出检查走**文件重定向**而不是管道捕获(与 E2E 同一个理由:沙箱下给子进程建管道可能失败,写文件不会)。 验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
This commit is contained in:
1 parent
4e3c0461c0
commit
c2bae35506
6 files changed
+257
No files matched your search
@@ -19,6 +19,7 @@
|
|||||||
CompatiblePSEditions = @('Desktop', 'Core')
|
CompatiblePSEditions = @('Desktop', 'Core')
|
||||||
|
|
||||||
FunctionsToExport = @(
|
FunctionsToExport = @(
|
||||||
|
'Invoke-BakNRetEntryScript',
|
||||||
'Invoke-BakNRetBackupListEditor',
|
'Invoke-BakNRetBackupListEditor',
|
||||||
'Set-BakNRetBackupListDirection',
|
'Set-BakNRetBackupListDirection',
|
||||||
'Save-BakNRetConfigFile',
|
'Save-BakNRetConfigFile',
|
||||||
|
|||||||
@@ -166,7 +166,10 @@
|
|||||||
|
|
||||||
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetBackupListEditor.ps1')
|
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetBackupListEditor.ps1')
|
||||||
|
|
||||||
|
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetEntryScript.ps1')
|
||||||
|
|
||||||
Export-ModuleMember -Function @(
|
Export-ModuleMember -Function @(
|
||||||
|
'Invoke-BakNRetEntryScript',
|
||||||
'Invoke-BakNRetBackupListEditor',
|
'Invoke-BakNRetBackupListEditor',
|
||||||
'Set-BakNRetBackupListDirection',
|
'Set-BakNRetBackupListDirection',
|
||||||
'Save-BakNRetConfigFile',
|
'Save-BakNRetConfigFile',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -1703,6 +1703,39 @@ Test-Case '源码里不得出现"左边空的赋值"(用脚本生成代码时
|
|||||||
Assert-Equal 0 $offenders.Count ('这些行看起来是赋值、左边却是空的:' + ($offenders -join ' / '))
|
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 $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|||||||
Reference in new issue
Block a user