Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and Base versions are tracked in the repo-root `VERSION` file.

### Fixed

- Made the `tool:bats-core` project artifact platform-aware on Ubuntu/Debian,
mapping it to the system `bats` package instead of planning a Homebrew
`brew install bats-core` command.
- Preflighted `mise` trust before `basectl setup` runs `mise install`, so
untrusted project configs fail with a Base recovery message instead of raw
lower-level `mise install` output.
Expand Down
101 changes: 100 additions & 1 deletion cli/python/base_setup/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import venv
from collections.abc import Iterable
from dataclasses import dataclass
from dataclasses import replace
from pathlib import Path

import base_cli
Expand All @@ -14,6 +15,7 @@
from .checks import ArtifactCheck
from .errors import ArtifactError
from .manifest import ArtifactRequest
from .platform_policy import current_base_platform
from .python_policy import evaluate_python_requirement
from .python_policy import inspect_python_interpreter
from .python_policy import PythonInterpreter
Expand All @@ -24,6 +26,9 @@

PIP_INSTALL_COMMAND_PREFIX = ("-m", "pip", "install", "--disable-pip-version-check")
PYTHON_ARTIFACT_PROBE_TIMEOUT_SECONDS = process.DIAGNOSTIC_TIMEOUT_SECONDS
LINUX_DEBIAN_SYSTEM_TOOL_PACKAGES = {
("tool", "bats-core"): "bats",
}


@dataclass(frozen=True)
Expand Down Expand Up @@ -65,10 +70,26 @@ def resolve_artifact_definitions(artifacts: tuple[ArtifactRequest, ...]) -> tupl
f"'{artifact.name}' of type '{artifact.artifact_type}'. "
"Base does not know how to manage this artifact yet."
)
definitions.append(definition)
definitions.append(platform_artifact_definition(definition))
return tuple(definitions)


def platform_artifact_definition(definition: ArtifactDefinition) -> ArtifactDefinition:
if current_base_platform() != "linux-debian":
return definition

system_package = LINUX_DEBIAN_SYSTEM_TOOL_PACKAGES.get((definition.artifact_type, definition.name))
if system_package is None:
return definition

return replace(
definition,
manager="system-package",
package=system_package,
check_kind="system_command",
)


def merge_artifacts(
default_artifacts: tuple[ArtifactRequest, ...],
manifest_artifacts: tuple[ArtifactRequest, ...],
Expand Down Expand Up @@ -106,6 +127,8 @@ def check_artifact(
) -> ArtifactCheck:
if definition.manager == "homebrew":
return check_homebrew_artifact(project, artifact, definition)
if definition.manager == "system-package":
return check_system_package_artifact(project, artifact, definition)
if definition.manager == "pip":
return check_python_artifact(project, artifact, definition)
return ArtifactCheck(
Expand Down Expand Up @@ -237,6 +260,44 @@ def check_python_artifact(
)


def check_system_package_artifact(
_project: str,
artifact: ArtifactRequest,
definition: ArtifactDefinition,
) -> ArtifactCheck:
if artifact.version != "latest":
return ArtifactCheck(
name=artifact.name,
ok=False,
message=(
f"System package artifact '{artifact.name}' specifies version '{artifact.version}', "
"but Base only supports system package artifact version 'latest' right now."
),
fix=f"Update '{artifact.name}' in the project manifest to use version 'latest'.",
finding_id="BASE-P034",
details=artifact_details(definition),
)

if process.command_exists(definition.package):
return ArtifactCheck(
name=artifact.name,
ok=True,
message=f"Artifact '{artifact.name}' is available through system package '{definition.package}'.",
fix="",
finding_id="BASE-P034",
details=artifact_details(definition),
)

return ArtifactCheck(
name=artifact.name,
ok=False,
message=f"Artifact '{artifact.name}' is missing system package '{definition.package}'.",
fix=f"Run 'basectl setup --yes' or install Ubuntu/Debian package '{definition.package}'.",
finding_id="BASE-P034",
details=artifact_details(definition),
)


def reconcile_artifact(
ctx: base_cli.Context,
definition: ArtifactDefinition,
Expand All @@ -248,6 +309,9 @@ def reconcile_artifact(
if definition.manager == "homebrew":
reconcile_homebrew_artifact(ctx, definition, version, dry_run=dry_run)
return
if definition.manager == "system-package":
reconcile_system_package_artifact(ctx, definition, version, dry_run=dry_run)
return
if definition.manager == "pip":
reconcile_python_artifact(ctx, definition, version, runtime_config, dry_run=dry_run)
return
Expand Down Expand Up @@ -348,6 +412,41 @@ def reconcile_homebrew_artifact(
process.run_command(ctx, install_command)


def reconcile_system_package_artifact(
ctx: base_cli.Context,
definition: ArtifactDefinition,
version: str,
dry_run: bool,
) -> None:
if version != "latest":
raise ArtifactError(
"System package artifact "
f"'{definition.name}' specifies version '{version}', but Base only supports "
"system package artifact version 'latest' right now."
)

if process.command_exists(definition.package):
ctx.log.info(
"Artifact '%s' is already available through system package '%s'.",
definition.name,
definition.package,
)
return

if dry_run:
ctx.log.info(
"[DRY-RUN] Would require system package '%s' for artifact '%s'.",
definition.package,
definition.name,
)
return

raise ArtifactError(
f"System package '{definition.package}' is required for artifact '{definition.name}'. "
"Run 'basectl setup --yes' or install the package manually, then rerun setup."
)


def reconcile_python_artifact(
ctx: base_cli.Context,
definition: ArtifactDefinition,
Expand Down
79 changes: 79 additions & 0 deletions cli/python/base_setup/tests/test_artifacts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

# pylint: disable=too-many-lines

import io
import importlib.util
import os
Expand Down Expand Up @@ -121,6 +123,18 @@ def test_base_dev_manifest_declares_supported_tools(self) -> None:
self.assertIsNotNone(get_artifact_definition("tool", "gh"))
self.assertIsNotNone(get_artifact_definition("tool", "shellcheck"))

def test_bats_core_uses_system_package_on_linux_debian(self) -> None:
artifact = ArtifactRequest("tool", "bats-core", "latest")

with mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}):
definition = artifacts.resolve_artifact_definitions((artifact,))[0]

self.assertEqual(definition.name, "bats-core")
self.assertEqual(definition.manager, "system-package")
self.assertEqual(definition.package, "bats")
self.assertEqual(definition.target, "system")
self.assertEqual(definition.check_kind, "system_command")



def test_docker_and_colima_are_supported_tools(self) -> None:
Expand Down Expand Up @@ -375,6 +389,37 @@ def test_known_homebrew_artifact_dry_run_does_not_require_brew(self) -> None:
self.assertIn("[DRY-RUN] Would run: brew install terraform", stderr)
run_check.assert_not_called()

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_bats_core_artifact_dry_run_uses_system_package_on_linux_debian(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
manifest_path = Path(tmpdir) / "base_manifest.yaml"
manifest_path.write_text(
"\n".join(
[
"project:",
" name: demo",
"",
"artifacts:",
" - type: tool",
" name: bats-core",
" version: latest",
]
),
encoding="utf-8",
)

with (
mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}),
mock.patch("base_setup.process.command_exists", return_value=False),
mock.patch("base_setup.process.run_check") as run_check,
):
status, _stdout, stderr = run_engine(["--dry-run", "--manifest", str(manifest_path)])

self.assertEqual(status, 0)
self.assertNotIn("brew install bats-core", stderr)
self.assertIn("system package 'bats'", stderr)
run_check.assert_not_called()



@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
Expand Down Expand Up @@ -411,6 +456,40 @@ def test_docker_and_colima_artifacts_dry_run_through_homebrew(self) -> None:
run_check.assert_not_called()


def test_bats_core_artifact_setup_accepts_installed_system_command_on_linux_debian(self) -> None:
artifact = ArtifactRequest("tool", "bats-core", "latest")
ctx = fake_context()

with (
mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}),
mock.patch("base_setup.process.command_exists", return_value=True),
mock.patch("base_setup.process.run_command") as run_command,
):
definition = artifacts.resolve_artifact_definitions((artifact,))[0]
artifacts.reconcile_artifact(ctx, definition, artifact.version, "demo", dry_run=False)

run_command.assert_not_called()
info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list]
self.assertIn(
"Artifact 'bats-core' is already available through system package 'bats'.",
info_messages,
)

