evo-cli 0.11.4__tar.gz → 0.12.2__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.
Files changed (67) hide show
  1. {evo_cli-0.11.4 → evo_cli-0.12.2}/PKG-INFO +44 -1
  2. {evo_cli-0.11.4 → evo_cli-0.12.2}/README.md +43 -0
  3. evo_cli-0.12.2/evo_cli/VERSION +1 -0
  4. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/cli.py +4 -0
  5. evo_cli-0.12.2/evo_cli/commands/cred.py +364 -0
  6. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/gdrive.py +30 -70
  7. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/localproxy.py +0 -1
  8. evo_cli-0.12.2/evo_cli/commands/update.py +291 -0
  9. evo_cli-0.12.2/evo_cli/credentials/__init__.py +27 -0
  10. evo_cli-0.12.2/evo_cli/credentials/doctor.py +73 -0
  11. evo_cli-0.12.2/evo_cli/credentials/google_oauth.py +101 -0
  12. evo_cli-0.12.2/evo_cli/credentials/migrate.py +147 -0
  13. evo_cli-0.12.2/evo_cli/credentials/oauth_flow.py +146 -0
  14. evo_cli-0.12.2/evo_cli/credentials/registry.py +411 -0
  15. evo_cli-0.12.2/evo_cli/credentials/store.py +178 -0
  16. evo_cli-0.12.2/evo_cli/credentials/sync.py +149 -0
  17. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli.egg-info/PKG-INFO +44 -1
  18. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli.egg-info/SOURCES.txt +12 -0
  19. evo_cli-0.12.2/tests/test_cred.py +488 -0
  20. evo_cli-0.12.2/tests/test_update.py +190 -0
  21. evo_cli-0.11.4/evo_cli/VERSION +0 -1
  22. {evo_cli-0.11.4 → evo_cli-0.12.2}/Containerfile +0 -0
  23. {evo_cli-0.11.4 → evo_cli-0.12.2}/HISTORY.md +0 -0
  24. {evo_cli-0.11.4 → evo_cli-0.12.2}/LICENSE +0 -0
  25. {evo_cli-0.11.4 → evo_cli-0.12.2}/MANIFEST.in +0 -0
  26. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/__init__.py +0 -0
  27. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/__main__.py +0 -0
  28. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/base.py +0 -0
  29. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/__init__.py +0 -0
  30. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/claude_code.py +0 -0
  31. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/cloudflare.py +0 -0
  32. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/download.py +0 -0
  33. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/fix_claude.py +0 -0
  34. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/gh.py +0 -0
  35. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/harness.py +0 -0
  36. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/hwid.py +0 -0
  37. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/hwid_reset.py +0 -0
  38. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/mcp.py +0 -0
  39. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/miniconda.py +0 -0
  40. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/netcheck.py +0 -0
  41. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/opencode.py +0 -0
  42. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/plantuml.py +0 -0
  43. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/site2s.py +0 -0
  44. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/ssh.py +0 -0
  45. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/sysmon.py +0 -0
  46. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/commands/wifi.py +0 -0
  47. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/console.py +0 -0
  48. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli/mcp_registry.py +0 -0
  49. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli.egg-info/dependency_links.txt +0 -0
  50. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli.egg-info/entry_points.txt +0 -0
  51. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli.egg-info/requires.txt +0 -0
  52. {evo_cli-0.11.4 → evo_cli-0.12.2}/evo_cli.egg-info/top_level.txt +0 -0
  53. {evo_cli-0.11.4 → evo_cli-0.12.2}/pyproject.toml +0 -0
  54. {evo_cli-0.11.4 → evo_cli-0.12.2}/setup.cfg +0 -0
  55. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/__init__.py +0 -0
  56. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_claude_code.py +0 -0
  57. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_cli.py +0 -0
  58. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_console.py +0 -0
  59. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_download.py +0 -0
  60. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_fix_claude.py +0 -0
  61. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_gh.py +0 -0
  62. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_harness.py +0 -0
  63. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_mcp.py +0 -0
  64. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_opencode.py +0 -0
  65. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_plantuml.py +0 -0
  66. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_sysmon.py +0 -0
  67. {evo_cli-0.11.4 → evo_cli-0.12.2}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.11.4
3
+ Version: 0.12.2
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -114,6 +114,49 @@ Options:
114
114
  - `-f, --force` - Apply the fix even if the version is not in the affected range
