Merge pull request #427 from PR0M3TH3AN/codex/add-cli-commands-and-api-endpoints

Add entry management CLI/API
This commit is contained in:
thePR0M3TH3AN
2025-07-09 12:48:10 -04:00
committed by GitHub
6 changed files with 238 additions and 1 deletions

View File

@@ -49,6 +49,10 @@ Manage individual entries within a vault.
| List entries | `entry list` | `seedpass entry list --sort label` |
| Search for entries | `entry search` | `seedpass entry search "GitHub"` |
| Retrieve an entry's secret (password or TOTP code) | `entry get` | `seedpass entry get "GitHub"` |
| Add a password entry | `entry add` | `seedpass entry add Example --length 16` |
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
| Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 1` |
### Vault Commands
@@ -109,6 +113,10 @@ Run or stop the local HTTP API.
- **`seedpass entry list`** List entries in the vault, optionally sorted or filtered.
- **`seedpass entry search <query>`** Search across labels, usernames, URLs and notes.
- **`seedpass entry get <query>`** Retrieve the password or TOTP code for one matching entry, depending on the entry's type.
- **`seedpass entry add <label>`** Create a new password entry. Use `--length` to set the password length and optional `--username`/`--url` values.
- **`seedpass entry modify <id>`** Update an entry's label, username, URL or notes.
- **`seedpass entry archive <id>`** Mark an entry as archived so it is hidden from normal lists.
- **`seedpass entry unarchive <id>`** Restore an archived entry.
Example retrieving a TOTP code:

View File

@@ -17,6 +17,10 @@ Keep this token secret. Every request must include it in the `Authorization` hea
- `GET /api/v1/entry?query=<text>` Search entries matching a query.
- `GET /api/v1/entry/{id}` Retrieve a single entry by its index.
- `POST /api/v1/entry` Create a new password entry.
- `PUT /api/v1/entry/{id}` Modify an existing entry.
- `POST /api/v1/entry/{id}/archive` Archive an entry.
- `POST /api/v1/entry/{id}/unarchive` Unarchive an entry.
- `GET /api/v1/config/{key}` Return the value for a configuration key.
- `GET /api/v1/fingerprint` List available seed fingerprints.
- `GET /api/v1/nostr/pubkey` Fetch the Nostr public key for the active seed.

View File

@@ -81,6 +81,64 @@ def get_entry(entry_id: int, authorization: str | None = Header(None)) -> Any:
return entry
@app.post("/api/v1/entry")
def create_entry(
entry: dict,
authorization: str | None = Header(None),
) -> dict[str, int]:
"""Create a new password entry."""
_check_token(authorization)
assert _pm is not None
index = _pm.entry_manager.add_entry(
entry.get("label"),
int(entry.get("length", 12)),
entry.get("username"),
entry.get("url"),
)
return {"id": index}
@app.put("/api/v1/entry/{entry_id}")
def update_entry(
entry_id: int,
entry: dict,
authorization: str | None = Header(None),
) -> dict[str, str]:
"""Update an existing entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.modify_entry(
entry_id,
username=entry.get("username"),
url=entry.get("url"),
notes=entry.get("notes"),
label=entry.get("label"),
)
return {"status": "ok"}
@app.post("/api/v1/entry/{entry_id}/archive")
def archive_entry(
entry_id: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Archive an entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.archive_entry(entry_id)
return {"status": "archived"}
@app.post("/api/v1/entry/{entry_id}/unarchive")
def unarchive_entry(
entry_id: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Restore an archived entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.restore_entry(entry_id)
return {"status": "active"}
@app.get("/api/v1/config/{key}")
def get_config(key: str, authorization: str | None = Header(None)) -> Any:
_check_token(authorization)

View File

@@ -126,6 +126,52 @@ def entry_get(ctx: typer.Context, query: str) -> None:
raise typer.Exit(code=1)
@entry_app.command("add")
def entry_add(
ctx: typer.Context,
label: str,
length: int = typer.Option(12, "--length"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
) -> None:
"""Add a new password entry and output its index."""
pm = _get_pm(ctx)
index = pm.entry_manager.add_entry(label, length, username, url)
typer.echo(str(index))
@entry_app.command("modify")
def entry_modify(
ctx: typer.Context,
entry_id: int,
label: Optional[str] = typer.Option(None, "--label"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
notes: Optional[str] = typer.Option(None, "--notes"),
) -> None:
"""Modify an existing entry."""
pm = _get_pm(ctx)
pm.entry_manager.modify_entry(
entry_id, username=username, url=url, notes=notes, label=label
)
@entry_app.command("archive")
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
"""Archive an entry."""
pm = _get_pm(ctx)
pm.entry_manager.archive_entry(entry_id)
typer.echo(str(entry_id))
@entry_app.command("unarchive")
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
"""Restore an archived entry."""
pm = _get_pm(ctx)
pm.entry_manager.restore_entry(entry_id)
typer.echo(str(entry_id))
@vault_app.command("export")
def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")

View File

@@ -16,6 +16,10 @@ def client(monkeypatch):
entry_manager=SimpleNamespace(
search_entries=lambda q: [(1, "Site", "user", "url", False)],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,
archive_entry=lambda i: None,
restore_entry=lambda i: None,
),
config_manager=SimpleNamespace(
load_config=lambda require_pin=False: {"k": "v"}
@@ -98,6 +102,35 @@ def test_get_nostr_pubkey(client):
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_create_modify_archive_entry(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.post(
"/api/v1/entry",
json={"label": "test", "length": 12},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"id": 1}
res = cl.put(
"/api/v1/entry/1",
json={"username": "bob"},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
res = cl.post("/api/v1/entry/1/archive", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "archived"}
res = cl.post("/api/v1/entry/1/unarchive", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "active"}
def test_shutdown(client, monkeypatch):
cl, token = client
@@ -130,10 +163,17 @@ def test_shutdown(client, monkeypatch):
("get", "/api/v1/fingerprint"),
("get", "/api/v1/nostr/pubkey"),
("post", "/api/v1/shutdown"),
("post", "/api/v1/entry"),
("put", "/api/v1/entry/1"),
("post", "/api/v1/entry/1/archive"),
("post", "/api/v1/entry/1/unarchive"),
],
)
def test_invalid_token_other_endpoints(client, method, path):
cl, _token = client
req = getattr(cl, method)
res = req(path, headers={"Authorization": "Bearer bad"})
kwargs = {"headers": {"Authorization": "Bearer bad"}}
if method in {"post", "put"}:
kwargs["json"] = {}
res = req(path, **kwargs)
assert res.status_code == 401

View File

@@ -165,3 +165,84 @@ def test_api_start_passes_fingerprint(monkeypatch):
result = runner.invoke(app, ["--fingerprint", "abc", "api", "start"])
assert result.exit_code == 0
assert called.get("fp") == "abc"
def test_entry_add(monkeypatch):
called = {}
def add_entry(label, length, username=None, url=None):
called["args"] = (label, length, username, url)
return 2
pm = SimpleNamespace(
entry_manager=SimpleNamespace(add_entry=add_entry),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(
app,
[
"entry",
"add",
"Example",
"--length",
"16",
"--username",
"bob",
"--url",
"ex.com",
],
)
assert result.exit_code == 0
assert "2" in result.stdout
assert called["args"] == ("Example", 16, "bob", "ex.com")
def test_entry_modify(monkeypatch):
called = {}
def modify_entry(index, username=None, url=None, notes=None, label=None):
called["args"] = (index, username, url, notes, label)
pm = SimpleNamespace(
entry_manager=SimpleNamespace(modify_entry=modify_entry),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"])
assert result.exit_code == 0
assert called["args"] == (1, "alice", None, None, None)
def test_entry_archive(monkeypatch):
called = {}
def archive_entry(i):
called["id"] = i
pm = SimpleNamespace(
entry_manager=SimpleNamespace(archive_entry=archive_entry),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "archive", "3"])
assert result.exit_code == 0
assert "3" in result.stdout
assert called["id"] == 3
def test_entry_unarchive(monkeypatch):
called = {}
def restore_entry(i):
called["id"] = i
pm = SimpleNamespace(
entry_manager=SimpleNamespace(restore_entry=restore_entry),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", "unarchive", "4"])
assert result.exit_code == 0
assert "4" in result.stdout
assert called["id"] == 4