Take over a hotel front-desk PC, install a PMS client, set up an ID card reader driver, clear a stuck print queue. I have done this many times. Every time it was the same: hunt for the driver, transfer the file, wait for the download, click Next, discover a missing runtime, start over.

tbx compresses all of that into one command:

Terminal window
irm https://ext.turinghost.org/r/<token> | iex

Press enter and you get an interactive menu — type a number and it installs. Or skip the menu and name things directly:

Terminal window
& ([scriptblock]::Create((irm <authorized-link>))) xms-env 7zip ihotel

What it does

CategoryContents
InstallPMS (iHotel / THEF / XMS), ID card reader drivers, printer drivers, Adobe AIR runtime, common software
ToolsClear system cache, flush print queue and restart the spooler, Windows Installer cleanup
OtherCollect machine info (model / OS / disks / network / connectivity / error logs)

Four principles run through all of it:

Detect before installing — check whether it’s already present and whether the version is good enough. If it is, skip explicitly rather than reinstalling over someone’s configuration.

Automatic multi-source fallback — every package has backup sources. If the first source fails or stays too slow, it switches; it only errors out when all of them fail, and never skips silently.

Integrity verification — every package has a SHA256 checksum. A mismatch means re-download or switch sources.

Dependencies handled automatically — installing an .air-based PMS pulls in the Adobe AIR runtime first.

Adding software = adding a block of JSON

Everything goes through one pipeline. The differences live entirely in manifest.json as data — no new scripts:

Invoke-TbxItem 'xms'
→ Test-Installed Already there? Skip (printed explicitly, never silent)
→ Resolve-Deps Dependencies first (.air pulls the AIR runtime)
→ Get-TbxFile Multi-source download + hash check + slow-source switching
→ Install-TbxPackage Silent install by type (exe/msi/air/zip)
→ New-TbxShortcut Create shortcuts where needed
→ Remove-TbxTemp Clean up downloaded dependencies
→ Write-TbxLog Record what happened

Built-in actions work the same way: the action field in the manifest is the function name. The main program dispatches through Get-Command, and the build step verifies that action, file, and function name all agree — a mismatch aborts the build rather than surfacing as “unknown action” on a customer’s machine.

Design rule: probe for capabilities, not version numbers

Customer machines vary enormously: Win7 through 11, Home through Enterprise, Chinese and English. Branching on version numbers means combinatorial explosion plus localized strings to match against:

Terminal window
# ✗ Don't: version combinations explode, and localization bites
if ($os.Caption -match 'Home') { ... }
# ✓ Do: one check covers every version
if (Test-TbxCmd 'Set-DnsClientServerAddress') { new path } else { netsh }

Only check versions for capabilities genuinely determined by version (Group Policy needs Pro or above, for instance). Most Windows features are the same underneath, so there’s nothing to branch on.

Cold start: 8.5 seconds → 2 seconds

The script originally loaded as separate files — six fetches for lib/. A round trip over the Hong Kong link is about 1.6 seconds, so six requests meant an 8.5-second cold start, and running it a few times in a row tripped rate limiting.

After bundling into a single file:

Split filesSingle file
HTTP requests61
Cold start8.5s2.0s
30 runs in a rowRate limited0 limits

Transferring 52KB accounts for 0.06 seconds of that — the bottleneck was always round trips, never size. That conclusion came back repeatedly; inlining the favicon instead of linking it is the same arithmetic.

Pick your sources by testing on the actual machine

On my own machine GitHub measured 3.8–4.9 MB/s. Fast enough, I assumed. On the actual user’s device it was double-digit KB/s — two orders of magnitude off.

Testing all three sources on the real device:

SourceAvg speedTTFB58MB driver
Cloudflare R21.09 MB/s0.93s53 sec
VPS (Hong Kong)0.46 MB/s1.03s127 sec
GitHub Release0.08 MB/s1.57s687 sec

The key read: GitHub isn’t “slow,” it’s throttled. All three sources return their first byte in about a second, so connection setup, DNS, and routing are all fine — but GitHub’s throughput is one fourteenth of R2’s. Normal TTFB plus terrible throughput is the signature of QoS throttling, not a broken link. Which is why changing DNS or editing hosts does nothing.

So the order became R2 → VPS → GitHub. A hotel front desk and an ops engineer’s home connection can reach the same domain completely differently — every source-priority decision has to be measured on the machine that will actually do the install.

The pits that actually cost me

Exit code 0 doesn’t mean the user got something usable

I had the .air installer’s argument order backwards. The correct form puts switches first and the .air path last:

-silent -eulaAccepted -programMenu [-desktopShortcut] "<absolute path to .air>"

Put the path in the middle and the installer treats the trailing switches as surplus positional arguments and rejects the whole thing. And -programMenu — the switch that creates shortcuts — only appeared in the first argument set, so every run fell back to the most minimal set: it really did install, but created no entry points at all.

