@yoonion/mimi-seed-mcp 0.13.6 → 0.13.8

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.
@@ -18,8 +18,17 @@ const CODE_HINTS = {
18
18
  // 버전 상태가 편집 불가 (READY_FOR_SALE / WAITING_FOR_REVIEW 등)
19
19
  ENTITY_STATE_INVALID: '버전 상태가 편집 가능 단계가 아니에요 (이미 심사중/출시됨). 새 versionString 으로 appstore_create_version 필요할 수 있어요.',
20
20
  // 필수 필드 누락
21
+ // ⚠️ Apple 이 실제로 보내는 코드는 점 표기다 (ENTITY_ERROR.ATTRIBUTE.REQUIRED).
22
+ // 아래 언더스코어 키들은 오래 매칭된 적이 없었다 — 점 표기를 정본으로 두고
23
+ // 언더스코어 변형은 혹시 모를 옛 응답용으로 남긴다 (2026-07-21 실측으로 발견).
24
+ 'ENTITY_ERROR.ATTRIBUTE.REQUIRED': '필수 필드가 빠졌어요 — source.pointer 위치 확인.',
21
25
  ENTITY_ERROR_ATTRIBUTE_REQUIRED: '필수 필드가 빠졌어요 — source.pointer 위치 확인.',
22
- // 길이 / 형식 위반
26
+ // 길이 초과 detail 에 실제 상한이 숫자로 들어온다 ("Max number of characters is (55)").
27
+ 'ENTITY_ERROR.ATTRIBUTE.INVALID.TOO_LONG': '길이 초과 — detail 의 괄호 숫자가 Apple 이 강제하는 실제 상한이에요. 그 값에 맞춰 줄이세요.',
28
+ // 심사 중이라 필드가 잠김. 상품이 WAITING_FOR_REVIEW/IN_REVIEW 면 현지화를 못 고친다.
29
+ 'ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE': '지금 상태에선 못 고치는 필드예요 — 상품이 심사 중이면 현지화가 잠깁니다. '
30
+ + '심사 결과를 기다리거나 appstore_cancel_review 로 철회한 뒤 수정하세요.',
31
+ 'ENTITY_ERROR.ATTRIBUTE.INVALID': '필드 값이 정책 위반(길이/형식). source.pointer 위치 확인.',
23
32
  ENTITY_ERROR_ATTRIBUTE_INVALID: '필드 값이 정책 위반(길이/형식). source.pointer 위치 확인.',
24
33
  // 빌드 attach 시 빌드를 못 찾음
25
34
  NOT_FOUND: '대상 리소스를 찾을 수 없어요 — id/versionId/buildId 가 유효한지, 동일 앱 소속인지 확인.',
@@ -45,12 +54,30 @@ function parseAppleErrorBody(body) {
45
54
  return null;
46
55
  }
47
56
  }
57
+ /**
58
+ * 코드 → hint. 정확히 일치하는 게 없으면 점 표기를 뒤에서부터 잘라 상위 코드로 폴백한다.
59
+ * Apple 은 `ENTITY_ERROR.ATTRIBUTE.INVALID.TOO_LONG` 처럼 세분화된 코드를 계속 늘리는데,
60
+ * 그때마다 키를 추가하지 않아도 `...INVALID` 수준의 안내는 나가야 한다.
61
+ */
62
+ function resolveHint(code) {
63
+ if (!code)
64
+ return undefined;
65
+ if (CODE_HINTS[code])
66
+ return CODE_HINTS[code];
67
+ const parts = code.split('.');
68
+ for (let i = parts.length - 1; i > 0; i -= 1) {
69
+ const hint = CODE_HINTS[parts.slice(0, i).join('.')];
70
+ if (hint)
71
+ return hint;
72
+ }
73
+ return undefined;
74
+ }
48
75
  /** 단일 Apple error → 한 줄 사람용 표기. */
