bun-nested-workspace 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,6 +51,8 @@ Then update the nested lockfile with `bun install --lockfile-only` in the direct
51
51
  bunx bun-nested-workspace
52
52
  ```
53
53
 
54
+ A nested repository without workspace packages updates its lockfile in a separate clone.
55
+
54
56
  Do not run other `bun install`, `bun add`, or `bun remove` commands in the nested directory or in its package directories:
55
57
 
56
58
  - In the nested root, Bun runs them for the host: it writes the host lockfile and leaves the nested one stale.
@@ -72,6 +74,7 @@ The sync:
72
74
  4. Fails if the install re-resolved own dependencies of host packages, or if the host does not resolve the nested packages as the nested lockfile does.
73
75
 
74
76
  Commit `package.json` and `bun.lock` after the sync.
77
+ A failed sync restores `package.json` and `bun.lock` to their contents before the run, while `node_modules` stays as the install left it.
75
78
 
76
79
  In a Docker build, run the sync with `--lockfile-only` in a stage that contains only the dependency files, before installing dependencies.
77
80
  The dependency install runs the `prepare` script of the nested root unless it gets `--ignore-scripts`.
@@ -132,7 +135,8 @@ bunx bun-nested-workspace --check
132
135
  ```
133
136
 
134
137
  The check writes nothing, does not run `bun install`, and exits with code 1 when the host files lag behind the nested checkout.
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.
138
+ 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.
139
+ Verify that in the CI of the nested repository with `bun install --lockfile-only` followed by `git diff --exit-code bun.lock`: `bun install --frozen-lockfile` misses some changes, such as a narrowed range that the locked version still satisfies.
136
140
  Use it in a non-blocking CI job that signals when a sync commit is due.
137
141
 
138
142
  It reports:
@@ -149,7 +153,7 @@ Limitations:
149
153
 
150
154
  - Peer dependencies are not walked.
151
155
  - Edges that the nested lockfile itself resolves outside their range are skipped, because the sync repairs them in the host.
152
- - The check does not verify that the host lockfile matches the host packages; `bun install --frozen-lockfile` does.
156
+ - The check does not verify that the host lockfile matches the host packages; `bun install --lockfile-only` followed by `git diff --exit-code bun.lock` does.
153
157
  - The check does not predict whether a sync would re-resolve own dependencies of host packages; only the sync detects it after `bun install`.
154
158
 
155
159
  ## API
@@ -173,7 +177,8 @@ if (!isInSync(check)) {
173
177
  }
174
178
  ```
175
179
 
176
- `syncNestedWorkspace` writes the files and runs `bun install` before it checks the result, so a failed sync leaves the written files for inspection.
180
+ `syncNestedWorkspace` writes the files and runs `bun install` before it checks the result.
181
+ When `bun install` fails or the result shows differences, it restores both files before it throws or returns, and throws an error saying so when it cannot restore them.
177
182
  Its `onManifestSync` option receives the replaced host entries and whether the sync added `trustedDependencies`, as soon as `package.json` is written and even when `bun install` then fails.
178
183
 
179
184
  Lower-level functions work on parsed files without touching the disk: `syncManifest`, `diffManifests`, `parseLockfile`, `relocateLockfile`, `applyManifestToLockfile`, `mergeLockfiles`, and the `find*` checks.
package/dist/cli.mjs CHANGED
@@ -1,11 +1,12 @@
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-ChlHn5I8.mjs";
2
+ import { a as isNestedWorkspace, i as isInSync, n as checkNestedWorkspace, o as loadNestedWorkspace, r as findNestedPackageDir, s as syncNestedWorkspace, t as BunInstallError, x as getErrorMessage } from "./workspace-DOwKm8pX.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
6
6
  bun-nested-workspace --check Exit with 1 if package.json or bun.lock lag behind the nested checkout
7
7
 
8
8
  Configure the nested directory in the "bun-nested-workspace" field of the host package.json.`;
9
+ const restoredMessage = "Restored package.json and bun.lock to their contents before the sync.";
9
10
  /** Runs the CLI with the given arguments in the given host root and returns the exit code. */
