create-lacspace-app 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib.cjs CHANGED
@@ -2001,6 +2001,11 @@ function applyFeatures(files, ctx) {
2001
2001
  files[rel] = content;
2002
2002
  }
2003
2003
  }
2004
+ if (ctx.mode === "static") {
2005
+ for (const feat of features) {
2006
+ for (const [rel, content] of Object.entries(feat.rootFiles?.(ctx) ?? {})) files[rel] = content;
2007
+ }
2008
+ }
2004
2009
  const addDeps = {};
2005
2010
  const addScripts = {};
2006
2011
  for (const feat of features) {
@@ -2279,6 +2284,68 @@ var FEATURES = [
2279
2284
  "Reuse the helpers in backend/src/mail/mailer.ts: sendWelcome / sendVerify / sendReset."
2280
2285
  ],
2281
2286
  learn: "https://developer.lacspace.com/packages/mailer"
2287
+ },
2288
+ {
2289
+ key: "i18n",
2290
+ label: "i18n (multi-language)",
2291
+ description: "Multi-language UI: a tiny runtime translator (t() + a language switcher) with English + Nepali locales, plus an i18n:check lint script.",
2292
+ deps: {},
2293
+ scripts: { "i18n:check": "npx lacspace-i18n@0.2.0 check locales" },
2294
+ files: () => ({
2295
+ "lib/i18n.ts": i18nLib(),
2296
+ "components/language-provider.tsx": i18nProvider(),
2297
+ "app/i18n-demo/page.tsx": i18nDemoPage(),
2298
+ "locales/en.json": i18nEn(),
2299
+ "locales/ne.json": i18nNe()
2300
+ }),
2301
+ nextSteps: [
2302
+ "Wrap app/layout.tsx's <body> with <LanguageProvider> (from @/components/language-provider).",
2303
+ 'Use it in a client component: const { t } = useT(); then {t("hello", { name })}.',
2304
+ "Add keys in locales/*.json, then lint them with `npm run i18n:check`."
2305
+ ],
2306
+ learn: "https://developer.lacspace.com/tools/i18n"
2307
+ },
2308
+ {
2309
+ key: "quality",
2310
+ label: "Quality & CI",
2311
+ description: "One-command quality gates from the Lacspace dev-tools \u2014 bundle-size budget, dependency audit and fake fixtures \u2014 plus a ready GitHub Actions CI workflow.",
2312
+ deps: {},
2313
+ scripts: {
2314
+ "size:check": "npx lacspace-size@0.2.0 .next/static --metric gzip --max 500kb",
2315
+ "deps:audit": "npx lacspace-deps@0.2.0 . --fail-on duplicates",
2316
+ "fixtures:gen": 'npx lacspace-fake@0.2.0 --fields "id:autoincrement,name:fullName,email:unique(email)" -n 50 --pretty -o fixtures/users.json'
2317
+ },
2318
+ files: () => ({}),
2319
+ rootFiles: (ctx) => ({ ".github/workflows/ci.yml": qualityCiWorkflow(ctx) }),
2320
+ nextSteps: [
2321
+ "Run `npm run size:check` (bundle-size budget) and `npm run deps:audit` (dependency audit) \u2014 both fail loudly in CI.",
2322
+ "Generate sample data: `npm run fixtures:gen` \u2192 fixtures/users.json.",
2323
+ "The GitHub Actions workflow (.github/workflows/ci.yml) runs the checks on every push."
2324
+ ],
2325
+ learn: "https://developer.lacspace.com/tools/size"
2326
+ },
2327
+ {
2328
+ key: "uploads",
2329
+ label: "File uploads",
2330
+ description: "Authenticated file uploads stored in MongoDB, served via signed, expiring URLs (@lacspace/signed-url). No S3 required. Full-stack.",
2331
+ requiresBackend: true,
2332
+ deps: {},
2333
+ files: () => ({ "app/uploads/page.tsx": uploadsPage() }),
2334
+ backend: () => ({
2335
+ deps: { "@lacspace/signed-url": "^1.1.0" },
2336
+ files: {
2337
+ "src/models/upload.ts": uploadModel(),
2338
+ "src/routes/uploads.ts": uploadsRoutes()
2339
+ },
2340
+ routes: [{ path: "/uploads", handler: "uploadRoutes", auth: false, importLine: 'import uploadRoutes from "./uploads.js";' }],
2341
+ env: { UPLOAD_URL_SECRET: "Secret for signing file download URLs (blank = reuse JWT_SECRET)." }
2342
+ }),
2343
+ nextSteps: [
2344
+ "Sign in, then open http://localhost:3000/uploads and upload an image or file.",
2345
+ "Files are stored in MongoDB and served via signed, expiring links \u2014 no S3 required.",
2346
+ "For large files or production, swap the Mongo storage for disk or object storage."
2347
+ ],
2348
+ learn: "https://developer.lacspace.com/packages/signed-url"
2282
2349
  }
