# 스토어 대신 하기

App Store Connect, Google Play, RevenueCat 에서 하던 일을 allob 이 대신 해요. 이미 연결해 둔 키로 allob 서버가 스토어에 접속하니까, 컴퓨터에 따로 설치하거나 키를 보관할 필요가 없어요.

- 스토어 상태 보기: 버전·빌드·심사 상태, 인앱 상품, 등록정보에서 빠진 항목, Play 트랙, RevenueCat 상품·권한(entitlement)·오퍼링을 제품의 **스토어** 탭과 CLI, MCP 에서 같이 봐요.
- 등록정보 글: 언어마다 이름·설명·키워드 같은 글을 리포의 파일로 관리하고, 바뀐 항목만 스토어에 올려요.
- 스크린샷과 스토어 이미지: 폴더에 넣은 그림을 스토어가 요구하는 크기와 비교해 보고 올려요. App Store 크리에이티브 이미지(제품 페이지 머리 이미지·검색 결과 이미지)도 같이 올려요.
- 인앱 상품·구독: 파일에 적은 상품을 App Store·Google Play 에 만들고, RevenueCat 의 상품·권한(entitlement)·오퍼링까지 연결해요. 구독의 무료 체험도 켜고 꺼요.
- 심사 제출과 출시: App Store 버전에 빌드를 붙여 심사에 내고, Google Play 트랙을 올려요. App Store 심사 제출과 Google Play 프로덕션 출시는 **심사 제출·출시까지 맡기기**를 켰을 때만 바로 하고, 꺼져 있으면 사람이 승인한 뒤에 해요. 심사에 낸 제출을 취소할 수도 있어요.
- 앱 기본 설정: App Store 가격·카테고리·연령 등급·심사 연락처, Google Play 연락처, 판매 국가, 맞춤 라이선스 계약(EULA), 번들 ID 와 기능을 파일에 적어 두고 맞춰요.

스토어 리뷰를 모아 보고 답글을 다는 건 [스토어 리뷰 관리하기](/docs/reviews)에 있어요.

## 먼저 할 일

1. [외부 서비스 연결하기](/docs/connections)에서 App Store Connect, Google Play Console, RevenueCat 을 연결해요.
2. 제품에 앱을 골라요. App Store Connect 는 앱을 고르고, Google Play 는 패키지 이름(예: `com.example.diary`)을 입력해요. 제품 › 연결 탭에서 하거나 아래 명령으로 해요.

```sh
allob connection link <연결 id> --product diary --resource <앱 id 또는 패키지 이름>
```

스토어 탭은 제품 플랫폼에 iOS, Android, macOS 중 하나가 있을 때 보여요.

## 에이전트에게 맡기기

:::prompt
allob store status 로 이 제품의 스토어 단계 중 남은 일을 확인해 줘. 남은 일마다 allob store view 에서 이유를 찾아서, 내가 직접 해야 하는 일과 에이전트가 할 수 있는 일을 나눠서 알려 줘.
:::

## 스토어 상태 보기

```sh
allob store view       # 스토어마다 앱·버전·빌드·심사·상품·등록정보·계약
allob store builds     # 최근 App Store 빌드와 Play 트랙별 출시
allob store status     # 단계마다 끝남·남음·직접 확인
```

에이전트는 MCP 도구 `get_store`, `store_checks` 로 같은 내용을 읽어요. `--json` 을 붙이면 기계가 읽기 좋은 모양으로 나와요.

![스토어 탭 — 남은 일, App Store·Google Play·RevenueCat 상태](/docs/img/stores.png)

## 등록정보 글 올리기

스토어의 지금 글을 받아 `allob.store.json` 에 적고, 고친 뒤 다시 올려요. 이 파일은 `allob.json` 옆에 두고 커밋해요.

```sh
allob store listing pull            # 스토어 글 → allob.store.json
allob store listing push            # 바뀐 항목만 보여 줘요(아직 올리지 않아요)
allob store listing push --apply    # 스토어에 올려요
```

```json
{
  "listing": {
    "appStore": {
      "ko": { "name": "하루 한 줄", "subtitle": "매일 한 줄 일기", "keywords": "일기,다이어리", "description": "…" }
    },
    "googlePlay": {
      "ko": { "title": "하루 한 줄", "shortDescription": "매일 한 줄 일기", "fullDescription": "…" }
    }
  }
}
```

| 스토어 | 항목 |
|---|---|
| App Store | `name`(30자), `subtitle`(30자), `description`(4,000자), `keywords`(100자), `promotionalText`(170자), `supportUrl`, `marketingUrl`, `privacyPolicyUrl` |
| Google Play | `title`(30자), `shortDescription`(80자), `fullDescription`(4,000자), `video` |

- 파일에 적은 항목만 바꿔요. 빈 글자(`""`)를 넣으면 그 항목을 지워요.
- 글자 수가 넘거나 주소가 `https://` 가 아니면 올리기 전에 알려 줘요. App Store 는 이모지를 받지 않아서, App Store 글에 이모지가 있으면 빼라고 알려 줘요(Google Play 는 받아요).
- "새로운 기능" 글은 등록정보 파일에 적지 않아요. allob [릴리스](/docs/releases) 노트가 심사 제출(`allob store submit`) 때 들어가요. 같은 글을 두 곳에서 고치지 않게 하려는 거예요.
- App Store 글은 심사 준비 중인 버전에 올려요. 그런 버전이 없으면 App Store Connect 에서 새 버전을 먼저 만들어요. 새 버전과 함께 심사를 받은 뒤에 스토어에 보여요.
- Google Play 는 다른 변경이 검토 중이면 올리지 않아요. 검토 중인 변경을 취소하고 처음부터 다시 보내지 않게 하려는 거예요. 꼭 지금 보내야 하면 `--cancel-review` 를 붙여요. 검토 중인 변경을 취소하고 이 글과 함께 다시 보내요(검토가 처음부터 다시 시작돼요). 이건 늘 사람이 승인해요.
- Google Play 등록정보와 그래픽은 **심사 제출·출시까지 맡기기** 설정과 상관없이 `--apply` 하면 바로 보내고, 승인 요청을 남기지 않아요. 이미 출시한 앱이면 Google 검토만 통과하면 그대로 스토어에 보여요(Play Console 에서 관리형 게시를 켜 두지 않았다면). 그래서 `--apply` 전에 바뀌는 항목을 꼭 확인해요.
- `pull` 은 마지막 `pull`·`push` 뒤에 파일에서 고친 글이 있으면 덮어쓰지 않아요. 먼저 `push --apply` 로 올리거나, 스토어 글로 덮어쓰려면 `--force` 를 붙여요. 이걸 알아보려고 `allob.store.json` 에 맞춘 글의 표시(`listingSynced`)를 같이 적어 둬요.
- 지금 연결이 없는 스토어의 글은 `pull` 해도 파일에 그대로 남아요.
- 올린 뒤에는 변경 타임라인에 스토어 변경으로 한 줄 남아요.

### 올리기 전에 점검하기

`allob store listing check` 는 스토어를 부르지 않고 `allob.store.json` 의 글만 봐요. `push` 도 바뀐 항목을 보여 주기 전에 같은 점검을 해요.

```sh
allob store listing check    # 오류와 검색에 손해인 것을 알려 줘요
```

| 종류 | 무엇을 보나 |
|---|---|
| 오류 | 글자 수 한도, 키워드 쉼표 앞뒤의 빈칸(빈칸도 100자에 들어가요), 쉼표가 두 번 이어진 빈 항목, 피할 말 |
| 경고 | 같은 키워드가 두 번, 이름·부제에 이미 있는 말을 키워드에 다시 넣음(App Store 는 이름·부제도 검색해요), 영어처럼 띄어 쓰는 언어의 여러 낱말 키워드(한 낱말씩 넣으면 App Store 가 조합해요), 부제가 이름의 말을 되풀이, 같은 언어의 Play 제목이 App Store 이름과 다름, 키워드(90자 미만)·Play 간단한 설명(60자 미만)에 남는 자리 |

