easy_setup
Take a Flutter app from "code exists" to "live in both stores" from one
easy_setup.yaml — without opening Xcode, and past each vendor's
one-time setup, without opening a web console again. The handful of
steps no API offers (the App Store Connect app record, the first Play AAB
upload, an Amplitude project) stay one-time visits that doctor walks you
through.
Two halves, one config file:
- Setup Kit — provisions the services (Sentry, Amplitude, Firebase, AdMob), writes the native config (entitlements, Info.plist, Xcode project settings), and renders the store assets (app icon, screenshots, promo site) from text sources in git.
- Deploy Kit — code signing via fastlane match, builds, and uploads to TestFlight and Play — locally and in CI, through the same command.
Every step is idempotent: run it twice, get the same tree. The yaml is the
intent, and setup converges the project onto it.
Install
dart pub global activate easy_setup
Everything else — Chrome for store assets, Xcode + simctl for capture,
fastlane for deploy, gcloud as the least-effort AdMob credential — is only
needed by the steps your config actually uses. easy_setup doctor reports
what is missing for your yaml, with step-by-step issuance guidance for
every key — and doctor --json emits the same report as one machine-readable
document (category, status, title, detail, fix per check), so an AI agent can
tell exactly what is blocked without scraping terminal output.
Quick start
On a new app setup runs twice. The first pass provisions the services
and seeds the design sources together with the Claude skills that
edit them; the second pass renders and uploads what those skills
produced. The few steps no API offers go to a human as a checklist from
doctor — in parallel, the pipeline never waits on them.
easy_setup create my_app # brand-new app: flutter create (ios,android) + init
easy_setup init # existing app: easy_setup.yaml + store_info + skeleton
# → uncomment the sections you need in easy_setup.yaml
easy_setup doctor # what is missing — hand the console items to a human now
# → /store-listing: the store copy (init seeded the skill)
easy_setup setup # 1st pass: provisions, seeds the screenshot/site sources,
# installs /app-icon /app-site /store-screenshots.
# Expect a non-zero exit — it lists what is still waiting.
# → first commit here: ios/.gitignore now covers Runner.xcodeproj
# → write the app, then /app-icon → /app-site → /store-screenshots
easy_setup capture # simulator tour → raw store screenshots
easy_setup setup # 2nd pass: renders the assets, uploads the listing
easy_setup certs # iOS signing for local device builds (optional)
easy_setup deploy # build + upload (TestFlight / Play track)
Or hand the whole thing to Claude: /release-playbook (seeded by init)
knows this order, reads doctor --json, and routes the console-only work
to you while it keeps going.
Commands
| Command | What it does |
|---|---|
create |
Run flutter create (--org, --platforms ios,android by default), then init inside the new project — the yaml's IDs match what flutter generated. Seeds AGENTS.md (+ a CLAUDE.md import of it): the coding-agent guide. --workspace scaffolds a melos monorepo around the app; --template <dir> scaffolds your own structure |
init |
Write an easy_setup.yaml template + a commented easy_setup_store_info.yaml (seeded once, never overwritten) + asset folder skeleton + the CI caller workflow + the /release-playbook and /store-listing skills |
doctor |
Verify tools, keys and secrets, with issuance guidance; --json for machines |
setup |
Apply the declared state; --only <step> runs one step, --adopt reads existing AdMob ad units into the yaml once |
capture |
Boot the right simulators, freeze the status bar, run your tour, save raw screenshots |
certs |
iOS signing for local builds: match (development/adhoc/appstore) + the Xcode project |
deploy |
Build and upload — iOS TestFlight (--submit for review), Android Play track |
flavor, ci-cd |
v1 commands on the old easy_setup: schema — see doc/v1.md |
Global flags: --dry-run / -n previews every change without touching
anything; --project-root / -p points at a project elsewhere.
setup runs its steps in order — sentry → amplitude → ios_project → firebase → admob → ios_capabilities → branding → screenshots → site → store — and skips whatever the yaml does not declare. ios_project runs
before firebase because flutterfire configure needs the generated
Xcode project.
A failing step does not stop the run. The first pass against a new app
always has something not ready yet — that is the point of doing the
console work in parallel — so an unavailable credential must not hold back
the steps that have nothing to do with it. The failure is printed where it
happens, listed again at the end, and the command exits non-zero;
--stop-on-error restores fail-fast. The exception is a step that works
from another's output: store publishes the site's URLs and the framed
screenshots, so it is skipped rather than run on half-written input when
site, screenshots or github failed. Rerun after fixing — setup is
idempotent, so what already converged does nothing.
Scaffolding your folder structure
create can build the whole repository shape around the app, not just the
app:
easy_setup create my-product --workspace # built-in melos monorepo
easy_setup create my-product --template ~/tpl # your own structure
--workspace scaffolds a melos-managed monorepo — flutter_app/ with the
workspace pubspec, shared lints, a mason make page brick, an FVM pin of
your installed Flutter, script/bootstrap.sh, an architecture doc, the
app at apps/customer, an empty packages/ for shared code extracted
later, and the agent guide (AGENTS.md + CLAUDE.md) at the repo root. The directory names the repository while the Dart package is named
after it (my-product → package my_product at apps/customer) — pass
--project-name to override.
Scaffolded IDs are <org>.<name>.<app directory>:
create safezone --workspace --org studio.etch gives
studio.etch.safezone.customer — the same on iOS and Android (underscores
stripped, no camel-casing), with the whole native layout generated by
flutter itself and only the Dart package renamed back to safezone
afterwards. --org defaults to com.example and must be reverse-domain,
letters and digits only — flutter create would silently strip anything
else from the iOS bundle ID. A --template marker at modules/main
yields <org>.<name>.main the same way.
--template <dir> scaffolds a directory of your own instead: every file
is copied with {{es:name}}, {{es:app_name}}, {{es:org}},
{{es:bundle_id}}, {{es:package_name}}, and {{es:flutter_version}}
substituted in paths and text content (binary files pass through, and
mason's own {{...}} placeholders survive — the es: namespace exists
for exactly that). Put an empty .easy_setup_app marker file in the one
directory where the Flutter app should be created.
Either way create then initializes git at the scaffold root (so the CI
caller workflow lands at the repository root with project-root wired to
the app path), runs flutter create at the marker, and seeds the
easy_setup files inside the app.
easy_setup.yaml
Top-level sections, all optional except app:
app:
name: MyApp
bundle_id: com.example.myapp # iOS (applied to the Xcode project)
package_name: com.example.myapp # Android
ios:
team_id: XXXXXXXXXX
match_git_url: git@github.com:my-org/certificates.git
release: manual # after approval: manual | automatic | phased
deployment_target: "26.0" # default; project spec + Podfile +
# AppFrameworkInfo. Lower it to reach
# older devices
capabilities:
- push_notifications # entitlements + Developer Portal
- app_groups: [group.com.example.myapp]
- associated_domains: ["applinks:example.com"]
- sign_in_with_apple
- keychain_sharing: [com.example.myapp]
background_modes: [audio, fetch] # Info.plist UIBackgroundModes
info_plist: # any scalar Info.plist key
NSCameraUsageDescription: Photos you attach are taken with the camera.
ITSAppUsesNonExemptEncryption: false
privacy_manifest: true # seed PrivacyInfo.xcprivacy once
localizations: # <locale>.lproj/InfoPlist.strings
ko: { CFBundleDisplayName: 마이앱 }
android:
play_track_default: internal # internal | alpha | beta | production
build:
dart_define_file: env.prod.json # default: env.prod.json when it exists
branding:
icon_src: assets/branding/icon/ # icon.svg (preferred) or icon.png
screenshots:
locales: [ko, en-US]
devices: [iphone_6_9, ipad_13, android_phone]
sentry:
org: my-org
project: myapp # created when missing, DSN fetched
amplitude: # key verification + env injection
firebase:
project_id: my-org-myapp # created when missing
analytics: true
firestore: asia-northeast3 # default database, created there
account: you@example.com # which Google account the CLI uses
admob:
ad_units:
banner_main: { type: banner } # IDs resolved per run via the API
site: # promo site → GitHub Pages
github: # opt-in: match repo · CI secrets · Pages via gh
privacy:
collects: [email_address, other_user_content] # what the app itself stores
Store listing copy, review information and age rating live in a second
file, easy_setup_store_info.yaml — limits are enforced at parse time and
setup --only store uploads both stores through fastlane — and
hard-blocks while site pages the listing links still carry TODO
markers, because App Review opens those URLs and a placeholder privacy
page is a rejection (self-hosted URLs are never scanned). Declaring
pricing: free there also converges the App Store price schedule and
territory availability over the official ASC API — a new app record has no
price, and that alone blocks the first submission (only free is
supported: paid apps need the paid-apps contract signed in the console
first). The App Privacy
("nutrition label") answers are derived into
fastlane/app_privacy_details.json, so the labels always match the config;
entering them stays the one manual step, because no App Store Connect
endpoint accepts them under API-key auth (verified: the official host has
no appDataUsages path, and fastlane's upload action requires an Apple ID
session).
Those answers have two halves. The declared SDK sections give one: every
SDK publishes what it collects, so naming the SDK is enough. The other is
what the app itself stores, which nothing can read out of your code —
privacy.collects is where you say it, in Apple's own category names
lower-cased (email_address, other_user_content, photos_or_videos, …;
a name Apple does not have is rejected with the full list). Everything
listed there is declared as collected for app functionality and linked to
the person, which is what having an account means. Leave it out and the
labels claim your app collects nothing beyond its SDKs — so setup --only store says so whenever firebase.firestore is declared, the one backend
easy_setup knows it provisioned. Any other backend is yours to remember.
Integrations — one credential each, then no browser
Error monitoring, product analytics and ads are declared in
easy_setup.yaml; the keys and IDs come from the vendors' APIs, so nothing is
copied out of a web console after the first credential exists.
| Section | setup does |
Needs from you |
|---|---|---|
sentry: |
creates the project, fetches the DSN into env.json/env.prod.json, adds sentry_flutter + sentry_dart_plugin, writes the pubspec sentry: block that flutter pub run sentry_dart_plugin reads to upload debug symbols |
SENTRY_API_TOKEN — an internal integration (or personal) token with project:write + org:read. Not an organization token: those have fixed CI scopes and cannot create projects. For symbol upload at build time, SENTRY_AUTH_TOKEN, where an organization token is exactly right |
amplitude: |
verifies the API key against the ingestion API, writes it into env.prod.json (and a dev key into env.json), adds amplitude_flutter |
AMPLITUDE_API_KEY — or the key already in the committed env.prod.json, so a clone converges without your secrets file (the variable wins, which is how a key is rotated); plus the project created once in Amplitude — it has no project-creation API |
firebase: |
creates the project (naming which Google account it uses), optionally creates the Cloud Firestore database — seeding firestore.rules and releasing it, because a new database denies every read and write — and runs flutterfire configure to write the per-platform config |
a firebase login; firebase.account when the machine juggles accounts. Uploading the APNs key and linking Google Analytics stay console steps |
admob: |
looks the app and ad unit IDs up through the AdMob API, creates the missing ones where the account may, injects them into AndroidManifest.xml / Info.plist / env files, and generates lib/ads/ad_ids.dart from the declared units so debug builds serve test ads even when the launch command passes no env file |
an OAuth credential (below); app + ad unit creation needs AdMob's limited-access approval |
Step-by-step guides: Sentry, Firebase, AdMob, iOS signing — Korean translations in doc/ko/.
AdMob auth is OAuth user credentials — the API does not accept service accounts. Easiest first:
gcloud auth application-default login \
--scopes=https://www.googleapis.com/auth/admob.monetization,https://www.googleapis.com/auth/admob.readonly
# or: export ADMOB_ACCESS_TOKEN=<token>
# or: export ADMOB_OAUTH_CLIENT_ID / ADMOB_OAUTH_CLIENT_SECRET / ADMOB_REFRESH_TOKEN
Ad units are declared by name and format, and the IDs are resolved per run:
admob:
ad_units:
banner_main:
type: banner # also what a created unit gets
display_name: Banner (main) # default: the key; what the lookup matches
Everything these steps write lands in env.json / env.prod.json, and
deploy passes the release one to the build:
build:
dart_define_file: env.prod.json # default: env.prod.json when it exists
Without it, flutter build compiles SENTRY_DSN and friends as empty
strings and the SDKs no-op — an upload that looks fine and reports nothing.
easy_setup doctor warns when the file is missing or carries empty values.
Verifying on a device. An App Store profile cannot install on a phone, so
easy_setup certs creates the development one and points Debug/Profile/Release
at it; deploy narrows its own signing switch to Release and restores the
project when it is done. Then
flutter run --release --dart-define-from-file=env.prod.json runs on the
device with the real keys. A simulator needs no signing at all and is the
quickest check of all. See doc/ios-signing.md.
Commit env.prod.json; keep env.json out of git — init appends the
env.json ignore entry to the app's .gitignore for you. CI builds from a clean
clone, so a gitignored file simply is not there when the compiler looks for it,
and the failure is silent. Everything the steps write into it — a Sentry DSN, an
Amplitude write key, AdMob unit IDs — ships inside the app binary anyway, so
tracking it exposes nothing new. The rule that follows: nothing that must stay
secret belongs in a dart-define file, committed or not, because those values
can be read straight out of a release build. Secrets stay in the environment
(SENTRY_API_TOKEN, ADMOB_*) or behind your server.
For an app whose ad units already exist in the console,
easy_setup setup --only admob --adopt reads them into admob.ad_units
once, so the names in the yaml match what the lookup will search for.
Pin ios_app_id / android_app_id / per-unit ios: / android: in the yaml
to skip the lookup for that value, or set admob.auto: false to keep setup
offline entirely. easy_setup doctor reports which credential it found and
what is still missing.
One thing stays outside setup's reach: app-ads.txt, which programmatic
buyers read from the root of the domain your store listing names — a host
easy_setup never writes to. doctor fetches it and warns when it is missing or
does not name your publisher account, which otherwise shows up months later
as unexplained low fill rather than as an error. See
doc/admob.md.
iOS native config — no Xcode either
ios/Runner.xcodeproj is generated, not edited: the ios_project step
derives an XcodeGen spec
(ios/project.yml) from the yaml and runs xcodegen generate. The
.xcodeproj and .xcworkspace become gitignored build artifacts — commit
the spec, regenerate the project, on any machine. The native keys under
ios: land in the project on the next setup run (match_git_url and
release are deploy-time settings, not project ones):
| yaml | Lands in |
|---|---|
app.bundle_id |
PRODUCT_BUNDLE_IDENTIFIER on the Runner target (RunnerTests keeps its own derived id) |
ios.team_id |
DEVELOPMENT_TEAM in the spec, so device flutter run survives regeneration |
ios.deployment_target |
the spec's IPHONEOS_DEPLOYMENT_TARGET + the Podfile platform :ios line + AppFrameworkInfo.plist — one number, three files. Defaults to iOS 26; declare a lower one to reach older devices |
ios.capabilities |
Runner.entitlements + the Developer Portal App ID (ASC API) |
ios.background_modes |
Info.plist UIBackgroundModes |
ios.info_plist |
any scalar Info.plist key — usage descriptions, export compliance, display name |
ios.privacy_manifest |
seeds PrivacyInfo.xcprivacy once — the spec's source globs register it |
ios.localizations |
<locale>.lproj/InfoPlist.strings + knownRegions (variant groups come free from the globs) |
Supported capabilities: push_notifications, app_groups,
associated_domains, sign_in_with_apple, keychain_sharing. Declaring
push splits the entitlements per build configuration —
Runner.entitlements (aps-environment development) for Debug/Profile and
RunnerRelease.entitlements (production) for Release — because a
development provisioning profile only permits the first and an App Store
profile only the second. Portal capability changes invalidate existing
provisioning profiles; the step says so and the next deploy run (or
fastlane match --force) regenerates them. The bundle ID itself is
registered on the Developer Portal whenever ios: is declared and the ASC
key env vars are set — even with no capabilities — so the App Store
Connect app-record picker can offer it.
Your own project customizations — extra targets, build phases, settings —
live in ios/project_overlay.yml, seeded once and merged into the spec via
XcodeGen include:. It is additive: on a conflict the generated file's
values win, so generator-owned settings are changed in easy_setup.yaml.
Generation needs the xcodegen binary (brew install xcodegen); while it
is missing, setup still writes the spec and skips generating the project,
with guidance. Plugins integrate via CocoaPods: Flutter's Swift Package
Manager migration cannot handle a generated project (it matches the
template's hardcoded target IDs), so create writes
flutter: config: enable-swift-package-manager: false into the new app's
pubspec and the step warns when an adopted project still has SPM on. A project.yml easy_setup did not write is reported and
left alone. On the first run against a project whose .xcodeproj is still
committed, the step prints the switchover notice, including the one-time
git rm -r --cached; after a regeneration with CocoaPods in play, run
pod install once to re-integrate.
The other native files stay yours: declared keys are added or updated,
never deleted, and anything hand-authored — a foreign
CODE_SIGN_ENTITLEMENTS value, an InfoPlist.strings without the
generated header, an edited PrivacyInfo.xcprivacy — is reported and left
alone.
Store assets — AI designs the source, easy_setup renders the output
The icon, the screenshots and the promo site are all authored as text
sources in git (SVG, HTML, YAML) and rendered by headless Chrome. Nothing
in the build path calls an AI, so setup stays deterministic and
re-runnable; the AI only ever edits the sources.
assets/branding/icon/icon.svg → AppIcon.appiconset (15) + mipmap-* (5 densities)
assets/store/screenshots/
template.html + screenshots.yaml → fastlane/screenshots/<locale>/ (one iPhone tier + iPad)
raw/<locale>/<device>/*.png → fastlane/metadata/android/<locale>/images/ (1080x1920)
feature_graphic.html → images/featureGraphic.png (1024x500)
integration_test/store_screenshots_test.dart → the raw captures above
site/ → GitHub Pages (support / marketing / privacy URLs)
easy_setup capture produces the raw screenshots: it boots the simulator that
matches each device key, freezes the status bar to 9:41, and runs your tour.
Device keys are iphone_6_9 (1320x2868), iphone_6_5 (1284x2778), ipad_13
(2064x2752) and android_phone (1080x1920). Apple takes one iPhone tier as
the required set and scales the smaller sizes off it, so pick 6.9 or 6.5, not
both. The capture is scaled into the frame, so the canvas size is a rendering
choice — switching tiers does not mean re-shooting. Capture goes through simctl, not
IntegrationTestWidgetsFlutterBinding.takeScreenshot — the binding reads back
the Flutter surface, which is unreliable under Impeller/Metal, while simctl
grabs the compositor output, status bar included. The tour signals each shot
through a marker file that the CLI watches. iOS only for now; capture Android
by hand.
The screenshot loop, in order:
easy_setup capture # ① tour the app, save raw/<locale>/<device>/*.png
easy_setup setup --only screenshots # ② frame them into store-spec PNGs
easy_setup setup --only store # ③ upload (App Store deliver, Play supply)
capture also writes an entry into screenshots.yaml for each screen the
tour produced — the ids are the capture file names, so there is nothing to
retype. The copy under them is left commented rather than blank: an empty
string would silence the "rendered empty" warning and ship a headline-less
screenshot. A plain easy_setup setup runs ② and ③ in order; ① is separate
because it needs a booted simulator and is not idempotent.
Editing the design is two files. screenshots.yaml holds the copy, the
palettes, the fonts and crop_bottom (pixels trimmed off the raw capture,
for an ad banner or a home indicator). template.html holds the layout, and
reads everything through one rule — {{PLACEHOLDER}}:
| Built in | {{W}} {{H}} {{IMG}} {{INDEX}} {{COUNT}} {{LOCALE}} {{DEVICE}} {{SCREEN}} {{FONT_CSS}} {{FONT_FAMILIES}} |
| Text fields | {{TITLE}}, {{SUBTITLE}}, and any other field you add |
| Palette entries | {{C_BG}}, {{C_TITLE}}, ... — C_ plus the key |
Add a palette key and it becomes a placeholder; no Dart change involved.
The first setup run seeds the screenshot template, screenshots.yaml and
the site pages with working defaults and installs a Claude Code skill —
/app-icon, /store-screenshots, /app-site — that knows the constraints
(1024 canvas with no transparency, store canvas sizes, embedded fonts, no
external resources). The icon source itself is yours to provide (/app-icon
draws it with you); the branding step says so when it is missing. easy_setup
never overwrites a source it did not just create, so hand edits and AI
redesigns survive re-runs.
Above those sits /release-playbook, seeded by init: the skill that
takes the app from code to a live release — it runs doctor --json and
interprets the result, hands console-only work to you as a checklist while
it keeps going, calls the design skills in order, and drives capture,
setup, deploy and the review submission. "Release this app" is one ask.
Renders are fingerprinted into the output PNG (tEXt), so unchanged screens
are skipped on the next run.
Requirement: Google Chrome (or any Chromium build; set CHROME_PATH to
point at a different one). easy_setup doctor reports it whenever
branding: or screenshots: is configured.
Deploy
easy_setup deploy is a thin, honest wrapping of fastlane: match → pinned
manual signing → flutter build ipa / appbundle → pilot / supply,
plus (with --submit) deliver to submit the build for review — the store
listing itself (metadata, screenshots) is setup --only store's job. Auth is
always an App Store Connect API key (ES256 JWT) — never an Apple ID
session, so CI never hits a 2FA prompt.
What happens after approval is ios.release in easy_setup.yaml: manual
(the default — someone presses Release), automatic (live right after
approval), or phased (automatic, then rolled out to automatic-update
users over seven days).
The signing rewrite is scoped to the Release configuration and the Xcode
project is snapshotted and restored afterwards, so a deploy never leaves
your flutter run broken. The project is a generated artifact, so even a
hard-interrupted deploy is recoverable with setup --only ios_project —
and, the same way, a regeneration reverts what certs --apply wrote
(rerun certs to pin match signing again).
Two things stay one-time console visits because no API exists for them: the App Store Connect app record and the first Play AAB upload. doctor checks the app record live over the ASC API and, when it is missing, prints the exact values to create it with (name, bundle ID, SKU — five minutes); the Play side is covered by its credential checks.
CI — tag push, both stores
easy_setup init writes a five-line caller workflow; the heavy lifting
lives in this repository's reusable workflows, pinned to the v1 tag:
name: Release
on:
push:
tags: ['v*']
jobs:
ios:
uses: Etch-Studio/flutter_easy_setup/.github/workflows/release-ios.yml@v1
secrets: inherit
android:
uses: Etch-Studio/flutter_easy_setup/.github/workflows/release-android.yml@v1
secrets: inherit
The git tag is the version — a v* push deploys, the build number is the CI
run number, and platforms without a section in the yaml are skipped. The
secrets are registered once (organization secrets cover every app):
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8, MATCH_PASSWORD and one
credential for the match repo (iOS); ANDROID_KEYSTORE_BASE64,
ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD,
PLAY_SERVICE_ACCOUNT_JSON (Android). Which match credential depends on
how ios.match_git_url is written, because a runner has no credential
helper to fall back on: MATCH_GIT_SSH_KEY (a deploy key) for an SSH url,
MATCH_GIT_BASIC_AUTHORIZATION (echo -n "<user>:<PAT>" | base64) for an
https one. The github step asks for whichever one the url implies.
While the easy_setup repository itself is private, add
EASY_SETUP_REPO_TOKEN too — the release jobs install the CLI from it by
git url, and a runner cannot clone a private repository without a
credential. A read-only token for that one repository is enough, and the
secret becomes unnecessary once the repository is public or the CLI is
installed from pub.dev. CI and local runs execute the same
easy_setup deploy code, so "works on my machine, fails in CI" has nowhere
to hide.
Declaring an (empty) github: section adds a github setup step that does
the GitHub-side provisioning through the authenticated gh CLI, so none of
it is a console visit: it creates the private match certificates repo
(ios.match_git_url), registers the missing CI secrets above from your
local environment (org-level for organizations — falling back to repo
secrets when the token lacks admin:org — repo-level for user accounts),
and enables GitHub Pages for the site (build type: GitHub Actions). Every
sub-task checks before it acts, secret values never appear in output, and
a missing or unauthenticated gh degrades to guidance.
v1: flavor and ci-cd
The two v1 commands (multi-flavor setup via XcodeGen, generated fastlane +
GitHub Actions files) still work and still use the old easy_setup:
root-key schema. They are documented in doc/v1.md.
Libraries
- easy_setup
- Defines the public API of the easy_setup library.