mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-15 03:32:43 +08:00
chore(v5.2.0): Update CHANGELOG and remove deprecated test files
- Added Skills System Enhancements section to CHANGELOG - Documented new skills: codeagent, product-requirements, prototype-prompt-generator - Removed deprecated test files (tests/test_*.py) - Updated release date to 2025-12-13 Generated with swe-agent-bot Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 5.2.0 - 2025-12-12
|
## 5.2.0 - 2025-12-13
|
||||||
|
|
||||||
### 🚀 Core Features
|
### 🚀 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)
|
#### Multi-Backend Support (codeagent-wrapper)
|
||||||
- **Renamed**: `codex-wrapper` → `codeagent-wrapper` with pluggable backend architecture
|
- **Renamed**: `codex-wrapper` → `codeagent-wrapper` with pluggable backend architecture
|
||||||
- **Three Backends**: Codex (default), Claude, Gemini via `--backend` flag
|
- **Three Backends**: Codex (default), Claude, Gemini via `--backend` flag
|
||||||
@@ -32,6 +37,7 @@
|
|||||||
- **Modular Installation**: `python3 install.py --module dev`
|
- **Modular Installation**: `python3 install.py --module dev`
|
||||||
- **Verbose Logging**: `--verbose/-v` enables terminal real-time output
|
- **Verbose Logging**: `--verbose/-v` enables terminal real-time output
|
||||||
- **Streaming Output**: `op_run_command` streams bash script execution
|
- **Streaming Output**: `op_run_command` streams bash script execution
|
||||||
|
- **Configuration Cleanup**: Removed deprecated `gh` module from `config.json`
|
||||||
|
|
||||||
### 📚 Documentation
|
### 📚 Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user