create-basalt 1.4.2 → 1.6.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.
package/dist/templates.js CHANGED
@@ -1,55 +1,64 @@
1
- /** Base release line for @basaltkit/* deps. */
2
- const BASALT_VERSION = '^1.0.0';
1
+ import { THIRD_PARTY_VERSIONS } from './latest-versions.js';
2
+ import { SCAFFOLD_VERSIONS } from './versions.js';
3
+ export { SCAFFOLD_VERSIONS } from './versions.js';
4
+ export { THIRD_PARTY_VERSIONS } from './latest-versions.js';
3
5
  /**
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.
6
+ * The dependency range for a @basaltkit/* package: the current release line
7
+ * (`^<major>.<minor>.0`) of its workspace version, from the generated
8
+ * {@link SCAFFOLD_VERSIONS} map. Throws for an unknown package rather than
9
+ * guessing a range — a stale guess once pinned new apps to frozen 1.x lines.
7
10
  */
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
+ export const versionOf = (pkg) => {
12
+ const range = SCAFFOLD_VERSIONS[pkg];
13
+ if (range === undefined) {
14
+ throw new Error(`create-basalt: no release line for ${pkg} — add it to scripts/sync-versions.mjs.`);
15
+ }
16
+ return range;
17
+ };
18
+ /**
19
+ * The fallback range for a third-party package the templates emit, from
20
+ * {@link THIRD_PARTY_VERSIONS}. At scaffold time the CLI replaces it with
21
+ * `^<latest>` when the registry's latest is on the same major. Throws for an
22
+ * unknown package so every emitted range lives in that single table.
23
+ */
24
+ export const thirdPartyVersionOf = (pkg) => {
25
+ const range = THIRD_PARTY_VERSIONS[pkg];
26
+ if (range === undefined) {
27
+ throw new Error(`create-basalt: no fallback range for ${pkg} — add it to THIRD_PARTY_VERSIONS.`);
28
+ }
29
+ return range;
11
30
  };
12
- /** The dependency range for a @basalt package (override, else the base line). */
13
- export const versionOf = (pkg) => VERSIONS[pkg] ?? BASALT_VERSION;
31
+ /** True when the scaffold binds users to the tenant they act on (tenancy + auth). */
32
+ export const enforcesMembership = (options) => options.tenancy && options.auth;
14
33
  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
- };
34
+ const basalt = ['@basaltkit/config', '@basaltkit/core', '@basaltkit/env', '@basaltkit/events', '@basaltkit/fastify', '@basaltkit/logger'];
24
35
  if (options.tenancy)
25
- dependencies['@basaltkit/tenancy'] = BASALT_VERSION;
36
+ basalt.push('@basaltkit/tenancy');
26
37
  if (options.auth)
27
- dependencies['@basaltkit/auth'] = BASALT_VERSION;
38
+ basalt.push('@basaltkit/auth');
39
+ // Tenancy + auth: @basaltkit/teams binds each user to the tenants they belong
40
+ // to (tenantMembershipPlugin) — without it x-tenant-id/Host is a free choice.
41
+ if (enforcesMembership(options))
42
+ basalt.push('@basaltkit/teams');
28
43
  if (options.billing)
29
- dependencies['@basaltkit/subscriptions'] = BASALT_VERSION;
44
+ basalt.push('@basaltkit/subscriptions');
30
45
  if (options.mcp)
31
- dependencies['@basaltkit/mcp'] = BASALT_VERSION;
46
+ basalt.push('@basaltkit/mcp');
32
47
  if (options.cli) {
33
48
  // 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);
49
+ // `basalt prisma:sync` (and prismaPlugin once the app adds a PrismaClient —
50
+ // the scaffold wires no database itself). @basaltkit/generator is dev-only (below).
51
+ basalt.push('@basaltkit/cli', '@basaltkit/prisma');
44
52
  }
53
+ const dependencies = { zod: thirdPartyVersionOf('zod') };
54
+ for (const pkg of basalt)
55
+ dependencies[pkg] = versionOf(pkg);
45
56
  const devDependencies = {
46
57
  '@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
58
  };
