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.

Migrate CLI to Cliffy with typed flags and auto-generated help

+106 -177
+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",
+64 -176
cli/mod.ts
··· 1 + import { Command } from "@cliffy/command"; 1 2 import { requireConfig } from "./src/config/config.ts"; 2 3 3 - const command = Deno.args[0]; 4 + const main = new Command() 5 + .name("attic") 6 + .version("0.1.0") 7 + .description("Back up your iCloud Photos library to S3-compatible storage") 8 + .action(function (this: Command) { 9 + this.showHelp(); 10 + }); 4 11 5 - switch (command) { 6 - case "scan": { 12 + main.command("scan", "Scan Photos library and show statistics") 13 + .option("--db <path:string>", "Path to Photos.sqlite") 14 + .action(async ({ db }: { db?: string }) => { 7 15 const { openPhotosDb } = await import("./src/photos-db/reader.ts"); 8 16 const { printScanReport } = await import("./src/commands/scan.ts"); 9 17 10 - const dbPath = Deno.args[1]; // optional override 11 - const reader = openPhotosDb(dbPath); 18 + const reader = openPhotosDb(db); 12 19 try { 13 20 const assets = reader.readAssets(); 14 21 printScanReport(assets); 15 22 } finally { 16 23 reader.close(); 17 24 } 18 - break; 19 - } 20 - case "status": { 25 + }); 26 + 27 + main.command("status", "Compare Photos DB vs backup manifest") 28 + .option("--db <path:string>", "Path to Photos.sqlite") 29 + .action(async ({ db }: { db?: string }) => { 21 30 const { openPhotosDb } = await import("./src/photos-db/reader.ts"); 22 31 const { printStatusReport } = await import("./src/commands/status.ts"); 23 32 const { createManifestStore } = await import("./src/manifest/manifest.ts"); 24 33 25 - const dbPath = Deno.args[1]; // optional override 26 - const reader = openPhotosDb(dbPath); 34 + const reader = openPhotosDb(db); 27 35 try { 28 36 const assets = reader.readAssets(); 29 37 const manifestStore = createManifestStore(); ··· 31 39 } finally { 32 40 reader.close(); 33 41 } 34 - break; 35 - } 36 - case "backup": { 42 + }); 43 + 44 + main.command("backup", "Back up pending assets to S3") 45 + .option("--dry-run", "Show what would be uploaded") 46 + .option("--limit <n:integer>", "Back up at most N assets") 47 + .option("--batch-size <n:integer>", "Assets per ladder batch", { 48 + default: 50, 49 + }) 50 + .option("--type <type:string>", "Only back up photos or videos") 51 + .option("--bucket <name:string>", "Override bucket from config") 52 + .option("--ladder <path:string>", "Path to ladder binary") 53 + .option("--db <path:string>", "Path to Photos.sqlite") 54 + .action(async (options: { 55 + dryRun?: boolean; 56 + limit?: number; 57 + batchSize: number; 58 + type?: string; 59 + bucket?: string; 60 + ladder?: string; 61 + db?: string; 62 + }) => { 37 63 const { openPhotosDb } = await import("./src/photos-db/reader.ts"); 38 64 const { runBackup } = await import("./src/commands/backup.ts"); 39 65 const { createManifestStore } = await import("./src/manifest/manifest.ts"); ··· 42 68 ); 43 69 const { createLadderExporter } = await import("./src/export/exporter.ts"); 44 70 45 - const flags = parseBackupFlags(Deno.args.slice(1)); 46 71 const config = requireConfig(); 47 - const reader = openPhotosDb(flags.dbPath); 72 + const reader = openPhotosDb(options.db); 48 73 try { 49 74 const assets = reader.readAssets(); 50 75 const manifestStore = createManifestStore(); ··· 56 81 ); 57 82 const s3 = createS3Provider( 58 83 credentials, 59 - flags.bucket ?? config.bucket, 84 + options.bucket ?? config.bucket, 60 85 { 61 86 endpoint: config.endpoint, 62 87 region: config.region, ··· 64 89 }, 65 90 ); 66 91 67 - const ladderPath = flags.ladderPath ?? 92 + const ladderPath = options.ladder ?? 68 93 Deno.env.get("LADDER_PATH") ?? 69 94 "ladder"; 70 95 const exporter = createLadderExporter(ladderPath); 71 96 97 + const typeFilter = options.type as "photo" | "video" | undefined; 98 + 72 99 await runBackup(assets, manifest, manifestStore, exporter, s3, { 73 - batchSize: flags.batchSize, 74 - limit: flags.limit, 75 - type: flags.type, 76 - dryRun: flags.dryRun, 100 + batchSize: options.batchSize, 101 + limit: options.limit ?? 0, 102 + type: typeFilter ?? null, 103 + dryRun: options.dryRun ?? false, 77 104 }); 78 105 } finally { 79 106 reader.close(); 80 107 } 81 - break; 82 - } 83 - case "verify": { 108 + }); 109 + 110 + main.command("verify", "Verify backup integrity against S3") 111 + .option("--deep", "Download and re-checksum each object") 112 + .option("--rebuild-manifest", "Reconstruct manifest from S3 metadata") 113 + .option("--bucket <name:string>", "Override bucket from config") 114 + .action(async (options: { 115 + deep?: boolean; 116 + rebuildManifest?: boolean; 117 + bucket?: string; 118 + }) => { 84 119 const { runVerify } = await import("./src/commands/verify.ts"); 85 120 const { rebuildManifest } = await import("./src/commands/rebuild.ts"); 86 121 const { createManifestStore } = await import("./src/manifest/manifest.ts"); ··· 88 123 "./src/storage/s3-client.ts" 89 124 ); 90 125 91 - const verifyFlags = parseVerifyFlags(Deno.args.slice(1)); 92 126 const config = requireConfig(); 93 127 94 128 const credentials = await loadKeychainCredentials( ··· 97 131 ); 98 132 const s3 = createS3Provider( 99 133 credentials, 100 - verifyFlags.bucket ?? config.bucket, 134 + options.bucket ?? config.bucket, 101 135 { 102 136 endpoint: config.endpoint, 103 137 region: config.region, ··· 106 140 ); 107 141 const manifestStore = createManifestStore(); 108 142 109 - if (verifyFlags.rebuildManifest) { 143 + if (options.rebuildManifest) { 110 144 await rebuildManifest(s3, manifestStore); 111 145 } else { 112 146 const manifest = await manifestStore.load(); 113 147 await runVerify(manifest, s3, { 114 - deep: verifyFlags.deep, 148 + deep: options.deep ?? false, 115 149 }); 116 150 } 117 - break; 118 - } 119 - default: 120 - console.log(`attic — iCloud Photos backup to S3-compatible storage\n`); 121 - console.log(`Commands:`); 122 - console.log(` scan Scan Photos library and show statistics`); 123 - console.log(` status Compare Photos DB vs backup manifest`); 124 - console.log(` backup Back up pending assets to S3`); 125 - console.log(` verify Verify backup integrity against S3`); 126 - console.log(`\nBackup flags:`); 127 - console.log(` --dry-run Show what would be uploaded`); 128 - console.log(` --limit N Back up at most N assets`); 129 - console.log(` --batch-size N Assets per ladder batch (default: 50)`); 130 - console.log(` --type photo|video Only back up photos or videos`); 131 - console.log(` --bucket NAME S3 bucket (overrides config)`); 132 - console.log(` --ladder PATH Path to ladder binary`); 133 - console.log(` --db PATH Path to Photos.sqlite`); 134 - console.log(`\nVerify flags:`); 135 - console.log(` --deep Download and re-checksum each object`); 136 - console.log(` --rebuild-manifest Reconstruct manifest from S3 metadata`); 137 - console.log(` --bucket NAME S3 bucket (overrides config)`); 138 - console.log(`\nUsage: deno task <command>`); 139 - if (command) { 140 - console.error(`\nUnknown command: ${command}`); 141 - Deno.exit(1); 142 - } 143 - } 144 - 145 - interface BackupFlags { 146 - dryRun: boolean; 147 - limit: number; 148 - batchSize: number; 149 - type: "photo" | "video" | null; 150 - bucket: string | null; 151 - ladderPath: string | null; 152 - dbPath: string | undefined; 153 - } 154 - 155 - function requireArg(args: string[], i: number, flag: string): string { 156 - const value = args[i]; 157 - if (value === undefined) { 158 - console.error(`Missing value for ${flag}`); 159 - Deno.exit(1); 160 - } 161 - return value; 162 - } 163 - 164 - function parsePositiveInt(value: string, flag: string): number { 165 - const n = parseInt(value, 10); 166 - if (!Number.isFinite(n) || n < 1) { 167 - console.error(`${flag} must be a positive integer, got: ${value}`); 168 - Deno.exit(1); 169 - } 170 - return n; 171 - } 172 - 173 - function parseBackupFlags(args: string[]): BackupFlags { 174 - const flags: BackupFlags = { 175 - dryRun: false, 176 - limit: 0, 177 - batchSize: 50, 178 - type: null, 179 - bucket: null, 180 - ladderPath: null, 181 - dbPath: undefined, 182 - }; 183 - 184 - for (let i = 0; i < args.length; i++) { 185 - const arg = args[i]; 186 - switch (arg) { 187 - case "--dry-run": 188 - flags.dryRun = true; 189 - break; 190 - case "--limit": 191 - flags.limit = parsePositiveInt( 192 - requireArg(args, ++i, "--limit"), 193 - "--limit", 194 - ); 195 - break; 196 - case "--batch-size": 197 - flags.batchSize = parsePositiveInt( 198 - requireArg(args, ++i, "--batch-size"), 199 - "--batch-size", 200 - ); 201 - break; 202 - case "--type": { 203 - const typeVal = requireArg(args, ++i, "--type"); 204 - if (typeVal !== "photo" && typeVal !== "video") { 205 - console.error(`--type must be "photo" or "video", got: ${typeVal}`); 206 - Deno.exit(1); 207 - } 208 - flags.type = typeVal; 209 - break; 210 - } 211 - case "--bucket": 212 - flags.bucket = requireArg(args, ++i, "--bucket"); 213 - break; 214 - case "--ladder": 215 - flags.ladderPath = requireArg(args, ++i, "--ladder"); 216 - break; 217 - case "--db": 218 - flags.dbPath = requireArg(args, ++i, "--db"); 219 - break; 220 - case "--": 221 - break; 222 - default: 223 - console.error(`Unknown flag: ${arg}`); 224 - Deno.exit(1); 225 - } 226 - } 227 - 228 - return flags; 229 - } 230 - 231 - interface VerifyFlags { 232 - deep: boolean; 233 - rebuildManifest: boolean; 234 - bucket: string | null; 235 - } 236 - 237 - function parseVerifyFlags(args: string[]): VerifyFlags { 238 - const flags: VerifyFlags = { 239 - deep: false, 240 - rebuildManifest: false, 241 - bucket: null, 242 - }; 243 - 244 - for (let i = 0; i < args.length; i++) { 245 - const arg = args[i]; 246 - switch (arg) { 247 - case "--deep": 248 - flags.deep = true; 249 - break; 250 - case "--rebuild-manifest": 251 - flags.rebuildManifest = true; 252 - break; 253 - case "--bucket": 254 - flags.bucket = requireArg(args, ++i, "--bucket"); 255 - break; 256 - case "--": 257 - break; 258 - default: 259 - console.error(`Unknown flag: ${arg}`); 260 - Deno.exit(1); 261 - } 262 - } 151 + }); 263 152 264 - return flags; 265 - } 153 + await main.parse(Deno.args);
+39 -1
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "jsr:@cliffy/command@1": "1.0.0", 5 + "jsr:@cliffy/flags@1.0.0": "1.0.0", 6 + "jsr:@cliffy/internal@1.0.0": "1.0.0", 7 + "jsr:@cliffy/table@1.0.0": "1.0.0", 4 8 "jsr:@db/sqlite@0.12": "0.12.0", 5 9 "jsr:@denosaurs/plug@1": "1.1.0", 6 10 "jsr:@std/assert@0.217": "0.217.0", 7 11 "jsr:@std/assert@1": "1.0.18", 8 12 "jsr:@std/encoding@1": "1.0.10", 9 13 "jsr:@std/fmt@1": "1.0.9", 14 + "jsr:@std/fmt@^1.0.9": "1.0.9", 10 15 "jsr:@std/fs@1": "1.0.22", 11 16 "jsr:@std/internal@^1.0.12": "1.0.12", 12 17 "jsr:@std/path@0.217": "0.217.0", 13 18 "jsr:@std/path@1": "1.1.4", 14 19 "jsr:@std/path@^1.1.4": "1.1.4", 20 + "jsr:@std/text@^1.0.17": "1.0.17", 15 21 "npm:@aws-sdk/client-s3@3": "3.1008.0" 16 22 }, 17 23 "jsr": { 24 + "@cliffy/command@1.0.0": { 25 + "integrity": "c52a241ea68857fcdaff4f3173eb404f8017d7bc35553b6f533c592b89dde7d2", 26 + "dependencies": [ 27 + "jsr:@cliffy/flags", 28 + "jsr:@cliffy/internal", 29 + "jsr:@cliffy/table", 30 + "jsr:@std/fmt@^1.0.9", 31 + "jsr:@std/text" 32 + ] 33 + }, 34 + "@cliffy/flags@1.0.0": { 35 + "integrity": "8b57698adc644da8f90422d58976362d41a4ebca39c312ca1c101585d0148feb", 36 + "dependencies": [ 37 + "jsr:@cliffy/internal", 38 + "jsr:@std/text" 39 + ] 40 + }, 41 + "@cliffy/internal@1.0.0": { 42 + "integrity": "1e17ccbcd5420093c0a93e5b3827bbdc9abac5195bacf187edc44665e54bdde6" 43 + }, 44 + "@cliffy/table@1.0.0": { 45 + "integrity": "3fdaa9e1ef1ea62022108adabd826932bdea8dd05497079896febcd41322907f", 46 + "dependencies": [ 47 + "jsr:@std/fmt@^1.0.9" 48 + ] 49 + }, 18 50 "@db/sqlite@0.12.0": { 19 51 "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", 20 52 "dependencies": [ ··· 26 58 "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", 27 59 "dependencies": [ 28 60 "jsr:@std/encoding", 29 - "jsr:@std/fmt", 61 + "jsr:@std/fmt@1", 30 62 "jsr:@std/fs", 31 63 "jsr:@std/path@1" 32 64 ] ··· 67 99 "dependencies": [ 68 100 "jsr:@std/internal" 69 101 ] 102 + }, 103 + "@std/text@1.0.17": { 104 + "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95" 70 105 } 71 106 }, 72 107 "npm": { ··· 1055 1090 "members": { 1056 1091 "cli": { 1057 1092 "dependencies": [ 1093 + "jsr:@cliffy/ansi@1", 1094 + "jsr:@cliffy/command@1", 1095 + "jsr:@cliffy/prompt@1", 1058 1096 "jsr:@db/sqlite@0.12", 1059 1097 "jsr:@std/assert@1", 1060 1098 "jsr:@std/crypto@1",