secryn-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.
- secryn_cli/__init__.py +3 -0
- secryn_cli/__main__.py +3 -0
- secryn_cli/cli.py +699 -0
- secryn_cli/client.py +206 -0
- secryn_cli/config.py +161 -0
- secryn_cli/tests/__init__.py +0 -0
- secryn_cli/tests/test_cli.py +838 -0
- secryn_cli-0.1.0.dist-info/METADATA +12 -0
- secryn_cli-0.1.0.dist-info/RECORD +12 -0
- secryn_cli-0.1.0.dist-info/WHEEL +5 -0
- secryn_cli-0.1.0.dist-info/entry_points.txt +2 -0
- secryn_cli-0.1.0.dist-info/top_level.txt +1 -0
secryn_cli/__init__.py
ADDED
secryn_cli/__main__.py
ADDED
secryn_cli/cli.py
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
"""Secryn CLI — manage your secrets from the terminal.
|
|
2
|
+
|
|
3
|
+
Provides commands for authentication, project management, secret
|
|
4
|
+
operations, API key management, and user profile access.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
# The ``click`` package does not ship type stubs, so static checkers
|
|
13
|
+
# that run without ``--no-typeshed`` may report a missing import.
|
|
14
|
+
# pyrefly: ignore [missing-import]
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .client import APIError, Client
|
|
19
|
+
from .config import config_path, cookie_jar_path, load_config, save_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Display helpers
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def echo_success(text: str) -> None:
|
|
27
|
+
"""Print a green success message to stderr."""
|
|
28
|
+
click.echo(f"\033[32m\u2713\033[0m {text}", err=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def echo_error(text: str) -> None:
|
|
32
|
+
"""Print a red error message to stderr."""
|
|
33
|
+
click.echo(f"\033[31m\u2717\033[0m {text}", err=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def echo_info(text: str) -> None:
|
|
37
|
+
"""Print a blue info message to stderr."""
|
|
38
|
+
click.echo(f"\033[34m\u2139\033[0m {text}", err=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def confirm_action(message: str) -> bool:
|
|
42
|
+
"""Prompt the user for a yes/no confirmation (default no)."""
|
|
43
|
+
return click.confirm(f"\033[33m?\033[0m {message}", default=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_client(api_url: Optional[str] = None) -> Client:
|
|
47
|
+
"""Create a configured API client, optionally overriding the API URL.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
api_url: If provided, overrides and persists the API base URL.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A ready-to-use ``Client`` instance.
|
|
54
|
+
"""
|
|
55
|
+
cfg = load_config()
|
|
56
|
+
if api_url:
|
|
57
|
+
old_url = cfg.api_url
|
|
58
|
+
cfg.api_url = api_url
|
|
59
|
+
save_config(cfg)
|
|
60
|
+
if api_url != old_url:
|
|
61
|
+
echo_info(f"API URL set to {api_url}")
|
|
62
|
+
return Client(cfg)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
66
|
+
"""Render a list of rows as an aligned ASCII table.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
headers: Column header labels.
|
|
70
|
+
rows: Row data, each inner list must have the same length as ``headers``.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Formatted table string with headers separated from rows by a
|
|
74
|
+
dashed line.
|
|
75
|
+
"""
|
|
76
|
+
col_widths = [len(h) for h in headers]
|
|
77
|
+
for row in rows:
|
|
78
|
+
for i, cell in enumerate(row):
|
|
79
|
+
col_widths[i] = max(col_widths[i], len(str(cell)))
|
|
80
|
+
col_widths = [w + 2 for w in col_widths]
|
|
81
|
+
|
|
82
|
+
lines: list[str] = []
|
|
83
|
+
hdr = "".join(h.ljust(col_widths[i]) for i, h in enumerate(headers))
|
|
84
|
+
lines.append(hdr)
|
|
85
|
+
lines.append("".join("-" * col_widths[i] for i in range(len(headers))))
|
|
86
|
+
|
|
87
|
+
for row in rows:
|
|
88
|
+
line = "".join(str(c).ljust(col_widths[i]) for i, c in enumerate(row))
|
|
89
|
+
lines.append(line)
|
|
90
|
+
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def mask_value(value: str) -> str:
|
|
95
|
+
"""Mask a secret value, showing only the first and last 4 characters.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
value: The plain-text secret value.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Masked string with the middle characters replaced by ``*``.
|
|
102
|
+
Short values (<= 8 chars) are fully obscured.
|
|
103
|
+
"""
|
|
104
|
+
if len(value) > 8:
|
|
105
|
+
return value[:4] + "*" * (len(value) - 8) + value[-4:]
|
|
106
|
+
if value:
|
|
107
|
+
return "*" * len(value)
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# CLI entry point
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
@click.group()
|
|
116
|
+
@click.option(
|
|
117
|
+
"--api-url",
|
|
118
|
+
envvar="SECRYN_API_URL",
|
|
119
|
+
help="API base URL (default: http://localhost:3000/api/v1)",
|
|
120
|
+
)
|
|
121
|
+
@click.pass_context
|
|
122
|
+
def cli(ctx: click.Context, api_url: Optional[str]) -> None:
|
|
123
|
+
"""Secryn CLI — manage secrets, projects, and API keys."""
|
|
124
|
+
ctx.obj = get_client(api_url)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Auth commands
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@cli.group()
|
|
132
|
+
def auth() -> None:
|
|
133
|
+
"""Authenticate with Secryn."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@auth.command("login")
|
|
137
|
+
@click.option("--email", help="Email address")
|
|
138
|
+
@click.option("--password", help="Password")
|
|
139
|
+
@click.pass_obj
|
|
140
|
+
def auth_login(client: Client, email: Optional[str], password: Optional[str]) -> None:
|
|
141
|
+
"""Log in to Secryn.
|
|
142
|
+
|
|
143
|
+
Prompts interactively for email and password if not supplied via flags.
|
|
144
|
+
Supports MFA: when the account has MFA enabled you will be asked for
|
|
145
|
+
the TOTP token.
|
|
146
|
+
"""
|
|
147
|
+
if not email:
|
|
148
|
+
email = click.prompt("Email", type=str)
|
|
149
|
+
if not password:
|
|
150
|
+
password = click.prompt("Password", hide_input=True, type=str)
|
|
151
|
+
|
|
152
|
+
result = client.post("/auth/login", {"email": email, "password": password})
|
|
153
|
+
|
|
154
|
+
if result.get("mfaRequired"):
|
|
155
|
+
echo_info("MFA is required for this account.")
|
|
156
|
+
token = click.prompt("Enter MFA token", type=str)
|
|
157
|
+
client.post(
|
|
158
|
+
"/auth/mfa/confirm",
|
|
159
|
+
{"token": token, "mfaToken": result["mfaToken"]},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
client.config.user_email = email
|
|
163
|
+
client.save_config()
|
|
164
|
+
echo_success(f"Logged in as {email}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@auth.command("logout")
|
|
168
|
+
@click.pass_obj
|
|
169
|
+
def auth_logout(client: Client) -> None:
|
|
170
|
+
"""Log out and clear locally stored credentials."""
|
|
171
|
+
client.logout()
|
|
172
|
+
echo_success("Logged out successfully")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@auth.command("whoami")
|
|
176
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
177
|
+
@click.pass_obj
|
|
178
|
+
def auth_whoami(client: Client, as_json: bool) -> None:
|
|
179
|
+
"""Show the currently logged-in user."""
|
|
180
|
+
user = client.get("/users/@me")
|
|
181
|
+
|
|
182
|
+
if as_json:
|
|
183
|
+
import json as _json
|
|
184
|
+
|
|
185
|
+
click.echo(_json.dumps(user, indent=2))
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
click.echo(
|
|
189
|
+
format_table(
|
|
190
|
+
["ID", "EMAIL", "USERNAME"],
|
|
191
|
+
[[user["id"], user["email"], user.get("username", "")]],
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
client.config.user_id = user["id"]
|
|
196
|
+
client.config.user_email = user["email"]
|
|
197
|
+
client.save_config()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Project commands
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
@cli.group()
|
|
205
|
+
def projects() -> None:
|
|
206
|
+
"""Manage projects."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@projects.command("list")
|
|
210
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
211
|
+
@click.pass_obj
|
|
212
|
+
def projects_list(client: Client, as_json: bool) -> None:
|
|
213
|
+
"""List all projects you own or are a member of."""
|
|
214
|
+
result = client.get("/projects/@all")
|
|
215
|
+
|
|
216
|
+
if as_json:
|
|
217
|
+
import json as _json
|
|
218
|
+
|
|
219
|
+
click.echo(_json.dumps(result, indent=2))
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
if not result:
|
|
223
|
+
echo_info("No projects found")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
rows = []
|
|
227
|
+
for p in result:
|
|
228
|
+
rows.append(
|
|
229
|
+
[
|
|
230
|
+
p["id"],
|
|
231
|
+
p["name"],
|
|
232
|
+
p.get("slug", ""),
|
|
233
|
+
p.get("description", "") or "",
|
|
234
|
+
p.get("createdAt", ""),
|
|
235
|
+
]
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
click.echo(
|
|
239
|
+
format_table(
|
|
240
|
+
["ID", "NAME", "SLUG", "DESCRIPTION", "CREATED"],
|
|
241
|
+
rows,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@projects.command("create")
|
|
247
|
+
@click.option("--name", required=True, help="Project name")
|
|
248
|
+
@click.option("--description", help="Project description")
|
|
249
|
+
@click.pass_obj
|
|
250
|
+
def projects_create(client: Client, name: str, description: Optional[str]) -> None:
|
|
251
|
+
"""Create a new project."""
|
|
252
|
+
body: dict[str, str] = {"name": name}
|
|
253
|
+
if description:
|
|
254
|
+
body["description"] = description
|
|
255
|
+
|
|
256
|
+
project = client.post("/projects", body)
|
|
257
|
+
echo_success(f"Project created: {project['name']} ({project['id']})")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@projects.command("delete")
|
|
261
|
+
@click.option("--id", "project_id", required=True, help="Project ID")
|
|
262
|
+
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
|
|
263
|
+
@click.pass_obj
|
|
264
|
+
def projects_delete(client: Client, project_id: str, force: bool) -> None:
|
|
265
|
+
"""Delete a project and all its secrets.
|
|
266
|
+
|
|
267
|
+
Asks for confirmation unless ``--force`` is passed.
|
|
268
|
+
"""
|
|
269
|
+
if not force and not confirm_action(
|
|
270
|
+
f"Delete project {project_id}? All secrets will also be deleted."
|
|
271
|
+
):
|
|
272
|
+
echo_info("Aborted")
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
client.delete(f"/projects/{project_id}")
|
|
276
|
+
echo_success(f"Project {project_id} deleted")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Secret commands
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
@cli.group()
|
|
284
|
+
def secrets() -> None:
|
|
285
|
+
"""Manage secrets."""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@secrets.command("list")
|
|
289
|
+
@click.option("--project-id", required=True, help="Project ID")
|
|
290
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
291
|
+
@click.pass_obj
|
|
292
|
+
def secrets_list(client: Client, project_id: str, as_json: bool) -> None:
|
|
293
|
+
"""List all secrets in a project.
|
|
294
|
+
|
|
295
|
+
Secret values are masked in table output; use ``sc secrets get`` with
|
|
296
|
+
``--show-value`` to reveal a single secret.
|
|
297
|
+
"""
|
|
298
|
+
result = client.get(f"/projects/{project_id}/secrets")
|
|
299
|
+
|
|
300
|
+
if as_json:
|
|
301
|
+
import json as _json
|
|
302
|
+
|
|
303
|
+
click.echo(_json.dumps(result, indent=2))
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
if not result:
|
|
307
|
+
echo_info("No secrets found in this project")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
rows = []
|
|
311
|
+
for s in result:
|
|
312
|
+
rows.append(
|
|
313
|
+
[
|
|
314
|
+
s["id"],
|
|
315
|
+
s["name"],
|
|
316
|
+
mask_value(s.get("value", "")),
|
|
317
|
+
s.get("notes", "") or "",
|
|
318
|
+
s.get("updatedAt", ""),
|
|
319
|
+
]
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
click.echo(
|
|
323
|
+
format_table(
|
|
324
|
+
["ID", "NAME", "VALUE", "NOTES", "UPDATED"],
|
|
325
|
+
rows,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@secrets.command("get")
|
|
331
|
+
@click.option("--id", "secret_id", required=True, help="Secret ID")
|
|
332
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
333
|
+
@click.option("--show-value", is_flag=True, help="Display the secret value in plain text")
|
|
334
|
+
@click.pass_obj
|
|
335
|
+
def secrets_get(
|
|
336
|
+
client: Client, secret_id: str, as_json: bool, show_value: bool
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Retrieve a single secret by ID.
|
|
339
|
+
|
|
340
|
+
By default the value is masked. Pass ``--show-value`` to reveal it.
|
|
341
|
+
"""
|
|
342
|
+
secret = client.get(f"/projects/secrets/{secret_id}")
|
|
343
|
+
|
|
344
|
+
if as_json:
|
|
345
|
+
import json as _json
|
|
346
|
+
|
|
347
|
+
if not show_value:
|
|
348
|
+
secret_display = dict(secret)
|
|
349
|
+
secret_display["value"] = mask_value(secret_display.get("value", ""))
|
|
350
|
+
click.echo(_json.dumps(secret_display, indent=2))
|
|
351
|
+
else:
|
|
352
|
+
click.echo(_json.dumps(secret, indent=2))
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
value_display = (
|
|
356
|
+
secret.get("value", "")
|
|
357
|
+
if show_value
|
|
358
|
+
else mask_value(secret.get("value", "")) + " (use --show-value to reveal)"
|
|
359
|
+
)
|
|
360
|
+
click.echo(
|
|
361
|
+
format_table(
|
|
362
|
+
["FIELD", "VALUE"],
|
|
363
|
+
[
|
|
364
|
+
["ID", secret["id"]],
|
|
365
|
+
["Name", secret["name"]],
|
|
366
|
+
["Value", value_display],
|
|
367
|
+
["Notes", secret.get("notes", "") or ""],
|
|
368
|
+
["Project ID", secret.get("projectId", "")],
|
|
369
|
+
["Created", secret.get("createdAt", "")],
|
|
370
|
+
["Updated", secret.get("updatedAt", "")],
|
|
371
|
+
],
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@secrets.command("create")
|
|
377
|
+
@click.option("--project-id", required=True, help="Project ID")
|
|
378
|
+
@click.option("--name", required=True, help="Secret name (e.g. DATABASE_URL)")
|
|
379
|
+
@click.option("--value", required=True, help="Secret value")
|
|
380
|
+
@click.option("--notes", help="Optional notes for this secret")
|
|
381
|
+
@click.pass_obj
|
|
382
|
+
def secrets_create(
|
|
383
|
+
client: Client,
|
|
384
|
+
project_id: str,
|
|
385
|
+
name: str,
|
|
386
|
+
value: str,
|
|
387
|
+
notes: Optional[str],
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Create a new secret in a project."""
|
|
390
|
+
body: dict[str, str] = {"name": name, "value": value}
|
|
391
|
+
if notes:
|
|
392
|
+
body["notes"] = notes
|
|
393
|
+
|
|
394
|
+
secret = client.post(f"/projects/{project_id}/secrets", body)
|
|
395
|
+
echo_success(f"Secret created: {secret['name']} ({secret['id']})")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@secrets.command("update")
|
|
399
|
+
@click.option("--id", "secret_id", required=True, help="Secret ID")
|
|
400
|
+
@click.option("--name", help="New name for the secret")
|
|
401
|
+
@click.option("--value", help="New value for the secret")
|
|
402
|
+
@click.option("--notes", help="New notes for the secret")
|
|
403
|
+
@click.pass_obj
|
|
404
|
+
def secrets_update(
|
|
405
|
+
client: Client,
|
|
406
|
+
secret_id: str,
|
|
407
|
+
name: Optional[str],
|
|
408
|
+
value: Optional[str],
|
|
409
|
+
notes: Optional[str],
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Update an existing secret.
|
|
412
|
+
|
|
413
|
+
Only the fields you pass will be changed; omitted fields stay
|
|
414
|
+
unchanged.
|
|
415
|
+
"""
|
|
416
|
+
body: dict[str, str] = {}
|
|
417
|
+
if name:
|
|
418
|
+
body["name"] = name
|
|
419
|
+
if value:
|
|
420
|
+
body["value"] = value
|
|
421
|
+
if notes is not None:
|
|
422
|
+
body["notes"] = notes
|
|
423
|
+
|
|
424
|
+
if not body:
|
|
425
|
+
echo_info("No fields to update")
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
secret = client.put(f"/projects/secrets/{secret_id}", body)
|
|
429
|
+
echo_success(f"Secret {secret['id']} updated")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@secrets.command("delete")
|
|
433
|
+
@click.option("--id", "secret_id", required=True, help="Secret ID")
|
|
434
|
+
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
|
|
435
|
+
@click.pass_obj
|
|
436
|
+
def secrets_delete(client: Client, secret_id: str, force: bool) -> None:
|
|
437
|
+
"""Delete a secret permanently."""
|
|
438
|
+
if not force and not confirm_action(f"Delete secret {secret_id}?"):
|
|
439
|
+
echo_info("Aborted")
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
client.delete(f"/projects/secrets/{secret_id}")
|
|
443
|
+
echo_success(f"Secret {secret_id} deleted")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@secrets.command("export")
|
|
447
|
+
@click.option("--project-id", required=True, help="Project ID")
|
|
448
|
+
@click.option(
|
|
449
|
+
"--output",
|
|
450
|
+
"-o",
|
|
451
|
+
"output_file",
|
|
452
|
+
help="Output file path (prints to stdout if omitted)",
|
|
453
|
+
)
|
|
454
|
+
@click.pass_obj
|
|
455
|
+
def secrets_export(
|
|
456
|
+
client: Client, project_id: str, output_file: Optional[str]
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Export all project secrets as a ``.env`` file.
|
|
459
|
+
|
|
460
|
+
Writes dotenv-formatted output to stdout or to the file specified
|
|
461
|
+
with ``-o``. The output file is created with mode ``0600``.
|
|
462
|
+
"""
|
|
463
|
+
data = client.get_raw(f"/projects/{project_id}/secrets/export")
|
|
464
|
+
|
|
465
|
+
if output_file:
|
|
466
|
+
Path(output_file).write_text(data)
|
|
467
|
+
os.chmod(output_file, 0o600)
|
|
468
|
+
echo_success(f"Secrets exported to {output_file}")
|
|
469
|
+
else:
|
|
470
|
+
click.echo(data)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
# API key commands
|
|
475
|
+
# ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
@cli.group(name="api-keys")
|
|
478
|
+
def api_keys() -> None:
|
|
479
|
+
"""Manage API keys for programmatic access."""
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@api_keys.command("list")
|
|
483
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
484
|
+
@click.pass_obj
|
|
485
|
+
def api_keys_list(client: Client, as_json: bool) -> None:
|
|
486
|
+
"""List all API keys for your account."""
|
|
487
|
+
keys = client.get("/api-keys/@all-user")
|
|
488
|
+
|
|
489
|
+
if as_json:
|
|
490
|
+
import json as _json
|
|
491
|
+
|
|
492
|
+
click.echo(_json.dumps(keys, indent=2))
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
if not keys:
|
|
496
|
+
echo_info("No API keys found")
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
rows = []
|
|
500
|
+
for k in keys:
|
|
501
|
+
status = "active" if k.get("isActive", True) else "inactive"
|
|
502
|
+
perms = ", ".join(k.get("permissions", []))
|
|
503
|
+
rows.append(
|
|
504
|
+
[
|
|
505
|
+
k["id"],
|
|
506
|
+
k.get("keyName", ""),
|
|
507
|
+
status,
|
|
508
|
+
perms,
|
|
509
|
+
k.get("createdAt", ""),
|
|
510
|
+
k.get("expiresAt", ""),
|
|
511
|
+
]
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
click.echo(
|
|
515
|
+
format_table(
|
|
516
|
+
["ID", "NAME", "STATUS", "PERMISSIONS", "CREATED", "EXPIRES"],
|
|
517
|
+
rows,
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@api_keys.command("create")
|
|
523
|
+
@click.option("--name", required=True, help="Human-readable label for the key")
|
|
524
|
+
@click.option(
|
|
525
|
+
"--permissions",
|
|
526
|
+
default="read,write",
|
|
527
|
+
help="Comma-separated permissions: read, write",
|
|
528
|
+
)
|
|
529
|
+
@click.pass_obj
|
|
530
|
+
def api_keys_create(client: Client, name: str, permissions: str) -> None:
|
|
531
|
+
"""Create a new API key.
|
|
532
|
+
|
|
533
|
+
The key value is displayed **only once** at creation time — save it
|
|
534
|
+
immediately. Permissions can be ``read`` and/or ``write``.
|
|
535
|
+
"""
|
|
536
|
+
perm_list = [p.strip() for p in permissions.split(",") if p.strip()]
|
|
537
|
+
|
|
538
|
+
key = client.post("/api-keys", {"name": name, "permissions": perm_list})
|
|
539
|
+
|
|
540
|
+
click.echo("")
|
|
541
|
+
click.echo("\u250c" + "\u2500" * 47 + "\u2510")
|
|
542
|
+
click.echo("\u2502 IMPORTANT: Save this key securely! \u2502")
|
|
543
|
+
click.echo("\u2502 It will NOT be shown again. \u2502")
|
|
544
|
+
click.echo("\u251c" + "\u2500" * 47 + "\u2524")
|
|
545
|
+
click.echo(f"\u2502 Key: {key['key']:<38} \u2502")
|
|
546
|
+
click.echo(f"\u2502 ID: {key['id']:<38} \u2502")
|
|
547
|
+
click.echo(
|
|
548
|
+
f"\u2502 Perm: {', '.join(key.get('permissions', [])):<38} \u2502"
|
|
549
|
+
)
|
|
550
|
+
click.echo("\u2514" + "\u2500" * 47 + "\u2518")
|
|
551
|
+
echo_success(f"API key created: {key.get('keyName', name)}")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@api_keys.command("delete")
|
|
555
|
+
@click.option("--id", "key_id", required=True, help="API key ID")
|
|
556
|
+
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
|
|
557
|
+
@click.pass_obj
|
|
558
|
+
def api_keys_delete(client: Client, key_id: str, force: bool) -> None:
|
|
559
|
+
"""Delete an API key permanently."""
|
|
560
|
+
if not force and not confirm_action(f"Delete API key {key_id}?"):
|
|
561
|
+
echo_info("Aborted")
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
client.delete(f"/api-keys/{key_id}")
|
|
565
|
+
echo_success(f"API key {key_id} deleted")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
# User commands
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
@cli.group()
|
|
573
|
+
def user() -> None:
|
|
574
|
+
"""User information and settings."""
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@user.command("info")
|
|
578
|
+
@click.pass_obj
|
|
579
|
+
def user_info(client: Client) -> None:
|
|
580
|
+
"""Show your user profile information."""
|
|
581
|
+
u = client.get("/users/@me")
|
|
582
|
+
|
|
583
|
+
click.echo(
|
|
584
|
+
format_table(
|
|
585
|
+
["FIELD", "VALUE"],
|
|
586
|
+
[
|
|
587
|
+
["ID", u["id"]],
|
|
588
|
+
["Email", u["email"]],
|
|
589
|
+
["Username", u.get("username", "")],
|
|
590
|
+
["Role", u.get("role", "")],
|
|
591
|
+
["Joined", u.get("createdAt", "")],
|
|
592
|
+
],
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
client.config.user_id = u["id"]
|
|
597
|
+
client.config.user_email = u["email"]
|
|
598
|
+
client.save_config()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
# Meta commands
|
|
603
|
+
# ---------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
@cli.command("version")
|
|
606
|
+
def version() -> None:
|
|
607
|
+
"""Print the CLI version and exit."""
|
|
608
|
+
click.echo(f"Secryn CLI v{__version__}")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@cli.command("config")
|
|
612
|
+
def config_cmd() -> None:
|
|
613
|
+
"""Show current CLI configuration paths and values."""
|
|
614
|
+
_cfg = load_config()
|
|
615
|
+
|
|
616
|
+
click.echo(
|
|
617
|
+
format_table(
|
|
618
|
+
["SETTING", "VALUE"],
|
|
619
|
+
[
|
|
620
|
+
["API URL", _cfg.api_url],
|
|
621
|
+
["Config file", str(config_path())],
|
|
622
|
+
["Cookie jar", str(cookie_jar_path())],
|
|
623
|
+
["Logged in as", _cfg.user_email or "(not logged in)"],
|
|
624
|
+
],
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ---------------------------------------------------------------------------
|
|
630
|
+
# Entry point
|
|
631
|
+
# ---------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
def main() -> None:
|
|
634
|
+
"""Entry point that wraps the Click CLI group with custom error handling.
|
|
635
|
+
|
|
636
|
+
Pre-processes the ``--api-url`` flag from ``sys.argv`` so the URL is
|
|
637
|
+
persisted to disk even when no subcommand is given (Click does not invoke
|
|
638
|
+
the group callback when a subcommand is absent).
|
|
639
|
+
|
|
640
|
+
Uses ``standalone_mode=False`` so exceptions propagate to this function
|
|
641
|
+
instead of being caught by Click's built-in handlers. This allows a
|
|
642
|
+
unified exit-code strategy:
|
|
643
|
+
|
|
644
|
+
- ``SystemExit`` (including ``click.exceptions.Exit``) → exit with the
|
|
645
|
+
exception's code, preserving Click's own exit semantics.
|
|
646
|
+
- ``NoArgsIsHelpError`` → display the command list and exit 0.
|
|
647
|
+
- ``UsageError`` with *Missing command* / *No such command* → show
|
|
648
|
+
the error text followed by the command list, exit 0.
|
|
649
|
+
- Other ``UsageError`` / ``ClickException`` / ``APIError`` /
|
|
650
|
+
``Exception`` → print the message to stderr and exit 1.
|
|
651
|
+
|
|
652
|
+
Note:
|
|
653
|
+
Exit codes 0 and 1 are the only codes emitted by this wrapper;
|
|
654
|
+
Click's own ``--help`` handling and built-in validation errors
|
|
655
|
+
(e.g. ``NoSuchOption``) may produce code 2 before reaching here.
|
|
656
|
+
"""
|
|
657
|
+
args = sys.argv[1:]
|
|
658
|
+
for i, arg in enumerate(args):
|
|
659
|
+
if arg == "--api-url" and i + 1 < len(args):
|
|
660
|
+
api_url = args[i + 1]
|
|
661
|
+
cfg = load_config()
|
|
662
|
+
if api_url != cfg.api_url:
|
|
663
|
+
cfg.api_url = api_url
|
|
664
|
+
save_config(cfg)
|
|
665
|
+
echo_info(f"API URL set to {api_url}")
|
|
666
|
+
break
|
|
667
|
+
|
|
668
|
+
exit_code = 0
|
|
669
|
+
try:
|
|
670
|
+
cli.main(args=args, prog_name="sc", standalone_mode=False)
|
|
671
|
+
except click.exceptions.Exit as e:
|
|
672
|
+
exit_code = e.code if e.code is not None else 0
|
|
673
|
+
except click.ClickException as e:
|
|
674
|
+
if isinstance(e, click.exceptions.NoArgsIsHelpError):
|
|
675
|
+
click.echo(cli.get_help(click.Context(cli, info_name="sc")))
|
|
676
|
+
elif isinstance(e, click.UsageError):
|
|
677
|
+
msg = str(e)
|
|
678
|
+
if "Missing command" in msg or "No such command" in msg:
|
|
679
|
+
click.echo(f"Error: {msg}", err=True)
|
|
680
|
+
click.echo()
|
|
681
|
+
click.echo(cli.get_help(click.Context(cli, info_name="sc")))
|
|
682
|
+
else:
|
|
683
|
+
echo_error(msg)
|
|
684
|
+
exit_code = 1
|
|
685
|
+
else:
|
|
686
|
+
echo_error(str(e))
|
|
687
|
+
exit_code = 1
|
|
688
|
+
except APIError as e:
|
|
689
|
+
echo_error(str(e))
|
|
690
|
+
exit_code = 1
|
|
691
|
+
except Exception as e:
|
|
692
|
+
echo_error(str(e))
|
|
693
|
+
exit_code = 1
|
|
694
|
+
|
|
695
|
+
sys.exit(exit_code)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
if __name__ == "__main__":
|
|
699
|
+
main()
|