zscripts-token-savers/ZHelpers.ps1
Kelly Michels 6c30f400ad
feat(zdeploy): sync deploy hardening from the private toolkit (#53)
Nine fixes that had accumulated only in the private copy. Each one is a
production failure that already happened:

  * ssh -n on the deploy path. Without it ssh reads its stdin, inherits
    the console handle under PowerShell, and can block forever. The
    existing timeouts cannot catch it - they bound a connection that is
    dying, and this one was never established. Get-Ec2ScpOpts derives
    from the same list minus -n, because scp rejects it with a usage
    error that reads like an unrelated failure.

  * Invoke-DeployGitPull now switches TO the default branch instead of
    pulling whatever is checked out. Pulling the current branch breaks
    as soon as the remote deletes branches on merge, and worse, could
    ship a feature branch to production. Refuses over local changes,
    excluding the files the deploy itself stamps.

  * nextjs handler resolves composeDir. It ran compose against
    remote.path, so a project whose compose lives in a subdirectory
    recycled whatever docker-compose.yml sat at the project root -
    usually the local dev one shipped in the same archive. Two deploys
    in a row exited 0 having shipped nothing, and verification passed
    because the untouched old container still answered.

  * BUILD BEFORE DOWN. The old order took the site offline for the whole
    build and left it offline if the build failed - one deploy served
    502 for 19 hours with no container running. The old image now keeps
    serving until the new one is ready.

  * compose build takes the named service, so a compose file that does
    not define it fails loudly instead of succeeding with nothing to do.

  * deploy.verifyHost overrides domain for verification only, for when a
    domain is retired ahead of its replacement.

  * deploy.stampCmd writes the build version from git before zipping,
    then reverts the tree so the stamp cannot block the next deploy.

  * Optional ztokens pseudo-project and passive usage records. Both
    degrade to a printed skip when no sibling ztokens checkout exists.

Sanitized on the way across, per the public-repo rule: the war stories
that make these comments worth reading are kept, the private product
names, domains, internal script names, outage dates and host figures are
not. Also dropped a "See issue #28" pointer that resolves to an
unrelated PR in this repo, and two references to scripts that exist only
in the private toolkit.

CHECKSUMS.txt refreshed alongside, per the manifest rule.

Tests: 222/222 (the 4 failures before the refresh were the checksum
suite correctly flagging both edited files).
2026-08-20 11:44:42 -05:00

751 lines
34 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, which is a confusing
# way to discover the archive filter ate a required build input.
$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.
# zec2online.ps1 and zsetup_mail.ps1 already set ConnectTimeout; the deploy
# path, the one place a hang costs the most, set none of them.
#
# -n is the one that fixes the hang these options did NOT catch. Without it ssh
# reads its stdin and forwards it to the remote command, and under PowerShell it
# inherits the console handle - so it can block forever waiting on input nobody
# is going to type. The timeouts above cannot help: they bound a connection that
# is dying, and this one was never established. One deploy stopped under
# "ensure unzip installed", a step whose body short-circuits when unzip is
# already present; the server showed no ssh session at all (`who` empty, no
# docker build running), which is what a client-side stdin block looks like
# from the other end. The zip had uploaded and prod stayed a release behind.
#
# Safe here because nothing that pipes stdin INTO ssh uses these options:
# zdeploy passes only command strings. Any script that DOES pipe into ssh must
# build its own option array - adding -n to those would break them, so do not
# hoist this beyond the deploy path.
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 - one deploy reported "Likely server disk
# space" on a box with plenty of room.
#
# 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 ──────────────────────────────────────────────────────────
# Put the project's checkout ON the default branch and fast-forward it 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).
#
# Deploys ship the default branch, so this SWITCHES to it rather than pulling
# whatever branch happens to be checked out. The old behaviour pulled the
# current branch, which breaks as soon as the remote deletes branches on merge:
# a checkout still sitting on its just-merged PR branch pulls a ref the merge
# deleted, and the deploy dies on "no such ref was fetched". Worse, when the ref
# DID still exist, pulling the feature branch meant a deploy could ship a
# branch rather than the default.
#
# The switch refuses to run over local changes: a dirty tree aborts the deploy
# with the file list rather than risk tangling uncommitted work. The stale
# branch is left in place for the operator to delete - under squash merges
# only a content diff can prove it safe, and a deploy is not the place to
# make that call.
#
# 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 default branch ---" -ForegroundColor Cyan
Push-Location -LiteralPath $root
try {
# Same PS 5.1 trap Invoke-Ec2Step documents: under the deploy's
# ErrorActionPreference='Stop', a stderr REDIRECT on a native command
# (the 2>$null on symbolic-ref below) wraps stderr lines in
# terminating ErrorRecords. Success is judged by $LASTEXITCODE
# throughout, so drop to Continue (function-scoped, auto-reverts).
$ErrorActionPreference = 'Continue'
git fetch origin --prune
if ($LASTEXITCODE -ne 0) {
throw "git fetch failed in '$root'. Check the remote, then re-run - refusing to deploy possibly-stale code."
}
# Ask the remote which branch is the default rather than assuming
# "main" - older repos or mirrors may differ.
$default = (git symbolic-ref --short refs/remotes/origin/HEAD 2>$null) -replace '^origin/', ''
if (-not $default) {
git remote set-head origin --auto | Out-Null
$default = (git symbolic-ref --short refs/remotes/origin/HEAD 2>$null) -replace '^origin/', ''
}
if (-not $default) { $default = 'main' }
$branch = (git rev-parse --abbrev-ref HEAD)
if ($branch -ne $default) {
# Tracked modifications only. Untracked files cannot be tangled
# by a branch switch, and zdeploy has always shipped them (the
# zip takes the working tree) - blocking on them would abort
# every deploy over stray local files.
$dirty = git status --porcelain --untracked-files=no
# Files the DEPLOY itself writes are excluded. zdeploy stamps the
# bumped build version (and appends the changelog) into the working
# tree after every successful run, so leaving them in scope made
# each deploy block the next one - the operator had to commit or
# stash a change they never made. The guard exists to stop
# unreviewed SOURCE shipping; a stamp the script just wrote is not
# that. It is still committed separately, one bump per PR, per the
# versioning rule - this only stops it being a gate.
$deployWritten = @('build-version.json', 'CHANGELOG.md')
$dirty = $dirty | Where-Object {
$path = ($_ -replace '^..\s+', '') -replace '^.*/', ''
$deployWritten -notcontains $path
}
if ($dirty) {
$files = ($dirty | ForEach-Object { " $_" }) -join "`n"
throw "Checkout is on '$branch' with local changes:`n$files`n Deploys ship '$default'. Commit or stash, then re-run."
}
Write-Host " On '$branch'; deploys ship '$default' - switching." -ForegroundColor Yellow
git checkout $default
if ($LASTEXITCODE -ne 0) {
throw "git checkout $default failed in '$root'. Resolve it, then re-run."
}
Write-Host " Stale branch '$branch' left in place - delete it once you've confirmed it merged." -ForegroundColor DarkGray
}
# Fast-forward against the remote-tracking ref, not `git pull`. The
# fetch at the top of this function already brought origin up to date,
# so pull's own fetch was redundant - and it was also the failure
# point: 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/$default has no such ambiguity.
git merge --ff-only "origin/$default"
if ($LASTEXITCODE -ne 0) {
throw "git merge --ff-only origin/$default 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 }
# Remember who is being tracked so Stop-ZTracking can log the run (ztokens).
try { $global:_ZTrackScript = [System.IO.Path]::GetFileNameWithoutExtension((Get-PSCallStack)[1].ScriptName) } catch { $global:_ZTrackScript = "" }
try {
$bp = (Get-PSCallStack)[1].InvocationInfo.BoundParameters
$global:_ZTrackProjects = (@($bp['Projects']) -join ',')
} catch { $global:_ZTrackProjects = "" }
$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 }
}
# Append this run to the ztokens data store (passive usage stats). Uses
# $env:ZTOKENS_DATA, else the sibling ..\ztokens\data directory. Silently
# no-ops when neither exists, so setups without ztokens are unaffected.
function Add-ZTokensRecord {
param([int]$Lines, [int]$Chars, [int]$Est)
try {
$dir = $env:ZTOKENS_DATA
if (-not $dir) { $dir = Join-Path (Split-Path -Parent $PSScriptRoot) "ztokens\data" }
if (-not (Test-Path -LiteralPath $dir)) { return }
$model = $env:ZTOKENS_MODEL
if (-not $model) { $model = "est. chars/3.5" }
$rec = @{
ts = (Get-Date).ToString("o")
script = [string]$global:_ZTrackScript
projects = [string]$global:_ZTrackProjects
lines = $Lines
chars = $Chars
est = $Est
model = $model
}
Add-Content -LiteralPath (Join-Path $dir "tokens.jsonl") -Value (ConvertTo-Json -InputObject $rec -Compress) -Encoding UTF8
} catch { }
}
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
Add-ZTokensRecord -Lines $lc -Chars $cc -Est $tok
} catch {}
Remove-Item -LiteralPath $tp -Force -ErrorAction SilentlyContinue
}