create-basalt 1.8.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 +78 -42
- package/dist/versions.js +15 -14
- package/package.json +20 -20
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output for the project commands: an injectable sink (tests capture it) and
|
|
3
|
+
* ANSI colors that switch off for `NO_COLOR`, `TERM=dumb` and non-TTY output
|
|
4
|
+
* (`FORCE_COLOR` forces them on).
|
|
5
|
+
*/
|
|
6
|
+
export function colorsEnabled(env, isTTY) {
|
|
7
|
+
if (env['NO_COLOR'] !== undefined && env['NO_COLOR'] !== '')
|
|
8
|
+
return false;
|
|
9
|
+
const force = env['FORCE_COLOR'];
|
|
10
|
+
if (force !== undefined)
|
|
11
|
+
return force !== '0' && force !== 'false';
|
|
12
|
+
if (env['TERM'] === 'dumb')
|
|
13
|
+
return false;
|
|
14
|
+
return isTTY;
|
|
15
|
+
}
|
|
16
|
+
export function makeColors(enabled) {
|
|
17
|
+
const wrap = (open, close) => (text) => enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text;
|
|
18
|
+
return {
|
|
19
|
+
enabled,
|
|
20
|
+
bold: wrap(1, 22),
|
|
21
|
+
dim: wrap(2, 22),
|
|
22
|
+
red: wrap(31, 39),
|
|
23
|
+
green: wrap(32, 39),
|
|
24
|
+
yellow: wrap(33, 39),
|
|
25
|
+
cyan: wrap(36, 39),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Visible width of a string (ANSI escapes excluded). */
|
|
29
|
+
// eslint-disable-next-line no-control-regex
|
|
30
|
+
const visible = (text) => text.replace(/\x1b\[\d+m/g, '').length;
|
|
31
|
+
/** A plain left-aligned table (header row + rows), two spaces between columns. */
|
|
32
|
+
export function table(header, rows, colors) {
|
|
33
|
+
const widths = header.map((cell, i) => Math.max(visible(cell), ...rows.map((row) => visible(row[i] ?? ''))));
|
|
34
|
+
const line = (cells) => cells
|
|
35
|
+
.map((cell, i) => (i === cells.length - 1 ? cell : cell + ' '.repeat((widths[i] ?? 0) - visible(cell))))
|
|
36
|
+
.join(' ')
|
|
37
|
+
.trimEnd();
|
|
38
|
+
return [colors.dim(line(header)), ...rows.map(line)];
|
|
39
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type ResolveLatestOptions } from '../latest-versions.js';
|
|
2
|
+
import type { ProjectContext } from './context.js';
|
|
3
|
+
import { type DependencySection } from './package-json.js';
|
|
4
|
+
import { type Colors } from './term.js';
|
|
5
|
+
import { type UpdateKind } from './semver.js';
|
|
6
|
+
/**
|
|
7
|
+
* `create-basalt update`: move an existing app's dependencies to the latest
|
|
8
|
+
* published versions under the SAME registry policy as a new scaffold
|
|
9
|
+
* (`lookupLatestVersions`, shared with latest-versions.ts):
|
|
10
|
+
*
|
|
11
|
+
* - `@basaltkit/*` and `create-basalt` → the registry's `latest`, across majors
|
|
12
|
+
* (each framework major is flagged with a pointer to its changelog);
|
|
13
|
+
* - third-party → the newest release on the app's CURRENT major; a newer major
|
|
14
|
+
* is reported and only taken with `--major`;
|
|
15
|
+
* - a third-party version not provably older than the release-age window
|
|
16
|
+
* (pnpm `minimumReleaseAge`) keeps the current range — the install would
|
|
17
|
+
* otherwise refuse it.
|
|
18
|
+
*
|
|
19
|
+
* Planning is pure (no writes); {@link UpdatePlan.files} holds the full
|
|
20
|
+
* before/after of every file the plan would write.
|
|
21
|
+
*/
|
|
22
|
+
export declare const DOCS_UPGRADING_URL = "https://basaltkit-docs.pages.dev/guide/whats-new#upgrading";
|
|
23
|
+
/** The CHANGELOG of a framework package in the Basalt repository. */
|
|
24
|
+
export declare function changelogUrl(name: string): string;
|
|
25
|
+
export type EntryStatus =
|
|
26
|
+
/** Will be written. */
|
|
27
|
+
'update'
|
|
28
|
+
/** Already at latest. */
|
|
29
|
+
| 'current'
|
|
30
|
+
/** Third-party latest is a new major (needs --major). */
|
|
31
|
+
| 'held-major'
|
|
32
|
+
/** Third-party latest is inside the release-age window (or its age is unknown). */
|
|
33
|
+
| 'too-fresh'
|
|
34
|
+
/** The registry did not answer. */
|
|
35
|
+
| 'failed'
|
|
36
|
+
/** A range `update` does not rewrite (workspace:, tags, git, >=, …). */
|
|
37
|
+
| 'unmanaged'
|
|
38
|
+
/** The app is already ahead of `latest` (a pre-release, a local build). */
|
|
39
|
+
| 'ahead'
|
|
40
|
+
/** Filtered out by --only. */
|
|
41
|
+
| 'skipped';
|
|
42
|
+
export interface UpdateEntry {
|
|
43
|
+
file: string;
|
|
44
|
+
section: DependencySection;
|
|
45
|
+
name: string;
|
|
46
|
+
current: string;
|
|
47
|
+
latest?: string;
|
|
48
|
+
/** New range (same style as `current`) when status is `update`. */
|
|
49
|
+
target?: string;
|
|
50
|
+
kind?: UpdateKind;
|
|
51
|
+
status: EntryStatus;
|
|
52
|
+
framework: boolean;
|
|
53
|
+
/** For `too-fresh`: proven recent, or age unknown. */
|
|
54
|
+
freshness?: 'recent' | 'unknown';
|
|
55
|
+
}
|
|
56
|
+
export interface FileWrite {
|
|
57
|
+
before: string | undefined;
|
|
58
|
+
after: string;
|
|
59
|
+
}
|
|
60
|
+
export interface UpdatePlan {
|
|
61
|
+
entries: UpdateEntry[];
|
|
62
|
+
/** Entries that change (status `update`). */
|
|
63
|
+
updates: UpdateEntry[];
|
|
64
|
+
/** Every file this plan writes, keyed by project-relative path. */
|
|
65
|
+
files: Record<string, FileWrite>;
|
|
66
|
+
/** Project tooling the plan also upgrades (bin/basalt.ts, create-basalt devDependency, …). */
|
|
67
|
+
tooling: string[];
|
|
68
|
+
/** Steps the user has to do by hand (printed, never applied). */
|
|
69
|
+
manual: string[];
|
|
70
|
+
/** Peer-range conflicts and registry failures worth reading before applying. */
|
|
71
|
+
warnings: string[];
|
|
72
|
+
/** Framework packages crossing a major: name → changelog URL. */
|
|
73
|
+
majors: {
|
|
74
|
+
name: string;
|
|
75
|
+
from: string;
|
|
76
|
+
to: string;
|
|
77
|
+
changelog: string;
|
|
78
|
+
}[];
|
|
79
|
+
registry: string;
|
|
80
|
+
}
|
|
81
|
+
export interface UpdateOptions {
|
|
82
|
+
/** Allow third-party packages to cross a major. */
|
|
83
|
+
major?: boolean;
|
|
84
|
+
/** `@basaltkit`: only the framework packages (and create-basalt). */
|
|
85
|
+
only?: 'basaltkit';
|
|
86
|
+
/** Also upgrade project tooling (default true). */
|
|
87
|
+
tooling?: boolean;
|
|
88
|
+
registry?: ResolveLatestOptions;
|
|
89
|
+
}
|
|
90
|
+
export declare class RegistryUnavailableError extends Error {
|
|
91
|
+
constructor(registry: string);
|
|
92
|
+
}
|
|
93
|
+
/** `minimumReleaseAge: <n>` from pnpm-workspace.yaml, when set. */
|
|
94
|
+
export declare function workspaceReleaseAge(yaml: string | undefined): number | undefined;
|
|
95
|
+
/** Whether pnpm-workspace.yaml's minimumReleaseAgeExclude lists `entry`. */
|
|
96
|
+
export declare function workspaceExcludes(yaml: string | undefined, entry: string): boolean;
|
|
97
|
+
/** Adds `entry` to minimumReleaseAgeExclude right after the `@basaltkit/*` line; undefined when that line is absent. */
|
|
98
|
+
export declare function addWorkspaceExclude(yaml: string, entry: string): string | undefined;
|
|
99
|
+
export declare function planUpdate(ctx: ProjectContext, options?: UpdateOptions): Promise<UpdatePlan>;
|
|
100
|
+
/** The plan as terminal lines: the update table, then notes, tooling, warnings and majors. */
|
|
101
|
+
export declare function renderUpdatePlan(plan: UpdatePlan, colors: Colors): string[];
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { binStatus, MANUAL_BIN_SNIPPET, patchBasaltBin } from '../bin-template.js';
|
|
4
|
+
import { alwaysLatest, lookupLatestVersions, minimumReleaseAgeMinutes, } from '../latest-versions.js';
|
|
5
|
+
import { BASALT_PROJECT_SCRIPT } from '../templates.js';
|
|
6
|
+
import { extendManifest, MANIFEST_PATH, serializeManifest } from './manifest.js';
|
|
7
|
+
import { DEPENDENCY_SECTIONS, mergePackageJson, setDependencyRange, } from './package-json.js';
|
|
8
|
+
import { table } from './term.js';
|
|
9
|
+
import { compareVersions, parseSimpleRange, parseVersion, satisfies, updateKind } from './semver.js';
|
|
10
|
+
/**
|
|
11
|
+
* `create-basalt update`: move an existing app's dependencies to the latest
|
|
12
|
+
* published versions under the SAME registry policy as a new scaffold
|
|
13
|
+
* (`lookupLatestVersions`, shared with latest-versions.ts):
|
|
14
|
+
*
|
|
15
|
+
* - `@basaltkit/*` and `create-basalt` → the registry's `latest`, across majors
|
|
16
|
+
* (each framework major is flagged with a pointer to its changelog);
|
|
17
|
+
* - third-party → the newest release on the app's CURRENT major; a newer major
|
|
18
|
+
* is reported and only taken with `--major`;
|
|
19
|
+
* - a third-party version not provably older than the release-age window
|
|
20
|
+
* (pnpm `minimumReleaseAge`) keeps the current range — the install would
|
|
21
|
+
* otherwise refuse it.
|
|
22
|
+
*
|
|
23
|
+
* Planning is pure (no writes); {@link UpdatePlan.files} holds the full
|
|
24
|
+
* before/after of every file the plan would write.
|
|
25
|
+
*/
|
|
26
|
+
export const DOCS_UPGRADING_URL = 'https://basaltkit-docs.pages.dev/guide/whats-new#upgrading';
|
|
27
|
+
const REPO_URL = 'https://github.com/basaltkit/basalt';
|
|
28
|
+
/** The CHANGELOG of a framework package in the Basalt repository. */
|
|
29
|
+
export function changelogUrl(name) {
|
|
30
|
+
const dir = name === 'create-basalt' ? 'create-app' : name.replace(/^@basaltkit\//, '');
|
|
31
|
+
return `${REPO_URL}/blob/main/packages/${dir}/CHANGELOG.md`;
|
|
32
|
+
}
|
|
33
|
+
export class RegistryUnavailableError extends Error {
|
|
34
|
+
constructor(registry) {
|
|
35
|
+
super(`Could not reach ${registry} for any package — update needs the npm registry. Check your connection (or npm_config_registry) and retry.`);
|
|
36
|
+
this.name = 'RegistryUnavailableError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const isFramework = (name) => alwaysLatest(name);
|
|
40
|
+
/** `minimumReleaseAge: <n>` from pnpm-workspace.yaml, when set. */
|
|
41
|
+
export function workspaceReleaseAge(yaml) {
|
|
42
|
+
const match = yaml ? /^minimumReleaseAge:\s*(\d+)\s*$/m.exec(yaml) : null;
|
|
43
|
+
return match ? Number(match[1]) : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Whether pnpm-workspace.yaml's minimumReleaseAgeExclude lists `entry`. */
|
|
46
|
+
export function workspaceExcludes(yaml, entry) {
|
|
47
|
+
if (!yaml)
|
|
48
|
+
return false;
|
|
49
|
+
const block = /^minimumReleaseAgeExclude:\s*\n((?:[ \t]+-.*\n?|[ \t]*#.*\n?)*)/m.exec(yaml);
|
|
50
|
+
if (!block)
|
|
51
|
+
return false;
|
|
52
|
+
return (block[1] ?? '')
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((line) => /^\s*-\s*['"]?([^'"#\s]+)['"]?/.exec(line)?.[1])
|
|
55
|
+
.includes(entry);
|
|
56
|
+
}
|
|
57
|
+
/** Adds `entry` to minimumReleaseAgeExclude right after the `@basaltkit/*` line; undefined when that line is absent. */
|
|
58
|
+
export function addWorkspaceExclude(yaml, entry) {
|
|
59
|
+
const line = /^([ \t]+)-[ \t]*['"]?@basaltkit\/\*['"]?[ \t]*$/m.exec(yaml);
|
|
60
|
+
if (!line)
|
|
61
|
+
return undefined;
|
|
62
|
+
const end = line.index + line[0].length;
|
|
63
|
+
return `${yaml.slice(0, end)}\n${line[1]}- ${entry}${yaml.slice(end)}`;
|
|
64
|
+
}
|
|
65
|
+
async function readOptional(path) {
|
|
66
|
+
try {
|
|
67
|
+
return await readFile(path, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function planUpdate(ctx, options = {}) {
|
|
74
|
+
const manifests = [{ file: 'package.json', text: ctx.packageJsonText, json: ctx.packageJson }];
|
|
75
|
+
if (ctx.web)
|
|
76
|
+
manifests.push({ file: 'web/package.json', text: ctx.web.text, json: ctx.web.json });
|
|
77
|
+
const workspaceYaml = await readOptional(join(ctx.dir, 'pnpm-workspace.yaml'));
|
|
78
|
+
const tooling = options.tooling !== false;
|
|
79
|
+
// Every declared dependency, per file and section.
|
|
80
|
+
const entries = [];
|
|
81
|
+
for (const manifest of manifests) {
|
|
82
|
+
for (const section of DEPENDENCY_SECTIONS) {
|
|
83
|
+
for (const [name, current] of Object.entries(manifest.json[section] ?? {})) {
|
|
84
|
+
const framework = isFramework(name);
|
|
85
|
+
const status = options.only === 'basaltkit' && !framework ? 'skipped' : parseSimpleRange(current) ? 'update' : 'unmanaged';
|
|
86
|
+
entries.push({ file: manifest.file, section, name, current, status, framework });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const rootHasCreateBasalt = entries.some((entry) => entry.file === 'package.json' && entry.name === 'create-basalt');
|
|
91
|
+
// Tooling: an app without create-basalt as a devDependency gets one, so
|
|
92
|
+
// `pnpm basalt update` resolves locally next time.
|
|
93
|
+
const addCreateBasalt = tooling && !rootHasCreateBasalt;
|
|
94
|
+
const managed = entries.filter((entry) => entry.status === 'update');
|
|
95
|
+
const names = [...new Set([...managed.map((entry) => entry.name), ...(addCreateBasalt ? ['create-basalt'] : [])])].sort();
|
|
96
|
+
// The release-age window and who is exempt from it: the project's own pnpm
|
|
97
|
+
// config decides (non-pnpm managers have no exclude list → nobody exempt).
|
|
98
|
+
const window = minimumReleaseAgeMinutes(options.registry?.minimumReleaseAge ?? workspaceReleaseAge(workspaceYaml));
|
|
99
|
+
const exempt = (name) => ctx.pm === 'pnpm' &&
|
|
100
|
+
((name.startsWith('@basaltkit/') && workspaceExcludes(workspaceYaml, '@basaltkit/*')) ||
|
|
101
|
+
(name === 'create-basalt' &&
|
|
102
|
+
(workspaceExcludes(workspaceYaml, 'create-basalt') ||
|
|
103
|
+
(tooling && workspaceYaml !== undefined && addWorkspaceExclude(workspaceYaml, 'create-basalt') !== undefined))));
|
|
104
|
+
const majorsOf = new Map();
|
|
105
|
+
for (const entry of managed) {
|
|
106
|
+
const range = parseSimpleRange(entry.current);
|
|
107
|
+
if (!range)
|
|
108
|
+
continue;
|
|
109
|
+
const set = majorsOf.get(entry.name) ?? new Set();
|
|
110
|
+
set.add(range.parsed[0]);
|
|
111
|
+
majorsOf.set(entry.name, set);
|
|
112
|
+
}
|
|
113
|
+
/** Whether `latest` would be taken for `name` from a range on `major`. */
|
|
114
|
+
const acceptsMajor = (name, major, latest) => isFramework(name) || options.major === true || Number(latest.split('.')[0]) === major;
|
|
115
|
+
const { results, registry } = await lookupLatestVersions(names, (name, latest) => !exempt(name) && (majorsOf.get(name) === undefined || [...(majorsOf.get(name) ?? [])].some((major) => acceptsMajor(name, major, latest))), { ...options.registry, minimumReleaseAge: window });
|
|
116
|
+
if (names.length > 0 && names.every((name) => results.get(name)?.latest === undefined)) {
|
|
117
|
+
throw new RegistryUnavailableError(registry);
|
|
118
|
+
}
|
|
119
|
+
for (const entry of managed) {
|
|
120
|
+
const info = results.get(entry.name);
|
|
121
|
+
const range = parseSimpleRange(entry.current);
|
|
122
|
+
if (!range)
|
|
123
|
+
continue;
|
|
124
|
+
if (info.latest === undefined) {
|
|
125
|
+
entry.status = 'failed';
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
entry.latest = info.latest;
|
|
129
|
+
const latest = parseVersion(info.latest);
|
|
130
|
+
if (!latest) {
|
|
131
|
+
entry.status = 'failed';
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const order = compareVersions(latest, range.parsed);
|
|
135
|
+
if (order === 0) {
|
|
136
|
+
entry.status = 'current';
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (order < 0) {
|
|
140
|
+
entry.status = 'ahead';
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
entry.kind = updateKind(range.parsed, latest);
|
|
144
|
+
if (!acceptsMajor(entry.name, range.parsed[0], info.latest)) {
|
|
145
|
+
entry.status = 'held-major';
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (info.age !== 'mature') {
|
|
149
|
+
entry.status = 'too-fresh';
|
|
150
|
+
entry.freshness = info.age;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
entry.status = 'update';
|
|
154
|
+
entry.target = `${range.style}${info.latest}`;
|
|
155
|
+
}
|
|
156
|
+
const updates = entries.filter((entry) => entry.status === 'update' && entry.target !== undefined);
|
|
157
|
+
const files = {};
|
|
158
|
+
for (const manifest of manifests) {
|
|
159
|
+
let text = manifest.text;
|
|
160
|
+
for (const entry of updates.filter((candidate) => candidate.file === manifest.file)) {
|
|
161
|
+
text = setDependencyRange(text, entry.section, entry.name, entry.target);
|
|
162
|
+
}
|
|
163
|
+
if (text !== manifest.text)
|
|
164
|
+
files[manifest.file] = { before: manifest.text, after: text };
|
|
165
|
+
}
|
|
166
|
+
const toolingNotes = [];
|
|
167
|
+
const manual = [];
|
|
168
|
+
const warnings = [];
|
|
169
|
+
if (tooling) {
|
|
170
|
+
// create-basalt devDependency + the `basalt` script that reaches it.
|
|
171
|
+
const createBasaltLatest = results.get('create-basalt');
|
|
172
|
+
const rootText = files['package.json']?.after ?? ctx.packageJsonText;
|
|
173
|
+
if (addCreateBasalt && createBasaltLatest?.latest !== undefined) {
|
|
174
|
+
const fresh = createBasaltLatest.age !== 'mature';
|
|
175
|
+
if (fresh) {
|
|
176
|
+
warnings.push(`create-basalt ${createBasaltLatest.latest} is inside the release-age window — not added as a devDependency this time.`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const hasBin = ctx.options.cli;
|
|
180
|
+
const merged = mergePackageJson(rootText, {
|
|
181
|
+
devDependencies: { 'create-basalt': `^${createBasaltLatest.latest}` },
|
|
182
|
+
scripts: { basalt: hasBin ? 'tsx bin/basalt.ts' : BASALT_PROJECT_SCRIPT },
|
|
183
|
+
});
|
|
184
|
+
if (merged.changes.length > 0) {
|
|
185
|
+
files['package.json'] = { before: ctx.packageJsonText, after: merged.text };
|
|
186
|
+
toolingNotes.push(`package.json: create-basalt ^${createBasaltLatest.latest} as a devDependency${merged.changes.some((change) => change.startsWith('scripts:')) ? ' + a "basalt" script' : ''} — so \`${ctx.pm === 'npm' ? 'npm run' : ctx.pm} basalt update\` works from now on`);
|
|
187
|
+
}
|
|
188
|
+
if (workspaceYaml !== undefined && !workspaceExcludes(workspaceYaml, 'create-basalt')) {
|
|
189
|
+
const patched = addWorkspaceExclude(workspaceYaml, 'create-basalt');
|
|
190
|
+
if (patched !== undefined) {
|
|
191
|
+
files['pnpm-workspace.yaml'] = { before: workspaceYaml, after: patched };
|
|
192
|
+
toolingNotes.push('pnpm-workspace.yaml: create-basalt joins minimumReleaseAgeExclude (like @basaltkit/*)');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// bin/basalt.ts from an earlier template: make it delegate the project commands.
|
|
198
|
+
const bin = await readOptional(join(ctx.dir, 'bin', 'basalt.ts'));
|
|
199
|
+
const status = binStatus(bin, ctx.manifest?.files['bin/basalt.ts']);
|
|
200
|
+
if (status === 'patchable' && bin !== undefined) {
|
|
201
|
+
const after = patchBasaltBin(bin);
|
|
202
|
+
files['bin/basalt.ts'] = { before: bin, after };
|
|
203
|
+
toolingNotes.push('bin/basalt.ts: unmodified template from an earlier release — gains update/add/doctor/info');
|
|
204
|
+
if (ctx.manifest) {
|
|
205
|
+
files[MANIFEST_PATH] = {
|
|
206
|
+
before: serializeManifest(ctx.manifest),
|
|
207
|
+
after: serializeManifest(extendManifest(ctx.manifest, ctx.options, { 'bin/basalt.ts': after })),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (status === 'modified') {
|
|
212
|
+
manual.push(`bin/basalt.ts was customised, so it was not patched. To give it the project commands:\n${MANUAL_BIN_SNIPPET}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Peer ranges of the framework versions we end up on vs the app's resulting ranges.
|
|
216
|
+
const resulting = new Map();
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (entry.file !== 'package.json' && resulting.has(entry.name))
|
|
219
|
+
continue;
|
|
220
|
+
resulting.set(entry.name, entry.target ?? entry.current);
|
|
221
|
+
}
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (!entry.framework || (entry.status !== 'update' && entry.status !== 'current') || entry.file !== 'package.json')
|
|
224
|
+
continue;
|
|
225
|
+
const info = results.get(entry.name);
|
|
226
|
+
for (const [peer, wanted] of Object.entries(info?.peers ?? {})) {
|
|
227
|
+
const range = resulting.get(peer);
|
|
228
|
+
const floor = range ? parseSimpleRange(range) : undefined;
|
|
229
|
+
if (!floor)
|
|
230
|
+
continue;
|
|
231
|
+
if (satisfies(floor.version, wanted) === false) {
|
|
232
|
+
const peerEntry = entries.find((candidate) => candidate.name === peer);
|
|
233
|
+
const hint = peerEntry?.status === 'held-major' ? ' — re-run with --major' : '';
|
|
234
|
+
warnings.push(`${entry.name}@${info?.latest} expects ${peer} ${wanted}, but the app will have ${range}${hint}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const failed = entries.filter((entry) => entry.status === 'failed').map((entry) => entry.name);
|
|
239
|
+
if (failed.length > 0)
|
|
240
|
+
warnings.push(`The registry did not answer for ${[...new Set(failed)].join(', ')} — left unchanged.`);
|
|
241
|
+
const majors = updates
|
|
242
|
+
.filter((entry) => entry.framework && entry.kind === 'major')
|
|
243
|
+
.map((entry) => ({
|
|
244
|
+
name: entry.name,
|
|
245
|
+
from: parseSimpleRange(entry.current)?.version ?? entry.current,
|
|
246
|
+
to: entry.latest,
|
|
247
|
+
changelog: changelogUrl(entry.name),
|
|
248
|
+
}))
|
|
249
|
+
.filter((major, i, all) => all.findIndex((other) => other.name === major.name) === i);
|
|
250
|
+
return { entries, updates, files, tooling: toolingNotes, manual, warnings, majors, registry };
|
|
251
|
+
}
|
|
252
|
+
/** The plan as terminal lines: the update table, then notes, tooling, warnings and majors. */
|
|
253
|
+
export function renderUpdatePlan(plan, colors) {
|
|
254
|
+
const lines = [];
|
|
255
|
+
const kindLabel = (kind) => kind === 'major' ? colors.red(colors.bold('MAJOR')) : kind === 'minor' ? colors.yellow('minor') : colors.green('patch');
|
|
256
|
+
const multiFile = new Set(plan.entries.map((entry) => entry.file)).size > 1;
|
|
257
|
+
if (plan.updates.length === 0) {
|
|
258
|
+
lines.push(colors.green('Every dependency is already on its latest allowed version.'));
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
lines.push(colors.bold(`${plan.updates.length} update(s):`), '');
|
|
262
|
+
lines.push(...table(['package', ...(multiFile ? ['in'] : []), 'current', '', 'target', 'kind'], plan.updates.map((entry) => [
|
|
263
|
+
entry.framework ? colors.cyan(entry.name) : entry.name,
|
|
264
|
+
...(multiFile ? [entry.file === 'package.json' ? '.' : entry.file.replace(/\/package\.json$/, '/')] : []),
|
|
265
|
+
entry.current,
|
|
266
|
+
'→',
|
|
267
|
+
colors.bold(entry.target),
|
|
268
|
+
kindLabel(entry.kind),
|
|
269
|
+
]), colors).map((line) => ` ${line}`));
|
|
270
|
+
}
|
|
271
|
+
const held = plan.entries.filter((entry) => entry.status === 'held-major');
|
|
272
|
+
const fresh = plan.entries.filter((entry) => entry.status === 'too-fresh');
|
|
273
|
+
if (held.length > 0 || fresh.length > 0)
|
|
274
|
+
lines.push('');
|
|
275
|
+
for (const entry of dedupe(held)) {
|
|
276
|
+
lines.push(colors.dim(` kept ${entry.name} ${entry.current}: ${entry.latest} is a new major — pass --major to take it`));
|
|
277
|
+
}
|
|
278
|
+
for (const entry of dedupe(fresh)) {
|
|
279
|
+
lines.push(colors.dim(entry.freshness === 'recent'
|
|
280
|
+
? ` kept ${entry.name} ${entry.current}: ${entry.latest} is inside the release-age window (minimumReleaseAge) — retry later`
|
|
281
|
+
: ` kept ${entry.name} ${entry.current}: could not prove ${entry.latest} is older than the release-age window`));
|
|
282
|
+
}
|
|
283
|
+
if (plan.majors.length > 0) {
|
|
284
|
+
lines.push('', colors.bold('Framework majors — read before applying:'));
|
|
285
|
+
for (const major of plan.majors)
|
|
286
|
+
lines.push(` ${major.name} ${major.from} → ${major.to}: ${major.changelog}`);
|
|
287
|
+
lines.push(` Upgrade notes: ${DOCS_UPGRADING_URL}`);
|
|
288
|
+
}
|
|
289
|
+
if (plan.tooling.length > 0) {
|
|
290
|
+
lines.push('', colors.bold('Project tooling:'));
|
|
291
|
+
for (const note of plan.tooling)
|
|
292
|
+
lines.push(` ${note}`);
|
|
293
|
+
}
|
|
294
|
+
for (const warning of plan.warnings)
|
|
295
|
+
lines.push('', colors.yellow(`Warning: ${warning}`));
|
|
296
|
+
for (const step of plan.manual)
|
|
297
|
+
lines.push('', colors.yellow('Manual step:'), step);
|
|
298
|
+
return lines;
|
|
299
|
+
}
|
|
300
|
+
function dedupe(entries) {
|
|
301
|
+
return entries.filter((entry, i) => entries.findIndex((other) => other.name === entry.name && other.current === entry.current) === i);
|
|
302
|
+
}
|
package/dist/templates.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface ProjectOptions {
|
|
|
16
16
|
*/
|
|
17
17
|
prisma: boolean;
|
|
18
18
|
}
|
|
19
|
+
export { basaltBin } from './bin-template.js';
|
|
19
20
|
export { SCAFFOLD_VERSIONS } from './versions.js';
|
|
20
21
|
export { THIRD_PARTY_VERSIONS } from './latest-versions.js';
|
|
21
22
|
/**
|
|
@@ -32,6 +33,10 @@ export declare const versionOf: (pkg: string) => string;
|
|
|
32
33
|
* unknown package so every emitted range lives in that single table.
|
|
33
34
|
*/
|
|
34
35
|
export declare const thirdPartyVersionOf: (pkg: string) => string;
|
|
36
|
+
/** The root `basalt` script of an app without `--cli`: create-basalt's project commands. */
|
|
37
|
+
export declare const BASALT_PROJECT_SCRIPT = "create-basalt --project";
|
|
38
|
+
/** The root `dev:web` script of a `--ui` app (web/ is a pnpm workspace member). */
|
|
39
|
+
export declare const webDevScript: (options: Pick<ProjectOptions, 'name'>) => string;
|
|
35
40
|
/** True when the scaffold binds users to the tenant they act on (tenancy + auth). */
|
|
36
41
|
export declare const enforcesMembership: (options: ProjectOptions) => boolean;
|
|
37
42
|
export declare function packageJson(options: ProjectOptions): string;
|
|
@@ -56,7 +61,6 @@ export declare function serverTs(): string;
|
|
|
56
61
|
* NODE_ENV counts as production and secrets are required (fail-closed).
|
|
57
62
|
*/
|
|
58
63
|
export declare function devTs(): string;
|
|
59
|
-
export declare function basaltBin(): string;
|
|
60
64
|
/**
|
|
61
65
|
* `prisma/schema.prisma` — the datasource, the client generator, the models of
|
|
62
66
|
* every Basalt domain this app enables, and the app's own first model.
|
|
@@ -87,6 +91,12 @@ export declare function dbTs(options: ProjectOptions): string;
|
|
|
87
91
|
export declare function prismaSeedTs(options: ProjectOptions): string;
|
|
88
92
|
export declare function appTest(options: ProjectOptions): string;
|
|
89
93
|
export declare function readme(options: ProjectOptions): string;
|
|
94
|
+
/** README section for the \`basalt\` CLI (also appended by \`create-basalt add cli\`). */
|
|
95
|
+
export declare function readmeCliSection(_options: ProjectOptions): string;
|
|
96
|
+
/** README section for the web/ frontend (also appended by \`create-basalt add ui\`). */
|
|
97
|
+
export declare function readmeUiSection(options: ProjectOptions): string;
|
|
98
|
+
/** README section for the MCP server (also appended by \`create-basalt add mcp\`). */
|
|
99
|
+
export declare function readmeMcpSection(_options: ProjectOptions): string;
|
|
90
100
|
/**
|
|
91
101
|
* A Claude Code / generic MCP client config that registers the dev-only
|
|
92
102
|
* `basalt-ai-mcp` bridge for this project. Claude Code reads `.mcp.json` at the
|