pi-sync-cli 0.4.0__tar.gz → 0.5.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.
@@ -0,0 +1,62 @@
1
+ # AGENTS.md
2
+
3
+ Working notes for AI agents (and humans) changing this repo. User-facing
4
+ documentation lives in [README.md](README.md); layout, tooling and the release
5
+ process live in [DEVELOPMENT.md](DEVELOPMENT.md).
6
+
7
+ ## What this is
8
+
9
+ pi-sync: a single click module that rsyncs pi agent config between hosts.
10
+ `src/pi_sync/cli.py` is the whole program and `tests/test_cli.py` the whole
11
+ suite, so a change is one file plus its tests.
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ uv sync # create/refresh .venv and uv.lock
17
+ uv run pytest # the suite (fast, offline)
18
+ uv run pi-sync --help
19
+ ```
20
+
21
+ ## Invariants — please don't "fix" these
22
+
23
+ - **Entry point is `pi_sync.cli:app`**, a click group with a default-command
24
+ fallback: `DefaultGroup.parse_args` prepends `sync` when the first token is not
25
+ a subcommand, and `SyncCommand.format_usage` keeps help reading
26
+ `pi-sync [OPTIONS] ...`. The sync function stays named `main` because the tests
27
+ invoke `cli.main` directly.
28
+ - **No `__version__`.** The version comes from distribution metadata; adding one
29
+ back reintroduces the drift that removal fixed.
30
+ - **Tests never spawn anything.** `FakeRun` records argv and replays output;
31
+ extend it instead of calling `subprocess`. That guarantee is deliberate.
32
+ - **Host probes are POSIX sh piped to `ssh host sh -s`**, because some hosts run
33
+ fish and `for …; do … done` is a syntax error there.
34
+ - **Never trust pi's installer exit status.** Its "do nothing" menu choice exits
35
+ 0, so `install_pi` re-probes the host. Keep new install/uninstall paths
36
+ verification-based.
37
+ - **`update` resolves the distribution from its own environment**; do not
38
+ hardcode `pi-sync-cli`, since an older install can coexist under `pi-sync` and
39
+ a hardcoded name would upgrade the wrong virtualenv.
40
+ - **`--dry-run` must not mutate anything**, including installing or uninstalling
41
+ pi on a host.
42
+
43
+ ## Conventions
44
+
45
+ - Commit messages: all lowercase, conventional prefix (`fix:`, `feat:`, `chore:`,
46
+ `ci:`, `docs:`).
47
+ - Run `uv run pytest` and `lens_diagnostics mode=all` before declaring work done;
48
+ fix blockers rather than reporting around them.
49
+ - pi-lens reformats files on write and sometimes *after* a commit. Check
50
+ `git status` before finishing and commit formatter churn separately.
51
+ - `.pi/tasks/` is gitignored local task output — never commit it.
52
+ - Never commit secrets. `auth.json` is host-local and only travels with an
53
+ explicit `--auth`.
54
+
55
+ ## Working against real hosts
56
+
57
+ - This Mac drives everything; `tinfoil` and the Raspberry Pi (`pi`) are real
58
+ hosts and can be offline.
59
+ - Prefer `--dry-run` when demonstrating. A real push overwrites host config — a
60
+ `.backup` is kept, but don't be cavalier about it.
61
+ - `~/.ssh/config` is protected: read it through code that extracts host aliases,
62
+ and don't dump its contents into output or a commit.
@@ -0,0 +1,119 @@
1
+ # Developing pi-sync
2
+
3
+ ## Layout
4
+
5
+ ```text
6
+ src/pi_sync/cli.py the whole CLI: probing, rsync, completions, self-update
7
+ tests/test_cli.py unit tests (no ssh or rsync process is ever executed)
8
+ pyproject.toml click dependency, console script, uv dev group
9
+ pyrightconfig.json points pyright at .venv (see "Tooling" below)
10
+ ```
11
+
12
+ Three design anchors worth understanding before editing:
13
+
14
+ - **The console script points at `pi_sync.cli:app`**, a click *group*.
15
+ `DefaultGroup.parse_args` prepends `sync` when the first argument is not a
16
+ subcommand, which is what lets `pi-sync tinfoil`, `pi-sync --config tinfoil`
17
+ and `pi-sync update` all work. `SyncCommand.format_usage` exists so help reads
18
+ `pi-sync [OPTIONS] [USER@]HOST...` instead of `pi-sync sync [OPTIONS] ...`.
19
+ `main` is the sync subcommand and keeps that name because the tests drive it
20
+ directly.
21
+ - **The version comes from distribution metadata** (`@click.version_option`), so
22
+ there is no `__version__` to fall out of step with the release.
23
+ - **`update` resolves the distribution from its own environment**
24
+ (`importlib.metadata.packages_distributions()`) rather than hardcoding a name.
25
+ An older install can coexist under the pre-rename name `pi-sync`, and a
26
+ hardcoded name would upgrade the wrong virtualenv — or nothing.
27
+
28
+ ## Commands
29
+
30
+ ```bash
31
+ uv sync # create/refresh .venv and uv.lock
32
+ uv run pytest # the suite
33
+ uv run pi-sync --help
34
+ uv build # wheel + sdist into dist/
35
+ ```
36
+
37
+ ## Tests
38
+
39
+ `FakeRun` records every command and replays canned output, so the suite needs no
40
+ network, no hosts, and no real transfers. When you add a command, extend
41
+ `FakeRun` rather than reaching for `subprocess` — the guarantee that nothing is
42
+ executed is what keeps the suite fast and safe.
43
+
44
+ The one path that cannot be unit-tested is the interactive installer handover,
45
+ because it depends on a real terminal. It was verified out-of-band with a PTY
46
+ harness that attaches a pseudo-terminal, feeds a keystroke, and asserts the
47
+ installer's `/dev/tty` menu reached the terminal and that the keypress reached
48
+ the remote host.
49
+
50
+ ## Tooling
51
+
52
+ `pi-lens` runs ruff format/lint as files are written, and sometimes reformats
53
+ *after* a commit — check `git status` before finishing, and commit formatter
54
+ churn on its own.
55
+
56
+ `pyrightconfig.json` sets `venvPath`/`venv` because pyright does not pick up a
57
+ uv-created venv on its own here. Measured: without it, `Import "pytest" could
58
+ not be resolved`; with it, zero errors. A long-lived pyright language server
59
+ that started before the venv existed will keep reporting the stale result until
60
+ it is restarted.
61
+
62
+ ## Releasing
63
+
64
+ ```bash
65
+ uv version --bump minor # or patch; also updates uv.lock
66
+ git commit -am "chore: release X.Y.Z"
67
+ git push origin main
68
+ git tag -a vX.Y.Z -m vX.Y.Z
69
+ git push origin vX.Y.Z # publishing happens on the tag
70
+ ```
71
+
72
+ `.github/workflows/publish.yml` then builds, smoke-tests both artifacts *through
73
+ the console script*, and publishes with OIDC trusted publishing — no token is
74
+ stored anywhere. It refuses to publish when the tag does not match the version in
75
+ `pyproject.toml`.
76
+
77
+ Verify a release:
78
+
79
+ ```bash
80
+ gh run view <run-id> --log | grep "Uploading pi_"
81
+ curl -s https://pypi.org/pypi/pi-sync-cli/X.Y.Z/json # per-version: immediate
82
+ ```
83
+
84
+ Expect **CDN lag** on the aggregate endpoints. Measured on 0.4.0: the
85
+ per-version endpoint answered immediately, `/pypi/pi-sync-cli/json` took ~45s,
86
+ and the simple index took ~40s. Until the simple index flips,
87
+ `uvx --from pi-sync-cli==X.Y.Z` fails with "no version … requirements are
88
+ unsatisfiable", which looks like a broken release but is not. Re-tagging never
89
+ helps (PyPI rejects duplicate versions); `workflow_dispatch` re-runs the job
90
+ without a new tag.
91
+
92
+ ## PyPI notes
93
+
94
+ The distribution is `pi-sync-cli`; the command is `pi-sync`. Plain `pi-sync` is
95
+ permanently unavailable: PyPI compares names with punctuation stripped, and
96
+ `pisync` already exists (an unrelated rsync backup script).
97
+
98
+ Trusted publisher fields: project `pi-sync-cli`, owner `say4n`, repository
99
+ `pi-sync`, workflow `publish.yml`, environment `pypi`. A pending publisher
100
+ reserves nothing until first use — it is invalidated if someone else registers
101
+ the name in the meantime.
102
+
103
+ ## Platform gotchas found the hard way
104
+
105
+ - macOS ships **openrsync**, which rejects rsync 3 flags. Only flags verified
106
+ against it are used; `--ignore-missing-args`, `--human-readable` and
107
+ `--mkpath` are not available.
108
+ - `--backup` with `--delete` fails on openrsync acting as *receiver*
109
+ (`fchownat: Operation not permitted`, exit 23) but works fine with rsync 3.x,
110
+ so backups are suppressed whenever `--delete` is in play.
111
+ - rsync's default check compares size and mtime at 1-second granularity: two
112
+ files created in the same second with equal sizes look "already in sync". Use
113
+ distinct mtimes when testing backups, or the test proves nothing.
114
+ - Remote shells are **fish** on some hosts, so probes are POSIX sh piped over
115
+ stdin (`ssh host sh -s`), never a shell loop in the command string
116
+ (`for …; do … done` is a syntax error there).
117
+ - pi's installer reads `/dev/tty`, not stdin, so capturing its output makes its
118
+ prompts invisible while it waits for a keypress. Interactive installs must
119
+ stream; unattended ones must close stdin so a prompt fails fast.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pi-sync-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Sync pi agent config and extensions between hosts over rsync
5
5
  Project-URL: Repository, https://github.com/say4n/pi-sync
6
6
  Project-URL: Issues, https://github.com/say4n/pi-sync/issues
@@ -25,6 +25,7 @@ pi-sync tinfoil # push config + extensions
25
25
  pi-sync --config laptop # only models.json and settings.json
26
26
  pi-sync --pull --all tinfoil # fetch the host's config back
27
27
  pi-sync --dry-run --all a b # preview against two hosts
28
+ pi-sync update # update pi-sync itself
28
29
  ```
