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
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ project:

brewfile: Brewfile

# Future contract; Base will delegate to mise rather than reimplementing it.
mise: .mise.toml

artifacts:
Expand All @@ -116,12 +115,13 @@ to the project root. When present, `basectl setup` runs
this for ordinary Homebrew formulae and casks instead of adding every Homebrew
package to Base's hand-curated artifact registry.

Future manifest fields should follow the same rule. A `mise` field should cause
Base to run `mise install` and later delegate task execution to `mise run` when a
project chooses that substrate. A `test` field should give `basectl test` a
single project-owned command to run. Base should not run arbitrary setup hooks
until there is an explicit, reviewable contract for when they run, where they
run, whether they are interactive, and how dry-run/check/doctor report them.
Future manifest fields should follow the same rule. A `mise` field causes Base
to run `mise install` from the project root when a project chooses that
substrate. Later, `basectl test` can delegate task execution to `mise run` when
declared. A `test` field should give `basectl test` a single project-owned
command to run. Base should not run arbitrary setup hooks until there is an
explicit, reviewable contract for when they run, where they run, whether they
are interactive, and how dry-run/check/doctor report them.

The curated tool artifact registry lives in `cli/python/base_setup/registry.py`.
It should stay small and Base-aware. `python-package` artifacts are pass-through
Expand Down
80 changes: 76 additions & 4 deletions cli/python/base_setup/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def reconcile_manifest(
ctx.log.info("Project '%s' has no artifacts to install.", effective_manifest.project_name)

reconcile_brewfile(ctx, effective_manifest, dry_run=dry_run)
reconcile_mise(ctx, effective_manifest, dry_run=dry_run)
reconcile_ide_installs(ctx, effective_manifest, dry_run=dry_run)
reconcile_ide_extensions(ctx, effective_manifest, dry_run=dry_run)
reconcile_ide_settings(ctx, effective_manifest, dry_run=dry_run)
Expand Down Expand Up @@ -264,6 +265,8 @@ def manifest_checks(default_manifest: BaseManifest, manifest: BaseManifest) -> t

if effective_manifest.brewfile is not None:
checks.append(check_brewfile(effective_manifest))
if effective_manifest.mise is not None:
checks.append(check_mise(effective_manifest))

checks.extend(check_ide_installs(effective_manifest))
checks.extend(check_ide_extensions(effective_manifest))
Expand Down Expand Up @@ -319,6 +322,33 @@ def check_brewfile(manifest: BaseManifest) -> ArtifactCheck:
)


def check_mise(manifest: BaseManifest) -> ArtifactCheck:
try:
mise_path = resolve_mise_path(manifest)
except ArtifactError as exc:
return ArtifactCheck(
name="mise",
ok=False,
message=str(exc),
fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.",
)

if not command_exists("mise"):
return ArtifactCheck(
name="mise",
ok=False,
message=f"mise is required for project config '{mise_path}'.",
fix="Install mise, then run 'basectl setup'.",
)

return ArtifactCheck(
name="mise",
ok=True,
message=f"mise config '{mise_path}' is present and the mise CLI is available.",
fix="",
)


