bun-nested-workspace 0.0.0-stage → 0.2.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,825 @@
1
+ import JSON5 from "json5";
2
+ import path from "node:path";
3
+ // ../lockfile/src/parse.ts
4
+ function isObject(value) {
5
+ return typeof value === "object" && value !== null;
6
+ }
7
+ function isGitPackageEntry(entry) {
8
+ return isObject(entry[1]);
9
+ }
10
+ function packageEntryMeta(entry) {
11
+ if (isGitPackageEntry(entry)) return entry[1];
12
+ const maybeMeta = entry[2];
13
+ return isObject(maybeMeta) ? maybeMeta : void 0;
14
+ }
15
+ /**
16
+ * Splits Bun's resolved package spec (`name@version`) using the last `@`, so
17
+ * scoped package names such as `@scope/name@1.0.0` are handled correctly.
18
+ */
19
+ function parseResolvedSpec(spec) {
20
+ const atIndex = spec.lastIndexOf("@");
21
+ if (atIndex <= 0 || atIndex >= spec.length - 1) return null;
22
+ const name = spec.slice(0, atIndex);
23
+ const version = spec.slice(atIndex + 1);
24
+ if (!name || !version) return null;
25
+ return {
26
+ name,
27
+ version
28
+ };
29
+ }
30
+ function parseBunLock(lockText) {
31
+ const parsed = JSON5.parse(lockText);
32
+ if (!isObject(parsed)) throw new Error("bun.lock must parse to an object");
33
+ const overrides = parsed.overrides;
34
+ if (overrides !== void 0) {
35
+ if (!isObject(overrides) || Array.isArray(overrides)) throw new Error("bun.lock overrides must be an object");
36
+ for (const [name, value] of Object.entries(overrides)) if (typeof value !== "string") throw new Error(`bun.lock override for ${JSON.stringify(name)} must be a string`);
37
+ }
38
+ return parsed;
39
+ }
40
+ // ../lockfile/src/overrides.ts
41
+ /**
42
+ * Read the exact bare-name override Bun stores for a dependency.
43
+ *
44
+ * Bun 1.3.14 ignores selector keys and omits unsupported nested/path forms,
45
+ * so matching those here would diverge from the package manager.
46
+ */
47
+ function dependencyOverrideRange(lock, dependencyName) {
48
+ const overrides = lock.overrides;
49
+ if (!overrides) return void 0;
50
+ if (!Object.hasOwn(overrides, dependencyName)) return void 0;
51
+ return overrides[dependencyName];
52
+ }
53
+ /**
54
+ * Return the range Bun actually enforces for a dependency request.
55
+ *
56
+ * An override remains authoritative even when it is not valid semver;
57
+ * compatibility then stays unknown instead of falling back to the declaration.
58
+ */
59
+ function effectiveDependencyRange(lock, dependencyName, declaredRange) {
60
+ const overrideRange = dependencyOverrideRange(lock, dependencyName);
61
+ return overrideRange === void 0 ? declaredRange : overrideRange;
62
+ }
63
+ // ../lockfile/src/render.ts
64
+ function isRecord$1(value) {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+ function renderInlineValue(value) {
68
+ if (Array.isArray(value)) return `[${value.map((item) => renderInlineValue(item)).join(", ")}]`;
69
+ if (isRecord$1(value)) {
70
+ const entries = Object.entries(value).filter(([, item]) => item !== void 0).map(([key, item]) => `${JSON.stringify(key)}: ${renderInlineValue(item)}`);
71
+ if (entries.length === 0) return "{}";
72
+ return `{ ${entries.join(", ")} }`;
73
+ }
74
+ const serialized = JSON.stringify(value);
75
+ return serialized === void 0 ? "null" : serialized;
76
+ }
77
+ function renderPackagesPropertyLines(key, value, indentLevel, trailingComma) {
78
+ const indent = " ".repeat(indentLevel);
79
+ const entryIndent = " ".repeat(indentLevel + 2);
80
+ const lines = [`${indent}${JSON.stringify(key)}: {`];
81
+ const packageEntries = Object.entries(value).filter(([, packageEntry]) => packageEntry !== void 0);
82
+ for (const [index, [lockKey, packageEntry]] of packageEntries.entries()) {
83
+ lines.push(`${entryIndent}${JSON.stringify(lockKey)}: ${renderInlineValue(packageEntry)},`);
84
+ if (index < packageEntries.length - 1) lines.push("");
85
+ }
86
+ lines.push(`${indent}}${trailingComma ? "," : ""}`);
87
+ return lines;
88
+ }
89
+ function renderObjectLines(value, indentLevel, allowPackagesFormatting) {
90
+ const indent = " ".repeat(indentLevel);
91
+ const propertyIndent = " ".repeat(indentLevel + 2);
92
+ const entries = Object.entries(value).filter(([, item]) => item !== void 0);
93
+ if (entries.length === 0) return [`${indent}{}`];
94
+ const lines = [`${indent}{`];
95
+ for (const [index, [key, item]] of entries.entries()) {
96
+ const trailingComma = index < entries.length - 1;
97
+ if (allowPackagesFormatting && key === "packages" && isRecord$1(item)) {
98
+ lines.push(...renderPackagesPropertyLines(key, item, indentLevel + 2, trailingComma));
99
+ continue;
100
+ }
101
+ if (isRecord$1(item)) {
102
+ const nestedLines = renderObjectLines(item, indentLevel + 2, false);
103
+ if (nestedLines.length === 1) {
104
+ const inlineObject = nestedLines[0]?.trimStart() ?? "{}";
105
+ lines.push(`${propertyIndent}${JSON.stringify(key)}: ${inlineObject},`);
106
+ continue;
107
+ }
108
+ lines.push(`${propertyIndent}${JSON.stringify(key)}: ${nestedLines[0]?.trimStart() ?? "{"}`);
109
+ lines.push(...nestedLines.slice(1, -1));
110
+ lines.push(`${nestedLines[nestedLines.length - 1]},`);
111
+ continue;
112
+ }
113
+ lines.push(`${propertyIndent}${JSON.stringify(key)}: ${renderInlineValue(item)},`);
114
+ }
115
+ lines.push(`${indent}}`);
116
+ return lines;
117
+ }
118
+ /** Render Bun package tuples inline while preserving lock key order. */
119
+ function renderBunLock(lock) {
120
+ return `${renderObjectLines(lock, 0, true).join("\n")}\n`;
121
+ }
122
+ // ../lockfile/src/resolve.ts
123
+ /**
124
+ * Tell nested dependency keys apart from root scoped package keys.
125
+ *
126
+ * For an unscoped dependency, `@scope/name` can match the `/name` suffix even
127
+ * though it is a root package key. A real nested key has another path segment,
128
+ * such as `@scope/requester/name`.
129
+ */
130
+ function isNestedDependencyLockKey(lockKey, dependencyName) {
131
+ if (!lockKey.endsWith(`/${dependencyName}`)) return false;
132
+ return dependencyName.startsWith("@") || !/^@[^/]+\/[^/]+$/.test(lockKey);
133
+ }
134
+ /**
135
+ * Resolve a dependency the way Bun lock keys are structured:
136
+ * nearest nested `requester/dependency` wins, then the closest
137
+ * ancestor-provided nested entry, then root `dependency`.
138
+ *
139
+ * This is the canonical lock-key resolution shared by graph building, update
140
+ * planning, and update safety checks; only the lock keys matter.
141
+ *
142
+ * @param requesterLockKey Package lock key or workspace name that owns the
143
+ * dependency. Use `undefined` for context-free resolution, which accepts a
144
+ * nested entry only when it is the unique candidate.
145
+ * @param dependencyName Real npm package name being resolved.
146
+ */
147
+ function resolveDependencyLockKey(requesterLockKey, dependencyName, packagesByLockKey) {
148
+ if (requesterLockKey) {
149
+ const names = splitLockKey(requesterLockKey);
150
+ for (let depth = names.length; depth > 0; depth--) {
151
+ const nestedKey = `${names.slice(0, depth).join("/")}/${dependencyName}`;
152
+ if (hasLockKey(packagesByLockKey, nestedKey)) return nestedKey;
153
+ }
154
+ }
155
+ if (hasLockKey(packagesByLockKey, dependencyName)) return dependencyName;
156
+ if (!requesterLockKey) {
157
+ let uniqueCandidate;
158
+ for (const key of getLockKeys(packagesByLockKey)) {
159
+ if (!isNestedDependencyLockKey(key, dependencyName)) continue;
160
+ if (uniqueCandidate) return;
161
+ uniqueCandidate = key;
162
+ }
163
+ return uniqueCandidate;
164
+ }
165
+ }
166
+ /** Split a lock key into the package names of its nesting chain, keeping scoped names whole. */
167
+ function splitLockKey(lockKey) {
168
+ return lockKey.match(/(?:@[^/]+\/)?[^/]+/g) ?? [];
169
+ }
170
+ function hasLockKey(packagesByLockKey, lockKey) {
171
+ return packagesByLockKey instanceof Map ? packagesByLockKey.has(lockKey) : Object.hasOwn(packagesByLockKey, lockKey);
172
+ }
173
+ function getLockKeys(packagesByLockKey) {
174
+ return packagesByLockKey instanceof Map ? packagesByLockKey.keys() : Object.keys(packagesByLockKey);
175
+ }
176
+ // src/errors.ts
177
+ /** Returns the message of a thrown value, which is not necessarily an `Error`. */
178
+ function getErrorMessage(error) {
179
+ return error instanceof Error ? error.message : String(error);
180
+ }
181
+ // src/paths.ts
182
+ /** Returns whether a POSIX path relative to the host root lies inside the nested directory. */
183
+ function isNestedPath(file, dir) {
184
+ return file === dir || file.startsWith(`${dir}/`);
185
+ }
186
+ // src/manifest.ts
187
+ /** All root fields in sync order; the default for `NestedWorkspaceConfig.fields`. */
188
+ const syncedFields = [
189
+ "catalog",
190
+ "catalogs",
191
+ "overrides",
192
+ "patchedDependencies",
193
+ "trustedDependencies"
194
+ ];
195
+ /** Field of the host `package.json` that holds `NestedWorkspaceConfig`. */
196
+ const configField = "bun-nested-workspace";
197
+ /** Reads and validates the `bun-nested-workspace` config of the host `package.json`. */
198
+ function readConfig(manifest) {
199
+ const config = manifest[configField];
200
+ if (typeof config !== "object" || config === null) throw new Error(`package.json has no "${configField}" field with the nested directory`);
201
+ if (typeof config.dir !== "string" || !config.dir) throw new Error(`"${configField}.dir" must be a non-empty string`);
202
+ const normalized = path.posix.normalize(config.dir);
203
+ const dir = normalized.replace(/\/+$/, "");
204
+ if (path.posix.isAbsolute(normalized) || !dir || dir === "." || dir === ".." || dir.startsWith("../")) throw new Error(`"${configField}.dir" must be a directory inside the host root, got "${config.dir}"`);
205
+ const fields = config.fields ?? syncedFields;
206
+ if (!Array.isArray(fields) || fields.some((field) => !syncedFields.includes(field))) throw new Error(`"${configField}.fields" must be an array of ${syncedFields.join(", ")}, got ${JSON.stringify(fields)}`);
207
+ return {
208
+ dir,
209
+ fields,
210
+ synced: config.synced ?? {}
211
+ };
212
+ }
213
+ /**
214
+ * Returns the catalogs of a root `package.json`.
215
+ *
216
+ * Bun reads catalogs either from `workspaces` or from the top level, so a field defined in both places is rejected.
217
+ */
218
+ function getCatalogs(manifest) {
219
+ const workspaces = Array.isArray(manifest.workspaces) ? void 0 : manifest.workspaces;
220
+ for (const field of ["catalog", "catalogs"]) if (manifest[field] && workspaces?.[field]) throw new Error(`package.json defines both "${field}" and "workspaces.${field}"`);
221
+ return {
222
+ catalog: manifest.catalog ?? workspaces?.catalog,
223
+ catalogs: manifest.catalogs ?? workspaces?.catalogs
224
+ };
225
+ }
226
+ /**
227
+ * Returns a copy of the manifest with the catalog fields replaced.
228
+ *
229
+ * Each catalog field stays where the manifest keeps it.
230
+ * A new field goes to the top level when the manifest keeps the other catalog field there, and to `workspaces` otherwise.
231
+ */
232
+ function setCatalogs(manifest, catalogs) {
233
+ const result = { ...manifest };
234
+ const workspaces = Array.isArray(manifest.workspaces) ? { packages: manifest.workspaces } : { ...manifest.workspaces };
235
+ for (const [field, other] of [["catalog", "catalogs"], ["catalogs", "catalog"]]) {
236
+ const target = field in manifest || !(field in workspaces) && other in manifest ? result : workspaces;
237
+ if (catalogs[field] !== void 0 && Object.keys(catalogs[field]).length) target[field] = catalogs[field];
238
+ else delete target[field];
239
+ }
240
+ if (manifest.workspaces !== void 0 || Object.keys(workspaces).length) result.workspaces = Array.isArray(manifest.workspaces) && Object.keys(workspaces).length === 1 ? workspaces.packages : workspaces;
241
+ return result;
242
+ }
243
+ /**
244
+ * Returns the host root `package.json` with the configured root fields of the nested `package.json` merged in.
245
+ *
246
+ * A nested entry replaces a host entry with the same name, while the other host entries stay.
247
+ * Entries synced earlier and dropped by the nested repository are removed.
248
+ * Throws when the host and the nested repository patch the same package version,
249
+ * and when either side defines `resolutions` while `overrides` are synced.
250
+ */
251
+ function syncManifest(host, nested) {
252
+ const config = readConfig(host);
253
+ const overridden = [];
254
+ const synced = { ...config.synced };
255
+ let manifest = { ...host };
256
+ const hostCatalogs = getCatalogs(host);
257
+ const nestedCatalogs = getCatalogs(nested);
258
+ const catalogs = { ...hostCatalogs };
259
+ if (config.fields.includes("catalog")) {
260
+ const merged = mergeEntries("catalog", hostCatalogs.catalog, nestedCatalogs.catalog, synced.catalog);
261
+ catalogs.catalog = merged.entries;
262
+ synced.catalog = merged.synced;
263
+ overridden.push(...merged.overridden);
264
+ }
265
+ if (config.fields.includes("catalogs")) {
266
+ const names = /* @__PURE__ */ new Set([
267
+ ...Object.keys(hostCatalogs.catalogs ?? {}),
268
+ ...Object.keys(nestedCatalogs.catalogs ?? {}),
269
+ ...Object.keys(synced.catalogs ?? {})
270
+ ]);
271
+ const entries = {};
272
+ const syncedCatalogs = {};
273
+ for (const name of [...names].sort()) {
274
+ const merged = mergeEntries(`catalogs.${name}`, hostCatalogs.catalogs?.[name], nestedCatalogs.catalogs?.[name], synced.catalogs?.[name]);
275
+ entries[name] = merged.entries;
276
+ syncedCatalogs[name] = merged.synced;
277
+ overridden.push(...merged.overridden);
278
+ }
279
+ catalogs.catalogs = omitEmpty(entries);
280
+ synced.catalogs = omitEmpty(syncedCatalogs);
281
+ }
282
+ manifest = setCatalogs(manifest, catalogs);
283
+ if (config.fields.includes("overrides")) {
284
+ for (const [source, resolutions] of [["The host package.json", host.resolutions], [`${config.dir}/package.json`, nested.resolutions]]) if (resolutions && Object.keys(resolutions).length) throw new Error(`${source} defines "resolutions", which Bun ignores once "overrides" is defined; move its entries to "overrides"`);
285
+ const merged = mergeEntries("overrides", host.overrides, nested.overrides, synced.overrides);
286
+ manifest.overrides = merged.entries;
287
+ synced.overrides = merged.synced;
288
+ overridden.push(...merged.overridden);
289
+ }
290
+ if (config.fields.includes("patchedDependencies")) manifest.patchedDependencies = mergePatches(host.patchedDependencies, nested.patchedDependencies, config.dir);
291
+ if (config.fields.includes("trustedDependencies")) {
292
+ const toEntries = (names = []) => Object.fromEntries(names.map((name) => [name, name]));
293
+ const merged = mergeEntries("trustedDependencies", toEntries(host.trustedDependencies), toEntries(nested.trustedDependencies), synced.trustedDependencies);
294
+ if (host.trustedDependencies !== void 0 || nested.trustedDependencies !== void 0) manifest.trustedDependencies = Object.keys(merged.entries);
295
+ synced.trustedDependencies = merged.synced;
296
+ }
297
+ for (const field of ["overrides", "patchedDependencies"]) {
298
+ const value = manifest[field];
299
+ if (config.fields.includes(field) && value !== void 0 && !Object.keys(value).length) delete manifest[field];
300
+ }
301
+ const { synced: _, ...rest } = host[configField];
302
+ const syncedEntries = omitEmpty(synced);
303
+ manifest[configField] = Object.keys(syncedEntries).length ? {
304
+ ...rest,
305
+ synced: syncedEntries
306
+ } : rest;
307
+ return {
308
+ manifest,
309
+ overridden,
310
+ addedTrustedDependencies: host.trustedDependencies === void 0 && manifest.trustedDependencies !== void 0
311
+ };
312
+ }
313
+ /**
314
+ * Merges name-keyed entries of one field.
315
+ *
316
+ * Host entries outside `synced` are the host's own.
317
+ * An own entry equal to the nested one stays own, so it survives if the nested repository drops it later.
318
+ */
319
+ function mergeEntries(field, host = {}, nested = {}, synced = []) {
320
+ const own = Object.fromEntries(Object.entries(host).filter(([name]) => !synced.includes(name)));
321
+ const overridden = Object.entries(nested).filter(([name, value]) => name in own && own[name] !== value).map(([name, nestedValue]) => ({
322
+ field,
323
+ name,
324
+ hostValue: own[name],
325
+ nestedValue
326
+ }));
327
+ return {
328
+ entries: sortKeys({
329
+ ...own,
330
+ ...nested
331
+ }),
332
+ synced: Object.keys(nested).filter((name) => own[name] !== nested[name]).sort(),
333
+ overridden
334
+ };
335
+ }
336
+ /** Merges host patches with the nested patches, rewriting nested patch paths through the nested directory. */
337
+ function mergePatches(host = {}, nested = {}, dir) {
338
+ const own = Object.entries(host).filter(([, patch]) => !isNestedPath(path.posix.normalize(patch), dir));
339
+ const relocated = Object.entries(nested).map(([key, patch]) => [key, path.posix.join(dir, patch)]);
340
+ const conflicts = own.filter(([key]) => key in nested).map(([key]) => key);
341
+ if (conflicts.length) throw new Error(`Both the host and ${dir} patch ${conflicts.join(", ")}; keep a single patch for each package version`);
342
+ return sortKeys(Object.fromEntries([...own, ...relocated]));
343
+ }
344
+ function sortKeys(record) {
345
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
346
+ }
347
+ /** Drops empty arrays and objects from a record. */
348
+ function omitEmpty(record) {
349
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0 && Object.keys(value).length));
350
+ }
351
+ /**
352
+ * Returns the differences of the synced root fields and the synced entry lists between two manifests.
353
+ *
354
+ * Key order and the order of `trustedDependencies` are ignored.
355
+ */
356
+ function diffManifests(expected, actual) {
357
+ const { fields } = readConfig(expected);
358
+ const pick = (manifest) => {
359
+ const catalogs = getCatalogs(manifest);
360
+ const values = {
361
+ catalog: catalogs.catalog,
362
+ catalogs: catalogs.catalogs,
363
+ overrides: manifest.overrides,
364
+ patchedDependencies: manifest.patchedDependencies,
365
+ trustedDependencies: manifest.trustedDependencies && [...manifest.trustedDependencies].sort()
366
+ };
367
+ return {
368
+ ...Object.fromEntries(fields.map((field) => [field, values[field]])),
369
+ [`${configField}.synced`]: manifest[configField]?.synced
370
+ };
371
+ };
372
+ return diffValues("", pick(expected), pick(actual));
373
+ }
374
+ function diffValues(prefix, expected, actual) {
375
+ if (isRecord(expected) && isRecord(actual)) return [.../* @__PURE__ */ new Set([...Object.keys(expected), ...Object.keys(actual)])].sort().flatMap((key) => diffValues(prefix ? `${prefix}.${key}` : key, expected[key], actual[key]));
376
+ if (isRecord(expected) && actual === void 0) return diffValues(prefix, expected, {});
377
+ if (expected === void 0 && isRecord(actual)) return diffValues(prefix, {}, actual);
378
+ return JSON.stringify(expected) === JSON.stringify(actual) ? [] : [{
379
+ path: prefix,
380
+ expected,
381
+ actual
382
+ }];
383
+ }
384
+ function isRecord(value) {
385
+ return typeof value === "object" && value !== null && !Array.isArray(value);
386
+ }
387
+ // src/lockfile.ts
388
+ /**
389
+ * `bun.lock` format versions this package understands.
390
+ *
391
+ * The format is internal to Bun, so other versions are rejected instead of being rewritten blindly.
392
+ */
393
+ const supportedLockfileVersions = [1, 2];
394
+ /** Parses `bun.lock` text and rejects lockfile versions outside `supportedLockfileVersions`. */
395
+ function parseLockfile(text, source = "bun.lock") {
396
+ let lock;
397
+ try {
398
+ lock = parseBunLock(text);
399
+ } catch (error) {
400
+ throw new Error(`${source}: ${getErrorMessage(error)}`, { cause: error });
401
+ }
402
+ if (!supportedLockfileVersions.includes(lock.lockfileVersion)) throw new Error(`${source} has lockfileVersion ${lock.lockfileVersion}, supported versions: ${supportedLockfileVersions.join(", ")}`);
403
+ return {
404
+ ...lock,
405
+ workspaces: lock.workspaces ?? {},
406
+ packages: lock.packages ?? {}
407
+ };
408
+ }
409
+ /** Reads and parses a `bun.lock` file. */
410
+ async function readLockfile(file) {
411
+ return parseLockfile(await Bun.file(file).text(), file);
412
+ }
413
+ /**
414
+ * Returns the nested lockfile with workspace and local paths relative to the host root.
415
+ *
416
+ * The nested root workspace is dropped, because it is not a workspace of the host.
417
+ */
418
+ function relocateLockfile(lock, dir) {
419
+ return {
420
+ ...lock,
421
+ workspaces: Object.fromEntries(Object.entries(lock.workspaces).filter(([workspacePath]) => workspacePath).map(([workspacePath, workspace]) => [path.posix.join(dir, workspacePath), workspace])),
422
+ packages: Object.fromEntries(Object.entries(lock.packages).map(([key, [resolution, ...rest]]) => [key, [relocateResolution(resolution, dir), ...rest]]))
423
+ };
424
+ }
425
+ /** Rewrites a root-relative `workspace:`, `file:` or `link:` resolution through the nested directory. */
426
+ function relocateResolution(resolution, dir) {
427
+ const match = /^((?:@[^/]+\/)?[^@]+)@(workspace|file|link):(.*)$/.exec(resolution);
428
+ if (!match) return resolution;
429
+ const [, name, protocol, target] = match;
430
+ if (path.posix.isAbsolute(target)) return resolution;
431
+ const relocated = path.posix.join(dir, target);
432
+ return `${name}@${protocol}:${target.startsWith("./") ? `./${relocated}` : relocated}`;
433
+ }
434
+ /**
435
+ * Returns the lockfile with root fields that affect resolution taken from the root `package.json`.
436
+ *
437
+ * `bun install` re-resolves dependencies whose catalog entry, override, or patch differs from the lockfile,
438
+ * so the merged lockfile must already record the synced root fields to keep the nested resolutions.
439
+ */
440
+ function applyManifestToLockfile(lock, manifest) {
441
+ const { catalog, catalogs } = getCatalogs(manifest);
442
+ const fields = {
443
+ catalog,
444
+ catalogs,
445
+ overrides: manifest.overrides,
446
+ patchedDependencies: manifest.patchedDependencies
447
+ };
448
+ const result = { ...lock };
449
+ for (const [field, value] of Object.entries(fields)) if (value && Object.keys(value).length) Object.assign(result, { [field]: value });
450
+ else delete result[field];
451
+ return result;
452
+ }
453
+ /**
454
+ * Merges the host lockfile with the relocated nested lockfile.
455
+ *
456
+ * Nested entries override host entries, so the nested packages keep the versions of the nested lockfile.
457
+ * A root entry overridden by the nested lockfile might not satisfy a dependency of a host package:
458
+ * such a dependency gets back its previous host resolution as a nested entry, so `bun install` keeps the host version
459
+ * instead of resolving the range again.
460
+ * A host workspace without a name cannot have nested entries, so `bun install` resolves its dependency again.
461
+ * When the previous host resolution does not satisfy the range either, which Bun lockfiles occasionally contain,
462
+ * the dependency gets the highest satisfying version recorded in either lockfile.
463
+ * Catalog dependencies are checked against the catalogs of the host lockfile, so apply the synced root fields
464
+ * to it first.
465
+ */
466
+ function mergeLockfiles(lock, nestedLock, dir) {
467
+ const hostPackages = Object.entries(lock.packages).filter(([key]) => !(splitPackageKey(key)[0] in nestedLock.packages));
468
+ const packages = {
469
+ ...Object.fromEntries(hostPackages),
470
+ ...nestedLock.packages
471
+ };
472
+ const hostWorkspaces = Object.entries(lock.workspaces).filter(([workspacePath]) => !isNestedPath(workspacePath, dir));
473
+ const dependents = [...hostWorkspaces.map(([, workspace]) => ({
474
+ key: workspace.name,
475
+ dependencies: getWorkspaceDependencies(workspace)
476
+ })), ...Object.keys(packages).map((key) => ({
477
+ key,
478
+ dependencies: getPackageDependencies(packages[key])
479
+ }))];
480
+ for (let index = 0; index < dependents.length; index++) {
481
+ const { key, dependencies } = dependents[index];
482
+ if (key === void 0) continue;
483
+ for (const [dependency, specifier] of Object.entries(dependencies)) {
484
+ const requirement = getRequirement(lock, dependency, specifier);
485
+ const resolved = resolveDependencyLockKey(key, dependency, packages);
486
+ if (!requirement || !resolved || satisfies(packages[resolved], requirement)) continue;
487
+ const previous = resolveDependencyLockKey(key, dependency, lock.packages);
488
+ const replacement = previous && satisfies(lock.packages[previous], requirement) ? lock.packages[previous] : findSatisfyingEntry([packages, lock.packages], requirement);
489
+ if (!replacement) continue;
490
+ const nestedKey = `${key}/${dependency}`;
491
+ packages[nestedKey] = replacement;
492
+ dependents.push({
493
+ key: nestedKey,
494
+ dependencies: getPackageDependencies(replacement)
495
+ });
496
+ }
497
+ }
498
+ return {
499
+ ...lock,
500
+ lockfileVersion: Math.max(lock.lockfileVersion, nestedLock.lockfileVersion),
501
+ workspaces: {
502
+ ...Object.fromEntries(hostWorkspaces),
503
+ ...nestedLock.workspaces
504
+ },
505
+ packages
506
+ };
507
+ }
508
+ /** Returns the installed dependencies declared by a lockfile workspace. */
509
+ function getWorkspaceDependencies(workspace) {
510
+ return {
511
+ ...workspace.dependencies,
512
+ ...workspace.devDependencies,
513
+ ...workspace.optionalDependencies
514
+ };
515
+ }
516
+ /** Returns the dependencies declared by a lockfile package entry. */
517
+ function getPackageDependencies(entry) {
518
+ const metadata = packageEntryMeta(entry);
519
+ return {
520
+ ...metadata?.dependencies,
521
+ ...metadata?.optionalDependencies
522
+ };
523
+ }
524
+ /**
525
+ * Returns the package and version range that a dependency specifier requires.
526
+ *
527
+ * An override replaces the specifier, as `bun install` applies it to direct and transitive dependencies alike.
528
+ * Returns `undefined` for specifiers that do not resolve by version, such as `workspace:` or git URLs.
529
+ */
530
+ function getRequirement(lock, dependency, specifier) {
531
+ return parseRequirement(lock, dependency, effectiveDependencyRange(lock, dependency, specifier));
532
+ }
533
+ /** Resolves `catalog:` specifiers and `npm:` aliases of `getRequirement`. */
534
+ function parseRequirement(lock, dependency, specifier) {
535
+ if (specifier.startsWith("catalog:")) {
536
+ const catalogName = specifier.slice(8);
537
+ const range = (catalogName && catalogName !== "default" ? lock.catalogs?.[catalogName] : lock.catalog)?.[dependency];
538
+ return range === void 0 ? void 0 : parseRequirement(lock, dependency, range);
539
+ }
540
+ if (specifier.startsWith("npm:")) {
541
+ const target = specifier.slice(4);
542
+ const separator = target.lastIndexOf("@");
543
+ return separator > 0 ? {
544
+ name: target.slice(0, separator),
545
+ range: target.slice(separator + 1)
546
+ } : {
547
+ name: target,
548
+ range: "*"
549
+ };
550
+ }
551
+ return /^[a-z+]+:|\//i.test(specifier) ? void 0 : {
552
+ name: dependency,
553
+ range: specifier
554
+ };
555
+ }
556
+ /** Returns the entry with the highest version of the required package that satisfies its range. */
557
+ function findSatisfyingEntry(sources, requirement) {
558
+ let best;
559
+ for (const entry of sources.flatMap((packages) => Object.values(packages))) if (satisfies(entry, requirement) && (!best || Bun.semver.order(getVersion(entry), getVersion(best)) > 0)) best = entry;
560
+ return best;
561
+ }
562
+ function getVersion([resolution]) {
563
+ return resolution.slice(resolution.lastIndexOf("@") + 1);
564
+ }
565
+ /** Returns whether an entry resolves the required package to a version in its range. */
566
+ function satisfies(entry, requirement) {
567
+ const spec = parseResolvedSpec(entry[0]);
568
+ return spec?.name === requirement.name && Bun.semver.satisfies(spec.version, requirement.range);
569
+ }
570
+ /**
571
+ * Returns whether a lockfile entry satisfies the specifier of a dependency on it.
572
+ *
573
+ * Specifiers that do not resolve by version, such as `workspace:` or git URLs, are always satisfied.
574
+ */
575
+ function satisfiesSpecifier(lock, dependency, specifier, entry) {
576
+ const requirement = getRequirement(lock, dependency, specifier);
577
+ return requirement === void 0 || satisfies(entry, requirement);
578
+ }
579
+ /** Splits a lockfile package key into the package names of its nesting chain. */
580
+ function splitPackageKey(key) {
581
+ return key.match(/(?:@[^/]+\/)?[^/]+/g) ?? [];
582
+ }
583
+ /**
584
+ * Returns the key of the entry that resolves a direct dependency of a workspace package.
585
+ *
586
+ * Bun nests workspace-specific entries under the workspace name, so a workspace without a name resolves at the root only.
587
+ */
588
+ function resolveWorkspaceDependencyKey(packages, workspaceName, dependency) {
589
+ if (workspaceName) return resolveDependencyLockKey(workspaceName, dependency, packages);
590
+ return Object.hasOwn(packages, dependency) ? dependency : void 0;
591
+ }
592
+ /** Returns the resolution of a direct dependency of a workspace package, such as `name@1.2.3`. */
593
+ function resolveDependency(lock, workspaceName, dependency) {
594
+ const key = resolveWorkspaceDependencyKey(lock.packages, workspaceName, dependency);
595
+ return key === void 0 ? void 0 : lock.packages[key][0];
596
+ }
597
+ // src/check.ts
598
+ /** Returns the nested workspace paths that the host `workspaces` patterns do not include. */
599
+ function findUncoveredWorkspaces(manifest, nestedLock) {
600
+ const patterns = (Array.isArray(manifest.workspaces) ? manifest.workspaces : manifest.workspaces?.packages ?? []).map((pattern) => pattern.replace(/^(!?)\.\//, "$1").replace(/\/+$/, ""));
601
+ const included = patterns.filter((pattern) => !pattern.startsWith("!")).map((pattern) => new Bun.Glob(pattern));
602
+ const excluded = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => new Bun.Glob(pattern.slice(1)));
603
+ return Object.keys(nestedLock.workspaces).filter((workspacePath) => !included.some((glob) => glob.match(workspacePath)) || excluded.some((glob) => glob.match(workspacePath)));
604
+ }
605
+ /**
606
+ * Returns the paths of nested workspaces whose host lockfile entries differ from the relocated nested lockfile,
607
+ * including host entries of workspaces that the nested repository no longer has.
608
+ */
609
+ function findWorkspaceMismatches(lock, nestedLock, dir) {
610
+ return [.../* @__PURE__ */ new Set([...Object.keys(nestedLock.workspaces), ...Object.keys(lock.workspaces).filter((workspacePath) => isNestedPath(workspacePath, dir))])].sort().filter((workspacePath) => toCanonicalJson(lock.workspaces[workspacePath]) !== toCanonicalJson(nestedLock.workspaces[workspacePath]));
611
+ }
612
+ /**
613
+ * Returns the direct dependencies of nested workspaces that the host lockfile resolves differently than the nested one.
614
+ *
615
+ * Entries are compared like `findTransitiveMismatches` compares them, so a different integrity is a mismatch too.
616
+ */
617
+ function findDirectDependencyMismatches(lock, nestedLock) {
618
+ return Object.entries(nestedLock.workspaces).flatMap(([workspacePath, workspace]) => Object.keys(getWorkspaceDependencies(workspace)).flatMap((dependency) => {
619
+ const expected = getWorkspaceDependencyEntry(nestedLock, workspace.name, dependency);
620
+ const actual = getWorkspaceDependencyEntry(lock, workspace.name, dependency);
621
+ if (expected === void 0 ? actual === void 0 : isSameResolution(expected, actual)) return [];
622
+ return [{
623
+ workspace: workspace.name ?? workspacePath,
624
+ dependency,
625
+ expected: expected?.[0],
626
+ actual: actual?.[0]
627
+ }];
628
+ }));
629
+ }
630
+ /**
631
+ * Returns the transitive dependencies of nested workspaces that the host lockfile resolves differently than the nested one.
632
+ *
633
+ * Walks the dependency edges from the nested workspaces through both lockfiles at once,
634
+ * so a different hoisting layout of the same versions is not a mismatch.
635
+ * Direct dependencies are covered by `findDirectDependencyMismatches`, and peer dependencies are not walked.
636
+ * Edges that the nested lockfile resolves outside their range are skipped, because the merge repairs them.
637
+ * Each mismatch is reported once, with the first dependency chain that reaches it, and its subtree is skipped.
638
+ */
639
+ function findTransitiveMismatches(lock, nestedLock) {
640
+ const queue = [];
641
+ for (const [workspacePath, workspace] of Object.entries(nestedLock.workspaces)) for (const dependency of Object.keys(getWorkspaceDependencies(workspace))) {
642
+ const nestedKey = resolveWorkspaceDependencyKey(nestedLock.packages, workspace.name, dependency);
643
+ const hostKey = resolveWorkspaceDependencyKey(lock.packages, workspace.name, dependency);
644
+ if (nestedKey && hostKey && isSameResolution(nestedLock.packages[nestedKey], lock.packages[hostKey])) queue.push({
645
+ path: [workspace.name ?? workspacePath, dependency],
646
+ nestedKey,
647
+ hostKey
648
+ });
649
+ }
650
+ const visited = /* @__PURE__ */ new Set();
651
+ const mismatches = [];
652
+ for (let index = 0; index < queue.length; index++) {
653
+ const { path, nestedKey, hostKey } = queue[index];
654
+ const entry = nestedLock.packages[nestedKey];
655
+ if (entry[0].includes("@workspace:")) continue;
656
+ for (const [dependency, specifier] of Object.entries(getPackageDependencies(entry))) {
657
+ const nestedDependencyKey = resolveDependencyLockKey(nestedKey, dependency, nestedLock.packages);
658
+ if (!nestedDependencyKey || !satisfiesSpecifier(nestedLock, dependency, specifier, nestedLock.packages[nestedDependencyKey])) continue;
659
+ const hostDependencyKey = resolveDependencyLockKey(hostKey, dependency, lock.packages);
660
+ const pair = `${nestedDependencyKey}\0${hostDependencyKey}`;
661
+ if (visited.has(pair)) continue;
662
+ visited.add(pair);
663
+ const expected = nestedLock.packages[nestedDependencyKey];
664
+ const actual = hostDependencyKey === void 0 ? void 0 : lock.packages[hostDependencyKey];
665
+ if (hostDependencyKey === void 0 || !isSameResolution(expected, actual)) {
666
+ mismatches.push({
667
+ path: [...path, dependency],
668
+ expected: expected[0],
669
+ actual: actual?.[0]
670
+ });
671
+ continue;
672
+ }
673
+ queue.push({
674
+ path: [...path, dependency],
675
+ nestedKey: nestedDependencyKey,
676
+ hostKey: hostDependencyKey
677
+ });
678
+ }
679
+ }
680
+ return mismatches;
681
+ }
682
+ /**
683
+ * Returns the external direct dependencies of host workspaces whose resolution differs between two host lockfiles.
684
+ *
685
+ * A dependency that changed to the root resolution of the nested lockfile is not reported, because the nested versions win,
686
+ * unless that resolution does not satisfy the host range.
687
+ */
688
+ function findOwnDependencyChanges(before, after, nestedLock, dir) {
689
+ return [...new Map([...Object.entries(before.workspaces), ...Object.entries(after.workspaces)].filter(([workspacePath]) => !isNestedPath(workspacePath, dir)))].flatMap(([workspacePath, workspace]) => {
690
+ const specifiers = {
691
+ ...getWorkspaceDependencies(before.workspaces[workspacePath] ?? {}),
692
+ ...getWorkspaceDependencies(after.workspaces[workspacePath] ?? {})
693
+ };
694
+ return Object.entries(specifiers).flatMap(([dependency, specifier]) => {
695
+ if (specifier.startsWith("workspace:")) return [];
696
+ const expected = resolveDependency(before, workspace.name, dependency);
697
+ const actual = resolveDependency(after, workspace.name, dependency);
698
+ const nestedEntry = nestedLock.packages[dependency];
699
+ if (actual === expected || actual !== void 0 && actual === nestedEntry?.[0] && satisfiesSpecifier(after, dependency, specifier, nestedEntry)) return [];
700
+ return [{
701
+ workspace: workspace.name ?? workspacePath,
702
+ dependency,
703
+ expected,
704
+ actual
705
+ }];
706
+ });
707
+ });
708
+ }
709
+ /** Returns the entry that resolves a direct dependency of a workspace package. */
710
+ function getWorkspaceDependencyEntry(lock, workspaceName, dependency) {
711
+ const key = resolveWorkspaceDependencyKey(lock.packages, workspaceName, dependency);
712
+ return key === void 0 ? void 0 : lock.packages[key];
713
+ }
714
+ /**
715
+ * Returns whether two lockfile entries resolve to the same package.
716
+ *
717
+ * Compares the resolution, registry, and integrity, but not the metadata object,
718
+ * because Bun versions differ in the metadata they record.
719
+ */
720
+ function isSameResolution(expected, actual) {
721
+ const strings = (entry) => entry.filter((value) => typeof value === "string");
722
+ return actual !== void 0 && JSON.stringify(strings(expected)) === JSON.stringify(strings(actual));
723
+ }
724
+ function toCanonicalJson(value) {
725
+ return JSON.stringify(value, (_, nested) => typeof nested === "object" && nested !== null && !Array.isArray(nested) ? Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : nested);
726
+ }
727
+ // src/workspace.ts
728
+ /** Loads the root `package.json` and `bun.lock` files of the host and of the nested checkout. */
729
+ async function loadNestedWorkspace(root) {
730
+ const manifestText = await Bun.file(path.join(root, "package.json")).text();
731
+ const manifest = JSON.parse(manifestText);
732
+ const config = readConfig(manifest);
733
+ const nestedRoot = path.join(root, config.dir);
734
+ return {
735
+ root,
736
+ config,
737
+ manifest,
738
+ manifestText,
739
+ nestedManifest: await Bun.file(path.join(nestedRoot, "package.json")).json(),
740
+ lock: await readLockfile(path.join(root, "bun.lock")),
741
+ nestedLock: relocateLockfile(await readLockfile(path.join(nestedRoot, "bun.lock")), config.dir)
742
+ };
743
+ }
744
+ /** Returns whether a check found no differences. */
745
+ function isInSync(result) {
746
+ return Object.values(result).every((list) => !list.length);
747
+ }
748
+ /**
749
+ * Throws when the host `workspaces` patterns miss nested workspaces.
750
+ *
751
+ * Bun would drop such workspaces from the merged lockfile, so neither mode can sync or check them.
752
+ */
753
+ function assertWorkspacesCovered(workspace) {
754
+ const uncovered = findUncoveredWorkspaces(workspace.manifest, workspace.nestedLock);
755
+ if (uncovered.length) throw new Error(`The host "workspaces" field does not include nested workspaces: ${uncovered.join(", ")}`);
756
+ }
757
+ /** Compares the host files with the nested checkout without writing anything or running `bun install`. */
758
+ function checkNestedWorkspace(workspace) {
759
+ assertWorkspacesCovered(workspace);
760
+ const { manifest } = syncManifest(workspace.manifest, workspace.nestedManifest);
761
+ return {
762
+ manifest: diffManifests(manifest, workspace.manifest),
763
+ workspaces: findWorkspaceMismatches(workspace.lock, workspace.nestedLock, workspace.config.dir),
764
+ directDependencies: findDirectDependencyMismatches(workspace.lock, workspace.nestedLock),
765
+ transitiveDependencies: findTransitiveMismatches(workspace.lock, workspace.nestedLock)
766
+ };
767
+ }
768
+ /** Error thrown when `bun install` exits with a non-zero code. */
769
+ var BunInstallError = class extends Error {
770
+ exitCode;
771
+ constructor(exitCode) {
772
+ super(`bun install exited with code ${exitCode}`);
773
+ this.exitCode = exitCode;
774
+ this.name = "BunInstallError";
775
+ }
776
+ };
777
+ /**
778
+ * Runs `bun install` in the host root with inherited stdio and throws `BunInstallError` on failure.
779
+ *
780
+ * Uses the Bun that runs this package, such as the one `bunx` started, rather than the first `bun` on `PATH`.
781
+ */
782
+ async function runBunInstall(root, args = []) {
783
+ const exitCode = await Bun.spawn([
784
+ process.execPath,
785
+ "install",
786
+ ...args
787
+ ], {
788
+ cwd: root,
789
+ stdio: [
790
+ "inherit",
791
+ "inherit",
792
+ "inherit"
793
+ ]
794
+ }).exited;
795
+ if (exitCode !== 0) throw new BunInstallError(exitCode);
796
+ }
797
+ /**
798
+ * Syncs the host root `package.json` and `bun.lock` with the nested checkout.
799
+ *
800
+ * Writes the synced `package.json`, writes the merged `bun.lock`, runs `bun install`,
801
+ * and then compares the result with the nested checkout and with the original host resolutions.
802
+ */
803
+ async function syncNestedWorkspace(root, options = {}) {
804
+ const workspace = await loadNestedWorkspace(root);
805
+ assertWorkspacesCovered(workspace);
806
+ const manifestSync = syncManifest(workspace.manifest, workspace.nestedManifest);
807
+ const { manifest } = manifestSync;
808
+ const manifestText = formatJson(manifest, workspace.manifestText);
809
+ if (manifestText !== workspace.manifestText) await Bun.write(path.join(root, "package.json"), manifestText);
810
+ options.onManifestSync?.(manifestSync);
811
+ const merged = mergeLockfiles(applyManifestToLockfile(workspace.lock, manifest), workspace.nestedLock, workspace.config.dir);
812
+ await Bun.write(path.join(root, "bun.lock"), renderBunLock(merged));
813
+ await runBunInstall(root, options.installArgs);
814
+ const synced = await loadNestedWorkspace(root);
815
+ return {
816
+ ownDependencyChanges: findOwnDependencyChanges(workspace.lock, synced.lock, workspace.nestedLock, workspace.config.dir),
817
+ check: checkNestedWorkspace(synced)
818
+ };
819
+ }
820
+ /** Serializes JSON with the indentation and final newline of the original text. */
821
+ function formatJson(value, original) {
822
+ const indent = /^[ \t]+(?=")/m.exec(original)?.[0] ?? " ";
823
+ return `${JSON.stringify(value, null, indent)}${original.endsWith("\n") ? "\n" : ""}`;
824
+ }
825
+ export { syncManifest as _, syncNestedWorkspace as a, findTransitiveMismatches as c, applyManifestToLockfile as d, mergeLockfiles as f, diffManifests as g, supportedLockfileVersions as h, loadNestedWorkspace as i, findUncoveredWorkspaces as l, relocateLockfile as m, checkNestedWorkspace as n, findDirectDependencyMismatches as o, parseLockfile as p, isInSync as r, findOwnDependencyChanges as s, BunInstallError as t, findWorkspaceMismatches as u, syncedFields as v, getErrorMessage as y };