29
30
 
30
31
  ## Install
@@ -64,6 +65,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
64
65
  | `-x, --exclude PATTERN` | skip matching files (repeatable) |
65
66
  | `--install` | install pi on hosts that lack it, without prompting |
66
67
  | `--uninstall` | remove pi from the host instead of syncing (config is kept) |
68
+ | `--update-pi` | update pi on each host before syncing |
67
69
  | `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
68
70
  | `--remote-dir` | default `~/.pi/agent` |
69
71
  | `-v` / `--verbose` | print each rsync command and its output |
@@ -71,6 +73,9 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
71
73
  Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
72
74
  unreachable or any transfer fails.
73
75
 
76
+ Anything pi-sync overwrites on the destination is kept beside it as
77
+ `<name>.backup`.
78
+
74
79
  ## Host preflight
75
80
 
76
81
  Each host gets one ssh probe that reports reachability and pi's location in the
@@ -99,6 +104,30 @@ The probe checks `command -v pi` plus the usual install locations
99
104
  `/usr/local/bin`), because a non-interactive ssh session does not source the
100
105
  host's shell init — on a linuxbrew host `command -v pi` alone misses it.
101
106
 
107
+ ## Updating
108
+
109
+ `pi-sync update` upgrades this tool through whichever installer owns it —
110
+ `pipx upgrade`, `uv tool upgrade`, or `pip install --upgrade` — and reports the
111
+ version it moved from and to. `--check` reports without changing anything.
112
+ Running from a source checkout it tells you to `git pull` instead, and from an
113
+ ephemeral `uvx --from …` environment it explains that there is nothing to
114
+ upgrade.
115
+
116
+ `pi-sync --update-pi <hosts>` runs pi's own updater (`pi update --self`) on each
117
+ host before syncing, so the fleet does not drift:
118
+
119
+ ```console
120
+ $ pi-sync --update-pi tinfoil
121
+ → tinfoil
122
+ pi 0.85.1 → 0.86.0
123
+ models.json already in sync
124
+ settings.json already in sync
125
+ extensions already in sync
126
+ ```
127
+
128
+ `update` is a reserved word — a host with that alias is still reachable as
129
+ `user@update`.
130
+
102
131
  ## Uninstalling
103
132
 
104
133
  `--uninstall` removes pi from the host instead of syncing. It runs
@@ -117,7 +146,13 @@ uninstall fails, that is usually a managed install
117
146
  ## Shell completions
118
147
 
119
148
  Host arguments complete from `~/.ssh/config`, following `Include` directives and
120
- skipping wildcard entries:
149
+ skipping wildcard entries. zsh and fish also show where each alias points:
150
+
151
+ ```console
152
+ $ pi-sync t<TAB>
153
+ tinfoil tinfoil@tinfoil.sayan.page
154
+ tinfoil-proxy notdebian@100.98.241.11
155
+ ```
121
156
 
122
157
  ```bash
