create-pkgbld 1.8.2 → 2.0.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/src/index.js ADDED
@@ -0,0 +1,257 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fsSync from 'node:fs';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { isPackageJson, processPackageJson, toFormattedJson } from 'pkgbld/options';
8
+ import prompts from 'prompts';
9
+
10
+ import { gray, green, red, white, yellow } from '@niceties/ansi';
11
+ import { parseArgsPlus } from '@niceties/node-parseargs-plus';
12
+ import { help } from '@niceties/node-parseargs-plus/help';
13
+ import { parameters } from '@niceties/node-parseargs-plus/parameters';
14
+
15
+ import { formatConflicts } from './conflicts.js';
16
+ import { renderChanges } from './diff.js';
17
+ import getGitRoot from './get-git-root.js';
18
+ import { changesAffectDependencies, detectPackageManager, runInstall } from './install.js';
19
+ import { openPackageOperations } from './package-operations.js';
20
+ import { ProjectChanges } from './project-changes.js';
21
+ import { loadRegistry } from './registry.js';
22
+ import { runAdd, runList, runRemoveCmd, runUpdate } from './subcommands.js';
23
+ import { pad16plus, runInteractiveLoop } from './tui.js';
24
+
25
+ const SUBCOMMANDS = new Set(['add', 'remove', 'update', 'list']);
26
+
27
+ /**
28
+ * @typedef {import('pkgbld/options').PackageJson} PackageJson
29
+ * @typedef {import('./types.js').PkgInfo} PkgInfo
30
+ */
31
+
32
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
+ const builtinRegistryPath = path.resolve(__dirname, '..', 'extensions.json');
34
+
35
+ async function execute() {
36
+ const version = await reportVersion();
37
+
38
+ const sub = process.argv[2];
39
+ if (sub && SUBCOMMANDS.has(sub)) {
40
+ const rest = process.argv.slice(3);
41
+ if (sub === 'list') return runList(version, rest);
42
+ if (sub === 'add') return runAdd(version, rest);
43
+ if (sub === 'remove') return runRemoveCmd(version, rest);
44
+ if (sub === 'update') return runUpdate(version, rest);
45
+ }
46
+
47
+ const args = parseArgsPlus(
48
+ {
49
+ name: 'create-pkgbld',
50
+ version,
51
+ parameters: ['[package name]'],
52
+ options: {
53
+ quiet: {
54
+ type: /** @type {'boolean'} */ ('boolean'),
55
+ description: 'Quiet mode',
56
+ default: false,
57
+ },
58
+ install: {
59
+ type: /** @type {'boolean'} */ ('boolean'),
60
+ description: 'Run package manager install after commit if deps changed',
61
+ default: false,
62
+ },
63
+ },
64
+ },
65
+ [help, parameters]
66
+ );
67
+
68
+ const quiet = Boolean(args.values.quiet);
69
+ const installFlag = Boolean(args.values.install);
70
+
71
+ if (!quiet) console.log(`create-pkgbld v${version}\n`);
72
+
73
+ const targetDir = path.join(process.cwd(), args.parameters.packageName ?? '.');
74
+
75
+ if (!quiet) console.log(gray(pad16plus('Target Directory', 0)) + white(targetDir));
76
+
77
+ const pkg = await readPackage(targetDir);
78
+ if (pkg.mode === 'create') await initializePackage(pkg, targetDir);
79
+
80
+ /** @type {import('./tui.js').PendingPackageOperation[]} */
81
+ let pendingPackageOperations = [];
82
+
83
+ if (!quiet && pkg.mode === 'update') {
84
+ const registry = await loadRegistry(builtinRegistryPath);
85
+ const packageOperations = await openPackageOperations({ projectRoot: targetDir, registry });
86
+ for (const warning of packageOperations.warnings) console.warn(yellow(`Warning: ${warning}`));
87
+ try {
88
+ pendingPackageOperations = await runInteractiveLoop({ packageOperations });
89
+ } catch (/** @type {any} */ err) {
90
+ if (err && err.message === 'cancelled') process.exit(-1);
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ await fs.mkdir(targetDir, { recursive: true });
96
+ const project = new ProjectChanges(targetDir);
97
+ if (pkg.mode === 'create') {
98
+ project.edit(tree => {
99
+ tree.write('package.json', toFormattedJson(pkg.pkg, tree.read('package.json')));
100
+ tree.write('README.md', pkg.readme);
101
+ });
102
+ }
103
+
104
+ for (const pending of pendingPackageOperations) {
105
+ await pending.operation.stage(project, pending.answers);
106
+ }
107
+
108
+ const { changes, conflicts } = project.review();
109
+
110
+ if (!quiet) {
111
+ console.log(`\n${gray('Pending changes:')}`);
112
+ console.log(renderChanges(changes, { readDiskJson: p => readDiskJsonFromDir(targetDir, p) }));
113
+ if (conflicts.length > 0) {
114
+ console.log(yellow('\nConflicts detected:'));
115
+ for (const line of formatConflicts(conflicts)) console.log(yellow(line));
116
+ }
117
+ }
118
+
119
+ const beforePkg = readDiskJsonFromDir(targetDir, 'package.json');
120
+ try {
121
+ await project.commit();
122
+ } catch (e) {
123
+ console.error(e);
124
+ process.exit(-1);
125
+ }
126
+
127
+ if (!quiet) {
128
+ for (const pending of pendingPackageOperations) {
129
+ const { effect, package: item } = pending.operation;
130
+ const verb = effect === 'adopt' ? green('adopted') : effect === 'remove' ? red('removed') : green('installed');
131
+ console.log(`${gray('Package')} ${white(item.name)} ${verb}`);
132
+ }
133
+ }
134
+
135
+ if (changesAffectDependencies(changes, beforePkg)) {
136
+ const pm = detectPackageManager(targetDir);
137
+ let shouldInstall = installFlag;
138
+ if (!shouldInstall && !quiet) {
139
+ const ans = await prompts({ type: 'confirm', name: 'go', message: `Run ${pm} install now?`, initial: false });
140
+ shouldInstall = Boolean(ans.go);
141
+ }
142
+ if (shouldInstall) {
143
+ if (!quiet) console.log(gray(`\nRunning ${pm} install...`));
144
+ const code = await runInstall(pm, targetDir);
145
+ if (code !== 0) {
146
+ console.error(red(`${pm} install exited with code ${code}`));
147
+ process.exitCode = code;
148
+ }
149
+ } else if (!quiet) {
150
+ console.log(gray(`\nDependencies changed. Run "${pm} install" to apply (or pass --install).`));
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * @param {string} dir
157
+ * @param {string} relPath
158
+ */
159
+ function readDiskJsonFromDir(dir, relPath) {
160
+ try {
161
+ return JSON.parse(fsSync.readFileSync(path.join(dir, relPath), 'utf8'));
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ execute();
168
+
169
+ async function reportVersion() {
170
+ const createPkgBldPackage = await readPackage(path.resolve(__dirname, '..'));
171
+
172
+ const version = createPkgBldPackage.pkg.version ?? '<unknown>';
173
+
174
+ return version;
175
+ }
176
+
177
+ /**
178
+ * @param {PkgInfo} pkg
179
+ * @param {string} targetDir
180
+ * @returns {Promise<void>}
181
+ */
182
+ async function initializePackage(pkg, targetDir) {
183
+ /** @type {PackageJson} */
184
+ const defaults = {
185
+ version: '0.0.1',
186
+ name: path.basename(targetDir),
187
+ description: '',
188
+ license: 'MIT',
189
+ author: getGitConfigValue('user.name'),
190
+ readme: 'README.md',
191
+ };
192
+
193
+ try {
194
+ const url = getGitConfigValue('remote.origin.url');
195
+ if (url) {
196
+ const root = await getGitRoot();
197
+ const directory = path.relative(root, targetDir);
198
+ defaults.homepage = url.replace('.git', `/blob/main${directory ? `/${directory}` : ''}/README.md`);
199
+ defaults.repository = /** @type {any} */ ({ type: 'git', url: `git+${url}`, directory: directory || undefined });
200
+ defaults.bugs = url.replace('.git', '/issues');
201
+ }
202
+ } catch (_) {
203
+ /* ignore */
204
+ }
205
+ pkg.pkg = processPackageJson(
206
+ defaults,
207
+ key => key in defaults,
208
+ key => /** @type {Record<string, unknown>} */ (defaults)[key]
209
+ );
210
+ pkg.readme = `# ${defaults.name}`;
211
+ }
212
+
213
+ /**
214
+ * @param {string} key
215
+ * @returns {string}
216
+ */
217
+ function getGitConfigValue(key) {
218
+ try {
219
+ return execFileSync('git', ['config', '--get', key], { encoding: 'utf8' }).trim();
220
+ } catch (_) {
221
+ return '';
222
+ }
223
+ }
224
+
225
+ /**
226
+ * @param {string} dir
227
+ * @returns {Promise<PkgInfo>}
228
+ */
229
+ async function readPackage(dir) {
230
+ const packageFileName = path.resolve(dir, 'package.json');
231
+ const readmeFileName = path.resolve(dir, 'README.md');
232
+ /** @type {PackageJson} */
233
+ const defaultPkg = {};
234
+ try {
235
+ const pkgFile = await fs.readFile(packageFileName);
236
+ const readmeFile = await fs.readFile(readmeFileName);
237
+ const pkg = JSON.parse(pkgFile.toString());
238
+ const isValidPackageJson = isPackageJson(pkg);
239
+ if (!isValidPackageJson) {
240
+ console.error('Invalid package.json');
241
+ throw new Error('Invalid package.json');
242
+ }
243
+ return {
244
+ pkg,
245
+ readme: readmeFile.toString(),
246
+ mode: /** @type {const} */ ('update'),
247
+ };
248
+ } catch (_) {
249
+ /**/
250
+ }
251
+
252
+ return {
253
+ pkg: defaultPkg,
254
+ readme: '',
255
+ mode: /** @type {const} */ ('create'),
256
+ };
257
+ }
package/src/install.js ADDED
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Detect the package manager for the given project root, based on lockfile
7
+ * presence. Defaults to `npm`.
8
+ *
9
+ * @param {string} projectRoot
10
+ * @returns {'pnpm' | 'yarn' | 'npm'}
11
+ */
12
+ export function detectPackageManager(projectRoot) {
13
+ if (existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) return 'pnpm';
14
+ if (existsSync(path.join(projectRoot, 'yarn.lock'))) return 'yarn';
15
+ return 'npm';
16
+ }
17
+
18
+ /**
19
+ * Return true if any of the given FileChanges touch dependency-related
20
+ * fields in package.json. Compares against a snapshot taken before commit.
21
+ *
22
+ * @param {readonly import('./tree.js').FileChange[]} changes
23
+ * @param {any} beforePackageJson
24
+ */
25
+ export function changesAffectDependencies(changes, beforePackageJson) {
26
+ for (const c of changes) {
27
+ if (!c.path.endsWith('package.json') || c.type === 'DELETE' || typeof c.content !== 'string') continue;
28
+ let after;
29
+ try {
30
+ after = JSON.parse(c.content);
31
+ } catch {
32
+ continue;
33
+ }
34
+ const before = beforePackageJson ?? {};
35
+ for (const group of ['dependencies', 'devDependencies', 'peerDependencies']) {
36
+ if (JSON.stringify(/** @type {any} */ (before)[group] ?? {}) !== JSON.stringify(after[group] ?? {})) {
37
+ return true;
38
+ }
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Spawn `<pm> install` in the project root. Inherits stdio so the user sees
46
+ * live progress. Never invoked automatically — only when the caller has
47
+ * confirmed (e.g. via `--install` flag or interactive prompt).
48
+ *
49
+ * @param {'pnpm' | 'yarn' | 'npm'} pm
50
+ * @param {string} projectRoot
51
+ * @returns {Promise<number>}
52
+ */
53
+ export function runInstall(pm, projectRoot) {
54
+ return new Promise((resolve, reject) => {
55
+ const child = spawn(pm, ['install'], { cwd: projectRoot, stdio: 'inherit', shell: process.platform === 'win32' });
56
+ child.on('error', reject);
57
+ child.on('close', code => resolve(code ?? 0));
58
+ });
59
+ }
@@ -0,0 +1,161 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { detectExtension } from './engine.js';
5
+ import { getPackageName } from './extension-cache.js';
6
+ import { getPackageKind, isPluginPackageName } from './package-names.js';
7
+ import { resolveInstalledPackage } from './package-resolution.js';
8
+ import { getPkgbldPeerRange, inspectInstalledPlugin } from './plugin-compatibility.js';
9
+ import { readProjectLock } from './project-lock.js';
10
+ import { resolveExtension } from './registry.js';
11
+ import { Tree } from './tree.js';
12
+
13
+ /**
14
+ * @typedef {'available' | 'applied' | 'installed-managed' | 'installed-unmanaged' | 'unavailable'} PackageState
15
+ * @typedef {{
16
+ * entry: import('./registry.js').ExtensionEntry,
17
+ * packageName: string,
18
+ * kind: 'plugin' | 'extension',
19
+ * state: PackageState,
20
+ * lockedVersion: string | null,
21
+ * resolvedVersion: string | null,
22
+ * dependencyFields: string[],
23
+ * hasExtensionContract: boolean,
24
+ * ext: import('./registry.js').Extension | null,
25
+ * error: string | null,
26
+ * installed: boolean,
27
+ * managed: boolean
28
+ * }} PackageItem
29
+ */
30
+
31
+ /**
32
+ * Build the package inventory without downloading or executing missing packages.
33
+ * @param {import('./registry.js').ExtensionEntry[]} registry
34
+ * @param {string} projectRoot
35
+ * @returns {Promise<{ items: PackageItem[], warnings: string[] }>}
36
+ */
37
+ export async function buildPackageInventory(registry, projectRoot) {
38
+ const lock = await readProjectLock(projectRoot);
39
+ const pkg = await readPackageJson(projectRoot);
40
+ /** @type {Map<string, { entry: import('./registry.js').ExtensionEntry, packageName: string, kind: 'plugin' | 'extension', lockedVersion: string | null, dependencyFields: string[], hasExtensionContract: boolean }>} */
41
+ const records = new Map();
42
+
43
+ for (const entry of registry) {
44
+ const packageName = getPackageName(entry.package);
45
+ const kind = packageName && getPackageKind(packageName);
46
+ if (!packageName || !kind) throw new Error(`Registry package "${entry.package}" does not use a supported package name`);
47
+ records.set(packageName, {
48
+ entry,
49
+ packageName,
50
+ kind,
51
+ lockedVersion: lock?.packages[packageName] ?? null,
52
+ dependencyFields: getDependencyFields(pkg, packageName),
53
+ hasExtensionContract: true,
54
+ });
55
+ }
56
+
57
+ for (const [packageName, version] of Object.entries(lock?.packages ?? {})) {
58
+ if (records.has(packageName)) continue;
59
+ const kind = /** @type {'plugin' | 'extension'} */ (getPackageKind(packageName));
60
+ records.set(packageName, {
61
+ entry: {
62
+ name: packageName,
63
+ package: packageName,
64
+ version,
65
+ description: kind === 'plugin' ? 'Third-party PKG BLD plugin' : 'PKG BLD extension',
66
+ tags: kind === 'plugin' ? ['plugin', 'pkgbld', 'third-party'] : ['extension', 'third-party'],
67
+ official: false,
68
+ },
69
+ packageName,
70
+ kind,
71
+ lockedVersion: version,
72
+ dependencyFields: getDependencyFields(pkg, packageName),
73
+ hasExtensionContract: kind === 'extension',
74
+ });
75
+ }
76
+
77
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
78
+ for (const packageName of Object.keys(pkg[field] ?? {})) {
79
+ if (!isPluginPackageName(packageName) || records.has(packageName)) continue;
80
+ records.set(packageName, {
81
+ entry: {
82
+ name: packageName,
83
+ package: packageName,
84
+ description: 'Third-party PKG BLD plugin',
85
+ tags: ['plugin', 'pkgbld', 'third-party'],
86
+ official: false,
87
+ },
88
+ packageName,
89
+ kind: 'plugin',
90
+ lockedVersion: null,
91
+ dependencyFields: getDependencyFields(pkg, packageName),
92
+ hasExtensionContract: false,
93
+ });
94
+ }
95
+ }
96
+
97
+ const tree = new Tree(projectRoot);
98
+ /** @type {PackageItem[]} */
99
+ const items = [];
100
+ /** @type {string[]} */
101
+ const warnings = [];
102
+ for (const record of records.values()) {
103
+ let installedPackage = resolveInstalledPackage(record.packageName, projectRoot);
104
+ if (record.kind === 'plugin' && record.dependencyFields.length > 0) {
105
+ const inspection = inspectInstalledPlugin(record.packageName, projectRoot);
106
+ if (!inspection.eligible) {
107
+ warnings.push(/** @type {string} */ (inspection.warning));
108
+ continue;
109
+ }
110
+ installedPackage = inspection.resolved;
111
+ } else if (record.kind === 'plugin' && record.lockedVersion && !record.entry.official) {
112
+ warnings.push(
113
+ `Ignoring ${record.packageName}: its plugin metadata cannot be verified from the project. Install and adopt a modern version before managing it with create-pkgbld.`
114
+ );
115
+ continue;
116
+ }
117
+ let ext = null;
118
+ let error = null;
119
+ let detected = false;
120
+ let resolvedVersion = installedPackage?.version ?? null;
121
+ if (record.hasExtensionContract && record.entry.official) {
122
+ try {
123
+ ext = await resolveExtension(record.entry, projectRoot, { exactVersion: record.lockedVersion ?? undefined });
124
+ if (record.kind === 'plugin' && !getPkgbldPeerRange(ext.__packageManifest)) {
125
+ warnings.push(
126
+ `Ignoring ${record.packageName}: the resolved package does not declare pkgbld in peerDependencies. Upgrade the plugin before managing it with create-pkgbld.`
127
+ );
128
+ continue;
129
+ }
130
+ detected = detectExtension(ext, tree);
131
+ resolvedVersion = ext.__packageVersion ?? resolvedVersion;
132
+ } catch (/** @type {any} */ cause) {
133
+ error = cause.message ?? String(cause);
134
+ }
135
+ }
136
+ const installed = record.kind === 'plugin' ? record.dependencyFields.length > 0 : detected;
137
+ const managed = record.lockedVersion !== null;
138
+ /** @type {PackageState} */
139
+ let state;
140
+ if (managed) state = installed ? 'installed-managed' : 'applied';
141
+ else if (installed) state = 'installed-unmanaged';
142
+ else if (record.entry.official) state = 'available';
143
+ else state = 'unavailable';
144
+ items.push({ ...record, state, resolvedVersion, ext, error, installed, managed });
145
+ }
146
+ return { items: items.sort((a, b) => a.entry.name.localeCompare(b.entry.name)), warnings };
147
+ }
148
+
149
+ /** @param {Record<string, any>} pkg @param {string} packageName */
150
+ function getDependencyFields(pkg, packageName) {
151
+ return ['dependencies', 'devDependencies', 'peerDependencies'].filter(field => pkg[field]?.[packageName] !== undefined);
152
+ }
153
+
154
+ /** @param {string} projectRoot */
155
+ async function readPackageJson(projectRoot) {
156
+ try {
157
+ return JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8'));
158
+ } catch {
159
+ return {};
160
+ }
161
+ }
@@ -0,0 +1,25 @@
1
+ const PLUGIN_PACKAGE_RE = /^(?:@[^/]+\/)?pkgbld-plugin-/;
2
+ const EXTENSION_PACKAGE_RE = /^(?:@[^/]+\/)?create-pkgbld-extension-/;
3
+ const LOCK_PACKAGE_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?(?:create-pkgbld-extension-|pkgbld-plugin-)[a-z0-9][a-z0-9._~-]*$/;
4
+
5
+ /** @param {string} packageName */
6
+ export function isPluginPackageName(packageName) {
7
+ return PLUGIN_PACKAGE_RE.test(packageName);
8
+ }
9
+
10
+ /** @param {string} packageName */
11
+ export function isExtensionPackageName(packageName) {
12
+ return EXTENSION_PACKAGE_RE.test(packageName);
13
+ }
14
+
15
+ /** @param {string} packageName @returns {'plugin' | 'extension' | null} */
16
+ export function getPackageKind(packageName) {
17
+ if (isPluginPackageName(packageName)) return 'plugin';
18
+ if (isExtensionPackageName(packageName)) return 'extension';
19
+ return null;
20
+ }
21
+
22
+ /** @param {string} packageName */
23
+ export function isLockPackageName(packageName) {
24
+ return LOCK_PACKAGE_RE.test(packageName);
25
+ }