Files
FTStore/AGENTS.md
T
Felitendo 3b19eaa37b docs(agents): keep the author out of the commit log
The activity feed strips the author name and email, so the only route from a
commit back to the person who wrote it is the prose. The rule says so now:
write as the project rather than as a person, and keep location, timezone,
working hours, hardware and personal accounts out of both halves of a message.

It also covers which locales a string landed in. Naming one in a subject, or
quoting a translated string, places the person writing it about as precisely as
a location would, and that is the same leak through a different door.
Localisation in the abstract points nowhere, every app has it, and a language
picker or a count of catalogue entries is fine. The specific locale is the part
that stays out.

The catalogues themselves are untouched by this. They sit in a private repo and
the feed never reads them; this is a rule about the commit log alone.
2026-08-05 23:50:15 +02:00

40 KiB

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 (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 Team · website ftstore.app · repo FTStuff/FTStore. Every developerName / author field says FTStore Team, never a person and never a bare 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 + the public ftstore.app landing page at /. Was its own repo under a different name 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 (and, once an operator signs in, FTStore Admin) 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 published AltStore source manifest. Its one listing is FTStore Manager (pinned there by apply-rebrand.sh — the app itself is not listed), refreshed by stable-release.yml whenever the Manager ships. Stays at the repo root — the workflows 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/*, /api/ftstore-admin/*), which holds the GitHub PAT server-side. Do not "fix" an app by pointing it at a raw GitHub URL. admin/ itself has no proxy: 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.

The three proxy prefixes are not equivalent, and the difference is the security model:

Prefix Serves Gate
/api/ftstore-app/* FTStore (v* / dev) UA only — public to the app
/api/ftstore-manager/* FTStore Manager (manager-v* / manager-dev) UA only — public to the app
/api/ftstore-admin/* FTStore Admin (admin-v* / admin-dev) UA plus an Authorization: Bearer token for an approved admin account

web/src/lib/github/ftstore-app.ts is one resolver with three ProxyTargets; the tag namespace is what separates them, never the asset name alone.

ftstore-admin is credential-gated on the server, and that is the whole point. POST /api/ftstore-admin/unlock verifies a dashboard username and password with the same scrypt check as the dashboard login, and mints a sessions row; manifest and download each re-check that token (web/src/lib/ftstore-admin-access.ts). Nothing secret is compiled into manager/ — its only baked-in value is still the base URL, and the token lives in the keychain (ManagerKeychain, device-only, out of backups). So decompiling the Manager, flipping its unlock flag, or rebuilding it without ManagerAdminAccess at all yields a UI that asks the server for the console and is refused. Never move that check into the app, and never answer unlock with anything but one message for every failure mode — a distinct "no such user" would let anyone with the Manager enumerate operator accounts.

"FTStore Admin" always means both halves: web/ and admin/. They are one product with two front ends over the same API, so a request to change FTStore Admin is a request to change the dashboard and the console app. A feature that lands in only one of them is half-built: the operator who uses the phone can't reach it, or the one on a laptop can't. When you add an operator-facing control, add it to web/src/components/… and to admin/FTStoreAdmin/Console/… in the same change, and keep the wire types in step (web/src/types/config.ts ↔ admin/…/AdminModels.swift). The one asymmetry that is deliberate is presentation: the dashboard can afford a dense grid of icon buttons, the console app uses a Form, and neither has to imitate the other.

Three iOS apps, three audiences. ios/ is for everyone, manager/ is for getting ios/ (and, for an operator, admin/) 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. Installing the console is the one exception, and it is only an install: it sits behind the Manager's developer mode and the server's credential check, and the console's own screens still live only in admin/. Never bake a credential into admin/ either: its only compiled-in value is the base URL, and the server session is the whole security model.

The Manager tracks a set of products, not one app. ManagedProduct (manager/FTStore/Manager/ManagedProduct.swift) carries everything that differs between FTStore and FTStore Admin — bundle id, manifest path, URL scheme, whether the routes need the operator token, and the UserDefaults prefix for its install stamps. FTStoreUpdateManager is instantiated once per product (.shared for FTStore, .admin for the console) so the version/freshness rules are written once. FTStore deliberately keeps the unprefixed defaults keys it has always used, so an upgraded install does not forget which build it is on. Adding a third product should be a new case, not a second copy of the manager.

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.

Every product needs its own derived data, and the Makefiles enforce it. manager/ was forked from ios/ rather than recreated, so both build a project called FTStore.xcodeproj, with a scheme and a target called FTStore, out of a project.pbxproj carrying the same GUIDs — and those GUIDs are the key Xcode's build database uses. Only SRCROOT differs, which is not enough to tell them apart. Both Makefiles therefore key TMP on the product directory ($(TMPDIR)/$(NAME)-$(notdir $(CURDIR))), never on $(NAME) alone; admin/ does the same for the sake of the next fork.

This is not hypothetical tidiness. While they shared $(TMPDIR)/FTStore, the Manager build — which has no FTStoreActivity target — swept the app's Live Activity extension as a stale file, and the app build found the Manager's object files in its own intermediates. The shipped IPA ended up carrying a leftover FTStoreActivity.appex that no longer matched the app, so the Dynamic Island quietly rendered nothing. It only ever bit on the self-hosted Mac: the GitHub-hosted runners handed every job a fresh VM, hence an empty $(TMPDIR) and a clean build every time. Both release workflows now assert the extension is inside the IPA, because a missing appex breaks nothing else and says nothing.

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 stable-release.yml
    web-v* the FTStore Admin server web-release.yml
    manager-v* FTStore Manager stable-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.

  • Stable releases of the app and the Manager are dispatch-only. Run Actions → Stable Release, pick the product (ftstore / manager / both) and a version; leaving a version empty bumps the patch of that product's last stable release, falling back to its FTStore.xcconfig version when it has never had one. The workflow stamps the version, builds, checks the IPA really reports it, publishes a published (never draft) release, refreshes whichever app-repo.json carries that bundle id, and commits the version bump back to the branch so the next dev build stays ahead of stable. Pushing a v* / manager-v* tag by hand no longer builds anything — the old ios-release.yml / manager-release.yml / ios-update-repo.yml were removed because they produced draft releases, and FTStore Admin's stable channel filters drafts out (web/src/lib/github/ftstore-app.ts), so nothing ever reached a device until someone pressed "Publish" by hand. admin-v* still works the old way — the console app is sideloaded, not served to users.

  • Only the newest stable release of each product survives. The last job of stable-release.yml deletes every older v* (or manager-v*) release and its tag, keeping KEEP_RELEASES — currently 1. Nothing else is reachable from it: the pattern is anchored and numeric, so dev, manager-dev, admin-dev, web-v* and admin-v* cannot match, and the job is gated on needs: release, so a failed build deletes nothing.

    • KEEP_RELEASES also bounds the AltStore version history. Each entry in a manifest's versions array is a download URL into a release asset, so keeping more history than releases would publish dead links. The two are trimmed together in the same workflow — change one and change the other.
    • Updates do not depend on old releases surviving: the proxy resolves the newest non-draft, non-prerelease release carrying a matching asset, and make_latest keeps the README's /releases/latest/download/… button on it. What deletion does cost is the ability to roll back to a previous build, and any download already in flight from a retired asset.
    • web-delete-release.yml is unrelated and stays manual: it is the web-v* namespace, where the Docker images have to go too.
  • The landing page is the one public surface, and it spans two products. web/ serves ftstore.app at /; its default flow takes a .p12, a .mobileprovision and the password, appends FTStoreSeed/ to the Manager IPA's ZIP (which manager/'s ManagerSeedImporter imports at first launch), signs the result, and hands the visitor an itms-services:// URL so iOS installs it directly. The format version is a contract across the two (web/src/lib/landing/ipa-seed.ts ↔ ManagerSeedImporter.swift) — bump both or neither. Opening / did not loosen anything else — /admin, the feed, /d/<token>/… and every /api route keep their existing gate, and only /, /robots.txt and /api/public/* are reachable without one.

  • The signing step is server-side, and that was a deliberate reversal. This guide used to say the certificate must never leave the browser and that there must be no endpoint accepting a .p12. That rule made the page's output uninstallable: iOS verifies the code signature before an over-the-air install completes, so an IPA with a certificate merely inside it is refused at the sheet, and every visitor needed a second signing tool anyway. So POST /api/public/manager/install now takes the certificate, signs with zsign built from manager/Zsign into the app image, and parks the result in storage/install/<token>.ipa for the phone to collect. Ordering is load-bearing: the seed goes in before signing, because signing is what seals the bundle. What replaced the old rule, and what must hold:

    • the certificate exists only as files in a temp folder deleted in a finally (web/src/lib/signing/zsign.ts), and the signed build — which embeds it — expires in minutes (SLOT_TTL_MS), swept on every write;
    • the 128-bit slot token is the only access control, exactly as for /d/<token>/…, and everything unknown answers 404;
    • the page must not claim the certificate stays on the device. It does not.
    • web/Dockerfile builds from the repository root, not web/, because it compiles manager/Zsign. Dockerfile.worker still builds from web/.
  • The server domain is ftstore.app, hardcoded in all three apps (ios/…/ManagerRepoClient.swift, …/TweakRequestClient.swift, …/Storage+Sources.swift, ios/…/Utilities/FTStoreLinks.swift, manager/…/ManagerConfig.swift, admin/…/AdminConfig.swift). DownloadManager.swift still allowlists the pre-rename host — that check is permissive, not identifying, and dropping it would break installs whose stored override still names it.

  • The public addresses users are sent to live in FTStoreLinks — the website and the Telegram channel (https://t.me/ftstore_app), which the Settings banner, the About screen's Links section and its "FTStore Team" credit all read from. web/'s landing page keeps its own copy in web/src/components/landing/footer.tsx; if the channel ever moves, both change.

  • The Telegram banner in Settings is dismissed once and for all. Closing it sets FTStore.telegramBannerDismissed in UserDefaults, which survives an app update, and nothing may clear it, expire it or key it to a version. It is an invitation, and re-showing it after every release would make it an advert. Only "Reset Settings", which drops the whole persistent domain, brings it back.

  • Docker images: ghcr.io/<owner>/ftstore-admin-app and -worker. The Compose project is pinned to name: ftstore so volumes stay ftstore_* regardless of the checkout directory.

  • The Store tab never lists FTStore Manager. The Manager is what keeps FTStore updated, so it belongs in My Apps as a permanent row and nowhere else. The filter is one line in SourceAppsTableRepresentableView._allAppsWithSource rather than a rule about which source may list it, so it holds whichever repository the entry turns up in. The seeded default source is the FTStore feed, named FTStore; installs made before that rename are corrected in place by Storage.addDefaultSourceIfNeeded.

  • Store tags are decided in one place. Official, Recommended and the red Paid are built by ASRepository.App.storeTags (ios/…/Views/Sources/Apps/StoreTagView.swift) and drawn by StoreTagsView, which both the list cell and the detail page use. Add a tag there, not at a call site, or it appears on one screen and not the other. Only Official and Recommended get the highlighted "featured" cell background: Paid is a warning, and giving it the promotional treatment would read as an endorsement.

  • A paid tweak carries paid and pricing in ftstore.json. paid is the tag and stands alone: a tweak can be known to cost money before anyone has written down what it costs. pricing holds monthly / yearly / lifetime plus an optional currency (absent means USD), and only the tiers the operator filled in are published — normalizePricing (web/src/lib/pricing.ts) drops blanks and zeroes, and the store lists exactly what arrives. A missing tier is not a free tier; never render a placeholder row for one. Prices are dropped entirely when paid is cleared, so stale figures can't outlive the flag.

  • The store's identity is a constant, not a setting. FTStore is the only thing this server publishes, so its name, subtitle and description live in web/src/lib/brand.ts and nowhere else. The source_* settings that used to hold them are gone, along with the "Source Metadata" card, /api/branding and the useBranding hook. The feed identifier is derived from the name (xyz.ftstore), so an operator who renamed the store orphaned every client that had already added it. site_domain survives in a "Server" card because it is deployment plumbing, not branding.

  • IPA downloads always require FTStore's User-Agent. /d/<token>/<name>.ipa checks it unconditionally (web/src/app/d/[token]/[name]/route.ts). There used to be a download_require_app_ua setting in front of that check, and turning it off put every build on the open web behind nothing but the token in the URL. The setting, its switch in Settings → General and the console's "Feed & Storage" group are all gone. Because a copied link is therefore a dead link anywhere but the app, ios/ has no "Copy Download URL" action either.

  • github_owner / github_repo / github_branch do not exist. The app repo is hardcoded in the Manager proxy and every scanned repository is configured per source under Channels & Sources, so those three settings were read by nothing. github_token is still live: it is the fallback the Manager proxy and the activity feed use when ftstore_app_github_token is blank (web/src/lib/github/ftstore-app.ts, web/src/lib/github/activity.ts).

  • FTStore Admin is bilingual, and the operator's own machine picks. Both halves follow the system locale and neither offers a language switch: a console that disagrees with the rest of the device is a bug, and a stored preference would be one more thing to restore from a backup.

    • admin/ ships FTStoreAdmin/Resources/Localizable.xcstrings (en + de) and iOS chooses. Most of the app needs no call-site change, because SwiftUI reads a literal in Text/Button/Label/Section/.navigationTitle/.alert as a LocalizedStringKey. String.localized(…) (Support/AdminLocalization.swift) is for the other half: a String built before it reached a view, which SwiftUI renders verbatim. de must stay in the project's knownRegions or the catalog is never compiled in.
    • web/ resolves the locale from Accept-Language in the two /admin layouts and hands it down through LocaleProvider; useT() translates. Resolving it on the client instead would render English on the server and German after hydration, i.e. a mismatch on every translated string.
    • Both dictionaries are keyed by the English source string. A key with no German entry renders as English, so adding a string is never a two-step change and forgetting a translation is a cosmetic gap, not a broken screen. Server-supplied text (an API error, a log line) is never translated.
    • The public landing page at / is not part of this and stays English.
  • Deciding a tweak request is approve or reject, and nothing else. Approving used to be able to create the scraper source in the same step, which meant one tap both answered the requester and started scraping a channel nobody had looked at. Adding a source is its own action in Channels & Sources. Do not reintroduce an addSource flag on PUT /api/tweak-requests.

  • The feedback board is public, and that is the whole design. Settings → Feedback is a shared list of bug reports and feature requests that every FTStore user can read, reply to and react to, answered by operators from /admin/feedback and AdminFeedbackView. It is the second thing the app writes to, and it is not a second tweak-request queue:

    Tweak requests Feedback
    Who can read one only the install that sent it everyone
    Answered by approve / reject a conversation plus a status
    Decided in bulk, per source one ticket at a time
    Grouped yes, by sourceId never

    What follows from "public", and must keep holding:

    • serializeTicket / serializeComment are the boundary (web/src/lib/feedback.ts). The "app" audience must never emit installId, clientVersion, systemVersion, deviceModel, authorUserId or operatorName. A bug reporter hands over their device model for triage, not to the board.
    • A team reply is authored by "FTStore Team", never by a person. The operator account is stored on FeedbackComment.authorUserId for the audit trail and stripped before the app sees it, and the team badge is drawn from authorRole, never from the display name. RESERVED_NAME_KEYS refuses the names that would imitate it.
    • A status change is written into the thread, as a FeedbackComment carrying statusFrom/statusTo and optionally a note. That is why there is no events table, and why "why was this declined?" is still answerable later. Do not turn the status into a silent column update.
    • Moderation has to stay reachable: hidden on a ticket and on a comment (reversible, and its author still sees it marked as removed), FeedbackAuthor.blocked to mute an install without deleting what it wrote. Deleting is admin-only, because it destroys other people's replies too.
    • The display name lives on FeedbackAuthor, keyed by install, and nowhere else. That is what makes a rename retroactive. Never denormalise it onto a ticket or a comment.
    • FeedbackReaction has no unique index and cannot have one: commentId is nullable and Postgres treats NULLs in a unique index as distinct. toggleReaction dedupes in a transaction, and every count is distinct installId.
    • The app's install id is the same one tweak requests use (FeedbackManager.installId reads TweakRequestManager.installId). One phone is one person; a second mint site would become a second identity.
  • Registration can be closed (registration_enabled, Settings → Users). POST /api/auth/register enforces it; the page's GET is cosmetic. The very first account is always allowed through, so a fresh install cannot lock itself out.

⚠ Commit messages on main are PUBLIC — subject and body

ftstore.app renders an activity feed built from this repository's commit log (web/src/lib/github/activity.ts → web/src/components/landing/activity.tsx). Both repositories are private, but the whole commit message of every commit on the default branch is published on a page anyone can load — the subject in the list, the body in the popup that opens when a row is clicked.

So, before writing a commit message:

  • The whole message is release copy. Write it for a stranger. Do not name unreleased plans, customer or user identities, hostnames, internal paths, or anything about a security fix beyond what it fixes.
  • A security fix goes in as fix: with a neutral subject. "fix: reject malformed manifests" is fine; naming the bypass is an advisory published before the fix has reached anyone's device — and that applies to the body, which is where the temptation to explain the bypass properly lives.
  • The body is not a private place. It used to be discarded server-side and this rule used to say so. It is not any more. Anything that must not be published goes in neither half of the message.
  • Nothing about the author. The feed strips the author name and email, so the only way anything personal reaches it is the prose. Write as the project, never as a person: no first person, no location, no timezone, no working hours, no employer, no hardware, no personal accounts or handles, and nothing that lets a reader place who writes this.

This includes the languages the app is translated into. A commit that says a string was translated into a specific language says roughly where the person writing it is, which is exactly what the rule above is for. Much of this app is translated into German and more will be, so this comes up constantly:

  • Never name a target language in a subject or a body, and never quote a translated string. lang: add german strings, keep en/de in sync and "Install Duplicate" (de: "Duplikat installieren") are both leaks; the second publishes the language in the words themselves.
  • Write the mechanism, not the locale. "translate the remaining UI strings" and "every string the Manager shows now goes through the catalogue" say what landed without saying for whom. Localisation in the abstract points at no country; every app has it. A named locale points at one.
  • lang: as a commit type is fine and so is a language picker or a count of catalogue entries. It is the specific locale that must not appear.
  • The catalogue files themselves are not covered by this. They live in a private repo and are not published by the feed. This rule is about the commit log.

The feed also publishes how many lines each commit added and removed, next to the date. That is two integers about the size of a change: it names no file and quotes no line, and it cannot be run backwards into a diff.

What the feed strips, so nothing here is load-bearing on the filter: the author name and email, git trailers and any line carrying an email address, the SHA, file names and the diff itself. It also drops a commit outright when the subject looks like it carries a credential (token shapes, long opaque strings, the words secret / token / password / credential / api key / private key / .env), and drops the body alone when the body does. That is a backstop for accidents, not a licence — it cannot recognise a leak that reads like ordinary English.

The feed is also the only route that reads this repository's commit log. If you add another public endpoint that touches the repo, apply the same rule: publish a whitelist of fields, never a filtered dump.

Copy style: no em-dashes in anything a user reads

Do not use — (em-dash) in user-facing text. It reads as machine-written, and the codebase now has none. This covers every string a person can end up looking at:

  • Localizable.xcstrings in ios/ and manager/ — both the key (the English source string) and every translation. A rename means editing the .localized("…") call site and the catalog key together.
  • Swift string literals that reach the screen: Text, Label, alert titles and messages, errorDescription, notification bodies, admin/'s hardcoded copy.
  • web/ UI copy, toasts, aria-labels, API error messages and log lines (the dashboard renders the log).
  • app-repo.json (localizedDescription, subtitle, news[].caption) and the README.

Rewrite instead of substituting a different dash. A sentence with an em-dash almost always wants a full stop, a comma, or a colon:

Instead of Write
The download failed — the server returned HTTP %@. The download failed. The server returned HTTP %@.
Public channels only — private invite links can't be read. Public channels only, private invite links can't be read.
IPA catalog — search, inspect, upload… IPA catalog: search, inspect, upload…

Two things are not covered, and both are deliberate:

  • A bare — as an empty-value placeholder (value ?? "—", if (!bytes) return "—"). That is a typographic glyph standing in for "no data", not prose, and every table in the console uses it.
  • Source comments. They are not user-facing, and this guide's own tables and prose use them. Don't churn comments to satisfy the rule; do apply it the moment a comment's wording becomes a string.

Separators inside a composed line use ·, not a dash: %@: %@ for a label/value pair, \(version) · \(status) for two facts side by side.

To check before committing:

grep -rn '—' ios/FTStore manager/FTStore admin/FTStoreAdmin --include=*.swift \
  | grep -vE ':[0-9]+:\s*(//|///|\*)' | grep -v '"—"'
grep -rn '—' web/src --include=*.ts --include=*.tsx \
  | grep -vE ':[0-9]+:\s*(//|/\*|\*)' | grep -v '"—"'
python3 -c "import json;d=json.load(open('ios/FTStore/Resources/Localizable.xcstrings'));\
print([k for k in d['strings'] if '—' in k])"

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?"
    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 admin/, and the grep/sed pair that reads and rewrites FTSTORE_PROJECT_VERSION in all three dev-repo workflows, were renamed by hand and are outside the script's scope; they cannot drift, because none is ever merged from upstream. Those three workflows must keep stamping the version through the xcconfig variable, not through agvtool new-marketing-version: every target sets MARKETING_VERSION = $(FTSTORE_PROJECT_VERSION) with GENERATE_INFOPLIST_FILE = YES, so Xcode regenerates the key agvtool wrote and the IPA ships the base version while the AltStore source advertises the dev one. 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)

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.

grep -rni "ftrepo" ios manager admin web .github scripts --exclude=package-lock.json

The old name is gone from prose everywhere. What is left is the set of places where it is a value somebody else already stored, plus the one document whose job is to name it. Those must survive; a hit anywhere else is a regression, and the fix is to reword the sentence, never to change a value:

  1. web/src/lib/settings/backup.ts — LEGACY_SETTINGS_BACKUP_TYPES, the type string in backups taken before the rename.
  2. manager/…/ManagerConfig.swift — manager.ftrepoBaseURL, the pre-rename UserDefaults key, read once and migrated.
  3. ios/…/DownloadManager.swift — the pre-rename host in the User-Agent allowlist.
  4. web/src/lib/github/activity.ts — BLOCKED_NAMES, which is what keeps the name out of the public feed, and its fixtures in activity.test.ts. Deleting either publishes the thing it was meant to hide.
  5. web/README.md — the "Migrating from FTRepo" section. It exists to tell an operator which Docker images, Postgres role and mount path to change, and every one of those is literally named after the old brand, so the section cannot do its job without saying it. Delete the section outright once nobody is left to migrate; do not hollow it out.

For web/:

cd web && npm ci && npx prisma generate && npx tsc --noEmit && npx eslint .
find src -name '*.test.ts' -exec npx tsx --test {} +          # 144 tests
npm run build

Build verification is per product, all on macOS: stable-release.yml (or ios-dev-repo.yml) / make in ios/ for the app, stable-release.yml (or manager-dev-repo.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. stable-release.yml takes a dry_run input that builds and verifies without tagging or publishing anything.

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.