pi-sync-cli 0.3.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.3.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
@@ -143,7 +178,11 @@ without regenerating anything.
143
178
 
144
179
  - `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
145
180
  toggles), so two hosts pushing it will overwrite each other's local
146
- preferences. Sync it when you change `packages`, not reflexively.
181
+ preferences. Sync it when you change `packages`, not reflexively — and note the
182
+ overwritten copy is kept as `settings.json.backup` on the receiving host.
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".
147
186
  - Extensions that write runtime files inside their own directory (logs,
148
187
  checkpoints) get those files synced too, and each host's copy is overwritten by
149
188
  whichever side pushed last — exclude them with `-x '*/logs/*'`.
@@ -153,10 +192,6 @@ without regenerating anything.
153
192
  in the environment where you can.
154
193
  - Remote paths go through the host's shell, so `~` expands there as usual.
155
194
 
156
- ## Development
195
+ ## Developing
157
196
 
158
- ```bash
159
- uv sync
160
- uv run pytest
161
- uv run pi-sync --help
162
- ```
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
@@ -126,7 +161,11 @@ without regenerating anything.
126
161
 
127
162
  - `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
128
163
  toggles), so two hosts pushing it will overwrite each other's local
129
- preferences. Sync it when you change `packages`, not reflexively.
164
+ preferences. Sync it when you change `packages`, not reflexively — and note the
165
+ overwritten copy is kept as `settings.json.backup` on the receiving host.
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".
130
169
  - Extensions that write runtime files inside their own directory (logs,
131
170
  checkpoints) get those files synced too, and each host's copy is overwritten by
132
171
  whichever side pushed last — exclude them with `-x '*/logs/*'`.
@@ -136,10 +175,6 @@ without regenerating anything.
136
175
  in the environment where you can.
137
176
  - Remote paths go through the host's shell, so `~` expands there as usual.
138
177
 
139
- ## Development
178
+ ## Developing
140
179
 
141
- ```bash
142
- uv sync
143
- uv run pytest
144
- uv run pi-sync --help
145
- ```
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.3.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,11 +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
21
+ from dataclasses import dataclass
22
+ from importlib import metadata
19
23
  from pathlib import Path
24
+ from urllib.parse import urlparse
25
+ from urllib.request import urlopen
20
26
 
21
27
  import click
22
28
  from click.shell_completion import CompletionItem
@@ -54,10 +60,29 @@ def local_agent_dir(override: str | None = None) -> Path:
54
60
  return Path(raw).expanduser()
55
61
 
56
62
 
57
- def ssh_hosts(
63
+ @dataclass(frozen=True)
64
+ class SshHost:
65
+ """A host alias, plus the details worth showing in shell completions."""
66
+
67
+ name: str
68
+ hostname: str | None = None
69
+ user: str | None = None
70
+
71
+ @property
72
+ def detail(self) -> str:
73
+ if self.hostname and self.user:
74
+ return f"{self.user}@{self.hostname}"
75
+ return self.hostname or self.user or ""
76
+
77
+
78
+ def ssh_config_hosts(
58
79
  config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
59
- ) -> list[str]:
60
- """Host aliases from an ssh config, following Include and skipping wildcards."""
80
+ ) -> list[SshHost]:
81
+ """Hosts from an ssh config, following Include and skipping wildcards.
82
+
83
+ Directives bind to the Host line that precedes them, so the current block is
84
+ flushed when the next Host or Include appears.
85
+ """
61
86
  path = Path(config).expanduser()
62
87
  if not path.is_file():
63
88
  return []
