Uses the tar.exe and curl.exe that ship with Windows 10+, shares ~/.config/nz (config and breadcrumbs) with ./nz, and accepts both --flag and -flag. test.sh now takes NZ= to run against either CLI.
- commit
- 3d8296f606f8c6309c24a9332caedf809ea90903
- parent
- ee9ef22ed7e72b6f1e788c50ae7db1cfaf01e0fa
- author
- cat <admin@dev.site>
- kind
- feat risk score 0.8 of 3, Jev's estimate
| M | README.md | 14 | +++- |
| A | nz.ps1 | 146 | ++++++++++++++++++++++++++++++ |
| M | site/ | 11 | ++- |
| M | test.sh | 23 | ++-- |
@@ -10,12 +10,13 @@ https://k3x9q.example.com/
1010 1111 ## Minimalism1212 13-nz is three files you can read in one sitting:13+nz is a few files you can read in one sitting:1414 1515 | file | what |1616 | ------------- | ------------------------------------------------------ |1717 | `worker.js` | Cloudflare Worker, plain JS, no dependencies, no build |1818 | `nz` | POSIX `sh` CLI: `tar` + `curl`, nothing to install |19+| `nz.ps1` | the same CLI for PowerShell on Windows |1920 | `test.sh` | end-to-end test against `wrangler dev` |2021 2122 A site is **one KV entry**: the value is a tar of the directory, and the metadata is@@ -33,6 +34,16 @@ ln -s "$PWD/nz" ~/bin/nz
3334 mkdir -p ~/.config/nz && printf 'NZ_SERVER=https://example.com\nNZ_TOKEN=...\n' > ~/.config/nz/config3435 ```3536 37+On Windows, put `nz.ps1` somewhere on your `PATH` and write the same config. It needs38+Windows 10 or later, which ships `tar.exe` and `curl.exe`:39+40+```powershell41+Unblock-File .\nz.ps1 # if it came from a download42+New-Item -ItemType Directory -Force ~\.config\nz | Out-Null43+"NZ_SERVER=https://example.com`nNZ_TOKEN=..." | Set-Content -Encoding ascii ~\.config\nz\config44+nz.ps1 push .\site --ttl 7d --password meow45+```46+3647 ## CLI3748 3849 ```text@@ -43,6 +54,7 @@ nz update <id> [dir|file] [--ttl 14d|never] [--password PW | --no-password]
4354 - `push` updates the site last pushed from the same path (`~/.config/nz/sites`, one4455 `id<TAB>path` per line), or makes a new one if there is none, it expired, or `--new`.4556 - `update` targets an id. Without a path it only changes ttl/password.57+- `nz.ps1` takes the same commands, and also accepts PowerShell-style `-ttl`, `-password`, `-new`.4658 - A single file is published as `index.html`. Dotfiles and `node_modules` are skipped.4759 - TTL: `30m`, `12h`, `14d`, `2w`, `never`. Default `DEFAULT_TTL` (14d). Omitted on update = kept.4860 - The url goes to stdout and details to stderr, so `open "$(nz push .)"` works.@@ -0,0 +1,146 @@
1+#!/usr/bin/env pwsh2+# nz.ps1: push a directory (or one html file) to your nz worker, print its url.3+#4+# nz push <dir|file> [--ttl 14d|never] [--password PW] [--new]5+# nz update <id> [dir|file] [--ttl 14d|never] [--password PW | --no-password]6+#7+# The same tool as ./nz, for Windows PowerShell 5.1+ and pwsh 7. It uses tar.exe8+# and curl.exe, which ship with Windows 10 and later, and shares $NZ_HOME9+# (~/.config/nz: config, sites) with ./nz. Flags also work as -ttl, -password.10+$ErrorActionPreference = 'Stop'11+12+function Die($msg) { [Console]::Error.WriteLine("nz: $msg"); exit 1 }13+function Usage {14+ [Console]::Error.WriteLine("nz push <dir|file> [--ttl 14d|never] [--password PW] [--new]")15+ [Console]::Error.WriteLine("nz update <id> [dir|file] [--ttl 14d|never] [--password PW | --no-password]")16+ exit 217+}18+19+if ($args.Count -lt 1 -or $args[0] -notin 'push', 'update') { Usage }20+$cmd = $args[0]21+$headers = @(); $new = $false; $pos = @()22+for ($i = 1; $i -lt $args.Count; $i++) {23+ switch -regex ([string]$args[$i]) {24+ '^--?ttl$' { if (++$i -ge $args.Count) { Usage }; $headers += "nz-ttl: $($args[$i])"; break }25+ '^--?password$' { if (++$i -ge $args.Count) { Usage }; $headers += "nz-password: $($args[$i])"; break }26+ '^--?no-password$' { $headers += 'nz-password;'; break }27+ '^--?new$' { $new = $true; break }28+ '^-' { Usage }29+ default { $pos += [string]$args[$i] }30+ }31+}32+33+# config: NZ_SERVER=... and NZ_TOKEN=... lines, the same file ./nz sources34+$nzHome = if ($env:NZ_HOME) { $env:NZ_HOME } else { Join-Path $HOME '.config/nz' }35+$crumbs = Join-Path $nzHome 'sites'36+$server = $env:NZ_SERVER; $token = $env:NZ_TOKEN37+$config = Join-Path $nzHome 'config'38+if (Test-Path -LiteralPath $config) {39+ foreach ($line in Get-Content -LiteralPath $config) {40+ if ($line -match '^\s*(NZ_SERVER|NZ_TOKEN)=(.*)$') {41+ $v = $Matches[2].Trim().Trim('"', "'")42+ if ($Matches[1] -eq 'NZ_SERVER') { $server = $v } else { $token = $v }43+ }44+ }45+}46+if (-not $server -or -not $token) { Die "set NZ_SERVER and NZ_TOKEN in $config" }47+# curl.exe, not curl: in Windows PowerShell `curl` means Invoke-WebRequest48+$curl = (Get-Command curl.exe, curl -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1).Source49+if (-not $curl) { Die 'curl not found' }50+51+# files under a directory, as forward-slash paths, minus dotfiles and node_modules52+function Get-Files($root, $rel) {53+ $dir = if ($rel) { Join-Path $root $rel } else { $root }54+ foreach ($e in Get-ChildItem -LiteralPath $dir -Force) {55+ if ($e.Name.StartsWith('.') -or $e.Name -eq 'node_modules') { continue }56+ $r = if ($rel) { "$rel/$($e.Name)" } else { $e.Name }57+ if ($e.PSIsContainer) { Get-Files $root $r } else { $r }58+ }59+}60+61+# ustar of a directory, or of one file as index.html, into $tar62+function Pack($path) {63+ if (Test-Path -LiteralPath $path -PathType Container) {64+ $root = Get-Src $path65+ [string[]]$files = @(Get-Files $root '')66+ [Array]::Sort($files, [StringComparer]::Ordinal)67+ [IO.File]::WriteAllText($list, ($files -join "`n") + "`n") # utf-8, no BOM, LF68+ $env:COPYFILE_DISABLE = '1' # macOS tar: no ._ files69+ & tar --format=ustar -cf $tar -C $root -T $list70+ if ($LASTEXITCODE) { Die 'tar failed' }71+ } elseif (Test-Path -LiteralPath $path -PathType Leaf) {72+ $one = Join-Path $tmp 'one'73+ New-Item -ItemType Directory $one | Out-Null74+ Copy-Item -LiteralPath $path (Join-Path $one 'index.html')75+ Pack $one76+ } else {77+ Die "no such file or directory: $path"78+ }79+}80+81+# absolute path, the key for the breadcrumb file82+function Get-Src($path) { (Resolve-Path -LiteralPath $path).ProviderPath.TrimEnd('\', '/') }83+84+function Get-Crumb($src) {85+ $id = ''86+ if (Test-Path -LiteralPath $crumbs) {87+ foreach ($l in [IO.File]::ReadAllLines($crumbs)) {88+ $p = $l -split "`t", 289+ if ($p.Count -eq 2 -and $p[1] -eq $src) { $id = $p[0] }90+ }91+ }92+ $id93+}94+95+# send <method> <path> [-Body]: returns the http status, response body in $out96+function Send($method, $path, [switch]$Body) {97+ $data = @()98+ if ($Body) { $data = @('-H', 'content-type: application/x-tar', '--data-binary', "@$tar") }99+ $code = & $curl -sS -o $out -w '%{http_code}' -X $method -H "@$hfile" @data "$server$path"100+ if ($LASTEXITCODE) { exit 1 }101+ $code102+}103+104+# url to stdout, details to stderr, and remember which path it came from105+function Finish($code, $src) {106+ $lines = @(Get-Content -LiteralPath $out -Encoding UTF8)107+ if ($code -notmatch '^2') { Die ($lines -join "`n") }108+ $lines | Select-Object -Skip 1 | ForEach-Object { [Console]::Error.WriteLine($_) }109+ if ($src) {110+ New-Item -ItemType Directory -Force $nzHome | Out-Null111+ $id = ($lines[0] -replace '^.*//', '') -replace '\..*$', ''112+ [IO.File]::AppendAllText($crumbs, "$id`t$src`n")113+ }114+ $lines[0]115+}116+117+$tmp = Join-Path ([IO.Path]::GetTempPath()) "nz-$([guid]::NewGuid())"118+New-Item -ItemType Directory $tmp | Out-Null119+$tar = Join-Path $tmp 'site.tar'; $list = Join-Path $tmp 'files'120+$out = Join-Path $tmp 'out'; $hfile = Join-Path $tmp 'headers'121+try {122+ # headers live in a file so the token and password stay out of the process list123+ [IO.File]::WriteAllText($hfile, ((@("authorization: Bearer $token") + $headers) -join "`n") + "`n")124+ if ($cmd -eq 'push') {125+ if ($pos.Count -ne 1) { Usage }126+ Pack $pos[0]127+ $src = Get-Src $pos[0]128+ $id = if ($new) { '' } else { Get-Crumb $src }129+ if ($id) {130+ $code = Send PUT "/$id" -Body131+ if ($code -eq '404') { [Console]::Error.WriteLine("nz: $id has expired, pushing a new site"); $id = '' }132+ }133+ if (-not $id) { $code = Send POST '/' -Body }134+ Finish $code $src135+ } else {136+ if ($pos.Count -lt 1 -or $pos.Count -gt 2 -or $new) { Usage }137+ if ($pos.Count -eq 2) {138+ Pack $pos[1]139+ Finish (Send PUT "/$($pos[0])" -Body) (Get-Src $pos[1])140+ } else {141+ Finish (Send PUT "/$($pos[0])") ''142+ }143+ }144+} finally {145+ Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue146+}@@ -47,7 +47,7 @@
4747 4848 <main>4949 <h1>nz<span>.</span></h1>50- <p class="lede">Push a directory of HTML, get a url back. A POSIX shell script, one Cloudflare Worker, and one KV entry per site.</p>50+ <p class="lede">Push a directory of HTML, get a url back. A small CLI for macOS, Linux and Windows, one Cloudflare Worker, and one KV entry per site.</p>5151 5252 <pre><code><span class="c">$</span> nz push ./report --ttl 7d --password meow5353 <span class="c">expires 2026-11-10T19:40:02.000Z, password protected</span>@@ -57,12 +57,13 @@ https://k3x9q.example.com/
5757 <span class="c">$</span> nz update k3x9q --no-password <span class="c"># policy only, content kept</span></code></pre>5858 5959 60- <h2>Three files</h2>60+ <h2>The files</h2>6161 <table>6262 <tr><th>file</th><th>what</th><th>lines</th></tr>6363 <tr><td><code>worker.js</code></td><td>Cloudflare Worker: plain JS, no dependencies, no build</td><td>170</td></tr>6464 <tr><td><code>nz</code></td><td>the CLI: POSIX <code>sh</code>, using only <code>tar</code> and <code>curl</code></td><td>95</td></tr>65- <tr><td><code>test.sh</code></td><td>end-to-end checks against <code>wrangler dev</code></td><td>60</td></tr>65+ <tr><td><code>nz.ps1</code></td><td>the same CLI for PowerShell, using the <code>tar.exe</code> and <code>curl.exe</code> built into Windows</td><td>146</td></tr>66+ <tr><td><code>test.sh</code></td><td>end-to-end checks against <code>wrangler dev</code></td><td>61</td></tr>6667 </table>6768 6869 <h2>Architecture</h2>@@ -162,6 +163,10 @@ wrangler deploy
162163 163164 ln -s "$PWD/nz" ~/bin/nz164165 printf 'NZ_SERVER=https://example.com\nNZ_TOKEN=...\n' > ~/.config/nz/config</code></pre>166+ <p>On Windows 10 or later, put <code>nz.ps1</code> on your <code>PATH</code> and write the same config:</p>167+<pre><code>New-Item -ItemType Directory -Force ~\.config\nz | Out-Null168+"NZ_SERVER=https://example.com`nNZ_TOKEN=..." | Set-Content -Encoding ascii ~\.config\nz\config169+nz.ps1 push .\site --ttl 7d --password meow</code></pre>165170 166171 <footer>167172 <a href="https://src.rest/cat/nz/">src.rest/cat/nz</a> | 0BSD@@ -1,9 +1,10 @@
11 2-# End-to-end test: worker.js under `wrangler dev`, driven by ./nz. Needs wrangler2+# End-to-end test: worker.js under `wrangler dev`, driven by ./nz (or NZ="pwsh ./nz.ps1"). Needs wrangler33 # (WRANGLER=path, default `npx wrangler`). Routes make wrangler dev rewrite the44 # Host header, so this runs from a routeless copy of the config.55 set -u66 cd "$(dirname "$0")"7+NZ=${NZ:-./nz}78 port=${PORT:-8787} t=$(mktemp -d) fails=089 trap 'kill $pid 2>/dev/null; rm -rf "$t"' EXIT910 @@ -24,7 +25,7 @@ mkdir -p "$s/sub/.git" "$s/node_modules"
2425 echo '<h1>v1</h1>' > "$s/index.html"; echo 'body{}' > "$s/style.css"; echo sub > "$s/sub/index.html"2526 echo x > "$s/sub/.git/x"; echo x > "$s/node_modules/y"; echo x > "$s/.env"2627 27-u=$(./nz push "$s" 2>/dev/null)28+u=$($NZ push "$s" 2>/dev/null)2829 is 'push prints a site url' "$(echo "$u" | grep -c "^http://[a-z0-9]\{5\}\.localhost:$port/$")" 12930 is 'serves index' "$(get "$u")" '<h1>v1</h1>'3031 is 'serves assets with a type' "$(curl -s -o /dev/null -w '%{content_type}' "${u}style.css")" 'text/css; charset=utf-8'@@ -33,28 +34,28 @@ is 'serves nested index' "$(get "${u}sub/")" sub
3334 is 'skips dot dirs' "$(code "${u}sub/.git/x")" 4043435 is 'skips dotfiles' "$(code "${u}.env")" 4043536 is 'skips node_modules' "$(code "${u}node_modules/y")" 40436-is 'default ttl is 14d' "$(./nz update "$(idof "$u")" 2>&1 >/dev/null | grep -c expires)" 137+is 'default ttl is 14d' "$($NZ update "$(idof "$u")" 2>&1 >/dev/null | grep -c expires)" 13738 3839 echo '<h1>v2</h1>' > "$s/index.html"39-is 'push again reuses the url' "$(./nz push "$s" --ttl 1h 2>/dev/null)" "$u"40+is 'push again reuses the url' "$($NZ push "$s" --ttl 1h 2>/dev/null)" "$u"4041 is 'and replaces content' "$(get "$u")" '<h1>v2</h1>'41-is '--new makes another url' "$(./nz push "$s" --new 2>/dev/null | grep -c "$u")" 042+is '--new makes another url' "$($NZ push "$s" --new 2>/dev/null | grep -c "$u")" 04243 4344 echo '<b>report</b>' > "$t/report.html"44-r=$(./nz push "$t/report.html" --password meow 2>/dev/null)45+r=$($NZ push "$t/report.html" --password meow 2>/dev/null)4546 is 'password locks the site' "$(code "$r")" 4014647 is 'wrong password is refused' "$(code -X POST -d password=nope "$r")" 4014748 cookie=$(curl -s -D - -o /dev/null -X POST -d password=meow "$r" | sed -n 's/^set-cookie: \(__Host-nz=[^;]*\).*/\1/ip')4849 is 'right password unlocks' "$(get -H "cookie: $cookie" "$r")" '<b>report</b>'49-./nz update "$(idof "$r")" --password purr 2>/dev/null >&250+$NZ update "$(idof "$r")" --password purr 2>/dev/null >&25051 is 'new password revokes cookies' "$(code -H "cookie: $cookie" "$r")" 4015152 is 'update keeps content' "$(get -H "cookie: $(curl -s -D - -o /dev/null -X POST -d password=purr "$r" | sed -n 's/^set-cookie: \(__Host-nz=[^;]*\).*/\1/ip')" "$r")" '<b>report</b>'52-is 'no-password + never' "$(./nz update "$(idof "$r")" --no-password --ttl never 2>&1 >/dev/null)" 'never expires'53+is 'no-password + never' "$($NZ update "$(idof "$r")" --no-password --ttl never 2>&1 >/dev/null)" 'never expires'5354 is 'is public again' "$(get "$r")" '<b>report</b>'5455 55-is 'bad ttl is refused' "$(./nz push "$s" --ttl 3x 2>&1)" 'nz: ttl must look like 30m, 12h, 14d, 2w or never'56-is 'bad token is refused' "$(NZ_TOKEN=nope ./nz push "$s" 2>&1)" 'nz: unauthorized'57-is 'unknown id' "$(./nz update zzzzz 2>&1)" 'nz: zzzzz not found (expired?)'56+is 'bad ttl is refused' "$($NZ push "$s" --ttl 3x 2>&1)" 'nz: ttl must look like 30m, 12h, 14d, 2w or never'57+is 'bad token is refused' "$(NZ_TOKEN=nope $NZ push "$s" 2>&1)" 'nz: unauthorized'58+is 'unknown id' "$($NZ update zzzzz 2>&1)" 'nz: zzzzz not found (expired?)'5859 is 'not a tar' "$(curl -s -X POST -H 'authorization: Bearer test' --data-binary hello "$NZ_SERVER/")" 'body must be a ustar archive with at least one file'5960 6061 [ $fails = 0 ] && echo "all passed" || { echo "$fails failed"; exit 1; }