✨ feat(playbook): add install_mode deployment config
BREAKING CHANGE: [vendor] and playbook.deploy_root are no longer supported. Use [playbook].install_mode and playbook.playbook_root instead.
This commit is contained in:
+137
-43
@@ -14,7 +14,6 @@ except ModuleNotFoundError: # Python < 3.11
|
||||
tomllib = None
|
||||
|
||||
ORDER = [
|
||||
"vendor",
|
||||
"sync_rules",
|
||||
"sync_memory_bank",
|
||||
"sync_prompts",
|
||||
@@ -29,7 +28,14 @@ MAIN_LOOP_SPEC = importlib.util.spec_from_file_location("playbook_main_loop", MA
|
||||
assert MAIN_LOOP_SPEC and MAIN_LOOP_SPEC.loader
|
||||
MAIN_LOOP = importlib.util.module_from_spec(MAIN_LOOP_SPEC)
|
||||
MAIN_LOOP_SPEC.loader.exec_module(MAIN_LOOP)
|
||||
PATH_CONFIG_KEYS = {"project_root", "deploy_root", "agents_home", "codex_home", "skill_link"}
|
||||
PATH_CONFIG_KEYS = {
|
||||
"project_root",
|
||||
"playbook_root",
|
||||
"deploy_root",
|
||||
"agents_home",
|
||||
"codex_home",
|
||||
"skill_link",
|
||||
}
|
||||
DOCS_INDEX_SECTION_HEADINGS = {
|
||||
"common": "## 跨语言(common)",
|
||||
"tsl": "## TSL(tsl/tsf)",
|
||||
@@ -275,13 +281,13 @@ def normalize_relative_dir(raw: object, label: str) -> str:
|
||||
return "." if normalized == "" else normalized
|
||||
|
||||
|
||||
def join_deploy_subpath(root: str, child: str) -> str:
|
||||
def join_playbook_subpath(root: str, child: str) -> str:
|
||||
if root in ("", "."):
|
||||
return child.lstrip("/")
|
||||
return f"{root.rstrip('/')}/{child.lstrip('/')}"
|
||||
|
||||
|
||||
def resolve_in_project_deploy_root(project_root: Path) -> str | None:
|
||||
def resolve_in_project_playbook_root(project_root: Path) -> str | None:
|
||||
try:
|
||||
rel = PLAYBOOK_ROOT.resolve().relative_to(project_root.resolve())
|
||||
if str(rel) != ".":
|
||||
@@ -291,9 +297,8 @@ def resolve_in_project_deploy_root(project_root: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def config_requires_deploy_root(config: dict) -> bool:
|
||||
def config_uses_playbook_root(config: dict) -> bool:
|
||||
for key in (
|
||||
"vendor",
|
||||
"sync_rules",
|
||||
"sync_memory_bank",
|
||||
"sync_prompts",
|
||||
@@ -305,46 +310,74 @@ def config_requires_deploy_root(config: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_configured_deploy_root(config: dict, project_root: Path) -> str:
|
||||
def validate_removed_config(config: dict) -> None:
|
||||
if "vendor" in config:
|
||||
raise ValueError(
|
||||
"[vendor] is no longer supported; use [playbook].install_mode = "
|
||||
'"snapshot" and configure languages in [sync_standards]'
|
||||
)
|
||||
|
||||
playbook_config = config.get("playbook", {})
|
||||
if not isinstance(playbook_config, dict):
|
||||
raise ValueError("[playbook] must be a table")
|
||||
|
||||
if "deploy_root" in playbook_config:
|
||||
raise ValueError(
|
||||
"playbook.deploy_root is no longer supported; use playbook.playbook_root"
|
||||
)
|
||||
if "intall_mode" in playbook_config:
|
||||
raise ValueError(
|
||||
"playbook.intall_mode is not supported; use playbook.install_mode"
|
||||
)
|
||||
|
||||
|
||||
def resolve_install_mode(config: dict) -> str:
|
||||
playbook_config = config.get("playbook", {})
|
||||
raw = "subtree"
|
||||
if (
|
||||
isinstance(playbook_config, dict)
|
||||
and playbook_config.get("install_mode") is not None
|
||||
):
|
||||
raw = playbook_config.get("install_mode")
|
||||
mode = str(raw).strip().lower()
|
||||
if mode not in ("subtree", "snapshot"):
|
||||
raise ValueError(
|
||||
"playbook.install_mode must be one of: subtree, snapshot"
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
def resolve_configured_playbook_root(config: dict, project_root: Path) -> str:
|
||||
playbook_config = config.get("playbook", {})
|
||||
raw = None
|
||||
if isinstance(playbook_config, dict):
|
||||
raw = playbook_config.get("deploy_root")
|
||||
vendor_config = config.get("vendor", {})
|
||||
if isinstance(vendor_config, dict) and vendor_config.get("target_dir") is not None:
|
||||
raise ValueError(
|
||||
"vendor.target_dir is no longer supported; use [playbook].deploy_root"
|
||||
)
|
||||
raw = playbook_config.get("playbook_root")
|
||||
if raw is not None and str(raw).strip():
|
||||
return normalize_relative_dir(raw, "deploy_root")
|
||||
return normalize_relative_dir(raw, "playbook_root")
|
||||
|
||||
in_project_deploy_root = resolve_in_project_deploy_root(project_root)
|
||||
if in_project_deploy_root is not None:
|
||||
return in_project_deploy_root
|
||||
in_project_playbook_root = resolve_in_project_playbook_root(project_root)
|
||||
if in_project_playbook_root is not None:
|
||||
return in_project_playbook_root
|
||||
|
||||
if config_requires_deploy_root(config):
|
||||
if resolve_install_mode(config) == "snapshot" or config_uses_playbook_root(config):
|
||||
raise ValueError(
|
||||
"playbook.deploy_root is required when running from an external clone; "
|
||||
"set it to the target project's relative deployment path"
|
||||
"playbook.playbook_root is required when running from an external clone; "
|
||||
"set it to the target project's relative Playbook path"
|
||||
)
|
||||
|
||||
return "docs/standards/playbook"
|
||||
|
||||
|
||||
def resolve_deploy_root(context: dict) -> str:
|
||||
project_root: Path = context["project_root"]
|
||||
in_project_deploy_root = resolve_in_project_deploy_root(project_root)
|
||||
if in_project_deploy_root is not None:
|
||||
return in_project_deploy_root
|
||||
return context["deploy_root"]
|
||||
def resolve_playbook_root(context: dict) -> str:
|
||||
return context["playbook_root"]
|
||||
|
||||
|
||||
def resolve_docs_prefix(context: dict) -> str:
|
||||
return join_deploy_subpath(resolve_deploy_root(context), "docs")
|
||||
return join_playbook_subpath(resolve_playbook_root(context), "docs")
|
||||
|
||||
|
||||
def resolve_playbook_scripts(context: dict) -> str:
|
||||
return join_deploy_subpath(resolve_deploy_root(context), "scripts")
|
||||
return join_playbook_subpath(resolve_playbook_root(context), "scripts")
|
||||
|
||||
|
||||
def read_git_commit(root: Path) -> str:
|
||||
@@ -408,9 +441,11 @@ def write_docs_index(dest_prefix: Path, langs: list[str]) -> None:
|
||||
docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def write_snapshot_readme(dest_prefix: Path, deploy_root: str, langs: list[str]) -> None:
|
||||
scripts_path = join_deploy_subpath(deploy_root, "scripts/playbook.py")
|
||||
docs_index_path = join_deploy_subpath(deploy_root, "docs/index.md")
|
||||
def write_snapshot_readme(
|
||||
dest_prefix: Path, playbook_root: str, langs: list[str]
|
||||
) -> None:
|
||||
scripts_path = join_playbook_subpath(playbook_root, "scripts/playbook.py")
|
||||
docs_index_path = join_playbook_subpath(playbook_root, "docs/index.md")
|
||||
lines = [
|
||||
"# Playbook(裁剪快照)",
|
||||
"",
|
||||
@@ -424,7 +459,7 @@ def write_snapshot_readme(dest_prefix: Path, deploy_root: str, langs: list[str])
|
||||
f"python {scripts_path} -config playbook.toml",
|
||||
"```",
|
||||
"",
|
||||
f"配置示例:`{join_deploy_subpath(deploy_root, 'playbook.toml.example')}`",
|
||||
f"配置示例:`{join_playbook_subpath(playbook_root, 'playbook.toml.example')}`",
|
||||
"",
|
||||
"文档入口:",
|
||||
"",
|
||||
@@ -453,23 +488,51 @@ def write_source_file(dest_prefix: Path, langs: list[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def vendor_action(config: dict, context: dict) -> int:
|
||||
def snapshot_langs(config: dict) -> list[str]:
|
||||
sync_standards = config.get("sync_standards")
|
||||
if isinstance(sync_standards, dict):
|
||||
return normalize_langs(sync_standards.get("langs"))
|
||||
return normalize_langs(None)
|
||||
|
||||
|
||||
def is_generated_snapshot(path: Path) -> bool:
|
||||
source = path / "SOURCE.md"
|
||||
if not source.is_file():
|
||||
return False
|
||||
try:
|
||||
langs = normalize_langs(config.get("langs"))
|
||||
return "Generated-by: scripts/playbook.py" in source.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def install_snapshot(config: dict, context: dict) -> int:
|
||||
try:
|
||||
langs = snapshot_langs(config)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
deploy_root = context["deploy_root"]
|
||||
target_path = Path(deploy_root)
|
||||
|
||||
playbook_root = context["playbook_root"]
|
||||
target_path = Path(playbook_root)
|
||||
project_root: Path = context["project_root"]
|
||||
dest_prefix = project_root / target_path
|
||||
dest_standards = dest_prefix.parent
|
||||
|
||||
if dest_prefix.resolve() == PLAYBOOK_ROOT.resolve():
|
||||
log(f"Snapshot already installed at {dest_prefix}")
|
||||
return 0
|
||||
|
||||
ensure_dir(dest_standards)
|
||||
|
||||
if dest_prefix.exists():
|
||||
if not is_generated_snapshot(dest_prefix):
|
||||
print(
|
||||
"ERROR: refusing to replace existing playbook_root because it is "
|
||||
"not a generated snapshot; use install_mode = \"subtree\" for a "
|
||||
"git subtree-managed Playbook",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
backup = dest_standards / f"{dest_prefix.name}.bak.{timestamp}"
|
||||
dest_prefix.rename(backup)
|
||||
@@ -535,13 +598,29 @@ def vendor_action(config: dict, context: dict) -> int:
|
||||
copy2(example_config, dest_prefix / "playbook.toml.example")
|
||||
|
||||
write_docs_index(dest_prefix, langs)
|
||||
write_snapshot_readme(dest_prefix, deploy_root, langs)
|
||||
write_snapshot_readme(dest_prefix, playbook_root, langs)
|
||||
write_source_file(dest_prefix, langs)
|
||||
|
||||
log(f"Vendored snapshot -> {dest_prefix}")
|
||||
log(f"Installed snapshot -> {dest_prefix}")
|
||||
return 0
|
||||
|
||||
|
||||
def validate_subtree_install(context: dict) -> int:
|
||||
project_root: Path = context["project_root"]
|
||||
playbook_root = context["playbook_root"]
|
||||
expected_root = (project_root / playbook_root).resolve()
|
||||
if PLAYBOOK_ROOT.resolve() == expected_root:
|
||||
return 0
|
||||
|
||||
print(
|
||||
"ERROR: install_mode = \"subtree\" requires running the project-local "
|
||||
f"Playbook script at "
|
||||
f"{join_playbook_subpath(playbook_root, 'scripts/playbook.py')}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
def replace_placeholders(
|
||||
text: str,
|
||||
project_name: str | None,
|
||||
@@ -1413,8 +1492,6 @@ def format_md_action(config: dict, context: dict) -> int:
|
||||
|
||||
def run_action(name: str, config: dict, context: dict) -> int:
|
||||
print(f"[action] {name}")
|
||||
if name == "vendor":
|
||||
return vendor_action(config, context)
|
||||
if name == "sync_rules":
|
||||
return sync_rules_action(config, context)
|
||||
if name == "sync_memory_bank":
|
||||
@@ -1489,6 +1566,13 @@ def main(argv: list[str]) -> int:
|
||||
return 2
|
||||
|
||||
config = load_config(config_path)
|
||||
try:
|
||||
validate_removed_config(config)
|
||||
install_mode = resolve_install_mode(config)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
playbook_config = config.get("playbook", {})
|
||||
project_root = playbook_config.get("project_root")
|
||||
if project_root:
|
||||
@@ -1499,7 +1583,7 @@ def main(argv: list[str]) -> int:
|
||||
root = config_path.parent
|
||||
resolved_root = root.resolve()
|
||||
try:
|
||||
deploy_root = resolve_configured_deploy_root(config, resolved_root)
|
||||
playbook_root = resolve_configured_playbook_root(config, resolved_root)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
@@ -1507,9 +1591,19 @@ def main(argv: list[str]) -> int:
|
||||
"project_root": resolved_root,
|
||||
"config_path": config_path.resolve(),
|
||||
"config": config,
|
||||
"deploy_root": deploy_root,
|
||||
"install_mode": install_mode,
|
||||
"playbook_root": playbook_root,
|
||||
}
|
||||
|
||||
if install_mode == "snapshot":
|
||||
result = install_snapshot(config, context)
|
||||
if result != 0:
|
||||
return result
|
||||
elif config_uses_playbook_root(config):
|
||||
result = validate_subtree_install(context)
|
||||
if result != 0:
|
||||
return result
|
||||
|
||||
if should_sync_agents(config):
|
||||
result = sync_agents_template(context)
|
||||
if result != 0:
|
||||
|
||||
Reference in New Issue
Block a user