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.
- package/README.md +192 -21
- package/dist/args.d.ts +1 -1
- package/dist/args.js +16 -2
- package/dist/bin-template.d.ts +28 -0
- package/dist/bin-template.js +291 -0
- package/dist/cli.js +46 -6
- package/dist/dev-entry.d.ts +23 -0
- package/dist/dev-entry.js +49 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +53 -28
- package/dist/latest-versions.d.ts +31 -2
- package/dist/latest-versions.js +91 -51
- package/dist/project/add.d.ts +66 -0
- package/dist/project/add.js +322 -0
- package/dist/project/context.d.ts +53 -0
- package/dist/project/context.js +112 -0
- package/dist/project/doctor.d.ts +50 -0
- package/dist/project/doctor.js +460 -0
- package/dist/project/manifest.d.ts +36 -0
- package/dist/project/manifest.js +81 -0
- package/dist/project/package-json.d.ts +57 -0
- package/dist/project/package-json.js +161 -0
- package/dist/project/run.d.ts +55 -0
- package/dist/project/run.js +477 -0
- package/dist/project/semver.d.ts +29 -0
- package/dist/project/semver.js +132 -0
- package/dist/project/term.d.ts +24 -0
- package/dist/project/term.js +39 -0
- package/dist/project/update.d.ts +101 -0
- package/dist/project/update.js +325 -0
- package/dist/templates.d.ts +31 -5
- package/dist/templates.js +102 -58
- package/dist/versions.js +6 -5
- package/dist/wizard.js +3 -3
- package/package.json +8 -8
|
@@ -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
|
|
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
|
-
|
|
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
|
|
122
|
-
// server
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
39
|
-
export
|
|
40
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
114
|
-
*
|
|
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). */
|