movitera-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.
- movitera_cli/__init__.py +25 -0
- movitera_cli/client.py +131 -0
- movitera_cli/config.py +22 -0
- movitera_cli/main.py +307 -0
- movitera_cli/token_store.py +94 -0
- movitera_cli-0.1.0.dist-info/METADATA +113 -0
- movitera_cli-0.1.0.dist-info/RECORD +10 -0
- movitera_cli-0.1.0.dist-info/WHEEL +4 -0
- movitera_cli-0.1.0.dist-info/entry_points.txt +2 -0
- movitera_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
movitera_cli/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Movitera CLI for vault secrets, dotenv export, TOTP codes.
|
|
2
|
+
|
|
3
|
+
Also exposes a small SDK surface for programmatic use:
|
|
4
|
+
|
|
5
|
+
from movitera_cli import VaultClient
|
|
6
|
+
from movitera_cli.token_store import load_token
|
|
7
|
+
|
|
8
|
+
client = VaultClient(api_url="https://api.movitera.com", token=load_token())
|
|
9
|
+
env = client.get_env(team_id="...", name="myapp-prod") # raw dotenv text
|
|
10
|
+
|
|
11
|
+
That's intentionally thin — the API itself is the SDK. We don't wrap it in
|
|
12
|
+
domain objects because the surface evolves with the backend and the wire
|
|
13
|
+
shape is small enough to consume directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from movitera_cli.client import AccessTokenSummary, MoviteraError, VaultClient
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AccessTokenSummary",
|
|
22
|
+
"MoviteraError",
|
|
23
|
+
"VaultClient",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
movitera_cli/client.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""HTTP client wrapping the Movitera vault API for the CLI.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around `httpx` that:
|
|
4
|
+
- Adds the `Authorization: Bearer <pat>` header.
|
|
5
|
+
- Surfaces server `errorCode` strings in raised exceptions so the CLI can
|
|
6
|
+
print actionable messages (e.g. CREDENTIAL_NAME_NOT_UNIQUE → "try --id").
|
|
7
|
+
- Picks `text/plain` vs JSON correctly per endpoint.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MoviteraError(Exception):
|
|
19
|
+
"""API call returned a non-2xx response we want to surface to the user."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status_code: int | None = None,
|
|
26
|
+
error_code: str | None = None,
|
|
27
|
+
):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.error_code = error_code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class AccessTokenSummary:
|
|
35
|
+
id: str
|
|
36
|
+
name: str
|
|
37
|
+
prefix: str
|
|
38
|
+
clientKind: str
|
|
39
|
+
createdAt: str
|
|
40
|
+
expiresAt: str | None
|
|
41
|
+
lastUsedAt: str | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VaultClient:
|
|
45
|
+
def __init__(self, api_url: str, token: str, *, timeout: float = 30.0):
|
|
46
|
+
self._client = httpx.Client(
|
|
47
|
+
base_url=api_url,
|
|
48
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
self._client.close()
|
|
54
|
+
|
|
55
|
+
def __enter__(self) -> VaultClient:
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __exit__(self, *_: Any) -> None:
|
|
59
|
+
self.close()
|
|
60
|
+
|
|
61
|
+
# ----- env bundle (CLI hot path) ------------------------------------
|
|
62
|
+
|
|
63
|
+
def get_env(self, team_id: str, name: str) -> str:
|
|
64
|
+
"""Return raw dotenv text for the named ENV_BUNDLE credential."""
|
|
65
|
+
response = self._client.get(
|
|
66
|
+
f"/vault/credentials/by-name/{name}/env",
|
|
67
|
+
params={"teamId": team_id},
|
|
68
|
+
)
|
|
69
|
+
self._raise_for_status(response)
|
|
70
|
+
return response.text
|
|
71
|
+
|
|
72
|
+
# ----- TOTP ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def get_totp(self, credential_id: str) -> tuple[str, str]:
|
|
75
|
+
"""Return (code, ISO-8601 expiresAt) from the server-side endpoint."""
|
|
76
|
+
response = self._client.get(f"/vault/credentials/{credential_id}/totp")
|
|
77
|
+
self._raise_for_status(response)
|
|
78
|
+
body = response.json()
|
|
79
|
+
return body["code"], body["expiresAt"]
|
|
80
|
+
|
|
81
|
+
# ----- access tokens (self-service) ---------------------------------
|
|
82
|
+
|
|
83
|
+
def list_access_tokens(self, team_id: str) -> list[AccessTokenSummary]:
|
|
84
|
+
response = self._client.get(
|
|
85
|
+
"/vault/access-tokens", params={"teamId": team_id}
|
|
86
|
+
)
|
|
87
|
+
self._raise_for_status(response)
|
|
88
|
+
return [
|
|
89
|
+
AccessTokenSummary(
|
|
90
|
+
id=item["id"],
|
|
91
|
+
name=item["name"],
|
|
92
|
+
prefix=item["prefix"],
|
|
93
|
+
clientKind=item["clientKind"],
|
|
94
|
+
createdAt=item["createdAt"],
|
|
95
|
+
expiresAt=item.get("expiresAt"),
|
|
96
|
+
lastUsedAt=item.get("lastUsedAt"),
|
|
97
|
+
)
|
|
98
|
+
for item in response.json()
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def revoke_access_token(self, token_id: str) -> None:
|
|
102
|
+
response = self._client.delete(f"/vault/access-tokens/{token_id}")
|
|
103
|
+
self._raise_for_status(response)
|
|
104
|
+
|
|
105
|
+
# ----- error handling -----------------------------------------------
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
109
|
+
if response.is_success:
|
|
110
|
+
return
|
|
111
|
+
error_code: str | None = None
|
|
112
|
+
message: str = response.text
|
|
113
|
+
try:
|
|
114
|
+
body = response.json()
|
|
115
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
116
|
+
if isinstance(detail, dict):
|
|
117
|
+
error_code = detail.get("errorCode")
|
|
118
|
+
messages = detail.get("errorMessages") or {}
|
|
119
|
+
# Prefer en-US; fall back to whatever's there.
|
|
120
|
+
message = (
|
|
121
|
+
messages.get("en-US")
|
|
122
|
+
or next(iter(messages.values()), None)
|
|
123
|
+
or message
|
|
124
|
+
)
|
|
125
|
+
except ValueError:
|
|
126
|
+
pass
|
|
127
|
+
raise MoviteraError(
|
|
128
|
+
message,
|
|
129
|
+
status_code=response.status_code,
|
|
130
|
+
error_code=error_code,
|
|
131
|
+
)
|
movitera_cli/config.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
_DEFAULT_API_URL = "https://api.movitera.com"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Settings:
|
|
11
|
+
api_url: str
|
|
12
|
+
default_team_id: str | None
|
|
13
|
+
inline_token: str | None
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_env(cls) -> Settings:
|
|
17
|
+
return cls(
|
|
18
|
+
api_url=os.environ.get("MOVITERA_API_URL", _DEFAULT_API_URL).rstrip("/"),
|
|
19
|
+
default_team_id=os.environ.get("MOVITERA_TEAM") or None,
|
|
20
|
+
# MOVITERA_TOKEN lets CI runners skip the keyring entirely.
|
|
21
|
+
inline_token=os.environ.get("MOVITERA_TOKEN") or None,
|
|
22
|
+
)
|
movitera_cli/main.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Movitera CLI entry point.
|
|
2
|
+
|
|
3
|
+
Sub-commands:
|
|
4
|
+
login / logout Manage the stored personal access token.
|
|
5
|
+
run Exec a child process with secrets injected as env vars.
|
|
6
|
+
secrets pull Dump the secret bundle as dotenv on stdout.
|
|
7
|
+
totp Print the current TOTP code for a credential.
|
|
8
|
+
tokens list/revoke Self-service PAT management.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import sys
|
|
16
|
+
from typing import NoReturn
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from movitera_cli.client import MoviteraError, VaultClient
|
|
21
|
+
from movitera_cli.config import Settings
|
|
22
|
+
from movitera_cli.token_store import clear_token, load_token, save_token
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_token(settings: Settings) -> str:
|
|
31
|
+
"""Return the PAT or exit with a `movitera login` hint.
|
|
32
|
+
|
|
33
|
+
`MOVITERA_TOKEN` overrides the keyring so CI runners can inject the PAT
|
|
34
|
+
directly via environment variable without touching disk.
|
|
35
|
+
"""
|
|
36
|
+
if settings.inline_token:
|
|
37
|
+
return settings.inline_token
|
|
38
|
+
token = load_token()
|
|
39
|
+
if not token:
|
|
40
|
+
click.echo(
|
|
41
|
+
"No vault access token found. Run `movitera login` to store one,"
|
|
42
|
+
" or set MOVITERA_TOKEN.",
|
|
43
|
+
err=True,
|
|
44
|
+
)
|
|
45
|
+
raise SystemExit(1)
|
|
46
|
+
return token
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_team_id(settings: Settings, override: str | None) -> str:
|
|
50
|
+
team_id = override or settings.default_team_id
|
|
51
|
+
if not team_id:
|
|
52
|
+
click.echo(
|
|
53
|
+
"Missing team id. Pass --team or set MOVITERA_TEAM.", err=True
|
|
54
|
+
)
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
return team_id
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _vault_client(settings: Settings) -> VaultClient:
|
|
60
|
+
return VaultClient(api_url=settings.api_url, token=_resolve_token(settings))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fail_with_movitera_error(exc: MoviteraError) -> NoReturn:
|
|
64
|
+
"""Translate API errors into actionable CLI messages."""
|
|
65
|
+
hint = ""
|
|
66
|
+
if exc.error_code == "CREDENTIAL_NAME_NOT_UNIQUE":
|
|
67
|
+
hint = (
|
|
68
|
+
"\nMultiple credentials match that name. Disambiguate by id or"
|
|
69
|
+
" rename one of them in the web app."
|
|
70
|
+
)
|
|
71
|
+
elif exc.error_code == "CREDENTIAL_KIND_MISMATCH":
|
|
72
|
+
hint = (
|
|
73
|
+
"\nThe credential exists but isn't an ENV_BUNDLE. Use a different"
|
|
74
|
+
" credential, or change its kind in the web app."
|
|
75
|
+
)
|
|
76
|
+
elif exc.error_code == "CREDENTIAL_TOTP_NOT_CONFIGURED":
|
|
77
|
+
hint = (
|
|
78
|
+
"\nThis credential has no OTPAUTH_URI field. Add one in the web"
|
|
79
|
+
" app to enable TOTP."
|
|
80
|
+
)
|
|
81
|
+
elif exc.error_code == "VAULT_ACCESS_TOKEN_INVALID":
|
|
82
|
+
hint = "\nYour PAT is revoked or expired. Run `movitera login` again."
|
|
83
|
+
click.echo(f"Error: {exc}{hint}", err=True)
|
|
84
|
+
raise SystemExit(2)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Click app
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
93
|
+
@click.version_option(package_name="movitera-cli", prog_name="movitera")
|
|
94
|
+
def cli() -> None:
|
|
95
|
+
"""Movitera vault CLI."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@cli.command()
|
|
99
|
+
@click.option(
|
|
100
|
+
"--stdin",
|
|
101
|
+
"from_stdin",
|
|
102
|
+
is_flag=True,
|
|
103
|
+
help="Read the PAT from stdin instead of prompting.",
|
|
104
|
+
)
|
|
105
|
+
def login(from_stdin: bool) -> None:
|
|
106
|
+
"""Store a Movitera vault access token in the OS keyring."""
|
|
107
|
+
if from_stdin:
|
|
108
|
+
token = sys.stdin.read().strip()
|
|
109
|
+
else:
|
|
110
|
+
token = click.prompt(
|
|
111
|
+
"Paste your Movitera vault PAT (input hidden)",
|
|
112
|
+
hide_input=True,
|
|
113
|
+
).strip()
|
|
114
|
+
if not token:
|
|
115
|
+
click.echo("Empty token; nothing saved.", err=True)
|
|
116
|
+
raise SystemExit(1)
|
|
117
|
+
if not token.startswith("mvt_pat_"):
|
|
118
|
+
click.echo(
|
|
119
|
+
"Token doesn't look like a vault PAT (missing `mvt_pat_` prefix).",
|
|
120
|
+
err=True,
|
|
121
|
+
)
|
|
122
|
+
raise SystemExit(1)
|
|
123
|
+
backend = save_token(token)
|
|
124
|
+
click.echo(f"Token saved to {backend}.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@cli.command()
|
|
128
|
+
def logout() -> None:
|
|
129
|
+
"""Remove the stored Movitera vault access token."""
|
|
130
|
+
clear_token()
|
|
131
|
+
click.echo("Stored token removed.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@cli.command(
|
|
135
|
+
name="run",
|
|
136
|
+
context_settings={
|
|
137
|
+
# Pass everything after `--` straight to the child process.
|
|
138
|
+
"ignore_unknown_options": True,
|
|
139
|
+
"allow_extra_args": True,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
@click.option("--team", "team_id", default=None)
|
|
143
|
+
@click.option(
|
|
144
|
+
"--credential",
|
|
145
|
+
"credential_name",
|
|
146
|
+
required=True,
|
|
147
|
+
help="Name of the ENV_BUNDLE credential to load.",
|
|
148
|
+
)
|
|
149
|
+
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
|
|
150
|
+
def run_cmd(
|
|
151
|
+
team_id: str | None, credential_name: str, argv: tuple[str, ...]
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Exec a command with vault secrets injected as env vars."""
|
|
154
|
+
if not argv:
|
|
155
|
+
click.echo(
|
|
156
|
+
"Provide a command to exec: `movitera run --credential X -- cmd`.",
|
|
157
|
+
err=True,
|
|
158
|
+
)
|
|
159
|
+
raise SystemExit(1)
|
|
160
|
+
|
|
161
|
+
settings = Settings.from_env()
|
|
162
|
+
team_id = _resolve_team_id(settings, team_id)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
with _vault_client(settings) as client:
|
|
166
|
+
dotenv_body = client.get_env(team_id=team_id, name=credential_name)
|
|
167
|
+
except MoviteraError as exc:
|
|
168
|
+
_fail_with_movitera_error(exc)
|
|
169
|
+
|
|
170
|
+
env = os.environ.copy()
|
|
171
|
+
env.update(_parse_dotenv(dotenv_body))
|
|
172
|
+
|
|
173
|
+
# `execvpe` replaces this process so signal handling stays clean (no
|
|
174
|
+
# Python supervisor in the middle). On Windows there's no `execvp` that
|
|
175
|
+
# respects PATH; we fall back to a subprocess.
|
|
176
|
+
cmd, *args = argv
|
|
177
|
+
if os.name == "nt": # pragma: no cover - posix-only CI
|
|
178
|
+
import subprocess
|
|
179
|
+
|
|
180
|
+
result = subprocess.run([cmd, *args], env=env)
|
|
181
|
+
raise SystemExit(result.returncode)
|
|
182
|
+
os.execvpe(cmd, [cmd, *args], env)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@cli.group(name="secrets")
|
|
186
|
+
def secrets_group() -> None:
|
|
187
|
+
"""Fetch and export vault secrets."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@secrets_group.command(name="pull")
|
|
191
|
+
@click.option("--team", "team_id", default=None)
|
|
192
|
+
@click.option(
|
|
193
|
+
"--credential",
|
|
194
|
+
"credential_name",
|
|
195
|
+
required=True,
|
|
196
|
+
help="Name of the ENV_BUNDLE credential.",
|
|
197
|
+
)
|
|
198
|
+
def secrets_pull(team_id: str | None, credential_name: str) -> None:
|
|
199
|
+
"""Print the dotenv body to stdout (suitable for redirection)."""
|
|
200
|
+
settings = Settings.from_env()
|
|
201
|
+
team_id = _resolve_team_id(settings, team_id)
|
|
202
|
+
try:
|
|
203
|
+
with _vault_client(settings) as client:
|
|
204
|
+
body = client.get_env(team_id=team_id, name=credential_name)
|
|
205
|
+
except MoviteraError as exc:
|
|
206
|
+
_fail_with_movitera_error(exc)
|
|
207
|
+
# `body` already ends with a newline; preserve it as-is so the consumer
|
|
208
|
+
# gets a fully-formed dotenv file even when piped.
|
|
209
|
+
sys.stdout.write(body)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@cli.command(name="totp")
|
|
213
|
+
@click.argument("credential_id")
|
|
214
|
+
def totp_cmd(credential_id: str) -> None:
|
|
215
|
+
"""Print the current TOTP code for a credential."""
|
|
216
|
+
settings = Settings.from_env()
|
|
217
|
+
try:
|
|
218
|
+
with _vault_client(settings) as client:
|
|
219
|
+
code, expires_at = client.get_totp(credential_id=credential_id)
|
|
220
|
+
except MoviteraError as exc:
|
|
221
|
+
_fail_with_movitera_error(exc)
|
|
222
|
+
click.echo(code)
|
|
223
|
+
click.echo(f"(valid until {expires_at})", err=True)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@cli.group(name="tokens")
|
|
227
|
+
def tokens_group() -> None:
|
|
228
|
+
"""Manage your own Movitera vault access tokens."""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@tokens_group.command(name="list")
|
|
232
|
+
@click.option("--team", "team_id", default=None)
|
|
233
|
+
def tokens_list(team_id: str | None) -> None:
|
|
234
|
+
"""List your active PATs in the team."""
|
|
235
|
+
settings = Settings.from_env()
|
|
236
|
+
team_id = _resolve_team_id(settings, team_id)
|
|
237
|
+
try:
|
|
238
|
+
with _vault_client(settings) as client:
|
|
239
|
+
tokens = client.list_access_tokens(team_id=team_id)
|
|
240
|
+
except MoviteraError as exc:
|
|
241
|
+
_fail_with_movitera_error(exc)
|
|
242
|
+
if not tokens:
|
|
243
|
+
click.echo("No active tokens.")
|
|
244
|
+
return
|
|
245
|
+
click.echo(
|
|
246
|
+
f"{'PREFIX':18} {'NAME':30} {'KIND':10} {'EXPIRES':25} ID"
|
|
247
|
+
)
|
|
248
|
+
for token in tokens:
|
|
249
|
+
click.echo(
|
|
250
|
+
f"{token.prefix:18} {token.name[:30]:30} "
|
|
251
|
+
f"{token.clientKind:10} {str(token.expiresAt or 'never')[:25]:25} "
|
|
252
|
+
f"{token.id}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@tokens_group.command(name="revoke")
|
|
257
|
+
@click.argument("token_id")
|
|
258
|
+
def tokens_revoke(token_id: str) -> None:
|
|
259
|
+
"""Revoke a PAT by id."""
|
|
260
|
+
settings = Settings.from_env()
|
|
261
|
+
try:
|
|
262
|
+
with _vault_client(settings) as client:
|
|
263
|
+
client.revoke_access_token(token_id=token_id)
|
|
264
|
+
except MoviteraError as exc:
|
|
265
|
+
_fail_with_movitera_error(exc)
|
|
266
|
+
click.echo(f"Revoked {token_id}.")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# dotenv parsing (matches the server's quoting rules)
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parse_dotenv(body: str) -> dict[str, str]:
|
|
275
|
+
"""Parse the server's dotenv format back into a dict.
|
|
276
|
+
|
|
277
|
+
The server quotes values containing whitespace, `#`, quotes, or
|
|
278
|
+
backslashes (see `GetCredentialEnvService._render_dotenv`). We mirror
|
|
279
|
+
that here so quoting round-trips correctly.
|
|
280
|
+
"""
|
|
281
|
+
out: dict[str, str] = {}
|
|
282
|
+
for line in body.splitlines():
|
|
283
|
+
line = line.rstrip("\r")
|
|
284
|
+
if not line or line.startswith("#"):
|
|
285
|
+
continue
|
|
286
|
+
if "=" not in line:
|
|
287
|
+
continue
|
|
288
|
+
key, _, raw_value = line.partition("=")
|
|
289
|
+
key = key.strip()
|
|
290
|
+
if not key:
|
|
291
|
+
continue
|
|
292
|
+
out[key] = _unquote_value(raw_value)
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _unquote_value(value: str) -> str:
|
|
297
|
+
value = value.strip()
|
|
298
|
+
if not value:
|
|
299
|
+
return ""
|
|
300
|
+
if value[0] == '"' and value[-1] == '"' and len(value) >= 2:
|
|
301
|
+
# Use shlex to handle backslash escapes consistently with most
|
|
302
|
+
# dotenv libraries; it understands `\"` and `\\` inside quotes.
|
|
303
|
+
try:
|
|
304
|
+
return next(iter(shlex.split(value)))
|
|
305
|
+
except ValueError:
|
|
306
|
+
return value
|
|
307
|
+
return value
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Persistent PAT storage with keyring-first / file-fallback strategy.
|
|
2
|
+
|
|
3
|
+
Keyring is preferred (OS-managed, encrypted at rest on most platforms).
|
|
4
|
+
When no backend is available (headless Linux without libsecret, some CI
|
|
5
|
+
runners), we fall back to a `0o600` file under `~/.config/movitera/token`.
|
|
6
|
+
The keyring service name is fixed so multiple installs share storage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import keyring
|
|
16
|
+
import keyring.errors
|
|
17
|
+
|
|
18
|
+
_SERVICE = "movitera-cli"
|
|
19
|
+
_USERNAME = "default"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fallback_path() -> Path:
|
|
23
|
+
base = Path(
|
|
24
|
+
os.environ.get("MOVITERA_CONFIG_DIR")
|
|
25
|
+
or os.environ.get("XDG_CONFIG_HOME")
|
|
26
|
+
or (Path.home() / ".config")
|
|
27
|
+
)
|
|
28
|
+
return base / "movitera" / "token"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _try_keyring_get() -> str | None:
|
|
32
|
+
try:
|
|
33
|
+
return keyring.get_password(_SERVICE, _USERNAME)
|
|
34
|
+
except keyring.errors.KeyringError:
|
|
35
|
+
return None
|
|
36
|
+
except Exception:
|
|
37
|
+
# Some keyring backends raise platform-specific exceptions on init
|
|
38
|
+
# (e.g. macOS sandbox issues). Treat any failure as "not available".
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _try_keyring_set(token: str) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
keyring.set_password(_SERVICE, _USERNAME, token)
|
|
45
|
+
return True
|
|
46
|
+
except Exception:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _try_keyring_delete() -> bool:
|
|
51
|
+
try:
|
|
52
|
+
keyring.delete_password(_SERVICE, _USERNAME)
|
|
53
|
+
return True
|
|
54
|
+
except keyring.errors.PasswordDeleteError:
|
|
55
|
+
# Already gone — caller treats this as success.
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_token() -> str | None:
|
|
62
|
+
"""Return the stored token, or None if none is stored."""
|
|
63
|
+
via_keyring = _try_keyring_get()
|
|
64
|
+
if via_keyring:
|
|
65
|
+
return via_keyring
|
|
66
|
+
path = _fallback_path()
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return None
|
|
69
|
+
return path.read_text(encoding="utf-8").strip() or None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_token(token: str) -> str:
|
|
73
|
+
"""Persist `token` and return a human-readable backend label.
|
|
74
|
+
|
|
75
|
+
Tries the OS keyring first; falls back to a `0o600` file. The label is
|
|
76
|
+
surfaced in `movitera login` output so the user knows where the secret
|
|
77
|
+
actually landed.
|
|
78
|
+
"""
|
|
79
|
+
if _try_keyring_set(token):
|
|
80
|
+
return "OS keyring"
|
|
81
|
+
path = _fallback_path()
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
path.write_text(token, encoding="utf-8")
|
|
84
|
+
# Best-effort on Windows where chmod is largely a no-op; the file path
|
|
85
|
+
# itself is under the user's profile which already has restricted ACLs.
|
|
86
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
87
|
+
return str(path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clear_token() -> None:
|
|
91
|
+
_try_keyring_delete()
|
|
92
|
+
path = _fallback_path()
|
|
93
|
+
if path.exists():
|
|
94
|
+
path.unlink()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: movitera-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Movitera CLI: inject vault secrets as environment variables, generate TOTP codes
|
|
5
|
+
Project-URL: Homepage, https://github.com/joaoheusi/movitera-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/joaoheusi/movitera-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/joaoheusi/movitera-cli/issues
|
|
8
|
+
Author: Movitera
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,dotenv,movitera,password-manager,secrets,totp,vault
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Requires-Python: <4,>=3.11
|
|
24
|
+
Requires-Dist: click<9,>=8.1
|
|
25
|
+
Requires-Dist: httpx<1,>=0.28.1
|
|
26
|
+
Requires-Dist: keyring<26,>=25
|
|
27
|
+
Requires-Dist: pyotp<3,>=2.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# movitera CLI
|
|
31
|
+
|
|
32
|
+
Inject Movitera vault secrets as environment variables, render dotenv files, and generate TOTP codes from the terminal. Authenticates with a Movitera vault personal access token (PAT).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install movitera-cli
|
|
38
|
+
# or install as an isolated CLI tool (recommended):
|
|
39
|
+
pipx install movitera-cli
|
|
40
|
+
# or with uv:
|
|
41
|
+
uv tool install movitera-cli
|
|
42
|
+
# or run without installing:
|
|
43
|
+
uvx --from movitera-cli movitera --help
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# 1. Mint a PAT in the web app (Settings → Vault → Access Tokens),
|
|
50
|
+
# then paste it once:
|
|
51
|
+
movitera login
|
|
52
|
+
|
|
53
|
+
# 2. Run any command with secrets injected as env vars:
|
|
54
|
+
movitera run --team <teamId> --credential myapp-prod -- npm start
|
|
55
|
+
|
|
56
|
+
# 3. Or dump as dotenv:
|
|
57
|
+
movitera secrets pull --team <teamId> --credential myapp-prod > .env
|
|
58
|
+
|
|
59
|
+
# 4. Or generate a TOTP code for a credential:
|
|
60
|
+
movitera totp --team <teamId> <credential-id>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
`movitera run -- cmd` fetches the dotenv body from
|
|
66
|
+
`GET /vault/credentials/by-name/{name}/env`, then `exec`s the child process
|
|
67
|
+
with those vars merged into its environment. The CLI never writes secrets
|
|
68
|
+
to disk — they exist only in the child process's `environ`.
|
|
69
|
+
|
|
70
|
+
Tokens are stored in the OS keyring (Keychain on macOS, libsecret on Linux,
|
|
71
|
+
Credential Manager on Windows) with a file fallback under
|
|
72
|
+
`~/.config/movitera/token` (mode `0o600`) if no keyring backend is
|
|
73
|
+
available.
|
|
74
|
+
|
|
75
|
+
## Config
|
|
76
|
+
|
|
77
|
+
| Env var | Purpose | Default |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `MOVITERA_API_URL` | API base URL | `https://api.movitera.com` |
|
|
80
|
+
| `MOVITERA_TOKEN` | PAT override (skips keyring lookup) | — |
|
|
81
|
+
| `MOVITERA_TEAM` | Default team id (you can omit `--team`) | — |
|
|
82
|
+
|
|
83
|
+
## Commands
|
|
84
|
+
|
|
85
|
+
### `movitera login`
|
|
86
|
+
Prompts for a PAT (or reads it from stdin via `--stdin`) and stores it.
|
|
87
|
+
|
|
88
|
+
### `movitera logout`
|
|
89
|
+
Removes the stored PAT.
|
|
90
|
+
|
|
91
|
+
### `movitera run --team T --credential N -- <cmd> [args...]`
|
|
92
|
+
Fetches the `ENV_BUNDLE` credential named `N` and execs `<cmd>` with those
|
|
93
|
+
vars in the environment.
|
|
94
|
+
|
|
95
|
+
### `movitera secrets pull --team T --credential N`
|
|
96
|
+
Writes the dotenv body to stdout. Exits non-zero on resolution failures so
|
|
97
|
+
you can chain `movitera secrets pull ... > .env`.
|
|
98
|
+
|
|
99
|
+
### `movitera totp --team T <credential-id>`
|
|
100
|
+
Prints the current TOTP code for the credential's `OTPAUTH_URI` field.
|
|
101
|
+
|
|
102
|
+
### `movitera tokens list --team T` / `movitera tokens revoke <id>`
|
|
103
|
+
Manage your own PATs from the CLI.
|
|
104
|
+
|
|
105
|
+
## Security model
|
|
106
|
+
|
|
107
|
+
- PATs have full 256-bit entropy and are stored as SHA-256 on the server,
|
|
108
|
+
so a server compromise cannot recover the plaintext.
|
|
109
|
+
- Every `movitera run` invocation is audited server-side, but PAT reads
|
|
110
|
+
are **coalesced per hour per token** (see the backend's
|
|
111
|
+
`FETCHED_VIA_TOKEN` aggregation) so a hot dev loop doesn't drown the log.
|
|
112
|
+
- Group/team access is re-checked on every request: if your vault scope is
|
|
113
|
+
revoked, your PAT immediately loses access — no token-cache divergence.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
movitera_cli/__init__.py,sha256=txXIGmjoER6_uoF31Ov8NSd3mHGBpQ6a6zHcu1Z_q3I,773
|
|
2
|
+
movitera_cli/client.py,sha256=6GT6gjrxZYUyh94dioffMTk9der3wzqHKwHrQb5B3lg,4178
|
|
3
|
+
movitera_cli/config.py,sha256=eHIlv8xwhVY0D28F4FrWCTXHcZqUNidGVyGpyOF2aEs,624
|
|
4
|
+
movitera_cli/main.py,sha256=zIu8xTeMATWBgSY-SJjjJA8i8ibPkK8pk-u6Upenikg,9850
|
|
5
|
+
movitera_cli/token_store.py,sha256=GCfATXx069r3bqWNi1SiUR0qtmVd5g-A00Yj3C-ehrM,2706
|
|
6
|
+
movitera_cli-0.1.0.dist-info/METADATA,sha256=mkOzvz_ks89nemyC3S2w47tbyj8WUVDgvnjBq3gv0kQ,4054
|
|
7
|
+
movitera_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
movitera_cli-0.1.0.dist-info/entry_points.txt,sha256=6sWijoQPmre_WeO7YAuyMXrAE_WpiPLxkOBsQJbRPIo,51
|
|
9
|
+
movitera_cli-0.1.0.dist-info/licenses/LICENSE,sha256=xNq9eLDnrXYIKD_6XLk0PovSlTCl8Fpk49E8aMnGLFk,1065
|
|
10
|
+
movitera_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Movitera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|