diff --git a/README.md b/README.md index 79b8d3b..d40a4e4 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,7 @@ deleted it again, `DELETE …/tokens/{id}` returning 204 under basic auth): ## Tests -* `python3 -m unittest discover -s tests` — 172 tests. The v1.2 additions +* `python3 -m unittest discover -s tests` — 184 tests. The v1.2 additions cover: a snapshot capturing uncommitted work while HEAD, the index and the working tree stay byte-identical; snapshots landing outside `refs/heads`; an unchanged tree not being re-pushed; a diverged folder still being backed @@ -512,6 +512,35 @@ holding a working forge token, and **the forge decides** whose it is (`GET /api/v1/user`). No login is ever read from the request body, so a body claiming another account changes nothing. +## Workspace bootstrap — what a new computer should pull first + + granthi-sync bootstrap ~/granthi/acme --into ~/work + +A workspace is a repo holding a `workspace.json`: + +```json +{"name": "acme", + "repos": ["notes", {"name": "reports", "mode": "mirror"}], + "apps": [{"id": "dash", "repo": "nirpa/hermes-agent", "model": "aum/70b", + "setup": "docs/SETUP.md", "needs_keys": ["anthropic"]}]} +``` + +`bootstrap` pulls the repos it names — **still only the ones the forge grants +this account**; a manifest asking for a repo you cannot see prints +`NOT GRANTED` and moves on, because that is a permissions answer and not an +error to route around. + +**It does not install applications, and that is the design, not a shortcut.** +Dash, deck, genie and shiva each have their own repo, deploy path and +reviewers. A sync client that installed them would create a second, +unreviewed deploy path beside the real one — the same copy-instead-of-refer +mistake the estate rulebook exists to end. So `apps` is *referenced*: the +command prints each app's repo, the model it uses, its setup doc, and which +vault keys must exist, with the `shre-cred request` line to supply them. + +Per-repo `mode` in the manifest overrides the default, so a documents folder +can be declared `mirror` while everything else stays on the safe `snapshot`. + ## Next phase — invites and per-repo access (designed, not built) Today `/v1/link` creates an account and every folder becomes a private repo diff --git a/client/granthi_sync_client.py b/client/granthi_sync_client.py index 2f6f427..e0394a3 100644 --- a/client/granthi_sync_client.py +++ b/client/granthi_sync_client.py @@ -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 ...)") + 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) diff --git a/tests/test_client.py b/tests/test_client.py index d4821f5..949f5c1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1120,5 +1120,114 @@ class TestDefaultServer(unittest.TestCase): self.assertEqual(reloaded.DEFAULT_SERVER, "http://10.0.0.5:3042") importlib.reload(client) # restore for the rest of the suite + +class TestWorkspaceBootstrap(GitScenarioBase): + """A new computer set up from a manifest -- pulling only what the forge + grants, and never installing an application behind someone's back.""" + + def setUp(self): + super().setUp() + self.forge = os.path.join(self.tmp, "forge") + for full in ("alice/notes", "alice/reports"): + path = os.path.join(self.forge, full + ".git") + os.makedirs(os.path.dirname(path), exist_ok=True) + subprocess.run(["git", "init", "-q", "--bare", "-b", "main", path], + check=True, capture_output=True, env=GIT_ENV) + seed = os.path.join(self.tmp, "seed-" + full.replace("/", "-")) + subprocess.run(["git", "clone", "-q", path, seed], check=True, + capture_output=True, env=GIT_ENV) + run_git(seed, "config", "user.name", "s") + run_git(seed, "config", "user.email", "s@s") + self.write(seed, "f.txt", full) + run_git(seed, "add", "-A"); run_git(seed, "commit", "-m", "seed") + run_git(seed, "push", "-q", "origin", "main") + client.save_config({"gitea_base": self.forge, "login": "alice", + "token": "t", "folders": {}}) + self.ws = os.path.join(self.tmp, "ws") + os.makedirs(self.ws) + self.repos = [{"name": "notes", "full_name": "alice/notes"}, + {"name": "reports", "full_name": "alice/reports"}] + + def manifest(self, obj): + with open(os.path.join(self.ws, "workspace.json"), "w") as f: + json.dump(obj, f) + + def ns(self, **kw): + kw.setdefault("folder", self.ws) + kw.setdefault("into", os.path.join(self.tmp, "out")) + kw.setdefault("dry_run", False) + return argparse.Namespace(**kw) + + def test_pulls_the_repos_the_manifest_names(self): + self.manifest({"name": "acme", + "repos": ["notes", {"name": "reports", "mode": "mirror"}]}) + with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \ + mock.patch("sys.stdout", new_callable=io.StringIO): + client.cmd_bootstrap(self.ns()) + out = os.path.join(self.tmp, "out") + self.assertTrue(os.path.exists(os.path.join(out, "notes", "f.txt"))) + self.assertTrue(os.path.exists(os.path.join(out, "reports", "f.txt"))) + modes = {m["name"]: m["mode"] + for m in client.load_config()["folders"].values()} + self.assertEqual(modes["reports"], "mirror") # manifest override + self.assertEqual(modes["notes"], "snapshot") # safe default + + def test_a_repo_the_account_cannot_see_is_reported_not_attempted(self): + """The forge decides. A manifest naming someone else's repo is a + permissions answer, not an error to route around.""" + self.manifest({"repos": ["notes", "someone-elses-secrets"]}) + with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \ + mock.patch("sys.stdout", new_callable=io.StringIO) as out: + client.cmd_bootstrap(self.ns()) + self.assertIn("NOT GRANTED", out.getvalue()) + self.assertIn("1 not granted", out.getvalue()) + self.assertFalse(os.path.exists( + os.path.join(self.tmp, "out", "someone-elses-secrets"))) + + def test_apps_are_described_never_installed(self): + self.manifest({"repos": [], "apps": [ + {"id": "dash", "repo": "nirpa/hermes-agent", "model": "aum/70b", + "setup": "docs/SETUP.md", "needs_keys": ["anthropic"]}]}) + with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \ + mock.patch("sys.stdout", new_callable=io.StringIO) as out: + client.cmd_bootstrap(self.ns()) + text = out.getvalue() + self.assertIn("NOT installed by granthi-sync", text) + self.assertIn("nirpa/hermes-agent", text) + self.assertIn("aum/70b", text) + self.assertIn("shre-cred request", text) # how to supply the key + # nothing was cloned or written for the app + self.assertFalse(os.path.exists(os.path.join(self.tmp, "out", "dash"))) + + def test_dry_run_changes_nothing(self): + self.manifest({"repos": ["notes"]}) + with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \ + mock.patch("sys.stdout", new_callable=io.StringIO) as out: + client.cmd_bootstrap(self.ns(dry_run=True)) + self.assertIn("WOULD PULL", out.getvalue()) + self.assertFalse(os.path.exists(os.path.join(self.tmp, "out", "notes"))) + self.assertEqual(client.load_config()["folders"], {}) + + def test_rerun_is_idempotent(self): + self.manifest({"repos": ["notes"]}) + with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \ + mock.patch("sys.stdout", new_callable=io.StringIO): + client.cmd_bootstrap(self.ns()) + with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \ + mock.patch("sys.stdout", new_callable=io.StringIO) as out: + client.cmd_bootstrap(self.ns()) + self.assertIn("already here", out.getvalue()) + + def test_a_broken_manifest_says_what_is_wrong(self): + with open(os.path.join(self.ws, "workspace.json"), "w") as f: + f.write("{not json") + with self.assertRaises(SystemExit) as e: + client.cmd_bootstrap(self.ns()) + self.assertIn("not valid JSON", str(e.exception)) + os.remove(os.path.join(self.ws, "workspace.json")) + with self.assertRaises(SystemExit) as e: + client.cmd_bootstrap(self.ns()) + self.assertIn("no workspace.json", str(e.exception)) + if __name__ == "__main__": unittest.main()