isolate-package 1.30.0-0 → 1.31.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/src/lib/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { detectMonorepo } from "detect-monorepo";
2
3
  import fs from "fs-extra";
3
4
  import path from "node:path";
4
5
  import { pathToFileURL } from "node:url";
@@ -14,7 +15,13 @@ export type IsolateConfigResolved = {
14
15
  targetPackagePath?: string;
15
16
  tsconfigPath: string;
16
17
  workspacePackages?: string[];
17
- workspaceRoot: string;
18
+ /**
19
+ * Path to the workspace root, relative to the target package directory.
20
+ * When omitted, the workspace root is auto-detected by walking upward from
21
+ * the target package directory looking for a pnpm-workspace.yaml, a
22
+ * package.json with a `workspaces` field, or a rush.json.
23
+ */
24
+ workspaceRoot?: string;
18
25
  forceNpm: boolean;
19
26
  pickFromScripts?: string[];
20
27
  omitFromScripts?: string[];
@@ -31,7 +38,7 @@ const configDefaults: IsolateConfigResolved = {
31
38
  targetPackagePath: undefined,
32
39
  tsconfigPath: "./tsconfig.json",
33
40
  workspacePackages: undefined,
34
- workspaceRoot: "../..",
41
+ workspaceRoot: undefined,
35
42
  forceNpm: false,
36
43
  pickFromScripts: undefined,
37
44
  omitFromScripts: undefined,
@@ -170,17 +177,35 @@ function validateConfig(config: IsolateConfig) {
170
177
  * Resolve the target package directory and workspace root directory from the
171
178
  * configuration. When targetPackagePath is set, the config is assumed to live
172
179
  * at the workspace root. Otherwise it lives in the target package directory.
180
+ *
181
+ * When `workspaceRoot` is not explicitly set, auto-detect the monorepo root by
182
+ * walking upward from the target package directory.
173
183
  */
174
184
  export function resolveWorkspacePaths(config: IsolateConfigResolved) {
175
185
  const targetPackageDir = config.targetPackagePath
176
186
  ? path.join(process.cwd(), config.targetPackagePath)
177
187
  : process.cwd();
178
188
 
179
- const workspaceRootDir = config.targetPackagePath
180
- ? process.cwd()
181
- : path.join(targetPackageDir, config.workspaceRoot);
189
+ if (config.targetPackagePath) {
190
+ return { targetPackageDir, workspaceRootDir: process.cwd() };
191
+ }
192
+
193
+ if (config.workspaceRoot !== undefined) {
194
+ return {
195
+ targetPackageDir,
196
+ workspaceRootDir: path.join(targetPackageDir, config.workspaceRoot),
197
+ };
198
+ }
199
+
200
+ const detected = detectMonorepo(targetPackageDir);
201
+
202
+ if (!detected) {
203
+ throw new Error(
204
+ `Failed to auto-detect monorepo workspace root from ${targetPackageDir}. Set the 'workspaceRoot' config option explicitly.`,
205
+ );
206
+ }
182
207
 
183
- return { targetPackageDir, workspaceRootDir };
208
+ return { targetPackageDir, workspaceRootDir: detected.rootDir };
184
209
  }
185
210
 
186
211
  export function resolveConfig(
@@ -211,7 +211,11 @@ export async function generateBunLockfile({
211
211
  const internalDepWorkspaceKeys = new Map<string, string>();
212
212
  for (const name of internalDepPackageNames) {
213
213
  const pkg = got(packagesRegistry, name);
214
- internalDepWorkspaceKeys.set(name, pkg.rootRelativeDir);
214
+ /** Normalize to POSIX separators for matching bun.lock workspace keys */
215
+ const workspaceKey = pkg.rootRelativeDir
216
+ .split(path.sep)
217
+ .join(path.posix.sep);
218
+ internalDepWorkspaceKeys.set(name, workspaceKey);
215
219
  }
216
220
 
217
221
  /** Build the filtered workspaces object */
@@ -219,7 +223,13 @@ export async function generateBunLockfile({
219
223
 
220
224
  /** Remap the target workspace to root ("") */
221
225
  const targetEntry = lockfile.workspaces[targetWorkspaceKey];
222
- if (targetEntry) {
226
+ if (!targetEntry) {
227
+ throw new Error(
228
+ `Target workspace "${targetWorkspaceKey}" not found in bun.lock. Available workspaces: ${Object.keys(lockfile.workspaces).join(", ")}`,
229
+ );
230
+ }
231
+
232
+ {
223
233
  const entry = { ...targetEntry };
224
234
  if (!includeDevDependencies) {
225
235
  delete entry.devDependencies;
@@ -330,7 +340,11 @@ export async function generateBunLockfile({
330
340
  }
331
341
 
332
342
  const outputPath = path.join(isolateDir, "bun.lock");
333
- await fs.writeFile(outputPath, serializeWithTrailingCommas(outputLockfile));
343
+ /** Append trailing newline to match Bun's native output format */
344
+ await fs.writeFile(
345
+ outputPath,
346
+ serializeWithTrailingCommas(outputLockfile) + "\n",
347
+ );
334
348
 
335
349
  log.debug("Created lockfile at", outputPath);
336
350
  } catch (err) {
@@ -56,7 +56,8 @@ export async function adaptTargetPackageManifest({
56
56
  ? /**
57
57
  * For PNPM and Bun the output itself is a workspace so we can preserve
58
58
  * the specifiers with "workspace:*" in the output manifest, but we do
59
- * want to adopt the pnpm.overrides field from the root package.json.
59
+ * want to adopt workspace-level fields from the root package.json
60
+ * (pnpm.overrides for PNPM, top-level overrides for Bun).
60
61
  */
61
62
  await adoptPnpmFieldsFromRoot(
62
63
  manifestWithResolvedCatalogs,
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import type { PackageManifest, PackagesRegistry } from "~/lib/types";
3
+
4
+ /** Mock dependencies */
5
+ vi.mock("~/lib/package-manager", () => ({
6
+ usePackageManager: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("../io", () => ({
10
+ writeManifest: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("./adapt-manifest-internal-deps", () => ({
14
+ adaptManifestInternalDeps: vi.fn(({ manifest }) => manifest),
15
+ }));
16
+
17
+ vi.mock("./resolve-catalog-dependencies", () => ({
18
+ resolveCatalogDependencies: vi.fn((deps) => Promise.resolve(deps)),
19
+ }));
20
+
21
+ const { usePackageManager } = vi.mocked(await import("~/lib/package-manager"));
22
+
23
+ const { writeManifest } = vi.mocked(await import("../io"));
24
+
25
+ const { adaptInternalPackageManifests } =
26
+ await import("./adapt-internal-package-manifests");
27
+
28
+ describe("adaptInternalPackageManifests", () => {
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ usePackageManager.mockReturnValue({
32
+ name: "pnpm",
33
+ version: "9.0.0",
34
+ majorVersion: 9,
35
+ });
36
+ });
37
+
38
+ function createRegistry(
39
+ entries: Record<
40
+ string,
41
+ { rootRelativeDir: string; manifest: PackageManifest }
42
+ >,
43
+ ): PackagesRegistry {
44
+ const registry: PackagesRegistry = {};
45
+ for (const [name, { rootRelativeDir, manifest }] of Object.entries(
46
+ entries,
47
+ )) {
48
+ registry[name] = {
49
+ absoluteDir: `/workspace/${rootRelativeDir}`,
50
+ rootRelativeDir,
51
+ manifest,
52
+ };
53
+ }
54
+ return registry;
55
+ }
56
+
57
+ it("should preserve scripts in internal dependency manifests", async () => {
58
+ const manifest: PackageManifest = {
59
+ name: "@repo/database",
60
+ version: "1.0.0",
61
+ scripts: {
62
+ postinstall: "prisma generate",
63
+ build: "tsc",
64
+ },
65
+ dependencies: {
66
+ prisma: "^5.0.0",
67
+ },
68
+ devDependencies: {
69
+ typescript: "^5.0.0",
70
+ },
71
+ };
72
+
73
+ const packagesRegistry = createRegistry({
74
+ "@repo/database": {
75
+ rootRelativeDir: "packages/database",
76
+ manifest,
77
+ },
78
+ });
79
+
80
+ await adaptInternalPackageManifests({
81
+ internalPackageNames: ["@repo/database"],
82
+ packagesRegistry,
83
+ isolateDir: "/output",
84
+ forceNpm: false,
85
+ workspaceRootDir: "/workspace",
86
+ });
87
+
88
+ expect(writeManifest).toHaveBeenCalledOnce();
89
+
90
+ const writtenManifest = writeManifest.mock.calls[0]![1];
91
+
92
+ expect(writtenManifest.scripts).toEqual({
93
+ postinstall: "prisma generate",
94
+ build: "tsc",
95
+ });
96
+ });
97
+
98
+ it("should strip the prepare script from internal dependency manifests", async () => {
99
+ const manifest: PackageManifest = {
100
+ name: "@repo/common",
101
+ version: "1.0.0",
102
+ scripts: {
103
+ prepare: "npm run clean && npm run build",
104
+ clean: "del-cli dist",
105
+ build: "tsdown",
106
+ postinstall: "prisma generate",
107
+ },
108
+ dependencies: {
109
+ ky: "^1.0.0",
110
+ },
111
+ devDependencies: {
112
+ "del-cli": "^7.0.0",
113
+ tsdown: "^0.20.0",
114
+ },
115
+ };
116
+
117
+ const packagesRegistry = createRegistry({
118
+ "@repo/common": {
119
+ rootRelativeDir: "packages/common",
120
+ manifest,
121
+ },
122
+ });
123
+
124
+ await adaptInternalPackageManifests({
125
+ internalPackageNames: ["@repo/common"],
126
+ packagesRegistry,
127
+ isolateDir: "/output",
128
+ forceNpm: false,
129
+ workspaceRootDir: "/workspace",
130
+ });
131
+
132
+ expect(writeManifest).toHaveBeenCalledOnce();
133
+
134
+ const writtenManifest = writeManifest.mock.calls[0]![1];
135
+
136
+ /** prepare should be stripped because it depends on devDependency binaries */
137
+ expect(writtenManifest.scripts?.prepare).toBeUndefined();
138
+
139
+ /** Other scripts should be preserved */
140
+ expect(writtenManifest.scripts).toEqual({
141
+ clean: "del-cli dist",
142
+ build: "tsdown",
143
+ postinstall: "prisma generate",
144
+ });
145
+ });
146
+
147
+ it("should strip devDependencies from internal dependency manifests", async () => {
148
+ const manifest: PackageManifest = {
149
+ name: "@repo/shared",
150
+ version: "1.0.0",
151
+ dependencies: {
152
+ lodash: "^4.0.0",
153
+ },
154
+ devDependencies: {
155
+ vitest: "^1.0.0",
156
+ typescript: "^5.0.0",
157
+ },
158
+ };
159
+
160
+ const packagesRegistry = createRegistry({
161
+ "@repo/shared": {
162
+ rootRelativeDir: "packages/shared",
163
+ manifest,
164
+ },
165
+ });
166
+
167
+ await adaptInternalPackageManifests({
168
+ internalPackageNames: ["@repo/shared"],
169
+ packagesRegistry,
170
+ isolateDir: "/output",
171
+ forceNpm: false,
172
+ workspaceRootDir: "/workspace",
173
+ });
174
+
175
+ expect(writeManifest).toHaveBeenCalledOnce();
176
+
177
+ const writtenManifest = writeManifest.mock.calls[0]![1];
178
+
179
+ expect(writtenManifest.devDependencies).toBeUndefined();
180
+ });
181
+ });
@@ -31,8 +31,19 @@ export async function adaptInternalPackageManifests({
31
31
  internalPackageNames.map(async (packageName) => {
32
32
  const { manifest, rootRelativeDir } = got(packagesRegistry, packageName);
33
33
 
34
- /** Dev dependencies and scripts are never included for internal deps */
35
- const strippedManifest = omit(manifest, ["scripts", "devDependencies"]);
34
+ /** Dev dependencies are never included for internal deps */
35
+ const strippedManifest = omit(manifest, ["devDependencies"]);
36
+
37
+ /**
38
+ * Strip the `prepare` script because it runs during `pnpm install` and
39
+ * typically depends on devDependency binaries (e.g. tsdown, del-cli)
40
+ * which are not available in the isolated output. Other lifecycle
41
+ * scripts like `postinstall` are preserved because they handle runtime
42
+ * setup (e.g. Prisma client generation).
43
+ */
44
+ if (strippedManifest.scripts) {
45
+ strippedManifest.scripts = omit(strippedManifest.scripts, ["prepare"]);
46
+ }
36
47
 
37
48
  /** Resolve catalog dependencies before adapting internal deps */
38
49
  const manifestWithResolvedCatalogs = {
@@ -44,10 +55,11 @@ export async function adaptInternalPackageManifests({
44
55
  };
45
56
 
46
57
  const outputManifest =
47
- packageManager.name === "pnpm" && !forceNpm
58
+ (packageManager.name === "pnpm" || packageManager.name === "bun") &&
59
+ !forceNpm
48
60
  ? /**
49
- * For PNPM the output itself is a workspace so we can preserve the specifiers
50
- * with "workspace:*" in the output manifest.
61
+ * For PNPM and Bun the output itself is a workspace so we can preserve
62
+ * the specifiers with "workspace:*" in the output manifest.
51
63
  */
52
64
  manifestWithResolvedCatalogs
53
65
  : /** For other package managers we replace the links to internal dependencies */
@@ -12,10 +12,16 @@ vi.mock("~/lib/utils", () => ({
12
12
  readTypedJson: vi.fn(),
13
13
  }));
14
14
 
15
+ vi.mock("~/lib/package-manager", () => ({
16
+ usePackageManager: vi.fn(() => ({ name: "pnpm", majorVersion: 9 })),
17
+ }));
18
+
15
19
  const { isRushWorkspace, readTypedJson } = vi.mocked(
16
20
  await import("~/lib/utils"),
17
21
  );
18
22
 
23
+ const { usePackageManager } = vi.mocked(await import("~/lib/package-manager"));
24
+
19
25
  describe("adoptPnpmFieldsFromRoot", () => {
20
26
  beforeEach(() => {
21
27
  vi.clearAllMocks();
@@ -209,4 +215,59 @@ describe("adoptPnpmFieldsFromRoot", () => {
209
215
  },
210
216
  });
211
217
  });
218
+
219
+ it("should adopt top-level overrides for Bun", async () => {
220
+ usePackageManager.mockReturnValue({
221
+ name: "bun",
222
+ majorVersion: 1,
223
+ version: "1.0.0",
224
+ packageManagerString: "bun@1.0.0",
225
+ } as ReturnType<typeof usePackageManager>);
226
+ isRushWorkspace.mockReturnValue(false);
227
+ readTypedJson.mockResolvedValue({
228
+ name: "root",
229
+ version: "1.0.0",
230
+ overrides: {
231
+ foo: "^1.0.0",
232
+ },
233
+ } as unknown as ProjectManifest);
234
+
235
+ const targetManifest: PackageManifest = {
236
+ name: "test-package",
237
+ version: "1.0.0",
238
+ };
239
+
240
+ const result = await adoptPnpmFieldsFromRoot(targetManifest, "/workspace");
241
+
242
+ expect(result).toEqual({
243
+ name: "test-package",
244
+ version: "1.0.0",
245
+ overrides: {
246
+ foo: "^1.0.0",
247
+ },
248
+ });
249
+ });
250
+
251
+ it("should return original manifest for Bun when no overrides are present", async () => {
252
+ usePackageManager.mockReturnValue({
253
+ name: "bun",
254
+ majorVersion: 1,
255
+ version: "1.0.0",
256
+ packageManagerString: "bun@1.0.0",
257
+ } as ReturnType<typeof usePackageManager>);
258
+ isRushWorkspace.mockReturnValue(false);
259
+ readTypedJson.mockResolvedValue({
260
+ name: "root",
261
+ version: "1.0.0",
262
+ } as ProjectManifest);
263
+
264
+ const targetManifest: PackageManifest = {
265
+ name: "test-package",
266
+ version: "1.0.0",
267
+ };
268
+
269
+ const result = await adoptPnpmFieldsFromRoot(targetManifest, "/workspace");
270
+
271
+ expect(result).toEqual(targetManifest);
272
+ });
212
273
  });
@@ -1,12 +1,13 @@
1
1
  import type { ProjectManifest, PnpmSettings } from "@pnpm/types";
2
2
  import path from "path";
3
+ import { usePackageManager } from "~/lib/package-manager";
3
4
  import type { PackageManifest } from "~/lib/types";
4
5
  import { isRushWorkspace, readTypedJson } from "~/lib/utils";
5
6
 
6
7
  /**
7
- * Adopts the `pnpm` fields from the root package manifest. Currently it takes
8
- * overrides, onlyBuiltDependencies, and ignoredBuiltDependencies, because these
9
- * are typically workspace-level configuration settings.
8
+ * Adopts workspace-level fields from the root package manifest. For pnpm this
9
+ * reads overrides, onlyBuiltDependencies, and ignoredBuiltDependencies from the
10
+ * `pnpm` key. For Bun it reads `overrides` from the top level.
10
11
  */
11
12
  export async function adoptPnpmFieldsFromRoot(
12
13
  targetPackageManifest: PackageManifest,
@@ -20,6 +21,44 @@ export async function adoptPnpmFieldsFromRoot(
20
21
  path.join(workspaceRootDir, "package.json"),
21
22
  );
22
23
 
24
+ const packageManager = usePackageManager();
25
+
26
+ if (packageManager.name === "bun") {
27
+ return adoptBunFieldsFromRoot(targetPackageManifest, rootPackageManifest);
28
+ }
29
+
30
+ return adoptPnpmFieldsOnly(targetPackageManifest, rootPackageManifest);
31
+ }
32
+
33
+ /** Adopt Bun's top-level overrides from the root manifest */
34
+ function adoptBunFieldsFromRoot(
35
+ targetPackageManifest: PackageManifest,
36
+ rootPackageManifest: ProjectManifest,
37
+ ): PackageManifest {
38
+ /**
39
+ * Bun supports `overrides` at the top level of package.json (same as npm).
40
+ * Read from the root manifest and set them on the output manifest so that
41
+ * `bun install --frozen-lockfile` succeeds.
42
+ */
43
+ const overrides = (rootPackageManifest as Record<string, unknown>)[
44
+ "overrides"
45
+ ] as Record<string, string> | undefined;
46
+
47
+ if (!overrides) {
48
+ return targetPackageManifest;
49
+ }
50
+
51
+ return {
52
+ ...targetPackageManifest,
53
+ overrides,
54
+ } as PackageManifest;
55
+ }
56
+
57
+ /** Adopt pnpm-specific fields from the root manifest */
58
+ function adoptPnpmFieldsOnly(
59
+ targetPackageManifest: PackageManifest,
60
+ rootPackageManifest: ProjectManifest,
61
+ ): PackageManifest {
23
62
  const { overrides, onlyBuiltDependencies, ignoredBuiltDependencies } =
24
63
  rootPackageManifest.pnpm || {};
25
64
 
@@ -43,7 +43,7 @@ describe("validateManifestMandatoryFields", () => {
43
43
 
44
44
  expect(() =>
45
45
  validateManifestMandatoryFields(invalidDevManifest, packagePath, false),
46
- ).toThrow(/missing mandatory fields: version/);
46
+ ).toThrow(/missing the "version" field/);
47
47
  });
48
48
 
49
49
  it("should throw error when version field is missing", () => {
@@ -54,7 +54,7 @@ describe("validateManifestMandatoryFields", () => {
54
54
 
55
55
  expect(() =>
56
56
  validateManifestMandatoryFields(invalidManifest, packagePath),
57
- ).toThrow(/missing mandatory fields: version/);
57
+ ).toThrow(/missing the "version" field/);
58
58
  });
59
59
 
60
60
  it("should throw error when files field is missing", () => {
@@ -65,7 +65,7 @@ describe("validateManifestMandatoryFields", () => {
65
65
 
66
66
  expect(() =>
67
67
  validateManifestMandatoryFields(invalidManifest, packagePath),
68
- ).toThrow(/missing mandatory fields: files/);
68
+ ).toThrow(/missing the "files" field/);
69
69
  });
70
70
 
71
71
  it("should throw error when files field is empty array", () => {
@@ -77,7 +77,7 @@ describe("validateManifestMandatoryFields", () => {
77
77
 
78
78
  expect(() =>
79
79
  validateManifestMandatoryFields(invalidManifest, packagePath),
80
- ).toThrow(/missing mandatory fields: files/);
80
+ ).toThrow(/missing the "files" field/);
81
81
  });
82
82
 
83
83
  it("should throw error when files field is not an array", () => {
@@ -89,7 +89,7 @@ describe("validateManifestMandatoryFields", () => {
89
89
 
90
90
  expect(() =>
91
91
  validateManifestMandatoryFields(invalidManifest, packagePath),
92
- ).toThrow(/missing mandatory fields: files/);
92
+ ).toThrow(/missing the "files" field/);
93
93
  });
94
94
 
95
95
  it("should throw error when both fields are missing", () => {
@@ -99,20 +99,29 @@ describe("validateManifestMandatoryFields", () => {
99
99
 
100
100
  expect(() =>
101
101
  validateManifestMandatoryFields(invalidManifest, packagePath),
102
- ).toThrow(/missing mandatory fields: version, files/);
102
+ ).toThrow(/missing mandatory fields in its package\.json: version, files/);
103
103
  });
104
104
 
105
- it("should include helpful error message", () => {
105
+ it("should include documentation URL in error message", () => {
106
106
  const invalidManifest = {
107
107
  name: "test-package",
108
108
  } as PackageManifest;
109
109
 
110
110
  expect(() =>
111
111
  validateManifestMandatoryFields(invalidManifest, packagePath),
112
- ).toThrow(/missing mandatory fields: version, files/);
112
+ ).toThrow(
113
+ /See https:\/\/isolate-package\.codecompose\.dev\/getting-started#prerequisites/,
114
+ );
115
+
116
+ const singleFieldManifest = {
117
+ name: "test-package",
118
+ version: "1.0.0",
119
+ } as PackageManifest;
113
120
 
114
121
  expect(() =>
115
- validateManifestMandatoryFields(invalidManifest, packagePath),
116
- ).toThrow(/See the documentation for more details/);
122
+ validateManifestMandatoryFields(singleFieldManifest, packagePath),
123
+ ).toThrow(
124
+ /See https:\/\/isolate-package\.codecompose\.dev\/getting-started#define-files-field-in-each-package-manifest/,
125
+ );
117
126
  });
118
127
  });
@@ -1,6 +1,14 @@
1
1
  import { useLogger } from "../logger";
2
2
  import type { PackageManifest } from "../types";
3
3
 
4
+ /** Maps field names to their documentation URLs */
5
+ const fieldDocUrls: Record<string, string> = {
6
+ version:
7
+ "https://isolate-package.codecompose.dev/getting-started#define-version-field-in-each-package-manifest",
8
+ files:
9
+ "https://isolate-package.codecompose.dev/getting-started#define-files-field-in-each-package-manifest",
10
+ };
11
+
4
12
  /**
5
13
  * Validate that mandatory fields are present in the package manifest. These
6
14
  * fields are required for the isolate process to work properly.
@@ -38,7 +46,11 @@ export function validateManifestMandatoryFields(
38
46
  }
39
47
 
40
48
  if (missingFields.length > 0) {
41
- const errorMessage = `Package at ${packagePath} is missing mandatory fields: ${missingFields.join(", ")}. See the documentation for more details.`;
49
+ const field = missingFields[0]!;
50
+ const errorMessage =
51
+ missingFields.length === 1
52
+ ? `Package at ${packagePath} is missing the "${field}" field in its package.json. See ${fieldDocUrls[field] ?? "https://isolate-package.codecompose.dev/getting-started#prerequisites"}`
53
+ : `Package at ${packagePath} is missing mandatory fields in its package.json: ${missingFields.join(", ")}. See https://isolate-package.codecompose.dev/getting-started#prerequisites`;
42
54
 
43
55
  log.error(errorMessage);
44
56
  throw new Error(errorMessage);