59
+ for (const pkg of ['@types/node', 'pino-pretty', 'tsx', 'typescript', 'vitest']) {
60
+ devDependencies[pkg] = thirdPartyVersionOf(pkg);
61
+ }
53
62
  if (options.cli) {
54
63
  // Dev-only: only bin/basalt.ts imports the code generator — the runtime server
55
64
  // never does, so the app runs completely without the codegen/AI layer.
@@ -67,7 +76,9 @@ export function packageJson(options) {
67
76
  private: true,
68
77
  type: 'module',
69
78
  scripts: {
70
- dev: 'tsx watch src/server.ts',
79
+ // dev opts into NODE_ENV=development (src/dev.ts); start does not, so an
80
+ // unset NODE_ENV counts as production and secrets are required.
81
+ dev: 'tsx watch src/dev.ts',
71
82
  start: 'tsx src/server.ts',
72
83
  test: 'vitest run',
73
84
  typecheck: 'tsc --noEmit',
@@ -104,21 +115,49 @@ export const env = defineEnv({
104
115
  // Typed against the logger's LogLevel union — a free-form string here fails
105
116
  // \`pnpm typecheck\` where loggerPlugin({ level }) consumes it.
106
117
  LOG_LEVEL: z.enum(LOG_LEVELS).default('info'),
107
- NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),${options.auth
118
+ // Unset counts as production (fail-closed); \`pnpm dev\` sets development.
119
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),${options.auth
108
120
  ? `
109
- // Signs JWTs and sessions. secret() is fail-closed: required in production
110
- // (no fallback), rejected if it looks like a placeholder. Dev uses devDefault.
121
+ // Signs JWTs and sessions. secret() is fail-closed: required unless NODE_ENV
122
+ // is explicitly development/test (no fallback when NODE_ENV is unset),
123
+ // rejected if it looks like a placeholder. \`pnpm dev\` uses devDefault.
111
124
  APP_SECRET: secret({ minLength: 32, devDefault: 'dev-only-insecure-secret-please-change-me' }),`
112
125
  : ''}
113
126
  })
114
127
  `;
115
128
  }
129
+ /**
130
+ * An app-specific env-var prefix derived from the project name
131
+ * (`my-saas` → `MY_SAAS`), suggested for variables whose generic names
132
+ * (`DATABASE_URL`, …) other projects export too.
133
+ */
134
+ export function envPrefix(name) {
135
+ let base = name
136
+ .replace(/^@[^/]+\//, '')
137
+ .toUpperCase()
138
+ .replace(/[^A-Z0-9]+/g, '_');
139
+ // Trim underscores in linear time (an anchored `_+$` regex backtracks).
140
+ let start = 0;
141
+ let end = base.length;
142
+ while (start < end && base[start] === '_')
143
+ start++;
144
+ while (end > start && base[end - 1] === '_')
145
+ end--;
146
+ base = base.slice(start, end);
147
+ return base === '' || /^\d/.test(base) ? `APP_${base}` : base;
148
+ }
116
149
  export function envExample(options) {
117
- return `PORT=3000
150
+ const prefix = envPrefix(options.name);
151
+ return `# Nothing loads this file for you: copy it to .env and start with
152
+ # \`node --env-file=.env\` / \`tsx --env-file=.env\`, or export the variables.
153
+ # --env-file NEVER overrides a variable already exported in your shell: if
154
+ # another project exported DATABASE_URL, the app silently uses THAT one. Give
155
+ # generic names an app-specific prefix, e.g. ${prefix}_DATABASE_URL.
156
+ PORT=3000
118
157
  HOST=0.0.0.0
119
158
  LOG_LEVEL=info
120
159
  NODE_ENV=development
121
- ${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' : ''}`;
160
+ ${options.auth ? '# Required unless NODE_ENV is development/test — `pnpm start` refuses to boot\n# without it. Generate a strong one: openssl rand -base64 48\n# APP_SECRET=\n' : ''}`;
122
161
  }
123
162
  export function appTs(options) {
124
163
  const imports = [
@@ -134,8 +173,11 @@ export function appTs(options) {
134
173
  `eventsPlugin()`,
135
174
  `securityPlugin({
136
175
  // Secure response headers (HSTS, X-Frame-Options, nosniff, …) are on by
137
- // default. Turn on rate limiting and a CORS allow-list for production:
138
- // rateLimit: { limit: 120, windowMs: 60_000 },
176
+ // default, and so is a global per-IP rate limit (auth routes add their
177
+ // own stricter budget). Behind a proxy, make the adapter set request.ip
178
+ // from it; with several replicas, pass a shared \`store\`.
179
+ rateLimit: { limit: 120, windowMs: 60_000 },
180
+ // Add a CORS allow-list if a browser app on another origin calls the API:
139
181
  // cors: { origin: ['https://app.example.com'], credentials: true },
140
182
  })`,
141
183
  ];
@@ -162,6 +204,25 @@ export function appTs(options) {
162
204
  // password recovery. mfaRoutes(): TOTP enroll/activate/status/disable.
163
205
  routesExpression = '[...appRoutes, ...authRoutes(), ...mfaRoutes()]';
164
206
  }
207
+ if (enforcesMembership(options)) {
208
+ // Secure by default: the tenant comes from client input (x-tenant-id / Host),
209
+ // so it is identification, never authorization. tenantMembershipPlugin()
210
+ // rejects (403) every authenticated request for a tenant the user is not a
211
+ // member of. Routes that act outside a single tenant opt out with
212
+ // meta: { central: true }.
213
+ imports[0] = `import { createApp, definePlugin } from '@basaltkit/core'`;
214
+ imports.push(`import { TEAMS, teamsPlugin, tenantMembershipPlugin } from '@basaltkit/teams'`);
215
+ plugins.push(`teamsPlugin({
216
+ // TODO: the default membership/invitation stores are in-memory — pass
217
+ // persistent ones (memberships, invitations) before production.
218
+ })`);
219
+ plugins.push(`// Rejects authenticated requests for a tenant the user is not a member of.
220
+ tenantMembershipPlugin()`);
221
+ plugins.push(`// Dev-only seed: each new registrant joins the 'demo' tenant so the scaffold
222
+ // works out of the box. Never in production — add members explicitly there
223
+ // (on tenant creation, or via TEAMS invite()/accept()).
224
+ ...(env.NODE_ENV === 'production' ? [] : [demoMembershipSeed()])`);
225
+ }
165
226
  if (options.billing) {
166
227
  imports.push(`import { definePlans, subscriptionsPlugin } from '@basaltkit/subscriptions'`);
167
228
  plugins.push(`subscriptionsPlugin({
@@ -211,7 +272,21 @@ export function buildApp(options: BuildAppOptions = {}) {
211
272
  ],
212
273
  })
213
274
  }
214
- `;
275
+ ${enforcesMembership(options)
276
+ ? `
277
+ /** Dev-only: adds each new registrant as a member of the 'demo' tenant. */
278
+ function demoMembershipSeed() {
279
+ return definePlugin({
280
+ name: 'app:demo-membership-seed',
281
+ register({ container, hooks }) {
282
+ hooks.on('auth:registered', async ({ user }) => {
283
+ await container.get(TEAMS).addMember('demo', user.id, 'member')
284
+ })
285
+ },
286
+ })
287
+ }
288
+ `
289
+ : ''}`;
215
290
  }
