From 804e76af2276e3170d5f50c668d32628682efb9a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 17 Jul 2025 04:33:30 -0700 Subject: [PATCH] Introduce `bun update --interactive` (#20850) Co-authored-by: Jarred-Sumner <709451+Jarred-Sumner@users.noreply.github.com> Co-authored-by: Dylan Conway Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Jarred Sumner Co-authored-by: Claude Co-authored-by: Claude Bot Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- cmake/sources/ZigSources.txt | 1 + src/cli.zig | 16 +- src/cli/outdated_command.zig | 2 +- src/cli/update_command.zig | 9 +- src/cli/update_interactive_command.zig | 1139 +++++++++++++++++ .../PackageManager/CommandLineArguments.zig | 6 + .../cli/update_interactive_formatting.test.ts | 267 ++++ .../update-interactive-formatting.test.ts | 222 ++++ 8 files changed, 1659 insertions(+), 3 deletions(-) create mode 100644 src/cli/update_interactive_command.zig create mode 100644 test/cli/update_interactive_formatting.test.ts create mode 100644 test/regression/issue/update-interactive-formatting.test.ts diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index 6eafa40c06..02e47eb067 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -356,6 +356,7 @@ src/cli/test_command.zig src/cli/test/Scanner.zig src/cli/unlink_command.zig src/cli/update_command.zig +src/cli/update_interactive_command.zig src/cli/upgrade_command.zig src/cli/why_command.zig src/codegen/process_windows_translate_c.zig diff --git a/src/cli.zig b/src/cli.zig index c37d4ba6f6..b084c8bc04 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -114,6 +114,7 @@ pub const ExecCommand = @import("./cli/exec_command.zig").ExecCommand; pub const PatchCommand = @import("./cli/patch_command.zig").PatchCommand; pub const PatchCommitCommand = @import("./cli/patch_commit_command.zig").PatchCommitCommand; pub const OutdatedCommand = @import("./cli/outdated_command.zig").OutdatedCommand; +pub const UpdateInteractiveCommand = @import("./cli/update_interactive_command.zig").UpdateInteractiveCommand; pub const PublishCommand = @import("./cli/publish_command.zig").PublishCommand; pub const PackCommand = @import("./cli/pack_command.zig").PackCommand; pub const AuditCommand = @import("./cli/audit_command.zig").AuditCommand; @@ -788,6 +789,13 @@ pub const Command = struct { try OutdatedCommand.exec(ctx); return; }, + .UpdateInteractiveCommand => { + if (comptime bun.fast_debug_build_mode and bun.fast_debug_build_cmd != .UpdateInteractiveCommand) unreachable; + const ctx = try Command.init(allocator, log, .UpdateInteractiveCommand); + + try UpdateInteractiveCommand.exec(ctx); + return; + }, .PublishCommand => { if (comptime bun.fast_debug_build_mode and bun.fast_debug_build_cmd != .PublishCommand) unreachable; const ctx = try Command.init(allocator, log, .PublishCommand); @@ -1232,6 +1240,7 @@ pub const Command = struct { PatchCommand, PatchCommitCommand, OutdatedCommand, + UpdateInteractiveCommand, PublishCommand, AuditCommand, WhyCommand, @@ -1268,6 +1277,7 @@ pub const Command = struct { .PatchCommand => 'x', .PatchCommitCommand => 'z', .OutdatedCommand => 'o', + .UpdateInteractiveCommand => 'U', .PublishCommand => 'k', .AuditCommand => 'A', .WhyCommand => 'W', @@ -1522,9 +1532,10 @@ pub const Command = struct { , .{}); Output.flush(); }, - .OutdatedCommand, .PublishCommand, .AuditCommand => { + .OutdatedCommand, .UpdateInteractiveCommand, .PublishCommand, .AuditCommand => { Install.PackageManager.CommandLineArguments.printHelp(switch (cmd) { .OutdatedCommand => .outdated, + .UpdateInteractiveCommand => .update, .PublishCommand => .publish, .AuditCommand => .audit, }); @@ -1636,6 +1647,7 @@ pub const Command = struct { .RunCommand = true, .RunAsNodeCommand = true, .OutdatedCommand = true, + .UpdateInteractiveCommand = true, .PublishCommand = true, .AuditCommand = true, }); @@ -1652,6 +1664,7 @@ pub const Command = struct { .PackageManagerCommand = true, .BunxCommand = true, .OutdatedCommand = true, + .UpdateInteractiveCommand = true, .PublishCommand = true, .AuditCommand = true, }); @@ -1665,6 +1678,7 @@ pub const Command = struct { .InstallCommand = false, .LinkCommand = false, .OutdatedCommand = false, + .UpdateInteractiveCommand = false, .PackageManagerCommand = false, .PatchCommand = false, .PatchCommitCommand = false, diff --git a/src/cli/outdated_command.zig b/src/cli/outdated_command.zig index 83963b1f48..accb1f4df8 100644 --- a/src/cli/outdated_command.zig +++ b/src/cli/outdated_command.zig @@ -529,7 +529,7 @@ pub const OutdatedCommand = struct { table.printBottomLineSeparator(); } - fn updateManifestsIfNecessary( + pub fn updateManifestsIfNecessary( manager: *PackageManager, workspace_pkg_ids: []const PackageID, ) !void { diff --git a/src/cli/update_command.zig b/src/cli/update_command.zig index 711c1b65c6..4d79276b58 100644 --- a/src/cli/update_command.zig +++ b/src/cli/update_command.zig @@ -1,6 +1,13 @@ pub const UpdateCommand = struct { pub fn exec(ctx: Command.Context) !void { - try updatePackageJSONAndInstallCatchError(ctx, .update); + const cli = try PackageManager.CommandLineArguments.parse(ctx.allocator, .update); + + if (cli.interactive) { + const UpdateInteractiveCommand = @import("update_interactive_command.zig").UpdateInteractiveCommand; + try UpdateInteractiveCommand.exec(ctx); + } else { + try updatePackageJSONAndInstallCatchError(ctx, .update); + } } }; diff --git a/src/cli/update_interactive_command.zig b/src/cli/update_interactive_command.zig new file mode 100644 index 0000000000..5e07ec561a --- /dev/null +++ b/src/cli/update_interactive_command.zig @@ -0,0 +1,1139 @@ +pub const TerminalHyperlink = struct { + link: []const u8, + text: []const u8, + enabled: bool, + + const Protocol = enum { + vscode, + cursor, + }; + + pub fn new(link: []const u8, text: []const u8, enabled: bool) TerminalHyperlink { + return TerminalHyperlink{ + .link = link, + .text = text, + .enabled = enabled, + }; + } + + pub fn format(this: @This(), comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + if (this.enabled) { + const ESC = "\x1b"; + const OSC8 = ESC ++ "]8;;"; + const ST = ESC ++ "\\"; + const link_fmt_string = OSC8 ++ "{s}" ++ ST ++ "{s}" ++ OSC8 ++ ST; + try writer.print(link_fmt_string, .{ this.link, this.text }); + } else { + try writer.print("{s}", .{this.text}); + } + } +}; + +pub const UpdateInteractiveCommand = struct { + const OutdatedPackage = struct { + name: []const u8, + current_version: []const u8, + latest_version: []const u8, + update_version: []const u8, + package_id: PackageID, + dep_id: DependencyID, + workspace_pkg_id: PackageID, + dependency_type: []const u8, + workspace_name: []const u8, + behavior: Behavior, + use_latest: bool = false, + manager: *PackageManager, + }; + fn resolveCatalogDependency(manager: *PackageManager, dep: Install.Dependency) ?Install.Dependency.Version { + return if (dep.version.tag == .catalog) blk: { + const catalog_dep = manager.lockfile.catalogs.get( + manager.lockfile, + dep.version.value.catalog, + dep.name, + ) orelse return null; + break :blk catalog_dep.version; + } else dep.version; + } + + pub fn exec(ctx: Command.Context) !void { + Output.prettyln("bun update --interactive v" ++ Global.package_json_version_with_sha ++ "", .{}); + Output.flush(); + + const cli = try PackageManager.CommandLineArguments.parse(ctx.allocator, .update); + + const manager, const original_cwd = PackageManager.init(ctx, cli, .update) catch |err| { + if (!cli.silent) { + if (err == error.MissingPackageJSON) { + Output.errGeneric("missing package.json, nothing outdated", .{}); + } + Output.errGeneric("failed to initialize bun install: {s}", .{@errorName(err)}); + } + + Global.crash(); + }; + defer ctx.allocator.free(original_cwd); + + try updateInteractive(ctx, original_cwd, manager); + } + + fn updatePackages( + manager: *PackageManager, + ctx: Command.Context, + updates: []UpdateRequest, + original_cwd: string, + ) !void { + // This function follows the same pattern as updatePackageJSONAndInstallWithManagerWithUpdates + // from updatePackageJSONAndInstall.zig + + // Load and parse the current package.json + var current_package_json = switch (manager.workspace_package_json_cache.getWithPath( + manager.allocator, + manager.log, + manager.original_package_json_path, + .{ .guess_indentation = true }, + )) { + .parse_err => |err| { + manager.log.print(Output.errorWriter()) catch {}; + Output.errGeneric("failed to parse package.json \"{s}\": {s}", .{ + manager.original_package_json_path, + @errorName(err), + }); + Global.crash(); + }, + .read_err => |err| { + Output.errGeneric("failed to read package.json \"{s}\": {s}", .{ + manager.original_package_json_path, + @errorName(err), + }); + Global.crash(); + }, + .entry => |entry| entry, + }; + + const current_package_json_indent = current_package_json.indentation; + const preserve_trailing_newline = current_package_json.source.contents.len > 0 and + current_package_json.source.contents[current_package_json.source.contents.len - 1] == '\n'; + + // Set update mode + manager.to_update = true; + manager.update_requests = updates; + + // Edit the package.json with all updates + // For interactive mode, we'll edit all as dependencies + // TODO: preserve original dependency types + var updates_mut = updates; + try PackageJSONEditor.edit( + manager, + &updates_mut, + ¤t_package_json.root, + "dependencies", + .{ + .exact_versions = manager.options.enable.exact_versions, + .before_install = true, + }, + ); + + // Serialize the updated package.json + var buffer_writer = JSPrinter.BufferWriter.init(manager.allocator); + try buffer_writer.buffer.list.ensureTotalCapacity(manager.allocator, current_package_json.source.contents.len + 1); + buffer_writer.append_newline = preserve_trailing_newline; + var package_json_writer = JSPrinter.BufferPrinter.init(buffer_writer); + + _ = JSPrinter.printJSON( + @TypeOf(&package_json_writer), + &package_json_writer, + current_package_json.root, + ¤t_package_json.source, + .{ + .indent = current_package_json_indent, + .mangled_props = null, + }, + ) catch |err| { + Output.prettyErrorln("package.json failed to write due to error {s}", .{@errorName(err)}); + Global.crash(); + }; + + const new_package_json_source = try manager.allocator.dupe(u8, package_json_writer.ctx.writtenWithoutTrailingZero()); + + // Call installWithManager to perform the installation + try manager.installWithManager(ctx, new_package_json_source, original_cwd); + } + + fn updateInteractive(ctx: Command.Context, original_cwd: string, manager: *PackageManager) !void { + const load_lockfile_result = manager.lockfile.loadFromCwd( + manager, + manager.allocator, + manager.log, + true, + ); + + manager.lockfile = switch (load_lockfile_result) { + .not_found => { + if (manager.options.log_level != .silent) { + Output.errGeneric("missing lockfile, nothing outdated", .{}); + } + Global.crash(); + }, + .err => |cause| { + if (manager.options.log_level != .silent) { + switch (cause.step) { + .open_file => Output.errGeneric("failed to open lockfile: {s}", .{ + @errorName(cause.value), + }), + .parse_file => Output.errGeneric("failed to parse lockfile: {s}", .{ + @errorName(cause.value), + }), + .read_file => Output.errGeneric("failed to read lockfile: {s}", .{ + @errorName(cause.value), + }), + .migrating => Output.errGeneric("failed to migrate lockfile: {s}", .{ + @errorName(cause.value), + }), + } + + if (ctx.log.hasErrors()) { + try manager.log.print(Output.errorWriter()); + } + } + + Global.crash(); + }, + .ok => |ok| ok.lockfile, + }; + + switch (Output.enable_ansi_colors) { + inline else => |_| { + const workspace_pkg_ids = if (manager.options.filter_patterns.len > 0) blk: { + const filters = manager.options.filter_patterns; + break :blk findMatchingWorkspaces( + bun.default_allocator, + original_cwd, + manager, + filters, + ) catch bun.outOfMemory(); + } else blk: { + // just the current workspace + const root_pkg_id = manager.root_package_id.get(manager.lockfile, manager.workspace_name_hash); + if (root_pkg_id == invalid_package_id) return; + const ids = bun.default_allocator.alloc(PackageID, 1) catch bun.outOfMemory(); + ids[0] = root_pkg_id; + break :blk ids; + }; + defer bun.default_allocator.free(workspace_pkg_ids); + + try OutdatedCommand.updateManifestsIfNecessary(manager, workspace_pkg_ids); + + // Get outdated packages + const outdated_packages = try getOutdatedPackages(bun.default_allocator, manager, workspace_pkg_ids); + defer { + for (outdated_packages) |pkg| { + bun.default_allocator.free(pkg.name); + bun.default_allocator.free(pkg.current_version); + bun.default_allocator.free(pkg.latest_version); + bun.default_allocator.free(pkg.update_version); + bun.default_allocator.free(pkg.workspace_name); + } + bun.default_allocator.free(outdated_packages); + } + + if (outdated_packages.len == 0) { + // Check if we're using --latest flag + const is_latest_mode = manager.options.do.update_to_latest; + + if (is_latest_mode) { + Output.prettyln(" All packages are up to date!", .{}); + } else { + // Count how many packages have newer versions available + var packages_with_newer_versions: usize = 0; + + // We need to check all packages for newer versions + for (workspace_pkg_ids) |workspace_pkg_id| { + const pkg_deps = manager.lockfile.packages.items(.dependencies)[workspace_pkg_id]; + for (pkg_deps.begin()..pkg_deps.end()) |dep_id| { + const package_id = manager.lockfile.buffers.resolutions.items[dep_id]; + if (package_id == invalid_package_id) continue; + const dep = manager.lockfile.buffers.dependencies.items[dep_id]; + const resolved_version = resolveCatalogDependency(manager, dep) orelse continue; + if (resolved_version.tag != .npm and resolved_version.tag != .dist_tag) continue; + const resolution = manager.lockfile.packages.items(.resolution)[package_id]; + if (resolution.tag != .npm) continue; + + const package_name = manager.lockfile.packages.items(.name)[package_id].slice(manager.lockfile.buffers.string_bytes.items); + + var expired = false; + const manifest = manager.manifests.byNameAllowExpired( + manager, + manager.scopeForPackageName(package_name), + package_name, + &expired, + .load_from_memory_fallback_to_disk, + ) orelse continue; + + const latest = manifest.findByDistTag("latest") orelse continue; + + // Check if current version is less than latest + if (resolution.value.npm.version.order(latest.version, manager.lockfile.buffers.string_bytes.items, manifest.string_buf) == .lt) { + packages_with_newer_versions += 1; + } + } + } + + if (packages_with_newer_versions > 0) { + Output.prettyln(" All packages are up to date!\n", .{}); + Output.prettyln("Excluded {d} package{s} with potentially breaking changes. Run `bun update -i --latest` to update", .{ packages_with_newer_versions, if (packages_with_newer_versions == 1) "" else "s" }); + } else { + Output.prettyln(" All packages are up to date!", .{}); + } + } + return; + } + + // Prompt user to select packages + const selected = try promptForUpdates(bun.default_allocator, outdated_packages); + defer bun.default_allocator.free(selected); + + // Create package specifier array from selected packages + var package_specifiers = std.ArrayList([]const u8).init(bun.default_allocator); + defer package_specifiers.deinit(); + + // Create a map to track dependency types for packages + var dep_types = bun.StringHashMap([]const u8).init(bun.default_allocator); + defer dep_types.deinit(); + + for (outdated_packages, selected) |pkg, is_selected| { + if (!is_selected) continue; + + try dep_types.put(pkg.name, pkg.dependency_type); + + // Use latest version if user selected it with 'l' key + const target_version = if (pkg.use_latest) pkg.latest_version else pkg.update_version; + + // Create a full package specifier string for UpdateRequest.parse + const package_specifier = try std.fmt.allocPrint(bun.default_allocator, "{s}@{s}", .{ pkg.name, target_version }); + + try package_specifiers.append(package_specifier); + } + + // dep_types will be freed when we exit this scope + + if (package_specifiers.items.len == 0) { + Output.prettyln("! No packages selected for update", .{}); + return; + } + + // Parse the package specifiers into UpdateRequests + var update_requests_array = UpdateRequest.Array{}; + const update_requests = UpdateRequest.parse( + bun.default_allocator, + manager, + manager.log, + package_specifiers.items, + &update_requests_array, + .update, + ); + + // Perform the update + Output.prettyln("\nInstalling updates...", .{}); + Output.flush(); + + try updatePackages( + manager, + ctx, + update_requests, + original_cwd, + ); + }, + } + } + + fn findMatchingWorkspaces( + allocator: std.mem.Allocator, + original_cwd: string, + manager: *PackageManager, + filters: []const string, + ) OOM![]const PackageID { + const lockfile = manager.lockfile; + const packages = lockfile.packages.slice(); + const pkg_names = packages.items(.name); + const pkg_resolutions = packages.items(.resolution); + const string_buf = lockfile.buffers.string_bytes.items; + + var workspace_pkg_ids: std.ArrayListUnmanaged(PackageID) = .{}; + for (pkg_resolutions, 0..) |resolution, pkg_id| { + if (resolution.tag != .workspace and resolution.tag != .root) continue; + try workspace_pkg_ids.append(allocator, @intCast(pkg_id)); + } + + var path_buf: bun.PathBuffer = undefined; + + const converted_filters = converted_filters: { + const buf = try allocator.alloc(WorkspaceFilter, filters.len); + for (filters, buf) |filter, *converted| { + converted.* = try WorkspaceFilter.init(allocator, filter, original_cwd, &path_buf); + } + break :converted_filters buf; + }; + defer { + for (converted_filters) |filter| { + filter.deinit(allocator); + } + allocator.free(converted_filters); + } + + // move all matched workspaces to front of array + var i: usize = 0; + while (i < workspace_pkg_ids.items.len) { + const workspace_pkg_id = workspace_pkg_ids.items[i]; + + const matched = matched: { + for (converted_filters) |filter| { + switch (filter) { + .path => |pattern| { + if (pattern.len == 0) continue; + const res = pkg_resolutions[workspace_pkg_id]; + + const res_path = switch (res.tag) { + .workspace => res.value.workspace.slice(string_buf), + .root => FileSystem.instance.top_level_dir, + else => unreachable, + }; + + const abs_res_path = path.joinAbsStringBuf(FileSystem.instance.top_level_dir, &path_buf, &[_]string{res_path}, .posix); + + if (!glob.walk.matchImpl(allocator, pattern, strings.withoutTrailingSlash(abs_res_path)).matches()) { + break :matched false; + } + }, + .name => |pattern| { + const name = pkg_names[workspace_pkg_id].slice(string_buf); + + if (!glob.walk.matchImpl(allocator, pattern, name).matches()) { + break :matched false; + } + }, + .all => {}, + } + } + + break :matched true; + }; + + if (matched) { + i += 1; + } else { + _ = workspace_pkg_ids.swapRemove(i); + } + } + + return workspace_pkg_ids.items; + } + + fn getOutdatedPackages( + allocator: std.mem.Allocator, + manager: *PackageManager, + workspace_pkg_ids: []const PackageID, + ) ![]OutdatedPackage { + const lockfile = manager.lockfile; + const string_buf = lockfile.buffers.string_bytes.items; + const packages = lockfile.packages.slice(); + const pkg_names = packages.items(.name); + const pkg_resolutions = packages.items(.resolution); + const pkg_dependencies = packages.items(.dependencies); + + var outdated_packages = std.ArrayList(OutdatedPackage).init(allocator); + defer outdated_packages.deinit(); + + var version_buf = std.ArrayList(u8).init(allocator); + defer version_buf.deinit(); + const version_writer = version_buf.writer(); + + for (workspace_pkg_ids) |workspace_pkg_id| { + const pkg_deps = pkg_dependencies[workspace_pkg_id]; + for (pkg_deps.begin()..pkg_deps.end()) |dep_id| { + const package_id = lockfile.buffers.resolutions.items[dep_id]; + if (package_id == invalid_package_id) continue; + const dep = lockfile.buffers.dependencies.items[dep_id]; + const resolved_version = resolveCatalogDependency(manager, dep) orelse continue; + if (resolved_version.tag != .npm and resolved_version.tag != .dist_tag) continue; + const resolution = pkg_resolutions[package_id]; + if (resolution.tag != .npm) continue; + + const name_slice = dep.name.slice(string_buf); + const package_name = pkg_names[package_id].slice(string_buf); + + var expired = false; + const manifest = manager.manifests.byNameAllowExpired( + manager, + manager.scopeForPackageName(package_name), + package_name, + &expired, + .load_from_memory_fallback_to_disk, + ) orelse continue; + + const latest = manifest.findByDistTag("latest") orelse continue; + + const update_version = if (manager.options.do.update_to_latest) + latest + else if (resolved_version.tag == .npm) + manifest.findBestVersion(resolved_version.value.npm.version, string_buf) orelse continue + else + manifest.findByDistTag(resolved_version.value.dist_tag.tag.slice(string_buf)) orelse continue; + + // Skip if current version is already the latest + if (resolution.value.npm.version.order(latest.version, string_buf, manifest.string_buf) != .lt) continue; + + // Skip if update version is the same as current version + // Note: Current version is in lockfile's string_buf, update version is in manifest's string_buf + const current_ver = resolution.value.npm.version; + const update_ver = update_version.version; + + // Compare the actual version numbers + if (current_ver.major == update_ver.major and + current_ver.minor == update_ver.minor and + current_ver.patch == update_ver.patch and + current_ver.tag.eql(update_ver.tag)) + { + continue; + } + + version_buf.clearRetainingCapacity(); + try version_writer.print("{}", .{resolution.value.npm.version.fmt(string_buf)}); + const current_version_buf = try allocator.dupe(u8, version_buf.items); + + version_buf.clearRetainingCapacity(); + try version_writer.print("{}", .{update_version.version.fmt(manifest.string_buf)}); + const update_version_buf = try allocator.dupe(u8, version_buf.items); + + version_buf.clearRetainingCapacity(); + try version_writer.print("{}", .{latest.version.fmt(manifest.string_buf)}); + const latest_version_buf = try allocator.dupe(u8, version_buf.items); + + // Already filtered by version.order check above + + version_buf.clearRetainingCapacity(); + const dep_type = if (dep.behavior.dev) "devDependencies" else if (dep.behavior.optional) "optionalDependencies" else if (dep.behavior.peer) "peerDependencies" else "dependencies"; + + // Get workspace name but only show if it's actually a workspace + const workspace_resolution = pkg_resolutions[workspace_pkg_id]; + const workspace_name = if (workspace_resolution.tag == .workspace) + pkg_names[workspace_pkg_id].slice(string_buf) + else + ""; + + try outdated_packages.append(.{ + .name = try allocator.dupe(u8, name_slice), + .current_version = try allocator.dupe(u8, current_version_buf), + .latest_version = try allocator.dupe(u8, latest_version_buf), + .update_version = try allocator.dupe(u8, update_version_buf), + .package_id = package_id, + .dep_id = @intCast(dep_id), + .workspace_pkg_id = workspace_pkg_id, + .dependency_type = dep_type, + .workspace_name = try allocator.dupe(u8, workspace_name), + .behavior = dep.behavior, + .manager = manager, + }); + } + } + + const result = try outdated_packages.toOwnedSlice(); + + // Sort packages: dependencies first, then devDependencies, etc. + std.sort.pdq(OutdatedPackage, result, {}, struct { + fn lessThan(_: void, a: OutdatedPackage, b: OutdatedPackage) bool { + // First sort by dependency type + const a_priority = depTypePriority(a.dependency_type); + const b_priority = depTypePriority(b.dependency_type); + if (a_priority != b_priority) return a_priority < b_priority; + + // Then by name + return strings.order(a.name, b.name) == .lt; + } + + fn depTypePriority(dep_type: []const u8) u8 { + if (strings.eqlComptime(dep_type, "dependencies")) return 0; + if (strings.eqlComptime(dep_type, "devDependencies")) return 1; + if (strings.eqlComptime(dep_type, "peerDependencies")) return 2; + if (strings.eqlComptime(dep_type, "optionalDependencies")) return 3; + return 4; + } + }.lessThan); + + return result; + } + + const MultiSelectState = struct { + packages: []OutdatedPackage, + selected: []bool, + cursor: usize = 0, + toggle_all: bool = false, + max_name_len: usize = 0, + max_current_len: usize = 0, + max_update_len: usize = 0, + max_latest_len: usize = 0, + }; + + fn promptForUpdates(allocator: std.mem.Allocator, packages: []OutdatedPackage) ![]bool { + if (packages.len == 0) { + Output.prettyln(" All packages are up to date!", .{}); + return allocator.alloc(bool, 0); + } + + const selected = try allocator.alloc(bool, packages.len); + // Default to all unselected + @memset(selected, false); + + // Calculate max column widths - need to account for diffFmt ANSI codes + var max_name_len: usize = "Package".len; + var max_current_len: usize = "Current".len; + var max_target_len: usize = "Target".len; + var max_latest_len: usize = "Latest".len; + + // Set reasonable limits to prevent excessive column widths + const MAX_NAME_WIDTH = 60; + const MAX_VERSION_WIDTH = 20; + + for (packages) |pkg| { + // Include dev tag length in max calculation + var dev_tag_len: usize = 0; + if (pkg.behavior.dev) { + dev_tag_len = 4; // " dev" + } else if (pkg.behavior.peer) { + dev_tag_len = 5; // " peer" + } else if (pkg.behavior.optional) { + dev_tag_len = 9; // " optional" + } + const name_len = @min(pkg.name.len + dev_tag_len, MAX_NAME_WIDTH); + max_name_len = @max(max_name_len, name_len); + + // For current version - it's always displayed without formatting + max_current_len = @max(max_current_len, @min(pkg.current_version.len, MAX_VERSION_WIDTH)); + + // For max width calculation, just use the plain version string length + // The diffFmt adds ANSI codes but doesn't change the visible width of the version + // Version strings are always ASCII so we can use string length directly + max_target_len = @max(max_target_len, @min(pkg.update_version.len, MAX_VERSION_WIDTH)); + max_latest_len = @max(max_latest_len, @min(pkg.latest_version.len, MAX_VERSION_WIDTH)); + } + + var state = MultiSelectState{ + .packages = packages, + .selected = selected, + .max_name_len = max_name_len, + .max_current_len = max_current_len, + .max_update_len = max_target_len, + .max_latest_len = max_latest_len, + }; + + // Set raw mode + const original_mode: if (Environment.isWindows) ?bun.windows.DWORD else void = if (comptime Environment.isWindows) + bun.windows.updateStdioModeFlags(.std_in, .{ + .set = bun.windows.ENABLE_VIRTUAL_TERMINAL_INPUT | bun.windows.ENABLE_PROCESSED_INPUT, + .unset = bun.windows.ENABLE_LINE_INPUT | bun.windows.ENABLE_ECHO_INPUT, + }) catch null; + + if (Environment.isPosix) + _ = Bun__ttySetMode(0, 1); + + defer { + if (comptime Environment.isWindows) { + if (original_mode) |mode| { + _ = bun.c.SetConsoleMode( + bun.FD.stdin().native(), + mode, + ); + } + } + if (Environment.isPosix) { + _ = Bun__ttySetMode(0, 0); + } + } + + const result = processMultiSelect(&state) catch |err| { + if (err == error.EndOfStream) { + Output.flush(); + Output.prettyln("\nx Cancelled", .{}); + Global.exit(0); + } + return err; + }; + + Output.flush(); + return result; + } + + fn processMultiSelect(state: *MultiSelectState) ![]bool { + const colors = Output.enable_ansi_colors; + + // Clear any previous progress output + Output.print("\r\x1B[2K", .{}); // Clear entire line + Output.print("\x1B[1A\x1B[2K", .{}); // Move up one line and clear it too + Output.flush(); + + // Print the prompt + Output.prettyln("? Select packages to update - Space to toggle, Enter to confirm, a to select all, n to select none, i to invert, l to toggle latest", .{}); + + Output.prettyln("", .{}); + + if (colors) Output.print("\x1b[?25l", .{}); // hide cursor + defer if (colors) Output.print("\x1b[?25h", .{}); // show cursor + + var initial_draw = true; + var reprint_menu = true; + var total_lines: usize = 0; + errdefer reprint_menu = false; + defer { + if (!initial_draw) { + Output.up(total_lines + 2); + } + Output.clearToEnd(); + + if (reprint_menu) { + var count: usize = 0; + for (state.selected) |sel| { + if (sel) count += 1; + } + Output.prettyln(" Selected {d} package{s} to update", .{ count, if (count == 1) "" else "s" }); + } + } + + while (true) { + if (!initial_draw) { + Output.up(total_lines); + Output.clearToEnd(); + } + initial_draw = false; + total_lines = 0; + + var displayed_lines: usize = 0; + + // Group by dependency type + var current_dep_type: ?[]const u8 = null; + + for (state.packages, state.selected, 0..) |*pkg, selected, i| { + // Print dependency type header with column headers if changed + if (current_dep_type == null or !strings.eql(current_dep_type.?, pkg.dependency_type)) { + if (displayed_lines > 0) { + Output.print("\n", .{}); + displayed_lines += 1; + } + + // Count selected packages in this dependency type + var selected_count: usize = 0; + for (state.packages, state.selected) |p, sel| { + if (strings.eql(p.dependency_type, pkg.dependency_type) and sel) { + selected_count += 1; + } + } + + // Print dependency type - bold if any selected + Output.print(" ", .{}); + if (selected_count > 0) { + Output.pretty("{s} {d}", .{ pkg.dependency_type, selected_count }); + } else { + Output.pretty("{s}", .{pkg.dependency_type}); + } + + // Calculate padding to align column headers with values + var j: usize = 0; + // Calculate actual displayed text length including count if present + const dep_type_text_len: usize = if (selected_count > 0) + pkg.dependency_type.len + 1 + std.fmt.count("{d}", .{selected_count}) // +1 for space + else + pkg.dependency_type.len; + + // The padding should align with the first character of package names + // Package names start at: " " (4 spaces) + "□ " (2 chars) = 6 chars from left + // Headers start at: " " (2 spaces) + dep_type_text + // So we need to pad: 4 (cursor/space) + 2 (checkbox+space) + max_name_len - dep_type_text_len + // Use safe subtraction to prevent underflow when dep_type_text_len is longer than available space + const base_padding = 4 + 2 + state.max_name_len; + const padding_to_current = if (dep_type_text_len >= base_padding) 1 else base_padding - dep_type_text_len; + while (j < padding_to_current) : (j += 1) { + Output.print(" ", .{}); + } + + // Column headers aligned with their columns + Output.print("Current", .{}); + j = 0; + while (j < state.max_current_len - "Current".len + 2) : (j += 1) { + Output.print(" ", .{}); + } + Output.print("Target", .{}); + j = 0; + while (j < state.max_update_len - "Target".len + 2) : (j += 1) { + Output.print(" ", .{}); + } + Output.print("Latest", .{}); + Output.print("\x1B[0K\n", .{}); + displayed_lines += 1; + current_dep_type = pkg.dependency_type; + } + const is_cursor = i == state.cursor; + const checkbox = if (selected) "■" else "□"; + + // Calculate padding - account for dev/peer/optional tags + var dev_tag_len: usize = 0; + if (pkg.behavior.dev) { + dev_tag_len = 4; // " dev" + } else if (pkg.behavior.peer) { + dev_tag_len = 5; // " peer" + } else if (pkg.behavior.optional) { + dev_tag_len = 9; // " optional" + } + const total_name_len = pkg.name.len + dev_tag_len; + const name_padding = if (total_name_len >= state.max_name_len) 0 else state.max_name_len - total_name_len; + + // Determine version change severity for checkbox color + const current_ver_parsed = Semver.Version.parse(SlicedString.init(pkg.current_version, pkg.current_version)); + const update_ver_parsed = if (pkg.use_latest) + Semver.Version.parse(SlicedString.init(pkg.latest_version, pkg.latest_version)) + else + Semver.Version.parse(SlicedString.init(pkg.update_version, pkg.update_version)); + + var checkbox_color: []const u8 = "green"; // default + if (current_ver_parsed.valid and update_ver_parsed.valid) { + const current_full = Semver.Version{ + .major = current_ver_parsed.version.major orelse 0, + .minor = current_ver_parsed.version.minor orelse 0, + .patch = current_ver_parsed.version.patch orelse 0, + .tag = current_ver_parsed.version.tag, + }; + const update_full = Semver.Version{ + .major = update_ver_parsed.version.major orelse 0, + .minor = update_ver_parsed.version.minor orelse 0, + .patch = update_ver_parsed.version.patch orelse 0, + .tag = update_ver_parsed.version.tag, + }; + + const target_ver_str = if (pkg.use_latest) pkg.latest_version else pkg.update_version; + const diff = update_full.whichVersionIsDifferent(current_full, target_ver_str, pkg.current_version); + if (diff) |d| { + switch (d) { + .major => checkbox_color = "red", + .minor => { + if (current_full.major == 0) { + checkbox_color = "red"; // 0.x.y minor changes are breaking + } else { + checkbox_color = "yellow"; + } + }, + .patch => { + if (current_full.major == 0 and current_full.minor == 0) { + checkbox_color = "red"; // 0.0.x patch changes are breaking + } else { + checkbox_color = "green"; + } + }, + else => checkbox_color = "green", + } + } + } + + // Cursor and checkbox + if (is_cursor) { + Output.pretty(" ", .{}); + } else { + Output.print(" ", .{}); + } + + // Checkbox with appropriate color + if (selected) { + if (strings.eqlComptime(checkbox_color, "red")) { + Output.pretty("{s} ", .{checkbox}); + } else if (strings.eqlComptime(checkbox_color, "yellow")) { + Output.pretty("{s} ", .{checkbox}); + } else { + Output.pretty("{s} ", .{checkbox}); + } + } else { + Output.print("{s} ", .{checkbox}); + } + + // Package name - make it a hyperlink if colors are enabled and using default registry + const uses_default_registry = pkg.manager.options.scope.url_hash == Install.Npm.Registry.default_url_hash and + pkg.manager.scopeForPackageName(pkg.name).url_hash == Install.Npm.Registry.default_url_hash; + const package_url = if (Output.enable_ansi_colors and uses_default_registry) + try std.fmt.allocPrint(bun.default_allocator, "https://npmjs.org/package/{s}/v/{s}", .{ pkg.name, brk: { + if (selected) { + if (pkg.use_latest) { + break :brk pkg.latest_version; + } else { + break :brk pkg.update_version; + } + } else { + break :brk pkg.current_version; + } + } }) + else + ""; + defer if (package_url.len > 0) bun.default_allocator.free(package_url); + + // Truncate package name if it's too long + const display_name = if (pkg.name.len > 60) + try std.fmt.allocPrint(bun.default_allocator, "{s}...", .{pkg.name[0..57]}) + else + pkg.name; + defer if (display_name.ptr != pkg.name.ptr) bun.default_allocator.free(display_name); + + const hyperlink = TerminalHyperlink.new(package_url, display_name, package_url.len > 0); + + if (selected) { + if (strings.eqlComptime(checkbox_color, "red")) { + Output.pretty("{}", .{hyperlink}); + } else if (strings.eqlComptime(checkbox_color, "yellow")) { + Output.pretty("{}", .{hyperlink}); + } else { + Output.pretty("{}", .{hyperlink}); + } + } else { + Output.pretty("{}", .{hyperlink}); + } + + // Print dev/peer/optional tag if applicable + if (pkg.behavior.dev) { + Output.pretty(" dev", .{}); + } else if (pkg.behavior.peer) { + Output.pretty(" peer", .{}); + } else if (pkg.behavior.optional) { + Output.pretty(" optional", .{}); + } + + // Print padding after name + var j: usize = 0; + while (j < name_padding + 2) : (j += 1) { + Output.print(" ", .{}); + } + + // Current version - truncate if too long + const display_current = if (pkg.current_version.len > 20) + try std.fmt.allocPrint(bun.default_allocator, "{s}...", .{pkg.current_version[0..17]}) + else + pkg.current_version; + defer if (display_current.ptr != pkg.current_version.ptr) bun.default_allocator.free(display_current); + + Output.pretty("{s}", .{display_current}); + + // Print padding after current version + const current_display_len = if (pkg.current_version.len > 20) 20 else pkg.current_version.len; + const current_padding = if (current_display_len >= state.max_current_len) 0 else state.max_current_len - current_display_len; + j = 0; + while (j < current_padding + 2) : (j += 1) { + Output.print(" ", .{}); + } + + // Target version with diffFmt coloring - bold if not using latest + const target_ver_parsed = Semver.Version.parse(SlicedString.init(pkg.update_version, pkg.update_version)); + + // For width calculation, use the plain version string length + // since diffFmt only adds colors, not visible characters + const target_width: usize = pkg.update_version.len; + + if (current_ver_parsed.valid and target_ver_parsed.valid) { + const current_full = Semver.Version{ + .major = current_ver_parsed.version.major orelse 0, + .minor = current_ver_parsed.version.minor orelse 0, + .patch = current_ver_parsed.version.patch orelse 0, + .tag = current_ver_parsed.version.tag, + }; + const target_full = Semver.Version{ + .major = target_ver_parsed.version.major orelse 0, + .minor = target_ver_parsed.version.minor orelse 0, + .patch = target_ver_parsed.version.patch orelse 0, + .tag = target_ver_parsed.version.tag, + }; + + // Print target version + if (selected and !pkg.use_latest) { + Output.print("\x1B[4m", .{}); // Start underline + } + Output.pretty("{}", .{target_full.diffFmt( + current_full, + pkg.update_version, + pkg.current_version, + )}); + if (selected and !pkg.use_latest) { + Output.print("\x1B[24m", .{}); // End underline + } + } else { + // Fallback if version parsing fails + if (selected and !pkg.use_latest) { + Output.print("\x1B[4m", .{}); // Start underline + } + Output.pretty("{s}", .{pkg.update_version}); + if (selected and !pkg.use_latest) { + Output.print("\x1B[24m", .{}); // End underline + } + } + + const target_padding = if (target_width >= state.max_update_len) 0 else state.max_update_len - target_width; + j = 0; + while (j < target_padding + 2) : (j += 1) { + Output.print(" ", .{}); + } + + // Latest version with diffFmt coloring - bold if using latest + const latest_ver_parsed = Semver.Version.parse(SlicedString.init(pkg.latest_version, pkg.latest_version)); + if (current_ver_parsed.valid and latest_ver_parsed.valid) { + const current_full = Semver.Version{ + .major = current_ver_parsed.version.major orelse 0, + .minor = current_ver_parsed.version.minor orelse 0, + .patch = current_ver_parsed.version.patch orelse 0, + .tag = current_ver_parsed.version.tag, + }; + const latest_full = Semver.Version{ + .major = latest_ver_parsed.version.major orelse 0, + .minor = latest_ver_parsed.version.minor orelse 0, + .patch = latest_ver_parsed.version.patch orelse 0, + .tag = latest_ver_parsed.version.tag, + }; + + // Dim if latest matches target version + const is_same_as_target = strings.eql(pkg.latest_version, pkg.update_version); + if (is_same_as_target) { + Output.print("\x1B[2m", .{}); // Dim + } + // Print latest version + if (selected and pkg.use_latest) { + Output.print("\x1B[4m", .{}); // Start underline + } + Output.pretty("{}", .{latest_full.diffFmt( + current_full, + pkg.latest_version, + pkg.current_version, + )}); + if (selected and pkg.use_latest) { + Output.print("\x1B[24m", .{}); // End underline + } + if (is_same_as_target) { + Output.print("\x1B[22m", .{}); // Reset dim + } + } else { + // Fallback if version parsing fails + const is_same_as_target = strings.eql(pkg.latest_version, pkg.update_version); + if (is_same_as_target) { + Output.print("\x1B[2m", .{}); // Dim + } + if (selected and pkg.use_latest) { + Output.print("\x1B[4m", .{}); // Start underline + } + Output.pretty("{s}", .{pkg.latest_version}); + if (selected and pkg.use_latest) { + Output.print("\x1B[24m", .{}); // End underline + } + if (is_same_as_target) { + Output.print("\x1B[22m", .{}); // Reset dim + } + } + + Output.print("\x1B[0K\n", .{}); + displayed_lines += 1; + } + + total_lines = displayed_lines; + Output.clearToEnd(); + Output.flush(); + + // Read input + const byte = std.io.getStdIn().reader().readByte() catch return state.selected; + + switch (byte) { + '\n', '\r' => return state.selected, + 3, 4 => return error.EndOfStream, // ctrl+c, ctrl+d + ' ' => { + state.selected[state.cursor] = !state.selected[state.cursor]; + // Don't move cursor on space - let user manually navigate + }, + 'a', 'A' => { + @memset(state.selected, true); + }, + 'n', 'N' => { + @memset(state.selected, false); + }, + 'i', 'I' => { + // Invert selection + for (state.selected) |*sel| { + sel.* = !sel.*; + } + }, + 'l', 'L' => { + state.packages[state.cursor].use_latest = !state.packages[state.cursor].use_latest; + }, + 'j' => { + if (state.cursor < state.packages.len - 1) { + state.cursor += 1; + } else { + state.cursor = 0; + } + }, + 'k' => { + if (state.cursor > 0) { + state.cursor -= 1; + } else { + state.cursor = state.packages.len - 1; + } + }, + 27 => { // escape sequence + const seq = std.io.getStdIn().reader().readByte() catch continue; + if (seq == '[') { + const arrow = std.io.getStdIn().reader().readByte() catch continue; + switch (arrow) { + 'A' => { // up arrow + if (state.cursor > 0) { + state.cursor -= 1; + } else { + state.cursor = state.packages.len - 1; + } + }, + 'B' => { // down arrow + if (state.cursor < state.packages.len - 1) { + state.cursor += 1; + } else { + state.cursor = 0; + } + }, + else => {}, + } + } + }, + else => {}, + } + } + } +}; + +extern fn Bun__ttySetMode(fd: c_int, mode: c_int) c_int; + +// @sortImports + +const std = @import("std"); + +const bun = @import("bun"); +const Environment = bun.Environment; +const Global = bun.Global; +const JSPrinter = bun.js_printer; +const OOM = bun.OOM; +const Output = bun.Output; +const PathBuffer = bun.PathBuffer; +const glob = bun.glob; +const path = bun.path; +const string = bun.string; +const strings = bun.strings; +const FileSystem = bun.fs.FileSystem; + +const Command = bun.CLI.Command; +const OutdatedCommand = bun.CLI.OutdatedCommand; + +const Semver = bun.Semver; +const SlicedString = Semver.SlicedString; + +const Install = bun.install; +const DependencyID = Install.DependencyID; +const PackageID = Install.PackageID; +const invalid_package_id = Install.invalid_package_id; +const Behavior = Install.Dependency.Behavior; + +const PackageManager = Install.PackageManager; +const PackageJSONEditor = PackageManager.PackageJSONEditor; +const UpdateRequest = PackageManager.UpdateRequest; +const WorkspaceFilter = PackageManager.WorkspaceFilter; diff --git a/src/install/PackageManager/CommandLineArguments.zig b/src/install/PackageManager/CommandLineArguments.zig index 313f8f0db6..4878547610 100644 --- a/src/install/PackageManager/CommandLineArguments.zig +++ b/src/install/PackageManager/CommandLineArguments.zig @@ -66,6 +66,7 @@ pub const install_params: []const ParamType = &(shared_params ++ [_]ParamType{ pub const update_params: []const ParamType = &(shared_params ++ [_]ParamType{ clap.parseParam("--latest Update packages to their latest versions") catch unreachable, + clap.parseParam("-i, --interactive Show an interactive list of outdated packages to select for update") catch unreachable, clap.parseParam(" ... \"name\" of packages to update") catch unreachable, }); @@ -186,6 +187,7 @@ ignore_scripts: bool = false, trusted: bool = false, no_summary: bool = false, latest: bool = false, +interactive: bool = false, json_output: bool = false, filters: []const string = &.{}, @@ -298,6 +300,9 @@ pub fn printHelp(subcommand: Subcommand) void { \\ Update all dependencies to latest: \\ bun update --latest \\ + \\ Interactive update (select packages to update): + \\ bun update -i + \\ \\ Update specific packages: \\ bun update zod jquery@3 \\ @@ -891,6 +896,7 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com if (comptime subcommand == .update) { cli.latest = args.flag("--latest"); + cli.interactive = args.flag("--interactive"); } const specified_backend: ?PackageInstall.Method = brk: { diff --git a/test/cli/update_interactive_formatting.test.ts b/test/cli/update_interactive_formatting.test.ts new file mode 100644 index 0000000000..7b28c7318f --- /dev/null +++ b/test/cli/update_interactive_formatting.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { join } from "path"; + +describe("bun update --interactive formatting", () => { + it("should handle package names of unusual lengths", async () => { + const dir = tempDirWithFiles("update-interactive-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "a": "1.0.0", + "really-long-package-name-that-causes-formatting-issues": "1.0.0", + "@org/extremely-long-scoped-package-name-that-will-test-formatting": "1.0.0", + "short": "1.0.0", + "another-package-with-a-very-long-name-to-test-column-alignment": "1.0.0", + }, + devDependencies: { + "dev-package": "1.0.0", + "super-long-dev-package-name-that-should-not-break-formatting": "1.0.0", + }, + peerDependencies: { + "peer-package": "1.0.0", + "extremely-long-peer-dependency-name-for-testing-column-alignment": "1.0.0", + }, + optionalDependencies: { + "optional-package": "1.0.0", + "very-long-optional-dependency-name-that-tests-formatting": "1.0.0", + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + "a": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "really-long-package-name-that-causes-formatting-issues": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "@org/extremely-long-scoped-package-name-that-will-test-formatting": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "short": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "another-package-with-a-very-long-name-to-test-column-alignment": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "dev-package": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "super-long-dev-package-name-that-should-not-break-formatting": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "peer-package": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "extremely-long-peer-dependency-name-for-testing-column-alignment": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "optional-package": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "very-long-optional-dependency-name-that-tests-formatting": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + }, + }), + }); + + // Mock outdated packages by creating fake manifests + const manifestsDir = join(dir, ".bun", "manifests"); + await Bun.write( + join(manifestsDir, "a.json"), + JSON.stringify({ + name: "a", + "dist-tags": { latest: "2.0.0" }, + versions: { + "1.0.0": { version: "1.0.0" }, + "2.0.0": { version: "2.0.0" }, + }, + }), + ); + + // Test that the command doesn't crash with unusual package name lengths + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(result.stdout).text(); + const stderr = await new Response(result.stderr).text(); + + // The command might fail due to missing manifests, but it shouldn't crash + // due to formatting issues + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("segfault"); + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("overflow"); + }); + + it("should handle version strings of unusual lengths", async () => { + const dir = tempDirWithFiles("update-interactive-versions-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "package-with-long-version": "1.0.0-alpha.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20", + "package-with-short-version": "1.0.0", + "package-with-prerelease": "1.0.0-beta.1+build.1234567890.abcdef", + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + "package-with-long-version": { + "integrity": "sha512-fake", + "version": "1.0.0-alpha.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20", + }, + "package-with-short-version": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + "package-with-prerelease": { + "integrity": "sha512-fake", + "version": "1.0.0-beta.1+build.1234567890.abcdef", + }, + }, + }), + }); + + // Test that the command doesn't crash with unusual version string lengths + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(result.stdout).text(); + const stderr = await new Response(result.stderr).text(); + + // The command might fail due to missing manifests, but it shouldn't crash + // due to formatting issues + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("segfault"); + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("overflow"); + }); + + it("should truncate extremely long package names", async () => { + const extremelyLongPackageName = "a".repeat(100); + const dir = tempDirWithFiles("update-interactive-truncate-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + [extremelyLongPackageName]: "1.0.0", + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + [extremelyLongPackageName]: { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + }, + }), + }); + + // Test that extremely long package names are handled gracefully + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(result.stdout).text(); + const stderr = await new Response(result.stderr).text(); + + // The command might fail due to missing manifests, but it shouldn't crash + // due to formatting issues + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("segfault"); + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("overflow"); + }); + + it("should handle mixed dependency types with various name lengths", async () => { + const dir = tempDirWithFiles("update-interactive-mixed-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "a": "1.0.0", + "really-long-dependency-name": "1.0.0", + }, + devDependencies: { + "b": "1.0.0", + "super-long-dev-dependency-name": "1.0.0", + }, + peerDependencies: { + "c": "1.0.0", + "extremely-long-peer-dependency-name": "1.0.0", + }, + optionalDependencies: { + "d": "1.0.0", + "very-long-optional-dependency-name": "1.0.0", + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + "a": { "integrity": "sha512-fake", "version": "1.0.0" }, + "really-long-dependency-name": { "integrity": "sha512-fake", "version": "1.0.0" }, + "b": { "integrity": "sha512-fake", "version": "1.0.0" }, + "super-long-dev-dependency-name": { "integrity": "sha512-fake", "version": "1.0.0" }, + "c": { "integrity": "sha512-fake", "version": "1.0.0" }, + "extremely-long-peer-dependency-name": { "integrity": "sha512-fake", "version": "1.0.0" }, + "d": { "integrity": "sha512-fake", "version": "1.0.0" }, + "very-long-optional-dependency-name": { "integrity": "sha512-fake", "version": "1.0.0" }, + }, + }), + }); + + // Test that mixed dependency types with various name lengths don't cause crashes + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(result.stdout).text(); + const stderr = await new Response(result.stderr).text(); + + // The command might fail due to missing manifests, but it shouldn't crash + // due to formatting issues + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("segfault"); + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("overflow"); + }); +}); diff --git a/test/regression/issue/update-interactive-formatting.test.ts b/test/regression/issue/update-interactive-formatting.test.ts new file mode 100644 index 0000000000..05bf8d8a82 --- /dev/null +++ b/test/regression/issue/update-interactive-formatting.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; + +describe("bun update --interactive formatting regression", () => { + it("should not underflow when dependency type text is longer than available space", async () => { + // This test verifies the fix for the padding calculation underflow issue + // in lines 745-750 of update_interactive_command.zig + const dir = tempDirWithFiles("formatting-regression-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "a": "1.0.0", // Very short package name + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + "a": { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + }, + }), + }); + + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await new Response(result.stderr).text(); + + // Verify no underflow errors occur + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("overflow"); + }); + + it("should handle dev tag length calculation correctly", async () => { + // This test verifies that dev/peer/optional tags are properly accounted for + // in the column width calculations + const dir = tempDirWithFiles("dev-tag-formatting-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "regular-package": "1.0.0", + }, + devDependencies: { + "dev-package": "1.0.0", + }, + peerDependencies: { + "peer-package": "1.0.0", + }, + optionalDependencies: { + "optional-package": "1.0.0", + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + "regular-package": { "integrity": "sha512-fake", "version": "1.0.0" }, + "dev-package": { "integrity": "sha512-fake", "version": "1.0.0" }, + "peer-package": { "integrity": "sha512-fake", "version": "1.0.0" }, + "optional-package": { "integrity": "sha512-fake", "version": "1.0.0" }, + }, + }), + }); + + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await new Response(result.stderr).text(); + + // Verify no formatting errors occur with dev tags + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("overflow"); + }); + + it("should truncate extremely long package names without crashing", async () => { + // This test verifies that package names longer than MAX_NAME_WIDTH (60) are handled + const longPackageName = "extremely-long-package-name-that-exceeds-maximum-width-and-should-be-truncated"; + const dir = tempDirWithFiles("truncate-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + [longPackageName]: "1.0.0", + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + [longPackageName]: { + "integrity": "sha512-fake", + "version": "1.0.0", + }, + }, + }), + }); + + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await new Response(result.stderr).text(); + + // Verify no crashes occur with extremely long package names + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("overflow"); + expect(stderr).not.toContain("segfault"); + }); + + it("should handle long version strings without formatting issues", async () => { + // This test verifies that version strings longer than MAX_VERSION_WIDTH (20) are handled + const longVersion = "1.0.0-alpha.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25"; + const dir = tempDirWithFiles("long-version-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "package-with-long-version": longVersion, + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + "package-with-long-version": { + "integrity": "sha512-fake", + "version": longVersion, + }, + }, + }), + }); + + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await new Response(result.stderr).text(); + + // Verify no crashes occur with extremely long version strings + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("overflow"); + expect(stderr).not.toContain("segfault"); + }); + + it("should handle edge case where all values are at maximum width", async () => { + // This test verifies edge cases where padding calculations might fail + const maxWidthPackage = "a".repeat(60); // MAX_NAME_WIDTH + const maxWidthVersion = "1.0.0-" + "a".repeat(15); // MAX_VERSION_WIDTH + + const dir = tempDirWithFiles("max-width-test", { + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + [maxWidthPackage]: maxWidthVersion, + }, + devDependencies: { + [maxWidthPackage + "-dev"]: maxWidthVersion, + }, + peerDependencies: { + [maxWidthPackage + "-peer"]: maxWidthVersion, + }, + optionalDependencies: { + [maxWidthPackage + "-optional"]: maxWidthVersion, + }, + }), + "bun.lockb": JSON.stringify({ + "lockfileVersion": 3, + "packages": { + [maxWidthPackage]: { "integrity": "sha512-fake", "version": maxWidthVersion }, + [maxWidthPackage + "-dev"]: { "integrity": "sha512-fake", "version": maxWidthVersion }, + [maxWidthPackage + "-peer"]: { "integrity": "sha512-fake", "version": maxWidthVersion }, + [maxWidthPackage + "-optional"]: { "integrity": "sha512-fake", "version": maxWidthVersion }, + }, + }), + }); + + const result = await Bun.spawn({ + cmd: [bunExe(), "update", "--interactive", "--dry-run"], + cwd: dir, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await new Response(result.stderr).text(); + + // Verify no crashes occur at maximum width values + expect(stderr).not.toContain("underflow"); + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("overflow"); + expect(stderr).not.toContain("segfault"); + }); +});