zscripts-token-savers/ZHelpers.ps1
Kelly Michels 8959d9fdb6
fix(zdeploy): stop passing ssh -n to scp, which rejects it (#52)
Mirrors evo.scripts #66, which added -n to Get-Ec2SshOpts so a deploy step
cannot block on inherited stdin, plus the follow-up that keeps that flag away
from scp. OpenSSH's scp has no -n: it exits 1 with 'unknown option -- n' and
prints its usage block, so every upload failed once the shared option array
reached it.

Get-Ec2ScpOpts is derived from Get-Ec2SshOpts with -n filtered out rather than
duplicated, so the connect and keepalive timeouts cannot drift apart between
the two transports. All four scp call sites here use it - three plain uploads
and the recursive directory upload, which the private tree does not have.

The upload failure message asserted 'Likely server disk space' without checking
anything; it now points at scp's own output, where the real diagnosis already
was.

Verified: both files parse clean, Get-Ec2ScpOpts returns the five -o pairs with
no -n, and the added lines carry no real hosts, keys, or paths.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:21:04 -05:00

639 lines
27 KiB
PowerShell

# Evomedia.net Token Savers — https://github.com/evomedia-net/evo.zscripts
# Created by Kelly Michels · dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
# Version: v1.0.0.0.11
# ZHelpers.ps1 — shared library dot-sourced by every z script. Not run directly.
# File extensions to skip recursively when building any deploy/backup zip.
$script:ArchiveExtensions = @(
'.zip', '.dmg', '.arj', '.gz', '.tgz', '.tar', '.rar', '.7z', '.iso',
'.bz2', '.xz', '.lz', '.lzma', '.cab', '.jar', '.war', '.ear', '.z',
'.zst', '.zstd'
)
# Directories whose archives are BUILD INPUTS, not incidental bloat, and so are
# exempt from ArchiveExtensions. A project that vendors a dependency as
# vendor/*.tgz (common when a bundler cannot resolve `file:` links outside the
# project root) needs that tarball in the deploy zip — dropping it makes a
# Dockerfile's `COPY vendor ./vendor` fail at image build time, which is a
# confusing way to discover the archive filter ate a required file.
$script:ArchiveKeepDirNames = @('vendor')
$script:ScriptExtensions = @('.ps1', '.cmd', '.bat')
$script:JunkExtensions = @(
'.swp', '.swo', '.swn', '.tmp', '.orig', '.rej', '.bak',
'.backup', '.old',
'.pyc', '.pyo', '.pyd',
'.tsbuildinfo',
'.log',
'.db', '.sqlite', '.sqlite3'
)
$script:JunkFileNames = @('.DS_Store', 'Thumbs.db', 'desktop.ini')
$script:JunkDirNames = @(
'.git', '.svn', '.hg',
'__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox',
'node_modules', '.npm', '.yarn', '.pnpm-store',
'.next', '.nuxt', '.svelte-kit', '.turbo', '.parcel-cache', '.cache',
'.idea', '.vscode',
'coverage', 'htmlcov', '.nyc_output'
)
# ── Config loading ───────────────────────────────────────────────────────────
$script:ZConfigCache = $null
# Where zconfig.json lives. Defaults to next to the scripts; override with the
# ZCONFIG environment variable. Useful for pointing a run at an alternate
# config, and it is the seam the test suite uses to inject a fixture.
function Get-ZConfigPath {
if ($env:ZCONFIG) { return $env:ZCONFIG }
return (Join-Path $PSScriptRoot "zconfig.json")
}
# Drop the memoised config so the next Get-ZConfig re-reads from disk. Only
# needed when the config changes mid-process (tests switching fixtures).
function Reset-ZConfigCache {
$script:ZConfigCache = $null
}
function Get-ZConfig {
if ($null -ne $script:ZConfigCache) { return $script:ZConfigCache }
$configPath = Get-ZConfigPath
if (-not (Test-Path -LiteralPath $configPath)) {
Write-Host "ERROR: zconfig.json not found at $configPath" -ForegroundColor Red
Write-Host " Copy zconfig.example.json to zconfig.json and fill in your values." -ForegroundColor DarkGray
exit 1
}
try {
$script:ZConfigCache = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8 | ConvertFrom-Json
} catch {
Write-Host "ERROR: Failed to parse zconfig.json - $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
return $script:ZConfigCache
}
# All project keys, in config order. Keys starting with "_" are comments, not projects.
function Get-ZProjectKeys {
$cfg = Get-ZConfig
return @($cfg.projects.PSObject.Properties.Name | Where-Object { $_ -notmatch '^_' })
}
function Get-ZProject {
param([Parameter(Mandatory)][string]$Key)
$cfg = Get-ZConfig
# Tolerate switch-style keys (zdeploy -myproject) from muscle memory.
$Key = $Key.TrimStart('-')
$proj = if ($Key -notmatch '^_') { $cfg.projects.$Key } else { $null }
if (-not $proj) {
$available = (Get-ZProjectKeys) -join ', '
Write-Host "ERROR: Unknown project key '$Key'. Available: $available" -ForegroundColor Red
exit 1
}
return $proj
}
# The first project of kind 'edge', or $null. Returns @{ Key; Config }.
function Get-ZEdgeProject {
$cfg = Get-ZConfig
foreach ($k in (Get-ZProjectKeys)) {
if ($cfg.projects.$k.kind -eq 'edge') {
return [pscustomobject]@{ Key = $k; Config = $cfg.projects.$k }
}
}
return $null
}
# Remote compose directory for a project: remote.composeDir if set, else remote.path.
function Get-RemoteComposeDir {
param([Parameter(Mandatory)][string]$Key)
$proj = Get-ZProject -Key $Key
if ($proj.remote.composeDir) { return $proj.remote.composeDir }
return $proj.remote.path
}
# ── SSH helpers ──────────────────────────────────────────────────────────────
function Get-Ec2Target {
$c = (Get-ZConfig).ec2
return "$($c.user)@$($c.ip)"
}
function Get-Ec2Home {
return "/home/$((Get-ZConfig).ec2.user)"
}
# Options every deploy-path ssh/scp carries. Splat with @sshOpts.
#
# BatchMode=yes is the one that matters. Without it ssh PROMPTS - for a
# passphrase, a password, a sudo password - and waits forever. The deploy pipes
# stderr into the pipeline (2>&1 | ForEach-Object) so the prompt is swallowed
# on its way to the screen: the run simply stops under whatever step label was
# printed last, with nothing to explain it and no obvious reason why that
# particular step would be slow. One deploy appeared to hang on "ensure shared
# web network", a step whose entire body is `docker network create web
# 2>/dev/null || true` against a network that already existed.
#
# There is no prompt here you would ever want to answer - a deploy key is
# unencrypted and sudo on the box is passwordless - so failing immediately is
# strictly better than waiting on input that is never coming.
#
# ConnectTimeout bounds the TCP connect. ServerAlive* bound everything after
# it, so a session that dies mid-command (dropped VPN, laptop asleep, host
# rebooting) errors out in about a minute instead of hanging indefinitely.
function Get-Ec2SshOpts {
return @(
'-n',
'-o', 'StrictHostKeyChecking=no',
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=15',
'-o', 'ServerAliveInterval=15',
'-o', 'ServerAliveCountMax=4'
)
}
# The same options for scp, which does NOT accept -n: OpenSSH's scp exits 1 with
# "unknown option -- n" and prints its usage block. That failure is easy to
# misread, because the caller's own error text is what the operator sees while
# the usage text scrolls past above it.
#
# Derived from Get-Ec2SshOpts rather than duplicated, so the timeouts can never
# drift apart between the two transports.
function Get-Ec2ScpOpts {
return @(Get-Ec2SshOpts | Where-Object { $_ -ne '-n' })
}
# Run one bash command on the server; throw on non-zero exit.
function Invoke-Ec2Step {
param(
[Parameter(Mandatory)][string]$Label,
[Parameter(Mandatory)][string]$Bash,
[string]$FailHint = ""
)
$cfg = Get-ZConfig
Write-Host " >> $Label" -ForegroundColor DarkCyan
# ssh can emit warnings on stderr (e.g. Docker's "COMPOSE_BAKE is
# deprecated" notice during a compose build). The deploy runs under
# ErrorActionPreference='Stop', and PowerShell 5.1 turns any native stderr
# line into a terminating NativeCommandError — aborting the deploy before we
# ever read the real exit code, even though the remote step succeeded. Drop
# to Continue locally (function-scoped, auto-reverts) and flatten stderr
# into normal output, so only the actual exit status decides success.
$ErrorActionPreference = 'Continue'
$sshOpts = Get-Ec2SshOpts
ssh @sshOpts -i $cfg.ec2.pemKey (Get-Ec2Target) $Bash 2>&1 |
ForEach-Object { "$_" }
if ($LASTEXITCODE -ne 0) {
$msg = "Remote step failed: '$Label' (exit $LASTEXITCODE)."
if ($FailHint) { $msg += " $FailHint" }
throw $msg
}
}
# ── Deploy git pull ──────────────────────────────────────────────────────────
# Fast-forward the project's checkout before a deploy when deploy.gitPull is set.
# zdeploy zips the working tree and does NOT otherwise pull, so after a merged
# PR the checkout can sit behind origin and the deploy would ship stale code
# while still bumping the build number (looks successful, changes nothing).
# Aborts the deploy on a failed pull rather than shipping uncertain code. Runs
# git bare (no 2>&1) and checks $LASTEXITCODE, matching Invoke-Ec2Step under
# $ErrorActionPreference='Stop'.
function Invoke-DeployGitPull {
param([Parameter(Mandatory)]$Proj)
if (-not ($Proj.deploy -and $Proj.deploy.gitPull)) { return }
$root = $Proj.localRoot
if (-not (Test-Path -LiteralPath (Join-Path $root ".git"))) {
Write-Host " gitPull set but '$root' is not a git repo - skipping pull." -ForegroundColor Yellow
return
}
Write-Host "`n--- [0] git sync (fetch + ff-only merge) ---" -ForegroundColor Cyan
Push-Location -LiteralPath $root
try {
$branch = (git rev-parse --abbrev-ref HEAD)
Write-Host " Branch: $branch" -ForegroundColor DarkGray
# Fetch explicitly, then fast-forward against the remote-tracking ref -
# not `git pull`. Pull merges whatever FETCH_HEAD marks "for merge",
# and a concurrent fetch in the same repo (an editor's background
# auto-fetch racing the deploy) can leave duplicate for-merge lines,
# killing the run with "Cannot fast-forward to multiple branches" even
# when both lines name the same commit. origin/$branch is unambiguous.
git fetch origin
if ($LASTEXITCODE -ne 0) {
throw "git fetch failed in '$root'. Check the remote, then re-run - refusing to deploy possibly-stale code."
}
git merge --ff-only "origin/$branch"
if ($LASTEXITCODE -ne 0) {
throw "git merge --ff-only origin/$branch failed in '$root'. Resolve it (commit / stash / reconcile), then re-run - refusing to deploy possibly-stale code."
}
Write-Host " Now at: $(git log -1 --oneline)" -ForegroundColor DarkGray
}
finally {
Pop-Location
}
}
# ── Build-version helpers ────────────────────────────────────────────────────
function Read-JsonBuildVersion {
param([string]$FilePath)
if (-not (Test-Path -LiteralPath $FilePath)) { return $null }
try { return Get-Content -LiteralPath $FilePath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
}
function Get-LabelFromBuildJsonObj {
param($obj)
if (-not $obj) { return $null }
# Five-segment scheme: v{major}.{rc}.{beta}.{alpha}.{build}
if ($null -ne $obj.build -or $null -ne $obj.alpha) {
$alpha = if ($null -ne $obj.alpha) { [int]$obj.alpha } else { 1 }
return "v$([int]$obj.major).$([int]$obj.rc).$([int]$obj.beta).$alpha.$([int]$obj.build)"
}
# Legacy two-part stamp (projects not yet migrated): v{productVersion}.{buildNumber}
return "v$([string]$obj.productVersion).$([int]$obj.buildNumber)"
}
# ── Deploy/backup exclude lists ──────────────────────────────────────────────
# Top-level excludes for a project archive: common junk + kind-specific dirs +
# anything the user lists in the project's deploy.exclude array.
function Get-ArchiveExcludes {
param(
[Parameter(Mandatory)]$Project,
[switch]$ForBackup
)
$common = @(".git", ".idea", ".vscode", ".claude", "tmp", "nul", ".DS_Store", "backups")
$byKind = switch ([string]$Project.kind) {
"python" {
$list = @(".venv", "venv", "__pycache__", ".pytest_cache", ".nicegui", "archive", "dist", "build", "htmlcov")
# Deploys exclude user uploads + local .env secrets (server keeps its
# own, preserved across deploys); backups keep both for completeness.
if (-not $ForBackup) { $list += @("uploads", ".env", ".env.local", ".env.production") }
$list
}
"vite" {
$list = @("node_modules", "dist")
if (-not $ForBackup) { $list += @(".env", ".env.local", ".env.production") }
$list
}
"nextjs" { @("node_modules", ".next", ".env", ".env.local", ".env.production", ".vercel", "coverage", "out", "build", "next-env.d.ts") }
default { @() }
}
$extra = @()
if ($Project.deploy -and $Project.deploy.exclude) { $extra = @($Project.deploy.exclude) }
return @($common + $byKind + $extra | Select-Object -Unique)
}
# ── Archive builder ──────────────────────────────────────────────────────────
# True when a file sits inside a directory named in $ArchiveKeepDirNames, i.e.
# its archive extension is a build input and must survive the archive filter.
function Test-InArchiveKeepDir {
param([Parameter(Mandatory)][System.IO.FileInfo] $File)
$dir = $File.DirectoryName
if (-not $dir) { return $false }
foreach ($segment in ($dir -split '[\\/]')) {
if ($script:ArchiveKeepDirNames -contains $segment) { return $true }
}
return $false
}
# Build a zip from a source directory (deploy/backup).
# - $TopLevelExclude: skip these entries at the source root
# - Archive/script/junk filters and $JunkDirNames pruning apply recursively
# - $IncludeScriptFiles: keep .ps1/.cmd/.bat (needed when backing up this repo itself)
function New-ProjectArchive {
param(
[Parameter(Mandatory)] [string] $SourcePath,
[Parameter(Mandatory)] [string] $DestinationZip,
[string[]] $TopLevelExclude = @(),
[string[]] $ExtraFiles = @(),
[switch] $IncludeScriptFiles
)
$sourceItem = Get-Item -LiteralPath $SourcePath -Force -ErrorAction Stop
if (-not $sourceItem.PSIsContainer) {
throw "Source path is not a directory: $($sourceItem.FullName)"
}
$sourceFull = $sourceItem.FullName.TrimEnd('\')
if (Test-Path -LiteralPath $DestinationZip) { Remove-Item -LiteralPath $DestinationZip -Force }
$destDir = Split-Path -Parent $DestinationZip
if ($destDir -and -not (Test-Path -LiteralPath $destDir)) {
New-Item -ItemType Directory -Path $destDir -Force | Out-Null
}
Add-Type -AssemblyName System.IO.Compression -ErrorAction SilentlyContinue
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
$topEntries = Get-ChildItem -LiteralPath $sourceItem.FullName -Force | Where-Object { $_.Name -notin $TopLevelExclude }
if ($topEntries.Count -eq 0 -and $ExtraFiles.Count -eq 0) {
throw "Nothing to archive in $sourceFull (after top-level excludes)."
}
$junkDirSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($n in $script:JunkDirNames) { [void]$junkDirSet.Add($n) }
$junkNameSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($n in $script:JunkFileNames) { [void]$junkNameSet.Add($n) }
$allFiles = New-Object System.Collections.Generic.List[System.IO.FileInfo]
$junkDirsPruned = 0
$stack = New-Object System.Collections.Generic.Stack[string]
foreach ($entry in $topEntries) {
if ($entry.PSIsContainer) {
if ($junkDirSet.Contains($entry.Name)) {
$junkDirsPruned++
continue
}
$stack.Push($entry.FullName)
while ($stack.Count -gt 0) {
$dir = $stack.Pop()
$children = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue)
foreach ($child in $children) {
if ($child.PSIsContainer) {
if ($junkDirSet.Contains($child.Name)) {
$junkDirsPruned++
} else {
$stack.Push($child.FullName)
}
} else {
[void]$allFiles.Add([System.IO.FileInfo]$child.FullName)
}
}
}
} else {
[void]$allFiles.Add([System.IO.FileInfo]$entry.FullName)
}
}
foreach ($extra in $ExtraFiles) {
if (Test-Path -LiteralPath $extra) {
$extraItem = Get-Item -LiteralPath $extra -Force
if (-not $extraItem.PSIsContainer) {
[void]$allFiles.Add([System.IO.FileInfo]$extraItem.FullName)
}
}
}
$archivesSkipped = 0
$scriptsSkipped = 0
$junkExtSkipped = 0
$junkNameSkipped = 0
$kept = New-Object System.Collections.Generic.List[System.IO.FileInfo]
foreach ($f in $allFiles) {
$ext = $f.Extension
if ($ext) { $ext = $ext.ToLowerInvariant() }
if ($script:ArchiveExtensions -contains $ext -and -not (Test-InArchiveKeepDir $f)) {
$archivesSkipped++; continue
}
if (-not $IncludeScriptFiles -and $script:ScriptExtensions -contains $ext) { $scriptsSkipped++; continue }
if ($script:JunkExtensions -contains $ext) { $junkExtSkipped++; continue }
if ($junkNameSet.Contains($f.Name)) { $junkNameSkipped++; continue }
[void]$kept.Add($f)
}
# "Including:" reflects what actually lands in the zip — top-level names
# derived from the kept files, not the pre-filter directory listing.
$srcPrefix = $sourceFull + '\'
$topSeen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$topNames = New-Object System.Collections.Generic.List[string]
foreach ($f in $kept) {
$topName = if ($f.FullName.StartsWith($srcPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
($f.FullName.Substring($srcPrefix.Length) -split '\\')[0]
} else { $f.Name }
if ($topSeen.Add($topName)) { [void]$topNames.Add($topName) }
}
if ($topNames.Count -gt 0) {
Write-Host (" Including: " + ($topNames -join ", ")) -ForegroundColor Gray
}
if ($archivesSkipped -gt 0) {
Write-Host (" Skipped {0} nested archive file(s)" -f $archivesSkipped) -ForegroundColor DarkGray
}
if ($scriptsSkipped -gt 0) {
Write-Host (" Skipped {0} local script file(s)" -f $scriptsSkipped) -ForegroundColor DarkGray
}
if ($junkExtSkipped -gt 0) {
Write-Host (" Skipped {0} junk file(s) by extension" -f $junkExtSkipped) -ForegroundColor DarkGray
}
if ($junkNameSkipped -gt 0) {
Write-Host (" Skipped {0} OS junk file(s)" -f $junkNameSkipped) -ForegroundColor DarkGray
}
if ($junkDirsPruned -gt 0) {
Write-Host (" Pruned {0} dev directory subtree(s)" -f $junkDirsPruned) -ForegroundColor DarkGray
}
if ($kept.Count -eq 0) {
throw "Nothing to archive after filters."
}
$prefix = $sourceFull + '\'
$zip = [System.IO.Compression.ZipFile]::Open($DestinationZip, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($f in $kept) {
if ($f.FullName.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$rel = $f.FullName.Substring($prefix.Length).Replace('\', '/')
} else {
$rel = $f.Name
}
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip, $f.FullName, $rel, [System.IO.Compression.CompressionLevel]::Optimal)
}
}
finally {
$zip.Dispose()
}
$info = Get-Item -LiteralPath $DestinationZip
$sizeMb = [math]::Round($info.Length / 1MB, 1)
Write-Host (" Archive: $($info.FullName) ($sizeMb MB, $($kept.Count) files)") -ForegroundColor Gray
}
# ── Process killers ──────────────────────────────────────────────────────────
# Kill a process and all of its descendants (children first). Needed for
# reloading servers (uvicorn/watchfiles, nodemon): workers inherit the
# listening socket and would keep serving stale code if orphaned.
function Stop-ProcessTree {
param([int]$TargetPid)
$killed = 0
$children = @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$TargetPid" -ErrorAction SilentlyContinue)
foreach ($child in $children) {
$killed += Stop-ProcessTree -TargetPid $child.ProcessId
}
$proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host " Killing PID $TargetPid ($($proc.ProcessName))" -ForegroundColor Red
Stop-Process -Id $TargetPid -Force -ErrorAction SilentlyContinue
$killed++
}
return $killed
}
function Stop-ListenersOnPort {
param([int]$Port)
$killed = 0
$listeners = netstat -ano | Select-String ":$Port\s+.*LISTENING"
if ($listeners) {
$seen = @{}
foreach ($line in $listeners) {
if ($line -match '\s(\d+)\s*$') {
$targetPid = $Matches[1]
if ($seen.ContainsKey($targetPid)) { continue }
$seen[$targetPid] = $true
$proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host " Killing PID $targetPid ($($proc.ProcessName)) on port $Port (and children)" -ForegroundColor Red
$killed += Stop-ProcessTree -TargetPid $targetPid
}
}
}
} else {
Write-Host " No LISTENING process on port $Port" -ForegroundColor DarkGray
}
return $killed
}
# Kill stray runtime processes (node, python, next-server) whose command line
# references the given project root. Safer than killing every node/python on the box.
function Stop-ProjectProcesses {
param([Parameter(Mandatory)][string]$ProjectRoot)
$killed = 0
foreach ($name in @("node", "python", "next-server")) {
$found = Get-Process -Name $name -ErrorAction SilentlyContinue
foreach ($p in $found) {
try {
$cmd = (Get-CimInstance Win32_Process -Filter "ProcessId = $($p.Id)" -ErrorAction SilentlyContinue).CommandLine
if ($cmd -and $cmd -like "*$ProjectRoot*") {
Write-Host " Killing $name PID $($p.Id) (references $ProjectRoot)" -ForegroundColor Red
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
$killed++
}
} catch {}
}
}
return $killed
}
# ── MOTD (message of the day) ────────────────────────────────────────────────
# If the project root has a motd/ folder of .txt files, print one on start,
# rotating through them in shuffled order. {{build_version}} is substituted.
function Show-ProjectMotd {
param(
[Parameter(Mandatory)][string]$Root,
[string]$BuildVersion = ""
)
$motdDir = Join-Path $Root "motd"
if (-not (Test-Path -LiteralPath $motdDir -PathType Container)) { return }
$motdFiles = @(Get-ChildItem -LiteralPath $motdDir -Filter "*.txt" -File -ErrorAction SilentlyContinue)
if ($motdFiles.Count -eq 0) { return }
$namesMatch = {
param([string[]]$Order, [object[]]$Files)
$a = @($Order | Sort-Object)
$b = @($Files | ForEach-Object { $_.Name } | Sort-Object)
return -not (Compare-Object -ReferenceObject $a -DifferenceObject $b)
}
$newShuffle = {
param([object[]]$Files)
return @($Files | Sort-Object { Get-Random } | ForEach-Object { $_.Name })
}
$statePath = Join-Path $motdDir ".motd_rotation.json"
$order = @()
$nextIdx = 0
$loaded = $false
if (Test-Path -LiteralPath $statePath) {
try {
$raw = Get-Content -LiteralPath $statePath -Raw -Encoding UTF8 | ConvertFrom-Json
$order = @($raw.order)
$nextIdx = [int]$raw.next_index
if ($order.Count -eq 0) { throw "empty order" }
if (-not (& $namesMatch $order $motdFiles)) { throw "file set changed" }
if ($nextIdx -lt 0 -or $nextIdx -ge $order.Count) { throw "bad index" }
$loaded = $true
}
catch { $loaded = $false }
}
if (-not $loaded) {
$order = & $newShuffle $motdFiles
$nextIdx = 0
}
$pickName = $order[$nextIdx]
$pick = $motdFiles | Where-Object { $_.Name -eq $pickName } | Select-Object -First 1
if (-not $pick) {
$order = & $newShuffle $motdFiles
$nextIdx = 0
$pickName = $order[$nextIdx]
$pick = $motdFiles | Where-Object { $_.Name -eq $pickName } | Select-Object -First 1
}
$following = $nextIdx + 1
if ($following -ge $order.Count) {
$followingOrder = & $newShuffle $motdFiles
$followingIdx = 0
} else {
$followingOrder = $order
$followingIdx = $following
}
if ($pick) {
try {
$motd = Get-Content -LiteralPath $pick.FullName -Raw -Encoding UTF8
if (-not [string]::IsNullOrWhiteSpace($BuildVersion)) {
$motd = $motd -replace "\{\{build_version\}\}", $BuildVersion
}
if (-not [string]::IsNullOrWhiteSpace($motd)) {
Write-Host ""
Write-Host $motd
Write-Host ""
}
@{
order = @($followingOrder)
next_index = $followingIdx
} | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8
}
catch { }
}
}
# ── Output tracking ───────────────────────────────────────────────────────────
$global:_ZTrackPath = $null
# Set by token-count.ps1's Invoke-Measured while a script is being timed.
# Start-ZTracking checks this so inner scripts don't replace the outer transcript.
if ($null -eq $global:_ZMeasuring) { $global:_ZMeasuring = $false }
function Start-ZTracking {
if ($global:_ZMeasuring) { return }
$tp = Join-Path ([System.IO.Path]::GetTempPath()) ("_ztrack_" + [System.Guid]::NewGuid().ToString("N") + ".txt")
$global:_ZTrackPath = $tp
try { Start-Transcript -Path $tp -NoClobber | Out-Null } catch { $global:_ZTrackPath = $null }
}
function Stop-ZTracking {
if (-not $global:_ZTrackPath) { return }
try { Stop-Transcript | Out-Null } catch {}
$tp = $global:_ZTrackPath
$global:_ZTrackPath = $null
if (-not (Test-Path -LiteralPath $tp)) { return }
try {
$raw = Get-Content -LiteralPath $tp -Raw -Encoding UTF8
$lines = @($raw -split "`n")
$si = 0
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^Transcript started') { $si = $i + 1; break }
}
$ei = $lines.Count
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
if ($lines[$i] -match '^\*{4}') { $ei = $i; break }
}
$body = if ($ei -gt $si) { @($lines[$si..($ei - 1)]) } else { @() }
$text = $body -join "`n"
$lc = ($body | Where-Object { $_.Trim() -ne "" }).Count
$cc = $text.Length
$tok = [math]::Round($cc / 3.5)
Write-Host ""
Write-Host ("--- {0:N0} lines / {1:N0} chars / ~{2:N0} tokens est. (Claude Code) ---" -f $lc, $cc, $tok) -ForegroundColor DarkGray
} catch {}
Remove-Item -LiteralPath $tp -Force -ErrorAction SilentlyContinue
}