tiny-pki 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.
- tiny_pki/__init__.py +110 -0
- tiny_pki/_build_metadata.py +6 -0
- tiny_pki/_fsutil.py +55 -0
- tiny_pki/_keys.py +74 -0
- tiny_pki/bundle.py +59 -0
- tiny_pki/check.py +318 -0
- tiny_pki/cli/__init__.py +1 -0
- tiny_pki/cli/commands.py +176 -0
- tiny_pki/cli/completer.py +91 -0
- tiny_pki/cli/completion.py +368 -0
- tiny_pki/cli/entry.py +27 -0
- tiny_pki/cli/handlers.py +969 -0
- tiny_pki/cli/main.py +281 -0
- tiny_pki/cli/theme.py +51 -0
- tiny_pki/constants.py +48 -0
- tiny_pki/errors.py +16 -0
- tiny_pki/inspect.py +99 -0
- tiny_pki/issue.py +613 -0
- tiny_pki/names.py +158 -0
- tiny_pki/py.typed +0 -0
- tiny_pki/revoke.py +88 -0
- tiny_pki/secrets.py +87 -0
- tiny_pki/store.py +1116 -0
- tiny_pki/version.py +111 -0
- tiny_pki-0.1.0.dist-info/METADATA +154 -0
- tiny_pki-0.1.0.dist-info/RECORD +29 -0
- tiny_pki-0.1.0.dist-info/WHEEL +4 -0
- tiny_pki-0.1.0.dist-info/entry_points.txt +2 -0
- tiny_pki-0.1.0.dist-info/licenses/LICENSE +21 -0
tiny_pki/cli/commands.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Command table, per-command flags, and help text for the tiny-pki CLI.
|
|
2
|
+
|
|
3
|
+
``COMMAND_FLAGS`` is the single source for which flags each verb accepts: the
|
|
4
|
+
flag parser, ``help <command>``, the REPL completer, and the bash/zsh/fish
|
|
5
|
+
completion scripts all read it, and ``docs/cli.md`` is tested against it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from tiny_pki.constants import KEY_TYPES
|
|
13
|
+
from tiny_pki.store import CHECK_KINDS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Flag:
|
|
18
|
+
"""One ``--name`` option of a command.
|
|
19
|
+
|
|
20
|
+
``value`` is the placeholder shown in help (``None`` for a switch that takes
|
|
21
|
+
no value). ``choices`` lists the accepted values for completion, ``path``
|
|
22
|
+
marks values that name a file, ``repeatable`` flags accumulate, and
|
|
23
|
+
``allow_empty`` lets ``--name`` without a value fall back to the default.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
help: str
|
|
28
|
+
value: str | None = None
|
|
29
|
+
choices: tuple[str, ...] = ()
|
|
30
|
+
path: bool = False
|
|
31
|
+
repeatable: bool = False
|
|
32
|
+
allow_empty: bool = False
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def option(self) -> str:
|
|
36
|
+
return f"--{self.name}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_DAYS = Flag("days", "validity in days (default 397 for clients, 90 for servers)", value="N", allow_empty=True)
|
|
40
|
+
_KEY_SIZE = Flag(
|
|
41
|
+
"key-size",
|
|
42
|
+
"RSA key size in bits (default 4096 for the CA, 3072 for leaves)",
|
|
43
|
+
value="BITS",
|
|
44
|
+
choices=("2048", "3072", "4096"),
|
|
45
|
+
)
|
|
46
|
+
_KEY_TYPE = Flag("key-type", "key algorithm (default rsa)", value="TYPE", choices=KEY_TYPES, allow_empty=True)
|
|
47
|
+
_ORG = Flag("org", "organization (O) in the subject", value="NAME", allow_empty=True)
|
|
48
|
+
_DRY_RUN = Flag("dry-run", "show what would change and write nothing")
|
|
49
|
+
_JSON = Flag("json", "machine-readable JSON output")
|
|
50
|
+
_PASSWORD_FILE = Flag("password-file", "read the PKCS#12 password from this file", value="PATH", path=True)
|
|
51
|
+
|
|
52
|
+
COMMAND_FLAGS: dict[str, tuple[Flag, ...]] = {
|
|
53
|
+
"check": (
|
|
54
|
+
Flag("by", "alert on anything expiring by this date", value="YYYY-MM-DD"),
|
|
55
|
+
Flag("ca", "CA certificate to verify file targets against", value="PATH", path=True),
|
|
56
|
+
Flag("crl", "CRL to check file targets against", value="PATH", path=True),
|
|
57
|
+
Flag("include-revoked", "also report revoked store entries"),
|
|
58
|
+
_JSON,
|
|
59
|
+
Flag(
|
|
60
|
+
"kind",
|
|
61
|
+
"only check this kind (repeatable)",
|
|
62
|
+
value="KIND",
|
|
63
|
+
choices=tuple(sorted(CHECK_KINDS)),
|
|
64
|
+
repeatable=True,
|
|
65
|
+
),
|
|
66
|
+
_PASSWORD_FILE,
|
|
67
|
+
Flag("quiet", "print only rows that need attention"),
|
|
68
|
+
Flag("within", "alert window in days (default: a third of each lifetime)", value="DAYS"),
|
|
69
|
+
),
|
|
70
|
+
"create": (
|
|
71
|
+
Flag("allow-dn-special-chars", 'allow , + = " < > ; or a leading # in the CN'),
|
|
72
|
+
Flag("allow-long-validity", "allow validity beyond the 200 / 825 day caps"),
|
|
73
|
+
_DAYS,
|
|
74
|
+
Flag("keep-previous", "client only: keep the previous certificate live for rotation"),
|
|
75
|
+
_KEY_SIZE,
|
|
76
|
+
_KEY_TYPE,
|
|
77
|
+
Flag("no-cn-san", "server only: do not add the CN to the SANs"),
|
|
78
|
+
_ORG,
|
|
79
|
+
Flag("san", "server only: DNS name or IP address (repeatable)", value="NAME", repeatable=True),
|
|
80
|
+
Flag("yes", "server only: add the CN to the SANs without asking"),
|
|
81
|
+
),
|
|
82
|
+
"crl": (Flag("days", "change the stored CRL lifetime (1-365)", value="N"),),
|
|
83
|
+
"delete": (Flag("force", "revoke an active certificate first"), _DRY_RUN),
|
|
84
|
+
"export": (
|
|
85
|
+
Flag("legacy", "p12 only: 3DES / SHA-1 encryption for old Android and Apple keychains"),
|
|
86
|
+
Flag("out", "output file", value="PATH", path=True),
|
|
87
|
+
_PASSWORD_FILE,
|
|
88
|
+
),
|
|
89
|
+
"init": (
|
|
90
|
+
Flag("cn", 'CA common name (default "Private CA")', value="NAME", allow_empty=True),
|
|
91
|
+
Flag("crl-days", "CRL lifetime in days (1-365, default 30)", value="N"),
|
|
92
|
+
Flag("days", "CA validity in days (default 3650)", value="N", allow_empty=True),
|
|
93
|
+
_KEY_SIZE,
|
|
94
|
+
_KEY_TYPE,
|
|
95
|
+
_ORG,
|
|
96
|
+
Flag("permit", "name constraint: DNS suffix or IP network (repeatable)", value="NAME", repeatable=True),
|
|
97
|
+
),
|
|
98
|
+
"inspect": (),
|
|
99
|
+
"list": (_JSON,),
|
|
100
|
+
"revoke": (_DRY_RUN,),
|
|
101
|
+
"show": (),
|
|
102
|
+
}
|
|
103
|
+
COMMAND_FLAGS["renew-crl"] = COMMAND_FLAGS["crl"]
|
|
104
|
+
COMMAND_FLAGS["completion"] = (
|
|
105
|
+
Flag("force", "with --install: overwrite a different existing script"),
|
|
106
|
+
Flag("install", "write the script to the per-user completion directory"),
|
|
107
|
+
_JSON,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
COMMAND_USAGE: dict[str, str] = {
|
|
111
|
+
"check": "check [PATH...]",
|
|
112
|
+
"completion": "completion bash|zsh|fish [--install] [--force] [--json]",
|
|
113
|
+
"create": "create client|server NAME",
|
|
114
|
+
"crl": "crl",
|
|
115
|
+
"delete": "delete NAME|0xSERIAL",
|
|
116
|
+
"edit-mode": "edit-mode [emacs|vim]",
|
|
117
|
+
"export": "export pem|p12 NAME",
|
|
118
|
+
"help": "help [COMMAND]",
|
|
119
|
+
"init": "init",
|
|
120
|
+
"inspect": "inspect NAME|PATH",
|
|
121
|
+
"list": "list [ca|certs|clients|servers|revoked]",
|
|
122
|
+
"renew-crl": "renew-crl",
|
|
123
|
+
"revoke": "revoke NAME|0xSERIAL",
|
|
124
|
+
"show": "show ca|certs|crl|NAME",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
COMMAND_HELP: tuple[tuple[str, str], ...] = (
|
|
128
|
+
(
|
|
129
|
+
"check",
|
|
130
|
+
"Flag expired, expiring, revoked or untrusted certificates and CRLs in the store or in files.",
|
|
131
|
+
),
|
|
132
|
+
("clear", "Clear the terminal screen."),
|
|
133
|
+
("completion", "Print or install bash/zsh/fish tab-completion scripts."),
|
|
134
|
+
("create", "Issue a client or server certificate."),
|
|
135
|
+
("crl", "Regenerate the CRL from revoked entries; --days N changes the stored CRL lifetime."),
|
|
136
|
+
("delete", "Remove a revoked certificate's files; --force revokes an active one first; --dry-run previews."),
|
|
137
|
+
("edit-mode", "Switch Emacs vs Vim keys: edit-mode emacs | vim."),
|
|
138
|
+
("exit", "Leave the REPL."),
|
|
139
|
+
("export", "Export pem|p12 for an identity."),
|
|
140
|
+
("help", "Show this list, or help <command> for its usage and flags."),
|
|
141
|
+
("init", "Create a new CA in --store."),
|
|
142
|
+
("inspect", "Inspect a store identity or PEM path."),
|
|
143
|
+
("list", "List ca|clients|servers|revoked|certs (optional --json)."),
|
|
144
|
+
("quit", "Leave the REPL (same as exit)."),
|
|
145
|
+
("renew-crl", "Alias for crl."),
|
|
146
|
+
("revoke", "Revoke an identity and regenerate the CRL; --dry-run previews."),
|
|
147
|
+
("show", "Show ca|certs|crl|<identity> (aliases list categories)."),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
COMMANDS: tuple[str, ...] = tuple(sorted({name for name, _ in COMMAND_HELP}))
|
|
151
|
+
|
|
152
|
+
POSITIONAL_CHOICES: dict[str, tuple[str, ...]] = {
|
|
153
|
+
"completion": ("bash", "fish", "zsh"),
|
|
154
|
+
"create": ("client", "server"),
|
|
155
|
+
"edit-mode": ("emacs", "vim"),
|
|
156
|
+
"export": ("p12", "pem"),
|
|
157
|
+
"help": COMMANDS,
|
|
158
|
+
"list": ("ca", "certs", "clients", "revoked", "servers"),
|
|
159
|
+
"show": ("ca", "certs", "clients", "crl", "revoked", "servers"),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
PKI_COMMANDS: frozenset[str] = frozenset(
|
|
163
|
+
{
|
|
164
|
+
"check",
|
|
165
|
+
"create",
|
|
166
|
+
"crl",
|
|
167
|
+
"delete",
|
|
168
|
+
"export",
|
|
169
|
+
"init",
|
|
170
|
+
"inspect",
|
|
171
|
+
"list",
|
|
172
|
+
"renew-crl",
|
|
173
|
+
"revoke",
|
|
174
|
+
"show",
|
|
175
|
+
}
|
|
176
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""prompt_toolkit completer for the tiny-pki REPL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
10
|
+
from prompt_toolkit.document import Document
|
|
11
|
+
|
|
12
|
+
from tiny_pki.cli.commands import COMMAND_FLAGS
|
|
13
|
+
from tiny_pki.cli.theme import Theme
|
|
14
|
+
|
|
15
|
+
_CMD_THEN_REST = re.compile(r"^(\S+)(\s+)(.*)$", re.DOTALL)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CmdCtx:
|
|
20
|
+
"""Completing a command name."""
|
|
21
|
+
|
|
22
|
+
partial: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ArgCtx:
|
|
27
|
+
"""Completing arguments for a known command; ``previous`` holds the complete tokens before ``partial``."""
|
|
28
|
+
|
|
29
|
+
command: str
|
|
30
|
+
partial: str
|
|
31
|
+
previous: tuple[str, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_completion_buffer(buf: str) -> CmdCtx | ArgCtx:
|
|
35
|
+
"""Classify the current input buffer for completion."""
|
|
36
|
+
stripped = buf.lstrip()
|
|
37
|
+
if not stripped:
|
|
38
|
+
return CmdCtx(partial="")
|
|
39
|
+
match = _CMD_THEN_REST.match(stripped)
|
|
40
|
+
if match is None:
|
|
41
|
+
return CmdCtx(partial=stripped)
|
|
42
|
+
command, _ws, rest = match.group(1), match.group(2), match.group(3)
|
|
43
|
+
tokens = tuple(rest.split())
|
|
44
|
+
if not rest or stripped[-1:].isspace():
|
|
45
|
+
return ArgCtx(command=command, partial="", previous=tokens)
|
|
46
|
+
return ArgCtx(command=command, partial=tokens[-1], previous=tokens[:-1])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ReplCompleter(Completer):
|
|
50
|
+
"""Complete command names, their flags and flag values, and positional argument tokens."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
commands: Iterable[str],
|
|
56
|
+
theme: Theme,
|
|
57
|
+
argument_tokens: Callable[[str], Iterable[str]] | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._commands = tuple(sorted(commands))
|
|
60
|
+
self._theme = theme
|
|
61
|
+
|
|
62
|
+
def _empty_tokens(_cmd: str) -> tuple[str, ...]:
|
|
63
|
+
return ()
|
|
64
|
+
|
|
65
|
+
self._argument_tokens: Callable[[str], Iterable[str]] = argument_tokens or _empty_tokens
|
|
66
|
+
|
|
67
|
+
def get_completions(self, document: Document, complete_event: object) -> Iterable[Completion]:
|
|
68
|
+
del complete_event
|
|
69
|
+
ctx = parse_completion_buffer(document.text_before_cursor)
|
|
70
|
+
if isinstance(ctx, CmdCtx):
|
|
71
|
+
style = self._theme.completion_command_style()
|
|
72
|
+
for cmd in self._commands:
|
|
73
|
+
if cmd.startswith(ctx.partial):
|
|
74
|
+
yield Completion(cmd, start_position=-len(ctx.partial), style=style)
|
|
75
|
+
return
|
|
76
|
+
style = self._theme.completion_parameter_style()
|
|
77
|
+
for token in self._candidates(ctx):
|
|
78
|
+
if token.startswith(ctx.partial):
|
|
79
|
+
yield Completion(token, start_position=-len(ctx.partial), style=style)
|
|
80
|
+
|
|
81
|
+
def _candidates(self, ctx: ArgCtx) -> Iterable[str]:
|
|
82
|
+
flags = COMMAND_FLAGS.get(ctx.command, ())
|
|
83
|
+
pending = next((f for f in flags if ctx.previous[-1:] == (f.option,)), None)
|
|
84
|
+
if pending is not None and pending.value is not None:
|
|
85
|
+
return pending.choices
|
|
86
|
+
used = set(ctx.previous)
|
|
87
|
+
unused = [f.option for f in flags if f.repeatable or f.option not in used]
|
|
88
|
+
if ctx.partial.startswith("-"):
|
|
89
|
+
return unused
|
|
90
|
+
tokens = list(self._argument_tokens(ctx.command))
|
|
91
|
+
return [*tokens, *unused] if not ctx.partial else tokens
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Shell completion script generation and optional per-user install."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from tiny_pki.cli.commands import COMMAND_FLAGS, COMMAND_HELP, COMMANDS, POSITIONAL_CHOICES, Flag
|
|
12
|
+
|
|
13
|
+
_PROG = "tiny-pki"
|
|
14
|
+
_SHELLS = frozenset({"bash", "fish", "zsh"})
|
|
15
|
+
_TOP_FLAGS = (
|
|
16
|
+
Flag("color", "colour output", value="WHEN", choices=("auto", "always", "never")),
|
|
17
|
+
Flag("edit-mode", "REPL key bindings", value="MODE", choices=POSITIONAL_CHOICES["edit-mode"]),
|
|
18
|
+
Flag("help", "show launcher help"),
|
|
19
|
+
Flag("store", "store directory (or TINY_PKI_STORE)", value="DIR", path=True),
|
|
20
|
+
Flag("version", "print the version and exit"),
|
|
21
|
+
)
|
|
22
|
+
# Commands whose positional arguments are file paths.
|
|
23
|
+
_PATH_POSITIONALS = frozenset({"check", "inspect"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def completion_install_path(shell: str) -> Path:
|
|
27
|
+
"""Conventional per-user path for a shell's tiny-pki completion script."""
|
|
28
|
+
xdg_data = _xdg_base("XDG_DATA_HOME", Path.home() / ".local" / "share")
|
|
29
|
+
if shell == "bash":
|
|
30
|
+
return xdg_data / "bash-completion" / "completions" / "tiny-pki.bash"
|
|
31
|
+
if shell == "fish":
|
|
32
|
+
xdg_config = _xdg_base("XDG_CONFIG_HOME", Path.home() / ".config")
|
|
33
|
+
return xdg_config / "fish" / "completions" / "tiny-pki.fish"
|
|
34
|
+
return xdg_data / "zsh" / "site-functions" / "_tiny-pki"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def completion_script(shell: str) -> str:
|
|
38
|
+
"""Return a tab-completion script for ``bash``, ``zsh``, or ``fish``."""
|
|
39
|
+
if shell == "bash":
|
|
40
|
+
return _bash_script()
|
|
41
|
+
if shell == "fish":
|
|
42
|
+
return _fish_script()
|
|
43
|
+
if shell == "zsh":
|
|
44
|
+
return _zsh_script()
|
|
45
|
+
raise ValueError(f"unsupported shell: {shell!r} (expected bash, zsh, or fish)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_completion(argv: list[str]) -> int:
|
|
49
|
+
"""Handle ``tiny-pki completion <shell> [--install] [--force] [--json]``.
|
|
50
|
+
|
|
51
|
+
Returns 0 on success, 2 on usage errors, 1 on install I/O failures.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
shell, install, force, as_json = _parse_completion_argv(argv)
|
|
55
|
+
except SystemExit as exc:
|
|
56
|
+
code = exc.code
|
|
57
|
+
return int(code) if isinstance(code, int) else 2
|
|
58
|
+
if force and not install:
|
|
59
|
+
_emit_error(
|
|
60
|
+
message="--force only applies with --install",
|
|
61
|
+
as_json=as_json,
|
|
62
|
+
hint="Pass --install, or drop --force.",
|
|
63
|
+
)
|
|
64
|
+
return 2
|
|
65
|
+
|
|
66
|
+
script = completion_script(shell)
|
|
67
|
+
if not script.endswith("\n"):
|
|
68
|
+
script += "\n"
|
|
69
|
+
|
|
70
|
+
if not install:
|
|
71
|
+
if as_json:
|
|
72
|
+
print(json.dumps({"shell": shell, "script": script}, sort_keys=True))
|
|
73
|
+
else:
|
|
74
|
+
sys.stdout.write(script)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
path = completion_install_path(shell)
|
|
78
|
+
want = script.encode()
|
|
79
|
+
|
|
80
|
+
def _refuse_clobber() -> NoReturn:
|
|
81
|
+
_emit_error(
|
|
82
|
+
message=f"{path} already exists with different contents",
|
|
83
|
+
as_json=as_json,
|
|
84
|
+
hint="Re-run with --force to overwrite it.",
|
|
85
|
+
)
|
|
86
|
+
raise SystemExit(2)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# Refuse symlinks (including dangling) so --force cannot rewrite a
|
|
90
|
+
# link target outside the conventional install path.
|
|
91
|
+
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
92
|
+
_emit_error(
|
|
93
|
+
message=f"{path} exists but is not a regular file",
|
|
94
|
+
as_json=as_json,
|
|
95
|
+
hint="Remove it (or point XDG_DATA_HOME/XDG_CONFIG_HOME elsewhere), then retry.",
|
|
96
|
+
)
|
|
97
|
+
return 1
|
|
98
|
+
current = path.read_bytes() if path.is_file() else None
|
|
99
|
+
if current == want:
|
|
100
|
+
action = "unchanged"
|
|
101
|
+
elif current is not None and not force:
|
|
102
|
+
_refuse_clobber()
|
|
103
|
+
elif force:
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_bytes(want)
|
|
106
|
+
action = "written"
|
|
107
|
+
else:
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
try:
|
|
110
|
+
with path.open("xb") as handle:
|
|
111
|
+
handle.write(want)
|
|
112
|
+
action = "written"
|
|
113
|
+
except FileExistsError:
|
|
114
|
+
if path.read_bytes() != want:
|
|
115
|
+
_refuse_clobber()
|
|
116
|
+
action = "unchanged"
|
|
117
|
+
except SystemExit as exc:
|
|
118
|
+
code = exc.code
|
|
119
|
+
return int(code) if isinstance(code, int) else 2
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
_emit_error(
|
|
122
|
+
message=f"could not install the completion script to {path}: {exc}",
|
|
123
|
+
as_json=as_json,
|
|
124
|
+
hint="Ensure the path is a writable file (not a directory) that you own.",
|
|
125
|
+
)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
if as_json:
|
|
129
|
+
print(json.dumps({"action": action, "path": str(path), "shell": shell}, sort_keys=True))
|
|
130
|
+
else:
|
|
131
|
+
lines = [f"{action}: {path}"]
|
|
132
|
+
if shell == "zsh":
|
|
133
|
+
lines.append(
|
|
134
|
+
f" ensure {path.parent} is on $fpath before `compinit` (e.g. in ~/.zshrc), then open a new shell"
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
lines.append(" open a new shell to pick it up")
|
|
138
|
+
print("\n".join(lines))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _bash_script() -> str:
|
|
143
|
+
value_flags = " ".join(f.option for f in _TOP_FLAGS if f.value is not None)
|
|
144
|
+
top = " ".join(f.option for f in _TOP_FLAGS)
|
|
145
|
+
top_values = "\n".join(f" {f.option}) {_bash_values(f)}; return ;;" for f in _TOP_FLAGS if f.value is not None)
|
|
146
|
+
value_cases = [f' *" {f.option}") {_bash_values(f)}; return ;;' for f in _TOP_FLAGS if f.value is not None]
|
|
147
|
+
flag_cases: list[str] = []
|
|
148
|
+
positional_cases: list[str] = []
|
|
149
|
+
for cmd in COMMANDS:
|
|
150
|
+
flags = COMMAND_FLAGS.get(cmd, ())
|
|
151
|
+
for flag in flags:
|
|
152
|
+
if flag.value is not None:
|
|
153
|
+
value_cases.append(f' "{cmd} {flag.option}") {_bash_values(flag)}; return ;;')
|
|
154
|
+
if flags:
|
|
155
|
+
flag_cases.append(
|
|
156
|
+
f' {cmd}) COMPREPLY=( $(compgen -W "{" ".join(f.option for f in flags)}" -- "$cur") ) ;;'
|
|
157
|
+
)
|
|
158
|
+
if cmd in _PATH_POSITIONALS:
|
|
159
|
+
positional_cases.append(f' {cmd}) COMPREPLY=( $(compgen -f -- "$cur") ) ;;')
|
|
160
|
+
elif cmd in POSITIONAL_CHOICES:
|
|
161
|
+
words = " ".join(POSITIONAL_CHOICES[cmd])
|
|
162
|
+
positional_cases.append(
|
|
163
|
+
f' {cmd}) [[ "$prev" == "$cmd" ]] && COMPREPLY=( $(compgen -W "{words}" -- "$cur") ) ;;'
|
|
164
|
+
)
|
|
165
|
+
nl = "\n"
|
|
166
|
+
return f"""# tiny-pki bash completion — generated by `tiny-pki completion bash`
|
|
167
|
+
_tiny_pki_completion() {{
|
|
168
|
+
local cur prev cmd="" i word
|
|
169
|
+
COMPREPLY=()
|
|
170
|
+
cur="${{COMP_WORDS[COMP_CWORD]}}"
|
|
171
|
+
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
|
|
172
|
+
for (( i = 1; i < COMP_CWORD; i++ )); do
|
|
173
|
+
word="${{COMP_WORDS[i]}}"
|
|
174
|
+
case " {value_flags} " in
|
|
175
|
+
*" $word "*) (( i++ )); continue ;;
|
|
176
|
+
esac
|
|
177
|
+
[[ "$word" == -* ]] && continue
|
|
178
|
+
cmd="$word"
|
|
179
|
+
break
|
|
180
|
+
done
|
|
181
|
+
if [[ -z "$cmd" ]]; then
|
|
182
|
+
case "$prev" in
|
|
183
|
+
{top_values}
|
|
184
|
+
esac
|
|
185
|
+
COMPREPLY=( $(compgen -W "{" ".join(COMMANDS)} {top}" -- "$cur") )
|
|
186
|
+
return
|
|
187
|
+
fi
|
|
188
|
+
case "$cmd $prev" in
|
|
189
|
+
{nl.join(value_cases)}
|
|
190
|
+
esac
|
|
191
|
+
if [[ "$cur" != -* ]]; then
|
|
192
|
+
case "$cmd" in
|
|
193
|
+
{nl.join(positional_cases)}
|
|
194
|
+
esac
|
|
195
|
+
(( ${{#COMPREPLY[@]}} )) && return
|
|
196
|
+
fi
|
|
197
|
+
case "$cmd" in
|
|
198
|
+
{nl.join(flag_cases)}
|
|
199
|
+
esac
|
|
200
|
+
}}
|
|
201
|
+
complete -F _tiny_pki_completion {_PROG}
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _bash_values(flag: Flag) -> str:
|
|
206
|
+
"""The ``COMPREPLY=...`` assignment for a flag's value."""
|
|
207
|
+
if flag.choices:
|
|
208
|
+
return f'COMPREPLY=( $(compgen -W "{" ".join(flag.choices)}" -- "$cur") )'
|
|
209
|
+
if flag.name == "store":
|
|
210
|
+
return 'COMPREPLY=( $(compgen -d -- "$cur") )'
|
|
211
|
+
if flag.path:
|
|
212
|
+
return 'COMPREPLY=( $(compgen -f -- "$cur") )'
|
|
213
|
+
return "COMPREPLY=()"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _emit_error(*, message: str, as_json: bool, hint: str | None = None) -> None:
|
|
217
|
+
if as_json:
|
|
218
|
+
payload: dict[str, object] = {"error": "usage_error", "message": message, "ok": False}
|
|
219
|
+
if hint:
|
|
220
|
+
payload["hint"] = hint
|
|
221
|
+
if "not a regular file" in message or "could not install" in message:
|
|
222
|
+
payload["error"] = "install_failed"
|
|
223
|
+
print(json.dumps(payload, sort_keys=True), file=sys.stderr)
|
|
224
|
+
return
|
|
225
|
+
print(message, file=sys.stderr)
|
|
226
|
+
if hint:
|
|
227
|
+
print(hint, file=sys.stderr)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _fish_script() -> str:
|
|
231
|
+
lines = [
|
|
232
|
+
"# tiny-pki fish completion — generated by `tiny-pki completion fish`",
|
|
233
|
+
f"complete -c {_PROG} -f",
|
|
234
|
+
]
|
|
235
|
+
for flag in _TOP_FLAGS:
|
|
236
|
+
condition = "" if flag.value is not None else "-n __fish_use_subcommand "
|
|
237
|
+
lines.append(f"complete -c {_PROG} {condition}{_fish_flag(flag)}")
|
|
238
|
+
descriptions = dict(COMMAND_HELP)
|
|
239
|
+
for cmd in COMMANDS:
|
|
240
|
+
lines.append(f"complete -c {_PROG} -n __fish_use_subcommand -a {cmd} -d {_sq(descriptions[cmd])}")
|
|
241
|
+
for cmd in COMMANDS:
|
|
242
|
+
seen = _sq(f"__fish_seen_subcommand_from {cmd}")
|
|
243
|
+
if cmd in _PATH_POSITIONALS:
|
|
244
|
+
lines.append(f"complete -c {_PROG} -n {seen} -F")
|
|
245
|
+
elif cmd in POSITIONAL_CHOICES:
|
|
246
|
+
lines.append(f"complete -c {_PROG} -n {seen} -a {_sq(' '.join(POSITIONAL_CHOICES[cmd]))}")
|
|
247
|
+
for flag in COMMAND_FLAGS.get(cmd, ()):
|
|
248
|
+
lines.append(f"complete -c {_PROG} -n {seen} {_fish_flag(flag)}")
|
|
249
|
+
return "\n".join(lines) + "\n"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _fish_flag(flag: Flag) -> str:
|
|
253
|
+
parts = [f"-l {flag.name}"]
|
|
254
|
+
if flag.choices:
|
|
255
|
+
parts.append(f"-x -a {_sq(' '.join(flag.choices))}")
|
|
256
|
+
elif flag.name == "store":
|
|
257
|
+
parts.append("-x -a '(__fish_complete_directories)'")
|
|
258
|
+
elif flag.path:
|
|
259
|
+
parts.append("-r -F")
|
|
260
|
+
elif flag.value is not None:
|
|
261
|
+
parts.append("-x")
|
|
262
|
+
parts.append(f"-d {_sq(flag.help)}")
|
|
263
|
+
return " ".join(parts)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_completion_argv(argv: list[str]) -> tuple[str, bool, bool, bool]:
|
|
267
|
+
if not argv:
|
|
268
|
+
print(
|
|
269
|
+
"usage: tiny-pki completion <bash|zsh|fish> [--install] [--force] [--json]",
|
|
270
|
+
file=sys.stderr,
|
|
271
|
+
)
|
|
272
|
+
raise SystemExit(2)
|
|
273
|
+
shell = argv[0]
|
|
274
|
+
if shell not in _SHELLS:
|
|
275
|
+
print(f"unsupported shell: {shell!r} (expected bash, zsh, or fish)", file=sys.stderr)
|
|
276
|
+
raise SystemExit(2)
|
|
277
|
+
install = False
|
|
278
|
+
force = False
|
|
279
|
+
as_json = False
|
|
280
|
+
for token in argv[1:]:
|
|
281
|
+
if token == "--install":
|
|
282
|
+
install = True
|
|
283
|
+
elif token == "--force":
|
|
284
|
+
force = True
|
|
285
|
+
elif token == "--json":
|
|
286
|
+
as_json = True
|
|
287
|
+
else:
|
|
288
|
+
print(f"unexpected argument: {token!r}", file=sys.stderr)
|
|
289
|
+
raise SystemExit(2)
|
|
290
|
+
return shell, install, force, as_json
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _xdg_base(var: str, default: Path) -> Path:
|
|
294
|
+
"""XDG base dir from ``$var``, honoring the spec: a relative value is ignored."""
|
|
295
|
+
value = os.environ.get(var, "")
|
|
296
|
+
candidate = Path(value) if value else default
|
|
297
|
+
return candidate if candidate.is_absolute() else default
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _zsh_script() -> str:
|
|
301
|
+
descriptions = dict(COMMAND_HELP)
|
|
302
|
+
cmds = "\n ".join(_sq(f"{cmd.replace(':', '\\:')}:{descriptions[cmd]}") for cmd in COMMANDS)
|
|
303
|
+
top = " \\\n ".join(_zsh_spec(flag) for flag in _TOP_FLAGS)
|
|
304
|
+
global_specs = [_zsh_spec(flag) for flag in _TOP_FLAGS if flag.value is not None]
|
|
305
|
+
cases: list[str] = []
|
|
306
|
+
for cmd in COMMANDS:
|
|
307
|
+
specs = [_zsh_spec(flag) for flag in COMMAND_FLAGS.get(cmd, ())] + global_specs
|
|
308
|
+
if cmd in _PATH_POSITIONALS:
|
|
309
|
+
specs.append("'*:file:_files'")
|
|
310
|
+
elif cmd == "export":
|
|
311
|
+
specs.extend(["'1:format:(p12 pem)'", "'2:name: '"])
|
|
312
|
+
elif cmd in POSITIONAL_CHOICES:
|
|
313
|
+
specs.append(_sq(f"1:argument:({' '.join(POSITIONAL_CHOICES[cmd])})"))
|
|
314
|
+
cases.append(f" {cmd}) _arguments {' '.join(specs)} ;;")
|
|
315
|
+
body = "\n".join(cases)
|
|
316
|
+
# Function name must match the installed file (``_tiny-pki``) so zsh
|
|
317
|
+
# autoload from ``$fpath`` finds the right completer without a second
|
|
318
|
+
# ``compdef`` call.
|
|
319
|
+
return f"""#compdef {_PROG}
|
|
320
|
+
# tiny-pki zsh completion — generated by `tiny-pki completion zsh`
|
|
321
|
+
_tiny-pki() {{
|
|
322
|
+
local -a cmds
|
|
323
|
+
cmds=(
|
|
324
|
+
{cmds}
|
|
325
|
+
)
|
|
326
|
+
local context state state_descr line
|
|
327
|
+
typeset -A opt_args
|
|
328
|
+
_arguments -C \\
|
|
329
|
+
{top} \\
|
|
330
|
+
'1:command:->cmd' \\
|
|
331
|
+
'*::arg:->args'
|
|
332
|
+
case $state in
|
|
333
|
+
cmd)
|
|
334
|
+
_describe -t commands 'tiny-pki command' cmds
|
|
335
|
+
;;
|
|
336
|
+
args)
|
|
337
|
+
case $words[1] in
|
|
338
|
+
{body}
|
|
339
|
+
esac
|
|
340
|
+
;;
|
|
341
|
+
esac
|
|
342
|
+
}}
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _zsh_spec(flag: Flag) -> str:
|
|
347
|
+
"""An ``_arguments`` option spec such as ``'--key-type[key algorithm]:TYPE:(rsa ec-p256)'``."""
|
|
348
|
+
text = flag.help.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
|
349
|
+
prefix = "*" if flag.repeatable else ""
|
|
350
|
+
spec = f"{prefix}{flag.option}[{text}]"
|
|
351
|
+
if flag.value is not None:
|
|
352
|
+
if flag.choices:
|
|
353
|
+
action = f"({' '.join(flag.choices)})"
|
|
354
|
+
elif flag.name == "store":
|
|
355
|
+
action = "_files -/"
|
|
356
|
+
elif flag.path:
|
|
357
|
+
action = "_files"
|
|
358
|
+
else:
|
|
359
|
+
action = " "
|
|
360
|
+
spec += f":{flag.value}:{action}"
|
|
361
|
+
elif flag.name in {"help", "version"}:
|
|
362
|
+
spec = f"(- *){spec}"
|
|
363
|
+
return _sq(spec)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _sq(text: str) -> str:
|
|
367
|
+
"""Quote ``text`` as one single-quoted shell word."""
|
|
368
|
+
return "'" + text.replace("'", "'\\''") + "'"
|
tiny_pki/cli/entry.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""``tiny-pki`` console script: explain a missing ``[cli]`` extra instead of a traceback.
|
|
2
|
+
|
|
3
|
+
The library needs only ``cryptography``; the REPL and one-shot CLI also need
|
|
4
|
+
``prompt-toolkit``, which ships in the optional ``tiny-pki[cli]`` extra. This
|
|
5
|
+
module must not import :mod:`tiny_pki.cli.main` at module level, because that
|
|
6
|
+
import is exactly what fails without the extra.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import sys
|
|
13
|
+
from importlib.util import find_spec
|
|
14
|
+
|
|
15
|
+
CLI_EXTRA_HINT = (
|
|
16
|
+
"tiny-pki: the command-line tool needs the [cli] extra, which is not installed. "
|
|
17
|
+
"Install it with: pipx install 'tiny-pki[cli]' (or uv tool install 'tiny-pki[cli]', "
|
|
18
|
+
"or pip install 'tiny-pki[cli]')"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
"""Run the CLI, or exit 1 with install instructions when ``prompt-toolkit`` is missing."""
|
|
24
|
+
if find_spec("prompt_toolkit") is None:
|
|
25
|
+
print(CLI_EXTRA_HINT, file=sys.stderr)
|
|
26
|
+
raise SystemExit(1)
|
|
27
|
+
importlib.import_module("tiny_pki.cli.main").main()
|