diff --git a/docs/cli/publish.md b/docs/cli/publish.md new file mode 100644 index 0000000000000..f679d220dba11 --- /dev/null +++ b/docs/cli/publish.md @@ -0,0 +1,111 @@ +## `bun publish` + +Use `bun publish` to publish a package to the npm registry. + +`bun publish` will automatically pack your package into a tarball, strip workspace protocols from the `package.json` (resolving versions if necessary), and publish to the registry specified in your configuration files. Both `bunfig.toml` and `.npmrc` files are supported. + +```sh +## Publishing the package from the current working directory +$ bun publish + +## Output +bun publish v1.1.30 (ca7428e9) + +packed 203B package.json +packed 224B README.md +packed 30B index.ts +packed 0.64KB tsconfig.json + +Total files: 4 +Shasum: 79e2b4377b63f4de38dc7ea6e5e9dbee08311a69 +Integrity: sha512-6QSNlDdSwyG/+[...]X6wXHriDWr6fA== +Unpacked size: 1.1KB +Packed size: 0.76KB +Tag: latest +Access: default +Registry: http://localhost:4873/ + + + publish-1@1.0.0 +``` + +Alternatively, you can pack and publish your package separately by using `bun pm pack` followed by `bun publish` with the path to the output tarball. + +```sh +$ bun pm pack +... +$ bun publish ./package.tgz +``` + +{% callout %} +**Note** - `bun publish` will not run lifecycle scripts (`prepublishOnly/prepack/prepare/postpack/publish/postpublish`) if a tarball path is provided. Scripts will only be run if the package is packed by `bun publish`. +{% /callout %} + +## Flags + +### `--access` + +The `--access` flag can be used to set the access level of the package being published. The access level can be one of `public` or `restricted`. Uscoped packages are always public, and attempting to publish an unscoped package with `--access restricted` will result in an error. + +```sh +$ bun publish --access public +``` + +`--access` can also be set in the `publishConfig` field of your `package.json`. + +```json +{ + "publishConfig": { + "access": "restricted" + } +} +``` + +### `--tag` + +Set the tag of the package version being published. By default, the tag is `latest`. The initial version of a package is always given the `latest` tag in addition to the specified tag. + +```sh +$ bun publish --tag alpha +``` + +`--tag` can also be set in the `publishConfig` field of your `package.json`. + +```json +{ + "publishConfig": { + "tag": "next" + } +} +``` + +### `--dry-run` + +The `--dry-run` flag can be used to simulate the publish process without actually publishing the package. This is useful for verifying the contents of the published package without actually publishing the package. + +```sh +$ bun publish --dry-run +``` + +### `--auth-type` + +If you have 2FA enabled for your npm account, `bun publish` will prompt you for a one-time password. This can be done through a browser or the CLI. The `--auth-type` flag can be used to tell the npm registry which method you prefer. The possible values are `web` and `legacy`, with `web` being the default. + +```sh +$ bun publish --auth-type legacy +... +This operation requires a one-time password. +Enter OTP: 123456 +... +``` + +### `--otp` + +Provide a one-time password directly to the CLI. If the password is valid, this will skip the extra prompt for a one-time password before publishing. Example usage: + +```sh +$ bun publish --otp 123456 +``` + +### `--gzip-level` + +Specify the level of gzip compression to use when packing the package. Only applies to `bun publish` without a tarball path argument. Values range from `0` to `9` (default is `9`). diff --git a/docs/nav.ts b/docs/nav.ts index fffad700e10a2..c4f04ca7c28cb 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -164,6 +164,9 @@ export default { page("cli/update", "`bun update`", { description: "Update your project's dependencies.", }), + page("cli/publish", "`bun publish`", { + description: "Publish your package to an npm registry.", + }), page("cli/outdated", "`bun outdated`", { description: "Check for outdated dependencies.", }), diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 75df32eeb185c..05d33b594a6c7 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -1330,7 +1330,7 @@ pub const FetchHeaders = opaque { pub fn createFromPicoHeaders( pico_headers: anytype, ) *FetchHeaders { - const out = PicoHeaders{ .ptr = pico_headers.ptr, .len = pico_headers.len }; + const out = PicoHeaders{ .ptr = pico_headers.list.ptr, .len = pico_headers.list.len }; const result = shim.cppFn("createFromPicoHeaders_", .{ &out, }); diff --git a/src/cli/create_command.zig b/src/cli/create_command.zig index d45a8b040831c..16ac76623ecc3 100644 --- a/src/cli/create_command.zig +++ b/src/cli/create_command.zig @@ -1996,7 +1996,7 @@ pub const Example = struct { var is_expected_content_type = false; var content_type: string = ""; - for (response.headers) |header| { + for (response.headers.list) |header| { if (strings.eqlCaseInsensitiveASCII(header.name, "content-type", true)) { content_type = header.value; diff --git a/src/cli/publish_command.zig b/src/cli/publish_command.zig index 3ffe09ed410a1..d3524b7aca5d7 100644 --- a/src/cli/publish_command.zig +++ b/src/cli/publish_command.zig @@ -561,14 +561,7 @@ pub const PublishCommand = struct { const prompt_for_otp = prompt_for_otp: { if (res.status_code != 401) break :prompt_for_otp false; - if (authenticate: { - for (res.headers) |header| { - if (strings.eqlCaseInsensitiveASCII(header.name, "www-authenticate", true)) { - break :authenticate header.value; - } - } - break :authenticate null; - }) |@"www-authenticate"| { + if (res.headers.get("www-authenticate")) |@"www-authenticate"| { var iter = strings.split(@"www-authenticate", ","); while (iter.next()) |part| { const trimmed = strings.trim(part, &strings.whitespace_chars); @@ -583,7 +576,7 @@ pub const PublishCommand = struct { Output.errGeneric("unable to authenticate, need: {s}", .{@"www-authenticate"}); Global.crash(); } else if (strings.containsComptime(response_buf.list.items, "one-time pass")) { - // missing www-authenticate header but one-time pass is still included + // missing www-authenicate header but one-time pass is still included break :prompt_for_otp true; } @@ -592,7 +585,14 @@ pub const PublishCommand = struct { if (!prompt_for_otp) { // general error - return handleResponseErrors(directory_publish, ctx, &req, &res, &response_buf, true); + const otp_response = false; + return handleResponseErrors(directory_publish, ctx, &req, &res, &response_buf, otp_response); + } + + if (res.headers.get("npm-notice")) |notice| { + Output.printError("\n", .{}); + Output.note("{s}", .{notice}); + Output.flush(); } const otp = try getOTP(directory_publish, ctx, registry, &response_buf, &print_buf); @@ -634,9 +634,16 @@ pub const PublishCommand = struct { switch (otp_res.status_code) { 400...std.math.maxInt(@TypeOf(otp_res.status_code)) => { - return handleResponseErrors(directory_publish, ctx, &otp_req, &otp_res, &response_buf, true); + const otp_response = true; + return handleResponseErrors(directory_publish, ctx, &otp_req, &otp_res, &response_buf, otp_response); + }, + else => { + if (otp_res.headers.get("npm-notice")) |notice| { + Output.printError("\n", .{}); + Output.note("{s}", .{notice}); + Output.flush(); + } }, - else => {}, } }, else => {}, @@ -649,7 +656,7 @@ pub const PublishCommand = struct { req: *const http.AsyncHTTP, res: *const bun.picohttp.Response, response_body: *MutableString, - comptime check_for_success: bool, + comptime otp_response: bool, ) OOM!void { const message = message: { const source = logger.Source.initPathString("???", response_body.list.items); @@ -660,29 +667,40 @@ pub const PublishCommand = struct { } }; - if (comptime check_for_success) { - if (json.get("success")) |success_expr| { - if (success_expr.asBool()) |successful| { - if (successful) { - // possible to hit this with otp responses - return; - } - } - } - } + // I don't think we should make this check, I cannot find code in npm + // that does this + // if (comptime otp_response) { + // if (json.get("success")) |success_expr| { + // if (success_expr.asBool()) |successful| { + // if (successful) { + // // possible to hit this with otp responses + // return; + // } + // } + // } + // } const @"error", _ = try json.getString(ctx.allocator, "error") orelse break :message null; break :message @"error"; }; - Output.prettyErrorln("\n{d}{s}{s}: {s}\n{s}{s}", .{ + Output.prettyErrorln("\n{d}{s}{s}: {s}\n", .{ res.status_code, if (res.status.len > 0) " " else "", res.status, bun.fmt.redactedNpmUrl(req.url.href), - if (message != null) "\n - " else "", - message orelse "", }); + + if (message) |msg| { + if (comptime otp_response) { + if (res.status_code == 401 and strings.containsComptime(msg, "You must provide a one-time pass. Upgrade your client to npm@latest in order to use 2FA.")) { + Output.prettyErrorln("\n - Received invalid OTP", .{}); + Global.crash(); + } + } + Output.prettyErrorln("\n - {s}", .{msg}); + } + Global.crash(); } @@ -810,12 +828,10 @@ pub const PublishCommand = struct { 202 => { // retry const nanoseconds = nanoseconds: { - default: for (res.headers) |header| { - if (strings.eqlCaseInsensitiveASCII(header.name, "retry-after", true)) { - const trimmed = strings.trim(header.value, &strings.whitespace_chars); - const seconds = bun.fmt.parseInt(u32, trimmed, 10) catch break :default; - break :nanoseconds seconds * std.time.ns_per_s; - } + if (res.headers.get("retry-after")) |retry| default: { + const trimmed = strings.trim(retry, &strings.whitespace_chars); + const seconds = bun.fmt.parseInt(u32, trimmed, 10) catch break :default; + break :nanoseconds seconds * std.time.ns_per_s; } break :nanoseconds 500 * std.time.ns_per_ms; @@ -837,13 +853,22 @@ pub const PublishCommand = struct { } }; - return try otp_done_json.getStringCloned(ctx.allocator, "token") orelse { + const token = try otp_done_json.getStringCloned(ctx.allocator, "token") orelse { Output.err("WebLogin", "missing `token` field in reponse json", .{}); Global.crash(); }; + + if (res.headers.get("npm-notice")) |notice| { + Output.printError("\n", .{}); + Output.note("{s}", .{notice}); + Output.flush(); + } + + return token; }, else => { - try handleResponseErrors(directory_publish, ctx, &req, &res, response_buf, false); + const otp_response = false; + try handleResponseErrors(directory_publish, ctx, &req, &res, response_buf, otp_response); }, } } diff --git a/src/deps/picohttp.zig b/src/deps/picohttp.zig index 08770fcde2d52..f551b949a7a57 100644 --- a/src/deps/picohttp.zig +++ b/src/deps/picohttp.zig @@ -6,6 +6,8 @@ const Match = ExactSizeMatcher(2); const Output = bun.Output; const Environment = bun.Environment; const StringBuilder = bun.StringBuilder; +const string = bun.string; +const strings = bun.strings; const fmt = std.fmt; @@ -15,6 +17,19 @@ pub const Header = struct { name: []const u8, value: []const u8, + pub const List = struct { + list: []Header = &.{}, + + pub fn get(this: *const List, name: string) ?string { + for (this.list) |header| { + if (strings.eqlCaseInsensitiveASCII(header.name, name, true)) { + return header.value; + } + } + return null; + } + }; + pub fn isMultiline(self: Header) bool { return @intFromPtr(self.name.ptr) == 0; } @@ -216,7 +231,7 @@ pub const Response = struct { minor_version: usize = 0, status_code: u32 = 0, status: []const u8 = "", - headers: []Header = &.{}, + headers: Header.List = .{}, bytes_read: c_int = 0, pub fn format(self: Response, comptime _: []const u8, _: fmt.FormatOptions, writer: anytype) !void { @@ -234,7 +249,7 @@ pub const Response = struct { self.status, }, ); - for (self.headers) |header| { + for (self.headers.list) |header| { if (Output.enable_ansi_colors_stderr) { _ = try writer.write(Output.prettyFmt("[fetch] ", true)); } @@ -247,7 +262,7 @@ pub const Response = struct { pub fn count(this: *const Response, builder: *StringBuilder) void { builder.count(this.status); - for (this.headers) |header| { + for (this.headers.list) |header| { header.count(builder); } } @@ -256,11 +271,11 @@ pub const Response = struct { var that = this.*; that.status = builder.append(this.status); - for (this.headers, 0..) |header, i| { + for (this.headers.list, 0..) |header, i| { headers[i] = header.clone(builder); } - that.headers = headers[0..this.headers.len]; + that.headers.list = headers[0..this.headers.list.len]; return that; } @@ -297,7 +312,7 @@ pub const Response = struct { .minor_version = @as(usize, @intCast(minor_version)), .status_code = @as(u32, @intCast(status_code)), .status = status, - .headers = src[0..@min(num_headers, src.len)], + .headers = .{ .list = src[0..@min(num_headers, src.len)] }, .bytes_read = rc, }, }; diff --git a/src/http.zig b/src/http.zig index 3e475ad0a14d6..c2c0da7ba577b 100644 --- a/src/http.zig +++ b/src/http.zig @@ -2790,10 +2790,10 @@ pub const HTTPResponseMetadata = struct { response: picohttp.Response = .{}, pub fn deinit(this: *HTTPResponseMetadata, allocator: std.mem.Allocator) void { if (this.owned_buf.len > 0) allocator.free(this.owned_buf); - if (this.response.headers.len > 0) allocator.free(this.response.headers); + if (this.response.headers.list.len > 0) allocator.free(this.response.headers.list); this.owned_buf = &.{}; this.url = ""; - this.response.headers = &.{}; + this.response.headers = .{}; this.response.status = ""; } }; @@ -3204,11 +3204,11 @@ pub fn handleOnDataHeaders( return; }; - if (this.state.content_encoding_i < response.headers.len and !this.state.flags.did_set_content_encoding) { + if (this.state.content_encoding_i < response.headers.list.len and !this.state.flags.did_set_content_encoding) { // if it compressed with this header, it is no longer because we will decompress it - const mutable_headers = std.ArrayListUnmanaged(picohttp.Header){ .items = response.headers, .capacity = response.headers.len }; + const mutable_headers = std.ArrayListUnmanaged(picohttp.Header){ .items = response.headers.list, .capacity = response.headers.list.len }; this.state.flags.did_set_content_encoding = true; - response.headers = mutable_headers.items; + response.headers = .{ .list = mutable_headers.items }; this.state.content_encoding_i = std.math.maxInt(@TypeOf(this.state.content_encoding_i)); // we need to reset the pending response because we removed a header this.state.pending_response = response; @@ -3390,7 +3390,7 @@ fn cloneMetadata(this: *HTTPClient) void { builder.count(this.url.href); builder.allocate(this.allocator) catch unreachable; // headers_buf is owned by the cloned_response (aka cloned_response.headers) - const headers_buf = this.allocator.alloc(picohttp.Header, response.headers.len) catch unreachable; + const headers_buf = this.allocator.alloc(picohttp.Header, response.headers.list.len) catch unreachable; const cloned_response = response.clone(headers_buf, builder); // we clean the temporary response since cloned_metadata is now the owner @@ -3852,7 +3852,7 @@ pub fn handleResponseMetadata( var location: string = ""; var pretend_304 = false; var is_server_sent_events = false; - for (response.headers, 0..) |header, header_i| { + for (response.headers.list, 0..) |header, header_i| { switch (hashHeaderName(header.name)) { hashHeaderConst("Content-Length") => { const content_length = std.fmt.parseInt(usize, header.value, 10) catch 0; diff --git a/src/http/websocket_http_client.zig b/src/http/websocket_http_client.zig index c2c78cc093f53..f8c8bac00b83d 100644 --- a/src/http/websocket_http_client.zig +++ b/src/http/websocket_http_client.zig @@ -517,7 +517,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { return; } - for (response.headers) |header| { + for (response.headers.list) |header| { switch (header.name.len) { "Connection".len => { if (connection_header.name.len == 0 and strings.eqlCaseInsensitiveASCII(header.name, "Connection", false)) { diff --git a/src/install/install.zig b/src/install/install.zig index 17b8edceee60c..95e6cb674801a 100644 --- a/src/install/install.zig +++ b/src/install/install.zig @@ -9617,14 +9617,14 @@ pub const PackageManager = struct { const outro_text = \\Examples: - \\ Publish the package in the current working directory with public access. - \\ bun publish --access public + \\ Display files that would be published, without publishing to the registry. + \\ bun publish --dry-run \\ - \\ Publish a pre-existing package tarball. - \\ bun publish ./path/to/tarball.tgz + \\ Publish the current package with public access. + \\ bun publish --access public \\ - \\ Publish with tag 'next'. - \\ bun publish --tag next + \\ Publish a pre-existing package tarball with tag 'next'. + \\ bun publish ./path/to/tarball.tgz --tag next \\ ; diff --git a/src/install/npm.zig b/src/install/npm.zig index 8352923ad632f..722782289ad9d 100644 --- a/src/install/npm.zig +++ b/src/install/npm.zig @@ -249,7 +249,7 @@ pub const Registry = struct { var newly_last_modified: string = ""; var new_etag: string = ""; - for (response.headers) |header| { + for (response.headers.list) |header| { if (!(header.name.len == "last-modified".len or header.name.len == "etag".len)) continue; const hashed = HTTPClient.hashHeaderName(header.name); diff --git a/src/output.zig b/src/output.zig index 6edd3236947fb..1812e29efbf93 100644 --- a/src/output.zig +++ b/src/output.zig @@ -1004,12 +1004,12 @@ pub const DebugTimer = struct { } }; -/// Print a blue note message +/// Print a blue note message to stderr pub inline fn note(comptime fmt: []const u8, args: anytype) void { prettyErrorln("note: " ++ fmt, args); } -/// Print a yellow warning message +/// Print a yellow warning message to stderr pub inline fn warn(comptime fmt: []const u8, args: anytype) void { prettyErrorln("warn: " ++ fmt, args); } diff --git a/test/cli/install/registry/bun-install-registry.test.ts b/test/cli/install/registry/bun-install-registry.test.ts index 756c168b57ad4..c2acdc61f6942 100644 --- a/test/cli/install/registry/bun-install-registry.test.ts +++ b/test/cli/install/registry/bun-install-registry.test.ts @@ -543,47 +543,72 @@ async function authBunfig(user: string) { describe("publish", async () => { describe("otp", async () => { + const mockRegistryFetch = function (opts: { + token: string; + setAuthHeader?: boolean; + otpFail?: boolean; + npmNotice?: boolean; + }) { + return async function (req: Request) { + const { token, setAuthHeader = true, otpFail = false, npmNotice = false } = opts; + if (req.url.includes("otp-pkg")) { + 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`); + + 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, - async fetch(req) { - if (req.url.endsWith("otp-pkg-1")) { - if (req.headers.get("npm-otp") === authToken) { - return new Response("OK", { status: 200 }); - } else { - const headers = new Headers(); - if (setAuthHeader) headers.set("www-authenticate", "OTP"); - 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: authToken }), { status: 200 }); - } - - expect.unreachable("unexpected url"); - }, + fetch: mockRegistryFetch({ token }), }); - const authToken = await generateRegistryUser("otp" + (setAuthHeader ? "" : "noheader"), "otp"); const bunfig = ` [install] cache = false - registry = { url = "http://localhost:${mockRegistry.port}", token = "${authToken}" }`; + registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; await Promise.all([ rm(join(import.meta.dir, "packages", "otp-pkg-1"), { recursive: true, force: true }), write(join(packageDir, "bunfig.toml"), bunfig), @@ -603,7 +628,78 @@ describe("publish", async () => { 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(import.meta.dir, "packages", "otp-pkg-2"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + join(packageDir, "package.json"), + 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"); + }); + + test("npm-notice with login url", 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", "otp"); + using mockRegistry = Bun.serve({ + port: 0, + fetch: mockRegistryFetch({ token, npmNotice: true }), + }); + + const bunfig = ` + [install] + cache = false + registry = { url = "http://localhost:${mockRegistry.port}", token = "${token}" }`; + + await Promise.all([ + rm(join(import.meta.dir, "packages", "otp-pkg-3"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write( + join(packageDir, "package.json"), + 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); + expect(err).toContain(`note: visit http://localhost:${mockRegistry.port}/auth to login`); + }); }); + test("can publish a package then install it", async () => { const bunfig = await authBunfig("basic"); await Promise.all([