remotivelabs-cli 0.0.42__py3-none-any.whl → 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.
Files changed (44) hide show
  1. cli/.DS_Store +0 -0
  2. cli/api/cloud/tokens.py +62 -0
  3. cli/broker/brokers.py +0 -1
  4. cli/broker/export.py +4 -4
  5. cli/broker/lib/broker.py +9 -13
  6. cli/broker/license_flows.py +1 -1
  7. cli/broker/scripting.py +2 -1
  8. cli/broker/signals.py +9 -10
  9. cli/cloud/auth/cmd.py +37 -13
  10. cli/cloud/auth/login.py +278 -24
  11. cli/cloud/auth_tokens.py +319 -12
  12. cli/cloud/brokers.py +3 -4
  13. cli/cloud/cloud_cli.py +5 -5
  14. cli/cloud/configs.py +1 -2
  15. cli/cloud/organisations.py +101 -2
  16. cli/cloud/projects.py +5 -6
  17. cli/cloud/recordings.py +9 -16
  18. cli/cloud/recordings_playback.py +6 -8
  19. cli/cloud/sample_recordings.py +2 -3
  20. cli/cloud/service_account_tokens.py +21 -5
  21. cli/cloud/service_accounts.py +32 -4
  22. cli/cloud/storage/cmd.py +1 -1
  23. cli/cloud/storage/copy.py +3 -4
  24. cli/connect/connect.py +1 -1
  25. cli/connect/protopie/protopie.py +12 -14
  26. cli/errors.py +6 -1
  27. cli/remotive.py +30 -6
  28. cli/settings/__init__.py +1 -2
  29. cli/settings/config_file.py +92 -0
  30. cli/settings/core.py +188 -45
  31. cli/settings/migrate_all_token_files.py +74 -0
  32. cli/settings/migrate_token_file.py +52 -0
  33. cli/settings/token_file.py +69 -4
  34. cli/tools/can/can.py +2 -2
  35. cli/typer/typer_utils.py +18 -1
  36. cli/utils/__init__.py +0 -0
  37. cli/{cloud → utils}/rest_helper.py +114 -39
  38. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0.dist-info}/METADATA +6 -4
  39. remotivelabs_cli-0.1.0.dist-info/RECORD +59 -0
  40. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0.dist-info}/WHEEL +1 -1
  41. cli/settings/cmd.py +0 -72
  42. remotivelabs_cli-0.0.42.dist-info/RECORD +0 -54
  43. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0.dist-info}/LICENSE +0 -0
  44. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0.dist-info}/entry_points.txt +0 -0
cli/cloud/auth/login.py CHANGED
@@ -1,18 +1,45 @@
1
- import datetime
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import os
6
+ import secrets
7
+ import sys
2
8
  import time
3
9
  import webbrowser
4
10
  from http.server import BaseHTTPRequestHandler, HTTPServer
5
11
  from threading import Thread
6
- from typing import Any
12
+ from typing import Any, Optional, Tuple
13
+ from urllib.parse import parse_qs, urlparse
7
14
 
15
+ import typer
16
+ from rich.console import Console
8
17
  from typing_extensions import override
9
18
 
10
- from cli.cloud.rest_helper import RestHelper as Rest
11
- from cli.settings import settings
12
- from cli.settings import token_file as tf
19
+ from cli.cloud.auth_tokens import do_activate, prompt_to_set_org
20
+ from cli.errors import ErrorPrinter
21
+ from cli.settings import TokenFile, TokenNotFoundError, settings
22
+ from cli.utils.rest_helper import RestHelper as Rest
13
23
 
14
24
  httpd: HTTPServer
15
25
 
