@yoonion/mimi-seed-mcp 0.19.8 → 0.19.10
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 +8 -0
- 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 +20 -17
- package/dist/checks/release-doctor-cli-args.d.ts +8 -0
- package/dist/checks/release-doctor-cli-args.js +32 -0
- package/dist/checks/release-doctor-cli.js +8 -80
- 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 +89 -16
- 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
|
```
|
|
@@ -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,6 +1,5 @@
|
|
|
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;
|
|
@@ -145,7 +144,7 @@ async function nearestNodePackage(packageDir, root) {
|
|
|
145
144
|
}
|
|
146
145
|
return null;
|
|
147
146
|
}
|
|
148
|
-
async function reactNativeIapEvidence(root, manifestFile, manifestText) {
|
|
147
|
+
async function reactNativeIapEvidence(root, manifestFile, manifestText, resolveTransitive) {
|
|
149
148
|
let manifest;
|
|
150
149
|
try {
|
|
151
150
|
manifest = JSON.parse(manifestText);
|
|
@@ -211,20 +210,24 @@ async function reactNativeIapEvidence(root, manifestFile, manifestText) {
|
|
|
211
210
|
};
|
|
212
211
|
}
|
|
213
212
|
const coordinate = `io.github.hyochan.openiap:openiap-google:${openIapVersion}`;
|
|
213
|
+
if (!resolveTransitive) {
|
|
214
|
+
return {
|
|
215
|
+
file: relativeManifest,
|
|
216
|
+
module: 'com.android.billingclient:billing',
|
|
217
|
+
expression: `react-native-iap ${declaredVersion} -> ${coordinate}; transitive lookup unavailable in repository-only mode`,
|
|
218
|
+
source: 'unresolved',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
214
221
|
try {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
expression: `react-native-iap ${declaredVersion} -> ${coordinate}`,
|
|
225
|
-
source: 'transitive',
|
|
226
|
-
};
|
|
227
|
-
}
|
|
222
|
+
const resolved = await resolveTransitive(openIapVersion);
|
|
223
|
+
if (resolved) {
|
|
224
|
+
return {
|
|
225
|
+
file: relativeManifest,
|
|
226
|
+
module: resolved.module,
|
|
227
|
+
version: resolved.version,
|
|
228
|
+
expression: `react-native-iap ${declaredVersion} -> ${coordinate}`,
|
|
229
|
+
source: 'transitive',
|
|
230
|
+
};
|
|
228
231
|
}
|
|
229
232
|
}
|
|
230
233
|
catch {
|
|
@@ -264,7 +267,7 @@ function isWithin(scope, file) {
|
|
|
264
267
|
const relative = path.relative(scope, file);
|
|
265
268
|
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
266
269
|
}
|
|
267
|
-
export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
270
|
+
export async function checkBillingCompliance(projectPath, now = new Date(), options = {}) {
|
|
268
271
|
const root = path.resolve(projectPath);
|
|
269
272
|
let rootStat;
|
|
270
273
|
try {
|
|
@@ -301,7 +304,7 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
301
304
|
for (const [file, text] of texts) {
|
|
302
305
|
if (path.basename(file) !== 'package.json')
|
|
303
306
|
continue;
|
|
304
|
-
const transitive = await reactNativeIapEvidence(root, file, text);
|
|
307
|
+
const transitive = await reactNativeIapEvidence(root, file, text, options.resolveTransitive);
|
|
305
308
|
if (transitive && !evidence.some((row) => row.expression === transitive.expression))
|
|
306
309
|
evidence.push(transitive);
|
|
307
310
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,92 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { resolveLang } from '../lib/lang.js';
|
|
3
3
|
import { scanReleaseDoctor } from './release-doctor.js';
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
}
|
|
4
|
+
import { parseReleaseDoctorArgs, RELEASE_DOCTOR_USAGE } from './release-doctor-cli-args.js';
|
|
5
|
+
import { renderReleaseDoctor } from './release-doctor-render.js';
|
|
66
6
|
async function main() {
|
|
67
|
-
const args =
|
|
7
|
+
const args = parseReleaseDoctorArgs(process.argv.slice(2));
|
|
8
|
+
if (args.help) {
|
|
9
|
+
process.stdout.write(`${RELEASE_DOCTOR_USAGE}\n`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
68
12
|
const report = await scanReleaseDoctor(args.projectPath);
|
|
69
13
|
if (args.json) {
|
|
70
14
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
71
15
|
}
|
|
72
16
|
else {
|
|
73
|
-
|
|
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'));
|
|
17
|
+
process.stdout.write(renderReleaseDoctor(report, resolveLang()));
|
|
90
18
|
}
|
|
91
19
|
if (args.failOnBlocker && report.counts.blocker > 0)
|
|
92
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
|
+
}
|
|
@@ -76,10 +76,15 @@ function parseExpo(files) {
|
|
|
76
76
|
const platforms = new Set();
|
|
77
77
|
let detected = false;
|
|
78
78
|
for (const file of files.filter((candidate) => /(?:^|\/)app(?:\.config)?\.(?:json|js|cjs|mjs|ts)$/.test(candidate.relative))) {
|
|
79
|
-
detected = true;
|
|
80
79
|
try {
|
|
81
80
|
const json = JSON.parse(file.text);
|
|
82
|
-
const
|
|
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;
|
|
83
88
|
if (typeof expo.android?.package === 'string')
|
|
84
89
|
androidPackageNames.push(expo.android.package);
|
|
85
90
|
if (typeof expo.ios?.bundleIdentifier === 'string')
|
|
@@ -94,6 +99,8 @@ function parseExpo(files) {
|
|
|
94
99
|
// Dynamic Expo configs are common. Resolve only obvious literal identifiers and keep the rest as warnings.
|
|
95
100
|
const android = file.text.match(/\bandroid\s*:\s*\{[\s\S]{0,3000}?\bpackage\s*:\s*['"]([^'"]+)['"]/);
|
|
96
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;
|
|
97
104
|
if (android?.[1])
|
|
98
105
|
androidPackageNames.push(android[1]);
|
|
99
106
|
if (ios?.[1])
|
|
@@ -118,42 +125,49 @@ function detectProject(files) {
|
|
|
118
125
|
const gradleFiles = files.filter((file) => /build\.gradle(?:\.kts)?$/.test(file.relative));
|
|
119
126
|
const pbxFiles = files.filter((file) => file.relative.endsWith('project.pbxproj'));
|
|
120
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);
|
|
121
134
|
const androidPackageNames = [...expo.androidPackageNames];
|
|
122
|
-
for (const file of
|
|
135
|
+
for (const file of androidAppGradleFiles) {
|
|
123
136
|
for (const match of file.text.matchAll(/\bapplicationId\s*(?:=\s*)?["']([^"']+)["']/g)) {
|
|
124
137
|
androidPackageNames.push(match[1]);
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
140
|
const iosBundleIds = [...expo.iosBundleIds];
|
|
128
|
-
for (const file of
|
|
141
|
+
for (const file of iosPbxFiles) {
|
|
129
142
|
for (const match of file.text.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;]+);/g)) {
|
|
130
143
|
const value = match[1].trim().replace(/^["']|["']$/g, '');
|
|
131
|
-
if (value && !value.includes('$'))
|
|
144
|
+
if (value && !value.includes('$') && !/(?:^|\.)(?:Tests?|UITests?|RunnerTests)$/i.test(value)) {
|
|
132
145
|
iosBundleIds.push(value);
|
|
146
|
+
}
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
|
-
for (const file of
|
|
149
|
+
for (const file of iosPlistFiles) {
|
|
136
150
|
const match = file.text.match(/<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/);
|
|
137
151
|
if (match?.[1] && !match[1].includes('$'))
|
|
138
152
|
iosBundleIds.push(match[1]);
|
|
139
153
|
}
|
|
140
154
|
const expoTargetsAndroid = expo.detected && (expo.platforms.size === 0 || expo.platforms.has('android'));
|
|
141
155
|
const expoTargetsIos = expo.detected && (expo.platforms.size === 0 || expo.platforms.has('ios'));
|
|
142
|
-
const android =
|
|
156
|
+
const android = androidAppGradleFiles.length > 0
|
|
143
157
|
|| expo.androidPackageNames.length > 0
|
|
144
158
|
|| expoTargetsAndroid
|
|
145
|
-
||
|
|
146
|
-
const ios =
|
|
147
|
-
||
|
|
159
|
+
|| androidAppManifestFiles.length > 0;
|
|
160
|
+
const ios = iosPbxFiles.length > 0
|
|
161
|
+
|| iosPlistFiles.some((file) => /(?:^|\/)ios\//.test(file.relative))
|
|
148
162
|
|| expo.iosBundleIds.length > 0
|
|
149
|
-
|| expoTargetsIos
|
|
150
|
-
|
|
163
|
+
|| expoTargetsIos;
|
|
164
|
+
const androidGradleFiles = gradleFiles.filter((file) => androidAppGradleFiles.includes(file) || /(?:^|\/)android\//.test(file.relative));
|
|
151
165
|
return {
|
|
152
166
|
android,
|
|
153
167
|
ios,
|
|
154
168
|
androidPackageNames: unique(androidPackageNames),
|
|
155
169
|
iosBundleIds: unique(iosBundleIds),
|
|
156
|
-
gradleFiles,
|
|
170
|
+
gradleFiles: [...androidGradleFiles, ...versionCatalogFiles],
|
|
157
171
|
};
|
|
158
172
|
}
|
|
159
173
|
function targetPolicy(now) {
|
|
@@ -169,12 +183,39 @@ function targetPolicy(now) {
|
|
|
169
183
|
function targetSdkFindings(gradleFiles, now) {
|
|
170
184
|
const evidence = [];
|
|
171
185
|
let hasUnresolvedExpression = false;
|
|
172
|
-
|
|
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))) {
|
|
173
204
|
for (const match of file.text.matchAll(/\btargetSdk(?:Version)?\s*(?:=\s*)?(\d+)/g)) {
|
|
174
205
|
evidence.push({ file: file.relative, value: Number.parseInt(match[1], 10) });
|
|
175
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
|
+
}
|
|
176
212
|
if (/\btargetSdk(?:Version)?\b/.test(file.text) && !/\btargetSdk(?:Version)?\s*(?:=\s*)?\d+/.test(file.text)) {
|
|
177
|
-
|
|
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;
|
|
178
219
|
}
|
|
179
220
|
}
|
|
180
221
|
const policy = targetPolicy(now);
|
|
@@ -247,7 +288,11 @@ function targetSdkFindings(gradleFiles, now) {
|
|
|
247
288
|
function hasSpecializedAndroidProfile(files) {
|
|
248
289
|
return files
|
|
249
290
|
.filter((file) => file.relative.endsWith('AndroidManifest.xml'))
|
|
250
|
-
.some((file) =>
|
|
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
|
+
});
|
|
251
296
|
}
|
|
252
297
|
export async function scanReleaseDoctor(projectPath, now = new Date()) {
|
|
253
298
|
const root = path.resolve(projectPath);
|
|
@@ -308,6 +353,20 @@ export async function scanReleaseDoctor(projectPath, now = new Date()) {
|
|
|
308
353
|
detail: detected.androidPackageNames.join(', '),
|
|
309
354
|
},
|
|
310
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
|
+
}
|
|
311
370
|
}
|
|
312
371
|
if (hasSpecializedAndroidProfile(files)) {
|
|
313
372
|
findings.push({
|
|
@@ -380,6 +439,20 @@ export async function scanReleaseDoctor(projectPath, now = new Date()) {
|
|
|
380
439
|
detail: detected.iosBundleIds.join(', '),
|
|
381
440
|
},
|
|
382
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
|
+
}
|
|
383
456
|
}
|
|
384
457
|
}
|
|
385
458
|
const counts = findings.reduce((result, finding) => ({ ...result, [finding.severity]: result[finding.severity] + 1 }), { blocker: 0, warning: 0, info: 0 });
|
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.10",
|
|
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": {
|