create-pkgbld 1.8.1 → 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/LICENSE +1 -1
- package/README.md +85 -10
- package/extensions-schema.json +53 -0
- package/extensions.json +33 -0
- package/index.js +2 -2
- package/lock-schema-v1.json +26 -0
- package/package.json +21 -9
- package/src/conflicts.js +21 -0
- package/src/diff.js +83 -0
- package/src/engine.js +120 -0
- package/src/extension-cache.js +110 -0
- package/src/get-git-root.js +8 -0
- package/src/index.js +257 -0
- package/src/install.js +59 -0
- package/src/inventory.js +161 -0
- package/src/package-names.js +25 -0
- package/src/package-operations.js +353 -0
- package/src/package-resolution.js +40 -0
- package/src/package-version.js +48 -0
- package/src/plugin-compatibility.js +79 -0
- package/src/project-changes.js +379 -0
- package/src/project-lock.js +74 -0
- package/src/registry.js +184 -0
- package/src/subcommands.js +345 -0
- package/src/tree.js +282 -0
- package/src/tui.js +167 -0
- package/src/types.js +28 -0
- package/src/update-engine.js +196 -0
- package/dist/index.mjs +0 -683
package/src/tui.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
|
|
3
|
+
import { blue, gray, green, red } from '@niceties/ansi';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('prompts').PromptObject} PromptObject
|
|
7
|
+
* @typedef {import('./types.js').Option} Option
|
|
8
|
+
* @typedef {import('./types.js').OptionsValue} OptionsValue
|
|
9
|
+
* @typedef {{
|
|
10
|
+
* operation: import('./package-operations.js').PreparedPackageOperation,
|
|
11
|
+
* answers: OptionsValue
|
|
12
|
+
* }} PendingPackageOperation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const done = Symbol('done');
|
|
16
|
+
const PACKAGE_PREFIX = '__package__:';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} value
|
|
20
|
+
* @param {number} [indent]
|
|
21
|
+
* @param {number} [offset]
|
|
22
|
+
*/
|
|
23
|
+
export function pad16plus(value, indent = 4, offset = 3) {
|
|
24
|
+
return value + ''.padEnd(offset - Math.floor((value.length + indent) / 8), '\t');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert an Option leaf into a prompts() configuration.
|
|
29
|
+
*
|
|
30
|
+
* @param {Option} option
|
|
31
|
+
* @param {OptionsValue} answers
|
|
32
|
+
* @returns {PromptObject}
|
|
33
|
+
*/
|
|
34
|
+
export function getPromptOption(option, answers) {
|
|
35
|
+
const value = answers[option.field];
|
|
36
|
+
const initialValue = value ?? option.initialValue;
|
|
37
|
+
const type = option.type ?? 'text';
|
|
38
|
+
/** @type {PromptObject} */
|
|
39
|
+
const promptOption = {
|
|
40
|
+
type,
|
|
41
|
+
name: option.field,
|
|
42
|
+
message: option.title,
|
|
43
|
+
initial: /** @type {any} */ (Array.isArray(initialValue) ? initialValue.join(',') : (initialValue ?? '')),
|
|
44
|
+
};
|
|
45
|
+
if (type === 'multiselect') {
|
|
46
|
+
promptOption.choices =
|
|
47
|
+
'list' in option
|
|
48
|
+
? option.list.map((/** @type {string} */ item) => ({
|
|
49
|
+
title: item,
|
|
50
|
+
value: item,
|
|
51
|
+
selected: Array.isArray(initialValue) && initialValue.includes(item),
|
|
52
|
+
}))
|
|
53
|
+
: [];
|
|
54
|
+
}
|
|
55
|
+
if (type === 'select') {
|
|
56
|
+
promptOption.choices =
|
|
57
|
+
'list' in option
|
|
58
|
+
? option.list.map((/** @type {string} */ item) => ({
|
|
59
|
+
title: item,
|
|
60
|
+
value: item,
|
|
61
|
+
}))
|
|
62
|
+
: [];
|
|
63
|
+
promptOption.initial = /** @type {import('prompts').Choice[]} */ (promptOption.choices).findIndex(
|
|
64
|
+
(/** @type {import('prompts').Choice} */ item) => item.value === promptOption.initial
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return promptOption;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {import('./package-operations.js').PackageView} item
|
|
72
|
+
* @param {PendingPackageOperation | undefined} pending
|
|
73
|
+
*/
|
|
74
|
+
function renderPackageLabel(item, pending) {
|
|
75
|
+
const left = pad16plus(item.name);
|
|
76
|
+
if (pending) return `${left}${blue(`[Pending ${pending.operation.effect}]`)}`;
|
|
77
|
+
if (item.state === 'unavailable') return `${left}${red('[Unavailable]')} ${gray(item.error ?? '')}`;
|
|
78
|
+
if (item.state === 'available') return `${left}${gray('[Available]')}`;
|
|
79
|
+
if (item.state === 'applied') return `${left}${blue('[Applied]')}`;
|
|
80
|
+
if (item.state === 'installed-managed') return `${left}${green('[Installed, managed]')}`;
|
|
81
|
+
return `${left}${blue('[Installed, unmanaged]')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Collect package targets and their answers. Project changes remain unstaged
|
|
86
|
+
* until the caller stages the returned operations.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ packageOperations: Awaited<ReturnType<import('./package-operations.js').openPackageOperations>> }} params
|
|
89
|
+
* @returns {Promise<PendingPackageOperation[]>}
|
|
90
|
+
*/
|
|
91
|
+
export async function runInteractiveLoop({ packageOperations }) {
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
function onCancel() {
|
|
94
|
+
cancelled = true;
|
|
95
|
+
}
|
|
96
|
+
/** @type {Map<string, PendingPackageOperation>} */
|
|
97
|
+
const pending = new Map();
|
|
98
|
+
|
|
99
|
+
for (;;) {
|
|
100
|
+
const packageAction = await prompts(
|
|
101
|
+
{
|
|
102
|
+
type: 'select',
|
|
103
|
+
name: 'value',
|
|
104
|
+
message: 'Select a PKG BLD package, Done to execute, Escape to cancel',
|
|
105
|
+
choices: [
|
|
106
|
+
{ title: green('Done'), value: /** @type {any} */ (done) },
|
|
107
|
+
...packageOperations.inventory.map(item => ({
|
|
108
|
+
title: renderPackageLabel(item, pending.get(item.id)),
|
|
109
|
+
value: `${PACKAGE_PREFIX}${item.id}`,
|
|
110
|
+
})),
|
|
111
|
+
],
|
|
112
|
+
initial: 0,
|
|
113
|
+
},
|
|
114
|
+
{ onCancel }
|
|
115
|
+
);
|
|
116
|
+
if (cancelled) throw new Error('cancelled');
|
|
117
|
+
if (packageAction.value === done) return [...pending.values()];
|
|
118
|
+
|
|
119
|
+
if (typeof packageAction.value !== 'string' || !packageAction.value.startsWith(PACKAGE_PREFIX)) continue;
|
|
120
|
+
const id = packageAction.value.slice(PACKAGE_PREFIX.length);
|
|
121
|
+
const item = packageOperations.inventory.find(candidate => candidate.id === id);
|
|
122
|
+
if (!item) continue;
|
|
123
|
+
if (pending.delete(id)) continue;
|
|
124
|
+
if (item.state === 'unavailable') {
|
|
125
|
+
console.log(red(`Package "${item.name}" is unavailable: ${item.error ?? 'not resolvable'}`));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @type {import('./package-operations.js').PackageTarget} */
|
|
130
|
+
let target;
|
|
131
|
+
if (item.state === 'installed-unmanaged') {
|
|
132
|
+
const action = await prompts(
|
|
133
|
+
{
|
|
134
|
+
type: 'select',
|
|
135
|
+
name: 'value',
|
|
136
|
+
message: `Manage ${item.name}`,
|
|
137
|
+
choices: [
|
|
138
|
+
{ title: 'Adopt', value: 'managed' },
|
|
139
|
+
{ title: 'Remove', value: 'absent' },
|
|
140
|
+
{ title: 'Cancel', value: null },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{ onCancel }
|
|
144
|
+
);
|
|
145
|
+
if (cancelled) throw new Error('cancelled');
|
|
146
|
+
if (action.value === null || action.value === undefined) continue;
|
|
147
|
+
target = action.value;
|
|
148
|
+
} else {
|
|
149
|
+
target = item.state === 'installed-managed' ? 'absent' : 'managed';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const operation = await packageOperations.prepare({ package: id, target });
|
|
154
|
+
/** @type {OptionsValue} */
|
|
155
|
+
const answers = {};
|
|
156
|
+
for (const question of operation.questions) {
|
|
157
|
+
const answer = await prompts(getPromptOption(question, answers), { onCancel });
|
|
158
|
+
if (cancelled) throw new Error('cancelled');
|
|
159
|
+
answers[question.field] = answer[question.field];
|
|
160
|
+
}
|
|
161
|
+
pending.set(id, { operation, answers });
|
|
162
|
+
} catch (/** @type {any} */ error) {
|
|
163
|
+
if (error.message === 'cancelled') throw error;
|
|
164
|
+
console.log(red(`Package "${item.name}" is unavailable: ${error.message ?? String(error)}`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** @typedef {import('pkgbld/options').PackageJson} PackageJson */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{
|
|
5
|
+
* title: string;
|
|
6
|
+
* field: string;
|
|
7
|
+
* initialValue?: string | string[] | boolean;
|
|
8
|
+
* } & (
|
|
9
|
+
* | { type?: undefined | 'toggle' | 'list' | 'text' }
|
|
10
|
+
* | { type: 'multiselect' | 'select'; list: string[] }
|
|
11
|
+
* )} Option
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{
|
|
16
|
+
* readme: string;
|
|
17
|
+
* pkg: PackageJson;
|
|
18
|
+
* mode: 'create' | 'update';
|
|
19
|
+
* }} PkgInfo
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {{
|
|
24
|
+
* [key: string]: undefined | null | number | boolean | string | string[] | OptionsValue;
|
|
25
|
+
* }} OptionsValue
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const ABSENT = Symbol('absent');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply target-version update behavior or guarded declarative reconciliation.
|
|
8
|
+
* @param {{
|
|
9
|
+
* previous: import('./registry.js').Extension,
|
|
10
|
+
* target: import('./registry.js').Extension,
|
|
11
|
+
* tree: import('./tree.js').Tree,
|
|
12
|
+
* fromVersion: string,
|
|
13
|
+
* toVersion: string,
|
|
14
|
+
* options?: import('./types.js').OptionsValue,
|
|
15
|
+
* exclude?: string[],
|
|
16
|
+
* reportConflict(conflict: { resource: string, message: string, expected?: unknown, current?: unknown, proposed?: unknown }): void
|
|
17
|
+
* }} params
|
|
18
|
+
*/
|
|
19
|
+
export async function runExtensionUpdate({ previous, target, tree, fromVersion, toVersion, options = {}, exclude = [], reportConflict }) {
|
|
20
|
+
const reconcileDeclarative = (/** @type {{ exclude?: string[] }} */ reconcileOptions = {}) => {
|
|
21
|
+
if (!isDeclarative(previous.setup) || !isDeclarative(target.setup)) {
|
|
22
|
+
throw new Error('Declarative reconciliation requires declarative setup in both package versions');
|
|
23
|
+
}
|
|
24
|
+
reconcileSetups(previous, target, tree, reportConflict, [...exclude, ...(reconcileOptions.exclude ?? [])]);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (typeof target.update === 'function') {
|
|
28
|
+
tree.setExtensionBase(target.__baseDir ?? null);
|
|
29
|
+
try {
|
|
30
|
+
await target.update(tree, Object.freeze({ fromVersion, toVersion, reconcileDeclarative, reportConflict }), options);
|
|
31
|
+
} finally {
|
|
32
|
+
tree.setExtensionBase(null);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!isDeclarative(previous.setup) || !isDeclarative(target.setup)) {
|
|
38
|
+
throw new Error(`Update from ${fromVersion} to ${toVersion} is unsupported: the target has no update function`);
|
|
39
|
+
}
|
|
40
|
+
reconcileDeclarative();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {unknown} setup */
|
|
44
|
+
function isDeclarative(setup) {
|
|
45
|
+
return Boolean(setup && typeof setup === 'object' && !Array.isArray(setup));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('./registry.js').Extension} previous
|
|
50
|
+
* @param {import('./registry.js').Extension} target
|
|
51
|
+
* @param {import('./tree.js').Tree} tree
|
|
52
|
+
* @param {(conflict: any) => void} reportConflict
|
|
53
|
+
* @param {string[]} excluded
|
|
54
|
+
*/
|
|
55
|
+
function reconcileSetups(previous, target, tree, reportConflict, excluded) {
|
|
56
|
+
const oldResources = collectResources(/** @type {import('./registry.js').SetupDeclarative} */ (previous.setup), previous);
|
|
57
|
+
const newResources = collectResources(/** @type {import('./registry.js').SetupDeclarative} */ (target.setup), target);
|
|
58
|
+
const excludedSet = new Set(excluded);
|
|
59
|
+
const keys = new Set([...oldResources.keys(), ...newResources.keys()]);
|
|
60
|
+
|
|
61
|
+
for (const key of [...keys].sort()) {
|
|
62
|
+
if (excludedSet.has(key)) continue;
|
|
63
|
+
const oldResource = oldResources.get(key);
|
|
64
|
+
const newResource = newResources.get(key);
|
|
65
|
+
const resource = newResource ?? oldResource;
|
|
66
|
+
if (!resource) continue;
|
|
67
|
+
const oldValue = oldResource?.value ?? ABSENT;
|
|
68
|
+
const newValue = newResource?.value ?? ABSENT;
|
|
69
|
+
const currentValue = readCurrent(resource, tree);
|
|
70
|
+
|
|
71
|
+
if (sameValue(currentValue, oldValue, resource) || sameValue(currentValue, newValue, resource)) {
|
|
72
|
+
if (!sameValue(currentValue, newValue, resource)) applyValue(resource, newValue, tree);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (sameValue(oldValue, newValue, resource)) continue;
|
|
76
|
+
|
|
77
|
+
reportConflict({
|
|
78
|
+
resource: key,
|
|
79
|
+
expected: externalValue(oldValue),
|
|
80
|
+
current: externalValue(currentValue),
|
|
81
|
+
proposed: externalValue(newValue),
|
|
82
|
+
message: `Resource "${displayResource(resource)}" was changed after the previous package version was applied`,
|
|
83
|
+
});
|
|
84
|
+
applyValue(resource, newValue, tree);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {import('./registry.js').SetupDeclarative} setup @param {import('./registry.js').Extension} extension */
|
|
89
|
+
function collectResources(setup, extension) {
|
|
90
|
+
/** @type {Map<string, any>} */
|
|
91
|
+
const resources = new Map();
|
|
92
|
+
for (const [name, value] of Object.entries(setup.dependencies ?? {})) {
|
|
93
|
+
resources.set(`dependency:dependencies:${name}`, { kind: 'dependency', field: 'dependencies', name, value });
|
|
94
|
+
}
|
|
95
|
+
for (const [name, value] of Object.entries(setup.devDependencies ?? {})) {
|
|
96
|
+
resources.set(`dependency:devDependencies:${name}`, { kind: 'dependency', field: 'devDependencies', name, value });
|
|
97
|
+
}
|
|
98
|
+
for (const [name, value] of Object.entries(setup.scripts ?? {})) {
|
|
99
|
+
resources.set(`script:${name}`, { kind: 'script', name, value });
|
|
100
|
+
}
|
|
101
|
+
for (const [target, source] of Object.entries(setup.files ?? {})) {
|
|
102
|
+
const value = source.startsWith('inline:')
|
|
103
|
+
? source.slice('inline:'.length)
|
|
104
|
+
: readFileSync(resolveExtensionFile(extension, source), 'utf8');
|
|
105
|
+
resources.set(`file:${target}`, { kind: 'file', path: target, value });
|
|
106
|
+
}
|
|
107
|
+
for (const [name, value] of Object.entries(setup.packageJson ?? {})) {
|
|
108
|
+
resources.set(`package-json:${name}`, { kind: 'package-json', name, value });
|
|
109
|
+
}
|
|
110
|
+
return resources;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {import('./registry.js').Extension} extension @param {string} relativePath */
|
|
114
|
+
function resolveExtensionFile(extension, relativePath) {
|
|
115
|
+
if (!extension.__baseDir) throw new Error(`Cannot resolve extension file "${relativePath}": package base is unavailable`);
|
|
116
|
+
return path.resolve(extension.__baseDir, relativePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param {any} resource @param {import('./tree.js').Tree} tree */
|
|
120
|
+
function readCurrent(resource, tree) {
|
|
121
|
+
if (resource.kind === 'file') return tree.read(resource.path) ?? ABSENT;
|
|
122
|
+
const pkg = tree.readJson('package.json') ?? {};
|
|
123
|
+
if (resource.kind === 'dependency') return pkg[resource.field]?.[resource.name] ?? ABSENT;
|
|
124
|
+
if (resource.kind === 'script') return pkg.scripts?.[resource.name] ?? ABSENT;
|
|
125
|
+
return pkg[resource.name] ?? ABSENT;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {any} resource @param {unknown} value @param {import('./tree.js').Tree} tree */
|
|
129
|
+
function applyValue(resource, value, tree) {
|
|
130
|
+
if (resource.kind === 'file') {
|
|
131
|
+
if (value === ABSENT) tree.delete(resource.path);
|
|
132
|
+
else tree.write(resource.path, /** @type {string} */ (value));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
tree.updateJson('package.json', pkg => {
|
|
136
|
+
const container = resource.kind === 'dependency' ? resource.field : resource.kind === 'script' ? 'scripts' : null;
|
|
137
|
+
if (container) {
|
|
138
|
+
if (value === ABSENT) {
|
|
139
|
+
if (pkg[container]) {
|
|
140
|
+
delete pkg[container][resource.name];
|
|
141
|
+
if (Object.keys(pkg[container]).length === 0) delete pkg[container];
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
pkg[container] ??= {};
|
|
145
|
+
pkg[container][resource.name] = value;
|
|
146
|
+
}
|
|
147
|
+
} else if (value === ABSENT) {
|
|
148
|
+
delete pkg[resource.name];
|
|
149
|
+
} else {
|
|
150
|
+
pkg[resource.name] = value;
|
|
151
|
+
}
|
|
152
|
+
return pkg;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @param {unknown} left @param {unknown} right @param {any} resource */
|
|
157
|
+
function sameValue(left, right, resource) {
|
|
158
|
+
if (left === ABSENT || right === ABSENT) return left === right;
|
|
159
|
+
if (resource.kind === 'file' && resource.path.endsWith('.json')) {
|
|
160
|
+
try {
|
|
161
|
+
return fingerprint(JSON.parse(/** @type {string} */ (left))) === fingerprint(JSON.parse(/** @type {string} */ (right)));
|
|
162
|
+
} catch {
|
|
163
|
+
return left === right;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return fingerprint(left) === fingerprint(right);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {unknown} value */
|
|
170
|
+
function fingerprint(value) {
|
|
171
|
+
return JSON.stringify(canonicalize(value));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @param {unknown} value @returns {unknown} */
|
|
175
|
+
function canonicalize(value) {
|
|
176
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
177
|
+
if (!value || typeof value !== 'object') return value;
|
|
178
|
+
return Object.fromEntries(
|
|
179
|
+
Object.entries(/** @type {Record<string, unknown>} */ (value))
|
|
180
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
181
|
+
.map(([key, item]) => [key, canonicalize(item)])
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {unknown} value */
|
|
186
|
+
function externalValue(value) {
|
|
187
|
+
return value === ABSENT ? undefined : value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {any} resource */
|
|
191
|
+
function displayResource(resource) {
|
|
192
|
+
if (resource.kind === 'file') return resource.path;
|
|
193
|
+
if (resource.kind === 'dependency') return `${resource.field}.${resource.name}`;
|
|
194
|
+
if (resource.kind === 'script') return `scripts.${resource.name}`;
|
|
195
|
+
return `package.json:${resource.name}`;
|
|
196
|
+
}
|