bun-nested-workspace 0.2.1 → 0.3.1

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/README.md CHANGED
@@ -3,8 +3,7 @@
3
3
  Embed a checkout of another Bun workspace repository into a subdirectory of your Bun workspace, and keep the root `package.json` and `bun.lock` of the host in sync with it.
4
4
 
5
5
  The nested repository stays a self-contained project with its own root `package.json` and `bun.lock`.
6
- Its packages, such as `nested/apps/*` and `nested/packages/*`, become workspaces of the host, while its root does not.
7
- The host installs none of the dependencies of the nested root `package.json` and cannot run its scripts.
6
+ Its root and its packages, such as `nested/apps/*` and `nested/packages/*`, become workspaces of the host.
8
7
  Bun reads catalogs, overrides, patches, and trusted dependencies from the workspace root only, and resolves the whole workspace from a single lockfile.
9
8
  This package carries those root fields over to the host and merges the nested lockfile into the host lockfile, so the nested packages build in the host with the same dependency versions as on their own.
10
9
 
@@ -16,22 +15,22 @@ The CLI runs before the host dependencies are installed, so run it with `bunx`:
16
15
  bunx bun-nested-workspace
17
16
  ```
18
17
 
19
- It requires Bun 1.2.19 or later, the first version that resolves `catalog:` dependencies from both catalog locations.
18
+ It requires Bun 1.4.4 or later, the first version that keeps a dependency of the host root package at its own version when the nested lockfile resolves the same package to another version.
20
19
 
21
20
  ## Setup
22
21
 
23
- Add the nested packages to the host workspaces and name the nested directory in the `bun-nested-workspace` field of the host `package.json`:
22
+ Add the nested directory and its packages to the host workspaces and name the nested directory in the `bun-nested-workspace` field of the host `package.json`:
24
23
 
25
24
  ```json
26
25
  {
27
- "workspaces": ["apps/*", "nested/apps/*", "nested/packages/*"],
26
+ "workspaces": ["apps/*", "nested", "nested/apps/*", "nested/packages/*"],
28
27
  "bun-nested-workspace": {
29
28
  "dir": "nested"
30
29
  }
31
30
  }
32
31
  ```
33
32
 
34
- Both modes fail when the host `workspaces` patterns miss a workspace package of the nested repository.
33
+ Both modes fail when the host `workspaces` patterns miss the nested root or a nested workspace package, or when the nested root `package.json` has no `name`.
35
34
 
36
35
  The config takes these fields:
37
36
 
@@ -39,6 +38,24 @@ The config takes these fields:
39
38
  - `fields`: the root fields to sync, a subset of `catalog`, `catalogs`, `overrides`, `patchedDependencies`, and `trustedDependencies`; all of them when omitted.
40
39
  - `synced`: the names of entries that came from the nested repository, maintained by the sync; commit it and do not edit it by hand.
41
40
 
41
+ ## Nested checkout
42
+
43
+ `bun install` in the host installs the dependencies of the nested root `package.json` in the versions of the nested lockfile, so the scripts and Git hooks of the nested repository work in the checkout.
44
+ It also runs the `prepare` script of the nested root.
45
+
46
+ Change the dependencies of the nested repository by editing its `package.json` files.
47
+ Then update the nested lockfile with `bun install --lockfile-only` in the directory of any nested workspace package, and run the sync in the host:
48
+
49
+ ```sh
50
+ (cd nested/packages/core && bun install --lockfile-only)
51
+ bunx bun-nested-workspace
52
+ ```
53
+
54
+ Do not run other `bun install`, `bun add`, or `bun remove` commands in the nested directory or in its package directories:
55
+
56
+ - In the nested root, Bun runs them for the host: it writes the host lockfile and leaves the nested one stale.
57
+ - In a nested package directory, Bun installs a separate `node_modules` of the nested repository over the host install.
58
+
42
59
  ## Sync
43
60
 
44
61
  Run the CLI in the host root after switching the nested checkout to another commit:
@@ -57,6 +74,7 @@ The sync:
57
74
  Commit `package.json` and `bun.lock` after the sync.
58
75
 
59
76
  In a Docker build, run the sync with `--lockfile-only` in a stage that contains only the dependency files, before installing dependencies.
77
+ The dependency install runs the `prepare` script of the nested root unless it gets `--ignore-scripts`.
60
78
  `bun install` also needs the patch files and the install configuration, such as `bunfig.toml` and `.npmrc`, so keep them, along with any files they reference.
61
79
  A separate stage can strip the build context down to these files, so the Dockerfile does not list every workspace package:
62
80
 
@@ -84,6 +102,7 @@ Exclude `node_modules` and other large directories from the build context with `
84
102
  An explicit list replaces the default trusted list of Bun, so when only one side defines the field, the packages of the other side lose the default list; list the ones whose lifecycle scripts they need.
