ralph-gate 0.1.1

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/dist/cli.js ADDED
@@ -0,0 +1,866 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import path3 from "path";
5
+
6
+ // src/config.ts
7
+ import { promises as fs } from "fs";
8
+ import path from "path";
9
+ var CONFIG_FILES = ["gate.config.json", ".gaterc.json", ".gaterc"];
10
+ function normalizeGate(gate) {
11
+ const order = typeof gate.order === "number" ? gate.order : 100;
12
+ const enabled = typeof gate.enabled === "boolean" ? gate.enabled : true;
13
+ const blocking = typeof gate.blocking === "boolean" ? gate.blocking : true;
14
+ return {
15
+ ...gate,
16
+ order,
17
+ enabled,
18
+ blocking,
19
+ description: typeof gate.description === "string" ? gate.description : void 0,
20
+ name: gate.name,
21
+ command: gate.command
22
+ };
23
+ }
24
+ function validateGate(gate) {
25
+ if (!gate || typeof gate !== "object") {
26
+ return "Gate entries must be objects.";
27
+ }
28
+ const maybeGate = gate;
29
+ if (typeof maybeGate.name !== "string" || maybeGate.name.trim() === "") {
30
+ return "Gate is missing required field: name.";
31
+ }
32
+ if (typeof maybeGate.command !== "string" || maybeGate.command.trim() === "") {
33
+ return `Gate '${maybeGate.name}' is missing required field: command.`;
34
+ }
35
+ return null;
36
+ }
37
+ function sortGates(gates) {
38
+ const withIndex = gates.map((gate, index) => ({ gate, index }));
39
+ withIndex.sort((a, b) => {
40
+ const orderA = typeof a.gate.order === "number" ? a.gate.order : 100;
41
+ const orderB = typeof b.gate.order === "number" ? b.gate.order : 100;
42
+ if (orderA !== orderB) {
43
+ return orderA - orderB;
44
+ }
45
+ return a.index - b.index;
46
+ });
47
+ return withIndex.map((entry) => entry.gate);
48
+ }
49
+ async function loadConfig(cwd = process.cwd()) {
50
+ for (const filename of CONFIG_FILES) {
51
+ const filePath = path.join(cwd, filename);
52
+ try {
53
+ await fs.access(filePath);
54
+ } catch {
55
+ continue;
56
+ }
57
+ let raw;
58
+ try {
59
+ raw = await fs.readFile(filePath, "utf8");
60
+ } catch (error) {
61
+ return {
62
+ config: null,
63
+ configPath: filePath,
64
+ error: `Invalid config: unable to read ${filename}: ${error.message}`
65
+ };
66
+ }
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(raw);
70
+ } catch (error) {
71
+ return {
72
+ config: null,
73
+ configPath: filePath,
74
+ error: `Invalid config: malformed JSON in ${filename}: ${error.message}`
75
+ };
76
+ }
77
+ if (!parsed || typeof parsed !== "object") {
78
+ return {
79
+ config: null,
80
+ configPath: filePath,
81
+ error: `Invalid config: expected object in ${filename}.`
82
+ };
83
+ }
84
+ const config = parsed;
85
+ if (!Array.isArray(config.gates)) {
86
+ return {
87
+ config: null,
88
+ configPath: filePath,
89
+ error: `Invalid config: missing required 'gates' array in ${filename}.`
90
+ };
91
+ }
92
+ const normalized = [];
93
+ for (const gate of config.gates) {
94
+ const gateError = validateGate(gate);
95
+ if (gateError) {
96
+ return {
97
+ config: null,
98
+ configPath: filePath,
99
+ error: `Invalid config: ${gateError}`
100
+ };
101
+ }
102
+ const normalizedGate = normalizeGate(gate);
103
+ if (normalizedGate.enabled === false) {
104
+ continue;
105
+ }
106
+ normalized.push(normalizedGate);
107
+ }
108
+ const failFast = typeof config.failFast === "boolean" ? config.failFast : true;
109
+ const outputPath = typeof config.outputPath === "string" ? config.outputPath : void 0;
110
+ const sorted = sortGates(normalized);
111
+ return {
112
+ config: {
113
+ gates: sorted,
114
+ failFast,
115
+ outputPath
116
+ },
117
+ configPath: filePath
118
+ };
119
+ }
120
+ return { config: null };
121
+ }
122
+
123
+ // src/init.ts
124
+ import { promises as fs2 } from "fs";
125
+ import { execSync } from "child_process";
126
+ import path2 from "path";
127
+ var DEFAULT_CONFIG_FILENAME = "gate.config.json";
128
+ async function fileExists(filePath) {
129
+ try {
130
+ await fs2.access(filePath);
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+ async function detectPackageManager(cwd) {
137
+ if (await fileExists(path2.join(cwd, "pnpm-lock.yaml"))) {
138
+ return "pnpm";
139
+ }
140
+ if (await fileExists(path2.join(cwd, "yarn.lock"))) {
141
+ return "yarn";
142
+ }
143
+ return "npm";
144
+ }
145
+ function runScriptCommand(packageManager, scriptName) {
146
+ switch (packageManager) {
147
+ case "yarn":
148
+ return `yarn ${scriptName}`;
149
+ case "pnpm":
150
+ return `pnpm run ${scriptName}`;
151
+ default:
152
+ return `npm run ${scriptName}`;
153
+ }
154
+ }
155
+ async function readJsonFile(filePath) {
156
+ const raw = await fs2.readFile(filePath, "utf8");
157
+ return JSON.parse(raw);
158
+ }
159
+ function extractRequirementName(line) {
160
+ const trimmed = line.trim();
161
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
162
+ return null;
163
+ }
164
+ const noEnvMarker = trimmed.split(";", 1)[0]?.trim() ?? "";
165
+ if (noEnvMarker.length === 0) {
166
+ return null;
167
+ }
168
+ const name = noEnvMarker.split(/[<>=\[]/, 1)[0]?.trim();
169
+ if (!name) {
170
+ return null;
171
+ }
172
+ return name.toLowerCase();
173
+ }
174
+ async function detectProject(cwd) {
175
+ const packageJsonPath = path2.join(cwd, "package.json");
176
+ if (await fileExists(packageJsonPath)) {
177
+ const pm = await detectPackageManager(cwd);
178
+ const parsed = await readJsonFile(packageJsonPath);
179
+ if (!parsed || typeof parsed !== "object") {
180
+ throw new Error("package.json is not a JSON object.");
181
+ }
182
+ return {
183
+ kind: "node",
184
+ packageManager: pm,
185
+ packageJson: parsed
186
+ };
187
+ }
188
+ const requirementsPath = path2.join(cwd, "requirements.txt");
189
+ const pyprojectPath = path2.join(cwd, "pyproject.toml");
190
+ if (await fileExists(requirementsPath) || await fileExists(pyprojectPath)) {
191
+ const requirements = /* @__PURE__ */ new Set();
192
+ if (await fileExists(requirementsPath)) {
193
+ const raw = await fs2.readFile(requirementsPath, "utf8");
194
+ for (const line of raw.split(/\r?\n/)) {
195
+ const name = extractRequirementName(line);
196
+ if (name) {
197
+ requirements.add(name);
198
+ }
199
+ }
200
+ }
201
+ return { kind: "python", requirements };
202
+ }
203
+ return { kind: "unknown" };
204
+ }
205
+ function getPackageJsonScripts(packageJson) {
206
+ const scripts = packageJson.scripts;
207
+ if (!scripts || typeof scripts !== "object") {
208
+ return {};
209
+ }
210
+ const result = {};
211
+ for (const [key, value] of Object.entries(
212
+ scripts
213
+ )) {
214
+ if (typeof value === "string") {
215
+ result[key] = value;
216
+ }
217
+ }
218
+ return result;
219
+ }
220
+ function getPackageJsonDeps(packageJson) {
221
+ const deps = /* @__PURE__ */ new Set();
222
+ const sections = [
223
+ "dependencies",
224
+ "devDependencies",
225
+ "peerDependencies",
226
+ "optionalDependencies"
227
+ ];
228
+ for (const section of sections) {
229
+ const values = packageJson[section];
230
+ if (!values || typeof values !== "object") {
231
+ continue;
232
+ }
233
+ for (const name of Object.keys(values)) {
234
+ deps.add(name);
235
+ }
236
+ }
237
+ return deps;
238
+ }
239
+ async function inferNodeGates(cwd, packageManager, packageJson, warnings) {
240
+ const gates = [];
241
+ const scripts = getPackageJsonScripts(packageJson);
242
+ const deps = getPackageJsonDeps(packageJson);
243
+ const addIfScript = (name, order) => {
244
+ if (!scripts[name]) {
245
+ return;
246
+ }
247
+ gates.push({
248
+ name,
249
+ order,
250
+ command: runScriptCommand(packageManager, name)
251
+ });
252
+ };
253
+ addIfScript("lint", 10);
254
+ addIfScript("typecheck", 20);
255
+ addIfScript("test", 30);
256
+ addIfScript("build", 40);
257
+ const tsconfigPath = path2.join(cwd, "tsconfig.json");
258
+ const hasTypescript = deps.has("typescript");
259
+ if (hasTypescript && await fileExists(tsconfigPath) && !scripts.typecheck) {
260
+ warnings.push(
261
+ "No 'typecheck' script found; generating a default tsc gate."
262
+ );
263
+ gates.splice(1, 0, {
264
+ name: "typecheck",
265
+ order: 20,
266
+ command: "npx tsc -p tsconfig.json --noEmit"
267
+ });
268
+ }
269
+ return gates;
270
+ }
271
+ function inferPythonGates(requirements, warnings) {
272
+ const gates = [];
273
+ if (requirements.has("ruff")) {
274
+ gates.push({ name: "lint", order: 10, command: "python -m ruff check ." });
275
+ }
276
+ if (requirements.has("mypy")) {
277
+ gates.push({ name: "typecheck", order: 20, command: "python -m mypy ." });
278
+ }
279
+ if (requirements.has("pytest")) {
280
+ gates.push({ name: "test", order: 30, command: "python -m pytest -q" });
281
+ } else {
282
+ warnings.push(
283
+ "pytest not detected in requirements; no test gate generated."
284
+ );
285
+ }
286
+ return gates;
287
+ }
288
+ async function updateGitignore(cwd) {
289
+ const gitignorePath = path2.join(cwd, ".gitignore");
290
+ const pattern = "gate-results-*.json";
291
+ try {
292
+ execSync("git check-ignore -q gate-results-test.json", {
293
+ cwd,
294
+ stdio: "ignore"
295
+ });
296
+ return false;
297
+ } catch {
298
+ }
299
+ try {
300
+ const exists = await fileExists(gitignorePath);
301
+ const content = exists ? await fs2.readFile(gitignorePath, "utf8") : "";
302
+ const newline = content && !content.endsWith("\n") ? "\n" : "";
303
+ await fs2.appendFile(gitignorePath, `${newline}${pattern}
304
+ `, "utf8");
305
+ return true;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+ async function generateGateConfig(cwd) {
311
+ const warnings = [];
312
+ const detected = await detectProject(cwd);
313
+ let gates = [];
314
+ if (detected.kind === "node") {
315
+ gates = await inferNodeGates(
316
+ cwd,
317
+ detected.packageManager,
318
+ detected.packageJson,
319
+ warnings
320
+ );
321
+ } else if (detected.kind === "python") {
322
+ gates = inferPythonGates(detected.requirements, warnings);
323
+ } else {
324
+ warnings.push(
325
+ "No supported project markers found; creating an empty gate config."
326
+ );
327
+ }
328
+ return {
329
+ config: {
330
+ gates,
331
+ failFast: true
332
+ },
333
+ projectKind: detected.kind,
334
+ warnings
335
+ };
336
+ }
337
+ async function initConfigFile(options = {}) {
338
+ const cwd = options.cwd ?? process.cwd();
339
+ const filename = options.filename ?? DEFAULT_CONFIG_FILENAME;
340
+ const configPath = path2.join(cwd, filename);
341
+ const { config, projectKind, warnings } = await generateGateConfig(cwd);
342
+ if (options.print) {
343
+ return {
344
+ config,
345
+ configPath,
346
+ created: false,
347
+ projectKind,
348
+ warnings
349
+ };
350
+ }
351
+ const writeFlag = options.force ? "w" : "wx";
352
+ try {
353
+ await fs2.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", {
354
+ encoding: "utf8",
355
+ flag: writeFlag
356
+ });
357
+ } catch (error) {
358
+ const message = error instanceof Error ? error.message : String(error);
359
+ const code = error?.code;
360
+ if (!options.force && code === "EEXIST") {
361
+ return {
362
+ config,
363
+ configPath,
364
+ created: false,
365
+ projectKind,
366
+ warnings,
367
+ error: `Config already exists at ${configPath}. Use --force to overwrite.`
368
+ };
369
+ }
370
+ return {
371
+ config,
372
+ configPath,
373
+ created: false,
374
+ projectKind,
375
+ warnings,
376
+ error: `Unable to write config at ${configPath}: ${message}`
377
+ };
378
+ }
379
+ const gitignoreUpdated = await updateGitignore(cwd);
380
+ return {
381
+ config,
382
+ configPath,
383
+ created: true,
384
+ projectKind,
385
+ warnings,
386
+ gitignoreUpdated
387
+ };
388
+ }
389
+
390
+ // src/runner.ts
391
+ import { spawn } from "child_process";
392
+ function resolveShell(shell) {
393
+ if (typeof shell === "string" && shell.length > 0) {
394
+ return shell;
395
+ }
396
+ const envShell = process.env.SHELL;
397
+ return envShell && envShell.length > 0 ? envShell : true;
398
+ }
399
+ async function runGate(gate, options) {
400
+ const start = Date.now();
401
+ const shell = resolveShell(options.shell);
402
+ const env = process.env;
403
+ return new Promise((resolve) => {
404
+ options.onGateStart?.(gate);
405
+ const child = spawn(gate.command, {
406
+ shell,
407
+ env,
408
+ cwd: options.cwd
409
+ });
410
+ let stdout = "";
411
+ let stderr = "";
412
+ let exitCode = null;
413
+ let resolved = false;
414
+ const finalize = () => {
415
+ if (resolved) {
416
+ return;
417
+ }
418
+ resolved = true;
419
+ const durationMs = Date.now() - start;
420
+ const result = {
421
+ name: gate.name,
422
+ passed: exitCode === 0,
423
+ exitCode,
424
+ stdout,
425
+ stderr,
426
+ durationMs,
427
+ skipped: false,
428
+ blocking: gate.blocking !== false,
429
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
430
+ };
431
+ options.onGateComplete?.(result);
432
+ resolve(result);
433
+ };
434
+ child.stdout?.on("data", (chunk) => {
435
+ const text = chunk.toString();
436
+ stdout += text;
437
+ if (options.verbose) {
438
+ process.stdout.write(text);
439
+ }
440
+ options.onGateOutput?.(gate, "stdout", text);
441
+ });
442
+ child.stderr?.on("data", (chunk) => {
443
+ const text = chunk.toString();
444
+ stderr += text;
445
+ if (options.verbose) {
446
+ process.stderr.write(text);
447
+ }
448
+ options.onGateOutput?.(gate, "stderr", text);
449
+ });
450
+ child.on("error", (error) => {
451
+ stderr += error.message;
452
+ exitCode = null;
453
+ finalize();
454
+ });
455
+ child.on("close", (code) => {
456
+ exitCode = code;
457
+ finalize();
458
+ });
459
+ });
460
+ }
461
+ async function runGates(gates, options = {}) {
462
+ const results = [];
463
+ const warnings = [];
464
+ const start = Date.now();
465
+ let firstFailure = null;
466
+ for (const gate of gates) {
467
+ const isBlocking = gate.blocking !== false;
468
+ const shouldSkip = options.failFast !== false && firstFailure !== null && isBlocking;
469
+ if (shouldSkip) {
470
+ const skippedResult = {
471
+ name: gate.name,
472
+ passed: true,
473
+ exitCode: null,
474
+ stdout: "",
475
+ stderr: "",
476
+ durationMs: 0,
477
+ skipped: true,
478
+ blocking: isBlocking,
479
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
480
+ };
481
+ results.push(skippedResult);
482
+ options.onGateComplete?.(skippedResult);
483
+ continue;
484
+ }
485
+ const result = await runGate(gate, options);
486
+ results.push(result);
487
+ if (!result.passed) {
488
+ if (result.blocking) {
489
+ if (!firstFailure) {
490
+ firstFailure = result;
491
+ }
492
+ } else {
493
+ warnings.push(result.name);
494
+ }
495
+ }
496
+ }
497
+ const totalDurationMs = Date.now() - start;
498
+ const passed = firstFailure === null;
499
+ return {
500
+ passed,
501
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
502
+ totalDurationMs,
503
+ results,
504
+ firstFailure,
505
+ warnings
506
+ };
507
+ }
508
+
509
+ // src/output.ts
510
+ import { promises as fs3 } from "fs";
511
+ var MAX_FAILURE_CHARS = 4e3;
512
+ var HEAD_RATIO = 0.6;
513
+ function truncateOutput(text, maxChars) {
514
+ const trimmed = text.trimEnd();
515
+ if (maxChars <= 0 || trimmed.length === 0) {
516
+ return "";
517
+ }
518
+ if (trimmed.length <= maxChars) {
519
+ return trimmed;
520
+ }
521
+ const headChars = Math.max(1, Math.floor(maxChars * HEAD_RATIO));
522
+ const tailChars = Math.max(0, maxChars - headChars);
523
+ const omitted = trimmed.length - maxChars;
524
+ const marker = `
525
+ ...<${omitted} chars omitted>...
526
+ `;
527
+ const head = trimmed.slice(0, headChars);
528
+ const tail = tailChars > 0 ? trimmed.slice(-tailChars) : "";
529
+ return `${head}${marker}${tail}`;
530
+ }
531
+ function splitBudget(stdoutLen, stderrLen) {
532
+ if (stdoutLen === 0 && stderrLen === 0) {
533
+ return { stdout: 0, stderr: 0 };
534
+ }
535
+ if (stdoutLen === 0) {
536
+ return { stdout: 0, stderr: MAX_FAILURE_CHARS };
537
+ }
538
+ if (stderrLen === 0) {
539
+ return { stdout: MAX_FAILURE_CHARS, stderr: 0 };
540
+ }
541
+ const base = Math.floor(MAX_FAILURE_CHARS / 2);
542
+ let stdout = Math.min(stdoutLen, base);
543
+ let stderr = Math.min(stderrLen, base);
544
+ let remaining = MAX_FAILURE_CHARS - stdout - stderr;
545
+ if (remaining > 0) {
546
+ const stdoutRemaining = stdoutLen - stdout;
547
+ const stderrRemaining = stderrLen - stderr;
548
+ if (stdoutRemaining === 0) {
549
+ stderr += remaining;
550
+ } else if (stderrRemaining === 0) {
551
+ stdout += remaining;
552
+ } else {
553
+ const totalRemaining = stdoutRemaining + stderrRemaining;
554
+ const stdoutExtra = Math.round(
555
+ stdoutRemaining / totalRemaining * remaining
556
+ );
557
+ stdout += stdoutExtra;
558
+ stderr += remaining - stdoutExtra;
559
+ }
560
+ }
561
+ return { stdout, stderr };
562
+ }
563
+ function colorize(text, code, enabled) {
564
+ if (!enabled) {
565
+ return text;
566
+ }
567
+ return `\x1B[${code}m${text}\x1B[0m`;
568
+ }
569
+ function formatResultLine(result, color) {
570
+ const statusSymbol = result.skipped ? "\u2298" : result.passed ? "\u2713" : "\u2717";
571
+ let symbolColor = "32";
572
+ if (result.skipped) {
573
+ symbolColor = "90";
574
+ } else if (!result.passed) {
575
+ symbolColor = "31";
576
+ }
577
+ const parts = [];
578
+ if (result.skipped) {
579
+ parts.push("skipped");
580
+ } else if (!result.passed) {
581
+ parts.push(`exit ${result.exitCode ?? "null"}`);
582
+ }
583
+ parts.push(`${result.durationMs}ms`);
584
+ const details = parts.length > 0 ? ` (${parts.join(", ")})` : "";
585
+ const coloredSymbol = colorize(statusSymbol, symbolColor, color);
586
+ return `${coloredSymbol} ${result.name}${details}`;
587
+ }
588
+ function formatFailureContext(stderr, stdout = "") {
589
+ const trimmedStderr = stderr.trimEnd();
590
+ const trimmedStdout = stdout.trimEnd();
591
+ if (trimmedStderr.length === 0 && trimmedStdout.length === 0) {
592
+ return "No output captured.";
593
+ }
594
+ if (trimmedStderr.length > 0 && trimmedStdout.length > 0) {
595
+ const budget = splitBudget(trimmedStdout.length, trimmedStderr.length);
596
+ const stderrSection = truncateOutput(trimmedStderr, budget.stderr);
597
+ const stdoutSection = truncateOutput(trimmedStdout, budget.stdout);
598
+ return `STDERR:
599
+ ${stderrSection}
600
+
601
+ STDOUT:
602
+ ${stdoutSection}`;
603
+ }
604
+ if (trimmedStderr.length > 0) {
605
+ return truncateOutput(trimmedStderr, MAX_FAILURE_CHARS);
606
+ }
607
+ return truncateOutput(trimmedStdout, MAX_FAILURE_CHARS);
608
+ }
609
+ function formatConsoleOutput(summary) {
610
+ const color = Boolean(process.stdout.isTTY);
611
+ const lines = [];
612
+ for (const result of summary.results) {
613
+ lines.push(formatResultLine(result, color));
614
+ }
615
+ if (summary.warnings.length > 0) {
616
+ const warningLine = `Warnings: ${summary.warnings.join(", ")}`;
617
+ lines.push(colorize(warningLine, "33", color));
618
+ }
619
+ const finalLine = summary.passed ? colorize("All blocking gates passed.", "32", color) : colorize("Blocking gate failed.", "31", color);
620
+ lines.push(finalLine);
621
+ return lines.join("\n");
622
+ }
623
+ async function writeResultsFile(summary, outputPath) {
624
+ const data = JSON.stringify(summary, null, 2);
625
+ await fs3.writeFile(outputPath, data, "utf8");
626
+ }
627
+
628
+ // src/hook.ts
629
+ function generateHookResponse(summary) {
630
+ const warnings = summary.warnings.length > 0 ? summary.warnings : void 0;
631
+ if (summary.passed) {
632
+ return warnings ? { warnings } : {};
633
+ }
634
+ const failure = summary.firstFailure;
635
+ if (failure) {
636
+ const reason2 = `Gate '${failure.name}' failed (exit ${failure.exitCode ?? "null"}):
637
+ ${formatFailureContext(failure.stderr, failure.stdout)}`;
638
+ return warnings ? { decision: "block", reason: reason2, warnings } : { decision: "block", reason: reason2 };
639
+ }
640
+ const reason = "Gate run failed without a blocking gate result.";
641
+ return warnings ? { decision: "block", reason, warnings } : { decision: "block", reason };
642
+ }
643
+ function outputHookResponse(output) {
644
+ process.stdout.write(`${JSON.stringify(output)}
645
+ `);
646
+ }
647
+
648
+ // src/cli.ts
649
+ function parseArgs(args) {
650
+ const options = {
651
+ hook: false,
652
+ dryRun: false,
653
+ verbose: false
654
+ };
655
+ for (let i = 0; i < args.length; i += 1) {
656
+ const arg = args[i];
657
+ switch (arg) {
658
+ case "--hook":
659
+ options.hook = true;
660
+ break;
661
+ case "--dry-run":
662
+ options.dryRun = true;
663
+ break;
664
+ case "--verbose":
665
+ options.verbose = true;
666
+ break;
667
+ case "--only": {
668
+ const value = args[i + 1];
669
+ if (!value) {
670
+ return { options, error: "Missing value for --only." };
671
+ }
672
+ options.only = value;
673
+ i += 1;
674
+ break;
675
+ }
676
+ default:
677
+ return { options, error: `Unknown argument: ${arg}` };
678
+ }
679
+ }
680
+ return { options };
681
+ }
682
+ function parseInitArgs(args) {
683
+ const options = {
684
+ force: false,
685
+ print: false
686
+ };
687
+ for (let i = 0; i < args.length; i += 1) {
688
+ const arg = args[i];
689
+ switch (arg) {
690
+ case "--force":
691
+ options.force = true;
692
+ break;
693
+ case "--print":
694
+ options.print = true;
695
+ break;
696
+ default:
697
+ return { options, error: `Unknown argument: ${arg}` };
698
+ }
699
+ }
700
+ return { options };
701
+ }
702
+ function defaultOutputPath() {
703
+ return `gate-results-${process.pid}.json`;
704
+ }
705
+ function createEmptySummary(passed) {
706
+ return {
707
+ passed,
708
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
709
+ totalDurationMs: 0,
710
+ results: [],
711
+ firstFailure: null,
712
+ warnings: []
713
+ };
714
+ }
715
+ function formatDryRun(gates, shell) {
716
+ const lines = [`SHELL: ${shell}`];
717
+ if (gates.length === 0) {
718
+ lines.push("No gates to run.");
719
+ return lines.join("\n");
720
+ }
721
+ for (const gate of gates) {
722
+ const order = typeof gate.order === "number" ? gate.order : 100;
723
+ lines.push(`- ${gate.name} (order ${order}): ${gate.command}`);
724
+ }
725
+ return lines.join("\n");
726
+ }
727
+ function createHookProgressReporter(enabled) {
728
+ if (!enabled) {
729
+ return {};
730
+ }
731
+ const prefix = "[ralph-gate]";
732
+ const writeLine = (line) => {
733
+ process.stderr.write(`${prefix} ${line}
734
+ `);
735
+ };
736
+ return {
737
+ onGateStart: (gate) => {
738
+ writeLine(`running ${gate.name}: ${gate.command}`);
739
+ },
740
+ onGateOutput: (_gate, _stream, text) => {
741
+ process.stderr.write(text);
742
+ },
743
+ onGateComplete: (result) => {
744
+ if (result.skipped) {
745
+ writeLine(`${result.name} skipped`);
746
+ return;
747
+ }
748
+ const status = result.passed ? `passed in ${result.durationMs}ms` : `failed (exit ${result.exitCode ?? "null"}) in ${result.durationMs}ms`;
749
+ writeLine(`${result.name} ${status}`);
750
+ }
751
+ };
752
+ }
753
+ async function main() {
754
+ const argv = process.argv.slice(2);
755
+ if (argv[0] === "init") {
756
+ const { options: options2, error: error2 } = parseInitArgs(argv.slice(1));
757
+ if (error2) {
758
+ console.error(error2);
759
+ process.exitCode = 1;
760
+ return;
761
+ }
762
+ const result = await initConfigFile({
763
+ force: options2.force,
764
+ print: options2.print
765
+ });
766
+ if (result.error) {
767
+ console.error(result.error);
768
+ process.exitCode = 1;
769
+ return;
770
+ }
771
+ for (const warning of result.warnings) {
772
+ console.error(`Warning: ${warning}`);
773
+ }
774
+ if (options2.print) {
775
+ console.log(JSON.stringify(result.config, null, 2));
776
+ return;
777
+ }
778
+ console.log(
779
+ `Created ${path3.basename(result.configPath)} with ${result.config.gates.length} gate(s).`
780
+ );
781
+ if (result.gitignoreUpdated) {
782
+ console.log("Updated .gitignore to exclude gate-results-*.json files.");
783
+ }
784
+ return;
785
+ }
786
+ const { options, error } = parseArgs(argv);
787
+ if (error) {
788
+ console.error(error);
789
+ process.exitCode = 1;
790
+ return;
791
+ }
792
+ const configResult = await loadConfig();
793
+ if (configResult.error) {
794
+ const summary2 = createEmptySummary(false);
795
+ const outputPath2 = defaultOutputPath();
796
+ await writeResultsFile(summary2, outputPath2);
797
+ if (options.hook) {
798
+ outputHookResponse({ decision: "block", reason: configResult.error });
799
+ process.exitCode = 0;
800
+ return;
801
+ }
802
+ console.error(configResult.error);
803
+ process.exitCode = 1;
804
+ return;
805
+ }
806
+ if (!configResult.config) {
807
+ if (options.dryRun) {
808
+ const shellLabel2 = process.env.SHELL ?? "(default)";
809
+ console.log(formatDryRun([], shellLabel2));
810
+ }
811
+ if (options.hook) {
812
+ outputHookResponse({});
813
+ process.exitCode = 0;
814
+ }
815
+ return;
816
+ }
817
+ const config = configResult.config;
818
+ const shellLabel = process.env.SHELL ?? "(default)";
819
+ if (options.dryRun) {
820
+ console.log(formatDryRun(config.gates, shellLabel));
821
+ return;
822
+ }
823
+ let gates = config.gates;
824
+ if (options.only) {
825
+ const match = gates.find((gate) => gate.name === options.only);
826
+ if (!match) {
827
+ console.error(`Gate not found: ${options.only}`);
828
+ process.exitCode = 1;
829
+ return;
830
+ }
831
+ gates = [match];
832
+ }
833
+ const outputPath = config.outputPath ?? defaultOutputPath();
834
+ if (gates.length === 0) {
835
+ const summary2 = createEmptySummary(true);
836
+ await writeResultsFile(summary2, outputPath);
837
+ if (options.hook) {
838
+ outputHookResponse({});
839
+ process.exitCode = 0;
840
+ return;
841
+ }
842
+ console.log(formatConsoleOutput(summary2));
843
+ return;
844
+ }
845
+ const hookProgress = createHookProgressReporter(options.hook);
846
+ const summary = await runGates(gates, {
847
+ failFast: config.failFast,
848
+ verbose: options.verbose && !options.hook,
849
+ ...hookProgress
850
+ });
851
+ await writeResultsFile(summary, outputPath);
852
+ if (options.hook) {
853
+ outputHookResponse(generateHookResponse(summary));
854
+ process.exitCode = 0;
855
+ return;
856
+ }
857
+ console.log(formatConsoleOutput(summary));
858
+ if (!summary.passed) {
859
+ process.exitCode = 1;
860
+ }
861
+ }
862
+ main().catch((error) => {
863
+ console.error(error instanceof Error ? error.message : String(error));
864
+ process.exitCode = 1;
865
+ });
866
+ //# sourceMappingURL=cli.js.map