supa.cc 0.3.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.
- supa_cc/__init__.py +2 -0
- supa_cc/__main__.py +188 -0
- supa_cc/account_store.py +284 -0
- supa_cc/accounts.py +256 -0
- supa_cc/auth.py +400 -0
- supa_cc/credentials.py +246 -0
- supa_cc/diagnostic_collectors.py +593 -0
- supa_cc/diagnostic_renderers.py +95 -0
- supa_cc/diagnostics.py +9 -0
- supa_cc/environment.py +117 -0
- supa_cc/file_lock.py +130 -0
- supa_cc/installation.py +64 -0
- supa_cc/models.py +18 -0
- supa_cc/native_session.py +260 -0
- supa_cc/process.py +176 -0
- supa_cc/state.py +141 -0
- supa_cc/strings.py +67 -0
- supa_cc/supabase_cli.py +380 -0
- supa_cc/transactions.py +619 -0
- supa_cc/tui.py +7 -0
- supa_cc/ui/__init__.py +5 -0
- supa_cc/ui/animations.py +30 -0
- supa_cc/ui/app.py +38 -0
- supa_cc/ui/console.py +4 -0
- supa_cc/ui/layout.py +51 -0
- supa_cc/ui/navigation.py +42 -0
- supa_cc/ui/render.py +67 -0
- supa_cc/ui/screens.py +225 -0
- supa_cc/ui/state.py +51 -0
- supa_cc/ui/theme.py +68 -0
- supa_cc-0.3.0.dist-info/METADATA +113 -0
- supa_cc-0.3.0.dist-info/RECORD +35 -0
- supa_cc-0.3.0.dist-info/WHEEL +4 -0
- supa_cc-0.3.0.dist-info/entry_points.txt +2 -0
- supa_cc-0.3.0.dist-info/licenses/LICENSE +21 -0
supa_cc/__init__.py
ADDED
supa_cc/__main__.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import click
|
|
3
|
+
import supa_cc
|
|
4
|
+
from .auth import classify_local_failure, normalize_exit_code
|
|
5
|
+
from .environment import detect_environment
|
|
6
|
+
from .installation import installation_guidance
|
|
7
|
+
from .tui import run as run_tui
|
|
8
|
+
from .strings import CLIStrings as Textos
|
|
9
|
+
|
|
10
|
+
def _exit_with_local_failure(error):
|
|
11
|
+
result = classify_local_failure(error)
|
|
12
|
+
click.echo(Textos.MSG_ERROR.format(result.message), err=True)
|
|
13
|
+
raise click.exceptions.Exit(normalize_exit_code(result.exit_code) or 1)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_for_updates():
|
|
17
|
+
"""Verifica se há uma versão mais recente disponível."""
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
update_hint = installation_guidance(detect_environment()).update_hint
|
|
21
|
+
pkg_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
git_dir = os.path.join(pkg_dir, ".git")
|
|
23
|
+
|
|
24
|
+
if not os.path.isdir(git_dir):
|
|
25
|
+
return f"Para verificar atualizações, clone o repositório ou execute: {update_hint}"
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
local = subprocess.run(
|
|
29
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
30
|
+
capture_output=True, text=True, check=True, timeout=5, cwd=pkg_dir,
|
|
31
|
+
)
|
|
32
|
+
remote = subprocess.run(
|
|
33
|
+
["git", "ls-remote", "origin", "HEAD"],
|
|
34
|
+
capture_output=True, text=True, check=True, timeout=5, cwd=pkg_dir,
|
|
35
|
+
)
|
|
36
|
+
local_hash = local.stdout.strip()
|
|
37
|
+
remote_hash = remote.stdout.split()[0][:7] if remote.stdout else local_hash
|
|
38
|
+
if remote_hash != local_hash:
|
|
39
|
+
return f"Nova versão disponível! (local: {local_hash} → remoto: {remote_hash})"
|
|
40
|
+
return "Você está na versão mais recente."
|
|
41
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
42
|
+
return f"Não foi possível verificar atualizações. Verifique sua conexão ou execute: {update_hint}"
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
return f"Git não encontrado. Para atualizar, execute: {update_hint}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group(invoke_without_command=True)
|
|
48
|
+
@click.version_option(version=supa_cc.__version__, prog_name="supa.cc")
|
|
49
|
+
@click.pass_context
|
|
50
|
+
def main(ctx):
|
|
51
|
+
"""Gerenciador de Contas Supabase"""
|
|
52
|
+
if ctx.invoked_subcommand is None:
|
|
53
|
+
try:
|
|
54
|
+
exit_code = run_tui()
|
|
55
|
+
except Exception as error:
|
|
56
|
+
_exit_with_local_failure(error)
|
|
57
|
+
if exit_code:
|
|
58
|
+
ctx.exit(normalize_exit_code(exit_code) or 1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@main.command()
|
|
62
|
+
def version():
|
|
63
|
+
"""Mostra a versão e verifica atualizações."""
|
|
64
|
+
click.echo(f"Supa.cc v{supa_cc.__version__}")
|
|
65
|
+
click.echo(_check_for_updates())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@main.command()
|
|
69
|
+
@click.argument("name")
|
|
70
|
+
def add(name):
|
|
71
|
+
"""Adicionar nova conta."""
|
|
72
|
+
from .accounts import AccountManager
|
|
73
|
+
|
|
74
|
+
token = click.prompt(
|
|
75
|
+
Textos.PROMPT_ACCESS_TOKEN,
|
|
76
|
+
hide_input=True,
|
|
77
|
+
confirmation_prompt=False,
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
manager = AccountManager()
|
|
81
|
+
manager.add(name, token)
|
|
82
|
+
click.echo(Textos.MSG_ACCOUNT_ADDED.format(name))
|
|
83
|
+
except Exception as error:
|
|
84
|
+
_exit_with_local_failure(error)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@main.command("list")
|
|
88
|
+
def list_accounts_command():
|
|
89
|
+
"""Listar contas cadastradas."""
|
|
90
|
+
from .accounts import AccountManager
|
|
91
|
+
try:
|
|
92
|
+
manager = AccountManager()
|
|
93
|
+
accounts = manager.list()
|
|
94
|
+
except Exception as error:
|
|
95
|
+
_exit_with_local_failure(error)
|
|
96
|
+
if not accounts:
|
|
97
|
+
click.echo(Textos.MSG_NO_ACCOUNTS)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
for account in accounts:
|
|
101
|
+
click.echo(f" {account.name}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@main.command()
|
|
105
|
+
@click.argument("name")
|
|
106
|
+
def switch(name):
|
|
107
|
+
"""Alternar conta ativa."""
|
|
108
|
+
from .accounts import AccountManager
|
|
109
|
+
try:
|
|
110
|
+
manager = AccountManager()
|
|
111
|
+
result = manager.set_active(name)
|
|
112
|
+
except Exception as error:
|
|
113
|
+
_exit_with_local_failure(error)
|
|
114
|
+
if result.ok:
|
|
115
|
+
click.echo(result.message)
|
|
116
|
+
return
|
|
117
|
+
click.echo(result.message, err=True)
|
|
118
|
+
raise click.exceptions.Exit(normalize_exit_code(result.exit_code) or 1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@main.command(
|
|
122
|
+
context_settings={
|
|
123
|
+
"ignore_unknown_options": True,
|
|
124
|
+
"allow_extra_args": True,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
@click.argument("arguments", nargs=-1, required=True, type=click.UNPROCESSED)
|
|
128
|
+
def run(arguments):
|
|
129
|
+
"""Executar a Supabase CLI com a conta ativa, usando PAT somente no ambiente."""
|
|
130
|
+
from .accounts import AccountManager
|
|
131
|
+
|
|
132
|
+
stdout = click.get_text_stream("stdout")
|
|
133
|
+
stderr = click.get_text_stream("stderr")
|
|
134
|
+
|
|
135
|
+
def stdout_sink(text):
|
|
136
|
+
stdout.write(text)
|
|
137
|
+
stdout.flush()
|
|
138
|
+
|
|
139
|
+
def stderr_sink(text):
|
|
140
|
+
stderr.write(text)
|
|
141
|
+
stderr.flush()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
result = AccountManager().run_active(
|
|
145
|
+
[argument for argument in arguments],
|
|
146
|
+
stdout_sink=stdout_sink,
|
|
147
|
+
stderr_sink=stderr_sink,
|
|
148
|
+
)
|
|
149
|
+
except Exception as error:
|
|
150
|
+
_exit_with_local_failure(error)
|
|
151
|
+
if not result.ok:
|
|
152
|
+
click.echo(result.message, err=True)
|
|
153
|
+
raise click.exceptions.Exit(normalize_exit_code(result.exit_code) or 1)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@main.command()
|
|
157
|
+
@click.option("--account", type=str, default=None)
|
|
158
|
+
@click.option("--live", is_flag=True, default=False)
|
|
159
|
+
@click.option("--json", "as_json", is_flag=True, default=False)
|
|
160
|
+
def doctor(account, live, as_json):
|
|
161
|
+
"""Diagnosticar executáveis, índice, ambiente e autenticação opcional."""
|
|
162
|
+
from .diagnostics import DiagnosticService
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
report = DiagnosticService().run(account=account, live=live)
|
|
166
|
+
except Exception as error:
|
|
167
|
+
_exit_with_local_failure(error)
|
|
168
|
+
click.echo(report.to_json() if as_json else report.to_human())
|
|
169
|
+
if not report.ok:
|
|
170
|
+
raise click.exceptions.Exit(normalize_exit_code(report.exit_code) or 1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@main.command()
|
|
174
|
+
@click.argument("name")
|
|
175
|
+
@click.confirmation_option(prompt=Textos.MSG_CONFIRM_REMOVE)
|
|
176
|
+
def remove(name):
|
|
177
|
+
"""Remover conta cadastrada."""
|
|
178
|
+
from .accounts import AccountManager
|
|
179
|
+
try:
|
|
180
|
+
manager = AccountManager()
|
|
181
|
+
manager.remove(name)
|
|
182
|
+
except Exception as error:
|
|
183
|
+
_exit_with_local_failure(error)
|
|
184
|
+
click.echo(Textos.MSG_ACCOUNT_REMOVED.format(name))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|
supa_cc/account_store.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterator, List, Optional
|
|
8
|
+
|
|
9
|
+
from .auth import (
|
|
10
|
+
AccountIndexInvalidError,
|
|
11
|
+
AccountIndexReadError,
|
|
12
|
+
AccountTransactionError,
|
|
13
|
+
is_valid_account_name,
|
|
14
|
+
)
|
|
15
|
+
from .credentials import CredentialStore, create_credential_store
|
|
16
|
+
from .environment import Environment, detect_environment
|
|
17
|
+
from .file_lock import acquire_file_lock, release_file_lock, validate_lock_file
|
|
18
|
+
from .models import Account, AccountSummary
|
|
19
|
+
from .state import atomic_write_text, read_text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
KEYCHAIN_SERVICE = "supa.cc.supabase.accounts.v2"
|
|
23
|
+
_INDEX_MAX_BYTES = 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_windows() -> bool:
|
|
27
|
+
return os.name == "nt"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validated_unique_names(names: List[str]) -> List[str]:
|
|
31
|
+
unique_names = []
|
|
32
|
+
seen = set()
|
|
33
|
+
for name in names:
|
|
34
|
+
if not is_valid_account_name(name):
|
|
35
|
+
raise AccountIndexInvalidError(
|
|
36
|
+
"O índice local de contas contém um nome inválido."
|
|
37
|
+
)
|
|
38
|
+
if name not in seen:
|
|
39
|
+
seen.add(name)
|
|
40
|
+
unique_names.append(name)
|
|
41
|
+
return unique_names
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def safe_load_json_index(path: Path) -> Optional[List[str]]:
|
|
45
|
+
"""Lê o índice; None significa exclusivamente que ele não existe."""
|
|
46
|
+
try:
|
|
47
|
+
contents = read_text(path, _INDEX_MAX_BYTES)
|
|
48
|
+
except OSError:
|
|
49
|
+
raise AccountIndexReadError(
|
|
50
|
+
"Não foi possível ler o índice local de contas."
|
|
51
|
+
) from None
|
|
52
|
+
if contents is None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(contents)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
raise AccountIndexInvalidError(
|
|
59
|
+
"O índice local de contas é inválido."
|
|
60
|
+
) from None
|
|
61
|
+
|
|
62
|
+
if not isinstance(data, dict) or not isinstance(data.get("accounts"), list):
|
|
63
|
+
raise AccountIndexInvalidError(
|
|
64
|
+
"O índice local de contas é inválido."
|
|
65
|
+
)
|
|
66
|
+
return _validated_unique_names(data["accounts"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AccountStore:
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
index_path: Optional[Path] = None,
|
|
73
|
+
service: str = KEYCHAIN_SERVICE,
|
|
74
|
+
credential_store: Optional[CredentialStore] = None,
|
|
75
|
+
environment: Optional[Environment] = None,
|
|
76
|
+
):
|
|
77
|
+
environment = environment if environment is not None else detect_environment()
|
|
78
|
+
self.index_path = (
|
|
79
|
+
Path(index_path)
|
|
80
|
+
if index_path is not None
|
|
81
|
+
else environment.config_directory() / "accounts.json"
|
|
82
|
+
)
|
|
83
|
+
self.credential_store = (
|
|
84
|
+
credential_store
|
|
85
|
+
if credential_store is not None
|
|
86
|
+
else create_credential_store(environment, service=service)
|
|
87
|
+
)
|
|
88
|
+
stored_service = getattr(self.credential_store, "service", None)
|
|
89
|
+
self.service = stored_service if isinstance(stored_service, str) else service
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def index_lock_path(self) -> Path:
|
|
93
|
+
return self.index_path.with_name(f".{self.index_path.name}.lock")
|
|
94
|
+
|
|
95
|
+
def _ensure_index_parent(self) -> None:
|
|
96
|
+
self.index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
if not _is_windows():
|
|
98
|
+
self.index_path.parent.chmod(0o700)
|
|
99
|
+
|
|
100
|
+
@contextmanager
|
|
101
|
+
def _index_lock(self) -> Iterator[None]:
|
|
102
|
+
self._ensure_index_parent()
|
|
103
|
+
flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)
|
|
104
|
+
descriptor = os.open(self.index_lock_path, flags, 0o600)
|
|
105
|
+
locked = False
|
|
106
|
+
try:
|
|
107
|
+
if not _is_windows():
|
|
108
|
+
os.fchmod(descriptor, 0o600)
|
|
109
|
+
validate_lock_file(descriptor, self.index_lock_path)
|
|
110
|
+
acquire_file_lock(descriptor)
|
|
111
|
+
locked = True
|
|
112
|
+
validate_lock_file(descriptor, self.index_lock_path)
|
|
113
|
+
yield
|
|
114
|
+
finally:
|
|
115
|
+
if locked:
|
|
116
|
+
release_file_lock(descriptor)
|
|
117
|
+
os.close(descriptor)
|
|
118
|
+
|
|
119
|
+
def _read_index_locked(self) -> List[str]:
|
|
120
|
+
names = safe_load_json_index(self.index_path)
|
|
121
|
+
return [] if names is None else names
|
|
122
|
+
|
|
123
|
+
def _write_index_locked(self, names: List[str]) -> None:
|
|
124
|
+
unique_names = _validated_unique_names(names)
|
|
125
|
+
self._ensure_index_parent()
|
|
126
|
+
data = json.dumps({"accounts": unique_names}, indent=2)
|
|
127
|
+
atomic_write_text(self.index_path, data)
|
|
128
|
+
|
|
129
|
+
def _read_token_uncached(self, name: str) -> Optional[str]:
|
|
130
|
+
return self.credential_store.get(name)
|
|
131
|
+
|
|
132
|
+
def _write_token_verified(self, account: Account) -> None:
|
|
133
|
+
self.credential_store.set(account)
|
|
134
|
+
|
|
135
|
+
def _delete_token(self, name: str) -> None:
|
|
136
|
+
self.credential_store.delete(name)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _backup_name(name: str) -> str:
|
|
140
|
+
digest = hashlib.sha256(name.encode("utf-8")).hexdigest()
|
|
141
|
+
return f"!supa.cc-backup!{digest}"
|
|
142
|
+
|
|
143
|
+
def _backup_failure(self) -> AccountTransactionError:
|
|
144
|
+
return AccountTransactionError(
|
|
145
|
+
"A credencial temporária da transação não pôde ser confirmada com segurança."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def create_account_backup(self, name: str) -> None:
|
|
149
|
+
"""Copia a credencial para armazenamento seguro fora do índice público."""
|
|
150
|
+
backup_name = self._backup_name(name)
|
|
151
|
+
try:
|
|
152
|
+
token = self._read_token_uncached(name)
|
|
153
|
+
if token is None:
|
|
154
|
+
raise self._backup_failure()
|
|
155
|
+
self._write_token_verified(Account(name=backup_name, token=token))
|
|
156
|
+
if self._read_token_uncached(backup_name) != token:
|
|
157
|
+
raise self._backup_failure()
|
|
158
|
+
except AccountTransactionError:
|
|
159
|
+
raise
|
|
160
|
+
except Exception:
|
|
161
|
+
raise self._backup_failure() from None
|
|
162
|
+
|
|
163
|
+
def read_account_backup(self, name: str) -> Optional[Account]:
|
|
164
|
+
try:
|
|
165
|
+
token = self._read_token_uncached(self._backup_name(name))
|
|
166
|
+
except Exception:
|
|
167
|
+
raise self._backup_failure() from None
|
|
168
|
+
return Account(name=name, token=token) if token is not None else None
|
|
169
|
+
|
|
170
|
+
def restore_account_backup(self, name: str) -> None:
|
|
171
|
+
backup = self.read_account_backup(name)
|
|
172
|
+
if backup is None:
|
|
173
|
+
raise self._backup_failure()
|
|
174
|
+
try:
|
|
175
|
+
self._write_token_verified(backup)
|
|
176
|
+
if self._read_token_uncached(name) != backup.token:
|
|
177
|
+
raise self._backup_failure()
|
|
178
|
+
except AccountTransactionError:
|
|
179
|
+
raise
|
|
180
|
+
except Exception:
|
|
181
|
+
raise self._backup_failure() from None
|
|
182
|
+
|
|
183
|
+
def delete_account_backup(self, name: str) -> None:
|
|
184
|
+
backup_name = self._backup_name(name)
|
|
185
|
+
try:
|
|
186
|
+
self._delete_token(backup_name)
|
|
187
|
+
if self._read_token_uncached(backup_name) is not None:
|
|
188
|
+
raise self._backup_failure()
|
|
189
|
+
except AccountTransactionError:
|
|
190
|
+
raise
|
|
191
|
+
except Exception:
|
|
192
|
+
raise self._backup_failure() from None
|
|
193
|
+
|
|
194
|
+
def _restore_token_best_effort(self, name: str, token: Optional[str]) -> bool:
|
|
195
|
+
try:
|
|
196
|
+
if token is None:
|
|
197
|
+
self._delete_token(name)
|
|
198
|
+
else:
|
|
199
|
+
self._write_token_verified(Account(name=name, token=token))
|
|
200
|
+
except Exception:
|
|
201
|
+
return False
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
def _ensure_initialized(self) -> None:
|
|
205
|
+
self._read_index()
|
|
206
|
+
|
|
207
|
+
def save_account(self, account: Account) -> None:
|
|
208
|
+
"""Salva e confirma uma credencial sem alterar nomes do índice."""
|
|
209
|
+
self._ensure_initialized()
|
|
210
|
+
self._write_token_verified(account)
|
|
211
|
+
|
|
212
|
+
def get_account(self, name: str) -> Optional[Account]:
|
|
213
|
+
"""Recupera uma credencial diretamente do armazenamento autoritativo."""
|
|
214
|
+
token = self._read_token_uncached(name)
|
|
215
|
+
if token:
|
|
216
|
+
return Account(name=name, token=token)
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
def list_accounts(self) -> List[AccountSummary]:
|
|
220
|
+
"""Lista nomes sem recuperar tokens do Keychain."""
|
|
221
|
+
return [AccountSummary(name=name) for name in self._read_index()]
|
|
222
|
+
|
|
223
|
+
def delete_account(self, name: str) -> None:
|
|
224
|
+
"""Remove uma credencial sem alterar nomes do índice."""
|
|
225
|
+
self._ensure_initialized()
|
|
226
|
+
self._delete_token(name)
|
|
227
|
+
|
|
228
|
+
def update_index(self, names: List[str]) -> None:
|
|
229
|
+
"""Substitui o índice sob lock, sem sobrescrever conteúdo inválido."""
|
|
230
|
+
unique_names = _validated_unique_names(names)
|
|
231
|
+
with self._index_lock():
|
|
232
|
+
safe_load_json_index(self.index_path)
|
|
233
|
+
self._write_index_locked(unique_names)
|
|
234
|
+
|
|
235
|
+
def add_account(self, account: Account) -> None:
|
|
236
|
+
"""Grava credencial e nome como uma transação lógica serializada."""
|
|
237
|
+
with self._index_lock():
|
|
238
|
+
names = self._read_index_locked()
|
|
239
|
+
previous_token = self._read_token_uncached(account.name)
|
|
240
|
+
try:
|
|
241
|
+
self._write_token_verified(account)
|
|
242
|
+
if account.name not in names:
|
|
243
|
+
names.append(account.name)
|
|
244
|
+
self._write_index_locked(names)
|
|
245
|
+
except Exception:
|
|
246
|
+
if not self._restore_token_best_effort(
|
|
247
|
+
account.name,
|
|
248
|
+
previous_token,
|
|
249
|
+
):
|
|
250
|
+
raise AccountTransactionError(
|
|
251
|
+
"A operação falhou e não pôde ser revertida com segurança."
|
|
252
|
+
) from None
|
|
253
|
+
raise
|
|
254
|
+
|
|
255
|
+
def remove_account(self, name: str) -> None:
|
|
256
|
+
"""Remove credencial e nome com rollback best-effort sob lock."""
|
|
257
|
+
with self._index_lock():
|
|
258
|
+
names = self._read_index_locked()
|
|
259
|
+
previous_token = self._read_token_uncached(name)
|
|
260
|
+
try:
|
|
261
|
+
self._delete_token(name)
|
|
262
|
+
names = [existing for existing in names if existing != name]
|
|
263
|
+
self._write_index_locked(names)
|
|
264
|
+
except Exception:
|
|
265
|
+
if (
|
|
266
|
+
previous_token is not None
|
|
267
|
+
and not self._restore_token_best_effort(name, previous_token)
|
|
268
|
+
):
|
|
269
|
+
raise AccountTransactionError(
|
|
270
|
+
"A operação falhou e não pôde ser revertida com segurança."
|
|
271
|
+
) from None
|
|
272
|
+
raise
|
|
273
|
+
|
|
274
|
+
def _read_index(self) -> List[str]:
|
|
275
|
+
names = safe_load_json_index(self.index_path)
|
|
276
|
+
if names is not None:
|
|
277
|
+
return names
|
|
278
|
+
|
|
279
|
+
with self._index_lock():
|
|
280
|
+
names = safe_load_json_index(self.index_path)
|
|
281
|
+
if names is None:
|
|
282
|
+
names = []
|
|
283
|
+
self._write_index_locked(names)
|
|
284
|
+
return names
|