Skip to content

The System Executor

If the Orchestrator is the state machine, the SystemExecutor is the engine that physically mutates the host. It is responsible for translating the declarative EnvironmentManifest into imperative disk operations and shell commands.

By strictly confining all physical mutations to this single class, Protostar ensures that partial failures (such as a missing dependency or a syntax error in an existing file) do not leave the workspace in a fragmented, irrecoverable state.

  • Abstract Syntax Tree (AST) Preservation

    Configuration files (like pyproject.toml) are not blindly overwritten or manipulated via fragile regex. They are parsed into ASTs, deeply merged, and serialized back to disk, preserving all user comments and structural formatting.

  • Pre-Execution Validation

    Before a single directory is created, the Executor validates the syntax of all target files. If a user has a malformed TOML file, execution halts immediately rather than failing halfway through the sequence.

  • Subprocess Isolation

    All shell executions (e.g., uv add, git init) are routed through a sandboxed wrapper. Standard output and error streams are captured, preventing silent failures and ensuring critical telemetry is preserved for debugging.


The Execution Topology

The SystemExecutor processes the manifest sequentially. This exact chronological ordering is critical to prevent race conditions (e.g., attempting to append configurations to a pyproject.toml before uv init has generated it).

flowchart TD
    classDef core fill:#1e293b,stroke:#00e5ff,stroke-width:2px,color:#fff;
    classDef io_file fill:#334155,stroke:#475569,stroke-width:1px,color:#e2e8f0;
    classDef io_shell fill:#0f172a,stroke:#3b82f6,stroke-width:1px,color:#e2e8f0;

    Start([Execute Manifest]) --> Prep

    subgraph Prep [Initialization & Base Scaffolding]
        direction LR
        V[1. Validate AST Targets]:::io_file --> D[2. Scaffold Directories]:::io_file
        D --> I[3. Write Injected Files]:::io_file
        I --> PC[4. Write pre-commit Config]:::io_file
    end

    Prep --> ST[5. Execute System Tasks]:::io_shell

    ST --> Synthesis

    subgraph Synthesis [Late-Binding Configurations]
        direction LR
        AF[6. AST Merge & File Appends]:::io_file --> IG[7. Deduplicate Ignores]:::io_file
        IG --> DOCK[8. Write Docker Artifacts]:::io_file
        DOCK --> IDE[9. Write IDE Settings]:::io_file
    end

    Synthesis --> Runtime

    subgraph Runtime [Dependency Resolution]
        direction LR
        DEP[10. Resolve Dependencies]:::io_shell --> PT[11. Execute Post-Install Tasks]:::io_shell
    end

    Runtime --> End([Execution Complete]):::core

AST Deep Merging

When merging arrays or configuration tables into existing TOML files, Protostar utilizes tomlkit to manipulate the Abstract Syntax Tree. This is handled by the recursive _deep_merge_tomlkit() method.

The merge behavior is governed by the orchestrator's resolved CollisionStrategy:

  • Merge (Default): The executor walks the AST, appending missing keys and extending Array of Tables (AoT). Existing scalar values or sibling tables that are not explicitly targeted by the payload are safely ignored and preserved.

  • Overwrite: The executor aggressively prunes the target. If the payload defines a specific table (e.g., [tool.ruff]), any existing scalar keys within that table on the host that do not exist in the payload are purged, forcing strict parity with Protostar's baseline.

Dynamic Python Version Resolution

During the file append phase, the executor dynamically resolves the target environment's Python version (scanning pyproject.toml, .venv/pyvenv.cfg, or the configuration fallback). Any {{PYTHON_VERSION}} tokens within the injected payloads are interpolated before the AST is evaluated.


Subprocess Telemetry

Directly calling subprocess.run in a CLI tool often leads to silent failures or messy interleaved terminal output. Protostar routes all system tasks and dependency resolutions through protostar.system.execute_subprocess.

This wrapper executes the command silently while capturing both stdout and stderr, and enforces granular task-level timeouts to prevent the orchestrator from blocking indefinitely on stalled network requests. If the process returns a non-zero exit code or exceeds its execution timeout, the streams are concatenated and raised within a RuntimeError. This ensures the Orchestrator can catch the failure and present the raw diagnostics to the user without dropping context.

