antigravity-manager 1.0.0__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.
- antigravity_manager-1.0.0/PKG-INFO +81 -0
- antigravity_manager-1.0.0/README.md +45 -0
- antigravity_manager-1.0.0/pyproject.toml +123 -0
- antigravity_manager-1.0.0/setup.cfg +4 -0
- antigravity_manager-1.0.0/src/antigravity_manager/__init__.py +3 -0
- antigravity_manager-1.0.0/src/antigravity_manager/backup.py +315 -0
- antigravity_manager-1.0.0/src/antigravity_manager/banner.py +55 -0
- antigravity_manager-1.0.0/src/antigravity_manager/cli.py +674 -0
- antigravity_manager-1.0.0/src/antigravity_manager/config.py +39 -0
- antigravity_manager-1.0.0/src/antigravity_manager/cooldown.py +249 -0
- antigravity_manager-1.0.0/src/antigravity_manager/credentials.py +152 -0
- antigravity_manager-1.0.0/src/antigravity_manager/doctor.py +90 -0
- antigravity_manager-1.0.0/src/antigravity_manager/list_backups.py +205 -0
- antigravity_manager-1.0.0/src/antigravity_manager/profile.py +49 -0
- antigravity_manager-1.0.0/src/antigravity_manager/prune.py +125 -0
- antigravity_manager-1.0.0/src/antigravity_manager/prune_backups.py +77 -0
- antigravity_manager-1.0.0/src/antigravity_manager/purge.py +124 -0
- antigravity_manager-1.0.0/src/antigravity_manager/registry.py +45 -0
- antigravity_manager-1.0.0/src/antigravity_manager/remove.py +112 -0
- antigravity_manager-1.0.0/src/antigravity_manager/restore.py +298 -0
- antigravity_manager-1.0.0/src/antigravity_manager/status.py +300 -0
- antigravity_manager-1.0.0/src/antigravity_manager/sync.py +145 -0
- antigravity_manager-1.0.0/src/antigravity_manager/ui.py +104 -0
- antigravity_manager-1.0.0/src/antigravity_manager/utils.py +19 -0
- antigravity_manager-1.0.0/src/antigravity_manager.egg-info/PKG-INFO +81 -0
- antigravity_manager-1.0.0/src/antigravity_manager.egg-info/SOURCES.txt +50 -0
- antigravity_manager-1.0.0/src/antigravity_manager.egg-info/dependency_links.txt +1 -0
- antigravity_manager-1.0.0/src/antigravity_manager.egg-info/entry_points.txt +3 -0
- antigravity_manager-1.0.0/src/antigravity_manager.egg-info/requires.txt +19 -0
- antigravity_manager-1.0.0/src/antigravity_manager.egg-info/top_level.txt +1 -0
- antigravity_manager-1.0.0/tests/test_backup_restore.py +283 -0
- antigravity_manager-1.0.0/tests/test_cli.py +99 -0
- antigravity_manager-1.0.0/tests/test_cli_commands.py +129 -0
- antigravity_manager-1.0.0/tests/test_cooldown.py +42 -0
- antigravity_manager-1.0.0/tests/test_cooldown2.py +121 -0
- antigravity_manager-1.0.0/tests/test_credentials.py +80 -0
- antigravity_manager-1.0.0/tests/test_doctor.py +18 -0
- antigravity_manager-1.0.0/tests/test_doctor_2.py +34 -0
- antigravity_manager-1.0.0/tests/test_list_backups.py +133 -0
- antigravity_manager-1.0.0/tests/test_profile.py +24 -0
- antigravity_manager-1.0.0/tests/test_prune.py +80 -0
- antigravity_manager-1.0.0/tests/test_prune_backups.py +11 -0
- antigravity_manager-1.0.0/tests/test_purge.py +64 -0
- antigravity_manager-1.0.0/tests/test_registry.py +16 -0
- antigravity_manager-1.0.0/tests/test_remove.py +55 -0
- antigravity_manager-1.0.0/tests/test_status.py +78 -0
- antigravity_manager-1.0.0/tests/test_status_2.py +21 -0
- antigravity_manager-1.0.0/tests/test_status_3.py +30 -0
- antigravity_manager-1.0.0/tests/test_status_4.py +39 -0
- antigravity_manager-1.0.0/tests/test_sync.py +59 -0
- antigravity_manager-1.0.0/tests/test_ui_rendering.py +185 -0
- antigravity_manager-1.0.0/tests/test_utils.py +15 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: antigravity-manager
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Account backup, restore, cooldown, and orchestration manager for Antigravity CLI
|
|
5
|
+
Author-email: Dhruv <dhruv13x@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dhruv13x/antigravity-manager
|
|
8
|
+
Project-URL: Repository, https://github.com/dhruv13x/antigravity-manager
|
|
9
|
+
Project-URL: Issues, https://github.com/dhruv13x/antigravity-manager/issues
|
|
10
|
+
Keywords: antigravity,gemini,backup,cli,automation,orchestration,quota
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: pyfakefs>=6.2.0
|
|
19
|
+
Requires-Dist: pytest-asyncio>=1.3.0
|
|
20
|
+
Requires-Dist: pytest-cov>=7.1.0
|
|
21
|
+
Requires-Dist: pytest-mock>=3.15.1
|
|
22
|
+
Requires-Dist: pytest-timeout>=2.4.0
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: b2sdk>=2.1.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-timeout>=2.2.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-json-report>=1.5.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pyfakefs>=5.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
34
|
+
Requires-Dist: black>=24.3.0; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.11.0; extra == "dev"
|
|
36
|
+
|
|
37
|
+
# antigravity-manager
|
|
38
|
+
|
|
39
|
+
Account backup, restore, cooldown, and status manager for Antigravity CLI.
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
agm # Defaults to 'cooldown'
|
|
44
|
+
agm -s # Shortcut for 'status'
|
|
45
|
+
agm -c # Shortcut for 'cooldown'
|
|
46
|
+
agm -v # Show version
|
|
47
|
+
agm -h # Show help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
uv run agm backup
|
|
51
|
+
uv run agm backup --auth-only
|
|
52
|
+
uv run agm backup --decision-model "Gemini 3.5 Flash"
|
|
53
|
+
uv run agm restore --email you@example.com
|
|
54
|
+
uv run agm restore --from-archive ./backup.tar.gz --full
|
|
55
|
+
uv run agm use you@example.com
|
|
56
|
+
uv run agm cooldown
|
|
57
|
+
uv run agm recommend
|
|
58
|
+
uv run agm list-backups
|
|
59
|
+
uv run agm doctor
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Default paths:
|
|
63
|
+
|
|
64
|
+
- Manager state: `~/.antigravity-manager`
|
|
65
|
+
- Backups: `~/.antigravity-manager/backups`
|
|
66
|
+
- Antigravity CLI state: `~/.gemini/antigravity-cli`
|
|
67
|
+
|
|
68
|
+
`agm restore` is auth-only by default. Use `--full` only when you want to replace
|
|
69
|
+
the whole Antigravity CLI state directory.
|
|
70
|
+
|
|
71
|
+
`agm` is scoped to the Antigravity CLI state directory and does not back up,
|
|
72
|
+
restore, or mutate files directly under `~/.gemini`.
|
|
73
|
+
|
|
74
|
+
Backups and recommendations use `Gemini 3.5 Flash` as the default decision
|
|
75
|
+
model. If Antigravity exposes a reset time for that model, the backup filename is
|
|
76
|
+
anchored to that reset time. If the model is available and `/usage` does not show
|
|
77
|
+
a reset time, the filename uses an explicit 5-hour estimate and records that
|
|
78
|
+
source in metadata.
|
|
79
|
+
|
|
80
|
+
Before restore/use, `agm` writes an account-named current-state copy under
|
|
81
|
+
`~/.antigravity-manager/safety_backups`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# antigravity-manager
|
|
2
|
+
|
|
3
|
+
Account backup, restore, cooldown, and status manager for Antigravity CLI.
|
|
4
|
+
## Commands
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
agm # Defaults to 'cooldown'
|
|
8
|
+
agm -s # Shortcut for 'status'
|
|
9
|
+
agm -c # Shortcut for 'cooldown'
|
|
10
|
+
agm -v # Show version
|
|
11
|
+
agm -h # Show help
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
uv run agm backup
|
|
15
|
+
uv run agm backup --auth-only
|
|
16
|
+
uv run agm backup --decision-model "Gemini 3.5 Flash"
|
|
17
|
+
uv run agm restore --email you@example.com
|
|
18
|
+
uv run agm restore --from-archive ./backup.tar.gz --full
|
|
19
|
+
uv run agm use you@example.com
|
|
20
|
+
uv run agm cooldown
|
|
21
|
+
uv run agm recommend
|
|
22
|
+
uv run agm list-backups
|
|
23
|
+
uv run agm doctor
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Default paths:
|
|
27
|
+
|
|
28
|
+
- Manager state: `~/.antigravity-manager`
|
|
29
|
+
- Backups: `~/.antigravity-manager/backups`
|
|
30
|
+
- Antigravity CLI state: `~/.gemini/antigravity-cli`
|
|
31
|
+
|
|
32
|
+
`agm restore` is auth-only by default. Use `--full` only when you want to replace
|
|
33
|
+
the whole Antigravity CLI state directory.
|
|
34
|
+
|
|
35
|
+
`agm` is scoped to the Antigravity CLI state directory and does not back up,
|
|
36
|
+
restore, or mutate files directly under `~/.gemini`.
|
|
37
|
+
|
|
38
|
+
Backups and recommendations use `Gemini 3.5 Flash` as the default decision
|
|
39
|
+
model. If Antigravity exposes a reset time for that model, the backup filename is
|
|
40
|
+
anchored to that reset time. If the model is available and `/usage` does not show
|
|
41
|
+
a reset time, the filename uses an explicit 5-hour estimate and records that
|
|
42
|
+
source in metadata.
|
|
43
|
+
|
|
44
|
+
Before restore/use, `agm` writes an account-named current-state copy under
|
|
45
|
+
`~/.antigravity-manager/safety_backups`.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "antigravity-manager"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Account backup, restore, cooldown, and orchestration manager for Antigravity CLI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Dhruv", email = "dhruv13x@gmail.com" }
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
"pyfakefs>=6.2.0",
|
|
15
|
+
"pytest-asyncio>=1.3.0",
|
|
16
|
+
"pytest-cov>=7.1.0",
|
|
17
|
+
"pytest-mock>=3.15.1",
|
|
18
|
+
"pytest-timeout>=2.4.0",
|
|
19
|
+
"rich>=13.0.0",
|
|
20
|
+
"b2sdk>=2.1.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
keywords = [
|
|
24
|
+
"antigravity",
|
|
25
|
+
"gemini",
|
|
26
|
+
"backup",
|
|
27
|
+
"cli",
|
|
28
|
+
"automation",
|
|
29
|
+
"orchestration",
|
|
30
|
+
"quota",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
classifiers = [
|
|
34
|
+
"Programming Language :: Python :: 3",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"License :: OSI Approved :: MIT License",
|
|
37
|
+
"Operating System :: OS Independent",
|
|
38
|
+
"Environment :: Console",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/dhruv13x/antigravity-manager"
|
|
43
|
+
Repository = "https://github.com/dhruv13x/antigravity-manager"
|
|
44
|
+
Issues = "https://github.com/dhruv13x/antigravity-manager/issues"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
agm = "antigravity_manager.cli:main"
|
|
48
|
+
antigravity-manager = "antigravity_manager.cli:main"
|
|
49
|
+
|
|
50
|
+
[build-system]
|
|
51
|
+
requires = ["setuptools>=68", "wheel"]
|
|
52
|
+
build-backend = "setuptools.build_meta"
|
|
53
|
+
|
|
54
|
+
[project.optional-dependencies]
|
|
55
|
+
dev = [
|
|
56
|
+
"pytest>=8.0.0",
|
|
57
|
+
"pytest-cov>=5.0.0",
|
|
58
|
+
"pytest-timeout>=2.2.0",
|
|
59
|
+
"pytest-json-report>=1.5.0",
|
|
60
|
+
"pytest-asyncio>=0.23.0",
|
|
61
|
+
"pytest-mock>=3.10.0",
|
|
62
|
+
"pyfakefs>=5.0.0",
|
|
63
|
+
"ruff>=0.6.0",
|
|
64
|
+
"black>=24.3.0",
|
|
65
|
+
"mypy>=1.11.0",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.setuptools]
|
|
69
|
+
package-dir = {"" = "src"}
|
|
70
|
+
|
|
71
|
+
[tool.setuptools.packages.find]
|
|
72
|
+
where = ["src"]
|
|
73
|
+
|
|
74
|
+
[tool.pytest.ini_options]
|
|
75
|
+
pythonpath = ["src"]
|
|
76
|
+
testpaths = ["tests"]
|
|
77
|
+
|
|
78
|
+
addopts = [
|
|
79
|
+
"-v",
|
|
80
|
+
"-ra",
|
|
81
|
+
"--strict-config",
|
|
82
|
+
"--strict-markers",
|
|
83
|
+
"--tb=short",
|
|
84
|
+
"--showlocals",
|
|
85
|
+
"--import-mode=importlib",
|
|
86
|
+
"--timeout=10",
|
|
87
|
+
"--durations=10",
|
|
88
|
+
"--cov=src/antigravity_manager",
|
|
89
|
+
"--cov-report=term-missing:skip-covered",
|
|
90
|
+
"--cov-report=html",
|
|
91
|
+
"--cov-report=xml",
|
|
92
|
+
"--cov-fail-under=50",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
log_cli = true
|
|
96
|
+
log_cli_level = "INFO"
|
|
97
|
+
|
|
98
|
+
[tool.coverage.run]
|
|
99
|
+
branch = true
|
|
100
|
+
source = ["src/antigravity_manager"]
|
|
101
|
+
|
|
102
|
+
[tool.coverage.report]
|
|
103
|
+
show_missing = true
|
|
104
|
+
skip_covered = true
|
|
105
|
+
fail_under = 50
|
|
106
|
+
precision = 2
|
|
107
|
+
|
|
108
|
+
[tool.ruff]
|
|
109
|
+
line-length = 100
|
|
110
|
+
target-version = "py312"
|
|
111
|
+
|
|
112
|
+
[tool.ruff.lint]
|
|
113
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
114
|
+
ignore = ["E501"]
|
|
115
|
+
|
|
116
|
+
[tool.black]
|
|
117
|
+
line-length = 100
|
|
118
|
+
target-version = ["py312"]
|
|
119
|
+
|
|
120
|
+
[tool.mypy]
|
|
121
|
+
python_version = "3.12"
|
|
122
|
+
strict = true
|
|
123
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tarfile
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .config import (
|
|
11
|
+
ANTIGRAVITY_AUTH_FILES,
|
|
12
|
+
DEFAULT_DECISION_MODEL,
|
|
13
|
+
EXCLUDED_TOP_LEVEL_NAMES,
|
|
14
|
+
)
|
|
15
|
+
from .registry import update_registry_from_status
|
|
16
|
+
from .status import LiveStatus, capture_tmux_status_text, parse_live_status_text, status_to_dict
|
|
17
|
+
from .ui import RenderableType
|
|
18
|
+
from .utils import build_archive_name, isoformat_local
|
|
19
|
+
|
|
20
|
+
ESTIMATED_MODEL_RESET_HOURS = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_decision_model(
|
|
24
|
+
status: LiveStatus, model_pattern: str = DEFAULT_DECISION_MODEL
|
|
25
|
+
) -> Any | None:
|
|
26
|
+
pattern = model_pattern.lower()
|
|
27
|
+
matches = [model for model in status.models if pattern in model.model_name.lower()]
|
|
28
|
+
if not matches:
|
|
29
|
+
return None
|
|
30
|
+
high = [model for model in matches if "(high)" in model.model_name.lower()]
|
|
31
|
+
return high[0] if high else matches[0]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_backup_anchor(
|
|
35
|
+
status: LiveStatus, model_pattern: str = DEFAULT_DECISION_MODEL
|
|
36
|
+
) -> tuple[datetime, str, str | None]:
|
|
37
|
+
model = find_decision_model(status, model_pattern)
|
|
38
|
+
if model and model.refresh_at is not None:
|
|
39
|
+
return model.refresh_at, "decision_model_refresh_at", model.model_name
|
|
40
|
+
if model and model.is_available:
|
|
41
|
+
return (
|
|
42
|
+
status.captured_at + timedelta(hours=ESTIMATED_MODEL_RESET_HOURS),
|
|
43
|
+
"estimated_5h_decision_model_available",
|
|
44
|
+
model.model_name,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
refreshes = [item.refresh_at for item in status.models if item.refresh_at is not None]
|
|
48
|
+
if refreshes:
|
|
49
|
+
return max(refreshes), "latest_model_refresh_at", model.model_name if model else None
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
status.captured_at + timedelta(hours=ESTIMATED_MODEL_RESET_HOURS),
|
|
53
|
+
"estimated_5h_no_model_reset_in_usage",
|
|
54
|
+
model.model_name if model else None,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_active_email(antigravity_home: Path) -> str | None:
|
|
59
|
+
path = antigravity_home / "google_accounts.json"
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
active = data.get("active")
|
|
65
|
+
return active.strip() if isinstance(active, str) and active.strip() else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fallback_status(email: str | None) -> LiveStatus:
|
|
69
|
+
from .status import ModelQuotaStatus
|
|
70
|
+
|
|
71
|
+
now = datetime.now().astimezone()
|
|
72
|
+
reset_at = now + timedelta(hours=ESTIMATED_MODEL_RESET_HOURS)
|
|
73
|
+
return LiveStatus(
|
|
74
|
+
email=email or "unknown",
|
|
75
|
+
plan="unknown",
|
|
76
|
+
is_pro=False,
|
|
77
|
+
captured_at=now,
|
|
78
|
+
models=(
|
|
79
|
+
ModelQuotaStatus(
|
|
80
|
+
model_name="unknown",
|
|
81
|
+
quota_percent_left=None,
|
|
82
|
+
refresh_in_text="Status capture bypassed; estimated 5h cooldown",
|
|
83
|
+
refresh_at=reset_at,
|
|
84
|
+
is_available=False,
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_status_for_backup(args: Any) -> LiveStatus:
|
|
91
|
+
if getattr(args, "without_status_check", False):
|
|
92
|
+
return fallback_status(read_active_email(Path(args.source_dir).expanduser()))
|
|
93
|
+
if getattr(args, "status_file", None):
|
|
94
|
+
text = Path(args.status_file).expanduser().read_text(encoding="utf-8")
|
|
95
|
+
else:
|
|
96
|
+
text = capture_tmux_status_text(
|
|
97
|
+
session_name=getattr(args, "tmux_session_name", None),
|
|
98
|
+
agy_command=getattr(args, "agy_command", "agy"),
|
|
99
|
+
cols=getattr(args, "tmux_cols", 140),
|
|
100
|
+
rows=getattr(args, "tmux_rows", 45),
|
|
101
|
+
startup_timeout_seconds=getattr(args, "startup_timeout_seconds", 30.0),
|
|
102
|
+
usage_timeout_seconds=getattr(args, "usage_timeout_seconds", 30.0),
|
|
103
|
+
)
|
|
104
|
+
return parse_live_status_text(text)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_backup_metadata(
|
|
108
|
+
status: LiveStatus,
|
|
109
|
+
archive_path: Path,
|
|
110
|
+
*,
|
|
111
|
+
source_antigravity_home: Path,
|
|
112
|
+
backup_mode: str,
|
|
113
|
+
include_bin: bool,
|
|
114
|
+
include_logs: bool,
|
|
115
|
+
decision_model: str,
|
|
116
|
+
backup_anchor_at: datetime,
|
|
117
|
+
backup_anchor_source: str,
|
|
118
|
+
backup_anchor_model: str | None,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
next_refreshes = [model.refresh_at for model in status.models if model.refresh_at is not None]
|
|
121
|
+
next_available_at = (
|
|
122
|
+
max(next_refreshes).isoformat() if next_refreshes else status.captured_at.isoformat()
|
|
123
|
+
)
|
|
124
|
+
return {
|
|
125
|
+
"schema_version": 1,
|
|
126
|
+
"product": "antigravity",
|
|
127
|
+
"email": status.email,
|
|
128
|
+
"plan": status.plan,
|
|
129
|
+
"created_at": isoformat_local(datetime.now().astimezone()),
|
|
130
|
+
"captured_at": isoformat_local(status.captured_at),
|
|
131
|
+
"next_available_at": next_available_at,
|
|
132
|
+
"decision_model": decision_model,
|
|
133
|
+
"backup_anchor_at": isoformat_local(backup_anchor_at),
|
|
134
|
+
"backup_anchor_source": backup_anchor_source,
|
|
135
|
+
"backup_anchor_model": backup_anchor_model,
|
|
136
|
+
"archive_name": archive_path.name,
|
|
137
|
+
"archive_path": str(archive_path),
|
|
138
|
+
"source_antigravity_home": str(source_antigravity_home),
|
|
139
|
+
"backup_mode": backup_mode,
|
|
140
|
+
"include_bin": include_bin,
|
|
141
|
+
"include_logs": include_logs,
|
|
142
|
+
"status": status_to_dict(status),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def iter_antigravity_entries(
|
|
147
|
+
source_dir: Path, *, auth_only: bool, include_bin: bool, include_logs: bool
|
|
148
|
+
) -> list[Path]:
|
|
149
|
+
if auth_only:
|
|
150
|
+
return [
|
|
151
|
+
source_dir / name for name in ANTIGRAVITY_AUTH_FILES if (source_dir / name).exists()
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
entries = []
|
|
155
|
+
for path in sorted(source_dir.iterdir(), key=lambda item: item.name):
|
|
156
|
+
if path.name == "bin" and not include_bin:
|
|
157
|
+
continue
|
|
158
|
+
if path.name == "log" and not include_logs:
|
|
159
|
+
continue
|
|
160
|
+
if path.name in EXCLUDED_TOP_LEVEL_NAMES and path.name != "log":
|
|
161
|
+
continue
|
|
162
|
+
entries.append(path)
|
|
163
|
+
return entries
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def create_backup_archive(
|
|
167
|
+
*,
|
|
168
|
+
archive_path: Path,
|
|
169
|
+
metadata_path: Path,
|
|
170
|
+
metadata: dict[str, Any],
|
|
171
|
+
antigravity_home: Path,
|
|
172
|
+
auth_only: bool,
|
|
173
|
+
include_bin: bool,
|
|
174
|
+
include_logs: bool,
|
|
175
|
+
) -> None:
|
|
176
|
+
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
with tempfile.TemporaryDirectory(prefix="agm-backup-") as temp_dir_str:
|
|
178
|
+
temp_metadata_path = Path(temp_dir_str) / metadata_path.name
|
|
179
|
+
temp_metadata_path.write_text(
|
|
180
|
+
json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
184
|
+
for path in iter_antigravity_entries(
|
|
185
|
+
antigravity_home,
|
|
186
|
+
auth_only=auth_only,
|
|
187
|
+
include_bin=include_bin,
|
|
188
|
+
include_logs=include_logs,
|
|
189
|
+
):
|
|
190
|
+
tar.add(path, arcname=f"antigravity-cli/{path.relative_to(antigravity_home)}", recursive=True)
|
|
191
|
+
tar.add(temp_metadata_path, arcname=temp_metadata_path.name, recursive=False)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def perform_backup(args: Any) -> tuple[Path, Path, dict[str, Any]]:
|
|
195
|
+
antigravity_home = Path(args.source_dir).expanduser()
|
|
196
|
+
if not antigravity_home.is_dir():
|
|
197
|
+
raise FileNotFoundError(f"Antigravity directory does not exist: {antigravity_home}")
|
|
198
|
+
|
|
199
|
+
status = get_status_for_backup(args)
|
|
200
|
+
backup_dir = Path(args.backup_dir).expanduser()
|
|
201
|
+
decision_model = getattr(args, "decision_model", DEFAULT_DECISION_MODEL)
|
|
202
|
+
backup_anchor_at, backup_anchor_source, backup_anchor_model = resolve_backup_anchor(
|
|
203
|
+
status, decision_model
|
|
204
|
+
)
|
|
205
|
+
archive_path = backup_dir / build_archive_name(backup_anchor_at, status.email)
|
|
206
|
+
metadata_path = archive_path.with_name(archive_path.name.replace(".tar.gz", ".metadata.json"))
|
|
207
|
+
metadata = build_backup_metadata(
|
|
208
|
+
status,
|
|
209
|
+
archive_path,
|
|
210
|
+
source_antigravity_home=antigravity_home,
|
|
211
|
+
backup_mode="auth-only" if getattr(args, "auth_only", False) else "full",
|
|
212
|
+
include_bin=getattr(args, "include_bin", False),
|
|
213
|
+
include_logs=getattr(args, "include_logs", False),
|
|
214
|
+
decision_model=decision_model,
|
|
215
|
+
backup_anchor_at=backup_anchor_at,
|
|
216
|
+
backup_anchor_source=backup_anchor_source,
|
|
217
|
+
backup_anchor_model=backup_anchor_model,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if getattr(args, "dry_run", False):
|
|
221
|
+
return archive_path, metadata_path, metadata
|
|
222
|
+
|
|
223
|
+
if archive_path.exists() and not getattr(args, "force", False):
|
|
224
|
+
raise FileExistsError(f"Archive already exists: {archive_path}. Use --force to overwrite.")
|
|
225
|
+
if archive_path.exists():
|
|
226
|
+
archive_path.unlink()
|
|
227
|
+
if metadata_path.exists():
|
|
228
|
+
metadata_path.unlink()
|
|
229
|
+
|
|
230
|
+
create_backup_archive(
|
|
231
|
+
archive_path=archive_path,
|
|
232
|
+
metadata_path=metadata_path,
|
|
233
|
+
metadata=metadata,
|
|
234
|
+
antigravity_home=antigravity_home,
|
|
235
|
+
auth_only=getattr(args, "auth_only", False),
|
|
236
|
+
include_bin=getattr(args, "include_bin", False),
|
|
237
|
+
include_logs=getattr(args, "include_logs", False),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if getattr(args, "encrypt", False):
|
|
241
|
+
import getpass
|
|
242
|
+
import subprocess
|
|
243
|
+
|
|
244
|
+
import antigravity_manager.ui
|
|
245
|
+
|
|
246
|
+
antigravity_manager.ui.console.print(f"Encrypting archive: {archive_path} -> .gpg")
|
|
247
|
+
import os
|
|
248
|
+
|
|
249
|
+
passphrase = os.environ.get("AGM_BACKUP_PASSWORD")
|
|
250
|
+
if not passphrase:
|
|
251
|
+
passphrase = getpass.getpass("Enter passphrase for backup encryption: ")
|
|
252
|
+
|
|
253
|
+
if not passphrase:
|
|
254
|
+
raise ValueError("Encryption requested but no passphrase provided.")
|
|
255
|
+
|
|
256
|
+
encrypted_path = archive_path.with_suffix(archive_path.suffix + ".gpg")
|
|
257
|
+
gpg_cmd = [
|
|
258
|
+
"gpg",
|
|
259
|
+
"--symmetric",
|
|
260
|
+
"--cipher-algo",
|
|
261
|
+
"AES256",
|
|
262
|
+
"--passphrase-fd",
|
|
263
|
+
"0",
|
|
264
|
+
"--batch",
|
|
265
|
+
"--yes",
|
|
266
|
+
"--output",
|
|
267
|
+
str(encrypted_path),
|
|
268
|
+
str(archive_path),
|
|
269
|
+
]
|
|
270
|
+
try:
|
|
271
|
+
subprocess.run(gpg_cmd, input=passphrase.encode(), check=True)
|
|
272
|
+
archive_path.unlink()
|
|
273
|
+
archive_path = encrypted_path
|
|
274
|
+
metadata["archive_name"] = archive_path.name
|
|
275
|
+
metadata["archive_path"] = str(archive_path)
|
|
276
|
+
metadata["encrypted"] = True
|
|
277
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
278
|
+
raise RuntimeError(f"GPG encryption failed: {e}") from e
|
|
279
|
+
|
|
280
|
+
metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8")
|
|
281
|
+
|
|
282
|
+
latest_path = backup_dir / f"{status.email}-latest-antigravity.tar.gz"
|
|
283
|
+
if latest_path.exists() or latest_path.is_symlink():
|
|
284
|
+
latest_path.unlink()
|
|
285
|
+
latest_path.symlink_to(archive_path.name)
|
|
286
|
+
update_registry_from_status(status)
|
|
287
|
+
return archive_path, metadata_path, metadata
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def backup_result_to_text(
|
|
291
|
+
archive_path: Path, metadata_path: Path, metadata: dict[str, Any], *, dry_run: bool
|
|
292
|
+
) -> RenderableType:
|
|
293
|
+
from .ui import Panel, Table
|
|
294
|
+
|
|
295
|
+
title = "[warning]Backup Result (Dry Run)[/]" if dry_run else "[success]Backup Created[/]"
|
|
296
|
+
border_style = "warning" if dry_run else "success"
|
|
297
|
+
|
|
298
|
+
table = Table.grid(padding=(0, 2))
|
|
299
|
+
table.add_column(style="bold cyan", justify="right")
|
|
300
|
+
table.add_column(style="white")
|
|
301
|
+
|
|
302
|
+
table.add_row("Email:", str(metadata.get("email", "unknown")))
|
|
303
|
+
table.add_row("Plan:", str(metadata.get("plan", "unknown")))
|
|
304
|
+
table.add_row("Archive:", str(archive_path))
|
|
305
|
+
table.add_row("Metadata:", str(metadata_path))
|
|
306
|
+
table.add_row("Next Available:", str(metadata.get("next_available_at", "unknown")))
|
|
307
|
+
|
|
308
|
+
anchor_src = metadata.get("backup_anchor_source", "unknown")
|
|
309
|
+
table.add_row(
|
|
310
|
+
"Anchor:",
|
|
311
|
+
f"{metadata.get('backup_anchor_at', 'unknown')} [dim]({anchor_src})[/dim]",
|
|
312
|
+
)
|
|
313
|
+
table.add_row("Mode:", str(metadata.get("backup_mode", "unknown")))
|
|
314
|
+
|
|
315
|
+
return Panel(table, title=title, border_style=border_style, expand=False)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def lerp(a, b, t):
|
|
10
|
+
return a + (b - a) * t
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def blend(c1, c2, t):
|
|
14
|
+
t = t**1.47
|
|
15
|
+
t = 0.82 * t + 0.08 * math.sin(3.2 * t)
|
|
16
|
+
r = int(lerp(c1[0], c2[0], t))
|
|
17
|
+
g = int(lerp(c1[1], c2[1], t))
|
|
18
|
+
b = int(lerp(c1[2], c2[2], t))
|
|
19
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_logo():
|
|
23
|
+
logo = r"""
|
|
24
|
+
_ _ _ _____ ___ ____ ____ _ __ _____ _______ __
|
|
25
|
+
/ \ | \| |_ _|_ _/ ___| _ \ / \ \ \ / /_ _|_ _\ \ / /
|
|
26
|
+
/ _ \| . ` | | | | | | _| |_) | / _ \ \ \ / / | | | | \ V /
|
|
27
|
+
/ ___ \ |\ | | | | | |___| _ < / ___ \ \ V / | | | | | |
|
|
28
|
+
/_/ \_\_| \_| |_| |___\____|_| \_\/_/ \_\_/ |___| |_| |_|
|
|
29
|
+
M A N A G E R
|
|
30
|
+
""".strip().split("\n")
|
|
31
|
+
|
|
32
|
+
palette = [
|
|
33
|
+
(0x2E, 0x7B, 0xEA),
|
|
34
|
+
(0x6C, 0x5B, 0xD8),
|
|
35
|
+
(0xB6, 0x6D, 0xB9),
|
|
36
|
+
(0xE8, 0x8A, 0xA6),
|
|
37
|
+
(0xFF, 0xB6, 0xC1),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
H = len(logo)
|
|
41
|
+
for i, line in enumerate(logo):
|
|
42
|
+
tline = Text()
|
|
43
|
+
W = len(line)
|
|
44
|
+
for j, ch in enumerate(line):
|
|
45
|
+
raw = i * 0.72 + j * 0.44
|
|
46
|
+
t = raw / (H * 0.72 + W * 0.44)
|
|
47
|
+
seg = t * (len(palette) - 1)
|
|
48
|
+
idx = int(seg)
|
|
49
|
+
t2 = seg - idx
|
|
50
|
+
c1 = palette[idx]
|
|
51
|
+
c2 = palette[min(idx + 1, len(palette) - 1)]
|
|
52
|
+
tline.append(ch, style=blend(c1, c2, t2))
|
|
53
|
+
console.print(tline)
|
|
54
|
+
|
|
55
|
+
console.print("[dim]CLI interface for managing Antigravity accounts and backups.[/dim]\n")
|