1import { ApiClient, commitTargetFor } from "../lib/api.ts";2import { buildBundle, formatBytes } from "../lib/bundle.ts";3import type { Config } from "../lib/config.ts";4import { lookupBySource, recordPush } from "../lib/paths.ts";5import { amber, bold, cyan, dim, emit, fail, green, parseTtl, ttlLeft } from "../lib/fmt.ts";67const UPLOAD_CONCURRENCY = 6;89export function formatPushPolicy(10 expiresAt: number | null,11 protectedSite: boolean,12): string {13 const expiry = expiresAt === null ? "forever" : `expires in ${ttlLeft(expiresAt)}`;14 const passwordStatus = protectedSite ? "password protected" : "no password";15 return `${expiry}, ${passwordStatus}`;16}1718export async function cmdPush(19 config: Config,20 path: string | undefined,21 targetRaw: string | undefined,22 ttlRaw: string | undefined,23 password: string | undefined,24 noPassword: boolean,25 newSite: boolean,26 app?: { contentSecurityPolicy?: string },27): Promise<void> {28 if (!path) {29 fail(30 "usage: nzip site push <dir|file> [target] [--new] [--ttl 14d|forever] [--password PW | --no-password]",31 );32 }33 if (password !== undefined && noPassword) {34 fail("choose either --password or --no-password, not both");35 }36 if (newSite && targetRaw !== undefined) {37 fail("--new cannot be combined with an explicit target");38 }39 const api = new ApiClient(config);4041 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 }5556 // Resolve (and vault-guard) the target up front — before any bundling or57 // upload — so a disallowed vault is refused without touching the network.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 ) {69 return config;70 }71 const defaultVault = status?.defaultVaults?.temporary ?? config.defaultVault;72 if (!defaultVault) {73 fail("server has no temporary default vault — specify vault:alias or configure one");74 }75 return { ...config, defaultVault };76 })();77 const target = (() => {78 try {79 return commitTargetFor(effectiveTargetRaw, targetConfig, status?.vaults);80 } catch (e) {81 return fail((e as Error).message);82 }83 })();8485 const bundle = await buildBundle(path).catch((e) => fail((e as Error).message));86 const fileCount = Object.keys(bundle.manifest.files).length;87 emit(() => {88 console.log(89 dim(90 ` bundling ${path} … ${fileCount} files, ${formatBytes(bundle.totalBytes)}`,91 ),92 );93 for (const w of bundle.warnings) console.log(` ${amber("!")} ${w}`);94 }, undefined);9596 const prep = await api.prepare(bundle.manifest);97 const dedupCount = bundle.blobs.size - prep.missing.length;98 const missingBytes = prep.missing.reduce(99 (n, h) => n + (bundle.blobs.get(h)?.length ?? 0),100 0,101 );102 emit(() =>103 console.log(104 dim(105 ` manifest ${prep.manifestHash.slice(0, 8)} — ${prep.missing.length} new blobs (${106 formatBytes(missingBytes)107 }), ${dedupCount} deduped`,108 ),109 ), undefined);110111 // Upload missing blobs with bounded concurrency.112 let done = 0;113 const queue = [...prep.missing];114 const workers = Array.from(115 { length: Math.min(UPLOAD_CONCURRENCY, queue.length) },116 async () => {117 for (let h = queue.shift(); h !== undefined; h = queue.shift()) {118 const bytes = bundle.blobs.get(h);119 if (!bytes) fail(`internal: missing blob bytes for ${h}`);120 await api.uploadBlob(h, bytes);121 done++;122 }123 },124 );125 await Promise.all(workers);126 if (prep.missing.length > 0) {127 emit(() => console.log(dim(` uploaded ${done}/${prep.missing.length}`)));128 }129130 const ttl = ttlRaw === undefined ? undefined : parseTtl(ttlRaw);131 const passwordPolicy = noPassword ? null : password;132 const res = await api.commit({133 manifest: bundle.manifest,134 target,135 ttl,136 password: passwordPolicy,137 ...(app === undefined ? {} : { app }),138 });139140 // Breadcrumb: remember which directory this machine pushed from so141 // `nzip site where` can find it later. Best-effort — never fail a push over it.142 await recordPush({143 address: res.address,144 vault: "vault" in target ? target.vault : undefined,145 alias: res.alias,146 path,147 url: res.url,148 expiresAt: res.expiresAt,149 rebindSource: targetRaw !== undefined,150 }).catch(() => {});151152 const label = res.alias ? `${"vault" in target ? target.vault : "?"}:${res.alias}` : res.address;153 const policy = formatPushPolicy(res.expiresAt, res.protected);154 emit(155 () =>156 console.log(157 `${green("✓")} pushed ${bold(label)} → ${cyan(res.url)} ${158 dim(`(${policy}, push #${res.seq})`)159 }`,160 ),161 {162 ok: true,163 ...res,164 files: fileCount,165 newBlobs: prep.missing.length,166 dedupedBlobs: dedupCount,167 warnings: bundle.warnings,168 },169 );170}