create-ereo 0.1.32 → 0.1.35

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.
Files changed (3) hide show
  1. package/README.md +25 -14
  2. package/dist/index.js +1926 -33
  3. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -47,14 +47,20 @@ function printHelp() {
47
47
  bunx create-ereo@latest <project-name> [options]
48
48
 
49
49
  \x1B[1mOptions:\x1B[0m
50
- -t, --template <name> Template to use (minimal, default, tailwind)
50
+ -t, --template <name> Template to use (minimal, default, tailwind, tasks)
51
51
  --no-typescript Use JavaScript instead of TypeScript
52
52
  --no-git Skip git initialization
53
53
  --no-install Skip package installation
54
54
  --trace Include @ereo/trace for full-stack observability
55
55
 
56
+ \x1B[1mTemplates:\x1B[0m
57
+ tailwind (default) Landing page with blog, Tailwind CSS styling
58
+ minimal Bare-bones starting point
59
+ tasks Full-stack CRUD app with auth + SQLite database
60
+
56
61
  \x1B[1mExamples:\x1B[0m
57
62
  bunx create-ereo@latest my-app
63
+ bunx create-ereo@latest my-app --template tasks
58
64
  bunx create-ereo@latest my-app --template minimal
59
65
  bunx create-ereo@latest my-app --no-typescript
60
66
  `);
@@ -75,8 +81,8 @@ function parseArgs(args) {
75
81
  process.exit(1);
76
82
  }
77
83
  const tmpl = args[++i];
78
- if (tmpl !== "minimal" && tmpl !== "default" && tmpl !== "tailwind") {
79
- console.error(` \x1B[31m\u2717\x1B[0m Unknown template "${tmpl}". Valid options: minimal, default, tailwind
84
+ if (tmpl !== "minimal" && tmpl !== "default" && tmpl !== "tailwind" && tmpl !== "tasks") {
85
+ console.error(` \x1B[31m\u2717\x1B[0m Unknown template "${tmpl}". Valid options: minimal, default, tailwind, tasks
80
86
  `);
81
87
  process.exit(1);
82
88
  }
@@ -111,25 +117,25 @@ COPY package.json bun.lockb* ./
111
117
  RUN bun install --production --ignore-scripts
112
118
 
113
119
  # ---- Stage 3: Production image ----
114
- FROM oven/bun:1-slim AS runner
120
+ FROM oven/bun:1-alpine AS runner
115
121
  WORKDIR /app
116
122
 
117
123
  ENV NODE_ENV=production
118
124
 
119
125
  # Non-root user for security
120
- RUN groupadd --system --gid 1001 ereo && \\
121
- useradd --system --uid 1001 --gid ereo --no-create-home ereo
126
+ RUN addgroup -S -g 1001 ereo && \\
127
+ adduser -S -u 1001 -G ereo -H ereo
122
128
 
123
129
  # Copy production node_modules
124
130
  COPY --from=deps --chown=ereo:ereo /app/node_modules ./node_modules
125
131
 
126
132
  # Copy build output and runtime files
127
- COPY --from=builder --chown=ereo:ereo /app/.ereo ./.ereo
128
- COPY --from=builder --chown=ereo:ereo /app/app ./app
129
- COPY --from=builder --chown=ereo:ereo /app/public ./public
130
- COPY --from=builder --chown=ereo:ereo /app/package.json ./
131
- COPY --from=builder --chown=ereo:ereo /app/ereo.config.* ./
132
- COPY --from=builder --chown=ereo:ereo /app/tsconfig.* ./
133
+ COPY --from=builder --chown=ereo:ereo /app/.ereo ./.ereo
134
+ COPY --from=builder --chown=ereo:ereo /app/app ./app
135
+ COPY --from=builder --chown=ereo:ereo /app/public ./public
136
+ COPY --from=builder --chown=ereo:ereo /app/package.json ./
137
+ COPY --from=builder --chown=ereo:ereo /app/ereo.config.* ./
138
+ COPY --from=builder --chown=ereo:ereo /app/tsconfig.* ./
133
139
 
134
140
  USER ereo
135
141
 
@@ -318,8 +324,8 @@ export default function HomePage() {
318
324
  <h1><span className="gradient-text">EreoJS</span></h1>
319
325
  <p>A React fullstack framework built on Bun. Fast server-side rendering, file-based routing, and islands architecture.</p>
320
326
  <div className="btn-group">
321
- <a href="https://ereo.dev/docs" className="cta-btn cta-btn-primary">Get Started</a>
322
- <a href="https://github.com/nicholasgriffintn/ereo" className="cta-btn cta-btn-secondary">
327
+ <a href="https://ereojs.github.io/ereoJS/" className="cta-btn cta-btn-primary">Get Started</a>
328
+ <a href="https://github.com/ereoJS/ereoJS" className="cta-btn cta-btn-secondary">
323
329
  <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
324
330
  GitHub
325
331
  </a>
@@ -392,7 +398,7 @@ export default function HomePage() {
392
398
 
393
399
  {/* Footer */}
394
400
  <footer className="site-footer">
395
- <p>Built with EreoJS &mdash; <a href="https://ereo.dev/docs">Docs</a> &middot; <a href="https://github.com/nicholasgriffintn/ereo">GitHub</a></p>
401
+ <p>Built with EreoJS &mdash; <a href="https://ereojs.github.io/ereoJS/">Docs</a> &middot; <a href="https://github.com/ereoJS/ereoJS">GitHub</a></p>
396
402
  </footer>
397
403
  </>
398
404
  );
@@ -453,40 +459,44 @@ async function generateTailwindProject(projectDir, projectName, typescript, trac
453
459
  "@ereo/data": EREO_VERSION,
454
460
  "@ereo/cli": EREO_VERSION,
455
461
  "@ereo/runtime-bun": EREO_VERSION,
456
- "@ereo/plugin-tailwind": EREO_VERSION,
457
462
  ...trace ? { "@ereo/trace": EREO_VERSION } : {},
458
463
  react: "^18.2.0",
459
464
  "react-dom": "^18.2.0"
460
465
  },
461
466
  devDependencies: {
467
+ "@ereo/bundler": EREO_VERSION,
462
468
  "@ereo/testing": EREO_VERSION,
463
469
  "@ereo/dev-inspector": EREO_VERSION,
470
+ "@ereo/plugin-tailwind": EREO_VERSION,
471
+ tailwindcss: "^3.4.0",
464
472
  ...ts ? {
465
473
  "@types/bun": "^1.1.0",
466
474
  "@types/react": "^18.2.0",
467
475
  "@types/react-dom": "^18.2.0",
468
476
  typescript: "^5.4.0"
469
- } : {},
470
- tailwindcss: "^3.4.0"
477
+ } : {}
471
478
  }
472
479
  };
473
480
  await Bun.write(join(projectDir, "package.json"), JSON.stringify(packageJson, null, 2));
474
481
  const ereoConfig = `
475
482
  import { defineConfig } from '@ereo/core';
476
- import tailwind from '@ereo/plugin-tailwind';
483
+
484
+ const plugins = [];
485
+
486
+ // Tailwind is a dev/build dependency \u2014 skip in production
487
+ if (process.env.NODE_ENV !== 'production') {
488
+ const { default: tailwind } = await import('@ereo/plugin-tailwind');
489
+ plugins.push(tailwind());
490
+ }
477
491
 
478
492
  export default defineConfig({
479
493
  server: {
480
494
  port: 3000,
481
- // Enable development features
482
- development: process.env.NODE_ENV !== 'production',
483
495
  },
484
496
  build: {
485
497
  target: 'bun',
486
498
  },
487
- plugins: [
488
- tailwind(),
489
- ],
499
+ plugins,
490
500
  });
491
501
  `.trim();
492
502
  await Bun.write(join(projectDir, `ereo.config.${ts ? "ts" : "js"}`), ereoConfig);
@@ -980,10 +990,10 @@ export function Footer() {
980
990
  <span>Built with EreoJS</span>
981
991
  </div>
982
992
  <div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-500">
983
- <a href="https://github.com/ereo-js/ereo" target="_blank" rel="noopener" className="hover:text-primary-600">
993
+ <a href="https://github.com/ereoJS/ereoJS" target="_blank" rel="noopener" className="hover:text-primary-600">
984
994
  GitHub
985
995
  </a>
986
- <a href="https://ereo.dev/docs" target="_blank" rel="noopener" className="hover:text-primary-600">
996
+ <a href="https://ereojs.github.io/ereoJS/" target="_blank" rel="noopener" className="hover:text-primary-600">
987
997
  Documentation
988
998
  </a>
989
999
  <span>&copy; {currentYear}</span>
@@ -1157,7 +1167,7 @@ export default function HomePage({ loaderData }${ts ? ": HomePageProps" : ""}) {
1157
1167
 
1158
1168
  {/* CTA Buttons */}
1159
1169
  <div className="flex flex-wrap gap-4 justify-center mt-10 opacity-0 animate-slide-up delay-400">
1160
- <a href="https://ereo.dev/docs" className="btn btn-primary text-base px-6 py-3">
1170
+ <a href="https://ereojs.github.io/ereoJS/" className="btn btn-primary text-base px-6 py-3">
1161
1171
  Get Started
1162
1172
  </a>
1163
1173
  <a href="/blog" className="btn btn-secondary text-base px-6 py-3">
@@ -1333,8 +1343,8 @@ export async function action({ request }) {
1333
1343
  </div>
1334
1344
  </div>
1335
1345
  <div className="flex flex-wrap gap-4 justify-center">
1336
- <a href="https://ereo.dev/docs" className="btn btn-primary text-base px-6 py-3">Documentation</a>
1337
- <a href="https://github.com/ereo-js/ereo" target="_blank" rel="noopener" className="btn btn-secondary text-base px-6 py-3">GitHub</a>
1346
+ <a href="https://ereojs.github.io/ereoJS/" className="btn btn-primary text-base px-6 py-3">Documentation</a>
1347
+ <a href="https://github.com/ereoJS/ereoJS" target="_blank" rel="noopener" className="btn btn-secondary text-base px-6 py-3">GitHub</a>
1338
1348
  </div>
1339
1349
  </section>
1340
1350
  </div>
@@ -1734,7 +1744,7 @@ export default function AboutPage() {
1734
1744
  </p>
1735
1745
  <div className="flex flex-wrap gap-4">
1736
1746
  <a
1737
- href="https://ereo.dev/docs"
1747
+ href="https://ereojs.github.io/ereoJS/"
1738
1748
  target="_blank"
1739
1749
  rel="noopener"
1740
1750
  className="btn btn-primary"
@@ -1742,7 +1752,7 @@ export default function AboutPage() {
1742
1752
  Documentation
1743
1753
  </a>
1744
1754
  <a
1745
- href="https://github.com/ereo-js/ereo"
1755
+ href="https://github.com/ereoJS/ereoJS"
1746
1756
  target="_blank"
1747
1757
  rel="noopener"
1748
1758
  className="btn btn-secondary"
@@ -1834,7 +1844,7 @@ NODE_ENV=development
1834
1844
  # API_KEY=`);
1835
1845
  const readme = `# ${projectName}
1836
1846
 
1837
- A modern web application built with [EreoJS](https://github.com/ereo-js/ereo) - a React fullstack framework powered by Bun.
1847
+ A modern web application built with [EreoJS](https://github.com/ereoJS/ereoJS) - a React fullstack framework powered by Bun.
1838
1848
 
1839
1849
  ## Features
1840
1850
 
@@ -1910,7 +1920,1888 @@ For production, alias \`@ereo/trace\` to \`@ereo/trace/noop\` in your bundler fo
1910
1920
 
1911
1921
  ` : ""}## Learn More
1912
1922
 
1913
- - [EreoJS Documentation](https://ereo.dev/docs)
1923
+ - [EreoJS Documentation](https://ereojs.github.io/ereoJS/)
1924
+ - [Bun Documentation](https://bun.sh/docs)
1925
+ - [Tailwind CSS](https://tailwindcss.com/docs)
1926
+ `;
1927
+ await Bun.write(join(projectDir, "README.md"), readme);
1928
+ }
1929
+ async function generateTasksProject(projectDir, projectName, typescript, trace = false) {
1930
+ const ext = typescript ? "tsx" : "jsx";
1931
+ const ts = typescript;
1932
+ await mkdir(projectDir, { recursive: true });
1933
+ await mkdir(join(projectDir, "app/routes/(auth)"), { recursive: true });
1934
+ await mkdir(join(projectDir, "app/routes/tasks"), { recursive: true });
1935
+ await mkdir(join(projectDir, "app/components"), { recursive: true });
1936
+ await mkdir(join(projectDir, "app/lib"), { recursive: true });
1937
+ await mkdir(join(projectDir, "public"), { recursive: true });
1938
+ await mkdir(join(projectDir, "data"), { recursive: true });
1939
+ const packageJson = {
1940
+ name: projectName,
1941
+ version: "0.1.0",
1942
+ type: "module",
1943
+ scripts: {
1944
+ dev: trace ? "ereo dev --trace" : "ereo dev",
1945
+ build: "ereo build",
1946
+ start: "ereo start",
1947
+ test: "bun test",
1948
+ typecheck: "tsc --noEmit"
1949
+ },
1950
+ dependencies: {
1951
+ "@ereo/core": EREO_VERSION,
1952
+ "@ereo/router": EREO_VERSION,
1953
+ "@ereo/server": EREO_VERSION,
1954
+ "@ereo/client": EREO_VERSION,
1955
+ "@ereo/data": EREO_VERSION,
1956
+ "@ereo/cli": EREO_VERSION,
1957
+ "@ereo/auth": EREO_VERSION,
1958
+ "@ereo/runtime-bun": EREO_VERSION,
1959
+ ...trace ? { "@ereo/trace": EREO_VERSION } : {},
1960
+ react: "^18.2.0",
1961
+ "react-dom": "^18.2.0"
1962
+ },
1963
+ devDependencies: {
1964
+ "@ereo/bundler": EREO_VERSION,
1965
+ "@ereo/testing": EREO_VERSION,
1966
+ "@ereo/dev-inspector": EREO_VERSION,
1967
+ "@ereo/plugin-tailwind": EREO_VERSION,
1968
+ tailwindcss: "^3.4.0",
1969
+ ...ts ? {
1970
+ "@types/bun": "^1.1.0",
1971
+ "@types/react": "^18.2.0",
1972
+ "@types/react-dom": "^18.2.0",
1973
+ typescript: "^5.4.0"
1974
+ } : {}
1975
+ }
1976
+ };
1977
+ await Bun.write(join(projectDir, "package.json"), JSON.stringify(packageJson, null, 2));
1978
+ const ereoConfig = `
1979
+ import { defineConfig } from '@ereo/core';
1980
+ import { createAuthPlugin, credentials } from '@ereo/auth';
1981
+
1982
+ const plugins${ts ? ": any[]" : ""} = [];
1983
+
1984
+ // Tailwind CSS \u2014 dev/build only
1985
+ if (process.env.NODE_ENV !== 'production') {
1986
+ const { default: tailwind } = await import('@ereo/plugin-tailwind');
1987
+ plugins.push(tailwind());
1988
+ }
1989
+
1990
+ // Database & auth helpers
1991
+ const { findUserByEmail, verifyPassword } = await import('./app/lib/db${ts ? "" : ".js"}');
1992
+
1993
+ // Authentication plugin \u2014 email/password with JWT sessions
1994
+ plugins.push(
1995
+ createAuthPlugin({
1996
+ session: {
1997
+ strategy: 'jwt',
1998
+ secret: process.env.AUTH_SECRET || 'dev-only-please-change-in-production',
1999
+ maxAge: 7 * 24 * 60 * 60, // 7 days
2000
+ },
2001
+ providers: [
2002
+ credentials({
2003
+ authorize: async (creds${ts ? ": Record<string, unknown>" : ""}) => {
2004
+ const email = creds.email as string;
2005
+ const password = creds.password as string;
2006
+ if (!email || !password) return null;
2007
+
2008
+ const user = findUserByEmail(email);
2009
+ if (!user) return null;
2010
+
2011
+ const valid = await verifyPassword(password, user.password_hash);
2012
+ if (!valid) return null;
2013
+
2014
+ return { id: String(user.id), email: user.email, name: user.name };
2015
+ },
2016
+ }),
2017
+ ],
2018
+ cookie: {
2019
+ name: 'ereo.session',
2020
+ secure: process.env.NODE_ENV === 'production',
2021
+ httpOnly: true,
2022
+ sameSite: 'lax',
2023
+ },
2024
+ })
2025
+ );
2026
+
2027
+ export default defineConfig({
2028
+ server: {
2029
+ port: 3000,
2030
+ },
2031
+ build: {
2032
+ target: 'bun',
2033
+ },
2034
+ plugins,
2035
+ });
2036
+ `.trim();
2037
+ await Bun.write(join(projectDir, `ereo.config.${ts ? "ts" : "js"}`), ereoConfig);
2038
+ if (ts) {
2039
+ const tsconfig = {
2040
+ compilerOptions: {
2041
+ target: "ESNext",
2042
+ module: "ESNext",
2043
+ moduleResolution: "bundler",
2044
+ jsx: "react-jsx",
2045
+ strict: true,
2046
+ esModuleInterop: true,
2047
+ skipLibCheck: true,
2048
+ forceConsistentCasingInFileNames: true,
2049
+ types: ["bun-types"],
2050
+ paths: {
2051
+ "~/*": ["./app/*"]
2052
+ }
2053
+ },
2054
+ include: ["app/**/*", "*.config.ts"]
2055
+ };
2056
+ await Bun.write(join(projectDir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2));
2057
+ }
2058
+ const tailwindConfig = `
2059
+ /** @type {import('tailwindcss').Config} */
2060
+ export default {
2061
+ content: ['./app/**/*.{js,ts,jsx,tsx}'],
2062
+ darkMode: 'class',
2063
+ theme: {
2064
+ extend: {
2065
+ fontFamily: {
2066
+ sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
2067
+ mono: ['ui-monospace', 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', 'monospace'],
2068
+ },
2069
+ colors: {
2070
+ primary: {
2071
+ 50: '#eef2ff',
2072
+ 100: '#e0e7ff',
2073
+ 200: '#c7d2fe',
2074
+ 300: '#a5b4fc',
2075
+ 400: '#818cf8',
2076
+ 500: '#6366f1',
2077
+ 600: '#4f46e5',
2078
+ 700: '#4338ca',
2079
+ 800: '#3730a3',
2080
+ 900: '#312e81',
2081
+ 950: '#1e1b4b',
2082
+ },
2083
+ },
2084
+ animation: {
2085
+ 'fade-in': 'fadeIn 0.5s ease forwards',
2086
+ 'slide-up': 'slideUp 0.6s ease forwards',
2087
+ },
2088
+ keyframes: {
2089
+ fadeIn: {
2090
+ '0%': { opacity: '0' },
2091
+ '100%': { opacity: '1' },
2092
+ },
2093
+ slideUp: {
2094
+ '0%': { opacity: '0', transform: 'translateY(20px)' },
2095
+ '100%': { opacity: '1', transform: 'translateY(0)' },
2096
+ },
2097
+ },
2098
+ },
2099
+ },
2100
+ plugins: [],
2101
+ };
2102
+ `.trim();
2103
+ await Bun.write(join(projectDir, "tailwind.config.js"), tailwindConfig);
2104
+ const styles = `
2105
+ @tailwind base;
2106
+ @tailwind components;
2107
+ @tailwind utilities;
2108
+
2109
+ @layer base {
2110
+ body {
2111
+ @apply antialiased font-sans;
2112
+ }
2113
+ }
2114
+
2115
+ @layer components {
2116
+ .btn {
2117
+ @apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 inline-flex items-center justify-center gap-2;
2118
+ }
2119
+ .btn-primary {
2120
+ @apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
2121
+ }
2122
+ .btn-secondary {
2123
+ @apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600;
2124
+ }
2125
+ .btn-danger {
2126
+ @apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
2127
+ }
2128
+ .btn-ghost {
2129
+ @apply bg-transparent text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 focus:ring-gray-500;
2130
+ }
2131
+ .btn-sm {
2132
+ @apply px-3 py-1.5 text-sm;
2133
+ }
2134
+ .input {
2135
+ @apply w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white;
2136
+ }
2137
+ .label {
2138
+ @apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5;
2139
+ }
2140
+ .card {
2141
+ @apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6;
2142
+ }
2143
+ .badge {
2144
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
2145
+ }
2146
+ }
2147
+
2148
+ @layer utilities {
2149
+ .delay-100 { animation-delay: 100ms; }
2150
+ .delay-200 { animation-delay: 200ms; }
2151
+ .delay-300 { animation-delay: 300ms; }
2152
+ }
2153
+ `.trim();
2154
+ await Bun.write(join(projectDir, "app/styles.css"), styles);
2155
+ const dbModule = `
2156
+ import { Database } from 'bun:sqlite';
2157
+ import { join } from 'node:path';
2158
+
2159
+ ${ts ? `export interface User {
2160
+ id: number;
2161
+ email: string;
2162
+ name: string;
2163
+ password_hash: string;
2164
+ created_at: string;
2165
+ }
2166
+
2167
+ export interface Task {
2168
+ id: number;
2169
+ user_id: number;
2170
+ title: string;
2171
+ description: string;
2172
+ status: 'todo' | 'in_progress' | 'done';
2173
+ priority: 'low' | 'medium' | 'high';
2174
+ created_at: string;
2175
+ updated_at: string;
2176
+ }
2177
+
2178
+ export interface TaskStats {
2179
+ todo: number;
2180
+ in_progress: number;
2181
+ done: number;
2182
+ total: number;
2183
+ }
2184
+ ` : ""}
2185
+ /**
2186
+ * SQLite database with production-ready configuration.
2187
+ *
2188
+ * The database file is stored in the /data directory so it can be
2189
+ * easily mounted as a Docker volume for persistence.
2190
+ */
2191
+ const DB_PATH = join(import.meta.dir, '../../data/app.db');
2192
+
2193
+ const db = new Database(DB_PATH, { create: true });
2194
+
2195
+ // Production-ready PRAGMA settings
2196
+ db.exec('PRAGMA journal_mode = WAL'); // Better concurrent reads
2197
+ db.exec('PRAGMA synchronous = NORMAL'); // Safe with WAL, much faster
2198
+ db.exec('PRAGMA foreign_keys = ON'); // Enforce referential integrity
2199
+ db.exec('PRAGMA cache_size = -64000'); // ~64MB cache
2200
+ db.exec('PRAGMA busy_timeout = 5000'); // Wait 5s on lock contention
2201
+
2202
+ // \u2500\u2500 Schema migrations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2203
+ db.exec(\`
2204
+ CREATE TABLE IF NOT EXISTS users (
2205
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2206
+ email TEXT UNIQUE NOT NULL,
2207
+ name TEXT NOT NULL,
2208
+ password_hash TEXT NOT NULL,
2209
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2210
+ );
2211
+
2212
+ CREATE TABLE IF NOT EXISTS tasks (
2213
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2214
+ user_id INTEGER NOT NULL,
2215
+ title TEXT NOT NULL,
2216
+ description TEXT NOT NULL DEFAULT '',
2217
+ status TEXT NOT NULL DEFAULT 'todo'
2218
+ CHECK (status IN ('todo', 'in_progress', 'done')),
2219
+ priority TEXT NOT NULL DEFAULT 'medium'
2220
+ CHECK (priority IN ('low', 'medium', 'high')),
2221
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2222
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
2223
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
2224
+ );
2225
+
2226
+ CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
2227
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(user_id, status);
2228
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
2229
+ \`);
2230
+
2231
+ // \u2500\u2500 User operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2232
+
2233
+ export function findUserByEmail(email${ts ? ": string" : ""})${ts ? ": User | null" : ""} {
2234
+ return db.prepare('SELECT * FROM users WHERE email = ?').get(email)${ts ? " as User | null" : ""};
2235
+ }
2236
+
2237
+ export function findUserById(id${ts ? ": number" : ""})${ts ? ": User | null" : ""} {
2238
+ return db.prepare('SELECT * FROM users WHERE id = ?').get(id)${ts ? " as User | null" : ""};
2239
+ }
2240
+
2241
+ export function createUser(email${ts ? ": string" : ""}, name${ts ? ": string" : ""}, passwordHash${ts ? ": string" : ""})${ts ? ": User" : ""} {
2242
+ return db.prepare(
2243
+ 'INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?) RETURNING *'
2244
+ ).get(email, name, passwordHash)${ts ? " as User" : ""};
2245
+ }
2246
+
2247
+ export function emailExists(email${ts ? ": string" : ""})${ts ? ": boolean" : ""} {
2248
+ const row = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email);
2249
+ return row !== null;
2250
+ }
2251
+
2252
+ // \u2500\u2500 Task operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2253
+
2254
+ export function getTasksByUser(
2255
+ userId${ts ? ": number" : ""},
2256
+ status${ts ? "?: string" : ""}
2257
+ )${ts ? ": Task[]" : ""} {
2258
+ if (status && status !== 'all') {
2259
+ return db.prepare(
2260
+ \`SELECT * FROM tasks WHERE user_id = ? AND status = ?
2261
+ ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
2262
+ created_at DESC\`
2263
+ ).all(userId, status)${ts ? " as Task[]" : ""};
2264
+ }
2265
+ return db.prepare(
2266
+ \`SELECT * FROM tasks WHERE user_id = ?
2267
+ ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
2268
+ created_at DESC\`
2269
+ ).all(userId)${ts ? " as Task[]" : ""};
2270
+ }
2271
+
2272
+ export function getTaskById(id${ts ? ": number" : ""}, userId${ts ? ": number" : ""})${ts ? ": Task | null" : ""} {
2273
+ return db.prepare(
2274
+ 'SELECT * FROM tasks WHERE id = ? AND user_id = ?'
2275
+ ).get(id, userId)${ts ? " as Task | null" : ""};
2276
+ }
2277
+
2278
+ export function createTask(
2279
+ userId${ts ? ": number" : ""},
2280
+ title${ts ? ": string" : ""},
2281
+ description${ts ? ": string" : ""},
2282
+ status${ts ? ": string" : ""},
2283
+ priority${ts ? ": string" : ""}
2284
+ )${ts ? ": Task" : ""} {
2285
+ return db.prepare(
2286
+ 'INSERT INTO tasks (user_id, title, description, status, priority) VALUES (?, ?, ?, ?, ?) RETURNING *'
2287
+ ).get(userId, title, description, status, priority)${ts ? " as Task" : ""};
2288
+ }
2289
+
2290
+ export function updateTask(
2291
+ id${ts ? ": number" : ""},
2292
+ userId${ts ? ": number" : ""},
2293
+ title${ts ? ": string" : ""},
2294
+ description${ts ? ": string" : ""},
2295
+ status${ts ? ": string" : ""},
2296
+ priority${ts ? ": string" : ""}
2297
+ )${ts ? ": Task | null" : ""} {
2298
+ return db.prepare(
2299
+ "UPDATE tasks SET title = ?, description = ?, status = ?, priority = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ? RETURNING *"
2300
+ ).get(title, description, status, priority, id, userId)${ts ? " as Task | null" : ""};
2301
+ }
2302
+
2303
+ export function deleteTask(id${ts ? ": number" : ""}, userId${ts ? ": number" : ""})${ts ? ": boolean" : ""} {
2304
+ const result = db.prepare('DELETE FROM tasks WHERE id = ? AND user_id = ?').run(id, userId);
2305
+ return result.changes > 0;
2306
+ }
2307
+
2308
+ export function getTaskStats(userId${ts ? ": number" : ""})${ts ? ": TaskStats" : ""} {
2309
+ const rows = db.prepare(
2310
+ 'SELECT status, COUNT(*) as count FROM tasks WHERE user_id = ? GROUP BY status'
2311
+ ).all(userId)${ts ? " as { status: string; count: number }[]" : ""};
2312
+
2313
+ const stats${ts ? ": TaskStats" : ""} = { todo: 0, in_progress: 0, done: 0, total: 0 };
2314
+ for (const row of rows) {
2315
+ stats[row.status${ts ? " as keyof TaskStats" : ""}] = row.count;
2316
+ stats.total += row.count;
2317
+ }
2318
+ return stats;
2319
+ }
2320
+
2321
+ // \u2500\u2500 Password hashing \u2014 Bun built-in argon2id \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2322
+
2323
+ export async function hashPassword(password${ts ? ": string" : ""})${ts ? ": Promise<string>" : ""} {
2324
+ return Bun.password.hash(password, { algorithm: 'argon2id' });
2325
+ }
2326
+
2327
+ export async function verifyPassword(password${ts ? ": string" : ""}, hash${ts ? ": string" : ""})${ts ? ": Promise<boolean>" : ""} {
2328
+ return Bun.password.verify(password, hash);
2329
+ }
2330
+
2331
+ export default db;
2332
+ `.trim();
2333
+ await Bun.write(join(projectDir, `app/lib/db.${ts ? "ts" : "js"}`), dbModule);
2334
+ if (ts) {
2335
+ const types = `
2336
+ /**
2337
+ * Shared types for the application.
2338
+ */
2339
+
2340
+ export type { User, Task, TaskStats } from './db';
2341
+
2342
+ export interface ActionResult<T = unknown> {
2343
+ success: boolean;
2344
+ data?: T;
2345
+ error?: string;
2346
+ errors?: Record<string, string>;
2347
+ }
2348
+ `.trim();
2349
+ await Bun.write(join(projectDir, "app/lib/types.ts"), types);
2350
+ }
2351
+ const navigation = `
2352
+ ${ts ? `interface NavigationProps {
2353
+ user?: { name: string; email: string } | null;
2354
+ }
2355
+ ` : ""}
2356
+ export function Navigation({ user }${ts ? ": NavigationProps" : ""}) {
2357
+ return (
2358
+ <nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-50">
2359
+ <div className="max-w-5xl mx-auto px-4">
2360
+ <div className="flex items-center justify-between h-16">
2361
+ {/* Logo */}
2362
+ <a href={user ? '/tasks' : '/'} className="flex items-center gap-2 group">
2363
+ <svg width="28" height="28" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg" className="transition-transform group-hover:scale-110">
2364
+ <path d="M40 8L72 24V56L40 72L8 56V24L40 8Z" stroke="url(#nav-grad)" strokeWidth="3" fill="none" />
2365
+ <path d="M40 28L52 34V46L40 52L28 46V34L40 28Z" fill="url(#nav-grad)" />
2366
+ <defs>
2367
+ <linearGradient id="nav-grad" x1="8" y1="8" x2="72" y2="72">
2368
+ <stop stopColor="#6366f1" />
2369
+ <stop offset="1" stopColor="#a855f7" />
2370
+ </linearGradient>
2371
+ </defs>
2372
+ </svg>
2373
+ <span className="font-bold text-xl">${projectName}</span>
2374
+ </a>
2375
+
2376
+ {/* Desktop */}
2377
+ <div className="hidden md:flex items-center gap-4">
2378
+ {user ? (
2379
+ <>
2380
+ <a href="/tasks" className="text-gray-600 dark:text-gray-300 hover:text-primary-600 transition-colors">
2381
+ Tasks
2382
+ </a>
2383
+ <a href="/tasks/new" className="btn btn-primary btn-sm">
2384
+ New Task
2385
+ </a>
2386
+ <div className="relative group">
2387
+ <button className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
2388
+ <div className="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center text-primary-700 dark:text-primary-300 font-medium text-sm">
2389
+ {user.name.charAt(0).toUpperCase()}
2390
+ </div>
2391
+ <span className="text-sm text-gray-700 dark:text-gray-300">{user.name}</span>
2392
+ </button>
2393
+ <div className="absolute right-0 mt-0 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150">
2394
+ <div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
2395
+ <p className="text-sm font-medium text-gray-900 dark:text-white">{user.name}</p>
2396
+ <p className="text-xs text-gray-500 dark:text-gray-400">{user.email}</p>
2397
+ </div>
2398
+ <form method="POST" action="/logout">
2399
+ <button
2400
+ type="submit"
2401
+ className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-50 dark:hover:bg-gray-700"
2402
+ >
2403
+ Sign out
2404
+ </button>
2405
+ </form>
2406
+ </div>
2407
+ </div>
2408
+ </>
2409
+ ) : (
2410
+ <>
2411
+ <a href="/login" className="text-gray-600 dark:text-gray-300 hover:text-primary-600 transition-colors">
2412
+ Sign in
2413
+ </a>
2414
+ <a href="/register" className="btn btn-primary btn-sm">
2415
+ Get Started
2416
+ </a>
2417
+ </>
2418
+ )}
2419
+ </div>
2420
+
2421
+ {/* Mobile */}
2422
+ <div className="md:hidden flex items-center gap-3">
2423
+ {user ? (
2424
+ <>
2425
+ <a href="/tasks/new" className="btn btn-primary btn-sm">New</a>
2426
+ <form method="POST" action="/logout">
2427
+ <button type="submit" className="text-sm text-red-600 hover:text-red-700">Sign out</button>
2428
+ </form>
2429
+ </>
2430
+ ) : (
2431
+ <>
2432
+ <a href="/login" className="text-gray-600 dark:text-gray-300 hover:text-primary-600 transition-colors">
2433
+ Sign in
2434
+ </a>
2435
+ <a href="/register" className="btn btn-primary btn-sm">
2436
+ Get Started
2437
+ </a>
2438
+ </>
2439
+ )}
2440
+ </div>
2441
+ </div>
2442
+ </div>
2443
+ </nav>
2444
+ );
2445
+ }
2446
+ `.trim();
2447
+ await Bun.write(join(projectDir, `app/components/Navigation.${ext}`), navigation);
2448
+ const footer = `
2449
+ export function Footer() {
2450
+ const currentYear = new Date().getFullYear();
2451
+
2452
+ return (
2453
+ <footer className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 mt-auto">
2454
+ <div className="max-w-5xl mx-auto px-4 py-6">
2455
+ <div className="flex flex-col md:flex-row items-center justify-between gap-4">
2456
+ <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm">
2457
+ <span className="text-lg">&#x2B21;</span>
2458
+ <span>Built with EreoJS</span>
2459
+ </div>
2460
+ <div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-500">
2461
+ <a href="https://ereojs.github.io/ereoJS/" target="_blank" rel="noopener" className="hover:text-primary-600 transition-colors">
2462
+ Docs
2463
+ </a>
2464
+ <a href="https://github.com/ereoJS/ereoJS" target="_blank" rel="noopener" className="hover:text-primary-600 transition-colors">
2465
+ GitHub
2466
+ </a>
2467
+ <span>&copy; {currentYear}</span>
2468
+ </div>
2469
+ </div>
2470
+ </div>
2471
+ </footer>
2472
+ );
2473
+ }
2474
+ `.trim();
2475
+ await Bun.write(join(projectDir, `app/components/Footer.${ext}`), footer);
2476
+ const taskCard = `
2477
+ ${ts ? `interface TaskCardProps {
2478
+ task: {
2479
+ id: number;
2480
+ title: string;
2481
+ description: string;
2482
+ status: 'todo' | 'in_progress' | 'done';
2483
+ priority: 'low' | 'medium' | 'high';
2484
+ created_at: string;
2485
+ updated_at: string;
2486
+ };
2487
+ }
2488
+ ` : ""}
2489
+ const statusConfig${ts ? ": Record<string, { label: string; color: string }>" : ""} = {
2490
+ todo: { label: 'To Do', color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' },
2491
+ in_progress: { label: 'In Progress', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' },
2492
+ done: { label: 'Done', color: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' },
2493
+ };
2494
+
2495
+ const priorityConfig${ts ? ": Record<string, { label: string; color: string; icon: string }>" : ""} = {
2496
+ high: { label: 'High', color: 'text-red-600 dark:text-red-400', icon: '!!!' },
2497
+ medium: { label: 'Medium', color: 'text-yellow-600 dark:text-yellow-400', icon: '!!' },
2498
+ low: { label: 'Low', color: 'text-gray-400 dark:text-gray-500', icon: '!' },
2499
+ };
2500
+
2501
+ export function TaskCard({ task }${ts ? ": TaskCardProps" : ""}) {
2502
+ const status = statusConfig[task.status] || statusConfig.todo;
2503
+ const priority = priorityConfig[task.priority] || priorityConfig.medium;
2504
+
2505
+ return (
2506
+ <a
2507
+ href={\`/tasks/\${task.id}\`}
2508
+ className="card hover:shadow-md hover:border-primary-300 dark:hover:border-primary-700 transition-all group block"
2509
+ >
2510
+ <div className="flex items-start justify-between gap-4">
2511
+ <div className="flex-1 min-w-0">
2512
+ <div className="flex items-center gap-2 mb-1">
2513
+ <span className={\`badge \${status.color}\`}>{status.label}</span>
2514
+ <span className={\`text-xs font-mono font-bold \${priority.color}\`}>
2515
+ {priority.icon}
2516
+ </span>
2517
+ </div>
2518
+ <h3 className="font-semibold text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors truncate">
2519
+ {task.title}
2520
+ </h3>
2521
+ {task.description && (
2522
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
2523
+ {task.description}
2524
+ </p>
2525
+ )}
2526
+ </div>
2527
+ <svg className="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2528
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
2529
+ </svg>
2530
+ </div>
2531
+ <div className="mt-3 flex items-center gap-4 text-xs text-gray-400 dark:text-gray-500">
2532
+ <span>Created {new Date(task.created_at).toLocaleDateString()}</span>
2533
+ {task.updated_at !== task.created_at && (
2534
+ <span>Updated {new Date(task.updated_at).toLocaleDateString()}</span>
2535
+ )}
2536
+ </div>
2537
+ </a>
2538
+ );
2539
+ }
2540
+ `.trim();
2541
+ await Bun.write(join(projectDir, `app/components/TaskCard.${ext}`), taskCard);
2542
+ const rootLayout = `
2543
+ import { Navigation } from '~/components/Navigation';
2544
+ import { Footer } from '~/components/Footer';
2545
+ import { useAuth } from '@ereo/auth';
2546
+
2547
+ ${ts ? `interface RootLayoutProps {
2548
+ children: React.ReactNode;
2549
+ context: any;
2550
+ }
2551
+ ` : ""}
2552
+ export async function loader({ context }${ts ? ": { context: any }" : ""}) {
2553
+ let user = null;
2554
+ try {
2555
+ const auth = useAuth(context);
2556
+ if (auth.isAuthenticated()) {
2557
+ user = auth.getUser();
2558
+ }
2559
+ } catch {
2560
+ // Not authenticated
2561
+ }
2562
+ return { user };
2563
+ }
2564
+
2565
+ export default function RootLayout({ children, loaderData }${ts ? ": RootLayoutProps & { loaderData: { user: any } }" : ""}) {
2566
+ return (
2567
+ <html lang="en">
2568
+ <head>
2569
+ <meta charSet="utf-8" />
2570
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2571
+ <meta name="description" content="${projectName} \u2014 A task management app built with EreoJS" />
2572
+ <title>${projectName}</title>
2573
+ <link rel="stylesheet" href="/__tailwind.css" />
2574
+ </head>
2575
+ <body className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
2576
+ <Navigation user={loaderData?.user} />
2577
+ <main className="flex-1">
2578
+ {children}
2579
+ </main>
2580
+ <Footer />
2581
+ </body>
2582
+ </html>
2583
+ );
2584
+ }
2585
+ `.trim();
2586
+ await Bun.write(join(projectDir, `app/routes/_layout.${ext}`), rootLayout);
2587
+ const indexPage = `
2588
+ import { useAuth } from '@ereo/auth';
2589
+
2590
+ export async function loader({ context }${ts ? ": { context: any }" : ""}) {
2591
+ try {
2592
+ const auth = useAuth(context);
2593
+ if (auth.isAuthenticated()) {
2594
+ return new Response(null, {
2595
+ status: 302,
2596
+ headers: { Location: '/tasks' },
2597
+ });
2598
+ }
2599
+ } catch {
2600
+ // Not authenticated \u2014 show landing page
2601
+ }
2602
+ return {};
2603
+ }
2604
+
2605
+ export default function LandingPage() {
2606
+ return (
2607
+ <div>
2608
+ {/* Hero Section */}
2609
+ <section className="relative py-24 sm:py-32 px-4 overflow-hidden">
2610
+ <div className="absolute inset-0 bg-gradient-to-b from-primary-50/50 via-transparent to-transparent dark:from-primary-950/20" />
2611
+ <div className="relative max-w-4xl mx-auto text-center">
2612
+ <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 text-sm font-medium mb-6 opacity-0 animate-slide-up">
2613
+ Built with EreoJS + SQLite
2614
+ </div>
2615
+
2616
+ <h1 className="text-5xl sm:text-6xl font-extrabold tracking-tight mb-6 opacity-0 animate-slide-up delay-100">
2617
+ Organize your work,{' '}
2618
+ <span className="bg-clip-text text-transparent bg-gradient-to-r from-primary-500 to-purple-500">
2619
+ ship faster
2620
+ </span>
2621
+ </h1>
2622
+
2623
+ <p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto mb-10 opacity-0 animate-slide-up delay-200">
2624
+ A full-stack task manager showcasing authentication, SQLite database,
2625
+ and CRUD operations \u2014 all powered by EreoJS and Bun.
2626
+ </p>
2627
+
2628
+ <div className="flex flex-wrap gap-4 justify-center opacity-0 animate-slide-up delay-300">
2629
+ <a href="/register" className="btn btn-primary text-base px-6 py-3">
2630
+ Get Started Free
2631
+ </a>
2632
+ <a href="/login" className="btn btn-secondary text-base px-6 py-3">
2633
+ Sign In
2634
+ </a>
2635
+ </div>
2636
+ </div>
2637
+ </section>
2638
+
2639
+ {/* Features */}
2640
+ <section className="py-20 px-4">
2641
+ <div className="max-w-5xl mx-auto">
2642
+ <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
2643
+ {[
2644
+ {
2645
+ title: 'Email & Password Auth',
2646
+ desc: 'Secure authentication with argon2id password hashing, JWT sessions, and protected routes.',
2647
+ icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
2648
+ },
2649
+ {
2650
+ title: 'SQLite + WAL Mode',
2651
+ desc: 'Production-ready SQLite with Write-Ahead Logging, prepared statements, and automatic migrations.',
2652
+ icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4',
2653
+ },
2654
+ {
2655
+ title: 'Full CRUD Operations',
2656
+ desc: 'Create, read, update, and delete tasks with server-side validation and error handling.',
2657
+ icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
2658
+ },
2659
+ {
2660
+ title: 'Server-Side Rendering',
2661
+ desc: 'Fast initial loads with SSR. Data fetched via loaders, mutations via actions.',
2662
+ icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2',
2663
+ },
2664
+ {
2665
+ title: 'File-Based Routing',
2666
+ desc: 'Routes map to files. Dynamic segments, layouts, route groups, and error boundaries.',
2667
+ icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
2668
+ },
2669
+ {
2670
+ title: 'Production Ready',
2671
+ desc: 'Docker support, environment configuration, secure cookies, and proper error handling.',
2672
+ icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
2673
+ },
2674
+ ].map((feature) => (
2675
+ <div key={feature.title} className="card hover:shadow-md transition-shadow">
2676
+ <div className="w-10 h-10 rounded-lg flex items-center justify-center mb-3 bg-primary-100 dark:bg-primary-900/40">
2677
+ <svg className="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
2678
+ <path strokeLinecap="round" strokeLinejoin="round" d={feature.icon} />
2679
+ </svg>
2680
+ </div>
2681
+ <h3 className="font-semibold mb-1">{feature.title}</h3>
2682
+ <p className="text-sm text-gray-600 dark:text-gray-400">{feature.desc}</p>
2683
+ </div>
2684
+ ))}
2685
+ </div>
2686
+ </div>
2687
+ </section>
2688
+
2689
+ {/* Code Preview */}
2690
+ <section className="py-20 px-4 bg-white dark:bg-gray-800/50">
2691
+ <div className="max-w-5xl mx-auto">
2692
+ <div className="text-center mb-12">
2693
+ <h2 className="text-3xl font-bold mb-3">Simple, Powerful Patterns</h2>
2694
+ <p className="text-gray-600 dark:text-gray-400">Loaders fetch data. Actions handle mutations. It just works.</p>
2695
+ </div>
2696
+ <div className="grid md:grid-cols-2 gap-6">
2697
+ <div className="rounded-xl overflow-hidden" style={{ background: '#1e1e2e' }}>
2698
+ <div className="flex items-center gap-1.5 px-4 py-3" style={{ background: 'rgba(255,255,255,0.05)' }}>
2699
+ <span className="w-3 h-3 rounded-full bg-red-500" />
2700
+ <span className="w-3 h-3 rounded-full bg-yellow-500" />
2701
+ <span className="w-3 h-3 rounded-full bg-green-500" />
2702
+ <span className="ml-3 text-xs text-gray-500 font-mono">routes/tasks/index.tsx</span>
2703
+ </div>
2704
+ <pre className="px-5 py-4 font-mono text-sm text-primary-300 leading-relaxed overflow-x-auto">
2705
+ {\`// Server loader \u2014 fetches data
2706
+ export async function loader({ context }) {
2707
+ const auth = useAuth(context);
2708
+ const user = auth.getUser();
2709
+ const tasks = getTasksByUser(user.id);
2710
+ return { tasks };
2711
+ }
2712
+
2713
+ // Component renders with data
2714
+ export default function Tasks({ loaderData }) {
2715
+ return (
2716
+ <div>
2717
+ {loaderData.tasks.map(task => (
2718
+ <TaskCard key={task.id} task={task} />
2719
+ ))}
2720
+ </div>
2721
+ );
2722
+ }\`}
2723
+ </pre>
2724
+ </div>
2725
+ <div className="rounded-xl overflow-hidden" style={{ background: '#1e1e2e' }}>
2726
+ <div className="flex items-center gap-1.5 px-4 py-3" style={{ background: 'rgba(255,255,255,0.05)' }}>
2727
+ <span className="w-3 h-3 rounded-full bg-red-500" />
2728
+ <span className="w-3 h-3 rounded-full bg-yellow-500" />
2729
+ <span className="w-3 h-3 rounded-full bg-green-500" />
2730
+ <span className="ml-3 text-xs text-gray-500 font-mono">routes/tasks/new.tsx</span>
2731
+ </div>
2732
+ <pre className="px-5 py-4 font-mono text-sm text-primary-300 leading-relaxed overflow-x-auto">
2733
+ {\`// Server action \u2014 handles mutations
2734
+ export async function action({ request, context }) {
2735
+ const auth = useAuth(context);
2736
+ const user = auth.getUser();
2737
+ const form = await request.formData();
2738
+
2739
+ const task = createTask(
2740
+ user.id,
2741
+ form.get('title'),
2742
+ form.get('description'),
2743
+ 'todo',
2744
+ form.get('priority')
2745
+ );
2746
+
2747
+ return Response.redirect('/tasks');
2748
+ }\`}
2749
+ </pre>
2750
+ </div>
2751
+ </div>
2752
+ </div>
2753
+ </section>
2754
+
2755
+ {/* CTA */}
2756
+ <section className="py-24 px-4 text-center">
2757
+ <h2 className="text-3xl font-bold mb-4">Ready to get organized?</h2>
2758
+ <p className="text-gray-600 dark:text-gray-400 mb-8">Create your account and start managing tasks in seconds.</p>
2759
+ <a href="/register" className="btn btn-primary text-base px-8 py-3">
2760
+ Create Free Account
2761
+ </a>
2762
+ </section>
2763
+ </div>
2764
+ );
2765
+ }
2766
+ `.trim();
2767
+ await Bun.write(join(projectDir, `app/routes/index.${ext}`), indexPage);
2768
+ const loginPage = `
2769
+ import { useAuth } from '@ereo/auth';
2770
+
2771
+ export async function loader({ context }${ts ? ": { context: any }" : ""}) {
2772
+ try {
2773
+ const auth = useAuth(context);
2774
+ if (auth.isAuthenticated()) {
2775
+ return new Response(null, { status: 302, headers: { Location: '/tasks' } });
2776
+ }
2777
+ } catch {}
2778
+ return {};
2779
+ }
2780
+
2781
+ export async function action({ request, context }${ts ? ": { request: Request; context: any }" : ""}) {
2782
+ const formData = await request.formData();
2783
+ const email = formData.get('email')${ts ? " as string" : ""};
2784
+ const password = formData.get('password')${ts ? " as string" : ""};
2785
+
2786
+ const errors${ts ? ": Record<string, string>" : ""} = {};
2787
+ if (!email) errors.email = 'Email is required';
2788
+ if (!password) errors.password = 'Password is required';
2789
+
2790
+ if (Object.keys(errors).length > 0) {
2791
+ return { success: false, errors };
2792
+ }
2793
+
2794
+ try {
2795
+ const auth = useAuth(context);
2796
+ await auth.signIn('credentials', { email, password });
2797
+
2798
+ return new Response(null, {
2799
+ status: 302,
2800
+ headers: {
2801
+ Location: '/tasks',
2802
+ 'Set-Cookie': auth.getCookieHeader() || '',
2803
+ },
2804
+ });
2805
+ } catch {
2806
+ return { success: false, errors: { form: 'Invalid email or password' } };
2807
+ }
2808
+ }
2809
+
2810
+ ${ts ? `interface LoginPageProps {
2811
+ actionData?: {
2812
+ success: boolean;
2813
+ errors?: Record<string, string>;
2814
+ };
2815
+ }
2816
+ ` : ""}
2817
+ export default function LoginPage({ actionData }${ts ? ": LoginPageProps" : ""}) {
2818
+ return (
2819
+ <div className="min-h-[80vh] flex items-center justify-center px-4">
2820
+ <div className="w-full max-w-md">
2821
+ <div className="text-center mb-8">
2822
+ <h1 className="text-3xl font-bold">Welcome back</h1>
2823
+ <p className="text-gray-600 dark:text-gray-400 mt-2">
2824
+ Sign in to manage your tasks
2825
+ </p>
2826
+ </div>
2827
+
2828
+ <div className="card">
2829
+ {actionData?.errors?.form && (
2830
+ <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
2831
+ <p className="text-sm text-red-700 dark:text-red-300">{actionData.errors.form}</p>
2832
+ </div>
2833
+ )}
2834
+
2835
+ <form method="POST" className="space-y-4">
2836
+ <div>
2837
+ <label htmlFor="email" className="label">Email</label>
2838
+ <input
2839
+ type="email"
2840
+ id="email"
2841
+ name="email"
2842
+ required
2843
+ autoComplete="email"
2844
+ className="input"
2845
+ placeholder="you@example.com"
2846
+ />
2847
+ {actionData?.errors?.email && (
2848
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
2849
+ )}
2850
+ </div>
2851
+
2852
+ <div>
2853
+ <label htmlFor="password" className="label">Password</label>
2854
+ <input
2855
+ type="password"
2856
+ id="password"
2857
+ name="password"
2858
+ required
2859
+ autoComplete="current-password"
2860
+ className="input"
2861
+ placeholder="Your password"
2862
+ />
2863
+ {actionData?.errors?.password && (
2864
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.password}</p>
2865
+ )}
2866
+ </div>
2867
+
2868
+ <button
2869
+ type="submit"
2870
+ className="btn btn-primary w-full py-2.5"
2871
+ >
2872
+ Sign in
2873
+ </button>
2874
+ </form>
2875
+
2876
+ <div className="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
2877
+ Don't have an account?{' '}
2878
+ <a href="/register" className="text-primary-600 hover:underline font-medium">
2879
+ Create one
2880
+ </a>
2881
+ </div>
2882
+ </div>
2883
+ </div>
2884
+ </div>
2885
+ );
2886
+ }
2887
+ `.trim();
2888
+ await Bun.write(join(projectDir, `app/routes/(auth)/login.${ext}`), loginPage);
2889
+ const registerPage = `
2890
+ import { useAuth } from '@ereo/auth';
2891
+ import { emailExists, createUser, hashPassword } from '~/lib/db';
2892
+
2893
+ export async function loader({ context }${ts ? ": { context: any }" : ""}) {
2894
+ try {
2895
+ const auth = useAuth(context);
2896
+ if (auth.isAuthenticated()) {
2897
+ return new Response(null, { status: 302, headers: { Location: '/tasks' } });
2898
+ }
2899
+ } catch {}
2900
+ return {};
2901
+ }
2902
+
2903
+ export async function action({ request, context }${ts ? ": { request: Request; context: any }" : ""}) {
2904
+ const formData = await request.formData();
2905
+ const name = (formData.get('name')${ts ? " as string" : ""} || '').trim();
2906
+ const email = (formData.get('email')${ts ? " as string" : ""} || '').trim().toLowerCase();
2907
+ const password = formData.get('password')${ts ? " as string" : ""} || '';
2908
+ const confirmPassword = formData.get('confirmPassword')${ts ? " as string" : ""} || '';
2909
+
2910
+ // Validate
2911
+ const errors${ts ? ": Record<string, string>" : ""} = {};
2912
+ if (!name || name.length < 2) errors.name = 'Name must be at least 2 characters';
2913
+ if (!email || !email.includes('@')) errors.email = 'Please enter a valid email';
2914
+ if (!password || password.length < 8) errors.password = 'Password must be at least 8 characters';
2915
+ if (password !== confirmPassword) errors.confirmPassword = 'Passwords do not match';
2916
+
2917
+ if (Object.keys(errors).length > 0) {
2918
+ return { success: false, errors };
2919
+ }
2920
+
2921
+ // Check if email already exists
2922
+ if (emailExists(email)) {
2923
+ return { success: false, errors: { email: 'An account with this email already exists' } };
2924
+ }
2925
+
2926
+ // Create user
2927
+ const passwordHash = await hashPassword(password);
2928
+ createUser(email, name, passwordHash);
2929
+
2930
+ // Sign in the new user
2931
+ try {
2932
+ const auth = useAuth(context);
2933
+ await auth.signIn('credentials', { email, password });
2934
+
2935
+ return new Response(null, {
2936
+ status: 302,
2937
+ headers: {
2938
+ Location: '/tasks',
2939
+ 'Set-Cookie': auth.getCookieHeader() || '',
2940
+ },
2941
+ });
2942
+ } catch {
2943
+ // Account created but auto-login failed \u2014 redirect to login
2944
+ return new Response(null, { status: 302, headers: { Location: '/login' } });
2945
+ }
2946
+ }
2947
+
2948
+ ${ts ? `interface RegisterPageProps {
2949
+ actionData?: {
2950
+ success: boolean;
2951
+ errors?: Record<string, string>;
2952
+ };
2953
+ }
2954
+ ` : ""}
2955
+ export default function RegisterPage({ actionData }${ts ? ": RegisterPageProps" : ""}) {
2956
+ return (
2957
+ <div className="min-h-[80vh] flex items-center justify-center px-4">
2958
+ <div className="w-full max-w-md">
2959
+ <div className="text-center mb-8">
2960
+ <h1 className="text-3xl font-bold">Create your account</h1>
2961
+ <p className="text-gray-600 dark:text-gray-400 mt-2">
2962
+ Start organizing your tasks today
2963
+ </p>
2964
+ </div>
2965
+
2966
+ <div className="card">
2967
+ <form method="POST" className="space-y-4">
2968
+ <div>
2969
+ <label htmlFor="name" className="label">Name</label>
2970
+ <input
2971
+ type="text"
2972
+ id="name"
2973
+ name="name"
2974
+ required
2975
+ autoComplete="name"
2976
+ className="input"
2977
+ placeholder="Your name"
2978
+ />
2979
+ {actionData?.errors?.name && (
2980
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
2981
+ )}
2982
+ </div>
2983
+
2984
+ <div>
2985
+ <label htmlFor="email" className="label">Email</label>
2986
+ <input
2987
+ type="email"
2988
+ id="email"
2989
+ name="email"
2990
+ required
2991
+ autoComplete="email"
2992
+ className="input"
2993
+ placeholder="you@example.com"
2994
+ />
2995
+ {actionData?.errors?.email && (
2996
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
2997
+ )}
2998
+ </div>
2999
+
3000
+ <div>
3001
+ <label htmlFor="password" className="label">Password</label>
3002
+ <input
3003
+ type="password"
3004
+ id="password"
3005
+ name="password"
3006
+ required
3007
+ autoComplete="new-password"
3008
+ minLength={8}
3009
+ className="input"
3010
+ placeholder="At least 8 characters"
3011
+ />
3012
+ {actionData?.errors?.password && (
3013
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.password}</p>
3014
+ )}
3015
+ </div>
3016
+
3017
+ <div>
3018
+ <label htmlFor="confirmPassword" className="label">Confirm Password</label>
3019
+ <input
3020
+ type="password"
3021
+ id="confirmPassword"
3022
+ name="confirmPassword"
3023
+ required
3024
+ autoComplete="new-password"
3025
+ className="input"
3026
+ placeholder="Repeat your password"
3027
+ />
3028
+ {actionData?.errors?.confirmPassword && (
3029
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.confirmPassword}</p>
3030
+ )}
3031
+ </div>
3032
+
3033
+ <button
3034
+ type="submit"
3035
+ className="btn btn-primary w-full py-2.5"
3036
+ >
3037
+ Create account
3038
+ </button>
3039
+ </form>
3040
+
3041
+ <div className="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
3042
+ Already have an account?{' '}
3043
+ <a href="/login" className="text-primary-600 hover:underline font-medium">
3044
+ Sign in
3045
+ </a>
3046
+ </div>
3047
+ </div>
3048
+ </div>
3049
+ </div>
3050
+ );
3051
+ }
3052
+ `.trim();
3053
+ await Bun.write(join(projectDir, `app/routes/(auth)/register.${ext}`), registerPage);
3054
+ const logoutRoute = `
3055
+ import { useAuth } from '@ereo/auth';
3056
+
3057
+ export async function action({ context }${ts ? ": { context: any }" : ""}) {
3058
+ try {
3059
+ const auth = useAuth(context);
3060
+ await auth.signOut();
3061
+ } catch {
3062
+ // Already signed out
3063
+ }
3064
+
3065
+ return new Response(null, {
3066
+ status: 302,
3067
+ headers: { Location: '/' },
3068
+ });
3069
+ }
3070
+
3071
+ export default function LogoutPage() {
3072
+ return (
3073
+ <div className="min-h-[80vh] flex items-center justify-center">
3074
+ <p className="text-gray-500">Signing out...</p>
3075
+ </div>
3076
+ );
3077
+ }
3078
+ `.trim();
3079
+ await Bun.write(join(projectDir, `app/routes/logout.${ext}`), logoutRoute);
3080
+ const tasksIndex = `
3081
+ import { useAuth, requireAuth } from '@ereo/auth';
3082
+ import { getTasksByUser, getTaskStats } from '~/lib/db';
3083
+ import { TaskCard } from '~/components/TaskCard';
3084
+
3085
+ export const config = { ...requireAuth({ redirect: '/login' }) };
3086
+
3087
+ export async function loader({ request, context }${ts ? ": { request: Request; context: any }" : ""}) {
3088
+ const auth = useAuth(context);
3089
+ if (!auth.isAuthenticated()) {
3090
+ return new Response(null, { status: 302, headers: { Location: '/login' } });
3091
+ }
3092
+ const user = auth.getUser()${ts ? "!" : ""};
3093
+ const userId = Number(user.id);
3094
+
3095
+ // Read filter from URL search params
3096
+ const url = new URL(request.url);
3097
+ const status = url.searchParams.get('status') || 'all';
3098
+
3099
+ const tasks = getTasksByUser(userId, status);
3100
+ const stats = getTaskStats(userId);
3101
+
3102
+ return { tasks, stats, currentFilter: status };
3103
+ }
3104
+
3105
+ ${ts ? `interface TasksPageProps {
3106
+ loaderData: {
3107
+ tasks: Array<{
3108
+ id: number;
3109
+ title: string;
3110
+ description: string;
3111
+ status: 'todo' | 'in_progress' | 'done';
3112
+ priority: 'low' | 'medium' | 'high';
3113
+ created_at: string;
3114
+ updated_at: string;
3115
+ }>;
3116
+ stats: { todo: number; in_progress: number; done: number; total: number };
3117
+ currentFilter: string;
3118
+ };
3119
+ }
3120
+ ` : ""}
3121
+ export default function TasksPage({ loaderData }${ts ? ": TasksPageProps" : ""}) {
3122
+ const { tasks, stats, currentFilter } = loaderData;
3123
+
3124
+ const filters = [
3125
+ { value: 'all', label: 'All', count: stats.total },
3126
+ { value: 'todo', label: 'To Do', count: stats.todo },
3127
+ { value: 'in_progress', label: 'In Progress', count: stats.in_progress },
3128
+ { value: 'done', label: 'Done', count: stats.done },
3129
+ ];
3130
+
3131
+ return (
3132
+ <div className="max-w-5xl mx-auto px-4 py-8">
3133
+ {/* Header */}
3134
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
3135
+ <div>
3136
+ <h1 className="text-3xl font-bold">Tasks</h1>
3137
+ <p className="text-gray-600 dark:text-gray-400 mt-1">
3138
+ {stats.total === 0
3139
+ ? 'No tasks yet. Create your first one!'
3140
+ : \`\${stats.total} task\${stats.total === 1 ? '' : 's'} total, \${stats.done} completed\`}
3141
+ </p>
3142
+ </div>
3143
+ <a href="/tasks/new" className="btn btn-primary">
3144
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
3145
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
3146
+ </svg>
3147
+ New Task
3148
+ </a>
3149
+ </div>
3150
+
3151
+ {/* Stats Cards */}
3152
+ {stats.total > 0 && (
3153
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
3154
+ <div className="card py-4">
3155
+ <div className="text-sm text-gray-500 dark:text-gray-400">Total</div>
3156
+ <div className="text-3xl font-bold mt-1">{stats.total}</div>
3157
+ </div>
3158
+ <div className="card py-4">
3159
+ <div className="text-sm text-gray-500 dark:text-gray-400">To Do</div>
3160
+ <div className="text-3xl font-bold mt-1 text-gray-600 dark:text-gray-300">{stats.todo}</div>
3161
+ </div>
3162
+ <div className="card py-4">
3163
+ <div className="text-sm text-blue-500">In Progress</div>
3164
+ <div className="text-3xl font-bold mt-1 text-blue-600 dark:text-blue-400">{stats.in_progress}</div>
3165
+ </div>
3166
+ <div className="card py-4">
3167
+ <div className="text-sm text-green-500">Done</div>
3168
+ <div className="text-3xl font-bold mt-1 text-green-600 dark:text-green-400">{stats.done}</div>
3169
+ </div>
3170
+ </div>
3171
+ )}
3172
+
3173
+ {/* Filter Tabs */}
3174
+ {stats.total > 0 && (
3175
+ <div className="flex gap-2 mb-6 overflow-x-auto pb-2">
3176
+ {filters.map((filter) => (
3177
+ <a
3178
+ key={filter.value}
3179
+ href={\`/tasks\${filter.value === 'all' ? '' : \`?status=\${filter.value}\`}\`}
3180
+ className={\`btn btn-sm whitespace-nowrap \${
3181
+ currentFilter === filter.value
3182
+ ? 'btn-primary'
3183
+ : 'btn-ghost'
3184
+ }\`}
3185
+ >
3186
+ {filter.label}
3187
+ <span className={\`ml-1.5 px-1.5 py-0.5 rounded-full text-xs \${
3188
+ currentFilter === filter.value
3189
+ ? 'bg-white/20'
3190
+ : 'bg-gray-200 dark:bg-gray-700'
3191
+ }\`}>
3192
+ {filter.count}
3193
+ </span>
3194
+ </a>
3195
+ ))}
3196
+ </div>
3197
+ )}
3198
+
3199
+ {/* Task List */}
3200
+ {tasks.length > 0 ? (
3201
+ <div className="space-y-3">
3202
+ {tasks.map((task) => (
3203
+ <TaskCard key={task.id} task={task} />
3204
+ ))}
3205
+ </div>
3206
+ ) : stats.total > 0 ? (
3207
+ <div className="card text-center py-12">
3208
+ <p className="text-gray-500 dark:text-gray-400 mb-4">No tasks match the current filter.</p>
3209
+ <a href="/tasks" className="btn btn-secondary btn-sm">Clear filter</a>
3210
+ </div>
3211
+ ) : (
3212
+ <div className="card text-center py-16">
3213
+ <svg className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
3214
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
3215
+ </svg>
3216
+ <h2 className="text-xl font-semibold mb-2">No tasks yet</h2>
3217
+ <p className="text-gray-500 dark:text-gray-400 mb-6">Create your first task to get started.</p>
3218
+ <a href="/tasks/new" className="btn btn-primary">
3219
+ Create your first task
3220
+ </a>
3221
+ </div>
3222
+ )}
3223
+ </div>
3224
+ );
3225
+ }
3226
+ `.trim();
3227
+ await Bun.write(join(projectDir, `app/routes/tasks/index.${ext}`), tasksIndex);
3228
+ const newTaskPage = `
3229
+ import { useAuth, requireAuth } from '@ereo/auth';
3230
+ import { createTask } from '~/lib/db';
3231
+
3232
+ export const config = { ...requireAuth({ redirect: '/login' }) };
3233
+
3234
+ export async function action({ request, context }${ts ? ": { request: Request; context: any }" : ""}) {
3235
+ const auth = useAuth(context);
3236
+ if (!auth.isAuthenticated()) {
3237
+ return new Response(null, { status: 302, headers: { Location: '/login' } });
3238
+ }
3239
+ const user = auth.getUser()${ts ? "!" : ""};
3240
+ const userId = Number(user.id);
3241
+
3242
+ const formData = await request.formData();
3243
+ const title = (formData.get('title')${ts ? " as string" : ""} || '').trim();
3244
+ const description = (formData.get('description')${ts ? " as string" : ""} || '').trim();
3245
+ const priority = formData.get('priority')${ts ? " as string" : ""} || 'medium';
3246
+
3247
+ // Validate
3248
+ const errors${ts ? ": Record<string, string>" : ""} = {};
3249
+ if (!title || title.length < 1) errors.title = 'Title is required';
3250
+ if (title.length > 200) errors.title = 'Title must be under 200 characters';
3251
+ if (!['low', 'medium', 'high'].includes(priority)) errors.priority = 'Invalid priority';
3252
+
3253
+ if (Object.keys(errors).length > 0) {
3254
+ return { success: false, errors };
3255
+ }
3256
+
3257
+ createTask(userId, title, description, 'todo', priority);
3258
+
3259
+ return new Response(null, {
3260
+ status: 302,
3261
+ headers: { Location: '/tasks' },
3262
+ });
3263
+ }
3264
+
3265
+ ${ts ? `interface NewTaskPageProps {
3266
+ actionData?: {
3267
+ success: boolean;
3268
+ errors?: Record<string, string>;
3269
+ };
3270
+ }
3271
+ ` : ""}
3272
+ export default function NewTaskPage({ actionData }${ts ? ": NewTaskPageProps" : ""}) {
3273
+ return (
3274
+ <div className="max-w-2xl mx-auto px-4 py-8">
3275
+ <div className="mb-8">
3276
+ <a href="/tasks" className="text-sm text-gray-500 hover:text-primary-600 transition-colors">
3277
+ &larr; Back to tasks
3278
+ </a>
3279
+ <h1 className="text-3xl font-bold mt-2">New Task</h1>
3280
+ </div>
3281
+
3282
+ <div className="card">
3283
+ <form method="POST" className="space-y-5">
3284
+ <div>
3285
+ <label htmlFor="title" className="label">Title</label>
3286
+ <input
3287
+ type="text"
3288
+ id="title"
3289
+ name="title"
3290
+ required
3291
+ maxLength={200}
3292
+ className="input"
3293
+ placeholder="What needs to be done?"
3294
+ autoFocus
3295
+ />
3296
+ {actionData?.errors?.title && (
3297
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.title}</p>
3298
+ )}
3299
+ </div>
3300
+
3301
+ <div>
3302
+ <label htmlFor="description" className="label">
3303
+ Description <span className="text-gray-400 font-normal">(optional)</span>
3304
+ </label>
3305
+ <textarea
3306
+ id="description"
3307
+ name="description"
3308
+ rows={4}
3309
+ className="input"
3310
+ placeholder="Add more details about this task..."
3311
+ />
3312
+ </div>
3313
+
3314
+ <div>
3315
+ <label htmlFor="priority" className="label">Priority</label>
3316
+ <select id="priority" name="priority" className="input" defaultValue="medium">
3317
+ <option value="low">Low</option>
3318
+ <option value="medium">Medium</option>
3319
+ <option value="high">High</option>
3320
+ </select>
3321
+ {actionData?.errors?.priority && (
3322
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.priority}</p>
3323
+ )}
3324
+ </div>
3325
+
3326
+ <div className="flex gap-3 pt-2">
3327
+ <button
3328
+ type="submit"
3329
+ className="btn btn-primary"
3330
+ >
3331
+ Create Task
3332
+ </button>
3333
+ <a href="/tasks" className="btn btn-secondary">Cancel</a>
3334
+ </div>
3335
+ </form>
3336
+ </div>
3337
+ </div>
3338
+ );
3339
+ }
3340
+ `.trim();
3341
+ await Bun.write(join(projectDir, `app/routes/tasks/new.${ext}`), newTaskPage);
3342
+ const taskDetailPage = `
3343
+ import { useAuth, requireAuth } from '@ereo/auth';
3344
+ import { getTaskById, updateTask, deleteTask } from '~/lib/db';
3345
+
3346
+ export const config = { ...requireAuth({ redirect: '/login' }) };
3347
+
3348
+ export async function loader({ params, context }${ts ? ": { params: { id: string }; context: any }" : ""}) {
3349
+ const auth = useAuth(context);
3350
+ if (!auth.isAuthenticated()) {
3351
+ return new Response(null, { status: 302, headers: { Location: '/login' } });
3352
+ }
3353
+ const user = auth.getUser()${ts ? "!" : ""};
3354
+ const userId = Number(user.id);
3355
+
3356
+ const task = getTaskById(Number(params.id), userId);
3357
+
3358
+ if (!task) {
3359
+ throw new Response('Task not found', { status: 404 });
3360
+ }
3361
+
3362
+ return { task };
3363
+ }
3364
+
3365
+ export async function action({ request, params, context }${ts ? ": { request: Request; params: { id: string }; context: any }" : ""}) {
3366
+ const auth = useAuth(context);
3367
+ if (!auth.isAuthenticated()) {
3368
+ return new Response(null, { status: 302, headers: { Location: '/login' } });
3369
+ }
3370
+ const user = auth.getUser()${ts ? "!" : ""};
3371
+ const userId = Number(user.id);
3372
+ const taskId = Number(params.id);
3373
+
3374
+ const formData = await request.formData();
3375
+ const intent = formData.get('_intent')${ts ? " as string" : ""};
3376
+
3377
+ // Handle delete
3378
+ if (intent === 'delete') {
3379
+ deleteTask(taskId, userId);
3380
+ return new Response(null, {
3381
+ status: 302,
3382
+ headers: { Location: '/tasks' },
3383
+ });
3384
+ }
3385
+
3386
+ // Handle status quick-toggle
3387
+ if (intent === 'toggle-status') {
3388
+ const task = getTaskById(taskId, userId);
3389
+ if (!task) throw new Response('Task not found', { status: 404 });
3390
+
3391
+ const nextStatus${ts ? ": Record<string, string>" : ""} = {
3392
+ todo: 'in_progress',
3393
+ in_progress: 'done',
3394
+ done: 'todo',
3395
+ };
3396
+
3397
+ updateTask(taskId, userId, task.title, task.description, nextStatus[task.status] || 'todo', task.priority);
3398
+ return new Response(null, {
3399
+ status: 302,
3400
+ headers: { Location: \`/tasks/\${taskId}\` },
3401
+ });
3402
+ }
3403
+
3404
+ // Handle update
3405
+ const title = (formData.get('title')${ts ? " as string" : ""} || '').trim();
3406
+ const description = (formData.get('description')${ts ? " as string" : ""} || '').trim();
3407
+ const status = formData.get('status')${ts ? " as string" : ""} || 'todo';
3408
+ const priority = formData.get('priority')${ts ? " as string" : ""} || 'medium';
3409
+
3410
+ const errors${ts ? ": Record<string, string>" : ""} = {};
3411
+ if (!title || title.length < 1) errors.title = 'Title is required';
3412
+ if (title.length > 200) errors.title = 'Title must be under 200 characters';
3413
+ if (!['todo', 'in_progress', 'done'].includes(status)) errors.status = 'Invalid status';
3414
+ if (!['low', 'medium', 'high'].includes(priority)) errors.priority = 'Invalid priority';
3415
+
3416
+ if (Object.keys(errors).length > 0) {
3417
+ return { success: false, errors };
3418
+ }
3419
+
3420
+ const updated = updateTask(taskId, userId, title, description, status, priority);
3421
+
3422
+ if (!updated) {
3423
+ return { success: false, errors: { form: 'Task not found' } };
3424
+ }
3425
+
3426
+ return new Response(null, {
3427
+ status: 302,
3428
+ headers: { Location: '/tasks' },
3429
+ });
3430
+ }
3431
+
3432
+ ${ts ? `interface TaskDetailProps {
3433
+ loaderData: {
3434
+ task: {
3435
+ id: number;
3436
+ title: string;
3437
+ description: string;
3438
+ status: 'todo' | 'in_progress' | 'done';
3439
+ priority: 'low' | 'medium' | 'high';
3440
+ created_at: string;
3441
+ updated_at: string;
3442
+ };
3443
+ };
3444
+ actionData?: {
3445
+ success: boolean;
3446
+ message?: string;
3447
+ errors?: Record<string, string>;
3448
+ };
3449
+ }
3450
+ ` : ""}
3451
+ export default function TaskDetailPage({ loaderData, actionData }${ts ? ": TaskDetailProps" : ""}) {
3452
+ const { task } = loaderData;
3453
+
3454
+ const statusLabels${ts ? ": Record<string, string>" : ""} = {
3455
+ todo: 'To Do',
3456
+ in_progress: 'In Progress',
3457
+ done: 'Done',
3458
+ };
3459
+
3460
+ return (
3461
+ <div className="max-w-2xl mx-auto px-4 py-8">
3462
+ <div className="mb-8 flex items-center justify-between">
3463
+ <div>
3464
+ <a href="/tasks" className="text-sm text-gray-500 hover:text-primary-600 transition-colors">
3465
+ &larr; Back to tasks
3466
+ </a>
3467
+ <h1 className="text-3xl font-bold mt-2">Edit Task</h1>
3468
+ </div>
3469
+
3470
+ {/* Quick status toggle */}
3471
+ <form method="POST">
3472
+ <input type="hidden" name="_intent" value="toggle-status" />
3473
+ <button type="submit" className="btn btn-secondary btn-sm">
3474
+ Move to {statusLabels[task.status === 'todo' ? 'in_progress' : task.status === 'in_progress' ? 'done' : 'todo']}
3475
+ </button>
3476
+ </form>
3477
+ </div>
3478
+
3479
+ {actionData?.errors?.form && (
3480
+ <div className="mb-6 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
3481
+ <p className="text-sm text-red-700 dark:text-red-300">{actionData.errors.form}</p>
3482
+ </div>
3483
+ )}
3484
+
3485
+ <div className="card">
3486
+ <form method="POST" className="space-y-5">
3487
+ <div>
3488
+ <label htmlFor="title" className="label">Title</label>
3489
+ <input
3490
+ type="text"
3491
+ id="title"
3492
+ name="title"
3493
+ required
3494
+ maxLength={200}
3495
+ className="input"
3496
+ defaultValue={task.title}
3497
+ />
3498
+ {actionData?.errors?.title && (
3499
+ <p className="mt-1 text-sm text-red-600">{actionData.errors.title}</p>
3500
+ )}
3501
+ </div>
3502
+
3503
+ <div>
3504
+ <label htmlFor="description" className="label">
3505
+ Description <span className="text-gray-400 font-normal">(optional)</span>
3506
+ </label>
3507
+ <textarea
3508
+ id="description"
3509
+ name="description"
3510
+ rows={4}
3511
+ className="input"
3512
+ defaultValue={task.description}
3513
+ />
3514
+ </div>
3515
+
3516
+ <div className="grid grid-cols-2 gap-4">
3517
+ <div>
3518
+ <label htmlFor="status" className="label">Status</label>
3519
+ <select id="status" name="status" className="input" defaultValue={task.status}>
3520
+ <option value="todo">To Do</option>
3521
+ <option value="in_progress">In Progress</option>
3522
+ <option value="done">Done</option>
3523
+ </select>
3524
+ </div>
3525
+
3526
+ <div>
3527
+ <label htmlFor="priority" className="label">Priority</label>
3528
+ <select id="priority" name="priority" className="input" defaultValue={task.priority}>
3529
+ <option value="low">Low</option>
3530
+ <option value="medium">Medium</option>
3531
+ <option value="high">High</option>
3532
+ </select>
3533
+ </div>
3534
+ </div>
3535
+
3536
+ <div className="text-xs text-gray-400 dark:text-gray-500">
3537
+ Created {new Date(task.created_at).toLocaleString()}
3538
+ {task.updated_at !== task.created_at && (
3539
+ <> &bull; Updated {new Date(task.updated_at).toLocaleString()}</>
3540
+ )}
3541
+ </div>
3542
+
3543
+ <div className="flex items-center gap-3 pt-2">
3544
+ <button
3545
+ type="submit"
3546
+ className="btn btn-primary"
3547
+ >
3548
+ Save Changes
3549
+ </button>
3550
+ <a href="/tasks" className="btn btn-secondary">Cancel</a>
3551
+ </div>
3552
+ </form>
3553
+ </div>
3554
+
3555
+ {/* Delete section - pure form, no client JS needed */}
3556
+ <div className="mt-6 card border-red-200 dark:border-red-800">
3557
+ <h3 className="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">Danger Zone</h3>
3558
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
3559
+ Permanently delete this task. This action cannot be undone.
3560
+ </p>
3561
+ <form method="POST">
3562
+ <input type="hidden" name="_intent" value="delete" />
3563
+ <button type="submit" className="btn btn-danger btn-sm">
3564
+ Delete Task
3565
+ </button>
3566
+ </form>
3567
+ </div>
3568
+ </div>
3569
+ );
3570
+ }
3571
+
3572
+ export function ErrorBoundary({ error }${ts ? ": { error: Error }" : ""}) {
3573
+ return (
3574
+ <div className="max-w-2xl mx-auto px-4 py-16 text-center">
3575
+ <h1 className="text-4xl font-bold mb-4">Task Not Found</h1>
3576
+ <p className="text-gray-600 dark:text-gray-400 mb-8">
3577
+ This task doesn't exist or you don't have access to it.
3578
+ </p>
3579
+ <a href="/tasks" className="btn btn-primary">
3580
+ Back to Tasks
3581
+ </a>
3582
+ </div>
3583
+ );
3584
+ }
3585
+ `.trim();
3586
+ await Bun.write(join(projectDir, `app/routes/tasks/[id].${ext}`), taskDetailPage);
3587
+ const errorPage = `
3588
+ ${ts ? `interface ErrorPageProps {
3589
+ error: Error;
3590
+ }
3591
+ ` : ""}
3592
+ export default function ErrorPage({ error }${ts ? ": ErrorPageProps" : ""}) {
3593
+ return (
3594
+ <div className="min-h-[80vh] flex items-center justify-center p-4">
3595
+ <div className="text-center">
3596
+ <h1 className="text-4xl font-bold mb-4">Something went wrong</h1>
3597
+ <p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md">
3598
+ {error?.message || 'An unexpected error occurred. Please try again.'}
3599
+ </p>
3600
+ <a href="/" className="btn btn-primary">
3601
+ Go Home
3602
+ </a>
3603
+ </div>
3604
+ </div>
3605
+ );
3606
+ }
3607
+ `.trim();
3608
+ await Bun.write(join(projectDir, `app/routes/_error.${ext}`), errorPage);
3609
+ const notFoundPage = `
3610
+ export default function NotFoundPage() {
3611
+ return (
3612
+ <div className="min-h-[80vh] flex items-center justify-center p-4">
3613
+ <div className="text-center">
3614
+ <div className="text-8xl font-bold text-gray-200 dark:text-gray-700 mb-4">404</div>
3615
+ <h1 className="text-4xl font-bold mb-4">Page Not Found</h1>
3616
+ <p className="text-gray-600 dark:text-gray-400 mb-8">
3617
+ The page you're looking for doesn't exist.
3618
+ </p>
3619
+ <a href="/" className="btn btn-primary">
3620
+ Go Home
3621
+ </a>
3622
+ </div>
3623
+ </div>
3624
+ );
3625
+ }
3626
+ `.trim();
3627
+ await Bun.write(join(projectDir, `app/routes/_404.${ext}`), notFoundPage);
3628
+ await Bun.write(join(projectDir, ".gitignore"), `node_modules
3629
+ .ereo
3630
+ dist
3631
+ *.log
3632
+ .DS_Store
3633
+ .env
3634
+ .env.local
3635
+ .env.*.local
3636
+ data/*.db
3637
+ data/*.db-wal
3638
+ data/*.db-shm`);
3639
+ await Bun.write(join(projectDir, ".env.example"), `# Environment Variables
3640
+ # Copy this file to .env and update the values
3641
+
3642
+ # Node environment
3643
+ NODE_ENV=development
3644
+
3645
+ # Server port (optional, defaults to 3000)
3646
+ # PORT=3000
3647
+
3648
+ # Auth secret \u2014 REQUIRED for production!
3649
+ # Generate one with: openssl rand -base64 32
3650
+ AUTH_SECRET=change-me-in-production`);
3651
+ await Bun.write(join(projectDir, ".env"), `NODE_ENV=development
3652
+ AUTH_SECRET=dev-secret-not-for-production`);
3653
+ await Bun.write(join(projectDir, "data/.gitkeep"), "");
3654
+ const dockerfile = `# ---- Stage 1: Install all deps + build ----
3655
+ FROM oven/bun:1-slim AS builder
3656
+ WORKDIR /app
3657
+ COPY package.json bun.lockb* ./
3658
+ RUN bun install --ignore-scripts
3659
+ COPY . .
3660
+ RUN bun run build
3661
+
3662
+ # ---- Stage 2: Production dependencies only ----
3663
+ FROM oven/bun:1-slim AS deps
3664
+ WORKDIR /app
3665
+ COPY package.json bun.lockb* ./
3666
+ RUN bun install --production --ignore-scripts
3667
+
3668
+ # ---- Stage 3: Production image ----
3669
+ FROM oven/bun:1-alpine AS runner
3670
+ WORKDIR /app
3671
+
3672
+ ENV NODE_ENV=production
3673
+
3674
+ # Non-root user for security
3675
+ RUN addgroup -S -g 1001 ereo && \\
3676
+ adduser -S -u 1001 -G ereo -H ereo
3677
+
3678
+ # Copy production node_modules
3679
+ COPY --from=deps --chown=ereo:ereo /app/node_modules ./node_modules
3680
+
3681
+ # Copy build output and runtime files
3682
+ COPY --from=builder --chown=ereo:ereo /app/.ereo ./.ereo
3683
+ COPY --from=builder --chown=ereo:ereo /app/app ./app
3684
+ COPY --from=builder --chown=ereo:ereo /app/public ./public
3685
+ COPY --from=builder --chown=ereo:ereo /app/package.json ./
3686
+ COPY --from=builder --chown=ereo:ereo /app/ereo.config.* ./
3687
+ COPY --from=builder --chown=ereo:ereo /app/tsconfig.* ./
3688
+
3689
+ # Create data directory for SQLite with proper permissions
3690
+ RUN mkdir -p /app/data && chown -R ereo:ereo /app/data
3691
+
3692
+ USER ereo
3693
+
3694
+ # Mount a volume for persistent SQLite data
3695
+ VOLUME ["/app/data"]
3696
+
3697
+ EXPOSE 3000
3698
+
3699
+ CMD ["bun", "run", "start"]
3700
+ `;
3701
+ await Bun.write(join(projectDir, "Dockerfile"), dockerfile);
3702
+ await Bun.write(join(projectDir, ".dockerignore"), generateDockerignore());
3703
+ const readme = `# ${projectName}
3704
+
3705
+ A full-stack task management app built with [EreoJS](https://github.com/ereoJS/ereoJS) \u2014 a React fullstack framework powered by Bun.
3706
+
3707
+ ## Features
3708
+
3709
+ - **Email & Password Authentication** \u2014 Secure sign up/sign in with argon2id hashing and JWT sessions
3710
+ - **SQLite Database** \u2014 Production-ready with WAL mode, foreign keys, and automatic migrations
3711
+ - **Full CRUD** \u2014 Create, read, update, and delete tasks with server-side validation
3712
+ - **Server-Side Rendering** \u2014 Fast initial loads with data fetched via loaders
3713
+ - **File-Based Routing** \u2014 Routes map to files with layouts, dynamic segments, and error boundaries
3714
+ - **Protected Routes** \u2014 Auth middleware redirects unauthenticated users to login
3715
+ - **Tailwind CSS** \u2014 Utility-first styling with dark mode support
3716
+ - **Docker Ready** \u2014 Multi-stage Dockerfile with SQLite volume persistence${trace ? `
3717
+ - **Full-Stack Tracing** \u2014 Request-level observability with \`@ereo/trace\`` : ""}
3718
+
3719
+ ## Getting Started
3720
+
3721
+ \`\`\`bash
3722
+ # Install dependencies
3723
+ bun install
3724
+
3725
+ # Start development server${trace ? " (tracing enabled)" : ""}
3726
+ bun run dev
3727
+
3728
+ # Open http://localhost:3000
3729
+ \`\`\`
3730
+
3731
+ Create an account at http://localhost:3000/register and start managing tasks!
3732
+
3733
+ ## Project Structure
3734
+
3735
+ \`\`\`
3736
+ app/
3737
+ \u251C\u2500\u2500 components/
3738
+ \u2502 \u251C\u2500\u2500 Navigation.tsx # Auth-aware navigation
3739
+ \u2502 \u251C\u2500\u2500 Footer.tsx
3740
+ \u2502 \u2514\u2500\u2500 TaskCard.tsx # Task list item
3741
+ \u251C\u2500\u2500 lib/
3742
+ \u2502 \u251C\u2500\u2500 db.ts # SQLite database, schema & queries${ts ? `
3743
+ \u2502 \u2514\u2500\u2500 types.ts # Shared TypeScript types` : ""}
3744
+ \u251C\u2500\u2500 routes/
3745
+ \u2502 \u251C\u2500\u2500 _layout.tsx # Root layout with auth context
3746
+ \u2502 \u251C\u2500\u2500 _error.tsx # Error boundary
3747
+ \u2502 \u251C\u2500\u2500 _404.tsx # Not found page
3748
+ \u2502 \u251C\u2500\u2500 index.tsx # Landing page
3749
+ \u2502 \u251C\u2500\u2500 logout.tsx # Logout action
3750
+ \u2502 \u251C\u2500\u2500 (auth)/
3751
+ \u2502 \u2502 \u251C\u2500\u2500 login.tsx # Sign in
3752
+ \u2502 \u2502 \u2514\u2500\u2500 register.tsx # Sign up
3753
+ \u2502 \u2514\u2500\u2500 tasks/
3754
+ \u2502 \u251C\u2500\u2500 index.tsx # Task list with filters
3755
+ \u2502 \u251C\u2500\u2500 new.tsx # Create task form
3756
+ \u2502 \u2514\u2500\u2500 [id].tsx # View/edit/delete task
3757
+ \u2514\u2500\u2500 styles.css # Tailwind directives
3758
+ data/
3759
+ \u2514\u2500\u2500 app.db # SQLite database (auto-created)
3760
+ \`\`\`
3761
+
3762
+ ## Scripts
3763
+
3764
+ - \`bun run dev\` \u2014 Start development server
3765
+ - \`bun run build\` \u2014 Build for production
3766
+ - \`bun run start\` \u2014 Start production server
3767
+ - \`bun test\` \u2014 Run tests
3768
+ - \`bun run typecheck\` \u2014 TypeScript type checking
3769
+
3770
+ ## Environment Variables
3771
+
3772
+ | Variable | Required | Default | Description |
3773
+ |----------|----------|---------|-------------|
3774
+ | \`AUTH_SECRET\` | **Yes** (production) | dev secret | JWT signing secret |
3775
+ | \`NODE_ENV\` | No | development | Environment |
3776
+ | \`PORT\` | No | 3000 | Server port |
3777
+
3778
+ Generate a production secret:
3779
+
3780
+ \`\`\`bash
3781
+ openssl rand -base64 32
3782
+ \`\`\`
3783
+
3784
+ ## Docker
3785
+
3786
+ \`\`\`bash
3787
+ # Build image
3788
+ docker build -t ${projectName} .
3789
+
3790
+ # Run with persistent SQLite data
3791
+ docker run -p 3000:3000 -v ${projectName}-data:/app/data -e AUTH_SECRET=your-secret ${projectName}
3792
+ \`\`\`
3793
+
3794
+ ${trace ? `## Tracing
3795
+
3796
+ This project includes \`@ereo/trace\` for full-stack observability.
3797
+
3798
+ - **CLI Reporter** \u2014 Live tree view in your terminal
3799
+ - **Trace Viewer** \u2014 http://localhost:3000/__ereo/traces
3800
+ - **Production** \u2014 Alias \`@ereo/trace\` to \`@ereo/trace/noop\` (592 bytes, zero cost)
3801
+
3802
+ ` : ""}## Learn More
3803
+
3804
+ - [EreoJS Documentation](https://ereojs.github.io/ereoJS/)
1914
3805
  - [Bun Documentation](https://bun.sh/docs)
1915
3806
  - [Tailwind CSS](https://tailwindcss.com/docs)
1916
3807
  `;
@@ -1920,6 +3811,8 @@ async function generateProject(projectDir, projectName, options) {
1920
3811
  const { template, typescript, trace } = options;
1921
3812
  if (template === "minimal") {
1922
3813
  await generateMinimalProject(projectDir, projectName, typescript, trace);
3814
+ } else if (template === "tasks") {
3815
+ await generateTasksProject(projectDir, projectName, typescript, trace);
1923
3816
  } else {
1924
3817
  await generateTailwindProject(projectDir, projectName, typescript, trace);
1925
3818
  }