magehand 0.1.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 +640 -0
- magehand-0.1.0.dist-info/METADATA +67 -0
- magehand-0.1.0.dist-info/RECORD +8 -0
- magehand-0.1.0.dist-info/WHEEL +4 -0
- magehand-0.1.0.dist-info/entry_points.txt +2 -0
- magehand-0.1.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,640 @@
|
|
|
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] the platform guide for apps (live from homelab-apps)
|
|
7
|
+
magehand app [name] this app's blocks, env vars and URLs
|
|
8
|
+
magehand login sign in to OpenBao through authentik (passkey)
|
|
9
|
+
magehand dev up|down|status local containers for the app's postgres/redis blocks
|
|
10
|
+
magehand run [--no-proxy] -- CMD...
|
|
11
|
+
run CMD with the env the app's pod gets; secrets
|
|
12
|
+
come from OpenBao and local containers, never disk
|
|
13
|
+
magehand run --container [--no-build] [-- CMD...]
|
|
14
|
+
build the repo's Dockerfile and run the image as
|
|
15
|
+
its pod runs (read-only, non-root, no capabilities)
|
|
16
|
+
magehand doctor check GitHub access, VPN/LAN reach, login, Docker
|
|
17
|
+
magehand version this version (upgrade: uv tool upgrade magehand)
|
|
18
|
+
|
|
19
|
+
Private by design: it talks only to GitHub and *.lab (on the LAN or over the
|
|
20
|
+
VPN), never to a public homelab endpoint. Its OpenBao role `dev` can only read
|
|
21
|
+
the LiteLLM keys of agent apps (homelab platform/openbao/policies/dev-read.hcl).
|
|
22
|
+
"""
|
|
23
|
+
import base64
|
|
24
|
+
import http.client
|
|
25
|
+
import http.server
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import pathlib
|
|
29
|
+
import secrets
|
|
30
|
+
import shutil
|
|
31
|
+
import signal
|
|
32
|
+
import socket
|
|
33
|
+
import subprocess # nosec B404 # runs docker, git, gh and the user's own command
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
import urllib.error
|
|
38
|
+
import urllib.parse
|
|
39
|
+
import urllib.request
|
|
40
|
+
import webbrowser
|
|
41
|
+
from importlib.metadata import PackageNotFoundError, version as package_version
|
|
42
|
+
|
|
43
|
+
import yaml
|
|
44
|
+
|
|
45
|
+
HOMELAB_REPO = 'davidlarrimore/homelab'
|
|
46
|
+
APPS_REPO = 'davidlarrimore/homelab-apps'
|
|
47
|
+
OPENBAO = os.environ.get('MAGEHAND_OPENBAO', 'https://openbao.lab.davidlarrimore.com')
|
|
48
|
+
LAB = 'lab.davidlarrimore.com'
|
|
49
|
+
HOME = pathlib.Path.home()
|
|
50
|
+
STATE = pathlib.Path(os.environ.get('XDG_STATE_HOME', HOME / '.local/state')) / 'magehand'
|
|
51
|
+
CACHE = pathlib.Path(os.environ.get('XDG_CACHE_HOME', HOME / '.cache')) / 'magehand'
|
|
52
|
+
KEYCHAIN_SERVICE = 'magehand'
|
|
53
|
+
CALLBACK = 'http://localhost:8250/oidc/callback'
|
|
54
|
+
PROXY_PORT = int(os.environ.get('MAGEHAND_PROXY_PORT', '8080'))
|
|
55
|
+
# Block type -> the Secret its chart hands over (platform/building-blocks/*).
|
|
56
|
+
SECRET_NAME = {'postgres': '{}-connection', 'redis': '{}-connection', 'llm': '{}-connection', 'secret': '{}'}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def die(msg):
|
|
60
|
+
sys.exit(f'magehand: {msg}')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- GitHub (read-only: the guide, app manifests, block images) ---------------
|
|
64
|
+
|
|
65
|
+
def github_token():
|
|
66
|
+
token = os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN')
|
|
67
|
+
if token:
|
|
68
|
+
return token
|
|
69
|
+
if shutil.which('gh'):
|
|
70
|
+
out = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True) # nosec B603 B607
|
|
71
|
+
if out.returncode == 0 and out.stdout.strip():
|
|
72
|
+
return out.stdout.strip()
|
|
73
|
+
die('no GitHub access: run `gh auth login` (or set GITHUB_TOKEN)')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def github_file(repo, path, required=True):
|
|
77
|
+
"""A file on main, cached for offline use; None if it doesn't exist."""
|
|
78
|
+
cached = CACHE / repo / path
|
|
79
|
+
req = urllib.request.Request(
|
|
80
|
+
f'https://api.github.com/repos/{repo}/contents/{urllib.parse.quote(path)}?ref=main',
|
|
81
|
+
headers={'Authorization': f'Bearer {github_token()}', 'Accept': 'application/vnd.github.raw',
|
|
82
|
+
'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'magehand'})
|
|
83
|
+
try:
|
|
84
|
+
with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # fixed https URL
|
|
85
|
+
text = resp.read().decode()
|
|
86
|
+
except urllib.error.HTTPError as error:
|
|
87
|
+
if error.code == 404:
|
|
88
|
+
if required:
|
|
89
|
+
die(f'{repo}: {path} not found on main')
|
|
90
|
+
return None
|
|
91
|
+
text = None
|
|
92
|
+
except OSError:
|
|
93
|
+
text = None
|
|
94
|
+
if text is None:
|
|
95
|
+
if cached.is_file():
|
|
96
|
+
print(f'magehand: GitHub unreachable, using the cached {path}', file=sys.stderr)
|
|
97
|
+
return cached.read_text()
|
|
98
|
+
die(f'cannot read {repo}/{path} from GitHub and nothing is cached')
|
|
99
|
+
cached.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
cached.write_text(text)
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def github_dir(repo, path):
|
|
105
|
+
"""Names in a directory on main ([] if it doesn't exist)."""
|
|
106
|
+
req = urllib.request.Request(
|
|
107
|
+
f'https://api.github.com/repos/{repo}/contents/{urllib.parse.quote(path)}?ref=main',
|
|
108
|
+
headers={'Authorization': f'Bearer {github_token()}', 'Accept': 'application/vnd.github+json',
|
|
109
|
+
'User-Agent': 'magehand'})
|
|
110
|
+
try:
|
|
111
|
+
with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # fixed https URL
|
|
112
|
+
return sorted(item['name'] for item in json.load(resp) if isinstance(item, dict))
|
|
113
|
+
except (urllib.error.HTTPError, OSError, ValueError):
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- The app ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def current_app():
|
|
120
|
+
"""The app this checkout is: an app repo is named after its app."""
|
|
121
|
+
out = subprocess.run(['git', 'remote', 'get-url', 'origin'], capture_output=True, text=True) # nosec B603 B607
|
|
122
|
+
if out.returncode != 0:
|
|
123
|
+
die('not in a git checkout; pass the app name')
|
|
124
|
+
name = out.stdout.strip().rstrip('/').rsplit('/', 1)[-1].rsplit(':', 1)[-1]
|
|
125
|
+
name = name[:-4] if name.endswith('.git') else name
|
|
126
|
+
if name in ('homelab', 'homelab-apps'):
|
|
127
|
+
die('this is a platform repo; pass the app name')
|
|
128
|
+
return name
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_app(name):
|
|
132
|
+
spec = yaml.safe_load(github_file(APPS_REPO, f'apps/{name}/app.yaml')) or {}
|
|
133
|
+
services = spec.get('services') or {}
|
|
134
|
+
docs = list(yaml.safe_load_all(github_file(APPS_REPO, f'apps/{name}/base/deployment.yaml')))
|
|
135
|
+
deployment = next((d for d in docs if isinstance(d, dict) and d.get('kind') == 'Deployment'), None)
|
|
136
|
+
if not deployment:
|
|
137
|
+
die(f'apps/{name}/base/deployment.yaml has no Deployment')
|
|
138
|
+
pod = deployment['spec']['template']['spec']
|
|
139
|
+
return {'name': name, 'spec': spec, 'services': services, 'container': pod['containers'][0], 'pod': pod}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def block_of(app, secret_name):
|
|
143
|
+
"""(block name, type) whose Secret this is, or (None, None)."""
|
|
144
|
+
for name, svc in app['services'].items():
|
|
145
|
+
kind = (svc or {}).get('type')
|
|
146
|
+
if kind in SECRET_NAME and SECRET_NAME[kind].format(name) == secret_name:
|
|
147
|
+
return name, kind
|
|
148
|
+
return None, None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def env_plan(app):
|
|
152
|
+
"""[(VAR, 'value', text) | (VAR, block, type, key)] from the pod spec."""
|
|
153
|
+
plan = []
|
|
154
|
+
for item in app['container'].get('env') or []:
|
|
155
|
+
if 'value' in item:
|
|
156
|
+
plan.append((item['name'], 'value', str(item['value'])))
|
|
157
|
+
continue
|
|
158
|
+
ref = ((item.get('valueFrom') or {}).get('secretKeyRef')) or {}
|
|
159
|
+
block, kind = block_of(app, ref.get('name'))
|
|
160
|
+
plan.append((item['name'], block, kind, ref.get('key')) if block
|
|
161
|
+
else (item['name'], 'unknown', ref.get('name') or '?'))
|
|
162
|
+
return plan
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def app_port(app):
|
|
166
|
+
for item in app['container'].get('env') or []:
|
|
167
|
+
if item.get('name') == 'PORT' and 'value' in item:
|
|
168
|
+
return int(item['value'])
|
|
169
|
+
ports = app['container'].get('ports') or []
|
|
170
|
+
return int(ports[0]['containerPort']) if ports else 8000
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- OpenBao (role dev: read-only LiteLLM keys) ---------------------------------
|
|
174
|
+
|
|
175
|
+
def bao(method, path, token=None, body=None):
|
|
176
|
+
headers = {'Content-Type': 'application/json'}
|
|
177
|
+
if token:
|
|
178
|
+
headers['X-Vault-Token'] = token
|
|
179
|
+
req = urllib.request.Request(f'{OPENBAO}/v1/{path}', method=method, headers=headers,
|
|
180
|
+
data=json.dumps(body).encode() if body is not None else None)
|
|
181
|
+
try:
|
|
182
|
+
with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # OpenBao URL (https, *.lab)
|
|
183
|
+
raw = resp.read()
|
|
184
|
+
return resp.status, (json.loads(raw) if raw.strip() else {})
|
|
185
|
+
except urllib.error.HTTPError as error:
|
|
186
|
+
return error.code, None
|
|
187
|
+
except OSError:
|
|
188
|
+
die(f'cannot reach {OPENBAO}: on the home network or the VPN? (`magehand doctor`)')
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def store_token(token, identity):
|
|
192
|
+
if sys.platform == 'darwin':
|
|
193
|
+
subprocess.run(['security', 'add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, # nosec B603 B607
|
|
194
|
+
'-a', 'openbao-token', '-w', token], check=True, capture_output=True)
|
|
195
|
+
else:
|
|
196
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
path = STATE / 'openbao-token'
|
|
198
|
+
path.touch(mode=0o600)
|
|
199
|
+
path.write_text(token)
|
|
200
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
(STATE / 'identity.json').write_text(json.dumps(identity))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def saved_token():
|
|
205
|
+
if os.environ.get('MAGEHAND_BAO_TOKEN'):
|
|
206
|
+
return os.environ['MAGEHAND_BAO_TOKEN']
|
|
207
|
+
if sys.platform == 'darwin':
|
|
208
|
+
out = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, # nosec B603 B607
|
|
209
|
+
'-a', 'openbao-token', '-w'], capture_output=True, text=True)
|
|
210
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
211
|
+
path = STATE / 'openbao-token'
|
|
212
|
+
return path.read_text().strip() if path.is_file() else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def valid_token():
|
|
216
|
+
token = saved_token()
|
|
217
|
+
if token and bao('GET', 'auth/token/lookup-self', token)[0] == 200:
|
|
218
|
+
return token
|
|
219
|
+
die('not signed in, or the token expired: run `magehand login`')
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def identity():
|
|
223
|
+
path = STATE / 'identity.json'
|
|
224
|
+
return json.loads(path.read_text()) if path.is_file() else {}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_login(_args):
|
|
228
|
+
status, body = bao('POST', 'auth/oidc/oidc/auth_url', body={'role': 'dev', 'redirect_uri': CALLBACK})
|
|
229
|
+
url = ((body or {}).get('data') or {}).get('auth_url')
|
|
230
|
+
if status != 200 or not url:
|
|
231
|
+
die(f'OpenBao gave no login URL (HTTP {status}); is the `dev` OIDC role set up? (scripts/setup-openbao.py)')
|
|
232
|
+
params = {}
|
|
233
|
+
|
|
234
|
+
class Callback(http.server.BaseHTTPRequestHandler):
|
|
235
|
+
def do_GET(self):
|
|
236
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
237
|
+
if parsed.path != '/oidc/callback':
|
|
238
|
+
self.send_response(404)
|
|
239
|
+
self.end_headers()
|
|
240
|
+
return
|
|
241
|
+
params.update(urllib.parse.parse_qsl(parsed.query))
|
|
242
|
+
self.send_response(200)
|
|
243
|
+
self.send_header('Content-Type', 'text/plain')
|
|
244
|
+
self.end_headers()
|
|
245
|
+
self.wfile.write(b'magehand: signed in. Return to the terminal.')
|
|
246
|
+
|
|
247
|
+
def log_message(self, *args):
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
with http.server.HTTPServer(('localhost', 8250), Callback) as server:
|
|
251
|
+
server.timeout = 5
|
|
252
|
+
print('Sign in through authentik in your browser (passkey)...')
|
|
253
|
+
webbrowser.open(url)
|
|
254
|
+
deadline = time.monotonic() + 300
|
|
255
|
+
while 'code' not in params and time.monotonic() < deadline:
|
|
256
|
+
server.handle_request()
|
|
257
|
+
if 'code' not in params:
|
|
258
|
+
die('sign-in timed out or was refused')
|
|
259
|
+
query = urllib.parse.urlencode({k: params[k] for k in ('state', 'code') if k in params})
|
|
260
|
+
status, body = bao('GET', f'auth/oidc/oidc/callback?{query}')
|
|
261
|
+
if status != 200:
|
|
262
|
+
die(f'OpenBao refused the sign-in (HTTP {status})')
|
|
263
|
+
auth = body['auth']
|
|
264
|
+
meta = auth.get('metadata') or {}
|
|
265
|
+
email = meta.get('email') or auth.get('display_name', '').removeprefix('oidc-')
|
|
266
|
+
who = {'email': email, 'uid': auth.get('entity_id', ''), 'username': email.split('@')[0],
|
|
267
|
+
'name': meta.get('name') or email.split('@')[0], 'groups': 'homelab-admins'}
|
|
268
|
+
store_token(auth['client_token'], who)
|
|
269
|
+
hours = auth.get('lease_duration', 3600) // 3600
|
|
270
|
+
print(f'Signed in as {email or "?"} (policy {", ".join(auth.get("policies", []))}, {hours}h).')
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --- guide and app --------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def cmd_guide(args):
|
|
276
|
+
topics = [n[:-3] for n in github_dir(APPS_REPO, 'docs/platform') if n.endswith('.md')]
|
|
277
|
+
if not topics: # before docs/platform exists
|
|
278
|
+
print(github_file(APPS_REPO, 'AGENTS.md'))
|
|
279
|
+
return
|
|
280
|
+
topic = args[0] if args else 'README'
|
|
281
|
+
if topic == 'catalog':
|
|
282
|
+
print(github_file(APPS_REPO, 'catalog.yaml'))
|
|
283
|
+
return
|
|
284
|
+
if topic not in topics:
|
|
285
|
+
die(f'no topic {topic!r}; topics: {", ".join(topics)}, catalog')
|
|
286
|
+
print(github_file(APPS_REPO, f'docs/platform/{topic}.md'))
|
|
287
|
+
if not args:
|
|
288
|
+
print(f'\nMore: magehand guide <topic> ({", ".join(t for t in topics if t != "README")}, catalog)')
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def cmd_app(args):
|
|
292
|
+
app = load_app(args[0] if args else current_app())
|
|
293
|
+
name = app['name']
|
|
294
|
+
source = (app['spec'].get('source') or {}).get('repo')
|
|
295
|
+
print(f'{name} (code: {"davidlarrimore/" + source if source else "homelab-apps apps/" + name + "/image"};'
|
|
296
|
+
f' deployment: homelab-apps apps/{name})')
|
|
297
|
+
print(f' dev: https://{name}-dev.{LAB}')
|
|
298
|
+
for preview in github_dir(APPS_REPO, f'previews/{name}'):
|
|
299
|
+
if preview.endswith('.yaml'):
|
|
300
|
+
print(f' preview: https://{name}-dev-{preview[:-5]}.{LAB}')
|
|
301
|
+
print('Blocks (app.yaml services:):')
|
|
302
|
+
if not app['services']:
|
|
303
|
+
print(' none; request one with a PR to homelab-apps apps/%s/app.yaml (`magehand guide blocks`)' % name)
|
|
304
|
+
for block, svc in app['services'].items():
|
|
305
|
+
opts = ', '.join(f'{k}={v}' for k, v in (svc or {}).items() if k != 'type')
|
|
306
|
+
print(f' {block}: {(svc or {}).get("type")}{" (" + opts + ")" if opts else ""}')
|
|
307
|
+
print(f'Env (the pod spec; locally: magehand run, app port {app_port(app)}):')
|
|
308
|
+
for entry in env_plan(app):
|
|
309
|
+
if entry[1] == 'value':
|
|
310
|
+
print(f' {entry[0]} = {entry[2]}')
|
|
311
|
+
elif entry[1] == 'unknown':
|
|
312
|
+
print(f' {entry[0]} <- Secret {entry[2]} (not a block; magehand run leaves it unset)')
|
|
313
|
+
else:
|
|
314
|
+
print(f' {entry[0]} <- block {entry[1]} ({entry[2]}) key {entry[3]}')
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# --- local containers -------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def docker(*args, check=True):
|
|
320
|
+
if not shutil.which('docker'):
|
|
321
|
+
die('docker is required for local blocks (Docker Desktop, OrbStack or colima)')
|
|
322
|
+
return subprocess.run(['docker', *args], capture_output=True, text=True, check=check) # nosec B603 B607
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def local_state(app_name):
|
|
326
|
+
path = STATE / f'{app_name}.json'
|
|
327
|
+
return json.loads(path.read_text()) if path.is_file() else {}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def save_local_state(app_name, data):
|
|
331
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
path = STATE / f'{app_name}.json'
|
|
333
|
+
path.touch(mode=0o600)
|
|
334
|
+
path.write_text(json.dumps(data, indent=1))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def block_image(kind, svc):
|
|
338
|
+
values = yaml.safe_load(github_file(HOMELAB_REPO, f'platform/building-blocks/{kind}/values.yaml'))
|
|
339
|
+
if kind == 'postgres' and (svc or {}).get('vector') in (True, 'true'):
|
|
340
|
+
return values['pgvector']['image']
|
|
341
|
+
return values['image']
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cmd_dev(args):
|
|
345
|
+
action = args[0] if args else 'status'
|
|
346
|
+
app = load_app(args[1] if len(args) > 1 else current_app())
|
|
347
|
+
state = local_state(app['name'])
|
|
348
|
+
network = f'magehand-{app["name"]}'
|
|
349
|
+
if action == 'up': # blocks and `run --container` share it, like a namespace
|
|
350
|
+
docker('network', 'create', network, check=False)
|
|
351
|
+
for block, svc in app['services'].items():
|
|
352
|
+
kind = (svc or {}).get('type')
|
|
353
|
+
container = f'magehand-{app["name"]}-{block}'
|
|
354
|
+
if kind == 'secret' and action == 'up':
|
|
355
|
+
state.setdefault(block, {'value': secrets.token_urlsafe(36)[:48]})
|
|
356
|
+
if kind not in ('postgres', 'redis'):
|
|
357
|
+
continue
|
|
358
|
+
if action == 'down':
|
|
359
|
+
docker('rm', '-f', '-v', container, check=False)
|
|
360
|
+
state.pop(block, None)
|
|
361
|
+
print(f'{block}: removed')
|
|
362
|
+
continue
|
|
363
|
+
running = docker('inspect', '-f', '{{.State.Running}}', container, check=False).stdout.strip() == 'true'
|
|
364
|
+
if action == 'status':
|
|
365
|
+
print(f'{block} ({kind}): {"running" if running else "not running"}')
|
|
366
|
+
continue
|
|
367
|
+
if not running:
|
|
368
|
+
password = state.get(block, {}).get('password') or secrets.token_urlsafe(24)
|
|
369
|
+
docker('rm', '-f', container, check=False)
|
|
370
|
+
if kind == 'postgres':
|
|
371
|
+
docker('run', '-d', '--name', container, '--network', network, '-p', '127.0.0.1::5432',
|
|
372
|
+
'-e', 'POSTGRES_USER=app',
|
|
373
|
+
'-e', 'POSTGRES_DB=app', '-e', f'POSTGRES_PASSWORD={password}', block_image(kind, svc))
|
|
374
|
+
else:
|
|
375
|
+
docker('run', '-d', '--name', container, '--network', network, '-p', '127.0.0.1::6379',
|
|
376
|
+
block_image(kind, svc),
|
|
377
|
+
'valkey-server', '--requirepass', password, '--maxmemory', '200mb',
|
|
378
|
+
'--maxmemory-policy', 'allkeys-lru', '--appendonly', 'yes')
|
|
379
|
+
state[block] = {'password': password}
|
|
380
|
+
inner = '5432' if kind == 'postgres' else '6379'
|
|
381
|
+
port = docker('port', container, inner).stdout.strip().splitlines()[0].rsplit(':', 1)[-1]
|
|
382
|
+
state[block]['port'] = port
|
|
383
|
+
print(f'{block} ({kind}): 127.0.0.1:{port}')
|
|
384
|
+
if action == 'up':
|
|
385
|
+
save_local_state(app['name'], state)
|
|
386
|
+
elif action == 'down':
|
|
387
|
+
(STATE / f'{app["name"]}.json').unlink(missing_ok=True)
|
|
388
|
+
docker('network', 'rm', network, check=False)
|
|
389
|
+
elif action != 'status':
|
|
390
|
+
die('usage: magehand dev up|down|status [app]')
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def local_connection(kind, block, local, app_name=None):
|
|
394
|
+
"""A block's Secret keys for a local run: on the host (published port), or
|
|
395
|
+
with app_name, from a container on the app's Docker network."""
|
|
396
|
+
if block not in local:
|
|
397
|
+
die(f'block {block} ({kind}) has no local container: run `magehand dev up`')
|
|
398
|
+
conn = local[block]
|
|
399
|
+
if kind == 'secret':
|
|
400
|
+
return {'value': conn['value']}
|
|
401
|
+
pw = conn['password']
|
|
402
|
+
inner = '5432' if kind == 'postgres' else '6379'
|
|
403
|
+
host, port = (f'magehand-{app_name}-{block}', inner) if app_name else ('127.0.0.1', conn['port'])
|
|
404
|
+
if kind == 'postgres':
|
|
405
|
+
return {'host': host, 'port': port, 'username': 'app', 'database': 'app', 'password': pw,
|
|
406
|
+
'uri': f'postgresql://app:{pw}@{host}:{port}/app'}
|
|
407
|
+
return {'host': host, 'port': port, 'password': pw, 'uri': f'redis://:{pw}@{host}:{port}/0'}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# --- run: the secrets adapter ---------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
def dotenv(path):
|
|
413
|
+
"""KEY=VALUE lines from the repo's committed .magehand.env (local, non-secret overrides)."""
|
|
414
|
+
values = {}
|
|
415
|
+
if path.is_file():
|
|
416
|
+
for line in path.read_text().splitlines():
|
|
417
|
+
line = line.strip()
|
|
418
|
+
if line and not line.startswith('#') and '=' in line:
|
|
419
|
+
key, value = line.split('=', 1)
|
|
420
|
+
values[key.strip()] = os.path.expandvars(value.strip().strip('"\''))
|
|
421
|
+
return values
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def resolve_env(app, container=False):
|
|
425
|
+
"""The pod's env, resolved for this machine. On the host, precedence is:
|
|
426
|
+
manifest values < .magehand.env < the caller's environment < secrets
|
|
427
|
+
(OpenBao, local blocks). In a container it is exactly the pod's: manifest
|
|
428
|
+
values and secrets, with blocks reached over the app's Docker network."""
|
|
429
|
+
plan = env_plan(app)
|
|
430
|
+
env = {e[0]: e[2] for e in plan if e[1] == 'value'}
|
|
431
|
+
if not container:
|
|
432
|
+
env.update(dotenv(pathlib.Path('.magehand.env')))
|
|
433
|
+
env.update({k: v for k, v in os.environ.items() if k in env})
|
|
434
|
+
local, fetched, token = local_state(app['name']), {}, None
|
|
435
|
+
for entry in plan:
|
|
436
|
+
if entry[1] in ('value', 'unknown'):
|
|
437
|
+
continue
|
|
438
|
+
var, block, kind, key = entry
|
|
439
|
+
if block not in fetched:
|
|
440
|
+
if kind == 'llm':
|
|
441
|
+
token = token or valid_token()
|
|
442
|
+
path = f'apps/data/{app["name"]}-dev/llm-{block}'
|
|
443
|
+
status, body = bao('GET', path, token)
|
|
444
|
+
if status != 200:
|
|
445
|
+
die(f'cannot read {path} (HTTP {status}); has the broker created the key? (`magehand app`)')
|
|
446
|
+
fetched[block] = body['data']['data']
|
|
447
|
+
else:
|
|
448
|
+
fetched[block] = local_connection(kind, block, local, app['name'] if container else None)
|
|
449
|
+
if key not in fetched[block]:
|
|
450
|
+
die(f'{var}: block {block} has no key {key!r} (keys: {", ".join(sorted(fetched[block]))})')
|
|
451
|
+
env[var] = str(fetched[block][key])
|
|
452
|
+
return env
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def start_proxy(app_port_, who):
|
|
456
|
+
"""127.0.0.1:PROXY_PORT -> the app, adding the X-authentik-* headers Traefik would."""
|
|
457
|
+
headers = {'X-authentik-uid': who.get('uid', 'local'), 'X-authentik-email': who.get('email', ''),
|
|
458
|
+
'X-authentik-name': who.get('name', 'Local developer'),
|
|
459
|
+
'X-authentik-username': who.get('username', 'local'), 'X-authentik-groups': who.get('groups', '')}
|
|
460
|
+
|
|
461
|
+
class Proxy(http.server.BaseHTTPRequestHandler):
|
|
462
|
+
protocol_version = 'HTTP/1.1'
|
|
463
|
+
|
|
464
|
+
def forward(self):
|
|
465
|
+
body = self.rfile.read(int(self.headers.get('Content-Length') or 0))
|
|
466
|
+
out = {k: v for k, v in self.headers.items()
|
|
467
|
+
if not k.lower().startswith('x-authentik-') and k.lower() not in ('connection', 'host')}
|
|
468
|
+
out.update(headers)
|
|
469
|
+
out['Host'] = self.headers.get('Host', f'127.0.0.1:{PROXY_PORT}')
|
|
470
|
+
conn = http.client.HTTPConnection('127.0.0.1', app_port_, timeout=300)
|
|
471
|
+
try:
|
|
472
|
+
conn.request(self.command, self.path, body=body or None, headers=out)
|
|
473
|
+
resp = conn.getresponse()
|
|
474
|
+
data = resp.read()
|
|
475
|
+
except OSError:
|
|
476
|
+
self.send_error(502, 'app not reachable yet')
|
|
477
|
+
return
|
|
478
|
+
finally:
|
|
479
|
+
conn.close()
|
|
480
|
+
self.send_response(resp.status, resp.reason)
|
|
481
|
+
for k, v in resp.getheaders():
|
|
482
|
+
if k.lower() not in ('transfer-encoding', 'connection', 'content-length'):
|
|
483
|
+
self.send_header(k, v)
|
|
484
|
+
self.send_header('Content-Length', str(len(data)))
|
|
485
|
+
self.end_headers()
|
|
486
|
+
self.wfile.write(data)
|
|
487
|
+
|
|
488
|
+
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = forward
|
|
489
|
+
|
|
490
|
+
def log_message(self, *args):
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
server = http.server.ThreadingHTTPServer(('127.0.0.1', PROXY_PORT), Proxy)
|
|
494
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
495
|
+
return server
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
LAB_SUFFIX = '.' + LAB
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def lab_hosts(env):
|
|
502
|
+
"""--add-host entries for the *.lab names in env values. Inside a container,
|
|
503
|
+
public DNS's 127.0.0.1 would be the container: use what the host resolves
|
|
504
|
+
(the Studio's LAN address on the MacBook) or, on the Studio, the host itself."""
|
|
505
|
+
hosts = set()
|
|
506
|
+
for value in env.values():
|
|
507
|
+
host = urllib.parse.urlparse(value).hostname if '://' in value else None
|
|
508
|
+
if host and host.endswith(LAB_SUFFIX):
|
|
509
|
+
hosts.add(host)
|
|
510
|
+
out = []
|
|
511
|
+
for host in sorted(hosts):
|
|
512
|
+
try:
|
|
513
|
+
address = socket.gethostbyname(host)
|
|
514
|
+
except OSError:
|
|
515
|
+
die(f'cannot resolve {host}: on the home network or Teleport? (`magehand doctor`)')
|
|
516
|
+
out.append(f'{host}:{"host-gateway" if address.startswith("127.") else address}')
|
|
517
|
+
return out
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def container_args(app, env, image, port, hosts, extra):
|
|
521
|
+
"""docker run arguments for the app as its pod runs: read-only, non-root,
|
|
522
|
+
no capabilities. Secrets are passed by name only (-e NAME): docker reads
|
|
523
|
+
the values from its own environment, so they are never in argv or on disk."""
|
|
524
|
+
pod_sc = app['pod'].get('securityContext') or {}
|
|
525
|
+
user = f'{pod_sc.get("runAsUser", 65532)}:{pod_sc.get("runAsGroup", 65532)}'
|
|
526
|
+
args = ['run', '--rm', '--name', f'magehand-{app["name"]}-app', '--network', f'magehand-{app["name"]}',
|
|
527
|
+
'-p', f'127.0.0.1:{port}:{port}', '--read-only', '--tmpfs', '/tmp', '--user', user, # nosec B108 # the container's own tmpfs
|
|
528
|
+
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges']
|
|
529
|
+
if sys.stdin.isatty():
|
|
530
|
+
args.append('-it')
|
|
531
|
+
for host in hosts:
|
|
532
|
+
args += ['--add-host', host]
|
|
533
|
+
for name in sorted(env):
|
|
534
|
+
args += ['-e', name]
|
|
535
|
+
command = extra or (app['container'].get('command') or []) + (app['container'].get('args') or [])
|
|
536
|
+
if command and not extra:
|
|
537
|
+
args += ['--entrypoint', command[0], image, *command[1:]]
|
|
538
|
+
else:
|
|
539
|
+
args += [image, *command]
|
|
540
|
+
return args
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def cmd_run(args):
|
|
544
|
+
proxy = '--no-proxy' not in args
|
|
545
|
+
container = '--container' in args
|
|
546
|
+
build = '--no-build' not in args
|
|
547
|
+
args = [a for a in args if a not in ('--no-proxy', '--container', '--no-build')]
|
|
548
|
+
name = None
|
|
549
|
+
if args and args[0] == '--app':
|
|
550
|
+
name, args = args[1], args[2:]
|
|
551
|
+
if args and args[0] == '--':
|
|
552
|
+
args = args[1:]
|
|
553
|
+
if not args and not container:
|
|
554
|
+
die('usage: magehand run [--no-proxy] [--app NAME] -- CMD... or magehand run --container [--no-build]')
|
|
555
|
+
app = load_app(name or current_app())
|
|
556
|
+
env = resolve_env(app, container=container)
|
|
557
|
+
print(f'magehand: {app["name"]} env: {", ".join(sorted(env))}', file=sys.stderr)
|
|
558
|
+
if proxy:
|
|
559
|
+
port = int(env.get('PORT') or app_port(app))
|
|
560
|
+
start_proxy(port, identity())
|
|
561
|
+
print(f'magehand: open http://127.0.0.1:{PROXY_PORT} (signed in as '
|
|
562
|
+
f'{identity().get("email") or "local"}; the app itself listens on {port})', file=sys.stderr)
|
|
563
|
+
if container:
|
|
564
|
+
image = f'magehand/{app["name"]}:local'
|
|
565
|
+
if build:
|
|
566
|
+
print(f'magehand: docker build -t {image} .', file=sys.stderr)
|
|
567
|
+
if subprocess.run(['docker', 'build', '-t', image, '.']).returncode: # nosec B603 B607
|
|
568
|
+
die('docker build failed')
|
|
569
|
+
docker('network', 'create', f'magehand-{app["name"]}', check=False)
|
|
570
|
+
docker('rm', '-f', f'magehand-{app["name"]}-app', check=False)
|
|
571
|
+
port = int(env.get('PORT') or app_port(app))
|
|
572
|
+
args = ['docker', *container_args(app, env, image, port, lab_hosts(env), args)]
|
|
573
|
+
child = subprocess.Popen(args, env={**os.environ, **env}) # nosec B603 # the user's own command
|
|
574
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
575
|
+
signal.signal(sig, lambda s, _f: child.send_signal(s))
|
|
576
|
+
sys.exit(child.wait())
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# --- doctor and update ---------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
def cmd_doctor(_args):
|
|
582
|
+
ok = True
|
|
583
|
+
|
|
584
|
+
def check(label, passed, hint=''):
|
|
585
|
+
nonlocal ok
|
|
586
|
+
ok = ok and passed
|
|
587
|
+
print(f'{"ok " if passed else "FAIL"} {label}{"" if passed else " -> " + hint}')
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
github_token()
|
|
591
|
+
check('GitHub access', github_file(APPS_REPO, 'catalog.yaml', required=False) is not None)
|
|
592
|
+
except SystemExit:
|
|
593
|
+
check('GitHub access', False, '`gh auth login`')
|
|
594
|
+
try:
|
|
595
|
+
reach = urllib.request.urlopen(f'{OPENBAO}/v1/sys/health', timeout=5).status == 200 # nosec B310
|
|
596
|
+
except (OSError, urllib.error.HTTPError):
|
|
597
|
+
reach = False
|
|
598
|
+
host = urllib.parse.urlparse(OPENBAO).hostname
|
|
599
|
+
try:
|
|
600
|
+
address = socket.gethostbyname(host)
|
|
601
|
+
except OSError:
|
|
602
|
+
address = None
|
|
603
|
+
if not reach and address == '127.0.0.1':
|
|
604
|
+
# Public DNS answers 127.0.0.1 for *.lab; only the home gateway (or the
|
|
605
|
+
# Studio itself) knows better. On Teleport the Mac keeps public DNS.
|
|
606
|
+
check(f'resolve {host}', False, 'public DNS answered 127.0.0.1: send *.lab lookups to the gateway once, '
|
|
607
|
+
'`echo "nameserver <gateway LAN IP>" | sudo tee /etc/resolver/lab.davidlarrimore.com` '
|
|
608
|
+
'(magehand guide local-dev)')
|
|
609
|
+
else:
|
|
610
|
+
check(f'reach {OPENBAO}', reach, f'resolved to {address or "nothing"}: on the home network or Teleport?')
|
|
611
|
+
token = saved_token()
|
|
612
|
+
check('signed in to OpenBao', bool(reach and token and bao('GET', 'auth/token/lookup-self', token)[0] == 200),
|
|
613
|
+
'`magehand login`')
|
|
614
|
+
check('docker (for postgres/redis blocks)', bool(shutil.which('docker')), 'install Docker Desktop or OrbStack')
|
|
615
|
+
sys.exit(0 if ok else 1)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def cmd_version(_args):
|
|
619
|
+
try:
|
|
620
|
+
print(package_version('magehand'))
|
|
621
|
+
except PackageNotFoundError:
|
|
622
|
+
print('unknown (not installed as a package)')
|
|
623
|
+
print('upgrade: uv tool upgrade magehand')
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
COMMANDS = {'guide': cmd_guide, 'app': cmd_app, 'login': cmd_login, 'dev': cmd_dev, 'run': cmd_run,
|
|
627
|
+
'doctor': cmd_doctor, 'version': cmd_version}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def main():
|
|
631
|
+
if len(sys.argv) > 1 and sys.argv[1] in ('--version', '-V'):
|
|
632
|
+
sys.argv[1] = 'version'
|
|
633
|
+
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
|
634
|
+
print(__doc__.split('Private by design')[0].strip())
|
|
635
|
+
sys.exit(0 if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help', 'help') else 2)
|
|
636
|
+
COMMANDS[sys.argv[1]](sys.argv[2:])
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
if __name__ == '__main__':
|
|
640
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: magehand
|
|
3
|
+
Version: 0.1.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 (home LAN or UniFi Teleport), 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 doctor
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Upgrade with `uv tool upgrade magehand`. You also need `gh auth login`, and
|
|
37
|
+
Docker Desktop or OrbStack for database blocks. Away from home, `*.lab` needs
|
|
38
|
+
Teleport and a one-time resolver file (`magehand doctor` prints it).
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
| Command | What it does |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `magehand guide [topic]` | The app platform guide, live from homelab-apps `docs/platform/`; `magehand guide catalog` for blocks and models |
|
|
45
|
+
| `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 |
|
|
46
|
+
| `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 |
|
|
47
|
+
| `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 |
|
|
48
|
+
| `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 |
|
|
49
|
+
| `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 |
|
|
50
|
+
| `magehand doctor` | Checks GitHub access, that `openbao.lab` is reachable, the sign-in and Docker |
|
|
51
|
+
| `magehand version` | The installed version |
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
uv venv && uv pip install -e .
|
|
57
|
+
.venv/bin/python -m unittest discover -s tests
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Python 3.9+ (the Mac's system Python works). Stdlib plus PyYAML. Releases:
|
|
61
|
+
bump `version` in `pyproject.toml`, merge, then tag `vX.Y.Z`. The release
|
|
62
|
+
workflow builds and publishes to PyPI through trusted publishing (no stored
|
|
63
|
+
token). OpenClaw's VM installs a pinned version from homelab
|
|
64
|
+
`platform/openclaw/vm/versions.env`.
|
|
65
|
+
|
|
66
|
+
Never commit secrets, tokens, IP addresses or email addresses: this repo is
|
|
67
|
+
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=sq3Hy6D_oEIb-VsEXQDAGoUnInH80olZZvysare2SH0,28559
|
|
4
|
+
magehand-0.1.0.dist-info/METADATA,sha256=Nw0we7QVJmCwwg4GvJedyCYrFqa2e2YUoDahd4_mU2Q,3384
|
|
5
|
+
magehand-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
magehand-0.1.0.dist-info/entry_points.txt,sha256=L60-tMFLrKQ1udzWoZs25GU_6uc0VwzqRxyUuu0CUD8,47
|
|
7
|
+
magehand-0.1.0.dist-info/licenses/LICENSE,sha256=5TMwVQu83KQhhv7Xw4PD37oxVkw-U3IyT78IXKrBb7U,1072
|
|
8
|
+
magehand-0.1.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.
|