115
115
  - `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
116
116
 
117
+ #### Credentials
118
+
119
+ `evo` owns the omelet credential store. The source of truth is a folder of one JSON file per service
120
+ (`~/.omelet.d/credentials/`), which compiles into a flat `~/.omelet.json` that older consumers read
121
+ directly.
122
+
123
+ ```bash
124
+ evo cred doctor # health + expiry of every credential, exit 1 if any expired
125
+ evo cred get openai_api_key # print one value by dotted path, nothing else on stdout
126
+ evo cred add openai_api_key # prompt with no echo, write the folder file, recompile
127
+ evo cred auth --service google-drive # first-time OAuth consent, stores the refresh token
128
+ evo cred refresh --all # refresh Google OAuth access tokens
129
+ evo cred compile # rebuild ~/.omelet.json from the folder
130
+ evo cred sync push # push the folder to a private GitHub repo via gh
131
+ ```
132
+
133
+ Read a value into the environment without it reaching your shell history:
134
+
135
+ ```bash
136
+ eval "$(evo cred get --export OPENAI_API_KEY openai_api_key)"
137
+ ```
138
+
139
+ Configuration is env-driven, with no personal defaults baked in:
140
+
141
+ - `OMELET_DIR` - store root (default `~/.omelet.d`; the folder is `$OMELET_DIR/credentials`)
142
+ - `OMELET_CONFIG` - compiled flat file (default `~/.omelet.json`)
143
+ - `OMELET_SYNC_REPO` - required for `sync`; a **private** GitHub repo as `owner/repo`
144
+ - `OMELET_SYNC_DIR` - folder name inside the sync repo (default `credentials`)
145
+ - `RCLONE_DRIVE_CLIENT_ID` / `RCLONE_DRIVE_CLIENT_SECRET` - OAuth client for the rclone refresh
146
+
147
+ `evo cred sync push` refuses to push to a repo whose visibility is not `PRIVATE`. Never echo a value
148
+ into a shared terminal or a log; `doctor` and `list` only ever print a masked preview.
149
+
150
+ `evo cred auth` performs the initial OAuth consent that `refresh` cannot: it starts a loopback server,
151
+ opens the consent screen, and writes the resulting refresh token into the store. Create the OAuth
152
+ client in the Cloud Console first (APIs & Services -> Credentials -> Create OAuth client ID ->
153
+ **Desktop app**) and pass the downloaded JSON with `--client-secrets`; there is no gcloud equivalent
154
+ for creating an OAuth client.
155
+
156
+ `evo gdrive` reads its token from the `google_drive` entry through this store rather than parsing
157
+ `~/.omelet.json` itself, so a refresh writes to the folder and recompiles instead of racing whatever
158
+ else has the flat file open.
159
+
117
160
  #### Harness Repositories
118
161
 
119
162
  Fast-forward every available repository declared in a harness manifest:
@@ -86,6 +86,49 @@ Options:
86
86
  - `-f, --force` - Apply the fix even if the version is not in the affected range
87
87
  - `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
88
88
 
89
+ #### Credentials
90
+
91
+ `evo` owns the omelet credential store. The source of truth is a folder of one JSON file per service
92
+ (`~/.omelet.d/credentials/`), which compiles into a flat `~/.omelet.json` that older consumers read
93
+ directly.
94
+
95
+ ```bash
96
+ evo cred doctor # health + expiry of every credential, exit 1 if any expired
97
+ evo cred get openai_api_key # print one value by dotted path, nothing else on stdout
98
+ evo cred add openai_api_key # prompt with no echo, write the folder file, recompile
99
+ evo cred auth --service google-drive # first-time OAuth consent, stores the refresh token
100
+ evo cred refresh --all # refresh Google OAuth access tokens
101
+ evo cred compile # rebuild ~/.omelet.json from the folder
102
+ evo cred sync push # push the folder to a private GitHub repo via gh
103
+ ```
104
+
105
+ Read a value into the environment without it reaching your shell history:
106
+
107
+ ```bash
108
+ eval "$(evo cred get --export OPENAI_API_KEY openai_api_key)"
109
+ ```
110
+
111
+ Configuration is env-driven, with no personal defaults baked in:
112
+
113
+ - `OMELET_DIR` - store root (default `~/.omelet.d`; the folder is `$OMELET_DIR/credentials`)
114
+ - `OMELET_CONFIG` - compiled flat file (default `~/.omelet.json`)
115
+ - `OMELET_SYNC_REPO` - required for `sync`; a **private** GitHub repo as `owner/repo`
116
+ - `OMELET_SYNC_DIR` - folder name inside the sync repo (default `credentials`)
117
+ - `RCLONE_DRIVE_CLIENT_ID` / `RCLONE_DRIVE_CLIENT_SECRET` - OAuth client for the rclone refresh
118
+
119
+ `evo cred sync push` refuses to push to a repo whose visibility is not `PRIVATE`. Never echo a value
120
+ into a shared terminal or a log; `doctor` and `list` only ever print a masked preview.
121
+
122
+ `evo cred auth` performs the initial OAuth consent that `refresh` cannot: it starts a loopback server,
123
+ opens the consent screen, and writes the resulting refresh token into the store. Create the OAuth
124
+ client in the Cloud Console first (APIs & Services -> Credentials -> Create OAuth client ID ->
125
+ **Desktop app**) and pass the downloaded JSON with `--client-secrets`; there is no gcloud equivalent
126
+ for creating an OAuth client.
127
+
128
+ `evo gdrive` reads its token from the `google_drive` entry through this store rather than parsing
129
+ `~/.omelet.json` itself, so a refresh writes to the folder and recompiles instead of racing whatever
130
+ else has the flat file open.
131
+
89
132
  #### Harness Repositories
