the best lightweight web dev stack built on bun

chore: biome format

dunkirk.sh 4ae31866 1c570f1e

verified
Changed files
+258 -208
src
+114 -114
cli.ts
··· 12 12 import { version } from "./package.json"; 13 13 14 14 async function main() { 15 - console.clear(); 15 + console.clear(); 16 16 17 - p.intro(`🥞 Tacy Stack Generator @ ${version}`); 17 + p.intro(`🥞 Tacy Stack Generator @ ${version}`); 18 18 19 - // Get project name 20 - const projectName = await p.text({ 21 - message: "What is your project name?", 22 - placeholder: "my-app", 23 - validate(value) { 24 - if (!value) return "Project name is required"; 25 - if (!/^[a-z0-9-_]+$/i.test(value)) { 26 - return "Project name can only contain letters, numbers, hyphens, and underscores"; 27 - } 28 - const targetDir = join(process.cwd(), value); 29 - if (existsSync(targetDir)) { 30 - return `Directory "${value}" already exists!`; 31 - } 32 - }, 33 - }); 19 + // Get project name 20 + const projectName = await p.text({ 21 + message: "What is your project name?", 22 + placeholder: "my-app", 23 + validate(value) { 24 + if (!value) return "Project name is required"; 25 + if (!/^[a-z0-9-_]+$/i.test(value)) { 26 + return "Project name can only contain letters, numbers, hyphens, and underscores"; 27 + } 28 + const targetDir = join(process.cwd(), value); 29 + if (existsSync(targetDir)) { 30 + return `Directory "${value}" already exists!`; 31 + } 32 + }, 33 + }); 34 34 35 - if (p.isCancel(projectName)) { 36 - p.cancel("Operation cancelled"); 37 - process.exit(0); 38 - } 35 + if (p.isCancel(projectName)) { 36 + p.cancel("Operation cancelled"); 37 + process.exit(0); 38 + } 39 39 40 - const targetDir = join(process.cwd(), projectName as string); 41 - const templateDir = import.meta.dir; 40 + const targetDir = join(process.cwd(), projectName as string); 41 + const templateDir = import.meta.dir; 42 42 43 - const s = p.spinner(); 43 + const s = p.spinner(); 44 44 45 - try { 46 - // Create directory 47 - s.start("Creating project directory"); 48 - mkdirSync(targetDir, { recursive: true }); 49 - await setTimeout(200); 50 - s.stop("Created project directory"); 45 + try { 46 + // Create directory 47 + s.start("Creating project directory"); 48 + mkdirSync(targetDir, { recursive: true }); 49 + await setTimeout(200); 50 + s.stop("Created project directory"); 51 51 52 - // Copy template files 53 - s.start("Copying template files"); 54 - await $`cp -r ${templateDir}/* ${targetDir}/`.quiet(); 52 + // Copy template files 53 + s.start("Copying template files"); 54 + await $`cp -r ${templateDir}/* ${targetDir}/`.quiet(); 55 55 56 - // Copy dotfiles explicitly 57 - const dotfiles = [".env.example", ".gitignore", ".gitattributes"]; 58 - for (const dotfile of dotfiles) { 59 - const source = join(templateDir, dotfile); 60 - const dest = join(targetDir, dotfile); 61 - if (existsSync(source)) { 62 - await $`cp ${source} ${dest}`.quiet(); 63 - } 64 - } 56 + // Copy dotfiles explicitly 57 + const dotfiles = [".env.example", ".gitignore", ".gitattributes"]; 58 + for (const dotfile of dotfiles) { 59 + const source = join(templateDir, dotfile); 60 + const dest = join(targetDir, dotfile); 61 + if (existsSync(source)) { 62 + await $`cp ${source} ${dest}`.quiet(); 63 + } 64 + } 65 65 66 - // Copy .github directory if it exists 67 - const githubDir = join(templateDir, ".github"); 68 - if (existsSync(githubDir)) { 69 - await $`cp -r ${githubDir} ${targetDir}/.github`.quiet(); 70 - } 66 + // Copy .github directory if it exists 67 + const githubDir = join(templateDir, ".github"); 68 + if (existsSync(githubDir)) { 69 + await $`cp -r ${githubDir} ${targetDir}/.github`.quiet(); 70 + } 71 71 72 - await setTimeout(200); 73 - s.stop("Copied template files"); 72 + await setTimeout(200); 73 + s.stop("Copied template files"); 74 74 75 - // Remove CLI and template files 76 - s.start("Cleaning up template files"); 77 - const filesToRemove = [ 78 - "cli.ts", 79 - "TEMPLATE.md", 80 - "TEMPLATE_SETUP_SUMMARY.md", 81 - "CLI_SUMMARY.md", 82 - "PUBLISHING.md", 83 - "template.toml", 84 - ".github/TEMPLATE_SETUP.md", 85 - ]; 75 + // Remove CLI and template files 76 + s.start("Cleaning up template files"); 77 + const filesToRemove = [ 78 + "cli.ts", 79 + "TEMPLATE.md", 80 + "TEMPLATE_SETUP_SUMMARY.md", 81 + "CLI_SUMMARY.md", 82 + "PUBLISHING.md", 83 + "template.toml", 84 + ".github/TEMPLATE_SETUP.md", 85 + ]; 86 86 87 - for (const file of filesToRemove) { 88 - const filePath = join(targetDir, file); 89 - if (existsSync(filePath)) { 90 - await $`rm -rf ${filePath}`.quiet(); 91 - } 92 - } 93 - await setTimeout(200); 94 - s.stop("Cleaned up template files"); 87 + for (const file of filesToRemove) { 88 + const filePath = join(targetDir, file); 89 + if (existsSync(filePath)) { 90 + await $`rm -rf ${filePath}`.quiet(); 91 + } 92 + } 93 + await setTimeout(200); 94 + s.stop("Cleaned up template files"); 95 95 96 - // Update package.json 97 - s.start("Configuring package.json"); 98 - const packageJsonPath = join(targetDir, "package.json"); 99 - const packageJson = await Bun.file(packageJsonPath).json(); 100 - packageJson.name = projectName; 101 - packageJson.version = "0.1.0"; 102 - delete packageJson.bin; 103 - // Remove @clack/prompts from dependencies since it's only for the CLI 104 - if (packageJson.dependencies?.["@clack/prompts"]) { 105 - delete packageJson.dependencies["@clack/prompts"]; 106 - } 107 - await Bun.write( 108 - packageJsonPath, 109 - JSON.stringify(packageJson, null, "\t") + "\n", 110 - ); 111 - await setTimeout(200); 112 - s.stop("Configured package.json"); 96 + // Update package.json 97 + s.start("Configuring package.json"); 98 + const packageJsonPath = join(targetDir, "package.json"); 99 + const packageJson = await Bun.file(packageJsonPath).json(); 100 + packageJson.name = projectName; 101 + packageJson.version = "0.1.0"; 102 + delete packageJson.bin; 103 + // Remove @clack/prompts from dependencies since it's only for the CLI 104 + if (packageJson.dependencies?.["@clack/prompts"]) { 105 + delete packageJson.dependencies["@clack/prompts"]; 106 + } 107 + await Bun.write( 108 + packageJsonPath, 109 + JSON.stringify(packageJson, null, "\t") + "\n", 110 + ); 111 + await setTimeout(200); 112 + s.stop("Configured package.json"); 113 113 114 - // Initialize git 115 - s.start("Initializing git repository"); 116 - await $`cd ${targetDir} && git init`.quiet(); 117 - await setTimeout(200); 118 - s.stop("Initialized git repository"); 114 + // Initialize git 115 + s.start("Initializing git repository"); 116 + await $`cd ${targetDir} && git init`.quiet(); 117 + await setTimeout(200); 118 + s.stop("Initialized git repository"); 119 119 120 - // Create .env 121 - s.start("Creating .env file"); 122 - await $`cd ${targetDir} && cp .env.example .env`.quiet(); 123 - await setTimeout(200); 124 - s.stop("Created .env file"); 120 + // Create .env 121 + s.start("Creating .env file"); 122 + await $`cd ${targetDir} && cp .env.example .env`.quiet(); 123 + await setTimeout(200); 124 + s.stop("Created .env file"); 125 125 126 - // Install dependencies 127 - s.start("Installing dependencies"); 128 - await $`cd ${targetDir} && bun install`.quiet(); 129 - s.stop("Installed dependencies"); 126 + // Install dependencies 127 + s.start("Installing dependencies"); 128 + await $`cd ${targetDir} && bun install`.quiet(); 129 + s.stop("Installed dependencies"); 130 130 131 - // Setup database 132 - s.start("Setting up database"); 133 - await $`cd ${targetDir} && bun run db:push`.quiet(); 134 - s.stop("Set up database"); 135 - } catch (error) { 136 - s.stop("Failed"); 137 - p.cancel( 138 - `Error: ${error instanceof Error ? error.message : "Unknown error"}`, 139 - ); 140 - process.exit(1); 141 - } 131 + // Setup database 132 + s.start("Setting up database"); 133 + await $`cd ${targetDir} && bun run db:push`.quiet(); 134 + s.stop("Set up database"); 135 + } catch (error) { 136 + s.stop("Failed"); 137 + p.cancel( 138 + `Error: ${error instanceof Error ? error.message : "Unknown error"}`, 139 + ); 140 + process.exit(1); 141 + } 142 142 143 - p.outro("🎉 Project created successfully!"); 143 + p.outro("🎉 Project created successfully!"); 144 144 145 - p.note(`cd ${projectName}\nbun dev`, "Next steps"); 145 + p.note(`cd ${projectName}\nbun dev`, "Next steps"); 146 146 } 147 147 148 148 main().catch((error) => { 149 - console.error(error); 150 - process.exit(1); 149 + console.error(error); 150 + process.exit(1); 151 151 });
+32 -32
package.json
··· 1 1 { 2 - "name": "tacy-stack", 3 - "version": "0.1.2", 4 - "module": "src/index.ts", 5 - "type": "module", 6 - "bin": { 7 - "tacy-stack": "./cli.ts" 8 - }, 9 - "scripts": { 10 - "dev": "bun run src/index.ts --hot", 11 - "test": "NODE_ENV=test bun test", 12 - "db:generate": "drizzle-kit generate", 13 - "db:push": "drizzle-kit push", 14 - "db:studio": "drizzle-kit studio" 15 - }, 16 - "devDependencies": { 17 - "@biomejs/biome": "^2.3.2", 18 - "@simplewebauthn/types": "^12.0.0", 19 - "@types/bun": "latest", 20 - "better-sqlite3": "^12.5.0", 21 - "drizzle-kit": "^0.30.1" 22 - }, 23 - "peerDependencies": { 24 - "typescript": "^5" 25 - }, 26 - "dependencies": { 27 - "@clack/prompts": "^0.11.0", 28 - "@simplewebauthn/browser": "^13.2.2", 29 - "@simplewebauthn/server": "^13.2.2", 30 - "drizzle-orm": "^0.38.3", 31 - "lit": "^3.3.1", 32 - "nanoid": "^5.1.6" 33 - } 2 + "name": "tacy-stack", 3 + "version": "0.1.2", 4 + "module": "src/index.ts", 5 + "type": "module", 6 + "bin": { 7 + "tacy-stack": "./cli.ts" 8 + }, 9 + "scripts": { 10 + "dev": "bun run src/index.ts --hot", 11 + "test": "NODE_ENV=test bun test", 12 + "db:generate": "drizzle-kit generate", 13 + "db:push": "drizzle-kit push", 14 + "db:studio": "drizzle-kit studio" 15 + }, 16 + "devDependencies": { 17 + "@biomejs/biome": "^2.3.2", 18 + "@simplewebauthn/types": "^12.0.0", 19 + "@types/bun": "latest", 20 + "better-sqlite3": "^12.5.0", 21 + "drizzle-kit": "^0.30.1" 22 + }, 23 + "peerDependencies": { 24 + "typescript": "^5" 25 + }, 26 + "dependencies": { 27 + "@clack/prompts": "^0.11.0", 28 + "@simplewebauthn/browser": "^13.2.2", 29 + "@simplewebauthn/server": "^13.2.2", 30 + "drizzle-orm": "^0.38.3", 31 + "lit": "^3.3.1", 32 + "nanoid": "^5.1.6" 33 + } 34 34 }
+32 -19
src/components/auth.ts
··· 286 286 // Reload to update counter 287 287 window.location.reload(); 288 288 } catch (error) { 289 - this.error = error instanceof Error ? error.message : "Registration failed"; 289 + this.error = 290 + error instanceof Error ? error.message : "Registration failed"; 290 291 } finally { 291 292 this.isSubmitting = false; 292 293 } ··· 309 310 310 311 return html` 311 312 <div class="auth-container"> 312 - ${this.user 313 - ? html` 313 + ${ 314 + this.user 315 + ? html` 314 316 <button class="auth-button" @click=${this.handleLogout}> 315 317 <div class="user-info"> 316 318 <img ··· 322 324 </div> 323 325 </button> 324 326 ` 325 - : html` 327 + : html` 326 328 <button class="auth-button" @click=${() => (this.showModal = true)}> 327 329 Sign In 328 330 </button> 329 - `} 330 - ${this.showModal 331 - ? html` 331 + ` 332 + } 333 + ${ 334 + this.showModal 335 + ? html` 332 336 <div class="modal-overlay" @click=${() => { 333 337 this.showModal = false; 334 338 this.showRegisterForm = false; ··· 336 340 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 337 341 <h2>Welcome</h2> 338 342 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 339 - ${!this.passkeySupported 340 - ? html` 343 + ${ 344 + !this.passkeySupported 345 + ? html` 341 346 <div class="error"> 342 347 Passkeys are not supported in this browser. 343 348 </div> 344 349 ` 345 - : ""} 346 - ${this.showRegisterForm 347 - ? html` 350 + : "" 351 + } 352 + ${ 353 + this.showRegisterForm 354 + ? html` 348 355 <div class="form-group"> 349 356 <label for="username">Username</label> 350 357 <input ··· 353 360 placeholder="Choose a username" 354 361 .value=${this.username} 355 362 @input=${(e: Event) => 356 - (this.username = (e.target as HTMLInputElement).value)} 363 + (this.username = ( 364 + e.target as HTMLInputElement 365 + ).value)} 357 366 ?disabled=${this.isSubmitting} 358 367 /> 359 368 </div> ··· 371 380 </button> 372 381 <button 373 382 @click=${this.handleRegister} 374 - ?disabled=${this.isSubmitting || 375 - !this.username.trim() || 376 - !this.passkeySupported} 383 + ?disabled=${ 384 + this.isSubmitting || 385 + !this.username.trim() || 386 + !this.passkeySupported 387 + } 377 388 > 378 389 Register 379 390 </button> 380 391 </div> 381 392 ` 382 - : html` 393 + : html` 383 394 <div class="button-group"> 384 395 <button 385 396 @click=${this.handleLogin} ··· 395 406 Register 396 407 </button> 397 408 </div> 398 - `} 409 + ` 410 + } 399 411 </div> 400 412 </div> 401 413 ` 402 - : ""} 414 + : "" 415 + } 403 416 </div> 404 417 `; 405 418 }
+37 -18
src/index.ts
··· 37 37 try { 38 38 const sessionId = getSessionFromRequest(req); 39 39 if (!sessionId) { 40 - return new Response(JSON.stringify({ error: "Not authenticated" }), { 41 - status: 401, 42 - }); 40 + return new Response( 41 + JSON.stringify({ error: "Not authenticated" }), 42 + { 43 + status: 401, 44 + }, 45 + ); 43 46 } 44 47 45 48 const user = getUserBySession(sessionId); ··· 70 73 const username = url.searchParams.get("username"); 71 74 72 75 if (!username) { 73 - return new Response(JSON.stringify({ error: "Username required" }), { 74 - status: 400, 75 - }); 76 + return new Response( 77 + JSON.stringify({ error: "Username required" }), 78 + { 79 + status: 400, 80 + }, 81 + ); 76 82 } 77 83 78 84 const existing = getUserByUsername(username); ··· 105 111 106 112 if (!username || !credential || !challenge) { 107 113 return new Response( 108 - JSON.stringify({ error: "Username, credential, and challenge required" }), 114 + JSON.stringify({ 115 + error: "Username, credential, and challenge required", 116 + }), 109 117 { status: 400 }, 110 118 ); 111 119 } ··· 180 188 const username = url.searchParams.get("username"); 181 189 182 190 if (!username) { 183 - return new Response(JSON.stringify({ error: "Username required" }), { 184 - status: 400, 185 - }); 191 + return new Response( 192 + JSON.stringify({ error: "Username required" }), 193 + { 194 + status: 400, 195 + }, 196 + ); 186 197 } 187 198 188 199 // Create temporary user object for registration options ··· 209 220 } 210 221 }, 211 222 }, 212 - 213 - 214 223 215 224 "/api/auth/passkey/authenticate/options": { 216 225 GET: async (req) => { ··· 244 253 ); 245 254 } 246 255 247 - const { userId } = await verifyAndAuthenticatePasskey(credential, challenge); 256 + const { userId } = await verifyAndAuthenticatePasskey( 257 + credential, 258 + challenge, 259 + ); 248 260 249 261 const user = getUserBySession( 250 - createSession(userId, req.headers.get("x-forwarded-for") || undefined), 262 + createSession( 263 + userId, 264 + req.headers.get("x-forwarded-for") || undefined, 265 + ), 251 266 ); 252 267 253 268 if (!user) { ··· 288 303 } catch (error) { 289 304 return new Response( 290 305 JSON.stringify({ 291 - error: error instanceof Error ? error.message : "Not authenticated", 306 + error: 307 + error instanceof Error ? error.message : "Not authenticated", 292 308 }), 293 309 { status: 401 }, 294 310 ); ··· 308 324 } catch (error) { 309 325 return new Response( 310 326 JSON.stringify({ 311 - error: error instanceof Error ? error.message : "Not authenticated", 327 + error: 328 + error instanceof Error ? error.message : "Not authenticated", 312 329 }), 313 330 { status: 401 }, 314 331 ); ··· 328 345 } catch (error) { 329 346 return new Response( 330 347 JSON.stringify({ 331 - error: error instanceof Error ? error.message : "Not authenticated", 348 + error: 349 + error instanceof Error ? error.message : "Not authenticated", 332 350 }), 333 351 { status: 401 }, 334 352 ); ··· 348 366 } catch (error) { 349 367 return new Response( 350 368 JSON.stringify({ 351 - error: error instanceof Error ? error.message : "Not authenticated", 369 + error: 370 + error instanceof Error ? error.message : "Not authenticated", 352 371 }), 353 372 { status: 401 }, 354 373 );
+23 -10
src/lib/auth.ts
··· 29 29 const sessionId = crypto.randomUUID(); 30 30 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION; 31 31 32 - db.insert(sessions).values({ 33 - id: sessionId, 34 - user_id: userId, 35 - ip_address: ipAddress ?? null, 36 - user_agent: userAgent ?? null, 37 - expires_at: new Date(expiresAt * 1000), 38 - }).run(); 32 + db.insert(sessions) 33 + .values({ 34 + id: sessionId, 35 + user_id: userId, 36 + ip_address: ipAddress ?? null, 37 + user_agent: userAgent ?? null, 38 + expires_at: new Date(expiresAt * 1000), 39 + }) 40 + .run(); 39 41 40 42 return sessionId; 41 43 } ··· 67 69 const session = getSession(sessionId); 68 70 if (!session) return null; 69 71 70 - const user = db.select().from(users).where(eq(users.id, session.user_id)).get(); 72 + const user = db 73 + .select() 74 + .from(users) 75 + .where(eq(users.id, session.user_id)) 76 + .get(); 71 77 72 78 if (!user) return null; 73 79 ··· 81 87 } 82 88 83 89 export function getUserByUsername(username: string): User | null { 84 - const user = db.select().from(users).where(eq(users.username, username)).get(); 90 + const user = db 91 + .select() 92 + .from(users) 93 + .where(eq(users.username, username)) 94 + .get(); 85 95 86 96 if (!user) return null; 87 97 ··· 98 108 db.delete(sessions).where(eq(sessions.id, sessionId)).run(); 99 109 } 100 110 101 - export async function createUser(username: string, name?: string): Promise<User> { 111 + export async function createUser( 112 + username: string, 113 + name?: string, 114 + ): Promise<User> { 102 115 // Generate deterministic avatar from username 103 116 const encoder = new TextEncoder(); 104 117 const data = encoder.encode(username.toLowerCase());
+20 -15
src/lib/passkey.ts
··· 155 155 // Store the passkey 156 156 const passkeyId = crypto.randomUUID(); 157 157 const credentialIdBase64 = credential.id; // Already base64url in v13 158 - const publicKeyBase64 = Buffer.from(credential.publicKey).toString("base64url"); 158 + const publicKeyBase64 = Buffer.from(credential.publicKey).toString( 159 + "base64url", 160 + ); 159 161 const transports = response.response.transports?.join(",") || null; 160 162 161 - db.insert(passkeys).values({ 162 - id: passkeyId, 163 - user_id: userId, 164 - credential_id: credentialIdBase64, 165 - public_key: publicKeyBase64, 166 - counter: credential.counter, 167 - transports, 168 - name: null, 169 - }).run(); 163 + db.insert(passkeys) 164 + .values({ 165 + id: passkeyId, 166 + user_id: userId, 167 + credential_id: credentialIdBase64, 168 + public_key: publicKeyBase64, 169 + counter: credential.counter, 170 + transports, 171 + name: null, 172 + }) 173 + .run(); 170 174 171 - const passkey = db.select().from(passkeys).where(eq(passkeys.id, passkeyId)).get(); 175 + const passkey = db 176 + .select() 177 + .from(passkeys) 178 + .where(eq(passkeys.id, passkeyId)) 179 + .get(); 172 180 173 181 if (!passkey) { 174 182 throw new Error("Failed to create passkey"); ··· 307 315 * Delete a passkey 308 316 */ 309 317 export function deletePasskey(passkeyId: string, userId: number): boolean { 310 - const result = db 311 - .delete(passkeys) 312 - .where(eq(passkeys.id, passkeyId)) 313 - .run(); 318 + const result = db.delete(passkeys).where(eq(passkeys.id, passkeyId)).run(); 314 319 315 320 return result.changes > 0; 316 321 }