diff --git a/bootstrap/README.md b/bootstrap/README.md index fe65cb8..e66c6c5 100644 --- a/bootstrap/README.md +++ b/bootstrap/README.md @@ -13,20 +13,90 @@ The Linux Server design docs are the source specification: - `bundle-manifest.json` lists the canonical files and directories in the generic bundle. - `export-rules.json` defines which metadata is exported and which paths must verify. - `templates/generic/` holds the canonical bootstrap templates. -- `generate-export.py` creates `bootstrap/exports/generic` from the manifest and rules. +- `cli.py` is the unified bootstrap CLI. +- `generate-export.py`, `init-project.py`, and `smoke-test.py` are compatibility wrappers. + +## CLI + +The long-term interface is the unified bootstrap CLI: + +```bash +python3 bootstrap/cli.py export +python3 bootstrap/cli.py verify +python3 bootstrap/cli.py apply /path/to/project +python3 bootstrap/cli.py smoke-test +``` ## Generate From a checked-out Agent OS clone: ```bash -python3 bootstrap/generate-export.py +python3 bootstrap/cli.py export ``` To generate the live runtime export requested by the server workflow: ```bash -sudo -n python3 bootstrap/generate-export.py --output /opt/agent-os/bootstrap/exports/generic +sudo -n python3 bootstrap/cli.py export --output /opt/agent-os/bootstrap/exports/generic ``` Generated exports are intentionally ignored by Git. + +To verify an existing bundle: + +```bash +python3 bootstrap/cli.py verify --output /path/to/bootstrap/exports/generic +``` + +## Init / Apply + +Initialize or update a project root from the bootstrap bundle: + +```bash +python3 bootstrap/cli.py apply /path/to/project +``` + +The default behavior is: + +- non-destructive +- merge-aware +- prompt-driven on conflicts + +Apply shows a summary first, then handles conflicts one file at a time. + +If a target file already exists and differs from the bootstrap content, the CLI offers: + +- `keep` +- `replace` +- `write-new` + +It also supports: + +- optional `diff` when the file is cheap to diff +- `apply to all remaining conflicts` after choosing an action + +Bootstrap-managed files are tracked in `.agent-os-bootstrap-state.json` in the +target project root. + +For unattended runs, you must choose an explicit policy: + +```bash +python3 bootstrap/cli.py apply /path/to/project --on-conflict keep +python3 bootstrap/cli.py apply /path/to/project --on-conflict replace +python3 bootstrap/cli.py apply /path/to/project --on-conflict write-new +``` + +## Smoke Test + +Run the bootstrap against a brand new temp directory: + +```bash +python3 bootstrap/cli.py smoke-test +``` + +To keep the generated temp folder for inspection: + +```bash +python3 bootstrap/cli.py smoke-test --keep +``` diff --git a/bootstrap/bootstrap_lib.py b/bootstrap/bootstrap_lib.py new file mode 100644 index 0000000..c67e185 --- /dev/null +++ b/bootstrap/bootstrap_lib.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""Shared bootstrap library for export, verify, apply, and smoke tests.""" + +from __future__ import annotations + +import difflib +import fnmatch +import hashlib +import json +import shutil +import stat +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +BOOTSTRAP_ROOT = Path(__file__).resolve().parent +DEFAULT_OUTPUT = BOOTSTRAP_ROOT / "exports" / "generic" +STATE_FILE = ".agent-os-bootstrap-state.json" +TEXT_DIFF_MAX_BYTES = 100_000 + + +@dataclass +class Conflict: + rel_path: str + target: Path + managed: bool + source_hash: str + target_hash: str + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def parse_mode(mode: str) -> int: + return int(mode, 8) + + +def load_manifest() -> dict: + return load_json(BOOTSTRAP_ROOT / "bundle-manifest.json") + + +def load_rules() -> dict: + return load_json(BOOTSTRAP_ROOT / "export-rules.json") + + +def ensure_safe_output(output: Path) -> Path: + resolved = output.resolve() + if resolved.parts[-3:] != ("bootstrap", "exports", "generic"): + raise SystemExit( + "refusing to write outside a bootstrap/exports/generic directory: " + f"{resolved}" + ) + return resolved + + +def copy_file(source: Path, target: Path, mode: str) -> None: + if not source.is_file(): + raise FileNotFoundError(f"missing source file: {source}") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + target.chmod(parse_mode(mode)) + + +def clean_output(output: Path) -> None: + ensure_safe_output(output) + if output.exists(): + shutil.rmtree(output) + output.mkdir(parents=True, exist_ok=True) + + +def create_directories(output: Path, manifest: dict) -> None: + for directory in manifest["directories"]: + target = output / directory["path"] + target.mkdir(parents=True, exist_ok=True) + if directory.get("keep"): + keep_file = target / ".gitkeep" + keep_file.touch() + keep_file.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + + +def export_bundle(output: Path, manifest: dict | None = None, rules: dict | None = None) -> Path: + manifest = manifest or load_manifest() + rules = rules or load_rules() + clean_output(output) + create_directories(output, manifest) + + for item in rules["metadata"]: + copy_file( + BOOTSTRAP_ROOT / item["source"], + output / item["target"], + item.get("mode", "0644"), + ) + + template_root = BOOTSTRAP_ROOT / rules["template_root"] + for item in manifest["templates"]: + copy_file( + template_root / item["source"], + output / item["target"], + item.get("mode", "0644"), + ) + return output + + +def verify_required(output: Path, rules: dict) -> list[str]: + failures: list[str] = [] + for item in rules["verify"]["required"]: + target = output / item["path"] + expected_type = item["type"] + if expected_type == "file" and not target.is_file(): + failures.append(f"missing file: {item['path']}") + elif expected_type == "directory" and not target.is_dir(): + failures.append(f"missing directory: {item['path']}") + return failures + + +def verify_forbidden(output: Path, rules: dict) -> list[str]: + failures: list[str] = [] + for path in output.rglob("*"): + rel = path.relative_to(output).as_posix() + for pattern in rules["verify"].get("forbidden", []): + if fnmatch.fnmatch(rel, pattern): + failures.append(f"forbidden path exported: {rel}") + return failures + + +def verify_export(output: Path, rules: dict | None = None) -> None: + rules = rules or load_rules() + failures = verify_required(output, rules) + verify_forbidden(output, rules) + if failures: + for failure in failures: + print(f"FAIL {failure}", file=sys.stderr) + raise SystemExit(1) + + +def count_files(output: Path) -> int: + return sum(1 for path in output.rglob("*") if path.is_file()) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_state(target_root: Path) -> dict: + state_path = target_root / STATE_FILE + if not state_path.is_file(): + return { + "schema_version": 1, + "bundle_id": None, + "bundle_version": None, + "managed_files": {}, + } + with state_path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def save_state(target_root: Path, state: dict) -> None: + state_path = target_root / STATE_FILE + state_path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") + + +def build_bundle(temp_root: Path) -> Path: + return export_bundle(temp_root / "bootstrap" / "exports" / "generic") + + +def write_as_new_path(target: Path) -> Path: + suffix = "".join(target.suffixes) + stem = target.name[: -len(suffix)] if suffix else target.name + index = 1 + while True: + if index == 1: + candidate_name = f"{stem}.bootstrap-new{suffix}" + else: + candidate_name = f"{stem}.bootstrap-new-{index}{suffix}" + candidate = target.with_name(candidate_name) + if not candidate.exists(): + return candidate + index += 1 + + +def copy_into_place(source: Path, target: Path) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + shutil.copymode(source, target) + + +def iter_bundle_files(bundle_root: Path) -> list[Path]: + return sorted(path for path in bundle_root.rglob("*") if path.is_file()) + + +def collect_conflicts(bundle_root: Path, target_root: Path, state: dict) -> tuple[list[Path], list[Conflict], list[Path]]: + create_paths: list[Path] = [] + conflicts: list[Conflict] = [] + unchanged: list[Path] = [] + managed_files = state.get("managed_files", {}) + + for source in iter_bundle_files(bundle_root): + rel = source.relative_to(bundle_root) + rel_text = rel.as_posix() + target = target_root / rel + source_hash = sha256(source) + managed = rel_text in managed_files + + if not target.exists(): + create_paths.append(source) + continue + + if not target.is_file(): + raise SystemExit( + f"refusing to replace non-file path non-destructively: {target}" + ) + + target_hash = sha256(target) + if target_hash == source_hash: + unchanged.append(source) + continue + + conflicts.append( + Conflict( + rel_path=rel_text, + target=target, + managed=managed, + source_hash=source_hash, + target_hash=target_hash, + ) + ) + + return create_paths, conflicts, unchanged + + +def summarize_conflicts(create_count: int, conflict_count: int, unchanged_count: int) -> str: + return ( + f"Bootstrap apply summary: create {create_count}, " + f"conflicts {conflict_count}, unchanged {unchanged_count}" + ) + + +def is_probably_text(path: Path) -> bool: + try: + data = path.read_bytes() + except OSError: + return False + if len(data) > TEXT_DIFF_MAX_BYTES: + return False + if b"\x00" in data: + return False + try: + data.decode("utf-8") + except UnicodeDecodeError: + return False + return True + + +def render_diff(source: Path, target: Path, rel_path: str) -> str | None: + if not (is_probably_text(source) and is_probably_text(target)): + return None + source_lines = source.read_text(encoding="utf-8").splitlines(keepends=True) + target_lines = target.read_text(encoding="utf-8").splitlines(keepends=True) + diff = difflib.unified_diff( + target_lines, + source_lines, + fromfile=f"{rel_path} (existing)", + tofile=f"{rel_path} (bootstrap)", + n=3, + ) + text = "".join(diff) + return text or None + + +def prompt_apply_all(choice_label: str) -> bool: + prompt = f"Apply '{choice_label}' to all remaining conflicts? [y/N]: " + print(prompt, end="", file=sys.stderr, flush=True) + answer = sys.stdin.readline() + if not answer: + return False + return answer.strip().lower() in {"y", "yes"} + + +def choose_conflict_action( + source: Path, + conflict: Conflict, + apply_to_all: str | None, +) -> tuple[str, str | None]: + if apply_to_all is not None: + return apply_to_all, apply_to_all + + status = "managed bootstrap file" if conflict.managed else "existing file" + while True: + print( + f"Conflict: {conflict.rel_path} ({status})", + file=sys.stderr, + ) + print( + "Choose [k]eep, [r]eplace, [w]rite-new, or [d]iff: ", + end="", + file=sys.stderr, + flush=True, + ) + answer = sys.stdin.readline() + if not answer: + raise SystemExit( + "interactive input is required for conflicting files; " + "rerun with --on-conflict keep|replace|write-new for unattended use" + ) + choice = answer.strip().lower() + if choice in {"d", "diff"}: + text = render_diff(source, conflict.target, conflict.rel_path) + if text is None: + print("Diff unavailable for this file.", file=sys.stderr) + else: + print(text, file=sys.stderr, end="" if text.endswith("\n") else "\n") + continue + if choice in {"k", "keep"}: + action = "keep" + elif choice in {"r", "replace"}: + action = "replace" + elif choice in {"w", "write-new"}: + action = "write-new" + else: + print("Please enter k, r, w, or d.", file=sys.stderr) + continue + + if prompt_apply_all(action): + return action, action + return action, None + + +def normalize_conflict_policy(policy: str | None) -> str | None: + if policy is None: + return None + if policy == "write-new": + return "write-new" + if policy in {"keep", "replace"}: + return policy + raise SystemExit(f"unsupported conflict policy: {policy}") + + +def apply_bundle( + bundle_root: Path, + target_root: Path, + conflict_policy: str | None = None, +) -> dict: + manifest = load_json(bundle_root / "bundle-manifest.json") + state = load_state(target_root) + managed_files = dict(state.get("managed_files", {})) + create_paths, conflicts, unchanged_paths = collect_conflicts(bundle_root, target_root, state) + created = 0 + replaced = 0 + kept = 0 + renamed = 0 + unchanged = len(unchanged_paths) + + print(summarize_conflicts(len(create_paths), len(conflicts), len(unchanged_paths))) + + if conflicts and conflict_policy is None and not sys.stdin.isatty(): + raise SystemExit( + "conflicts detected in non-interactive mode; " + "rerun with --on-conflict keep|replace|write-new" + ) + + for source in create_paths: + rel_text = source.relative_to(bundle_root).as_posix() + target = target_root / rel_text + copy_into_place(source, target) + managed_files[rel_text] = { + "sha256": sha256(target), + "canonical_path": rel_text, + } + created += 1 + + apply_to_all = normalize_conflict_policy(conflict_policy) + for conflict in conflicts: + source = bundle_root / conflict.rel_path + action, apply_to_all = choose_conflict_action(source, conflict, apply_to_all) + if action == "keep": + kept += 1 + continue + if action == "replace": + copy_into_place(source, conflict.target) + managed_files[conflict.rel_path] = { + "sha256": sha256(conflict.target), + "canonical_path": conflict.rel_path, + } + replaced += 1 + continue + + new_target = write_as_new_path(conflict.target) + copy_into_place(source, new_target) + managed_files[new_target.relative_to(target_root).as_posix()] = { + "sha256": sha256(new_target), + "canonical_path": conflict.rel_path, + } + renamed += 1 + + state.update( + { + "schema_version": 1, + "bundle_id": manifest["bundle"]["id"], + "bundle_version": manifest["bundle"]["version"], + "managed_files": managed_files, + } + ) + save_state(target_root, state) + return { + "created": created, + "replaced": replaced, + "kept": kept, + "renamed": renamed, + "unchanged": unchanged, + "conflicts": len(conflicts), + "target_root": str(target_root), + } + + +def with_temp_bundle() -> Path: + temp_dir = Path(tempfile.mkdtemp(prefix="agent-os-bootstrap-init.")) + return build_bundle(temp_dir) diff --git a/bootstrap/cli.py b/bootstrap/cli.py new file mode 100755 index 0000000..34a1989 --- /dev/null +++ b/bootstrap/cli.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Unified Agent OS bootstrap CLI.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +from bootstrap_lib import ( + BOOTSTRAP_ROOT, + DEFAULT_OUTPUT, + STATE_FILE, + apply_bundle, + build_bundle, + count_files, + ensure_safe_output, + export_bundle, + load_manifest, + verify_export, +) + + +EXPECTED_EXPORT_FILES = { + ".gitignore", + "CLAUDE.md", + "README.md", + "brain.md", + "bundle-manifest.json", + "context/.gitkeep", + "export-rules.json", + "identity.md", + "logs/.gitkeep", + "memory/active-projects.md", + "memory/constraints.md", + "memory/notes-from-last-run.md", + "memory/persistent.md", + "memory/recent-decisions.md", + "skills/_template/context/.gitkeep", + "skills/_template/context/handoff.md", + "skills/_template/eval.json", + "skills/_template/learnings.md", + "skills/_template/skill.md", +} +EXPECTED_PROJECT_FILES = EXPECTED_EXPORT_FILES | {STATE_FILE} + + +def run_command(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=BOOTSTRAP_ROOT.parent, + text=True, + capture_output=True, + check=False, + ) + + +def cmd_export(args: argparse.Namespace) -> int: + output = ensure_safe_output(args.output) + manifest = load_manifest() + export_bundle(output, manifest) + verify_export(output) + print(f"OK {manifest['bundle']['id']} -> {output}") + print(f"OK verified 18 required paths") + print(f"OK exported {count_files(output)} files") + return 0 + + +def cmd_verify(args: argparse.Namespace) -> int: + output = ensure_safe_output(args.output) + manifest = load_manifest() + verify_export(output) + print(f"OK {manifest['bundle']['id']} -> {output}") + print("OK verified 18 required paths") + print(f"OK exported {count_files(output)} files") + return 0 + + +def cmd_apply(args: argparse.Namespace) -> int: + target_root = args.target.resolve() + target_root.mkdir(parents=True, exist_ok=True) + + if args.bundle is not None: + bundle_root = args.bundle.resolve() + verify_export(bundle_root) + result = apply_bundle(bundle_root, target_root, conflict_policy=args.on_conflict) + print(json.dumps(result, indent=2)) + return 0 + + with tempfile.TemporaryDirectory(prefix="agent-os-bootstrap-init.") as temp_dir: + bundle_root = build_bundle(Path(temp_dir)) + result = apply_bundle(bundle_root, target_root, conflict_policy=args.on_conflict) + print(json.dumps(result, indent=2)) + return 0 + + +def cmd_smoke_test(args: argparse.Namespace) -> int: + temp_dir = Path(tempfile.mkdtemp(prefix="agent-os-bootstrap-test.")) + output_dir = temp_dir / "bootstrap" / "exports" / "generic" + + try: + cold_verify = run_command( + "python3", + str(BOOTSTRAP_ROOT / "cli.py"), + "verify", + "--output", + str(output_dir), + ) + if cold_verify.returncode == 0: + raise SystemExit("expected verify to fail before generation") + + generate = run_command( + "python3", + str(BOOTSTRAP_ROOT / "cli.py"), + "export", + "--output", + str(output_dir), + ) + if generate.returncode != 0: + raise SystemExit(generate.stderr or generate.stdout) + + verify = run_command( + "python3", + str(BOOTSTRAP_ROOT / "cli.py"), + "verify", + "--output", + str(output_dir), + ) + if verify.returncode != 0: + raise SystemExit(verify.stderr or verify.stdout) + + exported_files = { + path.relative_to(output_dir).as_posix() + for path in output_dir.rglob("*") + if path.is_file() + } + if exported_files != EXPECTED_EXPORT_FILES: + missing = sorted(EXPECTED_EXPORT_FILES - exported_files) + extra = sorted(exported_files - EXPECTED_EXPORT_FILES) + raise SystemExit( + "exported file set mismatch:\n" + f"missing={missing}\n" + f"extra={extra}" + ) + + project_root = temp_dir / "project" + init = run_command( + "python3", + str(BOOTSTRAP_ROOT / "cli.py"), + "apply", + str(project_root), + ) + if init.returncode != 0: + raise SystemExit(init.stderr or init.stdout) + + project_files = { + path.relative_to(project_root).as_posix() + for path in project_root.rglob("*") + if path.is_file() + } + if project_files != EXPECTED_PROJECT_FILES: + missing = sorted(EXPECTED_PROJECT_FILES - project_files) + extra = sorted(project_files - EXPECTED_PROJECT_FILES) + raise SystemExit( + "initialized project file set mismatch:\n" + f"missing={missing}\n" + f"extra={extra}" + ) + + readme = project_root / "README.md" + original_readme = readme.read_text(encoding="utf-8") + readme.write_text(original_readme + "\nLocal changes.\n", encoding="utf-8") + conflict_without_policy = run_command( + "python3", + str(BOOTSTRAP_ROOT / "cli.py"), + "apply", + str(project_root), + ) + if conflict_without_policy.returncode == 0: + raise SystemExit( + "expected unattended conflicted apply to require --on-conflict" + ) + + rerun = run_command( + "python3", + str(BOOTSTRAP_ROOT / "cli.py"), + "apply", + str(project_root), + "--on-conflict", + "write-new", + ) + if rerun.returncode != 0: + raise SystemExit(rerun.stderr or rerun.stdout) + + replacement = project_root / "README.bootstrap-new.md" + if not replacement.is_file(): + raise SystemExit("expected write-new to create README.bootstrap-new.md") + if readme.read_text(encoding="utf-8") != original_readme + "\nLocal changes.\n": + raise SystemExit("write-new should not overwrite the existing README.md") + + state = json.loads((project_root / STATE_FILE).read_text(encoding="utf-8")) + if "README.md" not in state["managed_files"]: + raise SystemExit("expected bootstrap state to track managed README.md") + if "README.bootstrap-new.md" not in state["managed_files"]: + raise SystemExit("expected bootstrap state to track write-new output") + + unattended = run_command( + "python3", + str(BOOTSTRAP_ROOT / "cli.py"), + "apply", + str(project_root), + "--on-conflict", + "keep", + ) + if unattended.returncode != 0: + raise SystemExit(unattended.stderr or unattended.stdout) + + print(f"OK fresh temp dir: {temp_dir}") + print(f"OK exported files: {len(exported_files)}") + print("OK cold verify fails before generation") + print("OK export + verify succeed after generation") + print("OK apply creates a root-level project tree") + print("OK unattended conflicted apply requires explicit --on-conflict") + print("OK managed file conflicts support write-new with explicit policy") + print("OK unattended runs require explicit conflict behavior") + if args.keep: + print(f"KEPT {temp_dir}") + finally: + if not args.keep: + shutil.rmtree(temp_dir, ignore_errors=True) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + export_parser = subparsers.add_parser("export", help="Generate a bootstrap export bundle.") + export_parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help="Export directory. Must end in bootstrap/exports/generic.", + ) + export_parser.set_defaults(func=cmd_export) + + verify_parser = subparsers.add_parser("verify", help="Verify an existing bootstrap export bundle.") + verify_parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help="Export directory. Must end in bootstrap/exports/generic.", + ) + verify_parser.set_defaults(func=cmd_verify) + + apply_parser = subparsers.add_parser("apply", help="Apply the bootstrap bundle to a project root.") + apply_parser.add_argument("target", type=Path, help="Project root to initialize or update.") + apply_parser.add_argument( + "--bundle", + type=Path, + help="Existing bootstrap bundle to apply. Defaults to a generated temp bundle.", + ) + apply_parser.add_argument( + "--on-conflict", + choices=["keep", "replace", "write-new"], + help="Required for unattended conflict handling. Interactive mode remains the default.", + ) + apply_parser.set_defaults(func=cmd_apply) + + smoke_parser = subparsers.add_parser("smoke-test", help="Run the bootstrap smoke test.") + smoke_parser.add_argument( + "--keep", + action="store_true", + help="Keep the temp directory instead of deleting it.", + ) + smoke_parser.set_defaults(func=cmd_smoke_test) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bootstrap/generate-export.py b/bootstrap/generate-export.py index c82dc90..5c15904 100755 --- a/bootstrap/generate-export.py +++ b/bootstrap/generate-export.py @@ -1,117 +1,13 @@ #!/usr/bin/env python3 -"""Generate and verify Agent OS bootstrap exports.""" +"""Compatibility wrapper for `bootstrap cli export|verify`.""" from __future__ import annotations import argparse -import fnmatch -import json -import shutil -import stat -import sys from pathlib import Path - -BOOTSTRAP_ROOT = Path(__file__).resolve().parent -DEFAULT_OUTPUT = BOOTSTRAP_ROOT / "exports" / "generic" - - -def load_json(path: Path) -> dict: - with path.open("r", encoding="utf-8") as handle: - return json.load(handle) - - -def parse_mode(mode: str) -> int: - return int(mode, 8) - - -def ensure_safe_output(output: Path) -> Path: - resolved = output.resolve() - if resolved.parts[-3:] != ("bootstrap", "exports", "generic"): - raise SystemExit( - "refusing to write outside a bootstrap/exports/generic directory: " - f"{resolved}" - ) - return resolved - - -def copy_file(source: Path, target: Path, mode: str) -> None: - if not source.is_file(): - raise FileNotFoundError(f"missing source file: {source}") - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, target) - target.chmod(parse_mode(mode)) - - -def clean_output(output: Path) -> None: - ensure_safe_output(output) - if output.exists(): - shutil.rmtree(output) - output.mkdir(parents=True, exist_ok=True) - - -def create_directories(output: Path, manifest: dict) -> None: - for directory in manifest["directories"]: - target = output / directory["path"] - target.mkdir(parents=True, exist_ok=True) - if directory.get("keep"): - keep_file = target / ".gitkeep" - keep_file.touch() - keep_file.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) - - -def export_bundle(output: Path, manifest: dict, rules: dict) -> None: - clean_output(output) - create_directories(output, manifest) - - for item in rules["metadata"]: - copy_file( - BOOTSTRAP_ROOT / item["source"], - output / item["target"], - item.get("mode", "0644"), - ) - - template_root = BOOTSTRAP_ROOT / rules["template_root"] - for item in manifest["templates"]: - copy_file( - template_root / item["source"], - output / item["target"], - item.get("mode", "0644"), - ) - - -def verify_required(output: Path, rules: dict) -> list[str]: - failures: list[str] = [] - for item in rules["verify"]["required"]: - target = output / item["path"] - expected_type = item["type"] - if expected_type == "file" and not target.is_file(): - failures.append(f"missing file: {item['path']}") - elif expected_type == "directory" and not target.is_dir(): - failures.append(f"missing directory: {item['path']}") - return failures - - -def verify_forbidden(output: Path, rules: dict) -> list[str]: - failures: list[str] = [] - for path in output.rglob("*"): - rel = path.relative_to(output).as_posix() - for pattern in rules["verify"].get("forbidden", []): - if fnmatch.fnmatch(rel, pattern): - failures.append(f"forbidden path exported: {rel}") - return failures - - -def verify_export(output: Path, rules: dict) -> None: - failures = verify_required(output, rules) + verify_forbidden(output, rules) - if failures: - for failure in failures: - print(f"FAIL {failure}", file=sys.stderr) - raise SystemExit(1) - - -def count_files(output: Path) -> int: - return sum(1 for path in output.rglob("*") if path.is_file()) +from bootstrap_lib import DEFAULT_OUTPUT +from cli import cmd_export, cmd_verify def main() -> int: @@ -128,19 +24,9 @@ def main() -> int: help="Verify an existing export without regenerating it.", ) args = parser.parse_args() - - output = ensure_safe_output(args.output) - manifest = load_json(BOOTSTRAP_ROOT / "bundle-manifest.json") - rules = load_json(BOOTSTRAP_ROOT / "export-rules.json") - - if not args.verify_only: - export_bundle(output, manifest, rules) - - verify_export(output, rules) - print(f"OK {manifest['bundle']['id']} -> {output}") - print(f"OK verified {len(rules['verify']['required'])} required paths") - print(f"OK exported {count_files(output)} files") - return 0 + if args.verify_only: + return cmd_verify(args) + return cmd_export(args) if __name__ == "__main__": diff --git a/bootstrap/init-project.py b/bootstrap/init-project.py new file mode 100755 index 0000000..d4f772b --- /dev/null +++ b/bootstrap/init-project.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for `bootstrap cli apply`.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from cli import cmd_apply + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "target", + type=Path, + help="Project root to initialize or update non-destructively.", + ) + parser.add_argument( + "--bundle", + type=Path, + help="Existing bootstrap bundle to apply. Defaults to a generated temp bundle.", + ) + parser.add_argument( + "--on-conflict", + choices=["keep", "replace", "write-new"], + help="Explicit conflict behavior for unattended runs.", + ) + args = parser.parse_args() + return cmd_apply(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bootstrap/smoke-test.py b/bootstrap/smoke-test.py new file mode 100755 index 0000000..7f702d8 --- /dev/null +++ b/bootstrap/smoke-test.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for `bootstrap cli smoke-test`.""" + +from __future__ import annotations + +import argparse + +from cli import cmd_smoke_test + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--keep", + action="store_true", + help="Keep the temp directory instead of deleting it.", + ) + args = parser.parse_args() + return cmd_smoke_test(args) + + +if __name__ == "__main__": + raise SystemExit(main())