UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands

2 lines (1 loc) 20 kB
import{existsSync as y,readFileSync as R}from"node:fs";import{runCodegen as _,discoverMigrations as O}from"@lunora/codegen";import{readLinkedProject as A,writeLinkedProject as B,resolveDeployDriver as D,inferLunoraBindings as U,DEV_VARS_FILE as w,parseDevVariableEntries as F,isMintableSecretKey as C,discoverContainerInfo as I,packageNamesFromBindings as P,requiredSecrets as W,generateSecretValue as K,upsertDevVariableLine as q,writeDevVariablesFileAtomically as M,discoverSchemaInfo as V}from"@lunora/config";import{validateWranglerProject as Y,reconcileWranglerBindings as G,reconcileWranglerCompatibilityDate as z,reconcileWranglerCrons as H,findWranglerFile as J,readWranglerJsonc as Q}from"@lunora/config/cloudflare";import{join as h}from"@visulima/path";import{Spinner as X}from"@visulima/spinner";import{Project as Z}from"ts-morph";import{n as ee,t as re}from"../packem_shared/advisory-gate-yaOOyUQl.mjs";import{r as te}from"../packem_shared/api-spec-BENwiyUa.mjs";import{d as j}from"../packem_shared/wrangler-name-CHDf3rMP.mjs";import{i as oe}from"../packem_shared/command-0l-ZPhIX.mjs";import{i as ne}from"../packem_shared/deploy-target-Dvr9vxpR.mjs";import{M as k,l as $}from"../packem_shared/detect-package-manager-DXDstphE.mjs";import{o as ie,r as ae}from"../packem_shared/docker-CKvYE9YF.mjs";import{p as se,b as ce,c as le}from"../packem_shared/health-probe-CiFgh1hn.mjs";import{i as de,t as b,d as ue,s as ge}from"../packem_shared/output-format-Cv3aLqhP.mjs";import{a as pe}from"../packem_shared/platform-diagnostics-C4TFj5Pu.mjs";import{m as me}from"../packem_shared/post-codegen-hook-DhL1RH5u.mjs";import{containerBuildTag as fe}from"@lunora/container";import{defaultSpawner as S}from"../packem_shared/createRecordingSpawner-SKs4R1fc.mjs";import{i as E}from"../packem_shared/resolve-target-CeiNrCAx.mjs";import{R as he}from"../packem_shared/schema-drift-gate-d12lMICD.mjs";import{I as ve}from"../packem_shared/tui-prompts-BU3irGxV.mjs";import{m as we,p as ye}from"../packem_shared/vectorize-metadata-BvZbbqUD.mjs";import{f as ke}from"../packem_shared/wrangler-secrets-D0Tv5GWn.mjs";import{runMigrateDataCommand as $e}from"./runMigrateGenerateCommand.mjs";const be=/https?:\/\/[^\s"'<>]+\.workers\.dev[^\s"'<>]*/u,Se=/https:\/\/[^\s"'<>]+/u,Re=e=>{if(e===void 0)return;const r=be.exec(e);return r?r[0]:Se.exec(e)?.[0]},N=e=>e===void 0?"":` (--env ${e})`,De=({cwd:e,env:r,logger:t,now:o,url:n})=>{if(n!==void 0)try{const i=A(e);if(i!==void 0){if(i.workerUrl===n&&i.env===r)return;t.warn(`link: .lunora/project.json records ${i.workerUrl??"(no url)"}${N(i.env)}, but this deploy published ${n}${N(r)}. Keeping the recorded value — run \`lunora link --url ${n}${r===void 0?"":` --env ${r}`}\` to update it.`);return}const a=(o??(()=>new Date().toISOString()))();B(e,{env:r,linkedAt:a,workerName:j(e),workerUrl:n}),t.success(`link: recorded ${n} in .lunora/project.json (run \`lunora link\` to change)`)}catch{}},je=e=>{const{cwd:r,env:t,logger:o,migrated:n,mintedSecretsFile:i}=e;try{const a=A(r),c=a?.workerName??j(r),l=e.url??a?.workerUrl;o.success("deploy complete"),o.info(` worker: ${c??"(see wrangler output above)"}`),t!==void 0&&o.info(` env: ${t}`),l===void 0?o.info(" url: run `lunora link --url <https://your-worker>` to record it"):o.info(` url: ${l}`),n&&o.info(" migrations: applied"),i!==void 0&&o.info(` secrets: generated value(s) recorded in ${i}`),o.info(" studio: lunora view --remote"),o.info(" logs: lunora logs")}catch{}},xe=async e=>{if(e.targets.length===0)return{builtTags:[],code:0};if(!(e.railpackAvailable??ie)()){const o="deploy blocked: a container uses `image: { build }` (Railpack), but Railpack isn't ready. Install the `railpack` CLI and start a BuildKit instance, e.g. `docker run --rm --privileged -d --name buildkit moby/buildkit` then `export BUILDKIT_HOST=docker-container://buildkit`. Alternatively switch the container's `image` to a Dockerfile path or a pre-built registry reference.";return e.logger.error(o),{builtTags:[],code:1,error:o}}const r=e.spawner??S,t=[];for(const o of e.targets){const n=fe(o.exportName),i={args:["build",o.buildDir,"--name",n],command:"railpack",cwd:e.cwd},a=k($(e.cwd),"wrangler",["containers","push",n]),c={args:a.args,command:a.command,cwd:e.cwd};e.logger.info(`railpack: building "${o.exportName}" → ${n} from ${o.buildDir}`);const l=await r(i);if(l.code!==0){const s=`railpack build failed for container "${o.exportName}" (${o.buildDir})`;return e.logger.error(s),{builtTags:t,code:l.code,error:s}}e.logger.info(`railpack: pushing ${n} to the Cloudflare Registry`);const d=await r(c);if(d.code!==0){const s=`wrangler containers push failed for "${o.exportName}" (${n})`;return e.logger.error(s),{builtTags:t,code:d.code,error:s}}t.push(n)}return{builtTags:t,code:0}},L="<replace-with-d1-create-id>",x=e=>{const r=J(e);return r?Q(r).parsed:void 0},Ce=["LUNORA_ORIGIN_URL","LUNORA_WORKER_ORIGIN","AUTH_URL"],Ne=e=>{try{const{hostname:r}=new URL(e);return r==="localhost"||r==="127.0.0.1"||r==="::1"||r==="[::1]"}catch{return!1}},Te=e=>e.startsWith("./")||e.startsWith("../")||e.startsWith("/")||e.includes("Dockerfile"),Ae=(e,r,t)=>{const o=(x(e)?.containers??[]).filter(i=>typeof i?.image=="string"&&Te(i.image));if(o.length===0||t())return;const n=`deploy blocked: wrangler.jsonc declares ${String(o.length)} container(s) built from a local Dockerfile, but no Docker-compatible engine is available. Start Docker (or Colima), or point the container's \`image\` at a pre-built registry reference. Note: container images must target linux/amd64.`;return r.error(n),n},Ue=e=>y(h(e,"src","worker.ts"))?"src/worker.ts":void 0,Fe=(e,r)=>{for(const t of I(e,"lunora").containers){const{image:o}=t;if(o.kind==="dockerfile"&&!y(h(e,o.dockerfilePath))){const n=`deploy blocked: container "${t.exportName}" references a Dockerfile at "${o.dockerfilePath}" that does not exist. Create it or fix the \`image\` path in lunora/containers.ts.`;return r.error(n),n}if(o.kind==="build"&&!y(h(e,o.buildDir))){const n=`deploy blocked: container "${t.exportName}" references a Railpack build directory "${o.buildDir}" that does not exist. Create it or fix the \`image.build\` path in lunora/containers.ts.`;return r.error(n),n}}},Ie=e=>b(e.format)?!1:e.interactive!==void 0?e.interactive:process.stdout.isTTY&&!process.env.CI,Ee=e=>(x(e)?.d1_databases??[]).find(r=>r.database_id===L)?.binding,Le=async(e,r)=>{const t=I(e,"lunora").containers.filter(n=>n.image.kind==="build").map(n=>({buildDir:n.image.buildDir,exportName:n.exportName}));if(t.length===0)return;const o=await xe({cwd:e,logger:r.logger,railpackAvailable:r.railpackAvailable,spawner:r.spawner,targets:t});return o.code===0?void 0:o.error??"railpack build failed"},_e=(e,r,t)=>{if(t!==void 0)try{const o=H(e,t);o.changed&&r.success(`synced ${String(t.length)} cron trigger(s) → ${o.wranglerPath??"wrangler.jsonc"}`)}catch(o){const n=o instanceof Error?o.message:String(o);r.warn(`cron trigger sync skipped: ${n}`)}},Oe=async(e,r,t,o,n)=>{try{D(o);const i=await U({projectRoot:e}),a=G(e,i,n);a.changed&&r.success(`provisioned bindings: ${a.added.join(", ")}${a.wranglerPath??"wrangler.jsonc"}`);for(const c of a.warnings)r.warn(c)}catch(i){const a=i instanceof Error?i.message:String(i);r.warn(`binding inference skipped: ${a}`)}try{const i=z(e);i.changed&&r.success(`bumped compatibility_date to ${i.date??"unknown"} (Workers Cache enabled) → ${i.wranglerPath??"wrangler.jsonc"}`)}catch(i){const a=i instanceof Error?i.message:String(i);r.warn(`compatibility date sync skipped: ${a}`)}_e(e,r,t)},Be=(e,r)=>{const t=h(e,w);if(!y(t))return;let o;try{o=F(R(t,"utf8")).length}catch{return}o!==0&&r.warn(`Note: \`lunora deploy\` does not push secrets. ${w} has ${String(o)} key(s); if you changed them, run \`lunora env push --yes\` to update the deployed secrets.`)},Pe=/(?:KEY|PASSWORD|SECRET|TOKEN)$/u,We=async e=>{let r=[];try{r=P(await U({projectRoot:e}))}catch{}const t=W(r).map(n=>n.key);let o=[];try{const n=h(e,w);y(n)&&(o=F(R(n,"utf8")).map(i=>i.key).filter(i=>Pe.test(i)))}catch{}return[...new Set([...t,...o])]},Ke=async(e,r,t,o)=>{const{logger:n}=r,i=r.spawner??S,a=$(e),c=r.env===void 0?"":` --env ${r.env}`,{toolchain:l}=D(o);if(l===void 0)return n.error("deploy target has no command-line toolchain; cannot push secrets"),{minted:[],ok:!1};const d=[];for(const s of t){const u=K(),m=l.secretPut({environment:r.env,key:s,temporary:r.temporary}),f=k(a,m.tool,m.args),g=await i({args:f.args,command:f.command,cwd:e,input:u});if(g.code!==0)return n.error(`failed to push secret ${s} (exit ${String(g.code)}); set it manually with \`wrangler secret put ${s}${c}\`.`),{minted:d,ok:!1};d.push({key:s,value:u}),n.success(`generated + pushed ${s}`)}return{minted:d,ok:!0}},qe=/^[\w-]+$/u,Me=(e,r,t)=>{if(t.length===0)return;const o=t.map(c=>c.key).join(", ");if(r.env!==void 0&&!qe.test(r.env)){r.logger.warn(`${o}: minted and pushed for --env ${r.env}, but "${r.env}" isn't a safe filename fragment — the value could not be recorded. Capture it manually if you need it again.`);return}const n=r.env===void 0?w:`${w}.${r.env}`,i=h(e,n);let a=y(i)?R(i,"utf8"):"";for(const c of t)a=q(a,c.key,c.value);return M(i,a),r.logger.success(r.env===void 0?`${o}: value(s) recorded in ${n}`:`${o}: value(s) recorded in ${n} (kept separate from ${w} so \`lunora dev\` and a plain \`lunora env push\` don't inherit the --env ${r.env} value)`),n},Ve=async(e,r,t,o)=>{if(r.dryRun===!0||r.preview===!0)return{};const{logger:n}=r,i=r.env===void 0?"":` --env ${r.env}`;let a;try{a=await(r.secretLister??ke)({cwd:e,env:r.env,temporary:r.temporary})}catch{return{}}if(!a.ok)return{};const c=new Set(a.names),l=(await We(e)).filter(s=>!c.has(s));if(l.length===0)return{};if(!t)return{error:`missing required secret(s) on the deploy target: ${l.join(", ")}. Set them with \`wrangler secret put <KEY>${i}\` (or \`lunora env generate --set\` then \`lunora env push --yes${i}\`), then re-deploy.`};for(const s of l.filter(u=>!C(u)))n.warn(`required secret ${s} is not set on the target — set it with: wrangler secret put ${s}${i}`);const d=l.filter(s=>C(s));if(d.length===0)return{};if(await(r.secretConfirm??ve())(`${String(d.length)} required secret(s) not set on the target (${d.join(", ")}). Generate strong values and push them now?`)){const{minted:s,ok:u}=await Ke(e,r,d,o),m=Me(e,r,s);return u?{mintedSecretsFile:m}:{error:"failed to push required secret(s) — set them manually and re-deploy",mintedSecretsFile:m}}return n.warn(`${String(d.length)} required secret(s) not set on the target: ${d.join(", ")}. Generate + push with \`lunora env generate --set\` then \`lunora env push --yes${i}\`.`),{}},Ye=async(e,r)=>{const t=new Z({skipAddingFilesFromTsConfig:!0}),o=h(r,"lunora");let n;try{n=O(t,o)}catch(i){const a=i instanceof Error?i.message:String(i);return e.logger.warn(`--migrate: could not discover migrations (${a}); skipping`),0}if(n.length===0)return e.logger.info("--migrate: no data migrations declared in lunora/"),0;e.logger.info(`--migrate: running ${String(n.length)} migration(s) against deployed worker`);for(const i of n){e.logger.info(`--migrate: up "${i.id}" (table "${i.table}")`);const a={cwd:r,fetchImpl:e.fetchImpl,id:i.id,logger:e.logger,prod:!0,subcommand:"up",token:e.migrateToken,url:e.migrateUrl,yes:e.migrateYes===!0},c=await $e(a);if(c.code!==0)return e.logger.error(`--migrate: migration "${i.id}" failed — see output above`),c.code;e.logger.success(`--migrate: "${i.id}" applied`)}return 0},Ge=e=>{if(!e.migrate||e.dryRun||e.preview)return;if(e.migrateUrl===void 0){const t="--migrate requires --migrate-url <https://your-worker> — the deploy target URL is only known after wrangler runs, and this gate runs before it; refusing to default to localhost";return e.logger.error(t),t}if(e.migrateYes!==!0){const t="--migrate runs production data migrations after deploy. Re-run with --migrate-yes to confirm.";return e.logger.error(t),t}const r=e.migrateToken??process.env.LUNORA_ADMIN_TOKEN;if(r===void 0||r===""){const t="admin token required for --migrate — pass --migrate-token or set LUNORA_ADMIN_TOKEN";return e.logger.error(t),t}},ze=async(e,r,t,o,n,i,a,c)=>{let l;r?(l=new X({name:"dots"}),l.start("running codegen")):t.info("running codegen");try{const d=_({apiSpec:o,projectRoot:e,target:n});l?.succeed("codegen complete"),l||t.success("codegen complete");const s=pe(d.platformDiagnostics,t);if(s!==void 0)return{error:s};const{errorAdvisories:u,names:m,shouldBlock:f}=ee(d.advisories,c);if(f){const p=`${u.length.toString()} ERROR-level ${u.length===1?"advisory":"advisories"} (${m.join(", ")}). Pass --no-strict-advisories to downgrade this to a warning and deploy anyway.`;return t.error(p),{error:p}}const g=await me({cwd:e,logger:t,spawner:i,stdoutToStderr:a});return g.error!==void 0?(t.error(g.error),{error:g.error}):{result:d}}catch(d){l?.failed("codegen failed");const s=d instanceof Error?d.message:String(d);return t.error(`codegen failed: ${s}`),{error:`codegen failed: ${s}`}}},He=(e,r)=>{const t=Ee(e);if(t===void 0)return;const o=`deploy blocked: the "${t}" D1 binding has a placeholder database_id ("${L}"). Run \`wrangler d1 create <name>\` to create the database, then replace the placeholder in wrangler.jsonc with the real id before deploying.`;return r.error(o),o},Je=(e,r)=>{const t=x(e)?.vars;if(!t)return;const o=Ce.filter(i=>typeof t[i]=="string"&&Ne(t[i]));if(o.length===0)return;const n=`deploy blocked: ${o.join(", ")} in wrangler.jsonc point at localhost. A deployed Worker can't reach a loopback address, so this silently breaks scheduled-job dispatch / auth callbacks. Set each to the deployed worker's public URL (or move it to a secret with \`wrangler secret put\`) before deploying.`;return r.error(n),n},Qe=async(e,r)=>{const{info:t}=V(r,"lunora"),o=t?.vectorMetadata??[];if(o.length===0)return;const n=[];for(const a of o){const c=ye(a.kind);if(c===void 0){e.logger.warn(`vector index "${a.index}" declares metadata "${a.property}", whose column type cannot be filtered on in Vectorize — it is stored with each vector but no filter will match it.`);continue}n.push({index:a.index,property:a.property,type:c})}if(n.length===0)return;const i=(await we({cwd:r,entries:n,exec:k($(r),"wrangler",[]),logger:e.logger,spawner:e.spawner??S})).filter(a=>a.status!=="failed").length;i>0&&e.logger.success(`vectorize metadata indexes ready: ${String(i)}/${String(n.length)}`)},T=5,Xe=2e3,Ze=async(e,r,t)=>{if(e.healthCheck!==!0)return;const o=t??E({cwd:r,env:e.env});if(o===void 0){const i="--health-check: the deploy succeeded, but no URL to probe could be resolved — wrangler's output carried none and this checkout has no link for this environment. Run `lunora link --url <https://your-worker>` and re-deploy, or drop --health-check.";return e.logger.error(i),{error:i,ok:!1,url:""}}const n=await se({attempts:T,baseUrl:o,delayMs:Xe,fetchImpl:e.healthFetch,paths:[ce,le],sleep:e.healthSleep});return n.error===void 0?(e.logger.success(`health check ok (${n.url})`),{ok:!0,url:n.url}):(e.logger.error(`--health-check: the deploy succeeded, but the new version did not answer after ${String(T)} attempt(s) — ${n.error}`),{error:n.error,ok:!1,url:n.url})},er=async(e,r,t,o,n,i)=>{await Qe(e,r);const a=e.migrate?await Ye(e,r):0;return a===0&&n?.(),{code:a,descriptor:t,mintedSecretsFile:i,validation:o}},rr=async(e,r)=>{const t=He(e,r.logger);if(t!==void 0)return t;const o=Je(e,r.logger);if(o!==void 0)return o;const n=Fe(e,r.logger);if(n!==void 0)return n;const i=Ae(e,r.logger,r.dockerAvailable??ae);return i!==void 0?i:Le(e,r)},tr=(e,r,t)=>{const o=Ue(e);o!==void 0&&r.logger.info(`class-B composition: deploying ${o} (overrides wrangler main)`),r.temporary&&r.logger.info("temporary account: deploying to a short-lived Cloudflare account (~60min); wrangler will print a claim URL"),r.dryRun&&r.logger.info("dry run: validating + bundling without publishing"),r.outDir!==void 0&&r.logger.info(`build artifact: emitting bundle to ${r.outDir}`);const n=D(t),i={dryRun:r.dryRun,entry:o,environment:r.env,outDir:r.outDir,preview:r.preview,temporary:r.temporary};if(n.toolchain===void 0)throw new Error(`deploy target "${n.id}" has no command-line toolchain`);return n.toolchain.deploy(i)},v=(e,r)=>({code:1,descriptor:void 0,error:e,validation:{problems:[],wranglerPath:void 0},...r}),or=(e,r)=>{if(e.problems.length===0)return!1;r.error("wrangler.jsonc validation failed:");for(const t of e.problems)r.error(` - ${t}`);return!0},nr=(e,r,t)=>{const o=b(r.format),n=r.dryRun!==!0,i=tr(e,r,t),a=k($(e),i.tool,i.args);return{args:a.args,captureStdout:n&&!o,captureStdoutSilently:n&&o,command:a.command,cwd:e,stdoutToStderr:o&&!n}},ir=async({cwd:e,descriptor:r,mintedSecretsFile:t,options:o,reblessSchemaBaseline:n,stdout:i,validation:a})=>{const c={deployedAt:new Date().toISOString(),dryRun:o.dryRun===!0,env:o.env,preview:o.preview===!0,url:o.dryRun===!0?void 0:Re(i),workerName:j(e)};if(o.dryRun===!0||o.preview===!0)return o.healthCheck===!0&&o.logger.warn(`--health-check skipped: ${o.dryRun===!0?"a dry run publishes nothing":"a preview version serves no live traffic"}`),{code:0,deployment:c,descriptor:r,mintedSecretsFile:t,validation:a};o.temporary!==!0&&De({cwd:e,env:o.env,logger:o.logger,url:c.url});const l=await Ze(o,e,c.url);return l?.error!==void 0?{code:1,deployment:c,descriptor:r,healthCheck:l,mintedSecretsFile:t,validation:a}:{...await er(o,e,r,a,n,t),deployment:c,healthCheck:l}},ar=async e=>{const r=e.cwd??process.cwd(),t=Ie(e),o=re(e),n=ne(r,e.target);if(n.target===void 0){const p=n.error??"unknown deploy target";return e.logger.error(p),v(p)}const{target:i}=n;let a;if(!e.skipCodegen){const p=await ze(r,t,e.logger,e.apiSpec,i,e.spawner,b(e.format),o);if(p.error!==void 0)return v(p.error);a=p.result}let c;if(a!==void 0){const p=he({allowDrift:e.allowSchemaDrift===!0,codegen:a,command:"deploy",logger:e.logger,updateBaseline:e.updateSchemaBaseline===!0});if(p.blocked)return v("schema drift gate blocked deploy",{schemaDrift:{blocked:!0,reason:p.reason}});c=p.rebless}await Oe(r,e.logger,a?.cronTriggers,i,e.env);const l=Ge(e);if(l!==void 0)return v(l);const d=await rr(r,e);if(d!==void 0)return v(d);const s=Y({environment:e.env,projectRoot:r});if(or(s,e.logger))return{code:1,descriptor:void 0,error:"wrangler validation failed",validation:s};Be(r,e.logger);const{error:u,mintedSecretsFile:m}=await Ve(r,e,t,i);if(u!==void 0)return e.logger.error(u),{code:1,descriptor:void 0,error:u,mintedSecretsFile:m,validation:s};const f=nr(r,e,i);e.logger.info(`deploying via ${f.command} ${f.args.join(" ")}`);const g=await(e.spawner??S)(f);return f.captureStdoutSilently===!0&&g.stdout!==void 0&&g.stdout!==""&&process.stderr.write(g.stdout),g.code!==0?{code:g.code,descriptor:f,mintedSecretsFile:m,validation:s}:ir({cwd:r,descriptor:f,mintedSecretsFile:m,options:e,reblessSchemaBaseline:c,stdout:g.stdout,validation:s})},sr=async e=>{const r=de("deploy",e.format);if(r!==void 0)return e.logger.error(r),v(r);const t=await ar({...e,logger:ge(e.format,e.logger)});if(b(e.format))return ue(t),t;if(t.code===0&&e.dryRun!==!0&&e.preview!==!0)je({cwd:e.cwd??process.cwd(),env:e.env,logger:e.logger,migrated:e.migrate===!0,mintedSecretsFile:t.mintedSecretsFile,url:t.deployment?.url});else if(t.code===0&&e.preview===!0){const o=t.deployment?.url;e.logger.success(o===void 0?"preview version uploaded":`preview version uploaded — ${o}`)}return t},Ir=oe(async({cwd:e,logger:r,options:t})=>({code:(await sr({allowSchemaDrift:t.allowSchemaDrift===!0,apiSpec:te(t.apiSpec),cwd:e,dryRun:t.dryRun===!0,env:t.env,format:t.format,healthCheck:t.healthCheck===!0,logger:r,migrate:t.migrate===!0,migrateToken:t.migrateToken,migrateUrl:E({cwd:e,env:t.env,url:t.migrateUrl}),migrateYes:t.migrateYes===!0,preview:t.preview===!0,skipCodegen:t.prebuilt===!0,strictAdvisories:t.strictAdvisories,target:t.target,temporary:t.temporary===!0,updateSchemaBaseline:t.updateSchemaBaseline===!0})).code}));export{Ir as execute,sr as runDeployCommand};