feat: unify bootstrap export and apply cli
This commit is contained in:
+73
-3
@@ -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.
|
- `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.
|
- `export-rules.json` defines which metadata is exported and which paths must verify.
|
||||||
- `templates/generic/` holds the canonical bootstrap templates.
|
- `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
|
## Generate
|
||||||
|
|
||||||
From a checked-out Agent OS clone:
|
From a checked-out Agent OS clone:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 bootstrap/generate-export.py
|
python3 bootstrap/cli.py export
|
||||||
```
|
```
|
||||||
|
|
||||||
To generate the live runtime export requested by the server workflow:
|
To generate the live runtime export requested by the server workflow:
|
||||||
|
|
||||||
```bash
|
```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.
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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)
|
||||||
Executable
+291
@@ -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())
|
||||||
@@ -1,117 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate and verify Agent OS bootstrap exports."""
|
"""Compatibility wrapper for `bootstrap cli export|verify`."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import fnmatch
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bootstrap_lib import DEFAULT_OUTPUT
|
||||||
BOOTSTRAP_ROOT = Path(__file__).resolve().parent
|
from cli import cmd_export, cmd_verify
|
||||||
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())
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -128,19 +24,9 @@ def main() -> int:
|
|||||||
help="Verify an existing export without regenerating it.",
|
help="Verify an existing export without regenerating it.",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.verify_only:
|
||||||
output = ensure_safe_output(args.output)
|
return cmd_verify(args)
|
||||||
manifest = load_json(BOOTSTRAP_ROOT / "bundle-manifest.json")
|
return cmd_export(args)
|
||||||
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 __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Executable
+34
@@ -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())
|
||||||
Executable
+23
@@ -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())
|
||||||
Reference in New Issue
Block a user