@yoonion/mimi-seed-mcp 0.19.9 → 0.19.11
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 +11 -3
- package/dist/checks/billing-network.d.ts +2 -0
- package/dist/checks/billing-network.js +9 -0
- package/dist/checks/billing.d.ts +7 -1
- package/dist/checks/billing.js +61 -20
- package/dist/checks/release-doctor-cli.js +2 -65
- package/dist/checks/release-doctor-render.d.ts +2 -0
- package/dist/checks/release-doctor-render.js +66 -0
- package/dist/checks/release-doctor.js +125 -20
- package/dist/registers/checks.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
스토어 자격증명을 연결하기 전에 저장소에서 확정할 수 있는 출시 블로커를 검사합니다.
|
|
10
10
|
|
|
11
|
+
일반 사용자는 전체 MCP 패키지를 먼저 설치하지 않는 경량 CLI 경로를 권장합니다.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx -y mimi-seed@latest check --local
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
MCP 패키지를 이미 설치했다면 같은 검사기의 직접 bin도 사용할 수 있습니다.
|
|
18
|
+
|
|
11
19
|
```bash
|
|
12
20
|
npx -y @yoonion/mimi-seed-mcp mimi-seed-release-doctor .
|
|
13
21
|
```
|
|
@@ -23,7 +31,7 @@ TV, Android Automotive OS, Android XR은 Target API 예외가 있으므로 카
|
|
|
23
31
|
Claude Code:
|
|
24
32
|
|
|
25
33
|
```bash
|
|
26
|
-
claude mcp add mimi-seed-local -- npx -y @yoonion/mimi-seed-mcp
|
|
34
|
+
claude mcp add mimi-seed-local -- npx -y @yoonion/mimi-seed-mcp@latest
|
|
27
35
|
```
|
|
28
36
|
|
|
29
37
|
Codex (`~/.codex/config.toml`):
|
|
@@ -31,7 +39,7 @@ Codex (`~/.codex/config.toml`):
|
|
|
31
39
|
```toml
|
|
32
40
|
[mcp_servers.mimi-seed-local]
|
|
33
41
|
command = "npx"
|
|
34
|
-
args = ["-y", "@yoonion/mimi-seed-mcp"]
|
|
42
|
+
args = ["-y", "@yoonion/mimi-seed-mcp@latest"]
|
|
35
43
|
enabled = true
|
|
36
44
|
```
|
|
37
45
|
|
|
@@ -42,7 +50,7 @@ Claude Desktop (`claude_desktop_config.json`):
|
|
|
42
50
|
"mcpServers": {
|
|
43
51
|
"mimi-seed-local": {
|
|
44
52
|
"command": "npx",
|
|
45
|
-
"args": ["-y", "@yoonion/mimi-seed-mcp"]
|
|
53
|
+
"args": ["-y", "@yoonion/mimi-seed-mcp@latest"]
|
|
46
54
|
}
|
|
47
55
|
}
|
|
48
56
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { fetchWithTimeout } from '../lib/http.js';
|
|
2
|
+
import { billingVersionFromPom } from './billing.js';
|
|
3
|
+
export const resolveOpenIapBilling = async (openIapVersion) => {
|
|
4
|
+
const pomUrl = `https://repo.maven.apache.org/maven2/io/github/hyochan/openiap/openiap-google/${openIapVersion}/openiap-google-${openIapVersion}.pom`;
|
|
5
|
+
const response = await fetchWithTimeout(pomUrl, {}, { timeoutMs: 15_000, maxAttempts: 2 });
|
|
6
|
+
if (!response.ok)
|
|
7
|
+
return null;
|
|
8
|
+
return billingVersionFromPom(await response.text());
|
|
9
|
+
};
|
package/dist/checks/billing.d.ts
CHANGED
|
@@ -33,8 +33,14 @@ export interface BillingComplianceResult {
|
|
|
33
33
|
automaticExecution: false;
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
export type BillingTransitiveResolver = (openIapVersion: string) => Promise<{
|
|
37
|
+
module: string;
|
|
38
|
+
version: string;
|
|
39
|
+
} | null>;
|
|
36
40
|
export declare function billingVersionFromPom(pom: string): {
|
|
37
41
|
module: string;
|
|
38
42
|
version: string;
|
|
39
43
|
} | null;
|
|
40
|
-
export declare function checkBillingCompliance(projectPath: string, now?: Date
|
|
44
|
+
export declare function checkBillingCompliance(projectPath: string, now?: Date, options?: {
|
|
45
|
+
resolveTransitive?: BillingTransitiveResolver;
|
|
46
|
+
}): Promise<BillingComplianceResult>;
|
package/dist/checks/billing.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fetchWithTimeout } from '../lib/http.js';
|
|
4
3
|
const BILLING_MODULE = /com\.android\.billingclient:billing(?:-ktx)?/;
|
|
5
4
|
const LITERAL_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})/g;
|
|
6
5
|
const VARIABLE_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:\$\{?([A-Za-z_][A-Za-z0-9_.-]*)\}?/g;
|
|
7
6
|
const VERSION_ASSIGNMENT = /(?:^|\s)([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:=|:)\s*["']([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})["']/gm;
|
|
7
|
+
// Repository-only scans cannot fetch Maven metadata. Keep a deliberately small,
|
|
8
|
+
// source-verified cache for OpenIAP releases used by supported react-native-iap
|
|
9
|
+
// versions. Unknown releases remain unresolved instead of being guessed.
|
|
10
|
+
const KNOWN_OPENIAP_BILLING = new Map([
|
|
11
|
+
['2.1.0', { module: 'com.android.billingclient:billing-ktx', version: '8.3.0' }],
|
|
12
|
+
['2.4.1', { module: 'com.android.billingclient:billing', version: '9.1.0' }],
|
|
13
|
+
]);
|
|
8
14
|
const BILLING_SUPPORT_SCHEDULE = [
|
|
9
15
|
{ major: 5, submissionDeadline: '2024-08-31', extensionDeadline: '2024-11-01' },
|
|
10
16
|
{ major: 6, submissionDeadline: '2025-08-31', extensionDeadline: '2025-11-01' },
|
|
@@ -42,6 +48,9 @@ async function walk(root, maxDepth = 7) {
|
|
|
42
48
|
else if (entry.isFile()
|
|
43
49
|
&& (entry.name === 'build.gradle'
|
|
44
50
|
|| entry.name === 'build.gradle.kts'
|
|
51
|
+
|| entry.name === 'mainTemplate.gradle'
|
|
52
|
+
|| entry.name === 'baseProjectTemplate.gradle'
|
|
53
|
+
|| entry.name === 'launcherTemplate.gradle'
|
|
45
54
|
|| entry.name === 'libs.versions.toml'
|
|
46
55
|
|| entry.name === 'package.json')) {
|
|
47
56
|
result.push(path.join(dir, entry.name));
|
|
@@ -145,7 +154,7 @@ async function nearestNodePackage(packageDir, root) {
|
|
|
145
154
|
}
|
|
146
155
|
return null;
|
|
147
156
|
}
|
|
148
|
-
async function reactNativeIapEvidence(root, manifestFile, manifestText) {
|
|
157
|
+
async function reactNativeIapEvidence(root, manifestFile, manifestText, resolveTransitive) {
|
|
149
158
|
let manifest;
|
|
150
159
|
try {
|
|
151
160
|
manifest = JSON.parse(manifestText);
|
|
@@ -171,6 +180,15 @@ async function reactNativeIapEvidence(root, manifestFile, manifestText) {
|
|
|
171
180
|
source: 'unresolved',
|
|
172
181
|
};
|
|
173
182
|
}
|
|
183
|
+
let installedVersion = declaredVersion;
|
|
184
|
+
try {
|
|
185
|
+
const installedManifest = JSON.parse(await fs.readFile(path.join(installedDir, 'package.json'), 'utf8'));
|
|
186
|
+
if (typeof installedManifest.version === 'string')
|
|
187
|
+
installedVersion = installedManifest.version;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// The declaration still provides useful evidence when package metadata is unavailable.
|
|
191
|
+
}
|
|
174
192
|
const directCandidates = [
|
|
175
193
|
path.join(installedDir, 'android', 'build.gradle'),
|
|
176
194
|
path.join(installedDir, 'android', 'build.gradle.kts'),
|
|
@@ -184,7 +202,7 @@ async function reactNativeIapEvidence(root, manifestFile, manifestText) {
|
|
|
184
202
|
file: relativeManifest,
|
|
185
203
|
module: direct[0].slice(0, direct[0].lastIndexOf(':')),
|
|
186
204
|
version: direct[1],
|
|
187
|
-
expression: `react-native-iap ${
|
|
205
|
+
expression: `react-native-iap ${installedVersion} native dependency`,
|
|
188
206
|
source: 'transitive',
|
|
189
207
|
};
|
|
190
208
|
}
|
|
@@ -206,34 +224,57 @@ async function reactNativeIapEvidence(root, manifestFile, manifestText) {
|
|
|
206
224
|
return {
|
|
207
225
|
file: relativeManifest,
|
|
208
226
|
module: 'com.android.billingclient:billing',
|
|
209
|
-
expression: `react-native-iap ${
|
|
227
|
+
expression: `react-native-iap ${installedVersion} detected; transitive Billing version is unresolved`,
|
|
210
228
|
source: 'unresolved',
|
|
211
229
|
};
|
|
212
230
|
}
|
|
213
231
|
const coordinate = `io.github.hyochan.openiap:openiap-google:${openIapVersion}`;
|
|
232
|
+
const known = KNOWN_OPENIAP_BILLING.get(openIapVersion);
|
|
233
|
+
if (!resolveTransitive) {
|
|
234
|
+
if (known) {
|
|
235
|
+
return {
|
|
236
|
+
file: relativeManifest,
|
|
237
|
+
module: known.module,
|
|
238
|
+
version: known.version,
|
|
239
|
+
expression: `react-native-iap ${installedVersion} -> ${coordinate} (embedded Maven metadata)`,
|
|
240
|
+
source: 'transitive',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
file: relativeManifest,
|
|
245
|
+
module: 'com.android.billingclient:billing',
|
|
246
|
+
expression: `react-native-iap ${installedVersion} -> ${coordinate}; transitive lookup unavailable in repository-only mode`,
|
|
247
|
+
source: 'unresolved',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
214
250
|
try {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
expression: `react-native-iap ${declaredVersion} -> ${coordinate}`,
|
|
225
|
-
source: 'transitive',
|
|
226
|
-
};
|
|
227
|
-
}
|
|
251
|
+
const resolved = await resolveTransitive(openIapVersion);
|
|
252
|
+
if (resolved) {
|
|
253
|
+
return {
|
|
254
|
+
file: relativeManifest,
|
|
255
|
+
module: resolved.module,
|
|
256
|
+
version: resolved.version,
|
|
257
|
+
expression: `react-native-iap ${installedVersion} -> ${coordinate}`,
|
|
258
|
+
source: 'transitive',
|
|
259
|
+
};
|
|
228
260
|
}
|
|
229
261
|
}
|
|
230
262
|
catch {
|
|
231
263
|
// Network failure must not turn a known IAP dependency into not_used.
|
|
232
264
|
}
|
|
265
|
+
if (known) {
|
|
266
|
+
return {
|
|
267
|
+
file: relativeManifest,
|
|
268
|
+
module: known.module,
|
|
269
|
+
version: known.version,
|
|
270
|
+
expression: `react-native-iap ${installedVersion} -> ${coordinate} (embedded Maven metadata fallback)`,
|
|
271
|
+
source: 'transitive',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
233
274
|
return {
|
|
234
275
|
file: relativeManifest,
|
|
235
276
|
module: 'com.android.billingclient:billing',
|
|
236
|
-
expression: `react-native-iap ${
|
|
277
|
+
expression: `react-native-iap ${installedVersion} -> ${coordinate}; Maven Billing version lookup failed`,
|
|
237
278
|
source: 'unresolved',
|
|
238
279
|
};
|
|
239
280
|
}
|
|
@@ -264,7 +305,7 @@ function isWithin(scope, file) {
|
|
|
264
305
|
const relative = path.relative(scope, file);
|
|
265
306
|
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
266
307
|
}
|
|
267
|
-
export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
308
|
+
export async function checkBillingCompliance(projectPath, now = new Date(), options = {}) {
|
|
268
309
|
const root = path.resolve(projectPath);
|
|
269
310
|
let rootStat;
|
|
270
311
|
try {
|
|
@@ -301,7 +342,7 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
301
342
|
for (const [file, text] of texts) {
|
|
302
343
|
if (path.basename(file) !== 'package.json')
|
|
303
344
|
continue;
|
|
304
|
-
const transitive = await reactNativeIapEvidence(root, file, text);
|
|
345
|
+
const transitive = await reactNativeIapEvidence(root, file, text, options.resolveTransitive);
|
|
305
346
|
if (transitive && !evidence.some((row) => row.expression === transitive.expression))
|
|
306
347
|
evidence.push(transitive);
|
|
307
348
|
}
|
|
@@ -2,54 +2,7 @@
|
|
|
2
2
|
import { resolveLang } from '../lib/lang.js';
|
|
3
3
|
import { scanReleaseDoctor } from './release-doctor.js';
|
|
4
4
|
import { parseReleaseDoctorArgs, RELEASE_DOCTOR_USAGE } from './release-doctor-cli-args.js';
|
|
5
|
-
|
|
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
|
-
}
|
|
5
|
+
import { renderReleaseDoctor } from './release-doctor-render.js';
|
|
53
6
|
async function main() {
|
|
54
7
|
const args = parseReleaseDoctorArgs(process.argv.slice(2));
|
|
55
8
|
if (args.help) {
|
|
@@ -61,23 +14,7 @@ async function main() {
|
|
|
61
14
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
62
15
|
}
|
|
63
16
|
else {
|
|
64
|
-
|
|
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'));
|
|
17
|
+
process.stdout.write(renderReleaseDoctor(report, resolveLang()));
|
|
81
18
|
}
|
|
82
19
|
if (args.failOnBlocker && report.counts.blocker > 0)
|
|
83
20
|
process.exitCode = 1;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const COPY = {
|
|
2
|
+
ko: {
|
|
3
|
+
title: 'Mimi Seed Release Doctor — 로그인 없는 출시 점검',
|
|
4
|
+
platforms: '감지 플랫폼',
|
|
5
|
+
summary: (blockers, warnings) => `결과: 블로커 ${blockers} · 경고 ${warnings}`,
|
|
6
|
+
labels: { blocker: '블로커', warning: '경고', info: '확인' },
|
|
7
|
+
evidence: '근거',
|
|
8
|
+
action: '조치',
|
|
9
|
+
source: '출처',
|
|
10
|
+
connected: '스토어 연결 후 추가 검사',
|
|
11
|
+
connectedChecks: [
|
|
12
|
+
'스토어 등록정보와 스크린샷',
|
|
13
|
+
'업로드된 빌드의 존재 여부와 처리 상태',
|
|
14
|
+
'App Store 연령등급 응답과 Google Play 선언',
|
|
15
|
+
'심사 제출과 단계적 출시 상태',
|
|
16
|
+
],
|
|
17
|
+
ready: '로컬 검사에서 제출 차단 요인을 찾지 못했습니다.',
|
|
18
|
+
partial: '이 결과는 저장소의 결정적 증거만 검사합니다. 스토어 상태는 연결 후 별도로 확인합니다.',
|
|
19
|
+
},
|
|
20
|
+
en: {
|
|
21
|
+
title: 'Mimi Seed Release Doctor — no-login release check',
|
|
22
|
+
platforms: 'Platforms',
|
|
23
|
+
summary: (blockers, warnings) => `Result: ${blockers} blockers · ${warnings} warnings`,
|
|
24
|
+
labels: { blocker: 'BLOCKER', warning: 'WARNING', info: 'INFO' },
|
|
25
|
+
evidence: 'Evidence',
|
|
26
|
+
action: 'Action',
|
|
27
|
+
source: 'Source',
|
|
28
|
+
connected: 'Checked after connecting stores',
|
|
29
|
+
connectedChecks: undefined,
|
|
30
|
+
ready: 'No submission blocker was found by the local checks.',
|
|
31
|
+
partial: 'This report checks deterministic repository evidence only. Store state is checked separately after connection.',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
function renderFinding(finding, copy, lang) {
|
|
35
|
+
const localized = lang === 'ko' ? finding.ko : undefined;
|
|
36
|
+
const lines = [
|
|
37
|
+
`[${copy.labels[finding.severity]}] ${localized?.title ?? finding.title}`,
|
|
38
|
+
` ${localized?.detail ?? finding.detail}`,
|
|
39
|
+
];
|
|
40
|
+
if (finding.file)
|
|
41
|
+
lines.push(` ${copy.evidence}: ${finding.file}`);
|
|
42
|
+
const action = localized?.action ?? finding.action;
|
|
43
|
+
if (action)
|
|
44
|
+
lines.push(` ${copy.action}: ${action}`);
|
|
45
|
+
if (finding.sourceUrl)
|
|
46
|
+
lines.push(` ${copy.source}: ${finding.sourceUrl}`);
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
export function renderReleaseDoctor(report, lang) {
|
|
50
|
+
const copy = COPY[lang];
|
|
51
|
+
const lines = [
|
|
52
|
+
'',
|
|
53
|
+
copy.title,
|
|
54
|
+
report.projectPath,
|
|
55
|
+
`${copy.platforms}: ${report.platforms.length ? report.platforms.join(' + ') : '-'}`,
|
|
56
|
+
copy.summary(report.counts.blocker, report.counts.warning),
|
|
57
|
+
'',
|
|
58
|
+
];
|
|
59
|
+
for (const finding of report.findings)
|
|
60
|
+
lines.push(...renderFinding(finding, copy, lang), '');
|
|
61
|
+
if (report.counts.blocker === 0)
|
|
62
|
+
lines.push(`✓ ${copy.ready}`, '');
|
|
63
|
+
const connectedChecks = copy.connectedChecks ?? report.coverage.requiresStoreConnection;
|
|
64
|
+
lines.push(copy.partial, '', `${copy.connected}:`, ...connectedChecks.map((item) => ` - ${item}`), '');
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
@@ -65,12 +65,67 @@ function isRelevantFile(name) {
|
|
|
65
65
|
|| name === 'AndroidManifest.xml'
|
|
66
66
|
|| name === 'Info.plist'
|
|
67
67
|
|| name === 'project.pbxproj'
|
|
68
|
+
|| name === 'ProjectSettings.asset'
|
|
68
69
|
|| name === 'package.json';
|
|
69
70
|
}
|
|
70
71
|
function unique(values) {
|
|
71
72
|
return [...new Set(values.filter(Boolean))].sort();
|
|
72
73
|
}
|
|
73
|
-
function
|
|
74
|
+
function unityApplicationIdentifiers(text) {
|
|
75
|
+
const lines = text.split(/\r?\n/);
|
|
76
|
+
const start = lines.findIndex((line) => /^\s*applicationIdentifier:\s*$/.test(line));
|
|
77
|
+
if (start < 0)
|
|
78
|
+
return {};
|
|
79
|
+
const baseIndent = lines[start].match(/^\s*/)?.[0].length ?? 0;
|
|
80
|
+
const result = {};
|
|
81
|
+
for (const line of lines.slice(start + 1)) {
|
|
82
|
+
if (!line.trim())
|
|
83
|
+
continue;
|
|
84
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
85
|
+
if (indent <= baseIndent)
|
|
86
|
+
break;
|
|
87
|
+
const entry = line.match(/^\s+(Android|iPhone|iOS):\s*([^\s#]+)\s*$/);
|
|
88
|
+
if (entry?.[1] === 'Android')
|
|
89
|
+
result.android = entry[2];
|
|
90
|
+
if (entry && entry[1] !== 'Android')
|
|
91
|
+
result.ios = entry[2];
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
async function readStaticJsonImports(file, root) {
|
|
96
|
+
const result = new Map();
|
|
97
|
+
const imports = [
|
|
98
|
+
...file.text.matchAll(/\bimport\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.json)['"]/g),
|
|
99
|
+
...file.text.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"]([^'"]+\.json)['"]\s*\)/g),
|
|
100
|
+
];
|
|
101
|
+
for (const match of imports) {
|
|
102
|
+
if (!match[2].startsWith('.'))
|
|
103
|
+
continue;
|
|
104
|
+
const candidate = path.resolve(path.dirname(file.absolute), match[2]);
|
|
105
|
+
const relative = path.relative(root, candidate);
|
|
106
|
+
if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative))
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
const json = JSON.parse(await fs.readFile(candidate, 'utf8'));
|
|
110
|
+
if (json && typeof json === 'object' && !Array.isArray(json)) {
|
|
111
|
+
result.set(match[1], json);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Dynamic configs remain useful even when one imported JSON file is absent or malformed.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function resolveJsonMember(text, block, field, imports) {
|
|
121
|
+
const expression = new RegExp(`\\b${block}\\s*:\\s*\\{[\\s\\S]{0,5000}?\\b${field}\\s*:\\s*([A-Za-z_$][\\w$]*)\\.([A-Za-z_$][\\w$]*)`)
|
|
122
|
+
.exec(text);
|
|
123
|
+
if (!expression)
|
|
124
|
+
return undefined;
|
|
125
|
+
const value = imports.get(expression[1])?.[expression[2]];
|
|
126
|
+
return typeof value === 'string' ? value : undefined;
|
|
127
|
+
}
|
|
128
|
+
async function parseExpo(files, root) {
|
|
74
129
|
const androidPackageNames = [];
|
|
75
130
|
const iosBundleIds = [];
|
|
76
131
|
const platforms = new Set();
|
|
@@ -99,12 +154,15 @@ function parseExpo(files) {
|
|
|
99
154
|
// Dynamic Expo configs are common. Resolve only obvious literal identifiers and keep the rest as warnings.
|
|
100
155
|
const android = file.text.match(/\bandroid\s*:\s*\{[\s\S]{0,3000}?\bpackage\s*:\s*['"]([^'"]+)['"]/);
|
|
101
156
|
const ios = file.text.match(/\bios\s*:\s*\{[\s\S]{0,3000}?\bbundleIdentifier\s*:\s*['"]([^'"]+)['"]/);
|
|
102
|
-
|
|
157
|
+
const imports = await readStaticJsonImports(file, root);
|
|
158
|
+
const importedAndroid = resolveJsonMember(file.text, 'android', 'package', imports);
|
|
159
|
+
const importedIos = resolveJsonMember(file.text, 'ios', 'bundleIdentifier', imports);
|
|
160
|
+
if (android?.[1] || ios?.[1] || importedAndroid || importedIos || /\bexpo\s*:/.test(file.text))
|
|
103
161
|
detected = true;
|
|
104
|
-
if (android?.[1])
|
|
105
|
-
androidPackageNames.push(android[1]);
|
|
106
|
-
if (ios?.[1])
|
|
107
|
-
iosBundleIds.push(ios[1]);
|
|
162
|
+
if (android?.[1] || importedAndroid)
|
|
163
|
+
androidPackageNames.push(android?.[1] ?? importedAndroid);
|
|
164
|
+
if (ios?.[1] || importedIos)
|
|
165
|
+
iosBundleIds.push(ios?.[1] ?? importedIos);
|
|
108
166
|
}
|
|
109
167
|
}
|
|
110
168
|
for (const file of files.filter((candidate) => candidate.relative.endsWith('package.json'))) {
|
|
@@ -120,8 +178,8 @@ function parseExpo(files) {
|
|
|
120
178
|
}
|
|
121
179
|
return { androidPackageNames, iosBundleIds, platforms, detected };
|
|
122
180
|
}
|
|
123
|
-
function detectProject(files) {
|
|
124
|
-
const expo = parseExpo(files);
|
|
181
|
+
async function detectProject(files, root) {
|
|
182
|
+
const expo = await parseExpo(files, root);
|
|
125
183
|
const gradleFiles = files.filter((file) => /build\.gradle(?:\.kts)?$/.test(file.relative));
|
|
126
184
|
const pbxFiles = files.filter((file) => file.relative.endsWith('project.pbxproj'));
|
|
127
185
|
const plistFiles = files.filter((file) => file.relative.endsWith('Info.plist'));
|
|
@@ -131,13 +189,30 @@ function detectProject(files) {
|
|
|
131
189
|
const iosPbxFiles = pbxFiles.filter((file) => /(?:^|\/)ios\//.test(file.relative)
|
|
132
190
|
|| /\b(?:SDKROOT\s*=\s*iphoneos|IPHONEOS_DEPLOYMENT_TARGET|TARGETED_DEVICE_FAMILY)\b/.test(file.text));
|
|
133
191
|
const iosPlistFiles = plistFiles.filter((file) => /(?:^|\/)ios\//.test(file.relative) || iosPbxFiles.length > 0);
|
|
134
|
-
const
|
|
192
|
+
const unitySettingsFiles = files.filter((file) => /(?:^|\/)ProjectSettings\/ProjectSettings\.asset$/.test(file.relative));
|
|
193
|
+
const unityAndroidPackageNames = [];
|
|
194
|
+
const unityIosBundleIds = [];
|
|
195
|
+
const unityTargetSdkEvidence = [];
|
|
196
|
+
for (const file of unitySettingsFiles) {
|
|
197
|
+
const identifiers = unityApplicationIdentifiers(file.text);
|
|
198
|
+
const androidId = identifiers.android;
|
|
199
|
+
const iosId = identifiers.ios;
|
|
200
|
+
const targetSdk = file.text.match(/^\s*AndroidTargetSdkVersion:\s*(-?\d+)\s*$/m)?.[1];
|
|
201
|
+
if (androidId)
|
|
202
|
+
unityAndroidPackageNames.push(androidId);
|
|
203
|
+
if (iosId)
|
|
204
|
+
unityIosBundleIds.push(iosId);
|
|
205
|
+
if (targetSdk && Number.parseInt(targetSdk, 10) > 0) {
|
|
206
|
+
unityTargetSdkEvidence.push({ file: file.relative, value: Number.parseInt(targetSdk, 10) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const androidPackageNames = [...expo.androidPackageNames, ...unityAndroidPackageNames];
|
|
135
210
|
for (const file of androidAppGradleFiles) {
|
|
136
211
|
for (const match of file.text.matchAll(/\bapplicationId\s*(?:=\s*)?["']([^"']+)["']/g)) {
|
|
137
212
|
androidPackageNames.push(match[1]);
|
|
138
213
|
}
|
|
139
214
|
}
|
|
140
|
-
const iosBundleIds = [...expo.iosBundleIds];
|
|
215
|
+
const iosBundleIds = [...expo.iosBundleIds, ...unityIosBundleIds];
|
|
141
216
|
for (const file of iosPbxFiles) {
|
|
142
217
|
for (const match of file.text.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;]+);/g)) {
|
|
143
218
|
const value = match[1].trim().replace(/^["']|["']$/g, '');
|
|
@@ -156,11 +231,14 @@ function detectProject(files) {
|
|
|
156
231
|
const android = androidAppGradleFiles.length > 0
|
|
157
232
|
|| expo.androidPackageNames.length > 0
|
|
158
233
|
|| expoTargetsAndroid
|
|
159
|
-
|| androidAppManifestFiles.length > 0
|
|
234
|
+
|| androidAppManifestFiles.length > 0
|
|
235
|
+
|| unityAndroidPackageNames.length > 0
|
|
236
|
+
|| unityTargetSdkEvidence.length > 0;
|
|
160
237
|
const ios = iosPbxFiles.length > 0
|
|
161
238
|
|| iosPlistFiles.some((file) => /(?:^|\/)ios\//.test(file.relative))
|
|
162
239
|
|| expo.iosBundleIds.length > 0
|
|
163
|
-
|| expoTargetsIos
|
|
240
|
+
|| expoTargetsIos
|
|
241
|
+
|| unityIosBundleIds.length > 0;
|
|
164
242
|
const androidGradleFiles = gradleFiles.filter((file) => androidAppGradleFiles.includes(file) || /(?:^|\/)android\//.test(file.relative));
|
|
165
243
|
return {
|
|
166
244
|
android,
|
|
@@ -168,6 +246,7 @@ function detectProject(files) {
|
|
|
168
246
|
androidPackageNames: unique(androidPackageNames),
|
|
169
247
|
iosBundleIds: unique(iosBundleIds),
|
|
170
248
|
gradleFiles: [...androidGradleFiles, ...versionCatalogFiles],
|
|
249
|
+
targetSdkEvidence: unityTargetSdkEvidence,
|
|
171
250
|
};
|
|
172
251
|
}
|
|
173
252
|
function targetPolicy(now) {
|
|
@@ -180,10 +259,11 @@ function targetPolicy(now) {
|
|
|
180
259
|
effectiveDate: current?.effectiveDate,
|
|
181
260
|
};
|
|
182
261
|
}
|
|
183
|
-
function targetSdkFindings(gradleFiles, now) {
|
|
184
|
-
const evidence = [];
|
|
262
|
+
function targetSdkFindings(gradleFiles, now, supplementalEvidence = []) {
|
|
263
|
+
const evidence = [...supplementalEvidence];
|
|
185
264
|
let hasUnresolvedExpression = false;
|
|
186
265
|
const catalogs = new Map();
|
|
266
|
+
let reactNativeTargetSdk;
|
|
187
267
|
for (const file of gradleFiles.filter((candidate) => candidate.relative.endsWith('libs.versions.toml'))) {
|
|
188
268
|
let section = '';
|
|
189
269
|
for (const rawLine of file.text.split(/\r?\n/)) {
|
|
@@ -196,25 +276,39 @@ function targetSdkFindings(gradleFiles, now) {
|
|
|
196
276
|
if (section !== 'versions')
|
|
197
277
|
continue;
|
|
198
278
|
const version = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*["'](\d+)["']/);
|
|
199
|
-
if (version)
|
|
200
|
-
|
|
279
|
+
if (version) {
|
|
280
|
+
const parsed = { value: Number.parseInt(version[2], 10), file: file.relative };
|
|
281
|
+
if (file.relative === 'node_modules/react-native/gradle/libs.versions.toml' && version[1] === 'targetSdk') {
|
|
282
|
+
reactNativeTargetSdk = parsed;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
catalogs.set(version[1], parsed);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
201
288
|
}
|
|
202
289
|
}
|
|
203
290
|
for (const file of gradleFiles.filter((candidate) => /build\.gradle(?:\.kts)?$/.test(candidate.relative))) {
|
|
291
|
+
let resolvedIndirectly = false;
|
|
204
292
|
for (const match of file.text.matchAll(/\btargetSdk(?:Version)?\s*(?:=\s*)?(\d+)/g)) {
|
|
205
293
|
evidence.push({ file: file.relative, value: Number.parseInt(match[1], 10) });
|
|
206
294
|
}
|
|
207
295
|
for (const match of file.text.matchAll(/\btargetSdk(?:Version)?\s*(?:=\s*)?libs\.versions\.([A-Za-z0-9_.-]+?)(?=\.get\(\)|\s|$)/g)) {
|
|
208
296
|
const resolved = catalogs.get(match[1]) ?? catalogs.get(match[1].replace(/\./g, '-'));
|
|
209
|
-
if (resolved)
|
|
297
|
+
if (resolved) {
|
|
210
298
|
evidence.push({ file: resolved.file, value: resolved.value });
|
|
299
|
+
resolvedIndirectly = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (/\btargetSdkVersion\s+rootProject\.ext\.targetSdkVersion\b/.test(file.text) && reactNativeTargetSdk) {
|
|
303
|
+
evidence.push({ file: reactNativeTargetSdk.file, value: reactNativeTargetSdk.value });
|
|
304
|
+
resolvedIndirectly = true;
|
|
211
305
|
}
|
|
212
306
|
if (/\btargetSdk(?:Version)?\b/.test(file.text) && !/\btargetSdk(?:Version)?\s*(?:=\s*)?\d+/.test(file.text)) {
|
|
213
307
|
const catalogExpression = /\btargetSdk(?:Version)?\s*(?:=\s*)?libs\.versions\.([A-Za-z0-9_.-]+?)(?=\.get\(\)|\s|$)/.exec(file.text);
|
|
214
308
|
const resolved = catalogExpression
|
|
215
309
|
? catalogs.get(catalogExpression[1]) ?? catalogs.get(catalogExpression[1].replace(/\./g, '-'))
|
|
216
310
|
: undefined;
|
|
217
|
-
if (!resolved)
|
|
311
|
+
if (!resolved && !resolvedIndirectly)
|
|
218
312
|
hasUnresolvedExpression = true;
|
|
219
313
|
}
|
|
220
314
|
}
|
|
@@ -306,7 +400,18 @@ export async function scanReleaseDoctor(projectPath, now = new Date()) {
|
|
|
306
400
|
if (!stat.isDirectory())
|
|
307
401
|
throw new Error(`Project path is not a directory: ${root}`);
|
|
308
402
|
const files = await walk(root);
|
|
309
|
-
const
|
|
403
|
+
const reactNativeCatalog = path.join(root, 'node_modules', 'react-native', 'gradle', 'libs.versions.toml');
|
|
404
|
+
try {
|
|
405
|
+
files.push({
|
|
406
|
+
absolute: reactNativeCatalog,
|
|
407
|
+
relative: 'node_modules/react-native/gradle/libs.versions.toml',
|
|
408
|
+
text: await fs.readFile(reactNativeCatalog, 'utf8'),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// The package may not be installed; the report will keep the indirect expression unresolved.
|
|
413
|
+
}
|
|
414
|
+
const detected = await detectProject(files, root);
|
|
310
415
|
const platforms = [];
|
|
311
416
|
if (detected.android)
|
|
312
417
|
platforms.push('android');
|
|
@@ -384,7 +489,7 @@ export async function scanReleaseDoctor(projectPath, now = new Date()) {
|
|
|
384
489
|
});
|
|
385
490
|
}
|
|
386
491
|
else {
|
|
387
|
-
findings.push(...targetSdkFindings(detected.gradleFiles, now));
|
|
492
|
+
findings.push(...targetSdkFindings(detected.gradleFiles, now, detected.targetSdkEvidence));
|
|
388
493
|
}
|
|
389
494
|
const billing = await checkBillingCompliance(root, now);
|
|
390
495
|
if (billing.status !== 'not_used') {
|
package/dist/registers/checks.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as appstore from '../appstore/tools.js';
|
|
|
6
6
|
import * as playstore from '../playstore/tools.js';
|
|
7
7
|
import { jsonResult } from '../lib/mcp-response.js';
|
|
8
8
|
import { checkBillingCompliance } from '../checks/billing.js';
|
|
9
|
+
import { resolveOpenIapBilling } from '../checks/billing-network.js';
|
|
9
10
|
export function registerChecksTools(server) {
|
|
10
11
|
server.tool('android_check_billing_compliance', [
|
|
11
12
|
'로컬 Android 저장소의 Google Play Billing Library 버전을 읽기 전용으로 탐지하고 제출 정책 마감과 비교합니다.',
|
|
@@ -13,7 +14,7 @@ export function registerChecksTools(server) {
|
|
|
13
14
|
'코드를 자동 수정하지 않고 공식 Android CLI Skill 설치 명령과 업그레이드 프롬프트만 반환합니다.',
|
|
14
15
|
].join(' '), {
|
|
15
16
|
projectPath: z.string().optional().describe('검사할 프로젝트 절대경로 (기본: MCP 프로세스 현재 디렉터리)'),
|
|
16
|
-
}, async ({ projectPath }) => jsonResult(await checkBillingCompliance(projectPath ?? process.cwd())));
|
|
17
|
+
}, async ({ projectPath }) => jsonResult(await checkBillingCompliance(projectPath ?? process.cwd(), new Date(), { resolveTransitive: resolveOpenIapBilling })));
|
|
17
18
|
server.tool('playstore_check_submission_risks', [
|
|
18
19
|
'Google Play 제출 전 위험 요소를 자동으로 점검합니다.',
|
|
19
20
|
'블로커(반드시 수정)와 경고(권장 수정)로 분류하여 반환합니다.',
|
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.11",
|
|
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": {
|