diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87c1217..a8148e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
+## v0.9.4
+
+Doctor release.
+
+- Add `doctor` to check Photos access and optional backup health.
+- Add `doctor --out
` to report backup directory, manifest entries, and failure count.
+- Add `doctor --json` for scriptable diagnostics.
+
## v0.9.3
Cleanup release.
diff --git a/Makefile b/Makefile
index 9bf4c13..92cb673 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli
-VERSION := 0.9.3
+VERSION := 0.9.4
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge
diff --git a/README.md b/README.md
index 5f95381..b54dc90 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,7 @@ Verify a backup later:
./bin/photoscli verify --out ./photos-backup --deep
./bin/photoscli manifest repair --out ./photos-backup --checksum sha256 --dry-run
./bin/photoscli cleanup --out ./photos-backup --dry-run
+./bin/photoscli doctor --out ./photos-backup
```
## Commands
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 99b9e2c..696ae36 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,18 +1,18 @@
-# v0.9.3
+# v0.9.4
-This release adds safe cleanup for manifest-backed backups.
+This release adds `doctor` diagnostics for Photos access and backups.
## Highlights
-- Add `cleanup --out ` to remove files not referenced by the manifest.
-- Add `cleanup --dry-run` to preview orphaned files before deletion.
-- Preserve manifest/log/failure files, `.photoscli`, and sidecars for manifest-referenced media.
-- Cleanup works with JSONL and SQLite manifests.
+- Add `doctor` to check Photos access.
+- Add `doctor --out ` to report backup directory, manifest entries, and failure count.
+- Add `doctor --json` for scriptable diagnostics.
+- `doctor` is read-only and does not modify backup data.
## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
-- `photoscli-0.9.3-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
+- `photoscli-0.9.4-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `USERGUIDE.md`: standalone user guide.
Intel Macs are not currently a supported release target.
diff --git a/USERGUIDE.md b/USERGUIDE.md
index ec7f12d..5548da4 100644
--- a/USERGUIDE.md
+++ b/USERGUIDE.md
@@ -856,6 +856,16 @@ Preview and remove files not referenced by the manifest:
Cleanup preserves manifest/log/failure files, `.photoscli`, and sidecars next to manifest-referenced media.
+Run read-only diagnostics:
+
+```bash
+./bin/photoscli doctor
+./bin/photoscli doctor --out ./PhotosBackup
+./bin/photoscli doctor --out ./PhotosBackup --json
+```
+
+Doctor checks Photos access and, when `--out` is provided, backup directory status, manifest entries, and failure count.
+
## Safe Operating Practices
- Run `--dry-run` before the first large backup.
diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go
index f5ac17f..90e3454 100644
--- a/cmd/photoscli/main.go
+++ b/cmd/photoscli/main.go
@@ -97,6 +97,8 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
return cmdManifest(args[1:], stdout, stderr)
case "cleanup":
return cmdCleanup(args[1:], stdout, stderr)
+ case "doctor":
+ return cmdDoctor(args[1:], stdout, stderr, bridge)
case "retry-failed":
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
case "failures":
@@ -144,6 +146,7 @@ USAGE
photoscli verify --out [--manifest jsonl|sqlite]
photoscli manifest repair --out [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
photoscli cleanup --out [--manifest jsonl|sqlite] [--dry-run]
+ photoscli doctor [--out ] [--manifest jsonl|sqlite] [--json]
photoscli retry-failed --out
photoscli retry-failed --out --clear-on-success
photoscli failures list --out
@@ -195,6 +198,9 @@ COMMANDS
cleanup --out [--manifest jsonl|sqlite] [--dry-run]
Remove files not referenced by the manifest. Use --dry-run first.
+ doctor [--out ] [--manifest jsonl|sqlite] [--json]
+ Check Photos access and optional backup/manifest health without changing data.
+
retry-failed --out
Retry assets previously written to failures.jsonl.
@@ -2396,6 +2402,64 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool)
return 0
}
+func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
+ result := map[string]any{"photos_access": "ok"}
+ problems := 0
+ if rc := mustAuth(stderr, bridge); rc != exitOK {
+ result["photos_access"] = "denied"
+ problems++
+ }
+ outDir := flagVal(args, "--out")
+ if outDir != "" {
+ result["out"] = outDir
+ if info, err := statFunc(outDir); err != nil || !info.IsDir() {
+ result["backup_dir"] = "missing"
+ problems++
+ } else {
+ result["backup_dir"] = "ok"
+ mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
+ if err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitErr
+ }
+ entries, err := loadManifestEntries(outDir, mf)
+ if err != nil {
+ result["manifest"] = "error"
+ problems++
+ } else {
+ result["manifest"] = string(mf)
+ result["entries"] = len(entries)
+ }
+ failures := loadFailures(outDir)
+ result["failures"] = len(failures)
+ if len(failures) > 0 {
+ problems++
+ }
+ }
+ }
+ if hasFlag(args, "--json") {
+ result["problems"] = problems
+ if err := json.NewEncoder(stdout).Encode(result); err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitErr
+ }
+ } else {
+ keys := make([]string, 0, len(result))
+ for k := range result {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ fmt.Fprintf(stdout, "%s\t%v\n", k, result[k])
+ }
+ fmt.Fprintf(stdout, "problems\t%d\n", problems)
+ }
+ if problems > 0 {
+ return exitPartial
+ }
+ return exitOK
+}
+
func cmdCleanup(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out")
if outDir == "" {
diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go
index bf77ef4..ce442ee 100644
--- a/cmd/photoscli/main_test.go
+++ b/cmd/photoscli/main_test.go
@@ -4506,6 +4506,53 @@ func TestCleanupErrors(t *testing.T) {
}
}
+func TestDoctor(t *testing.T) {
+ dir := t.TempDir()
+ m := manifest.LoadJSONL(dir)
+ if err := m.OpenAppend(); err != nil {
+ t.Fatal(err)
+ }
+ m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
+ m.Close()
+ out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{})
+ if rc != exitOK || !strings.Contains(stderr, "access granted") || !strings.Contains(out, "backup_dir\tok") || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "problems\t0") {
+ t.Fatalf("doctor rc=%d out=%q stderr=%q", rc, out, stderr)
+ }
+ out, stderr, rc = runWith([]string{"doctor", "--out", dir, "--json"}, &mockBridge{})
+ if rc != exitOK || !strings.Contains(out, `"entries":1`) || !strings.Contains(out, `"problems":0`) {
+ t.Fatalf("doctor json rc=%d out=%q stderr=%q", rc, out, stderr)
+ }
+}
+
+func TestDoctorProblems(t *testing.T) {
+ dir := t.TempDir()
+ appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}}, fmt.Errorf("failed"))
+ out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{accessErr: fmt.Errorf("denied")})
+ if rc != exitPartial || !strings.Contains(stderr, "denied") || !strings.Contains(out, "photos_access\tdenied") || !strings.Contains(out, "failures\t1") {
+ t.Fatalf("doctor problems rc=%d out=%q stderr=%q", rc, out, stderr)
+ }
+ _, stderr, rc = runWith([]string{"doctor", "--out", dir, "--manifest", "bad"}, &mockBridge{})
+ if rc != exitErr || !strings.Contains(stderr, "manifest") {
+ t.Fatalf("doctor bad manifest rc=%d stderr=%q", rc, stderr)
+ }
+ out, stderr, rc = runWith([]string{"doctor", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
+ if rc != exitPartial || !strings.Contains(out, "backup_dir\tmissing") {
+ t.Fatalf("doctor missing dir rc=%d out=%q stderr=%q", rc, out, stderr)
+ }
+ badDBDir := t.TempDir()
+ if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ out, stderr, rc = runWith([]string{"doctor", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
+ if rc != exitPartial || !strings.Contains(out, "manifest\terror") {
+ t.Fatalf("doctor manifest error rc=%d out=%q stderr=%q", rc, out, stderr)
+ }
+ stderrBuf := &bytes.Buffer{}
+ if rc := cmdDoctor([]string{"--json"}, errWriter{}, stderrBuf, &mockBridge{}); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") {
+ t.Fatalf("doctor json writer error rc=%d stderr=%q", rc, stderrBuf.String())
+ }
+}
+
func TestVerifySidecarBranches(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "sub")