90
133
 
91
134
  Fast-forward every available repository declared in a harness manifest:
@@ -0,0 +1 @@
1
+ 0.12.2
@@ -3,6 +3,7 @@ import rich_click as click
3
3
  from evo_cli import __version__
4
4
  from evo_cli.commands.claude_code import setup_claude
5
5
  from evo_cli.commands.cloudflare import cfssh
6
+ from evo_cli.commands.cred import cred_group
6
7
  from evo_cli.commands.download import download
7
8
  from evo_cli.commands.fix_claude import f_claude
8
9
  from evo_cli.commands.gdrive import gdrive
@@ -19,6 +20,7 @@ from evo_cli.commands.plantuml import plantuml
19
20
  from evo_cli.commands.site2s import site2s
20
21
  from evo_cli.commands.ssh import setupssh
21
22
  from evo_cli.commands.sysmon import sysmon
23
+ from evo_cli.commands.update import update
22
24
  from evo_cli.commands.wifi import wifi
23
25
 
24
26
 
@@ -56,6 +58,7 @@ setup_group.add_command(setupssh)
56
58
 
57
59
  cli.add_command(setup_group)
58
60
  cli.add_command(cfssh)
61
+ cli.add_command(cred_group)
59
62
  cli.add_command(download)
60
63
  cli.add_command(f_claude)
61
64
  cli.add_command(gdrive)
@@ -68,6 +71,7 @@ cli.add_command(mcp_group)
68
71
  cli.add_command(netcheck)
69
72
  cli.add_command(plantuml)
70
73
  cli.add_command(sysmon)
74
+ cli.add_command(update)
71
75
  cli.add_command(wifi)
72
76
 
73
77
 
