remotivelabs-cli 0.0.42__py3-none-any.whl → 0.1.0a2__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 +279 -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.0a2.dist-info}/METADATA +6 -4
  39. remotivelabs_cli-0.1.0a2.dist-info/RECORD +59 -0
  40. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a2.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.0a2.dist-info}/LICENSE +0 -0
  44. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a2.dist-info}/entry_points.txt +0 -0
cli/remotive.py CHANGED
@@ -1,18 +1,25 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
4
  from importlib.metadata import version
3
5
 
4
6
  import typer
5
7
  from rich import print as rich_print
8
+ from rich.console import Console
6
9
  from trogon import Trogon # type: ignore
7
10
  from typer.main import get_group
8
11
 
12
+ from cli.settings.migrate_all_token_files import migrate_any_legacy_tokens
13
+
9
14
  from .broker.brokers import app as broker_app
10
15
  from .cloud.cloud_cli import app as cloud_app
11
16
  from .connect.connect import app as connect_app
12
- from .settings.cmd import app as settings_app
17
+ from .settings import settings
13
18
  from .tools.tools import app as tools_app
14
19
  from .typer import typer_utils
15
20
 
21
+ err_console = Console(stderr=True)
22
+
16
23
  if os.getenv("GRPC_VERBOSITY") is None:
17
24
  os.environ["GRPC_VERBOSITY"] = "NONE"
18
25
 
