feat(client): workspace bootstrap — what a new computer pulls first
granthi-sync bootstrap <folder> reads a workspace.json and pulls the repos it names, still bounded by what the forge grants: a manifest naming a repo this account cannot see prints NOT GRANTED and continues, because that is a permissions answer, not an error to route around. It does NOT install applications. Dash, deck, genie and shiva each have their own repo, deploy path and reviewers; a sync client installing them would create a second unreviewed deploy path beside the real one. So apps are REFERENCED -- the command prints each app's repo, model, setup doc and the vault keys it needs, with the shre-cred line to supply them. Per-repo mode overrides the default, so a documents folder can be declared mirror while real projects stay on the safe snapshot default. 184 tests (was 178).
This commit is contained in:
@@ -1228,6 +1228,117 @@ def cmd_activity(args):
|
||||
return 0
|
||||
|
||||
|
||||
WORKSPACE_FILE = "workspace.json"
|
||||
|
||||
|
||||
def read_workspace(folder):
|
||||
"""Parse a workspace manifest, or raise SystemExit with a usable message.
|
||||
|
||||
Shape (everything except `repos` is optional):
|
||||
|
||||
{"name": "acme",
|
||||
"repos": [{"name": "notes", "mode": "mirror"}, "reports"],
|
||||
"apps": [{"id": "dash", "repo": "nirpa/hermes-agent",
|
||||
"model": "aum/70b", "setup": "docs/SETUP.md",
|
||||
"needs_keys": ["anthropic"]}]}
|
||||
|
||||
`apps` REFERENCES applications; it never vendors them. Dash, deck, genie
|
||||
and shiva each have their own repo, deploy path and reviewers, and copying
|
||||
them into a workspace would fork them silently -- the same
|
||||
copy-without-a-generator failure the estate rulebook exists to end.
|
||||
"""
|
||||
path = os.path.join(folder, WORKSPACE_FILE)
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
raise SystemExit(f"no {WORKSPACE_FILE} in {folder}")
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"{path} is not valid JSON: {e}")
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"{path} must be a JSON object")
|
||||
repos = data.get("repos") or []
|
||||
if not isinstance(repos, list):
|
||||
raise SystemExit(f"{path}: 'repos' must be a list")
|
||||
norm = []
|
||||
for entry in repos:
|
||||
if isinstance(entry, str):
|
||||
norm.append({"name": entry})
|
||||
elif isinstance(entry, dict) and entry.get("name"):
|
||||
norm.append(entry)
|
||||
else:
|
||||
raise SystemExit(
|
||||
f"{path}: every repo must be a name or an object with 'name'")
|
||||
data["repos"] = norm
|
||||
apps = data.get("apps") or []
|
||||
if not isinstance(apps, list) or any(not isinstance(a, dict) for a in apps):
|
||||
raise SystemExit(f"{path}: 'apps' must be a list of objects")
|
||||
return data
|
||||
|
||||
|
||||
def cmd_bootstrap(args):
|
||||
"""Set a computer up from a workspace manifest.
|
||||
|
||||
Deliberately does NOT install applications. It pulls the repos the
|
||||
workspace names -- still only the ones the forge grants this account --
|
||||
and then TELLS the user what each app needs: its repo, its model, its
|
||||
setup doc, and which vault keys must exist. Installing someone's dash or
|
||||
shiva from a sync client would put a second, unreviewed deploy path next
|
||||
to the real one.
|
||||
"""
|
||||
cfg = require_linked(load_config())
|
||||
folder = os.path.abspath(args.folder)
|
||||
ws = read_workspace(folder)
|
||||
name = ws.get("name") or os.path.basename(folder)
|
||||
into = os.path.abspath(args.into or ".")
|
||||
print(f"workspace: {name}\n")
|
||||
|
||||
granted = {r.get("full_name") for r in list_repos(cfg)[0]}
|
||||
granted |= {(r.get("full_name") or "").rsplit("/", 1)[-1] for r in []}
|
||||
pulled = skipped = denied = 0
|
||||
for repo in ws["repos"]:
|
||||
want = repo["name"]
|
||||
full = want if "/" in want else f"{cfg['login']}/{want}"
|
||||
if full not in granted:
|
||||
# The forge decides. A manifest asking for a repo this account was
|
||||
# not granted is a permissions answer, not an error to route round.
|
||||
log(f"NOT GRANTED {full}: your account cannot see it — ask the "
|
||||
f"workspace owner for access")
|
||||
denied += 1
|
||||
continue
|
||||
dest = os.path.join(into, full.rsplit("/", 1)[-1])
|
||||
if os.path.exists(dest) and os.listdir(dest):
|
||||
log(f"skip {full}: already at {dest}")
|
||||
skipped += 1
|
||||
continue
|
||||
if args.dry_run:
|
||||
log(f"WOULD PULL {full} -> {dest}")
|
||||
continue
|
||||
try:
|
||||
clone_one(cfg, full, dest, repo.get("mode"))
|
||||
pulled += 1
|
||||
except (SystemExit, RuntimeError) as e:
|
||||
log(f"FAILED {full}: {e}")
|
||||
print(f"\nrepos: {pulled} pulled, {skipped} already here, "
|
||||
f"{denied} not granted")
|
||||
|
||||
apps = ws.get("apps") or []
|
||||
if apps:
|
||||
print("\napps this workspace expects (NOT installed by granthi-sync —"
|
||||
"\neach has its own repo and deploy path):")
|
||||
for app in apps:
|
||||
print(f" {app.get('id', '?')}")
|
||||
for label, key in (("repo", "repo"), ("model", "model"),
|
||||
("setup", "setup")):
|
||||
if app.get(key):
|
||||
print(f" {label:6} {app[key]}")
|
||||
keys = app.get("needs_keys") or []
|
||||
if keys:
|
||||
print(f" keys {', '.join(keys)} "
|
||||
f"(add with: shre-cred request --name <key> ...)")
|
||||
return 0
|
||||
|
||||
|
||||
def _folder_meta(cfg, folder):
|
||||
path = os.path.abspath(folder)
|
||||
meta = cfg.get("folders", {}).get(path)
|
||||
@@ -1434,6 +1545,13 @@ def main(argv=None):
|
||||
sp.add_argument("--limit", type=int, default=50)
|
||||
sp.set_defaults(fn=cmd_activity)
|
||||
|
||||
sp = sub.add_parser("bootstrap",
|
||||
help="set this computer up from a workspace.json")
|
||||
sp.add_argument("folder", help="folder holding workspace.json")
|
||||
sp.add_argument("--into", help="where to clone the repos (default: .)")
|
||||
sp.add_argument("--dry-run", action="store_true")
|
||||
sp.set_defaults(fn=cmd_bootstrap)
|
||||
|
||||
sp = sub.add_parser("status", help="show linked folders")
|
||||
sp.set_defaults(fn=cmd_status)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user