123
158
  # bash
@@ -145,12 +180,9 @@ without regenerating anything.
145
180
  toggles), so two hosts pushing it will overwrite each other's local
146
181
  preferences. Sync it when you change `packages`, not reflexively — and note the
147
182
  overwritten copy is kept as `settings.json.backup` on the receiving host.
148
- - Anything pi-sync overwrites is kept on the destination as `<name>.backup`, and
149
- `*.backup` is never synced, so those copies stay host-local and never
150
- trampoline between hosts. `--delete` suppresses backups for the mirrored
151
- directory, because mirroring means "match exactly" — which also sidesteps an
152
- openrsync bug where backing up a file it deletes fails with
153
- `fchownat: Operation not permitted`.
183
+ - `*.backup` files are never synced, so those copies stay host-local and never
184
+ trampoline between hosts. `--delete` suppresses backups for the mirrored
185
+ directory, because mirroring means "match exactly".
154
186
  - Extensions that write runtime files inside their own directory (logs,
155
187
  checkpoints) get those files synced too, and each host's copy is overwritten by
156
188
  whichever side pushed last — exclude them with `-x '*/logs/*'`.
@@ -160,10 +192,6 @@ trampoline between hosts. `--delete` suppresses backups for the mirrored
160
192
  in the environment where you can.
161
193
  - Remote paths go through the host's shell, so `~` expands there as usual.
162
194
 
163
- ## Development
195
+ ## Developing
164
196
 
165
- ```bash
166
- uv sync
167
- uv run pytest
168
- uv run pi-sync --help
169
- ```
197
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for the layout, tests and release process.
@@ -8,6 +8,7 @@ pi-sync tinfoil # push config + extensions
8
8
  pi-sync --config laptop # only models.json and settings.json
9
9
  pi-sync --pull --all tinfoil # fetch the host's config back
10
10
  pi-sync --dry-run --all a b # preview against two hosts
11
+ pi-sync update # update pi-sync itself
11
12
  ```
12
13
 
13
14
  ## Install
@@ -47,6 +48,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
47
48
  | `-x, --exclude PATTERN` | skip matching files (repeatable) |
48
49
  | `--install` | install pi on hosts that lack it, without prompting |
49
50
  | `--uninstall` | remove pi from the host instead of syncing (config is kept) |
51
+ | `--update-pi` | update pi on each host before syncing |
50
52
  | `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
51
53
  | `--remote-dir` | default `~/.pi/agent` |
52
54
  | `-v` / `--verbose` | print each rsync command and its output |
@@ -54,6 +56,9 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
54
56
  Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
55
57
  unreachable or any transfer fails.
56
58
 
59
+ Anything pi-sync overwrites on the destination is kept beside it as
60
+ `<name>.backup`.
61
+
57
62
  ## Host preflight
58
63
 
59
64
  Each host gets one ssh probe that reports reachability and pi's location in the
@@ -82,6 +87,30 @@ The probe checks `command -v pi` plus the usual install locations
82
87
  `/usr/local/bin`), because a non-interactive ssh session does not source the
83
88
  host's shell init — on a linuxbrew host `command -v pi` alone misses it.
84
89
 
90
+ ## Updating
91
+
92
+ `pi-sync update` upgrades this tool through whichever installer owns it —
93
+ `pipx upgrade`, `uv tool upgrade`, or `pip install --upgrade` — and reports the
94
+ version it moved from and to. `--check` reports without changing anything.
95
+ Running from a source checkout it tells you to `git pull` instead, and from an
96
+ ephemeral `uvx --from …` environment it explains that there is nothing to
97
+ upgrade.
98
+
99
+ `pi-sync --update-pi <hosts>` runs pi's own updater (`pi update --self`) on each
100
+ host before syncing, so the fleet does not drift:
101
+
102
+ ```console
103
+ $ pi-sync --update-pi tinfoil
104
+ → tinfoil
105
+ pi 0.85.1 → 0.86.0
106
+ models.json already in sync
107
+ settings.json already in sync
108
+ extensions already in sync
109
+ ```
110
+
111
+ `update` is a reserved word — a host with that alias is still reachable as
112
+ `user@update`.
113
+
85
114
  ## Uninstalling