216
291
  export function routesTs(options) {
217
292
  const endpoints = [
@@ -283,12 +358,29 @@ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
283
358
  }
284
359
  `;
285
360
  }
361
+ /**
362
+ * `pnpm dev` entrypoint. Opts into NODE_ENV=development (dev secret defaults,
363
+ * pretty logs) unless NODE_ENV is already set, then starts the server. Kept out
364
+ * of server.ts on purpose: `pnpm start` runs server.ts directly, where an unset
365
+ * NODE_ENV counts as production and secrets are required (fail-closed).
366
+ */
367
+ export function devTs() {
368
+ return `// Development entrypoint (\`pnpm dev\`). \`pnpm start\` runs server.ts directly,
369
+ // where an unset NODE_ENV counts as production: secrets are then required.
370
+ process.env['NODE_ENV'] ??= 'development'
371
+ await import('./server.js')
372
+ `;
373
+ }
286
374
  export function basaltBin() {
287
375
  return `#!/usr/bin/env node
288
376
  import { runCli } from '@basaltkit/cli'
289
377
  import { generatorCommands } from '@basaltkit/generator'
290
378
  import { prismaSyncCommand } from '@basaltkit/prisma'
291
- import { buildApp } from '../src/app.js'
379
+
380
+ // Dev tooling: opt into development defaults unless NODE_ENV is already set
381
+ // (imported dynamically below so env.ts is evaluated after this line).
382
+ process.env['NODE_ENV'] ??= 'development'
383
+ const { buildApp } = await import('../src/app.js')
292
384
 
293
385
  // The 'basalt' CLI: boots the app WITH the dev/CLI commands, runs one, shuts down.
294
386
  // The dev tools (@basaltkit/generator; add @basaltkit/ai for ai:*) are imported
@@ -362,6 +454,18 @@ pnpm install
362
454
  pnpm dev # API on http://localhost:3000
363
455
  pnpm test
364
456
  \`\`\`
457
+
458
+ ## Environment
459
+
460
+ \`src/env.ts\` validates \`process.env\` — nothing loads \`.env\` for you. Copy
461
+ \`.env.example\` to \`.env\` and launch with \`--env-file=.env\` (Node/tsx), or
462
+ export the variables.
463
+
464
+ **\`--env-file\` never overrides a variable that is already exported.** In a shell
465
+ where another project exported \`DATABASE_URL\` (or \`PORT\`), this app boots
466
+ against THAT value and only fails on the first request that touches it. Give
467
+ generic names an app-specific prefix — \`${envPrefix(options.name)}_DATABASE_URL\` rather than
468
+ \`DATABASE_URL\` — and check \`env | grep DATABASE_URL\` when in doubt.
365
469
  ${options.cli
366
470
  ? `
367
471
  ## The \`basalt\` CLI
@@ -375,6 +479,18 @@ pnpm basalt make:service Project # a single artifact (--force to overwrite)
375
479
 
376
480
  Generated resources land in \`src/modules/<name>/\`. Register the generated
377
481
  plugin in \`src/app.ts\` to wire it up.
482
+
483
+ With pnpm 11, \`pnpm basalt …\` first verifies dependencies
484
+ (\`verifyDepsBeforeRun\`, default \`install\`): when any workspace project is out
485
+ of sync it runs \`pnpm install\` — network included — before the command. To
486
+ run the CLI without that check, call it directly:
487
+
488
+ \`\`\`bash
489
+ node_modules/.bin/tsx bin/basalt.ts make:resource Project
490
+ \`\`\`
491
+
492
+ or set \`verifyDepsBeforeRun: warn\` in \`pnpm-workspace.yaml\` (a conscious
493
+ choice: you then run \`pnpm install\` yourself after dependency changes).
378
494
  `
379
495
  : ''}${options.ui
380
496
  ? `
@@ -433,6 +549,29 @@ export function mcpJson(_options) {
433
549
  },
434
550
  }, null, 2)}\n`;
435
551
  }
