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.

Add config layer for generic S3 provider settings

+263
+134
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 + () => 61 + validateConfig({ endpoint: "https://s3.example.com", bucket: "bbb" }), 62 + Error, 63 + '"region" is required', 64 + ); 65 + }); 66 + 67 + Deno.test("validateConfig rejects missing bucket", () => { 68 + assertThrows( 69 + () => 70 + validateConfig({ 71 + endpoint: "https://s3.example.com", 72 + region: "us-east-1", 73 + }), 74 + Error, 75 + '"bucket" is required', 76 + ); 77 + }); 78 + 79 + Deno.test("validateConfig rejects invalid bucket name", () => { 80 + assertThrows( 81 + () => 82 + validateConfig({ 83 + endpoint: "https://s3.example.com", 84 + region: "us-east-1", 85 + bucket: "A", 86 + }), 87 + Error, 88 + "is invalid", 89 + ); 90 + }); 91 + 92 + Deno.test("validateConfig rejects non-object input", () => { 93 + assertThrows( 94 + () => validateConfig("not an object"), 95 + Error, 96 + "must be a JSON object", 97 + ); 98 + assertThrows( 99 + () => validateConfig(null), 100 + Error, 101 + "must be a JSON object", 102 + ); 103 + }); 104 + 105 + Deno.test("writeConfig and loadConfig round-trip", () => { 106 + const dir = Deno.makeTempDirSync(); 107 + const config = { 108 + endpoint: "https://s3.fr-par.scw.cloud", 109 + region: "fr-par", 110 + bucket: "test-bucket", 111 + pathStyle: true, 112 + keychain: { 113 + accessKeyService: "attic-s3-access-key", 114 + secretKeyService: "attic-s3-secret-key", 115 + }, 116 + }; 117 + 118 + writeConfig(config, dir); 119 + 120 + // Verify file exists 121 + const text = Deno.readTextFileSync(join(dir, "config.json")); 122 + const parsed = JSON.parse(text); 123 + assertEquals(parsed.endpoint, "https://s3.fr-par.scw.cloud"); 124 + 125 + // Round-trip through loadConfig 126 + const loaded = loadConfig(dir); 127 + assertEquals(loaded, config); 128 + }); 129 + 130 + Deno.test("loadConfig returns null when file does not exist", () => { 131 + const dir = Deno.makeTempDirSync(); 132 + const result = loadConfig(dir); 133 + assertEquals(result, null); 134 + });
+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('Config: "endpoint" is required (e.g. "https://s3.fr-par.scw.cloud")'); 49 + } 50 + if (!obj.endpoint.startsWith("https://")) { 51 + throw new Error('Config: "endpoint" must start with https://'); 52 + } 53 + 54 + if (typeof obj.region !== "string" || obj.region === "") { 55 + throw new Error('Config: "region" is required (e.g. "fr-par")'); 56 + } 57 + 58 + if (typeof obj.bucket !== "string" || obj.bucket === "") { 59 + throw new Error('Config: "bucket" is required'); 60 + } 61 + if (!BUCKET_PATTERN.test(obj.bucket)) { 62 + throw new Error( 63 + `Config: "bucket" name "${obj.bucket}" is invalid. ` + 64 + "Use lowercase letters, numbers, dots, and hyphens (3-63 chars).", 65 + ); 66 + } 67 + 68 + const pathStyle = obj.pathStyle !== undefined ? Boolean(obj.pathStyle) : true; 69 + 70 + const keychain = typeof obj.keychain === "object" && obj.keychain !== null 71 + ? obj.keychain as Record<string, unknown> 72 + : {}; 73 + 74 + const accessKeyService = typeof keychain.accessKeyService === "string" && 75 + keychain.accessKeyService !== "" 76 + ? keychain.accessKeyService 77 + : "attic-s3-access-key"; 78 + 79 + const secretKeyService = typeof keychain.secretKeyService === "string" && 80 + keychain.secretKeyService !== "" 81 + ? keychain.secretKeyService 82 + : "attic-s3-secret-key"; 83 + 84 + return { 85 + endpoint: obj.endpoint, 86 + region: obj.region, 87 + bucket: obj.bucket, 88 + pathStyle, 89 + keychain: { accessKeyService, secretKeyService }, 90 + }; 91 + } 92 + 93 + /** Write config to disk, creating ~/.attic/ if needed. */ 94 + export function writeConfig( 95 + config: AtticConfig, 96 + dir: string = CONFIG_DIR, 97 + ): void { 98 + Deno.mkdirSync(dir, { recursive: true }); 99 + const path = join(dir, "config.json"); 100 + Deno.writeTextFileSync( 101 + path, 102 + JSON.stringify(config, null, 2) + "\n", 103 + ); 104 + } 105 + 106 + /** Resolve the default config directory path. */ 107 + export function configDir(): string { 108 + return CONFIG_DIR; 109 + } 110 + 111 + /** Resolve the default config file path. */ 112 + export function configPath(): string { 113 + return CONFIG_PATH; 114 + } 115 + 116 + /** 117 + * Load and validate config, throwing a user-friendly error if missing. 118 + * Use this for commands that require S3 (backup, verify). 119 + */ 120 + export function requireConfig(dir?: string): AtticConfig { 121 + const config = loadConfig(dir); 122 + if (config === null) { 123 + throw new Error( 124 + `No config file found at ${configPath()}\n` + 125 + 'Run "attic init" to set up your S3 connection, or create the file manually.', 126 + ); 127 + } 128 + return config; 129 + }