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.
@@ -216,3 +216,6 @@ __marimo__/
216
216
 
217
217
  # Streamlit
218
218
  .streamlit/secrets.toml
219
+
220
+ # pi background task artifacts
221
+ .pi/tasks/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pi-sync-cli
3
- Version: 0.2.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/*'`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pi-sync-cli"
3
- version = "0.2.0"
3
+ version = "0.4.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"
@@ -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
- def ssh_hosts(
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[str]:
59
- """Host aliases from an ssh config, following Include and skipping wildcards."""
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[str] = []
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
- for token in rest.split():
78
- wildcard = "*" in token or "?" in token or token.startswith("!")
79
- if not wildcard and token not in hosts:
80
- hosts.append(token)
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
- hosts += [
88
- h for h in ssh_hosts(str(included), seen) if h not in hosts
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 ssh_hosts()
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
- ({}, ["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
+ ),
123
147
  (
124
148
  {"pull": True},
125
- ["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
+ ],
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
- ["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
+ ],
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 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]:
389
452
  """Run the completion callback the way click would."""
390
453
  ctx = click.Context(cli.main)
391
- items = cli.complete_target(ctx, cli.main.params[0], incomplete)
392
- 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)]
393
458
 
394
459
  def test_completes_matching_hosts(self, monkeypatch: pytest.MonkeyPatch) -> None:
395
460
  monkeypatch.setattr(
396
- cli, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame", "phatboi"]
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(cli, "ssh_hosts", lambda *a, **k: ["a", "b"])
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(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
+ )
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
@@ -52,7 +52,7 @@ wheels = [
52
52
 
53
53
  [[package]]
54
54
  name = "pi-sync-cli"
55
- version = "0.2.0"
55
+ version = "0.4.0"
56
56
  source = { editable = "." }
57
57
  dependencies = [
58
58
  { name = "click" },
File without changes