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/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
+ }