- 오류가 있으면 `push --apply` 는 보내지 않아요. 경고는 검색을 위한 조언이라 그대로 올릴 수 있어요.
- 피할 말은 `avoidWords` 에 적어요. 다른 회사 제품 이름이나, 다른 뜻으로 검색하는 사람을 끌어오는 말을 넣어 두면 이름·부제·키워드·설명에 들어갔을 때 막아요. `"*"` 는 모든 언어, 언어 코드(`ko`·`en-US`)는 그 언어에만 걸려요. `ko` 는 `ko-KR` 에도 걸려요.

```json
{
  "avoidWords": {
    "*": { "BigBrand": "다른 회사 제품 이름" },
    "ko": { "무료": "유료 구독 앱이라 오해를 불러요" }
  }
}
```

:::prompt
allob store listing pull 로 지금 스토어 글을 받아 줘. 한국어 설명과 키워드를 다듬어서 allob.store.json 에 적고, allob store listing push 로 바뀌는 항목을 보여 줘. 내가 확인하면 --apply 로 올려 줘.
:::

키워드에 넣을 말은 [검색어 찾기](/docs/search-terms)로 사람들이 실제로 치는 말을 모아 정해요.

## 스크린샷과 스토어 이미지 올리기

그림을 `store/media` 폴더에 묶음마다 넣어요. 파일 이름 순서가 스토어에 보이는 순서예요.

```text
store/media/
  app-store/ko/IPHONE_DYNAMIC_ISLAND_LARGE_PROFILE/01-home.png   iPhone 스크린샷
  app-store/ko/MAC_PROFILE/01.png                                  Mac 스크린샷
  app-store/all/product-page-header/header.mp4                     제품 페이지 헤더 — 모든 언어에(그림 또는 영상 하나)
  app-store/all/search-results/search.mp4                          검색 결과 — 모든 언어에(그림 또는 영상 하나)
  app-store/ja/product-page-header/header.png                      일본어만 다른 헤더(이 언어는 all 대신 이것)
  google-play/ko/phone/01.png                                      휴대전화 스크린샷(2~8장)
  google-play/ko/feature-graphic/feature.png                       그래픽 이미지(1024×500)
  google-play/ko/icon/icon.png                                     아이콘(512×512)
```

```sh
allob store media push            # 크기·장수 확인, 빠진 묶음 알려 줘요(아직 올리지 않아요)
allob store media push --apply    # 묶음마다 스토어의 그림을 바꿔요
```

- App Store 기기 묶음 이름(`IPHONE_DYNAMIC_ISLAND_LARGE_PROFILE` 같은)과 크기는 App Store Connect 에서 그때그때 읽어서 확인해요. 묶음 이름이 틀리면 쓸 수 있는 이름을 알려 줘요.
- App Store 는 투명한 배경(알파)이 있는 PNG 스크린샷을 받지 않아요. 확인 단계에서 알려 줘요.
- App Store 그림은 앱의 애셋 보관함에 올린 뒤, 심사 준비 중인 버전에 순서대로 놓아요. 그림 처리가 늦어지면 몇 분 뒤 같은 명령을 다시 실행해요. 심사는 버전을 제출할 때 같이 받아요.
- Google Play 는 묶음 하나를 한 번에 바꾸고, 다른 변경이 검토 중이면 바꾸지 않아요. 등록정보 글처럼 맡기기 설정과 상관없이 `--apply` 하면 바로 보내요.
- 묶음 하나는 한 번에 보내요. 묶음마다 파일 10개, 파일 하나 15MB(Play 스크린샷은 8MB), 합쳐서 32MB 까지예요. 넘으면 CLI 가 보내기 전에 어느 묶음인지 알려 줘요.
- `app-store/all/` 은 심사 준비 중인 버전의 모든 언어에 놓아요. 같은 종류 폴더가 따로 있는 언어는 그 폴더 것을 써요. 파일은 한 번만 올리고 언어마다 놓아요.
- 앱 미리보기 영상(스크린샷 자리)은 아직 올리지 않아요. App Store Connect 에서 올려요.

### App Store 헤더·검색 결과

iOS 27 부터 App Store 제품 페이지 맨 위(헤더)와 검색 결과에 큰 그림이나 영상이 보여요. 자리마다 파일 하나예요. 언어에 따로 없으면 기본 언어 것이 대신 보여요.

| 자리 | 폴더 | 그림 | 영상 |
|---|---|---|---|
| 제품 페이지 헤더 | `product-page-header/` | PNG 3840×1646(21:9) 또는 PNG 5244×2950(16:9) | 3840×1646 |
| 검색 결과 | `search-results/` | JPEG·PNG 1920×1280~3840×2560(3:2) 또는 PNG 5244×2950 | 1920×1280~3840×2560(3:2) |

- 영상은 MP4·MOV·M4V, 초당 30 또는 60프레임, 5~30초, 500MB 까지예요. 그림은 투명한 배경(알파)이 없어야 해요.
- 위 표는 지금 Apple 규격이에요. `allob store media push` 는 App Store Connect 에서 그때그때 규격을 읽어 묶음마다 "App Store takes:" 줄로 보여 주고, 크기·비율·길이·프레임이 맞는지 확인해요.
- 영상은 allob 을 거치지 않고 이 컴퓨터에서 App Store Connect 로 바로 올라가요(App Store 열쇠는 allob 서버에만 있어요). 처리가 늦어지면 몇 분 뒤 같은 명령을 다시 실행해요. 올린 영상을 다시 올리지 않고 그대로 써요.
- 버전이 심사 제출 초안에 들어 있으면 Apple 이 헤더·검색 결과를 바꾸지 않아요. 초안에서 빼고 올린 뒤 다시 제출해요.

:::prompt
store/media/app-store/all/ 에 제품 페이지 헤더 영상(product-page-header/header.mp4)과 검색 결과 영상(search-results/search.mp4)을 넣었어. allob store media push 로 Apple 규격에 맞는지 확인해 주고, 맞으면 --apply 로 모든 언어에 올려 줘.
:::

## 제품 페이지 테스트(스크린샷 A/B)

App Store 방문자 일부에게 다른 스크린샷을 보여 주고 어느 쪽이 더 많이 받는지 봐요. `allob.store.json` 에 테스트를 적고, 변형마다 그림 폴더를 둬요.

```json
{ "pageTests": { "spring": { "traffic": 30, "variants": { "bright": "store/page-tests/spring/bright", "calm": "store/page-tests/spring/calm" } } } }
```

변형 폴더 안은 `app-store/<언어>/<화면 종류>/` 에 그림을 이름 차례로 둬요. 화면 종류는 `APP_IPHONE_67`(6.9형 아이폰), `APP_IPAD_PRO_3GEN_129`(13형 아이패드), `APP_DESKTOP`(맥) 같은 이름이에요.

```sh
allob store page-test create spring            # 폴더·크기를 확인하고 계획만
allob store page-test create spring --apply    # 테스트를 만들고 그림을 올려요
allob store page-test submit spring --apply    # 심사에 내요(그림 처리가 끝난 뒤)
allob store page-test start spring --apply     # 심사를 통과하면 시작해요
allob store page-test list                     # 테스트와 상태
```

- 변형은 셋까지, `traffic` 은 테스트를 볼 방문자 비율(%)이에요.
- Apple 은 새 변형에 지금 제품 페이지의 스크린샷을 베껴 넣어요. 그대로 두면 그림을 넣지 않은 기기에선 원래 페이지가 보여 결과가 섞여서, allob 이 베낀 그림을 모두 지우고 폴더의 그림만 올려요.
- 심사 제출과 시작은 사람이 승인해요(**심사 제출·출시까지 맡기기**를 켰거나 대화에서 시켰으면 바로 해요). 시작하면 변경 타임라인에 남아서 그 앞뒤 숫자를 볼 수 있어요.
- 진행 중인 테스트를 지우면(`delete --apply`) 그 자리에서 끝나요.
- Google Play 의 등록정보 실험은 API 가 없어요. Play Console 에서 해요.

