@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands
733 lines (680 loc) • 92 kB
JavaScript
import{existsSync as m,mkdirSync as M,writeFileSync as v,readFileSync as b,readdirSync as re,rmSync as $,mkdtempSync as oe,cpSync as G,renameSync as Re,lstatSync as Oe}from"node:fs";import{tmpdir as se}from"node:os";import{detectFramework as Te,isInteractive as g,LUNA_ART as Pe,LUNA_NAME as De,LUNA_SIGNOFF as Be,BADGES as f,paintAnswer as je,detectLintTools as Ve,applyLintIgnores as Ue}from"@lunora/config";import{walkSync as Fe}from"@visulima/fs";import{join as u,dirname as A,basename as E,resolve as ne,relative as He}from"@visulima/path";import{downloadTemplate as ie}from"giget";import{modify as We,applyEdits as ze}from"jsonc-parser";import{join as V,dirname as qe}from"node:path";import{i as le,e as Ke,d as U,c as Ge,b as Je,a as Ye}from"../packem_shared/detect-package-manager-DSaAqRof.mjs";import{d as Xe}from"../packem_shared/command-DwC11Cwe.mjs";import Qe from"magic-string";import{Project as Ze,SyntaxKind as w}from"ts-morph";import{P as F}from"../packem_shared/prompt-cancelled-C8Sj7OqI.mjs";import{h as ce,d as de,i as ue,b as et}from"../packem_shared/resolve-DZAGzg67.mjs";import{defaultSpawner as pe}from"../packem_shared/createRecordingSpawner-DJlNacZ6.mjs";import{d as tt,e as at,f as rt,a as he,t as H,g as me,h as ot,b as fe,i as ve,w as st,j as nt,k as ge,l as it}from"../packem_shared/tui-prompts-Dd0pDuNr.mjs";import{E as we,p as lt,w as ct,M as dt,r as ut,k as pt,f as ht,c as ye,l as be,e as mt,g as ft}from"../packem_shared/lint-ignore-report-0sVOnXcO.mjs";import{logStep as J}from"../packem_shared/createLogger-hGafwf-3.mjs";import vt from"node:dns/promises";import{r as gt}from"../packem_shared/commands-B-YnEi2t.mjs";const W={bun:{githubCacheKey:void 0,githubSetupStep:` - uses: oven-sh/setup-bun@v2
`,gitlabImage:"oven/bun:1",gitlabProvisionScript:[],installCmd:"bun install --frozen-lockfile",lockfile:"bun.lockb"},npm:{githubCacheKey:"npm",githubSetupStep:"",gitlabImage:"node:22",gitlabProvisionScript:[],installCmd:"npm ci",lockfile:"package-lock.json"},pnpm:{githubCacheKey:"pnpm",githubSetupStep:` - uses: pnpm/action-setup@v4
`,gitlabImage:"node:22",gitlabProvisionScript:["corepack enable"],installCmd:"pnpm install --frozen-lockfile",lockfile:"pnpm-lock.yaml"},yarn:{githubCacheKey:"yarn",githubSetupStep:"",gitlabImage:"node:22",gitlabProvisionScript:["corepack enable"],installCmd:"yarn install --frozen-lockfile",lockfile:"yarn.lock"}},C=(e,t)=>{const a=Ke(e,"lunora",t);return[a.command,...a.args].join(" ")},Y=e=>` - uses: actions/checkout@v4
${e.githubSetupStep} - uses: actions/setup-node@v4
with:
node-version: 22
${e.githubCacheKey===void 0?"":` cache: ${e.githubCacheKey}
`} - run: ${e.installCmd}`,wt=e=>{const t=W[e],a=t.githubCacheKey===void 0?`# (below) requires it — run \`${e} install\` locally and`:`# (below) and the ${e} cache both require it — run \`${e} install\` locally and`;return`name: Deploy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Prerequisite: commit your ${t.lockfile}. \`${t.installCmd}\`
${a}
# commit the lockfile before pushing, or the first CI run fails.
#
# Set these repository secrets (Settings → Secrets and variables → Actions):
# CLOUDFLARE_API_TOKEN — a Workers-scoped API token
# CLOUDFLARE_ACCOUNT_ID — your Cloudflare account id
env:
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
jobs:
# Production deploy on push to the default branch.
deploy:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
${Y(t)}
# Codegen + wrangler validation gate (no deploy) — fails fast on drift.
- run: ${C(e,["prepare"])}
- run: ${C(e,["deploy"])}
# Preview version on every pull request — uploads a versioned preview URL;
# production traffic is untouched.
preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
${Y(t)}
- run: ${C(e,["deploy","--preview"])}
`},yt=e=>{const t=W[e],a=[...t.gitlabProvisionScript,t.installCmd].map(r=>` - ${r}`).join(`
`);return`stages:
- deploy
# Prerequisite: commit your ${t.lockfile}. \`${t.installCmd}\`
# (below) requires it — run \`${e} install\` locally and commit the lockfile
# before pushing, or the first pipeline fails.
#
# Set these as masked CI/CD variables (Settings → CI/CD → Variables):
# CLOUDFLARE_API_TOKEN — a Workers-scoped API token
# CLOUDFLARE_ACCOUNT_ID — your Cloudflare account id
.lunora_base:
image: ${t.gitlabImage}
stage: deploy
before_script:
${a}
# Production deploy on the default branch.
deploy:
extends: .lunora_base
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
# Codegen + wrangler validation gate (no deploy) — fails fast on drift.
- ${C(e,["prepare"])}
- ${C(e,["deploy"])}
# Preview version on every merge request (versioned preview URL; production
# traffic is untouched).
preview:
extends: .lunora_base
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- ${C(e,["deploy","--preview"])}
`},xe={github:{file:V(".github","workflows","deploy.yml"),secretsHint:"repository secrets (Settings → Secrets and variables → Actions)"},gitlab:{file:".gitlab-ci.yml",secretsHint:"masked CI/CD variables (Settings → CI/CD → Variables)"}},bt=(e,t)=>e==="github"?wt(t):yt(t),xt=e=>e==="github"||e==="gitlab",kt=(e,t,a,r={})=>{const o=V(e,xe[t].file);return m(o)&&r.overwrite!==!0?{path:o,skipped:!0,written:!1}:(M(qe(o),{recursive:!0}),v(o,bt(t,a),"utf8"),{path:o,skipped:!1,written:!0})},Ct=(e,t,a,r,o={})=>{const s=xe[t];try{const n=kt(e,t,a,o);if(n.skipped)r.info(`--ci ${t}: ${s.file} already exists — left unchanged (re-run with overwrite to replace).`);else{const i=le(a),l=[i.command,...i.args].join(" "),{installCmd:d,lockfile:c}=W[a];r.success(`--ci ${t}: wrote ${s.file}`),r.info(`--ci ${t}: set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as ${s.secretsHint} to enable deploys.`),r.info(`--ci ${t}: run \`${l}\` and commit ${c} before pushing — the pipeline runs \`${d}\`.`)}return n}catch(n){const i=n instanceof Error?n.message:String(n);return r.warn(`--ci ${t}: could not write ${s.file} (${i})`),{path:V(e,s.file),skipped:!1,written:!1}}},Lt={astro:"@lunora/react",none:"@lunora/react",nuxt:"@lunora/vue","react-router":"@lunora/react","solid-start":"@lunora/solid",sveltekit:"@lunora/svelte","tanstack-start":"@lunora/react","tanstack-start-solid":"@lunora/solid",vinext:"@lunora/react"},St=e=>{const t=Te(e);return{...t,adapter:Lt[t.framework]}},_="lunora()",X='import { lunora } from "@lunora/vite";',Mt=/\blunora\s*\(/u,At='"@lunora/vite"',Et="'@lunora/vite'",Nt=e=>{for(const t of e.getStatements()){if(t.getKind()!==w.ExportAssignment)continue;const a=t.asKindOrThrow(w.ExportAssignment).getExpression();if(a.getKind()!==w.CallExpression)continue;const r=a.asKindOrThrow(w.CallExpression);if(r.getExpression().getText()!=="defineConfig")continue;const o=r.getArguments()[0];if(o?.getKind()===w.ObjectLiteralExpression)return o.asKindOrThrow(w.ObjectLiteralExpression)}},It=e=>{for(const t of e.getStatements()){if(t.getKind()!==w.ExportAssignment)continue;const a=t.asKindOrThrow(w.ExportAssignment).getExpression();if(a.getKind()===w.ObjectLiteralExpression)return a.asKindOrThrow(w.ObjectLiteralExpression)}},_t=e=>new Ze({compilerOptions:{allowJs:!0},useInMemoryFileSystem:!0}).createSourceFile("vite.config.ts",e,{overwrite:!0}),$t=(e,t)=>{const a=t.getImportDeclarations().at(-1)?.getEnd()??0;a===0?e.prepend(`${X}
`):e.appendLeft(a,`
${X}`)},Rt=(e,t)=>{const a=t.getProperty("plugins");if(a===void 0){const s=t.getProperties(),n=t.getStart()+1;if(s.length===0)e.appendLeft(n,` plugins: [${_}] `);else{const i=s[0];i!==void 0&&e.appendLeft(i.getStart(),`plugins: [${_}],
`)}return}const r=a.getDescendantsOfKind(w.ArrayLiteralExpression)[0];if(r===void 0)return;const o=r.getElements();if(o.length===0)e.appendLeft(r.getStart()+1,_);else{const s=o[0];s!==void 0&&e.appendLeft(s.getStart(),`${_}, `)}},ke=e=>{if(Mt.test(e))return{changed:!1,code:e,reason:"lunora plugin already present"};const t=_t(e),a=Nt(t)??It(t);if(a===void 0)return{changed:!1,code:e,reason:"could not locate a Vite config plugins array to patch"};const r=new Qe(e);return!e.includes(At)&&!e.includes(Et)&&$t(r,t),Rt(r,a),{changed:!0,code:r.toString()}},Ot=/enotfound|eai_again|econnrefused|etimedout|network|fetch failed|getaddrinfo/u,Tt=/404|not found|could not find|no such/u,Pt=(e,t)=>{const a=e instanceof Error?e.message:String(e),r=a.toLowerCase(),o=`failed to download template "${t.templateType}" from ${t.remote}: ${a}`;return Tt.test(r)?{hints:[`Check the template name "${t.templateType}" and the ref "${t.ref}".`,"List/inspect available templates, or target a branch/tag with `--ref <branch>`."],message:`template "${t.templateType}" not found at ${t.remote}: ${a}`}:Ot.test(r)?{hints:["You appear to be offline or unable to reach GitHub.","To scaffold without a network, point at a local template root: `lunora init --from <dir>`."],message:o}:{hints:["If this is a network/offline issue, scaffold from a local root with `lunora init --from <dir>`."],message:o}},R=async(e,t,a)=>{if(g()){await at(f[e],t,a);return}if(process.stdout.write(`
`),a===void 0||a===""){J(e,t);return}const r=a.split(`
`).map(o=>je(o)).join(`
`);J(e,`${t}
${r}`)},Dt=async e=>{if(g()){await tt();return}e.info(`
${Pe}
${De}: ${Be}`)},Ce=[{description:"LLMs via Workers AI (summarize, generate, stream)",label:"AI",value:"ai"},{description:"Sign-up / sign-in (asks which provider)",label:"Authentication",value:"auth"},{description:"Copy-in auth screens for your framework (sign in/up, reset, 2FA)",label:"Auth UI",value:"auth-ui"},{description:"Snapshot + restore your Durable Object data",label:"Backups",value:"backup"},{description:"Headless browser screenshots + PDFs",label:"Browser rendering",value:"browser"},{description:"Zero Trust identity via Cloudflare Access",label:"Cloudflare Access",value:"cloudflare-access"},{description:"Scheduled jobs via Cron Triggers (@lunora/scheduler)",label:"Cron jobs",value:"crons"},{description:"Cloudflare Email Workers + a dev mail catcher",label:"Transactional email",value:"email"},{description:"OpenFeature feature flags (ctx.flags)",label:"Feature flags",value:"flags"},{description:"External Postgres/MySQL via Hyperdrive",label:"Hyperdrive",value:"hyperdrive"},{description:"Stripe-first payments (checkout, subscription, webhooks)",label:"Payments",value:"payment"},{description:"Live presence / who's-online over hibernated WebSockets",label:"Presence",value:"presence"},{description:"Async message queues (push/pull consumers)",label:"Queues",value:"queue"},{description:"Typed R2 buckets + signed URLs (@lunora/storage)",label:"File storage",value:"storage"},{description:"Durable long-running workflows (step.do, sleep, branch)",label:"Workflows",value:"workflow"}],Q=Ce.map(e=>e.value),Bt=e=>e==="email"?we:e,jt=(e,t)=>{const a=[];for(const r of e.split(",").map(o=>o.trim()).filter(Boolean))Q.includes(r)?a.includes(r)||a.push(r):t(`init: unknown --add feature "${r}" — expected ${Q.join(" | ")}; skipping.`);return a},Vt=async e=>{const t=await ht(e.select),a=await ye(e.text,e.projectName);return{label:"auth",names:[t],transformManifest:r=>be(r,a)}},Ut=async e=>{const t=await e.text(dt,{placeholder:"you@yourdomain.com"}),a=ut(t,r=>{e.logger.warn(r)});return{label:"email",names:[we],transformManifest:a===void 0?void 0:r=>pt(r,a)}},Ft=async(e,t)=>{const a=e.resolveAuthUiItem?.()??"auth-ui-react";if(t)return{label:"auth-ui",names:[a]};const r=await ye(e.text,e.projectName);return{label:"auth-ui",names:[a],transformManifest:o=>be(o,r)}},Ht=async e=>{const t=await lt(e.text,e.projectName);return{label:"storage",names:["storage"],transformManifest:a=>ct(a,t)}},Wt={auth:Vt,"auth-ui":async(e,t)=>Ft(e,t.includes("auth")),email:Ut,storage:Ht},Z=async e=>{if(e.preselected!==void 0&&e.preselected.length>0){await e.applyAll(e.preselected.map(r=>{const o=r==="auth-ui"?e.resolveAuthUiItem?.()??"auth-ui-react":Bt(r);return{label:r,names:[o]}}));return}if(!e.interactive){e.logger.info("tip: add features later with `lunora add <ai|auth|backup|browser|cloudflare-access|crons|email|flags|hyperdrive|payment|presence|queue|storage|workflow>`.");return}const t=await e.multiSelect("Which features do you want to add?",Ce,{defaults:[]});if(t.length===0)return;const a=[];for(const r of t){const o=Wt[r];a.push(o?await o(e,t):{label:r,names:[r]})}await e.applyAll(a)},zt=[{description:"Adds an ignores entry to eslint.config.js",label:"ESLint",value:"eslint"},{description:"Writes .prettierignore",label:"Prettier",value:"prettier"},{description:"Adds negated patterns to biome.json",label:"Biome",value:"biome"},{description:"Writes ignorePatterns to .oxlintrc.json",label:"oxlint (oxc)",value:"oxlint"}],qt="Which linter/formatter do you use? We'll tell it to skip Lunora's generated files.",Kt=async e=>{const t=e.interactive?await e.multiSelect(qt,zt,{defaults:e.detected}):[...e.detected];if(t.length===0)return[];const a=e.apply(t);return mt(a,e.logger),a},N="M 259.500 10.552 C 220.080 15.859, 182.424 32.566, 152.500 58.025 C 110.179 94.031, 85.380 137.183, 77.518 188.500 C 75.410 202.255, 74.569 225.677, 75.796 236.466 C 76.757 244.917, 76.683 245.692, 74.518 249.966 C 63.118 272.466, 53.141 303.876, 51.382 322.799 L 50.718 329.943 71.960 320.471 C 83.643 315.262, 93.326 311, 93.478 311 C 93.630 311, 96.547 316.063, 99.959 322.250 C 103.371 328.438, 107.249 334.850, 108.577 336.500 L 110.990 339.500 110.981 336 C 110.977 334.075, 111.499 324.991, 112.143 315.813 L 113.312 299.127 121.406 293.336 C 132.495 285.403, 149.593 271.554, 161 261.268 C 171.556 251.748, 189.116 235, 188.540 235 C 188.337 235, 183.069 238.648, 176.835 243.106 C 142.318 267.789, 68.537 314, 63.646 314 C 61.843 314, 72.791 281.179, 80.905 262.259 C 92.233 235.845, 107.473 212.389, 132.106 183.453 L 138.451 176 148.268 176 C 176.192 176, 197.512 187.154, 212.868 209.797 C 216.470 215.108, 217.035 216.595, 216.477 219.297 C 211.386 243.968, 202.359 274.496, 193.797 296 C 183.898 320.861, 167.147 352.101, 152.395 373.215 L 147.004 380.930 152.891 385.830 C 161.400 392.911, 165.563 396, 166.594 395.998 C 167.092 395.998, 168.772 391.641, 170.327 386.317 C 176.279 365.934, 188.422 338.749, 200.942 317.778 C 223.060 280.731, 256.432 244.369, 294.500 215.836 C 309.956 204.252, 313.937 201.603, 314.719 202.385 C 315.116 202.783, 315.449 213.096, 315.460 225.304 C 315.474 241.855, 315.021 250.405, 313.680 258.924 C 307.009 301.272, 291.175 336.677, 263.112 372 C 255.259 381.883, 227.182 410.673, 218.516 417.727 L 213.532 421.783 223.439 424.880 C 281.705 443.093, 349.165 436.018, 398.616 406.508 C 446.728 377.797, 483.322 331.466, 497.366 281.481 C 503.381 260.075, 504.480 250.741, 504.491 221 C 504.501 191.997, 503.598 184.047, 497.987 163.732 C 484.768 115.871, 452.505 72.708, 407.718 42.964 C 381.051 25.254, 352.818 14.828, 319.695 10.460 C 305.932 8.645, 273.298 8.695, 259.500 10.552",S=`/* The welcome page is a full-bleed starter — reset the browser's default
body margin/padding so it sits flush; everything else is scoped under
.lunora-welcome (collision-safe). */
body {
margin: 0;
padding: 0;
}
.lunora-welcome {
--cyan: hsl(186 84% 56%); --violet: hsl(256 72% 68%); --rose: hsl(330 80% 64%);
--ribbon: linear-gradient(115deg, var(--cyan), var(--violet) 52%, var(--rose));
--sans: "Geist Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
/* NIGHT (default) */
--bg: #0e0e11; --surface: hsl(240 12% 8% / 0.72); --surface-2: hsl(240 11% 11% / 0.82);
--line: hsl(0 0% 100% / 0.08); --line-2: hsl(0 0% 100% / 0.14);
--t-display: hsl(228 30% 97%); --t-primary: hsl(228 26% 90%); --t-secondary: hsl(228 12% 62%); --t-faint: hsl(228 10% 44%);
--logo: hsl(228 30% 97%); --accent: var(--violet); --shot-bg: hsl(240 12% 6%);
--glow-1: hsl(256 80% 52% / 0.30); --glow-2: hsl(196 84% 52% / 0.13); --arc: hsl(256 60% 70% / 0.11);
position: relative; min-height: 100vh; background: var(--bg); color: var(--t-primary);
font-family: var(--sans); line-height: 1.55; -webkit-font-smoothing: antialiased; overflow-x: hidden;
transition: background .3s, color .3s;
}
.lunora-welcome[data-theme="light"] {
--bg: hsl(228 32% 97%); --surface: hsl(0 0% 100% / 0.82); --surface-2: hsl(0 0% 100% / 0.95);
--line: hsl(228 16% 88%); --line-2: hsl(228 14% 80%);
--t-display: hsl(240 14% 10%); --t-primary: hsl(240 12% 18%); --t-secondary: hsl(235 9% 42%); --t-faint: hsl(235 8% 58%);
--logo: hsl(240 16% 9%); --accent: hsl(256 58% 56%); --shot-bg: hsl(228 26% 99%);
--glow-1: hsl(256 80% 60% / 0.14); --glow-2: hsl(196 84% 58% / 0.08); --arc: hsl(256 40% 55% / 0.12);
}
.lunora-welcome *, .lunora-welcome *::before, .lunora-welcome *::after { box-sizing: border-box; }
.lunora-welcome a { color: inherit; text-decoration: none; }
.lunora-welcome button { font-family: inherit; cursor: pointer; }
.lunora-welcome ::selection { background: hsl(256 72% 68% / 0.3); }
.lunora-welcome code { font-family: var(--mono); }
/* glow background */
.lunora-welcome .lw-bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.lunora-welcome .lw-bg .glow { position: absolute; left: 50%; top: -4%; width: 860px; height: 720px; transform: translateX(-50%);
border-radius: 50%; background: radial-gradient(circle at 50% 50%, var(--glow-1), var(--glow-2) 40%, transparent 66%); }
.lunora-welcome .lw-bg .arc { position: absolute; left: 50%; border-radius: 50%; border: 1px solid var(--arc); transform: translateX(-50%); }
.lunora-welcome .lw-bg .arc.a1 { top: -340px; width: 980px; height: 980px; }
.lunora-welcome .lw-bg .arc.a2 { top: -240px; width: 720px; height: 720px; opacity: .7; }
/* theme toggle */
.lunora-welcome .lw-toggle { position: fixed; z-index: 5; top: 20px; right: clamp(16px,4vw,36px); display: inline-flex; align-items: center;
gap: 7px; border: 1px solid var(--line-2); background: var(--surface); backdrop-filter: blur(12px); color: var(--t-secondary);
font-family: var(--mono); font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; padding: 7px 11px; transition: .15s; }
.lunora-welcome .lw-toggle:hover { color: var(--t-display); border-color: var(--accent); }
.lunora-welcome .lw-toggle svg { width: 13px; height: 13px; }
.lunora-welcome .lw-wrap { position: relative; z-index: 2; width: 100%; max-width: 1080px; margin: 0 auto;
padding: clamp(44px,8vh,92px) clamp(20px,5vw,48px); display: flex; flex-direction: column; min-height: 100vh; }
.lunora-welcome .brand { display: flex; align-items: center; justify-content: center; gap: 15px; margin-bottom: clamp(38px,6vh,68px); color: var(--logo); }
.lunora-welcome .brand svg { width: 54px; height: auto; display: block; }
.lunora-welcome .brand .word { font-size: 34px; font-weight: 600; letter-spacing: -0.03em; color: var(--t-display); }
.lunora-welcome .grid { display: grid; grid-template-columns: 1.06fr 0.94fr; gap: 16px; align-items: stretch; flex: 1; max-height: 580px; }
@media (max-width: 820px) { .lunora-welcome .grid { grid-template-columns: 1fr; max-height: none; } }
.lunora-welcome .card { border: 1px solid var(--line); background: var(--surface); backdrop-filter: blur(10px); transition: border-color .2s, background .2s; }
.lunora-welcome .card:hover { border-color: var(--line-2); background: var(--surface-2); }
.lunora-welcome .card:hover .arrow { color: var(--accent); transform: translateX(3px); }
.lunora-welcome .arrow { color: var(--t-faint); transition: color .2s, transform .2s; }
.lunora-welcome .arrow svg { width: 20px; height: 20px; display: block; }
.lunora-welcome .ic { display: grid; place-items: center; color: var(--accent);
border: 1px solid color-mix(in oklab, var(--accent) 36%, transparent); background: color-mix(in oklab, var(--accent) 12%, transparent); }
.lunora-welcome .ic svg { display: block; }
/* left feature card — stretches to fill the box height */
.lunora-welcome .feature { padding: clamp(18px,2vw,24px); display: flex; flex-direction: column; }
.lunora-welcome .shot { border: 1px solid var(--line); overflow: hidden; background: var(--shot-bg);
-webkit-mask-image: linear-gradient(to bottom, #000 56%, transparent 100%); mask-image: linear-gradient(to bottom, #000 56%, transparent 100%); }
.lunora-welcome .shot .top { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-bottom: 1px solid var(--line); }
.lunora-welcome .shot .wm { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; color: var(--t-primary); }
.lunora-welcome .shot .wm i { width: 11px; height: 11px; background: var(--ribbon); -webkit-mask: radial-gradient(circle,#000 60%,transparent 62%); mask: radial-gradient(circle,#000 60%,transparent 62%); }
.lunora-welcome .shot .search { flex: 1; height: 22px; border: 1px solid var(--line); border-radius: 4px; }
.lunora-welcome .shot .ver { font-family: var(--mono); font-size: 8px; letter-spacing: .08em; color: var(--t-faint); }
.lunora-welcome .shot .body { display: grid; grid-template-columns: 92px 1fr; min-height: 224px; }
.lunora-welcome .shot .nav { border-right: 1px solid var(--line); padding: 14px 12px; display: flex; flex-direction: column; gap: 11px; }
.lunora-welcome .shot .nav i, .lunora-welcome .shot .doc i { height: 5px; border-radius: 2px; background: var(--line); }
.lunora-welcome .shot .doc { padding: 16px 18px; display: flex; flex-direction: column; gap: 9px; }
.lunora-welcome .shot .doc .h { height: 8px; width: 46%; border-radius: 2px; background: var(--line-2); margin-bottom: 5px; }
.lunora-welcome .shot .doc .accent { height: 5px; width: 26%; border-radius: 2px; background: var(--accent); }
.lunora-welcome .feature .info { margin-top: auto; padding-top: clamp(18px,2.4vh,28px); }
.lunora-welcome .feature .ic { width: 40px; height: 40px; margin-bottom: 15px; }
.lunora-welcome .feature .ic svg { width: 19px; height: 19px; }
.lunora-welcome .feature h2 { margin: 0 0 10px; font-size: 19px; font-weight: 600; letter-spacing: -0.015em; color: var(--t-display); }
.lunora-welcome .feature .row { display: flex; align-items: flex-end; gap: 16px; }
.lunora-welcome .feature p { margin: 0; color: var(--t-secondary); font-size: 14px; max-width: 50ch; }
.lunora-welcome .feature .row .arrow { margin-left: auto; }
/* right stack — smaller cards, spread to align bottoms with the feature */
.lunora-welcome .stack { display: flex; flex-direction: column; gap: 16px; height: 100%; }
.lunora-welcome .mini { flex: 1; padding: 15px 17px; display: flex; align-items: center; gap: 16px; min-height: 0; }
.lunora-welcome .mini .mc { flex: 1; }
.lunora-welcome .mini .ic { width: 32px; height: 32px; margin-bottom: 10px; }
.lunora-welcome .mini .ic svg { width: 16px; height: 16px; }
.lunora-welcome .mini h3 { margin: 0 0 5px; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: var(--t-display); }
.lunora-welcome .mini p { margin: 0; color: var(--t-secondary); font-size: 12.5px; line-height: 1.45; }
.lunora-welcome .lw-foot { text-align: center; padding-top: 26px; font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--t-faint); }
@media (prefers-reduced-motion: reduce) { .lunora-welcome * { transition: none !important; } }
`,Gt=`import { useEffect, useState } from "react";
type Theme = "dark" | "light";
export default function App() {
// Default to "dark" for a stable first paint, then reconcile to the OS
// preference; the toggle takes over after that.
const [theme, setTheme] = useState<Theme>("dark");
useEffect(() => {
if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: light)").matches) {
setTheme("light");
}
}, []);
const isLight = theme === "light";
return (
<div className="lunora-welcome" data-theme={theme}>
<div className="lw-bg">
<div className="arc a1" />
<div className="arc a2" />
<div className="glow" />
</div>
<button className="lw-toggle" type="button" aria-label="Toggle color theme" onClick={() => setTheme(isLight ? "dark" : "light")}>
{isLight ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19" />
</svg>
)}
<span>{isLight ? "Ivory" : "Night"}</span>
</button>
<div className="lw-wrap">
<div className="brand">
<svg viewBox="0 0 543 446" role="img" aria-label="Lunora">
<path d="${N}" fill="currentColor" fillRule="evenodd" />
</svg>
<span className="word">Lunora</span>
</div>
<div className="grid">
<a className="card feature" href="https://lunora.sh/docs">
<div className="shot" aria-hidden="true">
<div className="top">
<span className="wm">
<i /> Lunora
</span>
<span className="search" />
<span className="ver">v0.1</span>
</div>
<div className="body">
<div className="nav">
<i style={{ width: "80%" }} />
<i style={{ width: "60%" }} />
<i style={{ width: "72%" }} />
<i style={{ width: "50%" }} />
<i style={{ width: "66%" }} />
<i style={{ width: "44%" }} />
<i style={{ width: "58%" }} />
</div>
<div className="doc">
<span className="h" />
<i style={{ width: "92%" }} />
<i style={{ width: "88%" }} />
<span className="accent" />
<i style={{ width: "80%" }} />
<i style={{ width: "90%" }} />
<i style={{ width: "72%" }} />
<i style={{ width: "84%" }} />
<i style={{ width: "78%" }} />
</div>
</div>
</div>
<div className="info">
<span className="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M4 5a2 2 0 0 1 2-2h9l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path d="M14 3v5h5" />
</svg>
</span>
<h2>Documentation</h2>
<div className="row">
<p>
Schemas, queries, live subscriptions, sharding, and edge deploy — start to finish. New here or coming from Convex or tRPC,
you'll have a live app fast.
</p>
<span className="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</div>
</div>
</a>
<div className="stack">
<a className="card mini" href="https://lunora.sh/blog">
<div className="mc">
<span className="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M5 4h11a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H6a2 2 0 0 1-2-2V5a1 1 0 0 1 1-1zM8 8h7M8 12h7M8 16h4" />
</svg>
</span>
<h3>Blog</h3>
<p>Product updates, deep dives, and what's new in Lunora.</p>
</div>
<span className="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
<a className="card mini" href="/_lunora">
<div className="mc">
<span className="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7">
<rect x="3" y="3" width="18" height="18" rx="1" />
<path d="M3 9h18M9 21V9" />
</svg>
</span>
<h3>Lunora Studio</h3>
<p>Local admin for schema, data, logs, and advisors.</p>
</div>
<span className="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
<a className="card mini" href="https://lunora.sh/packages">
<div className="mc">
<span className="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M12 2 3 7v10l9 5 9-5V7z" />
<path d="M3 7l9 5 9-5M12 12v10" />
</svg>
</span>
<h3>Cloudflare ecosystem</h3>
<p>Auth, mail, storage, AI, payments — one deploy.</p>
</div>
<span className="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
</div>
</div>
<div className="lw-foot">Running on Lunora · Vite + React</div>
</div>
</div>
);
}
`,Jt=`<script setup lang="ts">
import { onMounted, ref } from "vue";
// Default to "dark" for a stable first paint, then reconcile to the OS
// preference; the toggle takes over after that.
const theme = ref<"dark" | "light">("dark");
onMounted(() => {
if (window.matchMedia("(prefers-color-scheme: light)").matches) {
theme.value = "light";
}
});
const toggle = (): void => {
theme.value = theme.value === "light" ? "dark" : "light";
};
<\/script>
<template>
<div class="lunora-welcome" :data-theme="theme">
<div class="lw-bg">
<div class="arc a1" />
<div class="arc a2" />
<div class="glow" />
</div>
<button class="lw-toggle" type="button" aria-label="Toggle color theme" @click="toggle">
<svg v-if="theme === 'light'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19" />
</svg>
<span>{{ theme === "light" ? "Ivory" : "Night" }}</span>
</button>
<div class="lw-wrap">
<div class="brand">
<svg viewBox="0 0 543 446" role="img" aria-label="Lunora">
<path d="${N}" fill="currentColor" fill-rule="evenodd" />
</svg>
<span class="word">Lunora</span>
</div>
<div class="grid">
<a class="card feature" href="https://lunora.sh/docs">
<div class="shot" aria-hidden="true">
<div class="top">
<span class="wm"><i /> Lunora</span>
<span class="search" />
<span class="ver">v0.1</span>
</div>
<div class="body">
<div class="nav">
<i style="width: 80%" />
<i style="width: 60%" />
<i style="width: 72%" />
<i style="width: 50%" />
<i style="width: 66%" />
<i style="width: 44%" />
<i style="width: 58%" />
</div>
<div class="doc">
<span class="h" />
<i style="width: 92%" />
<i style="width: 88%" />
<span class="accent" />
<i style="width: 80%" />
<i style="width: 90%" />
<i style="width: 72%" />
<i style="width: 84%" />
<i style="width: 78%" />
</div>
</div>
</div>
<div class="info">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<path d="M4 5a2 2 0 0 1 2-2h9l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path d="M14 3v5h5" />
</svg>
</span>
<h2>Documentation</h2>
<div class="row">
<p>
Schemas, queries, live subscriptions, sharding, and edge deploy — start to finish. New here or coming from Convex or tRPC,
you'll have a live app fast.
</p>
<span class="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</div>
</div>
</a>
<div class="stack">
<a class="card mini" href="https://lunora.sh/blog">
<div class="mc">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<path d="M5 4h11a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H6a2 2 0 0 1-2-2V5a1 1 0 0 1 1-1zM8 8h7M8 12h7M8 16h4" />
</svg>
</span>
<h3>Blog</h3>
<p>Product updates, deep dives, and what's new in Lunora.</p>
</div>
<span class="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
<a class="card mini" href="/_lunora">
<div class="mc">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<rect x="3" y="3" width="18" height="18" rx="1" />
<path d="M3 9h18M9 21V9" />
</svg>
</span>
<h3>Lunora Studio</h3>
<p>Local admin for schema, data, logs, and advisors.</p>
</div>
<span class="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
<a class="card mini" href="https://lunora.sh/packages">
<div class="mc">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<path d="M12 2 3 7v10l9 5 9-5V7z" />
<path d="M3 7l9 5 9-5M12 12v10" />
</svg>
</span>
<h3>Cloudflare ecosystem</h3>
<p>Auth, mail, storage, AI, payments — one deploy.</p>
</div>
<span class="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
</div>
</div>
<div class="lw-foot">Running on Lunora · Vite + Vue</div>
</div>
</div>
</template>
`,Yt=`import { createSignal, onMount } from "solid-js";
export default function App() {
// Default to "dark" for a stable first paint, then reconcile to the OS
// preference; the toggle takes over after that.
const [theme, setTheme] = createSignal<"dark" | "light">("dark");
onMount(() => {
if (window.matchMedia("(prefers-color-scheme: light)").matches) {
setTheme("light");
}
});
const isLight = () => theme() === "light";
return (
<div class="lunora-welcome" data-theme={theme()}>
<div class="lw-bg">
<div class="arc a1" />
<div class="arc a2" />
<div class="glow" />
</div>
<button class="lw-toggle" type="button" aria-label="Toggle color theme" onClick={() => setTheme(isLight() ? "dark" : "light")}>
{isLight() ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19" />
</svg>
)}
<span>{isLight() ? "Ivory" : "Night"}</span>
</button>
<div class="lw-wrap">
<div class="brand">
<svg viewBox="0 0 543 446" role="img" aria-label="Lunora">
<path d="${N}" fill="currentColor" fill-rule="evenodd" />
</svg>
<span class="word">Lunora</span>
</div>
<div class="grid">
<a class="card feature" href="https://lunora.sh/docs">
<div class="shot" aria-hidden="true">
<div class="top">
<span class="wm">
<i /> Lunora
</span>
<span class="search" />
<span class="ver">v0.1</span>
</div>
<div class="body">
<div class="nav">
<i style={{ width: "80%" }} />
<i style={{ width: "60%" }} />
<i style={{ width: "72%" }} />
<i style={{ width: "50%" }} />
<i style={{ width: "66%" }} />
<i style={{ width: "44%" }} />
<i style={{ width: "58%" }} />
</div>
<div class="doc">
<span class="h" />
<i style={{ width: "92%" }} />
<i style={{ width: "88%" }} />
<span class="accent" />
<i style={{ width: "80%" }} />
<i style={{ width: "90%" }} />
<i style={{ width: "72%" }} />
<i style={{ width: "84%" }} />
<i style={{ width: "78%" }} />
</div>
</div>
</div>
<div class="info">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<path d="M4 5a2 2 0 0 1 2-2h9l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path d="M14 3v5h5" />
</svg>
</span>
<h2>Documentation</h2>
<div class="row">
<p>
Schemas, queries, live subscriptions, sharding, and edge deploy — start to finish. New here or coming from Convex or tRPC,
you'll have a live app fast.
</p>
<span class="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</div>
</div>
</a>
<div class="stack">
<a class="card mini" href="https://lunora.sh/blog">
<div class="mc">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<path d="M5 4h11a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H6a2 2 0 0 1-2-2V5a1 1 0 0 1 1-1zM8 8h7M8 12h7M8 16h4" />
</svg>
</span>
<h3>Blog</h3>
<p>Product updates, deep dives, and what's new in Lunora.</p>
</div>
<span class="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
<a class="card mini" href="/_lunora">
<div class="mc">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<rect x="3" y="3" width="18" height="18" rx="1" />
<path d="M3 9h18M9 21V9" />
</svg>
</span>
<h3>Lunora Studio</h3>
<p>Local admin for schema, data, logs, and advisors.</p>
</div>
<span class="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
<a class="card mini" href="https://lunora.sh/packages">
<div class="mc">
<span class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<path d="M12 2 3 7v10l9 5 9-5V7z" />
<path d="M3 7l9 5 9-5M12 12v10" />
</svg>
</span>
<h3>Cloudflare ecosystem</h3>
<p>Auth, mail, storage, AI, payments — one deploy.</p>
</div>
<span class="arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</a>
</div>
</div>
<div class="lw-foot">Running on Lunora · Vite + Solid</div>
</div>
</div>
);
}
`,Xt=`<script lang="ts">
import { onMount } from "svelte";
// Default to "dark" for a stable first paint, then reconcile to the OS
// preference; the toggle takes over after that.
let theme = $state<"dark" | "light">("dark");
const isLight = $derived(theme === "light");
onMount(() => {
if (window.matchMedia("(prefers-color-scheme: light)").matches) {
theme = "light";
}
});
const toggle = (): void => {
theme = theme === "light" ? "dark" : "light";
};
<\/script>
<div class="lunora-welcome" data-theme={theme}>
<div class="lw-bg">
<div class="arc a1"></div>
<div class="arc a2"></div>
<div class="glow"></div>
</div>
<button class="lw-toggle" type="button" aria-label="Toggle color theme" onclick={toggle}>
{#if isLight}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19" />
</svg>
{/if}
<span>{isLight ? "Ivory" : "Night"}</span>
</button>
<div class="lw-wrap">
<div class="brand">
<svg viewBox="0 0 543 446" role="img" aria-label="Lunora">
<path d="${N}" fill="currentColor" fill-rule="evenodd" />
</svg>
<span class="word">Lunora</span>
</div>
<div class="grid">
<a class="card feature" href="https://lunora.sh/docs">
<div class="shot" aria-hidden="true">
<div class="top">
<span class="wm"><i></i> Lunora</span>
<span class="search"></span>
<span class="ver">v0.1</span>
</div>
<div class="body">
<div class="nav">
<i style="