Skip to content

Commit

Permalink
chore(scripts): add cli dispatcher helper (#4312)
Browse files Browse the repository at this point in the history
  • Loading branch information
kuhe committed Dec 23, 2022
1 parent e3d8d44 commit 6fe57fe
Show file tree
Hide file tree
Showing 8 changed files with 285 additions and 0 deletions.
140 changes: 140 additions & 0 deletions scripts/cli-dispatcher/index.js
@@ -0,0 +1,140 @@
#!/usr/bin/env node

const fs = require("fs");
const path = require("path");
const readline = require("readline");

const findFolders = require("./lib/findFolders");
const findScripts = require("./lib/findScripts");
const Package = require("./lib/Package");

/**
* This script takes your command line arguments and infers the
* package in which to execute them.
*
* It is supposed to save time moving among clients/packages/lib folders
* for building and running other test commands.
*/
async function main() {
console.log("CLI dispatcher");

const root = path.join(__dirname, "..", "..");
const argv = process.argv;

const clients = fs.readdirSync(path.join(root, "clients"));
const lib = fs.readdirSync(path.join(root, "lib"));
const packages = fs.readdirSync(path.join(root, "packages"));

const allPackages = [
...clients.map((c) => new Package(c, path.join(root, "clients", c))),
...lib.map((l) => new Package(l, path.join(root, "lib", l))),
...packages.map((p) => new Package(p, path.join(root, "packages", p))),
];

const [node, dispatcher, ...rest] = argv;
const flags = rest.filter((f) => f.startsWith("--"));
const options = {
dry: flags.includes("--dry"),
help: flags.includes("--help") || rest.length === 0,
confirm: flags.includes("--c"),
};

if (options.help) {
console.info(`
Usage:
b [package query words] - [command query words]
b c s3 c - b t
matches to:
(cd clients/client-s3-control && yarn build:types)
Query words are substrings that match against the package name and npm scripts.
The substrings must appear in order for a match.
Match priority goes to whole-word matching and initial matching.
Options:
--dry
dry run with no command execution.
--help
show this message.
--c
ask for confirmation before executing command.
`);
return 0;
}

const nonFlags = rest.filter((_) => !_.startsWith("--"));
const separatorIndex = rest.indexOf("-") !== -1 ? rest.indexOf("-") : rest.length;
const query = nonFlags.slice(0, separatorIndex);
const commands = nonFlags.slice(separatorIndex + 1);

const matchedPackages = findFolders(allPackages, ...query);

if (matchedPackages.length === 0) {
console.error("No matching packages for query:", query);
return 0;
}

console.log("query:", ...query);
console.log(
"matches:",
matchedPackages.map((_) => _.name)
);

const [target] = matchedPackages;

const targetPkgJson = require(path.join(target.location, "package.json"));
const matchedScripts = findScripts(Object.keys(targetPkgJson.scripts || {}), ...commands);
const [script] = matchedScripts;

if (commands.length === 0) {
console.info("No commands entered");
return 0;
}

if (matchedScripts.length === 0) {
console.error("No matching scripts for command query:", commands);
return 0;
}

console.log("commands:", ...commands);
console.log("matched commands:", matchedScripts);

const command = `yarn ${script} in ${target.location}`;

if (options.dry) {
console.log("DRYRUN:", command);
return 0;
}

const execute = async () => {
const { spawnProcess } = require("../utils/spawn-process");
console.info("Running:", "yarn", script);
console.info("Location:", target.location);
await spawnProcess("yarn", [script], {
cwd: target.location,
});
return;
};

if (options.confirm) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

rl.question(`run script "${script}" in ${target.location} (y)/n?:`, async (confirm) => {
if (confirm.toLowerCase().trim() === "y" || confirm === "") {
await execute();
}
rl.close();
});
return 0;
}

await execute();

return 0;
}

main().catch(console.error);
6 changes: 6 additions & 0 deletions scripts/cli-dispatcher/lib/Package.js
@@ -0,0 +1,6 @@
module.exports = class Package {
constructor(name, location) {
this.name = name;
this.location = location;
}
};
19 changes: 19 additions & 0 deletions scripts/cli-dispatcher/lib/findFolders.js
@@ -0,0 +1,19 @@
const matcher = require("./matcher");
const matchSorter = require("./matchSorter");

