blog-cli 0.2.4__tar.gz → 0.3.0__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.
- {blog_cli-0.2.4 → blog_cli-0.3.0}/PKG-INFO +2 -1
- {blog_cli-0.2.4 → blog_cli-0.3.0}/pyproject.toml +2 -1
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/blog_cli.egg-info/PKG-INFO +2 -1
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/blog_cli.egg-info/SOURCES.txt +2 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/blog_cli.egg-info/requires.txt +1 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/personal_cli/cli.py +145 -20
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/personal_cli/client.py +5 -36
- blog_cli-0.3.0/src/personal_cli/credentials.py +100 -0
- blog_cli-0.3.0/src/personal_cli/setup_server.py +228 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/tests/test_cli.py +131 -4
- {blog_cli-0.2.4 → blog_cli-0.3.0}/README.md +0 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/setup.cfg +0 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/blog_cli.egg-info/dependency_links.txt +0 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/blog_cli.egg-info/entry_points.txt +0 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/blog_cli.egg-info/top_level.txt +0 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/personal_cli/__init__.py +0 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/personal_cli/__main__.py +0 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/src/personal_cli/formatting.py +0 -0
- {blog_cli-0.2.4 → blog_cli-0.3.0}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: blog-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links.
|
|
5
5
|
Author: tanaka-mambinge
|
|
6
6
|
License: MIT
|
|
@@ -14,6 +14,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
14
14
|
Requires-Python: >=3.13
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
Requires-Dist: httpx>=0.28
|
|
17
|
+
Requires-Dist: keyring>=25
|
|
17
18
|
Requires-Dist: typer>=0.16
|
|
18
19
|
Provides-Extra: dev
|
|
19
20
|
Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "blog-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -19,6 +19,7 @@ classifiers = [
|
|
|
19
19
|
]
|
|
20
20
|
dependencies = [
|
|
21
21
|
"httpx>=0.28",
|
|
22
|
+
"keyring>=25",
|
|
22
23
|
"typer>=0.16",
|
|
23
24
|
]
|
|
24
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: blog-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links.
|
|
5
5
|
Author: tanaka-mambinge
|
|
6
6
|
License: MIT
|
|
@@ -14,6 +14,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
14
14
|
Requires-Python: >=3.13
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
Requires-Dist: httpx>=0.28
|
|
17
|
+
Requires-Dist: keyring>=25
|
|
17
18
|
Requires-Dist: typer>=0.16
|
|
18
19
|
Provides-Extra: dev
|
|
19
20
|
Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
|
|
@@ -10,6 +10,8 @@ src/personal_cli/__init__.py
|
|
|
10
10
|
src/personal_cli/__main__.py
|
|
11
11
|
src/personal_cli/cli.py
|
|
12
12
|
src/personal_cli/client.py
|
|
13
|
+
src/personal_cli/credentials.py
|
|
13
14
|
src/personal_cli/formatting.py
|
|
15
|
+
src/personal_cli/setup_server.py
|
|
14
16
|
tests/test_cli.py
|
|
15
17
|
tests/test_client.py
|
|
@@ -3,22 +3,33 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import importlib.metadata
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
from typing import Callable, TypeVar
|
|
6
7
|
|
|
7
8
|
import typer
|
|
8
9
|
|
|
9
|
-
from personal_cli.client import ArticleApiClient, CLIError,
|
|
10
|
+
from personal_cli.client import ArticleApiClient, CLIError, get_config
|
|
11
|
+
from personal_cli.credentials import (
|
|
12
|
+
CredentialError,
|
|
13
|
+
CredentialStore,
|
|
14
|
+
MissingCredentialError,
|
|
15
|
+
)
|
|
10
16
|
from personal_cli.formatting import emit_result, read_markdown_from_source
|
|
17
|
+
from personal_cli.setup_server import run_setup
|
|
11
18
|
|
|
12
19
|
app = typer.Typer(help="Agent-facing article CLI.")
|
|
13
20
|
article_app = typer.Typer(help="Manage articles.")
|
|
14
21
|
blog_app = typer.Typer(help="Manage blog posts.")
|
|
15
22
|
project_app = typer.Typer(help="Manage projects.")
|
|
16
23
|
media_app = typer.Typer(help="Manage media uploads.")
|
|
24
|
+
keys_app = typer.Typer(help="Manage stored credentials.")
|
|
17
25
|
|
|
18
26
|
app.add_typer(article_app, name="article")
|
|
19
27
|
article_app.add_typer(blog_app, name="blog")
|
|
20
28
|
article_app.add_typer(project_app, name="project")
|
|
21
29
|
app.add_typer(media_app, name="media")
|
|
30
|
+
app.add_typer(keys_app, name="keys")
|
|
31
|
+
|
|
32
|
+
Result = TypeVar("Result")
|
|
22
33
|
|
|
23
34
|
|
|
24
35
|
def get_version() -> str:
|
|
@@ -33,8 +44,14 @@ def cli_version() -> None:
|
|
|
33
44
|
typer.echo(get_version())
|
|
34
45
|
|
|
35
46
|
|
|
36
|
-
def
|
|
37
|
-
|
|
47
|
+
def _emit(message: str) -> None:
|
|
48
|
+
typer.echo(message, err=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_client(
|
|
52
|
+
server_url: str | None = None, insecure: bool = False
|
|
53
|
+
) -> ArticleApiClient:
|
|
54
|
+
url, api_key, _ = get_config()
|
|
38
55
|
return ArticleApiClient(server_url or url, api_key=api_key, verify=not insecure)
|
|
39
56
|
|
|
40
57
|
|
|
@@ -42,6 +59,28 @@ def run(coro):
|
|
|
42
59
|
return asyncio.run(coro)
|
|
43
60
|
|
|
44
61
|
|
|
62
|
+
def _run(operation: Callable[[], Result]) -> Result:
|
|
63
|
+
"""Run an operation, opening the setup page when credentials are missing/rejected."""
|
|
64
|
+
while True:
|
|
65
|
+
try:
|
|
66
|
+
return operation()
|
|
67
|
+
except MissingCredentialError:
|
|
68
|
+
run_setup(
|
|
69
|
+
CredentialStore(),
|
|
70
|
+
output=_emit,
|
|
71
|
+
prompt="Credentials are missing. Open this link in your browser",
|
|
72
|
+
)
|
|
73
|
+
except CLIError as exc:
|
|
74
|
+
if exc.status_code not in (401, 403):
|
|
75
|
+
raise
|
|
76
|
+
_emit("The server rejected the stored API key. Re-enter it in the setup page.")
|
|
77
|
+
run_setup(
|
|
78
|
+
CredentialStore(),
|
|
79
|
+
output=_emit,
|
|
80
|
+
prompt="Enter replacement credentials in your browser",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
45
84
|
@article_app.command("list")
|
|
46
85
|
def article_list(
|
|
47
86
|
type_filter: str = typer.Option("all", "--type", help="all, blog, or project."),
|
|
@@ -50,10 +89,13 @@ def article_list(
|
|
|
50
89
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
51
90
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
52
91
|
) -> None:
|
|
53
|
-
|
|
92
|
+
def _op() -> None:
|
|
54
93
|
client = build_client(server_url, insecure=insecure)
|
|
55
94
|
articles = run(client.list_articles(status=status, type_filter=type_filter))
|
|
56
95
|
emit_result(articles, json_output=json_output)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
_run(_op)
|
|
57
99
|
except CLIError as exc:
|
|
58
100
|
typer.echo(str(exc), err=True)
|
|
59
101
|
raise typer.Exit(code=1) from exc
|
|
@@ -66,10 +108,13 @@ def article_show(
|
|
|
66
108
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
67
109
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
68
110
|
) -> None:
|
|
69
|
-
|
|
111
|
+
def _op() -> None:
|
|
70
112
|
client = build_client(server_url, insecure=insecure)
|
|
71
113
|
article = run(client.get_article(slug))
|
|
72
114
|
emit_result(article, json_output=json_output)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
_run(_op)
|
|
73
118
|
except CLIError as exc:
|
|
74
119
|
typer.echo(str(exc), err=True)
|
|
75
120
|
raise typer.Exit(code=1) from exc
|
|
@@ -88,7 +133,7 @@ def blog_create(
|
|
|
88
133
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
89
134
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
90
135
|
) -> None:
|
|
91
|
-
|
|
136
|
+
def _op() -> None:
|
|
92
137
|
body = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
|
|
93
138
|
client = build_client(server_url, insecure=insecure)
|
|
94
139
|
payload = {
|
|
@@ -105,6 +150,9 @@ def blog_create(
|
|
|
105
150
|
}
|
|
106
151
|
article = run(client.create_article(payload))
|
|
107
152
|
emit_result(article, json_output=json_output)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
_run(_op)
|
|
108
156
|
except CLIError as exc:
|
|
109
157
|
typer.echo(str(exc), err=True)
|
|
110
158
|
raise typer.Exit(code=1) from exc
|
|
@@ -126,7 +174,7 @@ def project_create(
|
|
|
126
174
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
127
175
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
128
176
|
) -> None:
|
|
129
|
-
|
|
177
|
+
def _op() -> None:
|
|
130
178
|
body = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
|
|
131
179
|
client = build_client(server_url, insecure=insecure)
|
|
132
180
|
payload = {
|
|
@@ -143,6 +191,9 @@ def project_create(
|
|
|
143
191
|
}
|
|
144
192
|
article = run(client.create_article(payload))
|
|
145
193
|
emit_result(article, json_output=json_output)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
_run(_op)
|
|
146
197
|
except CLIError as exc:
|
|
147
198
|
typer.echo(str(exc), err=True)
|
|
148
199
|
raise typer.Exit(code=1) from exc
|
|
@@ -166,7 +217,7 @@ def article_update(
|
|
|
166
217
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
167
218
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
168
219
|
) -> None:
|
|
169
|
-
|
|
220
|
+
def _op() -> None:
|
|
170
221
|
if cover_image is not None and clear_cover_image:
|
|
171
222
|
raise CLIError("Use either --cover-image or --clear-cover-image, not both.")
|
|
172
223
|
client = build_client(server_url, insecure=insecure)
|
|
@@ -193,6 +244,9 @@ def article_update(
|
|
|
193
244
|
payload["markdown"] = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
|
|
194
245
|
article = run(client.update_article(slug, payload))
|
|
195
246
|
emit_result(article, json_output=json_output)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
_run(_op)
|
|
196
250
|
except CLIError as exc:
|
|
197
251
|
typer.echo(str(exc), err=True)
|
|
198
252
|
raise typer.Exit(code=1) from exc
|
|
@@ -206,10 +260,13 @@ def article_publish(
|
|
|
206
260
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
207
261
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
208
262
|
) -> None:
|
|
209
|
-
|
|
263
|
+
def _op() -> None:
|
|
210
264
|
client = build_client(server_url, insecure=insecure)
|
|
211
265
|
article = run(client.publish_article(slug, {"published_by": published_by} if published_by else None))
|
|
212
266
|
emit_result(article, json_output=json_output)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
_run(_op)
|
|
213
270
|
except CLIError as exc:
|
|
214
271
|
typer.echo(str(exc), err=True)
|
|
215
272
|
raise typer.Exit(code=1) from exc
|
|
@@ -222,10 +279,13 @@ def article_delete(
|
|
|
222
279
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
223
280
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
224
281
|
) -> None:
|
|
225
|
-
|
|
282
|
+
def _op() -> None:
|
|
226
283
|
client = build_client(server_url, insecure=insecure)
|
|
227
284
|
result = run(client.delete_article(slug))
|
|
228
285
|
emit_result(result, json_output=json_output)
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
_run(_op)
|
|
229
289
|
except CLIError as exc:
|
|
230
290
|
typer.echo(str(exc), err=True)
|
|
231
291
|
raise typer.Exit(code=1) from exc
|
|
@@ -238,10 +298,13 @@ def article_unarchive(
|
|
|
238
298
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
239
299
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
240
300
|
) -> None:
|
|
241
|
-
|
|
301
|
+
def _op() -> None:
|
|
242
302
|
client = build_client(server_url, insecure=insecure)
|
|
243
303
|
article = run(client.unarchive_article(slug))
|
|
244
304
|
emit_result(article, json_output=json_output)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
_run(_op)
|
|
245
308
|
except CLIError as exc:
|
|
246
309
|
typer.echo(str(exc), err=True)
|
|
247
310
|
raise typer.Exit(code=1) from exc
|
|
@@ -256,12 +319,15 @@ def article_preview(
|
|
|
256
319
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
257
320
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
258
321
|
) -> None:
|
|
259
|
-
|
|
260
|
-
_, _, default_site_url =
|
|
322
|
+
def _op() -> None:
|
|
323
|
+
_, _, default_site_url = get_config()
|
|
261
324
|
resolved_site_url = site_url or default_site_url
|
|
262
325
|
client = build_client(server_url, insecure=insecure)
|
|
263
326
|
result = run(client.generate_preview(slug, ttl_hours=ttl_hours, base_url=resolved_site_url))
|
|
264
327
|
emit_result(result, json_output=json_output)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
_run(_op)
|
|
265
331
|
except CLIError as exc:
|
|
266
332
|
typer.echo(str(exc), err=True)
|
|
267
333
|
raise typer.Exit(code=1) from exc
|
|
@@ -274,10 +340,13 @@ def article_revoke_preview(
|
|
|
274
340
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
275
341
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
276
342
|
) -> None:
|
|
277
|
-
|
|
343
|
+
def _op() -> None:
|
|
278
344
|
client = build_client(server_url, insecure=insecure)
|
|
279
345
|
result = run(client.revoke_preview(slug))
|
|
280
346
|
emit_result(result, json_output=json_output)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
_run(_op)
|
|
281
350
|
except CLIError as exc:
|
|
282
351
|
typer.echo(str(exc), err=True)
|
|
283
352
|
raise typer.Exit(code=1) from exc
|
|
@@ -291,10 +360,13 @@ def tag_list(
|
|
|
291
360
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
292
361
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
293
362
|
) -> None:
|
|
294
|
-
|
|
363
|
+
def _op() -> None:
|
|
295
364
|
client = build_client(server_url, insecure=insecure)
|
|
296
365
|
result = run(client.list_tags(slug))
|
|
297
366
|
emit_result(result, json_output=json_output)
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
_run(_op)
|
|
298
370
|
except CLIError as exc:
|
|
299
371
|
typer.echo(str(exc), err=True)
|
|
300
372
|
raise typer.Exit(code=1) from exc
|
|
@@ -308,10 +380,13 @@ def tag_add(
|
|
|
308
380
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
309
381
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
310
382
|
) -> None:
|
|
311
|
-
|
|
383
|
+
def _op() -> None:
|
|
312
384
|
client = build_client(server_url, insecure=insecure)
|
|
313
385
|
result = run(client.attach_tags(slug, tag))
|
|
314
386
|
emit_result(result, json_output=json_output)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
_run(_op)
|
|
315
390
|
except CLIError as exc:
|
|
316
391
|
typer.echo(str(exc), err=True)
|
|
317
392
|
raise typer.Exit(code=1) from exc
|
|
@@ -325,12 +400,15 @@ def tag_remove(
|
|
|
325
400
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
326
401
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
327
402
|
) -> None:
|
|
328
|
-
|
|
403
|
+
def _op() -> None:
|
|
329
404
|
client = build_client(server_url, insecure=insecure)
|
|
330
405
|
for t in tag:
|
|
331
406
|
run(client.remove_tag(slug, t))
|
|
332
407
|
result = run(client.list_tags(slug))
|
|
333
408
|
emit_result(result, json_output=json_output)
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
_run(_op)
|
|
334
412
|
except CLIError as exc:
|
|
335
413
|
typer.echo(str(exc), err=True)
|
|
336
414
|
raise typer.Exit(code=1) from exc
|
|
@@ -344,10 +422,13 @@ def media_upload(
|
|
|
344
422
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
345
423
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
346
424
|
) -> None:
|
|
347
|
-
|
|
425
|
+
def _op() -> None:
|
|
348
426
|
client = build_client(server_url, insecure=insecure)
|
|
349
427
|
result = run(client.upload_media(name, path))
|
|
350
428
|
emit_result(result, json_output=json_output)
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
_run(_op)
|
|
351
432
|
except CLIError as exc:
|
|
352
433
|
typer.echo(str(exc), err=True)
|
|
353
434
|
raise typer.Exit(code=1) from exc
|
|
@@ -361,10 +442,13 @@ def media_update(
|
|
|
361
442
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
362
443
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
363
444
|
) -> None:
|
|
364
|
-
|
|
445
|
+
def _op() -> None:
|
|
365
446
|
client = build_client(server_url, insecure=insecure)
|
|
366
447
|
result = run(client.update_media(name, path))
|
|
367
448
|
emit_result(result, json_output=json_output)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
_run(_op)
|
|
368
452
|
except CLIError as exc:
|
|
369
453
|
typer.echo(str(exc), err=True)
|
|
370
454
|
raise typer.Exit(code=1) from exc
|
|
@@ -377,15 +461,56 @@ def media_delete(
|
|
|
377
461
|
insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
|
|
378
462
|
server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
|
|
379
463
|
) -> None:
|
|
380
|
-
|
|
464
|
+
def _op() -> None:
|
|
381
465
|
client = build_client(server_url, insecure=insecure)
|
|
382
466
|
result = run(client.delete_media(name))
|
|
383
467
|
emit_result(result, json_output=json_output)
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
_run(_op)
|
|
384
471
|
except CLIError as exc:
|
|
385
472
|
typer.echo(str(exc), err=True)
|
|
386
473
|
raise typer.Exit(code=1) from exc
|
|
387
474
|
|
|
388
475
|
|
|
476
|
+
@keys_app.command("revoke")
|
|
477
|
+
def keys_revoke(
|
|
478
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
479
|
+
) -> None:
|
|
480
|
+
"""Revoke the stored personal-cli credentials."""
|
|
481
|
+
try:
|
|
482
|
+
revoked = CredentialStore().remove()
|
|
483
|
+
emit_result(
|
|
484
|
+
{"revoked": revoked, "message": "Credentials revoked." if revoked else "No credentials were stored."},
|
|
485
|
+
json_output=json_output,
|
|
486
|
+
)
|
|
487
|
+
except CredentialError as exc:
|
|
488
|
+
typer.echo(str(exc), err=True)
|
|
489
|
+
raise typer.Exit(code=1) from exc
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@keys_app.command("show")
|
|
493
|
+
def keys_show(
|
|
494
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
495
|
+
) -> None:
|
|
496
|
+
"""Show whether credentials are stored (API key is masked)."""
|
|
497
|
+
try:
|
|
498
|
+
server_url, api_key, site_url = get_config()
|
|
499
|
+
emit_result(
|
|
500
|
+
{
|
|
501
|
+
"server_url": server_url,
|
|
502
|
+
"api_key": f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "****",
|
|
503
|
+
"site_url": site_url,
|
|
504
|
+
},
|
|
505
|
+
json_output=json_output,
|
|
506
|
+
)
|
|
507
|
+
except MissingCredentialError:
|
|
508
|
+
emit_result({"stored": False}, json_output=json_output)
|
|
509
|
+
except CredentialError as exc:
|
|
510
|
+
typer.echo(str(exc), err=True)
|
|
511
|
+
raise typer.Exit(code=1) from exc
|
|
512
|
+
|
|
513
|
+
|
|
389
514
|
def main() -> None:
|
|
390
515
|
app()
|
|
391
516
|
|
|
@@ -136,39 +136,8 @@ class ArticleApiClient:
|
|
|
136
136
|
return dict(result)
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
raise CLIError("PERSONAL_SERVER_URL is not set.")
|
|
145
|
-
api_key = os.environ.get("PERSONAL_API_KEY")
|
|
146
|
-
if not api_key:
|
|
147
|
-
raise CLIError("PERSONAL_API_KEY is not set.")
|
|
148
|
-
site_url = os.environ.get("PERSONAL_SITE_URL", "")
|
|
149
|
-
return (server_url, api_key, site_url)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _load_dotenv() -> None:
|
|
153
|
-
import os
|
|
154
|
-
from pathlib import Path
|
|
155
|
-
|
|
156
|
-
candidates = [
|
|
157
|
-
Path.cwd() / ".env",
|
|
158
|
-
Path(__file__).resolve().parents[3] / ".env",
|
|
159
|
-
]
|
|
160
|
-
for env_path in candidates:
|
|
161
|
-
if not env_path.exists():
|
|
162
|
-
continue
|
|
163
|
-
with env_path.open() as f:
|
|
164
|
-
for line in f:
|
|
165
|
-
line = line.strip()
|
|
166
|
-
if not line or line.startswith("#"):
|
|
167
|
-
continue
|
|
168
|
-
if "=" not in line:
|
|
169
|
-
continue
|
|
170
|
-
key, _, value = line.partition("=")
|
|
171
|
-
key = key.strip()
|
|
172
|
-
value = value.strip().strip("'\"")
|
|
173
|
-
os.environ[key] = value
|
|
174
|
-
break
|
|
139
|
+
def get_config() -> tuple[str, str, str]:
|
|
140
|
+
"""Read stored credentials from the OS credential store."""
|
|
141
|
+
from .credentials import CredentialStore
|
|
142
|
+
|
|
143
|
+
return CredentialStore().get()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import keyring
|
|
8
|
+
from keyring.errors import PasswordDeleteError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SERVICE_NAME = "personal-cli"
|
|
12
|
+
ACCOUNT_NAME = "default"
|
|
13
|
+
_REQUIRED_FIELDS = ("server_url", "api_key", "site_url")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CredentialError(RuntimeError):
|
|
17
|
+
"""An actionable error accessing the operating system credential store."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MissingCredentialError(CredentialError):
|
|
21
|
+
"""No credentials have been stored yet."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _service_name() -> str:
|
|
25
|
+
if os.environ.get("PERSONAL_CLI_ENV", "").strip().lower() == "dev":
|
|
26
|
+
return f"{SERVICE_NAME}-dev"
|
|
27
|
+
return SERVICE_NAME
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize(value: str) -> str:
|
|
31
|
+
return value.strip().strip("\"'").strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CredentialStore:
|
|
35
|
+
"""Store the personal-cli server credentials in the OS credential store.
|
|
36
|
+
|
|
37
|
+
The three values (server_url, api_key, site_url) are serialized as JSON
|
|
38
|
+
under a single keyring entry so they stay atomic. The service name is
|
|
39
|
+
suffixed with ``-dev`` when ``PERSONAL_CLI_ENV=dev`` so local testing
|
|
40
|
+
never touches production credentials.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, backend: Any | None = None) -> None:
|
|
44
|
+
self._backend = backend or keyring
|
|
45
|
+
|
|
46
|
+
def add(self, *, server_url: str, api_key: str, site_url: str) -> None:
|
|
47
|
+
server_url = _normalize(server_url)
|
|
48
|
+
api_key = _normalize(api_key)
|
|
49
|
+
site_url = _normalize(site_url)
|
|
50
|
+
if not server_url:
|
|
51
|
+
raise CredentialError("Server URL cannot be empty.")
|
|
52
|
+
if not api_key:
|
|
53
|
+
raise CredentialError("API key cannot be empty.")
|
|
54
|
+
if not site_url:
|
|
55
|
+
raise CredentialError("Site URL cannot be empty.")
|
|
56
|
+
payload = json.dumps(
|
|
57
|
+
{"server_url": server_url, "api_key": api_key, "site_url": site_url}
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
self._backend.set_password(_service_name(), ACCOUNT_NAME, payload)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise CredentialError(
|
|
63
|
+
"Could not access the operating system credential store. "
|
|
64
|
+
"Configure a native keyring backend and try again."
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
def get(self) -> tuple[str, str, str]:
|
|
68
|
+
try:
|
|
69
|
+
raw = self._backend.get_password(_service_name(), ACCOUNT_NAME)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise CredentialError(
|
|
72
|
+
"Could not access the operating system credential store. "
|
|
73
|
+
"Configure a native keyring backend and try again."
|
|
74
|
+
) from exc
|
|
75
|
+
if not raw:
|
|
76
|
+
raise MissingCredentialError(
|
|
77
|
+
"No credentials are stored. Run a command to trigger the setup page."
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
values = json.loads(raw)
|
|
81
|
+
except json.JSONDecodeError as exc:
|
|
82
|
+
raise CredentialError("Stored credentials are corrupted.") from exc
|
|
83
|
+
missing = [field for field in _REQUIRED_FIELDS if not values.get(field)]
|
|
84
|
+
if missing:
|
|
85
|
+
raise MissingCredentialError(
|
|
86
|
+
f"Stored credentials are incomplete (missing: {', '.join(missing)})."
|
|
87
|
+
)
|
|
88
|
+
return values["server_url"], values["api_key"], values["site_url"]
|
|
89
|
+
|
|
90
|
+
def remove(self) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
self._backend.delete_password(_service_name(), ACCOUNT_NAME)
|
|
93
|
+
except PasswordDeleteError:
|
|
94
|
+
return False
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise CredentialError(
|
|
97
|
+
"Could not access the operating system credential store. "
|
|
98
|
+
"Configure a native keyring backend and try again."
|
|
99
|
+
) from exc
|
|
100
|
+
return True
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import threading
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from typing import Callable
|
|
9
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .credentials import CredentialError, CredentialStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SETUP_PATH = "/setup"
|
|
17
|
+
SETUP_PORT = 3234
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _page(
|
|
21
|
+
message: str = "",
|
|
22
|
+
*,
|
|
23
|
+
error: bool = False,
|
|
24
|
+
token: str = "",
|
|
25
|
+
server_url: str = "",
|
|
26
|
+
site_url: str = "",
|
|
27
|
+
success: bool = False,
|
|
28
|
+
) -> bytes:
|
|
29
|
+
action = f"{SETUP_PATH}?{urlencode({'token': token})}"
|
|
30
|
+
if success:
|
|
31
|
+
return f"""<!doctype html>
|
|
32
|
+
<html lang="en">
|
|
33
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
34
|
+
<title>Personal CLI setup</title>
|
|
35
|
+
<style>
|
|
36
|
+
:root {{ color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }}
|
|
37
|
+
* {{ box-sizing: border-box; }}
|
|
38
|
+
body {{ margin: 0; min-height: 100vh; background: linear-gradient(135deg,#f4f7ff,#fff 55%,#eefbf7); color: #172033; }}
|
|
39
|
+
dialog {{ border: 0; border-radius: 1.25rem; padding: 0; max-width: 26rem; width: calc(100% - 2rem); box-shadow: 0 24px 60px rgba(24,39,75,.18); overflow: hidden; top: 50%; transform: translateY(-50%); margin: 0 auto; }}
|
|
40
|
+
dialog::backdrop {{ background: rgba(23,32,51,.45); backdrop-filter: blur(4px); }}
|
|
41
|
+
.modal {{ padding: 2.75rem 2rem 2.25rem; text-align: center; }}
|
|
42
|
+
.check {{ display: grid; place-items: center; width: 4rem; height: 4rem; margin: 0 auto 1.25rem; border-radius: 50%; background: #edfff6; color: #087443; }}
|
|
43
|
+
.check svg {{ width: 2.25rem; height: 2.25rem; }}
|
|
44
|
+
h2 {{ margin: 0 0 .5rem; font-size: 1.5rem; letter-spacing: -.02em; }}
|
|
45
|
+
.modal p {{ margin: 0; color: #667085; line-height: 1.55; font-size: .95rem; }}
|
|
46
|
+
</style></head>
|
|
47
|
+
<body>
|
|
48
|
+
<dialog open>
|
|
49
|
+
<div class="modal">
|
|
50
|
+
<div class="check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>
|
|
51
|
+
<h2>Credentials saved</h2>
|
|
52
|
+
<p>Your server URL, API key, and site URL are stored in your operating system credential store. You can close this browser window.</p>
|
|
53
|
+
</div>
|
|
54
|
+
</dialog>
|
|
55
|
+
</body></html>""".encode("utf-8")
|
|
56
|
+
|
|
57
|
+
escaped_message = html.escape(message)
|
|
58
|
+
message_html = (
|
|
59
|
+
f'<p class="{"error" if error else "success"}">{escaped_message}</p>'
|
|
60
|
+
if message
|
|
61
|
+
else ""
|
|
62
|
+
)
|
|
63
|
+
return f"""<!doctype html>
|
|
64
|
+
<html lang="en">
|
|
65
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
66
|
+
<title>Personal CLI setup</title>
|
|
67
|
+
<style>
|
|
68
|
+
:root {{ color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }}
|
|
69
|
+
* {{ box-sizing: border-box; }}
|
|
70
|
+
body {{ margin: 0; min-height: 100vh; background: linear-gradient(135deg,#f4f7ff,#fff 55%,#eefbf7); color: #172033; }}
|
|
71
|
+
.shell {{ max-width: 42rem; margin: 0 auto; padding: 4rem 1.25rem; }}
|
|
72
|
+
.eyebrow {{ color: #536dfe; font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }}
|
|
73
|
+
h1 {{ margin: .5rem 0 .75rem; font-size: clamp(2rem,5vw,2.75rem); letter-spacing: -.04em; }}
|
|
74
|
+
.intro {{ color: #667085; line-height: 1.6; margin: 0 0 2rem; }}
|
|
75
|
+
.card {{ padding: 1.5rem; border: 1px solid #e4e7ec; border-radius: 1rem; background: rgba(255,255,255,.86); box-shadow: 0 10px 30px rgba(24,39,75,.07); }}
|
|
76
|
+
label {{ display: block; margin: 1rem 0 .4rem; font-size: .85rem; font-weight: 700; }}
|
|
77
|
+
input {{ width: 100%; padding: .85rem 1rem; border: 1px solid #d0d5dd; border-radius: .65rem; font: inherit; background: #fff; }}
|
|
78
|
+
input:focus {{ outline: 3px solid #dfe3ff; border-color: #536dfe; }}
|
|
79
|
+
button {{ margin-top: 1.5rem; width: 100%; padding: .9rem 1rem; border: 0; border-radius: .65rem; background: #536dfe; color: white; font-weight: 750; cursor: pointer; box-shadow: 0 8px 18px rgba(83,109,254,.25); font: inherit; }}
|
|
80
|
+
button:hover {{ background: #4256d6; }}
|
|
81
|
+
.notice {{ margin: 1rem 0; padding: .85rem 1rem; border-radius: .65rem; }}
|
|
82
|
+
.error {{ color: #a4262c; background: #fff0f0; }} .success {{ color: #087443; background: #edfff6; }}
|
|
83
|
+
.footnote {{ color: #667085; font-size: .8rem; text-align: center; margin-top: 1.5rem; }}
|
|
84
|
+
</style></head>
|
|
85
|
+
<body><main class="shell"><div class="eyebrow">Personal CLI</div><h1>Connect your backend</h1>
|
|
86
|
+
<p class="intro">Enter your personal server URL, API key, and site URL. They are saved to your operating system credential store and never sent to the agent chat.</p>
|
|
87
|
+
{message_html}
|
|
88
|
+
<form method="post" action="{html.escape(action)}" class="card">
|
|
89
|
+
<label for="server-url">Server URL</label>
|
|
90
|
+
<input id="server-url" name="server_url" type="url" required placeholder="https://api.example.com" value="{html.escape(server_url)}">
|
|
91
|
+
<label for="api-key">API key</label>
|
|
92
|
+
<input id="api-key" name="api_key" type="password" autocomplete="off" required placeholder="Enter your API key">
|
|
93
|
+
<label for="site-url">Site URL</label>
|
|
94
|
+
<input id="site-url" name="site_url" type="url" required placeholder="https://example.com" value="{html.escape(site_url)}">
|
|
95
|
+
<button type="submit">Save credentials</button>
|
|
96
|
+
</form>
|
|
97
|
+
<div class="footnote">You can revoke later with <code>blog-cli keys revoke</code>.</div>
|
|
98
|
+
</main></body></html>""".encode("utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate(server_url: str, api_key: str, site_url: str) -> str | None:
|
|
102
|
+
"""Hit the server to confirm the URL + key work. Returns error message or None."""
|
|
103
|
+
try:
|
|
104
|
+
with httpx.Client(
|
|
105
|
+
base_url=server_url.rstrip("/"),
|
|
106
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
107
|
+
timeout=10.0,
|
|
108
|
+
verify=True,
|
|
109
|
+
) as client:
|
|
110
|
+
response = client.get("/api/v1/articles", params={"type": "all"})
|
|
111
|
+
except httpx.HTTPError as exc:
|
|
112
|
+
return f"Could not reach the server: {exc}"
|
|
113
|
+
if response.status_code in (401, 403):
|
|
114
|
+
return "The server rejected that API key."
|
|
115
|
+
if response.status_code >= 500:
|
|
116
|
+
return f"The server returned an error ({response.status_code})."
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _SetupServer(ThreadingHTTPServer):
|
|
121
|
+
allow_reuse_address = True
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
store: CredentialStore,
|
|
126
|
+
token: str,
|
|
127
|
+
port: int = SETUP_PORT,
|
|
128
|
+
bind_host: str = "127.0.0.1",
|
|
129
|
+
) -> None:
|
|
130
|
+
try:
|
|
131
|
+
super().__init__((bind_host, port), _SetupHandler)
|
|
132
|
+
except OSError:
|
|
133
|
+
super().__init__((bind_host, 0), _SetupHandler)
|
|
134
|
+
self.store = store
|
|
135
|
+
self.token = token
|
|
136
|
+
self.consumed = False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _SetupHandler(BaseHTTPRequestHandler):
|
|
140
|
+
server: _SetupServer
|
|
141
|
+
|
|
142
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
def _authorized(self) -> bool:
|
|
146
|
+
query = parse_qs(urlparse(self.path).query)
|
|
147
|
+
return urlparse(self.path).path == SETUP_PATH and query.get("token") == [
|
|
148
|
+
self.server.token
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
def _send(self, status: int, body: bytes) -> None:
|
|
152
|
+
self.send_response(status)
|
|
153
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
154
|
+
self.send_header("Content-Length", str(len(body)))
|
|
155
|
+
self.send_header("Cache-Control", "no-store")
|
|
156
|
+
self.end_headers()
|
|
157
|
+
self.wfile.write(body)
|
|
158
|
+
|
|
159
|
+
def do_GET(self) -> None:
|
|
160
|
+
if not self._authorized():
|
|
161
|
+
self._send(404, _page("Setup page not found.", error=True))
|
|
162
|
+
return
|
|
163
|
+
if self.server.consumed:
|
|
164
|
+
self._send(410, _page("This setup link has already been used.", error=True))
|
|
165
|
+
return
|
|
166
|
+
self._send(200, _page(token=self.server.token))
|
|
167
|
+
|
|
168
|
+
def do_POST(self) -> None:
|
|
169
|
+
if not self._authorized():
|
|
170
|
+
self._send(404, _page("Setup page not found.", error=True))
|
|
171
|
+
return
|
|
172
|
+
if self.server.consumed:
|
|
173
|
+
self._send(410, _page("This setup link has already been used.", error=True))
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
length = min(int(self.headers.get("Content-Length", "0")), 64 * 1024)
|
|
177
|
+
values = parse_qs(
|
|
178
|
+
self.rfile.read(length).decode("utf-8"), keep_blank_values=True
|
|
179
|
+
)
|
|
180
|
+
server_url = values.get("server_url", [""])[0].strip().strip("\"'").strip()
|
|
181
|
+
api_key = values.get("api_key", [""])[0].strip().strip("\"'").strip()
|
|
182
|
+
site_url = values.get("site_url", [""])[0].strip().strip("\"'").strip()
|
|
183
|
+
if not (server_url and api_key and site_url):
|
|
184
|
+
raise CredentialError("All three fields are required.")
|
|
185
|
+
|
|
186
|
+
error = _validate(server_url, api_key, site_url)
|
|
187
|
+
if error:
|
|
188
|
+
self._send(
|
|
189
|
+
400,
|
|
190
|
+
_page(
|
|
191
|
+
error,
|
|
192
|
+
error=True,
|
|
193
|
+
token=self.server.token,
|
|
194
|
+
server_url=server_url,
|
|
195
|
+
site_url=site_url,
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
self.server.store.add(
|
|
200
|
+
server_url=server_url, api_key=api_key, site_url=site_url
|
|
201
|
+
)
|
|
202
|
+
except (ValueError, UnicodeDecodeError, CredentialError) as exc:
|
|
203
|
+
self._send(400, _page(str(exc), error=True, token=self.server.token))
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
self.server.consumed = True
|
|
207
|
+
self._send(200, _page(success=True))
|
|
208
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def run_setup(
|
|
212
|
+
store: CredentialStore,
|
|
213
|
+
output: Callable[[str], None] = print,
|
|
214
|
+
prompt: str = "Credentials are missing. Open this link in your browser",
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Serve the one-time local setup page until credentials are saved."""
|
|
217
|
+
token = secrets.token_urlsafe(32)
|
|
218
|
+
server = _SetupServer(
|
|
219
|
+
store,
|
|
220
|
+
token,
|
|
221
|
+
bind_host=os.environ.get("PERSONAL_CLI_SETUP_BIND_HOST", "127.0.0.1"),
|
|
222
|
+
)
|
|
223
|
+
url = f"http://127.0.0.1:{server.server_port}{SETUP_PATH}?{urlencode({'token': token})}"
|
|
224
|
+
output(f"{prompt}: {url}")
|
|
225
|
+
try:
|
|
226
|
+
server.serve_forever()
|
|
227
|
+
finally:
|
|
228
|
+
server.server_close()
|
|
@@ -7,6 +7,29 @@ import pytest
|
|
|
7
7
|
from typer.testing import CliRunner
|
|
8
8
|
|
|
9
9
|
from personal_cli.cli import app
|
|
10
|
+
from personal_cli.credentials import (
|
|
11
|
+
CredentialError,
|
|
12
|
+
CredentialStore,
|
|
13
|
+
MissingCredentialError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeKeyringBackend:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._store: dict[str, dict[str, str]] = {}
|
|
20
|
+
|
|
21
|
+
def set_password(self, service: str, account: str, password: str) -> None:
|
|
22
|
+
self._store.setdefault(service, {})[account] = password
|
|
23
|
+
|
|
24
|
+
def get_password(self, service: str, account: str) -> str | None:
|
|
25
|
+
return self._store.get(service, {}).get(account)
|
|
26
|
+
|
|
27
|
+
def delete_password(self, service: str, account: str) -> None:
|
|
28
|
+
if account not in self._store.get(service, {}):
|
|
29
|
+
from keyring.errors import PasswordDeleteError
|
|
30
|
+
|
|
31
|
+
raise PasswordDeleteError("not found")
|
|
32
|
+
del self._store[service][account]
|
|
10
33
|
|
|
11
34
|
|
|
12
35
|
class FakeApiClient:
|
|
@@ -77,16 +100,52 @@ class FakeApiClient:
|
|
|
77
100
|
return {"deleted": True, "name": name}
|
|
78
101
|
|
|
79
102
|
|
|
103
|
+
VALID_CREDS = {
|
|
104
|
+
"server_url": "http://testserver",
|
|
105
|
+
"api_key": "test-key",
|
|
106
|
+
"site_url": "http://testsite",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
80
110
|
@pytest.fixture()
|
|
81
111
|
def runner() -> CliRunner:
|
|
82
112
|
return CliRunner()
|
|
83
113
|
|
|
84
114
|
|
|
115
|
+
@pytest.fixture()
|
|
116
|
+
def fake_backend() -> FakeKeyringBackend:
|
|
117
|
+
return FakeKeyringBackend()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.fixture(autouse=True)
|
|
121
|
+
def credential_store(monkeypatch: pytest.MonkeyPatch, fake_backend: FakeKeyringBackend) -> FakeKeyringBackend:
|
|
122
|
+
"""Every CredentialStore() instance uses the fake in-memory backend."""
|
|
123
|
+
real_init = CredentialStore.__init__
|
|
124
|
+
|
|
125
|
+
def _init(self, backend=None):
|
|
126
|
+
real_init(self, backend=fake_backend)
|
|
127
|
+
|
|
128
|
+
monkeypatch.setattr(CredentialStore, "__init__", _init)
|
|
129
|
+
return fake_backend
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.fixture(autouse=True)
|
|
133
|
+
def seeded_credentials(credential_store: FakeKeyringBackend) -> None:
|
|
134
|
+
"""Pre-seed valid credentials so commands can build a client."""
|
|
135
|
+
credential_store.set_password(
|
|
136
|
+
"personal-cli",
|
|
137
|
+
"default",
|
|
138
|
+
json.dumps(VALID_CREDS),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
85
142
|
@pytest.fixture(autouse=True)
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
143
|
+
def stub_run_setup(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
144
|
+
"""Never start the real setup server during tests."""
|
|
145
|
+
def _noop_run_setup(*args, **kwargs):
|
|
146
|
+
raise AssertionError("run_setup was called during a test that pre-seeded credentials.")
|
|
147
|
+
|
|
148
|
+
monkeypatch.setattr("personal_cli.cli.run_setup", _noop_run_setup)
|
|
90
149
|
|
|
91
150
|
|
|
92
151
|
@pytest.fixture()
|
|
@@ -204,3 +263,71 @@ def test_media_cli_smoke(monkeypatch, runner: CliRunner, client: FakeApiClient,
|
|
|
204
263
|
deleted = json.loads(delete_result.stdout)
|
|
205
264
|
assert deleted["deleted"] is True
|
|
206
265
|
assert deleted["name"] == "hero-image"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_keys_show_reports_stored_credentials(runner: CliRunner) -> None:
|
|
269
|
+
result = runner.invoke(app, ["keys", "show", "--json"])
|
|
270
|
+
assert result.exit_code == 0
|
|
271
|
+
shown = json.loads(result.stdout)
|
|
272
|
+
assert shown["server_url"] == "http://testserver"
|
|
273
|
+
assert shown["site_url"] == "http://testsite"
|
|
274
|
+
assert "api_key" in shown
|
|
275
|
+
assert shown["api_key"] != "test-key"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_keys_revoke_clears_credentials(
|
|
279
|
+
runner: CliRunner, credential_store: FakeKeyringBackend
|
|
280
|
+
) -> None:
|
|
281
|
+
revoke_result = runner.invoke(app, ["keys", "revoke", "--json"])
|
|
282
|
+
assert revoke_result.exit_code == 0
|
|
283
|
+
revoked = json.loads(revoke_result.stdout)
|
|
284
|
+
assert revoked["revoked"] is True
|
|
285
|
+
|
|
286
|
+
assert credential_store.get_password("personal-cli", "default") is None
|
|
287
|
+
|
|
288
|
+
second_revoke = runner.invoke(app, ["keys", "revoke", "--json"])
|
|
289
|
+
assert second_revoke.exit_code == 0
|
|
290
|
+
assert json.loads(second_revoke.stdout)["revoked"] is False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_keys_show_when_empty(runner: CliRunner, credential_store: FakeKeyringBackend) -> None:
|
|
294
|
+
credential_store.delete_password("personal-cli", "default")
|
|
295
|
+
result = runner.invoke(app, ["keys", "show", "--json"])
|
|
296
|
+
assert result.exit_code == 0
|
|
297
|
+
assert json.loads(result.stdout) == {"stored": False}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_missing_credentials_triggers_setup(
|
|
301
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
302
|
+
runner: CliRunner,
|
|
303
|
+
credential_store: FakeKeyringBackend,
|
|
304
|
+
) -> None:
|
|
305
|
+
credential_store.delete_password("personal-cli", "default")
|
|
306
|
+
|
|
307
|
+
setup_calls: list[int] = []
|
|
308
|
+
|
|
309
|
+
def _fake_run_setup(store, output=None, prompt=""):
|
|
310
|
+
output(f"{prompt}: http://127.0.0.1:3233/setup?token=fake")
|
|
311
|
+
setup_calls.append(1)
|
|
312
|
+
store.add(
|
|
313
|
+
server_url=VALID_CREDS["server_url"],
|
|
314
|
+
api_key=VALID_CREDS["api_key"],
|
|
315
|
+
site_url=VALID_CREDS["site_url"],
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
monkeypatch.setattr("personal_cli.cli.run_setup", _fake_run_setup)
|
|
319
|
+
|
|
320
|
+
attempts: list[int] = []
|
|
321
|
+
|
|
322
|
+
def _flaky_build_client(server_url=None, insecure=False):
|
|
323
|
+
attempts.append(1)
|
|
324
|
+
if len(attempts) == 1:
|
|
325
|
+
raise MissingCredentialError("missing")
|
|
326
|
+
return FakeApiClient()
|
|
327
|
+
|
|
328
|
+
monkeypatch.setattr("personal_cli.cli.build_client", _flaky_build_client)
|
|
329
|
+
monkeypatch.setattr("personal_cli.cli.run", lambda coro: __import__("asyncio").run(coro))
|
|
330
|
+
|
|
331
|
+
result = runner.invoke(app, ["article", "list", "--json"])
|
|
332
|
+
assert setup_calls, "run_setup should have been called"
|
|
333
|
+
assert result.exit_code == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|