26
+ console = Console()
27
+
28
+
29
+ def generate_pkce_pair() -> Tuple[str, str]:
30
+ """
31
+ PKCE is used for all cli login flows, both headless and browser.
32
+ """
33
+ code_verifier_ = secrets.token_urlsafe(64) # High-entropy string
34
+ code_challenge_ = base64.urlsafe_b64encode(hashlib.sha256(code_verifier_.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
35
+ return code_verifier_, code_challenge_
36
+
37
+
38
+ code_verifier, code_challenge = generate_pkce_pair()
39
+ state = secrets.token_urlsafe(16)
40
+
41
+ short_lived_token = None
42
+
16
43
 
17
44
  class S(BaseHTTPRequestHandler):
18
45
  def _set_response(self) -> None:
@@ -21,42 +48,269 @@ class S(BaseHTTPRequestHandler):
21
48
  self.end_headers()
22
49
 
23
50
  @override
24
- def log_message(self, format: Any, *args: Any) -> None: # pylint: disable=W0622,
51
+ def log_message(self, format: Any, *args: Any) -> None:
25
52
  return
26
53
 
27
54
  # Please do not change this into lowercase!
28
55
  @override
29
- # type: ignore
30
- def do_GET(self): # pylint: disable=invalid-name,
56
+ def do_GET(self) -> None: # type: ignore # noqa: PLR0912
31
57
  self._set_response()
32
- self.wfile.write("Successfully setup CLI, return to your terminal to continue".encode("utf-8"))
58
+
59
+ parsed_url = urlparse(self.path)
60
+
61
+ # Get query parameters as a dict
62
+ query_params = parse_qs(parsed_url.query)
63
+
64
+ # Example: Get the value of the "error" parameter if it exists
65
+ error_value = query_params.get("error", [None])[0]
33
66
  path = self.path
67
+ auth_code = path[1:] # Remotive /
34
68
  time.sleep(1)
35
69
  httpd.server_close()
36
70
 
37
71
  killerthread = Thread(target=httpd.shutdown)
38
72
  killerthread.start()
73
+ if error_value is None:
74
+ res = Rest.handle_get(
75
+ f"/api/open/token?code={auth_code}&code_verifier={code_verifier}",
76
+ return_response=True,
77
+ skip_access_token=True,
78
+ allow_status_codes=[401, 400],
79
+ )
80
+ if res.status_code != 200:
81
+ print(res.text)
82
+ ErrorPrinter.print_generic_error(
83
+ "Failed to fetch token. Please try again, if the problem persists please reach out to support@remotivelabs.com"
84
+ )
85
+ self.wfile.write(
86
+ "Failed to fetch token. Please try again, if the problem persists please reach out to support@remotivelabs.com".encode(
87
+ "utf-8"
88
+ )
89
+ )
90
+ sys.exit(1)
91
+
92
+ # TODO - This is written before we are done...
93
+ self.wfile.write(
94
+ """Successfully setup CLI, you may close this window now. Return to your terminal to continue""".encode("utf-8")
95
+ )
96
+ access_token = res.json()["access_token"]
97
+
98
+ global short_lived_token # noqa: PLW0603
99
+ short_lived_token = access_token
100
+
101
+ else:
102
+ if error_value == "no_consent":
103
+ self.wfile.write(
104
+ """
105
+ Authorization was cancelled.<br/>
106
+ To use RemotiveCLI, you need to grant access to your RemotiveCloud account.
107
+ <br/><br/>
108
+ Run `remotive cloud auth login` to try again.
109
+ """.encode("utf-8")
110
+ )
111
+ ErrorPrinter.print_generic_error("You did not grant access to RemotiveCloud, login aborted")
112
+ elif error_value == "user_not_exists":
113
+ self.wfile.write(
114
+ """
115
+ It seems like you do not have an account at RemotiveCloud with that user<br/>
116
+ To use RemotiveCLI you must first sign up at <a href="https://cloud.remotivelabs.com">cloud.remotivelabs.com</a>
117
+ and approve our agreements.<br/>
118
+ <br/><br/>
119
+ Once you are signed up, Run `remotive cloud auth login` again.
120
+ """.encode("utf-8")
121
+ )
122
+ ErrorPrinter.print_generic_error(
123
+ "To use RemotiveCLI you must first sign up at https://cloud.remotivelabs.com and approve our agreements"
124
+ )
125
+ else:
126
+ self.wfile.write(f"Unknown error {error_value}, please contact support@remotivelabs.com".encode("utf-8"))
127
+ ErrorPrinter.print_generic_error(f"Unexpected error {error_value}, please contact support@remotivelabs.com")
128
+ sys.exit(1)
39
129
 
40
- token = tf.TokenFile(
41
- name="CLI_login_token",
42
- token=path[1:],
43
- created=str(datetime.datetime.now().isoformat()),
44
- expires="unknown",
45
- )
46
- settings.add_and_activate_short_lived_cli_token(tf.dumps(token))
47
- print("Successfully logged on, you are ready to go with cli")
48
130
 
131
+ def prepare_local_webserver(server_class: type = HTTPServer, handler_class: type = S, port: Optional[int] = None) -> None:
132
+ if port is None:
133
+ env_val = os.getenv("REMOTIVE_LOGIN_CALLBACK_PORT" or "")
134
+ if env_val and env_val.isdigit():
135
+ port = int(env_val)
136
+ else:
137
+ port = 0
49
138
 
50
- def start_local_webserver(server_class: type = HTTPServer, handler_class: type = S, port: int = 0) -> None:
51
139
  server_address = ("", port)
52
- global httpd # pylint: disable=W0603
140
+ global httpd # noqa: PLW0603
53
141
  httpd = server_class(server_address, handler_class)
54
142
 
55
143
 
56
- def login() -> None:
144
+ def create_personal_token() -> None:
145
+ response = Rest.handle_post(
146
+ url="/api/me/keys",
147
+ return_response=True,
148
+ access_token=short_lived_token,
149
+ # TODO - add body with alias
150
+ )
151
+ token = response.json()
152
+ email = token["account"]["email"]
153
+ existing_file = settings.get_token_file_by_email(email=email)
154
+ if existing_file is not None:
155
+ # ErrorPrinter.print_hint(f"Revoking and deleting existing credentials [remove_me]{existing_file.name}")
156
+ res = Rest.handle_patch(
157
+ f"/api/me/keys/{existing_file.name}/revoke",
158
+ quiet=True,
159
+ access_token=short_lived_token,
160
+ allow_status_codes=[400, 404],
161
+ )
162
+ if res is not None and res.status_code == 204:
163
+ Rest.handle_delete(
164
+ f"/api/me/keys/{existing_file.name}",
165
+ quiet=True,
166
+ access_token=short_lived_token,
167
+ )
168
+ settings.remove_token_file(existing_file.name)
169
+
170
+ settings.add_personal_token(response.text, activate=True)
171
+
172
+ print("Successfully logged on")
173
+
174
+
175
+ def _do_prompt_to_use_existing_credentials() -> Optional[TokenFile]:
176
+ files = settings.list_personal_token_files()
177
+ if len(files) > 0:
178
+ should_select_token = typer.confirm(
179
+ "You have credentials available already, would you like to choose one of these instead?", default=True
180
+ )
181
+ if should_select_token:
182
+ token = do_activate(token_name=None)
183
+ # token = list_and_select_personal_token(skip_prompt=False, include_service_accounts=True)
184
+ if token is not None:
185
+ return token
186
+ # TODO - fix so this is not needed
187
+ sys.exit(0)
188
+ return None
189
+
190
+
191
+ def login(headless: bool = False) -> bool: # noqa: C901, PLR0912, PLR0915
57
192
  """
58
- Initiate login using browser
193
+ Initiate login
59
194
  """
60
- start_local_webserver()
61
- webbrowser.open_new_tab(f"{Rest.get_base_url()}/login?redirectUrl=http://localhost:{httpd.server_address[1]}")
62
- httpd.serve_forever()
195
+
196
+ #
197
+ # Check login.md flowchart for better understanding
198
+ #
199
+ # 1. Check for active token valid and working credentials
200
+ #
201
+ try:
202
+ activate_token = settings.get_active_token_file()
203
+
204
+ if not activate_token.is_expired():
205
+ if Rest.has_access("/api/whoami"):
206
+ token = _do_prompt_to_use_existing_credentials()
207
+ if token is not None:
208
+ return True
209
+ else:
210
+ settings.clear_active_token()
211
+ raise TokenNotFoundError()
212
+ else:
213
+ # TODO - Cleanup token since expired
214
+ pass
215
+
216
+ except TokenNotFoundError:
217
+ #
218
+ # 2. If no token was found, let user choose an existing if exists
219
+ #
220
+ token = _do_prompt_to_use_existing_credentials()
221
+ if token is not None:
222
+ return True
223
+
224
+ prepare_local_webserver()
225
+
226
+ def force_use_webserver_callback() -> bool:
227
+ env_val = os.getenv("REMOTIVE_LOGIN_FORCE_CALLBACK" or "no")
228
+ if env_val and env_val == "yes":
229
+ return True
230
+ return False
231
+
232
+ def login_with_callback_but_copy_url() -> None:
233
+ """
234
+ This will print a url the will trigger a callback later so the webserver must be up and running.
235
+ """
236
+ print("Copy the following link in a browser to login to cloud, and complete the sign-in prompts:")
237
+ print("")
238
+
239
+ url = (
240
+ f"{Rest.get_base_frontend_url()}/login"
241
+ f"?state={state}"
242
+ f"&cli_version={Rest.get_cli_version()}"
243
+ f"&response_type=code"
244
+ f"&code_challenge={code_challenge}"
245
+ f"&redirect_uri=http://localhost:{httpd.server_address[1]}"
246
+ )
247
+ console.print(url, style="bold", soft_wrap=True)
248
+ httpd.serve_forever()
249
+
250
+ def login_headless() -> None:
251
+ """
252
+ Full headless, opens a browser and expects a auth code to be entered and exchanged for the token
253
+ """
254
+ print("Copy the following link in a browser to login to cloud, and complete the sign-in prompts:")
255
+ print("")
256
+
257
+ url = (
258
+ f"{Rest.get_base_frontend_url()}/login"
259
+ f"?state={state}"
260
+ f"&cli_version={Rest.get_cli_version()}"
261
+ f"&response_type=code"
262
+ f"&code_challenge={code_challenge}"
263
+ )
264
+ console.print(url, style="bold", soft_wrap=True)
265
+
266
+ code = typer.prompt(
267
+ "Once finished, enter the verification code provided in your browser",
268
+ hide_input=False,
269
+ )
270
+ res = Rest.handle_get(
271
+ f"/api/open/token?code={code}&code_verifier={code_verifier}",
272
+ return_response=True,
273
+ skip_access_token=True,
274
+ allow_status_codes=[401],
275
+ )
276
+ if res.status_code == 401:
277
+ ErrorPrinter.print_generic_error(
278
+ "Failed to fetch token. Please try again, if the problem persists please reach out to support@remotivelabs.com"
279
+ )
280
+ sys.exit(1)
281
+ access_token = res.json()["access_token"]
282
+ # res = Rest.handle_get("/api/whoami", return_response=True, access_token=access_token)
283
+ global short_lived_token # noqa: PLW0603
284
+ short_lived_token = access_token
285
+ create_personal_token()
286
+ prompt_to_set_org()
287
+
288
+ if headless and not force_use_webserver_callback():
289
+ login_headless()
290
+ elif headless and force_use_webserver_callback():
291
+ login_with_callback_but_copy_url()
292
+ else:
293
+ could_open = webbrowser.open_new_tab(
294
+ f"{Rest.get_base_frontend_url()}/login"
295
+ f"?state={state}"
296
+ f"&cli_version={Rest.get_cli_version()}"
297
+ f"&response_type=code"
298
+ f"&code_challenge={code_challenge}"
299
+ f"&redirect_uri=http://localhost:{httpd.server_address[1]}"
300
+ )
301
+
302
+ if not could_open:
303
+ print(
304
+ "Could not open a browser on this machine, this is likely because you are in an environment where no browser is available"
305
+ )
306
+ print("")
307
+ if force_use_webserver_callback():
308
+ login_with_callback_but_copy_url()
309
+ create_personal_token()
310
+ else:
311
+ login_headless()
312
+ else:
313
+ httpd.serve_forever()
314
+ create_personal_token()
315
+
316
+ return True
cli/cloud/auth_tokens.py CHANGED
@@ -1,33 +1,340 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Literal, Optional
4
+
1
5
  import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
2
8
 
3
- from cli.settings import settings
9
+ from cli.api.cloud import tokens
10
+ from cli.cloud.organisations import do_select_default_org
11
+ from cli.errors import ErrorPrinter
12
+ from cli.settings import TokenFile, TokenNotFoundError, settings
4
13
  from cli.typer import typer_utils
14
+ from cli.utils.rest_helper import RestHelper as Rest
5
15
 
6
- from .rest_helper import RestHelper as Rest
16
+ console = Console(stderr=False)
17
+ err_console = Console(stderr=True)
7
18
 
8
19
  app = typer_utils.create_typer()
9
20
 
21
+ PromptType = Literal["activate", "login"]
22
+
23
+
24
+ def _prompt_choice( # noqa: C901, PLR0912
25
+ choices: List[TokenFile],
26
+ skip_prompt: bool = False,
27
+ info_message: Optional[str] = None,
28
+ ) -> Optional[TokenFile]:
29
+ accounts = settings.get_cli_config().accounts
30
+ try:
31
+ active_account = settings.get_cli_config().get_active()
32
+ except TokenNotFoundError:
33
+ active_account = None
34
+
35
+ table = Table("#", "Active", "Type", "Token", "Account", "Created", "Expires")
36
+
37
+ included_tokens: list[TokenFile] = []
38
+ excluded_tokens: list[TokenFile] = []
39
+
40
+ for token in choices:
41
+ account = accounts.get(token.account.email)
42
+ if account and account.credentials_name and account.credentials_name in (token.name or ""):
43
+ included_tokens.append(token)
44
+ else:
45
+ excluded_tokens.append(token)
46
+
47
+ if len(included_tokens) == 0:
48
+ return None
49
+
50
+ included_tokens.sort(key=lambda token: token.created, reverse=True)
51
+
52
+ active_token_index = None
53
+ for idx, choice in enumerate(included_tokens, start=1):
54
+ is_active = active_account is not None and active_account.credentials_name == choice.name
55
+ active_token_index = idx if is_active else active_token_index
56
+ table.add_row(
57
+ f"[yellow]{idx}",
58
+ ":white_check_mark:" if is_active else "",
59
+ "user" if choice.type == "authorized_user" else "sa",
60
+ choice.name,
61
+ f"[bold]{choice.account.email if choice.account else 'unknown'}[/bold]",
62
+ str(choice.created),
63
+ str(choice.expires),
64
+ )
65
+ # console.print("It seems like you have access tokens from previous login, you can select one of these instead of logging in")
66
+ console.print(table)
67
+
68
+ if skip_prompt:
69
+ return None
70
+
71
+ typer.echo("")
72
+ if info_message is not None:
73
+ console.print(info_message)
74
+
75
+ selection = typer.prompt(
76
+ f"Enter the number(# 1-{len(included_tokens)}) of the account to select (q to quit)",
77
+ default=f"{active_token_index}" if active_token_index is not None else None,
78
+ )
79
+
80
+ if selection == "q":
81
+ return None
82
+ try:
83
+ index = int(selection) - 1
84
+ if 0 <= index < len(included_tokens):
85
+ return included_tokens[index]
86
+ raise ValueError
87
+ except ValueError:
88
+ typer.echo("Invalid choice, please try again")
89
+ return _prompt_choice(included_tokens, skip_prompt, info_message)
10
90
 
11
- # TODO: add add interactive flag to set target directory # pylint: disable=fixme
12
- @app.command(name="create", help="Create and download a new personal access token")
13
- def create(activate: bool = typer.Option(False, help="Activate the token for use after download")) -> None: # pylint: disable=W0621
14
- response = Rest.handle_post(url="/api/me/keys", return_response=True)
15
- pat = settings.add_personal_token(response.text)
91
+
92
+ # @app.command(name="create")
93
+ def create(
94
+ activate: bool = typer.Option(False, help="Activate the token for use after download"),
95
+ ) -> None:
96
+ """
97
+ Create a new personal access token in [bold]cloud[/bold] and download locally
98
+ """
99
+ response = tokens.create()
100
+ pat = settings.add_personal_token(response.text())
16
101
  print(f"Personal access token added: {pat.name}")
17
102
 
18
103
  if not activate:
19
104
  print(f"Use 'remotive cloud auth tokens activate {pat.name}' to use this access token from cli")
20
105
  else:
21
- settings.activate_token(pat.name)
106
+ settings.activate_token(pat)
22
107
  print("Token file activated and ready for use")
23
108
  print("\033[93m This file contains secrets and must be kept safe")
24
109
 
25
110
 
26
- @app.command(name="list", help="List personal access tokens")
111
+ # @app.command(name="list", help="List personal credentials in [bold]cloud[/bold]")
27
112
  def list_tokens() -> None:
28
113
  Rest.handle_get("/api/me/keys")
29
114
 
30
115
 
31
- @app.command(name="revoke", help="Revoke personal access token")
32
- def revoke(name: str = typer.Argument(help="Access token name")) -> None:
33
- Rest.handle_delete(f"/api/me/keys/{name}")
116
+ # @app.command(name="revoke")
117
+ def revoke(
118
+ name: str = typer.Argument(help="Access token name"),
119
+ delete: bool = typer.Option(True, help="Also delete token"),
120
+ ) -> None:
121
+ """
122
+ Revoke personal credentials in cloud and removes the file from filesystem
123
+
124
+ If cloud token is not found but token is found on file system it will delete it and
125
+ vice versa.
126
+ """
127
+ _revoke_and_delete_personal_token(name, delete)
128
+
129
+
130
+ # @app.command(name="activate")
131
+ def activate(
132
+ token_name: str = typer.Argument(..., help="Token path, filename or name to activate"),
133
+ ) -> None:
134
+ """
135
+ Activate a credential file to be used for authentication using filename, path or name.
136
+
137
+ This will be used as the current access token in all requests.
138
+ """
139
+ try:
140
+ token_file = settings.get_token_file(token_name)
141
+ settings.activate_token(token_file)
142
+ except TokenNotFoundError:
143
+ err_console.print(f":boom: [bold red] Error: [/bold red] Token with filename or name {token_name} could not be found")
144
+
145
+
146
+ def prompt_to_set_org() -> None:
147
+ if settings.get_cli_config().get_active_default_organisation() is None:
148
+ set_default_organisation = typer.confirm(
149
+ "You have not set a default organization\nWould you like to choose one now?",
150
+ abort=False,
151
+ default=True,
152
+ )
153
+ if set_default_organisation:
154
+ do_select_default_org(get=False)
155
+
156
+
157
+ @app.command("activate")
158
+ def select_personal_token(
159
+ token_name: str = typer.Argument(None, help="Name, filename or path to a credentials file"),
160
+ ) -> None:
161
+ """
162
+ Activates is setting the current active credentials to use by the CLI, this can be done by specifying a name
163
+ of the token or getting prompted and choosing from existing.
164
+ """
165
+ do_activate(token_name)
166
+
167
+
168
+ def do_activate(
169
+ token_name: Optional[str],
170
+ ) -> Optional[TokenFile]:
171
+ if token_name is not None:
172
+ try:
173
+ token_file = settings.get_token_file(token_name)
174
+ settings.activate_token(token_file)
175
+ return token_file
176
+ except TokenNotFoundError:
177
+ err_console.print(f":boom: [bold red] Error: [/bold red] Token with filename or name {token_name} could not be found")
178
+ return None
179
+ else:
180
+ token_files = settings.list_personal_tokens()
181
+ token_files.extend(settings.list_service_account_tokens())
182
+ if len(token_files) > 0:
183
+ token_selected = list_and_select_personal_token(include_service_accounts=True)
184
+ if token_selected is not None:
185
+ is_logged_in = Rest.has_access("/api/whoami")
186
+ if not is_logged_in:
187
+ ErrorPrinter.print_generic_error("Could not access RemotiveCloud with selected token")
188
+ else:
189
+ console.print("[green]Success![/green] Access to RemotiveCloud granted")
190
+ # Only select default if activate was done with selection and successful
191
+ # and not SA since SA cannot list available organizations
192
+ if token_selected.type == "authorized_user":
193
+ prompt_to_set_org()
194
+ return token_selected
195
+
196
+ ErrorPrinter.print_hint("No credentials available, login to activate credentials")
197
+ return None
198
+
199
+
200
+ def list_and_select_personal_token(
201
+ skip_prompt: bool = False,
202
+ include_service_accounts: bool = False,
203
+ info_message: Optional[str] = None,
204
+ ) -> Optional[TokenFile]:
205
+ personal_tokens = settings.list_personal_tokens()
206
+
207
+ if include_service_accounts:
208
+ sa_tokens = settings.list_service_account_tokens()
209
+ personal_tokens.extend(sa_tokens)
210
+
211
+ # merged = _merge_local_tokens_with_cloud(personal_tokens)
212
+
213
+ selected_token = _prompt_choice(personal_tokens, skip_prompt=skip_prompt, info_message=info_message)
214
+ if selected_token is not None:
215
+ settings.activate_token(selected_token)
216
+
217
+ return selected_token
218
+
219
+
220
+ # @app.command("select-revoke")
221
+ def select_revoke_personal_token() -> None:
222
+ """
223
+ Prompts a user to select one of the credential files to revoke and delete
224
+ """
225
+ personal_tokens = settings.list_personal_tokens()
226
+ sa_tokens = settings.list_service_account_tokens()
227
+ personal_tokens.extend(sa_tokens)
228
+
229
+ is_logged_in = Rest.has_access("/api/whoami")
230
+ if not is_logged_in:
231
+ ErrorPrinter.print_hint("You must be logged in")
232
+ raise typer.Exit(0)
233
+
234
+ # merged = _merge_local_tokens_with_cloud(personal_tokens)
235
+
236
+ selected_token = _prompt_choice(personal_tokens)
237
+
238
+ if selected_token is not None:
239
+ _revoke_and_delete_personal_token(selected_token.name, True)
240
+ # Rest.handle_patch(f"/api/me/keys/{selected_token.name}/revoke", quiet=True, access_token=selected_token.token)
241
+ # Rest.handle_delete(f"/api/me/keys/{selected_token.name}", quiet=True, access_token=selected_token.token)
242
+ # settings.remove_token_file(selected_token.name)
243
+ # active_token = settings.get_active_token_file()
244
+ # if active_token.name == selected_token.name:
245
+ # settings.clear_active_token()
246
+ # select_revoke_personal_token()
247
+
248
+
249
+ # @app.command("test-all")
250
+ def test_all_personal_tokens() -> None:
251
+ """
252
+ Tests each credential file to see if it is valid
253
+ """
254
+ personal_tokens = settings.list_personal_tokens()
255
+ personal_tokens.extend(settings.list_service_account_tokens())
256
+ if len(personal_tokens) == 0:
257
+ console.print("No personal tokens found on disk")
258
+ return
259
+
260
+ for token in personal_tokens:
261
+ r = Rest.handle_get(
262
+ "/api/whoami",
263
+ allow_status_codes=[401],
264
+ access_token=token.token,
265
+ use_progress_indicator=True,
266
+ return_response=True,
267
+ )
268
+ if r.status_code == 200:
269
+ if token.account is not None:
270
+ console.print(f"{token.account.email} ({token.name}) :white_check_mark:")
271
+ else:
272
+ console.print(f"{token.name} :white_check_mark:")
273
+ elif token.account is not None:
274
+ console.print(f"{token.account.email} ({token.name}) :x: Failed")
275
+ else:
276
+ console.print(f"{token.name} :x: Failed")
277
+
278
+
279
+ # @app.command(name="list-service-account-tokens-files")
280
+ def list_sats_files() -> None:
281
+ """
282
+ List service account access token files in remotivelabs config directory
283
+ """
284
+ service_account_files = settings.list_service_account_token_files()
285
+ for file in service_account_files:
286
+ print(file)
287
+
288
+
289
+ @app.command(name="list")
290
+ def list_pats_files(
291
+ accounts: bool = typer.Option(True, help="Lists all available accounts"),
292
+ files: bool = typer.Option(False, help="Shows all token files in config directory"),
293
+ ) -> None:
294
+ """
295
+ Lists available credential files on filesystem
296
+ """
297
+
298
+ if accounts:
299
+ list_and_select_personal_token(skip_prompt=True, include_service_accounts=True, info_message="hello")
300
+
301
+ if files:
302
+ personal_files = settings.list_personal_token_files()
303
+ service_account_files = settings.list_service_account_token_files()
304
+ personal_files.extend(service_account_files)
305
+ for file in personal_files:
306
+ print(file)
307
+
308
+
309
+ def _revoke_and_delete_personal_token(name: str, delete: bool) -> None:
310
+ token_file = None
311
+
312
+ # First we try to find the file and make sure its not the currently active
313
+ try:
314
+ token_file = settings.get_token_file(name)
315
+ active_token = settings.get_active_token_file()
316
+ if token_file.name == active_token.name:
317
+ ErrorPrinter.print_hint("You cannot revoke the current active token")
318
+ return
319
+ except TokenNotFoundError:
320
+ pass
321
+
322
+ # The lets try to revoke from cloud
323
+ res_revoke = tokens.revoke(name)
324
+ if delete:
325
+ res_delete = tokens.delete(name)
326
+ if res_delete.is_success:
327
+ ErrorPrinter.print_generic_message("Token successfully revoked and deleted")
328
+ else:
329
+ ErrorPrinter.print_hint(f"Failed to revoke and delete token in cloud: {res_delete.status_code}")
330
+ elif res_revoke.is_success:
331
+ ErrorPrinter.print_generic_message("Token successfully revoked")
332
+ else:
333
+ ErrorPrinter.print_hint("Failed to revoke and delete token in cloud")
334
+
335
+ # Finally try to remove the file if exists
336
+ if token_file is not None:
337
+ settings.remove_token_file(token_file.name)
338
+ console.print("Successfully deleted token on filesystem")
339
+ else:
340
+ ErrorPrinter.print_hint("Token not found on filesystem")