Skip to content

v0.8.24

Choose a tag to compare

@fcreme fcreme released this 10 Sep 23:45
· 236 commits to main since this release

Fixed

  • tstyles uninstall silently corrupted a $PROFILE that contained a byte which is not valid UTF-8, and took no backup on the way out. Step 3 of uninstall reads the whole of the user's $PROFILE and writes the whole of it back, exactly as the rc half does -- and it did so through UTF-8. Get-RcFileEncoding exists because of what that costs, and its own docstring spells it out: a latin-1 comment or a stray byte from an old editor decodes to U+FFFD and is written back as the replacement character, so "the user's own content, silently and permanently corrupted, by a tool that was only asked to append three lines". That was fixed for .zshrc and .bashrc; the $PROFILE half was a second, open-coded copy of the same removal and never got it. Measured on a profile whose first line was # café, byte e9:

    The profile is now imported once and reused: a window is opened on the EXISTING settings set through AppleScript rather than by re-importing the file. That is the whole fix for the accumulation, but on its own it would have traded a duplicate for something worse -- a re-tuned style would open a window in the colours it no longer has, because the installed copy is not refreshed by regenerating the file. So an import record (.appleterminal-imported.json, the same idea as .installed-files) maps each profile name to the SHA256 of the .terminal file that produced it: unchanged, the installed profile is reused; changed, TerminalStyles deletes ITS copy and re-imports, which also keeps the new import from colliding with a name and being numbered.

    The record is what makes the delete safe. Without one there is no way to tell a settings set this tool created from one the user made and happened to name after a style, and deleting the wrong one cannot be undone -- so a profile with no record is never touched, and gets the old numbered-copy behaviour instead. Every path falls back to open when Terminal.app cannot be asked, so nothing here can make the feature worse than it was.

    Changes go through AppleScript rather than by writing ~/Library/Preferences/com.apple.Terminal.plist: Terminal.app holds its preferences in memory and rewrites that file when it quits, so an external write to a running Terminal is silently reverted.

  • tstyles uninstall threw before it could ask for consent, on any machine, because two lines were lost in a merge. $wezSplat = @{} and the ContainsKey('HomeDir') line beside it were dropped resolving the merge between the WezTerm writer and the $PROFILE-strip change -- both touched the same few lines at the top of Invoke-TerminalStylesUninstall -- while both USES of @wezSplat survived further down. main went red on the merge commit.

    The mechanism is worth recording, because it is the opposite of what everything in this project assumes. PowerShell does not object to splatting a variable that does not exist, and it does not pass "no arguments" either:

    function Probe { param([string]$HomeDir) ... }
    Probe @neverDefined   ->   bound=True   value=[]
    

    It binds the FIRST POSITIONAL parameter to an empty string, and $PSBoundParameters.ContainsKey('HomeDir') reports $true. That ContainsKey test is the guard this codebase uses everywhere to tell "the caller asked for a sandbox" from "use the live $HOME" -- so a lost splat definition does not turn the seam off, it announces a sandbox at the empty path. It fails unsafe. Here it happened to throw on Join-Path, which is why five tests went red rather than something quietly resolving against the wrong home; that was luck.

    tests/Splat-Variables-Defined.Tests.ps1 now walks the AST of every source file and fails if any scope splats a variable it never assigns. It names the offending function and variable, and it catches this exact regression when the fix is reverted.

  • tstyles shell-init deleted the user's own lines out of ~/.zshrc, reported updated in cyan, and left nothing to get them back from. The refresh path matches BEGIN .*? END with Singleline and writes the loader block over whatever it matched. A lazy .*? is lazy in the END, not in the BEGIN: the match still starts at the FIRST BEGIN in the file and runs to the first END anywhere after it. So an rc file carrying a stray or duplicated BEGIN -- a hand edit, a merged dotfile, an interrupted write, the inputs Unregister-ShellLoader's own docstring already names as things it must survive -- had every one of the user's lines between that marker and the real block replaced by our three. Measured on a .zshrc whose stray marker sat above export MY_API_KEY=... and export PATH="$PATH:/opt/mine": both gone, and no .zshrc.bak-* anywhere on disk. That second half is not an oversight: Save-FirstTouchBackup is called on the APPEND branch, and skips a file that already carries our block on purpose, because a copy taken then would capture a file that carries it. Which left the one path in this project that can destroy content the user wrote as the one path that never takes a copy.

    And it was never one command. Unregister-ShellLoader builds the same span, so tstyles shell-remove -- and tstyles uninstall, which strips the rc files and both $PROFILE files through it as of the entry below -- deleted those same lines and returned removed. Its malformed guard cannot catch this input: $stripped -eq $content fires only when there is no END ANYWHERE, and a stray BEGIN above a well-formed block matches perfectly well. tstyles register and install.ps1 keep the third and fourth copy of the pattern, for the $PROFILE, where the strip replaces with NOTHING rather than with the block -- so there the lines were not overwritten but simply removed, on a branch that takes no backup for the same first-touch reason. All four spans are now TEMPERED: they may not cross a second BEGIN, so the match fails at the stray marker, starts again at the real one, and the only bytes that can change are the ones between two markers this tool wrote. The stray marker is left exactly where it was, because it is the user's line now.

  • one style with a malformed scheme.json killed bare tstyles outright for every zsh and bash user, and quietly showed everyone else the PREVIOUS style's colours in its place. The picker pre-loads every style's scheme before it draws the menu, and that read was the one parse left with nothing around it: tstyles list has degraded a bad folder to an (unreadable scheme.json) row since 0.8.10, and the theme.json pre-load twelve lines below this one is inside a try/catch. Get-AvailableStyles admits a folder on scheme.json EXISTING, never on it parsing, so a hand-authored style with a JSON typo -- README invites them in that directory -- or a tstyles tune save killed mid-write (Save-TunedStyle writes the file with a plain, non-atomic WriteAllText) arrives at that loop as an ordinary entry. What it cost then split on $ErrorActionPreference, and both halves are ordinary:

    • Stop, which is what the generated tstyles-cli.ps1 sets and what shell/tstyles.sh runs for every tstyles call from zsh and bash: there ConvertFrom-Json's error is fully terminating, so the flagship command -- the one the README leads with -- died with a raw Conversion from JSON failed with error: Unterminated string, naming a line of tstyles.ps1 and never the style, and drew no menu at all, on every invocation until the folder was found and repaired by hand. tstyles list in the same state kept working and even flagged the culprit, which made the picker's death look like an unrelated bug.
    • Continue, pwsh's default: the error is only statement-terminating, so the loop carried on -- with $scheme still holding the previous style's object. The broken style took the previous style's swatch, the previous style's live OSC preview, and on Enter the previous style's palette written into current-style.osc under the broken style's name, which every new zsh/bash tab then replays. Measured: picking mytheme staged marquee's palette verbatim (fg #f0c8d8, bg #000000, cursor #ff4090) while current-style.json recorded "name": "mytheme", with nothing on screen after the first red block to say so.

    The parse now happens exactly once, in Get-PickerStyleSet, and a style that fails it is not in the picker's set at all. $idx, $startIdx, $swatches, $schemes, $titles, $oscPackets and $mergedCache are all keyed by position, so keeping the entry with a null scheme only moves the crash into the draw path -- which is what a malformed style sorting FIRST already did, as Cannot bind argument to parameter 'Scheme' because it is null on the picker's own first frame. Dropping it in silence would be the other half of the same defect, so the menu carries a line naming what is missing and why, inside the frame: everything printed above the menu is wiped by the picker's own Clear-Host, which is why the update notice is held back too. An empty scheme.json -- the other shape a truncated write leaves -- counts as unreadable rather than as a scheme, since ConvertFrom-Json returns $null for it on pwsh 7 instead of raising, and "it did not throw" was never the test.

  • Esc stopped re-emitting a style the user was never in. "Did the user arrive in a style" was [bool](Get-CurrentStyleName), but that name can be one the menu does not carry -- a style whose scheme.json just failed the parse above, or one recorded and since deleted -- and the revert then re-emitted index 0, which is some OTHER style's palette, while telling the user it was putting back what they had. It is now the index or nothing, and an unknown one falls back to handing colour control to the terminal, as it already did for a session that arrived unstyled.

  • every style deleted the user's own PSReadLine key bindings, on Windows, on every new tab. All sixteen styles/*/profile.ps1 called Set-PSReadLineOption -EditMode Windows behind a Windows-only guard. The comment above it in all sixteen files, and 0.8.21's CHANGELOG entry, both said this was costless because Windows mode is already the default there. Both were wrong. Supplying -EditMode at all makes PSReadLine throw away its dispatch tables and rebuild them from that mode's defaults -- the value is never compared against the current mode -- so every Set-PSReadLineKeyHandler the user had made in that session was deleted. Measured on PSReadLine 2.4.5 with the session already in Windows mode, so the call changed nothing:

    start                    : Alt+j=CustomAction; Ctrl+w=ForwardWord   total=65
    after other shipped calls: Alt+j=CustomAction; Ctrl+w=ForwardWord   total=65
    after -EditMode Windows  : Ctrl+w=BackwardKillWord                  total=63
    

    The module is imported from the END of $PROFILE, so the style always ran after the user's own bindings and the user always lost. 0.8.21 fixed the Unix half of this same statement -- where the switch additionally unbinds Ctrl+D, Ctrl+U, Ctrl+E and Ctrl+K -- and left the Windows half standing on the strength of that comment.

    The statement is DELETED rather than guarded. On Windows it has exactly two reachable effects: erase the keymap and change nothing else, or erase the keymap while overriding a Vi or Emacs mode the user deliberately chose. A read-then-write guard removes only the first and leaves the second, which is the case a colour theme has least business touching. The other three Set-PSReadLineOption calls in those files -- -PredictionSource, -PredictionViewStyle and -Colors -- were measured to leave all 65 handlers intact and are unchanged.

    The lint that was supposed to guard this matched the file's TEXT, and the shipped comment satisfied it; the replacement walks the AST for a Set-PSReadLineOption carrying an -EditMode parameter, and is joined by a runtime test that binds a key in a child engine, dot-sources the style, and asserts the binding survived.

  • one failed file copy during an install or update deleted the install, and the message said nothing had changed. Sync-InstallTree ran a removal loop over every install-owned entry to completion, and only then a copy loop. install.ps1's main body runs under $ErrorActionPreference = 'Stop' inside a try/finally with no catch, so the FIRST failing Copy-Item terminated the installer with the module already deleted. Assert-InstallLanded, whose entire job is to say "the module is not there", never ran, because the throw precedes it -- and so did Write-InstallManifest, leaving .installed-files stale against the tree on disk, which is what uninstall reads to decide what it owns.

    A full disk, or an antivirus holding a freshly-extracted file open, was enough. The data root came out with no TerminalStyles.psd1 while the $PROFILE loader still pointed at it, so every new tab opened on a red "no valid module file was found" with no tstyles command -- from a session that had been working seconds earlier, and after tstyles update had printed "Update failed: . You can retry manually", which reads as "nothing was changed". Measured against the shipped function with the source file locked: TerminalStyles.psd1 was gone from the install directory afterwards.

    Everything is now copied into a staging directory FIRST, and only then swapped in by rename. The long, failure-prone half -- reading the download and writing bytes to a volume that can fill up -- is entirely non-destructive; what is left destructive is renames, which read nothing. Staging is a sibling of the install directory so the swap stays on one volume, at the cost of both trees occupying it at once: a disk too full for both now fails during staging, with the working install still in place, which is the direction to fail in. The function also pins $ErrorActionPreference locally, because staging is worth nothing if a failed copy does not stop.

    The two failure phases now say different things, because "your install is fine, retry" and "your install is half-replaced" are opposite instructions and the caller prints the same "You can retry manually" under both. A staging failure says Nothing in '<dir>' was removed or replaced.; a swap failure says the install may now be incomplete -- re-run the installer to finish it. The swap is not atomic and is not claimed to be.

  • a tune.json the tuner could not read dropped the style's recorded adjustments and said nothing, and saving from there severed its lineage for good. Resolve-TuneSeed reports BaseMissing when the base is named and gone, and BaseChanged when it is there and re-baked, so the tuner can explain why the knobs are not where the user left them. A tune.json that could not be USED took neither flag: a zero-length or whitespace-only file, a bare scalar or array, {}, a base written as null or "", a delta that is not a number, a file that is not JSON at all, and a read that threw on a lock or a permission denial all landed in an empty catch or fell past the last if. The tuner opened on brightness 0 / saturation 0 as though the style had never been tuned, with every notice flag at its default.

    The loss then became permanent on the next save: Save-TunedStyle's if (-not $LineageBase) { $LineageBase = $BaseName } writes the style as its OWN base, and Get-TunedBaseBackground's self-reference guard returns $null for ever after. It is the same silence the deleted-base and drifted-base notices were added to close, arriving through a third door.

    TuneUnusable is that third state rather than a shade of the other two, because there is no base name to quote and no lineage to carry forward -- reusing the BaseChanged branch would have printed '' is gone. The tuner says so before a key is pressed, and says the harder part out loud: saving now records this style as its own base. The catch that swallowed the whole class for the life of the seed object now sets the flag rather than nothing.

    Narrower than first reported, and worth recording accurately: opacity and font come back CORRECTLY in this state, because the fallback reads the style's own theme.json, which the tuner itself wrote, and the tuned colours are already baked into scheme.json. What is actually lost is the record of the two deltas, and then the lineage.

  • applying a style could delete a background the user had set themselves, decided by the directory the shell happened to be in. Test-ManagedBackgroundPath is the ownership proof in front of a destructive edit to settings.json, and it answered with [System.IO.Path]::GetFullPath($Path).StartsWith(<root>). GetFullPath resolves a non-rooted string against the PROCESS WORKING DIRECTORY, so with the process started inside the module root, the data root, or a ...\Modules\TerminalStyles parent, Windows Terminal's own desktopWallpaper keyword normalised to <cwd>/desktopWallpaper, matched the root, and was reported as ours. Merge-StyleIntoSettings then took bgAction='remove' and stripped backgroundImage along with its opacity, stretch mode and alignment. README promises the opposite in as many words, and the comment inside the function stated the false premise verbatim -- that a WT keyword "resolves against the CWD and so never lands under one of our roots".

    apply.ps1's own usage header documents cd TerminalStyles; pwsh -File .\apply.ps1 ..., and a child pwsh inherits the parent's location as its working directory, so this is reachable exactly as written.

    Every background this module writes is Join-Pathed onto an absolute root, so a value that is not an absolute path is one it could not have written. The function now says so before consulting the roots, which covers the keyword, a relative path and an environment-variable form like %USERPROFILE%\Pictures\bg.png in one rule -- and a second line rejects the Windows drive-relative C:bg.png, which IsPathRooted calls rooted but which resolves through that drive's own current directory. Environment variables are deliberately NOT expanded: expanding would invent a path on which the tool then deletes the user's data, to buy nothing, since it never writes that form.

  • under set -u the shell runtime printed unbound-variable errors on every prompt and applied no style at all. shell/tstyles.sh is sourced from the user's rc file on EVERY interactive shell, so it is the one file in this project guaranteed to run for every zsh and bash user -- and it read its ambient variables bare. With nounset set, which plenty of people put in their rc, sourcing it failed at the first read and never defined ts_c. Measured in a real bash under set -eu: TSTYLES_DATA: unbound variable, then ZSH_VERSION, _ts_shell twice, _ts_loaded, then ts_c: command not found three times, the banner printing anyway, and the shell falling back to bash's own default PS1 -- eleven lines of error per tab, and no style. Every bare ambient read now uses the POSIX default-expansion form.

    $HOME is deliberately left bare. bash does not synthesise it when the environment lacks one, so ${HOME-} would turn a loud failure into the silently wrong absolute path /Library/Application Support/TerminalStyles -- and a bash with no HOME cannot find the ~/.bashrc that sources this file in the first place, so the loader never runs on that path. A loud failure on an unreachable path beats a silent wrong answer.

    $TS_GIT_OPEN / $TS_GIT_CLOSE were hardened although only gitbash defines them and it defines them first: the runtime owns {GITBRANCH} while one style owns its colours, which is the two-implementations-of-one-rule shape this file keeps being bitten by. An uncoloured branch name is a better answer than an error inside the prompt of every command.

    set -e was measured and is NOT a problem, before the fix as well as after: the path already guards with [ -t 1 ] || return 0, _ts_b=$(...) || return 0 and an explicit return 0 after a case whose body can end false. The only errexit interaction that mattered was combined with nounset, and the new tests assert under set -eu rather than set -u alone.

  • running the test suite rewrote the shell shim inside the developer's own TerminalStyles install, and on a bootstrap install that left every new zsh tab without a working tstyles. tests/FirstTouch-Backup.Tests.ps1 passed -HomeDir and nothing else. -HomeDir sandboxes the rc lookup; Invoke-TerminalStylesShellInit reaches Sync-ShellRuntime before it registers anything, and that stages shell/tstyles.sh and regenerates tstyles-cli.ps1 under the DATA ROOT -- the path -HomeDir does not reach, and the one function CLAUDE.md names as having no seam at all. Four calls in that file, four rewrites of the operator's live state per run. Which import line the shim gets comes from Get-TerminalStylesInstallKind, which asks whether the module root IS the data root; under the tests the module root is the working tree, so it always answers PSResourceGet and writes the by-name Import-Module TerminalStyles. A bootstrap install is not on $env:PSModulePath -- the entire reason the generated shim bakes in an absolute path -- so the next tstyles from zsh died with "The specified module 'TerminalStyles' was not loaded because no valid module file was found in any module directory". That is the 0.8.21 defect verbatim, delivered this time by a green 7/7 test run rather than by register. It has already fired on the machine this was found on: the installed tstyles-cli.ps1 there is byte-for-byte the file the test produces, on a bootstrap install whose correct shim carries the absolute path, and tstyles from zsh had been silently running 0.8.21 out of a stale PSGallery copy ever since.

    tests/Merge-StyleIntoSettings.Tests.ps1 was going through the same hole by a different door. Its fixture style is named eva, and merging a style with no -BackgroundImage makes the merge resolve one itself, whose third tier lazy-FETCHES from the gifs branch into <data root>/cache/<name> -- so an ordinary suite run downloaded the real 3 MB eva.gif into the operator's cache. Both files now sandbox the data root the way the siblings calling the same entry points already did.

    The guard itself had the defect it exists to catch, and it was found by reverting the fix and watching it report green. Its baseline sat in the guard file's own BeforeDiscovery, on the documented Pester 5 behaviour that every container is discovered before any is executed. Pester 6 does not do that: it discovers and executes each file in turn, so a guard file that sorts LAST photographed the state AFTER every other file had already run. The baseline contained the damage and the comparison compared it against itself. .github/workflows/test.yml installs Pester with [5.0.0,), so CI legs run whichever major is current. The baseline now lives in tests/Aa-RealStateBaseline.Tests.ps1, which sorts first and asserts that it does, and the guard fails the run naming the file and the timestamps when anything under the data root, the rc files or $PROFILE moves.

  • tstyles <style> -NewWindow added a permanent Terminal.app profile every single time it ran. Open-AppleTerminalProfile was & open $Path, and open on a .terminal file does not UPDATE a profile of the same name -- it imports a second one, which Terminal.app resolves by appending a number. So the command documented as "a new window with the background image as well" also grew the user's profile list, silently, once per run, for the life of the install. Found on a machine with nine of them: eva, eva 1, eva 2, eva 3, umbrella, umbrella 1, felitest, neon-rain, shorty -- four evas from four -NewWindow runs. Nothing deduplicated, nothing updated in place, and tstyles uninstall does not remove them either, because it promises in as many words not to touch Terminal.app's settings. Neither did anything mention that the command wrote to Terminal.app's profile list at all.

  • the guard that every theme's swatch is distinct compared zero pairs of themes, and reported green for its whole life. $themeNames was built in the file-level BeforeDiscovery, which Pester runs in the discovery scope, so inside an It body it is $null. @($null) is a ONE-element array, so the outer loop ran a single iteration and the inner for ($j = 1; $j -lt 1; ...) never entered. InModuleScope was handed the same $null, so the signature table came back empty and no swatch was ever computed. Instrumented on the unmodified file: themeNames is null? True, @(themeNames).Count = 1, signatures.Count = 0, pairs actually compared = 0, and a green tick.

    The re-enumeration is the repo's existing pattern for this, but on its own it would only have fixed half the defect: if the names were right and the InModuleScope handoff were broken, every lookup returns $null, every pair "collides", and the test fails with 120 bogus collisions rather than saying no swatch was computed. So the guard is three assertions -- that enough themes reached the run phase, that a signature exists for every one of them, and that the number of pairs compared is the number the loop bounds imply.