@@ -0,0 +1,364 @@
1
+ import getpass
2
+ import json
3
+ import os
4
+ import sys
5
+ import webbrowser
6
+ from pathlib import Path
7
+
8
+ import rich_click as click
9
+ from rich.table import Table
10
+
11
+ from evo_cli.console import console, info, success, warning
12
+ from evo_cli.credentials import doctor as doctor_module
13
+ from evo_cli.credentials import google_oauth, migrate, oauth_flow, sync
14
+ from evo_cli.credentials.registry import config_path, credentials_dir
15
+ from evo_cli.credentials.store import (
16
+ CredentialError,
17
+ compile_flat,
18
+ get_value,
19
+ read_flat,
20
+ relative_to_store,
21
+ require_folder,
22
+ set_value,
23
+ )
24
+
25
+ HEALTH_STYLE = {
26
+ "ok": "green",
27
+ "expiring": "yellow",
28
+ "EXPIRED": "bold red",
29
+ "deprecated": "dim",
30
+ }
31
+
32
+
33
+ def _guard(func, *args, **kwargs):
34
+ try:
35
+ return func(*args, **kwargs)
36
+ except CredentialError as exc:
37
+ raise click.ClickException(str(exc)) from exc
38
+ except ValueError as exc:
39
+ raise click.ClickException(str(exc)) from exc
40
+
41
+
42
+ def _render(rows):
43
+ table = Table(show_header=True, header_style="accent", box=None, pad_edge=False)
44
+ for column in ("FILE", "SERVICE", "TYPE", "HEALTH", "SECRET", "EXPIRY"):
45
+ table.add_column(column, overflow="fold")
46
+ for row in rows:
47
+ table.add_row(
48
+ row["file"],
49
+ row["service"],
50
+ row["type"],
51
+ f"[{HEALTH_STYLE.get(row['health'], 'white')}]{row['health']}[/]",
52
+ row["secret"],
53
+ row["expiry"],
54
+ )
55
+ console.print(table)
56
+
57
+
58
+ @click.group("cred", help="Read, refresh, and sync the omelet credential store.")
59
+ def cred_group():
60
+ pass
61
+
62
+
63
+ @cred_group.command("get", help="Print one credential by dotted key path.")
64
+ @click.argument("key_path")
65
+ @click.option("--export", "export_var", metavar="VAR", help="Print a shell export statement instead of the raw value.")
66
+ def get(key_path, export_var):
67
+ value = _guard(get_value, key_path)
68
+
69
+ if isinstance(value, (dict, list)):
70
+ sys.stdout.write(json.dumps(value, ensure_ascii=False) + "\n")
71
+ return
72
+
73
+ text = str(value)
74
+ if export_var:
75
+ escaped = text.replace("'", "'\\''")
76
+ sys.stdout.write(f"export {export_var}='{escaped}'\n")
77
+ return
78
+
79
+ sys.stdout.write(text)
80
+ if sys.stdout.isatty():
81
+ sys.stdout.write("\n")
82
+
83
+
84
+ @cred_group.command("list", help="List every credential in the store with its health.")
85
+ def list_credentials():
86
+ _guard(require_folder)
87
+ rows = _guard(doctor_module.scan)
88
+ _render(rows)
89
+ console.print(f"\n{len(rows)} entries in {credentials_dir()}")
90
+
91
+
92
+ @cred_group.command("doctor", help="Report health and expiry; exit 1 if anything is expired.")
93
+ def doctor():
94
+ _guard(require_folder)
95
+ rows = _guard(doctor_module.scan)
96
+ _render(rows)
97
+ expired = [row for row in rows if row["health"] == "EXPIRED"]
98
+ console.print(f"\n{len(rows)} entries in {credentials_dir()}")
99
+ if expired:
100
+ warning(f"{len(expired)} EXPIRED - run: evo cred refresh --all")
101
+ raise click.exceptions.Exit(1)
102
+ success("all credentials healthy")
103
+
104
+
105
+ @cred_group.command("compile", help="Rebuild the flat config from the credentials folder.")
106
+ @click.option("--include-legacy", is_flag=True, help="Also merge entries marked deprecated.")
107
+ def compile_command(include_legacy):
108
+ count, skipped, target = _guard(compile_flat, include_legacy=include_legacy)
109
+ message = f"compiled {count} entries -> {target}"
110
+ if skipped:
111
+ message += f" (skipped {len(skipped)} deprecated: {', '.join(skipped)})"
112
+ success(message)
113
+
114
+
115
+ @cred_group.command("add", help="Add or update a credential, then recompile the flat config.")
116
+ @click.argument("key_path")
117
+ @click.option("--from-stdin", is_flag=True, help="Read the value from stdin.")
118
+ @click.option("--from-env", metavar="VAR", help="Read the value from an environment variable.")
119
+ @click.option("--value", "inline_value", help="Inline value. Avoid: leaks to shell history.")
120
+ @click.option("--json", "parse_json", is_flag=True, help="Parse the value as JSON (bool/number/object/array).")
121
+ def add(key_path, from_stdin, from_env, inline_value, parse_json):
122
+ sources = [bool(from_stdin), bool(from_env), inline_value is not None]
123
+ if sum(sources) > 1:
124
+ raise click.UsageError("Use only one of --from-stdin, --from-env, --value.")
125
+
126
+ if from_stdin:
127
+ value = sys.stdin.read().rstrip("\n")
128
+ elif from_env:
129
+ if from_env not in os.environ:
130
+ raise click.ClickException(f"env var {from_env} not set")
131
+ value = os.environ[from_env]
132
+ elif inline_value is not None:
133
+ value = inline_value
134
+ else:
135
+ value = getpass.getpass(f"value for {key_path} (no echo): ")
136
+ if not value:
137
+ raise click.ClickException("empty value, aborting")
138
+
139
+ if parse_json:
140
+ try:
141
+ value = json.loads(value)
142
+ except json.JSONDecodeError as exc:
143
+ raise click.ClickException(f"--json: failed to parse value: {exc}") from exc
144
+
145
+ path, existed = _guard(set_value, key_path, value)
146
+ count, _, target = _guard(compile_flat)
147
+ verb = "updated" if existed else "added"
148
+ success(f"{verb} {key_path} in {relative_to_store(path)}, recompiled {target} ({count} entries)")
149
+
150
+
151
+ @cred_group.command("refresh", help="Refresh Google OAuth access tokens and recompile.")
152
+ @click.option("--all", "refresh_all", is_flag=True, help="Refresh every refreshable OAuth entry.")
153
+ @click.option(
154
+ "--service",
155
+ type=click.Choice(sorted(google_oauth.SERVICE_ALIASES)),
156
+ help="Refresh a single service.",
157
+ )
158
+ @click.option("--dry-run", is_flag=True, help="Show what would be refreshed without calling Google.")
159
+ def refresh(refresh_all, service, dry_run):
160
+ if refresh_all == bool(service):
161
+ raise click.UsageError("Pass exactly one of --all or --service.")
162
+
163
+ _guard(require_folder)
164
+ target = "*" if refresh_all else google_oauth.SERVICE_ALIASES[service]
165
+ entries = _guard(google_oauth.select_entries, target)
166
+ if not entries:
167
+ raise click.ClickException("no matching refreshable oauth entries")
168
+
169
+ failures = 0
170
+ refreshed = 0
171
+ for path, entry in entries:
172
+ name = entry.get("id", path.name)
173
+ creds, err = google_oauth.resolve_creds(entry)
174
+ if err:
175
+ warning(f"{name}: skip ({err})")
176
+ failures += 1
177
+ continue
178
+
179
+ if dry_run:
180
+ info(
181
+ f"{name}: would POST {google_oauth.TOKEN_URL} "
182
+ f"refresh_token={creds['refresh_token'][:10]}... client_id={creds['client_id'][:12]}..."
183
+ )
184
+ continue
185
+
186
+ try:
187
+ expiry = google_oauth.refresh_entry(path, entry, creds)
188
+ except Exception as exc:
189
+ warning(f"{name}: {google_oauth.describe_error(exc)}")
190
+ failures += 1
191
+ continue
192
+
193
+ refreshed += 1
194
+ success(f"{name}: refreshed, valid until {expiry}")
195
+
196
+ if refreshed:
197
+ count, _, target_path = _guard(compile_flat)
198
+ info(f"recompiled {target_path} ({count} entries)")
199
+
200
+ if failures:
201
+ raise click.exceptions.Exit(1)
202
+
203
+
204
+ @cred_group.command("auth", help="Run the first-time Google OAuth consent flow and store the refresh token.")
205
+ @click.option(
206
+ "--service",
207
+ required=True,
208
+ type=click.Choice(sorted(google_oauth.SERVICE_ALIASES)),
209
+ help="Which service to authorise.",
210
+ )
211
+ @click.option(
212
+ "--client-secrets",
213
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
214
+ help="OAuth client JSON downloaded from the Cloud Console. Omit to reuse the stored client.",
215
+ )
216
+ @click.option("--scope", "scopes", multiple=True, help="Override the scopes. Repeat for several.")
217
+ @click.option("--no-browser", is_flag=True, help="Print the URL instead of opening a browser.")
218
+ def auth(service, client_secrets, scopes, no_browser):
219
+ target = google_oauth.SERVICE_ALIASES[service]
220
+ _guard(require_folder)
221
+
222
+ entries = _guard(google_oauth.select_entries, target)
223
+ if not entries:
224
+ raise click.ClickException(f"no oauth entry for '{service}' in the store")
225
+ path, entry = entries[0]
226
+
227
+ resolved_scopes = list(scopes) or (entry.get("oauth") or {}).get("scopes")
228
+ if not resolved_scopes:
229
+ raise click.ClickException(
230
+ f"no scopes known for '{service}'. Pass --scope, or add them to the oauth descriptor "
231
+ f"in evo_cli/credentials/registry.py."
232
+ )
233
+
234
+ if client_secrets:
235
+ client_id, client_secret = _guard(oauth_flow.client_from_secrets_file, client_secrets)
236
+ else:
237
+ client_id, client_secret = oauth_flow.client_from_entry(entry)
238
+ if not client_id or not client_secret:
239
+ raise click.ClickException(
240
+ "no OAuth client available. Create one in the Cloud Console "
241
+ "(APIs & Services -> Credentials -> OAuth client ID -> Desktop app), download the JSON, "
242
+ "and pass it with --client-secrets."
243
+ )
244
+
245
+ server, redirect_uri, thread = oauth_flow.capture_code()
246
+ state = oauth_flow.new_state()
247
+ url = oauth_flow.build_auth_url(client_id, redirect_uri, resolved_scopes, state)
248
+
249
+ info(f"scopes: {', '.join(resolved_scopes)}")
250
+ info(f"listening on {redirect_uri}")
251
+ if no_browser:
252
+ console.print(f"\nOpen this URL to authorise:\n\n{url}\n")
253
+ else:
254
+ console.print("\nOpening your browser to authorise. Approve the consent screen.\n")
255
+ webbrowser.open(url)
256
+
257
+ thread.start()
258
+ thread.join(timeout=300)
259
+ result = oauth_flow._Handler.result
260
+ server.server_close()
261
+
262
+ if result is None:
263
+ raise click.ClickException("timed out waiting for the consent redirect")
264
+ if result.get("error"):
265
+ raise click.ClickException(f"Google returned an error: {result['error']}")
266
+ if result.get("state") != state:
267
+ raise click.ClickException("state mismatch on the redirect; aborting rather than trusting it")
268
+
269
+ body = _guard(oauth_flow.exchange_code, client_id, client_secret, result["code"], redirect_uri)
270
+
271
+ oauth = entry.get("oauth") or {}
272
+ container_path = oauth.get("client_from") or oauth.get("container") or []
273
+ if container_path:
274
+ cursor = entry.setdefault("flat", {})
275
+ for part in container_path[:-1]:
276
+ cursor = cursor.setdefault(part, {})
277
+ leaf = cursor.setdefault(container_path[-1], {})
278
+ leaf["client_id"] = client_id
279
+ leaf["client_secret"] = client_secret
280
+
281
+ expiry = _guard(oauth_flow.store_tokens, path, entry, body, resolved_scopes)
282
+ count, _, target_path = _guard(compile_flat)
283
+ success(f"{target}: authorised, valid until {expiry}")
284
+ success(f"compiled {count} entries -> {target_path}")
285
+
286
+
287
+ @cred_group.command("migrate", help="Split a flat omelet.json into the per-service credentials folder.")
288
+ @click.option("--source", "source", type=click.Path(), help="Flat file to migrate. Defaults to the compiled config.")
289
+ @click.option("--dry-run", is_flag=True, help="Show the plan, write nothing.")
290
+ @click.option("--merge", is_flag=True, help="Migrate into an existing folder, adding only keys not already present.")
291
+ @click.option("--strict", is_flag=True, help="Abort on keys not mapped in the registry instead of routing to misc/.")
292
+ @click.option("--force", is_flag=True, help="Overwrite an existing non-empty folder.")
293
+ def migrate_command(source, dry_run, merge, strict, force):
294
+ result = _guard(migrate.plan, source or config_path(), strict=strict, merge=merge)
295
+
296
+ console.print(f"source: {result['source_path']} ({result['source_keys']} top-level keys)")
297
+ console.print(f"target: {credentials_dir()}{' [MERGE]' if merge else ''}\n")
298
+
299
+ for item in result["actions"]:
300
+ flags = " [unmapped->misc]" if item["misc"] else ""
301
+ if item["entry"].get("status") == "deprecated":
302
+ flags += " [DEPRECATED]"
303
+ if item["entry"].get("expiry"):
304
+ flags += f" expiry={item['entry']['expiry']}"
305
+ console.print(f" {item['action']:6s} {item['rel']:40s} <- {', '.join(item['keys'])}{flags}")
306
+
307
+ if result["unmapped"]:
308
+ warning(f"{len(result['unmapped'])} unmapped key(s) routed to misc/: {', '.join(result['unmapped'])}")
309
+
310
+ if dry_run:
311
+ info(f"dry-run: would write {len(result['actions'])} file(s)")
312
+ return
313
+
314
+ if not result["actions"]:
315
+ info("nothing to do (folder already up to date)")
316
+ return
317
+
318
+ if migrate.folder_has_files() and not (merge or force):
319
+ raise click.ClickException(
320
+ f"refusing: {credentials_dir()} already has files.\n"
321
+ "Use --merge (add missing keys) or --force (overwrite), or point OMELET_DIR elsewhere."
322
+ )
323
+
324
+ backup = _guard(migrate.apply, result)
325
+ info(f"backed up source -> {backup}")
326
+ success(f"wrote {len(result['actions'])} file(s) to {credentials_dir()}")
327
+
328
+ count, _, target_path = _guard(compile_flat)
329
+ success(f"compiled {count} entries -> {target_path}")
330
+
331
+
332
+ @cred_group.group("sync", help="Sync the credentials folder with a private GitHub repo.")
333
+ def sync_group():
334
+ pass
335
+
336
+
337
+ @sync_group.command("push", help="Push the local credentials folder to the sync repo.")
338
+ def sync_push():
339
+ host = _guard(sync.push)
340
+ if host is None:
341
+ info("no changes to push")
342
+ return
343
+ success(f"pushed {sync.dir_in_repo()}/ to {sync.sync_repo()} (from {host})")
344
+
345
+
346
+ @sync_group.command("pull", help="Pull the credentials folder from the sync repo, then recompile.")
347
+ def sync_pull():
348
+ backup = _guard(sync.pull)
349
+ if backup:
350
+ info(f"backed up existing folder -> {backup}")
351
+ success(f"pulled {sync.sync_repo()}:{sync.dir_in_repo()}/ -> {credentials_dir()}")
352
+ count, _, target_path = _guard(compile_flat)
353
+ success(f"compiled {count} entries -> {target_path}")
354
+
355
+
356
+ @cred_group.command("path", help="Print the store locations this machine resolves to.")
357
+ def show_path():
358
+ console.print(f"folder: {credentials_dir()}")
359
+ console.print(f"flat: {config_path()}")
360
+ try:
361
+ keys = [key for key in read_flat() if not key.startswith("_")]
362
+ except CredentialError:
363
+ keys = []
364
+ console.print(f"keys: {len(keys)}")
@@ -1,22 +1,23 @@
1
1
  import base64
