diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index 95a491ae41..aa592e5957 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -344,6 +344,7 @@ src/cli/package_manager_command.zig src/cli/patch_command.zig src/cli/patch_commit_command.zig src/cli/pm_trusted_command.zig +src/cli/pm_version_command.zig src/cli/pm_view_command.zig src/cli/publish_command.zig src/cli/remove_command.zig diff --git a/completions/bun.zsh b/completions/bun.zsh index a62c6dc67e..3680e71814 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -261,6 +261,7 @@ _bun_pm_completion() { 'hash-string\:"print the string used to hash the lockfile" ' 'hash-print\:"print the hash stored in the current lockfile" ' 'cache\:"print the path to the cache folder" ' + 'version\:"bump the version in package.json and create a git tag" ' ) _alternative "args:cmd3:(($sub_commands))" @@ -299,6 +300,40 @@ _bun_pm_completion() { $pmargs && ret=0 + ;; + version) + version_args=( + "patch[increment patch version]" + "minor[increment minor version]" + "major[increment major version]" + "prepatch[increment patch version and add pre-release]" + "preminor[increment minor version and add pre-release]" + "premajor[increment major version and add pre-release]" + "prerelease[increment pre-release version]" + "from-git[use version from latest git tag]" + ) + + pmargs=( + "--no-git-tag-version[don't create a git commit and tag]" + "--allow-same-version[allow bumping to the same version]" + "-m[use the given message for the commit]:message" + "--message[use the given message for the commit]:message" + "--preid[identifier to prefix pre-release versions]:preid" + ) + + _arguments -s -C \ + '1: :->cmd' \ + '2: :->cmd2' \ + '3: :->increment' \ + $pmargs && + ret=0 + + case $state in + increment) + _alternative "args:increment:(($version_args))" + ;; + esac + ;; esac diff --git a/docs/cli/pm.md b/docs/cli/pm.md index 620a08ff9f..0739de7ae9 100644 --- a/docs/cli/pm.md +++ b/docs/cli/pm.md @@ -151,3 +151,44 @@ $ bun pm default-trusted ``` see the current list on GitHub [here](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt) + +## version + +To display current package version and help: + +```bash +$ bun pm version +bun pm version v$BUN_LATEST_VERSION (ca7428e9) +Current package version: v1.0.0 + +Increment: + patch 1.0.0 → 1.0.1 + minor 1.0.0 → 1.1.0 + major 1.0.0 → 2.0.0 + prerelease 1.0.0 → 1.0.1-0 + prepatch 1.0.0 → 1.0.1-0 + preminor 1.0.0 → 1.1.0-0 + premajor 1.0.0 → 2.0.0-0 + from-git Use version from latest git tag + 1.2.3 Set specific version + +Options: + --no-git-tag-version Skip git operations + --allow-same-version Prevents throwing error if version is the same + --message=, -m Custom commit message + --preid= Prerelease identifier + +Examples: + $ bun pm version patch + $ bun pm version 1.2.3 --no-git-tag-version + $ bun pm version prerelease --preid beta +``` + +To bump the version in `package.json`: + +```bash +$ bun pm version patch +v1.0.1 +``` + +Supports `patch`, `minor`, `major`, `premajor`, `preminor`, `prepatch`, `prerelease`, `from-git`, or specific versions like `1.2.3`. By default creates git commit and tag unless `--no-git-tag-version` was used to skip. diff --git a/src/cli/package_manager_command.zig b/src/cli/package_manager_command.zig index 4ef5083653..251e1e89d8 100644 --- a/src/cli/package_manager_command.zig +++ b/src/cli/package_manager_command.zig @@ -22,6 +22,7 @@ const Environment = bun.Environment; pub const PackCommand = @import("./pack_command.zig").PackCommand; const Npm = Install.Npm; const PmViewCommand = @import("./pm_view_command.zig"); +const PmVersionCommand = @import("./pm_version_command.zig").PmVersionCommand; const File = bun.sys.File; const ByName = struct { @@ -127,6 +128,8 @@ pub const PackageManagerCommand = struct { \\ --all list the entire dependency tree according to the current lockfile \\ bun pm whoami print the current npm username \\ bun pm view name[@version] view package metadata from the registry (use `bun info` instead) + \\ bun pm version [increment] bump the version in package.json and create a git tag + \\ increment patch, minor, major, prepatch, preminor, premajor, prerelease, from-git, or a specific version \\ bun pm hash generate & print the hash of the current lockfile \\ bun pm hash-string print the string used to hash the lockfile \\ bun pm hash-print print the hash stored in the current lockfile @@ -428,6 +431,9 @@ pub const PackageManagerCommand = struct { lockfile.saveToDisk(&load_lockfile, &pm.options); Global.exit(0); + } else if (strings.eqlComptime(subcommand, "version")) { + try PmVersionCommand.exec(ctx, pm, pm.options.positionals, cwd); + Global.exit(0); } printHelp(); diff --git a/src/cli/pm_version_command.zig b/src/cli/pm_version_command.zig new file mode 100644 index 0000000000..2dc75823b6 --- /dev/null +++ b/src/cli/pm_version_command.zig @@ -0,0 +1,647 @@ +const std = @import("std"); +const bun = @import("bun"); +const Global = bun.Global; +const Output = bun.Output; +const strings = bun.strings; +const string = bun.string; +const Command = bun.CLI.Command; +const PackageManager = bun.install.PackageManager; +const Semver = bun.Semver; +const logger = bun.logger; +const JSON = bun.JSON; +const RunCommand = bun.RunCommand; +const Environment = bun.Environment; + +pub const PmVersionCommand = struct { + const VersionType = enum { + patch, + minor, + major, + prepatch, + preminor, + premajor, + prerelease, + specific, + from_git, + + pub fn fromString(str: []const u8) ?VersionType { + if (strings.eqlComptime(str, "patch")) return .patch; + if (strings.eqlComptime(str, "minor")) return .minor; + if (strings.eqlComptime(str, "major")) return .major; + if (strings.eqlComptime(str, "prepatch")) return .prepatch; + if (strings.eqlComptime(str, "preminor")) return .preminor; + if (strings.eqlComptime(str, "premajor")) return .premajor; + if (strings.eqlComptime(str, "prerelease")) return .prerelease; + if (strings.eqlComptime(str, "from-git")) return .from_git; + return null; + } + }; + + pub fn exec(ctx: Command.Context, pm: *PackageManager, positionals: []const string, original_cwd: []const u8) !void { + const package_json_dir = try findPackageDir(ctx.allocator, original_cwd); + + if (positionals.len <= 1) { + try showHelp(ctx, pm, package_json_dir); + return; + } + + const version_type, const new_version = parseVersionArgument(positionals[1]); + + try verifyGit(package_json_dir, pm); + + var path_buf: bun.PathBuffer = undefined; + const package_json_path = bun.path.joinAbsStringBufZ(package_json_dir, &path_buf, &.{"package.json"}, .auto); + + const package_json_contents = bun.sys.File.readFrom(bun.FD.cwd(), package_json_path, ctx.allocator).unwrap() catch |err| { + Output.errGeneric("Failed to read package.json: {s}", .{@errorName(err)}); + Global.exit(1); + }; + defer ctx.allocator.free(package_json_contents); + + const package_json_source = logger.Source.initPathString(package_json_path, package_json_contents); + const json = JSON.parsePackageJSONUTF8(&package_json_source, ctx.log, ctx.allocator) catch |err| { + Output.errGeneric("Failed to parse package.json: {s}", .{@errorName(err)}); + Global.exit(1); + }; + + const scripts = json.asProperty("scripts"); + const scripts_obj = if (scripts) |s| if (s.expr.data == .e_object) s.expr else null else null; + + if (pm.options.do.run_scripts) { + if (scripts_obj) |s| { + if (s.get("preversion")) |script| { + if (script.asString(ctx.allocator)) |script_command| { + try RunCommand.runPackageScriptForeground( + ctx, + ctx.allocator, + script_command, + "preversion", + package_json_dir, + pm.env, + &.{}, + pm.options.log_level == .silent, + ctx.debug.use_system_shell, + ); + } + } + } + } + + const current_version = brk_version: { + if (json.asProperty("version")) |v| { + switch (v.expr.data) { + .e_string => |s| { + break :brk_version s.data; + }, + else => {}, + } + } + Output.errGeneric("No version field found in package.json", .{}); + Global.exit(1); + }; + + const new_version_str = try calculateNewVersion(ctx.allocator, current_version, version_type, new_version, pm.options.preid, package_json_dir); + defer ctx.allocator.free(new_version_str); + + if (!pm.options.allow_same_version and strings.eql(current_version, new_version_str)) { + Output.errGeneric("Version not changed", .{}); + Global.exit(1); + } + + { + const updated_contents = try updateVersionString(ctx.allocator, package_json_contents, current_version, new_version_str); + defer ctx.allocator.free(updated_contents); + + const file = std.fs.cwd().openFile(package_json_path, .{ .mode = .write_only }) catch |err| { + Output.errGeneric("Failed to open package.json for writing: {s}", .{@errorName(err)}); + Global.exit(1); + }; + defer file.close(); + + try file.seekTo(0); + try file.setEndPos(0); + try file.writeAll(updated_contents); + } + + if (pm.options.do.run_scripts) { + if (scripts_obj) |s| { + if (s.get("version")) |script| { + if (script.asString(ctx.allocator)) |script_command| { + try RunCommand.runPackageScriptForeground( + ctx, + ctx.allocator, + script_command, + "version", + package_json_dir, + pm.env, + &.{}, + pm.options.log_level == .silent, + ctx.debug.use_system_shell, + ); + } + } + } + } + + if (pm.options.git_tag_version) { + try gitCommitAndTag(ctx.allocator, new_version_str, pm.options.message, package_json_dir); + } + + if (pm.options.do.run_scripts) { + if (scripts_obj) |s| { + if (s.get("postversion")) |script| { + if (script.asString(ctx.allocator)) |script_command| { + try RunCommand.runPackageScriptForeground( + ctx, + ctx.allocator, + script_command, + "postversion", + package_json_dir, + pm.env, + &.{}, + pm.options.log_level == .silent, + ctx.debug.use_system_shell, + ); + } + } + } + } + + Output.println("v{s}", .{new_version_str}); + Output.flush(); + } + + fn findPackageDir(allocator: std.mem.Allocator, start_dir: []const u8) bun.OOM![]const u8 { + var path_buf: bun.PathBuffer = undefined; + var current_dir = start_dir; + + while (true) { + const package_json_path_z = bun.path.joinAbsStringBufZ(current_dir, &path_buf, &.{"package.json"}, .auto); + if (bun.FD.cwd().existsAt(package_json_path_z)) { + return try allocator.dupe(u8, current_dir); + } + + const parent = bun.path.dirname(current_dir, .auto); + if (strings.eql(parent, current_dir)) { + break; + } + current_dir = parent; + } + + return try allocator.dupe(u8, start_dir); + } + + fn verifyGit(cwd: []const u8, pm: *PackageManager) !void { + if (!pm.options.git_tag_version) return; + + var path_buf: bun.PathBuffer = undefined; + const git_dir_path = bun.path.joinAbsStringBuf(cwd, &path_buf, &.{".git"}, .auto); + if (!bun.FD.cwd().directoryExistsAt(git_dir_path).isTrue()) { + pm.options.git_tag_version = false; + return; + } + + if (!try isGitClean(cwd) and !pm.options.force) { + Output.errGeneric("Git working directory not clean.", .{}); + Global.exit(1); + } + } + + fn parseVersionArgument(arg: []const u8) struct { VersionType, ?[]const u8 } { + if (VersionType.fromString(arg)) |vtype| { + return .{ vtype, null }; + } + + const version = Semver.Version.parse(Semver.SlicedString.init(arg, arg)); + if (version.valid) { + return .{ .specific, arg }; + } + + Output.errGeneric("Invalid version argument: \"{s}\"", .{arg}); + Output.note("Valid options: patch, minor, major, prepatch, preminor, premajor, prerelease, from-git, or a specific semver version", .{}); + Global.exit(1); + } + + fn getCurrentVersion(ctx: Command.Context, cwd: []const u8) ?[]const u8 { + var path_buf: bun.PathBuffer = undefined; + const package_json_path = bun.path.joinAbsStringBufZ(cwd, &path_buf, &.{"package.json"}, .auto); + + const package_json_contents = bun.sys.File.readFrom(bun.FD.cwd(), package_json_path, ctx.allocator).unwrap() catch { + return null; + }; + + const package_json_source = logger.Source.initPathString(package_json_path, package_json_contents); + const json = JSON.parsePackageJSONUTF8(&package_json_source, ctx.log, ctx.allocator) catch { + return null; + }; + + if (json.asProperty("version")) |v| { + switch (v.expr.data) { + .e_string => |s| { + return s.data; + }, + else => {}, + } + } + + return null; + } + + fn showHelp(ctx: Command.Context, pm: *PackageManager, cwd: []const u8) bun.OOM!void { + const _current_version = getCurrentVersion(ctx, cwd); + const current_version = _current_version orelse "1.0.0"; + + Output.prettyln("bun pm version v" ++ Global.package_json_version_with_sha ++ "", .{}); + if (_current_version) |version| { + Output.prettyln("Current package version: v{s}", .{version}); + } + + const increment_help_text = + \\ + \\Increment: + \\ patch {s} → {s} + \\ minor {s} → {s} + \\ major {s} → {s} + \\ prerelease {s} → {s} + \\ + ; + Output.pretty(increment_help_text, .{ + current_version, try calculateNewVersion(ctx.allocator, current_version, .patch, null, pm.options.preid, cwd), + current_version, try calculateNewVersion(ctx.allocator, current_version, .minor, null, pm.options.preid, cwd), + current_version, try calculateNewVersion(ctx.allocator, current_version, .major, null, pm.options.preid, cwd), + current_version, try calculateNewVersion(ctx.allocator, current_version, .prerelease, null, pm.options.preid, cwd), + }); + + if (strings.indexOfChar(current_version, '-') != null or pm.options.preid.len > 0) { + const prerelease_help_text = + \\ prepatch {s} → {s} + \\ preminor {s} → {s} + \\ premajor {s} → {s} + \\ + ; + Output.pretty(prerelease_help_text, .{ + current_version, try calculateNewVersion(ctx.allocator, current_version, .prepatch, null, pm.options.preid, cwd), + current_version, try calculateNewVersion(ctx.allocator, current_version, .preminor, null, pm.options.preid, cwd), + current_version, try calculateNewVersion(ctx.allocator, current_version, .premajor, null, pm.options.preid, cwd), + }); + } + + const set_specific_version_help_text = + \\ from-git Use version from latest git tag + \\ 1.2.3 Set specific version + \\ + \\Options: + \\ --no-git-tag-version Skip git operations + \\ --allow-same-version Prevents throwing error if version is the same + \\ --message=\, -m Custom commit message + \\ --preid=\ Prerelease identifier + \\ + \\Examples: + \\ $ bun pm version patch + \\ $ bun pm version 1.2.3 --no-git-tag-version + \\ $ bun pm version prerelease --preid beta + \\ + \\More info: https://bun.sh/docs/cli/pm#version + \\ + ; + Output.pretty(set_specific_version_help_text, .{}); + Output.flush(); + } + + fn updateVersionString(allocator: std.mem.Allocator, contents: []const u8, old_version: []const u8, new_version: []const u8) ![]const u8 { + const version_key = "\"version\""; + + var search_start: usize = 0; + while (std.mem.indexOfPos(u8, contents, search_start, version_key)) |key_pos| { + var colon_pos = key_pos + version_key.len; + while (colon_pos < contents.len and (contents[colon_pos] == ' ' or contents[colon_pos] == '\t')) { + colon_pos += 1; + } + + if (colon_pos >= contents.len or contents[colon_pos] != ':') { + search_start = key_pos + 1; + continue; + } + + colon_pos += 1; + while (colon_pos < contents.len and (contents[colon_pos] == ' ' or contents[colon_pos] == '\t')) { + colon_pos += 1; + } + + if (colon_pos >= contents.len or contents[colon_pos] != '"') { + search_start = key_pos + 1; + continue; + } + + const value_start = colon_pos + 1; + + var value_end = value_start; + while (value_end < contents.len and contents[value_end] != '"') { + if (contents[value_end] == '\\' and value_end + 1 < contents.len) { + value_end += 2; + } else { + value_end += 1; + } + } + + if (value_end >= contents.len) { + search_start = key_pos + 1; + continue; + } + + const current_value = contents[value_start..value_end]; + if (strings.eql(current_value, old_version)) { + var result = std.ArrayList(u8).init(allocator); + try result.appendSlice(contents[0..value_start]); + try result.appendSlice(new_version); + try result.appendSlice(contents[value_end..]); + return result.toOwnedSlice(); + } + + search_start = value_end + 1; + } + + Output.errGeneric("Version not found in package.json", .{}); + Global.exit(1); + } + + fn calculateNewVersion(allocator: std.mem.Allocator, current_str: []const u8, version_type: VersionType, specific_version: ?[]const u8, preid: []const u8, cwd: []const u8) bun.OOM![]const u8 { + if (version_type == .specific) { + return try allocator.dupe(u8, specific_version.?); + } + + if (version_type == .from_git) { + return try getVersionFromGit(allocator, cwd); + } + + const current = Semver.Version.parse(Semver.SlicedString.init(current_str, current_str)); + if (!current.valid) { + Output.errGeneric("Current version \"{s}\" is not a valid semver", .{current_str}); + Global.exit(1); + } + + const prerelease_id: []const u8 = if (preid.len > 0) + try allocator.dupe(u8, preid) + else if (!current.version.tag.hasPre()) + try allocator.dupe(u8, "") + else blk: { + const current_prerelease = current.version.tag.pre.slice(current_str); + + if (strings.indexOfChar(current_prerelease, '.')) |dot_index| { + break :blk try allocator.dupe(u8, current_prerelease[0..dot_index]); + } + + break :blk if (std.fmt.parseInt(u32, current_prerelease, 10)) |_| + try allocator.dupe(u8, "") + else |_| + try allocator.dupe(u8, current_prerelease); + }; + defer allocator.free(prerelease_id); + + return try incrementVersion(allocator, current_str, current, version_type, prerelease_id); + } + + fn incrementVersion(allocator: std.mem.Allocator, current_str: []const u8, current: Semver.Version.ParseResult, version_type: VersionType, preid: []const u8) bun.OOM![]const u8 { + var new_version = current.version.min(); + + switch (version_type) { + .patch => { + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}", .{ new_version.major, new_version.minor, new_version.patch + 1 }); + }, + .minor => { + return try std.fmt.allocPrint(allocator, "{d}.{d}.0", .{ new_version.major, new_version.minor + 1 }); + }, + .major => { + return try std.fmt.allocPrint(allocator, "{d}.0.0", .{new_version.major + 1}); + }, + .prepatch => { + if (preid.len > 0) { + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}-{s}.0", .{ new_version.major, new_version.minor, new_version.patch + 1, preid }); + } else { + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}-0", .{ new_version.major, new_version.minor, new_version.patch + 1 }); + } + }, + .preminor => { + if (preid.len > 0) { + return try std.fmt.allocPrint(allocator, "{d}.{d}.0-{s}.0", .{ new_version.major, new_version.minor + 1, preid }); + } else { + return try std.fmt.allocPrint(allocator, "{d}.{d}.0-0", .{ new_version.major, new_version.minor + 1 }); + } + }, + .premajor => { + if (preid.len > 0) { + return try std.fmt.allocPrint(allocator, "{d}.0.0-{s}.0", .{ new_version.major + 1, preid }); + } else { + return try std.fmt.allocPrint(allocator, "{d}.0.0-0", .{new_version.major + 1}); + } + }, + .prerelease => { + if (current.version.tag.hasPre()) { + const current_prerelease = current.version.tag.pre.slice(current_str); + const identifier = if (preid.len > 0) preid else current_prerelease; + + if (strings.lastIndexOfChar(current_prerelease, '.')) |dot_index| { + const number_str = current_prerelease[dot_index + 1 ..]; + const next_num = std.fmt.parseInt(u32, number_str, 10) catch 0; + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}-{s}.{d}", .{ new_version.major, new_version.minor, new_version.patch, identifier, next_num + 1 }); + } else { + const num = std.fmt.parseInt(u32, current_prerelease, 10) catch null; + if (num) |n| { + if (preid.len > 0) { + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}-{s}.{d}", .{ new_version.major, new_version.minor, new_version.patch, preid, n + 1 }); + } else { + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}-{d}", .{ new_version.major, new_version.minor, new_version.patch, n + 1 }); + } + } else { + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}-{s}.1", .{ new_version.major, new_version.minor, new_version.patch, identifier }); + } + } + } else { + new_version.patch += 1; + if (preid.len > 0) { + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}-{s}.0", .{ new_version.major, new_version.minor, new_version.patch, preid }); + } else { + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}-0", .{ new_version.major, new_version.minor, new_version.patch }); + } + } + }, + else => {}, + } + return try std.fmt.allocPrint(allocator, "{d}.{d}.{d}", .{ new_version.major, new_version.minor, new_version.patch }); + } + + fn isGitClean(cwd: []const u8) bun.OOM!bool { + var path_buf: bun.PathBuffer = undefined; + const git_path = bun.which(&path_buf, bun.getenvZ("PATH") orelse "", cwd, "git") orelse { + Output.errGeneric("git must be installed to use `bun pm version --git-tag-version`", .{}); + Global.exit(1); + }; + + const proc = bun.spawnSync(&.{ + .argv = &.{ git_path, "status", "--porcelain" }, + .stdout = .buffer, + .stderr = .ignore, + .stdin = .ignore, + .cwd = cwd, + .envp = null, + .windows = if (Environment.isWindows) .{ + .loop = bun.JSC.EventLoopHandle.init(bun.JSC.MiniEventLoop.initGlobal(null)), + }, + }) catch return false; + + switch (proc) { + .err => |err| { + Output.err(err, "Failed to spawn git process", .{}); + return false; + }, + .result => |result| { + return result.isOK() and result.stdout.items.len == 0; + }, + } + } + + fn getVersionFromGit(allocator: std.mem.Allocator, cwd: []const u8) bun.OOM![]const u8 { + var path_buf: bun.PathBuffer = undefined; + const git_path = bun.which(&path_buf, bun.getenvZ("PATH") orelse "", cwd, "git") orelse { + Output.errGeneric("git must be installed to use `bun pm version from-git`", .{}); + Global.exit(1); + }; + + const proc = bun.spawnSync(&.{ + .argv = &.{ git_path, "describe", "--tags", "--abbrev=0" }, + .stdout = .buffer, + .stderr = .buffer, + .stdin = .ignore, + .cwd = cwd, + .envp = null, + .windows = if (Environment.isWindows) .{ + .loop = bun.JSC.EventLoopHandle.init(bun.JSC.MiniEventLoop.initGlobal(null)), + }, + }) catch |err| { + Output.err(err, "Failed to spawn git process", .{}); + Global.exit(1); + }; + + switch (proc) { + .err => |err| { + Output.err(err, "Git command failed unexpectedly", .{}); + Global.exit(1); + }, + .result => |result| { + if (!result.isOK()) { + if (result.stderr.items.len > 0) { + Output.errGeneric("Git error: {s}", .{strings.trim(result.stderr.items, " \n\r\t")}); + } else { + Output.errGeneric("No git tags found", .{}); + } + Global.exit(1); + } + + var version_str = strings.trim(result.stdout.items, " \n\r\t"); + if (strings.startsWith(version_str, "v")) { + version_str = version_str[1..]; + } + + return try allocator.dupe(u8, version_str); + }, + } + } + + fn gitCommitAndTag(allocator: std.mem.Allocator, version: []const u8, custom_message: ?[]const u8, cwd: []const u8) bun.OOM!void { + var path_buf: bun.PathBuffer = undefined; + const git_path = bun.which(&path_buf, bun.getenvZ("PATH") orelse "", cwd, "git") orelse { + Output.errGeneric("git must be installed to use `bun pm version --git-tag-version`", .{}); + Global.exit(1); + }; + + const stage_proc = bun.spawnSync(&.{ + .argv = &.{ git_path, "add", "package.json" }, + .cwd = cwd, + .stdout = .buffer, + .stderr = .buffer, + .stdin = .ignore, + .envp = null, + .windows = if (Environment.isWindows) .{ + .loop = bun.JSC.EventLoopHandle.init(bun.JSC.MiniEventLoop.initGlobal(null)), + }, + }) catch |err| { + Output.errGeneric("Git add failed: {s}", .{@errorName(err)}); + return; + }; + + switch (stage_proc) { + .err => |err| { + Output.err(err, "Git add failed unexpectedly", .{}); + return; + }, + .result => |result| { + if (!result.isOK()) { + Output.errGeneric("Git add failed with exit code {d}", .{result.status.exited.code}); + return; + } + }, + } + + const commit_message = custom_message orelse try std.fmt.allocPrint(allocator, "v{s}", .{version}); + defer if (custom_message == null) allocator.free(commit_message); + + const commit_proc = bun.spawnSync(&.{ + .argv = &.{ git_path, "commit", "-m", commit_message }, + .cwd = cwd, + .stdout = .buffer, + .stderr = .buffer, + .stdin = .ignore, + .envp = null, + .windows = if (Environment.isWindows) .{ + .loop = bun.JSC.EventLoopHandle.init(bun.JSC.MiniEventLoop.initGlobal(null)), + }, + }) catch |err| { + Output.errGeneric("Git commit failed: {s}", .{@errorName(err)}); + return; + }; + + switch (commit_proc) { + .err => |err| { + Output.err(err, "Git commit failed unexpectedly", .{}); + return; + }, + .result => |result| { + if (!result.isOK()) { + Output.errGeneric("Git commit failed", .{}); + return; + } + }, + } + + const tag_name = try std.fmt.allocPrint(allocator, "v{s}", .{version}); + defer allocator.free(tag_name); + + const tag_proc = bun.spawnSync(&.{ + .argv = &.{ git_path, "tag", "-a", tag_name, "-m", tag_name }, + .cwd = cwd, + .stdout = .buffer, + .stderr = .buffer, + .stdin = .ignore, + .envp = null, + .windows = if (Environment.isWindows) .{ + .loop = bun.JSC.EventLoopHandle.init(bun.JSC.MiniEventLoop.initGlobal(null)), + }, + }) catch |err| { + Output.errGeneric("Git tag failed: {s}", .{@errorName(err)}); + return; + }; + + switch (tag_proc) { + .err => |err| { + Output.err(err, "Git tag failed unexpectedly", .{}); + return; + }, + .result => |result| { + if (!result.isOK()) { + Output.errGeneric("Git tag failed", .{}); + return; + } + }, + } + } +}; diff --git a/src/install/PackageManager/CommandLineArguments.zig b/src/install/PackageManager/CommandLineArguments.zig index a3e35bf920..8f6876bdfa 100644 --- a/src/install/PackageManager/CommandLineArguments.zig +++ b/src/install/PackageManager/CommandLineArguments.zig @@ -74,6 +74,11 @@ pub const pm_params: []const ParamType = &(shared_params ++ [_]ParamType{ clap.parseParam("--destination The directory the tarball will be saved in") catch unreachable, clap.parseParam("--filename The filename of the tarball") catch unreachable, clap.parseParam("--gzip-level Specify a custom compression level for gzip. Default is 9.") catch unreachable, + clap.parseParam("--git-tag-version Create a git commit and tag") catch unreachable, + clap.parseParam("--no-git-tag-version") catch unreachable, + clap.parseParam("--allow-same-version Allow bumping to the same version") catch unreachable, + clap.parseParam("-m, --message Use the given message for the commit") catch unreachable, + clap.parseParam("--preid Identifier to be used to prefix premajor, preminor, prepatch or prerelease version increments") catch unreachable, clap.parseParam(" ... ") catch unreachable, }); @@ -200,6 +205,12 @@ save_text_lockfile: ?bool = null, lockfile_only: bool = false, +// `bun pm version` options +git_tag_version: bool = true, +allow_same_version: bool = false, +preid: string = "", +message: ?string = null, + const PatchOpts = union(enum) { nothing: struct {}, patch: struct {}, @@ -880,6 +891,28 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com Global.crash(); } + if (comptime subcommand == .pm) { + // `bun pm version` command options + if (args.option("--git-tag-version")) |git_tag_version| { + if (strings.eqlComptime(git_tag_version, "true")) { + cli.git_tag_version = true; + } else if (strings.eqlComptime(git_tag_version, "false")) { + cli.git_tag_version = false; + } + } else if (args.flag("--no-git-tag-version")) { + cli.git_tag_version = false; + } else { + cli.git_tag_version = true; + } + cli.allow_same_version = args.flag("--allow-same-version"); + if (args.option("--preid")) |preid| { + cli.preid = preid; + } + if (args.option("--message")) |message| { + cli.message = message; + } + } + return cli; } diff --git a/src/install/PackageManager/PackageManagerOptions.zig b/src/install/PackageManager/PackageManagerOptions.zig index 6e2275848f..764d352a3a 100644 --- a/src/install/PackageManager/PackageManagerOptions.zig +++ b/src/install/PackageManager/PackageManagerOptions.zig @@ -57,6 +57,13 @@ save_text_lockfile: ?bool = null, lockfile_only: bool = false, +// `bun pm version` command options +git_tag_version: bool = true, +allow_same_version: bool = false, +preid: string = "", +message: ?string = null, +force: bool = false, + pub const PublishConfig = struct { access: ?Access = null, tag: string = "", @@ -584,6 +591,13 @@ pub fn load( if (cli.ca_file_name.len > 0) { this.ca_file_name = cli.ca_file_name; } + + // `bun pm version` command options + this.git_tag_version = cli.git_tag_version; + this.allow_same_version = cli.allow_same_version; + this.preid = cli.preid; + this.message = cli.message; + this.force = cli.force; } else { this.log_level = if (default_disable_progress_bar) LogLevel.default_no_progress else LogLevel.default; PackageManager.verbose_install = false; diff --git a/test/cli/install/bun-pm-version.test.ts b/test/cli/install/bun-pm-version.test.ts new file mode 100644 index 0000000000..1734e8a0c3 --- /dev/null +++ b/test/cli/install/bun-pm-version.test.ts @@ -0,0 +1,787 @@ +import { spawn, spawnSync } from "bun"; +import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { join } from "node:path"; + +describe("bun pm version", () => { + let i = 0; + + function setupTest() { + const testDir = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "test-package", + version: "1.0.0", + }, + null, + 2, + ), + }); + return testDir; + } + + function setupGitTest() { + const testDir = setupTest(); + + spawnSync({ + cmd: ["git", "init"], + cwd: testDir, + env: bunEnv, + }); + + spawnSync({ + cmd: ["git", "config", "user.name", "Test User"], + cwd: testDir, + env: bunEnv, + }); + + spawnSync({ + cmd: ["git", "config", "user.email", "test@example.com"], + cwd: testDir, + env: bunEnv, + }); + + spawnSync({ + cmd: ["git", "add", "package.json"], + cwd: testDir, + env: bunEnv, + }); + + spawnSync({ + cmd: ["git", "commit", "-m", "Initial commit"], + cwd: testDir, + env: bunEnv, + }); + + return testDir; + } + + function setupMonorepoTest() { + const testDir = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "monorepo-root", + version: "1.0.0", + workspaces: ["packages/*"], + }, + null, + 2, + ), + "packages/pkg-a/package.json": JSON.stringify( + { + name: "@test/pkg-a", + version: "2.0.0", + }, + null, + 2, + ), + "packages/pkg-b/package.json": JSON.stringify( + { + name: "@test/pkg-b", + version: "3.0.0", + dependencies: { + "@test/pkg-a": "workspace:*", + }, + }, + null, + 2, + ), + }); + + return testDir; + } + + async function runCommand(args: string[], cwd: string, expectSuccess = true) { + const result = spawn({ + cmd: args, + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [output, error] = await Promise.all([new Response(result.stdout).text(), new Response(result.stderr).text()]); + + const code = await result.exited; + + return { output, error, code }; + } + + it("should show help when no arguments provided", async () => { + const testDir = setupTest(); + + const { output, code } = await runCommand([bunExe(), "pm", "version"], testDir); + + expect(code).toBe(0); + expect(output).toContain("bun pm version"); + expect(output).toContain("Current package version: v1.0.0"); + expect(output).toContain("patch"); + expect(output).toContain("minor"); + expect(output).toContain("major"); + }); + + it("should increment versions correctly", async () => { + const testDir = setupTest(); + + const { output: patchOutput, code: patchCode } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + testDir, + ); + expect(patchCode).toBe(0); + expect(patchOutput.trim()).toBe("v1.0.1"); + + const { output: minorOutput, code: minorCode } = await runCommand( + [bunExe(), "pm", "version", "minor", "--no-git-tag-version"], + testDir, + ); + expect(minorCode).toBe(0); + expect(minorOutput.trim()).toBe("v1.1.0"); + + const { output: majorOutput, code: majorCode } = await runCommand( + [bunExe(), "pm", "version", "major", "--no-git-tag-version"], + testDir, + ); + expect(majorCode).toBe(0); + expect(majorOutput.trim()).toBe("v2.0.0"); + + const packageJson = await Bun.file(`${testDir}/package.json`).json(); + expect(packageJson.version).toBe("2.0.0"); + }); + + it("should set specific version", async () => { + const testDir = setupTest(); + + const { output, code } = await runCommand([bunExe(), "pm", "version", "3.2.1", "--no-git-tag-version"], testDir); + + expect(code).toBe(0); + expect(output.trim()).toBe("v3.2.1"); + + const packageJson = await Bun.file(`${testDir}/package.json`).json(); + expect(packageJson.version).toBe("3.2.1"); + }); + + it("handles various error conditions", async () => { + const testDir1 = setupTest(); + await Bun.write(`${testDir1}/package.json`, ""); + + const { error: error1, code: code1 } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + testDir1, + false, + ); + expect(code1).toBe(1); + expect(error1).toContain("No version field found in package.json"); + + const testDir2 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "invalid-version" }, null, 2), + }); + + const { error: error2, code: code2 } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + testDir2, + false, + ); + expect(code2).toBe(1); + expect(error2).toContain("is not a valid semver"); + + const testDir3 = setupTest(); + + const { error: error3, code: code3 } = await runCommand( + [bunExe(), "pm", "version", "invalid-arg", "--no-git-tag-version"], + testDir3, + false, + ); + expect(code3).toBe(1); + expect(error3).toContain("Invalid version argument"); + + const testDir4 = setupTest(); + + const { error: error4, code: code4 } = await runCommand( + [bunExe(), "pm", "version", "1.0.0", "--no-git-tag-version"], + testDir4, + false, + ); + expect(code4).toBe(1); + expect(error4).toContain("Version not changed"); + + const { output: output5, code: code5 } = await runCommand( + [bunExe(), "pm", "version", "1.0.0", "--no-git-tag-version", "--allow-same-version"], + testDir4, + ); + expect(code5).toBe(0); + expect(output5.trim()).toBe("v1.0.0"); + }); + + it("handles git operations correctly", async () => { + const testDir1 = setupGitTest(); + + const { + output: output1, + code: code1, + error: stderr1, + } = await runCommand([bunExe(), "pm", "version", "patch"], testDir1); + + expect(stderr1.trim()).toBe(""); + expect(output1.trim()).toBe("v1.0.1"); + expect(code1).toBe(0); + + const { output: tagOutput } = await runCommand(["git", "tag", "-l"], testDir1); + expect(tagOutput).toContain("v1.0.1"); + + const { output: logOutput } = await runCommand(["git", "log", "--oneline"], testDir1); + expect(logOutput).toContain("v1.0.1"); + + const testDir2 = setupGitTest(); + + const { + output: output2, + error: error2, + code: code2, + } = await runCommand([bunExe(), "pm", "version", "patch", "--message", "Custom release message"], testDir2); + expect(error2).toBe(""); + + const { output: gitLogOutput } = await runCommand(["git", "log", "--oneline"], testDir2); + expect(gitLogOutput).toContain("Custom release message"); + + expect(code2).toBe(0); + expect(output2.trim()).toBe("v1.0.1"); + + const testDir3 = setupGitTest(); + + await Bun.write(join(testDir3, "untracked.txt"), "untracked content"); + + const { error: error3, code: code3 } = await runCommand([bunExe(), "pm", "version", "patch"], testDir3, false); + + expect(code3).toBe(1); + expect(error3).toContain("Git working directory not clean"); + + const testDir4 = setupTest(); + + const { output: output4, code: code4 } = await runCommand([bunExe(), "pm", "version", "patch"], testDir4); + + expect(code4).toBe(0); + expect(output4.trim()).toBe("v1.0.1"); + + const packageJson = await Bun.file(`${testDir4}/package.json`).json(); + expect(packageJson.version).toBe("1.0.1"); + + const testDir5 = setupGitTest(); + const { output: output5, code: code5 } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + testDir5, + ); + + expect(code5).toBe(0); + expect(output5.trim()).toBe("v1.0.1"); + + const packageJson5 = await Bun.file(`${testDir5}/package.json`).json(); + expect(packageJson5.version).toBe("1.0.1"); + + const { output: tagOutput5 } = await runCommand(["git", "tag", "-l"], testDir5); + expect(tagOutput5.trim()).toBe(""); + + const { output: logOutput5 } = await runCommand(["git", "log", "--oneline"], testDir5); + expect(logOutput5).toContain("Initial commit"); + expect(logOutput5).not.toContain("v1.0.1"); + + const testDir6 = setupGitTest(); + const { output: output6, code: code6 } = await runCommand( + [bunExe(), "pm", "version", "patch", "--git-tag-version=false"], + testDir6, + ); + + expect(code6).toBe(0); + expect(output6.trim()).toBe("v1.0.1"); + + const packageJson6 = await Bun.file(`${testDir6}/package.json`).json(); + expect(packageJson6.version).toBe("1.0.1"); + + const { output: tagOutput6 } = await runCommand(["git", "tag", "-l"], testDir6); + expect(tagOutput6.trim()).toBe(""); + + const { output: logOutput6 } = await runCommand(["git", "log", "--oneline"], testDir6); + expect(logOutput6).toContain("Initial commit"); + expect(logOutput6).not.toContain("v1.0.1"); + + const testDir7 = setupGitTest(); + const { output: output7, code: code7 } = await runCommand( + [bunExe(), "pm", "version", "patch", "--git-tag-version=true"], + testDir7, + ); + + expect(code7).toBe(0); + expect(output7.trim()).toBe("v1.0.1"); + + const packageJson7 = await Bun.file(`${testDir7}/package.json`).json(); + expect(packageJson7.version).toBe("1.0.1"); + + const { output: tagOutput7 } = await runCommand(["git", "tag", "-l"], testDir7); + expect(tagOutput7).toContain("v1.0.1"); + + const { output: logOutput7 } = await runCommand(["git", "log", "--oneline"], testDir7); + expect(logOutput7).toContain("v1.0.1"); + }); + + it("preserves JSON formatting correctly", async () => { + const originalJson1 = `{ + "name": "test", + "version": "1.0.0", + "scripts": { + "test": "echo test" + }, + "dependencies": { + "lodash": "^4.17.21" + } +}`; + + const testDir1 = tempDirWithFiles(`version-${i++}`, { + "package.json": originalJson1, + }); + + const { output: output1, code: code1 } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + testDir1, + ); + + expect(code1).toBe(0); + expect(output1.trim()).toBe("v1.0.1"); + + const updatedJson1 = await Bun.file(`${testDir1}/package.json`).text(); + + expect(updatedJson1).toContain('"version": "1.0.1"'); + expect(updatedJson1).toContain('"name": "test"'); + expect(updatedJson1).toContain(' "test": "echo test"'); + + const originalJson2 = `{ + "name": "test-package", + "version" : "2.5.0" , + "description": "A test package with weird formatting", + "main": "index.js", + "scripts":{ + "test":"npm test", + "build" : "webpack" + }, +"keywords":["test","package"], + "author": "Test Author" +}`; + + const testDir2 = tempDirWithFiles(`version-${i++}`, { + "package.json": originalJson2, + }); + + const { output: output2, code: code2 } = await runCommand( + [bunExe(), "pm", "version", "minor", "--no-git-tag-version"], + testDir2, + ); + + expect(code2).toBe(0); + expect(output2.trim()).toBe("v2.6.0"); + + const updatedJson2 = await Bun.file(`${testDir2}/package.json`).text(); + + expect(updatedJson2).toContain('"version" : "2.6.0" ,'); + expect(updatedJson2).toContain('"name": "test-package"'); + expect(updatedJson2).toContain('"main": "index.js"'); + expect(updatedJson2).toContain('"scripts":{'); + expect(updatedJson2).toContain('"build" : "webpack"'); + + const originalLines1 = originalJson1.split("\n"); + const updatedLines1 = updatedJson1.split("\n"); + expect(updatedLines1.length).toBe(originalLines1.length); + + const originalLines2 = originalJson2.split("\n"); + const updatedLines2 = updatedJson2.split("\n"); + expect(updatedLines2.length).toBe(originalLines2.length); + }); + + it("shows help with version previews", async () => { + const testDir1 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "2.5.3" }, null, 2), + }); + + const { output: output1, code: code1 } = await runCommand([bunExe(), "pm", "version"], testDir1); + + expect(code1).toBe(0); + expect(output1).toContain("Current package version: v2.5.3"); + expect(output1).toContain("patch 2.5.3 → 2.5.4"); + expect(output1).toContain("minor 2.5.3 → 2.6.0"); + expect(output1).toContain("major 2.5.3 → 3.0.0"); + + const testDir2 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "1.0.0-alpha.0" }, null, 2), + }); + + const { output: output2, code: code2 } = await runCommand([bunExe(), "pm", "version"], testDir2); + + expect(code2).toBe(0); + expect(output2).toContain("prepatch"); + expect(output2).toContain("preminor"); + expect(output2).toContain("premajor"); + expect(output2).toContain("1.0.1-alpha.0"); + expect(output2).toContain("1.1.0-alpha.0"); + expect(output2).toContain("2.0.0-alpha.0"); + + const testDir3 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "1.0.0" }, null, 2), + }); + + const { output: output3, code: code3 } = await runCommand([bunExe(), "pm", "version", "--preid", "beta"], testDir3); + + expect(code3).toBe(0); + expect(output3).toContain("prepatch"); + expect(output3).toContain("preminor"); + expect(output3).toContain("premajor"); + expect(output3).toContain("1.0.1-beta.0"); + expect(output3).toContain("1.1.0-beta.0"); + expect(output3).toContain("2.0.0-beta.0"); + + const testDir4 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test" }, null, 2), + }); + + const { output: output4 } = await runCommand([bunExe(), "pm", "version"], testDir4); + + expect(output4).not.toContain("Current package version:"); + expect(output4).toContain("patch 1.0.0 → 1.0.1"); + }); + + it("handles custom preid and prerelease scenarios", async () => { + const testDir1 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "1.0.0" }, null, 2), + }); + + const { output: output1, code: code1 } = await runCommand( + [bunExe(), "pm", "version", "prerelease", "--preid", "beta", "--no-git-tag-version"], + testDir1, + ); + + expect(code1).toBe(0); + expect(output1.trim()).toBe("v1.0.1-beta.0"); + + const testDir3 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "1.0.0" }, null, 2), + }); + + const { output: output3, code: code3 } = await runCommand( + [bunExe(), "pm", "version", "prerelease", "--no-git-tag-version"], + testDir3, + ); + + expect(code3).toBe(0); + expect(output3.trim()).toBe("v1.0.1-0"); + + const testDir5 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "1.0.0-alpha" }, null, 2), + }); + + const { output: output5, code: code5 } = await runCommand( + [bunExe(), "pm", "version", "prerelease", "--no-git-tag-version"], + testDir5, + ); + + expect(code5).toBe(0); + expect(output5.trim()).toBe("v1.0.0-alpha.1"); + + const testDir6 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "1.0.0-3" }, null, 2), + }); + + const { output: output6, code: code6 } = await runCommand( + [bunExe(), "pm", "version", "prerelease", "--no-git-tag-version"], + testDir6, + ); + + expect(code6).toBe(0); + expect(output6.trim()).toBe("v1.0.0-4"); + }); + + it("runs lifecycle scripts in correct order and handles failures", async () => { + const testDir1 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "test", + version: "1.0.0", + scripts: { + preversion: "echo 'step1' >> lifecycle.log", + version: "echo 'step2' >> lifecycle.log", + postversion: "echo 'step3' >> lifecycle.log", + }, + }, + null, + 2, + ), + }); + + await Bun.spawn([bunExe(), "pm", "version", "patch", "--no-git-tag-version"], { + cwd: testDir1, + env: bunEnv, + stderr: "ignore", + stdout: "ignore", + }).exited; + + expect(await Bun.file(join(testDir1, "lifecycle.log")).exists()).toBe(true); + const logContent = await Bun.file(join(testDir1, "lifecycle.log")).text(); + expect(logContent.trim()).toBe("step1\nstep2\nstep3"); + + const testDir2 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "test", + version: "1.0.0", + scripts: { + preversion: "echo $npm_lifecycle_event > event.log && echo $npm_lifecycle_script > script.log", + }, + }, + null, + 2, + ), + }); + + await Bun.spawn([bunExe(), "pm", "version", "patch", "--no-git-tag-version"], { + cwd: testDir2, + env: bunEnv, + stderr: "ignore", + stdout: "ignore", + }).exited; + + expect(Bun.file(join(testDir2, "event.log")).exists()).resolves.toBe(true); + expect(Bun.file(join(testDir2, "script.log")).exists()).resolves.toBe(true); + + const eventContent = await Bun.file(join(testDir2, "event.log")).text(); + const scriptContent = await Bun.file(join(testDir2, "script.log")).text(); + + expect(eventContent.trim()).toBe("preversion"); + expect(scriptContent.trim()).toContain("echo $npm_lifecycle_event"); + + const testDir3 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "test", + version: "1.0.0", + scripts: { + preversion: "exit 1", + }, + }, + null, + 2, + ), + }); + + const proc = Bun.spawn([bunExe(), "pm", "version", "minor", "--no-git-tag-version"], { + cwd: testDir3, + env: bunEnv, + stderr: "ignore", + stdout: "ignore", + }); + + await proc.exited; + expect(proc.exitCode).toBe(1); + + const packageJson = await Bun.file(join(testDir3, "package.json")).json(); + expect(packageJson.version).toBe("1.0.0"); + + const testDir4 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "test", + version: "1.0.0", + scripts: { + preversion: "mkdir -p build && echo 'built' > build/output.txt", + version: "cp build/output.txt version-output.txt", + postversion: "rm -rf build", + }, + }, + null, + 2, + ), + }); + + await Bun.spawn([bunExe(), "pm", "version", "patch", "--no-git-tag-version"], { + cwd: testDir4, + env: bunEnv, + stderr: "ignore", + stdout: "ignore", + }).exited; + + expect(Bun.file(join(testDir4, "version-output.txt")).exists()).resolves.toBe(true); + expect(Bun.file(join(testDir4, "build")).exists()).resolves.toBe(false); + + const content = await Bun.file(join(testDir4, "version-output.txt")).text(); + expect(content.trim()).toBe("built"); + + const testDir5 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "test", + version: "1.0.0", + scripts: { + preversion: "echo 'should not run' >> ignored.log", + version: "echo 'should not run' >> ignored.log", + postversion: "echo 'should not run' >> ignored.log", + }, + }, + null, + 2, + ), + }); + + const { output: output5, code: code5 } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version", "--ignore-scripts"], + testDir5, + ); + + expect(code5).toBe(0); + expect(output5.trim()).toBe("v1.0.1"); + + const packageJson5 = await Bun.file(join(testDir5, "package.json")).json(); + expect(packageJson5.version).toBe("1.0.1"); + + expect(await Bun.file(join(testDir5, "ignored.log")).exists()).toBe(false); + }); + + it("should version workspace packages individually", async () => { + const testDir = setupMonorepoTest(); + + const { output: outputA, code: codeA } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + join(testDir, "packages", "pkg-a"), + ); + + expect(codeA).toBe(0); + expect(outputA.trim()).toBe("v2.0.1"); + + const rootPackageJson = await Bun.file(`${testDir}/package.json`).json(); + expect(rootPackageJson.version).toBe("1.0.0"); + + const pkgAJson = await Bun.file(`${testDir}/packages/pkg-a/package.json`).json(); + const pkgBJson = await Bun.file(`${testDir}/packages/pkg-b/package.json`).json(); + + expect(pkgAJson.version).toBe("2.0.1"); + expect(pkgBJson.version).toBe("3.0.0"); + }); + + it("should work from subdirectories", async () => { + const testDir = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "1.0.0" }, null, 2), + "src/index.js": "console.log('hello');", + }); + + const { output, code } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + join(testDir, "src"), + ); + + expect(code).toBe(0); + expect(output.trim()).toBe("v1.0.1"); + + const packageJson = await Bun.file(`${testDir}/package.json`).json(); + expect(packageJson.version).toBe("1.0.1"); + + const monorepoDir = setupMonorepoTest(); + + await Bun.write(join(monorepoDir, "packages", "pkg-a", "lib", "index.js"), ""); + + const { output: output2, code: code2 } = await runCommand( + [bunExe(), "pm", "version", "minor", "--no-git-tag-version"], + join(monorepoDir, "packages", "pkg-a", "lib"), + ); + + expect(code2).toBe(0); + expect(output2.trim()).toBe("v2.1.0"); + + const rootJson = await Bun.file(`${monorepoDir}/package.json`).json(); + const pkgAJson = await Bun.file(`${monorepoDir}/packages/pkg-a/package.json`).json(); + const pkgBJson = await Bun.file(`${monorepoDir}/packages/pkg-b/package.json`).json(); + + expect(rootJson.version).toBe("1.0.0"); + expect(pkgAJson.version).toBe("2.1.0"); + expect(pkgBJson.version).toBe("3.0.0"); + }); + + it("should preserve prerelease identifiers correctly", async () => { + const scenarios = [ + { + version: "1.0.3-alpha.1", + preid: "beta", + expected: { + patch: "1.0.3-alpha.1 → 1.0.4", + minor: "1.0.3-alpha.1 → 1.1.0", + major: "1.0.3-alpha.1 → 2.0.0", + prerelease: "1.0.3-alpha.1 → 1.0.3-beta.2", + prepatch: "1.0.3-alpha.1 → 1.0.4-beta.0", + preminor: "1.0.3-alpha.1 → 1.1.0-beta.0", + premajor: "1.0.3-alpha.1 → 2.0.0-beta.0", + }, + }, + { + version: "1.0.3-1", + preid: "abcd", + expected: { + patch: "1.0.3-1 → 1.0.4", + minor: "1.0.3-1 → 1.1.0", + major: "1.0.3-1 → 2.0.0", + prerelease: "1.0.3-1 → 1.0.3-abcd.2", + prepatch: "1.0.3-1 → 1.0.4-abcd.0", + preminor: "1.0.3-1 → 1.1.0-abcd.0", + premajor: "1.0.3-1 → 2.0.0-abcd.0", + }, + }, + { + version: "2.5.0-rc.3", + preid: "next", + expected: { + patch: "2.5.0-rc.3 → 2.5.1", + minor: "2.5.0-rc.3 → 2.6.0", + major: "2.5.0-rc.3 → 3.0.0", + prerelease: "2.5.0-rc.3 → 2.5.0-next.4", + prepatch: "2.5.0-rc.3 → 2.5.1-next.0", + preminor: "2.5.0-rc.3 → 2.6.0-next.0", + premajor: "2.5.0-rc.3 → 3.0.0-next.0", + }, + }, + { + version: "1.0.0-a", + preid: "b", + expected: { + patch: "1.0.0-a → 1.0.1", + minor: "1.0.0-a → 1.1.0", + major: "1.0.0-a → 2.0.0", + prerelease: "1.0.0-a → 1.0.0-b.1", + prepatch: "1.0.0-a → 1.0.1-b.0", + preminor: "1.0.0-a → 1.1.0-b.0", + premajor: "1.0.0-a → 2.0.0-b.0", + }, + }, + ]; + + for (const scenario of scenarios) { + const testDir = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: scenario.version }, null, 2), + }); + + const { output, code } = await runCommand( + [bunExe(), "pm", "version", "--no-git-tag-version", `--preid=${scenario.preid}`], + testDir, + ); + + expect(code).toBe(0); + expect(output).toContain(`Current package version: v${scenario.version}`); + + for (const [incrementType, expectedTransformation] of Object.entries(scenario.expected)) { + expect(output).toContain(`${incrementType.padEnd(10)} ${expectedTransformation}`); + } + } + + const testDir2 = tempDirWithFiles(`version-${i++}`, { + "package.json": JSON.stringify({ name: "test", version: "1.0.3-alpha.1" }, null, 2), + }); + + const { output: output2, code: code2 } = await runCommand( + [bunExe(), "pm", "version", "--no-git-tag-version"], + testDir2, + ); + + expect(code2).toBe(0); + expect(output2).toContain("prerelease 1.0.3-alpha.1 → 1.0.3-alpha.2"); + }); +}); diff --git a/test/internal/ban-words.test.ts b/test/internal/ban-words.test.ts index 35575f992d..9f7d7d92cd 100644 --- a/test/internal/ban-words.test.ts +++ b/test/internal/ban-words.test.ts @@ -34,10 +34,10 @@ const words: Record [String.raw`: [a-zA-Z0-9_\.\*\?\[\]\(\)]+ = undefined,`]: { reason: "Do not default a struct field to undefined", limit: 243, regex: true }, "usingnamespace": { reason: "Zig 0.15 will remove `usingnamespace`" }, - "catch unreachable": { reason: "For out-of-memory, prefer 'catch bun.outOfMemory()'", limit: 1862 }, + "catch unreachable": { reason: "For out-of-memory, prefer 'catch bun.outOfMemory()'", limit: 1867 }, "std.fs.Dir": { reason: "Prefer bun.sys + bun.FD instead of std.fs", limit: 179 }, - "std.fs.cwd": { reason: "Prefer bun.FD.cwd()", limit: 102 }, + "std.fs.cwd": { reason: "Prefer bun.FD.cwd()", limit: 103 }, "std.fs.File": { reason: "Prefer bun.sys + bun.FD instead of std.fs", limit: 62 }, ".stdFile()": { reason: "Prefer bun.sys + bun.FD instead of std.fs.File. Zig hides 'errno' when Bun wants to match libuv", limit: 18 }, ".stdDir()": { reason: "Prefer bun.sys + bun.FD instead of std.fs.File. Zig hides 'errno' when Bun wants to match libuv", limit: 49 }, diff --git a/test/no-validate-exceptions.txt b/test/no-validate-exceptions.txt index d234c32610..f12b2b3940 100644 --- a/test/no-validate-exceptions.txt +++ b/test/no-validate-exceptions.txt @@ -60,6 +60,7 @@ test/cli/install/bun-lockb.test.ts test/cli/install/bun-pack.test.ts test/cli/install/bun-patch.test.ts test/cli/install/bun-pm.test.ts +test/cli/install/bun-pm-version.test.ts test/cli/install/bun-publish.test.ts test/cli/install/bun-remove.test.ts test/cli/install/bun-update.test.ts