safekeep 0.2.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.
safekeep-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 foxhound87
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: safekeep
3
+ Version: 0.2.0
4
+ Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://gitlab.com/foxhound87/safekeep
7
+ Project-URL: Repository, https://gitlab.com/foxhound87/safekeep
8
+ Keywords: backup,macos,fswatch,sync,allow-list,launchd
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: System :: Archiving :: Backup
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # safekeep
21
+
22
+ **Selective always-on backups for macOS**: watches source folders with
23
+ [fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
24
+ chose to their destinations — an **allow-list** model that **never deletes**
25
+ anything in the backup (a file removed or renamed at the source stays in the
26
+ backup).
27
+
28
+ ## Features
29
+
30
+ - **Allow-list**: a file is copied only if it matches at least one `include:`;
31
+ no match → not copied. Unmatched directories are still traversed, while
32
+ directories with an *excluded* verdict are pruned together with their subtree.
33
+ - **Atomic copy, never a half-written file**: the temp file lives in the
34
+ destination's own directory, followed by `fsync` + `os.replace` → a reader
35
+ sees either the old file or the new one, never a partial copy.
36
+ - **Reconcile** at startup, every 24h, on remount, and on `sync-once`: copies
37
+ whatever differs by size/mtime, idempotently — lost events, crashes and reboots
38
+ don't matter, the next reconcile brings everything back in sync.
39
+ - **Unmounted volumes**: missing destination → `pending` state with exponential
40
+ backoff (1s → 60s cap), then a reconcile once the mount is back.
41
+ - **launchd at boot**: an agent with `RunAtLoad` + `KeepAlive` keeps the process
42
+ alive and restarts it if it dies.
43
+ - **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
44
+ `.sync` file can neither add nor remove destinations (a `dest:` line is an
45
+ invalid line), so a repo cloned from a third party can't redirect the backup
46
+ somewhere else.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pipx install safekeep # recommended for a CLI
52
+ # or
53
+ pip install safekeep
54
+ ```
55
+
56
+ **Requirements**: macOS, Python >= 3.9 and
57
+ [fswatch](https://github.com/emcrisostomo/fswatch):
58
+
59
+ ```bash
60
+ brew install fswatch
61
+ ```
62
+
63
+ The **launchd agent** (daemon at boot) is installed from a checkout of this
64
+ repository with `./install.sh` — not from the wheel — see
65
+ [Agent (launchd)](#agent-launchd) below.
66
+
67
+ ## Agent (launchd)
68
+
69
+ ```bash
70
+ git clone https://gitlab.com/foxhound87/safekeep.git
71
+ cd safekeep
72
+
73
+ # only external dependency, if not installed yet
74
+ brew install fswatch
75
+
76
+ # copies the example into ~/.safekeep, renders the plist, runs preflight checks
77
+ bash install.sh
78
+ ```
79
+
80
+ Then:
81
+
82
+ 1. Fill `~/.safekeep` with your real `dest:` entries — and, optionally, `source:`
83
+ (the example ships with placeholders — see
84
+ [`examples/safekeep.example`](examples/safekeep.example));
85
+ 2. drop a `.sync` file in the root of every project you want to follow (see
86
+ [`examples/sync.example`](examples/sync.example)); a directory **without** a
87
+ `.sync` is not tracked;
88
+ 3. load the agent:
89
+
90
+ ```bash
91
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
92
+ # unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
93
+ ```
94
+
95
+ Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
96
+ interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
97
+
98
+ ## Quickstart
99
+
100
+ `~/.safekeep` (the only place where destinations live):
101
+
102
+ ```bash
103
+ # observed roots: only directories containing a .sync are followed
104
+ source: ~/Code
105
+ source: ~/Projects
106
+
107
+ # destination for ALL projects
108
+ dest: ~/Backup/safekeep
109
+
110
+ # relative (default): <dest>/<path relative to the source root>
111
+ # ~/Code/myapp/docs/a.md → ~/Backup/safekeep/myapp/docs/a.md
112
+ layout: relative
113
+
114
+ log_level: info
115
+ ```
116
+
117
+ `source:` is **optional**. With it present (source mode) the watched roots are
118
+ exactly those entries, as always. Without it safekeep switches to
119
+ **auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
120
+ directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
121
+ `__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
122
+ startup. `dest:` stays mandatory in both modes.
123
+
124
+ `~/Code/myapp/.sync` (rules only, no destinations):
125
+
126
+ ```bash
127
+ name: myapp
128
+
129
+ # allow-list semantics: without these lines NOT A SINGLE file would be copied
130
+ *.md
131
+ .env
132
+ !secrets/old.env # ! = exclude
133
+ ```
134
+
135
+ Rules can be written as bare gitignore-style lines — **the inverse of gitignore**:
136
+ a line lists what to **copy**, not what to ignore (`*.md` includes markdown here,
137
+ excludes it in a `.gitignore`), and a leading `!` turns it into an `exclude:`.
138
+ Bare lines share the same ordered list as the explicit `include:`/`exclude:`
139
+ keys, so last-match-wins works the same way (see
140
+ [`examples/sync.example`](examples/sync.example)).
141
+
142
+ Dry run first, then start the daemon:
143
+
144
+ ```bash
145
+ safekeep sync-once --dry-run # prints what would be copied, copies nothing
146
+ safekeep run # daemon: watch + copy (foreground)
147
+ ```
148
+
149
+ ## CLI commands
150
+
151
+ ```
152
+ safekeep <command> [--config PATH] [-v]
153
+ ```
154
+
155
+ | Command | What it does |
156
+ |---|---|
157
+ | `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
158
+ | `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
159
+ | `status` | read-only: config, sources, discovered projects with N rules, destination states |
160
+ | `doctor` | diagnostics: config, fswatch, TCC, launchd plist — exits non-zero if a fatal check fails |
161
+
162
+ ## Tests
163
+
164
+ ```bash
165
+ python3 -m unittest discover -s tests
166
+ ```
167
+
168
+ Stdlib (`unittest`) suite, zero dependencies: 156 tests covering the matcher,
169
+ config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
170
+ needed to run the tests.
171
+
172
+ ## Documentation
173
+
174
+ Everything in detail (config format, pattern semantics, event → sync flow,
175
+ atomic copy, launchd, edge cases) lives in [`SPEC.md`](SPEC.md); commented
176
+ examples in [`examples/`](examples/).
177
+
178
+ ## License
179
+
180
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,161 @@
1
+ # safekeep
2
+
3
+ **Selective always-on backups for macOS**: watches source folders with
4
+ [fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
5
+ chose to their destinations — an **allow-list** model that **never deletes**
6
+ anything in the backup (a file removed or renamed at the source stays in the
7
+ backup).
8
+
9
+ ## Features
10
+
11
+ - **Allow-list**: a file is copied only if it matches at least one `include:`;
12
+ no match → not copied. Unmatched directories are still traversed, while
13
+ directories with an *excluded* verdict are pruned together with their subtree.
14
+ - **Atomic copy, never a half-written file**: the temp file lives in the
15
+ destination's own directory, followed by `fsync` + `os.replace` → a reader
16
+ sees either the old file or the new one, never a partial copy.
17
+ - **Reconcile** at startup, every 24h, on remount, and on `sync-once`: copies
18
+ whatever differs by size/mtime, idempotently — lost events, crashes and reboots
19
+ don't matter, the next reconcile brings everything back in sync.
20
+ - **Unmounted volumes**: missing destination → `pending` state with exponential
21
+ backoff (1s → 60s cap), then a reconcile once the mount is back.
22
+ - **launchd at boot**: an agent with `RunAtLoad` + `KeepAlive` keeps the process
23
+ alive and restarts it if it dies.
24
+ - **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
25
+ `.sync` file can neither add nor remove destinations (a `dest:` line is an
26
+ invalid line), so a repo cloned from a third party can't redirect the backup
27
+ somewhere else.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pipx install safekeep # recommended for a CLI
33
+ # or
34
+ pip install safekeep
35
+ ```
36
+
37
+ **Requirements**: macOS, Python >= 3.9 and
38
+ [fswatch](https://github.com/emcrisostomo/fswatch):
39
+
40
+ ```bash
41
+ brew install fswatch
42
+ ```
43
+
44
+ The **launchd agent** (daemon at boot) is installed from a checkout of this
45
+ repository with `./install.sh` — not from the wheel — see
46
+ [Agent (launchd)](#agent-launchd) below.
47
+
48
+ ## Agent (launchd)
49
+
50
+ ```bash
51
+ git clone https://gitlab.com/foxhound87/safekeep.git
52
+ cd safekeep
53
+
54
+ # only external dependency, if not installed yet
55
+ brew install fswatch
56
+
57
+ # copies the example into ~/.safekeep, renders the plist, runs preflight checks
58
+ bash install.sh
59
+ ```
60
+
61
+ Then:
62
+
63
+ 1. Fill `~/.safekeep` with your real `dest:` entries — and, optionally, `source:`
64
+ (the example ships with placeholders — see
65
+ [`examples/safekeep.example`](examples/safekeep.example));
66
+ 2. drop a `.sync` file in the root of every project you want to follow (see
67
+ [`examples/sync.example`](examples/sync.example)); a directory **without** a
68
+ `.sync` is not tracked;
69
+ 3. load the agent:
70
+
71
+ ```bash
72
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
73
+ # unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
74
+ ```
75
+
76
+ Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
77
+ interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
78
+
79
+ ## Quickstart
80
+
81
+ `~/.safekeep` (the only place where destinations live):
82
+
83
+ ```bash
84
+ # observed roots: only directories containing a .sync are followed
85
+ source: ~/Code
86
+ source: ~/Projects
87
+
88
+ # destination for ALL projects
89
+ dest: ~/Backup/safekeep
90
+
91
+ # relative (default): <dest>/<path relative to the source root>
92
+ # ~/Code/myapp/docs/a.md → ~/Backup/safekeep/myapp/docs/a.md
93
+ layout: relative
94
+
95
+ log_level: info
96
+ ```
97
+
98
+ `source:` is **optional**. With it present (source mode) the watched roots are
99
+ exactly those entries, as always. Without it safekeep switches to
100
+ **auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
101
+ directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
102
+ `__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
103
+ startup. `dest:` stays mandatory in both modes.
104
+
105
+ `~/Code/myapp/.sync` (rules only, no destinations):
106
+
107
+ ```bash
108
+ name: myapp
109
+
110
+ # allow-list semantics: without these lines NOT A SINGLE file would be copied
111
+ *.md
112
+ .env
113
+ !secrets/old.env # ! = exclude
114
+ ```
115
+
116
+ Rules can be written as bare gitignore-style lines — **the inverse of gitignore**:
117
+ a line lists what to **copy**, not what to ignore (`*.md` includes markdown here,
118
+ excludes it in a `.gitignore`), and a leading `!` turns it into an `exclude:`.
119
+ Bare lines share the same ordered list as the explicit `include:`/`exclude:`
120
+ keys, so last-match-wins works the same way (see
121
+ [`examples/sync.example`](examples/sync.example)).
122
+
123
+ Dry run first, then start the daemon:
124
+
125
+ ```bash
126
+ safekeep sync-once --dry-run # prints what would be copied, copies nothing
127
+ safekeep run # daemon: watch + copy (foreground)
128
+ ```
129
+
130
+ ## CLI commands
131
+
132
+ ```
133
+ safekeep <command> [--config PATH] [-v]
134
+ ```
135
+
136
+ | Command | What it does |
137
+ |---|---|
138
+ | `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
139
+ | `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
140
+ | `status` | read-only: config, sources, discovered projects with N rules, destination states |
141
+ | `doctor` | diagnostics: config, fswatch, TCC, launchd plist — exits non-zero if a fatal check fails |
142
+
143
+ ## Tests
144
+
145
+ ```bash
146
+ python3 -m unittest discover -s tests
147
+ ```
148
+
149
+ Stdlib (`unittest`) suite, zero dependencies: 156 tests covering the matcher,
150
+ config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
151
+ needed to run the tests.
152
+
153
+ ## Documentation
154
+
155
+ Everything in detail (config format, pattern semantics, event → sync flow,
156
+ atomic copy, launchd, edge cases) lives in [`SPEC.md`](SPEC.md); commented
157
+ examples in [`examples/`](examples/).
158
+
159
+ ## License
160
+
161
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,37 @@
1
+ # Packaging per PyPI: package stdlib-only, un solo console script.
2
+ [build-system]
3
+ requires = ["setuptools>=77"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "safekeep"
8
+ dynamic = ["version"]
9
+ description = "Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.9"
14
+ keywords = ["backup", "macos", "fswatch", "sync", "allow-list", "launchd"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: System Administrators",
19
+ "Operating System :: MacOS",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: System :: Archiving :: Backup",
22
+ ]
23
+ # zero runtime dependencies: solo stdlib (fswatch è un binario esterno)
24
+
25
+ [project.urls]
26
+ Homepage = "https://gitlab.com/foxhound87/safekeep"
27
+ Repository = "https://gitlab.com/foxhound87/safekeep"
28
+
29
+ [project.scripts]
30
+ safekeep = "safekeep.cli:main"
31
+
32
+ [tool.setuptools]
33
+ # NO auto-discovery: bin/ e tests/ farebbero errore di build
34
+ packages = ["safekeep"]
35
+
36
+ [tool.setuptools.dynamic]
37
+ version = {attr = "safekeep.__version__"}
@@ -0,0 +1,6 @@
1
+ """safekeep — motore di backup event-driven per macOS (vedi SPEC.md)."""
2
+ import logging
3
+
4
+ logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
+
6
+ __version__ = '0.2.0'
@@ -0,0 +1,272 @@
1
+ """CLI di safekeep: `run`, `sync-once`, `status` e `doctor` (SPEC.md §9).
2
+
3
+ Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
4
+ shim che usano launchd e i test.
5
+ """
6
+ import argparse
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+
12
+ from safekeep.config import ConfigError, discover_projects, parse_sync
13
+ from safekeep.copier import TMP_INFIX
14
+ from safekeep.daemon import Daemon, setup_logging
15
+ from safekeep.volumes import dest_state
16
+
17
+ # Radice del repo quando si gira da checkout; in un install pip punta altrove
18
+ # (site-packages) e i riferimenti a file del repo degradano (vedi cmd_doctor).
19
+ REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+ EXAMPLE = os.path.join(REPO, 'examples', 'safekeep.example')
21
+ REPO_URL = 'https://gitlab.com/foxhound87/safekeep'
22
+
23
+
24
+ def build_parser():
25
+ common = argparse.ArgumentParser(add_help=False)
26
+ common.add_argument('--config', metavar='PATH',
27
+ help='config globale (default: ~/.safekeep)')
28
+ common.add_argument('-v', action='store_true', help='log verboso (debug)')
29
+
30
+ parser = argparse.ArgumentParser(
31
+ prog='safekeep',
32
+ description='safekeep — backup event-driven per macOS (vedi SPEC.md)')
33
+ sub = parser.add_subparsers(dest='comando', required=True)
34
+ sub.add_parser('run', parents=[common],
35
+ help='daemon: reconcile iniziale + watch fswatch + dispatch eventi + timer 24h')
36
+ once = sub.add_parser('sync-once', parents=[common],
37
+ help='un singolo passaggio: walk sorgente, copia ciò che differisce, esce')
38
+ once.add_argument('--project', metavar='PATH',
39
+ help='solo questo progetto (path della root o name)')
40
+ once.add_argument('--dry-run', action='store_true',
41
+ help='stampa cosa copierebbe, senza copiare')
42
+ once.add_argument('--prune', action='store_true',
43
+ help='rimuove dalla dest i file il cui sorgente esiste ancora ma '
44
+ 'non è più incluso dal matcher (default: mai cancellare)')
45
+ sub.add_parser('status', parents=[common],
46
+ help='config, progetti e stato delle dest — sola lettura')
47
+ sub.add_parser('doctor', parents=[common],
48
+ help='diagnostica: config, fswatch, TCC, plist — exit ≠ 0 se un check fatale fallisce')
49
+ return parser
50
+
51
+
52
+ def cmd_sync_once(args):
53
+ daemon = Daemon(args.config)
54
+ daemon.load_config()
55
+ setup_logging('debug' if args.v else daemon.cfg.log_level)
56
+ daemon.validate_dests() # CR-02: dest dentro source/$HOME → exit ≠ 0
57
+ daemon.load_projects()
58
+ rows = daemon.sync_once(dry_run=args.dry_run, only=args.project,
59
+ prune=args.prune)
60
+ if args.project and not rows:
61
+ print(f'progetto non trovato: {args.project}', file=sys.stderr)
62
+ return 1
63
+ if not rows:
64
+ print('nessun progetto trovato (nessun .sync scoperto)', file=sys.stderr)
65
+ for project, stats, absent in rows:
66
+ if args.dry_run:
67
+ for src, dst in stats.get('pianificati', []):
68
+ print(f' {src} -> {dst}')
69
+ verb = 'da copiare' if args.dry_run else 'copie'
70
+ line = (f'{project.name}: {stats.get("copiati", 0)} {verb}, '
71
+ f'{stats.get("skippati", 0)} già aggiornati, '
72
+ f'{stats.get("esclusi", 0)} esclusi, '
73
+ f'{stats.get("errati", 0)} errori, dest assenti: {absent}')
74
+ if args.prune:
75
+ verb = 'da rimuovere' if args.dry_run else 'rimossi'
76
+ line += f', {verb}: {stats.get("rimossi", 0)}'
77
+ print(line)
78
+ return 0
79
+
80
+
81
+ def cmd_status(args):
82
+ """Sola lettura: config, modalità, source/progetti con N regole, dest con dest_state."""
83
+ daemon = Daemon(args.config)
84
+ daemon.load_config()
85
+ print(f'config {daemon.config_path}')
86
+ print(f'layout {daemon.cfg.layout}')
87
+ if daemon.cfg.sources:
88
+ print('modalità source')
89
+ for src in daemon.cfg.sources:
90
+ print(f'source {src}')
91
+ else:
92
+ print(f'modalità auto-discovery da {os.path.expanduser("~")}')
93
+ daemon.load_projects()
94
+ for project in sorted(daemon.projects, key=lambda p: p.root):
95
+ print(f'progetto {project.name} — {len(project.matcher.rules)} regole '
96
+ f'— {project.root}')
97
+ if not daemon.projects:
98
+ print('progetto nessuno (.sync non trovato)')
99
+ for dest in daemon.cfg.dests:
100
+ print(f'dest {dest} [{dest_state(dest)}]')
101
+ return 0
102
+
103
+
104
+ def check_sync_files(daemon):
105
+ """SPEC.md §9.2 (CR-03): ogni `.sync` scoperto deve parsificare e avere
106
+ ≥1 regola valida. Non fatale: il conteggio delle righe scartate diventa
107
+ un warning di `doctor`.
108
+
109
+ → (progetti totali, `.sync` rotti con motivo, righe scartate totali)."""
110
+ roots, auto = daemon.discovery_roots()
111
+ total, broken, discarded = 0, [], 0
112
+ for root in discover_projects(roots, auto=auto):
113
+ total += 1
114
+ path = os.path.join(root, '.sync')
115
+ try:
116
+ with open(path, encoding='utf-8') as fh:
117
+ text = fh.read()
118
+ except (OSError, UnicodeError) as e: # OSError + UnicodeDecodeError
119
+ broken.append(f'{path}: {e}')
120
+ continue
121
+ # log_level fisso a info: il parse di doctor NON deve mai essere fatale
122
+ sync = parse_sync(text, log_level='info', origin=path)
123
+ discarded += sync.discarded
124
+ if not (sync.rules or sync.defaults):
125
+ broken.append(f'{path}: nessuna regola valida')
126
+ return total, broken, discarded
127
+
128
+
129
+ def cmd_doctor(args):
130
+ """Un check per riga con esito ✔/✗; exit 1 se almeno un check fatale fallisce."""
131
+ fatal = False
132
+
133
+ def out(ok, msg, is_fatal=False):
134
+ nonlocal fatal
135
+ print(('✔ ' if ok else '✗ ') + msg)
136
+ if not ok and is_fatal:
137
+ fatal = True
138
+
139
+ cfg_path = os.path.expanduser(args.config or '~/.safekeep')
140
+ cfg = None
141
+ daemon = None
142
+ if not os.path.exists(cfg_path):
143
+ if os.path.exists(EXAMPLE):
144
+ hint = f'creala: cp {EXAMPLE} {cfg_path}'
145
+ else:
146
+ # install pip: niente repo, niente esempi → hint degradato
147
+ hint = (f'clone {REPO_URL} and copy examples/safekeep.example to '
148
+ f'{cfg_path}')
149
+ out(False, f'config assente: {cfg_path} ({hint})',
150
+ is_fatal=bool(args.config))
151
+ else:
152
+ try:
153
+ daemon = Daemon(cfg_path)
154
+ cfg = daemon.load_config()
155
+ except ConfigError as e:
156
+ daemon = None
157
+ out(False, f'config non valida: {e}', is_fatal=True)
158
+ else:
159
+ # nessuna `source` → auto-discovery da $HOME: info, non warning (SPEC §6)
160
+ mode = (f'{len(cfg.sources)} source' if cfg.sources
161
+ else f'auto-discovery da {os.path.expanduser("~")}')
162
+ out(True, f'config valida: {cfg_path} ({mode}, {len(cfg.dests)} dest)')
163
+
164
+ if daemon is not None:
165
+ # base = source, oppure $HOME in auto-mode: copre anche "dest dentro $HOME"
166
+ try:
167
+ daemon.validate_dests()
168
+ except ConfigError as e:
169
+ out(False, str(e), is_fatal=True)
170
+ else:
171
+ out(True, 'dest non annidate nelle source/$HOME (nessun loop di copia)')
172
+
173
+ if daemon is not None:
174
+ total, broken, discarded = check_sync_files(daemon)
175
+ if broken:
176
+ detail = f' ({discarded} righe scartate)' if discarded else ''
177
+ out(False, '.sync non validi: ' + '; '.join(broken) + detail
178
+ + ' — warning non fatale')
179
+ elif discarded:
180
+ out(False, f'.sync: {discarded} righe scartate in {total} progetto/i '
181
+ '— warning non fatale')
182
+ elif total:
183
+ out(True, f'.sync: {total} progetto/i parsificati con regole valide')
184
+ else:
185
+ out(True, '.sync: nessun progetto scoperto (niente da validare)')
186
+
187
+ exe = shutil.which('fswatch')
188
+ if exe is None:
189
+ out(False, 'fswatch non trovato — installa con: brew install fswatch',
190
+ is_fatal=True)
191
+ else:
192
+ version = None
193
+ try:
194
+ proc = subprocess.run([exe, '--version'], capture_output=True,
195
+ text=True, timeout=15)
196
+ lines = (proc.stdout + proc.stderr).strip().splitlines()
197
+ version = lines[0] if lines else None
198
+ except (OSError, subprocess.SubprocessError):
199
+ pass
200
+ out(version is not None,
201
+ f'fswatch: {version}' if version else 'fswatch --version senza output',
202
+ is_fatal=True)
203
+ try:
204
+ monitors = subprocess.run([exe, '-M'], capture_output=True,
205
+ text=True, timeout=15)
206
+ has_fsevents = 'fsevents_monitor' in monitors.stdout
207
+ except (OSError, subprocess.SubprocessError):
208
+ has_fsevents = False
209
+ out(has_fsevents,
210
+ 'fswatch monitor: fsevents_monitor presente' if has_fsevents else
211
+ 'fswatch monitor: fsevents_monitor ASSENTE in `fswatch -M`',
212
+ is_fatal=True)
213
+
214
+ out(sys.version_info >= (3, 9),
215
+ f'python {sys.version.split()[0]} (≥ 3.9)', is_fatal=True)
216
+
217
+ docs = os.path.expanduser('~/Documents')
218
+ if not os.path.isdir(docs):
219
+ out(True, 'TCC (Transparency, Consent and Control): ~/Documents assente — probe saltato')
220
+ else:
221
+ try:
222
+ os.listdir(docs)
223
+ except PermissionError:
224
+ out(False, 'TCC: PermissionError su ~/Documents — serve FDA '
225
+ '(Full Disk Access) per i progetti in cartelle protette')
226
+ else:
227
+ out(True, 'TCC: ~/Documents leggibile')
228
+
229
+ if cfg is not None:
230
+ residui = [os.path.join(dirpath, name)
231
+ for dest in cfg.dests
232
+ for dirpath, _, names in os.walk(dest)
233
+ for name in names
234
+ if TMP_INFIX in name]
235
+ out(not residui,
236
+ 'residui *.safekeep.tmp.*: nessuno' if not residui else
237
+ 'residui *.safekeep.tmp.*: ' + ', '.join(residui))
238
+
239
+ plist = os.path.expanduser('~/Library/LaunchAgents/com.safekeep.agent.plist')
240
+ if not os.path.exists(plist):
241
+ out(True, 'plist non installato (nessun agent da validare)')
242
+ elif shutil.which('plutil') is None:
243
+ out(True, 'plutil assente — lint del plist saltato')
244
+ else:
245
+ try:
246
+ lint = subprocess.run(['plutil', '-lint', plist],
247
+ capture_output=True, text=True, timeout=15)
248
+ detail = (lint.stdout + lint.stderr).strip() or plist
249
+ out(lint.returncode == 0, f'plist lint: {detail}', is_fatal=True)
250
+ except (OSError, subprocess.SubprocessError) as e:
251
+ out(False, f'plist lint non eseguibile: {e}', is_fatal=True)
252
+
253
+ return 1 if fatal else 0
254
+
255
+
256
+ def main(argv=None):
257
+ args = build_parser().parse_args(argv)
258
+ try:
259
+ if args.comando == 'run':
260
+ return Daemon(args.config).run(verbose=args.v)
261
+ if args.comando == 'status':
262
+ return cmd_status(args)
263
+ if args.comando == 'doctor':
264
+ return cmd_doctor(args)
265
+ return cmd_sync_once(args)
266
+ except ConfigError as e:
267
+ print(f'errore di config: {e}', file=sys.stderr)
268
+ return 1
269
+
270
+
271
+ if __name__ == '__main__':
272
+ sys.exit(main())