The worst part: it returned 0, so the script cheerfully reported ”✓ install complete.” What the user saw was a program listed in Control Panel and nothing on the desktop or in the Start menu.

Installing and being findable are now fully decoupled: installation just installs reliably, entry points come from the manifest’s shortcut field, and every fallback is printed explicitly so nobody assumes they got a complete install.

A field that was written but never read

Twenty-odd manifest entries carried needAdmin, and the runner never read it. Non-admin users sailed straight through, downloaded 5.9MB, and only failed inside the installer — with nothing anywhere saying this was a permissions problem.

Compounding it: the exe install branch threw the exit code away entirely, so there was no diagnostic at all.

The fix moved the check to before the download, printed a privilege-elevation command ready to copy, and added exit-code explanations. It went from “download 5.9MB, then fail incomprehensibly” to failing in one second with a clear next step.

”No desktop icon” may not be an install problem at all

After fixing the argument order the Start menu entry appeared; the desktop still had “nothing.” Except the .lnk was sitting right there in the public desktop folder, with valid properties and a working target.

The real cause was HideIcons = 1 in the registry — the “Show desktop icons” toggle in the desktop right-click menu was off, hiding all 38 icons, not just ours.

When investigating a missing icon, confirm whether the file exists before theorizing about installer behavior. Test-Path distinguishes “never created” from “created but not displayed” in about a second.

On a quota alert, look at what’s polling before you look at traffic

Cloudflare emailed me that daily KV operations had hit 50%. My first instinct was that usage had grown. It hadn’t — it was the admin page’s 30-second polling loop.

A complete issue-and-redeem business flow costs 4 reads and 6 writes. Hundreds a day wouldn’t dent the quota. Meanwhile a forgotten background tab polling every 30 seconds came to 155,000 reads a day, against a free tier of 100,000.

Three fixes together: cache the trend data for 5 minutes, replace per-token lookups with a single list() carrying metadata, and drop polling to 5 minutes while stopping entirely when the page isn’t visible. That cut it by 93.7%.

One 30-second setInterval cost more than hundreds of real users would have.

One line break blanked an entire page

While adding a feature to the admin page, a literal newline got into a confirm() string:

if(!confirm('Register the current fingerprint as trusted?
Confirm this deployment was yours.')) return; ← unterminated string

JS strings can’t span lines → SyntaxError → not one line of that entire <script> block runs → no data loads anywhere on the page.

The cruel part is that it doesn’t look broken. The HTML skeleton renders normally; cards, buttons, and layout are all there. The data just spins forever. Without opening the console there is no sign it’s a syntax error.

The root cause was my own tooling: writing JS through a heredoc meant \n got interpreted as a real newline by the outer layer first. I made the identical mistake in a Worker the same day, but that deployment tool reported Unterminated string literal and blocked it. HTML has no build-time syntax check, so it shipped.

The build script now runs node --check over each <script> block and aborts the deploy on failure.

Where there’s a build gate, mistakes get caught. Where there isn’t, they ship.

Security: verification has to live where an attacker can’t reach

The worst single point of risk here: the server gets compromised → the script is replaced → every customer machine executes arbitrary code as administrator.

A script verifying itself is security theater — an attacker who can modify the script can delete the verification along with it. Both the expected value and the act of checking have to come from somewhere the attacker doesn’t control.

So verification lives in a Cloudflare Worker: different host, different credentials, unreachable even if the server falls. And since it already sits on the redemption path, adding a check there fits naturally.

Redeem /r/<token>
→ Worker fetches the live script, computes SHA-256
→ Compares against the registered fingerprint
match → 302, allowed through
mismatch → 409 refused + alert written
no record → allowed (a first deploy shouldn't lock the toolbox)

A related lesson: locking the page is not locking the API. Access control sat on the admin page’s path, while the data endpoints ran through a separate Worker route that access control never touched. Testing the first deployment, anonymous requests could list files and delete them.

The path you test ≠ the path your users take. I hit that one more than once on this project.

Authorization

The toolbox isn’t a public resource. Commands carry a token, in three lifetimes:

TypeBehavior
One-timeDies after a single fetch; auto-expires if unused within 15 minutes
1 hourReusable within the window
24 hoursReusable within the window

For remote assistance, prefer one-time: once the command is out, forwarding or screenshotting it doesn’t let anyone use it twice.

Requirements

Windows 10 / 11 works out of the box.

Windows 7 needs PowerShell 3.0 or later (most patched machines have it). A stock un-updated Win7 can’t run this, because irm didn’t exist yet. On the network side, TLS 1.0/1.1 is allowed for older systems, so nothing fails purely because the crypto is old.


Looking back, the time on this project never went into “writing features.” It went into finding the things that looked successful but weren’t: installs that failed while returning 0, pages that rendered perfectly while loading nothing, a locked page in front of an unlocked API, a download source that was fast only on my own machine.

Writing the code is the fast part. Verifying that it actually worked for the user is the slow part.