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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ movitera = movitera_cli.main:cli
@@ -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.