create-pkgbld 1.8.2 → 2.0.1

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.
@@ -0,0 +1,345 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import prompts from 'prompts';
6
+
7
+ import { blue, gray, green, red, white, yellow } from '@niceties/ansi';
8
+ import { parseArgsPlus } from '@niceties/node-parseargs-plus';
9
+ import { help } from '@niceties/node-parseargs-plus/help';
10
+ import { parameters } from '@niceties/node-parseargs-plus/parameters';
11
+
12
+ import { formatConflicts } from './conflicts.js';
13
+ import { renderChanges } from './diff.js';
14
+ import { changesAffectDependencies, detectPackageManager, runInstall } from './install.js';
15
+ import { openPackageOperations, PackageOperationError } from './package-operations.js';
16
+ import { resolveInstalledPackage } from './package-resolution.js';
17
+ import { ProjectChanges } from './project-changes.js';
18
+ import { loadRegistry } from './registry.js';
19
+
20
+ /**
21
+ * @typedef {import('./types.js').OptionsValue} OptionsValue
22
+ */
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const builtinRegistryPath = path.resolve(__dirname, '..', 'extensions.json');
26
+
27
+ const commonOptions = {
28
+ quiet: { type: /** @type {'boolean'} */ ('boolean'), short: 'q', description: 'Quiet mode', default: false },
29
+ yes: { type: /** @type {'boolean'} */ ('boolean'), short: 'y', description: 'Skip prompts, use defaults', default: false },
30
+ 'dry-run': { type: /** @type {'boolean'} */ ('boolean'), description: 'Print changes without writing', default: false },
31
+ install: {
32
+ type: /** @type {'boolean'} */ ('boolean'),
33
+ description: 'Run package manager install after committing dependency changes',
34
+ default: false,
35
+ },
36
+ };
37
+
38
+ /**
39
+ * @param {string} version
40
+ * @param {string[]} argv
41
+ */
42
+ export async function runList(version, argv) {
43
+ const args = parseArgsPlus({ name: 'create-pkgbld list', version, options: commonOptions, args: argv }, [help, parameters]);
44
+ const quiet = Boolean(args.values.quiet);
45
+ const projectRoot = process.cwd();
46
+ if (!quiet) console.log(`create-pkgbld v${version}\n`);
47
+
48
+ const packages = await openPackageOperations({ projectRoot, registry: await loadRegistry(builtinRegistryPath) });
49
+ printWarnings(packages.warnings);
50
+ const items = packages.inventory;
51
+ if (items.length === 0) {
52
+ console.log(gray('No PKG BLD packages found.'));
53
+ return;
54
+ }
55
+ for (const item of items) {
56
+ console.log(`${white(pad16plus(item.name))}${gray(item.description.padEnd(40))} ${formatState(item.state, item.error)}`);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {string} version
62
+ * @param {string[]} argv
63
+ */
64
+ export async function runAdd(version, argv) {
65
+ return runAddOrRemove('add', version, argv);
66
+ }
67
+
68
+ /**
69
+ * @param {string} version
70
+ * @param {string[]} argv
71
+ */
72
+ export async function runRemoveCmd(version, argv) {
73
+ return runAddOrRemove('remove', version, argv);
74
+ }
75
+
76
+ /**
77
+ * @param {string} version
78
+ * @param {string[]} argv
79
+ */
80
+ export async function runUpdate(version, argv) {
81
+ const args = parseArgsPlus(
82
+ {
83
+ name: 'create-pkgbld update',
84
+ version,
85
+ parameters: ['<package>'],
86
+ options: {
87
+ ...commonOptions,
88
+ 'accept-conflicts': {
89
+ type: /** @type {'boolean'} */ ('boolean'),
90
+ description: 'Apply proposed replacements for migration conflicts',
91
+ default: false,
92
+ },
93
+ },
94
+ args: argv,
95
+ },
96
+ [help, parameters]
97
+ );
98
+ const quiet = Boolean(args.values.quiet);
99
+ const yes = Boolean(args.values.yes);
100
+ const dryRun = Boolean(args.values['dry-run']);
101
+ const acceptConflicts = Boolean(args.values['accept-conflicts']);
102
+ const requestedName = /** @type {string} */ (args.parameters.package);
103
+ const projectRoot = process.cwd();
104
+ if (!quiet) console.log(`create-pkgbld v${version}\n`);
105
+
106
+ const packages = await openPackageOperations({ projectRoot, registry: await loadRegistry(builtinRegistryPath) });
107
+ printWarnings(packages.warnings);
108
+ let operation;
109
+ try {
110
+ operation = await packages.prepare({ package: requestedName, target: 'updated' });
111
+ } catch (/** @type {any} */ error) {
112
+ if (error instanceof PackageOperationError) {
113
+ console.error(red(`${error.message}.`));
114
+ process.exitCode = 1;
115
+ return;
116
+ }
117
+ throw error;
118
+ }
119
+ if (operation.effect === 'none') {
120
+ if (!quiet) console.log(gray(`${operation.package.name} is already up to date.`));
121
+ return;
122
+ }
123
+
124
+ const answers = await collectAnswers(operation.questions, yes);
125
+ const project = new ProjectChanges(projectRoot);
126
+ try {
127
+ await operation.stage(project, answers);
128
+ } catch (/** @type {any} */ error) {
129
+ console.error(red(`Cannot update "${operation.package.name}": ${error.message ?? String(error)}`));
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ const { changes, conflicts } = project.review();
134
+ const versions = operation.versionChange;
135
+ if (!quiet) {
136
+ console.log(
137
+ `${gray(`Updating ${operation.package.name}${versions ? ` ${versions.from} -> ${versions.to}` : ''}:`)}${
138
+ dryRun ? ` ${blue('(dry-run)')}` : ''
139
+ }`
140
+ );
141
+ console.log(renderChanges(changes, { readDiskJson: p => readDiskJson(projectRoot, p) }));
142
+ if (conflicts.length > 0) {
143
+ console.log(yellow('\nConflicts detected:'));
144
+ for (const line of formatConflicts(conflicts)) console.log(yellow(line));
145
+ }
146
+ }
147
+ if (dryRun) return;
148
+
149
+ if (conflicts.length > 0 && !acceptConflicts) {
150
+ let approved = false;
151
+ if (!yes && !quiet) {
152
+ const answer = await prompts({
153
+ type: 'confirm',
154
+ name: 'accept',
155
+ message: 'Apply the proposed replacements for these conflicts?',
156
+ initial: false,
157
+ });
158
+ approved = Boolean(answer.accept);
159
+ }
160
+ if (!approved) {
161
+ console.error(red('Update has unapplied conflicts. Review them and re-run with --accept-conflicts.'));
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+ }
166
+
167
+ const beforePkg = readDiskJson(projectRoot, 'package.json');
168
+ if (operation.requiresInstall) {
169
+ await project.commit({ lock: 'exclude' });
170
+ const pm = detectPackageManager(projectRoot);
171
+ if (!quiet) console.log(gray(`\nRunning ${pm} install...`));
172
+ const code = await runInstall(pm, projectRoot);
173
+ if (code !== 0) {
174
+ console.error(red(`${pm} install exited with code ${code}; the PKG BLD lock was not advanced`));
175
+ process.exitCode = code;
176
+ return;
177
+ }
178
+ const installed = resolveInstalledPackage(operation.package.id, projectRoot)?.version;
179
+ if (!versions || installed !== versions.to) {
180
+ console.error(
181
+ red(
182
+ `Installed plugin version ${installed ?? '<unresolved>'} does not match expected ${versions?.to ?? '<unknown>'}; the PKG BLD lock was not advanced`
183
+ )
184
+ );
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ await project.commit({ lock: 'only' });
189
+ } else {
190
+ await project.commit();
191
+ await maybeInstallDependencies({ changes, projectRoot, beforePkg, installFlag: Boolean(args.values.install), quiet, yes });
192
+ }
193
+ }
194
+
195
+ /**
196
+ * @param {'add' | 'remove'} mode
197
+ * @param {string} version
198
+ * @param {string[]} argv
199
+ */
200
+ async function runAddOrRemove(mode, version, argv) {
201
+ const args = parseArgsPlus(
202
+ {
203
+ name: `create-pkgbld ${mode}`,
204
+ version,
205
+ parameters: ['<package>'],
206
+ options: commonOptions,
207
+ args: argv,
208
+ },
209
+ [help, parameters]
210
+ );
211
+ const quiet = Boolean(args.values.quiet);
212
+ const yes = Boolean(args.values.yes);
213
+ const dryRun = Boolean(args.values['dry-run']);
214
+ const installFlag = Boolean(args.values.install);
215
+ const requestedName = /** @type {string} */ (args.parameters.package);
216
+ const projectRoot = process.cwd();
217
+ if (!quiet) console.log(`create-pkgbld v${version}\n`);
218
+
219
+ const packages = await openPackageOperations({ projectRoot, registry: await loadRegistry(builtinRegistryPath) });
220
+ printWarnings(packages.warnings);
221
+ const target = /** @type {import('./package-operations.js').PackageTarget} */ (mode === 'add' ? 'managed' : 'absent');
222
+ let operation;
223
+ try {
224
+ operation = await packages.prepare({ package: requestedName, target });
225
+ } catch (/** @type {any} */ error) {
226
+ if (error instanceof PackageOperationError) {
227
+ console.error(red(`${error.message}.`));
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+ throw error;
232
+ }
233
+ if (operation.effect === 'none') {
234
+ if (target === 'absent') {
235
+ console.error(red(`PKG BLD package "${requestedName}" is not installed.`));
236
+ process.exitCode = 1;
237
+ } else if (!quiet) {
238
+ console.log(gray(`${operation.package.name} is already managed.`));
239
+ }
240
+ return;
241
+ }
242
+
243
+ const answers = await collectAnswers(operation.questions, yes);
244
+ const project = new ProjectChanges(projectRoot);
245
+ await operation.stage(project, answers);
246
+ const { changes, conflicts } = project.review();
247
+
248
+ if (!quiet) {
249
+ const verb = mode === 'add' ? 'Adding' : 'Removing';
250
+ console.log(`${gray(`${verb} ${operation.package.name}:`)}${dryRun ? ` ${blue('(dry-run)')}` : ''}`);
251
+ console.log(renderChanges(changes, { readDiskJson: p => readDiskJson(projectRoot, p) }));
252
+ if (conflicts.length > 0) {
253
+ console.log(yellow('\nConflicts detected:'));
254
+ for (const line of formatConflicts(conflicts)) console.log(yellow(line));
255
+ }
256
+ }
257
+
258
+ if (dryRun) return;
259
+
260
+ const beforePkg = readDiskJson(projectRoot, 'package.json');
261
+ await project.commit();
262
+
263
+ await maybeInstallDependencies({ changes, projectRoot, beforePkg, installFlag, quiet, yes });
264
+ }
265
+
266
+ /** @param {{ changes: readonly import('./tree.js').FileChange[], projectRoot: string, beforePkg: any, installFlag: boolean, quiet: boolean, yes: boolean }} params */
267
+ async function maybeInstallDependencies({ changes, projectRoot, beforePkg, installFlag, quiet, yes }) {
268
+ if (!changesAffectDependencies(changes, beforePkg)) return;
269
+ const pm = detectPackageManager(projectRoot);
270
+ let shouldInstall = installFlag;
271
+ if (!shouldInstall && !yes && !quiet) {
272
+ const ans = await prompts({ type: 'confirm', name: 'go', message: `Run ${pm} install now?`, initial: false });
273
+ shouldInstall = Boolean(ans.go);
274
+ }
275
+ if (shouldInstall) {
276
+ if (!quiet) console.log(gray(`\nRunning ${pm} install...`));
277
+ const code = await runInstall(pm, projectRoot);
278
+ if (code !== 0) {
279
+ console.error(red(`${pm} install exited with code ${code}`));
280
+ process.exitCode = code;
281
+ }
282
+ } else if (!quiet) {
283
+ console.log(gray(`\nDependencies changed. Run "${pm} install" to apply (or re-run with --install).`));
284
+ }
285
+ }
286
+
287
+ /** @param {readonly string[]} warnings */
288
+ function printWarnings(warnings) {
289
+ for (const warning of warnings) console.warn(yellow(`Warning: ${warning}`));
290
+ }
291
+
292
+ /**
293
+ * @param {string} projectRoot
294
+ * @param {string} relPath
295
+ */
296
+ function readDiskJson(projectRoot, relPath) {
297
+ try {
298
+ return JSON.parse(readFileSync(path.join(projectRoot, relPath), 'utf8'));
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * @param {readonly import('./types.js').Option[]} questions
306
+ * @param {boolean} yes
307
+ * @returns {Promise<OptionsValue>}
308
+ */
309
+ async function collectAnswers(questions, yes) {
310
+ /** @type {OptionsValue} */
311
+ const out = {};
312
+ for (const opt of questions) {
313
+ const initial = 'initialValue' in opt ? opt.initialValue : undefined;
314
+ if (yes) {
315
+ out[opt.field] = /** @type {any} */ (initial);
316
+ continue;
317
+ }
318
+ const answer = await prompts({
319
+ type: /** @type {any} */ (opt.type ?? 'text'),
320
+ name: opt.field,
321
+ message: opt.title,
322
+ initial: /** @type {any} */ (initial),
323
+ });
324
+ out[opt.field] = answer[opt.field];
325
+ }
326
+ return out;
327
+ }
328
+
329
+ /**
330
+ * @param {string} value
331
+ * @param {number} [indent]
332
+ * @param {number} [offset]
333
+ */
334
+ function pad16plus(value, indent = 4, offset = 3) {
335
+ return value + ''.padEnd(offset - Math.floor((value.length + indent) / 8), '\t');
336
+ }
337
+
338
+ /** @param {import('./inventory.js').PackageState} state @param {string | null} error */
339
+ function formatState(state, error) {
340
+ if (state === 'available') return gray('[Available]');
341
+ if (state === 'applied') return blue('[Applied]');
342
+ if (state === 'installed-managed') return green('[Installed, managed]');
343
+ if (state === 'installed-unmanaged') return yellow('[Installed, unmanaged]');
344
+ return red(`[Unavailable${error ? `: ${error}` : ''}]`);
345
+ }
package/src/tree.js ADDED
@@ -0,0 +1,282 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { toFormattedJson } from 'pkgbld/options';
6
+
7
+ import { LOCK_FILE } from './project-lock.js';
8
+
9
+ /**
10
+ * @typedef {'CREATE' | 'UPDATE' | 'DELETE'} ChangeAction
11
+ * @typedef {{ path: string, type: ChangeAction, content?: string }} FileChange
12
+ * @typedef {{ content: string | null, action: ChangeAction | null }} Entry
13
+ */
14
+
15
+ export class Tree {
16
+ /**
17
+ * @param {string} projectRoot
18
+ * @param {{ onMutation?: (mutation: { path: string, before: string | null, after: string | null }) => void }} [options]
19
+ */
20
+ constructor(projectRoot, options = {}) {
21
+ this.projectRoot = projectRoot;
22
+ /** @type {Map<string, Entry>} */
23
+ this.entries = new Map();
24
+ /** @type {string | null} */
25
+ this.extensionBase = null;
26
+ this.onMutation = options.onMutation ?? null;
27
+ }
28
+
29
+ /**
30
+ * Set the base directory used to resolve extension-relative file paths.
31
+ * @param {string | null} dir
32
+ */
33
+ setExtensionBase(dir) {
34
+ this.extensionBase = dir;
35
+ }
36
+
37
+ /**
38
+ * @param {string} p
39
+ */
40
+ _abs(p) {
41
+ const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(this.projectRoot, p);
42
+ const rel = path.relative(this.projectRoot, abs);
43
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
44
+ throw new Error(`Path escapes project root: ${p}`);
45
+ }
46
+ return abs;
47
+ }
48
+
49
+ /**
50
+ * @param {string} p
51
+ */
52
+ _key(p) {
53
+ this._abs(p);
54
+ return path.normalize(p);
55
+ }
56
+
57
+ /**
58
+ * @param {string} p
59
+ */
60
+ _loadFromDisk(p) {
61
+ try {
62
+ return readFileSync(this._abs(p), 'utf8');
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * @param {string} p
70
+ * @returns {string | null}
71
+ */
72
+ read(p) {
73
+ const key = this._key(p);
74
+ const cached = this.entries.get(key);
75
+ if (cached !== undefined) {
76
+ if (cached.action === 'DELETE') return null;
77
+ return cached.content;
78
+ }
79
+ const content = this._loadFromDisk(key);
80
+ this.entries.set(key, { content, action: null });
81
+ return content;
82
+ }
83
+
84
+ /**
85
+ * @param {string} p
86
+ * @param {string} content
87
+ */
88
+ write(p, content) {
89
+ const key = this._key(p);
90
+ const before = this.read(key);
91
+ const existing = /** @type {Entry} */ (this.entries.get(key));
92
+ /** @type {ChangeAction} */
93
+ let action;
94
+ if (existing.action === 'CREATE') {
95
+ action = 'CREATE';
96
+ } else if (existing.action === 'DELETE') {
97
+ action = 'UPDATE';
98
+ } else if (existing.content === null) {
99
+ action = 'CREATE';
100
+ } else {
101
+ action = 'UPDATE';
102
+ }
103
+ this.entries.set(key, { content, action });
104
+ this.onMutation?.({ path: key, before, after: content });
105
+ }
106
+
107
+ /**
108
+ * @param {string} p
109
+ */
110
+ exists(p) {
111
+ return this.read(p) !== null;
112
+ }
113
+
114
+ /**
115
+ * @param {string} p
116
+ */
117
+ delete(p) {
118
+ const key = this._key(p);
119
+ const before = this.read(key);
120
+ if (before === null) return;
121
+ const cached = /** @type {Entry} */ (this.entries.get(key));
122
+ if (cached.action === 'CREATE') this.entries.delete(key);
123
+ else this.entries.set(key, { content: null, action: 'DELETE' });
124
+ this.onMutation?.({ path: key, before, after: null });
125
+ }
126
+
127
+ /**
128
+ * @param {string} oldPath
129
+ * @param {string} newPath
130
+ */
131
+ rename(oldPath, newPath) {
132
+ const content = this.read(oldPath);
133
+ if (content === null) return;
134
+ this.delete(oldPath);
135
+ this.write(newPath, content);
136
+ }
137
+
138
+ /**
139
+ * @param {string} p
140
+ * @returns {any | null}
141
+ */
142
+ readJson(p) {
143
+ const content = this.read(p);
144
+ if (content === null) return null;
145
+ return JSON.parse(content);
146
+ }
147
+
148
+ /**
149
+ * @param {string} p
150
+ * @param {(data: any) => any} updater
151
+ */
152
+ updateJson(p, updater) {
153
+ const current = this.read(p);
154
+ const data = current === null ? {} : JSON.parse(current);
155
+ const updated = updater(data) ?? data;
156
+ const formatted = toFormattedJson(updated, current);
157
+ if (formatted === current) return;
158
+ this.write(p, formatted);
159
+ }
160
+
161
+ /**
162
+ * @param {string} name
163
+ * @param {string} version
164
+ * @param {'dependencies' | 'devDependencies'} [type]
165
+ */
166
+ addDependency(name, version, type = 'dependencies') {
167
+ this.updateJson('package.json', pkg => {
168
+ pkg[type] ??= {};
169
+ pkg[type][name] = version;
170
+ return pkg;
171
+ });
172
+ }
173
+
174
+ /**
175
+ * @param {string} name
176
+ * @param {'dependencies' | 'devDependencies'} [type]
177
+ */
178
+ removeDependency(name, type = 'dependencies') {
179
+ this.updateJson('package.json', pkg => {
180
+ if (pkg[type] && name in pkg[type]) {
181
+ delete pkg[type][name];
182
+ if (Object.keys(pkg[type]).length === 0) delete pkg[type];
183
+ }
184
+ return pkg;
185
+ });
186
+ }
187
+
188
+ /**
189
+ * @param {string} name
190
+ * @param {string} command
191
+ */
192
+ addScript(name, command) {
193
+ this.updateJson('package.json', pkg => {
194
+ pkg.scripts ??= {};
195
+ pkg.scripts[name] = command;
196
+ return pkg;
197
+ });
198
+ }
199
+
200
+ /**
201
+ * @param {string} name
202
+ */
203
+ removeScript(name) {
204
+ this.updateJson('package.json', pkg => {
205
+ if (pkg.scripts && name in pkg.scripts) {
206
+ delete pkg.scripts[name];
207
+ if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts;
208
+ }
209
+ return pkg;
210
+ });
211
+ }
212
+
213
+ /**
214
+ * @param {string} relativePath
215
+ */
216
+ resolveExtensionFile(relativePath) {
217
+ if (!this.extensionBase) {
218
+ throw new Error('Tree.resolveExtensionFile: extension base directory is not set');
219
+ }
220
+ return path.resolve(this.extensionBase, relativePath);
221
+ }
222
+
223
+ /**
224
+ * @returns {FileChange[]}
225
+ */
226
+ listChanges() {
227
+ /** @type {FileChange[]} */
228
+ const result = [];
229
+ for (const [p, entry] of this.entries) {
230
+ if (entry.action === null) continue;
231
+ /** @type {FileChange} */
232
+ const change = { path: p, type: entry.action };
233
+ if (entry.action !== 'DELETE' && entry.content !== null) {
234
+ change.content = entry.content;
235
+ }
236
+ result.push(change);
237
+ }
238
+ result.sort((a, b) => a.path.localeCompare(b.path));
239
+ return result;
240
+ }
241
+
242
+ /** @returns {{ entries: Map<string, Entry>, extensionBase: string | null }} */
243
+ _createCheckpoint() {
244
+ return {
245
+ entries: new Map([...this.entries].map(([key, entry]) => [key, { ...entry }])),
246
+ extensionBase: this.extensionBase,
247
+ };
248
+ }
249
+
250
+ /** @param {{ entries: Map<string, Entry>, extensionBase: string | null }} checkpoint */
251
+ _restoreCheckpoint(checkpoint) {
252
+ this.entries = new Map([...checkpoint.entries].map(([key, entry]) => [key, { ...entry }]));
253
+ this.extensionBase = checkpoint.extensionBase;
254
+ }
255
+
256
+ /** @param {{ lock?: 'include' | 'exclude' | 'only' }} [options] */
257
+ async commit(options = {}) {
258
+ const lockMode = options.lock ?? 'include';
259
+ const changes = this.listChanges().filter(change => {
260
+ if (lockMode === 'exclude') return change.path !== LOCK_FILE;
261
+ if (lockMode === 'only') return change.path === LOCK_FILE;
262
+ return true;
263
+ });
264
+ const orderedChanges = [
265
+ ...changes.filter(change => change.path !== LOCK_FILE),
266
+ ...changes.filter(change => change.path === LOCK_FILE),
267
+ ];
268
+ for (const change of orderedChanges) {
269
+ const abs = this._abs(change.path);
270
+ if (change.type === 'DELETE') {
271
+ try {
272
+ await fs.unlink(abs);
273
+ } catch (/** @type {any} */ err) {
274
+ if (err.code !== 'ENOENT') throw err;
275
+ }
276
+ continue;
277
+ }
278
+ await fs.mkdir(path.dirname(abs), { recursive: true });
279
+ await fs.writeFile(abs, change.content ?? '', 'utf8');
280
+ }
281
+ }
282
+ }