@yoonion/mimi-seed-mcp 0.13.12 → 0.13.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,8 +34,11 @@ const CODE_HINTS = {
34
34
  NOT_FOUND: '대상 리소스를 찾을 수 없어요 — id/versionId/buildId 가 유효한지, 동일 앱 소속인지 확인.',
35
35
  // 권한 부족 (API Key role 문제)
36
36
  FORBIDDEN_ERROR: 'API Key 권한 부족 — App Store Connect > Users and Access 에서 키 role 확인.',
37
- // submit 직후 cancel 시도 (큐 진입 후)
38
- STATE_ERROR: '현재 상태에서 허용되지 않는 작업. cancel_review 라면 큐 진입 후라서 불가 versionString 으로 우회.',
37
+ // 상태 전이 거부. cancel WAITING_FOR_REVIEW 까지 되고, IN_REVIEW 이후만 막힌다.
38
+ // (예전 문구는 "큐 진입 후라 불가"였는데, 그건 cancel submitted:false 를 보내
39
+ // 항상 실패하던 버그의 증상을 상태 탓으로 오해한 것이었다 — 2026-07-25 정정.)
40
+ STATE_ERROR: '현재 상태에서 허용되지 않는 작업. 제출된 묶음은 항목 제거(removed)가 막히니 '
41
+ + 'appstore_cancel_review 로 묶음째 취소하세요. IN_REVIEW 이후면 그마저도 불가 — 심사 결과를 기다립니다.',
39
42
  // JWT 거부 — 잘못된 자격증명
40
43
  NOT_AUTHORIZED: 'API 키가 거부됐어요 — issuerId/keyId/.p8 조합 불일치 가능. appstore_verify_credentials 로 진단하고 mimi-seed auth appstore 로 재등록.',
41
44
  };
