dotbrain 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotbrain/__init__.py +7 -0
- dotbrain/_cli_reference.py +115 -0
- dotbrain/adopter_repos.py +573 -0
- dotbrain/beads.py +511 -0
- dotbrain/bootstrap.py +199 -0
- dotbrain/brainspaces.py +238 -0
- dotbrain/cli.py +660 -0
- dotbrain/config.py +428 -0
- dotbrain/doctor.py +279 -0
- dotbrain/hooks.py +84 -0
- dotbrain/migrate.py +304 -0
- dotbrain/paths.py +139 -0
- dotbrain/resource_loader.py +47 -0
- dotbrain/resources/__init__.py +1 -0
- dotbrain/resources/agents/claude/implementer.md +47 -0
- dotbrain/resources/agents/claude/investigator.md +35 -0
- dotbrain/resources/agents/claude/reviewer.md +38 -0
- dotbrain/resources/agents/claude/verifier.md +35 -0
- dotbrain/resources/agents/codex/implementer.toml +26 -0
- dotbrain/resources/agents/codex/investigator.toml +21 -0
- dotbrain/resources/agents/codex/reviewer.toml +27 -0
- dotbrain/resources/agents/codex/verifier.toml +21 -0
- dotbrain/resources/config.yaml +20 -0
- dotbrain/resources/core.yaml +18 -0
- dotbrain/resources/templates/brain/AGENTS.md +9 -0
- dotbrain/resources/templates/brain/DOTBRAIN.md +105 -0
- dotbrain/resources/templates/brain/adr/README.md +8 -0
- dotbrain/resources/templates/brain/designs/README.md +28 -0
- dotbrain/resources/templates/brain/docs/README.md +9 -0
- dotbrain/resources/templates/brain/project.yaml +27 -0
- dotbrain/resources/templates/gitignore +17 -0
- dotbrain/skills.py +264 -0
- dotbrain/subagents.py +252 -0
- dotbrain/updater.py +105 -0
- dotbrain/workflows.py +529 -0
- dotbrain-0.3.4.dist-info/METADATA +21 -0
- dotbrain-0.3.4.dist-info/RECORD +40 -0
- dotbrain-0.3.4.dist-info/WHEEL +4 -0
- dotbrain-0.3.4.dist-info/entry_points.txt +2 -0
- dotbrain-0.3.4.dist-info/licenses/LICENSE +21 -0
dotbrain/beads.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Beads tracker setup, hydration, and admin.
|
|
2
|
+
|
|
3
|
+
Everything that drives the ``bd`` CLI and the ``.beads`` workspace, in three bands:
|
|
4
|
+
|
|
5
|
+
- **init**: ``bd init --stealth`` in a Brainspace, server-mode metadata
|
|
6
|
+
(write/normalize/attach), and the project-#0 root-``.beads`` hijack guard.
|
|
7
|
+
- **load/hydrate**: bring a Brainspace's ``.beads`` into existence from ``config.yaml`` and pull
|
|
8
|
+
remote state, plus the dry-run preview.
|
|
9
|
+
- **remote admin**: list/drop databases on the shared Dolt sql-server.
|
|
10
|
+
|
|
11
|
+
The embedded-to-server migration workflow stays in ``migrate.py`` (it composes these helpers).
|
|
12
|
+
This module depends only on ``config`` and ``paths``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shlex
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from collections.abc import Callable, Sequence
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from dotbrain import config, paths
|
|
28
|
+
|
|
29
|
+
# A subprocess seam: same shape as ``subprocess.run`` but easy to fake in tests.
|
|
30
|
+
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
|
31
|
+
|
|
32
|
+
_DB_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
33
|
+
_PROTECTED_DATABASES = {"dotbrain"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BootstrapResult:
|
|
38
|
+
wired: list[str] = field(default_factory=list)
|
|
39
|
+
pulled: list[str] = field(default_factory=list)
|
|
40
|
+
logs: list[str] = field(default_factory=list)
|
|
41
|
+
warnings: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _default_run(
|
|
45
|
+
argv: Sequence[str], *, cwd: Path | None = None, env: dict | None = None, check: bool = True
|
|
46
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
47
|
+
# stdin=DEVNULL is load-bearing: bd auto-enables non-interactive mode on a non-TTY stdin,
|
|
48
|
+
# so destructive steps (e.g. bd init --reinit-local) skip their confirmation prompt instead
|
|
49
|
+
# of blocking forever on terminal input while capture_output swallows the prompt text.
|
|
50
|
+
return subprocess.run(
|
|
51
|
+
list(argv), cwd=cwd, env=env, check=check,
|
|
52
|
+
capture_output=True, encoding="utf-8", stdin=subprocess.DEVNULL,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------- init & server metadata
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_server_beads_metadata(
|
|
60
|
+
beads_dir: Path, *, host: str, port: str, user: str, database: str
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Write the server-mode ``.beads/metadata.json`` + ``dolt-server.port`` (shape).
|
|
63
|
+
|
|
64
|
+
Also normalizes the ``.beads`` dir to ``0700`` — bd expects a private beads dir and warns
|
|
65
|
+
otherwise; this matches what ``bd init`` creates.
|
|
66
|
+
"""
|
|
67
|
+
beads_dir = Path(beads_dir)
|
|
68
|
+
beads_dir.chmod(0o700)
|
|
69
|
+
(beads_dir / "metadata.json").write_text(
|
|
70
|
+
json.dumps({
|
|
71
|
+
"database": "dolt",
|
|
72
|
+
"backend": "dolt",
|
|
73
|
+
"dolt_mode": "server",
|
|
74
|
+
"dolt_server_host": host,
|
|
75
|
+
"dolt_server_user": user,
|
|
76
|
+
"dolt_database": database,
|
|
77
|
+
}, indent=2) + "\n",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
newline="\n",
|
|
80
|
+
)
|
|
81
|
+
(beads_dir / "dolt-server.port").write_text(
|
|
82
|
+
f"{port}\n", encoding="utf-8", newline="\n"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_server_beads_metadata(beads_dir: Path, port: str) -> None:
|
|
87
|
+
"""Make ``.beads/dolt-server.port`` the source of truth for the port and drop the deprecated
|
|
88
|
+
``dolt_server_port`` key from metadata.json (bd warns it can leak data across projects). Edits
|
|
89
|
+
metadata in place so identity fields like ``project_id`` survive."""
|
|
90
|
+
beads_dir = Path(beads_dir)
|
|
91
|
+
meta_file = beads_dir / "metadata.json"
|
|
92
|
+
if meta_file.is_file():
|
|
93
|
+
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
94
|
+
if data.pop("dolt_server_port", None) is not None:
|
|
95
|
+
meta_file.write_text(
|
|
96
|
+
json.dumps(data, indent=2) + "\n",
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
newline="\n",
|
|
99
|
+
)
|
|
100
|
+
(beads_dir / "dolt-server.port").write_text(
|
|
101
|
+
f"{port}\n", encoding="utf-8", newline="\n"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_server_db_exists_error(exc: subprocess.CalledProcessError) -> bool:
|
|
106
|
+
"""True when ``bd init`` failed only because the server database already exists."""
|
|
107
|
+
return "database exists" in (exc.stderr or "").lower()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def attach_existing_server_beads(
|
|
111
|
+
brainspace: Path,
|
|
112
|
+
project: str,
|
|
113
|
+
*,
|
|
114
|
+
host: str,
|
|
115
|
+
port: str,
|
|
116
|
+
user: str,
|
|
117
|
+
database: str,
|
|
118
|
+
run: Runner = _default_run,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Attach to an already-existing server beads DB via metadata hydration.
|
|
121
|
+
|
|
122
|
+
When ``bd init`` reports the server database already exists, a create is wrong: write the
|
|
123
|
+
server-mode ``.beads`` scaffolding (metadata.json + port) and verify the
|
|
124
|
+
connection instead. Any partial ``.beads`` left by the failed ``bd init`` is overwritten — the
|
|
125
|
+
data lives on the server, so nothing local is at risk.
|
|
126
|
+
|
|
127
|
+
Verification is ``bd dolt test`` (the server-mode check), not ``bd dolt pull``: in server mode no
|
|
128
|
+
Dolt remote is configured, so a pull is a no-op, whereas ``test`` actually confirms the attach
|
|
129
|
+
reaches the server and fails cleanly when it can't.
|
|
130
|
+
"""
|
|
131
|
+
beads = Path(brainspace) / ".beads"
|
|
132
|
+
beads.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
write_server_beads_metadata(beads, host=host, port=port, user=user, database=database)
|
|
134
|
+
run(["bd", "dolt", "test"], cwd=brainspace, env=_beads_env(brainspace), check=True)
|
|
135
|
+
return f"attached to existing server beads DB {host}:{port}/{database} (hydrated metadata)"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def init_beads(
|
|
139
|
+
brainspace: Path,
|
|
140
|
+
project: str,
|
|
141
|
+
dotbrain_home: Path,
|
|
142
|
+
*,
|
|
143
|
+
run_beads: bool = True,
|
|
144
|
+
remote: str = "",
|
|
145
|
+
server_host: str = "",
|
|
146
|
+
server_port: str = "3307",
|
|
147
|
+
server_user: str = "beads",
|
|
148
|
+
database: str = "",
|
|
149
|
+
run: Runner = _default_run,
|
|
150
|
+
) -> str | None:
|
|
151
|
+
"""Run ``bd init --stealth`` in the Brainspace. Returns a log line or None.
|
|
152
|
+
|
|
153
|
+
bd runs in stealth mode so it never commits into the dotbrain monorepo; dotbrain owns all git
|
|
154
|
+
writes here. ``_hide_root_beads`` / ``_restore_root_beads`` still guard bd's repo-root ``.beads``
|
|
155
|
+
workspace hijack, which is workspace resolution, not a commit.
|
|
156
|
+
|
|
157
|
+
In server mode, an existing server database makes ``bd init`` fail with a create error; that
|
|
158
|
+
case falls back to attaching via metadata hydration rather than a destructive
|
|
159
|
+
re-init. Any other ``bd init`` failure surfaces as a clean ``RuntimeError`` carrying bd's stderr.
|
|
160
|
+
"""
|
|
161
|
+
brainspace = Path(brainspace)
|
|
162
|
+
dotbrain_home = Path(dotbrain_home)
|
|
163
|
+
if not run_beads or (brainspace / ".beads").is_dir():
|
|
164
|
+
return None
|
|
165
|
+
if remote and server_host:
|
|
166
|
+
raise ValueError("--beads-remote and --beads-server-host are mutually exclusive")
|
|
167
|
+
|
|
168
|
+
hidden = _hide_root_beads(brainspace, dotbrain_home)
|
|
169
|
+
try:
|
|
170
|
+
try:
|
|
171
|
+
run(_bd_init_args(project, remote, server_host, server_port,
|
|
172
|
+
server_user, database),
|
|
173
|
+
cwd=brainspace, env=_beads_env(brainspace), check=True)
|
|
174
|
+
except subprocess.CalledProcessError as exc:
|
|
175
|
+
if server_host and _is_server_db_exists_error(exc):
|
|
176
|
+
return attach_existing_server_beads(
|
|
177
|
+
brainspace, project,
|
|
178
|
+
host=server_host, port=server_port,
|
|
179
|
+
user=server_user, database=database or project,
|
|
180
|
+
run=run,
|
|
181
|
+
)
|
|
182
|
+
stderr = (exc.stderr or "").strip()
|
|
183
|
+
raise RuntimeError(f"bd init failed: {stderr}" if stderr else "bd init failed") from exc
|
|
184
|
+
finally:
|
|
185
|
+
_restore_root_beads(hidden, dotbrain_home)
|
|
186
|
+
|
|
187
|
+
if server_host:
|
|
188
|
+
_configure_dolt_server(brainspace, server_host, server_port, server_user,
|
|
189
|
+
database or project, run)
|
|
190
|
+
normalize_server_beads_metadata(brainspace / ".beads", server_port)
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _bd_init_args(
|
|
195
|
+
project: str,
|
|
196
|
+
remote: str,
|
|
197
|
+
server_host: str,
|
|
198
|
+
server_port: str,
|
|
199
|
+
server_user: str,
|
|
200
|
+
database: str,
|
|
201
|
+
*,
|
|
202
|
+
reinit_local: bool = False,
|
|
203
|
+
destroy_token: str | None = None,
|
|
204
|
+
) -> list[str]:
|
|
205
|
+
# --stealth: bd must never git-commit into the dotbrain monorepo. It suppresses the
|
|
206
|
+
# "bd init: initialize beads issue tracking" commit and sets no-git-ops for bd's other git ops.
|
|
207
|
+
args = ["bd", "init", "--stealth", "--prefix", project,
|
|
208
|
+
"--skip-agents", "--skip-hooks", "--non-interactive"]
|
|
209
|
+
if server_host:
|
|
210
|
+
args += [
|
|
211
|
+
"--server", "--external",
|
|
212
|
+
"--server-host", server_host,
|
|
213
|
+
"--server-port", server_port,
|
|
214
|
+
"--server-user", server_user,
|
|
215
|
+
"--database", database or project,
|
|
216
|
+
]
|
|
217
|
+
if reinit_local:
|
|
218
|
+
args += ["--reinit-local"]
|
|
219
|
+
if destroy_token:
|
|
220
|
+
args += ["--destroy-token", destroy_token]
|
|
221
|
+
args += ["--remote", remote]
|
|
222
|
+
return args
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _beads_env(brainspace: Path) -> dict:
|
|
226
|
+
return {**os.environ, "BEADS_DIR": str(Path(brainspace) / ".beads"), "BD_NON_INTERACTIVE": "1"}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _hide_root_beads(brainspace: Path, dotbrain_home: Path) -> Path | None:
|
|
230
|
+
"""bd init runs in the Brainspace but git sees dotbrain; hide the root .beads symlink first."""
|
|
231
|
+
root_beads = Path(dotbrain_home) / ".beads"
|
|
232
|
+
if brainspace == paths.brainspace(dotbrain_home, "dotbrain"):
|
|
233
|
+
return None
|
|
234
|
+
if not root_beads.is_symlink():
|
|
235
|
+
return None
|
|
236
|
+
hidden = Path(dotbrain_home) / f".beads.wire-project.{os.getpid()}"
|
|
237
|
+
root_beads.rename(hidden)
|
|
238
|
+
return hidden
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _restore_root_beads(hidden: Path | None, dotbrain_home: Path) -> None:
|
|
242
|
+
"""Always put project #0's repo-root .beads symlink back.
|
|
243
|
+
|
|
244
|
+
bd init runs with the git top-level still resolving to dotbrain, so it may recreate a .beads at
|
|
245
|
+
the repo root pointing at the *wired* project. Remove whatever it left and restore the original
|
|
246
|
+
target unconditionally — a stale ``not root_beads.exists()`` guard here is what let the wired
|
|
247
|
+
project hijack project #0's tracker.
|
|
248
|
+
|
|
249
|
+
Uses ``os.path.lexists`` rather than ``Path.exists()``: the latter follows the symlink and
|
|
250
|
+
checks the *target*, and on Windows that check can spuriously fail for a relative-target
|
|
251
|
+
symlink resolved through a renamed path even when the symlink itself and its target are both
|
|
252
|
+
fine — the wrong question for "did we actually hide something to restore"."""
|
|
253
|
+
if hidden is None or not os.path.lexists(hidden):
|
|
254
|
+
return
|
|
255
|
+
root_beads = Path(dotbrain_home) / ".beads"
|
|
256
|
+
if root_beads.is_symlink() or root_beads.is_file():
|
|
257
|
+
root_beads.unlink()
|
|
258
|
+
elif root_beads.is_dir():
|
|
259
|
+
shutil.rmtree(root_beads)
|
|
260
|
+
hidden.rename(root_beads)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _configure_dolt_server(
|
|
264
|
+
brainspace: Path, host: str, port: str, user: str, database: str, run: Runner
|
|
265
|
+
) -> None:
|
|
266
|
+
env = _beads_env(brainspace)
|
|
267
|
+
for key, value in (("host", host), ("port", port), ("user", user), ("database", database)):
|
|
268
|
+
run(["bd", "dolt", "set", key, value], cwd=brainspace, env=env, check=True)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# --------------------------------------------------------------------------- load / hydrate
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def ensure_server_beads_metadata(
|
|
275
|
+
repo: Path,
|
|
276
|
+
name: str,
|
|
277
|
+
*,
|
|
278
|
+
server_host: str = "",
|
|
279
|
+
server_port: str = "3307",
|
|
280
|
+
server_user: str = "beads",
|
|
281
|
+
database: str = "",
|
|
282
|
+
run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
|
283
|
+
) -> str | None:
|
|
284
|
+
"""Hydrate server-mode ``metadata.json`` from config.yaml defaults, if needed.
|
|
285
|
+
|
|
286
|
+
Creates the ``.beads`` directory when absent: it is never git-tracked, so on a
|
|
287
|
+
fresh clone hydration is what brings it into existence.
|
|
288
|
+
"""
|
|
289
|
+
metadata = repo / ".beads" / "metadata.json"
|
|
290
|
+
port_file = repo / ".beads" / "dolt-server.port"
|
|
291
|
+
if metadata.is_file() or not server_host:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
database = database or name
|
|
295
|
+
port = server_port or "3307"
|
|
296
|
+
user = server_user or "beads"
|
|
297
|
+
|
|
298
|
+
(repo / ".beads").mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
299
|
+
write_server_beads_metadata(
|
|
300
|
+
repo / ".beads", host=server_host, port=port, user=user, database=database
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
run(["bd", "-C", str(repo), "dolt", "test"], check=True)
|
|
304
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
|
305
|
+
metadata.unlink(missing_ok=True)
|
|
306
|
+
port_file.unlink(missing_ok=True)
|
|
307
|
+
raise
|
|
308
|
+
return f"hydrated server beads metadata for {name} at {server_host}:{port}/{database}"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def ensure_embedded_beads(
|
|
312
|
+
brainspace: Path,
|
|
313
|
+
dotbrain_home: Path,
|
|
314
|
+
*,
|
|
315
|
+
remote: str = "",
|
|
316
|
+
run: Runner = _default_run,
|
|
317
|
+
) -> tuple[str | None, str | None]:
|
|
318
|
+
"""Hydrate a declared-embedded Brainspace via ``bd init --stealth``. Returns (log, warning).
|
|
319
|
+
|
|
320
|
+
With a declared remote the tracker is cloned from it; without one only an empty tracker can
|
|
321
|
+
be created, since embedded data was never recoverable from git.
|
|
322
|
+
"""
|
|
323
|
+
if (brainspace / ".beads").is_dir():
|
|
324
|
+
return None, None
|
|
325
|
+
init_beads(brainspace, brainspace.name, dotbrain_home, remote=remote, run=run)
|
|
326
|
+
if remote:
|
|
327
|
+
return f"hydrated embedded beads for {brainspace.name} from {remote}", None
|
|
328
|
+
return (
|
|
329
|
+
f"initialized empty embedded beads for {brainspace.name}",
|
|
330
|
+
f"{brainspace.name}: declared embedded with no remote; tracker starts empty",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _preview_load(brainspace: Path, beads_cfg, cfg) -> list[str]:
|
|
335
|
+
"""Pure dry-run preview of what :func:`pull_beads_for_all` would do for one Brainspace.
|
|
336
|
+
|
|
337
|
+
Mirrors the live branch selection (embedded vs server, already-hydrated skip) without touching
|
|
338
|
+
the filesystem or invoking ``bd``, then always notes the pull. Keeping this free of mutators is
|
|
339
|
+
what makes ``--dry-run`` provably side-effect-free (unwire-dry-run lesson, 9cfc44f).
|
|
340
|
+
"""
|
|
341
|
+
name = brainspace.name
|
|
342
|
+
lines: list[str] = []
|
|
343
|
+
if beads_cfg.mode == "embedded":
|
|
344
|
+
if not (brainspace / ".beads").is_dir():
|
|
345
|
+
if beads_cfg.remote:
|
|
346
|
+
lines.append(f"would hydrate embedded beads for {name} from {beads_cfg.remote}")
|
|
347
|
+
else:
|
|
348
|
+
lines.append(f"would initialize empty embedded beads for {name}")
|
|
349
|
+
elif not (brainspace / ".beads" / "metadata.json").is_file() and cfg.beads_server.host:
|
|
350
|
+
database = beads_cfg.database or name
|
|
351
|
+
port = cfg.beads_server.port or "3307"
|
|
352
|
+
lines.append(
|
|
353
|
+
f"would hydrate server beads metadata for {name} "
|
|
354
|
+
f"at {cfg.beads_server.host}:{port}/{database}"
|
|
355
|
+
)
|
|
356
|
+
lines.append(f"would pull beads for {name}")
|
|
357
|
+
return lines
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def pull_beads_for_all(
|
|
361
|
+
dotbrain_home: Path,
|
|
362
|
+
run: Runner = _default_run,
|
|
363
|
+
bd_timeout: int = 20,
|
|
364
|
+
*,
|
|
365
|
+
projects: Sequence[str] | None = None,
|
|
366
|
+
dry_run: bool = False,
|
|
367
|
+
) -> BootstrapResult:
|
|
368
|
+
"""Hydrate and pull beads state for Brainspaces declared to use beads.
|
|
369
|
+
|
|
370
|
+
Drives off the resolved config.yaml config, not an existing ``.beads`` directory: brainspace
|
|
371
|
+
roots' ``.beads`` are never git-tracked, so on a fresh clone hydration creates them. Targets
|
|
372
|
+
each Brainspace directly, so repo-less projects are hydrated too.
|
|
373
|
+
|
|
374
|
+
``projects`` restricts the run to the named Brainspaces (``None`` = all); a requested name
|
|
375
|
+
with no Brainspace yields a warning. ``dry_run`` only previews via :func:`_preview_load`,
|
|
376
|
+
reaching no filesystem or ``bd`` write by construction.
|
|
377
|
+
"""
|
|
378
|
+
dotbrain_home = Path(dotbrain_home).resolve()
|
|
379
|
+
result = BootstrapResult()
|
|
380
|
+
cfg = config.load_config(dotbrain_home)
|
|
381
|
+
|
|
382
|
+
if not shutil.which("bd"):
|
|
383
|
+
result.warnings.append("bd is not installed; skipping beads pulls")
|
|
384
|
+
return result
|
|
385
|
+
|
|
386
|
+
brainspaces = paths.brainspaces(dotbrain_home)
|
|
387
|
+
if projects is not None:
|
|
388
|
+
by_name = {c.name: c for c in brainspaces}
|
|
389
|
+
brainspaces = []
|
|
390
|
+
for name in projects:
|
|
391
|
+
brainspace = by_name.get(name)
|
|
392
|
+
if brainspace is None:
|
|
393
|
+
result.warnings.append(f"no Brainspace: {paths.data_dir(dotbrain_home).name}/{name}")
|
|
394
|
+
continue
|
|
395
|
+
brainspaces.append(brainspace)
|
|
396
|
+
|
|
397
|
+
for brainspace in brainspaces:
|
|
398
|
+
beads_cfg = config.load_project_config(dotbrain_home, brainspace.name)
|
|
399
|
+
if beads_cfg.mode == "none":
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
if dry_run:
|
|
403
|
+
result.logs += _preview_load(brainspace, beads_cfg, cfg)
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
if beads_cfg.mode == "embedded":
|
|
408
|
+
log, warning = ensure_embedded_beads(
|
|
409
|
+
brainspace, dotbrain_home, remote=beads_cfg.remote, run=run
|
|
410
|
+
)
|
|
411
|
+
if warning:
|
|
412
|
+
result.warnings.append(warning)
|
|
413
|
+
else:
|
|
414
|
+
log = ensure_server_beads_metadata(
|
|
415
|
+
brainspace,
|
|
416
|
+
brainspace.name,
|
|
417
|
+
server_host=cfg.beads_server.host,
|
|
418
|
+
server_port=cfg.beads_server.port,
|
|
419
|
+
server_user=cfg.beads_server.user,
|
|
420
|
+
database=beads_cfg.database,
|
|
421
|
+
run=run,
|
|
422
|
+
)
|
|
423
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
|
|
424
|
+
OSError, RuntimeError) as exc:
|
|
425
|
+
result.warnings.append(f"failed to hydrate beads metadata for {brainspace}: {exc}")
|
|
426
|
+
else:
|
|
427
|
+
if log:
|
|
428
|
+
result.logs.append(log)
|
|
429
|
+
|
|
430
|
+
if beads_cfg.mode == "embedded":
|
|
431
|
+
try:
|
|
432
|
+
subprocess.run(
|
|
433
|
+
["bd", "-C", str(brainspace), "dolt", "pull"],
|
|
434
|
+
check=True, capture_output=True, encoding="utf-8", timeout=bd_timeout,
|
|
435
|
+
)
|
|
436
|
+
result.pulled.append(str(brainspace))
|
|
437
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
438
|
+
result.warnings.append(f"bd dolt pull failed for {brainspace}")
|
|
439
|
+
|
|
440
|
+
return result
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# --------------------------------------------------------------------------- remote admin
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _mysql_argv(
|
|
447
|
+
query: str, *, server_host: str, server_port: str, server_user: str, ssh_host: str
|
|
448
|
+
) -> tuple[list[str], str]:
|
|
449
|
+
"""Build the argv to run ``query`` against the sql-server with the mysql client, optionally
|
|
450
|
+
over an ssh hop. Returns (argv, human-readable target). A bare ``dolt sql`` against the data
|
|
451
|
+
dir fails when a server holds it (client mode dials 127.0.0.1), so we use the mysql client."""
|
|
452
|
+
if not server_host:
|
|
453
|
+
raise ValueError("a Dolt sql-server host is required")
|
|
454
|
+
mysql_argv = [
|
|
455
|
+
"mysql", "--host", server_host, "--port", str(server_port),
|
|
456
|
+
"-u", server_user, "-e", query,
|
|
457
|
+
]
|
|
458
|
+
if ssh_host:
|
|
459
|
+
return ["ssh", ssh_host, " ".join(shlex.quote(a) for a in mysql_argv)], \
|
|
460
|
+
f"{ssh_host} -> {server_host}:{server_port}"
|
|
461
|
+
return mysql_argv, f"{server_host}:{server_port}"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def drop_remote_beads_database(
|
|
465
|
+
project: str,
|
|
466
|
+
*,
|
|
467
|
+
database: str = "",
|
|
468
|
+
server_host: str,
|
|
469
|
+
server_port: str = "3307",
|
|
470
|
+
server_user: str = "beads",
|
|
471
|
+
ssh_host: str = "",
|
|
472
|
+
dry_run: bool = False,
|
|
473
|
+
run: Runner = _default_run,
|
|
474
|
+
) -> str:
|
|
475
|
+
"""Drop a project's beads database on the shared Dolt sql-server (server backend only).
|
|
476
|
+
|
|
477
|
+
``ssh_host`` is the optional hop that can reach the server; empty means connect directly.
|
|
478
|
+
"""
|
|
479
|
+
db = database or project
|
|
480
|
+
if not _DB_NAME_RE.fullmatch(db):
|
|
481
|
+
raise ValueError(f"unsafe beads database name: {db!r}")
|
|
482
|
+
if db in _PROTECTED_DATABASES:
|
|
483
|
+
raise ValueError(f"refusing to drop protected beads database: {db}")
|
|
484
|
+
|
|
485
|
+
argv, via = _mysql_argv(
|
|
486
|
+
f"DROP DATABASE IF EXISTS `{db}`;",
|
|
487
|
+
server_host=server_host, server_port=server_port, server_user=server_user, ssh_host=ssh_host,
|
|
488
|
+
)
|
|
489
|
+
if dry_run:
|
|
490
|
+
return f"would drop remote beads database {db} via {via}"
|
|
491
|
+
run(argv, check=True)
|
|
492
|
+
return f"dropped remote beads database: {db}"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def list_remote_beads_databases(
|
|
496
|
+
*,
|
|
497
|
+
server_host: str,
|
|
498
|
+
server_port: str = "3307",
|
|
499
|
+
server_user: str = "beads",
|
|
500
|
+
ssh_host: str = "",
|
|
501
|
+
run: Runner = _default_run,
|
|
502
|
+
) -> list[str]:
|
|
503
|
+
"""Return the database names on the shared Dolt sql-server (the `SHOW DATABASES` rows)."""
|
|
504
|
+
argv, _ = _mysql_argv(
|
|
505
|
+
"SHOW DATABASES;",
|
|
506
|
+
server_host=server_host, server_port=server_port, server_user=server_user, ssh_host=ssh_host,
|
|
507
|
+
)
|
|
508
|
+
out = run(argv, check=True)
|
|
509
|
+
lines = (out.stdout or "").splitlines()
|
|
510
|
+
# mysql prints a "Database" header row; drop it.
|
|
511
|
+
return [l.strip() for l in lines[1:] if l.strip()]
|