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,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* The scaffold manifest, `.basalt/project.json`: which create-basalt release
|
|
7
|
+
* generated the project, with which options, and a content hash of every file
|
|
8
|
+
* it wrote. `add` and `update` use the hashes to tell a template file nobody
|
|
9
|
+
* touched (safe to regenerate or patch) from one the user edited (left alone,
|
|
10
|
+
* with manual steps printed instead). Commit it with the project.
|
|
11
|
+
*
|
|
12
|
+
* Projects scaffolded before the manifest existed have none: their features
|
|
13
|
+
* are inferred from package.json and every file counts as user-owned.
|
|
14
|
+
*/
|
|
15
|
+
export const MANIFEST_PATH = '.basalt/project.json';
|
|
16
|
+
/** Content hash, insensitive to CRLF/LF (git autocrlf must not make a file look edited). */
|
|
17
|
+
export function hashContent(content) {
|
|
18
|
+
return `sha256-${createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex')}`;
|
|
19
|
+
}
|
|
20
|
+
let cachedVersion;
|
|
21
|
+
/** This create-basalt release's version (from its own package.json). */
|
|
22
|
+
export function ownVersion() {
|
|
23
|
+
if (cachedVersion === undefined) {
|
|
24
|
+
try {
|
|
25
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
26
|
+
cachedVersion = pkg.version ?? '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
cachedVersion = '0.0.0';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return cachedVersion;
|
|
33
|
+
}
|
|
34
|
+
/** Files never hashed: package.json files are rewritten by every install/update, the manifest by itself. */
|
|
35
|
+
const UNTRACKED = (path) => path === MANIFEST_PATH || path === 'package.json' || path.endsWith('/package.json');
|
|
36
|
+
export function createManifest(options, files) {
|
|
37
|
+
const version = ownVersion();
|
|
38
|
+
return {
|
|
39
|
+
manifestVersion: 1,
|
|
40
|
+
generator: 'create-basalt',
|
|
41
|
+
createdWith: version,
|
|
42
|
+
updatedWith: version,
|
|
43
|
+
options,
|
|
44
|
+
files: Object.fromEntries(Object.entries(files)
|
|
45
|
+
.filter(([path]) => !UNTRACKED(path))
|
|
46
|
+
.map(([path, content]) => [path, hashContent(content)])
|
|
47
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Records newly written files (and the options now on) in an existing manifest. */
|
|
51
|
+
export function extendManifest(manifest, options, written) {
|
|
52
|
+
const files = { ...manifest.files };
|
|
53
|
+
for (const [path, content] of Object.entries(written)) {
|
|
54
|
+
if (!UNTRACKED(path))
|
|
55
|
+
files[path] = hashContent(content);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
...manifest,
|
|
59
|
+
updatedWith: ownVersion(),
|
|
60
|
+
options,
|
|
61
|
+
files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export const serializeManifest = (manifest) => `${JSON.stringify(manifest, null, 2)}\n`;
|
|
65
|
+
/** Reads `.basalt/project.json`; undefined when absent or unreadable (treated as "no manifest"). */
|
|
66
|
+
export async function readManifest(dir) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(await readFile(join(dir, MANIFEST_PATH), 'utf8'));
|
|
69
|
+
if (parsed.generator !== 'create-basalt' || typeof parsed.files !== 'object' || parsed.files === null)
|
|
70
|
+
return undefined;
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Whether `content` is exactly what create-basalt wrote at `path` (per the manifest). */
|
|
78
|
+
export function isPristine(manifest, path, content) {
|
|
79
|
+
const recorded = manifest?.files[path];
|
|
80
|
+
return recorded !== undefined && recorded === hashContent(content);
|
|
81
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package.json edits that keep the file looking like the user left it.
|
|
3
|
+
*
|
|
4
|
+
* - {@link setDependencyRange} rewrites ONE range in place (text-level): key
|
|
5
|
+
* order, indentation, line endings and every other byte stay untouched.
|
|
6
|
+
* - {@link mergePackageJson} adds dependencies/scripts (structural): the file is
|
|
7
|
+
* re-serialized with its own indentation and trailing newline, new keys of an
|
|
8
|
+
* alphabetically sorted dependency section are slotted in order, and an
|
|
9
|
+
* existing entry is never changed.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEPENDENCY_SECTIONS: readonly ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
12
|
+
export type DependencySection = (typeof DEPENDENCY_SECTIONS)[number];
|
|
13
|
+
export interface PackageJson {
|
|
14
|
+
name?: string;
|
|
15
|
+
version?: string;
|
|
16
|
+
packageManager?: string;
|
|
17
|
+
engines?: Record<string, string>;
|
|
18
|
+
scripts?: Record<string, string>;
|
|
19
|
+
dependencies?: Record<string, string>;
|
|
20
|
+
devDependencies?: Record<string, string>;
|
|
21
|
+
optionalDependencies?: Record<string, string>;
|
|
22
|
+
peerDependencies?: Record<string, string>;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The `[start, end)` span of a top-level key's VALUE in a JSON object text, or
|
|
27
|
+
* undefined. Walks the top level only, so a nested key of the same name (an
|
|
28
|
+
* `overrides.dependencies`) never matches.
|
|
29
|
+
*/
|
|
30
|
+
export declare function topLevelValueSpan(text: string, key: string): [number, number] | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Rewrites `section[name]` to `range`, touching only that string. Returns the
|
|
33
|
+
* text unchanged when the entry is not there.
|
|
34
|
+
*/
|
|
35
|
+
export declare function setDependencyRange(text: string, section: string, name: string, range: string): string;
|
|
36
|
+
/** The indentation unit of a JSON text (default two spaces). */
|
|
37
|
+
export declare function detectIndent(text: string): string;
|
|
38
|
+
export interface PackageJsonAdditions {
|
|
39
|
+
dependencies?: Record<string, string>;
|
|
40
|
+
devDependencies?: Record<string, string>;
|
|
41
|
+
/** New scripts. An existing script of the same name is kept (see `replaceScripts`). */
|
|
42
|
+
scripts?: Record<string, string>;
|
|
43
|
+
/** Scripts to overwrite: name → { from, to }; replaced only while still `from`. */
|
|
44
|
+
replaceScripts?: Record<string, {
|
|
45
|
+
from: string;
|
|
46
|
+
to: string;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
export interface MergeResult {
|
|
50
|
+
text: string;
|
|
51
|
+
/** What was added/changed, for the plan. */
|
|
52
|
+
changes: string[];
|
|
53
|
+
/** What was left alone because the user already has something else there. */
|
|
54
|
+
kept: string[];
|
|
55
|
+
}
|
|
56
|
+
/** Adds dependencies and scripts; never changes an existing entry. */
|
|
57
|
+
export declare function mergePackageJson(text: string, additions: PackageJsonAdditions): MergeResult;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package.json edits that keep the file looking like the user left it.
|
|
3
|
+
*
|
|
4
|
+
* - {@link setDependencyRange} rewrites ONE range in place (text-level): key
|
|
5
|
+
* order, indentation, line endings and every other byte stay untouched.
|
|
6
|
+
* - {@link mergePackageJson} adds dependencies/scripts (structural): the file is
|
|
7
|
+
* re-serialized with its own indentation and trailing newline, new keys of an
|
|
8
|
+
* alphabetically sorted dependency section are slotted in order, and an
|
|
9
|
+
* existing entry is never changed.
|
|
10
|
+
*/
|
|
11
|
+
export const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
12
|
+
/** Index just past the value starting at `start` (a string, object, array or literal). */
|
|
13
|
+
function skipValue(text, start) {
|
|
14
|
+
let i = start;
|
|
15
|
+
while (/\s/.test(text[i] ?? ''))
|
|
16
|
+
i++;
|
|
17
|
+
const first = text[i];
|
|
18
|
+
if (first === '"') {
|
|
19
|
+
i++;
|
|
20
|
+
while (i < text.length && text[i] !== '"')
|
|
21
|
+
i += text[i] === '\\' ? 2 : 1;
|
|
22
|
+
return i + 1;
|
|
23
|
+
}
|
|
24
|
+
if (first === '{' || first === '[') {
|
|
25
|
+
let depth = 0;
|
|
26
|
+
for (; i < text.length; i++) {
|
|
27
|
+
const ch = text[i];
|
|
28
|
+
if (ch === '"') {
|
|
29
|
+
i = skipValue(text, i) - 1;
|
|
30
|
+
}
|
|
31
|
+
else if (ch === '{' || ch === '[')
|
|
32
|
+
depth++;
|
|
33
|
+
else if (ch === '}' || ch === ']') {
|
|
34
|
+
depth--;
|
|
35
|
+
if (depth === 0)
|
|
36
|
+
return i + 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return text.length;
|
|
40
|
+
}
|
|
41
|
+
while (i < text.length && !/[,}\]\s]/.test(text[i] ?? ''))
|
|
42
|
+
i++;
|
|
43
|
+
return i;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The `[start, end)` span of a top-level key's VALUE in a JSON object text, or
|
|
47
|
+
* undefined. Walks the top level only, so a nested key of the same name (an
|
|
48
|
+
* `overrides.dependencies`) never matches.
|
|
49
|
+
*/
|
|
50
|
+
export function topLevelValueSpan(text, key) {
|
|
51
|
+
let i = text.indexOf('{');
|
|
52
|
+
if (i === -1)
|
|
53
|
+
return undefined;
|
|
54
|
+
i++;
|
|
55
|
+
while (i < text.length) {
|
|
56
|
+
while (/[\s,]/.test(text[i] ?? ''))
|
|
57
|
+
i++;
|
|
58
|
+
if (text[i] !== '"')
|
|
59
|
+
return undefined;
|
|
60
|
+
const keyEnd = skipValue(text, i);
|
|
61
|
+
const name = JSON.parse(text.slice(i, keyEnd));
|
|
62
|
+
i = keyEnd;
|
|
63
|
+
while (/\s/.test(text[i] ?? ''))
|
|
64
|
+
i++;
|
|
65
|
+
if (text[i] !== ':')
|
|
66
|
+
return undefined;
|
|
67
|
+
i++;
|
|
68
|
+
while (/\s/.test(text[i] ?? ''))
|
|
69
|
+
i++;
|
|
70
|
+
const valueEnd = skipValue(text, i);
|
|
71
|
+
if (name === key)
|
|
72
|
+
return [i, valueEnd];
|
|
73
|
+
i = valueEnd;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Rewrites `section[name]` to `range`, touching only that string. Returns the
|
|
79
|
+
* text unchanged when the entry is not there.
|
|
80
|
+
*/
|
|
81
|
+
export function setDependencyRange(text, section, name, range) {
|
|
82
|
+
const span = topLevelValueSpan(text, section);
|
|
83
|
+
if (!span)
|
|
84
|
+
return text;
|
|
85
|
+
const [start, end] = span;
|
|
86
|
+
const body = text.slice(start, end);
|
|
87
|
+
const inner = topLevelValueSpan(body, name);
|
|
88
|
+
if (!inner)
|
|
89
|
+
return text;
|
|
90
|
+
const [valueStart, valueEnd] = inner;
|
|
91
|
+
return text.slice(0, start) + body.slice(0, valueStart) + JSON.stringify(range) + body.slice(valueEnd) + text.slice(end);
|
|
92
|
+
}
|
|
93
|
+
/** The indentation unit of a JSON text (default two spaces). */
|
|
94
|
+
export function detectIndent(text) {
|
|
95
|
+
const match = /^[ \t]+(?=")/m.exec(text);
|
|
96
|
+
return match ? match[0] : ' ';
|
|
97
|
+
}
|
|
98
|
+
const isSorted = (keys) => keys.every((key, i) => i === 0 || keys[i - 1] <= key);
|
|
99
|
+
/** Adds dependencies and scripts; never changes an existing entry. */
|
|
100
|
+
export function mergePackageJson(text, additions) {
|
|
101
|
+
const pkg = JSON.parse(text);
|
|
102
|
+
const changes = [];
|
|
103
|
+
const kept = [];
|
|
104
|
+
for (const section of ['dependencies', 'devDependencies']) {
|
|
105
|
+
const wanted = additions[section];
|
|
106
|
+
if (!wanted)
|
|
107
|
+
continue;
|
|
108
|
+
const current = { ...(pkg[section] ?? {}) };
|
|
109
|
+
const sorted = isSorted(Object.keys(current));
|
|
110
|
+
let added = false;
|
|
111
|
+
for (const [name, range] of Object.entries(wanted)) {
|
|
112
|
+
const elsewhere = section === 'dependencies' ? pkg.devDependencies?.[name] : pkg.dependencies?.[name];
|
|
113
|
+
if (current[name] !== undefined || elsewhere !== undefined) {
|
|
114
|
+
if ((current[name] ?? elsewhere) !== range)
|
|
115
|
+
kept.push(`${name} (kept ${current[name] ?? elsewhere})`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
current[name] = range;
|
|
119
|
+
changes.push(`${section}: + ${name}@${range}`);
|
|
120
|
+
added = true;
|
|
121
|
+
}
|
|
122
|
+
if (added)
|
|
123
|
+
pkg[section] = sorted ? Object.fromEntries(Object.entries(current).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : current;
|
|
124
|
+
}
|
|
125
|
+
const scripts = { ...(pkg.scripts ?? {}) };
|
|
126
|
+
let scriptsChanged = false;
|
|
127
|
+
for (const [name, command] of Object.entries(additions.scripts ?? {})) {
|
|
128
|
+
if (scripts[name] !== undefined) {
|
|
129
|
+
if (scripts[name] !== command)
|
|
130
|
+
kept.push(`script "${name}" (kept "${scripts[name]}")`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
scripts[name] = command;
|
|
134
|
+
changes.push(`scripts: + ${name}: ${command}`);
|
|
135
|
+
scriptsChanged = true;
|
|
136
|
+
}
|
|
137
|
+
for (const [name, { from, to }] of Object.entries(additions.replaceScripts ?? {})) {
|
|
138
|
+
if (scripts[name] === from) {
|
|
139
|
+
scripts[name] = to;
|
|
140
|
+
changes.push(`scripts: ${name}: ${from} → ${to}`);
|
|
141
|
+
scriptsChanged = true;
|
|
142
|
+
}
|
|
143
|
+
else if (scripts[name] === undefined) {
|
|
144
|
+
scripts[name] = to;
|
|
145
|
+
changes.push(`scripts: + ${name}: ${to}`);
|
|
146
|
+
scriptsChanged = true;
|
|
147
|
+
}
|
|
148
|
+
else if (scripts[name] !== to) {
|
|
149
|
+
kept.push(`script "${name}" (kept "${scripts[name]}")`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (scriptsChanged)
|
|
153
|
+
pkg.scripts = scripts;
|
|
154
|
+
if (changes.length === 0)
|
|
155
|
+
return { text, changes, kept };
|
|
156
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
157
|
+
let out = JSON.stringify(pkg, null, detectIndent(text));
|
|
158
|
+
if (eol !== '\n')
|
|
159
|
+
out = out.replaceAll('\n', eol);
|
|
160
|
+
return { text: text.endsWith('\n') ? out + eol : out, changes, kept };
|
|
161
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PackageManager } from '../index.js';
|
|
2
|
+
import type { ResolveLatestOptions } from '../latest-versions.js';
|
|
3
|
+
import { type AddPlan } from './add.js';
|
|
4
|
+
import { type Colors, type Out } from './term.js';
|
|
5
|
+
/**
|
|
6
|
+
* The project commands — `create-basalt update | add | doctor | info` — run
|
|
7
|
+
* inside an EXISTING app (any app, with or without `--cli`). In a scaffolded
|
|
8
|
+
* app they are also reachable as `pnpm basalt <command>`: bin/basalt.ts (with
|
|
9
|
+
* `--cli`) or the `basalt` script (`create-basalt --project`) hands them here.
|
|
10
|
+
*
|
|
11
|
+
* Every side effect goes through {@link ProjectCommandDeps}, so the commands
|
|
12
|
+
* are tested with temp dirs, a fake registry and a fake package manager.
|
|
13
|
+
*/
|
|
14
|
+
export declare const PROJECT_COMMANDS: readonly ['update', 'add', 'doctor', 'info'];
|
|
15
|
+
export type ProjectCommand = (typeof PROJECT_COMMANDS)[number];
|
|
16
|
+
export declare const isProjectCommand: (token: string | undefined) => token is ProjectCommand;
|
|
17
|
+
export interface ProjectCommandDeps {
|
|
18
|
+
/** Directory to operate in (overridden by --cwd). */
|
|
19
|
+
cwd: string;
|
|
20
|
+
out: Out;
|
|
21
|
+
env: NodeJS.ProcessEnv;
|
|
22
|
+
/** Whether a human can answer a prompt (stdin is a TTY and not CI). */
|
|
23
|
+
interactive: boolean;
|
|
24
|
+
/** Whether stdout is a terminal (colors). */
|
|
25
|
+
isTTY: boolean;
|
|
26
|
+
/** Registry knobs (fetch, registry URL, clock, release-age window) — tests inject a fake fetch. */
|
|
27
|
+
registry?: ResolveLatestOptions;
|
|
28
|
+
/** Runs a command with inherited stdio; resolves false on failure (never throws). */
|
|
29
|
+
run(command: string, args: string[], cwd: string): Promise<boolean>;
|
|
30
|
+
/** Runs a command and returns its trimmed stdout (undefined on failure). */
|
|
31
|
+
capture(command: string, args: string[], cwd: string): Promise<string | undefined>;
|
|
32
|
+
/** Asks a yes/no question (default yes). */
|
|
33
|
+
confirm(message: string): Promise<boolean>;
|
|
34
|
+
/** Applies the installed @basaltkit/cli upgrade codemods. Default: {@link runInstalledCodemods}. */
|
|
35
|
+
codemods?(dir: string, out: Out, colors: Colors): Promise<void>;
|
|
36
|
+
nodeVersion?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare const PROJECT_USAGE = "Project commands \u2014 run inside an existing Basalt app:\n\n create-basalt update [options] Update dependencies to their latest versions\n create-basalt add <feature> Add ui | cli | mcp to the app\n create-basalt doctor Check the project's health (read-only)\n create-basalt info Versions summary for bug reports\n\nIn a scaffolded app: pnpm basalt <command>. Each command has --help.\n";
|
|
39
|
+
export declare const COMMAND_USAGE: Record<ProjectCommand, string>;
|
|
40
|
+
/** `pnpm basalt <args>` in that manager's spelling (npm needs `--` before flags). */
|
|
41
|
+
export declare const basaltCommand: (pm: PackageManager, args: string) => string;
|
|
42
|
+
/** Runs one project command; returns the process exit code. */
|
|
43
|
+
export declare function runProjectCommand(argv: readonly string[], deps: ProjectCommandDeps): Promise<number>;
|
|
44
|
+
export declare function renderAddPlan(plan: AddPlan, colors: Colors): string[];
|
|
45
|
+
/**
|
|
46
|
+
* Runs the upgrade codemods of the app's INSTALLED @basaltkit/cli (the version
|
|
47
|
+
* just installed knows the migrations for itself) without booting the app.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runInstalledCodemods(dir: string, out: Out, colors: Colors): Promise<void>;
|
|
50
|
+
/** An @basaltkit/cli UpgradeFs rooted at `dir` (relative paths resolve against it, not process.cwd()). */
|
|
51
|
+
export declare function projectUpgradeFs(dir: string): {
|
|
52
|
+
list(root: string): Promise<string[]>;
|
|
53
|
+
read(path: string): Promise<string>;
|
|
54
|
+
write(path: string, content: string): Promise<void>;
|
|
55
|
+
};
|