Simulated Subprocess Telemetry Output

When a shell execution fails, the captured streams are formatted to pinpoint the exact failure mechanism:

Command failed during setup: uv init --python 3.99

Diagnostics:
--- STDERR ---
error: Failed to download python 3.99
Caused by: No downloadable Python versions matching: 3.99

API Reference

Core Interface: SystemExecutor

protostar.executor.SystemExecutor

Executes the materialized environment manifest by mutating the local disk and shell.

Source code in src/protostar/executor.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
class SystemExecutor:
    """Executes the materialized environment manifest by mutating the local disk and shell."""

    def __init__(
        self,
        manifest: EnvironmentManifest,
        config: ProtostarConfig,
        docker: bool = False,
    ) -> None:
        """Initializes the executor with the target manifest state.

        Args:
            manifest: The centralized state object containing all execution directives.
            config: The active Protostar configuration instance.
            docker: If True, scaffolds a .dockerignore from the manifest ignores.
        """
        self.manifest = manifest
        self.config = config
        self.docker = docker
        self.warnings: list[str] = []

    def execute(self) -> None:
        """Executes the materialized manifest in a deterministic sequence."""
        self._validate_targets()
        self._create_directories()
        self._write_injected_files()
        self._write_pre_commit_config()
        self._execute_tasks()
        self._append_files()
        self._write_ignores()
        self._write_docker_artifacts()
        self._write_ide_settings()
        self._install_dependencies()
        self._execute_post_install_tasks()

    def _validate_targets(self) -> None:
        """Validates the syntax of existing target files before disk I/O begins.

        Uses the C-optimized tomllib to quickly evaluate target workspace files,
        ensuring that subsequent tomlkit operations will not fail mid-execution
        and leave the environment fragmented.

        Raises:
            SystemExit: If an existing target TOML file contains syntax errors.
        """
        for filepath in self.manifest.file_appends:
            target = Path(filepath)
            if target.suffix == ".toml" and target.exists():
                try:
                    with target.open("rb") as f:
                        tomllib.load(f)
                except tomllib.TOMLDecodeError as e:
                    console.print(
                        f"\n[bold red]Validation Failure:[/bold red] Syntax error in existing workspace file: {filepath}"
                    )
                    console.print(f"Details: {e}")
                    console.print(
                        "\nProtostar cannot safely merge configurations into a malformed file. "
                        "Please fix the syntax error and re-run the command."
                    )
                    sys.exit(1)

    def _write_pre_commit_config(self) -> None:
        """Assembles and interpolates the pre-commit configuration."""
        if not self.manifest.wants_pre_commit:
            return

        target = Path(".pre-commit-config.yaml")
        if (
            target.exists()
            and self.manifest.collision_strategy != CollisionStrategy.OVERWRITE
        ):
            logger.debug(
                "Skipping .pre-commit-config.yaml generation; file already exists."
            )
            return

        base_yaml = """repos:
  # 1. Generic hooks (configured to ignore Python to avoid formatting conflicts)
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
        exclude: \\.py$
      - id: end-of-file-fixer
        exclude: \\.py$
      - id: check-yaml
      - id: check-added-large-files
"""
        hooks_yaml = "\n".join(self.manifest.pre_commit_hooks)
        full_yaml = f"{base_yaml}\n{hooks_yaml}\n" if hooks_yaml else f"{base_yaml}\n"

        if "{{MYPY_DEPENDENCIES}}" in full_yaml:
            deps = self.manifest.dependencies + self.manifest.dev_dependencies
            if deps:
                deps_formatted = "\n".join(f"          - {d}" for d in deps)
            else:
                deps_formatted = "          []"
            full_yaml = full_yaml.replace("{{MYPY_DEPENDENCIES}}", deps_formatted)

        target.write_text(full_yaml)
        logger.debug("Scaffolded .pre-commit-config.yaml")

    def _write_injected_files(self) -> None:
        """Writes all queued boilerplate files to the local workspace."""
        if not self.manifest.file_injections:
            return

        for filepath, content in self.manifest.file_injections.items():
            target = Path(filepath)
            if (
                not target.exists()
                or self.manifest.collision_strategy == CollisionStrategy.OVERWRITE
            ):
                target.parent.mkdir(parents=True, exist_ok=True)
                target.write_text(content)
                logger.debug(f"Injected configuration file: {filepath}")

    def _create_directories(self) -> None:
        """Scaffolds all queued directories in the local workspace."""
        if not self.manifest.directories:
            return

        for dir_path in self.manifest.directories:
            path = Path(dir_path)
            path.mkdir(parents=True, exist_ok=True)
            logger.debug(f"Scaffolded directory: {path}")

    def _execute_tasks(self) -> None:
        """Runs the accumulated system tasks (e.g., initialization commands)."""
        for task in self.manifest.system_tasks:
            binary_name = Path(task.command[0]).name
            msg = task.description or f"Propelling sequence: {binary_name}"
            with console.status(msg):
                execute_subprocess(task.command, timeout=task.timeout)

    def _execute_post_install_tasks(self) -> None:
        """Runs accumulated tasks that require dependencies to be installed first."""
        for task in self.manifest.post_install_tasks:
            binary_name = Path(task.command[0]).name
            msg = task.description or f"Propelling sequence: {binary_name}"
            with console.status(msg):
                execute_subprocess(task.command, timeout=task.timeout)

    def _deep_merge_tomlkit(
        self, base: Any, payload: Any, overwrite: bool = False
    ) -> None:
        """Recursively deep-merges a tomlkit payload into a base document.

        Args:
            base: The existing tomlkit document or table to mutate.
            payload: The incoming tomlkit table to merge into the base.
            overwrite: If True, unmatched scalar keys in the base will be purged,
                and array-of-tables will be completely replaced.
        """
        import tomlkit.items

        # Purge scalar/array keys in base that are missing from the payload
        # to enforce strict AST overwriting, while preserving sibling tables.
        if overwrite:
            keys_to_remove = []
            for b_key, b_val in base.items():
                if b_key not in payload and not isinstance(
                    b_val, (tomlkit.items.Table, tomlkit.items.AoT)
                ):
                    keys_to_remove.append(b_key)
            for k in keys_to_remove:
                del base[k]

        for key, value in payload.items():
            if key in base:
                if isinstance(value, tomlkit.items.Table):
                    # Type Parity Guard
                    if not isinstance(base[key], tomlkit.items.Table):
                        self.warnings.append(
                            f"TOML Merge Collision: Expected a Table for key '{key}', "
                            f"but found {type(base[key]).__name__}. Skipping injection."
                        )
                        continue

                    has_sub_tables = any(
                        isinstance(v, (tomlkit.items.Table, tomlkit.items.AoT))
                        for v in value.values()
                    )

                    if overwrite and not has_sub_tables:
                        base[key] = value
                    else:
                        self._deep_merge_tomlkit(base[key], value, overwrite)

                elif isinstance(value, tomlkit.items.AoT):
                    # Type Parity Guard
                    if not isinstance(base[key], tomlkit.items.AoT):
                        self.warnings.append(
                            f"TOML Merge Collision: Expected an Array of Tables for key '{key}', "
                            f"but found {type(base[key]).__name__}. Skipping injection."
                        )
                        continue

                    if overwrite:
                        base[key] = value
                    else:
                        for item in value:
                            base[key].append(item)
                else:
                    base[key] = value
            else:
                if isinstance(value, tomlkit.items.Table):
                    value.add(tomlkit.nl())
                elif isinstance(value, tomlkit.items.AoT) and len(value) > 0:
                    value[-1].add(tomlkit.nl())

                base[key] = value

    def _append_files(self) -> None:
        """Appends late-binding configuration payloads to their target files."""
        if not self.manifest.file_appends:
            return

        # Resolve the active Python version via a fallback chain
        python_version = None

        # 1. pyproject.toml `requires-python` (uv-managed projects)
        pyproject_path = Path("pyproject.toml")
        if pyproject_path.exists():
            try:
                with pyproject_path.open("rb") as f:
                    pyproject_data = tomllib.load(f)
                    req_python = pyproject_data.get("project", {}).get(
                        "requires-python", ""
                    )
                    match = re.search(r"(\d+\.\d+)", req_python)
                    if match:
                        python_version = match.group(1)
                        logger.debug(
                            f"Resolved Python version {python_version} from pyproject.toml"
                        )
            except Exception as e:
                logger.debug(f"Failed to parse pyproject.toml for python version: {e}")

        # 2. .venv/pyvenv.cfg `version` field (pip/venv-managed projects)
        if not python_version:
            pyvenv_path = Path(".venv/pyvenv.cfg")
            if pyvenv_path.exists():
                content = pyvenv_path.read_text()
                match = re.search(r"^version\s*=\s*(\d+\.\d+)", content, re.MULTILINE)
                if match:
                    python_version = match.group(1)
                    logger.debug(
                        f"Resolved Python version {python_version} from pyvenv.cfg"
                    )
                else:
                    logger.warning(
                        "Found .venv/pyvenv.cfg but could not extract Python version. "
                        "Falling back to default."
                    )

        # 3. Protostar config or hardcoded default
        if not python_version:
            python_version = self.config.python_version or "3.13"

        for filepath, contents in self.manifest.file_appends.items():
            target = Path(filepath)
            original_content = target.read_text() if target.exists() else ""
            if not target.exists():
                target.parent.mkdir(parents=True, exist_ok=True)

            if target.suffix == ".toml":
                import tomlkit

                doc = (
                    tomlkit.parse(original_content)
                    if original_content
                    else tomlkit.document()
                )
                ast_mutated = False

                for payload in contents:
                    interpolated = payload.replace("{{PYTHON_VERSION}}", python_version)
                    try:
                        payload_doc = tomlkit.parse(interpolated)
                        ast_mutated = True
                        is_overwrite = (
                            self.manifest.collision_strategy
                            == CollisionStrategy.OVERWRITE
                        )
                        self._deep_merge_tomlkit(
                            doc, payload_doc, overwrite=is_overwrite
                        )
                    except Exception as e:
                        console.print(
                            f"\n[bold red]Internal Error:[/bold red] Failed to parse injected TOML payload for {filepath}.\nDetails: {e}"
                        )
                        sys.exit(1)

                if ast_mutated:
                    new_content = tomlkit.dumps(doc)
                    new_content = re.sub(r"\n{3,}", "\n\n", new_content)
                    if new_content.strip() != original_content.strip():
                        target.write_text(new_content)
                        logger.debug(f"Updated configuration AST in {filepath}")
                continue

            existing_clean = original_content.rstrip()
            missing_payloads = []

            for payload in contents:
                interpolated = payload.replace("{{PYTHON_VERSION}}", python_version)

                # Generate a deterministic boundary marker
                payload_hash = hashlib.md5(payload.encode("utf-8")).hexdigest()[:8]
                marker = f"# --- Protostar Injection: {payload_hash} ---"

                if (
                    marker in original_content
                    and self.manifest.collision_strategy != CollisionStrategy.OVERWRITE
                ):
                    continue

                framed_payload = f"{marker}\n{interpolated.strip()}\n# --- End Protostar Injection ---"
                missing_payloads.append(framed_payload)

            if not missing_payloads:
                continue

            combined_content = "\n\n".join(missing_payloads)
            prefix = "\n\n" if existing_clean and combined_content else ""
            target.write_text(existing_clean + prefix + combined_content + "\n")
            logger.debug(f"Updated configuration string block in {filepath}")

    def _write_ignores(self) -> None:
        """Deduplicates and appends paths to the local .gitignore."""
        if not self.manifest.vcs_ignores:
            return

        gitignore = Path(".gitignore")
        existing_content = gitignore.read_text() if gitignore.exists() else ""
        missing = [p for p in self.manifest.vcs_ignores if p not in existing_content]

        if missing:
            with gitignore.open("a") as f:
                prefix = (
                    "\n"
                    if existing_content and not existing_content.endswith("\n")
                    else ""
                )
                f.write(prefix + "\n".join(sorted(missing)) + "\n")
            logger.debug(f"Appended {len(missing)} items to .gitignore")

    def _write_docker_artifacts(self) -> None:
        """Generates a .dockerignore to optimize container build contexts."""
        if not self.docker:
            return

        dockerignore = Path(".dockerignore")
        existing_content = dockerignore.read_text() if dockerignore.exists() else ""
        base_ignores = {".git/", "tests/", "docs/", "README*", ".vscode/", ".idea/"}

        has_uv_init = any(
            task.command[:2] == ["uv", "init"] for task in self.manifest.system_tasks
        )
        if has_uv_init:
            base_ignores.add(".python-version")

        combined_ignores = self.manifest.vcs_ignores | base_ignores
        missing = [p for p in combined_ignores if p not in existing_content]

        if missing:
            with dockerignore.open("a") as f:
                prefix = (
                    "\n"
                    if existing_content and not existing_content.endswith("\n")
                    else ""
                )
                f.write(prefix + "\n".join(sorted(missing)) + "\n")
            logger.debug(f"Appended {len(missing)} items to .dockerignore")

    def _write_ide_settings(self) -> None:
        """Writes the aggregated IDE configuration to the appropriate local files."""
        if not self.manifest.ide_settings:
            return

        vscode_dir = Path(".vscode")
        settings_path = vscode_dir / "settings.json"
        settings = {}

        if settings_path.exists():
            original_content = settings_path.read_text()
            if not original_content.strip():
                # Handle completely empty files gracefully by defaulting to {}
                pass
            else:
                try:
                    parsed_data = json.loads(original_content)
                    if not isinstance(parsed_data, dict):
                        raise ValueError("Root JSON element is not an object.")
                    settings = parsed_data
                except (json.JSONDecodeError, ValueError):
                    console.print(
                        "Existing settings.json contains comments, "
                        "trailing commas, or is malformed. Skipping IDE settings injection "
                        "to prevent data loss."
                    )
                    return

        # 1-level deep dictionary merge
        for key, value in self.manifest.ide_settings.items():
            if isinstance(value, dict) and isinstance(settings.get(key), dict):
                settings[key].update(value)
            else:
                settings[key] = value

        vscode_dir.mkdir(exist_ok=True)
        # json.dumps inherently preserves dictionary insertion order in standard CPython
        settings_path.write_text(json.dumps(settings, indent=4) + "\n")

    def _install_dependencies(self) -> None:
        """Installs queued dependencies using the active Python manager."""
        if not self.manifest.dependencies and not self.manifest.dev_dependencies:
            return

        # Apply a generous 10-minute leash for heavy, network-bound payload resolutions
        resolution_timeout = 600

        if self.config.python_package_manager == "uv":
            if self.manifest.dependencies:
                cmd = ["uv", "add", *self.manifest.dependencies]
                try:
                    with console.status(
                        f"Resolving and injecting {len(self.manifest.dependencies)} payloads"
                    ):
                        execute_subprocess(cmd, timeout=resolution_timeout)
                except RuntimeError as e:
                    self.warnings.append(f"Standard dependency resolution failed: {e}")

            if self.manifest.dev_dependencies:
                dev_cmd = ["uv", "add", "--dev", *self.manifest.dev_dependencies]
                try:
                    with console.status(
                        f"Resolving and installing {len(self.manifest.dev_dependencies)} development dependencies"
                    ):
                        execute_subprocess(dev_cmd, timeout=resolution_timeout)
                except RuntimeError as e:
                    self.warnings.append(
                        f"Development dependency resolution failed: {e}"
                    )
        else:
            venv_pip = Path(".venv/bin/pip")
            pip_cmd = str(venv_pip) if venv_pip.exists() else "pip"
            all_deps = self.manifest.dependencies + self.manifest.dev_dependencies
            cmd = [pip_cmd, "install", *all_deps]

            try:
                with console.status(
                    f"Resolving and installing {len(all_deps)} total dependencies"
                ):
                    execute_subprocess(cmd, timeout=resolution_timeout)
            except RuntimeError as e:
                self.warnings.append(f"Pip dependency resolution failed: {e}")

            req_path = Path("requirements.txt")
            if req_path.exists():
                console.print(
                    "[yellow]Warning:[/yellow] requirements.txt already exists. "
                    "Dependencies were installed to the virtual environment, but the file was not overwritten."
                )
            else:
                try:
                    result = subprocess.run(
                        [pip_cmd, "freeze"],
                        capture_output=True,
                        text=True,
                        check=True,
                        timeout=30,
                    )
                    req_path.write_text(result.stdout)
                    logger.debug("Successfully froze dependencies to requirements.txt")
                except subprocess.TimeoutExpired:
                    self.warnings.append(
                        "Failed to freeze dependencies to requirements.txt: Process timed out."
                    )
                except Exception as e:
                    self.warnings.append(
                        f"Failed to freeze dependencies to requirements.txt: {e}"
                    )

