Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'feat/ux-open-source'

+2140 -340
+3
.gitignore
··· 1 1 node_modules/ 2 + 3 + # User config (contains endpoint, bucket, keychain service names) 4 + ~/.attic/
+11 -7
CLAUDE.md
··· 1 1 # Attic 2 2 3 - Deno/TypeScript CLI for backing up iCloud Photos to Scaleway S3. Part of the photo-cloud system (companion: [ladder](https://github.com/tijs/ladder)). 3 + Deno/TypeScript CLI for backing up iCloud Photos to S3-compatible storage. Part 4 + of the photo-cloud system (companion: [ladder](https://github.com/tijs/ladder)). 4 5 5 6 ## Commands 6 7 7 8 ```bash 8 9 deno task check # Type check 9 - deno task test # Run tests (44 tests) 10 + deno task test # Run tests (54 tests) 10 11 deno task lint # Lint 11 12 deno task fmt # Format 12 13 deno task fmt:check # Check formatting ··· 16 17 17 18 ``` 18 19 shared/ # @attic/shared — PhotoAsset type, S3 path helpers 19 - cli/ # @attic/cli — commands, storage, manifest, export 20 - src/commands/ # scan, status, backup, verify, rebuild 21 - src/storage/ # S3 client + Keychain credential loading 20 + cli/ # @attic/cli — commands, config, storage, manifest, export 21 + src/commands/ # init, scan, status, backup, verify, rebuild 22 + src/config/ # Config file (load, validate, write) 23 + src/storage/ # Generic S3 client + Keychain credential loading 22 24 src/manifest/ # Local JSON manifest with atomic writes 23 25 src/export/ # Exporter interface + ladder subprocess integration 24 26 ``` 25 27 26 28 ## Reference Docs 27 29 28 - - [Architecture](docs/architecture.md) — pipeline, reader, ladder protocol, manifest, interfaces, design boundaries 30 + - [Architecture](docs/architecture.md) — pipeline, reader, ladder protocol, 31 + manifest, interfaces, design boundaries 29 32 - [Asset Metadata](docs/metadata.md) — per-asset JSON schema uploaded to S3 30 33 31 34 ## Conventions 32 35 33 36 - Files should stay under 500 lines 34 37 - Use `AssetKind.PHOTO` / `AssetKind.VIDEO` constants, not magic numbers 35 - - S3 keys and UUIDs are validated with regex before interpolation (path traversal prevention) 38 + - S3 keys and UUIDs are validated with regex before interpolation (path 39 + traversal prevention) 36 40 - `removeStagedFile()` constrains deletion to the staging directory
+82 -35
README.md
··· 4 4 5 5 # Attic 6 6 7 - Back up your iCloud Photos library to Scaleway Object Storage (S3-compatible). 7 + Back up your iCloud Photos library to S3-compatible storage. 8 8 9 - Attic reads the Photos.sqlite database directly, exports originals via a companion Swift tool called [ladder](https://github.com/tijs/ladder), and uploads them to a Scaleway S3 bucket. A local manifest tracks what has already been backed up so subsequent runs only upload new assets. 9 + Attic reads the Photos.sqlite database directly, exports originals via a 10 + companion Swift tool called [ladder](https://github.com/tijs/ladder), and 11 + uploads them to an S3-compatible bucket. A local manifest tracks what has 12 + already been backed up so subsequent runs only upload new assets. 13 + 14 + Works with any S3-compatible provider. EU-friendly options include 15 + [Scaleway](https://www.scaleway.com/en/object-storage/), 16 + [Hetzner](https://www.hetzner.com/storage/object-storage), and 17 + [OVH](https://www.ovhcloud.com/en/public-cloud/object-storage/). 10 18 11 19 ## Prerequisites 12 20 13 21 - [Deno](https://deno.land/) (v2+) 14 - - The [ladder](https://github.com/tijs/ladder) binary. Ladder is a separate Swift tool that uses PhotoKit to export original photo/video files from the Photos library. 15 - - A Scaleway Object Storage bucket and API credentials 22 + - The [ladder](https://github.com/tijs/ladder) binary. Ladder is a separate 23 + Swift tool that uses PhotoKit to export original photo/video files from the 24 + Photos library. 25 + - An S3-compatible storage bucket and API credentials 16 26 - macOS (Photos.sqlite access and Keychain are macOS-only) 17 27 18 28 ## Setup 19 29 20 - Store your Scaleway S3 credentials in the macOS Keychain: 30 + Run the interactive setup: 21 31 22 32 ```bash 23 - security add-generic-password -s attic-s3-access-key -a attic -w "<your-access-key>" 24 - security add-generic-password -s attic-s3-secret-key -a attic -w "<your-secret-key>" 33 + deno task init 25 34 ``` 26 35 27 - Build the ladder binary (see [ladder](https://github.com/tijs/ladder) for details): 36 + This prompts for your S3 endpoint, region, bucket name, and credentials. Config 37 + is saved to `~/.attic/config.json` and credentials are stored in the macOS 38 + Keychain. 39 + 40 + Build the ladder binary (see [ladder](https://github.com/tijs/ladder) for 41 + details): 28 42 29 43 ```bash 30 44 git clone https://github.com/tijs/ladder.git ··· 36 50 37 51 All commands are run via `deno task`: 38 52 39 - ### scan 53 + ### init 40 54 41 - Scan the Photos library and print statistics (asset counts, sizes, types, local vs iCloud-only). 55 + Interactive setup — configure S3 connection and store credentials. 42 56 43 57 ```bash 44 - deno task scan 58 + deno task init 45 59 ``` 46 60 47 - Optionally pass a custom database path: 61 + ### scan 62 + 63 + Scan the Photos library and print statistics (asset counts, sizes, types, local 64 + vs iCloud-only). 48 65 49 66 ```bash 50 - deno task scan /path/to/Photos.sqlite 67 + deno task scan 51 68 ``` 52 69 53 70 ### status 54 71 55 - Compare the Photos database against the local backup manifest to show how many assets are backed up vs pending. 72 + Compare the Photos database against the local backup manifest to show how many 73 + assets are backed up vs pending. 56 74 57 75 ```bash 58 76 deno task status ··· 66 84 deno task backup 67 85 ``` 68 86 69 - Flags (append after `--`): 70 - 71 - | Flag | Description | 72 - |---|---| 73 - | `--dry-run` | Show what would be uploaded without uploading | 74 - | `--limit N` | Back up at most N assets | 75 - | `--batch-size N` | Assets per ladder export batch (default: 50) | 76 - | `--type photo\|video` | Only back up photos or videos | 77 - | `--bucket NAME` | S3 bucket name (default: `photo-cloud-storage`) | 78 - | `--ladder PATH` | Path to the ladder binary (or set `LADDER_PATH` env var) | 79 - | `--db PATH` | Path to Photos.sqlite | 87 + | Flag | Description | 88 + | --------------------- | -------------------------------------------------------- | 89 + | `--dry-run` | Show what would be uploaded without uploading | 90 + | `--limit N` | Back up at most N assets | 91 + | `--batch-size N` | Assets per ladder export batch (default: 50) | 92 + | `--type photo\|video` | Only back up photos or videos | 93 + | `--bucket NAME` | Override bucket from config | 94 + | `--ladder PATH` | Path to the ladder binary (or set `LADDER_PATH` env var) | 95 + | `--db PATH` | Path to Photos.sqlite | 80 96 81 97 ### verify 82 98 ··· 86 102 deno task verify 87 103 ``` 88 104 89 - | Flag | Description | 90 - |---|---| 91 - | `--deep` | Download each object and re-verify SHA-256 checksum (slow) | 92 - | `--rebuild-manifest` | Reconstruct the local manifest from S3 metadata files | 93 - | `--bucket NAME` | S3 bucket name (default: `photo-cloud-storage`) | 105 + | Flag | Description | 106 + | -------------------- | ---------------------------------------------------------- | 107 + | `--deep` | Download each object and re-verify SHA-256 checksum (slow) | 108 + | `--rebuild-manifest` | Reconstruct the local manifest from S3 metadata files | 109 + | `--bucket NAME` | Override bucket from config | 110 + 111 + ## Configuration 112 + 113 + Attic stores its configuration at `~/.attic/config.json`: 114 + 115 + ```json 116 + { 117 + "endpoint": "https://s3.fr-par.scw.cloud", 118 + "region": "fr-par", 119 + "bucket": "my-photo-backup", 120 + "pathStyle": true, 121 + "keychain": { 122 + "accessKeyService": "attic-s3-access-key", 123 + "secretKeyService": "attic-s3-secret-key" 124 + } 125 + } 126 + ``` 127 + 128 + The `keychain` section is optional and defaults to the service names shown 129 + above. Credentials are always stored in the macOS Keychain, never in config 130 + files or environment variables. 131 + 132 + `scan` and `status` work without config (they only read Photos.sqlite). `backup` 133 + and `verify` require config and will tell you to run `attic init` if it's 134 + missing. 94 135 95 136 ## Testing 96 137 ··· 98 139 deno task test 99 140 ``` 100 141 101 - Tests use dependency injection with mock implementations for the S3 client and exporter, so no external services or credentials are needed. 142 + Tests use dependency injection with mock implementations for the S3 client and 143 + exporter, so no external services or credentials are needed. 102 144 103 145 ## Documentation 104 146 105 - - [Architecture](docs/architecture.md) -- How attic works: the backup pipeline, Photos.sqlite reader, ladder protocol, manifest lifecycle, and design boundaries 106 - - [Asset Metadata](docs/metadata.md) -- Schema reference for the per-asset JSON uploaded to S3 147 + - [Architecture](docs/architecture.md) -- How attic works: the backup pipeline, 148 + Photos.sqlite reader, ladder protocol, manifest lifecycle, and design 149 + boundaries 150 + - [Asset Metadata](docs/metadata.md) -- Schema reference for the per-asset JSON 151 + uploaded to S3 107 152 108 153 ## Future Plans 109 154 110 - - **Scheduled backups via launchd** -- A LaunchAgent plist to run backups daily on a dedicated Mac 111 - - **Rendered edit backup** -- Detect and upload edited versions alongside originals (see `docs/plans/`) 155 + - **Scheduled backups via launchd** -- A LaunchAgent plist to run backups daily 156 + on a dedicated Mac 157 + - **Rendered edit backup** -- Detect and upload edited versions alongside 158 + originals (see `docs/plans/`)
+3
cli/deno.json
··· 5 5 "imports": { 6 6 "@attic/shared": "../shared/mod.ts", 7 7 "@aws-sdk/client-s3": "npm:@aws-sdk/client-s3@^3", 8 + "@cliffy/command": "jsr:@cliffy/command@^1.0", 9 + "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0", 10 + "@cliffy/ansi": "jsr:@cliffy/ansi@^1.0", 8 11 "@db/sqlite": "jsr:@db/sqlite@^0.12", 9 12 "@std/assert": "jsr:@std/assert@^1.0", 10 13 "@std/crypto": "jsr:@std/crypto@^1.0",
+165 -172
cli/mod.ts
··· 1 - const DEFAULT_BUCKET = "photo-cloud-storage"; 1 + import { Command, EnumType } from "@cliffy/command"; 2 + import { type AtticConfig, requireConfig } from "./src/config/config.ts"; 3 + import type { S3ConnectionConfig } from "./src/storage/s3-client.ts"; 4 + 5 + const assetType = new EnumType(["photo", "video"]); 2 6 3 - const command = Deno.args[0]; 7 + function s3ConnectionFromConfig(config: AtticConfig): S3ConnectionConfig { 8 + return { 9 + endpoint: config.endpoint, 10 + region: config.region, 11 + pathStyle: config.pathStyle, 12 + }; 13 + } 14 + 15 + const main = new Command() 16 + .name("attic") 17 + .version("0.1.0") 18 + .description("Back up your iCloud Photos library to S3-compatible storage") 19 + .action(function (this: Command) { 20 + this.showHelp(); 21 + }); 4 22 5 - switch (command) { 6 - case "scan": { 23 + main.command("scan", "Scan Photos library and show statistics") 24 + .option("--db <path:string>", "Path to Photos.sqlite") 25 + .action(async ({ db }: { db?: string }) => { 7 26 const { openPhotosDb } = await import("./src/photos-db/reader.ts"); 8 27 const { printScanReport } = await import("./src/commands/scan.ts"); 9 28 10 - const dbPath = Deno.args[1]; // optional override 11 - const reader = openPhotosDb(dbPath); 29 + const reader = openPhotosDb(db); 12 30 try { 13 31 const assets = reader.readAssets(); 14 32 printScanReport(assets); 15 33 } finally { 16 34 reader.close(); 17 35 } 18 - break; 19 - } 20 - case "status": { 36 + }); 37 + 38 + main.command("status", "Compare Photos DB vs backup manifest") 39 + .option("--db <path:string>", "Path to Photos.sqlite") 40 + .action(async ({ db }: { db?: string }) => { 21 41 const { openPhotosDb } = await import("./src/photos-db/reader.ts"); 22 42 const { printStatusReport } = await import("./src/commands/status.ts"); 23 43 const { createManifestStore } = await import("./src/manifest/manifest.ts"); 24 44 25 - const dbPath = Deno.args[1]; // optional override 26 - const reader = openPhotosDb(dbPath); 45 + const reader = openPhotosDb(db); 27 46 try { 28 47 const assets = reader.readAssets(); 29 48 const manifestStore = createManifestStore(); ··· 31 50 } finally { 32 51 reader.close(); 33 52 } 34 - break; 35 - } 36 - case "backup": { 53 + }); 54 + 55 + main.command("backup", "Back up pending assets to S3") 56 + .option("--dry-run", "Show what would be uploaded") 57 + .option("--limit <n:integer>", "Back up at most N assets") 58 + .option("--batch-size <n:integer>", "Assets per ladder batch", { 59 + default: 50, 60 + }) 61 + .type("asset-type", assetType) 62 + .option("--type <type:asset-type>", "Only back up photos or videos") 63 + .option("--bucket <name:string>", "Override bucket from config") 64 + .option("--ladder <path:string>", "Path to ladder binary") 65 + .option("--db <path:string>", "Path to Photos.sqlite") 66 + .action(async (options: { 67 + dryRun?: boolean; 68 + limit?: number; 69 + batchSize: number; 70 + type?: "photo" | "video"; 71 + bucket?: string; 72 + ladder?: string; 73 + db?: string; 74 + }) => { 37 75 const { openPhotosDb } = await import("./src/photos-db/reader.ts"); 38 76 const { runBackup } = await import("./src/commands/backup.ts"); 39 77 const { createManifestStore } = await import("./src/manifest/manifest.ts"); 40 - const { createS3Provider, loadKeychainCredentials } = await import( 41 - "./src/storage/s3-client.ts" 78 + const { createS3Provider } = await import("./src/storage/s3-client.ts"); 79 + const { loadKeychainCredentials } = await import( 80 + "./src/keychain/keychain.ts" 42 81 ); 43 82 const { createLadderExporter } = await import("./src/export/exporter.ts"); 44 83 45 - const flags = parseBackupFlags(Deno.args.slice(1)); 46 - const reader = openPhotosDb(flags.dbPath); 84 + const config = requireConfig(); 85 + const reader = openPhotosDb(options.db); 47 86 try { 48 87 const assets = reader.readAssets(); 49 88 const manifestStore = createManifestStore(); 50 89 const manifest = await manifestStore.load(); 51 90 52 - const credentials = await loadKeychainCredentials(); 91 + const credentials = await loadKeychainCredentials( 92 + config.keychain.accessKeyService, 93 + config.keychain.secretKeyService, 94 + ); 53 95 const s3 = createS3Provider( 54 96 credentials, 55 - flags.bucket ?? DEFAULT_BUCKET, 97 + options.bucket ?? config.bucket, 98 + s3ConnectionFromConfig(config), 56 99 ); 57 100 58 - const ladderPath = flags.ladderPath ?? 101 + const ladderPath = options.ladder ?? 59 102 Deno.env.get("LADDER_PATH") ?? 60 103 "ladder"; 61 104 const exporter = createLadderExporter(ladderPath); 62 105 63 106 await runBackup(assets, manifest, manifestStore, exporter, s3, { 64 - batchSize: flags.batchSize, 65 - limit: flags.limit, 66 - type: flags.type, 67 - dryRun: flags.dryRun, 107 + batchSize: options.batchSize, 108 + limit: options.limit ?? 0, 109 + type: options.type ?? null, 110 + dryRun: options.dryRun ?? false, 68 111 }); 69 112 } finally { 70 113 reader.close(); 71 114 } 72 - break; 73 - } 74 - case "verify": { 115 + }); 116 + 117 + main.command("init", "Set up attic configuration") 118 + .action(async () => { 119 + const { runInit } = await import("./src/commands/init.ts"); 120 + await runInit(); 121 + }); 122 + 123 + main.command("verify", "Verify backup integrity against S3") 124 + .option("--deep", "Download and re-checksum each object") 125 + .option("--rebuild-manifest", "Reconstruct manifest from S3 metadata") 126 + .option("--bucket <name:string>", "Override bucket from config") 127 + .action(async (options: { 128 + deep?: boolean; 129 + rebuildManifest?: boolean; 130 + bucket?: string; 131 + }) => { 75 132 const { runVerify } = await import("./src/commands/verify.ts"); 76 133 const { rebuildManifest } = await import("./src/commands/rebuild.ts"); 77 134 const { createManifestStore } = await import("./src/manifest/manifest.ts"); 78 - const { createS3Provider, loadKeychainCredentials } = await import( 79 - "./src/storage/s3-client.ts" 135 + const { createS3Provider } = await import("./src/storage/s3-client.ts"); 136 + const { loadKeychainCredentials } = await import( 137 + "./src/keychain/keychain.ts" 80 138 ); 81 139 82 - const verifyFlags = parseVerifyFlags(Deno.args.slice(1)); 140 + const config = requireConfig(); 83 141 84 - const credentials = await loadKeychainCredentials(); 142 + const credentials = await loadKeychainCredentials( 143 + config.keychain.accessKeyService, 144 + config.keychain.secretKeyService, 145 + ); 85 146 const s3 = createS3Provider( 86 147 credentials, 87 - verifyFlags.bucket ?? DEFAULT_BUCKET, 148 + options.bucket ?? config.bucket, 149 + s3ConnectionFromConfig(config), 88 150 ); 89 151 const manifestStore = createManifestStore(); 90 152 91 - if (verifyFlags.rebuildManifest) { 153 + if (options.rebuildManifest) { 92 154 await rebuildManifest(s3, manifestStore); 93 155 } else { 94 156 const manifest = await manifestStore.load(); 95 157 await runVerify(manifest, s3, { 96 - deep: verifyFlags.deep, 158 + deep: options.deep ?? false, 97 159 }); 98 160 } 99 - break; 100 - } 101 - default: 102 - console.log(`attic — iCloud Photos backup to Scaleway S3\n`); 103 - console.log(`Commands:`); 104 - console.log(` scan Scan Photos library and show statistics`); 105 - console.log(` status Compare Photos DB vs backup manifest`); 106 - console.log(` backup Back up pending assets to S3`); 107 - console.log(` verify Verify backup integrity against S3`); 108 - console.log(`\nBackup flags:`); 109 - console.log(` --dry-run Show what would be uploaded`); 110 - console.log(` --limit N Back up at most N assets`); 111 - console.log(` --batch-size N Assets per ladder batch (default: 50)`); 112 - console.log(` --type photo|video Only back up photos or videos`); 113 - console.log( 114 - ` --bucket NAME S3 bucket (default: ${DEFAULT_BUCKET})`, 115 - ); 116 - console.log(` --ladder PATH Path to ladder binary`); 117 - console.log(` --db PATH Path to Photos.sqlite`); 118 - console.log(`\nVerify flags:`); 119 - console.log(` --deep Download and re-checksum each object`); 120 - console.log(` --rebuild-manifest Reconstruct manifest from S3 metadata`); 121 - console.log( 122 - ` --bucket NAME S3 bucket (default: ${DEFAULT_BUCKET})`, 123 - ); 124 - console.log(`\nUsage: deno task <command>`); 125 - if (command) { 126 - console.error(`\nUnknown command: ${command}`); 127 - Deno.exit(1); 128 - } 129 - } 161 + }); 130 162 131 - interface BackupFlags { 132 - dryRun: boolean; 133 - limit: number; 134 - batchSize: number; 135 - type: "photo" | "video" | null; 136 - bucket: string | null; 137 - ladderPath: string | null; 138 - dbPath: string | undefined; 163 + try { 164 + await main.parse(Deno.args); 165 + } catch (error: unknown) { 166 + handleError(error); 167 + Deno.exit(1); 139 168 } 140 169 141 - function requireArg(args: string[], i: number, flag: string): string { 142 - const value = args[i]; 143 - if (value === undefined) { 144 - console.error(`Missing value for ${flag}`); 145 - Deno.exit(1); 170 + function handleError(error: unknown): void { 171 + if (!(error instanceof Error)) { 172 + console.error("An unexpected error occurred."); 173 + return; 146 174 } 147 - return value; 148 - } 149 175 150 - function parsePositiveInt(value: string, flag: string): number { 151 - const n = parseInt(value, 10); 152 - if (!Number.isFinite(n) || n < 1) { 153 - console.error(`${flag} must be a positive integer, got: ${value}`); 154 - Deno.exit(1); 176 + const msg = error.message; 177 + 178 + // Keychain not found 179 + if ( 180 + msg.includes("find-generic-password") || 181 + msg.includes("SecKeychainSearchCopyNext") 182 + ) { 183 + console.error("Could not read credentials from macOS Keychain."); 184 + console.error('Run "attic init" to set up your credentials.\n'); 185 + return; 155 186 } 156 - return n; 157 - } 158 187 159 - function parseBackupFlags(args: string[]): BackupFlags { 160 - const flags: BackupFlags = { 161 - dryRun: false, 162 - limit: 0, 163 - batchSize: 50, 164 - type: null, 165 - bucket: null, 166 - ladderPath: null, 167 - dbPath: undefined, 168 - }; 188 + // Config missing (thrown by requireConfig) 189 + if (msg.includes("No config file found")) { 190 + console.error(msg); 191 + return; 192 + } 169 193 170 - for (let i = 0; i < args.length; i++) { 171 - const arg = args[i]; 172 - switch (arg) { 173 - case "--dry-run": 174 - flags.dryRun = true; 175 - break; 176 - case "--limit": 177 - flags.limit = parsePositiveInt( 178 - requireArg(args, ++i, "--limit"), 179 - "--limit", 180 - ); 181 - break; 182 - case "--batch-size": 183 - flags.batchSize = parsePositiveInt( 184 - requireArg(args, ++i, "--batch-size"), 185 - "--batch-size", 186 - ); 187 - break; 188 - case "--type": { 189 - const typeVal = requireArg(args, ++i, "--type"); 190 - if (typeVal !== "photo" && typeVal !== "video") { 191 - console.error(`--type must be "photo" or "video", got: ${typeVal}`); 192 - Deno.exit(1); 193 - } 194 - flags.type = typeVal; 195 - break; 196 - } 197 - case "--bucket": 198 - flags.bucket = requireArg(args, ++i, "--bucket"); 199 - break; 200 - case "--ladder": 201 - flags.ladderPath = requireArg(args, ++i, "--ladder"); 202 - break; 203 - case "--db": 204 - flags.dbPath = requireArg(args, ++i, "--db"); 205 - break; 206 - case "--": 207 - break; 208 - default: 209 - console.error(`Unknown flag: ${arg}`); 210 - Deno.exit(1); 211 - } 194 + // Config validation error 195 + if (msg.startsWith("Config:")) { 196 + console.error(msg); 197 + console.error( 198 + 'Run "attic init" to reconfigure, or edit ~/.attic/config.json.\n', 199 + ); 200 + return; 212 201 } 213 202 214 - return flags; 215 - } 203 + // S3 access denied 204 + if (msg.includes("AccessDenied") || msg.includes("403")) { 205 + console.error( 206 + "S3 access denied. Check your credentials and bucket permissions.", 207 + ); 208 + console.error('Run "attic init" to update credentials.\n'); 209 + return; 210 + } 216 211 217 - interface VerifyFlags { 218 - deep: boolean; 219 - rebuildManifest: boolean; 220 - bucket: string | null; 221 - } 212 + // S3 bucket not found 213 + if (msg.includes("NoSuchBucket")) { 214 + console.error( 215 + "S3 bucket not found. Check the bucket name in ~/.attic/config.json.", 216 + ); 217 + return; 218 + } 222 219 223 - function parseVerifyFlags(args: string[]): VerifyFlags { 224 - const flags: VerifyFlags = { 225 - deep: false, 226 - rebuildManifest: false, 227 - bucket: null, 228 - }; 220 + // Network error 221 + if ( 222 + msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT") || 223 + msg.includes("fetch failed") 224 + ) { 225 + console.error( 226 + "Could not connect to S3 endpoint. Check your network and endpoint URL in ~/.attic/config.json.", 227 + ); 228 + return; 229 + } 229 230 230 - for (let i = 0; i < args.length; i++) { 231 - const arg = args[i]; 232 - switch (arg) { 233 - case "--deep": 234 - flags.deep = true; 235 - break; 236 - case "--rebuild-manifest": 237 - flags.rebuildManifest = true; 238 - break; 239 - case "--bucket": 240 - flags.bucket = requireArg(args, ++i, "--bucket"); 241 - break; 242 - case "--": 243 - break; 244 - default: 245 - console.error(`Unknown flag: ${arg}`); 246 - Deno.exit(1); 247 - } 231 + // Photos.sqlite not found 232 + if ( 233 + msg.includes("Photos.sqlite") || msg.includes("unable to open database") 234 + ) { 235 + console.error("Could not open Photos database."); 236 + console.error( 237 + "Make sure Photos is set up on this Mac and the database exists.\n", 238 + ); 239 + return; 248 240 } 249 241 250 - return flags; 242 + // Fallback 243 + console.error(`Error: ${msg}`); 251 244 }
+117
cli/src/commands/init.ts
··· 1 + import { Confirm, Input, Secret } from "@cliffy/prompt"; 2 + import { 3 + type AtticConfig, 4 + configPath, 5 + loadConfig, 6 + writeConfig, 7 + } from "../config/config.ts"; 8 + import { storeKeychainCredential } from "../keychain/keychain.ts"; 9 + 10 + const BUCKET_PATTERN = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/; 11 + 12 + const EU_PROVIDER_EXAMPLES = [ 13 + " Scaleway (EU): https://s3.fr-par.scw.cloud", 14 + " Hetzner (EU): https://fsn1.your-objectstorage.com", 15 + " OVH (EU): https://s3.gra.io.cloud.ovh.net", 16 + " AWS: https://s3.eu-west-1.amazonaws.com", 17 + ]; 18 + 19 + export async function runInit(): Promise<void> { 20 + console.log("\n attic — iCloud Photos backup to S3-compatible storage\n"); 21 + 22 + const existing = loadConfig(); 23 + if (existing) { 24 + const overwrite = await Confirm.prompt({ 25 + message: `Config already exists at ${configPath()}. Overwrite?`, 26 + default: false, 27 + }); 28 + if (!overwrite) { 29 + console.log(" Cancelled.\n"); 30 + return; 31 + } 32 + } 33 + 34 + console.log(" S3 Connection"); 35 + console.log(" " + "─".repeat(40) + "\n"); 36 + console.log(" Provider examples:"); 37 + for (const line of EU_PROVIDER_EXAMPLES) { 38 + console.log(line); 39 + } 40 + console.log(); 41 + 42 + const endpoint = await Input.prompt({ 43 + message: "Endpoint URL", 44 + validate: (v) => { 45 + if (!v.startsWith("https://")) return "Must start with https://"; 46 + return true; 47 + }, 48 + }); 49 + 50 + const region = await Input.prompt({ 51 + message: "Region", 52 + hint: "e.g. fr-par, eu-central-1, fsn1", 53 + validate: (v) => { 54 + if (v.trim() === "") return "Region is required"; 55 + return true; 56 + }, 57 + }); 58 + 59 + const bucket = await Input.prompt({ 60 + message: "Bucket name", 61 + validate: (v) => { 62 + if (v.trim() === "") return "Bucket name is required"; 63 + if (!BUCKET_PATTERN.test(v)) { 64 + return "Use lowercase letters, numbers, dots, and hyphens (3-63 chars)"; 65 + } 66 + return true; 67 + }, 68 + }); 69 + 70 + const pathStyle = await Confirm.prompt({ 71 + message: "Use path-style URLs? (most S3-compatible providers need this)", 72 + default: true, 73 + }); 74 + 75 + console.log("\n Credentials"); 76 + console.log(" " + "─".repeat(40) + "\n"); 77 + 78 + const accessKey = await Input.prompt({ 79 + message: "Access key", 80 + }); 81 + 82 + const secretKey = await Secret.prompt({ 83 + message: "Secret key", 84 + }); 85 + 86 + const config: AtticConfig = { 87 + endpoint, 88 + region, 89 + bucket, 90 + pathStyle, 91 + keychain: { 92 + accessKeyService: "attic-s3-access-key", 93 + secretKeyService: "attic-s3-secret-key", 94 + }, 95 + }; 96 + 97 + // Write config file 98 + console.log(`\n Writing config to ${configPath()}...`); 99 + writeConfig(config); 100 + console.log(" Done."); 101 + 102 + // Store credentials in Keychain (-U flag: update if exists, create if not) 103 + console.log(" Storing credentials in macOS Keychain..."); 104 + await storeKeychainCredential( 105 + config.keychain.accessKeyService, 106 + accessKey, 107 + ); 108 + await storeKeychainCredential( 109 + config.keychain.secretKeyService, 110 + secretKey, 111 + ); 112 + console.log(" Done."); 113 + 114 + console.log( 115 + '\n Setup complete. Run "attic scan" to see your Photos library.\n', 116 + ); 117 + }
+133
cli/src/config/config.test.ts
··· 1 + import { assertEquals, assertThrows } from "@std/assert"; 2 + import { loadConfig, validateConfig, writeConfig } from "./config.ts"; 3 + import { join } from "@std/path/join"; 4 + 5 + Deno.test("validateConfig accepts valid config with all fields", () => { 6 + const config = validateConfig({ 7 + endpoint: "https://s3.fr-par.scw.cloud", 8 + region: "fr-par", 9 + bucket: "my-photo-backup", 10 + pathStyle: false, 11 + keychain: { 12 + accessKeyService: "custom-access", 13 + secretKeyService: "custom-secret", 14 + }, 15 + }); 16 + 17 + assertEquals(config.endpoint, "https://s3.fr-par.scw.cloud"); 18 + assertEquals(config.region, "fr-par"); 19 + assertEquals(config.bucket, "my-photo-backup"); 20 + assertEquals(config.pathStyle, false); 21 + assertEquals(config.keychain.accessKeyService, "custom-access"); 22 + assertEquals(config.keychain.secretKeyService, "custom-secret"); 23 + }); 24 + 25 + Deno.test("validateConfig applies defaults for optional fields", () => { 26 + const config = validateConfig({ 27 + endpoint: "https://s3.fr-par.scw.cloud", 28 + region: "fr-par", 29 + bucket: "my-photo-backup", 30 + }); 31 + 32 + assertEquals(config.pathStyle, true); 33 + assertEquals(config.keychain.accessKeyService, "attic-s3-access-key"); 34 + assertEquals(config.keychain.secretKeyService, "attic-s3-secret-key"); 35 + }); 36 + 37 + Deno.test("validateConfig rejects missing endpoint", () => { 38 + assertThrows( 39 + () => validateConfig({ region: "fr-par", bucket: "b" }), 40 + Error, 41 + '"endpoint" is required', 42 + ); 43 + }); 44 + 45 + Deno.test("validateConfig rejects non-https endpoint", () => { 46 + assertThrows( 47 + () => 48 + validateConfig({ 49 + endpoint: "http://s3.example.com", 50 + region: "us-east-1", 51 + bucket: "bbb", 52 + }), 53 + Error, 54 + "must start with https://", 55 + ); 56 + }); 57 + 58 + Deno.test("validateConfig rejects missing region", () => { 59 + assertThrows( 60 + () => validateConfig({ endpoint: "https://s3.example.com", bucket: "bbb" }), 61 + Error, 62 + '"region" is required', 63 + ); 64 + }); 65 + 66 + Deno.test("validateConfig rejects missing bucket", () => { 67 + assertThrows( 68 + () => 69 + validateConfig({ 70 + endpoint: "https://s3.example.com", 71 + region: "us-east-1", 72 + }), 73 + Error, 74 + '"bucket" is required', 75 + ); 76 + }); 77 + 78 + Deno.test("validateConfig rejects invalid bucket name", () => { 79 + assertThrows( 80 + () => 81 + validateConfig({ 82 + endpoint: "https://s3.example.com", 83 + region: "us-east-1", 84 + bucket: "A", 85 + }), 86 + Error, 87 + "is invalid", 88 + ); 89 + }); 90 + 91 + Deno.test("validateConfig rejects non-object input", () => { 92 + assertThrows( 93 + () => validateConfig("not an object"), 94 + Error, 95 + "must be a JSON object", 96 + ); 97 + assertThrows( 98 + () => validateConfig(null), 99 + Error, 100 + "must be a JSON object", 101 + ); 102 + }); 103 + 104 + Deno.test("writeConfig and loadConfig round-trip", () => { 105 + const dir = Deno.makeTempDirSync(); 106 + const config = { 107 + endpoint: "https://s3.fr-par.scw.cloud", 108 + region: "fr-par", 109 + bucket: "test-bucket", 110 + pathStyle: true, 111 + keychain: { 112 + accessKeyService: "attic-s3-access-key", 113 + secretKeyService: "attic-s3-secret-key", 114 + }, 115 + }; 116 + 117 + writeConfig(config, dir); 118 + 119 + // Verify file exists 120 + const text = Deno.readTextFileSync(join(dir, "config.json")); 121 + const parsed = JSON.parse(text); 122 + assertEquals(parsed.endpoint, "https://s3.fr-par.scw.cloud"); 123 + 124 + // Round-trip through loadConfig 125 + const loaded = loadConfig(dir); 126 + assertEquals(loaded, config); 127 + }); 128 + 129 + Deno.test("loadConfig returns null when file does not exist", () => { 130 + const dir = Deno.makeTempDirSync(); 131 + const result = loadConfig(dir); 132 + assertEquals(result, null); 133 + });
+129
cli/src/config/config.ts
··· 1 + import { join } from "@std/path/join"; 2 + 3 + export interface AtticConfig { 4 + endpoint: string; 5 + region: string; 6 + bucket: string; 7 + pathStyle: boolean; 8 + keychain: { 9 + accessKeyService: string; 10 + secretKeyService: string; 11 + }; 12 + } 13 + 14 + const CONFIG_DIR = join( 15 + Deno.env.get("HOME") ?? "~", 16 + ".attic", 17 + ); 18 + 19 + const CONFIG_PATH = join(CONFIG_DIR, "config.json"); 20 + 21 + const BUCKET_PATTERN = /^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$/; 22 + 23 + /** Load config from ~/.attic/config.json. Returns null if file doesn't exist. */ 24 + export function loadConfig(dir: string = CONFIG_DIR): AtticConfig | null { 25 + const path = join(dir, "config.json"); 26 + let text: string; 27 + try { 28 + text = Deno.readTextFileSync(path); 29 + } catch (error: unknown) { 30 + if (error instanceof Deno.errors.NotFound) { 31 + return null; 32 + } 33 + throw error; 34 + } 35 + const raw: unknown = JSON.parse(text); 36 + return validateConfig(raw); 37 + } 38 + 39 + /** Validate a raw parsed config object. Throws with specific messages on invalid fields. */ 40 + export function validateConfig(raw: unknown): AtticConfig { 41 + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { 42 + throw new Error("Config must be a JSON object"); 43 + } 44 + 45 + const obj = raw as Record<string, unknown>; 46 + 47 + if (typeof obj.endpoint !== "string" || obj.endpoint === "") { 48 + throw new Error( 49 + 'Config: "endpoint" is required (e.g. "https://s3.fr-par.scw.cloud")', 50 + ); 51 + } 52 + if (!obj.endpoint.startsWith("https://")) { 53 + throw new Error('Config: "endpoint" must start with https://'); 54 + } 55 + 56 + if (typeof obj.region !== "string" || obj.region === "") { 57 + throw new Error('Config: "region" is required (e.g. "fr-par")'); 58 + } 59 + 60 + if (typeof obj.bucket !== "string" || obj.bucket === "") { 61 + throw new Error('Config: "bucket" is required'); 62 + } 63 + if (!BUCKET_PATTERN.test(obj.bucket)) { 64 + throw new Error( 65 + `Config: "bucket" name "${obj.bucket}" is invalid. ` + 66 + "Use lowercase letters, numbers, dots, and hyphens (3-63 chars).", 67 + ); 68 + } 69 + 70 + const pathStyle = obj.pathStyle !== undefined ? Boolean(obj.pathStyle) : true; 71 + 72 + const keychain = typeof obj.keychain === "object" && obj.keychain !== null && 73 + !Array.isArray(obj.keychain) 74 + ? obj.keychain as Record<string, unknown> 75 + : {}; 76 + 77 + const accessKeyService = typeof keychain.accessKeyService === "string" && 78 + keychain.accessKeyService !== "" 79 + ? keychain.accessKeyService 80 + : "attic-s3-access-key"; 81 + 82 + const secretKeyService = typeof keychain.secretKeyService === "string" && 83 + keychain.secretKeyService !== "" 84 + ? keychain.secretKeyService 85 + : "attic-s3-secret-key"; 86 + 87 + return { 88 + endpoint: obj.endpoint, 89 + region: obj.region, 90 + bucket: obj.bucket, 91 + pathStyle, 92 + keychain: { accessKeyService, secretKeyService }, 93 + }; 94 + } 95 + 96 + /** Write config to disk, creating ~/.attic/ if needed. */ 97 + export function writeConfig( 98 + config: AtticConfig, 99 + dir: string = CONFIG_DIR, 100 + ): void { 101 + Deno.mkdirSync(dir, { recursive: true, mode: 0o700 }); 102 + const path = join(dir, "config.json"); 103 + Deno.writeTextFileSync( 104 + path, 105 + JSON.stringify(config, null, 2) + "\n", 106 + { mode: 0o600 }, 107 + ); 108 + } 109 + 110 + /** Resolve the default config file path. */ 111 + export function configPath(): string { 112 + return CONFIG_PATH; 113 + } 114 + 115 + /** 116 + * Load and validate config, throwing a user-friendly error if missing. 117 + * Use this for commands that require S3 (backup, verify). 118 + */ 119 + export function requireConfig(dir?: string): AtticConfig { 120 + const config = loadConfig(dir); 121 + if (config === null) { 122 + const path = dir ? join(dir, "config.json") : configPath(); 123 + throw new Error( 124 + `No config file found at ${path}\n` + 125 + 'Run "attic init" to set up your S3 connection, or create the file manually.', 126 + ); 127 + } 128 + return config; 129 + }
+65
cli/src/keychain/keychain.ts
··· 1 + const ACCOUNT = "attic"; 2 + 3 + export interface KeychainCredentials { 4 + accessKeyId: string; 5 + secretAccessKey: string; 6 + } 7 + 8 + /** Read S3 credentials from macOS Keychain. */ 9 + export async function loadKeychainCredentials( 10 + accessKeyService = "attic-s3-access-key", 11 + secretKeyService = "attic-s3-secret-key", 12 + ): Promise<KeychainCredentials> { 13 + const accessKeyId = await keychainGet(accessKeyService); 14 + const secretAccessKey = await keychainGet(secretKeyService); 15 + return { accessKeyId, secretAccessKey }; 16 + } 17 + 18 + /** Store a credential in macOS Keychain. Uses -U to update if it already exists. */ 19 + export async function storeKeychainCredential( 20 + service: string, 21 + value: string, 22 + ): Promise<void> { 23 + const cmd = new Deno.Command("security", { 24 + args: [ 25 + "add-generic-password", 26 + "-U", 27 + "-s", 28 + service, 29 + "-a", 30 + ACCOUNT, 31 + "-w", 32 + value, 33 + ], 34 + stderr: "piped", 35 + }); 36 + const { code, stderr } = await cmd.output(); 37 + if (code !== 0) { 38 + const err = new TextDecoder().decode(stderr); 39 + throw new Error( 40 + `Failed to store credential in Keychain for service "${service}": ${err.trim()}`, 41 + ); 42 + } 43 + } 44 + 45 + async function keychainGet(service: string): Promise<string> { 46 + const cmd = new Deno.Command("security", { 47 + args: [ 48 + "find-generic-password", 49 + "-s", 50 + service, 51 + "-w", 52 + ], 53 + stdout: "piped", 54 + stderr: "piped", 55 + }); 56 + const { code, stdout, stderr } = await cmd.output(); 57 + if (code !== 0) { 58 + const err = new TextDecoder().decode(stderr); 59 + throw new Error( 60 + `Failed to read keychain item "${service}": ${err.trim()}. ` + 61 + `Store it with: security add-generic-password -s ${service} -a ${ACCOUNT} -w "<value>"`, 62 + ); 63 + } 64 + return new TextDecoder().decode(stdout).trim(); 65 + }
+8 -2
cli/src/photos-db/reader.test.ts
··· 305 305 INSERT INTO ZASSET VALUES (1, 'uuid-adj-only', 'IMG.HEIC', '/dir', ${coreDataTs}, 306 306 ${AssetKind.PHOTO}, 'public.heic', 4032, 3024, NULL, NULL, 0, ${CloudLocalState.LOCAL}, 0); 307 307 INSERT INTO ZADDITIONALASSETATTRIBUTES VALUES (1, 1, 1000, 'IMG.HEIC', 'hash', NULL, 1); 308 - INSERT INTO ZUNMANAGEDADJUSTMENT VALUES (1, ${coreDataTs + 100}, 'com.apple.photo'); 308 + INSERT INTO ZUNMANAGEDADJUSTMENT VALUES (1, ${ 309 + coreDataTs + 100 310 + }, 'com.apple.photo'); 309 311 `); 310 312 // No ZINTERNALRESOURCE row — adjustment exists but no rendered file 311 313 db.close(); ··· 316 318 reader.close(); 317 319 318 320 assertEquals(assets.length, 1); 319 - assertEquals(assets[0].hasEdit, false, "no rendered resource = hasEdit false"); 321 + assertEquals( 322 + assets[0].hasEdit, 323 + false, 324 + "no rendered resource = hasEdit false", 325 + ); 320 326 assertEquals(assets[0].editedAt, null); 321 327 assertEquals(assets[0].editor, null); 322 328 } finally {
+10 -43
cli/src/storage/s3-client.ts
··· 29 29 listObjects(prefix: string): AsyncIterable<S3Object>; 30 30 } 31 31 32 - export interface ScalewayCredentials { 33 - accessKeyId: string; 34 - secretAccessKey: string; 35 - } 36 - 37 - /** Read Scaleway S3 credentials from macOS Keychain. */ 38 - export async function loadKeychainCredentials(): Promise<ScalewayCredentials> { 39 - const accessKeyId = await keychainGet("attic-s3-access-key"); 40 - const secretAccessKey = await keychainGet("attic-s3-secret-key"); 41 - return { accessKeyId, secretAccessKey }; 32 + export interface S3ConnectionConfig { 33 + endpoint: string; 34 + region: string; 35 + pathStyle: boolean; 42 36 } 43 37 44 - async function keychainGet(service: string): Promise<string> { 45 - const cmd = new Deno.Command("security", { 46 - args: [ 47 - "find-generic-password", 48 - "-s", 49 - service, 50 - "-w", 51 - ], 52 - stdout: "piped", 53 - stderr: "piped", 54 - }); 55 - const { code, stdout, stderr } = await cmd.output(); 56 - if (code !== 0) { 57 - const err = new TextDecoder().decode(stderr); 58 - throw new Error( 59 - `Failed to read keychain item "${service}": ${err.trim()}. ` + 60 - `Store it with: security add-generic-password -s ${service} -a attic -w "<value>"`, 61 - ); 62 - } 63 - return new TextDecoder().decode(stdout).trim(); 64 - } 65 - 66 - const SCALEWAY_ENDPOINT = "https://s3.fr-par.scw.cloud"; 67 - const SCALEWAY_REGION = "fr-par"; 68 - 69 38 export function createS3Provider( 70 - credentials: ScalewayCredentials, 39 + credentials: { accessKeyId: string; secretAccessKey: string }, 71 40 bucket: string, 41 + connection: S3ConnectionConfig, 72 42 ): S3Provider { 73 43 const client = new S3Client({ 74 - endpoint: SCALEWAY_ENDPOINT, 75 - region: SCALEWAY_REGION, 44 + endpoint: connection.endpoint, 45 + region: connection.region, 76 46 credentials: { 77 47 accessKeyId: credentials.accessKeyId, 78 48 secretAccessKey: credentials.secretAccessKey, 79 49 }, 80 - forcePathStyle: true, 50 + forcePathStyle: connection.pathStyle, 81 51 }); 82 52 83 53 return { ··· 116 86 etag: result.ETag ?? null, 117 87 }; 118 88 } catch (error: unknown) { 119 - if ( 120 - error instanceof Error && "name" in error && 121 - error.name === "NotFound" 122 - ) { 89 + if (error instanceof Error && error.name === "NotFound") { 123 90 return null; 124 91 } 125 92 throw error;
+10
config.example.json
··· 1 + { 2 + "endpoint": "https://s3.fr-par.scw.cloud", 3 + "region": "fr-par", 4 + "bucket": "my-photo-backup", 5 + "pathStyle": true, 6 + "keychain": { 7 + "accessKeyService": "attic-s3-access-key", 8 + "secretKeyService": "attic-s3-secret-key" 9 + } 10 + }
+3 -2
deno.json
··· 6 6 "lint": "deno lint", 7 7 "fmt": "deno fmt", 8 8 "fmt:check": "deno fmt --check", 9 + "init": "deno run --allow-read --allow-write --allow-env --allow-run=security cli/mod.ts init", 9 10 "scan": "deno run --allow-read --allow-env --allow-ffi cli/mod.ts scan", 10 11 "status": "deno run --allow-read --allow-write --allow-env --allow-ffi cli/mod.ts status", 11 - "backup": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-ffi --allow-net=s3.fr-par.scw.cloud --allow-run cli/mod.ts backup", 12 - "verify": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-net=s3.fr-par.scw.cloud --allow-run=security cli/mod.ts verify" 12 + "backup": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-ffi --allow-net --allow-run cli/mod.ts backup", 13 + "verify": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-net --allow-run=security cli/mod.ts verify" 13 14 } 14 15 }
+84 -3
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "jsr:@cliffy/ansi@1.0.0": "1.0.0", 5 + "jsr:@cliffy/command@1": "1.0.0", 6 + "jsr:@cliffy/flags@1.0.0": "1.0.0", 7 + "jsr:@cliffy/internal@1.0.0": "1.0.0", 8 + "jsr:@cliffy/keycode@1.0.0": "1.0.0", 9 + "jsr:@cliffy/prompt@1": "1.0.0", 10 + "jsr:@cliffy/table@1.0.0": "1.0.0", 4 11 "jsr:@db/sqlite@0.12": "0.12.0", 5 12 "jsr:@denosaurs/plug@1": "1.1.0", 6 13 "jsr:@std/assert@0.217": "0.217.0", 7 14 "jsr:@std/assert@1": "1.0.18", 15 + "jsr:@std/assert@^1.0.18": "1.0.18", 8 16 "jsr:@std/encoding@1": "1.0.10", 17 + "jsr:@std/encoding@^1.0.10": "1.0.10", 9 18 "jsr:@std/fmt@1": "1.0.9", 19 + "jsr:@std/fmt@^1.0.9": "1.0.9", 10 20 "jsr:@std/fs@1": "1.0.22", 11 21 "jsr:@std/internal@^1.0.12": "1.0.12", 22 + "jsr:@std/io@~0.225.3": "0.225.3", 12 23 "jsr:@std/path@0.217": "0.217.0", 13 24 "jsr:@std/path@1": "1.1.4", 14 25 "jsr:@std/path@^1.1.4": "1.1.4", 15 - "npm:@aws-sdk/client-s3@3": "3.1008.0" 26 + "jsr:@std/text@^1.0.17": "1.0.17", 27 + "npm:@aws-sdk/client-s3@3": "3.1008.0", 28 + "npm:@types/node@*": "24.2.0" 16 29 }, 17 30 "jsr": { 31 + "@cliffy/ansi@1.0.0": { 32 + "integrity": "987008f74e50aa72cc1517ffccc769711734a14927bc4599e052efe1b9a840e2", 33 + "dependencies": [ 34 + "jsr:@cliffy/internal", 35 + "jsr:@std/encoding@^1.0.10", 36 + "jsr:@std/io" 37 + ] 38 + }, 39 + "@cliffy/command@1.0.0": { 40 + "integrity": "c52a241ea68857fcdaff4f3173eb404f8017d7bc35553b6f533c592b89dde7d2", 41 + "dependencies": [ 42 + "jsr:@cliffy/flags", 43 + "jsr:@cliffy/internal", 44 + "jsr:@cliffy/table", 45 + "jsr:@std/fmt@^1.0.9", 46 + "jsr:@std/text" 47 + ] 48 + }, 49 + "@cliffy/flags@1.0.0": { 50 + "integrity": "8b57698adc644da8f90422d58976362d41a4ebca39c312ca1c101585d0148feb", 51 + "dependencies": [ 52 + "jsr:@cliffy/internal", 53 + "jsr:@std/text" 54 + ] 55 + }, 56 + "@cliffy/internal@1.0.0": { 57 + "integrity": "1e17ccbcd5420093c0a93e5b3827bbdc9abac5195bacf187edc44665e54bdde6" 58 + }, 59 + "@cliffy/keycode@1.0.0": { 60 + "integrity": "755dbf007be110dcb5625f87eb61b362b6a0ca6835453af03ebd3b34d399cf14" 61 + }, 62 + "@cliffy/prompt@1.0.0": { 63 + "integrity": "48b4cd35199fda7832f35e1fe0a3e8bc2b1ea49ba57b4ec0e29e22db44e8ca9f", 64 + "dependencies": [ 65 + "jsr:@cliffy/ansi", 66 + "jsr:@cliffy/internal", 67 + "jsr:@cliffy/keycode", 68 + "jsr:@std/assert@^1.0.18", 69 + "jsr:@std/fmt@^1.0.9", 70 + "jsr:@std/io", 71 + "jsr:@std/path@^1.1.4", 72 + "jsr:@std/text" 73 + ] 74 + }, 75 + "@cliffy/table@1.0.0": { 76 + "integrity": "3fdaa9e1ef1ea62022108adabd826932bdea8dd05497079896febcd41322907f", 77 + "dependencies": [ 78 + "jsr:@std/fmt@^1.0.9" 79 + ] 80 + }, 18 81 "@db/sqlite@0.12.0": { 19 82 "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", 20 83 "dependencies": [ ··· 25 88 "@denosaurs/plug@1.1.0": { 26 89 "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", 27 90 "dependencies": [ 28 - "jsr:@std/encoding", 29 - "jsr:@std/fmt", 91 + "jsr:@std/encoding@1", 92 + "jsr:@std/fmt@1", 30 93 "jsr:@std/fs", 31 94 "jsr:@std/path@1" 32 95 ] ··· 56 119 "@std/internal@1.0.12": { 57 120 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 58 121 }, 122 + "@std/io@0.225.3": { 123 + "integrity": "27b07b591384d12d7b568f39e61dff966b8230559122df1e9fd11cc068f7ddd1" 124 + }, 59 125 "@std/path@0.217.0": { 60 126 "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", 61 127 "dependencies": [ ··· 67 133 "dependencies": [ 68 134 "jsr:@std/internal" 69 135 ] 136 + }, 137 + "@std/text@1.0.17": { 138 + "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95" 70 139 } 71 140 }, 72 141 "npm": { ··· 1024 1093 "tslib" 1025 1094 ] 1026 1095 }, 1096 + "@types/node@24.2.0": { 1097 + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", 1098 + "dependencies": [ 1099 + "undici-types" 1100 + ] 1101 + }, 1027 1102 "bowser@2.14.1": { 1028 1103 "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" 1029 1104 }, ··· 1049 1124 }, 1050 1125 "tslib@2.8.1": { 1051 1126 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" 1127 + }, 1128 + "undici-types@7.10.0": { 1129 + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" 1052 1130 } 1053 1131 }, 1054 1132 "workspace": { 1055 1133 "members": { 1056 1134 "cli": { 1057 1135 "dependencies": [ 1136 + "jsr:@cliffy/ansi@1", 1137 + "jsr:@cliffy/command@1", 1138 + "jsr:@cliffy/prompt@1", 1058 1139 "jsr:@db/sqlite@0.12", 1059 1140 "jsr:@std/assert@1", 1060 1141 "jsr:@std/crypto@1",
+88 -37
docs/architecture.md
··· 1 1 # Architecture 2 2 3 - Attic reads the macOS Photos library, exports original files via a companion Swift tool, and uploads them to S3 with rich metadata. A local manifest tracks progress so runs are incremental. 3 + Attic reads the macOS Photos library, exports original files via a companion 4 + Swift tool, and uploads them to S3 with rich metadata. A local manifest tracks 5 + progress so runs are incremental. 4 6 5 7 ## System overview 6 8 ··· 21 23 22 24 `reader.ts` queries Photos.sqlite in two stages: 23 25 24 - **Main query** — a single SELECT joining `ZASSET` and `ZADDITIONALASSETATTRIBUTES` returns core fields: UUID, filename, date, dimensions, GPS, file size, UTI, favorite status, cloud state. Trashed assets are excluded. 26 + **Main query** — a single SELECT joining `ZASSET` and 27 + `ZADDITIONALASSETATTRIBUTES` returns core fields: UUID, filename, date, 28 + dimensions, GPS, file size, UTI, favorite status, cloud state. Trashed assets 29 + are excluded. 25 30 26 - **Enrichment queries** — seven independent queries each build a `Map` keyed by asset primary key (Z_PK). During row mapping, each asset is enriched from these maps with a default of `null` or `[]` if no match exists. 31 + **Enrichment queries** — seven independent queries each build a `Map` keyed by 32 + asset primary key (Z_PK). During row mapping, each asset is enriched from these 33 + maps with a default of `null` or `[]` if no match exists. 27 34 28 - | Query | Source tables | Returns | 29 - |-------|--------------|---------| 30 - | Descriptions | `ZASSETDESCRIPTION` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, string>` | 31 - | Albums | `ZGENERICALBUM` → `Z_33ASSETS` | `Map<number, AlbumRef[]>` | 32 - | Keywords | `ZKEYWORD` → `Z_1KEYWORDS` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, string[]>` | 33 - | People | `ZPERSON` → `ZDETECTEDFACE` | `Map<number, PersonRef[]>` | 34 - | Edits | `ZUNMANAGEDADJUSTMENT` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, EditInfo>` | 35 - | Rendered resources | `ZINTERNALRESOURCE` (resource type 1) | `Set<number>` | 35 + | Query | Source tables | Returns | 36 + | ------------------ | --------------------------------------------------------- | -------------------------- | 37 + | Descriptions | `ZASSETDESCRIPTION` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, string>` | 38 + | Albums | `ZGENERICALBUM` → `Z_33ASSETS` | `Map<number, AlbumRef[]>` | 39 + | Keywords | `ZKEYWORD` → `Z_1KEYWORDS` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, string[]>` | 40 + | People | `ZPERSON` → `ZDETECTEDFACE` | `Map<number, PersonRef[]>` | 41 + | Edits | `ZUNMANAGEDADJUSTMENT` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, EditInfo>` | 42 + | Rendered resources | `ZINTERNALRESOURCE` (resource type 1) | `Set<number>` | 36 43 37 - All enrichment queries go through `safeQuery()`, which catches "no such table" errors silently and logs other failures. This makes the reader resilient across macOS versions where table schemas may differ. 44 + All enrichment queries go through `safeQuery()`, which catches "no such table" 45 + errors silently and logs other failures. This makes the reader resilient across 46 + macOS versions where table schemas may differ. 38 47 39 - People are deduplicated per asset — a person appears at most once even if detected in multiple face regions. 48 + People are deduplicated per asset — a person appears at most once even if 49 + detected in multiple face regions. 40 50 41 51 ### Edit detection 42 52 43 - An asset is considered edited (`hasEdit: true`) only when two conditions are met: 53 + An asset is considered edited (`hasEdit: true`) only when two conditions are 54 + met: 44 55 45 56 1. An entry exists in `ZUNMANAGEDADJUSTMENT` (an edit was performed) 46 - 2. An entry exists in `ZINTERNALRESOURCE` with resource type 1 (a rendered file was produced) 57 + 2. An entry exists in `ZINTERNALRESOURCE` with resource type 1 (a rendered file 58 + was produced) 47 59 48 - This distinguishes visual edits from metadata-only adjustments that don't produce a visible render. When `hasEdit` is false, `editedAt` and `editor` are both null. 60 + This distinguishes visual edits from metadata-only adjustments that don't 61 + produce a visible render. When `hasEdit` is false, `editedAt` and `editor` are 62 + both null. 49 63 50 64 ## The backup pipeline 51 65 52 - `backup.ts` orchestrates the full flow: filter → batch → export → upload → manifest. 66 + `backup.ts` orchestrates the full flow: filter → batch → export → upload → 67 + manifest. 53 68 54 69 ### 1. Filter 55 70 56 - Assets are filtered against the manifest to find pending work. Optional filters narrow by type (`--type photo|video`) or count (`--limit N`). Dry run mode stops here. 71 + Assets are filtered against the manifest to find pending work. Optional filters 72 + narrow by type (`--type photo|video`) or count (`--limit N`). Dry run mode stops 73 + here. 57 74 58 75 ### 2. Batch and export 59 76 60 - Pending assets are processed in batches (default 50). Each batch is sent to **ladder**, a companion Swift binary that uses PhotoKit to export original files. Communication is via JSON over stdin/stdout: 77 + Pending assets are processed in batches (default 50). Each batch is sent to 78 + **ladder**, a companion Swift binary that uses PhotoKit to export original 79 + files. Communication is via JSON over stdin/stdout: 61 80 62 81 ``` 63 82 attic → stdin: { "uuids": ["UUID/L0/001", ...], "stagingDir": "/path" } 64 83 ladder → stdout: { "results": [...], "errors": [...] } 65 84 ``` 66 85 67 - Each result includes the file path, size, and SHA-256 hash. PhotoKit identifiers use the `UUID/L0/001` format; attic strips the suffix before further processing. Ladder output is validated at the trust boundary with `assertExportBatchResult()`. 86 + Each result includes the file path, size, and SHA-256 hash. PhotoKit identifiers 87 + use the `UUID/L0/001` format; attic strips the suffix before further processing. 88 + Ladder output is validated at the trust boundary with 89 + `assertExportBatchResult()`. 68 90 69 91 ### 3. Upload 70 92 ··· 72 94 73 95 1. Reads the staged file from disk 74 96 2. Uploads the original to `originals/{year}/{month}/{uuid}.{ext}` 75 - 3. Builds and uploads a metadata JSON to `metadata/assets/{uuid}.json` (see `docs/metadata.md`) 97 + 3. Builds and uploads a metadata JSON to `metadata/assets/{uuid}.json` (see 98 + `docs/metadata.md`) 76 99 4. Updates the in-memory manifest 77 100 5. Cleans up the staged file 78 101 79 - S3 keys are built from UUID and extension, both validated with regex (`/^[A-Za-z0-9._-]+$/` and `/^[a-z0-9]+$/`) to prevent path traversal. Extensions are resolved from the asset's UTI via a lookup table, falling back to the filename extension. 102 + S3 keys are built from UUID and extension, both validated with regex 103 + (`/^[A-Za-z0-9._-]+$/` and `/^[a-z0-9]+$/`) to prevent path traversal. 104 + Extensions are resolved from the asset's UTI via a lookup table, falling back to 105 + the filename extension. 80 106 81 107 ### 4. Manifest 82 108 83 - The manifest is a JSON file at `~/.attic/manifest.json` mapping UUID to `{ s3Key, checksum, backedUpAt }`. It's saved periodically during backup (every 50 assets by default) and always at the end. Writes are atomic: write to `.tmp`, then rename. 109 + The manifest is a JSON file at `~/.attic/manifest.json` mapping UUID to 110 + `{ s3Key, checksum, backedUpAt }`. It's saved periodically during backup (every 111 + 50 assets by default) and always at the end. Writes are atomic: write to `.tmp`, 112 + then rename. 84 113 85 - The manifest can be reconstructed from S3 via `verify --rebuild-manifest`, which reads every `metadata/assets/*.json` file and validates UUID format, S3 key pattern, and checksum format before accepting an entry. 114 + The manifest can be reconstructed from S3 via `verify --rebuild-manifest`, which 115 + reads every `metadata/assets/*.json` file and validates UUID format, S3 key 116 + pattern, and checksum format before accepting an entry. 86 117 87 118 ## Verification 88 119 89 120 `verify.ts` checks backup integrity in two modes: 90 121 91 122 - **Quick** (default) — HEAD each S3 key in the manifest, confirm it exists 92 - - **Deep** — download each object, compute SHA-256, compare to the manifest checksum 123 + - **Deep** — download each object, compute SHA-256, compare to the manifest 124 + checksum 125 + 126 + Both modes use a bounded concurrency pool (default 50 workers). Errors are 127 + capped at 1,000 to prevent unbounded memory growth. 128 + 129 + ## Configuration 93 130 94 - Both modes use a bounded concurrency pool (default 50 workers). Errors are capped at 1,000 to prevent unbounded memory growth. 131 + Attic reads its configuration from `~/.attic/config.json`. The config file 132 + specifies the S3 endpoint, region, bucket, path-style preference, and Keychain 133 + service names. It's created by `attic init` or manually. 134 + 135 + `scan` and `status` work without config (they only read Photos.sqlite). `backup` 136 + and `verify` require config and fail fast with a clear message if it's missing 137 + or invalid. 95 138 96 139 ## Credentials 97 140 98 - S3 credentials are stored in the macOS Keychain under service names `attic-s3-access-key` and `attic-s3-secret-key`. They are read at runtime via `security find-generic-password` — never stored in env vars, config files, or code. 141 + S3 credentials are stored in the macOS Keychain under configurable service names 142 + (defaults: `attic-s3-access-key` and `attic-s3-secret-key`). They are read at 143 + runtime via `security find-generic-password` — never stored in env vars, config 144 + files, or code. 99 145 100 146 ## Interfaces and testability 101 147 102 148 All external dependencies are behind interfaces: 103 149 104 - | Interface | Real implementation | Mock | 105 - |-----------|-------------------|------| 106 - | `S3Provider` | AWS SDK client for Scaleway | In-memory `Map<string, Uint8Array>` | 107 - | `Exporter` | Ladder subprocess | Returns pre-configured assets from a `Map` | 108 - | `ManifestStore` | File-based JSON with atomic writes | Same implementation, pointed at a temp dir | 109 - | `PhotosDbReader` | SQLite reader for Photos.sqlite | In-memory SQLite with test fixtures | 150 + | Interface | Real implementation | Mock | 151 + | ---------------- | --------------------------------------------- | ------------------------------------------ | 152 + | `S3Provider` | AWS SDK client for any S3-compatible endpoint | In-memory `Map<string, Uint8Array>` | 153 + | `Exporter` | Ladder subprocess | Returns pre-configured assets from a `Map` | 154 + | `ManifestStore` | File-based JSON with atomic writes | Same implementation, pointed at a temp dir | 155 + | `PhotosDbReader` | SQLite reader for Photos.sqlite | In-memory SQLite with test fixtures | 110 156 111 157 Tests never hit external services, credentials, or the real Photos library. 112 158 ··· 114 160 115 161 - **Modify Photos.sqlite** — read-only access, always 116 162 - **Download from iCloud** — relies on Photos having local copies of originals 117 - - **Delete from S3** — the backup is append-only; there is no prune or cleanup command 163 + - **Delete from S3** — the backup is append-only; there is no prune or cleanup 164 + command 118 165 - **Back up thumbnails** — only original files and metadata 119 166 - **Back up adjustment plists** — Apple's edit recipes are not portable 120 - - **Back up rendered edits** — detecting edits is implemented (Phase 1); exporting and uploading rendered versions is planned (Phase 2/3, see `docs/plans/2026-03-13-feat-backup-rendered-edits-plan.md`) 121 - - **Handle slo-mo or Live Photos specially** — these have unique resource types that need dedicated investigation 122 - - **Run on non-macOS** — depends on Photos.sqlite, Keychain, and PhotoKit via ladder 167 + - **Back up rendered edits** — detecting edits is implemented (Phase 1); 168 + exporting and uploading rendered versions is planned (Phase 2/3, see 169 + `docs/plans/2026-03-13-feat-backup-rendered-edits-plan.md`) 170 + - **Handle slo-mo or Live Photos specially** — these have unique resource types 171 + that need dedicated investigation 172 + - **Run on non-macOS** — depends on Photos.sqlite, Keychain, and PhotoKit via 173 + ladder
+113
docs/brainstorms/2026-03-13-edited-assets-backup-brainstorm.md
··· 1 + # Back Up Rendered Edits Alongside Originals 2 + 3 + **Date:** 2026-03-13 **Status:** Ready for planning 4 + 5 + ## What We're Building 6 + 7 + Extend the backup pipeline to detect edited photos/videos and upload the 8 + rendered (fullsize JPEG) version alongside the original. Also detect edits made 9 + to already-backed-up assets and upload their rendered versions retroactively. 10 + 11 + ## Why This Approach 12 + 13 + The backup should be self-contained and viewable without Apple Photos. Currently 14 + only originals are backed up. Apple Photos edits are non-destructive (the 15 + original is always preserved), but the "finished" version a user actually wants 16 + to see requires either Apple Photos or the adjustment plist to re-render. 17 + Backing up the rendered version makes the backup independently useful. 18 + 19 + ## Current State 20 + 21 + | Metric | Value | 22 + | -------------------------------------------------- | --------------- | 23 + | Total assets | 37,289 | 24 + | Edited assets | 1,312 (3.5%) | 25 + | Rendered versions (fullsize JPEG, resource type 1) | 1,672 resources | 26 + | Locally available renders | 1,480 | 27 + | Original size (edited subset) | ~6.2 GB | 28 + | Rendered size (edited subset) | ~13.7 GB | 29 + 30 + Edit sources: Apple Photos (1,181), slo-mo (47), Google Photos (42), Markup 31 + (11), Adobe Lens (9), Snapseed (3). 32 + 33 + ## Key Decisions 34 + 35 + 1. **Back up rendered versions** (not just adjustment plists). The fullsize JPEG 36 + is what users actually see. Plists are Apple-internal and not portable. 37 + 38 + 2. **Sibling key with `_edited` suffix** in S3: 39 + ``` 40 + originals/2024/01/{uuid}.heic # original 41 + originals/2024/01/{uuid}_edited.jpg # rendered edit 42 + metadata/assets/{uuid}.json # includes edit metadata 43 + ``` 44 + 45 + 3. **Same pass as originals**. When processing a batch, detect edits and upload 46 + both files together. No separate command needed. 47 + 48 + 4. **Re-scan already-backed-up assets for new edits**. Compare adjustment 49 + timestamps against the manifest's `backedUpAt` to detect photos edited after 50 + their initial backup. 51 + 52 + ## Data Sources in Photos.sqlite 53 + 54 + ### Edit detection 55 + 56 + - `ZUNMANAGEDADJUSTMENT` joined via `ZADDITIONALASSETATTRIBUTES` tells us an 57 + asset has been edited 58 + - `ZADJUSTMENTTIMESTAMP` tells us when the edit happened 59 + - `ZADJUSTMENTFORMATIDENTIFIER` tells us which editor (com.apple.photo, 60 + com.adobe.lens, etc.) 61 + 62 + ### Rendered file location 63 + 64 + - `ZINTERNALRESOURCE` with `ZRESOURCETYPE = 1` (fullsize JPEG) points to the 65 + rendered version 66 + - `ZLOCALAVAILABILITY = 1` means the file is on disk 67 + - `ZDATALENGTH` gives the file size 68 + - The actual file lives in the Photos Library package, path derivable from 69 + `ZDATASTORECLASSID` + fingerprint 70 + 71 + ### Export via ladder 72 + 73 + - The current exporter uses PhotoKit ID `{uuid}/L0/001` for originals 74 + - Rendered versions may need a different resource variant or direct file copy 75 + from the library package 76 + 77 + ## Scope 78 + 79 + ### In scope 80 + 81 + - Detect which assets have edits (via ZUNMANAGEDADJUSTMENT) 82 + - Add edit metadata to PhotoAsset and the S3 metadata JSON (hasEdit, editedAt, 83 + editor) 84 + - Export and upload rendered fullsize JPEG alongside original 85 + - Re-scan manifest for assets edited after backup 86 + - Track edit backup state in manifest (so renders are not re-uploaded) 87 + 88 + ### Out of scope 89 + 90 + - Backing up adjustment plists (edit recipes) 91 + - Handling slo-mo video rendering (complex, different pipeline) 92 + - Re-rendering from adjustment data outside Apple Photos 93 + - Backing up thumbnails or other resource types 94 + 95 + ## Resolved Questions 96 + 97 + 1. **Manifest schema**: Extend the existing manifest entry with optional 98 + `editS3Key`, `editChecksum`, `editBackedUpAt` fields. No separate entries, no 99 + schema break. 100 + 101 + 2. **Re-edit handling**: Always upload the latest render. Compare adjustment 102 + timestamp against `editBackedUpAt` to detect re-edits. The backup should 103 + reflect the current state of the edit. 104 + 105 + ## Open Questions 106 + 107 + 1. **How does ladder/PhotoKit export the rendered version?** The current 108 + `/L0/001` suffix gets the original. Need to investigate what identifier or 109 + API call retrieves the fullsize rendered JPEG. May need a ladder change. 110 + 111 + 2. **What about iCloud-only rendered versions?** 1,480 of 1,672 renders are 112 + local. The remaining ~200 may need to be downloaded first, same as 113 + iCloud-only originals. Is there an existing mechanism for this?
+116
docs/brainstorms/2026-03-13-ux-open-source-readiness-brainstorm.md
··· 1 + # UX and Open-Source Readiness 2 + 3 + **Date:** 2026-03-13 **Status:** Ready for planning 4 + 5 + ## What We're Building 6 + 7 + Make attic friendly to use for technical Mac users and ready to open source. 8 + Replace hardcoded Scaleway configuration with a generic S3-compatible config 9 + layer, add an interactive `attic init` command, adopt Cliffy for polished CLI 10 + output, and improve error messages throughout. 11 + 12 + ## Why This Approach 13 + 14 + Attic currently works well as a personal tool but has Scaleway details baked 15 + into the code (endpoint, region, keychain service names, type names). To open 16 + source it, the tool needs to work with any S3-compatible provider out of the 17 + box. The UX should feel polished — good help text, colored output, and clear 18 + error messages that tell you what to do next. 19 + 20 + ## Current State 21 + 22 + | Area | Current | Target | 23 + | ------------------ | --------------------------------------- | ---------------------------------------------- | 24 + | S3 endpoint/region | Hardcoded Scaleway constants | Config file, any S3-compatible provider | 25 + | Bucket name | Hardcoded default, `--bucket` flag | Config file, CLI override | 26 + | Credentials | Keychain with hardcoded service names | Keychain with configurable service names | 27 + | Config file | None | `~/.attic/config.json` | 28 + | First-run setup | Manual (read README, set keychain, run) | `attic init` interactive prompts | 29 + | CLI framework | Hand-rolled arg parsing | Cliffy (subcommands, typed flags, help, color) | 30 + | Error messages | Raw exceptions in some paths | Friendly messages with suggested fixes | 31 + | path style | Hardcoded `true` | Config option, default `true` | 32 + | Provider docs | Scaleway-specific | Provider-neutral with EU-focused examples | 33 + 34 + ## Key Decisions 35 + 36 + 1. **Config file at `~/.attic/config.json`** — primary configuration source. CLI 37 + flags override. No env var fallback (keep it simple, macOS-only tool). 38 + 39 + 2. **Interactive `attic init`** — asks for S3 endpoint, region, bucket, and 40 + keychain service names step by step. Writes config.json. Can offer provider 41 + suggestions (Scaleway, Hetzner, OVH as EU options). 42 + 43 + 3. **Keychain with configurable service names** — stay macOS Keychain-only 44 + (security principle from CLAUDE.md), but let config.json specify the service 45 + names instead of hardcoding `attic-s3-access-key` / `attic-s3-secret-key`. 46 + 47 + 4. **Cliffy for CLI** — replace hand-rolled arg parsing with Cliffy. Gets us 48 + subcommands, typed flags, auto-generated help, colored output, and shell 49 + completions. 50 + 51 + 5. **`forcePathStyle` as config option** — default `true` (works with most 52 + S3-compatible providers). AWS users can set to `false`. 53 + 54 + 6. **EU-focused provider examples** — highlight Scaleway, Hetzner, OVH as EU 55 + data sovereignty options in docs and init prompts. Mention AWS/Backblaze as 56 + alternatives. Position attic as a good choice for keeping your photos in the 57 + EU. 58 + 59 + 7. **Top-level error boundary** — catch unhandled errors in mod.ts, present 60 + friendly messages instead of stack traces. Pattern: detect known error types 61 + (keychain missing, network timeout, S3 access denied) and print actionable 62 + guidance. 63 + 64 + ## Config File Schema 65 + 66 + ```json 67 + { 68 + "endpoint": "https://s3.fr-par.scw.cloud", 69 + "region": "fr-par", 70 + "bucket": "my-photo-backup", 71 + "pathStyle": true, 72 + "keychain": { 73 + "accessKeyService": "attic-s3-access-key", 74 + "secretKeyService": "attic-s3-secret-key" 75 + } 76 + } 77 + ``` 78 + 79 + ## Scope 80 + 81 + ### In scope 82 + 83 + - Config file (`~/.attic/config.json`) with validation 84 + - `attic init` interactive setup command 85 + - Cliffy migration for all commands (scan, status, backup, verify) 86 + - Rename `ScalewayCredentials` to `S3Credentials`, remove `SCALEWAY_*` constants 87 + - `createS3Provider()` accepts endpoint, region, pathStyle as parameters 88 + - Top-level error boundary with friendly messages for known failure modes 89 + - Updated README, CLAUDE.md, and architecture docs 90 + - EU-focused provider examples in docs and init 91 + 92 + ### Out of scope 93 + 94 + - Env var credential fallback (keep Keychain-only) 95 + - Non-macOS support 96 + - Provider presets in init (just ask for endpoint/region directly, with 97 + examples) 98 + - Web UI or GUI 99 + - Auto-detection of Photos.sqlite path across macOS versions 100 + 101 + ## Resolved Questions 102 + 103 + 1. **Audience**: Technical Mac users comfortable with terminal and S3 setup. 104 + 2. **Config approach**: Config file at `~/.attic/config.json`, CLI flags 105 + override. 106 + 3. **Init style**: Interactive prompts, writes config at the end. 107 + 4. **Credentials**: Keychain-only with configurable service names in config. 108 + 5. **Provider presentation**: EU-focused examples (Scaleway, Hetzner, OVH), 109 + others mentioned as alternatives. 110 + 6. **Path style**: Config option `pathStyle`, default `true`. 111 + 7. **CLI framework**: Cliffy. 112 + 8. **Init stores credentials directly**: `attic init` prompts for access key and 113 + secret key and runs `security add-generic-password` automatically. 114 + 9. **Validate config when S3 is needed**: scan/status only need Photos.sqlite — 115 + they work without config. backup/verify validate config and fail fast with a 116 + clear message if missing or incomplete.
+52 -39
docs/metadata.md
··· 1 1 # Asset Metadata 2 2 3 - Each backed-up asset gets a companion JSON file uploaded to S3 at `metadata/assets/{uuid}.json`. This file makes the backup browsable and searchable without access to Apple Photos or the original Photos.sqlite database. 3 + Each backed-up asset gets a companion JSON file uploaded to S3 at 4 + `metadata/assets/{uuid}.json`. This file makes the backup browsable and 5 + searchable without access to Apple Photos or the original Photos.sqlite 6 + database. 4 7 5 8 ## Example 6 9 ··· 39 42 40 43 ### Asset identification 41 44 42 - | Field | Type | Description | 43 - |-------|------|-------------| 44 - | `uuid` | string | Photos library UUID, unique per asset | 45 + | Field | Type | Description | 46 + | ------------------ | ------ | ------------------------------------------- | 47 + | `uuid` | string | Photos library UUID, unique per asset | 45 48 | `originalFilename` | string | Filename as imported (e.g. `IMG_4231.HEIC`) | 46 49 47 50 ### Date and dimensions 48 51 49 - | Field | Type | Description | 50 - |-------|------|-------------| 52 + | Field | Type | Description | 53 + | ------------- | -------------- | ---------------------------------------------------------------- | 51 54 | `dateCreated` | string \| null | ISO 8601 timestamp from Photos.sqlite (CoreData epoch converted) | 52 - | `width` | number | Pixel width | 53 - | `height` | number | Pixel height | 55 + | `width` | number | Pixel width | 56 + | `height` | number | Pixel height | 54 57 55 58 ### Location 56 59 57 - | Field | Type | Description | 58 - |-------|------|-------------| 59 - | `latitude` | number \| null | GPS latitude, null if no location data | 60 + | Field | Type | Description | 61 + | ----------- | -------------- | --------------------------------------- | 62 + | `latitude` | number \| null | GPS latitude, null if no location data | 60 63 | `longitude` | number \| null | GPS longitude, null if no location data | 61 64 62 65 ### File info 63 66 64 - | Field | Type | Description | 65 - |-------|------|-------------| 66 - | `fileSize` | number \| null | Original file size in bytes | 67 - | `type` | string \| null | Uniform Type Identifier (e.g. `public.heic`, `com.apple.quicktime-movie`) | 68 - | `favorite` | boolean | Whether the asset is marked as a favorite in Photos | 67 + | Field | Type | Description | 68 + | ---------- | -------------- | ------------------------------------------------------------------------- | 69 + | `fileSize` | number \| null | Original file size in bytes | 70 + | `type` | string \| null | Uniform Type Identifier (e.g. `public.heic`, `com.apple.quicktime-movie`) | 71 + | `favorite` | boolean | Whether the asset is marked as a favorite in Photos | 69 72 70 73 ### Enrichment 71 74 72 - These fields come from auxiliary tables in Photos.sqlite via separate enrichment queries. All degrade gracefully — if the source table is missing (older macOS versions), the field returns its empty default. 75 + These fields come from auxiliary tables in Photos.sqlite via separate enrichment 76 + queries. All degrade gracefully — if the source table is missing (older macOS 77 + versions), the field returns its empty default. 73 78 74 - | Field | Type | Default | Source table | 75 - |-------|------|---------|--------------| 76 - | `title` | string \| null | null | `ZADDITIONALASSETATTRIBUTES.ZTITLE` | 77 - | `description` | string \| null | null | `ZASSETDESCRIPTION.ZLONGDESCRIPTION` | 78 - | `albums` | AlbumRef[] | [] | `ZGENERICALBUM` via `Z_33ASSETS` join | 79 - | `keywords` | string[] | [] | `ZKEYWORD` via `Z_1KEYWORDS` join | 80 - | `people` | PersonRef[] | [] | `ZPERSON` via `ZDETECTEDFACE` join | 79 + | Field | Type | Default | Source table | 80 + | ------------- | -------------- | ------- | ------------------------------------- | 81 + | `title` | string \| null | null | `ZADDITIONALASSETATTRIBUTES.ZTITLE` | 82 + | `description` | string \| null | null | `ZASSETDESCRIPTION.ZLONGDESCRIPTION` | 83 + | `albums` | AlbumRef[] | [] | `ZGENERICALBUM` via `Z_33ASSETS` join | 84 + | `keywords` | string[] | [] | `ZKEYWORD` via `Z_1KEYWORDS` join | 85 + | `people` | PersonRef[] | [] | `ZPERSON` via `ZDETECTEDFACE` join | 81 86 82 - An `AlbumRef` contains `uuid` and `title`. A `PersonRef` contains `uuid` and `displayName`. People are deduplicated per asset (a person appears at most once even if detected in multiple face regions). 87 + An `AlbumRef` contains `uuid` and `title`. A `PersonRef` contains `uuid` and 88 + `displayName`. People are deduplicated per asset (a person appears at most once 89 + even if detected in multiple face regions). 83 90 84 91 ### Edit detection 85 92 86 - | Field | Type | Description | 87 - |-------|------|-------------| 88 - | `hasEdit` | boolean | True only when both an adjustment record and a rendered resource exist | 89 - | `editedAt` | string \| null | ISO 8601 timestamp of the edit, null when `hasEdit` is false | 90 - | `editor` | string \| null | Bundle ID of the editing app (e.g. `com.apple.photo`, `com.pixelmator.photomator`), null when `hasEdit` is false | 93 + | Field | Type | Description | 94 + | ---------- | -------------- | ---------------------------------------------------------------------------------------------------------------- | 95 + | `hasEdit` | boolean | True only when both an adjustment record and a rendered resource exist | 96 + | `editedAt` | string \| null | ISO 8601 timestamp of the edit, null when `hasEdit` is false | 97 + | `editor` | string \| null | Bundle ID of the editing app (e.g. `com.apple.photo`, `com.pixelmator.photomator`), null when `hasEdit` is false | 91 98 92 - `hasEdit` requires two conditions: an entry in `ZUNMANAGEDADJUSTMENT` (the edit happened) AND an entry in `ZINTERNALRESOURCE` with resource type 1 (a rendered file exists). Metadata-only adjustments that don't produce a visible render are excluded. 99 + `hasEdit` requires two conditions: an entry in `ZUNMANAGEDADJUSTMENT` (the edit 100 + happened) AND an entry in `ZINTERNALRESOURCE` with resource type 1 (a rendered 101 + file exists). Metadata-only adjustments that don't produce a visible render are 102 + excluded. 93 103 94 104 ### Backup tracking 95 105 96 - | Field | Type | Description | 97 - |-------|------|-------------| 98 - | `s3Key` | string | S3 object key where the original file is stored (e.g. `originals/2024/07/{uuid}.heic`) | 99 - | `checksum` | string | SHA-256 hash of the uploaded file, prefixed with `sha256:` | 100 - | `backedUpAt` | string | ISO 8601 timestamp of when this asset was uploaded | 106 + | Field | Type | Description | 107 + | ------------ | ------ | -------------------------------------------------------------------------------------- | 108 + | `s3Key` | string | S3 object key where the original file is stored (e.g. `originals/2024/07/{uuid}.heic`) | 109 + | `checksum` | string | SHA-256 hash of the uploaded file, prefixed with `sha256:` | 110 + | `backedUpAt` | string | ISO 8601 timestamp of when this asset was uploaded | 101 111 102 112 ## What's not included 103 113 104 - - **Adjustment plists** — Apple's non-destructive edit recipes are not portable outside Photos 114 + - **Adjustment plists** — Apple's non-destructive edit recipes are not portable 115 + outside Photos 105 116 - **Thumbnail data** — Not useful for a full backup 106 - - **iCloud sync state** — Only relevant at backup time, not for the archived copy 107 - - **Face region coordinates** — Only person identity is stored, not bounding boxes 117 + - **iCloud sync state** — Only relevant at backup time, not for the archived 118 + copy 119 + - **Face region coordinates** — Only person identity is stored, not bounding 120 + boxes 108 121 - **Slo-mo / Live Photo markers** — Deferred to a future phase
+457
docs/plans/2026-03-13-feat-backup-rendered-edits-plan.md
··· 1 + --- 2 + title: "feat: Back Up Rendered Edits Alongside Originals" 3 + type: feat 4 + status: active 5 + date: 2026-03-13 6 + brainstorm: docs/brainstorms/2026-03-13-edited-assets-backup-brainstorm.md 7 + --- 8 + 9 + # Back Up Rendered Edits Alongside Originals 10 + 11 + ## Overview 12 + 13 + Extend the backup pipeline to detect edited photos/videos and upload the 14 + rendered (fullsize) version alongside the original to S3. Also detect edits on 15 + already-backed-up assets and re-edits that produce newer renders. This makes the 16 + backup self-contained and viewable without Apple Photos. 17 + 18 + **Scope:** 1,312 edited assets out of 37,289 total (~3.5%), adding ~13.7 GB of 19 + rendered files. 20 + 21 + ## Problem Statement 22 + 23 + The backup currently stores only originals. Apple Photos edits are 24 + non-destructive — the original is preserved, but the "finished" version requires 25 + Apple Photos (or the adjustment plist) to re-render. If Photos.app is lost, the 26 + user has the raw originals but not the edited versions they actually curated. 27 + 28 + ## Proposed Solution 29 + 30 + Three-phase implementation that progresses from detection (metadata-only) 31 + through ladder protocol changes to full rendered file backup. 32 + 33 + ## Design Decisions 34 + 35 + | Decision | Choice | Rationale | 36 + | --------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | 37 + | S3 key layout | `originals/{y}/{m}/{uuid}_edited.{ext}` sibling | Easy to discover, clearly paired | 38 + | Manifest schema | Extend existing entry with optional edit fields | No schema break, simple comparison | 39 + | Rendered file extension | From the rendered resource's UTI, not the original's | A RAW `.orf` edited in Photos renders as HEIC | 40 + | Re-edit handling | Overwrite same `_edited` S3 key | S3 bucket versioning provides history if needed | 41 + | Edit revert | Leave S3 file, clear manifest edit fields | Safe, cheap, no data loss | 42 + | Slo-mo / Live Photos | Defer to follow-up | 47 slo-mo + Live Photos have special resource types | 43 + | Partial failure (original OK, rendered fails) | Back up original, retry rendered next run | Progressive backup, no data loss | 44 + | Visual vs metadata-only edits | Check for rendered resource existence in ZINTERNALRESOURCE | Not all adjustments produce a visible render | 45 + 46 + ## Implementation Phases 47 + 48 + ### Phase 1: Edit Detection and Metadata (TypeScript only) 49 + 50 + Add edit awareness to the reader and metadata JSON without exporting rendered 51 + files. This is independently useful and requires no ladder changes. 52 + 53 + #### 1.1 Extend `PhotoAsset` type — `shared/types.ts` 54 + 55 + Add three fields: 56 + 57 + ```typescript 58 + hasEdit: boolean; 59 + editedAt: Date | null; 60 + editor: string | null; 61 + ``` 62 + 63 + Export nothing new from `shared/mod.ts` (these are primitive types on the 64 + existing interface). 65 + 66 + #### 1.2 Add edit enrichment query — `cli/src/photos-db/reader.ts` 67 + 68 + New `buildEditMap()` following the existing enrichment pattern: 69 + 70 + ```sql 71 + SELECT aa.ZASSET, ua.ZADJUSTMENTTIMESTAMP, ua.ZADJUSTMENTFORMATIDENTIFIER 72 + FROM ZADDITIONALASSETATTRIBUTES aa 73 + JOIN ZUNMANAGEDADJUSTMENT ua ON aa.ZUNMANAGEDADJUSTMENT = ua.Z_PK 74 + WHERE aa.ZUNMANAGEDADJUSTMENT IS NOT NULL 75 + ``` 76 + 77 + Returns `Map<number, { editedAt: Date; editor: string }>` keyed by asset Z_PK. 78 + Uses `safeQuery` for schema resilience. `ZADJUSTMENTTIMESTAMP` is CoreData 79 + format — convert with existing `coreDataTimestampToDate()`. 80 + 81 + Update `rowToAsset()` to merge edit data: 82 + 83 + - `hasEdit`: `editMap.has(pk)` 84 + - `editedAt`: from map or `null` 85 + - `editor`: from `ZADJUSTMENTFORMATIDENTIFIER` or `null` 86 + 87 + #### 1.3 Add rendered resource metadata query — `cli/src/photos-db/reader.ts` 88 + 89 + Detect whether a rendered file actually exists (distinguishes visual edits from 90 + metadata-only adjustments): 91 + 92 + ```sql 93 + SELECT ir.ZASSET, ir.ZDATALENGTH, ir.ZCOMPACTUTI, ir.ZLOCALAVAILABILITY 94 + FROM ZINTERNALRESOURCE ir 95 + WHERE ir.ZRESOURCETYPE = 1 96 + AND ir.ZTRASHEDSTATE = 0 97 + AND ir.ZVERSION != 0 98 + ``` 99 + 100 + Returns `Map<number, { renderSize: number; renderLocallyAvailable: boolean }>`. 101 + An asset `hasEdit = true` only if it appears in BOTH the adjustment map AND the 102 + rendered resource map. This prevents trying to export rendered versions that 103 + don't exist. 104 + 105 + #### 1.4 Update metadata JSON — `cli/src/commands/backup.ts` 106 + 107 + Add to `AssetMetadata`: 108 + 109 + ```typescript 110 + hasEdit: boolean; 111 + editedAt: string | null; 112 + editor: string | null; 113 + ``` 114 + 115 + Simple pass-through from `PhotoAsset` in `buildMetadataJson()`. 116 + 117 + #### 1.5 Update scan report — `cli/src/commands/scan.ts` 118 + 119 + Add a line to `printScanReport()`: 120 + 121 + ``` 122 + Edited: 1,312 (rendered: 1,180 local, 132 iCloud-only) 123 + ``` 124 + 125 + #### 1.6 Tests — `cli/src/photos-db/reader.test.ts` 126 + 127 + - Extend `createTestDb()` with `ZUNMANAGEDADJUSTMENT` and `ZINTERNALRESOURCE` 128 + tables 129 + - Assert `hasEdit`, `editedAt`, `editor` on photo with edit data 130 + - Assert `hasEdit: false` on video without edit data 131 + - Schema resilience: missing adjustment tables return `hasEdit: false` 132 + 133 + **Files modified:** 134 + 135 + | File | Change | ~Lines | 136 + | ---------------------------------- | ------------------------------------------------------------- | ------ | 137 + | `shared/types.ts` | +3 fields on PhotoAsset | +3 | 138 + | `cli/src/photos-db/reader.ts` | +buildEditMap, +buildRenderedResourceMap, merge in rowToAsset | +50 | 139 + | `cli/src/commands/backup.ts` | +3 fields in AssetMetadata + buildMetadataJson | +6 | 140 + | `cli/src/commands/scan.ts` | Edit stats in report | +10 | 141 + | `cli/src/photos-db/reader.test.ts` | Edit enrichment fixtures + assertions | +40 | 142 + 143 + **Verification:** `deno task check && deno task test && deno task scan` — scan 144 + output shows edit counts. 145 + 146 + --- 147 + 148 + ### Phase 2: Ladder Protocol Extension (Swift) 149 + 150 + Extend the ladder binary to export rendered versions via PhotoKit's 151 + `PHAssetResource` API. 152 + 153 + #### 2.1 Extend `ExportRequest` JSON schema — ladder 154 + 155 + Current input format: 156 + 157 + ```json 158 + { "uuids": ["UUID/L0/001"], "stagingDir": "/path" } 159 + ``` 160 + 161 + New format — add optional `variant` field per UUID: 162 + 163 + ```json 164 + { 165 + "requests": [ 166 + { "id": "UUID/L0/001", "variant": "original" }, 167 + { "id": "UUID/L0/001", "variant": "rendered" } 168 + ], 169 + "stagingDir": "/path" 170 + } 171 + ``` 172 + 173 + If `variant` is omitted or `"original"`, use current behavior (`.photo` / 174 + `.video` resource type). If `"rendered"`, use `.fullSizePhoto` / 175 + `.fullSizeVideo`. 176 + 177 + #### 2.2 Extend `ExportResult` JSON — ladder 178 + 179 + Add `variant` to each result: 180 + 181 + ```json 182 + { 183 + "uuid": "UUID", 184 + "variant": "rendered", 185 + "path": "/staging/UUID_rendered.heic", 186 + "size": 3158112, 187 + "sha256": "abc123" 188 + } 189 + ``` 190 + 191 + The filename in `path` should reflect the actual rendered format (HEIC, JPEG, 192 + MOV). 193 + 194 + #### 2.3 PhotoKit resource selection — ladder Swift code 195 + 196 + For rendered exports, use: 197 + 198 + ```swift 199 + let resources = PHAssetResource.assetResources(for: asset) 200 + let rendered = resources.first { $0.type == .fullSizePhoto } 201 + ?? resources.first { $0.type == .fullSizeVideo } 202 + ``` 203 + 204 + If no rendered resource exists, return an error result for that UUID+variant 205 + (not a crash). 206 + 207 + #### 2.4 Backward compatibility 208 + 209 + If ladder receives the old `uuids` format (no `requests` field), fall back to 210 + current behavior. This allows the TypeScript CLI to be deployed independently of 211 + the ladder upgrade. 212 + 213 + #### 2.5 Tests — ladder 214 + 215 + - Export an edited photo: should return both original and rendered with correct 216 + variants 217 + - Export an unedited photo with `variant: "rendered"`: should return an error 218 + (no fullsize resource) 219 + - Old-format `uuids` input: should still work 220 + 221 + **Verification:** Run ladder manually against a known edited asset, confirm both 222 + variants export correctly. 223 + 224 + --- 225 + 226 + ### Phase 3: Full Rendered Backup Pipeline (TypeScript) 227 + 228 + Wire the ladder protocol changes into the backup pipeline. 229 + 230 + #### 3.1 S3 path helper — `shared/s3-paths.ts` 231 + 232 + New exported function: 233 + 234 + ```typescript 235 + export function editedKey( 236 + uuid: string, 237 + dateCreated: Date | null, 238 + ext: string, 239 + ): string; 240 + ``` 241 + 242 + Same structure as `originalKey()` but appends `_edited` before the extension. 243 + Same UUID/extension regex validation. Tests follow existing patterns in 244 + `s3-paths.test.ts`. 245 + 246 + #### 3.2 Update Exporter interface — `cli/src/export/exporter.ts` 247 + 248 + Extend `ExportedAsset` with variant: 249 + 250 + ```typescript 251 + interface ExportedAsset { 252 + uuid: string; 253 + variant: "original" | "rendered"; 254 + path: string; 255 + size: number; 256 + sha256: string; 257 + } 258 + ``` 259 + 260 + Update `exportBatch()` signature: 261 + 262 + ```typescript 263 + exportBatch( 264 + requests: Array<{ uuid: string; variant: "original" | "rendered" }>, 265 + ): Promise<ExportBatchResult>; 266 + ``` 267 + 268 + Update `createLadderExporter()` to send the new JSON format and parse variant 269 + from results. 270 + 271 + #### 3.3 Update mock exporter — `cli/src/export/exporter.mock.ts` 272 + 273 + Mirror the interface changes. Mock data includes variant-tagged assets. 274 + 275 + #### 3.4 Extend manifest — `cli/src/manifest/manifest.ts` 276 + 277 + Add optional fields to `ManifestEntry`: 278 + 279 + ```typescript 280 + interface ManifestEntry { 281 + uuid: string; 282 + s3Key: string; 283 + checksum: string; 284 + backedUpAt: string; 285 + editS3Key?: string; 286 + editChecksum?: string; 287 + editBackedUpAt?: string; 288 + } 289 + ``` 290 + 291 + New helpers: 292 + 293 + ```typescript 294 + function needsEditBackup( 295 + manifest: Manifest, 296 + uuid: string, 297 + editedAt: Date | null, 298 + ): boolean; 299 + function markEditBackedUp( 300 + manifest: Manifest, 301 + uuid: string, 302 + checksum: string, 303 + s3Key: string, 304 + ): void; 305 + ``` 306 + 307 + `needsEditBackup()` returns `true` if: 308 + 309 + - Asset has `hasEdit` and a rendered resource, AND 310 + - No `editBackedUpAt` in manifest, OR `editedAt > editBackedUpAt` 311 + 312 + #### 3.5 Update backup pipeline — `cli/src/commands/backup.ts` 313 + 314 + The backup loop changes to handle three categories per batch: 315 + 316 + 1. **New assets** (not in manifest): export original + rendered (if edited), 317 + upload both 318 + 2. **Edit-pending assets** (in manifest, but `needsEditBackup()` is true): 319 + export rendered only, upload, update manifest 320 + 3. **Fully backed up** (in manifest, no pending edit): skip 321 + 322 + The filtering step becomes: 323 + 324 + ```typescript 325 + const newAssets = assets.filter((a) => !isBackedUp(manifest, a.uuid)); 326 + const editPending = assets.filter((a) => 327 + isBackedUp(manifest, a.uuid) && needsEditBackup(manifest, a.uuid, a.editedAt) 328 + ); 329 + ``` 330 + 331 + For each new asset with `hasEdit`: 332 + 333 + - Build export requests: 334 + `[{uuid, variant: "original"}, {uuid, variant: "rendered"}]` 335 + - Upload original to `originalKey()`, rendered to `editedKey()` 336 + - `markBackedUp()` + `markEditBackedUp()` 337 + 338 + For each edit-pending asset: 339 + 340 + - Build export request: `[{uuid, variant: "rendered"}]` 341 + - Upload to `editedKey()`, update metadata JSON 342 + - `markEditBackedUp()` 343 + 344 + **Partial failure handling:** If original exports OK but rendered fails, upload 345 + original and mark it in manifest. The edit will be retried on the next run 346 + (detected by `needsEditBackup()`). 347 + 348 + #### 3.6 Update verify command — `cli/src/commands/verify.ts` 349 + 350 + When `entry.editS3Key` is present, also verify it with HEAD (quick mode) or 351 + checksum (deep mode). Report edit verification separately: 352 + 353 + ``` 354 + Checked 100/100 OK: 98 Missing: 1 Corrupted: 0 Edits OK: 45 Edits Missing: 1 355 + ``` 356 + 357 + #### 3.7 Update status command — `cli/src/commands/status.ts` 358 + 359 + Add edit backup progress: 360 + 361 + ``` 362 + Backed up: 35,000 (originals) 363 + Edits backed up: 1,100 / 1,312 364 + ``` 365 + 366 + #### 3.8 Handle edit reverts 367 + 368 + When `hasEdit` is `false` but manifest has `editS3Key`: 369 + 370 + - Clear `editS3Key`, `editChecksum`, `editBackedUpAt` from manifest 371 + - Re-upload metadata JSON (reflecting `hasEdit: false`) 372 + - Leave the S3 file in place (orphaned, but cheap and safe) 373 + 374 + #### 3.9 Tests 375 + 376 + **backup.test.ts:** 377 + 378 + - New asset with edit: both original + rendered uploaded, manifest has both keys 379 + - Already-backed-up asset gains edit: only rendered uploaded, manifest updated 380 + - Re-edited asset: rendered re-uploaded, manifest timestamp updated 381 + - Edit reverted: manifest edit fields cleared, metadata re-uploaded 382 + - Partial failure: original succeeds, rendered fails — original in manifest, 383 + edit retried 384 + 385 + **manifest.test.ts:** 386 + 387 + - `needsEditBackup()` returns true when no edit backed up 388 + - `needsEditBackup()` returns true when editedAt > editBackedUpAt 389 + - `needsEditBackup()` returns false when edit already current 390 + - `markEditBackedUp()` sets edit fields 391 + 392 + **s3-paths.test.ts:** 393 + 394 + - `editedKey()` generates correct path 395 + - `editedKey()` rejects unsafe UUID/extension 396 + 397 + **Files modified:** 398 + 399 + | File | Change | ~Lines | 400 + | ----------------------------------- | --------------------------------------------------------------- | ------ | 401 + | `shared/s3-paths.ts` | +editedKey() | +15 | 402 + | `shared/s3-paths.test.ts` | editedKey tests | +20 | 403 + | `cli/src/export/exporter.ts` | Variant support in interface + ladder exporter | +30 | 404 + | `cli/src/export/exporter.mock.ts` | Mirror variant changes | +15 | 405 + | `cli/src/manifest/manifest.ts` | +ManifestEntry edit fields, +needsEditBackup, +markEditBackedUp | +30 | 406 + | `cli/src/manifest/manifest.test.ts` | Edit-aware manifest tests | +30 | 407 + | `cli/src/commands/backup.ts` | Edit-aware pipeline + revert handling | +60 | 408 + | `cli/src/commands/backup.test.ts` | Edit backup scenarios | +80 | 409 + | `cli/src/commands/verify.ts` | Verify editS3Key | +15 | 410 + | `cli/src/commands/status.ts` | Edit backup stats | +10 | 411 + 412 + ## Out of Scope (Future Work) 413 + 414 + - **Slo-mo videos**: 47 assets with special resource types — needs dedicated 415 + investigation 416 + - **Live Photos**: Paired still + video resources; edit may affect one or both 417 + - **Adjustment plist backup**: The non-destructive recipe; low portability 418 + outside Apple Photos 419 + - **Thumbnail/preview backup**: Other resource types (3, 14) not needed for a 420 + "viewable backup" 421 + - **S3 cleanup of orphaned edit files**: Could add a `prune` command later 422 + 423 + ## Acceptance Criteria 424 + 425 + ### Phase 1 426 + 427 + - [ ] `deno task scan` shows edit count and rendered resource availability 428 + - [ ] Metadata JSON for edited assets includes `hasEdit`, `editedAt`, `editor` 429 + - [ ] All existing tests pass; new tests cover edit enrichment + schema 430 + resilience 431 + 432 + ### Phase 2 433 + 434 + - [ ] Ladder exports both original and rendered for an edited asset 435 + - [ ] Ladder handles missing rendered resource gracefully (error, not crash) 436 + - [ ] Old-format input still works (backward compat) 437 + 438 + ### Phase 3 439 + 440 + - [ ] Edited assets get `_edited.{ext}` sibling uploaded to S3 441 + - [ ] Already-backed-up assets with new edits get rendered version uploaded 442 + - [ ] Re-edits detected and re-uploaded 443 + - [ ] Reverted edits clear manifest edit fields 444 + - [ ] Verify checks both original and edit S3 keys 445 + - [ ] Status shows edit backup progress 446 + - [ ] Partial failures (original OK, rendered fails) handled gracefully 447 + 448 + ## References 449 + 450 + - Brainstorm: `docs/brainstorms/2026-03-13-edited-assets-backup-brainstorm.md` 451 + - Current reader enrichment pattern: `cli/src/photos-db/reader.ts:130-201` 452 + - Current S3 path helpers: `shared/s3-paths.ts` 453 + - Current manifest schema: `cli/src/manifest/manifest.ts:5-15` 454 + - Current backup pipeline: `cli/src/commands/backup.ts:61-243` 455 + - Current exporter: `cli/src/export/exporter.ts` 456 + - PhotoKit resource types: `PHAssetResourceType.fullSizePhoto` (type 3), 457 + `.fullSizeVideo` (type 5)
+491
docs/plans/2026-03-13-feat-ux-open-source-readiness-plan.md
··· 1 + --- 2 + title: "feat: UX and Open-Source Readiness" 3 + type: feat 4 + status: completed 5 + date: 2026-03-13 6 + --- 7 + 8 + # UX and Open-Source Readiness 9 + 10 + ## Overview 11 + 12 + Replace hardcoded Scaleway configuration with a generic S3-compatible config 13 + layer, add an interactive `attic init` command, migrate CLI to Cliffy, and 14 + improve error messages. Makes attic usable with any S3-compatible provider and 15 + ready to open source. 16 + 17 + ## Problem Statement 18 + 19 + Attic has Scaleway details baked into the code — endpoint, region, keychain 20 + service names, type names. A user who wants to use Hetzner, OVH, or AWS must 21 + fork and edit constants. The CLI uses hand-rolled arg parsing (130+ lines in 22 + `cli/mod.ts`) with no help generation, no colored output, and no shell 23 + completions. Error messages are raw exceptions in some paths. 24 + 25 + ## Proposed Solution 26 + 27 + Five phases, each independently shippable: 28 + 29 + 1. **Config layer** — `~/.attic/config.json` with validation 30 + 2. **Generic S3** — rename types, parameterize `createS3Provider()` 31 + 3. **Cliffy CLI** — replace hand-rolled parsing with Cliffy subcommands 32 + 4. **Interactive init** — `attic init` prompts for config + credentials 33 + 5. **Error boundary** — top-level catch with friendly messages 34 + 35 + ## Steps 36 + 37 + ### Phase 1: Config Layer 38 + 39 + Add config file support at `~/.attic/config.json`. 40 + 41 + **Files to modify:** 42 + 43 + - New: `cli/src/config/config.ts` (~80 lines) 44 + - New: `cli/src/config/config.test.ts` (~60 lines) 45 + 46 + **Config schema:** 47 + 48 + ```json 49 + { 50 + "endpoint": "https://s3.fr-par.scw.cloud", 51 + "region": "fr-par", 52 + "bucket": "my-photo-backup", 53 + "pathStyle": true, 54 + "keychain": { 55 + "accessKeyService": "attic-s3-access-key", 56 + "secretKeyService": "attic-s3-secret-key" 57 + } 58 + } 59 + ``` 60 + 61 + **Implementation:** 62 + 63 + ```typescript 64 + // cli/src/config/config.ts 65 + export interface AtticConfig { 66 + endpoint: string; 67 + region: string; 68 + bucket: string; 69 + pathStyle: boolean; 70 + keychain: { 71 + accessKeyService: string; 72 + secretKeyService: string; 73 + }; 74 + } 75 + 76 + const CONFIG_DIR = join(homedir(), ".attic"); 77 + const CONFIG_PATH = join(CONFIG_DIR, "config.json"); 78 + 79 + /** Load and validate config. Returns null if file doesn't exist. */ 80 + export function loadConfig(): AtticConfig | null; 81 + 82 + /** Validate config fields, throw with specific message on missing/invalid. */ 83 + export function validateConfig(raw: unknown): AtticConfig; 84 + 85 + /** Write config to disk, creating ~/.attic/ if needed. */ 86 + export function writeConfig(config: AtticConfig): void; 87 + ``` 88 + 89 + **Validation rules:** 90 + 91 + - `endpoint` — required, must start with `https://` 92 + - `region` — required, non-empty string 93 + - `bucket` — required, non-empty string, validated against 94 + `/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/` 95 + - `pathStyle` — optional, defaults to `true` 96 + - `keychain.accessKeyService` — optional, defaults to `"attic-s3-access-key"` 97 + - `keychain.secretKeyService` — optional, defaults to `"attic-s3-secret-key"` 98 + 99 + **Tests:** 100 + 101 + - Valid config round-trips through write/load 102 + - Missing required fields throw descriptive errors 103 + - Optional fields get defaults 104 + - Config file not found returns null 105 + - Invalid endpoint (no https) rejected 106 + - Invalid bucket name rejected 107 + 108 + ### Phase 2: Generic S3 109 + 110 + Remove Scaleway-specific naming. Parameterize the S3 client. 111 + 112 + **Files to modify:** 113 + 114 + - `cli/src/storage/s3-client.ts` (~20 lines changed) 115 + - `cli/mod.ts` (~15 lines changed) 116 + - `deno.json` (~2 lines changed — remove `--allow-net=s3.fr-par.scw.cloud`, use 117 + broader net permission) 118 + 119 + **Changes:** 120 + 121 + ```typescript 122 + // s3-client.ts — before 123 + export interface ScalewayCredentials { ... } 124 + const SCALEWAY_ENDPOINT = "https://s3.fr-par.scw.cloud"; 125 + const SCALEWAY_REGION = "fr-par"; 126 + export function createS3Provider(credentials: ScalewayCredentials, bucket: string): S3Provider 127 + 128 + // s3-client.ts — after 129 + export interface S3Credentials { 130 + accessKeyId: string; 131 + secretAccessKey: string; 132 + } 133 + 134 + export interface S3ConnectionConfig { 135 + endpoint: string; 136 + region: string; 137 + pathStyle: boolean; 138 + } 139 + 140 + export function createS3Provider( 141 + credentials: S3Credentials, 142 + bucket: string, 143 + connection: S3ConnectionConfig, 144 + ): S3Provider 145 + ``` 146 + 147 + - `loadKeychainCredentials()` accepts service names as parameters instead of 148 + hardcoding them 149 + - Delete `SCALEWAY_ENDPOINT` and `SCALEWAY_REGION` constants 150 + - `cli/mod.ts` reads config and passes connection details to 151 + `createS3Provider()` 152 + - `deno.json` tasks: replace `--allow-net=s3.fr-par.scw.cloud` with 153 + `--allow-net` (endpoint is now configurable) 154 + 155 + **Migration for existing users:** scan/status continue working without config 156 + (they don't need S3). backup/verify check for config and fail fast: 157 + 158 + ``` 159 + Error: No config file found at ~/.attic/config.json 160 + Run "attic init" to set up your S3 connection, or create the file manually. 161 + See: https://github.com/tijs/attic#setup 162 + ``` 163 + 164 + ### Phase 3: Cliffy CLI 165 + 166 + Replace hand-rolled arg parsing with Cliffy. 167 + 168 + **Dependencies to add (JSR):** 169 + 170 + - `@cliffy/command@1.0.0` 171 + - `@cliffy/prompt@1.0.0` 172 + - `@cliffy/ansi@1.0.0` 173 + 174 + **Files to modify:** 175 + 176 + - `cli/mod.ts` — full rewrite (~120 lines, replaces 252 lines) 177 + - `cli/deno.json` — add Cliffy imports 178 + 179 + **New structure:** 180 + 181 + ```typescript 182 + // cli/mod.ts 183 + import { Command } from "@cliffy/command"; 184 + 185 + const main = new Command() 186 + .name("attic") 187 + .version("0.1.0") 188 + .description("Back up your iCloud Photos library to S3-compatible storage") 189 + .action(() => main.showHelp()); 190 + 191 + // Each command in its own .command() chain 192 + main.command("scan", "Scan Photos library and show statistics") 193 + .option("--db <path:string>", "Path to Photos.sqlite") 194 + .action(async ({ db }) => { ... }); 195 + 196 + main.command("status", "Compare Photos DB vs backup manifest") 197 + .option("--db <path:string>", "Path to Photos.sqlite") 198 + .action(async ({ db }) => { ... }); 199 + 200 + main.command("backup", "Back up pending assets to S3") 201 + .option("--dry-run", "Show what would be uploaded") 202 + .option("--limit <n:integer>", "Back up at most N assets") 203 + .option("--batch-size <n:integer>", "Assets per ladder batch", { default: 50 }) 204 + .option("--type <type:string>", "Only back up photos or videos") 205 + .option("--bucket <name:string>", "Override bucket from config") 206 + .option("--ladder <path:string>", "Path to ladder binary") 207 + .option("--db <path:string>", "Path to Photos.sqlite") 208 + .action(async (options) => { ... }); 209 + 210 + main.command("verify", "Verify backup integrity against S3") 211 + .option("--deep", "Download and re-checksum each object") 212 + .option("--rebuild-manifest", "Reconstruct manifest from S3 metadata") 213 + .option("--bucket <name:string>", "Override bucket from config") 214 + .action(async (options) => { ... }); 215 + 216 + main.command("init", "Set up attic configuration") 217 + .action(async () => { ... }); 218 + 219 + await main.parse(Deno.args); 220 + ``` 221 + 222 + **What this gives us:** 223 + 224 + - Auto-generated `--help` for every command 225 + - Typed flags with validation (`:integer`, `:string`) 226 + - Unknown flag detection 227 + - Version flag (`--version`) 228 + - Shell completions via 229 + `main.command("completions", ...).action(completeCommand)` 230 + 231 + **What we delete:** 232 + 233 + - `parseBackupFlags()` (~55 lines) 234 + - `parseVerifyFlags()` (~30 lines) 235 + - `requireArg()`, `parsePositiveInt()` (~15 lines) 236 + - Manual help text block (~25 lines) 237 + 238 + ### Phase 4: Interactive Init 239 + 240 + Add `attic init` command with interactive prompts. 241 + 242 + **Files to modify:** 243 + 244 + - New: `cli/src/commands/init.ts` (~120 lines) 245 + - `cli/mod.ts` — wire up init command 246 + 247 + **Flow:** 248 + 249 + ``` 250 + $ attic init 251 + 252 + attic — iCloud Photos backup to S3-compatible storage 253 + 254 + S3 Connection 255 + ───────────── 256 + 257 + Endpoint URL: https://s3.fr-par.scw.cloud 258 + Examples: 259 + · Scaleway (EU): https://s3.fr-par.scw.cloud 260 + · Hetzner (EU): https://fsn1.your-objectstorage.com 261 + · OVH (EU): https://s3.gra.io.cloud.ovh.net 262 + · AWS: https://s3.eu-west-1.amazonaws.com 263 + 264 + Region: fr-par 265 + 266 + Bucket name: my-photo-backup 267 + 268 + Use path-style URLs? (Y/n): Y 269 + Most S3-compatible providers need this. AWS users: set to No. 270 + 271 + Credentials 272 + ─────────── 273 + 274 + Access key: SCWXXXXXXXXXXXXXXXXX 275 + Secret key: ········································ 276 + 277 + Writing config to ~/.attic/config.json... done 278 + Storing credentials in macOS Keychain... done 279 + 280 + ✓ Setup complete. Run "attic scan" to see your Photos library. 281 + ``` 282 + 283 + **Implementation:** 284 + 285 + ```typescript 286 + // cli/src/commands/init.ts 287 + import { Confirm, Input, Secret } from "@cliffy/prompt"; 288 + import { colors } from "@cliffy/ansi"; 289 + 290 + export async function runInit(): Promise<void> { 291 + // Check for existing config 292 + const existing = loadConfig(); 293 + if (existing) { 294 + const overwrite = await Confirm.prompt("Config already exists. Overwrite?"); 295 + if (!overwrite) return; 296 + } 297 + 298 + const endpoint = await Input.prompt({ message: "Endpoint URL", hint: "..." }); 299 + const region = await Input.prompt({ message: "Region" }); 300 + const bucket = await Input.prompt({ message: "Bucket name" }); 301 + const pathStyle = await Confirm.prompt({ 302 + message: "Use path-style URLs?", 303 + default: true, 304 + }); 305 + 306 + const accessKey = await Input.prompt({ message: "Access key" }); 307 + const secretKey = await Secret.prompt({ message: "Secret key" }); 308 + 309 + // Write config 310 + writeConfig({ 311 + endpoint, 312 + region, 313 + bucket, 314 + pathStyle, 315 + keychain: { 316 + accessKeyService: "attic-s3-access-key", 317 + secretKeyService: "attic-s3-secret-key", 318 + }, 319 + }); 320 + 321 + // Store credentials with -U flag (update if exists) 322 + await storeKeychainCredential("attic-s3-access-key", accessKey); 323 + await storeKeychainCredential("attic-s3-secret-key", secretKey); 324 + } 325 + 326 + async function storeKeychainCredential( 327 + service: string, 328 + value: string, 329 + ): Promise<void> { 330 + // Try update first, fall back to add 331 + const update = new Deno.Command("security", { 332 + args: [ 333 + "add-generic-password", 334 + "-U", 335 + "-s", 336 + service, 337 + "-a", 338 + "attic", 339 + "-w", 340 + value, 341 + ], 342 + stderr: "piped", 343 + }); 344 + const { code } = await update.output(); 345 + if (code !== 0) { 346 + throw new Error( 347 + `Failed to store credential in Keychain for service "${service}"`, 348 + ); 349 + } 350 + } 351 + ``` 352 + 353 + **Keychain idempotency:** Use `security add-generic-password -U` which updates 354 + an existing entry or creates a new one. No need to delete-then-add. 355 + 356 + **No test file for init** — it's pure I/O (prompts + Keychain + file writes). 357 + The config validation is tested in Phase 1. Keychain interaction is tested 358 + manually. 359 + 360 + ### Phase 5: Error Boundary 361 + 362 + Add a top-level error handler in `cli/mod.ts`. 363 + 364 + **Files to modify:** 365 + 366 + - `cli/mod.ts` (~40 lines added) 367 + 368 + **Implementation:** 369 + 370 + ```typescript 371 + // Wrap main.parse() in try/catch 372 + try { 373 + await main.parse(Deno.args); 374 + } catch (error: unknown) { 375 + handleError(error); 376 + Deno.exit(1); 377 + } 378 + 379 + function handleError(error: unknown): void { 380 + if (!(error instanceof Error)) { 381 + console.error("An unexpected error occurred."); 382 + return; 383 + } 384 + 385 + const msg = error.message; 386 + 387 + // Keychain not found 388 + if ( 389 + msg.includes("find-generic-password") || 390 + msg.includes("SecKeychainSearchCopyNext") 391 + ) { 392 + console.error("Could not read credentials from macOS Keychain."); 393 + console.error('Run "attic init" to set up your credentials.\n'); 394 + return; 395 + } 396 + 397 + // Config missing 398 + if (msg.includes("config.json") && msg.includes("ENOENT")) { 399 + console.error("No config file found at ~/.attic/config.json"); 400 + console.error('Run "attic init" to set up your S3 connection.\n'); 401 + return; 402 + } 403 + 404 + // S3 access denied 405 + if (msg.includes("AccessDenied") || msg.includes("403")) { 406 + console.error( 407 + "S3 access denied. Check your credentials and bucket permissions.", 408 + ); 409 + console.error("Your credentials are stored in macOS Keychain."); 410 + console.error('Run "attic init" to update them.\n'); 411 + return; 412 + } 413 + 414 + // S3 bucket not found 415 + if (msg.includes("NoSuchBucket") || msg.includes("404")) { 416 + console.error( 417 + `S3 bucket not found. Check the bucket name in ~/.attic/config.json`, 418 + ); 419 + return; 420 + } 421 + 422 + // Network error 423 + if ( 424 + msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT") || 425 + msg.includes("fetch failed") 426 + ) { 427 + console.error( 428 + "Could not connect to S3 endpoint. Check your network and endpoint URL.", 429 + ); 430 + return; 431 + } 432 + 433 + // Photos.sqlite not found 434 + if (msg.includes("Photos.sqlite") || msg.includes("no such file")) { 435 + console.error("Could not open Photos database."); 436 + console.error( 437 + "Make sure Photos is set up on this Mac and the database exists.", 438 + ); 439 + return; 440 + } 441 + 442 + // Fallback 443 + console.error(`Error: ${msg}`); 444 + } 445 + ``` 446 + 447 + ## Files Summary 448 + 449 + | Phase | File | Change | 450 + | ----- | ------------------------------- | -------------------------------- | 451 + | 1 | `cli/src/config/config.ts` | New — config load/validate/write | 452 + | 1 | `cli/src/config/config.test.ts` | New — config tests | 453 + | 2 | `cli/src/storage/s3-client.ts` | Rename types, parameterize | 454 + | 2 | `cli/mod.ts` | Read config, pass to S3 | 455 + | 2 | `deno.json` | Broader net permission | 456 + | 3 | `cli/mod.ts` | Rewrite with Cliffy | 457 + | 3 | `cli/deno.json` | Add Cliffy imports | 458 + | 4 | `cli/src/commands/init.ts` | New — interactive setup | 459 + | 5 | `cli/mod.ts` | Error boundary wrapper | 460 + 461 + ## Verification 462 + 463 + After each phase: 464 + 465 + 1. `deno task check` — type checking 466 + 2. `deno task test` — all tests pass 467 + 3. `deno task lint` — no lint errors 468 + 469 + Integration test (after all phases): 470 + 471 + 1. Run `attic init` with a test bucket 472 + 2. Run `attic scan` — works without config 473 + 3. Run `attic backup --dry-run` — reads config, validates, shows plan 474 + 4. Run `attic verify` — reads config, connects to S3 475 + 476 + ## Dependencies 477 + 478 + - `@cliffy/command@1.0.0` (JSR) — subcommands, typed flags, help generation 479 + - `@cliffy/prompt@1.0.0` (JSR) — interactive prompts for init 480 + - `@cliffy/ansi@1.0.0` (JSR) — colored output 481 + 482 + All three are Deno 2+ compatible via JSR. No npm dependencies. 483 + 484 + ## Out of Scope 485 + 486 + - Env var credential fallback (keep Keychain-only) 487 + - Non-macOS support 488 + - Provider presets/auto-detection in init 489 + - Web UI or GUI 490 + - Auto-detection of Photos.sqlite path across macOS versions 491 + - Shell completion generation (Cliffy supports it, but we can add it later)