evo-cli 0.11.4__tar.gz → 0.12.1__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.1}/PKG-INFO +33 -1
- {evo_cli-0.11.4 → evo_cli-0.12.1}/README.md +32 -0
- evo_cli-0.12.1/evo_cli/VERSION +1 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/cli.py +4 -0
- evo_cli-0.12.1/evo_cli/commands/cred.py +279 -0
- evo_cli-0.12.1/evo_cli/commands/update.py +291 -0
- evo_cli-0.12.1/evo_cli/credentials/__init__.py +27 -0
- evo_cli-0.12.1/evo_cli/credentials/doctor.py +73 -0
- evo_cli-0.12.1/evo_cli/credentials/google_oauth.py +101 -0
- evo_cli-0.12.1/evo_cli/credentials/migrate.py +147 -0
- evo_cli-0.12.1/evo_cli/credentials/registry.py +406 -0
- evo_cli-0.12.1/evo_cli/credentials/store.py +178 -0
- evo_cli-0.12.1/evo_cli/credentials/sync.py +149 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/PKG-INFO +33 -1
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/SOURCES.txt +11 -0
- evo_cli-0.12.1/tests/test_cred.py +342 -0
- evo_cli-0.12.1/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.1}/Containerfile +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/HISTORY.md +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/LICENSE +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/MANIFEST.in +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/__init__.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/__main__.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/base.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/harness.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/console.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/pyproject.toml +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/setup.cfg +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/__init__.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_claude_code.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_cli.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_console.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_download.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_gh.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_harness.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_mcp.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_opencode.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_plantuml.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_sysmon.py +0 -0
- {evo_cli-0.11.4 → evo_cli-0.12.1}/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.1
|
|
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,38 @@ 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 refresh --all # refresh Google OAuth access tokens
|
|
128
|
+
evo cred compile # rebuild ~/.omelet.json from the folder
|
|
129
|
+
evo cred sync push # push the folder to a private GitHub repo via gh
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Read a value into the environment without it reaching your shell history:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
eval "$(evo cred get --export OPENAI_API_KEY openai_api_key)"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Configuration is env-driven, with no personal defaults baked in:
|
|
139
|
+
|
|
140
|
+
- `OMELET_DIR` - store root (default `~/.omelet.d`; the folder is `$OMELET_DIR/credentials`)
|
|
141
|
+
- `OMELET_CONFIG` - compiled flat file (default `~/.omelet.json`)
|
|
142
|
+
- `OMELET_SYNC_REPO` - required for `sync`; a **private** GitHub repo as `owner/repo`
|
|
143
|
+
- `OMELET_SYNC_DIR` - folder name inside the sync repo (default `credentials`)
|
|
144
|
+
- `RCLONE_DRIVE_CLIENT_ID` / `RCLONE_DRIVE_CLIENT_SECRET` - OAuth client for the rclone refresh
|
|
145
|
+
|
|
146
|
+
`evo cred sync push` refuses to push to a repo whose visibility is not `PRIVATE`. Never echo a value
|
|
147
|
+
into a shared terminal or a log; `doctor` and `list` only ever print a masked preview.
|
|
148
|
+
|
|
117
149
|
#### Harness Repositories
|
|
118
150
|
|
|
119
151
|
Fast-forward every available repository declared in a harness manifest:
|
|
@@ -86,6 +86,38 @@ 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 refresh --all # refresh Google OAuth access tokens
|
|
100
|
+
evo cred compile # rebuild ~/.omelet.json from the folder
|
|
101
|
+
evo cred sync push # push the folder to a private GitHub repo via gh
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Read a value into the environment without it reaching your shell history:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
eval "$(evo cred get --export OPENAI_API_KEY openai_api_key)"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Configuration is env-driven, with no personal defaults baked in:
|
|
111
|
+
|
|
112
|
+
- `OMELET_DIR` - store root (default `~/.omelet.d`; the folder is `$OMELET_DIR/credentials`)
|
|
113
|
+
- `OMELET_CONFIG` - compiled flat file (default `~/.omelet.json`)
|
|
114
|
+
- `OMELET_SYNC_REPO` - required for `sync`; a **private** GitHub repo as `owner/repo`
|
|
115
|
+
- `OMELET_SYNC_DIR` - folder name inside the sync repo (default `credentials`)
|
|
116
|
+
- `RCLONE_DRIVE_CLIENT_ID` / `RCLONE_DRIVE_CLIENT_SECRET` - OAuth client for the rclone refresh
|
|
117
|
+
|
|
118
|
+
`evo cred sync push` refuses to push to a repo whose visibility is not `PRIVATE`. Never echo a value
|
|
119
|
+
into a shared terminal or a log; `doctor` and `list` only ever print a masked preview.
|
|
120
|
+
|
|
89
121
|
#### Harness Repositories
|
|
90
122
|
|
|
91
123
|
Fast-forward every available repository declared in a harness manifest:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.12.1
|
|
@@ -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,279 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import rich_click as click
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from evo_cli.console import console, info, success, warning
|
|
10
|
+
from evo_cli.credentials import doctor as doctor_module
|
|
11
|
+
from evo_cli.credentials import google_oauth, migrate, sync
|
|
12
|
+
from evo_cli.credentials.registry import config_path, credentials_dir
|
|
13
|
+
from evo_cli.credentials.store import (
|
|
14
|
+
CredentialError,
|
|
15
|
+
compile_flat,
|
|
16
|
+
get_value,
|
|
17
|
+
read_flat,
|
|
18
|
+
relative_to_store,
|
|
19
|
+
require_folder,
|
|
20
|
+
set_value,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
HEALTH_STYLE = {
|
|
24
|
+
"ok": "green",
|
|
25
|
+
"expiring": "yellow",
|
|
26
|
+
"EXPIRED": "bold red",
|
|
27
|
+
"deprecated": "dim",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _guard(func, *args, **kwargs):
|
|
32
|
+
try:
|
|
33
|
+
return func(*args, **kwargs)
|
|
34
|
+
except CredentialError as exc:
|
|
35
|
+
raise click.ClickException(str(exc)) from exc
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
raise click.ClickException(str(exc)) from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _render(rows):
|
|
41
|
+
table = Table(show_header=True, header_style="accent", box=None, pad_edge=False)
|
|
42
|
+
for column in ("FILE", "SERVICE", "TYPE", "HEALTH", "SECRET", "EXPIRY"):
|
|
43
|
+
table.add_column(column, overflow="fold")
|
|
44
|
+
for row in rows:
|
|
45
|
+
table.add_row(
|
|
46
|
+
row["file"],
|
|
47
|
+
row["service"],
|
|
48
|
+
row["type"],
|
|
49
|
+
f"[{HEALTH_STYLE.get(row['health'], 'white')}]{row['health']}[/]",
|
|
50
|
+
row["secret"],
|
|
51
|
+
row["expiry"],
|
|
52
|
+
)
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@click.group("cred", help="Read, refresh, and sync the omelet credential store.")
|
|
57
|
+
def cred_group():
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cred_group.command("get", help="Print one credential by dotted key path.")
|
|
62
|
+
@click.argument("key_path")
|
|
63
|
+
@click.option("--export", "export_var", metavar="VAR", help="Print a shell export statement instead of the raw value.")
|
|
64
|
+
def get(key_path, export_var):
|
|
65
|
+
value = _guard(get_value, key_path)
|
|
66
|
+
|
|
67
|
+
if isinstance(value, (dict, list)):
|
|
68
|
+
sys.stdout.write(json.dumps(value, ensure_ascii=False) + "\n")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
text = str(value)
|
|
72
|
+
if export_var:
|
|
73
|
+
escaped = text.replace("'", "'\\''")
|
|
74
|
+
sys.stdout.write(f"export {export_var}='{escaped}'\n")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
sys.stdout.write(text)
|
|
78
|
+
if sys.stdout.isatty():
|
|
79
|
+
sys.stdout.write("\n")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@cred_group.command("list", help="List every credential in the store with its health.")
|
|
83
|
+
def list_credentials():
|
|
84
|
+
_guard(require_folder)
|
|
85
|
+
rows = _guard(doctor_module.scan)
|
|
86
|
+
_render(rows)
|
|
87
|
+
console.print(f"\n{len(rows)} entries in {credentials_dir()}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@cred_group.command("doctor", help="Report health and expiry; exit 1 if anything is expired.")
|
|
91
|
+
def doctor():
|
|
92
|
+
_guard(require_folder)
|
|
93
|
+
rows = _guard(doctor_module.scan)
|
|
94
|
+
_render(rows)
|
|
95
|
+
expired = [row for row in rows if row["health"] == "EXPIRED"]
|
|
96
|
+
console.print(f"\n{len(rows)} entries in {credentials_dir()}")
|
|
97
|
+
if expired:
|
|
98
|
+
warning(f"{len(expired)} EXPIRED - run: evo cred refresh --all")
|
|
99
|
+
raise click.exceptions.Exit(1)
|
|
100
|
+
success("all credentials healthy")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@cred_group.command("compile", help="Rebuild the flat config from the credentials folder.")
|
|
104
|
+
@click.option("--include-legacy", is_flag=True, help="Also merge entries marked deprecated.")
|
|
105
|
+
def compile_command(include_legacy):
|
|
106
|
+
count, skipped, target = _guard(compile_flat, include_legacy=include_legacy)
|
|
107
|
+
message = f"compiled {count} entries -> {target}"
|
|
108
|
+
if skipped:
|
|
109
|
+
message += f" (skipped {len(skipped)} deprecated: {', '.join(skipped)})"
|
|
110
|
+
success(message)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@cred_group.command("add", help="Add or update a credential, then recompile the flat config.")
|
|
114
|
+
@click.argument("key_path")
|
|
115
|
+
@click.option("--from-stdin", is_flag=True, help="Read the value from stdin.")
|
|
116
|
+
@click.option("--from-env", metavar="VAR", help="Read the value from an environment variable.")
|
|
117
|
+
@click.option("--value", "inline_value", help="Inline value. Avoid: leaks to shell history.")
|
|
118
|
+
@click.option("--json", "parse_json", is_flag=True, help="Parse the value as JSON (bool/number/object/array).")
|
|
119
|
+
def add(key_path, from_stdin, from_env, inline_value, parse_json):
|
|
120
|
+
sources = [bool(from_stdin), bool(from_env), inline_value is not None]
|
|
121
|
+
if sum(sources) > 1:
|
|
122
|
+
raise click.UsageError("Use only one of --from-stdin, --from-env, --value.")
|
|
123
|
+
|
|
124
|
+
if from_stdin:
|
|
125
|
+
value = sys.stdin.read().rstrip("\n")
|
|
126
|
+
elif from_env:
|
|
127
|
+
if from_env not in os.environ:
|
|
128
|
+
raise click.ClickException(f"env var {from_env} not set")
|
|
129
|
+
value = os.environ[from_env]
|
|
130
|
+
elif inline_value is not None:
|
|
131
|
+
value = inline_value
|
|
132
|
+
else:
|
|
133
|
+
value = getpass.getpass(f"value for {key_path} (no echo): ")
|
|
134
|
+
if not value:
|
|
135
|
+
raise click.ClickException("empty value, aborting")
|
|
136
|
+
|
|
137
|
+
if parse_json:
|
|
138
|
+
try:
|
|
139
|
+
value = json.loads(value)
|
|
140
|
+
except json.JSONDecodeError as exc:
|
|
141
|
+
raise click.ClickException(f"--json: failed to parse value: {exc}") from exc
|
|
142
|
+
|
|
143
|
+
path, existed = _guard(set_value, key_path, value)
|
|
144
|
+
count, _, target = _guard(compile_flat)
|
|
145
|
+
verb = "updated" if existed else "added"
|
|
146
|
+
success(f"{verb} {key_path} in {relative_to_store(path)}, recompiled {target} ({count} entries)")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@cred_group.command("refresh", help="Refresh Google OAuth access tokens and recompile.")
|
|
150
|
+
@click.option("--all", "refresh_all", is_flag=True, help="Refresh every refreshable OAuth entry.")
|
|
151
|
+
@click.option(
|
|
152
|
+
"--service",
|
|
153
|
+
type=click.Choice(sorted(google_oauth.SERVICE_ALIASES)),
|
|
154
|
+
help="Refresh a single service.",
|
|
155
|
+
)
|
|
156
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be refreshed without calling Google.")
|
|
157
|
+
def refresh(refresh_all, service, dry_run):
|
|
158
|
+
if refresh_all == bool(service):
|
|
159
|
+
raise click.UsageError("Pass exactly one of --all or --service.")
|
|
160
|
+
|
|
161
|
+
_guard(require_folder)
|
|
162
|
+
target = "*" if refresh_all else google_oauth.SERVICE_ALIASES[service]
|
|
163
|
+
entries = _guard(google_oauth.select_entries, target)
|
|
164
|
+
if not entries:
|
|
165
|
+
raise click.ClickException("no matching refreshable oauth entries")
|
|
166
|
+
|
|
167
|
+
failures = 0
|
|
168
|
+
refreshed = 0
|
|
169
|
+
for path, entry in entries:
|
|
170
|
+
name = entry.get("id", path.name)
|
|
171
|
+
creds, err = google_oauth.resolve_creds(entry)
|
|
172
|
+
if err:
|
|
173
|
+
warning(f"{name}: skip ({err})")
|
|
174
|
+
failures += 1
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
if dry_run:
|
|
178
|
+
info(
|
|
179
|
+
f"{name}: would POST {google_oauth.TOKEN_URL} "
|
|
180
|
+
f"refresh_token={creds['refresh_token'][:10]}... client_id={creds['client_id'][:12]}..."
|
|
181
|
+
)
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
expiry = google_oauth.refresh_entry(path, entry, creds)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
warning(f"{name}: {google_oauth.describe_error(exc)}")
|
|
188
|
+
failures += 1
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
refreshed += 1
|
|
192
|
+
success(f"{name}: refreshed, valid until {expiry}")
|
|
193
|
+
|
|
194
|
+
if refreshed:
|
|
195
|
+
count, _, target_path = _guard(compile_flat)
|
|
196
|
+
info(f"recompiled {target_path} ({count} entries)")
|
|
197
|
+
|
|
198
|
+
if failures:
|
|
199
|
+
raise click.exceptions.Exit(1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@cred_group.command("migrate", help="Split a flat omelet.json into the per-service credentials folder.")
|
|
203
|
+
@click.option("--source", "source", type=click.Path(), help="Flat file to migrate. Defaults to the compiled config.")
|
|
204
|
+
@click.option("--dry-run", is_flag=True, help="Show the plan, write nothing.")
|
|
205
|
+
@click.option("--merge", is_flag=True, help="Migrate into an existing folder, adding only keys not already present.")
|
|
206
|
+
@click.option("--strict", is_flag=True, help="Abort on keys not mapped in the registry instead of routing to misc/.")
|
|
207
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing non-empty folder.")
|
|
208
|
+
def migrate_command(source, dry_run, merge, strict, force):
|
|
209
|
+
result = _guard(migrate.plan, source or config_path(), strict=strict, merge=merge)
|
|
210
|
+
|
|
211
|
+
console.print(f"source: {result['source_path']} ({result['source_keys']} top-level keys)")
|
|
212
|
+
console.print(f"target: {credentials_dir()}{' [MERGE]' if merge else ''}\n")
|
|
213
|
+
|
|
214
|
+
for item in result["actions"]:
|
|
215
|
+
flags = " [unmapped->misc]" if item["misc"] else ""
|
|
216
|
+
if item["entry"].get("status") == "deprecated":
|
|
217
|
+
flags += " [DEPRECATED]"
|
|
218
|
+
if item["entry"].get("expiry"):
|
|
219
|
+
flags += f" expiry={item['entry']['expiry']}"
|
|
220
|
+
console.print(f" {item['action']:6s} {item['rel']:40s} <- {', '.join(item['keys'])}{flags}")
|
|
221
|
+
|
|
222
|
+
if result["unmapped"]:
|
|
223
|
+
warning(f"{len(result['unmapped'])} unmapped key(s) routed to misc/: {', '.join(result['unmapped'])}")
|
|
224
|
+
|
|
225
|
+
if dry_run:
|
|
226
|
+
info(f"dry-run: would write {len(result['actions'])} file(s)")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
if not result["actions"]:
|
|
230
|
+
info("nothing to do (folder already up to date)")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
if migrate.folder_has_files() and not (merge or force):
|
|
234
|
+
raise click.ClickException(
|
|
235
|
+
f"refusing: {credentials_dir()} already has files.\n"
|
|
236
|
+
"Use --merge (add missing keys) or --force (overwrite), or point OMELET_DIR elsewhere."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
backup = _guard(migrate.apply, result)
|
|
240
|
+
info(f"backed up source -> {backup}")
|
|
241
|
+
success(f"wrote {len(result['actions'])} file(s) to {credentials_dir()}")
|
|
242
|
+
|
|
243
|
+
count, _, target_path = _guard(compile_flat)
|
|
244
|
+
success(f"compiled {count} entries -> {target_path}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@cred_group.group("sync", help="Sync the credentials folder with a private GitHub repo.")
|
|
248
|
+
def sync_group():
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@sync_group.command("push", help="Push the local credentials folder to the sync repo.")
|
|
253
|
+
def sync_push():
|
|
254
|
+
host = _guard(sync.push)
|
|
255
|
+
if host is None:
|
|
256
|
+
info("no changes to push")
|
|
257
|
+
return
|
|
258
|
+
success(f"pushed {sync.dir_in_repo()}/ to {sync.sync_repo()} (from {host})")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@sync_group.command("pull", help="Pull the credentials folder from the sync repo, then recompile.")
|
|
262
|
+
def sync_pull():
|
|
263
|
+
backup = _guard(sync.pull)
|
|
264
|
+
if backup:
|
|
265
|
+
info(f"backed up existing folder -> {backup}")
|
|
266
|
+
success(f"pulled {sync.sync_repo()}:{sync.dir_in_repo()}/ -> {credentials_dir()}")
|
|
267
|
+
count, _, target_path = _guard(compile_flat)
|
|
268
|
+
success(f"compiled {count} entries -> {target_path}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@cred_group.command("path", help="Print the store locations this machine resolves to.")
|
|
272
|
+
def show_path():
|
|
273
|
+
console.print(f"folder: {credentials_dir()}")
|
|
274
|
+
console.print(f"flat: {config_path()}")
|
|
275
|
+
try:
|
|
276
|
+
keys = [key for key in read_flat() if not key.startswith("_")]
|
|
277
|
+
except CredentialError:
|
|
278
|
+
keys = []
|
|
279
|
+
console.print(f"keys: {len(keys)}")
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
from urllib.request import url2pathname
|
|
12
|
+
|
|
13
|
+
import rich_click as click
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from evo_cli import __version__
|
|
18
|
+
from evo_cli.console import CommandError, console, info, run_command, step, success, warning
|
|
19
|
+
|
|
20
|
+
PACKAGE = "evo_cli"
|
|
21
|
+
PYPI_JSON = "https://pypi.org/pypi/{package}/json"
|
|
22
|
+
|
|
23
|
+
VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)(?:[-_.]?(dev|alpha|a|beta|b|rc|c|pre)[-_.]?(\d*))?")
|
|
24
|
+
PRE_RANK = {"dev": -4, "alpha": -3, "a": -3, "beta": -2, "b": -2, "pre": -1, "rc": -1, "c": -1}
|
|
25
|
+
|
|
26
|
+
EPILOG = Text.from_markup(
|
|
27
|
+
"[bold]Examples[/bold]\n\n"
|
|
28
|
+
" [cyan]evo update[/cyan] update to the newest release\n"
|
|
29
|
+
" [cyan]evo update --check[/cyan] only report whether an update exists\n"
|
|
30
|
+
" [cyan]evo update --pre[/cyan] include pre-release versions\n"
|
|
31
|
+
" [cyan]evo update --dry-run[/cyan] print the commands without running them\n"
|
|
32
|
+
" [cyan]evo update --force[/cyan] reinstall even when already current\n\n"
|
|
33
|
+
"[dim]An editable checkout is updated with git (fast-forward only, refusing to touch a\n"
|
|
34
|
+
"dirty tree); a pipx, uv or pip install is upgraded with its own installer.[/dim]"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_version(value):
|
|
39
|
+
match = VERSION_RE.match(str(value).strip().lower())
|
|
40
|
+
if not match:
|
|
41
|
+
return ((0,), 0, 0)
|
|
42
|
+
release = tuple(int(part) for part in match.group(1).split("."))
|
|
43
|
+
label = match.group(2)
|
|
44
|
+
if label is None:
|
|
45
|
+
return (release, 0, 0)
|
|
46
|
+
return (release, PRE_RANK.get(label, -1), int(match.group(3) or 0))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_prerelease(value):
|
|
50
|
+
return VERSION_RE.match(str(value).strip().lower()) is not None and parse_version(value)[1] < 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fetch_pypi(package, timeout):
|
|
54
|
+
request = urllib.request.Request(PYPI_JSON.format(package=package), headers={"User-Agent": "evo-cli"})
|
|
55
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
56
|
+
return json.loads(response.read().decode("utf-8"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def latest_version(data, allow_pre):
|
|
60
|
+
candidates = []
|
|
61
|
+
for version, files in (data.get("releases") or {}).items():
|
|
62
|
+
if not files or all(item.get("yanked") for item in files):
|
|
63
|
+
continue
|
|
64
|
+
if not allow_pre and is_prerelease(version):
|
|
65
|
+
continue
|
|
66
|
+
candidates.append(version)
|
|
67
|
+
if candidates:
|
|
68
|
+
return max(candidates, key=parse_version)
|
|
69
|
+
return (data.get("info") or {}).get("version")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_direct_url():
|
|
73
|
+
try:
|
|
74
|
+
dist = metadata.distribution(PACKAGE)
|
|
75
|
+
except metadata.PackageNotFoundError:
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
raw = dist.read_text("direct_url.json")
|
|
79
|
+
except OSError:
|
|
80
|
+
return {}
|
|
81
|
+
if not raw:
|
|
82
|
+
return {}
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(raw)
|
|
85
|
+
except ValueError:
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def editable_source():
|
|
90
|
+
data = read_direct_url()
|
|
91
|
+
if not (data.get("dir_info") or {}).get("editable"):
|
|
92
|
+
return None
|
|
93
|
+
url = data.get("url") or ""
|
|
94
|
+
if not url.startswith("file:"):
|
|
95
|
+
return None
|
|
96
|
+
path = Path(url2pathname(urlparse(url).path))
|
|
97
|
+
return path if path.is_dir() else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def detect_install():
|
|
101
|
+
source = editable_source()
|
|
102
|
+
if source is not None:
|
|
103
|
+
return {"mode": "editable", "location": source}
|
|
104
|
+
prefix = Path(sys.prefix).resolve()
|
|
105
|
+
parts = [part.lower() for part in prefix.parts]
|
|
106
|
+
if "pipx" in parts:
|
|
107
|
+
return {"mode": "pipx", "location": prefix}
|
|
108
|
+
if "uv" in parts and "tools" in parts:
|
|
109
|
+
return {"mode": "uv", "location": prefix}
|
|
110
|
+
return {"mode": "pip", "location": prefix}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def git(path, *args):
|
|
114
|
+
return subprocess.run(
|
|
115
|
+
["git", "-C", str(path), *args],
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
encoding="utf-8",
|
|
119
|
+
errors="replace",
|
|
120
|
+
check=False,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def git_detail(result, fallback):
|
|
125
|
+
lines = ((result.stderr or "") + (result.stdout or "")).strip().splitlines()
|
|
126
|
+
return lines[-1] if lines else fallback
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def report(install, current, latest):
|
|
130
|
+
table = Table(show_header=False, box=None, expand=False)
|
|
131
|
+
table.add_column(style="info", no_wrap=True)
|
|
132
|
+
table.add_column()
|
|
133
|
+
table.add_row("Installed", f"[accent]{current}[/accent]")
|
|
134
|
+
table.add_row("Latest on PyPI", f"[accent]{latest}[/accent]" if latest else "[dim]unknown[/dim]")
|
|
135
|
+
table.add_row("Install mode", install["mode"])
|
|
136
|
+
table.add_row("Location", f"[dim]{install['location']}[/dim]")
|
|
137
|
+
console.print(table)
|
|
138
|
+
console.print()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_commands(mode, allow_pre):
|
|
142
|
+
target = f"{PACKAGE}"
|
|
143
|
+
if mode == "pipx":
|
|
144
|
+
if shutil.which("pipx") is None:
|
|
145
|
+
raise click.ClickException("This looks like a pipx install but pipx is not on PATH.")
|
|
146
|
+
cmd = ["pipx", "upgrade", target]
|
|
147
|
+
if allow_pre:
|
|
148
|
+
cmd.append("--pip-args=--pre")
|
|
149
|
+
return [cmd]
|
|
150
|
+
if mode == "uv":
|
|
151
|
+
if shutil.which("uv") is None:
|
|
152
|
+
raise click.ClickException("This looks like a uv tool install but uv is not on PATH.")
|
|
153
|
+
cmd = ["uv", "tool", "upgrade", target]
|
|
154
|
+
if allow_pre:
|
|
155
|
+
cmd.append("--prerelease=allow")
|
|
156
|
+
return [cmd]
|
|
157
|
+
probe = subprocess.run([sys.executable, "-m", "pip", "--version"], capture_output=True)
|
|
158
|
+
if probe.returncode != 0:
|
|
159
|
+
raise click.ClickException(f"pip is not available for {sys.executable}. Install pip or use pipx/uv.")
|
|
160
|
+
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", target]
|
|
161
|
+
if allow_pre:
|
|
162
|
+
cmd.append("--pre")
|
|
163
|
+
return [cmd]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def execute(commands, dry_run):
|
|
167
|
+
for cmd in commands:
|
|
168
|
+
if dry_run:
|
|
169
|
+
console.print(f"[cmd]$ {' '.join(str(part) for part in cmd)}[/cmd]")
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
run_command(cmd)
|
|
173
|
+
except CommandError as exc:
|
|
174
|
+
raise click.ClickException(str(exc)) from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def update_editable(path, check, force, dry_run):
|
|
178
|
+
if shutil.which("git") is None:
|
|
179
|
+
raise click.ClickException("git is not installed or is not on PATH")
|
|
180
|
+
|
|
181
|
+
status = git(path, "status", "--porcelain")
|
|
182
|
+
if status.returncode != 0:
|
|
183
|
+
raise click.ClickException(f"{path} is not a git repository")
|
|
184
|
+
|
|
185
|
+
info(f"Editable checkout at [accent]{path}[/accent]")
|
|
186
|
+
if not dry_run:
|
|
187
|
+
fetched = git(path, "fetch", "--prune")
|
|
188
|
+
if fetched.returncode != 0:
|
|
189
|
+
warning(f"git fetch failed: {git_detail(fetched, 'unknown error')}")
|
|
190
|
+
|
|
191
|
+
upstream = git(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
192
|
+
if upstream.returncode != 0:
|
|
193
|
+
raise click.ClickException("The checkout has no upstream branch to update from.")
|
|
194
|
+
tracking = upstream.stdout.strip()
|
|
195
|
+
|
|
196
|
+
counts = git(path, "rev-list", "--left-right", "--count", "HEAD...@{u}")
|
|
197
|
+
ahead, behind = 0, 0
|
|
198
|
+
if counts.returncode == 0 and counts.stdout.split():
|
|
199
|
+
numbers = counts.stdout.split()
|
|
200
|
+
ahead, behind = int(numbers[0]), int(numbers[1])
|
|
201
|
+
|
|
202
|
+
if behind == 0 and not force:
|
|
203
|
+
success(f"Already up to date with [accent]{tracking}[/accent].")
|
|
204
|
+
if ahead:
|
|
205
|
+
info(f"{ahead} local commit(s) not pushed yet.")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
info(f"{behind} new commit(s) on [accent]{tracking}[/accent].")
|
|
209
|
+
if check:
|
|
210
|
+
info("Run [accent]evo update[/accent] to apply them.")
|
|
211
|
+
return
|
|
212
|
+
if status.stdout.strip():
|
|
213
|
+
if dry_run:
|
|
214
|
+
warning("The checkout has uncommitted changes, so the update would stop here.")
|
|
215
|
+
return
|
|
216
|
+
raise click.ClickException("The checkout has uncommitted changes. Commit or stash them first.")
|
|
217
|
+
|
|
218
|
+
before = git(path, "rev-parse", "HEAD").stdout.strip()
|
|
219
|
+
if dry_run:
|
|
220
|
+
console.print(f"[cmd]$ git -C {path} pull --ff-only --prune[/cmd]")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
pulled = git(path, "pull", "--ff-only", "--prune")
|
|
224
|
+
if pulled.returncode != 0:
|
|
225
|
+
raise click.ClickException(f"git pull failed: {git_detail(pulled, 'unknown error')}")
|
|
226
|
+
|
|
227
|
+
after = git(path, "rev-parse", "HEAD").stdout.strip()
|
|
228
|
+
head = git(path, "log", "-1", "--oneline").stdout.strip()
|
|
229
|
+
success(f"Updated to {head or after}")
|
|
230
|
+
|
|
231
|
+
changed = git(path, "diff", "--name-only", before, after).stdout.split()
|
|
232
|
+
if force or any(name in ("pyproject.toml", "setup.py", "setup.cfg") for name in changed):
|
|
233
|
+
info("Dependency metadata changed, reinstalling the package.")
|
|
234
|
+
execute([[sys.executable, "-m", "pip", "install", "-e", str(path), "--quiet"]], dry_run)
|
|
235
|
+
|
|
236
|
+
version_file = path / "evo_cli" / "VERSION"
|
|
237
|
+
if version_file.is_file():
|
|
238
|
+
success(f"evo is now at [accent]{version_file.read_text(encoding='utf-8').strip()}[/accent]")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def update_package(mode, current, latest, check, allow_pre, force, dry_run):
|
|
242
|
+
if latest is None:
|
|
243
|
+
if check:
|
|
244
|
+
raise click.ClickException("Cannot determine the latest version from PyPI.")
|
|
245
|
+
warning("Skipping the version check and upgrading anyway.")
|
|
246
|
+
elif parse_version(latest) <= parse_version(current) and not force:
|
|
247
|
+
success(f"evo [accent]{current}[/accent] is already the latest release.")
|
|
248
|
+
return
|
|
249
|
+
elif latest:
|
|
250
|
+
info(f"Update available: [accent]{current}[/accent] -> [accent]{latest}[/accent]")
|
|
251
|
+
|
|
252
|
+
if check:
|
|
253
|
+
info("Run [accent]evo update[/accent] to install it.")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
execute(build_commands(mode, allow_pre), dry_run)
|
|
257
|
+
if not dry_run:
|
|
258
|
+
success(f"evo updated to [accent]{latest or 'the latest release'}[/accent].")
|
|
259
|
+
info("Open a new shell if the `evo` command still reports the old version.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@click.command("update", epilog=EPILOG, context_settings={"help_option_names": ["-h", "--help"]})
|
|
263
|
+
@click.option("-c", "--check", is_flag=True, help="Only report whether a newer version is available.")
|
|
264
|
+
@click.option("--pre", "allow_pre", is_flag=True, help="Consider pre-release versions.")
|
|
265
|
+
@click.option("-f", "--force", is_flag=True, help="Reinstall even when already up to date.")
|
|
266
|
+
@click.option("-n", "--dry-run", is_flag=True, help="Print the commands instead of running them.")
|
|
267
|
+
@click.option("--timeout", type=float, default=15.0, show_default=True, help="PyPI request timeout in seconds.")
|
|
268
|
+
def update(check, allow_pre, force, dry_run, timeout):
|
|
269
|
+
"""Update **evo** itself to the latest version.
|
|
270
|
+
|
|
271
|
+
Detects how `evo` was installed and uses the matching updater: a git
|
|
272
|
+
fast-forward for an editable checkout, otherwise `pipx upgrade`,
|
|
273
|
+
`uv tool upgrade` or `pip install --upgrade`.
|
|
274
|
+
|
|
275
|
+
Use `--check` to see what is available without changing anything.
|
|
276
|
+
"""
|
|
277
|
+
step("evo update")
|
|
278
|
+
install = detect_install()
|
|
279
|
+
|
|
280
|
+
latest = None
|
|
281
|
+
try:
|
|
282
|
+
latest = latest_version(fetch_pypi(PACKAGE, timeout), allow_pre)
|
|
283
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
284
|
+
warning(f"Cannot reach PyPI ({exc})")
|
|
285
|
+
|
|
286
|
+
report(install, __version__, latest)
|
|
287
|
+
|
|
288
|
+
if install["mode"] == "editable":
|
|
289
|
+
update_editable(install["location"], check, force, dry_run)
|
|
290
|
+
return
|
|
291
|
+
update_package(install["mode"], __version__, latest, check, allow_pre, force, dry_run)
|