diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9c927..b9cbf86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Changelog -## 5.2.0 - 2025-12-12 +## 5.2.0 - 2025-12-13 ### 🚀 Core Features +#### Skills System Enhancements +- **New Skills**: Added `codeagent`, `product-requirements`, `prototype-prompt-generator` to `skill-rules.json` +- **Auto-Activation**: Skills automatically trigger based on keyword/pattern matching via hooks +- **Backward Compatibility**: Retained `skills/codex/SKILL.md` for existing workflows + #### Multi-Backend Support (codeagent-wrapper) - **Renamed**: `codex-wrapper` → `codeagent-wrapper` with pluggable backend architecture - **Three Backends**: Codex (default), Claude, Gemini via `--backend` flag @@ -32,6 +37,7 @@ - **Modular Installation**: `python3 install.py --module dev` - **Verbose Logging**: `--verbose/-v` enables terminal real-time output - **Streaming Output**: `op_run_command` streams bash script execution +- **Configuration Cleanup**: Removed deprecated `gh` module from `config.json` ### 📚 Documentation diff --git a/tests/test_config.cover b/tests/test_config.cover deleted file mode 100644 index ad764de..0000000 --- a/tests/test_config.cover +++ /dev/null @@ -1,76 +0,0 @@ - 1: import copy - 1: import json - 1: import unittest - 1: from pathlib import Path - - 1: import jsonschema - - - 1: CONFIG_PATH = Path(__file__).resolve().parents[1] / "config.json" - 1: SCHEMA_PATH = Path(__file__).resolve().parents[1] / "config.schema.json" - 1: ROOT = CONFIG_PATH.parent - - - 1: def load_config(): - with CONFIG_PATH.open(encoding="utf-8") as f: - return json.load(f) - - - 1: def load_schema(): - with SCHEMA_PATH.open(encoding="utf-8") as f: - return json.load(f) - - - 2: class ConfigSchemaTest(unittest.TestCase): - 1: def test_config_matches_schema(self): - config = load_config() - schema = load_schema() - jsonschema.validate(config, schema) - - 1: def test_required_modules_present(self): - modules = load_config()["modules"] - self.assertEqual(set(modules.keys()), {"dev", "bmad", "requirements", "essentials", "advanced"}) - - 1: def test_enabled_defaults_and_flags(self): - modules = load_config()["modules"] - self.assertTrue(modules["dev"]["enabled"]) - self.assertTrue(modules["essentials"]["enabled"]) - self.assertFalse(modules["bmad"]["enabled"]) - self.assertFalse(modules["requirements"]["enabled"]) - self.assertFalse(modules["advanced"]["enabled"]) - - 1: def test_operations_have_expected_shape(self): - config = load_config() - for name, module in config["modules"].items(): - self.assertTrue(module["operations"], f"{name} should declare at least one operation") - for op in module["operations"]: - self.assertIn("type", op) - if op["type"] in {"copy_dir", "copy_file"}: - self.assertTrue(op.get("source"), f"{name} operation missing source") - self.assertTrue(op.get("target"), f"{name} operation missing target") - elif op["type"] == "run_command": - self.assertTrue(op.get("command"), f"{name} run_command missing command") - if "env" in op: - self.assertIsInstance(op["env"], dict) - else: - self.fail(f"Unsupported operation type: {op['type']}") - - 1: def test_operation_sources_exist_on_disk(self): - config = load_config() - for module in config["modules"].values(): - for op in module["operations"]: - if op["type"] in {"copy_dir", "copy_file"}: - path = (ROOT / op["source"]).expanduser() - self.assertTrue(path.exists(), f"Source path not found: {path}") - - 1: def test_schema_rejects_invalid_operation_type(self): - config = load_config() - invalid = copy.deepcopy(config) - invalid["modules"]["dev"]["operations"][0]["type"] = "unknown_op" - schema = load_schema() - with self.assertRaises(jsonschema.exceptions.ValidationError): - jsonschema.validate(invalid, schema) - - - 1: if __name__ == "__main__": - 1: unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index d1dc93b..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,76 +0,0 @@ -import copy -import json -import unittest -from pathlib import Path - -import jsonschema - - -CONFIG_PATH = Path(__file__).resolve().parents[1] / "config.json" -SCHEMA_PATH = Path(__file__).resolve().parents[1] / "config.schema.json" -ROOT = CONFIG_PATH.parent - - -def load_config(): - with CONFIG_PATH.open(encoding="utf-8") as f: - return json.load(f) - - -def load_schema(): - with SCHEMA_PATH.open(encoding="utf-8") as f: - return json.load(f) - - -class ConfigSchemaTest(unittest.TestCase): - def test_config_matches_schema(self): - config = load_config() - schema = load_schema() - jsonschema.validate(config, schema) - - def test_required_modules_present(self): - modules = load_config()["modules"] - self.assertEqual(set(modules.keys()), {"dev", "bmad", "requirements", "essentials", "advanced"}) - - def test_enabled_defaults_and_flags(self): - modules = load_config()["modules"] - self.assertTrue(modules["dev"]["enabled"]) - self.assertTrue(modules["essentials"]["enabled"]) - self.assertFalse(modules["bmad"]["enabled"]) - self.assertFalse(modules["requirements"]["enabled"]) - self.assertFalse(modules["advanced"]["enabled"]) - - def test_operations_have_expected_shape(self): - config = load_config() - for name, module in config["modules"].items(): - self.assertTrue(module["operations"], f"{name} should declare at least one operation") - for op in module["operations"]: - self.assertIn("type", op) - if op["type"] in {"copy_dir", "copy_file"}: - self.assertTrue(op.get("source"), f"{name} operation missing source") - self.assertTrue(op.get("target"), f"{name} operation missing target") - elif op["type"] == "run_command": - self.assertTrue(op.get("command"), f"{name} run_command missing command") - if "env" in op: - self.assertIsInstance(op["env"], dict) - else: - self.fail(f"Unsupported operation type: {op['type']}") - - def test_operation_sources_exist_on_disk(self): - config = load_config() - for module in config["modules"].values(): - for op in module["operations"]: - if op["type"] in {"copy_dir", "copy_file"}: - path = (ROOT / op["source"]).expanduser() - self.assertTrue(path.exists(), f"Source path not found: {path}") - - def test_schema_rejects_invalid_operation_type(self): - config = load_config() - invalid = copy.deepcopy(config) - invalid["modules"]["dev"]["operations"][0]["type"] = "unknown_op" - schema = load_schema() - with self.assertRaises(jsonschema.exceptions.ValidationError): - jsonschema.validate(invalid, schema) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_install.py b/tests/test_install.py deleted file mode 100644 index 29909fb..0000000 --- a/tests/test_install.py +++ /dev/null @@ -1,458 +0,0 @@ -import json -import os -import shutil -import sys -from pathlib import Path - -import pytest - -import install - - -ROOT = Path(__file__).resolve().parents[1] -SCHEMA_PATH = ROOT / "config.schema.json" - - -def write_config(tmp_path: Path, config: dict) -> Path: - cfg_path = tmp_path / "config.json" - cfg_path.write_text(json.dumps(config), encoding="utf-8") - shutil.copy(SCHEMA_PATH, tmp_path / "config.schema.json") - return cfg_path - - -@pytest.fixture() -def valid_config(tmp_path): - sample_file = tmp_path / "sample.txt" - sample_file.write_text("hello", encoding="utf-8") - - sample_dir = tmp_path / "sample_dir" - sample_dir.mkdir() - (sample_dir / "f.txt").write_text("dir", encoding="utf-8") - - config = { - "version": "1.0", - "install_dir": "~/.fromconfig", - "log_file": "install.log", - "modules": { - "dev": { - "enabled": True, - "description": "dev module", - "operations": [ - {"type": "copy_dir", "source": "sample_dir", "target": "devcopy"} - ], - }, - "bmad": { - "enabled": False, - "description": "bmad", - "operations": [ - {"type": "copy_file", "source": "sample.txt", "target": "bmad.txt"} - ], - }, - "requirements": { - "enabled": False, - "description": "reqs", - "operations": [ - {"type": "copy_file", "source": "sample.txt", "target": "req.txt"} - ], - }, - "essentials": { - "enabled": True, - "description": "ess", - "operations": [ - {"type": "copy_file", "source": "sample.txt", "target": "ess.txt"} - ], - }, - "advanced": { - "enabled": False, - "description": "adv", - "operations": [ - {"type": "copy_file", "source": "sample.txt", "target": "adv.txt"} - ], - }, - }, - } - - cfg_path = write_config(tmp_path, config) - return cfg_path, config - - -def make_ctx(tmp_path: Path) -> dict: - install_dir = tmp_path / "install" - return { - "install_dir": install_dir, - "log_file": install_dir / "install.log", - "status_file": install_dir / "installed_modules.json", - "config_dir": tmp_path, - "force": False, - } - - -def test_parse_args_defaults(): - args = install.parse_args([]) - assert args.install_dir == install.DEFAULT_INSTALL_DIR - assert args.config == "config.json" - assert args.module is None - assert args.list_modules is False - assert args.force is False - - -def test_parse_args_custom(): - args = install.parse_args( - [ - "--install-dir", - "/tmp/custom", - "--module", - "dev,bmad", - "--config", - "/tmp/cfg.json", - "--list-modules", - "--force", - ] - ) - - assert args.install_dir == "/tmp/custom" - assert args.module == "dev,bmad" - assert args.config == "/tmp/cfg.json" - assert args.list_modules is True - assert args.force is True - - -def test_load_config_success(valid_config): - cfg_path, config_data = valid_config - loaded = install.load_config(str(cfg_path)) - assert loaded["modules"]["dev"]["description"] == config_data["modules"]["dev"]["description"] - - -def test_load_config_invalid_json(tmp_path): - bad = tmp_path / "bad.json" - bad.write_text("{broken", encoding="utf-8") - shutil.copy(SCHEMA_PATH, tmp_path / "config.schema.json") - with pytest.raises(ValueError): - install.load_config(str(bad)) - - -def test_load_config_schema_error(tmp_path): - cfg = tmp_path / "cfg.json" - cfg.write_text(json.dumps({"version": "1.0"}), encoding="utf-8") - shutil.copy(SCHEMA_PATH, tmp_path / "config.schema.json") - with pytest.raises(ValueError): - install.load_config(str(cfg)) - - -def test_resolve_paths_respects_priority(tmp_path): - config = { - "install_dir": str(tmp_path / "from_config"), - "log_file": "logs/install.log", - "modules": {}, - "version": "1.0", - } - cfg_path = write_config(tmp_path, config) - args = install.parse_args(["--config", str(cfg_path)]) - - ctx = install.resolve_paths(config, args) - assert ctx["install_dir"] == (tmp_path / "from_config").resolve() - assert ctx["log_file"] == (tmp_path / "from_config" / "logs" / "install.log").resolve() - assert ctx["config_dir"] == tmp_path.resolve() - - cli_args = install.parse_args( - ["--install-dir", str(tmp_path / "cli_dir"), "--config", str(cfg_path)] - ) - ctx_cli = install.resolve_paths(config, cli_args) - assert ctx_cli["install_dir"] == (tmp_path / "cli_dir").resolve() - - -def test_list_modules_output(valid_config, capsys): - _, config_data = valid_config - install.list_modules(config_data) - captured = capsys.readouterr().out - assert "dev" in captured - assert "essentials" in captured - assert "✓" in captured - - -def test_select_modules_behaviour(valid_config): - _, config_data = valid_config - - selected_default = install.select_modules(config_data, None) - assert set(selected_default.keys()) == {"dev", "essentials"} - - selected_specific = install.select_modules(config_data, "bmad") - assert set(selected_specific.keys()) == {"bmad"} - - with pytest.raises(ValueError): - install.select_modules(config_data, "missing") - - -def test_ensure_install_dir(tmp_path, monkeypatch): - target = tmp_path / "install_here" - install.ensure_install_dir(target) - assert target.is_dir() - - file_path = tmp_path / "conflict" - file_path.write_text("x", encoding="utf-8") - with pytest.raises(NotADirectoryError): - install.ensure_install_dir(file_path) - - blocked = tmp_path / "blocked" - real_access = os.access - - def fake_access(path, mode): - if Path(path) == blocked: - return False - return real_access(path, mode) - - monkeypatch.setattr(os, "access", fake_access) - with pytest.raises(PermissionError): - install.ensure_install_dir(blocked) - - -def test_op_copy_dir_respects_force(tmp_path): - ctx = make_ctx(tmp_path) - install.ensure_install_dir(ctx["install_dir"]) - - src = tmp_path / "src" - src.mkdir() - (src / "a.txt").write_text("one", encoding="utf-8") - - op = {"type": "copy_dir", "source": "src", "target": "dest"} - install.op_copy_dir(op, ctx) - target_file = ctx["install_dir"] / "dest" / "a.txt" - assert target_file.read_text(encoding="utf-8") == "one" - - (src / "a.txt").write_text("two", encoding="utf-8") - install.op_copy_dir(op, ctx) - assert target_file.read_text(encoding="utf-8") == "one" - - ctx["force"] = True - install.op_copy_dir(op, ctx) - assert target_file.read_text(encoding="utf-8") == "two" - - -def test_op_copy_file_behaviour(tmp_path): - ctx = make_ctx(tmp_path) - install.ensure_install_dir(ctx["install_dir"]) - - src = tmp_path / "file.txt" - src.write_text("first", encoding="utf-8") - - op = {"type": "copy_file", "source": "file.txt", "target": "out/file.txt"} - install.op_copy_file(op, ctx) - dst = ctx["install_dir"] / "out" / "file.txt" - assert dst.read_text(encoding="utf-8") == "first" - - src.write_text("second", encoding="utf-8") - install.op_copy_file(op, ctx) - assert dst.read_text(encoding="utf-8") == "first" - - ctx["force"] = True - install.op_copy_file(op, ctx) - assert dst.read_text(encoding="utf-8") == "second" - - -def test_op_run_command_success(tmp_path): - ctx = make_ctx(tmp_path) - install.ensure_install_dir(ctx["install_dir"]) - install.op_run_command({"type": "run_command", "command": "echo hello"}, ctx) - log_content = ctx["log_file"].read_text(encoding="utf-8") - assert "hello" in log_content - - -def test_op_run_command_failure(tmp_path): - ctx = make_ctx(tmp_path) - install.ensure_install_dir(ctx["install_dir"]) - with pytest.raises(RuntimeError): - install.op_run_command( - {"type": "run_command", "command": f"{sys.executable} -c 'import sys; sys.exit(2)'"}, - ctx, - ) - log_content = ctx["log_file"].read_text(encoding="utf-8") - assert "returncode: 2" in log_content - - -def test_execute_module_success(tmp_path): - ctx = make_ctx(tmp_path) - install.ensure_install_dir(ctx["install_dir"]) - src = tmp_path / "src.txt" - src.write_text("data", encoding="utf-8") - - cfg = {"operations": [{"type": "copy_file", "source": "src.txt", "target": "out.txt"}]} - result = install.execute_module("demo", cfg, ctx) - assert result["status"] == "success" - assert (ctx["install_dir"] / "out.txt").read_text(encoding="utf-8") == "data" - - -def test_execute_module_failure_logs_and_stops(tmp_path): - ctx = make_ctx(tmp_path) - install.ensure_install_dir(ctx["install_dir"]) - cfg = {"operations": [{"type": "unknown", "source": "", "target": ""}]} - - with pytest.raises(ValueError): - install.execute_module("demo", cfg, ctx) - - log_content = ctx["log_file"].read_text(encoding="utf-8") - assert "failed on unknown" in log_content - - -def test_write_log_and_status(tmp_path): - ctx = make_ctx(tmp_path) - install.ensure_install_dir(ctx["install_dir"]) - - install.write_log({"level": "INFO", "message": "hello"}, ctx) - content = ctx["log_file"].read_text(encoding="utf-8") - assert "hello" in content - - results = [ - {"module": "dev", "status": "success", "operations": [], "installed_at": "ts"} - ] - install.write_status(results, ctx) - status_data = json.loads(ctx["status_file"].read_text(encoding="utf-8")) - assert status_data["modules"]["dev"]["status"] == "success" - - -def test_main_success(valid_config, tmp_path): - cfg_path, _ = valid_config - install_dir = tmp_path / "install_final" - rc = install.main( - [ - "--config", - str(cfg_path), - "--install-dir", - str(install_dir), - "--module", - "dev", - ] - ) - - assert rc == 0 - assert (install_dir / "devcopy" / "f.txt").exists() - assert (install_dir / "installed_modules.json").exists() - - -def test_main_failure_without_force(tmp_path): - cfg = { - "version": "1.0", - "install_dir": "~/.claude", - "log_file": "install.log", - "modules": { - "dev": { - "enabled": True, - "description": "dev", - "operations": [ - { - "type": "run_command", - "command": f"{sys.executable} -c 'import sys; sys.exit(3)'", - } - ], - }, - "bmad": { - "enabled": False, - "description": "bmad", - "operations": [ - {"type": "copy_file", "source": "s.txt", "target": "t.txt"} - ], - }, - "requirements": { - "enabled": False, - "description": "reqs", - "operations": [ - {"type": "copy_file", "source": "s.txt", "target": "r.txt"} - ], - }, - "essentials": { - "enabled": False, - "description": "ess", - "operations": [ - {"type": "copy_file", "source": "s.txt", "target": "e.txt"} - ], - }, - "advanced": { - "enabled": False, - "description": "adv", - "operations": [ - {"type": "copy_file", "source": "s.txt", "target": "a.txt"} - ], - }, - }, - } - - cfg_path = write_config(tmp_path, cfg) - install_dir = tmp_path / "fail_install" - rc = install.main( - [ - "--config", - str(cfg_path), - "--install-dir", - str(install_dir), - "--module", - "dev", - ] - ) - - assert rc == 1 - assert not (install_dir / "installed_modules.json").exists() - - -def test_main_force_records_failure(tmp_path): - cfg = { - "version": "1.0", - "install_dir": "~/.claude", - "log_file": "install.log", - "modules": { - "dev": { - "enabled": True, - "description": "dev", - "operations": [ - { - "type": "run_command", - "command": f"{sys.executable} -c 'import sys; sys.exit(4)'", - } - ], - }, - "bmad": { - "enabled": False, - "description": "bmad", - "operations": [ - {"type": "copy_file", "source": "s.txt", "target": "t.txt"} - ], - }, - "requirements": { - "enabled": False, - "description": "reqs", - "operations": [ - {"type": "copy_file", "source": "s.txt", "target": "r.txt"} - ], - }, - "essentials": { - "enabled": False, - "description": "ess", - "operations": [ - {"type": "copy_file", "source": "s.txt", "target": "e.txt"} - ], - }, - "advanced": { - "enabled": False, - "description": "adv", - "operations": [ - {"type": "copy_file", "source": "s.txt", "target": "a.txt"} - ], - }, - }, - } - - cfg_path = write_config(tmp_path, cfg) - install_dir = tmp_path / "force_install" - rc = install.main( - [ - "--config", - str(cfg_path), - "--install-dir", - str(install_dir), - "--module", - "dev", - "--force", - ] - ) - - assert rc == 0 - status = json.loads((install_dir / "installed_modules.json").read_text(encoding="utf-8")) - assert status["modules"]["dev"]["status"] == "failed" diff --git a/tests/test_modules.py b/tests/test_modules.py deleted file mode 100644 index d709579..0000000 --- a/tests/test_modules.py +++ /dev/null @@ -1,224 +0,0 @@ -import json -import shutil -import sys -from pathlib import Path - -import pytest - -import install - - -ROOT = Path(__file__).resolve().parents[1] -SCHEMA_PATH = ROOT / "config.schema.json" - - -def _write_schema(target_dir: Path) -> None: - shutil.copy(SCHEMA_PATH, target_dir / "config.schema.json") - - -def _base_config(install_dir: Path, modules: dict) -> dict: - return { - "version": "1.0", - "install_dir": str(install_dir), - "log_file": "install.log", - "modules": modules, - } - - -def _prepare_env(tmp_path: Path, modules: dict) -> tuple[Path, Path, Path]: - """Create a temp config directory with schema and config.json.""" - - config_dir = tmp_path / "config" - install_dir = tmp_path / "install" - config_dir.mkdir() - _write_schema(config_dir) - - cfg_path = config_dir / "config.json" - cfg_path.write_text( - json.dumps(_base_config(install_dir, modules)), encoding="utf-8" - ) - return cfg_path, install_dir, config_dir - - -def _sample_sources(config_dir: Path) -> dict: - sample_dir = config_dir / "sample_dir" - sample_dir.mkdir() - (sample_dir / "nested.txt").write_text("dir-content", encoding="utf-8") - - sample_file = config_dir / "sample.txt" - sample_file.write_text("file-content", encoding="utf-8") - - return {"dir": sample_dir, "file": sample_file} - - -def _read_status(install_dir: Path) -> dict: - return json.loads((install_dir / "installed_modules.json").read_text("utf-8")) - - -def test_single_module_full_flow(tmp_path): - cfg_path, install_dir, config_dir = _prepare_env( - tmp_path, - { - "solo": { - "enabled": True, - "description": "single module", - "operations": [ - {"type": "copy_dir", "source": "sample_dir", "target": "payload"}, - { - "type": "copy_file", - "source": "sample.txt", - "target": "payload/sample.txt", - }, - { - "type": "run_command", - "command": f"{sys.executable} -c \"from pathlib import Path; Path('run.txt').write_text('ok', encoding='utf-8')\"", - }, - ], - } - }, - ) - - _sample_sources(config_dir) - rc = install.main(["--config", str(cfg_path), "--module", "solo"]) - - assert rc == 0 - assert (install_dir / "payload" / "nested.txt").read_text(encoding="utf-8") == "dir-content" - assert (install_dir / "payload" / "sample.txt").read_text(encoding="utf-8") == "file-content" - assert (install_dir / "run.txt").read_text(encoding="utf-8") == "ok" - - status = _read_status(install_dir) - assert status["modules"]["solo"]["status"] == "success" - assert len(status["modules"]["solo"]["operations"]) == 3 - - -def test_multi_module_install_and_status(tmp_path): - modules = { - "alpha": { - "enabled": True, - "description": "alpha", - "operations": [ - { - "type": "copy_file", - "source": "sample.txt", - "target": "alpha.txt", - } - ], - }, - "beta": { - "enabled": True, - "description": "beta", - "operations": [ - { - "type": "copy_dir", - "source": "sample_dir", - "target": "beta_dir", - } - ], - }, - } - - cfg_path, install_dir, config_dir = _prepare_env(tmp_path, modules) - _sample_sources(config_dir) - - rc = install.main(["--config", str(cfg_path)]) - assert rc == 0 - - assert (install_dir / "alpha.txt").read_text(encoding="utf-8") == "file-content" - assert (install_dir / "beta_dir" / "nested.txt").exists() - - status = _read_status(install_dir) - assert set(status["modules"].keys()) == {"alpha", "beta"} - assert all(mod["status"] == "success" for mod in status["modules"].values()) - - -def test_force_overwrites_existing_files(tmp_path): - modules = { - "forcey": { - "enabled": True, - "description": "force copy", - "operations": [ - { - "type": "copy_file", - "source": "sample.txt", - "target": "target.txt", - } - ], - } - } - - cfg_path, install_dir, config_dir = _prepare_env(tmp_path, modules) - sources = _sample_sources(config_dir) - - install.main(["--config", str(cfg_path), "--module", "forcey"]) - assert (install_dir / "target.txt").read_text(encoding="utf-8") == "file-content" - - sources["file"].write_text("new-content", encoding="utf-8") - - rc = install.main(["--config", str(cfg_path), "--module", "forcey", "--force"]) - assert rc == 0 - assert (install_dir / "target.txt").read_text(encoding="utf-8") == "new-content" - - status = _read_status(install_dir) - assert status["modules"]["forcey"]["status"] == "success" - - -def test_failure_triggers_rollback_and_restores_status(tmp_path): - # First successful run to create a known-good status file. - ok_modules = { - "stable": { - "enabled": True, - "description": "stable", - "operations": [ - { - "type": "copy_file", - "source": "sample.txt", - "target": "stable.txt", - } - ], - } - } - - cfg_path, install_dir, config_dir = _prepare_env(tmp_path, ok_modules) - _sample_sources(config_dir) - assert install.main(["--config", str(cfg_path)]) == 0 - pre_status = _read_status(install_dir) - assert "stable" in pre_status["modules"] - - # Rewrite config to introduce a failing module. - failing_modules = { - **ok_modules, - "broken": { - "enabled": True, - "description": "will fail", - "operations": [ - { - "type": "copy_file", - "source": "sample.txt", - "target": "broken.txt", - }, - { - "type": "run_command", - "command": f"{sys.executable} -c 'import sys; sys.exit(5)'", - }, - ], - }, - } - - cfg_path.write_text( - json.dumps(_base_config(install_dir, failing_modules)), encoding="utf-8" - ) - - rc = install.main(["--config", str(cfg_path)]) - assert rc == 1 - - # The failed module's file should have been removed by rollback. - assert not (install_dir / "broken.txt").exists() - # Previously installed files remain. - assert (install_dir / "stable.txt").exists() - - restored_status = _read_status(install_dir) - assert restored_status == pre_status - - log_content = (install_dir / "install.log").read_text(encoding="utf-8") - assert "Rolling back" in log_content -