85
103
  The sync warns when it adds the field to the host.
86
104
  - `patchedDependencies` paths are rewritten through the nested directory, such as `nested/patches/exceljs@4.4.0.patch`.
105
+ Bun warns on every install that it ignores `patchedDependencies` of the nested root package; keep them there, because the host applies the synced copies.
87
106
 
88
107
  A nested entry replaces a host entry with the same name, while the other host entries stay.
89
108
  The sync lists the host entries it replaced.
@@ -91,8 +110,6 @@ A host entry equal to the nested one stays the host's own, so it survives when t
91
110
  An entry that came from the nested repository and is no longer there is removed; for patches, these are the patches inside the nested directory.
92
111
  A package version patched by both the host and the nested repository is an error.
93
112
 
94
- Bun warns when a nested patch targets a package version that the host does not install, such as a patched tool used only by the nested root.
95
-
96
113
  ### Lockfile
97
114
 
98
115
  The nested lockfile entries replace the host entries, so the nested packages keep their versions.
@@ -102,7 +119,7 @@ Ranges are checked as `bun install` applies them: an override replaces the decla
102
119
  `bun install` then resolves new dependencies of host packages and drops unused entries.
103
120
 
104
121
  After the install, the sync compares the direct dependencies of host packages with the original host lockfile.
105
- A dependency may only change to the version that the nested lockfile resolves at its root, and only within the host range.
122
+ A dependency may only change to the version that the nested lockfile hoists to its top level, and only within the host range.
106
123
  Any other change means either that the host lockfile did not pin the host dependencies, for example after an edit of a host `package.json` without `bun install`, or that the lockfile cannot record the host version next to the nested one.
107
124
  The latter happens to a host workspace without a name, because Bun records workspace-specific versions under the workspace name; name the workspace or align its range with the nested version.
108
125
 
@@ -115,7 +132,7 @@ bunx bun-nested-workspace --check
115
132
  ```
116
133
 
117
134
  The check writes nothing, does not run `bun install`, and exits with code 1 when the host files lag behind the nested checkout.
118
- It reads the root `package.json` and `bun.lock` of the host and of the nested checkout only, so it relies on the nested `bun.lock` being up to date with the nested packages; `bun install --frozen-lockfile` in the nested directory verifies that.
135
+ It reads the root `package.json` and `bun.lock` of the host and of the nested checkout only, so it relies on the nested `bun.lock` being up to date with the nested packages; `bun install --frozen-lockfile` in the CI of the nested repository verifies that.
119
136
  Use it in a non-blocking CI job that signals when a sync commit is due.
120
137
 
121
138
  It reports:
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { a as syncNestedWorkspace, i as loadNestedWorkspace, n as checkNestedWorkspace, r as isInSync, t as BunInstallError, y as getErrorMessage } from "./workspace-DOgB-vkj.mjs";
2
+ import { a as syncNestedWorkspace, i as loadNestedWorkspace, n as checkNestedWorkspace, r as isInSync, t as BunInstallError, y as getErrorMessage } from "./workspace-BzA7ccrb.mjs";
3
3
  // src/cli.ts
4
4
  const usage = `Usage:
5
5
  bun-nested-workspace [bun install options] Sync package.json and bun.lock, then run bun install
