@yoonion/mimi-seed-mcp 0.2.0 → 0.3.0
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 +100 -20
- package/dist/ai/client.d.ts +4 -0
- package/dist/ai/client.js +30 -0
- package/dist/ai/notes.d.ts +22 -0
- package/dist/ai/notes.js +67 -0
- package/dist/ai/review.d.ts +15 -0
- package/dist/ai/review.js +60 -0
- package/dist/appstore/screenshots.d.ts +15 -0
- package/dist/appstore/screenshots.js +163 -0
- package/dist/appstore/tools.d.ts +15 -0
- package/dist/appstore/tools.js +48 -1
- package/dist/auth/google-auth.d.ts +15 -1
- package/dist/auth/google-auth.js +46 -7
- package/dist/checks/risks.d.ts +12 -0
- package/dist/checks/risks.js +123 -0
- package/dist/checks/screenshots.d.ts +68 -0
- package/dist/checks/screenshots.js +168 -0
- package/dist/index.js +260 -1
- package/dist/playstore/tools.d.ts +42 -7
- package/dist/playstore/tools.js +147 -9
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Mimi Seed** — Firebase · AdMob · Google Play · App Store Connect를 AI 콘솔에서 관리. Claude Code / Cursor / 기타 MCP 클라이언트에서 한 줄 등록으로 사용.
|
|
4
4
|
|
|
5
|
-
> 이 패키지는 Mimi Seed의
|
|
5
|
+
> 이 패키지는 Mimi Seed의 **로컬 MCP 서버**만 포함합니다. 웹 콘솔(Next.js 앱)은 <https://mimi-seed.pryzm.gg>.
|
|
6
6
|
|
|
7
7
|
## 설치
|
|
8
8
|
|
|
@@ -41,16 +41,100 @@ npx -y @yoonion/mimi-seed-mcp mimi-seed-appstore-auth
|
|
|
41
41
|
|
|
42
42
|
(App Store Connect → Users and Access → Keys에서 API Key 생성 후 Issuer ID / Key ID / .p8 경로 입력)
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
AI 기능(릴리즈 노트 생성, 리뷰 답변)을 쓰려면:
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
```bash
|
|
47
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 제공 도구 (65+개)
|
|
53
|
+
|
|
54
|
+
| 영역 | 주요 도구 |
|
|
47
55
|
|------|---------|
|
|
48
56
|
| Firebase | `firebase_list_projects` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_enable_service` |
|
|
49
57
|
| AdMob | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
|
|
50
|
-
| Google Play | `playstore_list_tracks` / `
|
|
51
|
-
| Google Cloud IAM | `iam_list_service_accounts` / `iam_create_service_account` / `iam_list_keys` /
|
|
52
|
-
| App Store Connect | `appstore_list_apps` / `appstore_list_builds` / `
|
|
53
|
-
|
|
|
58
|
+
| Google Play | `playstore_list_tracks` / `playstore_update_listing` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
|
|
59
|
+
| Google Cloud IAM | `iam_list_service_accounts` / `iam_create_service_account` / `iam_list_keys` / `iam_create_key` / `iam_add_iam_policy_binding` |
|
|
60
|
+
| App Store Connect | `appstore_list_apps` / `appstore_list_builds` / `appstore_upload_screenshot` / `appstore_update_whats_new` |
|
|
61
|
+
| 제출 위험 점검 | `playstore_check_submission_risks` / `appstore_check_submission_risks` |
|
|
62
|
+
| 스크린샷 검증 | `screenshot_validate` |
|
|
63
|
+
| AI (Claude) | `generate_release_notes_from_commits` / `generate_review_reply` |
|
|
64
|
+
| 인증 | `mimi_seed_auth_status` / `mimi_seed_auth_start` |
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 주요 기능
|
|
69
|
+
|
|
70
|
+
### 제출 위험 점검
|
|
71
|
+
|
|
72
|
+
출시 전 블로커와 경고를 자동으로 점검합니다.
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
"내 앱 출시 전 위험 요소 확인해줘"
|
|
76
|
+
→ playstore_check_submission_risks("com.example.myapp")
|
|
77
|
+
→ appstore_check_submission_risks("1234567890")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**점검 항목:**
|
|
81
|
+
- Google Play: 리스팅 완성도(제목/설명/짧은설명), 스크린샷 수, 아이콘, 내부 빌드 존재, 연락처
|
|
82
|
+
- App Store: What's New, 설명/키워드, 스크린샷 커버리지, TestFlight 빌드, 개인정보처리방침 URL
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
### 스크린샷 해상도 검증
|
|
87
|
+
|
|
88
|
+
업로드 전 로컬 파일을 스토어 규격과 비교합니다.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
"이 스크린샷들이 App Store 규격에 맞는지 확인해줘"
|
|
92
|
+
→ screenshot_validate(["/path/to/screen1.png", "/path/to/screen2.png"], platform="ios", displayType="APP_IPHONE_69")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
iOS displayType 예시: `APP_IPHONE_69`, `APP_IPHONE_67`, `APP_IPHONE_65`, `APP_IPAD_PRO_3GEN_129`
|
|
96
|
+
Android imageType 예시: `phoneScreenshots`, `sevenInchScreenshots`, `featureGraphic`
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### AI 릴리즈 노트 생성 (ANTHROPIC_API_KEY 필요)
|
|
101
|
+
|
|
102
|
+
git 커밋 내역을 Claude가 사용자 친화적인 릴리즈 노트로 변환합니다.
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
"최근 커밋으로 릴리즈 노트 만들어줘"
|
|
106
|
+
→ generate_release_notes_from_commits(
|
|
107
|
+
commits=[{message: "feat: 다크모드 추가"}, ...],
|
|
108
|
+
appName="MyApp",
|
|
109
|
+
locales=["ko", "en-US", "ja"]
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
3가지 톤(간결/상세/마케팅) + 다국어를 한 번에 생성. 이후 `playstore_update_release_notes` 또는 `appstore_update_whats_new`로 바로 적용.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### AI 리뷰 답변 생성 (ANTHROPIC_API_KEY 필요)
|
|
118
|
+
|
|
119
|
+
스토어 리뷰에 대한 AI 답변 초안을 생성합니다.
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
"이 리뷰에 답변 작성해줘"
|
|
123
|
+
→ generate_review_reply(
|
|
124
|
+
reviewText="앱이 자꾸 튕겨요",
|
|
125
|
+
rating=2,
|
|
126
|
+
appName="MyApp",
|
|
127
|
+
tone="empathetic",
|
|
128
|
+
language="ko"
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
tone 옵션: `friendly`(친근) / `professional`(정중) / `empathetic`(공감) / `brief`(간결)
|
|
133
|
+
|
|
134
|
+
> ⚠ AI 생성 답변은 초안입니다. 게시 전 반드시 검토하세요.
|
|
135
|
+
> 답변 게시는 `playstore_reply_to_review` 도구를 사용하세요.
|
|
136
|
+
|
|
137
|
+
---
|
|
54
138
|
|
|
55
139
|
## End-to-end: 서비스 계정 → JSON 키 → Play Console 권한
|
|
56
140
|
|
|
@@ -76,22 +160,15 @@ Claude가 연쇄 호출:
|
|
|
76
160
|
|
|
77
161
|
> Cloud IAM 역할과 Play Console 권한은 다릅니다. `iam_add_iam_policy_binding`은 Cloud IAM 역할(예: `roles/iam.serviceAccountTokenCreator`)만 부여 — Play Console의 "View financial data"는 별도.
|
|
78
162
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
[onesub](https://github.com/jeonghwanko/onesub) 같은 서버가 Google Play 영수증을 백그라운드로 검증하려면 OAuth가 아니라 **서비스 계정 JSON**이 필요. 이 도구는 JSON을 붙여넣으면 단계별로 확인:
|
|
163
|
+
---
|
|
82
164
|
|
|
83
|
-
|
|
84
|
-
2. **auth** — 실제로 Google에서 OAuth 토큰 받을 수 있는지 (private_key 유효성)
|
|
85
|
-
3. **api** — 'View financial data' 권한 있는지 (`monetization.subscriptions.list` 실 호출)
|
|
165
|
+
## 환경변수
|
|
86
166
|
|
|
87
|
-
|
|
167
|
+
| 변수 | 설명 |
|
|
168
|
+
|------|------|
|
|
169
|
+
| `ANTHROPIC_API_KEY` | AI 릴리즈 노트 생성 / 리뷰 답변 활성화 (선택) |
|
|
88
170
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
> (Claude에게) 이 서비스 계정 JSON이 `com.yourapp.id`에 대해 작동하는지 확인해줘:
|
|
92
|
-
> ```json
|
|
93
|
-
> {"type":"service_account", "project_id":"...", ...}
|
|
94
|
-
> ```
|
|
171
|
+
---
|
|
95
172
|
|
|
96
173
|
## 레거시 호환성
|
|
97
174
|
|
|
@@ -103,8 +180,11 @@ Claude가 연쇄 호출:
|
|
|
103
180
|
|
|
104
181
|
새로 쓰는 건 `~/.mimi-seed/`.
|
|
105
182
|
|
|
183
|
+
---
|
|
184
|
+
|
|
106
185
|
## Links
|
|
107
186
|
|
|
187
|
+
- CLI 패키지: [`mimi-seed`](https://www.npmjs.com/package/mimi-seed)
|
|
108
188
|
- 웹 콘솔: https://mimi-seed.pryzm.gg
|
|
109
189
|
- 저장소: https://github.com/jeonghwanko/app-gen
|
|
110
190
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
export const LOCALE_NAMES = {
|
|
3
|
+
'ko': '한국어', 'ko-KR': '한국어',
|
|
4
|
+
'en': '영어', 'en-US': '영어', 'en-GB': '영어',
|
|
5
|
+
'ja': '일본어', 'ja-JP': '일본어',
|
|
6
|
+
'zh': '중국어 (간체)', 'zh-CN': '중국어 (간체)', 'zh-TW': '중국어 (번체)',
|
|
7
|
+
'es': '스페인어', 'fr': '프랑스어', 'de': '독일어',
|
|
8
|
+
'pt': '포르투갈어', 'pt-BR': '포르투갈어 (브라질)',
|
|
9
|
+
};
|
|
10
|
+
export function requireApiKey() {
|
|
11
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
12
|
+
if (!key) {
|
|
13
|
+
throw new Error([
|
|
14
|
+
'❌ ANTHROPIC_API_KEY 환경변수가 필요합니다.',
|
|
15
|
+
'',
|
|
16
|
+
'설정 방법:',
|
|
17
|
+
' export ANTHROPIC_API_KEY=sk-ant-...',
|
|
18
|
+
'',
|
|
19
|
+
'또는 Claude Desktop MCP 설정에서:',
|
|
20
|
+
' "env": { "ANTHROPIC_API_KEY": "sk-ant-..." }',
|
|
21
|
+
].join('\n'));
|
|
22
|
+
}
|
|
23
|
+
return new Anthropic({ apiKey: key });
|
|
24
|
+
}
|
|
25
|
+
export function parseJsonResponse(text) {
|
|
26
|
+
const match = text.match(/\{[\s\S]*\}/);
|
|
27
|
+
if (!match)
|
|
28
|
+
throw new Error('AI 응답 파싱 실패. 다시 시도하세요.');
|
|
29
|
+
return JSON.parse(match[0]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface CommitEntry {
|
|
2
|
+
hash?: string;
|
|
3
|
+
message: string;
|
|
4
|
+
author?: string;
|
|
5
|
+
date?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ReleaseNotesTone {
|
|
8
|
+
name: string;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
export interface GeneratedReleaseNotes {
|
|
12
|
+
tones: ReleaseNotesTone[];
|
|
13
|
+
localized: Record<string, string>;
|
|
14
|
+
rawCommitsUsed: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function generateReleaseNotesFromCommits(commits: CommitEntry[], opts?: {
|
|
17
|
+
appName?: string;
|
|
18
|
+
tones?: string[];
|
|
19
|
+
locales?: string[];
|
|
20
|
+
maxTokens?: number;
|
|
21
|
+
}): Promise<GeneratedReleaseNotes>;
|
|
22
|
+
export declare function formatGeneratedNotes(result: GeneratedReleaseNotes): string;
|
package/dist/ai/notes.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { requireApiKey, parseJsonResponse, LOCALE_NAMES } from './client.js';
|
|
2
|
+
const TONE_DESCRIPTIONS = {
|
|
3
|
+
concise: '간결한 버전 (3줄 이내, 불릿 포인트, 사용자 혜택 중심)',
|
|
4
|
+
detailed: '상세 버전 (주요 변경사항 5~8개, 불릿 포인트, 구체적 기능 설명)',
|
|
5
|
+
marketing: '마케팅 버전 (감탄사 포함, 가치 제안 강조, 업데이트를 기대하게 만드는 톤)',
|
|
6
|
+
};
|
|
7
|
+
function commitsToText(commits) {
|
|
8
|
+
return commits
|
|
9
|
+
.map((c) => {
|
|
10
|
+
const meta = [c.author, c.date?.slice(0, 10)].filter(Boolean).join(', ');
|
|
11
|
+
return `- ${c.message}${meta ? ` (${meta})` : ''}`;
|
|
12
|
+
})
|
|
13
|
+
.join('\n');
|
|
14
|
+
}
|
|
15
|
+
export async function generateReleaseNotesFromCommits(commits, opts = {}) {
|
|
16
|
+
const client = requireApiKey();
|
|
17
|
+
const { appName = '앱', tones = ['concise', 'detailed', 'marketing'], locales = [], maxTokens = 2000 } = opts;
|
|
18
|
+
const commitsText = commitsToText(commits.slice(0, 50));
|
|
19
|
+
const localeList = locales.length > 0
|
|
20
|
+
? locales.map((l) => `"${l}": "${LOCALE_NAMES[l] ?? l}로 번역된 간결한 버전"`).join(',\n ')
|
|
21
|
+
: '';
|
|
22
|
+
const prompt = `다음은 "${appName}" 앱의 git 커밋 내역입니다.
|
|
23
|
+
사용자 친화적인 릴리즈 노트를 작성해주세요. 기술 용어 대신 사용자 경험 위주로 서술하세요.
|
|
24
|
+
|
|
25
|
+
커밋 내역:
|
|
26
|
+
${commitsText}
|
|
27
|
+
|
|
28
|
+
다음 JSON 형식으로 응답하세요 (한국어 기본):
|
|
29
|
+
{
|
|
30
|
+
"tones": [
|
|
31
|
+
${tones.map((t) => `{ "name": "${t}", "text": "${TONE_DESCRIPTIONS[t] ?? t}" }`).join(',\n ')}
|
|
32
|
+
]${localeList ? `,\n "localized": {\n ${localeList}\n }` : ''}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
각 tone의 "text" 필드에 실제 릴리즈 노트 내용을 채워주세요.`;
|
|
36
|
+
const response = await client.messages.create({
|
|
37
|
+
model: 'claude-haiku-4-5-20251001',
|
|
38
|
+
max_tokens: maxTokens,
|
|
39
|
+
system: '앱 스토어 릴리즈 노트 전문 카피라이터입니다. 커밋 내역을 사용자 친화적인 언어로 변환합니다. 항상 유효한 JSON으로만 응답하세요.',
|
|
40
|
+
messages: [{ role: 'user', content: prompt }],
|
|
41
|
+
});
|
|
42
|
+
const text = response.content[0].type === 'text' ? response.content[0].text : '';
|
|
43
|
+
const parsed = parseJsonResponse(text);
|
|
44
|
+
return {
|
|
45
|
+
tones: parsed.tones ?? [],
|
|
46
|
+
localized: parsed.localized ?? {},
|
|
47
|
+
rawCommitsUsed: commits.length,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function formatGeneratedNotes(result) {
|
|
51
|
+
const toneLabels = { concise: '간결한 버전', detailed: '상세 버전', marketing: '마케팅 버전' };
|
|
52
|
+
const lines = [`🤖 AI 릴리즈 노트 생성 완료 (커밋 ${result.rawCommitsUsed}개 분석)\n`];
|
|
53
|
+
for (const tone of result.tones) {
|
|
54
|
+
lines.push(`─── ${toneLabels[tone.name] ?? tone.name} ───`);
|
|
55
|
+
lines.push(tone.text);
|
|
56
|
+
lines.push('');
|
|
57
|
+
}
|
|
58
|
+
if (Object.keys(result.localized).length > 0) {
|
|
59
|
+
lines.push('─── 다국어 ───');
|
|
60
|
+
for (const [locale, text] of Object.entries(result.localized)) {
|
|
61
|
+
lines.push(`[${locale}]`);
|
|
62
|
+
lines.push(text);
|
|
63
|
+
lines.push('');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ReviewReplyResult {
|
|
2
|
+
suggested: string;
|
|
3
|
+
tone: string;
|
|
4
|
+
language: string;
|
|
5
|
+
note: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function generateReviewReply(opts: {
|
|
8
|
+
reviewText: string;
|
|
9
|
+
rating?: number;
|
|
10
|
+
appName?: string;
|
|
11
|
+
tone?: string;
|
|
12
|
+
language?: string;
|
|
13
|
+
developerName?: string;
|
|
14
|
+
}): Promise<ReviewReplyResult>;
|
|
15
|
+
export declare function formatReviewReply(result: ReviewReplyResult): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { requireApiKey, parseJsonResponse, LOCALE_NAMES } from './client.js';
|
|
2
|
+
const TONE_GUIDES = {
|
|
3
|
+
friendly: '친근하고 따뜻하게. 이모지 1~2개 사용. 감사 인사로 시작.',
|
|
4
|
+
professional: '정중하고 공식적으로. 문제를 인정하고 해결책을 제시.',
|
|
5
|
+
empathetic: '공감을 먼저 표현. 불편을 충분히 인정한 후 해결 의지를 보여줌.',
|
|
6
|
+
brief: '2~3문장으로 간결하게. 핵심 응답만.',
|
|
7
|
+
};
|
|
8
|
+
const SENTIMENT_PROMPTS = {
|
|
9
|
+
positive: '긍정적인 리뷰입니다. 감사 인사와 함께 앞으로도 좋은 경험을 제공하겠다는 의지를 표현하세요.',
|
|
10
|
+
negative: '부정적인 리뷰입니다. 먼저 불편을 사과하고, 문제 해결 의지를 보여주세요. 가능하면 지원 채널로 유도하세요.',
|
|
11
|
+
neutral: '중립적인 리뷰입니다. 피드백에 감사하고 개선 노력을 약속하세요.',
|
|
12
|
+
bug_report: '버그 리포트입니다. 문제를 확인했음을 알리고, 수정 예정임을 전달하세요.',
|
|
13
|
+
feature_request: '기능 요청입니다. 피드백에 감사하고, 검토하겠다고 약속하세요.',
|
|
14
|
+
};
|
|
15
|
+
function detectSentiment(text) {
|
|
16
|
+
const lower = text.toLowerCase();
|
|
17
|
+
if (['버그', '오류', '안됨', 'crash', 'bug', 'error', 'broken'].some((w) => lower.includes(w)))
|
|
18
|
+
return 'bug_report';
|
|
19
|
+
if (['추가', '원해', '있으면', 'wish', 'feature', 'add', 'would like'].some((w) => lower.includes(w)))
|
|
20
|
+
return 'feature_request';
|
|
21
|
+
if (['별로', '실망', '짜증', 'terrible', 'worst', 'awful'].some((w) => lower.includes(w)))
|
|
22
|
+
return 'negative';
|
|
23
|
+
if (['좋아', '최고', '훌륭', 'great', 'excellent', 'love', 'perfect'].some((w) => lower.includes(w)))
|
|
24
|
+
return 'positive';
|
|
25
|
+
return 'neutral';
|
|
26
|
+
}
|
|
27
|
+
export async function generateReviewReply(opts) {
|
|
28
|
+
const client = requireApiKey();
|
|
29
|
+
const { reviewText, rating, appName = '앱', tone = 'friendly', language = 'ko', developerName } = opts;
|
|
30
|
+
const sentiment = detectSentiment(reviewText);
|
|
31
|
+
const langName = LOCALE_NAMES[language] ?? language;
|
|
32
|
+
const response = await client.messages.create({
|
|
33
|
+
model: 'claude-haiku-4-5-20251001',
|
|
34
|
+
max_tokens: 500,
|
|
35
|
+
system: `앱 개발자를 대신해 스토어 리뷰에 답변하는 전문가입니다. ${langName}로 150자 이내로 답변하세요. ${TONE_GUIDES[tone] ?? TONE_GUIDES.friendly} 개발자 이름: ${developerName ?? '개발팀'}`,
|
|
36
|
+
messages: [{
|
|
37
|
+
role: 'user',
|
|
38
|
+
content: `앱: ${appName}\n${rating !== undefined ? `별점: ${'★'.repeat(rating)}${'☆'.repeat(5 - rating)} (${rating}/5)\n` : ''}리뷰: "${reviewText}"\n\n지침: ${SENTIMENT_PROMPTS[sentiment] ?? SENTIMENT_PROMPTS.neutral}\n\nJSON으로만 응답: { "reply": "답변 텍스트" }`,
|
|
39
|
+
}],
|
|
40
|
+
});
|
|
41
|
+
const text = response.content[0].type === 'text' ? response.content[0].text : '';
|
|
42
|
+
const parsed = parseJsonResponse(text);
|
|
43
|
+
return {
|
|
44
|
+
suggested: parsed.reply,
|
|
45
|
+
tone,
|
|
46
|
+
language,
|
|
47
|
+
note: '이 답변은 AI가 생성한 초안입니다. 게시 전 반드시 검토하세요.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function formatReviewReply(result) {
|
|
51
|
+
return [
|
|
52
|
+
'💬 AI 리뷰 답변 초안\n',
|
|
53
|
+
`─── 제안 답변 (${result.tone} / ${result.language}) ───`,
|
|
54
|
+
result.suggested,
|
|
55
|
+
'',
|
|
56
|
+
`⚠ ${result.note}`,
|
|
57
|
+
'',
|
|
58
|
+
'이 답변이 마음에 들면 playstore_reply_to_review 도구로 게시하세요.',
|
|
59
|
+
].join('\n');
|
|
60
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function listScreenshotSets(localizationId: string): Promise<any>;
|
|
2
|
+
export declare function uploadScreenshot(localizationId: string, displayType: string, filePath: string): Promise<{
|
|
3
|
+
id: string;
|
|
4
|
+
fileName: string;
|
|
5
|
+
fileSize: number;
|
|
6
|
+
displayType: string;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function deleteScreenshot(screenshotId: string): Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
id: string;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function deleteScreenshotSet(setId: string): Promise<{
|
|
13
|
+
ok: boolean;
|
|
14
|
+
id: string;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { getAuthHeaders } from './auth.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
/**
|
|
6
|
+
* App Store Connect API — Screenshot upload
|
|
7
|
+
*
|
|
8
|
+
* 4-step process per Apple docs:
|
|
9
|
+
* 1. ensureScreenshotSet(localizationId, displayType)
|
|
10
|
+
* → POST /appScreenshotSets (없으면)
|
|
11
|
+
* 2. reserve — POST /appScreenshots
|
|
12
|
+
* → response contains uploadOperations[]
|
|
13
|
+
* 3. upload — PUT each operation URL (slice of bytes by offset/length)
|
|
14
|
+
* 4. commit — PATCH /appScreenshots/{id} { uploaded: true, sourceFileChecksum }
|
|
15
|
+
*/
|
|
16
|
+
const BASE = 'https://api.appstoreconnect.apple.com/v1';
|
|
17
|
+
function authHeadersOrThrow() {
|
|
18
|
+
const headers = getAuthHeaders();
|
|
19
|
+
if (!headers) {
|
|
20
|
+
throw new Error([
|
|
21
|
+
'❌ App Store Connect 인증이 필요해.',
|
|
22
|
+
'',
|
|
23
|
+
'터미널에서 실행:',
|
|
24
|
+
' cd c:/users/turbo08/app-gen/packages/mcp-server',
|
|
25
|
+
' npx tsx src/appstore/setup-cli.ts',
|
|
26
|
+
].join('\n'));
|
|
27
|
+
}
|
|
28
|
+
return headers;
|
|
29
|
+
}
|
|
30
|
+
async function req(pathOrUrl, init = {}) {
|
|
31
|
+
const headers = authHeadersOrThrow();
|
|
32
|
+
const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${BASE}${pathOrUrl}`;
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
...init,
|
|
35
|
+
headers: { ...headers, ...(init.headers ?? {}) },
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const body = await res.text();
|
|
39
|
+
throw new Error(`App Store API ${res.status} ${init.method ?? 'GET'} ${pathOrUrl}: ${body}`);
|
|
40
|
+
}
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
return (text ? JSON.parse(text) : null);
|
|
43
|
+
}
|
|
44
|
+
// ─── 조회 ───
|
|
45
|
+
export async function listScreenshotSets(localizationId) {
|
|
46
|
+
const data = await req(`/appStoreVersionLocalizations/${localizationId}/appScreenshotSets` +
|
|
47
|
+
`?include=appScreenshots` +
|
|
48
|
+
`&fields[appScreenshotSets]=screenshotDisplayType,appScreenshots` +
|
|
49
|
+
`&fields[appScreenshots]=fileName,fileSize,assetDeliveryState,imageAsset`);
|
|
50
|
+
const included = data?.included ?? [];
|
|
51
|
+
return (data?.data ?? []).map((s) => ({
|
|
52
|
+
id: s.id,
|
|
53
|
+
displayType: s.attributes?.screenshotDisplayType,
|
|
54
|
+
screenshots: (s.relationships?.appScreenshots?.data ?? []).map((ref) => {
|
|
55
|
+
const inc = included.find((i) => i.type === 'appScreenshots' && i.id === ref.id);
|
|
56
|
+
return {
|
|
57
|
+
id: ref.id,
|
|
58
|
+
fileName: inc?.attributes?.fileName,
|
|
59
|
+
fileSize: inc?.attributes?.fileSize,
|
|
60
|
+
state: inc?.attributes?.assetDeliveryState?.state,
|
|
61
|
+
imageAsset: inc?.attributes?.imageAsset,
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
// ─── 셋 확보 (존재하면 재사용, 없으면 생성) ───
|
|
67
|
+
async function ensureScreenshotSet(localizationId, displayType) {
|
|
68
|
+
const existing = await listScreenshotSets(localizationId);
|
|
69
|
+
const match = existing.find((s) => s.displayType === displayType);
|
|
70
|
+
if (match)
|
|
71
|
+
return match.id;
|
|
72
|
+
const body = {
|
|
73
|
+
data: {
|
|
74
|
+
type: 'appScreenshotSets',
|
|
75
|
+
attributes: { screenshotDisplayType: displayType },
|
|
76
|
+
relationships: {
|
|
77
|
+
appStoreVersionLocalization: {
|
|
78
|
+
data: { type: 'appStoreVersionLocalizations', id: localizationId },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const created = await req('/appScreenshotSets', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
return created.data.id;
|
|
89
|
+
}
|
|
90
|
+
// ─── 청크 업로드 ───
|
|
91
|
+
async function uploadChunks(filePath, ops) {
|
|
92
|
+
const buf = fs.readFileSync(filePath);
|
|
93
|
+
for (const op of ops) {
|
|
94
|
+
const slice = buf.subarray(op.offset, op.offset + op.length);
|
|
95
|
+
const headers = {};
|
|
96
|
+
for (const h of op.requestHeaders)
|
|
97
|
+
headers[h.name] = h.value;
|
|
98
|
+
const res = await fetch(op.url, {
|
|
99
|
+
method: op.method,
|
|
100
|
+
headers,
|
|
101
|
+
body: slice,
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
throw new Error(`청크 업로드 실패 (offset=${op.offset}, length=${op.length}): ${res.status} ${text}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// ─── 메인: 업로드 ───
|
|
110
|
+
export async function uploadScreenshot(localizationId, displayType, filePath) {
|
|
111
|
+
if (!fs.existsSync(filePath)) {
|
|
112
|
+
throw new Error(`파일이 존재하지 않아: ${filePath}`);
|
|
113
|
+
}
|
|
114
|
+
const absPath = path.resolve(filePath);
|
|
115
|
+
const buf = fs.readFileSync(absPath);
|
|
116
|
+
const fileName = path.basename(absPath);
|
|
117
|
+
const fileSize = buf.length;
|
|
118
|
+
const md5 = crypto.createHash('md5').update(buf).digest('hex');
|
|
119
|
+
const screenshotSetId = await ensureScreenshotSet(localizationId, displayType);
|
|
120
|
+
// reserve
|
|
121
|
+
const reserved = await req('/appScreenshots', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
data: {
|
|
126
|
+
type: 'appScreenshots',
|
|
127
|
+
attributes: { fileName, fileSize },
|
|
128
|
+
relationships: {
|
|
129
|
+
appScreenshotSet: { data: { type: 'appScreenshotSets', id: screenshotSetId } },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
const screenshotId = reserved.data.id;
|
|
135
|
+
const ops = reserved.data.attributes?.uploadOperations ?? [];
|
|
136
|
+
if (ops.length === 0) {
|
|
137
|
+
throw new Error('uploadOperations가 비어있음 — Apple API 응답 형식 확인 필요.');
|
|
138
|
+
}
|
|
139
|
+
// upload
|
|
140
|
+
await uploadChunks(absPath, ops);
|
|
141
|
+
// commit
|
|
142
|
+
await req(`/appScreenshots/${screenshotId}`, {
|
|
143
|
+
method: 'PATCH',
|
|
144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
data: {
|
|
147
|
+
type: 'appScreenshots',
|
|
148
|
+
id: screenshotId,
|
|
149
|
+
attributes: { uploaded: true, sourceFileChecksum: md5 },
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
return { id: screenshotId, fileName, fileSize, displayType };
|
|
154
|
+
}
|
|
155
|
+
// ─── 삭제 ───
|
|
156
|
+
export async function deleteScreenshot(screenshotId) {
|
|
157
|
+
await req(`/appScreenshots/${screenshotId}`, { method: 'DELETE' });
|
|
158
|
+
return { ok: true, id: screenshotId };
|
|
159
|
+
}
|
|
160
|
+
export async function deleteScreenshotSet(setId) {
|
|
161
|
+
await req(`/appScreenshotSets/${setId}`, { method: 'DELETE' });
|
|
162
|
+
return { ok: true, id: setId };
|
|
163
|
+
}
|
package/dist/appstore/tools.d.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
export declare function apiGet(path: string, params?: Record<string, string>): Promise<any>;
|
|
1
2
|
export declare function listApps(): Promise<any>;
|
|
2
3
|
export declare function getApp(appId: string): Promise<any>;
|
|
3
4
|
export declare function listVersions(appId: string): Promise<any>;
|
|
4
5
|
export declare function getVersionLocalizations(versionId: string): Promise<any>;
|
|
6
|
+
export interface LocalizationUpdateFields {
|
|
7
|
+
whatsNew?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
keywords?: string;
|
|
10
|
+
promotionalText?: string;
|
|
11
|
+
supportUrl?: string;
|
|
12
|
+
marketingUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function updateVersionLocalization(localizationId: string, fields: LocalizationUpdateFields): Promise<any>;
|
|
15
|
+
/**
|
|
16
|
+
* versionId + locale로 로컬라이제이션을 찾아서 PATCH.
|
|
17
|
+
* localizationId를 직접 모를 때 편의용.
|
|
18
|
+
*/
|
|
19
|
+
export declare function updateVersionWhatsNew(versionId: string, locale: string, fields: LocalizationUpdateFields): Promise<any>;
|
|
5
20
|
export declare function listBuilds(appId: string): Promise<any>;
|
|
6
21
|
export declare function listBetaGroups(appId: string): Promise<any>;
|
|
7
22
|
export declare function getAppInfo(appId: string): Promise<any>;
|
package/dist/appstore/tools.js
CHANGED
|
@@ -4,7 +4,7 @@ import { getAuthHeaders } from './auth.js';
|
|
|
4
4
|
* https://developer.apple.com/documentation/appstoreconnectapi
|
|
5
5
|
*/
|
|
6
6
|
const BASE = 'https://api.appstoreconnect.apple.com/v1';
|
|
7
|
-
async function apiGet(path, params) {
|
|
7
|
+
export async function apiGet(path, params) {
|
|
8
8
|
const headers = getAuthHeaders();
|
|
9
9
|
if (!headers)
|
|
10
10
|
throw new Error([
|
|
@@ -29,6 +29,29 @@ async function apiGet(path, params) {
|
|
|
29
29
|
}
|
|
30
30
|
return res.json();
|
|
31
31
|
}
|
|
32
|
+
async function apiPatch(path, body) {
|
|
33
|
+
const headers = getAuthHeaders();
|
|
34
|
+
if (!headers)
|
|
35
|
+
throw new Error([
|
|
36
|
+
'❌ App Store Connect 인증이 필요해.',
|
|
37
|
+
'',
|
|
38
|
+
'터미널에서 실행:',
|
|
39
|
+
' cd c:/users/turbo08/app-gen/packages/mcp-server',
|
|
40
|
+
' npx tsx src/appstore/setup-cli.ts',
|
|
41
|
+
].join('\n'));
|
|
42
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
43
|
+
method: 'PATCH',
|
|
44
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
throw new Error(`App Store API ${res.status}: ${text}`);
|
|
50
|
+
}
|
|
51
|
+
// 204 No Content 가능
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
return text ? JSON.parse(text) : { ok: true };
|
|
54
|
+
}
|
|
32
55
|
// ─── 앱 ───
|
|
33
56
|
export async function listApps() {
|
|
34
57
|
const data = await apiGet('/apps', {
|
|
@@ -78,6 +101,30 @@ export async function getVersionLocalizations(versionId) {
|
|
|
78
101
|
whatsNew: l.attributes?.whatsNew,
|
|
79
102
|
}));
|
|
80
103
|
}
|
|
104
|
+
export async function updateVersionLocalization(localizationId, fields) {
|
|
105
|
+
const body = {
|
|
106
|
+
data: {
|
|
107
|
+
type: 'appStoreVersionLocalizations',
|
|
108
|
+
id: localizationId,
|
|
109
|
+
attributes: fields,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const res = await apiPatch(`/appStoreVersionLocalizations/${localizationId}`, body);
|
|
113
|
+
return res.data ?? res;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* versionId + locale로 로컬라이제이션을 찾아서 PATCH.
|
|
117
|
+
* localizationId를 직접 모를 때 편의용.
|
|
118
|
+
*/
|
|
119
|
+
export async function updateVersionWhatsNew(versionId, locale, fields) {
|
|
120
|
+
const localizations = await getVersionLocalizations(versionId);
|
|
121
|
+
const target = localizations.find((l) => l.locale === locale);
|
|
122
|
+
if (!target) {
|
|
123
|
+
const available = localizations.map((l) => l.locale).join(', ') || '(없음)';
|
|
124
|
+
throw new Error(`로캘 "${locale}"을 버전 ${versionId}에서 찾을 수 없어. 가능한 로캘: ${available}`);
|
|
125
|
+
}
|
|
126
|
+
return updateVersionLocalization(target.id, fields);
|
|
127
|
+
}
|
|
81
128
|
// ─── 빌드 ───
|
|
82
129
|
export async function listBuilds(appId) {
|
|
83
130
|
const data = await apiGet(`/builds`, {
|
|
@@ -17,6 +17,20 @@ export declare function createOAuth2Client(clientId: string, clientSecret: strin
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function getAuthenticatedClient(): ReturnType<typeof createOAuth2Client> | null;
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* OAuth 플로우 시작.
|
|
21
|
+
* URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
|
|
22
|
+
* 호출자가 URL을 사용자에게 전달하거나 `open()`을 직접 호출.
|
|
23
|
+
* `wait` Promise: 토큰 저장 시 resolve, 타임아웃/에러 시 reject.
|
|
24
|
+
* 재호출 시 기존 세션 자동 정리.
|
|
25
|
+
*/
|
|
26
|
+
export declare function startAuth(clientId: string, clientSecret: string, options?: {
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}): {
|
|
29
|
+
url: string;
|
|
30
|
+
wait: Promise<StoredTokens>;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Interactive login — opens browser, waits for callback.
|
|
34
|
+
* startAuth() 래퍼 — CLI에서 사용.
|
|
21
35
|
*/
|
|
22
36
|
export declare function login(clientId: string, clientSecret: string): Promise<StoredTokens>;
|