remotivelabs-cli 0.0.41__tar.gz → 0.1.0a1__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.
Files changed (65) hide show
  1. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/PKG-INFO +6 -4
  2. remotivelabs_cli-0.1.0a1/cli/.DS_Store +0 -0
  3. remotivelabs_cli-0.1.0a1/cli/api/cloud/tokens.py +62 -0
  4. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/brokers.py +0 -1
  5. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/export.py +4 -4
  6. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/lib/broker.py +9 -13
  7. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/license_flows.py +1 -1
  8. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/scripting.py +2 -1
  9. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/signals.py +9 -10
  10. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/auth/cmd.py +25 -6
  11. remotivelabs_cli-0.1.0a1/cli/cloud/auth/login.py +317 -0
  12. remotivelabs_cli-0.1.0a1/cli/cloud/auth_tokens.py +316 -0
  13. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/brokers.py +3 -4
  14. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/cloud_cli.py +2 -2
  15. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/configs.py +1 -2
  16. remotivelabs_cli-0.1.0a1/cli/cloud/organisations.py +101 -0
  17. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/projects.py +1 -2
  18. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/recordings.py +9 -16
  19. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/recordings_playback.py +6 -8
  20. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/sample_recordings.py +2 -3
  21. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/service_account_tokens.py +21 -5
  22. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/service_accounts.py +32 -4
  23. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/storage/cmd.py +1 -1
  24. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/storage/copy.py +3 -4
  25. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/connect/connect.py +1 -1
  26. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/connect/protopie/protopie.py +12 -14
  27. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/remotive.py +30 -6
  28. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/settings/__init__.py +1 -2
  29. remotivelabs_cli-0.1.0a1/cli/settings/config_file.py +85 -0
  30. remotivelabs_cli-0.1.0a1/cli/settings/core.py +410 -0
  31. remotivelabs_cli-0.1.0a1/cli/settings/migrate_all_token_files.py +74 -0
  32. remotivelabs_cli-0.1.0a1/cli/settings/migrate_token_file.py +52 -0
  33. remotivelabs_cli-0.1.0a1/cli/settings/token_file.py +87 -0
  34. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/tools/can/can.py +2 -2
  35. remotivelabs_cli-0.1.0a1/cli/typer/typer_utils.py +25 -0
  36. remotivelabs_cli-0.1.0a1/cli/utils/__init__.py +0 -0
  37. {remotivelabs_cli-0.0.41/cli/cloud → remotivelabs_cli-0.1.0a1/cli/utils}/rest_helper.py +109 -38
  38. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/pyproject.toml +21 -38
  39. remotivelabs_cli-0.0.41/cli/cloud/auth/login.py +0 -62
  40. remotivelabs_cli-0.0.41/cli/cloud/auth_tokens.py +0 -33
  41. remotivelabs_cli-0.0.41/cli/cloud/organisations.py +0 -29
  42. remotivelabs_cli-0.0.41/cli/settings/cmd.py +0 -72
  43. remotivelabs_cli-0.0.41/cli/settings/core.py +0 -261
  44. remotivelabs_cli-0.0.41/cli/settings/token_file.py +0 -22
  45. remotivelabs_cli-0.0.41/cli/typer/typer_utils.py +0 -8
  46. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/LICENSE +0 -0
  47. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/README.md +0 -0
  48. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/__init__.py +0 -0
  49. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/files.py +0 -0
  50. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/lib/__about__.py +0 -0
  51. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/licenses.py +0 -0
  52. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/playback.py +0 -0
  53. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/broker/record.py +0 -0
  54. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/__init__.py +0 -0
  55. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/auth/__init__.py +0 -0
  56. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/resumable_upload.py +0 -0
  57. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/storage/__init__.py +0 -0
  58. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/storage/uri_or_path.py +0 -0
  59. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/cloud/uri.py +0 -0
  60. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/connect/__init__.py +0 -0
  61. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/errors.py +0 -0
  62. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/tools/__init__.py +0 -0
  63. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/tools/can/__init__.py +0 -0
  64. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/tools/tools.py +0 -0
  65. {remotivelabs_cli-0.0.41 → remotivelabs_cli-0.1.0a1}/cli/typer/__init__.py +0 -0