def check_artifact(
project: str,
artifact: ArtifactRequest,
Expand Down Expand Up @@ -445,6 +475,7 @@ def effective_manifest_with_user_config(manifest: BaseManifest, user_config: Use
brewfile=manifest.brewfile,
artifacts=manifest.artifacts,
ide=effective_ide_config(manifest.ide, user_config),
mise=manifest.mise,
)


Expand Down Expand Up @@ -589,6 +620,24 @@ def reconcile_brewfile(ctx: base_cli.Context, manifest: BaseManifest, dry_run: b
run_command(ctx, command)


def reconcile_mise(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None:
if manifest.mise is None:
return

mise_path = resolve_mise_path(manifest)
project_root = manifest.path.parent.resolve()
command = ["mise", "install"]
if dry_run:
dry_run_command(ctx, command, cwd=project_root)
return

if not command_exists("mise"):
raise ArtifactError(f"mise is required to install project tool versions from '{mise_path}'.")

ctx.log.info("Installing mise-managed tools from '%s'.", mise_path)
run_command(ctx, command, cwd=project_root)


def resolve_brewfile_path(manifest: BaseManifest) -> Path:
if manifest.brewfile is None:
raise ArtifactError(f"{manifest.path}: brewfile is not configured.")
Expand All @@ -606,6 +655,23 @@ def resolve_brewfile_path(manifest: BaseManifest) -> Path:
return brewfile_path


def resolve_mise_path(manifest: BaseManifest) -> Path:
if manifest.mise is None:
raise ArtifactError(f"{manifest.path}: mise is not configured.")

mise = Path(manifest.mise)
if mise.is_absolute():
raise ArtifactError(f"{manifest.path}: mise must be relative to the project root.")
project_root = manifest.path.parent.resolve()
mise_path = (project_root / mise).resolve()
if not mise_path.is_relative_to(project_root):
raise ArtifactError(f"{manifest.path}: mise must stay inside the project root.")
if not mise_path.is_file():
raise ArtifactError(f"{manifest.path}: mise config '{manifest.mise}' does not exist.")
return mise_path



def reconcile_ide_installs(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None:
for ide_name, ide_config in manifest.ide.items():
definition = IDE_DEFINITIONS[ide_name]
Expand Down Expand Up @@ -1040,19 +1106,25 @@ def run_check(command: list[str]) -> bool:
return subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode == 0


def run_command(ctx: base_cli.Context, command: list[str]) -> None:
def run_command(ctx: base_cli.Context, command: list[str], cwd: Path | None = None) -> None:
# Keep stdout live for installer progress; capture stderr for persistent failure logs.
completed = subprocess.run(command, stderr=subprocess.PIPE, text=True, check=False)
completed = subprocess.run(command, cwd=cwd, stderr=subprocess.PIPE, text=True, check=False)
if completed.returncode:
stderr = (completed.stderr or "").strip()
message = f"Command failed with exit {completed.returncode}: {format_command(command)}"
if stderr:
message = f"{message}\n{stderr}"
raise ArtifactError(message)
ctx.log.debug("Command succeeded: %s", format_command(command))
if cwd is not None:
ctx.log.debug("Command succeeded in '%s': %s", cwd, format_command(command))
else:
ctx.log.debug("Command succeeded: %s", format_command(command))


def dry_run_command(ctx: base_cli.Context, command: list[str]) -> None:
def dry_run_command(ctx: base_cli.Context, command: list[str], cwd: Path | None = None) -> None:
if cwd is not None:
ctx.log.info("[DRY-RUN] Would run in '%s': %s", cwd, format_command(command))
return
ctx.log.info("[DRY-RUN] Would run: %s", format_command(command))


Expand Down
13 changes: 12 additions & 1 deletion cli/python/base_setup/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class BaseManifest:
brewfile: str | None
artifacts: tuple[ArtifactRequest, ...]
ide: dict[str, IdeConfig] = field(default_factory=dict)
mise: str | None = None


def read_manifest(path: Path) -> BaseManifest:
Expand All @@ -60,13 +61,14 @@ def read_manifest(path: Path) -> BaseManifest:
if not isinstance(data, dict):
raise ManifestError(f"{path}: manifest must be a YAML mapping.")

allowed_top_level = {"project", "brewfile", "ide", "artifacts"}
allowed_top_level = {"project", "brewfile", "mise", "ide", "artifacts"}
unknown_top_level = sorted(set(data) - allowed_top_level)
if unknown_top_level:
raise ManifestError(f"{path}: unsupported top-level keys: {', '.join(unknown_top_level)}.")

project_name = _read_project_name(path, data.get("project"))
brewfile = _read_brewfile(path, data.get("brewfile"))
mise = _read_mise(path, data.get("mise"))
ide = _read_ide(path, data.get("ide"))
artifacts = _read_artifacts(path, data.get("artifacts", []))

Expand All @@ -76,6 +78,7 @@ def read_manifest(path: Path) -> BaseManifest:
brewfile=brewfile,
artifacts=tuple(artifacts),
ide=ide,
mise=mise,
)


Expand All @@ -102,6 +105,14 @@ def _read_brewfile(path: Path, brewfile_data: Any) -> str | None:
return brewfile_data.strip()


def _read_mise(path: Path, mise_data: Any) -> str | None:
if mise_data is None:
return None
if not isinstance(mise_data, str) or not mise_data.strip():
raise ManifestError(f"{path}: mise must be a non-empty string when provided.")
return mise_data.strip()


def _read_ide(path: Path, ide_data: Any) -> dict[str, IdeConfig]:
if ide_data is None:
return {}
Expand Down
114 changes: 114 additions & 0 deletions cli/python/base_setup/tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ def test_reads_manifest_brewfile(self) -> None:

self.assertEqual(manifest.brewfile, "Brewfile")

def test_reads_manifest_mise_config(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
manifest_path = Path(tmpdir) / "base_manifest.yaml"
manifest_path.write_text(
"\n".join(
[
"project:",
" name: demo",
"mise: .mise.toml",
"artifacts: []",
]
),
encoding="utf-8",
)

manifest = read_manifest(manifest_path)

self.assertEqual(manifest.mise, ".mise.toml")

def test_reads_ide_manifest_section(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
manifest_path = Path(tmpdir) / "base_manifest.yaml"
Expand Down Expand Up @@ -840,6 +859,101 @@ def test_brewfile_must_stay_inside_project_root(self) -> None:
with self.assertRaisesRegex(ArtifactError, "must stay inside the project root"):
engine.resolve_brewfile_path(manifest)

def test_mise_dry_run_invokes_mise_install_in_project_root(self) -> None:
ctx = fake_context()
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir) / "demo"
project_root.mkdir()
(project_root / ".mise.toml").write_text("[tools]\n", encoding="utf-8")
manifest = BaseManifest(
path=project_root / "base_manifest.yaml",
project_name="demo",
brewfile=None,
mise=".mise.toml",
artifacts=(),
)

engine.reconcile_mise(ctx, manifest, dry_run=True)

info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list]
self.assertIn(f"[DRY-RUN] Would run in '{project_root.resolve()}': mise install", info_messages)

def test_mise_invokes_install_in_project_root(self) -> None:
ctx = fake_context()
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir) / "demo"
project_root.mkdir()
(project_root / ".mise.toml").write_text("[tools]\n", encoding="utf-8")
manifest = BaseManifest(
path=project_root / "base_manifest.yaml",
project_name="demo",
brewfile=None,
mise=".mise.toml",
artifacts=(),
)

with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch(
"base_setup.engine.run_command"
) as run_command:
engine.reconcile_mise(ctx, manifest, dry_run=False)

run_command.assert_called_once_with(ctx, ["mise", "install"], cwd=project_root.resolve())

def test_mise_missing_file_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir) / "demo"
project_root.mkdir()
manifest = BaseManifest(
path=project_root / "base_manifest.yaml",
project_name="demo",
brewfile=None,
mise=".mise.toml",
artifacts=(),
)

with self.assertRaisesRegex(ArtifactError, "mise config '.mise.toml' does not exist"):
engine.resolve_mise_path(manifest)

def test_mise_must_stay_inside_project_root(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir) / "demo"
project_root.mkdir()
manifest = BaseManifest(
path=project_root / "base_manifest.yaml",
project_name="demo",
brewfile=None,
mise="../.mise.toml",
artifacts=(),
)

with self.assertRaisesRegex(ArtifactError, "mise must stay inside the project root"):
engine.resolve_mise_path(manifest)

def test_manifest_checks_include_mise_config(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir) / "demo"
project_root.mkdir()
(project_root / ".mise.toml").write_text("[tools]\n", encoding="utf-8")
default_manifest = BaseManifest(
path=Path(tmpdir) / "default.yaml",
project_name="base",
brewfile=None,
artifacts=(),
)
manifest = BaseManifest(
path=project_root / "base_manifest.yaml",
project_name="demo",
brewfile=None,
mise=".mise.toml",
artifacts=(),
)

with mock.patch("base_setup.engine.command_exists", return_value=True):
checks = engine.manifest_checks(default_manifest, manifest)

self.assertIn("mise", [check.name for check in checks])
self.assertTrue(next(check for check in checks if check.name == "mise").ok)


class IdeInstallTests(unittest.TestCase):
def test_ide_install_dry_run_invokes_homebrew_cask_install(self) -> None:
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,6 @@ project:

brewfile: Brewfile

# Future contract; Base delegates to mise instead of reimplementing it.
mise: .mise.toml

artifacts:
Expand All @@ -347,7 +346,8 @@ orchestration actions. The design rule is delegation-first:

- Use Homebrew's own `Brewfile`/`brew bundle` flow for ordinary macOS packages.
- Use `mise` for tool versions, language runtimes, environment variables, and
tasks when a project opts into it.
future tasks when a project opts into it. Base runs `mise install` during
setup and does not reimplement mise's version management.
- Use a project-owned `test` contract for future `basectl test <project>`
delegation.
- Let Base own the project virtual environment and Base-aware package
Expand Down
Loading