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
10 changes: 5 additions & 5 deletions completions/bun-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2492,7 +2492,7 @@
"completionType": "package"
}
],
"examples": ["bun link", "bun link <package>"],
"examples": ["bun link", "bun link <package>", "bun link --save <package>"],
"usage": "Usage: bun link [flags] [<packages>]",
"documentationUrl": "https://bun.com/docs/cli/link.",
"dynamicCompletions": {}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/pm/cli/link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
{
Expand Down
6 changes: 3 additions & 3 deletions docs/snippets/cli/link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ bun link <packages>
</ParamField>

<ParamField path="--no-save" type="boolean">
Don't update <code>package.json</code> or save a lockfile
Don't update <code>package.json</code> or save a lockfile (the default)
</ParamField>

<ParamField path="--save" type="boolean" default="true">
Save to <code>package.json</code>
<ParamField path="--save" type="boolean">
Update <code>package.json</code> and save a lockfile (false by default)
</ParamField>

<ParamField path="--trust" type="boolean">
Expand Down
43 changes: 35 additions & 8 deletions src/install/PackageManager/CommandLineArguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <STR>... Provide a Certificate Authority signing certificate"
),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -165,6 +189,7 @@ pub(crate) static UPDATE_PARAMS: &[ParamType] = concat_params![
),
clap::param!("-P, --prod"),
],
SAVE_PARAMS,
SHARED_TAIL_PARAMS,
&[
clap::param!(
Expand Down Expand Up @@ -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!(
"<POS> ... \"name\" install package as a link"
),]
];

pub(crate) static UNLINK_PARAMS: &[ParamType] = concat_params![
SHARED_PARAMS,
LINK_SHARED_PARAMS,
&[clap::param!(
"<POS> ... \"name\" uninstall package as a link"
),]
Expand Down Expand Up @@ -905,9 +930,12 @@ Full documentation is available at <magenta>https://bun.com/docs/cli/remove<r>.
<d>Directory should contain a package.json.<r>
<b><green>bun link<r>

<d>Add a previously-registered linkable package as a dependency of the current project.<r>
<d>Link a previously-registered linkable package into the current project's node_modules.<r>
<b><green>bun link<r> <blue>\<package\><r>

<d>Also add it to the current project's package.json as a link: dependency.<r>
<b><green>bun link<r> <cyan>--save<r> <blue>\<package\><r>

Full documentation is available at <magenta>https://bun.com/docs/cli/link<r>.
";
pretty_help(intro_text);
Expand Down Expand Up @@ -1420,8 +1448,7 @@ Full documentation is available at <magenta>https://bun.com/docs/pm/cli/prune<r>
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 (see `LINK_SAVE_PARAMS`), all others to saving.
if matches!(subcommand, Subcommand::Link | Subcommand::Unlink) {
cli.no_save = !args.flag(b"--save");
} else {
Expand Down
10 changes: 10 additions & 0 deletions test/cli/bun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package>$/m,
/^ {2}Also add it to the current project's package\.json as a link: dependency\.\n {2}bun link --save <package>$/m,
],
],
[
"bun audit --help",
["audit"],
Expand Down
96 changes: 96 additions & 0 deletions test/cli/install/bun-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isWindows,
readdirSorted,
runBunInstall,
tempDir,
tmpdirSync,
toBeValidBin,
toHaveBins,
Expand Down Expand Up @@ -471,3 +472,98 @@ 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 <package>` 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, 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) =>
lines
.find(line => line.startsWith(`${flag} `))
?.slice(flag.length)
.trim();
expect({ "--save": descriptionOf("--save"), "--no-save": descriptionOf("--no-save"), stderr, exitCode }).toEqual({
"--save": save,
"--no-save": noSave,
stderr: "",
exitCode: 0,
});
});

it("bun link <package> 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"]);
});