evo-cli 0.12.1__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.
- {evo_cli-0.12.1 → evo_cli-0.12.2}/PKG-INFO +12 -1
- {evo_cli-0.12.1 → evo_cli-0.12.2}/README.md +11 -0
- evo_cli-0.12.2/evo_cli/VERSION +1 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/cred.py +86 -1
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/gdrive.py +30 -70
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/localproxy.py +0 -1
- evo_cli-0.12.2/evo_cli/credentials/oauth_flow.py +146 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/registry.py +9 -4
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/PKG-INFO +12 -1
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/SOURCES.txt +1 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_cred.py +156 -10
- evo_cli-0.12.1/evo_cli/VERSION +0 -1
- {evo_cli-0.12.1 → evo_cli-0.12.2}/Containerfile +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/HISTORY.md +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/LICENSE +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/MANIFEST.in +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/__init__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/__main__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/base.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/cli.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/harness.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/console.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/pyproject.toml +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/setup.cfg +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/__init__.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_claude_code.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_cli.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_console.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_download.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_gh.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_harness.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_mcp.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_opencode.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_plantuml.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_sysmon.py +0 -0
- {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_update.py +0 -0
- {evo_cli-0.12.1 → 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.12.
|
|
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
|
|
@@ -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:
|
|
@@ -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:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.12.2
|
|
@@ -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
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import secrets
|
|
3
|
+
import threading
|
|
4
|
+
import urllib.parse
|
|
5
|
+
import urllib.request
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
|
|
9
|
+
from evo_cli.credentials.google_oauth import TOKEN_URL
|
|
10
|
+
from evo_cli.credentials.registry import dig
|
|
11
|
+
from evo_cli.credentials.store import TZ, CredentialError, write_entry
|
|
12
|
+
|
|
13
|
+
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
14
|
+
|
|
15
|
+
SUCCESS_PAGE = b"""<!doctype html><meta charset="utf-8">
|
|
16
|
+
<title>evo cred auth</title>
|
|
17
|
+
<body style="font-family:system-ui;padding:3rem;max-width:32rem">
|
|
18
|
+
<h2>Authorised</h2><p>The token was written to your credential store. You can close this tab.</p>
|
|
19
|
+
</body>"""
|
|
20
|
+
|
|
21
|
+
FAILURE_PAGE = b"""<!doctype html><meta charset="utf-8">
|
|
22
|
+
<title>evo cred auth</title>
|
|
23
|
+
<body style="font-family:system-ui;padding:3rem;max-width:32rem">
|
|
24
|
+
<h2>Authorisation failed</h2><p>Check the terminal for details.</p>
|
|
25
|
+
</body>"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def client_from_secrets_file(path):
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
31
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
32
|
+
raise CredentialError(f"cannot read OAuth client file {path}: {exc}") from exc
|
|
33
|
+
|
|
34
|
+
block = data.get("installed") or data.get("web") or data
|
|
35
|
+
client_id = block.get("client_id")
|
|
36
|
+
client_secret = block.get("client_secret")
|
|
37
|
+
if not client_id or not client_secret:
|
|
38
|
+
raise CredentialError(
|
|
39
|
+
f"{path} has no client_id/client_secret. Download the JSON from "
|
|
40
|
+
"Console -> APIs & Services -> Credentials -> your OAuth client."
|
|
41
|
+
)
|
|
42
|
+
return client_id, client_secret
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def client_from_entry(entry):
|
|
46
|
+
oauth = entry.get("oauth") or {}
|
|
47
|
+
source = dig(entry.get("flat", {}), oauth.get("client_from") or []) or {}
|
|
48
|
+
return source.get("client_id"), source.get("client_secret")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_auth_url(client_id, redirect_uri, scopes, state):
|
|
52
|
+
params = {
|
|
53
|
+
"client_id": client_id,
|
|
54
|
+
"redirect_uri": redirect_uri,
|
|
55
|
+
"response_type": "code",
|
|
56
|
+
"scope": " ".join(scopes),
|
|
57
|
+
"access_type": "offline",
|
|
58
|
+
"prompt": "consent",
|
|
59
|
+
"state": state,
|
|
60
|
+
}
|
|
61
|
+
return f"{AUTH_URL}?{urllib.parse.urlencode(params)}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def exchange_code(client_id, client_secret, code, redirect_uri):
|
|
65
|
+
payload = urllib.parse.urlencode(
|
|
66
|
+
{
|
|
67
|
+
"client_id": client_id,
|
|
68
|
+
"client_secret": client_secret,
|
|
69
|
+
"code": code,
|
|
70
|
+
"grant_type": "authorization_code",
|
|
71
|
+
"redirect_uri": redirect_uri,
|
|
72
|
+
}
|
|
73
|
+
).encode("utf-8")
|
|
74
|
+
request = urllib.request.Request(TOKEN_URL, data=payload, method="POST")
|
|
75
|
+
request.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
76
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
77
|
+
return json.loads(response.read().decode("utf-8"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
81
|
+
result = None
|
|
82
|
+
|
|
83
|
+
def do_GET(self):
|
|
84
|
+
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
85
|
+
code = query.get("code", [None])[0]
|
|
86
|
+
state = query.get("state", [None])[0]
|
|
87
|
+
error = query.get("error", [None])[0]
|
|
88
|
+
|
|
89
|
+
ok = bool(code) and not error
|
|
90
|
+
self.send_response(200)
|
|
91
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
92
|
+
self.end_headers()
|
|
93
|
+
self.wfile.write(SUCCESS_PAGE if ok else FAILURE_PAGE)
|
|
94
|
+
type(self).result = {"code": code, "state": state, "error": error}
|
|
95
|
+
|
|
96
|
+
def log_message(self, *_args):
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def capture_code(timeout=300):
|
|
101
|
+
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
102
|
+
server.timeout = timeout
|
|
103
|
+
_Handler.result = None
|
|
104
|
+
redirect_uri = f"http://127.0.0.1:{server.server_port}"
|
|
105
|
+
|
|
106
|
+
def serve():
|
|
107
|
+
while _Handler.result is None:
|
|
108
|
+
server.handle_request()
|
|
109
|
+
|
|
110
|
+
thread = threading.Thread(target=serve, daemon=True)
|
|
111
|
+
return server, redirect_uri, thread
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def store_tokens(path, entry, body, scopes):
|
|
115
|
+
access = body.get("access_token")
|
|
116
|
+
refresh = body.get("refresh_token")
|
|
117
|
+
if not access:
|
|
118
|
+
raise CredentialError("no access_token in Google's response")
|
|
119
|
+
if not refresh:
|
|
120
|
+
raise CredentialError(
|
|
121
|
+
"Google returned no refresh_token. Revoke the app at "
|
|
122
|
+
"https://myaccount.google.com/permissions and authorise again."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
oauth = entry["oauth"]
|
|
126
|
+
flat = entry.setdefault("flat", {})
|
|
127
|
+
cursor = flat
|
|
128
|
+
for part in oauth["container"][:-1]:
|
|
129
|
+
cursor = cursor.setdefault(part, {})
|
|
130
|
+
container = cursor.setdefault(oauth["container"][-1], {})
|
|
131
|
+
|
|
132
|
+
expiry = (datetime.now(TZ) + timedelta(seconds=int(body.get("expires_in", 3600)))).isoformat()
|
|
133
|
+
container[oauth["access_field"]] = access
|
|
134
|
+
container["refresh_token"] = refresh
|
|
135
|
+
container[oauth["expiry_field"]] = expiry
|
|
136
|
+
container["scopes"] = list(scopes)
|
|
137
|
+
|
|
138
|
+
entry["expiry"] = expiry
|
|
139
|
+
entry["status"] = "active"
|
|
140
|
+
entry["last_rotated"] = datetime.now(TZ).isoformat()
|
|
141
|
+
write_entry(path, entry)
|
|
142
|
+
return expiry
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def new_state():
|
|
146
|
+
return secrets.token_urlsafe(24)
|
|
@@ -155,7 +155,11 @@ SPECS = [
|
|
|
155
155
|
"category": "google-oauth",
|
|
156
156
|
"type": "oauth_token",
|
|
157
157
|
"lifetime": "access ~1h, refresh until revoked",
|
|
158
|
-
"description":
|
|
158
|
+
"description": (
|
|
159
|
+
"rclone Google Drive remote: OAuth token + root folder. Being replaced by google_drive; "
|
|
160
|
+
"still active because red-life/packages/core/src/teaching/drive.ts reads it. Do not mark "
|
|
161
|
+
"deprecated until that consumer migrates - compile would drop the key and break it."
|
|
162
|
+
),
|
|
159
163
|
"rotate": "evo cred refresh --service rclone (or rclone config reconnect)",
|
|
160
164
|
"oauth": {
|
|
161
165
|
"container": ["rclone", "token"],
|
|
@@ -189,13 +193,14 @@ SPECS = [
|
|
|
189
193
|
"category": "google-oauth",
|
|
190
194
|
"type": "oauth_token",
|
|
191
195
|
"lifetime": "access ~1h, refresh until revoked",
|
|
192
|
-
"description": "Google Drive OAuth token (evo gdrive)",
|
|
193
|
-
"rotate": "evo cred refresh --service google-drive",
|
|
196
|
+
"description": "Google Drive OAuth token, project omelet-f0b89 (evo gdrive, life teach)",
|
|
197
|
+
"rotate": "evo cred refresh --service google-drive; first consent: evo cred auth --service google-drive",
|
|
194
198
|
"oauth": {
|
|
195
199
|
"container": ["google_drive", "token"],
|
|
196
200
|
"access_field": "token",
|
|
197
201
|
"expiry_field": "expiry",
|
|
198
202
|
"client_from": ["google_drive", "token"],
|
|
203
|
+
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
|
|
199
204
|
},
|
|
200
205
|
"keys": ["google_drive"],
|
|
201
206
|
},
|
|
@@ -322,7 +327,7 @@ SPECS = [
|
|
|
322
327
|
"category": "firebase",
|
|
323
328
|
"type": "service_account",
|
|
324
329
|
"lifetime": "stable",
|
|
325
|
-
"description": "Firebase Admin SDK service account (project
|
|
330
|
+
"description": "Firebase Admin SDK service account (project fu-instructor)",
|
|
326
331
|
"rotate": "Firebase console -> Service accounts -> generate new key",
|
|
327
332
|
"keys": ["firebase_admin_sdk"],
|
|
328
333
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo_cli
|
|
3
|
-
Version: 0.12.
|
|
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
|
|
@@ -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:
|
|
@@ -43,6 +43,7 @@ evo_cli/credentials/__init__.py
|
|
|
43
43
|
evo_cli/credentials/doctor.py
|
|
44
44
|
evo_cli/credentials/google_oauth.py
|
|
45
45
|
evo_cli/credentials/migrate.py
|
|
46
|
+
evo_cli/credentials/oauth_flow.py
|
|
46
47
|
evo_cli/credentials/registry.py
|
|
47
48
|
evo_cli/credentials/store.py
|
|
48
49
|
evo_cli/credentials/sync.py
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from urllib.parse import parse_qs, urlparse
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
4
5
|
from click.testing import CliRunner
|
|
5
6
|
|
|
6
7
|
from evo_cli.cli import cli
|
|
8
|
+
from evo_cli.commands import gdrive
|
|
7
9
|
from evo_cli.credentials import doctor as doctor_module
|
|
8
|
-
from evo_cli.credentials import migrate, registry
|
|
10
|
+
from evo_cli.credentials import migrate, oauth_flow, registry
|
|
9
11
|
from evo_cli.credentials.store import compile_flat, get_value, set_value
|
|
10
12
|
|
|
11
13
|
|
|
@@ -28,7 +30,7 @@ def _write(store, rel, entry):
|
|
|
28
30
|
|
|
29
31
|
def test_cred_command_is_registered():
|
|
30
32
|
assert "cred" in cli.commands
|
|
31
|
-
for name in ("get", "list", "doctor", "compile", "add", "refresh", "migrate", "sync", "path"):
|
|
33
|
+
for name in ("get", "list", "doctor", "compile", "add", "auth", "refresh", "migrate", "sync", "path"):
|
|
32
34
|
assert name in cli.commands["cred"].commands
|
|
33
35
|
assert set(cli.commands["cred"].commands["sync"].commands) == {"push", "pull"}
|
|
34
36
|
|
|
@@ -170,7 +172,11 @@ def test_doctor_flags_expired_oauth_and_exits_nonzero(store):
|
|
|
170
172
|
|
|
171
173
|
|
|
172
174
|
def test_secret_preview_masks_and_never_shows_full_value(store):
|
|
173
|
-
_write(
|
|
175
|
+
_write(
|
|
176
|
+
store,
|
|
177
|
+
"ai/openai.json",
|
|
178
|
+
{"id": "openai", "service": "OpenAI", "flat": {"openai_api_key": "sk-abcdefghijklmnop"}},
|
|
179
|
+
)
|
|
174
180
|
_write(store, "tools/short.json", {"id": "short", "service": "Short", "flat": {"short_token": "tiny"}})
|
|
175
181
|
|
|
176
182
|
previews = {row["service"]: row["secret"] for row in doctor_module.scan()}
|
|
@@ -261,8 +267,12 @@ def test_refresh_writes_new_token_and_recompiles(store, monkeypatch):
|
|
|
261
267
|
{
|
|
262
268
|
"id": "gmail",
|
|
263
269
|
"service": "Gmail",
|
|
264
|
-
"oauth": {
|
|
265
|
-
|
|
270
|
+
"oauth": {
|
|
271
|
+
"container": ["gmail", "token"],
|
|
272
|
+
"access_field": "token",
|
|
273
|
+
"expiry_field": "expiry",
|
|
274
|
+
"client_from": ["gmail", "token"],
|
|
275
|
+
},
|
|
266
276
|
"flat": {
|
|
267
277
|
"gmail": {
|
|
268
278
|
"token": {
|
|
@@ -293,9 +303,15 @@ def test_refresh_dry_run_does_not_write(store, monkeypatch):
|
|
|
293
303
|
"google-oauth/gmail.json",
|
|
294
304
|
{
|
|
295
305
|
"id": "gmail",
|
|
296
|
-
"oauth": {
|
|
297
|
-
|
|
298
|
-
|
|
306
|
+
"oauth": {
|
|
307
|
+
"container": ["gmail", "token"],
|
|
308
|
+
"access_field": "token",
|
|
309
|
+
"expiry_field": "expiry",
|
|
310
|
+
"client_from": ["gmail", "token"],
|
|
311
|
+
},
|
|
312
|
+
"flat": {
|
|
313
|
+
"gmail": {"token": {"token": "old", "refresh_token": "rt", "client_id": "c", "client_secret": "s"}}
|
|
314
|
+
},
|
|
299
315
|
},
|
|
300
316
|
)
|
|
301
317
|
|
|
@@ -316,8 +332,12 @@ def test_refresh_skips_entry_without_refresh_token(store):
|
|
|
316
332
|
"google-oauth/gmail.json",
|
|
317
333
|
{
|
|
318
334
|
"id": "gmail",
|
|
319
|
-
"oauth": {
|
|
320
|
-
|
|
335
|
+
"oauth": {
|
|
336
|
+
"container": ["gmail", "token"],
|
|
337
|
+
"access_field": "token",
|
|
338
|
+
"expiry_field": "expiry",
|
|
339
|
+
"client_from": ["gmail", "token"],
|
|
340
|
+
},
|
|
321
341
|
"flat": {"gmail": {"token": {"token": "old"}}},
|
|
322
342
|
},
|
|
323
343
|
)
|
|
@@ -340,3 +360,129 @@ def test_sync_requires_repo_env(store, monkeypatch):
|
|
|
340
360
|
def test_registry_maps_contract_keys_to_specs():
|
|
341
361
|
for key in ("rclone", "gmail", "google_drive", "google_calendar", "facebook", "openai_api_key"):
|
|
342
362
|
assert registry.spec_for_flat_key(key) is not None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_auth_command_is_registered():
|
|
366
|
+
assert "auth" in cli.commands["cred"].commands
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_build_auth_url_requests_offline_consent():
|
|
370
|
+
url = oauth_flow.build_auth_url("cid", "http://127.0.0.1:9999", ["scope/a", "scope/b"], "st8")
|
|
371
|
+
|
|
372
|
+
assert url.startswith(oauth_flow.AUTH_URL + "?")
|
|
373
|
+
query = parse_qs(urlparse(url).query)
|
|
374
|
+
assert query["client_id"] == ["cid"]
|
|
375
|
+
assert query["redirect_uri"] == ["http://127.0.0.1:9999"]
|
|
376
|
+
assert query["scope"] == ["scope/a scope/b"]
|
|
377
|
+
assert query["access_type"] == ["offline"]
|
|
378
|
+
assert query["prompt"] == ["consent"]
|
|
379
|
+
assert query["state"] == ["st8"]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_client_from_secrets_file_reads_installed_block(tmp_path):
|
|
383
|
+
path = tmp_path / "client.json"
|
|
384
|
+
path.write_text(json.dumps({"installed": {"client_id": "cid", "client_secret": "cs"}}), encoding="utf-8")
|
|
385
|
+
|
|
386
|
+
assert oauth_flow.client_from_secrets_file(path) == ("cid", "cs")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_client_from_secrets_file_rejects_a_file_without_a_client(tmp_path):
|
|
390
|
+
path = tmp_path / "client.json"
|
|
391
|
+
path.write_text(json.dumps({"nothing": True}), encoding="utf-8")
|
|
392
|
+
|
|
393
|
+
with pytest.raises(Exception) as excinfo:
|
|
394
|
+
oauth_flow.client_from_secrets_file(path)
|
|
395
|
+
|
|
396
|
+
assert "no client_id/client_secret" in str(excinfo.value)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_store_tokens_refuses_a_response_without_refresh_token(store):
|
|
400
|
+
entry = {
|
|
401
|
+
"id": "google_drive",
|
|
402
|
+
"oauth": {"container": ["google_drive", "token"], "access_field": "token", "expiry_field": "expiry"},
|
|
403
|
+
"flat": {},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
with pytest.raises(Exception) as excinfo:
|
|
407
|
+
oauth_flow.store_tokens(
|
|
408
|
+
store["dir"] / "google-oauth" / "google-drive.json",
|
|
409
|
+
entry,
|
|
410
|
+
{"access_token": "at", "expires_in": 3600},
|
|
411
|
+
["s"],
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
assert "no refresh_token" in str(excinfo.value)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_store_tokens_writes_token_refresh_and_scopes(store):
|
|
418
|
+
path = store["dir"] / "google-oauth" / "google-drive.json"
|
|
419
|
+
entry = {
|
|
420
|
+
"id": "google_drive",
|
|
421
|
+
"oauth": {"container": ["google_drive", "token"], "access_field": "token", "expiry_field": "expiry"},
|
|
422
|
+
"flat": {},
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
oauth_flow.store_tokens(
|
|
426
|
+
path,
|
|
427
|
+
entry,
|
|
428
|
+
{"access_token": "at", "refresh_token": "rt", "expires_in": 3600},
|
|
429
|
+
["https://www.googleapis.com/auth/drive.readonly"],
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
written = json.loads(path.read_text(encoding="utf-8"))["flat"]["google_drive"]["token"]
|
|
433
|
+
assert written["token"] == "at"
|
|
434
|
+
assert written["refresh_token"] == "rt"
|
|
435
|
+
assert written["scopes"] == ["https://www.googleapis.com/auth/drive.readonly"]
|
|
436
|
+
assert written["expiry"]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_gdrive_reads_the_google_drive_entry_not_rclone(store):
|
|
440
|
+
_write(
|
|
441
|
+
store,
|
|
442
|
+
"google-oauth/rclone.json",
|
|
443
|
+
{
|
|
444
|
+
"id": "rclone",
|
|
445
|
+
"oauth": {
|
|
446
|
+
"container": ["rclone", "token"],
|
|
447
|
+
"access_field": "access_token",
|
|
448
|
+
"expiry_field": "expiry",
|
|
449
|
+
"client_from": ["rclone"],
|
|
450
|
+
},
|
|
451
|
+
"flat": {"rclone": {"token": {"access_token": "rclone-token", "expiry": "2099-01-01T00:00:00+00:00"}}},
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
_write(
|
|
455
|
+
store,
|
|
456
|
+
"google-oauth/google-drive.json",
|
|
457
|
+
{
|
|
458
|
+
"id": "google_drive",
|
|
459
|
+
"oauth": {
|
|
460
|
+
"container": ["google_drive", "token"],
|
|
461
|
+
"access_field": "token",
|
|
462
|
+
"expiry_field": "expiry",
|
|
463
|
+
"client_from": ["google_drive", "token"],
|
|
464
|
+
},
|
|
465
|
+
"flat": {"google_drive": {"token": {"token": "drive-token", "expiry": "2099-01-01T00:00:00+00:00"}}},
|
|
466
|
+
},
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
assert gdrive.access_token() == "drive-token"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def test_gdrive_without_a_drive_entry_points_at_the_auth_command(store):
|
|
473
|
+
with pytest.raises(Exception) as excinfo:
|
|
474
|
+
gdrive.access_token()
|
|
475
|
+
|
|
476
|
+
assert "evo cred auth --service google-drive" in str(excinfo.value)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_registry_drive_scope_is_readonly_not_full_drive():
|
|
480
|
+
spec = registry.spec_for_flat_key("google_drive")
|
|
481
|
+
|
|
482
|
+
assert spec["oauth"]["scopes"] == ["https://www.googleapis.com/auth/drive.readonly"]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def test_rclone_is_not_deprecated_while_red_life_still_reads_it():
|
|
486
|
+
spec = registry.spec_for_flat_key("rclone")
|
|
487
|
+
|
|
488
|
+
assert spec.get("status") != "deprecated"
|
evo_cli-0.12.1/evo_cli/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.12.1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|