Added

  • tstyles profiles, which lists the Terminal.app profiles named after a style and, with -Clean, deletes the numbered duplicates left by every -NewWindow before the fix above. It removes ONLY the numbered ones: the unsuffixed eva may be a profile the user made themselves, and nothing can prove otherwise, while eva 3 is a suffix Terminal.app appends on an import collision and so can only have come from a repeated import. The listing names every profile it will delete before asking -- the rule the uninstall consent listing had to learn in 0.8.23 -- and touches nothing else, including the default profile and any window already open.

Changed

  • the header of tests/Get-SchemeSwatch.Tests.ps1 claimed that comparison catches "all themes look like rainbows". It does not, except in the degenerate byte-identical form: restoring the historical fixed-hue candidate list in Get-SchemeSwatch leaves every theme rendering its OWN brightRed, so the bytes differ while the rows look alike, and the fixed test stays green. The property that actually protects the picker is that the first cells are the theme's own background, foreground and cursor -- the candidate ORDER -- and nothing asserts it. The claim is corrected rather than the test stretched to cover it; the production code is not defective here, and the assertion is true as named.

  • uninstall's $PROFILE half is testable for the first time. Its paths come from RUNNING each engine (& $cmd.Source -Command 'Write-Output $PROFILE'), so the only profile any test could reach was the one belonging to whoever was running the suite -- which is why none of the three $PROFILE defects above was ever caught, the same shape as the rc omission fixed in 0.8.23. Discovery is now Get-PowerShellProfileTarget and the strip is Remove-PowerShellProfileLoader -Target, an internal injection of the same shape and purpose as Invoke-TerminalStylesRegister -Targets; nothing in the new tests goes near a real $PROFILE.

  • uninstall no longer processes one $PROFILE twice. pwsh and pwsh-preview report the same path on a macOS machine that has both, so a malformed or unwritable profile would now have printed its warning once per engine for a single file. Windows' two engines keep separate directories and nothing merges there.

  • the test that was supposed to guarantee uninstall used the shared engine probe asserted that the text of Invoke-TerminalStylesUninstall contained the string Get-PowerShellEngineCandidate. That is the same kind of test 0.8.22 already caught being worthless once -- "sharing a helper name is not symmetry, and it passed for the whole life of the bug" -- and it broke on a refactor that moved the call without changing a single behaviour. It now follows the discovery function, asserts that the caller actually reaches it, and checks behaviourally that every discovered target carries a label the probe issued.

  • the suite now fails the RUN when a test has written to the operator's own files, rather than waiting for someone to notice their prompt is gone. Every seam that keeps a test out of them -- -HomeDir, -ZDotDir, -Targets, $script:TStylesDataRoot -- is OPT-IN, so a test that forgets one is silently correct: it passes, prints nothing, and edits the machine running it. That is three times now, counting 0.8.22's ZDOTDIR leak into a real .zshrc and the two above, and fixing each of them fixes one file. tests/Zz-RealStateUntouched.Tests.ps1 photographs the real data root, the rc files Get-ShellRcRemovalCandidate itself names and both $PROFILE paths in BeforeDiscovery -- Pester discovers every container before it runs any, so that is the earliest moment in a run -- and compares them after the last test, naming every path that was created, changed or removed. It asks the module which rc files those are rather than listing them again, because a second list is a second answer, and it proves its own probe can see a write (including a dotfile, which Get-ChildItem without -Force cannot) rather than assuming it. The name sorts last on purpose and one of its tests asserts that it still does: a guard that quietly stops covering the files added after it is worse than no guard. Windows Terminal's settings.json and Terminal.app's plist are deliberately outside it -- the terminal rewrites those on its own schedule, and a check that fails for something no test did is one that gets deleted.

Full changelog: v0.8.23...v0.8.24