update-kit 0.1.0__py3-none-any.whl
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.
update_kit/__init__.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Self-update for git-tag-released Python CLIs (uv tool / pipx). Stdlib only.
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import update_kit
|
|
5
|
+
|
|
6
|
+
# `<cmd> update` / `<cmd> --update`
|
|
7
|
+
sys.exit(update_kit.update(app="my-cli", repo="owner/my-cli", dist="my-cli"))
|
|
8
|
+
|
|
9
|
+
# background hint, TTY stderr only
|
|
10
|
+
if update_kit.should_hint():
|
|
11
|
+
latest = update_kit.check(repo="owner/my-cli", current=__version__)
|
|
12
|
+
if latest:
|
|
13
|
+
print(update_kit.hint("my-cli", latest, __version__), file=sys.stderr)
|
|
14
|
+
|
|
15
|
+
Releases are strict ``vX.Y.Z`` git tags, discovered with ``git ls-remote``.
|
|
16
|
+
Never prompts.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shlex
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Sequence
|
|
29
|
+
from importlib import metadata
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TextIO
|
|
32
|
+
from urllib.parse import urlparse
|
|
33
|
+
from urllib.request import url2pathname
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"UpdateError", "check", "hint", "install_kind", "latest_tag", "normalize_lang",
|
|
39
|
+
"should_hint", "update",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
_IS_WINDOWS = os.name == "nt" # the running exe is locked there; tests patch this flag
|
|
43
|
+
_NO_PROMPT = {"GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"} # fail instead of asking for credentials
|
|
44
|
+
|
|
45
|
+
_STRICT = re.compile(r"v?([0-9]{1,9})\.([0-9]{1,9})\.([0-9]{1,9})")
|
|
46
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
|
47
|
+
_USERINFO = re.compile(r"(?<=://)[^/\s]*@")
|
|
48
|
+
# Only these remote forms reach git; no whitespace/control chars, no userinfo (credentials).
|
|
49
|
+
_URL_OK = re.compile(r"(?:https://(?![^/?#]*@)|ssh://(?![^/?#]*:[^/?#]*@)|file://)[^\s\x00-\x1f\x7f-\x9f]+|git@[A-Za-z0-9][A-Za-z0-9._-]*:[^\s\x00-\x1f\x7f-\x9f]+")
|
|
50
|
+
# cmd.exe would interpret these inside the detached chain; such updates are refused.
|
|
51
|
+
_CMD_META = re.compile(r'[&|<>^%"()!\r\n]')
|
|
52
|
+
|
|
53
|
+
_MESSAGES = {
|
|
54
|
+
"en": {
|
|
55
|
+
"dev": "{app}: dev install from {dir}; use git pull to update",
|
|
56
|
+
"check_failed": "{app}: couldn't check for updates ({error})",
|
|
57
|
+
"up_to_date": "{app} {current} is already up to date",
|
|
58
|
+
"manual": "{app} {latest} is available (you have {current}). Install it manually:\n {command}",
|
|
59
|
+
"installing": "Updating {app} {current} -> {latest} ...",
|
|
60
|
+
"install_failed": "{app}: update failed ({error})",
|
|
61
|
+
"updated": "{app} updated to {latest}",
|
|
62
|
+
"after_failed": "warning: `{step}` failed after the update ({error})",
|
|
63
|
+
"background": "{app} {latest}: update continues in the background (log: {log})",
|
|
64
|
+
"hint": "{cmd} {latest} is available (you have {current}) — run `{cmd} update`",
|
|
65
|
+
"source": "{app}: using update source {url} (UPDATE_KIT_REPO_URL)",
|
|
66
|
+
},
|
|
67
|
+
"zh-TW": {
|
|
68
|
+
"dev": "{app}:開發版安裝({dir}),請用 git pull 更新",
|
|
69
|
+
"check_failed": "{app}:無法檢查更新({error})",
|
|
70
|
+
"up_to_date": "{app} {current} 已是最新版本",
|
|
71
|
+
"manual": "{app} {latest} 可更新(目前為 {current}),請手動安裝:\n {command}",
|
|
72
|
+
"installing": "正在更新 {app} {current} -> {latest} ...",
|
|
73
|
+
"install_failed": "{app}:更新失敗({error})",
|
|
74
|
+
"updated": "{app} 已更新至 {latest}",
|
|
75
|
+
"after_failed": "警告:更新後執行 `{step}` 失敗({error})",
|
|
76
|
+
"background": "{app} {latest}:更新將在背景繼續(記錄檔:{log})",
|
|
77
|
+
"hint": "{cmd} {latest} 可更新(目前為 {current})— 執行 `{cmd} update`",
|
|
78
|
+
"source": "{app}:使用更新來源 {url}(UPDATE_KIT_REPO_URL)",
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class UpdateError(Exception):
|
|
84
|
+
"""The latest release could not be determined."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse(version: str | None) -> tuple[int, int, int] | None:
|
|
88
|
+
match = _STRICT.fullmatch(version.strip()) if version else None
|
|
89
|
+
return tuple(int(part) for part in match.groups()) if match else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _clean(text: str) -> str:
|
|
93
|
+
"""Printable, credential-free *text* (terminal escapes and URL userinfo removed)."""
|
|
94
|
+
return _USERINFO.sub("", _CONTROL.sub("", text))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _repo_url(repo: str, url: str | None) -> str:
|
|
98
|
+
target = url or os.environ.get("UPDATE_KIT_REPO_URL") or f"https://github.com/{repo}.git"
|
|
99
|
+
if not _URL_OK.fullmatch(target):
|
|
100
|
+
raise UpdateError(f"unsupported repo URL: {_clean(target)[:200]!r}")
|
|
101
|
+
return target
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _msg(lang: str, key: str, **fields) -> str:
|
|
105
|
+
return _MESSAGES[normalize_lang(lang)][key].format(**fields)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _say(out: TextIO, text: str) -> None:
|
|
109
|
+
try:
|
|
110
|
+
print(text, file=out)
|
|
111
|
+
except UnicodeEncodeError: # e.g. a cp1252 stream on Windows receiving zh-TW
|
|
112
|
+
encoding = getattr(out, "encoding", None) or "ascii"
|
|
113
|
+
print(text.encode(encoding, "replace").decode(encoding), file=out)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def latest_tag(repo: str, *, timeout: float = 5, url: str | None = None) -> str:
|
|
117
|
+
"""Newest strict ``vX.Y.Z`` tag of github.com/<repo>; raises UpdateError."""
|
|
118
|
+
# Validated before any git call: a value like "--upload-pack=..." must never reach argv.
|
|
119
|
+
target = _repo_url(repo, url)
|
|
120
|
+
# No credential/passphrase prompt for a private/missing repo: fail instead of blocking.
|
|
121
|
+
# Low-speed limits stop a stalled HTTP transfer that outlives the killed git process.
|
|
122
|
+
env = {
|
|
123
|
+
"GIT_SSH_COMMAND": "ssh -o BatchMode=yes",
|
|
124
|
+
**os.environ,
|
|
125
|
+
**_NO_PROMPT,
|
|
126
|
+
"GIT_HTTP_LOW_SPEED_LIMIT": "1",
|
|
127
|
+
"GIT_HTTP_LOW_SPEED_TIME": str(max(1, round(timeout))),
|
|
128
|
+
}
|
|
129
|
+
try:
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
["git", "-c", "protocol.ext.allow=never", "ls-remote", "--tags", "--refs", "--", target],
|
|
132
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
133
|
+
stdin=subprocess.DEVNULL, timeout=timeout, env=env, check=False,
|
|
134
|
+
cwd=Path.home(), # a repo-local config/hook in the caller's cwd must not apply
|
|
135
|
+
)
|
|
136
|
+
except FileNotFoundError as exc:
|
|
137
|
+
raise UpdateError("git not found") from exc
|
|
138
|
+
except subprocess.TimeoutExpired as exc:
|
|
139
|
+
raise UpdateError(f"timed out after {timeout:g}s") from exc
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
raise UpdateError(str(exc)) from exc
|
|
142
|
+
if result.returncode != 0:
|
|
143
|
+
detail = (result.stderr or "").strip().splitlines()
|
|
144
|
+
raise UpdateError(_clean(detail[-1]) if detail else f"git ls-remote exited {result.returncode}")
|
|
145
|
+
tags = [line.split("\trefs/tags/", 1)[1] for line in result.stdout.splitlines() if "\trefs/tags/" in line]
|
|
146
|
+
versions = [(tuple(map(int, m.groups())), tag) for tag in tags if (m := _STRICT.fullmatch(tag))]
|
|
147
|
+
if not versions:
|
|
148
|
+
raise UpdateError(f"no release tags in {_clean(target)}")
|
|
149
|
+
return max(versions)[1]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def install_kind(dist: str) -> tuple[str, str | None]:
|
|
153
|
+
"""("uv" | "pipx" | "dev" | "unknown", detail) for the installed *dist*."""
|
|
154
|
+
try:
|
|
155
|
+
distribution = metadata.distribution(dist)
|
|
156
|
+
except metadata.PackageNotFoundError:
|
|
157
|
+
return "unknown", None
|
|
158
|
+
try:
|
|
159
|
+
direct = json.loads(distribution.read_text("direct_url.json") or "{}")
|
|
160
|
+
except (OSError, ValueError):
|
|
161
|
+
direct = {}
|
|
162
|
+
if isinstance(direct, dict) and "dir_info" in direct: # PEP 610: editable or local dir
|
|
163
|
+
try:
|
|
164
|
+
return "dev", url2pathname(urlparse(direct.get("url", "")).path)
|
|
165
|
+
except Exception: # noqa: BLE001 - malformed metadata must not break `update`
|
|
166
|
+
return "dev", str(direct.get("url"))
|
|
167
|
+
prefix = Path(sys.prefix)
|
|
168
|
+
parents = [part.lower() for part in prefix.parent.parts]
|
|
169
|
+
# uv: <uv data>/tools/<name> (UV_TOOL_DIR overrides); pipx: <pipx home>/venvs/<name>.
|
|
170
|
+
tool_dir = os.environ.get("UV_TOOL_DIR")
|
|
171
|
+
if (parents[-1:] == ["tools"] and "uv" in parents) or (tool_dir and prefix.parent == Path(tool_dir)):
|
|
172
|
+
return "uv", str(prefix)
|
|
173
|
+
pipx_home = os.environ.get("PIPX_HOME")
|
|
174
|
+
if (parents[-1:] == ["venvs"] and "pipx" in parents) or (
|
|
175
|
+
pipx_home and prefix.parent == Path(pipx_home, "venvs")
|
|
176
|
+
):
|
|
177
|
+
return "pipx", str(prefix)
|
|
178
|
+
return "unknown", str(prefix)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _windows_chain(commands: Sequence[Sequence[str]], log: str) -> str:
|
|
182
|
+
"""One cmd.exe line: wait for the caller to exit (its exe is locked), then run *commands*."""
|
|
183
|
+
chain = " && ".join(subprocess.list2cmdline(list(argv)) for argv in commands)
|
|
184
|
+
wait = "ping -n 4 127.0.0.1 >nul" # `timeout` refuses to run without a console
|
|
185
|
+
return f'cmd.exe /d /s /c "{wait} & ({chain}) > {subprocess.list2cmdline([log])} 2>&1"'
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _spawn_detached(cmdline: str, log: str, env: dict | None = None) -> None: # pragma: no cover - Windows only
|
|
189
|
+
os.makedirs(os.path.dirname(log), exist_ok=True)
|
|
190
|
+
# CREATE_NO_WINDOW (not DETACHED_PROCESS): children inherit a hidden console, no flashing.
|
|
191
|
+
flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
192
|
+
# cwd=home: cmd.exe searches the cwd first, so a planted uv.bat/crw.exe there must not run.
|
|
193
|
+
subprocess.Popen(cmdline, creationflags=flags, close_fds=True, stdin=subprocess.DEVNULL,
|
|
194
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=str(Path.home()), env=env)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def update(*, app: str, repo: str, dist: str, current: str | None = None, lang: str = "en",
|
|
198
|
+
after: Sequence[Sequence[str]] = (), out: TextIO = sys.stderr,
|
|
199
|
+
run: Callable = subprocess.run, url: str | None = None) -> int:
|
|
200
|
+
"""Install the newest release. 0 updated/up to date, 1 failure, 2 dev/unknown install."""
|
|
201
|
+
kind, detail = install_kind(dist)
|
|
202
|
+
if kind == "dev":
|
|
203
|
+
_say(out, _msg(lang, "dev", app=app, dir=detail))
|
|
204
|
+
return 2
|
|
205
|
+
if current is None:
|
|
206
|
+
try:
|
|
207
|
+
current = metadata.version(dist)
|
|
208
|
+
except metadata.PackageNotFoundError:
|
|
209
|
+
pass
|
|
210
|
+
try:
|
|
211
|
+
target = _repo_url(repo, url)
|
|
212
|
+
if not url and os.environ.get("UPDATE_KIT_REPO_URL"):
|
|
213
|
+
_say(out, _msg(lang, "source", app=app, url=_clean(target)))
|
|
214
|
+
latest = latest_tag(repo, url=target)
|
|
215
|
+
except UpdateError as exc:
|
|
216
|
+
_say(out, _msg(lang, "check_failed", app=app, error=exc))
|
|
217
|
+
return 1
|
|
218
|
+
have = _parse(current)
|
|
219
|
+
if have and have >= _parse(latest):
|
|
220
|
+
_say(out, _msg(lang, "up_to_date", app=app, current=current))
|
|
221
|
+
return 0
|
|
222
|
+
# An unparseable/unknown current version is treated as older: the user asked to update.
|
|
223
|
+
# pip/uv can't parse the scp form (git@host:path); install over the equivalent ssh:// URL.
|
|
224
|
+
source = re.sub(r"^git@([^:]+):/?", r"ssh://git@\1/", target)
|
|
225
|
+
spec = f"git+{source}@{latest}"
|
|
226
|
+
shown = current or "?"
|
|
227
|
+
if kind == "uv":
|
|
228
|
+
install = ["uv", "tool", "install", "--force", "--no-sources", spec]
|
|
229
|
+
elif kind == "pipx":
|
|
230
|
+
install = ["pipx", "install", "--force", spec]
|
|
231
|
+
else:
|
|
232
|
+
argv = ["python", "-m", "pip", "install", "--upgrade", spec]
|
|
233
|
+
command = subprocess.list2cmdline(argv) if _IS_WINDOWS else shlex.join(argv)
|
|
234
|
+
_say(out, _msg(lang, "manual", app=app, latest=latest, current=shown, command=command))
|
|
235
|
+
return 2
|
|
236
|
+
if _IS_WINDOWS:
|
|
237
|
+
log = os.path.join(os.path.expanduser("~"), ".update-kit", f"{app}-update.log")
|
|
238
|
+
unsafe = [arg for arg in [log, *install, *(a for step in after for a in step)] if _CMD_META.search(arg)]
|
|
239
|
+
if unsafe:
|
|
240
|
+
_say(out, _msg(lang, "install_failed", app=app, error=f"unsafe for cmd.exe: {unsafe[0]}"))
|
|
241
|
+
return 1
|
|
242
|
+
try:
|
|
243
|
+
_spawn_detached(_windows_chain([install, *after], log), log, {**os.environ, **_NO_PROMPT})
|
|
244
|
+
except OSError as exc:
|
|
245
|
+
_say(out, _msg(lang, "install_failed", app=app, error=exc))
|
|
246
|
+
return 1
|
|
247
|
+
_say(out, _msg(lang, "background", app=app, latest=latest, log=log))
|
|
248
|
+
return 0
|
|
249
|
+
_say(out, _msg(lang, "installing", app=app, current=shown, latest=latest))
|
|
250
|
+
try:
|
|
251
|
+
code = run(install, cwd=Path.home(), env={**os.environ, **_NO_PROMPT}).returncode
|
|
252
|
+
except OSError as exc:
|
|
253
|
+
_say(out, _msg(lang, "install_failed", app=app, error=exc))
|
|
254
|
+
return 1
|
|
255
|
+
if code != 0:
|
|
256
|
+
_say(out, _msg(lang, "install_failed", app=app, error=f"exit {code}"))
|
|
257
|
+
return 1
|
|
258
|
+
_say(out, _msg(lang, "updated", app=app, latest=latest))
|
|
259
|
+
result = 0
|
|
260
|
+
for step in after:
|
|
261
|
+
step = list(step)
|
|
262
|
+
try:
|
|
263
|
+
step_code, error = run(step).returncode, None
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
step_code, error = -1, exc
|
|
266
|
+
if step_code != 0:
|
|
267
|
+
_say(out, _msg(lang, "after_failed", step=" ".join(step), error=error or f"exit {step_code}"))
|
|
268
|
+
result = 1
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def check(*, repo: str, current: str, cache_path: Path | None = None, ttl: int = 86400,
|
|
273
|
+
timeout: float = 1.5, url: str | None = None) -> str | None:
|
|
274
|
+
"""Newer release tag than *current*, or None. Cached per source URL for *ttl*; never raises."""
|
|
275
|
+
try:
|
|
276
|
+
path = Path(cache_path) if cache_path else Path.home() / ".update-kit" / "state.json"
|
|
277
|
+
try:
|
|
278
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
279
|
+
except (OSError, ValueError):
|
|
280
|
+
state = {}
|
|
281
|
+
if not isinstance(state, dict):
|
|
282
|
+
state = {}
|
|
283
|
+
key = _repo_url(repo, url) # keyed by source, so an override can't poison the normal entry
|
|
284
|
+
entry = state.get(key)
|
|
285
|
+
now = time.time()
|
|
286
|
+
checked = entry.get("checked") if isinstance(entry, dict) else None
|
|
287
|
+
cached = entry.get("latest") if isinstance(entry, dict) else None
|
|
288
|
+
valid = isinstance(checked, (int, float)) and (
|
|
289
|
+
cached is None or (isinstance(cached, str) and _STRICT.fullmatch(cached) is not None)
|
|
290
|
+
)
|
|
291
|
+
if valid and 0 <= now - checked < ttl:
|
|
292
|
+
latest = cached
|
|
293
|
+
else:
|
|
294
|
+
try:
|
|
295
|
+
latest = latest_tag(repo, timeout=timeout, url=url)
|
|
296
|
+
except Exception: # noqa: BLE001 - offline, rate-limited, anything: negative-cache it too
|
|
297
|
+
latest = None
|
|
298
|
+
# shortcut: read-merge-replace without a lock; a concurrent writer can drop one
|
|
299
|
+
# entry, which only costs one extra ls-remote later. Add a lock if that matters.
|
|
300
|
+
state[key] = {"checked": now, "latest": latest}
|
|
301
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
302
|
+
try:
|
|
303
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
|
305
|
+
os.replace(tmp, path)
|
|
306
|
+
except OSError:
|
|
307
|
+
try:
|
|
308
|
+
tmp.unlink()
|
|
309
|
+
except OSError:
|
|
310
|
+
pass
|
|
311
|
+
have, new = _parse(current), _parse(latest)
|
|
312
|
+
return latest if have and new and new > have else None
|
|
313
|
+
except Exception: # noqa: BLE001 - check() never raises
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def hint(cmd: str, latest: str, current: str, lang: str = "en") -> str:
|
|
318
|
+
"""One-line "update available" notice naming the command the user actually ran."""
|
|
319
|
+
return _msg(lang, "hint", cmd=cmd, latest=latest, current=current)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def should_hint(stream: TextIO = sys.stderr) -> bool:
|
|
323
|
+
"""True only when *stream* is a TTY (never under launchd/timers/pipes/--json)."""
|
|
324
|
+
try:
|
|
325
|
+
return bool(stream.isatty())
|
|
326
|
+
except Exception: # noqa: BLE001 - closed/None/odd streams mean "no"
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def normalize_lang(value: str | None) -> str:
|
|
331
|
+
"""zh_TW / zh-tw / zh-Hant / zh_HK -> "zh-TW"; anything else -> "en"."""
|
|
332
|
+
tag = re.split(r"[.@]", value or "")[0].replace("_", "-").lower()
|
|
333
|
+
return "zh-TW" if tag in ("zh-tw", "zh-hk", "zh-mo") or tag.startswith("zh-hant") else "en"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: update-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-update for git-tag-released Python CLIs (uv tool / pipx). Stdlib only.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# update-kit
|
|
11
|
+
|
|
12
|
+
Self-update for Python CLIs that are released as git tags (`vX.Y.Z` or `X.Y.Z`) and installed with
|
|
13
|
+
`uv tool` or `pipx`. Stdlib only, Python ≥ 3.10, macOS / Linux / Windows.
|
|
14
|
+
|
|
15
|
+
- `<cmd> update` installs the newest release tag — no prompts, ever.
|
|
16
|
+
- A cached background check prints a one-line hint on an interactive terminal only.
|
|
17
|
+
- Every message exists in English and Traditional Chinese (`zh-TW`).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```toml
|
|
22
|
+
# consumer pyproject.toml
|
|
23
|
+
dependencies = ["update-kit>=0.1,<0.2"]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Wiring a consumer CLI
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
import update_kit
|
|
32
|
+
|
|
33
|
+
from my_cli import __version__
|
|
34
|
+
|
|
35
|
+
APP, REPO, DIST = "my-cli", "owner/my-cli", "my-cli"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv=None):
|
|
39
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
40
|
+
lang = "zh-TW" # the app's own current language setting
|
|
41
|
+
|
|
42
|
+
# `my-cli update` and `my-cli --update`
|
|
43
|
+
if argv[:1] == ["update"] or "--update" in argv:
|
|
44
|
+
sys.exit(update_kit.update(
|
|
45
|
+
app=APP, repo=REPO, dist=DIST, lang=lang,
|
|
46
|
+
after=[["my-cli", "migrate"]], # optional: runs with the NEW binary
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
# Background hint: stderr, TTY only (never under launchd/timers/--json/pipes).
|
|
50
|
+
if update_kit.should_hint():
|
|
51
|
+
latest = update_kit.check(repo=REPO, current=__version__)
|
|
52
|
+
if latest:
|
|
53
|
+
print(update_kit.hint("my-cli", latest, __version__, lang), file=sys.stderr)
|
|
54
|
+
...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
| Function | Does |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `latest_tag(repo, *, timeout=5, url=None) -> str` | Newest strict `v?X.Y.Z` tag (ASCII digits) via `git ls-remote --tags --refs`. Prereleases and other names are ignored. Raises `UpdateError` (offline, no tags, git missing). |
|
|
62
|
+
| `install_kind(dist) -> (kind, detail)` | `"dev"` (PEP 610 local/editable dir, detail = the dir), `"uv"` / `"pipx"` (from `sys.prefix`), else `"unknown"`. |
|
|
63
|
+
| `update(*, app, repo, dist, current=None, lang="en", after=(), out=sys.stderr, run=subprocess.run, url=None) -> int` | Exit code: **0** updated or already up to date · **1** couldn't check (offline/rate limit) or install/after-step failed · **2** dev install (use `git pull`) or unknown install (manual command printed). |
|
|
64
|
+
| `check(*, repo, current, cache_path=None, ttl=86400, timeout=1.5, url=None) -> str \| None` | Newer tag or `None`. Never raises. Results — failures too — are cached for `ttl` in `$HOME/.update-kit/state.json`, keyed by the resolved source URL. |
|
|
65
|
+
| `hint(cmd, latest, current, lang="en") -> str` | `my-cli v1.2.0 is available (you have 1.1.0) — run` `` `my-cli update` `` |
|
|
66
|
+
| `should_hint(stream=sys.stderr) -> bool` | `True` only when `stream` is a TTY. |
|
|
67
|
+
| `normalize_lang(value) -> str` | `zh_TW` / `zh-tw` / `zh-Hant` / `zh_HK` → `"zh-TW"`; anything else → `"en"`. |
|
|
68
|
+
|
|
69
|
+
Install commands used by `update`:
|
|
70
|
+
|
|
71
|
+
- uv: `uv tool install --force --no-sources git+<url>@<tag>`
|
|
72
|
+
- pipx: `pipx install --force git+<url>@<tag>`
|
|
73
|
+
|
|
74
|
+
`<url>` is `url=`, else `$UPDATE_KIT_REPO_URL`, else `https://github.com/<repo>.git`
|
|
75
|
+
(`file://` works, which is how the tests run without a network). Only `https://`, `ssh://`,
|
|
76
|
+
`file://` and `git@host:path` are accepted (`ssh://git@host/...` is fine, `ssh://user:pass@` is not; `git@host:path` is installed as `ssh://git@host/path`); a leading `-`, whitespace/control characters,
|
|
77
|
+
`ext::` or credentials in the URL (`user:pass@`) raise `UpdateError` (`update` returns 1,
|
|
78
|
+
`check` returns `None`). When `$UPDATE_KIT_REPO_URL` is in effect, `update` prints one line
|
|
79
|
+
naming it. `git ls-remote` runs as `git -c protocol.ext.allow=never ls-remote --tags --refs -- <url>`
|
|
80
|
+
from the home directory, and so does the install; git's stderr is stripped of control characters.
|
|
81
|
+
|
|
82
|
+
**Windows:** the running `.exe` is locked, so `update` starts the install (and the `after`
|
|
83
|
+
steps, chained with `&&`) in a detached `cmd.exe` that waits a few seconds for the app to
|
|
84
|
+
exit, returns 0 and says the update continues in the background. Output goes to
|
|
85
|
+
`%USERPROFILE%\.update-kit\<app>-update.log`. Unlike POSIX (where every after-step runs and
|
|
86
|
+
any failure makes the result 1), the Windows chain stops at the first failing step and the
|
|
87
|
+
outcome is only in the log. An install whose arguments or log path contain a `cmd.exe`
|
|
88
|
+
metacharacter (`& | < > ^ % " ( ) !`) is refused with exit 1 instead of being spawned.
|
|
89
|
+
|
|
90
|
+
`git ls-remote` never prompts: `GIT_TERMINAL_PROMPT=0`, and `GIT_SSH_COMMAND` defaults to
|
|
91
|
+
`ssh -o BatchMode=yes` when unset. The install step (uv / pipx) also runs with
|
|
92
|
+
`GIT_TERMINAL_PROMPT=0` and `GCM_INTERACTIVE=never`.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
uv sync
|
|
98
|
+
uv run pytest
|
|
99
|
+
uv run ruff check .
|
|
100
|
+
uv build
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
update_kit/__init__.py,sha256=6HBl7ksLFJwcNKqyT-vojCqkFuPH-51Ex8WbKrly_MA,15424
|
|
2
|
+
update_kit-0.1.0.dist-info/METADATA,sha256=rreFtW4PcepUnNj9sFz3WR7gOsHA1oWDYRPlflEzpdQ,4796
|
|
3
|
+
update_kit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
4
|
+
update_kit-0.1.0.dist-info/licenses/LICENSE,sha256=hbegGfnKh0keOqgPtmGw5K4EYue_BwPemF07Uw2eFDs,1047
|
|
5
|
+
update_kit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wes Kao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.
|