## 인앱 상품·구독 만들기

`allob.store.json` 의 `products` 에 상품을 적으면, 없는 상품은 만들고 있는 상품은 글을 맞춰요. 한 번 사는 상품의 가격이 파일과 다르면 가격도 바꿀 수 있어요(아래).

```json
{
  "products": [
    {
      "id": "diary.pro",
      "type": "non_consumable",
      "price": { "country": "US", "amount": 4.99 },
      "countries": { "KR": 5900 },
      "text": { "ko": { "name": "프로", "description": "모든 기능을 평생 써요" } },
      "reviewScreenshot": "store/review/pro.png",
      "appStoreImage": "store/iap/pro.png",
      "promoted": true
    },
    {
      "id": "diary.plus.yearly",
      "type": "subscription",
      "period": "P1Y",
      "group": "Diary Plus",
      "basePlan": "yearly",
      "price": { "country": "US", "amount": 29.99 },
      "text": { "ko": { "name": "플러스 1년", "description": "모든 기능을 1년 동안" } },
      "trial": "P1W"
    }
  ],
  "revenueCat": {
    "entitlements": { "pro": ["diary.pro", "diary.plus.yearly"] },
    "offering": { "id": "default", "packages": { "$rc_lifetime": "diary.pro", "$rc_annual": "diary.plus.yearly" } }
  }
}
```

```sh
allob store products push            # 무엇을 만들고 고칠지 보여 줘요
allob store products push --apply    # 만들어요
```

