safekeep 0.6.0__tar.gz → 0.7.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.
Files changed (35) hide show
  1. {safekeep-0.6.0/safekeep.egg-info → safekeep-0.7.0}/PKG-INFO +60 -30
  2. {safekeep-0.6.0 → safekeep-0.7.0}/README.md +59 -29
  3. {safekeep-0.6.0 → safekeep-0.7.0}/pyproject.toml +5 -0
  4. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/__init__.py +1 -1
  5. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/cli.py +68 -14
  6. safekeep-0.7.0/safekeep/daemonctl.py +349 -0
  7. safekeep-0.7.0/safekeep/data/com.safekeep.agent.plist.tmpl +71 -0
  8. safekeep-0.7.0/safekeep/data/safekeep.example +91 -0
  9. safekeep-0.7.0/safekeep/data/safekeep.service.tmpl +47 -0
  10. {safekeep-0.6.0 → safekeep-0.7.0/safekeep.egg-info}/PKG-INFO +60 -30
  11. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/SOURCES.txt +5 -0
  12. safekeep-0.7.0/tests/test_daemonctl.py +651 -0
  13. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_platform.py +48 -51
  14. {safekeep-0.6.0 → safekeep-0.7.0}/LICENSE +0 -0
  15. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/config.py +0 -0
  16. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/copier.py +0 -0
  17. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/daemon.py +0 -0
  18. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/matcher.py +0 -0
  19. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/platform.py +0 -0
  20. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/s3dest.py +0 -0
  21. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/volumes.py +0 -0
  22. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/dependency_links.txt +0 -0
  23. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/entry_points.txt +0 -0
  24. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/requires.txt +0 -0
  25. {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/top_level.txt +0 -0
  26. {safekeep-0.6.0 → safekeep-0.7.0}/setup.cfg +0 -0
  27. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_cli.py +0 -0
  28. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_config.py +0 -0
  29. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_copier.py +0 -0
  30. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_daemon.py +0 -0
  31. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_matcher.py +0 -0
  32. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_s3_itest.py +0 -0
  33. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_s3dest.py +0 -0
  34. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_trend.py +0 -0
  35. {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/foxhound87/safekeep
@@ -84,21 +84,22 @@ sudo dnf install fswatch # Fedora
84
84
  ```
85
85
 
86
86
  The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
87
- is installed from a checkout of this repository with `./install.sh` — not from
88
- the wheel — see [Agent](#agent) below.
87
+ is installed from the installed package with `safekeep daemon install` — the
88
+ unit templates ship inside the wheel, no checkout needed — see
89
+ [Agent](#agent) below. The old `./install.sh` wrapper is deprecated.
89
90
 
90
91
  ## Agent
91
92
 
92
93
  ```bash
93
- git clone https://github.com/foxhound87/safekeep.git
94
- cd safekeep
94
+ pipx install safekeep # or: pip install --user safekeep
95
95
 
96
96
  # only external dependency, if not installed yet (see the per-OS list above)
97
- brew install fswatch # macOS — the script suggests the right command on Linux
97
+ brew install fswatch # macOS — the preflight suggests the right command on Linux
98
98
 
99
- # copies the example into ~/.safekeep, renders the plist / systemd unit,
100
- # runs preflight checks
101
- bash install.sh
99
+ # preflight (fswatch / shim on PATH / python / WSL systemd-on), then renders
100
+ # the plist or systemd user unit from the embedded template into place —
101
+ # idempotent, does NOT start anything yet
102
+ safekeep daemon install # --dry-run to preview, nothing is written
102
103
  ```
103
104
 
104
105
  Then:
@@ -112,16 +113,18 @@ Then:
112
113
  3. load the agent:
113
114
 
114
115
  ```bash
115
- # macOS (launchd)
116
- launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
117
- # unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
118
-
119
- # Linux (systemd user unit)
120
- systemctl --user enable --now safekeep
121
- # status with: systemctl --user status safekeep
122
- # logs with: journalctl --user -u safekeep -f
123
- # unload with: systemctl --user disable --now safekeep
124
- # start at boot without a login session: loginctl enable-linger $USER
116
+ safekeep daemon start # launchctl bootstrap (macOS) / systemctl --user enable --now (Linux)
117
+ safekeep daemon status # exit 0 while running, 1 otherwise
118
+ safekeep daemon stop # unload
119
+
120
+ # logs (Linux): journalctl --user -u safekeep -f
121
+ # remove the unit (config and logs stay): safekeep daemon uninstall
122
+ # remove everything, incl. ~/.safekeep: safekeep daemon uninstall --purge
123
+
124
+ # Linux - start at boot without a login session (linger = user units active
125
+ # without a login), either pass
126
+ safekeep daemon install --enable-linger
127
+ # or run once: loginctl enable-linger $USER
125
128
  ```
126
129
 
127
130
  On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
@@ -152,7 +155,8 @@ exactly those entries, as always. Without it safekeep switches to
152
155
  **auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
153
156
  directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
154
157
  `__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
155
- startup. `dest:` stays mandatory in both modes.
158
+ startup. `dest:` stays mandatory in both modes — unless `s3_bucket:` is set
159
+ (at least one of the two must be present).
156
160
 
157
161
  `~/Code/myapp/.sync` (rules only, no destinations):
158
162
 
@@ -187,9 +191,12 @@ destinations. The client is [botocore](https://botocore.amazonaws.com/),
187
191
  installed via the optional extra — the core package stays stdlib-only:
188
192
 
189
193
  ```bash
190
- pipx install "safekeep[s3]"
194
+ pipx install "safekeep[s3]" # or: pip install "safekeep[s3]"
191
195
  ```
192
196
 
197
+ Without the extra the config still parses, but any `s3_*` key fails fast with
198
+ an install hint (`doctor` reports it as a **non-fatal** warning).
199
+
193
200
  `~/.safekeep` (the `s3_*` keys are valid **only** in this file — a `.sync`
194
201
  can't add them; keep the file at `chmod 600`):
195
202
 
@@ -205,6 +212,10 @@ s3_addressing: path # path (default) | virtual
205
212
  s3_tls_check: true # false = self-signed certificates
206
213
  ```
207
214
 
215
+ With `s3_secret_key_env:` the variable must be in the **agent's** environment
216
+ (launchd `EnvironmentVariables`, systemd `Environment=`) — a shell `export`
217
+ alone doesn't reach a daemon started at boot.
218
+
208
219
  Semantics (see [SPEC.md](SPEC.md) §18): upload-only with `needs_copy` based on
209
220
  object metadata (`x-amz-meta-mtime` in nanoseconds, `x-amz-meta-mode`,
210
221
  symlinks = zero-byte objects with `x-amz-meta-symlink`), same 1s mtime
@@ -215,14 +226,18 @@ that's an accepted v1 limitation.
215
226
 
216
227
  Compatible services:
217
228
 
218
- | Service | Example `s3_endpoint` | `s3_addressing` | Notes |
219
- |---|---|---|---|
220
- | MinIO (self-hosted) | `https://minio.example.com:9000` | `path` | SigV4 (Signature Version 4), any region |
221
- | Silo | `https://s3.example.com:443` | `path` | fork of MinIO — same settings |
222
- | Ceph RGW | `https://rgw.example.com` | `path` | S3-compatible API |
223
- | Wasabi | `https://s3.wasabisys.com` | `path` | region required, must match the bucket |
224
- | Backblaze B2 | `https://s3.<region>.backblazeb2.com` | `virtual` | use an Application Key as access/secret |
225
- | AWS S3 | *(omit `s3_endpoint`)* | `virtual` | default endpoint, region must match |
229
+ | Service | Example `s3_endpoint` | `s3_addressing` | `s3_region` | TLS |
230
+ |---|---|---|---|---|
231
+ | MinIO (self-hosted) | `https://minio.example.com:9000` | `path` | any (`us-east-1` default) | `https` + `s3_tls_check: false` if self-signed |
232
+ | Silo (fork of MinIO) | `https://s3.example.com:443` | `path` | any (`us-east-1` default) | as the endpoint's own certificate |
233
+ | Ceph RGW | `https://rgw.example.com` | `path` | any, must match the RGW zone (`us-east-1` default) | as configured on the RGW |
234
+ | Wasabi | `https://s3.wasabisys.com` | `path` | **required**, must match the bucket (`us-east-1` on that endpoint) | `https` |
235
+ | Backblaze B2 | `https://s3.<region>.backblazeb2.com` | `virtual` or `path` | **required**, must match the bucket region | `https` only — cleartext rejected |
236
+ | AWS S3 | *(omit `s3_endpoint`)* | `virtual` | must match the bucket (`us-east-1` default) | `https` |
237
+
238
+ All of them speak SigV4 (Signature Version 4) with the same client — no
239
+ service-specific code path. B2 wants an **Application Key** (not the Master
240
+ Key) as `s3_access_key`/`s3_secret_key`.
226
241
 
227
242
  `safekeep status` shows the bucket as `[ok]`/`[offline]` (cached HeadBucket),
228
243
  and `safekeep doctor` reports credentials, file permissions and bucket
@@ -240,6 +255,7 @@ safekeep <command> [--config PATH] [-v]
240
255
  | `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) |
241
256
  | `status` | read-only: config, sources, discovered projects with N rules, destination states |
242
257
  | `doctor [--json]` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails; `--json` prints the same checks as one JSON document on stdout with the same exit code, including a structured `permission_errors` count of the last 24h |
258
+ | `daemon {install,start,stop,status,uninstall}` | manage the boot agent: `install` runs preflight (fswatch, shim on PATH, WSL systemd-on) and renders the launchd plist / systemd user unit from the embedded templates (idempotent, `--dry-run`, `--enable-linger`); `start`/`stop`/`status` load, unload and probe it; `uninstall` removes the unit (`--purge` also drops `~/.safekeep` and state) |
243
259
 
244
260
  ## Tests
245
261
 
@@ -247,7 +263,7 @@ safekeep <command> [--config PATH] [-v]
247
263
  python3 -m unittest discover -s tests
248
264
  ```
249
265
 
250
- Stdlib (`unittest`) suite, zero dependencies: 221 tests covering the matcher,
266
+ Stdlib (`unittest`) suite, zero dependencies: 287 tests covering the matcher,
251
267
  config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
252
268
  wrapper and the POSIX portability layer (platform helper, per-OS fswatch
253
269
  monitor, systemd template).
@@ -255,6 +271,20 @@ monitor, systemd template).
255
271
  the real binary are skipped when it's missing — but CI installs it so the
256
272
  Linux job exercises the real `inotify` monitor.
257
273
 
274
+ The S3 integration test talks to a real bucket and is **skipped** unless every
275
+ `SAFEKEEP_S3_TEST_*` variable is set (no credential is hard-coded; it uploads
276
+ under a throwaway timestamped prefix and performs **no** DeleteObject):
277
+
278
+ ```bash
279
+ export SAFEKEEP_S3_TEST_ENDPOINT=https://s3.example.com:443 \
280
+ SAFEKEEP_S3_TEST_BUCKET=my-backup-bucket \
281
+ SAFEKEEP_S3_TEST_ACCESS_KEY=REPLACEME_ACCESS_KEY \
282
+ SAFEKEEP_S3_TEST_SECRET_KEY=REPLACEME_SECRET_KEY \
283
+ SAFEKEEP_S3_TEST_REGION=us-east-1 # optional, default us-east-1
284
+ # SAFEKEEP_S3_TEST_PREFIX=safekeep # optional, default safekeep-itest
285
+ python3 -m unittest tests.test_s3_itest -v
286
+ ```
287
+
258
288
  ## Documentation
259
289
 
260
290
  Everything in detail (config format, pattern semantics, event → sync flow,
@@ -57,21 +57,22 @@ sudo dnf install fswatch # Fedora
57
57
  ```
58
58
 
59
59
  The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
60
- is installed from a checkout of this repository with `./install.sh` — not from
61
- the wheel — see [Agent](#agent) below.
60
+ is installed from the installed package with `safekeep daemon install` — the
61
+ unit templates ship inside the wheel, no checkout needed — see
62
+ [Agent](#agent) below. The old `./install.sh` wrapper is deprecated.
62
63
 
63
64
  ## Agent
64
65
 
65
66
  ```bash
66
- git clone https://github.com/foxhound87/safekeep.git
67
- cd safekeep
67
+ pipx install safekeep # or: pip install --user safekeep
68
68
 
69
69
  # only external dependency, if not installed yet (see the per-OS list above)
70
- brew install fswatch # macOS — the script suggests the right command on Linux
70
+ brew install fswatch # macOS — the preflight suggests the right command on Linux
71
71
 
72
- # copies the example into ~/.safekeep, renders the plist / systemd unit,
73
- # runs preflight checks
74
- bash install.sh
72
+ # preflight (fswatch / shim on PATH / python / WSL systemd-on), then renders
73
+ # the plist or systemd user unit from the embedded template into place —
74
+ # idempotent, does NOT start anything yet
75
+ safekeep daemon install # --dry-run to preview, nothing is written
75
76
  ```
76
77
 
77
78
  Then:
@@ -85,16 +86,18 @@ Then:
85
86
  3. load the agent:
86
87
 
87
88
  ```bash
88
- # macOS (launchd)
89
- launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
90
- # unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
91
-
92
- # Linux (systemd user unit)
93
- systemctl --user enable --now safekeep
94
- # status with: systemctl --user status safekeep
95
- # logs with: journalctl --user -u safekeep -f
96
- # unload with: systemctl --user disable --now safekeep
97
- # start at boot without a login session: loginctl enable-linger $USER
89
+ safekeep daemon start # launchctl bootstrap (macOS) / systemctl --user enable --now (Linux)
90
+ safekeep daemon status # exit 0 while running, 1 otherwise
91
+ safekeep daemon stop # unload
92
+
93
+ # logs (Linux): journalctl --user -u safekeep -f
94
+ # remove the unit (config and logs stay): safekeep daemon uninstall
95
+ # remove everything, incl. ~/.safekeep: safekeep daemon uninstall --purge
96
+
97
+ # Linux - start at boot without a login session (linger = user units active
98
+ # without a login), either pass
99
+ safekeep daemon install --enable-linger
100
+ # or run once: loginctl enable-linger $USER
98
101
  ```
99
102
 
100
103
  On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
@@ -125,7 +128,8 @@ exactly those entries, as always. Without it safekeep switches to
125
128
  **auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
126
129
  directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
127
130
  `__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
128
- startup. `dest:` stays mandatory in both modes.
131
+ startup. `dest:` stays mandatory in both modes — unless `s3_bucket:` is set
132
+ (at least one of the two must be present).
129
133
 
130
134
  `~/Code/myapp/.sync` (rules only, no destinations):
131
135
 
@@ -160,9 +164,12 @@ destinations. The client is [botocore](https://botocore.amazonaws.com/),
160
164
  installed via the optional extra — the core package stays stdlib-only:
161
165
 
162
166
  ```bash
163
- pipx install "safekeep[s3]"
167
+ pipx install "safekeep[s3]" # or: pip install "safekeep[s3]"
164
168
  ```
165
169
 
170
+ Without the extra the config still parses, but any `s3_*` key fails fast with
171
+ an install hint (`doctor` reports it as a **non-fatal** warning).
172
+
166
173
  `~/.safekeep` (the `s3_*` keys are valid **only** in this file — a `.sync`
167
174
  can't add them; keep the file at `chmod 600`):
168
175
 
@@ -178,6 +185,10 @@ s3_addressing: path # path (default) | virtual
178
185
  s3_tls_check: true # false = self-signed certificates
179
186
  ```
180
187
 
188
+ With `s3_secret_key_env:` the variable must be in the **agent's** environment
189
+ (launchd `EnvironmentVariables`, systemd `Environment=`) — a shell `export`
190
+ alone doesn't reach a daemon started at boot.
191
+
181
192
  Semantics (see [SPEC.md](SPEC.md) §18): upload-only with `needs_copy` based on
182
193
  object metadata (`x-amz-meta-mtime` in nanoseconds, `x-amz-meta-mode`,
183
194
  symlinks = zero-byte objects with `x-amz-meta-symlink`), same 1s mtime
@@ -188,14 +199,18 @@ that's an accepted v1 limitation.
188
199
 
189
200
  Compatible services:
190
201
 
191
- | Service | Example `s3_endpoint` | `s3_addressing` | Notes |
192
- |---|---|---|---|
193
- | MinIO (self-hosted) | `https://minio.example.com:9000` | `path` | SigV4 (Signature Version 4), any region |
194
- | Silo | `https://s3.example.com:443` | `path` | fork of MinIO — same settings |
195
- | Ceph RGW | `https://rgw.example.com` | `path` | S3-compatible API |
196
- | Wasabi | `https://s3.wasabisys.com` | `path` | region required, must match the bucket |
197
- | Backblaze B2 | `https://s3.<region>.backblazeb2.com` | `virtual` | use an Application Key as access/secret |
198
- | AWS S3 | *(omit `s3_endpoint`)* | `virtual` | default endpoint, region must match |
202
+ | Service | Example `s3_endpoint` | `s3_addressing` | `s3_region` | TLS |
203
+ |---|---|---|---|---|
204
+ | MinIO (self-hosted) | `https://minio.example.com:9000` | `path` | any (`us-east-1` default) | `https` + `s3_tls_check: false` if self-signed |
205
+ | Silo (fork of MinIO) | `https://s3.example.com:443` | `path` | any (`us-east-1` default) | as the endpoint's own certificate |
206
+ | Ceph RGW | `https://rgw.example.com` | `path` | any, must match the RGW zone (`us-east-1` default) | as configured on the RGW |
207
+ | Wasabi | `https://s3.wasabisys.com` | `path` | **required**, must match the bucket (`us-east-1` on that endpoint) | `https` |
208
+ | Backblaze B2 | `https://s3.<region>.backblazeb2.com` | `virtual` or `path` | **required**, must match the bucket region | `https` only — cleartext rejected |
209
+ | AWS S3 | *(omit `s3_endpoint`)* | `virtual` | must match the bucket (`us-east-1` default) | `https` |
210
+
211
+ All of them speak SigV4 (Signature Version 4) with the same client — no
212
+ service-specific code path. B2 wants an **Application Key** (not the Master
213
+ Key) as `s3_access_key`/`s3_secret_key`.
199
214
 
200
215
  `safekeep status` shows the bucket as `[ok]`/`[offline]` (cached HeadBucket),
201
216
  and `safekeep doctor` reports credentials, file permissions and bucket
@@ -213,6 +228,7 @@ safekeep <command> [--config PATH] [-v]
213
228
  | `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) |
214
229
  | `status` | read-only: config, sources, discovered projects with N rules, destination states |
215
230
  | `doctor [--json]` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails; `--json` prints the same checks as one JSON document on stdout with the same exit code, including a structured `permission_errors` count of the last 24h |
231
+ | `daemon {install,start,stop,status,uninstall}` | manage the boot agent: `install` runs preflight (fswatch, shim on PATH, WSL systemd-on) and renders the launchd plist / systemd user unit from the embedded templates (idempotent, `--dry-run`, `--enable-linger`); `start`/`stop`/`status` load, unload and probe it; `uninstall` removes the unit (`--purge` also drops `~/.safekeep` and state) |
216
232
 
217
233
  ## Tests
218
234
 
@@ -220,7 +236,7 @@ safekeep <command> [--config PATH] [-v]
220
236
  python3 -m unittest discover -s tests
221
237
  ```
222
238
 
223
- Stdlib (`unittest`) suite, zero dependencies: 221 tests covering the matcher,
239
+ Stdlib (`unittest`) suite, zero dependencies: 287 tests covering the matcher,
224
240
  config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
225
241
  wrapper and the POSIX portability layer (platform helper, per-OS fswatch
226
242
  monitor, systemd template).
@@ -228,6 +244,20 @@ monitor, systemd template).
228
244
  the real binary are skipped when it's missing — but CI installs it so the
229
245
  Linux job exercises the real `inotify` monitor.
230
246
 
247
+ The S3 integration test talks to a real bucket and is **skipped** unless every
248
+ `SAFEKEEP_S3_TEST_*` variable is set (no credential is hard-coded; it uploads
249
+ under a throwaway timestamped prefix and performs **no** DeleteObject):
250
+
251
+ ```bash
252
+ export SAFEKEEP_S3_TEST_ENDPOINT=https://s3.example.com:443 \
253
+ SAFEKEEP_S3_TEST_BUCKET=my-backup-bucket \
254
+ SAFEKEEP_S3_TEST_ACCESS_KEY=REPLACEME_ACCESS_KEY \
255
+ SAFEKEEP_S3_TEST_SECRET_KEY=REPLACEME_SECRET_KEY \
256
+ SAFEKEEP_S3_TEST_REGION=us-east-1 # optional, default us-east-1
257
+ # SAFEKEEP_S3_TEST_PREFIX=safekeep # optional, default safekeep-itest
258
+ python3 -m unittest tests.test_s3_itest -v
259
+ ```
260
+
231
261
  ## Documentation
232
262
 
233
263
  Everything in detail (config format, pattern semantics, event → sync flow,
@@ -43,5 +43,10 @@ safekeep = "safekeep.cli:main"
43
43
  # NO auto-discovery: bin/ e tests/ farebbero errore di build
44
44
  packages = ["safekeep"]
45
45
 
46
+ [tool.setuptools.package-data]
47
+ # 0.7.0: template unit + example config embedded — `safekeep daemon install`
48
+ # funziona da PyPI senza git clone (sorgente unica: safekeep/data/)
49
+ safekeep = ["data/*"]
50
+
46
51
  [tool.setuptools.dynamic]
47
52
  version = {attr = "safekeep.__version__"}
@@ -3,4 +3,4 @@ import logging
3
3
 
4
4
  logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
5
 
6
- __version__ = '0.6.0'
6
+ __version__ = '0.7.0'
@@ -1,4 +1,4 @@
1
- """CLI di safekeep: `run`, `sync-once`, `status` e `doctor` (SPEC.md §9).
1
+ """CLI di safekeep: `run`, `sync-once`, `status`, `doctor` e `daemon` (SPEC.md §9).
2
2
 
3
3
  Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
4
4
  shim che usano launchd/systemd e i test.
@@ -13,6 +13,7 @@ import sys
13
13
  from datetime import datetime
14
14
 
15
15
  from safekeep import __version__
16
+ from safekeep import daemonctl
16
17
  from safekeep.config import ConfigError, discover_projects, parse_sync
17
18
  from safekeep.copier import TMP_INFIX
18
19
  from safekeep.daemon import Daemon, setup_logging
@@ -34,10 +35,8 @@ from safekeep.platform import (
34
35
  from safekeep.s3dest import S3ExtraMissing, secret_perms_insecure
35
36
  from safekeep.volumes import dest_state
36
37
 
37
- # Radice del repo quando si gira da checkout; in un install pip punta altrove
38
- # (site-packages) e i riferimenti a file del repo degradano (vedi cmd_doctor).
39
- REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
40
- EXAMPLE = os.path.join(REPO, 'examples', 'safekeep.example')
38
+ # Repo upstream: solo nell'hint degradato di `cmd_doctor` quando l'esempio
39
+ # embedded non fosse leggibile (install pip rotto — package data mancante).
41
40
  REPO_URL = 'https://github.com/foxhound87/safekeep'
42
41
 
43
42
 
@@ -73,6 +72,38 @@ def build_parser():
73
72
  doc.add_argument('--json', action='store_true',
74
73
  help='stessi check, stesso exit code, ma un documento JSON su '
75
74
  'stdout (SPEC.md §9.1)')
75
+ daem = sub.add_parser(
76
+ 'daemon',
77
+ description="installa e gestisce l'agent di boot (launchd su macOS, "
78
+ 'systemd user su Linux/WSL2 con systemd-on) — ExecStart = '
79
+ 'shim `safekeep` sul PATH → `safekeep run`',
80
+ help="installa e gestisce l'agent di boot (launchd su macOS, systemd "
81
+ "user su Linux/WSL2) — ExecStart: safekeep run")
82
+ dsub = daem.add_subparsers(dest='daemon_cmd', required=True)
83
+ dinst = dsub.add_parser(
84
+ 'install',
85
+ help='preflight (fswatch/shim/python/WSL-systemd) + render della unit da '
86
+ 'template embedded + scrittura — idempotente, NON avvia')
87
+ dinst.add_argument('--enable-linger', action='store_true',
88
+ help='Linux: esegue `loginctl enable-linger $USER` '
89
+ '(default: solo hint — linger = unit user attive '
90
+ 'anche senza login)')
91
+ dinst.add_argument('--dry-run', action='store_true',
92
+ help='mostra cosa installerebbe, senza scrivere nulla')
93
+ dsub.add_parser('start',
94
+ help="carica e avvia l'agent (launchctl bootstrap / "
95
+ 'systemctl --user enable --now)')
96
+ dsub.add_parser('stop',
97
+ help="ferma l'agent (launchctl bootout / "
98
+ 'systemctl --user disable --now)')
99
+ dsub.add_parser('status',
100
+ help="stato dell'agent — sola lettura, exit 0 se in "
101
+ 'esecuzione, 1 altrimenti')
102
+ dun = dsub.add_parser('uninstall',
103
+ help='rimuove la unit — ~/.safekeep e i log restano '
104
+ 'intatti')
105
+ dun.add_argument('--purge', action='store_true',
106
+ help='rimuove anche ~/.safekeep e ~/.local/state/safekeep/')
76
107
  return parser
77
108
 
78
109
 
@@ -190,10 +221,13 @@ def cmd_doctor(args):
190
221
  daemon = None
191
222
  s3_missing = None
192
223
  if not os.path.exists(cfg_path):
193
- if os.path.exists(EXAMPLE):
194
- hint = f'creala: cp {EXAMPLE} {cfg_path}'
224
+ # Hint dall'example EMBEDDED (package data, task 4): un install pip non
225
+ # ha il repo → niente più "clone the repo"
226
+ embedded = daemonctl.example_path()
227
+ if os.path.exists(embedded):
228
+ hint = f'creala: cp {embedded} {cfg_path}'
195
229
  else:
196
- # install pip: niente repo, niente esempi → hint degradato
230
+ # install rotto (niente package data): fallback minimo
197
231
  hint = (f'clone {REPO_URL} and copy examples/safekeep.example to '
198
232
  f'{cfg_path}')
199
233
  out(False, f'config assente: {cfg_path} ({hint})',
@@ -377,7 +411,7 @@ def cmd_doctor(args):
377
411
  'residui *.safekeep.tmp.*: ' + ', '.join(residui))
378
412
 
379
413
  if is_darwin():
380
- plist = os.path.expanduser('~/Library/LaunchAgents/com.safekeep.agent.plist')
414
+ plist = os.path.expanduser(daemonctl.PLIST_PATH)
381
415
  if not os.path.exists(plist):
382
416
  out(True, 'plist non installato (nessun agent da validare)')
383
417
  elif shutil.which('plutil') is None:
@@ -391,8 +425,9 @@ def cmd_doctor(args):
391
425
  except (OSError, subprocess.SubprocessError) as e:
392
426
  out(False, f'plist lint non eseguibile: {e}', is_fatal=True)
393
427
  else:
394
- # Linux: user unit systemd al posto del plist (SPEC.md §14.3)
395
- unit = os.path.expanduser('~/.config/systemd/user/safekeep.service')
428
+ # Linux: user unit systemd al posto del plist (SPEC.md §14.3) — stesso
429
+ # path scritto da daemonctl (costanti condivise, task 4)
430
+ unit = os.path.expanduser(daemonctl.UNIT_PATH)
396
431
  if not os.path.exists(unit):
397
432
  out(True, 'unit systemd non installata (nessun agent da validare)')
398
433
  else:
@@ -402,9 +437,12 @@ def cmd_doctor(args):
402
437
  except (OSError, UnicodeError) as e:
403
438
  out(False, f'unit systemd illeggibile: {unit}: {e}', is_fatal=True)
404
439
  else:
405
- if '__REPO__' in text or '__PYTHON__' in text:
406
- out(False, f'unit systemd: {unit} contiene ancora i placeholder '
407
- 'del template — rilancia install.sh', is_fatal=True)
440
+ residui = [p for p in daemonctl.PLACEHOLDERS if p in text]
441
+ if residui:
442
+ out(False, f'unit systemd: {unit} contiene ancora i '
443
+ f'placeholder del template '
444
+ f'({", ".join(residui)}) — rilancia '
445
+ f'safekeep daemon install', is_fatal=True)
408
446
  else:
409
447
  out(True, f'unit systemd: {unit}')
410
448
  # linger: solo dove la unit è installata (SPEC.md §14.4) — non fatale
@@ -453,9 +491,25 @@ def cmd_doctor(args):
453
491
  return code
454
492
 
455
493
 
494
+ def cmd_daemon(args):
495
+ """Dispatch dei sottocomandi `safekeep daemon` (daemonctl, SPEC §14.3)."""
496
+ if args.daemon_cmd == 'install':
497
+ return daemonctl.install(enable_linger=args.enable_linger,
498
+ dry_run=args.dry_run)
499
+ if args.daemon_cmd == 'uninstall':
500
+ return daemonctl.uninstall(purge=args.purge)
501
+ if args.daemon_cmd == 'start':
502
+ return daemonctl.start()
503
+ if args.daemon_cmd == 'stop':
504
+ return daemonctl.stop()
505
+ return daemonctl.status()
506
+
507
+
456
508
  def main(argv=None):
457
509
  args = build_parser().parse_args(argv)
458
510
  try:
511
+ if args.comando == 'daemon':
512
+ return cmd_daemon(args)
459
513
  if args.comando == 'run':
460
514
  return Daemon(args.config).run(verbose=args.v)
461
515
  if args.comando == 'status':