2
2
  import json
3
3
  import mimetypes
4
- import os
5
4
  import re
6
5
  import sys
7
- import tempfile
8
6
  import urllib.error
9
7
  import urllib.parse
10
8
  import urllib.request
11
- from datetime import datetime, timedelta, timezone
9
+ from datetime import datetime, timezone
12
10
  from pathlib import Path
13
11
 
14
12
  import rich_click as click
15
13
  from rich.text import Text
16
14
 
17
15
  from evo_cli.console import error, info, step, success, warning
16
+ from evo_cli.credentials import google_oauth
17
+ from evo_cli.credentials.registry import dig, load_entries
18
+ from evo_cli.credentials.store import compile_flat
18
19
 
19
- CONFIG_PATH = Path(os.environ.get("OMELET_CONFIG", str(Path.home() / ".omelet.json")))
20
+ DRIVE_SERVICE_ID = "google_drive"
20
21
  TOKEN_URL = "https://oauth2.googleapis.com/token"
21
22
  DOCS_API = "https://docs.googleapis.com/v1/documents/{doc_id}"
22
23
  DRIVE_FILE_API = "https://www.googleapis.com/drive/v3/files/{file_id}"
@@ -48,24 +49,14 @@ def extract_doc_id(value):
48
49
  return None
49
50
 
