@yoonion/mimi-seed-mcp 0.19.7 → 0.19.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,20 @@
4
4
 
5
5
  > 이 패키지는 Mimi Seed의 **로컬 MCP 서버**만 포함합니다. 웹 콘솔(Next.js 앱)은 <https://mimi-seed.pryzm.gg/tool>.
6
6
 
7
+ ## 계정 없이 출시 점검
8
+
9
+ 스토어 자격증명을 연결하기 전에 저장소에서 확정할 수 있는 출시 블로커를 검사합니다.
10
+
11
+ ```bash
12
+ npx -y @yoonion/mimi-seed-mcp mimi-seed-release-doctor .
13
+ ```
14
+
15
+ 앱 식별자, 일반 Android 앱의 Target API 제출 기준, Google Play Billing 지원 마감을 확인하며 정책
16
+ 결과에는 근거 파일과 공식 출처를 함께 표시합니다. 모노레포에서는 `.` 대신 앱 경로를 전달하세요.
17
+ CI에서는 `--fail-on-blocker`, 기계가 읽는 결과에는 `--json`을 추가할 수 있습니다. Wear OS, Android
18
+ TV, Android Automotive OS, Android XR은 Target API 예외가 있으므로 카테고리 확인 경고를 냅니다.
19
+ 등록정보·스크린샷·업로드 빌드·심사 상태는 저장소만으로 알 수 없어 스토어 연결 후 검사합니다.
20
+
7
21
  ## 설치
8
22
 
9
23
  Claude Code:
