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 Apache 2.0 license, unattended backups guide, remove internal docs

- Add LICENSE (Apache 2.0)
- Add docs/unattended-backups.md with launchd setup guide
- Remove docs/brainstorms/ and docs/plans/ from repo
- Update .gitignore to exclude internal working docs
- Reference unattended guide from README, remove from future plans

+376 -1179
+4
.gitignore
··· 2 2 3 3 # User config (contains endpoint, bucket, keychain service names) 4 4 ~/.attic/ 5 + 6 + # Internal working docs 7 + docs/brainstorms/ 8 + docs/plans/
+190
LICENSE
··· 1 + Apache License 2 + Version 2.0, January 2004 3 + http://www.apache.org/licenses/ 4 + 5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 + 7 + 1. Definitions. 8 + 9 + "License" shall mean the terms and conditions for use, reproduction, 10 + and distribution as defined by Sections 1 through 9 of this document. 11 + 12 + "Licensor" shall mean the copyright owner or entity authorized by 13 + the copyright owner that is granting the License. 14 + 15 + "Legal Entity" shall mean the union of the acting entity and all 16 + other entities that control, are controlled by, or are under common 17 + control with that entity. For the purposes of this definition, 18 + "control" means (i) the power, direct or indirect, to cause the 19 + direction or management of such entity, whether by contract or 20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 + outstanding shares, or (iii) beneficial ownership of such entity. 22 + 23 + "You" (or "Your") shall mean an individual or Legal Entity 24 + exercising permissions granted by this License. 25 + 26 + "Source" form shall mean the preferred form for making modifications, 27 + including but not limited to software source code, documentation 28 + source, and configuration files. 29 + 30 + "Object" form shall mean any form resulting from mechanical 31 + transformation or translation of a Source form, including but 32 + not limited to compiled object code, generated documentation, 33 + and conversions to other media types. 34 + 35 + "Work" shall mean the work of authorship, whether in Source or 36 + Object form, made available under the License, as indicated by a 37 + copyright notice that is included in or attached to the work 38 + (an example is provided in the Appendix below). 39 + 40 + "Derivative Works" shall mean any work, whether in Source or Object 41 + form, that is based on (or derived from) the Work and for which the 42 + editorial revisions, annotations, elaborations, or other modifications 43 + represent, as a whole, an original work of authorship. For the purposes 44 + of this License, Derivative Works shall not include works that remain 45 + separable from, or merely link (or bind by name) to the interfaces of, 46 + the Work and Derivative Works thereof. 47 + 48 + "Contribution" shall mean any work of authorship, including 49 + the original version of the Work and any modifications or additions 50 + to that Work or Derivative Works thereof, that is intentionally 51 + submitted to the Licensor for inclusion in the Work by the copyright owner 52 + or by an individual or Legal Entity authorized to submit on behalf of 53 + the copyright owner. For the purposes of this definition, "submitted" 54 + means any form of electronic, verbal, or written communication sent 55 + to the Licensor or its representatives, including but not limited to 56 + communication on electronic mailing lists, source code control systems, 57 + and issue tracking systems that are managed by, or on behalf of, the 58 + Licensor for the purpose of discussing and improving the Work, but 59 + excluding communication that is conspicuously marked or otherwise 60 + designated in writing by the copyright owner as "Not a Contribution." 61 + 62 + "Contributor" shall mean Licensor and any individual or Legal Entity 63 + on behalf of whom a Contribution has been received by the Licensor and 64 + subsequently incorporated within the Work. 65 + 66 + 2. Grant of Copyright License. Subject to the terms and conditions of 67 + this License, each Contributor hereby grants to You a perpetual, 68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 + copyright license to reproduce, prepare Derivative Works of, 70 + publicly display, publicly perform, sublicense, and distribute the 71 + Work and such Derivative Works in Source or Object form. 72 + 73 + 3. Grant of Patent License. Subject to the terms and conditions of 74 + this License, each Contributor hereby grants to You a perpetual, 75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 + (except as stated in this section) patent license to make, have made, 77 + use, offer to sell, sell, import, and otherwise transfer the Work, 78 + where such license applies only to those patent claims licensable 79 + by such Contributor that are necessarily infringed by their 80 + Contribution(s) alone or by combination of their Contribution(s) 81 + with the Work to which such Contribution(s) was submitted. If You 82 + institute patent litigation against any entity (including a 83 + cross-claim or counterclaim in a lawsuit) alleging that the Work 84 + or a Contribution incorporated within the Work constitutes direct 85 + or contributory patent infringement, then any patent licenses 86 + granted to You under this License for that Work shall terminate 87 + as of the date such litigation is filed. 88 + 89 + 4. Redistribution. You may reproduce and distribute copies of the 90 + Work or Derivative Works thereof in any medium, with or without 91 + modifications, and in Source or Object form, provided that You 92 + meet the following conditions: 93 + 94 + (a) You must give any other recipients of the Work or 95 + Derivative Works a copy of this License; and 96 + 97 + (b) You must cause any modified files to carry prominent notices 98 + stating that You changed the files; and 99 + 100 + (c) You must retain, in the Source form of any Derivative Works 101 + that You distribute, all copyright, patent, trademark, and 102 + attribution notices from the Source form of the Work, 103 + excluding those notices that do not pertain to any part of 104 + the Derivative Works; and 105 + 106 + (d) If the Work includes a "NOTICE" text file as part of its 107 + distribution, then any Derivative Works that You distribute must 108 + include a readable copy of the attribution notices contained 109 + within such NOTICE file, excluding any notices that do not 110 + pertain to any part of the Derivative Works, in at least one 111 + of the following places: within a NOTICE text file distributed 112 + as part of the Derivative Works; within the Source form or 113 + documentation, if provided along with the Derivative Works; or, 114 + within a display generated by the Derivative Works, if and 115 + wherever such third-party notices normally appear. The contents 116 + of the NOTICE file are for informational purposes only and 117 + do not modify the License. You may add Your own attribution 118 + notices within Derivative Works that You distribute, alongside 119 + or as an addendum to the NOTICE text from the Work, provided 120 + that such additional attribution notices cannot be construed 121 + as modifying the License. 122 + 123 + You may add Your own copyright statement to Your modifications and 124 + may provide additional or different license terms and conditions 125 + for use, reproduction, or distribution of Your modifications, or 126 + for any such Derivative Works as a whole, provided Your use, 127 + reproduction, and distribution of the Work otherwise complies with 128 + the conditions stated in this License. 129 + 130 + 5. Submission of Contributions. Unless You explicitly state otherwise, 131 + any Contribution intentionally submitted for inclusion in the Work 132 + by You to the Licensor shall be under the terms and conditions of 133 + this License, without any additional terms or conditions. 134 + Notwithstanding the above, nothing herein shall supersede or modify 135 + the terms of any separate license agreement you may have executed 136 + with Licensor regarding such Contributions. 137 + 138 + 6. Trademarks. This License does not grant permission to use the trade 139 + names, trademarks, service marks, or product names of the Licensor, 140 + except as required for reasonable and customary use in describing the 141 + origin of the Work and reproducing the content of the NOTICE file. 142 + 143 + 7. Disclaimer of Warranty. Unless required by applicable law or 144 + agreed to in writing, Licensor provides the Work (and each 145 + Contributor provides its Contributions) on an "AS IS" BASIS, 146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 + implied, including, without limitation, any warranties or conditions 148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 + PARTICULAR PURPOSE. You are solely responsible for determining the 150 + appropriateness of using or redistributing the Work and assume any 151 + risks associated with Your exercise of permissions under this License. 152 + 153 + 8. Limitation of Liability. In no event and under no legal theory, 154 + whether in tort (including negligence), contract, or otherwise, 155 + unless required by applicable law (such as deliberate and grossly 156 + negligent acts) or agreed to in writing, shall any Contributor be 157 + liable to You for damages, including any direct, indirect, special, 158 + incidental, or consequential damages of any character arising as a 159 + result of this License or out of the use or inability to use the 160 + Work (including but not limited to damages for loss of goodwill, 161 + work stoppage, computer failure or malfunction, or any and all 162 + other commercial damages or losses), even if such Contributor 163 + has been advised of the possibility of such damages. 164 + 165 + 9. Accepting Warranty or Additional Liability. While redistributing 166 + the Work or Derivative Works thereof, You may choose to offer, 167 + and charge a fee for, acceptance of support, warranty, indemnity, 168 + or other liability obligations and/or rights consistent with this 169 + License. However, in accepting such obligations, You may act only 170 + on Your own behalf and on Your sole responsibility, not on behalf 171 + of any other Contributor, and only if You agree to indemnify, 172 + defend, and hold each Contributor harmless for any liability 173 + incurred by, or claims asserted against, such Contributor by reason 174 + of your accepting any such warranty or additional liability. 175 + 176 + END OF TERMS AND CONDITIONS 177 + 178 + Copyright 2025 Tijs Teulings 179 + 180 + Licensed under the Apache License, Version 2.0 (the "License"); 181 + you may not use this file except in compliance with the License. 182 + You may obtain a copy of the License at 183 + 184 + http://www.apache.org/licenses/LICENSE-2.0 185 + 186 + Unless required by applicable law or agreed to in writing, software 187 + distributed under the License is distributed on an "AS IS" BASIS, 188 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 + See the License for the specific language governing permissions and 190 + limitations under the License.
+2 -2
README.md
··· 197 197 boundaries 198 198 - [Asset Metadata](docs/metadata.md) -- Schema reference for the per-asset JSON 199 199 uploaded to S3 200 + - [Unattended Backups](docs/unattended-backups.md) -- Set up daily scheduled 201 + backups via launchd on a dedicated Mac 200 202 201 203 ## Future Plans 202 204 203 - - **Scheduled backups via launchd** -- A LaunchAgent plist to run backups daily 204 - on a dedicated Mac 205 205 - **Rendered edit backup** -- Detect and upload edited versions alongside 206 206 originals (see `docs/plans/`)
-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.
-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)
+180
docs/unattended-backups.md
··· 1 + # Unattended Backups 2 + 3 + Run attic on a schedule so your iCloud Photos library is continuously backed up 4 + without manual intervention. This guide covers setup using macOS launchd. 5 + 6 + ## Prerequisites 7 + 8 + Before setting up unattended backups, make sure: 9 + 10 + 1. `attic init` has been run and works interactively (`attic backup --limit 1`) 11 + 2. Both `attic` and `ladder` are installed via Homebrew (`brew install tijs/tap/attic`) 12 + 3. The Mac is signed into iCloud with Photos enabled 13 + 14 + ## Full Disk Access 15 + 16 + Attic and ladder need to read Photos.sqlite and access the Photos library via 17 + PhotoKit. macOS requires Full Disk Access for this. 18 + 19 + Open **System Settings > Privacy & Security > Full Disk Access** and enable it 20 + for both: 21 + 22 + - `/opt/homebrew/bin/attic` 23 + - `/opt/homebrew/bin/ladder` 24 + 25 + If you skip this, backups will fail with a permission error when trying to read 26 + the Photos database. 27 + 28 + ## LaunchAgent setup 29 + 30 + Create a LaunchAgent plist that runs `attic backup` daily. 31 + 32 + ```bash 33 + mkdir -p ~/.attic/logs 34 + cat > ~/Library/LaunchAgents/photos.attic.backup.plist << 'EOF' 35 + <?xml version="1.0" encoding="UTF-8"?> 36 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" 37 + "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 38 + <plist version="1.0"> 39 + <dict> 40 + <key>Label</key> 41 + <string>photos.attic.backup</string> 42 + 43 + <key>ProgramArguments</key> 44 + <array> 45 + <string>/opt/homebrew/bin/attic</string> 46 + <string>backup</string> 47 + </array> 48 + 49 + <key>StartCalendarInterval</key> 50 + <dict> 51 + <key>Hour</key> 52 + <integer>3</integer> 53 + <key>Minute</key> 54 + <integer>0</integer> 55 + </dict> 56 + 57 + <key>StandardOutPath</key> 58 + <string>/Users/YOU/.attic/logs/backup.log</string> 59 + <key>StandardErrorPath</key> 60 + <string>/Users/YOU/.attic/logs/backup-error.log</string> 61 + 62 + <key>ProcessType</key> 63 + <string>Background</string> 64 + </dict> 65 + </plist> 66 + EOF 67 + ``` 68 + 69 + Replace `YOU` with your macOS username, then load it: 70 + 71 + ```bash 72 + launchctl load ~/Library/LaunchAgents/photos.attic.backup.plist 73 + ``` 74 + 75 + The backup will now run daily at 3 AM. To change the schedule, edit the 76 + `StartCalendarInterval` section and reload: 77 + 78 + ```bash 79 + launchctl unload ~/Library/LaunchAgents/photos.attic.backup.plist 80 + launchctl load ~/Library/LaunchAgents/photos.attic.backup.plist 81 + ``` 82 + 83 + ## Optional: weekly verification 84 + 85 + Add a second LaunchAgent that runs `attic verify` weekly to check backup 86 + integrity. 87 + 88 + ```bash 89 + cat > ~/Library/LaunchAgents/photos.attic.verify.plist << 'EOF' 90 + <?xml version="1.0" encoding="UTF-8"?> 91 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" 92 + "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 93 + <plist version="1.0"> 94 + <dict> 95 + <key>Label</key> 96 + <string>photos.attic.verify</string> 97 + 98 + <key>ProgramArguments</key> 99 + <array> 100 + <string>/opt/homebrew/bin/attic</string> 101 + <string>verify</string> 102 + </array> 103 + 104 + <key>StartCalendarInterval</key> 105 + <dict> 106 + <key>Weekday</key> 107 + <integer>0</integer> 108 + <key>Hour</key> 109 + <integer>4</integer> 110 + <key>Minute</key> 111 + <integer>0</integer> 112 + </dict> 113 + 114 + <key>StandardOutPath</key> 115 + <string>/Users/YOU/.attic/logs/verify.log</string> 116 + <key>StandardErrorPath</key> 117 + <string>/Users/YOU/.attic/logs/verify-error.log</string> 118 + 119 + <key>ProcessType</key> 120 + <string>Background</string> 121 + </dict> 122 + </plist> 123 + EOF 124 + ``` 125 + 126 + Same drill — replace `YOU`, then `launchctl load` it. 127 + 128 + ## Checking logs 129 + 130 + ```bash 131 + # Most recent backup output 132 + cat ~/.attic/logs/backup.log 133 + 134 + # Errors only 135 + cat ~/.attic/logs/backup-error.log 136 + ``` 137 + 138 + Log files are overwritten each run by launchd. If you need history, consider 139 + redirecting through a script that appends with timestamps: 140 + 141 + ```bash 142 + #!/bin/bash 143 + /opt/homebrew/bin/attic backup 2>&1 | while IFS= read -r line; do 144 + echo "$(date '+%Y-%m-%d %H:%M:%S') $line" 145 + done >> ~/.attic/logs/backup.log 146 + ``` 147 + 148 + ## Checking status 149 + 150 + To see if backups are running and how far along they are: 151 + 152 + ```bash 153 + # How many assets are backed up vs pending 154 + attic status 155 + 156 + # Check if the LaunchAgent is loaded 157 + launchctl list | grep attic 158 + ``` 159 + 160 + ## Stopping scheduled backups 161 + 162 + ```bash 163 + launchctl unload ~/Library/LaunchAgents/photos.attic.backup.plist 164 + launchctl unload ~/Library/LaunchAgents/photos.attic.verify.plist 165 + rm ~/Library/LaunchAgents/photos.attic.backup.plist 166 + rm ~/Library/LaunchAgents/photos.attic.verify.plist 167 + ``` 168 + 169 + ## Tips 170 + 171 + - **Dedicated Mac**: A Mac mini signed into iCloud Photos makes a good 172 + always-on backup machine. Enable "Prevent automatic sleeping" in System 173 + Settings > Energy. 174 + - **Network**: Backups need a stable internet connection. If uploads fail, the 175 + next run picks up where it left off. 176 + - **Disk space**: Attic stages files temporarily in `~/.attic/staging/` during 177 + export. Make sure there's enough free space for a batch (default 50 assets). 178 + - **iCloud-only assets**: If most of your library is iCloud-only, the first 179 + backup will download everything from iCloud. This can be slow on the initial 180 + run.