@yoonion/mimi-seed-mcp 0.1.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.
@@ -92,10 +92,23 @@ export function getAuthenticatedClient() {
92
92
  });
93
93
  return client;
94
94
  }
95
+ // 동시 실행 방지용 — 활성 콜백 서버 참조
96
+ let activeAuthServer = null;
95
97
  /**
96
- * Interactive login — opens browser, waits for callback
98
+ * OAuth 플로우 시작.
99
+ * URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
100
+ * 호출자가 URL을 사용자에게 전달하거나 `open()`을 직접 호출.
101
+ * `wait` Promise: 토큰 저장 시 resolve, 타임아웃/에러 시 reject.
102
+ * 재호출 시 기존 세션 자동 정리.
97
103
  */
98
- export async function login(clientId, clientSecret) {
104
+ export function startAuth(clientId, clientSecret, options = {}) {
105
+ if (activeAuthServer) {
106
+ try {
107
+ activeAuthServer.close();
108
+ }
109
+ catch { /* noop */ }
110
+ activeAuthServer = null;
111
+ }
99
112
  saveCredentials(clientId, clientSecret);
100
113
  const oauth2Client = createOAuth2Client(clientId, clientSecret);
101
114
  const authUrl = oauth2Client.generateAuthUrl({
@@ -103,7 +116,7 @@ export async function login(clientId, clientSecret) {
103
116
  scope: SCOPES,
104
117
  prompt: 'consent',
105
118
  });
106
- return new Promise((resolve, reject) => {
119
+ const wait = new Promise((resolve, reject) => {
107
120
  const server = http.createServer(async (req, res) => {
108
121
  try {
109
122
  const url = new URL(req.url, `http://localhost:9876`);
@@ -139,21 +152,47 @@ export async function login(clientId, clientSecret) {
139
152
  </body></html>
140
153
  `);
141
154
  server.close();
155
+ activeAuthServer = null;
142
156
  resolve(stored);
143
157
  }
144
158
  catch (err) {
145
159
  res.writeHead(500);
146
160
  res.end('Auth error');
147
- server.close();
161
+ try {
162
+ server.close();
163
+ }
164
+ catch { /* noop */ }
165
+ activeAuthServer = null;
148
166
  reject(err);
149
167
  }
150
168
  });
151
169
  server.on('error', (err) => {
152
- reject(new Error(`포트 9876이 이미 사용 중입니다. 다른 mimi-seed auth 프로세스를 종료하세요.`));
170
+ reject(new Error(`포트 9876이 이미 사용 중이거나 에러: ${err.message}`));
153
171
  });
154
172
  server.listen(9876, () => {
155
- console.log('🔐 브라우저에서 Google 로그인 중...');
156
- open(authUrl);
173
+ activeAuthServer = server;
157
174
  });
175
+ const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
176
+ setTimeout(() => {
177
+ if (server.listening) {
178
+ try {
179
+ server.close();
180
+ }
181
+ catch { /* noop */ }
182
+ activeAuthServer = null;
183
+ reject(new Error(`Auth timeout (${Math.round(timeoutMs / 1000)}s).`));
184
+ }
185
+ }, timeoutMs);
158
186
  });
187
+ return { url: authUrl, wait };
188
+ }
189
+ /**
190
+ * Interactive login — opens browser, waits for callback.
191
+ * startAuth() 래퍼 — CLI에서 사용.
192
+ */
193
+ export async function login(clientId, clientSecret) {
194
+ const { url, wait } = startAuth(clientId, clientSecret);
195
+ console.log('🔐 브라우저에서 Google 로그인 중...');
196
+ open(url);
197
+ return wait;
159
198
  }
@@ -0,0 +1,12 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ export type RiskLevel = 'blocker' | 'warning' | 'info';
3
+ export interface SubmissionRisk {
4
+ level: RiskLevel;
5
+ code: string;
6
+ title: string;
7
+ detail: string;
8
+ fixUrl?: string;
9
+ }
10
+ export declare function checkPlayStoreRisks(auth: OAuth2Client, packageName: string, language?: string): Promise<SubmissionRisk[]>;
11
+ export declare function checkAppStoreRisks(appId: string): Promise<SubmissionRisk[]>;
12
+ export declare function formatRisks(risks: SubmissionRisk[], platform: string): string;
@@ -0,0 +1,123 @@
1
+ import { publisher, withEdit } from '../playstore/tools.js';
2
+ import { apiGet } from '../appstore/tools.js';
3
+ const APPSTORE_EDITABLE_STATES = 'PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW';
4
+ export async function checkPlayStoreRisks(auth, packageName, language = 'ko-KR') {
5
+ const risks = [];
6
+ await withEdit(auth, packageName, async (editId) => {
7
+ const [listing, phoneScreenshots, icons, details, tracks] = await Promise.all([
8
+ publisher().edits.listings.get({ auth, packageName, editId, language }).catch(() => null),
9
+ publisher().edits.images.list({ auth, packageName, editId, language, imageType: 'phoneScreenshots' }).catch(() => null),
10
+ publisher().edits.images.list({ auth, packageName, editId, language, imageType: 'icon' }).catch(() => null),
11
+ publisher().edits.details.get({ auth, packageName, editId }).catch(() => null),
12
+ publisher().edits.tracks.list({ auth, packageName, editId }).catch(() => null),
13
+ ]);
14
+ if (!listing?.data) {
15
+ risks.push({ level: 'blocker', code: 'NO_LISTING', title: `${language} 리스팅 없음`, detail: 'Play Console에서 스토어 정보를 입력하세요.', fixUrl: 'https://play.google.com/console/developers' });
16
+ }
17
+ else {
18
+ const { title, shortDescription, fullDescription } = listing.data;
19
+ if (!title || title.length < 2) {
20
+ risks.push({ level: 'blocker', code: 'NO_TITLE', title: '앱 이름 없음', detail: '제목은 필수 항목입니다.' });
21
+ }
22
+ else if (title.length > 50) {
23
+ risks.push({ level: 'warning', code: 'TITLE_TOO_LONG', title: '앱 이름 50자 초과', detail: `현재 ${title.length}자. Google 가이드라인: 50자 이내.` });
24
+ }
25
+ if (!shortDescription || shortDescription.length < 10) {
26
+ risks.push({ level: 'warning', code: 'NO_SHORT_DESC', title: '짧은 설명 없음', detail: '짧은 설명은 검색 노출에 영향을 줍니다.' });
27
+ }
28
+ if (!fullDescription || fullDescription.length < 50) {
29
+ risks.push({ level: 'blocker', code: 'NO_FULL_DESC', title: '전체 설명 없음', detail: '전체 설명은 필수 항목입니다.' });
30
+ }
31
+ else if (fullDescription.length > 4000) {
32
+ risks.push({ level: 'warning', code: 'DESC_TOO_LONG', title: '설명 4000자 초과', detail: `현재 ${fullDescription.length}자. 4000자 이내로 줄이세요.` });
33
+ }
34
+ }
35
+ const screenCount = phoneScreenshots?.data?.images?.length ?? 0;
36
+ if (screenCount === 0) {
37
+ risks.push({ level: 'blocker', code: 'NO_SCREENSHOTS', title: '전화 스크린샷 없음', detail: '최소 2장 이상의 스크린샷이 필요합니다.' });
38
+ }
39
+ else if (screenCount < 2) {
40
+ risks.push({ level: 'warning', code: 'FEW_SCREENSHOTS', title: `스크린샷 ${screenCount}장 (권장: 4~8장)`, detail: '더 많은 스크린샷이 전환율을 높입니다.' });
41
+ }
42
+ if (!icons?.data?.images?.length) {
43
+ risks.push({ level: 'blocker', code: 'NO_ICON', title: '앱 아이콘 없음', detail: '512×512px PNG 아이콘이 필요합니다.' });
44
+ }
45
+ if (!details?.data?.contactEmail) {
46
+ risks.push({ level: 'warning', code: 'NO_CONTACT_EMAIL', title: '개발자 이메일 없음', detail: 'Play Console에서 개발자 연락처를 설정하세요.' });
47
+ }
48
+ const hasBuild = (tracks?.data?.tracks ?? []).some((t) => (t.releases ?? []).some((r) => r.versionCodes && r.versionCodes.length > 0));
49
+ if (!hasBuild) {
50
+ risks.push({ level: 'blocker', code: 'NO_BUILD', title: '업로드된 빌드 없음', detail: '내부 테스트 트랙에 APK/AAB를 먼저 업로드하세요.' });
51
+ }
52
+ return null;
53
+ });
54
+ return risks;
55
+ }
56
+ export async function checkAppStoreRisks(appId) {
57
+ const risks = [];
58
+ const versions = await apiGet(`/apps/${appId}/appStoreVersions`, {
59
+ 'filter[appStoreState]': APPSTORE_EDITABLE_STATES,
60
+ 'limit': '5',
61
+ }).catch(() => null);
62
+ if (!versions?.data?.length) {
63
+ risks.push({ level: 'warning', code: 'NO_EDITABLE_VERSION', title: '편집 가능한 버전 없음', detail: 'App Store Connect에서 새 버전을 만드세요.', fixUrl: 'https://appstoreconnect.apple.com' });
64
+ return risks;
65
+ }
66
+ const versionId = versions.data[0].id;
67
+ const [locs, builds, appInfo] = await Promise.all([
68
+ apiGet(`/appStoreVersions/${versionId}/appStoreVersionLocalizations`).catch(() => null),
69
+ apiGet(`/apps/${appId}/builds`, { 'limit': '5', 'sort': '-uploadedDate' }).catch(() => null),
70
+ apiGet(`/apps/${appId}`, { 'fields[apps]': 'privacyPolicyUrl' }).catch(() => null),
71
+ ]);
72
+ if (!locs?.data?.length) {
73
+ risks.push({ level: 'blocker', code: 'NO_LOCALIZATIONS', title: '로컬라이제이션 없음', detail: '메타데이터를 입력하세요.' });
74
+ }
75
+ else {
76
+ for (const loc of locs.data) {
77
+ const { locale, description, whatsNew, keywords } = loc.attributes ?? {};
78
+ if (!description)
79
+ risks.push({ level: 'blocker', code: `NO_DESC_${locale}`, title: `${locale} 설명 없음`, detail: '앱 설명은 필수 항목입니다.' });
80
+ if (!whatsNew)
81
+ risks.push({ level: 'warning', code: `NO_WHATS_NEW_${locale}`, title: `${locale} 새로운 기능 없음`, detail: '릴리즈 노트를 입력하면 다운로드 전환율이 높아집니다.' });
82
+ if (!keywords)
83
+ risks.push({ level: 'warning', code: `NO_KEYWORDS_${locale}`, title: `${locale} 키워드 없음`, detail: '키워드는 검색 노출에 직접 영향을 줍니다.' });
84
+ }
85
+ // screenshots depend on locs being present
86
+ const locId = locs.data[0].id;
87
+ const screenshots = await apiGet(`/appStoreVersionLocalizations/${locId}/appScreenshotSets`).catch(() => null);
88
+ if (!screenshots?.data?.length) {
89
+ risks.push({ level: 'blocker', code: 'NO_SCREENSHOTS', title: '스크린샷 없음', detail: 'iPhone 6.5" 또는 6.9" 스크린샷이 필요합니다.' });
90
+ }
91
+ }
92
+ if (!builds?.data?.length) {
93
+ risks.push({ level: 'blocker', code: 'NO_BUILD', title: 'TestFlight 빌드 없음', detail: 'Xcode 또는 Fastlane으로 빌드를 업로드하세요.' });
94
+ }
95
+ if (!appInfo?.data?.attributes?.privacyPolicyUrl) {
96
+ risks.push({ level: 'blocker', code: 'NO_PRIVACY', title: '개인정보처리방침 URL 없음', detail: 'App Store 가이드라인 5.1.1 — 필수 항목입니다.' });
97
+ }
98
+ return risks;
99
+ }
100
+ export function formatRisks(risks, platform) {
101
+ if (risks.length === 0)
102
+ return `✅ ${platform} — 제출 위험 요소 없음`;
103
+ const blockers = risks.filter((r) => r.level === 'blocker');
104
+ const warnings = risks.filter((r) => r.level === 'warning');
105
+ const lines = [`📊 ${platform} 제출 위험 분석 (${risks.length}건)\n`];
106
+ if (blockers.length) {
107
+ lines.push(`🚫 블로커 ${blockers.length}건 — 반드시 수정해야 제출 가능:`);
108
+ for (const r of blockers) {
109
+ lines.push(` • [${r.code}] ${r.title}\n ${r.detail}${r.fixUrl ? `\n → ${r.fixUrl}` : ''}`);
110
+ }
111
+ lines.push('');
112
+ }
113
+ if (warnings.length) {
114
+ lines.push(`⚠ 경고 ${warnings.length}건 — 강력 권장:`);
115
+ for (const r of warnings) {
116
+ lines.push(` • [${r.code}] ${r.title}\n ${r.detail}`);
117
+ }
118
+ lines.push('');
119
+ }
120
+ if (blockers.length === 0)
121
+ lines.push('✅ 블로커 없음 — 경고 항목 검토 후 제출 가능');
122
+ return lines.join('\n');
123
+ }
@@ -0,0 +1,68 @@
1
+ export interface ScreenshotSpec {
2
+ label: string;
3
+ width: number;
4
+ height: number;
5
+ minWidth?: number;
6
+ maxWidth?: number;
7
+ minHeight?: number;
8
+ maxHeight?: number;
9
+ maxBytes: number;
10
+ }
11
+ export interface ValidationResult {
12
+ filePath: string;
13
+ fileName: string;
14
+ valid: boolean;
15
+ issues: string[];
16
+ info: {
17
+ sizeBytes: number;
18
+ detectedWidth?: number;
19
+ detectedHeight?: number;
20
+ };
21
+ matchedSpec?: string;
22
+ }
23
+ export declare const APPSTORE_SPECS: Record<string, ScreenshotSpec>;
24
+ export declare const PLAYSTORE_SPECS: {
25
+ phoneScreenshots: {
26
+ label: string;
27
+ minWidth: number;
28
+ maxWidth: number;
29
+ minHeight: number;
30
+ maxHeight: number;
31
+ minAspect: number;
32
+ maxAspect: number;
33
+ maxBytes: number;
34
+ };
35
+ sevenInchScreenshots: {
36
+ label: string;
37
+ minWidth: number;
38
+ maxWidth: number;
39
+ minHeight: number;
40
+ maxHeight: number;
41
+ minAspect: number;
42
+ maxAspect: number;
43
+ maxBytes: number;
44
+ };
45
+ tenInchScreenshots: {
46
+ label: string;
47
+ minWidth: number;
48
+ maxWidth: number;
49
+ minHeight: number;
50
+ maxHeight: number;
51
+ minAspect: number;
52
+ maxAspect: number;
53
+ maxBytes: number;
54
+ };
55
+ featureGraphic: {
56
+ label: string;
57
+ minWidth: number;
58
+ maxWidth: number;
59
+ minHeight: number;
60
+ maxHeight: number;
61
+ minAspect: number;
62
+ maxAspect: number;
63
+ maxBytes: number;
64
+ };
65
+ };
66
+ export declare function validateAppStoreScreenshots(filePaths: string[], expectedDisplayType?: string): ValidationResult[];
67
+ export declare function validatePlayStoreScreenshots(filePaths: string[], imageType?: string): ValidationResult[];
68
+ export declare function formatValidationResults(results: ValidationResult[], platform: string): string;
@@ -0,0 +1,168 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export const APPSTORE_SPECS = {
4
+ 'APP_IPHONE_69': { label: 'iPhone 6.9"', width: 1320, height: 2868, maxBytes: 50 * 1024 * 1024 },
5
+ 'APP_IPHONE_67': { label: 'iPhone 6.7"', width: 1290, height: 2796, maxBytes: 50 * 1024 * 1024 },
6
+ 'APP_IPHONE_65': { label: 'iPhone 6.5"', width: 1242, height: 2688, maxBytes: 50 * 1024 * 1024 },
7
+ 'APP_IPHONE_61': { label: 'iPhone 6.1"', width: 1170, height: 2532, maxBytes: 50 * 1024 * 1024 },
8
+ 'APP_IPHONE_58': { label: 'iPhone 5.8"', width: 1125, height: 2436, maxBytes: 50 * 1024 * 1024 },
9
+ 'APP_IPHONE_55': { label: 'iPhone 5.5"', width: 1242, height: 2208, maxBytes: 50 * 1024 * 1024 },
10
+ 'APP_IPAD_PRO_3GEN_129': { label: 'iPad Pro 12.9" (3세대+)', width: 2064, height: 2752, maxBytes: 50 * 1024 * 1024 },
11
+ 'APP_IPAD_PRO_3GEN_11': { label: 'iPad Pro 11"', width: 1668, height: 2388, maxBytes: 50 * 1024 * 1024 },
12
+ 'APP_IPAD_PRO_129': { label: 'iPad Pro 12.9" (2세대)', width: 2048, height: 2732, maxBytes: 50 * 1024 * 1024 },
13
+ 'APP_DESKTOP': { label: 'Mac', minWidth: 1280, maxWidth: 2560, minHeight: 800, maxHeight: 1600, width: 1440, height: 900, maxBytes: 50 * 1024 * 1024 },
14
+ };
15
+ export const PLAYSTORE_SPECS = {
16
+ phoneScreenshots: { label: '전화 스크린샷', minWidth: 320, maxWidth: 3840, minHeight: 320, maxHeight: 3840, minAspect: 0.5, maxAspect: 2.0, maxBytes: 8 * 1024 * 1024 },
17
+ sevenInchScreenshots: { label: '7인치 태블릿 스크린샷', minWidth: 320, maxWidth: 3840, minHeight: 320, maxHeight: 3840, minAspect: 0.5, maxAspect: 2.0, maxBytes: 8 * 1024 * 1024 },
18
+ tenInchScreenshots: { label: '10인치 태블릿 스크린샷', minWidth: 1080, maxWidth: 7680, minHeight: 1080, maxHeight: 7680, minAspect: 0.5, maxAspect: 2.0, maxBytes: 8 * 1024 * 1024 },
19
+ featureGraphic: { label: '특성 그래픽', minWidth: 1024, maxWidth: 1024, minHeight: 500, maxHeight: 500, minAspect: 2.048, maxAspect: 2.048, maxBytes: 1024 * 1024 },
20
+ };
21
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47];
22
+ function readPngDimensions(filePath) {
23
+ try {
24
+ const fd = fs.openSync(filePath, 'r');
25
+ const buf = Buffer.alloc(24);
26
+ fs.readSync(fd, buf, 0, 24, 0);
27
+ fs.closeSync(fd);
28
+ if (PNG_SIGNATURE.some((b, i) => buf[i] !== b))
29
+ return null;
30
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function readJpegDimensions(filePath) {
37
+ try {
38
+ // JPEG SOF markers typically appear within the first 64KB
39
+ const fd = fs.openSync(filePath, 'r');
40
+ const chunk = Buffer.alloc(65536);
41
+ const bytesRead = fs.readSync(fd, chunk, 0, chunk.length, 0);
42
+ fs.closeSync(fd);
43
+ const data = chunk.subarray(0, bytesRead);
44
+ if (data[0] !== 0xFF || data[1] !== 0xD8)
45
+ return null;
46
+ let i = 2;
47
+ while (i < data.length - 8) {
48
+ if (data[i] !== 0xFF)
49
+ break;
50
+ const marker = data[i + 1];
51
+ const length = data.readUInt16BE(i + 2);
52
+ if (marker === 0xC0 || marker === 0xC2) {
53
+ return { height: data.readUInt16BE(i + 5), width: data.readUInt16BE(i + 7) };
54
+ }
55
+ i += 2 + length;
56
+ }
57
+ return null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ function getImageDimensions(filePath) {
64
+ const ext = path.extname(filePath).toLowerCase();
65
+ if (ext === '.png')
66
+ return readPngDimensions(filePath);
67
+ if (ext === '.jpg' || ext === '.jpeg')
68
+ return readJpegDimensions(filePath);
69
+ return null;
70
+ }
71
+ export function validateAppStoreScreenshots(filePaths, expectedDisplayType) {
72
+ return filePaths.map((filePath) => {
73
+ const fileName = path.basename(filePath);
74
+ const issues = [];
75
+ if (!fs.existsSync(filePath)) {
76
+ return { filePath, fileName, valid: false, issues: ['파일 없음'], info: { sizeBytes: 0 } };
77
+ }
78
+ const sizeBytes = fs.statSync(filePath).size;
79
+ const dims = getImageDimensions(filePath);
80
+ const info = { sizeBytes, detectedWidth: dims?.width, detectedHeight: dims?.height };
81
+ if (!dims) {
82
+ return { filePath, fileName, valid: false, issues: ['PNG/JPEG가 아니거나 읽기 실패'], info };
83
+ }
84
+ let matchedSpec;
85
+ if (expectedDisplayType && APPSTORE_SPECS[expectedDisplayType]) {
86
+ const spec = APPSTORE_SPECS[expectedDisplayType];
87
+ if (sizeBytes > spec.maxBytes)
88
+ issues.push(`파일 크기 초과 (최대 ${spec.maxBytes / 1024 / 1024 | 0}MB)`);
89
+ const wOk = spec.minWidth ? dims.width >= spec.minWidth && dims.width <= (spec.maxWidth ?? Infinity) : dims.width === spec.width;
90
+ const hOk = spec.minHeight ? dims.height >= spec.minHeight && dims.height <= (spec.maxHeight ?? Infinity) : dims.height === spec.height;
91
+ if (!wOk || !hOk) {
92
+ const expected = spec.minWidth ? `${spec.minWidth}~${spec.maxWidth}×${spec.minHeight}~${spec.maxHeight}` : `${spec.width}×${spec.height}`;
93
+ issues.push(`해상도 불일치 (${spec.label}): 필요 ${expected}, 현재 ${dims.width}×${dims.height}`);
94
+ }
95
+ else {
96
+ matchedSpec = spec.label;
97
+ }
98
+ }
99
+ else {
100
+ for (const [key, spec] of Object.entries(APPSTORE_SPECS)) {
101
+ // check both portrait and landscape orientations
102
+ const fw = dims.width, fh = dims.height;
103
+ const matches = (w, h) => spec.minWidth ? (w >= spec.minWidth && w <= (spec.maxWidth ?? Infinity) && h >= (spec.minHeight ?? 0) && h <= (spec.maxHeight ?? Infinity))
104
+ : (w === spec.width && h === spec.height) || (w === spec.height && h === spec.width);
105
+ if (matches(fw, fh)) {
106
+ matchedSpec = `${key} (${spec.label})`;
107
+ break;
108
+ }
109
+ }
110
+ if (!matchedSpec) {
111
+ issues.push(`알 수 없는 해상도 ${dims.width}×${dims.height} — Apple 스펙과 일치하지 않음`);
112
+ }
113
+ if (sizeBytes > 50 * 1024 * 1024)
114
+ issues.push('파일 크기 50MB 초과');
115
+ }
116
+ return { filePath, fileName, valid: issues.length === 0, issues, info, matchedSpec };
117
+ });
118
+ }
119
+ export function validatePlayStoreScreenshots(filePaths, imageType = 'phoneScreenshots') {
120
+ if (!(imageType in PLAYSTORE_SPECS)) {
121
+ return filePaths.map((fp) => ({
122
+ filePath: fp, fileName: path.basename(fp), valid: false,
123
+ issues: [`알 수 없는 imageType: ${imageType}. 유효값: ${Object.keys(PLAYSTORE_SPECS).join(', ')}`],
124
+ info: { sizeBytes: 0 },
125
+ }));
126
+ }
127
+ const spec = PLAYSTORE_SPECS[imageType];
128
+ return filePaths.map((filePath) => {
129
+ const fileName = path.basename(filePath);
130
+ const issues = [];
131
+ if (!fs.existsSync(filePath)) {
132
+ return { filePath, fileName, valid: false, issues: ['파일 없음'], info: { sizeBytes: 0 } };
133
+ }
134
+ const sizeBytes = fs.statSync(filePath).size;
135
+ if (sizeBytes > spec.maxBytes) {
136
+ issues.push(`파일 크기 초과: ${(sizeBytes / 1024 / 1024).toFixed(1)}MB (최대 ${(spec.maxBytes / 1024 / 1024).toFixed(0)}MB)`);
137
+ }
138
+ const dims = getImageDimensions(filePath);
139
+ const info = { sizeBytes, detectedWidth: dims?.width, detectedHeight: dims?.height };
140
+ if (!dims)
141
+ return { filePath, fileName, valid: false, issues: ['PNG/JPEG가 아니거나 읽기 실패', ...issues], info };
142
+ if (dims.width < spec.minWidth || dims.width > spec.maxWidth) {
143
+ issues.push(`너비 범위 초과: ${dims.width}px (${spec.minWidth}~${spec.maxWidth}px 필요)`);
144
+ }
145
+ if (dims.height < spec.minHeight || dims.height > spec.maxHeight) {
146
+ issues.push(`높이 범위 초과: ${dims.height}px (${spec.minHeight}~${spec.maxHeight}px 필요)`);
147
+ }
148
+ const ar = dims.width / dims.height;
149
+ if (ar < spec.minAspect || ar > spec.maxAspect) {
150
+ issues.push(`종횡비 범위 초과: ${ar.toFixed(2)} (${spec.minAspect}~${spec.maxAspect} 필요)`);
151
+ }
152
+ return { filePath, fileName, valid: issues.length === 0, issues, info };
153
+ });
154
+ }
155
+ export function formatValidationResults(results, platform) {
156
+ const passed = results.filter((r) => r.valid).length;
157
+ const lines = [`📐 ${platform} 스크린샷 검증: ${passed}/${results.length} 통과\n`];
158
+ for (const r of results) {
159
+ const dims = r.info.detectedWidth ? ` (${r.info.detectedWidth}×${r.info.detectedHeight})` : '';
160
+ const size = ` ${(r.info.sizeBytes / 1024).toFixed(0)}KB`;
161
+ lines.push(`${r.valid ? '✅' : '❌'} ${r.fileName}${dims}${size}`);
162
+ if (r.matchedSpec)
163
+ lines.push(` → ${r.matchedSpec}`);
164
+ for (const issue of r.issues)
165
+ lines.push(` ⚠ ${issue}`);
166
+ }
167
+ return lines.join('\n');
168
+ }
@@ -0,0 +1,45 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ export declare function listServiceAccounts(auth: OAuth2Client, projectId: string): Promise<{
3
+ email: string | null | undefined;
4
+ displayName: string | null | undefined;
5
+ uniqueId: string | null | undefined;
6
+ disabled: boolean;
7
+ }[]>;
8
+ export declare function createServiceAccount(auth: OAuth2Client, projectId: string, accountId: string, displayName: string): Promise<{
9
+ email: string | null | undefined;
10
+ uniqueId: string | null | undefined;
11
+ displayName: string | null | undefined;
12
+ projectId: string | null | undefined;
13
+ }>;
14
+ export declare function createServiceAccountKey(auth: OAuth2Client, serviceAccountEmail: string): Promise<{
15
+ keyId: string | null;
16
+ clientEmail: string | undefined;
17
+ projectId: string | undefined;
18
+ json: string;
19
+ }>;
20
+ export declare function listServiceAccountKeys(auth: OAuth2Client, serviceAccountEmail: string): Promise<{
21
+ id: string | null;
22
+ keyType: string | null | undefined;
23
+ validBeforeTime: string | null | undefined;
24
+ validAfterTime: string | null | undefined;
25
+ }[]>;
26
+ /**
27
+ * 프로젝트의 IAM 정책을 read-modify-write 해서 member에게 role 부여.
28
+ * 이미 같은 (role, member) 바인딩이 있으면 no-op.
29
+ *
30
+ * member 예:
31
+ * 'serviceAccount:my-sa@my-project.iam.gserviceaccount.com'
32
+ * 'user:alice@example.com'
33
+ * 'group:admins@example.com'
34
+ *
35
+ * 자주 쓰는 role 예:
36
+ * 'roles/iam.serviceAccountUser' — 서비스 계정으로 위임 가능
37
+ * 'roles/iam.serviceAccountTokenCreator' — 서비스 계정의 단기 토큰 발급
38
+ * 'roles/editor' / 'roles/owner' — 광범위 (조심)
39
+ */
40
+ export declare function addProjectIamPolicyBinding(auth: OAuth2Client, projectId: string, member: string, role: string): Promise<{
41
+ added: boolean;
42
+ role: string;
43
+ member: string;
44
+ etag: string | null | undefined;
45
+ }>;
@@ -0,0 +1,126 @@
1
+ import { google } from 'googleapis';
2
+ /**
3
+ * Google Cloud IAM + Cloud Resource Manager 래퍼.
4
+ *
5
+ * 사용자가 mimi-seed-mcp로 "서비스 계정 만들기 → JSON 키 발급 → Cloud IAM
6
+ * 역할 부여"를 자동화할 때 쓰는 도구들. (Play Console의 'View financial
7
+ * data' 권한은 여기서 부여하는 Cloud IAM 역할과는 별개 — 그건 Play Console
8
+ * Users and permissions에서 androidpublisher.users API 또는 수동으로.)
9
+ */
10
+ const iam = () => google.iam('v1');
11
+ const crm = () => google.cloudresourcemanager('v1');
12
+ // ─── 서비스 계정 조회 ───
13
+ export async function listServiceAccounts(auth, projectId) {
14
+ const res = await iam().projects.serviceAccounts.list({
15
+ auth,
16
+ name: `projects/${projectId}`,
17
+ pageSize: 100,
18
+ });
19
+ return (res.data.accounts ?? []).map((a) => ({
20
+ email: a.email,
21
+ displayName: a.displayName,
22
+ uniqueId: a.uniqueId,
23
+ disabled: a.disabled ?? false,
24
+ }));
25
+ }
26
+ // ─── 서비스 계정 생성 ───
27
+ export async function createServiceAccount(auth, projectId, accountId, displayName) {
28
+ const res = await iam().projects.serviceAccounts.create({
29
+ auth,
30
+ name: `projects/${projectId}`,
31
+ requestBody: {
32
+ accountId,
33
+ serviceAccount: { displayName },
34
+ },
35
+ });
36
+ return {
37
+ email: res.data.email,
38
+ uniqueId: res.data.uniqueId,
39
+ displayName: res.data.displayName,
40
+ projectId: res.data.projectId,
41
+ };
42
+ }
43
+ // ─── 키 발급 (JSON 다운로드) ───
44
+ export async function createServiceAccountKey(auth, serviceAccountEmail) {
45
+ const res = await iam().projects.serviceAccounts.keys.create({
46
+ auth,
47
+ name: `projects/-/serviceAccounts/${serviceAccountEmail}`,
48
+ requestBody: {
49
+ keyAlgorithm: 'KEY_ALG_RSA_2048',
50
+ privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE',
51
+ },
52
+ });
53
+ // privateKeyData는 base64로 인코딩된 JSON 전체 — 디코딩해서 반환.
54
+ // 주의: 이 JSON은 영구 자격증명이므로 절대 로그에 남기거나 원격으로 보내면 안 됨.
55
+ const privateKeyData = res.data.privateKeyData;
56
+ if (!privateKeyData)
57
+ throw new Error('No privateKeyData in response');
58
+ const jsonString = Buffer.from(privateKeyData, 'base64').toString('utf-8');
59
+ // 파싱해서 필드 일부만 미리보기로 노출 + 전체 JSON도 함께 반환
60
+ const parsed = JSON.parse(jsonString);
61
+ return {
62
+ keyId: res.data.name?.split('/').pop() ?? null,
63
+ clientEmail: parsed.client_email,
64
+ projectId: parsed.project_id,
65
+ json: jsonString,
66
+ };
67
+ }
68
+ // ─── 키 목록 ───
69
+ export async function listServiceAccountKeys(auth, serviceAccountEmail) {
70
+ const res = await iam().projects.serviceAccounts.keys.list({
71
+ auth,
72
+ name: `projects/-/serviceAccounts/${serviceAccountEmail}`,
73
+ });
74
+ return (res.data.keys ?? []).map((k) => ({
75
+ id: k.name?.split('/').pop() ?? null,
76
+ keyType: k.keyType, // 'USER_MANAGED' | 'SYSTEM_MANAGED'
77
+ validBeforeTime: k.validBeforeTime,
78
+ validAfterTime: k.validAfterTime,
79
+ }));
80
+ }
81
+ // ─── 프로젝트 IAM 정책에 역할 바인딩 추가 ───
82
+ /**
83
+ * 프로젝트의 IAM 정책을 read-modify-write 해서 member에게 role 부여.
84
+ * 이미 같은 (role, member) 바인딩이 있으면 no-op.
85
+ *
86
+ * member 예:
87
+ * 'serviceAccount:my-sa@my-project.iam.gserviceaccount.com'
88
+ * 'user:alice@example.com'
89
+ * 'group:admins@example.com'
90
+ *
91
+ * 자주 쓰는 role 예:
92
+ * 'roles/iam.serviceAccountUser' — 서비스 계정으로 위임 가능
93
+ * 'roles/iam.serviceAccountTokenCreator' — 서비스 계정의 단기 토큰 발급
94
+ * 'roles/editor' / 'roles/owner' — 광범위 (조심)
95
+ */
96
+ export async function addProjectIamPolicyBinding(auth, projectId, member, role) {
97
+ const current = await crm().projects.getIamPolicy({
98
+ auth,
99
+ resource: projectId,
100
+ requestBody: {},
101
+ });
102
+ const policy = current.data;
103
+ const bindings = policy.bindings ?? [];
104
+ const existing = bindings.find((b) => b.role === role);
105
+ let added = false;
106
+ if (existing) {
107
+ const members = existing.members ?? [];
108
+ if (!members.includes(member)) {
109
+ existing.members = [...members, member];
110
+ added = true;
111
+ }
112
+ }
113
+ else {
114
+ bindings.push({ role, members: [member] });
115
+ added = true;
116
+ }
117
+ if (!added) {
118
+ return { added: false, role, member, etag: policy.etag };
119
+ }
120
+ const updated = await crm().projects.setIamPolicy({
121
+ auth,
122
+ resource: projectId,
123
+ requestBody: { policy: { bindings, etag: policy.etag } },
124
+ });
125
+ return { added: true, role, member, etag: updated.data.etag };
126
+ }