@@ -12,12 +12,15 @@ export declare function updateProductReviewNote(args: {
12
12
  }>;
13
13
  /**
14
14
  * 기존 IAP/구독 상품에 App Review 스크린샷을 reserve → upload → commit 한다.
15
- * App Store Connect API는 상품당 심사용 스크린샷 하나를 허용한다.
15
+ * App Store Connect API는 상품당 심사용 스크린샷 하나를 허용한다 —
16
+ * 이미 있으면 409 `MEDIA_ASSET_CREATION_NOT_ALLOWED: Screenshot already exists` 다.
17
+ * 갈아끼우려면 replace: true (기존 것을 지우고 올린다).
16
18
  */
17
19
  export declare function uploadProductReviewScreenshot(args: {
18
20
  internalId: string;
19
21
  productType: AppStoreProductType;
20
22
  filePath: string;
23
+ replace?: boolean;
21
24
  }): Promise<{
22
25
  id: string;
23
26
  internalId: string;
@@ -26,4 +29,5 @@ export declare function uploadProductReviewScreenshot(args: {
26
29
  fileSize: number;
27
30
  state?: string;
28
31
  verified: boolean;
32
+ replacedId?: string;
29
33
  }>;
@@ -70,12 +70,31 @@ async function uploadChunks(buffer, operations) {
70
70
  }
71
71
  }
72
72
  }
73
+ /**
74
+ * 상품에 이미 붙어 있는 심사 스크린샷 id 를 찾는다. 없으면 null.
75
+ *
76
+ * 조회 경로가 상품 종류마다 다르다 — 구독은 v1, IAP 는 **v2** 다.
77
+ * v1 으로 IAP 를 조회하면 스크린샷이 있어도 404 가 나서 "없음"으로 오독하기 쉽다
78
+ * (2026-07-25 실측: 404 를 믿고 업로드했다가 "Screenshot already exists" 409).
79
+ */
80
+ async function findExistingReviewScreenshotId(internalId, productType, authHeaders) {
81
+ const product = productResource(productType);
82
+ try {
83
+ const res = await apiRequest(product.base, `${product.path}/${internalId}/appStoreReviewScreenshot`, authHeaders, { method: 'GET' });
84
+ return res?.data?.id ?? null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
73
90
  /**
74
91
  * 기존 IAP/구독 상품에 App Review 스크린샷을 reserve → upload → commit 한다.
75
- * App Store Connect API는 상품당 심사용 스크린샷 하나를 허용한다.
92
+ * App Store Connect API는 상품당 심사용 스크린샷 하나를 허용한다 —
93
+ * 이미 있으면 409 `MEDIA_ASSET_CREATION_NOT_ALLOWED: Screenshot already exists` 다.
94
+ * 갈아끼우려면 replace: true (기존 것을 지우고 올린다).
76
95
  */
77
96
  export async function uploadProductReviewScreenshot(args) {
78
- const { internalId, productType, filePath } = args;
97
+ const { internalId, productType, filePath, replace = false } = args;
79
98
  if (!path.isAbsolute(filePath)) {
80
99
  throw new Error(`절대 경로가 필요해: ${filePath}`);
81
100
  }
@@ -94,6 +113,15 @@ export async function uploadProductReviewScreenshot(args) {
94
113
  const checksum = crypto.createHash('md5').update(buffer).digest('hex');
95
114
  const resource = reviewScreenshotResource(productType);
96
115
  const authHeaders = await authHeadersOrThrow();
116
+ // 상품당 1장이라 새로 올리려면 기존 것을 먼저 지워야 한다.
117
+ let replacedId;
118
+ if (replace) {
119
+ const existing = await findExistingReviewScreenshotId(internalId, productType, authHeaders);
120
+ if (existing) {
121
+ await apiRequest(V1_BASE, `${resource.path}/${existing}`, authHeaders, { method: 'DELETE' });
122
+ replacedId = existing;
123
+ }
124
+ }
97
125
  const reserved = await apiRequest(V1_BASE, resource.path, authHeaders, {
98
126
  method: 'POST',
99
127
  headers: { 'Content-Type': 'application/json' },
@@ -148,5 +176,5 @@ export async function uploadProductReviewScreenshot(args) {
148
176
  catch {
149
177
  // commit 성공 후 확인 GET만 실패하면 재업로드를 유도하지 않는다.
150
178
  }
151
- return { id: screenshotId, internalId, productType, fileName, fileSize, state, verified };
179
+ return { id: screenshotId, internalId, productType, fileName, fileSize, state, verified, replacedId };
152
180
  }
@@ -40,6 +40,23 @@ export declare function createVersion(input: CreateVersionInput): Promise<{
40
40
  releaseType: any;
41
41
  createdDate: any;
42
42
  }>;
43
+ /**
44
+ * 기존 버전 레코드의 versionString 을 바꾼다 (예: 2.0.5 → 2.0.6).
45
+ *
46
+ * 왜 필요한가: ASC 는 편집 가능한 버전이 이미 있으면 새 버전 생성을 거부한다
47
+ * 409 ENTITY_ERROR.RELATIONSHIP.INVALID "You cannot create a new version of the App in the current state"
48
+ * 그래서 거절/철회된 버전으로 다음 릴리스를 내보내려면 **같은 레코드의 이름을 올려야** 한다.
49
+ * 빌드는 CFBundleShortVersionString 이 일치하는 버전에만 붙으므로, 2.0.6 빌드를 올렸다면
50
+ * 버전 레코드도 2.0.6 이어야 attach 가 된다.
51
+ *
52
+ * 편집 가능한 상태(PREPARE_FOR_SUBMISSION / DEVELOPER_REJECTED / REJECTED 등)에서만 통한다.
53
+ */
54
+ export declare function updateVersionString(versionId: string, versionString: string): Promise<{
55
+ versionId: string;
56
+ versionString: any;
57
+ state: any;
58
+ platform: any;
59
+ }>;
43
60
  export declare function attachBuildToVersion(versionId: string, buildId: string): Promise<{
44
61
  versionId: string;
45
62
  buildId: string;
@@ -137,9 +154,11 @@ export declare function createReviewResponse(reviewId: string, responseBody: str
137
154
  /**
138
155
  * submit_for_review dry-run 프리뷰 — 비가역 제출 직전 사용자 확인용.
139
156
  *
140
- * 1.4.x 배포 사고 누적: submit 직후 cancel_review 진입으로 막혀 versionString
141
- * bump 으로 우회해야 하는 케이스가 반복됨 (reference_appstore_cancel_review_window).
142
- * 호출자가 의도한 버전·빌드인지 미리 보여줘서 잘못된 versionId 제출 차단.
157
+ * 1.4.x 배포 사고 누적: submit 직후 되돌리지 못해 versionString bump 으로 우회해야
158
+ * 하는 케이스가 반복됨 (reference_appstore_cancel_review_window). 그 원인이던
159
+ * cancel_review 버그(submitted:false 항상 409)는 2026-07-25 canceled:true 고쳤고,
160
+ * 이제 WAITING_FOR_REVIEW 까지는 되돌릴 수 있다. 그래도 IN_REVIEW 이후는 못 되돌리니
161
+ * 호출자가 의도한 그 버전·빌드인지 미리 보여줘서 잘못된 versionId 제출을 차단한다.
143
162
  */
144
163
  export declare function buildSubmitForReviewPreview(versionId: string): Promise<{
145
164
  versionId: string;
@@ -189,6 +208,10 @@ export interface ReviewSubmissionSummary {
189
208
  targetId?: string;
190
209
  versionString?: string;
191
210
  appVersionState?: string;
211
+ /** IAP/구독/구독그룹 항목의 식별자 — productId 또는 referenceName. */
212
+ label?: string;
213
+ /** 대상 리소스 자체의 상태 (상품이면 READY_TO_SUBMIT / WAITING_FOR_REVIEW 등). */
214
+ targetState?: string;
192
215
  }>;
193
216
  }
194
217
  export declare function listReviewSubmissions(args: {
@@ -206,6 +229,30 @@ export declare function removeReviewSubmissionItem(itemId: string): Promise<{
206
229
  state?: string;
207
230
  removed: boolean;
208
231
  }>;
232
+ /**
233
+ * 이미 존재하는 묶음에 앱 버전을 항목으로 끼워 넣는다.
234
+ *
235
+ * 앱 첫 심사에서 반드시 필요하다. 웹에서 상품을 담으면 **상품만 든 묶음**이 새로 생기는데,
236
+ * 그대로 제출하면 Apple 이 막는다:
237
+ * 409 ENTITY_ERROR.RELATIONSHIP.REQUIRED
238
+ * "must have an approved appStoreVersions ... or an appStoreVersions must be included
239
+ * in this review submission"
240
+ * 즉 상품 묶음에는 앱 버전이 함께 있어야 한다. reviewSubmissionItems 는 상품 관계는
241
+ * 거부하지만 **appStoreVersion 관계는 받는다** — 그래서 버전만 이쪽으로 옮기면 된다.
242
+ *
243
+ * 버전이 다른 묶음에 이미 물려 있으면 그 묶음을 먼저 정리해야 한다:
244
+ * - 미제출(READY_FOR_REVIEW) 묶음이면 appstore_remove_review_submission_item
245
+ * - 제출된(WAITING_FOR_REVIEW) 묶음이면 항목 제거가 막히므로 appstore_cancel_review 로 묶음째 취소
246
+ */
247
+ export declare function addVersionToReviewSubmission(args: {
248
+ submissionId: string;
249
+ versionId: string;
250
+ }): Promise<{
251
+ itemId?: string;
252
+ submissionId: string;
253
+ versionId: string;
254
+ itemCount: number;
255
+ }>;
209
256
  export declare function cancelVersionReview(versionId: string): Promise<{
210
257
  submissionId: string;
211
258
  previousState: string;
@@ -196,6 +196,33 @@ export async function createVersion(input) {
196
196
  createdDate: created?.data?.attributes?.createdDate,
197
197
  };
198
198
  }
199
+ /**
200
+ * 기존 버전 레코드의 versionString 을 바꾼다 (예: 2.0.5 → 2.0.6).
201
+ *
202
+ * 왜 필요한가: ASC 는 편집 가능한 버전이 이미 있으면 새 버전 생성을 거부한다
203
+ * 409 ENTITY_ERROR.RELATIONSHIP.INVALID "You cannot create a new version of the App in the current state"
204
+ * 그래서 거절/철회된 버전으로 다음 릴리스를 내보내려면 **같은 레코드의 이름을 올려야** 한다.
205
+ * 빌드는 CFBundleShortVersionString 이 일치하는 버전에만 붙으므로, 2.0.6 빌드를 올렸다면
206
+ * 버전 레코드도 2.0.6 이어야 attach 가 된다.
207
+ *
208
+ * 편집 가능한 상태(PREPARE_FOR_SUBMISSION / DEVELOPER_REJECTED / REJECTED 등)에서만 통한다.
209
+ */
210
+ export async function updateVersionString(versionId, versionString) {
211
+ const patched = await apiPatch(`/appStoreVersions/${versionId}`, {
212
+ data: {
213
+ type: 'appStoreVersions',
214
+ id: versionId,
215
+ attributes: { versionString },
216
+ },
217
+ });
218
+ const attrs = patched?.data?.attributes ?? {};
219
+ return {
220
+ versionId,
221
+ versionString: attrs.versionString ?? versionString,
222
+ state: attrs.appStoreState ?? attrs.appVersionState,
223
+ platform: attrs.platform,
224
+ };
225
+ }
199
226
  export async function attachBuildToVersion(versionId, buildId) {
200
227
  // /relationships/build 엔드포인트는 204 No Content 반환
201
228
  await apiPatch(`/appStoreVersions/${versionId}/relationships/build`, {
@@ -556,9 +583,11 @@ async function isVersionAttached(submissionId, versionId) {
556
583
  /**
557
584
  * submit_for_review dry-run 프리뷰 — 비가역 제출 직전 사용자 확인용.
558
585
  *
559
- * 1.4.x 배포 사고 누적: submit 직후 cancel_review 진입으로 막혀 versionString
560
- * bump 으로 우회해야 하는 케이스가 반복됨 (reference_appstore_cancel_review_window).
561
- * 호출자가 의도한 버전·빌드인지 미리 보여줘서 잘못된 versionId 제출 차단.
586
+ * 1.4.x 배포 사고 누적: submit 직후 되돌리지 못해 versionString bump 으로 우회해야
587
+ * 하는 케이스가 반복됨 (reference_appstore_cancel_review_window). 그 원인이던
588
+ * cancel_review 버그(submitted:false 항상 409)는 2026-07-25 canceled:true 고쳤고,
589
+ * 이제 WAITING_FOR_REVIEW 까지는 되돌릴 수 있다. 그래도 IN_REVIEW 이후는 못 되돌리니
590
+ * 호출자가 의도한 그 버전·빌드인지 미리 보여줘서 잘못된 versionId 제출을 차단한다.
562
591
  */
563
592
  export async function buildSubmitForReviewPreview(versionId) {
564
593
  const { appId, platform } = await getVersionAppAndPlatform(versionId);
@@ -742,10 +771,21 @@ export async function addProductToReviewSubmission(args) {
742
771
  const enriched = new Error([
743
772
  `상품 ${internalId} (${productType}) 은 API 로 심사 제출할 수 없는 상태야 — Apple 응답: "no pending version for submission".`,
744
773
  '',
745
- '이건 보통 **앱 첫 심사** 케이스다. 한 번도 승인된 적 없는 상품은 공개 API 로 심사에 못 넣고,',
746
- 'App Store Connect 웹의 해당 버전 페이지 "앱 내 구입 및 구독" 섹션에서 상품을 선택해',
747
- '버전과 같은 묶음으로 제출해야 한다 (reviewSubmissionItems 는 상품 관계를 받지 않는다 — 2026-07 실측).',
748
- '버전이 이미 심사 대기 중이면 appstore_cancel_review 로 내린 뒤 웹에서 담고 재제출한다.',
774
+ '**앱 첫 심사** 케이스다. 한 번도 승인된 적 없는 상품은 공개 API 로 심사에 못 넣는다.',
775
+ ' 버전을 심사 대기로 만들어도 벽은 그대로다 (2026-07-25 재확인).',
776
+ '',
777
+ '실제로 통하는 순서 (PenguinRun 2.0.6 실측):',
778
+ ' 1. appstore_submit_for_review 로 앱 버전을 먼저 제출한다.',
779
+ ' → 상품은 자동으로 안 딸려간다. 버전 1개짜리 묶음 A 가 생긴다. 이건 정상이다.',
780
+ ' 2. 그래야 ASC 웹에 상품의 "심사 추가" UI 가 나타난다. 웹에서 상품을 담으면',
781
+ ' 상품들만 든 **새 묶음 B** 가 READY_FOR_REVIEW(미제출)로 생성된다.',
782
+ ' 3. B 를 그냥 제출하면 409: "an appStoreVersions must be included in this review submission".',
783
+ ' 상품 묶음에는 앱 버전이 반드시 함께 있어야 한다.',
784
+ ' 4. appstore_cancel_review 로 묶음 A 를 취소해 버전을 풀어준다.',
785
+ ' 5. POST /reviewSubmissionItems 로 B 에 appStoreVersion 을 추가한다.',
786
+ ' (상품 관계는 거부되지만 **버전 관계는 받는다**.)',
787
+ ' 6. B 를 submitted=true 로 PATCH → 버전+상품이 한 묶음으로 제출된다.',
788
+ '',
749
789
  '이미 승인된 적 있는 상품이라면: 변경분(pending version)이 실제로 있는지 확인.',
750
790
  ].join('\n'));
751
791
  enriched.cause = error.cause;
@@ -779,22 +819,30 @@ export async function listReviewSubmissions(args) {
779
819
  const submissions = (data?.data ?? []);
780
820
  const result = [];
781
821
  for (const sub of submissions) {
822
+ // 버전만 include 하면 IAP·구독·구독그룹 항목이 전부 "?" 로 남아, 정작 중요한
823
+ // "이 묶음에 상품이 들어갔나"를 이 도구로 판별할 수 없었다 (2026-07-25 실측).
782
824
  const itemsData = await apiGet(`/reviewSubmissions/${sub.id}/items`, {
783
- include: 'appStoreVersion',
825
+ include: 'appStoreVersion,inAppPurchaseV2,subscription,subscriptionGroup',
784
826
  'fields[appStoreVersions]': 'versionString,appVersionState',
827
+ 'fields[inAppPurchases]': 'productId,name,state',
828
+ 'fields[subscriptions]': 'productId,name,state',
829
+ 'fields[subscriptionGroups]': 'referenceName',
785
830
  limit: '50',
786
831
  }).catch(() => null);
787
832
  const included = new Map((itemsData?.included ?? []).map((inc) => [`${inc.type}:${inc.id}`, inc]));
788
833
  const items = (itemsData?.data ?? []).map((item) => {
789
834
  const target = Object.entries(item.relationships ?? {}).find(([key, rel]) => key !== 'reviewSubmission' && rel?.data?.id)?.[1]?.data;
790
835
  const inc = target?.type && target.id ? included.get(`${target.type}:${target.id}`) : undefined;
836
+ const a = inc?.attributes;
791
837
  return {
792
838
  id: item.id,
793
839
  state: item.attributes?.state,
794
840
  targetType: target?.type,
795
841
  targetId: target?.id,
796
- versionString: inc?.attributes?.versionString,
797
- appVersionState: inc?.attributes?.appVersionState,
842
+ versionString: a?.versionString,
843
+ appVersionState: a?.appVersionState,
844
+ label: a?.productId ?? a?.referenceName ?? a?.name,
845
+ targetState: a?.state,
798
846
  };
799
847
  });
800
848
  result.push({
@@ -817,6 +865,41 @@ export async function removeReviewSubmissionItem(itemId) {
817
865
  removed: patched?.data?.attributes?.removed ?? true,
818
866
  };
819
867
  }
868
+ /**
869
+ * 이미 존재하는 묶음에 앱 버전을 항목으로 끼워 넣는다.
870
+ *
871
+ * 앱 첫 심사에서 반드시 필요하다. 웹에서 상품을 담으면 **상품만 든 묶음**이 새로 생기는데,
872
+ * 그대로 제출하면 Apple 이 막는다:
873
+ * 409 ENTITY_ERROR.RELATIONSHIP.REQUIRED
874
+ * "must have an approved appStoreVersions ... or an appStoreVersions must be included
875
+ * in this review submission"
876
+ * 즉 상품 묶음에는 앱 버전이 함께 있어야 한다. reviewSubmissionItems 는 상품 관계는
877
+ * 거부하지만 **appStoreVersion 관계는 받는다** — 그래서 버전만 이쪽으로 옮기면 된다.
878
+ *
879
+ * 버전이 다른 묶음에 이미 물려 있으면 그 묶음을 먼저 정리해야 한다:
880
+ * - 미제출(READY_FOR_REVIEW) 묶음이면 appstore_remove_review_submission_item
881
+ * - 제출된(WAITING_FOR_REVIEW) 묶음이면 항목 제거가 막히므로 appstore_cancel_review 로 묶음째 취소
882
+ */
883
+ export async function addVersionToReviewSubmission(args) {
884
+ const { submissionId, versionId } = args;
885
+ const created = await apiPost('/reviewSubmissionItems', {
886
+ data: {
887
+ type: 'reviewSubmissionItems',
888
+ relationships: {
889
+ reviewSubmission: { data: { type: 'reviewSubmissions', id: submissionId } },
890
+ appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
891
+ },
892
+ },
893
+ });
894
+ // 제출 전에 몇 개가 들어있는지 보여준다 — 첫 심사에서 "상품이 빠졌는지"를 눈으로 확인해야 한다.
895
+ const items = await apiGet(`/reviewSubmissions/${submissionId}/items`, { limit: '50' }).catch(() => null);
896
+ return {
897
+ itemId: created?.data?.id,
898
+ submissionId,
899
+ versionId,
900
+ itemCount: (items?.data ?? []).length,
901
+ };
902
+ }
820
903
  /**
821
904
  * 버전을 물고 있는 낡은 묶음(UNRESOLVED_ISSUES)에서 해당 버전 항목을 removed=true 로
822
905
  * 풀어준다. 항목이 풀리면 Apple 이 옛 묶음을 COMPLETE 로 정리하고, 종종 새
@@ -846,7 +929,7 @@ async function releaseVersionFromStaleSubmissions(appId, platform, versionId) {
846
929
  // ─── 심사 철회 (Cancel Review) ───
847
930
  // WAITING_FOR_REVIEW 상태의 reviewSubmission에만 적용 가능.
848
931
  // IN_REVIEW 진입 후에는 Apple API가 거부함 (409).
849
- // submitted=false PATCH → version PREPARE_FOR_SUBMISSION으로 복귀.
932
+ // PATCH attributes.canceled=true state CANCELING COMPLETE, version 편집 가능 상태로 복귀.
850
933
  export async function cancelVersionReview(versionId) {
851
934
  const { appId, platform } = await getVersionAppAndPlatform(versionId);
852
935
  // 취소 가능한 상태(WAITING_FOR_REVIEW)의 submission 검색
@@ -866,15 +949,21 @@ export async function cancelVersionReview(versionId) {
866
949
  }
867
950
  const submissionId = submission.id;
868
951
  const previousState = submission.attributes?.state ?? 'WAITING_FOR_REVIEW';
869
- // submitted=false WAITING_FOR_REVIEW READY_FOR_REVIEW (version은 PREPARE_FOR_SUBMISSION으로 복귀)
952
+ // 취소 속성은 canceled 다. submitted:false Apple 이 거부한다:
953
+ // 409 ENTITY_ERROR.ATTRIBUTE.INVALID "submitted must be set to true if present"
954
+ // 예전 구현이 submitted:false 를 보내서 이 도구는 사실상 항상 실패했고, 그 탓에
955
+ // "큐 진입 후에는 웹에서만 취소 가능" 이라는 잘못된 통설이 굳어 있었다.
956
+ // 실제로는 WAITING_FOR_REVIEW 에서도 canceled:true 가 통한다 (2026-07-25 실측):
957
+ // 200 → state: CANCELING → (수십 초) → COMPLETE, 항목은 REMOVED, 버전은 편집 가능 복귀.
870
958
  const patched = await apiPatch(`/reviewSubmissions/${submissionId}`, {
871
959
  data: {
872
960
  type: 'reviewSubmissions',
873
961
  id: submissionId,
874
- attributes: { submitted: false },
962
+ attributes: { canceled: true },
875
963
  },
876
964
  });
877
- const newState = patched?.data?.attributes?.state ?? 'READY_FOR_REVIEW';
965
+ // CANCELING 비동기다 호출 직후엔 아직 COMPLETE 가 아니다.
966
+ const newState = patched?.data?.attributes?.state ?? 'CANCELING';
878
967
  return { submissionId, previousState, newState, versionId };
879
968
  }
880
969
  // ─── 인앱 구매 (IAP) 생성 ───
@@ -22,7 +22,11 @@ export interface PlayStatisticsQuery {
22
22
  userCohort?: 'OS_PUBLIC' | 'APP_TESTERS' | 'OS_BETA';
23
23
  timeZone?: string;
24
24
  }
25
- export declare function withEdit<T>(auth: OAuth2Client | JWT, packageName: string, fn: (editId: string) => Promise<T>, commit?: boolean): Promise<T>;
25
+ export interface EditCommitInfo {
26
+ /** true 면 커밋은 됐지만 심사 전송은 Play Console 에서 사람이 눌러야 한다. */
27
+ changesNotSentForReview: boolean;
28
+ }
29
+ export declare function withEdit<T>(auth: OAuth2Client | JWT, packageName: string, fn: (editId: string) => Promise<T>, commit?: boolean, onCommit?: (info: EditCommitInfo) => void): Promise<T>;
26
30
  export declare function getStatistics(auth: OAuth2Client | JWT, packageName: string, query: PlayStatisticsQuery): Promise<unknown>;
27
31
  export declare function getAppDetails(auth: OAuth2Client | JWT, packageName: string): Promise<import("googleapis/build/src/apis/androidpublisher/v3.js").androidpublisher_v3.Schema$AppDetails>;
28
32
  export declare function updateAppDetails(auth: OAuth2Client | JWT, packageName: string, data: {
@@ -95,6 +99,9 @@ export interface PromoteReleaseOptions {
95
99
  copyReleaseNotes?: boolean;
96
100
  }
97
101
  export declare function promoteRelease(auth: OAuth2Client | JWT, packageName: string, fromTrack: string, toTrack: string, versionCode: string, options?: PromoteReleaseOptions): Promise<{
102
+ changesNotSentForReview: boolean;
103
+ nextAction: string | undefined;
104
+ warnings: string[] | undefined;
98
105
  packageName: string;
99
106
  fromTrack: string;
100
107
  toTrack: string;
@@ -41,7 +41,26 @@ function dateToReportingDateTime(date, timeZone) {
41
41
  timeZone: { id: timeZone },
42
42
  };
43
43
  }
44
- export async function withEdit(auth, packageName, fn, commit = false) {
44
+ // Play 앱/계정 상태에 따라 "커밋과 동시에 심사 전송"을 거부한다:
45
+ // Changes cannot be sent for review automatically.
46
+ // Please set the query parameter changesNotSentForReview to true.
47
+ // 이 경우 그 파라미터를 붙여야만 커밋이 통과한다. 예전 구현은 파라미터를 노출하지도,
48
+ // 폴백하지도 않아서 그런 앱에서는 promote/submit 계열이 **100% 실패**했다
49
+ // (PenguinRun 2026-07-25 실측 — 결국 Android Publisher API 를 직접 쳐서 우회해야 했다).
50
+ async function commitEdit(auth, packageName, editId) {
51
+ try {
52
+ await publisher().edits.commit({ auth, packageName, editId });
53
+ return { changesNotSentForReview: false };
54
+ }
55
+ catch (err) {
56
+ const msg = String(err?.message ?? err);
57
+ if (!/changesNotSentForReview/i.test(msg))
58
+ throw err;
59
+ await publisher().edits.commit({ auth, packageName, editId, changesNotSentForReview: true });
60
+ return { changesNotSentForReview: true };
61
+ }
62
+ }
63
+ export async function withEdit(auth, packageName, fn, commit = false, onCommit) {
45
64
  const res = await publisher().edits.insert({ auth, packageName });
46
65
  const editId = res.data.id;
47
66
  if (!editId)
@@ -49,7 +68,12 @@ export async function withEdit(auth, packageName, fn, commit = false) {
49
68
  try {
50
69
  const result = await fn(editId);
51
70
  if (commit) {
52
- await publisher().edits.commit({ auth, packageName, editId });
71
+ // 반드시 먼저 커밋하고 나서 콜백에 넘긴다.
72
+ // onCommit?.(await commitEdit(...)) 로 쓰면 안 된다 — 옵셔널 호출은 수신자가
73
+ // nullish 일 때 인자 자체를 평가하지 않아서, onCommit 을 안 넘긴 호출자(대부분)에서
74
+ // 커밋이 통째로 사라진다. playstore-listing 테스트가 이걸 잡아냈다.
75
+ const info = await commitEdit(auth, packageName, editId);
76
+ onCommit?.(info);
53
77
  }
54
78
  return result;
55
79
  }
@@ -379,7 +403,9 @@ export async function promoteRelease(auth, packageName, fromTrack, toTrack, vers
379
403
  if (status === 'inProgress' && (userFraction == null || userFraction <= 0 || userFraction >= 1)) {
380
404
  throw new Error('status="inProgress"일 때 userFraction은 0과 1 사이 필수 (예: 0.1 → 10%).');
381
405
  }
382
- return withEdit(auth, packageName, async (editId) => {
406
+ let commitInfo = { changesNotSentForReview: false };
407
+ const warnings = [];
408
+ const out = await withEdit(auth, packageName, async (editId) => {
383
409
  // 1. source 트랙에서 versionCode 매칭 release 찾기
384
410
  const fromData = await publisher().edits.tracks.get({
385
411
  auth, packageName, editId, track: fromTrack,
@@ -405,6 +431,20 @@ export async function promoteRelease(auth, packageName, fromTrack, toTrack, vers
405
431
  if (status === 'inProgress' && userFraction != null) {
406
432
  newRelease.userFraction = userFraction;
407
433
  }
434
+ // 2-b. 릴리스 노트 회귀 가드.
435
+ // CI 가 internal 에 올릴 때 "v2.0.6 (70)" 같은 플레이스홀더를 한 언어만 넣어두는 경우가 흔한데,
436
+ // copyReleaseNotes 기본값(true)으로 production 에 승격하면 살아 있던 다국어 노트가
437
+ // 그 플레이스홀더로 통째 덮인다 (PenguinRun 2026-07-25 에 실제로 밟을 뻔한 함정).
438
+ // 막지는 않되(의도적일 수 있으므로) 반드시 눈에 띄게 알린다.
439
+ if (!releaseNotes && copyReleaseNotes) {
440
+ const copied = sourceRelease.releaseNotes ?? [];
441
+ const placeholder = copied.filter((n) => /^\s*v?\d+(\.\d+)*\s*(\(\d+\))?\s*$/.test(n.text ?? ''));
442
+ if (placeholder.length > 0) {
443
+ warnings.push(`${fromTrack} 의 릴리스 노트가 버전 문자열뿐이라 플레이스홀더로 보인다 ` +
444
+ `(${placeholder.map((n) => `${n.language}="${n.text}"`).join(', ')}). ` +
445
+ `이대로 ${toTrack} 에 덮어쓰면 기존 노트가 사라진다 — releaseNotes 를 직접 넘기거나 copyReleaseNotes:false 를 고려할 것.`);
446
+ }
447
+ }
408
448
  // 3. target 트랙 현재 상태 조회 후 merge
409
449
  // - 같은 versionCode가 이미 target에 있으면 그 항목을 새 release로 교체
410
450
  // - status='completed'면 활성 release가 1개여야 하므로 [newRelease]로 통째 교체
@@ -442,7 +482,19 @@ export async function promoteRelease(auth, packageName, fromTrack, toTrack, vers
442
482
  committed: true,
443
483
  result: updated.data,
444
484
  };
445
- }, true);
485
+ }, true, (info) => { commitInfo = info; });
486
+ if ((out.releaseNotesLanguages ?? []).length === 0) {
487
+ warnings.push(`${toTrack} 에 릴리스 노트 없이 승격됐다 — 스토어의 "새로운 기능"이 비어 보인다.`);
488
+ }
489
+ return {
490
+ ...out,
491
+ changesNotSentForReview: commitInfo.changesNotSentForReview,
492
+ // Play 가 자동 심사 전송을 거부한 경우, 사람이 콘솔에서 눌러야 실제 심사가 시작된다.
493
+ nextAction: commitInfo.changesNotSentForReview
494
+ ? 'Play Console 에서 "변경사항 검토 후 게시"(심사를 위해 전송)를 눌러야 심사가 시작된다.'
495
+ : undefined,
496
+ warnings: warnings.length ? warnings : undefined,
497
+ };
446
498
  }
447
499
  export async function listReviews(auth, packageName) {
448
500
  const res = await publisher().reviews.list({ auth, packageName });
@@ -554,12 +554,16 @@ export function registerAppstoreTools(server) {
554
554
  }],
555
555
  };
556
556
  });
557
- server.tool('appstore_upload_product_review_screenshot', '기존 App Store IAP/구독 상품의 심사용 스크린샷을 reserve → upload → commit. 상품당 1장, 절대 파일 경로 필요.', {
557
+ server.tool('appstore_upload_product_review_screenshot', [
558
+ '기존 App Store IAP/구독 상품의 심사용 스크린샷을 reserve → upload → commit. 상품당 1장, 절대 파일 경로 필요.',
559
+ '이미 있으면 409 "Screenshot already exists" — 갈아끼우려면 replace: true (기존 것을 지우고 올린다).',
560
+ ].join(' '), {
558
561
  appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
559
562
  productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
560
563
  productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
561
564
  filePath: z.string().describe('업로드할 PNG/JPG의 절대 파일 경로'),
562
- }, async ({ appId, productId, productType, filePath }) => {
565
+ replace: z.boolean().optional().describe('이미 스크린샷이 있으면 지우고 새로 올린다 (기본 false)'),
566
+ }, async ({ appId, productId, productType, filePath, replace }) => {
563
567
  const creds = requireAppStoreCreds();
564
568
  const products = await listAppleProducts({
565
569
  appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
@@ -572,6 +576,7 @@ export function registerAppstoreTools(server) {
572
576
  internalId: product.internalId,
573
577
  productType,
574
578
  filePath,
579
+ replace,
575
580
  });
576
581
  return {
577
582
  content: [{
@@ -581,6 +586,7 @@ export function registerAppstoreTools(server) {
581
586
  `productId: ${productId}`,
582
587
  `internalId: ${result.internalId}`,
583
588
  `screenshotId: ${result.id}`,
589
+ result.replacedId ? `교체됨 (이전 screenshotId: ${result.replacedId})` : '',
584
590
  `file: ${result.fileName} (${result.fileSize} bytes)`,
585
591
  result.state ? `state: ${result.state}` : '',
586
592
  result.verified ? '✓ commit 후 조회 확인' : '⚠ commit은 성공했지만 후속 조회는 확인하지 못함',
@@ -647,10 +653,23 @@ export function registerAppstoreTools(server) {
647
653
  if (sub.items.length === 0) {
648
654
  lines.push(' items: (없음)');
649
655
  }
656
+ // 항목 종류별 요약. 상품이 몇 개 들어갔는지가 첫 심사에서 가장 중요한 정보다.
657
+ const kinds = new Map();
658
+ for (const item of sub.items) {
659
+ const k = item.targetType ?? 'unknown';
660
+ kinds.set(k, (kinds.get(k) ?? 0) + 1);
661
+ }
662
+ lines.push(` 항목 ${sub.items.length}개` +
663
+ (kinds.size ? ` — ${[...kinds].map(([k, n]) => `${k} ${n}`).join(', ')}` : ''));
664
+ if (sub.items.length === 0) {
665
+ lines.push(' items: (없음)');
666
+ }
650
667
  for (const item of sub.items) {
651
668
  const target = item.versionString
652
669
  ? `${item.targetType} ${item.versionString} (${item.appVersionState ?? '?'})`
653
- : `${item.targetType ?? '?'} ${item.targetId ?? ''}`;
670
+ : item.label
671
+ ? `${item.targetType} ${item.label}${item.targetState ? ` (${item.targetState})` : ''}`
672
+ : `${item.targetType ?? '?'} ${item.targetId ?? ''}`;
654
673
  lines.push(` - item ${item.id}`);
655
674
  lines.push(` state: ${item.state ?? '?'} → ${target}`);
656
675
  }
@@ -676,6 +695,53 @@ export function registerAppstoreTools(server) {
676
695
  }],
677
696
  };
678
697
  });
698
+ server.tool('appstore_add_version_to_review_submission', '이미 존재하는 심사 묶음에 앱 버전을 항목으로 추가한다 (POST /reviewSubmissionItems). ' +
699
+ '⚠️ 앱 첫 심사 필수 절차 — 웹에서 IAP/구독을 담으면 "상품만 든 묶음"이 새로 생기는데, ' +
700
+ '그대로 제출하면 409 "an appStoreVersions must be included in this review submission" 로 막힌다. ' +
701
+ 'reviewSubmissionItems 는 상품 관계는 거부하지만 appStoreVersion 관계는 받으므로, 버전을 이 묶음으로 옮기면 된다. ' +
702
+ '버전이 다른 묶음에 물려 있으면 먼저 풀 것: 미제출 묶음이면 appstore_remove_review_submission_item, ' +
703
+ '제출된 묶음이면 항목 제거가 막히므로 appstore_cancel_review 로 묶음째 취소. ' +
704
+ '추가 후 appstore_submit_for_review 로 제출하면 버전+상품이 한 묶음으로 나간다.', {
705
+ submissionId: z.string().describe('대상 reviewSubmission ID (appstore_list_review_submissions 결과)'),
706
+ versionId: z.string().describe('추가할 App Store 버전 ID (appstore_list_versions 결과)'),
707
+ }, async ({ submissionId, versionId }) => {
708
+ const result = await appstore.addVersionToReviewSubmission({ submissionId, versionId });
709
+ return {
710
+ content: [{
711
+ type: 'text',
712
+ text: [
713
+ '✓ 묶음에 앱 버전 추가됨',
714
+ `submissionId: ${result.submissionId}`,
715
+ `versionId: ${result.versionId}`,
716
+ result.itemId ? `itemId: ${result.itemId}` : '',
717
+ `현재 묶음 항목 수: ${result.itemCount}개`,
718
+ '제출 전 항목 수를 확인할 것 — 첫 심사라면 상품들이 함께 들어 있어야 한다.',
719
+ ].filter(Boolean).join('\n'),
720
+ }],
721
+ };
722
+ });
723
+ server.tool('appstore_update_version_string', '기존 App Store 버전 레코드의 versionString 을 변경 (예: 2.0.5 → 2.0.6). ' +
724
+ '⚠️ 편집 가능한 버전이 이미 있으면 appstore_create_version 이 409 "cannot create a new version in the current state" 로 막힌다 — ' +
725
+ '거절/철회된 버전으로 다음 릴리스를 내보내려면 새로 만들지 말고 이 도구로 **같은 레코드의 이름을 올린다**. ' +
726
+ '빌드는 CFBundleShortVersionString 이 같은 버전에만 붙으므로, 새 버전의 빌드를 attach 하려면 먼저 이걸 맞춰야 한다. ' +
727
+ 'PREPARE_FOR_SUBMISSION / DEVELOPER_REJECTED 등 편집 가능 상태에서만 통한다.', {
728
+ versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
729
+ versionString: z.string().describe('새 버전 문자열 (예: "2.0.6")'),
730
+ }, async ({ versionId, versionString }) => {
731
+ const result = await appstore.updateVersionString(versionId, versionString);
732
+ return {
733
+ content: [{
734
+ type: 'text',
735
+ text: [
736
+ '✓ 버전 문자열 변경 완료',
737
+ `versionId: ${result.versionId}`,
738
+ `versionString: ${result.versionString}`,
739
+ result.state ? `state: ${result.state}` : '',
740
+ '이제 같은 버전의 빌드를 attach 할 수 있다 (appstore_attach_latest_build).',
741
+ ].filter(Boolean).join('\n'),
742
+ }],
743
+ };
744
+ });
679
745
  server.tool('appstore_update_product', 'App Store IAP 상품의 reference name 변경. productId / 유형은 변경 불가. ' +
680
746
  '스토어에 보이는 표시 이름·설명은 appstore_update_product_localization 을 쓴다.', {
681
747
  appId: z.string().optional().describe('App Store 앱 ID'),
@@ -781,8 +847,10 @@ export function registerAppstoreTools(server) {
781
847
  });
782
848
  server.tool('appstore_cancel_review', [
783
849
  'App Store 심사 제출을 철회 — WAITING_FOR_REVIEW 상태에서만 가능.',
784
- 'submitted=false PATCH reviewSubmission이 READY_FOR_REVIEW로 복귀, 버전은 PREPARE_FOR_SUBMISSION 상태로 돌아가 메타데이터/빌드 수정 가능.',
850
+ 'PATCH attributes.canceled=truestate CANCELING 으로 바뀌고 수십 초 뒤 COMPLETE(항목 REMOVED), 버전은 편집 가능 상태로 복귀해 메타데이터/빌드 수정 가능.',
851
+ 'CANCELING 은 비동기라 호출 직후엔 아직 COMPLETE 가 아니다 — 이어서 작업하려면 상태를 폴링할 것.',
785
852
  '⚠️ IN_REVIEW 이상이면 Apple API가 409로 거부함 — 이 경우 App Store Connect 웹에서 직접 처리하거나 심사 결과를 기다려야 함.',
853
+ '제출된 묶음에서 항목만 빼내는 것(removed)은 막히므로, 버전을 다른 묶음으로 옮기려면 이 도구로 묶음째 취소한다.',
786
854
  '철회 후 수정 완료 시 appstore_submit_for_review로 재제출 가능.',
787
855
  ].join(' '), {
788
856
  versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
@@ -563,6 +563,8 @@ export function registerPlaystoreTools(server) {
563
563
  'status="completed" + production 으로 전체 출시, status="inProgress" + userFraction 으로 단계 출시(예: 0.1 = 10%).',
564
564
  'target에 같은 versionCode가 이미 있으면 해당 항목을 교체. status="completed"면 target 활성 release를 통째로 새 것으로 대체.',
565
565
  '⚠️ status="completed"는 비가역에 가까움. playstore_check_submission_risks로 사전 점검 권장.',
566
+ 'Play 가 자동 심사 전송을 거부하는 앱이면 changesNotSentForReview=true 로 자동 폴백해 커밋한다 — 이때 응답의 changesNotSentForReview=true 와 nextAction 을 사용자에게 전달할 것(콘솔에서 "심사를 위해 전송"을 눌러야 심사 시작).',
567
+ 'source 트랙 노트가 버전 문자열뿐인 플레이스홀더면 warnings 로 알린다 — 그대로 승격하면 대상 트랙의 다국어 노트가 덮인다.',
566
568
  ].join(' '), {
567
569
  packageName: z.string().describe('패키지명 (예: gg.pryzm.coffee)'),
568
570
  fromTrack: z.enum(['production', 'beta', 'alpha', 'internal']).describe('출처 트랙 (예: internal)'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.13.12",
3
+ "version": "0.13.14",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
3
- "total": 187,
3
+ "total": 189,
4
4
  "domains": {
5
5
  "admob": {
6
6
  "label": "AdMob",
@@ -78,6 +78,8 @@
78
78
  "appstore_submit_for_review",
79
79
  "appstore_list_review_submissions",
80
80
  "appstore_remove_review_submission_item",
81
+ "appstore_add_version_to_review_submission",
82
+ "appstore_update_version_string",
81
83
  "appstore_cancel_review"
82
84
  ]
83
85
  },