certmate-cli 0.1.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.
@@ -0,0 +1,111 @@
1
+ # Node (Tailwind CSS build)
2
+ node_modules/
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+ MANIFEST
26
+
27
+ # Virtual Environment
28
+ venv/
29
+ venv*/
30
+ .venv/
31
+ env/
32
+ ENV/
33
+
34
+ # Application specific
35
+ # SECURITY: Never commit settings.json - may contain API tokens and secrets
36
+ settings.json
37
+ data/settings.json
38
+ cloudflare.ini
39
+ certificates/
40
+ data/
41
+ logs/
42
+ *.pem
43
+ *.key
44
+ *.crt
45
+ *.csr
46
+ letsencrypt/
47
+ .secrets/
48
+
49
+ # Environment files
50
+ .env
51
+ .env.local
52
+ .env.production
53
+
54
+ # IDE
55
+ .vscode/
56
+ .idea/
57
+ *.swp
58
+ *.swo
59
+
60
+ # OS
61
+ .DS_Store
62
+ Thumbs.db
63
+
64
+ # Logs
65
+ *.log
66
+
67
+ # Backup files
68
+ *_old.*
69
+ *_backup.*
70
+
71
+ # Planning and development documents
72
+ NEXT_FEATURES.md
73
+ PLAN_2.0.md
74
+ PLAN_*.docx
75
+
76
+ htmlcov/
77
+ .pytest_cache/
78
+ .coverage
79
+ coverage.xml
80
+ test_results.json
81
+
82
+ # Local dev / debug scripts (kept on developer machines only)
83
+ debug_*.py
84
+
85
+ backups/
86
+ app.py.backup
87
+
88
+ .claude/
89
+ test_screenshots/
90
+
91
+ # Test files with sensitive data
92
+ test_real_cert_*.py
93
+ test_api_*.py
94
+ test_certificate_creation_*.py
95
+ real_world_test_*.py
96
+ integration_test_*.py
97
+ github_response.md
98
+
99
+ # local
100
+ PLAN.md
101
+ scratch/
102
+ test_settings.py
103
+ # Internal audit docs (not for distribution)
104
+ UI_AUDIT.md
105
+ UI_SHOTS_ANALYSIS.md
106
+
107
+ # Theme migration screenshot baseline (regenerated by scripts/theme_baseline.py)
108
+ theme-baseline/
109
+ UI_DRACONIAN_ANALYSIS.md
110
+ UI_UX_Opinioni_CertMate.pdf
111
+ UI_REDESIGN_PLAN.md
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: certmate-cli
3
+ Version: 0.1.0
4
+ Summary: CertMate command-line interface — the SSL certificate lifecycle from your terminal
5
+ Project-URL: Homepage, https://github.com/fabriziosalmi/certmate
6
+ Project-URL: Source, https://github.com/fabriziosalmi/certmate/tree/main/clients/certmate-cli
7
+ Author-email: Fabrizio Salmi <fabrizio.salmi@gmail.com>
8
+ License: MIT
9
+ Keywords: acme,certmate,cli,letsencrypt,tls
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: certmate-sdk>=0.1.0
27
+ Requires-Dist: rich>=13
28
+ Requires-Dist: typer>=0.9
29
+ Description-Content-Type: text/markdown
30
+
31
+ # certmate-cli
32
+
33
+ The [CertMate](https://github.com/fabriziosalmi/certmate) SSL certificate
34
+ lifecycle from your terminal — built on `certmate-sdk`.
35
+
36
+ ```bash
37
+ pip install certmate-cli
38
+ export CERTMATE_URL=http://localhost:8000
39
+ export CERTMATE_TOKEN=...
40
+
41
+ certmate cert create app.example.com --dns cloudflare --wait
42
+ certmate cert ls
43
+ certmate cert info app.example.com
44
+ certmate cert renew app.example.com --force
45
+ certmate cert create app.example.com --dns cloudflare --dry-run
46
+ certmate audit verify
47
+ ```
48
+
49
+ Connection comes from `--url`/`--token` or `CERTMATE_URL`/`CERTMATE_TOKEN`.
@@ -0,0 +1,19 @@
1
+ # certmate-cli
2
+
3
+ The [CertMate](https://github.com/fabriziosalmi/certmate) SSL certificate
4
+ lifecycle from your terminal — built on `certmate-sdk`.
5
+
6
+ ```bash
7
+ pip install certmate-cli
8
+ export CERTMATE_URL=http://localhost:8000
9
+ export CERTMATE_TOKEN=...
10
+
11
+ certmate cert create app.example.com --dns cloudflare --wait
12
+ certmate cert ls
13
+ certmate cert info app.example.com
14
+ certmate cert renew app.example.com --force
15
+ certmate cert create app.example.com --dns cloudflare --dry-run
16
+ certmate audit verify
17
+ ```
18
+
19
+ Connection comes from `--url`/`--token` or `CERTMATE_URL`/`CERTMATE_TOKEN`.
@@ -0,0 +1,2 @@
1
+ """certmate-cli — the CertMate SSL lifecycle from your terminal (built on certmate-sdk)."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,344 @@
1
+ """CertMate CLI — a thin, pleasant terminal front-end over certmate-sdk.
2
+
3
+ certmate cert create app.example.com --dns cloudflare --wait
4
+ certmate cert ls
5
+ certmate cert renew app.example.com --force
6
+ certmate audit verify
7
+
8
+ Connection comes from --url/--token or CERTMATE_URL/CERTMATE_TOKEN.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import List, Optional
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from certmate import Client, CertMateError, Job
20
+
21
+ app = typer.Typer(no_args_is_help=True, add_completion=False,
22
+ help="CertMate — the SSL certificate lifecycle from your terminal.")
23
+ cert_app = typer.Typer(no_args_is_help=True, help="Manage certificates.")
24
+ dns_app = typer.Typer(no_args_is_help=True, help="DNS providers and accounts.")
25
+ audit_app = typer.Typer(no_args_is_help=True, help="Tamper-evident audit trail.")
26
+ backup_app = typer.Typer(no_args_is_help=True, help="Backups.")
27
+ deploy_app = typer.Typer(no_args_is_help=True, help="Post-issuance deploy hooks.")
28
+ app.add_typer(cert_app, name="cert")
29
+ app.add_typer(dns_app, name="dns")
30
+ app.add_typer(audit_app, name="audit")
31
+ app.add_typer(backup_app, name="backup")
32
+ app.add_typer(deploy_app, name="deploy")
33
+
34
+ out = Console()
35
+ err = Console(stderr=True)
36
+
37
+ # A lax hostname/wildcard check for the client-side --dry-run preflight; the
38
+ # server is the real authority, this just catches obvious typos before we
39
+ # spend an API call.
40
+ _DOMAIN_RE = re.compile(
41
+ r"^(\*\.)?([a-zA-Z0-9_](-*[a-zA-Z0-9_])*\.)+[a-zA-Z]{2,}$")
42
+
43
+
44
+ @app.callback()
45
+ def _main(
46
+ ctx: typer.Context,
47
+ url: Optional[str] = typer.Option(None, "--url", envvar="CERTMATE_URL",
48
+ help="CertMate base URL (default http://localhost:8000)."),
49
+ token: Optional[str] = typer.Option(None, "--token", envvar="CERTMATE_TOKEN",
50
+ help="API bearer token."),
51
+ ):
52
+ ctx.obj = {"url": url, "token": token}
53
+
54
+
55
+ def _client(ctx: typer.Context) -> Client:
56
+ o = ctx.obj or {}
57
+ return Client(o.get("url"), o.get("token"))
58
+
59
+
60
+ def _die(msg: str, code: int = 1):
61
+ err.print(f"[bold red]error[/]: {msg}")
62
+ raise typer.Exit(code)
63
+
64
+
65
+ def _run(fn):
66
+ """Execute an SDK call, turning SDK errors into clean CLI failures."""
67
+ try:
68
+ return fn()
69
+ except CertMateError as e:
70
+ _die(str(e))
71
+
72
+
73
+ def _records(data) -> List[dict]:
74
+ """Coerce a list/dict API response into a list of record dicts."""
75
+ if isinstance(data, list):
76
+ return [d for d in data if isinstance(d, dict)]
77
+ if isinstance(data, dict):
78
+ for key in ("items", "accounts", "backups", "results", "data", "unified"):
79
+ v = data.get(key)
80
+ if isinstance(v, list):
81
+ return [d for d in v if isinstance(d, dict)]
82
+ return []
83
+
84
+
85
+ def _table(records: List[dict], columns: List[str]) -> None:
86
+ """Print a rich table of ``records`` over ``columns`` (missing keys -> '-')."""
87
+ if not records:
88
+ out.print("[dim]nothing to show.[/]")
89
+ return
90
+ table = Table(box=None, header_style="bold")
91
+ for c in columns:
92
+ table.add_column(c.upper())
93
+ for r in records:
94
+ table.add_row(*[str(r.get(c, "-")) for c in columns])
95
+ out.print(table)
96
+
97
+
98
+ # --------------------------------------------------------------------------
99
+ # cert
100
+ # --------------------------------------------------------------------------
101
+
102
+ @cert_app.command("ls")
103
+ def cert_ls(ctx: typer.Context):
104
+ """List certificates with expiry and status."""
105
+ certs = _run(lambda: _client(ctx).list_certificates())
106
+ if not certs:
107
+ out.print("[dim]No certificates.[/]")
108
+ return
109
+ table = Table(box=None, header_style="bold")
110
+ table.add_column("DOMAIN")
111
+ table.add_column("EXPIRES")
112
+ table.add_column("DAYS", justify="right")
113
+ table.add_column("CA")
114
+ table.add_column("AUTO-RENEW")
115
+ for c in sorted(certs, key=lambda x: (x.days_until_expiry if x.days_until_expiry is not None else 1 << 30)):
116
+ days = c.days_until_expiry
117
+ days_str = "-" if days is None else str(days)
118
+ colour = "green"
119
+ if days is not None:
120
+ colour = "red" if days < 7 else ("yellow" if days < 30 else "green")
121
+ table.add_row(
122
+ c.domain,
123
+ (c.expiry_date or "-")[:10],
124
+ f"[{colour}]{days_str}[/]",
125
+ c.ca_provider or "-",
126
+ "on" if c.auto_renew else ("off" if c.auto_renew is not None else "-"),
127
+ )
128
+ out.print(table)
129
+
130
+
131
+ @cert_app.command("info")
132
+ def cert_info(ctx: typer.Context, domain: str):
133
+ """Show a certificate's details."""
134
+ c = _run(lambda: _client(ctx).get_certificate(domain))
135
+ out.print(f"[bold]{c.domain}[/]")
136
+ out.print(f" expires: {c.expiry_date or '-'} "
137
+ f"({c.days_until_expiry if c.days_until_expiry is not None else '?'} days)")
138
+ out.print(f" CA: {c.ca_provider or '-'}")
139
+ out.print(f" DNS: {c.dns_provider or '-'}")
140
+ out.print(f" auto-renew: {c.auto_renew}")
141
+ if c.san_domains:
142
+ out.print(f" SAN: {', '.join(c.san_domains)}")
143
+ if c.needs_renewal:
144
+ out.print(" [yellow]needs renewal[/]")
145
+
146
+
147
+ @cert_app.command("create")
148
+ def cert_create(
149
+ ctx: typer.Context,
150
+ domain: str,
151
+ dns: Optional[str] = typer.Option(None, "--dns", help="DNS provider (e.g. cloudflare)."),
152
+ ca: Optional[str] = typer.Option(None, "--ca", help="CA provider (e.g. letsencrypt)."),
153
+ san: Optional[str] = typer.Option(None, "--san", help="Comma-separated SAN domains."),
154
+ wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for issuance to finish."),
155
+ dry_run: bool = typer.Option(False, "--dry-run",
156
+ help="Validate inputs and preflight the DNS provider WITHOUT issuing."),
157
+ ):
158
+ """Issue a certificate (async; waits for completion by default)."""
159
+ sans: List[str] = [s.strip() for s in san.split(",")] if san else []
160
+ client = _client(ctx)
161
+
162
+ if dry_run:
163
+ problems = []
164
+ if not _DOMAIN_RE.match(domain):
165
+ problems.append(f"domain {domain!r} does not look valid")
166
+ for s in sans:
167
+ if not _DOMAIN_RE.match(s):
168
+ problems.append(f"SAN {s!r} does not look valid")
169
+ if dns:
170
+ res = _run(lambda: client.test_dns_provider(dns))
171
+ ok = bool(res.get("success", res.get("ok", True)))
172
+ msg = res.get("message") or res.get("error") or ("reachable" if ok else "failed")
173
+ out.print(f"DNS provider [bold]{dns}[/]: {'[green]OK[/]' if ok else '[red]FAIL[/]'} — {msg}")
174
+ if not ok:
175
+ problems.append("DNS provider preflight failed")
176
+ else:
177
+ out.print("[dim]No --dns given; skipping provider preflight.[/]")
178
+ if problems:
179
+ _die("dry-run found issues:\n - " + "\n - ".join(problems))
180
+ out.print(f"[green]dry-run OK[/] — would issue [bold]{domain}[/]"
181
+ f"{(' (+' + str(len(sans)) + ' SAN)') if sans else ''}"
182
+ f"{(' via ' + dns) if dns else ''}{(' on ' + ca) if ca else ''}. Nothing issued.")
183
+ return
184
+
185
+ def _issue():
186
+ with out.status(f"Issuing [bold]{domain}[/] …") as status:
187
+ def _progress(j: Job):
188
+ if j.status:
189
+ status.update(f"Issuing [bold]{domain}[/] … [{j.status}]")
190
+ return client.create_certificate(
191
+ domain, dns_provider=dns, ca_provider=ca, san_domains=sans or None,
192
+ wait=wait, on_progress=_progress)
193
+
194
+ job = _run(_issue)
195
+ if wait:
196
+ out.print(f"[green]issued[/] [bold]{domain}[/]"
197
+ f"{(' (' + job.status + ')') if job.status else ''}.")
198
+ else:
199
+ out.print(f"[yellow]accepted[/] — job [bold]{job.job_id}[/] "
200
+ f"(poll: certmate cert job {job.job_id}).")
201
+
202
+
203
+ @cert_app.command("job")
204
+ def cert_job(ctx: typer.Context, job_id: str):
205
+ """Show the status of an async issuance/renewal job."""
206
+ j = _run(lambda: _client(ctx).get_job(job_id))
207
+ out.print(f"job [bold]{j.job_id}[/]: {j.status or '?'}"
208
+ f"{(' — ' + j.error) if j.error else ''}")
209
+
210
+
211
+ @cert_app.command("renew")
212
+ def cert_renew(ctx: typer.Context, domain: str,
213
+ force: bool = typer.Option(False, "--force", help="Force renewal even if not due.")):
214
+ """Renew a certificate."""
215
+ res = _run(lambda: _client(ctx).renew_certificate(domain, force=force))
216
+ if res.get("renewed") is False:
217
+ out.print(f"[yellow]not due[/] — {domain} was not yet due for renewal.")
218
+ else:
219
+ out.print(f"[green]renewed[/] {domain}.")
220
+
221
+
222
+ @cert_app.command("rm")
223
+ def cert_rm(ctx: typer.Context, domain: str,
224
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation.")):
225
+ """Delete a certificate."""
226
+ if not yes:
227
+ typer.confirm(f"Delete certificate {domain}?", abort=True)
228
+ _run(lambda: _client(ctx).delete_certificate(domain))
229
+ out.print(f"[green]deleted[/] {domain}.")
230
+
231
+
232
+ @cert_app.command("reissue")
233
+ def cert_reissue(ctx: typer.Context, domain: str,
234
+ san: Optional[str] = typer.Option(None, "--san", help="Comma-separated SAN domains.")):
235
+ """Reissue a certificate (e.g. to change its SAN list)."""
236
+ body = {}
237
+ if san is not None:
238
+ body["san_domains"] = [s.strip() for s in san.split(",") if s.strip()]
239
+ _run(lambda: _client(ctx).reissue_certificate(domain, **body))
240
+ out.print(f"[green]reissued[/] {domain}.")
241
+
242
+
243
+ # --------------------------------------------------------------------------
244
+ # dns
245
+ # --------------------------------------------------------------------------
246
+
247
+ @dns_app.command("providers")
248
+ def dns_providers(ctx: typer.Context):
249
+ """List supported DNS providers."""
250
+ out.print(_run(lambda: _client(ctx).list_dns_providers()))
251
+
252
+
253
+ @dns_app.command("accounts")
254
+ def dns_accounts(ctx: typer.Context,
255
+ provider: Optional[str] = typer.Argument(None, help="Filter by provider.")):
256
+ """List configured DNS accounts."""
257
+ data = _run(lambda: _client(ctx).list_dns_accounts(provider))
258
+ _table(_records(data), ["provider", "account_id", "name", "email"])
259
+
260
+
261
+ @dns_app.command("test")
262
+ def dns_test(ctx: typer.Context, provider: str):
263
+ """Preflight a DNS provider (does not issue)."""
264
+ res = _run(lambda: _client(ctx).test_dns_provider(provider))
265
+ ok = bool(res.get("success", res.get("ok", True)))
266
+ msg = res.get("message") or res.get("error") or ("reachable" if ok else "failed")
267
+ out.print(f"[bold]{provider}[/]: {'[green]OK[/]' if ok else '[red]FAIL[/]'} — {msg}")
268
+ if not ok:
269
+ raise typer.Exit(1)
270
+
271
+
272
+ # --------------------------------------------------------------------------
273
+ # audit
274
+ # --------------------------------------------------------------------------
275
+
276
+ @audit_app.command("verify")
277
+ def audit_verify(ctx: typer.Context):
278
+ """Verify the tamper-evident audit chain."""
279
+ res = _run(lambda: _client(ctx).audit_verify())
280
+ ok = bool(res.get("ok"))
281
+ reason = res.get("reason") or ""
282
+ cp = res.get("checkpoint_verified")
283
+ # "chain file does not exist" / "empty chain" is a fresh instance that has
284
+ # not audited anything yet — benign, not a tamper alarm. Show it neutrally.
285
+ if not ok and any(k in reason.lower() for k in ("does not exist", "empty")):
286
+ out.print(f"audit chain: [dim]none yet[/] — {reason}")
287
+ return
288
+ out.print(f"audit chain: {'[green]intact[/]' if ok else '[red]BROKEN[/]'}"
289
+ f"{(' — ' + reason) if reason else ''}")
290
+ if cp is not None:
291
+ out.print(f" signed checkpoint: {'[green]verified[/]' if cp else '[dim]not cross-checked[/]'}"
292
+ f"{(' @ seq ' + str(res.get('checkpoint_seq'))) if res.get('checkpoint_seq') is not None else ''}")
293
+ if not ok:
294
+ raise typer.Exit(1)
295
+
296
+
297
+ # --------------------------------------------------------------------------
298
+ # backup
299
+ # --------------------------------------------------------------------------
300
+
301
+ @backup_app.command("ls")
302
+ def backup_ls(ctx: typer.Context):
303
+ """List backups."""
304
+ data = _run(lambda: _client(ctx).list_backups())
305
+ # Items are {filename, metadata:{size, created, backup_reason, ...}} — flatten.
306
+ flat = [{**r, **(r.get("metadata") or {})} for r in _records(data)]
307
+ _table(flat, ["filename", "backup_reason", "created", "size"])
308
+
309
+
310
+ @backup_app.command("create")
311
+ def backup_create(ctx: typer.Context):
312
+ """Create a backup now."""
313
+ res = _run(lambda: _client(ctx).create_backup())
314
+ name = (res or {}).get("filename") or (res or {}).get("backup") or "ok"
315
+ out.print(f"[green]backup created[/] {name}")
316
+
317
+
318
+ # --------------------------------------------------------------------------
319
+ # deploy
320
+ # --------------------------------------------------------------------------
321
+
322
+ @deploy_app.command("run")
323
+ def deploy_run(ctx: typer.Context, domain: str):
324
+ """Run the configured deploy hooks for a domain now."""
325
+ res = _run(lambda: _client(ctx).deploy_certificate(domain))
326
+ ok = bool((res or {}).get("ok", True))
327
+ out.print(f"deploy [bold]{domain}[/]: {'[green]ok[/]' if ok else '[red]failed[/]'}"
328
+ f"{(' — ' + str(res.get('message'))) if isinstance(res, dict) and res.get('message') else ''}")
329
+ if not ok:
330
+ raise typer.Exit(1)
331
+
332
+
333
+ # --------------------------------------------------------------------------
334
+ # top-level convenience
335
+ # --------------------------------------------------------------------------
336
+
337
+ @app.command("health")
338
+ def health(ctx: typer.Context):
339
+ """Check the CertMate instance health."""
340
+ out.print(_run(lambda: _client(ctx).health()))
341
+
342
+
343
+ if __name__ == "__main__":
344
+ app()
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "certmate-cli"
7
+ version = "0.1.0"
8
+ description = "CertMate command-line interface — the SSL certificate lifecycle from your terminal"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Fabrizio Salmi", email = "fabrizio.salmi@gmail.com" }]
13
+ keywords = ["certmate", "acme", "letsencrypt", "tls", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Security",
28
+ "Topic :: System :: Systems Administration",
29
+ "Topic :: Utilities",
30
+ ]
31
+ # Built ON the SDK (never the reverse). typer for the command surface, rich
32
+ # for tables/spinners. Still light — no server deps.
33
+ dependencies = [
34
+ "certmate-sdk>=0.1.0",
35
+ "typer>=0.9",
36
+ "rich>=13",
37
+ ]
38
+
39
+ [project.scripts]
40
+ certmate = "certmate_cli.main:app"
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/fabriziosalmi/certmate"
44
+ Source = "https://github.com/fabriziosalmi/certmate/tree/main/clients/certmate-cli"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["certmate_cli"]