magehand 0.2.0__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.
- magehand/__init__.py +1 -0
- magehand/__main__.py +3 -0
- magehand/cli.py +1107 -0
- magehand-0.2.0.dist-info/METADATA +74 -0
- magehand-0.2.0.dist-info/RECORD +8 -0
- magehand-0.2.0.dist-info/WHEEL +4 -0
- magehand-0.2.0.dist-info/entry_points.txt +2 -0
- magehand-0.2.0.dist-info/licenses/LICENSE +21 -0
magehand/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""magehand: the developer CLI for building apps on the homelab."""
|
magehand/__main__.py
ADDED
magehand/cli.py
ADDED
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
"""magehand: build homelab apps from anywhere, the same way everywhere.
|
|
2
|
+
|
|
3
|
+
The developer CLI for apps on the homelab, used by the owner (Claude Code on
|
|
4
|
+
the MacBook) and by OpenClaw's coding workers:
|
|
5
|
+
|
|
6
|
+
magehand guide [topic ["section"]]
|
|
7
|
+
the platform guide for apps (live from homelab-apps)
|
|
8
|
+
magehand guide search WORDS the guide sections that answer a question
|
|
9
|
+
magehand app [name] this app's blocks, env vars and URLs
|
|
10
|
+
magehand check [name] [--code DIR] [--apps-dir DIR] [--manifests-only]
|
|
11
|
+
the platform's rules for this app's code and
|
|
12
|
+
deployment (run before every push; CI runs it too)
|
|
13
|
+
magehand login sign in to OpenBao through authentik (passkey)
|
|
14
|
+
magehand dev up|down|status local containers for the app's postgres/redis blocks
|
|
15
|
+
magehand run [--no-proxy] -- CMD...
|
|
16
|
+
run CMD with the env the app's pod gets; secrets
|
|
17
|
+
come from OpenBao and local containers, never disk
|
|
18
|
+
magehand run --container [--no-build] [-- CMD...]
|
|
19
|
+
build the repo's Dockerfile and run the image as
|
|
20
|
+
its pod runs (read-only, non-root, no capabilities)
|
|
21
|
+
magehand setup [--undo] once per machine: GitHub, Docker, *.lab names, sign-in,
|
|
22
|
+
the homelab-app skill for coding agents
|
|
23
|
+
magehand skill [--install] that skill (when Claude Code and Codex use magehand)
|
|
24
|
+
magehand doctor check GitHub access, homelab reach, login, Docker
|
|
25
|
+
magehand version this version (upgrade: uv tool upgrade magehand)
|
|
26
|
+
|
|
27
|
+
Private by design: it talks only to GitHub and the private *.lab network,
|
|
28
|
+
never to a public homelab endpoint. Everyday commands assume they can
|
|
29
|
+
connect; `magehand setup` checks that once per machine and, if needed, points
|
|
30
|
+
this Mac's *.lab lookups at the homelab's DNS. Its OpenBao role `dev` can
|
|
31
|
+
only read the LiteLLM keys of agent apps (homelab
|
|
32
|
+
platform/openbao/policies/dev-read.hcl).
|
|
33
|
+
"""
|
|
34
|
+
import base64
|
|
35
|
+
import http.client
|
|
36
|
+
import http.server
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import pathlib
|
|
40
|
+
import re
|
|
41
|
+
import secrets
|
|
42
|
+
import shutil
|
|
43
|
+
import signal
|
|
44
|
+
import socket
|
|
45
|
+
import subprocess # nosec B404 # runs docker, git, gh and the user's own command
|
|
46
|
+
import sys
|
|
47
|
+
import threading
|
|
48
|
+
import time
|
|
49
|
+
import urllib.error
|
|
50
|
+
import urllib.parse
|
|
51
|
+
import urllib.request
|
|
52
|
+
import webbrowser
|
|
53
|
+
from importlib.metadata import PackageNotFoundError, version as package_version
|
|
54
|
+
|
|
55
|
+
import yaml
|
|
56
|
+
|
|
57
|
+
HOMELAB_REPO = 'davidlarrimore/homelab'
|
|
58
|
+
APPS_REPO = 'davidlarrimore/homelab-apps'
|
|
59
|
+
OPENBAO = os.environ.get('MAGEHAND_OPENBAO', 'https://openbao.lab.davidlarrimore.com')
|
|
60
|
+
LAB = 'lab.davidlarrimore.com'
|
|
61
|
+
HOME = pathlib.Path.home()
|
|
62
|
+
STATE = pathlib.Path(os.environ.get('XDG_STATE_HOME', HOME / '.local/state')) / 'magehand'
|
|
63
|
+
CACHE = pathlib.Path(os.environ.get('XDG_CACHE_HOME', HOME / '.cache')) / 'magehand'
|
|
64
|
+
KEYCHAIN_SERVICE = 'magehand'
|
|
65
|
+
CALLBACK = 'http://localhost:8250/oidc/callback'
|
|
66
|
+
PROXY_PORT = int(os.environ.get('MAGEHAND_PROXY_PORT', '8080'))
|
|
67
|
+
# Block type -> the Secret its chart hands over (platform/building-blocks/*).
|
|
68
|
+
NOT_SET_UP = "this machine isn't set up for the homelab: run `magehand setup`"
|
|
69
|
+
RESOLVER = pathlib.Path('/etc/resolver') / LAB
|
|
70
|
+
SECRET_NAME = {'postgres': '{}-connection', 'redis': '{}-connection', 'llm': '{}-connection', 'secret': '{}'}
|
|
71
|
+
# A homelab-apps checkout to read instead of GitHub (CI has one; `magehand check --apps-dir`).
|
|
72
|
+
APPS_DIR = os.environ.get('MAGEHAND_APPS_DIR')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def die(msg):
|
|
76
|
+
sys.exit(f'magehand: {msg}')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- GitHub (read-only: the guide, app manifests, block images) ---------------
|
|
80
|
+
|
|
81
|
+
def github_token():
|
|
82
|
+
token = os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN')
|
|
83
|
+
if token:
|
|
84
|
+
return token
|
|
85
|
+
if shutil.which('gh'):
|
|
86
|
+
out = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True) # nosec B603 B607
|
|
87
|
+
if out.returncode == 0 and out.stdout.strip():
|
|
88
|
+
return out.stdout.strip()
|
|
89
|
+
die('no GitHub access: run `gh auth login` (or set GITHUB_TOKEN)')
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def github_file(repo, path, required=True):
|
|
93
|
+
"""A file on main, cached for offline use; None if it doesn't exist."""
|
|
94
|
+
if repo == APPS_REPO and APPS_DIR:
|
|
95
|
+
local = pathlib.Path(APPS_DIR) / path
|
|
96
|
+
if local.is_file():
|
|
97
|
+
return local.read_text()
|
|
98
|
+
if required:
|
|
99
|
+
die(f'{local} not found')
|
|
100
|
+
return None
|
|
101
|
+
cached = CACHE / repo / path
|
|
102
|
+
req = urllib.request.Request(
|
|
103
|
+
f'https://api.github.com/repos/{repo}/contents/{urllib.parse.quote(path)}?ref=main',
|
|
104
|
+
headers={'Authorization': f'Bearer {github_token()}', 'Accept': 'application/vnd.github.raw',
|
|
105
|
+
'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'magehand'})
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # fixed https URL
|
|
108
|
+
text = resp.read().decode()
|
|
109
|
+
except urllib.error.HTTPError as error:
|
|
110
|
+
if error.code == 404:
|
|
111
|
+
if required:
|
|
112
|
+
die(f'{repo}: {path} not found on main')
|
|
113
|
+
return None
|
|
114
|
+
text = None
|
|
115
|
+
except OSError:
|
|
116
|
+
text = None
|
|
117
|
+
if text is None:
|
|
118
|
+
if cached.is_file():
|
|
119
|
+
print(f'magehand: GitHub unreachable, using the cached {path}', file=sys.stderr)
|
|
120
|
+
return cached.read_text()
|
|
121
|
+
die(f'cannot read {repo}/{path} from GitHub and nothing is cached')
|
|
122
|
+
cached.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
cached.write_text(text)
|
|
124
|
+
return text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def github_dir(repo, path):
|
|
128
|
+
"""Names in a directory on main ([] if it doesn't exist)."""
|
|
129
|
+
if repo == APPS_REPO and APPS_DIR:
|
|
130
|
+
local = pathlib.Path(APPS_DIR) / path
|
|
131
|
+
return sorted(child.name for child in local.iterdir()) if local.is_dir() else []
|
|
132
|
+
req = urllib.request.Request(
|
|
133
|
+
f'https://api.github.com/repos/{repo}/contents/{urllib.parse.quote(path)}?ref=main',
|
|
134
|
+
headers={'Authorization': f'Bearer {github_token()}', 'Accept': 'application/vnd.github+json',
|
|
135
|
+
'User-Agent': 'magehand'})
|
|
136
|
+
try:
|
|
137
|
+
with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # fixed https URL
|
|
138
|
+
return sorted(item['name'] for item in json.load(resp) if isinstance(item, dict))
|
|
139
|
+
except (urllib.error.HTTPError, OSError, ValueError):
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --- The app ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def current_app():
|
|
146
|
+
"""The app this checkout is: an app repo is named after its app."""
|
|
147
|
+
out = subprocess.run(['git', 'remote', 'get-url', 'origin'], capture_output=True, text=True) # nosec B603 B607
|
|
148
|
+
if out.returncode != 0:
|
|
149
|
+
die('not in a git checkout; pass the app name')
|
|
150
|
+
name = out.stdout.strip().rstrip('/').rsplit('/', 1)[-1].rsplit(':', 1)[-1]
|
|
151
|
+
name = name[:-4] if name.endswith('.git') else name
|
|
152
|
+
if name in ('homelab', 'homelab-apps'):
|
|
153
|
+
die('this is a platform repo; pass the app name')
|
|
154
|
+
return name
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load_app(name):
|
|
158
|
+
spec = yaml.safe_load(github_file(APPS_REPO, f'apps/{name}/app.yaml')) or {}
|
|
159
|
+
services = spec.get('services') or {}
|
|
160
|
+
docs = list(yaml.safe_load_all(github_file(APPS_REPO, f'apps/{name}/base/deployment.yaml')))
|
|
161
|
+
deployment = next((d for d in docs if isinstance(d, dict) and d.get('kind') == 'Deployment'), None)
|
|
162
|
+
if not deployment:
|
|
163
|
+
die(f'apps/{name}/base/deployment.yaml has no Deployment')
|
|
164
|
+
pod = deployment['spec']['template']['spec']
|
|
165
|
+
return {'name': name, 'spec': spec, 'services': services, 'container': pod['containers'][0], 'pod': pod}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def block_of(app, secret_name):
|
|
169
|
+
"""(block name, type) whose Secret this is, or (None, None)."""
|
|
170
|
+
for name, svc in app['services'].items():
|
|
171
|
+
kind = (svc or {}).get('type')
|
|
172
|
+
if kind in SECRET_NAME and SECRET_NAME[kind].format(name) == secret_name:
|
|
173
|
+
return name, kind
|
|
174
|
+
return None, None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def env_plan(app):
|
|
178
|
+
"""[(VAR, 'value', text) | (VAR, block, type, key)] from the pod spec."""
|
|
179
|
+
plan = []
|
|
180
|
+
for item in app['container'].get('env') or []:
|
|
181
|
+
if 'value' in item:
|
|
182
|
+
plan.append((item['name'], 'value', str(item['value'])))
|
|
183
|
+
continue
|
|
184
|
+
ref = ((item.get('valueFrom') or {}).get('secretKeyRef')) or {}
|
|
185
|
+
block, kind = block_of(app, ref.get('name'))
|
|
186
|
+
plan.append((item['name'], block, kind, ref.get('key')) if block
|
|
187
|
+
else (item['name'], 'unknown', ref.get('name') or '?'))
|
|
188
|
+
return plan
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def app_port(app):
|
|
192
|
+
for item in app['container'].get('env') or []:
|
|
193
|
+
if item.get('name') == 'PORT' and 'value' in item:
|
|
194
|
+
return int(item['value'])
|
|
195
|
+
ports = app['container'].get('ports') or []
|
|
196
|
+
return int(ports[0]['containerPort']) if ports else 8000
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --- OpenBao (role dev: read-only LiteLLM keys) ---------------------------------
|
|
200
|
+
|
|
201
|
+
def bao(method, path, token=None, body=None):
|
|
202
|
+
headers = {'Content-Type': 'application/json'}
|
|
203
|
+
if token:
|
|
204
|
+
headers['X-Vault-Token'] = token
|
|
205
|
+
req = urllib.request.Request(f'{OPENBAO}/v1/{path}', method=method, headers=headers,
|
|
206
|
+
data=json.dumps(body).encode() if body is not None else None)
|
|
207
|
+
try:
|
|
208
|
+
with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # OpenBao URL (https, *.lab)
|
|
209
|
+
raw = resp.read()
|
|
210
|
+
return resp.status, (json.loads(raw) if raw.strip() else {})
|
|
211
|
+
except urllib.error.HTTPError as error:
|
|
212
|
+
return error.code, None
|
|
213
|
+
except OSError:
|
|
214
|
+
die(f'cannot reach {OPENBAO}: {NOT_SET_UP}')
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def store_token(token, identity):
|
|
218
|
+
if sys.platform == 'darwin':
|
|
219
|
+
subprocess.run(['security', 'add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, # nosec B603 B607
|
|
220
|
+
'-a', 'openbao-token', '-w', token], check=True, capture_output=True)
|
|
221
|
+
else:
|
|
222
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
path = STATE / 'openbao-token'
|
|
224
|
+
path.touch(mode=0o600)
|
|
225
|
+
path.write_text(token)
|
|
226
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
(STATE / 'identity.json').write_text(json.dumps(identity))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def saved_token():
|
|
231
|
+
if os.environ.get('MAGEHAND_BAO_TOKEN'):
|
|
232
|
+
return os.environ['MAGEHAND_BAO_TOKEN']
|
|
233
|
+
if sys.platform == 'darwin':
|
|
234
|
+
out = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, # nosec B603 B607
|
|
235
|
+
'-a', 'openbao-token', '-w'], capture_output=True, text=True)
|
|
236
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
237
|
+
path = STATE / 'openbao-token'
|
|
238
|
+
return path.read_text().strip() if path.is_file() else None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def valid_token():
|
|
242
|
+
token = saved_token()
|
|
243
|
+
if token and bao('GET', 'auth/token/lookup-self', token)[0] == 200:
|
|
244
|
+
return token
|
|
245
|
+
die('not signed in, or the token expired: run `magehand login`')
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def identity():
|
|
249
|
+
path = STATE / 'identity.json'
|
|
250
|
+
return json.loads(path.read_text()) if path.is_file() else {}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cmd_login(_args):
|
|
254
|
+
status, body = bao('POST', 'auth/oidc/oidc/auth_url', body={'role': 'dev', 'redirect_uri': CALLBACK})
|
|
255
|
+
url = ((body or {}).get('data') or {}).get('auth_url')
|
|
256
|
+
if status != 200 or not url:
|
|
257
|
+
die(f'OpenBao gave no login URL (HTTP {status}); is the `dev` OIDC role set up? (scripts/setup-openbao.py)')
|
|
258
|
+
params = {}
|
|
259
|
+
|
|
260
|
+
class Callback(http.server.BaseHTTPRequestHandler):
|
|
261
|
+
def do_GET(self):
|
|
262
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
263
|
+
if parsed.path != '/oidc/callback':
|
|
264
|
+
self.send_response(404)
|
|
265
|
+
self.end_headers()
|
|
266
|
+
return
|
|
267
|
+
params.update(urllib.parse.parse_qsl(parsed.query))
|
|
268
|
+
self.send_response(200)
|
|
269
|
+
self.send_header('Content-Type', 'text/plain')
|
|
270
|
+
self.end_headers()
|
|
271
|
+
self.wfile.write(b'magehand: signed in. Return to the terminal.')
|
|
272
|
+
|
|
273
|
+
def log_message(self, *args):
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
with http.server.HTTPServer(('localhost', 8250), Callback) as server:
|
|
277
|
+
server.timeout = 5
|
|
278
|
+
print('Sign in through authentik in your browser (passkey)...')
|
|
279
|
+
webbrowser.open(url)
|
|
280
|
+
deadline = time.monotonic() + 300
|
|
281
|
+
while 'code' not in params and time.monotonic() < deadline:
|
|
282
|
+
server.handle_request()
|
|
283
|
+
if 'code' not in params:
|
|
284
|
+
die('sign-in timed out or was refused')
|
|
285
|
+
query = urllib.parse.urlencode({k: params[k] for k in ('state', 'code') if k in params})
|
|
286
|
+
status, body = bao('GET', f'auth/oidc/oidc/callback?{query}')
|
|
287
|
+
if status != 200:
|
|
288
|
+
die(f'OpenBao refused the sign-in (HTTP {status})')
|
|
289
|
+
auth = body['auth']
|
|
290
|
+
meta = auth.get('metadata') or {}
|
|
291
|
+
email = meta.get('email') or auth.get('display_name', '').removeprefix('oidc-')
|
|
292
|
+
who = {'email': email, 'uid': auth.get('entity_id', ''), 'username': email.split('@')[0],
|
|
293
|
+
'name': meta.get('name') or email.split('@')[0], 'groups': 'homelab-admins'}
|
|
294
|
+
store_token(auth['client_token'], who)
|
|
295
|
+
hours = auth.get('lease_duration', 3600) // 3600
|
|
296
|
+
print(f'Signed in as {email or "?"} (policy {", ".join(auth.get("policies", []))}, {hours}h).')
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --- guide and app --------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
STOP_WORDS = {'the', 'and', 'for', 'how', 'can', 'what', 'with', 'app', 'apps', 'does', 'into', 'from', 'this',
|
|
302
|
+
'that', 'use', 'get', 'add', 'make', 'need', 'want', 'should', 'when', 'why', 'are', 'not', 'you'}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def sections(text):
|
|
306
|
+
"""[(heading, body)] of a Markdown page, split at its ## and ### headings (the intro is "")."""
|
|
307
|
+
out, heading, lines = [], '', []
|
|
308
|
+
for line in text.splitlines():
|
|
309
|
+
if re.match(r'#{2,3} ', line):
|
|
310
|
+
out.append((heading, '\n'.join(lines).strip()))
|
|
311
|
+
heading, lines = line.lstrip('#').strip(), []
|
|
312
|
+
else:
|
|
313
|
+
lines.append(line)
|
|
314
|
+
out.append((heading, '\n'.join(lines).strip()))
|
|
315
|
+
return [(h, b) for h, b in out if b]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def search_sections(pages, query, limit=3):
|
|
319
|
+
"""The best-matching sections [(score, page, heading, body)] for a free-text query."""
|
|
320
|
+
terms = [t for t in re.findall(r'[a-z0-9_]+', query.lower()) if len(t) > 2 and t not in STOP_WORDS]
|
|
321
|
+
scored = []
|
|
322
|
+
for page, text in pages.items():
|
|
323
|
+
for heading, body in sections(text):
|
|
324
|
+
head, low = heading.lower(), body.lower()
|
|
325
|
+
hits = [t for t in terms if t in head or t in low]
|
|
326
|
+
if hits:
|
|
327
|
+
score = len(hits) * 10 + sum(3 * head.count(t) + min(low.count(t), 5) for t in hits)
|
|
328
|
+
scored.append((score, page, heading, body))
|
|
329
|
+
return sorted(scored, key=lambda s: -s[0])[:limit]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def guide_pages(topics):
|
|
333
|
+
pages = {t: github_file(APPS_REPO, f'docs/platform/{t}.md') for t in topics}
|
|
334
|
+
pages['homelab-apps AGENTS.md'] = github_file(APPS_REPO, 'AGENTS.md')
|
|
335
|
+
return pages
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def cmd_guide(args):
|
|
339
|
+
topics = [n[:-3] for n in github_dir(APPS_REPO, 'docs/platform') if n.endswith('.md')]
|
|
340
|
+
if not topics: # before docs/platform exists
|
|
341
|
+
print(github_file(APPS_REPO, 'AGENTS.md'))
|
|
342
|
+
return
|
|
343
|
+
topic = args[0] if args else 'README'
|
|
344
|
+
if topic == 'search':
|
|
345
|
+
if len(args) < 2:
|
|
346
|
+
die('usage: magehand guide search WORDS (e.g. magehand guide search web search)')
|
|
347
|
+
hits = search_sections(guide_pages(topics), ' '.join(args[1:]))
|
|
348
|
+
if not hits:
|
|
349
|
+
die('nothing in the guide matches; try other words, `magehand guide` for the topics, or ask the owner '
|
|
350
|
+
'(and say in your PR what you looked for)')
|
|
351
|
+
for _, page, heading, body in hits:
|
|
352
|
+
print(f'=== {page}' + (f', "{heading}"' if heading else '') + ' ===')
|
|
353
|
+
lines = body.splitlines()
|
|
354
|
+
print('\n'.join(lines[:60]) + (f'\n[... {len(lines) - 60} more lines: magehand guide {page} '
|
|
355
|
+
f'"{heading}"]' if len(lines) > 60 else ''))
|
|
356
|
+
print()
|
|
357
|
+
return
|
|
358
|
+
if topic == 'catalog':
|
|
359
|
+
print(github_file(APPS_REPO, 'catalog.yaml'))
|
|
360
|
+
return
|
|
361
|
+
if topic not in topics:
|
|
362
|
+
die(f'no topic {topic!r}; topics: {", ".join(topics)}, catalog; or magehand guide search WORDS')
|
|
363
|
+
text = github_file(APPS_REPO, f'docs/platform/{topic}.md')
|
|
364
|
+
if len(args) > 1: # one section: magehand guide blocks "Web search"
|
|
365
|
+
want = ' '.join(args[1:]).lower()
|
|
366
|
+
found = [(h, b) for h, b in sections(text) if h.lower() == want] or \
|
|
367
|
+
[(h, b) for h, b in sections(text) if want in h.lower()]
|
|
368
|
+
if not found:
|
|
369
|
+
die(f'no section {want!r} in {topic}; sections: {", ".join(h for h, _ in sections(text) if h)}')
|
|
370
|
+
print(f'## {found[0][0]}\n\n{found[0][1]}')
|
|
371
|
+
return
|
|
372
|
+
print(text)
|
|
373
|
+
if not args:
|
|
374
|
+
print(f'\nMore: magehand guide <topic> ["section"] ({", ".join(t for t in topics if t != "README")}, '
|
|
375
|
+
'catalog), or magehand guide search WORDS')
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def cmd_app(args):
|
|
379
|
+
app = load_app(args[0] if args else current_app())
|
|
380
|
+
name = app['name']
|
|
381
|
+
source = (app['spec'].get('source') or {}).get('repo')
|
|
382
|
+
print(f'{name} (code: {"davidlarrimore/" + source if source else "homelab-apps apps/" + name + "/image"};'
|
|
383
|
+
f' deployment: homelab-apps apps/{name})')
|
|
384
|
+
print(f' dev: https://{name}-dev.{LAB}')
|
|
385
|
+
for preview in github_dir(APPS_REPO, f'previews/{name}'):
|
|
386
|
+
if preview.endswith('.yaml'):
|
|
387
|
+
print(f' preview: https://{name}-dev-{preview[:-5]}.{LAB}')
|
|
388
|
+
print('Blocks (app.yaml services:):')
|
|
389
|
+
if not app['services']:
|
|
390
|
+
print(' none; request one with a PR to homelab-apps apps/%s/app.yaml (`magehand guide blocks`)' % name)
|
|
391
|
+
for block, svc in app['services'].items():
|
|
392
|
+
opts = ', '.join(f'{k}={v}' for k, v in (svc or {}).items() if k != 'type')
|
|
393
|
+
print(f' {block}: {(svc or {}).get("type")}{" (" + opts + ")" if opts else ""}')
|
|
394
|
+
print(f'Env (the pod spec; locally: magehand run, app port {app_port(app)}):')
|
|
395
|
+
for entry in env_plan(app):
|
|
396
|
+
if entry[1] == 'value':
|
|
397
|
+
print(f' {entry[0]} = {entry[2]}')
|
|
398
|
+
elif entry[1] == 'unknown':
|
|
399
|
+
print(f' {entry[0]} <- Secret {entry[2]} (not a block; magehand run leaves it unset)')
|
|
400
|
+
else:
|
|
401
|
+
print(f' {entry[0]} <- block {entry[1]} ({entry[2]}) key {entry[3]}')
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# --- local containers -------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
def docker(*args, check=True):
|
|
407
|
+
if not shutil.which('docker'):
|
|
408
|
+
die('docker is required for local blocks (Docker Desktop, OrbStack or colima)')
|
|
409
|
+
return subprocess.run(['docker', *args], capture_output=True, text=True, check=check) # nosec B603 B607
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def local_state(app_name):
|
|
413
|
+
path = STATE / f'{app_name}.json'
|
|
414
|
+
return json.loads(path.read_text()) if path.is_file() else {}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def save_local_state(app_name, data):
|
|
418
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
path = STATE / f'{app_name}.json'
|
|
420
|
+
path.touch(mode=0o600)
|
|
421
|
+
path.write_text(json.dumps(data, indent=1))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def block_image(kind, svc):
|
|
425
|
+
values = yaml.safe_load(github_file(HOMELAB_REPO, f'platform/building-blocks/{kind}/values.yaml'))
|
|
426
|
+
if kind == 'postgres' and (svc or {}).get('vector') in (True, 'true'):
|
|
427
|
+
return values['pgvector']['image']
|
|
428
|
+
return values['image']
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cmd_dev(args):
|
|
432
|
+
action = args[0] if args else 'status'
|
|
433
|
+
app = load_app(args[1] if len(args) > 1 else current_app())
|
|
434
|
+
state = local_state(app['name'])
|
|
435
|
+
network = f'magehand-{app["name"]}'
|
|
436
|
+
if action == 'up': # blocks and `run --container` share it, like a namespace
|
|
437
|
+
docker('network', 'create', network, check=False)
|
|
438
|
+
for block, svc in app['services'].items():
|
|
439
|
+
kind = (svc or {}).get('type')
|
|
440
|
+
container = f'magehand-{app["name"]}-{block}'
|
|
441
|
+
if kind == 'secret' and action == 'up':
|
|
442
|
+
state.setdefault(block, {'value': secrets.token_urlsafe(36)[:48]})
|
|
443
|
+
if kind not in ('postgres', 'redis'):
|
|
444
|
+
continue
|
|
445
|
+
if action == 'down':
|
|
446
|
+
docker('rm', '-f', '-v', container, check=False)
|
|
447
|
+
state.pop(block, None)
|
|
448
|
+
print(f'{block}: removed')
|
|
449
|
+
continue
|
|
450
|
+
running = docker('inspect', '-f', '{{.State.Running}}', container, check=False).stdout.strip() == 'true'
|
|
451
|
+
if action == 'status':
|
|
452
|
+
print(f'{block} ({kind}): {"running" if running else "not running"}')
|
|
453
|
+
continue
|
|
454
|
+
if not running:
|
|
455
|
+
password = state.get(block, {}).get('password') or secrets.token_urlsafe(24)
|
|
456
|
+
docker('rm', '-f', container, check=False)
|
|
457
|
+
if kind == 'postgres':
|
|
458
|
+
docker('run', '-d', '--name', container, '--network', network, '-p', '127.0.0.1::5432',
|
|
459
|
+
'-e', 'POSTGRES_USER=app',
|
|
460
|
+
'-e', 'POSTGRES_DB=app', '-e', f'POSTGRES_PASSWORD={password}', block_image(kind, svc))
|
|
461
|
+
else:
|
|
462
|
+
docker('run', '-d', '--name', container, '--network', network, '-p', '127.0.0.1::6379',
|
|
463
|
+
block_image(kind, svc),
|
|
464
|
+
'valkey-server', '--requirepass', password, '--maxmemory', '200mb',
|
|
465
|
+
'--maxmemory-policy', 'allkeys-lru', '--appendonly', 'yes')
|
|
466
|
+
state[block] = {'password': password}
|
|
467
|
+
inner = '5432' if kind == 'postgres' else '6379'
|
|
468
|
+
port = docker('port', container, inner).stdout.strip().splitlines()[0].rsplit(':', 1)[-1]
|
|
469
|
+
state[block]['port'] = port
|
|
470
|
+
print(f'{block} ({kind}): 127.0.0.1:{port}')
|
|
471
|
+
if action == 'up':
|
|
472
|
+
save_local_state(app['name'], state)
|
|
473
|
+
elif action == 'down':
|
|
474
|
+
(STATE / f'{app["name"]}.json').unlink(missing_ok=True)
|
|
475
|
+
docker('network', 'rm', network, check=False)
|
|
476
|
+
elif action != 'status':
|
|
477
|
+
die('usage: magehand dev up|down|status [app]')
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def local_connection(kind, block, local, app_name=None):
|
|
481
|
+
"""A block's Secret keys for a local run: on the host (published port), or
|
|
482
|
+
with app_name, from a container on the app's Docker network."""
|
|
483
|
+
if block not in local:
|
|
484
|
+
die(f'block {block} ({kind}) has no local container: run `magehand dev up`')
|
|
485
|
+
conn = local[block]
|
|
486
|
+
if kind == 'secret':
|
|
487
|
+
return {'value': conn['value']}
|
|
488
|
+
pw = conn['password']
|
|
489
|
+
inner = '5432' if kind == 'postgres' else '6379'
|
|
490
|
+
host, port = (f'magehand-{app_name}-{block}', inner) if app_name else ('127.0.0.1', conn['port'])
|
|
491
|
+
if kind == 'postgres':
|
|
492
|
+
return {'host': host, 'port': port, 'username': 'app', 'database': 'app', 'password': pw,
|
|
493
|
+
'uri': f'postgresql://app:{pw}@{host}:{port}/app'}
|
|
494
|
+
return {'host': host, 'port': port, 'password': pw, 'uri': f'redis://:{pw}@{host}:{port}/0'}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --- run: the secrets adapter ---------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
def dotenv(path):
|
|
500
|
+
"""KEY=VALUE lines from the repo's committed .magehand.env (local, non-secret overrides)."""
|
|
501
|
+
values = {}
|
|
502
|
+
if path.is_file():
|
|
503
|
+
for line in path.read_text().splitlines():
|
|
504
|
+
line = line.strip()
|
|
505
|
+
if line and not line.startswith('#') and '=' in line:
|
|
506
|
+
key, value = line.split('=', 1)
|
|
507
|
+
values[key.strip()] = os.path.expandvars(value.strip().strip('"\''))
|
|
508
|
+
return values
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def resolve_env(app, container=False):
|
|
512
|
+
"""The pod's env, resolved for this machine. On the host, precedence is:
|
|
513
|
+
manifest values < .magehand.env < the caller's environment < secrets
|
|
514
|
+
(OpenBao, local blocks). In a container it is exactly the pod's: manifest
|
|
515
|
+
values and secrets, with blocks reached over the app's Docker network."""
|
|
516
|
+
plan = env_plan(app)
|
|
517
|
+
env = {e[0]: e[2] for e in plan if e[1] == 'value'}
|
|
518
|
+
if not container:
|
|
519
|
+
env.update(dotenv(pathlib.Path('.magehand.env')))
|
|
520
|
+
env.update({k: v for k, v in os.environ.items() if k in env})
|
|
521
|
+
local, fetched, token = local_state(app['name']), {}, None
|
|
522
|
+
for entry in plan:
|
|
523
|
+
if entry[1] in ('value', 'unknown'):
|
|
524
|
+
continue
|
|
525
|
+
var, block, kind, key = entry
|
|
526
|
+
if block not in fetched:
|
|
527
|
+
if kind == 'llm':
|
|
528
|
+
token = token or valid_token()
|
|
529
|
+
path = f'apps/data/{app["name"]}-dev/llm-{block}'
|
|
530
|
+
status, body = bao('GET', path, token)
|
|
531
|
+
if status != 200:
|
|
532
|
+
die(f'cannot read {path} (HTTP {status}); has the broker created the key? (`magehand app`)')
|
|
533
|
+
fetched[block] = body['data']['data']
|
|
534
|
+
else:
|
|
535
|
+
fetched[block] = local_connection(kind, block, local, app['name'] if container else None)
|
|
536
|
+
if key not in fetched[block]:
|
|
537
|
+
die(f'{var}: block {block} has no key {key!r} (keys: {", ".join(sorted(fetched[block]))})')
|
|
538
|
+
env[var] = str(fetched[block][key])
|
|
539
|
+
return env
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def start_proxy(app_port_, who):
|
|
543
|
+
"""127.0.0.1:PROXY_PORT -> the app, adding the X-authentik-* headers Traefik would."""
|
|
544
|
+
headers = {'X-authentik-uid': who.get('uid', 'local'), 'X-authentik-email': who.get('email', ''),
|
|
545
|
+
'X-authentik-name': who.get('name', 'Local developer'),
|
|
546
|
+
'X-authentik-username': who.get('username', 'local'), 'X-authentik-groups': who.get('groups', '')}
|
|
547
|
+
|
|
548
|
+
class Proxy(http.server.BaseHTTPRequestHandler):
|
|
549
|
+
protocol_version = 'HTTP/1.1'
|
|
550
|
+
|
|
551
|
+
def forward(self):
|
|
552
|
+
body = self.rfile.read(int(self.headers.get('Content-Length') or 0))
|
|
553
|
+
out = {k: v for k, v in self.headers.items()
|
|
554
|
+
if not k.lower().startswith('x-authentik-') and k.lower() not in ('connection', 'host')}
|
|
555
|
+
out.update(headers)
|
|
556
|
+
out['Host'] = self.headers.get('Host', f'127.0.0.1:{PROXY_PORT}')
|
|
557
|
+
conn = http.client.HTTPConnection('127.0.0.1', app_port_, timeout=300)
|
|
558
|
+
try:
|
|
559
|
+
conn.request(self.command, self.path, body=body or None, headers=out)
|
|
560
|
+
resp = conn.getresponse()
|
|
561
|
+
data = resp.read()
|
|
562
|
+
except OSError:
|
|
563
|
+
self.send_error(502, 'app not reachable yet')
|
|
564
|
+
return
|
|
565
|
+
finally:
|
|
566
|
+
conn.close()
|
|
567
|
+
self.send_response(resp.status, resp.reason)
|
|
568
|
+
for k, v in resp.getheaders():
|
|
569
|
+
if k.lower() not in ('transfer-encoding', 'connection', 'content-length'):
|
|
570
|
+
self.send_header(k, v)
|
|
571
|
+
self.send_header('Content-Length', str(len(data)))
|
|
572
|
+
self.end_headers()
|
|
573
|
+
self.wfile.write(data)
|
|
574
|
+
|
|
575
|
+
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = forward
|
|
576
|
+
|
|
577
|
+
def log_message(self, *args):
|
|
578
|
+
pass
|
|
579
|
+
|
|
580
|
+
server = http.server.ThreadingHTTPServer(('127.0.0.1', PROXY_PORT), Proxy)
|
|
581
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
582
|
+
return server
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
LAB_SUFFIX = '.' + LAB
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def lab_hosts(env):
|
|
589
|
+
"""--add-host entries for the *.lab names in env values. Inside a container,
|
|
590
|
+
public DNS's 127.0.0.1 would be the container: use what the host resolves
|
|
591
|
+
(the Studio's LAN address on the MacBook) or, on the Studio, the host itself."""
|
|
592
|
+
hosts = set()
|
|
593
|
+
for value in env.values():
|
|
594
|
+
host = urllib.parse.urlparse(value).hostname if '://' in value else None
|
|
595
|
+
if host and host.endswith(LAB_SUFFIX):
|
|
596
|
+
hosts.add(host)
|
|
597
|
+
out = []
|
|
598
|
+
for host in sorted(hosts):
|
|
599
|
+
try:
|
|
600
|
+
address = socket.gethostbyname(host)
|
|
601
|
+
except OSError:
|
|
602
|
+
die(f'cannot resolve {host}: {NOT_SET_UP}')
|
|
603
|
+
out.append(f'{host}:{"host-gateway" if address.startswith("127.") else address}')
|
|
604
|
+
return out
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def container_args(app, env, image, port, hosts, extra):
|
|
608
|
+
"""docker run arguments for the app as its pod runs: read-only, non-root,
|
|
609
|
+
no capabilities. Secrets are passed by name only (-e NAME): docker reads
|
|
610
|
+
the values from its own environment, so they are never in argv or on disk."""
|
|
611
|
+
pod_sc = app['pod'].get('securityContext') or {}
|
|
612
|
+
user = f'{pod_sc.get("runAsUser", 65532)}:{pod_sc.get("runAsGroup", 65532)}'
|
|
613
|
+
args = ['run', '--rm', '--name', f'magehand-{app["name"]}-app', '--network', f'magehand-{app["name"]}',
|
|
614
|
+
'-p', f'127.0.0.1:{port}:{port}', '--read-only', '--tmpfs', '/tmp', '--user', user, # nosec B108 # the container's own tmpfs
|
|
615
|
+
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges']
|
|
616
|
+
if sys.stdin.isatty():
|
|
617
|
+
args.append('-it')
|
|
618
|
+
for host in hosts:
|
|
619
|
+
args += ['--add-host', host]
|
|
620
|
+
for name in sorted(env):
|
|
621
|
+
args += ['-e', name]
|
|
622
|
+
command = extra or (app['container'].get('command') or []) + (app['container'].get('args') or [])
|
|
623
|
+
if command and not extra:
|
|
624
|
+
args += ['--entrypoint', command[0], image, *command[1:]]
|
|
625
|
+
else:
|
|
626
|
+
args += [image, *command]
|
|
627
|
+
return args
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def cmd_run(args):
|
|
631
|
+
proxy = '--no-proxy' not in args
|
|
632
|
+
container = '--container' in args
|
|
633
|
+
build = '--no-build' not in args
|
|
634
|
+
args = [a for a in args if a not in ('--no-proxy', '--container', '--no-build')]
|
|
635
|
+
name = None
|
|
636
|
+
if args and args[0] == '--app':
|
|
637
|
+
name, args = args[1], args[2:]
|
|
638
|
+
if args and args[0] == '--':
|
|
639
|
+
args = args[1:]
|
|
640
|
+
if not args and not container:
|
|
641
|
+
die('usage: magehand run [--no-proxy] [--app NAME] -- CMD... or magehand run --container [--no-build]')
|
|
642
|
+
app = load_app(name or current_app())
|
|
643
|
+
env = resolve_env(app, container=container)
|
|
644
|
+
print(f'magehand: {app["name"]} env: {", ".join(sorted(env))}', file=sys.stderr)
|
|
645
|
+
if proxy:
|
|
646
|
+
port = int(env.get('PORT') or app_port(app))
|
|
647
|
+
start_proxy(port, identity())
|
|
648
|
+
print(f'magehand: open http://127.0.0.1:{PROXY_PORT} (signed in as '
|
|
649
|
+
f'{identity().get("email") or "local"}; the app itself listens on {port})', file=sys.stderr)
|
|
650
|
+
if container:
|
|
651
|
+
image = f'magehand/{app["name"]}:local'
|
|
652
|
+
if build:
|
|
653
|
+
print(f'magehand: docker build -t {image} .', file=sys.stderr)
|
|
654
|
+
if subprocess.run(['docker', 'build', '-t', image, '.']).returncode: # nosec B603 B607
|
|
655
|
+
die('docker build failed')
|
|
656
|
+
docker('network', 'create', f'magehand-{app["name"]}', check=False)
|
|
657
|
+
docker('rm', '-f', f'magehand-{app["name"]}-app', check=False)
|
|
658
|
+
port = int(env.get('PORT') or app_port(app))
|
|
659
|
+
args = ['docker', *container_args(app, env, image, port, lab_hosts(env), args)]
|
|
660
|
+
child = subprocess.Popen(args, env={**os.environ, **env}) # nosec B603 # the user's own command
|
|
661
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
662
|
+
signal.signal(sig, lambda s, _f: child.send_signal(s))
|
|
663
|
+
sys.exit(child.wait())
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# --- doctor and update ---------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
def cmd_doctor(_args):
|
|
669
|
+
ok = True
|
|
670
|
+
|
|
671
|
+
def check(label, passed, hint=''):
|
|
672
|
+
nonlocal ok
|
|
673
|
+
ok = ok and passed
|
|
674
|
+
print(f'{"ok " if passed else "FAIL"} {label}{"" if passed else " -> " + hint}')
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
github_token()
|
|
678
|
+
check('GitHub access', github_file(APPS_REPO, 'catalog.yaml', required=False) is not None)
|
|
679
|
+
except SystemExit:
|
|
680
|
+
check('GitHub access', False, '`gh auth login`')
|
|
681
|
+
try:
|
|
682
|
+
reach = urllib.request.urlopen(f'{OPENBAO}/v1/sys/health', timeout=5).status == 200 # nosec B310
|
|
683
|
+
except (OSError, urllib.error.HTTPError):
|
|
684
|
+
reach = False
|
|
685
|
+
check(f'reach {OPENBAO}', reach, NOT_SET_UP)
|
|
686
|
+
token = saved_token()
|
|
687
|
+
check('signed in to OpenBao', bool(reach and token and bao('GET', 'auth/token/lookup-self', token)[0] == 200),
|
|
688
|
+
'`magehand login`')
|
|
689
|
+
check('docker (for postgres/redis blocks)', bool(shutil.which('docker')), 'install Docker Desktop or OrbStack')
|
|
690
|
+
sys.exit(0 if ok else 1)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# --- check: the platform's rules for an app's code and deployment -------------------
|
|
694
|
+
# Run before every push (the homelab-app skill says so) and in CI (homelab-apps
|
|
695
|
+
# build-app.yaml). Each rule is a mistake an app can make about the platform;
|
|
696
|
+
# every finding names the guide section that explains it. Errors fail (exit 1),
|
|
697
|
+
# warnings are for the author to fix or explain in the PR.
|
|
698
|
+
|
|
699
|
+
# The keys each block's Secret holds, when catalog.yaml doesn't list them.
|
|
700
|
+
BLOCK_KEYS = {
|
|
701
|
+
'postgres': {'keys': ['host', 'port', 'username', 'password', 'database', 'uri']},
|
|
702
|
+
'redis': {'keys': ['host', 'port', 'password', 'uri']},
|
|
703
|
+
'secret': {'keys': ['value']},
|
|
704
|
+
'llm': {'keys': ['api_key', 'base_url', 'models', 'search_url'], 'key_options': {'search_url': 'search'}},
|
|
705
|
+
}
|
|
706
|
+
CODE_SUFFIXES = ('.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.go', '.rb', '.php', '.java', '.kt', '.rs')
|
|
707
|
+
SKIP_DIRS = {'.git', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'vendor', 'target',
|
|
708
|
+
'tests', 'test', '__tests__', 'spec', 'e2e', 'ui-fixtures', 'fixtures'}
|
|
709
|
+
ENV_READ = re.compile(r'''(?:os\.environ\.get|os\.getenv|environ\.get|os\.Getenv|Deno\.env\.get|ENV\.fetch)\(\s*["']([A-Z][A-Z0-9_]*)["']'''
|
|
710
|
+
r'''|(?:os\.environ|process\.env|ENV)\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]'''
|
|
711
|
+
r'''|process\.env\.([A-Z][A-Z0-9_]*)''')
|
|
712
|
+
# Set by the runtime or the image, not the pod spec.
|
|
713
|
+
RUNTIME_ENV = {'HOME', 'PATH', 'HOSTNAME', 'USER', 'PWD', 'SHELL', 'LANG', 'LC_ALL', 'TZ', 'TERM', 'TMPDIR',
|
|
714
|
+
'NODE_ENV', 'PYTHONPATH', 'PYTHONUNBUFFERED', 'CI', 'DEBUG'}
|
|
715
|
+
PROVIDER_HOSTS = ('api.openai.com', 'api.anthropic.com', 'generativelanguage.googleapis.com', 'api.mistral.ai',
|
|
716
|
+
'api.groq.com', 'openrouter.ai', 'api.cohere.com', 'api.cohere.ai', 'api.together.xyz',
|
|
717
|
+
'api.perplexity.ai', 'api.deepseek.com', 'api.x.ai', 'api.exa.ai', 'api.tavily.com', 'serpapi.com',
|
|
718
|
+
'api.search.brave.com', 'api.bing.microsoft.com', 'customsearch.googleapis.com')
|
|
719
|
+
PROVIDER_KEY = re.compile(r'^(OPENAI|ANTHROPIC|GEMINI|GOOGLE_AI|MISTRAL|GROQ|OPENROUTER|COHERE|TOGETHER|PERPLEXITY|'
|
|
720
|
+
r'DEEPSEEK|XAI|EXA|TAVILY|SERPAPI|BRAVE)_[A-Z_]*KEY$')
|
|
721
|
+
OWN_AUTH = re.compile(r'''^\s*(?:import|from)\s+(passlib|bcrypt|argon2|flask_login|flask_security|authlib|jose)\b'''
|
|
722
|
+
r'''|require\(\s*["'](bcrypt|bcryptjs|passport[\w-]*|next-auth|jsonwebtoken|express-session)["']\s*\)'''
|
|
723
|
+
r'''|from\s+["'](bcrypt|bcryptjs|passport[\w-]*|next-auth|@auth/[\w-]+|jsonwebtoken|express-session)["']''',
|
|
724
|
+
re.MULTILINE)
|
|
725
|
+
LOCAL_DB = re.compile(r'''sqlite3\.connect\(\s*(?!["']:memory:)|better-sqlite3|new\s+sqlite3\.Database\(|gorm\.Open\(\s*sqlite''')
|
|
726
|
+
LAB_URL = re.compile(r'https?://[a-z0-9.-]+\.lab\.davidlarrimore\.com')
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def finding(level, where, text, see):
|
|
730
|
+
return {'level': level, 'where': where, 'text': text, 'see': see}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def block_spec(catalog, kind):
|
|
734
|
+
"""{'keys': [...], 'key_options': {key: option}} for a block type: catalog.yaml's, else built in."""
|
|
735
|
+
spec = ((catalog or {}).get('blocks') or {}).get(kind) or {}
|
|
736
|
+
return {'keys': spec.get('keys') or BLOCK_KEYS.get(kind, {}).get('keys') or [],
|
|
737
|
+
'key_options': spec.get('key_options') or BLOCK_KEYS.get(kind, {}).get('key_options') or {}}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def check_manifests(app, catalog):
|
|
741
|
+
"""Rules for homelab-apps apps/<app>/: the blocks requested and the pod's env."""
|
|
742
|
+
found = []
|
|
743
|
+
where = f"homelab-apps apps/{app['name']}"
|
|
744
|
+
blocks = (catalog or {}).get('blocks') or {}
|
|
745
|
+
models = {m.get('id') for m in (catalog or {}).get('models') or [] if isinstance(m, dict)}
|
|
746
|
+
for name, svc in app['services'].items():
|
|
747
|
+
svc = svc or {}
|
|
748
|
+
kind = svc.get('type')
|
|
749
|
+
if blocks and kind not in blocks:
|
|
750
|
+
found.append(finding('error', f'{where}/app.yaml', f'block {name}: unknown type {kind!r}; types: '
|
|
751
|
+
f'{", ".join(sorted(blocks))}', 'blocks'))
|
|
752
|
+
continue
|
|
753
|
+
options = blocks.get(kind) or {}
|
|
754
|
+
if svc.get('size') and options.get('sizes') and svc['size'] not in options['sizes']:
|
|
755
|
+
found.append(finding('error', f'{where}/app.yaml', f'block {name}: size {svc["size"]!r} not offered '
|
|
756
|
+
f'({", ".join(options["sizes"])})', 'blocks'))
|
|
757
|
+
if kind == 'llm':
|
|
758
|
+
for model in svc.get('models') or []:
|
|
759
|
+
if models and model not in models:
|
|
760
|
+
found.append(finding('error', f'{where}/app.yaml', f'block {name}: model {model!r} is not in '
|
|
761
|
+
'the catalog (magehand guide catalog)', 'blocks "Models"'))
|
|
762
|
+
if 'search' in svc and not isinstance(svc['search'], bool):
|
|
763
|
+
found.append(finding('error', f'{where}/app.yaml', f'block {name}: search must be true or false',
|
|
764
|
+
'blocks "Web search"'))
|
|
765
|
+
container = app['container']
|
|
766
|
+
image = container.get('image') or ''
|
|
767
|
+
if not re.fullmatch(r'ghcr\.io/davidlarrimore/homelab-apps/' + re.escape(app['name']) + r'(:[\w.-]+)?@sha256:[0-9a-f]{64}', image):
|
|
768
|
+
found.append(finding('error', f'{where}/base/deployment.yaml', f'image {image or "(none)"}: an app runs only '
|
|
769
|
+
f'its own image, pinned by digest (ghcr.io/davidlarrimore/homelab-apps/{app["name"]}@sha256:...)',
|
|
770
|
+
'README "How an app is put together"'))
|
|
771
|
+
for item in container.get('env') or []:
|
|
772
|
+
ref = ((item.get('valueFrom') or {}).get('secretKeyRef')) or {}
|
|
773
|
+
if not ref:
|
|
774
|
+
continue
|
|
775
|
+
var, secret, key = item.get('name'), ref.get('name'), ref.get('key')
|
|
776
|
+
block, kind = block_of(app, secret)
|
|
777
|
+
if not block and secret in app.get('provided', ()):
|
|
778
|
+
continue # the app's own ExternalSecret (homelab-apps AGENTS.md "Secrets")
|
|
779
|
+
if not block:
|
|
780
|
+
found.append(finding('error', f'{where}/base/deployment.yaml', f'env {var}: Secret {secret!r} is neither '
|
|
781
|
+
'a block\'s nor made by an ExternalSecret in the app\'s manifests, so the pod would '
|
|
782
|
+
'wait for it forever. Request a block in app.yaml (type secret for a generated value)',
|
|
783
|
+
'blocks'))
|
|
784
|
+
continue
|
|
785
|
+
if ref.get('optional'):
|
|
786
|
+
found.append(finding('error', f'{where}/base/deployment.yaml', f'env {var}: optional: true on a block key. '
|
|
787
|
+
'The pod would start with an empty value and keep it for its whole life; use a plain '
|
|
788
|
+
'secretKeyRef (the pod waits until the block is ready)', 'blocks'))
|
|
789
|
+
spec = block_spec(catalog, kind)
|
|
790
|
+
if spec['keys'] and key not in spec['keys']:
|
|
791
|
+
found.append(finding('error', f'{where}/base/deployment.yaml', f'env {var}: block {block} ({kind}) has no '
|
|
792
|
+
f'key {key!r}; keys: {", ".join(spec["keys"])}', 'blocks'))
|
|
793
|
+
option = spec['key_options'].get(key)
|
|
794
|
+
if option and (app['services'].get(block) or {}).get(option) is not True:
|
|
795
|
+
found.append(finding('error', f'{where}/base/deployment.yaml', f'env {var}: key {key} exists only with '
|
|
796
|
+
f'`{option}: true` on block {block}; without it the pod waits forever',
|
|
797
|
+
'blocks "Web search"' if option == 'search' else 'blocks'))
|
|
798
|
+
for source in container.get('envFrom') or []:
|
|
799
|
+
found.append(finding('warning', f'{where}/base/deployment.yaml', f'envFrom {source}: map each variable with '
|
|
800
|
+
'env + secretKeyRef instead, so `magehand app` and `magehand run` can see it', 'blocks'))
|
|
801
|
+
return found
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def code_files(root):
|
|
805
|
+
for path in sorted(root.rglob('*')):
|
|
806
|
+
rel = path.relative_to(root)
|
|
807
|
+
if any(part in SKIP_DIRS or part.startswith('.') for part in rel.parts[:-1]):
|
|
808
|
+
continue
|
|
809
|
+
name = rel.name
|
|
810
|
+
if (path.is_file() and name.endswith(CODE_SUFFIXES) and not name.startswith('test_')
|
|
811
|
+
and not re.search(r'(_test\.py|\.(test|spec)\.[jt]sx?|_test\.go)$', name)):
|
|
812
|
+
yield rel, path
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def line_of(text, index):
|
|
816
|
+
return text.count('\n', 0, index) + 1
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def check_code(root, app):
|
|
820
|
+
"""Rules for the app's code (the repo checkout); `app` is None before it has a deployment."""
|
|
821
|
+
found = []
|
|
822
|
+
pod_env = {item.get('name') for item in (app['container'].get('env') or [])} if app else set()
|
|
823
|
+
for name in ('Dockerfile', 'ui-check.yaml'):
|
|
824
|
+
if not (root / name).is_file():
|
|
825
|
+
found.append(finding('error', name, 'missing: the build needs it at the repo root',
|
|
826
|
+
'homelab-apps AGENTS.md "Apps with their own repo"'))
|
|
827
|
+
reported = set()
|
|
828
|
+
for rel, path in code_files(root):
|
|
829
|
+
try:
|
|
830
|
+
text = path.read_text(errors='replace')
|
|
831
|
+
except OSError:
|
|
832
|
+
continue
|
|
833
|
+
defaults = [] # spans of env reads, whose fallback values may name a lab URL
|
|
834
|
+
for match in ENV_READ.finditer(text):
|
|
835
|
+
end = text.find(')', match.end())
|
|
836
|
+
defaults.append((match.start(), end if end != -1 else match.end()))
|
|
837
|
+
var = next(g for g in match.groups() if g)
|
|
838
|
+
where = f'{rel}:{line_of(text, match.start())}'
|
|
839
|
+
if PROVIDER_KEY.match(var):
|
|
840
|
+
found.append(finding('error', where, f'reads {var}: apps never hold provider keys; AI and web search '
|
|
841
|
+
'go through the llm block (base_url + api_key, search_url)', 'blocks'))
|
|
842
|
+
elif app and var not in pod_env and var not in RUNTIME_ENV and var not in reported:
|
|
843
|
+
reported.add(var)
|
|
844
|
+
found.append(finding('warning', where, f'reads {var}, which the pod doesn\'t set, so the code\'s '
|
|
845
|
+
f'default applies in the cluster. Set it in homelab-apps apps/{app["name"]}/base/'
|
|
846
|
+
'deployment.yaml env (or drop it); local-only values go in .magehand.env',
|
|
847
|
+
'README "What the platform provides"'))
|
|
848
|
+
for host in PROVIDER_HOSTS:
|
|
849
|
+
for match in re.finditer(re.escape(host), text):
|
|
850
|
+
found.append(finding('error', f'{rel}:{line_of(text, match.start())}', f'calls {host} directly: '
|
|
851
|
+
'AI and web search go through the llm block (LiteLLM: base_url, search_url)',
|
|
852
|
+
'blocks'))
|
|
853
|
+
for match in LAB_URL.finditer(text):
|
|
854
|
+
if any(start <= match.start() <= end for start, end in defaults):
|
|
855
|
+
continue
|
|
856
|
+
found.append(finding('warning', f'{rel}:{line_of(text, match.start())}', f'hardcoded {match.group(0)}: '
|
|
857
|
+
'read URLs from the env the pod gets (a block\'s base_url/search_url); they differ '
|
|
858
|
+
'locally and in production', 'blocks'))
|
|
859
|
+
for match in OWN_AUTH.finditer(text):
|
|
860
|
+
lib = next(g for g in match.groups() if g)
|
|
861
|
+
found.append(finding('warning', f'{rel}:{line_of(text, match.start())}', f'{lib}: authentik signs users '
|
|
862
|
+
'in before a request reaches the app; read the X-authentik-* headers instead of '
|
|
863
|
+
'building login, sessions or password storage',
|
|
864
|
+
'homelab-apps AGENTS.md "Knowing who the user is"'))
|
|
865
|
+
for match in LOCAL_DB.finditer(text):
|
|
866
|
+
found.append(finding('warning', f'{rel}:{line_of(text, match.start())}', 'a database file: the pod\'s '
|
|
867
|
+
'root filesystem is read-only and replaced on every deploy; keep data in a postgres '
|
|
868
|
+
'block, or on a mounted volume (PVC) if it must be a file',
|
|
869
|
+
'homelab-apps AGENTS.md "Storage and resources"'))
|
|
870
|
+
return found
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def provided_secrets(name):
|
|
874
|
+
"""Secrets the app's own manifests make (ExternalSecrets from OpenBao, plain Secrets) in base/ and dev/."""
|
|
875
|
+
names = set()
|
|
876
|
+
for part in ('base', 'dev'):
|
|
877
|
+
for file in github_dir(APPS_REPO, f'apps/{name}/{part}'):
|
|
878
|
+
if not file.endswith(('.yaml', '.yml')):
|
|
879
|
+
continue
|
|
880
|
+
try:
|
|
881
|
+
docs = list(yaml.safe_load_all(github_file(APPS_REPO, f'apps/{name}/{part}/{file}') or ''))
|
|
882
|
+
except yaml.YAMLError:
|
|
883
|
+
continue
|
|
884
|
+
for doc in docs:
|
|
885
|
+
if isinstance(doc, dict) and doc.get('kind') == 'ExternalSecret':
|
|
886
|
+
names.add(((doc.get('spec') or {}).get('target') or {}).get('name')
|
|
887
|
+
or (doc.get('metadata') or {}).get('name'))
|
|
888
|
+
elif isinstance(doc, dict) and doc.get('kind') == 'Secret':
|
|
889
|
+
names.add((doc.get('metadata') or {}).get('name'))
|
|
890
|
+
return names - {None}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def try_load_app(name):
|
|
894
|
+
"""load_app (with the Secrets its manifests make), or None when homelab-apps has no deployment for it yet."""
|
|
895
|
+
if github_file(APPS_REPO, f'apps/{name}/app.yaml', required=False) is None:
|
|
896
|
+
return None
|
|
897
|
+
app = load_app(name)
|
|
898
|
+
app['provided'] = provided_secrets(name)
|
|
899
|
+
return app
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def cmd_check(args):
|
|
903
|
+
global APPS_DIR
|
|
904
|
+
opts = {'--code': '.', '--apps-dir': None}
|
|
905
|
+
names, manifests_only = [], False
|
|
906
|
+
rest = list(args)
|
|
907
|
+
while rest:
|
|
908
|
+
arg = rest.pop(0)
|
|
909
|
+
if arg in opts and rest:
|
|
910
|
+
opts[arg] = rest.pop(0)
|
|
911
|
+
elif arg == '--manifests-only':
|
|
912
|
+
manifests_only = True
|
|
913
|
+
elif arg.startswith('-'):
|
|
914
|
+
die(f'check: unknown option {arg}; usage: magehand check [NAME] [--code DIR] [--apps-dir DIR] '
|
|
915
|
+
'[--manifests-only]')
|
|
916
|
+
else:
|
|
917
|
+
names.append(arg)
|
|
918
|
+
if opts['--apps-dir']:
|
|
919
|
+
APPS_DIR = opts['--apps-dir']
|
|
920
|
+
name = names[0] if names else current_app()
|
|
921
|
+
catalog = yaml.safe_load(github_file(APPS_REPO, 'catalog.yaml', required=False) or '') or {}
|
|
922
|
+
app = try_load_app(name)
|
|
923
|
+
root = pathlib.Path(opts['--code'])
|
|
924
|
+
found = check_manifests(app, catalog) if app else []
|
|
925
|
+
if not manifests_only:
|
|
926
|
+
found += check_code(root, app)
|
|
927
|
+
print(f'magehand check {name}: code {"(skipped)" if manifests_only else root.resolve()}, deployment '
|
|
928
|
+
+ (f'homelab-apps apps/{name}' if app else 'none yet (code rules only)'))
|
|
929
|
+
for f in sorted(found, key=lambda f: (f['level'] != 'error', f['where'])):
|
|
930
|
+
see = f['see'] if f['see'].startswith('homelab-apps') else f"magehand guide {f['see']}"
|
|
931
|
+
print(f"{f['level']:7} {f['where']}: {f['text']}\n -> {see}")
|
|
932
|
+
errors = sum(f['level'] == 'error' for f in found)
|
|
933
|
+
print(f'{len(found)} finding(s), {errors} error(s)' if found else 'no findings')
|
|
934
|
+
sys.exit(1 if errors else 0)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# --- the skill: when coding agents should ask the platform ------------------------
|
|
938
|
+
|
|
939
|
+
SKILL_NAME = 'homelab-app'
|
|
940
|
+
SKILL = """---
|
|
941
|
+
name: homelab-app
|
|
942
|
+
description: Use when writing, changing, reviewing or debugging the code or deployment of a homelab app (a repo made from homelab-app-template, with ui-check.yaml and an AGENTS.md pointing to homelab-apps, or homelab-apps apps/<app>/), or when unsure how the homelab gives an app something (database, cache, AI models, web search, secrets, sign-in, env vars, local runs, deploys). Not for tasks that don't touch app code.
|
|
943
|
+
---
|
|
944
|
+
# Building a homelab app
|
|
945
|
+
|
|
946
|
+
The homelab decides how an app gets databases, AI, web search, secrets,
|
|
947
|
+
sign-in and deploys. Don't rely on habits from other platforms or guess:
|
|
948
|
+
ask the platform, with magehand (installed with `uv tool install magehand`).
|
|
949
|
+
|
|
950
|
+
1. Before designing or changing behavior: `magehand app` (in the app's repo).
|
|
951
|
+
It lists the app's blocks, every env var its pod gets and where each comes
|
|
952
|
+
from, and its URLs. Code reads exactly those variables.
|
|
953
|
+
2. Any "how do I ... on the homelab" question, before answering or coding:
|
|
954
|
+
`magehand guide search <words>` (e.g. `web search`, `database`,
|
|
955
|
+
`who is the user`, `env var`, `production`). Read the sections it prints
|
|
956
|
+
and follow them; `magehand guide` lists the topics.
|
|
957
|
+
3. Before every push: `magehand check`. Fix every error. Fix each warning, or
|
|
958
|
+
say in the PR why it's fine. Each finding names the guide section to read.
|
|
959
|
+
CI runs the same check.
|
|
960
|
+
4. Run it as the cluster does: `magehand dev up`, then
|
|
961
|
+
`magehand run -- <command>` (or `magehand run --container`).
|
|
962
|
+
5. If the guide doesn't answer, say so in the PR or issue (what you searched
|
|
963
|
+
for) instead of inventing a mechanism; the owner adds it to the guide.
|
|
964
|
+
|
|
965
|
+
Facts that override habits: no direct calls to AI or search providers (the
|
|
966
|
+
llm block's base_url and search_url); no own login or passwords (authentik
|
|
967
|
+
adds X-authentik-* headers); the root filesystem is read-only (state goes in
|
|
968
|
+
a block); a block key is never `optional: true`; merging code deploys dev by
|
|
969
|
+
itself (never pin images by hand).
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def skill_dirs():
|
|
974
|
+
"""Where coding agents on this machine read skills: Claude Code always, Codex if it is installed."""
|
|
975
|
+
dirs = [pathlib.Path(os.environ.get('CLAUDE_CONFIG_DIR') or HOME / '.claude') / 'skills']
|
|
976
|
+
codex = pathlib.Path(os.environ.get('CODEX_HOME') or HOME / '.codex')
|
|
977
|
+
if codex.is_dir():
|
|
978
|
+
dirs.append(codex / 'skills')
|
|
979
|
+
return dirs
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def install_skill():
|
|
983
|
+
"""Writes the skill (refreshed on every run, so an upgrade of magehand updates it); returns the paths."""
|
|
984
|
+
paths = []
|
|
985
|
+
for base in skill_dirs():
|
|
986
|
+
path = base / SKILL_NAME / 'SKILL.md'
|
|
987
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
988
|
+
path.write_text(SKILL)
|
|
989
|
+
paths.append(path)
|
|
990
|
+
return paths
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def cmd_skill(args):
|
|
994
|
+
if args[:1] == ['--install']:
|
|
995
|
+
for path in install_skill():
|
|
996
|
+
print(f'installed {path}')
|
|
997
|
+
return
|
|
998
|
+
print(SKILL)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
# --- setup: once per machine ---------------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
def lab_address(host):
|
|
1004
|
+
try:
|
|
1005
|
+
return socket.gethostbyname(host)
|
|
1006
|
+
except OSError:
|
|
1007
|
+
return None
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def dns_answer(server, host):
|
|
1011
|
+
"""What `server` answers for `host` (A record), or None. Uses dig (macOS)."""
|
|
1012
|
+
if not shutil.which('dig'):
|
|
1013
|
+
return None
|
|
1014
|
+
out = subprocess.run(['dig', '+short', '+time=3', '+tries=1', f'@{server}', host], # nosec B603 B607
|
|
1015
|
+
capture_output=True, text=True)
|
|
1016
|
+
lines = [line for line in out.stdout.split() if line and line[0].isdigit()]
|
|
1017
|
+
return lines[0] if out.returncode == 0 and lines else None
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def reachable():
|
|
1021
|
+
try:
|
|
1022
|
+
return urllib.request.urlopen(f'{OPENBAO}/v1/sys/health', timeout=5).status == 200 # nosec B310
|
|
1023
|
+
except (OSError, urllib.error.HTTPError):
|
|
1024
|
+
return False
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def sudo_write_resolver(server):
|
|
1028
|
+
"""/etc/resolver/<lab domain>: macOS asks `server` for *.lab names only."""
|
|
1029
|
+
print(f'Pointing *.{LAB} lookups (only those) at {server}; macOS asks for your password once.')
|
|
1030
|
+
subprocess.run(['sudo', 'mkdir', '-p', str(RESOLVER.parent)], check=True) # nosec B603 B607
|
|
1031
|
+
subprocess.run(['sudo', 'tee', str(RESOLVER)], input=f'nameserver {server}\n', text=True, # nosec B603 B607
|
|
1032
|
+
stdout=subprocess.DEVNULL, check=True)
|
|
1033
|
+
subprocess.run(['sudo', 'killall', '-HUP', 'mDNSResponder'], check=False) # nosec B603 B607
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def cmd_setup(args):
|
|
1037
|
+
host = urllib.parse.urlparse(OPENBAO).hostname
|
|
1038
|
+
if '--undo' in args:
|
|
1039
|
+
if RESOLVER.exists():
|
|
1040
|
+
subprocess.run(['sudo', 'rm', '-f', str(RESOLVER)], check=True) # nosec B603 B607
|
|
1041
|
+
print(f'removed {RESOLVER}')
|
|
1042
|
+
return
|
|
1043
|
+
print('1/5 GitHub')
|
|
1044
|
+
try:
|
|
1045
|
+
github_token()
|
|
1046
|
+
print(' ok')
|
|
1047
|
+
except SystemExit:
|
|
1048
|
+
die('sign in to GitHub first: `gh auth login`, then run `magehand setup` again')
|
|
1049
|
+
print('2/5 Docker (only for apps with database blocks)')
|
|
1050
|
+
print(' ok' if shutil.which('docker') else ' not found: install Docker Desktop or OrbStack when you need it')
|
|
1051
|
+
print(f'3/5 the homelab network (*.{LAB})')
|
|
1052
|
+
if reachable():
|
|
1053
|
+
print(f' ok: {host} -> {lab_address(host)}')
|
|
1054
|
+
elif sys.platform != 'darwin':
|
|
1055
|
+
die(f'{host} is not reachable and only macOS is set up automatically')
|
|
1056
|
+
else:
|
|
1057
|
+
current = lab_address(host)
|
|
1058
|
+
print(f' {host} resolves to {current or "nothing"} here, which is not the homelab.')
|
|
1059
|
+
server = args[args.index('--dns') + 1] if '--dns' in args else \
|
|
1060
|
+
input(' Homelab DNS address (your home gateway, e.g. 192.168.1.1): ').strip()
|
|
1061
|
+
answer = dns_answer(server, host)
|
|
1062
|
+
if not answer or answer.startswith('127.'):
|
|
1063
|
+
die(f'{server} did not answer for {host} with a homelab address ({answer or "no answer"}); '
|
|
1064
|
+
'is it the right address, and can this Mac reach it right now?')
|
|
1065
|
+
sudo_write_resolver(server)
|
|
1066
|
+
for _ in range(10):
|
|
1067
|
+
if reachable():
|
|
1068
|
+
break
|
|
1069
|
+
time.sleep(1)
|
|
1070
|
+
else:
|
|
1071
|
+
die(f'{host} now resolves via {server} but https still fails; is this Mac on a network that reaches it?')
|
|
1072
|
+
print(f' ok: {host} -> {lab_address(host)} (undo: magehand setup --undo)')
|
|
1073
|
+
print('4/5 sign in')
|
|
1074
|
+
token = saved_token()
|
|
1075
|
+
if token and bao('GET', 'auth/token/lookup-self', token)[0] == 200:
|
|
1076
|
+
print(' ok (already signed in)')
|
|
1077
|
+
else:
|
|
1078
|
+
cmd_login([])
|
|
1079
|
+
print('5/5 the homelab-app skill for coding agents (Claude Code, Codex)')
|
|
1080
|
+
for path in install_skill():
|
|
1081
|
+
print(f' ok: {path}')
|
|
1082
|
+
print('Done. Try `magehand app <name>` or, in an app repo, `magehand run -- <command>`.')
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def cmd_version(_args):
|
|
1086
|
+
try:
|
|
1087
|
+
print(package_version('magehand'))
|
|
1088
|
+
except PackageNotFoundError:
|
|
1089
|
+
print('unknown (not installed as a package)')
|
|
1090
|
+
print('upgrade: uv tool upgrade magehand')
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
COMMANDS = {'guide': cmd_guide, 'app': cmd_app, 'check': cmd_check, 'login': cmd_login, 'dev': cmd_dev,
|
|
1094
|
+
'run': cmd_run, 'doctor': cmd_doctor, 'setup': cmd_setup, 'skill': cmd_skill, 'version': cmd_version}
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def main():
|
|
1098
|
+
if len(sys.argv) > 1 and sys.argv[1] in ('--version', '-V'):
|
|
1099
|
+
sys.argv[1] = 'version'
|
|
1100
|
+
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
|
1101
|
+
print(__doc__.split('Private by design')[0].strip())
|
|
1102
|
+
sys.exit(0 if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help', 'help') else 2)
|
|
1103
|
+
COMMANDS[sys.argv[1]](sys.argv[2:])
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
if __name__ == '__main__':
|
|
1107
|
+
main()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: magehand
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: The developer CLI for building apps on davidlarrimore's homelab
|
|
5
|
+
Project-URL: Source, https://github.com/davidlarrimore/magehand
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: MacOS
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Requires-Dist: pyyaml>=6.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# magehand
|
|
17
|
+
|
|
18
|
+
The developer CLI for building apps on [davidlarrimore](https://github.com/davidlarrimore)'s
|
|
19
|
+
homelab, the same way on the owner's MacBook (Claude Code) and in OpenClaw's
|
|
20
|
+
VM (its coding workers). It reads platform state and runs apps locally. It
|
|
21
|
+
never deploys: changes go through pull requests.
|
|
22
|
+
|
|
23
|
+
It is public because it holds nothing sensitive. Every command needs access
|
|
24
|
+
that only the owner has: the private homelab repos on GitHub, the private
|
|
25
|
+
`*.lab` network, and an authentik passkey for
|
|
26
|
+
OpenBao.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
brew install uv # once
|
|
32
|
+
uv tool install magehand
|
|
33
|
+
magehand setup # once per machine
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`magehand setup` checks GitHub (`gh auth login` first) and Docker (Docker
|
|
37
|
+
Desktop or OrbStack, for database blocks), makes sure the homelab's `*.lab`
|
|
38
|
+
names resolve (if they don't, it asks for the homelab's DNS address, your home
|
|
39
|
+
gateway, and points only `*.lab` lookups at it; `magehand setup --undo`
|
|
40
|
+
reverses that), then signs you in and installs the `homelab-app` skill for Claude Code (and Codex). Every other command assumes this is done.
|
|
41
|
+
Upgrade with `uv tool upgrade magehand`.
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
| Command | What it does |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `magehand guide [topic ["section"]]` | The app platform guide, live from homelab-apps `docs/platform/`; `magehand guide catalog` for blocks and models |
|
|
48
|
+
| `magehand guide search WORDS` | The guide sections (homelab-apps `docs/platform/` and `AGENTS.md`) that best match a question, e.g. `magehand guide search web search` |
|
|
49
|
+
| `magehand app [name]` | The app's blocks, every env var its pod gets and where it comes from, its URLs. In an app repo the name is the repo's |
|
|
50
|
+
| `magehand check [name] [--code DIR] [--apps-dir DIR] [--manifests-only]` | The platform's rules for the app's code and its homelab-apps deployment: block keys and options, `optional: true` keys, Secrets that aren't blocks, the image, env the code reads but the pod doesn't set, direct AI/search provider calls, own login, database files. Each finding names the guide section; exit 1 on errors. Run before every push; CI (homelab-apps `build-app.yaml`) runs it too, with `--apps-dir` |
|
|
51
|
+
| `magehand login` | Sign in to OpenBao through authentik (passkey) with role `dev`: read-only access to agent apps' LiteLLM keys, 1h (8h max). The token is kept in the macOS Keychain |
|
|
52
|
+
| `magehand dev up\|down\|status` | Docker containers for the app's `postgres`/`redis` blocks (the blocks' own pinned images) on a per-app network, and local values for `secret` blocks |
|
|
53
|
+
| `magehand run [--no-proxy] -- CMD` | Runs CMD with the env the app's pod gets: values from its Deployment, the LLM key from OpenBao, databases from `dev up`. Precedence: manifest < the repo's `.magehand.env` (committed, non-secret overrides such as `WEB_DIR=./web`) < your shell < secrets. A proxy on `127.0.0.1:8080` adds the `X-authentik-*` headers Traefik would, as you |
|
|
54
|
+
| `magehand run --container [--no-build] [-- CMD]` | Builds the repo's Dockerfile and runs the image as its pod runs: read-only, `/tmp` tmpfs, the pod's user, no capabilities, exactly the pod's env. Secrets go in as `-e NAME` only, never in the image, argv or on disk |
|
|
55
|
+
| `magehand setup [--undo] [--dns ADDRESS]` | Once per machine: GitHub, Docker, `*.lab` name resolution (the only step that may ask for your password), sign-in |
|
|
56
|
+
| `magehand skill [--install]` | The `homelab-app` skill: tells Claude Code and Codex to use `app`, `guide search` and `check` while working on app code. `setup` installs it (`$CLAUDE_CONFIG_DIR` or `~/.claude`, and `$CODEX_HOME` or `~/.codex` if Codex is installed) |
|
|
57
|
+
| `magehand doctor` | Checks GitHub access, that `openbao.lab` is reachable, the sign-in and Docker |
|
|
58
|
+
| `magehand version` | The installed version |
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
uv venv && uv pip install -e .
|
|
64
|
+
.venv/bin/python -m unittest discover -s tests
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Python 3.9+ (the Mac's system Python works). Stdlib plus PyYAML. Releases:
|
|
68
|
+
bump `version` in `pyproject.toml`, merge, then tag `vX.Y.Z`. The release
|
|
69
|
+
workflow builds and publishes to PyPI through trusted publishing (no stored
|
|
70
|
+
token). OpenClaw's VM installs a pinned version from homelab
|
|
71
|
+
`platform/openclaw/vm/versions.env`.
|
|
72
|
+
|
|
73
|
+
Never commit secrets, tokens, IP addresses or email addresses: this repo is
|
|
74
|
+
public.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
magehand/__init__.py,sha256=3wkVZkTHDEnDYaUSKAAM5MD_1xc-1pNnMsvX3cQsiFw,68
|
|
2
|
+
magehand/__main__.py,sha256=l8JtBc57AqQxlAyQDlLABPUF3kWk8vcf1I3yfK6-XBE,38
|
|
3
|
+
magehand/cli.py,sha256=49SYgpGXUMRA78bE4_BD7_84tZJ71VroDZouOilKvBA,53864
|
|
4
|
+
magehand-0.2.0.dist-info/METADATA,sha256=MsD5O_mJUl_TnQfxL8XttYENL2cebMg1WNpvlvPg1QY,4747
|
|
5
|
+
magehand-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
magehand-0.2.0.dist-info/entry_points.txt,sha256=L60-tMFLrKQ1udzWoZs25GU_6uc0VwzqRxyUuu0CUD8,47
|
|
7
|
+
magehand-0.2.0.dist-info/licenses/LICENSE,sha256=5TMwVQu83KQhhv7Xw4PD37oxVkw-U3IyT78IXKrBb7U,1072
|
|
8
|
+
magehand-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Larrimore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|