morphdb 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {morphdb-0.1.0 → morphdb-0.1.2}/PKG-INFO +23 -12
- {morphdb-0.1.0 → morphdb-0.1.2}/README.md +21 -10
- morphdb-0.1.2/morphdb/__init__.py +8 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/__main__.py +1 -1
- morphdb-0.1.2/morphdb/cli/__init__.py +22 -0
- morphdb-0.1.2/morphdb/cli/dashboard.py +134 -0
- morphdb-0.1.2/morphdb/cli/main.py +145 -0
- morphdb-0.1.2/morphdb/cli/service.py +169 -0
- morphdb-0.1.2/morphdb/cli/skill.py +54 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/routes.py +1 -1
- morphdb-0.1.2/morphdb/skill/SKILL.md +258 -0
- morphdb-0.1.2/morphdb/skill/scripts/morphdb_schema.py +285 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb.egg-info/PKG-INFO +23 -12
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb.egg-info/SOURCES.txt +8 -0
- morphdb-0.1.2/morphdb.egg-info/entry_points.txt +2 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/pyproject.toml +9 -4
- morphdb-0.1.2/tests/test_cli.py +120 -0
- morphdb-0.1.0/morphdb/__init__.py +0 -7
- morphdb-0.1.0/morphdb.egg-info/entry_points.txt +0 -2
- {morphdb-0.1.0 → morphdb-0.1.2}/LICENSE +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/apps.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/associations.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/db.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/errors.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/fieldtypes.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/objects.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/router.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/schema.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/server.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb/util.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb.egg-info/dependency_links.txt +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb.egg-info/requires.txt +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/morphdb.egg-info/top_level.txt +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/setup.cfg +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/tests/test_apps.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/tests/test_core.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/tests/test_hardening.py +0 -0
- {morphdb-0.1.0 → morphdb-0.1.2}/tests/test_relations.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: morphdb
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary: A
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A coding-agent-friendly, multi-tenant backend for vibe-coded websites. One process hosts many isolated apps; reshape each app's schema as fast as your agent iterates while the frontend keeps calling the same generic endpoints.
|
|
5
5
|
Author: morphdb contributors
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/Savcab/morphdb
|
|
@@ -27,7 +27,7 @@ Dynamic: license-file
|
|
|
27
27
|
|
|
28
28
|
# MorphDB
|
|
29
29
|
|
|
30
|
-
**A
|
|
30
|
+
**A coding-agent-friendly, multi-tenant backend for vibe-coded websites.**
|
|
31
31
|
|
|
32
32
|
Reshape the data model as fast as your coding agent iterates — the frontend
|
|
33
33
|
keeps calling the same small set of generic, deterministic endpoints.
|
|
@@ -120,31 +120,42 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
|
|
|
120
120
|
- **Query layer**: filter operators, sorting, pagination — all generic.
|
|
121
121
|
- **Multi-tenant by app** — one process backs many isolated sites; every call is scoped by an `X-App-Key`, and deleting an app cascades away all its data.
|
|
122
122
|
- **Wide-open CORS** so any frontend origin can call it in dev.
|
|
123
|
-
- **A
|
|
123
|
+
- **A management CLI** — `morphdb start/status/stop`, a read-only admin dashboard, and one-command skill install.
|
|
124
|
+
- **A Claude Code skill** (`morphdb/skill/SKILL.md`, install with `morphdb install-skill`) with a schema CLI so the agent edits the model without hand-writing curl.
|
|
124
125
|
|
|
125
126
|
> Scope: a localhost-scale developer tool. Not built for multi-tenant auth,
|
|
126
127
|
> horizontal scale, or production durability guarantees.
|
|
127
128
|
|
|
128
129
|
## Install / run
|
|
129
130
|
|
|
130
|
-
No install required:
|
|
131
|
-
|
|
132
131
|
```bash
|
|
133
|
-
|
|
132
|
+
pip install morphdb
|
|
134
133
|
```
|
|
135
134
|
|
|
136
|
-
|
|
135
|
+
Manage the local server with the `morphdb` CLI:
|
|
137
136
|
|
|
138
137
|
```bash
|
|
139
|
-
|
|
140
|
-
morphdb
|
|
138
|
+
morphdb start # run in the background (default 127.0.0.1:8787)
|
|
139
|
+
morphdb status # running? where? how many apps?
|
|
140
|
+
morphdb stop # stop it
|
|
141
|
+
morphdb run # run in the foreground instead (blocking)
|
|
142
|
+
morphdb dashboard # read-only web view of every app + its tables
|
|
143
|
+
morphdb install-skill # install the MorphDB Claude Code skill (into ~/.claude)
|
|
141
144
|
```
|
|
142
145
|
|
|
143
|
-
|
|
144
|
-
`--db
|
|
146
|
+
Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH` or
|
|
147
|
+
`--db :memory:`; move the state dir with `$MORPHDB_HOME`). Server flags:
|
|
148
|
+
`--host`, `--port`, `--db`. From a source checkout with no install, the
|
|
149
|
+
foreground server is `python3 -m morphdb --port 8787 --db ./app.sqlite3`.
|
|
145
150
|
|
|
146
151
|
Then: `curl http://127.0.0.1:8787/help` for a live reference.
|
|
147
152
|
|
|
153
|
+
**Pointing clients at a hosted MorphDB.** Set `MORPHDB_HOST` to a full URL (e.g.
|
|
154
|
+
`https://db.example.com`) and the schema CLI — plus any frontend that reads
|
|
155
|
+
`window.MORPHDB_HOST` — calls that hosted server (running this same code) instead
|
|
156
|
+
of localhost. It's a client-side setting that names a *backend*, not a database
|
|
157
|
+
connection string.
|
|
158
|
+
|
|
148
159
|
## Quickstart
|
|
149
160
|
|
|
150
161
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# MorphDB
|
|
2
2
|
|
|
3
|
-
**A
|
|
3
|
+
**A coding-agent-friendly, multi-tenant backend for vibe-coded websites.**
|
|
4
4
|
|
|
5
5
|
Reshape the data model as fast as your coding agent iterates — the frontend
|
|
6
6
|
keeps calling the same small set of generic, deterministic endpoints.
|
|
@@ -93,31 +93,42 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
|
|
|
93
93
|
- **Query layer**: filter operators, sorting, pagination — all generic.
|
|
94
94
|
- **Multi-tenant by app** — one process backs many isolated sites; every call is scoped by an `X-App-Key`, and deleting an app cascades away all its data.
|
|
95
95
|
- **Wide-open CORS** so any frontend origin can call it in dev.
|
|
96
|
-
- **A
|
|
96
|
+
- **A management CLI** — `morphdb start/status/stop`, a read-only admin dashboard, and one-command skill install.
|
|
97
|
+
- **A Claude Code skill** (`morphdb/skill/SKILL.md`, install with `morphdb install-skill`) with a schema CLI so the agent edits the model without hand-writing curl.
|
|
97
98
|
|
|
98
99
|
> Scope: a localhost-scale developer tool. Not built for multi-tenant auth,
|
|
99
100
|
> horizontal scale, or production durability guarantees.
|
|
100
101
|
|
|
101
102
|
## Install / run
|
|
102
103
|
|
|
103
|
-
No install required:
|
|
104
|
-
|
|
105
104
|
```bash
|
|
106
|
-
|
|
105
|
+
pip install morphdb
|
|
107
106
|
```
|
|
108
107
|
|
|
109
|
-
|
|
108
|
+
Manage the local server with the `morphdb` CLI:
|
|
110
109
|
|
|
111
110
|
```bash
|
|
112
|
-
|
|
113
|
-
morphdb
|
|
111
|
+
morphdb start # run in the background (default 127.0.0.1:8787)
|
|
112
|
+
morphdb status # running? where? how many apps?
|
|
113
|
+
morphdb stop # stop it
|
|
114
|
+
morphdb run # run in the foreground instead (blocking)
|
|
115
|
+
morphdb dashboard # read-only web view of every app + its tables
|
|
116
|
+
morphdb install-skill # install the MorphDB Claude Code skill (into ~/.claude)
|
|
114
117
|
```
|
|
115
118
|
|
|
116
|
-
|
|
117
|
-
`--db
|
|
119
|
+
Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH` or
|
|
120
|
+
`--db :memory:`; move the state dir with `$MORPHDB_HOME`). Server flags:
|
|
121
|
+
`--host`, `--port`, `--db`. From a source checkout with no install, the
|
|
122
|
+
foreground server is `python3 -m morphdb --port 8787 --db ./app.sqlite3`.
|
|
118
123
|
|
|
119
124
|
Then: `curl http://127.0.0.1:8787/help` for a live reference.
|
|
120
125
|
|
|
126
|
+
**Pointing clients at a hosted MorphDB.** Set `MORPHDB_HOST` to a full URL (e.g.
|
|
127
|
+
`https://db.example.com`) and the schema CLI — plus any frontend that reads
|
|
128
|
+
`window.MORPHDB_HOST` — calls that hosted server (running this same code) instead
|
|
129
|
+
of localhost. It's a client-side setting that names a *backend*, not a database
|
|
130
|
+
connection string.
|
|
131
|
+
|
|
121
132
|
## Quickstart
|
|
122
133
|
|
|
123
134
|
```bash
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""MorphDB — a coding-agent-friendly, multi-tenant backend for vibe-coded websites.
|
|
2
|
+
|
|
3
|
+
One process hosts many isolated apps; reshape each app's schema as fast as a
|
|
4
|
+
coding agent iterates, while the frontend keeps calling the same small set of
|
|
5
|
+
generic, deterministic endpoints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.2"
|
|
@@ -10,7 +10,7 @@ from .server import serve
|
|
|
10
10
|
def main(argv=None):
|
|
11
11
|
parser = argparse.ArgumentParser(
|
|
12
12
|
prog="morphdb",
|
|
13
|
-
description="MorphDB — a
|
|
13
|
+
description="MorphDB — a coding-agent-friendly, multi-tenant backend for vibe-coded websites.",
|
|
14
14
|
)
|
|
15
15
|
parser.add_argument("--host", default="127.0.0.1",
|
|
16
16
|
help="Host/interface to bind (default: 127.0.0.1).")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""MorphDB command-line interface — process management + admin dashboard.
|
|
2
|
+
|
|
3
|
+
Intentionally separate from the core engine (db / schema / objects / server):
|
|
4
|
+
this package only *orchestrates* a server process and offers a read-only admin
|
|
5
|
+
view. It never changes how the core stores or serves data.
|
|
6
|
+
|
|
7
|
+
Commands (see :mod:`morphdb.cli.main`):
|
|
8
|
+
|
|
9
|
+
morphdb start the server in the background (alias of `start`)
|
|
10
|
+
morphdb start same, explicit
|
|
11
|
+
morphdb status is it running? where? how many apps?
|
|
12
|
+
morphdb stop stop the background server
|
|
13
|
+
morphdb run run in the foreground (blocking; for dev)
|
|
14
|
+
morphdb dashboard open a read-only web view of every app + its tables
|
|
15
|
+
|
|
16
|
+
Storage: the local server keeps data in a per-user SQLite file at
|
|
17
|
+
``~/.morphdb/data.sqlite3`` (override the file with ``--db``, or move the state
|
|
18
|
+
dir with ``$MORPHDB_HOME``). To talk to a MorphDB hosted somewhere else instead
|
|
19
|
+
of a local one, point *clients* at it with ``$MORPHDB_HOST`` (a full URL) — that
|
|
20
|
+
is a client-side setting, not a database connection string; the engine is always
|
|
21
|
+
SQLite.
|
|
22
|
+
"""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Read-only admin dashboard: every app and its tables, in one local page.
|
|
2
|
+
|
|
3
|
+
Operator-facing and local-only — it opens the SQLite file directly (read-only)
|
|
4
|
+
rather than going through the HTTP API, so it can list apps without adding a
|
|
5
|
+
"list apps" endpoint to the public surface (which is intentionally absent).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import webbrowser
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def gather(db):
|
|
16
|
+
"""A read-only snapshot: {apps: [{app, types:[{name,fields,count}], relations, edges}]}.
|
|
17
|
+
|
|
18
|
+
Tolerates a missing/empty/locked database by returning an ``error`` string.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
c = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2)
|
|
22
|
+
c.row_factory = sqlite3.Row
|
|
23
|
+
except Exception as e:
|
|
24
|
+
return {"error": f"cannot open database: {e}", "apps": []}
|
|
25
|
+
try:
|
|
26
|
+
try:
|
|
27
|
+
apps = [r["key"] for r in c.execute("SELECT key FROM apps ORDER BY key")]
|
|
28
|
+
except sqlite3.OperationalError:
|
|
29
|
+
return {"error": "no MorphDB schema in this database yet", "apps": []}
|
|
30
|
+
out = []
|
|
31
|
+
for app in apps:
|
|
32
|
+
types = []
|
|
33
|
+
for r in c.execute(
|
|
34
|
+
"SELECT name, fields FROM object_schemas WHERE app=? ORDER BY name",
|
|
35
|
+
(app,)):
|
|
36
|
+
try:
|
|
37
|
+
fields = list(json.loads(r["fields"]).keys())
|
|
38
|
+
except Exception:
|
|
39
|
+
fields = []
|
|
40
|
+
count = c.execute(
|
|
41
|
+
"SELECT COUNT(*) FROM objects WHERE app=? AND object_type=?",
|
|
42
|
+
(app, r["name"])).fetchone()[0]
|
|
43
|
+
types.append({"name": r["name"], "fields": fields, "count": count})
|
|
44
|
+
relations = c.execute(
|
|
45
|
+
"SELECT COUNT(*) FROM association_schemas WHERE app=?", (app,)).fetchone()[0]
|
|
46
|
+
edges = c.execute(
|
|
47
|
+
"SELECT COUNT(*) FROM associations WHERE app=?", (app,)).fetchone()[0]
|
|
48
|
+
out.append({"app": app, "types": types,
|
|
49
|
+
"relations": relations, "edges": edges})
|
|
50
|
+
return {"apps": out}
|
|
51
|
+
finally:
|
|
52
|
+
c.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_CSS = """
|
|
56
|
+
:root { color-scheme: light dark; }
|
|
57
|
+
body { font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
58
|
+
max-width: 900px; margin: 32px auto; padding: 0 16px; }
|
|
59
|
+
header { display:flex; align-items:baseline; justify-content:space-between; gap:12px;
|
|
60
|
+
border-bottom:1px solid #8884; padding-bottom:12px; margin-bottom:20px; }
|
|
61
|
+
h1 { font-size:1.3rem; margin:0; }
|
|
62
|
+
.db { color:#888; font-size:12px; font-family:ui-monospace,Menlo,monospace; }
|
|
63
|
+
.card { border:1px solid #8884; border-radius:10px; padding:14px 16px; margin-bottom:16px; }
|
|
64
|
+
.card h2 { font-size:1.05rem; margin:0 0 2px; }
|
|
65
|
+
.meta { color:#888; font-size:12px; margin-bottom:10px; }
|
|
66
|
+
table { width:100%; border-collapse:collapse; font-size:14px; }
|
|
67
|
+
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid #8882; }
|
|
68
|
+
th { color:#888; font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
|
69
|
+
td.n { text-align:right; font-variant-numeric:tabular-nums; }
|
|
70
|
+
.muted { color:#999; } .err { color:#ef4444; }
|
|
71
|
+
code { font-family:ui-monospace,Menlo,monospace; background:#8881; padding:1px 5px; border-radius:5px; }
|
|
72
|
+
footer { color:#999; font-size:12px; margin-top:24px; }
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render(data, db):
|
|
77
|
+
esc = html.escape
|
|
78
|
+
if data.get("error"):
|
|
79
|
+
body = f"<p class='err'>{esc(data['error'])}</p>"
|
|
80
|
+
elif not data["apps"]:
|
|
81
|
+
body = "<p class='muted'>No apps registered yet. Create one with <code>POST /app</code>.</p>"
|
|
82
|
+
else:
|
|
83
|
+
cards = []
|
|
84
|
+
for a in data["apps"]:
|
|
85
|
+
if a["types"]:
|
|
86
|
+
trows = "".join(
|
|
87
|
+
f"<tr><td><b>{esc(t['name'])}</b></td>"
|
|
88
|
+
f"<td>{esc(', '.join(t['fields']) or '—')}</td>"
|
|
89
|
+
f"<td class='n'>{t['count']}</td></tr>"
|
|
90
|
+
for t in a["types"])
|
|
91
|
+
else:
|
|
92
|
+
trows = "<tr><td colspan='3' class='muted'>no types yet</td></tr>"
|
|
93
|
+
cards.append(
|
|
94
|
+
f"<section class='card'><h2>{esc(a['app'])}</h2>"
|
|
95
|
+
f"<div class='meta'>{len(a['types'])} types · "
|
|
96
|
+
f"{a['relations']} relations · {a['edges']} edges</div>"
|
|
97
|
+
"<table><thead><tr><th>type</th><th>fields</th><th>objects</th></tr></thead>"
|
|
98
|
+
f"<tbody>{trows}</tbody></table></section>")
|
|
99
|
+
body = "\n".join(cards)
|
|
100
|
+
return (
|
|
101
|
+
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
102
|
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
|
103
|
+
f"<title>MorphDB admin</title><style>{_CSS}</style></head><body>"
|
|
104
|
+
f"<header><h1>MorphDB admin</h1><div class='db'>{esc(str(db))}</div></header>"
|
|
105
|
+
f"{body}<footer>read-only view · refresh to update</footer></body></html>")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def serve(db, host="127.0.0.1", port=8788, open_browser=True):
|
|
109
|
+
class Handler(BaseHTTPRequestHandler):
|
|
110
|
+
def do_GET(self):
|
|
111
|
+
body = render(gather(db), db).encode("utf-8")
|
|
112
|
+
self.send_response(200)
|
|
113
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
114
|
+
self.send_header("Content-Length", str(len(body)))
|
|
115
|
+
self.end_headers()
|
|
116
|
+
self.wfile.write(body)
|
|
117
|
+
|
|
118
|
+
def log_message(self, *a):
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
httpd = ThreadingHTTPServer((host, port), Handler)
|
|
122
|
+
url = f"http://{host}:{port}"
|
|
123
|
+
print(f"MorphDB admin dashboard: {url}\n reading: {db}\n Ctrl-C to stop.")
|
|
124
|
+
if open_browser:
|
|
125
|
+
try:
|
|
126
|
+
webbrowser.open(url)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
try:
|
|
130
|
+
httpd.serve_forever()
|
|
131
|
+
except KeyboardInterrupt:
|
|
132
|
+
pass
|
|
133
|
+
finally:
|
|
134
|
+
httpd.server_close()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""``morphdb`` console-script entry point: a small process/admin CLI.
|
|
2
|
+
|
|
3
|
+
morphdb start the server in the background (alias of `start`)
|
|
4
|
+
morphdb start start the server in the background
|
|
5
|
+
morphdb status show whether it is running, where, and how many apps
|
|
6
|
+
morphdb stop stop the background server
|
|
7
|
+
morphdb run run the server in the foreground (blocking)
|
|
8
|
+
morphdb dashboard open the read-only admin dashboard
|
|
9
|
+
|
|
10
|
+
``python -m morphdb`` remains the plain foreground server (what `start` and the
|
|
11
|
+
skill spawn under the hood); this CLI only wraps it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import dashboard, service
|
|
18
|
+
from . import skill as skill_mod
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fmt_status(st):
|
|
22
|
+
if not st.get("running"):
|
|
23
|
+
msg = "MorphDB is not running."
|
|
24
|
+
if st.get("stale"):
|
|
25
|
+
msg += " (cleared a stale pid file)"
|
|
26
|
+
return msg
|
|
27
|
+
health = "healthy" if st.get("healthy") else "starting / not responding yet"
|
|
28
|
+
lines = [
|
|
29
|
+
f"MorphDB is running (pid {st['pid']}) at http://{st['host']}:{st['port']} [{health}]",
|
|
30
|
+
f" db: {st['db']}",
|
|
31
|
+
]
|
|
32
|
+
n = service.app_count(st.get("db"))
|
|
33
|
+
if n is not None:
|
|
34
|
+
lines.append(f" apps: {n}")
|
|
35
|
+
return "\n".join(lines)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_start(args):
|
|
39
|
+
st, _ = service.start(args.host, args.port, args.db)
|
|
40
|
+
print(_fmt_status(st))
|
|
41
|
+
if not st.get("running"):
|
|
42
|
+
print(f" (server exited on startup — check the log: {service.log_file()})")
|
|
43
|
+
return 1
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_run(args):
|
|
48
|
+
from ..server import serve as serve_fg
|
|
49
|
+
serve_fg(host=args.host, port=args.port, db_path=args.db or service.default_db())
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_status(args):
|
|
54
|
+
print(_fmt_status(service.status()))
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_stop(args):
|
|
59
|
+
print("MorphDB stopped." if service.stop() else "MorphDB was not running.")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_dashboard(args):
|
|
64
|
+
dashboard.serve(args.db or service.default_db(), port=args.port,
|
|
65
|
+
open_browser=not args.no_open)
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_install_skill(args):
|
|
70
|
+
try:
|
|
71
|
+
dest = skill_mod.install_skill(project=args.project, force=args.force)
|
|
72
|
+
except FileExistsError as e:
|
|
73
|
+
print(f"Skill already installed at {e}. Re-run with --force to overwrite.")
|
|
74
|
+
return 1
|
|
75
|
+
except (FileNotFoundError, OSError) as e:
|
|
76
|
+
print(f"Could not install skill: {e}")
|
|
77
|
+
return 1
|
|
78
|
+
where = "this project" if args.project else "your home (~/.claude)"
|
|
79
|
+
print(f"Installed the 'morphdb' Claude skill to {dest}\n"
|
|
80
|
+
f" ({where}). Restart Claude Code (or reload skills) to pick it up.")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _add_server_opts(sp):
|
|
85
|
+
sp.add_argument("--host", default=service.DEFAULT_HOST,
|
|
86
|
+
help=f"bind host (default {service.DEFAULT_HOST})")
|
|
87
|
+
sp.add_argument("--port", type=int, default=service.DEFAULT_PORT,
|
|
88
|
+
help=f"bind port (default {service.DEFAULT_PORT})")
|
|
89
|
+
sp.add_argument("--db", default=None,
|
|
90
|
+
help="SQLite path or :memory: (default ~/.morphdb/data.sqlite3)")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_parser():
|
|
94
|
+
from .. import __version__
|
|
95
|
+
p = argparse.ArgumentParser(
|
|
96
|
+
prog="morphdb",
|
|
97
|
+
description="MorphDB — a multi-tenant backend for vibe-coded sites. "
|
|
98
|
+
"Manage the local server and view your apps.")
|
|
99
|
+
p.add_argument("--version", action="version", version=f"morphdb {__version__}")
|
|
100
|
+
sub = p.add_subparsers(dest="command")
|
|
101
|
+
|
|
102
|
+
sp = sub.add_parser("start", help="start the server in the background")
|
|
103
|
+
_add_server_opts(sp)
|
|
104
|
+
sp.set_defaults(func=cmd_start)
|
|
105
|
+
|
|
106
|
+
sp = sub.add_parser("run", help="run the server in the foreground (blocking)")
|
|
107
|
+
_add_server_opts(sp)
|
|
108
|
+
sp.set_defaults(func=cmd_run)
|
|
109
|
+
|
|
110
|
+
sub.add_parser("status", help="show whether the server is running"
|
|
111
|
+
).set_defaults(func=cmd_status)
|
|
112
|
+
sub.add_parser("stop", help="stop the background server"
|
|
113
|
+
).set_defaults(func=cmd_stop)
|
|
114
|
+
|
|
115
|
+
sp = sub.add_parser("dashboard", help="open the read-only admin dashboard")
|
|
116
|
+
sp.add_argument("--port", type=int, default=8788, help="dashboard port (default 8788)")
|
|
117
|
+
sp.add_argument("--db", default=None, help="database to inspect (default the server's)")
|
|
118
|
+
sp.add_argument("--no-open", action="store_true", help="don't auto-open a browser")
|
|
119
|
+
sp.set_defaults(func=cmd_dashboard)
|
|
120
|
+
|
|
121
|
+
sp = sub.add_parser("install-skill",
|
|
122
|
+
help="install the MorphDB skill into Claude Code")
|
|
123
|
+
sp.add_argument("--project", nargs="?", const=".", default=None,
|
|
124
|
+
metavar="DIR",
|
|
125
|
+
help="install into a project's .claude (DIR, default cwd) "
|
|
126
|
+
"instead of ~/.claude")
|
|
127
|
+
sp.add_argument("--force", action="store_true", help="overwrite if it exists")
|
|
128
|
+
sp.set_defaults(func=cmd_install_skill)
|
|
129
|
+
|
|
130
|
+
return p
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv=None):
|
|
134
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
135
|
+
if not argv: # bare `morphdb` => start in the background
|
|
136
|
+
argv = ["start"]
|
|
137
|
+
args = build_parser().parse_args(argv)
|
|
138
|
+
if not getattr(args, "func", None):
|
|
139
|
+
build_parser().print_help()
|
|
140
|
+
return 1
|
|
141
|
+
return args.func(args)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Background-service management for the MorphDB server.
|
|
2
|
+
|
|
3
|
+
Starts ``python -m morphdb`` as a detached child process (its own session, so
|
|
4
|
+
it survives the terminal closing — the zero-dependency equivalent of a tmux
|
|
5
|
+
session), and records pid + bind info under the state dir so ``status``/``stop``
|
|
6
|
+
can find it later. Pure stdlib.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import sqlite3
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
19
|
+
DEFAULT_PORT = 8787
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _home():
|
|
23
|
+
"""The MorphDB state dir: ``$MORPHDB_HOME`` or ``~/.morphdb``."""
|
|
24
|
+
return os.environ.get("MORPHDB_HOME") or os.path.join(
|
|
25
|
+
os.path.expanduser("~"), ".morphdb")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def state_dir():
|
|
29
|
+
d = _home()
|
|
30
|
+
os.makedirs(d, exist_ok=True)
|
|
31
|
+
return d
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _path(name):
|
|
35
|
+
return os.path.join(state_dir(), name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def meta_file():
|
|
39
|
+
return _path("service.json")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def log_file():
|
|
43
|
+
return _path("server.log")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_db():
|
|
47
|
+
"""Where the local server keeps data: a per-user SQLite file.
|
|
48
|
+
|
|
49
|
+
Override the file with ``--db`` (any path, or ``:memory:``) or move the whole
|
|
50
|
+
state dir with ``$MORPHDB_HOME``. To use a MorphDB hosted elsewhere instead of
|
|
51
|
+
a local one, you don't change this — you point *clients* at that server's URL
|
|
52
|
+
with ``$MORPHDB_HOST`` (see the skill); the engine itself is always SQLite.
|
|
53
|
+
"""
|
|
54
|
+
return _path("data.sqlite3")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_meta():
|
|
58
|
+
try:
|
|
59
|
+
with open(meta_file()) as f:
|
|
60
|
+
return json.load(f)
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write_meta(meta):
|
|
66
|
+
with open(meta_file(), "w") as f:
|
|
67
|
+
json.dump(meta, f)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def clear_meta():
|
|
71
|
+
try:
|
|
72
|
+
os.remove(meta_file())
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _alive(pid):
|
|
78
|
+
if not pid:
|
|
79
|
+
return False
|
|
80
|
+
try:
|
|
81
|
+
os.kill(pid, 0)
|
|
82
|
+
except ProcessLookupError:
|
|
83
|
+
return False
|
|
84
|
+
except PermissionError:
|
|
85
|
+
return True
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _health(host, port, timeout=1.0):
|
|
90
|
+
try:
|
|
91
|
+
with urllib.request.urlopen(
|
|
92
|
+
f"http://{host}:{port}/health", timeout=timeout) as r:
|
|
93
|
+
return r.status == 200
|
|
94
|
+
except Exception:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def status():
|
|
99
|
+
"""Snapshot: {running, [pid, host, port, db, healthy] | [stale]}."""
|
|
100
|
+
meta = read_meta()
|
|
101
|
+
if not meta:
|
|
102
|
+
return {"running": False}
|
|
103
|
+
if not _alive(meta.get("pid")):
|
|
104
|
+
return {"running": False, "stale": True, **meta}
|
|
105
|
+
return {
|
|
106
|
+
"running": True,
|
|
107
|
+
**meta,
|
|
108
|
+
"healthy": _health(meta.get("host", DEFAULT_HOST),
|
|
109
|
+
meta.get("port", DEFAULT_PORT)),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def app_count(db):
|
|
114
|
+
"""Read-only count of registered apps (for status). None if unreadable."""
|
|
115
|
+
try:
|
|
116
|
+
c = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1)
|
|
117
|
+
try:
|
|
118
|
+
return c.execute("SELECT COUNT(*) FROM apps").fetchone()[0]
|
|
119
|
+
finally:
|
|
120
|
+
c.close()
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def start(host=DEFAULT_HOST, port=DEFAULT_PORT, db=None, wait=6.0):
|
|
126
|
+
"""Start the server detached. Returns (status_dict, attempted_start_bool)."""
|
|
127
|
+
if status().get("running"):
|
|
128
|
+
return status(), False
|
|
129
|
+
db = db or default_db()
|
|
130
|
+
log = open(log_file(), "ab")
|
|
131
|
+
proc = subprocess.Popen(
|
|
132
|
+
[sys.executable, "-m", "morphdb",
|
|
133
|
+
"--host", host, "--port", str(port), "--db", db],
|
|
134
|
+
stdout=log, stderr=log, stdin=subprocess.DEVNULL,
|
|
135
|
+
start_new_session=True, # detach from the controlling terminal
|
|
136
|
+
)
|
|
137
|
+
write_meta({"pid": proc.pid, "host": host, "port": port, "db": db})
|
|
138
|
+
deadline = time.time() + wait
|
|
139
|
+
while time.time() < deadline:
|
|
140
|
+
if proc.poll() is not None: # died on startup (e.g. port in use)
|
|
141
|
+
break
|
|
142
|
+
if _health(host, port):
|
|
143
|
+
break
|
|
144
|
+
time.sleep(0.2)
|
|
145
|
+
return status(), True
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def stop(timeout=5.0):
|
|
149
|
+
"""Stop the background server. Returns True if a live process was killed."""
|
|
150
|
+
meta = read_meta()
|
|
151
|
+
if not meta or not _alive(meta.get("pid")):
|
|
152
|
+
clear_meta()
|
|
153
|
+
return False
|
|
154
|
+
pid = meta["pid"]
|
|
155
|
+
try:
|
|
156
|
+
os.kill(pid, signal.SIGTERM)
|
|
157
|
+
except ProcessLookupError:
|
|
158
|
+
clear_meta()
|
|
159
|
+
return False
|
|
160
|
+
deadline = time.time() + timeout
|
|
161
|
+
while time.time() < deadline and _alive(pid):
|
|
162
|
+
time.sleep(0.15)
|
|
163
|
+
if _alive(pid):
|
|
164
|
+
try:
|
|
165
|
+
os.kill(pid, signal.SIGKILL)
|
|
166
|
+
except ProcessLookupError:
|
|
167
|
+
pass
|
|
168
|
+
clear_meta()
|
|
169
|
+
return True
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Install the bundled MorphDB Claude skill into a `.claude/skills` directory.
|
|
2
|
+
|
|
3
|
+
The skill (SKILL.md + the schema CLI) ships as package data inside ``morphdb``,
|
|
4
|
+
so this works the same whether MorphDB was pip/brew-installed or run from a
|
|
5
|
+
source checkout. It copies the skill tree to ``~/.claude/skills/morphdb`` (or a
|
|
6
|
+
project's ``.claude/skills/morphdb``), where Claude Code auto-discovers it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from importlib import resources
|
|
11
|
+
|
|
12
|
+
SKILL_NAME = "morphdb"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _copy_tree(src, dst):
|
|
16
|
+
"""Recursively copy an importlib.resources Traversable tree to a real dir."""
|
|
17
|
+
if src.is_dir():
|
|
18
|
+
os.makedirs(dst, exist_ok=True)
|
|
19
|
+
for child in src.iterdir():
|
|
20
|
+
if child.name == "__pycache__":
|
|
21
|
+
continue
|
|
22
|
+
_copy_tree(child, os.path.join(dst, child.name))
|
|
23
|
+
else:
|
|
24
|
+
with open(dst, "wb") as f:
|
|
25
|
+
f.write(src.read_bytes())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def install_skill(claude_dir=None, project=None, force=False):
|
|
29
|
+
"""Copy the packaged skill into a `.claude/skills/morphdb` directory.
|
|
30
|
+
|
|
31
|
+
``project`` (a path, or "." for cwd) installs into that project's
|
|
32
|
+
``.claude``; otherwise it installs into ``~/.claude`` (all projects).
|
|
33
|
+
``claude_dir`` overrides the `.claude` location outright (used by tests).
|
|
34
|
+
Returns the destination path. Raises FileExistsError if it already exists
|
|
35
|
+
and ``force`` is false.
|
|
36
|
+
"""
|
|
37
|
+
if claude_dir is None:
|
|
38
|
+
base = os.path.abspath(project) if project else os.path.expanduser("~")
|
|
39
|
+
claude_dir = os.path.join(base, ".claude")
|
|
40
|
+
dest = os.path.join(claude_dir, "skills", SKILL_NAME)
|
|
41
|
+
|
|
42
|
+
if os.path.exists(dest) and not force:
|
|
43
|
+
raise FileExistsError(dest)
|
|
44
|
+
|
|
45
|
+
src = resources.files("morphdb") / "skill"
|
|
46
|
+
if not src.is_dir():
|
|
47
|
+
raise FileNotFoundError(
|
|
48
|
+
"packaged skill not found (morphdb/skill missing from the install).")
|
|
49
|
+
|
|
50
|
+
if os.path.exists(dest) and force:
|
|
51
|
+
import shutil
|
|
52
|
+
shutil.rmtree(dest)
|
|
53
|
+
_copy_tree(src, dest)
|
|
54
|
+
return dest
|