@yoonion/mimi-seed-mcp 0.19.0 → 0.19.1

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
@@ -79,9 +79,9 @@ export ANTHROPIC_API_KEY=sk-ant-...
79
79
 
80
80
  | 영역 | 도구 수 | 주요 도구 |
81
81
  |------|---------|-----------|
82
- | App Store Connect | 63 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
82
+ | App Store Connect | 64 | `appstore_submit_for_review` / `appstore_get_weekly_insight` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
83
83
  | Google Play | 39 | `playstore_submit_release` / `playstore_promote_release` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
84
- | Firebase | 20 | `firebase_create_project` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_create_ios_app` |
84
+ | Firebase | 21 | `firebase_create_project` / `firebase_get_remote_config_overview` / `firebase_get_android_config` / `firebase_create_ios_app` |
85
85
  | AdMob | 7 | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
86
86
  | CI/CD (GitHub Actions · GitLab) | 6 | `ci_trigger_build` / `ci_get_build_status` / `ci_list_workflows` / `ci_cancel_build` |
87
87
  | Jenkins (크리덴셜 + 잡) | 10 | `jenkins_create_credential` / `jenkins_upload_keystore` / `jenkins_create_job` / `jenkins_update_job` |
@@ -94,7 +94,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
94
94
  | BigQuery | 5 | `bigquery_run_query` / `bigquery_list_datasets` / `bigquery_get_table_schema` |
95
95
  | Threads | 7 | `threads_post` / `threads_post_video` / `threads_post_carousel` / `threads_refresh_token` |
96
96
  | TikTok Business | 7 | `tiktok_business_plan_video_post` / `tiktok_business_publish_video` / `tiktok_business_get_publish_status` |
97
- | 점검 / 위험 | 4 | `playstore_check_submission_risks` / `appstore_check_submission_risks` / `screenshot_validate` / `release_status` |
97
+ | 점검 / 위험 | 5 | `playstore_check_submission_risks` / `appstore_check_submission_risks` / `android_check_billing_compliance` / `screenshot_validate` / `release_status` |
98
98
  | Instagram | 4 | `instagram_post_image` / `instagram_post_carousel` / `instagram_save_config` |
99
99
  | Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
100
100
  | 인증 | 4 | `mimi_seed_status` / `mimi_seed_auth_start` / `mimi_seed_auth_status` / `mimi_seed_remote_sync_credentials` |
@@ -49,6 +49,7 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
49
49
  |------|--------------------|
50
50
  | First contact / "what's connected?" | `select:mimi_seed_status,mimi_seed_auth_status,mimi_seed_auth_start,mimi_seed_remote_sync_credentials` |
51
51
  | Release readiness (either store) | `select:release_status,playstore_check_submission_risks,appstore_check_submission_risks,screenshot_validate` |
52
+ | Android Billing compliance | `select:android_check_billing_compliance,playstore_check_submission_risks` |
52
53
  | Play Store release | `select:playstore_get_app,playstore_list_tracks,playstore_update_release_notes,playstore_update_latest_release_notes,playstore_promote_release,playstore_submit_release,playstore_check_submission_risks,playstore_plan_release` |
53
54
  | Play Store listing + images | `select:playstore_get_listing,playstore_update_listing,playstore_update_details,playstore_upload_image,playstore_list_images,playstore_replace_images,playstore_delete_all_images` |
54
55
  | Play Store reviews + stats | `select:playstore_list_reviews,playstore_reply_review,playstore_get_statistics,generate_review_reply` |
@@ -67,10 +68,12 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
67
68
  | Release notes from commits | `select:generate_release_notes_from_commits,playstore_update_release_notes,appstore_update_whats_new` |
68
69
  | Firebase setup | `select:firebase_list_projects,firebase_get_project,firebase_create_project,firebase_create_android_app,firebase_create_ios_app,firebase_get_android_config,firebase_get_ios_config,firebase_enable_common_services` |
69
70
  | Firebase apps + services (incl. web) | `select:firebase_list_android_apps,firebase_list_ios_apps,firebase_list_web_apps,firebase_create_web_app,firebase_get_web_config,firebase_enable_service,firebase_list_enabled_services,firebase_delete_android_app,firebase_delete_ios_app,firebase_delete_web_app` |
71
+ | Remote Config usage + experiments | `select:firebase_get_remote_config_overview,gcp_get_billing_info` |
70
72
  | Analytics wiring (Firebase ↔ GA4 ↔ BigQuery) | `select:firebase_link_analytics,firebase_get_analytics_details,ga4_list_account_summaries,ga4_list_properties,ga4_create_property,ga4_list_data_streams,ga4_create_data_stream,ga4_plan_bigquery_link,ga4_create_bigquery_link,ga4_run_report` |
71
73
  | BigQuery | `select:bigquery_auth_status,bigquery_list_datasets,bigquery_list_tables,bigquery_get_table_schema,bigquery_run_query` |
72
74
  | GCP billing (Blaze 여부 · 비용 범위 · 예산) | `select:gcp_get_billing_info,gcp_list_billing_projects,gcp_list_budgets,gcp_create_budget` |
73
75
  | Real revenue (스토어 정산 원장 — sandbox·테스터가 섞이지 않는 유일한 창구) | `select:appstore_get_sales_report,appstore_get_finance_report,playstore_list_financial_reports,playstore_get_financial_report` |
76
+ | App Store weekly growth insight | `select:appstore_get_weekly_insight,appstore_get_sales_report` |
74
77
  | AdMob | `select:admob_list_accounts,admob_list_apps,admob_create_app,admob_create_ad_unit,admob_list_ad_units,admob_get_today_earnings,admob_get_report` |
75
78
  | Google Ads (UAC) | `select:googleads_config_status,googleads_save_config,googleads_list_accessible_customers,googleads_list_campaigns,googleads_get_campaign_report,googleads_get_uac_report` |
76
79
  | Search Console | `select:gsc_list_sites,gsc_list_sitemaps,gsc_get_sitemap,gsc_submit_sitemap,gsc_inspect_url,gsc_search_analytics` |
@@ -176,8 +179,8 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
176
179
  | Domain | Representative tools |
177
180
  |--------|----------------------|
178
181
  | **Google Play** | `playstore_get_app` · `playstore_get_listing` · `playstore_update_listing` · `playstore_list_tracks` · `playstore_update_latest_release_notes` · `playstore_promote_release` · `playstore_submit_release` · `playstore_upload_image` · `playstore_replace_images` · `playstore_check_submission_risks` · `playstore_get_statistics` · `playstore_reply_review` · `playstore_register_service_account` |
179
- | **App Store Connect** | `appstore_list_apps` · `appstore_list_versions` · `appstore_create_version` · `appstore_update_whats_new` · `appstore_update_localization` · `appstore_list_builds` · `appstore_attach_latest_build` · `appstore_upload_screenshot` · `appstore_submit_for_review` · `appstore_cancel_review` · `appstore_list_beta_groups` · `appstore_reply_review` |
180
- | **Firebase** | `firebase_create_project` · `firebase_create_android_app` · `firebase_create_ios_app` · `firebase_get_android_config` · `firebase_enable_service` · `firebase_enable_common_services` · `firebase_list_*_apps` |
182
+ | **App Store Connect** | `appstore_list_apps` · `appstore_list_versions` · `appstore_create_version` · `appstore_update_whats_new` · `appstore_update_localization` · `appstore_list_builds` · `appstore_attach_latest_build` · `appstore_upload_screenshot` · `appstore_submit_for_review` · `appstore_cancel_review` · `appstore_list_beta_groups` · `appstore_reply_review` · `appstore_get_weekly_insight` |
183
+ | **Firebase** | `firebase_create_project` · `firebase_create_android_app` · `firebase_create_ios_app` · `firebase_get_android_config` · `firebase_enable_service` · `firebase_enable_common_services` · `firebase_get_remote_config_overview` · `firebase_list_*_apps` |
181
184
  | **AdMob** | `admob_create_app` · `admob_create_ad_unit` · `admob_list_ad_units` · `admob_get_today_earnings` · `admob_get_report` |
182
185
  | **CI/CD** | `ci_trigger_build` · `ci_get_build_status` · `ci_list_workflows` (**GitHub Actions / GitLab only**) |
183
186
  | **Jenkins** (credentials + jobs) | `jenkins_status` · `jenkins_save_config` · `jenkins_create_credential` · `jenkins_upload_keystore` · `jenkins_upload_playstore_sa` · `jenkins_create_job` · `jenkins_update_job` |
@@ -189,7 +192,7 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
189
192
  | **Android signing** | `android_signing_setup` · `android_generate_keystore` · `jenkins_upload_playstore_sa` |
190
193
  | **Facebook / Instagram / Threads** | `facebook_post_photo` · `instagram_post_carousel` · `threads_post` · `threads_post_video` · `threads_refresh_token` |
191
194
  | **TikTok Business** | `tiktok_business_auth_status` · `tiktok_business_plan_video_post` · `tiktok_business_publish_video` · `tiktok_business_get_publish_status` |
192
- | **Checks** | `playstore_check_submission_risks` · `appstore_check_submission_risks` · `screenshot_validate` · `release_status` |
195
+ | **Checks** | `playstore_check_submission_risks` · `appstore_check_submission_risks` · `android_check_billing_compliance` · `screenshot_validate` · `release_status` |
193
196
  | **AI / Auth** | `generate_release_notes_from_commits` · `generate_review_reply` · `mimi_seed_status` · `mimi_seed_auth_start` · `mimi_seed_auth_status` · `mimi_seed_remote_sync_credentials` |
194
197
  | **Video production** | `youtube_upload_video` · `youtube_get_video_status` · `youtube_update_video_privacy` · `video_save_plan` · `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
