@kozojs/cli 0.1.16 → 0.1.18

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 (2) hide show
  1. package/lib/index.js +226 -50
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -1495,20 +1495,19 @@ ${auth ? "JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars\n" : ""}`;
1495
1495
  exclude: ["node_modules", "dist"]
1496
1496
  };
1497
1497
  await import_fs_extra.default.writeJSON(import_node_path.default.join(apiDir, "tsconfig.json"), tsconfig, { spaces: 2 });
1498
- const dbImport = hasDb ? `import { createDb } from './db/index.js';
1499
- ` : "";
1500
1498
  const authImport = auth ? `import { authenticateJWT } from '@kozojs/auth';
1501
1499
  ` : "";
1502
- const servicesSetup = hasDb ? `
1503
- const db = await createDb();
1504
- const services = { db };
1505
- ` : "\nconst services = {};\n";
1500
+ const servicesSetup = "\nconst services = {};\n";
1506
1501
  const authMiddleware = auth ? `
1507
- app.use('*', authenticateJWT({ secret: process.env.JWT_SECRET || 'change-me' }));
1502
+ // Protect resource routes with JWT (auth login route remains public)
1503
+ const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
1504
+ app.use('*', authenticateJWT(JWT_SECRET, { prefix: '/api/users' }));
1505
+ app.use('*', authenticateJWT(JWT_SECRET, { prefix: '/api/posts' }));
1506
+ app.use('*', authenticateJWT(JWT_SECRET, { prefix: '/api/tasks' }));
1508
1507
  ` : "";
1509
1508
  await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "index.ts"), `import 'dotenv/config';
1510
1509
  import { createKozo } from '@kozojs/core';
1511
- ${dbImport}${authImport}import { registerRoutes } from './routes/index.js';
1510
+ ${authImport}import { registerRoutes } from './routes/index.js';
1512
1511
  ${servicesSetup}
1513
1512
  const app = createKozo({ port: 3000, services });
1514
1513
  ${authMiddleware}
@@ -1563,15 +1562,19 @@ export const tasks: z.infer<typeof TaskSchema>[] = [
1563
1562
  { id: '3', title: 'Deploy', completed: false, priority: 'low', createdAt: new Date().toISOString() },
1564
1563
  ];
1565
1564
  `);
1565
+ const authRoutesImport = auth ? `import { registerAuthRoutes } from './auth';
1566
+ ` : "";
1567
+ const authRoutesCall = auth ? ` registerAuthRoutes(app); // Public auth endpoint must register before JWT middleware
1568
+ ` : "";
1566
1569
  await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "index.ts"), `import type { Kozo } from '@kozojs/core';
1567
1570
  import { registerHealthRoutes } from './health';
1568
1571
  import { registerUserRoutes } from './users';
1569
1572
  import { registerPostRoutes } from './posts';
1570
1573
  import { registerTaskRoutes } from './tasks';
1571
1574
  import { registerToolRoutes } from './tools';
1572
-
1575
+ ${authRoutesImport}
1573
1576
  export function registerRoutes(app: Kozo) {
1574
- registerHealthRoutes(app);
1577
+ ${authRoutesCall} registerHealthRoutes(app);
1575
1578
  registerUserRoutes(app);
1576
1579
  registerPostRoutes(app);
1577
1580
  registerTaskRoutes(app);
@@ -1836,6 +1839,35 @@ export function registerToolRoutes(app: Kozo) {
1836
1839
  }));
1837
1840
  }
