1// Site lifecycle operations plus the global deployment status command.23import type { SiteDetail, SiteFeedback } from "@nzip/shared";4import { ApiClient, resolveCliTargetWithStatus } from "../lib/api.ts";5import type { Config } from "../lib/config.ts";6import { forget, reconcile } from "../lib/paths.ts";7import {8 ago,9 amber,10 bold,11 cyan,12 dim,13 emit,14 fail,15 green,16 parseTtl,17 table,18 ttlLeft,19} from "../lib/fmt.ts";2021async function targetOrFail(22 raw: string | undefined,23 config: Config,24 api: ApiClient,25 usage: string,26): Promise<string> {27 if (!raw) fail(usage);28 try {29 return await resolveCliTargetWithStatus(raw, config, api);30 } catch (e) {31 return fail((e as Error).message);32 }33}3435function emitSiteDetail(site: SiteDetail): void {36 const name = site.alias ? `${site.vault}:${site.alias}` : site.address;37 emit(38 () =>39 console.log(40 `${bold(name)} ${cyan(site.url)} ${site.manifestHash.slice(0, 8)} ${41 site.expiresAt === null ? green("forever") : `expires in ${ttlLeft(site.expiresAt)}`42 }${site.protected ? ` ${amber("🔒")}` : ""}${43 site.feedback ? ` ${dim("feedback on")}` : ""44 }`,45 ),46 { ok: true, ...site },47 );48}4950export async function cmdSiteShow(51 config: Config,52 raw: string | undefined,53): Promise<void> {54 const api = new ApiClient(config);55 const target = await targetOrFail(raw, config, api, "usage: nzip site show <target>");56 emitSiteDetail(await api.siteDetail(target));57}5859export async function cmdSitePolicy(60 config: Config,61 raw: string | undefined,62 ttlRaw: string | undefined,63 password: string | undefined,64 noPassword: boolean,65 feedback?: boolean,66): Promise<void> {67 const api = new ApiClient(config);68 const target = await targetOrFail(69 raw,70 config,71 api,72 "usage: nzip site policy <target> [--ttl 14d|forever] [--password PW | --no-password] [--feedback | --no-feedback]",73 );74 if (password !== undefined && noPassword) {75 fail("choose either --password or --no-password, not both");76 }77 const patch: { ttl?: number | "forever"; password?: string | null; feedback?: boolean } = {};78 if (ttlRaw !== undefined) patch.ttl = parseTtl(ttlRaw);79 if (noPassword) patch.password = null;80 else if (password !== undefined) patch.password = password;81 if (feedback !== undefined) patch.feedback = feedback;82 if (Object.keys(patch).length === 0) {83 fail(84 "site policy requires --ttl, --password, --no-password, --feedback, or --no-feedback",85 "to publish revised content, run: nzip site push <dir|file> <target>",86 );87 }88 emitSiteDetail(await api.patchSite(target, patch));89}9091/** Visitor text must not drive the terminal: drop control characters except newline and tab. */92export function printableNote(text: string): string {93 // deno-lint-ignore no-control-regex94 return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");95}9697function emitFeedback(feedback: SiteFeedback, extra: Record<string, unknown> = {}): void {98 emit(() => {99 const round = [100 feedback.enabled ? green("feedback on") : amber("feedback off"),101 feedback.requestedAt === null ? "" : `requested ${ago(feedback.requestedAt)}`,102 feedback.submittedAt === null ? "" : green(`submitted ${ago(feedback.submittedAt)}`),103 `push #${feedback.seq}`,104 ].filter(Boolean).join(" ");105 console.log(`${bold(feedback.address)} ${cyan(feedback.url)} ${round}`);106 if (feedback.items.length === 0) {107 console.log(dim(" no notes yet"));108 return;109 }110 for (const item of feedback.items) {111 console.log(` ${bold(item.item)} ${dim(`(push #${item.seq}, ${ago(item.updatedAt)})`)}`);112 for (const line of printableNote(item.text).split("\n")) console.log(` ${line}`);113 }114 }, { ok: true, ...feedback, ...extra });115}116117/** Read a site's visitor notes, or open a new review round with --request. */118export async function cmdSiteFeedback(119 config: Config,120 raw: string | undefined,121 request: boolean,122 message: string | undefined,123 callback?: string,124): Promise<void> {125 const api = new ApiClient(config);126 const target = await targetOrFail(127 raw,128 config,129 api,130 "usage: nzip site feedback <target> [--request [--message TEXT] [--callback HTTPS_URL]]",131 );132 if ((message !== undefined || callback !== undefined) && !request) {133 fail("--message and --callback only apply with --request");134 }135 if (!request) return emitFeedback(await api.siteFeedback(target));136137 const { notification, ...feedback } = await api.requestFeedback(target, message, callback);138 emit(() => {139 const delivery = notification === null140 ? dim("notifications are disabled on the server")141 : `notified ${notification.queuedDevices} device${142 notification.queuedDevices === 1 ? "" : "s"143 }`;144 const armed = feedback.callbackPending ? ` ${dim("callback armed")}` : "";145 console.log(146 `${green("✓")} requested feedback on ${bold(feedback.address)} ${delivery}${armed}`,147 );148 }, { ok: true, ...feedback, notification });149}150151export async function cmdLs(152 config: Config,153 vault: string | undefined,154): Promise<void> {155 const api = new ApiClient(config);156 const sites = await api.listSites(vault);157 // The server is authoritative for what still exists — drop breadcrumbs for158 // sites it no longer lists (expired, deleted here or on another machine).159 await reconcile(new Set(sites.map((s) => s.address)), vault).catch(() => {});160 emit(() => {161 if (sites.length === 0) {162 console.log(dim(vault ? `no sites in ${vault}` : "no sites"));163 return;164 }165 console.log(table(166 ["ADDR", "VAULT", "ALIAS", "PUSHED", "EXPIRES", "MANIFEST"],167 sites.map((s) => [168 cyan(s.address),169 s.vault,170 (s.alias ?? dim("—")) + (s.protected ? " 🔒" : ""),171 ago(s.updatedAt),172 s.expiresAt === null ? green("forever") : ttlLeft(s.expiresAt),173 dim(s.manifestHash.slice(0, 8)),174 ]),175 ));176 }, { ok: true, sites });177}178179export async function cmdRm(180 config: Config,181 raw: string | undefined,182 yes: boolean,183): Promise<void> {184 const api = new ApiClient(config);185 const target = await targetOrFail(raw, config, api, "usage: nzip site rm <target> [--yes]");186 const site = await api.siteDetail(target);187 const name = site.alias ? `${site.vault}:${site.alias}` : site.address;188 if (!yes) {189 const answer = prompt(`delete ${name} (${site.url})? [y/N]`);190 if (answer?.toLowerCase() !== "y") {191 emit(() => console.log(dim("aborted")), {192 ok: false,193 error: "aborted (pass --yes to skip the prompt)",194 });195 return;196 }197 }198 await api.deleteSite(target);199 await forget({200 address: site.address,201 vault: site.vault,202 alias: site.alias ?? undefined,203 }).catch(204 () => {},205 );206 emit(207 () =>208 console.log(209 `${green("✓")} removed ${bold(name)} — content becomes eligible for GC`,210 ),211 { ok: true, removed: site.address, alias: site.alias, vault: site.vault },212 );213}214215export async function cmdStatus(config: Config): Promise<void> {216 const api = new ApiClient(config);217 const status = await api.status();218 emit(() => {219 console.log(`${green("●")} ${config.server} ${dim(`v${status.version}`)}`);220 console.log(221 ` sites: ${status.siteCount}${222 status.expiringSoon > 0 ? ` ${amber(`(${status.expiringSoon} expiring <48h)`)}` : ""223 }`,224 );225 console.log(226 ` defaults: temporary=${status.defaultVaults.temporary ?? "unset"} permanent=${227 status.defaultVaults.permanent ?? "unset"228 } global ttl=${status.globalDefaultTtl}d`,229 );230 if (status.vaults.length > 0) {231 console.log(table(232 ["SLOT", "VAULT", "SITES", "DEFAULT TTL", "DEFAULT FOR", "DESCRIPTION"],233 status.vaults.map((234 v,235 ) => [236 `0x${v.slot.toString(16)}`,237 v.name,238 String(v.siteCount),239 String(v.effectiveDefaultTtl),240 v.defaultFor.join(","),241 v.description ?? "",242 ]),243 ));244 }245 }, { ...status, server: config.server });246}247248export async function cmdRevert(249 config: Config,250 raw: string | undefined,251 toSeq: number | undefined,252 list: boolean,253): Promise<void> {254 const api = new ApiClient(config);255 const target = await targetOrFail(256 raw,257 config,258 api,259 "usage: nzip site revert <target> [--to N] [--list]",260 );261262 if (list) {263 const site = await api.siteDetail(target);264 emit(() =>265 console.log(table(266 ["PUSH", "MANIFEST", "WHEN", "NOTE"],267 site.history.map((h) => [268 h.manifestHash === site.manifestHash ? bold(`#${h.seq} ←`) : `#${h.seq}`,269 dim(h.manifestHash.slice(0, 8)),270 ago(h.pushedAt),271 h.note ?? "",272 ]),273 )), { ok: true, current: site.manifestHash, history: site.history });274 return;275 }276277 const res = await api.revert(target, toSeq);278 emit(279 () =>280 console.log(281 `${green("✓")} ${bold(raw!)} reverted to push #${res.revertedTo} (${282 dim(res.manifestHash.slice(0, 8))283 }) — ${cyan(res.url)}`,284 ),285 { ok: true, ...res },286 );287}