@@ -65,7 +90,22 @@ def ssh_hosts(
65
90
  if resolved in _seen:
66
91
  return []
67
92
  seen = _seen | {resolved}
68
- hosts: list[str] = []
93
+ hosts: list[SshHost] = []
94
+ names: set[str] = set()
95
+ pending: list[str] = []
96
+ hostname: str | None = None
97
+ user: str | None = None
98
+
99
+ def add(entries: list[SshHost]) -> None:
100
+ for entry in entries:
101
+ if entry.name not in names:
102
+ names.add(entry.name)
103
+ hosts.append(entry)
104
+
105
+ def flush() -> None:
106
+ add([SshHost(name, hostname, user) for name in pending])
107
+ pending.clear()
108
+
69
109
  for raw_line in path.read_text(errors="replace").splitlines():
70
110
  line = raw_line.strip()
71
111
  if not line or line.startswith("#"):
@@ -75,22 +115,32 @@ def ssh_hosts(
75
115
  continue
76
116
  keyword, rest = parts[0].lower(), parts[1].strip()
77
117
  if keyword == "host":
78
- for token in rest.split():
79
- wildcard = "*" in token or "?" in token or token.startswith("!")
80
- if not wildcard and token not in hosts:
81
- hosts.append(token)
118
+ flush()
119
+ hostname = user = None
120
+ pending += [t for t in rest.split() if not any(c in t for c in "*?!")]
121
+ elif keyword == "hostname":
122
+ hostname = rest.split()[0]
123
+ elif keyword == "user":
124
+ user = rest.split()[0]
82
125
  elif keyword == "include":
126
+ flush()
83
127
  for pattern in rest.split():
84
128
  target = Path(pattern).expanduser()
85
129
  if not target.is_absolute():
86
130
  target = Path.home() / ".ssh" / target
87
131
  for included in sorted(target.parent.glob(target.name)):
88
- hosts += [
89
- h for h in ssh_hosts(str(included), seen) if h not in hosts
90
- ]
132
+ add(ssh_config_hosts(str(included), seen))
133
+ flush()
91
134
  return hosts
92
135
 
93
136
 
137
+ def ssh_hosts(
138
+ config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
139
+ ) -> list[str]:
140
+ """Host alias names from an ssh config (see ssh_config_hosts)."""
141
+ return [host.name for host in ssh_config_hosts(config, _seen)]
142
+
143
+
94
144
  def complete_target(
95
145
  ctx: click.Context, param: click.Parameter, incomplete: str
96
146
  ) -> list[CompletionItem]:
@@ -98,9 +148,9 @@ def complete_target(
98
148
  prefix = incomplete.rpartition("@")[2]
99
149
  lead = incomplete[: len(incomplete) - len(prefix)] if prefix else incomplete
100
150
  return [
101
- CompletionItem(f"{lead}{host}")
102
- for host in ssh_hosts()
103
- if host.startswith(prefix)
151
+ CompletionItem(f"{lead}{host.name}", help=host.detail)
152
+ for host in ssh_config_hosts()
153
+ if host.name.startswith(prefix)
104
154
  ]
105
155
 
106
156
 
@@ -128,7 +178,14 @@ def rsync_argv(
128
178
  argv = ["rsync", "-az", "-i"]
129
179
  argv += [f"--exclude={pattern}" for pattern in excludes]
130
180
  if delete and rel in DIRECTORY_ITEMS:
181
+ # Mirroring wins over backups here: the destination is meant to match the
182
+ # source exactly, and openrsync (the rsync macOS ships) errors out when it
183
+ # has to back up a file it deletes.
131
184
  argv.append("--delete-during")
185
+ else:
186
+ # Keep the destination's copy of whatever we replace, as <file>.backup.
187
+ argv += ["--backup", "--suffix=.backup"]
188
+ argv.append("--exclude=*.backup") # backups stay host-local
132
189
  if dry_run:
133
190
  argv.append("-n")
134
191
  argv += [remote, local] if pull else [local, remote]
@@ -308,7 +365,192 @@ def sync_host(
308
365
  return ok
309
366
 
310
367
 
311
- @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"]})
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
+ )
312
554
  @click.argument(
313
555
  "targets",
314
556
  nargs=-1,
@@ -358,6 +600,12 @@ def sync_host(
358
600
  is_flag=True,
359
601
  help="Uninstall pi from the host instead of syncing (keeps ~/.pi/agent).",
360
602
  )
603
+ @click.option(
604
+ "--update-pi",
605
+ "update_pi",
606
+ is_flag=True,
607
+ help="Update pi on each host before syncing.",
608
+ )
361
609
  @click.option(
362
610
  "--local-dir",
363
611
  default=None,
@@ -384,6 +632,7 @@ def main(
384
632
  excludes: tuple[str, ...],
385
633
  install_: bool,
386
634
  uninstall_: bool,
635
+ update_pi: bool,
387
636
  local_dir: str | None,
388
637
  remote_dir: str,
389
638
  verbose: bool,
@@ -394,6 +643,7 @@ def main(
394
643
  pi-sync tinfoil # push config + extensions
395
644
  pi-sync --config laptop # just models.json and settings.json
396
645
  pi-sync --pull --all tinfoil # fetch the host's config back
646
+ pi-sync update # update pi-sync itself
397
647
  """
398
648
  groups = {
399
649
  group
@@ -459,6 +709,20 @@ def main(
459
709
  if not ensure_pi(target, install_, remote_dir):
460
710
  failed = True
461
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)")
462
726
  if not sync_host(
463
727
  target,
464
728
  items,
@@ -477,4 +741,4 @@ def main(
477
741
 
478
742
 
479
743
  if __name__ == "__main__":
480
- main()
744
+ app()
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
 
14
14
  import click
15
15
  import pytest
16
+ from click.shell_completion import CompletionItem
16
17
  from click.testing import CliRunner
17
18
 
18
19
  from pi_sync import cli
@@ -130,10 +131,31 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
130
131
  @pytest.mark.parametrize(
131
132
  ("argv_kwargs", "expected"),
132
133
  [
133
- ({}, ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"]),
134
+ (
135
+ {},
136
+ [
137
+ "rsync",
138
+ "-az",
139
+ "-i",
140
+ "--backup",
141
+ "--suffix=.backup",
142
+ "--exclude=*.backup",
143
+ "/a/models.json",
144
+ "host:~/.pi/agent/models.json",
145
+ ],
146
+ ),
134
147
  (
135
148
  {"pull": True},
136
- ["rsync", "-az", "-i", "host:~/.pi/agent/models.json", "/a/models.json"],
149
+ [
150
+ "rsync",
151
+ "-az",
152
+ "-i",
153
+ "--backup",
154
+ "--suffix=.backup",
155
+ "--exclude=*.backup",
156
+ "host:~/.pi/agent/models.json",
157
+ "/a/models.json",
158
+ ],
137
159
  ),
138
160
  (
139
161
  {"dry_run": True},
@@ -141,6 +163,9 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
141
163
  "rsync",
142
164
  "-az",
143
165
  "-i",
166
+ "--backup",
167
+ "--suffix=.backup",
168
+ "--exclude=*.backup",
144
169
  "-n",
145
170
  "/a/models.json",
146
171
  "host:~/.pi/agent/models.json",
@@ -148,7 +173,16 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
148
173
  ),
149
174
  (
150
175
  {"delete": True},
151
- ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"],
176
+ [
177
+ "rsync",
178
+ "-az",
179
+ "-i",
180
+ "--backup",
181
+ "--suffix=.backup",
182
+ "--exclude=*.backup",
183
+ "/a/models.json",
184
+ "host:~/.pi/agent/models.json",
185
+ ],
152
186
  ),
153
187
  ],
154
188
  )
@@ -173,6 +207,20 @@ def test_rsync_argv_delete_only_for_directories() -> None:
173
207
  )
174
208
 
175
209
 
210
+ def test_backups_are_taken_except_when_mirroring() -> None:
211
+ """Originals are kept as .backup, unless --delete means "match exactly"."""
212
+ plain = cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent")
213
+ assert "--backup" in plain
214
+ assert "--suffix=.backup" in plain
215
+ assert "--exclude=*.backup" in plain # never propagate backups to other hosts
216
+
217
+ mirroring = cli.rsync_argv(
218
+ "extensions", "host", Path("/a"), "~/.pi/agent", delete=True
219
+ )
220
+ assert "--backup" not in mirroring
221
+ assert "--exclude=*.backup" in mirroring # existing backups survive the delete
222
+
223
+
176
224
  def test_rsync_argv_excludes() -> None:
177
225
  argv = cli.rsync_argv(
178
226
  "extensions", "host", Path("/a"), "~/.pi/agent", excludes=("*/logs/*",)
@@ -396,28 +444,51 @@ class TestSshHosts:
396
444
 
397
445
  class TestCompletion:
398
446
  @staticmethod
399
- def complete(incomplete: str) -> list[str]:
447
+ def hosts(*names: str) -> list[cli.SshHost]:
448
+ return [cli.SshHost(name) for name in names]
449
+
450
+ @staticmethod
451
+ def items(incomplete: str) -> list[CompletionItem]:
400
452
  """Run the completion callback the way click would."""
401
453
  ctx = click.Context(cli.main)
402
- items = cli.complete_target(ctx, cli.main.params[0], incomplete)
403
- return [item.value for item in items]
454
+ return cli.complete_target(ctx, cli.main.params[0], incomplete)
455
+
456
+ def complete(self, incomplete: str) -> list[str]:
457
+ return [item.value for item in self.items(incomplete)]
404
458
 
405
459
  def test_completes_matching_hosts(self, monkeypatch: pytest.MonkeyPatch) -> None:
406
460
  monkeypatch.setattr(
407
- cli, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame", "phatboi"]
461
+ cli, "ssh_config_hosts", lambda *a, **k: self.hosts("tinfoil", "zero-frame")
408
462
  )
409
463
  assert self.complete("t") == ["tinfoil"]
410
464
 
411
465
  def test_empty_incomplete_lists_everything(
412
466
  self, monkeypatch: pytest.MonkeyPatch
413
467
  ) -> None:
414
- monkeypatch.setattr(cli, "ssh_hosts", lambda *a, **k: ["a", "b"])
468
+ monkeypatch.setattr(
469
+ cli, "ssh_config_hosts", lambda *a, **k: self.hosts("a", "b")
470
+ )
415
471
  assert self.complete("") == ["a", "b"]
416
472
 
417
473
  def test_user_at_prefix_is_preserved(self, monkeypatch: pytest.MonkeyPatch) -> None:
418
- monkeypatch.setattr(cli, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame"])
474
+ monkeypatch.setattr(
475
+ cli, "ssh_config_hosts", lambda *a, **k: self.hosts("tinfoil", "zero-frame")
476
+ )
419
477
  assert self.complete("sayan@zer") == ["sayan@zero-frame"]
420
478
 
479
+ def test_suggestions_carry_the_real_hostname(
480
+ self, monkeypatch: pytest.MonkeyPatch
481
+ ) -> None:
482
+ """zsh and fish render CompletionItem.help next to the suggestion."""
483
+ monkeypatch.setattr(
484
+ cli,
485
+ "ssh_config_hosts",
486
+ lambda *a, **k: [cli.SshHost("tinfoil", "tinfoil.sayan.page", "sayan")],
487
+ )
488
+ (item,) = self.items("t")
489
+ assert item.value == "tinfoil"
490
+ assert item.help == "sayan@tinfoil.sayan.page"
491
+
421
492
 
422
493
  class TestProbe:
423
494
  def test_reports_pi_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -630,3 +701,314 @@ class TestUninstall:
630
701
  assert result.exit_code == 1
631
702
  assert "unreachable" in result.output
632
703
  assert self.uninstall_calls(fake) == []
704
+
705
+
706
+ class TestSshHostDetails:
707
+ def test_hostname_and_user_are_captured(self, tmp_path: Path) -> None:
708
+ cfg = tmp_path / "config"
709
+ cfg.write_text(
710
+ "Host tinfoil\n HostName tinfoil.sayan.page\n User sayan\nHost bare\n"
711
+ )
712
+ hosts = cli.ssh_config_hosts(str(cfg))
713
+ assert hosts == [
714
+ cli.SshHost("tinfoil", "tinfoil.sayan.page", "sayan"),
715
+ cli.SshHost("bare"),
716
+ ]
717
+ assert hosts[0].detail == "sayan@tinfoil.sayan.page"
718
+ assert hosts[1].detail == ""
719
+
720
+ def test_directives_bind_to_the_preceding_host(self, tmp_path: Path) -> None:
721
+ cfg = tmp_path / "config"
722
+ cfg.write_text("Host first\n HostName one.example\nHost second\n User bob\n")
723
+ hosts = cli.ssh_config_hosts(str(cfg))
724
+ assert [h.hostname for h in hosts] == ["one.example", None]
725
+ assert [h.user for h in hosts] == [None, "bob"]
726
+ assert hosts[1].detail == "bob"
727
+
728
+ def test_details_survive_includes(self, tmp_path: Path) -> None:
729
+ extra = tmp_path / "extra"
730
+ extra.write_text("Host orb\n HostName orb.local\n")
731
+ cfg = tmp_path / "config"
732
+ cfg.write_text(f"Host tinfoil\n User sayan\nInclude {extra}\n")
733
+ hosts = cli.ssh_config_hosts(str(cfg))
734
+ assert [(h.name, h.hostname, h.user) for h in hosts] == [
735
+ ("tinfoil", None, "sayan"),
736
+ ("orb", "orb.local", None),
737
+ ]
738
+
739
+ def test_names_still_available_as_strings(self, tmp_path: Path) -> None:
740
+ cfg = tmp_path / "config"
741
+ cfg.write_text("Host tinfoil\n HostName tinfoil.sayan.page\n")
742
+ assert cli.ssh_hosts(str(cfg)) == ["tinfoil"]
743
+
744
+
745
+ def test_version_option_reports_the_installed_version() -> None:
746
+ from importlib.metadata import version
747
+
748
+ result = CliRunner().invoke(cli.app, ["--version"])
749
+ assert result.exit_code == 0, result.output
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.3.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