clone

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

main
nzip / cli / lib / paths.ts
paths.ts 6.0K
1// Local breadcrumb registry: which directory on *this* machine produced each2// pushed site. The server never learns the source path, so we keep a small3// map here so `nzip site where <target>` can answer "where does that live on disk".4//5// Kept clean by three forces: expired entries are dropped on every write,6// `nzip site rm` deletes its entry, and `nzip site ls` reconciles against the live set.78import { basename, dirname, join, resolve } from "@std/path";9import { configDir } from "./config.ts";1011export interface PathEntry {12 address: string; // 4-hex, canonical identity of the site13 vault?: string;14 alias?: string;15 path: string; // absolute source dir/file this machine pushed from16 url: string;17 expiresAt: number | null; // unix seconds, null = permanent18 pushedAt: number; // unix seconds19}2021/** address → entry */22type Registry = Record<string, PathEntry>;2324function pathsFile(): string {25 return join(configDir(), "paths.json");26}2728function nowSeconds(): number {29 return Math.floor(Date.now() / 1000);30}3132/**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 */37export function sourceRoot(path: string): string {38 let absolute: string;39 try {40 absolute = Deno.realPathSync(path);41 } catch {42 absolute = resolve(path);43 }4445 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}5556async function load(): Promise<Registry> {57 try {58 const parsed = JSON.parse(await Deno.readTextFile(pathsFile())) as Registry;59 return parsed && typeof parsed === "object" ? parsed : {};60 } catch {61 return {};62 }63}6465async function save(reg: Registry): Promise<void> {66 await Deno.mkdir(configDir(), { recursive: true });67 const file = pathsFile();68 await Deno.writeTextFile(file, JSON.stringify(reg, null, 2) + "\n");69 await Deno.chmod(file, 0o600);70}7172/** Drop entries whose TTL has already elapsed — the server has GC'd them. */73function pruneExpired(reg: Registry, now = nowSeconds()): Registry {74 for (const [addr, e] of Object.entries(reg)) {75 if (e.expiresAt !== null && e.expiresAt <= now) delete reg[addr];76 }77 return reg;78}7980/** Record (or refresh) the source path for a site just pushed from `path`. */81export async function recordPush(entry: {82 address: string;83 vault?: string;84 alias?: string | null;85 path: string;86 url: string;87 expiresAt: number | null;88 rebindSource?: boolean;89}): Promise<void> {90 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 }99 // An alias points at exactly one address at a time; clear any stale100 // entry that still claims this vault:alias under a different address.101 if (entry.vault && entry.alias) {102 for (const [addr, e] of Object.entries(reg)) {103 if (104 addr !== entry.address && e.vault === entry.vault &&105 e.alias === entry.alias106 ) {107 delete reg[addr];108 }109 }110 }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 })();118 reg[entry.address] = {119 address: entry.address,120 vault: entry.vault,121 alias: entry.alias ?? undefined,122 path: abs,123 url: entry.url,124 expiresAt: entry.expiresAt,125 pushedAt: nowSeconds(),126 };127 await save(reg);128}129130/** Look up a tracked source path by address or by vault:alias. */131export async function lookup(132 q: { address?: string; vault?: string; alias?: string },133): Promise<PathEntry | null> {134 const reg = pruneExpired(await load());135 if (q.address && reg[q.address]) return reg[q.address];136 if (q.vault && q.alias) {137 for (const e of Object.values(reg)) {138 if (e.vault === q.vault && e.alias === q.alias) return e;139 }140 }141 return null;142}143144/** Find every unexpired local breadcrumb produced from the same logical source. */145export 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}152153/** Rewrite breadcrumbs after `nzip vault update --name` so `nzip site where` keeps working. */154export async function renameVault(155 oldName: string,156 newName: string,157): Promise<void> {158 const reg = pruneExpired(await load());159 for (const e of Object.values(reg)) {160 if (e.vault === oldName) e.vault = newName;161 }162 await save(reg);163}164165/** Forget a single site (called from `nzip site rm`). */166export async function forget(167 q: { address?: string; vault?: string; alias?: string },168): Promise<void> {169 const reg = await load();170 let changed = false;171 for (const [addr, e] of Object.entries(reg)) {172 const hit = (q.address && addr === q.address) ||173 (q.vault && q.alias && e.vault === q.vault && e.alias === q.alias);174 if (hit) {175 delete reg[addr];176 changed = true;177 }178 }179 if (changed) await save(reg);180}181182/**183 * Drop registry entries the server no longer lists. `live` is the set of184 * addresses returned by the API; when `scopeVault` is set only entries in185 * that vault are eligible for removal (the listing was vault-scoped).186 */187export async function reconcile(188 live: Set<string>,189 scopeVault?: string,190): Promise<void> {191 const reg = pruneExpired(await load());192 let changed = false;193 for (const [addr, e] of Object.entries(reg)) {194 if (scopeVault && e.vault !== scopeVault) continue;195 if (!live.has(addr)) {196 delete reg[addr];197 changed = true;198 }199 }200 // pruneExpired above may also have removed entries — persist either way.201 await save(reg);202 void changed;203}