50
51
 
51
- def read_config():
52
- if not CONFIG_PATH.exists():
53
- raise click.ClickException(f"config not found: {CONFIG_PATH}")
54
- return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
55
-
56
-
57
- def write_config(data):
58
- tmp = tempfile.NamedTemporaryFile("w", delete=False, dir=str(CONFIG_PATH.parent), encoding="utf-8")
59
- try:
60
- json.dump(data, tmp, indent=2, ensure_ascii=False)
61
- tmp.write("\n")
62
- tmp.close()
63
- os.chmod(tmp.name, 0o600)
64
- os.replace(tmp.name, CONFIG_PATH)
65
- except Exception:
66
- if os.path.exists(tmp.name):
67
- os.unlink(tmp.name)
68
- raise
52
+ def drive_entry():
53
+ for path, entry in load_entries():
54
+ if entry.get("id") == DRIVE_SERVICE_ID and entry.get("oauth"):
55
+ return path, entry
56
+ raise click.ClickException(
57
+ f"no '{DRIVE_SERVICE_ID}' oauth entry in the credential store. "
58
+ "Authorise it once with: evo cred auth --service google-drive --client-secrets <client.json>"
59
+ )
69
60
 
70
61
 
71
62
  def token_expired(token_section):
@@ -73,7 +64,7 @@ def token_expired(token_section):
73
64
  if not expiry:
74
65
  return True
75
66
  try:
76
- when = datetime.fromisoformat(expiry)
67
+ when = datetime.fromisoformat(str(expiry).replace("Z", "+00:00"))
77
68
  except ValueError:
78
69
  return True
79
70
  if when.tzinfo is None:
@@ -82,62 +73,31 @@ def token_expired(token_section):
82
73
  return (when - now).total_seconds() < 60
83
74
 
84
75
 
85
- def refresh_token(data):
86
- rclone_section = data.get("rclone", {})
87
- token_section = rclone_section.get("token", {})
88
- refresh = token_section.get("refresh_token")
89
- if not refresh:
90
- raise click.ClickException("rclone.token.refresh_token missing in config")
91
-
92
- client_id = os.environ.get("RCLONE_DRIVE_CLIENT_ID") or rclone_section.get("client_id")
93
- client_secret = os.environ.get("RCLONE_DRIVE_CLIENT_SECRET") or rclone_section.get("client_secret")
94
- if not client_id or not client_secret:
76
+ def refresh_token(path, entry):
77
+ creds, err = google_oauth.resolve_creds(entry)
78
+ if err:
95
79
  raise click.ClickException(
96
- "OAuth client credentials required. Set RCLONE_DRIVE_CLIENT_ID + RCLONE_DRIVE_CLIENT_SECRET "
97
- "or add rclone.client_id + rclone.client_secret to the config."
80
+ f"cannot refresh {DRIVE_SERVICE_ID}: {err}. "
81
+ "Authorise once with: evo cred auth --service google-drive --client-secrets <client.json>"
98
82
  )