49
76
  function formatAppleError(e) {
50
77
  const code = e.code ?? 'UNKNOWN';
51
78
  const detail = e.detail ?? e.title ?? '(no detail)';
52
79
  const pointer = e.source?.pointer ?? e.source?.parameter;
53
- const hint = e.code ? CODE_HINTS[e.code] : undefined;
80
+ const hint = resolveHint(e.code);
54
81
  const parts = [`[${code}] ${detail}`];
55
82
  if (pointer)
56
83
  parts.push(`@ ${pointer}`);
@@ -166,6 +166,7 @@ export declare function submitVersionForReview(versionId: string): Promise<{
166
166
  versionId: string;
167
167
  reusedSubmission: boolean;
168
168
  itemAttached: boolean;
169
+ recoveredFromStaleSubmission: boolean;
169
170
  state: any;
170
171
  }>;
171
172
  export declare function addProductToReviewSubmission(args: {
@@ -599,38 +599,73 @@ export async function buildSubmitForReviewPreview(versionId) {
599
599
  whatsNewByLocale,
600
600
  };
601
601
  }
602
+ async function createReviewSubmission(appId, platform) {
603
+ const created = await apiPost('/reviewSubmissions', {
604
+ data: {
605
+ type: 'reviewSubmissions',
606
+ attributes: { platform },
607
+ relationships: {
608
+ app: { data: { type: 'apps', id: appId } },
609
+ },
610
+ },
611
+ });
612
+ const submissionId = created?.data?.id;
613
+ if (!submissionId) {
614
+ throw new Error(`reviewSubmission 생성 응답에 id가 없어: ${JSON.stringify(created)}`);
615
+ }
616
+ return submissionId;
617
+ }
618
+ function isItemAddRejected(error) {
619
+ const cause = error?.cause;
620
+ if (cause?.status !== 409)
621
+ return false;
622
+ return (cause.parsedErrors ?? []).some((e) => (e.code ?? '').startsWith('STATE_ERROR'));
623
+ }
602
624
  export async function submitVersionForReview(versionId) {
603
625
  const { appId, platform } = await getVersionAppAndPlatform(versionId);
604
- // 1. CREATED 상태의 reviewSubmission이 있으면 재사용, 없으면 새로 생성
626
+ // 1. 열린 reviewSubmission이 있으면 재사용, 없으면 새로 생성
605
627
  let submissionId = await findOpenReviewSubmission(appId, platform);
606
- const reusedSubmission = Boolean(submissionId);
628
+ let reusedSubmission = Boolean(submissionId);
629
+ // findOpenReviewSubmission 은 WAITING_FOR_REVIEW 도 잡아온다. 그 상태의 실제 진행도는
630
+ // API 의 state 필드보다 앞서 있을 수 있어(실측: 이미 심사 큐를 탄 옛 제출), 항목 추가
631
+ // 자체를 거부당하는 경우가 있다 — 아래 recoveredFromStaleSubmission 이 그 케이스다.
632
+ let recoveredFromStaleSubmission = false;
607
633
  if (!submissionId) {
608
- const created = await apiPost('/reviewSubmissions', {
609
- data: {
610
- type: 'reviewSubmissions',
611
- attributes: { platform },
612
- relationships: {
613
- app: { data: { type: 'apps', id: appId } },
614
- },
615
- },
616
- });
617
- submissionId = created?.data?.id;
618
- if (!submissionId) {
619
- throw new Error(`reviewSubmission 생성 응답에 id가 없어: ${JSON.stringify(created)}`);
620
- }
634
+ submissionId = await createReviewSubmission(appId, platform);
635
+ reusedSubmission = false;
621
636
  }
622
637
  // 2. 버전을 reviewSubmissionItems로 attach (이미 붙어있으면 skip)
623
- const alreadyAttached = reusedSubmission ? await isVersionAttached(submissionId, versionId) : false;
638
+ let alreadyAttached = reusedSubmission ? await isVersionAttached(submissionId, versionId) : false;
624
639
  if (!alreadyAttached) {
625
- await apiPost('/reviewSubmissionItems', {
626
- data: {
627
- type: 'reviewSubmissionItems',
628
- relationships: {
629
- reviewSubmission: { data: { type: 'reviewSubmissions', id: submissionId } },
630
- appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
640
+ try {
641
+ await apiPost('/reviewSubmissionItems', {
642
+ data: {
643
+ type: 'reviewSubmissionItems',
644
+ relationships: {
645
+ reviewSubmission: { data: { type: 'reviewSubmissions', id: submissionId } },
646
+ appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
647
+ },
631
648
  },
632
- },
633
- });
649
+ });
650
+ }
651
+ catch (error) {
652
+ if (!reusedSubmission || !isItemAddRejected(error))
653
+ throw error;
654
+ // 재사용하려던 묶음이 실제로는 잠겨 있었다 — 새 묶음을 만들어 한 번만 재시도한다.
655
+ submissionId = await createReviewSubmission(appId, platform);
656
+ reusedSubmission = false;
657
+ recoveredFromStaleSubmission = true;
658
+ alreadyAttached = false;
659
+ await apiPost('/reviewSubmissionItems', {
660
+ data: {
661
+ type: 'reviewSubmissionItems',
662
+ relationships: {
663
+ reviewSubmission: { data: { type: 'reviewSubmissions', id: submissionId } },
664
+ appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
665
+ },
666
+ },
667
+ });
668
+ }
634
669
  }
635
670
  // 3. PATCH submitted=true → state: CREATED → WAITING_FOR_REVIEW
636
671
  const submitted = await apiPatch(`/reviewSubmissions/${submissionId}`, {
@@ -647,6 +682,7 @@ export async function submitVersionForReview(versionId) {
647
682
  versionId,
648
683
  reusedSubmission,
649
684
  itemAttached: !alreadyAttached,
685
+ recoveredFromStaleSubmission,
650
686
  state: submitted?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW',
651
687
  };
652
688
  }
@@ -511,13 +511,19 @@ export function registerAppstoreTools(server) {
511
511
  });
512
512
  server.tool('appstore_update_product_localization', 'App Store IAP/구독 상품의 현지화(표시 이름·설명)를 로케일 단위로 upsert — 있으면 수정, 없으면 생성. ' +
513
513
  '현지화가 비면 상품이 MISSING_METADATA 에서 안 풀려 심사에 넣을 수 없다 (리뷰 노트·스크린샷과는 별개 리소스). ' +
514
- 'locale 은 App Store 표기(ko, en-US, ja, zh-Hant)를 쓴다. name 30자 / description 45자 제한.', {
514
+ 'locale 은 App Store 표기(ko, en-US, ja, zh-Hant)를 쓴다. ' +
515
+ '길이 상한은 리소스마다 다르고 Apple 이 강제한다 — 구독은 실측으로 name 35 / description 55 다. ' +
516
+ '초과하면 Apple 이 "Max number of characters is (N)" 으로 실제 상한을 알려주므로 그 값에 맞춰 줄이면 된다. ' +
517
+ '⚠️ 심사 중인 상품은 현지화가 잠겨 UNMODIFIABLE 로 거부된다 — 결과를 기다리거나 철회 후 수정한다.', {
515
518
  appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
516
519
  productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
517
520
  productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
518
521
  locale: z.string().describe('로케일 (예: ko, en-US, ja, zh-Hant)'),
519
- name: z.string().max(30).optional().describe('표시 이름 (30자 이하). 로케일 생성 필수'),
520
- description: z.string().max(45).optional().describe('설명 (45자 이하). 생략하면 기존 유지'),
522
+ // 실제 상한은 Apple 이 리소스별로 강제한다(구독 name 35 / description 55 실측).
523
+ // 여기서 좁게 잡으면 정상 문구를 클라이언트가 먼저 거부한다 — 실제로 45로 잡아
524
+ // 55자짜리 정상 설명을 막았다. 오타 수준만 걸러내는 넉넉한 상한만 둔다.
525
+ name: z.string().max(200).optional().describe('표시 이름. 새 로케일 생성 시 필수 (구독 실측 상한 35자)'),
526
+ description: z.string().max(500).optional().describe('설명. 생략하면 기존 값 유지 (구독 실측 상한 55자)'),
521
527
  }, async ({ appId, productId, productType, locale, name, description }) => {
522
528
  const creds = requireAppStoreCreds();
523
529
  const products = await listAppleProducts({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.13.6",
3
+ "version": "0.13.8",
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": {