feat(orchestrator): read the pkgmgr branch tree file instead of running git
Phase 3 of requirement 001. The orchestrator no longer starts git: orchestrator/kvb_orchestrator/tree.py maps the document that pkgmgr branch tree writes (repos, branches, worktrees, detached heads, delete_ready) onto the Tree view, re-reads it when mtime or size change, and reports a missing or broken file as an empty tree with the error. compose.yml mounts the tree directory read-only (a file mount would keep the replaced inode). /api/state touches state/tree-heartbeat; kvb-orchestrator-tree.timer runs every minute and its oneshot service runs only while that heartbeat is younger than 5 minutes, with PATH=/usr/bin:/bin and the pkgmgr venv that the install builds from the newest v* tag a fully trusted GPG key signed, cloned fresh from PKGMGR_REPO because the local checkout's config is sandbox-writable. Dialectic verdict 2026-11-06 (2 rounds, 5 skeptics, ~85 %): hardened git runner in pkgmgr, gc classification shared with the tree scan, forge facts only from an unlocked Secret Service token with a 600 s cache, atomic tree output refreshed by open/close/drop/gc, host units outside sandbox-writable paths, orchestrator reads the file only. Losing objections: extending the host runner (it would run bash -c on signed text), a new payload kind, copying the pkgmgr checkout, a tree limited to .worktrees. Operator answers: signed-tag venv, UI delete keeps volumes and is local only, ignored files beyond known caches block a delete, scan only while the page polls. Delete jobs, gc_item signing and the UI delete path follow in phase 4. MEDUSA acceptances (kvb-think-criticize 2026-11-07, 1 skeptic): MEDUSA-MM-SCAN-058 orchestrator/server.py:64 and :80 false positives (case-insensitive substring pla in --templates and Places; --tree-file is a read-only JSON file, no model loading). 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: – ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ef2fb6327
commit
912a3dc07f
17 files changed
+453
-251
No files matched your search
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"generated_at": "2026-11-06T12:00:00+00:00",
|
||||
"host_pid_namespace": true,
|
||||
"delete_ready": false,
|
||||
"delete_reason": "Secret Service locked",
|
||||
"repos": [
|
||||
{
|
||||
"path": "@ROOT@/repos/github.com/me/demo",
|
||||
"remote": "@ROOT@/origin.git",
|
||||
"default": "main",
|
||||
"missing": false,
|
||||
"known": true,
|
||||
"forge": "none",
|
||||
"scanned_at": "2026-11-06T12:00:00+00:00",
|
||||
"warnings": [],
|
||||
"items": [
|
||||
{
|
||||
"branch": "feature",
|
||||
"worktree": "@ROOT@/worktrees/github.com/me/demo/feature",
|
||||
"head": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
|
||||
"detached": false,
|
||||
"locked": null,
|
||||
"worktree_state": "changed",
|
||||
"staged": 0,
|
||||
"unstaged": 0,
|
||||
"untracked": 1,
|
||||
"upstream": null,
|
||||
"ahead": null,
|
||||
"behind": null,
|
||||
"gone": false,
|
||||
"local_sha": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
|
||||
"remotes": {},
|
||||
"compose_projects": [],
|
||||
"images": [],
|
||||
"state": "dirty",
|
||||
"merged_by": null,
|
||||
"merged_into": null,
|
||||
"size_bytes": null,
|
||||
"skip": null,
|
||||
"actions": [
|
||||
"worktree",
|
||||
"local-branch"
|
||||
],
|
||||
"reason": "dirty: 1 paths",
|
||||
"result": null
|
||||
},
|
||||
{
|
||||
"branch": "main",
|
||||
"worktree": "@ROOT@/repos/github.com/me/demo",
|
||||
"head": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
|
||||
"detached": false,
|
||||
"locked": null,
|
||||
"worktree_state": "clean",
|
||||
"staged": 0,
|
||||
"unstaged": 0,
|
||||
"untracked": 0,
|
||||
"upstream": "origin/main",
|
||||
"ahead": 1,
|
||||
"behind": 0,
|
||||
"gone": false,
|
||||
"local_sha": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
|
||||
"remotes": {
|
||||
"origin": "cff243cb6d57c4b4b435b418fd49d64c9751dda8"
|
||||
},
|
||||
"compose_projects": [],
|
||||
"images": [],
|
||||
"state": "protected",
|
||||
"merged_by": null,
|
||||
"merged_into": null,
|
||||
"size_bytes": null,
|
||||
"skip": null,
|
||||
"actions": [],
|
||||
"reason": "default or protected branch",
|
||||
"result": null
|
||||
},
|
||||
{
|
||||
"branch": null,
|
||||
"worktree": "@ROOT@/worktrees/github.com/me/demo/detached",
|
||||
"head": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
|
||||
"detached": true,
|
||||
"locked": null,
|
||||
"worktree_state": "clean",
|
||||
"staged": 0,
|
||||
"unstaged": 0,
|
||||
"untracked": 0,
|
||||
"upstream": null,
|
||||
"ahead": null,
|
||||
"behind": null,
|
||||
"gone": false,
|
||||
"local_sha": null,
|
||||
"remotes": {},
|
||||
"compose_projects": [],
|
||||
"images": [],
|
||||
"state": "unmerged",
|
||||
"merged_by": null,
|
||||
"merged_into": null,
|
||||
"size_bytes": null,
|
||||
"skip": null,
|
||||
"actions": [
|
||||
"worktree"
|
||||
],
|
||||
"reason": "unmerged: 1 commits not in origin/main",
|
||||
"result": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "@ROOT@/outside/elsewhere",
|
||||
"remote": null,
|
||||
"default": "main",
|
||||
"missing": false,
|
||||
"known": false,
|
||||
"forge": "none",
|
||||
"scanned_at": "2026-11-06T12:00:00+00:00",
|
||||
"warnings": [],
|
||||
"items": [
|
||||
{
|
||||
"branch": "lone",
|
||||
"worktree": "@ROOT@/worktrees/x/y/lone",
|
||||
"head": "ff5731c77d13db964854912057ed09267431b6d9",
|
||||
"detached": false,
|
||||
"locked": null,
|
||||
"worktree_state": "clean",
|
||||
"staged": 0,
|
||||
"unstaged": 0,
|
||||
"untracked": 0,
|
||||
"upstream": null,
|
||||
"ahead": null,
|
||||
"behind": null,
|
||||
"gone": false,
|
||||
"local_sha": "ff5731c77d13db964854912057ed09267431b6d9",
|
||||
"remotes": {},
|
||||
"compose_projects": [],
|
||||
"images": [],
|
||||
"state": "unmerged",
|
||||
"merged_by": null,
|
||||
"merged_into": null,
|
||||
"size_bytes": null,
|
||||
"skip": null,
|
||||
"actions": [
|
||||
"worktree",
|
||||
"local-branch"
|
||||
],
|
||||
"reason": "no own commits",
|
||||
"result": null
|
||||
},
|
||||
{
|
||||
"branch": "main",
|
||||
"worktree": "@ROOT@/outside/elsewhere",
|
||||
"head": "ff5731c77d13db964854912057ed09267431b6d9",
|
||||
"detached": false,
|
||||
"locked": null,
|
||||
"worktree_state": "clean",
|
||||
"staged": 0,
|
||||
"unstaged": 0,
|
||||
"untracked": 0,
|
||||
"upstream": null,
|
||||
"ahead": null,
|
||||
"behind": null,
|
||||
"gone": false,
|
||||
"local_sha": "ff5731c77d13db964854912057ed09267431b6d9",
|
||||
"remotes": {},
|
||||
"compose_projects": [],
|
||||
"images": [],
|
||||
"state": "protected",
|
||||
"merged_by": null,
|
||||
"merged_into": null,
|
||||
"size_bytes": null,
|
||||
"skip": null,
|
||||
"actions": [],
|
||||
"reason": "default or protected branch",
|
||||
"result": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -99,13 +99,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
})
|
||||
self.request("feat: orchestrator", "")
|
||||
self.create_ask(ASK)
|
||||
repo = self.repos / "github.com" / "me" / "demo"
|
||||
repo.mkdir(parents=True)
|
||||
self.git(repo, "init", "--quiet", "--initial-branch=main")
|
||||
(repo / "a.txt").write_text("a\n", encoding="utf-8")
|
||||
self.git(repo, "add", "a.txt")
|
||||
self.git(repo, "commit", "--quiet", "-m", "a")
|
||||
self.git(repo, "worktree", "add", "--quiet", "-b", "feature", str(self.worktrees / "github.com" / "me" / "demo" / "feature"))
|
||||
self.write_tree()
|
||||
self.base = f"http://{self.host}/"
|
||||
|
||||
def drive(self, steps: list[dict]) -> dict:
|
||||
|
||||
+9
-4
@@ -40,12 +40,11 @@ class HubCase(unittest.TestCase):
|
||||
self.state = base / "state"
|
||||
self.outside = base / "outside"
|
||||
self.projects = base / "projects"
|
||||
self.repos = base / "repos"
|
||||
self.worktrees = base / "worktrees"
|
||||
self.tree_file = base / "tree" / "branch-tree.json"
|
||||
self.timers = base / "timers"
|
||||
self.approvers = base / "config" / "approvers.json"
|
||||
self.replay = base / "replay"
|
||||
for path in (self.pages, self.state, self.outside, self.projects, self.repos, self.worktrees, self.timers):
|
||||
for path in (self.pages, self.state, self.outside, self.projects, self.tree_file.parent, self.timers):
|
||||
path.mkdir()
|
||||
silence = mock.patch.object(server.Handler, "log_message")
|
||||
silence.start()
|
||||
@@ -64,7 +63,7 @@ class HubCase(unittest.TestCase):
|
||||
transcripts=self.projects,
|
||||
scripts=self.scripts,
|
||||
templates=HUB / "templates",
|
||||
places=server.Places(repos=self.repos, worktrees=self.worktrees, timers=self.timers),
|
||||
places=server.Places(tree_file=self.tree_file, timers=self.timers),
|
||||
model=server.Model(url="", model="", key=""),
|
||||
engines=self.engines,
|
||||
approvers=self.approvers,
|
||||
@@ -190,6 +189,12 @@ class HubCase(unittest.TestCase):
|
||||
text = content if isinstance(content, str) else json.dumps(content)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
def write_tree(self) -> Path:
|
||||
root = self.outside / "scan"
|
||||
sample = (Path(__file__).parent / "branch-tree.json").read_text(encoding="utf-8")
|
||||
self.tree_file.write_text(sample.replace("@ROOT@", str(root)), encoding="utf-8")
|
||||
return root
|
||||
|
||||
def jsonl(self, rows: list[dict]) -> str:
|
||||
return "".join(json.dumps(row) + "\n" for row in rows)
|
||||
|
||||
|
||||
@@ -174,8 +174,8 @@ class TestHubSpeech(HubCase):
|
||||
def test_the_server_requires_every_engine_argument(self):
|
||||
args = {
|
||||
"--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state),
|
||||
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"), "--repos": str(self.repos),
|
||||
"--worktrees": str(self.worktrees), "--timers": str(self.timers), "--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
|
||||
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"),
|
||||
"--tree-file": str(self.tree_file), "--timers": str(self.timers), "--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
|
||||
"--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper),
|
||||
"--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers),
|
||||
}
|
||||
|
||||
+55
-71
@@ -1,90 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import PAGE
|
||||
from hub_support import HUB, PAGE
|
||||
|
||||
GENERATED = datetime.datetime(2026, 11, 6, 12, tzinfo=datetime.UTC).timestamp()
|
||||
|
||||
|
||||
def starts_git(path) -> list[int]:
|
||||
lines = []
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value.split(" ")[0] in ("git", "/usr/bin/git"):
|
||||
lines.append(node.lineno)
|
||||
return lines
|
||||
|
||||
|
||||
class TestHubTree(HubCase):
|
||||
def test_tree_lists_repos_branches_and_worktrees(self):
|
||||
origin = self.outside / "origin.git"
|
||||
self.git(self.outside, "init", "--quiet", "--bare", "--initial-branch=main", str(origin))
|
||||
repo = self.repos / "github.com" / "me" / "demo"
|
||||
repo.mkdir(parents=True)
|
||||
self.git(repo, "init", "--quiet", "--initial-branch=main")
|
||||
(repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
self.git(repo, "add", "a.txt")
|
||||
self.git(repo, "commit", "--quiet", "-m", "a")
|
||||
self.git(repo, "remote", "add", "origin", str(origin))
|
||||
self.git(repo, "push", "--quiet", "-u", "origin", "main")
|
||||
(repo / "b.txt").write_text("b", encoding="utf-8")
|
||||
self.git(repo, "add", "b.txt")
|
||||
self.git(repo, "commit", "--quiet", "-m", "b")
|
||||
feature = self.worktrees / "github.com" / "me" / "demo" / "feature"
|
||||
self.git(repo, "worktree", "add", "--quiet", "-b", "feature", str(feature))
|
||||
(feature / "dirty.txt").write_text("x", encoding="utf-8")
|
||||
other = self.outside / "elsewhere"
|
||||
other.mkdir()
|
||||
self.git(other, "init", "--quiet", "--initial-branch=main")
|
||||
(other / "c.txt").write_text("c", encoding="utf-8")
|
||||
self.git(other, "add", "c.txt")
|
||||
self.git(other, "commit", "--quiet", "-m", "c")
|
||||
lone = self.worktrees / "x" / "y" / "lone"
|
||||
self.git(other, "worktree", "add", "--quiet", "-b", "lone", str(lone))
|
||||
feature, repo, other, lone = (path.resolve() for path in (feature, repo, other, lone))
|
||||
self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running", "worker": "Skills session", "workdir": str(feature)}}})
|
||||
self.tool("review_request", {**self.arguments("feat: tree", "", self.page("tree.html", PAGE)), "worktree": str(feature), "branch": "feature"})
|
||||
def test_tree_serves_the_pkgmgr_file_with_workers_and_reviews(self):
|
||||
root = self.write_tree()
|
||||
demo, feature = f"{root}/repos/github.com/me/demo", f"{root}/worktrees/github.com/me/demo/feature"
|
||||
self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running", "worker": "Skills session", "workdir": feature}}})
|
||||
self.tool("review_request", {**self.arguments("feat: tree", "", self.page("tree.html", PAGE)), "worktree": feature, "branch": "feature"})
|
||||
status, _, body = self.get("/api/tree")
|
||||
self.assertEqual(status, 200, body)
|
||||
data = json.loads(body)
|
||||
repos = {entry["name"]: entry for entry in data["repos"]}
|
||||
self.assertEqual(set(repos), {"github.com/me/demo", str(other)})
|
||||
demo = {entry["name"]: entry for entry in repos["github.com/me/demo"]["branches"]}
|
||||
self.assertEqual((demo["main"]["upstream"], demo["main"]["ahead"], demo["main"]["behind"]), ("origin/main", 1, 0))
|
||||
trees = [(tree["path"], tree["dirty"], tree["workers"], tree["review"]) for tree in demo["main"]["worktrees"] + demo["feature"]["worktrees"]]
|
||||
self.assertEqual(trees, [(str(repo), False, [], None), (str(feature), True, ["Skills"], {"status": "pending", "gate": "pass", "title": "feat: tree"})])
|
||||
self.assertEqual([tree["path"] for tree in {entry["name"]: entry for entry in repos[str(other)]["branches"]}["lone"]["worktrees"]], [str(lone)])
|
||||
self.assertEqual(json.loads(self.get("/api/tree")[2])["scanned"], data["scanned"])
|
||||
repos = {entry["path"]: entry for entry in data["repos"]}
|
||||
self.assertEqual((repos[demo]["name"], repos[demo]["known"], repos[f"{root}/outside/elsewhere"]["known"]), ("github.com/me/demo", True, False))
|
||||
self.assertEqual((data["scanned"], data["delete_ready"], data["delete_reason"]), (GENERATED, False, "Secret Service locked"))
|
||||
branches = {entry["name"]: entry for entry in repos[demo]["branches"]}
|
||||
main = branches["main"]
|
||||
self.assertEqual((main["upstream"], main["ahead"], main["behind"], main["gone"], main["state"]), ("origin/main", 1, 0, False, "protected"))
|
||||
self.assertEqual((branches["feature"]["state"], branches["feature"]["reason"]), ("dirty", "dirty: 1 paths"))
|
||||
trees = [(tree["path"], tree["dirty"], tree["untracked"], tree["workers"], tree["review"]) for tree in main["worktrees"] + branches["feature"]["worktrees"]]
|
||||
self.assertEqual(trees, [(demo, False, 0, [], None), (feature, True, 1, ["Skills"], {"status": "pending", "gate": "pass", "title": "feat: tree"})])
|
||||
self.assertEqual([(tree["path"], tree["workers"]) for tree in repos[demo]["detached"]], [(f"{root}/worktrees/github.com/me/demo/detached", [])])
|
||||
|
||||
def test_tree_runs_no_command_a_repo_config_names(self):
|
||||
marker = self.outside / "ran"
|
||||
trap, filtered = (self.repos / "github.com" / "me" / name for name in ("trap", "filtered"))
|
||||
for repo in (trap, filtered):
|
||||
repo.mkdir(parents=True)
|
||||
self.git(repo, "init", "--quiet", "--initial-branch=main")
|
||||
(repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
self.git(repo, "add", "a.txt")
|
||||
self.git(repo, "commit", "--quiet", "-m", "a")
|
||||
self.git(trap, "config", "core.fsmonitor", f"touch {marker}")
|
||||
self.git(filtered, "config", "filter.x.clean", f"touch {marker}")
|
||||
(filtered / ".gitattributes").write_text("* filter=x\n", encoding="utf-8")
|
||||
(filtered / "a.txt").write_text("b", encoding="utf-8")
|
||||
def test_tree_reads_the_file_again_once_it_changes(self):
|
||||
self.write_tree()
|
||||
self.assertEqual(len(json.loads(self.get("/api/tree")[2])["repos"]), 2)
|
||||
data = json.loads(self.tree_file.read_text(encoding="utf-8"))
|
||||
data["repos"] = data["repos"][:1]
|
||||
self.tree_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
self.assertEqual(len(json.loads(self.get("/api/tree")[2])["repos"]), 1)
|
||||
|
||||
def test_a_missing_or_broken_file_answers_with_the_reason(self):
|
||||
status, _, body = self.get("/api/tree")
|
||||
self.assertEqual(status, 200, body)
|
||||
dirty = {entry["name"]: entry["branches"][0]["worktrees"][0]["dirty"] for entry in json.loads(body)["repos"]}
|
||||
self.assertEqual(dirty, {"github.com/me/trap": False, "github.com/me/filtered": None})
|
||||
self.assertFalse(marker.exists())
|
||||
self.assertEqual((json.loads(body)["repos"], json.loads(body)["scanned"]), ([], None))
|
||||
self.assertIn("no pkgmgr tree file", json.loads(body)["error"])
|
||||
self.tree_file.write_text('{"repos": [{"path": "/x"}]}', encoding="utf-8")
|
||||
self.assertIn("unreadable", json.loads(self.get("/api/tree")[2])["error"])
|
||||
|
||||
def test_tree_never_fetches_a_missing_object(self):
|
||||
marker = self.outside / "fetched"
|
||||
origin = self.outside / "lazy.git"
|
||||
self.git(self.outside, "init", "--quiet", "--bare", "--initial-branch=main", str(origin))
|
||||
repo = self.repos / "github.com" / "me" / "lazy"
|
||||
repo.mkdir(parents=True)
|
||||
self.git(repo, "init", "--quiet", "--initial-branch=main")
|
||||
(repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
self.git(repo, "add", "a.txt")
|
||||
self.git(repo, "commit", "--quiet", "-m", "a")
|
||||
self.git(repo, "remote", "add", "origin", str(origin))
|
||||
self.git(repo, "push", "--quiet", "-u", "origin", "main")
|
||||
self.git(repo, "config", "remote.origin.promisor", "true")
|
||||
self.git(repo, "config", "remote.origin.uploadpack", f"touch {marker}; git-upload-pack")
|
||||
(repo / ".git" / "refs" / "remotes" / "origin" / "main").write_text("1" * 40 + "\n", encoding="utf-8")
|
||||
status, _, body = self.get("/api/tree")
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertFalse(marker.exists())
|
||||
def test_the_state_poll_beats_the_heartbeat_the_tree_timer_waits_for(self):
|
||||
heartbeat = self.state / "tree-heartbeat"
|
||||
self.assertFalse(heartbeat.exists())
|
||||
self.snapshot()
|
||||
self.assertTrue(heartbeat.is_file())
|
||||
os.utime(heartbeat, (0, 0))
|
||||
self.snapshot()
|
||||
self.assertGreater(heartbeat.stat().st_mtime, 0)
|
||||
|
||||
def test_no_module_of_the_orchestrator_starts_git(self):
|
||||
sources = [HUB / "server.py", *sorted((HUB / "kvb_orchestrator").glob("*.py"))]
|
||||
found = {path.name: starts_git(path) for path in sources}
|
||||
self.assertEqual({name: lines for name, lines in found.items() if lines}, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -82,7 +82,7 @@ class TestOrchestratorMigration(unittest.TestCase):
|
||||
(stubs / tool).symlink_to("stub")
|
||||
self.state = self.root / "stub.json"
|
||||
self.state.write_text(json.dumps({"calls": [], "images": [f"{OLD}:local"], "mcp": {OLD: "http://127.0.0.1:8737/mcp"}, "enabled": [f"{OLD}-runner.service"]}), encoding="utf-8")
|
||||
self.env = {**os.environ, "HOME": str(self.home), "PATH": f"{stubs}:{os.environ['PATH']}", "STUB_STATE": str(self.state), "AGENTS_COUNCIL_ROOT": str(self.root / "council")}
|
||||
self.env = {**os.environ, "HOME": str(self.home), "PATH": f"{stubs}:{os.environ['PATH']}", "STUB_STATE": str(self.state), "AGENTS_COUNCIL_ROOT": str(self.root / "council"), "PKGMGR_REPO": str(self.root / "no-pkgmgr")}
|
||||
share, local, config = self.home / ".local" / "share", self.home / ".local" / "state", self.home / ".config"
|
||||
self.write(share / OLD / "compose.yml", f"name: {OLD}\n")
|
||||
self.write(share / OLD / ".env", "KVB_HUB_PORT=8737\n")
|
||||
|
||||
Reference in new issue
Block a user