@@ -52,7 +52,7 @@ async function runSync(root, installArgs) {
52
52
  console.error([
53
53
  "The sync changed own dependencies of host packages.",
54
54
  "Either the host bun.lock did not pin them: restore bun.lock, run bun install, commit bun.lock, and sync again.",
55
- "Or the nested root resolves them outside the host range: align the host range with the nested version.",
55
+ "Or the nested lockfile resolves them outside the host range: align the host range with the nested version.",
56
56
  ...ownDependencyChanges.map(({ workspace, dependency, expected, actual }) => formatChange(`${workspace} > ${dependency}`, expected, actual))
57
57
  ].join("\n"));
58
58
  exitCode = 1;
package/dist/index.d.mts CHANGED
@@ -92,6 +92,10 @@ interface ResolvedConfig {
92
92
  type Catalog = Record<string, string>;
93
93
  /** Root `package.json` fields this package reads or rewrites; other fields are preserved as is. */
94
94
  interface PackageManifest {
95
+ dependencies?: Record<string, string>;
96
+ devDependencies?: Record<string, string>;
97
+ optionalDependencies?: Record<string, string>;
98
+ peerDependencies?: Record<string, string>;
95
99
  workspaces?: string[] | WorkspacesConfig;
96
100
  catalog?: Catalog;
97
101
  catalogs?: Record<string, Catalog>;
@@ -135,7 +139,8 @@ interface ManifestSync {
135
139
  * A nested entry replaces a host entry with the same name, while the other host entries stay.
136
140
  * Entries synced earlier and dropped by the nested repository are removed.
137
141
  * Throws when the host and the nested repository patch the same package version,
138
- * and when either side defines `resolutions` while `overrides` are synced.
142
+ * when either side defines `resolutions` while `overrides` are synced,
143
+ * and when a synced `$name` override would refer to another range in the host root than in the nested root.
139
144
  */
140
145
  export declare function syncManifest(host: PackageManifest, nested: PackageManifest): ManifestSync;
141
146
  /** Difference between the expected and the actual value at a `package.json` path. */
@@ -168,14 +173,18 @@ export declare function parseLockfile(text: string, source?: string): Lockfile;
168
173
  /**
169
174
  * Returns the nested lockfile with workspace and local paths relative to the host root.
170
175
  *
171
- * The nested root workspace is dropped, because it is not a workspace of the host.
176
+ * The nested root workspace moves to the nested directory, where it is a workspace of the host,
177
+ * and takes `rootName`, the name of the nested root `package.json`, when it is given.
178
+ * Bun requires the name of a workspace package entry but keeps a root entry without the name
179
+ * when the name was added to `package.json` after the lockfile.
172
180
  */
173
- export declare function relocateLockfile(lock: Lockfile, dir: string): Lockfile;
181
+ export declare function relocateLockfile(lock: Lockfile, dir: string, rootName?: string): Lockfile;
174
182
  /**
175
183
  * Returns the lockfile with root fields that affect resolution taken from the root `package.json`.
176
184
  *
177
185
  * `bun install` re-resolves dependencies whose catalog entry, override, or patch differs from the lockfile,
178
186
  * so the merged lockfile must already record the synced root fields to keep the nested resolutions.
187
+ * Records `$name` overrides with the range they refer to, as Bun does, and throws when `resolveOverrideValue` does.
179
188
  */
180
189
  export declare function applyManifestToLockfile(lock: Lockfile, manifest: PackageManifest): Lockfile;
181
190
  /**
@@ -251,7 +260,7 @@ interface NestedWorkspace {
251
260
  manifestText: string;
252
261
  nestedManifest: PackageManifest;
253
262
  lock: Lockfile;
254
- /** Nested lockfile relocated with `relocateLockfile`. */
263
+ /** Nested lockfile relocated with `relocateLockfile`, with the root name of `nestedManifest`. */
255
264
  nestedLock: Lockfile;
256
265
  }
257
266
  /** Loads the root `package.json` and `bun.lock` files of the host and of the nested checkout. */
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { t as BunInstallError, d as applyManifestToLockfile, n as checkNestedWorkspace, g as diffManifests, o as findDirectDependencyMismatches, s as findOwnDependencyChanges, c as findTransitiveMismatches, l as findUncoveredWorkspaces, u as findWorkspaceMismatches, r as isInSync, i as loadNestedWorkspace, f as mergeLockfiles, p as parseLockfile, m as relocateLockfile, h as supportedLockfileVersions, _ as syncManifest, a as syncNestedWorkspace, v as syncedFields } from "./workspace-DOgB-vkj.mjs";
1
+ export { t as BunInstallError, d as applyManifestToLockfile, n as checkNestedWorkspace, g as diffManifests, o as findDirectDependencyMismatches, s as findOwnDependencyChanges, c as findTransitiveMismatches, l as findUncoveredWorkspaces, u as findWorkspaceMismatches, r as isInSync, i as loadNestedWorkspace, f as mergeLockfiles, p as parseLockfile, m as relocateLockfile, h as supportedLockfileVersions, _ as syncManifest, a as syncNestedWorkspace, v as syncedFields } from "./workspace-BzA7ccrb.mjs";
@@ -1,4 +1,5 @@
1
1
  import JSON5 from "json5";
2
+ import semver from "semver";
2
3
  import path from "node:path";
3
4
  // ../lockfile/src/parse.ts
4
5
  function isObject(value) {
@@ -13,11 +14,14 @@ function packageEntryMeta(entry) {
13
14
  return isObject(maybeMeta) ? maybeMeta : void 0;
14
15
  }
15
16
  /**
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.
17
+ * Splits Bun's resolved package spec (`name@version`) at the first `@` after
18
+ * the leading `@` of a scoped name, as Bun does.
19
+ *
20
+ * The version part may contain `@` itself, as in a git, tarball, or workspace
21
+ * source such as `pkg@git+ssh://git@host/repo.git#sha`.
18
22
  */
19
23
  function parseResolvedSpec(spec) {
20
- const atIndex = spec.lastIndexOf("@");
24
+ const atIndex = spec.indexOf("@", 1);
21
25
  if (atIndex <= 0 || atIndex >= spec.length - 1) return null;
22
26
  const name = spec.slice(0, atIndex);
23
27
  const version = spec.slice(atIndex + 1);
@@ -47,17 +51,67 @@ function parseBunLock(lockText) {
47
51
  }
48
52
  // ../lockfile/src/overrides.ts
49
53
  /**
50
- * Read the exact bare-name override Bun stores for a dependency.
54
+ * Return the package and range that an `npm:` alias specifier installs, or `undefined` for any other specifier.
55
+ *
56
+ * Follows Bun: `npm:<name>@<version>` always names a package.
57
+ * Without a version, a target with a range operator or an exact version, such as `npm:^1.0.0`, `npm:1.0.0`, or
58
+ * `npm:v1.0.0`, is a range of the dependency itself; any other target, such as `npm:x`, `npm:1`, or `npm:1.x`, is a
59
+ * package name.
60
+ */
61
+ function npmAliasTarget(specifier) {
62
+ if (!specifier.startsWith("npm:")) return void 0;
63
+ const target = specifier.slice(4);
64
+ const separator = target.indexOf("@", 1);
65
+ if (separator !== -1) return {
66
+ name: target.slice(0, separator),
67
+ range: target.slice(separator + 1)
68
+ };
69
+ const isRange = /[\^~><=*| ]/.test(target) || /^v?\d+\.\d+\.\d+(?:[-+][\w.+-]*)?$/i.test(target);
70
+ return target && !isRange ? {
71
+ name: target,
72
+ range: "*"
73
+ } : void 0;
74
+ }
75
+ /**
76
+ * Return the package and version range that a dependency specifier requires.
77
+ *
78
+ * An `npm:` alias requires the aliased package, and `npm:<range>` is a range of the dependency itself.
79
+ */
80
+ function dependencyRequirement(dependencyName, specifier) {
81
+ const alias = npmAliasTarget(specifier);
82
+ if (alias) return alias;
83
+ return {
84
+ name: dependencyName,
85
+ range: specifier.startsWith("npm:") ? specifier.slice(4) : specifier
86
+ };
87
+ }
88
+ /**
89
+ * Read the bare-name override that Bun applies to a dependency declaration.
51
90
  *
91
+ * Bun never overrides an `npm:` alias of a semver range.
92
+ * It overrides an alias of a dist-tag, such as `npm:debug@latest`, by the name of the aliased package,
93
+ * so the override is returned as an alias of that package.
52
94
  * `parseBunLock` rejects overrides scoped to a parent package or a version selector.
53
95
  * Bun versions before 1.4 keep selector keys as strings without applying them,
54
96
  * so matching those here would diverge from the package manager.
55
97
  */
56
- function dependencyOverrideRange(lock, dependencyName) {
98
+ function dependencyOverrideRange(lock, dependencyName, declaredRange) {
99
+ const alias = npmAliasTarget(declaredRange);
100
+ if (!alias) return bareNameOverride(lock, dependencyName);
101
+ if (!isDistTag(alias.range)) return void 0;
102
+ const override = bareNameOverride(lock, alias.name);
103
+ if (override === void 0) return void 0;
104
+ const requirement = dependencyRequirement(alias.name, override);
105
+ return requirement.name !== alias.name || requirement.range.includes(":") ? override : `npm:${alias.name}@${requirement.range}`;
106
+ }
107
+ function bareNameOverride(lock, name) {
57
108
  const overrides = lock.overrides;
58
- if (!overrides) return void 0;
59
- if (!Object.hasOwn(overrides, dependencyName)) return void 0;
60
- return overrides[dependencyName];
109
+ if (!overrides || !Object.hasOwn(overrides, name)) return void 0;
110
+ return overrides[name];
111
+ }
112
+ /** Whether a version of an `npm:` alias is a dist-tag, such as `latest`, rather than a range or a source. */
113
+ function isDistTag(version) {
114
+ return /^[^\s:/#@]+$/.test(version) && semver.validRange(version) === null;
61
115
  }
62
116
  /**
63
117
  * Return the range Bun actually enforces for a dependency request.
@@ -66,7 +120,7 @@ function dependencyOverrideRange(lock, dependencyName) {
66
120
  * compatibility then stays unknown instead of falling back to the declaration.
67
121
  */
68
122
  function effectiveDependencyRange(lock, dependencyName, declaredRange) {
69
- const overrideRange = dependencyOverrideRange(lock, dependencyName);
123
+ const overrideRange = dependencyOverrideRange(lock, dependencyName, declaredRange);
70
124
  return overrideRange === void 0 ? declaredRange : overrideRange;
71
125
  }
72
126
  // ../lockfile/src/render.ts
@@ -130,15 +184,14 @@ function renderBunLock(lock) {
130
184
  }
131
185
  // ../lockfile/src/resolve.ts
132
186
  /**
133
- * Tell nested dependency keys apart from root scoped package keys.
187
+ * Tell whether a lock key is a nested entry of a dependency: the last of several package names in the key.
134
188
  *
135
- * For an unscoped dependency, `@scope/name` can match the `/name` suffix even
136
- * though it is a root package key. A real nested key has another path segment,
137
- * such as `@scope/requester/name`.
189
+ * A suffix match is not enough: `@scope/name` is a root package key, and `app/@alias/name` is a nested entry
190
+ * of the dependency `@alias/name`, such as an `npm:` alias.
138
191
  */
139
192
  function isNestedDependencyLockKey(lockKey, dependencyName) {
140
- if (!lockKey.endsWith(`/${dependencyName}`)) return false;
141
- return dependencyName.startsWith("@") || !/^@[^/]+\/[^/]+$/.test(lockKey);
193
+ const names = splitLockKey(lockKey);
194
+ return names.length > 1 && names.at(-1) === dependencyName;
142
195
  }
143
196
  /**
144
197
  * Resolve a dependency the way Bun lock keys are structured:
@@ -249,13 +302,39 @@ function setCatalogs(manifest, catalogs) {
249
302
  if (manifest.workspaces !== void 0 || Object.keys(workspaces).length) result.workspaces = Array.isArray(manifest.workspaces) && Object.keys(workspaces).length === 1 ? workspaces.packages : workspaces;
250
303
  return result;
251
304
  }
305
+ /** Returns the distinct ranges that the root `package.json` declares for a package across its dependency groups. */
306
+ function getDeclaredRanges(manifest, name) {
307
+ const groups = [
308
+ manifest.dependencies,
309
+ manifest.devDependencies,
310
+ manifest.optionalDependencies,
311
+ manifest.peerDependencies
312
+ ];
313
+ return [...new Set(groups.flatMap((group) => group && Object.hasOwn(group, name) ? [group[name]] : []))];
314
+ }
315
+ /**
316
+ * Returns the range that a `$name` override value refers to, or the value itself when it is not a reference.
317
+ *
318
+ * Bun replaces `$name` with the range that the root `package.json` declares for `name`, as written.
319
+ * Resolves only references to the root's own declarations: throws when the reference names another package,
320
+ * which Bun versions resolve differently, and when the dependency groups declare no range or different ranges for it.
321
+ */
322
+ function resolveOverrideValue(manifest, name, value, source = "package.json") {
323
+ if (!value.startsWith("$")) return value;
324
+ const entry = `${source} override ${JSON.stringify(name)}: ${JSON.stringify(value)}`;
325
+ if (value.slice(1) !== name) throw new Error(`${entry} refers to another package, which Bun versions resolve differently; replace it with an explicit range`);
326
+ const ranges = getDeclaredRanges(manifest, name);
327
+ if (ranges.length !== 1) throw new Error(`${entry} refers to ${ranges.length ? "different ranges in the dependency groups" : "a dependency that the root does not declare"}; replace it with an explicit range`);
328
+ return ranges[0];
329
+ }
252
330
  /**
253
331
  * Returns the host root `package.json` with the configured root fields of the nested `package.json` merged in.
254
332
  *
255
333
  * A nested entry replaces a host entry with the same name, while the other host entries stay.
256
334
  * Entries synced earlier and dropped by the nested repository are removed.
257
335
  * Throws when the host and the nested repository patch the same package version,
258
- * and when either side defines `resolutions` while `overrides` are synced.
336
+ * when either side defines `resolutions` while `overrides` are synced,
337
+ * and when a synced `$name` override would refer to another range in the host root than in the nested root.
259
338
  */
260
339
  function syncManifest(host, nested) {
261
340
  const config = readConfig(host);
@@ -291,6 +370,11 @@ function syncManifest(host, nested) {
291
370
  manifest = setCatalogs(manifest, catalogs);
292
371
  if (config.fields.includes("overrides")) {
293
372
  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"`);
373
+ for (const [name, value] of Object.entries(nested.overrides ?? {})) {
374
+ const nestedRange = resolveOverrideValue(nested, name, value, `${config.dir}/package.json`);
375
+ const hostRanges = getDeclaredRanges(host, name);
376
+ if (value.startsWith("$") && (hostRanges.length !== 1 || hostRanges[0] !== nestedRange)) throw new Error(`${config.dir}/package.json override ${JSON.stringify(name)}: ${JSON.stringify(value)} refers to ${JSON.stringify(nestedRange)} in the nested root, but the host root ${hostRanges.length ? `declares ${hostRanges.map((range) => JSON.stringify(range)).join(", ")}` : "does not declare it"}; replace it with an explicit range`);
377
+ }
294
378
  const merged = mergeEntries("overrides", host.overrides, nested.overrides, synced.overrides);
295
379
  manifest.overrides = merged.entries;
296
380
  synced.overrides = merged.synced;
@@ -422,12 +506,18 @@ async function readLockfile(file) {
422
506
  /**
423
507
  * Returns the nested lockfile with workspace and local paths relative to the host root.
424
508
  *
425
- * The nested root workspace is dropped, because it is not a workspace of the host.
509
+ * The nested root workspace moves to the nested directory, where it is a workspace of the host,
510
+ * and takes `rootName`, the name of the nested root `package.json`, when it is given.
511
+ * Bun requires the name of a workspace package entry but keeps a root entry without the name
512
+ * when the name was added to `package.json` after the lockfile.
426
513
  */
427
- function relocateLockfile(lock, dir) {
514
+ function relocateLockfile(lock, dir, rootName) {
428
515
  return {
429
516
  ...lock,
430
- workspaces: Object.fromEntries(Object.entries(lock.workspaces).filter(([workspacePath]) => workspacePath).map(([workspacePath, workspace]) => [path.posix.join(dir, workspacePath), workspace])),
517
+ workspaces: Object.fromEntries(Object.entries(lock.workspaces).map(([workspacePath, workspace]) => [path.posix.join(dir, workspacePath), workspacePath === "" && rootName !== void 0 ? {
518
+ ...workspace,
519
+ name: rootName
520
+ } : workspace])),
431
521
  packages: Object.fromEntries(Object.entries(lock.packages).map(([key, [resolution, ...rest]]) => [key, [relocateResolution(resolution, dir), ...rest]]))
432
522
  };
433
523
  }
@@ -445,13 +535,14 @@ function relocateResolution(resolution, dir) {
445
535
  *
446
536
  * `bun install` re-resolves dependencies whose catalog entry, override, or patch differs from the lockfile,
447
537
  * so the merged lockfile must already record the synced root fields to keep the nested resolutions.
538
+ * Records `$name` overrides with the range they refer to, as Bun does, and throws when `resolveOverrideValue` does.
448
539
  */
449
540
  function applyManifestToLockfile(lock, manifest) {
450
541
  const { catalog, catalogs } = getCatalogs(manifest);
451
542
  const fields = {
452
543
  catalog,
453
544
  catalogs,
454
- overrides: manifest.overrides,
545
+ overrides: manifest.overrides && Object.fromEntries(Object.entries(manifest.overrides).map(([name, value]) => [name, resolveOverrideValue(manifest, name, value)])),
455
546
  patchedDependencies: manifest.patchedDependencies
456
547
  };
457
548
  const result = { ...lock };
@@ -473,7 +564,8 @@ function applyManifestToLockfile(lock, manifest) {
473
564
  * to it first.
474
565
  */
475
566
  function mergeLockfiles(lock, nestedLock, dir) {
476
- const hostPackages = Object.entries(lock.packages).filter(([key]) => !(splitPackageKey(key)[0] in nestedLock.packages));
567
+ const nestedNames = /* @__PURE__ */ new Set([...Object.keys(nestedLock.packages), ...Object.values(nestedLock.workspaces).flatMap((workspace) => workspace.name ?? [])]);
568
+ const hostPackages = Object.entries(lock.packages).filter(([key]) => !nestedNames.has(splitPackageKey(key)[0]));
477
569
  const packages = {
478
570
  ...Object.fromEntries(hostPackages),
479
571
  ...nestedLock.packages
@@ -546,21 +638,8 @@ function parseRequirement(lock, dependency, specifier) {
546
638
  const range = (catalogName && catalogName !== "default" ? lock.catalogs?.[catalogName] : lock.catalog)?.[dependency];
547
639
  return range === void 0 ? void 0 : parseRequirement(lock, dependency, range);
548
640
  }
549
- if (specifier.startsWith("npm:")) {
550
- const target = specifier.slice(4);
551
- const separator = target.lastIndexOf("@");
552
- return separator > 0 ? {
553
- name: target.slice(0, separator),
554
- range: target.slice(separator + 1)
555
- } : {
556
- name: target,
557
- range: "*"
558
- };
559
- }
560
- return /^[a-z+]+:|\//i.test(specifier) ? void 0 : {
561
- name: dependency,
562
- range: specifier
563
- };
641
+ const requirement = dependencyRequirement(dependency, specifier);
642
+ return /^[a-z+]+:|\//i.test(requirement.range) ? void 0 : requirement;
564
643
  }
565
644
  /** Returns the entry with the highest version of the required package that satisfies its range. */
566
645
  function findSatisfyingEntry(sources, requirement) {
@@ -569,7 +648,7 @@ function findSatisfyingEntry(sources, requirement) {
569
648
  return best;
570
649
  }
571
650
  function getVersion([resolution]) {
572
- return resolution.slice(resolution.lastIndexOf("@") + 1);
651
+ return parseResolvedSpec(resolution)?.version;
573
652
  }
574
653
  /** Returns whether an entry resolves the required package to a version in its range. */
575
654
  function satisfies(entry, requirement) {
@@ -616,7 +695,9 @@ function findUncoveredWorkspaces(manifest, nestedLock) {
616
695
  * including host entries of workspaces that the nested repository no longer has.
617
696
  */
618
697
  function findWorkspaceMismatches(lock, nestedLock, dir) {
619
- 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]));
698
+ const paths = /* @__PURE__ */ new Set([...Object.keys(nestedLock.workspaces), ...Object.keys(lock.workspaces).filter((workspacePath) => isNestedPath(workspacePath, dir))]);
699
+ const comparable = (workspacePath, workspace) => workspacePath === dir && workspace ? Object.fromEntries(Object.entries(workspace).filter(([key]) => key !== "version" && key !== "bin")) : workspace;
700
+ return [...paths].sort().filter((workspacePath) => toCanonicalJson(comparable(workspacePath, lock.workspaces[workspacePath])) !== toCanonicalJson(comparable(workspacePath, nestedLock.workspaces[workspacePath])));
620
701
  }
621
702
  /**
622
703
  * Returns the direct dependencies of nested workspaces that the host lockfile resolves differently than the nested one.
@@ -740,14 +821,15 @@ async function loadNestedWorkspace(root) {
740
821
  const manifest = JSON.parse(manifestText);
741
822
  const config = readConfig(manifest);
742
823
  const nestedRoot = path.join(root, config.dir);
824
+ const nestedManifest = await Bun.file(path.join(nestedRoot, "package.json")).json();
743
825
  return {
744
826
  root,
745
827
  config,
746
828
  manifest,
747
829
  manifestText,
748
- nestedManifest: await Bun.file(path.join(nestedRoot, "package.json")).json(),
830
+ nestedManifest,
749
831
  lock: await readLockfile(path.join(root, "bun.lock")),
750
- nestedLock: relocateLockfile(await readLockfile(path.join(nestedRoot, "bun.lock")), config.dir)
832
+ nestedLock: relocateLockfile(await readLockfile(path.join(nestedRoot, "bun.lock")), config.dir, typeof nestedManifest.name === "string" ? nestedManifest.name : void 0)
751
833
  };
752
834
  }
753
835
  /** Returns whether a check found no differences. */
@@ -755,11 +837,13 @@ function isInSync(result) {
755
837
  return Object.values(result).every((list) => !list.length);
756
838
  }
757
839
  /**
758
- * Throws when the host `workspaces` patterns miss nested workspaces.
840
+ * Throws when the host `workspaces` patterns miss nested workspaces or the nested root has no name.
759
841
  *
760
842
  * Bun would drop such workspaces from the merged lockfile, so neither mode can sync or check them.
843
+ * Bun skips a workspace package without a name, or fails on it in versions before 1.4.
761
844
  */
762
845
  function assertWorkspacesCovered(workspace) {
846
+ if (typeof workspace.nestedManifest.name !== "string" || !workspace.nestedManifest.name) throw new Error(`${workspace.config.dir}/package.json needs a "name" field to be a workspace of the host`);
763
847
  const uncovered = findUncoveredWorkspaces(workspace.manifest, workspace.nestedLock);
764
848
  if (uncovered.length) throw new Error(`The host "workspaces" field does not include nested workspaces: ${uncovered.join(", ")}`);
765
849
  }
@@ -814,10 +898,10 @@ async function syncNestedWorkspace(root, options = {}) {
814
898
  assertWorkspacesCovered(workspace);
815
899
  const manifestSync = syncManifest(workspace.manifest, workspace.nestedManifest);
816
900
  const { manifest } = manifestSync;
901
+ const merged = mergeLockfiles(applyManifestToLockfile(workspace.lock, manifest), workspace.nestedLock, workspace.config.dir);
817
902
  const manifestText = formatJson(manifest, workspace.manifestText);
818
903
  if (manifestText !== workspace.manifestText) await Bun.write(path.join(root, "package.json"), manifestText);
819
904
  options.onManifestSync?.(manifestSync);
820
- const merged = mergeLockfiles(applyManifestToLockfile(workspace.lock, manifest), workspace.nestedLock, workspace.config.dir);
821
905
  await Bun.write(path.join(root, "bun.lock"), renderBunLock(merged));
822
906
  await runBunInstall(root, options.installArgs);
823
907
  const synced = await loadNestedWorkspace(root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-nested-workspace",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Embed a checkout of another Bun workspace repository into a Bun workspace and keep the root package.json and bun.lock in sync with it",
5
5
  "keywords": [
6
6
  "bun",
@@ -43,7 +43,8 @@
43
43
  "prepublishOnly": "bun run build"
44
44
  },
45
45
  "dependencies": {
46
- "json5": "^2.2.3"
46
+ "json5": "^2.2.3",
47
+ "semver": "^7.7.4"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@arethetypeswrong/core": "catalog:",
@@ -54,6 +55,6 @@
54
55
  "tsdown": "catalog:"
55
56
  },
56
57
  "engines": {
57
- "bun": ">=1.2.19"
58
+ "bun": ">=1.4.4"
58
59
  }
59
60
  }