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.
- package/README.md +64 -26
- package/dist/args.d.ts +1 -1
- package/dist/args.js +5 -2
- package/dist/bin-template.d.ts +13 -8
- package/dist/bin-template.js +178 -13
- package/dist/cli.js +6 -2
- package/dist/dev-entry.d.ts +23 -0
- package/dist/dev-entry.js +49 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +23 -2
- package/dist/latest-versions.js +1 -0
- package/dist/project/doctor.d.ts +11 -1
- package/dist/project/doctor.js +176 -14
- package/dist/project/production.d.ts +32 -0
- package/dist/project/production.js +74 -0
- package/dist/project/run.js +8 -4
- package/dist/project/seed-script.d.ts +18 -0
- package/dist/project/seed-script.js +32 -0
- package/dist/project/update.js +53 -11
- package/dist/stubs.d.ts +30 -0
- package/dist/stubs.js +123 -0
- package/dist/templates.d.ts +53 -7
- package/dist/templates.js +183 -55
- package/dist/versions.js +13 -13
- package/dist/wizard.js +3 -3
- package/package.json +20 -17
|
@@ -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
|
-
|
|
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';
|
package/dist/latest-versions.js
CHANGED
package/dist/project/doctor.d.ts
CHANGED
|
@@ -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. */
|
package/dist/project/doctor.js
CHANGED
|
@@ -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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
}
|
package/dist/project/run.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
89
|
-
(from the environment or .env), Prisma client/migrations,
|
|
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
|
+
}
|