firest commit
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
import commandLineUsage from "command-line-usage";
|
||||
import pc from "picocolors";
|
||||
import { compareTaskNames } from "./utils.js";
|
||||
export function formatTasks(format, tasks, defaultTask) {
|
||||
const visibleTasks = [...tasks].filter(isTaskVisible).sort(compareTaskNames);
|
||||
if (format === "simple") {
|
||||
return visibleTasks.map((task) => task.options.name).join("\n");
|
||||
}
|
||||
return commandLineUsage({
|
||||
header: "Available tasks",
|
||||
content: visibleTasks.map((task) => {
|
||||
var _a;
|
||||
const name = task === defaultTask
|
||||
? `${pc.green(task.options.name)} (default)`
|
||||
: pc.blue(task.options.name);
|
||||
let descriptionParts = task.options.description ? [task.options.description] : undefined;
|
||||
const deps = (_a = task.options.dependencies) === null || _a === void 0 ? void 0 : _a.filter(isTaskVisible).sort(compareTaskNames);
|
||||
if (deps === null || deps === void 0 ? void 0 : deps.length) {
|
||||
const depNames = deps.map((task) => pc.blue(task.options.name));
|
||||
(descriptionParts !== null && descriptionParts !== void 0 ? descriptionParts : (descriptionParts = [])).push(`Depends on: ${depNames.join(", ")}`);
|
||||
}
|
||||
return { name, description: descriptionParts === null || descriptionParts === void 0 ? void 0 : descriptionParts.join("\n") };
|
||||
}),
|
||||
});
|
||||
}
|
||||
function isTaskVisible(task) {
|
||||
return !task.options.hiddenFromTaskList;
|
||||
}
|
||||
//# sourceMappingURL=formatTasks.js.map
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { types } from "node:util";
|
||||
import pc from "picocolors";
|
||||
import { formatTasks } from "./formatTasks.js";
|
||||
import { findHerebyfile, loadHerebyfile } from "./loadHerebyfile.js";
|
||||
import { getUsage, parseArgs } from "./parseArgs.js";
|
||||
import { reexec } from "./reexec.js";
|
||||
import { Runner } from "./runner.js";
|
||||
import { UserError } from "./utils.js";
|
||||
export async function main(d) {
|
||||
try {
|
||||
await mainWorker(d);
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof UserError) {
|
||||
d.error(`${pc.red("Error")}: ${e.message}`);
|
||||
}
|
||||
else if (types.isNativeError(e) && e.stack) { // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
d.error(e.stack);
|
||||
}
|
||||
else {
|
||||
d.error(`${e}`);
|
||||
}
|
||||
d.setExitCode(1);
|
||||
}
|
||||
}
|
||||
async function mainWorker(d) {
|
||||
var _a;
|
||||
const args = parseArgs(d.argv.slice(2));
|
||||
if (args.help) {
|
||||
d.log(getUsage());
|
||||
return;
|
||||
}
|
||||
const herebyfilePath = path.resolve(d.cwd(), (_a = args.herebyfile) !== null && _a !== void 0 ? _a : findHerebyfile(d.cwd()));
|
||||
if (await reexec(herebyfilePath))
|
||||
return;
|
||||
if (args.version) {
|
||||
d.log(`hereby ${d.version()}`);
|
||||
return;
|
||||
}
|
||||
d.chdir(path.dirname(herebyfilePath));
|
||||
const herebyfile = await loadHerebyfile(herebyfilePath);
|
||||
if (args.printTasks) {
|
||||
d.log(formatTasks(args.printTasks, herebyfile.tasks.values(), herebyfile.defaultTask));
|
||||
return;
|
||||
}
|
||||
const tasks = await selectTasks(d, herebyfile, herebyfilePath, args.run);
|
||||
const taskNames = tasks.map((task) => pc.blue(task.options.name)).join(", ");
|
||||
d.log(`Using ${pc.yellow(d.simplifyPath(herebyfilePath))} to run ${taskNames}`);
|
||||
const start = performance.now();
|
||||
const runner = new Runner(d);
|
||||
try {
|
||||
await runner.runTasks(...tasks);
|
||||
}
|
||||
catch {
|
||||
// We will have already printed some message here.
|
||||
// Set the error code and let the process run to completion,
|
||||
// so we don't end up with an unflushed output.
|
||||
d.setExitCode(1);
|
||||
}
|
||||
finally {
|
||||
const took = performance.now() - start;
|
||||
const failed = runner.failedTasks.length > 0;
|
||||
d.log(`Completed ${taskNames}${failed ? pc.red(" with errors") : ""} in ${d.prettyMilliseconds(took)}`);
|
||||
if (failed) {
|
||||
const names = runner.failedTasks.sort().map((task) => pc.red(task)).join(", ");
|
||||
d.log(`Failed tasks: ${names}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Exported for testing.
|
||||
export async function selectTasks(d, herebyfile, herebyfilePath, taskNames) {
|
||||
if (taskNames.length === 0) {
|
||||
if (herebyfile.defaultTask)
|
||||
return [herebyfile.defaultTask];
|
||||
throw new UserError(`No default task has been exported from ${d.simplifyPath(herebyfilePath)}; please specify a task name.`);
|
||||
}
|
||||
const tasks = [];
|
||||
for (const name of taskNames) {
|
||||
const task = herebyfile.tasks.get(name);
|
||||
if (!task) {
|
||||
let message = `Task "${name}" does not exist or is not exported from ${d.simplifyPath(herebyfilePath)}.`;
|
||||
const { closest, distance } = await import("fastest-levenshtein");
|
||||
const candidate = closest(name, [...herebyfile.tasks.keys()]);
|
||||
if (distance(name, candidate) < name.length * 0.4) {
|
||||
message += ` Did you mean "${candidate}"?`;
|
||||
}
|
||||
throw new UserError(message);
|
||||
}
|
||||
tasks.push(task);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
//# sourceMappingURL=index.js.map
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import pc from "picocolors";
|
||||
import { Task } from "../index.js";
|
||||
import { findUp, UserError } from "./utils.js";
|
||||
const herebyfileRegExp = /^herebyfile\.m?[jt]s$/i;
|
||||
export function findHerebyfile(dir) {
|
||||
const result = findUp(dir, (dir) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const matching = entries.filter((e) => herebyfileRegExp.test(e.name));
|
||||
if (matching.length > 1) {
|
||||
throw new UserError(`Found more than one Herebyfile: ${matching.map((e) => e.name).join(", ")}`);
|
||||
}
|
||||
if (matching.length === 1) {
|
||||
const candidate = matching[0];
|
||||
if (!candidate.isFile()) {
|
||||
throw new UserError(`${candidate.name} is not a file.`);
|
||||
}
|
||||
return path.join(dir, candidate.name);
|
||||
}
|
||||
if (entries.some((e) => e.name === "package.json")) {
|
||||
return false; // TODO: Is this actually desirable? What about monorepos?
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
throw new UserError("Unable to find Herebyfile.");
|
||||
}
|
||||
export async function loadHerebyfile(herebyfilePath) {
|
||||
// Note: calling pathToFileURL is required on Windows to disambiguate URLs
|
||||
// from drive letters.
|
||||
const herebyfile = await import(pathToFileURL(herebyfilePath).toString());
|
||||
const exportedTasks = new Set();
|
||||
let defaultTask;
|
||||
for (const [key, value] of Object.entries(herebyfile)) {
|
||||
if (!(value instanceof Task))
|
||||
continue;
|
||||
if (key === "default") {
|
||||
defaultTask = value;
|
||||
}
|
||||
else if (exportedTasks.has(value)) {
|
||||
throw new UserError(`Task "${pc.blue(value.options.name)}" has been exported twice.`);
|
||||
}
|
||||
else {
|
||||
exportedTasks.add(value);
|
||||
}
|
||||
}
|
||||
if (defaultTask) {
|
||||
exportedTasks.add(defaultTask);
|
||||
}
|
||||
if (exportedTasks.size === 0) {
|
||||
throw new UserError("No tasks found. Did you forget to export your tasks?");
|
||||
}
|
||||
// We check this here by walking the DAG, as some dependencies may not be
|
||||
// exported and therefore would not be seen by the above loop.
|
||||
checkTaskInvariants(exportedTasks);
|
||||
const tasks = new Map([...exportedTasks].map((task) => [task.options.name, task]));
|
||||
return { tasks, defaultTask };
|
||||
}
|
||||
function checkTaskInvariants(tasks) {
|
||||
const checkedTasks = new Set();
|
||||
const taskStack = new Set();
|
||||
const seenNames = new Set();
|
||||
checkTaskInvariantsWorker(tasks);
|
||||
function checkTaskInvariantsWorker(tasks) {
|
||||
for (const task of tasks) {
|
||||
if (checkedTasks.has(task))
|
||||
continue;
|
||||
if (taskStack.has(task)) {
|
||||
throw new UserError(`Task "${pc.blue(task.options.name)}" references itself.`);
|
||||
}
|
||||
const name = task.options.name;
|
||||
if (seenNames.has(name)) {
|
||||
throw new UserError(`Task "${pc.blue(name)}" was declared twice.`);
|
||||
}
|
||||
seenNames.add(name);
|
||||
if (task.options.dependencies) {
|
||||
taskStack.add(task);
|
||||
checkTaskInvariantsWorker(task.options.dependencies);
|
||||
taskStack.delete(task);
|
||||
}
|
||||
checkedTasks.add(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=loadHerebyfile.js.map
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import commandLineUsage from "command-line-usage";
|
||||
import minimist from "minimist";
|
||||
export function parseArgs(argv) {
|
||||
let parseUnknownAsTask = true;
|
||||
const options = minimist(argv, {
|
||||
"--": true,
|
||||
string: ["herebyfile"],
|
||||
boolean: ["tasks", "tasks-simple", "help", "version"],
|
||||
alias: {
|
||||
"h": "help",
|
||||
"T": "tasks",
|
||||
},
|
||||
unknown: (name) => parseUnknownAsTask && (parseUnknownAsTask = !name.startsWith("-")),
|
||||
});
|
||||
return {
|
||||
help: options["help"],
|
||||
run: options._,
|
||||
herebyfile: options["herebyfile"],
|
||||
printTasks: options["tasks"] ? "normal" : (options["tasks-simple"] ? "simple" : undefined),
|
||||
version: options["version"],
|
||||
};
|
||||
}
|
||||
export function getUsage() {
|
||||
const usage = commandLineUsage([
|
||||
{
|
||||
header: "hereby",
|
||||
content: "A simple task runner.",
|
||||
},
|
||||
{
|
||||
header: "Synopsis",
|
||||
content: "$ hereby <task>",
|
||||
},
|
||||
{
|
||||
header: "Options",
|
||||
optionList: [
|
||||
{
|
||||
name: "help",
|
||||
description: "Display this usage guide.",
|
||||
alias: "h",
|
||||
type: Boolean,
|
||||
},
|
||||
{
|
||||
name: "herebyfile",
|
||||
description: "A path to a Herebyfile. Optional.",
|
||||
type: String,
|
||||
defaultOption: true,
|
||||
typeLabel: "{underline path}",
|
||||
},
|
||||
{
|
||||
name: "tasks",
|
||||
description: "Print a listing of the available tasks.",
|
||||
alias: "T",
|
||||
type: Boolean,
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
description: "Print the current hereby version.",
|
||||
type: Boolean,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Example usage",
|
||||
content: [
|
||||
"$ hereby build",
|
||||
"$ hereby build lint",
|
||||
"$ hereby test --skip someTest --lint=false",
|
||||
"$ hereby --tasks",
|
||||
],
|
||||
},
|
||||
]);
|
||||
return usage;
|
||||
}
|
||||
//# sourceMappingURL=parseArgs.js.map
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { findUp, UserError } from "./utils.js";
|
||||
const thisCLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
||||
const distCLIPath = path.join("dist", "cli.js");
|
||||
const expectedCLIPath = path.join("node_modules", "hereby", distCLIPath);
|
||||
/**
|
||||
* Checks to see if we need to re-exec another version of hereby.
|
||||
* If this function returns true, the caller should return immediately
|
||||
* and do no further work.
|
||||
*/
|
||||
export async function reexec(herebyfilePath) {
|
||||
// If hereby is installed globally, but run against a Herebyfile in some
|
||||
// other package, that Herebyfile's import will resolve to a different
|
||||
// installation of the hereby package. There's no guarantee that the two
|
||||
// are compatible (in fact, they are guaranteed not to as Task is a class).
|
||||
//
|
||||
// Rather than trying to fix this by messing around with Node's resolution
|
||||
// (which won't work in ESM anyway), instead opt to figure out the location
|
||||
// of hereby as imported by the Herebyfile, and then "reexec" it by importing.
|
||||
//
|
||||
// This code used to use `import.meta.resolve` to find `hereby/cli`, but
|
||||
// manually encoding this behavior is faster and avoids the dependency.
|
||||
// If Node ever makes the two-argument form of `import.meta.resolve` unflagged,
|
||||
// we could switch to that.
|
||||
const otherCLI = findUp(path.dirname(herebyfilePath), (dir) => {
|
||||
const p = path.resolve(dir, expectedCLIPath);
|
||||
// This is the typical case; we've walked up and found it in node_modules.
|
||||
if (fs.existsSync(p))
|
||||
return p;
|
||||
// Otherwise, we check to see if we're self-resolving. Realistically,
|
||||
// this only happens when developing hereby itself.
|
||||
//
|
||||
// Technically, this should go before the above check since self-resolution
|
||||
// comes before node_modules resolution, but this could only happen if hereby
|
||||
// happened to depend on itself somehow.
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
if (packageJson.name === "hereby") {
|
||||
return path.resolve(dir, distCLIPath);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
if (!otherCLI) {
|
||||
throw new UserError("Unable to find hereby; ensure hereby is installed in your package.");
|
||||
}
|
||||
if (fs.realpathSync(thisCLI) === fs.realpathSync(otherCLI)) {
|
||||
return false;
|
||||
}
|
||||
// Note: calling pathToFileURL is required on Windows to disambiguate URLs
|
||||
// from drive letters.
|
||||
await import(pathToFileURL(otherCLI).toString());
|
||||
return true;
|
||||
}
|
||||
//# sourceMappingURL=reexec.js.map
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import pc from "picocolors";
|
||||
export class Runner {
|
||||
constructor(_d) {
|
||||
this._d = _d;
|
||||
this._addedTasks = new Map();
|
||||
this.failedTasks = [];
|
||||
this._startTimes = new Map();
|
||||
}
|
||||
async runTasks(...tasks) {
|
||||
// Using allSettled here so that we don't immediately exit; it could be
|
||||
// the case that a task has code that needs to run before, e.g. a
|
||||
// cleanup function in a "finally" or something.
|
||||
const results = await Promise.allSettled(tasks.map((task) => {
|
||||
const cached = this._addedTasks.get(task);
|
||||
if (cached)
|
||||
return cached;
|
||||
const promise = this._runTask(task);
|
||||
this._addedTasks.set(task, promise);
|
||||
return promise;
|
||||
}));
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
throw result.reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
async _runTask(task) {
|
||||
const { dependencies, run } = task.options;
|
||||
if (dependencies) {
|
||||
await this.runTasks(...dependencies);
|
||||
}
|
||||
if (!run)
|
||||
return;
|
||||
try {
|
||||
this.onTaskStart(task);
|
||||
await run();
|
||||
this.onTaskFinish(task);
|
||||
}
|
||||
catch (e) {
|
||||
this.onTaskError(task, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
onTaskStart(task) {
|
||||
this._startTimes.set(task, performance.now());
|
||||
if (this.failedTasks.length > 0)
|
||||
return; // Skip logging.
|
||||
this._d.log(`Starting ${pc.blue(task.options.name)}`);
|
||||
}
|
||||
onTaskFinish(task) {
|
||||
if (this.failedTasks.length > 0)
|
||||
return; // Skip logging.
|
||||
const took = performance.now() - this._startTimes.get(task);
|
||||
this._d.log(`Finished ${pc.green(task.options.name)} in ${this._d.prettyMilliseconds(took)}`);
|
||||
}
|
||||
onTaskError(task, e) {
|
||||
this.failedTasks.push(task.options.name);
|
||||
if (this.failedTasks.length > 1)
|
||||
return; // Skip logging.
|
||||
const took = performance.now() - this._startTimes.get(task);
|
||||
this._d.error(`Error in ${pc.red(task.options.name)} in ${this._d.prettyMilliseconds(took)}\n${e}`);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=runner.js.map
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
export function compareTaskNames(a, b) {
|
||||
return compareStrings(a.options.name, b.options.name);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const compareStrings = new Intl.Collator(undefined, { numeric: true }).compare;
|
||||
// Exported for testing.
|
||||
export function simplifyPath(p) {
|
||||
p = path.normalize(p);
|
||||
const homedir = path.normalize(os.homedir() + path.sep);
|
||||
if (p.startsWith(homedir)) {
|
||||
p = p.slice(homedir.length);
|
||||
return `~${path.sep}${p}`;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
export function findUp(dir, predicate) {
|
||||
const root = path.parse(dir).root;
|
||||
while (true) {
|
||||
const result = predicate(dir);
|
||||
if (result !== undefined)
|
||||
return result;
|
||||
if (dir === root)
|
||||
break;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* UserError is a special error that, when caught in the CLI will be printed
|
||||
* as a message only, without stacktrace. Use this instead of process.exit.
|
||||
*/
|
||||
export class UserError extends Error {
|
||||
}
|
||||
export async function real() {
|
||||
const { default: prettyMilliseconds } = await import("pretty-ms");
|
||||
/* eslint-disable no-restricted-globals */
|
||||
return {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
cwd: process.cwd,
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
chdir: process.chdir,
|
||||
simplifyPath,
|
||||
argv: process.argv,
|
||||
setExitCode: (code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
version: () => {
|
||||
const packageJsonURL = new URL("../../package.json", import.meta.url);
|
||||
const packageJson = fs.readFileSync(packageJsonURL, "utf8");
|
||||
return JSON.parse(packageJson).version;
|
||||
},
|
||||
prettyMilliseconds,
|
||||
};
|
||||
/* eslint-enable no-restricted-globals */
|
||||
}
|
||||
//# sourceMappingURL=utils.js.map
|
||||
Reference in New Issue
Block a user