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 w,readFileSync as R}from"node:fs";import{runCodegen as O,discoverMigrations as F}from"@lunora/codegen";import{readLinkedProject as x,writeLinkedProject as H,resolveDeployDriver as E,inferLunoraBindings as L,DEV_VARS_FILE as k,parseDevVariableEntries as N,isMintableSecretKey as T,discoverContainerInfo as j,packageNamesFromBindings as M,requiredSecrets as W,generateSecretValue as V,upsertDevVariableLine as B,writeDevVariablesFileAtomically as K,discoverSchemaInfo as q}from"@lunora/config";import{validateWranglerProject as Y,reconcileWranglerBindings as G,reconcileWranglerCompatibilityDate as z,reconcileWranglerCrons as J,findWranglerFile as Q,readWranglerJsonc as X}from"@lunora/config/cloudflare";import{join as p}from"@visulima/path";import{Spinner as Z}from"@visulima/spinner";import{Project as ee}from"ts-morph";import{r as re,e as te}from"../packem_shared/advisory-gate-Cl8IEFIj.mjs";import{p as ae}from"../packem_shared/api-spec-lYyq4vL6.mjs";import{r as C}from"../packem_shared/wrangler-name-Dsk5K1f-.mjs";import{d as ne}from"../packem_shared/command-DwC11Cwe.mjs";import{r as oe}from"../packem_shared/deploy-target-D9GPy_tq.mjs";import{e as b,d as $}from"../packem_shared/detect-package-manager-DSaAqRof.mjs";import{a as ie,i as se}from"../packem_shared/docker-DpVxvYpL.mjs";import{p as ce,H as le,a as de}from"../packem_shared/health-probe-QEPfFdgJ.mjs";import{v as ue,l as ge,i as S,p as me}from"../packem_shared/output-format-DfogXx0p.mjs";import{r as fe}from"../packem_shared/platform-diagnostics-uOXqysk3.mjs";import{r as he}from"../packem_shared/post-codegen-hook-DRdEoTsQ.mjs";import{containerBuildTag as ve}from"@lunora/container";import{defaultSpawner as D}from"../packem_shared/createRecordingSpawner-DJlNacZ6.mjs";import{r as I}from"../packem_shared/resolve-target-C_ZloTvd.mjs";import{r as pe}from"../packem_shared/schema-drift-gate-AscQpYH5.mjs";import{c as ye}from"../packem_shared/tui-prompts-Dd0pDuNr.mjs";import{m as ke,e as we}from"../packem_shared/vectorize-metadata-Cs8ycNaL.mjs";import{l as be}from"../packem_shared/wrangler-secrets-CE7jcf8E.mjs";import{runMigrateDataCommand as $e}from"./runMigrateGenerateCommand.mjs";const Se=/https?:\/\/[^\s"'<>]+\.workers\.dev[^\s"'<>]*/u,De=/https:\/\/[^\s"'<>]+/u,Re=e=>{if(e===void 0)return;const r=Se.exec(e);return r?r[0]:De.exec(e)?.[0]},P=e=>e===void 0?"":` (--env ${e})`,Ee=({cwd:e,env:r,logger:t,now:a,url:n})=>{if(n!==void 0)try{const o=x(e);if(o!==void 0){if(o.workerUrl===n&&o.env===r)return;t.warn(`link: .lunora/project.json records ${o.workerUrl??"(no url)"}${P(o.env)}, but this deploy published ${n}${P(r)}. Keeping the recorded value — run \`lunora link --url ${n}${r===void 0?"":` --env ${r}`}\` to update it.`);return}const i=(a??(()=>new Date().toISOString()))();H(e,{env:r,linkedAt:i,workerName:C(e),workerUrl:n}),t.success(`link: recorded ${n} in .lunora/project.json (run \`lunora link\` to change)`)}catch{}},Ce=e=>{const{cwd:r,env:t,logger:a,migrated:n,mintedSecretsFile:o}=e;try{const i=x(r),s=i?.workerName??C(r),l=e.url??i?.workerUrl;a.success("deploy complete"),a.info(` worker: ${s??"(see wrangler output above)"}`),t!==void 0&&a.info(` env: ${t}`),l===void 0?a.info(" url: run `lunora link --url <https://your-worker>` to record it"):a.info(` url: ${l}`),n&&a.info(" migrations: applied"),o!==void 0&&a.info(` secrets: generated value(s) recorded in ${o}`),a.info(" studio: lunora view --remote"),a.info(" logs: lunora logs")}catch{}},Ae=async e=>{if(e.targets.length===0)return{builtTags:[],code:0};if(!(e.railpackAvailable??ie)()){const a="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(a),{builtTags:[],code:1,error:a}}const r=e.spawner??D,t=[];for(const a of e.targets){const n=ve(a.exportName),o={args:["build",a.buildDir,"--name",n],command:"railpack",cwd:e.cwd},i=b($(e.cwd),"wrangler",["containers","push",n]),s={args:i.args,command:i.command,cwd:e.cwd};e.logger.info(`railpack: building "${a.exportName}" → ${n} from ${a.buildDir}`);const l=await r(o);if(l.code!==0){const c=`railpack build failed for container "${a.exportName}" (${a.buildDir})`;return e.logger.error(c),{builtTags:t,code:l.code,error:c}}e.logger.info(`railpack: pushing ${n} to the Cloudflare Registry`);const d=await r(s);if(d.code!==0){const c=`wrangler containers push failed for "${a.exportName}" (${n})`;return e.logger.error(c),{builtTags:t,code:d.code,error:c}}t.push(n)}return{builtTags:t,code:0}},U="<replace-with-d1-create-id>",A=e=>{const r=Q(e);return r?X(r).parsed:void 0},Te=["LUNORA_ORIGIN_URL","LUNORA_WORKER_ORIGIN","AUTH_URL"],Pe=e=>{try{const{hostname:r}=new URL(e);return r==="localhost"||r==="127.0.0.1"||r==="::1"||r==="[::1]"}catch{return!1}},_e=e=>e.startsWith("./")||e.startsWith("../")||e.startsWith("/")||e.includes("Dockerfile"),xe=(e,r,t)=>{const a=(A(e)?.containers??[]).filter(o=>typeof o?.image=="string"&&_e(o.image));if(a.length===0||t())return;const n=`deploy blocked: wrangler.jsonc declares ${String(a.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},Le=e=>w(p(e,"src","worker.ts"))?"src/worker.ts":void 0,Ne=(e,r)=>{for(const t of j(e,"lunora").containers){const{image:a}=t;if(a.kind==="dockerfile"&&!w(p(e,a.dockerfilePath))){const n=`deploy blocked: container "${t.exportName}" references a Dockerfile at "${a.dockerfilePath}" that does not exist. Create it or fix the \`image\` path in lunora/containers.ts.`;return r.error(n),n}if(a.kind==="build"&&!w(p(e,a.buildDir))){const n=`deploy blocked: container "${t.exportName}" references a Railpack build directory "${a.buildDir}" that does not exist. Create it or fix the \`image.build\` path in lunora/containers.ts.`;return r.error(n),n}}},je=e=>S(e.format)?!1:e.interactive!==void 0?e.interactive:process.stdout.isTTY&&!process.env.CI,Ie=e=>(A(e)?.d1_databases??[]).find(r=>r.database_id===U)?.binding,Ue=async(e,r)=>{const t=j(e,"lunora").containers.filter(n=>n.image.kind==="build").map(n=>({buildDir:n.image.buildDir,exportName:n.exportName}));if(t.length===0)return;const a=await Ae({cwd:e,logger:r.logger,railpackAvailable:r.railpackAvailable,spawner:r.spawner,targets:t});return a.code===0?void 0:a.error??"railpack build failed"},Oe=(e,r,t)=>{if(t!==void 0)try{const a=J(e,t);a.changed&&r.success(`synced ${String(t.length)} cron trigger(s) → ${a.wranglerPath??"wrangler.jsonc"}`)}catch(a){const n=a instanceof Error?a.message:String(a);r.warn(`cron trigger sync skipped: ${n}`)}},Fe=async(e,r,t,a,n)=>{try{E(a);const o=await L({projectRoot:e}),i=G(e,o,n);i.changed&&r.success(`provisioned bindings: ${i.added.join(", ")}${i.wranglerPath??"wrangler.jsonc"}`);for(const s of i.warnings)r.warn(s)}catch(o){const i=o instanceof Error?o.message:String(o);r.warn(`binding inference skipped: ${i}`)}try{const o=z(e);o.changed&&r.success(`bumped compatibility_date to ${o.date??"unknown"} (Workers Cache enabled) → ${o.wranglerPath??"wrangler.jsonc"}`)}catch(o){const i=o instanceof Error?o.message:String(o);r.warn(`compatibility date sync skipped: ${i}`)}Oe(e,r,t)},He=(e,r)=>{const t=p(e,k);if(!w(t))return;let a;try{a=N(R(t,"utf8")).length}catch{return}a!==0&&r.warn(`Note: \`lunora deploy\` does not push secrets. ${k} has ${String(a)} key(s); if you changed them, run \`lunora env push --yes\` to update the deployed secrets.`)},Me=/(?:KEY|PASSWORD|SECRET|TOKEN)$/u,We=async e=>{let r=[];try{r=M(await L({projectRoot:e}))}catch{}const t=W(r).map(n=>n.key);let a=[];try{const n=p(e,k);w(n)&&(a=N(R(n,"utf8")).map(o=>o.key).filter(o=>Me.test(o)))}catch{}return[...new Set([...t,...a])]},Ve=async(e,r,t,a)=>{const{logger:n}=r,o=r.spawner??D,i=$(e),s=r.env===void 0?"":` --env ${r.env}`,{toolchain:l}=E(a);if(l===void 0)return n.error("deploy target has no command-line toolchain; cannot push secrets"),{minted:[],ok:!1};const d=[];for(const c of t){const m=V(),u=l.secretPut({environment:r.env,key:c,temporary:r.temporary}),g=b(i,u.tool,u.args),f=await o({args:g.args,command:g.command,cwd:e,input:m});if(f.code!==0)return n.error(`failed to push secret ${c} (exit ${String(f.code)}); set it manually with \`wrangler secret put ${c}${s}\`.`),{minted:d,ok:!1};d.push({key:c,value:m}),n.success(`generated + pushed ${c}`)}return{minted:d,ok:!0}},Be=/^[\w-]+$/u,Ke=(e,r,t)=>{if(t.length===0)return;const a=t.map(s=>s.key).join(", ");if(r.env!==void 0&&!Be.test(r.env)){r.logger.warn(`${a}: 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?k:`${k}.${r.env}`,o=p(e,n);let i=w(o)?R(o,"utf8"):"";for(const s of t)i=B(i,s.key,s.value);return K(o,i),r.logger.success(r.env===void 0?`${a}: value(s) recorded in ${n}`:`${a}: value(s) recorded in ${n} (kept separate from ${k} so \`lunora dev\` and a plain \`lunora env push\` don't inherit the --env ${r.env} value)`),n},qe=async(e,r,t,a)=>{if(r.dryRun===!0||r.preview===!0)return{};const{logger:n}=r,o=r.env===void 0?"":` --env ${r.env}`;let i;try{i=await(r.secretLister??be)({cwd:e,env:r.env,temporary:r.temporary})}catch{return{}}if(!i.ok)return{};const s=new Set(i.names),d=(await We(e)).filter(u=>!s.has(u));if(d.length===0)return{};if(!t)return{error:`missing required secret(s) on the deploy target: ${d.join(", ")}. Set them with \`wrangler secret put <KEY>${o}\` (or \`lunora env generate --set\` then \`lunora env push --yes${o}\`), then re-deploy.`};for(const u of d.filter(g=>!T(g)))n.warn(`required secret ${u} is not set on the target — set it with: wrangler secret put ${u}${o}`);const c=d.filter(u=>T(u));if(c.length===0)return{};if(await(r.secretConfirm??ye())(`${String(c.length)} required secret(s) not set on the target (${c.join(", ")}). Generate strong values and push them now?`)){const{minted:u,ok:g}=await Ve(e,r,c,a),f=Ke(e,r,u);return g?{mintedSecretsFile:f}:{error:"failed to push required secret(s) — set them manually and re-deploy",mintedSecretsFile:f}}return n.warn(`${String(c.length)} required secret(s) not set on the target: ${c.join(", ")}. Generate + push with \`lunora env generate --set\` then \`lunora env push --yes${o}\`.`),{}},Ye=async(e,r)=>{const t=new ee({skipAddingFilesFromTsConfig:!0}),a=p(r,"lunora");let n;try{n=F(t,a)}catch(o){const i=o instanceof Error?o.message:String(o);return e.logger.warn(`--migrate: could not discover migrations (${i}); 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 o of n){e.logger.info(`--migrate: up "${o.id}" (table "${o.table}")`);const i={cwd:r,fetchImpl:e.fetchImpl,id:o.id,logger:e.logger,prod:!0,subcommand:"up",token:e.migrateToken,url:e.migrateUrl,yes:e.migrateYes===!0},s=await $e(i);if(s.code!==0)return e.logger.error(`--migrate: migration "${o.id}" failed — see output above`),s.code;e.logger.success(`--migrate: "${o.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,a,n,o,i,s)=>{let l;r?(l=new Z({name:"dots"}),l.start("running codegen")):t.info("running codegen");try{const d=O({apiSpec:a,projectRoot:e,target:n});l?.succeed("codegen complete"),l||t.success("codegen complete");const c=fe(d.platformDiagnostics,t);if(c!==void 0)return{error:c};const{errorAdvisories:m,names:u,shouldBlock:g}=te(d.advisories,s);if(g){const v=`${m.length.toString()} ERROR-level ${m.length===1?"advisory":"advisories"} (${u.join(", ")}). Pass --no-strict-advisories to downgrade this to a warning and deploy anyway.`;return t.error(v),{error:v}}const f=await he({cwd:e,logger:t,spawner:o,stdoutToStderr:i});return f.error!==void 0?(t.error(f.error),{error:f.error}):{result:d}}catch(d){l?.failed("codegen failed");const c=d instanceof Error?d.message:String(d);return t.error(`codegen failed: ${c}`),{error:`codegen failed: ${c}`}}},Je=(e,r)=>{const t=Ie(e);if(t===void 0)return;const a=`deploy blocked: the "${t}" D1 binding has a placeholder database_id ("${U}"). 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(a),a},Qe=(e,r)=>{const t=A(e)?.vars;if(!t)return;const a=Te.filter(o=>typeof t[o]=="string"&&Pe(t[o]));if(a.length===0)return;const n=`deploy blocked: ${a.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},Xe=async(e,r)=>{const{info:t}=q(r,"lunora"),a=t?.vectorMetadata??[];if(a.length===0)return;const n=[];for(const s of a){const l=ke(s.kind);if(l===void 0){e.logger.warn(`vector index "${s.index}" declares metadata "${s.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:s.index,property:s.property,type:l})}if(n.length===0)return;const i=(await we({cwd:r,entries:n,exec:b($(r),"wrangler",[]),logger:e.logger,spawner:e.spawner??D})).filter(s=>s.status!=="failed").length;i>0&&e.logger.success(`vectorize metadata indexes ready: ${String(i)}/${String(n.length)}`)},_=5,Ze=2e3,er=async(e,r,t)=>{if(e.healthCheck!==!0)return;const a=t??I({cwd:r,env:e.env});if(a===void 0){const o="--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(o),{error:o,ok:!1,url:""}}const n=await ce({attempts:_,baseUrl:a,delayMs:Ze,fetchImpl:e.healthFetch,paths:[le,de],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(_)} attempt(s) — ${n.error}`),{error:n.error,ok:!1,url:n.url})},rr=async(e,r,t,a,n,o)=>{await Xe(e,r);const i=e.migrate?await Ye(e,r):0;return i===0&&n?.(),{code:i,descriptor:t,mintedSecretsFile:o,validation:a}},tr=async(e,r)=>{const t=Je(e,r.logger);if(t!==void 0)return t;const a=Qe(e,r.logger);if(a!==void 0)return a;const n=Ne(e,r.logger);if(n!==void 0)return n;const o=xe(e,r.logger,r.dockerAvailable??se);return o!==void 0?o:Ue(e,r)},ar=(e,r,t)=>{const a=Le(e);a!==void 0&&r.logger.info(`class-B composition: deploying ${a} (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=E(t),o={dryRun:r.dryRun,entry:a,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(o)},y=(e,r)=>({code:1,descriptor:void 0,error:e,validation:{problems:[],wranglerPath:void 0},...r}),nr=(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},or=(e,r,t)=>{const a=S(r.format),n=r.dryRun!==!0,o=ar(e,r,t),i=b($(e),o.tool,o.args);return{args:i.args,captureStdout:n&&!a,captureStdoutSilently:n&&a,command:i.command,cwd:e,stdoutToStderr:a&&!n}},ir=async({cwd:e,descriptor:r,mintedSecretsFile:t,options:a,reblessSchemaBaseline:n,stdout:o,validation:i})=>{const s={deployedAt:new Date().toISOString(),dryRun:a.dryRun===!0,env:a.env,preview:a.preview===!0,url:a.dryRun===!0?void 0:Re(o),workerName:C(e)};if(a.dryRun===!0||a.preview===!0)return a.healthCheck===!0&&a.logger.warn(`--health-check skipped: ${a.dryRun===!0?"a dry run publishes nothing":"a preview version serves no live traffic"}`),{code:0,deployment:s,descriptor:r,mintedSecretsFile:t,validation:i};a.temporary!==!0&&Ee({cwd:e,env:a.env,logger:a.logger,url:s.url});const l=await er(a,e,s.url);return l?.error!==void 0?{code:1,deployment:s,descriptor:r,healthCheck:l,mintedSecretsFile:t,validation:i}:{...await rr(a,e,r,i,n,t),deployment:s,healthCheck:l}},sr=async e=>{const r=e.cwd??process.cwd(),t=je(e),a=re(e),n=oe(r,e.target);if(n.target===void 0){const h=n.error??"unknown deploy target";return e.logger.error(h),y(h)}const{target:o}=n;let i;if(!e.skipCodegen){const h=await ze(r,t,e.logger,e.apiSpec,o,e.spawner,S(e.format),a);if(h.error!==void 0)return y(h.error);i=h.result}let s;if(i!==void 0){const h=pe({allowDrift:e.allowSchemaDrift===!0,codegen:i,command:"deploy",logger:e.logger,updateBaseline:e.updateSchemaBaseline===!0});if(h.blocked)return y("schema drift gate blocked deploy",{schemaDrift:{blocked:!0,reason:h.reason}});s=h.rebless}await Fe(r,e.logger,i?.cronTriggers,o,e.env);const l=Ge(e);if(l!==void 0)return y(l);const d=await tr(r,e);if(d!==void 0)return y(d);const c=Y({environment:e.env,projectRoot:r});if(nr(c,e.logger))return{code:1,descriptor:void 0,error:"wrangler validation failed",validation:c};He(r,e.logger);const{error:m,mintedSecretsFile:u}=await qe(r,e,t,o);if(m!==void 0)return e.logger.error(m),{code:1,descriptor:void 0,error:m,mintedSecretsFile:u,validation:c};const g=or(r,e,o);e.logger.info(`deploying via ${g.command} ${g.args.join(" ")}`);const v=await(e.spawner??D)(g);return g.captureStdoutSilently===!0&&v.stdout!==void 0&&v.stdout!==""&&process.stderr.write(v.stdout),v.code!==0?{code:v.code,descriptor:g,mintedSecretsFile:u,validation:c}:ir({cwd:r,descriptor:g,mintedSecretsFile:u,options:e,reblessSchemaBaseline:s,stdout:v.stdout,validation:c})},cr=async e=>{const r=ue("deploy",e.format);if(r!==void 0)return e.logger.error(r),y(r);const t=await sr({...e,logger:ge(e.format,e.logger)});if(S(e.format))return me(t),t;if(t.code===0&&e.dryRun!==!0&&e.preview!==!0)Ce({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 a=t.deployment?.url;e.logger.success(a===void 0?"preview version uploaded":`preview version uploaded — ${a}`)}return t},jr=ne(async({cwd:e,logger:r,options:t})=>({code:(await cr({allowSchemaDrift:t.allowSchemaDrift===!0,apiSpec:ae(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:I({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{jr as execute,cr as runDeployCommand};