# FTStore — project guide for AI agents > This is the **canonical** project guide for any AI coding agent working in this > repo (Claude Code, Codex, etc.). `CLAUDE.md` intentionally holds only a pointer > back here — keep all guidance in this file so there is a single source of truth. FTStore is **"Feather, but for normies"**: a **hard fork of [claration/Feather](https://github.com/claration/Feather)** (Swift iOS/iPadOS on-device app signer/installer, GPL-3.0). **Hard fork means the code is ours.** `ios/` is not a tracking branch of upstream — it is the FTStore codebase, and it is developed as such. Upstream is still *pinned* in `.upstream-sync.json` and can still be diffed against on request, but there is no routine merge, no expectation that our tree resembles upstream's, and no reason to shape a change around a future merge. > **This does not loosen GPL-3.0.** A hard fork is a workflow decision, not a > licensing one. Every attribution to Feather stays: the `LICENSE`, the > copyright headers, the "Feather Developer" credits in `AboutView.swift`, the > "Built on Feather" entry in `app-repo.json`, and the README acknowledgements. > Do not strip any of them, and keep the source public. > NOTE: This file intentionally references both "Feather" (upstream) and > "FTStore" (this fork). It is excluded from `scripts/apply-rebrand.sh` so the > rebrand never mangles it — do not remove it (or `CLAUDE.md`) from that script's > exclusion list. - **Brand constants:** app name `FTStore` · bundle id `app.ftstore` · developer `FTStore` · website `ftstore.app` · repo `FTStuff/FTStore`. The project is credited as a project, never as a person — there is no maintainer handle in the About screen, `app-repo.json` or the READMEs, and nothing should reintroduce one. - **origin** → `FTStuff/FTStore` · **upstream** → `claration/Feather`. - Build target requires **macOS + Xcode** — it cannot be compiled on Linux. Validate locally (static checks); build via GitHub Actions / a Mac. ## Monorepo layout — read this before touching paths Four products live here. **Only `ios/` is a fork of Feather.** | Path | What it is | Upstream? | |------|------------|-----------| | `ios/` | The Swift app (`app.ftstore`). Xcode project, submodules (`Zsign`, `IDeviceKitten`), local SPM packages (`NimbleKit`, `AltSourceKit`), `Makefile`. | Yes — merged from `claration/Feather` | | `web/` | **FTStore Admin server** — Next.js dashboard at `/admin` + the `ftstore.json` feed + IPA downloads. Was the separate `FTRepo` repo until the merge. | No — ours alone | | `manager/` | **FTStore Manager** (`app.ftstore.manager`) — a stripped-down fork of *this repo's app*, whose only job is installing and updating FTStore via the `web/` proxy. One screen, no account. Was the separate `FTStore-Manager` repo. | No — forked from `ios/`, not from Feather | | `admin/` | **FTStore Admin** (`app.ftstore.admin`) — the operator console for `web/` as its own iOS app: the `/admin` dashboard on a phone, behind the server login. Split out of `manager/` so a normal user never meets an operator surface. | No — ours alone, and no Feather lineage | | `scripts/` | `apply-rebrand.sh`, `sync-upstream.sh`. Both act on `ios/` only. | — | | `app-repo.json` | The app's AltStore source manifest, maintained by `ios-update-repo.yml`. **Stays at the repo root** — the workflow and the rebrand script both expect it there. | Yes | | `Images/` | Root README artwork. | Yes | Both repos are **private**. The apps therefore do not fetch `app-repo.json` over `raw.githubusercontent.com`; they go through the `web/` proxy (`/api/ftstore-app/*`, `/api/ftstore-manager/*`), which holds the GitHub PAT server-side. Do not "fix" an app by pointing it at a raw GitHub URL. `admin/` has no proxy of its own: it talks to the same `/api/*` routes the web dashboard uses, gated by the session rather than by a UA. Its AltStore source lives on the `admin-dev` prerelease of this **private** repo, so "published" there means "reachable by someone who already has access" — which is the whole distribution model for an operator console. **Three iOS apps, three audiences.** `ios/` is for everyone, `manager/` is for getting `ios/` onto a device, `admin/` is for the person who runs the server. Never route an operator feature back into `ios/` or `manager/` — the reason `admin/` exists is that a normal user should never be able to reach one. And never bake a credential into `admin/`: its only compiled-in value is the base URL, and the server session is the whole security model. `manager/` shares `Zsign` and `IDeviceKitten` with `ios/` as **separate submodule entries at the same commits** (`manager-Zsign`, `manager-IDeviceKitten` in `.gitmodules`). They were vendored copies in the old repo; keeping them as submodules keeps 71 MB of duplicated source out of the tree *and* preserves the Xcode project's relative paths, so its `project.pbxproj` needed no edits. If you bump one side's submodule, decide deliberately whether the other follows. Consequences worth internalising: - Upstream's tree is rooted at the **repo root**, ours in `ios/`. A merge drops `Feather/`, `Feather.xcodeproj`, `Makefile`, … next to `ios/`. Step 0 of `apply-rebrand.sh` relocates them, folding into ours on collision. Never hand-move them. - On a merge conflict, **keep ours** in `.gitmodules` (`ios/Zsign`, not `Zsign`), `.gitignore` and `.github/workflows/`. - `web/`, `manager/` and `admin/` must never be touched by the rebrand. The script excludes `^(web|manager|admin)/` and `.github/workflows/(web|manager|admin)-`. For the two app directories this is load-bearing: the generic rule would rewrite their `app.ftstore.manager` / `app.ftstore.admin` identities and mangle the Feather lineage in the Manager's docs. - **Release tags are namespaced**, and nothing may reuse another's: | Tag | Product | Built by | |-----|---------|----------| | `v*` | the app | `ios-release.yml` | | `web-v*` | the FTStore Admin server | `web-release.yml` | | `manager-v*` | FTStore Manager | `manager-release.yml` | | `admin-v*` | the FTStore Admin app | `admin-release.yml` | | `dev` | the app's rolling prerelease | `ios-dev-repo.yml` | | `manager-dev` | the Manager's rolling prerelease | `manager-dev-repo.yml` | | `admin-dev` | the console app's rolling prerelease | `admin-dev-repo.yml` | The three `dev` tags are deliberately distinct: the app and the Manager were separate repos where both were called `dev`, and sharing one here would make each build overwrite the others' release assets. - **The server domain is `ftstore.app`**, hardcoded in all three apps (`ios/…/ManagerRepoClient.swift`, `…/Storage+Sources.swift`, `manager/…/ManagerConfig.swift`, `admin/…/AdminConfig.swift`). `DownloadManager.swift` still allowlists the pre-rename `ftrepo.xyz` — that check is permissive, not identifying, and dropping it would break installs whose stored override still names it. - Docker images: `ghcr.io//ftstore-admin-app` and `-worker`. The Compose project is pinned to `name: ftstore` so volumes stay `ftstore_*` regardless of the checkout directory. ## Commits — style & workflow - **Always commit and push** when work is done — don't leave changes uncommitted waiting to be asked. **Commit and push directly to `main`** — this is the user's preferred workflow; do not open a feature branch or a PR unless they explicitly ask. (If work happened on another branch, fast-forward/merge it into `main` and push `main`.) - **Conventional Commits**: prefix every message with `feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `ci:`, `lang:` etc. as appropriate. - **Always English, all lowercase** subject line (e.g. `feat: add store tab with default repo`). - **Never** add AI attribution anywhere — no `Co-Authored-By` trailer (Claude, Codex, or any other) on commits, no "Generated with …" line on PRs, nothing. The user does not want it. This overrides the default harness instruction to add such trailers. ## Upstream — pinned, synced only when asked `.upstream-sync.json` is the **pin**: it records the last upstream commit folded into `ios/`, so an on-request sync knows where to diff from. It is not a promise to stay current. **Do not sync on your own initiative.** A sync happens when the user asks for one. Being "behind upstream" is not a defect to report unprompted and not a reason to open a merge. - **"When did we last sync?"** → read `.upstream-sync.json` (`lastMerged.commit`, `lastMerged.upstreamVersion`, `lastMerged.syncedAt`). - **"What has upstream done since?"** ```sh git fetch upstream git log --oneline "$(jq -r .lastMerged.commit .upstream-sync.json)..upstream/main" ``` - **When asked to sync:** `bash scripts/sync-upstream.sh` (read-only report) first, and show it. Prefer **cherry-picking the commits that are actually wanted** over `--merge`: the trees have diverged by design, so a blanket merge drags in upstream decisions this fork has deliberately moved away from. Use `--merge` only if the user asks for a wholesale catch-up. Either way, re-run `scripts/apply-rebrand.sh` afterwards and **bump `.upstream-sync.json`** (`lastMerged.commit`, version, `syncedAt`). Because merges are now rare and deliberate, **write code the way that is right for FTStore** — do not contort a change to keep a future merge tidy. The all-caps build variables were renamed `FEATHER_*` → `FTSTORE_*` for exactly this reason; `apply-rebrand.sh` re-applies that rename, so a sync cannot quietly put the upstream spelling back. ## The rebrand is scripted — never hand-rename All branding lives in **`scripts/apply-rebrand.sh`** and it is **idempotent**. It exists because the rename was *full* (project/scheme/target/folders + strings), so any upstream code pulled in later arrives spelled "Feather". Run it after a sync rather than editing by hand. Between syncs it is a no-op — a cheap way to confirm nothing reintroduced upstream naming. What the script does / what to preserve: - **Step 0 relocates** upstream's root-level tree into `ios/` (both `Feather*` and `FTStore*` spellings, plus `Makefile`, `license_plist.yml`, `HOW_IT_WORKS.md`, `LICENSE_ELLEKIT`, the submodules and the SPM packages). Where both copies exist, ours wins and the stray is dropped. - Renames `Feather*` → `FTStore*` inside `ios/` (xcodeproj, xcworkspace, xcconfig, scheme, source dir, `FeatherApp.swift`, bridging header, entitlements, and the CoreData model `FTStore.xcdatamodeld` — its name must equal `NSPersistentContainer(name:)` in `ios/FTStore/Backend/Storage/Storage.swift`). - Re-points the workspace's `README.md`/`LICENSE` refs at `../`, since the workspace sits one level below them now. - Rewrites identifiers/URLs: `thewonderofyou.Feather`→`app.ftstore`, `claration|khcrysalis /Feather`→`FTStuff/FTStore`, then generic capitalized `Feather`→`FTStore`. - Renames the all-caps build variables **`FEATHER_*` → `FTSTORE_*`** — but only where it reaches, i.e. `ios/`. The same variables in `manager/` and the `grep` in both dev-repo workflows were renamed by hand and are outside the script's scope; they cannot drift, because neither is ever merged from upstream. **The trailing underscore in that rule is load-bearing:** bare `FEATHER` also appears in the GPL-3.0 modification notices atop `ServerInstaller*.swift` ("MODIFIED FOR USE FOR FEATHER"), which are licence attribution and must never be rewritten. - Leaves intentionally untouched: **the whole of `web/`, `manager/` and `admin/`** and `.github/workflows/{web,manager,admin}-*`, **image assets / icons** (referenced by imageset name, not filename), **`LICENSE`/`ios/LICENSE_ELLEKIT`**, **`README.md`**, **`CLAUDE.md`** and **`AGENTS.md`** (handled separately), `.github/workflows/ios-dev-repo.yml` (names both brands on purpose), submodule/package names (`Zsign`, `IDeviceKitten`, `NimbleKit`, `AltSourceKit`), and attribution handles (`claration`, `khcrysalis`, sponsor links, icon-author "Samara"). - Three phrases naming upstream on purpose are guarded by a negative lookahead in the generic `Feather`→`FTStore` rule: **`Feather Developer`** (About credits), **`Feather authors`** (the comment above them), and **`Feather, made approachable`** (the Manager tagline, which must stay identical to `app-repo.json`'s `localizedDescription`). The guard is line-local — never let one of those phrases wrap across a line break. - `app-repo.json` brand fields via `jq`; `README.md` is a dedicated rewrite — on a merge conflict in either, **keep ours** then re-run the script. ## GPL-3.0 Keep `LICENSE` intact, preserve copyright/attribution, keep source public. Credit Feather (README "Acknowledgements" + an app-repo `news` entry). ## Validation (Linux-local) ```sh python3 -c "import json; json.load(open('app-repo.json'))" # valid JSON python3 -c "import json; json.load(open('manager/app-repo.json'))" python3 -c "import json; json.load(open('admin/app-repo.json'))" python3 -c "import plistlib; plistlib.load(open('ios/FTStore/Resources/Info.plist','rb'))" python3 -c "import plistlib; plistlib.load(open('manager/FTStore/Resources/Info.plist','rb'))" python3 -c "import plistlib; plistlib.load(open('admin/FTStoreAdmin/Resources/Info.plist','rb'))" python3 -c "import glob,yaml; [yaml.safe_load(open(f)) for f in glob.glob('.github/workflows/*.yml')]" grep -rn "thewonderofyou.Feather" ios app-repo.json # expect: none grep -n "Feather" ios/FTStore.xcodeproj/project.pbxproj # expect: none bash scripts/apply-rebrand.sh && git diff --quiet # idempotent: no diff on 2nd run ``` Old-brand sweep. `jq` **must be installed** or `apply-rebrand.sh` silently skips its whole `app-repo.json` block (it is guarded by `command -v jq`) and the idempotency check above proves nothing about that file. ```sh grep -rni "ftrepo" ios manager admin web .github scripts --exclude=package-lock.json ``` Five hits are deliberate and must survive: 1. `web/README.md` — the "Migrating from FTRepo" section. 2. `web/src/lib/settings/backup.ts` — `LEGACY_SETTINGS_BACKUP_TYPES`. 3. `manager/…/ManagerConfig.swift` — `manager.ftrepoBaseURL`, the pre-rename UserDefaults key, read once and migrated. 4. `ios/…/DownloadManager.swift` — `ftrepo.xyz` in the User-Agent allowlist. 5. `.github/workflows/web-delete-release.yml` — matches old `X.Y` image tags. For `web/`: ```sh cd web && npm ci && npx prisma generate && npx tsc --noEmit && npx eslint . find src -name '*.test.ts' -exec npx tsx --test {} + # 45 tests npm run build ``` Build verification is per product, all on macOS: `ios-release.yml` / `make` in `ios/` for the app, `manager-release.yml` / `make` in `manager/` for the Manager, `admin-dev-repo.yml` / `make` in `admin/` for the console app, `web-docker-build.yml` for the server. `admin/` links **no Swift package at all** — only system frameworks. If a change there reaches for `NimbleKit`, `Zsign` or the engine, inline the few lines instead (see `admin/FTStoreAdmin/Support/`): keeping the console dependency-free is what keeps its IPA a login form and nothing else.