Skip to content

Commit

Permalink
♻️ Refactor @percy/cli to use @percy/cli-command (#676)
Browse files Browse the repository at this point in the history
Historically, this package was used as the entry point to Percy CLI and its various commands through oclif plugins, which are defined by the `package.json` file's `oclif` entry. To support autoloading of plugins found within a project's dependencies, this package was adapted to modify its own package.json at load time before oclif reads it. 

This alleviated requiring oclif's plugins plugin to manually install plugins and allowed us to rely on Node's normal module resolution. However, in part due to oclif's plugin loading, and in part due to our own plugin discovery, the CLI _only_ worked with Node's native module loading (and not plug-n-play environments).

With the new `@percy/cli-command` as a foundation, oclif can be peeled away to give us complete control over plugin loading. This is done by using a function as the `commands` definition argument which when called will search for plugins within the projects dependencies as well as this packages own and sibling dependencies. 

For environments with a native module loading, it will walk through top-level directories within `node_modules` (and recurse into the `@percy` scope). For Yarn 2+, it will use the PnP API to find declared package dependencies. Matching CLI plugins will be named either `@percy/cli-` or `percy-cli-` and contain a `package.json` entry for `@percy/cli`. And for legacy Percy oclif plugins, command classes are found and transformed into functional commands using the `legacyCommand` function from `@percy/cli-command`.
  • Loading branch information
Wil Wilsman authored Dec 16, 2021
1 parent f04e979 commit 5464cc6
Show file tree
Hide file tree
Showing 12 changed files with 470 additions and 231 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"karma-mocha-reporter": "^2.2.5",
"karma-rollup-preprocessor": "^7.0.5",
"lerna": "^4.0.0",
"memfs": "^3.4.0",
"nock": "^13.1.1",
"nyc": "^15.1.0",
"rollup": "^2.53.2",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ integrations, Percy API communication, DOM serialization, asset discovery, etc.
- [`@percy/config`](./packages/config#readme) - loads Percy configuration files
- [`@percy/logger`](./packages/logger#readme) - common logger used throughout the CLI
- [`@percy/sdk-utils`](./packages/sdk-utils#readme) - shared helpers for JavaScript SDKs
- [`@percy/cli-command`](./packages/cli-command#readme) - Percy CLI command framework

## Issues

Expand All @@ -56,7 +57,7 @@ use the following scripts for various development tasks:
- `yarn build:watch` - build and watch all packages in parallel
- `yarn clean` - clean up build and coverage output
- `yarn lint` - lint all packages
- `yarn readme` - generate oclif readme usage
- `yarn readme` - generate cli commands readme usage
- `yarn test` - run all tests, one package after another
- `yarn test:coverage` - run all tests with coverage, one package after another

Expand Down
12 changes: 9 additions & 3 deletions packages/cli/bin/run
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
#!/usr/bin/env node

require('@percy/cli').run()
.then(require('@oclif/command/flush'))
.catch(require('@oclif/errors/handle'))
if (parseInt(process.version.split('.')[0].substring(1), 10) < 12) {
console.error(`Node ${process.version} is not supported. Percy only ` + (
'supports the current LTS version of Node. Please upgrade to Node 12+'));
process.exit(1);
}

import('@percy/cli').then(async ({ percy }) => {
await percy(process.argv.slice(2));
});
69 changes: 0 additions & 69 deletions packages/cli/index.js

This file was deleted.

40 changes: 15 additions & 25 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,39 @@
"name": "@percy/cli",
"version": "1.0.0-beta.71",
"license": "MIT",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://github.com/percy/cli",
"directory": "packages/cli"
},
"publishConfig": {
"access": "public"
},
"main": "dist/index.js",
"bin": {
"percy": "bin/run"
},
"files": [
"bin",
"index.js",
"oclif.manifest.json"
"dist"
],
"engines": {
"node": ">=12"
},
"scripts": {
"build": "node ../../scripts/build",
"lint": "eslint --ignore-path ../../.gitignore .",
"test": "node ../../scripts/test",
"test:coverage": "yarn test --coverage"
},
"publishConfig": {
"access": "public"
},
"oclif": {
"bin": "percy",
"plugins": [
"@percy/cli-config",
"@percy/cli-exec",
"@percy/cli-build",
"@percy/cli-snapshot",
"@percy/cli-upload",
"@oclif/plugin-help"
]
},
"dependencies": {
"@oclif/command": "^1.8.0",
"@oclif/plugin-help": "^3.2.0",
"@percy/cli-build": "1.0.0-beta.71",
"@percy/cli-command": "1.0.0-beta.71",
"@percy/cli-config": "1.0.0-beta.71",
"@percy/cli-exec": "1.0.0-beta.71",
"@percy/cli-snapshot": "1.0.0-beta.71",
"@percy/cli-upload": "1.0.0-beta.71"
},
"repository": {
"type": "git",
"url": "https://github.com/percy/cli",
"directory": "packages/cli"
"@percy/cli-upload": "1.0.0-beta.71",
"@percy/client": "1.0.0-beta.71",
"@percy/logger": "1.0.0-beta.71"
}
}
162 changes: 162 additions & 0 deletions packages/cli/src/commands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import os from 'os';
import fs from 'fs';
import path from 'path';
import { findPnpApi } from 'module';
import logger from '@percy/logger';
import { command, legacyCommand } from '@percy/cli-command';

// Helper to simplify reducing async functions
async function reduceAsync(iter, reducer, accum = []) {
for (let i of iter) accum = await reducer(accum, i);
return accum;
}

// Helper to read and reduce files within a directory
function reduceFiles(dir, reducer) {
return reduceAsync(fs.readdirSync(dir, { withFileTypes: true }), reducer);
}

// Returns the paths of potential percy packages found within node_modules
function findModulePackages(dir) {
try {
// not given node_modules or a directory that contains node_modules, look up
if (path.basename(dir) !== 'node_modules') {
let modulesPath = path.join(dir, 'node_modules');
let next = fs.existsSync(modulesPath) ? modulesPath : path.dirname(dir);
if (next === dir || next === os.homedir()) return [];
return findModulePackages(next);
}

// given node modules, look for percy packages
return reduceFiles(dir, async (roots, file) => {
let rootPath = path.join(dir, file.name);

if (file.name === '@percy') {
return roots.concat(await reduceFiles(rootPath, (dirs, f) => (
// specifically protect against files to allow linked directories
f.isFile() ? dirs : dirs.concat(path.join(rootPath, f.name))
), []));
} else if (file.name.startsWith('percy-cli-')) {
return roots.concat(rootPath);
} else {
return roots;
}
}, []);
} catch (error) {
logger('cli:plugins').debug(error);
return [];
}
}

// Used by `findPnpPackages` to filter Percy CLI plugins
const PERCY_PKG_REG = /^(@percy\/|percy-cli-)/;

// Returns the paths of potential percy packages found within yarn's pnp system
function findPnpPackages(dir) {
let pnpapi = findPnpApi?.(`${dir}/`);
let pkgLoc = pnpapi?.findPackageLocator(`${dir}/`);
let pkgInfo = pkgLoc && pnpapi?.getPackageInformation(pkgLoc);
let pkgDeps = pkgInfo?.packageDependencies.entries() ?? [];

return Array.from(pkgDeps).reduce((roots, [name, ref]) => {
if (!ref || !PERCY_PKG_REG.test(name)) return roots;
let depLoc = pnpapi.getLocator(name, ref);
let depInfo = pnpapi.getPackageInformation(depLoc);
return roots.concat(depInfo.packageLocation);
}, []);
}

// Helper to import and wrap legacy percy commands for reverse compatibility
function importLegacyCommands(commandsPath) {
return reduceFiles(commandsPath, async (cmds, file) => {
let { name } = path.parse(file.name);
let filepath = path.join(commandsPath, name);

if (file.isDirectory()) {
// recursively import nested commands and find the index command
let commands = await importLegacyCommands(filepath);
let index = commands.findIndex(cmd => cmd.name === 'index');

// modify or create an index command to hold nested commands
index = ~index ? commands.splice(index, 1)[0] : command();
Object.defineProperty(index, 'name', { value: name });
index.definition.commands = commands;

return cmds.concat(index);
} else {
// find and wrap the command exported by the module
let exports = Object.values(await import(filepath));
let cmd = exports.find(e => typeof e?.prototype?.run === 'function');
return cmd ? cmds.concat(legacyCommand(name, cmd)) : cmds;
}
});
}

// Imports and returns compatibile CLI commands from various sources
export async function importCommands() {
// start with a set to get built-in deduplication
let cmdPkgs = await reduceAsync(new Set([
// find included dependencies
path.join(__dirname, '..'),
// find potential sibling packages
path.join(__dirname, '..', '..'),
// find any current project dependencies
process.cwd()
]), async (roots, dir) => {
roots.push(...await findModulePackages(dir));
roots.push(...await findPnpPackages(dir));
return roots;
});

// reduce found packages to functions which import cli commands
let cmdImports = await reduceAsync(cmdPkgs, async (pkgs, pkgPath) => {
let pkg = require(path.join(pkgPath, 'package.json'));
// do not include self
if (pkg.name === '@percy/cli') return pkgs;

// support legacy oclif percy commands
if (pkg.oclif?.bin === 'percy') {
pkgs.set(pkg.name, async () => {
if (pkg.oclif.hooks?.init) {
let initPath = path.join(pkgPath, pkg.oclif.hooks.init);
let init = await import(initPath);
await init.default();
}

if (pkg.oclif.commands) {
let commandsPath = path.join(pkgPath, pkg.oclif.commands);
return importLegacyCommands(commandsPath);
}

return [];
});
}

// overwrite any found package of the same name
if (pkg['@percy/cli']?.commands) {
pkgs.set(pkg.name, () => Promise.all(
pkg['@percy/cli'].commands.map(async cmdPath => {
let module = await import(path.join(pkgPath, cmdPath));
return module.default;
})
));
}

return pkgs;
}, new Map());

// actually import found commands
let cmds = await reduceAsync(
cmdImports.values(),
async (cmds, importCmds) => (
cmds.concat(await importCmds())
)
);

// sort standalone commands before command topics
return cmds.sort((a, b) => {
if (a.callback && !b.callback) return -1;
if (b.callback && !a.callback) return 1;
return a.name.localeCompare(b.name);
});
}
1 change: 1 addition & 0 deletions packages/cli/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default, percy } from './percy';
11 changes: 11 additions & 0 deletions packages/cli/src/percy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import command from '@percy/cli-command';
import { importCommands } from './commands';
import pkg from '../package.json';

export const percy = command('percy', {
version: `${pkg.name} ${pkg.version}`,
commands: () => importCommands(),
exitOnError: true
});

export default percy;
Loading

0 comments on commit 5464cc6

Please sign in to comment.