builderman 1.3.0 → 1.4.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
@@ -10,28 +10,29 @@ It is designed for monorepos, long-running development processes, and CI/CD pipe
10
10
 
11
11
  ## Table of Contents
12
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)
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
+ > - [Environment Variables](#environment-variables)
20
+ > - [Dependencies](#dependencies)
21
+ > - [Pipelines](#pipelines)
22
+ > - [Pipeline Composition](#pipeline-composition)
23
+ > - [Error Handling Guarantees](#error-handling-guarantees)
24
+ > - [Cancellation](#cancellation)
25
+ > - [Teardown](#teardown)
26
+ > - [Basic Teardown](#basic-teardown)
27
+ > - [Teardown Callbacks](#teardown-callbacks)
28
+ > - [Teardown Execution Rules](#teardown-execution-rules)
29
+ > - [Skipping Tasks](#skipping-tasks)
30
+ > - [Strict Mode](#strict-mode)
31
+ > - [Task-Level Skip Override](#task-level-skip-override)
32
+ > - [Execution Statistics](#execution-statistics)
33
+ > - [Pipeline Statistics](#pipeline-statistics)
34
+ > - [Task Statistics](#task-statistics)
35
+ > - [When Should I Use builderman?](#when-should-i-use-builderman)
35
36
 
36
37
  ## Key Features
37
38
 
@@ -62,17 +63,19 @@ import { task, pipeline } from "builderman"
62
63
  const build = task({
63
64
  name: "build",
64
65
  commands: { build: "tsc" },
65
- cwd: ".",
66
+ cwd: "packages/my-package", // Optional: defaults to "."
66
67
  })
67
68
 
68
69
  const test = task({
69
70
  name: "test",
70
71
  commands: { build: "npm test" },
71
- cwd: ".",
72
72
  dependencies: [build],
73
+ cwd: "packages/my-package",
73
74
  })
74
75
 
75
- const result = await pipeline([build, test]).run()
76
+ const result = await pipeline([build, test]).run({
77
+ command: "build",
78
+ })
76
79
 
77
80
  if (!result.ok) {
78
81
  console.error("Pipeline failed:", result.error.message)
@@ -93,6 +96,7 @@ A **task** represents a unit of work. Each task:
93
96
  - Defines commands for one or more modes
94
97
  - May depend on other tasks
95
98
  - May register teardown logic
99
+ - Has an optional working directory (`cwd`, defaults to `"."`)
96
100
 
97
101
  ```ts
98
102
  import { task } from "builderman"
@@ -130,6 +134,89 @@ Commands may be:
130
134
  - `run`: the command to execute
131
135
  - `readyWhen`: a predicate that marks the task as ready
132
136
  - `teardown`: cleanup logic to run after completion
137
+ - `env`: environment variables specific to this command
138
+
139
+ ---
140
+
141
+ ### Environment Variables
142
+
143
+ Environment variables can be provided at multiple levels, with more specific levels overriding less specific ones:
144
+
145
+ **Precedence order (highest to lowest):**
146
+
147
+ 1. Command-level `env` (in command config)
148
+ 2. Task-level `env` (in task config)
149
+ 3. Pipeline-level `env` (in `pipeline.run()`)
150
+ 4. Process environment variables
151
+
152
+ #### Command-Level Environment Variables
153
+
154
+ ```ts
155
+ const apiTask = task({
156
+ name: "api",
157
+ commands: {
158
+ dev: {
159
+ run: "npm run dev",
160
+ env: {
161
+ PORT: "3000",
162
+ NODE_ENV: "development",
163
+ },
164
+ },
165
+ },
166
+ })
167
+ ```
168
+
169
+ #### Task-Level Environment Variables
170
+
171
+ ```ts
172
+ const apiTask = task({
173
+ name: "api",
174
+ commands: {
175
+ dev: "npm run dev",
176
+ build: "npm run build",
177
+ },
178
+ env: {
179
+ API_URL: "http://localhost:3000",
180
+ LOG_LEVEL: "debug",
181
+ },
182
+ })
183
+ ```
184
+
185
+ #### Pipeline-Level Environment Variables
186
+
187
+ ```ts
188
+ const result = await pipeline([apiTask]).run({
189
+ env: {
190
+ DATABASE_URL: "postgres://localhost/mydb",
191
+ REDIS_URL: "redis://localhost:6379",
192
+ },
193
+ })
194
+ ```
195
+
196
+ #### Nested Pipeline Environment Variables
197
+
198
+ When converting a pipeline to a task, you can provide environment variables that will be merged with the outer pipeline's environment:
199
+
200
+ ```ts
201
+ const innerPipeline = pipeline([
202
+ /* ... */
203
+ ])
204
+ const innerTask = innerPipeline.toTask({
205
+ name: "inner",
206
+ env: {
207
+ INNER_VAR: "inner-value",
208
+ },
209
+ })
210
+
211
+ const outerPipeline = pipeline([innerTask])
212
+ const result = await outerPipeline.run({
213
+ env: {
214
+ OUTER_VAR: "outer-value",
215
+ },
216
+ })
217
+ ```
218
+
219
+ In this example, tasks in `innerPipeline` will receive both `INNER_VAR` and `OUTER_VAR`, with `INNER_VAR` taking precedence if there's a conflict.
133
220
 
134
221
  ---
135
222
 
@@ -187,7 +274,11 @@ const deploy = pipeline([
187
274
  ])
188
275
 
189
276
  const buildTask = build.toTask({ name: "build" })
190
- const testTask = test.toTask({ name: "test", dependencies: [buildTask] })
277
+ const testTask = test.toTask({
278
+ name: "test",
279
+ dependencies: [buildTask],
280
+ env: { TEST_ENV: "test-value" }, // Optional: env for nested pipeline
281
+ })
191
282
  const deployTask = deploy.toTask({ name: "deploy", dependencies: [testTask] })
192
283
 
193
284
  const ci = pipeline([buildTask, testTask, deployTask])
@@ -278,7 +369,6 @@ const dbTask = task({
278
369
  },
279
370
  build: "echo build",
280
371
  },
281
- cwd: ".",
282
372
  })
283
373
  ```
284
374
 
@@ -336,7 +426,6 @@ const dbTask = task({
336
426
  commands: {
337
427
  dev: "docker-compose up",
338
428
  },
339
- cwd: ".",
340
429
  })
341
430
 
342
431
  const apiTask = task({
@@ -345,7 +434,6 @@ const apiTask = task({
345
434
  dev: "npm run dev",
346
435
  build: "npm run build",
347
436
  },
348
- cwd: ".",
349
437
  dependencies: [dbTask],
350
438
  })
351
439
 
@@ -386,7 +474,6 @@ const dbTask = task({
386
474
  commands: {
387
475
  dev: "docker-compose up",
388
476
  },
389
- cwd: ".",
390
477
  allowSkip: true,
391
478
  })
392
479
 
@@ -17,13 +17,13 @@ export function executeTask(task, executorConfig) {
17
17
  return;
18
18
  // Handle pipeline tasks
19
19
  if (nestedPipeline) {
20
- executeNestedPipeline(taskId, taskName, nestedPipeline, executorConfig);
20
+ executeNestedPipeline(task, taskId, taskName, nestedPipeline, executorConfig);
21
21
  return;
22
22
  }
23
23
  // Regular task execution
24
24
  executeRegularTask(task, taskId, taskName, executorConfig);
25
25
  }
26
- function executeNestedPipeline(taskId, taskName, nestedPipeline, executorConfig) {
26
+ function executeNestedPipeline(task, taskId, taskName, nestedPipeline, executorConfig) {
27
27
  const { config, runningPipelines, pipelineTasksCache, failPipeline, advanceScheduler, updateTaskStatus, } = executorConfig;
28
28
  // Track nested pipeline state for skip behavior
29
29
  let nestedSkippedCount = 0;
@@ -33,7 +33,7 @@ function executeNestedPipeline(taskId, taskName, nestedPipeline, executorConfig)
33
33
  const nestedTotalTasks = nestedTasks
34
34
  ? createTaskGraph(nestedTasks).nodes.size
35
35
  : 0;
36
- const commandName = (config?.command ?? process.env.NODE_ENV === "production") ? "build" : "dev";
36
+ const commandName = config?.command ?? process.env.NODE_ENV === "production" ? "build" : "dev";
37
37
  // Mark as ready immediately (pipeline entry nodes will handle their own ready state)
38
38
  const startedAt = Date.now();
39
39
  updateTaskStatus(taskId, {
@@ -51,6 +51,13 @@ function executeNestedPipeline(taskId, taskName, nestedPipeline, executorConfig)
51
51
  // In a more sophisticated implementation, we could propagate stop signals
52
52
  };
53
53
  runningPipelines.set(taskId, { stop: stopPipeline });
54
+ // Merge environment variables: pipeline.env -> task.env (from pipeline.toTask config)
55
+ const taskEnv = task[$TASK_INTERNAL].env;
56
+ const pipelineEnv = config?.env ?? {};
57
+ const mergedEnv = {
58
+ ...pipelineEnv,
59
+ ...taskEnv,
60
+ };
54
61
  // Run the nested pipeline with signal propagation
55
62
  nestedPipeline
56
63
  .run({
@@ -58,6 +65,7 @@ function executeNestedPipeline(taskId, taskName, nestedPipeline, executorConfig)
58
65
  command: config?.command,
59
66
  strict: config?.strict,
60
67
  signal: executorConfig.signal, // Pass signal to nested pipeline
68
+ env: mergedEnv, // Pass merged env to nested pipeline
61
69
  onTaskBegin: (nestedTaskName) => {
62
70
  if (pipelineStopped)
63
71
  return;
@@ -124,11 +132,11 @@ function executeNestedPipeline(taskId, taskName, nestedPipeline, executorConfig)
124
132
  }
125
133
  function executeRegularTask(task, taskId, taskName, executorConfig) {
126
134
  const { spawn: spawnFn, signal, config, runningTasks, teardownManager, failPipeline, advanceScheduler, updateTaskStatus, } = executorConfig;
127
- const commandName = (config?.command ?? process.env.NODE_ENV === "production") ? "build" : "dev";
128
- const commandConfig = task[$TASK_INTERNAL].commands[commandName];
135
+ const { allowSkip, commands, cwd, env: taskEnv } = task[$TASK_INTERNAL];
136
+ const commandName = config?.command ?? process.env.NODE_ENV === "production" ? "build" : "dev";
137
+ const commandConfig = commands[commandName];
129
138
  // Check if command exists
130
139
  if (commandConfig === undefined) {
131
- const allowSkip = task[$TASK_INTERNAL].allowSkip ?? false;
132
140
  const strict = config?.strict ?? false;
133
141
  // If strict mode and not explicitly allowed to skip, fail
134
142
  if (strict && !allowSkip) {
@@ -159,13 +167,21 @@ function executeRegularTask(task, taskId, taskName, executorConfig) {
159
167
  });
160
168
  return;
161
169
  }
162
- const command = typeof commandConfig === "string" ? commandConfig : commandConfig.run;
163
- const readyWhen = typeof commandConfig === "string" ? undefined : commandConfig.readyWhen;
164
- const readyTimeout = typeof commandConfig === "string"
165
- ? Infinity
166
- : (commandConfig.readyTimeout ?? Infinity);
167
- const teardown = typeof commandConfig === "string" ? undefined : commandConfig.teardown;
168
- const { cwd } = task[$TASK_INTERNAL];
170
+ let command;
171
+ let readyWhen;
172
+ let readyTimeout = Infinity;
173
+ let teardown;
174
+ let commandEnv = {};
175
+ if (typeof commandConfig === "string") {
176
+ command = commandConfig;
177
+ }
178
+ else {
179
+ command = commandConfig.run;
180
+ readyWhen = commandConfig.readyWhen;
181
+ readyTimeout = commandConfig.readyTimeout ?? Infinity;
182
+ teardown = commandConfig.teardown;
183
+ commandEnv = commandConfig.env ?? {};
184
+ }
169
185
  const taskCwd = path.isAbsolute(cwd) ? cwd : path.resolve(process.cwd(), cwd);
170
186
  if (!fs.existsSync(taskCwd)) {
171
187
  const finishedAt = Date.now();
@@ -187,16 +203,20 @@ function executeRegularTask(task, taskId, taskName, executorConfig) {
187
203
  ]
188
204
  .filter(Boolean)
189
205
  .join(process.platform === "win32" ? ";" : ":");
190
- const env = {
206
+ // Merge environment variables in order: process.env -> pipeline.env -> task.env -> command.env
207
+ const accumulatedEnv = {
191
208
  ...process.env,
192
209
  PATH: accumulatedPath,
193
210
  Path: accumulatedPath,
211
+ ...config?.env,
212
+ ...taskEnv,
213
+ ...commandEnv,
194
214
  };
195
215
  const child = spawnFn(command, {
196
216
  cwd: taskCwd,
197
217
  stdio: ["inherit", "pipe", "pipe"],
198
218
  shell: true,
199
- env,
219
+ env: accumulatedEnv,
200
220
  });
201
221
  runningTasks.set(taskId, child);
202
222
  const startedAt = Date.now();
package/dist/pipeline.js CHANGED
@@ -26,12 +26,12 @@ export function pipeline(tasks) {
26
26
  graph.validate();
27
27
  graph.simplify();
28
28
  const pipelineImpl = {
29
- toTask({ name, dependencies }) {
29
+ toTask({ name, dependencies, env }) {
30
30
  const syntheticTask = task({
31
31
  name,
32
32
  commands: {},
33
- cwd: ".",
34
33
  dependencies,
34
+ env,
35
35
  });
36
36
  // Mark this task as a pipeline task so it can be detected by the executor
37
37
  syntheticTask[$TASK_INTERNAL].pipeline = pipelineImpl;
package/dist/task.js CHANGED
@@ -10,18 +10,26 @@ import { validateTasks } from "./util.js";
10
10
  * await pipeline([build, deploy]).run()
11
11
  */
12
12
  export function task(config) {
13
- validateTasks(config.dependencies);
14
- const taskInstance = {
15
- name: config.name,
13
+ const { name, commands, cwd = ".", dependencies = [], env, allowSkip, } = config;
14
+ const dependenciesClone = [...dependencies];
15
+ validateTasks(dependenciesClone);
16
+ const commandsClone = Object.fromEntries(Object.entries(commands).map(([key, command]) => {
17
+ if (typeof command === "string") {
18
+ return [key, command];
19
+ }
20
+ const { run, readyWhen, readyTimeout, teardown, env } = command;
21
+ return [key, { run, readyWhen, readyTimeout, teardown, env: { ...env } }];
22
+ }));
23
+ return {
24
+ name,
16
25
  [$TASK_INTERNAL]: {
17
- ...config,
26
+ name,
27
+ cwd,
28
+ dependencies: dependenciesClone,
29
+ env: { ...env },
30
+ allowSkip,
18
31
  id: crypto.randomUUID(),
19
- commands: Object.fromEntries(Object.entries(config.commands).map(([key, value]) => [
20
- key,
21
- typeof value === "string" ? value : { ...value },
22
- ])),
23
- dependencies: [...(config.dependencies || [])],
32
+ commands: commandsClone,
24
33
  },
25
34
  };
26
- return taskInstance;
27
35
  }
package/dist/types.d.ts CHANGED
@@ -25,6 +25,11 @@ export interface CommandConfig {
25
25
  * Optional command to run during teardown (e.g., to stop a server).
26
26
  */
27
27
  teardown?: string;
28
+ /**
29
+ * Optional environment variables to set for the process spawned by this command.
30
+ * Overrides environment variables inherited from the parent process & task config.
31
+ */
32
+ env?: Record<string, string>;
28
33
  }
29
34
  /**
30
35
  * A command can be either a simple string or a CommandConfig object.
@@ -57,8 +62,9 @@ export interface TaskConfig {
57
62
  /**
58
63
  * Working directory for the task's commands.
59
64
  * Can be absolute or relative to the current working directory.
65
+ * @default "."
60
66
  */
61
- cwd: string;
67
+ cwd?: string;
62
68
  /**
63
69
  * Optional array of tasks that must complete before this task can start.
64
70
  * Dependencies are executed in parallel when possible.
@@ -69,10 +75,17 @@ export interface TaskConfig {
69
75
  * Use this to explicitly mark tasks that are intentionally mode-specific.
70
76
  */
71
77
  allowSkip?: boolean;
78
+ /**
79
+ * Optional environment variables to set for the process spawned by this task.
80
+ * Overrides environment variables inherited from the parent process.
81
+ */
82
+ env?: Record<string, string>;
72
83
  }
73
84
  interface TaskInternal extends TaskConfig {
74
85
  id: string;
86
+ cwd: string;
75
87
  dependencies: Task[];
88
+ env: Record<string, string>;
76
89
  pipeline?: Pipeline;
77
90
  }
78
91
  /**
@@ -99,6 +112,11 @@ export interface PipelineRunConfig {
99
112
  * @default process.env.NODE_ENV === "production" ? "build" : "dev"
100
113
  */
101
114
  command?: string;
115
+ /**
116
+ * Optional environment variables to set for processes spawned by this pipeline.
117
+ * Overrides environment variables inherited from the parent process.
118
+ */
119
+ env?: Record<string, string>;
102
120
  /**
103
121
  * Provides a custom abort signal for the pipeline.
104
122
  * Aborting the signal will cause the pipeline to fail.
@@ -156,6 +174,11 @@ export interface PipelineTaskConfig {
156
174
  * Optional array of tasks that must complete before this pipeline task can start.
157
175
  */
158
176
  dependencies?: Task[];
177
+ /**
178
+ * Optional environment variables to set for the process spawned by this pipeline task.
179
+ * Overrides environment variables inherited from the parent process.
180
+ */
181
+ env?: Record<string, string>;
159
182
  }
160
183
  /**
161
184
  * A pipeline manages the execution of tasks with dependency-based coordination.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "builderman",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Simple task runner for building and developing projects.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",