diff --git a/README.md b/README.md index ef58d03..1fa02b6 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,6 @@ project: brewfile: Brewfile -# Future contract; Base will delegate to mise rather than reimplementing it. mise: .mise.toml artifacts: @@ -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 diff --git a/cli/python/base_setup/engine.py b/cli/python/base_setup/engine.py index 0148502..e1aca88 100644 --- a/cli/python/base_setup/engine.py +++ b/cli/python/base_setup/engine.py @@ -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) @@ -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)) @@ -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, @@ -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, ) @@ -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.") @@ -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] @@ -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)) diff --git a/cli/python/base_setup/manifest.py b/cli/python/base_setup/manifest.py index a654c4c..1abb0c4 100644 --- a/cli/python/base_setup/manifest.py +++ b/cli/python/base_setup/manifest.py @@ -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: @@ -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", [])) @@ -76,6 +78,7 @@ def read_manifest(path: Path) -> BaseManifest: brewfile=brewfile, artifacts=tuple(artifacts), ide=ide, + mise=mise, ) @@ -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 {} diff --git a/cli/python/base_setup/tests/test_engine.py b/cli/python/base_setup/tests/test_engine.py index e3473ac..018910c 100644 --- a/cli/python/base_setup/tests/test_engine.py +++ b/cli/python/base_setup/tests/test_engine.py @@ -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" @@ -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: diff --git a/docs/architecture.md b/docs/architecture.md index 153cabf..96a45d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -329,7 +329,6 @@ project: brewfile: Brewfile -# Future contract; Base delegates to mise instead of reimplementing it. mise: .mise.toml artifacts: @@ -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 ` delegation. - Let Base own the project virtual environment and Base-aware package