@livingdata/pipex 0.0.9 → 0.0.10

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.
Files changed (88) hide show
  1. package/README.md +154 -14
  2. package/dist/__tests__/errors.js +162 -0
  3. package/dist/__tests__/helpers.js +41 -0
  4. package/dist/__tests__/types.js +8 -0
  5. package/dist/cli/__tests__/condition.js +23 -0
  6. package/dist/cli/__tests__/dag.js +154 -0
  7. package/dist/cli/__tests__/pipeline-loader.js +267 -0
  8. package/dist/cli/__tests__/pipeline-runner.js +257 -0
  9. package/dist/cli/__tests__/state-persistence.js +80 -0
  10. package/dist/cli/__tests__/state.js +58 -0
  11. package/dist/cli/__tests__/step-runner.js +116 -0
  12. package/dist/cli/commands/bundle.js +35 -0
  13. package/dist/cli/commands/cat.js +54 -0
  14. package/dist/cli/commands/exec.js +89 -0
  15. package/dist/cli/commands/export.js +2 -2
  16. package/dist/cli/commands/inspect.js +1 -1
  17. package/dist/cli/commands/list.js +2 -1
  18. package/dist/cli/commands/logs.js +1 -1
  19. package/dist/cli/commands/prune.js +1 -1
  20. package/dist/cli/commands/rm-step.js +41 -0
  21. package/dist/cli/commands/run-bundle.js +59 -0
  22. package/dist/cli/commands/run.js +9 -4
  23. package/dist/cli/commands/show.js +42 -7
  24. package/dist/cli/condition.js +11 -0
  25. package/dist/cli/dag.js +143 -0
  26. package/dist/cli/index.js +6 -0
  27. package/dist/cli/interactive-reporter.js +227 -0
  28. package/dist/cli/pipeline-loader.js +10 -110
  29. package/dist/cli/pipeline-runner.js +164 -78
  30. package/dist/cli/reporter.js +2 -158
  31. package/dist/cli/state.js +8 -0
  32. package/dist/cli/step-loader.js +25 -0
  33. package/dist/cli/step-resolver.js +111 -0
  34. package/dist/cli/step-runner.js +226 -0
  35. package/dist/cli/utils.js +0 -46
  36. package/dist/core/__tests__/bundle.js +663 -0
  37. package/dist/core/__tests__/condition.js +23 -0
  38. package/dist/core/__tests__/dag.js +154 -0
  39. package/dist/core/__tests__/env-file.test.js +41 -0
  40. package/dist/core/__tests__/event-aggregator.js +244 -0
  41. package/dist/core/__tests__/pipeline-loader.js +267 -0
  42. package/dist/core/__tests__/pipeline-runner.js +257 -0
  43. package/dist/core/__tests__/state-persistence.js +80 -0
  44. package/dist/core/__tests__/state.js +58 -0
  45. package/dist/core/__tests__/step-runner.js +118 -0
  46. package/dist/core/__tests__/stream-reporter.js +142 -0
  47. package/dist/core/__tests__/transport.js +50 -0
  48. package/dist/core/__tests__/utils.js +40 -0
  49. package/dist/core/bundle.js +130 -0
  50. package/dist/core/condition.js +11 -0
  51. package/dist/core/dag.js +143 -0
  52. package/dist/core/env-file.js +6 -0
  53. package/dist/core/event-aggregator.js +114 -0
  54. package/dist/core/index.js +14 -0
  55. package/dist/core/pipeline-loader.js +81 -0
  56. package/dist/core/pipeline-runner.js +360 -0
  57. package/dist/core/reporter.js +11 -0
  58. package/dist/core/state.js +110 -0
  59. package/dist/core/step-loader.js +25 -0
  60. package/dist/core/step-resolver.js +117 -0
  61. package/dist/core/step-runner.js +225 -0
  62. package/dist/core/stream-reporter.js +41 -0
  63. package/dist/core/transport.js +9 -0
  64. package/dist/core/utils.js +56 -0
  65. package/dist/engine/__tests__/workspace.js +288 -0
  66. package/dist/engine/docker-executor.js +10 -2
  67. package/dist/engine/index.js +1 -0
  68. package/dist/engine/workspace.js +76 -12
  69. package/dist/errors.js +122 -0
  70. package/dist/index.js +3 -0
  71. package/dist/kits/__tests__/index.js +23 -0
  72. package/dist/kits/builtin/__tests__/node.js +74 -0
  73. package/dist/kits/builtin/__tests__/python.js +67 -0
  74. package/dist/kits/builtin/__tests__/shell.js +74 -0
  75. package/dist/kits/builtin/node.js +10 -5
  76. package/dist/kits/builtin/python.js +10 -5
  77. package/dist/kits/builtin/shell.js +2 -1
  78. package/dist/kits/index.js +2 -1
  79. package/package.json +6 -3
  80. package/dist/cli/types.js +0 -3
  81. package/dist/engine/docker-runtime.js +0 -65
  82. package/dist/engine/runtime.js +0 -2
  83. package/dist/kits/bash.js +0 -19
  84. package/dist/kits/builtin/bash.js +0 -19
  85. package/dist/kits/node.js +0 -56
  86. package/dist/kits/python.js +0 -51
  87. package/dist/kits/types.js +0 -1
  88. package/dist/reporter.js +0 -13
