UNPKG

@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) 14.7 kB
import M,{join as l,resolve as S}from"node:path";import{isInteractive as C,promptYesNo as F}from"@lunora/config";import{backupObjectKey as k,isBackupManifestEntry as U,normalizeBackupPrefix as J,BACKUP_KEY_PREFIX as G,backupManifestKey as H,isBackupManifestKey as W,backupObjectKeyOfManifest as A}from"@lunora/runtime";import{l as R}from"../packem_shared/admin-token-BKmc3AUm.mjs";import{u as y}from"../packem_shared/admin-url-Ca-KI3d_.mjs";import{i as Y}from"../packem_shared/command-0l-ZPhIX.mjs";import{d as X}from"../packem_shared/resolve-target-CeiNrCAx.mjs";import{readAndLogBody as m}from"./runRpcCommand.mjs";import{randomUUID as j,createHash as Q}from"node:crypto";import{existsSync as $,createReadStream as V,createWriteStream as Z}from"node:fs";import{mkdir as tt,rm as g,rename as B,readFile as P,writeFile as et,realpath as N,mkdtemp as x}from"node:fs/promises";import{LunoraError as f}from"@lunora/errors";import{e as T}from"../packem_shared/path-containment-CcTCKfzV.mjs";import{tmpdir as I}from"node:os";import{Readable as rt}from"node:stream";import{pipeline as ot}from"node:stream/promises";import{m as nt,a as E,b as at,c as it,S as st}from"../packem_shared/import-C9in1wC3.mjs";import{runExportCommand as ct}from"../packem_shared/runExportCommand-BpE4AbuX.mjs";const b="manifest.json",L=async t=>{const e=Q("sha256");let o=0;for await(const r of V(t)){const n=r;o+=n.byteLength,e.update(n)}return{bytes:o,sha256:e.digest("hex")}},_=async t=>{const e=l(t,b);if(!$(e))return[];let o;try{o=JSON.parse(await P(e,"utf8"))}catch(r){const n=r instanceof Error?r.message:String(r);throw new f("INTERNAL",`backup: ${e} exists but is not valid JSON (${n}) — refusing to overwrite it; fix or remove it manually`,{cause:r})}if(!Array.isArray(o))throw new TypeError(`backup: ${e} exists but is not a JSON array — refusing to overwrite it; fix or remove it manually`);return o.filter(r=>U(r))},dt=async(t,e)=>{const o=l(t,`${b}.${j()}.tmp`);try{await et(o,`${JSON.stringify(e,void 0,2)} `,"utf8"),await B(o,l(t,b))}catch(r){throw await g(o,{force:!0}),r}},ut=async(t,e)=>{const o=S(t),r=S(o,e.file),n=()=>{throw new f("INTERNAL",`backup: entry ${e.id} points outside ${o} (${e.file}) — refusing to read it`)};if(T(o,r)||n(),!!$(r))return T(await N(o),await N(r))||n(),r},lt=t=>{const e=o=>k("",o);return{commit:async(o,r)=>{await B(r,l(t,o))},label:t,list:async()=>_(t),locate:e,materialize:async(o,r)=>(a=>a===void 0?void 0:{path:a,release:()=>Promise.resolve()})(o===void 0?$(r)?r:void 0:await ut(t,o)),record:async o=>{const r=await _(t);r.push(o),await dt(t,r)},stage:async o=>{await tt(t,{recursive:!0});const r=l(t,`${e(o)}.${j()}.partial`);return{path:r,release:()=>g(r,{force:!0})}}}},pt="/_lunora/admin/storage/object",ft="/_lunora/admin/storage",O=8,gt=(t,e=M)=>{const o=e.basename(t);return o===""||o==="."||o===".."?"snapshot.ndjson":o},ht=t=>{const{context:e,logger:o}=t,r=J(t.prefix??G),n=`bucket ${e.bucket??"(default)"} under ${r}`,a=it(e),u=s=>`${e.baseUrl}${pt}?key=${encodeURIComponent(s)}${a}`,h=async s=>{const c=await e.fetchImpl(u(s),{headers:{authorization:`Bearer ${e.token}`},method:"GET"});if(!c.ok){const i=await c.text().catch(()=>"<no body>");throw new f("INTERNAL",`backup: could not read ${s} (HTTP ${String(c.status)}): ${i}`)}return c.text()},K=async s=>{try{const c=JSON.parse(await h(s));if(U(c))return c;o.warn(`backup: ignoring ${s} — not a backup manifest`)}catch(c){o.warn(`backup: ignoring ${s}${c instanceof Error?c.message:String(c)}`)}};return{commit:async(s,c,i)=>{if(i.bytes>E)throw new f("INTERNAL",`backup: the snapshot is ${String(i.bytes)} bytes, above the ${String(E)}-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 P(c);await at(e,s,d,{contentType:"application/x-ndjson",id:s,sha256:i.sha256,size:i.bytes},o)},label:n,list:async()=>{const s=(await nt(e,r)).map(i=>i.key).filter(i=>W(i)),c=[];for(let i=0;i<s.length;i+=O){const d=await Promise.all(s.slice(i,i+O).map(async p=>K(p)));c.push(...d.filter(p=>p!==void 0))}return c.toSorted((i,d)=>i.id.localeCompare(d.id))},locate:s=>k(r,s),materialize:async(s,c)=>{const i=s?.file??c,d=await e.fetchImpl(u(i),{headers:{authorization:`Bearer ${e.token}`},method:"GET"});if(d.status===404)return;if(!d.ok||d.body===null){const w=await d.text().catch(()=>"<no body>");throw new f("INTERNAL",`backup: could not download ${i} (HTTP ${String(d.status)}): ${w}`)}const p=await x(l(I(),"lunora-backup-")),v=l(p,gt(i));try{await ot(rt.from(d.body),Z(v))}catch(w){throw await g(p,{force:!0,recursive:!0}),w}return{path:v,release:async()=>g(p,{force:!0,recursive:!0})}},record:async s=>{const c=H(s.file),i=await e.fetchImpl(`${e.baseUrl}${ft}?key=${encodeURIComponent(c)}${a}`,{body:`${JSON.stringify(s,void 0,2)} `,headers:{authorization:`Bearer ${e.token}`,"content-type":"application/json"},method:"PUT"});if(!i.ok){const d=await i.text().catch(()=>"<no body>");throw new f("INTERNAL",`backup: snapshot ${s.file} was written but its manifest was not (HTTP ${String(i.status)}): ${d}`)}},stage:async s=>{const c=await x(l(I(),"lunora-backup-"));return{path:l(c,k("",s)),release:async()=>g(c,{force:!0,recursive:!0})}}}},mt=".lunora-backups",D="/_lunora/admin/backup/retention",wt="/_lunora/admin/backup/prune",kt="/_lunora/admin/pitr",bt="__lunora_admin__:getPitrBookmark",yt="__lunora_admin__:pitrRestore",$t=async(t,e)=>{const o=(t.now??(()=>new Date))().toISOString(),r=await e.stage(o);try{const n=await ct({cwd:t.cwd,fetchImpl:t.fetchImpl,logger:t.logger,out:r.path,prod:t.prod,tables:t.tables,token:t.token,url:t.url});if(n.code!==0)return{code:n.code};const a=e.locate(o),u=await L(r.path);await e.commit(a,r.path,u);const h={bytes:u.bytes,createdAt:o,file:a,id:o,rows:n.rows,sha256:u.sha256,tables:t.tables};return await e.record(h),t.logger.success(`backup created: ${a} (${n.rows.toString()} rows, ${u.bytes.toString()} bytes)`),{code:0,entry:h}}finally{await r.release().catch(n=>{t.logger.warn(`backup: could not clean up staging (${n instanceof Error?n.message:String(n)})`)})}},vt=async(t,e,o)=>{if(e?.sha256===void 0)return o.error(e===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 ${e.id} carries no recorded checksum — it was taken by a release before checksums existed`),{code:1};const r=await L(t);if(r.sha256!==e.sha256)return o.error(`--verify: ${e.file} does not match its recorded checksum (expected ${e.sha256}, got ${r.sha256}) — nothing was imported`),{code:1};o.success(`verified ${e.file} against its recorded sha256`)},St=async(t,e)=>{const o=await e.list();if(o.length===0)return t.logger.info(`no backups found in ${e.label}`),{code:0};for(const r of o)t.logger.info(`${r.id} ${r.rows.toString()} rows ${r.bytes.toString()} bytes ${r.file}`);return{code:0}},Nt=async(t,e)=>{const{target:o}=t;if(o===void 0||o.length===0)return t.logger.error("restore requires a backup id or file path. Usage: lunora backup restore <id|file>"),{code:1};const r=(await e.list()).find(a=>a.id===o),n=await e.materialize(r,o);if(n===void 0)return t.logger.error(`backup not found: ${o}`),{code:1};try{if(t.verify===!0){const a=await vt(n.path,r,t.logger);if(a!==void 0)return a}return{code:(await st({cwd:t.cwd,fetchImpl:t.fetchImpl,file:n.path,logger:t.logger,prod:t.prod,token:t.token,url:t.url,yes:t.yes})).code}}finally{await n.release().catch(a=>{t.logger.warn(`backup: could not clean up the downloaded snapshot (${a instanceof Error?a.message:String(a)})`)})}},xt=t=>{const e=t.token??process.env.LUNORA_ADMIN_TOKEN;if(!e){t.logger.error("admin token required — pass --token or set LUNORA_ADMIN_TOKEN");return}if(t.prod&&t.url===void 0){t.logger.error("--prod requires an explicit --url (refusing to target the implicit localhost worker)");return}if(t.restore===!0&&t.at===void 0&&t.bookmark===void 0){t.logger.error("pitr --restore requires --at <time> or --bookmark <bookmark>");return}if(t.restore===!0&&t.prod===!0&&t.yes!==!0){t.logger.error("pitr --restore --prod restores production data in place. Re-run with --yes to confirm.");return}const o=y(t.url,t.logger,t.cwd);if(o===void 0)return;const r=t.adminFetch??globalThis.fetch;if(typeof r!="function")throw new TypeError("no fetch implementation available — pass pitrFetch or run on Node >= 18");return{fetchImpl:r,requestUrl:`${o}${kt}`,token:e}},Tt=(t,e)=>{const o={};return t.at!==void 0&&(o.time=t.at),e&&t.bookmark!==void 0&&(o.bookmark=t.bookmark),e&&t.restart===!0&&(o.restart=!0),o},It=async t=>{const e=xt(t);if(e===void 0)return{code:1};const o=t.restore===!0,r=o?yt:bt,n=Tt(t,o),a=o?"restore":"bookmark";t.logger.info(`POST ${e.requestUrl} -> pitr ${a}${t.shard===void 0?"":` (shard "${t.shard}")`}`);const u=await e.fetchImpl(e.requestUrl,{body:JSON.stringify({args:n,functionPath:r,shardKey:t.shard}),headers:{authorization:`Bearer ${e.token}`,"content-type":"application/json"},method:"POST"});return await m(u,t.logger),{code:u.ok?0:1}},Et=(t,e)=>{if(t.bucket===void 0)return lt(l(e,t.dir??mt));if(t.prod===!0&&t.url===void 0){t.logger.error("--prod requires an explicit --url (refusing to target the implicit localhost worker)");return}const o=y(t.url,t.logger,t.cwd);if(o===void 0)return;const{token:r}=R({cwd:e,token:t.token,url:o});if(!r){t.logger.error("admin token required — pass --token, set LUNORA_ADMIN_TOKEN, or add it to .dev.vars (local targets only)");return}const n=t.fetchImpl??globalThis.fetch;if(typeof n!="function")throw new TypeError("no fetch implementation available — pass fetchImpl or run on Node >= 18");return ht({context:{baseUrl:o,bucket:t.bucket==="default"?void 0:t.bucket,fetchImpl:n,token:r},logger:t.logger,prefix:t.prefix})},q=t=>{const e=[t.bucket===void 0?void 0:"--bucket",t.prefix===void 0?void 0:"--prefix",t.dir===void 0?void 0:"--dir"].filter(a=>a!==void 0);if(e.length>0){t.logger.error(`${e.join(" / ")} ${e.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.`);return}if(t.prod===!0&&t.url===void 0){t.logger.error("--prod requires an explicit --url (refusing to target the implicit localhost worker)");return}const o=y(t.url,t.logger,t.cwd);if(o===void 0)return;const{token:r}=R({cwd:t.cwd??process.cwd(),token:t.token,url:o});if(!r){t.logger.error("admin token required — pass --token, set LUNORA_ADMIN_TOKEN, or add it to .dev.vars (local targets only)");return}const n=t.adminFetch??globalThis.fetch;if(typeof n!="function")throw new TypeError("no fetch implementation available — pass pitrFetch or run on Node >= 18");return{baseUrl:o,fetchImpl:n,token:r}},z=(t,e)=>{t.logger.info(`backup retention would keep the newest ${e.keep.toString()} of ${e.eligible.toString()} under ${e.prefix} and delete ${e.wouldDelete.length.toString()}`);for(const o of e.wouldDelete)t.logger.info(` ${A(o)}`)},_t=async t=>{const e=q(t);if(e===void 0)return{code:1};const o=await e.fetchImpl(`${e.baseUrl}${D}`,{headers:{authorization:`Bearer ${e.token}`},method:"GET"});if(!o.ok)return await m(o,t.logger),{code:1};const r=await o.json();return r.cron===void 0?(t.logger.info("no scheduled backup is configured (`backupCron`), so retention would delete nothing"),{code:0,preview:r}):r.keep<=0?(t.logger.info(`retention is off for cron "${r.cron}" (\`backupRetain\` unset) — every snapshot under ${r.prefix} is kept`),{code:0,preview:r}):(z(t,r),r.eligible===0&&t.logger.info(`no snapshot under ${r.prefix} was written by this cron, so retention has nothing of its own to remove`),{code:0,preview:r})},Ot=(t,e)=>{t.logger.success(`deleted ${e.deleted.length.toString()} backup(s)`);for(const o of e.deleted)t.logger.info(` ${A(o)}`);e.failed.length>0&&t.logger.warn(`${e.failed.length.toString()} could not be removed — the snapshot may be gone with its manifest left behind; run again to retry`),e.ignored>0&&t.logger.info(`${e.ignored.toString()} were already gone by the time the delete ran`),e.remaining>0&&t.logger.info(`${e.remaining.toString()} more are past the window — run \`lunora backup prune\` again`)},Ut=async t=>{const e=q(t);if(e===void 0)return{code:1};const o=await e.fetchImpl(`${e.baseUrl}${D}`,{headers:{authorization:`Bearer ${e.token}`},method:"GET"});if(!o.ok)return await m(o,t.logger),{code:1};const r=await o.json();if(r.keep<=0||r.cron===void 0)return t.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:1,preview:r};if(r.wouldDelete.length===0)return t.logger.info(`nothing to prune — ${r.eligible.toString()} snapshot(s) under ${r.prefix} and retention keeps the newest ${r.keep.toString()}`),{code:0,preview:r};if(z(t,r),t.yes!==!0){const u=t.confirm??(C()?F:void 0);if(u===void 0)return t.logger.error("refusing to delete backups without confirmation — re-run with --yes (there is no undo for an object-store delete)"),{code:1,preview:r};if(!await u(`delete ${r.wouldDelete.length.toString()} backup(s)? This cannot be undone. [y/N] `))return t.logger.info("nothing was deleted"),{code:0,preview:r}}const n=await e.fetchImpl(`${e.baseUrl}${wt}`,{body:JSON.stringify({confirm:r.wouldDelete}),headers:{authorization:`Bearer ${e.token}`,"content-type":"application/json"},method:"POST"});if(!n.ok)return await m(n,t.logger),{code:1,preview:r};const a=await n.json();return Ot(t,a),{code:a.failed.length>0?1:0,deleted:a.deleted,preview:r}},At=async t=>{const e=t.cwd??process.cwd();try{if(t.subcommand==="pitr")return await It(t);if(t.subcommand==="retention")return await _t(t);if(t.subcommand==="prune")return await Ut(t);const o=Et(t,e);return o===void 0?{code:1}:t.subcommand==="create"?await $t(t,o):t.subcommand==="list"?await St(t,o):await Nt(t,o)}catch(o){return t.logger.error(o instanceof Error?o.message:String(o)),{code:1}}},Rt=t=>t==="create"||t==="list"||t==="pitr"||t==="prune"||t==="restore"||t==="retention",Vt=Y(({argument:t,cwd:e,logger:o,options:r})=>{const n=t[0];return Rt(n)?At({at:r.at,bookmark:r.bookmark,bucket:r.bucket,cwd:e,dir:r.dir,logger:o,prefix:r.prefix,prod:r.prod===!0,restart:r.restart===!0,restore:r.restore===!0,shard:r.shard,subcommand:n,tables:r.tables,target:t[1],token:r.token,url:X({cwd:e,prod:r.prod===!0,url:r.url}),verify:r.verify===!0,yes:r.yes===!0}):(o.error(`backup: unknown subcommand "${n??""}" — expected create | list | restore | retention | prune | pitr`),{code:1})});export{Vt as execute,At as runBackupCommand};