create-basalt 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1342 +0,0 @@
1
- // src/index.ts
2
- import { mkdir, readdir, writeFile } from "fs/promises";
3
- import { dirname, join, resolve } from "path";
4
-
5
- // src/templates.ts
6
- var BASALT_VERSION = "^1.0.0";
7
- var VERSIONS = {};
8
- var versionOf = (pkg) => VERSIONS[pkg] ?? BASALT_VERSION;
9
- function packageJson(options) {
10
- const dependencies = {
11
- "@basaltkit/config": BASALT_VERSION,
12
- "@basaltkit/core": BASALT_VERSION,
13
- "@basaltkit/env": BASALT_VERSION,
14
- "@basaltkit/events": BASALT_VERSION,
15
- "@basaltkit/fastify": BASALT_VERSION,
16
- "@basaltkit/logger": BASALT_VERSION,
17
- zod: "^4.0.0"
18
- };
19
- if (options.tenancy) dependencies["@basaltkit/tenancy"] = BASALT_VERSION;
20
- if (options.auth) dependencies["@basaltkit/auth"] = BASALT_VERSION;
21
- if (options.billing) dependencies["@basaltkit/subscriptions"] = BASALT_VERSION;
22
- if (options.cli) {
23
- dependencies["@basaltkit/cli"] = BASALT_VERSION;
24
- dependencies["@basaltkit/prisma"] = BASALT_VERSION;
25
- }
26
- for (const pkg of Object.keys(dependencies)) {
27
- if (pkg.startsWith("@basaltkit/")) dependencies[pkg] = versionOf(pkg);
28
- }
29
- const devDependencies = {
30
- "@basaltkit/testing": versionOf("@basaltkit/testing"),
31
- "@types/node": "^22.15.0",
32
- "pino-pretty": "^13.0.0",
33
- tsx: "^4.19.0",
34
- typescript: "^5.8.0",
35
- vitest: "^3.1.0"
36
- };
37
- if (options.cli) {
38
- devDependencies["@basaltkit/generator"] = versionOf("@basaltkit/generator");
39
- }
40
- return `${JSON.stringify(
41
- {
42
- name: options.name,
43
- version: "0.1.0",
44
- private: true,
45
- type: "module",
46
- scripts: {
47
- dev: "tsx watch src/server.ts",
48
- start: "tsx src/server.ts",
49
- test: "vitest run",
50
- typecheck: "tsc --noEmit",
51
- ...options.cli ? { basalt: "tsx bin/basalt.ts" } : {}
52
- },
53
- dependencies: Object.fromEntries(Object.entries(dependencies).sort()),
54
- devDependencies: Object.fromEntries(Object.entries(devDependencies).sort())
55
- },
56
- null,
57
- 2
58
- )}
59
- `;
60
- }
61
- function tsconfigJson() {
62
- return `${JSON.stringify(
63
- {
64
- compilerOptions: {
65
- target: "ES2022",
66
- module: "NodeNext",
67
- moduleResolution: "NodeNext",
68
- strict: true,
69
- noUncheckedIndexedAccess: true,
70
- verbatimModuleSyntax: true,
71
- skipLibCheck: true,
72
- noEmit: true,
73
- types: ["node"]
74
- },
75
- include: ["src", "tests"]
76
- },
77
- null,
78
- 2
79
- )}
80
- `;
81
- }
82
- function envTs(options) {
83
- return `import { defineEnv${options.auth ? ", secret" : ""} } from '@basaltkit/env'
84
- import { z } from 'zod'
85
-
86
- export const env = defineEnv({
87
- PORT: z.coerce.number().default(3000),
88
- HOST: z.string().default('0.0.0.0'),
89
- LOG_LEVEL: z.string().default('info'),
90
- NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),${options.auth ? `
91
- // Signs JWTs and sessions. secret() is fail-closed: required in production
92
- // (no fallback), rejected if it looks like a placeholder. Dev uses devDefault.
93
- APP_SECRET: secret({ minLength: 32, devDefault: 'dev-only-insecure-secret-please-change-me' }),` : ""}
94
- })
95
- `;
96
- }
97
- function envExample(options) {
98
- return `PORT=3000
99
- HOST=0.0.0.0
100
- LOG_LEVEL=info
101
- NODE_ENV=development
102
- ${options.auth ? "# Required in production (the app refuses to boot without it).\n# Generate a strong one: openssl rand -base64 48\n# APP_SECRET=\n" : ""}`;
103
- }
104
- function appTs(options) {
105
- const imports = [
106
- `import { createApp } from '@basaltkit/core'`,
107
- `import { configPlugin } from '@basaltkit/config'`,
108
- `import { eventsPlugin } from '@basaltkit/events'`,
109
- `import { fastifyPlugin, securityPlugin } from '@basaltkit/fastify'`,
110
- `import { loggerPlugin } from '@basaltkit/logger'`
111
- ];
112
- const plugins = [
113
- `configPlugin({ app: { name: '${options.name}' } })`,
114
- `loggerPlugin({ level: options.logLevel ?? 'info', ...(options.pretty ? { pretty: true } : {}) })`,
115
- `eventsPlugin()`,
116
- `securityPlugin({
117
- // Secure response headers (HSTS, X-Frame-Options, nosniff, \u2026) are on by
118
- // default. Turn on rate limiting and a CORS allow-list for production:
119
- // rateLimit: { limit: 120, windowMs: 60_000 },
120
- // cors: { origin: ['https://app.example.com'], credentials: true },
121
- })`
122
- ];
123
- let routesExpression = "appRoutes";
124
- if (options.tenancy) {
125
- imports.push(
126
- `import { headerResolver, MemoryTenantSource, subdomainResolver, tenancyPlugin } from '@basaltkit/tenancy'`
127
- );
128
- plugins.push(`tenancyPlugin({
129
- // Replace MemoryTenantSource with your database-backed source.
130
- source: new MemoryTenantSource().add({ id: 'demo', name: 'Demo Tenant' }),
131
- resolvers: [headerResolver(), subdomainResolver({ base: 'localhost' })],
132
- })`);
133
- }
134
- if (options.auth) {
135
- imports.push(`import { authPlugin, authRoutes, mfaRoutes, MemoryUserSource } from '@basaltkit/auth'`);
136
- imports.push(`import { env } from './env.js'`);
137
- plugins.push(`authPlugin({
138
- // Replace MemoryUserSource with your database-backed source (e.g.
139
- // @basaltkit/auth-sqlite or @basaltkit/auth-prisma). The default in-memory
140
- // MFA store is enough for the ready-made TOTP flow below.
141
- users: new MemoryUserSource(),
142
- secret: env.APP_SECRET,
143
- })`);
144
- routesExpression = "[...appRoutes, ...authRoutes(), ...mfaRoutes()]";
145
- }
146
- if (options.billing) {
147
- imports.push(`import { definePlans, subscriptionsPlugin } from '@basaltkit/subscriptions'`);
148
- plugins.push(`subscriptionsPlugin({
149
- plans: definePlans({
150
- free: { price: 0, features: { projects: 3 } },
151
- pro: { price: { monthly: 29, yearly: 290 }, trial: '14d', features: { projects: 50 } },
152
- }),
153
- fallbackPlan: 'free',
154
- })`);
155
- }
156
- if (options.cli) {
157
- imports.push(`import { commandsPlugin, type CommandDefinition } from '@basaltkit/cli'`);
158
- plugins.push(`...(options.commands && options.commands.length > 0 ? [commandsPlugin(options.commands)] : [])`);
159
- }
160
- plugins.push(`fastifyPlugin({ routes: ${routesExpression} })`);
161
- const commandsField = options.cli ? `
162
- /** Dev/CLI commands (make:*, ai:*, prisma:sync) \u2014 passed ONLY by bin/basalt.ts. */
163
- commands?: CommandDefinition[]` : "";
164
- return `${imports.join("\n")}
165
- import { appRoutes } from './routes.js'
166
-
167
- export interface BuildAppOptions {
168
- logLevel?: string
169
- pretty?: boolean${commandsField}
170
- }
171
-
172
- export function buildApp(options: BuildAppOptions = {}) {
173
- return createApp({
174
- plugins: [
175
- ${plugins.join(",\n ")},
176
- ],
177
- })
178
- }
179
- `;
180
- }
181
- function routesTs(options) {
182
- const endpoints = [
183
- "'GET /'",
184
- "'GET /health'",
185
- ...options.auth ? [
186
- "'POST /auth/register'",
187
- "'POST /auth/login'",
188
- "'POST /auth/refresh'",
189
- "'POST /auth/logout'",
190
- "'GET /auth/me'"
191
- ] : []
192
- ];
193
- return `import { ctx } from '@basaltkit/core'
194
- import { route } from '@basaltkit/fastify'
195
-
196
- export const appRoutes = [
197
- // Friendly index so \`GET /\` is never a bare 404 \u2014 lists what the API exposes.
198
- route({
199
- method: 'GET',
200
- url: '/',
201
- async handler() {
202
- return {
203
- name: '${options.name}',
204
- status: 'ok',
205
- endpoints: [${endpoints.join(", ")}],
206
- }
207
- },
208
- }),
209
- route({
210
- method: 'GET',
211
- url: '/health',
212
- async handler() {
213
- return { ok: true, requestId: ctx().requestId${options.tenancy ? ", tenant: ctx().tenant?.id ?? null" : ""} }
214
- },
215
- }),
216
- ]
217
- `;
218
- }
219
- function serverTs() {
220
- return `import { FASTIFY } from '@basaltkit/fastify'
221
- import { buildApp } from './app.js'
222
- import { env } from './env.js'
223
-
224
- const app = await buildApp({
225
- logLevel: env.LOG_LEVEL,
226
- pretty: env.NODE_ENV === 'development',
227
- }).boot()
228
-
229
- const server = app.container.get(FASTIFY)
230
- await server.listen({ port: env.PORT, host: env.HOST })
231
- console.log('ready at http://' + env.HOST + ':' + env.PORT)
232
-
233
- for (const signal of ['SIGINT', 'SIGTERM'] as const) {
234
- process.once(signal, async () => {
235
- await app.shutdown()
236
- process.exit(0)
237
- })
238
- }
239
- `;
240
- }
241
- function basaltBin() {
242
- return `#!/usr/bin/env node
243
- import { runCli } from '@basaltkit/cli'
244
- import { generatorCommands } from '@basaltkit/generator'
245
- import { prismaSyncCommand } from '@basaltkit/prisma'
246
- import { buildApp } from '../src/app.js'
247
-
248
- // The 'basalt' CLI: boots the app WITH the dev/CLI commands, runs one, shuts down.
249
- // The dev tools (@basaltkit/generator; add @basaltkit/ai for ai:*) are imported
250
- // ONLY here \u2014 the runtime server (src/server.ts) never loads them, so the SaaS
251
- // runs without the codegen/AI layer.
252
- // pnpm basalt dev \u2014 dev server: route table + watch (--worker for a queue worker)
253
- // pnpm basalt list \u2014 show available commands
254
- // pnpm basalt routes \u2014 list registered HTTP routes
255
- // pnpm basalt make:resource Project \u2014 generate a full resource vertical
256
- // pnpm basalt make:service Project \u2014 generate a single artifact (schema/service/\u2026)
257
- const app = buildApp({
258
- logLevel: 'silent',
259
- commands: [...generatorCommands(), prismaSyncCommand()],
260
- })
261
- process.exit(await runCli({ app }))
262
- `;
263
- }
264
- function appTest(options) {
265
- return `import { describe, expect, it } from 'vitest'
266
- import { FASTIFY } from '@basaltkit/fastify'
267
- import { buildApp } from '../src/app.js'
268
-
269
- describe('app', () => {
270
- it('boots and responds on /health', async () => {
271
- const app = await buildApp({ logLevel: 'silent' }).boot()
272
- const server = app.container.get(FASTIFY)
273
-
274
- const index = await server.inject({ method: 'GET', url: '/' })
275
- expect(index.statusCode).toBe(200)
276
- expect(index.json().name).toBe('${options.name}')
277
-
278
- const response = await server.inject({ method: 'GET', url: '/health' })
279
- expect(response.statusCode).toBe(200)
280
- expect(response.json().ok).toBe(true)${options.tenancy ? `
281
-
282
- const tenant = await server.inject({
283
- method: 'GET',
284
- url: '/health',
285
- headers: { 'x-tenant-id': 'demo' },
286
- })
287
- expect(tenant.json().tenant).toBe('demo')` : ""}
288
- await app.shutdown()
289
- })
290
- })
291
- `;
292
- }
293
- function readme(options) {
294
- const features = [
295
- "typed routes with Zod validation",
296
- "structured logging with request/tenant context",
297
- "typed domain events",
298
- ...options.tenancy ? ["multi-tenancy (header + subdomain resolvers)"] : [],
299
- ...options.auth ? ["authentication (register/login/refresh/me)"] : [],
300
- ...options.billing ? ["subscriptions with plans and feature limits"] : [],
301
- ...options.ui ? ["web UI (React + shadcn/ui on @basaltkit/admin-shadcn + @basaltkit/sdk)"] : [],
302
- ...options.cli ? ["`basalt` CLI with code generators (`make:*`) and built-in commands"] : []
303
- ];
304
- return `# ${options.name}
305
-
306
- A SaaS app scaffolded with [Basalt](https://github.com/basaltkit/basalt).
307
-
308
- Included: ${features.join(" \xB7 ")}.
309
-
310
- ## Getting started
311
-
312
- \`\`\`bash
313
- pnpm install
314
- pnpm dev # API on http://localhost:3000
315
- pnpm test
316
- \`\`\`
317
- ${options.cli ? `
318
- ## The \`basalt\` CLI
319
-
320
- \`\`\`bash
321
- pnpm basalt list # available commands
322
- pnpm basalt routes # registered HTTP routes
323
- pnpm basalt make:resource Project # schema \u2192 repository \u2192 service \u2192 plugin \u2192 routes \u2192 test
324
- pnpm basalt make:service Project # a single artifact (--force to overwrite)
325
- \`\`\`
326
-
327
- Generated resources land in \`src/modules/<name>/\`. Register the generated
328
- plugin in \`src/app.ts\` to wire it up.
329
- ` : ""}${options.ui ? `
330
- ## Web UI
331
-
332
- \`\`\`bash
333
- pnpm dev # terminal 1 \u2014 API on :3000
334
- pnpm --filter ${options.name}-web dev # terminal 2 \u2014 UI on http://localhost:5180
335
- \`\`\`
336
-
337
- Open <http://localhost:5180>. The Vite dev server proxies \`/api\` to the API,
338
- so there is no CORS to configure${options.auth ? ". Register, then sign in" : ""}.
339
- ` : ""}`;
340
- }
341
- function gitignore() {
342
- return `node_modules/
343
- dist/
344
- .env
345
- .env.*
346
- !.env.example
347
- *.log
348
- `;
349
- }
350
- function pnpmWorkspaceYaml(options) {
351
- return `${options.ui ? `packages:
352
- - web
353
- ` : ""}allowBuilds:
354
- esbuild: true
355
- msgpackr-extract: false
356
- # @basaltkit/* releases in lockstep, often within hours \u2014 exclude the scope from
357
- # pnpm's minimumReleaseAge policy so \`pnpm up\` is never blocked on a fresh release.
358
- minimumReleaseAgeExclude:
359
- - '@basaltkit/*'
360
- `;
361
- }
362
-
363
- // src/templates-ui.ts
364
- var BASALT_VERSION2 = "^1.0.0";
365
- function uiFiles(options) {
366
- return {
367
- "web/package.json": webPackageJson(options),
368
- "web/vite.config.ts": webViteConfig(),
369
- "web/tsconfig.json": webTsconfig(),
370
- "web/index.html": webIndexHtml(options),
371
- "web/postcss.config.js": webPostcss(),
372
- "web/tailwind.config.js": webTailwind(),
373
- "web/src/main.tsx": webMain(),
374
- "web/src/crypto-shim.ts": webCryptoShim(),
375
- "web/src/index.css": webCss(),
376
- "web/src/api.ts": webApi(options),
377
- "web/src/App.tsx": webApp(options)
378
- };
379
- }
380
- function webPackageJson(options) {
381
- return `${JSON.stringify(
382
- {
383
- name: `${options.name}-web`,
384
- private: true,
385
- type: "module",
386
- scripts: { dev: "vite", build: "vite build", preview: "vite preview" },
387
- dependencies: {
388
- "@basaltkit/admin": BASALT_VERSION2,
389
- "@basaltkit/admin-shadcn": BASALT_VERSION2,
390
- "@basaltkit/sdk": BASALT_VERSION2,
391
- react: "^18.3.1",
392
- "react-dom": "^18.3.1",
393
- zod: "^3.24.0"
394
- },
395
- devDependencies: {
396
- "@types/react": "^18.3.0",
397
- "@types/react-dom": "^18.3.0",
398
- "@vitejs/plugin-react": "^4.3.0",
399
- autoprefixer: "^10.4.0",
400
- postcss: "^8.4.0",
401
- tailwindcss: "^3.4.0",
402
- typescript: "^5.8.0",
403
- vite: "^6.0.0"
404
- }
405
- },
406
- null,
407
- 2
408
- )}
409
- `;
410
- }
411
- function webViteConfig() {
412
- return `import { fileURLToPath } from 'node:url'
413
- import react from '@vitejs/plugin-react'
414
- import { defineConfig } from 'vite'
415
-
416
- export default defineConfig({
417
- plugins: [react()],
418
- resolve: {
419
- alias: {
420
- // @basaltkit/admin uses randomUUID from crypto; map it to Web Crypto in the browser.
421
- crypto: fileURLToPath(new URL('./src/crypto-shim.ts', import.meta.url)),
422
- },
423
- },
424
- server: {
425
- port: 5180,
426
- // Proxy API calls to the backend so there is no CORS in dev.
427
- proxy: {
428
- '/api': {
429
- target: 'http://localhost:3000',
430
- changeOrigin: true,
431
- rewrite: (path) => path.replace(/^\\/api/, ''),
432
- },
433
- },
434
- },
435
- })
436
- `;
437
- }
438
- function webTsconfig() {
439
- return `${JSON.stringify(
440
- {
441
- compilerOptions: {
442
- target: "ES2022",
443
- lib: ["ES2022", "DOM", "DOM.Iterable"],
444
- module: "ESNext",
445
- moduleResolution: "Bundler",
446
- jsx: "react-jsx",
447
- strict: true,
448
- noUncheckedIndexedAccess: true,
449
- skipLibCheck: true,
450
- noEmit: true,
451
- allowImportingTsExtensions: true,
452
- verbatimModuleSyntax: true,
453
- types: ["node"]
454
- },
455
- include: ["src"]
456
- },
457
- null,
458
- 2
459
- )}
460
- `;
461
- }
462
- function webIndexHtml(options) {
463
- return `<!doctype html>
464
- <html lang="en">
465
- <head>
466
- <meta charset="UTF-8" />
467
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
468
- <title>${options.name}</title>
469
- </head>
470
- <body>
471
- <div id="root"></div>
472
- <script type="module" src="/src/main.tsx"></script>
473
- </body>
474
- </html>
475
- `;
476
- }
477
- function webPostcss() {
478
- return `export default {
479
- plugins: {
480
- tailwindcss: {},
481
- autoprefixer: {},
482
- },
483
- }
484
- `;
485
- }
486
- function webTailwind() {
487
- return `export default {
488
- darkMode: ['class'],
489
- content: ['./index.html', './src/**/*.{ts,tsx}', './node_modules/@basaltkit/admin-shadcn/dist/**/*.js'],
490
- theme: {
491
- extend: {
492
- colors: {
493
- border: 'hsl(var(--border))',
494
- input: 'hsl(var(--input))',
495
- ring: 'hsl(var(--ring))',
496
- background: 'hsl(var(--background))',
497
- foreground: 'hsl(var(--foreground))',
498
- primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
499
- secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
500
- destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
501
- muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
502
- accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
503
- card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
504
- },
505
- borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' },
506
- },
507
- },
508
- plugins: [],
509
- }
510
- `;
511
- }
512
- function webMain() {
513
- return `import { StrictMode } from 'react'
514
- import { createRoot } from 'react-dom/client'
515
- import { App } from './App.js'
516
- import './index.css'
517
-
518
- createRoot(document.getElementById('root') as HTMLElement).render(
519
- <StrictMode>
520
- <App />
521
- </StrictMode>,
522
- )
523
- `;
524
- }
525
- function webCryptoShim() {
526
- return `export const randomUUID = (): string => globalThis.crypto.randomUUID()
527
- `;
528
- }
529
- function webCss() {
530
- return `@tailwind base;
531
- @tailwind components;
532
- @tailwind utilities;
533
-
534
- @layer base {
535
- :root {
536
- --background: 240 20% 99%;
537
- --foreground: 240 10% 3.9%;
538
- --card: 0 0% 100%;
539
- --card-foreground: 240 10% 3.9%;
540
- --primary: 243 75% 59%;
541
- --primary-foreground: 0 0% 100%;
542
- --secondary: 240 4.8% 95.9%;
543
- --secondary-foreground: 240 5.9% 10%;
544
- --muted: 240 4.8% 95.9%;
545
- --muted-foreground: 240 3.8% 46.1%;
546
- --accent: 243 75% 96%;
547
- --accent-foreground: 243 60% 40%;
548
- --destructive: 0 72% 51%;
549
- --destructive-foreground: 0 0% 100%;
550
- --border: 240 5.9% 90%;
551
- --input: 240 5.9% 88%;
552
- --ring: 243 75% 59%;
553
- --radius: 0.6rem;
554
- }
555
- .dark {
556
- --background: 240 10% 6%;
557
- --foreground: 0 0% 98%;
558
- --card: 240 10% 9%;
559
- --card-foreground: 0 0% 98%;
560
- --primary: 243 75% 68%;
561
- --primary-foreground: 240 10% 6%;
562
- --secondary: 240 3.7% 15.9%;
563
- --secondary-foreground: 0 0% 98%;
564
- --muted: 240 3.7% 15.9%;
565
- --muted-foreground: 240 5% 64.9%;
566
- --accent: 243 40% 22%;
567
- --accent-foreground: 243 75% 85%;
568
- --destructive: 0 72% 55%;
569
- --destructive-foreground: 0 0% 98%;
570
- --border: 240 3.7% 16%;
571
- --input: 240 3.7% 18%;
572
- --ring: 243 75% 68%;
573
- }
574
- * {
575
- border-color: hsl(var(--border));
576
- }
577
- body {
578
- margin: 0;
579
- background-color: hsl(var(--background));
580
- color: hsl(var(--foreground));
581
- font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
582
- -webkit-font-smoothing: antialiased;
583
- }
584
- }
585
- `;
586
- }
587
- function webApi(options) {
588
- const authEndpoints = options.auth ? `
589
- register: endpoint({ method: 'POST', path: '/auth/register', body: credentials, result: User }),
590
- login: endpoint({
591
- method: 'POST',
592
- path: '/auth/login',
593
- body: credentials.extend({ mfaCode: z.string().optional() }),
594
- result: z.object({ accessToken: z.string(), refreshToken: z.string(), user: User }),
595
- }),
596
- me: endpoint({ method: 'GET', path: '/auth/me', result: User }),
597
- // Password recovery
598
- forgotPassword: endpoint({
599
- method: 'POST',
600
- path: '/auth/password/forgot',
601
- body: z.object({ email: z.string().email() }),
602
- result: z.object({ ok: z.boolean() }),
603
- }),
604
- resetPassword: endpoint({
605
- method: 'POST',
606
- path: '/auth/password/reset',
607
- body: z.object({ token: z.string(), password: z.string().min(8) }),
608
- result: z.object({ ok: z.boolean() }),
609
- }),
610
- // TOTP / two-factor
611
- mfaStatus: endpoint({
612
- method: 'GET',
613
- path: '/auth/mfa/status',
614
- result: z.object({ enabled: z.boolean(), pending: z.boolean() }),
615
- }),
616
- mfaEnroll: endpoint({
617
- method: 'POST',
618
- path: '/auth/mfa/enroll',
619
- result: z.object({ secret: z.string(), otpauthUri: z.string() }),
620
- }),
621
- mfaActivate: endpoint({
622
- method: 'POST',
623
- path: '/auth/mfa/activate',
624
- body: z.object({ code: z.string().min(6) }),
625
- result: z.object({ recoveryCodes: z.array(z.string()) }),
626
- }),
627
- mfaDisable: endpoint({
628
- method: 'POST',
629
- path: '/auth/mfa/disable',
630
- body: z.object({ code: z.string().min(6) }),
631
- result: z.unknown(),
632
- }),` : "";
633
- const credentials = options.auth ? `
634
- const credentials = z.object({ email: z.string().email(), password: z.string().min(8) })` : "";
635
- const user = options.auth ? `
636
- const User = z.object({ id: z.string(), email: z.string() })` : "";
637
- return `import { createClient, endpoint, type Client as SdkClient } from '@basaltkit/sdk'
638
- import { z } from 'zod'
639
- ${user}${credentials}
640
-
641
- const Index = z.object({ name: z.string(), status: z.string(), endpoints: z.array(z.string()) })
642
- const Health = z.object({ ok: z.boolean() })
643
-
644
- export const endpoints = {
645
- index: endpoint({ method: 'GET', path: '/', result: Index }),
646
- health: endpoint({ method: 'GET', path: '/health', result: Health }),${authEndpoints}
647
- }
648
-
649
- export type Api = SdkClient<typeof endpoints>
650
-
651
- export function makeApi(getToken: () => string | undefined = () => undefined): Api {
652
- return createClient(endpoints, { baseUrl: '/api', getToken })
653
- }
654
- `;
655
- }
656
- function webApp(options) {
657
- return options.auth ? webAppWithAuth(options) : webAppStatusOnly(options);
658
- }
659
- function webAppStatusOnly(options) {
660
- return `import { useEffect, useState } from 'react'
661
- import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from '@basaltkit/admin-shadcn'
662
- import { makeApi } from './api.js'
663
-
664
- const api = makeApi()
665
-
666
- export function App() {
667
- const [name, setName] = useState('${options.name}')
668
- const [endpoints, setEndpoints] = useState<string[]>([])
669
- const [healthy, setHealthy] = useState<boolean | null>(null)
670
- const [error, setError] = useState<string | null>(null)
671
- const [dark, setDark] = useState(false)
672
-
673
- const refresh = async () => {
674
- setError(null)
675
- try {
676
- const [index, health] = await Promise.all([api.index(), api.health()])
677
- setName(index.name)
678
- setEndpoints(index.endpoints)
679
- setHealthy(health.ok)
680
- } catch (err) {
681
- setError(String((err as Error)?.message ?? err))
682
- }
683
- }
684
- useEffect(() => {
685
- void refresh()
686
- }, [])
687
-
688
- const toggleTheme = () => {
689
- const next = !dark
690
- document.documentElement.classList.toggle('dark', next)
691
- setDark(next)
692
- }
693
-
694
- return (
695
- <div className="grid min-h-screen place-items-center px-4">
696
- <Card className="w-full max-w-lg">
697
- <CardHeader className="flex-row items-center justify-between space-y-0">
698
- <CardTitle>{name}</CardTitle>
699
- <div className="flex items-center gap-2">
700
- {healthy !== null && (
701
- <Badge variant={healthy ? 'secondary' : 'destructive'}>
702
- {healthy ? 'healthy' : 'down'}
703
- </Badge>
704
- )}
705
- <Button variant="ghost" size="sm" onClick={toggleTheme}>
706
- {dark ? 'Light' : 'Dark'}
707
- </Button>
708
- </div>
709
- </CardHeader>
710
- <CardContent>
711
- {error && <p className="mb-3 text-sm font-medium text-destructive">{error}</p>}
712
- <p className="mb-2 text-sm text-muted-foreground">Endpoints</p>
713
- <ul className="space-y-1">
714
- {endpoints.map((path) => (
715
- <li key={path} className="rounded-md bg-muted px-3 py-1.5 font-mono text-sm">
716
- {path}
717
- </li>
718
- ))}
719
- </ul>
720
- <Button className="mt-4" variant="outline" size="sm" onClick={() => void refresh()}>
721
- Refresh
722
- </Button>
723
- </CardContent>
724
- </Card>
725
- </div>
726
- )
727
- }
728
- `;
729
- }
730
- function webAppWithAuth(options) {
731
- return `import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
732
- import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from '@basaltkit/admin-shadcn'
733
- import { makeApi } from './api.js'
734
-
735
- const STORAGE_KEY = '${options.name}.token'
736
- const errorMessage = (err: unknown): string => String((err as Error)?.message ?? err)
737
- const errorCode = (err: unknown): string => String((err as { code?: string })?.code ?? '')
738
- // Password-reset emails link back here with ?token=\u2026 \u2014 that switches to the reset screen.
739
- const resetToken = new URLSearchParams(window.location.search).get('token')
740
-
741
- export function App() {
742
- const [token, setToken] = useState<string | null>(() => localStorage.getItem(STORAGE_KEY))
743
-
744
- const store = (next: string | null) => {
745
- if (next) localStorage.setItem(STORAGE_KEY, next)
746
- else localStorage.removeItem(STORAGE_KEY)
747
- setToken(next)
748
- }
749
-
750
- if (resetToken) return <ResetPassword token={resetToken} />
751
- if (!token) return <AuthScreen onToken={store} />
752
- return <Dashboard token={token} onLogout={() => store(null)} />
753
- }
754
-
755
- // Centered card shell reused by every auth screen.
756
- function Shell({ subtitle, children }: { subtitle?: string; children: ReactNode }) {
757
- return (
758
- <div className="grid min-h-screen place-items-center px-4">
759
- <Card className="w-full max-w-md">
760
- <CardHeader>
761
- <CardTitle>${options.name}</CardTitle>
762
- {subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
763
- </CardHeader>
764
- <CardContent>{children}</CardContent>
765
- </Card>
766
- </div>
767
- )
768
- }
769
-
770
- type Mode = 'login' | 'register' | 'forgot'
771
-
772
- function AuthScreen({ onToken }: { onToken: (token: string) => void }) {
773
- const [mode, setMode] = useState<Mode>('login')
774
- const [error, setError] = useState<string | null>(null)
775
- const [notice, setNotice] = useState<string | null>(null)
776
- const [busy, setBusy] = useState(false)
777
- // When the account has 2FA on, login throws AUTH_MFA_REQUIRED; we keep the
778
- // credentials and ask for the 6-digit code, then retry.
779
- const [challenge, setChallenge] = useState<{ email: string; password: string } | null>(null)
780
- const api = makeApi()
781
-
782
- const submit = async (event: FormEvent<HTMLFormElement>) => {
783
- event.preventDefault()
784
- setError(null)
785
- setNotice(null)
786
- setBusy(true)
787
- const form = new FormData(event.currentTarget)
788
- try {
789
- if (challenge) {
790
- const res = await api.login({ body: { ...challenge, mfaCode: String(form.get('mfaCode')) } })
791
- onToken(res.accessToken)
792
- return
793
- }
794
- const email = String(form.get('email'))
795
- const password = String(form.get('password'))
796
- if (mode === 'forgot') {
797
- await api.forgotPassword({ body: { email } })
798
- setNotice('If that email exists, a reset link is on its way.')
799
- return
800
- }
801
- if (mode === 'register') await api.register({ body: { email, password } })
802
- const res = await api.login({ body: { email, password } })
803
- onToken(res.accessToken)
804
- } catch (err) {
805
- if (errorCode(err) === 'AUTH_MFA_REQUIRED') {
806
- setChallenge({
807
- email: String(new FormData(event.currentTarget).get('email')),
808
- password: String(new FormData(event.currentTarget).get('password')),
809
- })
810
- } else {
811
- setError(errorMessage(err))
812
- }
813
- } finally {
814
- setBusy(false)
815
- }
816
- }
817
-
818
- if (challenge) {
819
- return (
820
- <Shell subtitle="Two-factor authentication">
821
- <form onSubmit={submit} className="space-y-4">
822
- <div className="space-y-2">
823
- <Label htmlFor="mfaCode">Authenticator code</Label>
824
- <Input id="mfaCode" name="mfaCode" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" required />
825
- </div>
826
- {error && <p className="text-sm font-medium text-destructive">{error}</p>}
827
- <Button type="submit" className="w-full" disabled={busy}>{busy ? '\u2026' : 'Verify'}</Button>
828
- </form>
829
- <button className="mt-4 w-full text-center text-sm text-muted-foreground hover:text-foreground" onClick={() => { setChallenge(null); setError(null) }}>
830
- Back to sign in
831
- </button>
832
- </Shell>
833
- )
834
- }
835
-
836
- const subtitle = mode === 'register' ? 'Create an account' : mode === 'forgot' ? 'Reset your password' : 'Sign in'
837
- return (
838
- <Shell subtitle={subtitle}>
839
- <form onSubmit={submit} className="space-y-4">
840
- <div className="space-y-2">
841
- <Label htmlFor="email">Email</Label>
842
- <Input id="email" name="email" type="email" placeholder="you@example.com" required />
843
- </div>
844
- {mode !== 'forgot' && (
845
- <div className="space-y-2">
846
- <Label htmlFor="password">Password</Label>
847
- <Input id="password" name="password" type="password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" required />
848
- </div>
849
- )}
850
- {error && <p className="text-sm font-medium text-destructive">{error}</p>}
851
- {notice && <p className="text-sm font-medium text-foreground">{notice}</p>}
852
- <Button type="submit" className="w-full" disabled={busy}>
853
- {busy ? '\u2026' : mode === 'register' ? 'Create account' : mode === 'forgot' ? 'Send reset link' : 'Sign in'}
854
- </Button>
855
- </form>
856
- <div className="mt-4 space-y-1 text-center text-sm text-muted-foreground">
857
- {mode === 'login' && (
858
- <button className="block w-full hover:text-foreground" onClick={() => { setMode('forgot'); setError(null); setNotice(null) }}>
859
- Forgot your password?
860
- </button>
861
- )}
862
- <button className="block w-full hover:text-foreground" onClick={() => { setMode(mode === 'register' ? 'login' : 'register'); setError(null); setNotice(null) }}>
863
- {mode === 'register' ? 'Already have an account? Sign in' : mode === 'forgot' ? 'Back to sign in' : 'New here? Create an account'}
864
- </button>
865
- </div>
866
- </Shell>
867
- )
868
- }
869
-
870
- function ResetPassword({ token }: { token: string }) {
871
- const [done, setDone] = useState(false)
872
- const [error, setError] = useState<string | null>(null)
873
- const [busy, setBusy] = useState(false)
874
-
875
- const backToLogin = () => {
876
- window.history.replaceState({}, '', window.location.pathname)
877
- window.location.reload()
878
- }
879
-
880
- const submit = async (event: FormEvent<HTMLFormElement>) => {
881
- event.preventDefault()
882
- setError(null)
883
- setBusy(true)
884
- const password = String(new FormData(event.currentTarget).get('password'))
885
- try {
886
- await makeApi().resetPassword({ body: { token, password } })
887
- setDone(true)
888
- } catch (err) {
889
- setError(errorMessage(err))
890
- } finally {
891
- setBusy(false)
892
- }
893
- }
894
-
895
- if (done) {
896
- return (
897
- <Shell subtitle="Password updated">
898
- <Button className="w-full" onClick={backToLogin}>Sign in</Button>
899
- </Shell>
900
- )
901
- }
902
- return (
903
- <Shell subtitle="Choose a new password">
904
- <form onSubmit={submit} className="space-y-4">
905
- <div className="space-y-2">
906
- <Label htmlFor="password">New password</Label>
907
- <Input id="password" name="password" type="password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" required minLength={8} />
908
- </div>
909
- {error && <p className="text-sm font-medium text-destructive">{error}</p>}
910
- <Button type="submit" className="w-full" disabled={busy}>{busy ? '\u2026' : 'Reset password'}</Button>
911
- </form>
912
- </Shell>
913
- )
914
- }
915
-
916
- function Dashboard({ token, onLogout }: { token: string; onLogout: () => void }) {
917
- const api = makeApi(() => token)
918
- const [email, setEmail] = useState<string | null>(null)
919
- const [healthy, setHealthy] = useState<boolean | null>(null)
920
- const [mfa, setMfa] = useState<{ enabled: boolean; pending: boolean } | null>(null)
921
- const [error, setError] = useState<string | null>(null)
922
-
923
- const load = async () => {
924
- try {
925
- const [me, health, status] = await Promise.all([api.me(), api.health(), api.mfaStatus()])
926
- setEmail(me.email)
927
- setHealthy(health.ok)
928
- setMfa(status)
929
- } catch (err) {
930
- setError(errorMessage(err))
931
- }
932
- }
933
- useEffect(() => {
934
- void load()
935
- // eslint-disable-next-line react-hooks/exhaustive-deps
936
- }, [token])
937
-
938
- return (
939
- <div className="grid min-h-screen place-items-center px-4 py-10">
940
- <Card className="w-full max-w-lg">
941
- <CardHeader className="flex-row items-center justify-between space-y-0">
942
- <CardTitle>${options.name}</CardTitle>
943
- {healthy !== null && (
944
- <Badge variant={healthy ? 'secondary' : 'destructive'}>{healthy ? 'healthy' : 'down'}</Badge>
945
- )}
946
- </CardHeader>
947
- <CardContent className="space-y-4">
948
- {error && <p className="text-sm font-medium text-destructive">{error}</p>}
949
- <p className="text-sm text-muted-foreground">
950
- Signed in as <span className="font-medium text-foreground">{email ?? '\u2026'}</span>
951
- </p>
952
- <MfaCard status={mfa} api={api} onChange={() => void load()} />
953
- <Button variant="outline" size="sm" onClick={onLogout}>Sign out</Button>
954
- </CardContent>
955
- </Card>
956
- </div>
957
- )
958
- }
959
-
960
- type ApiClient = ReturnType<typeof makeApi>
961
-
962
- function MfaCard({ status, api, onChange }: { status: { enabled: boolean; pending: boolean } | null; api: ApiClient; onChange: () => void }) {
963
- const [enroll, setEnroll] = useState<{ secret: string; otpauthUri: string } | null>(null)
964
- const [recovery, setRecovery] = useState<string[] | null>(null)
965
- const [error, setError] = useState<string | null>(null)
966
- const [busy, setBusy] = useState(false)
967
-
968
- const run = async (fn: () => Promise<void>) => {
969
- setError(null)
970
- setBusy(true)
971
- try {
972
- await fn()
973
- } catch (err) {
974
- setError(errorMessage(err))
975
- } finally {
976
- setBusy(false)
977
- }
978
- }
979
-
980
- const start = () => run(async () => setEnroll(await api.mfaEnroll()))
981
- const activate = (event: FormEvent<HTMLFormElement>) => {
982
- event.preventDefault()
983
- const code = String(new FormData(event.currentTarget).get('code'))
984
- return run(async () => {
985
- const res = await api.mfaActivate({ body: { code } })
986
- setRecovery(res.recoveryCodes)
987
- setEnroll(null)
988
- onChange()
989
- })
990
- }
991
- const disable = (event: FormEvent<HTMLFormElement>) => {
992
- event.preventDefault()
993
- const code = String(new FormData(event.currentTarget).get('code'))
994
- return run(async () => {
995
- await api.mfaDisable({ body: { code } })
996
- onChange()
997
- })
998
- }
999
-
1000
- return (
1001
- <div className="rounded-lg border p-3">
1002
- <div className="mb-2 flex items-center justify-between">
1003
- <span className="text-sm font-medium">Two-factor authentication</span>
1004
- <Badge variant={status?.enabled ? 'secondary' : 'outline'}>{status?.enabled ? 'on' : 'off'}</Badge>
1005
- </div>
1006
- {error && <p className="mb-2 text-sm font-medium text-destructive">{error}</p>}
1007
-
1008
- {recovery && (
1009
- <div className="space-y-2">
1010
- <p className="text-sm text-muted-foreground">Save these recovery codes \u2014 each works once:</p>
1011
- <ul className="grid grid-cols-2 gap-1">
1012
- {recovery.map((c) => (
1013
- <li key={c} className="rounded bg-muted px-2 py-1 font-mono text-xs">{c}</li>
1014
- ))}
1015
- </ul>
1016
- <Button size="sm" variant="outline" onClick={() => setRecovery(null)}>Done</Button>
1017
- </div>
1018
- )}
1019
-
1020
- {!recovery && enroll && (
1021
- <form onSubmit={activate} className="space-y-2">
1022
- <p className="text-sm text-muted-foreground">Add this secret to your authenticator app, then enter a code:</p>
1023
- <code className="block break-all rounded bg-muted px-2 py-1 font-mono text-xs">{enroll.secret}</code>
1024
- <a className="block truncate text-xs text-primary underline" href={enroll.otpauthUri}>{enroll.otpauthUri}</a>
1025
- <Input name="code" inputMode="numeric" placeholder="123456" required />
1026
- <Button size="sm" type="submit" disabled={busy}>Activate</Button>
1027
- </form>
1028
- )}
1029
-
1030
- {!recovery && !enroll && status?.enabled && (
1031
- <form onSubmit={disable} className="flex items-end gap-2">
1032
- <div className="flex-1">
1033
- <Label htmlFor="mfa-off" className="text-xs">Enter a code to turn off</Label>
1034
- <Input id="mfa-off" name="code" inputMode="numeric" placeholder="123456" required />
1035
- </div>
1036
- <Button size="sm" variant="destructive" type="submit" disabled={busy}>Disable</Button>
1037
- </form>
1038
- )}
1039
-
1040
- {!recovery && !enroll && !status?.enabled && (
1041
- <Button size="sm" onClick={start} disabled={busy}>{busy ? '\u2026' : 'Enable 2FA'}</Button>
1042
- )}
1043
- </div>
1044
- )
1045
- }
1046
- `;
1047
- }
1048
-
1049
- // src/prompt.ts
1050
- import { emitKeypressEvents } from "readline";
1051
- import { createInterface } from "readline/promises";
1052
- import { stdin, stdout } from "process";
1053
- var WizardCancelledError = class extends Error {
1054
- constructor() {
1055
- super("Cancelled.");
1056
- this.name = "WizardCancelledError";
1057
- }
1058
- };
1059
- var c = {
1060
- dim: (s) => `\x1B[2m${s}\x1B[0m`,
1061
- cyan: (s) => `\x1B[36m${s}\x1B[0m`,
1062
- green: (s) => `\x1B[32m${s}\x1B[0m`,
1063
- bold: (s) => `\x1B[1m${s}\x1B[0m`
1064
- };
1065
- function ttyPrompter() {
1066
- return {
1067
- intro: (message) => stdout.write(`
1068
- ${c.bold(c.cyan("\u25C6 " + message))}
1069
-
1070
- `),
1071
- note: (message) => stdout.write(`${message}
1072
- `),
1073
- outro: (message) => stdout.write(`
1074
- ${c.green("\u2713 " + message)}
1075
- `),
1076
- async text({ message, placeholder, initial, validate }) {
1077
- const rl = createInterface({ input: stdin, output: stdout });
1078
- rl.on("SIGINT", () => {
1079
- rl.close();
1080
- throw new WizardCancelledError();
1081
- });
1082
- try {
1083
- for (; ; ) {
1084
- const hint = placeholder ? c.dim(` (${placeholder})`) : "";
1085
- const answer = (await rl.question(`${c.cyan("?")} ${message}${hint}: `)).trim() || initial || "";
1086
- const error = validate?.(answer);
1087
- if (!error) return answer;
1088
- stdout.write(` ${c.dim(error)}
1089
- `);
1090
- }
1091
- } catch (error) {
1092
- if (error.code === "ABORT_ERR") throw new WizardCancelledError();
1093
- throw error;
1094
- } finally {
1095
- rl.close();
1096
- }
1097
- },
1098
- select({ message, choices, initial = 0 }) {
1099
- return keyboardSelect(message, choices, initial, false).then((v) => v[0]);
1100
- },
1101
- multiselect({ message, choices, initial = [] }) {
1102
- const preselected = choices.map((choice) => initial.includes(choice.value));
1103
- return keyboardSelect(message, choices, 0, true, preselected);
1104
- },
1105
- async confirm({ message, initial = false }) {
1106
- const rl = createInterface({ input: stdin, output: stdout });
1107
- try {
1108
- const answer = (await rl.question(`${c.cyan("?")} ${message} ${c.dim(initial ? "(Y/n)" : "(y/N)")} `)).trim().toLowerCase();
1109
- return answer ? answer.startsWith("y") : initial;
1110
- } finally {
1111
- rl.close();
1112
- }
1113
- }
1114
- };
1115
- }
1116
- function keyboardSelect(message, choices, initialIndex, multi, selected = choices.map(() => false)) {
1117
- return new Promise((resolve2, reject) => {
1118
- let index = initialIndex;
1119
- let rendered = 0;
1120
- emitKeypressEvents(stdin);
1121
- const wasRaw = stdin.isTTY ? stdin.isRaw : false;
1122
- if (stdin.isTTY) stdin.setRawMode(true);
1123
- const render = () => {
1124
- if (rendered > 0) stdout.write(`\x1B[${rendered}A\x1B[0J`);
1125
- const lines = [`${c.cyan("?")} ${message}${multi ? c.dim(" (space to toggle, enter to confirm)") : ""}`];
1126
- choices.forEach((choice, i) => {
1127
- const pointer = i === index ? c.cyan("\u276F") : " ";
1128
- const box = multi ? (selected[i] ? c.green("\u25C9") : "\u25EF") + " " : "";
1129
- const label = i === index ? c.cyan(choice.label) : choice.label;
1130
- const hint = choice.hint ? c.dim(` \u2014 ${choice.hint}`) : "";
1131
- lines.push(`${pointer} ${box}${label}${hint}`);
1132
- });
1133
- stdout.write(lines.join("\n") + "\n");
1134
- rendered = lines.length;
1135
- };
1136
- const cleanup = () => {
1137
- stdin.off("keypress", onKey);
1138
- if (stdin.isTTY) stdin.setRawMode(wasRaw);
1139
- stdin.pause();
1140
- };
1141
- const onKey = (_str, key) => {
1142
- if (key.ctrl && key.name === "c") {
1143
- cleanup();
1144
- reject(new WizardCancelledError());
1145
- } else if (key.name === "up" || key.name === "k") {
1146
- index = (index - 1 + choices.length) % choices.length;
1147
- render();
1148
- } else if (key.name === "down" || key.name === "j") {
1149
- index = (index + 1) % choices.length;
1150
- render();
1151
- } else if (multi && key.name === "space") {
1152
- selected[index] = !selected[index];
1153
- render();
1154
- } else if (key.name === "return") {
1155
- cleanup();
1156
- resolve2(
1157
- multi ? choices.filter((_, i) => selected[i]).map((choice) => choice.value) : [choices[index].value]
1158
- );
1159
- }
1160
- };
1161
- stdin.resume();
1162
- stdin.on("keypress", onKey);
1163
- render();
1164
- });
1165
- }
1166
- function scriptedPrompter(script) {
1167
- const text = [...script.text ?? []];
1168
- const select = [...script.select ?? []];
1169
- const multiselect = [...script.multiselect ?? []];
1170
- const confirm = [...script.confirm ?? []];
1171
- const log = [];
1172
- const next = (queue, kind) => {
1173
- if (queue.length === 0) throw new Error(`scriptedPrompter: no more ${kind} answers`);
1174
- return queue.shift();
1175
- };
1176
- return {
1177
- log,
1178
- intro: (m) => log.push(m),
1179
- note: (m) => log.push(m),
1180
- outro: (m) => log.push(m),
1181
- async text() {
1182
- return next(text, "text");
1183
- },
1184
- async select() {
1185
- return next(select, "select");
1186
- },
1187
- async multiselect() {
1188
- return next(multiselect, "multiselect");
1189
- },
1190
- async confirm() {
1191
- return next(confirm, "confirm");
1192
- }
1193
- };
1194
- }
1195
-
1196
- // src/wizard.ts
1197
- var FEATURES = [
1198
- { value: "tenancy", label: "Multi-tenancy", hint: "per-tenant data isolation" },
1199
- { value: "auth", label: "Authentication", hint: "JWT, sessions, MFA, OAuth" },
1200
- { value: "billing", label: "Subscriptions / billing", hint: "Stripe, Paddle, Lemon Squeezy" },
1201
- { value: "ui", label: "Web UI", hint: "React + shadcn + SDK (pnpm workspace)" },
1202
- { value: "cli", label: "basalt CLI", hint: "code generators + commands" }
1203
- ];
1204
- var PRESETS = [
1205
- { value: "saas", label: "SaaS starter", hint: "tenancy + auth + billing + CLI", features: ["tenancy", "auth", "billing", "cli"] },
1206
- { value: "api", label: "API only", hint: "auth, no tenancy or UI", features: ["auth"] },
1207
- { value: "full", label: "Full stack", hint: "everything + web UI", features: ["tenancy", "auth", "billing", "ui", "cli"] },
1208
- { value: "minimal", label: "Minimal", hint: "no batteries, add them later", features: [] },
1209
- { value: "custom", label: "Custom", hint: "pick features yourself", features: null }
1210
- ];
1211
- var PACKAGE_MANAGERS = [
1212
- { value: "pnpm", label: "pnpm", hint: "recommended" },
1213
- { value: "npm", label: "npm" },
1214
- { value: "yarn", label: "yarn" },
1215
- { value: "bun", label: "bun" }
1216
- ];
1217
- function validateProjectName(name) {
1218
- const trimmed = name.trim();
1219
- if (!trimmed) return "Please enter a project name.";
1220
- if (trimmed.length > 214) return "Name is too long (max 214 characters).";
1221
- if (!/^(?:@[a-z0-9-~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(trimmed)) {
1222
- return "Use a valid npm package name (lowercase, no spaces).";
1223
- }
1224
- return void 0;
1225
- }
1226
- async function runWizard(prompter, options = {}) {
1227
- prompter.intro("create-basalt \u2014 scaffold a Basalt application");
1228
- const name = options.name && !validateProjectName(options.name) ? options.name : await prompter.text({
1229
- message: "Project name",
1230
- placeholder: "my-saas",
1231
- initial: "my-saas",
1232
- validate: validateProjectName
1233
- });
1234
- const presetValue = await prompter.select({
1235
- message: "Choose a starting point",
1236
- choices: PRESETS.map((p) => ({ value: p.value, label: p.label, hint: p.hint })),
1237
- initial: 0
1238
- });
1239
- const preset = PRESETS.find((p) => p.value === presetValue) ?? PRESETS[0];
1240
- const featureSet = new Set(
1241
- preset.features ?? await prompter.multiselect({
1242
- message: "Select features",
1243
- choices: FEATURES,
1244
- initial: ["tenancy", "auth"]
1245
- })
1246
- );
1247
- let pm = await prompter.select({
1248
- message: "Package manager",
1249
- choices: PACKAGE_MANAGERS,
1250
- initial: Math.max(0, PACKAGE_MANAGERS.findIndex((m) => m.value === (options.defaultPm ?? "pnpm")))
1251
- });
1252
- if (featureSet.has("ui") && pm !== "pnpm") {
1253
- prompter.note(` Note: the Web UI is a pnpm workspace \u2014 using pnpm instead of ${pm}.`);
1254
- pm = "pnpm";
1255
- }
1256
- const install = await prompter.confirm({ message: "Install dependencies now?", initial: false });
1257
- const git = await prompter.confirm({ message: "Initialize a git repository?", initial: false });
1258
- const enabled = FEATURES.filter((f) => featureSet.has(f.value)).map((f) => f.label);
1259
- prompter.note(
1260
- `
1261
- Summary
1262
- name ${name}
1263
- features ${enabled.length ? enabled.join(", ") : "(none)"}
1264
- pm ${pm}
1265
- install ${install ? "yes" : "no"}
1266
- git ${git ? "yes" : "no"}
1267
- `
1268
- );
1269
- const ok = await prompter.confirm({ message: "Create project?", initial: true });
1270
- if (!ok) throw new WizardCancelledError();
1271
- return {
1272
- name,
1273
- tenancy: featureSet.has("tenancy"),
1274
- auth: featureSet.has("auth"),
1275
- billing: featureSet.has("billing"),
1276
- ui: featureSet.has("ui"),
1277
- cli: featureSet.has("cli"),
1278
- pm,
1279
- install,
1280
- git
1281
- };
1282
- }
1283
-
1284
- // src/index.ts
1285
- var TargetNotEmptyError = class extends Error {
1286
- constructor(dir) {
1287
- super(`Target directory "${dir}" already exists and is not empty.`);
1288
- this.name = "TargetNotEmptyError";
1289
- }
1290
- };
1291
- function detectPackageManager(userAgent = process.env["npm_config_user_agent"]) {
1292
- const name = userAgent?.split(" ")[0]?.split("/")[0];
1293
- if (name === "pnpm" || name === "yarn" || name === "bun") return name;
1294
- return "npm";
1295
- }
1296
- async function createProject(input) {
1297
- const options = {
1298
- name: input.name,
1299
- tenancy: input.tenancy ?? true,
1300
- auth: input.auth ?? true,
1301
- billing: input.billing ?? false,
1302
- ui: input.ui ?? false,
1303
- cli: input.cli ?? false
1304
- };
1305
- const dir = resolve(input.dir ?? input.name);
1306
- const existing = await readdir(dir).catch(() => null);
1307
- if (existing && existing.length > 0) throw new TargetNotEmptyError(dir);
1308
- const files = {
1309
- "package.json": packageJson(options),
1310
- "tsconfig.json": tsconfigJson(),
1311
- ".env.example": envExample(options),
1312
- ".gitignore": gitignore(),
1313
- "README.md": readme(options),
1314
- "pnpm-workspace.yaml": pnpmWorkspaceYaml(options),
1315
- "src/env.ts": envTs(options),
1316
- "src/app.ts": appTs(options),
1317
- "src/routes.ts": routesTs(options),
1318
- "src/server.ts": serverTs(),
1319
- "tests/app.test.ts": appTest(options),
1320
- ...options.cli ? { "bin/basalt.ts": basaltBin() } : {},
1321
- ...options.ui ? uiFiles(options) : {}
1322
- };
1323
- for (const [path, content] of Object.entries(files)) {
1324
- const target = join(dir, path);
1325
- await mkdir(dirname(target), { recursive: true });
1326
- await writeFile(target, content);
1327
- }
1328
- return { dir, files: Object.keys(files).sort(), options };
1329
- }
1330
-
1331
- export {
1332
- WizardCancelledError,
1333
- ttyPrompter,
1334
- scriptedPrompter,
1335
- FEATURES,
1336
- PRESETS,
1337
- validateProjectName,
1338
- runWizard,
1339
- TargetNotEmptyError,
1340
- detectPackageManager,
1341
- createProject
1342
- };