From 8e2cf8665a8db5c68b341e49393b593d8cf2867f Mon Sep 17 00:00:00 2001 From: Dylan Conway <35280289+dylan-conway@users.noreply.github.com> Date: Fri, 24 Jan 2025 18:48:42 -0800 Subject: [PATCH] fix(publish): `"tarball"` and `"_attachment"` path fix (#16630) --- src/cli/pack_command.zig | 22 +- src/cli/publish_command.zig | 14 +- test/cli/install/bun-install-registry.test.ts | 903 ++---------------- test/cli/install/bun-publish.test.ts | 850 +++++++++++++++++ test/harness.ts | 48 + 5 files changed, 986 insertions(+), 851 deletions(-) create mode 100644 test/cli/install/bun-publish.test.ts diff --git a/src/cli/pack_command.zig b/src/cli/pack_command.zig index 7a89ca9fbb..511342cbf5 100644 --- a/src/cli/pack_command.zig +++ b/src/cli/pack_command.zig @@ -1402,7 +1402,7 @@ pub const PackCommand = struct { if (comptime !for_publish) { if (manager.options.pack_destination.len == 0) { - Output.pretty("\n{}\n", .{fmtTarballFilename(package_name, package_version)}); + Output.pretty("\n{}\n", .{fmtTarballFilename(package_name, package_version, .normalize)}); } else { var dest_buf: PathBuffer = undefined; const abs_tarball_dest, _ = absTarballDestination( @@ -1726,7 +1726,6 @@ pub const PackCommand = struct { json.source, shasum, integrity, - abs_tarball_dest, ); printArchivedFilesAndPackages( @@ -1739,7 +1738,7 @@ pub const PackCommand = struct { if (comptime !for_publish) { if (manager.options.pack_destination.len == 0) { - Output.pretty("\n{}\n", .{fmtTarballFilename(package_name, package_version)}); + Output.pretty("\n{}\n", .{fmtTarballFilename(package_name, package_version, .normalize)}); } else { Output.pretty("\n{s}\n", .{abs_tarball_dest}); } @@ -1809,11 +1808,11 @@ pub const PackCommand = struct { ); const tarball_name = std.fmt.bufPrint(dest_buf[strings.withoutTrailingSlash(tarball_destination_dir).len..], "/{}\x00", .{ - fmtTarballFilename(package_name, package_version), + fmtTarballFilename(package_name, package_version, .normalize), }) catch { Output.errGeneric("archive destination name too long: \"{s}/{}\"", .{ strings.withoutTrailingSlash(tarball_destination_dir), - fmtTarballFilename(package_name, package_version), + fmtTarballFilename(package_name, package_version, .normalize), }); Global.crash(); }; @@ -1824,18 +1823,29 @@ pub const PackCommand = struct { }; } - fn fmtTarballFilename(package_name: string, package_version: string) TarballNameFormatter { + pub fn fmtTarballFilename(package_name: string, package_version: string, style: TarballNameFormatter.Style) TarballNameFormatter { return .{ .package_name = package_name, .package_version = package_version, + .style = style, }; } const TarballNameFormatter = struct { package_name: string, package_version: string, + style: Style, + + pub const Style = enum { + normalize, + raw, + }; pub fn format(this: TarballNameFormatter, comptime _: string, _: std.fmt.FormatOptions, writer: anytype) !void { + if (this.style == .raw) { + return writer.print("{s}-{s}.tgz", .{ this.package_name, this.package_version }); + } + if (this.package_name[0] == '@') { if (this.package_name.len > 1) { if (strings.indexOfChar(this.package_name, '/')) |slash| { diff --git a/src/cli/publish_command.zig b/src/cli/publish_command.zig index d1e26d06fc..43ff1eaabc 100644 --- a/src/cli/publish_command.zig +++ b/src/cli/publish_command.zig @@ -242,7 +242,6 @@ pub const PublishCommand = struct { json_source, shasum, integrity, - abs_tarball_path, ); Pack.Context.printSummary( @@ -876,7 +875,6 @@ pub const PublishCommand = struct { json_source: logger.Source, shasum: sha.SHA1.Digest, integrity: sha.SHA512.Digest, - abs_tarball_path: stringZ, ) OOM!string { bun.assertWithLocation(json.isObject(), @src()); @@ -928,10 +926,12 @@ pub const PublishCommand = struct { .value = Expr.init( E.String, .{ - .data = try bun.fmt.allocPrint(allocator, "http://{s}/{s}/-/{s}", .{ - strings.withoutTrailingSlash(registry.url.href), + .data = try bun.fmt.allocPrint(allocator, "http://{s}/{s}/-/{}", .{ + // always use replace https with http + // https://github.com/npm/cli/blob/9281ebf8e428d40450ad75ba61bc6f040b3bf896/workspaces/libnpmpublish/lib/publish.js#L120 + strings.withoutTrailingSlash(strings.withoutPrefixComptime(registry.url.href, "https://")), package_name, - std.fs.path.basename(abs_tarball_path), + Pack.fmtTarballFilename(package_name, package_version, .raw), }), }, logger.Loc.Empty, @@ -1362,8 +1362,8 @@ pub const PublishCommand = struct { // "_attachments" { - try writer.print(",\"_attachments\":{{\"{s}\":{{\"content_type\":\"{s}\",\"data\":\"", .{ - std.fs.path.basename(ctx.abs_tarball_path), + try writer.print(",\"_attachments\":{{\"{}\":{{\"content_type\":\"{s}\",\"data\":\"", .{ + Pack.fmtTarballFilename(ctx.package_name, ctx.package_version, .raw), "application/octet-stream", }); diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 1461b3a78a..a088b69870 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -742,38 +742,9 @@ ljelkjwelkgjw;lekj;lkejflkj }); }); -export async function publish( - env: any, - cwd: string, - ...args: string[] -): Promise<{ out: string; err: string; exitCode: number }> { - const { stdout, stderr, exited } = spawn({ - cmd: [bunExe(), "publish", ...args], - cwd, - stdout: "pipe", - stderr: "pipe", - env, - }); - - const out = await Bun.readableStreamToText(stdout); - const err = stderrForInstall(await Bun.readableStreamToText(stderr)); - const exitCode = await exited; - return { out, err, exitCode }; -} - -async function authBunfig(user: string) { - const authToken = await generateRegistryUser(user, user); - return ` - [install] - cache = false - registry = { url = "http://localhost:${port}/", token = "${authToken}" } - saveTextLockfile = false - `; -} - describe("whoami", async () => { test("can get username", async () => { - const bunfig = await authBunfig("whoami"); + const bunfig = await verdaccio.authBunfig("whoami"); await Promise.all([ write( packageJson, @@ -919,690 +890,6 @@ describe("whoami", async () => { }); }); -describe("publish", async () => { - describe("otp", async () => { - const mockRegistryFetch = function (opts: { - token: string; - setAuthHeader?: boolean; - otpFail?: boolean; - npmNotice?: boolean; - xLocalCache?: boolean; - expectedCI?: string; - }) { - return async function (req: Request) { - const { token, setAuthHeader = true, otpFail = false, npmNotice = false, xLocalCache = false } = opts; - if (req.url.includes("otp-pkg")) { - if (opts.expectedCI) { - expect(req.headers.get("user-agent")).toContain("ci/" + opts.expectedCI); - } - if (req.headers.get("npm-otp") === token) { - if (otpFail) { - return new Response( - JSON.stringify({ - error: "You must provide a one-time pass. Upgrade your client to npm@latest in order to use 2FA.", - }), - { status: 401 }, - ); - } else { - return new Response("OK", { status: 200 }); - } - } else { - const headers = new Headers(); - if (setAuthHeader) headers.set("www-authenticate", "OTP"); - - // `bun publish` won't request a url from a message in the npm-notice header, but we - // can test that it's displayed - if (npmNotice) headers.set("npm-notice", `visit http://localhost:${this.port}/auth to login`); - - // npm-notice will be ignored - if (xLocalCache) headers.set("x-local-cache", "true"); - - return new Response( - JSON.stringify({ - // this isn't accurate, but we just want to check that finding this string works - mock: setAuthHeader ? "" : "one-time password", - - authUrl: `http://localhost:${this.port}/auth`, - doneUrl: `http://localhost:${this.port}/done`, - }), - { - status: 401, - headers, - }, - ); - } - } else if (req.url.endsWith("auth")) { - expect.unreachable("url given to user, bun publish should not request"); - } else if (req.url.endsWith("done")) { - // send a fake response saying the user has authenticated successfully with the auth url - return new Response(JSON.stringify({ token: token }), { status: 200 }); - } - - expect.unreachable("unexpected url"); - }; - }; - - for (const setAuthHeader of [true, false]) { - test("mock web login" + (setAuthHeader ? "" : " (without auth header)"), async () => { - const token = await generateRegistryUser("otp" + (setAuthHeader ? "" : "noheader"), "otp"); - - using mockRegistry = Bun.serve({ - port: 0, - fetch: mockRegistryFetch({ token }), - }); - - const bunfig = ` - [install] - cache = false - registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; - await Promise.all([ - rm(join(verdaccio.packagesPath, "otp-pkg-1"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "otp-pkg-1", - version: "2.2.2", - dependencies: { - "otp-pkg-1": "2.2.2", - }, - }), - ), - ]); - - const { out, err, exitCode } = await publish(env, packageDir); - expect(exitCode).toBe(0); - }); - } - - test("otp failure", async () => { - const token = await generateRegistryUser("otp-fail", "otp"); - using mockRegistry = Bun.serve({ - port: 0, - fetch: mockRegistryFetch({ token, otpFail: true }), - }); - - const bunfig = ` - [install] - cache = false - registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; - - await Promise.all([ - rm(join(verdaccio.packagesPath, "otp-pkg-2"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "otp-pkg-2", - version: "1.1.1", - dependencies: { - "otp-pkg-2": "1.1.1", - }, - }), - ), - ]); - - const { out, err, exitCode } = await publish(env, packageDir); - expect(exitCode).toBe(1); - expect(err).toContain(" - Received invalid OTP"); - }); - - for (const shouldIgnoreNotice of [false, true]) { - test(`npm-notice with login url${shouldIgnoreNotice ? " (ignored)" : ""}`, async () => { - // Situation: user has 2FA enabled account with faceid sign-in. - // They run `bun publish` with --auth-type=legacy, prompting them - // to enter their OTP. Because they have faceid sign-in, they don't - // have a code to enter, so npm sends a message in the npm-notice - // header with a url for logging in. - const token = await generateRegistryUser(`otp-notice${shouldIgnoreNotice ? "-ignore" : ""}`, "otp"); - using mockRegistry = Bun.serve({ - port: 0, - fetch: mockRegistryFetch({ token, npmNotice: true, xLocalCache: shouldIgnoreNotice }), - }); - - const bunfig = ` - [install] - cache = false - registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; - - await Promise.all([ - rm(join(verdaccio.packagesPath, "otp-pkg-3"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "otp-pkg-3", - version: "3.3.3", - dependencies: { - "otp-pkg-3": "3.3.3", - }, - }), - ), - ]); - - const { out, err, exitCode } = await publish(env, packageDir); - expect(exitCode).toBe(0); - if (shouldIgnoreNotice) { - expect(err).not.toContain(`note: visit http://localhost:${mockRegistry.port}/auth to login`); - } else { - expect(err).toContain(`note: visit http://localhost:${mockRegistry.port}/auth to login`); - } - }); - } - - const fakeCIEnvs = [ - { ci: "expo-application-services", envs: { EAS_BUILD: "hi" } }, - { ci: "codemagic", envs: { CM_BUILD_ID: "hi" } }, - { ci: "vercel", envs: { "NOW_BUILDER": "hi" } }, - ]; - for (const envInfo of fakeCIEnvs) { - test(`CI user agent name: ${envInfo.ci}`, async () => { - const token = await generateRegistryUser(`otp-${envInfo.ci}`, "otp"); - using mockRegistry = Bun.serve({ - port: 0, - fetch: mockRegistryFetch({ token, expectedCI: envInfo.ci }), - }); - - const bunfig = ` - [install] - cache = false - registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; - - await Promise.all([ - rm(join(verdaccio.packagesPath, "otp-pkg-4"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "otp-pkg-4", - version: "4.4.4", - dependencies: { - "otp-pkg-4": "4.4.4", - }, - }), - ), - ]); - - const { out, err, exitCode } = await publish( - { ...env, ...envInfo.envs, ...{ BUILDKITE: undefined, GITHUB_ACTIONS: undefined } }, - packageDir, - ); - expect(exitCode).toBe(0); - }); - } - }); - - test("can publish a package then install it", async () => { - const bunfig = await authBunfig("basic"); - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-1"), { recursive: true, force: true }), - write( - packageJson, - JSON.stringify({ - name: "publish-pkg-1", - version: "1.1.1", - dependencies: { - "publish-pkg-1": "1.1.1", - }, - }), - ), - write(join(packageDir, "bunfig.toml"), bunfig), - ]); - - const { out, err, exitCode } = await publish(env, packageDir); - expect(err).not.toContain("error:"); - expect(err).not.toContain("warn:"); - expect(exitCode).toBe(0); - - await runBunInstall(env, packageDir); - expect(await exists(join(packageDir, "node_modules", "publish-pkg-1", "package.json"))).toBeTrue(); - }); - test("can publish from a tarball", async () => { - const bunfig = await authBunfig("tarball"); - const json = { - name: "publish-pkg-2", - version: "2.2.2", - dependencies: { - "publish-pkg-2": "2.2.2", - }, - }; - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-2"), { recursive: true, force: true }), - write(packageJson, JSON.stringify(json)), - write(join(packageDir, "bunfig.toml"), bunfig), - ]); - - await pack(packageDir, env); - - let { out, err, exitCode } = await publish(env, packageDir, "./publish-pkg-2-2.2.2.tgz"); - expect(err).not.toContain("error:"); - expect(err).not.toContain("warn:"); - expect(exitCode).toBe(0); - - await runBunInstall(env, packageDir); - expect(await exists(join(packageDir, "node_modules", "publish-pkg-2", "package.json"))).toBeTrue(); - - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-2"), { recursive: true, force: true }), - rm(join(packageDir, "bun.lockb"), { recursive: true, force: true }), - rm(join(packageDir, "node_modules"), { recursive: true, force: true }), - ]); - - // now with an absoute path - ({ out, err, exitCode } = await publish(env, packageDir, join(packageDir, "publish-pkg-2-2.2.2.tgz"))); - expect(err).not.toContain("error:"); - expect(err).not.toContain("warn:"); - expect(exitCode).toBe(0); - - await runBunInstall(env, packageDir); - expect(await file(join(packageDir, "node_modules", "publish-pkg-2", "package.json")).json()).toEqual(json); - }); - - for (const info of [ - { user: "bin1", bin: "bin1.js" }, - { user: "bin2", bin: { bin1: "bin1.js", bin2: "bin2.js" } }, - { user: "bin3", directories: { bin: "bins" } }, - ]) { - test(`can publish and install binaries with ${JSON.stringify(info)}`, async () => { - const publishDir = tmpdirSync(); - const bunfig = await authBunfig("binaries-" + info.user); - console.log({ packageDir, publishDir }); - - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-bins"), { recursive: true, force: true }), - write( - join(publishDir, "package.json"), - JSON.stringify({ - name: "publish-pkg-bins", - version: "1.1.1", - ...info, - }), - ), - write(join(publishDir, "bunfig.toml"), bunfig), - write(join(publishDir, "bin1.js"), `#!/usr/bin/env bun\nconsole.log("bin1!")`), - write(join(publishDir, "bin2.js"), `#!/usr/bin/env bun\nconsole.log("bin2!")`), - write(join(publishDir, "bins", "bin3.js"), `#!/usr/bin/env bun\nconsole.log("bin3!")`), - write(join(publishDir, "bins", "moredir", "bin4.js"), `#!/usr/bin/env bun\nconsole.log("bin4!")`), - - write( - packageJson, - JSON.stringify({ - name: "foo", - dependencies: { - "publish-pkg-bins": "1.1.1", - }, - }), - ), - ]); - - const { out, err, exitCode } = await publish(env, publishDir); - expect(err).not.toContain("error:"); - expect(err).not.toContain("warn:"); - expect(out).toContain("+ publish-pkg-bins@1.1.1"); - expect(exitCode).toBe(0); - - await runBunInstall(env, packageDir); - - const results = await Promise.all([ - exists(join(packageDir, "node_modules", ".bin", isWindows ? "bin1.bunx" : "bin1")), - exists(join(packageDir, "node_modules", ".bin", isWindows ? "bin2.bunx" : "bin2")), - exists(join(packageDir, "node_modules", ".bin", isWindows ? "bin3.js.bunx" : "bin3.js")), - exists(join(packageDir, "node_modules", ".bin", isWindows ? "bin4.js.bunx" : "bin4.js")), - exists(join(packageDir, "node_modules", ".bin", isWindows ? "moredir" : "moredir/bin4.js")), - exists(join(packageDir, "node_modules", ".bin", isWindows ? "publish-pkg-bins.bunx" : "publish-pkg-bins")), - ]); - - switch (info.user) { - case "bin1": { - expect(results).toEqual([false, false, false, false, false, true]); - break; - } - case "bin2": { - expect(results).toEqual([true, true, false, false, false, false]); - break; - } - case "bin3": { - expect(results).toEqual([false, false, true, true, !isWindows, false]); - break; - } - } - }); - } - - test("dependencies are installed", async () => { - const publishDir = tmpdirSync(); - const bunfig = await authBunfig("manydeps"); - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-deps"), { recursive: true, force: true }), - write( - join(publishDir, "package.json"), - JSON.stringify( - { - name: "publish-pkg-deps", - version: "1.1.1", - dependencies: { - "no-deps": "1.0.0", - }, - peerDependencies: { - "a-dep": "1.0.1", - }, - optionalDependencies: { - "basic-1": "1.0.0", - }, - }, - null, - 2, - ), - ), - write(join(publishDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "foo", - dependencies: { - "publish-pkg-deps": "1.1.1", - }, - }), - ), - ]); - - let { out, err, exitCode } = await publish(env, publishDir); - expect(err).not.toContain("error:"); - expect(err).not.toContain("warn:"); - expect(out).toContain("+ publish-pkg-deps@1.1.1"); - expect(exitCode).toBe(0); - - await runBunInstall(env, packageDir); - - const results = await Promise.all([ - exists(join(packageDir, "node_modules", "no-deps", "package.json")), - exists(join(packageDir, "node_modules", "a-dep", "package.json")), - exists(join(packageDir, "node_modules", "basic-1", "package.json")), - ]); - - expect(results).toEqual([true, true, true]); - }); - - test("can publish workspace package", async () => { - const bunfig = await authBunfig("workspace"); - const pkgJson = { - name: "publish-pkg-3", - version: "3.3.3", - dependencies: { - "publish-pkg-3": "3.3.3", - }, - }; - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-3"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "root", - workspaces: ["packages/*"], - }), - ), - write(join(packageDir, "packages", "publish-pkg-3", "package.json"), JSON.stringify(pkgJson)), - ]); - - await publish(env, join(packageDir, "packages", "publish-pkg-3")); - - await write(packageJson, JSON.stringify({ name: "root", "dependencies": { "publish-pkg-3": "3.3.3" } })); - - await runBunInstall(env, packageDir); - - expect(await file(join(packageDir, "node_modules", "publish-pkg-3", "package.json")).json()).toEqual(pkgJson); - }); - - describe("--dry-run", async () => { - test("does not publish", async () => { - const bunfig = await authBunfig("dryrun"); - await Promise.all([ - rm(join(verdaccio.packagesPath, "dry-run-1"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "dry-run-1", - version: "1.1.1", - dependencies: { - "dry-run-1": "1.1.1", - }, - }), - ), - ]); - - const { out, err, exitCode } = await publish(env, packageDir, "--dry-run"); - expect(exitCode).toBe(0); - - expect(await exists(join(verdaccio.packagesPath, "dry-run-1"))).toBeFalse(); - }); - test("does not publish from tarball path", async () => { - const bunfig = await authBunfig("dryruntarball"); - await Promise.all([ - rm(join(verdaccio.packagesPath, "dry-run-2"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "dry-run-2", - version: "2.2.2", - dependencies: { - "dry-run-2": "2.2.2", - }, - }), - ), - ]); - - await pack(packageDir, env); - - const { out, err, exitCode } = await publish(env, packageDir, "./dry-run-2-2.2.2.tgz", "--dry-run"); - expect(exitCode).toBe(0); - - expect(await exists(join(verdaccio.packagesPath, "dry-run-2"))).toBeFalse(); - }); - }); - - describe("lifecycle scripts", async () => { - const script = `const fs = require("fs"); - fs.writeFileSync(process.argv[2] + ".txt", \` -prepublishOnly: \${fs.existsSync("prepublishOnly.txt")} -publish: \${fs.existsSync("publish.txt")} -postpublish: \${fs.existsSync("postpublish.txt")} -prepack: \${fs.existsSync("prepack.txt")} -prepare: \${fs.existsSync("prepare.txt")} -postpack: \${fs.existsSync("postpack.txt")}\`)`; - const json = { - name: "publish-pkg-4", - version: "4.4.4", - scripts: { - // should happen in this order - "prepublishOnly": `${bunExe()} script.js prepublishOnly`, - "prepack": `${bunExe()} script.js prepack`, - "prepare": `${bunExe()} script.js prepare`, - "postpack": `${bunExe()} script.js postpack`, - "publish": `${bunExe()} script.js publish`, - "postpublish": `${bunExe()} script.js postpublish`, - }, - dependencies: { - "publish-pkg-4": "4.4.4", - }, - }; - - for (const arg of ["", "--dry-run"]) { - test(`should run in order${arg ? " (--dry-run)" : ""}`, async () => { - const bunfig = await authBunfig("lifecycle" + (arg ? "dry" : "")); - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-4"), { recursive: true, force: true }), - write(packageJson, JSON.stringify(json)), - write(join(packageDir, "script.js"), script), - write(join(packageDir, "bunfig.toml"), bunfig), - ]); - - const { out, err, exitCode } = await publish(env, packageDir, arg); - expect(exitCode).toBe(0); - - const results = await Promise.all([ - file(join(packageDir, "prepublishOnly.txt")).text(), - file(join(packageDir, "prepack.txt")).text(), - file(join(packageDir, "prepare.txt")).text(), - file(join(packageDir, "postpack.txt")).text(), - file(join(packageDir, "publish.txt")).text(), - file(join(packageDir, "postpublish.txt")).text(), - ]); - - expect(results).toEqual([ - "\nprepublishOnly: false\npublish: false\npostpublish: false\nprepack: false\nprepare: false\npostpack: false", - "\nprepublishOnly: true\npublish: false\npostpublish: false\nprepack: false\nprepare: false\npostpack: false", - "\nprepublishOnly: true\npublish: false\npostpublish: false\nprepack: true\nprepare: false\npostpack: false", - "\nprepublishOnly: true\npublish: false\npostpublish: false\nprepack: true\nprepare: true\npostpack: false", - "\nprepublishOnly: true\npublish: false\npostpublish: false\nprepack: true\nprepare: true\npostpack: true", - "\nprepublishOnly: true\npublish: true\npostpublish: false\nprepack: true\nprepare: true\npostpack: true", - ]); - }); - } - - test("--ignore-scripts", async () => { - const bunfig = await authBunfig("ignorescripts"); - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-5"), { recursive: true, force: true }), - write(packageJson, JSON.stringify(json)), - write(join(packageDir, "script.js"), script), - write(join(packageDir, "bunfig.toml"), bunfig), - ]); - - const { out, err, exitCode } = await publish(env, packageDir, "--ignore-scripts"); - expect(exitCode).toBe(0); - - const results = await Promise.all([ - exists(join(packageDir, "prepublishOnly.txt")), - exists(join(packageDir, "prepack.txt")), - exists(join(packageDir, "prepare.txt")), - exists(join(packageDir, "postpack.txt")), - exists(join(packageDir, "publish.txt")), - exists(join(packageDir, "postpublish.txt")), - ]); - - expect(results).toEqual([false, false, false, false, false, false]); - }); - }); - - test("attempting to publish a private package should fail", async () => { - const bunfig = await authBunfig("privatepackage"); - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-6"), { recursive: true, force: true }), - write( - packageJson, - JSON.stringify({ - name: "publish-pkg-6", - version: "6.6.6", - private: true, - dependencies: { - "publish-pkg-6": "6.6.6", - }, - }), - ), - write(join(packageDir, "bunfig.toml"), bunfig), - ]); - - // should fail - let { out, err, exitCode } = await publish(env, packageDir); - expect(exitCode).toBe(1); - expect(err).toContain("error: attempted to publish a private package"); - expect(await exists(join(verdaccio.packagesPath, "publish-pkg-6-6.6.6.tgz"))).toBeFalse(); - - // try tarball - await pack(packageDir, env); - ({ out, err, exitCode } = await publish(env, packageDir, "./publish-pkg-6-6.6.6.tgz")); - expect(exitCode).toBe(1); - expect(err).toContain("error: attempted to publish a private package"); - expect(await exists(join(packageDir, "publish-pkg-6-6.6.6.tgz"))).toBeTrue(); - }); - - describe("access", async () => { - test("--access", async () => { - const bunfig = await authBunfig("accessflag"); - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-7"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write( - packageJson, - JSON.stringify({ - name: "publish-pkg-7", - version: "7.7.7", - }), - ), - ]); - - // should fail - let { out, err, exitCode } = await publish(env, packageDir, "--access", "restricted"); - expect(exitCode).toBe(1); - expect(err).toContain("error: unable to restrict access to unscoped package"); - - ({ out, err, exitCode } = await publish(env, packageDir, "--access", "public")); - expect(exitCode).toBe(0); - - expect(await exists(join(verdaccio.packagesPath, "publish-pkg-7"))).toBeTrue(); - }); - - for (const access of ["restricted", "public"]) { - test(`access ${access}`, async () => { - const bunfig = await authBunfig("access" + access); - - const pkgJson = { - name: "@secret/publish-pkg-8", - version: "8.8.8", - dependencies: { - "@secret/publish-pkg-8": "8.8.8", - }, - publishConfig: { - access, - }, - }; - - await Promise.all([ - rm(join(verdaccio.packagesPath, "@secret", "publish-pkg-8"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write(packageJson, JSON.stringify(pkgJson)), - ]); - - let { out, err, exitCode } = await publish(env, packageDir); - expect(exitCode).toBe(0); - - await runBunInstall(env, packageDir); - - expect(await file(join(packageDir, "node_modules", "@secret", "publish-pkg-8", "package.json")).json()).toEqual( - pkgJson, - ); - }); - } - }); - - describe("tag", async () => { - test("can publish with a tag", async () => { - const bunfig = await authBunfig("simpletag"); - const pkgJson = { - name: "publish-pkg-9", - version: "9.9.9", - dependencies: { - "publish-pkg-9": "simpletag", - }, - }; - await Promise.all([ - rm(join(verdaccio.packagesPath, "publish-pkg-9"), { recursive: true, force: true }), - write(join(packageDir, "bunfig.toml"), bunfig), - write(packageJson, JSON.stringify(pkgJson)), - ]); - - let { out, err, exitCode } = await publish(env, packageDir, "--tag", "simpletag"); - expect(exitCode).toBe(0); - - await runBunInstall(env, packageDir); - expect(await file(join(packageDir, "node_modules", "publish-pkg-9", "package.json")).json()).toEqual(pkgJson); - }); - }); -}); - describe("package.json indentation", async () => { test("works for root and workspace packages", async () => { await Promise.all([ @@ -5367,7 +4654,7 @@ describe("hoisting", async () => { }); }); -describe.only("transitive file dependencies", () => { +describe("transitive file dependencies", () => { async function checkHoistedFiles() { const aliasedFileDepFilesPackageJson = join( packageDir, @@ -5379,28 +4666,36 @@ describe.only("transitive file dependencies", () => { "package.json", ); const results = await Promise.all([ - (await lstat(join(packageDir, "node_modules", "file-dep", "node_modules", "files", "package.json"))).isSymbolicLink(), + ( + await lstat(join(packageDir, "node_modules", "file-dep", "node_modules", "files", "package.json")) + ).isSymbolicLink(), readdirSorted(join(packageDir, "node_modules", "missing-file-dep", "node_modules")), exists(join(packageDir, "node_modules", "aliased-file-dep", "package.json")), isWindows ? file(await readlink(aliasedFileDepFilesPackageJson)).json() : file(aliasedFileDepFilesPackageJson).json(), - (await lstat( - join(packageDir, "node_modules", "@scoped", "file-dep", "node_modules", "@scoped", "files", "package.json"), - )).isSymbolicLink(), - (await lstat( - join( - packageDir, - "node_modules", - "@another-scope", - "file-dep", - "node_modules", - "@scoped", - "files", - "package.json", - ), - )).isSymbolicLink(), - (await lstat(join(packageDir, "node_modules", "self-file-dep", "node_modules", "self-file-dep", "package.json"))).isSymbolicLink(), + ( + await lstat( + join(packageDir, "node_modules", "@scoped", "file-dep", "node_modules", "@scoped", "files", "package.json"), + ) + ).isSymbolicLink(), + ( + await lstat( + join( + packageDir, + "node_modules", + "@another-scope", + "file-dep", + "node_modules", + "@scoped", + "files", + "package.json", + ), + ) + ).isSymbolicLink(), + ( + await lstat(join(packageDir, "node_modules", "self-file-dep", "node_modules", "self-file-dep", "package.json")) + ).isSymbolicLink(), ]); expect(results).toEqual([ @@ -5430,38 +4725,46 @@ describe.only("transitive file dependencies", () => { file(join(packageDir, "node_modules", "@another-scope", "file-dep", "package.json")).json(), file(join(packageDir, "node_modules", "self-file-dep", "package.json")).json(), - (await lstat(join(packageDir, "pkg1", "node_modules", "file-dep", "node_modules", "files", "package.json"))).isSymbolicLink(), + ( + await lstat(join(packageDir, "pkg1", "node_modules", "file-dep", "node_modules", "files", "package.json")) + ).isSymbolicLink(), readdirSorted(join(packageDir, "pkg1", "node_modules", "missing-file-dep", "node_modules")), // [] exists(join(packageDir, "pkg1", "node_modules", "aliased-file-dep")), // false - (await lstat( - join( - packageDir, - "pkg1", - "node_modules", - "@scoped", - "file-dep", - "node_modules", - "@scoped", - "files", - "package.json", - ), - )).isSymbolicLink(), - (await lstat( - join( - packageDir, - "pkg1", - "node_modules", - "@another-scope", - "file-dep", - "node_modules", - "@scoped", - "files", - "package.json", - ), - )).isSymbolicLink(), - (await lstat( - join(packageDir, "pkg1", "node_modules", "self-file-dep", "node_modules", "self-file-dep", "package.json"), - )).isSymbolicLink(), + ( + await lstat( + join( + packageDir, + "pkg1", + "node_modules", + "@scoped", + "file-dep", + "node_modules", + "@scoped", + "files", + "package.json", + ), + ) + ).isSymbolicLink(), + ( + await lstat( + join( + packageDir, + "pkg1", + "node_modules", + "@another-scope", + "file-dep", + "node_modules", + "@scoped", + "files", + "package.json", + ), + ) + ).isSymbolicLink(), + ( + await lstat( + join(packageDir, "pkg1", "node_modules", "self-file-dep", "node_modules", "self-file-dep", "package.json"), + ) + ).isSymbolicLink(), readdirSorted(join(packageDir, "pkg1", "node_modules")), ]); @@ -9683,79 +8986,3 @@ registry = "http://localhost:${port}/" }); } }); - -it("$npm_command is accurate during publish", async () => { - await write( - packageJson, - JSON.stringify({ - name: "publish-pkg-10", - version: "1.0.0", - scripts: { - publish: "echo $npm_command", - }, - }), - ); - await write(join(packageDir, "bunfig.toml"), await authBunfig("npm_command")); - await rm(join(verdaccio.packagesPath, "publish-pkg-10"), { recursive: true, force: true }); - let { out, err, exitCode } = await publish(env, packageDir, "--tag", "simpletag"); - expect(err).toBe(`$ echo $npm_command\n`); - expect(out.split("\n")).toEqual([ - `bun publish ${Bun.version_with_sha}`, - ``, - `packed 95B package.json`, - ``, - `Total files: 1`, - expect.stringContaining(`Shasum: `), - expect.stringContaining(`Integrity: sha512-`), - `Unpacked size: 95B`, - expect.stringContaining(`Packed size: `), - `Tag: simpletag`, - `Access: default`, - `Registry: http://localhost:${port}/`, - ``, - ` + publish-pkg-10@1.0.0`, - `publish`, - ``, - ]); - expect(exitCode).toBe(0); -}); - -it("$npm_lifecycle_event is accurate during publish", async () => { - await write( - packageJson, - `{ - "name": "publish-pkg-11", - "version": "1.0.0", - "scripts": { - "prepublish": "echo 1 $npm_lifecycle_event", - "publish": "echo 2 $npm_lifecycle_event", - "postpublish": "echo 3 $npm_lifecycle_event", - }, - } - `, - ); - await write(join(packageDir, "bunfig.toml"), await authBunfig("npm_lifecycle_event")); - await rm(join(verdaccio.packagesPath, "publish-pkg-11"), { recursive: true, force: true }); - let { out, err, exitCode } = await publish(env, packageDir, "--tag", "simpletag"); - expect(err).toBe(`$ echo 2 $npm_lifecycle_event\n$ echo 3 $npm_lifecycle_event\n`); - expect(out.split("\n")).toEqual([ - `bun publish ${Bun.version_with_sha}`, - ``, - `packed 256B package.json`, - ``, - `Total files: 1`, - expect.stringContaining(`Shasum: `), - expect.stringContaining(`Integrity: sha512-`), - `Unpacked size: 256B`, - expect.stringContaining(`Packed size: `), - `Tag: simpletag`, - `Access: default`, - `Registry: http://localhost:${port}/`, - ``, - ` + publish-pkg-11@1.0.0`, - `2 publish`, - `3 postpublish`, - ``, - ]); - expect(exitCode).toBe(0); -}); diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts new file mode 100644 index 0000000000..0141b078f6 --- /dev/null +++ b/test/cli/install/bun-publish.test.ts @@ -0,0 +1,850 @@ +import { describe, expect, test, beforeAll, afterAll, it } from "bun:test"; +import { spawn, file, write } from "bun"; +import { rm, exists } from "fs/promises"; +import { join } from "path"; +import { + VerdaccioRegistry, + bunExe, + bunEnv as env, + stderrForInstall, + runBunInstall, + pack, + tmpdirSync, + isWindows, +} from "harness"; + +const registry = new VerdaccioRegistry(); + +beforeAll(async () => { + await registry.start(); +}); + +afterAll(() => { + registry.stop(); +}); + +export async function publish( + env: any, + cwd: string, + ...args: string[] +): Promise<{ out: string; err: string; exitCode: number }> { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "publish", ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env, + }); + + const out = await Bun.readableStreamToText(stdout); + const err = stderrForInstall(await Bun.readableStreamToText(stderr)); + const exitCode = await exited; + return { out, err, exitCode }; +} + +describe("otp", async () => { + const mockRegistryFetch = function (opts: { + token: string; + setAuthHeader?: boolean; + otpFail?: boolean; + npmNotice?: boolean; + xLocalCache?: boolean; + expectedCI?: string; + }) { + return async function (req: Request) { + const { token, setAuthHeader = true, otpFail = false, npmNotice = false, xLocalCache = false } = opts; + if (req.url.includes("otp-pkg")) { + if (opts.expectedCI) { + expect(req.headers.get("user-agent")).toContain("ci/" + opts.expectedCI); + } + if (req.headers.get("npm-otp") === token) { + if (otpFail) { + return new Response( + JSON.stringify({ + error: "You must provide a one-time pass. Upgrade your client to npm@latest in order to use 2FA.", + }), + { status: 401 }, + ); + } else { + return new Response("OK", { status: 200 }); + } + } else { + const headers = new Headers(); + if (setAuthHeader) headers.set("www-authenticate", "OTP"); + + // `bun publish` won't request a url from a message in the npm-notice header, but we + // can test that it's displayed + if (npmNotice) headers.set("npm-notice", `visit http://localhost:${this.port}/auth to login`); + + // npm-notice will be ignored + if (xLocalCache) headers.set("x-local-cache", "true"); + + return new Response( + JSON.stringify({ + // this isn't accurate, but we just want to check that finding this string works + mock: setAuthHeader ? "" : "one-time password", + + authUrl: `http://localhost:${this.port}/auth`, + doneUrl: `http://localhost:${this.port}/done`, + }), + { + status: 401, + headers, + }, + ); + } + } else if (req.url.endsWith("auth")) { + expect.unreachable("url given to user, bun publish should not request"); + } else if (req.url.endsWith("done")) { + // send a fake response saying the user has authenticated successfully with the auth url + return new Response(JSON.stringify({ token: token }), { status: 200 }); + } + + expect.unreachable("unexpected url"); + }; + }; + + for (const setAuthHeader of [true, false]) { + test("mock web login" + (setAuthHeader ? "" : " (without auth header)"), async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const token = await registry.generateUser("otp" + (setAuthHeader ? "" : "noheader"), "otp"); + + using mockRegistry = Bun.serve({ + port: 0, + fetch: mockRegistryFetch({ token }), + }); + + const bunfig = ` + [install] + cache = false + registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; + await Promise.all([ + rm(join(registry.packagesPath, "otp-pkg-1"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "otp-pkg-1", + version: "2.2.2", + dependencies: { + "otp-pkg-1": "2.2.2", + }, + }), + ), + ]); + + const { out, err, exitCode } = await publish(env, packageDir); + expect(exitCode).toBe(0); + }); + } + + test("otp failure", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const token = await registry.generateUser("otp-fail", "otp"); + using mockRegistry = Bun.serve({ + port: 0, + fetch: mockRegistryFetch({ token, otpFail: true }), + }); + + const bunfig = ` + [install] + cache = false + registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; + + await Promise.all([ + rm(join(registry.packagesPath, "otp-pkg-2"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "otp-pkg-2", + version: "1.1.1", + dependencies: { + "otp-pkg-2": "1.1.1", + }, + }), + ), + ]); + + const { out, err, exitCode } = await publish(env, packageDir); + expect(exitCode).toBe(1); + expect(err).toContain(" - Received invalid OTP"); + }); + + for (const shouldIgnoreNotice of [false, true]) { + test(`npm-notice with login url${shouldIgnoreNotice ? " (ignored)" : ""}`, async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + // Situation: user has 2FA enabled account with faceid sign-in. + // They run `bun publish` with --auth-type=legacy, prompting them + // to enter their OTP. Because they have faceid sign-in, they don't + // have a code to enter, so npm sends a message in the npm-notice + // header with a url for logging in. + const token = await registry.generateUser(`otp-notice${shouldIgnoreNotice ? "-ignore" : ""}`, "otp"); + using mockRegistry = Bun.serve({ + port: 0, + fetch: mockRegistryFetch({ token, npmNotice: true, xLocalCache: shouldIgnoreNotice }), + }); + + const bunfig = ` + [install] + cache = false + registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; + + await Promise.all([ + rm(join(registry.packagesPath, "otp-pkg-3"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "otp-pkg-3", + version: "3.3.3", + dependencies: { + "otp-pkg-3": "3.3.3", + }, + }), + ), + ]); + + const { out, err, exitCode } = await publish(env, packageDir); + expect(exitCode).toBe(0); + if (shouldIgnoreNotice) { + expect(err).not.toContain(`note: visit http://localhost:${mockRegistry.port}/auth to login`); + } else { + expect(err).toContain(`note: visit http://localhost:${mockRegistry.port}/auth to login`); + } + }); + } + + const fakeCIEnvs = [ + { ci: "expo-application-services", envs: { EAS_BUILD: "hi" } }, + { ci: "codemagic", envs: { CM_BUILD_ID: "hi" } }, + { ci: "vercel", envs: { "NOW_BUILDER": "hi" } }, + ]; + for (const envInfo of fakeCIEnvs) { + test(`CI user agent name: ${envInfo.ci}`, async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const token = await registry.generateUser(`otp-${envInfo.ci}`, "otp"); + using mockRegistry = Bun.serve({ + port: 0, + fetch: mockRegistryFetch({ token, expectedCI: envInfo.ci }), + }); + + const bunfig = ` + [install] + cache = false + registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; + + await Promise.all([ + rm(join(registry.packagesPath, "otp-pkg-4"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "otp-pkg-4", + version: "4.4.4", + dependencies: { + "otp-pkg-4": "4.4.4", + }, + }), + ), + ]); + + const { out, err, exitCode } = await publish( + { ...env, ...envInfo.envs, ...{ BUILDKITE: undefined, GITHUB_ACTIONS: undefined } }, + packageDir, + ); + expect(exitCode).toBe(0); + }); + } +}); + +test("can publish a package then install it", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("basic"); + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-1"), { recursive: true, force: true }), + write( + packageJson, + JSON.stringify({ + name: "publish-pkg-1", + version: "1.1.1", + dependencies: { + "publish-pkg-1": "1.1.1", + }, + }), + ), + write(join(packageDir, "bunfig.toml"), bunfig), + ]); + + const { out, err, exitCode } = await publish(env, packageDir); + expect(err).not.toContain("error:"); + expect(err).not.toContain("warn:"); + expect(exitCode).toBe(0); + + await runBunInstall(env, packageDir); + expect(await exists(join(packageDir, "node_modules", "publish-pkg-1", "package.json"))).toBeTrue(); +}); +test("can publish from a tarball", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("tarball"); + const json = { + name: "publish-pkg-2", + version: "2.2.2", + dependencies: { + "publish-pkg-2": "2.2.2", + }, + }; + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-2"), { recursive: true, force: true }), + write(packageJson, JSON.stringify(json)), + write(join(packageDir, "bunfig.toml"), bunfig), + ]); + + await pack(packageDir, env); + + let { out, err, exitCode } = await publish(env, packageDir, "./publish-pkg-2-2.2.2.tgz"); + expect(err).not.toContain("error:"); + expect(err).not.toContain("warn:"); + expect(exitCode).toBe(0); + + await runBunInstall(env, packageDir); + expect(await exists(join(packageDir, "node_modules", "publish-pkg-2", "package.json"))).toBeTrue(); + + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-2"), { recursive: true, force: true }), + rm(join(packageDir, "bun.lockb"), { recursive: true, force: true }), + rm(join(packageDir, "node_modules"), { recursive: true, force: true }), + ]); + + // now with an absoute path + ({ out, err, exitCode } = await publish(env, packageDir, join(packageDir, "publish-pkg-2-2.2.2.tgz"))); + expect(err).not.toContain("error:"); + expect(err).not.toContain("warn:"); + expect(exitCode).toBe(0); + + await runBunInstall(env, packageDir, { savesLockfile: false }); + expect(await file(join(packageDir, "node_modules", "publish-pkg-2", "package.json")).json()).toEqual(json); +}); +test("can publish scoped packages", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("scoped-pkg"); + const json = { + name: "@scoped/pkg-1", + version: "1.1.1", + dependencies: { + "@scoped/pkg-1": "1.1.1", + }, + }; + await Promise.all([ + rm(join(registry.packagesPath, "@scoped", "pkg-1"), { recursive: true, force: true }), + write(packageJson, JSON.stringify(json)), + write(join(packageDir, "bunfig.toml"), bunfig), + ]); + + const { out, err, exitCode } = await publish(env, packageDir); + expect(err).not.toContain("error:"); + expect(err).not.toContain("warn:"); + expect(exitCode).toBe(0); + + await runBunInstall(env, packageDir); + expect(await file(join(packageDir, "node_modules", "@scoped", "pkg-1", "package.json")).json()).toEqual(json); +}); + +for (const info of [ + { user: "bin1", bin: "bin1.js" }, + { user: "bin2", bin: { bin1: "bin1.js", bin2: "bin2.js" } }, + { user: "bin3", directories: { bin: "bins" } }, +]) { + test(`can publish and install binaries with ${JSON.stringify(info)}`, async () => { + const { packageDir, packageJson } = await registry.createTestDir({ saveTextLockfile: false }); + const publishDir = tmpdirSync(); + const bunfig = await registry.authBunfig("binaries-" + info.user); + + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-" + info.user), { recursive: true, force: true }), + write( + join(publishDir, "package.json"), + JSON.stringify({ + name: "publish-pkg-" + info.user, + version: "1.1.1", + ...info, + }), + ), + write(join(publishDir, "bunfig.toml"), bunfig), + write(join(publishDir, "bin1.js"), `#!/usr/bin/env bun\nconsole.log("bin1!")`), + write(join(publishDir, "bin2.js"), `#!/usr/bin/env bun\nconsole.log("bin2!")`), + write(join(publishDir, "bins", "bin3.js"), `#!/usr/bin/env bun\nconsole.log("bin3!")`), + write(join(publishDir, "bins", "moredir", "bin4.js"), `#!/usr/bin/env bun\nconsole.log("bin4!")`), + + write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + ["publish-pkg-" + info.user]: "1.1.1", + }, + }), + ), + ]); + + const { out, err, exitCode } = await publish(env, publishDir); + expect(err).not.toContain("error:"); + expect(err).not.toContain("warn:"); + expect(out).toContain(`+ publish-pkg-${info.user}@1.1.1`); + expect(exitCode).toBe(0); + + await runBunInstall(env, packageDir); + + const results = await Promise.all([ + exists(join(packageDir, "node_modules", ".bin", isWindows ? "bin1.bunx" : "bin1")), + exists(join(packageDir, "node_modules", ".bin", isWindows ? "bin2.bunx" : "bin2")), + exists(join(packageDir, "node_modules", ".bin", isWindows ? "bin3.js.bunx" : "bin3.js")), + exists(join(packageDir, "node_modules", ".bin", isWindows ? "bin4.js.bunx" : "bin4.js")), + exists(join(packageDir, "node_modules", ".bin", isWindows ? "moredir" : "moredir/bin4.js")), + exists( + join( + packageDir, + "node_modules", + ".bin", + isWindows ? `publish-pkg-${info.user}.bunx` : "publish-pkg-" + info.user, + ), + ), + ]); + + switch (info.user) { + case "bin1": { + expect(results).toEqual([false, false, false, false, false, true]); + break; + } + case "bin2": { + expect(results).toEqual([true, true, false, false, false, false]); + break; + } + case "bin3": { + expect(results).toEqual([false, false, true, true, !isWindows, false]); + break; + } + } + }); +} + +test("dependencies are installed", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const publishDir = tmpdirSync(); + const bunfig = await registry.authBunfig("manydeps"); + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-deps"), { recursive: true, force: true }), + write( + join(publishDir, "package.json"), + JSON.stringify( + { + name: "publish-pkg-deps", + version: "1.1.1", + dependencies: { + "no-deps": "1.0.0", + }, + peerDependencies: { + "a-dep": "1.0.1", + }, + optionalDependencies: { + "basic-1": "1.0.0", + }, + }, + null, + 2, + ), + ), + write(join(publishDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "publish-pkg-deps": "1.1.1", + }, + }), + ), + ]); + + let { out, err, exitCode } = await publish(env, publishDir); + expect(err).not.toContain("error:"); + expect(err).not.toContain("warn:"); + expect(out).toContain("+ publish-pkg-deps@1.1.1"); + expect(exitCode).toBe(0); + + await runBunInstall(env, packageDir); + + const results = await Promise.all([ + exists(join(packageDir, "node_modules", "no-deps", "package.json")), + exists(join(packageDir, "node_modules", "a-dep", "package.json")), + exists(join(packageDir, "node_modules", "basic-1", "package.json")), + ]); + + expect(results).toEqual([true, true, true]); +}); + +test("can publish workspace package", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("workspace"); + const pkgJson = { + name: "publish-pkg-3", + version: "3.3.3", + dependencies: { + "publish-pkg-3": "3.3.3", + }, + }; + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-3"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "root", + workspaces: ["packages/*"], + }), + ), + write(join(packageDir, "packages", "publish-pkg-3", "package.json"), JSON.stringify(pkgJson)), + ]); + + await publish(env, join(packageDir, "packages", "publish-pkg-3")); + + await write(packageJson, JSON.stringify({ name: "root", "dependencies": { "publish-pkg-3": "3.3.3" } })); + + await runBunInstall(env, packageDir); + + expect(await file(join(packageDir, "node_modules", "publish-pkg-3", "package.json")).json()).toEqual(pkgJson); +}); + +describe("--dry-run", async () => { + test("does not publish", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("dryrun"); + await Promise.all([ + rm(join(registry.packagesPath, "dry-run-1"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "dry-run-1", + version: "1.1.1", + dependencies: { + "dry-run-1": "1.1.1", + }, + }), + ), + ]); + + const { out, err, exitCode } = await publish(env, packageDir, "--dry-run"); + expect(exitCode).toBe(0); + + expect(await exists(join(registry.packagesPath, "dry-run-1"))).toBeFalse(); + }); + test("does not publish from tarball path", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("dryruntarball"); + await Promise.all([ + rm(join(registry.packagesPath, "dry-run-2"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "dry-run-2", + version: "2.2.2", + dependencies: { + "dry-run-2": "2.2.2", + }, + }), + ), + ]); + + await pack(packageDir, env); + + const { out, err, exitCode } = await publish(env, packageDir, "./dry-run-2-2.2.2.tgz", "--dry-run"); + expect(exitCode).toBe(0); + + expect(await exists(join(registry.packagesPath, "dry-run-2"))).toBeFalse(); + }); +}); + +describe("lifecycle scripts", async () => { + const script = `const fs = require("fs"); + fs.writeFileSync(process.argv[2] + ".txt", \` +prepublishOnly: \${fs.existsSync("prepublishOnly.txt")} +publish: \${fs.existsSync("publish.txt")} +postpublish: \${fs.existsSync("postpublish.txt")} +prepack: \${fs.existsSync("prepack.txt")} +prepare: \${fs.existsSync("prepare.txt")} +postpack: \${fs.existsSync("postpack.txt")}\`)`; + const json = { + name: "publish-pkg-4", + version: "4.4.4", + scripts: { + // should happen in this order + "prepublishOnly": `${bunExe()} script.js prepublishOnly`, + "prepack": `${bunExe()} script.js prepack`, + "prepare": `${bunExe()} script.js prepare`, + "postpack": `${bunExe()} script.js postpack`, + "publish": `${bunExe()} script.js publish`, + "postpublish": `${bunExe()} script.js postpublish`, + }, + dependencies: { + "publish-pkg-4": "4.4.4", + }, + }; + + for (const arg of ["", "--dry-run"]) { + test(`should run in order${arg ? " (--dry-run)" : ""}`, async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("lifecycle" + (arg ? "dry" : "")); + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-4"), { recursive: true, force: true }), + write(packageJson, JSON.stringify(json)), + write(join(packageDir, "script.js"), script), + write(join(packageDir, "bunfig.toml"), bunfig), + ]); + + const { out, err, exitCode } = await publish(env, packageDir, arg); + expect(exitCode).toBe(0); + + const results = await Promise.all([ + file(join(packageDir, "prepublishOnly.txt")).text(), + file(join(packageDir, "prepack.txt")).text(), + file(join(packageDir, "prepare.txt")).text(), + file(join(packageDir, "postpack.txt")).text(), + file(join(packageDir, "publish.txt")).text(), + file(join(packageDir, "postpublish.txt")).text(), + ]); + + expect(results).toEqual([ + "\nprepublishOnly: false\npublish: false\npostpublish: false\nprepack: false\nprepare: false\npostpack: false", + "\nprepublishOnly: true\npublish: false\npostpublish: false\nprepack: false\nprepare: false\npostpack: false", + "\nprepublishOnly: true\npublish: false\npostpublish: false\nprepack: true\nprepare: false\npostpack: false", + "\nprepublishOnly: true\npublish: false\npostpublish: false\nprepack: true\nprepare: true\npostpack: false", + "\nprepublishOnly: true\npublish: false\npostpublish: false\nprepack: true\nprepare: true\npostpack: true", + "\nprepublishOnly: true\npublish: true\npostpublish: false\nprepack: true\nprepare: true\npostpack: true", + ]); + }); + } + + test("--ignore-scripts", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("ignorescripts"); + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-5"), { recursive: true, force: true }), + write(packageJson, JSON.stringify(json)), + write(join(packageDir, "script.js"), script), + write(join(packageDir, "bunfig.toml"), bunfig), + ]); + + const { out, err, exitCode } = await publish(env, packageDir, "--ignore-scripts"); + expect(exitCode).toBe(0); + + const results = await Promise.all([ + exists(join(packageDir, "prepublishOnly.txt")), + exists(join(packageDir, "prepack.txt")), + exists(join(packageDir, "prepare.txt")), + exists(join(packageDir, "postpack.txt")), + exists(join(packageDir, "publish.txt")), + exists(join(packageDir, "postpublish.txt")), + ]); + + expect(results).toEqual([false, false, false, false, false, false]); + }); +}); + +test("attempting to publish a private package should fail", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("privatepackage"); + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-6"), { recursive: true, force: true }), + write( + packageJson, + JSON.stringify({ + name: "publish-pkg-6", + version: "6.6.6", + private: true, + dependencies: { + "publish-pkg-6": "6.6.6", + }, + }), + ), + write(join(packageDir, "bunfig.toml"), bunfig), + ]); + + // should fail + let { out, err, exitCode } = await publish(env, packageDir); + expect(exitCode).toBe(1); + expect(err).toContain("error: attempted to publish a private package"); + expect(await exists(join(registry.packagesPath, "publish-pkg-6-6.6.6.tgz"))).toBeFalse(); + + // try tarball + await pack(packageDir, env); + ({ out, err, exitCode } = await publish(env, packageDir, "./publish-pkg-6-6.6.6.tgz")); + expect(exitCode).toBe(1); + expect(err).toContain("error: attempted to publish a private package"); + expect(await exists(join(packageDir, "publish-pkg-6-6.6.6.tgz"))).toBeTrue(); +}); + +describe("access", async () => { + test("--access", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("accessflag"); + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-7"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + packageJson, + JSON.stringify({ + name: "publish-pkg-7", + version: "7.7.7", + }), + ), + ]); + + // should fail + let { out, err, exitCode } = await publish(env, packageDir, "--access", "restricted"); + expect(exitCode).toBe(1); + expect(err).toContain("error: unable to restrict access to unscoped package"); + + ({ out, err, exitCode } = await publish(env, packageDir, "--access", "public")); + expect(exitCode).toBe(0); + + expect(await exists(join(registry.packagesPath, "publish-pkg-7"))).toBeTrue(); + }); + + for (const access of ["restricted", "public"]) { + test(`access ${access}`, async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("access" + access); + + const pkgJson = { + name: "@secret/publish-pkg-8", + version: "8.8.8", + dependencies: { + "@secret/publish-pkg-8": "8.8.8", + }, + publishConfig: { + access, + }, + }; + + await Promise.all([ + rm(join(registry.packagesPath, "@secret", "publish-pkg-8"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write(packageJson, JSON.stringify(pkgJson)), + ]); + + let { out, err, exitCode } = await publish(env, packageDir); + expect(exitCode).toBe(0); + + await runBunInstall(env, packageDir); + + expect(await file(join(packageDir, "node_modules", "@secret", "publish-pkg-8", "package.json")).json()).toEqual( + pkgJson, + ); + }); + } +}); + +describe("tag", async () => { + test("can publish with a tag", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("simpletag"); + const pkgJson = { + name: "publish-pkg-9", + version: "9.9.9", + dependencies: { + "publish-pkg-9": "simpletag", + }, + }; + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-9"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write(packageJson, JSON.stringify(pkgJson)), + ]); + + let { out, err, exitCode } = await publish(env, packageDir, "--tag", "simpletag"); + expect(exitCode).toBe(0); + + await runBunInstall(env, packageDir); + expect(await file(join(packageDir, "node_modules", "publish-pkg-9", "package.json")).json()).toEqual(pkgJson); + }); +}); + +it("$npm_command is accurate during publish", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + await write( + packageJson, + JSON.stringify({ + name: "publish-pkg-10", + version: "1.0.0", + scripts: { + publish: "echo $npm_command", + }, + }), + ); + await write(join(packageDir, "bunfig.toml"), await registry.authBunfig("npm_command")); + await rm(join(registry.packagesPath, "publish-pkg-10"), { recursive: true, force: true }); + let { out, err, exitCode } = await publish(env, packageDir, "--tag", "simpletag"); + expect(err).toBe(`$ echo $npm_command\n`); + expect(out.split("\n")).toEqual([ + `bun publish ${Bun.version_with_sha}`, + ``, + `packed 95B package.json`, + ``, + `Total files: 1`, + expect.stringContaining(`Shasum: `), + expect.stringContaining(`Integrity: sha512-`), + `Unpacked size: 95B`, + expect.stringContaining(`Packed size: `), + `Tag: simpletag`, + `Access: default`, + `Registry: http://localhost:${registry.port}/`, + ``, + ` + publish-pkg-10@1.0.0`, + `publish`, + ``, + ]); + expect(exitCode).toBe(0); +}); + +it("$npm_lifecycle_event is accurate during publish", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + await write( + packageJson, + `{ + "name": "publish-pkg-11", + "version": "1.0.0", + "scripts": { + "prepublish": "echo 1 $npm_lifecycle_event", + "publish": "echo 2 $npm_lifecycle_event", + "postpublish": "echo 3 $npm_lifecycle_event", + }, + } + `, + ); + await write(join(packageDir, "bunfig.toml"), await registry.authBunfig("npm_lifecycle_event")); + await rm(join(registry.packagesPath, "publish-pkg-11"), { recursive: true, force: true }); + let { out, err, exitCode } = await publish(env, packageDir, "--tag", "simpletag"); + expect(err).toBe(`$ echo 2 $npm_lifecycle_event\n$ echo 3 $npm_lifecycle_event\n`); + expect(out.split("\n")).toEqual([ + `bun publish ${Bun.version_with_sha}`, + ``, + `packed 256B package.json`, + ``, + `Total files: 1`, + expect.stringContaining(`Shasum: `), + expect.stringContaining(`Integrity: sha512-`), + `Unpacked size: 256B`, + expect.stringContaining(`Packed size: `), + `Tag: simpletag`, + `Access: default`, + `Registry: http://localhost:${registry.port}/`, + ``, + ` + publish-pkg-11@1.0.0`, + `2 publish`, + `3 postpublish`, + ``, + ]); + expect(exitCode).toBe(0); +}); diff --git a/test/harness.ts b/test/harness.ts index 7c26b67ee3..a7cf32a27b 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -1056,6 +1056,7 @@ export async function runBunInstall( frozenLockfile?: boolean; saveTextLockfile?: boolean; packages?: string[]; + verbose?: boolean; }, ) { const production = options?.production ?? false; @@ -1072,6 +1073,9 @@ export async function runBunInstall( if (options?.saveTextLockfile) { args.push("--save-text-lockfile"); } + if (options?.verbose) { + args.push("--verbose"); + } const { stdout, stderr, exited } = Bun.spawn({ cmd: args, cwd, @@ -1452,6 +1456,7 @@ export class VerdaccioRegistry { process: ChildProcess | undefined; configPath: string; packagesPath: string; + users: Record = {}; constructor(opts?: { configPath?: string; packagesPath?: string; verbose?: boolean }) { this.port = randomPort(); @@ -1504,10 +1509,53 @@ export class VerdaccioRegistry { this.process?.kill(0); } + /** + * returns auth token + */ + async generateUser(username: string, password: string): Promise { + if (this.users[username]) { + throw new Error(`User ${username} already exists`); + } else this.users[username] = password; + + const url = `http://localhost:${this.port}/-/user/org.couchdb.user:${username}`; + const user = { + name: username, + password: password, + email: `${username}@example.com`, + }; + + const response = await fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(user), + }); + + if (response.ok) { + const data = await response.json(); + return data.token; + } + + throw new Error("Failed to create user:", response.statusText); + } + + async authBunfig(user: string) { + const authToken = await this.generateUser(user, user); + return ` + [install] + cache = false + registry = { url = "http://localhost:${this.port}/", token = "${authToken}" } + `; + } + async createTestDir(bunfigOpts: BunfigOpts = {}) { + await rm(join(dirname(this.configPath), "htpasswd"), { force: true }); + await rm(join(this.packagesPath, "private-pkg-dont-touch"), { force: true }); const packageDir = tmpdirSync(); const packageJson = join(packageDir, "package.json"); this.writeBunfig(packageDir, bunfigOpts); + this.users = {}; return { packageDir, packageJson }; }