Skip to content

Commit da1782e

Browse files
authored
feat(pnpm): support pnpm to install dependencies (#53)
- Auto-detect package manager based on lock file - Allow users to override the package manager (--pkg=pnpm)
1 parent 8cc54d3 commit da1782e

File tree

14 files changed

+298
-25
lines changed

14 files changed

+298
-25
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222
uses: actions/setup-node@v6
2323
with:
2424
node-version: ${{ matrix.node-version }}
25+
- name: Install pnpm
26+
run: npm install -g pnpm
2527
- name: Install dependencies
2628
run: npm ci
2729
- name: Build & lint & test
@@ -35,6 +37,8 @@ jobs:
3537
uses: actions/setup-node@v6
3638
with:
3739
node-version: 'latest'
40+
- name: Install pnpm
41+
run: npm install -g pnpm
3842
- name: Install dependencies
3943
run: npm ci
4044
- name: test-mutation

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Options:
3636

3737
- `-h, --help`: Output this help
3838
- `-S, --save`: Saved packages will appear in your package.json under "localDependencies"
39+
- `--pkg=<pnpm|npm>`: Specify which package manager to use. By default it will try to auto detect (see if `pnpm-lock.yaml` exists in the current directory).
3940
- `-T, --target-siblings`: Instead of installing into this package, this package gets installed into sibling packages
4041
which depend on this package by putting it in the "localDependencies".
4142
Useful in a [lerna](https://github.com/lerna/lerna) style monorepo.

src/LocalInstaller.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { helpers } from './helpers.ts';
66
import type { InstallTarget, PackageJson } from './index.ts';
77
import { utils } from './utils.ts';
88
import type { Options as ExecaOptions } from 'execa';
9+
import { prober, type PackageManager } from './prober.ts';
910

1011
interface PackageByDirectory {
1112
[directory: string]: PackageJson;
@@ -17,6 +18,7 @@ export interface Env {
1718

1819
export interface Options {
1920
npmEnv?: Env;
21+
packageManager: PackageManager | undefined;
2022
}
2123

2224
export interface ListByPackage {
@@ -89,14 +91,25 @@ export class LocalInstaller extends EventEmitter {
8991
const toInstall = target.sources.map((source) =>
9092
resolvePackFile(this.uniqueDir, source.packageJson),
9193
);
94+
const pkgManager =
95+
this.options.packageManager ?? (await prober.probePackageManager());
9296
const options: ExecaOptions = {
9397
cwd: target.directory,
9498
maxBuffer: TEN_MEGA_BYTE,
95-
env: this.options.npmEnv,
99+
env: {
100+
...this.options.npmEnv,
101+
npm_config_save: 'false',
102+
npm_config_lockfile: 'false',
103+
},
96104
};
105+
const installArgs =
106+
pkgManager === 'pnpm'
107+
? ['add', ...toInstall]
108+
: ['i', '--no-save', '--no-package-lock', ...toInstall];
109+
97110
const { stdout, stderr } = await utils.exec(
98-
'npm',
99-
['i', '--no-save', '--no-package-lock', ...toInstall],
111+
pkgManager,
112+
installArgs,
100113
options,
101114
);
102115
this.emit(

src/Options.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { validPackageManagers, type PackageManager } from './prober.ts';
2+
13
export class Options {
24
public readonly dependencies: string[];
35
public readonly options: string[];
@@ -21,6 +23,15 @@ export class Options {
2123
`Invalid use of option --target-siblings. Cannot be used together with --save`,
2224
),
2325
);
26+
} else if (
27+
this.packageManager &&
28+
!validPackageManagers.includes(this.packageManager)
29+
) {
30+
return Promise.reject(
31+
new Error(
32+
`Invalid package manager <${this.packageManager}> specified. Please use either 'npm' or 'pnpm'.`,
33+
),
34+
);
2435
} else {
2536
return Promise.resolve();
2637
}
@@ -34,6 +45,15 @@ export class Options {
3445
return this.flag('-T', '--target-siblings');
3546
}
3647

48+
public get packageManager(): PackageManager | undefined {
49+
const pkgOption = this.options.find((opt) => opt.startsWith('--pkg='));
50+
if (pkgOption) {
51+
const [, value] = pkgOption.split('=');
52+
return value as PackageManager;
53+
}
54+
return undefined;
55+
}
56+
3757
public get save(): boolean {
3858
return this.flag('-S', '--save');
3959
}

src/cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export async function cli(argv: string[]): Promise<void> {
2323
l(
2424
' -S, --save Saved packages will appear in your package.json under "localDependencies"',
2525
);
26+
l(
27+
'--pkg=<npm|pnpm> Specify which package manager to use (default: auto-detect)',
28+
);
2629
l(
2730
' -T, --target-siblings Instead of installing into this package, this package gets installed into sibling packages',
2831
);

src/currentDirectoryInstall.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import { storage } from './save.ts';
77
export const currentDirectoryInstaller = {
88
install: async (options: Options): Promise<void> => {
99
const localDependencies = await readLocalDependencies(options.dependencies);
10-
const installer = new LocalInstaller({
11-
[process.cwd()]: localDependencies,
12-
});
10+
const installer = new LocalInstaller(
11+
{
12+
[process.cwd()]: localDependencies,
13+
},
14+
{ packageManager: options.packageManager },
15+
);
1316
progressReporter.report(installer);
1417
const targets = await installer.install();
1518
await storage.saveIfNeeded(targets, options);

src/executor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { siblingInstaller } from './siblingInstall.ts';
44

55
export function execute(options: Options): Promise<void> {
66
if (options.targetSiblings) {
7-
return siblingInstaller.install();
7+
return siblingInstaller.install(options);
88
} else {
99
return currentDirectoryInstaller.install(options);
1010
}

src/prober.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export const validPackageManagers = ['npm', 'pnpm'] as const;
2+
export type PackageManager = (typeof validPackageManagers)[number];
3+
import { promises as fs } from 'fs';
4+
5+
export const prober = {
6+
probePackageManager: async (): Promise<PackageManager> => {
7+
// Check for pnpm-lock.yaml
8+
try {
9+
await fs.access('pnpm-lock.yaml');
10+
return 'pnpm';
11+
} catch {
12+
// File does not exist
13+
}
14+
// Default to npm if no lock file found
15+
return 'npm';
16+
},
17+
};

src/siblingInstall.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { promises as fs } from 'fs';
22
import path from 'path';
33
import { helpers } from './helpers.ts';
4-
import { type ListByPackage, type Package, LocalInstaller } from './index.ts';
4+
import {
5+
type ListByPackage,
6+
type Package,
7+
LocalInstaller,
8+
Options,
9+
} from './index.ts';
510
import { progressReporter } from './progress.ts';
611

712
function filterTruthy(values: Array<Package | null>): Package[] {
@@ -34,12 +39,14 @@ function siblingTargetsCurrent(siblingPackage: Package): boolean {
3439
}
3540

3641
export const siblingInstaller = {
37-
async install(): Promise<void> {
42+
async install(options: Options): Promise<void> {
3843
const siblings = await readSiblingTargets();
3944
const targets = siblings.filter(siblingTargetsCurrent);
4045
const sourceByTarget: ListByPackage = {};
4146
targets.forEach((target) => (sourceByTarget[target.directory] = ['.']));
42-
const installer = new LocalInstaller(sourceByTarget);
47+
const installer = new LocalInstaller(sourceByTarget, {
48+
packageManager: options.packageManager,
49+
});
4350
progressReporter.report(installer);
4451
await installer.install();
4552
},

test/integration/cli.it.spec.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const installLocal = path.resolve('bin', 'install-local');
1010
const tmpDir = path.resolve(os.tmpdir(), 'local-installer-it');
1111
const tmpFolder = (name: string) => path.resolve(tmpDir, name);
1212

13-
describe('install-local cli given 3 packages', () => {
13+
describe('install-local cli', () => {
1414
let packages: {
1515
one: PackageHelper;
1616
two: PackageHelper;
@@ -85,13 +85,62 @@ describe('install-local cli given 3 packages', () => {
8585
cwd: packages.one.directory,
8686
});
8787
});
88+
it('should support pnpm', async () => {
89+
// Arrange
90+
packages.one.packageJson.localDependencies = {
91+
two: '../two',
92+
};
93+
packages.two.packageJson.version = '1.0.0';
94+
packages.one.pnpmLock = emptyPnpmLockFile;
95+
await Promise.all([
96+
packages.one.writePackage(),
97+
packages.two.writePackage(),
98+
]);
99+
await execaCommand('pnpm add -E [email protected]', {
100+
cwd: packages.one.directory,
101+
});
102+
const expectedLink = await fs.readlink(
103+
path.resolve(packages.one.directory, 'node_modules', 'typed-inject'),
104+
);
105+
expect(expectedLink).to.contain(
106+
path.join('.pnpm', '[email protected]', 'node_modules', 'typed-inject'),
107+
); // verify arrange
108+
const expectedPnpmLock = await packages.one.readFile('pnpm-lock.yaml');
109+
110+
// Act
111+
await execaCommand(`node ${installLocal}`, {
112+
cwd: packages.one.directory,
113+
});
114+
115+
// Assert
116+
const installed = await packages.one.readdir('node_modules');
117+
expect(installed).to.deep.eq(['two', 'typed-inject']);
118+
const actualLink = await fs.readlink(
119+
path.resolve(packages.one.directory, 'node_modules', 'typed-inject'),
120+
);
121+
expect(actualLink).to.eq(expectedLink); // verify arrange
122+
expect(await packages.one.readFile('pnpm-lock.yaml')).to.eq(
123+
expectedPnpmLock,
124+
);
125+
const actualPackageOne = JSON.parse(
126+
await packages.one.readFile('package.json'),
127+
) as PackageJson;
128+
expect(actualPackageOne.dependencies).deep.eq({
129+
'typed-inject': '5.0.0',
130+
});
131+
expect(actualPackageOne.devDependencies).to.be.undefined;
132+
expect(actualPackageOne.localDependencies).to.deep.eq({
133+
two: '../two',
134+
});
135+
});
88136
});
89137

90138
class PackageHelper implements Package {
91139
private name;
92140
public directory: string;
93141
public packageJson: PackageJson;
94142
public packageLock: Record<string, unknown> | undefined;
143+
public pnpmLock: string | undefined;
95144
constructor(name: string) {
96145
this.name = name;
97146
this.directory = tmpFolder(name);
@@ -124,6 +173,24 @@ class PackageHelper implements Package {
124173
'utf-8',
125174
)
126175
: Promise.resolve(),
176+
this.pnpmLock
177+
? fs.writeFile(
178+
path.resolve(this.directory, 'pnpm-lock.yaml'),
179+
this.pnpmLock,
180+
'utf-8',
181+
)
182+
: Promise.resolve(),
127183
]);
128184
}
129185
}
186+
187+
const emptyPnpmLockFile = `lockfileVersion: '9.0'
188+
189+
settings:
190+
autoInstallPeers: true
191+
excludeLinksFromLockfile: false
192+
193+
importers:
194+
195+
.: {}
196+
`;

0 commit comments

Comments
 (0)