@@ -25,6 +32,8 @@ For documentation - https://docs.remotivelabs.com
25
32
  """,
26
33
  )
27
34
 
35
+ # settings.set_default_config_as_env()
36
+
28
37
 
29
38
  def version_callback(value: bool) -> None:
30
39
  if value:
@@ -37,17 +46,33 @@ def test_callback(value: int) -> None:
37
46
  if value:
38
47
  rich_print(value)
39
48
  raise typer.Exit()
40
- # if value:
41
- # typer.echo(f"Awesome CLI Version: 0.0.22a")
42
- # raise typer.Exit()
49
+
50
+
51
+ def _migrate_old_tokens() -> None:
52
+ tokens = settings.list_personal_tokens()
53
+ tokens.extend(settings.list_service_account_tokens())
54
+ if migrate_any_legacy_tokens(tokens):
55
+ err_console.print("Migrated old credentials and configuration files, you may need to login again or activate correct credentials")
56
+
57
+
58
+ def _set_default_org_as_env() -> None:
59
+ """
60
+ If not already set, take the default organisation from file and set as env
61
+ This has to be done early before it is read
62
+ """
63
+ if "REMOTIVE_CLOUD_ORGANIZATION" not in os.environ:
64
+ org = settings.get_cli_config().get_active_default_organisation()
65
+ if org is not None:
66
+ os.environ["REMOTIVE_CLOUD_ORGANIZATION"] = org
43
67
 
44
68
 
45
69
  @app.callback()
46
70
  def main(
47
71
  _the_version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=False, help="Print current version"),
48
72
  ) -> None:
73
+ _set_default_org_as_env()
74
+ _migrate_old_tokens()
49
75
  # Do other global stuff, handle other global options here
50
- return
51
76
 
52
77
 
53
78
  @app.command()
@@ -65,6 +90,5 @@ app.add_typer(
65
90
  name="cloud",
66
91
  help="Manage resources in RemotiveCloud",
67
92
  )
68
- app.add_typer(settings_app, name="config", help="Manage access tokens")
69
93
  app.add_typer(connect_app, name="connect", help="Integrations with other systems")
70
94
  app.add_typer(tools_app, name="tools")
cli/settings/__init__.py CHANGED
@@ -1,5 +1,4 @@
1
- from cli.settings.cmd import app
2
1
  from cli.settings.core import InvalidSettingsFilePathError, Settings, TokenNotFoundError, settings
3
2
  from cli.settings.token_file import TokenFile
4
3
 
5
- __all__ = ["app", "settings", "TokenFile", "TokenNotFoundError", "InvalidSettingsFilePathError", "Settings"]
4
+ __all__ = ["settings", "TokenFile", "TokenNotFoundError", "InvalidSettingsFilePathError", "Settings"]
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ from dataclasses import dataclass
6
+ from json import JSONDecodeError
7
+ from typing import Dict, Optional
8
+
9
+ from dacite import from_dict
10
+
11
+
12
+ def loads(data: str) -> ConfigFile:
13
+ try:
14
+ d = json.loads(data)
15
+ return from_dict(ConfigFile, d)
16
+ except JSONDecodeError as e:
17
+ # ErrorPrinter.print_generic_error("Invalid json format, config.json")
18
+ raise JSONDecodeError(
19
+ f"File config.json is not valid json, please edit or remove file to have it re-created ({e.msg})", pos=e.pos, doc=e.doc
20
+ )
21
+
22
+
23
+ def dumps(config: ConfigFile) -> str:
24
+ return json.dumps(dataclasses.asdict(config), default=str)
25
+
26
+
27
+ @dataclass
28
+ class Account:
29
+ credentials_name: str
30
+ default_organization: Optional[str] = None
31
+ # Add project as well
32
+
33
+
34
+ @dataclass
35
+ class ConfigFile:
36
+ version: str = "1.0"
37
+ active: Optional[str] = None
38
+ accounts: Dict[str, Account] = dataclasses.field(default_factory=dict)
39
+
40
+ def get_active_default_organisation(self) -> Optional[str]:
41
+ active_account = self.get_active()
42
+ return active_account.default_organization if active_account is not None else None
43
+
44
+ def get_active(self) -> Optional[Account]:
45
+ if self.active is not None:
46
+ account = self.accounts.get(self.active)
47
+ if account is not None:
48
+ return account
49
+ raise KeyError(f"Activated account {self.active} is not a valid account")
50
+ return None
51
+
52
+ def activate(self, email: str) -> None:
53
+ account = self.accounts.get(email)
54
+
55
+ if account is not None:
56
+ self.active = email
57
+ else:
58
+ raise KeyError(f"Account {email} does not exists")
59
+
60
+ def get_account(self, email: str) -> Optional[Account]:
61
+ if self.accounts:
62
+ return self.accounts[email]
63
+ return None
64
+
65
+ def remove_account(self, email: str) -> None:
66
+ if self.accounts:
67
+ self.accounts.pop(email, None)
68
+
69
+ def init_account(self, email: str, token_name: str) -> None:
70
+ if self.accounts is None:
71
+ self.accounts = {}
72
+
73
+ account = self.accounts.get(email)
74
+ if not account:
75
+ account = Account(credentials_name=token_name)
76
+ else:
77
+ account.credentials_name = token_name
78
+ self.accounts[email] = account
79
+
80
+ def set_account_field(self, email: str, default_organization: Optional[str] = None) -> ConfigFile:
81
+ if self.accounts is None:
82
+ self.accounts = {}
83
+
84
+ account = self.accounts.get(email)
85
+ if not account:
86
+ raise KeyError(f"Account with email {email} has not been initialized with token")
87
+
88
+ # Update only fields explicitly passed
89
+ if default_organization is not None:
90
+ account.default_organization = default_organization
91
+
92
+ return self
cli/settings/core.py CHANGED
@@ -1,15 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
- import datetime
3
+ import os
4
+ import re
4
5
  import shutil
6
+ import stat
5
7
  import sys
8
+ from dataclasses import dataclass
6
9
  from json import JSONDecodeError
7
10
  from pathlib import Path
8
- from typing import Tuple
11
+ from typing import Optional, Tuple, Union
9
12
 
10
13
  from rich.console import Console
11
14
 
15
+ from cli.errors import ErrorPrinter
16
+ from cli.settings import config_file
12
17
  from cli.settings import token_file as tf
18
+ from cli.settings.config_file import ConfigFile
13
19
  from cli.settings.token_file import TokenFile
14
20
 
15
21
  err_console = Console(stderr=True)
@@ -19,6 +25,7 @@ CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
19
25
  INCORRECT_CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive"
20
26
  DEPRECATED_CONFIG_DIR_PATH = Path.home() / ".remotive"
21
27
 
28
+ CLI_CONFIG_FILE_NAME = "config.json"
22
29
  ACTIVE_TOKEN_FILE_NAME = "cloud.secret.token"
23
30
  PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
24
31
  SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
@@ -31,10 +38,19 @@ class InvalidSettingsFilePathError(Exception):
31
38
  """Raised when trying to access an invalid settings file or file path"""
32
39
 
33
40
 
41
+ class NotFoundError(Exception):
42
+ """Raised when a token cannot be found in settings"""
43
+
44
+
34
45
  class TokenNotFoundError(Exception):
35
46
  """Raised when a token cannot be found in settings"""
36
47
 
37
48
 
49
+ @dataclass()
50
+ class CliConfigFile:
51
+ default_organisation: Union[str, None]
52
+
53
+
38
54
  class Settings:
39
55
  """
