diff --git a/scripts/cli-dispatcher/index.js b/scripts/cli-dispatcher/index.js new file mode 100644 index 000000000000..001dad6bc3c7 --- /dev/null +++ b/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); diff --git a/scripts/cli-dispatcher/lib/Package.js b/scripts/cli-dispatcher/lib/Package.js new file mode 100644 index 000000000000..c93092383155 --- /dev/null +++ b/scripts/cli-dispatcher/lib/Package.js @@ -0,0 +1,6 @@ +module.exports = class Package { + constructor(name, location) { + this.name = name; + this.location = location; + } +}; diff --git a/scripts/cli-dispatcher/lib/findFolders.js b/scripts/cli-dispatcher/lib/findFolders.js new file mode 100644 index 000000000000..22db7a551fe9 --- /dev/null +++ b/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); +}; diff --git a/scripts/cli-dispatcher/lib/findScripts.js b/scripts/cli-dispatcher/lib/findScripts.js new file mode 100644 index 000000000000..dee47a6d0591 --- /dev/null +++ b/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); +}; diff --git a/scripts/cli-dispatcher/lib/matchSorter.js b/scripts/cli-dispatcher/lib/matchSorter.js new file mode 100644 index 000000000000..978049f89782 --- /dev/null +++ b/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+|-|_/); +} diff --git a/scripts/cli-dispatcher/lib/matcher.js b/scripts/cli-dispatcher/lib/matcher.js new file mode 100644 index 000000000000..36d5f7ff3d49 --- /dev/null +++ b/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; +}; diff --git a/scripts/cli-dispatcher/readme.md b/scripts/cli-dispatcher/readme.md new file mode 100644 index 000000000000..c25ea35eeee5 --- /dev/null +++ b/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. diff --git a/scripts/cli-dispatcher/set-alias.sh b/scripts/cli-dispatcher/set-alias.sh new file mode 100755 index 000000000000..4cb6813e1ebb --- /dev/null +++ b/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" \ No newline at end of file