evo-cli 0.12.1__tar.gz → 0.13.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.
Files changed (78) hide show
  1. {evo_cli-0.12.1 → evo_cli-0.13.0}/PKG-INFO +58 -1
  2. {evo_cli-0.12.1 → evo_cli-0.13.0}/README.md +57 -0
  3. evo_cli-0.13.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/cli.py +2 -0
  5. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/cred.py +86 -1
  6. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/gdrive.py +30 -70
  7. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/localproxy.py +0 -1
  8. evo_cli-0.13.0/evo_cli/commands/tts.py +398 -0
  9. evo_cli-0.13.0/evo_cli/credentials/oauth_flow.py +146 -0
  10. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/registry.py +20 -4
  11. evo_cli-0.13.0/evo_cli/tts/__init__.py +28 -0
  12. evo_cli-0.13.0/evo_cli/tts/chunking.py +61 -0
  13. evo_cli-0.13.0/evo_cli/tts/core.py +203 -0
  14. evo_cli-0.13.0/evo_cli/tts/creds.py +59 -0
  15. evo_cli-0.13.0/evo_cli/tts/errors.py +2 -0
  16. evo_cli-0.13.0/evo_cli/tts/http.py +54 -0
  17. evo_cli-0.13.0/evo_cli/tts/openai.py +58 -0
  18. evo_cli-0.13.0/evo_cli/tts/player.py +83 -0
  19. evo_cli-0.13.0/evo_cli/tts/vbee.py +133 -0
  20. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/PKG-INFO +58 -1
  21. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/SOURCES.txt +12 -0
  22. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_cred.py +156 -10
  23. evo_cli-0.13.0/tests/test_tts.py +260 -0
  24. evo_cli-0.12.1/evo_cli/VERSION +0 -1
  25. {evo_cli-0.12.1 → evo_cli-0.13.0}/Containerfile +0 -0
  26. {evo_cli-0.12.1 → evo_cli-0.13.0}/HISTORY.md +0 -0
  27. {evo_cli-0.12.1 → evo_cli-0.13.0}/LICENSE +0 -0
  28. {evo_cli-0.12.1 → evo_cli-0.13.0}/MANIFEST.in +0 -0
  29. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/__init__.py +0 -0
  30. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/__main__.py +0 -0
  31. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/base.py +0 -0
  32. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/__init__.py +0 -0
  33. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/claude_code.py +0 -0
  34. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/cloudflare.py +0 -0
  35. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/download.py +0 -0
  36. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/fix_claude.py +0 -0
  37. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/gh.py +0 -0
  38. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/harness.py +0 -0
  39. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/hwid.py +0 -0
  40. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/hwid_reset.py +0 -0
  41. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/mcp.py +0 -0
  42. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/miniconda.py +0 -0
  43. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/netcheck.py +0 -0
  44. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/opencode.py +0 -0
  45. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/plantuml.py +0 -0
  46. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/site2s.py +0 -0
  47. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/ssh.py +0 -0
  48. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/sysmon.py +0 -0
  49. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/update.py +0 -0
  50. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/wifi.py +0 -0
  51. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/console.py +0 -0
  52. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/__init__.py +0 -0
  53. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/doctor.py +0 -0
  54. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/google_oauth.py +0 -0
  55. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/migrate.py +0 -0
  56. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/store.py +0 -0
  57. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/sync.py +0 -0
  58. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/mcp_registry.py +0 -0
  59. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  60. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/entry_points.txt +0 -0
  61. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/requires.txt +0 -0
  62. {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/top_level.txt +0 -0
  63. {evo_cli-0.12.1 → evo_cli-0.13.0}/pyproject.toml +0 -0
  64. {evo_cli-0.12.1 → evo_cli-0.13.0}/setup.cfg +0 -0
  65. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/__init__.py +0 -0
  66. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_claude_code.py +0 -0
  67. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_cli.py +0 -0
  68. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_console.py +0 -0
  69. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_download.py +0 -0
  70. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_fix_claude.py +0 -0
  71. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_gh.py +0 -0
  72. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_harness.py +0 -0
  73. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_mcp.py +0 -0
  74. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_opencode.py +0 -0
  75. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_plantuml.py +0 -0
  76. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_sysmon.py +0 -0
  77. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_update.py +0 -0
  78. {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.12.1
3
+ Version: 0.13.0
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
@@ -124,6 +124,7 @@ directly.
124
124
  evo cred doctor # health + expiry of every credential, exit 1 if any expired
125
125
  evo cred get openai_api_key # print one value by dotted path, nothing else on stdout
126
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
127
128
  evo cred refresh --all # refresh Google OAuth access tokens
128
129
  evo cred compile # rebuild ~/.omelet.json from the folder
129
130
  evo cred sync push # push the folder to a private GitHub repo via gh
@@ -146,6 +147,16 @@ Configuration is env-driven, with no personal defaults baked in:
146
147
  `evo cred sync push` refuses to push to a repo whose visibility is not `PRIVATE`. Never echo a value
147
148
  into a shared terminal or a log; `doctor` and `list` only ever print a masked preview.
148
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
+
149
160
  #### Harness Repositories
150
161
 
151
162
  Fast-forward every available repository declared in a harness manifest:
@@ -166,3 +177,49 @@ evo harness pull --dry-run
166
177
  The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
167
178
  `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
168
179
  uses `git pull --ff-only` so the command never creates merge commits.
180
+
181
+ #### Text to Speech
182
+
183
+ Synthesise speech through Vbee (Vietnamese) or OpenAI `gpt-4o-mini-tts`, and play it right away:
184
+
185
+ ```bash
186
+ evo tts speak "Xin chào, bản build đã xong"
187
+ evo tts speak -f notes.md -o notes.mp3
188
+ evo tts speak "hello there" -p openai -V nova --instructions "calm and encouraging"
189
+ git log -1 --format=%s | evo tts speak
190
+ ```
191
+
192
+ `speak` is the realtime path. Text longer than the provider's per-request limit (Vbee 300
193
+ characters, OpenAI 4000) is split on sentence boundaries and the audio is joined back into one file,
194
+ so the first words start playing while the rest is still being synthesised.
195
+
196
+ For bulk work use the batch path, which goes through Vbee's async API and polls
197
+ `/v1/tts/requests/{id}` until each audio link appears:
198
+
199
+ ```bash
200
+ evo tts batch chapters/ -o audio/ # one mp3 per .txt/.md file
201
+ evo tts batch a.txt b.txt -c 8 # 8 items in flight
202
+ evo tts batch --manifest jobs.jsonl # {"id":.., "text":.., "voice":..} per line
203
+ ```
204
+
205
+ OpenAI has no batch speech endpoint, so with `-p openai` the items are parallelised locally instead.
206
+
207
+ Voice codes come from `evo tts voices` (`-l en-US`, `--gender male`, `-p openai`, `--json`).
208
+
209
+ Credentials live in the omelet store, never in flags or source:
210
+
211
+ ```bash
212
+ evo cred add vbee.app_id --from-stdin # UUID from https://studio.vbee.vn/apps
213
+ evo cred add vbee.token --from-stdin # JWT from the same app page
214
+ evo cred add openai_api_key --from-stdin
215
+ ```
216
+
217
+ `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set. `--provider auto`
218
+ (the default) picks Vbee when its credentials exist, otherwise OpenAI.
219
+
220
+ Playback uses whichever of `ffplay`, `mpv`, `cvlc`, `afplay`, or `paplay`/`aplay` is on PATH, and
221
+ falls back to PowerShell's `MediaPlayer` on Windows. Without any of them the audio is still written
222
+ to disk and the command warns.
223
+
224
+ The same engine backs the `evo-tts` MCP server in
225
+ [agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
@@ -96,6 +96,7 @@ directly.
96
96
  evo cred doctor # health + expiry of every credential, exit 1 if any expired
97
97
  evo cred get openai_api_key # print one value by dotted path, nothing else on stdout
98
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
99
100
  evo cred refresh --all # refresh Google OAuth access tokens
100
101
  evo cred compile # rebuild ~/.omelet.json from the folder
101
102
  evo cred sync push # push the folder to a private GitHub repo via gh
@@ -118,6 +119,16 @@ Configuration is env-driven, with no personal defaults baked in:
118
119
  `evo cred sync push` refuses to push to a repo whose visibility is not `PRIVATE`. Never echo a value
119
120
  into a shared terminal or a log; `doctor` and `list` only ever print a masked preview.
120
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
+
121
132
  #### Harness Repositories
122
133
 
123
134
  Fast-forward every available repository declared in a harness manifest:
@@ -138,3 +149,49 @@ evo harness pull --dry-run
138
149
  The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
139
150
  `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
140
151
  uses `git pull --ff-only` so the command never creates merge commits.
152
+
153
+ #### Text to Speech
154
+
155
+ Synthesise speech through Vbee (Vietnamese) or OpenAI `gpt-4o-mini-tts`, and play it right away:
156
+
157
+ ```bash
158
+ evo tts speak "Xin chào, bản build đã xong"
159
+ evo tts speak -f notes.md -o notes.mp3
160
+ evo tts speak "hello there" -p openai -V nova --instructions "calm and encouraging"
161
+ git log -1 --format=%s | evo tts speak
162
+ ```
163
+
164
+ `speak` is the realtime path. Text longer than the provider's per-request limit (Vbee 300
165
+ characters, OpenAI 4000) is split on sentence boundaries and the audio is joined back into one file,
166
+ so the first words start playing while the rest is still being synthesised.
167
+
168
+ For bulk work use the batch path, which goes through Vbee's async API and polls
169
+ `/v1/tts/requests/{id}` until each audio link appears:
170
+
171
+ ```bash
172
+ evo tts batch chapters/ -o audio/ # one mp3 per .txt/.md file
173
+ evo tts batch a.txt b.txt -c 8 # 8 items in flight
174
+ evo tts batch --manifest jobs.jsonl # {"id":.., "text":.., "voice":..} per line
175
+ ```
176
+
177
+ OpenAI has no batch speech endpoint, so with `-p openai` the items are parallelised locally instead.
178
+
179
+ Voice codes come from `evo tts voices` (`-l en-US`, `--gender male`, `-p openai`, `--json`).
180
+
181
+ Credentials live in the omelet store, never in flags or source:
182
+
183
+ ```bash
184
+ evo cred add vbee.app_id --from-stdin # UUID from https://studio.vbee.vn/apps
185
+ evo cred add vbee.token --from-stdin # JWT from the same app page
186
+ evo cred add openai_api_key --from-stdin
187
+ ```
188
+
189
+ `VBEE_APP_ID`, `VBEE_TOKEN`, and `OPENAI_API_KEY` override the store when set. `--provider auto`
190
+ (the default) picks Vbee when its credentials exist, otherwise OpenAI.
191
+
192
+ Playback uses whichever of `ffplay`, `mpv`, `cvlc`, `afplay`, or `paplay`/`aplay` is on PATH, and
193
+ falls back to PowerShell's `MediaPlayer` on Windows. Without any of them the audio is still written
194
+ to disk and the command warns.
195
+
196
+ The same engine backs the `evo-tts` MCP server in
197
+ [agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
@@ -0,0 +1 @@
1
+ 0.13.0
@@ -20,6 +20,7 @@ from evo_cli.commands.plantuml import plantuml
20
20
  from evo_cli.commands.site2s import site2s
21
21
  from evo_cli.commands.ssh import setupssh
22
22
  from evo_cli.commands.sysmon import sysmon
23
+ from evo_cli.commands.tts import tts_group
23
24
  from evo_cli.commands.update import update
24
25
  from evo_cli.commands.wifi import wifi
25
26
 
@@ -71,6 +72,7 @@ cli.add_command(mcp_group)
71
72
  cli.add_command(netcheck)
72
73
  cli.add_command(plantuml)
73
74
  cli.add_command(sysmon)
75
+ cli.add_command(tts_group)
74
76
  cli.add_command(update)
75
77
  cli.add_command(wifi)
76
78
 
@@ -2,13 +2,15 @@ import getpass
2
2
  import json
3
3
  import os
4
4
  import sys
5
+ import webbrowser
6
+ from pathlib import Path
5
7
 
6
8
  import rich_click as click
7
9
  from rich.table import Table
8
10
 
9
11
  from evo_cli.console import console, info, success, warning
10
12
  from evo_cli.credentials import doctor as doctor_module
11
- from evo_cli.credentials import google_oauth, migrate, sync
13
+ from evo_cli.credentials import google_oauth, migrate, oauth_flow, sync
12
14
  from evo_cli.credentials.registry import config_path, credentials_dir
13
15
  from evo_cli.credentials.store import (
14
16
  CredentialError,
@@ -199,6 +201,89 @@ def refresh(refresh_all, service, dry_run):
199
201
  raise click.exceptions.Exit(1)
200
202
 
201
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
+
202
287
  @cred_group.command("migrate", help="Split a flat omelet.json into the per-service credentials folder.")
203
288
  @click.option("--source", "source", type=click.Path(), help="Flat file to migrate. Defaults to the compiled config.")
204
289
  @click.option("--dry-run", is_flag=True, help="Show the plan, write nothing.")
@@ -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):