@hackwaly/task 0.1.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/.editorconfig ADDED
@@ -0,0 +1,12 @@
1
+ # EditorConfig is awesome: https://EditorConfig.org
2
+
3
+ # top-most EditorConfig file
4
+ root = true
5
+
6
+ [*]
7
+ indent_style = space
8
+ indent_size = 2
9
+ end_of_line = lf
10
+ charset = utf-8
11
+ trim_trailing_whitespace = true
12
+ insert_final_newline = true
package/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # @hackwaly/task
2
+
3
+ A lightweight, TypeScript-native task runner inspired by Turborepo. Define your build pipeline with code, not configuration files.
4
+
5
+ ## Features
6
+
7
+ - 🚀 **TypeScript-first**: Define tasks in TypeScript with full type safety
8
+ - 📦 **Dependency management**: Automatic task dependency resolution and execution
9
+ - 👀 **Watch mode**: File watching with intelligent task re-execution
10
+ - ⚡ **Parallel execution**: Run independent tasks concurrently
11
+ - 🎯 **Persistent tasks**: Support for long-running processes (servers, watchers)
12
+ - 🔄 **Interruptible tasks**: Graceful handling of task interruption
13
+ - 📁 **Input/Output tracking**: File-based change detection for efficient rebuilds
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @hackwaly/task
19
+ # or
20
+ pnpm add @hackwaly/task
21
+ # or
22
+ yarn add @hackwaly/task
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ 1. Create a `taskfile.ts` in your project root:
28
+
29
+ ```typescript
30
+ import { configInit } from "@hackwaly/task";
31
+
32
+ const { defineTask } = configInit(import.meta);
33
+
34
+ export const build = defineTask({
35
+ name: "build",
36
+ command: "tsc --build",
37
+ inputs: ["src/**/*.ts", "tsconfig.json"],
38
+ outputs: ["dist/**/*.js"],
39
+ });
40
+
41
+ export const test = defineTask({
42
+ name: "test",
43
+ command: "vitest run",
44
+ dependsOn: [build],
45
+ });
46
+
47
+ export const dev = defineTask({
48
+ name: "dev",
49
+ command: "tsc --watch",
50
+ persistent: true,
51
+ });
52
+
53
+ export default build;
54
+ ```
55
+
56
+ 2. Run tasks:
57
+
58
+ ```bash
59
+ # Run a single task
60
+ npx task run build
61
+
62
+ # Run multiple tasks
63
+ npx task run build test
64
+
65
+ # Run with watch mode
66
+ npx task run build --watch
67
+
68
+ # List available tasks
69
+ npx task list
70
+ ```
71
+
72
+ ## Task Configuration
73
+
74
+ Tasks are defined using the `defineTask` function with the following options:
75
+
76
+ ```typescript
77
+ interface TaskConfig {
78
+ name: string; // Task name (required)
79
+ description?: string; // Task description for help text
80
+ command?: string | string[] | { program: string; args?: string[] };
81
+ env?: Record<string, string>; // Environment variables
82
+ cwd?: string; // Working directory (defaults to taskfile location)
83
+ inputs?: string[]; // Input file patterns (for change detection)
84
+ outputs?: string[]; // Output file patterns
85
+ persistent?: boolean; // Whether task runs continuously (like servers)
86
+ interruptible?: boolean; // Whether task can be interrupted safely
87
+ dependsOn?: TaskDef[]; // Task dependencies
88
+ }
89
+ ```
90
+
91
+ ### Command Formats
92
+
93
+ Commands can be specified in multiple formats:
94
+
95
+ ```typescript
96
+ // String (parsed with shell-like parsing)
97
+ command: "tsc --build --verbose"
98
+
99
+ // Array
100
+ command: ["tsc", "--build", "--verbose"]
101
+
102
+ // Object
103
+ command: {
104
+ program: "tsc",
105
+ args: ["--build", "--verbose"]
106
+ }
107
+ ```
108
+
109
+ ### Dependencies
110
+
111
+ Tasks can depend on other tasks. Dependencies are resolved automatically:
112
+
113
+ ```typescript
114
+ export const generateTypes = defineTask({
115
+ name: "generate-types",
116
+ command: "generate-types src/schema.json",
117
+ outputs: ["src/types.ts"],
118
+ });
119
+
120
+ export const build = defineTask({
121
+ name: "build",
122
+ command: "tsc --build",
123
+ inputs: ["src/**/*.ts"],
124
+ dependsOn: [generateTypes], // Runs generateTypes first
125
+ });
126
+ ```
127
+
128
+ ### Persistent Tasks
129
+
130
+ For long-running processes like development servers:
131
+
132
+ ```typescript
133
+ export const server = defineTask({
134
+ name: "server",
135
+ command: "node server.js",
136
+ persistent: true, // Runs continuously
137
+ interruptible: true, // Can be stopped gracefully
138
+ });
139
+ ```
140
+
141
+ ## Watch Mode
142
+
143
+ Watch mode automatically re-runs tasks when their input files change:
144
+
145
+ ```bash
146
+ npx task run build --watch
147
+ ```
148
+
149
+ Features:
150
+ - Monitors all input patterns defined in tasks
151
+ - Ignores `node_modules` by default
152
+ - Propagates changes through the dependency graph
153
+ - Only reruns tasks that are affected by changes
154
+
155
+ ## Commands
156
+
157
+ ### `run <tasks...>`
158
+
159
+ Run one or more tasks:
160
+
161
+ ```bash
162
+ # Single task
163
+ npx task run build
164
+
165
+ # Multiple tasks
166
+ npx task run lint test build
167
+
168
+ # With watch mode
169
+ npx task run build --watch
170
+ ```
171
+
172
+ ### `list` / `ls`
173
+
174
+ List all available tasks:
175
+
176
+ ```bash
177
+ npx task list
178
+ ```
179
+
180
+ Shows task names and descriptions in a formatted table.
181
+
182
+ ## Examples
183
+
184
+ ### Basic Build Pipeline
185
+
186
+ ```typescript
187
+ import { configInit } from "@hackwaly/task";
188
+
189
+ const { defineTask } = configInit(import.meta);
190
+
191
+ export const lint = defineTask({
192
+ name: "lint",
193
+ description: "Lint TypeScript files",
194
+ command: "eslint src/**/*.ts",
195
+ inputs: ["src/**/*.ts", ".eslintrc.json"],
196
+ });
197
+
198
+ export const typecheck = defineTask({
199
+ name: "typecheck",
200
+ description: "Type check TypeScript files",
201
+ command: "tsc --noEmit",
202
+ inputs: ["src/**/*.ts", "tsconfig.json"],
203
+ });
204
+
205
+ export const build = defineTask({
206
+ name: "build",
207
+ description: "Build the project",
208
+ command: "tsc --build",
209
+ inputs: ["src/**/*.ts", "tsconfig.json"],
210
+ outputs: ["dist/**/*.js"],
211
+ dependsOn: [lint, typecheck],
212
+ });
213
+
214
+ export const test = defineTask({
215
+ name: "test",
216
+ description: "Run tests",
217
+ command: "vitest run",
218
+ dependsOn: [build],
219
+ });
220
+ ```
221
+
222
+ ### Development Workflow
223
+
224
+ ```typescript
225
+ export const generateSchema = defineTask({
226
+ name: "generate-schema",
227
+ command: "generate-schema api.yaml",
228
+ inputs: ["api.yaml"],
229
+ outputs: ["src/generated/schema.ts"],
230
+ });
231
+
232
+ export const dev = defineTask({
233
+ name: "dev",
234
+ description: "Start development server",
235
+ command: "vite dev",
236
+ persistent: true,
237
+ interruptible: true,
238
+ dependsOn: [generateSchema],
239
+ });
240
+
241
+ export const buildWatch = defineTask({
242
+ name: "build:watch",
243
+ description: "Build in watch mode",
244
+ command: "tsc --watch",
245
+ persistent: true,
246
+ dependsOn: [generateSchema],
247
+ });
248
+ ```
249
+
250
+ ## Advanced Usage
251
+
252
+ ### Monorepo Support
253
+
254
+ Each package can have its own `taskfile.ts`:
255
+
256
+ ```typescript
257
+ // packages/ui/taskfile.ts
258
+ import { configInit } from "@hackwaly/task";
259
+
260
+ const { defineTask } = configInit(import.meta);
261
+
262
+ export const build = defineTask({
263
+ name: "build:ui",
264
+ command: "rollup -c",
265
+ inputs: ["src/**/*", "rollup.config.js"],
266
+ outputs: ["dist/**/*"],
267
+ });
268
+
269
+ // packages/app/taskfile.ts
270
+ import { build as buildUI } from "../ui/taskfile.ts";
271
+
272
+ export const build = defineTask({
273
+ name: "build:app",
274
+ command: "vite build",
275
+ dependsOn: [buildUI], // Cross-package dependency
276
+ });
277
+ ```
278
+
279
+ ### Custom Task Logic
280
+
281
+ For complex tasks, you can provide custom logic:
282
+
283
+ ```typescript
284
+ export const customTask = defineTask({
285
+ name: "custom",
286
+ async run(ctx) {
287
+ // Custom async logic
288
+ console.log("Running custom task...");
289
+
290
+ // Check if aborted
291
+ if (ctx.abort.aborted) {
292
+ return;
293
+ }
294
+
295
+ // Your custom logic here
296
+ },
297
+ inputs: ["src/**/*"],
298
+ });
299
+ ```
300
+
301
+ ## Requirements
302
+
303
+ - Node.js 18+ with `--experimental-strip-types` support
304
+ - TypeScript 5.0+
305
+
306
+ ## License
307
+
308
+ MIT
package/bin/task ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning --experimental-strip-types
2
+ import { cliMain } from "../src/index.ts";
3
+
4
+ await cliMain();
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@hackwaly/task",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "repository": "github:hackwaly/task",
7
+ "bin": {
8
+ "task": "bin/task"
9
+ },
10
+ "main": "./src/index.ts",
11
+ "pnpm": {},
12
+ "dependencies": {
13
+ "ansi-styles": "^6.2.3",
14
+ "chokidar": "^4.0.3",
15
+ "commander": "^14.0.1",
16
+ "execa": "^9.6.0",
17
+ "micromatch": "^4.0.8",
18
+ "rxjs": "^7.8.2",
19
+ "string-argv": "^0.3.2"
20
+ },
21
+ "devDependencies": {
22
+ "@types/micromatch": "^4.0.9",
23
+ "@types/node": "^24.8.1"
24
+ }
25
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,150 @@
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", () => {
93
+ aborter.abort();
94
+ watcher.close();
95
+ });
96
+ taskChan.next(topTaskSet);
97
+ } else {
98
+ taskChan.next(topTaskSet);
99
+ taskChan.complete();
100
+ }
101
+ await loop;
102
+ });
103
+ const listCommand = new Command()
104
+ .name("list")
105
+ .alias("ls")
106
+ .description("List all available tasks")
107
+ .action(async () => {
108
+ const path = process.cwd();
109
+ const allTaskDefs = await import(NodePath.join(path, "taskfile.ts"));
110
+
111
+ // Get all exported tasks
112
+ const tasks: Array<{ name: string; description?: string }> = [];
113
+ for (const [exportName, taskDef] of Object.entries(allTaskDefs)) {
114
+ if (
115
+ exportName !== "default" &&
116
+ taskDef &&
117
+ typeof taskDef === "object" &&
118
+ "meta" in taskDef
119
+ ) {
120
+ const task = taskDef as TaskDef;
121
+ tasks.push({
122
+ name: task.meta.name,
123
+ description: task.meta.description,
124
+ });
125
+ }
126
+ }
127
+
128
+ if (tasks.length === 0) {
129
+ process.stdout.write("No tasks found in taskfile.ts\n");
130
+ return;
131
+ }
132
+
133
+ // Sort tasks by name
134
+ tasks.sort((a, b) => a.name.localeCompare(b.name));
135
+
136
+ // Find the longest task name for formatting
137
+ const maxNameLength = Math.max(...tasks.map((t) => t.name.length));
138
+
139
+ process.stdout.write("Available tasks:\n");
140
+ for (const task of tasks) {
141
+ const paddedName = task.name.padEnd(maxNameLength);
142
+ const description = task.description || "No description";
143
+ process.stdout.write(` ${paddedName} ${description}\n`);
144
+ }
145
+ });
146
+
147
+ program.addCommand(runCommand);
148
+ program.addCommand(listCommand);
149
+ await program.parseAsync();
150
+ }
package/src/config.ts ADDED
@@ -0,0 +1,82 @@
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 ADDED
@@ -0,0 +1,5 @@
1
+ export class InvariantViolation extends Error {
2
+ get name(): string {
3
+ return InvariantViolation.name;
4
+ }
5
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { cliMain } from "./cli.ts";
2
+ export { configInit } from "./config.ts";
package/src/run.ts ADDED
@@ -0,0 +1,31 @@
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
+
6
+ export async function runCommand(
7
+ command: Command,
8
+ meta: TaskMeta,
9
+ ctx: TaskRunContext
10
+ ): Promise<void> {
11
+ const { abort } = ctx;
12
+ const { name, cwd, env } = meta;
13
+
14
+ const transform = function* (line: string) {
15
+ const lastCR = line.lastIndexOf("\r");
16
+ const line2 = lastCR >= 0 ? line.substring(lastCR + 1, line.length) : line;
17
+ const line3 = line2.replace(/\x1bc|\x1b\[2J(?:\x1b\[H)?/g, "");
18
+ yield `${name} | ${line3}`;
19
+ };
20
+
21
+ process.stdout.write(`▪▪▪▪ ${styles.bold.open}${name}${styles.bold.close}\n`);
22
+ await execa({
23
+ // @ts-expect-error
24
+ cwd: cwd,
25
+ env: env,
26
+ stdout: [transform, "inherit"],
27
+ stderr: [transform, "inherit"],
28
+ cancelSignal: abort,
29
+ reject: false,
30
+ })`${command.program} ${command.args}`;
31
+ }
@@ -0,0 +1,156 @@
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 ADDED
@@ -0,0 +1,27 @@
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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2024",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "allowImportingTsExtensions": true,
7
+ "skipLibCheck": true,
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "isolatedModules": true,
11
+ "erasableSyntaxOnly": true,
12
+ "verbatimModuleSyntax": true,
13
+ "noEmit": true
14
+ }
15
+ }