40
56
  Settings for the remotive CLI
@@ -45,8 +61,8 @@ class Settings:
45
61
  def __init__(self, config_dir: Path, deprecated_config_dirs: list[Path] | None = None) -> None:
46
62
  self.config_dir = config_dir
47
63
  self._active_secret_token_path = self.config_dir / ACTIVE_TOKEN_FILE_NAME
64
+ self._cli_config = self.config_dir / CLI_CONFIG_FILE_NAME
48
65
 
49
- # no migration of deprecated config dirs if the new config dir already exists
50
66
  if self.config_dir.exists():
51
67
  return
52
68
 
@@ -56,6 +72,48 @@ class Settings:
56
72
  for deprecated_config_dir in deprecated_config_dirs:
57
73
  self._migrate_legacy_config_dir(deprecated_config_dir)
58
74
 
75
+ # def _write_properties(self, filepath: Path, props: CliConfigFile) -> None:
76
+ # with open(filepath, "w", encoding="utf-8") as file:
77
+ # # keys = sorted(props.keys()) if sort_keys else props.keys()
78
+ # # for key in keys:
79
+ # file.write(f"default_organisation={props.default_organisation}\n")
80
+
81
+ def _read_properties(self, filepath: Path) -> CliConfigFile:
82
+ props = {}
83
+ with open(filepath, "r", encoding="utf-8") as file:
84
+ for line_num, line in enumerate(file, start=1):
85
+ line_stripped = line.strip()
86
+ if not line_stripped or line_stripped.startswith("#"):
87
+ continue
88
+ if "=" not in line_stripped:
89
+ raise ValueError(f"Invalid line format at line {line_num}: {line}")
90
+ key, value = line_stripped.split("=", 1)
91
+ key, value = key.strip(), value.strip()
92
+ if key in props:
93
+ raise ValueError(f"Duplicate key '{key}' found at line {line_num}")
94
+ props[key] = value
95
+ if "default_organisation" in props:
96
+ return CliConfigFile(default_organisation=props["default_organisation"])
97
+ return CliConfigFile(default_organisation=None)
98
+
99
+ def set_default_organisation(self, organisation: str) -> None:
100
+ cli_config = self.get_cli_config()
101
+
102
+ try:
103
+ token = settings.get_active_token_file()
104
+ cli_config.set_account_field(token.account.email, organisation)
105
+ self._write_config_file(cli_config)
106
+ except TokenNotFoundError:
107
+ ErrorPrinter.print_hint("You must have an account activated in order to set default organization")
108
+ sys.exit(1)
109
+
110
+ def get_cli_config(self) -> ConfigFile:
111
+ try:
112
+ return self._read_config_file()
113
+ except TokenNotFoundError:
114
+ return ConfigFile()
115
+ # self._write_properties(CONFIG_DIR_PATH / CLI_CONFIG_FILE_NAME, [])
116
+
59
117
  def get_active_token(self) -> str:
60
118
  """
61
119
  Get the current active token secret
@@ -67,25 +125,48 @@ class Settings:
67
125
  """
68
126
  Get the current active token file
