clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

codex/agent-interface
nzip / README.md
README.md 9.3K rendered
1# `nzip`23**Push a directory of HTML from the terminal. Get a four-character URL back.**45_A personal share tool. Deno CLI → Cloudflare Worker → R2 + D1. No daemon, no build step, no6redeploys._78[![JSR](https://jsr.io/badges/@nzip/cli)](https://jsr.io/@nzip/cli) · [args.io/cat/nzip](https://args.io/cat/nzip)910```console11$ nzip push ./demo work:demo --ttl 30d12 bundling ./demo … 3 files, 197 B13 manifest 4a91d75d — 3 new blobs (197 B), 0 deduped14✓ pushed work:demo -> https://share.example.com/12d8 (expires in 30d, push #1)15```1617---1819## How addresses work2021Every share lives at four hex characters. The first digit selects one of **16 registered vaults**,22the rest a site within it. Each vault holds 4,096 slots, allocated randomly so URLs don't leak how23many sites exist.2425```text26https://share.example.com/2a3f27 |+- site 0xa3f (0x000-0xFFF, random within the vault)28 +-- vault 0x2 ("work" - 16 slots, registered by name)29```3031Aliases like `work:demo` are resolved by the API; the URL never exposes them. Commands accept any of32three target forms:3334| form | example | meaning |35| ----------- | ----------- | -------------------------- |36| address | `2a3f` | direct hex address |37| vault:alias | `work:demo` | alias within a named vault |38| bare alias | `demo` | alias in the default vault |3940## Features4142- **Instant.** A push is one manifest exchange plus the blobs the server hasn't seen. Nothing43 rebuilds, nothing redeploys.44- **Content-addressed.** Per-file sha256 blobs plus a canonical manifest per push. Identical files45 are stored once across every site; re-pushing an unchanged directory uploads `0 new blobs`.46- **Ephemeral by default.** 14-day TTL, with `--ttl 30d` or `--ttl forever` to override. Expired47 shares answer `410 Gone`; a daily cron sweeps them and garbage-collects unreferenced content.48- **Revertible.** The last 10 pushes per site are kept. `nzip revert` repoints the address at any of49 them, and the revert itself is recorded as a push.50- **Password-protectable.** `nzip share work:demo --password …` gates the site behind an unlock form51 (PBKDF2 hashing, signed per-site cookie, 7 days).52- **Single-user by design.** One bearer token, stored as a Worker secret and in53 `~/.config/nzip/config.json` (mode 0600).54- **Locates itself.** `nzip where <target>` prints the local directory this machine pushed a site55 from. A breadcrumb registry (`~/.config/nzip/paths.json`) records the source path on every push and56 self-cleans: expired entries drop on write, `rm` forgets its entry, and `ls` reconciles against the57 live set. Use it as `cd "$(nzip where personal:plan)"`.58- **Vault guardrail.** An optional `"allowVaults": ["home"]` in `config.json` restricts which vaults59 this install may target by name. Pushes or aliases outside the list are refused before any upload,60 so a home-project agent can't drop a doc into a vault that sits adjacent to what you share61 professionally. Absent = unrestricted; raw hex addresses bypass it (they name no vault).6263## Commands6465```text66nzip auth [--server URL] [--token T] authenticate and save config67nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)68nzip vault ls | default <name> list vaults / set the default69nzip push <dir|file> [target] [--ttl 14d|30d|forever]70nzip share <target> [--ttl …] [--password PW | --no-password]71nzip ls [vault] list sites72nzip where <target> print the local dir this machine pushed from73nzip rm <target> [--yes] delete a site74nzip status server + vault overview75nzip revert <target> [--to N] [--list] repoint to a previous push76```7778Pushing a single `page.html` stores it as the site's `index.html`. Directory pushes skip dotfiles79and `node_modules`, and honor a `.nzipignore` (one glob per line). Single-file sites serve directly80at the bare address (`/2a3f`); multi-file bundles redirect to `/2a3f/` so relative asset URLs81resolve.8283## Architecture8485```mermaid86flowchart LR87 CLI["nzip<br/><i>Deno CLI</i>"] -- "https + bearer" --> W["Worker<br/><i>self-hosted</i>"]88 W --> R2[("R2<br/>blob/{sha256}<br/>manifest/{sha256}")]89 W --> D1[("D1<br/>vaults · sites · pushes")]90 V(["visitor"]) -- "GET /2a3f" --> W91```9293A push is a stateless three-step protocol, and the manifest itself is the state:94951. `POST /api/push/prepare`: send the manifest, get back which blob hashes the server is missing962. `PUT /api/blob/{sha256}`: upload only those, one per request, 6 at a time; the server re-hashes97 and rejects mismatches983. `POST /api/push/commit`: the server re-verifies every blob exists, then commits atomically,99 resolving or allocating the address, repointing the site, and appending history100101Serving is one D1 read (address → manifest, expiry, password) and two R2 reads, with `ETag`102revalidation and a 60-second cache. Metadata lives in three tables:103104| table | keys |105| -------- | ------------------------------------------------------------------------ |106| `vaults` | slot (0–15), name |107| `sites` | address (0–65535), vault, alias, current manifest, expiry, password hash |108| `pushes` | per-site history (seq, manifest, note), capped at 10, powers `revert` |109110**GC safety rule:** an R2 object is deleted only if no live site or retained history entry111references it _and_ it's older than 24 hours, so an in-flight push can never lose objects to a112concurrent sweep.113114## Repo layout115116```text117shared/ manifest canonicalization, hashing, addressing (imported by both sides)118cli/ the nzip command (Deno, no framework), published as jsr:@nzip/cli119worker/ Cloudflare Worker: serving + API + GC cron (wrangler)120```121122`shared/` is the contract: canonical JSON serialization lives in exactly one file123([`shared/manifest.ts`](shared/manifest.ts)) so the CLI and Worker can never disagree about a124manifest hash. It sticks to Web-standard APIs only, so the same code runs under Deno and workerd.125126## Install127128The CLI ships on [JSR](https://jsr.io/@nzip/cli). With [Deno](https://docs.deno.com/runtime/) on129your `PATH`, install it in one line, then point it at your server and push:130131```sh132deno install -g -A -f -n nzip jsr:@nzip/cli # 1. install the `nzip` command133134nzip auth --server https://share.example.com # 2. authenticate (prompts for the token)135136nzip push ./site work:demo # 3. get a URL back137```138139`nzip auth` prompts for anything you omit and saves it to `~/.config/nzip/config.json` (mode 0600),140so every later command just works. Upgrade any time by re-running the install line; try it without141installing via `deno run -A jsr:@nzip/cli --help`.142143Adding a **second machine**? Only the CLI is per-machine; the Worker, R2, D1, vaults, and token are144already provisioned. Install from JSR and run `nzip auth` with the same server and token, and every145vault and site is instantly there. (The `nzip where` breadcrumb registry is local, so it only knows146sites pushed from this machine.)147148`nzip` is self-hosted. The server URL is supplied by the operator; there is no bundled public149service. Use the same URL you set as `vars.PUBLIC_BASE` in your Wrangler config. Standing up that150server is the one-time setup below.151152## Self-hosting: one-time Cloudflare setup153154See [`worker/setup.md`](worker/setup.md) for the full checklist. In short:155156```sh157cd worker158npx wrangler login159cp wrangler.local.example.jsonc wrangler.local.jsonc160# edit wrangler.local.jsonc:161# - routes[0].pattern: your hostname, such as share.example.com162# - vars.PUBLIC_BASE: https://<that hostname>163# - d1_databases[0].database_id: filled in after D1 creation164npx wrangler r2 bucket create nzip-content165npx wrangler d1 create nzip # paste id into wrangler.local.jsonc166npx wrangler d1 execute nzip --remote --file schema.sql167openssl rand -hex 32 | npx wrangler secret put NZIP_TOKEN --config wrangler.local.jsonc168npx wrangler deploy --config wrangler.local.jsonc169nzip auth --server https://share.example.com # use your actual PUBLIC_BASE170```171172`routes[0].pattern` and `vars.PUBLIC_BASE` are required for a real deployment. `PUBLIC_BASE`173controls the URLs returned by `nzip push`, and the CLI authenticates against that same origin.174175Two gotchas learned the hard way:176177- **Cron triggers require a workers.dev subdomain** on the account (API error `10063`), even if the178 Worker only serves a custom domain. Register one in the dashboard (or179 `PUT /accounts/{id}/workers/subdomain`) before deploying with `triggers`.180- The required custom-domain route181 (`"routes": [{ "pattern": "share.example.com",182 "custom_domain": true }]`) needs the zone on your183 account; it provisions DNS and the cert automatically on deploy.184185## Development186187```sh188deno task check # typecheck CLI + shared189cd worker && npx tsc --noEmit # typecheck Worker190191npx wrangler d1 execute nzip --local --file schema.sql # once per fresh state192npx wrangler dev # local R2/D1; token in .dev.vars193nzip auth --server http://localhost:8787 --token dev-token-local-only194```195196Test the GC cron: `npx wrangler dev --test-scheduled`, then197`curl "http://localhost:8787/__scheduled?cron=0+4+*+*+*"`.