1838
1841
  `);
1842
+ if (auth) {
1843
+ await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "auth.ts"), `import type { Kozo } from '@kozojs/core';
1844
+ import { z } from 'zod';
1845
+ import { createJWT, UnauthorizedError } from '@kozojs/auth';
1846
+
1847
+ const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
1848
+
1849
+ const DEMO_USERS = [
1850
+ { email: 'admin@demo.com', password: 'admin123', role: 'admin', name: 'Admin' },
1851
+ { email: 'user@demo.com', password: 'user123', role: 'user', name: 'User' },
1852
+ ];
1853
+
1854
+ export function registerAuthRoutes(app: Kozo) {
1855
+ app.post('/api/auth/login', {
1856
+ body: z.object({ email: z.string().email(), password: z.string() }),
1857
+ }, async (c) => {
1858
+ const { email, password } = c.body;
1859
+ const user = DEMO_USERS.find(u => u.email === email && u.password === password);
1860
+ if (!user) throw new UnauthorizedError('Invalid credentials');
1861
+ const token = await createJWT(
1862
+ { email: user.email, role: user.role, name: user.name },
1863
+ JWT_SECRET,
1864
+ { expiresIn: '24h' },
1865
+ );
1866
+ return { token, user: { email: user.email, role: user.role, name: user.name } };
1867
+ });
1868
+ }
1869
+ `);
1870
+ }
1839
1871
  }
1840
1872
  async function scaffoldFullstackWeb(projectDir, projectName, frontend, auth = false) {
1841
1873
  const webDir = import_node_path.default.join(projectDir, "apps", "web");
@@ -1891,11 +1923,6 @@ export default defineConfig({
1891
1923
  port: 5173,
1892
1924
  proxy: {
1893
1925
  '/api': { target: 'http://localhost:3000', changeOrigin: true },
1894
- '/health': { target: 'http://localhost:3000', changeOrigin: true },
1895
- '/auth': { target: 'http://localhost:3000', changeOrigin: true },
1896
- '/users': { target: 'http://localhost:3000', changeOrigin: true },
1897
- '/posts': { target: 'http://localhost:3000', changeOrigin: true },
1898
- '/stats': { target: 'http://localhost:3000', changeOrigin: true },
1899
1926
  },
1900
1927
  },
1901
1928
  });
@@ -1976,6 +2003,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
1976
2003
  await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Dashboard.tsx"), generateDashboardPage());
1977
2004
  await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Users.tsx"), generateUsersPage());
1978
2005
  await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Posts.tsx"), generatePostsPage());
2006
+ await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Tasks.tsx"), generateTasksPage());
1979
2007
  if (auth) {
1980
2008
  await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Login.tsx"), generateLoginPage());
1981
2009
  }
@@ -1984,16 +2012,18 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
1984
2012
  function generateAppTsx(projectName, auth) {
1985
2013
  const authImports = auth ? `import { getToken, clearToken } from './lib/api';` : "";
1986
2014
  const loginImport = auth ? `import Login from './pages/Login';` : "";
2015
+ const queryClientImport = auth ? `import { useQueryClient } from '@tanstack/react-query';` : "";
1987
2016
  return `import { useState${auth ? ", useEffect" : ""} } from 'react';
1988
- import { useQueryClient } from '@tanstack/react-query';
1989
- import { LayoutDashboard, Users, FileText, Server, LogOut } from 'lucide-react';
2017
+ ${queryClientImport}
2018
+ import { LayoutDashboard, Users, FileText, CheckSquare, Server${auth ? ", LogOut" : ""} } from 'lucide-react';
1990
2019
  ${authImports}
1991
2020
  ${loginImport}
1992
2021
  import Dashboard from './pages/Dashboard';
1993
2022
  import UsersPage from './pages/Users';
1994
2023
  import PostsPage from './pages/Posts';
2024
+ import TasksPage from './pages/Tasks';
1995
2025
 
1996
- type Page = 'dashboard' | 'users' | 'posts';
2026
+ type Page = 'dashboard' | 'users' | 'posts' | 'tasks';
1997
2027
 
1998
2028
  export default function App() {
1999
2029
  const [page, setPage] = useState<Page>('dashboard');
@@ -2010,12 +2040,12 @@ ${auth ? ` const [token, setToken] = useState<string | null>(() => getToken());
2010
2040
  };
2011
2041
 
2012
2042
  if (!token) return <Login onLogin={handleLogin} />;
2013
- ` : ` const queryClient = useQueryClient();
2014
- `}
2043
+ ` : ""}
2015
2044
  const nav = [
2016
2045
  { id: 'dashboard' as Page, label: 'Dashboard', icon: LayoutDashboard },
2017
2046
  { id: 'users' as Page, label: 'Users', icon: Users },
2018
2047
  { id: 'posts' as Page, label: 'Posts', icon: FileText },
2048
+ { id: 'tasks' as Page, label: 'Tasks', icon: CheckSquare },
2019
2049
  ];
2020
2050
 
2021
2051
  return (
@@ -2055,6 +2085,7 @@ ${auth ? ` <button
2055
2085
  {page === 'dashboard' && <Dashboard />}
2056
2086
  {page === 'users' && <UsersPage />}
2057
2087
  {page === 'posts' && <PostsPage />}
2088
+ {page === 'tasks' && <TasksPage />}
2058
2089
  </main>
2059
2090
  </div>
2060
2091
  );
