update-kit 0.1.0__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.
- update_kit-0.1.0/.github/dependabot.yml +19 -0
- update_kit-0.1.0/.github/workflows/ci.yml +62 -0
- update_kit-0.1.0/.github/workflows/release.yml +63 -0
- update_kit-0.1.0/.gitignore +7 -0
- update_kit-0.1.0/CHANGELOG.md +46 -0
- update_kit-0.1.0/CLAUDE.md +33 -0
- update_kit-0.1.0/LICENSE +20 -0
- update_kit-0.1.0/PKG-INFO +105 -0
- update_kit-0.1.0/README.md +96 -0
- update_kit-0.1.0/TODO.md +10 -0
- update_kit-0.1.0/pyproject.toml +27 -0
- update_kit-0.1.0/src/update_kit/__init__.py +333 -0
- update_kit-0.1.0/tests/test_update_kit.py +659 -0
- update_kit-0.1.0/uv.lock +202 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
# Actions stay pinned by commit SHA with a "# vX.Y.Z" comment; Dependabot
|
|
4
|
+
# understands that form and bumps both the SHA and the comment.
|
|
5
|
+
- package-ecosystem: github-actions
|
|
6
|
+
directory: /
|
|
7
|
+
schedule:
|
|
8
|
+
interval: weekly
|
|
9
|
+
commit-message:
|
|
10
|
+
prefix: "chore(deps)"
|
|
11
|
+
- package-ecosystem: uv
|
|
12
|
+
directory: /
|
|
13
|
+
schedule:
|
|
14
|
+
interval: weekly
|
|
15
|
+
commit-message:
|
|
16
|
+
prefix: "chore(deps)"
|
|
17
|
+
groups:
|
|
18
|
+
minor-and-patch:
|
|
19
|
+
update-types: [minor, patch]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
12
|
+
cancel-in-progress: true
|
|
13
|
+
|
|
14
|
+
env:
|
|
15
|
+
PYTHONUTF8: "1"
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
test:
|
|
19
|
+
name: Python 3.10 / ${{ matrix.os }}
|
|
20
|
+
timeout-minutes: 15
|
|
21
|
+
strategy:
|
|
22
|
+
fail-fast: false
|
|
23
|
+
matrix:
|
|
24
|
+
os: [macos-latest, ubuntu-latest, windows-latest]
|
|
25
|
+
runs-on: ${{ matrix.os }}
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
|
28
|
+
with:
|
|
29
|
+
persist-credentials: false
|
|
30
|
+
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.10"
|
|
33
|
+
enable-cache: true
|
|
34
|
+
cache-dependency-glob: uv.lock
|
|
35
|
+
- name: Install locked dependencies
|
|
36
|
+
run: uv sync --locked --all-groups
|
|
37
|
+
- name: Lint
|
|
38
|
+
run: uv run ruff check .
|
|
39
|
+
- name: Test
|
|
40
|
+
run: uv run pytest -v
|
|
41
|
+
- name: Build distributions
|
|
42
|
+
run: uv build
|
|
43
|
+
- name: Validate wheel is importable standalone
|
|
44
|
+
shell: python
|
|
45
|
+
run: |
|
|
46
|
+
import os
|
|
47
|
+
import pathlib
|
|
48
|
+
import subprocess
|
|
49
|
+
import tempfile
|
|
50
|
+
|
|
51
|
+
wheel, = pathlib.Path("dist").glob("*.whl")
|
|
52
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
53
|
+
environment = pathlib.Path(directory)
|
|
54
|
+
subprocess.run(["uv", "venv", environment], check=True)
|
|
55
|
+
scripts = environment / ("Scripts" if os.name == "nt" else "bin")
|
|
56
|
+
python = scripts / ("python.exe" if os.name == "nt" else "python")
|
|
57
|
+
subprocess.run(
|
|
58
|
+
["uv", "pip", "install", "--python", python, "--no-index", wheel],
|
|
59
|
+
check=True,
|
|
60
|
+
)
|
|
61
|
+
subprocess.run([python, "-I", "-c", "import update_kit"], check=True)
|
|
62
|
+
print("update_kit imports standalone OK")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# PyPI Trusted Publishing binds to this filename: keep it `release.yml` in every repo.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- "v*"
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: write
|
|
11
|
+
id-token: write
|
|
12
|
+
|
|
13
|
+
concurrency:
|
|
14
|
+
group: release-${{ github.ref }}
|
|
15
|
+
|
|
16
|
+
env:
|
|
17
|
+
PYTHONUTF8: "1"
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
release:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
timeout-minutes: 15
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
|
25
|
+
with:
|
|
26
|
+
persist-credentials: false
|
|
27
|
+
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
|
28
|
+
with:
|
|
29
|
+
python-version: "3.10"
|
|
30
|
+
enable-cache: true
|
|
31
|
+
cache-dependency-glob: uv.lock
|
|
32
|
+
- name: Install locked dependencies
|
|
33
|
+
run: uv sync --locked --all-groups
|
|
34
|
+
- name: Check, test, and build
|
|
35
|
+
run: |
|
|
36
|
+
uv run ruff check .
|
|
37
|
+
uv run pytest -v
|
|
38
|
+
uv build
|
|
39
|
+
- name: Validate release wheel
|
|
40
|
+
run: |
|
|
41
|
+
uv venv .release-venv
|
|
42
|
+
uv pip install --python .release-venv/bin/python --no-index dist/*.whl
|
|
43
|
+
reported="$(.release-venv/bin/python -I -c 'import importlib.metadata as m; print(m.version("update-kit"))')"
|
|
44
|
+
if [ "$reported" != "${GITHUB_REF_NAME#v}" ]; then
|
|
45
|
+
echo "::error::wheel reports '$reported', expected '${GITHUB_REF_NAME#v}'"
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
- name: Publish to PyPI
|
|
49
|
+
run: uv publish --trusted-publishing always
|
|
50
|
+
|
|
51
|
+
# After publish: uv publish uploads dist/*, and SHA256SUMS is not a distribution.
|
|
52
|
+
- name: Create checksums
|
|
53
|
+
run: sha256sum dist/* > dist/SHA256SUMS
|
|
54
|
+
- name: Create GitHub release
|
|
55
|
+
env:
|
|
56
|
+
GH_TOKEN: ${{ github.token }}
|
|
57
|
+
run: |
|
|
58
|
+
gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/$GITHUB_REF_NAME" >/dev/null
|
|
59
|
+
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
|
60
|
+
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
|
|
61
|
+
else
|
|
62
|
+
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes --title "$GITHUB_REF_NAME" --verify-tag
|
|
63
|
+
fi
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
## [0.1.0] - 2026-10-15
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- Add update-kit 0.1.0
|
|
6
|
+
|
|
7
|
+
### 🐛 Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Harden update chain, tag parsing and cache repair
|
|
10
|
+
- **security:** Validate repo URL, end-of-options before URL, run git/install from home
|
|
11
|
+
- Install step never prompts for git credentials; test: kill 4 surviving mutants
|
|
12
|
+
|
|
13
|
+
### ⚙️ Miscellaneous Tasks
|
|
14
|
+
|
|
15
|
+
- Add tag-triggered PyPI release workflow
|
|
16
|
+
## [Unreleased]
|
|
17
|
+
|
|
18
|
+
### 🔒 Security
|
|
19
|
+
|
|
20
|
+
- The install step (uv / pipx, and the Windows detached chain) runs with `GIT_TERMINAL_PROMPT=0`
|
|
21
|
+
and `GCM_INTERACTIVE=never`, so the installer's own git fetch fails instead of blocking on a
|
|
22
|
+
credential prompt
|
|
23
|
+
|
|
24
|
+
## [0.1.0] - 2026-10-15
|
|
25
|
+
|
|
26
|
+
### 🚀 Features
|
|
27
|
+
|
|
28
|
+
- Initial release: `latest_tag`, `install_kind`, `update`, `check`, `hint`, `should_hint`,
|
|
29
|
+
`normalize_lang` — self-update for git-tag-released CLIs installed with uv tool or pipx,
|
|
30
|
+
en + zh-TW messages, cached background check in `$HOME/.update-kit/state.json`
|
|
31
|
+
|
|
32
|
+
### 🔒 Security
|
|
33
|
+
|
|
34
|
+
- Repo URL (`url=` / `UPDATE_KIT_REPO_URL`) is validated (`https://`, `ssh://`, `file://`,
|
|
35
|
+
`git@host:path`; no leading `-`, control chars, `ext::` or embedded credentials) and passed to
|
|
36
|
+
`git ls-remote` after `--` with `protocol.ext.allow=never`, so `--upload-pack=...` can no longer
|
|
37
|
+
run code from the background `check()`
|
|
38
|
+
- The POSIX install and the Windows detached chain run with the home directory as cwd, so
|
|
39
|
+
`cmd.exe` no longer resolves `uv` / the after-steps from a planted `uv.bat` / `crw.exe` in the
|
|
40
|
+
caller's cwd; `git ls-remote` also runs from home. Only `https`, `ssh`, `file` schemes (lowercase)
|
|
41
|
+
are accepted now (plain `http://` is rejected)
|
|
42
|
+
- git stderr is stripped of control characters and URL userinfo before printing; `update` names
|
|
43
|
+
`UPDATE_KIT_REPO_URL` when it is in effect (en + zh-TW)
|
|
44
|
+
- Tag parsing: version parts capped at 9 digits, exact `\trefs/tags/` split, no whitespace
|
|
45
|
+
tolerance on tag names; `check()` only returns strict tags
|
|
46
|
+
- `check()` caches by resolved source URL, so an override can't poison the default entry
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
## Frozen public API
|
|
4
|
+
|
|
5
|
+
`update_kit`'s public functions (`latest_tag`, `install_kind`, `update`, `check`, `hint`,
|
|
6
|
+
`should_hint`, `normalize_lang`, `UpdateError`) are consumed by aicp, ai-accounts,
|
|
7
|
+
codex-reset-watch and agent-toggle. Signatures, defaults, return codes and the
|
|
8
|
+
`$HOME/.update-kit/state.json` layout are a contract: changing any of them is a breaking
|
|
9
|
+
change (`type(scope)!:` + `BREAKING CHANGE:` footer, minor bump while 0.x).
|
|
10
|
+
|
|
11
|
+
## Rules
|
|
12
|
+
|
|
13
|
+
- Stdlib only. No runtime dependencies, no `packaging`.
|
|
14
|
+
- Every user-visible string exists in `en` and `zh-TW` in `_MESSAGES`.
|
|
15
|
+
- Never prompt; never hit the network in tests (use a `file://` repo in `tmp_path` or a fake `run`).
|
|
16
|
+
- Keep `README.md` and `CHANGELOG.md` in sync with user-visible changes.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
- Test: `uv run pytest`
|
|
21
|
+
- Lint: `uv run ruff check .`
|
|
22
|
+
- Build: `uv build`
|
|
23
|
+
|
|
24
|
+
## Cross-platform
|
|
25
|
+
|
|
26
|
+
Code must run on macOS, Linux, and Windows (paths, separators, shell commands, line
|
|
27
|
+
endings, `$HOME` vs `%USERPROFILE%`, file locking, symlinks, terminal/ANSI). Guard
|
|
28
|
+
platform-specific code and say so.
|
|
29
|
+
|
|
30
|
+
- Reuse the shared platform helpers first: `src/update_kit/__init__.py` (`_IS_WINDOWS`, `_windows_chain`, `_spawn_detached`).
|
|
31
|
+
- Text I/O always passes `encoding="utf-8"` (Windows defaults to cp1252).
|
|
32
|
+
- Windows is verified by CI's `windows-latest` job, not locally; POSIX-only tests are
|
|
33
|
+
skipped on Windows (`os.name == "nt"`).
|
update_kit-0.1.0/LICENSE
ADDED
|
@@ -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.
|
|
@@ -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,96 @@
|
|
|
1
|
+
# update-kit
|
|
2
|
+
|
|
3
|
+
Self-update for Python CLIs that are released as git tags (`vX.Y.Z` or `X.Y.Z`) and installed with
|
|
4
|
+
`uv tool` or `pipx`. Stdlib only, Python ≥ 3.10, macOS / Linux / Windows.
|
|
5
|
+
|
|
6
|
+
- `<cmd> update` installs the newest release tag — no prompts, ever.
|
|
7
|
+
- A cached background check prints a one-line hint on an interactive terminal only.
|
|
8
|
+
- Every message exists in English and Traditional Chinese (`zh-TW`).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```toml
|
|
13
|
+
# consumer pyproject.toml
|
|
14
|
+
dependencies = ["update-kit>=0.1,<0.2"]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Wiring a consumer CLI
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
import update_kit
|
|
23
|
+
|
|
24
|
+
from my_cli import __version__
|
|
25
|
+
|
|
26
|
+
APP, REPO, DIST = "my-cli", "owner/my-cli", "my-cli"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv=None):
|
|
30
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
31
|
+
lang = "zh-TW" # the app's own current language setting
|
|
32
|
+
|
|
33
|
+
# `my-cli update` and `my-cli --update`
|
|
34
|
+
if argv[:1] == ["update"] or "--update" in argv:
|
|
35
|
+
sys.exit(update_kit.update(
|
|
36
|
+
app=APP, repo=REPO, dist=DIST, lang=lang,
|
|
37
|
+
after=[["my-cli", "migrate"]], # optional: runs with the NEW binary
|
|
38
|
+
))
|
|
39
|
+
|
|
40
|
+
# Background hint: stderr, TTY only (never under launchd/timers/--json/pipes).
|
|
41
|
+
if update_kit.should_hint():
|
|
42
|
+
latest = update_kit.check(repo=REPO, current=__version__)
|
|
43
|
+
if latest:
|
|
44
|
+
print(update_kit.hint("my-cli", latest, __version__, lang), file=sys.stderr)
|
|
45
|
+
...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
| Function | Does |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `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). |
|
|
53
|
+
| `install_kind(dist) -> (kind, detail)` | `"dev"` (PEP 610 local/editable dir, detail = the dir), `"uv"` / `"pipx"` (from `sys.prefix`), else `"unknown"`. |
|
|
54
|
+
| `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). |
|
|
55
|
+
| `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. |
|
|
56
|
+
| `hint(cmd, latest, current, lang="en") -> str` | `my-cli v1.2.0 is available (you have 1.1.0) — run` `` `my-cli update` `` |
|
|
57
|
+
| `should_hint(stream=sys.stderr) -> bool` | `True` only when `stream` is a TTY. |
|
|
58
|
+
| `normalize_lang(value) -> str` | `zh_TW` / `zh-tw` / `zh-Hant` / `zh_HK` → `"zh-TW"`; anything else → `"en"`. |
|
|
59
|
+
|
|
60
|
+
Install commands used by `update`:
|
|
61
|
+
|
|
62
|
+
- uv: `uv tool install --force --no-sources git+<url>@<tag>`
|
|
63
|
+
- pipx: `pipx install --force git+<url>@<tag>`
|
|
64
|
+
|
|
65
|
+
`<url>` is `url=`, else `$UPDATE_KIT_REPO_URL`, else `https://github.com/<repo>.git`
|
|
66
|
+
(`file://` works, which is how the tests run without a network). Only `https://`, `ssh://`,
|
|
67
|
+
`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,
|
|
68
|
+
`ext::` or credentials in the URL (`user:pass@`) raise `UpdateError` (`update` returns 1,
|
|
69
|
+
`check` returns `None`). When `$UPDATE_KIT_REPO_URL` is in effect, `update` prints one line
|
|
70
|
+
naming it. `git ls-remote` runs as `git -c protocol.ext.allow=never ls-remote --tags --refs -- <url>`
|
|
71
|
+
from the home directory, and so does the install; git's stderr is stripped of control characters.
|
|
72
|
+
|
|
73
|
+
**Windows:** the running `.exe` is locked, so `update` starts the install (and the `after`
|
|
74
|
+
steps, chained with `&&`) in a detached `cmd.exe` that waits a few seconds for the app to
|
|
75
|
+
exit, returns 0 and says the update continues in the background. Output goes to
|
|
76
|
+
`%USERPROFILE%\.update-kit\<app>-update.log`. Unlike POSIX (where every after-step runs and
|
|
77
|
+
any failure makes the result 1), the Windows chain stops at the first failing step and the
|
|
78
|
+
outcome is only in the log. An install whose arguments or log path contain a `cmd.exe`
|
|
79
|
+
metacharacter (`& | < > ^ % " ( ) !`) is refused with exit 1 instead of being spawned.
|
|
80
|
+
|
|
81
|
+
`git ls-remote` never prompts: `GIT_TERMINAL_PROMPT=0`, and `GIT_SSH_COMMAND` defaults to
|
|
82
|
+
`ssh -o BatchMode=yes` when unset. The install step (uv / pipx) also runs with
|
|
83
|
+
`GIT_TERMINAL_PROMPT=0` and `GCM_INTERACTIVE=never`.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv sync
|
|
89
|
+
uv run pytest
|
|
90
|
+
uv run ruff check .
|
|
91
|
+
uv build
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
update_kit-0.1.0/TODO.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# TODO
|
|
2
|
+
|
|
3
|
+
- [ ] Pin the install to the commit SHA seen by `ls-remote` (TOCTOU: a tag can move between check and install)
|
|
4
|
+
- [ ] Verify release tag signatures before installing
|
|
5
|
+
- [ ] Windows: launch `cmd.exe` (ComSpec) and `git` by absolute path (CreateProcess searches the parent's cwd first)
|
|
6
|
+
- [ ] Run `after` steps with cwd=home too; document that an injected `run` must accept `cwd=`
|
|
7
|
+
- [x] 0.1.0: frozen public API, tests, CI matrix (macOS / Linux / Windows)
|
|
8
|
+
- [ ] Create the GitHub repo, push, tag `v0.1.0` and publish to PyPI (manual hand-off)
|
|
9
|
+
- [ ] Add `.github/workflows/release.yml` (PyPI Trusted Publishing) like telegram-kit
|
|
10
|
+
- [ ] Verify the Windows detached update chain on a real Windows uv tool install
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "update-kit"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Self-update for git-tag-released Python CLIs (uv tool / pipx). Stdlib only."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.version]
|
|
15
|
+
path = "src/update_kit/__init__.py"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/update_kit"]
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8",
|
|
23
|
+
"ruff>=0.15.21",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|