create-basalt 1.9.0 → 1.10.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 +161 -9
- package/dist/args.d.ts +1 -1
- package/dist/args.js +11 -0
- package/dist/bin-template.d.ts +23 -0
- package/dist/bin-template.js +140 -0
- package/dist/cli.js +44 -5
- package/dist/index.d.ts +9 -1
- package/dist/index.js +32 -26
- 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 +40 -0
- package/dist/project/doctor.js +350 -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 +473 -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 +302 -0
- package/dist/templates.d.ts +11 -1
- package/dist/templates.js +48 -42
- package/dist/versions.js +6 -5
- package/package.json +12 -12
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { PackageManager } from '../index.js';
|
|
2
|
+
import type { ProjectOptions } from '../templates.js';
|
|
3
|
+
import { type ProjectManifest } from './manifest.js';
|
|
4
|
+
import type { PackageJson } from './package-json.js';
|
|
5
|
+
/** A Basalt app on disk, as the project commands see it. */
|
|
6
|
+
export interface ProjectContext {
|
|
7
|
+
dir: string;
|
|
8
|
+
/** Raw root package.json text (edits preserve its formatting). */
|
|
9
|
+
packageJsonText: string;
|
|
10
|
+
packageJson: PackageJson;
|
|
11
|
+
/** web/package.json, when the app has the frontend. */
|
|
12
|
+
web?: {
|
|
13
|
+
text: string;
|
|
14
|
+
json: PackageJson;
|
|
15
|
+
};
|
|
16
|
+
manifest?: ProjectManifest;
|
|
17
|
+
/** Features the app has now (manifest options, corrected by what package.json/the tree show). */
|
|
18
|
+
options: ProjectOptions;
|
|
19
|
+
pm: PackageManager;
|
|
20
|
+
/** Where `pm` came from — shown by doctor/info. */
|
|
21
|
+
pmSource: 'flag' | 'packageManager' | 'lockfile' | 'user-agent' | 'default';
|
|
22
|
+
/** Lockfiles present in the project root. */
|
|
23
|
+
lockfiles: string[];
|
|
24
|
+
}
|
|
25
|
+
export declare class NotABasaltAppError extends Error {
|
|
26
|
+
constructor(message: string);
|
|
27
|
+
}
|
|
28
|
+
export declare const LOCKFILES: ReadonlyArray<readonly [string, PackageManager]>;
|
|
29
|
+
/**
|
|
30
|
+
* The package manager of an existing project: `--pm`, then the
|
|
31
|
+
* `packageManager` field, then the lockfile, then the manager that launched us
|
|
32
|
+
* (`npm_config_user_agent`), else npm. pnpm-workspace.yaml is deliberately not
|
|
33
|
+
* a signal — every scaffold writes it, whatever the manager.
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectProjectPackageManager(pkg: PackageJson, lockfiles: readonly string[], flag?: PackageManager, userAgent?: string): {
|
|
36
|
+
pm: PackageManager;
|
|
37
|
+
source: ProjectContext['pmSource'];
|
|
38
|
+
};
|
|
39
|
+
export declare const allDependencies: (pkg: PackageJson) => Record<string, string>;
|
|
40
|
+
/** Whether a package.json belongs to a Basalt app (any @basaltkit/* — or legacy @machize/* — dependency). */
|
|
41
|
+
export declare const isBasaltPackage: (pkg: PackageJson) => boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The features an app has, from what is actually there: dependencies for the
|
|
44
|
+
* framework domains, the tree for ui (web/) and cli (bin/basalt.ts). The
|
|
45
|
+
* manifest supplies nothing the tree contradicts — a dependency removed since
|
|
46
|
+
* the scaffold turns its feature off.
|
|
47
|
+
*/
|
|
48
|
+
export declare function inferOptions(dir: string, pkg: PackageJson, manifest: ProjectManifest | undefined): ProjectOptions;
|
|
49
|
+
/**
|
|
50
|
+
* Loads the app at `dir`. Throws {@link NotABasaltAppError} with an actionable
|
|
51
|
+
* message when there is no package.json or it has no @basaltkit dependency.
|
|
52
|
+
*/
|
|
53
|
+
export declare function loadProject(dir: string, pmFlag?: PackageManager, userAgent?: string | undefined): Promise<ProjectContext>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readManifest } from './manifest.js';
|
|
5
|
+
export class NotABasaltAppError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'NotABasaltAppError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export const LOCKFILES = [
|
|
12
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
13
|
+
['package-lock.json', 'npm'],
|
|
14
|
+
['npm-shrinkwrap.json', 'npm'],
|
|
15
|
+
['yarn.lock', 'yarn'],
|
|
16
|
+
['bun.lock', 'bun'],
|
|
17
|
+
['bun.lockb', 'bun'],
|
|
18
|
+
];
|
|
19
|
+
const MANAGERS = ['pnpm', 'npm', 'yarn', 'bun'];
|
|
20
|
+
/**
|
|
21
|
+
* The package manager of an existing project: `--pm`, then the
|
|
22
|
+
* `packageManager` field, then the lockfile, then the manager that launched us
|
|
23
|
+
* (`npm_config_user_agent`), else npm. pnpm-workspace.yaml is deliberately not
|
|
24
|
+
* a signal — every scaffold writes it, whatever the manager.
|
|
25
|
+
*/
|
|
26
|
+
export function detectProjectPackageManager(pkg, lockfiles, flag, userAgent) {
|
|
27
|
+
if (flag)
|
|
28
|
+
return { pm: flag, source: 'flag' };
|
|
29
|
+
const declared = typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : undefined;
|
|
30
|
+
if (declared && MANAGERS.includes(declared)) {
|
|
31
|
+
return { pm: declared, source: 'packageManager' };
|
|
32
|
+
}
|
|
33
|
+
for (const [file, pm] of LOCKFILES)
|
|
34
|
+
if (lockfiles.includes(file))
|
|
35
|
+
return { pm, source: 'lockfile' };
|
|
36
|
+
const agent = userAgent?.split(' ')[0]?.split('/')[0];
|
|
37
|
+
if (agent && MANAGERS.includes(agent))
|
|
38
|
+
return { pm: agent, source: 'user-agent' };
|
|
39
|
+
return { pm: 'npm', source: 'default' };
|
|
40
|
+
}
|
|
41
|
+
export const allDependencies = (pkg) => ({
|
|
42
|
+
...pkg.optionalDependencies,
|
|
43
|
+
...pkg.devDependencies,
|
|
44
|
+
...pkg.dependencies,
|
|
45
|
+
});
|
|
46
|
+
/** Whether a package.json belongs to a Basalt app (any @basaltkit/* — or legacy @machize/* — dependency). */
|
|
47
|
+
export const isBasaltPackage = (pkg) => Object.keys(allDependencies(pkg)).some((name) => name.startsWith('@basaltkit/') || name.startsWith('@machize/'));
|
|
48
|
+
/**
|
|
49
|
+
* The features an app has, from what is actually there: dependencies for the
|
|
50
|
+
* framework domains, the tree for ui (web/) and cli (bin/basalt.ts). The
|
|
51
|
+
* manifest supplies nothing the tree contradicts — a dependency removed since
|
|
52
|
+
* the scaffold turns its feature off.
|
|
53
|
+
*/
|
|
54
|
+
export function inferOptions(dir, pkg, manifest) {
|
|
55
|
+
const deps = allDependencies(pkg);
|
|
56
|
+
const has = (name) => deps[name] !== undefined;
|
|
57
|
+
return {
|
|
58
|
+
name: typeof pkg.name === 'string' && pkg.name !== '' ? pkg.name : (manifest?.options.name ?? 'app'),
|
|
59
|
+
tenancy: has('@basaltkit/tenancy'),
|
|
60
|
+
auth: has('@basaltkit/auth'),
|
|
61
|
+
billing: has('@basaltkit/subscriptions'),
|
|
62
|
+
ui: existsSync(join(dir, 'web', 'package.json')),
|
|
63
|
+
cli: existsSync(join(dir, 'bin', 'basalt.ts')),
|
|
64
|
+
mcp: has('@basaltkit/mcp'),
|
|
65
|
+
prisma: has('@prisma/client') || has('prisma'),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Loads the app at `dir`. Throws {@link NotABasaltAppError} with an actionable
|
|
70
|
+
* message when there is no package.json or it has no @basaltkit dependency.
|
|
71
|
+
*/
|
|
72
|
+
export async function loadProject(dir, pmFlag, userAgent = process.env['npm_config_user_agent']) {
|
|
73
|
+
let packageJsonText;
|
|
74
|
+
try {
|
|
75
|
+
packageJsonText = await readFile(join(dir, 'package.json'), 'utf8');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new NotABasaltAppError(`No package.json in ${dir}. Run this inside a Basalt app (or pass --cwd=<app dir>). To create one: npm create basalt <name>`);
|
|
79
|
+
}
|
|
80
|
+
let packageJson;
|
|
81
|
+
try {
|
|
82
|
+
packageJson = JSON.parse(packageJsonText);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
throw new NotABasaltAppError(`${join(dir, 'package.json')} is not valid JSON: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
if (!isBasaltPackage(packageJson)) {
|
|
88
|
+
throw new NotABasaltAppError(`${join(dir, 'package.json')} has no @basaltkit/* dependency — this does not look like a Basalt app. To create one: npm create basalt <name>`);
|
|
89
|
+
}
|
|
90
|
+
let web;
|
|
91
|
+
try {
|
|
92
|
+
const text = await readFile(join(dir, 'web', 'package.json'), 'utf8');
|
|
93
|
+
web = { text, json: JSON.parse(text) };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
web = undefined;
|
|
97
|
+
}
|
|
98
|
+
const manifest = await readManifest(dir);
|
|
99
|
+
const lockfiles = LOCKFILES.map(([file]) => file).filter((file) => existsSync(join(dir, file)));
|
|
100
|
+
const { pm, source } = detectProjectPackageManager(packageJson, lockfiles, pmFlag, userAgent);
|
|
101
|
+
return {
|
|
102
|
+
dir,
|
|
103
|
+
packageJsonText,
|
|
104
|
+
packageJson,
|
|
105
|
+
...(web ? { web } : {}),
|
|
106
|
+
...(manifest ? { manifest } : {}),
|
|
107
|
+
options: inferOptions(dir, packageJson, manifest),
|
|
108
|
+
pm,
|
|
109
|
+
pmSource: source,
|
|
110
|
+
lockfiles,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ResolveLatestOptions } from '../latest-versions.js';
|
|
2
|
+
import { type ProjectContext } from './context.js';
|
|
3
|
+
import type { Colors } from './term.js';
|
|
4
|
+
/**
|
|
5
|
+
* `create-basalt doctor`: a read-only health check of an app — nothing is
|
|
6
|
+
* written, nothing is installed. Errors make the exit code non-zero; warnings
|
|
7
|
+
* and notes do not.
|
|
8
|
+
*/
|
|
9
|
+
export type Level = 'ok' | 'info' | 'warn' | 'error';
|
|
10
|
+
export interface DoctorFinding {
|
|
11
|
+
level: Level;
|
|
12
|
+
/** Short area label: node, pm, deps, env, prisma, mcp, tooling. */
|
|
13
|
+
area: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
export interface DoctorOptions {
|
|
17
|
+
/** Node version to check (default: the running one). */
|
|
18
|
+
nodeVersion?: string;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
/** Skip the registry ("behind latest" check). */
|
|
21
|
+
offline?: boolean;
|
|
22
|
+
registry?: ResolveLatestOptions;
|
|
23
|
+
/** `<pm> --version` (injected; undefined when unavailable). */
|
|
24
|
+
pmVersion?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The Node range every @basaltkit package declares. */
|
|
27
|
+
export declare const BASALT_NODE_RANGE = ">=22.5.0";
|
|
28
|
+
/** Installed version of `name` as the app resolves it (top-level node_modules). */
|
|
29
|
+
export declare function installedVersion(dir: string, name: string): Promise<string | undefined>;
|
|
30
|
+
/** Parses a dotenv file (KEY=VALUE, optional quotes, # comments). */
|
|
31
|
+
export declare function parseDotenv(text: string): Record<string, string>;
|
|
32
|
+
/** The env prefix src/env.ts passes to defineEnv (`prefix: 'X'` or `prefix: { value: 'X' … }`). */
|
|
33
|
+
export declare function envPrefixOf(envTs: string | undefined, name: string): string | undefined;
|
|
34
|
+
export declare function runDoctor(ctx: ProjectContext, options?: DoctorOptions): Promise<DoctorFinding[]>;
|
|
35
|
+
export declare function renderFindings(findings: readonly DoctorFinding[], colors: Colors): string[];
|
|
36
|
+
/** `create-basalt info`: a paste-able versions summary for bug reports. */
|
|
37
|
+
export declare function projectInfo(ctx: ProjectContext, options?: {
|
|
38
|
+
nodeVersion?: string;
|
|
39
|
+
pmVersion?: string;
|
|
40
|
+
}): Promise<string[]>;
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { arch, platform } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { binStatus } from '../bin-template.js';
|
|
6
|
+
import { alwaysLatest, lookupLatestVersions } from '../latest-versions.js';
|
|
7
|
+
import { envPrefix } from '../templates.js';
|
|
8
|
+
import { allDependencies } from './context.js';
|
|
9
|
+
import { ownVersion } from './manifest.js';
|
|
10
|
+
import { compareVersions, parseSimpleRange, parseVersion, satisfies } from './semver.js';
|
|
11
|
+
/** 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;
|
|
13
|
+
/** The Node range every @basaltkit package declares. */
|
|
14
|
+
export const BASALT_NODE_RANGE = '>=22.5.0';
|
|
15
|
+
async function readJson(path) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function readText(path) {
|
|
24
|
+
try {
|
|
25
|
+
return await readFile(path, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Installed version of `name` as the app resolves it (top-level node_modules). */
|
|
32
|
+
export async function installedVersion(dir, name) {
|
|
33
|
+
const pkg = await readJson(join(dir, 'node_modules', ...name.split('/'), 'package.json'));
|
|
34
|
+
return typeof pkg?.version === 'string' ? pkg.version : undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Every installed version of each @basaltkit package: pnpm's virtual store
|
|
38
|
+
* (`node_modules/.pnpm/@basaltkit+core@1.5.0…`) plus npm/yarn nesting one level deep.
|
|
39
|
+
*/
|
|
40
|
+
async function installedFrameworkVersions(dir) {
|
|
41
|
+
const found = new Map();
|
|
42
|
+
const add = (name, version) => {
|
|
43
|
+
const set = found.get(name) ?? new Set();
|
|
44
|
+
set.add(version);
|
|
45
|
+
found.set(name, set);
|
|
46
|
+
};
|
|
47
|
+
const store = await readdir(join(dir, 'node_modules', '.pnpm')).catch(() => []);
|
|
48
|
+
for (const entry of store) {
|
|
49
|
+
const match = /^@basaltkit\+([^@]+)@(\d+\.\d+\.\d+[^_]*)/.exec(entry);
|
|
50
|
+
if (match)
|
|
51
|
+
add(`@basaltkit/${match[1]}`, match[2]);
|
|
52
|
+
}
|
|
53
|
+
const scope = join(dir, 'node_modules', '@basaltkit');
|
|
54
|
+
for (const entry of await readdir(scope).catch(() => [])) {
|
|
55
|
+
const version = await installedVersion(dir, `@basaltkit/${entry}`);
|
|
56
|
+
if (version)
|
|
57
|
+
add(`@basaltkit/${entry}`, version);
|
|
58
|
+
for (const nested of await readdir(join(scope, entry, 'node_modules', '@basaltkit')).catch(() => [])) {
|
|
59
|
+
const pkg = await readJson(join(scope, entry, 'node_modules', '@basaltkit', nested, 'package.json'));
|
|
60
|
+
if (typeof pkg?.version === 'string')
|
|
61
|
+
add(`@basaltkit/${nested}`, pkg.version);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
/** Parses a dotenv file (KEY=VALUE, optional quotes, # comments). */
|
|
67
|
+
export function parseDotenv(text) {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
70
|
+
const line = raw.trim();
|
|
71
|
+
if (line === '' || line.startsWith('#'))
|
|
72
|
+
continue;
|
|
73
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
74
|
+
if (!match)
|
|
75
|
+
continue;
|
|
76
|
+
let value = (match[2] ?? '').trim();
|
|
77
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
78
|
+
value = value.slice(1, -1);
|
|
79
|
+
out[match[1]] = value;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** The env prefix src/env.ts passes to defineEnv (`prefix: 'X'` or `prefix: { value: 'X' … }`). */
|
|
84
|
+
export function envPrefixOf(envTs, name) {
|
|
85
|
+
if (envTs === undefined)
|
|
86
|
+
return envPrefix(name);
|
|
87
|
+
const match = /prefix:\s*(?:\{\s*value:\s*)?['"]([A-Z0-9_]+)['"]/.exec(envTs);
|
|
88
|
+
return match ? match[1] : /prefix:/.test(envTs) ? undefined : '';
|
|
89
|
+
}
|
|
90
|
+
export async function runDoctor(ctx, options = {}) {
|
|
91
|
+
const findings = [];
|
|
92
|
+
const push = (level, area, message) => {
|
|
93
|
+
findings.push({ level, area, message });
|
|
94
|
+
};
|
|
95
|
+
const env = options.env ?? process.env;
|
|
96
|
+
const deps = allDependencies(ctx.packageJson);
|
|
97
|
+
const { dir } = ctx;
|
|
98
|
+
// --- Node -----------------------------------------------------------------
|
|
99
|
+
const node = options.nodeVersion ?? process.versions.node;
|
|
100
|
+
const engines = ctx.packageJson.engines?.['node'];
|
|
101
|
+
for (const [range, origin] of [
|
|
102
|
+
...(engines ? [[engines, 'package.json engines.node']] : []),
|
|
103
|
+
[BASALT_NODE_RANGE, 'Basalt'],
|
|
104
|
+
]) {
|
|
105
|
+
const ok = satisfies(node, range);
|
|
106
|
+
if (ok === false)
|
|
107
|
+
push('error', 'node', `Node ${node} does not satisfy ${range} (${origin}).`);
|
|
108
|
+
else if (ok === true)
|
|
109
|
+
push('ok', 'node', `Node ${node} satisfies ${range} (${origin}).`);
|
|
110
|
+
}
|
|
111
|
+
// --- Package manager --------------------------------------------------------
|
|
112
|
+
push('ok', 'pm', `Package manager: ${ctx.pm}${options.pmVersion ? ` ${options.pmVersion}` : ''} (from ${ctx.pmSource}).`);
|
|
113
|
+
if (ctx.lockfiles.length > 1)
|
|
114
|
+
push('warn', 'pm', `Several lockfiles (${ctx.lockfiles.join(', ')}) — keep the one of ${ctx.pm}.`);
|
|
115
|
+
if (ctx.lockfiles.length === 0)
|
|
116
|
+
push('warn', 'pm', `No lockfile — run \`${ctx.pm} install\` and commit it.`);
|
|
117
|
+
const declaredPm = ctx.packageJson.packageManager?.split('@')[0];
|
|
118
|
+
const lockPm = ctx.lockfiles[0] ? ctx.lockfiles[0].split(/[-.]/)[0] : undefined;
|
|
119
|
+
if (declaredPm && lockPm && ctx.lockfiles.length === 1 && !ctx.lockfiles[0]?.startsWith(declaredPm) && !(declaredPm === 'npm' && lockPm === 'npm')) {
|
|
120
|
+
push('warn', 'pm', `packageManager says ${declaredPm}, but the lockfile is ${ctx.lockfiles[0]}.`);
|
|
121
|
+
}
|
|
122
|
+
if (ctx.options.ui && ctx.pm !== 'pnpm') {
|
|
123
|
+
push('error', 'pm', `web/ is a pnpm workspace member, but this project uses ${ctx.pm}.`);
|
|
124
|
+
}
|
|
125
|
+
// --- Dependencies -----------------------------------------------------------
|
|
126
|
+
const installedRoot = existsSync(join(dir, 'node_modules'));
|
|
127
|
+
if (!installedRoot) {
|
|
128
|
+
push('error', 'deps', `Dependencies are not installed — run \`${ctx.pm} install\`.`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const missing = [];
|
|
132
|
+
const outOfSync = [];
|
|
133
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
134
|
+
const version = await installedVersion(dir, name);
|
|
135
|
+
if (version === undefined) {
|
|
136
|
+
if (!(ctx.packageJson.optionalDependencies && name in ctx.packageJson.optionalDependencies))
|
|
137
|
+
missing.push(name);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (satisfies(version, range) === false)
|
|
141
|
+
outOfSync.push(`${name} (${version} installed, ${range} declared)`);
|
|
142
|
+
}
|
|
143
|
+
if (missing.length > 0)
|
|
144
|
+
push('error', 'deps', `Not installed: ${missing.join(', ')} — run \`${ctx.pm} install\`.`);
|
|
145
|
+
if (outOfSync.length > 0)
|
|
146
|
+
push('error', 'deps', `Installed versions do not match package.json: ${outOfSync.join('; ')} — run \`${ctx.pm} install\`.`);
|
|
147
|
+
const framework = await installedFrameworkVersions(dir);
|
|
148
|
+
const duplicates = [...framework].filter(([, versions]) => versions.size > 1);
|
|
149
|
+
for (const [name, versions] of duplicates) {
|
|
150
|
+
push('warn', 'deps', `${name} is installed in ${versions.size} versions (${[...versions].sort().join(', ')}) — framework singletons (DI tokens, context) may not line up; align the ranges (\`basalt update\`).`);
|
|
151
|
+
}
|
|
152
|
+
let peerProblems = 0;
|
|
153
|
+
for (const name of Object.keys(deps).filter((dep) => dep.startsWith('@basaltkit/'))) {
|
|
154
|
+
const pkg = await readJson(join(dir, 'node_modules', ...name.split('/'), 'package.json'));
|
|
155
|
+
const optional = (pkg?.['peerDependenciesMeta'] ?? {});
|
|
156
|
+
for (const [peer, range] of Object.entries(pkg?.peerDependencies ?? {})) {
|
|
157
|
+
const version = await installedVersion(dir, peer);
|
|
158
|
+
if (version === undefined) {
|
|
159
|
+
if (!optional[peer]?.optional && peer.startsWith('@basaltkit/')) {
|
|
160
|
+
peerProblems++;
|
|
161
|
+
push('warn', 'deps', `${name} expects ${peer} ${range}, which is not installed.`);
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (satisfies(version, range) === false) {
|
|
166
|
+
peerProblems++;
|
|
167
|
+
push('error', 'deps', `${name} expects ${peer} ${range}, but ${version} is installed.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (missing.length === 0 && outOfSync.length === 0 && duplicates.length === 0 && peerProblems === 0) {
|
|
172
|
+
push('ok', 'deps', 'Installed dependencies match package.json; @basaltkit peer ranges are satisfied.');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const name of ['@basaltkit/ai-mcp', '@basaltkit/ai', '@basaltkit/generator', 'create-basalt']) {
|
|
176
|
+
if (ctx.packageJson.dependencies?.[name] !== undefined) {
|
|
177
|
+
push('warn', 'deps', `${name} is a runtime dependency — it is dev tooling; move it to devDependencies.`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// --- Behind latest ----------------------------------------------------------
|
|
181
|
+
if (!options.offline) {
|
|
182
|
+
const framework = Object.entries(deps).filter(([name]) => alwaysLatest(name));
|
|
183
|
+
const { results } = await lookupLatestVersions(framework.map(([name]) => name), () => false, options.registry ?? {});
|
|
184
|
+
if (framework.length > 0 && framework.every(([name]) => results.get(name)?.latest === undefined)) {
|
|
185
|
+
push('info', 'updates', 'Registry unreachable — skipped the "behind latest" check.');
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const behind = framework.filter(([name, range]) => {
|
|
189
|
+
const latest = parseVersion(results.get(name)?.latest ?? '');
|
|
190
|
+
const floor = parseSimpleRange(range);
|
|
191
|
+
return latest !== undefined && floor !== undefined && compareVersions(latest, floor.parsed) > 0;
|
|
192
|
+
});
|
|
193
|
+
if (behind.length > 0) {
|
|
194
|
+
push('warn', 'updates', `${behind.length} framework package(s) behind latest: ${behind
|
|
195
|
+
.map(([name, range]) => `${name} ${range} → ${results.get(name)?.latest}`)
|
|
196
|
+
.join(', ')} — run \`basalt update\` (or npx create-basalt@latest update).`);
|
|
197
|
+
}
|
|
198
|
+
else if (framework.length > 0)
|
|
199
|
+
push('ok', 'updates', 'Framework packages are on their latest versions.');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// --- Environment ------------------------------------------------------------
|
|
203
|
+
const envTs = await readText(join(dir, 'src', 'env.ts'));
|
|
204
|
+
const dotenvText = await readText(join(dir, '.env'));
|
|
205
|
+
const dotenv = dotenvText ? parseDotenv(dotenvText) : {};
|
|
206
|
+
if (dotenvText === undefined && existsSync(join(dir, '.env.example'))) {
|
|
207
|
+
push('info', 'env', 'No .env — copy .env.example to .env (and start with --env-file=.env) or export the variables.');
|
|
208
|
+
}
|
|
209
|
+
const prefix = envPrefixOf(envTs, ctx.options.name);
|
|
210
|
+
const lookup = (name) => {
|
|
211
|
+
const names = prefix ? [`${prefix}_${name}`, name] : [name];
|
|
212
|
+
for (const key of names) {
|
|
213
|
+
if (env[key] !== undefined && env[key] !== '')
|
|
214
|
+
return { value: env[key], from: `$${key}` };
|
|
215
|
+
}
|
|
216
|
+
for (const key of names) {
|
|
217
|
+
if (dotenv[key] !== undefined && dotenv[key] !== '')
|
|
218
|
+
return { value: dotenv[key], from: `.env ${key}` };
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
};
|
|
222
|
+
if (ctx.options.auth) {
|
|
223
|
+
const declared = envTs ? /APP_SECRET:\s*secret\(\{[^}]*minLength:\s*(\d+)/.exec(envTs) : null;
|
|
224
|
+
const minLength = declared ? Number(declared[1]) : 32;
|
|
225
|
+
const secret = lookup('APP_SECRET');
|
|
226
|
+
const label = prefix ? `${prefix}_APP_SECRET` : 'APP_SECRET';
|
|
227
|
+
if (!secret) {
|
|
228
|
+
push('warn', 'env', `${label} is not set — fine for \`dev\` (development default), required by \`start\`/production (≥ ${minLength} chars: openssl rand -base64 48).`);
|
|
229
|
+
}
|
|
230
|
+
else if (secret.value.length < minLength) {
|
|
231
|
+
push('error', 'env', `${label} (${secret.from}) is ${secret.value.length} characters — the app requires at least ${minLength}.`);
|
|
232
|
+
}
|
|
233
|
+
else if (INSECURE_SECRET.test(secret.value)) {
|
|
234
|
+
push('error', 'env', `${label} (${secret.from}) looks like a placeholder — the app rejects it outside development.`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
push('ok', 'env', `${label} is set (${secret.from}, ${secret.value.length} characters).`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (ctx.options.prisma) {
|
|
241
|
+
const url = lookup('DATABASE_URL');
|
|
242
|
+
if (!url)
|
|
243
|
+
push('warn', 'env', `${prefix ? `${prefix}_` : ''}DATABASE_URL is not set — the app does not boot without it.`);
|
|
244
|
+
else
|
|
245
|
+
push('ok', 'env', `${prefix ? `${prefix}_` : ''}DATABASE_URL is set (${url.from}).`);
|
|
246
|
+
}
|
|
247
|
+
// --- Prisma -------------------------------------------------------------------
|
|
248
|
+
const schemaPath = join(dir, 'prisma', 'schema.prisma');
|
|
249
|
+
const schema = await readText(schemaPath);
|
|
250
|
+
if (schema !== undefined) {
|
|
251
|
+
const output = /generator\s+\w+\s*\{[^}]*output\s*=\s*"([^"]+)"/.exec(schema)?.[1];
|
|
252
|
+
const clientDir = output ? resolve(dirname(schemaPath), output) : join(dir, 'node_modules', '.prisma', 'client');
|
|
253
|
+
if (!existsSync(clientDir)) {
|
|
254
|
+
push('error', 'prisma', `Prisma client not generated (${clientDir.slice(dir.length + 1) || clientDir}) — run \`${ctx.pm === 'npm' ? 'npm run' : ctx.pm} db:generate\` (prisma generate).`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
push('ok', 'prisma', 'Prisma client is generated.');
|
|
258
|
+
}
|
|
259
|
+
const migrations = await readdir(join(dir, 'prisma', 'migrations'), { withFileTypes: true }).catch(() => []);
|
|
260
|
+
const count = migrations.filter((entry) => entry.isDirectory()).length;
|
|
261
|
+
if (count === 0)
|
|
262
|
+
push('warn', 'prisma', `No migrations yet — run \`${ctx.pm === 'npm' ? 'npm run' : ctx.pm} db:migrate\` (the app boots with assertMigrated).`);
|
|
263
|
+
else
|
|
264
|
+
push('info', 'prisma', `${count} migration(s) on disk; whether they are applied needs the database: \`prisma migrate status\`.`);
|
|
265
|
+
}
|
|
266
|
+
// --- MCP -------------------------------------------------------------------------
|
|
267
|
+
if (deps['@basaltkit/ai-mcp'] !== undefined) {
|
|
268
|
+
const text = await readText(join(dir, '.mcp.json'));
|
|
269
|
+
if (text === undefined) {
|
|
270
|
+
push('warn', 'mcp', '@basaltkit/ai-mcp is installed but there is no .mcp.json — MCP clients will not find the dev bridge.');
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
let parsed;
|
|
274
|
+
try {
|
|
275
|
+
parsed = JSON.parse(text);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
push('error', 'mcp', `.mcp.json is not valid JSON: ${error.message}`);
|
|
279
|
+
}
|
|
280
|
+
if (parsed) {
|
|
281
|
+
const servers = Object.values(parsed.mcpServers ?? {});
|
|
282
|
+
const bridge = servers.some((server) => {
|
|
283
|
+
const parts = [server.command, ...(Array.isArray(server.args) ? server.args : [])].map(String);
|
|
284
|
+
return parts.some((part) => part.includes('@basaltkit/ai-mcp') || part.includes('basalt-ai-mcp'));
|
|
285
|
+
});
|
|
286
|
+
if (bridge)
|
|
287
|
+
push('ok', 'mcp', '.mcp.json registers the basalt-ai-mcp dev bridge.');
|
|
288
|
+
else
|
|
289
|
+
push('warn', 'mcp', '.mcp.json has no server running @basaltkit/ai-mcp (basalt-ai-mcp).');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// --- Project tooling -------------------------------------------------------------
|
|
294
|
+
const bin = await readText(join(dir, 'bin', 'basalt.ts'));
|
|
295
|
+
const status = binStatus(bin, ctx.manifest?.files['bin/basalt.ts']);
|
|
296
|
+
if (status === 'patchable')
|
|
297
|
+
push('warn', 'tooling', 'bin/basalt.ts predates the project commands — `npx create-basalt@latest update` patches it.');
|
|
298
|
+
if (status === 'modified')
|
|
299
|
+
push('info', 'tooling', 'bin/basalt.ts is customised and does not delegate update/add/doctor/info — see `create-basalt update` for the snippet.');
|
|
300
|
+
if (!ctx.manifest)
|
|
301
|
+
push('info', 'tooling', 'No .basalt/project.json (scaffolded before create-basalt 1.10) — features are inferred from package.json.');
|
|
302
|
+
return findings;
|
|
303
|
+
}
|
|
304
|
+
export function renderFindings(findings, colors) {
|
|
305
|
+
const icon = {
|
|
306
|
+
ok: colors.green('✓'),
|
|
307
|
+
info: colors.cyan('i'),
|
|
308
|
+
warn: colors.yellow('!'),
|
|
309
|
+
error: colors.red('✗'),
|
|
310
|
+
};
|
|
311
|
+
const lines = findings.map((finding) => `${icon[finding.level]} ${colors.dim(finding.area.padEnd(8))} ${finding.message}`);
|
|
312
|
+
const errors = findings.filter((finding) => finding.level === 'error').length;
|
|
313
|
+
const warnings = findings.filter((finding) => finding.level === 'warn').length;
|
|
314
|
+
lines.push('', errors > 0
|
|
315
|
+
? colors.red(`${errors} error(s), ${warnings} warning(s).`)
|
|
316
|
+
: warnings > 0
|
|
317
|
+
? colors.yellow(`No errors, ${warnings} warning(s).`)
|
|
318
|
+
: colors.green('All checks passed.'));
|
|
319
|
+
return lines;
|
|
320
|
+
}
|
|
321
|
+
/** `create-basalt info`: a paste-able versions summary for bug reports. */
|
|
322
|
+
export async function projectInfo(ctx, options = {}) {
|
|
323
|
+
const deps = allDependencies(ctx.packageJson);
|
|
324
|
+
const enabled = ['tenancy', 'auth', 'billing', 'ui', 'cli', 'mcp', 'prisma'].filter((key) => ctx.options[key]);
|
|
325
|
+
const lines = [
|
|
326
|
+
`create-basalt ${ownVersion()}`,
|
|
327
|
+
`node ${options.nodeVersion ?? process.versions.node}`,
|
|
328
|
+
`os ${platform()} ${arch()}`,
|
|
329
|
+
`pm ${ctx.pm}${options.pmVersion ? ` ${options.pmVersion}` : ''} (${ctx.pmSource})`,
|
|
330
|
+
`app ${ctx.options.name}${ctx.manifest ? ` (created with create-basalt ${ctx.manifest.createdWith})` : ''}`,
|
|
331
|
+
`features ${enabled.length > 0 ? enabled.join(', ') : 'none'}`,
|
|
332
|
+
'',
|
|
333
|
+
'package declared installed',
|
|
334
|
+
];
|
|
335
|
+
const interesting = Object.keys(deps)
|
|
336
|
+
.filter((name) => alwaysLatest(name) || ['typescript', 'zod', 'prisma', '@prisma/client', 'tsx', 'vitest'].includes(name))
|
|
337
|
+
.sort();
|
|
338
|
+
for (const name of interesting) {
|
|
339
|
+
const installed = (await installedVersion(ctx.dir, name)) ?? '-';
|
|
340
|
+
lines.push(`${name.padEnd(30)}${deps[name].padEnd(16)}${installed}`);
|
|
341
|
+
}
|
|
342
|
+
if (ctx.web) {
|
|
343
|
+
const webDeps = allDependencies(ctx.web.json);
|
|
344
|
+
for (const name of Object.keys(webDeps).filter((dep) => dep.startsWith('@basaltkit/') || ['react', 'vite'].includes(dep)).sort()) {
|
|
345
|
+
const installed = (await installedVersion(join(ctx.dir, 'web'), name)) ?? '-';
|
|
346
|
+
lines.push(`${`web: ${name}`.padEnd(30)}${webDeps[name].padEnd(16)}${installed}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return lines;
|
|
350
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ProjectOptions } from '../templates.js';
|
|
2
|
+
/**
|
|
3
|
+
* The scaffold manifest, `.basalt/project.json`: which create-basalt release
|
|
4
|
+
* generated the project, with which options, and a content hash of every file
|
|
5
|
+
* it wrote. `add` and `update` use the hashes to tell a template file nobody
|
|
6
|
+
* touched (safe to regenerate or patch) from one the user edited (left alone,
|
|
7
|
+
* with manual steps printed instead). Commit it with the project.
|
|
8
|
+
*
|
|
9
|
+
* Projects scaffolded before the manifest existed have none: their features
|
|
10
|
+
* are inferred from package.json and every file counts as user-owned.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MANIFEST_PATH = ".basalt/project.json";
|
|
13
|
+
export interface ProjectManifest {
|
|
14
|
+
/** Bumped only on an incompatible change of this file's shape. */
|
|
15
|
+
manifestVersion: 1;
|
|
16
|
+
generator: 'create-basalt';
|
|
17
|
+
/** create-basalt version that created the project. */
|
|
18
|
+
createdWith: string;
|
|
19
|
+
/** create-basalt version that last updated this manifest. */
|
|
20
|
+
updatedWith: string;
|
|
21
|
+
options: ProjectOptions;
|
|
22
|
+
/** Project-relative path → `sha256-<hex>` of the content create-basalt wrote. */
|
|
23
|
+
files: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
/** Content hash, insensitive to CRLF/LF (git autocrlf must not make a file look edited). */
|
|
26
|
+
export declare function hashContent(content: string): string;
|
|
27
|
+
/** This create-basalt release's version (from its own package.json). */
|
|
28
|
+
export declare function ownVersion(): string;
|
|
29
|
+
export declare function createManifest(options: ProjectOptions, files: Readonly<Record<string, string>>): ProjectManifest;
|
|
30
|
+
/** Records newly written files (and the options now on) in an existing manifest. */
|
|
31
|
+
export declare function extendManifest(manifest: ProjectManifest, options: ProjectOptions, written: Readonly<Record<string, string>>): ProjectManifest;
|
|
32
|
+
export declare const serializeManifest: (manifest: ProjectManifest) => string;
|
|
33
|
+
/** Reads `.basalt/project.json`; undefined when absent or unreadable (treated as "no manifest"). */
|
|
34
|
+
export declare function readManifest(dir: string): Promise<ProjectManifest | undefined>;
|
|
35
|
+
/** Whether `content` is exactly what create-basalt wrote at `path` (per the manifest). */
|
|
36
|
+
export declare function isPristine(manifest: ProjectManifest | undefined, path: string, content: string): boolean;
|