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) 15.1 kB
import F,{join as l,resolve as N}from"node:path";import{isInteractive as K,promptYesNo as z}from"@lunora/config";import{backupObjectKey as p,isBackupManifestEntry as O,normalizeBackupPrefix as H,BACKUP_KEY_PREFIX as J,backupManifestKey as G,isBackupManifestKey as W,backupObjectKeyOfManifest as A}from"@lunora/runtime";import{r as v,t as Y}from"../packem_shared/admin-token-VdUnvnKW.mjs";import{r as $}from"../packem_shared/admin-url-BhF5ufg1.mjs";import{d as X}from"../packem_shared/command-DwC11Cwe.mjs";import{r as Q}from"../packem_shared/resolve-target-BRvw8Pf7.mjs";import{readAndLogBody as b}from"./runRpcCommand.mjs";import{randomUUID as B,createHash as V}from"node:crypto";import{existsSync as T,createReadStream as Z,createWriteStream as ee}from"node:fs";import{mkdir as re,rm as w,rename as U,readFile as D,writeFile as te,realpath as S,mkdtemp as E}from"node:fs/promises";import{LunoraError as h}from"@lunora/errors";import{i as I}from"../packem_shared/path-containment-CgxYZggb.mjs";import{tmpdir as _}from"node:os";import{Readable as ae}from"node:stream";import{pipeline as oe}from"node:stream/promises";import{b as ne,l as ie,M as x,u as ce,r as se}from"../packem_shared/import-Dqd1MOOv.mjs";import{runExportCommand as de}from"../packem_shared/runExportCommand-Dq-SpjAe.mjs";const y="manifest.json",j=async e=>{const r=V("sha256");let a=0;for await(const t of Z(e)){const o=t;a+=o.byteLength,r.update(o)}return{bytes:a,sha256:r.digest("hex")}},R=async e=>{const r=l(e,y);if(!T(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=>O(t))},ue=async(e,r)=>{const a=l(e,`${y}.${B()}.tmp`);try{await te(a,`${JSON.stringify(r,void 0,2)} `,"utf8"),await U(a,l(e,y))}catch(t){throw await w(a,{force:!0}),t}},le=async(e,r)=>{const a=N(e),t=N(a,r.file),o=()=>{throw new h("INTERNAL",`backup: entry ${r.id} points outside ${a} (${r.file}) — refusing to read it`)};if(I(a,t)||o(),!!T(t))return I(await S(a),await S(t))||o(),t},fe=e=>{const r=a=>p("",a);return{commit:async(a,t)=>{await U(t,l(e,a))},label:e,list:async()=>R(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)?t:void 0:await le(e,a))},record:async a=>{const t=await R(e);t.push(a),await ue(e,t)},stage:async a=>{await re(e,{recursive:!0});const t=l(e,`${r(a)}.${B()}.partial`);return{path:t,release:()=>w(t,{force:!0})}}}},ge="/_lunora/admin/storage/object",me="/_lunora/admin/storage",P=8,he=(e,r=F)=>{const a=r.basename(e);return a===""||a==="."||a===".."?"snapshot.ndjson":a},we=e=>{const{context:r,logger:a}=e,t=H(e.prefix??J),o=`bucket ${r.bucket??"(default)"} under ${t}`,i=ne(r),d=c=>`${r.baseUrl}${ge}?key=${encodeURIComponent(c)}${i}`,f=async c=>{const n=await r.fetchImpl(d(c),{headers:{authorization:`Bearer ${r.token}`},method:"GET"});if(!n.ok){const u=await n.text().catch(()=>"<no body>");throw new h("INTERNAL",`backup: could not read ${c} (HTTP ${String(n.status)}): ${u}`)}return n.text()},M=async c=>{try{const n=JSON.parse(await f(c));if(O(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,u)=>{if(u.bytes>x)throw new h("INTERNAL",`backup: the snapshot is ${String(u.bytes)} bytes, above the ${String(x)}-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 ce(r,c,s,{contentType:"application/x-ndjson",id:c,sha256:u.sha256,size:u.bytes},a)},label:o,list:async()=>{const n=(await ie(r,t)).map(s=>s.key).filter(s=>W(s)),u=[];for(let s=0;s<n.length;s+=P){const g=await Promise.all(n.slice(s,s+P).map(async m=>M(m)));u.push(...g.filter(m=>m!==void 0))}return u.toSorted((s,g)=>s.id.localeCompare(g.id))},locate:c=>p(t,c),materialize:async(c,n)=>{const u=c?.file??n,s=await r.fetchImpl(d(u),{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 ${u} (HTTP ${String(s.status)}): ${k}`)}const g=await E(l(_(),"lunora-backup-")),m=l(g,he(u));try{await oe(ae.from(s.body),ee(m))}catch(k){throw await w(g,{force:!0,recursive:!0}),k}return{path:m,release:async()=>w(g,{force:!0,recursive:!0})}},record:async c=>{const n=G(c.file),u=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(!u.ok){const s=await u.text().catch(()=>"<no body>");throw new h("INTERNAL",`backup: snapshot ${c.file} was written but its manifest was not (HTTP ${String(u.status)}): ${s}`)}},stage:async c=>{const n=await E(l(_(),"lunora-backup-"));return{path:l(n,p("",c)),release:async()=>w(n,{force:!0,recursive:!0})}}}},be=".lunora-backups",q="/_lunora/admin/backup/retention",ke="/_lunora/admin/backup/prune",pe="/_lunora/admin/pitr",ye="__lunora_admin__:getPitrBookmark",ve="__lunora_admin__:pitrRestore",$e=async(e,r)=>{const a=(e.now??(()=>new Date))().toISOString(),t=await r.stage(a);try{const o=await de({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),d=await j(t.path);await r.commit(i,t.path,d);const f={bytes:d.bytes,createdAt:a,file:i,id:a,rows:o.rows,sha256:d.sha256,tables:e.tables};return await r.record(f),e.logger.success(`backup created: ${i} (${o.rows.toString()} rows, ${d.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)})`)})}},Te=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`)},Ne=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}},Se=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(d=>d.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 Te(i.path,o,e.logger);if(f!==void 0)return f}return{code:(await se({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(d=>{e.logger.warn(`backup: could not clean up the downloaded snapshot (${d instanceof Error?d.message:String(d)})`)})}},Ee=e=>{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}const r=$(e.url,e.logger,e.cwd);if(r===void 0)return;if(e.restore===!0&&Y({prod:e.prod,url:r})&&e.yes!==!0){e.logger.error(`pitr --restore restores data in place at ${r}, which is not local. Re-run with --yes to confirm.`);return}const{token:a}=v({cwd:e.cwd??process.cwd(),token:e.token,url:r});if(!a){e.logger.error("admin token required — pass --token, set LUNORA_ADMIN_TOKEN, or add it to .dev.vars (local targets only)");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:`${r}${pe}`,token:a}},Ie=(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},_e=async e=>{const r=Ee(e);if(r===void 0)return{code:1};const a=e.restore===!0,t=a?ve:ye,o=Ie(e,a),i=a?"restore":"bookmark";e.logger.info(`POST ${r.requestUrl} -> pitr ${i}${e.shard===void 0?"":` (shard "${e.shard}")`}`);const d=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(d,e.logger),{code:d.ok?0:1}},xe=(e,r)=>{if(e.bucket===void 0){if(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.");return}return fe(l(r,e.dir??be))}if(e.dir!==void 0){e.logger.error("--dir applies only to a local destination and does not apply alongside --bucket — drop one.");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=$(e.url,e.logger,e.cwd);if(a===void 0)return;const{token:t}=v({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 we({context:{baseUrl:a,bucket:e.bucket==="default"?void 0:e.bucket,fetchImpl:o,token:t},logger:e.logger,prefix:e.prefix})},L=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=$(e.url,e.logger,e.cwd);if(a===void 0)return;const{token:t}=v({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}},C=(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(` ${A(a)}`)},Re=async e=>{const r=L(e);if(r===void 0)return{code:1};const a=await r.fetchImpl(`${r.baseUrl}${q}`,{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}):(C(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})},Pe=(e,r)=>{e.logger.success(`deleted ${r.deleted.length.toString()} backup(s)`);for(const a of r.deleted)e.logger.info(` ${A(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`)},Oe=async e=>{const r=L(e);if(r===void 0)return{code:1};const a=await r.fetchImpl(`${r.baseUrl}${q}`,{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(C(e,t),e.yes!==!0){const d=e.confirm??(K()?z:void 0);if(d===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 d(`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}${ke}`,{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 Pe(e,i),{code:i.failed.length>0?1:0,deleted:i.deleted,preview:t}},Ae=async e=>{const r=e.cwd??process.cwd();try{if(e.subcommand==="pitr")return await _e(e);if(e.subcommand==="retention")return await Re(e);if(e.subcommand==="prune")return await Oe(e);const a=xe(e,r);return a===void 0?{code:1}:e.subcommand==="create"?await $e(e,a):e.subcommand==="list"?await Ne(e,a):await Se(e,a)}catch(a){return e.logger.error(a instanceof Error?a.message:String(a)),{code:1}}},Be=e=>e==="create"||e==="list"||e==="pitr"||e==="prune"||e==="restore"||e==="retention",Ze=X(({argument:e,cwd:r,logger:a,options:t})=>{const o=e[0];return Be(o)?Ae({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:Q({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{Ze as execute,Ae as runBackupCommand};