552
+ /**
553
+ * Keeps secrets and local state out of Docker build contexts, so a
554
+ * `COPY . .` can never bake `.env` or private keys into an image layer. Every
555
+ * rule is `**`-prefixed: .dockerignore patterns are anchored at the context
556
+ * root, so a bare `.env` or `*.pem` would still let `prisma/.env` or
557
+ * `certs/server.key` through.
558
+ */
559
+ export function dockerignore() {
560
+ return `**/.env
561
+ **/.env.*
562
+ !**/.env.example
563
+ **/.npmrc
564
+ **/.git
565
+ **/node_modules
566
+ **/coverage
567
+ **/*.log
568
+ **/*.pem
569
+ **/*.key
570
+ **/*.p12
571
+ **/*.pfx
572
+ **/.DS_Store
573
+ `;
574
+ }
436
575
  export function gitignore() {
437
576
  return `node_modules/
438
577
  dist/
@@ -447,6 +586,12 @@ dist/
447
586
  * msgpackr-extract (optional native accelerator via BullMQ) is declined —
448
587
  * msgpackr falls back to pure JS. With --ui, `web` is a workspace member so
449
588
  * its own dependencies resolve.
589
+ *
590
+ * pnpm 11 notes (BK-002), spelled out in the file because both bite silently:
591
+ * `minimumReleaseAgeExclude` is evaluated first-match-wins BY PACKAGE NAME, so
592
+ * per-version exclusions of one package must be a single `||` union entry; and
593
+ * `verifyDepsBeforeRun` defaults to `install`, so any `pnpm <script>` may run
594
+ * `pnpm install` first — `warn` is offered commented out, never set for you.
450
595
  */
451
596
  export function pnpmWorkspaceYaml(options) {
452
597
  return `${options.ui
@@ -460,5 +605,16 @@ export function pnpmWorkspaceYaml(options) {
460
605
  # pnpm's minimumReleaseAge policy so \`pnpm up\` is never blocked on a fresh release.
461
606
  minimumReleaseAgeExclude:
462
607
  - '@basaltkit/*'
608
+ # To let specific versions of ONE package bypass the policy, write ONE entry
609
+ # with a \`||\` union. pnpm evaluates this list first match wins by package
610
+ # name, so a second '<name>@<version>' entry for the same package is ignored:
611
+ # - '@types/node@22.20.4 || 26.6.2'
612
+
613
+ # pnpm 11 checks dependencies before every \`pnpm <script>\` / \`pnpm exec\`
614
+ # (verifyDepsBeforeRun defaults to \`install\`): when node_modules is out of sync
615
+ # with ANY workspace project's manifest, it runs \`pnpm install\` first — network
616
+ # and supply-chain checks included. To opt out consciously (you then run
617
+ # \`pnpm install\` yourself after dependency changes), uncomment:
618
+ # verifyDepsBeforeRun: warn
463
619
  `;
464
620
  }
@@ -0,0 +1 @@
1
+ export declare const SCAFFOLD_VERSIONS: Readonly<Record<string, string>>;
@@ -0,0 +1,24 @@
1
+ // GENERATED by scripts/sync-versions.mjs (runs before every build) — do not edit.
2
+ // The dependency range a new app gets for each @basaltkit/* package: the
3
+ // current release line (^<major>.<minor>.0) of the workspace version.
4
+ export const SCAFFOLD_VERSIONS = {
5
+ '@basaltkit/admin': '^2.0.0',
6
+ '@basaltkit/admin-shadcn': '^1.1.0',
7
+ '@basaltkit/ai-mcp': '^0.1.0',
8
+ '@basaltkit/auth': '^3.1.0',
9
+ '@basaltkit/cli': '^1.2.0',
10
+ '@basaltkit/config': '^1.1.0',
11
+ '@basaltkit/core': '^1.3.0',
12
+ '@basaltkit/env': '^3.0.0',
13
+ '@basaltkit/events': '^1.3.0',
14
+ '@basaltkit/fastify': '^2.2.0',
15
+ '@basaltkit/generator': '^1.4.0',
16
+ '@basaltkit/logger': '^1.3.0',
17
+ '@basaltkit/mcp': '^3.0.0',
18
+ '@basaltkit/prisma': '^2.1.0',
19
+ '@basaltkit/sdk': '^2.0.0',
20
+ '@basaltkit/subscriptions': '^4.0.0',
21
+ '@basaltkit/teams': '^3.0.0',
22
+ '@basaltkit/tenancy': '^2.0.0',
23
+ '@basaltkit/testing': '^2.0.0',
24
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.4.2",
3
+ "version": "1.6.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },
@@ -21,24 +21,25 @@
21
21
  "dist"
22
22
  ],
23
23
  "devDependencies": {
24
- "@types/node": "^26.3.0",
24
+ "@types/node": "^26.6.1",
25
25
  "typescript": "^7.0.2",
26
- "vitest": "^4.1.11",
27
- "zod": "^3.24.0 || ^4.0.0",
28
- "@basaltkit/auth": "^1.8.0",
29
- "@basaltkit/cli": "^1.2.3",
26
+ "vitest": "^5.0.1",
27
+ "zod": "^4.6.5",
28
+ "@basaltkit/auth": "^3.1.0",
30
29
  "@basaltkit/config": "^1.1.2",
31
- "@basaltkit/core": "^1.3.1",
32
- "@basaltkit/env": "^1.0.2",
33
- "@basaltkit/events": "^1.1.1",
34
- "@basaltkit/fastify": "^1.8.1",
35
- "@basaltkit/generator": "^1.2.2",
36
- "@basaltkit/logger": "^1.2.2",
37
- "@basaltkit/mcp": "^1.1.1",
38
- "@basaltkit/prisma": "^1.4.3",
39
- "@basaltkit/subscriptions": "^2.8.1",
40
- "@basaltkit/tenancy": "^1.4.2",
41
- "@basaltkit/testing": "^1.1.1",
30
+ "@basaltkit/core": "^1.3.2",
31
+ "@basaltkit/env": "^3.0.0",
32
+ "@basaltkit/cli": "^1.2.5",
33
+ "@basaltkit/events": "^1.3.0",
34
+ "@basaltkit/generator": "^1.4.0",
35
+ "@basaltkit/logger": "^1.3.0",
36
+ "@basaltkit/mcp": "^3.0.0",
37
+ "@basaltkit/prisma": "^2.1.0",
38
+ "@basaltkit/teams": "^3.0.1",
39
+ "@basaltkit/fastify": "^2.2.0",
40
+ "@basaltkit/tenancy": "^2.0.0",
41
+ "@basaltkit/testing": "^2.0.2",
42
+ "@basaltkit/subscriptions": "^4.0.0",
42
43
  "@basaltkit/tsconfig": "^0.24.0"
43
44
  },
44
45
  "publishConfig": {
@@ -61,7 +62,8 @@
61
62
  "boilerplate"
62
63
  ],
63
64
  "scripts": {
64
- "build": "tsc -p tsconfig.build.json",
65
+ "build": "node scripts/sync-versions.mjs && tsc -p tsconfig.build.json",
66
+ "sync-versions": "node scripts/sync-versions.mjs",
65
67
  "test": "vitest run",
66
68
  "typecheck": "tsc --noEmit"
67
69
  }