evercoast-cli 0.1.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.
- evercoast_cli-0.1.0/PKG-INFO +12 -0
- evercoast_cli-0.1.0/README.md +166 -0
- evercoast_cli-0.1.0/pyproject.toml +27 -0
- evercoast_cli-0.1.0/setup.cfg +4 -0
- evercoast_cli-0.1.0/src/evercoast_cli/__init__.py +3 -0
- evercoast_cli-0.1.0/src/evercoast_cli/api.py +264 -0
- evercoast_cli-0.1.0/src/evercoast_cli/config.py +151 -0
- evercoast_cli-0.1.0/src/evercoast_cli/main.py +472 -0
- evercoast_cli-0.1.0/src/evercoast_cli/upload.py +387 -0
- evercoast_cli-0.1.0/src/evercoast_cli.egg-info/PKG-INFO +12 -0
- evercoast_cli-0.1.0/src/evercoast_cli.egg-info/SOURCES.txt +17 -0
- evercoast_cli-0.1.0/src/evercoast_cli.egg-info/dependency_links.txt +1 -0
- evercoast_cli-0.1.0/src/evercoast_cli.egg-info/entry_points.txt +2 -0
- evercoast_cli-0.1.0/src/evercoast_cli.egg-info/requires.txt +8 -0
- evercoast_cli-0.1.0/src/evercoast_cli.egg-info/top_level.txt +1 -0
- evercoast_cli-0.1.0/tests/test_api.py +164 -0
- evercoast_cli-0.1.0/tests/test_cli.py +168 -0
- evercoast_cli-0.1.0/tests/test_config.py +149 -0
- evercoast_cli-0.1.0/tests/test_upload.py +199 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: evercoast-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Evercoast CLI — upload files to your company's S3 bucket
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: click>=8.0
|
|
7
|
+
Requires-Dist: boto3>=1.28
|
|
8
|
+
Requires-Dist: requests>=2.28
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-mock>=3.10; extra == "dev"
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Evercoast CLI
|
|
2
|
+
|
|
3
|
+
Upload files to your company's S3 bucket with resumable uploads, checksum validation, and automatic credential management.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python 3.9+
|
|
8
|
+
- An active Evercoast account with S3 direct upload enabled by Evercoast support
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install evercoast-cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
### 1. Login (one-time setup)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
evercoast login
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
You'll be prompted for your Evercoast email and password. You only need to do this once — credentials are saved and refresh automatically.
|
|
25
|
+
|
|
26
|
+
### 2. Upload
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
evercoast upload ./session-42/ --type takes
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That's it. The CLI handles credentials, checksums, progress, and retries automatically.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Upload Guide
|
|
37
|
+
|
|
38
|
+
### Data types
|
|
39
|
+
|
|
40
|
+
Choose a data type to route files to the right location:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
evercoast upload ./session-42/ --type takes # Raw capture data
|
|
44
|
+
evercoast upload ./meshes/ --type renders # PLY or OBJ sequences
|
|
45
|
+
evercoast upload notes.zip --type other # Anything else
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If you don't pass `--type`, the CLI prompts you:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
What type of data are you uploading?
|
|
52
|
+
|
|
53
|
+
1) Raw take data (images, videos, calibration, 2D data)
|
|
54
|
+
2) PLY or OBJ sequences
|
|
55
|
+
3) Other data
|
|
56
|
+
|
|
57
|
+
Select [1-3] (default: 1):
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Your last choice is remembered for next time.
|
|
61
|
+
|
|
62
|
+
| Type | Destination |
|
|
63
|
+
|------|-------------|
|
|
64
|
+
| `takes` | `client-uploads/takes/<folder-name>/` |
|
|
65
|
+
| `renders` | `client-uploads/renders/<folder-name>/` |
|
|
66
|
+
| `other` | `client-uploads/<folder-name>/` |
|
|
67
|
+
|
|
68
|
+
Use `--to` to specify a custom destination:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
evercoast upload ./data/ --to my/custom/path
|
|
72
|
+
# → uploads to client-uploads/my/custom/path/
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Resumable uploads
|
|
76
|
+
|
|
77
|
+
Directories are synced. If an upload is interrupted (Ctrl+C, network drop, laptop sleep), re-run the same command and only the remaining files are uploaded:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# First run: uploads 847 files
|
|
81
|
+
evercoast upload ./session-42/ --type takes
|
|
82
|
+
|
|
83
|
+
# Second run: skips everything
|
|
84
|
+
evercoast upload ./session-42/ --type takes
|
|
85
|
+
# → "Nothing to upload — all 847 files already exist at destination."
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Checksum validation
|
|
89
|
+
|
|
90
|
+
SHA256 checksums are enabled by default. The CLI calculates a checksum locally and the server validates it — if data is corrupted in transit, the upload is rejected and retried.
|
|
91
|
+
|
|
92
|
+
Disable with `--no-checksum` if needed (not recommended).
|
|
93
|
+
|
|
94
|
+
### Network resilience
|
|
95
|
+
|
|
96
|
+
If the network drops during an upload, the CLI:
|
|
97
|
+
|
|
98
|
+
1. Detects the failure
|
|
99
|
+
2. Waits for connectivity to return
|
|
100
|
+
3. Retries with increasing delays (up to 5 minutes between attempts)
|
|
101
|
+
|
|
102
|
+
Retries continue indefinitely until the upload succeeds or you cancel with Ctrl+C. If you cancel, re-run the same command to resume where you left off.
|
|
103
|
+
|
|
104
|
+
### Dry run
|
|
105
|
+
|
|
106
|
+
Preview what would be uploaded without actually uploading:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
evercoast upload ./session-42/ --type takes --dry-run
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Exclude files
|
|
113
|
+
|
|
114
|
+
Skip files matching glob patterns:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
evercoast upload ./data/ --type takes --exclude "*.tmp" --exclude ".DS_Store"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Skip confirmation
|
|
121
|
+
|
|
122
|
+
Uploads over 1 GB prompt for confirmation. Skip with `--yes`:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
evercoast upload ./large-dataset/ --type takes --yes
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## All Options
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
evercoast login
|
|
134
|
+
Authenticate and save credentials (one-time setup)
|
|
135
|
+
|
|
136
|
+
evercoast upload <path> [options]
|
|
137
|
+
--type [takes|renders|other] Data type (prompted if omitted)
|
|
138
|
+
--to TEXT Custom destination path
|
|
139
|
+
--dry-run Preview without uploading
|
|
140
|
+
--no-checksum Disable SHA256 checksum validation
|
|
141
|
+
--exclude TEXT Exclude glob pattern (repeatable)
|
|
142
|
+
-y, --yes Skip confirmation prompt
|
|
143
|
+
--help Show help
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Troubleshooting
|
|
147
|
+
|
|
148
|
+
### "Not configured. Run 'evercoast login' first."
|
|
149
|
+
|
|
150
|
+
You haven't logged in yet. Run `evercoast login` and enter your Evercoast credentials.
|
|
151
|
+
|
|
152
|
+
### "Authentication expired."
|
|
153
|
+
|
|
154
|
+
Your saved credentials are no longer valid. Run `evercoast login` again.
|
|
155
|
+
|
|
156
|
+
### "S3 direct upload is not enabled for your company."
|
|
157
|
+
|
|
158
|
+
Contact Evercoast support to enable this feature for your company.
|
|
159
|
+
|
|
160
|
+
### Upload seems stuck
|
|
161
|
+
|
|
162
|
+
Large files use multipart upload — the progress bar may pause between parts. This is normal. If the upload truly stalls, Ctrl+C and re-run the same command to resume.
|
|
163
|
+
|
|
164
|
+
## Support
|
|
165
|
+
|
|
166
|
+
Contact Evercoast support or your account representative for help.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "evercoast-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Evercoast CLI — upload files to your company's S3 bucket"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.0",
|
|
12
|
+
"boto3>=1.28",
|
|
13
|
+
"requests>=2.28",
|
|
14
|
+
"rich>=13.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=7.0",
|
|
20
|
+
"pytest-mock>=3.10",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
evercoast = "evercoast_cli.main:cli"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""API client for Evercoast backend.
|
|
2
|
+
|
|
3
|
+
Handles login, S3 credential fetching, and auto-refreshing boto3 sessions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
import requests
|
|
14
|
+
from botocore.credentials import RefreshableCredentials
|
|
15
|
+
from botocore.session import get_session
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
|
|
19
|
+
EC_AGENT_HEADER = "CB"
|
|
20
|
+
USER_AGENT = f"evercoast-cli/{__version__} ({platform.system()})"
|
|
21
|
+
|
|
22
|
+
# Common headers for all API calls
|
|
23
|
+
BASE_HEADERS = {
|
|
24
|
+
"EC-Agent": EC_AGENT_HEADER,
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"User-Agent": USER_AGENT,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APIError(Exception):
|
|
31
|
+
"""Raised when an API call fails."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def login(api_url: str, email: str, password: str) -> str:
|
|
39
|
+
"""Authenticate with the Evercoast API and return an auth token.
|
|
40
|
+
|
|
41
|
+
Uses EC-Agent: CB to create a Cloudbreak token that persists
|
|
42
|
+
indefinitely and won't conflict with Mavericks sessions.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Auth token string
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
APIError: If login fails
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
resp = requests.post(
|
|
52
|
+
f"{api_url}/login/",
|
|
53
|
+
headers=BASE_HEADERS,
|
|
54
|
+
json={"email": email, "password": password},
|
|
55
|
+
timeout=30,
|
|
56
|
+
)
|
|
57
|
+
except requests.ConnectionError:
|
|
58
|
+
raise APIError("Could not connect to Evercoast API. Check your internet connection.")
|
|
59
|
+
except requests.Timeout:
|
|
60
|
+
raise APIError("Connection to Evercoast API timed out.")
|
|
61
|
+
|
|
62
|
+
if resp.status_code != 200:
|
|
63
|
+
data = {}
|
|
64
|
+
try:
|
|
65
|
+
data = resp.json()
|
|
66
|
+
except ValueError:
|
|
67
|
+
pass
|
|
68
|
+
msg = (
|
|
69
|
+
data.get("detail")
|
|
70
|
+
or data.get("error")
|
|
71
|
+
or (data.get("non_field_errors") or [""])[0]
|
|
72
|
+
or "Invalid credentials"
|
|
73
|
+
)
|
|
74
|
+
raise APIError(f"Login failed: {msg}", status_code=resp.status_code)
|
|
75
|
+
|
|
76
|
+
data = resp.json()
|
|
77
|
+
token = data.get("token")
|
|
78
|
+
if not token:
|
|
79
|
+
raise APIError("Login failed: no token received")
|
|
80
|
+
|
|
81
|
+
return token
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fetch_s3_credentials(api_url: str, token: str) -> Dict:
|
|
85
|
+
"""Fetch S3 upload credentials from the API.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dict with keys: access_key_id, secret_access_key, session_token,
|
|
89
|
+
expiration, bucket, region, upload_path
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
APIError: If credential fetch fails
|
|
93
|
+
"""
|
|
94
|
+
headers = {
|
|
95
|
+
**BASE_HEADERS,
|
|
96
|
+
"Authorization": f"Token {token}",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
resp = requests.post(
|
|
101
|
+
f"{api_url}/s3-credentials/",
|
|
102
|
+
headers=headers,
|
|
103
|
+
timeout=30,
|
|
104
|
+
)
|
|
105
|
+
except requests.ConnectionError:
|
|
106
|
+
raise APIError("Could not connect to Evercoast API. Check your internet connection.")
|
|
107
|
+
except requests.Timeout:
|
|
108
|
+
raise APIError("Connection to Evercoast API timed out.")
|
|
109
|
+
|
|
110
|
+
if resp.status_code == 401:
|
|
111
|
+
raise APIError(
|
|
112
|
+
"Authentication expired. Run 'evercoast login' to re-authenticate.",
|
|
113
|
+
status_code=401,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if resp.status_code != 200:
|
|
117
|
+
data = {}
|
|
118
|
+
try:
|
|
119
|
+
data = resp.json()
|
|
120
|
+
except ValueError:
|
|
121
|
+
pass
|
|
122
|
+
msg = data.get("error") or data.get("detail") or "Unknown error"
|
|
123
|
+
raise APIError(f"Failed to get S3 credentials: {msg}", status_code=resp.status_code)
|
|
124
|
+
|
|
125
|
+
return resp.json()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _make_refreshable_metadata(api_url: str, token: str) -> Dict[str, str]:
|
|
129
|
+
"""Fetch credentials and return in botocore RefreshableCredentials format."""
|
|
130
|
+
data = fetch_s3_credentials(api_url, token)
|
|
131
|
+
return {
|
|
132
|
+
"access_key": data["access_key_id"],
|
|
133
|
+
"secret_key": data["secret_access_key"],
|
|
134
|
+
"token": data["session_token"],
|
|
135
|
+
"expiry_time": data["expiration"],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_s3_client(api_url: str, token: str, region: str):
|
|
140
|
+
"""Create an S3 client with auto-refreshing credentials.
|
|
141
|
+
|
|
142
|
+
Uses botocore's RefreshableCredentials so credentials are automatically
|
|
143
|
+
refreshed before expiration during long uploads.
|
|
144
|
+
"""
|
|
145
|
+
credentials = RefreshableCredentials.create_from_metadata(
|
|
146
|
+
metadata=_make_refreshable_metadata(api_url, token),
|
|
147
|
+
refresh_using=lambda: _make_refreshable_metadata(api_url, token),
|
|
148
|
+
method="sts-assume-role",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
botocore_session = get_session()
|
|
152
|
+
botocore_session._credentials = credentials
|
|
153
|
+
boto3_session = boto3.Session(botocore_session=botocore_session)
|
|
154
|
+
return boto3_session.client("s3", region_name=region)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def setup_aws_cli_profile(
|
|
158
|
+
api_url: str,
|
|
159
|
+
token: str,
|
|
160
|
+
region: str,
|
|
161
|
+
profile_name: str,
|
|
162
|
+
):
|
|
163
|
+
"""Set up an AWS CLI credential_process profile as a bonus.
|
|
164
|
+
|
|
165
|
+
Creates a credential helper script and configures AWS CLI to use it.
|
|
166
|
+
If AWS CLI is not installed, this is silently skipped.
|
|
167
|
+
"""
|
|
168
|
+
# Check if AWS CLI is available
|
|
169
|
+
try:
|
|
170
|
+
subprocess.run(
|
|
171
|
+
["aws", "--version"],
|
|
172
|
+
capture_output=True,
|
|
173
|
+
check=True,
|
|
174
|
+
)
|
|
175
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
config_dir = Path.home() / ".evercoast"
|
|
179
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
if platform.system() == "Windows":
|
|
182
|
+
helper_path = config_dir / f"{profile_name}-credential-helper.ps1"
|
|
183
|
+
helper_content = _generate_powershell_helper(api_url, token)
|
|
184
|
+
helper_path.write_text(helper_content, encoding="utf-8")
|
|
185
|
+
credential_process = f'powershell.exe -NoProfile -File "{helper_path}"'
|
|
186
|
+
else:
|
|
187
|
+
helper_path = config_dir / f"{profile_name}-credential-helper.sh"
|
|
188
|
+
helper_content = _generate_bash_helper(api_url, token)
|
|
189
|
+
helper_path.write_text(helper_content, encoding="utf-8")
|
|
190
|
+
helper_path.chmod(0o700)
|
|
191
|
+
credential_process = str(helper_path)
|
|
192
|
+
|
|
193
|
+
subprocess.run(
|
|
194
|
+
["aws", "configure", "set", "credential_process", credential_process,
|
|
195
|
+
"--profile", profile_name],
|
|
196
|
+
capture_output=True,
|
|
197
|
+
)
|
|
198
|
+
subprocess.run(
|
|
199
|
+
["aws", "configure", "set", "region", region,
|
|
200
|
+
"--profile", profile_name],
|
|
201
|
+
capture_output=True,
|
|
202
|
+
)
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _generate_bash_helper(api_url: str, token: str) -> str:
|
|
207
|
+
"""Generate a bash credential helper script."""
|
|
208
|
+
return f"""#!/usr/bin/env bash
|
|
209
|
+
# Auto-generated by evercoast CLI
|
|
210
|
+
# Called automatically by AWS CLI to get fresh credentials.
|
|
211
|
+
RESPONSE=$(curl -s -X POST "{api_url}/s3-credentials/" \\
|
|
212
|
+
-H "Authorization: Token {token}" \\
|
|
213
|
+
-H "EC-Agent: CB" \\
|
|
214
|
+
-H "Content-Type: application/json")
|
|
215
|
+
|
|
216
|
+
ERROR=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('error',''))" 2>/dev/null)
|
|
217
|
+
if [ -n "$ERROR" ]; then
|
|
218
|
+
echo "Error: $ERROR" >&2
|
|
219
|
+
echo "Run 'evercoast login' to re-authenticate." >&2
|
|
220
|
+
exit 1
|
|
221
|
+
fi
|
|
222
|
+
|
|
223
|
+
echo "$RESPONSE" | python3 -c "
|
|
224
|
+
import sys, json
|
|
225
|
+
d = json.load(sys.stdin)
|
|
226
|
+
print(json.dumps({{
|
|
227
|
+
'Version': 1,
|
|
228
|
+
'AccessKeyId': d['access_key_id'],
|
|
229
|
+
'SecretAccessKey': d['secret_access_key'],
|
|
230
|
+
'SessionToken': d['session_token'],
|
|
231
|
+
'Expiration': d['expiration']
|
|
232
|
+
}}))
|
|
233
|
+
"
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _generate_powershell_helper(api_url: str, token: str) -> str:
|
|
238
|
+
"""Generate a PowerShell credential helper script."""
|
|
239
|
+
return f"""# Auto-generated by evercoast CLI
|
|
240
|
+
$ErrorActionPreference = "Stop"
|
|
241
|
+
try {{
|
|
242
|
+
$Headers = @{{
|
|
243
|
+
"Authorization" = "Token {token}"
|
|
244
|
+
"EC-Agent" = "CB"
|
|
245
|
+
"Content-Type" = "application/json"
|
|
246
|
+
}}
|
|
247
|
+
$Response = Invoke-RestMethod -Uri "{api_url}/s3-credentials/" `
|
|
248
|
+
-Method Post `
|
|
249
|
+
-ContentType "application/json" `
|
|
250
|
+
-Headers $Headers `
|
|
251
|
+
-ErrorAction Stop
|
|
252
|
+
@{{
|
|
253
|
+
Version = 1
|
|
254
|
+
AccessKeyId = $Response.access_key_id
|
|
255
|
+
SecretAccessKey = $Response.secret_access_key
|
|
256
|
+
SessionToken = $Response.session_token
|
|
257
|
+
Expiration = $Response.expiration
|
|
258
|
+
}} | ConvertTo-Json
|
|
259
|
+
}}
|
|
260
|
+
catch {{
|
|
261
|
+
Write-Error "Error refreshing credentials. Run 'evercoast login' to re-authenticate."
|
|
262
|
+
exit 1
|
|
263
|
+
}}
|
|
264
|
+
"""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Config file management for Evercoast CLI.
|
|
2
|
+
|
|
3
|
+
Stores authentication tokens, bucket info, and user preferences
|
|
4
|
+
in ~/.evercoast/config.toml with per-profile sections.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# API URLs
|
|
16
|
+
PRODUCTION_API_URL = "https://api.cloudbreak.evercoast.com/api/v1"
|
|
17
|
+
STAGING_API_URL = "https://api.staging.cloudbreak.evercoast.com/api/v1"
|
|
18
|
+
|
|
19
|
+
DEFAULT_PROFILE = "default"
|
|
20
|
+
STAGING_PROFILE = "staging"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_config_dir() -> Path:
|
|
24
|
+
"""Return the config directory path (~/.evercoast/)."""
|
|
25
|
+
return Path.home() / ".evercoast"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_config_path() -> Path:
|
|
29
|
+
"""Return the config file path (~/.evercoast/config.toml)."""
|
|
30
|
+
return get_config_dir() / "config.toml"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_toml(text: str) -> Dict[str, Dict[str, str]]:
|
|
34
|
+
"""Parse a simple TOML file into nested dicts.
|
|
35
|
+
|
|
36
|
+
Only supports [section] headers and key = "value" pairs.
|
|
37
|
+
No nested tables, arrays, or complex types.
|
|
38
|
+
"""
|
|
39
|
+
result: Dict[str, Dict[str, str]] = {}
|
|
40
|
+
current_section = None
|
|
41
|
+
|
|
42
|
+
for line in text.splitlines():
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line or line.startswith("#"):
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
# Section header
|
|
48
|
+
section_match = re.match(r"^\[([^\]]+)\]$", line)
|
|
49
|
+
if section_match:
|
|
50
|
+
current_section = section_match.group(1).strip()
|
|
51
|
+
result[current_section] = {}
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
# Key = value pair
|
|
55
|
+
kv_match = re.match(r'^(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"$', line)
|
|
56
|
+
if kv_match and current_section is not None:
|
|
57
|
+
key = kv_match.group(1)
|
|
58
|
+
value = kv_match.group(2).replace('\\"', '"').replace("\\\\", "\\")
|
|
59
|
+
result[current_section][key] = value
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _serialize_toml(data: Dict[str, Dict[str, str]]) -> str:
|
|
65
|
+
"""Serialize nested dicts to simple TOML format."""
|
|
66
|
+
lines = []
|
|
67
|
+
for i, (section, values) in enumerate(data.items()):
|
|
68
|
+
if i > 0:
|
|
69
|
+
lines.append("")
|
|
70
|
+
lines.append(f"[{section}]")
|
|
71
|
+
for key, value in values.items():
|
|
72
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
73
|
+
lines.append(f'{key} = "{escaped}"')
|
|
74
|
+
lines.append("") # trailing newline
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Config:
|
|
79
|
+
"""Manages the Evercoast CLI config file."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
82
|
+
self.path = config_path or get_config_path()
|
|
83
|
+
self._data: Dict[str, Dict[str, str]] = {}
|
|
84
|
+
self._load()
|
|
85
|
+
|
|
86
|
+
def _load(self):
|
|
87
|
+
"""Load config from disk if it exists."""
|
|
88
|
+
if self.path.exists():
|
|
89
|
+
text = self.path.read_text(encoding="utf-8")
|
|
90
|
+
self._data = _parse_toml(text)
|
|
91
|
+
|
|
92
|
+
def _save(self):
|
|
93
|
+
"""Write config to disk with restricted permissions."""
|
|
94
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
self.path.write_text(_serialize_toml(self._data), encoding="utf-8")
|
|
96
|
+
# Restrict permissions on Unix (owner read/write only)
|
|
97
|
+
if platform.system() != "Windows":
|
|
98
|
+
self.path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
99
|
+
|
|
100
|
+
def save_profile(
|
|
101
|
+
self,
|
|
102
|
+
profile: str,
|
|
103
|
+
api_url: str,
|
|
104
|
+
token: str,
|
|
105
|
+
bucket: str,
|
|
106
|
+
region: str,
|
|
107
|
+
upload_path: str = "client-uploads/",
|
|
108
|
+
):
|
|
109
|
+
"""Save a profile with all connection details."""
|
|
110
|
+
self._data[profile] = {
|
|
111
|
+
"api_url": api_url,
|
|
112
|
+
"token": token,
|
|
113
|
+
"bucket": bucket,
|
|
114
|
+
"region": region,
|
|
115
|
+
"upload_path": upload_path,
|
|
116
|
+
}
|
|
117
|
+
self._save()
|
|
118
|
+
|
|
119
|
+
def get_profile(self, profile: str) -> Optional[Dict[str, str]]:
|
|
120
|
+
"""Get a profile's config, or None if it doesn't exist."""
|
|
121
|
+
return self._data.get(profile)
|
|
122
|
+
|
|
123
|
+
def has_profile(self, profile: str) -> bool:
|
|
124
|
+
"""Check if a profile exists."""
|
|
125
|
+
return profile in self._data
|
|
126
|
+
|
|
127
|
+
def get_value(self, profile: str, key: str) -> Optional[str]:
|
|
128
|
+
"""Get a single value from a profile."""
|
|
129
|
+
p = self._data.get(profile)
|
|
130
|
+
if p is None:
|
|
131
|
+
return None
|
|
132
|
+
return p.get(key)
|
|
133
|
+
|
|
134
|
+
def set_value(self, profile: str, key: str, value: str):
|
|
135
|
+
"""Set a single value in a profile (profile must exist)."""
|
|
136
|
+
if profile not in self._data:
|
|
137
|
+
self._data[profile] = {}
|
|
138
|
+
self._data[profile][key] = value
|
|
139
|
+
self._save()
|
|
140
|
+
|
|
141
|
+
def get_profile_name(self, staging: bool) -> str:
|
|
142
|
+
"""Return the profile name based on staging flag."""
|
|
143
|
+
return STAGING_PROFILE if staging else DEFAULT_PROFILE
|
|
144
|
+
|
|
145
|
+
def get_api_url(self, staging: bool) -> str:
|
|
146
|
+
"""Return the API URL for the given environment."""
|
|
147
|
+
profile = self.get_profile_name(staging)
|
|
148
|
+
p = self.get_profile(profile)
|
|
149
|
+
if p and "api_url" in p:
|
|
150
|
+
return p["api_url"]
|
|
151
|
+
return STAGING_API_URL if staging else PRODUCTION_API_URL
|