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

1import sys 

2import typing 

3 

4import configuraptor 

5import questionary 

6import rich 

7import typer 

8 

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 

13 

14app = typer.Typer() 

15 

16TwoFactorDetailStorage: typing.TypeAlias = TwoFactorStorage[TwoFactorAuthDetails] 

17 

18 

19class AppState(configuraptor.TypedConfig, configuraptor.Singleton): 

20 verbose: bool = False 

21 

22 

23state = AppState.load({}) 

24 

25 

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. 

32 

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 ) 

49 

50 

51def prepare_to_generate(filename: str) -> TwoFactorDetailStorage: 

52 keyring_manager.cleanup_keyring() 

53 return load_services(filename) 

54 

55 

56def generate_all_totp(services: TwoFactorDetailStorage) -> None: 

57 print("verbose", state.verbose) 

58 

59 for service_name, code in services.generate(): 

60 rich.print(f"- {service_name}: {code}") 

61 

62 

63def generate_one_otp(services: TwoFactorDetailStorage) -> None: 

64 print("verbose", state.verbose) 

65 

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}") 

71 

72 

73def command_interactive(filename: str = None) -> None: 

74 if not filename: 

75 # get from settings or 

76 filename = default_2fas_file() 

77 

78 services = prepare_to_generate(filename) 

79 

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) 

104 

105 

106def default_2fas_file() -> str: 

107 print("todo: query filename or get from settings") 

108 return "~/Nextcloud/2fa/2fas-backup-20240117132052.2fas" 

109 

110 

111def default_2fas_services() -> TwoFactorDetailStorage: 

112 filename = default_2fas_file() 

113 return prepare_to_generate(filename) 

114 

115 

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) 

121 

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? ") 

125 

126 other_args = [_ for _ in args if not _.endswith(".2fas")] 

127 

128 storage = prepare_to_generate(filename) 

129 found: list[TwoFactorAuthDetails] = [] 

130 

131 if not other_args: 

132 # only .2fas file entered - switch to interactive 

133 return command_interactive(filename) 

134 

135 for query in other_args: 

136 found.extend(storage.find(query)) 

137 

138 for twofa in found: 

139 rich.print(f"- {twofa.name}:", twofa.generate()) 

140 

141 

142def get_setting(key: str) -> None: 

143 value = get_cli_setting(key) 

144 rich.print(f"- {key}: {value}") 

145 

146 

147def set_setting(key: str, value: str) -> None: 

148 set_cli_setting(key, value) 

149 

150 

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 

157 

158 rich.print(f"- {key}: {value}") 

159 

160 

161def command_setting(args: list[str]) -> None: 

162 # required until PyCharm understands 'match' better: 

163 keyvalue: str 

164 key: str 

165 value: str 

166 

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}'.") 

182 

183 

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 

195 

196 # 2fas path/to/file.fas <service> 

197 # 2fas <service> path/to/file.fas 

198 # 2fas <subcommand> 

199 

200 # 2fas --setting key value 

201 # 2fas --setting key=value 

202 if verbose: 

203 state.update(verbose=verbose) 

204 

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() 

215 

216 # todo: something like --add and --remove files 

217 # todo: better --help info