├── .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 | --------------------------------------------------------------------------------