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.
Files changed (66) hide show
  1. {evo_cli-0.11.4 → evo_cli-0.12.1}/PKG-INFO +33 -1
  2. {evo_cli-0.11.4 → evo_cli-0.12.1}/README.md +32 -0
  3. evo_cli-0.12.1/evo_cli/VERSION +1 -0
  4. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/cli.py +4 -0
  5. evo_cli-0.12.1/evo_cli/commands/cred.py +279 -0
  6. evo_cli-0.12.1/evo_cli/commands/update.py +291 -0
  7. evo_cli-0.12.1/evo_cli/credentials/__init__.py +27 -0
  8. evo_cli-0.12.1/evo_cli/credentials/doctor.py +73 -0
  9. evo_cli-0.12.1/evo_cli/credentials/google_oauth.py +101 -0
  10. evo_cli-0.12.1/evo_cli/credentials/migrate.py +147 -0
  11. evo_cli-0.12.1/evo_cli/credentials/registry.py +406 -0
  12. evo_cli-0.12.1/evo_cli/credentials/store.py +178 -0
  13. evo_cli-0.12.1/evo_cli/credentials/sync.py +149 -0
  14. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/PKG-INFO +33 -1
  15. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/SOURCES.txt +11 -0
  16. evo_cli-0.12.1/tests/test_cred.py +342 -0
  17. evo_cli-0.12.1/tests/test_update.py +190 -0
  18. evo_cli-0.11.4/evo_cli/VERSION +0 -1
  19. {evo_cli-0.11.4 → evo_cli-0.12.1}/Containerfile +0 -0
  20. {evo_cli-0.11.4 → evo_cli-0.12.1}/HISTORY.md +0 -0
  21. {evo_cli-0.11.4 → evo_cli-0.12.1}/LICENSE +0 -0
  22. {evo_cli-0.11.4 → evo_cli-0.12.1}/MANIFEST.in +0 -0
  23. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/__init__.py +0 -0
  24. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/__main__.py +0 -0
  25. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/base.py +0 -0
  26. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/__init__.py +0 -0
  27. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/claude_code.py +0 -0
  28. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/cloudflare.py +0 -0
  29. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/download.py +0 -0
  30. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/fix_claude.py +0 -0
  31. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/gdrive.py +0 -0
  32. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/gh.py +0 -0
  33. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/harness.py +0 -0
  34. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/hwid.py +0 -0
  35. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/hwid_reset.py +0 -0
  36. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/localproxy.py +0 -0
  37. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/mcp.py +0 -0
  38. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/miniconda.py +0 -0
  39. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/netcheck.py +0 -0
  40. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/opencode.py +0 -0
  41. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/plantuml.py +0 -0
  42. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/site2s.py +0 -0
  43. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/ssh.py +0 -0
  44. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/sysmon.py +0 -0
  45. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/commands/wifi.py +0 -0
  46. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/console.py +0 -0
  47. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli/mcp_registry.py +0 -0
  48. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  49. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/entry_points.txt +0 -0
  50. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/requires.txt +0 -0
  51. {evo_cli-0.11.4 → evo_cli-0.12.1}/evo_cli.egg-info/top_level.txt +0 -0
  52. {evo_cli-0.11.4 → evo_cli-0.12.1}/pyproject.toml +0 -0
  53. {evo_cli-0.11.4 → evo_cli-0.12.1}/setup.cfg +0 -0
  54. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/__init__.py +0 -0
  55. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_claude_code.py +0 -0
  56. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_cli.py +0 -0
  57. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_console.py +0 -0
  58. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_download.py +0 -0
  59. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_fix_claude.py +0 -0
  60. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_gh.py +0 -0
  61. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_harness.py +0 -0
  62. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_mcp.py +0 -0
  63. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_opencode.py +0 -0
  64. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_plantuml.py +0 -0
  65. {evo_cli-0.11.4 → evo_cli-0.12.1}/tests/test_sysmon.py +0 -0
  66. {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.11.4
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)