@livingdata/pipex 0.0.3 → 0.0.5

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,51 @@ 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
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
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
27
-
28
- # With workspace name (enables caching)
29
- npm start -- run pipeline.example.json --workspace my-build
31
+ pipex run pipeline.yaml
30
32
 
31
33
  # JSON mode (for CI/CD)
32
- npm start -- run pipeline.example.json --json
34
+ pipex run pipeline.yaml --json
33
35
 
34
36
  # Custom workdir
35
- npm start -- run pipeline.example.json --workdir /tmp/builds
37
+ pipex run pipeline.yaml --workdir /tmp/builds
36
38
  ```
37
39
 
38
40
  ### Managing workspaces
39
41
 
40
42
  ```bash
41
43
  # List workspaces (with artifact/cache counts)
42
- npm start -- list
43
- npm start -- ls --json
44
+ pipex list
45
+ pipex ls --json
44
46
 
45
47
  # Remove specific workspaces
46
- npm start -- rm my-build other-build
48
+ pipex rm my-build other-build
47
49
 
48
50
  # 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
51
+ pipex clean
61
52
  ```
62
53
 
63
54
  ### Commands
@@ -85,34 +76,138 @@ npx . list
85
76
 
86
77
  ## Pipeline Format
87
78
 
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
- }
79
+ Pipeline files can be written in **YAML** (`.yaml` / `.yml`) or **JSON** (`.json`). YAML is recommended for readability; JSON is still fully supported.
80
+
81
+ 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.
82
+
83
+ ### Pipeline and Step Identity
84
+
85
+ Both pipelines and steps support an `id`/`name` duality:
86
+
87
+ - **`id`** — Machine identifier (alphanum, dash, underscore). Used for caching, state, artifacts.
88
+ - **`name`** — Human-readable label (free-form text). Used for display.
89
+ - 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.
90
+
91
+ ```yaml
92
+ # Pipeline with both id and name
93
+ id: data-pipeline
94
+ name: Data Processing Pipeline
95
+ steps:
96
+ # Step with only id (current style, still works)
97
+ - id: download
98
+ image: alpine:3.19
99
+ cmd: [sh, -c, "echo hello > /output/hello.txt"]
100
+
101
+ # Step with only name (id auto-derived to "build-assets")
102
+ - name: Build Assets
103
+ image: node:22-alpine
104
+ cmd: [sh, -c, "echo done > /output/result.txt"]
105
+
106
+ # Step with both
107
+ - id: deploy
108
+ name: Deploy to Staging
109
+ image: alpine:3.19
110
+ cmd: [echo, deployed]
111
+ ```
112
+
113
+ ### Kit Steps
114
+
115
+ 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:
116
+
117
+ ```yaml
118
+ name: my-pipeline
119
+ steps:
120
+ - id: build
121
+ uses: node
122
+ with: { script: build.js, src: src/app }
123
+ - id: analyze
124
+ uses: python
125
+ with: { script: analyze.py, src: scripts }
126
+ - id: extract
127
+ uses: shell
128
+ with: { packages: [unzip], run: "unzip /input/build/archive.zip -d /output/" }
129
+ inputs: [{ step: build }]
130
+ ```
131
+
132
+ `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.
133
+
134
+ #### Available Kits
135
+
136
+ **`node`** -- Run a Node.js script with automatic dependency installation.
137
+
138
+ | Parameter | Default | Description |
139
+ |-----------|---------|-------------|
140
+ | `script` | *(required)* | Script to run (relative to `/app`) |
141
+ | `src` | -- | Host directory to mount at `/app` |
142
+ | `version` | `"24"` | Node.js version |
143
+ | `packageManager` | `"npm"` | `"npm"`, `"pnpm"`, or `"yarn"` |
144
+ | `install` | `true` | Run package install before script |
145
+ | `variant` | `"alpine"` | Image variant |
146
+
147
+ **`python`** -- Run a Python script with automatic dependency installation from `requirements.txt`.
148
+
149
+ | Parameter | Default | Description |
150
+ |-----------|---------|-------------|
151
+ | `script` | *(required)* | Script to run (relative to `/app`) |
152
+ | `src` | -- | Host directory to mount at `/app` |
153
+ | `version` | `"3.12"` | Python version |
154
+ | `packageManager` | `"pip"` | `"pip"` or `"uv"` |
155
+ | `install` | `true` | Run dependency install before script |
156
+ | `variant` | `"slim"` | Image variant |
157
+
158
+ **`shell`** -- Run a shell command in a container, with optional apt package installation.
159
+
160
+ | Parameter | Default | Description |
161
+ |-----------|---------|-------------|
162
+ | `run` | *(required)* | Shell command to execute |
163
+ | `packages` | -- | Apt packages to install before running |
164
+ | `src` | -- | Host directory to mount at `/app` |
165
+ | `image` | `"alpine:3.20"` | Docker image (defaults to `"debian:bookworm-slim"` when `packages` is set) |
166
+
167
+ When `packages` is provided, the kit automatically switches to a Debian image, enables network access, and provides an `apt-cache` cache. Without packages, it runs on a minimal Alpine image with no network.
168
+
169
+ ```yaml
170
+ # Simple command (alpine, no network)
171
+ - id: list-files
172
+ uses: shell
173
+ with:
174
+ run: ls -lhR /input/data/
175
+
176
+ # With system packages (debian, network + apt cache)
177
+ - id: extract
178
+ uses: shell
179
+ with:
180
+ packages: [unzip, jq]
181
+ run: unzip /input/download/data.zip -d /output/
182
+ inputs: [{ step: download }]
183
+ ```
184
+
185
+ ### Raw Steps
186
+
187
+ For full control, define `image` and `cmd` directly:
188
+
189
+ ```yaml
190
+ name: my-pipeline
191
+ steps:
192
+ - id: download
193
+ image: alpine:3.19
194
+ cmd: [sh, -c, "echo hello > /output/hello.txt"]
195
+ - id: process
196
+ image: alpine:3.19
197
+ cmd: [cat, /input/download/hello.txt]
198
+ inputs: [{ step: download }]
107
199
  ```