@@ -0,0 +1,663 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+ import { buffer as streamToBuffer } from 'node:stream/consumers';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import test from 'ava';
9
+ import * as tar from 'tar';
10
+ import { BundleError } from '../../errors.js';
11
+ import { collectDependencies, buildIgnoreFilter, buildBundle, extractBundle } from '../bundle.js';
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ async function createTempDir() {
16
+ const dir = join(tmpdir(), `pipex-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
17
+ await mkdir(dir, { recursive: true });
18
+ return dir;
19
+ }
20
+ async function writePipeline(dir, definition, filename = 'pipeline.json') {
21
+ const filePath = join(dir, filename);
22
+ await writeFile(filePath, JSON.stringify(definition));
23
+ return filePath;
24
+ }
25
+ async function listTarEntries(archive) {
26
+ const entries = [];
27
+ await pipeline(Readable.from(archive), tar.extract({
28
+ cwd: tmpdir(),
29
+ onReadEntry(entry) {
30
+ entries.push(entry.path);
31
+ }
32
+ }));
33
+ return entries;
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // collectDependencies
37
+ // ---------------------------------------------------------------------------
38
+ test('collectDependencies returns empty array for pipeline without mounts or sources', t => {
39
+ const pipeline = {
40
+ id: 'test',
41
+ steps: [{ id: 's', image: 'alpine', cmd: ['echo'] }]
42
+ };
43
+ t.deepEqual(collectDependencies(pipeline), []);
44
+ });
45
+ test('collectDependencies collects from mounts', t => {
46
+ const pipeline = {
47
+ id: 'test',
48
+ steps: [{
49
+ id: 's',
50
+ image: 'alpine',
51
+ cmd: ['echo'],
52
+ mounts: [{ host: 'config', container: '/config' }]
53
+ }]
54
+ };
55
+ t.deepEqual(collectDependencies(pipeline), ['config']);
56
+ });
57
+ test('collectDependencies collects from sources', t => {
58
+ const pipeline = {
59
+ id: 'test',
60
+ steps: [{
61
+ id: 's',
62
+ image: 'alpine',
63
+ cmd: ['echo'],
64
+ sources: [{ host: 'src', container: '/app' }]
65
+ }]
66
+ };
67
+ t.deepEqual(collectDependencies(pipeline), ['src']);
68
+ });
69
+ test('collectDependencies collects from both mounts and sources', t => {
70
+ const pipeline = {
71
+ id: 'test',
72
+ steps: [{
73
+ id: 's',
74
+ image: 'alpine',
75
+ cmd: ['echo'],
76
+ mounts: [{ host: 'config', container: '/config' }],
77
+ sources: [{ host: 'src', container: '/app' }]
78
+ }]
79
+ };
80
+ t.deepEqual(collectDependencies(pipeline), ['config', 'src']);
81
+ });
82
+ test('collectDependencies deduplicates same path from different steps', t => {
83
+ const pipeline = {
84
+ id: 'test',
85
+ steps: [
86
+ {
87
+ id: 'a',
88
+ image: 'alpine',
89
+ cmd: ['echo'],
90
+ mounts: [{ host: 'data', container: '/data' }]
91
+ },
92
+ {
93
+ id: 'b',
94
+ image: 'alpine',
95
+ cmd: ['echo'],
96
+ sources: [{ host: 'data', container: '/app/data' }]
97
+ }
98
+ ]
99
+ };
100
+ t.deepEqual(collectDependencies(pipeline), ['data']);
101
+ });
102
+ test('collectDependencies returns sorted results', t => {
103
+ const pipeline = {
104
+ id: 'test',
105
+ steps: [{
106
+ id: 's',
107
+ image: 'alpine',
108
+ cmd: ['echo'],
109
+ mounts: [{ host: 'zebra', container: '/z' }],
110
+ sources: [{ host: 'alpha', container: '/a' }]
111
+ }]
112
+ };
113
+ t.deepEqual(collectDependencies(pipeline), ['alpha', 'zebra']);
114
+ });
115
+ test('collectDependencies normalizes paths', t => {
116
+ const pipeline = {
117
+ id: 'test',
118
+ steps: [
119
+ {
120
+ id: 'a',
121
+ image: 'alpine',
122
+ cmd: ['echo'],
123
+ mounts: [{ host: './src/', container: '/app' }]
124
+ },
125
+ {
126
+ id: 'b',
127
+ image: 'alpine',
128
+ cmd: ['echo'],
129
+ sources: [{ host: 'src', container: '/app' }]
130
+ }
131
+ ]
132
+ };
133
+ t.deepEqual(collectDependencies(pipeline), ['src']);
134
+ });
135
+ // ---------------------------------------------------------------------------
136
+ // buildIgnoreFilter
137
+ // ---------------------------------------------------------------------------
138
+ test('buildIgnoreFilter excludes .git by default', async (t) => {
139
+ const dir = await createTempDir();
140
+ try {
141
+ const filter = await buildIgnoreFilter(dir);
142
+ t.true(filter('.git'));
143
+ t.true(filter('.git/config'));
144
+ }
145
+ finally {
146
+ await rm(dir, { recursive: true, force: true });
147
+ }
148
+ });
149
+ test('buildIgnoreFilter excludes node_modules by default', async (t) => {
150
+ const dir = await createTempDir();
151
+ try {
152
+ const filter = await buildIgnoreFilter(dir);
153
+ t.true(filter('node_modules'));
154
+ t.true(filter('node_modules/foo/index.js'));
155
+ }
156
+ finally {
157
+ await rm(dir, { recursive: true, force: true });
158
+ }
159
+ });
160
+ test('buildIgnoreFilter excludes __pycache__ and .pyc by default', async (t) => {
161
+ const dir = await createTempDir();
162
+ try {
163
+ const filter = await buildIgnoreFilter(dir);
164
+ t.true(filter('__pycache__'));
165
+ t.true(filter('test.pyc'));
166
+ }
167
+ finally {
168
+ await rm(dir, { recursive: true, force: true });
169
+ }
170
+ });
171
+ test('buildIgnoreFilter excludes .DS_Store and .env by default', async (t) => {
172
+ const dir = await createTempDir();
173
+ try {
174
+ const filter = await buildIgnoreFilter(dir);
175
+ t.true(filter('.DS_Store'));
176
+ t.true(filter('.env'));
177
+ t.true(filter('src/.DS_Store'));
178
+ }
179
+ finally {
180
+ await rm(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+ test('buildIgnoreFilter works without .gitignore', async (t) => {
184
+ const dir = await createTempDir();
185
+ try {
186
+ // No .gitignore present — should not throw
187
+ const filter = await buildIgnoreFilter(dir);
188
+ t.true(filter('.git'));
189
+ t.false(filter('src/app.js'));
190
+ }
191
+ finally {
192
+ await rm(dir, { recursive: true, force: true });
193
+ }
194
+ });
195
+ test('buildIgnoreFilter excludes nested ignored paths', async (t) => {
196
+ const dir = await createTempDir();
197
+ try {
198
+ const filter = await buildIgnoreFilter(dir);
199
+ t.true(filter('src/.git'));
200
+ t.true(filter('src/.git/config'));
201
+ t.true(filter('lib/node_modules'));
202
+ t.true(filter('lib/node_modules/pkg/index.js'));
203
+ t.true(filter('src/__pycache__'));
204
+ }
205
+ finally {
206
+ await rm(dir, { recursive: true, force: true });
207
+ }
208
+ });
209
+ test('buildIgnoreFilter returns false for empty string', async (t) => {
210
+ const dir = await createTempDir();
211
+ try {
212
+ const filter = await buildIgnoreFilter(dir);
213
+ t.false(filter(''));
214
+ }
215
+ finally {
216
+ await rm(dir, { recursive: true, force: true });
217
+ }
218
+ });
219
+ test('buildIgnoreFilter does not exclude normal files', async (t) => {
220
+ const dir = await createTempDir();
221
+ try {
222
+ const filter = await buildIgnoreFilter(dir);
223
+ t.false(filter('src/app.js'));
224
+ t.false(filter('config/settings.yaml'));
225
+ }
226
+ finally {
227
+ await rm(dir, { recursive: true, force: true });
228
+ }
229
+ });
230
+ test('buildIgnoreFilter respects .gitignore if present', async (t) => {
231
+ const dir = await createTempDir();
232
+ try {
233
+ await writeFile(join(dir, '.gitignore'), 'dist/\n*.log\n');
234
+ const filter = await buildIgnoreFilter(dir);
235
+ t.true(filter('dist'));
236
+ t.true(filter('dist/bundle.js'));
237
+ t.true(filter('error.log'));
238
+ t.false(filter('src/app.js'));
239
+ }
240
+ finally {
241
+ await rm(dir, { recursive: true, force: true });
242
+ }
243
+ });
244
+ // ---------------------------------------------------------------------------
245
+ // buildBundle + extractBundle round-trip
246
+ // ---------------------------------------------------------------------------
247
+ test('round-trip: pipeline with source directory', async (t) => {
248
+ const dir = await createTempDir();
249
+ const extractDir = await createTempDir();
250
+ try {
251
+ // Create source directory
252
+ await mkdir(join(dir, 'src'), { recursive: true });
253
+ await writeFile(join(dir, 'src', 'app.js'), 'console.log("hello")');
254
+ // Create pipeline
255
+ const pipelinePath = await writePipeline(dir, {
256
+ id: 'test-pipeline',
257
+ steps: [{
258
+ id: 'build',
259
+ image: 'node:24-alpine',
260
+ cmd: ['node', 'app.js'],
261
+ sources: [{ host: 'src', container: '/app' }]
262
+ }]
263
+ });
264
+ const archive = await buildBundle(pipelinePath);
265
+ t.true(archive.length > 0);
266
+ const pipeline = await extractBundle(archive, extractDir);
267
+ t.is(pipeline.id, 'test-pipeline');
268
+ t.is(pipeline.steps.length, 1);
269
+ t.is(pipeline.steps[0].id, 'build');
270
+ t.is(pipeline.steps[0].image, 'node:24-alpine');
271
+ // Verify source files are extracted
272
+ const content = await readFile(join(extractDir, 'src', 'app.js'), 'utf8');
273
+ t.is(content, 'console.log("hello")');
274
+ }
275
+ finally {
276
+ await rm(dir, { recursive: true, force: true });
277
+ await rm(extractDir, { recursive: true, force: true });
278
+ }
279
+ });
280
+ test('round-trip: manifest contains resolved pipeline (no uses field)', async (t) => {
281
+ const dir = await createTempDir();
282
+ const extractDir = await createTempDir();
283
+ try {
284
+ // Create pipeline using a kit step
285
+ const pipelinePath = await writePipeline(dir, {
286
+ id: 'kit-pipeline',
287
+ steps: [{
288
+ id: 'run',
289
+ uses: 'shell',
290
+ with: { run: 'echo hello' }
291
+ }]
292
+ });
293
+ const archive = await buildBundle(pipelinePath);
294
+ const pipeline = await extractBundle(archive, extractDir);
295
+ // Manifest should have resolved step (image and cmd, no uses)
296
+ t.is(pipeline.steps[0].image, 'alpine:3.20');
297
+ t.deepEqual(pipeline.steps[0].cmd, ['sh', '-c', 'echo hello']);
298
+ t.is(pipeline.steps[0].uses, undefined);
299
+ }
300
+ finally {
301
+ await rm(dir, { recursive: true, force: true });
302
+ await rm(extractDir, { recursive: true, force: true });
303
+ }
304
+ });
305
+ test('round-trip: ignored files are not in the archive', async (t) => {
306
+ const dir = await createTempDir();
307
+ try {
308
+ // Create source with ignored files
309
+ await mkdir(join(dir, 'src', '.git'), { recursive: true });
310
+ await mkdir(join(dir, 'src', 'node_modules', 'pkg'), { recursive: true });
311
+ await writeFile(join(dir, 'src', 'app.js'), 'hello');
312
+ await writeFile(join(dir, 'src', '.git', 'config'), 'git-data');
313
+ await writeFile(join(dir, 'src', 'node_modules', 'pkg', 'index.js'), 'pkg');
314
+ const pipelinePath = await writePipeline(dir, {
315
+ id: 'test',
316
+ steps: [{
317
+ id: 's',
318
+ image: 'alpine',
319
+ cmd: ['echo'],
320
+ sources: [{ host: 'src', container: '/app' }]
321
+ }]
322
+ });
323
+ const archive = await buildBundle(pipelinePath);
324
+ const entries = await listTarEntries(archive);
325
+ t.true(entries.includes('manifest.json'));
326
+ t.true(entries.some(e => e.startsWith('src/') || e === 'src'));
327
+ t.false(entries.some(e => e.includes('.git')));
328
+ t.false(entries.some(e => e.includes('node_modules')));
329
+ }
330
+ finally {
331
+ await rm(dir, { recursive: true, force: true });
332
+ }
333
+ });
334
+ test('round-trip: manifest.json has correct structure', async (t) => {
335
+ const dir = await createTempDir();
336
+ const extractDir = await createTempDir();
337
+ try {
338
+ const pipelinePath = await writePipeline(dir, {
339
+ id: 'my-pipeline',
340
+ steps: [{
341
+ id: 'step1',
342
+ image: 'alpine',
343
+ cmd: ['echo', 'hello']
344
+ }]
345
+ });
346
+ const archive = await buildBundle(pipelinePath);
347
+ await extractBundle(archive, extractDir);
348
+ const manifest = JSON.parse(await readFile(join(extractDir, 'manifest.json'), 'utf8'));
349
+ t.is(manifest.version, 1);
350
+ t.is(manifest.pipeline.id, 'my-pipeline');
351
+ t.truthy(manifest.pipeline.steps);
352
+ }
353
+ finally {
354
+ await rm(dir, { recursive: true, force: true });
355
+ await rm(extractDir, { recursive: true, force: true });
356
+ }
357
+ });
358
+ test('round-trip: pipeline with mounts bundles host directories', async (t) => {
359
+ const dir = await createTempDir();
360
+ const extractDir = await createTempDir();
361
+ try {
362
+ await mkdir(join(dir, 'config'), { recursive: true });
363
+ await writeFile(join(dir, 'config', 'settings.yaml'), 'key: value');
364
+ const pipelinePath = await writePipeline(dir, {
365
+ id: 'mount-test',
366
+ steps: [{
367
+ id: 's',
368
+ image: 'alpine',
369
+ cmd: ['cat', '/config/settings.yaml'],
370
+ mounts: [{ host: 'config', container: '/config' }]
371
+ }]
372
+ });
373
+ const archive = await buildBundle(pipelinePath);
374
+ const p = await extractBundle(archive, extractDir);
375
+ t.is(p.id, 'mount-test');
376
+ const content = await readFile(join(extractDir, 'config', 'settings.yaml'), 'utf8');
377
+ t.is(content, 'key: value');
378
+ }
379
+ finally {
380
+ await rm(dir, { recursive: true, force: true });
381
+ await rm(extractDir, { recursive: true, force: true });
382
+ }
383
+ });
384
+ test('round-trip: pipeline without deps produces manifest-only archive', async (t) => {
385
+ const dir = await createTempDir();
386
+ try {
387
+ const pipelinePath = await writePipeline(dir, {
388
+ id: 'no-deps',
389
+ steps: [{
390
+ id: 's',
391
+ image: 'alpine',
392
+ cmd: ['echo', 'hello']
393
+ }]
394
+ });
395
+ const archive = await buildBundle(pipelinePath);
396
+ const entries = await listTarEntries(archive);
397
+ t.deepEqual(entries, ['manifest.json']);
398
+ }
399
+ finally {
400
+ await rm(dir, { recursive: true, force: true });
401
+ }
402
+ });
403
+ test('round-trip: YAML pipeline file', async (t) => {
404
+ const dir = await createTempDir();
405
+ const extractDir = await createTempDir();
406
+ try {
407
+ await mkdir(join(dir, 'src'), { recursive: true });
408
+ await writeFile(join(dir, 'src', 'app.js'), 'hello');
409
+ const yamlContent = [
410
+ 'id: yaml-test',
411
+ 'steps:',
412
+ ' - id: build',
413
+ ' image: alpine',
414
+ ' cmd: [echo, hello]',
415
+ ' sources:',
416
+ ' - host: src',
417
+ ' container: /app'
418
+ ].join('\n');
419
+ const pipelinePath = join(dir, 'pipeline.yaml');
420
+ await writeFile(pipelinePath, yamlContent);
421
+ const archive = await buildBundle(pipelinePath);
422
+ const p = await extractBundle(archive, extractDir);
423
+ t.is(p.id, 'yaml-test');
424
+ const content = await readFile(join(extractDir, 'src', 'app.js'), 'utf8');
425
+ t.is(content, 'hello');
426
+ }
427
+ finally {
428
+ await rm(dir, { recursive: true, force: true });
429
+ await rm(extractDir, { recursive: true, force: true });
430
+ }
431
+ });
432
+ test('round-trip: multiple deps with nested directory structure', async (t) => {
433
+ const dir = await createTempDir();
434
+ const extractDir = await createTempDir();
435
+ try {
436
+ await mkdir(join(dir, 'scripts', 'nodejs'), { recursive: true });
437
+ await mkdir(join(dir, 'scripts', 'python'), { recursive: true });
438
+ await mkdir(join(dir, 'config'), { recursive: true });
439
+ await writeFile(join(dir, 'scripts', 'nodejs', 'index.js'), 'node');
440
+ await writeFile(join(dir, 'scripts', 'python', 'main.py'), 'python');
441
+ await writeFile(join(dir, 'config', 'app.yaml'), 'config');
442
+ const pipelinePath = await writePipeline(dir, {
443
+ id: 'multi-deps',
444
+ steps: [
445
+ {
446
+ id: 'a',
447
+ image: 'node:22',
448
+ cmd: ['node', 'index.js'],
449
+ sources: [{ host: 'scripts/nodejs', container: '/app' }]
450
+ },
451
+ {
452
+ id: 'b',
453
+ image: 'python:3',
454
+ cmd: ['python', 'main.py'],
455
+ sources: [{ host: 'scripts/python', container: '/app' }],
456
+ mounts: [{ host: 'config', container: '/config' }]
457
+ }
458
+ ]
459
+ });
460
+ const archive = await buildBundle(pipelinePath);
461
+ const p = await extractBundle(archive, extractDir);
462
+ t.is(p.steps.length, 2);
463
+ t.is(await readFile(join(extractDir, 'scripts', 'nodejs', 'index.js'), 'utf8'), 'node');
464
+ t.is(await readFile(join(extractDir, 'scripts', 'python', 'main.py'), 'utf8'), 'python');
465
+ t.is(await readFile(join(extractDir, 'config', 'app.yaml'), 'utf8'), 'config');
466
+ }
467
+ finally {
468
+ await rm(dir, { recursive: true, force: true });
469
+ await rm(extractDir, { recursive: true, force: true });
470
+ }
471
+ });
472
+ test('round-trip: .gitignore in pipeline root filters files from archive', async (t) => {
473
+ const dir = await createTempDir();
474
+ try {
475
+ await mkdir(join(dir, 'src'), { recursive: true });
476
+ await mkdir(join(dir, 'src', 'dist'), { recursive: true });
477
+ await writeFile(join(dir, 'src', 'app.js'), 'hello');
478
+ await writeFile(join(dir, 'src', 'dist', 'bundle.js'), 'bundled');
479
+ await writeFile(join(dir, 'src', 'debug.log'), 'log data');
480
+ await writeFile(join(dir, '.gitignore'), 'dist/\n*.log\n');
481
+ const pipelinePath = await writePipeline(dir, {
482
+ id: 'gitignore-test',
483
+ steps: [{
484
+ id: 's',
485
+ image: 'alpine',
486
+ cmd: ['echo'],
487
+ sources: [{ host: 'src', container: '/app' }]
488
+ }]
489
+ });
490
+ const archive = await buildBundle(pipelinePath);
491
+ const entries = await listTarEntries(archive);
492
+ t.true(entries.some(e => e.includes('app.js')));
493
+ t.false(entries.some(e => e.includes('dist')));
494
+ t.false(entries.some(e => e.includes('debug.log')));
495
+ }
496
+ finally {
497
+ await rm(dir, { recursive: true, force: true });
498
+ }
499
+ });
500
+ test('round-trip: kit step with src resolves sources into bundle', async (t) => {
501
+ const dir = await createTempDir();
502
+ const extractDir = await createTempDir();
503
+ try {
504
+ await mkdir(join(dir, 'myapp'), { recursive: true });
505
+ await writeFile(join(dir, 'myapp', 'package.json'), '{"name":"test"}');
506
+ await writeFile(join(dir, 'myapp', 'build.js'), 'console.log("build")');
507
+ const pipelinePath = await writePipeline(dir, {
508
+ id: 'kit-src',
509
+ steps: [{
510
+ id: 'build',
511
+ uses: 'node',
512
+ with: { script: 'build.js', src: 'myapp' }
513
+ }]
514
+ });
515
+ const archive = await buildBundle(pipelinePath);
516
+ const p = await extractBundle(archive, extractDir);
517
+ // Kit resolved: image is node, sources contain myapp
518
+ t.truthy(p.steps[0].image.startsWith('node:'));
519
+ t.truthy(p.steps[0].sources);
520
+ t.is(p.steps[0].sources[0].host, 'myapp');
521
+ // Files bundled
522
+ t.is(await readFile(join(extractDir, 'myapp', 'package.json'), 'utf8'), '{"name":"test"}');
523
+ t.is(await readFile(join(extractDir, 'myapp', 'build.js'), 'utf8'), 'console.log("build")');
524
+ }
525
+ finally {
526
+ await rm(dir, { recursive: true, force: true });
527
+ await rm(extractDir, { recursive: true, force: true });
528
+ }
529
+ });
530
+ // ---------------------------------------------------------------------------
531
+ // extractBundle edge cases
532
+ // ---------------------------------------------------------------------------
533
+ test('extractBundle creates target directory if it does not exist', async (t) => {
534
+ const dir = await createTempDir();
535
+ const nonExistentDir = join(dir, 'deep', 'nested', 'extract');
536
+ try {
537
+ const pipelinePath = await writePipeline(dir, {
538
+ id: 'test',
539
+ steps: [{ id: 's', image: 'alpine', cmd: ['echo'] }]
540
+ });
541
+ const archive = await buildBundle(pipelinePath);
542
+ const p = await extractBundle(archive, nonExistentDir);
543
+ t.is(p.id, 'test');
544
+ // Directory was created
545
+ const s = await stat(nonExistentDir);
546
+ t.true(s.isDirectory());
547
+ }
548
+ finally {
549
+ await rm(dir, { recursive: true, force: true });
550
+ }
551
+ });
552
+ test('extractBundle throws BundleError for invalid JSON in manifest', async (t) => {
553
+ const dir = await createTempDir();
554
+ const extractDir = await createTempDir();
555
+ try {
556
+ // Create a tar.gz with a malformed manifest.json
557
+ await writeFile(join(dir, 'manifest.json'), '{not valid json');
558
+ const stream = tar.create({ cwd: dir, gzip: true }, ['manifest.json']);
559
+ const archive = await streamToBuffer(stream);
560
+ const error = await t.throwsAsync(async () => extractBundle(Buffer.from(archive), extractDir), { message: /not valid JSON/ });
561
+ t.true(error instanceof BundleError);
562
+ }
563
+ finally {
564
+ await rm(dir, { recursive: true, force: true });
565
+ await rm(extractDir, { recursive: true, force: true });
566
+ }
567
+ });
568
+ test('extractBundle throws BundleError when manifest has no pipeline field', async (t) => {
569
+ const dir = await createTempDir();
570
+ const extractDir = await createTempDir();
571
+ try {
572
+ await writeFile(join(dir, 'manifest.json'), JSON.stringify({ version: 1 }));
573
+ const stream = tar.create({ cwd: dir, gzip: true }, ['manifest.json']);
574
+ const archive = await streamToBuffer(stream);
575
+ const error = await t.throwsAsync(async () => extractBundle(Buffer.from(archive), extractDir), { message: /missing pipeline/ });
576
+ t.true(error instanceof BundleError);
577
+ }
578
+ finally {
579
+ await rm(dir, { recursive: true, force: true });
580
+ await rm(extractDir, { recursive: true, force: true });
581
+ }
582
+ });
583
+ // ---------------------------------------------------------------------------
584
+ // Error cases
585
+ // ---------------------------------------------------------------------------
586
+ test('buildBundle throws BundleError for missing dependency', async (t) => {
587
+ const dir = await createTempDir();
588
+ try {
589
+ const pipelinePath = await writePipeline(dir, {
590
+ id: 'test',
591
+ steps: [{
592
+ id: 's',
593
+ image: 'alpine',
594
+ cmd: ['echo'],
595
+ sources: [{ host: 'nonexistent', container: '/app' }]
596
+ }]
597
+ });
598
+ const error = await t.throwsAsync(async () => buildBundle(pipelinePath), {
599
+ message: /Dependency not found: nonexistent/
600
+ });
601
+ t.true(error instanceof BundleError);
602
+ }
603
+ finally {
604
+ await rm(dir, { recursive: true, force: true });
605
+ }
606
+ });
607
+ test('extractBundle throws BundleError for corrupted archive', async (t) => {
608
+ const dir = await createTempDir();
609
+ try {
610
+ const error = await t.throwsAsync(async () => extractBundle(Buffer.from('not-a-tar-archive'), dir));
611
+ t.truthy(error);
612
+ }
613
+ finally {
614
+ await rm(dir, { recursive: true, force: true });
615
+ }
616
+ });
617
+ test('extractBundle throws BundleError when manifest.json is missing', async (t) => {
618
+ const dir = await createTempDir();
619
+ const extractDir = await createTempDir();
620
+ try {
621
+ // Create a valid tar.gz without manifest.json
622
+ await writeFile(join(dir, 'dummy.txt'), 'hello');
623
+ const stream = tar.create({ cwd: dir, gzip: true }, ['dummy.txt']);
624
+ const { buffer } = await import('node:stream/consumers');
625
+ const archive = await buffer(stream);
626
+ const error = await t.throwsAsync(async () => extractBundle(Buffer.from(archive), extractDir), { message: /manifest\.json not found/ });
627
+ t.true(error instanceof BundleError);
628
+ }
629
+ finally {
630
+ await rm(dir, { recursive: true, force: true });
631
+ await rm(extractDir, { recursive: true, force: true });
632
+ }
633
+ });
634
+ test('buildBundle throws BundleError when archive exceeds 50 MB', async (t) => {
635
+ const dir = await createTempDir();
636
+ try {
637
+ // Create a large file (tar with gzip compression, so we need significantly
638
+ // more than 50MB of incompressible data)
639
+ await mkdir(join(dir, 'data'), { recursive: true });
640
+ // Write a large random-ish file that won't compress well
641
+ const crypto = await import('node:crypto');
642
+ const chunk = crypto.randomBytes(1024 * 1024); // 1MB of random data
643
+ for (let i = 0; i < 55; i++) {
644
+ await writeFile(join(dir, 'data', `file-${i}.bin`), chunk);
645
+ }
646
+ const pipelinePath = await writePipeline(dir, {
647
+ id: 'large',
648
+ steps: [{
649
+ id: 's',
650
+ image: 'alpine',
651
+ cmd: ['echo'],
652
+ sources: [{ host: 'data', container: '/data' }]
653
+ }]
654
+ });
655
+ const error = await t.throwsAsync(async () => buildBundle(pipelinePath), {
656
+ message: /exceeds maximum/
657
+ });
658
+ t.true(error instanceof BundleError);
659
+ }
660
+ finally {
661
+ await rm(dir, { recursive: true, force: true });
662
+ }
663
+ });
@@ -0,0 +1,23 @@
1
+ import test from 'ava';
2
+ import { evaluateCondition } from '../condition.js';
3
+ test('env.CI truthy when CI is defined', async (t) => {
4
+ const result = await evaluateCondition('env.CI', { env: { CI: 'true' } });
5
+ t.true(result);
6
+ });
7
+ test('env.CI falsy when CI is absent', async (t) => {
8
+ const result = await evaluateCondition('env.CI', { env: {} });
9
+ t.false(result);
10
+ });
11
+ test('env.NODE_ENV == "production" — equality', async (t) => {
12
+ t.true(await evaluateCondition('env.NODE_ENV == "production"', { env: { NODE_ENV: 'production' } }));
13
+ t.false(await evaluateCondition('env.NODE_ENV == "production"', { env: { NODE_ENV: 'development' } }));
14
+ });
15
+ test('env.CI && !env.STAGING — combined logic', async (t) => {
16
+ t.true(await evaluateCondition('env.CI && !env.STAGING', { env: { CI: 'true' } }));
17
+ t.false(await evaluateCondition('env.CI && !env.STAGING', { env: { CI: 'true', STAGING: 'true' } }));
18
+ t.false(await evaluateCondition('env.CI && !env.STAGING', { env: {} }));
19
+ });
20
+ test('invalid expression returns false (fail-closed)', async (t) => {
21
+ const result = await evaluateCondition(')))invalid(((', { env: {} });
22
+ t.false(result);
23
+ });