| 항목 | 뜻 |
|---|---|
| `id` | 두 스토어에 같이 쓰는 상품 ID. 영문 소문자·숫자·점·밑줄 |
| `type` | `consumable`(여러 번 사는 것), `non_consumable`(한 번 사면 계속), `subscription`(자동 갱신 구독) |
| `price` | 기준 나라와 그 나라 통화 금액. 다른 나라는 스토어가 환산해요 |
| `countries` | 나라별로 직접 정할 금액(그 나라 통화) |
| `text` | 언어마다 이름(35자)·설명(55자) — 두 스토어 중 좁은 App Store 한도예요. 언어 이름은 어느 스토어 쪽으로 적어도 돼요 — `ko` 든 `ko-KR` 이든 App Store 엔 `ko`, Google Play 엔 `ko-KR` 로 보내요. 두 이름이 같은 언어(`ko` 와 `ko-KR`)면 하나만 적으라고 알려 줘요 |
| `years` | 해마다 하나씩 파는 상품: `{ "from": 2024, "to": 2040 }`. `id`·글에 `{year}` 를 넣으면(예: `diary.pass_{year}`) 해마다 상품 하나로 풀어서 만들어요. 심사에는 올해 것까지만 내요 |
| `period`·`group`·`basePlan` | 구독만: 기간(`P1W`·`P1M`·`P2M`·`P3M`·`P6M`·`P1Y`), App Store 구독 그룹 이름, Google Play 기본 요금제 ID |
| `trial` | 구독만: 무료 체험 기간. `null` 이면 체험을 꺼요. 필드를 빼면 체험은 건드리지 않아요([무료 체험 켜고 끄기](#무료-체험-켜고-끄기)) |
| `reviewNote`·`reviewScreenshot` | App Store 심사 담당자에게 남길 메모와 구매 화면 스크린샷 |
| `appStoreImage` | App Store 제품 페이지에 이 상품과 같이 보이는 홍보 이미지. 1024×1024 PNG·JPEG, 투명도 없이. 경로는 `allob.store.json` 기준이에요 |
| `promoted` | App Store 제품 페이지에 이 상품을 홍보할지. `true` 면 모든 사람에게 보여요(홍보 이미지가 있어야 해요). `false` 면 홍보를 꺼요. 필드를 빼면 건드리지 않아요 |

- 새 상품은 글, 가격, 판매 나라(전체)를 정하고 활성화까지 해요. App Store 가격은 Apple 가격표에 있는 금액이어야 해요. 없으면 가까운 금액을 알려 줘요.
- 이미 있는 한 번 사는 상품의 가격(기준 나라·`countries`)이 스토어와 다르면 계획에 `price` 줄로 보여 줘요. `--apply` 하면 allob 웹 화면에서는 바로 바꾸고, 에이전트가 하면 스토어마다 승인 요청을 남겨요. 승인할 때 지금 가격을 다시 읽어 아직 다른 상품만 바꿔요. App Store 는 새 가격표를 만들어서, 파일에 적지 않은 나라에 손으로 정해 둔 가격은 Apple 환산 가격으로 바뀌어요(계획에 그 나라를 보여 줘요). 바꾼 가격은 변경 타임라인에 남아요.
- 구독 가격은 바꾸지 않아요. 기존 구독자 동의 규칙이 걸리는 일이라 각 스토어 화면에서 해요.
- RevenueCat 에 이어 둔 App Store 앱(번들 ID)이나 Play 패키지의 앱이 없으면 `<제품 이름> iOS·macOS·Android` 로 만들어요. 스토어 자격(앱 안 구매 키·Play 서비스 계정)은 API 로 넣을 수 없어서 RevenueCat › 프로젝트 설정 › 앱에서 올려요. `allob store status` 가 무엇이 빠졌는지 알려 줘요.
- RevenueCat 은 앱(App Store·Play)마다 상품을 만들고 권한(entitlement)·오퍼링에 붙여요. 이미 붙어 있는 것은 떼지 않아요. RevenueCat 로그인으로 연결했으면 그대로 되고, 비밀 키로 연결했으면 키에 Project configuration 쓰기(Write) 권한이 있어야 해요.
- Google Play 는 결제 권한(BILLING)이 들어간 빌드를 한 번 올린 앱에만 상품을 만들 수 있어요.
- 홍보 이미지는 보내기 전에 크기(1024×1024)와 투명도를 먼저 봐요. 안 맞는 파일이 하나라도 있으면 아무것도 보내지 않아요. 계획에 이미지마다 올릴지, 그대로 둘지 보여 줘요. 상품을 만든 다음에 올리고, App Store 에 같은 파일이 이미 있으면 다시 올리지 않아요. 다른 파일이면 고치던 사본을 바꿔요. 이미 승인된 이미지는 지우지 않고, 파일이 승인된 이미지와 같으면 고치던 사본만 지워요. App Store 에서 건너뛰었거나 실패한 상품의 이미지는 보내지 않아요. 바꾼 이미지·홍보는 변경 타임라인에 남아요.
- 홍보(`promoted: true`)는 홍보 이미지가 App Store 에 있어야 켤 수 있어서 이미지를 올린 다음에 켜요. 이미지가 없으면 계획에 알려 주고 보내지 않아요. `false` 로 끄면 App Store 의 홍보 목록에 꺼진 채로 남아요.
- 심사 제출에 들어가 있는 상품은 App Store 가 홍보 이미지·홍보를 바꾸지 못하게 막아요. allob 이 그렇다고 알려 주고 다른 상품은 계속해요. 심사가 끝난 뒤 같은 명령을 다시 돌리면 돼요.
- Google Play 에는 상품마다 붙이는 홍보 이미지가 없어요.
- 만든 뒤에는 변경 타임라인에 스토어 변경으로 한 줄 남아요.

### 무료 체험 켜고 끄기

구독에 `trial` 을 적으면 처음 구독하는 사람에게 그 기간만큼 무료 체험을 줘요. 위 예시의 `"trial": "P1W"` 는 1주 무료 체험이에요. 같은 `allob store products push` 로 켜고, 바꾸고, 꺼요.

| `trial` | 하는 일 |
|---|---|
| 필드 없음 | 체험을 맡지 않아요. 읽지도, 계획에 보이지도, 바꾸지도 않아요. App Store Connect·Play Console 에서 직접 켠 체험은 그대로예요 |
| `null` | 체험을 꺼요 |
| `"P1W"` 같은 기간 | 그 기간으로 켜요. 다른 기간이 켜져 있으면 바꿔요 |

| 값 | 기간 |
|---|---|
| `P3D` | 3일 |
| `P1W`·`P2W` | 1주·2주 |
| `P1M`·`P2M`·`P3M`·`P6M` | 1·2·3·6개월 |
| `P1Y` | 1년 |

두 스토어가 모두 받는 기간만 쓸 수 있어요. 다른 값(`P7D`, `1 week` 같은)을 적으면 스토어를 부르기 전에 쓸 수 있는 값을 알려 줘요. 구독이 아닌 상품에는 `trial` 필드를 아예 쓸 수 없어요(`null` 도 오류예요).

- **App Store**: 구독을 파는 나라(가격이 있고 판매 나라로 고른 나라)마다 무료 체험 소개 혜택(introductory offer)을 만들어요. 구독 그룹을 처음 사는 사람만 받아요.
  - 기간을 바꾸면 나라마다 옛 혜택을 지우고 새 혜택을 만들어요. App Store 는 소개 혜택을 고칠 수 없어서 지우고 다시 만드는 거예요.
  - 지울 수 없는 혜택은 내일 끝나게 하고, 그 나라의 새 체험은 겹치지 않게 그 끝 날부터 시작해요. App Store 가 그 날을 겹친다고 보면 하루 뒤부터로 한 번 더 해 보고, 그래도 안 되면 그 나라는 "try again tomorrow" 로 실패해요. 같은 혜택을 두 번 만들지 않아요.
  - 날짜는 미국 태평양 시간 기준이에요.
  - 돈을 받는 소개 혜택(처음 몇 달 할인 같은)이 있는 나라는 건드리지 않고 계획에 그 나라 수를 알려 줘요.
  - 판매 나라가 아직 없으면(새 구독의 가격을 다 정하기 전) 체험은 기다렸다가, 판매 나라가 생긴 뒤 같은 명령을 다시 실행할 때 켜요.
- **Google Play**: 기본 요금제에 첫 단계가 무료인 혜택(offer, ID `free-trial-p1w` 같은)을 만들고 켜요. allob 이 만드는 혜택은 이 앱의 구독을 한 번도 하지 않은 사람만 받아요.
  - 같은 길이의 꺼진 체험 혜택이 있으면 새로 만들지 않고 다시 켜요. Play 가 7일을 `P7D` 로 돌려줘도 `P1W` 와 같은 길이로 봐요.
  - 기간을 바꾸면 옛 혜택을 먼저 끄고 새 혜택을 켜요. 두 혜택이 같이 켜져 있는 때가 없어요.
- **끄기**(`null`): App Store 무료 체험 소개 혜택을 지우고(지울 수 없으면 내일 끝나게) Play 체험 혜택을 꺼요(비활성). 이미 체험 중인 사람은 그대로 체험을 이어 가요.
  - 체험으로 보는 것은 App Store 의 무료 체험(FREE_TRIAL) 소개 혜택과, Play 에서 첫 단계가 무료이고 **신규 고객 대상**인 혜택뿐이에요. Play 의 신규 고객 대상은 세 가지예요: 이 구독을 한 번도 하지 않은 사람, 이 앱의 어떤 구독도 하지 않은 사람, 정해 둔 다른 구독 하나를 한 번도 하지 않은 사람.
  - Play 에서 개발자가 정하는 혜택(돌아온 사람 할인 같은)이나 업그레이드 혜택은 첫 단계가 무료여도 체험으로 보지 않고 건드리지 않아요. App Store 의 돈 받는 소개 혜택도 그대로 둬요.
- **RevenueCat**: 따로 할 일이 없어요. RevenueCat 은 스토어의 체험 혜택을 그대로 읽어요.
- 이미 같은 상태면 아무것도 하지 않아요.
- 한쪽 스토어나 몇 나라만 실패하면 결과에 어느 쪽·몇 나라가 됐는지 보이고, 같은 명령을 다시 실행하면 남은 것만 채워요. 다시 실행할 때 계획 줄은 남은 일을 말해요(`free trial P1W: 3 countries still to switch on`·`remove the old length in 3 countries`).
- App Store 는 명령 한 번에 소개 혜택을 바꾸는 요청을 350번까지 보내요. 지우기, 만들기, 지울 수 없어 끝 날을 정하는 요청, 겹쳐서 하루 뒤로 다시 만드는 요청을 모두 하나씩 세요. 넘으면 남은 나라 수와 함께 `run the same command again` 이라고 알려 줘요. Google Play 는 이 한도가 없어요.
- 체험 상태를 읽지 못한 구독은 계획에 `could not read the free trial state` 로 나오고, 다른 상품은 그대로 진행해요.

계획에는 체험 줄이 따로 보여요. CLI(에이전트)로 보면 이렇게 나와요.

```text
App Store    update  diary.plus.monthly  text, price and countries only if not set yet
App Store    trial   diary.plus.monthly  free trial on · P1W, 175 countries — needs a person's approval
Google Play  update  diary.plus.monthly  text (prices stay as they are)
Google Play  trial   diary.plus.monthly  free trial P2W → P1W — needs a person's approval

Dry run — nothing changed in the stores. To create them: allob store products push --apply
Free trial changes wait for a person: --apply sends an approval request to allob (product › Store, and their phone).
```

**승인**: 체험은 가격과 매출에 닿는 일이에요.
- 로그인한 allob 웹 화면, 또는 Face ID·지문으로 본인 확인을 거친 폰 앱에서 보낸 요청은 바로 반영해요.
- 에이전트(CLI·MCP)가 `--apply` 하거나, 본인 확인 없이 폰 앱에서 보내면 체험마다 승인 요청을 남겨요. 사람이 제품 › 스토어나 폰 앱에서 승인하면 그때 반영해요(심사 제출 승인과 같은 목록이에요).
- 승인 화면에는 어느 스토어의 어느 구독인지, 지금 체험과 바꾼 뒤 체험이 보여요.
- 요청한 뒤 스토어의 체험이 바뀌었거나 제품에 이어 둔 스토어 앱이 바뀌었으면, 승인해도 반영하지 않고 "바뀌었어요, 다시 요청해 주세요"로 끝나요.
- 파일에서 그 구독이나 `trial` 필드를 빼면, 기다리던 그 구독의 체험 요청은 다음 `--apply` 때 닫혀요.
- 사람이 직접(웹 화면·본인 확인한 폰 앱) `--apply` 하면 그 제품의 기다리던 체험 요청은 모두 닫혀요. 그 push 가 체험을 맞췄거나 이미 같은 상태라서예요. 체험 상태를 읽지 못한 구독의 요청만 남겨 둬요.
- 사람이 그 대화에서 직접 시킨 일이면(`--asked`) 요청 없이 바로 하고 폰으로 알려 줘요. 금고 › "에이전트가 하기 전에 나한테 물을 일"의 **구독 무료 체험 켜기·끄기** 줄에서 늘 물을지 정할 수 있어요.

**기록**: 체험을 켜고 끄고 바꿀 때마다 변경 타임라인에 할인·혜택(`promo`) 변경으로 한 줄 남아요(`App Store diary.plus.yearly: free trial on · P1W`). 중간에 실패해도 스토어에서 실제로 바뀐 만큼 남겨요.
- 몇 나라만 됐으면 `(170 of 175 countries)` 가 붙어요. 대부분의 나라가 아직 옛 길이면 하던 일과 함께 남아요(`free trial P1W → P2W (50 of 175 countries)`).
- 옛 체험을 끈 뒤 새 체험이 실패했으면 `free trial off (was P2W)` 처럼 남아요.
- 지표 › 매출 › **결제 보기**의 무료 체험 섹션에 그 기간에 바꾼 체험이 타임라인과 같은 글로 보여서(`2주 → 1주 (175개국 중 50곳)` 처럼), 체험이 얼마나 유료로 바뀌는지 그 전후로 볼 수 있어요. 체험이 아직 한 번도 시작되지 않았어도 그 기간에 체험을 바꿨으면 섹션이 보여요(숫자는 0)([지표 보기](/docs/metrics#무료-체험)).

> **켜기 전에 확인해요**: 앱이 체험 시작을 결제(`purchase`)로 세고 있지 않은지 먼저 봐요. 결제를 RevenueCat 으로 연결했으면 allob 이 체험 시작을 결제가 아닌 `trial_start` 로 따로 세요([결제 연결 › 무료 체험](/docs/payments#무료-체험)). 앱 코드가 구매 버튼을 누를 때 직접 `purchase` 이벤트를 보내고 있다면, 체험을 켜는 순간부터 결제 수·전환율이 부풀어 보여요. 그 이벤트는 체험이 아닐 때만 보내게 먼저 고쳐요.

:::prompt
allob.store.json 의 구독 상품에 1주 무료 체험("trial": "P1W")을 넣고 allob store products push 로 계획을 보여 줘. 그 전에 앱 코드가 체험 시작을 purchase 이벤트로 보내고 있지 않은지 확인해 줘. 문제가 없으면 --apply 로 승인 요청을 남겨 줘. 승인은 내가 할게.
:::

## 빌드 만들기

빌드와 서명은 내 컴퓨터에서 해요. allob 은 빌드하지 않고, 다 만든 파일을 스토어에 올리기만 해요. 프로젝트에 빌드 명령이 있으면 그걸 쓰고, 없으면 아래처럼 만들어요.

**Android** — 프레임워크의 출시 빌드 명령으로 AAB 를 만들어요. 보통 `./gradlew bundleRelease`(Expo 는 `npx expo prebuild` 뒤 `android` 폴더에서, Flutter 는 `flutter build appbundle`)예요. 업로드 키로 서명돼야 해요.

**iOS·macOS** — 맥의 Xcode 로 아카이브하고 App Store Connect 용으로 내보내요.

1. 서명할 수단을 하나 준비해요. Xcode › Settings › Accounts 에 팀에 든 Apple ID 를 추가하거나, App Store Connect API 키 파일(.p8)을 이 내보내기에만 쓰고 끝나면 지워요.
2. 네이티브 프로젝트를 최신으로 만들어요 — Expo 는 `npx expo prebuild`, Capacitor 는 웹 빌드 뒤 `npx cap sync`, Flutter 는 `flutter build ios --config-only`.
3. 빌드 번호는 `allob store builds` 에 보이는 가장 큰 번호 + 1 로 정해요.
4. 서명 없이 아카이브해요(`xcodebuild archive … CODE_SIGNING_ALLOWED=NO`). 등록된 기기가 없어도 돼요.
5. 서명 없는 아카이브에서 자주 걸리는 것을 고쳐요 — 프레임워크 임시 서명, 권한 파일(푸시·Apple 로그인 등) 넣기, 맥 앱의 리소스 묶음 서명 지우기.
6. `xcodebuild -exportArchive -allowProvisioningUpdates` 로 `.ipa`(맥 앱은 `.pkg`)를 내보내요. 1 의 Xcode 계정이나 API 키로 배포 서명이 붙어요.

Xcode 화면에서 Product › Archive → Distribute App 으로 내보내도 돼요. 단계마다 쓰는 명령과 자주 걸리는 함정은 `app-build-upload` [스킬](/docs/skills)에 있어요.

:::prompt
이 앱의 iOS 출시 빌드를 만들어 줘. allob store builds 로 다음 빌드 번호를 정하고, app-build-upload 스킬대로 Xcode 로 아카이브하고 내보내 줘. 서명은 Xcode 에 로그인한 내 Apple 계정으로 해. 다 되면 allob store upload 로 계획만 보여 줘.
:::

## 빌드 올리기

내 컴퓨터에서 만든 빌드 파일을 스토어에 올려요. allob 이 스토어에서 받은 올리기 주소로 파일을 바로 보내요 — 열쇠는 내 컴퓨터에 오지 않아요.

```sh
allob store upload build/export/Diary.ipa              # 파일을 검사하고 계획만 보여 줘요
allob store upload build/export/Diary.ipa --apply      # App Store Connect 로(TestFlight·심사용)
allob store upload app-release.aab --apply             # Google Play 내부 테스트 트랙으로
allob store builds                                     # 올라간 빌드와 처리 상태
```

- 받는 파일: `.ipa`(아이폰·아이패드), `.pkg`(Mac App Store), `.aab`(Google Play). `.apk` 나 `.xcarchive` 면 다음에 할 일을 알려 줘요.
- 올리기 전에 이 컴퓨터에서 파일을 읽어 번들 ID·패키지, 버전, 빌드 번호를 보고, 제품에 이어 둔 앱과 다르거나 빌드 번호가 스토어에 있는 것보다 낮으면 멈춰요.
- `allob.store.json` 의 `"upload"` 에 검사를 적어 둘 수 있어요. 광고 단위 파일(`ads.unitsFile`)을 적으면 테스트 광고가 섞였거나 실제 광고 단위가 빠진 빌드를 막아요(웹 번들을 싣는 앱만 읽을 수 있어요). Expo 앱 설정(`updates.appConfig`)을 적으면 업데이트가 꺼졌거나 운영 채널이 아닌 빌드를 막아요. 셸에 있으면 안 되는 변수(`forbidEnv`)는 이름만 봐요.
- Google Play 는 `--track internal|alpha|beta` 로 트랙을 골라요(기본 internal). 프로덕션은 `allob store promote` 로만 가요. 공개 테스트로 열어 둔 트랙은 링크만 있으면 누구나 받을 수 있어요. `--release <버전>` 이면 그 릴리스 노트가 출시 노트로 들어가요.
- App Store 는 올린 뒤 Apple 이 파일을 검사하는 몇 분을 기다렸다가 결과를 보여 줘요. 처리는 10~30분 걸려요 — `allob store builds` 나 `allob store submit --wait` 로 봐요.
- 올린 빌드는 기록돼서 `allob store builds` 에 ✓ 로 보이고, Expo 앱이면 런타임 버전도 같이 보여요. 다른 도구로 이미 올린 빌드를 다시 돌리면 올리지 않고 검사만 기록해요.

### CI 에서 올리기

EAS Workflows·GitHub Actions 처럼 사람이 없는 곳에서는 내 로그인 대신 **CI 키**를 써요. CI 키는 한 제품의 빌드 올리기와 심사 제출·출시 요청만 할 수 있고, 승인은 못 해요. 자동으로 둔 일은 바로 하고, 나머지는 확인 요청으로 폰과 allob 화면에 와서 내가 승인해요. 자동으로 두는 곳은 제품 › 설정의 **심사 제출·출시까지 맡기기**와 금고 › "에이전트가 하기 전에 나한테 물을 일"이에요.

1. 제품 › 설정 › **CI 키**에서 이름을 적고 **CI 키 만들기**를 눌러요(`allob ci-key create` 가 그 화면을 열어 줘요). 키는 그 자리에서 한 번만 보여요.
2. CI 의 비밀값으로 `ALLOB_CI_KEY` 에 넣어요. EAS 면 보이지 않는 값(secret)으로 넣어요.

   ```sh
   eas env:create --name ALLOB_CI_KEY --value <키> --visibility secret --environment production
   ```

3. CI 에서 allob 을 설치하고, `allob.json` 이 있는 폴더에서 올려요.

   ```sh
   curl -fsSL https://allob.app/install.sh | sh
   ~/.allob/bin/allob store upload build/app.ipa --apply
   ~/.allob/bin/allob store submit --wait --apply
   ```

- 키가 새면 제품 › 설정 › CI 키(또는 `allob ci-key revoke <id>`)에서 바로 끊어요. 키를 만든 사람이 계정을 떠나도 그 키는 멈춰요.
- CI 키로는 설정·연결·다른 제품·승인 결정을 할 수 없고, `--asked` 도 받지 않아요.

## 심사 제출과 출시

```sh
allob store submit                                   # App Store: 무엇을 낼지 보여 줘요
allob store submit --version 1.2.0 --apply           # 버전 준비 → 빌드 붙이기 → 새로운 기능 → 심사 제출
allob store promote --to production --fraction 20%   # Google Play: 내부 테스트 → 프로덕션 20%
allob store approvals                                # 승인을 기다리는 요청
```

- App Store: 심사 준비 중인 버전(없으면 `--version` 으로 새로 만들어요)에, 그 버전의 처리가 끝난 가장 최근 빌드를 붙여요. 빌드가 아직 처리 중이면 `--wait` 로 1분마다 다시 보며 60분까지 기다릴 수 있어요(CI 에서 써요). 새로운 기능 글은 같은 버전 이름의 allob [릴리스](/docs/releases) 노트를 언어마다 써요(`--release` 로 고를 수 있어요). 그 언어로 적어 둔 노트가 없으면 기본 노트를 써요. 첫 버전은 새로운 기능 글을 받지 않아요.
- Google Play: 한 트랙의 가장 최근 출시를 다른 트랙으로 올려요. `--build <versionCode>` 로 그 빌드가 든 출시를 고를 수 있어요. `--fraction` 으로 일부 사용자에게만 먼저 내보낼 수 있어요. 다른 변경이 검토 중이면 올리지 않아요. 검토 중인 변경을 취소하고 처음부터 다시 보내지 않게 하려는 거예요. 꼭 지금 보내야 하면 `--cancel-review` 를 붙여요 — 늘 사람이 승인하고, 승인하면 검토 중인 변경을 취소하고 이 변경과 함께 다시 보내요.
- `allob.store.json` 의 `products` 중 심사에 낼 수 있는 상품(Ready to Submit)은 버전과 같이 내요. 상품을 먼저 내고 버전을 내요. 빠진 것이 있거나 반려된 상품이 있으면 무엇을 고칠지와 함께 멈춰요(맥 앱의 심사 스크린샷은 2880×1800 같은 맥 크기). 해마다 상품은 올해 것까지만 내요.
- 맨 처음 내는 인앱 상품은 Apple 이 웹에서만 받아요. App Store Connect › 앱 내 구입 › 상품 › **심사에 추가**로 담아 두면, 다음 `allob store submit --apply` 가 그 제출을 그대로 내요. 자동 갱신 구독은 구독 하나씩이 아니라 구독 그룹 화면의 **심사에 추가**로 그룹째 담아야 해요. 그룹이 빠져 있으면 allob 이 내기 전에 알려 줘요.
- 심사에서 반려된 뒤(해결되지 않은 문제)에는 반려된 항목을 해결로 표시하고 같은 제출을 다시 내요.
- 자동 갱신 구독이 있는 앱은 맞춤 라이선스 계약이 있거나 모든 설명에 Apple 표준 계약 링크가 있어야 해요(심사 지침 3.1.2). 없으면 제출 전에 멈추고 알려 줘요.
- 붙일 빌드를 `allob store upload` 로 올렸으면 계획에 ✓ 와 검사 기록(OTA 런타임 등)이 보이고, 아니면 광고·OTA 설정을 확인하지 않은 빌드라고 경고해요(막지는 않아요).
- 이미 심사를 기다리거나 심사 중인 제출이 있으면 새로 내지 않아요. 먼저 취소해야 하면 아래 [심사 제출 취소하기](#심사-제출-취소하기)를 봐요.

### 심사 제출 취소하기

심사를 기다리거나 심사 중인 App Store 제출을 취소해요. 취소하면 버전이 다시 준비 중으로 돌아와서 빌드·글·스크린샷을 고친 뒤 `allob store submit` 으로 다시 낼 수 있어요. 다시 내면 심사를 처음부터 기다려야 해요.

```sh
allob store cancel            # 취소할 제출을 보여 줘요
allob store cancel --apply    # 취소해요
```

- 제품 › 스토어 탭의 심사 줄에서 **제출 취소**를 눌러도 돼요. 한 번 더 물은 뒤 바로 취소해요.
- 에이전트가 `--apply` 하면 승인 요청을 남기고, 사람이 승인하면 그때 취소해요. 그사이 심사가 끝났으면 승인해도 아무것도 하지 않아요.

### 심사 제출·출시까지 맡기기

제품 › 설정의 **심사 제출·출시까지 맡기기**를 켜야 allob 이 App Store 심사 제출과 Google Play 프로덕션 출시를 바로 해요. 기본은 꺼져 있어요.

이 설정은 켤 때 제품에 연결해 둔 스토어 앱에만 맡겨요. 나중에 스토어 앱 연결을 바꾸면(다른 앱을 고르거나, 연결을 끊었다 다시 하거나, 다른 키로 연결하면) 그때 꺼지고 설정 화면에 그 까닭이 보여요. 지금 연결한 앱으로 맡기려면 다시 켜 주세요. 스토어 앱 하나는 계정 안에서 제품 하나에만 연결할 수 있어요.

꺼져 있으면 allob 은 버전 준비(빌드·새로운 기능)까지만 하고 승인 요청을 남겨요. 요청은 [금고](/docs/vault)의 승인을 기다리는 요청 목록에 쌓여요. 금고와 제품 › 스토어 탭에 보이고, 누르면 같은 확인 화면이 열려요. `allob approval list` 로도 볼 수 있고, 14일이 지나면 끝나요. 사람이 승인하면 그때 스토어에 보내요. 승인과 이 설정은 로그인한 allob 화면에서만 할 수 있어요. 에이전트나 CLI 는 요청만 남길 수 있어서, 사람이 모르게 앱이 출시되지 않아요.

승인 요청은 이렇게 보여요.

| 상태 | 뜻 |
|---|---|
| 승인 기다림 | 아직 아무도 결정하지 않았어요. 카드에 보낼 버전·빌드와 릴리스 노트 첫 줄, 언어별 글이 있는 언어가 같이 보여요 |
| 보내는 중 | 승인하고 스토어에 보내고 있어요 |
| 확인 필요 | 승인해서 보냈는데 10분이 지나도 결과를 받지 못했어요. App Store Connect 나 Play Console 에서 심사·출시 상태를 직접 확인한 뒤 스토어 탭에서 **확인했어요**를 눌러 닫아요 |
| 확인함 | 사람이 스토어에서 확인하고 닫았어요 |
| 보냄 | 스토어에 보냈어요. 변경 타임라인에도 한 줄 남아요 |
| 보내지 못함 | 스토어가 받지 않았어요. 까닭이 같이 보여요 |
| 거절함·닫힘 | 사람이 거절했거나, 14일이 지났거나, 같은 앱에 더 새 요청이 들어와서 닫혔어요 |

요청을 남긴 뒤에 연결한 앱, 붙인 빌드, 버전 번호·출시 방식, 새로운 기능 글 중 하나라도 바뀌면, 승인해도 보내지 않고 "바뀌었어요, 다시 요청해 주세요"라고 알려 줘요. 사람이 본 내용과 실제로 보내는 내용이 늘 같게 하려는 거예요. 승인은 계정 소유자나 관리자만 할 수 있어요.

:::prompt
allob store submit 으로 이번 버전 계획을 보여 줘. 문제가 없으면 --apply 로 버전을 준비하고 승인 요청을 남겨 줘. 승인은 내가 allob 화면에서 할게.
:::

## 앱 기본 설정

스토어 앱을 만든 뒤 한 번씩 정하는 것들이에요. 모두 `allob.store.json` 에 적어 두고, 계획을 본 뒤 `--apply` 로 맞춰요. allob 은 스토어의 지금 값을 읽어서 다른 것만 바꿔요.

**승인**: 이 명령들은 스토어 계정의 앱 설정을 바꿔요.
- 로그인한 allob 웹 화면, 또는 Face ID·지문으로 본인 확인을 거친 폰 앱에서 보낸 요청은 바로 해요.
- 에이전트(CLI·MCP)가 `--apply` 하면 스토어마다 승인 요청을 남기고, 사람이 제품 › 스토어나 폰 앱에서 승인하면 그때 해요. 승인할 때 스토어를 다시 읽어 그때도 다른 것만 보내요.
- 사람이 그 대화에서 직접 시킨 일이면(`--asked`) 요청 없이 바로 하고 폰으로 알려 줘요. 금고 › "에이전트가 하기 전에 나한테 물을 일"의 **스토어 앱 설정 바꾸기** 줄에서 늘 물을지 정할 수 있어요. 심사 제출 취소는 **심사 내기·취소하기·출시하기** 줄을 따라요.
- 보낸 것은 변경 타임라인에 스토어 변경으로 한 줄 남아요.

### 가격·카테고리·연령 등급·심사 연락처

```json
{
  "setup": {
    "appStore": {
      "price": "free",
      "category": { "primary": "PRODUCTIVITY", "secondary": "UTILITIES" },
      "ageRating": { "violenceCartoonOrFantasy": "NONE", "gambling": false },
      "contentRights": "none",
      "reviewContact": { "copyFrom": "다른 앱 이름", "notes": "로그인 없이 쓸 수 있어요." },
      "copyright": "2026 Example Studio",
      "release": "afterApproval"
    },
    "googlePlay": { "contactEmail": "help@example.com", "contactWebsite": "https://example.com", "defaultLanguage": "ko-KR" }
  }
}
```

```sh
allob store setup            # 무엇이 바뀌는지 보여 줘요
allob store setup --apply
```

| 항목 | 뜻 |
|---|---|
| `price` | `"free"` 또는 `{ "country": "US", "amount": 2.99 }`. Apple 가격표에 있는 금액이어야 하고, 없으면 가까운 금액을 알려 줘요. 다른 나라는 Apple 이 환산해요. 다른 나라에 손으로 정해 둔 가격이 있으면 환산 가격으로 바뀌니, 계획에 그 나라 수를 함께 보여 줘요 |
| `category` | App Store Connect 카테고리 ID(`PRODUCTIVITY`·`GAMES` 같은). 게임이면 `primarySubcategories`(예: `["GAMES_PUZZLE"]`)로 하위 카테고리를 두 개까지 |
| `ageRating` | App Store Connect 연령 등급 질문지의 답. Apple 이 더는 묻지 않는 질문은 빼고 보내고 결과에 알려 줘요 |
| `contentRights` | 다른 사람의 콘텐츠를 쓰지 않으면 `"none"`, 쓰면 `"thirdParty"` |
| `reviewContact` | 심사 담당자가 연락할 이름·전화번호·이메일(`firstName`·`lastName`·`phone`·`email`)과 메모(`notes`). `copyFrom` 에 같은 계정의 다른 앱 ID 나 이름을 적으면 그 앱의 연락처를 옮겨 와서 전화번호를 파일에 적지 않아도 돼요. 심사 담당자가 로그인해야 하면 `demoAccount: true` — 데모 계정 이름·비밀번호는 App Store Connect › 앱 심사 정보에 직접 넣어요(allob 은 갖지 않아요). 적지 않으면 App Store Connect 에 있는 값 그대로예요 — 거기서 로그인이 필요하다고 돼 있는데 데모 계정이 없으면 계획에 알려 줘요 |
| `copyright` | 저작권 표시(예: `2026 Example Studio`) |
| `release` | 심사를 통과한 버전을 언제 내보낼지: `"afterApproval"`(통과하면 바로), `"manual"`(내가 직접), `{ "after": "2026-11-05T09:00:00+09:00" }`(그 시각 이후 — Apple 은 정시로만 받아 올려요). 적지 않으면 지금 설정을 건드리지 않아요 |
| `googlePlay` | 연락 이메일(`contactEmail`)·전화번호(`contactPhone`)·웹사이트(`contactWebsite`)·기본 언어(`defaultLanguage`) |

- 심사 연락처·저작권·출시 방식은 심사 준비 중인 버전에 넣어요. 그런 버전이 없으면 먼저 만들라고 알려 줘요.
- 연령 등급 답은 앱 주인이 확인한 것만 적어요. 답이 틀리면 심사에서 반려되거나 앱이 내려갈 수 있어요. [법 문서](/docs/legal)의 사실에서 답 초안을 만들 수 있어요.
- Google Play 의 카테고리, 무료·유료, 콘텐츠 등급 설문은 API 가 없어서 Play Console 에서 해요. 계획 끝에 그 안내가 한 줄 보여요.

### 판매 국가

팔지 않을 나라(`exclude`) 또는 팔 나라만(`only`)을 두 글자 나라 코드로 적어요. 둘 중 하나만 써요.

```json
{ "countries": { "exclude": ["RU", "CN"] } }
```

```json
{ "countries": { "only": ["KR"] } }
```

```sh
allob store countries            # 지금 몇 나라에서 파는지, 무엇이 바뀌는지
allob store countries --apply
```

- App Store: 앱을 그 나라에서 팔지 않게 해요. 판매 국가를 정한 적이 없으면 그 나라만 빼고 모든 나라로 정해요. 인앱 상품·구독도 그 나라만 빼고, 다른 나라·가격·새 나라 자동 추가 설정은 그대로 둬요.
- Google Play: 한 번 사는 상품은 그 나라에서 더 팔지 않고(판매한 적 있는 나라는 지울 수 없어서 판매 중지로 둬요), 구독은 그 나라에서 새 구독만 막아요. 이미 구독한 사람은 계속 갱신돼요.
- `exclude` 는 나라를 다시 열지 않아요. 다시 팔려면 각 스토어 화면에서 열어요.
- `only` 는 그 나라에서만 팔아요. 다른 나라는 `exclude` 와 같은 방식으로 멈추고, 꺼져 있던 그 나라는 켜요. 상품은 Apple 이 새로 여는 나라를 따라가지 않게 해요. 판매 국가를 이미 정한 App Store 앱은 "새 나라에도 팔기" 설정을 API 로 바꿀 수 없어서, 켜져 있으면 App Store Connect 에서 끄라고 알려 줘요.
- Google Play 트랙별 배포 국가는 API 로 바꿀 수 없어요. 뺄 나라가 아직 들어 있는 트랙을 알려 주면 Play Console › 테스트 및 출시 › 트랙 › 국가/지역에서 빼요.
- 새 상품을 만든 뒤에는 이 명령을 한 번 더 실행해요. 새 상품은 모든 나라에서 팔도록 만들어지거든요.
- `countries` 를 적지 않으면 뺄 나라가 없어서, 판매 국가를 이미 정한 앱과 상품은 그대로 두고 지금 판매 국가 수만 보여 줘요. 판매 국가를 아직 정한 적 없는 새 앱이면 모든 나라에서 팔도록 정해요.
- 제품에 연결하지 않은 스토어는 건너뛰고 계획에 알려 줘요. 예를 들어 Google Play 를 아직 연결하지 않았어도 App Store 판매 국가는 바꿀 수 있어요.

### 라이선스 계약(EULA)

App Store 는 기본으로 Apple 표준 라이선스 계약을 써요. 앱만의 약관을 계약으로 걸려면 그 글을 넣어요.

```sh
allob store eula --url https://example.com/terms     # 공개 약관 페이지에서 글을 읽어요
allob store eula --file legal/terms.html --apply      # 파일(글자·HTML)에서
allob store eula --standard --apply                    # Apple 표준 계약으로 되돌려요
```

- 약관 페이지는 `<article>`(없으면 `<main>`) 안의 글만 읽고, 링크는 "글 (주소)" 로 바꿔요. `allob.store.json` 에 `"eula": "https://…"` 나 파일 경로를 적어 두면 깃발 없이 실행해도 돼요.
- App Store Connect 에는 모든 나라에 같은 글 하나만 들어가요. 여러 언어가 필요하면 한 페이지나 파일에 모두 넣거나, `"eula"` 에 언어마다 약관 페이지 주소를 목록으로 적어요(영어 먼저). 목록이면 차례대로 읽어 가로줄 한 줄을 사이에 두고 이어 붙여요. 이어 붙이는 모양은 늘 같아서 페이지가 그대로면 계획도 "그대로"예요.
- 글이 너무 짧으면 막고, Apple 이 요구하는 최소 조건(제3자 수익자 문장)이 보이지 않으면 알려 줘요.
- 승인한 글 그대로 보내요. 요청한 뒤 약관 페이지가 바뀌어도 승인한 글이 들어가요.
- 자동 갱신 구독이 있는 앱은 맞춤 계약을 넣거나, 설명 끝에 Apple 표준 계약 링크를 넣어야 심사를 통과해요.

### 번들 ID 와 기능

App Store Connect 에 앱을 만들기 전에 번들 ID 부터 등록해요. 제품에 App Store Connect 연결만 있으면 되고, 앱은 아직 고르지 않아도 돼요.

```json
{
  "bundle": {
    "id": "com.example.diary",
    "capabilities": ["APPLE_ID_AUTH", "PUSH_NOTIFICATIONS"],
    "extensions": [{ "id": "com.example.diary.widget", "name": "Diary Widget", "capabilities": ["APP_GROUPS"] }]
  }
}
```

```sh
allob store bundle --apply          # 번들 ID(와 앱 확장)를 등록해요
allob store capabilities --apply    # 적어 둔 기능을 켜요
```

- 등록한다고 앱이 만들어지지는 않아요. App Store Connect › 앱 › + 에서 앱을 만든 뒤(API 가 없어요) 제품에 연결해요.
- 플랫폼은 제품을 따라요. iOS 가 있으면 iOS, macOS 만 있으면 macOS 로 등록해요.
- 기능은 App Store Connect 기능 이름(`APPLE_ID_AUTH`·`PUSH_NOTIFICATIONS`·`GAME_CENTER`·`ICLOUD`·`APP_GROUPS`·`IN_APP_PURCHASE` 같은)으로 적어요. 이미 켜진 기능은 그대로 두고, 적지 않은 기능을 끄지 않아요. `bundle.id` 가 없으면 제품에 연결한 앱의 번들 ID 를 써요.
- 기능을 켠 뒤에는 새로 빌드해야 프로비저닝 프로필에 들어가요. 앱 그룹 자체는 Apple Developer 웹사이트에서 만들어요.

:::prompt
allob.store.json 에 setup·countries·bundle 을 채워 줘. 앱 이름과 기능은 코드에서 찾아서 넣고, 연령 등급 답은 초안만 만들어서 나한테 확인받아 줘. 그다음 allob store setup, allob store countries, allob store bundle, allob store capabilities 로 계획을 차례로 보여 주고, 내가 확인하면 --apply 로 승인 요청을 남겨 줘.
:::

## 단계 확인

`allob store status` 는 스토어 단계마다 끝났는지 알려 줘요. 끝나지 않았으면 이유도 같이 보여 줘요.

| 단계 | 끝난 것으로 보는 때 |
|---|---|
| App Store 앱 등록 | App Store Connect 에 앱이 있고 allob 이 읽을 수 있을 때 |
| iOS 빌드 | 처리가 끝난(사용 가능) 빌드가 하나 이상 있을 때 |
| App Store 등록정보 | 모든 언어에 이름·설명·키워드·지원 URL·개인정보 처리방침 URL 이 있을 때 |
| App Store 스크린샷 | 기본 언어에 스크린샷이 한 장 이상 있을 때 |
| App Store 크리에이티브 이미지 | 기본 언어에 제품 페이지 머리 이미지와 검색 결과 이미지가 모두 놓였을 때 |
| 연령 등급 | 연령 등급 질문지에 답했을 때 |
| App Store 심사 제출 | 버전을 심사에 냈거나 이미 판매 중일 때 |
| Play Console 앱 등록 | Play Console 에 앱이 있고 서비스 계정에 권한이 있을 때 |
| Android 빌드 | 어느 트랙이든 빌드가 올라가 있을 때 |
| Play 등록정보 | 모든 언어에 앱 이름·간단한 설명·자세한 설명이 있을 때 |
| Play 그래픽 | 아이콘, 그래픽 이미지, 휴대전화 스크린샷 2장 이상이 있을 때 |
| Play 프로덕션 출시 | 프로덕션 트랙에 배포됐거나 배포 중일 때 |
| 스토어 인앱 상품 | App Store 상품이 제출 준비 이상, Play 상품이 판매 중일 때 |

**직접 확인**으로 나오는 단계는 스토어 API 로 알 수 없거나, 연결이나 앱 선택이 빠져서 읽지 못한 거예요.

단계 아래 **확인할 것**에는 놓치기 쉬운 것을 따로 보여 줘요.

- 자동 갱신 구독이 있는데 라이선스 계약(맞춤 계약이나 설명의 Apple 표준 계약 링크)이 없을 때 — 심사에서 반려돼요.
- 해마다 하나씩 파는 상품이 3년 안에 끝날 때 — `years.to` 를 늘려 미리 만들어 둬요.
- RevenueCat 앱에 스토어 자격(앱 안 구매 키·Play 서비스 계정)이 없을 때 — 구매를 확인하지 못해요.
- `allob.store.json` 에 `"revenueCat": { "keysFile": "src/pay/keys.ts" }` 를 적어 두면, 그 파일에 RevenueCat 공개 SDK 키가 들어 있는지도 봐요. 파일은 이 컴퓨터에서만 읽고 보내지 않아요. 공개 키는 `allob store view` 의 RevenueCat 부분(sdk keys)에 있어요.

## 사람이 직접 해야 하는 일

스토어 API 로는 할 수 없어서 사람이 브라우저에서 해야 하는 일이 있어요. allob 은 어디서 무엇을 누를지 알려 주고, 끝났는지는 다음 확인 때 스토어 상태로 봐요.

| 일 | 이유 |
|---|---|
| App Store Connect 에 새 앱 만들기 | Apple 이 API 로는 앱을 만들 수 없게 해 두었어요. 번들 ID 는 `allob store bundle` 로 먼저 등록하고, 웹에서 + 를 눌러 앱을 만들어요 |
| Play Console 에 새 앱 만들기 | Google Play API 로는 앱을 만들 수 없어요. Play Console 에서 **앱 만들기**를 눌러 만들고, 패키지 이름을 제품에 입력해요 |
| App Store 앱 개인정보 보호 답변 | 앱이 모으는 데이터와 추적 여부를 App Store Connect › 앱 개인정보 보호에서 답해요. 결제·광고·로그인을 바꾸면 답도 고쳐요 |
| 연령 등급 질문지 | 답은 앱 주인이 정해요. 답이 틀리면 심사에서 반려되거나 앱이 내려갈 수 있어요. App Store 는 주인이 확인한 답을 `allob store setup` 의 `ageRating` 으로 보낼 수 있고, Play Console(앱 콘텐츠 › 콘텐츠 등급)은 API 가 없어 웹에서 답해요 |
| 계약·세금·은행 정보 | App Store Connect › 비즈니스, Play Console › 결제 프로필에서 계정 소유자가 동의하고 입력해요 |
| 유료 앱 계약 확인 | Apple 은 계약 상태를 알려 주는 API 가 없어요. 계약이 빠졌으면 다른 요청이 막혀서 그때 알려 드려요. 승인된 인앱 상품이 있으면 계약이 살아 있는 거예요 |
| Play 첫 출시 | Play Console 은 앱의 첫 빌드를 웹에서 올리고, 앱 콘텐츠 설문(데이터 보안 등)을 마쳐야 API 로 트랙을 다룰 수 있어요 |
| 테스터 목록 | TestFlight·Play 테스트 트랙의 테스터 이메일 목록은 각 콘솔에서 관리해요 |
| 수출 규정 답 | 빌드마다 암호화 사용 여부를 답해야 심사에 낼 수 있어요. Info.plist 에 `ITSAppUsesNonExemptEncryption` 을 넣어 두면 빌드를 올릴 때 답이 같이 가요 |
| 앱 미리보기 영상 | App Store Connect 에서 올려요 |
| 이미 정한 상품 가격 바꾸기 | 각 스토어 화면에서 바꿔요 |