195
198
 
@@ -0,0 +1,106 @@
1
+ import { type ReportRow } from './sales.js';
2
+ interface WeeklyMetrics {
3
+ date: string;
4
+ impressions: number;
5
+ productPageViews: number;
6
+ downloads: number;
7
+ proceeds: number;
8
+ }
9
+ export declare function buildWeeklyInsight(input: {
10
+ engagement: ReportRow[];
11
+ downloads: ReportRow[];
12
+ purchases: ReportRow[];
13
+ }): {
14
+ status: string;
15
+ weeks: WeeklyMetrics[];
16
+ recommendation: string;
17
+ current?: undefined;
18
+ previous?: undefined;
19
+ changes?: undefined;
20
+ insight?: undefined;
21
+ } | {
22
+ status: string;
23
+ current: WeeklyMetrics;
24
+ previous: WeeklyMetrics;
25
+ changes: {
26
+ impressions: number | null;
27
+ productPageViews: number | null;
28
+ downloads: number | null;
29
+ proceeds: number | null;
30
+ productPageRate: number | null;
31
+ downloadRate: number | null;
32
+ revenuePerDownload: number | null;
33
+ };
34
+ insight: {
35
+ area: string;
36
+ changePercent: number | null;
37
+ recommendation: string;
38
+ };
39
+ weeks?: undefined;
40
+ recommendation?: undefined;
41
+ };
42
+ export declare function getWeeklyInsight(input: {
43
+ appId: string;
44
+ createIfMissing?: boolean;
45
+ confirmCreate?: boolean;
46
+ }): Promise<{
47
+ status: string;
48
+ message: string;
49
+ nextAction: string;
50
+ requestId?: undefined;
51
+ } | {
52
+ status: string;
53
+ message: string;
54
+ nextAction?: undefined;
55
+ requestId?: undefined;
56
+ } | {
57
+ status: string;
58
+ requestId: string;
59
+ message: string;
60
+ nextAction?: undefined;
61
+ } | {
62
+ status: string;
63
+ weeks: WeeklyMetrics[];
64
+ recommendation: string;
65
+ current?: undefined;
66
+ previous?: undefined;
67
+ changes?: undefined;
68
+ insight?: undefined;
69
+ requestId: string;
70
+ reports: {
71
+ engagement: string | null;
72
+ downloads: string | null;
73
+ purchases: string | null;
74
+ };
75
+ message?: undefined;
76
+ nextAction?: undefined;
77
+ } | {
78
+ status: string;
79
+ current: WeeklyMetrics;
80
+ previous: WeeklyMetrics;
81
+ changes: {
82
+ impressions: number | null;
83
+ productPageViews: number | null;
84
+ downloads: number | null;
85
+ proceeds: number | null;
86
+ productPageRate: number | null;
87
+ downloadRate: number | null;
88
+ revenuePerDownload: number | null;
89
+ };
90
+ insight: {
91
+ area: string;
92
+ changePercent: number | null;
93
+ recommendation: string;
94
+ };
95
+ weeks?: undefined;
96
+ recommendation?: undefined;
97
+ requestId: string;
98
+ reports: {
99
+ engagement: string | null;
100
+ downloads: string | null;
101
+ purchases: string | null;
102
+ };
103
+ message?: undefined;
104
+ nextAction?: undefined;
105
+ }>;
106
+ export {};
@@ -0,0 +1,242 @@
1
+ import crypto from 'node:crypto';
2
+ import zlib from 'node:zlib';
3
+ import { getReportsAuthHeaders } from './auth.js';
4
+ import { V1_BASE, apiRequest, authHeadersOrThrow } from './http.js';
5
+ import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
6
+ import { parseTsv } from './sales.js';
7
+ const MAX_SEGMENT_BYTES = 50 * 1024 * 1024;
8
+ async function reportHeaders() {
9
+ const auth = await getReportsAuthHeaders();
10
+ if (!auth)
11
+ throw new Error('App Store Connect reportsKey 또는 기본 API 키가 필요합니다.');
12
+ return auth.headers;
13
+ }
14
+ async function reportGet(path, params) {
15
+ const query = params ? `?${new URLSearchParams(params).toString()}` : '';
16
+ return apiRequest(V1_BASE, `${path}${query}`, await reportHeaders(), { method: 'GET' });
17
+ }
18
+ async function createOngoingRequest(appId) {
19
+ const response = await apiRequest(V1_BASE, '/analyticsReportRequests', await authHeadersOrThrow(), {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body: JSON.stringify({
23
+ data: {
24
+ type: 'analyticsReportRequests',
25
+ attributes: { accessType: 'ONGOING' },
26
+ relationships: { app: { data: { type: 'apps', id: appId } } },
27
+ },
28
+ }),
29
+ });
30
+ return response.data;
31
+ }
32
+ async function downloadSegment(segment) {
33
+ const attributes = segment.attributes ?? {};
34
+ if (!attributes.url)
35
+ return [];
36
+ if ((attributes.sizeInBytes ?? 0) > MAX_SEGMENT_BYTES) {
37
+ throw new Error(`Analytics segment ${segment.id} is larger than the 50 MB safety limit.`);
38
+ }
39
+ const response = await fetchWithTimeout(attributes.url, {}, HTTP_TRANSFER_TIMEOUT_MS);
40
+ if (!response.ok)
41
+ throw new Error(`Analytics segment download failed (${response.status}).`);
42
+ const bytes = Buffer.from(await response.arrayBuffer());
43
+ if (attributes.checksum) {
44
+ const actual = crypto.createHash('md5').update(bytes).digest('hex');
45
+ if (actual !== attributes.checksum.toLowerCase()) {
46
+ throw new Error(`Analytics segment checksum mismatch for ${segment.id}.`);
47
+ }
48
+ }
49
+ let text;
50
+ try {
51
+ text = zlib.gunzipSync(bytes).toString('utf8');
52
+ }
53
+ catch {
54
+ text = bytes.toString('utf8');
55
+ }
56
+ return parseTsv(text);
57
+ }
58
+ async function rowsForReport(reportId) {
59
+ const instances = await reportGet(`/analyticsReports/${reportId}/instances`, { 'filter[granularity]': 'WEEKLY', limit: '200' });
60
+ const latest = (instances.data ?? [])
61
+ .filter((row) => row.attributes?.processingDate)
62
+ .sort((a, b) => (b.attributes?.processingDate ?? '').localeCompare(a.attributes?.processingDate ?? ''))
63
+ .slice(0, 2);
64
+ const allRows = [];
65
+ for (const instance of latest) {
66
+ const segments = await reportGet(`/analyticsReportInstances/${instance.id}/segments`, { limit: '200' });
67
+ for (const segment of segments.data ?? [])
68
+ allRows.push(...await downloadSegment(segment));
69
+ }
70
+ return allRows;
71
+ }
72
+ function numberFrom(row, names) {
73
+ for (const name of names) {
74
+ const raw = row[name];
75
+ if (raw === undefined || raw === '')
76
+ continue;
77
+ const value = Number.parseFloat(raw.replace(/,/g, ''));
78
+ if (Number.isFinite(value))
79
+ return value;
80
+ }
81
+ return 0;
82
+ }
83
+ function dateOf(row) {
84
+ return row.Date || row['Start Date'] || row['Week Start Date'] || null;
85
+ }
86
+ function ensureWeek(map, date) {
87
+ let week = map.get(date);
88
+ if (!week) {
89
+ week = { date, impressions: 0, productPageViews: 0, downloads: 0, proceeds: 0 };
90
+ map.set(date, week);
91
+ }
92
+ return week;
93
+ }
94
+ function percentageChange(current, previous) {
95
+ if (previous <= 0)
96
+ return null;
97
+ return ((current - previous) / previous) * 100;
98
+ }
99
+ export function buildWeeklyInsight(input) {
100
+ const weeks = new Map();
101
+ for (const row of input.engagement) {
102
+ const date = dateOf(row);
103
+ if (!date)
104
+ continue;
105
+ const week = ensureWeek(weeks, date);
106
+ const event = (row.Event ?? '').toLowerCase();
107
+ const pageType = (row['Page Type'] ?? '').toLowerCase();
108
+ const count = numberFrom(row, ['Counts', 'Count']);
109
+ if (event === 'impression')
110
+ week.impressions += count;
111
+ if (event === 'page view' && (pageType === 'product page' || !pageType))
112
+ week.productPageViews += count;
113
+ }
114
+ for (const row of input.downloads) {
115
+ const date = dateOf(row);
116
+ if (!date)
117
+ continue;
118
+ ensureWeek(weeks, date).downloads += numberFrom(row, ['Counts', 'Count', 'Downloads', 'Units']);
119
+ }
120
+ for (const row of input.purchases) {
121
+ const date = dateOf(row);
122
+ if (!date)
123
+ continue;
124
+ ensureWeek(weeks, date).proceeds += numberFrom(row, [
125
+ 'Developer Proceeds',
126
+ 'Proceeds',
127
+ 'Estimated Proceeds',
128
+ 'Sales',
129
+ ]);
130
+ }
131
+ const ordered = [...weeks.values()].sort((a, b) => a.date.localeCompare(b.date));
132
+ if (ordered.length < 2) {
133
+ return {
134
+ status: 'collecting',
135
+ weeks: ordered,
136
+ recommendation: 'Analytics 주간 데이터가 두 기간 이상 쌓인 뒤 다시 실행하세요.',
137
+ };
138
+ }
139
+ const previous = ordered.at(-2);
140
+ const current = ordered.at(-1);
141
+ const previousPageRate = previous.impressions > 0 ? previous.productPageViews / previous.impressions : 0;
142
+ const currentPageRate = current.impressions > 0 ? current.productPageViews / current.impressions : 0;
143
+ const previousDownloadRate = previous.productPageViews > 0 ? previous.downloads / previous.productPageViews : 0;
144
+ const currentDownloadRate = current.productPageViews > 0 ? current.downloads / current.productPageViews : 0;
145
+ const previousRevenuePerDownload = previous.downloads > 0 ? previous.proceeds / previous.downloads : 0;
146
+ const currentRevenuePerDownload = current.downloads > 0 ? current.proceeds / current.downloads : 0;
147
+ const candidates = [
148
+ {
149
+ area: 'product_page',
150
+ changePercent: percentageChange(currentPageRate, previousPageRate),
151
+ recommendation: '제품 페이지 진입률이 하락했습니다. 아이콘·스크린샷 첫 장·부제 중 하나를 바꿔 Product Page Optimization 테스트를 시작하세요.',
152
+ },
153
+ {
154
+ area: 'acquisition',
155
+ changePercent: percentageChange(currentDownloadRate, previousDownloadRate),
156
+ recommendation: '제품 페이지 조회 대비 다운로드 전환이 하락했습니다. 유입 소스별 전환을 나누고 가장 큰 하락 소스에 맞춘 커스텀 제품 페이지를 만드세요.',
157
+ },
158
+ {
159
+ area: 'monetization',
160
+ changePercent: percentageChange(currentRevenuePerDownload, previousRevenuePerDownload),
161
+ recommendation: '다운로드당 수익이 하락했습니다. 구매 리포트에서 상품별 하락을 확인하고 가격·오퍼·구독 전환 중 한 가지를 실험하세요.',
162
+ },
163
+ ].filter((candidate) => candidate.changePercent !== null)
164
+ .sort((a, b) => (a.changePercent ?? 0) - (b.changePercent ?? 0));
165
+ const selected = candidates[0];
166
+ return {
167
+ status: 'ready',
168
+ current,
169
+ previous,
170
+ changes: {
171
+ impressions: percentageChange(current.impressions, previous.impressions),
172
+ productPageViews: percentageChange(current.productPageViews, previous.productPageViews),
173
+ downloads: percentageChange(current.downloads, previous.downloads),
174
+ proceeds: percentageChange(current.proceeds, previous.proceeds),
175
+ productPageRate: percentageChange(currentPageRate, previousPageRate),
176
+ downloadRate: percentageChange(currentDownloadRate, previousDownloadRate),
177
+ revenuePerDownload: percentageChange(currentRevenuePerDownload, previousRevenuePerDownload),
178
+ },
179
+ insight: selected
180
+ ? {
181
+ area: selected.area,
182
+ changePercent: selected.changePercent,
183
+ recommendation: selected.recommendation,
184
+ }
185
+ : {
186
+ area: 'data_quality',
187
+ changePercent: null,
188
+ recommendation: '비교 가능한 분모 데이터가 부족합니다. 표준 Engagement·Downloads·Purchases 리포트가 생성되는지 확인하세요.',
189
+ },
190
+ };
191
+ }
192
+ export async function getWeeklyInsight(input) {
193
+ const requests = await reportGet(`/apps/${input.appId}/analyticsReportRequests`, { 'filter[accessType]': 'ONGOING', limit: '10' });
194
+ const active = (requests.data ?? []).find((row) => !row.attributes?.stoppedDueToInactivity);
195
+ if (!active) {
196
+ if (!input.createIfMissing) {
197
+ return {
198
+ status: 'setup_required',
199
+ message: 'ONGOING Analytics report request가 없습니다.',
200
+ nextAction: 'createIfMissing=true와 confirmCreate=true로 다시 호출하세요.',
201
+ };
202
+ }
203
+ if (!input.confirmCreate) {
204
+ return {
205
+ status: 'confirmation_required',
206
+ message: 'Analytics report request 생성은 App Store Connect에 지속 리소스를 만듭니다.',
207
+ };
208
+ }
209
+ const created = await createOngoingRequest(input.appId);
210
+ return {
211
+ status: 'provisioning',
212
+ requestId: created.id,
213
+ message: 'ONGOING Analytics report request를 만들었습니다. 최초 데이터는 보통 24~48시간 뒤 생성됩니다.',
214
+ };
215
+ }
216
+ const reports = await reportGet(`/analyticsReportRequests/${active.id}/reports`, { limit: '200' });
217
+ const standard = (reports.data ?? []).filter((row) => !/Detailed/i.test(row.attributes?.name ?? ''));
218
+ const engagement = standard.find((row) => /Discovery and Engagement/i.test(row.attributes?.name ?? ''));
219
+ const downloads = standard.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? ''));
220
+ const purchases = standard.find((row) => /App Store Purchases/i.test(row.attributes?.name ?? ''));
221
+ if (!engagement && !downloads && !purchases) {
222
+ return {
223
+ status: 'collecting',
224
+ requestId: active.id,
225
+ message: '요청은 활성 상태지만 주간 Engagement·Downloads·Purchases 리포트가 아직 생성되지 않았습니다.',
226
+ };
227
+ }
228
+ const [engagementRows, downloadRows, purchaseRows] = await Promise.all([
229
+ engagement ? rowsForReport(engagement.id) : [],
230
+ downloads ? rowsForReport(downloads.id) : [],
231
+ purchases ? rowsForReport(purchases.id) : [],
232
+ ]);
233
+ return {
234
+ requestId: active.id,
235
+ reports: {
236
+ engagement: engagement?.attributes?.name ?? null,
237
+ downloads: downloads?.attributes?.name ?? null,
238
+ purchases: purchases?.attributes?.name ?? null,
239
+ },
240
+ ...buildWeeklyInsight({ engagement: engagementRows, downloads: downloadRows, purchases: purchaseRows }),
241
+ };
242
+ }
@@ -0,0 +1,30 @@
1
+ export type BillingComplianceStatus = 'pass' | 'warning' | 'blocker' | 'unresolved' | 'not_used';
2
+ export interface BillingEvidence {
3
+ file: string;
4
+ module: string;
5
+ version?: string;
6
+ expression?: string;
7
+ source: 'literal' | 'variable' | 'version_catalog' | 'unresolved';
8
+ }
9
+ export interface BillingComplianceResult {
10
+ projectPath: string;
11
+ checkedAt: string;
12
+ status: BillingComplianceStatus;
13
+ detectedVersions: string[];
14
+ evidence: BillingEvidence[];
15
+ policy: {
16
+ minimumSupportedMajor: number;
17
+ submissionDeadline: string;
18
+ extensionDeadline: string;
19
+ latestKnownMajor: number;
20
+ sourceUrl: string;
21
+ };
22
+ summary: string;
23
+ actions: string[];
24
+ upgrade: {
25
+ installCommand: string;
26
+ prompt: string;
27
+ automaticExecution: false;
28
+ };
29
+ }
30
+ export declare function checkBillingCompliance(projectPath: string, now?: Date): Promise<BillingComplianceResult>;
@@ -0,0 +1,225 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const BILLING_MODULE = /com\.android\.billingclient:billing(?:-ktx)?/;
4
+ const LITERAL_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})/g;
5
+ const VARIABLE_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:\$\{?([A-Za-z_][A-Za-z0-9_.-]*)\}?/g;
6
+ const VERSION_ASSIGNMENT = /(?:^|\s)([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:=|:)\s*["']([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})["']/gm;
7
+ const SKIP_DIRS = new Set([
8
+ '.git',
9
+ '.gradle',
10
+ '.idea',
11
+ 'build',
12
+ 'dist',
13
+ 'node_modules',
14
+ 'Pods',
15
+ 'DerivedData',
16
+ ]);
17
+ async function walk(root, maxDepth = 7) {
18
+ const result = [];
19
+ async function visit(dir, depth) {
20
+ if (depth > maxDepth)
21
+ return;
22
+ let entries;
23
+ try {
24
+ entries = await fs.readdir(dir, { withFileTypes: true });
25
+ }
26
+ catch {
27
+ return;
28
+ }
29
+ for (const entry of entries) {
30
+ if (entry.isDirectory()) {
31
+ if (!SKIP_DIRS.has(entry.name))
32
+ await visit(path.join(dir, entry.name), depth + 1);
33
+ }
34
+ else if (entry.isFile()
35
+ && (entry.name === 'build.gradle'
36
+ || entry.name === 'build.gradle.kts'
37
+ || entry.name === 'libs.versions.toml')) {
38
+ result.push(path.join(dir, entry.name));
39
+ }
40
+ }
41
+ }
42
+ await visit(root, 0);
43
+ return result;
44
+ }
45
+ function parseCatalog(text) {
46
+ const versions = new Map();
47
+ const libraries = new Map();
48
+ let section = '';
49
+ for (const rawLine of text.split(/\r?\n/)) {
50
+ const line = rawLine.replace(/\s+#.*$/, '').trim();
51
+ if (!line)
52
+ continue;
53
+ const sectionMatch = line.match(/^\[([^\]]+)]$/);
54
+ if (sectionMatch) {
55
+ section = sectionMatch[1];
56
+ continue;
57
+ }
58
+ if (section === 'versions') {
59
+ const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*["']([^"']+)["']/);
60
+ if (match)
61
+ versions.set(match[1], match[2]);
62
+ continue;
63
+ }
64
+ if (section !== 'libraries')
65
+ continue;
66
+ const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*\{(.+)}\s*$/);
67
+ if (!match)
68
+ continue;
69
+ const body = match[2];
70
+ const module = body.match(/module\s*=\s*["']([^"']+)["']/)?.[1];
71
+ const version = body.match(/(?:^|,)\s*version\s*=\s*["']([^"']+)["']/)?.[1];
72
+ const versionRef = body.match(/version\.ref\s*=\s*["']([^"']+)["']/)?.[1];
73
+ libraries.set(match[1], { module, version, versionRef });
74
+ }
75
+ return { versions, libraries };
76
+ }
77
+ function collectVariables(text) {
78
+ const result = new Map();
79
+ for (const match of text.matchAll(VERSION_ASSIGNMENT))
80
+ result.set(match[1], match[2]);
81
+ return result;
82
+ }
83
+ function normalizeAlias(alias) {
84
+ return alias.replace(/^libs\./, '').replace(/\./g, '-');
85
+ }
86
+ function majorOf(version) {
87
+ const major = Number.parseInt(version.split('.')[0], 10);
88
+ return Number.isFinite(major) ? major : null;
89
+ }
90
+ function policyAt(now) {
91
+ const currentYear = now.getUTCFullYear();
92
+ const deadlineThisYear = new Date(Date.UTC(currentYear, 7, 31, 23, 59, 59));
93
+ const minimumSupportedMajor = now > deadlineThisYear ? currentYear - 2018 : currentYear - 2019;
94
+ const unsupportedMajor = minimumSupportedMajor - 1;
95
+ return {
96
+ minimumSupportedMajor,
97
+ submissionDeadline: `${unsupportedMajor + 2019}-08-31`,
98
+ extensionDeadline: `${unsupportedMajor + 2019}-11-01`,
99
+ latestKnownMajor: Math.max(9, minimumSupportedMajor + 1),
100
+ sourceUrl: 'https://developer.android.com/google/play/billing/deprecation-faq',
101
+ };
102
+ }
103
+ export async function checkBillingCompliance(projectPath, now = new Date()) {
104
+ const root = path.resolve(projectPath);
105
+ const files = await walk(root);
106
+ const texts = new Map();
107
+ for (const file of files)
108
+ texts.set(file, await fs.readFile(file, 'utf8'));
109
+ const catalogFile = files.find((file) => path.basename(file) === 'libs.versions.toml');
110
+ const catalog = catalogFile ? parseCatalog(texts.get(catalogFile) ?? '') : { versions: new Map(), libraries: new Map() };
111
+ const allVariables = new Map();
112
+ for (const [file, text] of texts) {
113
+ if (path.basename(file) === 'libs.versions.toml')
114
+ continue;
115
+ for (const [key, value] of collectVariables(text))
116
+ allVariables.set(key, value);
117
+ }
118
+ const evidence = [];
119
+ for (const [file, text] of texts) {
120
+ const relative = path.relative(root, file).replace(/\\/g, '/');
121
+ if (path.basename(file) === 'libs.versions.toml') {
122
+ for (const [alias, lib] of catalog.libraries) {
123
+ if (!lib.module || !BILLING_MODULE.test(lib.module))
124
+ continue;
125
+ const version = lib.version ?? (lib.versionRef ? catalog.versions.get(lib.versionRef) : undefined);
126
+ evidence.push({
127
+ file: relative,
128
+ module: lib.module,
129
+ version,
130
+ expression: `libs.${alias.replace(/-/g, '.')}`,
131
+ source: version ? 'version_catalog' : 'unresolved',
132
+ });
133
+ }
134
+ continue;
135
+ }
136
+ for (const match of text.matchAll(LITERAL_DEPENDENCY)) {
137
+ evidence.push({
138
+ file: relative,
139
+ module: match[0].slice(0, match[0].lastIndexOf(':')),
140
+ version: match[1],
141
+ source: 'literal',
142
+ });
143
+ }
144
+ for (const match of text.matchAll(VARIABLE_DEPENDENCY)) {
145
+ const version = allVariables.get(match[1]);
146
+ evidence.push({
147
+ file: relative,
148
+ module: match[0].slice(0, match[0].lastIndexOf(':')),
149
+ version,
150
+ expression: match[1],
151
+ source: version ? 'variable' : 'unresolved',
152
+ });
153
+ }
154
+ for (const aliasMatch of text.matchAll(/\blibs\.([A-Za-z0-9_.-]+)/g)) {
155
+ const alias = normalizeAlias(aliasMatch[1]);
156
+ const lib = catalog.libraries.get(alias) ?? catalog.libraries.get(aliasMatch[1]);
157
+ if (!lib?.module || !BILLING_MODULE.test(lib.module))
158
+ continue;
159
+ const version = lib.version ?? (lib.versionRef ? catalog.versions.get(lib.versionRef) : undefined);
160
+ if (!evidence.some((row) => row.file === relative && row.expression === aliasMatch[0])) {
161
+ evidence.push({
162
+ file: relative,
163
+ module: lib.module,
164
+ version,
165
+ expression: aliasMatch[0],
166
+ source: version ? 'version_catalog' : 'unresolved',
167
+ });
168
+ }
169
+ }
170
+ if (BILLING_MODULE.test(text) && !evidence.some((row) => row.file === relative)) {
171
+ evidence.push({
172
+ file: relative,
173
+ module: 'com.android.billingclient:billing',
174
+ expression: 'Billing dependency found but version could not be resolved',
175
+ source: 'unresolved',
176
+ });
177
+ }
178
+ }
179
+ const detectedVersions = [...new Set(evidence.flatMap((row) => row.version ? [row.version] : []))].sort();
180
+ const policy = policyAt(now);
181
+ const majors = detectedVersions.map(majorOf).filter((major) => major !== null);
182
+ const unresolved = evidence.some((row) => !row.version);
183
+ let status;
184
+ let summary;
185
+ const actions = [];
186
+ if (evidence.length === 0) {
187
+ status = 'not_used';
188
+ summary = 'Google Play Billing dependency was not found in the scanned Gradle project.';
189
+ }
190
+ else if (majors.some((major) => major < policy.minimumSupportedMajor)) {
191
+ status = 'blocker';
192
+ summary = `Billing Library ${detectedVersions.join(', ')} is below the submission minimum major ${policy.minimumSupportedMajor}.`;
193
+ actions.push(`Upgrade to a supported Billing Library before submitting a new app or update.`);
194
+ actions.push(`If Google granted an extension, verify it in Play Console; the listed extension deadline is ${policy.extensionDeadline}.`);
195
+ }
196
+ else if (unresolved || majors.length === 0) {
197
+ status = 'unresolved';
198
+ summary = 'A Billing dependency was found, but at least one version expression could not be resolved statically.';
199
+ actions.push('Resolve the reported Gradle variable or version catalog alias and run the check again.');
200
+ }
201
+ else if (majors.some((major) => major === policy.minimumSupportedMajor)) {
202
+ status = 'warning';
203
+ summary = `Billing Library ${detectedVersions.join(', ')} is currently supported but is the next major scheduled for deprecation.`;
204
+ actions.push(`Plan an upgrade before ${policy.minimumSupportedMajor + 2019}-08-31.`);
205
+ }
206
+ else {
207
+ status = 'pass';
208
+ summary = `Billing Library ${detectedVersions.join(', ')} satisfies the current submission policy.`;
209
+ }
210
+ return {
211
+ projectPath: root,
212
+ checkedAt: now.toISOString(),
213
+ status,
214
+ detectedVersions,
215
+ evidence,
216
+ policy,
217
+ summary,
218
+ actions,
219
+ upgrade: {
220
+ installCommand: 'android skills add play-billing-library-version-upgrade',
221
+ prompt: 'Help me upgrade my Play Billing Library implementation.',
222
+ automaticExecution: false,
223
+ },
224
+ };
225
+ }
@@ -8,5 +8,8 @@ export interface SubmissionRisk {
8
8
  fixUrl?: string;
9
9
  }
