+114
-114
cli.ts
+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
+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
+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
-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
+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
+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
}