86
115
 
87
116
  `--uninstall` removes pi from the host instead of syncing. It runs
@@ -100,7 +129,13 @@ uninstall fails, that is usually a managed install
100
129
  ## Shell completions
101
130
 
102
131
  Host arguments complete from `~/.ssh/config`, following `Include` directives and
103
- skipping wildcard entries:
132
+ skipping wildcard entries. zsh and fish also show where each alias points:
133
+
134
+ ```console
135
+ $ pi-sync t<TAB>
136
+ tinfoil tinfoil@tinfoil.sayan.page
137
+ tinfoil-proxy notdebian@100.98.241.11
138
+ ```
104
139
 
105
140
  ```bash
106
141
  # bash
@@ -128,12 +163,9 @@ without regenerating anything.
128
163
  toggles), so two hosts pushing it will overwrite each other's local
129
164
  preferences. Sync it when you change `packages`, not reflexively — and note the
130
165
  overwritten copy is kept as `settings.json.backup` on the receiving host.
131
- - Anything pi-sync overwrites is kept on the destination as `<name>.backup`, and
132
- `*.backup` is never synced, so those copies stay host-local and never
133
- trampoline between hosts. `--delete` suppresses backups for the mirrored
134
- directory, because mirroring means "match exactly" — which also sidesteps an
135
- openrsync bug where backing up a file it deletes fails with
136
- `fchownat: Operation not permitted`.
166
+ - `*.backup` files are never synced, so those copies stay host-local and never
167
+ trampoline between hosts. `--delete` suppresses backups for the mirrored
168
+ directory, because mirroring means "match exactly".
137
169
  - Extensions that write runtime files inside their own directory (logs,
138
170
  checkpoints) get those files synced too, and each host's copy is overwritten by
139
171
  whichever side pushed last — exclude them with `-x '*/logs/*'`.
@@ -143,10 +175,6 @@ trampoline between hosts. `--delete` suppresses backups for the mirrored
143
175
  in the environment where you can.
144
176
  - Remote paths go through the host's shell, so `~` expands there as usual.
145
177
 
146
- ## Development
178
+ ## Developing
147
179
 
148
- ```bash
149
- uv sync
150
- uv run pytest
151
- uv run pi-sync --help
152
- ```
180
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for the layout, tests and release process.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pi-sync-cli"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Sync pi agent config and extensions between hosts over rsync"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -20,7 +20,7 @@ Repository = "https://github.com/say4n/pi-sync"
20
20
  Issues = "https://github.com/say4n/pi-sync/issues"
21
21
 
22
22
  [project.scripts]
23
- pi-sync = "pi_sync.cli:main"
23
+ pi-sync = "pi_sync.cli:app"
24
24
 
25
25
  [dependency-groups]
26
26
  dev = ["pytest>=8"]
@@ -0,0 +1,5 @@
1
+ """Sync pi agent config between hosts over rsync.
2
+
3
+ The version lives in the distribution metadata (see `pi-sync --version`), so
4
+ there is deliberately no `__version__` here to fall out of step with it.
5
+ """
@@ -12,12 +12,17 @@ is deliberately never touched.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import json
15
16
  import os
17
+ import re
16
18
  import subprocess
17
19
  import sys
18
20
  from collections.abc import Sequence
19
21
  from dataclasses import dataclass
22
+ from importlib import metadata
20
23
  from pathlib import Path
24
+ from urllib.parse import urlparse
25
+ from urllib.request import urlopen
21
26
 
22
27
  import click
23
28
  from click.shell_completion import CompletionItem
@@ -360,8 +365,192 @@ def sync_host(
360
365
  return ok
361
366
 
362
367
 
363
- @click.command(context_settings={"help_option_names": ["-h", "--help"]})
368
+ PYPI_JSON = "https://pypi.org/pypi/{dist}/json"
369
+ EPHEMERAL_MARKERS = ("/uv/archive-", "/uv/environments-")
370
+
371
+
372
+ @dataclass(frozen=True)
373
+ class Install:
374
+ """How the running copy of pi-sync got onto this machine."""
375
+
376
+ dist: str
377
+ version: str
378
+ kind: str
379
+ detail: str = ""
380
+
381
+
382
+ def install_kind(direct_url: str, prefix: str) -> tuple[str, str]:
383
+ """Classify an environment from its PEP 610 payload and its prefix.
384
+
385
+ The payload is parsed rather than pattern-matched: uv and pip disagree about
386
+ whitespace in `direct_url.json`, so a substring test silently misses one.
387
+ """
388
+ try:
389
+ info = json.loads(direct_url) if direct_url else {}
390
+ except ValueError:
391
+ info = {}
392
+ if not isinstance(info, dict):
393
+ info = {}
394
+ dir_info = info.get("dir_info")
395
+ if isinstance(dir_info, dict) and dir_info.get("editable"):
396
+ return "editable", str(info.get("url", "")).removeprefix("file://")
397
+ if "/pipx/venvs/" in prefix:
398
+ return "pipx", prefix
399
+ if "/uv/tools/" in prefix:
400
+ return "uv-tool", prefix
401
+ if any(marker in prefix for marker in EPHEMERAL_MARKERS):
402
+ return "ephemeral", prefix
403
+ return "pip", prefix
404
+
405
+
406
+ def running_install() -> Install:
407
+ """Identify the install we are running *from*, not the one merely on PATH.
408
+
409
+ Two installs of this tool can coexist (an older distribution name and the
410
+ current one), so the distribution is resolved from this process's own
411
+ environment: a hardcoded package name would happily upgrade somebody else's
412
+ virtualenv, or none at all.
413
+ """
414
+ found = metadata.packages_distributions().get("pi_sync") or ["pi-sync-cli"]
415
+ dist = metadata.distribution(found[0])
416
+ kind, detail = install_kind(dist.read_text("direct_url.json") or "", sys.prefix)
417
+ return Install(dist.metadata["Name"], dist.version, kind, detail)
418
+
419
+
420
+ def upgrade_argv(install: Install) -> list[str] | None:
421
+ """The command that upgrades this install, or None when it is not managed."""
422
+ if install.kind == "pipx":
423
+ return ["pipx", "upgrade", install.dist]
424
+ if install.kind == "uv-tool":
425
+ return ["uv", "tool", "upgrade", install.dist]
426
+ if install.kind == "pip":
427
+ return [sys.executable, "-m", "pip", "install", "--upgrade", install.dist]
428
+ return None
429
+
430
+
431
+ def latest_version(dist: str) -> str | None:
432
+ """Newest release on PyPI, or None when it cannot be determined."""
433
+ url = PYPI_JSON.format(dist=dist)
434
+ if urlparse(url).scheme != "https": # never fetch over anything else
435
+ return None
436
+ try:
437
+ with urlopen(url, timeout=10) as response: # noqa: S310 - scheme checked
438
+ return json.load(response)["info"]["version"]
439
+ except Exception: # offline, renamed project, unexpected payload
440
+ return None
441
+
442
+
443
+ def version_tuple(value: str) -> tuple[int, ...]:
444
+ """The leading numeric release, ignoring any pre-release suffix."""
445
+ match = re.match(r"\d+(?:\.\d+)*", value.strip())
446
+ if not match:
447
+ return ()
448
+ try:
449
+ return tuple(int(part) for part in match.group(0).split("."))
450
+ except ValueError: # unreachable: the pattern only matches digits
451
+ return ()
452
+
453
+
454
+ def pi_version_on(target: str) -> str | None:
455
+ """The host's pi version, or None when it cannot be read."""
456
+ proc = run_cmd(["ssh", target, "pi --version"])
457
+ text = (proc.stdout or "").strip()
458
+ return text if proc.returncode == 0 and text else None
459
+
460
+
461
+ def update_pi_on(target: str) -> str | None:
462
+ """Run pi's own updater on the host. Returns an error message, or None."""
463
+ proc = run_cmd(["ssh", target, "pi update --self"])
464
+ if proc.returncode == 0:
465
+ return None
466
+ detail = ((proc.stderr or proc.stdout) or "").strip().splitlines()
467
+ return detail[-1] if detail else f"pi update exited {proc.returncode}"
468
+
469
+
470
+ class SyncCommand(click.Command):
471
+ """`sync` is the default command, so its usage should not read `pi-sync sync`."""
472
+
473
+ def format_usage(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
474
+ pieces = " ".join(self.collect_usage_pieces(ctx))
475
+ program = ctx.find_root().info_name or "pi-sync"
476
+ formatter.write_usage(program, pieces, prefix=None)
477
+
478
+
479
+ class DefaultGroup(click.Group):
480
+ """Group that treats the absence of a subcommand as `sync`.
481
+
482
+ This keeps `pi-sync host`, `pi-sync --config host` and `pi-sync update` all
483
+ working. The cost is that "update" is now a reserved word: a host with that
484
+ alias has to be reached as `user@update` or by renaming the alias.
485
+
486
+ The prepend happens before click parses the group's options, because that
487
+ parser rejects sync's flags (`No such option: --config`) long before
488
+ command resolution would get a chance to fall back.
489
+ """
490
+
491
+ def parse_args( # type: ignore[override]
492
+ self, ctx: click.Context, args: list[str]
493
+ ) -> list[str]:
494
+ if args and args[0] not in self.commands and args[0] != "--version":
495
+ args = ["sync", *args]
496
+ return super().parse_args(ctx, args)
497
+
498
+
499
+ @click.group(cls=DefaultGroup, context_settings={"help_option_names": ["-h", "--help"]})
364
500
  @click.version_option(package_name="pi-sync-cli")
501
+ def app() -> None:
502
+ """Sync pi agent config across hosts, and manage pi itself."""
503
+
504
+
505
+ @app.command("update", context_settings={"help_option_names": ["-h", "--help"]})
506
+ @click.option("--check", is_flag=True, help="Report what would happen; change nothing.")
507
+ def update_cmd(check: bool) -> None:
508
+ """Update pi-sync itself to the latest release.
509
+
510
+ \b
511
+ pi-sync update # upgrades via pipx, uv, or pip — whichever installed it
512
+ pi-sync update --check # just report
513
+ """
514
+ install = running_install()
515
+ click.echo(f"pi-sync {install.version} ({install.kind})")
516
+ if install.kind == "editable":
517
+ click.echo(
518
+ f"running from a checkout; update it with:\n git -C {install.detail} pull"
519
+ )
520
+ return
521
+ if install.kind == "ephemeral":
522
+ click.echo(
523
+ "running from an ephemeral uvx environment — nothing to update.\n"
524
+ "for a durable install: uv tool install pi-sync-cli"
525
+ )
526
+ return
527
+ argv = upgrade_argv(install)
528
+ if argv is None: # pragma: no cover - every kind above is handled earlier
529
+ click.secho("do not know how this copy was installed", fg="red")
530
+ raise SystemExit(1)
531
+ latest = latest_version(install.dist)
532
+ if latest is None:
533
+ click.secho(f"could not reach PyPI for {install.dist}", fg="red")
534
+ raise SystemExit(1)
535
+ if version_tuple(latest) <= version_tuple(install.version):
536
+ click.secho(f"already up to date (latest is {latest})", fg="green")
537
+ return
538
+ if check:
539
+ click.echo(f"would run: {' '.join(argv)}\n{install.version} → {latest}")
540
+ return
541
+ click.echo(f"upgrading {install.version} → {latest}...")
542
+ proc = run_cmd(argv, capture=False) # streamed: pipx/uv show their own progress
543
+ if proc.returncode:
544
+ click.secho(f"upgrade failed (exit {proc.returncode})", fg="red")
545
+ raise SystemExit(1)
546
+ click.secho(f"pi-sync {latest} installed", fg="green")
547
+
548
+
549
+ @app.command(
550
+ "sync",
551
+ cls=SyncCommand,
552
+ context_settings={"help_option_names": ["-h", "--help"]},
553
+ )
365
554
  @click.argument(
366
555
  "targets",
367
556
  nargs=-1,
@@ -411,6 +600,12 @@ def sync_host(
411
600
  is_flag=True,
412
601
  help="Uninstall pi from the host instead of syncing (keeps ~/.pi/agent).",
413
602
  )
603
+ @click.option(
604
+ "--update-pi",
605
+ "update_pi",
606
+ is_flag=True,
607
+ help="Update pi on each host before syncing.",
608
+ )
414
609
  @click.option(
415
610
  "--local-dir",
416
611
  default=None,
@@ -437,6 +632,7 @@ def main(
437
632
  excludes: tuple[str, ...],
438
633
  install_: bool,
439
634
  uninstall_: bool,
635
+ update_pi: bool,
440
636
  local_dir: str | None,
441
637
  remote_dir: str,
442
638
  verbose: bool,
@@ -447,6 +643,7 @@ def main(
447
643
  pi-sync tinfoil # push config + extensions
448
644
  pi-sync --config laptop # just models.json and settings.json
449
645
  pi-sync --pull --all tinfoil # fetch the host's config back
646
+ pi-sync update # update pi-sync itself
450
647
  """
451
648
  groups = {
452
649
  group
@@ -512,6 +709,20 @@ def main(
512
709
  if not ensure_pi(target, install_, remote_dir):
513
710
  failed = True
514
711
  continue
712
+ if update_pi:
713
+ if dry_run:
714
+ click.secho(" would run: pi update --self")
715
+ else:
716
+ before = pi_version_on(target)
717
+ error = update_pi_on(target)
718
+ after = pi_version_on(target)
719
+ if error:
720
+ failed = True
721
+ click.secho(f" pi update failed: {error}", fg="red")
722
+ elif before and after and before != after:
723
+ click.secho(f" pi {before} → {after}", fg="green")
724
+ else:
725
+ click.secho(f" pi {after or 'unknown'} (already current)")
515
726
  if not sync_host(
516
727
  target,
517
728
  items,
@@ -530,4 +741,4 @@ def main(
530
741
 
531
742
 
532
743
  if __name__ == "__main__":
533
- main()
744
+ app()
@@ -745,6 +745,270 @@ class TestSshHostDetails:
745
745
  def test_version_option_reports_the_installed_version() -> None:
746
746
  from importlib.metadata import version
747
747
 
748
- result = CliRunner().invoke(cli.main, ["--version"])
748
+ result = CliRunner().invoke(cli.app, ["--version"])
749
749
  assert result.exit_code == 0, result.output
750
750
  assert version("pi-sync-cli") in result.output
751
+
752
+
753
+ class TestGroupFallback:
754
+ """`pi-sync update` and `pi-sync <hosts>` must both work."""
755
+
756
+ def test_hosts_still_route_to_sync(self, fake: FakeRun, agent_dir: Path) -> None:
757
+ result = CliRunner().invoke(
758
+ cli.app, ["--config", "--local-dir", str(agent_dir), "host"]
759
+ )
760
+ assert result.exit_code == 0, result.output
761
+ assert any(c[0] == "rsync" for c in fake.calls)
762
+
763
+ def test_bare_host_routes_to_sync(self, fake: FakeRun, agent_dir: Path) -> None:
764
+ result = CliRunner().invoke(cli.app, ["--local-dir", str(agent_dir), "host"])
765
+ assert result.exit_code == 0, result.output
766
+ assert any(c[0] == "rsync" for c in fake.calls)
767
+
768
+ def test_help_shows_sync_options_not_just_subcommands(self) -> None:
769
+ result = CliRunner().invoke(cli.app, ["--help"])
770
+ assert "[USER@]HOST" in result.output
771
+
772
+ def test_version_is_answered_by_the_group(self) -> None:
773
+ result = CliRunner().invoke(cli.app, ["--version"])
774
+ assert result.exit_code == 0, result.output
775
+ assert "version" in result.output
776
+
777
+ def test_update_is_not_swallowed_by_the_fallback(
778
+ self, monkeypatch: pytest.MonkeyPatch
779
+ ) -> None:
780
+ monkeypatch.setattr(
781
+ cli,
782
+ "running_install",
783
+ lambda: cli.Install(
784
+ "pi-sync-cli", "0.4.0", "ephemeral", "/prefix/ephemeral"
785
+ ),
786
+ )
787
+ result = CliRunner().invoke(cli.app, ["update"])
788
+ assert result.exit_code == 0, result.output
789
+ assert "ephemeral" in result.output
790
+
791
+
792
+ class TestUpdate:
793
+ @staticmethod
794
+ def install(
795
+ kind: str,
796
+ version: str = "0.4.0",
797
+ dist: str = "pi-sync-cli",
798
+ detail: str | None = None,
799
+ ) -> cli.Install:
800
+ return cli.Install(dist, version, kind, detail or f"/prefix/{kind}")
801
+
802
+ def test_editable_checkout_points_at_git(
803
+ self, monkeypatch: pytest.MonkeyPatch
804
+ ) -> None:
805
+ monkeypatch.setattr(
806
+ cli,
807
+ "running_install",
808
+ lambda: self.install("editable", detail="/opt/checkout"),
809
+ )
810
+ result = CliRunner().invoke(cli.app, ["update"])
811
+ assert result.exit_code == 0, result.output
812
+ assert "git -C /opt/checkout pull" in result.output
813
+
814
+ def test_ephemeral_run_suggests_a_durable_install(
815
+ self, monkeypatch: pytest.MonkeyPatch
816
+ ) -> None:
817
+ monkeypatch.setattr(cli, "running_install", lambda: self.install("ephemeral"))
818
+ result = CliRunner().invoke(cli.app, ["update"])
819
+ assert result.exit_code == 0
820
+ assert "uv tool install pi-sync-cli" in result.output
821
+
822
+ def test_pipx_install_upgrades_through_pipx(
823
+ self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
824
+ ) -> None:
825
+ monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
826
+ monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
827
+ result = CliRunner().invoke(cli.app, ["update"])
828
+ assert result.exit_code == 0, result.output
829
+ assert ["pipx", "upgrade", "pi-sync-cli"] in fake.calls
830
+ assert "0.5.0 installed" in result.output
831
+
832
+ def test_uv_tool_install_upgrades_through_uv(
833
+ self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
834
+ ) -> None:
835
+ monkeypatch.setattr(cli, "running_install", lambda: self.install("uv-tool"))
836
+ monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
837
+ CliRunner().invoke(cli.app, ["update"])
838
+ assert ["uv", "tool", "upgrade", "pi-sync-cli"] in fake.calls
839
+
840
+ def test_old_distribution_name_is_used_as_is(
841
+ self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
842
+ ) -> None:
843
+ """A copy installed under the pre-rename name upgrades *that* package."""
844
+ monkeypatch.setattr(
845
+ cli, "running_install", lambda: self.install("pipx", dist="pi-sync")
846
+ )
847
+ monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
848
+ CliRunner().invoke(cli.app, ["update"])
849
+ assert ["pipx", "upgrade", "pi-sync"] in fake.calls
850
+
851
+ def test_already_current_does_not_upgrade(
852
+ self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
853
+ ) -> None:
854
+ monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
855
+ monkeypatch.setattr(cli, "latest_version", lambda dist: "0.4.0")
856
+ result = CliRunner().invoke(cli.app, ["update"])
857
+ assert result.exit_code == 0
858
+ assert "already up to date" in result.output
859
+ assert not any(c[0] in ("pipx", "uv") for c in fake.calls)
860
+
861
+ def test_newer_local_than_pypi_is_not_a_downgrade(
862
+ self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
863
+ ) -> None:
864
+ monkeypatch.setattr(
865
+ cli, "running_install", lambda: self.install("pipx", version="0.9.0")
866
+ )
867
+ monkeypatch.setattr(cli, "latest_version", lambda dist: "0.4.0")
868
+ CliRunner().invoke(cli.app, ["update"])
869
+ assert not any(c[0] in ("pipx", "uv") for c in fake.calls)
870
+
871
+ def test_check_reports_without_upgrading(
872
+ self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
873
+ ) -> None:
874
+ monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
875
+ monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
876
+ result = CliRunner().invoke(cli.app, ["update", "--check"])
877
+ assert "would run: pipx upgrade pi-sync-cli" in result.output
878
+ assert not any(c[0] == "pipx" for c in fake.calls)
879
+
880
+ def test_unreachable_pypi_is_reported(
881
+ self, monkeypatch: pytest.MonkeyPatch
882
+ ) -> None:
883
+ monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
884
+ monkeypatch.setattr(cli, "latest_version", lambda dist: None)
885
+ result = CliRunner().invoke(cli.app, ["update"])
886
+ assert result.exit_code == 1
887
+ assert "could not reach PyPI" in result.output
888
+
889
+ def test_failed_upgrade_exits_nonzero(
890
+ self, monkeypatch: pytest.MonkeyPatch
891
+ ) -> None:
892
+ monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
893
+ monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
894
+ monkeypatch.setattr(cli, "run_cmd", FakeRun(rsync_rc=1))
895
+ result = CliRunner().invoke(cli.app, ["update"])
896
+ assert result.exit_code == 1
897
+ assert "upgrade failed" in result.output
898
+
899
+ @pytest.mark.parametrize(
900
+ ("latest", "current", "expected"),
901
+ [
902
+ ("0.4.0", "0.4.0", False),
903
+ ("0.5.0", "0.4.0", True),
904
+ ("0.10.0", "0.9.0", True),
905
+ ("0.4.0", "0.5.0", False),
906
+ ("1.0.0rc1", "1.0.0", False),
907
+ ],
908
+ )
909
+ def test_version_comparison(
910
+ self, latest: str, current: str, expected: bool
911
+ ) -> None:
912
+ assert (cli.version_tuple(latest) > cli.version_tuple(current)) is expected
913
+
914
+
915
+ class TestUpdatePi:
916
+ def test_reports_the_version_change(
917
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
918
+ ) -> None:
919
+ monkeypatch.setattr(cli, "run_cmd", FakeRun(pi_path="/usr/bin/pi"))
920
+ versions = iter(["0.85.1", "0.86.0"])
921
+ monkeypatch.setattr(cli, "pi_version_on", lambda target: next(versions))
922
+ monkeypatch.setattr(cli, "update_pi_on", lambda target: None)
923
+ result = CliRunner().invoke(
924
+ cli.main, ["--update-pi", "--local-dir", str(agent_dir), "host"]
925
+ )
926
+ assert result.exit_code == 0, result.output
927
+ assert "pi 0.85.1 → 0.86.0" in result.output
928
+
929
+ def test_updates_before_syncing(
930
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
931
+ ) -> None:
932
+ fake = FakeRun(pi_path="/usr/bin/pi")
933
+ monkeypatch.setattr(cli, "run_cmd", fake)
934
+ monkeypatch.setattr(cli, "pi_version_on", lambda target: "0.85.1")
935
+ monkeypatch.setattr(cli, "update_pi_on", lambda target: None)
936
+ CliRunner().invoke(
937
+ cli.main, ["--update-pi", "--local-dir", str(agent_dir), "host"]
938
+ )
939
+ assert any(c[0] == "rsync" for c in fake.calls)
940
+
941
+ def test_failure_is_reported_and_fails_the_run(
942
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
943
+ ) -> None:
944
+ monkeypatch.setattr(cli, "run_cmd", FakeRun(pi_path="/usr/bin/pi"))
945
+ monkeypatch.setattr(cli, "pi_version_on", lambda target: "0.85.1")
946
+ monkeypatch.setattr(cli, "update_pi_on", lambda target: "npm ERR! boom")
947
+ result = CliRunner().invoke(
948
+ cli.main, ["--update-pi", "--local-dir", str(agent_dir), "host"]
949
+ )
950
+ assert result.exit_code == 1
951
+ assert "pi update failed: npm ERR! boom" in result.output
952
+
953
+ def test_dry_run_only_reports(
954
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
955
+ ) -> None:
956
+ monkeypatch.setattr(cli, "run_cmd", FakeRun(pi_path="/usr/bin/pi"))
957
+ called: list[str] = []
958
+ monkeypatch.setattr(cli, "update_pi_on", lambda target: called.append(target))
959
+ result = CliRunner().invoke(
960
+ cli.main,
961
+ ["--update-pi", "--dry-run", "--local-dir", str(agent_dir), "host"],
962
+ )
963
+ assert "would run: pi update --self" in result.output
964
+ assert called == []
965
+
966
+
967
+ class TestInstallDetection:
968
+ """Installers disagree on direct_url.json whitespace; both must be understood."""
969
+
970
+ def test_uv_editable_payload(self) -> None:
971
+ payload = '{"url":"file:///Users/x/pi-sync","dir_info":{"editable":true}}'
972
+ assert cli.install_kind(payload, "/Users/x/pi-sync/.venv") == (
973
+ "editable",
974
+ "/Users/x/pi-sync",
975
+ )
976
+
977
+ def test_pip_editable_payload_with_spaces(self) -> None:
978
+ payload = '{"url": "file:///scratch/checkout", "dir_info": {"editable": true}}'
979
+ assert cli.install_kind(payload, "/x/.venv") == (
980
+ "editable",
981
+ "/scratch/checkout",
982
+ )
983
+
984
+ def test_pipx_prefix(self) -> None:
985
+ kind, detail = cli.install_kind("", "/home/x/.local/pipx/venvs/pi-sync-cli")
986
+ assert (kind, detail) == ("pipx", "/home/x/.local/pipx/venvs/pi-sync-cli")
987
+
988
+ def test_uv_tool_prefix(self) -> None:
989
+ prefix = "/home/x/.local/share/uv/tools/pi-sync-cli"
990
+ assert cli.install_kind("", prefix)[0] == "uv-tool"
991
+
992
+ def test_uvx_ephemeral_prefix(self) -> None:
993
+ assert (
994
+ cli.install_kind("", "/Users/x/.cache/uv/archive-v0/abc")[0] == "ephemeral"
995
+ )
996
+
997
+ def test_plain_venv_is_pip(self) -> None:
998
+ assert cli.install_kind("", "/Users/x/project/.venv")[0] == "pip"
999
+
1000
+ def test_unparsable_payload_falls_back(self) -> None:
1001
+ assert cli.install_kind("{not json", "/x/.venv")[0] == "pip"
1002
+
1003
+ def test_editable_wins_over_prefix(self) -> None:
1004
+ payload = '{"url":"file:///scratch/checkout","dir_info":{"editable":true}}'
1005
+ assert (
1006
+ cli.install_kind(payload, "/x/.local/pipx/venvs/pi-sync-cli")[0]
1007
+ == "editable"
1008
+ )
1009
+
1010
+
1011
+ def test_help_usage_reads_as_the_program_not_the_subcommand() -> None:
1012
+ result = CliRunner().invoke(cli.app, ["--help"], prog_name="pi-sync")
1013
+ assert "Usage: pi-sync [OPTIONS] [USER@]HOST..." in result.output
1014
+ assert "pi-sync sync" not in result.output
@@ -52,7 +52,7 @@ wheels = [
52
52
 
53
53
  [[package]]
54
54
  name = "pi-sync-cli"
55
- version = "0.4.0"
55
+ version = "0.5.0"
56
56
  source = { editable = "." }
57
57
  dependencies = [
58
58
  { name = "click" },
@@ -1,3 +0,0 @@
1
- """Sync pi agent config and extensions between hosts over rsync."""
2
-
3
- __version__ = "0.1.0"
File without changes
File without changes