@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 +175 -82
- package/dist/cli/index.js +1 -1
- package/dist/cli/pipeline-loader.js +94 -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/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 +6 -3
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
|
-
##
|
|
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 --workspace my-build
|
|
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 --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
|
-
|
|
31
|
+
pipex run pipeline.yaml
|
|
27
32
|
|
|
28
33
|
# With workspace name (enables caching)
|
|
29
|
-
|
|
34
|
+
pipex run pipeline.yaml --workspace my-build
|
|
30
35
|
|
|
31
36
|
# JSON mode (for CI/CD)
|
|
32
|
-
|
|
37
|
+
pipex run pipeline.yaml --json
|
|
33
38
|
|
|
34
39
|
# Custom workdir
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
47
|
+
pipex list
|
|
48
|
+
pipex ls --json
|
|
44
49
|
|
|
45
50
|
# Remove specific workspaces
|
|
46
|
-
|
|
51
|
+
pipex rm my-build other-build
|
|
47
52
|
|
|
48
53
|
# 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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
| `
|
|
115
|
-
| `
|
|
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
|
-
```
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
```
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
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
|
-
```
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
262
|
+
├── pipeline.yaml
|
|
186
263
|
└── scripts/
|
|
187
|
-
├── nodejs/
|
|
264
|
+
├── nodejs/ # lodash-based data analysis
|
|
188
265
|
│ ├── package.json
|
|
189
266
|
│ ├── analyze.js
|
|
190
267
|
│ └── transform.js
|
|
191
|
-
└── python/
|
|
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
|
|
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
|
-
|
|
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.
|
|
283
|
+
Workspaces enable caching across runs. The workspace ID is determined by:
|
|
206
284
|
1. CLI flag `--workspace` (highest priority)
|
|
207
|
-
2.
|
|
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
|
-
|
|
236
|
-
|
|
311
|
+
pipex list
|
|
312
|
+
pipex rm old-workspace-id
|
|
237
313
|
# Or remove all at once
|
|
238
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
npm
|
|
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
|
|
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
|
|
6
|
-
if (!
|
|
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
|
-
|
|
18
|
+
const steps = input.steps.map(step => this.resolveStep(step));
|
|
19
|
+
for (const step of steps) {
|
|
10
20
|
this.validateStep(step);
|
|
11
21
|
}
|
|
12
|
-
|
|
22
|
+
this.validateUniqueStepIds(steps);
|
|
23
|
+
return { id: pipelineId, name: input.name, steps };
|
|
13
24
|
}
|
|
14
|
-
|
|
15
|
-
if (!step.id
|
|
16
|
-
throw new Error('Invalid step: id
|
|
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 {
|
|
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,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.
|
|
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
|
-
"
|
|
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"
|