69
127
  """
70
- if not self._active_secret_token_path.exists():
71
- raise TokenNotFoundError("no active token file found")
72
128
 
73
- return self._read_token_file(self._active_secret_token_path)
129
+ active_account = self.get_cli_config().get_active()
130
+ if active_account is not None:
131
+ token_name = active_account.credentials_name
132
+ return self.get_token_file(token_name)
133
+ raise TokenNotFoundError
134
+ # if not self._active_secret_token_path.exists():
135
+ # raise TokenNotFoundError("no active token file found")
136
+ # return self._read_token_file(self._active_secret_token_path)
74
137
 
75
- def activate_token(self, name: str) -> None:
138
+ def activate_token(self, token: TokenFile) -> None:
76
139
  """
77
140
  Activate a token by name or path
78
141
 
79
142
  The token secret will be set as the current active secret.
80
143
  """
81
- token_file = self.get_token_file(name)
82
- self._write_token_file(self._active_secret_token_path, token_file)
144
+ # token_file = self.get_token_file(name)
145
+ cli_config = self.get_cli_config()
146
+ cli_config.activate(token.account.email)
147
+ # if token_file.account.email not in cli_config.accounts:
148
+ # cli_config.set_account_field(token_file.account.email)
149
+ self._write_config_file(cli_config)
150
+ # self._write_token_file(self._active_secret_token_path, token_file)
83
151
 
84
152
  def clear_active_token(self) -> None:
85
153
  """
86
154
  Clear the current active token
87
155
  """
88
- self._active_secret_token_path.unlink(missing_ok=True)
156
+ config = self.get_cli_config()
157
+ config.active = None
158
+ self._write_config_file(config)
159
+
160
+ # self._active_secret_token_path.unlink(missing_ok=True)
161
+
162
+ def get_token_file_by_email(self, email: str) -> Optional[TokenFile]:
163
+ tokens = [t for t in self.list_personal_tokens() if t.account is not None and t.account.email == email]
164
+ if len(tokens) > 0:
165
+ return tokens[0]
166
+ tokens = [t for t in self.list_service_account_tokens() if t.account is not None and t.account.email == email]
167
+ if len(tokens) > 0:
168
+ return tokens[0]
169
+ return None
89
170
 
90
171
  def get_token_file(self, name: str) -> TokenFile:
91
172
  """
@@ -93,6 +174,8 @@ class Settings:
93
174
  """
94
175
  if Path(name).exists():
95
176
  return self._read_token_file(Path(name))
177
+ if Path(CONFIG_DIR_PATH / name).exists():
178
+ return self._read_token_file(Path(CONFIG_DIR_PATH / name))
96
179
 
97
180
  return self._get_token_by_name(name)[0]
98
181
 
@@ -107,18 +190,18 @@ class Settings:
107
190
  raise InvalidSettingsFilePathError(f"cannot remove a token file not located in settings dir {self.config_dir}")
108
191
  return Path(name).unlink()
109
192
 
110
- # TODO: what about the active token? # pylint: disable=fixme
111
-
193
+ # TODO: what about the active token?
112
194
  path = self._get_token_by_name(name)[1]
195
+ # print("Deleting", path)
113
196
  return path.unlink()
114
197
 
115
- def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
116
- """
117
- Activates a short lived token
118
- """
119
- token_file = tf.loads(token)
120
- self._write_token_file(self._active_secret_token_path, token_file)
121
- return token_file
198
+ # def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
199
+ # """
200
+ # Activates a short lived token
201
+ # """
202
+ # token_file = tf.loads(token)
203
+ # self._write_token_file(self._active_secret_token_path, token_file)
204
+ # return token_file
122
205
 
