powerbase-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from ..session import SessionError
7
+ from ..transport import ApiError
8
+ from .agent import register_agent_commands
9
+ from .auth import register_auth_commands
10
+ from .branch import register_branch_commands
11
+ from .config_cmd import register_config_commands
12
+ from .context import register_context_commands
13
+ from .database import register_database_commands
14
+ from .instance import register_instance_commands
15
+ from .org import register_org_commands
16
+ from .publish import register_publish_commands
17
+ from .sandbox import register_sandbox_commands
18
+ from .sql import register_sql_commands
19
+
20
+ GLOBAL_OPTION_ARITY = {
21
+ "--config-dir": 1,
22
+ "--base-url": 1,
23
+ "--anon-key": 1,
24
+ "--ca-cert": 1,
25
+ "--insecure": 0,
26
+ "--json": 0,
27
+ }
28
+
29
+
30
+ def normalize_global_argv(argv: list[str] | None) -> list[str] | None:
31
+ if argv is None:
32
+ return None
33
+
34
+ global_args: list[str] = []
35
+ remaining_args: list[str] = []
36
+ i = 0
37
+ while i < len(argv):
38
+ token = argv[i]
39
+ arity = GLOBAL_OPTION_ARITY.get(token)
40
+ if arity is None:
41
+ remaining_args.append(token)
42
+ i += 1
43
+ continue
44
+
45
+ if arity == 1:
46
+ if i + 1 >= len(argv):
47
+ remaining_args.append(token)
48
+ i += 1
49
+ continue
50
+ global_args.append(token)
51
+ global_args.append(argv[i + 1])
52
+ i += 2
53
+ continue
54
+ global_args.append(token)
55
+ i += 1
56
+
57
+ return global_args + remaining_args
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ parser = argparse.ArgumentParser(
62
+ prog="powerbase",
63
+ description=(
64
+ "Operate Powerbase console workflows from the command line. "
65
+ "Default instance creation uses the managed Powerbase database flow; "
66
+ "`powerbase database ...` is the advanced bring-your-own-database path."
67
+ ),
68
+ epilog=(
69
+ "Important: values are resolved in this order: command flags, environment variables, "
70
+ "~/.config/powerbase files, then defaults."
71
+ ),
72
+ )
73
+ parser.add_argument("--config-dir", help="Override the config directory. Defaults to ~/.config/powerbase.")
74
+ parser.add_argument("--base-url", help="Console base URL. Overrides the built-in default console endpoint.")
75
+ parser.add_argument("--anon-key", help="Supabase anon key used for functions and auth requests. Overrides the built-in default.")
76
+ parser.add_argument(
77
+ "--ca-cert",
78
+ dest="ca_cert_file",
79
+ help="Path to a CA certificate PEM file to trust for HTTPS requests.",
80
+ )
81
+ parser.add_argument(
82
+ "--insecure",
83
+ action="store_true",
84
+ help="Disable TLS certificate verification for HTTPS requests. Use only for testing with self-signed certs.",
85
+ )
86
+ parser.add_argument("--json", action="store_true", help="Print JSON output.")
87
+
88
+ subparsers = parser.add_subparsers(dest="command")
89
+ register_auth_commands(subparsers)
90
+ register_config_commands(subparsers)
91
+ register_context_commands(subparsers)
92
+ register_org_commands(subparsers)
93
+ register_database_commands(subparsers)
94
+ register_instance_commands(subparsers)
95
+ register_branch_commands(subparsers)
96
+ register_sql_commands(subparsers)
97
+ register_publish_commands(subparsers)
98
+ register_sandbox_commands(subparsers)
99
+ register_agent_commands(subparsers)
100
+ return parser
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ parser = build_parser()
105
+ raw_argv = sys.argv[1:] if argv is None else argv
106
+ args = parser.parse_args(normalize_global_argv(raw_argv))
107
+ if not hasattr(args, "handler"):
108
+ parser.print_help()
109
+ return 0
110
+ try:
111
+ return int(args.handler(args) or 0)
112
+ except (ApiError, SessionError, RuntimeError) as exc:
113
+ print(f"Error: {exc}", file=sys.stderr)
114
+ return 1
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from .shared import build_api, render_output, require_instance
6
+
7
+
8
+ def handle_publish_diff(args: argparse.Namespace) -> int:
9
+ _, _, context, _, api = build_api(args)
10
+ instance_id = require_instance(args.instance_id, context)
11
+ render_output(args, api.publish_diff(instance_id, args.target))
12
+ return 0
13
+
14
+
15
+ def handle_publish_run(args: argparse.Namespace) -> int:
16
+ _, _, context, _, api = build_api(args)
17
+ instance_id = require_instance(args.instance_id, context)
18
+ sql = args.sql
19
+ if not sql:
20
+ diff = api.publish_diff(instance_id, args.target)
21
+ sql = diff.get("sql") or ""
22
+ events = list(api.publish_run(instance_id, sql, args.target))
23
+ render_output(args, {"events": events})
24
+ return 0
25
+
26
+
27
+ def register_publish_commands(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
28
+ publish = subparsers.add_parser("publish", help="Publish commands.", description="Generate diffs and publish an instance.")
29
+ publish_sub = publish.add_subparsers(dest="publish_command")
30
+ p = publish_sub.add_parser("diff", help="Show publish diff.", description="Generate publish diff for the current instance.")
31
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
32
+ p.add_argument("--target", default="prod", help="Publish target. Default: prod")
33
+ p.set_defaults(handler=handle_publish_diff)
34
+ p = publish_sub.add_parser(
35
+ "run",
36
+ help="Run publish flow.",
37
+ description=(
38
+ "Publish the current instance. If --sql is omitted, the command first runs publish diff and uses its SQL."
39
+ ),
40
+ )
41
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
42
+ p.add_argument("--target", default="prod", help="Publish target. Default: prod")
43
+ p.add_argument("--sql", help="Optional SQL to execute directly instead of deriving it from diff.")
44
+ p.set_defaults(handler=handle_publish_run)
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from .shared import build_api, render_output, require_instance
6
+
7
+
8
+ def handle_files_list(args: argparse.Namespace) -> int:
9
+ _, _, context, _, api = build_api(args)
10
+ render_output(args, api.sandbox_files_list(require_instance(args.instance_id, context), args.path, args.include_hidden))
11
+ return 0
12
+
13
+
14
+ def handle_files_tree(args: argparse.Namespace) -> int:
15
+ _, _, context, _, api = build_api(args)
16
+ render_output(args, api.sandbox_files_tree(require_instance(args.instance_id, context), args.root, args.max_depth))
17
+ return 0
18
+
19
+
20
+ def handle_files_read(args: argparse.Namespace) -> int:
21
+ _, _, context, _, api = build_api(args)
22
+ render_output(args, api.sandbox_files_read(require_instance(args.instance_id, context), args.path))
23
+ return 0
24
+
25
+
26
+ def handle_files_create_file(args: argparse.Namespace) -> int:
27
+ _, _, context, _, api = build_api(args)
28
+ render_output(args, api.sandbox_files_create_file(require_instance(args.instance_id, context), args.path, args.content or ""))
29
+ return 0
30
+
31
+
32
+ def handle_files_create_folder(args: argparse.Namespace) -> int:
33
+ _, _, context, _, api = build_api(args)
34
+ render_output(args, api.sandbox_files_create_folder(require_instance(args.instance_id, context), args.path))
35
+ return 0
36
+
37
+
38
+ def handle_files_upload(args: argparse.Namespace) -> int:
39
+ _, _, context, _, api = build_api(args)
40
+ render_output(args, api.sandbox_files_upload(require_instance(args.instance_id, context), args.source, args.target_path))
41
+ return 0
42
+
43
+
44
+ def handle_files_delete(args: argparse.Namespace) -> int:
45
+ _, _, context, _, api = build_api(args)
46
+ render_output(args, api.sandbox_files_delete(require_instance(args.instance_id, context), args.path))
47
+ return 0
48
+
49
+
50
+ def register_sandbox_commands(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
51
+ sandbox = subparsers.add_parser("sandbox", help="Sandbox commands.", description="Read or modify sandbox files.")
52
+ sandbox_sub = sandbox.add_subparsers(dest="sandbox_command")
53
+ files = sandbox_sub.add_parser("files", help="Sandbox file commands.", description="Read or modify sandbox files.")
54
+ files_sub = files.add_subparsers(dest="files_command")
55
+ p = files_sub.add_parser("list", help="List a directory.", description="List sandbox files at one path.")
56
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
57
+ p.add_argument("--path", default="/", help="Sandbox path, such as /src")
58
+ p.add_argument("--include-hidden", action="store_true", help="Include hidden files.")
59
+ p.set_defaults(handler=handle_files_list)
60
+ p = files_sub.add_parser("tree", help="Show file tree.", description="Show a sandbox directory tree.")
61
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
62
+ p.add_argument("--root", default="/", help="Sandbox root path.")
63
+ p.add_argument("--max-depth", type=int, default=3, help="Maximum depth to fetch. Default: 3")
64
+ p.set_defaults(handler=handle_files_tree)
65
+ p = files_sub.add_parser("read", help="Read one file.", description="Read one sandbox file path.")
66
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
67
+ p.add_argument("path", help="Sandbox file path, such as /src/app.tsx")
68
+ p.set_defaults(handler=handle_files_read)
69
+ p = files_sub.add_parser("create-file", help="Create a file.", description="Create a sandbox file at one path.")
70
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
71
+ p.add_argument("path")
72
+ p.add_argument("--content", default="", help="Initial file content.")
73
+ p.set_defaults(handler=handle_files_create_file)
74
+ p = files_sub.add_parser("create-folder", help="Create a folder.", description="Create a sandbox folder at one path.")
75
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
76
+ p.add_argument("path")
77
+ p.set_defaults(handler=handle_files_create_folder)
78
+ p = files_sub.add_parser(
79
+ "upload",
80
+ help="Upload a local file.",
81
+ description="Upload a local file into the sandbox. target-path is the destination directory.",
82
+ )
83
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
84
+ p.add_argument("source", help="Local source file path.")
85
+ p.add_argument("--target-path", default="/", help="Sandbox destination directory.")
86
+ p.set_defaults(handler=handle_files_upload)
87
+ p = files_sub.add_parser("delete", help="Delete a sandbox path.", description="Delete a sandbox file or folder.")
88
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
89
+ p.add_argument("path")
90
+ p.set_defaults(handler=handle_files_delete)
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from dataclasses import asdict
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from ..api import PowerbaseApi
10
+ from ..config import (
11
+ BUNDLED_CA_CERT_SENTINEL,
12
+ DEFAULT_ANON_KEY,
13
+ DEFAULT_BASE_URL,
14
+ AppConfig,
15
+ AuthSession,
16
+ AuthState,
17
+ ConfigStore,
18
+ ContextState,
19
+ load_bundled_ca_cert,
20
+ merge_config_with_env,
21
+ merge_context_with_env,
22
+ )
23
+ from ..session import SessionManager
24
+ from ..transport import PowerbaseTransport
25
+
26
+
27
+ def now_iso() -> str:
28
+ return datetime.now(timezone.utc).isoformat()
29
+
30
+
31
+ def build_store(args: argparse.Namespace) -> ConfigStore:
32
+ return ConfigStore(base_dir=args.config_dir)
33
+
34
+
35
+ def resolve_config(args: argparse.Namespace, store: ConfigStore) -> AppConfig:
36
+ config = merge_config_with_env(store.load_config())
37
+ saved_auth = store.load_auth()
38
+ explicit_ca_cert_file = getattr(args, "ca_cert_file", None) or config.ca_cert_file
39
+ return AppConfig(
40
+ base_url=args.base_url or config.base_url or (saved_auth.base_url if saved_auth else None) or DEFAULT_BASE_URL,
41
+ anon_key=args.anon_key or config.anon_key or (saved_auth.anon_key if saved_auth else None) or DEFAULT_ANON_KEY,
42
+ output="json" if args.json else config.output,
43
+ tls_insecure=bool(getattr(args, "insecure", False) or config.tls_insecure),
44
+ # Keep explicit CA settings first. The bundled test CA is only the final fallback for the
45
+ # current self-signed test environment and should be removed once the deployment uses a
46
+ # publicly trusted certificate.
47
+ ca_cert_file=explicit_ca_cert_file or (BUNDLED_CA_CERT_SENTINEL if load_bundled_ca_cert() else None),
48
+ )
49
+
50
+
51
+ def resolve_context(store: ConfigStore) -> ContextState:
52
+ return merge_context_with_env(store.load_context())
53
+
54
+
55
+ def build_api(args: argparse.Namespace) -> tuple[ConfigStore, AppConfig, ContextState, SessionManager, PowerbaseApi]:
56
+ store = build_store(args)
57
+ config = resolve_config(args, store)
58
+ context = resolve_context(store)
59
+ session_manager = SessionManager(
60
+ store,
61
+ config.base_url,
62
+ config.anon_key,
63
+ tls_insecure=config.tls_insecure,
64
+ ca_cert_file=config.ca_cert_file,
65
+ )
66
+ api = PowerbaseApi(PowerbaseTransport(config, session_manager))
67
+ return store, config, context, session_manager, api
68
+
69
+
70
+ def render_output(args: argparse.Namespace, data: Any) -> None:
71
+ if isinstance(data, str):
72
+ print(data)
73
+ return
74
+ print(json.dumps(data, indent=2, ensure_ascii=False))
75
+
76
+
77
+ def require_instance(instance_id: str | None, context: ContextState) -> str:
78
+ resolved = instance_id or context.instance_id
79
+ if not resolved:
80
+ raise RuntimeError(
81
+ "instance_id is required. Pass --instance-id or set one with `powerbase context use-instance`."
82
+ )
83
+ return resolved
84
+
85
+
86
+ def resolve_branch(branch: str | None, context: ContextState) -> str | None:
87
+ return branch or context.branch
88
+
89
+
90
+ def load_sql(sql: str | None, sql_file: str | None) -> str:
91
+ if sql_file:
92
+ return open(sql_file, "r", encoding="utf-8").read()
93
+ if not sql:
94
+ raise RuntimeError("SQL is required. Pass --sql or --sql-file.")
95
+ if sql.startswith("@"):
96
+ return open(sql[1:], "r", encoding="utf-8").read()
97
+ return sql
98
+
99
+
100
+ def load_text(value: str | None, file_path: str | None, *, required_message: str) -> str:
101
+ if file_path:
102
+ return open(file_path, "r", encoding="utf-8").read()
103
+ if value:
104
+ if value.startswith("@"):
105
+ return open(value[1:], "r", encoding="utf-8").read()
106
+ return value
107
+ raise RuntimeError(required_message)
108
+
109
+
110
+ def load_json_document(file_path: str) -> dict[str, Any]:
111
+ with open(file_path, "r", encoding="utf-8") as fh:
112
+ data = json.load(fh)
113
+ if not isinstance(data, dict):
114
+ raise RuntimeError("JSON document must be an object.")
115
+ return data
116
+
117
+
118
+ def parse_json_specs(raw_values: list[str] | None, *, label: str) -> list[dict[str, Any]]:
119
+ parsed: list[dict[str, Any]] = []
120
+ for raw in raw_values or []:
121
+ try:
122
+ item = json.loads(raw)
123
+ except json.JSONDecodeError as exc:
124
+ raise RuntimeError(f"Invalid {label} JSON: {raw}") from exc
125
+ if not isinstance(item, dict):
126
+ raise RuntimeError(f"Each {label} must be a JSON object.")
127
+ parsed.append(item)
128
+ return parsed
129
+
130
+
131
+ __all__ = [
132
+ "AppConfig",
133
+ "AuthSession",
134
+ "AuthState",
135
+ "ConfigStore",
136
+ "ContextState",
137
+ "PowerbaseApi",
138
+ "SessionManager",
139
+ "asdict",
140
+ "build_api",
141
+ "build_store",
142
+ "load_json_document",
143
+ "load_sql",
144
+ "load_text",
145
+ "now_iso",
146
+ "parse_json_specs",
147
+ "render_output",
148
+ "require_instance",
149
+ "resolve_branch",
150
+ "resolve_config",
151
+ "resolve_context",
152
+ ]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from .shared import build_api, load_sql, render_output, require_instance, resolve_branch
6
+
7
+
8
+ def handle_sql_run(args: argparse.Namespace) -> int:
9
+ _, _, context, _, api = build_api(args)
10
+ instance_id = require_instance(args.instance_id, context)
11
+ branch = resolve_branch(args.branch_name, context) or "main"
12
+ sql = load_sql(args.sql, args.sql_file)
13
+ render_output(args, api.run_sql(instance_id, sql, branch))
14
+ return 0
15
+
16
+
17
+ def register_sql_commands(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
18
+ sql = subparsers.add_parser("sql", help="Run SQL.", description="Run SQL against an instance branch.")
19
+ sql_sub = sql.add_subparsers(dest="sql_command")
20
+ p = sql_sub.add_parser(
21
+ "run",
22
+ help="Run SQL.",
23
+ description=(
24
+ "Run SQL against the current instance. Pass --sql directly, or use --sql-file. "
25
+ "If you omit --instance-id, the saved context instance_id is used."
26
+ ),
27
+ )
28
+ p.add_argument("--instance-id", help="Instance ID. Falls back to context.json.")
29
+ p.add_argument("--branch", dest="branch_name", help="Branch slug. Falls back to context.json or main.")
30
+ p.add_argument("--sql", help="Inline SQL string. Prefix with @ to load from a file.")
31
+ p.add_argument("--sql-file", help="Path to a SQL file.")
32
+ p.set_defaults(handler=handle_sql_run)
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.resources
4
+ import json
5
+ import os
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ try:
11
+ import tomllib
12
+ except ModuleNotFoundError: # pragma: no cover
13
+ tomllib = None # type: ignore[assignment]
14
+
15
+ DEFAULT_BASE_URL = "https://console.6.12.235.165.nip.io"
16
+ DEFAULT_ANON_KEY = "reallyreallyreallyreallyverysafe"
17
+ # This bundled CA is only for the self-signed test deployment so `pip install powerbase-cli`
18
+ # can work out of the box. Remove it after the deployed console switches to a publicly trusted
19
+ # TLS certificate, then delete the bundled PEM file and its package-data entry as well.
20
+ BUNDLED_CA_CERT_SENTINEL = "<bundled test CA>"
21
+ BUNDLED_CA_CERT_RESOURCE = "certs/powerbase-test-ca.pem"
22
+
23
+
24
+ def default_config_dir() -> Path:
25
+ env_dir = os.environ.get("POWERBASE_CONFIG_DIR")
26
+ if env_dir:
27
+ return Path(env_dir).expanduser()
28
+ return Path.home() / ".config" / "powerbase"
29
+
30
+
31
+ @dataclass
32
+ class AppConfig:
33
+ base_url: str | None = DEFAULT_BASE_URL
34
+ anon_key: str | None = DEFAULT_ANON_KEY
35
+ output: str = "text"
36
+ tls_insecure: bool = False
37
+ ca_cert_file: str | None = None
38
+
39
+
40
+ @dataclass
41
+ class AuthSession:
42
+ access_token: str
43
+ refresh_token: str | None = None
44
+ token_type: str = "bearer"
45
+ expires_at: int | None = None
46
+
47
+
48
+ @dataclass
49
+ class AuthState:
50
+ source: str
51
+ session: AuthSession
52
+ base_url: str | None = None
53
+ anon_key: str | None = None
54
+ user: dict[str, Any] | None = None
55
+ updated_at: str | None = None
56
+
57
+
58
+ @dataclass
59
+ class ContextState:
60
+ instance_id: str | None = None
61
+ org_id: str | None = None
62
+ branch: str | None = None
63
+ updated_at: str | None = None
64
+
65
+
66
+ def _as_path(path: str | Path | None) -> Path:
67
+ if path is None:
68
+ return default_config_dir()
69
+ return Path(path).expanduser()
70
+
71
+
72
+ def env_flag(name: str) -> bool:
73
+ value = os.environ.get(name)
74
+ if value is None:
75
+ return False
76
+ return value.strip().lower() in {"1", "true", "yes", "on"}
77
+
78
+
79
+ def load_bundled_ca_cert() -> str | None:
80
+ # The PEM contents are loaded at runtime so test builds can trust the packaged self-signed CA
81
+ # without asking end users to pass `--ca-cert`. Once the server uses a trusted certificate,
82
+ # this helper and its callers can be removed.
83
+ try:
84
+ resource = importlib.resources.files("powerbase_cli").joinpath(BUNDLED_CA_CERT_RESOURCE)
85
+ if not resource.is_file():
86
+ return None
87
+ return resource.read_text(encoding="utf-8")
88
+ except (FileNotFoundError, ModuleNotFoundError):
89
+ return None
90
+
91
+
92
+ class ConfigStore:
93
+ def __init__(self, base_dir: str | Path | None = None) -> None:
94
+ self.base_dir = _as_path(base_dir)
95
+ self.config_path = self.base_dir / "config.toml"
96
+ self.auth_path = self.base_dir / "auth.json"
97
+ self.context_path = self.base_dir / "context.json"
98
+
99
+ def ensure_base_dir(self) -> None:
100
+ self.base_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ def load_config(self) -> AppConfig:
103
+ if not self.config_path.exists():
104
+ return AppConfig(base_url=None, anon_key=None)
105
+ if tomllib is None:
106
+ raise RuntimeError("tomllib is required to read config.toml")
107
+ data = tomllib.loads(self.config_path.read_text(encoding="utf-8"))
108
+ return AppConfig(
109
+ base_url=data.get("base_url"),
110
+ anon_key=data.get("anon_key"),
111
+ output=data.get("output", "text"),
112
+ tls_insecure=bool(data.get("tls_insecure", False)),
113
+ ca_cert_file=data.get("ca_cert_file"),
114
+ )
115
+
116
+ def save_config(self, config: AppConfig) -> None:
117
+ self.ensure_base_dir()
118
+ lines = []
119
+ if config.base_url and config.base_url != DEFAULT_BASE_URL:
120
+ lines.append(f'base_url = "{config.base_url}"')
121
+ if config.anon_key and config.anon_key != DEFAULT_ANON_KEY:
122
+ lines.append(f'anon_key = "{config.anon_key}"')
123
+ if config.output:
124
+ lines.append(f'output = "{config.output}"')
125
+ if config.tls_insecure:
126
+ lines.append("tls_insecure = true")
127
+ if config.ca_cert_file:
128
+ lines.append(f'ca_cert_file = "{config.ca_cert_file}"')
129
+ self.config_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
130
+
131
+ def load_auth(self) -> AuthState | None:
132
+ if not self.auth_path.exists():
133
+ return None
134
+ data = json.loads(self.auth_path.read_text(encoding="utf-8"))
135
+ session_data = data.get("session") or {
136
+ "access_token": data.get("access_token"),
137
+ "refresh_token": data.get("refresh_token"),
138
+ "token_type": data.get("token_type", "bearer"),
139
+ "expires_at": data.get("expires_at"),
140
+ }
141
+ if not session_data.get("access_token"):
142
+ return None
143
+ return AuthState(
144
+ source=data.get("source", "file"),
145
+ base_url=data.get("base_url"),
146
+ anon_key=data.get("anon_key"),
147
+ session=AuthSession(
148
+ access_token=session_data["access_token"],
149
+ refresh_token=session_data.get("refresh_token"),
150
+ token_type=session_data.get("token_type", "bearer"),
151
+ expires_at=session_data.get("expires_at"),
152
+ ),
153
+ user=data.get("user"),
154
+ updated_at=data.get("updated_at"),
155
+ )
156
+
157
+ def save_auth(self, auth: AuthState) -> None:
158
+ self.ensure_base_dir()
159
+ payload = {
160
+ "version": 1,
161
+ "source": auth.source,
162
+ "base_url": auth.base_url,
163
+ "anon_key": auth.anon_key,
164
+ "session": asdict(auth.session),
165
+ "user": auth.user,
166
+ "updated_at": auth.updated_at,
167
+ }
168
+ self.auth_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
169
+
170
+ def clear_auth(self) -> None:
171
+ if self.auth_path.exists():
172
+ self.auth_path.unlink()
173
+
174
+ def load_context(self) -> ContextState:
175
+ if not self.context_path.exists():
176
+ return ContextState()
177
+ data = json.loads(self.context_path.read_text(encoding="utf-8"))
178
+ return ContextState(
179
+ instance_id=data.get("instance_id"),
180
+ org_id=data.get("org_id"),
181
+ branch=data.get("branch"),
182
+ updated_at=data.get("updated_at"),
183
+ )
184
+
185
+ def save_context(self, context: ContextState) -> None:
186
+ self.ensure_base_dir()
187
+ self.context_path.write_text(json.dumps(asdict(context), indent=2) + "\n", encoding="utf-8")
188
+
189
+ def clear_context(self, *, instance: bool = False, org: bool = False, branch: bool = False) -> ContextState:
190
+ context = self.load_context()
191
+ if not any([instance, org, branch]):
192
+ context = ContextState()
193
+ else:
194
+ if instance:
195
+ context.instance_id = None
196
+ if org:
197
+ context.org_id = None
198
+ if branch:
199
+ context.branch = None
200
+ self.save_context(context)
201
+ return context
202
+
203
+
204
+ def env_auth_state() -> AuthState | None:
205
+ access_token = os.environ.get("POWERBASE_ACCESS_TOKEN")
206
+ if not access_token:
207
+ return None
208
+ refresh_token = os.environ.get("POWERBASE_REFRESH_TOKEN")
209
+ expires_at_raw = os.environ.get("POWERBASE_EXPIRES_AT")
210
+ expires_at = int(expires_at_raw) if expires_at_raw and expires_at_raw.isdigit() else None
211
+ return AuthState(
212
+ source="env",
213
+ base_url=os.environ.get("POWERBASE_BASE_URL"),
214
+ anon_key=os.environ.get("POWERBASE_ANON_KEY"),
215
+ session=AuthSession(
216
+ access_token=access_token,
217
+ refresh_token=refresh_token,
218
+ expires_at=expires_at,
219
+ ),
220
+ )
221
+
222
+
223
+ def merge_config_with_env(config: AppConfig) -> AppConfig:
224
+ return AppConfig(
225
+ base_url=os.environ.get("POWERBASE_BASE_URL") or config.base_url,
226
+ anon_key=os.environ.get("POWERBASE_ANON_KEY") or config.anon_key,
227
+ output=os.environ.get("POWERBASE_OUTPUT") or config.output,
228
+ tls_insecure=env_flag("POWERBASE_TLS_INSECURE") or config.tls_insecure,
229
+ ca_cert_file=os.environ.get("POWERBASE_CA_CERT_FILE") or config.ca_cert_file,
230
+ )
231
+
232
+
233
+ def merge_context_with_env(context: ContextState) -> ContextState:
234
+ return ContextState(
235
+ instance_id=os.environ.get("POWERBASE_INSTANCE_ID") or context.instance_id,
236
+ org_id=os.environ.get("POWERBASE_ORG_ID") or context.org_id,
237
+ branch=os.environ.get("POWERBASE_BRANCH") or context.branch,
238
+ updated_at=context.updated_at,
239
+ )