@livingdata/pipex 0.0.2 → 0.0.4

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
@@ -4,60 +4,54 @@ Execution engine for containerized steps via Docker CLI.
4
4
 
5
5
  Runs containers with explicit volume mounts and manages artifacts through a staging/commit lifecycle. Designed to be driven by different orchestrators (CLI included, AI agent planned).
6
6
 
7
- ## Installation
7
+ ## Prerequisites
8
+
9
+ - Node.js 24+
10
+ - Docker CLI installed and accessible
11
+
12
+ ## Quick Start
13
+
14
+ Run directly without installing:
8
15
 
9
16
  ```bash
10
- npm install
11
- cp .env.example .env
12
- # Edit .env to set PIPEX_WORKDIR if needed (defaults to ./workdir)
17
+ npx @livingdata/pipex run pipeline.yaml --workspace my-build
13
18
  ```
14
19
 
15
- ## Prerequisites
20
+ Or install globally:
16
21
 
17
- - Node.js 24+
18
- - Docker CLI installed and accessible
22
+ ```bash
23
+ npm install -g @livingdata/pipex
24
+ pipex run pipeline.yaml --workspace my-build
25
+ ```
19
26
 
20
27
  ## Usage
21
28
 
22
- ### Running a pipeline
23
-
24
29
  ```bash
25
30
  # Interactive mode (default)
26
- npm start -- run pipeline.example.json
31
+ pipex run pipeline.yaml
27
32
 
28
33
  # With workspace name (enables caching)
29
- npm start -- run pipeline.example.json --workspace my-build
34
+ pipex run pipeline.yaml --workspace my-build
30
35
 
31
36
  # JSON mode (for CI/CD)
32
- npm start -- run pipeline.example.json --json
37
+ pipex run pipeline.yaml --json
33
38
 
34
39
  # Custom workdir
35
- npm start -- run pipeline.example.json --workdir /tmp/builds
40
+ pipex run pipeline.yaml --workdir /tmp/builds
36
41
  ```
37
42
 
38
43
  ### Managing workspaces
39
44
 
40
45
  ```bash
41
46
  # List workspaces (with artifact/cache counts)
42
- npm start -- list
43
- npm start -- ls --json
47
+ pipex list
48
+ pipex ls --json
44
49
 
45
50
  # Remove specific workspaces
46
- npm start -- rm my-build other-build
51
+ pipex rm my-build other-build
47
52
 
48
53
  # Remove all workspaces
49
- npm start -- clean
50
- ```
51
-
52
- ### Via npx
53
-
54
- ```bash
55
- # Build first
56
- npm run build
57
-
58
- # Run locally via npx
59
- npx . run example/pipeline.json --workspace my-build
60
- npx . list
54
+ pipex clean
61
55
  ```
62
56
 
63
57
  ### Commands
@@ -85,34 +79,115 @@ npx . list
85
79
 
86
80
  ## Pipeline Format
87
81
 
