├── .github
├── logo-uncropped.png
├── logo.png
├── logo.webp
└── workflows
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .nvmrc
├── README.md
├── package.json
├── pnpm-lock.yaml
├── src
├── cli.ts
├── commands
│ └── publish
│ │ ├── hardlink-package.ts
│ │ ├── index.ts
│ │ └── link-publish-mode.ts
├── link-package
│ ├── index.ts
│ ├── link-binaries.ts
│ └── symlink-package.ts
├── types.ts
└── utils
│ ├── cwd-path.ts
│ ├── fs-exists.ts
│ ├── get-npm-packlist.ts
│ ├── load-config.ts
│ ├── read-json-file.ts
│ ├── read-package-json.ts
│ └── symlink.ts
├── tests
├── fixtures
│ ├── nested
│ │ └── package-deep-link
│ │ │ ├── index.js
│ │ │ ├── link.config.json
│ │ │ └── package.json
│ ├── package-binary
│ │ ├── binary.js
│ │ ├── index.js
│ │ └── package.json
│ ├── package-entry
│ │ ├── index.js
│ │ └── package.json
│ ├── package-files
│ │ ├── lib
│ │ │ └── index.js
│ │ ├── non-publish-file.js
│ │ └── package.json
│ └── package-scoped
│ │ ├── cli.js
│ │ ├── file.js
│ │ └── package.json
├── index.ts
├── specs
│ ├── cli.spec.ts
│ ├── link-config.spec.ts
│ └── publish.spec.ts
└── utils
│ ├── link.ts
│ └── npm-pack.ts
└── tsconfig.json
/.github/logo-uncropped.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/privatenumber/link/288fc82f026560ca0b84387a0e3e485939045791/.github/logo-uncropped.png
--------------------------------------------------------------------------------
/.github/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/privatenumber/link/288fc82f026560ca0b84387a0e3e485939045791/.github/logo.png
--------------------------------------------------------------------------------
/.github/logo.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/privatenumber/link/288fc82f026560ca0b84387a0e3e485939045791/.github/logo.webp
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches: [master, next]
6 |
7 | jobs:
8 | release:
9 | name: Release
10 | runs-on: ubuntu-latest
11 | timeout-minutes: 10
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v4
15 |
16 | - name: Setup Node.js
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version-file: .nvmrc
20 |
21 | - name: Setup pnpm
22 | uses: pnpm/action-setup@v4
23 | with:
24 | run_install: true
25 |
26 | - name: Release
27 | env:
28 | GH_TOKEN: ${{ secrets.GH_TOKEN }}
29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
30 | run: pnpm dlx semantic-release
31 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on:
3 | push:
4 | branches: [develop]
5 | pull_request:
6 | branches: [master, develop, next]
7 | jobs:
8 | test:
9 | name: Test
10 | runs-on: ${{ matrix.os }}
11 | strategy:
12 | matrix:
13 | os: [ubuntu-latest, windows-latest]
14 | timeout-minutes: 10
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup Node.js
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version-file: .nvmrc
23 |
24 | - name: Setup pnpm
25 | uses: pnpm/action-setup@v4
26 | with:
27 | run_install: true
28 |
29 | - name: Build
30 | run: pnpm build
31 |
32 | - name: Test
33 | run: pnpm test
34 |
35 | - name: Lint
36 | if: ${{ matrix.os == 'ubuntu-latest' }}
37 | run: pnpm lint
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # macOS
2 | .DS_Store
3 |
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 |
12 | # Dependency directories
13 | node_modules/
14 |
15 | # Output of 'npm pack'
16 | *.tgz
17 |
18 | # dotenv environment variables file
19 | .env
20 | .env.test
21 |
22 | # VSCode
23 | .vscode
24 |
25 | # Cache
26 | .eslintcache
27 |
28 | # Distribution
29 | dist
30 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20.13.0
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | npx link
6 |
7 |
8 |
9 |
10 | A safer and enhanced version of [`npm link`](https://docs.npmjs.com/cli/v8/commands/npm-link).
11 |
12 | Why is `npm link` unsafe? Read the [blog post](https://hirok.io/posts/avoid-npm-link).
13 |
14 | ### Features
15 | - 🔗 Link dependencies without removing previous links
16 | - 🛡 Only resolves to local paths
17 | - 🔥 Config file quickly linking multiple packages
18 | - 💫 Deep linking for quickling linking multilple packages
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Already a sponsor? Join the discussion in the Development repo!
27 |
28 | ## Terminology
29 |
30 | - **Dependency package**
31 |
32 | The package getting linked. This is usually a library.
33 |
34 | - **Consuming package**
35 |
36 | The project you want to link the _Dependency package_ as a dependency of. This is usually an application.
37 |
38 | `consuming-package/node_modules/dependency-package` → `dependency-package`
39 |
40 |
41 | ## Usage
42 |
43 | ### Linking a package
44 |
45 | From the _Consuming package_ directory, link the _Dependency package_:
46 |
47 | ```sh
48 | npx link
49 | ```
50 |
51 | This creates a symbolic link inside the `node_modules` of _Consuming package_, referencing the _Dependency package_.
52 |
53 |
54 | > **🛡️ Secure linking**
55 | >
56 | > Unlike `npm link`, it doesn't install the _Dependency package_ globally or re-install project dependencies.
57 |
58 | ### Publish mode
59 |
60 | Using symbolic links may not replicate the exact environment you get from a standard `npm install`. This discrepancy primarily arises from symlinked packages retaining their development `node_modules` directory. This can lead to issues, especially when multiple packages depend on the same library.
61 |
62 |
63 | Here's an example
64 |
65 |
66 | In a production environment, `npm install` detects common dependencies and installs only one instance of a shared dependency. However, when there's a symbolic link to the development directory of a dependency, separate copies of those dependencies are resolved from the development `node_modules`.
67 |
68 | Let's say there's an _App A_ with a dependency on _Package B_, and they both depend on _Library C_:
69 |
70 | - Production environment
71 |
72 | `npm install` detects that both _App A_ and _Package B_ depends on _Library C_, and only installs one copy of _Library C_ for them to share.
73 |
74 | - Symbolic link environment
75 |
76 | _App A_ has its copy of _Library C_, and _Package B_ also has its development copy of _Library C_—possibly with different versions. Consequently, when you run the application, it will load two different versions of _Library C_, leading to unexpected outcomes.
77 |
78 |
79 |
80 | _Publish mode_ helps replicate the production environment in your development setup.
81 |
82 | #### Setup instructions
83 |
84 | 1. In the _Dependency package_, run `npm pack` to create a tarball:
85 |
86 | ```sh
87 | cd dependency-package-path
88 | npm pack
89 | ```
90 |
91 | This generates a tarball (`.tgz`) file in the current directory. Installing from this simulates the conditions of a published package without actually publishing it.
92 |
93 | > **Tip:** You can skip this step if this dependency is already installed from npm and there are no changes to the dependency's `package.json`
94 |
95 | 2. In the _Consuming package_
96 |
97 | 1. Install the Dependency tarball from _Step 1_
98 |
99 | ```sh
100 | npm install --no-save
101 | ```
102 |
103 | This sets up the same `node_modules` tree used in a production environment.
104 |
105 | 2. Link the _Dependency package_
106 |
107 | ```sh
108 | npx link publish
109 | ```
110 |
111 | This creates hard links in `node_modules/dependency` to the specific publish assets of the _Dependency package_.
112 |
113 |
114 | Why hard links instead of symbolic links?
115 |
116 |
117 | Another issue with the symlink approach is that Node.js, and popular bundlers, looks up the `node_module` directory relative to a module's realpath rather than the import path (symlink path). By using hard links, we can prevent this behavior and ensure that the `node_modules` directory is resolved using the production tree we set up in _Step 2_.
118 |
119 |
120 | 4. Start developing!
121 |
122 | Any changes you make to the _Dependency package_ will be reflected in the `node_modules` directory of the _Consuming package_.
123 |
124 | > **Note:** If the _Dependency package_ emits new files, you'll need to re-run `npx link publish ` to create new hard links.
125 |
126 | ### Configuration file
127 |
128 | Create a `link.config.json` (or `link.config.js`) configuration file at the root of the _Consuming package_ to automatically setup links to multiple _Dependency packages_.
129 |
130 | Example _link.config.json_:
131 | ```json5
132 | {
133 | "packages": [
134 | "/path/to/dependency-path-a",
135 | "../dependency-path-b"
136 | ]
137 | }
138 | ```
139 |
140 | The configuration has the following type schema:
141 | ```ts
142 | type LinkConfig = {
143 |
144 | // Whether to run `npx link` on dependency packages with link.config.json
145 | deepLink?: boolean
146 |
147 | // List of dependency packages to link
148 | packages?: string[]
149 | }
150 | ```
151 |
152 | > **Note:** It's not recommended to commit this file to source control since this is for local development with local paths.
153 |
154 |
155 | To link the dependencies defined in `link.config.json`, run:
156 | ```sh
157 | npx link
158 | ```
159 |
160 | ### Deep linking
161 |
162 | By default, `npx link` only links packages in the _Consuming package_. However, there are cases where the _Dependency packages_ also needs linking setup.
163 |
164 | Deep linking recursively runs link on every linked dependency that has a `link.config.json` file.
165 |
166 | Enable with the `--deep` flag or `deepLink` property in `link.config.json`.
167 |
168 | ```sh
169 | npx link --deep
170 | ```
171 |
172 | ## FAQ
173 |
174 | ### Why should I use `npx link` over `npm link`?
175 | Because `npm link` [is complicated and dangerous to use](https://hirok.io/posts/avoid-npm-link). And `npx link` offers more features such as _Publish mode_.
176 |
177 | ### How do I remove the links?
178 | Run `npm install` and it should remove them.
179 |
180 | `npm install` enforces the integrity of `node_modules` by making sure all packages are correctly installed. Reverting the links is a side effect of this.
181 |
182 | ### Why does `npx link` point to `ln`?
183 |
184 | You must use npx v7 or higher. Check the version with `npx -v`.
185 |
186 | In the obsolete npx v6, local binaries take precedence over npm modules so `npx link` can point to the native `link`/`ln` command:
187 | ```
188 | $ npx link
189 | usage: ln [-s [-F] | -L | -P] [-f | -i] [-hnv] source_file [target_file]
190 | ln [-s [-F] | -L | -P] [-f | -i] [-hnv] source_file ... target_dir
191 | link source_file target_file
192 | ```
193 |
194 | To work around this, install `link` globally first:
195 | ```sh
196 | $ npm i -g link
197 | $ npx link
198 | ```
199 |
200 | ## Related
201 |
202 | - [`npx ci`](https://github.com/privatenumber/ci) - A better `npm ci`.
203 |
204 |
205 | ## Sponsors
206 |
207 |
208 |
209 |
210 |
211 |
212 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "link",
3 | "version": "0.0.0-semantic-release",
4 | "description": "A better npm link",
5 | "keywords": [
6 | "npm",
7 | "link",
8 | "symlink"
9 | ],
10 | "license": "MIT",
11 | "repository": "privatenumber/link",
12 | "funding": "https://github.com/privatenumber/link?sponsor=1",
13 | "author": {
14 | "name": "Hiroki Osame",
15 | "email": "hiroki.osame@gmail.com"
16 | },
17 | "files": [
18 | "dist"
19 | ],
20 | "bin": "dist/cli.js",
21 | "packageManager": "pnpm@9.4.0",
22 | "scripts": {
23 | "build": "pkgroll --minify",
24 | "test": "tsx tests/index.ts",
25 | "dev": "tsx watch tests/index.ts",
26 | "lint": "lintroll --cache --ignore-pattern=tests/fixtures .",
27 | "type-check": "tsc",
28 | "prepack": "pnpm build && clean-pkg-json"
29 | },
30 | "devDependencies": {
31 | "@types/cmd-shim": "^5.0.2",
32 | "@types/node": "^20.14.14",
33 | "@types/npm-packlist": "^7.0.3",
34 | "@types/npmcli__package-json": "^4.0.4",
35 | "clean-pkg-json": "^1.2.0",
36 | "cleye": "^1.3.2",
37 | "cmd-shim": "^6.0.3",
38 | "execa": "^8.0.1",
39 | "fs-fixture": "^2.4.0",
40 | "get-node": "^15.0.1",
41 | "kolorist": "^1.8.0",
42 | "lintroll": "^1.7.1",
43 | "manten": "^1.3.0",
44 | "npm-packlist": "^8.0.2",
45 | "outdent": "^0.8.0",
46 | "pkgroll": "^2.4.2",
47 | "tsx": "^4.16.5",
48 | "typescript": "^5.5.4"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs/promises';
2 | import { cli } from 'cleye';
3 | import outdent from 'outdent';
4 | import { linkPackage, linkFromConfig } from './link-package';
5 | import { loadConfig } from './utils/load-config';
6 | import { publishCommand, publishHandler } from './commands/publish/index.js';
7 |
8 | (async () => {
9 | const argv = cli({
10 | name: 'link',
11 | parameters: ['[package paths...]'],
12 | flags: {
13 | deep: {
14 | type: Boolean,
15 | alias: 'd',
16 | description: 'Run `npx link` on dependencies if they have a link.config.json',
17 | },
18 | },
19 | help: {
20 | description: 'A better `npm link` -- symlink local dependencies to the current project',
21 |
22 | render: (nodes, renderers) => {
23 | nodes[0].data = 'npx link\n';
24 |
25 | nodes.splice(2, 0, {
26 | type: 'section',
27 | data: {
28 | title: 'Website',
29 | body: 'https://www.npmjs.com/package/link',
30 | },
31 | });
32 |
33 | return renderers.render(nodes);
34 | },
35 | },
36 | commands: [
37 | publishCommand,
38 | ],
39 | });
40 |
41 | const cwdProjectPath = await fs.realpath(process.cwd());
42 |
43 | if (!argv.command) {
44 | const { packagePaths } = argv._;
45 |
46 | if (packagePaths.length > 0) {
47 | await Promise.all(
48 | packagePaths.map(
49 | linkPackagePath => linkPackage(
50 | cwdProjectPath,
51 | linkPackagePath,
52 | argv.flags,
53 | ),
54 | ),
55 | );
56 | return;
57 | }
58 |
59 | const config = await loadConfig(cwdProjectPath);
60 |
61 | if (!config) {
62 | console.warn(
63 | outdent`
64 | Warning: Config file "link.config.json" not found in current directory.
65 | Read the documentation to learn more: https://www.npmjs.com/package/link
66 | `,
67 | );
68 | argv.showHelp();
69 | return;
70 | }
71 |
72 | await linkFromConfig(
73 | cwdProjectPath,
74 | config,
75 | {
76 | deep: argv.flags.deep,
77 | },
78 | );
79 | } else if (argv.command === 'publish') {
80 | await publishHandler(cwdProjectPath, argv._);
81 | }
82 | })().catch((error) => {
83 | console.error('Error:', error.message);
84 | process.exit(1);
85 | });
86 |
--------------------------------------------------------------------------------
/src/commands/publish/hardlink-package.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import fs from 'node:fs/promises';
3 | import { green, magenta, cyan } from 'kolorist';
4 | import type { PackageJsonWithName } from '../../utils/read-package-json';
5 | import { hardlink } from '../../utils/symlink';
6 | import { getNpmPacklist } from '../../utils/get-npm-packlist';
7 | import { cwdPath } from '../../utils/cwd-path.js';
8 |
9 | export const hardlinkPackage = async (
10 | linkPath: string,
11 | absoluteLinkPackagePath: string,
12 | packageJson: PackageJsonWithName,
13 | ) => {
14 | const [oldPublishFiles, publishFiles] = await Promise.all([
15 | getNpmPacklist(
16 | linkPath,
17 |
18 | /**
19 | * This is evaluated in the context of the new package.json since that
20 | * defines which files belong to the package.
21 | */
22 | packageJson,
23 | ),
24 | getNpmPacklist(
25 | absoluteLinkPackagePath,
26 | packageJson,
27 | ),
28 | ]);
29 |
30 | console.log(`Linking ${magenta(packageJson.name)} in publish mode:`);
31 | await Promise.all(
32 | publishFiles.map(async (file) => {
33 | const sourcePath = path.join(absoluteLinkPackagePath, file);
34 | const targetPath = path.join(linkPath, file);
35 |
36 | await fs.mkdir(
37 | path.dirname(targetPath),
38 | { recursive: true },
39 | );
40 |
41 | await hardlink(sourcePath, targetPath);
42 |
43 | const fileIndex = oldPublishFiles.indexOf(file);
44 | if (fileIndex > -1) {
45 | oldPublishFiles.splice(fileIndex, 1);
46 | }
47 |
48 | console.log(
49 | ` ${green('✔')}`,
50 | cyan(cwdPath(targetPath)),
51 | '→',
52 | cyan(cwdPath(sourcePath)),
53 | );
54 | }),
55 | );
56 |
57 | await Promise.all(
58 | oldPublishFiles.map(async (file) => {
59 | const cleanPath = path.join(linkPath, file);
60 | await fs.rm(cleanPath);
61 | }),
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/src/commands/publish/index.ts:
--------------------------------------------------------------------------------
1 | import { command } from 'cleye';
2 | import { linkPublishMode } from './link-publish-mode.js';
3 |
4 | export const publishCommand = command({
5 | name: 'publish',
6 | parameters: [''],
7 | flags: {
8 | // watch: {
9 | // type: Boolean,
10 | // alias: 'w',
11 | // description: 'Watch for changes in the package and automatically relink',
12 | // },
13 | },
14 | help: {
15 | description: 'Link a package to simulate an environment similar to `npm install`',
16 | },
17 | });
18 |
19 | export const publishHandler = async (
20 | cwdProjectPath: string,
21 | packagePaths: string[],
22 | ) => {
23 | if (packagePaths.length > 0) {
24 | await Promise.all(
25 | packagePaths.map(
26 | linkPackagePath => linkPublishMode(
27 | cwdProjectPath,
28 | linkPackagePath,
29 | ),
30 | ),
31 | );
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/src/commands/publish/link-publish-mode.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs/promises';
3 | import outdent from 'outdent';
4 | import { magenta, bold, dim } from 'kolorist';
5 | import { readPackageJson } from '../../utils/read-package-json.js';
6 | import { hardlinkPackage } from './hardlink-package.js';
7 |
8 | const isValidSetup = async (
9 | linkPath: string,
10 | expectedPrefix: string,
11 | ) => {
12 | const linkPathStat = await fs.stat(linkPath).catch(() => null);
13 | if (!linkPathStat?.isDirectory()) {
14 | return false;
15 | }
16 |
17 | /**
18 | * If it's a symlink, make sure it's in the node_modules directory of the base package.
19 | * e.g. This could happen with pnpm
20 | *
21 | * If it's not, it might be a development directory and we don't want to overwrite it.
22 | */
23 | const linkPathReal = await fs.realpath(linkPath);
24 | return linkPathReal.startsWith(expectedPrefix);
25 | };
26 |
27 | export const linkPublishMode = async (
28 | basePackagePath: string,
29 | linkPackagePath: string,
30 | ) => {
31 | const absoluteLinkPackagePath = path.resolve(basePackagePath, linkPackagePath);
32 | const packageJson = await readPackageJson(absoluteLinkPackagePath);
33 | const expectedPrefix = path.join(basePackagePath, 'node_modules/');
34 | const linkPath = path.join(expectedPrefix, packageJson.name);
35 |
36 | if (!(await isValidSetup(linkPath, expectedPrefix))) {
37 | console.error(
38 | outdent`
39 | Error: Package ${magenta(packageJson.name)} is not set up
40 |
41 | ${bold('Setup instructions')}
42 | 1. In the Dependency package, create a tarball:
43 | ${dim('$ npm pack')}
44 |
45 | 2. In the Consuming package, install the tarball and link the Dependency:
46 | ${dim('$ npm install --no-save ')}
47 | ${dim('$ npx link publish ')}
48 |
49 | 3. Start developing!
50 |
51 | Learn more: https://npmjs.com/link
52 | `,
53 | );
54 | return;
55 | }
56 |
57 | /**
58 | * If it's a symlink, make sure it's in the node_modules directory of the base package.
59 | * e.g. This could happen with pnpm
60 | *
61 | * If it's not, it might be a development directory and we don't want to overwrite it.
62 | */
63 | const linkPathReal = await fs.realpath(linkPath);
64 | if (!linkPathReal.startsWith(expectedPrefix)) {
65 | return;
66 | }
67 |
68 | await hardlinkPackage(
69 | linkPath,
70 | absoluteLinkPackagePath,
71 | packageJson,
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/src/link-package/index.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import {
3 | green, red, cyan, magenta,
4 | } from 'kolorist';
5 | import { fsExists } from '../utils/fs-exists';
6 | import type { LinkConfig } from '../types';
7 | import { loadConfig } from '../utils/load-config';
8 | import { symlinkPackage } from './symlink-package';
9 |
10 | export const linkPackage = async (
11 | basePackagePath: string,
12 | linkPackagePath: string,
13 | options: {
14 | deep?: boolean;
15 | },
16 | ) => {
17 | const absoluteLinkPackagePath = path.resolve(basePackagePath, linkPackagePath);
18 | const pathExists = await fsExists(absoluteLinkPackagePath);
19 |
20 | if (!pathExists) {
21 | console.warn(red('✖'), `Package path does not exist: ${linkPackagePath}`);
22 | process.exitCode = 1;
23 | return;
24 | }
25 |
26 | try {
27 | const link = await symlinkPackage(
28 | basePackagePath,
29 | linkPackagePath,
30 | );
31 | console.log(green('✔'), `Symlinked ${magenta(link.name)}:`, cyan(link.path), '→', cyan(link.target));
32 | } catch (error) {
33 | console.warn(red('✖'), 'Failed to symlink', cyan(linkPackagePath), 'with error:', (error as Error).message);
34 | process.exitCode = 1;
35 | return;
36 | }
37 |
38 | if (options.deep) {
39 | const config = await loadConfig(absoluteLinkPackagePath);
40 |
41 | if (config) {
42 | await linkFromConfig(
43 | absoluteLinkPackagePath,
44 | config,
45 | options,
46 | );
47 | }
48 | }
49 | };
50 |
51 | export const linkFromConfig = async (
52 | basePackagePath: string,
53 | config: LinkConfig,
54 | options: {
55 | deep?: boolean;
56 | },
57 | ) => {
58 | if (!config.packages) {
59 | return;
60 | }
61 |
62 | const newOptions = {
63 | deep: options.deep ?? config.deepLink ?? false,
64 | };
65 |
66 | await Promise.all(
67 | config.packages.map(
68 | async linkPackagePath => await linkPackage(
69 | basePackagePath,
70 | linkPackagePath,
71 | newOptions,
72 | ),
73 | ),
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/src/link-package/link-binaries.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs/promises';
3 | import type { PackageJson } from '@npmcli/package-json';
4 |
5 | export const linkBinaries = async (
6 | linkPackagePath: string,
7 | nodeModulesPath: string,
8 | {
9 | name,
10 | bin,
11 | }: PackageJson,
12 | linkFunction: (targetPath: string, linkPath: string) => Promise,
13 | ) => {
14 | if (!bin) {
15 | return [];
16 | }
17 |
18 | if (name?.startsWith('@')) {
19 | [, name] = name.split('/');
20 | }
21 |
22 | const binDirectoryPath = path.join(nodeModulesPath, '.bin');
23 |
24 | await fs.mkdir(binDirectoryPath, {
25 | recursive: true,
26 | });
27 |
28 | if (typeof bin === 'string') {
29 | await linkFunction(
30 | path.resolve(linkPackagePath, bin),
31 | path.join(binDirectoryPath, name!),
32 | );
33 | return;
34 | }
35 |
36 | await Promise.all(
37 | Object.entries(bin).map(
38 | async ([binaryName, binaryPath]) => await linkFunction(
39 | path.resolve(linkPackagePath, binaryPath!),
40 | path.join(binDirectoryPath, binaryName),
41 | ),
42 | ),
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/link-package/symlink-package.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs/promises';
3 | import cmdShim from 'cmd-shim';
4 | import { readPackageJson } from '../utils/read-package-json';
5 | import { symlink, symlinkBinary } from '../utils/symlink';
6 | import { linkBinaries } from './link-binaries';
7 |
8 | const nodeModulesDirectory = 'node_modules';
9 |
10 | export const symlinkPackage = async (
11 | basePackagePath: string,
12 | linkPackagePath: string,
13 | ) => {
14 | const absoluteLinkPackagePath = path.resolve(basePackagePath, linkPackagePath);
15 | const packageJson = await readPackageJson(absoluteLinkPackagePath);
16 | const nodeModulesPath = path.join(basePackagePath, nodeModulesDirectory);
17 | const symlinkPath = path.join(nodeModulesPath, packageJson.name);
18 | const symlinkDirectory = path.dirname(symlinkPath);
19 |
20 | await fs.mkdir(symlinkDirectory, {
21 | recursive: true,
22 | });
23 |
24 | // Link path relative from symlink path
25 | const targetPath = path.relative(symlinkDirectory, absoluteLinkPackagePath);
26 |
27 | await symlink(
28 | targetPath,
29 | symlinkPath,
30 |
31 | /**
32 | * On Windows, 'dir' requires admin privileges so use 'junction' instead
33 | *
34 | * npm also uses junction:
35 | * https://github.com/npm/cli/blob/v9.9.3/workspaces/arborist/lib/arborist/reify.js#L738
36 | */
37 | 'junction',
38 | );
39 |
40 | await linkBinaries(
41 | absoluteLinkPackagePath,
42 | nodeModulesPath,
43 | packageJson,
44 | (process.platform === 'win32') ? cmdShim : symlinkBinary,
45 | );
46 |
47 | return {
48 | name: packageJson.name,
49 | path: symlinkPath,
50 | target: targetPath,
51 | };
52 | };
53 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type LinkConfig = {
2 | deepLink?: boolean;
3 | packages?: string[];
4 | };
5 |
--------------------------------------------------------------------------------
/src/utils/cwd-path.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | const cwd = process.cwd();
4 |
5 | export const cwdPath = (
6 | filePath: string,
7 | ) => path.relative(cwd, filePath);
8 |
--------------------------------------------------------------------------------
/src/utils/fs-exists.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs/promises';
2 |
3 | export const fsExists = (
4 | path: string,
5 | ) => fs.access(path).then(
6 | () => true,
7 | () => false,
8 | );
9 |
--------------------------------------------------------------------------------
/src/utils/get-npm-packlist.ts:
--------------------------------------------------------------------------------
1 | import packlist from 'npm-packlist';
2 | import type { PackageJson } from '@npmcli/package-json';
3 |
4 | // Only to silence types
5 | const edgesOut = new Map();
6 |
7 | export const getNpmPacklist = (
8 | absoluteLinkPackagePath: string,
9 | packageJson: PackageJson,
10 | ) => packlist({
11 | path: absoluteLinkPackagePath,
12 | package: packageJson,
13 | // @ts-expect-error outdated types
14 | edgesOut,
15 | });
16 |
--------------------------------------------------------------------------------
/src/utils/load-config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import type { LinkConfig } from '../types';
3 | import { fsExists } from './fs-exists';
4 | import { readJsonFile } from './read-json-file';
5 |
6 | const configJsonFile = 'link.config.json';
7 | const configJsFile = 'link.config.js';
8 |
9 | export const loadConfig = async (
10 | packageDirectory: string,
11 | ) => {
12 | const configJsonPath = path.join(packageDirectory, configJsonFile);
13 | if (await fsExists(configJsonPath)) {
14 | try {
15 | return readJsonFile(configJsonPath) as LinkConfig;
16 | } catch (error) {
17 | throw new Error(`Failed to parse config JSON ${configJsonPath}: ${(error as Error).message}`);
18 | }
19 | }
20 |
21 | const configJsPath = path.join(packageDirectory, configJsFile);
22 | if (await fsExists(configJsPath)) {
23 | try {
24 | // eslint-disable-next-line @typescript-eslint/no-var-requires,import-x/no-dynamic-require
25 | return require(configJsPath) as LinkConfig;
26 | } catch (error) {
27 | throw new Error(`Failed to load config file ${configJsFile}: ${(error as Error).message}`);
28 | }
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/src/utils/read-json-file.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs/promises';
2 |
3 | export const readJsonFile = async (
4 | filePath: string,
5 | ) => {
6 | const jsonString = await fs.readFile(filePath, 'utf8');
7 | return JSON.parse(jsonString) as unknown;
8 | };
9 |
--------------------------------------------------------------------------------
/src/utils/read-package-json.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import type { PackageJson } from '@npmcli/package-json';
3 | import { fsExists } from './fs-exists';
4 | import { readJsonFile } from './read-json-file';
5 |
6 | type WithRequired = T & { [P in K]-?: T[P] };
7 |
8 | export type PackageJsonWithName = WithRequired;
9 |
10 | export const readPackageJson = async (
11 | packagePath: string,
12 | ) => {
13 | const packageJsonPath = path.join(packagePath, 'package.json');
14 | const packageJsonExists = await fsExists(packageJsonPath);
15 |
16 | if (!packageJsonExists) {
17 | throw new Error(`package.json not found in ${packagePath}`);
18 | }
19 |
20 | const packageJson = await readJsonFile(packageJsonPath) as PackageJson;
21 |
22 | if (!packageJson.name) {
23 | throw new Error(`package.json must contain a name: ${packageJsonPath}`);
24 | }
25 |
26 | return packageJson as PackageJsonWithName;
27 | };
28 |
--------------------------------------------------------------------------------
/src/utils/symlink.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs/promises';
2 | import { fsExists } from './fs-exists';
3 |
4 | /**
5 | * Helper to create a symlink
6 | * Deletes the target path if it exists
7 | */
8 | export const symlink = async (
9 | targetPath: string,
10 | symlinkPath: string,
11 | type?: string,
12 | ) => {
13 | const stats = await fs.lstat(symlinkPath).catch(() => null);
14 | if (stats) {
15 | if (stats.isSymbolicLink()) {
16 | const symlinkRealpath = await fs.realpath(symlinkPath).catch(() => null);
17 |
18 | if (targetPath === symlinkRealpath) {
19 | return;
20 | }
21 | }
22 |
23 | await fs.rm(symlinkPath, {
24 | recursive: true,
25 | });
26 | }
27 |
28 | await fs.symlink(
29 | targetPath,
30 | symlinkPath,
31 | type,
32 | );
33 | };
34 |
35 | export const symlinkBinary = async (
36 | binaryPath: string,
37 | linkPath: string,
38 | ) => {
39 | await symlink(binaryPath, linkPath);
40 | await fs.chmod(linkPath, 0o755);
41 | };
42 |
43 | export const hardlink = async (
44 | sourcePath: string,
45 | hardlinkPath: string,
46 | ) => {
47 | if (await fsExists(hardlinkPath)) {
48 | const [
49 | existingStat,
50 | sourceStat,
51 | ] = await Promise.all([
52 | fs.stat(hardlinkPath),
53 | fs.stat(sourcePath),
54 | ]);
55 | if (existingStat.ino === sourceStat.ino) {
56 | return;
57 | }
58 |
59 | await fs.rm(hardlinkPath, {
60 | recursive: true,
61 | });
62 | }
63 |
64 | await fs.link(sourcePath, hardlinkPath);
65 | };
66 |
--------------------------------------------------------------------------------
/tests/fixtures/nested/package-deep-link/index.js:
--------------------------------------------------------------------------------
1 | const requireSafe = (specifier) => {
2 | try {
3 | return require(specifier);
4 | } catch {
5 | return null;
6 | }
7 | };
8 |
9 | module.exports = [
10 | 'package-deep-link',
11 | requireSafe('package-files'),
12 | requireSafe('@scope/package-scoped'),
13 | ];
14 |
--------------------------------------------------------------------------------
/tests/fixtures/nested/package-deep-link/link.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "packages": [
3 | "../../package-files",
4 | "../../package-scoped"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/tests/fixtures/nested/package-deep-link/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "package-deep-link"
3 | }
4 |
--------------------------------------------------------------------------------
/tests/fixtures/package-binary/binary.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | console.log('package-binary');
4 |
--------------------------------------------------------------------------------
/tests/fixtures/package-binary/index.js:
--------------------------------------------------------------------------------
1 | module.exports = 'package-binary';
2 |
--------------------------------------------------------------------------------
/tests/fixtures/package-binary/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "package-binary",
3 | "bin": {
4 | "binary": "./binary.js"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/tests/fixtures/package-entry/index.js:
--------------------------------------------------------------------------------
1 | console.log(JSON.stringify([
2 | 'package-entry',
3 | require('package-binary'),
4 | require('package-files'),
5 | require('@scope/package-scoped'),
6 | require('package-deep-link'),
7 | ]));
8 |
--------------------------------------------------------------------------------
/tests/fixtures/package-entry/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "package-entry"
3 | }
4 |
--------------------------------------------------------------------------------
/tests/fixtures/package-files/lib/index.js:
--------------------------------------------------------------------------------
1 | module.exports = 'package-files';
2 |
--------------------------------------------------------------------------------
/tests/fixtures/package-files/non-publish-file.js:
--------------------------------------------------------------------------------
1 | console.log('non publish file');
2 |
--------------------------------------------------------------------------------
/tests/fixtures/package-files/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "package-files",
3 | "version": "0.0.0",
4 | "files": ["lib"],
5 | "main": "lib/index.js"
6 | }
7 |
--------------------------------------------------------------------------------
/tests/fixtures/package-scoped/cli.js:
--------------------------------------------------------------------------------
1 | console.log('cli');
--------------------------------------------------------------------------------
/tests/fixtures/package-scoped/file.js:
--------------------------------------------------------------------------------
1 | module.exports = '@scope/package-scoped';
2 |
--------------------------------------------------------------------------------
/tests/fixtures/package-scoped/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@scope/package-scoped",
3 | "main": "file.js",
4 | "bin": "./cli.js"
5 | }
6 |
--------------------------------------------------------------------------------
/tests/index.ts:
--------------------------------------------------------------------------------
1 | import { describe } from 'manten';
2 | import getNode from 'get-node';
3 |
4 | const nodeVersions = [
5 | '20',
6 | ...(
7 | process.env.CI
8 | ? [
9 | '18',
10 | ]
11 | : []
12 | ),
13 | ];
14 |
15 | (async () => {
16 | for (const nodeVersion of nodeVersions) {
17 | const node = await getNode(nodeVersion);
18 | await describe(`Node ${node.version}`, ({ runTestSuite }) => {
19 | runTestSuite(import('./specs/cli.spec'), node.path);
20 | runTestSuite(import('./specs/link-config.spec'), node.path);
21 | runTestSuite(import('./specs/publish.spec'), node.path);
22 | });
23 | }
24 | })();
25 |
--------------------------------------------------------------------------------
/tests/specs/cli.spec.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs/promises';
2 | import path from 'path';
3 | import { testSuite, expect } from 'manten';
4 | import { execa, execaNode } from 'execa';
5 | import { createFixture } from 'fs-fixture';
6 | import { link } from '../utils/link.js';
7 |
8 | export default testSuite(({ describe }, nodePath: string) => {
9 | describe('cli', ({ test, describe }) => {
10 | describe('error-cases', ({ test }) => {
11 | test('link package doesnt exist', async () => {
12 | await using fixture = await createFixture('./tests/fixtures/');
13 |
14 | const linkProcess = await link(['../non-existing'], {
15 | cwd: path.join(fixture.path, 'package-entry'),
16 | nodePath,
17 | });
18 |
19 | expect(linkProcess.exitCode).toBe(1);
20 | expect(linkProcess.stderr).toBe('✖ Package path does not exist: ../non-existing');
21 | });
22 |
23 | test('link package.json doesnt exist', async () => {
24 | await using fixture = await createFixture('./tests/fixtures/');
25 |
26 | await fixture.rm('package-files/package.json');
27 |
28 | const linkProcess = await link(['../package-files'], {
29 | cwd: path.join(fixture.path, 'package-entry'),
30 | nodePath,
31 | });
32 |
33 | expect(linkProcess.exitCode).toBe(1);
34 | expect(linkProcess.stderr).toMatch('✖ Failed to symlink ../package-files with error: package.json not found');
35 | });
36 |
37 | test('single failure should exit 1', async () => {
38 | await using fixture = await createFixture('./tests/fixtures/');
39 |
40 | await fixture.rm('package-files/package.json');
41 |
42 | const linkProcess = await link([
43 | '../package-binary',
44 | path.join(fixture.path, 'package-files'),
45 | '../package-scoped',
46 | ], {
47 | cwd: path.join(fixture.path, 'package-entry'),
48 | nodePath,
49 | });
50 |
51 | expect(linkProcess.exitCode).toBe(1);
52 | expect(linkProcess.stdout).toMatch('✔ Symlinked package-binary');
53 | expect(linkProcess.stdout).toMatch('✔ Symlinked @scope/package-scoped');
54 | expect(linkProcess.stderr).toMatch('✖ Failed to symlink');
55 | });
56 |
57 | test('symlink exists', async () => {
58 | await using fixture = await createFixture('./tests/fixtures/');
59 | const packageEntryPath = path.join(fixture.path, 'package-entry');
60 |
61 | await fs.mkdir(path.join(packageEntryPath, 'node_modules'));
62 | await fs.symlink(
63 | '../../package-files',
64 | path.join(packageEntryPath, 'node_modules/package-files'),
65 | );
66 |
67 | const linkProcess = await link(['../package-files'], {
68 | cwd: packageEntryPath,
69 | nodePath,
70 | });
71 |
72 | expect(linkProcess.exitCode).toBe(0);
73 | });
74 |
75 | test('broken symlink exists', async () => {
76 | await using fixture = await createFixture('./tests/fixtures/');
77 | const packageEntryPath = path.join(fixture.path, 'package-entry');
78 |
79 | await fs.mkdir(path.join(packageEntryPath, 'node_modules'));
80 | await fs.symlink(
81 | '../broken-symlink/../../package-files',
82 | path.join(packageEntryPath, 'node_modules/package-files'),
83 | );
84 |
85 | const linkProcess = await link(['../package-files'], {
86 | cwd: path.join(fixture.path, 'package-entry'),
87 | nodePath,
88 | });
89 |
90 | expect(linkProcess.exitCode).toBe(0);
91 | });
92 |
93 | test('directory in-place of symlink', async () => {
94 | await using fixture = await createFixture('./tests/fixtures/');
95 | const packageEntryPath = path.join(fixture.path, 'package-entry');
96 |
97 | await fs.mkdir(path.join(packageEntryPath, 'node_modules/package-files'), {
98 | recursive: true,
99 | });
100 |
101 | const linkProcess = await link(['../package-files'], {
102 | cwd: path.join(fixture.path, 'package-entry'),
103 | nodePath,
104 | });
105 |
106 | expect(linkProcess.exitCode).toBe(0);
107 | });
108 | });
109 |
110 | test('consecutive links', async () => {
111 | await using fixture = await createFixture('./tests/fixtures/');
112 |
113 | const entryPackagePath = path.join(fixture.path, 'package-entry');
114 |
115 | // Links multiple packages consecutively
116 | await Promise.all(
117 | [
118 | '../package-binary',
119 | path.join(fixture.path, 'package-files'),
120 | '../package-scoped',
121 | '../nested/package-deep-link',
122 | ].map(async (packagePath) => {
123 | await link([packagePath], {
124 | cwd: entryPackagePath,
125 | nodePath,
126 | });
127 | }),
128 | );
129 |
130 | // Test that linked packages are resolvable
131 | const entryPackage = await execaNode(
132 | entryPackagePath,
133 | [],
134 | {
135 | nodePath,
136 | nodeOptions: [],
137 | },
138 | );
139 | expect(entryPackage.stdout).toBe('["package-entry","package-binary","package-files","@scope/package-scoped",["package-deep-link",null,null]]');
140 |
141 | // Test binary
142 | await fixture.writeJson('package-entry/package.json', {
143 | scripts: {
144 | test: 'binary',
145 | },
146 | });
147 |
148 | const binary = await execa('npm', ['test'], {
149 | cwd: entryPackagePath,
150 | });
151 | expect(binary.stdout).toMatch('package-binary');
152 | expect(
153 | await fixture.exists('package-entry/node_modules/.bin/package-scoped'),
154 | ).toBe(true);
155 |
156 | // Expect non publish files to exist in symlink
157 | expect(
158 | await fixture.exists('package-entry/node_modules/package-files/non-publish-file.js'),
159 | ).toBe(true);
160 | });
161 |
162 | test('multiple packages', async () => {
163 | await using fixture = await createFixture('./tests/fixtures/');
164 |
165 | const entryPackagePath = path.join(fixture.path, 'package-entry');
166 |
167 | await link([
168 | '../package-binary',
169 | path.join(fixture.path, 'package-files'),
170 | '../package-scoped',
171 | '../nested/package-deep-link',
172 | ], {
173 | cwd: entryPackagePath,
174 | nodePath,
175 | });
176 |
177 | // Test that linked packages are resolvable
178 | const entryPackage = await execaNode(
179 | entryPackagePath,
180 | [],
181 | {
182 | nodePath,
183 | nodeOptions: [],
184 | },
185 | );
186 | expect(entryPackage.stdout).toBe('["package-entry","package-binary","package-files","@scope/package-scoped",["package-deep-link",null,null]]');
187 |
188 | // Test binary
189 | const binary = await execa(path.join(entryPackagePath, 'node_modules/.bin/binary'));
190 | expect(binary.stdout).toMatch('package-binary');
191 | expect(
192 | await fixture.exists('package-entry/node_modules/.bin/package-scoped'),
193 | ).toBe(true);
194 |
195 | // Executable via npm
196 | await fixture.writeJson('package-entry/package.json', {
197 | scripts: {
198 | test: 'binary',
199 | },
200 | });
201 | const binaryNpm = await execa('npm', ['test'], {
202 | cwd: entryPackagePath,
203 | });
204 | expect(binaryNpm.stdout).toMatch('package-binary');
205 |
206 | // Expect non publish files to exist in symlink
207 | const nonPublishFileExists = await fixture.exists('package-entry/node_modules/package-files/non-publish-file.js');
208 | expect(nonPublishFileExists).toBe(true);
209 | });
210 |
211 | test('works without package.json in cwd', async () => {
212 | await using fixture = await createFixture('./tests/fixtures/');
213 | const entryPackagePath = path.join(fixture.path, 'package-entry');
214 |
215 | await fixture.rm('package-entry/package.json');
216 |
217 | await Promise.all(
218 | [
219 | '../package-binary',
220 | path.join(fixture.path, 'package-files'),
221 | '../package-scoped',
222 | '../nested/package-deep-link',
223 | ].map(async (packagePath) => {
224 | await link([packagePath], {
225 | cwd: entryPackagePath,
226 | nodePath,
227 | });
228 | }),
229 | );
230 |
231 | const entryPackage = await execaNode(
232 | path.join(fixture.path, 'package-entry'),
233 | [],
234 | {
235 | nodePath,
236 | nodeOptions: [],
237 | },
238 | );
239 | expect(entryPackage.stdout).toBe('["package-entry","package-binary","package-files","@scope/package-scoped",["package-deep-link",null,null]]');
240 | });
241 |
242 | test('deep linking', async () => {
243 | await using fixture = await createFixture('./tests/fixtures/');
244 | const entryPackagePath = path.join(fixture.path, 'package-entry');
245 |
246 | await fixture.rm('package-entry/package.json');
247 |
248 | const linkProcess = await link([
249 | '../package-binary',
250 | path.join(fixture.path, 'package-files'),
251 | '../package-scoped',
252 | '../nested/package-deep-link',
253 | '--deep',
254 | ], {
255 | cwd: entryPackagePath,
256 | nodePath,
257 | });
258 |
259 | expect(linkProcess.exitCode).toBe(0);
260 |
261 | const entryPackage = await execaNode(
262 | entryPackagePath,
263 | [],
264 | {
265 | nodePath,
266 | nodeOptions: [],
267 | },
268 | );
269 | expect(entryPackage.stdout).toBe('["package-entry","package-binary","package-files","@scope/package-scoped",["package-deep-link","package-files","@scope/package-scoped"]]');
270 | });
271 |
272 | test('overwrites directory in place of symlink', async () => {
273 | await using fixture = await createFixture('./tests/fixtures/');
274 | const entryPackagePath = path.join(fixture.path, 'package-entry');
275 |
276 | await fs.mkdir(
277 | path.join(entryPackagePath, 'node_modules/package-binary'),
278 | { recursive: true },
279 | );
280 |
281 | const linkBinary = await link(['../package-binary'], {
282 | cwd: entryPackagePath,
283 | nodePath,
284 | });
285 |
286 | expect(linkBinary.exitCode).toBe(0);
287 | });
288 | });
289 | });
290 |
--------------------------------------------------------------------------------
/tests/specs/link-config.spec.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { testSuite, expect } from 'manten';
3 | import { execa, execaNode } from 'execa';
4 | import { createFixture } from 'fs-fixture';
5 | import { link } from '../utils/link.js';
6 |
7 | export default testSuite(({ describe }, nodePath: string) => {
8 | describe('link.config.json', ({ test, describe }) => {
9 | test('symlink', async () => {
10 | await using fixture = await createFixture('./tests/fixtures/');
11 | const entryPackagePath = path.join(fixture.path, 'package-entry');
12 |
13 | await fixture.writeJson('package-entry/link.config.json', {
14 | packages: [
15 | // Relative path & binary
16 | '../package-binary',
17 |
18 | // Absolute path
19 | path.join(fixture.path, 'package-files'),
20 |
21 | // Package with @org in name
22 | '../package-scoped',
23 |
24 | '../nested/package-deep-link',
25 | ],
26 | });
27 |
28 | await link([], {
29 | cwd: entryPackagePath,
30 | nodePath,
31 | });
32 |
33 | const entryPackage = await execaNode(
34 | path.join(entryPackagePath, 'index.js'),
35 | [],
36 | {
37 | nodePath,
38 | nodeOptions: [],
39 | },
40 | );
41 | expect(entryPackage.stdout).toBe('["package-entry","package-binary","package-files","@scope/package-scoped",["package-deep-link",null,null]]');
42 |
43 | // Executable via npm
44 | await fixture.writeJson('package-entry/package.json', {
45 | scripts: {
46 | test: 'binary',
47 | },
48 | });
49 | const binaryNpm = await execa('npm', ['test'], {
50 | cwd: entryPackagePath,
51 | });
52 | expect(binaryNpm.stdout).toMatch('package-binary');
53 |
54 | const binary = await execa(path.join(entryPackagePath, 'node_modules/.bin/binary'));
55 | expect(binary.stdout).toBe('package-binary');
56 |
57 | const nonPublishFileExists = await fixture.exists('package-entry/node_modules/package-files/non-publish-file.js');
58 | expect(nonPublishFileExists).toBe(true);
59 | });
60 |
61 | describe('deep linking', ({ test }) => {
62 | test('cli', async () => {
63 | await using fixture = await createFixture('./tests/fixtures/');
64 | const entryPackagePath = path.join(fixture.path, 'package-entry');
65 |
66 | await fixture.writeJson('package-entry/link.config.json', {
67 | packages: [
68 | // Relative path & binary
69 | '../package-binary',
70 |
71 | // Absolute path
72 | path.join(fixture.path, 'package-files'),
73 |
74 | // Package with @org in name
75 | '../package-scoped',
76 |
77 | '../nested/package-deep-link',
78 | ],
79 | });
80 |
81 | await link(['--deep'], {
82 | cwd: entryPackagePath,
83 | nodePath,
84 | });
85 |
86 | const entryPackage = await execaNode(
87 | path.join(entryPackagePath, 'index.js'),
88 | [],
89 | {
90 | nodePath,
91 | nodeOptions: [],
92 | },
93 | );
94 | expect(entryPackage.stdout).toBe('["package-entry","package-binary","package-files","@scope/package-scoped",["package-deep-link","package-files","@scope/package-scoped"]]');
95 | });
96 |
97 | test('link.config', async () => {
98 | await using fixture = await createFixture('./tests/fixtures/');
99 | const entryPackagePath = path.join(fixture.path, 'package-entry');
100 |
101 | await fixture.writeJson('package-entry/link.config.json', {
102 | deepLink: true,
103 |
104 | packages: [
105 | // Relative path & binary
106 | '../package-binary',
107 |
108 | // Absolute path
109 | path.join(fixture.path, 'package-files'),
110 |
111 | // Package with @org in name
112 | '../package-scoped',
113 |
114 | '../nested/package-deep-link',
115 | ],
116 | });
117 |
118 | await link([], {
119 | cwd: entryPackagePath,
120 | nodePath,
121 | });
122 |
123 | const entryPackage = await execaNode(
124 | path.join(entryPackagePath, 'index.js'),
125 | [],
126 | {
127 | nodePath,
128 | nodeOptions: [],
129 | },
130 | );
131 | expect(entryPackage.stdout).toBe('["package-entry","package-binary","package-files","@scope/package-scoped",["package-deep-link","package-files","@scope/package-scoped"]]');
132 | });
133 | });
134 | });
135 |
136 | describe('link.config.js', ({ test }) => {
137 | test('catches invalid config error', async () => {
138 | await using fixture = await createFixture('./tests/fixtures/');
139 | const entryPackagePath = path.join(fixture.path, 'package-entry');
140 |
141 | await fixture.writeFile(
142 | 'package-entry/link.config.js',
143 | 'module.export.throws.error = {}',
144 | );
145 |
146 | const linkProcess = await link([], {
147 | cwd: entryPackagePath,
148 | nodePath,
149 | });
150 |
151 | expect(linkProcess.stderr).toMatch('Error: Failed to load config file link.config.js:');
152 | });
153 |
154 | test('symlink', async () => {
155 | await using fixture = await createFixture('./tests/fixtures/');
156 | const entryPackagePath = path.join(fixture.path, 'package-entry');
157 |
158 | await fixture.writeFile(
159 | 'package-entry/link.config.js',
160 | `module.exports = ${JSON.stringify({
161 | packages: [
162 | // Relative path & binary
163 | '../package-binary',
164 |
165 | // Absolute path
166 | path.join(fixture.path, 'package-files'),
167 |
168 | // Package with @org in name
169 | '../package-scoped',
170 |
171 | '../nested/package-deep-link',
172 | ],
173 | })}`,
174 | );
175 |
176 | await link([], {
177 | cwd: entryPackagePath,
178 | nodePath,
179 | });
180 |
181 | const entryPackage = await execaNode(
182 | path.join(entryPackagePath, 'index.js'),
183 | [],
184 | {
185 | nodePath,
186 | nodeOptions: [],
187 | },
188 | );
189 | expect(entryPackage.stdout).toBe('["package-entry","package-binary","package-files","@scope/package-scoped",["package-deep-link",null,null]]');
190 |
191 | // Executable via npm
192 | await fixture.writeJson('package-entry/package.json', {
193 | scripts: {
194 | test: 'binary',
195 | },
196 | });
197 | const binaryNpm = await execa('npm', ['test'], {
198 | cwd: entryPackagePath,
199 | });
200 | expect(binaryNpm.stdout).toMatch('package-binary');
201 |
202 | const binary = await execa(path.join(entryPackagePath, 'node_modules/.bin/binary'));
203 | expect(binary.stdout).toBe('package-binary');
204 |
205 | const nonPublishFileExists = await fixture.exists('package-entry/node_modules/package-files/non-publish-file.js');
206 | expect(nonPublishFileExists).toBe(true);
207 | });
208 | });
209 | });
210 |
--------------------------------------------------------------------------------
/tests/specs/publish.spec.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises';
2 | import path from 'node:path';
3 | import { testSuite, expect } from 'manten';
4 | import { execa } from 'execa';
5 | import { createFixture } from 'fs-fixture';
6 | import { link } from '../utils/link.js';
7 | import { npmPack } from '../utils/npm-pack.js';
8 |
9 | export default testSuite(({ describe }, nodePath: string) => {
10 | describe('publish mode', ({ test }) => {
11 | test('hard links', async () => {
12 | await using fixture = await createFixture('./tests/fixtures/');
13 |
14 | const packageFilesPath = fixture.getPath('package-files');
15 | const statOriginalFile = await fs.stat(fixture.getPath('package-files/package.json'));
16 | const tarballPath = await npmPack(packageFilesPath);
17 | const entryPackagePath = fixture.getPath('package-entry');
18 |
19 | await execa('npm', [
20 | 'install',
21 | '--no-save',
22 | tarballPath,
23 | ], {
24 | cwd: entryPackagePath,
25 | });
26 |
27 | const statBeforeLink = await fs.stat(path.join(entryPackagePath, 'node_modules/package-files/package.json'));
28 | expect(statBeforeLink.ino).not.toBe(statOriginalFile.ino);
29 |
30 | const linked = await link([
31 | 'publish',
32 | packageFilesPath,
33 | ], {
34 | cwd: entryPackagePath,
35 | nodePath,
36 | });
37 | expect(linked.exitCode).toBe(0);
38 |
39 | // Assert hardlink to be established by comparing inodes
40 | const statAfterLink = await fs.stat(path.join(entryPackagePath, 'node_modules/package-files/package.json'));
41 | expect(statAfterLink.ino).toBe(statOriginalFile.ino);
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/tests/utils/link.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { execaNode } from 'execa';
3 |
4 | const linkBinPath = path.resolve('./dist/cli.js');
5 |
6 | type Options = {
7 | cwd: string;
8 | nodePath: string;
9 | };
10 |
11 | export const link = (
12 | cliArguments: string[],
13 | {
14 | cwd,
15 | nodePath,
16 | }: Options,
17 | ) => execaNode(
18 | linkBinPath,
19 | cliArguments,
20 | {
21 | env: {},
22 | extendEnv: false,
23 | nodeOptions: [],
24 | cwd,
25 | nodePath,
26 | reject: false,
27 | },
28 | );
29 |
--------------------------------------------------------------------------------
/tests/utils/npm-pack.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { execa } from 'execa';
3 |
4 | export const npmPack = async (packageDirectory: string) => {
5 | const pack = await execa('npm', ['pack'], {
6 | cwd: packageDirectory,
7 | });
8 | return path.join(packageDirectory, pack.stdout);
9 | };
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 |
4 | "target": "ES2022",
5 | // Node 18
6 | "module": "commonjs",
7 | "moduleResolution": "node",
8 | "strict": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "isolatedModules": true,
12 | "skipLibCheck": true,
13 | },
14 | }
15 |
--------------------------------------------------------------------------------