Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/install/lockfile.zig
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,81 @@ pub fn cleanWithLogger(
}
}

// Handle `bun update --latest` without specific packages:
// Update dependency version literals from "latest" to actual resolved versions
// so the lockfile records e.g. "^19.0.0" instead of "latest".
if (manager.subcommand == .update and manager.options.do.update_to_latest and updates.len == 0 and manager.updating_packages.count() > 0) {
const slice = new.packages.slice();
const resolutions_all = slice.items(.resolution);
const workspace_package_id = manager.root_package_id.get(new, manager.workspace_name_hash);
const dep_list = slice.items(.dependencies)[workspace_package_id];
const res_list = slice.items(.resolutions)[workspace_package_id];
const workspace_deps = dep_list.mut(new.buffers.dependencies.items);
const resolved_ids = res_list.get(new.buffers.resolutions.items);
var string_buf_alloc = new.stringBuf();

for (workspace_deps, resolved_ids) |*ws_dep, package_id| {
if (package_id == invalid_package_id) continue;

const resolution = resolutions_all[package_id];
if (resolution.tag != .npm) continue;

// Re-read string_buf each iteration since appends may reallocate
const cur_string_buf = new.buffers.string_bytes.items;
const dep_name = ws_dep.name.slice(cur_string_buf);
const entry = manager.updating_packages.get(dep_name) orelse continue;

const version_fmt = resolution.value.npm.version.fmt(cur_string_buf);
var temp_buf: [513]u8 = undefined;
const new_version = new_version: {
if (exact_versions) {
break :new_version std.fmt.bufPrint(&temp_buf, "{f}", .{version_fmt}) catch continue;
}

const version_literal = version_literal: {
if (!entry.is_alias) break :version_literal entry.original_version_literal;
if (strings.lastIndexOfChar(entry.original_version_literal, '@')) |at_index| {
break :version_literal entry.original_version_literal[at_index + 1 ..];
}
break :version_literal entry.original_version_literal;
};

const pinned_version = Semver.Version.whichVersionIsPinned(version_literal);
break :new_version switch (pinned_version) {
.patch => std.fmt.bufPrint(&temp_buf, "{f}", .{version_fmt}) catch continue,
.minor => std.fmt.bufPrint(&temp_buf, "~{f}", .{version_fmt}) catch continue,
.major => std.fmt.bufPrint(&temp_buf, "^{f}", .{version_fmt}) catch continue,
};
};

var alias_tmp: [1025]u8 = undefined;
const full_version = if (entry.is_alias) full: {
const alias_string_buf = new.buffers.string_bytes.items;
const dep_literal = ws_dep.version.literal.slice(alias_string_buf);
if (strings.lastIndexOfChar(dep_literal, '@')) |at_index| {
break :full std.fmt.bufPrint(&alias_tmp, "{s}@{s}", .{
dep_literal[0..at_index],
new_version,
}) catch continue;
}
break :full new_version;
} else new_version;

const appended = try string_buf_alloc.append(full_version);
// Re-read string_bytes after append (may have reallocated)
const sliced = appended.sliced(new.buffers.string_bytes.items);
ws_dep.version = Dependency.parse(
new.allocator,
ws_dep.name,
ws_dep.name_hash,
sliced.slice,
&sliced,
null,
manager,
) orelse Dependency.Version{};
}
}

if (log_level.isVerbose()) {
Output.prettyErrorln("Clean lockfile: {d} packages -> {d} packages in {f}\n", .{
old.packages.len,
Expand Down
105 changes: 105 additions & 0 deletions test/cli/install/bun-install-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4979,6 +4979,111 @@ describe("update", () => {
},
});
});
test("update --latest without packages records resolved versions in lockfile", async () => {
// Override bunfig to enable text lockfile for easy inspection
await registry.writeBunfig(packageDir, { saveTextLockfile: true, linker: "hoisted" });

await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: {
"no-deps": "latest",
},
}),
);

await runBunInstall(env, packageDir);
assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl());

expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
name: "no-deps",
version: "2.0.0",
});

// bun update --latest without specifying packages should update the lockfile
// version literal from "latest" to the actual resolved version (e.g. "^2.0.0")
await runBunUpdate(env, packageDir, ["--latest"]);
assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl());

expect(await file(packageJson).json()).toEqual({
name: "foo",
dependencies: {
"no-deps": "^2.0.0",
},
});

// Verify the text lockfile also has the resolved version, not "latest"
const lockfileText = await Bun.file(join(packageDir, "bun.lock")).text();
const lockfileJson = JSON.parse(lockfileText);
expect(lockfileJson.workspaces[""].dependencies["no-deps"]).toBe("^2.0.0");
});
test("update --latest without packages preserves version pinning in lockfile", async () => {
await registry.writeBunfig(packageDir, { saveTextLockfile: true, linker: "hoisted" });

await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: {
"a-dep": "~1.0.1",
"no-deps": "^1.0.0",
},
}),
);

await runBunInstall(env, packageDir);
assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl());

await runBunUpdate(env, packageDir, ["--latest"]);
assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl());

// package.json should preserve the pinning style
expect(await file(packageJson).json()).toEqual({
name: "foo",
dependencies: {
"a-dep": "~1.0.10",
"no-deps": "^2.0.0",
},
});

// Verify the lockfile also has the actual resolved versions, not "latest"
const lockfileText = await Bun.file(join(packageDir, "bun.lock")).text();
const lockfileJson = JSON.parse(lockfileText);
const deps = lockfileJson.workspaces[""].dependencies;
expect(deps["a-dep"]).toBe("~1.0.10");
expect(deps["no-deps"]).toBe("^2.0.0");
});
test("update --latest without packages handles aliases in lockfile", async () => {
await registry.writeBunfig(packageDir, { saveTextLockfile: true, linker: "hoisted" });

await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: {
"aliased": "npm:no-deps@latest",
},
}),
);

await runBunInstall(env, packageDir);
assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl());

await runBunUpdate(env, packageDir, ["--latest"]);
assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl());

expect(await file(packageJson).json()).toEqual({
name: "foo",
dependencies: {
"aliased": "npm:no-deps@^2.0.0",
},
});

const lockfileText = await Bun.file(join(packageDir, "bun.lock")).text();
const lockfileJson = JSON.parse(lockfileText);
expect(lockfileJson.workspaces[""].dependencies["aliased"]).toBe("npm:no-deps@^2.0.0");
});
test("exact versions stay exact", async () => {
const runs = [
{ version: "1.0.1", dependency: "a-dep" },
Expand Down