99
-
100
- payload = urllib.parse.urlencode(
101
- {
102
- "client_id": client_id,
103
- "client_secret": client_secret,
104
- "refresh_token": refresh,
105
- "grant_type": "refresh_token",
106
- }
107
- ).encode("utf-8")
108
- req = urllib.request.Request(TOKEN_URL, data=payload, method="POST")
109
- req.add_header("Content-Type", "application/x-www-form-urlencoded")
110
-
111
83
  try:
112
- with urllib.request.urlopen(req, timeout=30) as resp:
113
- body = json.loads(resp.read().decode("utf-8"))
114
- except urllib.error.HTTPError as exc:
115
- err_body = exc.read().decode("utf-8", "replace")
116
- raise click.ClickException(f"token refresh failed: HTTP {exc.code} {err_body}") from exc
117
-
118
- access = body.get("access_token")
119
- if not access:
120
- raise click.ClickException(f"no access_token in refresh response: {body}")
121
-
122
- expires_in = int(body.get("expires_in", 3600))
123
- tz = timezone(timedelta(hours=7))
124
- expiry = (datetime.now(tz) + timedelta(seconds=expires_in)).isoformat()
84
+ expiry = google_oauth.refresh_entry(path, entry, creds)
85
+ except Exception as exc:
86
+ raise click.ClickException(f"token refresh failed: {google_oauth.describe_error(exc)}") from exc
125
87
 
126
- data["rclone"]["token"]["access_token"] = access
127
- data["rclone"]["token"]["expiry"] = expiry
128
- data["rclone"]["token"]["expires_in"] = expires_in
129
- write_config(data)
88
+ compile_flat()
130
89
  info(f"Refreshed Drive access token (valid until [accent]{expiry}[/accent])")
131
- return access
90
+ return creds["container"][entry["oauth"]["access_field"]]
132
91
 
133
92
 
134
93
  def access_token():
135
- data = read_config()
136
- token_section = data.get("rclone", {}).get("token", {})
137
- token = token_section.get("access_token")
138
- if not token or token_expired(token_section):
94
+ path, entry = drive_entry()
95
+ oauth = entry["oauth"]
96
+ container = dig(entry.get("flat", {}), oauth["container"]) or {}
97
+ token = container.get(oauth["access_field"])
98
+ if not token or token_expired(container):
139
99
  info("Access token missing or expired - refreshing.")
140
- token = refresh_token(data)
100
+ token = refresh_token(path, entry)
141
101
  return token
142
102
 
143
103
 
@@ -32,7 +32,6 @@ from rich.text import Text
32
32
 
33
33
  from evo_cli.console import console, error, info, step, success, warning
34
34
 
35
-
36
35
  # ── Certificate authority / leaf generation ──────────────────────────────────
37
36
 
38
37
  def _cert_dir(custom=None):