155 lines
5.9 KiB
Python
155 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""migrate.py — bring an existing ECHO vault up to the plugin's current schema.
|
|
|
|
Reads the marker's schema_version and applies each intervening migration in order.
|
|
Migrations are idempotent and additive; every destructive step (DELETE/move) is
|
|
gated behind --apply AND printed first. Default mode is a DRY-RUN plan.
|
|
Cross-platform: pure Python via echo.py.
|
|
|
|
Usage:
|
|
migrate.py # print the migration plan (no changes)
|
|
migrate.py --apply # perform the migration (moves/deletes included)
|
|
|
|
Env: ECHO_BASE, ECHO_KEY (via echo.py).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
|
import echo # noqa: E402
|
|
|
|
CURRENT_SCHEMA = 3
|
|
|
|
|
|
def get_text(path: str) -> str | None:
|
|
status, body = echo.request("GET", echo.vault_url(path))
|
|
if status == 404:
|
|
return None
|
|
echo.check(status, body, f"get {path}")
|
|
return body.decode(errors="replace")
|
|
|
|
|
|
def list_files(path: str) -> list[str]:
|
|
if not path.endswith("/"):
|
|
path += "/"
|
|
status, body = echo.request("GET", echo.vault_url(path))
|
|
if status == 404:
|
|
return []
|
|
echo.check(status, body, f"ls {path}")
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
return [entry for entry in payload.get("files", []) if not entry.endswith("/")]
|
|
|
|
|
|
def put_text(path: str, text: str) -> None:
|
|
status, body = echo.request("PUT", echo.vault_url(path), data=text.encode(),
|
|
headers={"Content-Type": "text/markdown"})
|
|
echo.check(status, body, f"put {path}")
|
|
|
|
|
|
def delete(path: str) -> None:
|
|
status, body = echo.request("DELETE", echo.vault_url(path))
|
|
if status != 404:
|
|
echo.check(status, body, f"delete {path}")
|
|
|
|
|
|
def move(src: str, dst: str) -> None:
|
|
text = get_text(src)
|
|
if text is None:
|
|
return
|
|
put_text(dst, text)
|
|
delete(src)
|
|
|
|
|
|
def do_or_show(apply: bool, desc: str, func=None) -> None:
|
|
if apply:
|
|
print(f"migrate: APPLY {desc}")
|
|
if func:
|
|
func()
|
|
else:
|
|
print(f"migrate: PLAN {desc}")
|
|
|
|
|
|
def marker_schema() -> int:
|
|
marker = get_text("_agent/echo-vault.md")
|
|
if marker is None:
|
|
print("migrate: marker missing — vault not bootstrapped. Run bootstrap.py, not migrate.py.")
|
|
raise SystemExit(3)
|
|
for line in marker.splitlines():
|
|
if line.startswith("schema_version:"):
|
|
try:
|
|
return int(line.split(":", 1)[1].strip())
|
|
except ValueError:
|
|
return 0
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Migrate ECHO vault schema")
|
|
parser.add_argument("--apply", action="store_true")
|
|
args = parser.parse_args(argv)
|
|
|
|
start = marker_schema()
|
|
print(f"migrate: vault schema_version={start}, plugin schema={CURRENT_SCHEMA} "
|
|
f"{'(APPLY)' if args.apply else '(dry-run)'}")
|
|
if start >= CURRENT_SCHEMA:
|
|
print("migrate: up to date — nothing to do.")
|
|
return 0
|
|
|
|
if start < 1:
|
|
print("migrate: [0->1] retire in-vault control docs (CLAUDE/BOOTSTRAP/STRUCTURE/index.md)")
|
|
for filename in ["CLAUDE.md", "BOOTSTRAP.md", "STRUCTURE.md", "index.md"]:
|
|
if get_text(filename) is not None:
|
|
do_or_show(args.apply, f"delete vault/{filename} (back it up outside the vault first)",
|
|
lambda f=filename: delete(f))
|
|
print("migrate: [0->1] reminder: scrub dangling control-doc [[links]] from Related sections.")
|
|
|
|
if start < 2:
|
|
print("migrate: [1->2] fold reviews/ into journal/ and _agent/health/")
|
|
for filename in list_files("reviews/weekly"):
|
|
if filename.endswith(".md"):
|
|
dst = f"journal/weekly/{filename.replace('-review.md', '.md')}"
|
|
do_or_show(args.apply, f"move reviews/weekly/{filename} -> {dst}",
|
|
lambda f=filename, d=dst: move(f"reviews/weekly/{f}", d))
|
|
for filename in list_files("reviews/monthly"):
|
|
if filename.endswith(".md"):
|
|
dst = f"_agent/health/{filename}" if filename.endswith("vault-health.md") else f"journal/monthly/{filename}"
|
|
do_or_show(args.apply, f"move reviews/monthly/{filename} -> {dst}",
|
|
lambda f=filename, d=dst: move(f"reviews/monthly/{f}", d))
|
|
for period in ["quarterly", "annual"]:
|
|
for filename in list_files(f"reviews/{period}"):
|
|
if filename.endswith(".md"):
|
|
dst = f"journal/{period}/{filename}"
|
|
do_or_show(args.apply, f"move reviews/{period}/{filename} -> {dst}",
|
|
lambda p=period, f=filename, d=dst: move(f"reviews/{p}/{f}", d))
|
|
print("migrate: [1->2] reminder: update inbound [[reviews/...]] wikilinks manually.")
|
|
|
|
if start < 3:
|
|
print("migrate: [2->3] add the entity-index folder (cross-link / recall feature)")
|
|
if get_text("_agent/index/README.md") is None:
|
|
do_or_show(args.apply, "create _agent/index/README.md",
|
|
lambda: put_text("_agent/index/README.md",
|
|
"# index\n\nMachine-maintained entity index. See the echo-memory plugin.\n"))
|
|
print("migrate: [2->3] then run `sweep.py --apply` to build entities.json and symmetrize links.")
|
|
|
|
do_or_show(args.apply, f"set _agent/echo-vault.md schema_version -> {CURRENT_SCHEMA}",
|
|
lambda: echo.cmd_fm("_agent/echo-vault.md", "schema_version", str(CURRENT_SCHEMA)))
|
|
if args.apply:
|
|
print(f"migrate: migration complete -> schema {CURRENT_SCHEMA}. Run sweep.py --apply, then vault_lint.py.")
|
|
else:
|
|
print("migrate: dry-run only. Re-run with --apply to perform the moves/deletes above.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|