@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 +216 -86
- package/dist/cli/index.js +1 -1
- package/dist/cli/pipeline-loader.js +97 -7
- package/dist/cli/pipeline-runner.js +13 -14
- package/dist/cli/reporter.js +28 -27
- package/dist/cli/types.js +3 -1
- package/dist/kits/bash.js +19 -0
- package/dist/kits/builtin/bash.js +19 -0
- package/dist/kits/builtin/node.js +56 -0
- package/dist/kits/builtin/python.js +51 -0
- package/dist/kits/builtin/shell.js +31 -0
- package/dist/kits/index.js +15 -0
- package/dist/kits/node.js +56 -0
- package/dist/kits/python.js +51 -0
- package/dist/kits/types.js +1 -0
- package/dist/types.js +10 -0
- package/package.json +9 -3
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
Or install globally:
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
pipex run pipeline.yaml --json
|
|
33
35
|
|
|
34
36
|
# Custom workdir
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
+
pipex list
|
|
45
|
+
pipex ls --json
|
|
44
46
|
|
|
45
47
|
# Remove specific workspaces
|
|
46
|
-
|
|
48
|
+
pipex rm my-build other-build
|
|
47
49
|
|
|
48
50
|
# Remove all workspaces
|
|
49
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
| `
|
|
115
|
-
| `
|
|
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
|
-
```
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
```
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
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
|
-
```
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
##
|
|
276
|
+
## Examples
|
|
180
277
|
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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/
|
|
301
|
+
├── nodejs/ # lodash-based data analysis
|
|
188
302
|
│ ├── package.json
|
|
189
303
|
│ ├── analyze.js
|
|
190
304
|
│ └── transform.js
|
|
191
|
-
└── python/
|
|
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
|
-
|
|
312
|
+
Steps: `node-analyze` → `node-transform` → `python-analyze` → `python-transform`
|
|
198
313
|
|
|
199
314
|
```bash
|
|
200
|
-
|
|
315
|
+
pipex run examples/multi-language/pipeline.yaml
|
|
201
316
|
```
|
|
202
317
|
|
|
203
318
|
## Caching & Workspaces
|
|
204
319
|
|
|
205
|
-
Workspaces enable caching across runs.
|
|
320
|
+
Workspaces enable caching across runs. The workspace ID is determined by:
|
|
206
321
|
1. CLI flag `--workspace` (highest priority)
|
|
207
|
-
2.
|
|
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
|
-
|
|
236
|
-
|
|
348
|
+
pipex list
|
|
349
|
+
pipex rm old-workspace-id
|
|
237
350
|
# Or remove all at once
|
|
238
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
npm
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
21
|
+
const steps = input.steps.map(step => this.resolveStep(step));
|
|
22
|
+
for (const step of steps) {
|
|
10
23
|
this.validateStep(step);
|
|
11
24
|
}
|
|
12
|
-
|
|
25
|
+
this.validateUniqueStepIds(steps);
|
|
26
|
+
return { id: pipelineId, name: input.name, steps };
|
|
13
27
|
}
|
|
14
|
-
|
|
15
|
-
if (!step.id
|
|
16
|
-
throw new Error('Invalid step: id
|
|
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 {
|
|
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 >
|
|
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',
|
|
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,
|
|
108
|
+
this.reporter.log(workspace.id, stepRef, stream, line);
|
|
110
109
|
});
|
|
111
|
-
this.reporter.result(workspace.id,
|
|
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',
|
|
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',
|
|
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',
|
|
134
|
+
this.reporter.state(workspace.id, 'STEP_SKIPPED', stepRef, { artifactId: cached.artifactId, reason: 'cached' });
|
|
136
135
|
return true;
|
|
137
136
|
}
|
|
138
137
|
}
|
package/dist/cli/reporter.js
CHANGED
|
@@ -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,
|
|
11
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
28
|
+
state(workspaceId, event, step, meta) {
|
|
28
29
|
if (event === 'PIPELINE_START') {
|
|
29
|
-
|
|
30
|
+
const displayName = meta?.pipelineName ?? workspaceId;
|
|
31
|
+
console.log(chalk.bold(`\n▶ Pipeline: ${chalk.cyan(displayName)}\n`));
|
|
30
32
|
}
|
|
31
|
-
if (event === 'STEP_STARTING' &&
|
|
32
|
-
const spinner = ora({ text:
|
|
33
|
-
this.stepSpinners.set(
|
|
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' &&
|
|
36
|
-
const spinner = this.stepSpinners.get(
|
|
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(`${
|
|
39
|
-
this.stepSpinners.delete(
|
|
40
|
+
spinner.stopAndPersist({ symbol: chalk.gray('⊙'), text: chalk.gray(`${step.displayName} (cached)`) });
|
|
41
|
+
this.stepSpinners.delete(step.id);
|
|
40
42
|
}
|
|
41
43
|
else {
|
|
42
|
-
|
|
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' &&
|
|
47
|
-
const spinner = this.stepSpinners.get(
|
|
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(
|
|
50
|
-
this.stepSpinners.delete(
|
|
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' &&
|
|
54
|
-
const spinner = this.stepSpinners.get(
|
|
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(`${
|
|
61
|
+
text: chalk.red(`${step.displayName}${exitInfo}`)
|
|
61
62
|
});
|
|
62
|
-
this.stepSpinners.delete(
|
|
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,
|
|
73
|
+
log(_workspaceId, _step, _stream, _line) {
|
|
73
74
|
// Suppress logs in interactive mode
|
|
74
75
|
}
|
|
75
|
-
result(_workspaceId,
|
|
76
|
+
result(_workspaceId, _step, _result) {
|
|
76
77
|
// Results shown via state updates
|
|
77
78
|
}
|
|
78
79
|
}
|
package/dist/cli/types.js
CHANGED
|
@@ -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
|
+
"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
|
-
"
|
|
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
|
}
|