clone

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

feat: make HTML revisions source-stable

Dami committed 701fedd
Reuse unique source breadcrumbs for untargeted pushes, validate raw addresses against authenticated vault metadata, split content pushes from site policy changes, and release the CLI/shared packages as v0.10.0.
commit
701fedd83b0fef008054b3dc694daad8305e6a4d
parent
e4af3d79ae92d3b4c06eac942b78c2c00377e07f
author
Dami <dami.etoile@gmail.com>
24 files changed, +283 -81
MAGENTS.md5+++
MREADME.md17++++++---
Mcli/README.md10++++-
Mcli/commands/app.ts1+
Mcli/commands/cp.ts12++++--
Mcli/commands/notify.ts4+-
Mcli/commands/push.ts42++++++++++++++++++----
Mcli/commands/site.ts21+++++++----
Mcli/commands/site_test.ts4+-
Mcli/commands/sites.ts36++++++++++++-------
Mcli/commands/where.ts13++++++-
Mcli/deno.json2+-
Mcli/lib/api.ts37+++++++++++++++++---
Mcli/lib/api_test.ts27+++++++++++---
Mcli/lib/config.ts11+-----
Mcli/lib/paths.ts57++++++++++++++++++++++++++----
Acli/lib/paths_test.ts33+++++++++++++++++
Mcli/main.ts8+++--
Mpackage-lock.json1+
Mpackage.json2+
Mshared/deno.json2+-
Mshared/version.ts2+-
Mskills/nzip/SKILL.md15+++++++-
Mworker/test/index.runtime.test.ts2+-
M AGENTS.md +5 -0
M README.md +12 -5
@@ -77,9 +77,10 @@ also the site hostname; see the parent-domain caveat in [SECURITY.md](SECURITY.m
7777 - **Owner notifications.** Explicitly approved phones receive Web Push notifications with bounded,7878 same-origin click targets.7979 - **Machine-local breadcrumbs.** `nzip site where` locates the source directory recorded during a80- push.81-- **Vault guardrails.** Optional `allowVaults` configuration refuses disallowed vaults and raw82- addresses before upload.80+ push. An untargeted push from the same logical source updates its unique existing site; a81+ directory and its `index.html` count as the same source. Pass `--new` to create another address.82+- **Vault guardrails.** Optional `allowVaults` configuration refuses disallowed vaults before83+ upload. Raw addresses are mapped to their authenticated vault slot before the same check.8384 - **Observable abuse controls.** Enumeration is rate-limited and evaluated through bounded,8485 privacy-preserving telemetry.8586
@@ -99,10 +100,10 @@ nzip
99100 │ ├─ ls list vaults100101 │ └─ default <temporary|permanent> <name>101102 ├─ site102-│ ├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]103+│ ├─ push <dir|file> [target] [--new] [--ttl …] [--password PW | --no-password]103104 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle104105 │ ├─ show <target> show site details105-│ ├─ update <target> [--ttl …] [--password PW | --no-password]106+│ ├─ policy <target> [--ttl …] [--password PW | --no-password]106107 │ ├─ ls [vault] list sites107108 │ ├─ where <target> print this machine's source directory108109 │ ├─ rm <target> [--yes] delete a site
@@ -124,6 +125,12 @@ sites there may still be password-protected. `status --json` and `vault ls --jso
124125 reports `ttl` and `ttlSource`. Directory pushes skip dotfiles and `node_modules` and honor a125126 `.nzipignore` file containing one glob per line.126127 128+Content revisions use `site push`; access and expiry changes use `site policy`. With no explicit129+target, `site push` reuses a unique unexpired local breadcrumb for the same logical source. It stops130+when multiple sites match instead of guessing. Pass the intended target to resolve the ambiguity,131+which rebinds later untargeted pushes to that site, or pass `--new` only when a separate URL is132+desired.133+127134 ## Host a lofi app128135 129136 Reserve the production origin before configuring credentials or building:
M cli/README.md +8 -2
@@ -55,10 +55,10 @@ nzip
5555 │ ├─ ls list vaults5656 │ └─ default <temporary|permanent> <name>5757 ├─ site58-│ ├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]58+│ ├─ push <dir|file> [target] [--new] [--ttl …] [--password PW | --no-password]5959 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle6060 │ ├─ show <target> show site details61-│ ├─ update <target> [--ttl …] [--password PW | --no-password]61+│ ├─ policy <target> [--ttl …] [--password PW | --no-password]6262 │ ├─ ls [vault] list sites6363 │ ├─ where <target> print this machine's source directory6464 │ ├─ rm <target> [--yes] delete a site
@@ -83,6 +83,12 @@ as the temporary 14-day default and `public` in slot `f` as the permanent foreve
8383 overwriting occupied slots on upgrade. `public` does not mean unprotected. Pass `--no-password` to8484 clear protection explicitly. Push JSON includes the resolved `ttl` and `ttlSource`.8585 86+Use `site push` for content revisions and `site policy` for TTL or password changes. An untargeted87+push reuses a unique unexpired local breadcrumb for the same logical source; a directory and its88+`index.html` are treated as one source. Multiple matches stop with candidate targets instead of89+guessing; an explicit targeted push rebinds the source for later untargeted revisions. Pass `--new`90+only when a separate URL is intended.91+8692 For a lofi PWA, run `nzip app init <alias>` before editing its deployed credential origins. The8793 command reserves the permanent default vault's final URL and writes a token-free `nzip.app.json`.8894 After adding the printed origin to `src/app.ts` `credentialOrigins`, run `nzip app deploy`. It
M cli/commands/app.ts +1 -0
M cli/commands/cp.ts +8 -4
M cli/commands/notify.ts +2 -2
M cli/commands/push.ts +35 -7
@@ -1,7 +1,7 @@
11 import { ApiClient, commitTargetFor } from "../lib/api.ts";22 import { buildBundle, formatBytes } from "../lib/bundle.ts";33 import type { Config } from "../lib/config.ts";4-import { recordPush } from "../lib/paths.ts";4+import { lookupBySource, recordPush } from "../lib/paths.ts";55 import { amber, bold, cyan, dim, emit, fail, green, parseTtl, ttlLeft } from "../lib/fmt.ts";66 77 const UPLOAD_CONCURRENCY = 6;
@@ -22,26 +22,53 @@ export async function cmdPush(
2222 ttlRaw: string | undefined,2323 password: string | undefined,2424 noPassword: boolean,25+ newSite: boolean,2526 app?: { contentSecurityPolicy?: string },2627 ): Promise<void> {2728 if (!path) {2829 fail(29- "usage: nzip site push <dir|file> [target] [--ttl 14d|forever] [--password PW | --no-password]",30+ "usage: nzip site push <dir|file> [target] [--new] [--ttl 14d|forever] [--password PW | --no-password]",3031 );3132 }3233 if (password !== undefined && noPassword) {3334 fail("choose either --password or --no-password, not both");3435 }36+ if (newSite && targetRaw !== undefined) {37+ fail("--new cannot be combined with an explicit target");38+ }3539 const api = new ApiClient(config);3640 41+ let effectiveTargetRaw = targetRaw;42+ if (effectiveTargetRaw === undefined && !newSite) {43+ const matches = await lookupBySource(path);44+ if (matches.length === 1) {45+ effectiveTargetRaw = matches[0].address;46+ } else if (matches.length > 1) {47+ fail(48+ `multiple existing sites were pushed from this source: ${49+ matches.map((entry) => entry.address).join(", ")50+ }`,51+ "pass the intended target explicitly, or pass --new to create a separate site",52+ );53+ }54+ }55+3756 // Resolve (and vault-guard) the target up front — before any bundling or3857 // upload — so a disallowed vault is refused without touching the network.39- const targetConfig = await (async (): Promise<Config> => {40- if (targetRaw !== undefined && (/^[0-9a-f]{4}$/.test(targetRaw) || targetRaw.includes(":"))) {58+ const rawAddress = effectiveTargetRaw !== undefined &&59+ /^[0-9a-f]{4}$/.test(effectiveTargetRaw);60+ const needsStatus = effectiveTargetRaw === undefined ||61+ (effectiveTargetRaw !== undefined && !rawAddress && !effectiveTargetRaw.includes(":")) ||62+ (rawAddress && config.allowVaults !== undefined);63+ const status = needsStatus ? await api.status() : undefined;64+ const targetConfig = (() => {65+ if (66+ effectiveTargetRaw !== undefined &&67+ (/^[0-9a-f]{4}$/.test(effectiveTargetRaw) || effectiveTargetRaw.includes(":"))68+ ) {4169 return config;4270 }43- const status = await api.status();44- const defaultVault = status.defaultVaults?.temporary ?? config.defaultVault;71+ const defaultVault = status?.defaultVaults?.temporary ?? config.defaultVault;4572 if (!defaultVault) {4673 fail("server has no temporary default vault — specify vault:alias or configure one");4774 }
@@ -49,7 +76,7 @@ export async function cmdPush(
4976 })();5077 const target = (() => {5178 try {52- return commitTargetFor(targetRaw, targetConfig);79+ return commitTargetFor(effectiveTargetRaw, targetConfig, status?.vaults);5380 } catch (e) {5481 return fail((e as Error).message);5582 }
@@ -119,6 +146,7 @@ export async function cmdPush(
119146 path,120147 url: res.url,121148 expiresAt: res.expiresAt,149+ rebindSource: targetRaw !== undefined,122150 }).catch(() => {});123151 124152 const label = res.alias ? `${"vault" in target ? target.vault : "?"}:${res.alias}` : res.address;
M cli/commands/site.ts +14 -7
@@ -2,14 +2,14 @@ import type { Config } from "../lib/config.ts";
22 import { fail } from "../lib/fmt.ts";33 import { cmdCp } from "./cp.ts";44 import { cmdPush } from "./push.ts";5-import { cmdLs, cmdRevert, cmdRm, cmdSiteShow, cmdSiteUpdate } from "./sites.ts";5+import { cmdLs, cmdRevert, cmdRm, cmdSitePolicy, cmdSiteShow } from "./sites.ts";66 import { cmdWhere } from "./where.ts";77 88 export type SiteInvocation =99 | { kind: "push"; source?: string; target?: string }1010 | { kind: "cp"; target?: string; dir?: string }1111 | { kind: "show"; target?: string }12- | { kind: "update"; target?: string }12+ | { kind: "policy"; target?: string }1313 | { kind: "ls"; vault?: string }1414 | { kind: "where"; target?: string }1515 | { kind: "rm"; target?: string }
@@ -17,7 +17,7 @@ export type SiteInvocation =
1717 1818 export function parseSiteInvocation(rest: string[]): SiteInvocation {1919 const [action, first, second, ...extra] = rest;20- if (!action) fail("usage: nzip site <push|cp|show|update|ls|where|rm|revert> ...");20+ if (!action) fail("usage: nzip site <push|cp|show|policy|ls|where|rm|revert> ...");2121 if (extra.length > 0) fail(`too many arguments for nzip site ${action}`);2222 switch (action) {2323 case "push":
@@ -27,9 +27,14 @@ export function parseSiteInvocation(rest: string[]): SiteInvocation {
2727 case "show":2828 if (second !== undefined) fail("usage: nzip site show <target>");2929 return { kind: "show", target: first };30+ case "policy":31+ if (second !== undefined) fail("usage: nzip site policy <target> [options]");32+ return { kind: "policy", target: first };3033 case "update":31- if (second !== undefined) fail("usage: nzip site update <target> [options]");32- return { kind: "update", target: first };34+ return fail(35+ "site update was replaced by site policy",36+ "publish content with `nzip site push <dir|file> <target>`; change TTL/password with `nzip site policy <target> ...`",37+ );3338 case "ls":3439 if (second !== undefined) fail("usage: nzip site ls [vault]");3540 return { kind: "ls", vault: first };
@@ -54,6 +59,7 @@ export async function cmdSiteGroup(
5459 ttl?: string;5560 password?: string;5661 noPassword: boolean;62+ newSite: boolean;5763 overwrite: boolean;5864 yes: boolean;5965 toSeq?: number;
@@ -70,13 +76,14 @@ export async function cmdSiteGroup(
7076 options.ttl,7177 options.password,7278 options.noPassword,79+ options.newSite,7380 );7481 case "cp":7582 return await cmdCp(config, invocation.target, invocation.dir, options.overwrite);7683 case "show":7784 return await cmdSiteShow(config, invocation.target);78- case "update":79- return await cmdSiteUpdate(85+ case "policy":86+ return await cmdSitePolicy(8087 config,8188 invocation.target,8289 options.ttl,
M cli/commands/site_test.ts +2 -2
M cli/commands/sites.ts +22 -14
@@ -1,7 +1,7 @@
11 // Site lifecycle operations plus the global deployment status command.22 33 import type { SiteDetail } from "@nzip/shared";4-import { ApiClient, resolveCliTarget } from "../lib/api.ts";4+import { ApiClient, resolveCliTargetWithStatus } from "../lib/api.ts";55 import type { Config } from "../lib/config.ts";66 import { forget, reconcile } from "../lib/paths.ts";77 import {
@@ -18,14 +18,15 @@ import {
1818 ttlLeft,1919 } from "../lib/fmt.ts";2020 21-function targetOrFail(21+async function targetOrFail(2222 raw: string | undefined,2323 config: Config,24+ api: ApiClient,2425 usage: string,25-): string {26+): Promise<string> {2627 if (!raw) fail(usage);2728 try {28- return resolveCliTarget(raw, config);29+ return await resolveCliTargetWithStatus(raw, config, api);2930 } catch (e) {3031 return fail((e as Error).message);3132 }
@@ -48,21 +49,24 @@ export async function cmdSiteShow(
4849 config: Config,4950 raw: string | undefined,5051 ): Promise<void> {51- const target = targetOrFail(raw, config, "usage: nzip site show <target>");52- emitSiteDetail(await new ApiClient(config).siteDetail(target));52+ const api = new ApiClient(config);53+ const target = await targetOrFail(raw, config, api, "usage: nzip site show <target>");54+ emitSiteDetail(await api.siteDetail(target));5355 }5456 55-export async function cmdSiteUpdate(57+export async function cmdSitePolicy(5658 config: Config,5759 raw: string | undefined,5860 ttlRaw: string | undefined,5961 password: string | undefined,6062 noPassword: boolean,6163 ): Promise<void> {62- const target = targetOrFail(64+ const api = new ApiClient(config);65+ const target = await targetOrFail(6366 raw,6467 config,65- "usage: nzip site update <target> [--ttl 14d|forever] [--password PW | --no-password]",68+ api,69+ "usage: nzip site policy <target> [--ttl 14d|forever] [--password PW | --no-password]",6670 );6771 if (password !== undefined && noPassword) {6872 fail("choose either --password or --no-password, not both");
@@ -72,9 +76,12 @@ export async function cmdSiteUpdate(
7276 if (noPassword) patch.password = null;7377 else if (password !== undefined) patch.password = password;7478 if (Object.keys(patch).length === 0) {75- fail("site update requires --ttl, --password, or --no-password");79+ fail(80+ "site policy requires --ttl, --password, or --no-password",81+ "to publish revised content, run: nzip site push <dir|file> <target>",82+ );7683 }77- emitSiteDetail(await new ApiClient(config).patchSite(target, patch));84+ emitSiteDetail(await api.patchSite(target, patch));7885 }7986 8087 export async function cmdLs(
@@ -110,8 +117,8 @@ export async function cmdRm(
110117 raw: string | undefined,111118 yes: boolean,112119 ): Promise<void> {113- const target = targetOrFail(raw, config, "usage: nzip site rm <target> [--yes]");114120 const api = new ApiClient(config);121+ const target = await targetOrFail(raw, config, api, "usage: nzip site rm <target> [--yes]");115122 const site = await api.siteDetail(target);116123 const name = site.alias ? `${site.vault}:${site.alias}` : site.address;117124 if (!yes) {
@@ -180,12 +187,13 @@ export async function cmdRevert(
180187 toSeq: number | undefined,181188 list: boolean,182189 ): Promise<void> {183- const target = targetOrFail(190+ const api = new ApiClient(config);191+ const target = await targetOrFail(184192 raw,185193 config,194+ api,186195 "usage: nzip site revert <target> [--to N] [--list]",187196 );188- const api = new ApiClient(config);189197 190198 if (list) {191199 const site = await api.siteDetail(target);
M cli/commands/where.ts +11 -2
M cli/deno.json +1 -1
M cli/lib/api.ts +32 -5
@@ -22,7 +22,8 @@ import type {
2222 VaultInfo,2323 VaultLifecycle,2424 } from "@nzip/shared";25-import { assertRawAddressAllowed, assertVaultAllowed, type Config } from "./config.ts";25+import { parseAddress, vaultSlotOf } from "@nzip/shared";26+import { assertVaultAllowed, type Config } from "./config.ts";2627 2728 export class ApiClient {2829 constructor(private config: Config) {}
@@ -243,9 +244,24 @@ export class ApiClient {
243244 * Resolve a CLI target string to (API path target, commit body target).244245 * A bare alias picks up the default vault from config.245246 */246-export function resolveCliTarget(raw: string, config: Config): string {247+export function resolveCliTarget(248+ raw: string,249+ config: Config,250+ vaults: Pick<VaultInfo, "slot" | "name">[] = [],251+): string {247252 if (/^[0-9a-f]{4}$/.test(raw)) {248- assertRawAddressAllowed(config);253+ if (config.allowVaults) {254+ const slot = vaultSlotOf(parseAddress(raw));255+ const vault = vaults.find((candidate) => candidate.slot === slot);256+ if (!vault) {257+ throw new Error(258+ `cannot validate address ${raw}: authenticated status did not identify vault slot 0x${259+ slot.toString(16)260+ }`,261+ );262+ }263+ assertVaultAllowed(vault.name, config);264+ }249265 return raw;250266 }251267 if (raw.includes(":")) {
@@ -266,6 +282,7 @@ export function resolveCliTarget(raw: string, config: Config): string {
266282 export function commitTargetFor(267283 raw: string | undefined,268284 config: Config,285+ vaults: Pick<VaultInfo, "slot" | "name">[] = [],269286 ): Target {270287 if (raw === undefined) {271288 const vault = config.defaultVault;
@@ -274,10 +291,20 @@ export function commitTargetFor(
274291 return { vault };275292 }276293 if (/^[0-9a-f]{4}$/.test(raw)) {277- assertRawAddressAllowed(config);294+ resolveCliTarget(raw, config, vaults);278295 return { address: parseInt(raw, 16) };279296 }280- const resolved = resolveCliTarget(raw, config);297+ const resolved = resolveCliTarget(raw, config, vaults);281298 const [vault, alias] = resolved.split(":");282299 return { vault, alias };283300 }301+302+/** Resolve a target, fetching vault-slot metadata only when a raw address needs guarding. */303+export async function resolveCliTargetWithStatus(304+ raw: string,305+ config: Config,306+ api: Pick<ApiClient, "status">,307+): Promise<string> {308+ const vaults = /^[0-9a-f]{4}$/.test(raw) && config.allowVaults ? (await api.status()).vaults : [];309+ return resolveCliTarget(raw, config, vaults);310+}
M cli/lib/api_test.ts +21 -6
@@ -32,20 +32,35 @@ Deno.test("raw addresses remain available without allowVaults", () => {
3232 assertEquals(commitTargetFor("2a3f", unrestricted), { address: 0x2a3f });3333 });3434 35-Deno.test("resolveCliTarget refuses a raw address when allowVaults is set", () => {35+Deno.test("resolveCliTarget accepts a raw address in an allowed authenticated vault", () => {36+ assertEquals(37+ resolveCliTarget("2a3f", { ...restricted, allowVaults: ["home"] }, [38+ { slot: 2, name: "home" },39+ ]),40+ "2a3f",41+ );42+});43+44+Deno.test("resolveCliTarget refuses a raw address in a disallowed authenticated vault", () => {3645 assertThrows(37- () => resolveCliTarget("2a3f", restricted),38- "raw address targets are not allowed by this config because allowVaults is set",46+ () => resolveCliTarget("2a3f", restricted, [{ slot: 2, name: "work" }]),47+ 'vault "work" is not allowed',3948 );4049 });4150 42-Deno.test("commitTargetFor refuses a raw address when allowVaults is set", () => {51+Deno.test("resolveCliTarget refuses a raw address without authenticated slot metadata", () => {4352 assertThrows(44- () => commitTargetFor("2a3f", restricted),45- "raw address targets are not allowed by this config because allowVaults is set",53+ () => resolveCliTarget("2a3f", restricted),54+ "authenticated status did not identify vault slot 0x2",4655 );4756 });4857 58+Deno.test("commitTargetFor accepts a validated raw address when allowVaults is set", () => {59+ assertEquals(commitTargetFor("2a3f", restricted, [{ slot: 2, name: "home" }]), {60+ address: 0x2a3f,61+ });62+});63+4964 Deno.test("allowVaults still accepts an allowed named target", () => {5065 assertEquals(resolveCliTarget("home:demo", restricted), "home:demo");5166 assertEquals(commitTargetFor("home:demo", restricted), {
M cli/lib/config.ts +2 -9
M cli/lib/paths.ts +50 -7
@@ -5,7 +5,7 @@
55 // Kept clean by three forces: expired entries are dropped on every write,66 // `nzip site rm` deletes its entry, and `nzip site ls` reconciles against the live set.77 8-import { join, resolve } from "@std/path";8+import { basename, dirname, join, resolve } from "@std/path";99 import { configDir } from "./config.ts";1010 1111 export interface PathEntry {
@@ -29,6 +29,30 @@ function nowSeconds(): number {
2929 return Math.floor(Date.now() / 1000);3030 }3131 32+/**33+ * Canonical identity for a pushed source. A directory and its entry-point34+ * index.html are the same logical site, while other single-file pushes retain35+ * their own file identity.36+ */37+export function sourceRoot(path: string): string {38+ let absolute: string;39+ try {40+ absolute = Deno.realPathSync(path);41+ } catch {42+ absolute = resolve(path);43+ }44+45+ try {46+ const stat = Deno.statSync(absolute);47+ if (stat.isFile && basename(absolute).toLowerCase() === "index.html") {48+ return dirname(absolute);49+ }50+ } catch {51+ // Preserve a stable best-effort identity when the source has moved.52+ }53+ return absolute;54+}55+3256 async function load(): Promise<Registry> {3357 try {3458 const parsed = JSON.parse(await Deno.readTextFile(pathsFile())) as Registry;
@@ -61,8 +85,17 @@ export async function recordPush(entry: {
6185 path: string;6286 url: string;6387 expiresAt: number | null;88+ rebindSource?: boolean;6489 }): Promise<void> {6590 const reg = pruneExpired(await load());91+ if (entry.rebindSource) {92+ const root = sourceRoot(entry.path);93+ for (const [addr, existing] of Object.entries(reg)) {94+ if (addr !== entry.address && sourceRoot(existing.path) === root) {95+ delete reg[addr];96+ }97+ }98+ }6699 // An alias points at exactly one address at a time; clear any stale67100 // entry that still claims this vault:alias under a different address.68101 if (entry.vault && entry.alias) {
@@ -75,12 +108,13 @@ export async function recordPush(entry: {
75108 }76109 }77110 }78- let abs: string;79- try {80- abs = Deno.realPathSync(entry.path); // canonical absolute path; file exists now81- } catch {82- abs = resolve(entry.path);83- }111+ const abs = (() => {112+ try {113+ return Deno.realPathSync(entry.path); // canonical absolute path; file exists now114+ } catch {115+ return resolve(entry.path);116+ }117+ })();84118 reg[entry.address] = {85119 address: entry.address,86120 vault: entry.vault,
@@ -107,6 +141,15 @@ export async function lookup(
107141 return null;108142 }109143 144+/** Find every unexpired local breadcrumb produced from the same logical source. */145+export async function lookupBySource(path: string): Promise<PathEntry[]> {146+ const wanted = sourceRoot(path);147+ const reg = pruneExpired(await load());148+ return Object.values(reg)149+ .filter((entry) => sourceRoot(entry.path) === wanted)150+ .sort((a, b) => b.pushedAt - a.pushedAt);151+}152+110153 /** Rewrite breadcrumbs after `nzip vault update --name` so `nzip site where` keeps working. */111154 export async function renameVault(112155 oldName: string,
A cli/lib/paths_test.ts +33 -0
M cli/main.ts +5 -3
@@ -46,10 +46,10 @@ commands:
4646 │ ├─ ls list vaults4747 │ └─ default <temporary|permanent> <name>4848 ├─ site49- │ ├─ push <dir|file> [target] [--ttl ...] [--password PW | --no-password]49+ │ ├─ push <dir|file> [target] [--new] publish content; reuse source by default5050 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle5151 │ ├─ show <target> show site details52- │ ├─ update <target> [--ttl ...] [--password PW | --no-password]52+ │ ├─ policy <target> [--ttl ...] [--password PW | --no-password]5353 │ ├─ ls [vault] list sites5454 │ ├─ where <target> print this machine's source directory5555 │ ├─ rm <target> [--yes] delete a site
@@ -68,7 +68,7 @@ agent mode: add --json to any command for one-line JSON on stdout;
6868 errors go to stderr as {"ok":false,"error":…,"hint":…} with a suggested next step.6969 7070 vault guard: set "allowVaults": ["home"] in config.json to restrict this install71-to named vaults — pushes/aliases outside the list are refused (agent guardrail).71+to named vaults — raw addresses are authenticated and mapped to a vault before use.7272 7373 notification privacy: titles and bodies may appear on a lock screen; never include7474 passwords, tokens, private URLs, or sensitive personal data.
@@ -112,6 +112,7 @@ export async function main(argv = Deno.args): Promise<void> {
112112 "no-description",113113 "all",114114 "no-default-for",115+ "new",115116 ],116117 alias: { h: "help", V: "version", y: "yes" },117118 });
@@ -144,6 +145,7 @@ export async function main(argv = Deno.args): Promise<void> {
144145 ttl: args.ttl,145146 password: args.password,146147 noPassword: args["no-password"],148+ newSite: args.new,147149 overwrite: args.overwrite,148150 yes: args.yes,149151 toSeq,
M package-lock.json +1 -0
M package.json +2 -0
M shared/deno.json +1 -1
M shared/version.ts +1 -1
M skills/nzip/SKILL.md +13 -2
@@ -87,6 +87,12 @@ permissions when a sandbox blocks a check instead of reporting nzip as broken.
8787 plainly needs it. Do not use `forever` by default. Vault defaults may vary, so always pass8888 `--ttl 1d` for these short-lived artifacts rather than relying on omission.8989 90+ When revising an artifact, rerun the push from the same artifact root or pass the previously91+ returned target explicitly. An untargeted push reuses a unique local breadcrumb for that logical92+ source, treating a directory and its `index.html` as the same site. If multiple sites match, it93+ stops and lists candidates; select the intended existing target to rebind later revisions.94+ Never pass `--new` unless the user asked for a separate URL.95+9096 4. Use an explicit `<vault>:<alias>` target when the destination matters, picking a vault from9197 `nzip vault ls --json`:9298
@@ -117,10 +123,10 @@ nzip
117123 │ ├─ ls118124 │ └─ default <temporary|permanent> <name>119125 ├─ site120-│ ├─ push <dir|file> [target] [--ttl DAYS] [--password PW | --no-password]126+│ ├─ push <dir|file> [target] [--new] [--ttl DAYS] [--password PW | --no-password]121127 │ ├─ cp <target> [dir] [--overwrite]122128 │ ├─ show <target>123-│ ├─ update <target> [--ttl DAYS] [--password PW | --no-password]129+│ ├─ policy <target> [--ttl DAYS] [--password PW | --no-password]124130 │ ├─ ls [vault]125131 │ ├─ where <target>126132 │ ├─ rm <target> [--yes]
@@ -144,6 +150,9 @@ renaming to a name outside `allowVaults` is refused.
144150 For new sites, TTL precedence is explicit flag, existing-site expiry, vault default, then the145151 global 14-day fallback. Read `ttl` and `ttlSource` from JSON results instead of inferring them.146152 `public` describes durable retention, not access policy; it may still be password-protected.153+Use `site push` to publish revised content and `site policy` only to change TTL or password. An154+unaliased address is still a stable revision target; aliases are optional handles, not a requirement155+for retaining the same URL.147156 Treat `revert`, vault renames, password changes, TTL changes, and deletion as state changes; do not148157 perform them while merely diagnosing. Never infer deletion confirmation.149158
@@ -200,6 +209,8 @@ application concern.
200209 - Prefer finite TTLs; use `forever` only when explicitly requested.201210 - For durable lofi apps, use `app init`/`app deploy`; do not emulate the reservation with `site push`.202211 - Prefer descriptive aliases and an explicit vault when identity or audience matters.212+- Preserve a previously returned URL across revisions; use `--new` only for an intentionally213+ separate share.203214 - Preserve the local artifact directory for iteration and `nzip site where`; do not commit it.204215 - Use complete, self-contained HTML and relative asset paths.205216 - Use read-only status, list, and inspect operations before mutations when destination state is
M worker/test/index.runtime.test.ts +1 -1