@@ -1,16 +1,18 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: remotivelabs-cli
3
- Version: 0.0.41
3
+ Version: 0.1.0a1
4
4
  Summary: CLI for operating RemotiveCloud and RemotiveBroker
5
5
  Author: Johan Rask
6
6
  Author-email: johan.rask@remotivelabs.com
7
- Requires-Python: >=3.8,<4
7
+ Requires-Python: >=3.9,<4
8
8
  Classifier: Programming Language :: Python :: 3
9
- Classifier: Programming Language :: Python :: 3.8
10
9
  Classifier: Programming Language :: Python :: 3.9
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: click (<8.2.0)
15
+ Requires-Dist: dacite (>=1.9.2,<2.0.0)
14
16
  Requires-Dist: grpc-stubs (>=1.53.0.5)
15
17
  Requires-Dist: mypy-protobuf (>=3.0.0)
16
18
  Requires-Dist: plotext (>=5.2,<6.0)
Binary file
@@ -0,0 +1,62 @@
1
+ import dataclasses
2
+ from typing import Any
3
+
4
+ import requests
5
+
6
+ from cli.utils.rest_helper import RestHelper as Rest
7
+
8
+ #
9
+ # Attempt to start cloud-api.
10
+ # Might be better to generate somehow instead.
11
+ # Lets discuss further
12
+ #
13
+
14
+
15
+ @dataclasses.dataclass
16
+ class EmptyResponse:
17
+ is_success: bool
18
+ status_code: int
19
+
20
+
21
+ @dataclasses.dataclass
22
+ class JsonResponse:
23
+ is_success: bool
24
+ status_code: int
25
+ __response: requests.Response
26
+
27
+ def json(self) -> Any:
28
+ return self.__response.json()
29
+
30
+ def text(self) -> str:
31
+ return self.__response.text
32
+
33
+
34
+ def __delete_response_from_response(response: requests.Response) -> EmptyResponse:
35
+ if 200 > response.status_code > 299:
36
+ ok = False
37
+ else:
38
+ ok = True
39
+ return EmptyResponse(is_success=ok, status_code=response.status_code)
40
+
41
+
42
+ def __json_response_from_response(response: requests.Response) -> JsonResponse:
43
+ if 200 > response.status_code > 299:
44
+ ok = False
45
+ else:
46
+ ok = True
47
+ return JsonResponse(is_success=ok, status_code=response.status_code, __response=response)
48
+
49
+
50
+ def create() -> JsonResponse:
51
+ response = Rest.handle_post(url="/api/me/keys", return_response=True)
52
+ return __json_response_from_response(response)
53
+
54
+
55
+ def revoke(name: str) -> EmptyResponse:
56
+ res_revoke = Rest.handle_patch(f"/api/me/keys/{name}/revoke", quiet=True, allow_status_codes=[404, 401])
57
+ return __delete_response_from_response(res_revoke)
58
+
59
+
60
+ def delete(name: str) -> EmptyResponse:
61
+ res_delete = Rest.handle_delete(f"/api/me/keys/{name}", quiet=True, allow_status_codes=[404, 401])
62
+ return __delete_response_from_response(res_delete)
@@ -26,7 +26,6 @@ def main(
26
26
  if url is not None:
27
27
  os.environ["REMOTIVE_BROKER_URL"] = url
28
28
  # Do other global stuff, handle other global options here
29
- return
30
29
 
31
30
 
32
31
  @app.command(help="Discover brokers on this network")
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- import os
4
3
  import signal as os_signal
4
+ import sys
5
5
  from typing import Any, List
6
6
 
7
7
  import grpc
@@ -57,7 +57,7 @@ def influxdb(
57
57
  def exit_on_ctrlc(_sig: Any, _frame: Any) -> None:
58
58
  if output is not None:
59
59
  f.close()
60
- os._exit(0)
60
+ sys.exit(0)
61
61
 
62
62
  def per_frame_influx_line_protocol(x: Any) -> None:
63
63
  signals = list(x)
@@ -67,7 +67,7 @@ def influxdb(
67
67
  frame = signals[0]["name"].rsplit(".", 1)[0]
68
68
  # frame_name = signals[0]["name"].split(".")[1]
69
69
  namespace = signals[0]["namespace"]
70
- signals_str = ",".join(list(map(lambda s: f'{sig}={s["value"]}', signals)))
70
+ signals_str = ",".join(list(map(lambda s: f"{sig}={s['value']}", signals)))
71
71
  influx_lp = f"{frame},namespace={namespace} {signals_str} {round(signals[0]['timestamp_us'] * 1000)}"
72
72
  if output is not None:
73
73
  f.write(f"{influx_lp}\n")
@@ -112,7 +112,7 @@ def influxdb(
112
112
  arr = sig.split(":")
113
113
  if len(arr) != 2:
114
114
  ErrorPrinter.print_hint(f"--signal must have format namespace:signal ({sig})")
115
- exit(1)
115
+ sys.exit(1)
116
116
 
117
117
  return SubscribableSignal(namespace=arr[0], name=arr[1])
118
118
 
@@ -6,6 +6,7 @@ import os
6
6
  import posixpath
7
7
  import queue
8
8
  import signal as os_signal
9
+ import sys
9
10
  import tempfile
10
11
  import time
11
12
  import zipfile
@@ -59,9 +60,7 @@ class Broker:
59
60
  os.environ["ACCESS_TOKEN"] = "false"
60
61
  self.intercept_channel = br.create_channel(url, None, None)
61
62
  else:
62
- err_console.print(
63
- "Option --api-key will is deprecated and will be removed. " "Use access access tokens by logging in with cli."
64
- )
63
+ err_console.print("Option --api-key will is deprecated and will be removed. Use access access tokens by logging in with cli.")
65
64
  os.environ["ACCESS_TOKEN"] = "false"
66
65
  self.intercept_channel = br.create_channel(url, api_key, None)
67
66
 
@@ -303,11 +302,10 @@ class Broker:
303
302
 
304
303
  for r in response:
305
304
  if r["data"]:
306
- print(f'Successfully received traffic on {r["namespace"]}')
305
+ print(f"Successfully received traffic on {r['namespace']}")
307
306
  keep_running = False
308
- else:
309
- if not wait_for_traffic or (not keep_running and not keep_running_during_recording):
310
- print(f'Namespace {r["namespace"]} did not receive any traffic')
307
+ elif not wait_for_traffic or (not keep_running and not keep_running_during_recording):
308
+ print(f"Namespace {r['namespace']} did not receive any traffic")
311
309
 
312
310
  def upload(self, file: str, dest: str) -> None:
313
311
  try:
@@ -456,10 +454,8 @@ class Broker:
456
454
  if ns not in existing_ns:
457
455
  ns_not_matching.append(ns)
458
456
  if len(ns_not_matching) > 0:
459
- ErrorPrinter.print_hint(
460
- f"Namespace(s) {ns_not_matching} does not exist on broker. " f"Namespaces found on broker: {existing_ns}"
461
- )
462
- exit(1)
457
+ ErrorPrinter.print_hint(f"Namespace(s) {ns_not_matching} does not exist on broker. Namespaces found on broker: {existing_ns}")
458
+ sys.exit(1)
463
459
 
464
460
  available_signals = list(filter(verify_namespace, existing_signals)) # type: ignore
465
461
  signals_to_subscribe_to = list(filter(find_subscribed_signal, available_signals)) # type: ignore
@@ -468,8 +464,8 @@ class Broker:
468
464
  signals_subscribed_to_but_does_not_exist = set(subscribed_signals) - set(map(lambda s: s["signal"], signals_to_subscribe_to))
469
465
 
470
466
  if len(signals_subscribed_to_but_does_not_exist) > 0:
471
- ErrorPrinter.print_hint(f"One or more signals you subscribed to does not exist " f"{signals_subscribed_to_but_does_not_exist}")
472
- exit(1)
467
+ ErrorPrinter.print_hint(f"One or more signals you subscribed to does not exist {signals_subscribed_to_but_does_not_exist}")
468
+ sys.exit(1)
473
469
 
474
470
  return list(map(lambda s: SubscribableSignal(s["signal"], s["namespace"]), signals_to_subscribe_to))
475
471
 
@@ -11,7 +11,7 @@ from rich.console import Console
11
11
  from rich.progress import Progress, SpinnerColumn, TextColumn
12
12
 
13
13
  from cli.broker.lib.broker import Broker, LicenseInfo
14
- from cli.cloud.rest_helper import RestHelper as Rest
14
+ from cli.utils.rest_helper import RestHelper as Rest
15
15
 
16
16
  console = Console(stderr=True)
17
17
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import sys
3
4
  from string import Template
4
5
  from typing import List
5
6
 
@@ -35,7 +36,7 @@ def new_script(
35
36
  arr = sig.split(":")
36
37
  if len(arr) != 2:
37
38
  ErrorPrinter.print_hint(f"--input-signal must have format namespace:signal ({sig})")
38
- exit(1)
39
+ sys.exit(1)
39
40
  return arr[0], arr[1]
40
41
 
41
42
  signals_to_subscribe_to = list(map(to_subscribable_signal, input_signal))
@@ -4,6 +4,7 @@ import json
4
4
  import numbers
5
5
  import os
6
6
  import signal as os_signal
7
+ import sys
7
8
  from datetime import datetime
8
9
  from pathlib import Path
9
10
  from typing import Any, Dict, Iterable, List, TypedDict, Union
@@ -60,11 +61,11 @@ def read_scripted_code_file(file_path: Path) -> bytes:
60
61
  return file.read()
61
62
  except FileNotFoundError:
62
63
  print("File not found. Please check your file path.")
63
- exit(1)
64
+ sys.exit(1)
64
65
 
65
66
 
66
67
  @app.command()
67
- def subscribe( # noqa: C901
68
+ def subscribe( # noqa: C901, PLR0913, PLR0915
68
69
  url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
69
70
  api_key: str = typer.Option(None, help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
70
71
  signal: List[str] = typer.Option([], help="Signal names to subscribe to, mandatory when not using script"),
@@ -79,7 +80,7 @@ def subscribe( # noqa: C901
79
80
  help="Supply a path to Lua script that to use for signal transformation",
80
81
  ),
81
82
  on_change_only: bool = typer.Option(default=False, help="Only get signal if value is changed"),
82
- x_plot: bool = typer.Option(default=False, help="Experimental: Plot the signal in terminal. Note graphs are not " "aligned by time"),
83
+ x_plot: bool = typer.Option(default=False, help="Experimental: Plot the signal in terminal. Note graphs are not aligned by time"),
83
84
  x_plot_size: int = typer.Option(default=100, help="Experimental: how many points show for each plot"),
84
85
  # samples: int = typer.Option(default=0, he)
85
86
  ) -> None:
@@ -100,12 +101,12 @@ def subscribe( # noqa: C901
100
101
  if script is None:
101
102
  if len(signal) == 0:
102
103
  ErrorPrinter.print_generic_error("You must use --signal or use --script when subscribing")
103
- exit(1)
104
+ sys.exit(1)
104
105
 
105
106
  if script is not None:
106
107
  if len(signal) > 0:
107
108
  ErrorPrinter.print_generic_error("You must must not specify --signal when using --script")
108
- exit(1)
109
+ sys.exit(1)
109
110
 
110
111
  plt.title("Signals")
111
112
 
@@ -113,8 +114,6 @@ def subscribe( # noqa: C901
113
114
  os._exit(0)
114
115
 
115
116
  def on_frame_plot(x: Iterable[Any]) -> None:
116
- global signal_values
117
-
118
117
  plt.clt() # to clear the terminal
119
118
  plt.cld() # to clear the data only
120
119
  frames = list(x)
@@ -146,9 +145,9 @@ def subscribe( # noqa: C901
146
145
  signal_values[f"ts_{name}"] = signal_values[f"ts_{name}"][len(signal_values[f"ts_{name}"]) - x_plot_size :]
147
146
 
148
147
  cnt = 1
149
- for key in signal_values:
148
+ for key, value in signal_values.items():
150
149
  if not key.startswith("ts_"):
151
- plt.subplot(cnt, 1).plot(signal_values[f"ts_{key}"], signal_values[key], label=key, color=cnt)
150
+ plt.subplot(cnt, 1).plot(signal_values[f"ts_{key}"], value, label=key, color=cnt)
152
151
  cnt = cnt + 1
153
152
  plt.sleep(0.001) # to add
154
153
  plt.show()
@@ -174,7 +173,7 @@ def subscribe( # noqa: C901
174
173
  arr = sig.split(":")
175
174
  if len(arr) != 2:
176
175
  ErrorPrinter.print_hint(f"--signal must have format namespace:signal ({sig})")
177
- exit(1)
176
+ sys.exit(1)
178
177
  return SubscribableSignal(namespace=arr[0], name=arr[1])
179
178
 
180
179
  signals_to_subscribe_to = list(map(to_subscribable_signal, signal))
@@ -1,35 +1,50 @@
1
+ from __future__ import annotations
2
+
1
3
  import sys
2
4
 
5
+ import typer
6
+
3
7
  from cli.cloud.auth.login import login as do_login
4
- from cli.cloud.rest_helper import RestHelper as Rest
5
8
  from cli.errors import ErrorPrinter
6
9
  from cli.settings import TokenNotFoundError, settings
7
10
  from cli.typer import typer_utils
11
+ from cli.utils.rest_helper import RestHelper as Rest
8
12
 
9
13
  from .. import auth_tokens
14
+ from ..organisations import do_select_default_org
10
15
 
11
16
  HELP = """
12
17
  Manage how you authenticate with our cloud platform
13
18
  """
14
19
  app = typer_utils.create_typer(help=HELP)
15
- app.add_typer(auth_tokens.app, name="tokens", help="Manage users personal access tokens")
20
+ # app.add_typer(auth_tokens.app, name="credentials", help="Manage account credentials")
16
21
 
17
22
 
18
23
  @app.command(name="login")
19
- def login() -> None:
24
+ def login(browser: bool = typer.Option(default=True, help="Does not automatically open browser, instead shows a link")) -> None:
20
25
  """
21
26
  Login to the cli using browser
22
27
 
28
+ If not able to open a browser it will show fallback to headless login and show a link that
29
+ users can copy into any browser when this is unsupported where running the cli - such as in docker,
30
+ virtual machine or ssh sessions.
31
+
23
32
  This will be used as the current access token in all subsequent requests. This would
24
33
  be the same as activating a personal access key or service-account access key.
25
34
  """
26
- do_login()
35
+ do_login(headless=not browser)
36
+ if settings.get_cli_config().get_active_default_organisation() is None:
37
+ set_default_organisation = typer.confirm(
38
+ "You have not set a default organisation\nWould you like to choose one now?", abort=False, default=True
39
+ )
40
+ if set_default_organisation:
41
+ do_select_default_org(get=False)
27
42
 
28
43
 
29
44
  @app.command()
30
45
  def whoami() -> None:
31
46
  """
32
- Validates authentication and fetches your user information
47
+ Validates authentication and fetches your account information
33
48
  """
34
49
  try:
35
50
  Rest.handle_get("/api/whoami")
@@ -61,7 +76,11 @@ def print_access_token_file() -> None:
61
76
  sys.exit(1)
62
77
 
63
78
 
64
- @app.command(help="Clear access token")
79
+ # @app.command(help="Clears active credentials")
65
80
  def logout() -> None:
66
81
  settings.clear_active_token()
67
82
  print("Access token removed")
83
+
84
+
85
+ app.command("activate")(auth_tokens.select_personal_token)
86
+ app.command("list")(auth_tokens.list_pats_files)
@@ -0,0 +1,317 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import secrets
8
+ import sys
9
+ import time
10
+ import webbrowser
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+ from threading import Thread
13
+ from typing import Any, Optional, Tuple
14
+ from urllib.parse import parse_qs, urlparse
15
+
16
+ import typer
17
+ from rich.console import Console
18
+ from typing_extensions import override
19
+
20
+ from cli.cloud.auth_tokens import list_and_select_personal_token
21
+ from cli.errors import ErrorPrinter
22
+ from cli.settings import TokenNotFoundError, settings
23
+ from cli.utils.rest_helper import RestHelper as Rest
24
+
25
+ httpd: HTTPServer
26
+
27
+ console = Console()
28
+
29
+
30
+ def generate_pkce_pair() -> Tuple[str, str]:
31
+ """
32
+ PKCE is used for all cli login flows, both headless and browser.
33
+ """
34
+ code_verifier_ = secrets.token_urlsafe(64) # High-entropy string
35
+ code_challenge_ = base64.urlsafe_b64encode(hashlib.sha256(code_verifier_.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
36
+ return code_verifier_, code_challenge_
37
+
38
+
39
+ code_verifier, code_challenge = generate_pkce_pair()
40
+ state = secrets.token_urlsafe(16)
41
+
42
+ short_lived_token = None
43
+
44
+
45
+ class S(BaseHTTPRequestHandler):
46
+ def _set_response(self) -> None:
47
+ self.send_response(200)
48
+ self.send_header("Content-type", "text/html")
49
+ self.end_headers()
50
+
51
+ @override
52
+ def log_message(self, format: Any, *args: Any) -> None:
53
+ return
54
+
55
+ # Please do not change this into lowercase!
56
+ @override
57
+ def do_GET(self) -> None: # type: ignore # noqa: PLR0912
58
+ self._set_response()
59
+
60
+ parsed_url = urlparse(self.path)
61
+
62
+ # Get query parameters as a dict
63
+ query_params = parse_qs(parsed_url.query)
64
+
65
+ # Example: Get the value of the "error" parameter if it exists
66
+ error_value = query_params.get("error", [None])[0]
67
+ path = self.path
68
+ auth_code = path[1:] # Remotive /
69
+ time.sleep(1)
70
+ httpd.server_close()
71
+
72
+ killerthread = Thread(target=httpd.shutdown)
73
+ killerthread.start()
74
+ if error_value is None:
75
+ res = Rest.handle_get(
76
+ f"/api/open/token?code={auth_code}&code_verifier={code_verifier}",
77
+ return_response=True,
78
+ skip_access_token=True,
79
+ allow_status_codes=[401, 400],
80
+ )
81
+ if res.status_code != 200:
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
+ self.wfile.write("Successfully setup CLI, return to your terminal to continue".encode("utf-8"))
92
+ access_token = res.json()["access_token"]
93
+ # token = tf.TokenFile(
94
+ # name="CLI_login_token",
95
+ # token=access_token,
96
+ # created=str(datetime.datetime.now().isoformat()),
97
+ # expires="unknown",
98
+ # )
99
+
100
+ global short_lived_token # noqa: PLW0603
101
+ short_lived_token = access_token
102
+
103
+ # settings.add_and_activate_short_lived_cli_token(tf.dumps(token))
104
+ # print("Successfully logged on, you are ready to go with cli")
105
+ else:
106
+ if error_value == "no_consent":
107
+ self.wfile.write(
108
+ """
109
+ Authorization was cancelled.<br/>
110
+ To use RemotiveCLI, you need to grant access to your RemotiveCloud account.
111
+ <br/><br/>
112
+ Run `remotive cloud auth login` to try again.
113
+ """.encode("utf-8")
114
+ )
115
+ ErrorPrinter.print_generic_error("You did not grant access to RemotiveCloud, login aborted")
116
+ else:
117
+ self.wfile.write(f"Unknown error {error_value}, please contact support@remotivelabs.com".encode("utf-8"))
118
+ ErrorPrinter.print_generic_error(f"Unexpected error {error_value}, please contact support@remotivelabs.com")
119
+ sys.exit(1)
120
+
121
+
122
+ def prepare_local_webserver(server_class: type = HTTPServer, handler_class: type = S, port: Optional[int] = None) -> None:
123
+ if port is None:
124
+ env_val = os.getenv("REMOTIVE_LOGIN_CALLBACK_PORT" or "")
125
+ if env_val and env_val.isdigit():
126
+ port = int(env_val)
127
+ else:
128
+ port = 0
129
+
130
+ server_address = ("", port)
131
+ global httpd # noqa: PLW0603
132
+ httpd = server_class(server_address, handler_class)
133
+
134
+
135
+ def create_personal_token() -> None:
136
+ response = Rest.handle_post(
137
+ url="/api/me/keys",
138
+ return_response=True,
139
+ body=json.dumps({"alias": "roine"}),
140
+ access_token=short_lived_token,
141
+ )
142
+ token = response.json()
143
+ email = token["account"]["email"]
144
+ existing_file = settings.get_token_file_by_email(email=email)
145
+ if existing_file is not None:
146
+ # ErrorPrinter.print_hint(f"Revoking and deleting existing credentials [remove_me]{existing_file.name}")
147
+ res = Rest.handle_patch(
148
+ f"/api/me/keys/{existing_file.name}/revoke",
149
+ quiet=True,
150
+ access_token=short_lived_token,
151
+ allow_status_codes=[400, 404],
152
+ )
153
+ if res is not None and res.status_code == 200:
154
+ Rest.handle_delete(
155
+ f"/api/me/keys/{existing_file.name}",
156
+ quiet=True,
157
+ access_token=short_lived_token,
158
+ )
159
+ settings.remove_token_file(existing_file.name)
160
+
161
+ settings.add_personal_token(response.text, activate=True)
162
+
163
+ print("Successfully logged on")
164
+
165
+
166
+ def login(headless: bool = False) -> bool: # noqa: C901, PLR0912, PLR0915
167
+ """
168
+ Initiate login
169
+ """
170
+
171
+ #
172
+ # Check login.md flowchart for better understanding
173
+ #
174
+ # 1. Check for active token valid and working credentials
175
+ #
176
+ try:
177
+ activate_token = settings.get_active_token_file()
178
+
179
+ if not activate_token.is_expired():
180
+ if Rest.has_access("/api/whoami"):
181
+ console.print(f"You are already signed in with valid credentials that expires in {activate_token.expires_in_days()} days")
182
+ files = settings.list_personal_token_files()
183
+ if len(files) > 0:
184
+ print("")
185
+ console.print("You have available credentials on disk, [bold]choose one or press q to login again[/bold]")
186
+ token_selected = list_and_select_personal_token(skip_prompt=False)
187
+ if token_selected:
188
+ return True
189
+ # list_and_select_personal_token(skip_prompt=True)
190
+ # print("")
191
+ # typer.confirm("Are you sure you want to login again?", abort=True)
192
+ # If we are here, user still wants to login
193
+ else:
194
+ settings.clear_active_token()
195
+ raise TokenNotFoundError()
196
+ else:
197
+ # TODO - Cleanup token since expired
198
+ pass
199
+
200
+ except TokenNotFoundError:
201
+ #
202
+ # 2. If no token was found, let user choose an existing if exists
203
+ #
204
+ files = settings.list_personal_token_files()
205
+ if len(files) > 0:
206
+ print("")
207
+ token_selected = list_and_select_personal_token(
208
+ skip_prompt=False,
209
+ info_message="You have available credentials on disk, choose one or press q to login again",
210
+ )
211
+ if token_selected:
212
+ return True
213
+
214
+ prepare_local_webserver()
215
+
216
+ def force_use_webserver_callback() -> bool:
217
+ env_val = os.getenv("REMOTIVE_LOGIN_FORCE_CALLBACK" or "no")
218
+ if env_val and env_val == "yes":
219
+ return True
220
+ return False
221
+
222
+ def login_with_callback_but_copy_url() -> None:
223
+ """
224
+ This will print a url the will trigger a callback later so the webserver must be up and running.
225
+ """
226
+ print("Copy the following link in a browser to login to cloud, and complete the sign-in prompts:")
227
+ print("")
228
+
229
+ url = (
230
+ f"{Rest.get_base_frontend_url()}/login"
231
+ f"?state={state}"
232
+ f"&cli_version={Rest.get_cli_version()}"
233
+ f"&response_type=code"
234
+ f"&code_challenge={code_challenge}"
235
+ f"&redirect_uri=http://localhost:{httpd.server_address[1]}"
236
+ )
237
+ console.print(url, style="bold")
238
+ httpd.serve_forever()
239
+
240
+ def login_headless() -> None:
241
+ """
242
+ Full headless, opens a browser and expects a auth code to be entered and exchanged for the token
243
+ """
244
+ print("Copy the following link in a browser to login to cloud, and complete the sign-in prompts:")
245
+ print("")
246
+
247
+ url = (
248
+ f"{Rest.get_base_frontend_url()}/login"
249
+ f"?state={state}"
250
+ f"&cli_version={Rest.get_cli_version()}"
251
+ f"&response_type=code"
252
+ f"&code_challenge={code_challenge}"
253
+ )
254
+ console.print(url, style="bold")
255
+
256
+ code = typer.prompt(
257
+ "Once finished, enter the verification code provided in your browser",
258
+ hide_input=False,
259
+ )
260
+ res = Rest.handle_get(
261
+ f"/api/open/token?code={code}&code_verifier={code_verifier}",
262
+ return_response=True,
263
+ skip_access_token=True,
264
+ allow_status_codes=[401],
265
+ )
266
+ if res.status_code == 401:
267
+ ErrorPrinter.print_generic_error(
268
+ "Failed to fetch token. Please try again, if the problem persists please reach out to support@remotivelabs.com"
269
+ )
270
+ sys.exit(1)
271
+ access_token = res.json()["access_token"]
272
+ # res = Rest.handle_get("/api/whoami", return_response=True, access_token=access_token)
273
+ global short_lived_token # noqa: PLW0603
274
+ short_lived_token = access_token
275
+ create_personal_token()
276
+ # current_user = res.json()
277
+ # token = tf.TokenFile(
278
+ # type="authorized_user",
279
+ # name="CLI_login_token",
280
+ # token=access_token,
281
+ # created=str(datetime.datetime.now().isoformat()),
282
+ # expires="unknown",
283
+ # account=TokenFileUser(email=current_user["email"], uid=current_user["uid"], project=None),
284
+ # )
285
+ # settings.add_and_activate_short_lived_cli_token(tf.dumps(token))
286
+ # console.print("Successfully logged on, you are ready to go with cli", style="green bold")
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 avaialble"
305
+ )
306
+ print("")
307
+ if force_use_webserver_callback():
308
+ login_with_callback_but_copy_url()
309
+ else:
310
+ login_headless()
311
+ else:
312
+ httpd.serve_forever()
313
+
314
+ # Once we received our callback or code we are logged in and ready to go
315
+ create_personal_token()
316
+
317
+ return True