88
- Minimal example:
89
-
90
- ```json
91
- {
92
- "name": "my-pipeline",
93
- "steps": [
94
- {
95
- "id": "download",
96
- "image": "alpine:3.19",
97
- "cmd": ["sh", "-c", "echo hello > /output/hello.txt"]
98
- },
99
- {
100
- "id": "process",
101
- "image": "alpine:3.19",
102
- "cmd": ["cat", "/input/download/hello.txt"],
103
- "inputs": [{"step": "download"}]
104
- }
105
- ]
106
- }
82
+ Pipeline files can be written in **YAML** (`.yaml` / `.yml`) or **JSON** (`.json`). YAML is recommended for readability; JSON is still fully supported.
83
+
84
+ Steps can be defined in two ways: **raw steps** with explicit image/cmd, or **kit steps** using `uses` for common patterns. Both can coexist in the same pipeline.
85
+
86
+ ### Pipeline and Step Identity
87
+
88
+ Both pipelines and steps support an `id`/`name` duality:
89
+
90
+ - **`id`** — Machine identifier (alphanum, dash, underscore). Used for caching, state, artifacts.
91
+ - **`name`** — Human-readable label (free-form text). Used for display.
92
+ - At least one must be defined. If `id` is missing it is derived from `name` via slugification (e.g. `"Données préparées"` → `donnees-preparees`). If `name` is missing, `id` is used for display.
93
+
94
+ ```yaml
95
+ # Pipeline with both id and name
96
+ id: data-pipeline
97
+ name: Data Processing Pipeline
98
+ steps:
99
+ # Step with only id (current style, still works)
100
+ - id: download
101
+ image: alpine:3.19
102
+ cmd: [sh, -c, "echo hello > /output/hello.txt"]
103
+
104
+ # Step with only name (id auto-derived to "build-assets")
105
+ - name: Build Assets
106
+ image: node:22-alpine
107
+ cmd: [sh, -c, "echo done > /output/result.txt"]
108
+
109
+ # Step with both
110
+ - id: deploy
111
+ name: Deploy to Staging
112
+ image: alpine:3.19
113
+ cmd: [echo, deployed]
114
+ ```
115
+
116
+ ### Kit Steps
117
+
118
+ Kits are reusable templates that generate the image, command, caches, and mounts for common runtimes. Use `uses` to select a kit and `with` to pass parameters:
119
+
120
+ ```yaml
121
+ name: my-pipeline
122
+ steps:
123
+ - id: build
124
+ uses: node
125
+ with: { script: build.js, src: src/app }
126
+ - id: analyze
127
+ uses: python
128
+ with: { script: analyze.py, src: scripts }
129
+ ```
130
+
131
+ `uses` and `image`/`cmd` are mutually exclusive. All other step fields (`env`, `inputs`, `mounts`, `caches`, `timeoutSec`, `allowFailure`, `allowNetwork`) remain available and merge with kit defaults (user values take priority). The `src` parameter in `with` generates a read-only mount at `/app` in the container.
132
+
133
+ #### Available Kits
134
+
135
+ **`node`** -- Run a Node.js script with automatic dependency installation.
136
+
137
+ | Parameter | Default | Description |
138
+ |-----------|---------|-------------|
139
+ | `script` | *(required)* | Script to run (relative to `/app`) |
140
+ | `src` | -- | Host directory to mount at `/app` |
141
+ | `version` | `"24"` | Node.js version |
142
+ | `packageManager` | `"npm"` | `"npm"`, `"pnpm"`, or `"yarn"` |
143
+ | `install` | `true` | Run package install before script |
144
+ | `variant` | `"alpine"` | Image variant |
145
+
146
+ **`python`** -- Run a Python script with automatic dependency installation from `requirements.txt`.
147
+
148
+ | Parameter | Default | Description |
149
+ |-----------|---------|-------------|
150
+ | `script` | *(required)* | Script to run (relative to `/app`) |
151
+ | `src` | -- | Host directory to mount at `/app` |
152
+ | `version` | `"3.12"` | Python version |
153
+ | `packageManager` | `"pip"` | `"pip"` or `"uv"` |
154
+ | `install` | `true` | Run dependency install before script |
155
+ | `variant` | `"slim"` | Image variant |
156
+
157
+ **`bash`** -- Run a shell command in a lightweight container.
158
+
159
+ | Parameter | Default | Description |
160
+ |-----------|---------|-------------|
161
+ | `run` | *(required)* | Shell command to execute |
162
+ | `src` | -- | Host directory to mount at `/app` |
163
+ | `image` | `"alpine:3.20"` | Docker image |
164
+
165
+ ### Raw Steps
166
+
167
+ For full control, define `image` and `cmd` directly:
168
+
169
+ ```yaml
170
+ name: my-pipeline
171
+ steps:
172
+ - id: download
173
+ image: alpine:3.19
174
+ cmd: [sh, -c, "echo hello > /output/hello.txt"]
175
+ - id: process
176
+ image: alpine:3.19
177
+ cmd: [cat, /input/download/hello.txt]
178
+ inputs: [{ step: download }]
107
179
  ```
108
180
 
109
181
  ### Step Options
110
182
 
111
183
  | Field | Type | Description |
112
184
  |-------|------|-------------|
113
- | `id` | string | Step identifier (required) |
114
- | `image` | string | Docker image (required) |
115
- | `cmd` | string[] | Command to execute (required) |
185
+ | `id` | string | Step identifier (at least one of `id`/`name` required) |
186
+ | `name` | string | Human-readable display name |
187
+ | `image` | string | Docker image (required for raw steps) |
188
+ | `cmd` | string[] | Command to execute (required for raw steps) |
189
+ | `uses` | string | Kit name (required for kit steps) |
190
+ | `with` | object | Kit parameters |
116
191
  | `inputs` | InputSpec[] | Previous steps to mount as read-only |
117
192
  | `env` | Record<string, string> | Environment variables |
118
193
  | `outputPath` | string | Output mount point (default: `/output`) |
@@ -126,11 +201,11 @@ Minimal example:
126
201
 
127
202
  Mount previous steps as read-only:
128
203
 
129
- ```json
130
- "inputs": [
131
- {"step": "step1"},
132
- {"step": "step2", "copyToOutput": true}
133
- ]
204
+ ```yaml
205
+ inputs:
206
+ - step: step1
207
+ - step: step2
208
+ copyToOutput: true
134
209
  ```
135
210
 
136
211
  - Mounted under `/input/{stepName}/`
@@ -140,11 +215,12 @@ Mount previous steps as read-only:
140
215
 
141
216
  Mount host directories into containers as **read-only**:
142
217
 
143
- ```json
144
- "mounts": [
145
- {"host": "src/app", "container": "/app"},
146
- {"host": "config", "container": "/config"}
147
- ]
218
+ ```yaml
219
+ mounts:
220
+ - host: src/app
221
+ container: /app
222
+ - host: config
223
+ container: /config
148
224
  ```
149
225
 
150
226
  - `host` must be a **relative** path (resolved from the pipeline file's directory)
@@ -152,17 +228,18 @@ Mount host directories into containers as **read-only**:
152
228
  - Neither path can contain `..`
153
229
  - Always mounted read-only -- containers cannot modify host files
154
230
 
155
- This means a pipeline at `/project/ci/pipeline.json` can only mount subdirectories of `/project/ci/`. Use `/tmp` or `/output` inside the container for writes.
231
+ This means a pipeline at `/project/ci/pipeline.yaml` can only mount subdirectories of `/project/ci/`. Use `/tmp` or `/output` inside the container for writes.
156
232
 
157
233
  ### Caches
158
234
 
159
235
  Persistent read-write directories shared across steps and executions:
160
236
 
161
- ```json
162
- "caches": [
163
- {"name": "pnpm-store", "path": "/root/.local/share/pnpm/store"},
164
- {"name": "build-cache", "path": "/tmp/cache"}
165
- ]
237
+ ```yaml
238
+ caches:
239
+ - name: pnpm-store
240
+ path: /root/.local/share/pnpm/store
241
+ - name: build-cache
242
+ path: /tmp/cache
166
243
  ```
