create-basalt 1.10.0 → 1.12.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,49 @@
1
+ import { hashContent } from './project/manifest.js';
2
+ import { devTs, ENV_EXAMPLE_LEAD, LEGACY_ENV_EXAMPLE_LEAD } from './templates.js';
3
+ /**
4
+ * `src/dev.ts` (the `pnpm dev` entrypoint) and the `.env.example` header across
5
+ * create-basalt releases. Up to 1.10, nothing loaded `.env`: `pnpm dev` booted
6
+ * without the variables the example told you to set. `update` brings untouched
7
+ * copies to the current templates; customised ones get the snippet instead.
8
+ */
9
+ /** Hashes of every src/dev.ts an earlier create-basalt release generated (CRLF-insensitive). */
10
+ export const LEGACY_DEV_HASHES = new Set([
11
+ // 2026-09-19 → create-basalt 1.10: NODE_ENV defaulted, no .env loading.
12
+ 'sha256-f25f09fed1b36281a806e29f5aab139a1dda0c5fa898f7c4c71b6ac294326cb7',
13
+ ]);
14
+ /** Marker proving a src/dev.ts already loads .env. */
15
+ export const DEV_ENV_MARKER = 'process.loadEnvFile(';
16
+ /** Where a src/dev.ts stands: loads .env already, an untouched old template, or user code. */
17
+ export function devEntryStatus(content, manifestHash) {
18
+ if (content === undefined)
19
+ return 'absent';
20
+ if (content.includes(DEV_ENV_MARKER))
21
+ return 'current';
22
+ const hash = hashContent(content);
23
+ return LEGACY_DEV_HASHES.has(hash) || hash === manifestHash ? 'patchable' : 'modified';
24
+ }
25
+ /** The current src/dev.ts — what a patchable one becomes. */
26
+ export const patchDevEntry = () => devTs();
27
+ /** What to paste by hand into a customised src/dev.ts. */
28
+ export const MANUAL_DEV_SNIPPET = `Paste this at the top of src/dev.ts, BEFORE the server is imported (keep that
29
+ import dynamic — \`await import('./server.js')\` — so the variables are set first):
30
+
31
+ import { existsSync } from 'node:fs'
32
+ import { fileURLToPath } from 'node:url'
33
+
34
+ // Development loads .env (when present) like \`node --env-file=.env\`: a variable
35
+ // already exported in your shell still wins, the file only fills in what is missing.
36
+ const envFile = fileURLToPath(new URL('../.env', import.meta.url))
37
+ if (existsSync(envFile)) process.loadEnvFile(envFile)
38
+ `;
39
+ /**
40
+ * `.env.example` with the old "nothing loads this file" lead swapped for the
41
+ * current one; undefined when the file does not start with the old lead (a
42
+ * newer or hand-written file is left alone — it is only comments).
43
+ */
44
+ export function patchEnvExample(content) {
45
+ const normalized = content.replace(/\r\n/g, '\n');
46
+ if (!normalized.startsWith(`${LEGACY_ENV_EXAMPLE_LEAD}\n`))
47
+ return undefined;
48
+ return `${ENV_EXAMPLE_LEAD}${normalized.slice(LEGACY_ENV_EXAMPLE_LEAD.length)}`;
49
+ }
package/dist/index.d.ts CHANGED
@@ -68,6 +68,12 @@ export declare function resolveRunDefaults(input: {
68
68
  export declare function scaffoldFiles(options: ProjectOptions): Record<string, string>;
69
69
  /** Generates a ready-to-run Basalt app. Does not install dependencies. */
70
70
  export declare function createProject(input: CreateProjectInput): Promise<CreateProjectResult>;
71
+ /**
72
+ * A random APP_SECRET for the development .env: 48 random bytes, base64url —
73
+ * redrawn in the (rare) case it happens to contain a word `secret()` rejects
74
+ * as a placeholder (`test`, `0000`, …).
75
+ */
76
+ export declare function generateAppSecret(random?: (size: number) => Buffer): string;
71
77
  export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, resolveFileVersions, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, type FreshVersion, type HeldBackVersion, type ResolveLatestOptions, type VersionResolution, } from './latest-versions.js';
72
78
  export { runProjectCommand, PROJECT_COMMANDS, isProjectCommand, type ProjectCommandDeps } from './project/run.js';
73
79
  export { planUpdate, type UpdatePlan, type UpdateEntry } from './project/update.js';
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
2
3
  import { dirname, join, resolve } from 'node:path';
3
- import { appTest, appTs, dbTs, devTs, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, prismaConfigTs, prismaSchema, prismaSeedTs, readme, routesTs, serverTs, tsconfigJson, } from './templates.js';
4
+ import { appTest, appTs, dbTs, devEnvFile, devTs, dockerfile, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, prismaConfigTs, prismaSchema, prismaSeedTs, readme, routesTs, serverTs, tsconfigBuildJson, tsconfigJson, } from './templates.js';
4
5
  import { uiFiles } from './templates-ui.js';
6
+ import { INSECURE_SECRET } from './project/doctor.js';
5
7
  import { createManifest, MANIFEST_PATH, serializeManifest } from './project/manifest.js';
6
8
  import { resolveFileVersions } from './latest-versions.js';
7
9
  export class TargetNotEmptyError extends Error {
@@ -41,8 +43,10 @@ export function scaffoldFiles(options) {
41
43
  return {
42
44
  'package.json': packageJson(options),
43
45
  'tsconfig.json': tsconfigJson(options),
46
+ 'tsconfig.build.json': tsconfigBuildJson(),
44
47
  '.env.example': envExample(options),
45
48
  '.gitignore': gitignore(options),
49
+ Dockerfile: dockerfile(),
46
50
  '.dockerignore': dockerignore(),
47
51
  'README.md': readme(options),
48
52
  'pnpm-workspace.yaml': pnpmWorkspaceYaml(options),
@@ -85,13 +89,30 @@ export async function createProject(input) {
85
89
  const files = scaffoldFiles(options);
86
90
  const versions = input.resolveLatest ? await resolveFileVersions(files, input.registry) : undefined;
87
91
  files[MANIFEST_PATH] = serializeManifest(createManifest(options, files));
92
+ // A ready-to-use .env for development, so `pnpm dev` / `pnpm basalt` boot
93
+ // without a copy step. Written after the manifest on purpose: it is
94
+ // git-ignored local state (with a generated secret), not a template file.
95
+ files['.env'] = devEnvFile(options, generateAppSecret());
88
96
  for (const [path, content] of Object.entries(files)) {
89
97
  const target = join(dir, path);
90
98
  await mkdir(dirname(target), { recursive: true });
91
- await writeFile(target, content);
99
+ // .env holds a secret: owner-only, like `umask 077` would make it.
100
+ await writeFile(target, content, path === '.env' ? { mode: 0o600 } : undefined);
92
101
  }
93
102
  return { dir, files: Object.keys(files).sort(), options, ...(versions ? { versions } : {}) };
94
103
  }
104
+ /**
105
+ * A random APP_SECRET for the development .env: 48 random bytes, base64url —
106
+ * redrawn in the (rare) case it happens to contain a word `secret()` rejects
107
+ * as a placeholder (`test`, `0000`, …).
108
+ */
109
+ export function generateAppSecret(random = randomBytes) {
110
+ for (;;) {
111
+ const value = random(48).toString('base64url');
112
+ if (!INSECURE_SECRET.test(value))
113
+ return value;
114
+ }
115
+ }
95
116
  export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, resolveFileVersions, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, } from './latest-versions.js';
96
117
  export { runProjectCommand, PROJECT_COMMANDS, isProjectCommand } from './project/run.js';
97
118
  export { planUpdate } from './project/update.js';
@@ -33,6 +33,7 @@
33
33
  export const THIRD_PARTY_VERSIONS = {
34
34
  '@prisma/adapter-pg': '^7.10.0',
35
35
  '@prisma/client': '^7.10.0',
36
+ '@prisma/client-runtime-utils': '^7.10.0',
36
37
  '@tailwindcss/vite': '^4.3.3',
37
38
  '@types/node': '^26.6.2',
38
39
  '@types/pg': '^8.15.6',
@@ -9,7 +9,7 @@ import type { Colors } from './term.js';
9
9
  export type Level = 'ok' | 'info' | 'warn' | 'error';
10
10
  export interface DoctorFinding {
11
11
  level: Level;
12
- /** Short area label: node, pm, deps, env, prisma, mcp, tooling. */
12
+ /** Short area label: node, pm, deps, env, prisma, mcp, tooling, build. */
13
13
  area: string;
14
14
  message: string;
15
15
  }
@@ -23,6 +23,8 @@ export interface DoctorOptions {
23
23
  /** `<pm> --version` (injected; undefined when unavailable). */
24
24
  pmVersion?: string;
25
25
  }
26
+ /** Mirrors the placeholder check of `secret()` in @basaltkit/env (src/secret.ts). */
27
+ export declare const INSECURE_SECRET: RegExp;
26
28
  /** The Node range every @basaltkit package declares. */
27
29
  export declare const BASALT_NODE_RANGE = ">=22.5.0";
28
30
  /** Installed version of `name` as the app resolves it (top-level node_modules). */
@@ -31,6 +33,14 @@ export declare function installedVersion(dir: string, name: string): Promise<str
31
33
  export declare function parseDotenv(text: string): Record<string, string>;
32
34
  /** The env prefix src/env.ts passes to defineEnv (`prefix: 'X'` or `prefix: { value: 'X' … }`). */
33
35
  export declare function envPrefixOf(envTs: string | undefined, name: string): string | undefined;
36
+ /**
37
+ * The variables src/env.ts requires: keys of the schema object passed to
38
+ * `defineEnv` whose declaration has no `.default(`, `.optional(`, `.catch(` or
39
+ * `.nullish(` (`secret()` is checked separately — it has a dev default).
40
+ * A light scan, not a parser: comments and strings are skipped, nesting is
41
+ * tracked. Undefined when there is no `defineEnv({ … })` to read.
42
+ */
43
+ export declare function requiredEnvKeys(envTs: string | undefined): string[] | undefined;
34
44
  export declare function runDoctor(ctx: ProjectContext, options?: DoctorOptions): Promise<DoctorFinding[]>;
35
45
  export declare function renderFindings(findings: readonly DoctorFinding[], colors: Colors): string[];
36
46
  /** `create-basalt info`: a paste-able versions summary for bug reports. */
@@ -1,15 +1,19 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { readdir, readFile } from 'node:fs/promises';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
3
  import { arch, platform } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { binStatus } from '../bin-template.js';
6
+ import { devEntryStatus } from '../dev-entry.js';
6
7
  import { alwaysLatest, lookupLatestVersions } from '../latest-versions.js';
8
+ import { PRODUCTION_ENTRY } from '../stubs.js';
7
9
  import { envPrefix } from '../templates.js';
8
10
  import { allDependencies } from './context.js';
9
11
  import { ownVersion } from './manifest.js';
12
+ import { LEGACY_PRISMA_OUTPUT, RUNS_TSX } from './production.js';
13
+ import { legacySeedScriptFix } from './seed-script.js';
10
14
  import { compareVersions, parseSimpleRange, parseVersion, satisfies } from './semver.js';
11
15
  /** Mirrors the placeholder check of `secret()` in @basaltkit/env (src/secret.ts). */
12
- const INSECURE_SECRET = /change.?me|changeme|placeholder|example|secret|password|default|test|xxxx+|0000+/i;
16
+ export const INSECURE_SECRET = /change.?me|changeme|placeholder|example|secret|password|default|test|xxxx+|0000+/i;
13
17
  /** The Node range every @basaltkit package declares. */
14
18
  export const BASALT_NODE_RANGE = '>=22.5.0';
15
19
  async function readJson(path) {
@@ -87,6 +91,72 @@ export function envPrefixOf(envTs, name) {
87
91
  const match = /prefix:\s*(?:\{\s*value:\s*)?['"]([A-Z0-9_]+)['"]/.exec(envTs);
88
92
  return match ? match[1] : /prefix:/.test(envTs) ? undefined : '';
89
93
  }
94
+ /**
95
+ * The variables src/env.ts requires: keys of the schema object passed to
96
+ * `defineEnv` whose declaration has no `.default(`, `.optional(`, `.catch(` or
97
+ * `.nullish(` (`secret()` is checked separately — it has a dev default).
98
+ * A light scan, not a parser: comments and strings are skipped, nesting is
99
+ * tracked. Undefined when there is no `defineEnv({ … })` to read.
100
+ */
101
+ export function requiredEnvKeys(envTs) {
102
+ if (envTs === undefined)
103
+ return undefined;
104
+ const call = envTs.indexOf('defineEnv(');
105
+ const open = call < 0 ? -1 : envTs.indexOf('{', call);
106
+ if (open < 0)
107
+ return undefined;
108
+ const entries = [];
109
+ let depth = 0;
110
+ let current;
111
+ for (let i = open; i < envTs.length; i++) {
112
+ const char = envTs[i];
113
+ if (char === '/' && envTs[i + 1] === '/') {
114
+ const end = envTs.indexOf('\n', i);
115
+ i = end < 0 ? envTs.length : end - 1;
116
+ continue;
117
+ }
118
+ if (char === '/' && envTs[i + 1] === '*') {
119
+ const end = envTs.indexOf('*/', i + 2);
120
+ i = end < 0 ? envTs.length : end + 1;
121
+ continue;
122
+ }
123
+ if (char === "'" || char === '"' || char === '`') {
124
+ let end = i + 1;
125
+ while (end < envTs.length && envTs[end] !== char)
126
+ end += envTs[end] === '\\' ? 2 : 1;
127
+ if (current)
128
+ current.body += envTs.slice(i, end + 1);
129
+ i = end;
130
+ continue;
131
+ }
132
+ if (char === '{' || char === '(' || char === '[')
133
+ depth++;
134
+ else if (char === '}' || char === ')' || char === ']') {
135
+ depth--;
136
+ if (depth === 0)
137
+ break;
138
+ }
139
+ if (depth === 1 && (char === ',' || char === '{')) {
140
+ if (current)
141
+ entries.push(current);
142
+ current = undefined;
143
+ const start = skipTrivia(envTs, i + 1);
144
+ const match = /^([A-Z][A-Z0-9_]*)\s*:/.exec(envTs.slice(start, start + 200));
145
+ if (match) {
146
+ current = { key: match[1], body: '' };
147
+ i = start + match[0].length - 1;
148
+ }
149
+ continue;
150
+ }
151
+ if (current)
152
+ current.body += char;
153
+ }
154
+ if (current)
155
+ entries.push(current);
156
+ return entries
157
+ .filter(({ body }) => !/\.(default|optional|catch|nullish)\(|\bsecret\(/.test(body))
158
+ .map(({ key }) => key);
159
+ }
90
160
  export async function runDoctor(ctx, options = {}) {
91
161
  const findings = [];
92
162
  const push = (level, area, message) => {
@@ -203,8 +273,10 @@ export async function runDoctor(ctx, options = {}) {
203
273
  const envTs = await readText(join(dir, 'src', 'env.ts'));
204
274
  const dotenvText = await readText(join(dir, '.env'));
205
275
  const dotenv = dotenvText ? parseDotenv(dotenvText) : {};
206
- if (dotenvText === undefined && existsSync(join(dir, '.env.example'))) {
207
- push('info', 'env', 'No .env — copy .env.example to .env (and start with --env-file=.env) or export the variables.');
276
+ const exampleText = await readText(join(dir, '.env.example'));
277
+ const example = exampleText ? parseDotenv(exampleText) : {};
278
+ if (dotenvText === undefined && exampleText !== undefined) {
279
+ push('warn', 'env', 'No .env — `dev` and `basalt` load it for development: `cp .env.example .env` (or export the variables).');
208
280
  }
209
281
  const prefix = envPrefixOf(envTs, ctx.options.name);
210
282
  const lookup = (name) => {
@@ -237,12 +309,25 @@ export async function runDoctor(ctx, options = {}) {
237
309
  push('ok', 'env', `${label} is set (${secret.from}, ${secret.value.length} characters).`);
238
310
  }
239
311
  }
240
- if (ctx.options.prisma) {
241
- const url = lookup('DATABASE_URL');
242
- if (!url)
243
- push('warn', 'env', `${prefix ? `${prefix}_` : ''}DATABASE_URL is not set — the app does not boot without it.`);
244
- else
245
- push('ok', 'env', `${prefix ? `${prefix}_` : ''}DATABASE_URL is set (${url.from}).`);
312
+ // Required variables: declared in src/env.ts without a default (from the
313
+ // environment or .env, the way the dev entrypoints load it). Without a
314
+ // readable schema, a Prisma app still needs DATABASE_URL.
315
+ const required = requiredEnvKeys(envTs) ?? (ctx.options.prisma ? ['DATABASE_URL'] : []);
316
+ for (const name of required) {
317
+ const label = prefix ? `${prefix}_${name}` : name;
318
+ const found = lookup(name);
319
+ if (found) {
320
+ push('ok', 'env', `${label} is set (${found.from}).`);
321
+ continue;
322
+ }
323
+ const inExample = [label, name].some((key) => example[key] !== undefined && example[key] !== '');
324
+ const fix = dotenvText === undefined
325
+ ? exampleText !== undefined
326
+ ? `\`cp .env.example .env\`${inExample ? '' : ` and set ${label} in it`}`
327
+ : `create .env with ${label}=…`
328
+ : `set ${label} in .env${inExample ? ' (.env.example has an example value)' : ''}`;
329
+ const database = name === 'DATABASE_URL' && ctx.options.prisma ? ' — and start PostgreSQL where it points' : '';
330
+ push('error', 'env', `${label} is not set (environment or .env) — the app does not boot without it. Fix: ${fix}, or export it${database}.`);
246
331
  }
247
332
  // --- Prisma -------------------------------------------------------------------
248
333
  const schemaPath = join(dir, 'prisma', 'schema.prisma');
@@ -262,6 +347,9 @@ export async function runDoctor(ctx, options = {}) {
262
347
  push('warn', 'prisma', `No migrations yet — run \`${ctx.pm === 'npm' ? 'npm run' : ctx.pm} db:migrate\` (the app boots with assertMigrated).`);
263
348
  else
264
349
  push('info', 'prisma', `${count} migration(s) on disk; whether they are applied needs the database: \`prisma migrate status\`.`);
350
+ const seedFix = legacySeedScriptFix(ctx.packageJson, await readText(join(dir, 'prisma.config.ts')));
351
+ if (seedFix !== undefined)
352
+ push('warn', 'prisma', seedFix);
265
353
  }
266
354
  // --- MCP -------------------------------------------------------------------------
267
355
  if (deps['@basaltkit/ai-mcp'] !== undefined) {
@@ -293,14 +381,68 @@ export async function runDoctor(ctx, options = {}) {
293
381
  // --- Project tooling -------------------------------------------------------------
294
382
  const bin = await readText(join(dir, 'bin', 'basalt.ts'));
295
383
  const status = binStatus(bin, ctx.manifest?.files['bin/basalt.ts']);
296
- if (status === 'patchable')
297
- push('warn', 'tooling', 'bin/basalt.ts predates the project commands — `npx create-basalt@latest update` patches it.');
298
- if (status === 'modified')
299
- push('info', 'tooling', 'bin/basalt.ts is customised and does not delegate update/add/doctor/info — see `create-basalt update` for the snippet.');
384
+ if (status === 'patchable') {
385
+ push('warn', 'tooling', 'bin/basalt.ts is an older template (no .env loading / pre-boot project commands) — `npx create-basalt@latest update` patches it.');
386
+ }
387
+ if (status === 'modified') {
388
+ push('info', 'tooling', 'bin/basalt.ts is customised and lacks the current preludes (project commands, .env loading, pre-boot `upgrade`) — see `create-basalt update` for the snippet.');
389
+ }
390
+ const dev = await readText(join(dir, 'src', 'dev.ts'));
391
+ const devStatus = devEntryStatus(dev, ctx.manifest?.files['src/dev.ts']);
392
+ if (devStatus === 'patchable')
393
+ push('warn', 'tooling', 'src/dev.ts does not load .env (older template) — `npx create-basalt@latest update` patches it.');
394
+ if (devStatus === 'modified')
395
+ push('info', 'tooling', 'src/dev.ts is customised and does not load .env — see `create-basalt update` for the snippet.');
300
396
  if (!ctx.manifest)
301
397
  push('info', 'tooling', 'No .basalt/project.json (scaffolded before create-basalt 1.10) — features are inferred from package.json.');
398
+ // --- Production path (BK-026) — static: nothing is built or started ----------
399
+ const scripts = ctx.packageJson.scripts ?? {};
400
+ const run = (script) => `${ctx.pm === 'npm' ? 'npm run' : ctx.pm} ${script}`;
401
+ const start = scripts['start'];
402
+ const tsxIsDevOnly = ctx.packageJson.dependencies?.['tsx'] === undefined && ctx.packageJson.devDependencies?.['tsx'] !== undefined;
403
+ if (start !== undefined && RUNS_TSX.test(start) && tsxIsDevOnly) {
404
+ push('warn', 'build', `\`start\` runs tsx ("${start}"), but tsx is only a devDependency — a production install (--prod) has no tsx. Build and run the compiled server: \`npx create-basalt@latest update\` prints the scripts.`);
405
+ }
406
+ if (scripts['build'] === undefined) {
407
+ push('warn', 'build', `No \`build\` script — there is no tested way to run the app on plain node. \`npx create-basalt@latest update\` adds tsconfig.build.json and \`build\`.`);
408
+ }
409
+ else {
410
+ const built = await mtimeOf(join(dir, PRODUCTION_ENTRY));
411
+ const source = await newestMtime(join(dir, 'src'));
412
+ if (built !== undefined && source !== undefined && source > built) {
413
+ push('info', 'build', `${PRODUCTION_ENTRY} is older than src/ — run \`${run('build')}\` before \`${run('start')}\`.`);
414
+ }
415
+ }
416
+ if (schema !== undefined) {
417
+ if (LEGACY_PRISMA_OUTPUT.test(schema)) {
418
+ push('warn', 'build', 'The Prisma client is generated under src/ — `tsc` does not copy its .js files, so the built app cannot load it. `npx create-basalt@latest update` prints how to move it to ./generated.');
419
+ }
420
+ if (deps['@prisma/client'] !== undefined && deps['@prisma/client-runtime-utils'] === undefined) {
421
+ push('warn', 'build', 'The generated Prisma client requires @prisma/client-runtime-utils by name — declare it as a dependency (same range as @prisma/client), or plain `node` cannot load the client under pnpm.');
422
+ }
423
+ }
302
424
  return findings;
303
425
  }
426
+ async function mtimeOf(path) {
427
+ try {
428
+ return (await stat(path)).mtimeMs;
429
+ }
430
+ catch {
431
+ return undefined;
432
+ }
433
+ }
434
+ /** Newest modification time of the .ts files under `dir` (recursive), or undefined. */
435
+ async function newestMtime(dir) {
436
+ let newest;
437
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
438
+ for (const entry of entries) {
439
+ const path = join(dir, entry.name);
440
+ const time = entry.isDirectory() ? await newestMtime(path) : entry.name.endsWith('.ts') ? await mtimeOf(path) : undefined;
441
+ if (time !== undefined && (newest === undefined || time > newest))
442
+ newest = time;
443
+ }
444
+ return newest;
445
+ }
304
446
  export function renderFindings(findings, colors) {
305
447
  const icon = {
306
448
  ok: colors.green('✓'),
@@ -348,3 +490,23 @@ export async function projectInfo(ctx, options = {}) {
348
490
  }
349
491
  return lines;
350
492
  }
493
+ /** Index of the first character at or after `from` that is not whitespace or a comment (linear scan). */
494
+ function skipTrivia(source, from) {
495
+ let i = from;
496
+ while (i < source.length) {
497
+ const char = source[i];
498
+ if (char === ' ' || char === '\t' || char === '\n' || char === '\r')
499
+ i++;
500
+ else if (source.startsWith('//', i)) {
501
+ const end = source.indexOf('\n', i);
502
+ i = end === -1 ? source.length : end + 1;
503
+ }
504
+ else if (source.startsWith('/*', i)) {
505
+ const end = source.indexOf('*/', i + 2);
506
+ i = end === -1 ? source.length : end + 2;
507
+ }
508
+ else
509
+ break;
510
+ }
511
+ return i;
512
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The production path (BK-026) for an app scaffolded before create-basalt
3
+ * shipped one: `create-basalt update` OFFERS what is missing — it is part of
4
+ * the printed plan, applied only on confirmation — and never rewrites what the
5
+ * app already has. In particular an existing `start` script is never changed:
6
+ * switching it from tsx to the compiled server changes how the app is
7
+ * deployed, so that is printed as a manual step.
8
+ */
9
+ export interface ProductionPathPlan {
10
+ /** New files: path → content (only files that do not exist yet). */
11
+ files: Record<string, string>;
12
+ /** package.json after the additions (the input text when nothing changes). */
13
+ packageJsonText: string;
14
+ /** One line per change, for the plan's "Project tooling" section. */
15
+ notes: string[];
16
+ /** Steps printed for the user, never applied. */
17
+ manual: string[];
18
+ }
19
+ /** A Prisma generator that still writes the client under src/ — tsc does not copy its .js files to dist/. */
20
+ export declare const LEGACY_PRISMA_OUTPUT: RegExp;
21
+ /** `start` (or any script) that runs TypeScript through tsx. */
22
+ export declare const RUNS_TSX: RegExp;
23
+ export declare const MANUAL_START_SNIPPET = "In package.json, run the compiled server in production (build first: `pnpm build`):\n \"start\": \"node --enable-source-maps dist/src/server.js\",\n \"start:dev\": \"tsx src/server.ts\"";
24
+ export declare const MANUAL_PRISMA_LAYOUT_SNIPPET: string;
25
+ /**
26
+ * What an existing app lacks of the production path: tsconfig.build.json, a
27
+ * `build` script, the Dockerfile and .dockerignore, and — for a Prisma app —
28
+ * a direct `@prisma/client-runtime-utils` dependency. Pure apart from reading
29
+ * the app's files. The Dockerfile is offered to pnpm apps only, and not
30
+ * while the Prisma client is still generated under src/.
31
+ */
32
+ export declare function planProductionPath(dir: string, packageJsonText: string, pm?: string): Promise<ProductionPathPlan>;
@@ -0,0 +1,74 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { DOCKERFILE, DOCKERIGNORE, PRODUCTION_ENTRY } from '../stubs.js';
4
+ import { BUILD_SCRIPT, PRISMA_IMPORTS, START_DEV_SCRIPT, START_SCRIPT, tsconfigBuildJson } from '../templates.js';
5
+ import { mergePackageJson } from './package-json.js';
6
+ /** A Prisma generator that still writes the client under src/ — tsc does not copy its .js files to dist/. */
7
+ export const LEGACY_PRISMA_OUTPUT = /generator\s+\w+\s*\{[^}]*output\s*=\s*"\.\.\/src\/[^"]*"/;
8
+ /** `start` (or any script) that runs TypeScript through tsx. */
9
+ export const RUNS_TSX = /(^|[\s;&|])(?:npx\s+|pnpm\s+(?:exec\s+)?)?tsx(\s|$)|--import[= ]tsx\b/;
10
+ async function readOptional(path) {
11
+ try {
12
+ return await readFile(path, 'utf8');
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ const exists = async (path) => (await readOptional(path)) !== undefined;
19
+ export const MANUAL_START_SNIPPET = `In package.json, run the compiled server in production (build first: \`pnpm build\`):
20
+ "start": "${START_SCRIPT}",
21
+ "start:dev": "${START_DEV_SCRIPT}"`;
22
+ export const MANUAL_PRISMA_LAYOUT_SNIPPET = `The Prisma client is generated under src/, where \`tsc\` does not copy its .js files — \`node ${PRODUCTION_ENTRY}\` cannot load it. Move it out of src/:
23
+ prisma/schema.prisma output = "../generated/prisma"
24
+ package.json "imports": ${JSON.stringify(PRISMA_IMPORTS)}
25
+ src/db.ts import { PrismaClient } from '#db/client.js'
26
+ .gitignore generated/ (instead of src/generated/)
27
+ then \`pnpm db:generate\` and delete src/generated/. The Dockerfile is offered once this is done.`;
28
+ /**
29
+ * What an existing app lacks of the production path: tsconfig.build.json, a
30
+ * `build` script, the Dockerfile and .dockerignore, and — for a Prisma app —
31
+ * a direct `@prisma/client-runtime-utils` dependency. Pure apart from reading
32
+ * the app's files. The Dockerfile is offered to pnpm apps only, and not
33
+ * while the Prisma client is still generated under src/.
34
+ */
35
+ export async function planProductionPath(dir, packageJsonText, pm = 'pnpm') {
36
+ const pkg = JSON.parse(packageJsonText);
37
+ const files = {};
38
+ const notes = [];
39
+ const manual = [];
40
+ if (!(await exists(join(dir, 'tsconfig.build.json')))) {
41
+ files['tsconfig.build.json'] = tsconfigBuildJson();
42
+ notes.push('tsconfig.build.json: what `build` compiles (src/ → dist/, rootDir `.`)');
43
+ }
44
+ const schema = await readOptional(join(dir, 'prisma', 'schema.prisma'));
45
+ const prismaClient = pkg.dependencies?.['@prisma/client'];
46
+ const merged = mergePackageJson(packageJsonText, {
47
+ scripts: { build: BUILD_SCRIPT },
48
+ ...(schema !== undefined && prismaClient !== undefined
49
+ ? { dependencies: { '@prisma/client-runtime-utils': prismaClient } }
50
+ : {}),
51
+ });
52
+ for (const change of merged.changes) {
53
+ if (change.startsWith('scripts:'))
54
+ notes.push(`package.json: a "build" script (${BUILD_SCRIPT})`);
55
+ else
56
+ notes.push(`package.json: @prisma/client-runtime-utils ${prismaClient} — the generated client requires it by name; plain node cannot reach it as a transitive dependency`);
57
+ }
58
+ const start = pkg.scripts?.['start'];
59
+ if (start !== undefined && RUNS_TSX.test(start))
60
+ manual.push(MANUAL_START_SNIPPET);
61
+ const legacyPrisma = schema !== undefined && LEGACY_PRISMA_OUTPUT.test(schema);
62
+ if (legacyPrisma)
63
+ manual.push(MANUAL_PRISMA_LAYOUT_SNIPPET);
64
+ // The Dockerfile installs with pnpm (frozen pnpm-lock.yaml): offered to pnpm apps only.
65
+ else if (pm === 'pnpm' && !(await exists(join(dir, 'Dockerfile')))) {
66
+ files['Dockerfile'] = DOCKERFILE;
67
+ notes.push(`Dockerfile: build stage + plain-node runtime running ${PRODUCTION_ENTRY} (same as \`basalt publish dockerfile\`)`);
68
+ }
69
+ if (!(await exists(join(dir, '.dockerignore')))) {
70
+ files['.dockerignore'] = DOCKERIGNORE;
71
+ notes.push('.dockerignore: keeps .env, keys and node_modules out of the image');
72
+ }
73
+ return { files, packageJsonText: merged.text, notes, manual };
74
+ }
@@ -47,7 +47,9 @@ present) to their latest published versions, with the scaffold's policy:
47
47
  Range style (^, ~, exact) and package.json formatting are preserved. Then it
48
48
  installs, runs the @basaltkit/cli upgrade codemods, and suggests typecheck+test.
49
49
  It also upgrades project tooling: an unmodified old bin/basalt.ts gains the
50
- project commands; create-basalt becomes a devDependency (pnpm basalt update).
50
+ project commands, .env loading for development and a pre-boot \`upgrade\`; an
51
+ unmodified src/dev.ts loads .env; the .env.example header is refreshed;
52
+ create-basalt becomes a devDependency (pnpm basalt update).
51
53
 
52
54
  Options:
53
55
  --dry Show the plan, write nothing
@@ -56,7 +58,8 @@ Options:
56
58
  --only=@basaltkit
57
59
  Only the framework packages (and create-basalt)
58
60
  --no-install Write package.json, skip the install (and the codemods)
59
- --no-tooling Leave bin/basalt.ts and the create-basalt devDependency alone
61
+ --no-tooling Leave bin/basalt.ts, src/dev.ts, .env.example and the
62
+ create-basalt devDependency alone
60
63
  ${COMMON_FLAGS}
61
64
  `,
62
65
  add: `Usage: create-basalt add <feature> [options] (in an app: pnpm basalt add <feature>)
@@ -85,8 +88,9 @@ ${COMMON_FLAGS}
85
88
 
86
89
  Read-only health check: Node vs engines, package manager and lockfiles,
87
90
  installed vs declared versions, duplicated @basaltkit versions and unmet peer
88
- ranges, framework packages behind latest, the auth secret and DATABASE_URL
89
- (from the environment or .env), Prisma client/migrations, .mcp.json.
91
+ ranges, framework packages behind latest, the required variables of src/env.ts
92
+ and the auth secret (from the environment or .env), Prisma client/migrations,
93
+ .mcp.json, and whether bin/basalt.ts / src/dev.ts are current.
90
94
  Exits 1 when an error is found; warnings alone exit 0.
91
95
 
92
96
  Options:
@@ -0,0 +1,18 @@
1
+ import type { PackageJson } from './package-json.js';
2
+ /**
3
+ * The `db:seed` script create-basalt 1.8.0–1.11.0 generated for `--prisma`
4
+ * apps: `tsx prisma/seed.ts` loads no .env, so on a fresh app it fails in
5
+ * src/env.ts (`ENV_INVALID`: database URL / app secret missing) unless the
6
+ * variables are exported. `prisma db seed` goes through prisma.config.ts,
7
+ * which loads .env and runs the `migrations.seed` command it declares.
8
+ *
9
+ * Only the exact old template is matched: a customised script is the app's.
10
+ * Nothing here rewrites package.json — `doctor` reports it and `update`
11
+ * prints the replacement.
12
+ */
13
+ export declare const LEGACY_DB_SEED: RegExp;
14
+ /**
15
+ * The fix for an old `db:seed` script, as printed lines; undefined when the
16
+ * app does not have the old script (or no prisma.config.ts to load .env).
17
+ */
18
+ export declare function legacySeedScriptFix(pkg: PackageJson, prismaConfig: string | undefined): string | undefined;
@@ -0,0 +1,32 @@
1
+ import { DB_SEED_SCRIPT } from '../templates.js';
2
+ /**
3
+ * The `db:seed` script create-basalt 1.8.0–1.11.0 generated for `--prisma`
4
+ * apps: `tsx prisma/seed.ts` loads no .env, so on a fresh app it fails in
5
+ * src/env.ts (`ENV_INVALID`: database URL / app secret missing) unless the
6
+ * variables are exported. `prisma db seed` goes through prisma.config.ts,
7
+ * which loads .env and runs the `migrations.seed` command it declares.
8
+ *
9
+ * Only the exact old template is matched: a customised script is the app's.
10
+ * Nothing here rewrites package.json — `doctor` reports it and `update`
11
+ * prints the replacement.
12
+ */
13
+ export const LEGACY_DB_SEED = /^\s*tsx\s+(?:\.\/)?prisma\/seed\.ts\s*$/;
14
+ /** Whether prisma.config.ts declares a seed command (`migrations: { seed: … }`). */
15
+ const DECLARES_SEED = /migrations\s*:\s*\{[^}]*\bseed\s*:/;
16
+ /**
17
+ * The fix for an old `db:seed` script, as printed lines; undefined when the
18
+ * app does not have the old script (or no prisma.config.ts to load .env).
19
+ */
20
+ export function legacySeedScriptFix(pkg, prismaConfig) {
21
+ const script = pkg.scripts?.['db:seed'];
22
+ if (script === undefined || prismaConfig === undefined || !LEGACY_DB_SEED.test(script))
23
+ return undefined;
24
+ const lines = [
25
+ `\`db:seed\` runs "${script}", which does not load .env — it fails with ENV_INVALID unless the variables are exported. Run it through Prisma (prisma.config.ts loads .env):`,
26
+ ` package.json "db:seed": "${DB_SEED_SCRIPT}"`,
27
+ ];
28
+ if (!DECLARES_SEED.test(prismaConfig)) {
29
+ lines.push(` prisma.config.ts migrations: { seed: '${script.trim()}' }, (inside defineConfig)`);
30
+ }
31
+ return lines.join('\n');
32
+ }