Coverage for src/twofas/cli.py: 0%
90 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-22 17:32 +0100
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-22 17:32 +0100
1import sys
2import typing
4import configuraptor
5import questionary
6import rich
7import typer
9from ._security import keyring_manager
10from ._types import TwoFactorAuthDetails
11from .cli_settings import get_cli_setting, load_cli_settings, set_cli_setting
12from .core import TwoFactorStorage, load_services
14app = typer.Typer()
16TwoFactorDetailStorage: typing.TypeAlias = TwoFactorStorage[TwoFactorAuthDetails]
19class AppState(configuraptor.TypedConfig, configuraptor.Singleton):
20 verbose: bool = False
23state = AppState.load({})
26def generate_custom_style(
27 main_color: str = "green", # "#673ab7"
28 secondary_color: str = "#673ab7", # "#f44336"
29) -> questionary.Style:
30 """
31 Reusable questionary style for all prompts of this tool.
33 Primary and secondary color can be changed, other styles stay the same for consistency.
34 """
35 return questionary.Style(
36 [
37 ("qmark", f"fg:{main_color} bold"), # token in front of the question
38 ("question", "bold"), # question text
39 ("answer", f"fg:{secondary_color} bold"), # submitted answer text behind the question
40 ("pointer", f"fg:{main_color} bold"), # pointer used in select and checkbox prompts
41 ("highlighted", f"fg:{main_color} bold"), # pointed-at choice in select and checkbox prompts
42 ("selected", "fg:#cc5454"), # style for a selected item of a checkbox
43 ("separator", "fg:#cc5454"), # separator in lists
44 ("instruction", ""), # user instructions for select, rawselect, checkbox
45 ("text", ""), # plain text
46 ("disabled", "fg:#858585 italic"), # disabled choices for select and checkbox prompts
47 ]
48 )
51def prepare_to_generate(filename: str) -> TwoFactorDetailStorage:
52 keyring_manager.cleanup_keyring()
53 return load_services(filename)
56def generate_all_totp(services: TwoFactorDetailStorage) -> None:
57 print("verbose", state.verbose)
59 for service_name, code in services.generate():
60 rich.print(f"- {service_name}: {code}")
63def generate_one_otp(services: TwoFactorDetailStorage) -> None:
64 print("verbose", state.verbose)
66 while service_name := questionary.autocomplete(
67 "Choose a service", choices=services.keys(), style=generate_custom_style()
68 ).ask():
69 for service_name, code in services.find(service_name).generate():
70 rich.print(f"- {service_name}: {code}")
73def command_interactive(filename: str = None) -> None:
74 if not filename:
75 # get from settings or
76 filename = default_2fas_file()
78 services = prepare_to_generate(filename)
80 match questionary.select(
81 "What do you want to do?",
82 choices=[
83 questionary.Choice("Generate a TOTP", "generate-one", shortcut_key="1"),
84 questionary.Choice("Generate all TOTPs", "generate-all", shortcut_key="2"),
85 questionary.Choice("Settings", "settings", shortcut_key="3"),
86 questionary.Choice("Exit", "exit", shortcut_key="0"),
87 ],
88 use_shortcuts=True,
89 style=generate_custom_style(),
90 ).ask():
91 case "generate-one":
92 # query list of items
93 return generate_one_otp(services)
94 case "generate-all":
95 # show all
96 return generate_all_totp(services)
97 case "settings":
98 print("todo: settings")
99 # manage files
100 # change specific settings
101 # default file - choose from list of files
102 case _:
103 exit(0)
106def default_2fas_file() -> str:
107 print("todo: query filename or get from settings")
108 return "~/Nextcloud/2fa/2fas-backup-20240117132052.2fas"
111def default_2fas_services() -> TwoFactorDetailStorage:
112 filename = default_2fas_file()
113 return prepare_to_generate(filename)
116def command_generate(args: list[str]) -> None:
117 file_args = [_ for _ in args if _.endswith(".2fas")]
118 if len(file_args) > 1:
119 rich.print("[red]Err: can't work on multiple .2fas files![/red]", file=sys.stderr)
120 exit(1)
122 filename = file_args[0] if file_args else default_2fas_file()
123 print(f"todo: store {filename} in ~/.config/2fas settings")
124 print("todo: possibly set default in settings? ")
126 other_args = [_ for _ in args if not _.endswith(".2fas")]
128 storage = prepare_to_generate(filename)
129 found: list[TwoFactorAuthDetails] = []
131 if not other_args:
132 # only .2fas file entered - switch to interactive
133 return command_interactive(filename)
135 for query in other_args:
136 found.extend(storage.find(query))
138 for twofa in found:
139 rich.print(f"- {twofa.name}:", twofa.generate())
142def get_setting(key: str) -> None:
143 value = get_cli_setting(key)
144 rich.print(f"- {key}: {value}")
147def set_setting(key: str, value: str) -> None:
148 set_cli_setting(key, value)
151def list_settings() -> None:
152 settings = load_cli_settings()
153 rich.print("Current settings:")
154 for key, value in settings.__dict__.items():
155 if key.startswith("_"):
156 continue
158 rich.print(f"- {key}: {value}")
161def command_setting(args: list[str]) -> None:
162 # required until PyCharm understands 'match' better:
163 keyvalue: str
164 key: str
165 value: str
167 match args:
168 case []:
169 list_settings()
170 case [keyvalue]:
171 # key=value
172 if "=" not in keyvalue:
173 # get setting
174 get_setting(keyvalue)
175 else:
176 # set settings
177 set_setting(*keyvalue.split("=", 1))
178 case [key, value]:
179 set_setting(key, value)
180 case other:
181 raise ValueError(f"Can't set setting '{other}'.")
184@app.command()
185def main(
186 args: list[str] = typer.Argument(None),
187 setting: bool = typer.Option(False, "--setting", "--settings", "-s"),
188 generate_all: bool = typer.Option(False, "--all", "-a"),
189 verbose: bool = typer.Option(False, "--verbose", "-v"),
190) -> None: # pragma: no cover
191 """
192 Cli entrypoint.
193 """
194 # 2fas
196 # 2fas path/to/file.fas <service>
197 # 2fas <service> path/to/file.fas
198 # 2fas <subcommand>
200 # 2fas --setting key value
201 # 2fas --setting key=value
202 if verbose:
203 state.update(verbose=verbose)
205 if setting:
206 command_setting(args)
207 elif generate_all:
208 # todo: look for .2fas file in 'args' !
209 services = default_2fas_services()
210 generate_all_totp(services)
211 elif args:
212 command_generate(args)
213 else:
214 command_interactive()
216 # todo: something like --add and --remove files
217 # todo: better --help info