1// Recover the current uploaded bundle for a site into a local directory.23import { dirname, join } from "@std/path";4import { sha256hex, validatePath } from "@nzip/shared";5import { ApiClient, resolveCliTargetWithStatus } from "../lib/api.ts";6import { formatBytes } from "../lib/bundle.ts";7import type { Config } from "../lib/config.ts";8import { bold, cyan, emit, fail, green } from "../lib/fmt.ts";910const DOWNLOAD_CONCURRENCY = 6;1112interface SourceApi {13 source(target: string): ReturnType<ApiClient["source"]>;14 downloadSourceBlob(15 target: string,16 hash: string,17 ): ReturnType<ApiClient["downloadSourceBlob"]>;18}1920export interface CopyResult {21 address: string;22 manifestHash: string;23 destination: string;24 files: number;25 bytes: number;26}2728async function targetOrFail(29 raw: string | undefined,30 config: Config,31 api: ApiClient,32): Promise<string> {33 if (!raw) fail("usage: nzip site cp <target> [dir] [--overwrite]");34 try {35 return await resolveCliTargetWithStatus(raw, config, api);36 } catch (e) {37 return fail((e as Error).message);38 }39}4041async function prepareDestination(42 path: string,43 overwrite: boolean,44): Promise<void> {45 try {46 const stat = await Deno.lstat(path);47 if (!stat.isDirectory || stat.isSymlink) {48 fail(`destination is not a directory: ${path}`);49 }50 for await (const _entry of Deno.readDir(path)) {51 if (!overwrite) {52 fail(53 `destination is not empty: ${path} (pass --overwrite to replace files)`,54 );55 }56 break;57 }58 } catch (e) {59 if (e instanceof Deno.errors.NotFound) {60 await Deno.mkdir(path, { recursive: true });61 return;62 }63 throw e;64 }65}6667async function makeParent(path: string): Promise<void> {68 await Deno.mkdir(dirname(path), { recursive: true });69}7071async function writeFile(72 path: string,73 bytes: Uint8Array,74 overwrite: boolean,75): Promise<void> {76 try {77 const existing = await Deno.lstat(path);78 if (existing.isDirectory || existing.isSymlink) {79 fail(`refusing to replace non-file: ${path}`);80 }81 if (!overwrite) fail(`destination file already exists: ${path}`);82 } catch (e) {83 if (!(e instanceof Deno.errors.NotFound)) throw e;84 }85 const temporary = `${path}.nzip-download-${crypto.randomUUID()}`;86 try {87 await Deno.writeFile(temporary, bytes);88 await Deno.rename(temporary, path);89 } finally {90 await Deno.remove(temporary).catch(() => {});91 }92}9394export async function cmdCp(95 config: Config,96 raw: string | undefined,97 destinationRaw: string | undefined,98 overwrite: boolean,99): Promise<void> {100 const api = new ApiClient(config);101 const target = await targetOrFail(raw, config, api);102 const result = await downloadSource(api, target, destinationRaw, overwrite);103104 emit(105 () =>106 console.log(107 `${green("✓")} copied ${bold(result.address)} → ${108 cyan(result.destination)109 } (${result.files} files, ${formatBytes(result.bytes)})`,110 ),111 { ok: true, ...result },112 );113}114115/** Reconstruct a source response locally; exported to keep recovery behavior testable. */116export async function downloadSource(117 api: SourceApi,118 target: string,119 destinationRaw: string | undefined,120 overwrite: boolean,121): Promise<CopyResult> {122 const source = await api.source(target);123 const destination = destinationRaw ?? `${source.address}-source`;124 await prepareDestination(destination, overwrite);125126 const files = Object.entries(source.manifest.files);127 const totalBytes = files.reduce((total, [, entry]) => total + entry.s, 0);128 const queue = [...files];129 let downloaded = 0;130 const workers = Array.from({131 length: Math.min(DOWNLOAD_CONCURRENCY, queue.length),132 }, async () => {133 for (let next = queue.shift(); next !== undefined; next = queue.shift()) {134 const [relativePath, entry] = next;135 if (!validatePath(relativePath)) {136 fail(`invalid path in source manifest: ${relativePath}`);137 }138 const bytes = await api.downloadSourceBlob(target, entry.h);139 if (bytes.length !== entry.s) {140 fail(`size mismatch while downloading ${relativePath}`);141 }142 if (await sha256hex(bytes) !== entry.h) {143 fail(`hash mismatch while downloading ${relativePath}`);144 }145 const outputPath = join(destination, ...relativePath.split("/"));146 await makeParent(outputPath);147 await writeFile(outputPath, bytes, overwrite);148 downloaded++;149 }150 });151 await Promise.all(workers);152153 return {154 address: source.address,155 manifestHash: source.manifestHash,156 destination,157 files: downloaded,158 bytes: totalBytes,159 };160}