diff --git a/packages/core/test/mcp-oauth.test.ts b/packages/core/test/mcp-oauth.test.ts index 6b9a3302d9b6..bb99ff0b2188 100644 --- a/packages/core/test/mcp-oauth.test.ts +++ b/packages/core/test/mcp-oauth.test.ts @@ -162,6 +162,6 @@ describe("MCP OAuth", () => { }) test("rejects an invalid redirect URL", async () => { - await expect(authorize("not a URL")).rejects.toThrow("cannot be parsed as a URL") + await expect(authorize("not a URL")).rejects.toThrow(TypeError) }) }) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 13de58519878..b2ce86a884b7 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -66,7 +66,7 @@ async function createRegistryFixture(directory: string) { exports: "./index.js", }) await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`) - await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package` + await Bun.$`tar -czf package.tgz package`.cwd(root) tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes()) } const state = { latest: "1.0.0" } @@ -252,7 +252,7 @@ describe("Npm.add", () => { } }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) - expect(entries.added.entrypoint).toEndWith("/index.js") + expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href) expect(entries.added.version).toBe(fixture.commit) expect(entries.cached).toEqual(entries.added) expect(entries.resolved).toEqual(entries.added) diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index efd9032612a3..461958fe97d0 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -133,10 +133,13 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[ interface ArboristNode { name: string path: string + realpath: string + isLink: boolean } interface ArboristTree { edgesOut: Map + inventory: { values(): IterableIterator } } const PackageJson = Schema.Struct({ @@ -293,10 +296,17 @@ const layer = Layer.effect( installedNameValue, installed?.path ?? path.join(staging, "node_modules", installedNameValue), target, - subpaths, + // Resolve installed entrypoints after rename so Bun cannot hold the staging directory open on Windows. + installed ? [] : subpaths, ) if (!installed && !result.entrypoint) return yield* new InstallFailedError({ add: [pkg], dir: staging }) - return { name: installedNameValue, result } + const links = + process.platform === "win32" + ? Array.from(tree.inventory.values()).filter( + (node) => node.isLink && FSUtil.contains(staging, node.path) && FSUtil.contains(staging, node.realpath), + ) + : [] + return { name: installedNameValue, result, links } }).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore))) if (active) { @@ -310,6 +320,22 @@ const layer = Layer.effect( const completedAt = yield* Clock.currentTimeMillis const newest = Number((yield* generations(dir)).at(-1) ?? 0) const generation = path.join(dir, String(Math.max(completedAt, newest + 1))) + // Windows junctions use absolute targets, so rebase internal links before publishing the generation. + if (staged.links.length > 0) { + const { unlink, symlink } = yield* Effect.promise(() => import("node:fs/promises")) + yield* Effect.forEach( + staged.links, + (link) => + Effect.tryPromise({ + try: async () => { + await unlink(link.path) + await symlink(path.join(generation, path.relative(staging, link.realpath)), link.path, "junction") + }, + catch: (cause) => new InstallFailedError({ dir, cause }), + }), + { discard: true }, + ).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore))) + } yield* rename(staging, generation, dir) return yield* entry( generation,