kanbaroo-cli 0.2.2__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.
- kanbaroo_cli/__init__.py +5 -0
- kanbaroo_cli/app.py +114 -0
- kanbaroo_cli/client.py +304 -0
- kanbaroo_cli/commands/__init__.py +6 -0
- kanbaroo_cli/commands/audit.py +123 -0
- kanbaroo_cli/commands/backup.py +78 -0
- kanbaroo_cli/commands/epic.py +323 -0
- kanbaroo_cli/commands/export.py +81 -0
- kanbaroo_cli/commands/init.py +160 -0
- kanbaroo_cli/commands/server.py +133 -0
- kanbaroo_cli/commands/story.py +627 -0
- kanbaroo_cli/commands/tag.py +252 -0
- kanbaroo_cli/commands/token.py +150 -0
- kanbaroo_cli/commands/workspace.py +263 -0
- kanbaroo_cli/config.py +233 -0
- kanbaroo_cli/context.py +104 -0
- kanbaroo_cli/rendering.py +83 -0
- kanbaroo_cli/resolvers.py +185 -0
- kanbaroo_cli/similar.py +67 -0
- kanbaroo_cli-0.2.2.dist-info/METADATA +26 -0
- kanbaroo_cli-0.2.2.dist-info/RECORD +23 -0
- kanbaroo_cli-0.2.2.dist-info/WHEEL +4 -0
- kanbaroo_cli-0.2.2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of ``kb epic``.
|
|
3
|
+
|
|
4
|
+
Wraps ``/workspaces/{id}/epics`` and the epic id-addressed endpoints.
|
|
5
|
+
``show``, ``close``, and ``reopen`` all accept a ``{KEY}-{N}`` handle
|
|
6
|
+
and translate via ``GET /epics/by-key/{human_id}`` (added this cage)
|
|
7
|
+
so the user never has to type a UUID.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from kanbaroo_cli.client import ApiClient, ApiError, ApiRequestError
|
|
17
|
+
from kanbaroo_cli.context import build_client, require_config
|
|
18
|
+
from kanbaroo_cli.rendering import (
|
|
19
|
+
exit_on_api_error,
|
|
20
|
+
print_json,
|
|
21
|
+
print_table,
|
|
22
|
+
stderr_console,
|
|
23
|
+
stdout_console,
|
|
24
|
+
)
|
|
25
|
+
from kanbaroo_cli.resolvers import (
|
|
26
|
+
require_effective_workspace,
|
|
27
|
+
resolve_epic,
|
|
28
|
+
resolve_workspace,
|
|
29
|
+
try_resolve_other,
|
|
30
|
+
)
|
|
31
|
+
from kanbaroo_cli.similar import fetch_similar_entities, print_similar_entities
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="epic",
|
|
35
|
+
help="Create, list, and transition epics.",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _epic_rows(items: list[dict[str, object]]) -> list[list[str]]:
|
|
41
|
+
"""
|
|
42
|
+
Format a list of epic bodies into table rows.
|
|
43
|
+
"""
|
|
44
|
+
return [
|
|
45
|
+
[
|
|
46
|
+
str(item["human_id"]),
|
|
47
|
+
str(item["title"]),
|
|
48
|
+
str(item["state"]),
|
|
49
|
+
"yes" if item["deleted_at"] else "no",
|
|
50
|
+
]
|
|
51
|
+
for item in items
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("list")
|
|
56
|
+
def list_epics(
|
|
57
|
+
workspace: str | None = typer.Option(
|
|
58
|
+
None,
|
|
59
|
+
"--workspace",
|
|
60
|
+
help=(
|
|
61
|
+
"Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
|
|
62
|
+
"and default_workspace from config."
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
include_deleted: bool = typer.Option(
|
|
66
|
+
False,
|
|
67
|
+
"--include-deleted",
|
|
68
|
+
help="Include soft-deleted epics in the listing.",
|
|
69
|
+
),
|
|
70
|
+
as_json: bool = typer.Option(
|
|
71
|
+
False,
|
|
72
|
+
"--json",
|
|
73
|
+
help="Emit machine-readable JSON instead of a Rich table.",
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
List every epic in a workspace.
|
|
78
|
+
"""
|
|
79
|
+
config = require_config()
|
|
80
|
+
workspace_ref = require_effective_workspace(workspace, config)
|
|
81
|
+
items: list[dict[str, object]] = []
|
|
82
|
+
with build_client(config) as client:
|
|
83
|
+
try:
|
|
84
|
+
ws = resolve_workspace(client, workspace_ref)
|
|
85
|
+
cursor: str | None = None
|
|
86
|
+
while True:
|
|
87
|
+
params: dict[str, object] = {"limit": 200}
|
|
88
|
+
if include_deleted:
|
|
89
|
+
params["include_deleted"] = True
|
|
90
|
+
if cursor is not None:
|
|
91
|
+
params["cursor"] = cursor
|
|
92
|
+
response = client.get(
|
|
93
|
+
f"/workspaces/{ws['id']}/epics",
|
|
94
|
+
params=params,
|
|
95
|
+
)
|
|
96
|
+
body = response.json()
|
|
97
|
+
items.extend(body["items"])
|
|
98
|
+
cursor = body.get("next_cursor")
|
|
99
|
+
if cursor is None:
|
|
100
|
+
break
|
|
101
|
+
except ApiError as exc:
|
|
102
|
+
exit_on_api_error(exc)
|
|
103
|
+
|
|
104
|
+
if as_json:
|
|
105
|
+
print_json(items)
|
|
106
|
+
return
|
|
107
|
+
print_table(
|
|
108
|
+
columns=["human_id", "title", "state", "deleted"],
|
|
109
|
+
rows=_epic_rows(items),
|
|
110
|
+
title=f"epics in {ws['key']}",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command("create")
|
|
115
|
+
def create_epic(
|
|
116
|
+
workspace: str | None = typer.Option(
|
|
117
|
+
None,
|
|
118
|
+
"--workspace",
|
|
119
|
+
help=(
|
|
120
|
+
"Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
|
|
121
|
+
"and default_workspace from config."
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
title: str = typer.Option(..., "--title", help="Epic title."),
|
|
125
|
+
description: str | None = typer.Option(
|
|
126
|
+
None,
|
|
127
|
+
"--description",
|
|
128
|
+
help="Optional markdown description.",
|
|
129
|
+
),
|
|
130
|
+
force: bool = typer.Option(
|
|
131
|
+
False,
|
|
132
|
+
"--force",
|
|
133
|
+
help="Skip the duplicate-title prompt and create the epic regardless.",
|
|
134
|
+
),
|
|
135
|
+
as_json: bool = typer.Option(
|
|
136
|
+
False,
|
|
137
|
+
"--json",
|
|
138
|
+
help="Emit the created epic as JSON instead of a Rich table.",
|
|
139
|
+
),
|
|
140
|
+
) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Create a new epic in ``workspace``.
|
|
143
|
+
|
|
144
|
+
Mirrors ``kb story create``: a normalised-title check runs against
|
|
145
|
+
the workspace before posting and warns the user about likely
|
|
146
|
+
duplicates. ``--force`` skips the prompt; ``--json`` never prompts
|
|
147
|
+
but folds the matches into a ``warnings`` field.
|
|
148
|
+
"""
|
|
149
|
+
config = require_config()
|
|
150
|
+
workspace_ref = require_effective_workspace(workspace, config)
|
|
151
|
+
payload: dict[str, object] = {"title": title}
|
|
152
|
+
if description is not None:
|
|
153
|
+
payload["description"] = description
|
|
154
|
+
similar: list[dict[str, Any]] = []
|
|
155
|
+
with build_client(config) as client:
|
|
156
|
+
try:
|
|
157
|
+
ws = resolve_workspace(client, workspace_ref)
|
|
158
|
+
similar = fetch_similar_entities(
|
|
159
|
+
client,
|
|
160
|
+
workspace_id=str(ws["id"]),
|
|
161
|
+
resource="epics",
|
|
162
|
+
field_name="title",
|
|
163
|
+
value=title,
|
|
164
|
+
)
|
|
165
|
+
if similar and not as_json and not force:
|
|
166
|
+
print_similar_entities(similar, label_key="human_id", entity="epic")
|
|
167
|
+
confirmed = typer.confirm("Create anyway?", default=False)
|
|
168
|
+
if not confirmed:
|
|
169
|
+
stdout_console.print("aborted: existing entity has a similar name")
|
|
170
|
+
raise typer.Exit(code=1)
|
|
171
|
+
response = client.post(
|
|
172
|
+
f"/workspaces/{ws['id']}/epics",
|
|
173
|
+
json=payload,
|
|
174
|
+
)
|
|
175
|
+
except ApiError as exc:
|
|
176
|
+
exit_on_api_error(exc)
|
|
177
|
+
body = response.json()
|
|
178
|
+
|
|
179
|
+
if as_json:
|
|
180
|
+
if similar:
|
|
181
|
+
body = {**body, "warnings": {"similar": [s["id"] for s in similar]}}
|
|
182
|
+
print_json(body)
|
|
183
|
+
return
|
|
184
|
+
print_table(
|
|
185
|
+
columns=["field", "value"],
|
|
186
|
+
rows=[
|
|
187
|
+
["human_id", body["human_id"]],
|
|
188
|
+
["title", body["title"]],
|
|
189
|
+
["state", body["state"]],
|
|
190
|
+
["id", body["id"]],
|
|
191
|
+
],
|
|
192
|
+
title=f"created epic {body['human_id']}",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command("show")
|
|
197
|
+
def show_epic(
|
|
198
|
+
ref: str = typer.Argument(..., help="Epic handle (e.g. KAN-4) or UUID."),
|
|
199
|
+
as_json: bool = typer.Option(
|
|
200
|
+
False,
|
|
201
|
+
"--json",
|
|
202
|
+
help="Emit the epic body as JSON instead of a Rich table.",
|
|
203
|
+
),
|
|
204
|
+
) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Show a single epic by its human id or UUID.
|
|
207
|
+
|
|
208
|
+
A 404 from the epic lookup falls through to a story lookup with
|
|
209
|
+
the same ref; if that hits, the user gets a hint to run
|
|
210
|
+
``kb story show`` instead of a bare "not found" message.
|
|
211
|
+
"""
|
|
212
|
+
config = require_config()
|
|
213
|
+
with build_client(config) as client:
|
|
214
|
+
try:
|
|
215
|
+
body = resolve_epic(client, ref)
|
|
216
|
+
except ApiRequestError as exc:
|
|
217
|
+
if exc.status_code == 404:
|
|
218
|
+
_suggest_alternative(client, ref, missing="epic", alternative="story")
|
|
219
|
+
raise typer.Exit(code=1) from exc
|
|
220
|
+
exit_on_api_error(exc)
|
|
221
|
+
except ApiError as exc:
|
|
222
|
+
exit_on_api_error(exc)
|
|
223
|
+
|
|
224
|
+
if as_json:
|
|
225
|
+
print_json(body)
|
|
226
|
+
return
|
|
227
|
+
print_table(
|
|
228
|
+
columns=["field", "value"],
|
|
229
|
+
rows=[
|
|
230
|
+
["human_id", body["human_id"]],
|
|
231
|
+
["title", body["title"]],
|
|
232
|
+
["state", body["state"]],
|
|
233
|
+
["description", body["description"] or ""],
|
|
234
|
+
["id", body["id"]],
|
|
235
|
+
["version", str(body["version"])],
|
|
236
|
+
["created_at", body["created_at"]],
|
|
237
|
+
["updated_at", body["updated_at"]],
|
|
238
|
+
["deleted_at", body["deleted_at"] or ""],
|
|
239
|
+
],
|
|
240
|
+
title=f"epic {body['human_id']}",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _transition(ref: str, action: str, *, as_json: bool) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Shared implementation of ``close`` and ``reopen``.
|
|
247
|
+
|
|
248
|
+
Both endpoints require ``If-Match``; we fetch the epic first to
|
|
249
|
+
learn its current version and then POST to the action path.
|
|
250
|
+
"""
|
|
251
|
+
config = require_config()
|
|
252
|
+
with build_client(config) as client:
|
|
253
|
+
try:
|
|
254
|
+
epic = resolve_epic(client, ref)
|
|
255
|
+
response = client.post_with_etag(
|
|
256
|
+
f"/epics/{epic['id']}",
|
|
257
|
+
f"/epics/{epic['id']}/{action}",
|
|
258
|
+
)
|
|
259
|
+
except ApiError as exc:
|
|
260
|
+
exit_on_api_error(exc)
|
|
261
|
+
body = response.json()
|
|
262
|
+
|
|
263
|
+
if as_json:
|
|
264
|
+
print_json(body)
|
|
265
|
+
return
|
|
266
|
+
print_table(
|
|
267
|
+
columns=["field", "value"],
|
|
268
|
+
rows=[
|
|
269
|
+
["human_id", body["human_id"]],
|
|
270
|
+
["title", body["title"]],
|
|
271
|
+
["state", body["state"]],
|
|
272
|
+
["version", str(body["version"])],
|
|
273
|
+
],
|
|
274
|
+
title=f"{action}d epic {body['human_id']}",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command("close")
|
|
279
|
+
def close_epic(
|
|
280
|
+
ref: str = typer.Argument(..., help="Epic handle (e.g. KAN-4) or UUID."),
|
|
281
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
282
|
+
) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Close an epic.
|
|
285
|
+
"""
|
|
286
|
+
_transition(ref, "close", as_json=as_json)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command("reopen")
|
|
290
|
+
def reopen_epic(
|
|
291
|
+
ref: str = typer.Argument(..., help="Epic handle (e.g. KAN-4) or UUID."),
|
|
292
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
293
|
+
) -> None:
|
|
294
|
+
"""
|
|
295
|
+
Reopen a closed epic.
|
|
296
|
+
"""
|
|
297
|
+
_transition(ref, "reopen", as_json=as_json)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _suggest_alternative(
|
|
301
|
+
client: ApiClient,
|
|
302
|
+
ref: str,
|
|
303
|
+
*,
|
|
304
|
+
missing: str,
|
|
305
|
+
alternative: str,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""
|
|
308
|
+
Print a Rich error explaining that ``ref`` is the other entity type.
|
|
309
|
+
|
|
310
|
+
Mirrors the helper in ``kanbaroo_cli.commands.story`` so both
|
|
311
|
+
``show`` commands offer the same "this looks like a story / epic"
|
|
312
|
+
hint when the user picks the wrong one.
|
|
313
|
+
"""
|
|
314
|
+
stderr_console.print(
|
|
315
|
+
f"[red]Error (404 not_found):[/red] {missing} {ref!r} not found."
|
|
316
|
+
)
|
|
317
|
+
other = try_resolve_other(client, ref, other=alternative)
|
|
318
|
+
if other is None:
|
|
319
|
+
return
|
|
320
|
+
handle = other.get("human_id", ref)
|
|
321
|
+
stderr_console.print(
|
|
322
|
+
f"{handle} is a {alternative} - try `kb {alternative} show {handle}`."
|
|
323
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of ``kb export``.
|
|
3
|
+
|
|
4
|
+
Hits ``GET /api/v1/workspaces/{id}/export`` which is cage K's
|
|
5
|
+
responsibility. The CLI-side implementation here is correct in its
|
|
6
|
+
own right: resolve the workspace, stream the response body to
|
|
7
|
+
``<output>/<workspace_key>-<timestamp>.tar.gz``, and surface clear
|
|
8
|
+
errors when the endpoint is not yet available.
|
|
9
|
+
|
|
10
|
+
Until the export endpoint lands the server returns 404 (or another
|
|
11
|
+
canonical error envelope) and ``kb export`` prints a friendly
|
|
12
|
+
``export endpoint not available yet`` message instead of a traceback.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from kanbaroo_cli.client import ApiError, ApiRequestError, ApiTransportError
|
|
24
|
+
from kanbaroo_cli.context import build_client, require_config
|
|
25
|
+
from kanbaroo_cli.rendering import (
|
|
26
|
+
exit_on_api_error,
|
|
27
|
+
stderr_console,
|
|
28
|
+
stdout_console,
|
|
29
|
+
)
|
|
30
|
+
from kanbaroo_cli.resolvers import require_effective_workspace, resolve_workspace
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def export_command(
|
|
34
|
+
workspace: str | None = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--workspace",
|
|
37
|
+
help=(
|
|
38
|
+
"Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
|
|
39
|
+
"and default_workspace from config."
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
output: Path = typer.Option(
|
|
43
|
+
...,
|
|
44
|
+
"--output",
|
|
45
|
+
help="Directory to write the exported archive into.",
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Download a workspace export archive into ``output``.
|
|
50
|
+
"""
|
|
51
|
+
config = require_config()
|
|
52
|
+
workspace_ref = require_effective_workspace(workspace, config)
|
|
53
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with build_client(config) as client:
|
|
55
|
+
try:
|
|
56
|
+
ws = resolve_workspace(client, workspace_ref)
|
|
57
|
+
except ApiError as exc:
|
|
58
|
+
exit_on_api_error(exc)
|
|
59
|
+
|
|
60
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
61
|
+
dest = output / f"{ws['key']}-{timestamp}.tar.gz"
|
|
62
|
+
try:
|
|
63
|
+
response = client.get(f"/workspaces/{ws['id']}/export")
|
|
64
|
+
except ApiRequestError as exc:
|
|
65
|
+
if exc.status_code == 404:
|
|
66
|
+
stderr_console.print(
|
|
67
|
+
"[red]Error:[/red] export endpoint not available yet. "
|
|
68
|
+
"This server does not expose /workspaces/{id}/export; "
|
|
69
|
+
"the feature arrives in a later milestone."
|
|
70
|
+
)
|
|
71
|
+
raise typer.Exit(code=1) from exc
|
|
72
|
+
exit_on_api_error(exc)
|
|
73
|
+
except ApiTransportError as exc:
|
|
74
|
+
exit_on_api_error(exc)
|
|
75
|
+
except httpx.HTTPError as exc:
|
|
76
|
+
stderr_console.print(f"[red]Error:[/red] {exc}")
|
|
77
|
+
raise typer.Exit(code=1) from exc
|
|
78
|
+
|
|
79
|
+
dest.write_bytes(response.content)
|
|
80
|
+
|
|
81
|
+
stdout_console.print(f"wrote [bold]{dest}[/bold].")
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of ``kb init``.
|
|
3
|
+
|
|
4
|
+
Creates ``$KANBAROO_CONFIG_DIR`` (defaulting to ``~/.kanbaroo``), runs
|
|
5
|
+
Alembic migrations to stand up the SQLite database, issues the first
|
|
6
|
+
personal API token, writes ``config.toml``, and prints the plaintext
|
|
7
|
+
token exactly once.
|
|
8
|
+
|
|
9
|
+
The command is idempotent-adjacent: running it twice against an existing
|
|
10
|
+
config.toml is an error unless ``--force`` is passed. ``--force`` issues
|
|
11
|
+
a fresh token and rewrites the config but never revokes existing tokens
|
|
12
|
+
in the database; the user is free to revoke old tokens later via
|
|
13
|
+
``kb token`` (milestone 10).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
23
|
+
|
|
24
|
+
from kanbaroo_core import ActorType
|
|
25
|
+
from kanbaroo_core.auth import create_token
|
|
26
|
+
from kanbaroo_core.db import engine_for_url
|
|
27
|
+
from kanbaroo_core.migrations import upgrade_to_head
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_config_dir() -> Path:
|
|
33
|
+
"""
|
|
34
|
+
Resolve the config directory from ``KANBAROO_CONFIG_DIR`` or fall
|
|
35
|
+
back to ``$HOME/.kanbaroo``.
|
|
36
|
+
"""
|
|
37
|
+
override = os.environ.get("KANBAROO_CONFIG_DIR")
|
|
38
|
+
if override:
|
|
39
|
+
return Path(override)
|
|
40
|
+
return Path.home() / ".kanbaroo"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_database_url(config_dir: Path) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Resolve the database URL from ``KANBAROO_DATABASE_URL`` or fall
|
|
46
|
+
back to a SQLite file inside ``config_dir``.
|
|
47
|
+
"""
|
|
48
|
+
override = os.environ.get("KANBAROO_DATABASE_URL")
|
|
49
|
+
if override:
|
|
50
|
+
return override
|
|
51
|
+
return f"sqlite:///{config_dir / 'kanbaroo.db'}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _default_actor_id() -> str:
|
|
55
|
+
"""
|
|
56
|
+
Default the initial token's actor id to the OS user, or
|
|
57
|
+
``"human"`` if ``$USER`` is not set (e.g. minimal containers).
|
|
58
|
+
"""
|
|
59
|
+
return os.environ.get("USER") or "human"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _render_config_toml(*, database_url: str, api_url: str, token: str) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Render the on-disk ``config.toml`` contents.
|
|
65
|
+
|
|
66
|
+
Kept deliberately hand-crafted rather than pulling in a TOML writer:
|
|
67
|
+
the file has three scalar keys and the escaping surface is tiny
|
|
68
|
+
(double-quoted strings, backslash-escape internal quotes).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def _escape(value: str) -> str:
|
|
72
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
f'database_url = "{_escape(database_url)}"\n'
|
|
76
|
+
f'api_url = "{_escape(api_url)}"\n'
|
|
77
|
+
f'token = "{_escape(token)}"\n'
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def init(
|
|
82
|
+
actor_id: str = typer.Option(
|
|
83
|
+
None,
|
|
84
|
+
"--actor-id",
|
|
85
|
+
help="Label stamped on mutations from this token. Defaults to $USER.",
|
|
86
|
+
),
|
|
87
|
+
name: str = typer.Option(
|
|
88
|
+
"personal",
|
|
89
|
+
"--name",
|
|
90
|
+
help="Human-readable token name (shown in token lists).",
|
|
91
|
+
),
|
|
92
|
+
force: bool = typer.Option(
|
|
93
|
+
False,
|
|
94
|
+
"--force",
|
|
95
|
+
help="Overwrite an existing config.toml and issue a new token.",
|
|
96
|
+
),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Initialise Kanbaroo's local config, database, and first API token.
|
|
100
|
+
"""
|
|
101
|
+
config_dir = _default_config_dir()
|
|
102
|
+
database_url = _default_database_url(config_dir)
|
|
103
|
+
resolved_actor_id = actor_id or _default_actor_id()
|
|
104
|
+
|
|
105
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
config_path = config_dir / "config.toml"
|
|
107
|
+
|
|
108
|
+
if config_path.exists() and not force:
|
|
109
|
+
console.print(
|
|
110
|
+
f"[red]Error:[/red] {config_path} already exists. "
|
|
111
|
+
"Re-run with [bold]--force[/bold] to overwrite the config "
|
|
112
|
+
"and issue a new token."
|
|
113
|
+
)
|
|
114
|
+
raise typer.Exit(code=1)
|
|
115
|
+
|
|
116
|
+
upgrade_to_head(database_url)
|
|
117
|
+
|
|
118
|
+
engine = engine_for_url(database_url)
|
|
119
|
+
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
|
120
|
+
session: Session = factory()
|
|
121
|
+
try:
|
|
122
|
+
_, plaintext = create_token(
|
|
123
|
+
session,
|
|
124
|
+
actor_type=ActorType.HUMAN,
|
|
125
|
+
actor_id=resolved_actor_id,
|
|
126
|
+
name=name,
|
|
127
|
+
)
|
|
128
|
+
session.commit()
|
|
129
|
+
finally:
|
|
130
|
+
session.close()
|
|
131
|
+
engine.dispose()
|
|
132
|
+
|
|
133
|
+
api_url = "http://localhost:8080"
|
|
134
|
+
config_path.write_text(
|
|
135
|
+
_render_config_toml(
|
|
136
|
+
database_url=database_url,
|
|
137
|
+
api_url=api_url,
|
|
138
|
+
token=plaintext,
|
|
139
|
+
),
|
|
140
|
+
encoding="utf-8",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
warning = (
|
|
144
|
+
"[bold yellow]Save this token now. "
|
|
145
|
+
"It will not be shown again.[/bold yellow]\n\n"
|
|
146
|
+
)
|
|
147
|
+
body = (
|
|
148
|
+
f"[bold]Token:[/bold] [green]{plaintext}[/green]\n"
|
|
149
|
+
f"[bold]Config path:[/bold] {config_path}\n"
|
|
150
|
+
f"[bold]Database URL:[/bold] {database_url}\n"
|
|
151
|
+
f"[bold]Actor:[/bold] human / {resolved_actor_id}\n"
|
|
152
|
+
f"[bold]Token name:[/bold] {name}"
|
|
153
|
+
)
|
|
154
|
+
console.print(
|
|
155
|
+
Panel.fit(
|
|
156
|
+
warning + body,
|
|
157
|
+
title="Kanbaroo initialized",
|
|
158
|
+
border_style="green",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of ``kb server``.
|
|
3
|
+
|
|
4
|
+
Thin wrappers around ``docker compose up -d`` and ``docker compose
|
|
5
|
+
down``. ``--wait`` on ``start`` polls ``/api/v1/workspaces`` until the
|
|
6
|
+
server responds or 30 seconds elapse.
|
|
7
|
+
|
|
8
|
+
We shell out via :func:`subprocess.run` rather than importing a
|
|
9
|
+
docker-compose Python binding because the binding surface is messy and
|
|
10
|
+
users already have ``docker compose`` on PATH as the supported entry
|
|
11
|
+
point per ``docker-compose.yml`` at the project root. When
|
|
12
|
+
``$KANBAROO_COMPOSE_FILE`` is set we pass ``-f`` so the caller can
|
|
13
|
+
target a non-default file (useful for CI).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from kanbaroo_cli.client import API_PREFIX
|
|
27
|
+
from kanbaroo_cli.context import require_config_api_only
|
|
28
|
+
from kanbaroo_cli.rendering import stderr_console, stdout_console
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="server",
|
|
32
|
+
help="Start or stop the Kanbaroo API server via docker compose.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_READY_TIMEOUT_SECONDS = 30.0
|
|
37
|
+
_READY_POLL_SECONDS = 0.5
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _compose_command(subcommand: list[str]) -> list[str]:
|
|
41
|
+
"""
|
|
42
|
+
Build the ``docker compose`` command line, injecting
|
|
43
|
+
``-f $KANBAROO_COMPOSE_FILE`` when the override is set.
|
|
44
|
+
"""
|
|
45
|
+
argv: list[str] = ["docker", "compose"]
|
|
46
|
+
compose_file = os.environ.get("KANBAROO_COMPOSE_FILE")
|
|
47
|
+
if compose_file:
|
|
48
|
+
argv.extend(["-f", compose_file])
|
|
49
|
+
argv.extend(subcommand)
|
|
50
|
+
return argv
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_compose(subcommand: list[str]) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Invoke docker compose with the given subcommand and check the
|
|
56
|
+
result. Any non-zero exit propagates to the user.
|
|
57
|
+
"""
|
|
58
|
+
subprocess.run(_compose_command(subcommand), check=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _poll_until_ready(
|
|
62
|
+
*,
|
|
63
|
+
base_url: str,
|
|
64
|
+
token: str,
|
|
65
|
+
timeout_seconds: float = _READY_TIMEOUT_SECONDS,
|
|
66
|
+
) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
Poll ``GET {base_url}{API_PREFIX}/workspaces`` until it responds
|
|
69
|
+
with a 2xx or ``timeout_seconds`` elapses.
|
|
70
|
+
|
|
71
|
+
Returns ``True`` on success, ``False`` on timeout.
|
|
72
|
+
"""
|
|
73
|
+
deadline = time.monotonic() + timeout_seconds
|
|
74
|
+
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
|
|
75
|
+
url = f"{base_url.rstrip('/')}{API_PREFIX}/workspaces"
|
|
76
|
+
with httpx.Client(timeout=2.0) as probe:
|
|
77
|
+
while time.monotonic() < deadline:
|
|
78
|
+
try:
|
|
79
|
+
response = probe.get(url, headers=headers)
|
|
80
|
+
if 200 <= response.status_code < 300:
|
|
81
|
+
return True
|
|
82
|
+
except httpx.HTTPError:
|
|
83
|
+
pass
|
|
84
|
+
time.sleep(_READY_POLL_SECONDS)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("start")
|
|
89
|
+
def start_server(
|
|
90
|
+
wait: bool = typer.Option(
|
|
91
|
+
False,
|
|
92
|
+
"--wait",
|
|
93
|
+
help="Poll the API until it responds or 30 seconds elapse.",
|
|
94
|
+
),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Start the server (``docker compose up -d``).
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
_run_compose(["up", "-d"])
|
|
101
|
+
except subprocess.CalledProcessError as exc:
|
|
102
|
+
stderr_console.print(
|
|
103
|
+
f"[red]Error:[/red] docker compose up failed (exit {exc.returncode})."
|
|
104
|
+
)
|
|
105
|
+
raise typer.Exit(code=exc.returncode) from exc
|
|
106
|
+
|
|
107
|
+
if wait:
|
|
108
|
+
config = require_config_api_only()
|
|
109
|
+
if not _poll_until_ready(
|
|
110
|
+
base_url=config.api_url,
|
|
111
|
+
token=config.token,
|
|
112
|
+
):
|
|
113
|
+
stderr_console.print(
|
|
114
|
+
"[red]Error:[/red] server did not respond within 30 seconds."
|
|
115
|
+
)
|
|
116
|
+
raise typer.Exit(code=1)
|
|
117
|
+
|
|
118
|
+
stdout_console.print("server started.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("stop")
|
|
122
|
+
def stop_server() -> None:
|
|
123
|
+
"""
|
|
124
|
+
Stop the server (``docker compose down``).
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
_run_compose(["down"])
|
|
128
|
+
except subprocess.CalledProcessError as exc:
|
|
129
|
+
stderr_console.print(
|
|
130
|
+
f"[red]Error:[/red] docker compose down failed (exit {exc.returncode})."
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(code=exc.returncode) from exc
|
|
133
|
+
stdout_console.print("server stopped.")
|