@@ -0,0 +1,8 @@
1
+ export interface ReleaseDoctorCliArgs {
2
+ projectPath: string;
3
+ json: boolean;
4
+ failOnBlocker: boolean;
5
+ help: boolean;
6
+ }
7
+ export declare const RELEASE_DOCTOR_USAGE: string;
8
+ export declare function parseReleaseDoctorArgs(argv: string[], defaultProjectPath?: string): ReleaseDoctorCliArgs;
@@ -0,0 +1,32 @@
1
+ export const RELEASE_DOCTOR_USAGE = [
2
+ 'Usage: mimi-seed-release-doctor [project-path] [options]',
3
+ '',
4
+ 'Options:',
5
+ ' --json Print the machine-readable report',
6
+ ' --fail-on-blocker Exit with status 1 when a blocker is found',
7
+ ' -h, --help Show this help',
8
+ ].join('\n');
9
+ export function parseReleaseDoctorArgs(argv, defaultProjectPath = process.cwd()) {
10
+ let projectPath = defaultProjectPath;
11
+ let projectPathExplicit = false;
12
+ let json = false;
13
+ let failOnBlocker = false;
14
+ let help = false;
15
+ for (const token of argv) {
16
+ if (token === '--json')
17
+ json = true;
18
+ else if (token === '--fail-on-blocker')
19
+ failOnBlocker = true;
20
+ else if (token === '--help' || token === '-h')
21
+ help = true;
22
+ else if (token.startsWith('-'))
23
+ throw new Error(`Unknown option: ${token}`);
24
+ else if (projectPathExplicit)
25
+ throw new Error(`Only one project path is allowed; received: ${token}`);
26
+ else {
27
+ projectPath = token;
28
+ projectPathExplicit = true;
29
+ }
30
+ }
31
+ return { projectPath, json, failOnBlocker, help };
32
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import { resolveLang } from '../lib/lang.js';
3
+ import { scanReleaseDoctor } from './release-doctor.js';
4
+ import { parseReleaseDoctorArgs, RELEASE_DOCTOR_USAGE } from './release-doctor-cli-args.js';
5
+ const COPY = {
6
+ ko: {
7
+ title: 'Mimi Seed Release Doctor — 로그인 없는 출시 점검',
8
+ platforms: '감지 플랫폼',
9
+ summary: (blockers, warnings) => `결과: 블로커 ${blockers} · 경고 ${warnings}`,
10
+ labels: { blocker: '블로커', warning: '경고', info: '확인' },
11
+ evidence: '근거',
12
+ action: '조치',
13
+ source: '출처',
14
+ connected: '스토어 연결 후 추가 검사',
15
+ connectedChecks: [
16
+ '스토어 등록정보와 스크린샷',
17
+ '업로드된 빌드의 존재 여부와 처리 상태',
18
+ 'App Store 연령등급 응답과 Google Play 선언',
19
+ '심사 제출과 단계적 출시 상태',
20
+ ],
21
+ ready: '로컬 검사에서 제출 차단 요인을 찾지 못했습니다.',
22
+ partial: '이 결과는 저장소의 결정적 증거만 검사합니다. 스토어 상태는 연결 후 별도로 확인합니다.',
23
+ },
24
+ en: {
25
+ title: 'Mimi Seed Release Doctor — no-login release check',
26
+ platforms: 'Platforms',
27
+ summary: (blockers, warnings) => `Result: ${blockers} blockers · ${warnings} warnings`,
28
+ labels: { blocker: 'BLOCKER', warning: 'WARNING', info: 'INFO' },
29
+ evidence: 'Evidence',
30
+ action: 'Action',
31
+ source: 'Source',
32
+ connected: 'Checked after connecting stores',
33
+ connectedChecks: undefined,
34
+ ready: 'No submission blocker was found by the local checks.',
35
+ partial: 'This report checks deterministic repository evidence only. Store state is checked separately after connection.',
36
+ },
37
+ };
38
+ function renderFinding(finding, copy, lang) {
39
+ const localized = lang === 'ko' ? finding.ko : undefined;
40
+ const lines = [
41
+ `[${copy.labels[finding.severity]}] ${localized?.title ?? finding.title}`,
42
+ ` ${localized?.detail ?? finding.detail}`,
43
+ ];
44
+ if (finding.file)
45
+ lines.push(` ${copy.evidence}: ${finding.file}`);
46
+ const action = localized?.action ?? finding.action;
47
+ if (action)
48
+ lines.push(` ${copy.action}: ${action}`);
49
+ if (finding.sourceUrl)
50
+ lines.push(` ${copy.source}: ${finding.sourceUrl}`);
51
+ return lines;
52
+ }
53
+ async function main() {
54
+ const args = parseReleaseDoctorArgs(process.argv.slice(2));
55
+ if (args.help) {
56
+ process.stdout.write(`${RELEASE_DOCTOR_USAGE}\n`);
57
+ return;
58
+ }
59
+ const report = await scanReleaseDoctor(args.projectPath);
60
+ if (args.json) {
61
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
62
+ }
63
+ else {
64
+ const lang = resolveLang();
65
+ const copy = COPY[lang];
66
+ const lines = [
67
+ '',
68
+ copy.title,
69
+ report.projectPath,
70
+ `${copy.platforms}: ${report.platforms.length ? report.platforms.join(' + ') : '-'}`,
71
+ copy.summary(report.counts.blocker, report.counts.warning),
72
+ '',
73
+ ];
74
+ for (const finding of report.findings)
75
+ lines.push(...renderFinding(finding, copy, lang), '');
76
+ if (report.counts.blocker === 0)
77
+ lines.push(`✓ ${copy.ready}`, '');
78
+ const connectedChecks = copy.connectedChecks ?? report.coverage.requiresStoreConnection;
79
+ lines.push(copy.partial, '', `${copy.connected}:`, ...connectedChecks.map((item) => ` - ${item}`), '');
80
+ process.stdout.write(lines.join('\n'));
81
+ }
82
+ if (args.failOnBlocker && report.counts.blocker > 0)
83
+ process.exitCode = 1;
84
+ }
85
+ main().catch((error) => {
86
+ process.stderr.write(`Release Doctor failed: ${error instanceof Error ? error.message : String(error)}\n`);
87
+ process.exitCode = 2;
88
+ });
@@ -0,0 +1,31 @@
1
+ export type ReleaseDoctorSeverity = 'blocker' | 'warning' | 'info';
2
+ export interface ReleaseDoctorFinding {
3
+ code: string;
4
+ severity: ReleaseDoctorSeverity;
5
+ title: string;
6
+ detail: string;
7
+ action?: string;
8
+ file?: string;
9
+ sourceUrl?: string;
10
+ ko?: {
11
+ title: string;
12
+ detail: string;
13
+ action?: string;
14
+ };
15
+ }
16
+ export interface ReleaseDoctorReport {
17
+ projectPath: string;
18
+ checkedAt: string;
19
+ platforms: Array<'android' | 'ios'>;
20
+ identifiers: {
21
+ androidPackageNames: string[];
22
+ iosBundleIds: string[];
23
+ };
24
+ counts: Record<ReleaseDoctorSeverity, number>;
25
+ findings: ReleaseDoctorFinding[];
26
+ coverage: {
27
+ checked: string[];
28
+ requiresStoreConnection: string[];
29
+ };
30
+ }
31
+ export declare function scanReleaseDoctor(projectPath: string, now?: Date): Promise<ReleaseDoctorReport>;
@@ -0,0 +1,482 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { checkBillingCompliance } from './billing.js';
4
+ const SKIP_DIRS = new Set([
5
+ '.git',
6
+ '.gradle',
7
+ '.idea',
8
+ '.next',
9
+ '.expo',
10
+ 'build',
11
+ 'dist',
12
+ 'node_modules',
13
+ 'Pods',
14
+ 'DerivedData',
15
+ ]);
16
+ const TARGET_SDK_POLICY = [
17
+ { effectiveDate: '2025-08-31', minimum: 35 },
18
+ { effectiveDate: '2026-08-31', minimum: 36 },
19
+ ];
20
+ const TARGET_SDK_SOURCE = 'https://support.google.com/googleplay/android-developer/answer/11926878';
21
+ async function walk(root, maxDepth = 7) {
22
+ const files = [];
23
+ async function visit(dir, depth) {
24
+ if (depth > maxDepth)
25
+ return;
26
+ let entries;
27
+ try {
28
+ entries = await fs.readdir(dir, { withFileTypes: true });
29
+ }
30
+ catch {
31
+ return;
32
+ }
33
+ for (const entry of entries) {
34
+ const absolute = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ if (!SKIP_DIRS.has(entry.name))
37
+ await visit(absolute, depth + 1);
38
+ continue;
39
+ }
40
+ if (!entry.isFile() || !isRelevantFile(entry.name))
41
+ continue;
42
+ try {
43
+ files.push({
44
+ absolute,
45
+ relative: path.relative(root, absolute).replace(/\\/g, '/'),
46
+ text: await fs.readFile(absolute, 'utf8'),
47
+ });
48
+ }
49
+ catch {
50
+ // Unreadable files are ignored; other evidence can still produce a useful partial report.
51
+ }
52
+ }
53
+ }
54
+ await visit(root, 0);
55
+ return files;
56
+ }
57
+ function isRelevantFile(name) {
58
+ return name === 'app.json'
59
+ || name === 'app.config.json'
60
+ || /^app\.config\.(?:js|cjs|mjs|ts)$/.test(name)
61
+ || name === 'build.gradle'
62
+ || name === 'build.gradle.kts'
63
+ || name === 'libs.versions.toml'
64
+ || name === 'gradle.properties'
65
+ || name === 'AndroidManifest.xml'
66
+ || name === 'Info.plist'
67
+ || name === 'project.pbxproj'
68
+ || name === 'package.json';
69
+ }
70
+ function unique(values) {
71
+ return [...new Set(values.filter(Boolean))].sort();
72
+ }
73
+ function parseExpo(files) {
74
+ const androidPackageNames = [];
75
+ const iosBundleIds = [];
76
+ const platforms = new Set();
77
+ let detected = false;
78
+ for (const file of files.filter((candidate) => /(?:^|\/)app(?:\.config)?\.(?:json|js|cjs|mjs|ts)$/.test(candidate.relative))) {
79
+ try {
80
+ const json = JSON.parse(file.text);
81
+ const hasExpoRoot = Boolean(json.expo && typeof json.expo === 'object');
82
+ const expo = (hasExpoRoot ? json.expo : json);
83
+ if (hasExpoRoot
84
+ || typeof expo.android?.package === 'string'
85
+ || typeof expo.ios?.bundleIdentifier === 'string'
86
+ || Array.isArray(expo.platforms))
87
+ detected = true;
88
+ if (typeof expo.android?.package === 'string')
89
+ androidPackageNames.push(expo.android.package);
90
+ if (typeof expo.ios?.bundleIdentifier === 'string')
91
+ iosBundleIds.push(expo.ios.bundleIdentifier);
92
+ if (Array.isArray(expo.platforms)) {
93
+ for (const platform of expo.platforms)
94
+ if (typeof platform === 'string')
95
+ platforms.add(platform);
96
+ }
97
+ }
98
+ catch {
99
+ // Dynamic Expo configs are common. Resolve only obvious literal identifiers and keep the rest as warnings.
100
+ const android = file.text.match(/\bandroid\s*:\s*\{[\s\S]{0,3000}?\bpackage\s*:\s*['"]([^'"]+)['"]/);
101
+ const ios = file.text.match(/\bios\s*:\s*\{[\s\S]{0,3000}?\bbundleIdentifier\s*:\s*['"]([^'"]+)['"]/);
102
+ if (android?.[1] || ios?.[1] || /\bexpo\s*:/.test(file.text))
103
+ detected = true;
104
+ if (android?.[1])
105
+ androidPackageNames.push(android[1]);
106
+ if (ios?.[1])
107
+ iosBundleIds.push(ios[1]);
108
+ }
109
+ }
110
+ for (const file of files.filter((candidate) => candidate.relative.endsWith('package.json'))) {
111
+ try {
112
+ const manifest = JSON.parse(file.text);
113
+ const groups = ['dependencies', 'devDependencies'].map((key) => manifest[key]);
114
+ if (groups.some((group) => group && typeof group === 'object' && 'expo' in group))
115
+ detected = true;
116
+ }
117
+ catch {
118
+ // A malformed package manifest cannot add Expo evidence.
119
+ }
120
+ }
121
+ return { androidPackageNames, iosBundleIds, platforms, detected };
122
+ }
123
+ function detectProject(files) {
124
+ const expo = parseExpo(files);
125
+ const gradleFiles = files.filter((file) => /build\.gradle(?:\.kts)?$/.test(file.relative));
126
+ const pbxFiles = files.filter((file) => file.relative.endsWith('project.pbxproj'));
127
+ const plistFiles = files.filter((file) => file.relative.endsWith('Info.plist'));
128
+ const androidAppGradleFiles = gradleFiles.filter((file) => /\bcom\.android\.application\b|\blibs\.plugins\.android\.application\b|\bapplicationId\b/.test(file.text));
129
+ const androidAppManifestFiles = files.filter((file) => /(?:^|\/)android\/app\/src\/main\/AndroidManifest\.xml$/.test(file.relative));
130
+ const versionCatalogFiles = files.filter((file) => file.relative.endsWith('libs.versions.toml'));
131
+ const iosPbxFiles = pbxFiles.filter((file) => /(?:^|\/)ios\//.test(file.relative)
132
+ || /\b(?:SDKROOT\s*=\s*iphoneos|IPHONEOS_DEPLOYMENT_TARGET|TARGETED_DEVICE_FAMILY)\b/.test(file.text));
133
+ const iosPlistFiles = plistFiles.filter((file) => /(?:^|\/)ios\//.test(file.relative) || iosPbxFiles.length > 0);
134
+ const androidPackageNames = [...expo.androidPackageNames];
135
+ for (const file of androidAppGradleFiles) {
136
+ for (const match of file.text.matchAll(/\bapplicationId\s*(?:=\s*)?["']([^"']+)["']/g)) {
137
+ androidPackageNames.push(match[1]);
138
+ }
139
+ }
140
+ const iosBundleIds = [...expo.iosBundleIds];
141
+ for (const file of iosPbxFiles) {
142
+ for (const match of file.text.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;]+);/g)) {
143
+ const value = match[1].trim().replace(/^["']|["']$/g, '');
144
+ if (value && !value.includes('$') && !/(?:^|\.)(?:Tests?|UITests?|RunnerTests)$/i.test(value)) {
145
+ iosBundleIds.push(value);
146
+ }
147
+ }
148
+ }
149
+ for (const file of iosPlistFiles) {
150
+ const match = file.text.match(/<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/);
151
+ if (match?.[1] && !match[1].includes('$'))
152
+ iosBundleIds.push(match[1]);
153
+ }
154
+ const expoTargetsAndroid = expo.detected && (expo.platforms.size === 0 || expo.platforms.has('android'));
155
+ const expoTargetsIos = expo.detected && (expo.platforms.size === 0 || expo.platforms.has('ios'));
156
+ const android = androidAppGradleFiles.length > 0
157
+ || expo.androidPackageNames.length > 0
158
+ || expoTargetsAndroid
159
+ || androidAppManifestFiles.length > 0;
160
+ const ios = iosPbxFiles.length > 0
161
+ || iosPlistFiles.some((file) => /(?:^|\/)ios\//.test(file.relative))
162
+ || expo.iosBundleIds.length > 0
163
+ || expoTargetsIos;
164
+ const androidGradleFiles = gradleFiles.filter((file) => androidAppGradleFiles.includes(file) || /(?:^|\/)android\//.test(file.relative));
165
+ return {
166
+ android,
167
+ ios,
168
+ androidPackageNames: unique(androidPackageNames),
169
+ iosBundleIds: unique(iosBundleIds),
170
+ gradleFiles: [...androidGradleFiles, ...versionCatalogFiles],
171
+ };
172
+ }
173
+ function targetPolicy(now) {
174
+ const today = now.toISOString().slice(0, 10);
175
+ const current = [...TARGET_SDK_POLICY].filter((row) => row.effectiveDate <= today).at(-1);
176
+ const refreshAfter = `${Number(TARGET_SDK_POLICY.at(-1).effectiveDate.slice(0, 4)) + 1}-08-31`;
177
+ return {
178
+ minimum: current?.minimum ?? null,
179
+ scheduleCurrent: today <= refreshAfter,
180
+ effectiveDate: current?.effectiveDate,
181
+ };
182
+ }
183
+ function targetSdkFindings(gradleFiles, now) {
184
+ const evidence = [];
185
+ let hasUnresolvedExpression = false;
186
+ const catalogs = new Map();
187
+ for (const file of gradleFiles.filter((candidate) => candidate.relative.endsWith('libs.versions.toml'))) {
188
+ let section = '';
189
+ for (const rawLine of file.text.split(/\r?\n/)) {
190
+ const line = rawLine.replace(/\s+#.*$/, '').trim();
191
+ const sectionMatch = line.match(/^\[([^\]]+)]$/);
192
+ if (sectionMatch) {
193
+ section = sectionMatch[1];
194
+ continue;
195
+ }
196
+ if (section !== 'versions')
197
+ continue;
198
+ const version = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*["'](\d+)["']/);
199
+ if (version)
200
+ catalogs.set(version[1], { value: Number.parseInt(version[2], 10), file: file.relative });
201
+ }
202
+ }
203
+ for (const file of gradleFiles.filter((candidate) => /build\.gradle(?:\.kts)?$/.test(candidate.relative))) {
204
+ for (const match of file.text.matchAll(/\btargetSdk(?:Version)?\s*(?:=\s*)?(\d+)/g)) {
205
+ evidence.push({ file: file.relative, value: Number.parseInt(match[1], 10) });
206
+ }
207
+ for (const match of file.text.matchAll(/\btargetSdk(?:Version)?\s*(?:=\s*)?libs\.versions\.([A-Za-z0-9_.-]+?)(?=\.get\(\)|\s|$)/g)) {
208
+ const resolved = catalogs.get(match[1]) ?? catalogs.get(match[1].replace(/\./g, '-'));
209
+ if (resolved)
210
+ evidence.push({ file: resolved.file, value: resolved.value });
211
+ }
212
+ if (/\btargetSdk(?:Version)?\b/.test(file.text) && !/\btargetSdk(?:Version)?\s*(?:=\s*)?\d+/.test(file.text)) {
213
+ const catalogExpression = /\btargetSdk(?:Version)?\s*(?:=\s*)?libs\.versions\.([A-Za-z0-9_.-]+?)(?=\.get\(\)|\s|$)/.exec(file.text);
214
+ const resolved = catalogExpression
215
+ ? catalogs.get(catalogExpression[1]) ?? catalogs.get(catalogExpression[1].replace(/\./g, '-'))
216
+ : undefined;
217
+ if (!resolved)
218
+ hasUnresolvedExpression = true;
219
+ }
220
+ }
221
+ const policy = targetPolicy(now);
222
+ if (!policy.scheduleCurrent || policy.minimum === null) {
223
+ return [{
224
+ code: 'TARGET_SDK_POLICY_REFRESH_REQUIRED',
225
+ severity: 'warning',
226
+ title: 'Target API policy table needs a refresh',
227
+ detail: 'The embedded Google Play Target API schedule is no longer current enough for a definitive result.',
228
+ action: 'Check the current Google Play Target API requirement before submission.',
229
+ sourceUrl: TARGET_SDK_SOURCE,
230
+ ko: {
231
+ title: 'Target API 정책표 갱신 필요',
232
+ detail: '내장된 Google Play Target API 일정만으로는 현재 제출 요건을 확정할 수 없습니다.',
233
+ action: '제출 전에 최신 Google Play Target API 요구사항을 확인하세요.',
234
+ },
235
+ }];
236
+ }
237
+ if (evidence.length === 0) {
238
+ return [{
239
+ code: 'TARGET_SDK_UNRESOLVED',
240
+ severity: 'warning',
241
+ title: 'Android targetSdk could not be resolved locally',
242
+ detail: hasUnresolvedExpression
243
+ ? 'A targetSdk expression exists, but its numeric value is defined indirectly.'
244
+ : 'No literal targetSdk value was found in the scanned Gradle files.',
245
+ action: `Resolve the release variant and confirm targetSdk ${policy.minimum} or newer before submission.`,
246
+ sourceUrl: TARGET_SDK_SOURCE,
247
+ ko: {
248
+ title: 'Android targetSdk 값을 로컬에서 확정하지 못함',
249
+ detail: hasUnresolvedExpression
250
+ ? 'targetSdk 표현식은 있지만 숫자 값이 다른 파일이나 변수에 정의되어 있습니다.'
251
+ : '검사한 Gradle 파일에서 숫자로 된 targetSdk 값을 찾지 못했습니다.',
252
+ action: `릴리스 variant의 값을 확인해 targetSdk ${policy.minimum} 이상인지 검증하세요.`,
253
+ },
254
+ }];
255
+ }
256
+ const below = evidence.filter((row) => row.value < policy.minimum);
257
+ if (below.length > 0) {
258
+ const first = below.sort((left, right) => left.value - right.value)[0];
259
+ return [{
260
+ code: 'TARGET_SDK_BELOW_MINIMUM',
261
+ severity: 'blocker',
262
+ title: `Android targetSdk ${first.value} is below the submission minimum`,
263
+ detail: `Google Play requires targetSdk ${policy.minimum} or newer for new apps and updates after ${policy.effectiveDate}.`,
264
+ action: `Upgrade targetSdk to ${policy.minimum} or newer and test the release build.`,
265
+ file: first.file,
266
+ sourceUrl: TARGET_SDK_SOURCE,
267
+ ko: {
268
+ title: `Android targetSdk ${first.value}은 현재 제출 기준 미달`,
269
+ detail: `${policy.effectiveDate} 이후 신규 앱과 업데이트는 targetSdk ${policy.minimum} 이상이어야 합니다.`,
270
+ action: `targetSdk를 ${policy.minimum} 이상으로 올리고 릴리스 빌드를 테스트하세요.`,
271
+ },
272
+ }];
273
+ }
274
+ const lowest = evidence.sort((left, right) => left.value - right.value)[0];
275
+ return [{
276
+ code: 'TARGET_SDK_OK',
277
+ severity: 'info',
278
+ title: `Android targetSdk ${lowest.value} meets the current minimum`,
279
+ detail: `The lowest literal targetSdk found is at least ${policy.minimum}.`,
280
+ file: lowest.file,
281
+ sourceUrl: TARGET_SDK_SOURCE,
282
+ ko: {
283
+ title: `Android targetSdk ${lowest.value}은 현재 제출 기준 충족`,
284
+ detail: `감지된 가장 낮은 targetSdk가 현재 최소값 ${policy.minimum} 이상입니다.`,
285
+ },
286
+ }];
287
+ }
288
+ function hasSpecializedAndroidProfile(files) {
289
+ return files
290
+ .filter((file) => file.relative.endsWith('AndroidManifest.xml'))
291
+ .some((file) => {
292
+ const manifest = file.text.replace(/<!--[\s\S]*?-->/g, '');
293
+ return /android\.hardware\.type\.(?:watch|automotive)|android\.(?:software|hardware)\.xr|LEANBACK_LAUNCHER/i.test(manifest)
294
+ || /android\.software\.leanback[^>]*android:required\s*=\s*["']true["']/i.test(manifest);
295
+ });
296
+ }
297
+ export async function scanReleaseDoctor(projectPath, now = new Date()) {
298
+ const root = path.resolve(projectPath);
299
+ let stat;
300
+ try {
301
+ stat = await fs.stat(root);
302
+ }
303
+ catch {
304
+ throw new Error(`Project path does not exist or is not readable: ${root}`);
305
+ }
306
+ if (!stat.isDirectory())
307
+ throw new Error(`Project path is not a directory: ${root}`);
308
+ const files = await walk(root);
309
+ const detected = detectProject(files);
310
+ const platforms = [];
311
+ if (detected.android)
312
+ platforms.push('android');
313
+ if (detected.ios)
314
+ platforms.push('ios');
315
+ const findings = [];
316
+ if (platforms.length === 0) {
317
+ findings.push({
318
+ code: 'NO_MOBILE_PROJECT',
319
+ severity: 'blocker',
320
+ title: 'No Android or iOS app project was detected',
321
+ detail: 'Release Doctor could not find an Expo config, Android Gradle app, Xcode project, or iOS Info.plist.',
322
+ action: 'Run this command from the mobile app repository root.',
323
+ ko: {
324
+ title: 'Android 또는 iOS 앱 프로젝트를 찾지 못함',
325
+ detail: 'Expo 설정, Android Gradle 앱, Xcode 프로젝트, iOS Info.plist를 찾지 못했습니다.',
326
+ action: '모바일 앱 저장소의 루트에서 다시 실행하세요.',
327
+ },
328
+ });
329
+ }
330
+ if (detected.android) {
331
+ if (detected.androidPackageNames.length === 0) {
332
+ findings.push({
333
+ code: 'ANDROID_PACKAGE_UNRESOLVED',
334
+ severity: 'warning',
335
+ title: 'Android application ID could not be resolved',
336
+ detail: 'The Android project was detected, but no literal applicationId or Expo android.package was found.',
337
+ action: 'Confirm the release variant applicationId before connecting Google Play.',
338
+ ko: {
339
+ title: 'Android application ID를 확정하지 못함',
340
+ detail: 'Android 프로젝트는 감지했지만 applicationId 또는 Expo android.package의 문자열 값을 찾지 못했습니다.',
341
+ action: 'Google Play 연결 전에 릴리스 variant의 applicationId를 확인하세요.',
342
+ },
343
+ });
344
+ }
345
+ else {
346
+ findings.push({
347
+ code: 'ANDROID_PACKAGE_FOUND',
348
+ severity: 'info',
349
+ title: 'Android application ID detected',
350
+ detail: detected.androidPackageNames.join(', '),
351
+ ko: {
352
+ title: 'Android application ID 감지 완료',
353
+ detail: detected.androidPackageNames.join(', '),
354
+ },
355
+ });
356
+ if (detected.androidPackageNames.length > 1) {
357
+ findings.push({
358
+ code: 'MULTIPLE_ANDROID_APPLICATION_IDS',
359
+ severity: 'warning',
360
+ title: 'Multiple Android application IDs share this scan scope',
361
+ detail: 'Repository-wide Target API and Billing evidence may belong to different apps or variants.',
362
+ action: 'Run Release Doctor with --path set to one mobile app root before treating blockers as app-specific.',
363
+ ko: {
364
+ title: '검사 범위에 Android application ID가 여러 개 있음',
365
+ detail: '저장소 전체에서 찾은 Target API와 Billing 근거가 서로 다른 앱 또는 variant에 속할 수 있습니다.',
366
+ action: '블로커를 특정 앱의 결과로 판단하기 전에 --path로 모바일 앱 루트를 하나만 지정해 다시 검사하세요.',
367
+ },
368
+ });
369
+ }
370
+ }
371
+ if (hasSpecializedAndroidProfile(files)) {
372
+ findings.push({
373
+ code: 'TARGET_SDK_SPECIALIZED_APP_REVIEW',
374
+ severity: 'warning',
375
+ title: 'Specialized Android app type needs a category-specific Target API check',
376
+ detail: 'Wear OS, Android TV, Android Automotive OS, or Android XR evidence was found. Their submission minimums differ from general mobile apps, so Release Doctor did not apply the generic API 36 rule.',
377
+ action: 'Confirm the app category and its current Target API requirement in the official table.',
378
+ sourceUrl: TARGET_SDK_SOURCE,
379
+ ko: {
380
+ title: '특수 Android 앱 유형은 카테고리별 Target API 확인 필요',
381
+ detail: 'Wear OS, Android TV, Android Automotive OS 또는 Android XR 근거를 찾았습니다. 일반 모바일 앱과 제출 최소값이 달라 API 36 기준을 일괄 적용하지 않았습니다.',
382
+ action: '앱 카테고리와 해당 Target API 요구사항을 공식 표에서 확인하세요.',
383
+ },
384
+ });
385
+ }
386
+ else {
387
+ findings.push(...targetSdkFindings(detected.gradleFiles, now));
388
+ }
389
+ const billing = await checkBillingCompliance(root, now);
390
+ if (billing.status !== 'not_used') {
391
+ const billingKoDetail = billing.status === 'pass'
392
+ ? `감지된 Billing Library ${billing.detectedVersions.join(', ')}은 현재 제출 기준을 충족합니다.`
393
+ : billing.status === 'blocker'
394
+ ? `감지된 Billing Library ${billing.detectedVersions.join(', ')}은 현재 제출 최소 버전보다 낮습니다.`
395
+ : billing.status === 'warning'
396
+ ? `감지된 Billing Library ${billing.detectedVersions.join(', ')}은 현재 지원되지만 다음 지원 종료 대상입니다.`
397
+ : 'Billing 의존성은 찾았지만 버전을 정적으로 확정하지 못했습니다.';
398
+ findings.push({
399
+ code: `BILLING_${billing.status.toUpperCase()}`,
400
+ severity: billing.status === 'blocker' ? 'blocker' : billing.status === 'pass' ? 'info' : 'warning',
401
+ title: billing.status === 'pass' ? 'Google Play Billing version is supported' : 'Google Play Billing needs attention',
402
+ detail: billing.summary,
403
+ action: billing.actions[0] ?? billing.upgrade.prompt,
404
+ file: billing.evidence[0]?.file,
405
+ sourceUrl: billing.policy.sourceUrl,
406
+ ko: {
407
+ title: billing.status === 'pass' ? 'Google Play Billing 버전 기준 충족' : 'Google Play Billing 확인 필요',
408
+ detail: billingKoDetail,
409
+ action: billing.actions[0]
410
+ ? '공식 마감일과 보고된 Gradle 근거를 확인한 뒤 지원 버전으로 업그레이드하세요.'
411
+ : 'Google Play Billing 업그레이드 Skill을 사용해 변경사항을 검토하세요.',
412
+ },
413
+ });
414
+ }
415
+ }
416
+ if (detected.ios) {
417
+ if (detected.iosBundleIds.length === 0) {
418
+ findings.push({
419
+ code: 'IOS_BUNDLE_ID_UNRESOLVED',
420
+ severity: 'warning',
421
+ title: 'iOS bundle identifier could not be resolved',
422
+ detail: 'The iOS project was detected, but no literal bundle identifier was found in Expo config, Info.plist, or project.pbxproj.',
423
+ action: 'Confirm PRODUCT_BUNDLE_IDENTIFIER for the release configuration before connecting App Store Connect.',
424
+ ko: {
425
+ title: 'iOS bundle identifier를 확정하지 못함',
426
+ detail: 'iOS 프로젝트는 감지했지만 Expo 설정, Info.plist, project.pbxproj에서 문자열 bundle identifier를 찾지 못했습니다.',
427
+ action: 'App Store Connect 연결 전에 릴리스 구성의 PRODUCT_BUNDLE_IDENTIFIER를 확인하세요.',
428
+ },
429
+ });
430
+ }
431
+ else {
432
+ findings.push({
433
+ code: 'IOS_BUNDLE_ID_FOUND',
434
+ severity: 'info',
435
+ title: 'iOS bundle identifier detected',
436
+ detail: detected.iosBundleIds.join(', '),
437
+ ko: {
438
+ title: 'iOS bundle identifier 감지 완료',
439
+ detail: detected.iosBundleIds.join(', '),
440
+ },
441
+ });
442
+ if (detected.iosBundleIds.length > 1) {
443
+ findings.push({
444
+ code: 'MULTIPLE_IOS_BUNDLE_IDS',
445
+ severity: 'warning',
446
+ title: 'Multiple iOS bundle identifiers share this scan scope',
447
+ detail: 'The identifiers may represent multiple apps, extensions, or release targets in the same repository.',
448
+ action: 'Confirm which identifier belongs to the release target; use --path to narrow a monorepo scan.',
449
+ ko: {
450
+ title: '검사 범위에 iOS bundle identifier가 여러 개 있음',
451
+ detail: '같은 저장소의 여러 앱, extension 또는 출시 타깃 식별자가 함께 감지됐을 수 있습니다.',
452
+ action: '출시 타깃의 식별자를 확인하고, 모노레포라면 --path로 검사 범위를 좁히세요.',
453
+ },
454
+ });
455
+ }
456
+ }
457
+ }
458
+ const counts = findings.reduce((result, finding) => ({ ...result, [finding.severity]: result[finding.severity] + 1 }), { blocker: 0, warning: 0, info: 0 });
459
+ return {
460
+ projectPath: root,
461
+ checkedAt: now.toISOString(),
462
+ platforms,
463
+ identifiers: {
464
+ androidPackageNames: detected.androidPackageNames,
465
+ iosBundleIds: detected.iosBundleIds,
466
+ },
467
+ counts,
468
+ findings,
469
+ coverage: {
470
+ checked: [
471
+ 'mobile project and app identifier detection',
472
+ ...(detected.android ? ['Android targetSdk policy', 'Google Play Billing dependency policy'] : []),
473
+ ],
474
+ requiresStoreConnection: [
475
+ 'store listing metadata and screenshots',
476
+ 'uploaded build availability and processing state',
477
+ 'App Store age-rating answers and Google Play declarations',
478
+ 'review submission and rollout status',
479
+ ],
480
+ },
481
+ };
482
+ }
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ const SUBCOMMANDS = {
21
21
  'mimi-seed-firebase': () => import('./firebase/cli.js'),
22
22
  'mimi-seed-admob': () => import('./admob/cli.js'),
23
23
  'mimi-seed-ga4': () => import('./ga4/cli.js'),
24
+ 'mimi-seed-release-doctor': () => import('./checks/release-doctor-cli.js'),
24
25
  };
25
26
  async function main() {
26
27
  const sub = process.argv[2];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.19.7",
3
+ "version": "0.19.9",
4
4
  "description": "Mimi Seed MCP server \u2014 Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "mimi-seed-tiktok-business-auth": "dist/tiktok-business/setup-cli.js",
16
16
  "mimi-seed-firebase": "dist/firebase/cli.js",
17
17
  "mimi-seed-admob": "dist/admob/cli.js",
18
- "mimi-seed-ga4": "dist/ga4/cli.js"
18
+ "mimi-seed-ga4": "dist/ga4/cli.js",
19
+ "mimi-seed-release-doctor": "dist/checks/release-doctor-cli.js"
19
20
  },
20
21
  "files": [
21
22
  "dist",