__init__

__init__(manifest, config, docker=False)

Initializes the executor with the target manifest state.

Parameters:

Name Type Description Default
manifest EnvironmentManifest

The centralized state object containing all execution directives.

required
config ProtostarConfig

The active Protostar configuration instance.

required
docker bool

If True, scaffolds a .dockerignore from the manifest ignores.

False
Source code in src/protostar/executor.py
def __init__(
    self,
    manifest: EnvironmentManifest,
    config: ProtostarConfig,
    docker: bool = False,
) -> None:
    """Initializes the executor with the target manifest state.

    Args:
        manifest: The centralized state object containing all execution directives.
        config: The active Protostar configuration instance.
        docker: If True, scaffolds a .dockerignore from the manifest ignores.
    """
    self.manifest = manifest
    self.config = config
    self.docker = docker
    self.warnings: list[str] = []

execute

execute()

Executes the materialized manifest in a deterministic sequence.

Source code in src/protostar/executor.py
def execute(self) -> None:
    """Executes the materialized manifest in a deterministic sequence."""
    self._validate_targets()
    self._create_directories()
    self._write_injected_files()
    self._write_pre_commit_config()
    self._execute_tasks()
    self._append_files()
    self._write_ignores()
    self._write_docker_artifacts()
    self._write_ide_settings()
    self._install_dependencies()
    self._execute_post_install_tasks()
