pi-sync-cli 0.2.0__tar.gz → 0.4.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.
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/.gitignore +3 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/PKG-INFO +25 -2
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/README.md +24 -1
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/pyproject.toml +1 -1
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/src/pi_sync/cli.py +121 -14
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/tests/test_cli.py +207 -9
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/uv.lock +1 -1
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/LICENSE +0 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/pyrightconfig.json +0 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.4.0}/src/pi_sync/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pi-sync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -63,6 +63,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
63
63
|
| `--dry-run` | report changes, copy nothing |
|
|
64
64
|
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
65
65
|
| `--install` | install pi on hosts that lack it, without prompting |
|
|
66
|
+
| `--uninstall` | remove pi from the host instead of syncing (config is kept) |
|
|
66
67
|
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
67
68
|
| `--remote-dir` | default `~/.pi/agent` |
|
|
68
69
|
| `-v` / `--verbose` | print each rsync command and its output |
|
|
@@ -98,6 +99,21 @@ The probe checks `command -v pi` plus the usual install locations
|
|
|
98
99
|
`/usr/local/bin`), because a non-interactive ssh session does not source the
|
|
99
100
|
host's shell init — on a linuxbrew host `command -v pi` alone misses it.
|
|
100
101
|
|
|
102
|
+
## Uninstalling
|
|
103
|
+
|
|
104
|
+
`--uninstall` removes pi from the host instead of syncing. It runs
|
|
105
|
+
`npm uninstall -g --prefix <prefix> @earendil-works/pi-coding-agent`, deriving the
|
|
106
|
+
prefix from where pi actually lives (as the official installer does), then
|
|
107
|
+
re-probes to confirm the binary is really gone — npm can exit 0 having removed
|
|
108
|
+
nothing. `~/.pi/agent` is deliberately left untouched, since that is your config
|
|
109
|
+
rather than the CLI.
|
|
110
|
+
|
|
111
|
+
This exists because the official installer can only uninstall through its
|
|
112
|
+
interactive menu: its unattended mode always installs or reinstalls. If the
|
|
113
|
+
uninstall fails, that is usually a managed install
|
|
114
|
+
(`PI_EXPERIMENTAL=1`, under `<agent dir>/install`) — run
|
|
115
|
+
`curl -fsSL https://pi.dev/install.sh | sh` on the host and choose `u`.
|
|
116
|
+
|
|
101
117
|
## Shell completions
|
|
102
118
|
|
|
103
119
|
Host arguments complete from `~/.ssh/config`, following `Include` directives and
|
|
@@ -127,7 +143,14 @@ without regenerating anything.
|
|
|
127
143
|
|
|
128
144
|
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
129
145
|
toggles), so two hosts pushing it will overwrite each other's local
|
|
130
|
-
preferences. Sync it when you change `packages`, not reflexively
|
|
146
|
+
preferences. Sync it when you change `packages`, not reflexively — and note the
|
|
147
|
+
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`.
|
|
131
154
|
- Extensions that write runtime files inside their own directory (logs,
|
|
132
155
|
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
133
156
|
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
@@ -46,6 +46,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
46
46
|
| `--dry-run` | report changes, copy nothing |
|
|
47
47
|
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
48
48
|
| `--install` | install pi on hosts that lack it, without prompting |
|
|
49
|
+
| `--uninstall` | remove pi from the host instead of syncing (config is kept) |
|
|
49
50
|
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
50
51
|
| `--remote-dir` | default `~/.pi/agent` |
|
|
51
52
|
| `-v` / `--verbose` | print each rsync command and its output |
|
|
@@ -81,6 +82,21 @@ The probe checks `command -v pi` plus the usual install locations
|
|
|
81
82
|
`/usr/local/bin`), because a non-interactive ssh session does not source the
|
|
82
83
|
host's shell init — on a linuxbrew host `command -v pi` alone misses it.
|
|
83
84
|
|
|
85
|
+
## Uninstalling
|
|
86
|
+
|
|
87
|
+
`--uninstall` removes pi from the host instead of syncing. It runs
|
|
88
|
+
`npm uninstall -g --prefix <prefix> @earendil-works/pi-coding-agent`, deriving the
|
|
89
|
+
prefix from where pi actually lives (as the official installer does), then
|
|
90
|
+
re-probes to confirm the binary is really gone — npm can exit 0 having removed
|
|
91
|
+
nothing. `~/.pi/agent` is deliberately left untouched, since that is your config
|
|
92
|
+
rather than the CLI.
|
|
93
|
+
|
|
94
|
+
This exists because the official installer can only uninstall through its
|
|
95
|
+
interactive menu: its unattended mode always installs or reinstalls. If the
|
|
96
|
+
uninstall fails, that is usually a managed install
|
|
97
|
+
(`PI_EXPERIMENTAL=1`, under `<agent dir>/install`) — run
|
|
98
|
+
`curl -fsSL https://pi.dev/install.sh | sh` on the host and choose `u`.
|
|
99
|
+
|
|
84
100
|
## Shell completions
|
|
85
101
|
|
|
86
102
|
Host arguments complete from `~/.ssh/config`, following `Include` directives and
|
|
@@ -110,7 +126,14 @@ without regenerating anything.
|
|
|
110
126
|
|
|
111
127
|
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
112
128
|
toggles), so two hosts pushing it will overwrite each other's local
|
|
113
|
-
preferences. Sync it when you change `packages`, not reflexively
|
|
129
|
+
preferences. Sync it when you change `packages`, not reflexively — and note the
|
|
130
|
+
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`.
|
|
114
137
|
- Extensions that write runtime files inside their own directory (logs,
|
|
115
138
|
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
116
139
|
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
@@ -16,6 +16,7 @@ import os
|
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
18
|
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
22
|
import click
|
|
@@ -24,6 +25,7 @@ from click.shell_completion import CompletionItem
|
|
|
24
25
|
DEFAULT_AGENT_DIR = "~/.pi/agent"
|
|
25
26
|
SSH_CONFIG = "~/.ssh/config"
|
|
26
27
|
PI_INSTALL_CMD = "curl -fsSL https://pi.dev/install.sh | sh"
|
|
28
|
+
PI_PACKAGE = "@earendil-works/pi-coding-agent"
|
|
27
29
|
# One ssh round trip answers both "is it reachable" and "where is pi".
|
|
28
30
|
PI_PROBE = """\
|
|
29
31
|
found="$(command -v pi 2>/dev/null)"
|
|
@@ -53,10 +55,29 @@ def local_agent_dir(override: str | None = None) -> Path:
|
|
|
53
55
|
return Path(raw).expanduser()
|
|
54
56
|
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SshHost:
|
|
60
|
+
"""A host alias, plus the details worth showing in shell completions."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
hostname: str | None = None
|
|
64
|
+
user: str | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def detail(self) -> str:
|
|
68
|
+
if self.hostname and self.user:
|
|
69
|
+
return f"{self.user}@{self.hostname}"
|
|
70
|
+
return self.hostname or self.user or ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ssh_config_hosts(
|
|
57
74
|
config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
|
|
58
|
-
) -> list[
|
|
59
|
-
"""
|
|
75
|
+
) -> list[SshHost]:
|
|
76
|
+
"""Hosts from an ssh config, following Include and skipping wildcards.
|
|
77
|
+
|
|
78
|
+
Directives bind to the Host line that precedes them, so the current block is
|
|
79
|
+
flushed when the next Host or Include appears.
|
|
80
|
+
"""
|
|
60
81
|
path = Path(config).expanduser()
|
|
61
82
|
if not path.is_file():
|
|
62
83
|
return []
|
|
@@ -64,7 +85,22 @@ def ssh_hosts(
|
|
|
64
85
|
if resolved in _seen:
|
|
65
86
|
return []
|
|
66
87
|
seen = _seen | {resolved}
|
|
67
|
-
hosts: list[
|
|
88
|
+
hosts: list[SshHost] = []
|
|
89
|
+
names: set[str] = set()
|
|
90
|
+
pending: list[str] = []
|
|
91
|
+
hostname: str | None = None
|
|
92
|
+
user: str | None = None
|
|
93
|
+
|
|
94
|
+
def add(entries: list[SshHost]) -> None:
|
|
95
|
+
for entry in entries:
|
|
96
|
+
if entry.name not in names:
|
|
97
|
+
names.add(entry.name)
|
|
98
|
+
hosts.append(entry)
|
|
99
|
+
|
|
100
|
+
def flush() -> None:
|
|
101
|
+
add([SshHost(name, hostname, user) for name in pending])
|
|
102
|
+
pending.clear()
|
|
103
|
+
|
|
68
104
|
for raw_line in path.read_text(errors="replace").splitlines():
|
|
69
105
|
line = raw_line.strip()
|
|
70
106
|
if not line or line.startswith("#"):
|
|
@@ -74,22 +110,32 @@ def ssh_hosts(
|
|
|
74
110
|
continue
|
|
75
111
|
keyword, rest = parts[0].lower(), parts[1].strip()
|
|
76
112
|
if keyword == "host":
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
113
|
+
flush()
|
|
114
|
+
hostname = user = None
|
|
115
|
+
pending += [t for t in rest.split() if not any(c in t for c in "*?!")]
|
|
116
|
+
elif keyword == "hostname":
|
|
117
|
+
hostname = rest.split()[0]
|
|
118
|
+
elif keyword == "user":
|
|
119
|
+
user = rest.split()[0]
|
|
81
120
|
elif keyword == "include":
|
|
121
|
+
flush()
|
|
82
122
|
for pattern in rest.split():
|
|
83
123
|
target = Path(pattern).expanduser()
|
|
84
124
|
if not target.is_absolute():
|
|
85
125
|
target = Path.home() / ".ssh" / target
|
|
86
126
|
for included in sorted(target.parent.glob(target.name)):
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
]
|
|
127
|
+
add(ssh_config_hosts(str(included), seen))
|
|
128
|
+
flush()
|
|
90
129
|
return hosts
|
|
91
130
|
|
|
92
131
|
|
|
132
|
+
def ssh_hosts(
|
|
133
|
+
config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
|
|
134
|
+
) -> list[str]:
|
|
135
|
+
"""Host alias names from an ssh config (see ssh_config_hosts)."""
|
|
136
|
+
return [host.name for host in ssh_config_hosts(config, _seen)]
|
|
137
|
+
|
|
138
|
+
|
|
93
139
|
def complete_target(
|
|
94
140
|
ctx: click.Context, param: click.Parameter, incomplete: str
|
|
95
141
|
) -> list[CompletionItem]:
|
|
@@ -97,9 +143,9 @@ def complete_target(
|
|
|
97
143
|
prefix = incomplete.rpartition("@")[2]
|
|
98
144
|
lead = incomplete[: len(incomplete) - len(prefix)] if prefix else incomplete
|
|
99
145
|
return [
|
|
100
|
-
CompletionItem(f"{lead}{host}")
|
|
101
|
-
for host in
|
|
102
|
-
if host.startswith(prefix)
|
|
146
|
+
CompletionItem(f"{lead}{host.name}", help=host.detail)
|
|
147
|
+
for host in ssh_config_hosts()
|
|
148
|
+
if host.name.startswith(prefix)
|
|
103
149
|
]
|
|
104
150
|
|
|
105
151
|
|
|
@@ -127,7 +173,14 @@ def rsync_argv(
|
|
|
127
173
|
argv = ["rsync", "-az", "-i"]
|
|
128
174
|
argv += [f"--exclude={pattern}" for pattern in excludes]
|
|
129
175
|
if delete and rel in DIRECTORY_ITEMS:
|
|
176
|
+
# Mirroring wins over backups here: the destination is meant to match the
|
|
177
|
+
# source exactly, and openrsync (the rsync macOS ships) errors out when it
|
|
178
|
+
# has to back up a file it deletes.
|
|
130
179
|
argv.append("--delete-during")
|
|
180
|
+
else:
|
|
181
|
+
# Keep the destination's copy of whatever we replace, as <file>.backup.
|
|
182
|
+
argv += ["--backup", "--suffix=.backup"]
|
|
183
|
+
argv.append("--exclude=*.backup") # backups stay host-local
|
|
131
184
|
if dry_run:
|
|
132
185
|
argv.append("-n")
|
|
133
186
|
argv += [remote, local] if pull else [local, remote]
|
|
@@ -195,6 +248,30 @@ def install_pi(target: str) -> str | None:
|
|
|
195
248
|
return None
|
|
196
249
|
|
|
197
250
|
|
|
251
|
+
def uninstall_pi(target: str, pi_path: str) -> str | None:
|
|
252
|
+
"""Remove pi's npm install. Returns an error message, or None on success.
|
|
253
|
+
|
|
254
|
+
Mirrors the installer's own npm path — the prefix comes from where pi
|
|
255
|
+
actually lives — because its unattended mode can only install or reinstall;
|
|
256
|
+
uninstall is reachable only through its interactive menu. Like the
|
|
257
|
+
installer, the result is verified rather than trusted, and only the CLI is
|
|
258
|
+
removed: the agent directory is left alone.
|
|
259
|
+
"""
|
|
260
|
+
prefix = Path(pi_path).parent.parent
|
|
261
|
+
cmd = (
|
|
262
|
+
f"npm uninstall -g --prefix {prefix} --no-fund --no-audit "
|
|
263
|
+
f"--loglevel=error --progress=false {PI_PACKAGE}"
|
|
264
|
+
)
|
|
265
|
+
proc = run_cmd(["ssh", target, cmd])
|
|
266
|
+
if proc.returncode:
|
|
267
|
+
detail = ((proc.stderr or proc.stdout) or "").strip().splitlines()
|
|
268
|
+
return detail[-1] if detail else f"npm uninstall exited {proc.returncode}"
|
|
269
|
+
_, remaining = probe_host(target)
|
|
270
|
+
if remaining:
|
|
271
|
+
return f"npm uninstall finished, but pi is still present at {remaining}"
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
198
275
|
def ensure_pi(target: str, assume_yes: bool, remote_dir: str) -> bool:
|
|
199
276
|
"""Make sure the host has pi. Returns False when the host must be skipped.
|
|
200
277
|
|
|
@@ -284,6 +361,7 @@ def sync_host(
|
|
|
284
361
|
|
|
285
362
|
|
|
286
363
|
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
364
|
+
@click.version_option(package_name="pi-sync-cli")
|
|
287
365
|
@click.argument(
|
|
288
366
|
"targets",
|
|
289
367
|
nargs=-1,
|
|
@@ -327,6 +405,12 @@ def sync_host(
|
|
|
327
405
|
is_flag=True,
|
|
328
406
|
help="Install pi on hosts that lack it, without prompting.",
|
|
329
407
|
)
|
|
408
|
+
@click.option(
|
|
409
|
+
"--uninstall",
|
|
410
|
+
"uninstall_",
|
|
411
|
+
is_flag=True,
|
|
412
|
+
help="Uninstall pi from the host instead of syncing (keeps ~/.pi/agent).",
|
|
413
|
+
)
|
|
330
414
|
@click.option(
|
|
331
415
|
"--local-dir",
|
|
332
416
|
default=None,
|
|
@@ -352,6 +436,7 @@ def main(
|
|
|
352
436
|
dry_run: bool,
|
|
353
437
|
excludes: tuple[str, ...],
|
|
354
438
|
install_: bool,
|
|
439
|
+
uninstall_: bool,
|
|
355
440
|
local_dir: str | None,
|
|
356
441
|
remote_dir: str,
|
|
357
442
|
verbose: bool,
|
|
@@ -397,6 +482,28 @@ def main(
|
|
|
397
482
|
click.secho(f"→ {target}\n unreachable: {error}", fg="red")
|
|
398
483
|
continue
|
|
399
484
|
click.secho(f"→ {target}", bold=True)
|
|
485
|
+
if uninstall_:
|
|
486
|
+
if pi_path is None:
|
|
487
|
+
click.secho(" pi is not installed — nothing to uninstall")
|
|
488
|
+
continue
|
|
489
|
+
prefix = Path(pi_path).parent.parent
|
|
490
|
+
if dry_run:
|
|
491
|
+
click.secho(
|
|
492
|
+
f" would run: npm uninstall -g --prefix {prefix} {PI_PACKAGE}"
|
|
493
|
+
)
|
|
494
|
+
continue
|
|
495
|
+
click.secho(f" uninstalling pi from {pi_path}...")
|
|
496
|
+
error = uninstall_pi(target, pi_path)
|
|
497
|
+
if error:
|
|
498
|
+
failed = True
|
|
499
|
+
click.secho(f" {error}", fg="red")
|
|
500
|
+
click.secho(
|
|
501
|
+
" for a managed install, run the installer and choose 'u'",
|
|
502
|
+
fg="yellow",
|
|
503
|
+
)
|
|
504
|
+
else:
|
|
505
|
+
click.secho(" pi uninstalled — ~/.pi/agent was left alone", fg="green")
|
|
506
|
+
continue
|
|
400
507
|
if pi_path is None:
|
|
401
508
|
if dry_run:
|
|
402
509
|
click.secho(" pi not installed — nothing would be synced", fg="yellow")
|
|
@@ -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
|
|
@@ -29,6 +30,8 @@ class FakeRun:
|
|
|
29
30
|
pi_path: str | None = "/usr/bin/pi",
|
|
30
31
|
install_rc: int = 0,
|
|
31
32
|
install_installs: bool = True,
|
|
33
|
+
uninstall_rc: int = 0,
|
|
34
|
+
uninstall_removes: bool = True,
|
|
32
35
|
):
|
|
33
36
|
self.calls: list[list[str]] = []
|
|
34
37
|
self.inputs: list[str | None] = []
|
|
@@ -39,6 +42,8 @@ class FakeRun:
|
|
|
39
42
|
self.pi_path = pi_path
|
|
40
43
|
self.install_rc = install_rc
|
|
41
44
|
self.install_installs = install_installs
|
|
45
|
+
self.uninstall_rc = uninstall_rc
|
|
46
|
+
self.uninstall_removes = uninstall_removes
|
|
42
47
|
self.installed = False
|
|
43
48
|
|
|
44
49
|
def __call__(
|
|
@@ -51,6 +56,13 @@ class FakeRun:
|
|
|
51
56
|
return subprocess.CompletedProcess(
|
|
52
57
|
argv, self.rsync_rc, self.rsync_stdout, ""
|
|
53
58
|
)
|
|
59
|
+
if any("npm uninstall" in arg for arg in argv):
|
|
60
|
+
if self.uninstall_rc == 0 and self.uninstall_removes:
|
|
61
|
+
self.pi_path = None
|
|
62
|
+
stderr = "" if self.uninstall_rc == 0 else "npm ERR! code E404"
|
|
63
|
+
return subprocess.CompletedProcess(
|
|
64
|
+
argv, self.uninstall_rc, "removed\n", stderr
|
|
65
|
+
)
|
|
54
66
|
if any("install.sh" in arg for arg in argv):
|
|
55
67
|
if self.install_rc == 0:
|
|
56
68
|
# a real installer exits 0 even when the user picks "do nothing"
|
|
@@ -119,10 +131,31 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
|
119
131
|
@pytest.mark.parametrize(
|
|
120
132
|
("argv_kwargs", "expected"),
|
|
121
133
|
[
|
|
122
|
-
(
|
|
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
|
+
),
|
|
123
147
|
(
|
|
124
148
|
{"pull": True},
|
|
125
|
-
[
|
|
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
|
+
],
|
|
126
159
|
),
|
|
127
160
|
(
|
|
128
161
|
{"dry_run": True},
|
|
@@ -130,6 +163,9 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
|
130
163
|
"rsync",
|
|
131
164
|
"-az",
|
|
132
165
|
"-i",
|
|
166
|
+
"--backup",
|
|
167
|
+
"--suffix=.backup",
|
|
168
|
+
"--exclude=*.backup",
|
|
133
169
|
"-n",
|
|
134
170
|
"/a/models.json",
|
|
135
171
|
"host:~/.pi/agent/models.json",
|
|
@@ -137,7 +173,16 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
|
137
173
|
),
|
|
138
174
|
(
|
|
139
175
|
{"delete": True},
|
|
140
|
-
[
|
|
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
|
+
],
|
|
141
186
|
),
|
|
142
187
|
],
|
|
143
188
|
)
|
|
@@ -162,6 +207,20 @@ def test_rsync_argv_delete_only_for_directories() -> None:
|
|
|
162
207
|
)
|
|
163
208
|
|
|
164
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
|
+
|
|
165
224
|
def test_rsync_argv_excludes() -> None:
|
|
166
225
|
argv = cli.rsync_argv(
|
|
167
226
|
"extensions", "host", Path("/a"), "~/.pi/agent", excludes=("*/logs/*",)
|
|
@@ -385,28 +444,51 @@ class TestSshHosts:
|
|
|
385
444
|
|
|
386
445
|
class TestCompletion:
|
|
387
446
|
@staticmethod
|
|
388
|
-
def
|
|
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]:
|
|
389
452
|
"""Run the completion callback the way click would."""
|
|
390
453
|
ctx = click.Context(cli.main)
|
|
391
|
-
|
|
392
|
-
|
|
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)]
|
|
393
458
|
|
|
394
459
|
def test_completes_matching_hosts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
395
460
|
monkeypatch.setattr(
|
|
396
|
-
cli, "
|
|
461
|
+
cli, "ssh_config_hosts", lambda *a, **k: self.hosts("tinfoil", "zero-frame")
|
|
397
462
|
)
|
|
398
463
|
assert self.complete("t") == ["tinfoil"]
|
|
399
464
|
|
|
400
465
|
def test_empty_incomplete_lists_everything(
|
|
401
466
|
self, monkeypatch: pytest.MonkeyPatch
|
|
402
467
|
) -> None:
|
|
403
|
-
monkeypatch.setattr(
|
|
468
|
+
monkeypatch.setattr(
|
|
469
|
+
cli, "ssh_config_hosts", lambda *a, **k: self.hosts("a", "b")
|
|
470
|
+
)
|
|
404
471
|
assert self.complete("") == ["a", "b"]
|
|
405
472
|
|
|
406
473
|
def test_user_at_prefix_is_preserved(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
407
|
-
monkeypatch.setattr(
|
|
474
|
+
monkeypatch.setattr(
|
|
475
|
+
cli, "ssh_config_hosts", lambda *a, **k: self.hosts("tinfoil", "zero-frame")
|
|
476
|
+
)
|
|
408
477
|
assert self.complete("sayan@zer") == ["sayan@zero-frame"]
|
|
409
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
|
+
|
|
410
492
|
|
|
411
493
|
class TestProbe:
|
|
412
494
|
def test_reports_pi_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -550,3 +632,119 @@ class TestInstallOffering:
|
|
|
550
632
|
)
|
|
551
633
|
assert result.exit_code == 1
|
|
552
634
|
assert install_calls(fake) == []
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
class TestUninstall:
|
|
638
|
+
def uninstall_calls(self, fake: FakeRun) -> list[list[str]]:
|
|
639
|
+
return [c for c in fake.calls if any("npm uninstall" in a for a in c)]
|
|
640
|
+
|
|
641
|
+
def test_uninstalls_with_the_prefix_pi_lives_in(
|
|
642
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
643
|
+
) -> None:
|
|
644
|
+
fake = FakeRun(pi_path="/home/linuxbrew/.linuxbrew/bin/pi")
|
|
645
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
646
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
647
|
+
assert result.exit_code == 0, result.output
|
|
648
|
+
(call,) = self.uninstall_calls(fake)
|
|
649
|
+
cmd = call[-1]
|
|
650
|
+
assert "npm uninstall -g --prefix /home/linuxbrew/.linuxbrew" in cmd
|
|
651
|
+
assert cli.PI_PACKAGE in cmd
|
|
652
|
+
assert "~/.pi/agent was left alone" in result.output
|
|
653
|
+
# uninstalling never syncs
|
|
654
|
+
assert not any(c[0] == "rsync" for c in fake.calls)
|
|
655
|
+
|
|
656
|
+
def test_absent_pi_is_a_noop(
|
|
657
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
658
|
+
) -> None:
|
|
659
|
+
fake = FakeRun(pi_path=None)
|
|
660
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
661
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
662
|
+
assert result.exit_code == 0
|
|
663
|
+
assert "nothing to uninstall" in result.output
|
|
664
|
+
assert self.uninstall_calls(fake) == []
|
|
665
|
+
|
|
666
|
+
def test_npm_failure_is_reported(
|
|
667
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
668
|
+
) -> None:
|
|
669
|
+
fake = FakeRun(pi_path="/usr/bin/pi", uninstall_rc=1)
|
|
670
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
671
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
672
|
+
assert result.exit_code == 1
|
|
673
|
+
assert "npm ERR!" in result.output
|
|
674
|
+
|
|
675
|
+
def test_still_present_after_uninstall_is_a_failure(
|
|
676
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
677
|
+
) -> None:
|
|
678
|
+
"""npm can exit 0 having removed nothing; the re-probe has the last word."""
|
|
679
|
+
fake = FakeRun(pi_path="/usr/bin/pi", uninstall_removes=False)
|
|
680
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
681
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
682
|
+
assert result.exit_code == 1
|
|
683
|
+
assert "still present at /usr/bin/pi" in result.output
|
|
684
|
+
|
|
685
|
+
def test_dry_run_uninstall_changes_nothing(
|
|
686
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
687
|
+
) -> None:
|
|
688
|
+
fake = FakeRun(pi_path="/usr/bin/pi")
|
|
689
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
690
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "--dry-run", "host"])
|
|
691
|
+
assert result.exit_code == 0
|
|
692
|
+
assert "would run: npm uninstall" in result.output
|
|
693
|
+
assert self.uninstall_calls(fake) == []
|
|
694
|
+
|
|
695
|
+
def test_unreachable_host_fails(
|
|
696
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
697
|
+
) -> None:
|
|
698
|
+
fake = FakeRun(ssh_rc=255)
|
|
699
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
700
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
701
|
+
assert result.exit_code == 1
|
|
702
|
+
assert "unreachable" in result.output
|
|
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.main, ["--version"])
|
|
749
|
+
assert result.exit_code == 0, result.output
|
|
750
|
+
assert version("pi-sync-cli") in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|