Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: npm ci
- name: Build & lint & test
Expand All @@ -35,6 +37,8 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 'latest'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: npm ci
- name: test-mutation
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Options:

- `-h, --help`: Output this help
- `-S, --save`: Saved packages will appear in your package.json under "localDependencies"
- `--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).
- `-T, --target-siblings`: Instead of installing into this package, this package gets installed into sibling packages
which depend on this package by putting it in the "localDependencies".
Useful in a [lerna](https://github.com/lerna/lerna) style monorepo.
Expand Down
19 changes: 16 additions & 3 deletions src/LocalInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { helpers } from './helpers.ts';
import type { InstallTarget, PackageJson } from './index.ts';
import { utils } from './utils.ts';
import type { Options as ExecaOptions } from 'execa';
import { prober, type PackageManager } from './prober.ts';

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

export interface Options {
npmEnv?: Env;
packageManager: PackageManager | undefined;
}

export interface ListByPackage {
Expand Down Expand Up @@ -89,14 +91,25 @@ export class LocalInstaller extends EventEmitter {
const toInstall = target.sources.map((source) =>
resolvePackFile(this.uniqueDir, source.packageJson),
);
const pkgManager =
this.options.packageManager ?? (await prober.probePackageManager());
const options: ExecaOptions = {
cwd: target.directory,
maxBuffer: TEN_MEGA_BYTE,
env: this.options.npmEnv,
env: {
...this.options.npmEnv,
npm_config_save: 'false',
npm_config_lockfile: 'false',
},
};
const installArgs =
pkgManager === 'pnpm'
? ['add', ...toInstall]
: ['i', '--no-save', '--no-package-lock', ...toInstall];

const { stdout, stderr } = await utils.exec(
'npm',
['i', '--no-save', '--no-package-lock', ...toInstall],
pkgManager,
installArgs,
options,
);
this.emit(
Expand Down
20 changes: 20 additions & 0 deletions src/Options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { validPackageManagers, type PackageManager } from './prober.ts';

export class Options {
public readonly dependencies: string[];
public readonly options: string[];
Expand All @@ -21,6 +23,15 @@ export class Options {
`Invalid use of option --target-siblings. Cannot be used together with --save`,
),
);
} else if (
this.packageManager &&
!validPackageManagers.includes(this.packageManager)
) {
return Promise.reject(
new Error(
`Invalid package manager <${this.packageManager}> specified. Please use either 'npm' or 'pnpm'.`,
),
);
} else {
return Promise.resolve();
}
Expand All @@ -34,6 +45,15 @@ export class Options {
return this.flag('-T', '--target-siblings');
}

public get packageManager(): PackageManager | undefined {
const pkgOption = this.options.find((opt) => opt.startsWith('--pkg='));
if (pkgOption) {
const [, value] = pkgOption.split('=');
return value as PackageManager;
}
return undefined;
}

public get save(): boolean {
return this.flag('-S', '--save');
}
Expand Down
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export async function cli(argv: string[]): Promise<void> {
l(
' -S, --save Saved packages will appear in your package.json under "localDependencies"',
);
l(
'--pkg=<npm|pnpm> Specify which package manager to use (default: auto-detect)',
);
l(
' -T, --target-siblings Instead of installing into this package, this package gets installed into sibling packages',
);
Expand Down
9 changes: 6 additions & 3 deletions src/currentDirectoryInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import { storage } from './save.ts';
export const currentDirectoryInstaller = {
install: async (options: Options): Promise<void> => {
const localDependencies = await readLocalDependencies(options.dependencies);
const installer = new LocalInstaller({
[process.cwd()]: localDependencies,
});
const installer = new LocalInstaller(
{
[process.cwd()]: localDependencies,
},
{ packageManager: options.packageManager },
);
progressReporter.report(installer);
const targets = await installer.install();
await storage.saveIfNeeded(targets, options);
Expand Down
2 changes: 1 addition & 1 deletion src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { siblingInstaller } from './siblingInstall.ts';

export function execute(options: Options): Promise<void> {
if (options.targetSiblings) {
return siblingInstaller.install();
return siblingInstaller.install(options);
} else {
return currentDirectoryInstaller.install(options);
}
Expand Down
17 changes: 17 additions & 0 deletions src/prober.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const validPackageManagers = ['npm', 'pnpm'] as const;
export type PackageManager = (typeof validPackageManagers)[number];
import { promises as fs } from 'fs';

export const prober = {
probePackageManager: async (): Promise<PackageManager> => {
// Check for pnpm-lock.yaml
try {
await fs.access('pnpm-lock.yaml');
return 'pnpm';
} catch {
// File does not exist
}
// Default to npm if no lock file found
return 'npm';
},
};
13 changes: 10 additions & 3 deletions src/siblingInstall.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { promises as fs } from 'fs';
import path from 'path';
import { helpers } from './helpers.ts';
import { type ListByPackage, type Package, LocalInstaller } from './index.ts';
import {
type ListByPackage,
type Package,
LocalInstaller,
Options,
} from './index.ts';
import { progressReporter } from './progress.ts';

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

export const siblingInstaller = {
async install(): Promise<void> {
async install(options: Options): Promise<void> {
const siblings = await readSiblingTargets();
const targets = siblings.filter(siblingTargetsCurrent);
const sourceByTarget: ListByPackage = {};
targets.forEach((target) => (sourceByTarget[target.directory] = ['.']));
const installer = new LocalInstaller(sourceByTarget);
const installer = new LocalInstaller(sourceByTarget, {
packageManager: options.packageManager,
});
progressReporter.report(installer);
await installer.install();
},
Expand Down
69 changes: 68 additions & 1 deletion test/integration/cli.it.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const installLocal = path.resolve('bin', 'install-local');
const tmpDir = path.resolve(os.tmpdir(), 'local-installer-it');
const tmpFolder = (name: string) => path.resolve(tmpDir, name);

describe('install-local cli given 3 packages', () => {
describe('install-local cli', () => {
let packages: {
one: PackageHelper;
two: PackageHelper;
Expand Down Expand Up @@ -85,13 +85,62 @@ describe('install-local cli given 3 packages', () => {
cwd: packages.one.directory,
});
});
it('should support pnpm', async () => {
// Arrange
packages.one.packageJson.localDependencies = {
two: '../two',
};
packages.two.packageJson.version = '1.0.0';
packages.one.pnpmLock = emptyPnpmLockFile;
await Promise.all([
packages.one.writePackage(),
packages.two.writePackage(),
]);
await execaCommand('pnpm add -E typed-inject@5.0.0', {
cwd: packages.one.directory,
});
const expectedLink = await fs.readlink(
path.resolve(packages.one.directory, 'node_modules', 'typed-inject'),
);
expect(expectedLink).to.contain(
path.join('.pnpm', 'typed-inject@5.0.0', 'node_modules', 'typed-inject'),
); // verify arrange
const expectedPnpmLock = await packages.one.readFile('pnpm-lock.yaml');

// Act
await execaCommand(`node ${installLocal}`, {
cwd: packages.one.directory,
});

// Assert
const installed = await packages.one.readdir('node_modules');
expect(installed).to.deep.eq(['two', 'typed-inject']);
const actualLink = await fs.readlink(
path.resolve(packages.one.directory, 'node_modules', 'typed-inject'),
);
expect(actualLink).to.eq(expectedLink); // verify arrange
expect(await packages.one.readFile('pnpm-lock.yaml')).to.eq(
expectedPnpmLock,
);
const actualPackageOne = JSON.parse(
await packages.one.readFile('package.json'),
) as PackageJson;
expect(actualPackageOne.dependencies).deep.eq({
'typed-inject': '5.0.0',
});
expect(actualPackageOne.devDependencies).to.be.undefined;
expect(actualPackageOne.localDependencies).to.deep.eq({
two: '../two',
});
});
});

class PackageHelper implements Package {
private name;
public directory: string;
public packageJson: PackageJson;
public packageLock: Record<string, unknown> | undefined;
public pnpmLock: string | undefined;
constructor(name: string) {
this.name = name;
this.directory = tmpFolder(name);
Expand Down Expand Up @@ -124,6 +173,24 @@ class PackageHelper implements Package {
'utf-8',
)
: Promise.resolve(),
this.pnpmLock
? fs.writeFile(
path.resolve(this.directory, 'pnpm-lock.yaml'),
this.pnpmLock,
'utf-8',
)
: Promise.resolve(),
]);
}
}

const emptyPnpmLockFile = `lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
`;
Loading