10
11
  async function runCli(args, root = process.cwd()) {
11
12
  if (args.includes("--help") || args.includes("-h")) {
@@ -23,7 +24,10 @@ async function runCli(args, root = process.cwd()) {
23
24
  }
24
25
  return await runSync(root, args);
25
26
  } catch (error) {
26
- if (error instanceof BunInstallError) return error.exitCode;
27
+ if (error instanceof BunInstallError) {
28
+ console.error(restoredMessage);
29
+ return error.exitCode;
30
+ }
27
31
  console.error(getErrorMessage(error));
28
32
  return 1;
29
33
  }
@@ -51,7 +55,7 @@ async function runSync(root, installArgs) {
51
55
  if (ownDependencyChanges.length) {
52
56
  console.error([
53
57
  "The sync changed own dependencies of host packages.",
54
- "Either the host bun.lock did not pin them: restore bun.lock, run bun install, commit bun.lock, and sync again.",
58
+ "Either the host bun.lock did not pin them: run bun install, commit bun.lock, and sync again.",
55
59
  "Or the nested lockfile resolves them outside the host range: align the host range with the nested version.",
56
60
  ...ownDependencyChanges.map(({ workspace, dependency, expected, actual }) => formatChange(`${workspace} > ${dependency}`, expected, actual))
57
61
  ].join("\n"));
@@ -59,10 +63,23 @@ async function runSync(root, installArgs) {
59
63
  }
60
64
  if (!isInSync(check)) {
61
65
  console.error(["bun install did not keep the nested resolutions:", ...formatCheckResult(check)].join("\n"));
66
+ const workspace = await loadNestedWorkspace(root);
67
+ if (check.workspaces.some((workspacePath) => isNestedWorkspace(workspace, workspacePath))) console.error(await formatNestedLockfileUpdate(workspace));
62
68
  exitCode = 1;
63
69
  }
70
+ if (exitCode) console.error(restoredMessage);
64
71
  return exitCode;
65
72
  }
73
+ /** Formats how to update a nested lockfile that lags behind the nested package.json files. */
74
+ async function formatNestedLockfileUpdate(workspace) {
75
+ const { dir } = workspace.config;
76
+ const packageDir = await findNestedPackageDir(workspace);
77
+ return packageDir ? `${dir}/bun.lock lags behind the package.json files of ${dir}. Update it and sync again:\n (cd ${quoteShellArgument(packageDir)} && bun install --lockfile-only)` : `${dir}/bun.lock lags behind the package.json files of ${dir}. Update it with bun install --lockfile-only in a separate clone of the nested repository and sync again.`;
78
+ }
79
+ /** Quotes a value for a POSIX shell when it contains characters other than those of plain paths. */
80
+ function quoteShellArgument(value) {
81
+ return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
82
+ }
66
83
  function printManifestSync({ overridden, addedTrustedDependencies }) {
67
84
  if (overridden.length) console.log(["Nested entries replaced host entries:", ...overridden.map(formatOverridden)].join("\n"));
68
85
  if (addedTrustedDependencies) console.warn([
package/dist/index.d.mts CHANGED
@@ -298,11 +298,15 @@ interface SyncOptions {
298
298
  */
299
299
  onManifestSync?: (sync: ManifestSync) => void;
300
300
  }
301
- /** Result of `syncNestedWorkspace`. */
301
+ /**
302
+ * Result of `syncNestedWorkspace`.
303
+ *
304
+ * The sync failed and restored the host files when `ownDependencyChanges` is not empty or `isInSync(check)` is false.
305
+ */
302
306
  interface SyncResult {
303
- /** Own dependencies of host workspaces that the sync re-resolved; the sync failed when this is not empty. */
307
+ /** Own dependencies of host workspaces that the sync re-resolved. */
304
308
  ownDependencyChanges: DependencyMismatch[];
305
- /** Check of the synced files; the sync failed when it is not in sync. */
309
+ /** Check of the synced files. */
306
310
  check: CheckResult;
307
311
  }
308
312
  /**
@@ -310,6 +314,9 @@ interface SyncResult {
310
314
  *
311
315
  * Writes the synced `package.json`, writes the merged `bun.lock`, runs `bun install`,
312
316
  * and then compares the result with the nested checkout and with the original host resolutions.
317
+ * When `bun install` fails or the result differs, restores both files before it throws or returns,
318
+ * so that the next sync starts from the original host resolutions.
319
+ * Throws an error that says so when it cannot restore them.
313
320
  */
314
321
  export declare function syncNestedWorkspace(root: string, options?: SyncOptions): Promise<SyncResult>;
315
322
  export type { CheckResult, DependencyMismatch, Lockfile, ManifestDifference, ManifestSync, NestedWorkspace, NestedWorkspaceConfig, OverriddenEntry, PackageManifest, PackageMismatch, ResolvedConfig, SyncOptions, SyncResult, SyncedEntries, SyncedField, WorkspacesConfig };
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-ChlHn5I8.mjs";
1
+ export { t as BunInstallError, p as applyManifestToLockfile, n as checkNestedWorkspace, v as diffManifests, c as findDirectDependencyMismatches, l as findOwnDependencyChanges, u as findTransitiveMismatches, d as findUncoveredWorkspaces, f as findWorkspaceMismatches, i as isInSync, o as loadNestedWorkspace, m as mergeLockfiles, h as parseLockfile, g as relocateLockfile, _ as supportedLockfileVersions, y as syncManifest, s as syncNestedWorkspace, b as syncedFields } from "./workspace-DOwKm8pX.mjs";
@@ -1,6 +1,6 @@
1
+ import path from "node:path";
1
2
  import JSON5 from "json5";
2
3
  import semver from "semver";
3
- import path from "node:path";
4
4
  // ../lockfile/src/parse.ts
5
5
  function isObject(value) {
6
6
  return typeof value === "object" && value !== null;
@@ -742,12 +742,40 @@ function resolveDependency(lock, workspaceName, dependency) {
742
742
  return key === void 0 ? void 0 : lock.packages[key][0];
743
743
  }
744
744
  // src/check.ts
745
+ /** Returns the `workspaces` patterns of a root `package.json`. */
746
+ function getWorkspacePatterns(manifest) {
747
+ const patterns = (Array.isArray(manifest.workspaces) ? manifest.workspaces : manifest.workspaces?.packages ?? []).map((pattern) => pattern.replace(/^(!?)\.\//, "$1").replace(/\/+$/, ""));
748
+ return {
749
+ included: patterns.filter((pattern) => !pattern.startsWith("!")),
750
+ excluded: patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1))
751
+ };
752
+ }
753
+ /**
754
+ * Returns whether the patterns include a directory relative to their root, as `bun install` decides it.
755
+ *
756
+ * The root itself is never included.
757
+ * A pattern without glob syntax includes its exact path regardless of exclusions.
758
+ * A glob pattern matches the `package.json` path of the directory, so `dir/**` includes `dir` itself,
759
+ * and skips `node_modules`, `.git`, and `CMakeFiles` directories.
760
+ * An exclusion `dir/**` also excludes `dir` itself.
761
+ */
762
+ function matchesWorkspacePatterns(patterns, dir) {
763
+ const isGlob = (pattern) => /[*?[\]{}]/.test(pattern);
764
+ const matches = (pattern) => new Bun.Glob(pattern).match(dir);
765
+ const matchesManifest = (pattern) => new Bun.Glob(path.posix.join(pattern, "package.json")).match(path.posix.join(dir, "package.json"));
766
+ if (path.posix.normalize(dir) === ".") return false;
767
+ if (patterns.included.some((pattern) => !isGlob(pattern) && path.posix.normalize(pattern) === dir)) return true;
768
+ return !dir.split("/").some((part) => ignoredGlobDirs.has(part)) && patterns.included.some((pattern) => isGlob(pattern) && matchesManifest(pattern)) && !patterns.excluded.some((pattern) => matches(pattern) || pattern.endsWith("/**") && matches(pattern.slice(0, -3)));
769
+ }
770
+ const ignoredGlobDirs = /* @__PURE__ */ new Set([
771
+ "node_modules",
772
+ ".git",
773
+ "CMakeFiles"
774
+ ]);
745
775
  /** Returns the nested workspace paths that the host `workspaces` patterns do not include. */
746
776
  function findUncoveredWorkspaces(manifest, nestedLock) {
747
- const patterns = (Array.isArray(manifest.workspaces) ? manifest.workspaces : manifest.workspaces?.packages ?? []).map((pattern) => pattern.replace(/^(!?)\.\//, "$1").replace(/\/+$/, ""));
748
- const included = patterns.filter((pattern) => !pattern.startsWith("!")).map((pattern) => new Bun.Glob(pattern));
749
- const excluded = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => new Bun.Glob(pattern.slice(1)));
750
- return Object.keys(nestedLock.workspaces).filter((workspacePath) => !included.some((glob) => glob.match(workspacePath)) || excluded.some((glob) => glob.match(workspacePath)));
777
+ const patterns = getWorkspacePatterns(manifest);
778
+ return Object.keys(nestedLock.workspaces).filter((workspacePath) => !matchesWorkspacePatterns(patterns, workspacePath));
751
779
  }
752
780
  /**
753
781
  * Returns the paths of nested workspaces whose host lockfile entries differ from the relocated nested lockfile,
@@ -891,6 +919,33 @@ async function loadNestedWorkspace(root) {
891
919
  nestedLock: relocateLockfile(await readLockfile(path.join(nestedRoot, "bun.lock")), config.dir, typeof nestedManifest.name === "string" ? nestedManifest.name : void 0)
892
920
  };
893
921
  }
922
+ /**
923
+ * Returns whether a workspace path of the host lockfile is a workspace of the nested repository:
924
+ * its root, a workspace of the nested lockfile, or a directory that the nested `workspaces` patterns include.
925
+ */
926
+ function isNestedWorkspace(workspace, workspacePath) {
927
+ const { dir } = workspace.config;
928
+ return workspacePath === dir || workspacePath in workspace.nestedLock.workspaces || isNestedPath(workspacePath, dir) && matchesWorkspacePatterns(getWorkspacePatterns(workspace.nestedManifest), workspacePath.slice(dir.length + 1));
929
+ }
930
+ /**
931
+ * Returns the first directory with a named package that the nested `workspaces` patterns include, relative to the host root.
932
+ *
933
+ * `bun install` treats the nested root as the workspace root there and writes the nested lockfile,
934
+ * while in the nested root itself it writes the host lockfile.
935
+ */
936
+ async function findNestedPackageDir(workspace) {
937
+ const nestedRoot = path.join(workspace.root, workspace.config.dir);
938
+ const patterns = getWorkspacePatterns(workspace.nestedManifest);
939
+ const dirs = /* @__PURE__ */ new Set();
940
+ for (const pattern of patterns.included) for await (const file of new Bun.Glob(`${pattern}/package.json`).scan(nestedRoot)) {
941
+ const dir = path.posix.normalize(path.posix.dirname(file.split(path.sep).join("/")));
942
+ if (matchesWorkspacePatterns(patterns, dir)) dirs.add(dir);
943
+ }
944
+ for (const dir of [...dirs].sort()) {
945
+ const manifest = await Bun.file(path.join(nestedRoot, dir, "package.json")).json();
946
+ if (typeof manifest.name === "string" && manifest.name) return `${workspace.config.dir}/${dir}`;
947
+ }
948
+ }
894
949
  /** Returns whether a check found no differences. */
895
950
  function isInSync(result) {
896
951
  return Object.values(result).every((list) => !list.length);
@@ -951,6 +1006,9 @@ async function runBunInstall(root, args = []) {
951
1006
  *
952
1007
  * Writes the synced `package.json`, writes the merged `bun.lock`, runs `bun install`,
953
1008
  * and then compares the result with the nested checkout and with the original host resolutions.
1009
+ * When `bun install` fails or the result differs, restores both files before it throws or returns,
1010
+ * so that the next sync starts from the original host resolutions.
1011
+ * Throws an error that says so when it cannot restore them.
954
1012
  */
955
1013
  async function syncNestedWorkspace(root, options = {}) {
956
1014
  const workspace = await loadNestedWorkspace(root);
@@ -958,20 +1016,56 @@ async function syncNestedWorkspace(root, options = {}) {
958
1016
  const manifestSync = syncManifest(workspace.manifest, workspace.nestedManifest);
959
1017
  const { manifest } = manifestSync;
960
1018
  const merged = mergeLockfiles(applyManifestToLockfile(workspace.lock, manifest), workspace.nestedLock, workspace.config.dir);
961
- const manifestText = formatJson(manifest, workspace.manifestText);
962
- if (manifestText !== workspace.manifestText) await Bun.write(path.join(root, "package.json"), manifestText);
963
- options.onManifestSync?.(manifestSync);
964
- await Bun.write(path.join(root, "bun.lock"), renderBunLock(merged));
965
- await runBunInstall(root, options.installArgs);
966
- const synced = await loadNestedWorkspace(root);
967
- return {
968
- ownDependencyChanges: findOwnDependencyChanges(workspace.lock, synced.lock, workspace.nestedLock, workspace.config.dir),
969
- check: checkNestedWorkspace(synced)
1019
+ const manifestPath = path.join(root, "package.json");
1020
+ const lockPath = path.join(root, "bun.lock");
1021
+ const originals = /* @__PURE__ */ new Map([[manifestPath, workspace.manifestText], [lockPath, await Bun.file(lockPath).text()]]);
1022
+ const restore = async () => {
1023
+ const failures = await restoreFiles(originals);
1024
+ if (failures.length) throw new Error([
1025
+ "The sync failed and could not restore the host files:",
1026
+ ...failures.map(({ file, error }) => ` ${path.basename(file)}: ${getErrorMessage(error)}`),
1027
+ "Restore them before running the sync again."
1028
+ ].join("\n"));
970
1029
  };
1030
+ let result;
1031
+ try {
1032
+ const manifestText = formatJson(manifest, workspace.manifestText);
1033
+ if (manifestText !== workspace.manifestText) await Bun.write(manifestPath, manifestText);
1034
+ options.onManifestSync?.(manifestSync);
1035
+ await Bun.write(lockPath, renderBunLock(merged));
1036
+ await runBunInstall(root, options.installArgs);
1037
+ const synced = await loadNestedWorkspace(root);
1038
+ result = {
1039
+ ownDependencyChanges: findOwnDependencyChanges(workspace.lock, synced.lock, workspace.nestedLock, workspace.config.dir),
1040
+ check: checkNestedWorkspace(synced)
1041
+ };
1042
+ } catch (error) {
1043
+ await restore();
1044
+ throw error;
1045
+ }
1046
+ if (result.ownDependencyChanges.length || !isInSync(result.check)) await restore();
1047
+ return result;
1048
+ }
1049
+ /**
1050
+ * Writes back the original texts of files whose contents differ from them and returns the files it could not restore.
1051
+ *
1052
+ * Restores each file separately, so that a failure does not keep the other files changed.
1053
+ */
1054
+ async function restoreFiles(originals) {
1055
+ const failures = [];
1056
+ for (const [file, text] of originals) try {
1057
+ if (await Bun.file(file).text().catch(() => void 0) !== text) await Bun.write(file, text);
1058
+ } catch (error) {
1059
+ failures.push({
1060
+ file,
1061
+ error
1062
+ });
1063
+ }
1064
+ return failures;
971
1065
  }
972
1066
  /** Serializes JSON with the indentation and final newline of the original text. */
973
1067
  function formatJson(value, original) {
974
1068
  const indent = /^[ \t]+(?=")/m.exec(original)?.[0] ?? " ";
975
1069
  return `${JSON.stringify(value, null, indent)}${original.endsWith("\n") ? "\n" : ""}`;
976
1070
  }
977
- 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 };
1071
+ export { supportedLockfileVersions as _, isNestedWorkspace as a, syncedFields as b, findDirectDependencyMismatches as c, findUncoveredWorkspaces as d, findWorkspaceMismatches as f, relocateLockfile as g, parseLockfile as h, isInSync as i, findOwnDependencyChanges as l, mergeLockfiles as m, checkNestedWorkspace as n, loadNestedWorkspace as o, applyManifestToLockfile as p, findNestedPackageDir as r, syncNestedWorkspace as s, BunInstallError as t, findTransitiveMismatches as u, diffManifests as v, getErrorMessage as x, syncManifest as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-nested-workspace",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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",