2283
2350
  ];
2284
2351
  var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
@@ -6263,6 +6330,291 @@ export default function EmailTestPage() {
6263
6330
  );
6264
6331
  }
6265
6332
  `;
6333
+ var i18nLib = () => `import en from "@/locales/en.json";
6334
+ import ne from "@/locales/ne.json";
6335
+
6336
+ // how this works: a tiny, dependency-free translator. Locale files live in locales/,
6337
+ // keys are flat strings, and {vars} are interpolated. A missing key falls back to
6338
+ // English, then to the key itself. Lint the files with \`npm run i18n:check\`.
6339
+ export const locales = ["en", "ne"] as const;
6340
+ export type Locale = (typeof locales)[number];
6341
+ export const defaultLocale: Locale = "en";
6342
+
6343
+ const dicts: Record<Locale, Record<string, string>> = {
6344
+ en: en as Record<string, string>,
6345
+ ne: ne as Record<string, string>,
6346
+ };
6347
+
6348
+ export function translate(locale: Locale, key: string, vars?: Record<string, string | number>): string {
6349
+ const table = dicts[locale] ?? dicts[defaultLocale];
6350
+ let str = table[key] ?? dicts[defaultLocale][key] ?? key;
6351
+ if (vars) for (const [k, v] of Object.entries(vars)) str = str.split("{" + k + "}").join(String(v));
6352
+ return str;
6353
+ }
6354
+ `;
6355
+ var i18nProvider = () => `"use client";
6356
+ import { createContext, useContext, useEffect, useState } from "react";
6357
+ import { locales, defaultLocale, translate, type Locale } from "@/lib/i18n";
6358
+
6359
+ // how this works: holds the current locale (persisted in localStorage) and exposes
6360
+ // t() via the useT() hook. Wrap your app in <LanguageProvider> once (in layout.tsx).
6361
+ interface LocaleCtxValue { locale: Locale; setLocale: (l: Locale) => void; }
6362
+ const LocaleCtx = createContext<LocaleCtxValue>({ locale: defaultLocale, setLocale: () => {} });
6363
+
6364
+ export function LanguageProvider({ children }: { children: React.ReactNode }) {
6365
+ const [locale, setLocaleState] = useState<Locale>(defaultLocale);
6366
+ useEffect(() => {
6367
+ try {
6368
+ const saved = localStorage.getItem("locale");
6369
+ if (saved && (locales as readonly string[]).includes(saved)) setLocaleState(saved as Locale);
6370
+ } catch {}
6371
+ }, []);
6372
+ const setLocale = (l: Locale) => { setLocaleState(l); try { localStorage.setItem("locale", l); } catch {} };
6373
+ return <LocaleCtx.Provider value={{ locale, setLocale }}>{children}</LocaleCtx.Provider>;
6374
+ }
6375
+
6376
+ export function useT() {
6377
+ const { locale, setLocale } = useContext(LocaleCtx);
6378
+ return {
6379
+ t: (key: string, vars?: Record<string, string | number>) => translate(locale, key, vars),
6380
+ locale,
6381
+ setLocale,
6382
+ };
6383
+ }
6384
+
6385
+ export function LanguageSwitcher() {
6386
+ const { locale, setLocale } = useT();
6387
+ return (
6388
+ <div className="inline-flex gap-1 rounded-full border border-hairline p-1">
6389
+ {locales.map((l) => (
6390
+ <button
6391
+ key={l}
6392
+ onClick={() => setLocale(l)}
6393
+ className={"rounded-full px-3 py-1 text-sm transition " + (l === locale ? "gradient-bg on-accent" : "text-muted hover:text-fg")}
6394
+ >
6395
+ {l.toUpperCase()}
6396
+ </button>
6397
+ ))}
6398
+ </div>
6399
+ );
6400
+ }
6401
+ `;
6402
+ var i18nDemoPage = () => `"use client";
6403
+ import { LanguageProvider, LanguageSwitcher, useT } from "@/components/language-provider";
6404
+
6405
+ function Demo() {
6406
+ const { t } = useT();
6407
+ return (
6408
+ <div className="space-y-4">
6409
+ <h1 className="text-3xl font-bold">{t("hello", { name: "world" })}</h1>
6410
+ <p className="text-muted">{t("tagline")}</p>
6411
+ <LanguageSwitcher />
6412
+ </div>
6413
+ );
6414
+ }
6415
+
6416
+ export default function I18nDemoPage() {
6417
+ return (
6418
+ <main className="mx-auto max-w-md px-6 py-16">
6419
+ <LanguageProvider>
6420
+ <Demo />
6421
+ </LanguageProvider>
6422
+ </main>
6423
+ );
6424
+ }
6425
+ `;
6426
+ var i18nEn = () => JSON.stringify(
6427
+ { hello: "Hello, {name}!", tagline: "This text switches language instantly \u2014 no reload.", "nav.home": "Home", "nav.about": "About" },
6428
+ null,
6429
+ 2
6430
+ ) + "\n";
6431
+ var i18nNe = () => JSON.stringify(
6432
+ { hello: "\u0928\u092E\u0938\u094D\u0924\u0947, {name}!", tagline: "\u092F\u094B \u092A\u093E\u0920 \u0924\u0941\u0930\u0941\u0928\u094D\u0924\u0948 \u092D\u093E\u0937\u093E \u092B\u0947\u0930\u094D\u091B \u2014 \u0930\u093F\u0932\u094B\u0921 \u092C\u093F\u0928\u093E\u0964", "nav.home": "\u0917\u0943\u0939", "nav.about": "\u092C\u093E\u0930\u0947\u092E\u093E" },
6433
+ null,
6434
+ 2
6435
+ ) + "\n";
6436
+ var qualityCiWorkflow = (ctx) => {
6437
+ const sizePath = ctx.mode === "dynamic" ? "frontend/.next/static" : ".next/static";
6438
+ const depsGate = ctx.mode === "dynamic" ? "duplicates" : "missing,duplicates";
6439
+ return `name: CI
6440
+ on:
6441
+ push:
6442
+ branches: [main]
6443
+ pull_request:
6444
+ jobs:
6445
+ quality:
6446
+ runs-on: ubuntu-latest
6447
+ steps:
6448
+ - uses: actions/checkout@v4
6449
+ - uses: actions/setup-node@v4
6450
+ with:
6451
+ node-version: 20
6452
+ cache: npm
6453
+ - run: npm ci
6454
+ - run: npm run build
6455
+ - name: Bundle-size budget
6456
+ run: npx lacspace-size@0.2.0 ${sizePath} --metric gzip --max 500kb
6457
+ - name: Dependency audit
6458
+ run: npx lacspace-deps@0.2.0 . --fail-on ${depsGate}
6459
+ `;
6460
+ };
6461
+ var uploadModel = () => `import mongoose from "mongoose";
6462
+ import { uuidv7 } from "@lacspace/id";
6463
+
6464
+ // how this works: files are stored base64-in-Mongo so it works with NO S3/disk setup.
6465
+ // For large files or production, swap this for disk or object storage.
6466
+ export interface UploadDoc {
6467
+ _id: string;
6468
+ userId: string;
6469
+ filename: string;
6470
+ contentType: string;
6471
+ size: number;
6472
+ data: string; // base64
6473
+ createdAt: Date;
6474
+ }
6475
+
6476
+ const schema = new mongoose.Schema<UploadDoc>(
6477
+ {
6478
+ _id: { type: String, default: () => uuidv7() },
6479
+ userId: { type: String, required: true, index: true },
6480
+ filename: { type: String, required: true },
6481
+ contentType: { type: String, default: "application/octet-stream" },
6482
+ size: { type: Number, default: 0 },
6483
+ data: { type: String, required: true },
6484
+ },
6485
+ { timestamps: { createdAt: true, updatedAt: false } },
6486
+ );
6487
+
6488
+ export const Upload =
6489
+ (mongoose.models.Upload as mongoose.Model<UploadDoc>) ?? mongoose.model<UploadDoc>("Upload", schema);
6490
+ `;
6491
+ var uploadsRoutes = () => `import express from "express";
6492
+ import { signUrl, verifyUrl } from "@lacspace/signed-url";
6493
+ import { v } from "@lacspace/validate";
6494
+ import { asyncHandler, HttpError } from "../http.js";
6495
+ import { requireAuth } from "../middleware/auth.js";
6496
+ import { env } from "../env.js";
6497
+ import { Upload } from "../models/upload.js";
6498
+
6499
+ // how this works: uploads live in Mongo; each file is served from a PUBLIC route
6500
+ // protected by a signed, expiring URL (@lacspace/signed-url) \u2014 so <img src> works
6501
+ // without a bearer token, yet the link can't be forged or shared forever.
6502
+ const router = express.Router();
6503
+ const SECRET = process.env.UPLOAD_URL_SECRET ?? env.JWT_SECRET;
6504
+ const MAX_BYTES = 5 * 1024 * 1024; // 5 MB \u2014 base64-in-Mongo, keep it small
6505
+
6506
+ const UploadInput = v.object({ filename: v.string().min(1).max(200), dataUrl: v.string().min(1) });
6507
+
6508
+ // Sign a RELATIVE URL; the frontend prefixes it with the API base. Verified as-is.
6509
+ function signed(id: string): Promise<string> {
6510
+ return signUrl("/uploads/" + id + "/raw", { secret: SECRET, expiresIn: 3600 });
6511
+ }
6512
+
6513
+ // GET /uploads/:id/raw \u2014 PUBLIC, but the signed query params must verify.
6514
+ router.get("/:id/raw", asyncHandler(async (req, res) => {
6515
+ const check = await verifyUrl(req.originalUrl, { secret: SECRET, clockToleranceSec: 60 });
6516
+ if (!check.valid) throw new HttpError(check.reason === "expired" ? 410 : 403, "Link expired or invalid");
6517
+ const file = await Upload.findById(req.params.id);
6518
+ if (!file) throw new HttpError(404, "Not found");
6519
+ res.setHeader("Content-Type", file.contentType);
6520
+ res.send(Buffer.from(file.data, "base64"));
6521
+ }));
6522
+
6523
+ // POST /uploads \u2014 protected: store a base64 data URL.
6524
+ router.post("/", requireAuth, asyncHandler(async (req, res) => {
6525
+ const { filename, dataUrl } = UploadInput.parse(req.body);
6526
+ const m = /^data:(.+?);base64,(.*)$/.exec(dataUrl);
6527
+ if (!m) throw new HttpError(400, "Expected a base64 data URL");
6528
+ const contentType = m[1]!;
6529
+ const data = m[2]!;
6530
+ const size = Math.floor((data.length * 3) / 4);
6531
+ if (size > MAX_BYTES) throw new HttpError(413, "File too large (max 5 MB)");
6532
+ const file = await Upload.create({ userId: req.user!.sub, filename, contentType, size, data });
6533
+ res.status(201).json({ id: String(file._id), filename, contentType, size, url: await signed(String(file._id)) });
6534
+ }));
6535
+
6536
+ // GET /uploads \u2014 protected: this user's files (with fresh signed URLs).
6537
+ router.get("/", requireAuth, asyncHandler(async (req, res) => {
6538
+ const files = await Upload.find({ userId: req.user!.sub }).sort({ createdAt: -1 }).select("filename contentType size");
6539
+ const out = await Promise.all(
6540
+ files.map(async (f) => ({ id: String(f._id), filename: f.filename, contentType: f.contentType, size: f.size, url: await signed(String(f._id)) })),
6541
+ );
6542
+ res.json(out);
6543
+ }));
6544
+
6545
+ export default router;
6546
+ `;
6547
+ var uploadsPage = () => `"use client";
6548
+ import { useEffect, useState } from "react";
6549
+ import { useRouter } from "next/navigation";
6550
+ import { getToken } from "@/lib/api";
6551
+
6552
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6553
+ interface FileItem { id: string; filename: string; contentType: string; size: number; url: string; }
6554
+
6555
+ function readAsDataUrl(file: File): Promise<string> {
6556
+ return new Promise((resolve, reject) => {
6557
+ const r = new FileReader();
6558
+ r.onload = () => resolve(String(r.result));
6559
+ r.onerror = () => reject(new Error("read failed"));
6560
+ r.readAsDataURL(file);
6561
+ });
6562
+ }
6563
+
6564
+ export default function UploadsPage() {
6565
+ const router = useRouter();
6566
+ const [files, setFiles] = useState<FileItem[]>([]);
6567
+ const [busy, setBusy] = useState(false);
6568
+ const [error, setError] = useState<string | null>(null);
6569
+
6570
+ async function load() {
6571
+ const token = getToken();
6572
+ if (!token) { router.push("/login"); return; }
6573
+ const res = await fetch(API + "/uploads", { headers: { Authorization: "Bearer " + token } });
6574
+ if (res.ok) setFiles(await res.json());
6575
+ }
6576
+ useEffect(() => { void load(); }, []);
6577
+
6578
+ async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
6579
+ const file = e.target.files?.[0];
6580
+ if (!file) return;
6581
+ setBusy(true); setError(null);
6582
+ try {
6583
+ const dataUrl = await readAsDataUrl(file);
6584
+ const token = getToken();
6585
+ const res = await fetch(API + "/uploads", {
6586
+ method: "POST",
6587
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6588
+ body: JSON.stringify({ filename: file.name, dataUrl }),
6589
+ });
6590
+ const data: unknown = await res.json().catch(() => ({}));
6591
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Upload failed");
6592
+ await load();
6593
+ } catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); }
6594
+ finally { setBusy(false); }
6595
+ }
6596
+
6597
+ return (
6598
+ <main className="mx-auto max-w-lg px-6 py-16">
6599
+ <h1 className="text-2xl font-bold">Uploads</h1>
6600
+ <p className="mt-1 text-muted">Stored in MongoDB, served via signed, expiring links.</p>
6601
+ <label className="mt-6 flex cursor-pointer items-center justify-center rounded-2xl border border-dashed border-hairline p-8 text-muted transition hover:bg-surface">
6602
+ <input type="file" className="hidden" onChange={onPick} disabled={busy} />
6603
+ {busy ? "Uploading\u2026" : "Choose a file (max 5 MB)"}
6604
+ </label>
6605
+ {error && <p className="mt-3 text-sm text-red-400">{error}</p>}
6606
+ <ul className="mt-6 space-y-2">
6607
+ {files.map((f) => (
6608
+ <li key={f.id} className="flex items-center justify-between rounded-xl border border-hairline p-3">
6609
+ <a href={API + f.url} target="_blank" rel="noreferrer" className="truncate underline">{f.filename}</a>
6610
+ <span className="ml-3 shrink-0 text-sm text-muted">{Math.max(1, Math.round(f.size / 1024))} KB</span>
6611
+ </li>
6612
+ ))}
6613
+ </ul>
6614
+ </main>
6615
+ );
6616
+ }
6617
+ `;
6266
6618
  var scope = (ctx) => `@${ctx.name}`;
6267
6619
  var typesPkgJson = (ctx) => JSON.stringify({
6268
6620
  name: `${scope(ctx)}/types`,
@@ -6662,6 +7014,9 @@ function buildFullStack(ctx) {
6662
7014
  out[".gitignore"] = rootGitignore();
6663
7015
  out[".env.example"] = rootEnvExample(ctx);
6664
7016
  out["README.md"] = rootReadme(ctx);
7017
+ for (const f of ctx.features) {
7018
+ for (const [rel, content] of Object.entries(f.rootFiles?.(ctx) ?? {})) out[rel] = content;
7019
+ }
6665
7020
  const envBase = out[".env.example"];
6666
7021
  const seen = new Set(
6667
7022
  (envBase.match(/^#?\s*([A-Z0-9_]+)=/gm) ?? []).map((l) => l.replace(/^#?\s*/, "").replace(/=.*/, ""))
@@ -7221,7 +7576,7 @@ function backendFiles(ctx) {
7221
7576
  }
7222
7577
  var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
7223
7578
  function parseArgs(list) {
7224
- const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false };
7579
+ const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false, dryRun: false };
7225
7580
  for (let i = 0; i < list.length; i++) {
7226
7581
  const arg = list[i];
7227
7582
  const next = () => list[++i] ?? "";
@@ -7229,8 +7584,9 @@ function parseArgs(list) {
7229
7584
  else if (arg === "-y" || arg === "--yes") a.yes = true;
7230
7585
  else if (arg === "--no-install") a.install = false;
7231
7586
  else if (arg === "--no-git") a.git = false;
7587
+ else if (arg === "--dry-run" || arg === "--dry") a.dryRun = true;
7232
7588
  else if (arg === "--pm") a.pm = next();
7233
- else if (arg === "--theme" || arg === "--accent") a.theme = next();
7589
+ else if (arg === "--theme" || arg === "--accent" || arg === "--brand") a.theme = next();
7234
7590
  else if (arg === "--with" || arg === "--features") a.features.push(...splitList(next()));
7235
7591
  else if (arg === "--fullstack" || arg === "--full-stack" || arg === "--dynamic") a.mode = "dynamic";
7236
7592
  else if (arg === "--static") a.mode = "static";
@@ -7246,6 +7602,7 @@ function parseArgs(list) {
7246
7602
  else if (arg.startsWith("--template=")) a.template = arg.slice(11);
7247
7603
  else if (arg.startsWith("--theme=")) a.theme = arg.slice(8);
7248
7604
  else if (arg.startsWith("--accent=")) a.theme = arg.slice(9);
7605
+ else if (arg.startsWith("--brand=")) a.theme = arg.slice(8);
7249
7606
  else if (arg.startsWith("--with=")) a.features.push(...splitList(arg.slice(7)));
7250
7607
  else if (arg.startsWith("--features=")) a.features.push(...splitList(arg.slice(11)));
7251
7608
  else if (!arg.startsWith("-") && !a.name) a.name = arg;
@@ -7336,6 +7693,8 @@ ${c("bold", "Usage")}
7336
7693
  npm create lacspace-app@latest <name> [options]
7337
7694
  npx create-lacspace-app <name> --template <key>
7338
7695
  npx create-lacspace-app add <section...> ${c("dim", "# grow an existing app")}
7696
+ npx create-lacspace-app list ${c("dim", "# templates, add-ons & recipes")}
7697
+ npx create-lacspace-app explain <name> ${c("dim", "# what an add-on/recipe gives you")}
7339
7698
 
7340
7699
  ${c("bold", "Templates")}
7341
7700
  ${TEMPLATES.map((t) => ` ${t.key.padEnd(10)} ${t.description}`).join("\n")}
@@ -7347,8 +7706,9 @@ ${c("bold", "Options")}
7347
7706
  ${c("dim", "(alias --dynamic; default is --static, a single Next.js app)")}
7348
7707
  --with <a,b> Feature add-ons, comma-separated (alias --features)
7349
7708
  --recipe <key> Start from a curated stack (${RECIPES.map((r) => r.key).join(" | ")})
7350
- --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (e.g. --theme lacspace)
7709
+ --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (alias --brand)
7351
7710
  --pm <npm|pnpm|yarn|bun> Package manager (default npm)
7711
+ --dry-run Print the files that would be created; write nothing
7352
7712
  --no-install Skip installing dependencies
7353
7713
  --no-git Skip git init
7354
7714
  -y, --yes Accept defaults (needs <name>)
@@ -7724,12 +8084,103 @@ ${c("bold", "Use them")} \u2014 import into any page:
7724
8084
  return;
7725
8085
  }
7726
8086
  }
8087
+ function runList() {
8088
+ process$1.stdout.write(`
8089
+ ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 what you can build")}
8090
+ `);
8091
+ process$1.stdout.write(`
8092
+ ${c("bold", "Templates")} ${c("dim", "(-t / --template)")}
8093
+ `);
8094
+ for (const t of TEMPLATES) process$1.stdout.write(` ${c("cyan", t.key.padEnd(12))} ${c("dim", t.description)}
8095
+ `);
8096
+ process$1.stdout.write(`
8097
+ ${c("bold", "Add-ons")} ${c("dim", "(--with) \u2014 free & keyless")}
8098
+ `);
8099
+ for (const f of FEATURES) process$1.stdout.write(` ${c("cyan", f.key.padEnd(12))} ${c("dim", f.description)}${f.requiresBackend ? c("green", " [full-stack]") : ""}
8100
+ `);
8101
+ process$1.stdout.write(`
8102
+ ${c("bold", "Recipes")} ${c("dim", "(--recipe) \u2014 a whole product in one command")}
8103
+ `);
8104
+ for (const r of RECIPES) process$1.stdout.write(` ${c("cyan", r.key.padEnd(14))} ${c("dim", r.description)}
8105
+ `);
8106
+ process$1.stdout.write(`
8107
+ ${c("dim", "Learn one:")} ${c("cyan", "npx create-lacspace-app explain <name>")}
8108
+
8109
+ `);
8110
+ }
8111
+ function runExplain(rawNames) {
8112
+ const name = rawNames.find((n) => !n.startsWith("-"))?.toLowerCase();
8113
+ if (!name) {
8114
+ process$1.stdout.write(`Usage: ${c("cyan", "npx create-lacspace-app explain <template|add-on|recipe>")}
8115
+
8116
+ `);
8117
+ runList();
8118
+ return;
8119
+ }
8120
+ const feat = FEATURES.find((f) => f.key === name);
8121
+ const recipe = RECIPES.find((r) => r.key === name);
8122
+ const tmpl = TEMPLATES.find((t) => t.key === name);
8123
+ process$1.stdout.write("\n");
8124
+ if (feat) {
8125
+ process$1.stdout.write(`${c("bold", c("magenta", "add-on: " + feat.key))} ${feat.requiresBackend ? c("green", "[full-stack]") : c("dim", "[frontend]")}
8126
+
8127
+ ${feat.description}
8128
+ `);
8129
+ const deps = { ...feat.deps, ...feat.backend?.({ name: "app", template: TEMPLATES[0], features: [], mode: "dynamic" })?.deps ?? {} };
8130
+ if (Object.keys(deps).length) process$1.stdout.write(`
8131
+ ${c("bold", "Packages")}: ${Object.keys(deps).map((d) => c("cyan", d)).join(", ")}
8132
+ `);
8133
+ process$1.stdout.write(`
8134
+ ${c("bold", "Next steps")}:
8135
+ `);
8136
+ for (const s of feat.nextSteps) process$1.stdout.write(` ${c("dim", "\u2022")} ${s}
8137
+ `);
8138
+ if (feat.learn) process$1.stdout.write(`
8139
+ Learn more \u2192 ${c("cyan", feat.learn)}
8140
+ `);
8141
+ process$1.stdout.write(`
8142
+ Add it: ${c("cyan", "npx create-lacspace-app my-app --with " + feat.key)}
8143
+
8144
+ `);
8145
+ } else if (recipe) {
8146
+ process$1.stdout.write(`${c("bold", c("magenta", "recipe: " + recipe.key))}
8147
+
8148
+ ${recipe.description}
8149
+
8150
+ ${c("bold", "Template")}: ${c("cyan", recipe.template)}${recipe.mode ? c("dim", " \xB7 " + recipe.mode) : ""}
8151
+ ${c("bold", "Add-ons")}: ${recipe.features.map((f) => c("cyan", f)).join(", ")}
8152
+
8153
+ Use it: ${c("cyan", "npx create-lacspace-app my-app --recipe " + recipe.key)}
8154
+
8155
+ `);
8156
+ } else if (tmpl) {
8157
+ process$1.stdout.write(`${c("bold", c("magenta", "template: " + tmpl.key))}
8158
+
8159
+ ${tmpl.description}
8160
+
8161
+ Use it: ${c("cyan", "npx create-lacspace-app my-app --template " + tmpl.key)}
8162
+
8163
+ `);
8164
+ } else {
8165
+ process$1.stdout.write(c("yellow", `Unknown "${name}".
8166
+ `));
8167
+ runList();
8168
+ }
8169
+ }
7727
8170
  async function main() {
7728
8171
  const raw = process$1.argv.slice(2);
7729
8172
  if (raw[0] === "add") {
7730
8173
  runAdd(raw.slice(1));
7731
8174
  return;
7732
8175
  }
8176
+ if (raw[0] === "list" || raw[0] === "ls") {
8177
+ runList();
8178
+ return;
8179
+ }
8180
+ if (raw[0] === "explain" || raw[0] === "info") {
8181
+ runExplain(raw.slice(1));
8182
+ return;
8183
+ }
7733
8184
  const args = parseArgs(raw);
7734
8185
  if (args.help) {
7735
8186
  process$1.stdout.write(HELP + "\n");
@@ -7748,10 +8199,32 @@ ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 a go
7748
8199
  let templateKey = args.template ?? recipe?.template;
7749
8200
  let mode = args.mode ?? recipe?.mode ?? "static";
7750
8201
  const featureKeys = [...recipe?.features ?? [], ...args.features];
8202
+ let usedRecipe = Boolean(recipe);
7751
8203
  if (!args.yes && process$1.stdin.isTTY) {
7752
8204
  const rl = promises.createInterface({ input: process$1.stdin, output: process$1.stdout });
7753
8205
  try {
7754
8206
  if (!name) name = (await rl.question(`${c("green", "?")} Project name ${c("dim", "(my-app)")}: `)).trim() || "my-app";
8207
+ if (!usedRecipe && !templateKey && featureKeys.length === 0) {
8208
+ process$1.stdout.write(`
8209
+ Start from a recipe? ${c("dim", "(a template + add-ons, ready to go)")}
8210
+ `);
8211
+ RECIPES.forEach((r, i) => process$1.stdout.write(` ${c("cyan", String(i + 1))}. ${c("bold", r.label)} ${c("dim", "\u2014 " + r.description)}
8212
+ `));
8213
+ process$1.stdout.write(` ${c("cyan", "0")}. ${c("dim", "No recipe \u2014 I'll choose a template myself")}
8214
+ `);
8215
+ const ans = (await rl.question(`
8216
+ ${c("green", "?")} Recipe ${c("dim", "(0)")}: `)).trim() || "0";
8217
+ const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : RECIPES.findIndex((r) => r.key === ans);
8218
+ const chosen = ans === "0" ? void 0 : RECIPES[idx];
8219
+ if (chosen) {
8220
+ usedRecipe = true;
8221
+ templateKey = chosen.template;
8222
+ mode = chosen.mode ?? mode;
8223
+ for (const k of chosen.features) if (!featureKeys.includes(k)) featureKeys.push(k);
8224
+ process$1.stdout.write(` ${c("green", "\u2714")} Recipe ${c("cyan", chosen.key)}
8225
+ `);
8226
+ }
8227
+ }
7755
8228
  if (!templateKey) {
7756
8229
  process$1.stdout.write(`
