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.
@@ -0,0 +1,379 @@
1
+ import path from 'node:path';
2
+
3
+ import { LOCK_FILE, removeLockedPackage, setLockedPackage } from './project-lock.js';
4
+ import { Tree } from './tree.js';
5
+
6
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies'];
7
+
8
+ /**
9
+ * @typedef {{
10
+ * kind: 'file' | 'dependency' | 'script' | 'package-json',
11
+ * resource: string,
12
+ * source: string,
13
+ * path?: string,
14
+ * key?: string,
15
+ * value: unknown,
16
+ * fingerprint: string
17
+ * }} ChangeClaim
18
+ * @typedef {{
19
+ * kind: 'write-conflict' | 'write-vs-delete' | 'dependency-version' | 'script-value' | 'package-json-value' | 'migration-conflict',
20
+ * path?: string,
21
+ * key?: string,
22
+ * sources: readonly string[],
23
+ * message: string,
24
+ * resource?: string,
25
+ * expected?: unknown,
26
+ * current?: unknown,
27
+ * proposed?: unknown
28
+ * }} Conflict
29
+ */
30
+
31
+ export class ProjectChanges {
32
+ #tree;
33
+
34
+ /** @param {string} projectRoot */
35
+ constructor(projectRoot) {
36
+ this.projectRoot = projectRoot;
37
+ this.#tree = new Tree(projectRoot, { onMutation: mutation => this._recordMutation(mutation) });
38
+ /** @type {{ source: string, touched: Map<string, { before: string | null, after: string | null }>, conflicts: Conflict[] } | null} */
39
+ this.activeStage = null;
40
+ this.bookkeepingDepth = 0;
41
+ /** @type {ChangeClaim[]} */
42
+ this.claims = [];
43
+ /** @type {Conflict[]} */
44
+ this.migrationConflicts = [];
45
+ }
46
+
47
+ /**
48
+ * Stage unattributed project changes, such as package initialization.
49
+ * @template T
50
+ * @param {(tree: Tree) => T} fn
51
+ * @returns {T}
52
+ */
53
+ edit(fn) {
54
+ if (this.activeStage) throw new Error('Cannot edit project changes while a package operation is active');
55
+ const checkpoint = this.#tree._createCheckpoint();
56
+ const scope = new ScopedTree(this.#tree);
57
+ try {
58
+ return fn(/** @type {Tree} */ (scope));
59
+ } catch (error) {
60
+ this.#tree._restoreCheckpoint(checkpoint);
61
+ throw error;
62
+ } finally {
63
+ scope.close();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Stage one package operation against the accumulated project state.
69
+ * @template T
70
+ * @param {string} source
71
+ * @param {(scope: { tree: Tree, projectLock: { set(packageName: string, version: string): void, remove(packageName: string): void }, reportConflict(conflict: { resource: string, message: string, expected?: unknown, current?: unknown, proposed?: unknown }): void }) => T | Promise<T>} fn
72
+ * @returns {Promise<T>}
73
+ */
74
+ async stagePackageOperation(source, fn) {
75
+ if (!source) throw new TypeError('Package operation source must not be empty');
76
+ if (this.activeStage) throw new Error('A package operation is already active');
77
+
78
+ const checkpoint = this.#tree._createCheckpoint();
79
+ const scope = new ScopedTree(this.#tree);
80
+ this.activeStage = { source, touched: new Map(), conflicts: [] };
81
+ let stageOpen = true;
82
+ const assertStageOpen = () => {
83
+ if (!stageOpen) throw new Error('Project-lock scope is closed');
84
+ };
85
+ const projectLock = Object.freeze({
86
+ set: (/** @type {string} */ packageName, /** @type {string} */ version) => {
87
+ assertStageOpen();
88
+ this._recordBookkeeping(() => setLockedPackage(this.#tree, packageName, version));
89
+ },
90
+ remove: (/** @type {string} */ packageName) => {
91
+ assertStageOpen();
92
+ this._recordBookkeeping(() => removeLockedPackage(this.#tree, packageName));
93
+ },
94
+ });
95
+ const reportConflict = (
96
+ /** @type {{ resource: string, message: string, expected?: unknown, current?: unknown, proposed?: unknown }} */ conflict
97
+ ) => {
98
+ assertStageOpen();
99
+ if (!conflict.resource || !conflict.message) throw new TypeError('Migration conflicts require a resource and message');
100
+ this.activeStage?.conflicts.push({ kind: 'migration-conflict', sources: [source], ...conflict });
101
+ };
102
+
103
+ try {
104
+ const result = await fn({ tree: /** @type {Tree} */ (scope), projectLock, reportConflict });
105
+ for (const [changedPath, change] of this.activeStage.touched) {
106
+ this.claims.push(...classifyChange(source, changedPath, change.before, change.after, this.projectRoot));
107
+ }
108
+ this.migrationConflicts.push(...this.activeStage.conflicts);
109
+ return result;
110
+ } catch (error) {
111
+ this.#tree._restoreCheckpoint(checkpoint);
112
+ throw error;
113
+ } finally {
114
+ stageOpen = false;
115
+ scope.close();
116
+ this.activeStage = null;
117
+ }
118
+ }
119
+
120
+ review() {
121
+ const changes = Object.freeze(this.#tree.listChanges().map(change => Object.freeze({ ...change })));
122
+ const conflicts = Object.freeze(
123
+ [...listConflicts(this.claims), ...this.migrationConflicts].map(conflict =>
124
+ Object.freeze({ ...conflict, sources: Object.freeze(conflict.sources) })
125
+ )
126
+ );
127
+ return Object.freeze({ changes, conflicts });
128
+ }
129
+
130
+ /** @param {{ lock?: 'include' | 'exclude' | 'only' }} [options] */
131
+ async commit(options) {
132
+ if (this.activeStage) throw new Error('Cannot commit while a package operation is active');
133
+ await this.#tree.commit(options);
134
+ }
135
+
136
+ /** @param {{ path: string, before: string | null, after: string | null }} mutation */
137
+ _recordMutation(mutation) {
138
+ if (!this.activeStage || this.bookkeepingDepth > 0) return;
139
+ const existing = this.activeStage.touched.get(mutation.path);
140
+ this.activeStage.touched.set(mutation.path, {
141
+ before: existing ? existing.before : mutation.before,
142
+ after: mutation.after,
143
+ });
144
+ }
145
+
146
+ /** @template T @param {() => T} fn @returns {T} */
147
+ _recordBookkeeping(fn) {
148
+ this.bookkeepingDepth += 1;
149
+ try {
150
+ return fn();
151
+ } finally {
152
+ this.bookkeepingDepth -= 1;
153
+ }
154
+ }
155
+ }
156
+
157
+ class ScopedTree extends Tree {
158
+ #delegate;
159
+ #closed = false;
160
+
161
+ /** @param {Tree} tree */
162
+ constructor(tree) {
163
+ super(tree.projectRoot);
164
+ this.#delegate = tree;
165
+ }
166
+
167
+ close() {
168
+ this.#closed = true;
169
+ }
170
+
171
+ _assertOpen() {
172
+ if (this.#closed) throw new Error('Tree scope is closed');
173
+ }
174
+
175
+ /** @param {string} value */
176
+ _assertMutablePath(value) {
177
+ const resolved = path.isAbsolute(value) ? path.resolve(value) : path.resolve(this.projectRoot, value);
178
+ if (resolved === path.join(this.projectRoot, LOCK_FILE)) {
179
+ throw new Error('The project lock can only be changed through the package-operation lock capability');
180
+ }
181
+ }
182
+
183
+ /** @param {string | null} dir */
184
+ setExtensionBase(dir) {
185
+ this._assertOpen();
186
+ this.#delegate.setExtensionBase(dir);
187
+ }
188
+
189
+ /** @param {string} value */
190
+ read(value) {
191
+ this._assertOpen();
192
+ return this.#delegate.read(value);
193
+ }
194
+
195
+ /** @param {string} value @param {string} content */
196
+ write(value, content) {
197
+ this._assertOpen();
198
+ this._assertMutablePath(value);
199
+ return this.#delegate.write(value, content);
200
+ }
201
+
202
+ /** @param {string} value */
203
+ delete(value) {
204
+ this._assertOpen();
205
+ this._assertMutablePath(value);
206
+ return this.#delegate.delete(value);
207
+ }
208
+
209
+ /** @param {string} oldPath @param {string} newPath */
210
+ rename(oldPath, newPath) {
211
+ this._assertOpen();
212
+ this._assertMutablePath(oldPath);
213
+ this._assertMutablePath(newPath);
214
+ return this.#delegate.rename(oldPath, newPath);
215
+ }
216
+
217
+ /** @param {string} relativePath */
218
+ resolveExtensionFile(relativePath) {
219
+ this._assertOpen();
220
+ return this.#delegate.resolveExtensionFile(relativePath);
221
+ }
222
+
223
+ listChanges() {
224
+ this._assertOpen();
225
+ return this.#delegate.listChanges();
226
+ }
227
+
228
+ async commit() {
229
+ this._assertOpen();
230
+ throw new Error('Tree.commit() is unavailable inside a project change scope');
231
+ }
232
+ }
233
+
234
+ /** @param {string} source @param {string} changedPath @param {string | null} before @param {string | null} after @param {string} root */
235
+ function classifyChange(source, changedPath, before, after, root) {
236
+ if (before === after) return [];
237
+ const absolute = path.isAbsolute(changedPath) ? path.resolve(changedPath) : path.resolve(root, changedPath);
238
+ if (absolute !== path.join(root, 'package.json')) {
239
+ return [createClaim('file', `file:${absolute}`, source, after, { path: changedPath })];
240
+ }
241
+
242
+ const beforeJson = parsePackageJson(before);
243
+ const afterJson = parsePackageJson(after);
244
+ if (!beforeJson || !afterJson) {
245
+ return [createClaim('file', `file:${absolute}`, source, after, { path: changedPath })];
246
+ }
247
+
248
+ /** @type {ChangeClaim[]} */
249
+ const claims = [];
250
+ const dependencyNames = new Set();
251
+ for (const field of DEPENDENCY_FIELDS) {
252
+ for (const name of Object.keys(beforeJson[field] ?? {})) dependencyNames.add(name);
253
+ for (const name of Object.keys(afterJson[field] ?? {})) dependencyNames.add(name);
254
+ }
255
+ for (const name of dependencyNames) {
256
+ const beforeValue = dependencyPlacements(beforeJson, name);
257
+ const afterValue = dependencyPlacements(afterJson, name);
258
+ if (!sameValue(beforeValue, afterValue)) {
259
+ claims.push(
260
+ createClaim('dependency', `dependency:${name}`, source, afterValue.length > 0 ? afterValue : undefined, { key: name })
261
+ );
262
+ }
263
+ }
264
+
265
+ for (const name of new Set([...Object.keys(beforeJson.scripts ?? {}), ...Object.keys(afterJson.scripts ?? {})])) {
266
+ const beforeValue = beforeJson.scripts?.[name];
267
+ const afterValue = afterJson.scripts?.[name];
268
+ if (!sameValue(beforeValue, afterValue)) {
269
+ claims.push(createClaim('script', `script:${name}`, source, afterValue, { key: name }));
270
+ }
271
+ }
272
+
273
+ const ignored = new Set([...DEPENDENCY_FIELDS, 'scripts']);
274
+ for (const key of new Set([...Object.keys(beforeJson), ...Object.keys(afterJson)])) {
275
+ if (ignored.has(key)) continue;
276
+ const beforeValue = beforeJson[key];
277
+ const afterValue = afterJson[key];
278
+ if (!sameValue(beforeValue, afterValue)) {
279
+ claims.push(createClaim('package-json', `package-json:${key}`, source, afterValue, { key }));
280
+ }
281
+ }
282
+ return claims;
283
+ }
284
+
285
+ /** @param {string | null} value */
286
+ function parsePackageJson(value) {
287
+ if (value === null) return {};
288
+ try {
289
+ const parsed = JSON.parse(value);
290
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ /** @param {Record<string, any>} pkg @param {string} name */
297
+ function dependencyPlacements(pkg, name) {
298
+ return DEPENDENCY_FIELDS.flatMap(field => (pkg[field]?.[name] === undefined ? [] : [{ field, version: pkg[field][name] }]));
299
+ }
300
+
301
+ /** @param {ChangeClaim['kind']} kind @param {string} resource @param {string} source @param {unknown} value @param {{ path?: string, key?: string }} location */
302
+ function createClaim(kind, resource, source, value, location) {
303
+ return { kind, resource, source, value, fingerprint: fingerprint(value), ...location };
304
+ }
305
+
306
+ /** @param {unknown} left @param {unknown} right */
307
+ function sameValue(left, right) {
308
+ return fingerprint(left) === fingerprint(right);
309
+ }
310
+
311
+ /** @param {unknown} value */
312
+ function fingerprint(value) {
313
+ return value === undefined ? '<absent>' : JSON.stringify(canonicalize(value));
314
+ }
315
+
316
+ /** @param {unknown} value @returns {unknown} */
317
+ function canonicalize(value) {
318
+ if (Array.isArray(value)) return value.map(canonicalize);
319
+ if (!value || typeof value !== 'object') return value;
320
+ return Object.fromEntries(
321
+ Object.entries(/** @type {Record<string, unknown>} */ (value))
322
+ .sort(([left], [right]) => left.localeCompare(right))
323
+ .map(([key, item]) => [key, canonicalize(item)])
324
+ );
325
+ }
326
+
327
+ /** @param {ChangeClaim[]} claims @returns {Conflict[]} */
328
+ function listConflicts(claims) {
329
+ /** @type {Map<string, ChangeClaim[]>} */
330
+ const groups = new Map();
331
+ for (const claim of claims) {
332
+ const group = groups.get(claim.resource) ?? [];
333
+ const previous = group.findIndex(item => item.source === claim.source);
334
+ if (previous === -1) group.push(claim);
335
+ else group[previous] = claim;
336
+ groups.set(claim.resource, group);
337
+ }
338
+
339
+ /** @type {Conflict[]} */
340
+ const conflicts = [];
341
+ for (const [, group] of [...groups].sort(([left], [right]) => left.localeCompare(right))) {
342
+ if (group.length < 2 || new Set(group.map(item => item.fingerprint)).size < 2) continue;
343
+ const first = group[0];
344
+ const sources = group.map(item => item.source);
345
+ if (first.kind === 'dependency') {
346
+ conflicts.push({
347
+ kind: 'dependency-version',
348
+ key: first.key,
349
+ sources,
350
+ message: `Dependency "${first.key}" is requested with different placements or versions`,
351
+ });
352
+ } else if (first.kind === 'script') {
353
+ conflicts.push({
354
+ kind: 'script-value',
355
+ key: first.key,
356
+ sources,
357
+ message: `Script "${first.key}" is set to different commands`,
358
+ });
359
+ } else if (first.kind === 'package-json') {
360
+ conflicts.push({
361
+ kind: 'package-json-value',
362
+ key: first.key,
363
+ sources,
364
+ message: `package.json property "${first.key}" is set to different values`,
365
+ });
366
+ } else {
367
+ const deletes = group.some(item => item.value === undefined || item.value === null);
368
+ conflicts.push({
369
+ kind: deletes ? 'write-vs-delete' : 'write-conflict',
370
+ path: first.path,
371
+ sources,
372
+ message: deletes
373
+ ? `"${first.path}" is both written and deleted in the same run`
374
+ : `Multiple package operations write different content to "${first.path}"`,
375
+ });
376
+ }
377
+ }
378
+ return conflicts;
379
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { toFormattedJson } from 'pkgbld/options';
5
+
6
+ import { isLockPackageName } from './package-names.js';
7
+
8
+ export const LOCK_FILE = '.pkgbld-lock.json';
9
+ export const LOCK_SCHEMA = 'https://unpkg.com/create-pkgbld/lock-schema-v1.json';
10
+ const EXACT_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
11
+
12
+ /** @typedef {{ $schema: string, packages: Record<string, string> }} ProjectLock */
13
+
14
+ /** @param {string} projectRoot @returns {Promise<ProjectLock | null>} */
15
+ export async function readProjectLock(projectRoot) {
16
+ let value;
17
+ try {
18
+ value = JSON.parse(await readFile(path.join(projectRoot, LOCK_FILE), 'utf8'));
19
+ } catch (/** @type {any} */ error) {
20
+ if (error.code === 'ENOENT') return null;
21
+ throw new Error(`Cannot read ${LOCK_FILE}: ${error.message}`);
22
+ }
23
+ validateProjectLock(value);
24
+ return value;
25
+ }
26
+
27
+ /** @param {unknown} value */
28
+ export function validateProjectLock(value) {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${LOCK_FILE}: expected an object`);
30
+ const lock = /** @type {Record<string, any>} */ (value);
31
+ if (lock.$schema !== LOCK_SCHEMA) throw new Error(`Invalid ${LOCK_FILE}: unsupported $schema`);
32
+ if (!lock.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
33
+ throw new Error(`Invalid ${LOCK_FILE}: expected a packages object`);
34
+ }
35
+ const extra = Object.keys(lock).filter(key => key !== '$schema' && key !== 'packages');
36
+ if (extra.length > 0) throw new Error(`Invalid ${LOCK_FILE}: unexpected property "${extra[0]}"`);
37
+ for (const [packageName, version] of Object.entries(lock.packages)) {
38
+ if (!isLockPackageName(packageName)) throw new Error(`Invalid ${LOCK_FILE}: unsupported package name "${packageName}"`);
39
+ if (typeof version !== 'string' || !EXACT_VERSION_RE.test(version)) {
40
+ throw new Error(`Invalid ${LOCK_FILE}: "${packageName}" must use an exact version`);
41
+ }
42
+ }
43
+ }
44
+
45
+ /** @param {import('./tree.js').Tree} tree @returns {ProjectLock | null} */
46
+ export function readProjectLockFromTree(tree) {
47
+ const value = tree.readJson(LOCK_FILE);
48
+ if (value === null) return null;
49
+ validateProjectLock(value);
50
+ return value;
51
+ }
52
+
53
+ /** @param {import('./tree.js').Tree} tree @param {Record<string, string>} packages */
54
+ export function writeProjectLock(tree, packages) {
55
+ const sorted = Object.fromEntries(Object.entries(packages).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
56
+ tree.write(LOCK_FILE, toFormattedJson({ $schema: LOCK_SCHEMA, packages: sorted }, tree.read(LOCK_FILE)));
57
+ }
58
+
59
+ /** @param {import('./tree.js').Tree} tree @param {string} packageName @param {string} version */
60
+ export function setLockedPackage(tree, packageName, version) {
61
+ if (!isLockPackageName(packageName)) throw new Error(`Cannot lock unsupported package name "${packageName}"`);
62
+ if (!EXACT_VERSION_RE.test(version)) throw new Error(`Cannot lock "${packageName}": version "${version}" is not exact`);
63
+ const lock = readProjectLockFromTree(tree) ?? { $schema: LOCK_SCHEMA, packages: {} };
64
+ writeProjectLock(tree, { ...lock.packages, [packageName]: version });
65
+ }
66
+
67
+ /** @param {import('./tree.js').Tree} tree @param {string} packageName */
68
+ export function removeLockedPackage(tree, packageName) {
69
+ const lock = readProjectLockFromTree(tree);
70
+ if (!lock || !(packageName in lock.packages)) return;
71
+ const packages = { ...lock.packages };
72
+ delete packages[packageName];
73
+ writeProjectLock(tree, packages);
74
+ }
@@ -0,0 +1,184 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ import semver from 'semver';
6
+
7
+ import {
8
+ getExtensionCacheDir,
9
+ getExtensionCacheSlot,
10
+ getPackageName,
11
+ installCachedExtension,
12
+ listCachedExtensionSlots,
13
+ } from './extension-cache.js';
14
+ import { readResolvedPackage } from './package-resolution.js';
15
+
16
+ /**
17
+ * @typedef {{ name: string, package: string, version?: string, description: string, tags?: string[], official?: boolean }} ExtensionEntry
18
+ * @typedef {import('./tree.js').Tree} Tree
19
+ * @typedef {import('./types.js').Option} Option
20
+ * @typedef {import('./types.js').OptionsValue} OptionsValue
21
+ *
22
+ * @typedef {{
23
+ * dependencies?: Record<string, string>,
24
+ * devDependencies?: Record<string, string>,
25
+ * scripts?: Record<string, string>,
26
+ * files?: Record<string, string>,
27
+ * packageJson?: Record<string, unknown>
28
+ * }} SetupDeclarative
29
+ *
30
+ * @typedef {{
31
+ * dependencies?: string[],
32
+ * devDependencies?: string[],
33
+ * scripts?: string[],
34
+ * files?: string[]
35
+ * }} RemoveDeclarative
36
+ *
37
+ * @typedef {{
38
+ * manifest: { name: string, description: string, tags?: string[] },
39
+ * setup?: SetupDeclarative | ((tree: Tree, options: OptionsValue) => Promise<void>),
40
+ * remove?: RemoveDeclarative | ((tree: Tree, options: OptionsValue) => Promise<void>),
41
+ * update?: (tree: Tree, context: any, options: OptionsValue) => Promise<void>,
42
+ * detect?: (tree: Tree) => boolean,
43
+ * prompts?: (tree: Tree, context?: any) => Option[],
44
+ * __baseDir?: string,
45
+ * __packageVersion?: string,
46
+ * __packageManifest?: Record<string, any>
47
+ * }} Extension
48
+ */
49
+
50
+ /**
51
+ * Load a registry file.
52
+ *
53
+ * @param {string} builtinPath - absolute path to the built-in registry JSON
54
+ * @param {boolean} [builtinOfficial] - whether the primary registry is maintained by create-pkgbld
55
+ * @returns {Promise<ExtensionEntry[]>}
56
+ */
57
+ export async function loadRegistry(builtinPath, builtinOfficial = true) {
58
+ const builtin = await readRegistryFile(builtinPath);
59
+ return builtin.map(entry => ({ ...entry, official: builtinOfficial }));
60
+ }
61
+
62
+ /**
63
+ * @param {string} file
64
+ * @returns {Promise<ExtensionEntry[]>}
65
+ */
66
+ async function readRegistryFile(file) {
67
+ const raw = await readFile(file, 'utf8');
68
+ const data = JSON.parse(raw);
69
+ if (!data || !Array.isArray(data.extensions)) {
70
+ throw new Error(`Invalid registry file ${file}: missing "extensions" array`);
71
+ }
72
+ return data.extensions;
73
+ }
74
+
75
+ /**
76
+ * Dynamically import an extension package and normalize its named exports
77
+ * into an Extension object. Resolution prefers the project's node_modules
78
+ * before create-pkgbld's own resolution.
79
+ *
80
+ * @param {ExtensionEntry} entry
81
+ * @param {string} projectRoot
82
+ * @param {{ install?: boolean, exactVersion?: string, resolveVersion?: (packageName: string, selector: string) => Promise<string> }} [options]
83
+ * @returns {Promise<Extension>}
84
+ */
85
+ export async function resolveExtension(entry, projectRoot, options = {}) {
86
+ const specifier = entry.package;
87
+ const packageName = getPackageName(specifier);
88
+ let resolved;
89
+ const projectManifest = path.join(projectRoot, 'package.json');
90
+ try {
91
+ resolved = createRequire(projectManifest).resolve(specifier);
92
+ if (!hasExpectedVersion(resolved, packageName, options.exactVersion, entry.version)) resolved = undefined;
93
+ } catch {
94
+ // Fall back to the shared cache and create-pkgbld's dependencies.
95
+ }
96
+
97
+ if (!resolved && packageName) {
98
+ const slots = options.exactVersion
99
+ ? [{ cacheDir: getExtensionCacheSlot(packageName, options.exactVersion), version: options.exactVersion }]
100
+ : await listCachedExtensionSlots(packageName, entry.version);
101
+ for (const slot of slots) {
102
+ try {
103
+ resolved = createRequire(path.join(slot.cacheDir, 'package.json')).resolve(specifier);
104
+ if (hasExpectedVersion(resolved, packageName, options.exactVersion, entry.version)) break;
105
+ resolved = undefined;
106
+ } catch {
107
+ // Try the next cached exact version.
108
+ }
109
+ }
110
+ }
111
+
112
+ // Read caches created by the pre-versioned layout. New installations are
113
+ // always written to exact-version slots.
114
+ if (!resolved && packageName) {
115
+ try {
116
+ resolved = createRequire(path.join(getExtensionCacheDir(), 'package.json')).resolve(specifier);
117
+ if (!hasExpectedVersion(resolved, packageName, options.exactVersion, entry.version)) resolved = undefined;
118
+ } catch {
119
+ // Continue to bundled resolution or installation.
120
+ }
121
+ }
122
+
123
+ if (!resolved) {
124
+ try {
125
+ resolved = createRequire(import.meta.url).resolve(specifier);
126
+ if (!hasExpectedVersion(resolved, packageName, options.exactVersion, entry.version)) resolved = undefined;
127
+ } catch {
128
+ // Installation may provide the package below.
129
+ }
130
+ }
131
+
132
+ if (!resolved && options.install && entry.official && packageName) {
133
+ const installed = await installCachedExtension(
134
+ { ...entry, version: options.exactVersion ?? entry.version },
135
+ getExtensionCacheDir(),
136
+ undefined,
137
+ options.resolveVersion
138
+ );
139
+ resolved = createRequire(path.join(installed.cacheDir, 'package.json')).resolve(specifier);
140
+ }
141
+ if (!resolved) {
142
+ const hint = entry.official && packageName ? ' Select it to download it to the shared cache.' : '';
143
+ throw new Error(`Cannot resolve extension package "${specifier}" for "${entry.name}".${hint}`);
144
+ }
145
+
146
+ const mod = await import(resolved);
147
+ const ext = normalizeModule(mod);
148
+ ext.__baseDir = path.dirname(resolved);
149
+ if (packageName) {
150
+ const pkg = readResolvedPackage(resolved, packageName);
151
+ ext.__packageVersion = pkg?.version;
152
+ ext.__packageManifest = pkg?.manifest;
153
+ }
154
+ if (!ext.manifest) {
155
+ throw new Error(`Extension "${entry.name}" (${specifier}) does not export a "manifest"`);
156
+ }
157
+ return ext;
158
+ }
159
+
160
+ /** @param {string} resolved @param {string | null} packageName @param {string | undefined} exactVersion @param {string | undefined} selector */
161
+ function hasExpectedVersion(resolved, packageName, exactVersion, selector) {
162
+ if (!packageName) return true;
163
+ const version = readResolvedPackage(resolved, packageName)?.version;
164
+ if (!version) return false;
165
+ if (exactVersion) return version === exactVersion;
166
+ if (selector && semver.validRange(selector)) return semver.satisfies(version, selector);
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * @param {any} mod
172
+ * @returns {Extension}
173
+ */
174
+ function normalizeModule(mod) {
175
+ const source = mod && typeof mod === 'object' && mod.default && typeof mod.default === 'object' ? { ...mod.default, ...mod } : mod;
176
+ return {
177
+ manifest: source.manifest,
178
+ setup: source.setup,
179
+ remove: source.remove,
180
+ update: source.update,
181
+ detect: source.detect,
182
+ prompts: source.prompts,
183
+ };
184
+ }