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.
Files changed (67) hide show
  1. {evo_cli-0.12.1 → evo_cli-0.12.2}/PKG-INFO +12 -1
  2. {evo_cli-0.12.1 → evo_cli-0.12.2}/README.md +11 -0
  3. evo_cli-0.12.2/evo_cli/VERSION +1 -0
  4. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/cred.py +86 -1
  5. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/gdrive.py +30 -70
  6. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/localproxy.py +0 -1
  7. evo_cli-0.12.2/evo_cli/credentials/oauth_flow.py +146 -0
  8. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/registry.py +9 -4
  9. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/PKG-INFO +12 -1
  10. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/SOURCES.txt +1 -0
  11. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_cred.py +156 -10
  12. evo_cli-0.12.1/evo_cli/VERSION +0 -1
  13. {evo_cli-0.12.1 → evo_cli-0.12.2}/Containerfile +0 -0
  14. {evo_cli-0.12.1 → evo_cli-0.12.2}/HISTORY.md +0 -0
  15. {evo_cli-0.12.1 → evo_cli-0.12.2}/LICENSE +0 -0
  16. {evo_cli-0.12.1 → evo_cli-0.12.2}/MANIFEST.in +0 -0
  17. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/__init__.py +0 -0
  18. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/__main__.py +0 -0
  19. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/base.py +0 -0
  20. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/cli.py +0 -0
  21. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/__init__.py +0 -0
  22. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/claude_code.py +0 -0
  23. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/cloudflare.py +0 -0
  24. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/download.py +0 -0
  25. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/fix_claude.py +0 -0
  26. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/gh.py +0 -0
  27. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/harness.py +0 -0
  28. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/hwid.py +0 -0
  29. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/hwid_reset.py +0 -0
  30. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/mcp.py +0 -0
  31. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/miniconda.py +0 -0
  32. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/netcheck.py +0 -0
  33. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/opencode.py +0 -0
  34. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/plantuml.py +0 -0
  35. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/site2s.py +0 -0
  36. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/ssh.py +0 -0
  37. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/sysmon.py +0 -0
  38. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/update.py +0 -0
  39. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/commands/wifi.py +0 -0
  40. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/console.py +0 -0
  41. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/__init__.py +0 -0
  42. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/doctor.py +0 -0
  43. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/google_oauth.py +0 -0
  44. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/migrate.py +0 -0
  45. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/store.py +0 -0
  46. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/credentials/sync.py +0 -0
  47. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli/mcp_registry.py +0 -0
  48. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/dependency_links.txt +0 -0
  49. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/entry_points.txt +0 -0
  50. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/requires.txt +0 -0
  51. {evo_cli-0.12.1 → evo_cli-0.12.2}/evo_cli.egg-info/top_level.txt +0 -0
  52. {evo_cli-0.12.1 → evo_cli-0.12.2}/pyproject.toml +0 -0
  53. {evo_cli-0.12.1 → evo_cli-0.12.2}/setup.cfg +0 -0
  54. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/__init__.py +0 -0
  55. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_claude_code.py +0 -0
  56. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_cli.py +0 -0
  57. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_console.py +0 -0
  58. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_download.py +0 -0
  59. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_fix_claude.py +0 -0
  60. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_gh.py +0 -0
  61. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_harness.py +0 -0
  62. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_mcp.py +0 -0
  63. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_opencode.py +0 -0
  64. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_plantuml.py +0 -0
  65. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_sysmon.py +0 -0
  66. {evo_cli-0.12.1 → evo_cli-0.12.2}/tests/test_update.py +0 -0
  67. {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.1
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, 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):
@@ -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": "rclone Google Drive remote: OAuth token + root folder",
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 omelet-f0b89)",
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.1
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(store, "ai/openai.json", {"id": "openai", "service": "OpenAI", "flat": {"openai_api_key": "sk-abcdefghijklmnop"}})
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": {"container": ["gmail", "token"], "access_field": "token", "expiry_field": "expiry",
265
- "client_from": ["gmail", "token"]},
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": {"container": ["gmail", "token"], "access_field": "token", "expiry_field": "expiry",
297
- "client_from": ["gmail", "token"]},
298
- "flat": {"gmail": {"token": {"token": "old", "refresh_token": "rt", "client_id": "c", "client_secret": "s"}}},
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": {"container": ["gmail", "token"], "access_field": "token", "expiry_field": "expiry",
320
- "client_from": ["gmail", "token"]},
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"
@@ -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