/**
* @param allPackages {Package[]} - list of all packages.
* @param query {string} - query for the package list.
* @returns the folders matching the args.
*/
module.exports = function findFolders(allPackages, ...query) {
const folders = [];
for (const pkg of allPackages) {
const { name } = pkg;
const isMatch = matcher(name, ...query);
if (isMatch) {
folders.push(pkg);
}
}
return matchSorter(folders, ...query);
};
18 changes: 18 additions & 0 deletions scripts/cli-dispatcher/lib/findScripts.js
@@ -0,0 +1,18 @@
const matcher = require("./matcher");
const matchSorter = require("./matchSorter");

/**
* @param scripts {string[]} - scripts entry from a package.json file.
* @param query {string} - query for the script names.
* @returns the scripts matching the args.
*/
module.exports = function findScripts(scripts, ...query) {
const matches = [];
for (const script of scripts) {
const isMatch = matcher(script, ...query);
if (isMatch) {
matches.push(script);
}
}
return matchSorter(matches, ...query);
};
66 changes: 66 additions & 0 deletions scripts/cli-dispatcher/lib/matchSorter.js
@@ -0,0 +1,66 @@
/**
* @param {string[]} matches - unordered list of matches.
* @param {...string} query - original query that generated matches
* @returns {string[]} matches sorted by estimated priority.
*
* matches that start with the first query component are prioritized.
* @example
* (["build:types", "test"], "t") -> ["test", "build:types"]
*
* Matches that contain a full word match with the query are prioritized.
* @example
* (["presigner", "signer"], "signer") -> ["signer", "presigner"]
*
* Shorter matches are prioritized.
*/
module.exports = function matchSorter(matches, ...query) {
return matches.sort((a, b) => {
a = a.name || a;
b = b.name || b;

let score = 0;

if (wholeWordMatch(a, ...query)) {
score -= 100;
}
if (wholeWordMatch(b, ...query)) {
score += 100;
}
if (wordInitialMatch(a, ...query)) {
score -= 10;
}
if (wordInitialMatch(b, ...query)) {
score += 10;
}
if (a.length < b.length) {
score -= 1;
}
if (a.length > b.length) {
score += 1;
}
return score;
});
};

/**
* @returns {boolean} subject has a word that starts with a query component.
*/
function wordInitialMatch(subject, ...query) {
const _words = words(subject);
return _words.filter((w) => undefined !== query.find((q) => w.startsWith(q))).length > 0;
}

/**
* @returns {boolean} subject has a whole word match with part of the query.
*/
function wholeWordMatch(subject, ...query) {
const _words = words(subject);
return _words.filter((w) => query.includes(w)).length > 0;
}

/**
* Splits the search subject into words.
*/
function words(subject) {
return subject.split(/:|\s+|-|_/);
}
22 changes: 22 additions & 0 deletions scripts/cli-dispatcher/lib/matcher.js
@@ -0,0 +1,22 @@
/**
* @param {string} subject - the string to test.
* @param {string} query - the query.
* @returns {boolean} whether all elements of query appear in-order in the subject string.
*
* @example
* ("client-s3-control", "c s3 c") -> true
*/
module.exports = function matcher(subject, ...query) {
let cursor = undefined;

for (const q of query) {
const searchString = subject.slice(cursor + 1);
const index = searchString.indexOf(q);
if (index === -1) {
return false;
}
cursor = index;
}

return true;
};
9 changes: 9 additions & 0 deletions scripts/cli-dispatcher/readme.md
@@ -0,0 +1,9 @@
## CLI dispatcher

This script provides a CLI helper to send shorthand commands to a matching package.

### Usage

First, alias the script entry point. An example is provided in `./set-alias.sh`.

Then run the script with no arguments to see the help message detailing usage.
5 changes: 5 additions & 0 deletions scripts/cli-dispatcher/set-alias.sh
@@ -0,0 +1,5 @@
#!/bin/bash

# Set a command line alias to make running the dispatcher easier.

alias b="node ./scripts/cli-dispatcher/index.js"

0 comments on commit 6fe57fe

Please sign in to comment.