123
206
  def add_personal_token(
124
207
  self,
@@ -131,15 +214,24 @@ class Settings:
131
214
  """
132
215
  token_file = tf.loads(token)
133
216
 
134
- file = f"{PERSONAL_TOKEN_FILE_PREFIX}{token_file.name}.json"
217
+ def email_to_safe_filename(email: str) -> str:
218
+ # Replace any invalid character with an underscore
219
+ return re.sub(r'[<>:"/\\|?*]', "_", email)
220
+
221
+ # From now, user will never be None when adding a token so in this case token_file.user is never None
222
+ email = email_to_safe_filename(token_file.account.email) if token_file.account is not None else "unknown"
223
+ file = f"{PERSONAL_TOKEN_FILE_PREFIX}{token_file.name}-{email}.json"
135
224
  path = self.config_dir / file
136
225
  if path.exists() and not overwrite_if_exists:
137
226
  raise FileExistsError(f"Token file already exists: {path}")
138
227
 
139
228
  self._write_token_file(path, token_file)
229
+ cli_config = self.get_cli_config()
230
+ cli_config.init_account(email=token_file.account.email, token_name=token_file.name)
231
+ self._write_config_file(cli_config)
140
232
 
141
233
  if activate:
142
- self.activate_token(token_file.name)
234
+ self.activate_token(token_file)
143
235
 
144
236
  return token_file
145
237
 
@@ -155,20 +247,39 @@ class Settings:
155
247
  """
156
248
  return [f[1] for f in self._list_personal_tokens()]
157
249
 
158
- def add_service_account_token(self, service_account: str, token: str) -> TokenFile:
159
- """
160
- Add a service account token to the config directory
161
- """
250
+ def add_service_account_token(self, token: str) -> TokenFile:
162
251
  token_file = tf.loads(token)
163
252
 
164
- file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{service_account}-{token_file.name}.json"
253
+ def email_to_safe_filename(email: str) -> str:
254
+ # Replace any invalid character with an underscore
255
+ return re.sub(r'[<>:"/\\|?*]', "_", email)
256
+
257
+ # From now, user will never be None when adding a token so in this case token_file.user is never None
258
+
259
+ email = email_to_safe_filename(token_file.account.email) if token_file.account is not None else "unknown"
260
+ file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{token_file.name}-{email}.json"
165
261
  path = self.config_dir / file
166
- if path.exists():
167
- raise FileExistsError(f"Token file already exists: {path}")
168
262
 
169
263
  self._write_token_file(path, token_file)
264
+ print(f"Service account token stored at {path}")
265
+ cli_config = self.get_cli_config()
266
+ cli_config.init_account(email=token_file.account.email, token_name=token_file.name)
267
+ self._write_config_file(cli_config)
268
+
269
+ # if activate:
270
+ # self.activate_token(token_file.account.email)
271
+
170
272
  return token_file
171
273
 
274
+ # token_file = tf.loads(token)
275
+ # file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{service_account}-{token_file.name}.json"
276
+ # path = self.config_dir / file
277
+ # if path.exists():
278
+ # raise FileExistsError(f"Token file already exists: {path}")
279
+
280
+ # self._write_token_file(path, token_file)
281
+ # return token_file
282
+
172
283
  def list_service_account_tokens(self) -> list[TokenFile]:
173
284
  """
174
285
  List all service account tokens
@@ -195,13 +306,25 @@ class Settings:
195
306
  return matches[0]
196
307
 
197
308
  def _list_token_files(self, prefix: str = "") -> list[TokenFileMetadata]:
198
- # list all tokens with the correct prefix in the config dir, but omit the special active token file
199
- def is_path_prefixed_and_not_active_secret(path: Path) -> bool:
309
+ """list all tokens with the correct prefix in the config dir, but omit files that are not token files"""
310
+
311
+ def is_valid_json(path: Path) -> bool:
312
+ try:
313
+ self._read_token_file(path)
314
+ return True
315
+ except JSONDecodeError:
316
+ # TODO - this should be printed but printing it here causes it to be displayed to many times
317
+ # err_console.print(f"File is not valid json, skipping. {path}")
318
+ return False
319
+
320
+ def is_valid_token_file(path: Path) -> bool:
321
+ is_token_file = path.name.startswith(SERVICE_ACCOUNT_TOKEN_FILE_PREFIX) or path.name.startswith(PERSONAL_TOKEN_FILE_PREFIX)
200
322
  has_correct_prefix = path.is_file() and path.name.startswith(prefix)
201
323
  is_active_secret = path == self._active_secret_token_path
202
- return has_correct_prefix and not is_active_secret
324
+ is_cli_config = path == self._cli_config
325
+ return is_token_file and is_valid_json(path) and has_correct_prefix and not is_active_secret and not is_cli_config
203
326
 
204
- paths = [path for path in self.config_dir.iterdir() if is_path_prefixed_and_not_active_secret(path)]
327
+ paths = [path for path in self.config_dir.iterdir() if is_valid_token_file(path)]
205
328
 
206
329
  return [(self._read_token_file(token_file), token_file) for token_file in paths]
207
330
 
@@ -209,6 +332,10 @@ class Settings:
209
332
  data = self._read_file(path)
210
333
  return tf.loads(data)
211
334
 
335
+ def _read_config_file(self) -> ConfigFile:
336
+ data = self._read_file(self.config_dir / CLI_CONFIG_FILE_NAME)
337
+ return config_file.loads(data)
338
+
212
339
  def _read_file(self, path: Path) -> str:
213
340
  if not path.exists():
214
341
  raise TokenNotFoundError(f"File could not be found: {path}")
@@ -216,7 +343,15 @@ class Settings:
216
343
 
217
344
  def _write_token_file(self, path: Path, token: TokenFile) -> Path:
218
345
  data = tf.dumps(token)
219
- return self._write_file(path, data)
346
+ path = self._write_file(path, data)
347
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
348
+ return path
349
+
350
+ def _write_config_file(self, config: ConfigFile) -> Path:
351
+ data = config_file.dumps(config)
352
+ path = self._write_file(self._cli_config, data)
353
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
354
+ return path
220
355
 
221
356
  def _write_file(self, path: Path, data: str) -> Path:
222
357
  if self.config_dir not in path.parents:
@@ -233,24 +368,32 @@ class Settings:
233
368
  shutil.copytree(str(path), str(self.config_dir), dirs_exist_ok=True)
234
369
  secret = path / ACTIVE_TOKEN_FILE_NAME
235
370
  if secret.exists():
236
- value = secret.read_text(encoding="utf-8").strip()
371
+ sys.stderr.write(f"Removing old activated token {secret}")
372
+ secret.unlink(missing_ok=True)
373
+ # value = secret.read_text(encoding="utf-8").strip()
237
374
  # The existing token file might either be a token file, or simply a string. We handle both cases...
238
- try:
239
- token = tf.loads(value)
240
- except JSONDecodeError:
241
- token = tf.TokenFile(
242
- name="MigratedActiveToken",
243
- token=value,
244
- created=str(datetime.datetime.now().isoformat()),
245
- expires="unknown",
246
- )
247
- self.add_and_activate_short_lived_cli_token(tf.dumps(token))
375
+ # try:
376
+ # token = tf.loads(value)
377
+ # except JSONDecodeError:
378
+ # token = tf.TokenFile(
379
+ # version="1.0",
380
+ # type="service-account" if value.startswith("sa") else "authorized_user",
381
+ # name="MigratedActiveToken",
382
+ # token=value,
383
+ # created=str(datetime.datetime.now().isoformat()),
384
+ # expires="unknown",
385
+ # account=TokenFileAccount(email="unknown@remotivecloud.com"),
386
+ # )
387
+ # self.add_and_activate_short_lived_cli_token(tf.dumps(token))
248
388
  shutil.rmtree(str(path))
249
389
 
250
390
 
251
391
  def create_settings() -> Settings:
252
392
  """Create remotive CLI config directory and return its settings instance"""
253
- return Settings(CONFIG_DIR_PATH, deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH])
393
+ return Settings(
394
+ CONFIG_DIR_PATH,
395
+ deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH],
396
+ )
254
397
 
255
398
 
256
399
  settings = create_settings()
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from cli.settings import settings
4
+ from cli.settings.core import ACTIVE_TOKEN_FILE_NAME
5
+ from cli.settings.migrate_token_file import InvalidTokenError, UnsupportedTokenVersionError, migrate_legacy_token
6
+ from cli.settings.token_file import TokenFile, dumps
7
+
8
+
9
+ def _migrate_legacy_tokens(tokens: list[TokenFile]) -> tuple[list[TokenFile], set[str]]:
10
+ """
11
+ Determine which tokens can be updated and which should be removed.
12
+
13
+ Returns:
14
+ tuple of (updated_tokens, invalid_tokens)
15
+ """
16
+ updated_tokens: list[TokenFile] = []
17
+ invalid_tokens: set[str] = set()
18
+
19
+ for token in tokens:
20
+ try:
21
+ migrated_token = migrate_legacy_token(token)
22
+ if migrated_token.version != token.version:
23
+ updated_tokens.append(migrated_token)
24
+ except (InvalidTokenError, UnsupportedTokenVersionError):
25
+ # Token not valid or unsupported version, mark for removal
26
+ invalid_tokens.add(token.name)
27
+
28
+ return updated_tokens, invalid_tokens
29
+
30
+
31
+ def _write_updated_tokens(updated_tokens: list[TokenFile]) -> None:
32
+ for updated_token in updated_tokens:
33
+ settings.remove_token_file(name=updated_token.name)
34
+ if updated_token.type == "authorized_user":
35
+ settings.add_personal_token(dumps(updated_token), overwrite_if_exists=True)
36
+ elif updated_token.type == "service_account":
37
+ settings.add_service_account_token(dumps(updated_token))
38
+ else:
39
+ raise ValueError(f"Unsupported token type: {updated_token.type}")
40
+
41
+
42
+ def _remove_invalid_tokens(invalid_tokens: set[str]) -> None:
43
+ for token_name in invalid_tokens:
44
+ settings.remove_token_file(name=token_name)
45
+
46
+
47
+ def _remove_old_secret_file() -> bool:
48
+ old_activated_secret_file = settings.config_dir / ACTIVE_TOKEN_FILE_NAME
49
+ old_secret_exists = old_activated_secret_file.exists()
50
+ if old_secret_exists:
51
+ old_activated_secret_file.unlink(missing_ok=True)
52
+ return old_secret_exists
53
+
54
+
55
+ def migrate_any_legacy_tokens(tokens: list[TokenFile]) -> bool:
56
+ """
57
+ Migrate any legacy tokens to the latest TokenFile format.
58
+
59
+ Returns True if any tokens were migrated, False otherwise.
60
+ """
61
+ # Get tokens to update/remove
62
+ updated_tokens, invalid_tokens = _migrate_legacy_tokens(tokens)
63
+
64
+ # Perform file operations
65
+ _write_updated_tokens(updated_tokens)
66
+ _remove_invalid_tokens(invalid_tokens)
67
+
68
+ # Remove old secret file if exists
69
+ old_secret_removed = _remove_old_secret_file()
70
+ if old_secret_removed:
71
+ return True # We migrated at least one token
72
+
73
+ # only return True if we migrated at least one token
74
+ return len(updated_tokens) + len(invalid_tokens) > 0
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from cli.settings.token_file import TokenFile, TokenFileAccount
4
+ from cli.utils.rest_helper import RestHelper
5
+
6
+
7
+ class InvalidTokenError(Exception):
8
+ """Raised when a token is invalid."""
9
+
10
+
11
+ class UnsupportedTokenVersionError(Exception):
12
+ """Raised when a token version is not supported."""
13
+
14
+
15
+ def migrate_legacy_token(token: TokenFile) -> TokenFile:
16
+ """
17
+ Migrate a token from a legacy format to the latest format.
18
+
19
+ Args:
20
+ token: The token to migrate.
21
+
22
+ Returns:
23
+ TokenFile: The migrated token.
24
+
25
+ Raises:
26
+ InvalidTokenError: If the token is invalid.
27
+ UnsupportedTokenVersionError: If the token version is not supported.
28
+ """
29
+ # use a naive approach to compare versions for now
30
+ version = float(token.version)
31
+
32
+ # already migrated
33
+ if version >= 1.1:
34
+ return token
35
+
36
+ if version == 1.0:
37
+ res = RestHelper.handle_get("/api/whoami", return_response=True, allow_status_codes=[401, 400, 403], access_token=token.token)
38
+ if res.status_code != 200:
39
+ raise InvalidTokenError(f"Token {token.name} is invalid")
40
+
41
+ email = res.json()["email"]
42
+ return TokenFile(
43
+ version="1.1",
44
+ type=token.type,
45
+ name=token.name,
46
+ token=token.token,
47
+ created=token.created,
48
+ expires=token.expires,
49
+ account=TokenFileAccount(email=email),
50
+ )
51
+
52
+ raise UnsupportedTokenVersionError(f"Unsupported token version: {token.version}")