From 07625c7385cfd7f2bcf65ee2ef7cf2c824251769 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:39:27 +0000 Subject: [PATCH 1/4] install: describe the real --save default in bun link and bun unlink --help The install family prints one shared flag table, whose --save entry says "true by default". CommandLineArguments::parse inverts the default for link and unlink: they only write package.json and the lockfile when --save is passed. Give those two subcommands their own --save/--no-save entries, the way update overrides --production, and update the docs snippet and completions copies of the text to match. --- completions/bun-cli.json | 8 +- docs/snippets/cli/link.mdx | 6 +- .../PackageManager/CommandLineArguments.rs | 39 ++++++-- test/cli/install/bun-link.test.ts | 95 +++++++++++++++++++ 4 files changed, 134 insertions(+), 14 deletions(-) diff --git a/completions/bun-cli.json b/completions/bun-cli.json index 5bcf1c989fbd..7d94491a2df8 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -2269,14 +2269,14 @@ }, { "name": "no-save", - "description": "Don't update package.json or save a lockfile", + "description": "Don't update package.json or save a lockfile (the default)", "hasValue": false, "required": false, "multiple": false }, { "name": "save", - "description": "Save to package.json (true by default)", + "description": "Update package.json and save a lockfile (false by default)", "hasValue": false, "required": false, "multiple": false @@ -2528,14 +2528,14 @@ }, { "name": "no-save", - "description": "Don't update package.json or save a lockfile", + "description": "Don't update package.json or save a lockfile (the default)", "hasValue": false, "required": false, "multiple": false }, { "name": "save", - "description": "Save to package.json (true by default)", + "description": "Update package.json and save a lockfile (false by default)", "hasValue": false, "required": false, "multiple": false diff --git a/docs/snippets/cli/link.mdx b/docs/snippets/cli/link.mdx index dd125b9cc34e..218868409cd6 100644 --- a/docs/snippets/cli/link.mdx +++ b/docs/snippets/cli/link.mdx @@ -39,11 +39,11 @@ bun link - Don't update package.json or save a lockfile + Don't update package.json or save a lockfile (the default) - - Save to package.json + + Update package.json and save a lockfile (false by default) diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index be895af25bf1..8fb52407b91e 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -60,11 +60,24 @@ const PRODUCTION_PARAMS: &[ParamType] = &[ clap::param!("-P, --prod"), ]; -const SHARED_TAIL_PARAMS: &[ParamType] = &[ +const SAVE_PARAMS: &[ParamType] = &[ clap::param!( "--no-save Don't update package.json or save a lockfile" ), clap::param!("--save Save to package.json (true by default)"), +]; + +// `parse` gives link and unlink the opposite default: they only save when --save is passed. +const LINK_SAVE_PARAMS: &[ParamType] = &[ + clap::param!( + "--no-save Don't update package.json or save a lockfile (the default)" + ), + clap::param!( + "--save Update package.json and save a lockfile (false by default)" + ), +]; + +const SHARED_TAIL_PARAMS: &[ParamType] = &[ clap::param!( "--ca ... Provide a Certificate Authority signing certificate" ), @@ -128,8 +141,19 @@ const SHARED_TAIL_PARAMS: &[ParamType] = &[ clap::param!("-h, --help Print this help menu"), ]; -const SHARED_PARAMS: &[ParamType] = - concat_params![SHARED_HEAD_PARAMS, PRODUCTION_PARAMS, SHARED_TAIL_PARAMS]; +const SHARED_PARAMS: &[ParamType] = concat_params![ + SHARED_HEAD_PARAMS, + PRODUCTION_PARAMS, + SAVE_PARAMS, + SHARED_TAIL_PARAMS +]; + +const LINK_SHARED_PARAMS: &[ParamType] = concat_params![ + SHARED_HEAD_PARAMS, + PRODUCTION_PARAMS, + LINK_SAVE_PARAMS, + SHARED_TAIL_PARAMS +]; pub(crate) static INSTALL_PARAMS: &[ParamType] = concat_params![ SHARED_PARAMS, @@ -165,6 +189,7 @@ pub(crate) static UPDATE_PARAMS: &[ParamType] = concat_params![ ), clap::param!("-P, --prod"), ], + SAVE_PARAMS, SHARED_TAIL_PARAMS, &[ clap::param!( @@ -269,14 +294,14 @@ pub(crate) static REMOVE_PARAMS: &[ParamType] = concat_params![ ]; pub(crate) static LINK_PARAMS: &[ParamType] = concat_params![ - SHARED_PARAMS, + LINK_SHARED_PARAMS, &[clap::param!( " ... \"name\" install package as a link" ),] ]; pub(crate) static UNLINK_PARAMS: &[ParamType] = concat_params![ - SHARED_PARAMS, + LINK_SHARED_PARAMS, &[clap::param!( " ... \"name\" uninstall package as a link" ),] @@ -1420,8 +1445,8 @@ Full documentation is available at https://bun.com/docs/pm/cli/prune cli.tolerate_republish = args.flag(b"--tolerate-republish"); } - // link and unlink default to not saving, all others default to - // saving. + // link and unlink default to not saving (their --help says so via + // `LINK_SAVE_PARAMS`), all others default to saving. if matches!(subcommand, Subcommand::Link | Subcommand::Unlink) { cli.no_save = !args.flag(b"--save"); } else { diff --git a/test/cli/install/bun-link.test.ts b/test/cli/install/bun-link.test.ts index 8a937dad63fd..9c9ff11e10d9 100644 --- a/test/cli/install/bun-link.test.ts +++ b/test/cli/install/bun-link.test.ts @@ -7,6 +7,7 @@ import { isWindows, readdirSorted, runBunInstall, + tempDir, tmpdirSync, toBeValidBin, toHaveBins, @@ -471,3 +472,97 @@ it("should link dependency without crashing", async () => { // This should fail with a non-zero exit code. expect(await exited4).toBe(1); }); + +// The install family shares one flag table, but `bun link ` and `bun unlink` invert the +// --save default (CommandLineArguments.parse): they only touch package.json and the lockfile when +// --save is passed, so their --help has to describe the flag differently from bun install's. +it.each([ + ["install", "Save to package.json (true by default)", "Don't update package.json or save a lockfile"], + ["add", "Save to package.json (true by default)", "Don't update package.json or save a lockfile"], + ["update", "Save to package.json (true by default)", "Don't update package.json or save a lockfile"], + ["remove", "Save to package.json (true by default)", "Don't update package.json or save a lockfile"], + [ + "link", + "Update package.json and save a lockfile (false by default)", + "Don't update package.json or save a lockfile (the default)", + ], + [ + "unlink", + "Update package.json and save a lockfile (false by default)", + "Don't update package.json or save a lockfile (the default)", + ], +])("bun %s --help describes the --save default it actually has", async (subcommand, save, noSave) => { + await using proc = spawn({ + cmd: [bunExe(), subcommand, "--help"], + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + const lines = stdout.split(/\r?\n/).map(line => line.trim()); + const descriptionOf = (flag: string) => + lines + .find(line => line.startsWith(`${flag} `)) + ?.slice(flag.length) + .trim(); + expect({ "--save": descriptionOf("--save"), "--no-save": descriptionOf("--no-save") }).toEqual({ + "--save": save, + "--no-save": noSave, + }); + expect(exitCode).toBe(0); +}); + +it("bun link only writes package.json and a lockfile when --save is passed", async () => { + const appPackageJson = JSON.stringify({ name: "app", version: "1.0.0" }); + using dir = tempDir("bun-link-save", { + "lib/package.json": JSON.stringify({ name: "lib-to-link", version: "1.0.0" }), + "app/package.json": appPackageJson, + }); + const app = join(String(dir), "app"); + // Register the package in a global dir private to this test instead of the machine's real one. + const linkEnv = { + ...env, + BUN_INSTALL_GLOBAL_DIR: join(String(dir), "global", "install", "global"), + BUN_INSTALL_BIN: join(String(dir), "global", "bin"), + }; + async function bun(cwd: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd, + env: linkEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + let { stdout, stderr, exitCode } = await bun(join(String(dir), "lib"), "link"); + expect(stderr).toBe(""); + expect(stdout).toContain('Success! Registered "lib-to-link"'); + expect(exitCode).toBe(0); + + ({ stdout, stderr, exitCode } = await bun(app, "link", "lib-to-link")); + expect(stderr).toBe(""); + expect(stdout).toContain("installed lib-to-link@link:lib-to-link"); + expect(exitCode).toBe(0); + expect(await file(join(app, "node_modules", "lib-to-link", "package.json")).json()).toEqual({ + name: "lib-to-link", + version: "1.0.0", + }); + expect(await file(join(app, "package.json")).text()).toBe(appPackageJson); + expect(await readdirSorted(app)).toEqual(["node_modules", "package.json"]); + + ({ stdout, stderr, exitCode } = await bun(app, "link", "lib-to-link", "--save")); + expect(stderr).toContain("Saved lockfile"); + expect(stdout).toContain("installed lib-to-link@link:lib-to-link"); + expect(exitCode).toBe(0); + expect(await file(join(app, "package.json")).json()).toEqual({ + name: "app", + version: "1.0.0", + dependencies: { "lib-to-link": "link:lib-to-link" }, + }); + expect(await readdirSorted(app)).toEqual(["bun.lock", "node_modules", "package.json"]); +}); From 4cfe9edfb07480379a913f3b3d58720a7c971a56 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:47:02 +0000 Subject: [PATCH 2/4] test: drain and assert stderr in the --help save-default test --- test/cli/install/bun-link.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/cli/install/bun-link.test.ts b/test/cli/install/bun-link.test.ts index 9c9ff11e10d9..4d007637de03 100644 --- a/test/cli/install/bun-link.test.ts +++ b/test/cli/install/bun-link.test.ts @@ -498,7 +498,7 @@ it.each([ stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const lines = stdout.split(/\r?\n/).map(line => line.trim()); const descriptionOf = (flag: string) => @@ -506,11 +506,12 @@ it.each([ .find(line => line.startsWith(`${flag} `)) ?.slice(flag.length) .trim(); - expect({ "--save": descriptionOf("--save"), "--no-save": descriptionOf("--no-save") }).toEqual({ + expect({ "--save": descriptionOf("--save"), "--no-save": descriptionOf("--no-save"), stderr, exitCode }).toEqual({ "--save": save, "--no-save": noSave, + stderr: "", + exitCode: 0, }); - expect(exitCode).toBe(0); }); it("bun link only writes package.json and a lockfile when --save is passed", async () => { From 3d9240d611e1043e68fca09603f9abe1e029033e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:49:15 +0000 Subject: [PATCH 3/4] install: shorten the no_save comment in CommandLineArguments::parse --- src/install/PackageManager/CommandLineArguments.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index 8fb52407b91e..2fe01e9ddff6 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -1445,8 +1445,7 @@ Full documentation is available at https://bun.com/docs/pm/cli/prune cli.tolerate_republish = args.flag(b"--tolerate-republish"); } - // link and unlink default to not saving (their --help says so via - // `LINK_SAVE_PARAMS`), all others default to saving. + // link and unlink default to not saving (see `LINK_SAVE_PARAMS`), all others to saving. if matches!(subcommand, Subcommand::Link | Subcommand::Unlink) { cli.no_save = !args.flag(b"--save"); } else { From 71ce65ccfb7943a292de34042e220e99bd80bcf4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:31:11 +0000 Subject: [PATCH 4/4] install: stop describing bare bun link as adding a dependency in --help The Examples block of bun link --help said the bare form adds the package as a dependency of the project, which is what --save does. Describe the bare form as linking into node_modules, add a --save example, mirror the new example in the completions data and the docs, and pin the block in the existing --help examples table. --- completions/bun-cli.json | 2 +- docs/pm/cli/link.mdx | 6 +++++- src/install/PackageManager/CommandLineArguments.rs | 5 ++++- test/cli/bun.test.ts | 10 ++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/completions/bun-cli.json b/completions/bun-cli.json index 7d94491a2df8..4aa423371ea1 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -2492,7 +2492,7 @@ "completionType": "package" } ], - "examples": ["bun link", "bun link "], + "examples": ["bun link", "bun link ", "bun link --save "], "usage": "Usage: bun link [flags] []", "documentationUrl": "https://bun.com/docs/cli/link.", "dynamicCompletions": {} diff --git a/docs/pm/cli/link.mdx b/docs/pm/cli/link.mdx index 246b17275992..9f0d5d1040b7 100644 --- a/docs/pm/cli/link.mdx +++ b/docs/pm/cli/link.mdx @@ -31,7 +31,11 @@ cd /path/to/my-app bun link cool-pkg ``` -The `--save` flag also adds `cool-pkg` to the `dependencies` field of your app's package.json, with a version specifier that tells Bun to load from the registered local directory instead of installing from `npm`: +Pass `--save` to also add `cool-pkg` to the `dependencies` field of your app's package.json, with a version specifier that tells Bun to load from the registered local directory instead of installing from `npm`: + +```bash terminal icon="terminal" +bun link --save cool-pkg +``` ```json package.json icon="file-json" { diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index 2fe01e9ddff6..1da710a32fea 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -930,9 +930,12 @@ Full documentation is available at https://bun.com/docs/cli/remove. Directory should contain a package.json. bun link - Add a previously-registered linkable package as a dependency of the current project. + Link a previously-registered linkable package into the current project's node_modules. bun link \ + Also add it to the current project's package.json as a link: dependency. + bun link --save \ + Full documentation is available at https://bun.com/docs/cli/link. "; pretty_help(intro_text); diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index fbb74a3b51fb..b9ecd7bffaac 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -212,6 +212,16 @@ describe("bun", () => { /^ {2}Add to the workspace catalog instead of pinning a version\n {2}bun add --catalog react\n {2}bun add --catalog=testing vitest$/m, ], ], + // The bare form only installs the symlink; package.json is written with --save (see the --save + // flag row), so the examples must say which form does what. + [ + "bun link --help", + ["link"], + [ + /^ {2}Link a previously-registered linkable package into the current project's node_modules\.\n {2}bun link $/m, + /^ {2}Also add it to the current project's package\.json as a link: dependency\.\n {2}bun link --save $/m, + ], + ], [ "bun audit --help", ["audit"],