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:
claude
2026-08-23 13:45:05 -04:00
parent 4ba1256894
commit 24a7fa6cdb
3 changed files with 257 additions and 1 deletions
+109
View File
@@ -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()