Skip to content

Commit 691ec7d

Browse files
authored
fix(align-deps): add support for Bun catalogs (#3822)
1 parent 339bfbf commit 691ec7d

12 files changed

Lines changed: 287 additions & 33 deletions

File tree

.changeset/stupid-emus-study.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@rnx-kit/align-deps": patch
3+
---
4+
5+
Support Bun catalogs

packages/align-deps/src/commands/exportCatalogs.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import yaml from "js-yaml";
2-
import * as fs from "node:fs";
2+
import * as nodefs from "node:fs";
33
import * as path from "node:path";
44
import { isMetaPackage } from "../capabilities";
55
import { mergePresets } from "../preset";
@@ -28,12 +28,25 @@ function compare(lhs: PackageDefinition, rhs: PackageDefinition): -1 | 0 | 1 {
2828
return lhs.name < rhs.name ? -1 : 1;
2929
}
3030

31-
function parseFile(filename: string): Config {
31+
function parseFile(filename: string, fs: typeof nodefs): Config {
3232
const content =
3333
fs.existsSync(filename) && fs.readFileSync(filename, { encoding: "utf-8" });
3434

3535
const extension = path.extname(filename).toLowerCase();
3636
switch (extension) {
37+
// Bun v1.2.14+
38+
case ".json": {
39+
const data = content ? JSON.parse(content) : {};
40+
const preferWorkspaces = !("catalog" in data || "catalogs" in data);
41+
if (preferWorkspaces && !data["workspaces"]) {
42+
data["workspaces"] = {};
43+
}
44+
return {
45+
data: preferWorkspaces ? data["workspaces"] : data,
46+
serialize: () => JSON.stringify(data, null, 2) + "\n",
47+
};
48+
}
49+
3750
// pnpm v9.5.0+ and Yarn v4.10.0+
3851
case ".yaml":
3952
case ".yml": {
@@ -47,8 +60,12 @@ function parseFile(filename: string): Config {
4760
throw new Error(`Unsupported file format: ${extension}`);
4861
}
4962

50-
export function exportCatalogs(dest: string, preset = defaultPreset): void {
51-
const config = parseFile(dest);
63+
export function exportCatalogs(
64+
dest: string,
65+
preset = defaultPreset,
66+
fs = nodefs
67+
): void {
68+
const config = parseFile(dest, fs);
5269

5370
const catalogs = config.data["catalogs"] ?? {};
5471
config.data["catalogs"] = catalogs;

packages/align-deps/test/__mocks__/fs.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ const actualFs = jest.requireActual("fs");
44
let data = "";
55

66
fs.__setMockContent = (content, space = 2) => {
7-
data = JSON.stringify(content, undefined, space) + "\n";
7+
data =
8+
typeof content === "string"
9+
? content
10+
: JSON.stringify(content, undefined, space) + "\n";
811
};
912

1013
fs.__setMockFileWriter = (writer) => {
1114
fs.writeFileSync = writer;
1215
};
1316

17+
fs.existsSync = (...args) => Boolean(data) || actualFs.existsSync(...args);
1418
fs.lstatSync = (...args) => actualFs.lstatSync(...args);
1519
fs.readFileSync = (...args) => data || actualFs.readFileSync(...args);
1620
fs.realpathSync = (...args) => actualFs.realpathSync(...args);

packages/align-deps/test/__snapshots__/check.app.test.ts.snap renamed to packages/align-deps/test/commands/__snapshots__/check.app.test.ts.snap

File renamed without changes.

packages/align-deps/test/__snapshots__/check.test.ts.snap renamed to packages/align-deps/test/commands/__snapshots__/check.test.ts.snap

File renamed without changes.

packages/align-deps/test/__snapshots__/vigilant.test.ts.snap renamed to packages/align-deps/test/commands/__snapshots__/vigilant.test.ts.snap

File renamed without changes.

packages/align-deps/test/check.app.test.ts renamed to packages/align-deps/test/commands/check.app.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as path from "node:path";
2-
import { checkPackageManifest as checkPackageManifestActual } from "../src/commands/check";
3-
import { defaultConfig } from "../src/config";
2+
import { checkPackageManifest as checkPackageManifestActual } from "../../src/commands/check";
3+
import { defaultConfig } from "../../src/config";
44

55
jest.unmock("@rnx-kit/config");
66

@@ -14,7 +14,7 @@ const defaultOptions = {
1414
};
1515

1616
function checkPackageManifest(manifestPath: string) {
17-
const fs = require("./__mocks__/fs.js");
17+
const fs = require("../__mocks__/fs.js");
1818
return checkPackageManifestActual(
1919
manifestPath,
2020
defaultOptions,
@@ -25,7 +25,7 @@ function checkPackageManifest(manifestPath: string) {
2525
}
2626

2727
function fixturePath(name: string) {
28-
return path.join(__dirname, "__fixtures__", name);
28+
return path.join(__dirname, "..", "__fixtures__", name);
2929
}
3030

3131
describe("checkPackageManifest({ kitType: 'app' })", () => {
@@ -42,7 +42,7 @@ describe("checkPackageManifest({ kitType: 'app' })", () => {
4242
});
4343

4444
describe("checkPackageManifest({ kitType: 'app' }) (backwards compatibility)", () => {
45-
const fs = require("./__mocks__/fs.js");
45+
const fs = require("../__mocks__/fs.js");
4646

4747
afterAll(() => {
4848
jest.clearAllMocks();

packages/align-deps/test/check.test.ts renamed to packages/align-deps/test/commands/check.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { checkPackageManifest as checkPackageManifestActual } from "../src/commands/check";
2-
import type { ConfigResult } from "../src/config";
3-
import { defaultConfig, loadConfig } from "../src/config";
4-
import { profile as profile_0_68 } from "../src/presets/microsoft/react-native/profile-0.68";
5-
import { profile as profile_0_69 } from "../src/presets/microsoft/react-native/profile-0.69";
6-
import { profile as profile_0_70 } from "../src/presets/microsoft/react-native/profile-0.70";
7-
import type { Options } from "../src/types";
8-
import { packageVersion } from "./helpers";
1+
import { checkPackageManifest as checkPackageManifestActual } from "../../src/commands/check";
2+
import type { ConfigResult } from "../../src/config";
3+
import { defaultConfig, loadConfig } from "../../src/config";
4+
import { profile as profile_0_68 } from "../../src/presets/microsoft/react-native/profile-0.68";
5+
import { profile as profile_0_69 } from "../../src/presets/microsoft/react-native/profile-0.69";
6+
import { profile as profile_0_70 } from "../../src/presets/microsoft/react-native/profile-0.70";
7+
import type { Options } from "../../src/types";
8+
import { packageVersion } from "../helpers";
99

1010
const defaultOptions = {
1111
presets: defaultConfig.presets,
@@ -27,7 +27,7 @@ function checkPackageManifest(
2727
_inputConfig?: ConfigResult,
2828
logError?: (message: string) => void
2929
) {
30-
const fs = require("./__mocks__/fs.js");
30+
const fs = require("../__mocks__/fs.js");
3131
return checkPackageManifestActual(
3232
manifestPath,
3333
options,
@@ -39,7 +39,7 @@ function checkPackageManifest(
3939

4040
describe("checkPackageManifest({ kitType: 'library' })", () => {
4141
const rnxKitConfig = require("@rnx-kit/config");
42-
const fs = require("./__mocks__/fs.js");
42+
const fs = require("../__mocks__/fs.js");
4343

4444
const consoleLogSpy = jest.spyOn(global.console, "log");
4545
const consoleWarnSpy = jest.spyOn(global.console, "warn");
@@ -491,7 +491,7 @@ describe("checkPackageManifest({ kitType: 'library' })", () => {
491491

492492
describe("checkPackageManifest({ kitType: 'library' }) (backwards compatibility)", () => {
493493
const rnxKitConfig = require("@rnx-kit/config");
494-
const fs = require("./__mocks__/fs.js");
494+
const fs = require("../__mocks__/fs.js");
495495

496496
const mockManifest = {
497497
name: "@rnx-kit/align-deps",
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { afterEach } from "node:test";
2+
import { exportCatalogs as exportCatalogsActual } from "../../src/commands/exportCatalogs.ts";
3+
import type { Preset } from "../../src/types.ts";
4+
5+
describe("exportCatalogs()", () => {
6+
const fs = require("../__mocks__/fs.js");
7+
8+
const preset = {
9+
"0.82": {
10+
react: {
11+
name: "react",
12+
version: "19.1.1",
13+
},
14+
core: {
15+
name: "react-native",
16+
version: "^0.82.0",
17+
},
18+
},
19+
"1.0": {
20+
react: {
21+
name: "react",
22+
version: "19.2.0",
23+
},
24+
core: {
25+
name: "react-native",
26+
version: "^1.0.0",
27+
},
28+
},
29+
} as unknown as Preset;
30+
31+
function exportCatalogs(dst: string, preset: Preset) {
32+
return exportCatalogsActual(dst, preset, fs);
33+
}
34+
35+
afterEach(() => {
36+
fs.__setMockContent({});
37+
fs.__setMockFileWriter(undefined);
38+
});
39+
40+
it("throws for unsupported file formats", () => {
41+
const output = "catalogs.bin";
42+
43+
expect(() => exportCatalogs(output, preset)).toThrow(
44+
"Unsupported file format: .bin"
45+
);
46+
});
47+
48+
it("supports pnpm catalogs", () => {
49+
const output = "catalogs.yaml";
50+
51+
fs.__setMockFileWriter((dst: string, content: string) => {
52+
expect(dst).toBe(output);
53+
expect(content).toBe(`catalogs:
54+
"0.82":
55+
react: 19.1.1
56+
react-native: ^0.82.0
57+
"1.0":
58+
react: 19.2.0
59+
react-native: ^1.0.0
60+
`);
61+
});
62+
63+
exportCatalogs(output, preset);
64+
});
65+
66+
it("preserves existing content in the output file", () => {
67+
const output = "catalogs.yaml";
68+
69+
fs.__setMockContent(`
70+
enableScripts: false
71+
globalFolder: .yarn/berry
72+
nodeLinker: pnpm
73+
catalogs:
74+
"0.81":
75+
react: 19.0.0
76+
react-native: ^0.81.0
77+
"0.82":
78+
react: 19.1.1
79+
react-native: ^0.82.0-0
80+
`);
81+
82+
fs.__setMockFileWriter((dst: string, content: string) => {
83+
expect(dst).toBe(output);
84+
expect(content).toBe(`enableScripts: false
85+
globalFolder: .yarn/berry
86+
nodeLinker: pnpm
87+
catalogs:
88+
"0.81":
89+
react: 19.0.0
90+
react-native: ^0.81.0
91+
"0.82":
92+
react: 19.1.1
93+
react-native: ^0.82.0
94+
"1.0":
95+
react: 19.2.0
96+
react-native: ^1.0.0
97+
`);
98+
});
99+
100+
exportCatalogs(output, preset);
101+
});
102+
103+
it("supports Bun catalogs (under 'workspaces')", () => {
104+
const output = "package.json";
105+
106+
fs.__setMockContent({
107+
name: "my-monorepo",
108+
workspaces: {
109+
packages: ["packages/*"],
110+
catalog: {
111+
react: "^19.0.0",
112+
"react-dom": "^19.0.0",
113+
},
114+
catalogs: {
115+
testing: {
116+
jest: "30.0.0",
117+
"testing-library": "14.0.0",
118+
},
119+
},
120+
},
121+
});
122+
123+
fs.__setMockFileWriter((dst: string, content: string) => {
124+
expect(dst).toBe(output);
125+
expect(content).toBe(`{
126+
"name": "my-monorepo",
127+
"workspaces": {
128+
"packages": [
129+
"packages/*"
130+
],
131+
"catalog": {
132+
"react": "^19.0.0",
133+
"react-dom": "^19.0.0"
134+
},
135+
"catalogs": {
136+
"testing": {
137+
"jest": "30.0.0",
138+
"testing-library": "14.0.0"
139+
},
140+
"0.82": {
141+
"react": "19.1.1",
142+
"react-native": "^0.82.0"
143+
},
144+
"1.0": {
145+
"react": "19.2.0",
146+
"react-native": "^1.0.0"
147+
}
148+
}
149+
}
150+
}
151+
`);
152+
});
153+
154+
exportCatalogs(output, preset);
155+
});
156+
157+
it("supports Bun catalogs (at the root level)", () => {
158+
const output = "package.json";
159+
160+
fs.__setMockContent({
161+
name: "my-monorepo",
162+
workspaces: {
163+
packages: ["packages/*"],
164+
},
165+
catalog: {
166+
react: "^19.0.0",
167+
"react-dom": "^19.0.0",
168+
},
169+
});
170+
171+
fs.__setMockFileWriter((dst: string, content: string) => {
172+
expect(dst).toBe(output);
173+
expect(content).toBe(`{
174+
"name": "my-monorepo",
175+
"workspaces": {
176+
"packages": [
177+
"packages/*"
178+
]
179+
},
180+
"catalog": {
181+
"react": "^19.0.0",
182+
"react-dom": "^19.0.0"
183+
},
184+
"catalogs": {
185+
"0.82": {
186+
"react": "19.1.1",
187+
"react-native": "^0.82.0"
188+
},
189+
"1.0": {
190+
"react": "19.2.0",
191+
"react-native": "^1.0.0"
192+
}
193+
}
194+
}
195+
`);
196+
});
197+
198+
exportCatalogs(output, preset);
199+
});
200+
201+
it("prefers catalogs under 'workspaces'", () => {
202+
const output = "package.json";
203+
204+
fs.__setMockContent({ name: "my-monorepo" });
205+
206+
fs.__setMockFileWriter((dst: string, content: string) => {
207+
expect(dst).toBe(output);
208+
expect(content).toBe(`{
209+
"name": "my-monorepo",
210+
"workspaces": {
211+
"catalogs": {
212+
"0.82": {
213+
"react": "19.1.1",
214+
"react-native": "^0.82.0"
215+
},
216+
"1.0": {
217+
"react": "19.2.0",
218+
"react-native": "^1.0.0"
219+
}
220+
}
221+
}
222+
}
223+
`);
224+
});
225+
226+
exportCatalogs(output, preset);
227+
});
228+
});

packages/align-deps/test/initialize.test.ts renamed to packages/align-deps/test/commands/initialize.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {
22
initializeConfig,
33
makeInitializeCommand,
4-
} from "../src/commands/initialize";
5-
import { defaultConfig } from "../src/config";
4+
} from "../../src/commands/initialize";
5+
import { defaultConfig } from "../../src/config";
66

77
const defaultOptions = {
88
presets: defaultConfig.presets,

0 commit comments

Comments
 (0)