From c92e0d588d22c15e5d7ed314b0d8b5eb92bf9d82 Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:01:43 +0530 Subject: [PATCH 1/5] =?UTF-8?q?fix(cli):=20use=20the=20Node=20process=20gl?= =?UTF-8?q?obal=20=E2=80=94=20namespace=20import=20breaks=20signal=20handl?= =?UTF-8?q?ers=20on=20Node=2024+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CommonJS interop wrapper (__importStar) only copies an object's own properties. On Node >= 24, process.on/once/emit are inherited from EventEmitter.prototype, so 'import * as process' yields a module object without them and the TypeScript CLI crashes at startup when registering SIGINT/SIGTERM handlers. The Node global 'process' carries the full prototype chain. Co-Authored-By: Claude Fable 5 --- packages/client-typescript/src/cli/rocketride.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/client-typescript/src/cli/rocketride.ts b/packages/client-typescript/src/cli/rocketride.ts index a11befbcf..c92fc4856 100644 --- a/packages/client-typescript/src/cli/rocketride.ts +++ b/packages/client-typescript/src/cli/rocketride.ts @@ -67,10 +67,14 @@ * ``` */ +// NOTE: do not `import * as process from 'process'` here — the CommonJS +// interop wrapper (__importStar) only copies own properties, and on Node >= 24 +// process.on/once/emit are inherited from EventEmitter.prototype, so the +// wrapped module is missing them and the CLI crashes at startup. The Node +// global `process` has the full prototype chain. import * as fs from 'fs'; import * as path from 'path'; import * as glob from 'glob'; -import * as process from 'process'; import { Command } from 'commander'; import { RocketRideClient } from '../client/client'; import { DAPMessage, PipelineConfig, UPLOAD_RESULT } from '../client/types'; From 1675fc88ed01f5bce6ed7079ea5618ea689390ce Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:02:01 +0530 Subject: [PATCH 2/5] fix(ai): wrap rrext_validate payload in the {'pipeline': ...} envelope validatePipeline expects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DAP rrext_validate handler passed the pipeline config flat to rocketlib validatePipeline, but the C++ binding requires the config under a top-level 'pipeline' key — the envelope the HTTP path (modules/pipe pipe_Validate) already builds. As a result every rrext_validate call, including SDK client.validate(), failed with ccode 40 "'pipeline' is missing or invalid" regardless of the config's actual validity (verified against the published engine image v3.3.1.35 and current source). Wrap the resolved payload exactly like pipe_Validate does. Existing handler tests updated to assert the envelope, plus a regression test pinning the wire shape. Co-Authored-By: Claude Fable 5 --- .../src/ai/modules/task/commands/cmd_misc.py | 6 ++-- .../ai/modules/task/commands/test_cmd_misc.py | 34 ++++++++++++++++--- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/ai/modules/task/commands/cmd_misc.py b/packages/ai/src/ai/modules/task/commands/cmd_misc.py index 1549a281a..d1ca28170 100644 --- a/packages/ai/src/ai/modules/task/commands/cmd_misc.py +++ b/packages/ai/src/ai/modules/task/commands/cmd_misc.py @@ -219,8 +219,10 @@ async def on_rrext_validate(self, request: Dict[str, Any]) -> Dict[str, Any]: if source: inner['source'] = source - # Validate it - data = validatePipeline(inner) + # validatePipeline expects the config under a top-level 'pipeline' + # key — same envelope pipe_Validate (modules/pipe) builds; without + # it every config fails with "'pipeline' is missing or invalid" + data = validatePipeline({'pipeline': inner}) # Return the results return self.build_response(request, body=data) diff --git a/packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py b/packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py index 8706cb6b8..723856f5c 100644 --- a/packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py +++ b/packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py @@ -201,8 +201,8 @@ def _fake_validate(payload): } result = await MiscCommands.on_rrext_validate(conn, request) - assert captured['payload']['source'] == 'explicit-source' - assert captured['payload']['version'] == 1 # default + assert captured['payload']['pipeline']['source'] == 'explicit-source' + assert captured['payload']['pipeline']['version'] == 1 # default assert result == {'type': 'response', 'body': {'ok': True}} @@ -219,7 +219,7 @@ async def test_on_rrext_validate_falls_back_to_pipeline_source(monkeypatch): conn = _make_conn() request = {'arguments': {'pipeline': {'source': 'pipeline-source', 'components': []}}} await MiscCommands.on_rrext_validate(conn, request) - assert captured['source'] == 'pipeline-source' + assert captured['pipeline']['source'] == 'pipeline-source' @pytest.mark.asyncio @@ -235,7 +235,7 @@ async def test_on_rrext_validate_falls_back_to_implied_source(monkeypatch): conn = _make_conn() await MiscCommands.on_rrext_validate(conn, {'arguments': {'pipeline': {}}}) - assert captured.get('source') == 'implied' + assert captured['pipeline'].get('source') == 'implied' @pytest.mark.asyncio @@ -251,7 +251,31 @@ async def test_on_rrext_validate_no_source_anywhere_omits_field(monkeypatch): conn = _make_conn() await MiscCommands.on_rrext_validate(conn, {'arguments': {'pipeline': {'components': []}}}) - assert 'source' not in captured + assert 'source' not in captured['pipeline'] + + +@pytest.mark.asyncio +async def test_on_rrext_validate_wraps_config_in_pipeline_envelope(monkeypatch): + """The C++ payload is {'pipeline': } — the same envelope pipe_Validate + (modules/pipe) builds. Regression test: passing the config flat makes + validatePipeline reject every pipeline with "'pipeline' is missing or invalid". + """ + captured = {} + monkeypatch.setattr(cmd_misc, 'resolve_implied_source', lambda p: None) + monkeypatch.setattr( + cmd_misc, + 'validatePipeline', + lambda payload: captured.update(payload) or {'ok': True}, + ) + + conn = _make_conn() + config = {'components': [{'id': 'webhook_1'}], 'project_id': 'p1'} + await MiscCommands.on_rrext_validate(conn, {'arguments': {'pipeline': config}}) + + assert set(captured.keys()) == {'pipeline'} + assert captured['pipeline']['components'] == [{'id': 'webhook_1'}] + assert captured['pipeline']['project_id'] == 'p1' + assert captured['pipeline']['version'] == 1 @pytest.mark.asyncio From 80f3e00d5c2ded8219d563603398ffb94611e5b4 Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:02:18 +0530 Subject: [PATCH 3/5] feat(cli): add 'rocketride validate' subcommand to the Python and TypeScript CLIs (#1572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the existing SDK validate() (rrext_validate) in both CLIs so .pipe files can be checked from a terminal or CI without executing them: rocketride validate [--source ] [--json] - Files: literal paths and/or glob patterns, expanded in-CLI (recursive, sorted, deduped) so behavior is identical on shells without globbing. - Accepts either the flat pipeline config or the { "pipeline": {...} } wrapper (unwrapped exactly like client.use()); non-object JSON is rejected locally before any server call. - --json emits a single machine-readable document (per-file errors and warnings passed through verbatim from the engine, plus a summary). - Exit codes: 0 all valid; 1 at least one file invalid; 2 usage error, connection failure, or no file received a server verdict. - Connection handling, argument style, and output formatting mirror the existing subcommands in each CLI; no new dependencies. Both CLIs implement the identical contract (byte-aligned human output and JSON shape); covered by 14 pytest and 27 jest unit tests with a mocked client — no server required. Co-Authored-By: Claude Fable 5 --- .../src/rocketride/cli/commands/__init__.py | 3 + .../src/rocketride/cli/commands/validate.py | 323 +++++++++++++++ .../client-python/src/rocketride/cli/main.py | 33 ++ .../client-python/tests/test_validate_cli.py | 308 ++++++++++++++ .../client-typescript/src/cli/rocketride.ts | 249 ++++++++++++ .../client-typescript/tests/validate.test.ts | 383 ++++++++++++++++++ 6 files changed, 1299 insertions(+) create mode 100644 packages/client-python/src/rocketride/cli/commands/validate.py create mode 100644 packages/client-python/tests/test_validate_cli.py create mode 100644 packages/client-typescript/tests/validate.test.ts diff --git a/packages/client-python/src/rocketride/cli/commands/__init__.py b/packages/client-python/src/rocketride/cli/commands/__init__.py index 7edf47528..9e5728b46 100644 --- a/packages/client-python/src/rocketride/cli/commands/__init__.py +++ b/packages/client-python/src/rocketride/cli/commands/__init__.py @@ -34,6 +34,7 @@ EventsCommand: Monitor real-time pipeline events ListCommand: List all active tasks StoreCommand: Project and template storage operations + ValidateCommand: Validate pipeline configuration files """ from .start import StartCommand @@ -43,6 +44,7 @@ from .events import EventsCommand from .list import ListCommand from .store import StoreCommand +from .validate import ValidateCommand __all__ = [ 'StartCommand', @@ -52,4 +54,5 @@ 'EventsCommand', 'ListCommand', 'StoreCommand', + 'ValidateCommand', ] diff --git a/packages/client-python/src/rocketride/cli/commands/validate.py b/packages/client-python/src/rocketride/cli/commands/validate.py new file mode 100644 index 000000000..6c02f3c82 --- /dev/null +++ b/packages/client-python/src/rocketride/cli/commands/validate.py @@ -0,0 +1,323 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +RocketRide CLI Pipeline Validation Command Implementation. + +This module provides the ValidateCommand class for validating one or more +pipeline configuration files against the RocketRide server without executing +them. Use this command to check pipeline structure, component compatibility, +and connection integrity before deployment, either interactively or from CI. + +The validate command expands shell-style glob patterns in-CLI (so behavior is +identical on shells that do not expand globs, e.g. Windows), parses each file +as JSON, and sends each parsed pipeline to the server's rrext_validate command +via the SDK validate() method. + +Key Features: + - Validate one or more .pipe files in a single invocation + - In-CLI glob expansion for cross-platform wildcard support + - Optional source component override via --source + - Human-readable per-file output plus summary + - Machine-readable output via --json for CI pipelines + +Exit Codes: + 0: All files are valid + 1: At least one file failed validation + 2: Usage error, connection failure, or no file could be processed at all + (a file counts as processed only when the server returns a validation + verdict for it) + +Usage: + rocketride validate my_pipeline.pipe --apikey + rocketride validate examples/*.pipe --source webhook_1 --json + +Components: + ValidateCommand: Main command implementation for pipeline validation +""" + +import glob +import json +import os +import sys +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from .base import BaseCommand +from ..ui.colors import ANSI_GREEN, ANSI_RED, ANSI_RESET, ANSI_YELLOW, CHR_CHECK, CHR_CROSS + +if TYPE_CHECKING: + from ..main import RocketRideClient + + +class ValidateCommand(BaseCommand): + """ + Command implementation for validating pipeline configuration files. + + Expands file arguments (including glob patterns), parses each file as + JSON, and validates each parsed pipeline against the server using the + SDK validate() method. Results are reported per file in human-readable + or JSON format, with exit codes suitable for CI usage. + + Example: + ```python + # Initialize and execute validate command + command = ValidateCommand(cli, args) + exit_code = await command.execute(client) + ``` + + Key Features: + - Multi-file validation with in-CLI glob expansion + - Optional --source override passed through to the server + - Human-readable and JSON output formats + - Exit codes: 0 all valid, 1 any invalid, 2 nothing processable + """ + + def __init__(self, cli, args): + """ + Initialize ValidateCommand with CLI context and parsed arguments. + + Args: + cli: CLI instance providing cancellation state and event handling + args: Parsed command line arguments containing files and options + """ + super().__init__(cli, args) + + def _expand_files(self, patterns: List[str]) -> List[str]: + """ + Expand file arguments into a deduplicated, ordered list of paths. + + Literal paths are kept as-is; anything else is treated as a glob + pattern (expanded in-CLI so wildcards work on shells that do not + expand them). Patterns that match nothing are kept verbatim so they + can be reported as unreadable files. + + Args: + patterns: File paths and/or glob patterns from the command line + + Returns: + List[str]: Expanded file paths, deduplicated, preserving order + """ + expanded: List[str] = [] + for pattern in patterns: + if os.path.isfile(pattern): + expanded.append(pattern) + continue + + # Not a literal file - try shell-style glob expansion + matches = sorted(path for path in glob.glob(pattern, recursive=True) if os.path.isfile(path)) + if matches: + expanded.extend(matches) + else: + # Keep the unmatched pattern so it is reported per-file below + expanded.append(pattern) + + # Remove duplicates while preserving order + seen = set() + unique_files = [] + for file_path in expanded: + if file_path not in seen: + seen.add(file_path) + unique_files.append(file_path) + return unique_files + + def _load_pipeline(self, file_path: str) -> Dict[str, Any]: + """ + Load and parse a pipeline configuration file as strict JSON. + + ``.pipe`` files may wrap the configuration in ``{"pipeline": {...}}``; + the inner object is extracted when present, mirroring the SDK's use() + loader and the TypeScript CLI. + + Args: + file_path: Path to the pipeline configuration file + + Returns: + Dict[str, Any]: Parsed pipeline configuration + + Raises: + ValueError: If the file cannot be read, is not valid JSON, or its + top-level value is not a JSON object + """ + if not os.path.isfile(file_path): + raise ValueError(f'File not found: {file_path}') + + try: + with open(file_path, 'r', encoding='utf-8') as handle: + parsed = json.load(handle) + except json.JSONDecodeError as err: + raise ValueError(f'Invalid JSON in {file_path}: {err}') from err + except OSError as err: + raise ValueError(f'Cannot read {file_path}: {err}') from err + + if not isinstance(parsed, dict): + raise ValueError(f'Invalid pipeline format in {file_path}: expected a JSON object') + + # .pipe files wrap the config in { "pipeline": { ... } } - unwrap if present + inner = parsed.get('pipeline') + return inner if isinstance(inner, dict) else parsed + + def _print_human(self, results: List[Dict[str, Any]], summary: Dict[str, int]) -> None: + """ + Print per-file validation results and a summary in human format. + + Args: + results: Per-file result entries (file, valid, errors, warnings) + summary: Aggregate counts (total, valid, invalid) + """ + for entry in results: + if entry['valid']: + print(f'{ANSI_GREEN}{CHR_CHECK}{ANSI_RESET} {entry["file"]}: valid') + else: + print(f'{ANSI_RED}{CHR_CROSS}{ANSI_RESET} {entry["file"]}: invalid') + + # Show every error with its component id when available + for error in entry['errors']: + message = error.get('message', str(error)) if isinstance(error, dict) else str(error) + component = error.get('id') if isinstance(error, dict) else None + suffix = f' ({component})' if component else '' + print(f' {ANSI_RED}error{ANSI_RESET}: {message}{suffix}') + + # Show every warning with its component id when available + for warning in entry['warnings']: + message = warning.get('message', str(warning)) if isinstance(warning, dict) else str(warning) + component = warning.get('id') if isinstance(warning, dict) else None + suffix = f' ({component})' if component else '' + print(f' {ANSI_YELLOW}warning{ANSI_RESET}: {message}{suffix}') + + print() + print(f'Summary: {summary["total"]} file(s), {summary["valid"]} valid, {summary["invalid"]} invalid') + + async def execute(self, client: 'RocketRideClient') -> int: + """ + Execute the pipeline validation command. + + Expands file arguments, parses each file as JSON, validates each + parsed pipeline against the server, and reports per-file results + with a summary in either human-readable or JSON format. + + Args: + client: RocketRideClient instance for server communication + + Returns: + Exit code: 0 if all files are valid, 1 if at least one file + failed validation, 2 if the server connection failed or no + file could be processed at all (i.e. no file received a + server validation verdict) + + Process Flow: + 1. Expand glob patterns and literal paths into a file list + 2. Parse each file as JSON, recording parse errors per file + 3. Connect to the server if any file parsed successfully + 4. Validate each parsed pipeline via the SDK validate() method + 5. Report per-file results and summary in the requested format + 6. Compute the exit code from the aggregate results + """ + # Save the client for SDK calls + self.client = client + + # Expand globs and literal paths into the working file list + files = self._expand_files(self.args.files) + + # Parse each file up front; parse failures are per-file errors + pipelines: Dict[str, Optional[Dict[str, Any]]] = {} + parse_errors: Dict[str, str] = {} + for file_path in files: + try: + pipelines[file_path] = self._load_pipeline(file_path) + except ValueError as err: + pipelines[file_path] = None + parse_errors[file_path] = str(err) + + # Connect only if at least one file parsed successfully + if any(config is not None for config in pipelines.values()): + try: + if not self.cli.client.is_connected(): + await self.cli.connect() + except Exception as err: + print(f'Error: Unable to connect to server: {err}', file=sys.stderr) + return 2 + + # Validate each file in order, collecting per-file results + results: List[Dict[str, Any]] = [] + processed = 0 + for file_path in files: + config = pipelines[file_path] + if config is None: + # Unreadable or unparseable file - report as invalid + results.append( + { + 'file': file_path, + 'valid': False, + 'errors': [{'message': parse_errors[file_path]}], + 'warnings': [], + } + ) + continue + + try: + # Pass through the optional --source override to the SDK + result = await self.client.validate(config, source=self.args.source) + except Exception as err: + # Server rejected the request for this file + results.append( + { + 'file': file_path, + 'valid': False, + 'errors': [{'message': str(err)}], + 'warnings': [], + } + ) + continue + + errors = result.get('errors') or [] + warnings = result.get('warnings') or [] + results.append( + { + 'file': file_path, + 'valid': not errors, + 'errors': errors, + 'warnings': warnings, + } + ) + processed += 1 + + # Build the aggregate summary + valid_count = sum(1 for entry in results if entry['valid']) + summary = { + 'total': len(results), + 'valid': valid_count, + 'invalid': len(results) - valid_count, + } + + # Emit results in the requested format + if self.args.json: + # Machine-readable output only - keep stdout pipeable + print(json.dumps({'files': results, 'summary': summary}, indent=2)) + else: + self._print_human(results, summary) + + # Exit 2 if no file could be processed at all + if processed == 0: + return 2 + + # Exit 1 if any file is invalid, 0 if all files are valid + return 0 if summary['invalid'] == 0 else 1 diff --git a/packages/client-python/src/rocketride/cli/main.py b/packages/client-python/src/rocketride/cli/main.py index daff72ed6..6b1683bd8 100644 --- a/packages/client-python/src/rocketride/cli/main.py +++ b/packages/client-python/src/rocketride/cli/main.py @@ -67,6 +67,7 @@ from .commands.events import EventsCommand from .commands.list import ListCommand from .commands.store import StoreCommand +from .commands.validate import ValidateCommand try: # Try importing from installed package first @@ -463,6 +464,37 @@ def add_common_args(subparser): help='Output results in JSON format', ) + # Validate command - validates pipeline files without executing them + validate_parser = subparsers.add_parser( + 'validate', + help='Validate pipeline files', + description='Validate pipeline files against the server without executing them. ' + 'Exit codes: 0 = all files valid; 1 = at least one file failed validation; ' + '2 = usage error, connection failure, or no file could be processed at all ' + '(no file received a server validation verdict).', + ) + add_common_args(validate_parser) + + # Pipeline files as positional arguments - supports glob patterns + validate_parser.add_argument( + 'files', + nargs='+', + help='Pipeline files or glob patterns to validate', + ) + + # Optional source component override passed through to validation + validate_parser.add_argument( + '--source', + help='Override source component ID for validation', + ) + + # Optional JSON output format + validate_parser.add_argument( + '--json', + action='store_true', + help='Output results in JSON format', + ) + # Store command - file store and domain storage operations store_common_parser = argparse.ArgumentParser(add_help=False) add_common_args(store_common_parser) @@ -605,6 +637,7 @@ async def run(self) -> int: 'events': EventsCommand, 'list': ListCommand, 'store': StoreCommand, + 'validate': ValidateCommand, } if self.args.command in command_map: diff --git a/packages/client-python/tests/test_validate_cli.py b/packages/client-python/tests/test_validate_cli.py new file mode 100644 index 000000000..99560d904 --- /dev/null +++ b/packages/client-python/tests/test_validate_cli.py @@ -0,0 +1,308 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Unit tests for the `rocketride validate` CLI command. + +These tests exercise the ValidateCommand through the full CLI entry point +(RocketRideCLI.run) with a fake client, so no live server or network is +required. They cover glob expansion, per-file validation results, JSON +output shape, --source passthrough, and the exit code contract: +0 = all valid, 1 = at least one invalid, 2 = nothing processable or +connection failure. +""" + +import importlib +import json +import sys +from typing import Any, Dict, List, Optional + +import pytest + +# `rocketride.cli.main` must be imported as a module: the `rocketride.cli` +# package re-exports the `main()` function under the same name, which would +# shadow the module on attribute-style imports. +cli_main = importlib.import_module('rocketride.cli.main') + +VALID_PIPELINE = { + 'project_id': 'test-project', + 'components': [ + { + 'id': 'webhook_1', + 'provider': 'webhook', + 'config': {'hideForm': True, 'mode': 'Source', 'type': 'webhook'}, + }, + { + 'id': 'response_1', + 'provider': 'response', + 'config': {'lanes': []}, + 'input': [{'lane': 'text', 'from': 'webhook_1'}], + }, + ], + 'source': 'webhook_1', +} + +CLEAN_RESULT: Dict[str, Any] = {'errors': [], 'warnings': []} + +FAILED_RESULT: Dict[str, Any] = { + 'errors': [{'message': 'Component has no input', 'id': 'response_1'}], + 'warnings': [{'message': 'Source has no consumers', 'id': 'webhook_1'}], +} + + +class FakeClient: + """Minimal stand-in for RocketRideClient used by the CLI under test.""" + + def __init__(self, results: Optional[List[Any]] = None, connect_error: Optional[Exception] = None): + """ + Initialize the fake client. + + Args: + results: Validation results (or exceptions) consumed in call order + connect_error: Exception to raise from connect(), if any + """ + self.results = list(results or []) + self.connect_error = connect_error + self.connected = False + self.validate_calls: List[Dict[str, Any]] = [] + + def is_connected(self) -> bool: + """Report the fake connection state.""" + return self.connected + + async def connect(self) -> None: + """Simulate connecting, raising connect_error when configured.""" + if self.connect_error is not None: + raise self.connect_error + self.connected = True + + async def disconnect(self) -> None: + """Simulate disconnecting.""" + self.connected = False + + async def validate(self, pipeline: Dict[str, Any], *, source: Optional[str] = None) -> Dict[str, Any]: + """Record the call and return (or raise) the next queued result.""" + self.validate_calls.append({'pipeline': pipeline, 'source': source}) + result = self.results.pop(0) if self.results else CLEAN_RESULT + if isinstance(result, Exception): + raise result + return result + + +async def run_cli(monkeypatch, fake_client: FakeClient, argv: List[str]) -> int: + """Run the CLI end-to-end with a fake client and return its exit code.""" + monkeypatch.setattr(cli_main, 'RocketRideClient', lambda **kwargs: fake_client) + monkeypatch.setattr(sys, 'argv', ['rocketride', 'validate', *argv]) + cli = cli_main.RocketRideCLI() + return await cli.run() + + +@pytest.fixture +def pipe_file(tmp_path): + """Create a valid .pipe file and return its path as a string.""" + path = tmp_path / 'pipeline.pipe' + path.write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + return str(path) + + +class TestValidateCli: + async def test_single_valid_file(self, monkeypatch, capsys, pipe_file): + fake = FakeClient(results=[CLEAN_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [pipe_file]) + + assert exit_code == 0 + assert len(fake.validate_calls) == 1 + assert fake.validate_calls[0]['pipeline'] == VALID_PIPELINE + out = capsys.readouterr().out + assert pipe_file in out + assert 'valid' in out + assert 'Summary: 1 file(s), 1 valid, 0 invalid' in out + + async def test_unwraps_pipeline_wrapper(self, monkeypatch, tmp_path): + wrapped = tmp_path / 'wrapped.pipe' + wrapped.write_text(json.dumps({'pipeline': VALID_PIPELINE}), encoding='utf-8') + fake = FakeClient(results=[CLEAN_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [str(wrapped)]) + + # The { "pipeline": { ... } } wrapper is stripped before the SDK call + assert exit_code == 0 + assert len(fake.validate_calls) == 1 + assert fake.validate_calls[0]['pipeline'] == VALID_PIPELINE + + async def test_invalid_file_surfaces_errors(self, monkeypatch, capsys, pipe_file): + fake = FakeClient(results=[FAILED_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [pipe_file]) + + assert exit_code == 1 + out = capsys.readouterr().out + assert 'Component has no input' in out + assert 'response_1' in out + assert 'Source has no consumers' in out + assert 'Summary: 1 file(s), 0 valid, 1 invalid' in out + + async def test_multiple_files_mixed(self, monkeypatch, capsys, tmp_path): + good = tmp_path / 'good.pipe' + good.write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + bad = tmp_path / 'bad.pipe' + bad.write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + fake = FakeClient(results=[CLEAN_RESULT, FAILED_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [str(good), str(bad)]) + + assert exit_code == 1 + assert len(fake.validate_calls) == 2 + out = capsys.readouterr().out + assert 'Summary: 2 file(s), 1 valid, 1 invalid' in out + + async def test_unparseable_file_alone_exits_2(self, monkeypatch, capsys, tmp_path): + broken = tmp_path / 'broken.pipe' + broken.write_text('{ not valid json', encoding='utf-8') + fake = FakeClient() + + exit_code = await run_cli(monkeypatch, fake, [str(broken)]) + + # No file could be processed, so exit 2 and never touch the server + assert exit_code == 2 + assert fake.validate_calls == [] + assert not fake.connected + out = capsys.readouterr().out + assert 'Invalid JSON' in out + + async def test_unparseable_file_with_valid_file_exits_1(self, monkeypatch, capsys, tmp_path): + broken = tmp_path / 'broken.pipe' + broken.write_text('{ not valid json', encoding='utf-8') + good = tmp_path / 'good.pipe' + good.write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + fake = FakeClient(results=[CLEAN_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [str(broken), str(good)]) + + # Unparseable file counts as invalid when other files were processed + assert exit_code == 1 + assert len(fake.validate_calls) == 1 + out = capsys.readouterr().out + assert 'Invalid JSON' in out + assert 'Summary: 2 file(s), 1 valid, 1 invalid' in out + + async def test_non_object_json_alone_exits_2(self, monkeypatch, capsys, tmp_path): + array_file = tmp_path / 'array.pipe' + array_file.write_text('[1, 2, 3]', encoding='utf-8') + fake = FakeClient() + + exit_code = await run_cli(monkeypatch, fake, [str(array_file)]) + + # Non-object JSON is rejected locally: no connection, no server call + assert exit_code == 2 + assert fake.validate_calls == [] + assert not fake.connected + out = capsys.readouterr().out + assert 'expected a JSON object' in out + + async def test_missing_file_alone_exits_2(self, monkeypatch, capsys, tmp_path): + missing = str(tmp_path / 'does-not-exist.pipe') + fake = FakeClient() + + exit_code = await run_cli(monkeypatch, fake, [missing]) + + assert exit_code == 2 + assert fake.validate_calls == [] + out = capsys.readouterr().out + assert 'File not found' in out + + async def test_glob_expansion(self, monkeypatch, capsys, tmp_path): + for name in ('a.pipe', 'b.pipe'): + (tmp_path / name).write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + fake = FakeClient(results=[CLEAN_RESULT, CLEAN_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [str(tmp_path / '*.pipe')]) + + assert exit_code == 0 + assert len(fake.validate_calls) == 2 + out = capsys.readouterr().out + assert 'Summary: 2 file(s), 2 valid, 0 invalid' in out + + async def test_json_output_shape(self, monkeypatch, capsys, tmp_path): + good = tmp_path / 'good.pipe' + good.write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + bad = tmp_path / 'bad.pipe' + bad.write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + fake = FakeClient(results=[CLEAN_RESULT, FAILED_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [str(good), str(bad), '--json']) + + assert exit_code == 1 + out = capsys.readouterr().out + + # stdout must be exactly one machine-readable JSON document + document = json.loads(out) + assert set(document.keys()) == {'files', 'summary'} + assert document['summary'] == {'total': 2, 'valid': 1, 'invalid': 1} + + good_entry, bad_entry = document['files'] + assert good_entry == {'file': str(good), 'valid': True, 'errors': [], 'warnings': []} + assert bad_entry['file'] == str(bad) + assert bad_entry['valid'] is False + assert bad_entry['errors'] == FAILED_RESULT['errors'] + assert bad_entry['warnings'] == FAILED_RESULT['warnings'] + + async def test_source_passthrough(self, monkeypatch, pipe_file): + fake = FakeClient(results=[CLEAN_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [pipe_file, '--source', 'webhook_1']) + + assert exit_code == 0 + assert fake.validate_calls[0]['source'] == 'webhook_1' + + async def test_source_defaults_to_none(self, monkeypatch, pipe_file): + fake = FakeClient(results=[CLEAN_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [pipe_file]) + + assert exit_code == 0 + assert fake.validate_calls[0]['source'] is None + + async def test_connection_failure_exits_2(self, monkeypatch, capsys, pipe_file): + fake = FakeClient(connect_error=ConnectionError('connection refused')) + + exit_code = await run_cli(monkeypatch, fake, [pipe_file]) + + assert exit_code == 2 + assert fake.validate_calls == [] + err = capsys.readouterr().err + assert 'connection refused' in err + + async def test_server_error_marks_file_invalid(self, monkeypatch, capsys, tmp_path): + good = tmp_path / 'good.pipe' + good.write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + bad = tmp_path / 'bad.pipe' + bad.write_text(json.dumps(VALID_PIPELINE), encoding='utf-8') + fake = FakeClient(results=[RuntimeError('Pipeline validation failed: boom'), CLEAN_RESULT]) + + exit_code = await run_cli(monkeypatch, fake, [str(good), str(bad)]) + + assert exit_code == 1 + out = capsys.readouterr().out + assert 'boom' in out + assert 'Summary: 2 file(s), 1 valid, 1 invalid' in out diff --git a/packages/client-typescript/src/cli/rocketride.ts b/packages/client-typescript/src/cli/rocketride.ts index c92fc4856..716f6a743 100644 --- a/packages/client-typescript/src/cli/rocketride.ts +++ b/packages/client-typescript/src/cli/rocketride.ts @@ -50,6 +50,7 @@ * - upload: Upload files to a pipeline (with --pipeline or --token) * - status: Monitor real-time status of a running pipeline * - stop: Terminate a running pipeline gracefully + * - validate: Validate pipeline configuration files without executing them * * @example * ```bash @@ -64,6 +65,9 @@ * * # Stop a pipeline * rocketride stop --token TASK_TOKEN + * + * # Validate pipeline files + * rocketride validate examples/*.pipe --json * ``` */ @@ -779,6 +783,8 @@ interface CLIArgs { files?: string[]; max_concurrent?: number; pipeline_args?: string[]; + source?: string; + json?: boolean; [key: string]: unknown; } @@ -790,6 +796,139 @@ interface UploadStats { upload_times: number[]; } +/** + * A pipeline file loaded for validation: either a parsed config or a load error. + */ +export interface ValidateFileLoad { + file: string; + config?: Record; + error?: string; +} + +/** + * Validation outcome for a single file. + * + * `processed` is true only when the server returned a validation result for + * the file (as opposed to a local read/parse failure or a request error). + */ +export interface FileValidationResult { + file: string; + valid: boolean; + errors: unknown[]; + warnings: unknown[]; + processed: boolean; +} + +/** + * Machine-readable report emitted by `rocketride validate --json`. + */ +export interface ValidateReport { + files: Array<{ file: string; valid: boolean; errors: unknown[]; warnings: unknown[] }>; + summary: { total: number; valid: number; invalid: number }; +} + +/** + * Expand shell-style glob patterns into a deduplicated file list. + * + * Globs are expanded in-CLI (Windows shells do not expand them). A pattern + * with no matches is kept as a literal path so it is later reported as + * unreadable instead of being silently dropped. + */ +export function expandFilePatterns(patterns: string[]): string[] { + const files: string[] = []; + + for (const pattern of patterns) { + const matches = glob.sync(pattern, { nodir: true, windowsPathsNoEscape: process.platform === 'win32' }).sort(); + if (matches.length > 0) { + files.push(...matches); + } else { + files.push(pattern); + } + } + + return [...new Set(files)]; +} + +/** + * Read and parse a pipeline file for validation. + * + * Returns the parsed pipeline config, or a human-readable error when the file + * cannot be read, is not valid JSON, or is not a JSON object. + */ +export function loadPipelineFile(file: string): ValidateFileLoad { + // Mirror the Python CLI: a path that is not an existing regular file is + // reported as not found (message skeletons match across both CLIs). + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { + return { file, error: `File not found: ${file}` }; + } + + let content: string; + try { + content = fs.readFileSync(file, 'utf-8'); + } catch (error) { + return { file, error: `Cannot read ${file}: ${error instanceof Error ? error.message : error}` }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (error) { + return { file, error: `Invalid JSON in ${file}: ${error instanceof Error ? error.message : error}` }; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { file, error: `Invalid pipeline format in ${file}: expected a JSON object` }; + } + + // .pipe files wrap the config in { "pipeline": { ... } } — unwrap if present + const record = parsed as Record; + const inner = record.pipeline; + const config = inner && typeof inner === 'object' && !Array.isArray(inner) ? (inner as Record) : record; + return { file, config }; +} + +/** + * Build the machine-readable validation report for `--json` output. + */ +export function buildValidateReport(results: FileValidationResult[]): ValidateReport { + const validCount = results.filter((r) => r.valid).length; + return { + files: results.map((r) => ({ file: r.file, valid: r.valid, errors: r.errors, warnings: r.warnings })), + summary: { total: results.length, valid: validCount, invalid: results.length - validCount }, + }; +} + +/** + * Map validation results to the CLI exit code. + * + * 0 = all files valid, 1 = at least one file invalid (including unreadable or + * unparseable files when at least one other file was processed), 2 = no file + * could be processed at all. A file counts as processed only when the server + * returned a validation verdict for it, so per-file server errors on every + * file also yield exit code 2. + */ +export function validateExitCode(results: FileValidationResult[]): number { + if (results.length === 0 || !results.some((r) => r.processed)) { + return 2; + } + return results.every((r) => r.valid) ? 0 : 1; +} + +/** + * Format a single validation error/warning entry for human-readable output. + */ +export function formatValidationIssue(issue: unknown): string { + if (typeof issue === 'string') { + return issue; + } + if (issue && typeof issue === 'object') { + const record = issue as Record; + const message = typeof record.message === 'string' ? record.message : JSON.stringify(issue); + return record.id ? `${message} (${record.id})` : message; + } + return String(issue); +} + export class RocketRideCLI { private client?: RocketRideClient; private args: CLIArgs = {}; @@ -1024,6 +1163,49 @@ export class RocketRideCLI { addCommonOptions(stopCmd); + // Validate command + const validateCmd = program + .command('validate') + .description('Validate pipeline files without executing them') + .argument('', 'Pipeline .pipe files or glob patterns to validate') + .option('--source ', 'Override source component ID for validation') + .option('--json', 'Output machine-readable JSON results to stdout') + .addHelpText( + 'after', + '\nExit codes:\n' + + ' 0 all files valid\n' + + ' 1 at least one file failed validation\n' + + ' 2 usage error, connection failure, or no file could be processed at all\n' + + ' (no file received a server validation verdict)', + ) + .action(async (files, options) => { + this.args = { + command: 'validate', + ...options, + files, + }; + this.uri = options.uri; + + try { + const exitCode = await this.cmdValidate(); + if (!this.isCancelled()) { + process.exit(exitCode); + } + } finally { + if (!this.isCancelled()) { + this.cancel(); + await this.cleanupClient(); + } + } + }); + + // Usage errors (e.g. missing ) must exit with code 2 + validateCmd.exitOverride((err) => { + process.exit(err.exitCode === 0 ? 0 : 2); + }); + + addCommonOptions(validateCmd); + // Store command with file system subcommands const storeCmd = program.command('store').description('File store operations'); @@ -1752,6 +1934,73 @@ export class RocketRideCLI { } } + async cmdValidate(): Promise { + try { + const files = expandFilePatterns(this.args.files || []); + const loaded = files.map((file) => loadPipelineFile(file)); + + // Only connect when at least one file parsed successfully + let client: RocketRideClient | undefined; + if (loaded.some((entry) => entry.config !== undefined)) { + try { + client = await this.createAndConnectClient(); + } catch (error) { + console.error(`Error: Failed to connect to ${this.uri}: ${error instanceof Error ? error.message : error}`); + return 2; + } + } + + const results: FileValidationResult[] = []; + for (const entry of loaded) { + if (entry.config === undefined) { + results.push({ file: entry.file, valid: false, errors: [{ message: entry.error }], warnings: [], processed: false }); + continue; + } + + try { + const result = await client!.validate({ pipeline: entry.config, source: this.args.source }); + const errors = Array.isArray(result.errors) ? result.errors : []; + const warnings = Array.isArray(result.warnings) ? result.warnings : []; + results.push({ file: entry.file, valid: errors.length === 0, errors, warnings, processed: true }); + } catch (error) { + results.push({ file: entry.file, valid: false, errors: [{ message: error instanceof Error ? error.message : String(error) }], warnings: [], processed: false }); + } + } + + if (this.args.json) { + // Machine-readable output only — keep stdout pipeable + console.log(JSON.stringify(buildValidateReport(results), null, 2)); + } else { + this.printValidateResults(results); + } + + return validateExitCode(results); + } finally { + await this.cleanupClient(); + } + } + + private printValidateResults(results: FileValidationResult[]): void { + for (const result of results) { + if (result.valid) { + console.log(`${ANSI_GREEN}${CHR_CHECK}${ANSI_RESET} ${result.file}: valid`); + } else { + console.log(`${ANSI_RED}${CHR_CROSS}${ANSI_RESET} ${result.file}: invalid`); + } + + for (const error of result.errors) { + console.log(` ${ANSI_RED}error${ANSI_RESET}: ${formatValidationIssue(error)}`); + } + for (const warning of result.warnings) { + console.log(` ${ANSI_YELLOW}warning${ANSI_RESET}: ${formatValidationIssue(warning)}`); + } + } + + const validCount = results.filter((r) => r.valid).length; + console.log(''); + console.log(`Summary: ${results.length} file(s), ${validCount} valid, ${results.length - validCount} invalid`); + } + async run(): Promise { const program = this.createProgram(); diff --git a/packages/client-typescript/tests/validate.test.ts b/packages/client-typescript/tests/validate.test.ts new file mode 100644 index 000000000..a0d9a91fe --- /dev/null +++ b/packages/client-typescript/tests/validate.test.ts @@ -0,0 +1,383 @@ +/** + * MIT License + * + * Copyright (c) 2026 Aparavi Software AG + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Unit tests for the `rocketride validate` CLI command. + * + * These tests do NOT require a live server: they exercise the pure helpers + * (glob expansion, file loading, exit-code mapping, JSON report shape) and + * the cmdValidate wiring with a mocked client. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect, beforeAll, afterAll, jest } from '@jest/globals'; +import { RocketRideCLI, expandFilePatterns, loadPipelineFile, buildValidateReport, validateExitCode, formatValidationIssue, FileValidationResult } from '../src/cli/rocketride'; + +const PIPELINE = { + project_id: 'test-project', + components: [ + { + id: 'webhook_1', + provider: 'webhook', + config: { hideForm: true, mode: 'Source', type: 'webhook' }, + }, + { + id: 'response_1', + provider: 'response', + config: { lanes: [] }, + input: [{ lane: 'text', from: 'webhook_1' }], + }, + ], + source: 'webhook_1', +}; + +let tmpDir: string; +let validFile: string; +let wrappedFile: string; +let badJsonFile: string; +let arrayFile: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rocketride-validate-')); + validFile = path.join(tmpDir, 'valid.pipe'); + wrappedFile = path.join(tmpDir, 'wrapped.pipe'); + badJsonFile = path.join(tmpDir, 'bad.pipe'); + arrayFile = path.join(tmpDir, 'array.pipe'); + + fs.writeFileSync(validFile, JSON.stringify(PIPELINE)); + fs.writeFileSync(wrappedFile, JSON.stringify({ pipeline: PIPELINE })); + fs.writeFileSync(badJsonFile, '{ this is not json'); + fs.writeFileSync(arrayFile, '[1, 2, 3]'); + fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'not a pipeline'); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ── expandFilePatterns ─────────────────────────────────────────────────────── + +describe('expandFilePatterns', () => { + it('expands a glob pattern to sorted matching files', () => { + const result = expandFilePatterns([path.join(tmpDir, '*.pipe')]); + expect(result).toEqual([arrayFile, badJsonFile, validFile, wrappedFile]); + }); + + it('does not match files outside the pattern extension', () => { + const result = expandFilePatterns([path.join(tmpDir, '*.pipe')]); + expect(result).not.toContain(path.join(tmpDir, 'notes.txt')); + }); + + it('keeps a literal path with no matches so it can be reported as unreadable', () => { + const missing = path.join(tmpDir, 'missing.pipe'); + expect(expandFilePatterns([missing])).toEqual([missing]); + }); + + it('deduplicates files matched by multiple patterns', () => { + const result = expandFilePatterns([validFile, path.join(tmpDir, 'valid.*')]); + expect(result).toEqual([validFile]); + }); + + it('returns an empty list for no patterns', () => { + expect(expandFilePatterns([])).toEqual([]); + }); +}); + +// ── loadPipelineFile ───────────────────────────────────────────────────────── + +describe('loadPipelineFile', () => { + it('parses a flat pipeline object', () => { + const result = loadPipelineFile(validFile); + expect(result.error).toBeUndefined(); + expect(result.config).toEqual(PIPELINE); + }); + + it('unwraps the { "pipeline": { ... } } wrapper format', () => { + const result = loadPipelineFile(wrappedFile); + expect(result.error).toBeUndefined(); + expect(result.config).toEqual(PIPELINE); + }); + + it('reports invalid JSON as an error', () => { + const result = loadPipelineFile(badJsonFile); + expect(result.config).toBeUndefined(); + expect(result.error).toContain('Invalid JSON'); + }); + + it('reports non-object JSON as an error', () => { + const result = loadPipelineFile(arrayFile); + expect(result.config).toBeUndefined(); + expect(result.error).toContain('expected a JSON object'); + }); + + it('reports a missing file as an error', () => { + const result = loadPipelineFile(path.join(tmpDir, 'missing.pipe')); + expect(result.config).toBeUndefined(); + expect(result.error).toContain('File not found'); + }); +}); + +// ── validateExitCode ───────────────────────────────────────────────────────── + +function makeResult(overrides: Partial): FileValidationResult { + return { file: 'a.pipe', valid: true, errors: [], warnings: [], processed: true, ...overrides }; +} + +describe('validateExitCode', () => { + it('returns 0 when all files are valid', () => { + expect(validateExitCode([makeResult({}), makeResult({ file: 'b.pipe' })])).toBe(0); + }); + + it('returns 1 when at least one file failed validation', () => { + expect(validateExitCode([makeResult({}), makeResult({ file: 'b.pipe', valid: false, errors: [{ message: 'bad' }] })])).toBe(1); + }); + + it('returns 1 when one file is unparseable but another was processed', () => { + expect(validateExitCode([makeResult({}), makeResult({ file: 'b.pipe', valid: false, processed: false })])).toBe(1); + }); + + it('returns 2 when no file could be processed', () => { + expect(validateExitCode([makeResult({ valid: false, processed: false }), makeResult({ file: 'b.pipe', valid: false, processed: false })])).toBe(2); + }); + + it('returns 2 for an empty result list', () => { + expect(validateExitCode([])).toBe(2); + }); +}); + +// ── buildValidateReport ────────────────────────────────────────────────────── + +describe('buildValidateReport', () => { + it('produces the machine-readable report shape', () => { + const results = [makeResult({}), makeResult({ file: 'b.pipe', valid: false, errors: [{ message: 'bad' }], warnings: [{ message: 'careful' }] })]; + const report = buildValidateReport(results); + + expect(Object.keys(report)).toEqual(['files', 'summary']); + expect(report.summary).toEqual({ total: 2, valid: 1, invalid: 1 }); + expect(report.files).toHaveLength(2); + expect(Object.keys(report.files[0])).toEqual(['file', 'valid', 'errors', 'warnings']); + expect(report.files[0]).toEqual({ file: 'a.pipe', valid: true, errors: [], warnings: [] }); + expect(report.files[1]).toEqual({ file: 'b.pipe', valid: false, errors: [{ message: 'bad' }], warnings: [{ message: 'careful' }] }); + }); +}); + +// ── formatValidationIssue ──────────────────────────────────────────────────── + +describe('formatValidationIssue', () => { + it('passes strings through unchanged', () => { + expect(formatValidationIssue('plain message')).toBe('plain message'); + }); + + it('formats { message } objects', () => { + expect(formatValidationIssue({ message: 'bad component' })).toBe('bad component'); + }); + + it('appends the component id when present', () => { + expect(formatValidationIssue({ message: 'bad component', id: 'chat_1' })).toBe('bad component (chat_1)'); + }); + + it('stringifies unexpected values', () => { + expect(formatValidationIssue(42)).toBe('42'); + }); +}); + +// ── cmdValidate wiring (mocked client) ─────────────────────────────────────── + +interface MockValidateResponse { + errors?: unknown[]; + warnings?: unknown[]; +} + +// Instantiate without running the constructor: it registers process signal +// handlers, which jest's sandboxed `process` module does not support. +function bareCLI(): RocketRideCLI { + return Object.create(RocketRideCLI.prototype) as RocketRideCLI; +} + +function makeCLI(args: Record, validateImpl: (options: { pipeline: Record; source?: string }) => Promise) { + const cli = bareCLI(); + const fakeClient = { + validate: jest.fn(validateImpl), + disconnect: jest.fn(async () => {}), + }; + + /* eslint-disable @typescript-eslint/no-explicit-any */ + (cli as any).args = { command: 'validate', ...args }; + (cli as any).uri = 'ws://localhost:5565'; + (cli as any).createAndConnectClient = jest.fn(async () => { + (cli as any).client = fakeClient; + return fakeClient; + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + return { cli, fakeClient }; +} + +function captureConsole(): { logs: string[]; errors: string[]; restore: () => void } { + const logs: string[] = []; + const errors: string[] = []; + const logSpy = jest.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }); + const errorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + return { + logs, + errors, + restore: () => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }, + }; +} + +describe('cmdValidate', () => { + it('returns 0 and prints the JSON report when all files are valid', async () => { + const { cli, fakeClient } = makeCLI({ files: [validFile], json: true }, async () => ({ errors: [], warnings: [] })); + const output = captureConsole(); + + try { + const exitCode = await cli.cmdValidate(); + expect(exitCode).toBe(0); + } finally { + output.restore(); + } + + const report = JSON.parse(output.logs.join('\n')); + expect(report.summary).toEqual({ total: 1, valid: 1, invalid: 0 }); + expect(report.files).toEqual([{ file: validFile, valid: true, errors: [], warnings: [] }]); + expect(fakeClient.validate).toHaveBeenCalledTimes(1); + expect(fakeClient.validate).toHaveBeenCalledWith({ pipeline: PIPELINE, source: undefined }); + }); + + it('passes --source through to the client validate call', async () => { + const { cli, fakeClient } = makeCLI({ files: [validFile], json: true, source: 'chat_1' }, async () => ({ errors: [], warnings: [] })); + const output = captureConsole(); + + try { + const exitCode = await cli.cmdValidate(); + expect(exitCode).toBe(0); + } finally { + output.restore(); + } + + expect(fakeClient.validate).toHaveBeenCalledWith({ pipeline: PIPELINE, source: 'chat_1' }); + }); + + it('returns 1 when the server reports validation errors', async () => { + const { cli } = makeCLI({ files: [validFile], json: true }, async () => ({ errors: [{ message: 'unknown provider', id: 'x_1' }], warnings: [] })); + const output = captureConsole(); + + try { + const exitCode = await cli.cmdValidate(); + expect(exitCode).toBe(1); + } finally { + output.restore(); + } + + const report = JSON.parse(output.logs.join('\n')); + expect(report.summary).toEqual({ total: 1, valid: 0, invalid: 1 }); + expect(report.files[0].valid).toBe(false); + expect(report.files[0].errors).toEqual([{ message: 'unknown provider', id: 'x_1' }]); + }); + + it('returns 1 when one file is unparseable but another validates', async () => { + const { cli, fakeClient } = makeCLI({ files: [badJsonFile, validFile], json: true }, async () => ({ errors: [], warnings: [] })); + const output = captureConsole(); + + try { + const exitCode = await cli.cmdValidate(); + expect(exitCode).toBe(1); + } finally { + output.restore(); + } + + const report = JSON.parse(output.logs.join('\n')); + expect(report.summary).toEqual({ total: 2, valid: 1, invalid: 1 }); + // Only the parseable file reaches the server + expect(fakeClient.validate).toHaveBeenCalledTimes(1); + }); + + it('returns 2 without connecting when no file can be parsed', async () => { + const { cli, fakeClient } = makeCLI({ files: [badJsonFile], json: true }, async () => ({ errors: [], warnings: [] })); + const output = captureConsole(); + + try { + const exitCode = await cli.cmdValidate(); + expect(exitCode).toBe(2); + } finally { + output.restore(); + } + + const report = JSON.parse(output.logs.join('\n')); + expect(report.summary).toEqual({ total: 1, valid: 0, invalid: 1 }); + expect(fakeClient.validate).not.toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((cli as any).createAndConnectClient).not.toHaveBeenCalled(); + }); + + it('returns 2 on connection failure', async () => { + const cli = bareCLI(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (cli as any).args = { command: 'validate', files: [validFile], json: true }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (cli as any).uri = 'ws://localhost:5565'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (cli as any).createAndConnectClient = jest.fn(async () => { + throw new Error('connection refused'); + }); + const output = captureConsole(); + + try { + const exitCode = await cli.cmdValidate(); + expect(exitCode).toBe(2); + } finally { + output.restore(); + } + + expect(output.errors.join('\n')).toContain('Failed to connect'); + }); + + it('prints per-file lines and a summary in human-readable mode', async () => { + const { cli } = makeCLI({ files: [validFile, badJsonFile] }, async () => ({ errors: [], warnings: [{ message: 'heads up' }] })); + const output = captureConsole(); + + try { + const exitCode = await cli.cmdValidate(); + expect(exitCode).toBe(1); + } finally { + output.restore(); + } + + const text = output.logs.join('\n'); + expect(text).toContain(`${validFile}: valid`); + expect(text).toContain('heads up'); + expect(text).toContain(`${badJsonFile}: invalid`); + expect(text).toContain('Invalid JSON'); + expect(text).toContain('Summary: 2 file(s), 1 valid, 1 invalid'); + }); +}); From d237562fe397c329ceedf39ae2437479effbaf00 Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:02:34 +0530 Subject: [PATCH 4/5] feat(ci): add validate-pipes composite GitHub Action Reusable composite action that gates .pipe files in CI: sets up Python, installs the rocketride CLI (fails fast with a clear message when the installed release lacks the validate subcommand; cli-version input to pin), starts the engine image in Docker, waits for the public /version endpoint, validates the repo's .pipe files (changed-only vs the PR base by default), and always removes the container. Referencable as rocketride-org/rocketride-server/.github/actions/validate-pipes@. No existing workflow files are modified. Co-Authored-By: Claude Fable 5 --- .github/actions/validate-pipes/README.md | 82 ++++++++ .github/actions/validate-pipes/action.yml | 221 ++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 .github/actions/validate-pipes/README.md create mode 100644 .github/actions/validate-pipes/action.yml diff --git a/.github/actions/validate-pipes/README.md b/.github/actions/validate-pipes/README.md new file mode 100644 index 000000000..d9bf2c6d9 --- /dev/null +++ b/.github/actions/validate-pipes/README.md @@ -0,0 +1,82 @@ +# Validate RocketRide Pipelines + +Composite GitHub Action that validates `.pipe` pipeline files against a real +RocketRide engine. It: + +1. Sets up Python and installs the [`rocketride`](https://pypi.org/project/rocketride/) CLI. +2. Starts the RocketRide engine in Docker and waits for its public `/version` + endpoint to respond (available on every published engine image). +3. Resolves the files to validate — on pull requests, only the `.pipe` files + changed relative to the base branch by default. +4. Runs `rocketride validate` against the local engine. +5. Always removes the engine container, even when validation fails. + +The job fails when any pipeline is invalid (CLI exit code `1`) or when the +engine cannot be started or reached (exit code `2`). + +## Requirements + +- A Linux runner with Docker available (e.g. `ubuntu-latest`). +- The repository checked out before this action runs (`actions/checkout`). + The default `fetch-depth: 1` is fine — the action fetches the PR base + commit itself when `changed-only` is enabled. +- A `rocketride` CLI release that includes the `validate` command. The + install step fails fast with a clear error when the installed release does + not; use the `cli-version` input to pin a release that does. + +## Inputs + +| Input | Default | Description | +| --- | --- | --- | +| `files` | `**/*.pipe` | Glob pattern(s) selecting the files to validate. Multiple patterns may be separated by whitespace; `**` recurses into subdirectories. | +| `changed-only` | `true` | On `pull_request` events, validate only the matching files changed vs the PR base (via `git diff`). Ignored on other events. | +| `engine-image` | `ghcr.io/rocketride-org/rocketride-engine:latest` | Docker image of the engine to validate against. | +| `engine-port` | `5565` | Host port mapped to the engine container's internal port `5565`. | +| `api-key` | _(empty)_ | Optional API key, passed to the CLI via the `ROCKETRIDE_APIKEY` environment variable (never on the command line). | +| `python-version` | `3.12` | Python version used to run the CLI. | +| `cli-version` | _(empty)_ | pip version specifier for the `rocketride` CLI (e.g. `==1.2.3` or `>=1.2.3`). Must resolve to a release that includes the `validate` subcommand. Installs the latest release when empty. | +| `fail-on-warnings` | `false` | Fail the job when a pipeline validates with warnings (the CLI itself exits `0` on warnings). | + +## Usage + +```yaml +name: Validate pipelines + +on: + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: ./.github/actions/validate-pipes + with: + files: 'examples/**/*.pipe' + # api-key: ${{ secrets.ROCKETRIDE_APIKEY }} # if your engine requires auth +``` + +> [!TIP] +> For reproducible CI results, pin `engine-image` to a released version tag +> (e.g. `ghcr.io/rocketride-org/rocketride-engine:1.3.0`) or an image digest +> instead of `latest`, and bump it deliberately. + +## Running the same check locally + +```bash +docker run -d --name rocketride-engine -p 5565:5565 \ + ghcr.io/rocketride-org/rocketride-engine:latest +curl -fsS --retry 30 --retry-delay 2 --retry-all-errors http://127.0.0.1:5565/version + +pip install rocketride +ROCKETRIDE_URI=http://127.0.0.1:5565 rocketride validate examples/*.pipe + +docker rm -f rocketride-engine +``` + +`rocketride validate` exits `0` when all files are valid, `1` when at least +one file fails validation, and `2` on usage errors, unreadable files, or +connection failures. Add `--json` for a machine-readable report. diff --git a/.github/actions/validate-pipes/action.yml b/.github/actions/validate-pipes/action.yml new file mode 100644 index 000000000..a4f83705a --- /dev/null +++ b/.github/actions/validate-pipes/action.yml @@ -0,0 +1,221 @@ +name: 'Validate RocketRide Pipelines' +description: >- + Start a RocketRide engine in Docker and validate .pipe pipeline files with + the rocketride CLI. Fails the job when any pipeline is invalid. +author: 'RocketRide' + +branding: + icon: 'check-circle' + color: 'orange' + +inputs: + files: + description: >- + Glob pattern(s) selecting the .pipe files to validate. Multiple patterns + may be separated by whitespace. Patterns are expanded with bash globstar, + so '**' recurses into subdirectories. + required: false + default: '**/*.pipe' + changed-only: + description: >- + On pull_request events, validate only the matching .pipe files that + changed relative to the PR base (via git diff). Ignored on other events. + required: false + default: 'true' + engine-image: + description: 'Docker image of the RocketRide engine to validate against.' + required: false + default: 'ghcr.io/rocketride-org/rocketride-engine:latest' + engine-port: + description: "Host port mapped to the engine container's internal port 5565." + required: false + default: '5565' + api-key: + description: >- + Optional API key for the engine (passed to the CLI via the + ROCKETRIDE_APIKEY environment variable, never via argv). + required: false + default: '' + python-version: + description: 'Python version used to run the rocketride CLI.' + required: false + default: '3.12' + cli-version: + description: >- + pip version specifier for the rocketride CLI (e.g. '==1.2.3' or + '>=1.2.3'). Must resolve to a release that includes the validate + subcommand; the install step fails fast when it does not. Installs + the latest release when empty. + required: false + default: '' + fail-on-warnings: + description: >- + Fail the job when a pipeline validates with warnings (the CLI itself + exits 0 on warnings). + required: false + default: 'false' + +runs: + using: 'composite' + steps: + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install rocketride CLI + shell: bash + env: + CLI_VERSION: ${{ inputs.cli-version }} + run: | + set -euo pipefail + python -m pip install --quiet --upgrade pip + python -m pip install --quiet "rocketride${CLI_VERSION}" + rocketride --help > /dev/null + # Fail fast when the installed release predates the validate command; + # otherwise the failure would surface later as a confusing argparse + # "invalid choice: 'validate'" usage error (exit code 2). + if ! rocketride validate --help > /dev/null 2>&1; then + installed="$(python -m pip show rocketride | sed -n 's/^Version: //p')" + echo "::error::rocketride ${installed:-unknown} does not support the 'validate' subcommand. Set the cli-version input to a release that includes it." + exit 2 + fi + + - name: Start RocketRide engine + id: engine + shell: bash + env: + ENGINE_IMAGE: ${{ inputs.engine-image }} + ENGINE_PORT: ${{ inputs.engine-port }} + run: | + set -euo pipefail + if ! [[ "${ENGINE_PORT}" =~ ^[0-9]+$ ]]; then + echo "::error::engine-port must be a number, got '${ENGINE_PORT}'" + exit 2 + fi + # The engine always listens on 5565 inside the container + # (see docker/docker-compose.yml). + cid="$(docker run -d -p "${ENGINE_PORT}:5565" "${ENGINE_IMAGE}")" + echo "cid=${cid}" >> "${GITHUB_OUTPUT}" + echo "Started engine container ${cid} (${ENGINE_IMAGE}) on host port ${ENGINE_PORT}." + + - name: Wait for engine health + shell: bash + env: + ENGINE_PORT: ${{ inputs.engine-port }} + ENGINE_CID: ${{ steps.engine.outputs.cid }} + run: | + set -euo pipefail + # /version is the engine's unconditionally public route (server.py + # registers it outside the standardEndpoints gate), so it works on + # every published engine image; /ping is gated and 404s on some. + deadline=$(( SECONDS + 120 )) + until curl -fsS "http://127.0.0.1:${ENGINE_PORT}/version" > /dev/null 2>&1; do + if (( SECONDS >= deadline )); then + echo "::error::RocketRide engine did not become healthy within 120s." + echo '--- engine logs (last 100 lines) ---' + docker logs --tail 100 "${ENGINE_CID}" || true + exit 2 + fi + sleep 2 + done + echo 'Engine is healthy.' + + - name: Validate pipeline files + shell: bash + env: + FILES_GLOB: ${{ inputs.files }} + CHANGED_ONLY: ${{ inputs.changed-only }} + FAIL_ON_WARNINGS: ${{ inputs.fail-on-warnings }} + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + ROCKETRIDE_URI: http://127.0.0.1:${{ inputs.engine-port }} + RR_API_KEY_INPUT: ${{ inputs.api-key }} + run: | + set -euo pipefail + shopt -s globstar nullglob + + # Expand the configured glob pattern(s). + candidates=() + declare -A seen=() + read -ra patterns <<< "${FILES_GLOB//$'\n'/ }" + for pattern in "${patterns[@]}"; do + # shellcheck disable=SC2086 # intentional glob expansion + for f in ${pattern}; do + if [[ -f "${f}" && -z "${seen[${f}]:-}" ]]; then + seen["${f}"]=1 + candidates+=("${f}") + fi + done + done + + # On pull requests, optionally narrow to files changed vs the base. + files=() + if [[ "${CHANGED_ONLY}" == 'true' && "${EVENT_NAME}" == 'pull_request' && -n "${BASE_SHA}" ]]; then + if git fetch --quiet --no-tags --depth=1 origin "${BASE_SHA}"; then + declare -A changed=() + while IFS= read -r f; do + if [[ -n "${f}" ]]; then + changed["${f}"]=1 + fi + done < <(git diff --name-only --diff-filter=ACMR "${BASE_SHA}" HEAD) + for f in ${candidates[@]+"${candidates[@]}"}; do + if [[ -n "${changed[${f}]:-}" ]]; then + files+=("${f}") + fi + done + else + echo "::warning::Could not fetch PR base ${BASE_SHA}; validating all matching files instead." + files=(${candidates[@]+"${candidates[@]}"}) + fi + else + files=(${candidates[@]+"${candidates[@]}"}) + fi + + if (( ${#files[@]} == 0 )); then + echo "::warning::No .pipe files to validate (files='${FILES_GLOB}', changed-only=${CHANGED_ONLY})." + exit 0 + fi + + # Pass the API key via the environment only when set, never via argv. + if [[ -n "${RR_API_KEY_INPUT}" ]]; then + export ROCKETRIDE_APIKEY="${RR_API_KEY_INPUT}" + fi + + echo "Validating ${#files[@]} file(s) against ${ROCKETRIDE_URI}:" + printf ' %s\n' "${files[@]}" + + if [[ "${FAIL_ON_WARNINGS}" == 'true' ]]; then + report="${RUNNER_TEMP}/rocketride-validate-report.json" + rc=0 + rocketride validate "${files[@]}" --json > "${report}" || rc=$? + cat "${report}" + if (( rc != 0 )); then + exit "${rc}" + fi + python3 - "${report}" <<'PY' + import json + import sys + + with open(sys.argv[1], encoding='utf-8') as fh: + report = json.load(fh) + + warned = [f['file'] for f in report.get('files', []) if f.get('warnings')] + for name in warned: + print(f'::error::fail-on-warnings: {name} validated with warnings') + sys.exit(1 if warned else 0) + PY + else + rocketride validate "${files[@]}" + fi + + - name: Stop RocketRide engine + if: always() + shell: bash + env: + ENGINE_CID: ${{ steps.engine.outputs.cid }} + run: | + set -euo pipefail + if [[ -n "${ENGINE_CID}" ]]; then + docker rm -f "${ENGINE_CID}" > /dev/null 2>&1 || true + fi From 32649c2c88e5866c580b3b30d651bdf8beb83cd0 Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:02:34 +0530 Subject: [PATCH 5/5] docs: document the validate CLI subcommand and CI usage Adds the validate synopsis, flags, exit-code semantics, and examples to the Python and TypeScript client READMEs and the CLI reference, with a pointer to the validate-pipes GitHub Action. Co-Authored-By: Claude Fable 5 --- docs/README-python-client.md | 62 ++++++++++++++++++++++ docs/README-typescript-client.md | 79 ++++++++++++++++++++++++++++ packages/docs/content-static/cli.mdx | 47 +++++++++++++++-- 3 files changed, 184 insertions(+), 4 deletions(-) diff --git a/docs/README-python-client.md b/docs/README-python-client.md index 37f79bf7a..161a6f6c9 100644 --- a/docs/README-python-client.md +++ b/docs/README-python-client.md @@ -552,6 +552,7 @@ rocketride start pipeline.pipe # Start a pipeline rocketride upload *.pdf --token # Upload files to a running pipeline rocketride status --token # Monitor task progress rocketride stop --token # Terminate a running task +rocketride validate examples/*.pipe # Validate pipelines without running them rocketride list # List all active tasks rocketride events ALL --token # Stream task events rocketride rrext_store get_all_projects # List stored projects @@ -559,6 +560,67 @@ rocketride rrext_store get_all_projects # List stored projects All commands accept `--uri` and `--apikey` flags, or read from environment variables. +### validate + +Validates one or more `.pipe` files against the server without starting them. Glob patterns are expanded by the CLI itself, so wildcards work the same on shells that do not expand them (e.g. Windows). Files must contain a JSON object — either the pipeline config itself or the `{ "pipeline": { ... } }` wrapper that `.pipe` files use; a file that cannot be read or parsed is reported as invalid. + +```bash +rocketride validate [--source ] [--json] +``` + +| Flag / argument | Description | +| --------------- | ---------------------------------------------------------------------------- | +| `files...` | One or more `.pipe` file paths or glob patterns (e.g. `examples/*.pipe`). | +| `--source ` | Override the source component ID used for validation. | +| `--json` | Print a machine-readable JSON report to stdout instead of human output. | + +Exit codes make `validate` suitable for CI: + +| Code | Meaning | +| ---- | ------------------------------------------------------------------------------------------------------------------------------ | +| `0` | All files are valid. | +| `1` | At least one file failed validation (a file that cannot be read or parsed counts as invalid when other files were validated). | +| `2` | Usage error, connection failure, or no file could be processed at all — i.e. no file received a server validation verdict (e.g. every file was missing or not valid JSON). | + +**Example:** + +```bash +rocketride validate examples/rag-pipeline.pipe +``` + +```text +✓ examples/rag-pipeline.pipe: valid + +Summary: 1 file(s), 1 valid, 0 invalid +``` + +**Example - JSON output:** + +```bash +rocketride validate examples/rag-pipeline.pipe --json +``` + +```json +{ + "files": [ + { + "file": "examples/rag-pipeline.pipe", + "valid": true, + "errors": [], + "warnings": [] + } + ], + "summary": { + "total": 1, + "valid": 1, + "invalid": 0 + } +} +``` + +To validate `.pipe` files automatically on every pull request, use the ready-made GitHub Action at [`.github/actions/validate-pipes`](https://github.com/rocketride-org/rocketride-server/tree/develop/.github/actions/validate-pipes). +It starts an engine container and runs `rocketride validate` on your repository's pipeline files. + ## Configuration | Variable | Description | diff --git a/docs/README-typescript-client.md b/docs/README-typescript-client.md index 7bf6f6fd2..86d29c58b 100644 --- a/docs/README-typescript-client.md +++ b/docs/README-typescript-client.md @@ -68,6 +68,7 @@ You build your `.pipe` - and you run it against the fastest AI runtime available - **File upload** - `sendFiles()` with progress; streaming with `pipe()` - **Connection lifecycle** - Optional persist mode, reconnection, and callbacks (`onConnected`, `onDisconnected`, `onConnectError`) - **Full TypeScript support** - Complete type definitions +- **CLI included** - Manage pipelines from the command line --- @@ -464,6 +465,84 @@ await client.disconnect(); --- +## CLI + +The `rocketride` command is installed automatically with the package. + +```bash +rocketride start --pipeline pipeline.pipe # Start a pipeline +rocketride upload *.pdf --token # Upload files to a running pipeline +rocketride status --token # Monitor task progress +rocketride stop --token # Terminate a running task +rocketride validate examples/*.pipe # Validate pipelines without running them +rocketride store dir / # File store operations +``` + +All commands accept `--uri` and `--apikey` flags, or read from the `ROCKETRIDE_URI` and `ROCKETRIDE_APIKEY` environment variables. + +### validate + +Validates one or more `.pipe` files against the server without starting them. Glob patterns are expanded by the CLI itself, so wildcards work the same on shells that do not expand them (e.g. Windows). Files must contain a JSON object — either the pipeline config itself or the `{ "pipeline": { ... } }` wrapper that `.pipe` files use; a file that cannot be read or parsed is reported as invalid. + +```bash +rocketride validate [--source ] [--json] +``` + +| Flag / argument | Description | +| --------------- | ---------------------------------------------------------------------------- | +| `files...` | One or more `.pipe` file paths or glob patterns (e.g. `examples/*.pipe`). | +| `--source ` | Override the source component ID used for validation. | +| `--json` | Print a machine-readable JSON report to stdout instead of human output. | + +Exit codes make `validate` suitable for CI: + +| Code | Meaning | +| ---- | ------------------------------------------------------------------------------------------------------------------------------ | +| `0` | All files are valid. | +| `1` | At least one file failed validation (a file that cannot be read or parsed counts as invalid when other files were validated). | +| `2` | Usage error, connection failure, or no file could be processed at all — i.e. no file received a server validation verdict (e.g. every file was missing or not valid JSON). | + +**Example:** + +```bash +rocketride validate examples/rag-pipeline.pipe +``` + +```text +✓ examples/rag-pipeline.pipe: valid + +Summary: 1 file(s), 1 valid, 0 invalid +``` + +**Example - JSON output:** + +```bash +rocketride validate examples/rag-pipeline.pipe --json +``` + +```json +{ + "files": [ + { + "file": "examples/rag-pipeline.pipe", + "valid": true, + "errors": [], + "warnings": [] + } + ], + "summary": { + "total": 1, + "valid": 1, + "invalid": 0 + } +} +``` + +To validate `.pipe` files automatically on every pull request, use the ready-made GitHub Action at [`.github/actions/validate-pipes`](https://github.com/rocketride-org/rocketride-server/tree/develop/.github/actions/validate-pipes). +It starts an engine container and runs `rocketride validate` on your repository's pipeline files. + +--- + ## Links - [Documentation](https://docs.rocketride.org/) diff --git a/packages/docs/content-static/cli.mdx b/packages/docs/content-static/cli.mdx index 2d5d24efc..ef14e9870 100644 --- a/packages/docs/content-static/cli.mdx +++ b/packages/docs/content-static/cli.mdx @@ -13,10 +13,10 @@ and manages the engine's file store — the same operations the installing either package puts `rocketride` on your path. > **Python vs TypeScript CLI:** Both packages ship a `rocketride` command. The -> core commands (`start`, `upload`, `status`, `stop`, `store`) are available in -> both, but flag names differ in a few places and the Python CLI includes two -> additional commands (`events`, `list`) not present in TypeScript. Differences -> are called out inline below. +> core commands (`start`, `upload`, `status`, `stop`, `validate`, `store`) are +> available in both, but flag names differ in a few places and the Python CLI +> includes two additional commands (`events`, `list`) not present in +> TypeScript. Differences are called out inline below. ## Install @@ -138,6 +138,7 @@ connection is encrypted. | `upload` | Send files through a pipeline (by `--pipeline` or an existing task token). | | `status` | Monitor a running task's status continuously. | | `stop` | Stop a running task. | +| `validate` | Validate `.pipe` files against the engine without executing them. | | `events` | Stream all raw events from a running task. Python CLI only. | | `list` | List all active tasks. Python CLI only. | | `store` | File-store operations: `dir`, `type`, `write`, `rm`, `mkdir`, `stat`. | @@ -223,6 +224,44 @@ rocketride stop --token The token is printed by `start` and `upload` at the beginning of the run, and by `status`. Save it if you need to stop a long-running pipeline later. +### validate + +Use `validate` to check one or more `.pipe` files against the engine without +executing them — before deploying a pipeline, or as a CI gate on every pull +request. Glob patterns are expanded by the CLI itself, so wildcards behave the +same on Windows shells. Files must contain a JSON object — either the pipeline +config itself or the `{ "pipeline": { ... } }` wrapper that `.pipe` files use; +a file that cannot be read or parsed is reported as invalid. + +```bash +# Validate a single pipeline +rocketride validate ./rag.pipe + +# Validate every pipeline in a directory +rocketride validate ./pipelines/*.pipe + +# Machine-readable report for CI +rocketride validate ./pipelines/*.pipe --json +``` + +Key flags: + +| Flag | Description | +| --- | --- | +| `` | One or more `.pipe` files or glob patterns. | +| `--source ` | Override the source component ID used for validation. | +| `--json` | Print a machine-readable JSON report (`files` + `summary`) to stdout. | + +Exit codes: `0` — all files valid; `1` — at least one file failed validation +(a file that cannot be read or parsed counts as invalid); `2` — usage error, +connection failure, or no file could be processed at all (no file received a +server validation verdict). + +> Runs in CI too: the +> [validate-pipes GitHub Action](https://github.com/rocketride-org/rocketride-server/tree/develop/.github/actions/validate-pipes) +> starts an engine container and runs `rocketride validate` on your +> repository's `.pipe` files. + ### events Use `events` to stream all raw events from a running task to your terminal or a