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) • 13.9 kB
import{existsSync as f,readFileSync as v,realpathSync as w}from"node:fs";import{join as u}from"node:path";import{DEV_VARS_FILE as h,parseDevVariableEntries as $,isPlaceholderValue as A,discoverSchemaInfo as R,inferLunoraBindings as S,resolveSchemaDirectory as j}from"@lunora/config";import{GLOBAL_FETCH_STRICTLY_PUBLIC_FLAG as m,resolveObservabilitySampling as L,findWranglerFile as I,readWranglerJsonc as O,validateWranglerProject as N,UNEXPORTED_CLASS_MARKER as y,detectCloudflareCliConfig as C,collectExportGaps as E}from"@lunora/config/cloudflare";import{i as D,m as P,c as V}from"../packem_shared/secret-key-DsMeUoEg.mjs";import{r as F,d as U}from"../packem_shared/admin-token-VdUnvnKW.mjs";import{d as G}from"../packem_shared/command-CAXl2A1S.mjs";import{i as T}from"../packem_shared/path-containment-CgxYZggb.mjs";import{b as k,c as x,a as g}from"../packem_shared/variables-acxcvVQa.mjs";import{readServiceBindings as W}from"@lunora/codegen";const M=(t,r)=>{const e=new Set,n=s=>{for(const[a,i]of Object.entries(s??{}))typeof i=="string"&&i.trim().length>0&&e.add(a)};n(t.vars);for(const s of Object.values(t.env??{}))n(s.vars);const o=u(r,h);if(f(o))try{n(Object.fromEntries($(v(o,"utf8")).map(s=>[s.key,s.value])))}catch{}return e},B=(t,r,e,n)=>{if(t===void 0||!e)return;const o=M(t,r),s=o.has(k),a=t.ai?.binding;!s&&(typeof a!="string"||a.length===0)&&n.push({code:"ai-binding-missing",fix:`Run \`lunora dev\` (it adds the binding), add \`"ai": { "binding": "AI" }\` to wrangler.jsonc, or set ${k} to an OpenAI-compatible proxy.`,level:"warn",message:"the project uses ctx.ai (@lunora/ai / env.AI) but wrangler.jsonc declares no `ai` binding — model calls will fail at runtime."}),o.has(x)&&n.push({code:"ai-gateway-token-unused",fix:"Make the gateway unauthenticated for ctx.ai, or remove the token. A bring-your-own AI SDK provider built with `resolveAiGateway` does send it.",level:"warn",message:`${x} is set, but ctx.ai's Workers AI binding cannot send a gateway token — it is ignored on that path.`}),!s&&!o.has(g)&&n.push({code:"ai-gateway-default",fix:`Run \`lunora ai gateway\` to create a gateway for this app and write ${g} into wrangler vars.`,level:"info",message:`no ${g} in vars or ${h} — \`<provider>/<model>\` slugs route through the account's \`default\` AI Gateway.`})},K=(t,r,e)=>{if(t===void 0||!r)return;const n=[];(t.compatibility_flags??[]).includes(m)||n.push("the top level");for(const[o,s]of Object.entries(t.env??{}))s.compatibility_flags!==void 0&&!s.compatibility_flags.includes(m)&&n.push(`env.${o}`);n.length>0&&e.push({code:"cimd-fetch-not-strictly-public",fix:`Add "${m}" to "compatibility_flags" in wrangler.jsonc.`,level:"warn",message:`@lunora/auth/cimd/workers is imported, but ${n.join(", ")} of wrangler.jsonc lacks the ${m} compatibility flag — workersCimdFetch() refuses to build without it.`})},_=t=>{const{logs:r,traces:e}=L(t);return Object.entries({logs:r,traces:e}).filter(([,n])=>n.enabled&&n.headSamplingRate>=1).map(([n])=>n)},z=(t,r)=>{if(t===void 0)return;const e=[],n=_(t.observability);n.length>0&&e.push(`${n.join(" + ")} (top level)`);for(const[o,s]of Object.entries(t.env??{})){if(s.observability===void 0)continue;const a=_(s.observability);a.length>0&&e.push(`${a.join(" + ")} (env.${o})`)}e.length!==0&&r.push({code:"observability-full-sampling",fix:"For a high-traffic Worker, lower head_sampling_rate in wrangler.jsonc: under observability for logs, under observability.traces for traces — e.g. 0.1 keeps 10% of requests. Lunora never changes a rate you set.",level:"info",message:`observability samples every request (head_sampling_rate 1) for ${e.join(", ")}. From 2026-12-01 Workers Observability is billed: Free caps ingestion at 0.5 GB/day; Paid includes 50 GB per billing cycle, then $0.25/GB.`})},H=(t,r)=>{for(const e of W(t).services)for(const n of e.publicScopes){const o=n===""?"":` (env.${n})`;r.push({code:"service-workers-dev",fix:`Set \`"workers_dev": false\`${o} in ${e.wranglerPath} — the app's service binding is its only way in, so internal auth (HMAC) can go too.`,level:"warn",message:`service ${e.name} (${e.worker}) is bound by the app but still public on workers.dev${o}.`})}},q=t=>{const r=t.trim();if(r==="")return!0;const e=r.toLowerCase();return e.includes("replace")||e.startsWith("<")&&e.endsWith(">")},Y=t=>{const r=I(t);if(r===void 0)return{parsed:void 0,path:void 0};const{parsed:e}=O(r);return{parsed:e,path:r}},J=(t,r,e,n)=>{if(e===void 0){n.push({code:"wrangler-missing",fix:"Run `lunora init` (or `lunora dev`) to scaffold and reconcile wrangler.jsonc.",level:"fail",message:"wrangler.jsonc not found."});return}if(r===void 0){n.push({code:"wrangler-unparseable",fix:`Check ${e} is valid JSONC.`,level:"fail",message:`Could not parse ${e}.`});return}const{report:o}=N({projectRoot:t});for(const i of o.errors.filter(l=>l.includes(y)))n.push({code:"wrangler-class-unexported",level:"fail",message:i});for(const i of o.warnings.filter(l=>l.includes("LUNORA_ORIGIN_URL")))n.push({code:"scheduler-origin-missing",fix:"Set LUNORA_ORIGIN_URL in wrangler vars to the worker's public URL (or `wrangler secret put LUNORA_ORIGIN_URL`).",level:"warn",message:i});const s=i=>i.includes(y)||i.includes("SHARD");for(const i of o.errors.filter(l=>!s(l)))n.push({code:"wrangler-invalid",fix:"Fix the reported wrangler.jsonc problem; `lunora verify` reports the same set.",level:"fail",message:i});for(const i of o.warnings.filter(l=>!l.includes("LUNORA_ORIGIN_URL")))n.push({code:"wrangler-advisory",level:"warn",message:i});const a=o.errors.find(i=>i.includes("SHARD"));a===void 0?n.push({code:"wrangler-shard-binding-ok",level:"pass",message:"wrangler.jsonc present with a SHARD durable-object binding."}):n.push({code:"wrangler-shard-binding-missing",fix:"Run `lunora dev` to auto-reconcile, or add the binding manually.",level:"fail",message:a})},X=(t,r)=>{f(u(t,"lunora.json"))&&r.push({code:"stale-lunora-json",fix:'Move `target` / `remote` into `lunora.config.ts` (`export default { target: "…" }`), then delete lunora.json.',level:"warn",message:"lunora.json is present but no longer read — lunora.config.* replaced it, so any `target` or `remote` in it is being ignored."})},Q=(t,r)=>{const e=C(t);e!==void 0&&r.push({code:"cf-config-present",fix:e.fix,level:"warn",message:e.message})},Z=(t,r)=>{t===void 0||t.limits?.cpu_ms!==void 0||r.push({code:"cpu-limit-missing",fix:'Add `"limits": { "cpu_ms": 30000 }` to wrangler.jsonc, tuned to your slowest legitimate handler.',level:"info",message:"no limits.cpu_ms is set — a runaway handler can burn CPU up to the platform default before anything stops it."})},ee=(t,r)=>{const e=(t?.r2_buckets??[]).map(n=>n?.bucket_name).filter(n=>typeof n=="string"&&n.length>0);e.length!==0&&r.push({code:"r2-lifecycle-unset",fix:"wrangler r2 bucket lifecycle list <bucket> — add an abort-incomplete-multipart rule if none exists.",level:"info",message:`R2 bucket(s) ${e.join(", ")}: verify a lifecycle rule aborts incomplete multipart uploads — orphaned parts are billed but invisible in a listing.`})},ne=(t,r)=>{const{error:e,info:n}=R(t,"lunora");if(e!==void 0){r.push({code:"schema-unreadable",fix:"Fix the parse error, then re-run `lunora doctor`. `lunora codegen` reports the same failure with the offending source.",level:"fail",message:`lunora/schema.ts could not be parsed, so every schema-derived check was skipped: ${e}`});return}for(const o of n?.vectorMetadata??[]){const s=P(o.kind);if(s===void 0){r.push({code:"vector-metadata-unfilterable",fix:"Filter on a string, number or boolean column, or drop it from `metadata`.",level:"warn",message:`vector index "${o.index}" declares metadata "${o.property}", whose type Vectorize cannot filter on.`});continue}r.push({code:"vector-metadata-index-required",fix:`wrangler ${V({index:o.index,property:o.property,type:s}).join(" ")}`,level:"info",message:`vector index "${o.index}" filters on metadata "${o.property}" — that needs a Vectorize metadata index (\`lunora deploy\` creates it).`})}},re=(t,r)=>{if(t===void 0)return;const e=(t.d1_databases??[]).filter(Boolean);for(const n of e){const o=typeof n.database_id=="string"?n.database_id:"";if(q(o)){const s=typeof n.binding=="string"&&n.binding.length>0?n.binding:"<unnamed>";r.push({code:"d1-placeholder-id",fix:"Run `wrangler d1 create <name>` and paste the returned database_id into wrangler.jsonc.",level:"fail",message:`D1 binding "${s}" has a placeholder database_id ("${o||"<empty>"}").`})}}},te=(t,r)=>{if(t===void 0)return;const e=(t.send_email??[]).filter(Boolean);for(const n of e){const o=typeof n.destination_address=="string"?n.destination_address:"";if(o!==""&&A(o)){const s=typeof n.name=="string"&&n.name.length>0?n.name:"send_email";r.push({code:"email-destination-placeholder",fix:"Set destination_address to a verified Cloudflare Email Routing address.",level:"warn",message:`send_email binding "${s}" has a placeholder destination_address ("${o}").`})}}},oe=(t,r)=>{const e=u(t,h);if(!f(e))return;let n;try{n=v(e,"utf8")}catch{return}const o=$(n).filter(s=>D(s.key)&&A(s.value)).map(s=>s.key);o.length>0&&r.push({code:"dev-vars-missing-secret",fix:"Run `lunora dev` to auto-generate secrets, or fill them in by hand.",level:"warn",message:`${h} has unfilled secret value(s): ${o.join(", ")}.`})},se=(t,r)=>{const{source:e}=F({cwd:t});e===void 0?r.push({code:"admin-token-missing",fix:"Set LUNORA_ADMIN_TOKEN (env or `.dev.vars`) to enable admin RPCs / studio.",level:"info",message:"LUNORA_ADMIN_TOKEN is not set."}):r.push({code:"admin-token-set",level:"pass",message:`LUNORA_ADMIN_TOKEN is set (${U(e)}).`})},ae=async(t,r)=>{try{return await S({projectRoot:t,schemaDir:j(t)})}catch(e){r.push({code:"declared-export-unchecked",fix:"Make sure the worker entry resolves (see `lunora codegen`), then re-run.",level:"warn",message:`could not check whether declared containers/workflows/agents are re-exported by the worker entry: ${e instanceof Error?e.message:String(e)}`});return}},ie=(t,r)=>{const e=E(t),n=[...t.containers.map(o=>({...o,kind:"container"})),...t.workflows.map(o=>({...o,kind:"workflow"})),...t.agents.map(o=>({...o,kind:"agent"}))];for(const o of n.filter(s=>s.exported))r.push({code:"declared-export-ok",level:"pass",message:`${o.kind} "${o.exportName}" is exported by the worker entry.`});for(const o of e)r.push({code:"declared-export-missing",fix:`Add \`export * from "./lunora/_generated/${o.module}"\` to your worker entry (or re-run \`vis generate lunora-${o.kind}\`).`,level:"fail",message:`${o.kind} "${o.exportName}" is declared but ${o.className} is not exported by the worker entry.`})},ce=/^[\^~><= ]+/u,le=/^(?<core>\d+\.\d+\.\d+)(?:-(?<channel>[a-z]+)\.(?<counter>\d+))?$/u,de=t=>{const r=t.replace(ce,"").trim(),e=le.exec(r);if(!e?.groups)return;const{channel:n,core:o,counter:s}=e.groups;return{channel:n??"",core:o??"",counter:s===void 0?void 0:Number.parseInt(s,10),raw:r}},ue=(t,r)=>{const e=u(t,"package.json");if(!f(e))return;let n;try{n=JSON.parse(v(e,"utf8"))}catch{return}const o={...n.devDependencies,...n.dependencies},s=[];for(const[c,d]of Object.entries(o)){if(c!=="lunorash"&&!c.startsWith("@lunora/"))continue;const b=de(d);b&&s.push({name:c,version:b})}if(s.length<2)return;const a=c=>c.map(d=>`${d.name}@${d.version.raw}`).join(", "),i=new Set(s.map(c=>c.version.core)),l=new Set(s.map(c=>c.version.channel));if(i.size>1){r.push({code:"version-skew-cores",fix:'Run `pnpm update "@lunora/*" lunorash` (or `npm`/`yarn` equivalent) so every Lunora package moves to the same release.',level:"warn",message:`Lunora packages span ${String(i.size)} different versions: ${a(s)}.`});return}if(l.size>1){r.push({code:"version-skew-channels",fix:"Pick one channel (all stable, or all alpha/beta) and update the odd package out.",level:"warn",message:`Lunora packages mix release channels (${[...l].map(c=>c===""?"stable":c).join(" + ")}): ${a(s)}.`});return}const p=s.map(c=>c.version.counter).filter(c=>c!==void 0);if(p.length>1){const c=Math.min(...p),d=Math.max(...p);r.push({code:"version-counter-spread",level:"info",message:p.length===s.length&&c===d?`Lunora packages are all at ${[...l][0]??""}.${String(d)}.`:`Lunora pre-release counters span ${String(c)}–${String(d)}: ${a(s)}.`})}},fe=[u("@lunora","cli"),"lunorash"],pe=(t,r,e)=>{if(r===void 0)return;const n=[];for(const s of fe){const a=u(t,"node_modules",s);if(f(a))try{n.push(w(a))}catch{}}if(n.length===0)return;let o;try{o=w(r)}catch{return}n.some(s=>T(s,o))||e.push({code:"cli-shadowed",fix:"Run the project's own CLI: `pnpm exec lunora …` (or `npx lunora …`).",level:"warn",message:`the running lunora (${o}) is not the project's own install (${n.join(", ")}).`})},me=async t=>{const r=t.cwd??process.cwd(),e=[],{parsed:n,path:o}=Y(r);J(r,n,o,e),Q(r,e),re(n,e),te(n,e),Z(n,e),ee(n,e),z(n,e),oe(r,e),se(r,e),ue(r,e),ne(r,e),X(r,e),pe(r,t.executablePath??process.argv[1],e),H(r,e);const s=await ae(r,e);s!==void 0&&ie(s,e),B(n,r,s?.usesAi===!0,e),K(n,s?.usesCimdWorkers===!0,e);const a={fail:0,info:0,pass:0,warn:0};for(const l of e)a[l.level]+=1;const i=a.fail>0?1:0;return{code:i,findings:e,ok:i===0,summary:a}},he={fail:"FAIL",info:"INFO",pass:"PASS",warn:"WARN"},ge=(t,r)=>{r.info("lunora doctor — project preflight");for(const o of t.findings){const s=`[${he[o.level]}] ${o.message}`;o.level==="fail"?r.error(s):o.level==="warn"?r.warn(s):r.info(s),o.fix!==void 0&&o.level!=="pass"&&r.info(` fix: ${o.fix}`)}const{fail:e,warn:n}=t.summary;e>0?r.error(`${String(e)} failure(s), ${String(n)} warning(s).`):n>0?r.warn(`0 failures, ${String(n)} warning(s).`):r.success("all checks passed.")},ve=async t=>{const{logger:r}=t,e=await me({...t});return ge(e,r),e},je=G(async({cwd:t,format:r,logger:e})=>{const n=await ve({cwd:t,format:r,logger:e});return{code:n.code,data:{findings:n.findings,summary:n.summary}}});export{je as execute,me as runDoctor,ve as runDoctorCommand};