@yoonion/mimi-seed-mcp 0.3.15 → 0.3.17
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/dist/appstore/tools.d.ts +7 -0
- package/dist/appstore/tools.js +72 -25
- package/dist/auth/playstore-auth.d.ts +35 -2
- package/dist/auth/playstore-auth.js +95 -11
- package/dist/checks/risks.js +65 -11
- package/dist/index.js +153 -32
- package/package.json +1 -1
package/dist/appstore/tools.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export declare function updateVersionWhatsNew(versionId: string, locale: string,
|
|
|
44
44
|
export declare function updateReviewNotes(versionId: string, notes: string): Promise<{
|
|
45
45
|
reviewDetailId: string;
|
|
46
46
|
notes: string;
|
|
47
|
+
created: boolean;
|
|
47
48
|
}>;
|
|
48
49
|
export declare function getReviewNotes(versionId: string): Promise<{
|
|
49
50
|
reviewDetailId: string | null;
|
|
@@ -88,6 +89,12 @@ export declare function submitVersionForReview(versionId: string): Promise<{
|
|
|
88
89
|
itemAttached: boolean;
|
|
89
90
|
state: any;
|
|
90
91
|
}>;
|
|
92
|
+
export declare function cancelVersionReview(versionId: string): Promise<{
|
|
93
|
+
submissionId: string;
|
|
94
|
+
previousState: string;
|
|
95
|
+
newState: string;
|
|
96
|
+
versionId: string;
|
|
97
|
+
}>;
|
|
91
98
|
export type AppleIapType = 'CONSUMABLE' | 'NON_CONSUMABLE' | 'NON_RENEWING_SUBSCRIPTION';
|
|
92
99
|
export interface CreateInAppPurchaseInput {
|
|
93
100
|
appId: string;
|
package/dist/appstore/tools.js
CHANGED
|
@@ -186,8 +186,16 @@ export async function updateVersionWhatsNew(versionId, locale, fields) {
|
|
|
186
186
|
return updateVersionLocalization(target.id, fields);
|
|
187
187
|
}
|
|
188
188
|
// ─── 리뷰어 노트 (appStoreReviewDetail.notes) ───
|
|
189
|
+
/**
|
|
190
|
+
* apiGet이 throw한 에러가 404(리소스 없음)인지 판별.
|
|
191
|
+
* apiGet은 `App Store API ${status}: ${body}` 형식으로 throw하므로 prefix로 판별.
|
|
192
|
+
* 404 외(401/403/500 등)는 마스킹하지 않고 그대로 throw해야 디버깅 가능.
|
|
193
|
+
*/
|
|
194
|
+
function isNotFoundError(err) {
|
|
195
|
+
return err instanceof Error && /^App Store API 404:/.test(err.message);
|
|
196
|
+
}
|
|
189
197
|
export async function updateReviewNotes(versionId, notes) {
|
|
190
|
-
// 1. 기존 reviewDetail 조회
|
|
198
|
+
// 1. 기존 reviewDetail 조회 — 404면 신규 생성, 그 외 에러는 throw
|
|
191
199
|
let reviewDetailId = null;
|
|
192
200
|
try {
|
|
193
201
|
const existing = await apiGet(`/appStoreVersions/${versionId}/appStoreReviewDetail`, {
|
|
@@ -195,34 +203,36 @@ export async function updateReviewNotes(versionId, notes) {
|
|
|
195
203
|
});
|
|
196
204
|
reviewDetailId = existing?.data?.id ?? null;
|
|
197
205
|
}
|
|
198
|
-
catch {
|
|
199
|
-
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (!isNotFoundError(err))
|
|
208
|
+
throw err;
|
|
200
209
|
}
|
|
201
210
|
if (reviewDetailId) {
|
|
202
|
-
// 2a. 있으면 PATCH
|
|
203
211
|
const updated = await apiPatch(`/appStoreReviewDetails/${reviewDetailId}`, {
|
|
204
|
-
data: {
|
|
205
|
-
type: 'appStoreReviewDetails',
|
|
206
|
-
id: reviewDetailId,
|
|
207
|
-
attributes: { notes },
|
|
208
|
-
},
|
|
212
|
+
data: { type: 'appStoreReviewDetails', id: reviewDetailId, attributes: { notes } },
|
|
209
213
|
});
|
|
210
|
-
return {
|
|
214
|
+
return {
|
|
215
|
+
reviewDetailId,
|
|
216
|
+
notes: updated?.data?.attributes?.notes ?? notes,
|
|
217
|
+
created: false,
|
|
218
|
+
};
|
|
211
219
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
|
|
220
|
-
},
|
|
220
|
+
// 신규 생성
|
|
221
|
+
const created = await apiPost('/appStoreReviewDetails', {
|
|
222
|
+
data: {
|
|
223
|
+
type: 'appStoreReviewDetails',
|
|
224
|
+
attributes: { notes },
|
|
225
|
+
relationships: {
|
|
226
|
+
appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
|
|
221
227
|
},
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const newId = created?.data?.id ?? '';
|
|
231
|
+
return {
|
|
232
|
+
reviewDetailId: newId,
|
|
233
|
+
notes: created?.data?.attributes?.notes ?? notes,
|
|
234
|
+
created: true,
|
|
235
|
+
};
|
|
226
236
|
}
|
|
227
237
|
export async function getReviewNotes(versionId) {
|
|
228
238
|
try {
|
|
@@ -235,8 +245,11 @@ export async function getReviewNotes(versionId) {
|
|
|
235
245
|
contactEmail: data?.data?.attributes?.contactEmail ?? null,
|
|
236
246
|
};
|
|
237
247
|
}
|
|
238
|
-
catch {
|
|
239
|
-
|
|
248
|
+
catch (err) {
|
|
249
|
+
if (isNotFoundError(err)) {
|
|
250
|
+
return { reviewDetailId: null, notes: null, contactEmail: null };
|
|
251
|
+
}
|
|
252
|
+
throw err;
|
|
240
253
|
}
|
|
241
254
|
}
|
|
242
255
|
// ─── 빌드 ───
|
|
@@ -464,6 +477,40 @@ export async function submitVersionForReview(versionId) {
|
|
|
464
477
|
state: submitted?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW',
|
|
465
478
|
};
|
|
466
479
|
}
|
|
480
|
+
// ─── 심사 철회 (Cancel Review) ───
|
|
481
|
+
// WAITING_FOR_REVIEW 상태의 reviewSubmission에만 적용 가능.
|
|
482
|
+
// IN_REVIEW 진입 후에는 Apple API가 거부함 (409).
|
|
483
|
+
// submitted=false PATCH → version이 PREPARE_FOR_SUBMISSION으로 복귀.
|
|
484
|
+
export async function cancelVersionReview(versionId) {
|
|
485
|
+
const { appId, platform } = await getVersionAppAndPlatform(versionId);
|
|
486
|
+
// 취소 가능한 상태(WAITING_FOR_REVIEW)의 submission 검색
|
|
487
|
+
const data = await apiGet('/reviewSubmissions', {
|
|
488
|
+
'filter[app]': appId,
|
|
489
|
+
'filter[platform]': platform,
|
|
490
|
+
'filter[state]': 'WAITING_FOR_REVIEW',
|
|
491
|
+
'limit': '1',
|
|
492
|
+
});
|
|
493
|
+
const submission = data?.data?.[0];
|
|
494
|
+
if (!submission) {
|
|
495
|
+
throw new Error([
|
|
496
|
+
`취소 가능한 심사 제출이 없어 (WAITING_FOR_REVIEW 상태 없음).`,
|
|
497
|
+
`IN_REVIEW 이상은 API로 취소 불가 — App Store Connect 웹에서 직접 처리하거나`,
|
|
498
|
+
`Apple 심사 결과(APPROVED/REJECTED)를 기다려야 해.`,
|
|
499
|
+
].join('\n'));
|
|
500
|
+
}
|
|
501
|
+
const submissionId = submission.id;
|
|
502
|
+
const previousState = submission.attributes?.state ?? 'WAITING_FOR_REVIEW';
|
|
503
|
+
// submitted=false → WAITING_FOR_REVIEW → READY_FOR_REVIEW (version은 PREPARE_FOR_SUBMISSION으로 복귀)
|
|
504
|
+
const patched = await apiPatch(`/reviewSubmissions/${submissionId}`, {
|
|
505
|
+
data: {
|
|
506
|
+
type: 'reviewSubmissions',
|
|
507
|
+
id: submissionId,
|
|
508
|
+
attributes: { submitted: false },
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
const newState = patched?.data?.attributes?.state ?? 'READY_FOR_REVIEW';
|
|
512
|
+
return { submissionId, previousState, newState, versionId };
|
|
513
|
+
}
|
|
467
514
|
// ─── 인앱 구매 (IAP) 생성 ───
|
|
468
515
|
// 2023년 출시된 IAP v2 API (POST /v2/inAppPurchases) 사용.
|
|
469
516
|
// 흐름: (1) IAP draft 생성 → (2) 로컬라이제이션 → (3) priceSchedule → (4) submission(선택).
|
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
import { JWT } from 'google-auth-library';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* 패키지별 → 레거시(default) 순으로 SA JSON을 로드.
|
|
4
|
+
* 여러 앱이 다른 GCP 프로젝트의 SA를 쓰는 환경 지원.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getServiceAccountJson(packageName?: string): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* 레거시 호환 — 단일 SA 저장 (default).
|
|
9
|
+
*/
|
|
3
10
|
export declare function saveServiceAccountJson(json: string): void;
|
|
4
|
-
|
|
11
|
+
/**
|
|
12
|
+
* 패키지명에 묶여 SA JSON을 저장. 여러 앱이 다른 GCP 프로젝트일 때 사용.
|
|
13
|
+
* ~/.mimi-seed/play-service-accounts/{packageName}.json
|
|
14
|
+
*/
|
|
15
|
+
export declare function saveServiceAccountJsonForPackage(packageName: string, json: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* 등록된 패키지별 SA + default(레거시) 정보 요약.
|
|
18
|
+
*/
|
|
19
|
+
export declare function listRegisteredServiceAccounts(): {
|
|
20
|
+
perPackage: {
|
|
21
|
+
packageName: string;
|
|
22
|
+
clientEmail: string | null;
|
|
23
|
+
projectId: string | null;
|
|
24
|
+
}[];
|
|
25
|
+
default: {
|
|
26
|
+
clientEmail: string | null;
|
|
27
|
+
projectId: string | null;
|
|
28
|
+
} | null;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* 패키지별 SA 삭제. 없으면 false.
|
|
32
|
+
*/
|
|
33
|
+
export declare function deleteServiceAccountJsonForPackage(packageName: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* SA JWT 클라이언트 생성. packageName이 주어지면 패키지별 → default 순으로 탐색.
|
|
36
|
+
*/
|
|
37
|
+
export declare function getServiceAccountClient(packageName?: string): JWT | null;
|
|
@@ -2,25 +2,109 @@ import { JWT } from 'google-auth-library';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
|
|
6
|
+
const SA_DIR = path.join(CONFIG_DIR, 'play-service-accounts');
|
|
7
|
+
const LEGACY_SA_PATH = path.join(CONFIG_DIR, 'play-service-account.json');
|
|
8
|
+
function safeReadFile(p) {
|
|
9
9
|
try {
|
|
10
|
-
return fs.readFileSync(
|
|
10
|
+
return fs.readFileSync(p, 'utf-8');
|
|
11
11
|
}
|
|
12
12
|
catch {
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* 패키지별 → 레거시(default) 순으로 SA JSON을 로드.
|
|
18
|
+
* 여러 앱이 다른 GCP 프로젝트의 SA를 쓰는 환경 지원.
|
|
19
|
+
*/
|
|
20
|
+
export function getServiceAccountJson(packageName) {
|
|
21
|
+
if (packageName) {
|
|
22
|
+
const perPkg = path.join(SA_DIR, `${packageName}.json`);
|
|
23
|
+
if (fs.existsSync(perPkg)) {
|
|
24
|
+
const json = safeReadFile(perPkg);
|
|
25
|
+
if (json)
|
|
26
|
+
return json;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (fs.existsSync(LEGACY_SA_PATH)) {
|
|
30
|
+
const json = safeReadFile(LEGACY_SA_PATH);
|
|
31
|
+
if (json)
|
|
32
|
+
return json;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 레거시 호환 — 단일 SA 저장 (default).
|
|
38
|
+
*/
|
|
16
39
|
export function saveServiceAccountJson(json) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
40
|
+
if (!fs.existsSync(CONFIG_DIR))
|
|
41
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
42
|
+
fs.writeFileSync(LEGACY_SA_PATH, json, { mode: 0o600 });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 패키지명에 묶여 SA JSON을 저장. 여러 앱이 다른 GCP 프로젝트일 때 사용.
|
|
46
|
+
* ~/.mimi-seed/play-service-accounts/{packageName}.json
|
|
47
|
+
*/
|
|
48
|
+
export function saveServiceAccountJsonForPackage(packageName, json) {
|
|
49
|
+
if (!fs.existsSync(SA_DIR))
|
|
50
|
+
fs.mkdirSync(SA_DIR, { recursive: true });
|
|
51
|
+
const filePath = path.join(SA_DIR, `${packageName}.json`);
|
|
52
|
+
fs.writeFileSync(filePath, json, { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 등록된 패키지별 SA + default(레거시) 정보 요약.
|
|
56
|
+
*/
|
|
57
|
+
export function listRegisteredServiceAccounts() {
|
|
58
|
+
const perPackage = [];
|
|
59
|
+
if (fs.existsSync(SA_DIR)) {
|
|
60
|
+
for (const f of fs.readdirSync(SA_DIR)) {
|
|
61
|
+
if (!f.endsWith('.json'))
|
|
62
|
+
continue;
|
|
63
|
+
const packageName = f.replace(/\.json$/, '');
|
|
64
|
+
const json = safeReadFile(path.join(SA_DIR, f));
|
|
65
|
+
let clientEmail = null;
|
|
66
|
+
let projectId = null;
|
|
67
|
+
if (json) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(json);
|
|
70
|
+
clientEmail = parsed.client_email ?? null;
|
|
71
|
+
projectId = parsed.project_id ?? null;
|
|
72
|
+
}
|
|
73
|
+
catch { /* ignore parse errors */ }
|
|
74
|
+
}
|
|
75
|
+
perPackage.push({ packageName, clientEmail, projectId });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let defaultInfo = null;
|
|
79
|
+
if (fs.existsSync(LEGACY_SA_PATH)) {
|
|
80
|
+
const json = safeReadFile(LEGACY_SA_PATH);
|
|
81
|
+
if (json) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(json);
|
|
84
|
+
defaultInfo = { clientEmail: parsed.client_email ?? null, projectId: parsed.project_id ?? null };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
defaultInfo = { clientEmail: null, projectId: null };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { perPackage, default: defaultInfo };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 패키지별 SA 삭제. 없으면 false.
|
|
95
|
+
*/
|
|
96
|
+
export function deleteServiceAccountJsonForPackage(packageName) {
|
|
97
|
+
const filePath = path.join(SA_DIR, `${packageName}.json`);
|
|
98
|
+
if (!fs.existsSync(filePath))
|
|
99
|
+
return false;
|
|
100
|
+
fs.unlinkSync(filePath);
|
|
101
|
+
return true;
|
|
21
102
|
}
|
|
22
|
-
|
|
23
|
-
|
|
103
|
+
/**
|
|
104
|
+
* SA JWT 클라이언트 생성. packageName이 주어지면 패키지별 → default 순으로 탐색.
|
|
105
|
+
*/
|
|
106
|
+
export function getServiceAccountClient(packageName) {
|
|
107
|
+
const json = getServiceAccountJson(packageName);
|
|
24
108
|
if (!json)
|
|
25
109
|
return null;
|
|
26
110
|
try {
|
package/dist/checks/risks.js
CHANGED
|
@@ -53,21 +53,42 @@ export async function checkPlayStoreRisks(auth, packageName, language = 'ko-KR')
|
|
|
53
53
|
});
|
|
54
54
|
return risks;
|
|
55
55
|
}
|
|
56
|
+
// Apple의 ASC API 호출은 권한·필드명·deprecated endpoint 등으로 실패할 수 있다.
|
|
57
|
+
// 실패를 silent하게 null로 만들면 후속 check가 false-positive blocker를 토해낸다 —
|
|
58
|
+
// 이 헬퍼는 실패 사실을 risks 배열에 warning으로 기록해 사용자에게 노출시키되,
|
|
59
|
+
// 호출부는 null을 받아 blocker 분기를 건너뛸 수 있게 해준다.
|
|
60
|
+
async function safeGet(call, risks, code, title) {
|
|
61
|
+
try {
|
|
62
|
+
return await call();
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
risks.push({
|
|
66
|
+
level: 'warning',
|
|
67
|
+
code: `API_ERROR_${code}`,
|
|
68
|
+
title: `App Store Connect 조회 실패 — ${title}`,
|
|
69
|
+
detail: `이 항목은 위험 분석에서 제외됨. 원인: ${e instanceof Error ? e.message : String(e)}`,
|
|
70
|
+
});
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const APP_INFO_LIVE_STATE = 'READY_FOR_DISTRIBUTION';
|
|
56
75
|
export async function checkAppStoreRisks(appId) {
|
|
57
76
|
const risks = [];
|
|
58
|
-
const versions = await apiGet(`/apps/${appId}/appStoreVersions`, {
|
|
77
|
+
const versions = await safeGet(() => apiGet(`/apps/${appId}/appStoreVersions`, {
|
|
59
78
|
'filter[appStoreState]': APPSTORE_EDITABLE_STATES,
|
|
60
79
|
'limit': '5',
|
|
61
|
-
})
|
|
80
|
+
}), risks, 'VERSIONS', '편집 가능한 버전 목록');
|
|
62
81
|
if (!versions?.data?.length) {
|
|
63
82
|
risks.push({ level: 'warning', code: 'NO_EDITABLE_VERSION', title: '편집 가능한 버전 없음', detail: 'App Store Connect에서 새 버전을 만드세요.', fixUrl: 'https://appstoreconnect.apple.com' });
|
|
64
83
|
return risks;
|
|
65
84
|
}
|
|
66
85
|
const versionId = versions.data[0].id;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
apiGet(`/
|
|
86
|
+
// 1) 메타데이터·스크린샷·버전 첨부 빌드는 versionId 기반으로 조회.
|
|
87
|
+
// 2) 개인정보 URL은 appInfoLocalization 단위(앱 단위)로 조회.
|
|
88
|
+
const [locs, attachedBuildRel, appInfos] = await Promise.all([
|
|
89
|
+
safeGet(() => apiGet(`/appStoreVersions/${versionId}/appStoreVersionLocalizations`), risks, 'LOCALIZATIONS', '버전 로컬라이제이션'),
|
|
90
|
+
safeGet(() => apiGet(`/appStoreVersions/${versionId}/relationships/build`), risks, 'BUILD', '버전 첨부 빌드'),
|
|
91
|
+
safeGet(() => apiGet(`/apps/${appId}/appInfos`, { 'fields[appInfos]': 'state', 'limit': '10' }), risks, 'APP_INFO', '앱 정보'),
|
|
71
92
|
]);
|
|
72
93
|
if (!locs?.data?.length) {
|
|
73
94
|
risks.push({ level: 'blocker', code: 'NO_LOCALIZATIONS', title: '로컬라이제이션 없음', detail: '메타데이터를 입력하세요.' });
|
|
@@ -84,16 +105,49 @@ export async function checkAppStoreRisks(appId) {
|
|
|
84
105
|
}
|
|
85
106
|
// screenshots depend on locs being present
|
|
86
107
|
const locId = locs.data[0].id;
|
|
87
|
-
const screenshots = await apiGet(`/appStoreVersionLocalizations/${locId}/appScreenshotSets`)
|
|
88
|
-
if (!screenshots?.data?.length) {
|
|
108
|
+
const screenshots = await safeGet(() => apiGet(`/appStoreVersionLocalizations/${locId}/appScreenshotSets`), risks, 'SCREENSHOTS', '스크린샷 셋');
|
|
109
|
+
if (screenshots && !screenshots?.data?.length) {
|
|
89
110
|
risks.push({ level: 'blocker', code: 'NO_SCREENSHOTS', title: '스크린샷 없음', detail: 'iPhone 6.5" 또는 6.9" 스크린샷이 필요합니다.' });
|
|
90
111
|
}
|
|
91
112
|
}
|
|
92
|
-
|
|
113
|
+
// 빌드 — 1차: 버전에 첨부된 빌드. 2차 폴백: 앱 전체 빌드 (예전 동작).
|
|
114
|
+
// attachedBuildRel?.data가 null이면 "관계 조회 성공 + 첨부 안 됨" 의미.
|
|
115
|
+
// attachedBuildRel 자체가 null이면 safeGet 실패 — fall back.
|
|
116
|
+
let buildVerdict;
|
|
117
|
+
if (attachedBuildRel) {
|
|
118
|
+
buildVerdict = attachedBuildRel.data ? 'present' : 'missing';
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const fallbackBuilds = await safeGet(() => apiGet(`/builds`, {
|
|
122
|
+
'filter[app]': appId,
|
|
123
|
+
'fields[builds]': 'version,uploadedDate,processingState',
|
|
124
|
+
'sort': '-uploadedDate',
|
|
125
|
+
'limit': '5',
|
|
126
|
+
}), risks, 'BUILDS_FALLBACK', '앱 빌드 목록(폴백)');
|
|
127
|
+
buildVerdict = fallbackBuilds?.data?.length ? 'present' : fallbackBuilds ? 'missing' : 'unknown';
|
|
128
|
+
}
|
|
129
|
+
if (buildVerdict === 'missing') {
|
|
93
130
|
risks.push({ level: 'blocker', code: 'NO_BUILD', title: 'TestFlight 빌드 없음', detail: 'Xcode 또는 Fastlane으로 빌드를 업로드하세요.' });
|
|
94
131
|
}
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
// 개인정보처리방침 — appInfoLocalization 어디 한 곳이라도 URL 또는 in-app 텍스트가 있으면 OK.
|
|
133
|
+
// appInfos 조회 자체가 실패하면 unknown으로 두고 blocker 안 띄움 (warning만 기록됨).
|
|
134
|
+
if (appInfos) {
|
|
135
|
+
const infos = (appInfos.data ?? []);
|
|
136
|
+
const stateOf = (i) => i.attributes?.state ?? i.attributes?.appStoreState ?? '';
|
|
137
|
+
const editable = infos.find((i) => stateOf(i) !== APP_INFO_LIVE_STATE) ?? infos[0];
|
|
138
|
+
if (editable) {
|
|
139
|
+
const localizations = await safeGet(() => apiGet(`/appInfos/${editable.id}/appInfoLocalizations`, {
|
|
140
|
+
'fields[appInfoLocalizations]': 'locale,privacyPolicyUrl,privacyPolicyText',
|
|
141
|
+
'limit': '200',
|
|
142
|
+
}), risks, 'PRIVACY', '개인정보 로컬라이제이션');
|
|
143
|
+
if (localizations) {
|
|
144
|
+
const locs = (localizations.data ?? []);
|
|
145
|
+
const hasPrivacy = locs.some((l) => l.attributes?.privacyPolicyUrl || l.attributes?.privacyPolicyText);
|
|
146
|
+
if (!hasPrivacy) {
|
|
147
|
+
risks.push({ level: 'blocker', code: 'NO_PRIVACY', title: '개인정보처리방침 URL 없음', detail: 'App Store 가이드라인 5.1.1 — 필수 항목입니다. 모든 로컬라이제이션에서 privacyPolicyUrl 또는 privacyPolicyText 중 하나를 입력하세요.' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
97
151
|
}
|
|
98
152
|
return risks;
|
|
99
153
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { getAuthenticatedClient, startAuth, ensureFreshAccessToken } from './auth/google-auth.js';
|
|
6
|
-
import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore-auth.js';
|
|
6
|
+
import { getServiceAccountClient, getServiceAccountJson, saveServiceAccountJsonForPackage, listRegisteredServiceAccounts, deleteServiceAccountJsonForPackage, } from './auth/playstore-auth.js';
|
|
7
7
|
import { getAppStoreCredentials } from './appstore/auth.js';
|
|
8
8
|
import { createGoogleOneTimePurchase, createGoogleSubscription, updateGoogleProduct, deleteGoogleProduct, listGoogleProducts, createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
|
|
9
9
|
import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './auth/constants.js';
|
|
@@ -60,14 +60,14 @@ const APPSTORE_AUTH_HINT = [
|
|
|
60
60
|
'Issuer ID, Key ID, .p8 파일 경로를 입력하면 저장 완료.',
|
|
61
61
|
'그 다음에 다시 물어봐줘.',
|
|
62
62
|
].join('\n');
|
|
63
|
-
function requirePlayStoreAuth() {
|
|
64
|
-
const client = getServiceAccountClient();
|
|
63
|
+
function requirePlayStoreAuth(packageName) {
|
|
64
|
+
const client = getServiceAccountClient(packageName);
|
|
65
65
|
if (!client)
|
|
66
66
|
throw new Error(PLAY_AUTH_HINT);
|
|
67
67
|
return client;
|
|
68
68
|
}
|
|
69
|
-
function requireServiceAccountJson() {
|
|
70
|
-
const json = getServiceAccountJson();
|
|
69
|
+
function requireServiceAccountJson(packageName) {
|
|
70
|
+
const json = getServiceAccountJson(packageName);
|
|
71
71
|
if (!json)
|
|
72
72
|
throw new Error(PLAY_AUTH_HINT);
|
|
73
73
|
return json;
|
|
@@ -301,7 +301,7 @@ server.tool('admob_create_ad_unit', 'AdMob 광고 단위 생성 (v1beta — Limi
|
|
|
301
301
|
// Google Play Store Tools
|
|
302
302
|
// ══════════════════════════════════════════════════
|
|
303
303
|
server.tool('playstore_get_app', 'Google Play 앱 상세 정보 조회', { packageName: z.string().describe('패키지명 (예: com.findthem.app)') }, async ({ packageName }) => {
|
|
304
|
-
const auth = requirePlayStoreAuth();
|
|
304
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
305
305
|
const details = await playstore.getAppDetails(auth, packageName);
|
|
306
306
|
return { content: [{ type: 'text', text: JSON.stringify(details, null, 2) }] };
|
|
307
307
|
});
|
|
@@ -309,7 +309,7 @@ server.tool('playstore_get_listing', 'Google Play 스토어 리스팅 조회 (
|
|
|
309
309
|
packageName: z.string().describe('패키지명'),
|
|
310
310
|
language: z.string().default('ko-KR').describe('언어 코드 (기본: ko-KR)'),
|
|
311
311
|
}, async ({ packageName, language }) => {
|
|
312
|
-
const auth = requirePlayStoreAuth();
|
|
312
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
313
313
|
const listing = await playstore.getListing(auth, packageName, language);
|
|
314
314
|
return { content: [{ type: 'text', text: JSON.stringify(listing, null, 2) }] };
|
|
315
315
|
});
|
|
@@ -320,14 +320,14 @@ server.tool('playstore_update_listing', 'Google Play 스토어 리스팅 수정
|
|
|
320
320
|
shortDescription: z.string().optional().describe('짧은 설명 (80자 이내)'),
|
|
321
321
|
fullDescription: z.string().optional().describe('전체 설명 (4000자 이내)'),
|
|
322
322
|
}, async ({ packageName, language, title, shortDescription, fullDescription }) => {
|
|
323
|
-
const auth = requirePlayStoreAuth();
|
|
323
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
324
324
|
const result = await playstore.updateListing(auth, packageName, language, {
|
|
325
325
|
title, shortDescription, fullDescription,
|
|
326
326
|
});
|
|
327
327
|
return { content: [{ type: 'text', text: `수정 완료:\n${JSON.stringify(result, null, 2)}` }] };
|
|
328
328
|
});
|
|
329
329
|
server.tool('playstore_list_tracks', 'Google Play 릴리스 트랙 현황 (프로덕션/베타/알파/내부)', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
|
|
330
|
-
const auth = requirePlayStoreAuth();
|
|
330
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
331
331
|
const tracks = await playstore.listTracks(auth, packageName);
|
|
332
332
|
return { content: [{ type: 'text', text: JSON.stringify(tracks, null, 2) }] };
|
|
333
333
|
});
|
|
@@ -336,7 +336,7 @@ server.tool('playstore_list_images', 'Google Play 리스팅 이미지 목록 조
|
|
|
336
336
|
language: z.string().describe('언어 코드 (예: ko-KR)'),
|
|
337
337
|
imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']).describe('이미지 타입'),
|
|
338
338
|
}, async ({ packageName, language, imageType }) => {
|
|
339
|
-
const auth = requirePlayStoreAuth();
|
|
339
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
340
340
|
const images = await playstore.listImages(auth, packageName, language, imageType);
|
|
341
341
|
return { content: [{ type: 'text', text: JSON.stringify(images, null, 2) }] };
|
|
342
342
|
});
|
|
@@ -346,7 +346,7 @@ server.tool('playstore_upload_image', 'Google Play 리스팅 이미지 단일
|
|
|
346
346
|
imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
|
|
347
347
|
filePath: z.string().describe('업로드할 이미지 절대 경로'),
|
|
348
348
|
}, async ({ packageName, language, imageType, filePath }) => {
|
|
349
|
-
const auth = requirePlayStoreAuth();
|
|
349
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
350
350
|
const result = await playstore.uploadImage(auth, packageName, language, imageType, filePath);
|
|
351
351
|
return { content: [{ type: 'text', text: `✅ 업로드 완료\n${JSON.stringify(result, null, 2)}` }] };
|
|
352
352
|
});
|
|
@@ -355,7 +355,7 @@ server.tool('playstore_delete_all_images', 'Google Play 리스팅 특정 imageTy
|
|
|
355
355
|
language: z.string().describe('언어 코드'),
|
|
356
356
|
imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
|
|
357
357
|
}, async ({ packageName, language, imageType }) => {
|
|
358
|
-
const auth = requirePlayStoreAuth();
|
|
358
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
359
359
|
const result = await playstore.deleteAllImages(auth, packageName, language, imageType);
|
|
360
360
|
return { content: [{ type: 'text', text: `✅ 전체 삭제\n${JSON.stringify(result, null, 2)}` }] };
|
|
361
361
|
});
|
|
@@ -365,7 +365,7 @@ server.tool('playstore_replace_images', 'Google Play 리스팅 이미지 일괄
|
|
|
365
365
|
imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
|
|
366
366
|
filePaths: z.array(z.string()).describe('업로드할 이미지 절대 경로 배열 (순서 = 노출 순서)'),
|
|
367
367
|
}, async ({ packageName, language, imageType, filePaths }) => {
|
|
368
|
-
const auth = requirePlayStoreAuth();
|
|
368
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
369
369
|
const result = await playstore.replaceImages(auth, packageName, language, imageType, filePaths);
|
|
370
370
|
return { content: [{ type: 'text', text: `✅ ${result.count}장 교체 완료\n${JSON.stringify(result, null, 2)}` }] };
|
|
371
371
|
});
|
|
@@ -376,7 +376,7 @@ server.tool('playstore_update_release_notes', "Google Play 트랙 릴리스의 '
|
|
|
376
376
|
language: z.string().describe('언어 코드 (예: ko-KR, en-US)'),
|
|
377
377
|
text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
|
|
378
378
|
}, async ({ packageName, track, versionCode, language, text }) => {
|
|
379
|
-
const auth = requirePlayStoreAuth();
|
|
379
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
380
380
|
const result = await playstore.updateReleaseNotes(auth, packageName, track, versionCode, language, text);
|
|
381
381
|
return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode} ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
382
382
|
});
|
|
@@ -386,12 +386,12 @@ server.tool('playstore_update_latest_release_notes', "Google Play 트랙의 최
|
|
|
386
386
|
language: z.string().describe('언어 코드 (예: ko-KR)'),
|
|
387
387
|
text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
|
|
388
388
|
}, async ({ packageName, track, language, text }) => {
|
|
389
|
-
const auth = requirePlayStoreAuth();
|
|
389
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
390
390
|
const result = await playstore.updateLatestReleaseNotes(auth, packageName, track, language, text);
|
|
391
391
|
return { content: [{ type: 'text', text: `✅ ${packageName} ${track} (versionCodes=${JSON.stringify(result.updatedVersionCodes)}) ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
392
392
|
});
|
|
393
393
|
server.tool('playstore_list_reviews', 'Google Play 리뷰 목록 조회', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
|
|
394
|
-
const auth = requirePlayStoreAuth();
|
|
394
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
395
395
|
const reviews = await playstore.listReviews(auth, packageName);
|
|
396
396
|
return { content: [{ type: 'text', text: JSON.stringify(reviews, null, 2) }] };
|
|
397
397
|
});
|
|
@@ -400,17 +400,17 @@ server.tool('playstore_reply_review', 'Google Play 리뷰에 답변', {
|
|
|
400
400
|
reviewId: z.string().describe('리뷰 ID'),
|
|
401
401
|
replyText: z.string().describe('답변 내용'),
|
|
402
402
|
}, async ({ packageName, reviewId, replyText }) => {
|
|
403
|
-
const auth = requirePlayStoreAuth();
|
|
403
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
404
404
|
const result = await playstore.replyToReview(auth, packageName, reviewId, replyText);
|
|
405
405
|
return { content: [{ type: 'text', text: `답변 완료:\n${JSON.stringify(result, null, 2)}` }] };
|
|
406
406
|
});
|
|
407
407
|
server.tool('playstore_list_inapp_products', 'Google Play 인앱 상품 목록', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
|
|
408
|
-
const auth = requirePlayStoreAuth();
|
|
408
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
409
409
|
const products = await playstore.listInAppProducts(auth, packageName);
|
|
410
410
|
return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] };
|
|
411
411
|
});
|
|
412
412
|
server.tool('playstore_list_subscriptions', 'Google Play 구독 상품 목록', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
|
|
413
|
-
const auth = requirePlayStoreAuth();
|
|
413
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
414
414
|
const subs = await playstore.listSubscriptions(auth, packageName);
|
|
415
415
|
return { content: [{ type: 'text', text: JSON.stringify(subs, null, 2) }] };
|
|
416
416
|
});
|
|
@@ -437,7 +437,7 @@ server.tool('playstore_create_onetime_product', [
|
|
|
437
437
|
.optional()
|
|
438
438
|
.describe('추가 지역별 명시 가격. 자동 환산이 부정확한 KRW/JPY 등에 직접 지정.'),
|
|
439
439
|
}, async (args) => {
|
|
440
|
-
const json = requireServiceAccountJson();
|
|
440
|
+
const json = requireServiceAccountJson(args.packageName);
|
|
441
441
|
const result = await createGoogleOneTimePurchase({
|
|
442
442
|
packageName: args.packageName,
|
|
443
443
|
productId: args.productId,
|
|
@@ -488,7 +488,7 @@ server.tool('playstore_create_subscription', [
|
|
|
488
488
|
.optional()
|
|
489
489
|
.describe('추가 지역별 명시 가격'),
|
|
490
490
|
}, async (args) => {
|
|
491
|
-
const json = requireServiceAccountJson();
|
|
491
|
+
const json = requireServiceAccountJson(args.packageName);
|
|
492
492
|
const result = await createGoogleSubscription({
|
|
493
493
|
packageName: args.packageName,
|
|
494
494
|
productId: args.productId,
|
|
@@ -584,6 +584,96 @@ server.tool('playstore_verify_service_account', [
|
|
|
584
584
|
}
|
|
585
585
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
586
586
|
});
|
|
587
|
+
server.tool('playstore_register_service_account', [
|
|
588
|
+
'Google Play 서비스 계정 JSON을 패키지 단위로 등록 (~/.mimi-seed/play-service-accounts/{packageName}.json, 0600 mode).',
|
|
589
|
+
'여러 앱이 서로 다른 GCP 프로젝트의 SA를 쓰는 환경 지원. 등록 후 해당 packageName으로 호출하는 모든 playstore_* 도구가 이 SA를 사용 (등록 안 된 패키지는 ~/.mimi-seed/play-service-account.json 의 default SA로 폴백).',
|
|
590
|
+
'먼저 playstore_verify_service_account 로 권한 확인 후 등록을 권장.',
|
|
591
|
+
].join(' '), {
|
|
592
|
+
packageName: z.string().describe('Android 패키지명 (예: gg.pryzm.weather)'),
|
|
593
|
+
serviceAccountJson: z.string().describe('서비스 계정 JSON 전체 내용 (문자열)'),
|
|
594
|
+
skipVerify: z.boolean().optional().describe('true면 사전 검증 건너뜀 (기본 false: 등록 전 verifyServiceAccountJson 실행)'),
|
|
595
|
+
}, async ({ packageName, serviceAccountJson, skipVerify }) => {
|
|
596
|
+
if (!skipVerify) {
|
|
597
|
+
const verify = await playstore.verifyServiceAccountJson(serviceAccountJson, packageName);
|
|
598
|
+
if (!verify.ok) {
|
|
599
|
+
return {
|
|
600
|
+
content: [{
|
|
601
|
+
type: 'text',
|
|
602
|
+
text: [
|
|
603
|
+
`❌ 검증 실패 (stage: ${verify.stage})로 등록 중단.`,
|
|
604
|
+
verify.message,
|
|
605
|
+
'',
|
|
606
|
+
`검증을 건너뛰고 강제 등록하려면 skipVerify=true 옵션 추가.`,
|
|
607
|
+
].join('\n'),
|
|
608
|
+
}],
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
let clientEmail = '';
|
|
613
|
+
let projectId = '';
|
|
614
|
+
try {
|
|
615
|
+
const parsed = JSON.parse(serviceAccountJson);
|
|
616
|
+
clientEmail = parsed.client_email ?? '';
|
|
617
|
+
projectId = parsed.project_id ?? '';
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return { content: [{ type: 'text', text: '❌ JSON 파싱 실패 — 서비스 계정 JSON 형식이 올바르지 않음.' }] };
|
|
621
|
+
}
|
|
622
|
+
saveServiceAccountJsonForPackage(packageName, serviceAccountJson);
|
|
623
|
+
return {
|
|
624
|
+
content: [{
|
|
625
|
+
type: 'text',
|
|
626
|
+
text: [
|
|
627
|
+
`✓ ${packageName} 서비스 계정 등록 완료`,
|
|
628
|
+
'',
|
|
629
|
+
`**clientEmail**: \`${clientEmail}\``,
|
|
630
|
+
`**projectId**: \`${projectId}\``,
|
|
631
|
+
`**저장 경로**: \`~/.mimi-seed/play-service-accounts/${packageName}.json\` (0600)`,
|
|
632
|
+
'',
|
|
633
|
+
'이후 이 packageName으로 호출하는 모든 playstore_* 도구가 자동으로 이 SA 사용.',
|
|
634
|
+
].join('\n'),
|
|
635
|
+
}],
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
server.tool('playstore_list_service_accounts', '등록된 패키지별 서비스 계정 + default(레거시) SA 정보 요약. clientEmail / projectId 만 노출 (private_key 미노출).', {}, async () => {
|
|
639
|
+
const info = listRegisteredServiceAccounts();
|
|
640
|
+
const lines = [];
|
|
641
|
+
if (info.default) {
|
|
642
|
+
lines.push('**Default (legacy)**: `~/.mimi-seed/play-service-account.json`');
|
|
643
|
+
lines.push(` - clientEmail: \`${info.default.clientEmail ?? '(parse error)'}\``);
|
|
644
|
+
lines.push(` - projectId: \`${info.default.projectId ?? '(parse error)'}\``);
|
|
645
|
+
lines.push('');
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
lines.push('**Default (legacy)**: 미등록');
|
|
649
|
+
lines.push('');
|
|
650
|
+
}
|
|
651
|
+
if (info.perPackage.length === 0) {
|
|
652
|
+
lines.push('**Per-package**: 없음');
|
|
653
|
+
lines.push('');
|
|
654
|
+
lines.push('등록 방법: `playstore_register_service_account(packageName, serviceAccountJson)`');
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
lines.push(`**Per-package** (${info.perPackage.length}개):`);
|
|
658
|
+
for (const item of info.perPackage) {
|
|
659
|
+
lines.push(`- \`${item.packageName}\` → \`${item.clientEmail ?? '(parse error)'}\` (project: \`${item.projectId ?? 'unknown'}\`)`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
663
|
+
});
|
|
664
|
+
server.tool('playstore_delete_service_account', '등록된 패키지별 서비스 계정 삭제. default(레거시) SA는 영향 없음.', {
|
|
665
|
+
packageName: z.string().describe('삭제할 패키지명'),
|
|
666
|
+
}, async ({ packageName }) => {
|
|
667
|
+
const deleted = deleteServiceAccountJsonForPackage(packageName);
|
|
668
|
+
return {
|
|
669
|
+
content: [{
|
|
670
|
+
type: 'text',
|
|
671
|
+
text: deleted
|
|
672
|
+
? `✓ ${packageName} 서비스 계정 파일 삭제 완료. 이후 이 패키지는 default SA로 폴백.`
|
|
673
|
+
: `(skip) ${packageName} 등록된 패키지별 SA 없음.`,
|
|
674
|
+
}],
|
|
675
|
+
};
|
|
676
|
+
});
|
|
587
677
|
// ══════════════════════════════════════════════════
|
|
588
678
|
// Google Cloud IAM Tools
|
|
589
679
|
// ══════════════════════════════════════════════════
|
|
@@ -831,15 +921,17 @@ server.tool('appstore_update_whats_new', "App Store '이 버전의 새로운 기
|
|
|
831
921
|
const result = await appstore.updateVersionWhatsNew(versionId, locale, { whatsNew });
|
|
832
922
|
return { content: [{ type: 'text', text: `✅ ${locale} 로캘의 What's New가 업데이트됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
833
923
|
});
|
|
834
|
-
server.tool('appstore_update_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 등록/수정. versionId 버전에 appStoreReviewDetail.notes를 PATCH하거나 없으면 POST로 생성. 심사 시 리뷰어에게 전달되는 테스트 계정·기능 안내 텍스트 작성에 사용.", {
|
|
924
|
+
server.tool('appstore_update_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 등록/수정. versionId 버전에 appStoreReviewDetail.notes를 PATCH하거나 없으면 POST로 생성. 심사 시 리뷰어에게 전달되는 테스트 계정·기능 안내 텍스트 작성에 사용. 4000자 권장 한도.", {
|
|
835
925
|
versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
|
|
836
|
-
notes: z.string().describe('리뷰어에게 전달할 메모 (테스트 계정, 주요 변경사항, 접근 방법 등). 4000자
|
|
926
|
+
notes: z.string().min(1).max(4000).describe('리뷰어에게 전달할 메모 (테스트 계정, 주요 변경사항, 접근 방법 등). 4000자 이내.'),
|
|
837
927
|
}, async ({ versionId, notes }) => {
|
|
838
928
|
const result = await appstore.updateReviewNotes(versionId, notes);
|
|
929
|
+
const action = result.created ? 'created' : 'updated';
|
|
930
|
+
const summary = `✅ 리뷰어 노트 ${result.created ? '신규 등록' : '수정'} 완료 (reviewDetailId: ${result.reviewDetailId})`;
|
|
839
931
|
return {
|
|
840
932
|
content: [{
|
|
841
933
|
type: 'text',
|
|
842
|
-
text:
|
|
934
|
+
text: `${summary}\n\n${JSON.stringify({ ok: true, action, ...result }, null, 2)}`,
|
|
843
935
|
}],
|
|
844
936
|
};
|
|
845
937
|
});
|
|
@@ -848,12 +940,18 @@ server.tool('appstore_get_review_notes', "App Store 심사 리뷰어 노트(Note
|
|
|
848
940
|
}, async ({ versionId }) => {
|
|
849
941
|
const result = await appstore.getReviewNotes(versionId);
|
|
850
942
|
if (!result.reviewDetailId) {
|
|
851
|
-
return {
|
|
943
|
+
return {
|
|
944
|
+
content: [{
|
|
945
|
+
type: 'text',
|
|
946
|
+
text: `이 버전에는 아직 리뷰어 노트가 없어. appstore_update_review_notes로 등록해줘.\n\n${JSON.stringify({ ok: true, exists: false }, null, 2)}`,
|
|
947
|
+
}],
|
|
948
|
+
};
|
|
852
949
|
}
|
|
950
|
+
const summary = `reviewDetailId: ${result.reviewDetailId}\ncontactEmail: ${result.contactEmail ?? '(없음)'}\n\n노트:\n${result.notes ?? '(비어있음)'}`;
|
|
853
951
|
return {
|
|
854
952
|
content: [{
|
|
855
953
|
type: 'text',
|
|
856
|
-
text:
|
|
954
|
+
text: `${summary}\n\n${JSON.stringify({ ok: true, exists: true, ...result }, null, 2)}`,
|
|
857
955
|
}],
|
|
858
956
|
};
|
|
859
957
|
});
|
|
@@ -1049,7 +1147,7 @@ server.tool('appstore_create_subscription', [
|
|
|
1049
1147
|
server.tool('playstore_list_products', 'Google Play의 모든 IAP 상품(구독 + 일회성) 통합 조회. productId / name / status / type / price / currency 반환.', {
|
|
1050
1148
|
packageName: z.string().describe('패키지명'),
|
|
1051
1149
|
}, async ({ packageName }) => {
|
|
1052
|
-
const json = requireServiceAccountJson();
|
|
1150
|
+
const json = requireServiceAccountJson(packageName);
|
|
1053
1151
|
const products = await listGoogleProducts({ packageName, serviceAccountKey: json });
|
|
1054
1152
|
return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] };
|
|
1055
1153
|
});
|
|
@@ -1059,7 +1157,7 @@ server.tool('playstore_update_product', 'Google Play IAP 상품의 표시 이름
|
|
|
1059
1157
|
productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
|
|
1060
1158
|
name: z.string().describe('새 표시 이름'),
|
|
1061
1159
|
}, async ({ packageName, productId, productType, name }) => {
|
|
1062
|
-
const json = requireServiceAccountJson();
|
|
1160
|
+
const json = requireServiceAccountJson(packageName);
|
|
1063
1161
|
const result = await updateGoogleProduct({
|
|
1064
1162
|
packageName, productId, productType, name, serviceAccountKey: json,
|
|
1065
1163
|
});
|
|
@@ -1073,7 +1171,7 @@ server.tool('playstore_delete_product', '⚠️ 비가역. Google Play IAP 상
|
|
|
1073
1171
|
productId: z.string().describe('상품 ID'),
|
|
1074
1172
|
productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
|
|
1075
1173
|
}, async ({ packageName, productId, productType }) => {
|
|
1076
|
-
const json = requireServiceAccountJson();
|
|
1174
|
+
const json = requireServiceAccountJson(packageName);
|
|
1077
1175
|
const result = await deleteGoogleProduct({
|
|
1078
1176
|
packageName, productId, productType, serviceAccountKey: json,
|
|
1079
1177
|
});
|
|
@@ -1146,7 +1244,7 @@ server.tool('playstore_plan_release', [
|
|
|
1146
1244
|
track: z.enum(['production', 'beta', 'alpha', 'internal']).optional().describe('대상 트랙 (기본: production)'),
|
|
1147
1245
|
language: z.string().optional().describe('점검할 리스팅 언어 (기본: ko-KR)'),
|
|
1148
1246
|
}, async ({ packageName, versionCode, track, language }) => {
|
|
1149
|
-
const auth = requirePlayStoreAuth();
|
|
1247
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
1150
1248
|
const text = await buildPlayStoreReleasePlan({
|
|
1151
1249
|
auth,
|
|
1152
1250
|
packageName,
|
|
@@ -1183,6 +1281,29 @@ server.tool('appstore_submit_for_review', [
|
|
|
1183
1281
|
const result = await appstore.submitVersionForReview(versionId);
|
|
1184
1282
|
return { content: [{ type: 'text', text: `✅ 버전 ${versionId} 심사 제출 완료 (state: ${result.state}). App Store Connect에서 진행 상태 확인 가능.\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
1185
1283
|
});
|
|
1284
|
+
// --- App Store 심사 철회 (Cancel Review) ---
|
|
1285
|
+
server.tool('appstore_cancel_review', [
|
|
1286
|
+
'App Store 심사 제출을 철회 — WAITING_FOR_REVIEW 상태에서만 가능.',
|
|
1287
|
+
'submitted=false PATCH → reviewSubmission이 READY_FOR_REVIEW로 복귀, 버전은 PREPARE_FOR_SUBMISSION 상태로 돌아가 메타데이터/빌드 수정 가능.',
|
|
1288
|
+
'⚠️ IN_REVIEW 이상이면 Apple API가 409로 거부함 — 이 경우 App Store Connect 웹에서 직접 처리하거나 심사 결과를 기다려야 함.',
|
|
1289
|
+
'철회 후 수정 완료 시 appstore_submit_for_review로 재제출 가능.',
|
|
1290
|
+
].join(' '), {
|
|
1291
|
+
versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
|
|
1292
|
+
}, async ({ versionId }) => {
|
|
1293
|
+
const result = await appstore.cancelVersionReview(versionId);
|
|
1294
|
+
return {
|
|
1295
|
+
content: [{
|
|
1296
|
+
type: 'text',
|
|
1297
|
+
text: [
|
|
1298
|
+
`✅ 심사 철회 완료`,
|
|
1299
|
+
` submissionId: ${result.submissionId}`,
|
|
1300
|
+
` ${result.previousState} → ${result.newState}`,
|
|
1301
|
+
` 버전 ${result.versionId}이(가) PREPARE_FOR_SUBMISSION 상태로 복귀됨.`,
|
|
1302
|
+
` 메타데이터/빌드 수정 후 appstore_submit_for_review로 재제출 가능.`,
|
|
1303
|
+
].join('\n'),
|
|
1304
|
+
}],
|
|
1305
|
+
};
|
|
1306
|
+
});
|
|
1186
1307
|
// --- Play Store 심사 제출 / release status 변경 ---
|
|
1187
1308
|
server.tool('playstore_submit_release', [
|
|
1188
1309
|
'Google Play 트랙의 release status를 변경 — 일반적으로 draft → completed로 바꿔 검토/배포 큐에 진입시킬 때 사용.',
|
|
@@ -1195,7 +1316,7 @@ server.tool('playstore_submit_release', [
|
|
|
1195
1316
|
versionCode: z.string().describe('대상 versionCode (문자열)'),
|
|
1196
1317
|
status: z.enum(['draft', 'inProgress', 'completed', 'halted']).optional().describe('새 status (기본: completed)'),
|
|
1197
1318
|
}, async ({ packageName, track, versionCode, status }) => {
|
|
1198
|
-
const auth = requirePlayStoreAuth();
|
|
1319
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
1199
1320
|
const result = await playstore.submitRelease(auth, packageName, track, versionCode, status);
|
|
1200
1321
|
return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode}: ${result.previousStatus} → ${result.newStatus}\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
1201
1322
|
});
|
|
@@ -1221,7 +1342,7 @@ server.tool('playstore_promote_release', [
|
|
|
1221
1342
|
text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
|
|
1222
1343
|
})).optional().describe('대상 트랙용 릴리스 노트 (미지정 + copyReleaseNotes=true면 source 그대로)'),
|
|
1223
1344
|
}, async ({ packageName, fromTrack, toTrack, versionCode, status, userFraction, releaseName, copyReleaseNotes, releaseNotes }) => {
|
|
1224
|
-
const auth = requirePlayStoreAuth();
|
|
1345
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
1225
1346
|
const result = await playstore.promoteRelease(auth, packageName, fromTrack, toTrack, versionCode, {
|
|
1226
1347
|
status,
|
|
1227
1348
|
userFraction,
|
package/package.json
CHANGED