@yoonion/mimi-seed-mcp 0.3.2 → 0.3.4

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.
package/README.md CHANGED
@@ -172,7 +172,7 @@ Claude가 연쇄 호출:
172
172
 
173
173
  ## 레거시 호환성
174
174
 
175
- 패키지 이름이 `@preseed/mcp-server` / `preseed-mcp` 시절의 데이터는 자동으로 이어받음:
175
+ Preseed 시절(`~/.preseed/`) 데이터는 자동으로 이어받음:
176
176
 
177
177
  - `~/.preseed/tokens.json` 있으면 읽음 (재인증 불필요)
178
178
  - `~/.preseed/appstore.json`도 동일
@@ -21,8 +21,7 @@ function authHeadersOrThrow() {
21
21
  '❌ App Store Connect 인증이 필요해.',
22
22
  '',
23
23
  '터미널에서 실행:',
24
- ' cd c:/users/turbo08/app-gen/packages/mcp-server',
25
- ' npx tsx src/appstore/setup-cli.ts',
24
+ ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
26
25
  ].join('\n'));
27
26
  }
28
27
  return headers;
@@ -20,3 +20,38 @@ export declare function updateVersionWhatsNew(versionId: string, locale: string,
20
20
  export declare function listBuilds(appId: string): Promise<any>;
21
21
  export declare function listBetaGroups(appId: string): Promise<any>;
22
22
  export declare function getAppInfo(appId: string): Promise<any>;
23
+ export interface AppInfoLocalizationFields {
24
+ name?: string;
25
+ subtitle?: string;
26
+ privacyPolicyUrl?: string;
27
+ privacyPolicyText?: string;
28
+ }
29
+ export declare function listAppInfoLocalizations(appId: string, locale?: string): Promise<{
30
+ appInfoId: string;
31
+ appInfoState: string;
32
+ localizations: {
33
+ id: any;
34
+ locale: any;
35
+ name: any;
36
+ subtitle: any;
37
+ privacyPolicyUrl: any;
38
+ privacyPolicyText: any;
39
+ }[];
40
+ }>;
41
+ export declare function updateAppInfoLocalization(localizationId: string, fields: AppInfoLocalizationFields): Promise<any>;
42
+ export interface ListCustomerReviewsOptions {
43
+ limit?: number;
44
+ territory?: string;
45
+ rating?: 1 | 2 | 3 | 4 | 5;
46
+ }
47
+ export declare function listCustomerReviews(appId: string, opts?: ListCustomerReviewsOptions): Promise<any>;
48
+ export declare function createReviewResponse(reviewId: string, responseBody: string): Promise<any>;
49
+ export declare function submitVersionForReview(versionId: string): Promise<{
50
+ submissionId: string;
51
+ appId: string;
52
+ platform: string;
53
+ versionId: string;
54
+ reusedSubmission: boolean;
55
+ itemAttached: boolean;
56
+ state: any;
57
+ }>;
@@ -11,8 +11,7 @@ export async function apiGet(path, params) {
11
11
  '❌ App Store Connect 인증이 필요해.',
12
12
  '',
13
13
  '터미널에서 실행:',
14
- ' cd c:/users/turbo08/app-gen/packages/mcp-server',
15
- ' npx tsx src/appstore/setup-cli.ts',
14
+ ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
16
15
  '',
17
16
  'API Key가 필요해:',
18
17
  ' App Store Connect > Users and Access > Integrations > Keys',
@@ -36,8 +35,7 @@ async function apiPatch(path, body) {
36
35
  '❌ App Store Connect 인증이 필요해.',
37
36
  '',
38
37
  '터미널에서 실행:',
39
- ' cd c:/users/turbo08/app-gen/packages/mcp-server',
40
- ' npx tsx src/appstore/setup-cli.ts',
38
+ ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
41
39
  ].join('\n'));
42
40
  const res = await fetch(`${BASE}${path}`, {
43
41
  method: 'PATCH',
@@ -52,6 +50,28 @@ async function apiPatch(path, body) {
52
50
  const text = await res.text();
53
51
  return text ? JSON.parse(text) : { ok: true };
54
52
  }
53
+ async function apiPost(path, body) {
54
+ const headers = getAuthHeaders();
55
+ if (!headers)
56
+ throw new Error([
57
+ '❌ App Store Connect 인증이 필요해.',
58
+ '',
59
+ '터미널에서 실행:',
60
+ ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
61
+ ].join('\n'));
62
+ const res = await fetch(`${BASE}${path}`, {
63
+ method: 'POST',
64
+ headers: { ...headers, 'Content-Type': 'application/json' },
65
+ body: JSON.stringify(body),
66
+ });
67
+ if (!res.ok) {
68
+ const text = await res.text();
69
+ throw new Error(`App Store API ${res.status}: ${text}`);
70
+ }
71
+ // 201 Created — 본문에 created entity. 일부 엔드포인트는 204
72
+ const text = await res.text();
73
+ return text ? JSON.parse(text) : { ok: true };
74
+ }
55
75
  // ─── 앱 ───
56
76
  export async function listApps() {
57
77
  const data = await apiGet('/apps', {
@@ -154,13 +174,203 @@ export async function listBetaGroups(appId) {
154
174
  }));
155
175
  }
156
176
  // ─── 앱 정보 (카테고리 등) ───
177
+ // AppInfoState — READY_FOR_DISTRIBUTION이 라이브 버전, 그 외(PREPARE_FOR_SUBMISSION /
178
+ // DEVELOPER_REJECTED 등)가 편집 가능한 appInfo.
179
+ const APP_INFO_LIVE_STATE = 'READY_FOR_DISTRIBUTION';
157
180
  export async function getAppInfo(appId) {
158
181
  const data = await apiGet(`/apps/${appId}/appInfos`, {
159
- 'fields[appInfos]': 'appStoreState,appStoreAgeRating,brazilAgeRating',
182
+ 'fields[appInfos]': 'state,appStoreAgeRating,brazilAgeRating',
160
183
  });
161
184
  return (data.data ?? []).map((i) => ({
162
185
  id: i.id,
163
- state: i.attributes?.appStoreState,
186
+ // 새 필드명 state, 옛 필드명 appStoreState 모두 케어
187
+ state: i.attributes?.state ?? i.attributes?.appStoreState,
164
188
  ageRating: i.attributes?.appStoreAgeRating,
165
189
  }));
166
190
  }
191
+ async function findEditableAppInfoId(appId) {
192
+ const data = await apiGet(`/apps/${appId}/appInfos`, {
193
+ 'fields[appInfos]': 'state',
194
+ 'limit': '10',
195
+ });
196
+ const infos = (data?.data ?? []);
197
+ if (infos.length === 0) {
198
+ throw new Error(`앱 ${appId}에 appInfos가 없어. 앱 ID 확인 필요.`);
199
+ }
200
+ const stateOf = (i) => i.attributes?.state ?? i.attributes?.appStoreState ?? '';
201
+ const editable = infos.find((i) => stateOf(i) !== APP_INFO_LIVE_STATE);
202
+ const target = editable ?? infos[0];
203
+ return { appInfoId: target.id, state: stateOf(target) };
204
+ }
205
+ export async function listAppInfoLocalizations(appId, locale) {
206
+ const { appInfoId, state } = await findEditableAppInfoId(appId);
207
+ const data = await apiGet(`/appInfos/${appInfoId}/appInfoLocalizations`, {
208
+ 'fields[appInfoLocalizations]': 'locale,name,subtitle,privacyPolicyUrl,privacyPolicyText',
209
+ 'limit': '200',
210
+ });
211
+ const all = (data?.data ?? []).map((l) => ({
212
+ id: l.id,
213
+ locale: l.attributes?.locale,
214
+ name: l.attributes?.name,
215
+ subtitle: l.attributes?.subtitle,
216
+ privacyPolicyUrl: l.attributes?.privacyPolicyUrl,
217
+ privacyPolicyText: l.attributes?.privacyPolicyText,
218
+ }));
219
+ const filtered = locale ? all.filter((l) => l.locale === locale) : all;
220
+ return { appInfoId, appInfoState: state, localizations: filtered };
221
+ }
222
+ export async function updateAppInfoLocalization(localizationId, fields) {
223
+ const attributes = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
224
+ if (Object.keys(attributes).length === 0) {
225
+ throw new Error('수정할 필드가 없어 (name / subtitle / privacyPolicyUrl / privacyPolicyText 중 하나 이상).');
226
+ }
227
+ return apiPatch(`/appInfoLocalizations/${localizationId}`, {
228
+ data: {
229
+ type: 'appInfoLocalizations',
230
+ id: localizationId,
231
+ attributes,
232
+ },
233
+ });
234
+ }
235
+ export async function listCustomerReviews(appId, opts = {}) {
236
+ const params = {
237
+ 'sort': '-createdDate',
238
+ 'limit': String(opts.limit ?? 50),
239
+ 'fields[customerReviews]': 'rating,title,body,reviewerNickname,createdDate,territory',
240
+ 'include': 'response',
241
+ 'fields[customerReviewResponses]': 'responseBody,lastModifiedDate,state',
242
+ };
243
+ if (opts.territory)
244
+ params['filter[territory]'] = opts.territory;
245
+ if (opts.rating != null)
246
+ params['filter[rating]'] = String(opts.rating);
247
+ const data = await apiGet(`/apps/${appId}/customerReviews`, params);
248
+ // include로 가져온 답변 매핑
249
+ const responses = new Map();
250
+ for (const inc of data.included ?? []) {
251
+ if (inc.type === 'customerReviewResponses') {
252
+ responses.set(inc.id, inc.attributes);
253
+ }
254
+ }
255
+ return (data.data ?? []).map((r) => {
256
+ const respId = r.relationships?.response?.data?.id;
257
+ const resp = respId ? responses.get(respId) : null;
258
+ return {
259
+ id: r.id,
260
+ rating: r.attributes?.rating,
261
+ title: r.attributes?.title,
262
+ body: r.attributes?.body,
263
+ nickname: r.attributes?.reviewerNickname,
264
+ createdDate: r.attributes?.createdDate,
265
+ territory: r.attributes?.territory,
266
+ response: resp
267
+ ? {
268
+ body: resp.responseBody,
269
+ lastModifiedDate: resp.lastModifiedDate,
270
+ state: resp.state,
271
+ }
272
+ : null,
273
+ };
274
+ });
275
+ }
276
+ export async function createReviewResponse(reviewId, responseBody) {
277
+ return apiPost('/customerReviewResponses', {
278
+ data: {
279
+ type: 'customerReviewResponses',
280
+ attributes: { responseBody },
281
+ relationships: {
282
+ review: { data: { type: 'customerReviews', id: reviewId } },
283
+ },
284
+ },
285
+ });
286
+ }
287
+ // ─── 심사 제출 (Submit for Review) ───
288
+ // 2024년 1월부터 옛 /appStoreVersionSubmissions가 deprecated 됐고,
289
+ // 새 모델은 /reviewSubmissions + /reviewSubmissionItems 2단계 + PATCH submitted=true.
290
+ // 참고: https://developer.apple.com/documentation/appstoreconnectapi/submit-an-app-for-review
291
+ async function getVersionAppAndPlatform(versionId) {
292
+ const data = await apiGet(`/appStoreVersions/${versionId}`, {
293
+ 'fields[appStoreVersions]': 'platform,app',
294
+ 'include': 'app',
295
+ });
296
+ const platform = data?.data?.attributes?.platform;
297
+ const appId = data?.data?.relationships?.app?.data?.id;
298
+ if (!platform || !appId) {
299
+ throw new Error(`appStoreVersion ${versionId}에서 app 또는 platform을 찾지 못했어. 버전 ID 확인 필요.`);
300
+ }
301
+ return { appId, platform };
302
+ }
303
+ async function findOpenReviewSubmission(appId, platform) {
304
+ const data = await apiGet('/reviewSubmissions', {
305
+ 'filter[app]': appId,
306
+ 'filter[platform]': platform,
307
+ 'filter[state]': 'READY_FOR_REVIEW,COMPLETING,UNRESOLVED_ISSUES',
308
+ 'limit': '1',
309
+ });
310
+ // CREATED 상태(아직 제출 안 된)도 별도 조회해서 우선 재사용
311
+ const created = await apiGet('/reviewSubmissions', {
312
+ 'filter[app]': appId,
313
+ 'filter[platform]': platform,
314
+ 'filter[state]': 'CREATED',
315
+ 'limit': '1',
316
+ });
317
+ return created?.data?.[0]?.id ?? data?.data?.[0]?.id ?? null;
318
+ }
319
+ async function isVersionAttached(submissionId, versionId) {
320
+ const data = await apiGet(`/reviewSubmissions/${submissionId}/items`, {
321
+ 'limit': '50',
322
+ });
323
+ const items = (data?.data ?? []);
324
+ return items.some((it) => it?.relationships?.appStoreVersion?.data?.id === versionId);
325
+ }
326
+ export async function submitVersionForReview(versionId) {
327
+ const { appId, platform } = await getVersionAppAndPlatform(versionId);
328
+ // 1. CREATED 상태의 reviewSubmission이 있으면 재사용, 없으면 새로 생성
329
+ let submissionId = await findOpenReviewSubmission(appId, platform);
330
+ const reusedSubmission = Boolean(submissionId);
331
+ if (!submissionId) {
332
+ const created = await apiPost('/reviewSubmissions', {
333
+ data: {
334
+ type: 'reviewSubmissions',
335
+ attributes: { platform },
336
+ relationships: {
337
+ app: { data: { type: 'apps', id: appId } },
338
+ },
339
+ },
340
+ });
341
+ submissionId = created?.data?.id;
342
+ if (!submissionId) {
343
+ throw new Error(`reviewSubmission 생성 응답에 id가 없어: ${JSON.stringify(created)}`);
344
+ }
345
+ }
346
+ // 2. 버전을 reviewSubmissionItems로 attach (이미 붙어있으면 skip)
347
+ const alreadyAttached = reusedSubmission ? await isVersionAttached(submissionId, versionId) : false;
348
+ if (!alreadyAttached) {
349
+ await apiPost('/reviewSubmissionItems', {
350
+ data: {
351
+ type: 'reviewSubmissionItems',
352
+ relationships: {
353
+ reviewSubmission: { data: { type: 'reviewSubmissions', id: submissionId } },
354
+ appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
355
+ },
356
+ },
357
+ });
358
+ }
359
+ // 3. PATCH submitted=true → state: CREATED → WAITING_FOR_REVIEW
360
+ const submitted = await apiPatch(`/reviewSubmissions/${submissionId}`, {
361
+ data: {
362
+ type: 'reviewSubmissions',
363
+ id: submissionId,
364
+ attributes: { submitted: true },
365
+ },
366
+ });
367
+ return {
368
+ submissionId,
369
+ appId,
370
+ platform,
371
+ versionId,
372
+ reusedSubmission,
373
+ itemAttached: !alreadyAttached,
374
+ state: submitted?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW',
375
+ };
376
+ }
package/dist/index.js CHANGED
@@ -631,10 +631,93 @@ server.tool('appstore_list_beta_groups', 'TestFlight 베타 그룹 목록', { ap
631
631
  const groups = await appstore.listBetaGroups(appId);
632
632
  return { content: [{ type: 'text', text: JSON.stringify(groups, null, 2) }] };
633
633
  });
634
- server.tool('appstore_get_app_info', 'App Store 앱 정보 (카테고리, 연령 등급)', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
634
+ server.tool('appstore_get_app_info', 'App Store 앱 정보 (카테고리, 연령 등급, state). state=READY_FOR_DISTRIBUTION이 라이브, 그 외가 편집 가능 appInfo.', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
635
635
  const info = await appstore.getAppInfo(appId);
636
636
  return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
637
637
  });
638
+ server.tool('appstore_list_app_info_localizations', [
639
+ '편집 가능한 appInfo의 로컬라이제이션 목록 조회 — 앱 이름(name), 부제(subtitle), 개인정보 URL/텍스트.',
640
+ 'appInfo.relationships.appInfoLocalizations가 빈 배열로 오는 케이스를 우회하려고 /appInfos/{id}/appInfoLocalizations 직접 호출.',
641
+ 'locale을 주면 해당 언어만 반환 (예: "ko", "en-US").',
642
+ '※ versionLocalization(설명/키워드/whatsNew)과 다름 — 그건 appstore_get_metadata 사용.',
643
+ ].join(' '), {
644
+ appId: z.string().describe('앱 ID (appstore_list_apps 결과)'),
645
+ locale: z.string().optional().describe('언어 필터 (예: "ko", "en-US"). 생략 시 전체 반환.'),
646
+ }, async ({ appId, locale }) => {
647
+ const result = await appstore.listAppInfoLocalizations(appId, locale);
648
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
649
+ });
650
+ server.tool('appstore_update_app_info_localization', [
651
+ 'appInfo 로컬라이제이션(앱 이름/부제/개인정보 URL/텍스트) 수정 — PATCH /appInfoLocalizations/{id}.',
652
+ 'localizationId는 appstore_list_app_info_localizations 결과의 id.',
653
+ '편집 가능 상태(PREPARE_FOR_SUBMISSION / DEVELOPER_REJECTED 등)에서만 반영됨.',
654
+ '제한: name 30자, subtitle 30자.',
655
+ ].join(' '), {
656
+ localizationId: z.string().describe('appInfoLocalization ID'),
657
+ name: z.string().optional().describe('앱 이름 (30자 이내)'),
658
+ subtitle: z.string().optional().describe('부제 (30자 이내)'),
659
+ privacyPolicyUrl: z.string().url().optional().describe('개인정보 처리방침 URL'),
660
+ privacyPolicyText: z.string().optional().describe('개인정보 처리방침 텍스트'),
661
+ }, async ({ localizationId, ...fields }) => {
662
+ const result = await appstore.updateAppInfoLocalization(localizationId, fields);
663
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
664
+ });
665
+ // --- App Store 고객 리뷰 ---
666
+ server.tool('appstore_list_reviews', [
667
+ 'App Store 받은 고객 리뷰 조회 (최신순).',
668
+ 'response 필드에 개발자 답변 존재 여부와 내용이 함께 포함됨 (없으면 null).',
669
+ 'territory(예: KOR/USA — ISO 3166 alpha-3) / rating(1~5)으로 필터 가능.',
670
+ ].join(' '), {
671
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과)'),
672
+ limit: z.number().int().positive().max(200).optional().describe('가져올 개수 (기본 50, 최대 200)'),
673
+ territory: z.string().optional().describe("국가 코드 (예: 'KOR', 'USA' — alpha-3)"),
674
+ rating: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional().describe('별점 필터 (1~5)'),
675
+ }, async ({ appId, limit, territory, rating }) => {
676
+ const reviews = await appstore.listCustomerReviews(appId, { limit, territory, rating });
677
+ return { content: [{ type: 'text', text: JSON.stringify(reviews, null, 2) }] };
678
+ });
679
+ server.tool('appstore_reply_review', [
680
+ 'App Store 고객 리뷰에 개발자 답변을 등록 (또는 갱신).',
681
+ '동일 리뷰에 한 번만 답변 가능 — 기존 답변이 있으면 Apple이 새 응답으로 대체함.',
682
+ 'reviewId는 appstore_list_reviews 결과의 id.',
683
+ '답변 본문은 5970자 이내.',
684
+ ].join(' '), {
685
+ reviewId: z.string().describe('리뷰 ID (appstore_list_reviews 결과)'),
686
+ responseBody: z.string().describe('답변 본문 (5970자 이내)'),
687
+ }, async ({ reviewId, responseBody }) => {
688
+ const result = await appstore.createReviewResponse(reviewId, responseBody);
689
+ return { content: [{ type: 'text', text: `✅ 리뷰 ${reviewId}에 답변 등록됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
690
+ });
691
+ // --- App Store 심사 제출 (Submit for Review) ---
692
+ server.tool('appstore_submit_for_review', [
693
+ 'App Store 버전을 심사에 제출 — 새 reviewSubmissions API 사용 (옛 /appStoreVersionSubmissions는 2024-01 deprecated).',
694
+ '내부 흐름: POST /reviewSubmissions(또는 CREATED 상태 재사용) → POST /reviewSubmissionItems(version attach) → PATCH submitted=true.',
695
+ 'appId와 platform은 versionId에서 자동 조회 — 별도 입력 불필요.',
696
+ '⚠️ 비가역 작업: 제출 후엔 Apple 심사가 시작되며, 메타데이터/스크린샷/빌드를 더 못 바꿈 (REJECTED/METADATA_REJECTED 시 다시 편집 가능).',
697
+ '사전 조건: 버전이 PREPARE_FOR_SUBMISSION 또는 DEVELOPER_REJECTED 상태, 빌드 attached, 모든 필수 메타데이터 채워짐.',
698
+ 'appstore_check_submission_risks로 사전 점검 권장.',
699
+ ].join(' '), {
700
+ versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
701
+ }, async ({ versionId }) => {
702
+ const result = await appstore.submitVersionForReview(versionId);
703
+ return { content: [{ type: 'text', text: `✅ 버전 ${versionId} 심사 제출 완료 (state: ${result.state}). App Store Connect에서 진행 상태 확인 가능.\n\n${JSON.stringify(result, null, 2)}` }] };
704
+ });
705
+ // --- Play Store 심사 제출 / release status 변경 ---
706
+ server.tool('playstore_submit_release', [
707
+ 'Google Play 트랙의 release status를 변경 — 일반적으로 draft → completed로 바꿔 검토/배포 큐에 진입시킬 때 사용.',
708
+ '⚠️ status="completed"는 비가역에 가까움 (전체 출시 또는 Google 검토 시작). halted로 일시 중단은 가능하나 한 번 라이브된 release는 되돌리기 어려움.',
709
+ 'status 옵션: draft(검토 미시작) / inProgress(단계 출시) / completed(전체 출시) / halted(중단).',
710
+ 'playstore_check_submission_risks로 사전 점검 권장.',
711
+ ].join(' '), {
712
+ packageName: z.string().describe('패키지명 (예: gg.pryzm.coffee)'),
713
+ track: z.enum(['production', 'beta', 'alpha', 'internal']).describe('릴리스 트랙'),
714
+ versionCode: z.string().describe('대상 versionCode (문자열)'),
715
+ status: z.enum(['draft', 'inProgress', 'completed', 'halted']).optional().describe('새 status (기본: completed)'),
716
+ }, async ({ packageName, track, versionCode, status }) => {
717
+ const auth = requireAuth();
718
+ const result = await playstore.submitRelease(auth, packageName, track, versionCode, status);
719
+ return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode}: ${result.previousStatus} → ${result.newStatus}\n\n${JSON.stringify(result, null, 2)}` }] };
720
+ });
638
721
  // --- 인증 상태 ---
639
722
  server.tool('mimi_seed_auth_start', [
640
723
  'Google OAuth 로그인 링크를 발급하고 백그라운드 콜백 서버를 시작.',
@@ -54,6 +54,14 @@ export declare function replaceImages(auth: OAuth2Client, packageName: string, l
54
54
  sha256?: string | null;
55
55
  }[];
56
56
  }>;
57
+ export declare function submitRelease(auth: OAuth2Client, packageName: string, track: string, versionCode: string, status?: 'completed' | 'draft' | 'inProgress' | 'halted'): Promise<{
58
+ track: string;
59
+ versionCode: string;
60
+ previousStatus: string | null | undefined;
61
+ newStatus: "completed" | "draft" | "inProgress" | "halted";
62
+ committed: boolean;
63
+ result: import("googleapis").androidpublisher_v3.Schema$Track;
64
+ }>;
57
65
  export declare function listReviews(auth: OAuth2Client, packageName: string): Promise<{
58
66
  reviewId: string | null | undefined;
59
67
  authorName: string | null | undefined;
@@ -221,6 +221,47 @@ export async function replaceImages(auth, packageName, language, imageType, file
221
221
  }, true);
222
222
  }
223
223
  // ─── 리뷰 조회 ───
224
+ // ─── 릴리스 상태 변경 / 심사 제출 ───
225
+ //
226
+ // Play Store는 명시적 "Submit for Review" 버튼이 없고, track의 release
227
+ // status를 "completed"로 바꾸면 자동으로 심사 큐에 들어감 (또는 즉시 publish).
228
+ // status: draft → 검토 미시작, inProgress → 단계적 출시, completed → 전체 출시
229
+ // halted → 일시 중단
230
+ // 신중히 사용해야 함 — completed로 바꾸면 되돌리기 어려움.
231
+ export async function submitRelease(auth, packageName, track, versionCode, status = 'completed') {
232
+ return withEdit(auth, packageName, async (editId) => {
233
+ const current = await publisher().edits.tracks.get({
234
+ auth, packageName, editId, track,
235
+ });
236
+ const releases = current.data.releases ?? [];
237
+ if (releases.length === 0) {
238
+ throw new Error(`${track} 트랙에 릴리스가 없어.`);
239
+ }
240
+ const target = releases.find((r) => (r.versionCodes ?? []).some((v) => String(v) === String(versionCode)));
241
+ if (!target) {
242
+ const available = releases.map((r) => ({
243
+ name: r.name,
244
+ versionCodes: r.versionCodes,
245
+ status: r.status,
246
+ }));
247
+ throw new Error(`versionCode "${versionCode}"를 ${track} 트랙에서 찾을 수 없어. 가능한 릴리스: ${JSON.stringify(available)}`);
248
+ }
249
+ const previousStatus = target.status;
250
+ target.status = status;
251
+ const updated = await publisher().edits.tracks.update({
252
+ auth, packageName, editId, track,
253
+ requestBody: { track, releases },
254
+ });
255
+ return {
256
+ track,
257
+ versionCode,
258
+ previousStatus,
259
+ newStatus: status,
260
+ committed: true,
261
+ result: updated.data,
262
+ };
263
+ }, true);
264
+ }
224
265
  export async function listReviews(auth, packageName) {
225
266
  const res = await publisher().reviews.list({ auth, packageName });
226
267
  return (res.data.reviews ?? []).map((r) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {