create-basalt 1.9.0 → 1.11.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,291 @@
1
+ import { hashContent } from './project/manifest.js';
2
+ /**
3
+ * `bin/basalt.ts` — the app's `basalt` CLI (scaffolded with `--cli`, or added
4
+ * later with `create-basalt add cli`).
5
+ *
6
+ * The file opens with the PROJECT-COMMANDS PRELUDE: `update`, `add`, `doctor`
7
+ * and `info` are handed to create-basalt BEFORE anything of the app is
8
+ * imported — those commands exist precisely for the moments the app does not
9
+ * boot (mid-upgrade, a missing dependency). That is also why the dev tools
10
+ * below are imported dynamically: a static import is evaluated before the
11
+ * first statement, so a broken `@basaltkit/generator` would take `pnpm basalt
12
+ * update` down with it.
13
+ */
14
+ const PRELUDE = `import { spawnSync } from 'node:child_process'
15
+ import { existsSync, readFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ // Project commands run BEFORE anything of the app is imported, so they keep
20
+ // working while the app is broken mid-upgrade. create-basalt provides them —
21
+ // the project's devDependency when installed, else its latest release:
22
+ // pnpm basalt update — update dependencies to the latest versions (--dry to preview)
23
+ // pnpm basalt add ui — add a feature later (ui, cli, mcp)
24
+ // pnpm basalt doctor — check the project's health
25
+ // pnpm basalt info — versions summary for bug reports
26
+ const root = fileURLToPath(new URL('..', import.meta.url))
27
+ if (['update', 'add', 'doctor', 'info'].includes(process.argv[2] ?? '')) {
28
+ process.exit(createBasalt(process.argv.slice(2)))
29
+ }
30
+
31
+ /** Runs create-basalt: the installed devDependency, else \`<pm> dlx create-basalt@latest\`. */
32
+ function createBasalt(args: string[]): number {
33
+ const local = join(root, 'node_modules', 'create-basalt', 'dist', 'cli.js')
34
+ if (existsSync(local)) {
35
+ return spawnSync(process.execPath, [local, ...args], { cwd: root, stdio: 'inherit' }).status ?? 1
36
+ }
37
+ let declared = ''
38
+ try {
39
+ declared = String(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).packageManager ?? '')
40
+ } catch {
41
+ // No readable package.json: the lockfiles decide.
42
+ }
43
+ const uses = (pm: string, ...lockfiles: string[]): boolean =>
44
+ declared.startsWith(\`\${pm}@\`) || lockfiles.some((file) => existsSync(join(root, file)))
45
+ const [command, prefix]: [string, string[]] = uses('pnpm', 'pnpm-lock.yaml')
46
+ ? ['pnpm', ['dlx']]
47
+ : uses('bun', 'bun.lock', 'bun.lockb')
48
+ ? ['bunx', []]
49
+ : ['npx', ['--yes']]
50
+ const result = spawnSync(command, [...prefix, 'create-basalt@latest', ...args], {
51
+ cwd: root,
52
+ stdio: 'inherit',
53
+ shell: process.platform === 'win32',
54
+ })
55
+ return result.status ?? 1
56
+ }
57
+ `;
58
+ /**
59
+ * The DEV PRELUDE, right after the project commands and still before anything
60
+ * of the app is imported:
61
+ *
62
+ * - `upgrade` (the @basaltkit/cli codemods) only rewrites source files, so it
63
+ * runs without booting the app — it must keep working while the app cannot
64
+ * start (that is usually why you are upgrading);
65
+ * - `.env` is loaded for development with `node --env-file` semantics: a
66
+ * variable already exported in the shell wins, the file only fills in what
67
+ * is missing (the app-prefixed names in src/env.ts exist because of exactly
68
+ * that rule). `pnpm start` / src/server.ts never loads it;
69
+ * - a boot-time `EnvValidationError` becomes a fix (which variables, where they
70
+ * are read from, what to do) instead of a stack trace.
71
+ */
72
+ const DEV_PRELUDE = `// \`upgrade\` (framework codemods) only rewrites source files: it runs without
73
+ // booting the app, so it works even while the app cannot start.
74
+ if (process.argv[2] === 'upgrade') {
75
+ const { consoleIo, parseArgv, upgradeCommand } = await import('@basaltkit/cli')
76
+ const { args, flags } = parseArgv(process.argv.slice(2))
77
+ // upgradeCommand reads only io/args/flags — there is no app to hand it.
78
+ const context = { io: consoleIo(), args, flags } as unknown as Parameters<typeof upgradeCommand.handle>[0]
79
+ process.exit((await upgradeCommand.handle(context)) ?? 0)
80
+ }
81
+
82
+ // Development loads .env (when present) like \`node --env-file=.env\`: a variable
83
+ // already exported in your shell still wins, the file only fills in what is
84
+ // missing. \`pnpm start\` (production) never loads it — there the configuration
85
+ // comes from the real environment.
86
+ const envFile = join(root, '.env')
87
+ if (existsSync(envFile)) process.loadEnvFile(envFile)
88
+
89
+ /**
90
+ * A boot failure as a fix instead of a stack trace (stack: BASALT_DEBUG=1 or --debug):
91
+ * invalid environment variables, a database that does not answer, one that is not
92
+ * migrated. Anything else is rethrown untouched.
93
+ */
94
+ async function explainBootFailure(error: unknown): Promise<never> {
95
+ const failure = (error ?? {}) as { code?: unknown; report?: unknown; message?: unknown; cause?: unknown; errors?: unknown }
96
+ const message = String(failure.message ?? '')
97
+ const codes = [failure, failure.cause, ...(Array.isArray(failure.errors) ? failure.errors : [])].map((item) =>
98
+ String((item as { code?: unknown } | null | undefined)?.code ?? ''),
99
+ )
100
+ const unreachable =
101
+ codes.some((code) => /^(ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT|EAI_AGAIN|P1001|P1002)$/.test(code)) ||
102
+ /ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|Can't reach database server/.test(message) ||
103
+ // assertMigrated could not even query the database (down, wrong host/port or credentials).
104
+ (failure.code === 'PRISMA_NOT_MIGRATED' && message.startsWith('Could not verify'))
105
+ const debugHint = 'BASALT_DEBUG=1 shows the stack trace.'
106
+ let explanation: string | undefined
107
+ if (failure.code === 'ENV_INVALID' && Array.isArray(failure.report)) {
108
+ const hasEnvFile = existsSync(envFile)
109
+ explanation = [
110
+ 'The app cannot start — invalid environment variables:',
111
+ ...failure.report.map((line) => ' - ' + String(line)),
112
+ '',
113
+ "Read from: your shell's environment" +
114
+ (hasEnvFile ? ' and ' + envFile + ' (exported variables win).' : ' only — there is no ' + envFile + '.'),
115
+ 'Fix: ' + (hasEnvFile ? 'set them in .env' : 'cp .env.example .env, then fill them in') + ' (or export them)' +
116
+ (existsSync(join(root, 'prisma')) ? ' — for DATABASE_URL, start PostgreSQL and point the URL at it' : '') +
117
+ '. \`pnpm basalt doctor\` checks the environment; ' + debugHint,
118
+ ].join('\\n')
119
+ } else if (unreachable || failure.code === 'PRISMA_NOT_MIGRATED') {
120
+ const database = await databaseInUse()
121
+ const where = 'Database: ' + database.target + ' (from ' + database.name + '; credentials never shown).'
122
+ explanation = unreachable
123
+ ? [
124
+ 'The app cannot start — the database did not answer.',
125
+ where,
126
+ 'Fix: start PostgreSQL (e.g. \`docker compose up -d\`, or your local service) and check that ' +
127
+ database.name + ' in .env points at it (host, port, user, password); on a new database, then run \`pnpm db:migrate\`. ' + debugHint,
128
+ ].join('\\n')
129
+ : [
130
+ 'The app cannot start — the database is not migrated:',
131
+ ' ' + message,
132
+ where,
133
+ 'Fix: run \`pnpm db:migrate\` (development; \`pnpm db:deploy\` in production) — or, if this is not the database you meant, check ' +
134
+ database.name + ' (a shell may have exported another project\\'s). ' + debugHint,
135
+ ].join('\\n')
136
+ }
137
+ if (explanation === undefined || process.env['BASALT_DEBUG'] === '1' || process.argv.includes('--debug')) throw error
138
+ console.error(explanation)
139
+ process.exit(1)
140
+ }
141
+
142
+ /** The database URL the app booted with — protocol, host, port and database only, never the credentials. */
143
+ async function databaseInUse(): Promise<{ name: string; target: string }> {
144
+ let url: unknown
145
+ try {
146
+ url = ((await import('../src/env.js')).env as unknown as Record<string, unknown>)['DATABASE_URL']
147
+ } catch {
148
+ // env.ts itself failed: there is no URL to show.
149
+ }
150
+ // The variable it came from: the app-prefixed name first (it wins in src/env.ts).
151
+ const name =
152
+ Object.keys(process.env)
153
+ .filter((key) => key.endsWith('DATABASE_URL') && process.env[key] === url)
154
+ .sort((a, b) => b.length - a.length)[0] ?? 'DATABASE_URL'
155
+ try {
156
+ const parsed = new URL(String(url))
157
+ return { name, target: parsed.protocol + '//' + parsed.hostname + ':' + (parsed.port || '5432') + parsed.pathname }
158
+ } catch {
159
+ return { name, target: '(no valid URL)' }
160
+ }
161
+ }
162
+ `;
163
+ /** Marker proving a bin/basalt.ts already delegates the project commands. */
164
+ export const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'info'].includes(process.argv[2]";
165
+ /** Marker proving a bin/basalt.ts already has the dev prelude (.env loading, `upgrade`, env errors). */
166
+ export const DEV_PRELUDE_MARKER = 'process.loadEnvFile(envFile)';
167
+ const APP_IMPORT = "await import('../src/app.js')";
168
+ const APP_IMPORT_EXPLAINED = `${APP_IMPORT}.catch(explainBootFailure)`;
169
+ const RUN_CLI = 'process.exit(await runCli({ app })';
170
+ export function basaltBin() {
171
+ return `#!/usr/bin/env node
172
+ ${PRELUDE}
173
+ ${DEV_PRELUDE}
174
+ // The dev tools are imported dynamically, after the project commands above.
175
+ const { runCli } = await import('@basaltkit/cli')
176
+ const { generatorCommands } = await import('@basaltkit/generator')
177
+ const { prismaSyncCommand } = await import('@basaltkit/prisma')
178
+
179
+ // Dev tooling: opt into development defaults unless NODE_ENV is already set
180
+ // (imported dynamically below so env.ts is evaluated after this line).
181
+ process.env['NODE_ENV'] ??= 'development'
182
+ const { buildApp } = ${APP_IMPORT_EXPLAINED}
183
+
184
+ // The 'basalt' CLI: boots the app WITH the dev/CLI commands, runs one, shuts down.
185
+ // The dev tools (@basaltkit/generator; add @basaltkit/ai for ai:*) are imported
186
+ // ONLY here — the runtime server (src/server.ts) never loads them, so the SaaS
187
+ // runs without the codegen/AI layer.
188
+ // pnpm basalt dev — dev server: route table + watch (--worker for a queue worker)
189
+ // pnpm basalt list — show available commands
190
+ // pnpm basalt routes — list registered HTTP routes
191
+ // pnpm basalt make:resource Project — generate a full resource vertical
192
+ // pnpm basalt make:service Project — generate a single artifact (schema/service/…)
193
+ const app = buildApp({
194
+ logLevel: 'silent',
195
+ commands: [...generatorCommands(), prismaSyncCommand()],
196
+ })
197
+ process.exit(await runCli({ app }).catch(explainBootFailure))
198
+ `;
199
+ }
200
+ /**
201
+ * Hashes of every bin/basalt.ts an earlier create-basalt release generated
202
+ * (CRLF-insensitive). Only these — or a file the project's manifest records as
203
+ * untouched — are patched automatically.
204
+ */
205
+ export const LEGACY_BIN_HASHES = new Set([
206
+ // create-basalt 0.x (rebrand, 2026-08-09): static imports, no dev commands.
207
+ 'sha256-78537f4bf21ca44d78dc50f8841f39898b86300c4eb2c696f3d1a6e6c9921cbd',
208
+ // 2026-08-13: generator + prisma:sync passed via `commands`.
209
+ 'sha256-0c898eb1faf32792fc9d2593070c8421905882ba8d8f2aba9a69c74ce2dfa5d8',
210
+ // 2026-08-22: `basalt dev` documented.
211
+ 'sha256-d09d583f1a39bffcb72f302c5f6c3e2651badea358ba38019238a4aa4b842545',
212
+ // 2026-09-19 (create-basalt ≤ 1.9): NODE_ENV defaulted before the app import.
213
+ 'sha256-a0df89dee04091a9470f67968ff72cfc3bf64076c1bf3d141ae657c7438a94c8',
214
+ // 2026-10-01 (create-basalt 1.10): project commands (update/add/doctor/info)
215
+ // delegated before boot — but no .env loading and `upgrade` booted the app.
216
+ 'sha256-9091e137a5de39929d0e70b2fd973386b9cc1c2f996e130989a22c795b9d0893',
217
+ ]);
218
+ export const hasProjectCommands = (content) => content.includes(PROJECT_COMMANDS_MARKER);
219
+ export const hasDevPrelude = (content) => content.includes(DEV_PRELUDE_MARKER);
220
+ /**
221
+ * Upgrades a template-generated bin/basalt.ts to the current template, step by
222
+ * step: (1) without the project commands, inserts their prelude after the
223
+ * shebang and turns the remaining static imports into dynamic ones (so they no
224
+ * longer run before it); (2) without the dev prelude, inserts it right after
225
+ * the project commands and routes the app import through `explainBootFailure`.
226
+ * Deterministic for the known templates — for the latest ones (1.9, 1.10) the
227
+ * result is exactly {@link basaltBin}.
228
+ */
229
+ export function patchBasaltBin(content) {
230
+ let text = content.replace(/\r\n/g, '\n');
231
+ if (!hasProjectCommands(text))
232
+ text = addProjectCommands(text);
233
+ if (!hasDevPrelude(text))
234
+ text = addDevPrelude(text);
235
+ return text;
236
+ }
237
+ function addProjectCommands(content) {
238
+ const lines = content.split('\n');
239
+ const shebang = lines[0]?.startsWith('#!') ? `${lines.shift()}\n` : '';
240
+ const body = lines.map((line) => {
241
+ const match = /^import \{ ([^}]+) \} from '([^']+)'$/.exec(line);
242
+ return match ? `const { ${match[1]} } = await import('${match[2]}')` : line;
243
+ });
244
+ const firstDynamic = body.findIndex((line) => line.startsWith('const {') && line.includes('= await import('));
245
+ if (firstDynamic >= 0 && !(body[firstDynamic] ?? '').includes("'../src/app.js'")) {
246
+ body.splice(firstDynamic, 0, '// The dev tools are imported dynamically, after the project commands above.');
247
+ }
248
+ return `${shebang}${PRELUDE}\n${body.join('\n')}`;
249
+ }
250
+ function addDevPrelude(content) {
251
+ const at = content.indexOf(PRELUDE);
252
+ // A file without the template's prelude block is not ours to rearrange.
253
+ if (at < 0)
254
+ return content;
255
+ const end = at + PRELUDE.length + (content.startsWith('\n', at + PRELUDE.length) ? 1 : 0);
256
+ const rest = content
257
+ .slice(end)
258
+ .replace(`= ${APP_IMPORT}\n`, `= ${APP_IMPORT_EXPLAINED}\n`)
259
+ .replace(`${RUN_CLI})\n`, `${RUN_CLI}.catch(explainBootFailure))\n`);
260
+ return `${content.slice(0, end)}${DEV_PRELUDE}\n${rest}`;
261
+ }
262
+ /** Where a bin/basalt.ts stands: already current, an untouched old template, or user code. */
263
+ export function binStatus(content, manifestHash) {
264
+ if (content === undefined)
265
+ return 'absent';
266
+ if (hasProjectCommands(content) && hasDevPrelude(content))
267
+ return 'current';
268
+ const hash = hashContent(content);
269
+ return LEGACY_BIN_HASHES.has(hash) || hash === manifestHash ? 'patchable' : 'modified';
270
+ }
271
+ /** What to paste by hand into a customised bin/basalt.ts (only the parts it lacks). */
272
+ export function manualBinSnippet(content) {
273
+ const steps = [];
274
+ if (!hasProjectCommands(content)) {
275
+ steps.push(`Paste this right after the shebang line of bin/basalt.ts, then turn its
276
+ static imports of @basaltkit/* and ../src/app.js into dynamic ones
277
+ (\`import { runCli } from '@basaltkit/cli'\` → \`const { runCli } = await import('@basaltkit/cli')\`)
278
+ so they no longer run before it:
279
+
280
+ ${PRELUDE}`);
281
+ }
282
+ if (!hasDevPrelude(content)) {
283
+ steps.push(`${steps.length > 0 ? 'Then paste' : 'Paste'} this after the project-commands block (before anything of the app is
284
+ imported) — it runs \`upgrade\` without booting the app, loads .env for development
285
+ and explains env errors — and change the app import to
286
+ \`const { buildApp } = ${APP_IMPORT_EXPLAINED}\`:
287
+
288
+ ${DEV_PRELUDE}`);
289
+ }
290
+ return steps.join('\n');
291
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from 'node:child_process';
2
+ import { execFile, spawn } from 'node:child_process';
3
3
  import { stdin, stdout } from 'node:process';
4
4
  import { createProject, describeResolution, detectPackageManager, resolveRunDefaults, runWizard, ttyPrompter, TargetNotEmptyError, WizardCancelledError, } from './index.js';
5
+ import { basaltCommand, isProjectCommand, runProjectCommand } from './project/run.js';
5
6
  import { parseArgs, resolvesLatest, USAGE } from './args.js';
6
7
  import { envPrefix } from './templates.js';
7
8
  /** Runs a command inheriting stdio; resolves false on non-zero exit (never throws). */
@@ -16,12 +17,49 @@ function run(command, args, cwd) {
16
17
  child.on('close', (code) => resolve(code === 0));
17
18
  });
18
19
  }
20
+ /** Runs a command and returns its trimmed stdout, or undefined (never throws). */
21
+ function capture(command, args, cwd) {
22
+ return new Promise((resolve) => {
23
+ execFile(command, args, { cwd, shell: process.platform === 'win32', timeout: 10_000 }, (error, out) => {
24
+ resolve(error ? undefined : String(out).trim() || undefined);
25
+ });
26
+ });
27
+ }
19
28
  /** Cleanly abort on Ctrl+C instead of dumping a Node AbortError stack trace. */
20
29
  function cancel() {
21
30
  stdout.write('\nCancelled.\n');
22
31
  process.exit(130); // 128 + SIGINT
23
32
  }
24
- const flags = parseArgs(process.argv.slice(2));
33
+ const argv = process.argv.slice(2);
34
+ // Project commands (update | add | doctor | info) operate on an EXISTING app.
35
+ // They are selected by the first positional argument — those four words are
36
+ // reserved, so a project literally named "update" is created with
37
+ // --name=update. `--project` (the `basalt` script of apps without --cli) forces
38
+ // project mode: `pnpm basalt` alone then prints the project-command help.
39
+ const firstPositional = argv.find((token) => !token.startsWith('-'));
40
+ if (argv.includes('--project') || isProjectCommand(firstPositional)) {
41
+ const code = await runProjectCommand(argv.filter((token) => token !== '--project'), {
42
+ cwd: process.cwd(),
43
+ out: { log: (line = '') => stdout.write(`${line}\n`), error: (line) => process.stderr.write(`${line}\n`) },
44
+ env: process.env,
45
+ interactive: Boolean(stdin.isTTY) && !process.env['CI'],
46
+ isTTY: Boolean(stdout.isTTY),
47
+ run,
48
+ capture,
49
+ confirm: async (message) => {
50
+ try {
51
+ return await ttyPrompter().confirm({ message, initial: true });
52
+ }
53
+ catch (error) {
54
+ if (error instanceof WizardCancelledError)
55
+ cancel();
56
+ throw error;
57
+ }
58
+ },
59
+ });
60
+ process.exit(code);
61
+ }
62
+ const flags = parseArgs(argv);
25
63
  // Rich interactive wizard when nothing was specified in a terminal. CI, piped
26
64
  // input, and `--yes` keep the flag-driven path.
27
65
  if (!flags.yes && flags.name === undefined && stdin.isTTY) {
@@ -114,15 +152,17 @@ try {
114
152
  if (flags.prisma) {
115
153
  // Nothing boots before the database exists: env.DATABASE_URL is required and
116
154
  // the app asserts at boot that it reached the MIGRATED database.
117
- steps.push(`cp .env.example .env # then point ${envPrefix(result.options.name)}_DATABASE_URL at your database`);
155
+ // .env is already written with a local URL (pnpm dev / pnpm basalt load it).
156
+ steps.push(`# start PostgreSQL, or edit ${envPrefix(result.options.name)}_DATABASE_URL in .env`);
118
157
  steps.push(`${pm} run db:migrate${flags.tenancy ? ' # creates the tables and seeds the demo tenant' : ' # creates the tables'}`);
119
158
  }
120
159
  steps.push(`${pm} run dev${flags.ui ? ' # API on :3000' : ''}`);
121
- // `web` is wired as a pnpm workspace member (pnpm-workspace.yaml), so its dev
122
- // server is launched with a pnpm filter regardless of the root package manager.
160
+ // `web` is a pnpm workspace member (pnpm-workspace.yaml); `dev:web` runs its
161
+ // dev server through a pnpm filter.
123
162
  if (flags.ui)
124
- steps.push(`pnpm --filter ${result.options.name}-web dev # UI on :5180`);
163
+ steps.push(`pnpm dev:web # UI on :5180`);
125
164
  console.log(`\nNext steps:\n${steps.map((s) => ` ${s}`).join('\n')}\n`);
165
+ console.log(`Later: \`${basaltCommand(pm, 'update')}\` updates the dependencies, \`${basaltCommand(pm, 'add ui|cli|mcp')}\` adds a feature, \`${basaltCommand(pm, 'doctor')}\` checks the project.\n`);
126
166
  }
127
167
  catch (error) {
128
168
  if (error instanceof TargetNotEmptyError) {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `src/dev.ts` (the `pnpm dev` entrypoint) and the `.env.example` header across
3
+ * create-basalt releases. Up to 1.10, nothing loaded `.env`: `pnpm dev` booted
4
+ * without the variables the example told you to set. `update` brings untouched
5
+ * copies to the current templates; customised ones get the snippet instead.
6
+ */
7
+ /** Hashes of every src/dev.ts an earlier create-basalt release generated (CRLF-insensitive). */
8
+ export declare const LEGACY_DEV_HASHES: ReadonlySet<string>;
9
+ /** Marker proving a src/dev.ts already loads .env. */
10
+ export declare const DEV_ENV_MARKER = "process.loadEnvFile(";
11
+ export type DevEntryStatus = 'current' | 'patchable' | 'modified' | 'absent';
12
+ /** Where a src/dev.ts stands: loads .env already, an untouched old template, or user code. */
13
+ export declare function devEntryStatus(content: string | undefined, manifestHash?: string): DevEntryStatus;
14
+ /** The current src/dev.ts — what a patchable one becomes. */
15
+ export declare const patchDevEntry: () => string;
16
+ /** What to paste by hand into a customised src/dev.ts. */
17
+ export declare const MANUAL_DEV_SNIPPET = "Paste this at the top of src/dev.ts, BEFORE the server is imported (keep that\nimport dynamic \u2014 `await import('./server.js')` \u2014 so the variables are set first):\n\nimport { existsSync } from 'node:fs'\nimport { fileURLToPath } from 'node:url'\n\n// Development loads .env (when present) like `node --env-file=.env`: a variable\n// already exported in your shell still wins, the file only fills in what is missing.\nconst envFile = fileURLToPath(new URL('../.env', import.meta.url))\nif (existsSync(envFile)) process.loadEnvFile(envFile)\n";
18
+ /**
19
+ * `.env.example` with the old "nothing loads this file" lead swapped for the
20
+ * current one; undefined when the file does not start with the old lead (a
21
+ * newer or hand-written file is left alone — it is only comments).
22
+ */
23
+ export declare function patchEnvExample(content: string): string | undefined;
@@ -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
@@ -64,8 +64,22 @@ export declare function resolveRunDefaults(input: {
64
64
  git: boolean;
65
65
  interactive: boolean;
66
66
  };
67
+ /** Every file a scaffold with `options` writes (dependency ranges = the bundled fallbacks). */
68
+ export declare function scaffoldFiles(options: ProjectOptions): Record<string, string>;
67
69
  /** Generates a ready-to-run Basalt app. Does not install dependencies. */
68
70
  export declare function createProject(input: CreateProjectInput): Promise<CreateProjectResult>;
69
- export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, type FreshVersion, type HeldBackVersion, type ResolveLatestOptions, type VersionResolution, } from './latest-versions.js';
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;
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';
78
+ export { runProjectCommand, PROJECT_COMMANDS, isProjectCommand, type ProjectCommandDeps } from './project/run.js';
79
+ export { planUpdate, type UpdatePlan, type UpdateEntry } from './project/update.js';
80
+ export { planAdd, ADDABLE_FEATURES, type AddPlan, type AddableFeature } from './project/add.js';
81
+ export { runDoctor, type DoctorFinding } from './project/doctor.js';
82
+ export { loadProject, NotABasaltAppError, type ProjectContext } from './project/context.js';
83
+ export { MANIFEST_PATH, readManifest, hashContent, type ProjectManifest } from './project/manifest.js';
70
84
  export { runWizard, validateProjectName, PRESETS, FEATURES, type WizardResult, type WizardOptions, type FeatureKey } from './wizard.js';
71
85
  export { ttyPrompter, scriptedPrompter, WizardCancelledError, type Prompter, type Choice } from './prompt.js';
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
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, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, prismaConfigTs, prismaSchema, prismaSeedTs, readme, routesTs, serverTs, tsconfigJson, } from './templates.js';
4
5
  import { uiFiles } from './templates-ui.js';
5
- import { applyVersions, collectDependencies, resolveLatestVersions, } from './latest-versions.js';
6
+ import { INSECURE_SECRET } from './project/doctor.js';
7
+ import { createManifest, MANIFEST_PATH, serializeManifest } from './project/manifest.js';
8
+ import { resolveFileVersions } from './latest-versions.js';
6
9
  export class TargetNotEmptyError extends Error {
7
10
  constructor(dir) {
8
11
  super(`Target directory "${dir}" already exists and is not empty.`);
@@ -35,23 +38,9 @@ export function resolveRunDefaults(input) {
35
38
  git: input.git ?? interactive,
36
39
  };
37
40
  }
38
- /** Generates a ready-to-run Basalt app. Does not install dependencies. */
39
- export async function createProject(input) {
40
- const options = {
41
- name: input.name,
42
- tenancy: input.tenancy ?? true,
43
- auth: input.auth ?? true,
44
- billing: input.billing ?? false,
45
- ui: input.ui ?? false,
46
- cli: input.cli ?? false,
47
- mcp: input.mcp ?? false,
48
- prisma: input.prisma ?? false,
49
- };
50
- const dir = resolve(input.dir ?? input.name);
51
- const existing = await readdir(dir).catch(() => null);
52
- if (existing && existing.length > 0)
53
- throw new TargetNotEmptyError(dir);
54
- const files = {
41
+ /** Every file a scaffold with `options` writes (dependency ranges = the bundled fallbacks). */
42
+ export function scaffoldFiles(options) {
43
+ return {
55
44
  'package.json': packageJson(options),
56
45
  'tsconfig.json': tsconfigJson(options),
57
46
  '.env.example': envExample(options),
@@ -78,20 +67,56 @@ export async function createProject(input) {
78
67
  ...(options.mcp ? { '.mcp.json': mcpJson(options) } : {}),
79
68
  ...(options.ui ? uiFiles(options) : {}),
80
69
  };
81
- let versions;
82
- if (input.resolveLatest) {
83
- const manifests = Object.keys(files).filter((path) => path === 'package.json' || path.endsWith('/package.json'));
84
- versions = await resolveLatestVersions(collectDependencies(manifests.map((path) => files[path])), input.registry);
85
- for (const path of manifests)
86
- files[path] = applyVersions(files[path], versions.versions);
87
- }
70
+ }
71
+ /** Generates a ready-to-run Basalt app. Does not install dependencies. */
72
+ export async function createProject(input) {
73
+ const options = {
74
+ name: input.name,
75
+ tenancy: input.tenancy ?? true,
76
+ auth: input.auth ?? true,
77
+ billing: input.billing ?? false,
78
+ ui: input.ui ?? false,
79
+ cli: input.cli ?? false,
80
+ mcp: input.mcp ?? false,
81
+ prisma: input.prisma ?? false,
82
+ };
83
+ const dir = resolve(input.dir ?? input.name);
84
+ const existing = await readdir(dir).catch(() => null);
85
+ if (existing && existing.length > 0)
86
+ throw new TargetNotEmptyError(dir);
87
+ const files = scaffoldFiles(options);
88
+ const versions = input.resolveLatest ? await resolveFileVersions(files, input.registry) : undefined;
89
+ files[MANIFEST_PATH] = serializeManifest(createManifest(options, files));
90
+ // A ready-to-use .env for development, so `pnpm dev` / `pnpm basalt` boot
91
+ // without a copy step. Written after the manifest on purpose: it is
92
+ // git-ignored local state (with a generated secret), not a template file.
93
+ files['.env'] = devEnvFile(options, generateAppSecret());
88
94
  for (const [path, content] of Object.entries(files)) {
89
95
  const target = join(dir, path);
90
96
  await mkdir(dirname(target), { recursive: true });
91
- await writeFile(target, content);
97
+ // .env holds a secret: owner-only, like `umask 077` would make it.
98
+ await writeFile(target, content, path === '.env' ? { mode: 0o600 } : undefined);
92
99
  }
93
100
  return { dir, files: Object.keys(files).sort(), options, ...(versions ? { versions } : {}) };
94
101
  }
95
- export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, } from './latest-versions.js';
102
+ /**
103
+ * A random APP_SECRET for the development .env: 48 random bytes, base64url —
104
+ * redrawn in the (rare) case it happens to contain a word `secret()` rejects
105
+ * as a placeholder (`test`, `0000`, …).
106
+ */
107
+ export function generateAppSecret(random = randomBytes) {
108
+ for (;;) {
109
+ const value = random(48).toString('base64url');
110
+ if (!INSECURE_SECRET.test(value))
111
+ return value;
112
+ }
113
+ }
114
+ export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, resolveFileVersions, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, } from './latest-versions.js';
115
+ export { runProjectCommand, PROJECT_COMMANDS, isProjectCommand } from './project/run.js';
116
+ export { planUpdate } from './project/update.js';
117
+ export { planAdd, ADDABLE_FEATURES } from './project/add.js';
118
+ export { runDoctor } from './project/doctor.js';
119
+ export { loadProject, NotABasaltAppError } from './project/context.js';
120
+ export { MANIFEST_PATH, readManifest, hashContent } from './project/manifest.js';
96
121
  export { runWizard, validateProjectName, PRESETS, FEATURES } from './wizard.js';
97
122
  export { ttyPrompter, scriptedPrompter, WizardCancelledError } from './prompt.js';
@@ -108,10 +108,34 @@ export declare function registryUrl(explicit?: string, env?: NodeJS.ProcessEnv):
108
108
  export declare const latestUrl: (registry: string, name: string) => string;
109
109
  /** `<registry>/<name>` — the packument (probed with HEAD for its `last-modified`). */
110
110
  export declare const packumentUrl: (registry: string, name: string) => string;
111
+ /** What the registry says about one package (see {@link lookupLatestVersions}). */
112
+ export interface LatestInfo {
113
+ /** The `latest` dist-tag (stable releases only), or undefined when the lookup failed. */
114
+ latest?: string;
115
+ /** `peerDependencies` declared by that latest version. */
116
+ peers: Record<string, string>;
117
+ /**
118
+ * Release age of `latest` against the window: `mature` (provably older, or
119
+ * not probed), `recent` (inside the window) or `unknown` (could not prove it).
120
+ */
121
+ age: 'mature' | 'recent' | 'unknown';
122
+ }
123
+ /**
124
+ * The shared registry primitive behind both the scaffold and `create-basalt
125
+ * update`: looks up `latest` for every name through a small pool of parallel
126
+ * requests (one retry on transient failures, a global time cap) and, when
127
+ * `shouldProbe(name, latest)` says the version would actually be used, probes
128
+ * its release age against the window. Never throws — a package the registry
129
+ * cannot answer for comes back without `latest`.
130
+ */
131
+ export declare function lookupLatestVersions(names: readonly string[], shouldProbe: (name: string, latest: string) => boolean, options?: ResolveLatestOptions): Promise<{
132
+ results: Map<string, LatestInfo>;
133
+ registry: string;
134
+ }>;
111
135
  /**
112
136
  * Resolves the latest published version of every package in `fallbacks`
113
- * (name → fallback range) through a small pool of parallel requests. Never throws: a package the registry
114
- * cannot answer for keeps its fallback range and is listed in `failed`.
137
+ * (name → fallback range). Never throws: a package the registry cannot answer
138
+ * for keeps its fallback range and is listed in `failed`.
115
139
  */
116
140
  export declare function resolveLatestVersions(fallbacks: Readonly<Record<string, string>>, options?: ResolveLatestOptions): Promise<VersionResolution>;
117
141
  /**
@@ -119,6 +143,11 @@ export declare function resolveLatestVersions(fallbacks: Readonly<Record<string,
119
143
  * resolved versions (keys, order and every other field untouched).
120
144
  */
121
145
  export declare function applyVersions(packageJsonText: string, versions: Readonly<Record<string, string>>): string;
146
+ /**
147
+ * Resolves the latest published versions for every package.json in `files`
148
+ * (in place) — the scaffold's registry policy, shared with `create-basalt add`.
149
+ */
150
+ export declare function resolveFileVersions(files: Record<string, string>, registry?: ResolveLatestOptions): Promise<VersionResolution>;
122
151
  /** Every dependency/devDependency name → range declared in the given package.json texts. */
123
152
  export declare function collectDependencies(packageJsonTexts: readonly string[]): Record<string, string>;
124
153
  /** Human-readable lines summarizing a resolution (for the CLI). */