@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands
4 lines (3 loc) • 16 kB
JavaScript
import H,{join as m,resolve as N}from"node:path";import{isInteractive as z,promptYesNo as J}from"@lunora/config";import{backupObjectKey as v,isBackupManifestEntry as B,normalizeBackupPrefix as W,BACKUP_KEY_PREFIX as Y,backupManifestKey as X,isBackupManifestKey as Q,backupObjectKeyOfManifest as D}from"@lunora/runtime";import{r as E,t as V}from"../packem_shared/admin-token-VdUnvnKW.mjs";import{r as T}from"../packem_shared/admin-url-BhF5ufg1.mjs";import{d as Z}from"../packem_shared/command-Mmxzxyr5.mjs";import{EXIT_CODE as u,isRefusal as p,exitCodeForStatus as k}from"../packem_shared/EXIT_CODE-08cwt3MK.mjs";import{a as ee}from"../packem_shared/resolve-target-C_ZloTvd.mjs";import{runExportCommand as re}from"../packem_shared/runExportCommand-LEwEr8p0.mjs";import{b as te,l as ae,M as I,u as oe,r as ne}from"../packem_shared/import-D7qdpSmJ.mjs";import{readAndLogBody as y}from"./runRpcCommand.mjs";import{randomUUID as G,createHash as ie}from"node:crypto";import{existsSync as A,createReadStream as se,createWriteStream as ce}from"node:fs";import{mkdir as de,rm as b,rename as j,readFile as C,writeFile as ue,realpath as U,mkdtemp as _}from"node:fs/promises";import{LunoraError as w}from"@lunora/errors";import{i as x}from"../packem_shared/path-containment-CgxYZggb.mjs";import{tmpdir as R}from"node:os";import{Readable as le}from"node:stream";import{pipeline as fe}from"node:stream/promises";const S="manifest.json",q=async e=>{const t=ie("sha256");let r=0;for await(const a of se(e)){const o=a;r+=o.byteLength,t.update(o)}return{bytes:r,sha256:t.digest("hex")}},O=async e=>{const t=m(e,S);if(!A(t))return[];let r;try{r=JSON.parse(await C(t,"utf8"))}catch(a){const o=a instanceof Error?a.message:String(a);throw new w("INTERNAL",`backup: ${t} exists but is not valid JSON (${o}) — refusing to overwrite it; fix or remove it manually`,{cause:a})}if(!Array.isArray(r))throw new TypeError(`backup: ${t} exists but is not a JSON array — refusing to overwrite it; fix or remove it manually`);return r.filter(a=>B(a))},me=async(e,t)=>{const r=m(e,`${S}.${G()}.tmp`);try{await ue(r,`${JSON.stringify(t,void 0,2)}
`,"utf8"),await j(r,m(e,S))}catch(a){throw await b(r,{force:!0}),a}},ge=async(e,t)=>{const r=N(e),a=N(r,t.file),o=()=>{throw new w("INTERNAL",`backup: entry ${t.id} points outside ${r} (${t.file}) — refusing to read it`)};if(x(r,a)||o(),!!A(a))return x(await U(r),await U(a))||o(),a},he=e=>{const t=r=>v("",r);return{commit:async(r,a)=>{await j(a,m(e,r))},label:e,list:async()=>O(e),locate:t,materialize:async(r,a)=>{const o=i=>i===void 0?void 0:{path:i,release:()=>Promise.resolve()};return o(r===void 0?A(a)?a:void 0:await ge(e,r))},record:async r=>{const a=await O(e);a.push(r),await me(e,a)},stage:async r=>{await de(e,{recursive:!0});const a=m(e,`${t(r)}.${G()}.partial`);return{path:a,release:()=>b(a,{force:!0})}}}},we="/_lunora/admin/storage/object",be="/_lunora/admin/storage",P=8,ke=(e,t=H)=>{const r=t.basename(e);return r===""||r==="."||r===".."?"snapshot.ndjson":r},ye=e=>{const{context:t,logger:r}=e,a=W(e.prefix??Y),o=`bucket ${t.bucket??"(default)"} under ${a}`,i=te(t),n=c=>`${t.baseUrl}${we}?key=${encodeURIComponent(c)}${i}`,f=async c=>{const s=await t.fetchImpl(n(c),{headers:{authorization:`Bearer ${t.token}`},method:"GET"});if(!s.ok){const l=await s.text().catch(()=>"<no body>");throw new w("INTERNAL",`backup: could not read ${c} (HTTP ${String(s.status)}): ${l}`)}return s.text()},K=async c=>{try{const s=JSON.parse(await f(c));if(B(s))return s;r.warn(`backup: ignoring ${c} — not a backup manifest`)}catch(s){r.warn(`backup: ignoring ${c} — ${s instanceof Error?s.message:String(s)}`)}};return{commit:async(c,s,l)=>{if(l.bytes>I)throw new w("INTERNAL",`backup: the snapshot is ${String(l.bytes)} bytes, above the ${String(I)}-byte limit for a checksum-verified upload — nothing was written. Narrow it with \`--tables\`, or write it to a directory with \`--dir\` and move the file yourself (\`wrangler r2 object put\`).`);const d=await C(s);await oe(t,c,d,{contentType:"application/x-ndjson",id:c,sha256:l.sha256,size:l.bytes},r)},label:o,list:async()=>{const s=(await ae(t,a)).map(d=>d.key).filter(d=>Q(d)),l=[];for(let d=0;d<s.length;d+=P){const g=await Promise.all(s.slice(d,d+P).map(async h=>K(h)));l.push(...g.filter(h=>h!==void 0))}return l.toSorted((d,g)=>d.id.localeCompare(g.id))},locate:c=>v(a,c),materialize:async(c,s)=>{const l=c?.file??s,d=await t.fetchImpl(n(l),{headers:{authorization:`Bearer ${t.token}`},method:"GET"});if(d.status===404)return;if(!d.ok||d.body===null){const $=await d.text().catch(()=>"<no body>");throw new w("INTERNAL",`backup: could not download ${l} (HTTP ${String(d.status)}): ${$}`)}const g=await _(m(R(),"lunora-backup-")),h=m(g,ke(l));try{await fe(le.from(d.body),ce(h))}catch($){throw await b(g,{force:!0,recursive:!0}),$}return{path:h,release:async()=>b(g,{force:!0,recursive:!0})}},record:async c=>{const s=X(c.file),l=await t.fetchImpl(`${t.baseUrl}${be}?key=${encodeURIComponent(s)}${i}`,{body:`${JSON.stringify(c,void 0,2)}
`,headers:{authorization:`Bearer ${t.token}`,"content-type":"application/json"},method:"PUT"});if(!l.ok){const d=await l.text().catch(()=>"<no body>");throw new w("INTERNAL",`backup: snapshot ${c.file} was written but its manifest was not (HTTP ${String(l.status)}): ${d}`)}},stage:async c=>{const s=await _(m(R(),"lunora-backup-"));return{path:m(s,v("",c)),release:async()=>b(s,{force:!0,recursive:!0})}}}},pe=".lunora-backups",F="/_lunora/admin/backup/retention",$e="/_lunora/admin/backup/prune",ve="/_lunora/admin/pitr",Se="__lunora_admin__:getPitrBookmark",Ee="__lunora_admin__:pitrRestore",Te=async(e,t)=>{const r=(e.now??(()=>new Date))().toISOString(),a=await t.stage(r);try{const o=await re({cwd:e.cwd,fetchImpl:e.fetchImpl,logger:e.logger,out:a.path,prod:e.prod,tables:e.tables,token:e.token,url:e.url});if(o.code!==0)return{code:o.code};const i=t.locate(r),n=await q(a.path);await t.commit(i,a.path,n);const f={bytes:n.bytes,createdAt:r,file:i,id:r,rows:o.rows,sha256:n.sha256,tables:e.tables};return await t.record(f),e.logger.success(`backup created: ${i} (${o.rows.toString()} rows, ${n.bytes.toString()} bytes)`),{code:0,entry:f}}finally{await a.release().catch(o=>{e.logger.warn(`backup: could not clean up staging (${o instanceof Error?o.message:String(o)})`)})}},Ae=async(e,t,r)=>{if(t?.sha256===void 0)return r.error(t===void 0?"--verify needs a recorded checksum, and a snapshot restored by path/key has no manifest entry — restore it by its backup id instead":`--verify: backup ${t.id} carries no recorded checksum — it was taken by a release before checksums existed`),{code:u.USAGE};const a=await q(e);if(a.sha256!==t.sha256)return r.error(`--verify: ${t.file} does not match its recorded checksum (expected ${t.sha256}, got ${a.sha256}) — nothing was imported`),{code:1};r.success(`verified ${t.file} against its recorded sha256`)},Ne=async(e,t)=>{const r=await t.list();if(r.length===0)return e.logger.info(`no backups found in ${t.label}`),{code:0,entries:[]};for(const a of r)e.logger.info(`${a.id} ${a.rows.toString()} rows ${a.bytes.toString()} bytes ${a.file}`);return{code:0,entries:r}},Ie=async(e,t)=>{const{target:r}=e;if(r===void 0||r.length===0)return e.logger.error("restore requires a backup id or file path. Usage: lunora backup restore <id|file>"),{code:u.USAGE};const o=(await t.list()).find(n=>n.id===r),i=await t.materialize(o,r);if(i===void 0)return e.logger.error(`backup not found: ${r}`),{code:u.NOT_FOUND};try{if(e.verify===!0){const f=await Ae(i.path,o,e.logger);if(f!==void 0)return f}const n=await ne({cwd:e.cwd,fetchImpl:e.fetchImpl,file:i.path,logger:e.logger,prod:e.prod,token:e.token,url:e.url,yes:e.yes});return{code:n.code,entry:o,restored:n.inserted}}finally{await i.release().catch(n=>{e.logger.warn(`backup: could not clean up the downloaded snapshot (${n instanceof Error?n.message:String(n)})`)})}},Ue=e=>{if(e.prod&&e.url===void 0)return e.logger.error("--prod requires an explicit --url (refusing to target the implicit localhost worker)"),{refused:u.USAGE};if(e.restore===!0&&e.at===void 0&&e.bookmark===void 0)return e.logger.error("pitr --restore requires --at <time> or --bookmark <bookmark>"),{refused:u.USAGE};const t=T(e.url,e.logger,e.cwd);if(t===void 0)return{refused:u.USAGE};if(e.restore===!0&&V({prod:e.prod,url:t})&&e.yes!==!0)return e.logger.error(`pitr --restore restores data in place at ${t}, which is not local. Re-run with --yes to confirm.`),{refused:u.USAGE};const{token:r}=E({cwd:e.cwd??process.cwd(),token:e.token,url:t});if(!r)return e.logger.error("admin token required — pass --token, set LUNORA_ADMIN_TOKEN, or add it to .dev.vars (local targets only)"),{refused:u.AUTH};const a=e.adminFetch??globalThis.fetch;if(typeof a!="function")throw new TypeError("no fetch implementation available — pass pitrFetch or run on Node >= 18");return{fetchImpl:a,requestUrl:`${t}${ve}`,token:r}},_e=(e,t)=>{const r={};return e.at!==void 0&&(r.time=e.at),t&&e.bookmark!==void 0&&(r.bookmark=e.bookmark),t&&e.restart===!0&&(r.restart=!0),r},xe=async e=>{const t=Ue(e);if(p(t))return{code:t.refused};const r=e.restore===!0,a=r?Ee:Se,o=_e(e,r),i=r?"restore":"bookmark";e.logger.info(`POST ${t.requestUrl} -> pitr ${i}${e.shard===void 0?"":` (shard "${e.shard}")`}`);const n=await t.fetchImpl(t.requestUrl,{body:JSON.stringify({args:o,functionPath:a,shardKey:e.shard}),headers:{authorization:`Bearer ${t.token}`,"content-type":"application/json"},method:"POST"});return{body:await y(n,e.logger),code:n.ok?0:k(n.status)}},Re=(e,t)=>{if(e.bucket===void 0)return e.prefix!==void 0?(e.logger.error("--prefix applies only to an R2 destination — pass --bucket alongside it, or use --dir to point at a local directory."),{refused:u.USAGE}):he(m(t,e.dir??pe));if(e.dir!==void 0)return e.logger.error("--dir applies only to a local destination and does not apply alongside --bucket — drop one."),{refused:u.USAGE};if(e.prod===!0&&e.url===void 0)return e.logger.error("--prod requires an explicit --url (refusing to target the implicit localhost worker)"),{refused:u.USAGE};const r=T(e.url,e.logger,e.cwd);if(r===void 0)return{refused:u.USAGE};const{token:a}=E({cwd:t,token:e.token,url:r});if(!a)return e.logger.error("admin token required — pass --token, set LUNORA_ADMIN_TOKEN, or add it to .dev.vars (local targets only)"),{refused:u.AUTH};const o=e.fetchImpl??globalThis.fetch;if(typeof o!="function")throw new TypeError("no fetch implementation available — pass fetchImpl or run on Node >= 18");return ye({context:{baseUrl:r,bucket:e.bucket==="default"?void 0:e.bucket,fetchImpl:o,token:a},logger:e.logger,prefix:e.prefix})},L=e=>{const t=[e.bucket===void 0?void 0:"--bucket",e.prefix===void 0?void 0:"--prefix",e.dir===void 0?void 0:"--dir"].filter(i=>i!==void 0);if(t.length>0)return e.logger.error(`${t.join(" / ")} ${t.length===1?"does":"do"} not apply here — retention and prune act on the store the worker itself is configured with (\`backupStore\`), not a destination you name.`),{refused:u.USAGE};if(e.prod===!0&&e.url===void 0)return e.logger.error("--prod requires an explicit --url (refusing to target the implicit localhost worker)"),{refused:u.USAGE};const r=T(e.url,e.logger,e.cwd);if(r===void 0)return{refused:u.USAGE};const{token:a}=E({cwd:e.cwd??process.cwd(),token:e.token,url:r});if(!a)return e.logger.error("admin token required — pass --token, set LUNORA_ADMIN_TOKEN, or add it to .dev.vars (local targets only)"),{refused:u.AUTH};const o=e.adminFetch??globalThis.fetch;if(typeof o!="function")throw new TypeError("no fetch implementation available — pass pitrFetch or run on Node >= 18");return{baseUrl:r,fetchImpl:o,token:a}},M=(e,t)=>{e.logger.info(`backup retention would keep the newest ${t.keep.toString()} of ${t.eligible.toString()} under ${t.prefix} and delete ${t.wouldDelete.length.toString()}`);for(const r of t.wouldDelete)e.logger.info(` ${D(r)}`)},Oe=async e=>{const t=L(e);if(p(t))return{code:t.refused};const r=await t.fetchImpl(`${t.baseUrl}${F}`,{headers:{authorization:`Bearer ${t.token}`},method:"GET"});if(!r.ok)return await y(r,e.logger),{code:k(r.status)};const a=await r.json();return a.cron===void 0?(e.logger.info("no scheduled backup is configured (`backupCron`), so retention would delete nothing"),{code:0,preview:a}):a.keep<=0?(e.logger.info(`retention is off for cron "${a.cron}" (\`backupRetain\` unset) — every snapshot under ${a.prefix} is kept`),{code:0,preview:a}):(M(e,a),a.eligible===0&&e.logger.info(`no snapshot under ${a.prefix} was written by this cron, so retention has nothing of its own to remove`),{code:0,preview:a})},Pe=(e,t)=>{e.logger.success(`deleted ${t.deleted.length.toString()} backup(s)`);for(const r of t.deleted)e.logger.info(` ${D(r)}`);t.failed.length>0&&e.logger.warn(`${t.failed.length.toString()} could not be removed — the snapshot may be gone with its manifest left behind; run again to retry`),t.ignored>0&&e.logger.info(`${t.ignored.toString()} were already gone by the time the delete ran`),t.remaining>0&&e.logger.info(`${t.remaining.toString()} more are past the window — run \`lunora backup prune\` again`)},Be=async e=>{const t=L(e);if(p(t))return{code:t.refused};const r=await t.fetchImpl(`${t.baseUrl}${F}`,{headers:{authorization:`Bearer ${t.token}`},method:"GET"});if(!r.ok)return await y(r,e.logger),{code:k(r.status)};const a=await r.json();if(a.keep<=0||a.cron===void 0)return e.logger.error("backup prune needs a retention window: set `backupRetain` (and `backupCron`) on the worker. Without one there is nothing past the window to remove."),{code:u.USAGE,preview:a};if(a.wouldDelete.length===0)return e.logger.info(`nothing to prune — ${a.eligible.toString()} snapshot(s) under ${a.prefix} and retention keeps the newest ${a.keep.toString()}`),{code:0,preview:a};if(M(e,a),e.yes!==!0){const n=e.confirm??(z()?J:void 0);if(n===void 0)return e.logger.error("refusing to delete backups without confirmation — re-run with --yes (there is no undo for an object-store delete)"),{code:u.USAGE,preview:a};if(!await n(`delete ${a.wouldDelete.length.toString()} backup(s)? This cannot be undone. [y/N] `))return e.logger.info("nothing was deleted"),{code:0,preview:a}}const o=await t.fetchImpl(`${t.baseUrl}${$e}`,{body:JSON.stringify({confirm:a.wouldDelete}),headers:{authorization:`Bearer ${t.token}`,"content-type":"application/json"},method:"POST"});if(!o.ok)return await y(o,e.logger),{code:k(o.status),preview:a};const i=await o.json();return Pe(e,i),{code:i.failed.length>0?1:0,deleted:i.deleted,preview:a}},De=async(e,t)=>{try{if(e.subcommand==="pitr")return await xe(e);if(e.subcommand==="retention")return await Oe(e);if(e.subcommand==="prune")return await Be(e);const r=Re(e,t);return p(r)?{code:r.refused}:e.subcommand==="create"?await Te(e,r):e.subcommand==="list"?await Ne(e,r):await Ie(e,r)}catch(r){return e.logger.error(r instanceof Error?r.message:String(r)),{code:1}}},Ge=async e=>{const t=e.cwd??process.cwd(),r=await De(e,t);return{...r,data:{subcommand:e.subcommand,...r.entries===void 0?{}:{entries:r.entries},...r.entry===void 0?{}:{entry:r.entry},...r.restored===void 0?{}:{restored:r.restored},...r.preview===void 0?{}:{preview:r.preview},...r.deleted===void 0?{}:{deleted:r.deleted},...r.body===void 0?{}:{result:r.body}}}},je=e=>e==="create"||e==="list"||e==="pitr"||e==="prune"||e==="restore"||e==="retention",or=Z(async({argument:e,cwd:t,format:r,logger:a,options:o})=>{const i=e[0];if(!je(i)){const f=`backup: unknown subcommand "${i??""}" — expected create | list | restore | retention | prune | pitr`;return a.error(f),{code:u.USAGE,error:f}}const n=await Ge({at:o.at,bookmark:o.bookmark,bucket:o.bucket,cwd:t,dir:o.dir,format:r,logger:a,prefix:o.prefix,prod:o.prod===!0,restart:o.restart===!0,restore:o.restore===!0,shard:o.shard,subcommand:i,tables:o.tables,target:e[1],token:o.token,url:ee({cwd:t,prod:o.prod===!0,url:o.url}),verify:o.verify===!0,yes:o.yes===!0});return{code:n.code,data:n.data,error:n.error}});export{or as execute,Ge as runBackupCommand};