167
244
 
168
245
  - **Persistent**: Caches survive across pipeline executions
@@ -178,35 +255,34 @@ Common use cases:
178
255
 
179
256
  ## Example
180
257
 
181
- The `example/` directory contains a multi-language pipeline that chains Node.js and Python steps:
258
+ The `example/` directory contains a multi-language pipeline that chains Node.js and Python steps using kits:
182
259
 
183
260
  ```
184
261
  example/
185
- ├── pipeline.json
262
+ ├── pipeline.yaml
186
263
  └── scripts/
187
- ├── nodejs/ # lodash-based data analysis
264
+ ├── nodejs/ # lodash-based data analysis
188
265
  │ ├── package.json
189
266
  │ ├── analyze.js
190
267
  │ └── transform.js
191
- └── python/ # pyyaml-based enrichment
268
+ └── python/ # pyyaml-based enrichment
192
269
  ├── pyproject.toml
270
+ ├── requirements.txt
193
271
  ├── analyze.py
194
272
  └── transform.py
195
273
  ```
196
274
 
197
- The pipeline runs 4 steps: `node-analyze` → `node-transform` → `python-analyze` → `python-transform`. Each step mounts its scripts directory as read-only and passes artifacts to the next step via `/input`.
275
+ The pipeline uses the `node` and `python` kits to run 4 steps: `node-analyze` → `node-transform` → `python-analyze` → `python-transform`. Each step passes artifacts to the next via `/input`.
198
276
 
199
277
  ```bash
200
- npm start -- run example/pipeline.json --workspace example-test
278
+ pipex run example/pipeline.yaml --workspace example-test
201
279
  ```
202
280
 
203
281
  ## Caching & Workspaces
204
282
 
205
- Workspaces enable caching across runs. Name is determined by:
283
+ Workspaces enable caching across runs. The workspace ID is determined by:
206
284
  1. CLI flag `--workspace` (highest priority)
207
- 2. Config `"name"` field
208
- 3. Filename (e.g., `build.json` → `build`)
209
- 4. Auto-generated timestamp
285
+ 2. Pipeline `id` (explicit or derived from `name`)
210
286
 
211
287
  **Cache behavior**: Steps are skipped if image, cmd, env, inputs, and mounts haven't changed. See code documentation for details.
212
288
 
@@ -232,10 +308,10 @@ newgrp docker
232
308
  Clean old workspaces:
233
309
 
234
310
  ```bash
235
- npm start -- list
236
- npm start -- rm old-workspace-id
311
+ pipex list
312
+ pipex rm old-workspace-id
237
313
  # Or remove all at once
238
- npm start -- clean
314
+ pipex clean
239
315
  ```
240
316
 
241
317
  ### Cached step with missing artifact
@@ -249,9 +325,25 @@ rm $PIPEX_WORKDIR/{workspace-id}/state.json
249
325
  ## Development
250
326
 
251
327
  ```bash
252
- npm run build
253
- npm run lint
254
- npm run lint:fix
328
+ git clone https://github.com/livingdata-co/pipex.git
329
+ cd pipex
330
+ npm install
331
+ cp .env.example .env
332
+ ```
333
+
334
+ Run the CLI without building (via tsx):
335
+
336
+ ```bash
337
+ npm run cli -- run pipeline.yaml --workspace my-build
338
+ npm run cli -- list
339
+ ```
340
+
341
+ Other commands:
342
+
343
+ ```bash
344
+ npm run build # Compile TypeScript (tsc → dist/)
345
+ npm run lint # Lint with XO
346
+ npm run lint:fix # Auto-fix lint issues
255
347
  ```
256
348
 
257
349
  ## Architecture
@@ -259,3 +351,4 @@ npm run lint:fix
259
351
  For implementation details, see code documentation in:
260
352
  - `src/engine/` - Low-level container execution (workspace, executor)
261
353
  - `src/cli/` - Pipeline orchestration (runner, loader, state)
