From 0399ae0ee96e885daeee7bb120dc36aa969d645a Mon Sep 17 00:00:00 2001 From: Michael H Date: Tue, 8 Jul 2025 04:21:36 +1000 Subject: [PATCH] followup for `bun pm version` (#20799) --- docs/cli/pm.md | 7 +- src/cli/pm_version_command.zig | 289 +++-- test/cli/install/bun-pm-version.test.ts | 1392 ++++++++++++----------- 3 files changed, 888 insertions(+), 800 deletions(-) diff --git a/docs/cli/pm.md b/docs/cli/pm.md index 0739de7ae9..577ab383cd 100644 --- a/docs/cli/pm.md +++ b/docs/cli/pm.md @@ -175,13 +175,14 @@ Increment: 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 + --message=, -m Custom commit message, use %s for version substitution + --preid= Prerelease identifier (i.e beta → 1.0.1-beta.0) + --force, -f Bypass dirty git history check Examples: $ bun pm version patch $ bun pm version 1.2.3 --no-git-tag-version - $ bun pm version prerelease --preid beta + $ bun pm version prerelease --preid beta --message "Release beta: %s" ``` To bump the version in `package.json`: diff --git a/src/cli/pm_version_command.zig b/src/cli/pm_version_command.zig index 2dc75823b6..5bc8511b9b 100644 --- a/src/cli/pm_version_command.zig +++ b/src/cli/pm_version_command.zig @@ -11,6 +11,7 @@ const logger = bun.logger; const JSON = bun.JSON; const RunCommand = bun.RunCommand; const Environment = bun.Environment; +const JSPrinter = bun.js_printer; pub const PmVersionCommand = struct { const VersionType = enum { @@ -59,30 +60,45 @@ pub const PmVersionCommand = struct { 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| { + const json_result = JSON.parsePackageJSONUTF8WithOpts( + &package_json_source, + ctx.log, + ctx.allocator, + .{ + .is_json = true, + .allow_comments = true, + .allow_trailing_commas = true, + .guess_indentation = true, + }, + ) catch |err| { Output.errGeneric("Failed to parse package.json: {s}", .{@errorName(err)}); Global.exit(1); }; - const scripts = json.asProperty("scripts"); + var json = json_result.root; + + if (json.data != .e_object) { + Output.errGeneric("Failed to parse package.json: root must be an object", .{}); + Global.exit(1); + } + + const scripts = if (pm.options.do.run_scripts) json.asProperty("scripts") else null; 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, - ); - } + 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, + ); } } } @@ -96,49 +112,63 @@ pub const PmVersionCommand = struct { else => {}, } } - Output.errGeneric("No version field found in package.json", .{}); - Global.exit(1); + break :brk_version null; }; - const new_version_str = try calculateNewVersion(ctx.allocator, current_version, version_type, new_version, pm.options.preid, package_json_dir); + const new_version_str = try calculateNewVersion(ctx.allocator, current_version orelse "0.0.0", 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); + if (current_version) |version| { + if (!pm.options.allow_same_version and strings.eql(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); + try json.data.e_object.putString(ctx.allocator, "version", new_version_str); - 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)}); + var buffer_writer = JSPrinter.BufferWriter.init(ctx.allocator); + buffer_writer.append_newline = package_json_contents.len > 0 and package_json_contents[package_json_contents.len - 1] == '\n'; + var package_json_writer = JSPrinter.BufferPrinter.init(buffer_writer); + + _ = JSPrinter.printJSON( + @TypeOf(&package_json_writer), + &package_json_writer, + json, + &package_json_source, + .{ + .indent = json_result.indentation, + .mangled_props = null, + }, + ) catch |err| { + Output.errGeneric("Failed to save package.json: {s}", .{@errorName(err)}); Global.exit(1); }; - defer file.close(); - try file.seekTo(0); - try file.setEndPos(0); - try file.writeAll(updated_contents); + std.fs.cwd().writeFile(.{ + .sub_path = package_json_path, + .data = package_json_writer.ctx.writtenWithoutTrailingZero(), + }) catch |err| { + Output.errGeneric("Failed to write package.json: {s}", .{@errorName(err)}); + Global.exit(1); + }; } - 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 (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, + ); } } } @@ -147,22 +177,20 @@ pub const PmVersionCommand = struct { 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, - ); - } + 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, + ); } } } @@ -201,7 +229,7 @@ pub const PmVersionCommand = struct { return; } - if (!try isGitClean(cwd) and !pm.options.force) { + if (!pm.options.force and !try isGitClean(cwd)) { Output.errGeneric("Git working directory not clean.", .{}); Global.exit(1); } @@ -256,6 +284,15 @@ pub const PmVersionCommand = struct { Output.prettyln("Current package version: v{s}", .{version}); } + const patch_version = try calculateNewVersion(ctx.allocator, current_version, .patch, null, pm.options.preid, cwd); + const minor_version = try calculateNewVersion(ctx.allocator, current_version, .minor, null, pm.options.preid, cwd); + const major_version = try calculateNewVersion(ctx.allocator, current_version, .major, null, pm.options.preid, cwd); + const prerelease_version = try calculateNewVersion(ctx.allocator, current_version, .prerelease, null, pm.options.preid, cwd); + defer ctx.allocator.free(patch_version); + defer ctx.allocator.free(minor_version); + defer ctx.allocator.free(major_version); + defer ctx.allocator.free(prerelease_version); + const increment_help_text = \\ \\Increment: @@ -266,13 +303,20 @@ pub const PmVersionCommand = struct { \\ ; 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), + current_version, patch_version, + current_version, minor_version, + current_version, major_version, + current_version, prerelease_version, }); if (strings.indexOfChar(current_version, '-') != null or pm.options.preid.len > 0) { + const prepatch_version = try calculateNewVersion(ctx.allocator, current_version, .prepatch, null, pm.options.preid, cwd); + const preminor_version = try calculateNewVersion(ctx.allocator, current_version, .preminor, null, pm.options.preid, cwd); + const premajor_version = try calculateNewVersion(ctx.allocator, current_version, .premajor, null, pm.options.preid, cwd); + defer ctx.allocator.free(prepatch_version); + defer ctx.allocator.free(preminor_version); + defer ctx.allocator.free(premajor_version); + const prerelease_help_text = \\ prepatch {s} → {s} \\ preminor {s} → {s} @@ -280,12 +324,15 @@ pub const PmVersionCommand = struct { \\ ; 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), + current_version, prepatch_version, + current_version, preminor_version, + current_version, premajor_version, }); } + const beta_prerelease_version = try calculateNewVersion(ctx.allocator, current_version, .prerelease, null, "beta", cwd); + defer ctx.allocator.free(beta_prerelease_version); + const set_specific_version_help_text = \\ from-git Use version from latest git tag \\ 1.2.3 Set specific version @@ -293,78 +340,22 @@ pub const PmVersionCommand = struct { \\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 + \\ --message=\, -m Custom commit message, use %s for version substitution + \\ --preid=\ Prerelease identifier (i.e beta → {s}) + \\ --force, -f Bypass dirty git history check \\ \\Examples: \\ $ bun pm version patch \\ $ bun pm version 1.2.3 --no-git-tag-version - \\ $ bun pm version prerelease --preid beta + \\ $ bun pm version prerelease --preid beta --message "Release beta: %s" \\ \\More info: https://bun.sh/docs/cli/pm#version \\ ; - Output.pretty(set_specific_version_help_text, .{}); + Output.pretty(set_specific_version_help_text, .{beta_prerelease_version}); 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.?); @@ -487,12 +478,15 @@ pub const PmVersionCommand = struct { .windows = if (Environment.isWindows) .{ .loop = bun.JSC.EventLoopHandle.init(bun.JSC.MiniEventLoop.initGlobal(null)), }, - }) catch return false; + }) catch |err| { + Output.errGeneric("Failed to spawn git process: {s}", .{@errorName(err)}); + Global.exit(1); + }; switch (proc) { .err => |err| { Output.err(err, "Failed to spawn git process", .{}); - return false; + Global.exit(1); }, .result => |result| { return result.isOK() and result.stdout.items.len == 0; @@ -566,24 +560,27 @@ pub const PmVersionCommand = struct { }, }) catch |err| { Output.errGeneric("Git add failed: {s}", .{@errorName(err)}); - return; + Global.exit(1); }; switch (stage_proc) { .err => |err| { Output.err(err, "Git add failed unexpectedly", .{}); - return; + Global.exit(1); }, .result => |result| { if (!result.isOK()) { Output.errGeneric("Git add failed with exit code {d}", .{result.status.exited.code}); - return; + Global.exit(1); } }, } - 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_message = if (custom_message) |msg| + try std.mem.replaceOwned(u8, allocator, msg, "%s", version) + else + try std.fmt.allocPrint(allocator, "v{s}", .{version}); + defer allocator.free(commit_message); const commit_proc = bun.spawnSync(&.{ .argv = &.{ git_path, "commit", "-m", commit_message }, @@ -597,18 +594,18 @@ pub const PmVersionCommand = struct { }, }) catch |err| { Output.errGeneric("Git commit failed: {s}", .{@errorName(err)}); - return; + Global.exit(1); }; switch (commit_proc) { .err => |err| { Output.err(err, "Git commit failed unexpectedly", .{}); - return; + Global.exit(1); }, .result => |result| { if (!result.isOK()) { Output.errGeneric("Git commit failed", .{}); - return; + Global.exit(1); } }, } @@ -628,18 +625,18 @@ pub const PmVersionCommand = struct { }, }) catch |err| { Output.errGeneric("Git tag failed: {s}", .{@errorName(err)}); - return; + Global.exit(1); }; switch (tag_proc) { .err => |err| { Output.err(err, "Git tag failed unexpectedly", .{}); - return; + Global.exit(1); }, .result => |result| { if (!result.isOK()) { Output.errGeneric("Git tag failed", .{}); - return; + Global.exit(1); } }, } diff --git a/test/cli/install/bun-pm-version.test.ts b/test/cli/install/bun-pm-version.test.ts index 1734e8a0c3..6f37ad2a9e 100644 --- a/test/cli/install/bun-pm-version.test.ts +++ b/test/cli/install/bun-pm-version.test.ts @@ -107,681 +107,771 @@ describe("bun pm version", () => { return { output, error, code }; } - it("should show help when no arguments provided", async () => { - const testDir = setupTest(); + describe("help and version previews", () => { + it("should show help when no arguments provided", async () => { + const testDir = setupTest(); - const { output, code } = await runCommand([bunExe(), "pm", "version"], testDir); + 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"); + 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("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("should increment versions correctly", async () => { - const testDir = setupTest(); + describe("basic version incrementing", () => { + 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: 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: 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 { 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 packageJson = await Bun.file(`${testDir}/package.json`).json(); + expect(packageJson.version).toBe("2.0.0"); }); - 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"); + it("should set specific version", async () => { + const testDir = setupTest(); - const testDir3 = setupTest(); + const { output, code } = await runCommand([bunExe(), "pm", "version", "3.2.1", "--no-git-tag-version"], testDir); - 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"); + expect(code).toBe(0); + expect(output.trim()).toBe("v3.2.1"); - 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 packageJson = await Bun.file(`${testDir}/package.json`).json(); + expect(packageJson.version).toBe("3.2.1"); }); - 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) { + it("handles empty package.json", async () => { const testDir = tempDirWithFiles(`version-${i++}`, { - "package.json": JSON.stringify({ name: "test", version: scenario.version }, null, 2), + "package.json": "{}", + }); + + const { output, code } = await runCommand([bunExe(), "pm", "version", "patch", "--no-git-tag-version"], testDir); + + expect(code).toBe(0); + expect(output.trim()).toBe("v0.0.1"); + + const packageJson = await Bun.file(`${testDir}/package.json`).json(); + expect(packageJson.version).toBe("0.0.1"); + }); + }); + + describe("error handling", () => { + it("handles various error conditions", async () => { + 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(error2).toContain("is not a valid semver"); + expect(code2).toBe(1); + + const testDir3 = setupTest(); + + const { error: error3, code: code3 } = await runCommand( + [bunExe(), "pm", "version", "invalid-arg", "--no-git-tag-version"], + testDir3, + false, + ); + expect(error3).toContain("Invalid version argument"); + expect(code3).toBe(1); + + const testDir4 = setupTest(); + + const { error: error4, code: code4 } = await runCommand( + [bunExe(), "pm", "version", "1.0.0", "--no-git-tag-version"], + testDir4, + false, + ); + expect(error4).toContain("Version not changed"); + expect(code4).toBe(1); + + const { output: output5, code: code5 } = await runCommand( + [bunExe(), "pm", "version", "1.0.0", "--no-git-tag-version", "--allow-same-version"], + testDir4, + ); + expect(output5.trim()).toBe("v1.0.0"); + expect(code5).toBe(0); + }); + + it("handles missing package.json like npm", async () => { + const testDir = tempDirWithFiles(`version-${i++}`, { + "README.md": "# Test project", + }); + + const { error, code } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + testDir, + false, + ); + expect(error).toContain("package.json"); + expect(code).toBe(1); + // its an ealier check that "bun pm *" commands do so not "bun pm version" specific + // expect(error.includes("ENOENT") || error.includes("no such file")).toBe(true); + }); + + it("handles empty string package.json like npm", async () => { + const testDir = tempDirWithFiles(`version-${i++}`, { + "package.json": '""', + }); + + const { error, code } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + testDir, + false, + ); + expect(error).toContain("Failed to parse package.json"); + expect(code).toBe(1); + }); + + it("handles malformed JSON like npm", async () => { + const testDir = tempDirWithFiles(`version-${i++}`, { + "package.json": '{ "name": "test", invalid json }', + }); + + const { error, code } = await runCommand( + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + testDir, + false, + ); + expect(error).toContain("Failed to parse package.json"); + expect(code).toBe(1); + }); + }); + + describe("git integration", () => { + it("creates git commits and tags by default", 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"); + }); + + it("supports custom commit messages", async () => { + 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"); + }); + + it("fails when git working directory is not clean", async () => { + 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(error3).toContain("Git working directory not clean"); + expect(code3).toBe(1); + }); + + it("allows dirty working directory with --force flag", async () => { + const testDir = setupGitTest(); + + await Bun.write(join(testDir, "untracked.txt"), "untracked content"); + + const { output, code, error } = await runCommand([bunExe(), "pm", "version", "patch", "--force"], testDir); + + expect(code).toBe(0); + expect(error.trim()).toBe(""); + expect(output.trim()).toBe("v1.0.1"); + + const { output: tagOutput } = await runCommand(["git", "tag", "-l"], testDir); + expect(tagOutput).toContain("v1.0.1"); + + const { output: logOutput } = await runCommand(["git", "log", "--oneline"], testDir); + expect(logOutput).toContain("v1.0.1"); + }); + + it("works without git when no repo is present", async () => { + 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"); + }); + + it("respects --no-git-tag-version flag", async () => { + 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"); + }); + + it("respects --git-tag-version=false flag", async () => { + 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"); + }); + + it("respects --git-tag-version=true flag", async () => { + 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("supports %s substitution in commit messages", async () => { + const testDir8 = setupGitTest(); + const { output: output8, code: code8 } = await runCommand( + [bunExe(), "pm", "version", "patch", "--message", "Bump version to %s"], + testDir8, + ); + + expect(code8).toBe(0); + expect(output8.trim()).toBe("v1.0.1"); + + const { output: logOutput8 } = await runCommand(["git", "log", "--oneline", "-1"], testDir8); + expect(logOutput8).toContain("Bump version to 1.0.1"); + + const testDir9 = setupGitTest(); + const { output: output9, code: code9 } = await runCommand( + [bunExe(), "pm", "version", "2.5.0", "-m", "Release %s with fixes"], + testDir9, + ); + + expect(code9).toBe(0); + expect(output9.trim()).toBe("v2.5.0"); + + const { output: logOutput9 } = await runCommand(["git", "log", "--oneline", "-1"], testDir9); + expect(logOutput9).toContain("Release 2.5.0 with fixes"); + }); + }); + + describe("JSON formatting preservation", () => { + 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"'); + + expect(JSON.parse(updatedJson1)).toMatchObject({ + name: "test", + version: "1.0.1", + scripts: { + test: "echo test", + }, + }); + }); + }); + + describe("prerelease handling", () => { + 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("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"); + }); + }); + + describe("lifecycle scripts", () => { + 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: "pipe", + stdout: "ignore", + }); + + await proc.exited; + expect(proc.exitCode).toBe(1); + expect(await proc.stderr.text()).toContain('script "preversion" exited with code 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); + }); + }); + + describe("workspace and directory handling", () => { + 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", "--no-git-tag-version", `--preid=${scenario.preid}`], - testDir, + [bunExe(), "pm", "version", "patch", "--no-git-tag-version"], + join(testDir, "src"), ); expect(code).toBe(0); - expect(output).toContain(`Current package version: v${scenario.version}`); + expect(output.trim()).toBe("v1.0.1"); - for (const [incrementType, expectedTransformation] of Object.entries(scenario.expected)) { - expect(output).toContain(`${incrementType.padEnd(10)} ${expectedTransformation}`); - } - } + const packageJson = await Bun.file(`${testDir}/package.json`).json(); + expect(packageJson.version).toBe("1.0.1"); - const testDir2 = tempDirWithFiles(`version-${i++}`, { - "package.json": JSON.stringify({ name: "test", version: "1.0.3-alpha.1" }, null, 2), + 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"); }); - - 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"); }); });