@invarn/cibuild 2.0.9 → 2.1.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"render-post-processor.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/render-post-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH;;;;;;;;;GASG;AACH,eAAO,MAAM,wBAAwB,EAAE,MAiJpB,CAAC;AAEpB,eAAO,MAAM,wBAAwB,EAAE,MAsFtC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,EAAE,YAAY,CAAC;IACxB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,sBAAsB,CAMlE"}
1
+ {"version":3,"file":"render-post-processor.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/render-post-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH;;;;;;;;;GASG;AACH,eAAO,MAAM,wBAAwB,EAAE,MA8JpB,CAAC;AAEpB,eAAO,MAAM,wBAAwB,EAAE,MAsFtC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,EAAE,YAAY,CAAC;IACxB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,sBAAsB,CAMlE"}
@@ -61,14 +61,25 @@ export const TRIM_HELPER_SWIFT_SOURCE = [
61
61
  ' return CGImageDestinationFinalize(dest)',
62
62
  '}',
63
63
  '',
64
- '// Redraw an image at an exact pixel size (used to match the reference).',
65
- 'func resize(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
64
+ '// Draw `image` centered on a transparent reference-sized canvas, scaled to fit',
65
+ '// while PRESERVING its aspect ratio. Unlike `resize` (which stretches to exact',
66
+ '// dimensions and distorts proportions when the aspect ratios differ), this',
67
+ '// keeps the rendered shape honest: an aspect mismatch shows as transparent',
68
+ '// letterbox/pillarbox padding rather than a squished image, while the canvas',
69
+ '// stays exactly reference-sized so the pair still overlays 1:1.',
70
+ 'func fitPad(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
66
71
  ' if w <= 0 || h <= 0 { return nil }',
67
72
  ' let bytesPerRow = w * 4',
68
73
  ' let space = CGColorSpaceCreateDeviceRGB()',
69
74
  ' guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
75
+ ' ctx.clear(CGRect(x: 0, y: 0, width: w, height: h))',
70
76
  ' ctx.interpolationQuality = .high',
71
- ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
77
+ ' let scale = min(Double(w) / Double(image.width), Double(h) / Double(image.height))',
78
+ ' let drawW = Double(image.width) * scale',
79
+ ' let drawH = Double(image.height) * scale',
80
+ ' let originX = (Double(w) - drawW) / 2.0',
81
+ ' let originY = (Double(h) - drawH) / 2.0',
82
+ ' ctx.draw(image, in: CGRect(x: originX, y: originY, width: drawW, height: drawH))',
72
83
  ' return ctx.makeImage()',
73
84
  '}',
74
85
  '',
@@ -169,8 +180,10 @@ export const TRIM_HELPER_SWIFT_SOURCE = [
169
180
  ' exit(68)',
170
181
  '}',
171
182
  '',
172
- '// Optional aligned output: the trimmed image resized to the reference pixel',
173
- '// size, a 1:1 overlay pair. Best-effort; dimensions are reported either way.',
183
+ '// Optional aligned output: the trimmed image fit onto a reference-sized',
184
+ '// canvas, preserving aspect ratio (transparent letterbox/pillarbox padding',
185
+ '// when aspect ratios differ). A reference-sized, undistorted overlay pair.',
186
+ '// Best-effort; dimensions are reported either way.',
174
187
  'var referenceWidth = 0',
175
188
  'var referenceHeight = 0',
176
189
  'if arguments.count >= 5 {',
@@ -179,7 +192,7 @@ export const TRIM_HELPER_SWIFT_SOURCE = [
179
192
  ' if let reference = loadCGImage(referencePath) {',
180
193
  ' referenceWidth = reference.width',
181
194
  ' referenceHeight = reference.height',
182
- ' if let aligned = resize(cropped, toWidth: referenceWidth, height: referenceHeight) {',
195
+ ' if let aligned = fitPad(cropped, toWidth: referenceWidth, height: referenceHeight) {',
183
196
  ' _ = writePNG(aligned, to: alignedPath)',
184
197
  ' }',
185
198
  ' }',
@@ -116,13 +116,39 @@ describe('trim helper pixel logic (real toolchain)', () => {
116
116
  const BLUE = { r: 0, g: 0, b: 1, a: 1 };
117
117
  const WHITE = { r: 1, g: 1, b: 1, a: 1 };
118
118
  const PINK = { r: 1, g: 240 / 255, b: 238 / 255, a: 1 };
119
+ // Prints "<r> <g> <b> <a>" (0..255) for one pixel, so a test can tell padding
120
+ // (transparent) apart from content (opaque) in an aligned image.
121
+ const PROBE_SWIFT = [
122
+ 'import Foundation',
123
+ 'import ImageIO',
124
+ 'import CoreGraphics',
125
+ 'let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: CommandLine.arguments[1]) as CFURL, nil)!',
126
+ 'let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!',
127
+ 'let x = Int(CommandLine.arguments[2])!, y = Int(CommandLine.arguments[3])!',
128
+ 'let w = img.width, h = img.height',
129
+ 'var data = [UInt8](repeating: 0, count: w * h * 4)',
130
+ 'let cs = CGColorSpaceCreateDeviceRGB()',
131
+ 'let c = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!',
132
+ 'c.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h))',
133
+ 'let i = y * w * 4 + x * 4',
134
+ 'print("\\(data[i]) \\(data[i+1]) \\(data[i+2]) \\(data[i+3])")',
135
+ ].join('\n') + '\n';
119
136
  function scene() {
120
137
  const dir = mkdtempSync(join(tmpdir(), 'ui-fidelity-postproc-'));
121
138
  dirs.push(dir);
122
139
  writeFileSync(join(dir, 'Make.swift'), MAKE_FIXTURE_SWIFT);
123
140
  writeFileSync(join(dir, 'Trim.swift'), TRIM_HELPER_SWIFT_SOURCE);
141
+ writeFileSync(join(dir, 'Probe.swift'), PROBE_SWIFT);
124
142
  return dir;
125
143
  }
144
+ // Alpha (0..255) of one pixel in a PNG.
145
+ function alphaAt(dir, filePath, x, y) {
146
+ const probe = spawnSync('swift', [join(dir, 'Probe.swift'), filePath, String(x), String(y)], {
147
+ encoding: 'utf-8',
148
+ });
149
+ expect(probe.status).toBe(0);
150
+ return Number(probe.stdout.trim().split(/\s+/)[3]);
151
+ }
126
152
  function craft(dir, name, w, h, rects) {
127
153
  const out = join(dir, name);
128
154
  const make = spawnSync('swift', [join(dir, 'Make.swift'), out, JSON.stringify({ w, h, rects })], { encoding: 'utf-8' });
@@ -178,8 +204,8 @@ describe('trim helper pixel logic (real toolchain)', () => {
178
204
  const out = join(dir, 'out.png');
179
205
  const aligned = join(dir, 'aligned.png');
180
206
  const result = trim(dir, input, out, aligned, reference);
181
- // The trimmed render is the content; the aligned copy is resized to the
182
- // reference pixel size for a 1:1 overlay.
207
+ // The trimmed render is the content; the aligned copy is fit onto a
208
+ // reference-sized canvas (preserving aspect) for a 1:1 overlay.
183
209
  expect(pngSize(out)).toEqual({ width: 60, height: 40 });
184
210
  expect(pngSize(aligned)).toEqual({ width: 100, height: 50 });
185
211
  // The TRIMDIMS line reports rendered (trimmed) / reference / pre-trim
@@ -190,5 +216,23 @@ describe('trim helper pixel logic (real toolchain)', () => {
190
216
  source: [200, 200],
191
217
  });
192
218
  }, 600_000);
219
+ realSwiftTest('fits a wide render into a square reference without distortion (transparent letterbox)', () => {
220
+ const dir = scene();
221
+ // Content fills its canvas: a 2:1 render (80x40), all opaque red.
222
+ const input = craft(dir, 'in.png', 80, 40, [{ x: 0, y: 0, w: 80, h: 40, ...RED }]);
223
+ // Square 1:1 reference (50x50) — a deliberately different aspect ratio.
224
+ const reference = craft(dir, 'ref.png', 50, 50, [{ x: 0, y: 0, w: 50, h: 50, ...BLUE }]);
225
+ const out = join(dir, 'out.png');
226
+ const aligned = join(dir, 'aligned.png');
227
+ trim(dir, input, out, aligned, reference);
228
+ // Aligned stays exactly reference-sized (overlay pair holds).
229
+ expect(pngSize(aligned)).toEqual({ width: 50, height: 50 });
230
+ // Aspect preserved: the 2:1 content fits by width (50x25) and is centered,
231
+ // so both letterbox bands are transparent while the center is opaque
232
+ // content. A stretch-to-fit would make every one of these opaque.
233
+ expect(alphaAt(dir, aligned, 25, 3)).toBe(0); // top band
234
+ expect(alphaAt(dir, aligned, 25, 46)).toBe(0); // bottom band
235
+ expect(alphaAt(dir, aligned, 25, 25)).toBe(255); // content (center)
236
+ }, 600_000);
193
237
  });
194
238
  //# sourceMappingURL=render-post-processor.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ui-fidelity-preview-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAsBH,OAAO,KAAK,EAEV,uBAAuB,EACvB,uBAAuB,EAExB,MAAM,0BAA0B,CAAC;AAElC,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAkBD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,8BAA8B,CAAC;AA4CrE;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,uBAAuB,CAsKzB"}
1
+ {"version":3,"file":"ui-fidelity-preview-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAsBH,OAAO,KAAK,EAEV,uBAAuB,EACvB,uBAAuB,EAExB,MAAM,0BAA0B,CAAC;AAElC,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAkBD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,8BAA8B,CAAC;AAqErE;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,uBAAuB,CA6KzB"}
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
23
- import { tmpdir } from 'node:os';
23
+ import { homedir, tmpdir } from 'node:os';
24
24
  import { basename, join, resolve } from 'node:path';
25
25
  import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
26
26
  import { DEFAULT_GRADLE_TASK, PACKAGE_ARCHIVE_BASENAME, generateAndroidRenderScript, isValidGradleTask, } from './ui-fidelity-render-android.js';
@@ -51,19 +51,44 @@ function commandExists(command, pathPrepend) {
51
51
  : spawnSync(`command -v ${command}`, { encoding: 'utf-8', env, shell: true });
52
52
  return probe.status === 0 && (probe.stdout || '').trim() !== '';
53
53
  }
54
+ /**
55
+ * Resolve the Android SDK directory: prefer an explicit `ANDROID_HOME` /
56
+ * `ANDROID_SDK_ROOT` env var (when it points at a real directory), otherwise
57
+ * fall back to the platform's default install location. This mirrors the
58
+ * auto-detect the Android build step already does, so a dev machine with a
59
+ * standard Android Studio install previews locally without the caller (or the
60
+ * MCP server's launch environment) having to export `ANDROID_HOME`. Returns the
61
+ * resolved path, or null when no SDK can be found.
62
+ */
63
+ function resolveAndroidSdk() {
64
+ const fromEnv = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '';
65
+ if (fromEnv && existsSync(fromEnv))
66
+ return fromEnv;
67
+ const home = process.env.HOME || homedir();
68
+ const candidates = [
69
+ join(home, 'Library', 'Android', 'sdk'), // macOS
70
+ join(home, 'Android', 'Sdk'), // Linux
71
+ '/usr/local/android-sdk',
72
+ ];
73
+ for (const candidate of candidates) {
74
+ if (existsSync(candidate))
75
+ return candidate;
76
+ }
77
+ return null;
78
+ }
54
79
  /**
55
80
  * Report which env-level Android prerequisites are missing: the JVM (`java`) and
56
- * the SDK (`ANDROID_HOME`/`ANDROID_SDK_ROOT`). Gradle itself ships inside the
81
+ * the SDK (a resolved [resolveAndroidSdk] path). Gradle itself ships inside the
57
82
  * render project (gradlew), so it is not an env-level prerequisite. Returns an
58
83
  * empty array when the toolchain is present.
59
84
  */
60
- function missingToolchain(pathPrepend) {
85
+ function missingToolchain(sdk, pathPrepend) {
61
86
  const missing = [];
62
87
  if (!commandExists('java', pathPrepend))
63
88
  missing.push('a JDK (java not on PATH)');
64
- const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '';
65
- if (!sdk || !existsSync(sdk))
66
- missing.push('the Android SDK (ANDROID_HOME not set)');
89
+ if (!sdk) {
90
+ missing.push('the Android SDK (ANDROID_HOME not set and no SDK at the default location)');
91
+ }
67
92
  return missing;
68
93
  }
69
94
  /** Resolve an artifact-relative path to absolute, or null. */
@@ -122,7 +147,8 @@ export function renderUiFidelityPreviewAndroid(options) {
122
147
  // A render is a heavy Gradle/Robolectric build; if the dev machine has no
123
148
  // local Android toolchain at all, fall back to a remote run with a clear
124
149
  // message instead of letting the build fail confusingly.
125
- const missing = missingToolchain(options.toolchainPath);
150
+ const androidSdk = resolveAndroidSdk();
151
+ const missing = missingToolchain(androidSdk, options.toolchainPath);
126
152
  if (missing.length > 0) {
127
153
  return {
128
154
  packageSource: 'inputs',
@@ -187,6 +213,13 @@ export function renderUiFidelityPreviewAndroid(options) {
187
213
  if (options.toolchainPath) {
188
214
  env.PATH = options.toolchainPath + ':' + (process.env.PATH ?? '');
189
215
  }
216
+ // Export the resolved SDK so gradlew sees it even when ANDROID_HOME was
217
+ // auto-detected (env unset) rather than already present in process.env.
218
+ if (androidSdk) {
219
+ env.ANDROID_HOME = androidSdk;
220
+ if (!process.env.ANDROID_SDK_ROOT)
221
+ env.ANDROID_SDK_ROOT = androidSdk;
222
+ }
190
223
  const run = spawnSync('node', ['-e', script], {
191
224
  cwd: workDir,
192
225
  env,
@@ -189,8 +189,13 @@ describe('renderUiFidelityPreviewAndroid — missing local toolchain', () => {
189
189
  test('reports a clear toolchain-missing build error instead of crashing', () => {
190
190
  const savedHome = process.env.ANDROID_HOME;
191
191
  const savedRoot = process.env.ANDROID_SDK_ROOT;
192
+ const savedUserHome = process.env.HOME;
192
193
  delete process.env.ANDROID_HOME;
193
194
  delete process.env.ANDROID_SDK_ROOT;
195
+ // Point HOME at an empty dir so the default-location auto-detect
196
+ // (~/Library/Android/sdk, ~/Android/Sdk) finds nothing — otherwise a dev
197
+ // machine with the SDK installed would resolve one and this guard wouldn't fire.
198
+ process.env.HOME = tempDir('ui-fidelity-android-emptyhome-');
194
199
  try {
195
200
  const result = renderUiFidelityPreviewAndroid({
196
201
  packagePath: gradleProject(),
@@ -211,6 +216,52 @@ describe('renderUiFidelityPreviewAndroid — missing local toolchain', () => {
211
216
  delete process.env.ANDROID_SDK_ROOT;
212
217
  else
213
218
  process.env.ANDROID_SDK_ROOT = savedRoot;
219
+ if (savedUserHome === undefined)
220
+ delete process.env.HOME;
221
+ else
222
+ process.env.HOME = savedUserHome;
223
+ }
224
+ });
225
+ test('auto-detects the SDK at the default install location when ANDROID_HOME is unset', () => {
226
+ const savedHome = process.env.ANDROID_HOME;
227
+ const savedRoot = process.env.ANDROID_SDK_ROOT;
228
+ const savedUserHome = process.env.HOME;
229
+ const savedScreens = process.env.FAKE_ROBORAZZI_SCREENS;
230
+ delete process.env.ANDROID_HOME;
231
+ delete process.env.ANDROID_SDK_ROOT;
232
+ // A clean HOME with an SDK at the macOS default location, but no ANDROID_HOME.
233
+ const fakeHome = tempDir('ui-fidelity-android-home-');
234
+ mkdirSync(join(fakeHome, 'Library', 'Android', 'sdk'), { recursive: true });
235
+ process.env.HOME = fakeHome;
236
+ process.env.FAKE_ROBORAZZI_SCREENS = 'XProof';
237
+ try {
238
+ const result = renderUiFidelityPreviewAndroid({
239
+ packagePath: gradleProject(),
240
+ screens: [{ screen: 'XProof', reference: writeReference() }],
241
+ toolchainPath: fakeToolchain(),
242
+ });
243
+ // Auto-detected the SDK and rendered, rather than bailing with TOOLCHAIN_MISSING.
244
+ expect(result.buildError).toBeNull();
245
+ expect(result.ok).toBe(true);
246
+ expect(result.screens[0]?.status).toBe('rendered');
247
+ }
248
+ finally {
249
+ if (savedHome === undefined)
250
+ delete process.env.ANDROID_HOME;
251
+ else
252
+ process.env.ANDROID_HOME = savedHome;
253
+ if (savedRoot === undefined)
254
+ delete process.env.ANDROID_SDK_ROOT;
255
+ else
256
+ process.env.ANDROID_SDK_ROOT = savedRoot;
257
+ if (savedUserHome === undefined)
258
+ delete process.env.HOME;
259
+ else
260
+ process.env.HOME = savedUserHome;
261
+ if (savedScreens === undefined)
262
+ delete process.env.FAKE_ROBORAZZI_SCREENS;
263
+ else
264
+ process.env.FAKE_ROBORAZZI_SCREENS = savedScreens;
214
265
  }
215
266
  });
216
267
  });
@@ -218,7 +269,8 @@ describe('renderUiFidelityPreviewAndroid — missing local toolchain', () => {
218
269
  // toolchain and asserts a non-blank paired render at the expected size. Requires
219
270
  // the Android toolchain (JDK + SDK) and warm Roborazzi caches; gated behind
220
271
  // CIBUILD_UI_FIDELITY_REAL_ANDROID=1 (the Android analog of the iOS real-Swift
221
- // gate). It needs ANDROID_HOME set on the dev machine.
272
+ // gate). ANDROID_HOME need not be exported a standard SDK install at the
273
+ // default location (~/Library/Android/sdk etc.) is auto-detected.
222
274
  describe('renderUiFidelityPreviewAndroid — real toolchain', () => {
223
275
  const realAndroidTest = process.env.CIBUILD_UI_FIDELITY_REAL_ANDROID === '1' ? test : test.skip;
224
276
  // A real, decodable 1x1 PNG so the trim helper's reference load/align succeeds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.0.9",
3
+ "version": "2.1.0",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",