create-basalt 1.2.1 → 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.
@@ -0,0 +1,38 @@
1
+ export interface ProjectOptions {
2
+ name: string;
3
+ tenancy: boolean;
4
+ auth: boolean;
5
+ billing: boolean;
6
+ /** Scaffold a web/ frontend (React + shadcn + SDK). */
7
+ ui: boolean;
8
+ /** Scaffold the `basalt` CLI entrypoint (code generators + built-in commands). */
9
+ cli: boolean;
10
+ /** Expose opted-in routes as MCP tools (@basaltkit/mcp) over HTTP at `/mcp`. */
11
+ mcp: boolean;
12
+ }
13
+ /** The dependency range for a @basalt package (override, else the base line). */
14
+ export declare const versionOf: (pkg: string) => string;
15
+ export declare function packageJson(options: ProjectOptions): string;
16
+ export declare function tsconfigJson(): string;
17
+ export declare function envTs(options: ProjectOptions): string;
18
+ export declare function envExample(options: ProjectOptions): string;
19
+ export declare function appTs(options: ProjectOptions): string;
20
+ export declare function routesTs(options: ProjectOptions): string;
21
+ export declare function serverTs(): string;
22
+ export declare function basaltBin(): string;
23
+ export declare function appTest(options: ProjectOptions): string;
24
+ export declare function readme(options: ProjectOptions): string;
25
+ /**
26
+ * A Claude Code / generic MCP client config that registers the dev-only
27
+ * `basalt-ai-mcp` bridge for this project. Claude Code reads `.mcp.json` at the
28
+ * project root automatically; Claude Desktop uses the same server entry.
29
+ */
30
+ export declare function mcpJson(_options: ProjectOptions): string;
31
+ export declare function gitignore(): string;
32
+ /**
33
+ * pnpm settings: esbuild's build script is required by tsx;
34
+ * msgpackr-extract (optional native accelerator via BullMQ) is declined —
35
+ * msgpackr falls back to pure JS. With --ui, `web` is a workspace member so
36
+ * its own dependencies resolve.
37
+ */
38
+ export declare function pnpmWorkspaceYaml(options: ProjectOptions): string;
@@ -0,0 +1,461 @@
1
+ /** Base release line for @basaltkit/* deps. */
2
+ const BASALT_VERSION = '^1.0.0';
3
+ /**
4
+ * Per-package range overrides. In semver 0.x, `^0.4.0` locks the minor, so a
5
+ * package that advances past the base line needs its own range or a fresh app
6
+ * can't install it. Add an entry when a package crosses a minor.
7
+ */
8
+ const VERSIONS = {
9
+ // ai-mcp is on the 0.x line (published separately from the 1.x framework base).
10
+ '@basaltkit/ai-mcp': '^0.1.0',
11
+ };
12
+ /** The dependency range for a @basalt package (override, else the base line). */
13
+ export const versionOf = (pkg) => VERSIONS[pkg] ?? BASALT_VERSION;
14
+ export function packageJson(options) {
15
+ const dependencies = {
16
+ '@basaltkit/config': BASALT_VERSION,
17
+ '@basaltkit/core': BASALT_VERSION,
18
+ '@basaltkit/env': BASALT_VERSION,
19
+ '@basaltkit/events': BASALT_VERSION,
20
+ '@basaltkit/fastify': BASALT_VERSION,
21
+ '@basaltkit/logger': BASALT_VERSION,
22
+ zod: '^4.0.0',
23
+ };
24
+ if (options.tenancy)
25
+ dependencies['@basaltkit/tenancy'] = BASALT_VERSION;
26
+ if (options.auth)
27
+ dependencies['@basaltkit/auth'] = BASALT_VERSION;
28
+ if (options.billing)
29
+ dependencies['@basaltkit/subscriptions'] = BASALT_VERSION;
30
+ if (options.mcp)
31
+ dependencies['@basaltkit/mcp'] = BASALT_VERSION;
32
+ if (options.cli) {
33
+ // Runtime deps: app.ts uses commandsPlugin (@basaltkit/cli); prisma powers
34
+ // prismaPlugin + `basalt prisma:sync`. @basaltkit/generator is dev-only (below).
35
+ dependencies['@basaltkit/cli'] = BASALT_VERSION;
36
+ dependencies['@basaltkit/prisma'] = BASALT_VERSION;
37
+ }
38
+ // Apply per-package range overrides. Only @basaltkit/* packages: versionOf
39
+ // falls back to BASALT_VERSION, which would clobber third-party ranges
40
+ // (this once rewrote zod's range to the @basalt base line).
41
+ for (const pkg of Object.keys(dependencies)) {
42
+ if (pkg.startsWith('@basaltkit/'))
43
+ dependencies[pkg] = versionOf(pkg);
44
+ }
45
+ const devDependencies = {
46
+ '@basaltkit/testing': versionOf('@basaltkit/testing'),
47
+ '@types/node': '^22.15.0',
48
+ 'pino-pretty': '^13.0.0',
49
+ tsx: '^4.19.0',
50
+ typescript: '^5.8.0',
51
+ vitest: '^3.1.0',
52
+ };
53
+ if (options.cli) {
54
+ // Dev-only: only bin/basalt.ts imports the code generator — the runtime server
55
+ // never does, so the app runs completely without the codegen/AI layer.
56
+ devDependencies['@basaltkit/generator'] = versionOf('@basaltkit/generator');
57
+ }
58
+ if (options.mcp) {
59
+ // Dev-only AI bridge: exposes analyze/plan/make to MCP clients (Claude
60
+ // Code/Desktop) via the `basalt-ai-mcp` bin. NEVER a runtime dependency — the
61
+ // app ships and runs without it (see `.mcp.json` for the client config).
62
+ devDependencies['@basaltkit/ai-mcp'] = versionOf('@basaltkit/ai-mcp');
63
+ }
64
+ return `${JSON.stringify({
65
+ name: options.name,
66
+ version: '0.1.0',
67
+ private: true,
68
+ type: 'module',
69
+ scripts: {
70
+ dev: 'tsx watch src/server.ts',
71
+ start: 'tsx src/server.ts',
72
+ test: 'vitest run',
73
+ typecheck: 'tsc --noEmit',
74
+ ...(options.cli ? { basalt: 'tsx bin/basalt.ts' } : {}),
75
+ },
76
+ dependencies: Object.fromEntries(Object.entries(dependencies).sort()),
77
+ devDependencies: Object.fromEntries(Object.entries(devDependencies).sort()),
78
+ }, null, 2)}\n`;
79
+ }
80
+ export function tsconfigJson() {
81
+ return `${JSON.stringify({
82
+ compilerOptions: {
83
+ target: 'ES2022',
84
+ module: 'NodeNext',
85
+ moduleResolution: 'NodeNext',
86
+ strict: true,
87
+ noUncheckedIndexedAccess: true,
88
+ verbatimModuleSyntax: true,
89
+ skipLibCheck: true,
90
+ noEmit: true,
91
+ types: ['node'],
92
+ },
93
+ include: ['src', 'tests'],
94
+ }, null, 2)}\n`;
95
+ }
96
+ export function envTs(options) {
97
+ return `import { defineEnv${options.auth ? ', secret' : ''} } from '@basaltkit/env'
98
+ import { z } from 'zod'
99
+
100
+ export const env = defineEnv({
101
+ PORT: z.coerce.number().default(3000),
102
+ HOST: z.string().default('0.0.0.0'),
103
+ LOG_LEVEL: z.string().default('info'),
104
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),${options.auth
105
+ ? `
106
+ // Signs JWTs and sessions. secret() is fail-closed: required in production
107
+ // (no fallback), rejected if it looks like a placeholder. Dev uses devDefault.
108
+ APP_SECRET: secret({ minLength: 32, devDefault: 'dev-only-insecure-secret-please-change-me' }),`
109
+ : ''}
110
+ })
111
+ `;
112
+ }
113
+ export function envExample(options) {
114
+ return `PORT=3000
115
+ HOST=0.0.0.0
116
+ LOG_LEVEL=info
117
+ NODE_ENV=development
118
+ ${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' : ''}`;
119
+ }
120
+ export function appTs(options) {
121
+ const imports = [
122
+ `import { createApp } from '@basaltkit/core'`,
123
+ `import { configPlugin } from '@basaltkit/config'`,
124
+ `import { eventsPlugin } from '@basaltkit/events'`,
125
+ `import { fastifyPlugin, securityPlugin } from '@basaltkit/fastify'`,
126
+ `import { loggerPlugin } from '@basaltkit/logger'`,
127
+ ];
128
+ const plugins = [
129
+ `configPlugin({ app: { name: '${options.name}' } })`,
130
+ `loggerPlugin({ level: options.logLevel ?? 'info', ...(options.pretty ? { pretty: true } : {}) })`,
131
+ `eventsPlugin()`,
132
+ `securityPlugin({
133
+ // Secure response headers (HSTS, X-Frame-Options, nosniff, …) are on by
134
+ // default. Turn on rate limiting and a CORS allow-list for production:
135
+ // rateLimit: { limit: 120, windowMs: 60_000 },
136
+ // cors: { origin: ['https://app.example.com'], credentials: true },
137
+ })`,
138
+ ];
139
+ let routesExpression = 'appRoutes';
140
+ if (options.tenancy) {
141
+ imports.push(`import { headerResolver, MemoryTenantSource, subdomainResolver, tenancyPlugin } from '@basaltkit/tenancy'`);
142
+ plugins.push(`tenancyPlugin({
143
+ // Replace MemoryTenantSource with your database-backed source.
144
+ source: new MemoryTenantSource().add({ id: 'demo', name: 'Demo Tenant' }),
145
+ resolvers: [headerResolver(), subdomainResolver({ base: 'localhost' })],
146
+ })`);
147
+ }
148
+ if (options.auth) {
149
+ imports.push(`import { authPlugin, authRoutes, mfaRoutes, MemoryUserSource } from '@basaltkit/auth'`);
150
+ imports.push(`import { env } from './env.js'`);
151
+ plugins.push(`authPlugin({
152
+ // Replace MemoryUserSource with your database-backed source (e.g.
153
+ // @basaltkit/auth-sqlite or @basaltkit/auth-prisma). The default in-memory
154
+ // MFA store is enough for the ready-made TOTP flow below.
155
+ users: new MemoryUserSource(),
156
+ secret: env.APP_SECRET,
157
+ })`);
158
+ // authRoutes(): register, login, logout, refresh, me, email verification and
159
+ // password recovery. mfaRoutes(): TOTP enroll/activate/status/disable.
160
+ routesExpression = '[...appRoutes, ...authRoutes(), ...mfaRoutes()]';
161
+ }
162
+ if (options.billing) {
163
+ imports.push(`import { definePlans, subscriptionsPlugin } from '@basaltkit/subscriptions'`);
164
+ plugins.push(`subscriptionsPlugin({
165
+ plans: definePlans({
166
+ free: { price: 0, features: { projects: 3 } },
167
+ pro: { price: { monthly: 29, yearly: 290 }, trial: '14d', features: { projects: 50 } },
168
+ }),
169
+ fallbackPlan: 'free',
170
+ })`);
171
+ }
172
+ if (options.cli) {
173
+ // Only the commandsPlugin lives here. The dev tools (@basaltkit/generator, and
174
+ // @basaltkit/ai if installed) are imported ONLY by bin/basalt.ts and passed via
175
+ // \`options.commands\`, so the runtime server never imports them — the SaaS runs
176
+ // completely without the AI/codegen layer. Built-ins (routes, schedule:list) come free.
177
+ imports.push(`import { commandsPlugin, type CommandDefinition } from '@basaltkit/cli'`);
178
+ plugins.push(`...(options.commands && options.commands.length > 0 ? [commandsPlugin(options.commands)] : [])`);
179
+ }
180
+ let adapterRoutesExpr = routesExpression;
181
+ if (options.mcp) {
182
+ imports.push(`import { mcpPlugin, mcpRoutes } from '@basaltkit/mcp'`);
183
+ // The MCP server scans the SAME app routes for `meta.mcp` (see routes.ts); the
184
+ // `/mcp` transport is added to the adapter so agents POST JSON-RPC to it. Tool
185
+ // calls run through the neutral pipeline, so tenancy/auth apply unchanged.
186
+ plugins.push(`mcpPlugin({ routes: ${routesExpression}, serverInfo: { name: '${options.name}', version: '0.1.0' } })`);
187
+ adapterRoutesExpr =
188
+ routesExpression === 'appRoutes'
189
+ ? '[...appRoutes, ...mcpRoutes()]'
190
+ : routesExpression.replace(/]$/, ', ...mcpRoutes()]');
191
+ }
192
+ plugins.push(`fastifyPlugin({ routes: ${adapterRoutesExpr} })`);
193
+ const commandsField = options.cli
194
+ ? `\n /** Dev/CLI commands (make:*, ai:*, prisma:sync) — passed ONLY by bin/basalt.ts. */\n commands?: CommandDefinition[]`
195
+ : '';
196
+ return `${imports.join('\n')}
197
+ import { appRoutes } from './routes.js'
198
+
199
+ export interface BuildAppOptions {
200
+ logLevel?: string
201
+ pretty?: boolean${commandsField}
202
+ }
203
+
204
+ export function buildApp(options: BuildAppOptions = {}) {
205
+ return createApp({
206
+ plugins: [
207
+ ${plugins.join(',\n ')},
208
+ ],
209
+ })
210
+ }
211
+ `;
212
+ }
213
+ export function routesTs(options) {
214
+ const endpoints = [
215
+ "'GET /'",
216
+ "'GET /health'",
217
+ ...(options.auth
218
+ ? [
219
+ "'POST /auth/register'",
220
+ "'POST /auth/login'",
221
+ "'POST /auth/refresh'",
222
+ "'POST /auth/logout'",
223
+ "'GET /auth/me'",
224
+ ]
225
+ : []),
226
+ ];
227
+ // Opt these read-only routes in as MCP tools when MCP is enabled. Only safe,
228
+ // non-mutating endpoints are exposed — auth routes are deliberately left out.
229
+ const overviewMeta = options.mcp
230
+ ? `\n meta: { mcp: { name: 'overview', description: 'List what this API exposes: name, status and available endpoints.' } },`
231
+ : '';
232
+ const healthMeta = options.mcp
233
+ ? `\n meta: { mcp: { name: 'health', description: 'Liveness check — returns ok and the current request id.' } },`
234
+ : '';
235
+ return `import { ctx } from '@basaltkit/core'
236
+ import { route } from '@basaltkit/fastify'
237
+
238
+ export const appRoutes = [
239
+ // Friendly index so \`GET /\` is never a bare 404 — lists what the API exposes.
240
+ route({
241
+ method: 'GET',
242
+ url: '/',${overviewMeta}
243
+ async handler() {
244
+ return {
245
+ name: '${options.name}',
246
+ status: 'ok',
247
+ endpoints: [${endpoints.join(', ')}],
248
+ }
249
+ },
250
+ }),
251
+ route({
252
+ method: 'GET',
253
+ url: '/health',${healthMeta}
254
+ async handler() {
255
+ return { ok: true, requestId: ctx().requestId${options.tenancy ? ', tenant: ctx().tenant?.id ?? null' : ''} }
256
+ },
257
+ }),
258
+ ]
259
+ `;
260
+ }
261
+ export function serverTs() {
262
+ return `import { FASTIFY } from '@basaltkit/fastify'
263
+ import { buildApp } from './app.js'
264
+ import { env } from './env.js'
265
+
266
+ const app = await buildApp({
267
+ logLevel: env.LOG_LEVEL,
268
+ pretty: env.NODE_ENV === 'development',
269
+ }).boot()
270
+
271
+ const server = app.container.get(FASTIFY)
272
+ await server.listen({ port: env.PORT, host: env.HOST })
273
+ console.log('ready at http://' + env.HOST + ':' + env.PORT)
274
+
275
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
276
+ process.once(signal, async () => {
277
+ await app.shutdown()
278
+ process.exit(0)
279
+ })
280
+ }
281
+ `;
282
+ }
283
+ export function basaltBin() {
284
+ return `#!/usr/bin/env node
285
+ import { runCli } from '@basaltkit/cli'
286
+ import { generatorCommands } from '@basaltkit/generator'
287
+ import { prismaSyncCommand } from '@basaltkit/prisma'
288
+ import { buildApp } from '../src/app.js'
289
+
290
+ // The 'basalt' CLI: boots the app WITH the dev/CLI commands, runs one, shuts down.
291
+ // The dev tools (@basaltkit/generator; add @basaltkit/ai for ai:*) are imported
292
+ // ONLY here — the runtime server (src/server.ts) never loads them, so the SaaS
293
+ // runs without the codegen/AI layer.
294
+ // pnpm basalt dev — dev server: route table + watch (--worker for a queue worker)
295
+ // pnpm basalt list — show available commands
296
+ // pnpm basalt routes — list registered HTTP routes
297
+ // pnpm basalt make:resource Project — generate a full resource vertical
298
+ // pnpm basalt make:service Project — generate a single artifact (schema/service/…)
299
+ const app = buildApp({
300
+ logLevel: 'silent',
301
+ commands: [...generatorCommands(), prismaSyncCommand()],
302
+ })
303
+ process.exit(await runCli({ app }))
304
+ `;
305
+ }
306
+ export function appTest(options) {
307
+ return `import { describe, expect, it } from 'vitest'
308
+ import { FASTIFY } from '@basaltkit/fastify'
309
+ import { buildApp } from '../src/app.js'
310
+
311
+ describe('app', () => {
312
+ it('boots and responds on /health', async () => {
313
+ const app = await buildApp({ logLevel: 'silent' }).boot()
314
+ const server = app.container.get(FASTIFY)
315
+
316
+ const index = await server.inject({ method: 'GET', url: '/' })
317
+ expect(index.statusCode).toBe(200)
318
+ expect(index.json().name).toBe('${options.name}')
319
+
320
+ const response = await server.inject({ method: 'GET', url: '/health' })
321
+ expect(response.statusCode).toBe(200)
322
+ expect(response.json().ok).toBe(true)${options.tenancy
323
+ ? `
324
+
325
+ const tenant = await server.inject({
326
+ method: 'GET',
327
+ url: '/health',
328
+ headers: { 'x-tenant-id': 'demo' },
329
+ })
330
+ expect(tenant.json().tenant).toBe('demo')`
331
+ : ''}
332
+ await app.shutdown()
333
+ })
334
+ })
335
+ `;
336
+ }
337
+ export function readme(options) {
338
+ const features = [
339
+ 'typed routes with Zod validation',
340
+ 'structured logging with request/tenant context',
341
+ 'typed domain events',
342
+ ...(options.tenancy ? ['multi-tenancy (header + subdomain resolvers)'] : []),
343
+ ...(options.auth ? ['authentication (register/login/refresh/me)'] : []),
344
+ ...(options.billing ? ['subscriptions with plans and feature limits'] : []),
345
+ ...(options.ui ? ['web UI (React + shadcn/ui on @basaltkit/admin-shadcn + @basaltkit/sdk)'] : []),
346
+ ...(options.cli ? ['`basalt` CLI with code generators (`make:*`) and built-in commands'] : []),
347
+ ...(options.mcp ? ['MCP server — read-only routes exposed as AI-agent tools at `/mcp`'] : []),
348
+ ];
349
+ return `# ${options.name}
350
+
351
+ A SaaS app scaffolded with [Basalt](https://github.com/basaltkit/basalt).
352
+
353
+ Included: ${features.join(' · ')}.
354
+
355
+ ## Getting started
356
+
357
+ \`\`\`bash
358
+ pnpm install
359
+ pnpm dev # API on http://localhost:3000
360
+ pnpm test
361
+ \`\`\`
362
+ ${options.cli
363
+ ? `
364
+ ## The \`basalt\` CLI
365
+
366
+ \`\`\`bash
367
+ pnpm basalt list # available commands
368
+ pnpm basalt routes # registered HTTP routes
369
+ pnpm basalt make:resource Project # schema → repository → service → plugin → routes → test
370
+ pnpm basalt make:service Project # a single artifact (--force to overwrite)
371
+ \`\`\`
372
+
373
+ Generated resources land in \`src/modules/<name>/\`. Register the generated
374
+ plugin in \`src/app.ts\` to wire it up.
375
+ `
376
+ : ''}${options.ui
377
+ ? `
378
+ ## Web UI
379
+
380
+ \`\`\`bash
381
+ pnpm dev # terminal 1 — API on :3000
382
+ pnpm --filter ${options.name}-web dev # terminal 2 — UI on http://localhost:5180
383
+ \`\`\`
384
+
385
+ Open <http://localhost:5180>. The Vite dev server proxies \`/api\` to the API,
386
+ so there is no CORS to configure${options.auth ? '. Register, then sign in' : ''}.
387
+ `
388
+ : ''}${options.mcp
389
+ ? `
390
+ ## MCP server
391
+
392
+ Read-only routes marked with \`meta.mcp\` in \`src/routes.ts\` (the overview and
393
+ health endpoints) are exposed as [Model Context Protocol](https://modelcontextprotocol.io)
394
+ tools over HTTP at \`POST /mcp\`. Point any MCP client at it:
395
+
396
+ \`\`\`bash
397
+ # list the tools
398
+ curl -s localhost:3000/mcp -H 'content-type: application/json' \\
399
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
400
+ \`\`\`
401
+
402
+ Tool calls run through the same neutral pipeline as HTTP, so validation, tenancy
403
+ and auth apply unchanged. Add \`meta: { mcp: true }\` to any route to expose it —
404
+ keep mutations and auth flows off unless an agent should really call them.
405
+
406
+ ### AI dev tools
407
+
408
+ This project is also wired for AI-assisted development. \`.mcp.json\` registers the
409
+ **dev-only** \`basalt-ai-mcp\` bridge (a \`devDependency\` — never shipped in your
410
+ app's runtime) with MCP clients such as Claude Code and Claude Desktop. It exposes
411
+ \`basalt_analyze\`, \`basalt_doctor\`, \`basalt_plan\`, \`basalt_review\` and
412
+ \`basalt_make\` (preview by default, writes confined to this project) plus workflow
413
+ prompts. Claude Code picks up \`.mcp.json\` automatically; for Claude Desktop add the
414
+ same server to its config with \`--cwd=<absolute project path>\`.
415
+ `
416
+ : ''}`;
417
+ }
418
+ /**
419
+ * A Claude Code / generic MCP client config that registers the dev-only
420
+ * `basalt-ai-mcp` bridge for this project. Claude Code reads `.mcp.json` at the
421
+ * project root automatically; Claude Desktop uses the same server entry.
422
+ */
423
+ export function mcpJson(_options) {
424
+ return `${JSON.stringify({
425
+ mcpServers: {
426
+ 'basalt-ai': {
427
+ command: 'npx',
428
+ args: ['-y', '@basaltkit/ai-mcp', '--cwd=.'],
429
+ },
430
+ },
431
+ }, null, 2)}\n`;
432
+ }
433
+ export function gitignore() {
434
+ return `node_modules/
435
+ dist/
436
+ .env
437
+ .env.*
438
+ !.env.example
439
+ *.log
440
+ `;
441
+ }
442
+ /**
443
+ * pnpm settings: esbuild's build script is required by tsx;
444
+ * msgpackr-extract (optional native accelerator via BullMQ) is declined —
445
+ * msgpackr falls back to pure JS. With --ui, `web` is a workspace member so
446
+ * its own dependencies resolve.
447
+ */
448
+ export function pnpmWorkspaceYaml(options) {
449
+ return `${options.ui
450
+ ? `packages:
451
+ - web
452
+ `
453
+ : ''}allowBuilds:
454
+ esbuild: true
455
+ msgpackr-extract: false
456
+ # @basaltkit/* releases in lockstep, often within hours — exclude the scope from
457
+ # pnpm's minimumReleaseAge policy so \`pnpm up\` is never blocked on a fresh release.
458
+ minimumReleaseAgeExclude:
459
+ - '@basaltkit/*'
460
+ `;
461
+ }
@@ -0,0 +1,40 @@
1
+ import type { PackageManager } from './index.js';
2
+ import { type Choice, type Prompter } from './prompt.js';
3
+ export type FeatureKey = 'tenancy' | 'auth' | 'billing' | 'ui' | 'cli' | 'mcp';
4
+ /** The feature toggles offered in the "custom" preset (and shown in the summary). */
5
+ export declare const FEATURES: Choice<FeatureKey>[];
6
+ interface Preset {
7
+ value: string;
8
+ label: string;
9
+ hint: string;
10
+ /** null → let the user pick features (custom). */
11
+ features: FeatureKey[] | null;
12
+ }
13
+ export declare const PRESETS: Preset[];
14
+ /** Validates a project name as an npm-installable package name. */
15
+ export declare function validateProjectName(name: string): string | undefined;
16
+ export interface WizardResult {
17
+ name: string;
18
+ tenancy: boolean;
19
+ auth: boolean;
20
+ billing: boolean;
21
+ ui: boolean;
22
+ cli: boolean;
23
+ mcp: boolean;
24
+ pm: PackageManager;
25
+ install: boolean;
26
+ git: boolean;
27
+ }
28
+ export interface WizardOptions {
29
+ /** Pre-filled name (from argv) — skips the name prompt when valid. */
30
+ name?: string;
31
+ /** Detected package manager, used as the default selection. */
32
+ defaultPm?: PackageManager;
33
+ }
34
+ /**
35
+ * The interactive scaffolding flow. Pure with respect to I/O — it drives the
36
+ * injected {@link Prompter}, so tests script the answers and assert the result.
37
+ * Throws {@link WizardCancelledError} if the user declines the final confirm.
38
+ */
39
+ export declare function runWizard(prompter: Prompter, options?: WizardOptions): Promise<WizardResult>;
40
+ export {};
package/dist/wizard.js ADDED
@@ -0,0 +1,97 @@
1
+ import { WizardCancelledError } from './prompt.js';
2
+ /** The feature toggles offered in the "custom" preset (and shown in the summary). */
3
+ export const FEATURES = [
4
+ { value: 'tenancy', label: 'Multi-tenancy', hint: 'per-tenant data isolation' },
5
+ { value: 'auth', label: 'Authentication', hint: 'JWT, sessions, MFA, OAuth' },
6
+ { value: 'billing', label: 'Subscriptions / billing', hint: 'Stripe, Paddle, Lemon Squeezy' },
7
+ { value: 'ui', label: 'Web UI', hint: 'React + shadcn + SDK (pnpm workspace)' },
8
+ { value: 'cli', label: 'basalt CLI', hint: 'code generators + commands' },
9
+ { value: 'mcp', label: 'MCP server', hint: 'expose routes as AI-agent tools (/mcp)' },
10
+ ];
11
+ export const PRESETS = [
12
+ { value: 'saas', label: 'SaaS starter', hint: 'tenancy + auth + billing + CLI', features: ['tenancy', 'auth', 'billing', 'cli'] },
13
+ { value: 'api', label: 'API only', hint: 'auth + MCP, no tenancy or UI', features: ['auth', 'mcp'] },
14
+ { value: 'full', label: 'Full stack', hint: 'everything + web UI', features: ['tenancy', 'auth', 'billing', 'ui', 'cli', 'mcp'] },
15
+ { value: 'minimal', label: 'Minimal', hint: 'no batteries, add them later', features: [] },
16
+ { value: 'custom', label: 'Custom', hint: 'pick features yourself', features: null },
17
+ ];
18
+ const PACKAGE_MANAGERS = [
19
+ { value: 'pnpm', label: 'pnpm', hint: 'recommended' },
20
+ { value: 'npm', label: 'npm' },
21
+ { value: 'yarn', label: 'yarn' },
22
+ { value: 'bun', label: 'bun' },
23
+ ];
24
+ /** Validates a project name as an npm-installable package name. */
25
+ export function validateProjectName(name) {
26
+ const trimmed = name.trim();
27
+ if (!trimmed)
28
+ return 'Please enter a project name.';
29
+ if (trimmed.length > 214)
30
+ return 'Name is too long (max 214 characters).';
31
+ if (!/^(?:@[a-z0-9-~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(trimmed)) {
32
+ return 'Use a valid npm package name (lowercase, no spaces).';
33
+ }
34
+ return undefined;
35
+ }
36
+ /**
37
+ * The interactive scaffolding flow. Pure with respect to I/O — it drives the
38
+ * injected {@link Prompter}, so tests script the answers and assert the result.
39
+ * Throws {@link WizardCancelledError} if the user declines the final confirm.
40
+ */
41
+ export async function runWizard(prompter, options = {}) {
42
+ prompter.intro('create-basalt — scaffold a Basalt application');
43
+ const name = options.name && !validateProjectName(options.name)
44
+ ? options.name
45
+ : await prompter.text({
46
+ message: 'Project name',
47
+ placeholder: 'my-saas',
48
+ initial: 'my-saas',
49
+ validate: validateProjectName,
50
+ });
51
+ const presetValue = await prompter.select({
52
+ message: 'Choose a starting point',
53
+ choices: PRESETS.map((p) => ({ value: p.value, label: p.label, hint: p.hint })),
54
+ initial: 0,
55
+ });
56
+ const preset = PRESETS.find((p) => p.value === presetValue) ?? PRESETS[0];
57
+ const featureSet = new Set(preset.features ??
58
+ (await prompter.multiselect({
59
+ message: 'Select features',
60
+ choices: FEATURES,
61
+ initial: ['tenancy', 'auth'],
62
+ })));
63
+ let pm = await prompter.select({
64
+ message: 'Package manager',
65
+ choices: PACKAGE_MANAGERS,
66
+ initial: Math.max(0, PACKAGE_MANAGERS.findIndex((m) => m.value === (options.defaultPm ?? 'pnpm'))),
67
+ });
68
+ // The web/ frontend is a pnpm workspace member — force pnpm when UI is on.
69
+ if (featureSet.has('ui') && pm !== 'pnpm') {
70
+ prompter.note(` Note: the Web UI is a pnpm workspace — using pnpm instead of ${pm}.`);
71
+ pm = 'pnpm';
72
+ }
73
+ const install = await prompter.confirm({ message: 'Install dependencies now?', initial: false });
74
+ const git = await prompter.confirm({ message: 'Initialize a git repository?', initial: false });
75
+ const enabled = FEATURES.filter((f) => featureSet.has(f.value)).map((f) => f.label);
76
+ prompter.note(`\n Summary\n` +
77
+ ` name ${name}\n` +
78
+ ` features ${enabled.length ? enabled.join(', ') : '(none)'}\n` +
79
+ ` pm ${pm}\n` +
80
+ ` install ${install ? 'yes' : 'no'}\n` +
81
+ ` git ${git ? 'yes' : 'no'}\n`);
82
+ const ok = await prompter.confirm({ message: 'Create project?', initial: true });
83
+ if (!ok)
84
+ throw new WizardCancelledError();
85
+ return {
86
+ name,
87
+ tenancy: featureSet.has('tenancy'),
88
+ auth: featureSet.has('auth'),
89
+ billing: featureSet.has('billing'),
90
+ ui: featureSet.has('ui'),
91
+ cli: featureSet.has('cli'),
92
+ mcp: featureSet.has('mcp'),
93
+ pm,
94
+ install,
95
+ git,
96
+ };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Scaffold a production-shaped Basalt SaaS app: npm create basalt my-saas [--billing] [--ui] [--cli] [--install] [--git]",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,10 +17,9 @@
17
17
  "dist"
18
18
  ],
19
19
  "devDependencies": {
20
- "@types/node": "^22.15.0",
21
- "tsup": "^8.4.0",
22
- "typescript": "^5.8.0",
23
- "vitest": "^3.1.0",
20
+ "@types/node": "^26.3.0",
21
+ "typescript": "^7.0.2",
22
+ "vitest": "^4.1.11",
24
23
  "@basaltkit/tsconfig": "^0.24.0"
25
24
  },
26
25
  "publishConfig": {
@@ -28,11 +27,11 @@
28
27
  },
29
28
  "repository": {
30
29
  "type": "git",
31
- "url": "git+https://github.com/Zebedeu/basalt.git",
30
+ "url": "git+https://github.com/basaltkit/basalt.git",
32
31
  "directory": "packages/create-app"
33
32
  },
34
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/create-app#readme",
35
- "bugs": "https://github.com/Zebedeu/basalt/issues",
33
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/create-app#readme",
34
+ "bugs": "https://github.com/basaltkit/basalt/issues",
36
35
  "keywords": [
37
36
  "basalt",
38
37
  "typescript",
@@ -43,7 +42,7 @@
43
42
  "boilerplate"
44
43
  ],
45
44
  "scripts": {
46
- "build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
45
+ "build": "tsc -p tsconfig.build.json",
47
46
  "test": "vitest run",
48
47
  "typecheck": "tsc --noEmit"
49
48
  }