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