def test_bats_core_artifact_check_reports_missing_system_package_on_linux_debian(self) -> None:
artifact = ArtifactRequest("tool", "bats-core", "latest")

with (
mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}),
mock.patch("base_setup.process.command_exists", return_value=False),
):
definition = artifacts.resolve_artifact_definitions((artifact,))[0]
check = artifacts.check_artifact("demo", artifact, definition)

self.assertFalse(check.ok)
self.assertEqual(check.finding_id, "BASE-P034")
self.assertIn("system package 'bats'", check.message)
self.assertEqual(check.fix, "Run 'basectl setup --yes' or install Ubuntu/Debian package 'bats'.")


def test_homebrew_artifact_rejects_non_latest_version(self) -> None:
definition = get_artifact_definition("tool", "terraform")
Expand Down
5 changes: 5 additions & 0 deletions docs/artifact-adapter-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ Today, Base has two artifact behaviors backed by the bundled registry:
Base-managed project virtual environment.
- `tool` artifacts resolve to Homebrew packages installed on the host system.

On Ubuntu/Debian, the provider layer may adapt a small supported subset of
portable `tool` artifacts to system packages. The initial mapping is
`tool:bats-core` to system package `bats`; macOS keeps using the Homebrew
`bats-core` package for the same manifest artifact.

That behavior is useful but too implicit. The supported artifact list, manager
selection, package name, install target, and diagnostics are encoded in Python.
Adding another artifact family would require changing command code before Base
Expand Down
1 change: 1 addition & 0 deletions docs/doctor-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ Doctor commands use the same diagnostic item fields. The top-level
| `BASE-P031` | Unsupported Homebrew artifact version |
| `BASE-P032` | Homebrew unavailable for artifact checks |
| `BASE-P033` | Homebrew artifact package presence and freshness |
| `BASE-P034` | Platform system-package artifact presence |
| `BASE-P040` | Python package artifact status in the project virtual environment |
| `BASE-P050` | Project virtual environment readiness |
| `BASE-P060` | Project demo declaration |
Expand Down
5 changes: 5 additions & 0 deletions docs/linux-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ delegating project tool setup to `mise install`. Base does not automatically
trust project-owned mise configs; if mise reports an untrusted config, review it
and run `mise trust <path-to-mise-config>` before retrying setup.

Project tool artifacts are still intentionally conservative on Linux. The first
platform-aware mapping is `tool:bats-core`: the manifest keeps the portable
artifact name, macOS continues to use Homebrew package `bats-core`, and
Ubuntu/Debian treats the artifact as satisfied by system package `bats`.

GitHub CLI authentication remains a user-owned step. After `gh` is installed,
run the browser-backed flow when you need GitHub access:

Expand Down
Loading