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