10
10
  export declare function checkPlayStoreRisks(auth: OAuth2Client, packageName: string, language?: string): Promise<SubmissionRisk[]>;
11
- export declare function checkAppStoreRisks(appId: string): Promise<SubmissionRisk[]>;
11
+ export declare function checkAppStoreRisks(appId: string, expected?: {
12
+ socialMedia?: boolean;
13
+ socialMediaAgeRestricted?: boolean;
14
+ }): Promise<SubmissionRisk[]>;
12
15
  export declare function formatRisks(risks: SubmissionRisk[], platform: string): string;
@@ -72,7 +72,7 @@ async function safeGet(call, risks, code, title) {
72
72
  }
73
73
  }
74
74
  const APP_INFO_LIVE_STATE = 'READY_FOR_DISTRIBUTION';
75
- export async function checkAppStoreRisks(appId) {
75
+ export async function checkAppStoreRisks(appId, expected = {}) {
76
76
  const risks = [];
77
77
  const versions = await safeGet(() => apiGet(`/apps/${appId}/appStoreVersions`, {
78
78
  'filter[appStoreState]': APPSTORE_EDITABLE_STATES,
@@ -140,10 +140,15 @@ export async function checkAppStoreRisks(appId) {
140
140
  const stateOf = (i) => i.attributes?.state ?? i.attributes?.appStoreState ?? '';
141
141
  const editable = infos.find((i) => stateOf(i) !== APP_INFO_LIVE_STATE) ?? infos[0];
142
142
  if (editable) {
143
- const localizations = await safeGet(() => apiGet(`/appInfos/${editable.id}/appInfoLocalizations`, {
144
- 'fields[appInfoLocalizations]': 'locale,privacyPolicyUrl,privacyPolicyText',
145
- 'limit': '200',
146
- }), risks, 'PRIVACY', '개인정보 로컬라이제이션');
143
+ const [localizations, ageRating] = await Promise.all([
144
+ safeGet(() => apiGet(`/appInfos/${editable.id}/appInfoLocalizations`, {
145
+ 'fields[appInfoLocalizations]': 'locale,privacyPolicyUrl,privacyPolicyText',
146
+ 'limit': '200',
147
+ }), risks, 'PRIVACY', '개인정보 로컬라이제이션'),
148
+ safeGet(() => apiGet(`/appInfos/${editable.id}/ageRatingDeclaration`, {
149
+ 'fields[ageRatingDeclarations]': 'userGeneratedContent,socialMedia,socialMediaAgeRestricted',
150
+ }), risks, 'AGE_RATING_SOCIAL', '연령등급 소셜 미디어 응답'),
151
+ ]);
147
152
  if (localizations) {
148
153
  const locs = (localizations.data ?? []);
149
154
  const hasPrivacy = locs.some((l) => l.attributes?.privacyPolicyUrl || l.attributes?.privacyPolicyText);
@@ -151,6 +156,51 @@ export async function checkAppStoreRisks(appId) {
151
156
  risks.push({ level: 'blocker', code: 'NO_PRIVACY', title: '개인정보처리방침 URL 없음', detail: 'App Store 가이드라인 5.1.1 — 필수 항목입니다. 모든 로컬라이제이션에서 privacyPolicyUrl 또는 privacyPolicyText 중 하나를 입력하세요.' });
152
157
  }
153
158
  }
159
+ if (ageRating) {
160
+ const declaration = ageRating.data?.attributes ?? {};
161
+ if (declaration.socialMedia === undefined || declaration.socialMediaAgeRestricted === undefined) {
162
+ risks.push({
163
+ level: 'blocker',
164
+ code: 'SOCIAL_MEDIA_ANSWERS_MISSING',
165
+ title: '연령등급 소셜 미디어 질문 미응답',
166
+ detail: '2026년 9월부터 신규 앱·업데이트 제출에 socialMedia와 socialMediaAgeRestricted 응답이 필요합니다.',
167
+ fixUrl: 'https://developer.apple.com/news/?id=tlur8uvi',
168
+ });
169
+ }
170
+ if (declaration.socialMediaAgeRestricted === true && declaration.socialMedia !== true) {
171
+ risks.push({
172
+ level: 'blocker',
173
+ code: 'SOCIAL_MEDIA_AGE_RESTRICTION_MISMATCH',
174
+ title: '13세 미만 제한 응답 불일치',
175
+ detail: 'socialMediaAgeRestricted=true는 socialMedia=true인 앱에서만 의미가 있습니다.',
176
+ });
177
+ }
178
+ if (expected.socialMedia !== undefined && declaration.socialMedia !== expected.socialMedia) {
179
+ risks.push({
180
+ level: 'blocker',
181
+ code: 'SOCIAL_MEDIA_CAPABILITY_MISMATCH',
182
+ title: '앱 기능과 App Store 소셜 미디어 응답 불일치',
183
+ detail: `프로젝트 기능 선언은 socialMedia=${expected.socialMedia}이지만 App Store Connect는 ${String(declaration.socialMedia)}입니다.`,
184
+ });
185
+ }
186
+ if (expected.socialMediaAgeRestricted !== undefined
187
+ && declaration.socialMediaAgeRestricted !== expected.socialMediaAgeRestricted) {
188
+ risks.push({
189
+ level: 'blocker',
190
+ code: 'SOCIAL_MEDIA_AGE_GATE_MISMATCH',
191
+ title: '13세 미만 기능 제한과 App Store 응답 불일치',
192
+ detail: `프로젝트 기능 선언은 socialMediaAgeRestricted=${expected.socialMediaAgeRestricted}이지만 App Store Connect는 ${String(declaration.socialMediaAgeRestricted)}입니다.`,
193
+ });
194
+ }
195
+ if (declaration.userGeneratedContent && declaration.socialMedia === false) {
196
+ risks.push({
197
+ level: 'warning',
198
+ code: 'UGC_WITHOUT_SOCIAL_MEDIA',
199
+ title: 'UGC는 있으나 소셜 미디어 기능은 없음으로 응답',
200
+ detail: '피드·좋아요·댓글·공유·발견 기능이 있다면 socialMedia 응답을 true로 바꿔야 합니다.',
201
+ });
202
+ }
203
+ }
154
204
  }
155
205
  }
156
206
  return risks;
@@ -0,0 +1,132 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ interface OptionalResult<T> {
3
+ available: boolean;
4
+ data?: T;
5
+ error?: string;
6
+ }
7
+ interface RemoteConfigTemplate {
8
+ parameters?: Record<string, unknown>;
9
+ parameterGroups?: Record<string, unknown>;
10
+ conditions?: unknown[];
11
+ version?: {
12
+ versionNumber?: string;
13
+ updateTime?: string;
14
+ updateUser?: {
15
+ email?: string;
16
+ };
17
+ updateOrigin?: string;
18
+ updateType?: string;
19
+ };
20
+ }
21
+ interface ExperimentList {
22
+ experiments?: Array<{
23
+ name?: string;
24
+ state?: string;
25
+ startTime?: string;
26
+ endTime?: string;
27
+ lastUpdateTime?: string;
28
+ definition?: unknown;
29
+ }>;
30
+ }
31
+ interface RolloutList {
32
+ rollouts?: Array<{
33
+ name?: string;
34
+ state?: string;
35
+ startTime?: string;
36
+ endTime?: string;
37
+ lastUpdateTime?: string;
38
+ definition?: unknown;
39
+ }>;
40
+ }
41
+ export declare function estimateRemoteConfigDailyCost(fetches: number): number;
42
+ export declare function remoteConfigUsageLevel(fetches: number): 'ok' | 'warning' | 'critical';
43
+ export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
44
+ projectId: string;
45
+ namespace?: string;
46
+ days?: number;
47
+ }): Promise<{
48
+ projectId: string;
49
+ namespace: string;
50
+ pricingPolicy: {
51
+ effectiveDate: string;
52
+ noCostDailyFetches: number;
53
+ firstPaidTier: {
54
+ from: number;
55
+ to: number;
56
+ usdPerRequest: number;
57
+ };
58
+ secondPaidTier: {
59
+ from: number;
60
+ usdPerRequest: number;
61
+ };
62
+ transitionNotice: string;
63
+ sourceUrl: string;
64
+ };
65
+ billing: {
66
+ readonly available: false;
67
+ readonly error: string;
68
+ } | {
69
+ available: boolean;
70
+ plan: string;
71
+ billingEnabled: boolean;
72
+ };
73
+ usage: OptionalResult<{
74
+ date: string;
75
+ fetches: number;
76
+ }[]> | {
77
+ available: boolean;
78
+ metric: string;
79
+ days: {
80
+ date: string;
81
+ fetches: number;
82
+ }[];
83
+ latest: {
84
+ utilizationPercent: number;
85
+ level: "ok" | "warning" | "critical";
86
+ projectedStandardDailyCostUsd: number;
87
+ date: string;
88
+ fetches: number;
89
+ } | null;
90
+ };
91
+ template: OptionalResult<RemoteConfigTemplate> | {
92
+ available: boolean;
93
+ version: {
94
+ versionNumber?: string;
95
+ updateTime?: string;
96
+ updateUser?: {
97
+ email?: string;
98
+ };
99
+ updateOrigin?: string;
100
+ updateType?: string;
101
+ } | null;
102
+ parameterCount: number;
103
+ parameterGroupCount: number;
104
+ conditionCount: number;
105
+ };
106
+ experiments: OptionalResult<ExperimentList> | {
107
+ available: boolean;
108
+ total: number;
109
+ active: {
110
+ name?: string;
111
+ state?: string;
112
+ startTime?: string;
113
+ endTime?: string;
114
+ lastUpdateTime?: string;
115
+ definition?: unknown;
116
+ }[];
117
+ };
118
+ rollouts: OptionalResult<RolloutList> | {
119
+ available: boolean;
120
+ total: number;
121
+ active: {
122
+ name?: string;
123
+ state?: string;
124
+ startTime?: string;
125
+ endTime?: string;
126
+ lastUpdateTime?: string;
127
+ definition?: unknown;
128
+ }[];
129
+ };
130
+ warnings: string[];
131
+ }>;
132
+ export {};
@@ -0,0 +1,160 @@
1
+ import { google } from '../lib/googleapis-lite.js';
2
+ import { getBillingInfo } from '../billing/tools.js';
3
+ const FREE_DAILY_FETCHES = 100_000;
4
+ const FIRST_PAID_TIER_END = 10_000_000;
5
+ const REMOTE_CONFIG_BASE = 'https://firebaseremoteconfig.googleapis.com/v1';
6
+ async function optionalRequest(auth, url) {
7
+ try {
8
+ const response = await auth.request({ url });
9
+ return { available: true, data: response.data };
10
+ }
11
+ catch (error) {
12
+ return {
13
+ available: false,
14
+ error: error instanceof Error ? error.message : String(error),
15
+ };
16
+ }
17
+ }
18
+ function numericPoint(point) {
19
+ const raw = point.value?.int64Value ?? point.value?.doubleValue ?? 0;
20
+ const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : raw;
21
+ return Number.isFinite(value) ? value : 0;
22
+ }
23
+ export function estimateRemoteConfigDailyCost(fetches) {
24
+ if (fetches <= FREE_DAILY_FETCHES)
25
+ return 0;
26
+ const firstPaid = Math.min(fetches, FIRST_PAID_TIER_END) - FREE_DAILY_FETCHES;
27
+ const secondPaid = Math.max(0, fetches - FIRST_PAID_TIER_END);
28
+ return firstPaid * 0.000006 + secondPaid * 0.000001;
29
+ }
30
+ export function remoteConfigUsageLevel(fetches) {
31
+ const ratio = fetches / FREE_DAILY_FETCHES;
32
+ if (ratio >= 1)
33
+ return 'critical';
34
+ if (ratio >= 0.8)
35
+ return 'warning';
36
+ return 'ok';
37
+ }
38
+ async function fetchDailyUsage(auth, projectId, days) {
39
+ try {
40
+ const end = new Date();
41
+ const start = new Date(end.getTime() - days * 86_400_000);
42
+ const monitoring = google.monitoring('v3');
43
+ const response = await monitoring.projects.timeSeries.list({
44
+ auth,
45
+ name: `projects/${projectId}`,
46
+ filter: 'metric.type="firebaseremoteconfig.googleapis.com/project/fetch_request_count"',
47
+ 'interval.startTime': start.toISOString(),
48
+ 'interval.endTime': end.toISOString(),
49
+ 'aggregation.alignmentPeriod': '86400s',
50
+ 'aggregation.perSeriesAligner': 'ALIGN_SUM',
51
+ 'aggregation.crossSeriesReducer': 'REDUCE_SUM',
52
+ view: 'FULL',
53
+ pageSize: Math.max(days + 2, 10),
54
+ });
55
+ const rows = (response.data.timeSeries ?? [])
56
+ .flatMap((series) => series.points ?? [])
57
+ .map((point) => ({
58
+ date: (point.interval?.endTime ?? end.toISOString()).slice(0, 10),
59
+ fetches: numericPoint(point),
60
+ }))
61
+ .sort((a, b) => a.date.localeCompare(b.date));
62
+ return { available: true, data: rows };
63
+ }
64
+ catch (error) {
65
+ return {
66
+ available: false,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ };
69
+ }
70
+ }
71
+ export async function getRemoteConfigOverview(auth, input) {
72
+ const namespace = input.namespace ?? 'firebase';
73
+ const days = Math.min(Math.max(input.days ?? 7, 1), 30);
74
+ const encodedProject = encodeURIComponent(input.projectId);
75
+ const encodedNamespace = encodeURIComponent(namespace);
76
+ const parent = `${REMOTE_CONFIG_BASE}/projects/${encodedProject}/namespaces/${encodedNamespace}`;
77
+ const [template, experiments, rollouts, usage, billing] = await Promise.all([
78
+ optionalRequest(auth, `${parent}/remoteConfig`),
79
+ optionalRequest(auth, `${parent}/experiments`),
80
+ optionalRequest(auth, `${parent}/rollouts`),
81
+ fetchDailyUsage(auth, input.projectId, days),
82
+ getBillingInfo(auth, input.projectId)
83
+ .then((data) => ({ available: true, data }))
84
+ .catch((error) => ({
85
+ available: false,
86
+ error: error instanceof Error ? error.message : String(error),
87
+ })),
88
+ ]);
89
+ const daily = usage.data ?? [];
90
+ const latest = daily.at(-1);
91
+ const activeExperiments = experiments.data?.experiments?.filter((item) => ['RUNNING', 'ACTIVE', 'STARTED'].includes(item.state ?? '')) ?? [];
92
+ const activeRollouts = rollouts.data?.rollouts?.filter((item) => ['RUNNING', 'ACTIVE', 'STARTED', 'IN_PROGRESS'].includes(item.state ?? '')) ?? [];
93
+ return {
94
+ projectId: input.projectId,
95
+ namespace,
96
+ pricingPolicy: {
97
+ effectiveDate: '2026-09-01',
98
+ noCostDailyFetches: FREE_DAILY_FETCHES,
99
+ firstPaidTier: { from: 100_001, to: FIRST_PAID_TIER_END, usdPerRequest: 0.000006 },
100
+ secondPaidTier: { from: FIRST_PAID_TIER_END + 1, usdPerRequest: 0.000001 },
101
+ transitionNotice: 'Existing projects can have a plan-specific transition grace period; this tool reports projected standard pricing and does not infer whether a grace period applies.',
102
+ sourceUrl: 'https://firebase.google.com/docs/remote-config/pricing',
103
+ },
104
+ billing: billing.available
105
+ ? {
106
+ available: true,
107
+ plan: billing.data.billingEnabled ? 'blaze' : 'spark',
108
+ billingEnabled: billing.data.billingEnabled,
109
+ }
110
+ : billing,
111
+ usage: usage.available
112
+ ? {
113
+ available: true,
114
+ metric: 'firebaseremoteconfig.googleapis.com/project/fetch_request_count',
115
+ days: daily,
116
+ latest: latest
117
+ ? {
118
+ ...latest,
119
+ utilizationPercent: Number(((latest.fetches / FREE_DAILY_FETCHES) * 100).toFixed(2)),
120
+ level: remoteConfigUsageLevel(latest.fetches),
121
+ projectedStandardDailyCostUsd: Number(estimateRemoteConfigDailyCost(latest.fetches).toFixed(4)),
122
+ }
123
+ : null,
124
+ }
125
+ : usage,
126
+ template: template.available
127
+ ? {
128
+ available: true,
129
+ version: template.data?.version ?? null,
130
+ parameterCount: Object.keys(template.data?.parameters ?? {}).length,
131
+ parameterGroupCount: Object.keys(template.data?.parameterGroups ?? {}).length,
132
+ conditionCount: template.data?.conditions?.length ?? 0,
133
+ }
134
+ : template,
135
+ experiments: experiments.available
136
+ ? {
137
+ available: true,
138
+ total: experiments.data?.experiments?.length ?? 0,
139
+ active: activeExperiments,
140
+ }
141
+ : experiments,
142
+ rollouts: rollouts.available
143
+ ? {
144
+ available: true,
145
+ total: rollouts.data?.rollouts?.length ?? 0,
146
+ active: activeRollouts,
147
+ }
148
+ : rollouts,
149
+ warnings: [
150
+ ...(latest && remoteConfigUsageLevel(latest.fetches) !== 'ok'
151
+ ? [`Daily fetch usage is ${remoteConfigUsageLevel(latest.fetches)} at ${latest.fetches.toLocaleString()} requests.`]
152
+ : []),
153
+ ...(latest && latest.fetches >= FREE_DAILY_FETCHES && billing.available && !billing.data.billingEnabled
154
+ ? ['Spark projects risk throttling above 100,000 daily fetches after the applicable grace period; upgrade to Blaze for uninterrupted overage.']
155
+ : []),
156
+ ...(!usage.available ? ['Cloud Monitoring usage is unavailable; no fetch count was estimated.'] : []),
157
+ ...(!billing.available ? ['Billing plan could not be verified; cost/throttling interpretation is incomplete.'] : []),
158
+ ],
159
+ };
160
+ }
@@ -15,6 +15,7 @@ export declare const google: {
15
15
  readonly cloudresourcemanager: typeof import("googleapis/build/src/apis/cloudresourcemanager/index.js").cloudresourcemanager;
16
16
  readonly firebase: typeof import("googleapis/build/src/apis/firebase/index.js").firebase;
17
17
  readonly iam: typeof import("googleapis/build/src/apis/iam/index.js").iam;
18
+ readonly monitoring: typeof import("googleapis/build/src/apis/monitoring/index.js").monitoring;
18
19
  readonly searchconsole: typeof import("googleapis/build/src/apis/searchconsole/index.js").searchconsole;
19
20
  readonly serviceusage: typeof import("googleapis/build/src/apis/serviceusage/index.js").serviceusage;
20
21
  readonly youtube: typeof import("googleapis/build/src/apis/youtube/index.js").youtube;
@@ -79,6 +79,9 @@ export const google = {
79
79
  get iam() {
80
80
  return sub('iam').iam;
81
81
  },
82
+ get monitoring() {
83
+ return sub('monitoring').monitoring;
84
+ },
82
85
  get searchconsole() {
83
86
  return sub('searchconsole').searchconsole;
84
87
  },
@@ -8,6 +8,7 @@ import * as appstoreDeclarations from '../appstore/declarations.js';
8
8
  import * as testflight from '../appstore/testflight.js';
9
9
  import * as previews from '../appstore/previews.js';
10
10
  import * as appstoreSales from '../appstore/sales.js';
11
+ import { getWeeklyInsight } from '../appstore/analytics.js';
11
12
  import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
12
13
  import { requireAppStoreCreds } from '../helpers.js';
13
14
  import { buildAppStoreReleasePlan } from '../checks/plan.js';
@@ -20,6 +21,15 @@ function stringifyAttr(value) {
20
21
  return typeof value === 'string' ? value : JSON.stringify(value);
21
22
  }
22
23
  export function registerAppstoreTools(server) {
24
+ server.tool('appstore_get_weekly_insight', [
25
+ 'App Store Connect Analytics의 표준 주간 Engagement·Downloads·Purchases 리포트를 내려받아 지난 두 기간을 비교합니다.',
26
+ '제품 페이지·획득·수익화 중 가장 크게 악화된 한 영역과 근거 지표, 실행할 개선안 하나를 반환합니다.',
27
+ 'ONGOING report request가 없으면 기본은 생성하지 않습니다. createIfMissing=true와 confirmCreate=true를 함께 줘야 생성하며 최초 데이터는 24~48시간 뒤 제공됩니다.',
28
+ ].join(' '), {
29
+ appId: z.string().describe('App Store 앱 ID'),
30
+ createIfMissing: z.boolean().optional().describe('ONGOING Analytics report request가 없을 때 생성 준비'),
31
+ confirmCreate: z.boolean().optional().describe('지속 Analytics report request 생성 명시 확인'),
32
+ }, async (args) => jsonResult(await getWeeklyInsight(args)));
23
33
  server.tool('appstore_list_apps', 'App Store Connect 앱 목록 조회', {}, async () => {
24
34
  const apps = await appstore.listApps();
25
35
  return jsonResult(apps);
@@ -1070,6 +1080,8 @@ export function registerAppstoreTools(server) {
1070
1080
  lootBox: z.boolean().optional().describe('확률형 아이템(루트박스) 포함 여부'),
1071
1081
  unrestrictedWebAccess: z.boolean().optional().describe('제한 없는 웹 접근'),
1072
1082
  userGeneratedContent: z.boolean().optional().describe('사용자 생성 콘텐츠'),
1083
+ socialMedia: z.boolean().optional().describe('UGC를 피드·탐색 방식으로 재배포·확산·상호작용하는 소셜 미디어 기능'),
1084
+ socialMediaAgeRestricted: z.boolean().optional().describe('소셜 미디어 기능을 13세 미만 사용자에게 비활성화'),
1073
1085
  messagingAndChat: z.boolean().optional().describe('메시지/채팅 기능'),
1074
1086
  advertising: z.boolean().optional().describe('광고 포함'),
1075
1087
  healthOrWellnessTopics: z.boolean().optional().describe('건강/웰니스 주제'),
@@ -5,7 +5,15 @@ import { requireAuth, requirePlayStoreAuth } from '../helpers.js';
5
5
  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
+ import { checkBillingCompliance } from '../checks/billing.js';
8
9
  export function registerChecksTools(server) {
10
+ server.tool('android_check_billing_compliance', [
11
+ '로컬 Android 저장소의 Google Play Billing Library 버전을 읽기 전용으로 탐지하고 제출 정책 마감과 비교합니다.',
12
+ 'build.gradle, build.gradle.kts, gradle/libs.versions.toml의 literal·변수·version catalog를 검사합니다.',
13
+ '코드를 자동 수정하지 않고 공식 Android CLI Skill 설치 명령과 업그레이드 프롬프트만 반환합니다.',
14
+ ].join(' '), {
15
+ projectPath: z.string().optional().describe('검사할 프로젝트 절대경로 (기본: MCP 프로세스 현재 디렉터리)'),
16
+ }, async ({ projectPath }) => jsonResult(await checkBillingCompliance(projectPath ?? process.cwd())));
9
17
  server.tool('playstore_check_submission_risks', [
10
18
  'Google Play 제출 전 위험 요소를 자동으로 점검합니다.',
11
19
  '블로커(반드시 수정)와 경고(권장 수정)로 분류하여 반환합니다.',
@@ -23,11 +31,17 @@ export function registerChecksTools(server) {
23
31
  'App Store 제출 전 위험 요소를 자동으로 점검합니다.',
24
32
  '블로커(반드시 수정)와 경고(권장 수정)로 분류하여 반환합니다.',
25
33
  '점검 항목: 메타데이터 완성도(설명/What\'s New/키워드), 스크린샷, TestFlight 빌드, 개인정보처리방침 URL.',
34
+ '2026년 9월 필수화된 소셜 미디어 연령등급 응답을 확인하고, 기대 기능 값을 주면 App Store Connect 응답과 비교합니다.',
26
35
  'App Store 인증이 필요합니다 (appstore_* 도구 사용 가능 상태여야 함).',
27
36
  ].join(' '), {
28
37
  appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id 필드)'),
29
- }, async ({ appId }) => {
30
- const risks = await checkAppStoreRisks(appId);
38
+ socialMediaCapability: z.boolean().optional().describe('앱이 소셜 피드·UGC 확산 기능을 제공하는지에 대한 프로젝트 사실'),
39
+ socialMediaAgeRestricted: z.boolean().optional().describe('소셜 미디어 기능이 13세 미만에게 비활성화되는지에 대한 프로젝트 사실'),
40
+ }, async ({ appId, socialMediaCapability, socialMediaAgeRestricted }) => {
41
+ const risks = await checkAppStoreRisks(appId, {
42
+ socialMedia: socialMediaCapability,
43
+ socialMediaAgeRestricted,
44
+ });
31
45
  const text = formatRisks(risks, 'App Store');
32
46
  return { content: [{ type: 'text', text }] };
33
47
  });
@@ -4,6 +4,7 @@ import { requireAuth } from '../helpers.js';
4
4
  import { CLOUD_PLATFORM_SCOPE } from '../auth/scopes.js';
5
5
  import { friendlyGoogleError } from '../lib/google-errors.js';
6
6
  import { jsonResult, textResult } from '../lib/mcp-response.js';
7
+ import { getRemoteConfigOverview } from '../firebase/remote-config.js';
7
8
  // 모든 firebase tools 호출을 친절 에러로 감싸는 프록시 — 17개 핸들러에 개별
8
9
  // try/catch 없이 raw GaxiosError(API 미활성화/프로젝트 없음/billing/권한)를
9
10
  // "다음에 뭘 할지" 메시지로 변환. 비-Promise 반환은 그대로 통과.
@@ -29,6 +30,18 @@ const firebase = new Proxy(firebaseRaw, {
29
30
  },
30
31
  });
31
32
  export function registerFirebaseTools(server) {
33
+ server.tool('firebase_get_remote_config_overview', [
34
+ 'Firebase Remote Config의 일일 fetch 사용량, 과금 임계치, 현재 템플릿, A/B 실험과 rollout을 한 번에 조회합니다.',
35
+ '사용량은 Cloud Monitoring의 공식 fetch_request_count 메트릭을 사용하며, 조회 권한이 없으면 숫자를 추정하지 않고 unavailable로 반환합니다.',
36
+ '읽기 전용입니다. 10만 건 무료 구간 대비 80%부터 warning, 100%부터 critical로 표시합니다.',
37
+ ].join(' '), {
38
+ projectId: z.string().describe('Firebase / Google Cloud 프로젝트 ID'),
39
+ namespace: z.string().optional().describe('Remote Config namespace (기본: firebase)'),
40
+ days: z.number().int().min(1).max(30).optional().describe('사용량 조회 일수 (기본: 7, 최대: 30)'),
41
+ }, async ({ projectId, namespace, days }) => {
42
+ const auth = await requireAuth();
43
+ return jsonResult(await getRemoteConfigOverview(auth, { projectId, namespace, days }));
44
+ });
32
45
  server.tool('firebase_list_projects', '내 Firebase 프로젝트 목록 조회', {}, async () => {
33
46
  const auth = await requireAuth();
34
47
  const projects = await firebase.listProjects(auth);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
3
- "total": 231,
3
+ "total": 234,
4
4
  "domains": {
5
5
  "admob": {
6
6
  "label": "AdMob",
@@ -38,7 +38,7 @@
38
38
  "appstore": {
39
39
  "label": "App Store Connect",
40
40
  "credential": "ASC API 키 (mimi-seed auth appstore)",
41
- "summary": "버전·빌드 attach·What's New·스크린샷·IAP 심사 메타데이터·심사 제출·매출/재무 리포트",
41
+ "summary": "버전·빌드 attach·What's New·스크린샷·IAP 심사 메타데이터·심사 제출·매출/재무·주간 인사이트",
42
42
  "tools": [
43
43
  "appstore_list_apps",
44
44
  "appstore_verify_credentials",
@@ -102,6 +102,7 @@
102
102
  "appstore_upload_preview",
103
103
  "appstore_delete_preview",
104
104
  "appstore_get_sales_report",
105
+ "appstore_get_weekly_insight",
105
106
  "appstore_get_finance_report"
106
107
  ]
107
108
  },
@@ -142,10 +143,11 @@
142
143
  "checks": {
143
144
  "label": "출시 점검",
144
145
  "credential": "점검 대상 스토어의 자격증명",
145
- "summary": "제출 전 위험 점검·스크린샷 규격 검증·릴리스 상태",
146
+ "summary": "제출 전 위험·Billing 정책 점검·스크린샷 규격 검증·릴리스 상태",
146
147
  "tools": [
147
148
  "playstore_check_submission_risks",
148
149
  "appstore_check_submission_risks",
150
+ "android_check_billing_compliance",
149
151
  "screenshot_validate",
150
152
  "release_status"
151
153
  ]
@@ -179,7 +181,7 @@
179
181
  "firebase": {
180
182
  "label": "Firebase",
181
183
  "credential": "Google OAuth",
182
- "summary": "프로젝트/앱 생성·설정 파일 다운로드·서비스 활성화",
184
+ "summary": "프로젝트/앱 생성·설정 파일 다운로드·서비스 활성화·Remote Config 운영 현황",
183
185
  "tools": [
184
186
  "firebase_list_projects",
185
187
  "firebase_get_project",
@@ -200,7 +202,8 @@
200
202
  "firebase_enable_common_services",
201
203
  "firebase_list_enabled_services",
202
204
  "firebase_link_analytics",
203
- "firebase_get_analytics_details"
205
+ "firebase_get_analytics_details",
206
+ "firebase_get_remote_config_overview"
204
207
  ]
205
208
  },
206
209
  "ga4": {