builderman 1.1.0 β†’ 1.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/README.md CHANGED
@@ -1,86 +1,179 @@
1
- # **builderman**
1
+ # builderman
2
2
 
3
- #### _A simple task runner for building and developing projects._
3
+ #### A dependency-aware task runner for building, developing, and orchestrating complex workflows.
4
4
 
5
- <br />
5
+ **builderman** lets you define tasks with explicit dependencies, lifecycle hooks, and multiple execution modes (`dev`, `build`, `deploy`, etc.), then compose them into pipelines that run **deterministically**, **observably**, and **safely**.
6
+
7
+ It is designed for monorepos, long-running development processes, and CI/CD pipelines where **cleanup, cancellation, and failure handling matter**.
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ - [Key Features](#key-features)
14
+ - [Installation](#installation)
15
+ - [Quick Start](#quick-start)
16
+ - [Core Concepts](#core-concepts)
17
+ - [Tasks](#tasks)
18
+ - [Commands & Modes](#commands--modes)
19
+ - [Dependencies](#dependencies)
20
+ - [Pipelines](#pipelines)
21
+ - [Pipeline Composition](#pipeline-composition)
22
+ - [Error Handling Guarantees](#error-handling-guarantees)
23
+ - [Cancellation](#cancellation)
24
+ - [Teardown](#teardown)
25
+ - [Basic Teardown](#basic-teardown)
26
+ - [Teardown Callbacks](#teardown-callbacks)
27
+ - [Teardown Execution Rules](#teardown-execution-rules)
28
+ - [Skipping Tasks](#skipping-tasks)
29
+ - [Strict Mode](#strict-mode)
30
+ - [Task-Level Skip Override](#task-level-skip-override)
31
+ - [Execution Statistics](#execution-statistics)
32
+ - [Pipeline Statistics](#pipeline-statistics)
33
+ - [Task Statistics](#task-statistics)
34
+ - [When Should I Use builderman?](#when-should-i-use-builderman)
35
+
36
+ ## Key Features
37
+
38
+ - 🧩 **Explicit dependency graph** β€” tasks run only when their dependencies are satisfied
39
+ - πŸ” **Multi-mode commands** β€” `dev`, `build`, `deploy`, or any custom mode
40
+ - ⏳ **Readiness detection** β€” wait for long-running processes to become β€œready”
41
+ - 🧹 **Guaranteed teardown** β€” automatic cleanup in reverse dependency order
42
+ - πŸ›‘ **Cancellation support** β€” abort pipelines using `AbortSignal`
43
+ - πŸ“Š **Rich execution statistics** β€” always available, even on failure
44
+ - ❌ **Never throws** β€” failures are returned as structured results
45
+ - 🧱 **Composable pipelines** β€” pipelines can be converted into tasks
46
+
47
+ ---
6
48
 
7
49
  ## Installation
8
50
 
9
- ```bash
51
+ ```sh
10
52
  npm install builderman
11
53
  ```
12
54
 
13
- ## Usage
55
+ ---
56
+
57
+ ## Quick Start
14
58
 
15
59
  ```ts
16
60
  import { task, pipeline } from "builderman"
17
61
 
18
- const task1 = task({
62
+ const build = task({
63
+ name: "build",
64
+ commands: { build: "tsc" },
65
+ cwd: ".",
66
+ })
67
+
68
+ const test = task({
69
+ name: "test",
70
+ commands: { build: "npm test" },
71
+ cwd: ".",
72
+ dependencies: [build],
73
+ })
74
+
75
+ const result = await pipeline([build, test]).run()
76
+
77
+ if (!result.ok) {
78
+ console.error("Pipeline failed:", result.error.message)
79
+ }
80
+ ```
81
+
82
+ This defines a simple dependency graph where `test` runs only after `build` completes successfully.
83
+
84
+ ---
85
+
86
+ ## Core Concepts
87
+
88
+ ### Tasks
89
+
90
+ A **task** represents a unit of work. Each task:
91
+
92
+ - Has a unique name
93
+ - Defines commands for one or more modes
94
+ - May depend on other tasks
95
+ - May register teardown logic
96
+
97
+ ```ts
98
+ import { task } from "builderman"
99
+
100
+ const libTask = task({
19
101
  name: "lib:build",
20
102
  commands: {
21
- dev: "tsc --watch",
22
103
  build: "tsc",
104
+ dev: {
105
+ run: "tsc --watch",
106
+ readyWhen: (stdout) => stdout.includes("Watching for file changes."),
107
+ },
23
108
  },
24
109
  cwd: "packages/lib",
25
- isReady: (stdout) => {
26
- // mark this this task as ready when the process is watching for file changes
27
- return stdout.includes("Watching for file changes.")
28
- },
29
110
  })
111
+ ```
112
+
113
+ ---
114
+
115
+ ### Commands & Modes
116
+
117
+ Each task can define commands for different **modes** (for example `dev`, `build`, `deploy`).
118
+
119
+ When running a pipeline:
120
+
121
+ - If `command` is provided, that mode is used
122
+ - Otherwise:
123
+ - `"build"` is used when `NODE_ENV === "production"`
124
+ - `"dev"` is used in all other cases
30
125
 
31
- const task2 = task({
126
+ Commands may be:
127
+
128
+ - A string (executed directly), or
129
+ - An object with:
130
+ - `run`: the command to execute
131
+ - `readyWhen`: a predicate that marks the task as ready
132
+ - `teardown`: cleanup logic to run after completion
133
+
134
+ ---
135
+
136
+ ### Dependencies
137
+
138
+ Tasks may depend on other tasks. A task will not start until all its dependencies have completed (or been skipped).
139
+
140
+ ```ts
141
+ const consumerTask = task({
32
142
  name: "consumer:dev",
33
143
  commands: {
34
- dev: "npm run dev",
35
144
  build: "npm run build",
145
+ dev: "npm run dev",
36
146
  },
37
147
  cwd: "packages/consumer",
38
- dependencies: [task1],
39
- })
40
-
41
- await pipeline([task1, task2]).run({
42
- onTaskError: (taskName, error) => {
43
- console.error(`[${taskName}] Error: ${error.message}`)
44
- },
45
- onTaskComplete: (taskName) => {
46
- console.log(`[${taskName}] Complete!`)
47
- },
48
- onPipelineComplete: () => {
49
- console.log("All tasks complete! πŸŽ‰")
50
- },
51
- onPipelineError: (error) => {
52
- console.error(`Pipeline error: ${error.message}`)
53
- },
148
+ dependencies: [libTask],
54
149
  })
55
150
  ```
56
151
 
57
- ## Pipeline Composition
58
-
59
- Build complex workflows by composing tasks and pipelines together.
152
+ ---
60
153
 
61
- ### Task Chaining
154
+ ### Pipelines
62
155
 
63
- Chain tasks together using `andThen()` to create a pipeline that will run the tasks in order:
156
+ A **pipeline** executes a set of tasks according to their dependency graph.
64
157
 
65
158
  ```ts
66
- import { task, pipeline } from "builderman"
159
+ import { pipeline } from "builderman"
67
160
 
68
- const build = task({
69
- name: "compile",
70
- commands: { dev: "tsc --watch", build: "tsc" },
71
- cwd: "packages/lib",
72
- }).andThen({
73
- name: "bundle",
74
- commands: { dev: "rollup --watch", build: "rollup" },
75
- cwd: "packages/lib",
161
+ const result = await pipeline([libTask, consumerTask]).run({
162
+ command: "dev",
163
+ onTaskBegin: (name) => {
164
+ console.log(`[${name}] starting`)
165
+ },
166
+ onTaskComplete: (name) => {
167
+ console.log(`[${name}] complete`)
168
+ },
76
169
  })
77
-
78
- await build.run()
79
170
  ```
80
171
 
81
- ### Composing Pipelines as Tasks
172
+ ---
173
+
174
+ ### Pipeline Composition
82
175
 
83
- Convert pipelines to tasks and compose them with explicit dependencies:
176
+ Pipelines can be converted into tasks and composed like any other unit of work.
84
177
 
85
178
  ```ts
86
179
  const build = pipeline([
@@ -93,15 +186,269 @@ const deploy = pipeline([
93
186
  /* ... */
94
187
  ])
95
188
 
96
- // Convert to tasks first
97
189
  const buildTask = build.toTask({ name: "build" })
98
190
  const testTask = test.toTask({ name: "test", dependencies: [buildTask] })
99
191
  const deployTask = deploy.toTask({ name: "deploy", dependencies: [testTask] })
100
192
 
101
- // Compose into final pipeline
102
193
  const ci = pipeline([buildTask, testTask, deployTask])
194
+ const result = await ci.run()
195
+ ```
196
+
197
+ When a pipeline is converted to a task, it becomes a **single node** in the dependency graph. The nested pipeline must fully complete before dependents can start.
198
+
199
+ ---
200
+
201
+ ## Error Handling Guarantees
202
+
203
+ **builderman pipelines never throw.**
204
+
205
+ All failures β€” including task errors, invalid configuration, cancellation, and process termination β€” are reported through a structured `RunResult`.
206
+
207
+ ```ts
208
+ import { pipeline, PipelineError } from "builderman"
209
+
210
+ const result = await pipeline([libTask, consumerTask]).run()
211
+
212
+ if (!result.ok) {
213
+ switch (result.error.code) {
214
+ case PipelineError.Aborted:
215
+ console.error("Pipeline was cancelled")
216
+ break
217
+ case PipelineError.TaskFailed:
218
+ console.error(`Task failed: ${result.error.message}`)
219
+ break
220
+ case PipelineError.ProcessTerminated:
221
+ console.error("Process was terminated")
222
+ break
223
+ case PipelineError.InvalidTask:
224
+ console.error(`Invalid task configuration: ${result.error.message}`)
225
+ break
226
+ case PipelineError.InvalidSignal:
227
+ console.error("Invalid abort signal")
228
+ break
229
+ }
230
+ }
231
+ ```
232
+
233
+ Execution statistics are **always available**, even on failure.
234
+
235
+ ---
236
+
237
+ ## Cancellation
238
+
239
+ You can cancel a running pipeline using an `AbortSignal`.
240
+
241
+ ```ts
242
+ const controller = new AbortController()
103
243
 
104
- await ci.run()
244
+ const runPromise = pipeline([libTask, consumerTask]).run({
245
+ signal: controller.signal,
246
+ })
247
+
248
+ // Cancel after 5 seconds
249
+ setTimeout(() => {
250
+ controller.abort()
251
+ }, 5000)
252
+
253
+ const result = await runPromise
254
+
255
+ if (!result.ok && result.error.code === PipelineError.Aborted) {
256
+ console.error("Pipeline was cancelled")
257
+ console.log(`Tasks still running: ${result.stats.summary.running}`)
258
+ }
105
259
  ```
106
260
 
107
- **Note:** When a pipeline is converted to a task, it becomes a single unit in the dependency graph. The nested pipeline will execute completely before any dependent tasks can start.
261
+ ---
262
+
263
+ ## Teardown
264
+
265
+ Tasks may specify teardown commands that run automatically when a task completes or fails.
266
+
267
+ Teardowns are executed **in reverse dependency order** (dependents before dependencies) to ensure safe cleanup.
268
+
269
+ ### Basic Teardown
270
+
271
+ ```ts
272
+ const dbTask = task({
273
+ name: "database",
274
+ commands: {
275
+ dev: {
276
+ run: "docker-compose up",
277
+ teardown: "docker-compose down",
278
+ },
279
+ build: "echo build",
280
+ },
281
+ cwd: ".",
282
+ })
283
+ ```
284
+
285
+ ---
286
+
287
+ ### Teardown Callbacks
288
+
289
+ You can observe teardown execution using callbacks. Teardown failures do **not** cause the pipeline to fail β€” they are best-effort cleanup operations.
290
+
291
+ ```ts
292
+ const result = await pipeline([dbTask]).run({
293
+ onTaskTeardown: (taskName) => {
294
+ console.log(`[${taskName}] starting teardown`)
295
+ },
296
+ onTaskTeardownError: (taskName, error) => {
297
+ console.error(`[${taskName}] teardown failed: ${error.message}`)
298
+ },
299
+ })
300
+ ```
301
+
302
+ Teardown results are recorded in task statistics.
303
+
304
+ ---
305
+
306
+ ### Teardown Execution Rules
307
+
308
+ Teardowns run when:
309
+
310
+ - The command entered the running state
311
+ - The pipeline completes successfully
312
+ - The pipeline fails after tasks have started
313
+
314
+ Teardowns do **not** run when:
315
+
316
+ - The task was skipped
317
+ - The task failed before starting (spawn error)
318
+ - The pipeline never began execution
319
+
320
+ ---
321
+
322
+ ## Skipping Tasks
323
+
324
+ If a task does not define a command for the current mode, it is **skipped** by default.
325
+
326
+ Skipped tasks:
327
+
328
+ - Participate in the dependency graph
329
+ - Resolve immediately
330
+ - Unblock dependent tasks
331
+ - Do not execute commands or teardowns
332
+
333
+ ```ts
334
+ const dbTask = task({
335
+ name: "database",
336
+ commands: {
337
+ dev: "docker-compose up",
338
+ },
339
+ cwd: ".",
340
+ })
341
+
342
+ const apiTask = task({
343
+ name: "api",
344
+ commands: {
345
+ dev: "npm run dev",
346
+ build: "npm run build",
347
+ },
348
+ cwd: ".",
349
+ dependencies: [dbTask],
350
+ })
351
+
352
+ const result = await pipeline([dbTask, apiTask]).run({
353
+ command: "build",
354
+ onTaskSkipped: (taskName, mode) => {
355
+ console.log(`[${taskName}] skipped (no "${mode}" command)`)
356
+ },
357
+ })
358
+ ```
359
+
360
+ ---
361
+
362
+ ### Strict Mode
363
+
364
+ In **strict mode**, missing commands cause the pipeline to fail. This is useful for CI and release pipelines.
365
+
366
+ ```ts
367
+ const result = await pipeline([dbTask, apiTask]).run({
368
+ command: "build",
369
+ strict: true,
370
+ })
371
+
372
+ if (!result.ok) {
373
+ console.error("Pipeline failed in strict mode:", result.error.message)
374
+ }
375
+ ```
376
+
377
+ ---
378
+
379
+ ### Task-Level Skip Override
380
+
381
+ Tasks may explicitly allow skipping, even when strict mode is enabled.
382
+
383
+ ```ts
384
+ const dbTask = task({
385
+ name: "database",
386
+ commands: {
387
+ dev: "docker-compose up",
388
+ },
389
+ cwd: ".",
390
+ allowSkip: true,
391
+ })
392
+
393
+ const result = await pipeline([dbTask]).run({
394
+ command: "build",
395
+ strict: true,
396
+ })
397
+ ```
398
+
399
+ ---
400
+
401
+ ## Execution Statistics
402
+
403
+ Every pipeline run returns detailed execution statistics.
404
+
405
+ ### Pipeline Statistics
406
+
407
+ ```ts
408
+ console.log(result.stats.status) // "success" | "failed" | "aborted"
409
+ console.log(result.stats.command) // Executed mode
410
+ console.log(result.stats.durationMs) // Total execution time
411
+ console.log(result.stats.summary.total)
412
+ console.log(result.stats.summary.completed)
413
+ console.log(result.stats.summary.failed)
414
+ console.log(result.stats.summary.skipped)
415
+ console.log(result.stats.summary.running)
416
+ ```
417
+
418
+ ---
419
+
420
+ ### Task Statistics
421
+
422
+ Each task provides detailed per-task data:
423
+
424
+ ```ts
425
+ for (const task of Object.values(result.stats.tasks)) {
426
+ console.log(task.name, task.status)
427
+ console.log(task.durationMs)
428
+
429
+ if (task.status === "failed") {
430
+ console.error(task.error?.message)
431
+ console.error(task.exitCode)
432
+ }
433
+
434
+ if (task.teardown) {
435
+ console.log("Teardown:", task.teardown.status)
436
+ }
437
+ }
438
+ ```
439
+
440
+ ---
441
+
442
+ ## When Should I Use builderman?
443
+
444
+ **builderman** is a good fit when:
445
+
446
+ - You have dependent tasks that must run in a strict order
447
+ - You run long-lived dev processes that need readiness detection
448
+ - Cleanup matters (databases, containers, servers)
449
+ - You want structured results instead of log-scraping
450
+
451
+ It may be overkill if:
452
+
453
+ - You only need a few linear npm scripts
454
+ - You do not need dependency graphs or teardown guarantees
package/dist/graph.js CHANGED
@@ -1,8 +1,6 @@
1
1
  import { $TASK_INTERNAL } from "./constants.js";
2
- import { validateTasks } from "./util.js";
3
2
  export function createTaskGraph(tasks) {
4
3
  const nodes = new Map();
5
- validateTasks(tasks);
6
4
  // Create nodes for all tasks
7
5
  for (const task of tasks) {
8
6
  const { id: taskId } = task[$TASK_INTERNAL];
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { task } from "./task.js";
2
2
  export { pipeline } from "./pipeline.js";
3
- export type { Task, Pipeline, TaskConfig } from "./types.js";
3
+ export { PipelineError, type PipelineErrorCode } from "./pipeline-error.js";
4
+ export type { Task, Pipeline, TaskConfig, Command, CommandConfig, Commands, PipelineRunConfig, PipelineTaskConfig, RunResult, PipelineStats, TaskStats, TaskStatus, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { task } from "./task.js";
2
2
  export { pipeline } from "./pipeline.js";
3
+ export { PipelineError } from "./pipeline-error.js";
@@ -0,0 +1,13 @@
1
+ export interface SignalHandlerConfig {
2
+ abortSignal?: AbortSignal;
3
+ onProcessTerminated: (message: string) => void;
4
+ onAborted: () => void;
5
+ }
6
+ export interface SignalHandler {
7
+ cleanup(): void;
8
+ }
9
+ /**
10
+ * Creates a signal handler for pipeline execution.
11
+ * Handles process termination signals (SIGINT, SIGTERM, etc.) and abort signals.
12
+ */
13
+ export declare function createSignalHandler({ abortSignal, onAborted, onProcessTerminated, }: SignalHandlerConfig): SignalHandler;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Creates a signal handler for pipeline execution.
3
+ * Handles process termination signals (SIGINT, SIGTERM, etc.) and abort signals.
4
+ */
5
+ export function createSignalHandler({ abortSignal, onAborted, onProcessTerminated, }) {
6
+ // Handle termination signals
7
+ const processTerminationListenerCleanups = [
8
+ "SIGINT",
9
+ "SIGTERM",
10
+ "SIGQUIT",
11
+ "SIGBREAK",
12
+ ].map((sig) => {
13
+ const handleSignal = () => {
14
+ onProcessTerminated(`Received ${sig}`);
15
+ };
16
+ process.once(sig, handleSignal);
17
+ return () => {
18
+ process.removeListener(sig, handleSignal);
19
+ };
20
+ });
21
+ // Handle abort signal if provided
22
+ let signalCleanup = null;
23
+ if (abortSignal) {
24
+ abortSignal.addEventListener("abort", onAborted);
25
+ signalCleanup = () => {
26
+ abortSignal.removeEventListener("abort", onAborted);
27
+ };
28
+ }
29
+ return {
30
+ /**
31
+ * Cleans up all signal listeners.
32
+ */
33
+ cleanup() {
34
+ processTerminationListenerCleanups.forEach((cleanup) => cleanup());
35
+ signalCleanup?.();
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,25 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import { PipelineError } from "../pipeline-error.js";
3
+ import type { Task, Pipeline, PipelineRunConfig, TaskGraph, TaskStats } from "../types.js";
4
+ import type { SchedulerInput } from "../scheduler.js";
5
+ import type { TeardownManager } from "./teardown-manager.js";
6
+ export interface TaskExecutorConfig {
7
+ spawn: typeof import("node:child_process").spawn;
8
+ signal?: AbortSignal;
9
+ config?: PipelineRunConfig;
10
+ graph: TaskGraph;
11
+ runningTasks: Map<string, ChildProcess>;
12
+ runningPipelines: Map<string, {
13
+ stop: () => void;
14
+ }>;
15
+ teardownManager: TeardownManager;
16
+ pipelineTasksCache: WeakMap<Pipeline, Task[]>;
17
+ failPipeline: (error: PipelineError) => Promise<void>;
18
+ advanceScheduler: (input?: SchedulerInput) => void;
19
+ updateTaskStatus: (taskId: string, updates: Partial<TaskStats>) => void;
20
+ taskStats: Map<string, TaskStats>;
21
+ }
22
+ /**
23
+ * Executes a task (either a regular task or a nested pipeline).
24
+ */
25
+ export declare function executeTask(task: Task, executorConfig: TaskExecutorConfig): void;