nadle
Version:
A modern, type-safe task runner for Node.js inspired by the awesome Gradle build tool
1,267 lines (1,243 loc) • 39.8 kB
JavaScript
// package.json
var package_default = {
name: "nadle",
version: "0.3.5",
description: "A modern, type-safe task runner for Node.js inspired by the awesome Gradle build tool",
type: "module",
scripts: {
build: 'pnpm "/build:/"',
"build:tsup": "tsup",
"build:ts": "tsc -p tsconfig.build.json",
start: "pnpm run build:ts --watch --noEmit false",
test: "vitest"
},
files: [
"lib",
"nadle.mjs",
"CHANGELOG.md"
],
main: "lib/index.js",
bin: "nadle.mjs",
types: "lib/index.d.ts",
dependencies: {
consola: "^3.4.2",
execa: "^9.6.0",
"fastest-levenshtein": "^1.0.16",
"find-up": "^7.0.0",
glob: "^11.0.2",
jiti: "^2.4.2",
"lodash-es": "^4.17.21",
rimraf: "^6.0.1",
"std-env": "^3.9.0",
tinypool: "^1.1.0",
tinyrainbow: "^2.0.0",
yargs: "^18.0.0"
},
devDependencies: {
"@size-limit/file": "^11.2.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.17.57",
"@types/yargs": "^17.0.33",
fixturify: "^3.0.0",
knip: "^5.60.2",
"size-limit": "^11.2.0",
"strip-ansi": "^7.1.0",
tsup: "^8.5.0",
typescript: "^5.8.2",
vitest: "3.2.2"
},
engines: {
node: ">=20"
},
author: {
name: "Nam Hoang Le",
email: "namhoangle.dev@gmail.com"
},
repository: {
type: "git",
url: "https://github.com/nam-hle/nadle.git",
directory: "packages/nadle"
},
keywords: [
"nodejs",
"task-runner",
"build-tool",
"typescript",
"cli",
"automation",
"gradle-inspired",
"task-orchestration",
"type-safe",
"developer-tools",
"lightweight"
],
homepage: "https://nadle.dev",
bugs: "https://github.com/nam-hle/nadle/issues",
"size-limit": [
{
path: "lib/**",
limit: "40 KB",
name: "bundled"
}
]
};
// src/version.ts
var VERSION = package_default.version;
// src/core/types.ts
var TaskStatus = /* @__PURE__ */ ((TaskStatus2) => {
TaskStatus2["Registered"] = "registered";
TaskStatus2["Scheduled"] = "scheduled";
TaskStatus2["Running"] = "running";
TaskStatus2["Finished"] = "finished";
TaskStatus2["Failed"] = "failed";
return TaskStatus2;
})(TaskStatus || {});
// src/core/task-registry.ts
import c from "tinyrainbow";
var TaskRegistry = class {
constructor() {
this.registry = /* @__PURE__ */ new Map();
}
register(name, task) {
this.registry.set(name, task);
}
has(name) {
return this.registry.has(name);
}
getAll() {
return [...this.registry.values()];
}
findByName(taskName) {
return this.registry.get(taskName);
}
getByName(taskName) {
const task = this.findByName(taskName);
if (!task) {
throw new Error(`Task ${c.bold(taskName)} not found`);
}
return task;
}
onTaskStart(name) {
const task = this.getByName(name);
task.status = "running" /* Running */;
task.result.startTime = Date.now();
}
onTaskFinish(name) {
const task = this.getByName(name);
task.status = "finished" /* Finished */;
task.result.duration = Date.now() - (task.result.startTime ?? 0);
}
onTaskFailed(name) {
const task = this.getByName(name);
task.status = "failed" /* Failed */;
task.result.duration = Date.now() - (task.result.startTime ?? 0);
}
onTasksScheduled(names) {
for (const name of names) {
this.getByName(name).status = "scheduled" /* Scheduled */;
}
}
};
var taskRegistry = new TaskRegistry();
// src/core/logger.ts
import c2 from "tinyrainbow";
// src/core/file-logger.ts
import * as Path from "path";
import { inspect } from "util";
import * as Fs from "fs/promises";
var logItems = [];
var ESC = "\\x1B[";
var CLEAR_LINE = `${ESC}K`;
var MOVE_CURSOR_ONE_ROW_UP = `${ESC}1A`;
var SYNC_START = `${ESC}?2026h`;
var SYNC_END = `${ESC}?2026l`;
var ERASE_DOWN = `${ESC}J`;
var ERASE_SCROLLBACK = `${ESC}3J`;
var CURSOR_TO_START = `${ESC}1;1H`;
var HIDE_CURSOR = `${ESC}?25l`;
var SHOW_CURSOR = `${ESC}?25h`;
var filePath = Path.join(import.meta.dirname, "..", "..", "logs", `${(/* @__PURE__ */ new Date()).toISOString()}.txt`);
var FileLogger = class {
constructor(namespace, options) {
this.namespace = namespace;
this.options = options;
}
log(subspace, ...args) {
if (!this.options?.silent) {
logItems.push({ args, subspace, namespace: this.namespace });
}
}
};
// src/core/consola-reporters.ts
import { isCI, isTest } from "std-env";
import { LogLevels, createConsola } from "consola";
var [BasicReporter] = createConsola({ fancy: false }).options.reporters;
var [FancyReporter] = createConsola({ fancy: true }).options.reporters;
var CIReporter = class {
log(logObj, ctx) {
BasicReporter.log({ ...logObj, type: "" }, ctx);
}
};
function createConsolaReporters() {
if (isTest) {
return [BasicReporter];
}
if (isCI) {
return [new CIReporter()];
}
return [FancyReporter];
}
function createNadleConsola(options) {
return createConsola({ formatOptions: { date: false }, level: LogLevels[options.logLevel], reporters: createConsolaReporters() });
}
// src/core/constants.ts
var UnnamedGroup = "Unnamed";
var ESC2 = "\x1B[";
var ERASE_DOWN2 = `${ESC2}J`;
var ERASE_SCROLLBACK2 = `${ESC2}3J`;
var CURSOR_TO_START2 = `${ESC2}1;1H`;
var HIDE_CURSOR2 = `${ESC2}?25l`;
var CLEAR_SCREEN = "\x1Bc";
var CROSS = "\xD7";
var CHECK = "\u2713";
var RIGHT_ARROW = "\u2192";
var VERTICAL_BAR = "|";
var DEFAULT_CONFIG_FILE_NAME = "nadle.config";
// src/core/logger.ts
var SupportLogLevels = ["error", "log", "info", "debug"];
var l = new FileLogger("logger");
var Logger = class {
constructor(options) {
this.outputStream = process.stdout;
this.errorStream = process.stderr;
this.options = { logLevel: "log", isWorkerThread: false, ...options };
this.consola = createNadleConsola(this.options);
if (this.outputStream.isTTY) {
this.outputStream.write(HIDE_CURSOR2);
}
if (!this.options.isWorkerThread) {
const { stderr, stdout, ...consoleOptions } = this.consola.options;
this.info(`Initialized logger with consola options:`, consoleOptions);
}
}
/* eslint-disable @typescript-eslint/no-explicit-any */
log(message, ...args) {
l.log("log", message, ...args);
this._clearScreen();
this.consola.log(message, ...args);
}
error(message, ...args) {
l.log("error", message, ...args);
this._clearScreen();
this.consola.error(message, ...args);
}
warn(message, ...args) {
l.log("warn", message, ...args);
this._clearScreen();
this.consola.warn(message, ...args);
}
info(message, ...args) {
l.log("info", message, ...args);
this._clearScreen();
this.consola.info(message, ...args);
}
debug(message, ...args) {
l.log("debug", message, ...args);
this._clearScreen();
if (typeof message === "string") {
this.consola.debug(message, ...args);
return;
}
const { tag, ...rest } = message;
if (Object.keys(rest).length === 0) {
this.consola.debug(c2.yellow(tag), ...args);
return;
}
this.consola.debug(rest, c2.yellow(tag), ...args);
}
/* eslint-enable @typescript-eslint/no-explicit-any */
clearFullScreen(message = "") {
l.log("clearFullScreen");
if (message) {
this.consola.log(`${CLEAR_SCREEN}${ERASE_SCROLLBACK2}${message}`);
} else {
this.outputStream.write(`${CLEAR_SCREEN}${ERASE_SCROLLBACK2}`);
}
}
clearScreen(message, force = false) {
l.log("clearScreen", { message });
this._clearScreenPending = message;
if (force) {
this._clearScreen();
}
}
_clearScreen() {
l.log("_clearScreen");
if (this._clearScreenPending == null) {
return;
}
const log = this._clearScreenPending;
this._clearScreenPending = void 0;
this.consola.log(`${CURSOR_TO_START2}${ERASE_DOWN2}${log}`);
}
getColumns() {
return "columns" in this.outputStream ? this.outputStream.columns : 80;
}
};
// src/core/reporter.ts
import { fileURLToPath } from "url";
import c3 from "tinyrainbow";
import { isCI as isCI2, isTest as isTest2 } from "std-env";
// src/core/utils.ts
import Path2 from "path";
function noop() {
}
function capitalize(str) {
if (str.length === 0) {
return str;
}
return str.charAt(0).toUpperCase() + str.slice(1);
}
function formatTime(time) {
if (time > 1e3) {
return `${Math.round(time / 1e3)}s`;
}
return `${Math.round(time)}ms`;
}
function formatTimeString(date) {
return date.toTimeString().split(" ")[0];
}
function normalizeGlobPath(path) {
if (path.startsWith(".")) {
return path;
}
return `.${Path2.sep}${path}`;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// src/core/renderers/normal-renderer.ts
var NormalRenderer = class {
start() {
noop();
}
finish() {
noop();
}
schedule() {
noop();
}
};
// src/core/renderers/summary-renderer.ts
import { stripVTControlCharacters } from "util";
var DEFAULT_RENDER_INTERVAL_MS = 200;
var ESC3 = "\x1B[";
var CLEAR_LINE2 = `${ESC3}K`;
var MOVE_CURSOR_ONE_ROW_UP2 = `${ESC3}1A`;
var SYNC_START2 = `${ESC3}?2026h`;
var SYNC_END2 = `${ESC3}?2026l`;
var l2 = new FileLogger("WindowRenderer");
var SummaryRenderer = class {
constructor(options) {
this.buffer = [];
this.renderInterval = void 0;
this.renderScheduled = false;
this.windowHeight = 0;
this.finished = false;
this.cleanups = [];
this.options = {
interval: DEFAULT_RENDER_INTERVAL_MS,
...options
};
this.streams = {
error: options.logger.errorStream.write.bind(options.logger.errorStream),
output: options.logger.outputStream.write.bind(options.logger.outputStream)
};
this.cleanups.push(this.interceptStream(process.stdout, "output"), this.interceptStream(process.stderr, "error"));
this.start();
}
start() {
l2.log("start");
this.finished = false;
this.renderInterval = setInterval(() => this.schedule(), this.options.interval).unref();
}
stop() {
l2.log("stop");
this.cleanups.splice(0).forEach((fn) => fn());
clearInterval(this.renderInterval);
}
/**
* Write all buffered output and stop buffering.
* All intercepted writes are forwarded to actual write after this.
*/
finish() {
l2.log("finish");
this.finished = true;
this.flushBuffer();
clearInterval(this.renderInterval);
}
/**
* Queue new render update
*/
schedule() {
if (!this.renderScheduled) {
this.renderScheduled = true;
this.flushBuffer();
setTimeout(() => {
this.renderScheduled = false;
}, 100).unref();
}
}
flushBuffer() {
if (this.buffer.length === 0) {
return this.render();
}
let current;
for (const next of this.buffer.splice(0)) {
if (!current) {
current = next;
continue;
}
if (current.type !== next.type) {
this.render(current.message, current.type);
current = next;
continue;
}
current.message += next.message;
}
if (current) {
this.render(current.message, current.type);
}
}
render(message, type = "output") {
l2.log("render", { message });
if (this.finished) {
this.clearWindow();
return this.write(message ?? "", type);
}
const win = this.windowHeight;
const windowContent = this.options.getWindow();
const rowCount = getRenderedRowCount(windowContent, this.options.logger.getColumns());
let padding = this.windowHeight - rowCount;
if (padding > 0 && message) {
padding -= getRenderedRowCount([message], this.options.logger.getColumns());
}
l2.log("render", { padding, rowCount, windowHeight: win, col: this.options.logger.getColumns() });
this.write(SYNC_START2);
this.clearWindow();
if (message) {
this.write(message, type);
}
if (padding > 0) {
this.write("\n".repeat(padding));
}
this.write(windowContent.join("\n"));
this.write(SYNC_END2);
this.windowHeight = rowCount + Math.max(0, padding);
}
clearWindow() {
l2.log("clearWindow", `this.windowHeight = ${this.windowHeight}`);
if (this.windowHeight === 0) {
return;
}
this.write(CLEAR_LINE2);
for (let i = 1; i < this.windowHeight; i++) {
this.write(`${MOVE_CURSOR_ONE_ROW_UP2}${CLEAR_LINE2}`);
}
this.windowHeight = 0;
}
interceptStream(stream, type) {
const original = stream.write;
stream.write = (chunk, _, callback) => {
if (chunk) {
if (this.finished) {
this.write(chunk.toString(), type);
} else {
this.buffer.push({ type, message: chunk.toString() });
}
}
callback?.();
};
return function restore() {
stream.write = original;
};
}
write(message, type = "output") {
l2.log("write", { message });
this.streams[type](message);
}
};
function getRenderedRowCount(rows, columns) {
let count = 0;
for (const row of rows) {
const text = stripVTControlCharacters(row);
count += Math.max(1, Math.ceil(text.length / columns));
}
return count;
}
// src/core/reporter.ts
var DURATION_UPDATE_INTERVAL_MS = 100;
var DefaultReporter = class {
constructor(nadle) {
this.nadle = nadle;
this.taskStat = {
failed: 0,
running: 0,
finished: 0,
scheduled: 0
};
this.threadIdPerWorker = {};
this.startTime = "";
this.currentTime = 0;
this.duration = 0;
this.durationInterval = void 0;
this.renderer = this.nadle.options.showSummary ? new SummaryRenderer({ logger: this.nadle.logger, getWindow: () => this.createSummary() }) : new NormalRenderer();
}
printLabel(label) {
return c3.dim(label.padEnd(11, " "));
}
createSummary() {
const summary = [""];
const stats = [
c3.cyanBright(`${this.taskStat.scheduled - this.taskStat.running - this.taskStat.finished - this.taskStat.failed} pending`),
c3.yellow(`${this.taskStat.running} running`),
c3.green(`${this.taskStat.finished} finished`)
].join(` ${c3.gray(VERTICAL_BAR)} `);
summary.push([this.printLabel("Tasks"), stats, c3.dim(`(${this.taskStat.scheduled} scheduled)`)].join(" "));
summary.push([this.printLabel("Duration"), formatTime(this.duration)].join(" "));
summary.push(...this.printRunningTasks());
summary.push("");
return summary;
}
printRunningTasks() {
const lines = Array.from({ length: this.nadle.options.maxWorkers }, () => ` ${c3.yellow(">")} ${c3.dim("IDLE")}`);
for (const runningTask of this.nadle.registry.getAll().filter((task) => task.status === "running" /* Running */)) {
lines[this.threadIdPerWorker[runningTask.name] - 1] = ` ${c3.yellow(">")} :${c3.bold(runningTask.name)}`;
}
return lines;
}
onInit() {
const { minWorkers, maxWorkers } = this.nadle.options;
if (!this.nadle.options.isWorkerThread) {
this.nadle.logger.log(c3.bold(c3.cyan(`\u{1F6E0}\uFE0F Welcome to Nadle v${this.nadle.version}!`)));
this.nadle.logger.info(`Using Nadle from: ${fileURLToPath(import.meta.resolve("nadle"))}`);
this.nadle.logger.log(c3.dim(`Loading configuration file from: ${this.nadle.options.configPath}`));
this.nadle.logger.log(
c3.dim(`Using ${minWorkers === maxWorkers ? minWorkers : `${minWorkers}\u2013${maxWorkers}`} worker${maxWorkers > 1 ? "s" : ""} for task execution`)
);
this.nadle.logger.info("Resolved options:", this.nadle.options);
this.nadle.logger.info("Detected environments:", { CI: isCI2, TEST: isTest2 });
}
this.renderer.start();
}
async onTaskStart(task, threadId) {
this.nadle.logger.log(`${c3.yellow(">")} Task ${c3.bold(task.name)} started
`);
this.taskStat = { ...this.taskStat, running: ++this.taskStat.running };
this.threadIdPerWorker = { ...this.threadIdPerWorker, [task.name]: threadId };
this.renderer.schedule();
}
async onTaskFinish(task) {
this.nadle.logger.log(`
${c3.green(CHECK)} Task ${c3.bold(task.name)} done in ${formatTime(task.result.duration ?? 0)}`);
this.taskStat = { ...this.taskStat, running: --this.taskStat.running, finished: ++this.taskStat.finished };
this.renderer.schedule();
}
async onTaskFailed(task) {
this.nadle.logger.log(`
${c3.red(CROSS)} Task ${c3.bold(task.name)} failed in ${formatTime(task.result.duration ?? 0)}`);
this.taskStat = { ...this.taskStat, failed: ++this.taskStat.failed, running: --this.taskStat.running };
this.renderer.schedule();
}
async onTasksScheduled(tasks) {
this.nadle.logger.info(`Scheduled tasks: ${tasks.join(", ")}`);
this.taskStat = { ...this.taskStat, scheduled: tasks.length };
this.renderer.schedule();
}
async onExecutionStart() {
this.nadle.logger.info("Execution started");
this.startTimers();
this.renderer.start();
}
async onExecutionFinish() {
this.nadle.logger.info("Execution finished");
this.renderer.finish();
clearInterval(this.durationInterval);
const finishedTasks = `${c3.bold(this.taskStat.finished)} task${this.taskStat.finished > 1 ? "s" : ""}`;
this.nadle.logger.log(`
${c3.bold(c3.green("RUN SUCCESSFUL"))} in ${c3.bold(formatTime(this.duration))} ${c3.dim(`(${finishedTasks} executed)`)}`);
}
async onExecutionFailed(error) {
this.nadle.logger.info("Execution failed");
this.renderer.finish();
clearInterval(this.durationInterval);
const finishedTasks = `${c3.bold(this.taskStat.finished)} task${this.taskStat.finished > 1 ? "s" : ""}`;
const failedTasks = `${c3.bold(this.taskStat.failed)} task${this.taskStat.failed > 1 ? "s" : ""}`;
this.nadle.logger.log(
`
${c3.bold(c3.red("RUN FAILED"))} in ${c3.bold(formatTime(this.duration))} ${c3.dim(`(${finishedTasks} executed, ${failedTasks} failed)`)}`
);
if (!this.nadle.options.stacktrace) {
this.nadle.logger.log(
`
For more details, re-run the command with the ${c3.yellow("--stacktrace")} option to display the full error and help identify the root cause.`
);
} else {
this.nadle.logger.error(error instanceof Error ? error.stack : error);
}
}
startTimers() {
const start = performance.now();
this.startTime = formatTimeString(/* @__PURE__ */ new Date());
this.durationInterval = setInterval(() => {
this.currentTime = performance.now();
this.duration = this.currentTime - start;
}, DURATION_UPDATE_INTERVAL_MS).unref();
}
};
// src/core/resolve-task.ts
import { words } from "lodash-es";
import { distance } from "fastest-levenshtein";
var InitialsMatcher = {
name: "InitialsMatcher",
match(input, pattern) {
return input.toLowerCase() === getInitials(pattern);
}
};
var FUZZY_MATCH_THRESHOLD = 2;
var MAX_SUGGESTION_DISTANCE = 3;
var fuzzyMatcher = {
name: "FuzzyMatcher",
match(input, pattern) {
return distance(input.toLowerCase(), pattern.toLowerCase()) <= FUZZY_MATCH_THRESHOLD;
}
};
function match(input, allTasks, matcher) {
return allTasks.filter((task) => matcher.match(input, task));
}
function resolveTask(input, allTasks) {
if (allTasks.includes(input)) {
return { result: input };
}
const initialsMatches = match(input, allTasks, InitialsMatcher);
if (initialsMatches.length === 1) {
return { result: initialsMatches[0] };
}
const fuzzyMatches = match(input, allTasks, fuzzyMatcher);
if (fuzzyMatches.length === 1) {
return { result: fuzzyMatches[0] };
}
const suggestedTasks = allTasks.flatMap((name) => {
const score = distance(input, name);
if (score <= MAX_SUGGESTION_DISTANCE) {
return { name, score };
}
return [];
}).sort((a, b) => a.score - b.score).map(({ name }) => name);
return { result: void 0, suggestions: suggestedTasks };
}
function getInitials(name) {
return words(name).map((w) => w[0]).join("").toLowerCase();
}
function formatSuggestions(names) {
if (names.length === 0) {
return "";
}
let tasksList;
if (names.length === 1) {
tasksList = names[0];
} else if (names.length === 2) {
tasksList = `${names[0]} or ${names[1]}`;
} else {
tasksList = `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}`;
}
return ` Did you mean ${tasksList}?`;
}
// src/core/options/options-resolver.ts
import Fs2 from "fs";
import Os from "os";
import Path3 from "path";
import Process from "process";
import { isCI as isCI3 } from "std-env";
import { findUpSync } from "find-up";
var OptionsResolver = class _OptionsResolver {
constructor(cliOptions) {
this.cliOptions = cliOptions;
this.defaultOptions = {
parallel: false,
logLevel: "log",
showConfig: false,
showSummary: !isCI3,
isWorkerThread: false
};
this.#options = this.resolve();
}
static {
this.SUPPORT_EXTENSIONS = ["js", "mjs", "ts", "mts"];
}
#options;
get options() {
return this.#options;
}
addConfigFileOptions(configFileOptions) {
this.#options = this.resolve(configFileOptions);
}
resolve(configFileOptions) {
const baseOptions = {
...this.defaultOptions,
...configFileOptions,
...this.cliOptions
};
const maxWorkers = this.resolveWorkers(baseOptions.maxWorkers);
const minWorkers = Math.min(this.resolveWorkers(baseOptions.minWorkers), maxWorkers);
return { ...baseOptions, minWorkers, maxWorkers, configPath: this.resolveConfigPath(baseOptions.configPath) };
}
resolveConfigPath(configPath) {
const cwd = Process.cwd();
if (configPath !== void 0) {
const resolvedConfigPath = Path3.resolve(cwd, configPath);
if (!Fs2.existsSync(resolvedConfigPath)) {
throw new Error(`Config file not found at ${resolvedConfigPath}. Please check the path.`);
}
return resolvedConfigPath;
}
const resolveConfigPath = findUpSync(_OptionsResolver.SUPPORT_EXTENSIONS.map((ext) => `${DEFAULT_CONFIG_FILE_NAME}.${ext}`));
if (!resolveConfigPath) {
throw new Error(
`No ${DEFAULT_CONFIG_FILE_NAME}.{${_OptionsResolver.SUPPORT_EXTENSIONS.join(",")}} found in ${Process.cwd()} directory or parent directories. Please use --config to specify a custom path.`
);
}
return resolveConfigPath;
}
resolveWorkers(configValue) {
let result;
if (configValue === void 0) {
result = this.availableWorkers - 1;
} else if (typeof configValue === "number") {
result = configValue;
} else if (typeof configValue === "string") {
result = Math.round(Number.parseInt(configValue) / 100 * this.availableWorkers);
} else {
throw new Error(`Invalid worker value: ${configValue}`);
}
return clamp(result, 1, this.availableWorkers);
}
get availableWorkers() {
if (process.env.NADLE_MAX_WORKERS) {
return Number(process.env.NADLE_MAX_WORKERS);
}
return Os.availableParallelism();
}
};
// src/core/options/cli-options.ts
function resolveCLIOptions(argv) {
let resolvedOptions = {};
const aliases = Object.values(CLIOptions).map(({ options }) => options.alias).filter(Boolean);
for (const [key, value] of Object.entries(argv)) {
if (aliases.includes(key) || key.includes("-") || key === "_" || key === "$0" || key === "tasks") {
continue;
}
if ((key === "minWorkers" || key === "maxWorkers") && value === void 0) {
continue;
}
if (key === "config") {
if (value !== void 0) {
resolvedOptions = { ...resolvedOptions, configPath: value };
}
continue;
}
resolvedOptions = { ...resolvedOptions, [key]: value };
}
return resolvedOptions;
}
var CLIOptions = {
configPath: {
key: "config",
options: {
alias: "c",
type: "string",
description: "Path to config file",
defaultDescription: `<cwd>/build.nadle.{${OptionsResolver.SUPPORT_EXTENSIONS.join(",")}}`
}
},
list: {
key: "list",
options: {
alias: "l",
default: false,
type: "boolean",
description: "List all available tasks"
}
},
parallel: {
key: "parallel",
options: {
default: false,
type: "boolean",
description: "Run all specified tasks in parallel regardless of their order, while still respecting task dependencies."
}
},
dryRun: {
key: "dry-run",
options: {
alias: "m",
default: false,
type: "boolean",
description: "Run specified tasks in dry run mode"
}
},
stacktrace: {
key: "stacktrace",
options: {
default: false,
type: "boolean",
description: "Print stacktrace on error"
}
},
showConfig: {
key: "show-config",
options: {
default: false,
type: "boolean",
description: "Print the resolved configuration"
}
},
showSummary: {
key: "show-summary",
options: {
hidden: true,
type: "boolean"
}
},
logLevel: {
key: "log-level",
options: {
type: "string",
defaultDescription: "log",
choices: SupportLogLevels,
describe: "Set the logging level"
}
},
minWorkers: {
key: "min-workers",
options: {
type: "string",
describe: "Minimum number of workers (integer or percentage)",
defaultDescription: "Os.availableParallelism() - 1",
coerce: createWorkerCoercer("min")
}
},
maxWorkers: {
key: "max-workers",
options: {
type: "string",
describe: "Maximum number of workers (integer or percentage)",
defaultDescription: "Os.availableParallelism() - 1",
coerce: createWorkerCoercer("max")
}
}
};
function createWorkerCoercer(type) {
return (workers) => {
if (workers === void 0) {
return void 0;
}
if (/^\d+$/.test(workers)) {
return Number(workers);
}
if (/^\d+(\.\d+)?%$/.test(workers)) {
return workers;
}
throw new Error(`Invalid value for --${type}-workers. It should be an integer or a percentage (e.g., 50%).`);
};
}
// src/core/options/options-registry.ts
var OptionsRegistry = class {
constructor() {
this.options = {};
}
add(options) {
this.options = options;
}
get() {
return this.options;
}
};
var optionRegistry = new OptionsRegistry();
// src/core/nadle.ts
import process2 from "process";
import { pathToFileURL } from "url";
import c5 from "tinyrainbow";
import { createJiti } from "jiti";
// src/core/task-pool.ts
import TinyPool from "tinypool";
var TaskPool = class {
constructor(nadle, getNextReadyTasks) {
this.nadle = nadle;
this.getNextReadyTasks = getNextReadyTasks;
this.pool = new TinyPool({
concurrentTasksPerWorker: 1,
minThreads: this.nadle.options.minWorkers,
maxThreads: this.nadle.options.maxWorkers,
filename: new URL("./worker.js", import.meta.url).href
});
}
async run() {
try {
await Promise.all(Array.from(this.getNextReadyTasks()).map((taskName) => this.pushTask(taskName)));
} finally {
await this.pool.destroy();
}
}
async pushTask(taskName) {
const task = this.nadle.registry.getByName(taskName);
try {
const { port2: poolPort, port1: workerPort } = new MessageChannel();
poolPort.on("message", async (msg) => {
if (msg.type === "start") {
await this.nadle.onTaskStart(task, msg.threadId);
}
});
const workerParams = {
name: task.name,
port: workerPort,
env: process.env,
options: { ...this.nadle.options, showSummary: false, isWorkerThread: true }
};
await this.pool.run(workerParams, { transferList: [workerPort] });
await this.nadle.onTaskFinish(task);
} catch (error) {
await this.nadle.onTaskFailed(task);
throw error;
}
await Promise.all(Array.from(this.getNextReadyTasks(taskName)).map((taskName2) => this.pushTask(taskName2)));
}
};
// src/core/task-scheduler.ts
import c4 from "tinyrainbow";
// src/core/ensure-map.ts
var EnsureMap = class extends Map {
constructor(initializer) {
super();
this.initializer = initializer;
}
get(key) {
if (this.has(key)) {
return super.get(key);
}
const value = this.initializer();
this.set(key, value);
return value;
}
update(key, updater) {
const value = this.get(key);
const updatedValue = updater(value);
this.set(key, updatedValue);
return updatedValue;
}
};
// src/core/task-scheduler.ts
var TaskScheduler = class {
constructor(context, taskNames) {
this.context = context;
this.taskNames = taskNames;
// Map between a task and the set of tasks that depend on it
this.dependentsGraph = new EnsureMap(() => /* @__PURE__ */ new Set());
// Map between a task and the set of tasks that it depends on
this.dependencyGraph = new EnsureMap(() => /* @__PURE__ */ new Set());
// Map between a task and the set of tasks that it depends on transitively
this.transitiveDependencyGraph = new EnsureMap(() => /* @__PURE__ */ new Set());
// Map between a tasks and its indegree
this.indegree = new EnsureMap(() => 0);
// Set of tasks that are ready to be executed
this.readyTasks = /* @__PURE__ */ new Set();
// The main task listed in the tasks argument need to be focused on
this.mainTask = void 0;
taskNames.forEach((taskName) => this.analyze(taskName));
taskNames.forEach((taskName) => this.detectCycle(taskName, [taskName]));
this.context.nadle.logger.debug({ tag: "Scheduler" }, `transitiveDependencyGraph`, this.transitiveDependencyGraph);
this.context.nadle.logger.debug({ tag: "Scheduler" }, `dependencyGraph`, this.dependencyGraph);
if (!context.nadle.options.parallel) {
this.mainTask = taskNames[0];
}
}
detectCycle(taskName, paths) {
for (const dependency of this.dependencyGraph.get(taskName)) {
const startTaskIndex = paths.indexOf(dependency);
if (startTaskIndex !== -1) {
const cyclePath = [...paths.slice(startTaskIndex), dependency].map(c4.bold).join(` ${RIGHT_ARROW} `);
this.context.nadle.logger.error(`Cycle detected: ${cyclePath}`);
throw new Error(`Cycle detected: ${cyclePath}`);
}
this.detectCycle(dependency, [...paths, dependency]);
}
}
analyze(taskName) {
if (this.dependencyGraph.has(taskName)) {
return;
}
const task = this.context.nadle.registry.getByName(taskName);
const dependencies = new Set(task.configResolver({ context: this.context }).dependsOn ?? []);
this.dependencyGraph.set(taskName, dependencies);
this.indegree.set(taskName, dependencies.size);
this.transitiveDependencyGraph.set(taskName, new Set(dependencies));
for (const dependency of dependencies) {
this.dependentsGraph.update(dependency, (oldDependents) => oldDependents.add(taskName));
this.analyze(dependency);
this.transitiveDependencyGraph.update(taskName, (current) => {
this.transitiveDependencyGraph.get(dependency).forEach((d) => current.add(d));
return current;
});
}
}
get scheduledTask() {
return Array.from(this.dependencyGraph.keys());
}
getIndegreeEntries() {
const runningRootTask = this.mainTask;
if (!runningRootTask) {
return this.indegree.entries();
}
return new Map(
Array.from(this.indegree.entries()).filter(
([taskName]) => taskName === runningRootTask || this.transitiveDependencyGraph.get(runningRootTask)?.has(taskName)
)
);
}
isBelongToRootTaskTree(taskName) {
if (!this.mainTask) {
return true;
}
if (taskName === this.mainTask) {
return true;
}
return this.transitiveDependencyGraph.get(this.mainTask).has(taskName);
}
getReadyTasks(doneTask) {
this.context.nadle.logger.debug({ tag: "Scheduler" }, `runningRoot = ${this.mainTask}, finishedTaskName = ${doneTask}`);
if (doneTask === void 0) {
return this.getInitialReadyTasks();
}
const nextReadyTasks = /* @__PURE__ */ new Set();
for (const dependentTask of this.dependentsGraph.get(doneTask)) {
if (this.readyTasks.has(dependentTask)) {
continue;
}
let indegree = this.indegree.get(dependentTask);
if (indegree === 0) {
const message = `Incorrect state. Expect ${dependentTask} to have indegree > 0 because it depends on the running task ${doneTask}`;
this.context.nadle.logger.error(message);
throw new Error(message);
}
this.indegree.set(dependentTask, --indegree);
if (indegree === 0 && this.isBelongToRootTaskTree(dependentTask)) {
nextReadyTasks.add(dependentTask);
this.readyTasks.add(dependentTask);
}
}
if (doneTask === this.mainTask) {
const nextMainTask = this.moveToNextMainTask();
if (!nextMainTask) {
return /* @__PURE__ */ new Set();
}
return this.getReadyTasks();
}
this.context.nadle.logger.debug({ tag: "Scheduler" }, `Next tasks = ${Array.from(nextReadyTasks).join(",")}`);
return nextReadyTasks;
}
getInitialReadyTasks() {
const nextReadyTasks = /* @__PURE__ */ new Set();
for (const [taskName, indegree] of this.getIndegreeEntries()) {
this.context.nadle.logger.debug({ tag: "Scheduler" }, `taskName = ${taskName}, indegree = ${indegree}`);
if (indegree === 0 && !this.readyTasks.has(taskName)) {
nextReadyTasks.add(taskName);
this.readyTasks.add(taskName);
}
}
this.context.nadle.logger.debug({ tag: "Scheduler" }, `Next tasks = ${Array.from(nextReadyTasks).join(",")}`);
return nextReadyTasks;
}
moveToNextMainTask() {
this.mainTask = this.mainTask !== void 0 ? this.taskNames[this.taskNames.indexOf(this.mainTask) + 1] : void 0;
return this.mainTask;
}
getOrderedTasks(task) {
const readyTasks = Array.from(this.getReadyTasks(task));
for (const readyTask of readyTasks) {
readyTasks.push(...this.getOrderedTasks(readyTask));
}
return readyTasks;
}
};
// src/core/nadle.ts
var Nadle = class {
constructor(options) {
this.version = VERSION;
this.registry = taskRegistry;
this.optionsResolver = new OptionsResolver(options);
this.logger = new Logger(options);
this.reporter = new DefaultReporter(this);
this.reporter.onInit?.();
}
async execute(tasks) {
await this.registerTask();
this.optionsResolver.addConfigFileOptions(optionRegistry.get());
try {
const resolvedTasks = this.resolveTasks(tasks);
this.reporter.onExecutionStart?.();
if (this.options.showConfig) {
this.showConfig();
} else if (this.options.list) {
this.listTasks();
} else if (this.options.dryRun) {
this.dryRunTasks(resolvedTasks);
} else {
await this.runTasks(resolvedTasks);
}
} catch (error) {
this.reporter.onExecutionFailed?.(error);
process2.exit(error.errorCode || 1);
}
this.reporter.onExecutionFinish?.();
}
get options() {
return this.optionsResolver.options;
}
async runTasks(tasks) {
if (tasks.length === 0) {
this.printNoTasksFound();
return;
}
const scheduler = new TaskScheduler({ nadle: this }, tasks);
await this.onTasksScheduled(scheduler.scheduledTask);
await new TaskPool(this, (taskName) => scheduler.getReadyTasks(taskName)).run();
}
dryRunTasks(tasks) {
if (tasks.length === 0) {
this.printNoTasksFound();
return;
}
const orderedTasks = new TaskScheduler({ nadle: this }, tasks).getOrderedTasks();
this.logger.log(c5.bold("Execution plan:"));
for (const task of orderedTasks) {
this.logger.log(`${c5.yellow(">")} Task ${c5.bold(task)}`);
}
}
listTasks() {
if (taskRegistry.getAll().length === 0) {
this.logger.log("No tasks found");
return;
}
const groups = this.computeTaskGroups();
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const [groupName, tasks] = groups[groupIndex];
const label = `${capitalize(groupName)} tasks`;
this.logger.log(c5.bold(label));
this.logger.log(c5.bold("-".repeat(label.length)));
for (const task of tasks) {
const { description, name: taskName } = task;
if (description) {
this.logger.log(c5.bold(c5.green(taskName)) + c5.yellow(` - ${description}`));
} else {
this.logger.log(c5.green(taskName));
}
}
if (groupIndex < groups.length - 1) {
this.logger.log("");
}
}
}
showConfig() {
this.logger.log(JSON.stringify(this.options, null, 2));
}
resolveTasks(tasks) {
const allTasks = this.registry.getAll().map(({ name }) => name);
const resolveTaskPairs = [];
const resolvedTasks = tasks.map((task) => {
const resolvedTask = resolveTask(task, allTasks);
if (resolvedTask.result === void 0) {
const message = `Task ${c5.yellow(c5.bold(task))} not found.${formatSuggestions(resolvedTask.suggestions.map((task2) => c5.yellow(c5.bold(task2))))}`;
this.logger.error(message);
throw new Error(message);
}
if (resolvedTask.result !== task) {
resolveTaskPairs.push({ original: task, resolved: resolvedTask.result });
}
return resolvedTask.result;
});
if (resolveTaskPairs.length > 0) {
const maxOriginTaskLength = Math.max(...resolveTaskPairs.map(({ original }) => original?.length ?? 0));
const message = [
`Resolved tasks:
`,
...resolveTaskPairs.map(
({ resolved, original }) => `${" ".repeat(4)}${c5.yellow(c5.bold(original?.padEnd(maxOriginTaskLength, " ")))} ${RIGHT_ARROW} ${c5.green(c5.bold(resolved))}
`
)
].join("");
this.logger.log(message);
}
this.logger.info(`Resolved tasks: [ ${resolvedTasks.join(", ")} ]`);
return resolvedTasks;
}
computeTaskGroups() {
const tasksByGroup = {};
for (const task of this.registry.getAll()) {
const { description, group = UnnamedGroup } = task.configResolver({ context: { nadle: this } });
tasksByGroup[group] ??= [];
tasksByGroup[group].push({ ...task, description });
}
return Object.entries(tasksByGroup).sort(([firstGroupName], [secondGroupName]) => {
if (firstGroupName === UnnamedGroup) {
return 1;
}
if (secondGroupName === UnnamedGroup) {
return -1;
}
return firstGroupName.localeCompare(secondGroupName);
}).map(([groupName, tasks]) => {
return [groupName, tasks.sort((firstTask, secondTask) => firstTask.name.localeCompare(secondTask.name))];
});
}
printNoTasksFound() {
this.logger.log("No tasks were specified. Please specify one or more tasks to execute, or use the --list option to view available tasks.");
}
async registerTask() {
const configFile = this.options.configPath;
const jiti = createJiti(import.meta.url, { interopDefault: true, extensions: OptionsResolver.SUPPORT_EXTENSIONS.map((ext) => `.${ext}`) });
await jiti.import(pathToFileURL(configFile).toString());
}
async onTaskStart(task, threadId) {
this.registry.onTaskStart(task.name);
await this.reporter.onTaskStart?.(task, threadId);
}
async onTaskFinish(task) {
this.registry.onTaskFinish(task.name);
await this.reporter.onTaskFinish?.(task);
}
async onTaskFailed(task) {
this.registry.onTaskFailed(task.name);
await this.reporter.onTaskFailed?.(task);
}
async onTasksScheduled(tasks) {
this.registry.onTasksScheduled(tasks);
await this.reporter.onTasksScheduled?.(tasks);
}
};
export {
VERSION,
TaskStatus,
TaskRegistry,
taskRegistry,
SupportLogLevels,
Logger,
normalizeGlobPath,
DefaultReporter,
resolveTask,
formatSuggestions,
resolveCLIOptions,
CLIOptions,
optionRegistry,
Nadle
};
//# sourceMappingURL=chunk-U7OSCGJU.js.map