@yoonion/mimi-seed-mcp 0.19.7 → 0.19.8
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,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolveLang } from '../lib/lang.js';
|
|
3
|
+
import { scanReleaseDoctor } from './release-doctor.js';
|
|
4
|
+
function parseArgs(argv) {
|
|
5
|
+
let projectPath = process.cwd();
|
|
6
|
+
let json = false;
|
|
7
|
+
let failOnBlocker = false;
|
|
8
|
+
for (const arg of argv) {
|
|
9
|
+
if (arg === '--json')
|
|
10
|
+
json = true;
|
|
11
|
+
else if (arg === '--fail-on-blocker')
|
|
12
|
+
failOnBlocker = true;
|
|
13
|
+
else if (!arg.startsWith('-'))
|
|
14
|
+
projectPath = arg;
|
|
15
|
+
}
|
|
16
|
+
return { projectPath, json, failOnBlocker };
|
|
17
|
+
}
|
|
18
|
+
const COPY = {
|
|
19
|
+
ko: {
|
|
20
|
+
title: 'Mimi Seed Release Doctor — 로그인 없는 출시 점검',
|
|
21
|
+
platforms: '감지 플랫폼',
|
|
22
|
+
summary: (blockers, warnings) => `결과: 블로커 ${blockers} · 경고 ${warnings}`,
|
|
23
|
+
labels: { blocker: '블로커', warning: '경고', info: '확인' },
|
|
24
|
+
evidence: '근거',
|
|
25
|
+
action: '조치',
|
|
26
|
+
source: '출처',
|
|
27
|
+
connected: '스토어 연결 후 추가 검사',
|
|
28
|
+
connectedChecks: [
|
|
29
|
+
'스토어 등록정보와 스크린샷',
|
|
30
|
+
'업로드된 빌드의 존재 여부와 처리 상태',
|
|
31
|
+
'App Store 연령등급 응답과 Google Play 선언',
|
|
32
|
+
'심사 제출과 단계적 출시 상태',
|
|
33
|
+
],
|
|
34
|
+
ready: '로컬 검사에서 제출 차단 요인을 찾지 못했습니다.',
|
|
35
|
+
partial: '이 결과는 저장소의 결정적 증거만 검사합니다. 스토어 상태는 연결 후 별도로 확인합니다.',
|
|
36
|
+
},
|
|
37
|
+
en: {
|
|
38
|
+
title: 'Mimi Seed Release Doctor — no-login release check',
|
|
39
|
+
platforms: 'Platforms',
|
|
40
|
+
summary: (blockers, warnings) => `Result: ${blockers} blockers · ${warnings} warnings`,
|
|
41
|
+
labels: { blocker: 'BLOCKER', warning: 'WARNING', info: 'INFO' },
|
|
42
|
+
evidence: 'Evidence',
|
|
43
|
+
action: 'Action',
|
|
44
|
+
source: 'Source',
|
|
45
|
+
connected: 'Checked after connecting stores',
|
|
46
|
+
connectedChecks: undefined,
|
|
47
|
+
ready: 'No submission blocker was found by the local checks.',
|
|
48
|
+
partial: 'This report checks deterministic repository evidence only. Store state is checked separately after connection.',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
function renderFinding(finding, copy, lang) {
|
|
52
|
+
const localized = lang === 'ko' ? finding.ko : undefined;
|
|
53
|
+
const lines = [
|
|
54
|
+
`[${copy.labels[finding.severity]}] ${localized?.title ?? finding.title}`,
|
|
55
|
+
` ${localized?.detail ?? finding.detail}`,
|
|
56
|
+
];
|
|
57
|
+
if (finding.file)
|
|
58
|
+
lines.push(` ${copy.evidence}: ${finding.file}`);
|
|
59
|
+
const action = localized?.action ?? finding.action;
|
|
60
|
+
if (action)
|
|
61
|
+
lines.push(` ${copy.action}: ${action}`);
|
|
62
|
+
if (finding.sourceUrl)
|
|
63
|
+
lines.push(` ${copy.source}: ${finding.sourceUrl}`);
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
async function main() {
|
|
67
|
+
const args = parseArgs(process.argv.slice(2));
|
|
68
|
+
const report = await scanReleaseDoctor(args.projectPath);
|
|
69
|
+
if (args.json) {
|
|
70
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const lang = resolveLang();
|
|
74
|
+
const copy = COPY[lang];
|
|
75
|
+
const lines = [
|
|
76
|
+
'',
|
|
77
|
+
copy.title,
|
|
78
|
+
report.projectPath,
|
|
79
|
+
`${copy.platforms}: ${report.platforms.length ? report.platforms.join(' + ') : '-'}`,
|
|
80
|
+
copy.summary(report.counts.blocker, report.counts.warning),
|
|
81
|
+
'',
|
|
82
|
+
];
|
|
83
|
+
for (const finding of report.findings)
|
|
84
|
+
lines.push(...renderFinding(finding, copy, lang), '');
|
|
85
|
+
if (report.counts.blocker === 0)
|
|
86
|
+
lines.push(`✓ ${copy.ready}`, '');
|
|
87
|
+
const connectedChecks = copy.connectedChecks ?? report.coverage.requiresStoreConnection;
|
|
88
|
+
lines.push(copy.partial, '', `${copy.connected}:`, ...connectedChecks.map((item) => ` - ${item}`), '');
|
|
89
|
+
process.stdout.write(lines.join('\n'));
|
|
90
|
+
}
|
|
91
|
+
if (args.failOnBlocker && report.counts.blocker > 0)
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
}
|
|
94
|
+
main().catch((error) => {
|
|
95
|
+
process.stderr.write(`Release Doctor failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
96
|
+
process.exitCode = 2;
|
|
97
|
+
});
|
|
@@ -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,409 @@
|
|
|
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
|
+
detected = true;
|
|
80
|
+
try {
|
|
81
|
+
const json = JSON.parse(file.text);
|
|
82
|
+
const expo = ((json.expo && typeof json.expo === 'object') ? json.expo : json);
|
|
83
|
+
if (typeof expo.android?.package === 'string')
|
|
84
|
+
androidPackageNames.push(expo.android.package);
|
|
85
|
+
if (typeof expo.ios?.bundleIdentifier === 'string')
|
|
86
|
+
iosBundleIds.push(expo.ios.bundleIdentifier);
|
|
87
|
+
if (Array.isArray(expo.platforms)) {
|
|
88
|
+
for (const platform of expo.platforms)
|
|
89
|
+
if (typeof platform === 'string')
|
|
90
|
+
platforms.add(platform);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Dynamic Expo configs are common. Resolve only obvious literal identifiers and keep the rest as warnings.
|
|
95
|
+
const android = file.text.match(/\bandroid\s*:\s*\{[\s\S]{0,3000}?\bpackage\s*:\s*['"]([^'"]+)['"]/);
|
|
96
|
+
const ios = file.text.match(/\bios\s*:\s*\{[\s\S]{0,3000}?\bbundleIdentifier\s*:\s*['"]([^'"]+)['"]/);
|
|
97
|
+
if (android?.[1])
|
|
98
|
+
androidPackageNames.push(android[1]);
|
|
99
|
+
if (ios?.[1])
|
|
100
|
+
iosBundleIds.push(ios[1]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const file of files.filter((candidate) => candidate.relative.endsWith('package.json'))) {
|
|
104
|
+
try {
|
|
105
|
+
const manifest = JSON.parse(file.text);
|
|
106
|
+
const groups = ['dependencies', 'devDependencies'].map((key) => manifest[key]);
|
|
107
|
+
if (groups.some((group) => group && typeof group === 'object' && 'expo' in group))
|
|
108
|
+
detected = true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// A malformed package manifest cannot add Expo evidence.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { androidPackageNames, iosBundleIds, platforms, detected };
|
|
115
|
+
}
|
|
116
|
+
function detectProject(files) {
|
|
117
|
+
const expo = parseExpo(files);
|
|
118
|
+
const gradleFiles = files.filter((file) => /build\.gradle(?:\.kts)?$/.test(file.relative));
|
|
119
|
+
const pbxFiles = files.filter((file) => file.relative.endsWith('project.pbxproj'));
|
|
120
|
+
const plistFiles = files.filter((file) => file.relative.endsWith('Info.plist'));
|
|
121
|
+
const androidPackageNames = [...expo.androidPackageNames];
|
|
122
|
+
for (const file of gradleFiles) {
|
|
123
|
+
for (const match of file.text.matchAll(/\bapplicationId\s*(?:=\s*)?["']([^"']+)["']/g)) {
|
|
124
|
+
androidPackageNames.push(match[1]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const iosBundleIds = [...expo.iosBundleIds];
|
|
128
|
+
for (const file of pbxFiles) {
|
|
129
|
+
for (const match of file.text.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;]+);/g)) {
|
|
130
|
+
const value = match[1].trim().replace(/^["']|["']$/g, '');
|
|
131
|
+
if (value && !value.includes('$'))
|
|
132
|
+
iosBundleIds.push(value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const file of plistFiles) {
|
|
136
|
+
const match = file.text.match(/<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/);
|
|
137
|
+
if (match?.[1] && !match[1].includes('$'))
|
|
138
|
+
iosBundleIds.push(match[1]);
|
|
139
|
+
}
|
|
140
|
+
const expoTargetsAndroid = expo.detected && (expo.platforms.size === 0 || expo.platforms.has('android'));
|
|
141
|
+
const expoTargetsIos = expo.detected && (expo.platforms.size === 0 || expo.platforms.has('ios'));
|
|
142
|
+
const android = gradleFiles.length > 0
|
|
143
|
+
|| expo.androidPackageNames.length > 0
|
|
144
|
+
|| expoTargetsAndroid
|
|
145
|
+
|| files.some((file) => /(?:^|\/)android\//.test(file.relative));
|
|
146
|
+
const ios = pbxFiles.length > 0
|
|
147
|
+
|| plistFiles.length > 0
|
|
148
|
+
|| expo.iosBundleIds.length > 0
|
|
149
|
+
|| expoTargetsIos
|
|
150
|
+
|| files.some((file) => /(?:^|\/)ios\//.test(file.relative));
|
|
151
|
+
return {
|
|
152
|
+
android,
|
|
153
|
+
ios,
|
|
154
|
+
androidPackageNames: unique(androidPackageNames),
|
|
155
|
+
iosBundleIds: unique(iosBundleIds),
|
|
156
|
+
gradleFiles,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function targetPolicy(now) {
|
|
160
|
+
const today = now.toISOString().slice(0, 10);
|
|
161
|
+
const current = [...TARGET_SDK_POLICY].filter((row) => row.effectiveDate <= today).at(-1);
|
|
162
|
+
const refreshAfter = `${Number(TARGET_SDK_POLICY.at(-1).effectiveDate.slice(0, 4)) + 1}-08-31`;
|
|
163
|
+
return {
|
|
164
|
+
minimum: current?.minimum ?? null,
|
|
165
|
+
scheduleCurrent: today <= refreshAfter,
|
|
166
|
+
effectiveDate: current?.effectiveDate,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function targetSdkFindings(gradleFiles, now) {
|
|
170
|
+
const evidence = [];
|
|
171
|
+
let hasUnresolvedExpression = false;
|
|
172
|
+
for (const file of gradleFiles) {
|
|
173
|
+
for (const match of file.text.matchAll(/\btargetSdk(?:Version)?\s*(?:=\s*)?(\d+)/g)) {
|
|
174
|
+
evidence.push({ file: file.relative, value: Number.parseInt(match[1], 10) });
|
|
175
|
+
}
|
|
176
|
+
if (/\btargetSdk(?:Version)?\b/.test(file.text) && !/\btargetSdk(?:Version)?\s*(?:=\s*)?\d+/.test(file.text)) {
|
|
177
|
+
hasUnresolvedExpression = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const policy = targetPolicy(now);
|
|
181
|
+
if (!policy.scheduleCurrent || policy.minimum === null) {
|
|
182
|
+
return [{
|
|
183
|
+
code: 'TARGET_SDK_POLICY_REFRESH_REQUIRED',
|
|
184
|
+
severity: 'warning',
|
|
185
|
+
title: 'Target API policy table needs a refresh',
|
|
186
|
+
detail: 'The embedded Google Play Target API schedule is no longer current enough for a definitive result.',
|
|
187
|
+
action: 'Check the current Google Play Target API requirement before submission.',
|
|
188
|
+
sourceUrl: TARGET_SDK_SOURCE,
|
|
189
|
+
ko: {
|
|
190
|
+
title: 'Target API 정책표 갱신 필요',
|
|
191
|
+
detail: '내장된 Google Play Target API 일정만으로는 현재 제출 요건을 확정할 수 없습니다.',
|
|
192
|
+
action: '제출 전에 최신 Google Play Target API 요구사항을 확인하세요.',
|
|
193
|
+
},
|
|
194
|
+
}];
|
|
195
|
+
}
|
|
196
|
+
if (evidence.length === 0) {
|
|
197
|
+
return [{
|
|
198
|
+
code: 'TARGET_SDK_UNRESOLVED',
|
|
199
|
+
severity: 'warning',
|
|
200
|
+
title: 'Android targetSdk could not be resolved locally',
|
|
201
|
+
detail: hasUnresolvedExpression
|
|
202
|
+
? 'A targetSdk expression exists, but its numeric value is defined indirectly.'
|
|
203
|
+
: 'No literal targetSdk value was found in the scanned Gradle files.',
|
|
204
|
+
action: `Resolve the release variant and confirm targetSdk ${policy.minimum} or newer before submission.`,
|
|
205
|
+
sourceUrl: TARGET_SDK_SOURCE,
|
|
206
|
+
ko: {
|
|
207
|
+
title: 'Android targetSdk 값을 로컬에서 확정하지 못함',
|
|
208
|
+
detail: hasUnresolvedExpression
|
|
209
|
+
? 'targetSdk 표현식은 있지만 숫자 값이 다른 파일이나 변수에 정의되어 있습니다.'
|
|
210
|
+
: '검사한 Gradle 파일에서 숫자로 된 targetSdk 값을 찾지 못했습니다.',
|
|
211
|
+
action: `릴리스 variant의 값을 확인해 targetSdk ${policy.minimum} 이상인지 검증하세요.`,
|
|
212
|
+
},
|
|
213
|
+
}];
|
|
214
|
+
}
|
|
215
|
+
const below = evidence.filter((row) => row.value < policy.minimum);
|
|
216
|
+
if (below.length > 0) {
|
|
217
|
+
const first = below.sort((left, right) => left.value - right.value)[0];
|
|
218
|
+
return [{
|
|
219
|
+
code: 'TARGET_SDK_BELOW_MINIMUM',
|
|
220
|
+
severity: 'blocker',
|
|
221
|
+
title: `Android targetSdk ${first.value} is below the submission minimum`,
|
|
222
|
+
detail: `Google Play requires targetSdk ${policy.minimum} or newer for new apps and updates after ${policy.effectiveDate}.`,
|
|
223
|
+
action: `Upgrade targetSdk to ${policy.minimum} or newer and test the release build.`,
|
|
224
|
+
file: first.file,
|
|
225
|
+
sourceUrl: TARGET_SDK_SOURCE,
|
|
226
|
+
ko: {
|
|
227
|
+
title: `Android targetSdk ${first.value}은 현재 제출 기준 미달`,
|
|
228
|
+
detail: `${policy.effectiveDate} 이후 신규 앱과 업데이트는 targetSdk ${policy.minimum} 이상이어야 합니다.`,
|
|
229
|
+
action: `targetSdk를 ${policy.minimum} 이상으로 올리고 릴리스 빌드를 테스트하세요.`,
|
|
230
|
+
},
|
|
231
|
+
}];
|
|
232
|
+
}
|
|
233
|
+
const lowest = evidence.sort((left, right) => left.value - right.value)[0];
|
|
234
|
+
return [{
|
|
235
|
+
code: 'TARGET_SDK_OK',
|
|
236
|
+
severity: 'info',
|
|
237
|
+
title: `Android targetSdk ${lowest.value} meets the current minimum`,
|
|
238
|
+
detail: `The lowest literal targetSdk found is at least ${policy.minimum}.`,
|
|
239
|
+
file: lowest.file,
|
|
240
|
+
sourceUrl: TARGET_SDK_SOURCE,
|
|
241
|
+
ko: {
|
|
242
|
+
title: `Android targetSdk ${lowest.value}은 현재 제출 기준 충족`,
|
|
243
|
+
detail: `감지된 가장 낮은 targetSdk가 현재 최소값 ${policy.minimum} 이상입니다.`,
|
|
244
|
+
},
|
|
245
|
+
}];
|
|
246
|
+
}
|
|
247
|
+
function hasSpecializedAndroidProfile(files) {
|
|
248
|
+
return files
|
|
249
|
+
.filter((file) => file.relative.endsWith('AndroidManifest.xml'))
|
|
250
|
+
.some((file) => /android\.hardware\.type\.(?:watch|automotive)|android\.software\.(?:leanback|xr)|LEANBACK_LAUNCHER/i.test(file.text));
|
|
251
|
+
}
|
|
252
|
+
export async function scanReleaseDoctor(projectPath, now = new Date()) {
|
|
253
|
+
const root = path.resolve(projectPath);
|
|
254
|
+
let stat;
|
|
255
|
+
try {
|
|
256
|
+
stat = await fs.stat(root);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
throw new Error(`Project path does not exist or is not readable: ${root}`);
|
|
260
|
+
}
|
|
261
|
+
if (!stat.isDirectory())
|
|
262
|
+
throw new Error(`Project path is not a directory: ${root}`);
|
|
263
|
+
const files = await walk(root);
|
|
264
|
+
const detected = detectProject(files);
|
|
265
|
+
const platforms = [];
|
|
266
|
+
if (detected.android)
|
|
267
|
+
platforms.push('android');
|
|
268
|
+
if (detected.ios)
|
|
269
|
+
platforms.push('ios');
|
|
270
|
+
const findings = [];
|
|
271
|
+
if (platforms.length === 0) {
|
|
272
|
+
findings.push({
|
|
273
|
+
code: 'NO_MOBILE_PROJECT',
|
|
274
|
+
severity: 'blocker',
|
|
275
|
+
title: 'No Android or iOS app project was detected',
|
|
276
|
+
detail: 'Release Doctor could not find an Expo config, Android Gradle app, Xcode project, or iOS Info.plist.',
|
|
277
|
+
action: 'Run this command from the mobile app repository root.',
|
|
278
|
+
ko: {
|
|
279
|
+
title: 'Android 또는 iOS 앱 프로젝트를 찾지 못함',
|
|
280
|
+
detail: 'Expo 설정, Android Gradle 앱, Xcode 프로젝트, iOS Info.plist를 찾지 못했습니다.',
|
|
281
|
+
action: '모바일 앱 저장소의 루트에서 다시 실행하세요.',
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (detected.android) {
|
|
286
|
+
if (detected.androidPackageNames.length === 0) {
|
|
287
|
+
findings.push({
|
|
288
|
+
code: 'ANDROID_PACKAGE_UNRESOLVED',
|
|
289
|
+
severity: 'warning',
|
|
290
|
+
title: 'Android application ID could not be resolved',
|
|
291
|
+
detail: 'The Android project was detected, but no literal applicationId or Expo android.package was found.',
|
|
292
|
+
action: 'Confirm the release variant applicationId before connecting Google Play.',
|
|
293
|
+
ko: {
|
|
294
|
+
title: 'Android application ID를 확정하지 못함',
|
|
295
|
+
detail: 'Android 프로젝트는 감지했지만 applicationId 또는 Expo android.package의 문자열 값을 찾지 못했습니다.',
|
|
296
|
+
action: 'Google Play 연결 전에 릴리스 variant의 applicationId를 확인하세요.',
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
findings.push({
|
|
302
|
+
code: 'ANDROID_PACKAGE_FOUND',
|
|
303
|
+
severity: 'info',
|
|
304
|
+
title: 'Android application ID detected',
|
|
305
|
+
detail: detected.androidPackageNames.join(', '),
|
|
306
|
+
ko: {
|
|
307
|
+
title: 'Android application ID 감지 완료',
|
|
308
|
+
detail: detected.androidPackageNames.join(', '),
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (hasSpecializedAndroidProfile(files)) {
|
|
313
|
+
findings.push({
|
|
314
|
+
code: 'TARGET_SDK_SPECIALIZED_APP_REVIEW',
|
|
315
|
+
severity: 'warning',
|
|
316
|
+
title: 'Specialized Android app type needs a category-specific Target API check',
|
|
317
|
+
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.',
|
|
318
|
+
action: 'Confirm the app category and its current Target API requirement in the official table.',
|
|
319
|
+
sourceUrl: TARGET_SDK_SOURCE,
|
|
320
|
+
ko: {
|
|
321
|
+
title: '특수 Android 앱 유형은 카테고리별 Target API 확인 필요',
|
|
322
|
+
detail: 'Wear OS, Android TV, Android Automotive OS 또는 Android XR 근거를 찾았습니다. 일반 모바일 앱과 제출 최소값이 달라 API 36 기준을 일괄 적용하지 않았습니다.',
|
|
323
|
+
action: '앱 카테고리와 해당 Target API 요구사항을 공식 표에서 확인하세요.',
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
findings.push(...targetSdkFindings(detected.gradleFiles, now));
|
|
329
|
+
}
|
|
330
|
+
const billing = await checkBillingCompliance(root, now);
|
|
331
|
+
if (billing.status !== 'not_used') {
|
|
332
|
+
const billingKoDetail = billing.status === 'pass'
|
|
333
|
+
? `감지된 Billing Library ${billing.detectedVersions.join(', ')}은 현재 제출 기준을 충족합니다.`
|
|
334
|
+
: billing.status === 'blocker'
|
|
335
|
+
? `감지된 Billing Library ${billing.detectedVersions.join(', ')}은 현재 제출 최소 버전보다 낮습니다.`
|
|
336
|
+
: billing.status === 'warning'
|
|
337
|
+
? `감지된 Billing Library ${billing.detectedVersions.join(', ')}은 현재 지원되지만 다음 지원 종료 대상입니다.`
|
|
338
|
+
: 'Billing 의존성은 찾았지만 버전을 정적으로 확정하지 못했습니다.';
|
|
339
|
+
findings.push({
|
|
340
|
+
code: `BILLING_${billing.status.toUpperCase()}`,
|
|
341
|
+
severity: billing.status === 'blocker' ? 'blocker' : billing.status === 'pass' ? 'info' : 'warning',
|
|
342
|
+
title: billing.status === 'pass' ? 'Google Play Billing version is supported' : 'Google Play Billing needs attention',
|
|
343
|
+
detail: billing.summary,
|
|
344
|
+
action: billing.actions[0] ?? billing.upgrade.prompt,
|
|
345
|
+
file: billing.evidence[0]?.file,
|
|
346
|
+
sourceUrl: billing.policy.sourceUrl,
|
|
347
|
+
ko: {
|
|
348
|
+
title: billing.status === 'pass' ? 'Google Play Billing 버전 기준 충족' : 'Google Play Billing 확인 필요',
|
|
349
|
+
detail: billingKoDetail,
|
|
350
|
+
action: billing.actions[0]
|
|
351
|
+
? '공식 마감일과 보고된 Gradle 근거를 확인한 뒤 지원 버전으로 업그레이드하세요.'
|
|
352
|
+
: 'Google Play Billing 업그레이드 Skill을 사용해 변경사항을 검토하세요.',
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (detected.ios) {
|
|
358
|
+
if (detected.iosBundleIds.length === 0) {
|
|
359
|
+
findings.push({
|
|
360
|
+
code: 'IOS_BUNDLE_ID_UNRESOLVED',
|
|
361
|
+
severity: 'warning',
|
|
362
|
+
title: 'iOS bundle identifier could not be resolved',
|
|
363
|
+
detail: 'The iOS project was detected, but no literal bundle identifier was found in Expo config, Info.plist, or project.pbxproj.',
|
|
364
|
+
action: 'Confirm PRODUCT_BUNDLE_IDENTIFIER for the release configuration before connecting App Store Connect.',
|
|
365
|
+
ko: {
|
|
366
|
+
title: 'iOS bundle identifier를 확정하지 못함',
|
|
367
|
+
detail: 'iOS 프로젝트는 감지했지만 Expo 설정, Info.plist, project.pbxproj에서 문자열 bundle identifier를 찾지 못했습니다.',
|
|
368
|
+
action: 'App Store Connect 연결 전에 릴리스 구성의 PRODUCT_BUNDLE_IDENTIFIER를 확인하세요.',
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
findings.push({
|
|
374
|
+
code: 'IOS_BUNDLE_ID_FOUND',
|
|
375
|
+
severity: 'info',
|
|
376
|
+
title: 'iOS bundle identifier detected',
|
|
377
|
+
detail: detected.iosBundleIds.join(', '),
|
|
378
|
+
ko: {
|
|
379
|
+
title: 'iOS bundle identifier 감지 완료',
|
|
380
|
+
detail: detected.iosBundleIds.join(', '),
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const counts = findings.reduce((result, finding) => ({ ...result, [finding.severity]: result[finding.severity] + 1 }), { blocker: 0, warning: 0, info: 0 });
|
|
386
|
+
return {
|
|
387
|
+
projectPath: root,
|
|
388
|
+
checkedAt: now.toISOString(),
|
|
389
|
+
platforms,
|
|
390
|
+
identifiers: {
|
|
391
|
+
androidPackageNames: detected.androidPackageNames,
|
|
392
|
+
iosBundleIds: detected.iosBundleIds,
|
|
393
|
+
},
|
|
394
|
+
counts,
|
|
395
|
+
findings,
|
|
396
|
+
coverage: {
|
|
397
|
+
checked: [
|
|
398
|
+
'mobile project and app identifier detection',
|
|
399
|
+
...(detected.android ? ['Android targetSdk policy', 'Google Play Billing dependency policy'] : []),
|
|
400
|
+
],
|
|
401
|
+
requiresStoreConnection: [
|
|
402
|
+
'store listing metadata and screenshots',
|
|
403
|
+
'uploaded build availability and processing state',
|
|
404
|
+
'App Store age-rating answers and Google Play declarations',
|
|
405
|
+
'review submission and rollout status',
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
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.
|
|
3
|
+
"version": "0.19.8",
|
|
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",
|