deyta-cli 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.
- deyta_cli/__init__.py +1 -0
- deyta_cli/cli.py +65 -0
- deyta_cli/client.py +108 -0
- deyta_cli/commands/__init__.py +1 -0
- deyta_cli/commands/_common.py +84 -0
- deyta_cli/commands/aliases.py +47 -0
- deyta_cli/commands/auth.py +25 -0
- deyta_cli/commands/context.py +53 -0
- deyta_cli/commands/db.py +58 -0
- deyta_cli/commands/init.py +148 -0
- deyta_cli/commands/memory.py +233 -0
- deyta_cli/commands/namespace.py +117 -0
- deyta_cli/commands/serve.py +102 -0
- deyta_cli/commands/update.py +66 -0
- deyta_cli/commands/version.py +36 -0
- deyta_cli/config.py +349 -0
- deyta_cli/docker.py +118 -0
- deyta_cli/ingest_walk.py +49 -0
- deyta_cli/render.py +121 -0
- deyta_cli/server/__init__.py +1 -0
- deyta_cli/server/app.py +80 -0
- deyta_cli/server/routes.py +242 -0
- deyta_cli/server/runner.py +151 -0
- deyta_cli/templates/compose.yaml +52 -0
- deyta_cli/versions.py +150 -0
- deyta_cli-0.1.0.dist-info/METADATA +156 -0
- deyta_cli-0.1.0.dist-info/RECORD +30 -0
- deyta_cli-0.1.0.dist-info/WHEEL +4 -0
- deyta_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deyta_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""`deyta memory …` — Khora's primitives: remember / recall / forget / ingest.
|
|
2
|
+
|
|
3
|
+
The core logic lives in module-level ``run_*`` functions so the top-level shorthands
|
|
4
|
+
(`deyta ingest`, `deyta query`) can reuse them verbatim.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn
|
|
14
|
+
|
|
15
|
+
from .. import ingest_walk, render
|
|
16
|
+
from ..client import DeytaClientError
|
|
17
|
+
from ._common import get_client, require_project, resolve_namespace, resolve_ontology
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="Memory operations (Khora).", no_args_is_help=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
# Core logic (reused by the top-level aliases)
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
def run_remember(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
text: str,
|
|
28
|
+
namespace: str | None,
|
|
29
|
+
title: str,
|
|
30
|
+
source: str,
|
|
31
|
+
entity_types: str | None,
|
|
32
|
+
relationship_types: str | None,
|
|
33
|
+
) -> None:
|
|
34
|
+
cfg = require_project(ctx)
|
|
35
|
+
nsid = resolve_namespace(cfg, namespace)
|
|
36
|
+
ents, rels = resolve_ontology(cfg, entity_types, relationship_types)
|
|
37
|
+
client = get_client(ctx)
|
|
38
|
+
try:
|
|
39
|
+
result = client.remember(
|
|
40
|
+
{
|
|
41
|
+
"content": text,
|
|
42
|
+
"namespace": nsid,
|
|
43
|
+
"title": title,
|
|
44
|
+
"source": source,
|
|
45
|
+
"entity_types": ents,
|
|
46
|
+
"relationship_types": rels,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
except DeytaClientError as exc:
|
|
50
|
+
render.error(str(exc))
|
|
51
|
+
raise typer.Exit(1) from exc
|
|
52
|
+
render.success(
|
|
53
|
+
f"Remembered → document {result.get('document_id')} "
|
|
54
|
+
f"({result.get('chunks_created', 0)} chunks, "
|
|
55
|
+
f"{result.get('entities_extracted', 0)} entities)"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_recall(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
query: str,
|
|
62
|
+
namespace: str | None,
|
|
63
|
+
mode: str,
|
|
64
|
+
limit: int,
|
|
65
|
+
min_similarity: float,
|
|
66
|
+
since: str | None,
|
|
67
|
+
until: str | None,
|
|
68
|
+
as_json: bool,
|
|
69
|
+
as_context: bool,
|
|
70
|
+
) -> None:
|
|
71
|
+
cfg = require_project(ctx)
|
|
72
|
+
nsid = resolve_namespace(cfg, namespace)
|
|
73
|
+
client = get_client(ctx)
|
|
74
|
+
try:
|
|
75
|
+
result = client.recall(
|
|
76
|
+
{
|
|
77
|
+
"query": query,
|
|
78
|
+
"namespace": nsid,
|
|
79
|
+
"mode": mode,
|
|
80
|
+
"limit": limit,
|
|
81
|
+
"min_similarity": min_similarity,
|
|
82
|
+
"start_time": since,
|
|
83
|
+
"end_time": until,
|
|
84
|
+
"context": as_context,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
except DeytaClientError as exc:
|
|
88
|
+
render.error(str(exc))
|
|
89
|
+
raise typer.Exit(1) from exc
|
|
90
|
+
if as_json:
|
|
91
|
+
render.console.print_json(json.dumps(result))
|
|
92
|
+
elif as_context:
|
|
93
|
+
render.console.print(result.get("context_text", ""))
|
|
94
|
+
else:
|
|
95
|
+
render.recall_result(result)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_ingest(
|
|
99
|
+
ctx: typer.Context,
|
|
100
|
+
path: Path,
|
|
101
|
+
namespace: str | None,
|
|
102
|
+
recursive: bool,
|
|
103
|
+
include: str | None,
|
|
104
|
+
entity_types: str | None,
|
|
105
|
+
relationship_types: str | None,
|
|
106
|
+
dry_run: bool,
|
|
107
|
+
) -> None:
|
|
108
|
+
cfg = require_project(ctx)
|
|
109
|
+
nsid = resolve_namespace(cfg, namespace)
|
|
110
|
+
ents, rels = resolve_ontology(cfg, entity_types, relationship_types)
|
|
111
|
+
try:
|
|
112
|
+
files = ingest_walk.discover(path, recursive=recursive, include=include)
|
|
113
|
+
except FileNotFoundError as exc:
|
|
114
|
+
render.error(str(exc))
|
|
115
|
+
raise typer.Exit(1) from exc
|
|
116
|
+
root = path if path.is_dir() else path.parent
|
|
117
|
+
documents = ingest_walk.build_documents(files, root=root)
|
|
118
|
+
if not documents:
|
|
119
|
+
render.error(f"No ingestible text files found under {path}.")
|
|
120
|
+
raise typer.Exit(1)
|
|
121
|
+
|
|
122
|
+
if dry_run:
|
|
123
|
+
render.info(f"[bold]{len(documents)}[/] document(s) would be ingested into {nsid}:")
|
|
124
|
+
for d in documents:
|
|
125
|
+
render.info(f" • {d['source']}")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
client = get_client(ctx)
|
|
129
|
+
payload = {
|
|
130
|
+
"documents": documents,
|
|
131
|
+
"namespace": nsid,
|
|
132
|
+
"entity_types": ents,
|
|
133
|
+
"relationship_types": rels,
|
|
134
|
+
}
|
|
135
|
+
with Progress(
|
|
136
|
+
TextColumn("[bold blue]Ingesting"),
|
|
137
|
+
BarColumn(),
|
|
138
|
+
TaskProgressColumn(),
|
|
139
|
+
TextColumn("{task.completed}/{task.total} docs"),
|
|
140
|
+
console=render.console,
|
|
141
|
+
) as progress:
|
|
142
|
+
task = progress.add_task("ingest", total=len(documents))
|
|
143
|
+
try:
|
|
144
|
+
for event in client.ingest(payload):
|
|
145
|
+
kind = event.get("type")
|
|
146
|
+
if kind == "progress":
|
|
147
|
+
progress.update(task, completed=event.get("processed", 0), total=event.get("total"))
|
|
148
|
+
elif kind == "error":
|
|
149
|
+
progress.stop()
|
|
150
|
+
render.error(event.get("detail", "ingest failed"))
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
elif kind == "result":
|
|
153
|
+
progress.update(task, completed=event.get("total", len(documents)))
|
|
154
|
+
render.batch_summary(event)
|
|
155
|
+
except DeytaClientError as exc:
|
|
156
|
+
progress.stop()
|
|
157
|
+
render.error(str(exc))
|
|
158
|
+
raise typer.Exit(1) from exc
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------- #
|
|
162
|
+
# `deyta memory …` commands
|
|
163
|
+
# --------------------------------------------------------------------------- #
|
|
164
|
+
_NS = typer.Option(None, "--namespace", "-n", help="Namespace name or id (default: active).")
|
|
165
|
+
_ENT = typer.Option(None, "--entity-types", help="Comma-separated; overrides config default.")
|
|
166
|
+
_REL = typer.Option(None, "--relationship-types", help="Comma-separated; overrides config default.")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command("remember")
|
|
170
|
+
def memory_remember(
|
|
171
|
+
ctx: typer.Context,
|
|
172
|
+
text: str = typer.Argument(..., help="Text to remember."),
|
|
173
|
+
namespace: str = _NS,
|
|
174
|
+
title: str = typer.Option("", "--title"),
|
|
175
|
+
source: str = typer.Option("", "--source"),
|
|
176
|
+
entity_types: str = _ENT,
|
|
177
|
+
relationship_types: str = _REL,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Remember a single piece of text."""
|
|
180
|
+
run_remember(ctx, text, namespace, title, source, entity_types, relationship_types)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("recall")
|
|
184
|
+
def memory_recall(
|
|
185
|
+
ctx: typer.Context,
|
|
186
|
+
query: str = typer.Argument(..., help="Query text."),
|
|
187
|
+
namespace: str = _NS,
|
|
188
|
+
mode: str = typer.Option("hybrid", "--mode", help="hybrid|vector|graph|keyword|all"),
|
|
189
|
+
limit: int = typer.Option(10, "--limit", "-k"),
|
|
190
|
+
min_similarity: float = typer.Option(0.0, "--min-similarity"),
|
|
191
|
+
since: str = typer.Option(None, "--since", help="ISO timestamp lower bound."),
|
|
192
|
+
until: str = typer.Option(None, "--until", help="ISO timestamp upper bound."),
|
|
193
|
+
as_json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
|
|
194
|
+
as_context: bool = typer.Option(False, "--context", help="Emit LLM-ready context text."),
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Recall from memory (semantic / graph / keyword / hybrid)."""
|
|
197
|
+
run_recall(ctx, query, namespace, mode, limit, min_similarity, since, until, as_json, as_context)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("forget")
|
|
201
|
+
def memory_forget(
|
|
202
|
+
ctx: typer.Context,
|
|
203
|
+
document_id: str = typer.Argument(..., help="Document id to forget."),
|
|
204
|
+
namespace: str = _NS,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Forget a document (cascades through its provenance)."""
|
|
207
|
+
cfg = require_project(ctx)
|
|
208
|
+
nsid = resolve_namespace(cfg, namespace)
|
|
209
|
+
client = get_client(ctx)
|
|
210
|
+
try:
|
|
211
|
+
result = client.forget({"document_id": document_id, "namespace": nsid})
|
|
212
|
+
except DeytaClientError as exc:
|
|
213
|
+
render.error(str(exc))
|
|
214
|
+
raise typer.Exit(1) from exc
|
|
215
|
+
if result.get("forgotten"):
|
|
216
|
+
render.success(f"Forgot document {document_id}.")
|
|
217
|
+
else:
|
|
218
|
+
render.info(f"Document {document_id} not found.")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command("ingest")
|
|
222
|
+
def memory_ingest(
|
|
223
|
+
ctx: typer.Context,
|
|
224
|
+
path: Path = typer.Argument(..., help="File or directory to ingest."),
|
|
225
|
+
namespace: str = _NS,
|
|
226
|
+
recursive: bool = typer.Option(True, "--recursive/--no-recursive"),
|
|
227
|
+
include: str = typer.Option(None, "--include", help="Glob (e.g. '**/*.md')."),
|
|
228
|
+
entity_types: str = _ENT,
|
|
229
|
+
relationship_types: str = _REL,
|
|
230
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Ingest files from a path (bulk remember)."""
|
|
233
|
+
run_ingest(ctx, path, namespace, recursive, include, entity_types, relationship_types, dry_run)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""`deyta namespace …` (alias `ns`) — manage namespaces.
|
|
2
|
+
|
|
3
|
+
Khora keys namespaces by UUID; friendly names are a CLI concern, stored as a
|
|
4
|
+
name->UUID map in deyta.toml (and echoed into Khora's config_overrides server-side).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .. import render
|
|
12
|
+
from ..client import DeytaClientError
|
|
13
|
+
from ._common import get_client, require_project
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Manage namespaces.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("create")
|
|
19
|
+
def ns_create(ctx: typer.Context, name: str = typer.Argument(..., help="Friendly name.")) -> None:
|
|
20
|
+
"""Create a namespace and remember its name locally."""
|
|
21
|
+
cfg = require_project(ctx)
|
|
22
|
+
if name in cfg.namespaces:
|
|
23
|
+
render.error(f"Namespace {name!r} already exists locally.")
|
|
24
|
+
raise typer.Exit(1)
|
|
25
|
+
client = get_client(ctx)
|
|
26
|
+
try:
|
|
27
|
+
result = client.create_namespace(name)
|
|
28
|
+
except DeytaClientError as exc:
|
|
29
|
+
render.error(str(exc))
|
|
30
|
+
raise typer.Exit(1) from exc
|
|
31
|
+
nsid = result["namespace_id"]
|
|
32
|
+
cfg.add_namespace(name, nsid)
|
|
33
|
+
if cfg.active is None:
|
|
34
|
+
cfg.active = name
|
|
35
|
+
cfg.save()
|
|
36
|
+
render.success(f"Created namespace {name!r} [dim]({nsid})[/]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("list")
|
|
40
|
+
def ns_list(ctx: typer.Context) -> None:
|
|
41
|
+
"""List namespaces known to the server."""
|
|
42
|
+
cfg = require_project(ctx)
|
|
43
|
+
client = get_client(ctx)
|
|
44
|
+
try:
|
|
45
|
+
namespaces = client.list_namespaces()
|
|
46
|
+
except DeytaClientError as exc:
|
|
47
|
+
render.error(str(exc))
|
|
48
|
+
raise typer.Exit(1) from exc
|
|
49
|
+
# Backfill names from the local alias map for any the server didn't store.
|
|
50
|
+
id_to_name = {v: k for k, v in cfg.namespaces.items()}
|
|
51
|
+
for ns in namespaces:
|
|
52
|
+
ns["name"] = ns.get("name") or id_to_name.get(ns.get("namespace_id"))
|
|
53
|
+
render.namespaces_table(namespaces, cfg.active)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("get")
|
|
57
|
+
def ns_get(ctx: typer.Context, name_or_id: str = typer.Argument(...)) -> None:
|
|
58
|
+
"""Show one namespace."""
|
|
59
|
+
cfg = require_project(ctx)
|
|
60
|
+
client = get_client(ctx)
|
|
61
|
+
nsid = cfg.resolve_namespace(name_or_id)
|
|
62
|
+
try:
|
|
63
|
+
ns = client.get_namespace(nsid)
|
|
64
|
+
except DeytaClientError as exc:
|
|
65
|
+
render.error(str(exc))
|
|
66
|
+
raise typer.Exit(1) from exc
|
|
67
|
+
render.console.print(ns)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("delete")
|
|
71
|
+
def ns_delete(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
name_or_id: str = typer.Argument(...),
|
|
74
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Deactivate a namespace (Khora soft-delete) and drop its local alias."""
|
|
77
|
+
cfg = require_project(ctx)
|
|
78
|
+
if not yes:
|
|
79
|
+
import questionary
|
|
80
|
+
|
|
81
|
+
if not questionary.confirm(f"Delete namespace {name_or_id!r}?", default=False).ask():
|
|
82
|
+
raise typer.Exit(0)
|
|
83
|
+
client = get_client(ctx)
|
|
84
|
+
nsid = cfg.resolve_namespace(name_or_id)
|
|
85
|
+
try:
|
|
86
|
+
client.delete_namespace(nsid)
|
|
87
|
+
except DeytaClientError as exc:
|
|
88
|
+
render.error(str(exc))
|
|
89
|
+
raise typer.Exit(1) from exc
|
|
90
|
+
cfg.remove_namespace(name_or_id)
|
|
91
|
+
cfg.save()
|
|
92
|
+
render.success(f"Deleted namespace {name_or_id!r}.")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("use")
|
|
96
|
+
def ns_use(ctx: typer.Context, name: str = typer.Argument(...)) -> None:
|
|
97
|
+
"""Set the active namespace (so commands don't need --namespace)."""
|
|
98
|
+
cfg = require_project(ctx)
|
|
99
|
+
if name not in cfg.namespaces:
|
|
100
|
+
# Not in the local alias map — the server may still know it (e.g. the
|
|
101
|
+
# namespace was created against a different deyta.toml). Look it up and
|
|
102
|
+
# backfill the alias so future resolution works offline.
|
|
103
|
+
client = get_client(ctx)
|
|
104
|
+
try:
|
|
105
|
+
namespaces = client.list_namespaces()
|
|
106
|
+
except DeytaClientError as exc:
|
|
107
|
+
render.error(str(exc))
|
|
108
|
+
raise typer.Exit(1) from exc
|
|
109
|
+
match = next((ns for ns in namespaces if ns.get("name") == name), None)
|
|
110
|
+
if match is None:
|
|
111
|
+
known = ", ".join(sorted(n for ns in namespaces if (n := ns.get("name")))) or "(none)"
|
|
112
|
+
render.error(f"Unknown namespace {name!r}. Known on server: {known}")
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
cfg.add_namespace(name, match["namespace_id"])
|
|
115
|
+
cfg.active = name
|
|
116
|
+
cfg.save()
|
|
117
|
+
render.success(f"Active namespace is now {name!r}.")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Daemon + whole-stack lifecycle: serve / status / stop / up / down."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .. import config, docker, render
|
|
10
|
+
from ..client import DeytaClient, DeytaClientError
|
|
11
|
+
from ..server import runner
|
|
12
|
+
from ._common import bring_up_datastores, get_host, require_project
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def serve(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
port: int = typer.Option(None, "--port", "-p", help="Port to bind (default from deyta.toml)."),
|
|
18
|
+
detach: bool = typer.Option(False, "--detach", "-d", help="Run in the background."),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Start the Deyta daemon (FastAPI wrapping Khora)."""
|
|
21
|
+
cfg = require_project(ctx)
|
|
22
|
+
bind_port = port or cfg.port
|
|
23
|
+
if detach:
|
|
24
|
+
pid = runner.serve_detached(cfg, bind_port)
|
|
25
|
+
if _wait_until_up(bind_port):
|
|
26
|
+
render.success(
|
|
27
|
+
f"Deyta server started (pid {pid}) on http://localhost:{bind_port}"
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
render.error("Server did not become healthy; check .deyta/server.log")
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
else:
|
|
33
|
+
render.info(f"Deyta server on http://localhost:{bind_port} (Ctrl-C to stop)")
|
|
34
|
+
runner.serve_foreground(cfg, bind_port)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def status(ctx: typer.Context) -> None:
|
|
38
|
+
"""Show whether the daemon is running and how it's configured."""
|
|
39
|
+
client = DeytaClient(get_host(ctx))
|
|
40
|
+
try:
|
|
41
|
+
health = client.health()
|
|
42
|
+
except DeytaClientError as exc:
|
|
43
|
+
render.error(str(exc))
|
|
44
|
+
raise typer.Exit(1) from exc
|
|
45
|
+
render.health_panel(health, client.host)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def stop(ctx: typer.Context) -> None:
|
|
49
|
+
"""Stop a detached daemon started with `deyta serve --detach`."""
|
|
50
|
+
cfg = require_project(ctx)
|
|
51
|
+
try:
|
|
52
|
+
stopped = runner.stop_detached(cfg)
|
|
53
|
+
except RuntimeError as exc:
|
|
54
|
+
render.error(str(exc))
|
|
55
|
+
raise typer.Exit(1) from exc
|
|
56
|
+
if stopped:
|
|
57
|
+
render.success("Deyta server stopped.")
|
|
58
|
+
else:
|
|
59
|
+
render.info("No detached Deyta server recorded for this project.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def up(
|
|
63
|
+
ctx: typer.Context,
|
|
64
|
+
detach: bool = typer.Option(True, "--detach/--no-detach", "-d", help="Background the server."),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Bring up the whole local stack (datastores if postgres, then the server)."""
|
|
67
|
+
cfg = require_project(ctx)
|
|
68
|
+
if cfg.backend == config.BACKEND_POSTGRES:
|
|
69
|
+
bring_up_datastores()
|
|
70
|
+
render.info("Starting server…")
|
|
71
|
+
pid = runner.serve_detached(cfg, cfg.port)
|
|
72
|
+
if _wait_until_up(cfg.port):
|
|
73
|
+
render.success(f"Stack up (server pid {pid}) on http://localhost:{cfg.port}")
|
|
74
|
+
else:
|
|
75
|
+
render.error("Server did not become healthy; check .deyta/server.log")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def down(ctx: typer.Context) -> None:
|
|
80
|
+
"""Tear down the whole local stack."""
|
|
81
|
+
cfg = require_project(ctx)
|
|
82
|
+
try:
|
|
83
|
+
stopped = runner.stop_detached(cfg)
|
|
84
|
+
except RuntimeError as exc:
|
|
85
|
+
render.error(str(exc))
|
|
86
|
+
raise typer.Exit(1) from exc
|
|
87
|
+
render.info("Server stopped." if stopped else "No detached server running.")
|
|
88
|
+
if cfg.backend == config.BACKEND_POSTGRES:
|
|
89
|
+
try:
|
|
90
|
+
docker.down()
|
|
91
|
+
render.info("Datastores stopped.")
|
|
92
|
+
except docker.DockerError as exc:
|
|
93
|
+
render.error(str(exc))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _wait_until_up(port: int, attempts: int = 60) -> bool:
|
|
97
|
+
client = DeytaClient(f"http://localhost:{port}")
|
|
98
|
+
for _ in range(attempts):
|
|
99
|
+
if client.is_up():
|
|
100
|
+
return True
|
|
101
|
+
time.sleep(0.5)
|
|
102
|
+
return False
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""`deyta update` — upgrade whichever of the CLI / Khora has a newer release."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .. import render, versions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def update(
|
|
13
|
+
ctx: typer.Context,
|
|
14
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
15
|
+
dry_run: bool = typer.Option(
|
|
16
|
+
False, "--dry-run", help="Show what would be upgraded without running it."
|
|
17
|
+
),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Upgrade the Deyta CLI and/or Khora to the latest published versions."""
|
|
20
|
+
with render.console.status("[bold]Checking PyPI for newer releases…[/]", spinner="dots"):
|
|
21
|
+
statuses = versions.gather(check_remote=True)
|
|
22
|
+
render.versions_report(statuses)
|
|
23
|
+
|
|
24
|
+
outdated = [s for s in statuses if s.outdated]
|
|
25
|
+
if not outdated:
|
|
26
|
+
render.success("Everything is already up to date.")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
installer = versions.detect_installer()
|
|
30
|
+
if installer is versions.Installer.EDITABLE:
|
|
31
|
+
render.info(
|
|
32
|
+
"\nThis is an editable/dev install. Update it from the source checkout:\n"
|
|
33
|
+
" [bold]git pull && uv sync[/]"
|
|
34
|
+
)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
commands = versions.upgrade_commands(installer, [s.name for s in outdated])
|
|
38
|
+
if not commands:
|
|
39
|
+
render.error(
|
|
40
|
+
"Couldn't determine how to upgrade this installation. "
|
|
41
|
+
"Upgrade `deyta-cli` (and `khora`) with your package manager manually."
|
|
42
|
+
)
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
names = " and ".join(s.label for s in outdated)
|
|
46
|
+
render.info(f"\nWill upgrade {names} via [bold]{installer.value}[/]:")
|
|
47
|
+
for cmd in commands:
|
|
48
|
+
render.info(f" [dim]$[/] {' '.join(cmd)}")
|
|
49
|
+
|
|
50
|
+
if dry_run:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if not yes:
|
|
54
|
+
import questionary
|
|
55
|
+
|
|
56
|
+
if not questionary.confirm("Proceed with the upgrade?", default=True).ask():
|
|
57
|
+
render.info("Aborted.")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
for cmd in commands:
|
|
61
|
+
result = subprocess.run(cmd)
|
|
62
|
+
if result.returncode != 0:
|
|
63
|
+
render.error(f"Upgrade command failed: {' '.join(cmd)}")
|
|
64
|
+
raise typer.Exit(result.returncode)
|
|
65
|
+
|
|
66
|
+
render.success(f"Upgraded {names}. Run `deyta version` to confirm.")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""`deyta version` — show installed CLI/Khora versions and flag updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .. import render, versions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def version(
|
|
11
|
+
ctx: typer.Context,
|
|
12
|
+
check: bool = typer.Option(
|
|
13
|
+
True,
|
|
14
|
+
"--check/--no-check",
|
|
15
|
+
help="Check PyPI for newer releases (network). Use --no-check to stay offline.",
|
|
16
|
+
),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Show installed Deyta CLI and Khora versions, and whether updates exist."""
|
|
19
|
+
if check:
|
|
20
|
+
with render.console.status("[bold]Checking PyPI for newer releases…[/]", spinner="dots"):
|
|
21
|
+
statuses = versions.gather(check_remote=True)
|
|
22
|
+
else:
|
|
23
|
+
statuses = versions.gather(check_remote=False)
|
|
24
|
+
|
|
25
|
+
render.banner()
|
|
26
|
+
render.versions_report(statuses)
|
|
27
|
+
|
|
28
|
+
outdated = [s for s in statuses if s.outdated]
|
|
29
|
+
if outdated:
|
|
30
|
+
names = " and ".join(s.label for s in outdated)
|
|
31
|
+
render.info(
|
|
32
|
+
f"\n[yellow]Update available[/] for {names}. "
|
|
33
|
+
f"Run [bold]deyta update[/] to upgrade."
|
|
34
|
+
)
|
|
35
|
+
elif check:
|
|
36
|
+
render.success("Everything is up to date.")
|