@@ -2071,8 +2102,8 @@ interface Props {
2071
2102
  }
2072
2103
 
2073
2104
  export default function Login({ onLogin }: Props) {
2074
- const [email, setEmail] = useState('admin@kozo.app');
2075
- const [password, setPassword] = useState('password123');
2105
+ const [email, setEmail] = useState('admin@demo.com');
2106
+ const [password, setPassword] = useState('admin123');
2076
2107
  const [error, setError] = useState('');
2077
2108
  const [loading, setLoading] = useState(false);
2078
2109
 
@@ -2081,7 +2112,7 @@ export default function Login({ onLogin }: Props) {
2081
2112
  setError('');
2082
2113
  setLoading(true);
2083
2114
  try {
2084
- const res = await apiFetch<{ token: string }>('/auth/login', {
2115
+ const res = await apiFetch<{ token: string }>('/api/auth/login', {
2085
2116
  method: 'POST',
2086
2117
  body: JSON.stringify({ email, password }),
2087
2118
  });
@@ -2149,15 +2180,15 @@ export default function Login({ onLogin }: Props) {
2149
2180
  function generateDashboardPage() {
2150
2181
  return `import { useQuery } from '@tanstack/react-query';
2151
2182
  import { apiFetch } from '../lib/api';
2152
- import { Users, FileText, Activity, Zap } from 'lucide-react';
2183
+ import { Users, FileText, CheckSquare, Zap } from 'lucide-react';
2153
2184
 
2185
+ interface Health { status: string; uptime: number; version: string; }
2154
2186
  interface Stats {
2155
- users?: { total: number; admins?: number; regular?: number };
2156
- posts?: { total: number; published?: number; drafts?: number };
2157
- performance?: { uptime: number; memoryUsage?: { heapUsed: number } };
2158
- // fullstack format
2159
- timestamp?: string;
2160
- version?: string;
2187
+ users: number;
2188
+ posts: number;
2189
+ tasks: number;
2190
+ publishedPosts: number;
2191
+ completedTasks: number;
2161
2192
  }
2162
2193
 
2163
2194
  function StatCard({ label, value, sub, icon: Icon, color }: {
@@ -2183,15 +2214,15 @@ function StatCard({ label, value, sub, icon: Icon, color }: {
2183
2214
  export default function Dashboard() {
2184
2215
  const { data: health } = useQuery({
2185
2216
  queryKey: ['health'],
2186
- queryFn: () => apiFetch<{ status: string; uptime: number; version: string }>('/health').then(r => r.data),
2217
+ queryFn: () => apiFetch<Health>('/api/health').then(r => r.data),
2187
2218
  });
2188
2219
 
2189
2220
  const { data: stats, isLoading } = useQuery({
2190
2221
  queryKey: ['stats'],
2191
- queryFn: () => apiFetch<Stats>('/stats').then(r => r.data),
2222
+ queryFn: () => apiFetch<Stats>('/api/stats').then(r => r.data),
2192
2223
  });
2193
2224
 
2194
- const uptime = health?.uptime ?? stats?.performance?.uptime;
2225
+ const uptime = health?.uptime;
2195
2226
  const uptimeStr = uptime !== undefined
2196
2227
  ? uptime > 3600 ? \`\${Math.floor(uptime / 3600)}h \${Math.floor((uptime % 3600) / 60)}m\`
2197
2228
  : uptime > 60 ? \`\${Math.floor(uptime / 60)}m \${Math.floor(uptime % 60)}s\`
@@ -2211,24 +2242,24 @@ export default function Dashboard() {
2211
2242
  <div className="grid grid-cols-2 gap-4 mb-8">
2212
2243
  <StatCard
2213
2244
  label="Users"
2214
- value={stats?.users?.total ?? '\u2014'}
2215
- sub={stats?.users ? \`\${stats.users.admins ?? 0} admins \xB7 \${stats.users.regular ?? 0} regular\` : undefined}
2245
+ value={stats?.users ?? '\u2014'}
2216
2246
  icon={Users} color="bg-blue-600"
2217
2247
  />
2218
2248
  <StatCard
2219
2249
  label="Posts"
2220
- value={stats?.posts?.total ?? '\u2014'}
2221
- sub={stats?.posts ? \`\${stats.posts.published ?? 0} published \xB7 \${stats.posts.drafts ?? 0} drafts\` : undefined}
2250
+ value={stats?.posts ?? '\u2014'}
2251
+ sub={stats ? \`\${stats.publishedPosts} published\` : undefined}
2222
2252
  icon={FileText} color="bg-purple-600"
2223
2253
  />
2224
2254
  <StatCard
2225
- label="Uptime"
2226
- value={uptimeStr}
2227
- icon={Activity} color="bg-emerald-600"
2255
+ label="Tasks"
2256
+ value={stats?.tasks ?? '\u2014'}
2257
+ sub={stats ? \`\${stats.completedTasks} completed\` : undefined}
2258
+ icon={CheckSquare} color="bg-emerald-600"
2228
2259
  />
2229
2260
  <StatCard
2230
- label="API Status"
2231
- value={health?.status === 'ok' ? '\u2713 Online' : '? Unknown'}
2261
+ label="Uptime"
2262
+ value={uptimeStr}
2232
2263
  sub={health?.version ? \`v\${health.version}\` : undefined}
2233
2264
  icon={Zap} color="bg-amber-600"
2234
2265
  />
@@ -2236,10 +2267,10 @@ export default function Dashboard() {
2236
2267
  )}
2237
2268
 
2238
2269
  <div className="bg-slate-800 rounded-xl border border-slate-700 p-4">
2239
- <h3 className="text-sm font-medium text-slate-300 mb-3">Quick API Test</h3>
2270
+ <h3 className="text-sm font-medium text-slate-300 mb-3">API Status</h3>
2240
2271
  <div className="flex items-center gap-2">
2241
2272
  <div className="flex-1 px-3 py-2 bg-slate-900 rounded-lg text-sm text-slate-400 font-mono">
2242
- GET /health
2273
+ GET /api/health
2243
2274
  </div>
2244
2275
  <span className={\`px-2 py-0.5 rounded text-xs font-medium \${
2245
2276
  health?.status === 'ok' ? 'bg-emerald-900 text-emerald-300' : 'bg-slate-700 text-slate-400'
@@ -2276,7 +2307,7 @@ export default function UsersPage() {
2276
2307
  const { data: users = [], isLoading } = useQuery({
2277
2308
  queryKey: ['users'],
2278
2309
  queryFn: async () => {
2279
- const res = await apiFetch<User[] | { users: User[] }>('/users');
2310
+ const res = await apiFetch<User[] | { users: User[] }>('/api/users');
2280
2311
  return Array.isArray(res.data) ? res.data : res.data.users ?? [];
2281
2312
  },
2282
2313
  });
@@ -2285,7 +2316,7 @@ export default function UsersPage() {
2285
2316
  if (!form.name || !form.email) { setError('Name and email required'); return; }
2286
2317
  setLoading(true); setError('');
2287
2318
  try {
2288
- const res = await apiFetch('/users', { method: 'POST', body: JSON.stringify(form) });
2319
+ const res = await apiFetch('/api/users', { method: 'POST', body: JSON.stringify(form) });
2289
2320
  if (res.status >= 400) throw new Error('Failed');
2290
2321
  setForm({ name: '', email: '' });
2291
2322
  queryClient.invalidateQueries({ queryKey: ['users'] });
@@ -2295,7 +2326,7 @@ export default function UsersPage() {
2295
2326
 
2296
2327
  const deleteUser = async (id: string | number) => {
2297
2328
  setLoading(true);
2298
- await apiFetch(\`/users/\${id}\`, { method: 'DELETE' });
2329
+ await apiFetch(\`/api/users/\${id}\`, { method: 'DELETE' });
2299
2330
  queryClient.invalidateQueries({ queryKey: ['users'] });
2300
2331
  setLoading(false);
2301
2332
  };
@@ -2410,7 +2441,7 @@ export default function PostsPage() {
2410
2441
  const { data: posts = [], isLoading } = useQuery({
2411
2442
  queryKey: ['posts'],
2412
2443
  queryFn: async () => {
2413
- const res = await apiFetch<Post[] | { posts: Post[] }>('/posts');
2444
+ const res = await apiFetch<Post[] | { posts: Post[] }>('/api/posts');
2414
2445
  return Array.isArray(res.data) ? res.data : res.data.posts ?? [];
2415
2446
  },
2416
2447
  });
@@ -2419,7 +2450,7 @@ export default function PostsPage() {
2419
2450
  if (!form.title) { setError('Title required'); return; }
2420
2451
  setLoading(true); setError('');
2421
2452
  try {
2422
- const res = await apiFetch('/posts', { method: 'POST', body: JSON.stringify(form) });
2453
+ const res = await apiFetch('/api/posts', { method: 'POST', body: JSON.stringify(form) });
2423
2454
  if (res.status >= 400) throw new Error('Failed');
2424
2455
  setForm({ title: '', content: '', published: false });
2425
2456
  queryClient.invalidateQueries({ queryKey: ['posts'] });
@@ -2429,7 +2460,7 @@ export default function PostsPage() {
2429
2460
 
2430
2461
  const deletePost = async (id: string | number) => {
2431
2462
  setLoading(true);
2432
- await apiFetch(\`/posts/\${id}\`, { method: 'DELETE' });
2463
+ await apiFetch(\`/api/posts/\${id}\`, { method: 'DELETE' });
2433
2464
  queryClient.invalidateQueries({ queryKey: ['posts'] });
2434
2465
  setLoading(false);
2435
2466
  };
@@ -2524,6 +2555,151 @@ export default function PostsPage() {
2524
2555
  }
2525
2556
  `;
2526
2557
  }
2558
+ function generateTasksPage() {
2559
+ return `import { useState } from 'react';
2560
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
2561
+ import { apiFetch } from '../lib/api';
2562
+ import { Plus, Trash2, Loader2, CheckCircle2, Circle } from 'lucide-react';
2563
+
2564
+ interface Task {
2565
+ id: string | number;
2566
+ title: string;
2567
+ completed: boolean;
2568
+ priority: 'low' | 'medium' | 'high';
2569
+ createdAt?: string;
2570
+ }
2571
+
2572
+ const priorityColor: Record<string, string> = {
2573
+ high: 'bg-red-900 text-red-300',
2574
+ medium: 'bg-amber-900 text-amber-300',
2575
+ low: 'bg-slate-700 text-slate-300',
2576
+ };
2577
+
2578
+ export default function TasksPage() {
2579
+ const queryClient = useQueryClient();
2580
+ const [form, setForm] = useState({ title: '', priority: 'medium' as 'low' | 'medium' | 'high' });
2581
+ const [loading, setLoading] = useState(false);
2582
+ const [error, setError] = useState('');
2583
+
2584
+ const { data: tasks = [], isLoading } = useQuery({
2585
+ queryKey: ['tasks'],
2586
+ queryFn: async () => {
2587
+ const res = await apiFetch<Task[] | { tasks: Task[] }>('/api/tasks');
2588
+ return Array.isArray(res.data) ? res.data : res.data.tasks ?? [];
2589
+ },
2590
+ });
2591
+
2592
+ const createTask = async () => {
2593
+ if (!form.title) { setError('Title required'); return; }
2594
+ setLoading(true); setError('');
2595
+ try {
2596
+ const res = await apiFetch('/api/tasks', { method: 'POST', body: JSON.stringify(form) });
2597
+ if (res.status >= 400) throw new Error('Failed');
2598
+ setForm({ title: '', priority: 'medium' });
2599
+ queryClient.invalidateQueries({ queryKey: ['tasks'] });
2600
+ } catch { setError('Failed to create task'); }
2601
+ finally { setLoading(false); }
2602
+ };
2603
+
2604
+ const toggleTask = async (id: string | number) => {
2605
+ await apiFetch(\`/api/tasks/\${id}/toggle\`, { method: 'PATCH' });
2606
+ queryClient.invalidateQueries({ queryKey: ['tasks'] });
2607
+ };
2608
+
2609
+ const deleteTask = async (id: string | number) => {
2610
+ setLoading(true);
2611
+ await apiFetch(\`/api/tasks/\${id}\`, { method: 'DELETE' });
2612
+ queryClient.invalidateQueries({ queryKey: ['tasks'] });
2613
+ setLoading(false);
2614
+ };
2615
+
2616
+ const done = tasks.filter(t => t.completed).length;
2617
+
2618
+ return (
2619
+ <div>
2620
+ <div className="mb-6">
2621
+ <h1 className="text-2xl font-bold text-slate-100">Tasks</h1>
2622
+ <p className="text-slate-400 text-sm mt-1">{done}/{tasks.length} completed</p>
2623
+ </div>
2624
+
2625
+ {/* Create form */}
2626
+ <div className="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
2627
+ <h3 className="text-sm font-medium text-slate-300 mb-3">New Task</h3>
2628
+ <div className="flex gap-3">
2629
+ <input
2630
+ placeholder="Task title"
2631
+ value={form.title}
2632
+ onChange={e => setForm({ ...form, title: e.target.value })}
2633
+ onKeyDown={e => { if (e.key === 'Enter') { void createTask(); } }}
2634
+ className="flex-1 px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2635
+ />
2636
+ <select
2637
+ value={form.priority}
2638
+ onChange={e => setForm({ ...form, priority: e.target.value as 'low' | 'medium' | 'high' })}
2639
+ className="px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2640
+ >
2641
+ <option value="low">Low</option>
2642
+ <option value="medium">Medium</option>
2643
+ <option value="high">High</option>
2644
+ </select>
2645
+ <button
2646
+ onClick={() => { void createTask(); }}
2647
+ disabled={loading}
2648
+ className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition flex items-center gap-2"
2649
+ >
2650
+ {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
2651
+ Add
2652
+ </button>
2653
+ </div>
2654
+ {error && <p className="text-red-400 text-xs mt-2">{error}</p>}
2655
+ </div>
2656
+
2657
+ {/* Tasks list */}
2658
+ <div className="space-y-2">
2659
+ {isLoading ? (
2660
+ <div className="text-center text-slate-400 text-sm py-8">Loading tasks...</div>
2661
+ ) : tasks.length === 0 ? (
2662
+ <div className="text-center text-slate-400 text-sm py-8">No tasks yet. Add one above.</div>
2663
+ ) : (
2664
+ tasks.map((task) => (
2665
+ <div
2666
+ key={task.id}
2667
+ className={\`flex items-center justify-between p-3 rounded-xl border transition \${
2668
+ task.completed ? 'bg-slate-800/50 border-slate-700/50' : 'bg-slate-800 border-slate-700'
2669
+ }\`}
2670
+ >
2671
+ <div className="flex items-center gap-3 flex-1 min-w-0">
2672
+ <button
2673
+ onClick={() => { void toggleTask(task.id); }}
2674
+ className="flex-shrink-0 text-slate-400 hover:text-blue-400 transition"
2675
+ >
2676
+ {task.completed
2677
+ ? <CheckCircle2 className="w-5 h-5 text-emerald-400" />
2678
+ : <Circle className="w-5 h-5" />
2679
+ }
2680
+ </button>
2681
+ <span className={\`text-sm font-medium truncate \${task.completed ? 'line-through text-slate-500' : 'text-slate-200'}\`}>
2682
+ {task.title}
2683
+ </span>
2684
+ <span className={\`flex-shrink-0 px-2 py-0.5 rounded-full text-xs font-medium \${priorityColor[task.priority] ?? ''}\`}>
2685
+ {task.priority}
2686
+ </span>
2687
+ </div>
2688
+ <button
2689
+ onClick={() => { void deleteTask(task.id); }}
2690
+ className="ml-3 p-1.5 text-slate-500 hover:text-red-400 transition rounded flex-shrink-0"
2691
+ >
2692
+ <Trash2 className="w-4 h-4" />
2693
+ </button>
2694
+ </div>
2695
+ ))
2696
+ )}
2697
+ </div>
2698
+ </div>
2699
+ );
2700
+ }
2701
+ `;
2702
+ }
2527
2703
  async function scaffoldFullstackReadme(projectDir, projectName) {
2528
2704
  const readme = `# ${projectName}
2529
2705
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kozojs/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "CLI to scaffold new Kozo Framework projects - The next-gen TypeScript Backend Framework",
5
5
  "bin": {
6
6
  "create-kozo": "./lib/index.js",