@yoonion/mimi-seed-mcp 0.19.8 → 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.
@@ -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,20 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { resolveLang } from '../lib/lang.js';
3
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
- }
4
+ import { parseReleaseDoctorArgs, RELEASE_DOCTOR_USAGE } from './release-doctor-cli-args.js';
18
5
  const COPY = {
19
6
  ko: {
20
7
  title: 'Mimi Seed Release Doctor — 로그인 없는 출시 점검',
@@ -64,7 +51,11 @@ function renderFinding(finding, copy, lang) {
64
51
  return lines;
65
52
  }
66
53
  async function main() {
67
- const args = parseArgs(process.argv.slice(2));
54
+ const args = parseReleaseDoctorArgs(process.argv.slice(2));
55
+ if (args.help) {
56
+ process.stdout.write(`${RELEASE_DOCTOR_USAGE}\n`);
57
+ return;
58
+ }
68
59
  const report = await scanReleaseDoctor(args.projectPath);
69
60
  if (args.json) {
70
61
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
@@ -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 expo = ((json.expo && typeof json.expo === 'object') ? json.expo : json);
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 gradleFiles) {
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 pbxFiles) {
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 plistFiles) {
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 = gradleFiles.length > 0
156
+ const android = androidAppGradleFiles.length > 0
143
157
  || expo.androidPackageNames.length > 0
144
158
  || expoTargetsAndroid
145
- || files.some((file) => /(?:^|\/)android\//.test(file.relative));
146
- const ios = pbxFiles.length > 0
147
- || plistFiles.length > 0
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
- || files.some((file) => /(?:^|\/)ios\//.test(file.relative));
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
- for (const file of gradleFiles) {
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
- hasUnresolvedExpression = true;
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) => /android\.hardware\.type\.(?:watch|automotive)|android\.software\.(?:leanback|xr)|LEANBACK_LAUNCHER/i.test(file.text));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.19.8",
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": {