@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 +12 -0
- package/README.md +308 -0
- package/bin/task +4 -0
- package/package.json +25 -0
- package/src/cli.ts +150 -0
- package/src/config.ts +82 -0
- package/src/errors.ts +5 -0
- package/src/index.ts +2 -0
- package/src/run.ts +31 -0
- package/src/scheduler.ts +156 -0
- package/src/types.ts +27 -0
- package/tsconfig.json +15 -0
package/.editorconfig
ADDED
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
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
package/src/index.ts
ADDED
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
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -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
|
+
}
|