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.
- {evo_cli-0.12.1 → evo_cli-0.13.0}/PKG-INFO +58 -1
- {evo_cli-0.12.1 → evo_cli-0.13.0}/README.md +57 -0
- evo_cli-0.13.0/evo_cli/VERSION +1 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/cli.py +2 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/cred.py +86 -1
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/gdrive.py +30 -70
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/localproxy.py +0 -1
- evo_cli-0.13.0/evo_cli/commands/tts.py +398 -0
- evo_cli-0.13.0/evo_cli/credentials/oauth_flow.py +146 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/registry.py +20 -4
- evo_cli-0.13.0/evo_cli/tts/__init__.py +28 -0
- evo_cli-0.13.0/evo_cli/tts/chunking.py +61 -0
- evo_cli-0.13.0/evo_cli/tts/core.py +203 -0
- evo_cli-0.13.0/evo_cli/tts/creds.py +59 -0
- evo_cli-0.13.0/evo_cli/tts/errors.py +2 -0
- evo_cli-0.13.0/evo_cli/tts/http.py +54 -0
- evo_cli-0.13.0/evo_cli/tts/openai.py +58 -0
- evo_cli-0.13.0/evo_cli/tts/player.py +83 -0
- evo_cli-0.13.0/evo_cli/tts/vbee.py +133 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/PKG-INFO +58 -1
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/SOURCES.txt +12 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_cred.py +156 -10
- evo_cli-0.13.0/tests/test_tts.py +260 -0
- evo_cli-0.12.1/evo_cli/VERSION +0 -1
- {evo_cli-0.12.1 → evo_cli-0.13.0}/Containerfile +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/HISTORY.md +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/LICENSE +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/MANIFEST.in +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/base.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/harness.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/console.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/pyproject.toml +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/setup.cfg +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/__init__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_cli.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_console.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_download.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_gh.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_harness.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.13.0}/tests/test_update.py +0 -0
- {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.
|
|
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,
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
"
|
|
97
|
-
"
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
90
|
+
return creds["container"][entry["oauth"]["access_field"]]
|
|
132
91
|
|
|
133
92
|
|
|
134
93
|
def access_token():
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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(
|
|
100
|
+
token = refresh_token(path, entry)
|
|
141
101
|
return token
|
|
142
102
|
|
|
143
103
|
|