Core Interface: execute_subprocess

protostar.system.execute_subprocess

execute_subprocess(cmd, timeout=None)

Executes a subprocess silently and captures telemetry on failure.

Parameters:

Name Type Description Default
cmd list[str]

The command and its arguments as a list of strings.

required
timeout int | None

The maximum execution time in seconds. Defaults to None.

None

Raises:

Type Description
RuntimeError

If the subprocess returns a non-zero exit code or times out.

Source code in src/protostar/system.py
def execute_subprocess(cmd: list[str], timeout: int | None = None) -> None:
    """Executes a subprocess silently and captures telemetry on failure.

    Args:
        cmd: The command and its arguments as a list of strings.
        timeout: The maximum execution time in seconds. Defaults to None.

    Raises:
        RuntimeError: If the subprocess returns a non-zero exit code or times out.
    """
    try:
        subprocess.run(
            cmd,
            check=True,
            capture_output=True,
            text=True,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired as e:
        logger.error(f"Task timed out after {timeout} seconds: {' '.join(cmd)}")
        raise RuntimeError(
            f"Command timed out after {timeout} seconds: {' '.join(cmd)}\n"
            "Hint: This is often caused by a stalled network request or an unresponsive registry."
        ) from e
    except subprocess.CalledProcessError as e:
        # Concatenate both streams to prevent critical context loss
        output_blocks = []
        if e.stdout:
            output_blocks.append(f"--- STDOUT ---\n{e.stdout.strip()}")
        if e.stderr:
            output_blocks.append(f"--- STDERR ---\n{e.stderr.strip()}")

        output = (
            "\n\n".join(output_blocks)
            if output_blocks
            else "No standard output or error captured."
        )
        logger.error(f"Task failed: {' '.join(cmd)}\nOutput:\n{output}")

        # Catch known edge cases where uv fails to resolve/download python versions
        if cmd[0] == "uv" and "python" in output.lower():
            raise RuntimeError(
                f"Command failed during setup: {cmd[0]}\n"
                "Hint: `uv` encountered an error resolving the requested Python version. "
                "If you have a global `uv.toml` (e.g., at `~/.config/uv/uv.toml`), "
                "ensure `python-downloads` is not set to 'never', or verify the requested "
                "version exists locally.\n\n"
                f"Diagnostics:\n{output}"
            ) from e

        error_msg = (
            f"Command failed during setup: {' '.join(cmd)}\n\nDiagnostics:\n{output}"
        )
        raise RuntimeError(error_msg) from e