Skip to content

Refactoring of the CLI interface #291

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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
15 changes: 15 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
"registry": {
"type": "npm",
"package": "npm"
},
"commands": {
"use": ["npm", "install"]
}
}
}
Expand Down Expand Up @@ -62,6 +65,9 @@
"registry": {
"type": "npm",
"package": "pnpm"
},
"commands": {
"use": ["pnpm", "install"]
}
},
">=6.0.0": {
Expand All @@ -73,6 +79,9 @@
"registry": {
"type": "npm",
"package": "pnpm"
},
"commands": {
"use": ["pnpm", "install"]
}
}
}
Expand Down Expand Up @@ -102,6 +111,9 @@
"registry": {
"type": "npm",
"package": "yarn"
},
"commands": {
"use": ["yarn", "install"]
}
},
">=2.0.0": {
Expand All @@ -118,6 +130,9 @@
"tags": "latest",
"versions": "tags"
}
},
"commands": {
"use": ["yarn", "install"]
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@
"@jest/globals": "^29.0.0",
"@types/debug": "^4.1.5",
"@types/jest": "^29.0.0",
"@types/node": "^20.0.0",
"@types/node": "^20.4.6",
"@types/semver": "^7.1.0",
"@types/tar": "^6.0.0",
"@types/which": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"@yarnpkg/eslint-config": "^0.6.0-rc.7",
"@yarnpkg/fslib": "^2.1.0",
"@yarnpkg/fslib": "^3.0.0-rc.48",
"@zkochan/cmd-shim": "^6.0.0",
"babel-plugin-dynamic-import-node": "^2.3.3",
"clipanion": "^3.0.1",
Expand Down
62 changes: 31 additions & 31 deletions sources/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as semverUtils from './semverUtil
import {Config, Descriptor, Locator} from './types';
import {SupportedPackageManagers, SupportedPackageManagerSet} from './types';

export type PreparedPackageManagerInfo = Awaited<ReturnType<Engine[`ensurePackageManager`]>>;

export class Engine {
constructor(public config: Config = defaultConfig as Config) {
Expand All @@ -33,6 +34,19 @@ export class Engine {
return null;
}

getPackageManagerSpecFor(locator: Locator) {
const definition = this.config.definitions[locator.name];
if (typeof definition === `undefined`)
throw new UsageError(`This package manager (${locator.name}) isn't supported by this corepack build`);

const ranges = Object.keys(definition.ranges).reverse();
const range = ranges.find(range => semverUtils.satisfiesWithPrereleases(locator.reference, range));
if (typeof range === `undefined`)
throw new Error(`Assertion failed: Specified resolution (${locator.reference}) isn't supported by any of ${ranges.join(`, `)}`);

return definition.ranges[range];
}

getBinariesFor(name: SupportedPackageManagers) {
const binNames = new Set<string>();

Expand Down Expand Up @@ -111,25 +125,23 @@ export class Engine {
}

async ensurePackageManager(locator: Locator) {
const definition = this.config.definitions[locator.name];
if (typeof definition === `undefined`)
throw new UsageError(`This package manager (${locator.name}) isn't supported by this corepack build`);

const ranges = Object.keys(definition.ranges).reverse();
const range = ranges.find(range => semverUtils.satisfiesWithPrereleases(locator.reference, range));
if (typeof range === `undefined`)
throw new Error(`Assertion failed: Specified resolution (${locator.reference}) isn't supported by any of ${ranges.join(`, `)}`);
const spec = this.getPackageManagerSpecFor(locator);

const installedLocation = await corepackUtils.installVersion(folderUtils.getInstallFolder(), locator, {
spec: definition.ranges[range],
const packageManagerInfo = await corepackUtils.installVersion(folderUtils.getInstallFolder(), locator, {
spec,
});

return {
location: installedLocation,
spec: definition.ranges[range],
...packageManagerInfo,
locator,
spec,
};
}

async fetchAvailableVersions() {

}

async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}) {
const definition = this.config.definitions[descriptor.name];
if (typeof definition === `undefined`)
Expand All @@ -138,7 +150,7 @@ export class Engine {
let finalDescriptor = descriptor;
if (!semver.valid(descriptor.range) && !semver.validRange(descriptor.range)) {
if (!allowTags)
throw new UsageError(`Packages managers can't be referended via tags in this context`);
throw new UsageError(`Packages managers can't be referenced via tags in this context`);

// We only resolve tags from the latest registry entry
const ranges = Object.keys(definition.ranges);
Expand All @@ -165,28 +177,16 @@ export class Engine {
if (semver.valid(finalDescriptor.range))
return {name: finalDescriptor.name, reference: finalDescriptor.range};

const candidateRangeDefinitions = Object.keys(definition.ranges).filter(range => {
return semverUtils.satisfiesWithPrereleases(finalDescriptor.range, range);
});

const tagResolutions = await Promise.all(candidateRangeDefinitions.map(async range => {
return [range, await corepackUtils.fetchAvailableVersions(definition.ranges[range].registry)] as const;
const versions = await Promise.all(Object.keys(definition.ranges).map(async range => {
const versions = await corepackUtils.fetchAvailableVersions(definition.ranges[range].registry);
return versions.filter(version => semverUtils.satisfiesWithPrereleases(version, finalDescriptor.range));
}));

// If a version is available under multiple strategies (for example if
// Yarn is published to both the v1 package and git), we only care
// about the latest one
const resolutionMap = new Map();
for (const [range, resolutions] of tagResolutions)
for (const entry of resolutions)
resolutionMap.set(entry, range);

const candidates = [...resolutionMap.keys()];
const maxSatisfying = semver.maxSatisfying(candidates, finalDescriptor.range);
if (maxSatisfying === null)
const highestVersion = [...new Set(versions.flat())].sort(semver.rcompare);
if (highestVersion.length === 0)
return null;

return {name: finalDescriptor.name, reference: maxSatisfying};
return {name: finalDescriptor.name, reference: highestVersion[0]};
}

private getLastKnownGoodFile() {
Expand Down
64 changes: 64 additions & 0 deletions sources/commands/Base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {Command, UsageError} from 'clipanion';
import fs from 'fs';

import {PreparedPackageManagerInfo} from '../Engine';
import * as corepackUtils from '../corepackUtils';
import {Context} from '../main';
import * as nodeUtils from '../nodeUtils';
import * as specUtils from '../specUtils';

export abstract class BaseCommand extends Command<Context> {
async resolvePatternsToDescriptors({all, patterns}: {all: boolean, patterns: Array<string>}) {
if (all && patterns.length > 0)
throw new UsageError(`The --all option cannot be used along with an explicit package manager specification`);

const resolvedSpecs = all
? await this.context.engine.getDefaultDescriptors()
: patterns.map(pattern => specUtils.parseSpec(pattern, `CLI arguments`, {enforceExactVersion: false}));

if (resolvedSpecs.length === 0) {
const lookup = await specUtils.loadSpec(this.context.cwd);
switch (lookup.type) {
case `NoProject`:
throw new UsageError(`Couldn't find a project in the local directory - please explicit the package manager to pack, or run this command from a valid project`);

case `NoSpec`:
throw new UsageError(`The local project doesn't feature a 'packageManager' field - please explicit the package manager to pack, or update the manifest to reference it`);

default: {
return [lookup.spec];
}
}
}

return resolvedSpecs;
}

async setLocalPackageManager(info: PreparedPackageManagerInfo) {
const lookup = await specUtils.loadSpec(this.context.cwd);

const content = lookup.target !== `NoProject`
? await fs.promises.readFile(lookup.target, `utf8`)
: ``;

const {data, indent} = nodeUtils.readPackageJson(content);

const previousPackageManager = data.packageManager ?? `unknown`;
data.packageManager = `${info.locator.name}@${info.locator.reference}+${info.hash}`;

const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`);
await fs.promises.writeFile(lookup.target, newContent, `utf8`);

const command = this.context.engine.getPackageManagerSpecFor(info.locator).commands?.use ?? null;
if (command === null)
return 0;

// Adding it into the environment avoids breaking package managers that
// don't expect those options.
process.env.COREPACK_MIGRATE_FROM = previousPackageManager;
this.context.stdout.write(`\n`);

const [binaryName, ...args] = command;
return await corepackUtils.runVersion(info.locator, info, binaryName, args);
}
}
115 changes: 115 additions & 0 deletions sources/commands/InstallGlobal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {Command, Option, UsageError} from 'clipanion';
import fs from 'fs';
import path from 'path';

import * as folderUtils from '../folderUtils';
import * as specUtils from '../specUtils';
import {Descriptor, isSupportedPackageManager} from '../types';

import {BaseCommand} from './Base';

export class InstallGlobalCommand extends BaseCommand {
static paths = [
[`install`],
];

static usage = Command.Usage({
description: `Install package managers on the system`,
details: `
Install the selected package managers and install them on the system.

Package managers thus installed will be configured as the new default when calling their respective binaries outside of projects defining the 'packageManager' field.
`,
examples: [[
`Install the latest version of Yarn 1.x and make it globally available`,
`corepack install -g yarn@^1`,
], [
`Install the latest version of all available package managers, and make them globally available`,
`corepack install -g --all`,
]],
});

global = Option.Boolean(`-g,--global`, {
required: true,
});

all = Option.Boolean(`--all`, false, {
description: `If true, all available default package managers will be installed`,
});

args = Option.Rest();

async execute() {
if (this.args.length === 0 && !this.all)
throw new UsageError(`No package managers specified; use --all to install all available package managers, or specify one or more package managers to proceed`);

if (!this.all) {
for (const arg of this.args) {
if (arg.endsWith(`.tgz`)) {
await this.installFromTarball(path.resolve(this.context.cwd, arg));
} else {
await this.installFromDescriptor(specUtils.parseSpec(arg, `CLI arguments`, {enforceExactVersion: false}));
}
}
} else {
for (const descriptor of await this.context.engine.getDefaultDescriptors()) {
await this.installFromDescriptor(descriptor);
}
}
}

async installFromDescriptor(descriptor: Descriptor) {
const resolved = await this.context.engine.resolveDescriptor(descriptor, {allowTags: true, useCache: false});
if (resolved === null)
throw new UsageError(`Failed to successfully resolve '${descriptor.range}' to a valid ${descriptor.name} release`);

this.context.stdout.write(`Installing ${resolved.name}@${resolved.reference}...\n`);
await this.context.engine.ensurePackageManager(resolved);

await this.context.engine.activatePackageManager(resolved);
}

async installFromTarball(p: string) {
const installFolder = folderUtils.getInstallFolder();

const archiveEntries = new Map<string, Set<string>>();
const {default: tar} = await import(`tar`);

let hasShortEntries = false;

await tar.t({file: p, onentry: entry => {
const segments = entry.path.split(/\//g);
if (segments.length > 0 && segments[segments.length - 1] !== `.corepack`)
return;


if (segments.length < 3) {
hasShortEntries = true;
} else {
let references = archiveEntries.get(segments[0]);
if (typeof references === `undefined`)
archiveEntries.set(segments[0], references = new Set());

references.add(segments[1]);
}
}});

if (hasShortEntries || archiveEntries.size < 1)
throw new UsageError(`Invalid archive format; did it get generated by 'corepack pack'?`);

for (const [name, references] of archiveEntries) {
for (const reference of references) {
if (!isSupportedPackageManager(name))
throw new UsageError(`Unsupported package manager '${name}'`);

this.context.stdout.write(`Installing ${name}@${reference}...\n`);

// Recreate the folder in case it was deleted somewhere else:
await fs.promises.mkdir(installFolder, {recursive: true});

await tar.x({file: p, cwd: installFolder}, [`${name}/${reference}`]);
await this.context.engine.activatePackageManager({name, reference});
}
}
}
}
34 changes: 34 additions & 0 deletions sources/commands/InstallLocal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {Command, Option, UsageError} from 'clipanion';

import {BaseCommand} from './Base';

export class InstallLocalCommand extends BaseCommand {
static paths = [
[`install`],
];

static usage = Command.Usage({
description: `Install the package manager configured in the local project`,
details: `
Download and install the package manager configured in the local project. This command doesn't change the global version used when running the package manager from outside the project (use the \`-g,--global\` flag if you wish to do this).
`,
examples: [[
`Install the project's package manager in the cache`,
`corepack install`,
]],
});

async execute() {
const [descriptor] = await this.resolvePatternsToDescriptors({
all: false,
patterns: [],
});

const resolved = await this.context.engine.resolveDescriptor(descriptor, {allowTags: true});
if (resolved === null)
throw new UsageError(`Failed to successfully resolve '${descriptor.range}' to a valid ${descriptor.name} release`);

this.context.stdout.write(`Adding ${resolved.name}@${resolved.reference} to the cache...\n`);
await this.context.engine.ensurePackageManager(resolved);
}
}
Loading