354
+ - `src/kits/` - Kit system (registry, built-in kit implementations)
package/dist/cli/index.js CHANGED
@@ -23,7 +23,7 @@ async function main() {
23
23
  program
24
24
  .command('run')
25
25
  .description('Execute a pipeline')
26
- .argument('<pipeline>', 'Pipeline JSON file to execute')
26
+ .argument('<pipeline>', 'Pipeline file to execute (JSON or YAML)')
27
27
  .option('-w, --workspace <name>', 'Workspace name (for caching)')
28
28
  .option('-f, --force [steps]', 'Skip cache for all steps, or a comma-separated list (e.g. --force step1,step2)')
29
29
  .action(async (pipelineFile, options, cmd) => {
@@ -1,20 +1,57 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { extname } from 'node:path';
3
+ import { deburr } from 'lodash-es';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { getKit } from '../kits/index.js';
6
+ import { isKitStep } from '../types.js';
2
7
  export class PipelineLoader {
3
8
  async load(filePath) {
4
9
  const content = await readFile(filePath, 'utf8');
5
- const config = JSON.parse(content);
6
- if (!Array.isArray(config.steps) || config.steps.length === 0) {
10
+ const input = parsePipelineFile(content, filePath);
11
+ if (!input.id && !input.name) {
12
+ throw new Error('Invalid pipeline: at least one of "id" or "name" must be defined');
13
+ }
14
+ const pipelineId = input.id ?? slugify(input.name);
15
+ if (!Array.isArray(input.steps) || input.steps.length === 0) {
7
16
  throw new Error('Invalid pipeline: steps must be a non-empty array');
8
17
  }
9
- for (const step of config.steps) {
18
+ const steps = input.steps.map(step => this.resolveStep(step));
19
+ for (const step of steps) {
10
20
  this.validateStep(step);
11
21
  }
12
- return config;
22
+ this.validateUniqueStepIds(steps);
23
+ return { id: pipelineId, name: input.name, steps };
13
24
  }
14
- validateStep(step) {
15
- if (!step.id || typeof step.id !== 'string') {
16
- throw new Error('Invalid step: id is required');
25
+ resolveStep(step) {
26
+ if (!step.id && !step.name) {
27
+ throw new Error('Invalid step: at least one of "id" or "name" must be defined');
28
+ }
29
+ const id = step.id ?? slugify(step.name);
30
+ const { name } = step;
31
+ if (!isKitStep(step)) {
32
+ return { ...step, id, name };
17
33
  }
34
+ return this.resolveKitStep(step, id, name);
35
+ }
36
+ resolveKitStep(step, id, name) {
37
+ const kit = getKit(step.uses);
38
+ const kitOutput = kit.resolve(step.with ?? {});
39
+ return {
40
+ id,
41
+ name,
42
+ image: kitOutput.image,
43
+ cmd: kitOutput.cmd,
44
+ env: mergeEnv(kitOutput.env, step.env),
45
+ inputs: step.inputs,
46
+ outputPath: step.outputPath,
47
+ caches: mergeCaches(kitOutput.caches, step.caches),
48
+ mounts: mergeMounts(kitOutput.mounts, step.mounts),
49
+ timeoutSec: step.timeoutSec,
50
+ allowFailure: step.allowFailure,
51
+ allowNetwork: step.allowNetwork ?? kitOutput.allowNetwork
52
+ };
53
+ }
54
+ validateStep(step) {
18
55
  this.validateIdentifier(step.id, 'step id');
19
56
  if (!step.image || typeof step.image !== 'string') {
20
57
  throw new Error(`Invalid step ${step.id}: image is required`);
@@ -84,4 +121,54 @@ export class PipelineLoader {
84
121
  throw new Error(`Invalid ${context}: '${id}' cannot contain '..'`);
85
122
  }
86
123
  }
124
+ validateUniqueStepIds(steps) {
125
+ const seen = new Set();
126
+ for (const step of steps) {
127
+ if (seen.has(step.id)) {
128
+ throw new Error(`Duplicate step id: '${step.id}'`);
129
+ }
130
+ seen.add(step.id);
131
+ }
132
+ }
133
+ }
134
+ /** Convert a free-form name into a valid identifier. */
135
+ function slugify(name) {
136
+ return deburr(name)
137
+ .toLowerCase()
138
+ .replaceAll(/[^\w-]/g, '-')
139
+ .replaceAll(/-{2,}/g, '-')
140
+ .replace(/^-/, '')
141
+ .replace(/-$/, '');
142
+ }
143
+ function parsePipelineFile(content, filePath) {
144
+ const ext = extname(filePath).toLowerCase();
145
+ if (ext === '.yaml' || ext === '.yml') {
146
+ return parseYaml(content);
147
+ }
148
+ return JSON.parse(content);
149
+ }
150
+ function mergeEnv(kitEnv, userEnv) {
151
+ if (!kitEnv && !userEnv) {
152
+ return undefined;
153
+ }
154
+ return { ...kitEnv, ...userEnv };
155
+ }
156
+ function mergeCaches(kitCaches, userCaches) {
157
+ if (!kitCaches && !userCaches) {
158
+ return undefined;
159
+ }
160
+ const map = new Map();
161
+ for (const c of kitCaches ?? []) {
162
+ map.set(c.name, c);
163
+ }
164
+ for (const c of userCaches ?? []) {
165
+ map.set(c.name, c);
166
+ }
167
+ return [...map.values()];
168
+ }
169
+ function mergeMounts(kitMounts, userMounts) {
170
+ if (!kitMounts && !userMounts) {
171
+ return undefined;
172
+ }
173
+ return [...(kitMounts ?? []), ...(userMounts ?? [])];
87
174
  }
@@ -1,5 +1,5 @@
1
1
  import { cp } from 'node:fs/promises';
2
- import { basename, dirname, resolve } from 'node:path';
2
+ import { dirname, resolve } from 'node:path';
3
3
  import { Workspace } from '../engine/index.js';
4
4
  import { StateManager } from './state.js';
5
5
  /**
@@ -47,10 +47,8 @@ export class PipelineRunner {
47
47
  const { workspace: workspaceName, force } = options ?? {};
48
48
  const config = await this.loader.load(pipelineFilePath);
49
49
  const pipelineRoot = dirname(resolve(pipelineFilePath));
50
- // Workspace ID priority: CLI arg > config.name > filename
51
- const workspaceId = workspaceName
52
- ?? config.name
53
- ?? basename(pipelineFilePath, '.json').replaceAll(/[^\w-]/g, '-');
50
+ // Workspace ID priority: CLI arg > pipeline id
51
+ const workspaceId = workspaceName ?? config.id;
54
52
  let workspace;
55
53
  try {
56
54
  workspace = await Workspace.open(this.workdirRoot, workspaceId);
@@ -63,8 +61,9 @@ export class PipelineRunner {
63
61
  const state = new StateManager(workspace.root);
64
62
  await state.load();
65
63
  const stepArtifacts = new Map();
66
- this.reporter.state(workspace.id, 'PIPELINE_START');
64
+ this.reporter.state(workspace.id, 'PIPELINE_START', undefined, { pipelineName: config.name ?? config.id });
67
65
  for (const step of config.steps) {
66
+ const stepRef = { id: step.id, displayName: step.name ?? step.id };
68
67
  const inputArtifactIds = step.inputs
69
68
  ?.map(i => stepArtifacts.get(i.step))
70
69
  .filter((id) => id !== undefined);
@@ -80,10 +79,10 @@ export class PipelineRunner {
80
79
  mounts: resolvedMounts
81
80
  });
82
81
  const skipCache = force === true || (Array.isArray(force) && force.includes(step.id));
83
- if (!skipCache && await this.tryUseCache({ workspace, state, step, currentFingerprint, stepArtifacts })) {
82
+ if (!skipCache && await this.tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepArtifacts })) {
84
83
  continue;
85
84
  }
86
- this.reporter.state(workspace.id, 'STEP_STARTING', step.id);
85
+ this.reporter.state(workspace.id, 'STEP_STARTING', stepRef);
87
86
  const artifactId = workspace.generateArtifactId();
88
87
  const stagingPath = await workspace.prepareArtifact(artifactId);
89
88
  await this.prepareStagingWithInputs(workspace, step, stagingPath, stepArtifacts);
@@ -106,33 +105,33 @@ export class PipelineRunner {
106
105
  network: step.allowNetwork ? 'bridge' : 'none',
107
106
  timeoutSec: step.timeoutSec
108
107
  }, ({ stream, line }) => {
109
- this.reporter.log(workspace.id, step.id, stream, line);
108
+ this.reporter.log(workspace.id, stepRef, stream, line);
110
109
  });
111
- this.reporter.result(workspace.id, step.id, result);
110
+ this.reporter.result(workspace.id, stepRef, result);
112
111
  if (result.exitCode === 0 || step.allowFailure) {
113
112
  await workspace.commitArtifact(artifactId);
114
113
  stepArtifacts.set(step.id, artifactId);
115
114
  state.setStep(step.id, artifactId, currentFingerprint);
116
115
  await state.save();
117
- this.reporter.state(workspace.id, 'STEP_FINISHED', step.id, { artifactId });
116
+ this.reporter.state(workspace.id, 'STEP_FINISHED', stepRef, { artifactId });
118
117
  }
119
118
  else {
120
119
  await workspace.discardArtifact(artifactId);
121
- this.reporter.state(workspace.id, 'STEP_FAILED', step.id, { exitCode: result.exitCode });
120
+ this.reporter.state(workspace.id, 'STEP_FAILED', stepRef, { exitCode: result.exitCode });
122
121
  this.reporter.state(workspace.id, 'PIPELINE_FAILED');
123
122
  throw new Error(`Step ${step.id} failed with exit code ${result.exitCode}`);
124
123
  }
125
124
  }
126
125
  this.reporter.state(workspace.id, 'PIPELINE_FINISHED');
127
126
  }
128
- async tryUseCache({ workspace, state, step, currentFingerprint, stepArtifacts }) {
127
+ async tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepArtifacts }) {
129
128
  const cached = state.getStep(step.id);
130
129
  if (cached?.fingerprint === currentFingerprint) {
131
130
  try {
132
131
  const artifacts = await workspace.listArtifacts();
133
132
  if (artifacts.includes(cached.artifactId)) {
134
133
  stepArtifacts.set(step.id, cached.artifactId);
135
- this.reporter.state(workspace.id, 'STEP_SKIPPED', step.id, { artifactId: cached.artifactId, reason: 'cached' });
134
+ this.reporter.state(workspace.id, 'STEP_SKIPPED', stepRef, { artifactId: cached.artifactId, reason: 'cached' });
136
135
  return true;
137
136
  }
138
137
  }
@@ -7,14 +7,15 @@ import ora from 'ora';
7
7
  */
8
8
  export class ConsoleReporter {
9
9
  logger = pino({ level: 'info' });
10
- state(workspaceId, event, stepId, meta) {
11
- this.logger.info({ workspaceId, event, stepId, ...meta });
10
+ state(workspaceId, event, step, meta) {
11
+ const stepName = step?.displayName === step?.id ? undefined : step?.displayName;
12
+ this.logger.info({ workspaceId, event, stepId: step?.id, stepName, ...meta });
12
13
  }
13
- log(workspaceId, stepId, stream, line) {
14
- this.logger.info({ workspaceId, stepId, stream, line });
14
+ log(workspaceId, step, stream, line) {
15
+ this.logger.info({ workspaceId, stepId: step.id, stream, line });
15
16
  }
16
- result(workspaceId, stepId, result) {
17
- this.logger.info({ workspaceId, stepId, result });
17
+ result(workspaceId, step, result) {
18
+ this.logger.info({ workspaceId, stepId: step.id, result });
18
19
  }
19
20
  }
20
21
  /**
@@ -24,42 +25,42 @@ export class ConsoleReporter {
24
25
  export class InteractiveReporter {
25
26
  spinner;
26
27
  stepSpinners = new Map();
27
- state(workspaceId, event, stepId, meta) {
28
+ state(workspaceId, event, step, meta) {
28
29
  if (event === 'PIPELINE_START') {
29
- console.log(chalk.bold(`\n▶ Pipeline: ${chalk.cyan(workspaceId)}\n`));
30
+ const displayName = meta?.pipelineName ?? workspaceId;
31
+ console.log(chalk.bold(`\n▶ Pipeline: ${chalk.cyan(displayName)}\n`));
30
32
  }
31
- if (event === 'STEP_STARTING' && stepId) {
32
- const spinner = ora({ text: stepId, prefixText: ' ' }).start();
33
- this.stepSpinners.set(stepId, spinner);
33
+ if (event === 'STEP_STARTING' && step) {
34
+ const spinner = ora({ text: step.displayName, prefixText: ' ' }).start();
35
+ this.stepSpinners.set(step.id, spinner);
34
36
  }
35
- if (event === 'STEP_SKIPPED' && stepId) {
36
- const spinner = this.stepSpinners.get(stepId);
37
+ if (event === 'STEP_SKIPPED' && step) {
38
+ const spinner = this.stepSpinners.get(step.id);
37
39
  if (spinner) {
38
- spinner.stopAndPersist({ symbol: chalk.gray('⊙'), text: chalk.gray(`${stepId} (cached)`) });
39
- this.stepSpinners.delete(stepId);
40
+ spinner.stopAndPersist({ symbol: chalk.gray('⊙'), text: chalk.gray(`${step.displayName} (cached)`) });
41
+ this.stepSpinners.delete(step.id);
40
42
  }
41
43
  else {
42
- // Step was skipped before spinner was created
43
- console.log(` ${chalk.gray('⊙')} ${chalk.gray(`${stepId} (cached)`)}`);
44
+ console.log(` ${chalk.gray('⊙')} ${chalk.gray(`${step.displayName} (cached)`)}`);
44
45
  }
45
46
  }
46
- if (event === 'STEP_FINISHED' && stepId) {
47
- const spinner = this.stepSpinners.get(stepId);
47
+ if (event === 'STEP_FINISHED' && step) {
48
+ const spinner = this.stepSpinners.get(step.id);
48
49
  if (spinner) {
49
- spinner.stopAndPersist({ symbol: chalk.green('✓'), text: chalk.green(stepId) });
50
- this.stepSpinners.delete(stepId);
50
+ spinner.stopAndPersist({ symbol: chalk.green('✓'), text: chalk.green(step.displayName) });
51
+ this.stepSpinners.delete(step.id);
51
52
  }
52
53
  }
53
- if (event === 'STEP_FAILED' && stepId) {
54
- const spinner = this.stepSpinners.get(stepId);
54
+ if (event === 'STEP_FAILED' && step) {
55
+ const spinner = this.stepSpinners.get(step.id);
55
56
  const exitCode = meta?.exitCode;
56
57
  if (spinner) {
57
58
  const exitInfo = exitCode === undefined ? '' : ` (exit ${exitCode})`;
58
59
  spinner.stopAndPersist({
59
60
  symbol: chalk.red('✗'),
60
- text: chalk.red(`${stepId}${exitInfo}`)
61
+ text: chalk.red(`${step.displayName}${exitInfo}`)
61
62
  });
62
- this.stepSpinners.delete(stepId);
63
+ this.stepSpinners.delete(step.id);
63
64
  }
64
65
  }
65
66
  if (event === 'PIPELINE_FINISHED') {
@@ -69,10 +70,10 @@ export class InteractiveReporter {
69
70
  console.log(chalk.bold.red('\n✗ Pipeline failed\n'));
70
71
  }
71
72
  }
72
- log(_workspaceId, _stepId, _stream, _line) {
73
+ log(_workspaceId, _step, _stream, _line) {
73
74
  // Suppress logs in interactive mode
74
75
  }
75
- result(_workspaceId, _stepId, _result) {
76
+ result(_workspaceId, _step, _result) {
76
77
  // Results shown via state updates
77
78
  }
78
79
  }
package/dist/cli/types.js CHANGED
@@ -1 +1,3 @@
1
- export {};
1
+ export function isKitStep(step) {
2
+ return 'uses' in step && typeof step.uses === 'string';
3
+ }
@@ -0,0 +1,19 @@
1
+ export const bashKit = {
2
+ name: 'bash',
3
+ resolve(params) {
4
+ const run = params.run;
5
+ if (!run || typeof run !== 'string') {
6
+ throw new Error('Kit "bash": "run" parameter is required');
7
+ }
8
+ const image = params.image ?? 'alpine:3.20';
9
+ const src = params.src;
10
+ const output = {
11
+ image,
12
+ cmd: ['sh', '-c', run]
13
+ };
14
+ if (src) {
15
+ output.mounts = [{ host: src, container: '/app' }];
16
+ }
17
+ return output;
18
+ }
19
+ };
@@ -0,0 +1,19 @@
1
+ export const bashKit = {
2
+ name: 'bash',
3
+ resolve(params) {
4
+ const run = params.run;
5
+ if (!run || typeof run !== 'string') {
6
+ throw new Error('Kit "bash": "run" parameter is required');
7
+ }
8
+ const image = params.image ?? 'alpine:3.20';
9
+ const src = params.src;
10
+ const output = {
11
+ image,
12
+ cmd: ['sh', '-c', run]
13
+ };
14
+ if (src) {
15
+ output.mounts = [{ host: src, container: '/app' }];
16
+ }
17
+ return output;
18
+ }
19
+ };
@@ -0,0 +1,56 @@
1
+ const cacheMap = {
2
+ npm: { name: 'npm-cache', path: '/root/.npm' },
3
+ pnpm: { name: 'pnpm-store', path: '/root/.local/share/pnpm/store' },
4
+ yarn: { name: 'yarn-cache', path: '/usr/local/share/.cache/yarn' }
5
+ };
6
+ function buildInstallCommand(packageManager) {
7
+ switch (packageManager) {
8
+ case 'npm': {
9
+ return 'cd /tmp && cp /app/package*.json . && npm install --no-audit --no-fund 2>&1';
10
+ }
11
+ case 'pnpm': {
12
+ return 'cd /tmp && cp /app/package.json . && cp /app/pnpm-lock.yaml . 2>/dev/null; pnpm install --no-frozen-lockfile 2>&1';
13
+ }
14
+ case 'yarn': {
15
+ return 'cd /tmp && cp /app/package.json . && cp /app/yarn.lock . 2>/dev/null; yarn install 2>&1';
16
+ }
17
+ default: {
18
+ throw new Error(`Kit "node": unsupported packageManager "${packageManager}"`);
19
+ }
20
+ }
21
+ }
22
+ export const nodeKit = {
23
+ name: 'node',
24
+ resolve(params) {
25
+ const version = params.version ?? '24';
26
+ const packageManager = params.packageManager ?? 'npm';
27
+ const script = params.script;
28
+ const install = params.install ?? true;
29
+ const variant = params.variant ?? 'alpine';
30
+ const src = params.src;
31
+ if (!script || typeof script !== 'string') {
32
+ throw new Error('Kit "node": "script" parameter is required');
33
+ }
34
+ const image = `node:${version}-${variant}`;
35
+ const parts = [];
36
+ if (install) {
37
+ parts.push(buildInstallCommand(packageManager));
38
+ }
39
+ const nodePathPrefix = install ? 'NODE_PATH=/tmp/node_modules ' : '';
40
+ parts.push(`${nodePathPrefix}node /app/${script}`);
41
+ const cache = cacheMap[packageManager];
42
+ if (!cache) {
43
+ throw new Error(`Kit "node": unsupported packageManager "${packageManager}"`);
44
+ }
45
+ const output = {
46
+ image,
47
+ cmd: ['sh', '-c', parts.join(' && ')],
48
+ caches: [cache],
49
+ allowNetwork: true
50
+ };
51
+ if (src) {
52
+ output.mounts = [{ host: src, container: '/app' }];
53
+ }
54
+ return output;
55
+ }
56
+ };
@@ -0,0 +1,51 @@
1
+ const cacheMap = {
2
+ pip: { name: 'pip-cache', path: '/root/.cache/pip' },
3
+ uv: { name: 'uv-cache', path: '/root/.cache/uv' }
4
+ };
5
+ function buildInstallCommand(packageManager) {
6
+ switch (packageManager) {
7
+ case 'pip': {
8
+ return 'pip install --quiet -r /app/requirements.txt 2>&1';
9
+ }
10
+ case 'uv': {
11
+ return 'uv pip install --quiet -r /app/requirements.txt 2>&1';
12
+ }
13
+ default: {
14
+ throw new Error(`Kit "python": unsupported packageManager "${packageManager}"`);
15
+ }
16
+ }
17
+ }
18
+ export const pythonKit = {
19
+ name: 'python',
20
+ resolve(params) {
21
+ const version = params.version ?? '3.12';
22
+ const packageManager = params.packageManager ?? 'pip';
23
+ const script = params.script;
24
+ const install = params.install ?? true;
25
+ const variant = params.variant ?? 'slim';
26
+ const src = params.src;
27
+ if (!script || typeof script !== 'string') {
28
+ throw new Error('Kit "python": "script" parameter is required');
29
+ }
30
+ const image = `python:${version}-${variant}`;
31
+ const cache = cacheMap[packageManager];
32
+ if (!cache) {
33
+ throw new Error(`Kit "python": unsupported packageManager "${packageManager}"`);
34
+ }
35
+ const parts = [];
36
+ if (install) {
37
+ parts.push(buildInstallCommand(packageManager));
38
+ }
39
+ parts.push(`python /app/${script}`);
40
+ const output = {
41
+ image,
42
+ cmd: ['sh', '-c', parts.join(' && ')],
43
+ caches: [cache],
44
+ allowNetwork: true
45
+ };
46
+ if (src) {
47
+ output.mounts = [{ host: src, container: '/app' }];
48
+ }
49
+ return output;
50
+ }
51
+ };
@@ -0,0 +1,15 @@
1
+ import { bashKit } from './builtin/bash.js';
2
+ import { nodeKit } from './builtin/node.js';
3
+ import { pythonKit } from './builtin/python.js';
4
+ const kits = new Map([
5
+ [bashKit.name, bashKit],
6
+ [nodeKit.name, nodeKit],
7
+ [pythonKit.name, pythonKit]
8
+ ]);
9
+ export function getKit(name) {
10
+ const kit = kits.get(name);
11
+ if (!kit) {
12
+ throw new Error(`Unknown kit: "${name}". Available kits: ${[...kits.keys()].join(', ')}`);
13
+ }
14
+ return kit;
15
+ }
@@ -0,0 +1,56 @@
1
+ const cacheMap = {
2
+ npm: { name: 'npm-cache', path: '/root/.npm' },
3
+ pnpm: { name: 'pnpm-store', path: '/root/.local/share/pnpm/store' },
4
+ yarn: { name: 'yarn-cache', path: '/usr/local/share/.cache/yarn' }
5
+ };
6
+ function buildInstallCommand(packageManager) {
7
+ switch (packageManager) {
8
+ case 'npm': {
9
+ return 'cd /tmp && cp /app/package*.json . && npm install --no-audit --no-fund 2>&1';
10
+ }
11
+ case 'pnpm': {
12
+ return 'cd /tmp && cp /app/package.json . && cp /app/pnpm-lock.yaml . 2>/dev/null; pnpm install --no-frozen-lockfile 2>&1';
13
+ }
14
+ case 'yarn': {
15
+ return 'cd /tmp && cp /app/package.json . && cp /app/yarn.lock . 2>/dev/null; yarn install 2>&1';
16
+ }
17
+ default: {
18
+ throw new Error(`Kit "node": unsupported packageManager "${packageManager}"`);
19
+ }
20
+ }
21
+ }
22
+ export const nodeKit = {
23
+ name: 'node',
24
+ resolve(params) {
25
+ const version = params.version ?? '22';
26
+ const packageManager = params.packageManager ?? 'npm';
27
+ const script = params.script;
28
+ const install = params.install ?? true;
29
+ const variant = params.variant ?? 'alpine';
30
+ const src = params.src;
31
+ if (!script || typeof script !== 'string') {
32
+ throw new Error('Kit "node": "script" parameter is required');
33
+ }
34
+ const image = `node:${version}-${variant}`;
35
+ const parts = [];
36
+ if (install) {
37
+ parts.push(buildInstallCommand(packageManager));
38
+ }
39
+ const nodePathPrefix = install ? 'NODE_PATH=/tmp/node_modules ' : '';
40
+ parts.push(`${nodePathPrefix}node /app/${script}`);
41
+ const cache = cacheMap[packageManager];
42
+ if (!cache) {
43
+ throw new Error(`Kit "node": unsupported packageManager "${packageManager}"`);
44
+ }
45
+ const output = {
46
+ image,
47
+ cmd: ['sh', '-c', parts.join(' && ')],
48
+ caches: [cache],
49
+ allowNetwork: true
50
+ };
51
+ if (src) {
52
+ output.mounts = [{ host: src, container: '/app' }];
53
+ }
54
+ return output;
55
+ }
56
+ };
@@ -0,0 +1,51 @@
1
+ const cacheMap = {
2
+ pip: { name: 'pip-cache', path: '/root/.cache/pip' },
3
+ uv: { name: 'uv-cache', path: '/root/.cache/uv' }
4
+ };
5
+ function buildInstallCommand(packageManager) {
6
+ switch (packageManager) {
7
+ case 'pip': {
8
+ return 'pip install --quiet /app/ 2>&1';
9
+ }
10
+ case 'uv': {
11
+ return 'uv pip install --quiet /app/ 2>&1';
12
+ }
13
+ default: {
14
+ throw new Error(`Kit "python": unsupported packageManager "${packageManager}"`);
15
+ }
16
+ }
17
+ }
18
+ export const pythonKit = {
19
+ name: 'python',
20
+ resolve(params) {
21
+ const version = params.version ?? '3.12';
22
+ const packageManager = params.packageManager ?? 'pip';
23
+ const script = params.script;
24
+ const install = params.install ?? true;
25
+ const variant = params.variant ?? 'slim';
26
+ const src = params.src;
27
+ if (!script || typeof script !== 'string') {
28
+ throw new Error('Kit "python": "script" parameter is required');
29
+ }
30
+ const image = `python:${version}-${variant}`;
31
+ const cache = cacheMap[packageManager];
32
+ if (!cache) {
33
+ throw new Error(`Kit "python": unsupported packageManager "${packageManager}"`);
34
+ }
35
+ const parts = [];
36
+ if (install) {
37
+ parts.push(buildInstallCommand(packageManager));
38
+ }
39
+ parts.push(`python /app/${script}`);
40
+ const output = {
41
+ image,
42
+ cmd: ['sh', '-c', parts.join(' && ')],
43
+ caches: [cache],
44
+ allowNetwork: true
45
+ };
46
+ if (src) {
47
+ output.mounts = [{ host: src, container: '/app' }];
48
+ }
49
+ return output;
50
+ }
51
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,10 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared pipeline domain types.
3
+ //
4
+ // These types are used by both the CLI runner and the kit system, and will
5
+ // also be consumed by future orchestrators (remote API, programmatic usage).
6
+ // ---------------------------------------------------------------------------
7
+ /** Type guard: returns true when the step uses a kit (`uses` field present). */
8
+ export function isKitStep(step) {
9
+ return 'uses' in step && typeof step.uses === 'string';
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livingdata/pipex",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Execution engine for containerized pipeline steps",
5
5
  "author": "Jérôme Desboeufs <jerome@livingdata.co>",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "access": "public"
21
21
  },
22
22
  "scripts": {
23
- "start": "tsx src/cli/index.ts",
23
+ "cli": "tsx src/cli/index.ts",
24
24
  "lint": "xo",
25
25
  "lint:fix": "xo --fix",
26
26
  "build": "tsc"
@@ -30,10 +30,13 @@
30
30
  "commander": "^14.0.3",
31
31
  "dotenv": "^17.2.3",
32
32
  "execa": "^9.6.1",
33
+ "lodash-es": "^4.17.23",
33
34
  "ora": "^9.3.0",
34
- "pino": "^10.3.0"
35
+ "pino": "^10.3.0",
36
+ "yaml": "^2.8.2"
35
37
  },
36
38
  "devDependencies": {
39
+ "@types/lodash-es": "^4.17.12",
37
40
  "@types/node": "^25.2.0",
38
41
  "tsx": "^4.21.0",
39
42
  "xo": "^1.2.3"