Skip to content

Commit 526c8a5

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

File tree

13 files changed

+288
-25
lines changed

13 files changed

+288
-25
lines changed

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: 15 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,24 @@ export class LocalInstaller extends EventEmitter {
8991
const toInstall = target.sources.map((source) =>
9092
resolvePackFile(this.uniqueDir, source.packageJson),
9193
);
94+
const pkgManager = this.options.packageManager ?? (await prober.probePackageManager());
9295
const options: ExecaOptions = {
9396
cwd: target.directory,
9497
maxBuffer: TEN_MEGA_BYTE,
95-
env: this.options.npmEnv,
98+
env: {
99+
...this.options.npmEnv,
100+
npm_config_save: 'false',
101+
npm_config_lockfile: 'false',
102+
},
96103
};
104+
const installArgs =
105+
pkgManager === 'pnpm'
106+
? ['add', ...toInstall]
107+
: ['i', '--no-save', '--no-package-lock', ...toInstall];
108+
97109
const { stdout, stderr } = await utils.exec(
98-
'npm',
99-
['i', '--no-save', '--no-package-lock', ...toInstall],
110+
pkgManager,
111+
installArgs,
100112
options,
101113
);
102114
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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
2+
export const validPackageManagers = ['npm', 'pnpm'] as const;
3+
export type PackageManager = typeof validPackageManagers[number];
4+
import { promises as fs } from 'fs';
5+
6+
export const prober = {
7+
probePackageManager: async (): Promise<PackageManager> => {
8+
// Check for pnpm-lock.yaml
9+
try {
10+
await fs.access('pnpm-lock.yaml');
11+
return 'pnpm';
12+
} catch {
13+
// File does not exist
14+
}
15+
// Default to npm if no lock file found
16+
return 'npm';
17+
18+
}
19+
}

src/siblingInstall.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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 { type ListByPackage, type Package, LocalInstaller, Options } from './index.ts';
55
import { progressReporter } from './progress.ts';
66

77
function filterTruthy(values: Array<Package | null>): Package[] {
@@ -34,12 +34,14 @@ function siblingTargetsCurrent(siblingPackage: Package): boolean {
3434
}
3535

3636
export const siblingInstaller = {
37-
async install(): Promise<void> {
37+
async install(options: Options): Promise<void> {
3838
const siblings = await readSiblingTargets();
3939
const targets = siblings.filter(siblingTargetsCurrent);
4040
const sourceByTarget: ListByPackage = {};
4141
targets.forEach((target) => (sourceByTarget[target.directory] = ['.']));
42-
const installer = new LocalInstaller(sourceByTarget);
42+
const installer = new LocalInstaller(sourceByTarget, {
43+
packageManager: options.packageManager,
44+
});
4345
progressReporter.report(installer);
4446
await installer.install();
4547
},

test/integration/cli.it.spec.ts

Lines changed: 66 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,60 @@ 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 typed-inject@5.0.0', {
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.eq('.pnpm/typed-inject@5.0.0/node_modules/typed-inject'); // verify arrange
106+
const expectedPnpmLock = await packages.one.readFile('pnpm-lock.yaml');
107+
108+
// Act
109+
await execaCommand(`node ${installLocal}`, {
110+
cwd: packages.one.directory,
111+
});
112+
113+
// Assert
114+
const installed = await packages.one.readdir('node_modules');
115+
expect(installed).to.deep.eq(['two', 'typed-inject']);
116+
const actualLink = await fs.readlink(
117+
path.resolve(packages.one.directory, 'node_modules', 'typed-inject'),
118+
);
119+
expect(actualLink).to.eq(expectedLink); // verify arrange
120+
expect(await packages.one.readFile('pnpm-lock.yaml')).to.eq(
121+
expectedPnpmLock,
122+
);
123+
const actualPackageOne = JSON.parse(
124+
await packages.one.readFile('package.json'),
125+
) as PackageJson;
126+
expect(actualPackageOne.dependencies).deep.eq({
127+
'typed-inject': '5.0.0',
128+
});
129+
expect(actualPackageOne.devDependencies).to.be.undefined;
130+
expect(actualPackageOne.localDependencies).to.deep.eq({
131+
two: '../two',
132+
});
133+
});
88134
});
89135

90136
class PackageHelper implements Package {
91137
private name;
92138
public directory: string;
93139
public packageJson: PackageJson;
94140
public packageLock: Record<string, unknown> | undefined;
141+
public pnpmLock: string | undefined;
95142
constructor(name: string) {
96143
this.name = name;
97144
this.directory = tmpFolder(name);
@@ -124,6 +171,24 @@ class PackageHelper implements Package {
124171
'utf-8',
125172
)
126173
: Promise.resolve(),
174+
this.pnpmLock
175+
? fs.writeFile(
176+
path.resolve(this.directory, 'pnpm-lock.yaml'),
177+
this.pnpmLock,
178+
'utf-8',
179+
)
180+
: Promise.resolve(),
127181
]);
128182
}
129183
}
184+
185+
const emptyPnpmLockFile = `lockfileVersion: '9.0'
186+
187+
settings:
188+
autoInstallPeers: true
189+
excludeLinksFromLockfile: false
190+
191+
importers:
192+
193+
.: {}
194+
`;

0 commit comments

Comments
 (0)