@hackwaly/task 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/task +2 -2
- package/package.json +15 -5
- package/src/cli.js +145 -0
- package/src/config.js +54 -0
- package/src/errors.js +5 -0
- package/src/index.js +2 -0
- package/src/run.js +28 -0
- package/src/scheduler.js +128 -0
- package/src/types.js +1 -0
- package/tsconfig.json +1 -2
- package/src/cli.ts +0 -157
- package/src/config.ts +0 -82
- package/src/errors.ts +0 -5
- package/src/index.ts +0 -2
- package/src/run.ts +0 -37
- package/src/scheduler.ts +0 -156
- package/src/types.ts +0 -27
package/bin/task
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hackwaly/task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:hackwaly/task",
|
|
7
7
|
"bin": {
|
|
8
8
|
"task": "bin/task"
|
|
9
9
|
},
|
|
10
|
-
"
|
|
11
|
-
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./src/index.js",
|
|
13
|
+
"types": "./src/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"pnpm": {
|
|
17
|
+
"overrides": {}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prepublish": "tsc --build"
|
|
21
|
+
},
|
|
12
22
|
"dependencies": {
|
|
13
|
-
"amaro": "^1.1.4",
|
|
14
23
|
"ansi-styles": "^6.2.3",
|
|
15
24
|
"chokidar": "^4.0.3",
|
|
16
25
|
"commander": "^14.0.1",
|
|
@@ -22,6 +31,7 @@
|
|
|
22
31
|
},
|
|
23
32
|
"devDependencies": {
|
|
24
33
|
"@types/micromatch": "^4.0.9",
|
|
25
|
-
"@types/node": "^24.8.1"
|
|
34
|
+
"@types/node": "^24.8.1",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
26
36
|
}
|
|
27
37
|
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { start } from "./scheduler.js";
|
|
4
|
+
import { ReplaySubject } from "rxjs";
|
|
5
|
+
import chokidar from "chokidar";
|
|
6
|
+
import micromatch from "micromatch";
|
|
7
|
+
import NodePath from "node:path";
|
|
8
|
+
export async function cliMain() {
|
|
9
|
+
const program = new Command()
|
|
10
|
+
.name("task")
|
|
11
|
+
.version("0.1.0")
|
|
12
|
+
.description("Just another task runner");
|
|
13
|
+
const runCommand = new Command()
|
|
14
|
+
.name("run")
|
|
15
|
+
.option("-w, --watch", "Watch mode")
|
|
16
|
+
.argument("<tasks...>", "Task(s) to run")
|
|
17
|
+
.action(async (taskNames, options) => {
|
|
18
|
+
const path = process.cwd();
|
|
19
|
+
const allTaskDefs = await import(NodePath.join(path, "taskfile.js"));
|
|
20
|
+
const topTaskSet = new Set();
|
|
21
|
+
for (const name of taskNames) {
|
|
22
|
+
const taskDef = allTaskDefs[name];
|
|
23
|
+
if (!taskDef) {
|
|
24
|
+
throw new Error(`Task "${name}" not found.`);
|
|
25
|
+
}
|
|
26
|
+
topTaskSet.add(taskDef);
|
|
27
|
+
}
|
|
28
|
+
const aborter = new AbortController();
|
|
29
|
+
const taskChan = new ReplaySubject();
|
|
30
|
+
const loop = start(taskChan, {
|
|
31
|
+
abort: aborter.signal,
|
|
32
|
+
});
|
|
33
|
+
if (options.watch) {
|
|
34
|
+
const watchTargets = new Set();
|
|
35
|
+
const watchSet = new Set();
|
|
36
|
+
const addWatchDir = (task) => {
|
|
37
|
+
for (const dep of task.deps) {
|
|
38
|
+
addWatchDir(dep);
|
|
39
|
+
}
|
|
40
|
+
if (!task.meta.persistent || task.meta.interruptible) {
|
|
41
|
+
watchSet.add(task);
|
|
42
|
+
watchTargets.add(task.meta.cwd);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
for (const task of topTaskSet) {
|
|
46
|
+
addWatchDir(task);
|
|
47
|
+
}
|
|
48
|
+
const watcher = chokidar.watch([...watchTargets], {
|
|
49
|
+
ignoreInitial: true,
|
|
50
|
+
ignored: (path) => /\bnode_modules\b/.test(path),
|
|
51
|
+
});
|
|
52
|
+
const dirtySeedSet = new Set();
|
|
53
|
+
const flush = () => {
|
|
54
|
+
const dirtySet = new Set();
|
|
55
|
+
const process = (task, buffer) => {
|
|
56
|
+
if (topTaskSet.has(task)) {
|
|
57
|
+
for (const t of buffer) {
|
|
58
|
+
dirtySet.add(t);
|
|
59
|
+
}
|
|
60
|
+
buffer.length = 0;
|
|
61
|
+
}
|
|
62
|
+
for (const invDep of task.invDeps) {
|
|
63
|
+
process(invDep, !invDep.meta.persistent || invDep.meta.interruptible
|
|
64
|
+
? [...buffer, invDep]
|
|
65
|
+
: buffer);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
for (const task of dirtySeedSet) {
|
|
69
|
+
process(task, [task]);
|
|
70
|
+
}
|
|
71
|
+
dirtySeedSet.clear();
|
|
72
|
+
if (dirtySet.size > 0) {
|
|
73
|
+
taskChan.next(dirtySet);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
watcher.on("all", (event, path) => {
|
|
77
|
+
for (const task of watchSet) {
|
|
78
|
+
const relPath = NodePath.relative(task.meta.cwd, path);
|
|
79
|
+
if (micromatch.isMatch(relPath, task.meta.inputs)) {
|
|
80
|
+
dirtySeedSet.add(task);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (dirtySeedSet.size > 0) {
|
|
84
|
+
flush();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
process.on("SIGINT", async () => {
|
|
88
|
+
aborter.abort();
|
|
89
|
+
watcher.close();
|
|
90
|
+
await loop;
|
|
91
|
+
process.exit(0);
|
|
92
|
+
});
|
|
93
|
+
taskChan.next(topTaskSet);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
process.on("SIGINT", async () => {
|
|
97
|
+
aborter.abort();
|
|
98
|
+
await loop;
|
|
99
|
+
process.exit(0);
|
|
100
|
+
});
|
|
101
|
+
taskChan.next(topTaskSet);
|
|
102
|
+
taskChan.complete();
|
|
103
|
+
}
|
|
104
|
+
await loop;
|
|
105
|
+
});
|
|
106
|
+
const listCommand = new Command()
|
|
107
|
+
.name("list")
|
|
108
|
+
.alias("ls")
|
|
109
|
+
.description("List all available tasks")
|
|
110
|
+
.action(async () => {
|
|
111
|
+
const path = process.cwd();
|
|
112
|
+
const allTaskDefs = await import(NodePath.join(path, "taskfile.js"));
|
|
113
|
+
// Get all exported tasks
|
|
114
|
+
const tasks = [];
|
|
115
|
+
for (const [exportName, taskDef] of Object.entries(allTaskDefs)) {
|
|
116
|
+
if (exportName !== "default" &&
|
|
117
|
+
taskDef &&
|
|
118
|
+
typeof taskDef === "object" &&
|
|
119
|
+
"meta" in taskDef) {
|
|
120
|
+
const task = taskDef;
|
|
121
|
+
tasks.push({
|
|
122
|
+
name: task.meta.name,
|
|
123
|
+
description: task.meta.description,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (tasks.length === 0) {
|
|
128
|
+
process.stdout.write("No tasks found in taskfile.ts\n");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Sort tasks by name
|
|
132
|
+
tasks.sort((a, b) => a.name.localeCompare(b.name));
|
|
133
|
+
// Find the longest task name for formatting
|
|
134
|
+
const maxNameLength = Math.max(...tasks.map((t) => t.name.length));
|
|
135
|
+
process.stdout.write("Available tasks:\n");
|
|
136
|
+
for (const task of tasks) {
|
|
137
|
+
const paddedName = task.name.padEnd(maxNameLength);
|
|
138
|
+
const description = task.description || "No description";
|
|
139
|
+
process.stdout.write(` ${paddedName} ${description}\n`);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
program.addCommand(runCommand);
|
|
143
|
+
program.addCommand(listCommand);
|
|
144
|
+
await program.parseAsync();
|
|
145
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import NodePath from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { runCommand } from "./run.js";
|
|
4
|
+
import { parseArgsStringToArgv } from "string-argv";
|
|
5
|
+
function normalizeCommand(command) {
|
|
6
|
+
if (typeof command === "string") {
|
|
7
|
+
const argv = parseArgsStringToArgv(command);
|
|
8
|
+
return { program: argv[0], args: argv.slice(1) };
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(command)) {
|
|
11
|
+
return { program: command[0], args: command.slice(1) };
|
|
12
|
+
}
|
|
13
|
+
if (command !== undefined) {
|
|
14
|
+
return { program: command.program, args: command.args ?? [] };
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
export function configInit(importMeta) {
|
|
19
|
+
return {
|
|
20
|
+
defineTask: (config) => {
|
|
21
|
+
const command = normalizeCommand(config.command);
|
|
22
|
+
const meta = {
|
|
23
|
+
name: config.name,
|
|
24
|
+
description: config.description,
|
|
25
|
+
cwd: config.cwd ?? NodePath.dirname(fileURLToPath(importMeta.url)),
|
|
26
|
+
env: config.env ?? {},
|
|
27
|
+
inputs: config.inputs ?? ["**/*"],
|
|
28
|
+
outputs: config.outputs ?? ["**/*"],
|
|
29
|
+
persistent: config.persistent ?? false,
|
|
30
|
+
// interactive: config.interactive ?? false,
|
|
31
|
+
interruptible: config.interruptible ?? false,
|
|
32
|
+
};
|
|
33
|
+
const def = {
|
|
34
|
+
run: config.run ??
|
|
35
|
+
(async (ctx) => {
|
|
36
|
+
if (command !== undefined) {
|
|
37
|
+
await runCommand(command, meta, ctx);
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
meta: meta,
|
|
41
|
+
deps: new Set(),
|
|
42
|
+
invDeps: new Set(),
|
|
43
|
+
};
|
|
44
|
+
for (const dep of config.dependsOn ?? []) {
|
|
45
|
+
def.deps.add(dep);
|
|
46
|
+
if (dep.meta.persistent && !def.meta.persistent) {
|
|
47
|
+
throw new Error(`Task "${def.meta.name}" depends on persistent task "${dep.meta.name}", so it must also be marked as persistent.`);
|
|
48
|
+
}
|
|
49
|
+
dep.invDeps.add(def);
|
|
50
|
+
}
|
|
51
|
+
return def;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/errors.js
ADDED
package/src/index.js
ADDED
package/src/run.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import styles from "ansi-styles";
|
|
4
|
+
import supportsColor from "supports-color";
|
|
5
|
+
const colorSupport = supportsColor.stdout;
|
|
6
|
+
export async function runCommand(command, meta, ctx) {
|
|
7
|
+
const { abort } = ctx;
|
|
8
|
+
const { name, cwd, env } = meta;
|
|
9
|
+
const transform = function* (line) {
|
|
10
|
+
const lastCR = line.lastIndexOf("\r");
|
|
11
|
+
const line2 = lastCR >= 0 ? line.substring(lastCR + 1, line.length) : line;
|
|
12
|
+
const line3 = line2.replace(/\x1bc|\x1b\[2J(?:\x1b\[H)?/g, "");
|
|
13
|
+
yield `\r${name} | ${line3}`;
|
|
14
|
+
};
|
|
15
|
+
process.stdout.write(`▪▪▪▪ ${styles.bold.open}${name}${styles.bold.close}\n`);
|
|
16
|
+
await execa({
|
|
17
|
+
// @ts-expect-error
|
|
18
|
+
cwd: cwd,
|
|
19
|
+
env: colorSupport
|
|
20
|
+
? { ...(env ?? {}), FORCE_COLOR: `${colorSupport.level}` }
|
|
21
|
+
: env,
|
|
22
|
+
preferLocal: true,
|
|
23
|
+
stdout: [transform, "inherit"],
|
|
24
|
+
stderr: [transform, "inherit"],
|
|
25
|
+
cancelSignal: abort,
|
|
26
|
+
reject: false,
|
|
27
|
+
}) `${command.program} ${command.args}`;
|
|
28
|
+
}
|
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { InvariantViolation } from "./errors.js";
|
|
2
|
+
import { firstValueFrom, Subject } from "rxjs";
|
|
3
|
+
export async function start(taskChan, options) {
|
|
4
|
+
const abort = options.abort;
|
|
5
|
+
// A pending task is dirty, but a dirty task may not be pending
|
|
6
|
+
const dirtySet = new Set();
|
|
7
|
+
const pendingSet = new Set();
|
|
8
|
+
const upToDateSet = new Set();
|
|
9
|
+
const readySignal = new Subject();
|
|
10
|
+
const readySet = new Set();
|
|
11
|
+
const runningSet = new Map();
|
|
12
|
+
const abortedRunningSet = new Map();
|
|
13
|
+
const isReady = (task) => {
|
|
14
|
+
if (!dirtySet.has(task))
|
|
15
|
+
throw new InvariantViolation();
|
|
16
|
+
if (pendingSet.has(task)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
for (const dep of task.deps) {
|
|
20
|
+
if (!upToDateSet.has(dep)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
26
|
+
const checkReady = (task) => {
|
|
27
|
+
if (isReady(task)) {
|
|
28
|
+
dirtySet.delete(task);
|
|
29
|
+
readySet.add(task);
|
|
30
|
+
readySignal.next();
|
|
31
|
+
// Mark inverse dependencies as pending, so they won't become ready
|
|
32
|
+
for (const invDep of task.invDeps) {
|
|
33
|
+
if (dirtySet.has(invDep)) {
|
|
34
|
+
pendingSet.add(invDep);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const cancel = (task) => {
|
|
40
|
+
if (!runningSet.has(task))
|
|
41
|
+
throw new InvariantViolation();
|
|
42
|
+
const { promise, aborter } = runningSet.get(task);
|
|
43
|
+
runningSet.delete(task);
|
|
44
|
+
abortedRunningSet.set(task, promise);
|
|
45
|
+
aborter.abort();
|
|
46
|
+
};
|
|
47
|
+
const addDirtyAndCheckReady = (task) => {
|
|
48
|
+
if (runningSet.has(task)) {
|
|
49
|
+
cancel(task);
|
|
50
|
+
runningSet.delete(task);
|
|
51
|
+
}
|
|
52
|
+
else if (readySet.has(task)) {
|
|
53
|
+
readySet.delete(task);
|
|
54
|
+
}
|
|
55
|
+
dirtySet.add(task);
|
|
56
|
+
if (upToDateSet.has(task)) {
|
|
57
|
+
upToDateSet.delete(task);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
for (const dep of task.deps) {
|
|
61
|
+
addDirtyAndCheckReady(dep);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
checkReady(task);
|
|
65
|
+
};
|
|
66
|
+
const runTask = async (task, abort) => {
|
|
67
|
+
if (runningSet.has(task)) {
|
|
68
|
+
const { promise, aborter } = runningSet.get(task);
|
|
69
|
+
aborter.abort();
|
|
70
|
+
await Promise.allSettled([promise]);
|
|
71
|
+
}
|
|
72
|
+
await task.run({ abort });
|
|
73
|
+
upToDateSet.add(task);
|
|
74
|
+
for (const invDep of task.invDeps) {
|
|
75
|
+
if (pendingSet.has(invDep)) {
|
|
76
|
+
pendingSet.delete(invDep);
|
|
77
|
+
checkReady(invDep);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
let noMoreTasks = false;
|
|
82
|
+
taskChan.subscribe({
|
|
83
|
+
next: (tasks) => {
|
|
84
|
+
for (const task of tasks) {
|
|
85
|
+
addDirtyAndCheckReady(task);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
complete: () => {
|
|
89
|
+
noMoreTasks = true;
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const runLoop = async () => {
|
|
93
|
+
while (!abort.aborted) {
|
|
94
|
+
if (readySet.size === 0) {
|
|
95
|
+
await firstValueFrom(readySignal);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// TODO: limit the concurrency
|
|
99
|
+
for (const task of readySet) {
|
|
100
|
+
const aborter = new AbortController();
|
|
101
|
+
const promise = runTask(task, aborter.signal).finally(() => {
|
|
102
|
+
// Clean up abortedRunningSet if this was the last run
|
|
103
|
+
const wait = abortedRunningSet.get(task);
|
|
104
|
+
if (wait === promise) {
|
|
105
|
+
abortedRunningSet.delete(task);
|
|
106
|
+
}
|
|
107
|
+
const runningEntry = runningSet.get(task);
|
|
108
|
+
if (runningEntry !== undefined && runningEntry.promise === promise) {
|
|
109
|
+
runningSet.delete(task);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
runningSet.set(task, { promise, aborter });
|
|
113
|
+
}
|
|
114
|
+
readySet.clear();
|
|
115
|
+
if (dirtySet.size === 0 && noMoreTasks) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
abort.addEventListener("abort", () => {
|
|
121
|
+
for (const task of runningSet.keys()) {
|
|
122
|
+
cancel(task);
|
|
123
|
+
}
|
|
124
|
+
runningSet.clear();
|
|
125
|
+
});
|
|
126
|
+
await runLoop();
|
|
127
|
+
await Promise.allSettled(abortedRunningSet.values());
|
|
128
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/tsconfig.json
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
"target": "es2024",
|
|
4
4
|
"module": "nodenext",
|
|
5
5
|
"moduleResolution": "nodenext",
|
|
6
|
-
"allowImportingTsExtensions": true,
|
|
7
6
|
"skipLibCheck": true,
|
|
8
7
|
"strict": true,
|
|
9
8
|
"noUncheckedIndexedAccess": true,
|
|
10
9
|
"isolatedModules": true,
|
|
11
10
|
"erasableSyntaxOnly": true,
|
|
12
11
|
"verbatimModuleSyntax": true,
|
|
13
|
-
"
|
|
12
|
+
"declaration": true
|
|
14
13
|
}
|
|
15
14
|
}
|
package/src/cli.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import process from "node:process";
|
|
3
|
-
import type { TaskDef } from "./types.ts";
|
|
4
|
-
import { start } from "./scheduler.ts";
|
|
5
|
-
import { ReplaySubject } from "rxjs";
|
|
6
|
-
import chokidar from "chokidar";
|
|
7
|
-
import micromatch from "micromatch";
|
|
8
|
-
import NodePath from "node:path";
|
|
9
|
-
|
|
10
|
-
export async function cliMain(): Promise<void> {
|
|
11
|
-
const program = new Command()
|
|
12
|
-
.name("task")
|
|
13
|
-
.version("0.1.0")
|
|
14
|
-
.description("Just another task runner");
|
|
15
|
-
const runCommand = new Command()
|
|
16
|
-
.name("run")
|
|
17
|
-
.option("-w, --watch", "Watch mode")
|
|
18
|
-
.argument("<tasks...>", "Task(s) to run")
|
|
19
|
-
.action(async (taskNames: string[], options: { watch: boolean }) => {
|
|
20
|
-
const path = process.cwd();
|
|
21
|
-
const allTaskDefs = await import(NodePath.join(path, "taskfile.ts"));
|
|
22
|
-
const topTaskSet = new Set<TaskDef>();
|
|
23
|
-
for (const name of taskNames) {
|
|
24
|
-
const taskDef = allTaskDefs[name];
|
|
25
|
-
if (!taskDef) {
|
|
26
|
-
throw new Error(`Task "${name}" not found.`);
|
|
27
|
-
}
|
|
28
|
-
topTaskSet.add(taskDef);
|
|
29
|
-
}
|
|
30
|
-
const aborter = new AbortController();
|
|
31
|
-
const taskChan = new ReplaySubject<Set<TaskDef>>();
|
|
32
|
-
const loop = start(taskChan, {
|
|
33
|
-
abort: aborter.signal,
|
|
34
|
-
});
|
|
35
|
-
if (options.watch) {
|
|
36
|
-
const watchTargets = new Set<string>();
|
|
37
|
-
const watchSet = new Set<TaskDef>();
|
|
38
|
-
const addWatchDir = (task: TaskDef) => {
|
|
39
|
-
for (const dep of task.deps) {
|
|
40
|
-
addWatchDir(dep);
|
|
41
|
-
}
|
|
42
|
-
if (!task.meta.persistent || task.meta.interruptible) {
|
|
43
|
-
watchSet.add(task);
|
|
44
|
-
watchTargets.add(task.meta.cwd);
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
for (const task of topTaskSet) {
|
|
48
|
-
addWatchDir(task);
|
|
49
|
-
}
|
|
50
|
-
const watcher = chokidar.watch([...watchTargets], {
|
|
51
|
-
ignoreInitial: true,
|
|
52
|
-
ignored: (path) => /\bnode_modules\b/.test(path),
|
|
53
|
-
});
|
|
54
|
-
const dirtySeedSet = new Set<TaskDef>();
|
|
55
|
-
const flush = () => {
|
|
56
|
-
const dirtySet = new Set<TaskDef>();
|
|
57
|
-
const process = (task: TaskDef, buffer: TaskDef[]) => {
|
|
58
|
-
if (topTaskSet.has(task)) {
|
|
59
|
-
for (const t of buffer) {
|
|
60
|
-
dirtySet.add(t);
|
|
61
|
-
}
|
|
62
|
-
buffer.length = 0;
|
|
63
|
-
}
|
|
64
|
-
for (const invDep of task.invDeps) {
|
|
65
|
-
process(
|
|
66
|
-
invDep,
|
|
67
|
-
!invDep.meta.persistent || invDep.meta.interruptible
|
|
68
|
-
? [...buffer, invDep]
|
|
69
|
-
: buffer
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
for (const task of dirtySeedSet) {
|
|
74
|
-
process(task, [task]);
|
|
75
|
-
}
|
|
76
|
-
dirtySeedSet.clear();
|
|
77
|
-
if (dirtySet.size > 0) {
|
|
78
|
-
taskChan.next(dirtySet);
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
watcher.on("all", (event, path) => {
|
|
82
|
-
for (const task of watchSet) {
|
|
83
|
-
const relPath = NodePath.relative(task.meta.cwd, path);
|
|
84
|
-
if (micromatch.isMatch(relPath, task.meta.inputs)) {
|
|
85
|
-
dirtySeedSet.add(task);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
if (dirtySeedSet.size > 0) {
|
|
89
|
-
flush();
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
process.on("SIGINT", async () => {
|
|
93
|
-
aborter.abort();
|
|
94
|
-
watcher.close();
|
|
95
|
-
await loop;
|
|
96
|
-
process.exit(0);
|
|
97
|
-
});
|
|
98
|
-
taskChan.next(topTaskSet);
|
|
99
|
-
} else {
|
|
100
|
-
process.on("SIGINT", async () => {
|
|
101
|
-
aborter.abort();
|
|
102
|
-
await loop;
|
|
103
|
-
process.exit(0);
|
|
104
|
-
});
|
|
105
|
-
taskChan.next(topTaskSet);
|
|
106
|
-
taskChan.complete();
|
|
107
|
-
}
|
|
108
|
-
await loop;
|
|
109
|
-
});
|
|
110
|
-
const listCommand = new Command()
|
|
111
|
-
.name("list")
|
|
112
|
-
.alias("ls")
|
|
113
|
-
.description("List all available tasks")
|
|
114
|
-
.action(async () => {
|
|
115
|
-
const path = process.cwd();
|
|
116
|
-
const allTaskDefs = await import(NodePath.join(path, "taskfile.ts"));
|
|
117
|
-
|
|
118
|
-
// Get all exported tasks
|
|
119
|
-
const tasks: Array<{ name: string; description?: string }> = [];
|
|
120
|
-
for (const [exportName, taskDef] of Object.entries(allTaskDefs)) {
|
|
121
|
-
if (
|
|
122
|
-
exportName !== "default" &&
|
|
123
|
-
taskDef &&
|
|
124
|
-
typeof taskDef === "object" &&
|
|
125
|
-
"meta" in taskDef
|
|
126
|
-
) {
|
|
127
|
-
const task = taskDef as TaskDef;
|
|
128
|
-
tasks.push({
|
|
129
|
-
name: task.meta.name,
|
|
130
|
-
description: task.meta.description,
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (tasks.length === 0) {
|
|
136
|
-
process.stdout.write("No tasks found in taskfile.ts\n");
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Sort tasks by name
|
|
141
|
-
tasks.sort((a, b) => a.name.localeCompare(b.name));
|
|
142
|
-
|
|
143
|
-
// Find the longest task name for formatting
|
|
144
|
-
const maxNameLength = Math.max(...tasks.map((t) => t.name.length));
|
|
145
|
-
|
|
146
|
-
process.stdout.write("Available tasks:\n");
|
|
147
|
-
for (const task of tasks) {
|
|
148
|
-
const paddedName = task.name.padEnd(maxNameLength);
|
|
149
|
-
const description = task.description || "No description";
|
|
150
|
-
process.stdout.write(` ${paddedName} ${description}\n`);
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
program.addCommand(runCommand);
|
|
155
|
-
program.addCommand(listCommand);
|
|
156
|
-
await program.parseAsync();
|
|
157
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import type { Command, TaskDef, TaskMeta, TaskRunContext } from "./types.ts";
|
|
2
|
-
import NodePath from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { runCommand } from "./run.ts";
|
|
5
|
-
import { parseArgsStringToArgv } from "string-argv";
|
|
6
|
-
|
|
7
|
-
export interface TaskConfig {
|
|
8
|
-
name: string;
|
|
9
|
-
description?: string;
|
|
10
|
-
run?: (ctx: TaskRunContext) => Promise<void>;
|
|
11
|
-
command?: string | string[] | { program: string; args?: string[] };
|
|
12
|
-
env?: Record<string, string>;
|
|
13
|
-
cwd?: string;
|
|
14
|
-
inputs?: string[];
|
|
15
|
-
outputs?: string[];
|
|
16
|
-
persistent?: boolean;
|
|
17
|
-
// interactive?: boolean;
|
|
18
|
-
interruptible?: boolean;
|
|
19
|
-
// cache?: boolean;
|
|
20
|
-
dependsOn?: [TaskDef];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface ConfigAPI {
|
|
24
|
-
defineTask(config: TaskConfig): TaskDef;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function normalizeCommand(
|
|
28
|
-
command: string | string[] | { program: string; args?: string[] } | undefined
|
|
29
|
-
): Command | undefined {
|
|
30
|
-
if (typeof command === "string") {
|
|
31
|
-
const argv = parseArgsStringToArgv(command);
|
|
32
|
-
return { program: argv[0]!, args: argv.slice(1) };
|
|
33
|
-
}
|
|
34
|
-
if (Array.isArray(command)) {
|
|
35
|
-
return { program: command[0]!, args: command.slice(1) };
|
|
36
|
-
}
|
|
37
|
-
if (command !== undefined) {
|
|
38
|
-
return { program: command.program, args: command.args ?? [] };
|
|
39
|
-
}
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function configInit(importMeta: ImportMeta): ConfigAPI {
|
|
44
|
-
return {
|
|
45
|
-
defineTask: (config: TaskConfig): TaskDef => {
|
|
46
|
-
const command = normalizeCommand(config.command);
|
|
47
|
-
const meta: TaskMeta = {
|
|
48
|
-
name: config.name,
|
|
49
|
-
description: config.description,
|
|
50
|
-
cwd: config.cwd ?? NodePath.dirname(fileURLToPath(importMeta.url)),
|
|
51
|
-
env: config.env ?? {},
|
|
52
|
-
inputs: config.inputs ?? ["**/*"],
|
|
53
|
-
outputs: config.outputs ?? ["**/*"],
|
|
54
|
-
persistent: config.persistent ?? false,
|
|
55
|
-
// interactive: config.interactive ?? false,
|
|
56
|
-
interruptible: config.interruptible ?? false,
|
|
57
|
-
};
|
|
58
|
-
const def: TaskDef = {
|
|
59
|
-
run:
|
|
60
|
-
config.run ??
|
|
61
|
-
(async (ctx: TaskRunContext) => {
|
|
62
|
-
if (command !== undefined) {
|
|
63
|
-
await runCommand(command, meta, ctx);
|
|
64
|
-
}
|
|
65
|
-
}),
|
|
66
|
-
meta: meta,
|
|
67
|
-
deps: new Set(),
|
|
68
|
-
invDeps: new Set(),
|
|
69
|
-
};
|
|
70
|
-
for (const dep of config.dependsOn ?? []) {
|
|
71
|
-
def.deps.add(dep);
|
|
72
|
-
if (dep.meta.persistent && !def.meta.persistent) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`Task "${def.meta.name}" depends on persistent task "${dep.meta.name}", so it must also be marked as persistent.`
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
dep.invDeps.add(def);
|
|
78
|
-
}
|
|
79
|
-
return def;
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
}
|
package/src/errors.ts
DELETED
package/src/index.ts
DELETED
package/src/run.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type { Command, TaskMeta, TaskRunContext } from "./types.ts";
|
|
2
|
-
import { execa } from "execa";
|
|
3
|
-
import process from "node:process";
|
|
4
|
-
import styles from "ansi-styles";
|
|
5
|
-
import supportsColor from "supports-color";
|
|
6
|
-
|
|
7
|
-
const colorSupport = supportsColor.stdout;
|
|
8
|
-
|
|
9
|
-
export async function runCommand(
|
|
10
|
-
command: Command,
|
|
11
|
-
meta: TaskMeta,
|
|
12
|
-
ctx: TaskRunContext
|
|
13
|
-
): Promise<void> {
|
|
14
|
-
const { abort } = ctx;
|
|
15
|
-
const { name, cwd, env } = meta;
|
|
16
|
-
|
|
17
|
-
const transform = function* (line: string) {
|
|
18
|
-
const lastCR = line.lastIndexOf("\r");
|
|
19
|
-
const line2 = lastCR >= 0 ? line.substring(lastCR + 1, line.length) : line;
|
|
20
|
-
const line3 = line2.replace(/\x1bc|\x1b\[2J(?:\x1b\[H)?/g, "");
|
|
21
|
-
yield `${name} | ${line3}`;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
process.stdout.write(`▪▪▪▪ ${styles.bold.open}${name}${styles.bold.close}\n`);
|
|
25
|
-
await execa({
|
|
26
|
-
// @ts-expect-error
|
|
27
|
-
cwd: cwd,
|
|
28
|
-
env: colorSupport
|
|
29
|
-
? { ...(env ?? {}), FORCE_COLOR: `${colorSupport.level}` }
|
|
30
|
-
: env,
|
|
31
|
-
preferLocal: true,
|
|
32
|
-
stdout: [transform, "inherit"],
|
|
33
|
-
stderr: [transform, "inherit"],
|
|
34
|
-
cancelSignal: abort,
|
|
35
|
-
reject: false,
|
|
36
|
-
})`${command.program} ${command.args}`;
|
|
37
|
-
}
|
package/src/scheduler.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import type { TaskDef } from "./types.ts";
|
|
2
|
-
import { InvariantViolation } from "./errors.ts";
|
|
3
|
-
import { firstValueFrom, Subject, type Observable } from "rxjs";
|
|
4
|
-
|
|
5
|
-
export async function start(
|
|
6
|
-
taskChan: Observable<Set<TaskDef>>,
|
|
7
|
-
options: {
|
|
8
|
-
abort: AbortSignal;
|
|
9
|
-
}
|
|
10
|
-
): Promise<void> {
|
|
11
|
-
const abort = options.abort;
|
|
12
|
-
|
|
13
|
-
// A pending task is dirty, but a dirty task may not be pending
|
|
14
|
-
const dirtySet = new Set<TaskDef>();
|
|
15
|
-
const pendingSet = new Set<TaskDef>();
|
|
16
|
-
const upToDateSet = new Set<TaskDef>();
|
|
17
|
-
|
|
18
|
-
const readySignal = new Subject<void>();
|
|
19
|
-
const readySet = new Set<TaskDef>();
|
|
20
|
-
const runningSet = new Map<
|
|
21
|
-
TaskDef,
|
|
22
|
-
{
|
|
23
|
-
promise: Promise<void>;
|
|
24
|
-
aborter: AbortController;
|
|
25
|
-
}
|
|
26
|
-
>();
|
|
27
|
-
const abortedRunningSet = new Map<TaskDef, Promise<void>>();
|
|
28
|
-
|
|
29
|
-
const isReady = (task: TaskDef) => {
|
|
30
|
-
if (!dirtySet.has(task)) throw new InvariantViolation();
|
|
31
|
-
|
|
32
|
-
if (pendingSet.has(task)) {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
for (const dep of task.deps) {
|
|
37
|
-
if (!upToDateSet.has(dep)) {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return true;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const checkReady = (task: TaskDef) => {
|
|
45
|
-
if (isReady(task)) {
|
|
46
|
-
dirtySet.delete(task);
|
|
47
|
-
readySet.add(task);
|
|
48
|
-
readySignal.next();
|
|
49
|
-
|
|
50
|
-
// Mark inverse dependencies as pending, so they won't become ready
|
|
51
|
-
for (const invDep of task.invDeps) {
|
|
52
|
-
if (dirtySet.has(invDep)) {
|
|
53
|
-
pendingSet.add(invDep);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const cancel = (task: TaskDef) => {
|
|
60
|
-
if (!runningSet.has(task)) throw new InvariantViolation();
|
|
61
|
-
|
|
62
|
-
const { promise, aborter } = runningSet.get(task)!;
|
|
63
|
-
runningSet.delete(task);
|
|
64
|
-
abortedRunningSet.set(task, promise);
|
|
65
|
-
aborter.abort();
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const addDirtyAndCheckReady = (task: TaskDef) => {
|
|
69
|
-
if (runningSet.has(task)) {
|
|
70
|
-
cancel(task);
|
|
71
|
-
runningSet.delete(task);
|
|
72
|
-
} else if (readySet.has(task)) {
|
|
73
|
-
readySet.delete(task);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
dirtySet.add(task);
|
|
77
|
-
|
|
78
|
-
if (upToDateSet.has(task)) {
|
|
79
|
-
upToDateSet.delete(task);
|
|
80
|
-
} else {
|
|
81
|
-
for (const dep of task.deps) {
|
|
82
|
-
addDirtyAndCheckReady(dep);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
checkReady(task);
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const runTask = async (task: TaskDef, abort: AbortSignal) => {
|
|
90
|
-
if (runningSet.has(task)) {
|
|
91
|
-
const { promise, aborter } = runningSet.get(task)!;
|
|
92
|
-
aborter.abort();
|
|
93
|
-
await Promise.allSettled([promise]);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
await task.run({ abort });
|
|
97
|
-
upToDateSet.add(task);
|
|
98
|
-
for (const invDep of task.invDeps) {
|
|
99
|
-
if (pendingSet.has(invDep)) {
|
|
100
|
-
pendingSet.delete(invDep);
|
|
101
|
-
checkReady(invDep);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
let noMoreTasks = false;
|
|
107
|
-
taskChan.subscribe({
|
|
108
|
-
next: (tasks) => {
|
|
109
|
-
for (const task of tasks) {
|
|
110
|
-
addDirtyAndCheckReady(task);
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
complete: () => {
|
|
114
|
-
noMoreTasks = true;
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const runLoop = async () => {
|
|
119
|
-
while (!abort.aborted) {
|
|
120
|
-
if (readySet.size === 0) {
|
|
121
|
-
await firstValueFrom(readySignal);
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
// TODO: limit the concurrency
|
|
125
|
-
for (const task of readySet) {
|
|
126
|
-
const aborter = new AbortController();
|
|
127
|
-
const promise = runTask(task, aborter.signal).finally(() => {
|
|
128
|
-
// Clean up abortedRunningSet if this was the last run
|
|
129
|
-
const wait = abortedRunningSet.get(task);
|
|
130
|
-
if (wait === promise) {
|
|
131
|
-
abortedRunningSet.delete(task);
|
|
132
|
-
}
|
|
133
|
-
const runningEntry = runningSet.get(task);
|
|
134
|
-
if (runningEntry !== undefined && runningEntry.promise === promise) {
|
|
135
|
-
runningSet.delete(task);
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
runningSet.set(task, { promise, aborter });
|
|
139
|
-
}
|
|
140
|
-
readySet.clear();
|
|
141
|
-
if (dirtySet.size === 0 && noMoreTasks) {
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
abort.addEventListener("abort", () => {
|
|
148
|
-
for (const task of runningSet.keys()) {
|
|
149
|
-
cancel(task);
|
|
150
|
-
}
|
|
151
|
-
runningSet.clear();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
await runLoop();
|
|
155
|
-
await Promise.allSettled(abortedRunningSet.values());
|
|
156
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
export interface Command {
|
|
2
|
-
program: string;
|
|
3
|
-
args: string[];
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface TaskRunContext {
|
|
7
|
-
abort: AbortSignal;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface TaskMeta {
|
|
11
|
-
name: string;
|
|
12
|
-
description: string | undefined;
|
|
13
|
-
cwd: string;
|
|
14
|
-
env: Record<string, string>;
|
|
15
|
-
inputs: string[];
|
|
16
|
-
outputs: string[];
|
|
17
|
-
persistent: boolean;
|
|
18
|
-
// interactive: boolean;
|
|
19
|
-
interruptible: boolean;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface TaskDef {
|
|
23
|
-
run: (ctx: TaskRunContext) => Promise<void>;
|
|
24
|
-
meta: TaskMeta;
|
|
25
|
-
deps: Set<TaskDef>;
|
|
26
|
-
invDeps: Set<TaskDef>;
|
|
27
|
-
}
|