108
200
 
109
201
  ### Step Options
110
202
 
111
203
  | Field | Type | Description |
112
204
  |-------|------|-------------|
113
- | `id` | string | Step identifier (required) |
114
- | `image` | string | Docker image (required) |
115
- | `cmd` | string[] | Command to execute (required) |
205
+ | `id` | string | Step identifier (at least one of `id`/`name` required) |
206
+ | `name` | string | Human-readable display name |
207
+ | `image` | string | Docker image (required for raw steps) |
208
+ | `cmd` | string[] | Command to execute (required for raw steps) |
209
+ | `uses` | string | Kit name (required for kit steps) |
210
+ | `with` | object | Kit parameters |
116
211
  | `inputs` | InputSpec[] | Previous steps to mount as read-only |
117
212
  | `env` | Record<string, string> | Environment variables |
118
213
  | `outputPath` | string | Output mount point (default: `/output`) |
@@ -126,11 +221,11 @@ Minimal example:
126
221
 
127
222
  Mount previous steps as read-only:
128
223
 
129
- ```json
130
- "inputs": [
131
- {"step": "step1"},
132
- {"step": "step2", "copyToOutput": true}
133
- ]
224
+ ```yaml
225
+ inputs:
226
+ - step: step1
227
+ - step: step2
228
+ copyToOutput: true
134
229
  ```
135
230
 
136
231
  - Mounted under `/input/{stepName}/`
@@ -140,11 +235,12 @@ Mount previous steps as read-only:
140
235
 
141
236
  Mount host directories into containers as **read-only**:
142
237
 
143
- ```json
144
- "mounts": [
145
- {"host": "src/app", "container": "/app"},
146
- {"host": "config", "container": "/config"}
147
- ]
238
+ ```yaml
239
+ mounts:
240
+ - host: src/app
241
+ container: /app
242
+ - host: config
243
+ container: /config
148
244
  ```
149
245
 