7757
8230
  Choose a template:
@@ -7763,7 +8236,7 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
7763
8236
  const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : TEMPLATES.findIndex((t) => t.key === ans);
7764
8237
  templateKey = TEMPLATES[idx]?.key ?? "personal";
7765
8238
  }
7766
- if (args.mode === void 0 && !recipe) {
8239
+ if (args.mode === void 0 && !usedRecipe) {
7767
8240
  process$1.stdout.write(`
7768
8241
  What kind of app?
7769
8242
  `);
@@ -7822,6 +8295,19 @@ ${c("green", "?")} Add features? ${c("dim", "(comma-separated numbers, or Enter
7822
8295
  `);
7823
8296
  }
7824
8297
  const files = buildFiles({ name: projectName, template, features, mode });
8298
+ if (args.dryRun) {
8299
+ const keys = Object.keys(files).sort();
8300
+ process$1.stdout.write(`
8301
+ ${c("bold", "Dry run")} ${c("dim", `\u2014 ${keys.length} files (${mode}${features.length ? ", +" + features.map((f) => f.key).join(",") : ""}), nothing written`)}
8302
+ `);
8303
+ for (const rel of keys) process$1.stdout.write(` ${c("dim", "+ " + rel)}
8304
+ `);
8305
+ process$1.stdout.write(`
8306
+ ${c("dim", "Remove --dry-run to create the project.")}
8307
+
8308
+ `);
8309
+ return;
8310
+ }
7825
8311
  for (const [rel, content] of Object.entries(files)) {
7826
8312
  const full = path.join(dir, rel);
7827
8313
  fs.mkdirSync(path.dirname(full), { recursive: true });
package/dist/lib.d.cts CHANGED
@@ -26,6 +26,12 @@ interface FeatureDef {
26
26
  deps: Record<string, string>;
27
27
  /** The files this feature drops in, given the resolved {@link Ctx}. */
28
28
  files: (ctx: Ctx) => Record<string, string>;
29
+ /**
30
+ * Files placed at the PROJECT ROOT — in static mode that's the app root; in
31
+ * dynamic mode it's the monorepo root (not `frontend/`). Use it for things a
32
+ * repo needs at the top, like a CI workflow.
33
+ */
34
+ rootFiles?: (ctx: Ctx) => Record<string, string>;
29
35
  /** Optional `.env.example` entries — `NAME: "explanatory comment"`. */
30
36
  env?: Record<string, string>;
31
37
  /** Optional `package.json` scripts, merged in. */
package/dist/lib.d.ts CHANGED
@@ -26,6 +26,12 @@ interface FeatureDef {
26
26
  deps: Record<string, string>;
27
27
  /** The files this feature drops in, given the resolved {@link Ctx}. */
28
28
  files: (ctx: Ctx) => Record<string, string>;
29
+ /**
30
+ * Files placed at the PROJECT ROOT — in static mode that's the app root; in
31
+ * dynamic mode it's the monorepo root (not `frontend/`). Use it for things a
32
+ * repo needs at the top, like a CI workflow.
33
+ */
34
+ rootFiles?: (ctx: Ctx) => Record<string, string>;
29
35
  /** Optional `.env.example` entries — `NAME: "explanatory comment"`. */
30
36
  env?: Record<string, string>;
31
37
  /** Optional `package.json` scripts, merged in. */