1// Small output helpers.23export const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;4export const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;5export const green = (s: string) => `\x1b[32m${s}\x1b[0m`;6export const amber = (s: string) => `\x1b[33m${s}\x1b[0m`;7export const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;8export const red = (s: string) => `\x1b[31m${s}\x1b[0m`;910export function ago(unixSeconds: number): string {11 const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);12 if (s < 60) return `${s}s ago`;13 if (s < 3600) return `${Math.floor(s / 60)}m ago`;14 if (s < 86400) return `${Math.floor(s / 3600)}h ago`;15 return `${Math.floor(s / 86400)}d ago`;16}1718export function ttlLeft(expiresAt: number | null): string {19 if (expiresAt === null) return "forever";20 const s = expiresAt - Math.floor(Date.now() / 1000);21 if (s <= 0) return "expired";22 if (s < 3600) return `${Math.ceil(s / 60)}m`;23 if (s < 86400) return `${Math.ceil(s / 3600)}h`;24 return `${Math.ceil(s / 86400)}d`;25}2627export function table(headers: string[], rows: string[][]): string {28 const widths = headers.map((h, i) =>29 Math.max(h.length, ...rows.map((r) => stripAnsi(r[i]).length))30 );31 const line = (cells: string[], pad: (s: string, w: number) => string) =>32 cells.map((c, i) => pad(c, widths[i])).join(" ");33 const padAnsi = (s: string, w: number) => s + " ".repeat(Math.max(0, w - stripAnsi(s).length));34 return [35 dim(line(headers, (s, w) => s.padEnd(w))),36 ...rows.map((r) => line(r, padAnsi)),37 ].join("\n");38}3940function stripAnsi(s: string): string {41 // deno-lint-ignore no-control-regex42 return s.replace(/\x1b\[[0-9;]*m/g, "");43}4445let jsonMode = false;4647export function setJsonMode(on: boolean): void {48 jsonMode = on;49}5051/**52 * Print `data` as one JSON line in --json mode, otherwise run the human printer.53 * With `data` undefined the line is progress-only and suppressed in --json mode.54 */55export function emit(human: () => void, data?: unknown): void {56 if (jsonMode) {57 if (data !== undefined) console.log(JSON.stringify(data));58 } else {59 human();60 }61}6263/** Actionable next step for common failures — agents read this from the `hint` field. */64export function hintFor(message: string): string | undefined {65 if (/not authenticated/.test(message)) {66 return "run: nzip auth --server <url> --token <token>";67 }68 if (/401|unauthorized/i.test(message)) {69 return "token rejected — re-run: nzip auth --server <url> --token <token>";70 }71 if (/not allowed by this config/.test(message)) {72 return "this config restricts which vaults it may target — target an allowed vault, or ask to edit allowVaults in ~/.config/nzip/config.json";73 }74 if (/unknown vault/.test(message)) {75 return "list vaults with: nzip vault ls — register one with: nzip vault add <name>";76 }77 if (/site not found/.test(message)) {78 return "list sites with: nzip site ls --json (targets: 2a3f | vault:alias | alias)";79 }80 if (/bare alias.*defaultVault|no defaultVault/.test(message)) {81 return "use vault:alias explicitly, or set one with: nzip vault default temporary <name>";82 }83 if (/blobs missing/.test(message)) {84 return "an earlier upload was interrupted — re-run the same nzip site push";85 }86 if (/invalid --ttl|ttl must be/.test(message)) {87 return "use --ttl 14d, --ttl 30d, or --ttl forever";88 }89 if (/invalid target/.test(message)) {90 return "targets are 4 hex chars (2a3f), vault:alias (work:demo), or a bare alias";91 }92 if (/error sending request|Connection refused|dns error/i.test(message)) {93 return "server unreachable — check the server URL with: nzip status, or re-run nzip auth";94 }95 if (/vault is full|slots are taken/.test(message)) {96 return "check usage with: nzip vault ls — remove sites with: nzip site rm <target>";97 }98 return undefined;99}100101export function fail(message: string, hint = hintFor(message)): never {102 if (jsonMode) {103 console.error(104 JSON.stringify({ ok: false, error: message, ...(hint ? { hint } : {}) }),105 );106 } else {107 console.error(`${red("✗")} ${message}`);108 if (hint) console.error(` ${dim(`↳ ${hint}`)}`);109 }110 Deno.exit(1);111}112113/** Parse "14d" | "30" | "forever" → ttl value for the API. */114export function parseTtl(raw: string): number | "forever" {115 if (raw === "forever" || raw === "0") return "forever";116 const m = raw.match(/^(\d+)d?$/);117 if (!m) throw new Error(`invalid --ttl: ${raw} (use e.g. 14d, 30d, forever)`);118 return parseInt(m[1], 10);119}