English | 한국어

easy_setup

Take a Flutter app from "code exists" to "live in both stores" from one easy_setup.yamlwithout 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 gitinit 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 / appbundlepilot / 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.