150
246
  - `host` must be a **relative** path (resolved from the pipeline file's directory)
@@ -152,17 +248,18 @@ Mount host directories into containers as **read-only**:
152
248
  - Neither path can contain `..`
153
249
  - Always mounted read-only -- containers cannot modify host files
154
250
 
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.
251
+ 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
252
 
157
253
  ### Caches
158
254
 
159
255
  Persistent read-write directories shared across steps and executions:
160
256
 
161
- ```json
162
- "caches": [
163
- {"name": "pnpm-store", "path": "/root/.local/share/pnpm/store"},
164
- {"name": "build-cache", "path": "/tmp/cache"}
165
- ]
257
+ ```yaml
258
+ caches:
259
+ - name: pnpm-store
260
+ path: /root/.local/share/pnpm/store
261
+ - name: build-cache
262
+ path: /tmp/cache
166
263
  ```
167
264
 
168
265
  - **Persistent**: Caches survive across pipeline executions
@@ -176,37 +273,53 @@ Common use cases:
176
273
 
177
274
  **Note**: Caches are workspace-scoped (not global). Different workspaces have isolated caches.
178
275
 
179
- ## Example
276
+ ## Examples
180
277
 
181
- The `example/` directory contains a multi-language pipeline that chains Node.js and Python steps:
278
+ ### Geodata Processing
182
279
 
280
+ The `examples/geodata/` pipeline downloads a shapefile archive, extracts it, and produces a CSV inventory — using the `debian` and `bash` kits:
281
+
282
+ ```
283
+ examples/geodata/
284
+ └── pipeline.yaml
183
285
  ```
184
- example/
185
- ├── pipeline.json
286
+
287
+ Steps: `download` → `extract` → `list-files` / `build-csv`
288
+
289
+ ```bash
290
+ pipex run examples/geodata/pipeline.yaml
291
+ ```
292
+
293
+ ### Multi-Language
294
+
295
+ The `examples/multi-language/` pipeline chains Node.js and Python steps using kits:
296
+
297
+ ```
298
+ examples/multi-language/
299
+ ├── pipeline.yaml
186
300
  └── scripts/
187
- ├── nodejs/ # lodash-based data analysis
301
+ ├── nodejs/ # lodash-based data analysis
188
302
  │ ├── package.json
189
303
  │ ├── analyze.js
190
304
  │ └── transform.js
191
- └── python/ # pyyaml-based enrichment
305
+ └── python/ # pyyaml-based enrichment
192
306
  ├── pyproject.toml
307
+ ├── requirements.txt
193
308
  ├── analyze.py
194
309
  └── transform.py
195
310
  ```
196
311
 
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`.
312
+ Steps: `node-analyze` → `node-transform` → `python-analyze` → `python-transform`
198
313
 
199
314
  ```bash
200
- npm start -- run example/pipeline.json --workspace example-test
315
+ pipex run examples/multi-language/pipeline.yaml
201
316
  ```
202
317
 
203
318
  ## Caching & Workspaces
204
319
 
205
- Workspaces enable caching across runs. Name is determined by:
320
+ Workspaces enable caching across runs. The workspace ID is determined by:
206
321
  1. CLI flag `--workspace` (highest priority)
207
- 2. Config `"name"` field
208
- 3. Filename (e.g., `build.json` → `build`)
209
- 4. Auto-generated timestamp
322
+ 2. Pipeline `id` (explicit or derived from `name`)
210
323
 
211
324
  **Cache behavior**: Steps are skipped if image, cmd, env, inputs, and mounts haven't changed. See code documentation for details.
212
325
 
@@ -232,10 +345,10 @@ newgrp docker
232
345
  Clean old workspaces:
233
346
 
234
347
  ```bash
235
- npm start -- list
236
- npm start -- rm old-workspace-id
348
+ pipex list
349
+ pipex rm old-workspace-id
237
350
  # Or remove all at once
238
- npm start -- clean
351
+ pipex clean
239
352
  ```
240
353
 
241
354
  ### Cached step with missing artifact
@@ -249,9 +362,25 @@ rm $PIPEX_WORKDIR/{workspace-id}/state.json
249
362
  ## Development
250
363
 
251
364
  ```bash
252
- npm run build
253
- npm run lint
254
- npm run lint:fix
365
+ git clone https://github.com/livingdata-co/pipex.git
366
+ cd pipex
367
+ npm install
368
+ cp .env.example .env
369
+ ```
370
+
371
+ Run the CLI without building (via tsx):
372
+
373
+ ```bash
374
+ npm run cli -- run pipeline.yaml
375
+ npm run cli -- list
376
+ ```
377
+
378
+ Other commands:
379
+
380
+ ```bash
381
+ npm run build # Compile TypeScript (tsc → dist/)
382
+ npm run lint # Lint with XO
383
+ npm run lint:fix # Auto-fix lint issues
255
384
  ```
256
385
 
257
386
  ## Architecture
@@ -259,3 +388,4 @@ npm run lint:fix
259
388
  For implementation details, see code documentation in:
260
389
  - `src/engine/` - Low-level container execution (workspace, executor)
261
390
  - `src/cli/` - Pipeline orchestration (runner, loader, state)
391
+ - `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,60 @@
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
+ return this.parse(content, filePath);
11
+ }
12
+ parse(content, filePath) {
13
+ const input = parsePipelineFile(content, filePath);
14
+ if (!input.id && !input.name) {
15
+ throw new Error('Invalid pipeline: at least one of "id" or "name" must be defined');
16
+ }
17
+ const pipelineId = input.id ?? slugify(input.name);
18
+ if (!Array.isArray(input.steps) || input.steps.length === 0) {
7
19
  throw new Error('Invalid pipeline: steps must be a non-empty array');
8
20
  }
9
- for (const step of config.steps) {
21
+ const steps = input.steps.map(step => this.resolveStep(step));
22
+ for (const step of steps) {
10
23
  this.validateStep(step);
11
24
  }
12
- return config;
25
+ this.validateUniqueStepIds(steps);
26
+ return { id: pipelineId, name: input.name, steps };
13
27
  }
14
- validateStep(step) {
15
- if (!step.id || typeof step.id !== 'string') {
16
- throw new Error('Invalid step: id is required');
28
+ resolveStep(step) {
29
+ if (!step.id && !step.name) {
30
+ throw new Error('Invalid step: at least one of "id" or "name" must be defined');
17
31
  }
32
+ const id = step.id ?? slugify(step.name);
33
+ const { name } = step;
34
+ if (!isKitStep(step)) {
35
+ return { ...step, id, name };
36
+ }
37
+ return this.resolveKitStep(step, id, name);
38
+ }
39
+ resolveKitStep(step, id, name) {
40
+ const kit = getKit(step.uses);
41
+ const kitOutput = kit.resolve(step.with ?? {});
42
+ return {
43
+ id,
44
+ name,
45
+ image: kitOutput.image,
46
+ cmd: kitOutput.cmd,
47
+ env: mergeEnv(kitOutput.env, step.env),
48
+ inputs: step.inputs,
49
+ outputPath: step.outputPath,
50
+ caches: mergeCaches(kitOutput.caches, step.caches),
51
+ mounts: mergeMounts(kitOutput.mounts, step.mounts),
52
+ timeoutSec: step.timeoutSec,
53
+ allowFailure: step.allowFailure,
54
+ allowNetwork: step.allowNetwork ?? kitOutput.allowNetwork
55
+ };
56
+ }
57
+ validateStep(step) {
18
58
  this.validateIdentifier(step.id, 'step id');
19
59
  if (!step.image || typeof step.image !== 'string') {
20
60
  throw new Error(`Invalid step ${step.id}: image is required`);
@@ -84,4 +124,54 @@ export class PipelineLoader {
84
124
  throw new Error(`Invalid ${context}: '${id}' cannot contain '..'`);
85
125
  }
86
126
  }
127
+ validateUniqueStepIds(steps) {
128
+ const seen = new Set();
129
+ for (const step of steps) {
130
+ if (seen.has(step.id)) {
131
+ throw new Error(`Duplicate step id: '${step.id}'`);
132
+ }
133
+ seen.add(step.id);
134
+ }
135
+ }
136
+ }
137
+ /** Convert a free-form name into a valid identifier. */
138
+ export function slugify(name) {
139
+ return deburr(name)
140
+ .toLowerCase()
141
+ .replaceAll(/[^\w-]/g, '-')
142
+ .replaceAll(/-{2,}/g, '-')
143
+ .replace(/^-/, '')
144
+ .replace(/-$/, '');
145
+ }
146
+ export function parsePipelineFile(content, filePath) {
147
+ const ext = extname(filePath).toLowerCase();
148
+ if (ext === '.yaml' || ext === '.yml') {
149
+ return parseYaml(content);
150
+ }
151
+ return JSON.parse(content);
152
+ }
153
+ export function mergeEnv(kitEnv, userEnv) {
154
+ if (!kitEnv && !userEnv) {
155
+ return undefined;
156
+ }
157
+ return { ...kitEnv, ...userEnv };
158
+ }
159
+ export function mergeCaches(kitCaches, userCaches) {
160
+ if (!kitCaches && !userCaches) {
161
+ return undefined;
162
+ }
163
+ const map = new Map();
164
+ for (const c of kitCaches ?? []) {
165
+ map.set(c.name, c);
166
+ }
167
+ for (const c of userCaches ?? []) {
168
+ map.set(c.name, c);
169
+ }
170
+ return [...map.values()];
171
+ }
172
+ export function mergeMounts(kitMounts, userMounts) {
173
+ if (!kitMounts && !userMounts) {
174
+ return undefined;
175
+ }
176
+ return [...(kitMounts ?? []), ...(userMounts ?? [])];
87
177
  }
@@ -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,31 @@
1
+ export const shellKit = {
2
+ name: 'shell',
3
+ resolve(params) {
4
+ const run = params.run;
5
+ if (!run || typeof run !== 'string') {
6
+ throw new Error('Kit "shell": "run" parameter is required');
7
+ }
8
+ const packages = params.packages;
9
+ const hasPackages = packages && packages.length > 0;
10
+ const src = params.src;
11
+ const defaultImage = hasPackages ? 'debian:bookworm-slim' : 'alpine:3.20';
12
+ const image = params.image ?? defaultImage;
13
+ const parts = [];
14
+ if (hasPackages) {
15
+ parts.push(`apt-get update && apt-get install -y --no-install-recommends ${packages.join(' ')} && rm -rf /var/lib/apt/lists/*`);
16
+ }
17
+ parts.push(run);
18
+ const output = {
19
+ image,
20
+ cmd: ['sh', '-c', parts.join(' && ')]
21
+ };
22
+ if (hasPackages) {
23
+ output.caches = [{ name: 'apt-cache', path: '/var/cache/apt' }];
24
+ output.allowNetwork = true;
25
+ }
26
+ if (src) {
27
+ output.mounts = [{ host: src, container: '/app' }];
28
+ }
29
+ return output;
30
+ }
31
+ };
@@ -0,0 +1,15 @@
1
+ import { nodeKit } from './builtin/node.js';
2
+ import { pythonKit } from './builtin/python.js';
3
+ import { shellKit } from './builtin/shell.js';
4
+ const kits = new Map([
5
+ [nodeKit.name, nodeKit],
6
+ [pythonKit.name, pythonKit],
7
+ [shellKit.name, shellKit]
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.3",
3
+ "version": "0.0.5",
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,8 @@
20
20
  "access": "public"
21
21
  },
22
22
  "scripts": {
23
- "start": "tsx src/cli/index.ts",
23
+ "cli": "tsx src/cli/index.ts",
24
+ "test": "ava",
24
25
  "lint": "xo",
25
26
  "lint:fix": "xo --fix",
26
27
  "build": "tsc"
@@ -30,11 +31,16 @@
30
31
  "commander": "^14.0.3",
31
32
  "dotenv": "^17.2.3",
32
33
  "execa": "^9.6.1",
34
+ "lodash-es": "^4.17.23",
33
35
  "ora": "^9.3.0",
34
- "pino": "^10.3.0"
36
+ "pino": "^10.3.0",
37
+ "yaml": "^2.8.2"
35
38
  },
36
39
  "devDependencies": {
40
+ "@ava/typescript": "^6.0.0",
41
+ "@types/lodash-es": "^4.17.12",
37
42
  "@types/node": "^25.2.0",
43
+ "ava": "^6.4.1",
38
44
  "tsx": "^4.21.0",
39
45
  "xo": "^1.2.3"
40
46
  }