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 ADDED
@@ -0,0 +1,2 @@
1
+ """Supa.cc - CLI para gerenciamento de contas Supabase."""
2
+ __version__ = "0.3.0"
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()
@@ -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