@yoonion/mimi-seed-mcp 0.19.1 → 0.19.3
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/dist/appstore/analytics.d.ts +34 -0
- package/dist/appstore/analytics.js +95 -28
- package/dist/checks/billing.d.ts +12 -2
- package/dist/checks/billing.js +247 -35
- package/dist/checks/risks.js +27 -4
- package/dist/firebase/remote-config.d.ts +64 -33
- package/dist/firebase/remote-config.js +98 -19
- package/dist/registers/checks.js +1 -1
- package/package.json +1 -1
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import { type ReportRow } from './sales.js';
|
|
2
|
+
interface JsonApiRow<T> {
|
|
3
|
+
id: string;
|
|
4
|
+
attributes?: T;
|
|
5
|
+
}
|
|
6
|
+
interface AnalyticsReportAttributes {
|
|
7
|
+
name?: string;
|
|
8
|
+
category?: 'APP_USAGE' | 'APP_STORE_ENGAGEMENT' | 'COMMERCE' | 'FRAMEWORK_USAGE' | 'PERFORMANCE';
|
|
9
|
+
}
|
|
10
|
+
export declare function selectWeeklyAnalyticsReports(reports: Array<JsonApiRow<AnalyticsReportAttributes>>): {
|
|
11
|
+
engagement: JsonApiRow<AnalyticsReportAttributes> | undefined;
|
|
12
|
+
downloads: JsonApiRow<AnalyticsReportAttributes> | undefined;
|
|
13
|
+
purchases: JsonApiRow<AnalyticsReportAttributes> | undefined;
|
|
14
|
+
};
|
|
2
15
|
interface WeeklyMetrics {
|
|
3
16
|
date: string;
|
|
4
17
|
impressions: number;
|
|
5
18
|
productPageViews: number;
|
|
6
19
|
downloads: number;
|
|
7
20
|
proceeds: number;
|
|
21
|
+
available: {
|
|
22
|
+
engagement: boolean;
|
|
23
|
+
downloads: boolean;
|
|
24
|
+
purchases: boolean;
|
|
25
|
+
};
|
|
8
26
|
}
|
|
27
|
+
export declare function selectLatestInstanceRows(batches: Array<{
|
|
28
|
+
processingDate: string;
|
|
29
|
+
rows: ReportRow[];
|
|
30
|
+
}>): ReportRow[];
|
|
9
31
|
export declare function buildWeeklyInsight(input: {
|
|
10
32
|
engagement: ReportRow[];
|
|
11
33
|
downloads: ReportRow[];
|
|
@@ -34,7 +56,13 @@ export declare function buildWeeklyInsight(input: {
|
|
|
34
56
|
insight: {
|
|
35
57
|
area: string;
|
|
36
58
|
changePercent: number | null;
|
|
59
|
+
trend: string;
|
|
37
60
|
recommendation: string;
|
|
61
|
+
} | {
|
|
62
|
+
area: string;
|
|
63
|
+
changePercent: null;
|
|
64
|
+
recommendation: string;
|
|
65
|
+
trend?: undefined;
|
|
38
66
|
};
|
|
39
67
|
weeks?: undefined;
|
|
40
68
|
recommendation?: undefined;
|
|
@@ -90,7 +118,13 @@ export declare function getWeeklyInsight(input: {
|
|
|
90
118
|
insight: {
|
|
91
119
|
area: string;
|
|
92
120
|
changePercent: number | null;
|
|
121
|
+
trend: string;
|
|
122
|
+
recommendation: string;
|
|
123
|
+
} | {
|
|
124
|
+
area: string;
|
|
125
|
+
changePercent: null;
|
|
93
126
|
recommendation: string;
|
|
127
|
+
trend?: undefined;
|
|
94
128
|
};
|
|
95
129
|
weeks?: undefined;
|
|
96
130
|
recommendation?: undefined;
|
|
@@ -4,7 +4,21 @@ import { getReportsAuthHeaders } from './auth.js';
|
|
|
4
4
|
import { V1_BASE, apiRequest, authHeadersOrThrow } from './http.js';
|
|
5
5
|
import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
|
|
6
6
|
import { parseTsv } from './sales.js';
|
|
7
|
+
export function selectWeeklyAnalyticsReports(reports) {
|
|
8
|
+
const standard = reports.filter((row) => !/Detailed/i.test(row.attributes?.name ?? ''));
|
|
9
|
+
const detailed = reports.filter((row) => /Detailed/i.test(row.attributes?.name ?? ''));
|
|
10
|
+
return {
|
|
11
|
+
engagement: standard.find((row) => /Discovery and Engagement/i.test(row.attributes?.name ?? '')),
|
|
12
|
+
// Apple only exposes weekly App Store Downloads as a detailed report. Selecting the
|
|
13
|
+
// standard report here silently yields no WEEKLY instances even when analytics is ready.
|
|
14
|
+
downloads: detailed.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? ''))
|
|
15
|
+
?? standard.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? '')),
|
|
16
|
+
purchases: standard.find((row) => /App Store Purchases/i.test(row.attributes?.name ?? '')),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
7
19
|
const MAX_SEGMENT_BYTES = 50 * 1024 * 1024;
|
|
20
|
+
const MAX_DECOMPRESSED_BYTES = 100 * 1024 * 1024;
|
|
21
|
+
const MAX_WEEKLY_INSTANCES = 8;
|
|
8
22
|
async function reportHeaders() {
|
|
9
23
|
const auth = await getReportsAuthHeaders();
|
|
10
24
|
if (!auth)
|
|
@@ -39,35 +53,64 @@ async function downloadSegment(segment) {
|
|
|
39
53
|
const response = await fetchWithTimeout(attributes.url, {}, HTTP_TRANSFER_TIMEOUT_MS);
|
|
40
54
|
if (!response.ok)
|
|
41
55
|
throw new Error(`Analytics segment download failed (${response.status}).`);
|
|
56
|
+
const contentLength = Number(response.headers.get('content-length') ?? 0);
|
|
57
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_SEGMENT_BYTES) {
|
|
58
|
+
throw new Error(`Analytics segment ${segment.id} response is larger than the 50 MB safety limit.`);
|
|
59
|
+
}
|
|
42
60
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
61
|
+
if (bytes.length > MAX_SEGMENT_BYTES) {
|
|
62
|
+
throw new Error(`Analytics segment ${segment.id} downloaded more than the 50 MB safety limit.`);
|
|
63
|
+
}
|
|
43
64
|
if (attributes.checksum) {
|
|
44
65
|
const actual = crypto.createHash('md5').update(bytes).digest('hex');
|
|
45
66
|
if (actual !== attributes.checksum.toLowerCase()) {
|
|
46
67
|
throw new Error(`Analytics segment checksum mismatch for ${segment.id}.`);
|
|
47
68
|
}
|
|
48
69
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
catch {
|
|
54
|
-
text = bytes.toString('utf8');
|
|
55
|
-
}
|
|
70
|
+
const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b;
|
|
71
|
+
const text = isGzip
|
|
72
|
+
? zlib.gunzipSync(bytes, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')
|
|
73
|
+
: bytes.toString('utf8');
|
|
56
74
|
return parseTsv(text);
|
|
57
75
|
}
|
|
76
|
+
export function selectLatestInstanceRows(batches) {
|
|
77
|
+
const latestByDate = new Map();
|
|
78
|
+
for (const batch of batches) {
|
|
79
|
+
const rowsByDate = new Map();
|
|
80
|
+
for (const row of batch.rows) {
|
|
81
|
+
const date = dateOf(row);
|
|
82
|
+
if (!date)
|
|
83
|
+
continue;
|
|
84
|
+
const rows = rowsByDate.get(date) ?? [];
|
|
85
|
+
rows.push(row);
|
|
86
|
+
rowsByDate.set(date, rows);
|
|
87
|
+
}
|
|
88
|
+
for (const [date, rows] of rowsByDate) {
|
|
89
|
+
const current = latestByDate.get(date);
|
|
90
|
+
if (!current || batch.processingDate > current.processingDate) {
|
|
91
|
+
latestByDate.set(date, { processingDate: batch.processingDate, rows });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...latestByDate.entries()]
|
|
96
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
97
|
+
.flatMap(([, value]) => value.rows);
|
|
98
|
+
}
|
|
58
99
|
async function rowsForReport(reportId) {
|
|
59
100
|
const instances = await reportGet(`/analyticsReports/${reportId}/instances`, { 'filter[granularity]': 'WEEKLY', limit: '200' });
|
|
60
101
|
const latest = (instances.data ?? [])
|
|
61
102
|
.filter((row) => row.attributes?.processingDate)
|
|
62
103
|
.sort((a, b) => (b.attributes?.processingDate ?? '').localeCompare(a.attributes?.processingDate ?? ''))
|
|
63
|
-
.slice(0,
|
|
64
|
-
const
|
|
104
|
+
.slice(0, MAX_WEEKLY_INSTANCES);
|
|
105
|
+
const batches = [];
|
|
65
106
|
for (const instance of latest) {
|
|
66
107
|
const segments = await reportGet(`/analyticsReportInstances/${instance.id}/segments`, { limit: '200' });
|
|
108
|
+
const rows = [];
|
|
67
109
|
for (const segment of segments.data ?? [])
|
|
68
|
-
|
|
110
|
+
rows.push(...await downloadSegment(segment));
|
|
111
|
+
batches.push({ processingDate: instance.attributes.processingDate, rows });
|
|
69
112
|
}
|
|
70
|
-
return
|
|
113
|
+
return selectLatestInstanceRows(batches);
|
|
71
114
|
}
|
|
72
115
|
function numberFrom(row, names) {
|
|
73
116
|
for (const name of names) {
|
|
@@ -86,7 +129,14 @@ function dateOf(row) {
|
|
|
86
129
|
function ensureWeek(map, date) {
|
|
87
130
|
let week = map.get(date);
|
|
88
131
|
if (!week) {
|
|
89
|
-
week = {
|
|
132
|
+
week = {
|
|
133
|
+
date,
|
|
134
|
+
impressions: 0,
|
|
135
|
+
productPageViews: 0,
|
|
136
|
+
downloads: 0,
|
|
137
|
+
proceeds: 0,
|
|
138
|
+
available: { engagement: false, downloads: false, purchases: false },
|
|
139
|
+
};
|
|
90
140
|
map.set(date, week);
|
|
91
141
|
}
|
|
92
142
|
return week;
|
|
@@ -103,9 +153,10 @@ export function buildWeeklyInsight(input) {
|
|
|
103
153
|
if (!date)
|
|
104
154
|
continue;
|
|
105
155
|
const week = ensureWeek(weeks, date);
|
|
156
|
+
week.available.engagement = true;
|
|
106
157
|
const event = (row.Event ?? '').toLowerCase();
|
|
107
158
|
const pageType = (row['Page Type'] ?? '').toLowerCase();
|
|
108
|
-
const count = numberFrom(row, ['Counts', 'Count']);
|
|
159
|
+
const count = numberFrom(row, ['Unique Counts', 'Counts', 'Count']);
|
|
109
160
|
if (event === 'impression')
|
|
110
161
|
week.impressions += count;
|
|
111
162
|
if (event === 'page view' && (pageType === 'product page' || !pageType))
|
|
@@ -115,20 +166,27 @@ export function buildWeeklyInsight(input) {
|
|
|
115
166
|
const date = dateOf(row);
|
|
116
167
|
if (!date)
|
|
117
168
|
continue;
|
|
118
|
-
ensureWeek(weeks, date)
|
|
169
|
+
const week = ensureWeek(weeks, date);
|
|
170
|
+
week.available.downloads = true;
|
|
171
|
+
week.downloads += numberFrom(row, ['Counts', 'Count', 'Downloads', 'Units']);
|
|
119
172
|
}
|
|
120
173
|
for (const row of input.purchases) {
|
|
121
174
|
const date = dateOf(row);
|
|
122
175
|
if (!date)
|
|
123
176
|
continue;
|
|
124
|
-
ensureWeek(weeks, date)
|
|
177
|
+
const week = ensureWeek(weeks, date);
|
|
178
|
+
week.available.purchases = true;
|
|
179
|
+
week.proceeds += numberFrom(row, [
|
|
180
|
+
'Proceeds in USD',
|
|
125
181
|
'Developer Proceeds',
|
|
126
182
|
'Proceeds',
|
|
127
183
|
'Estimated Proceeds',
|
|
128
184
|
'Sales',
|
|
129
185
|
]);
|
|
130
186
|
}
|
|
131
|
-
const ordered = [...weeks.values()]
|
|
187
|
+
const ordered = [...weeks.values()]
|
|
188
|
+
.filter((week) => week.available.engagement && week.available.downloads)
|
|
189
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
132
190
|
if (ordered.length < 2) {
|
|
133
191
|
return {
|
|
134
192
|
status: 'collecting',
|
|
@@ -148,17 +206,22 @@ export function buildWeeklyInsight(input) {
|
|
|
148
206
|
{
|
|
149
207
|
area: 'product_page',
|
|
150
208
|
changePercent: percentageChange(currentPageRate, previousPageRate),
|
|
151
|
-
|
|
209
|
+
declineRecommendation: '제품 페이지 진입률이 하락했습니다. 아이콘·스크린샷 첫 장·부제 중 하나를 바꿔 Product Page Optimization 테스트를 시작하세요.',
|
|
210
|
+
healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 제품 페이지 진입률을 다음 실험 대상으로 삼으세요.',
|
|
152
211
|
},
|
|
153
212
|
{
|
|
154
213
|
area: 'acquisition',
|
|
155
214
|
changePercent: percentageChange(currentDownloadRate, previousDownloadRate),
|
|
156
|
-
|
|
215
|
+
declineRecommendation: '제품 페이지 조회 대비 다운로드 전환이 하락했습니다. 유입 소스별 전환을 나누고 가장 큰 하락 소스에 맞춘 커스텀 제품 페이지를 만드세요.',
|
|
216
|
+
healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 다운로드 전환을 유입 소스별로 나눠 다음 실험을 정하세요.',
|
|
157
217
|
},
|
|
158
218
|
{
|
|
159
219
|
area: 'monetization',
|
|
160
|
-
changePercent:
|
|
161
|
-
|
|
220
|
+
changePercent: previous.available.purchases && current.available.purchases
|
|
221
|
+
? percentageChange(currentRevenuePerDownload, previousRevenuePerDownload)
|
|
222
|
+
: null,
|
|
223
|
+
declineRecommendation: '다운로드당 수익이 하락했습니다. 구매 리포트에서 상품별 하락을 확인하고 가격·오퍼·구독 전환 중 한 가지를 실험하세요.',
|
|
224
|
+
healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 다운로드당 수익을 상품별로 나눠 다음 가격·오퍼 실험을 정하세요.',
|
|
162
225
|
},
|
|
163
226
|
].filter((candidate) => candidate.changePercent !== null)
|
|
164
227
|
.sort((a, b) => (a.changePercent ?? 0) - (b.changePercent ?? 0));
|
|
@@ -171,21 +234,28 @@ export function buildWeeklyInsight(input) {
|
|
|
171
234
|
impressions: percentageChange(current.impressions, previous.impressions),
|
|
172
235
|
productPageViews: percentageChange(current.productPageViews, previous.productPageViews),
|
|
173
236
|
downloads: percentageChange(current.downloads, previous.downloads),
|
|
174
|
-
proceeds:
|
|
237
|
+
proceeds: previous.available.purchases && current.available.purchases
|
|
238
|
+
? percentageChange(current.proceeds, previous.proceeds)
|
|
239
|
+
: null,
|
|
175
240
|
productPageRate: percentageChange(currentPageRate, previousPageRate),
|
|
176
241
|
downloadRate: percentageChange(currentDownloadRate, previousDownloadRate),
|
|
177
|
-
revenuePerDownload:
|
|
242
|
+
revenuePerDownload: previous.available.purchases && current.available.purchases
|
|
243
|
+
? percentageChange(currentRevenuePerDownload, previousRevenuePerDownload)
|
|
244
|
+
: null,
|
|
178
245
|
},
|
|
179
246
|
insight: selected
|
|
180
247
|
? {
|
|
181
248
|
area: selected.area,
|
|
182
249
|
changePercent: selected.changePercent,
|
|
183
|
-
|
|
250
|
+
trend: (selected.changePercent ?? 0) < 0 ? 'declining' : 'stable_or_improving',
|
|
251
|
+
recommendation: (selected.changePercent ?? 0) < 0
|
|
252
|
+
? selected.declineRecommendation
|
|
253
|
+
: selected.healthyRecommendation,
|
|
184
254
|
}
|
|
185
255
|
: {
|
|
186
256
|
area: 'data_quality',
|
|
187
257
|
changePercent: null,
|
|
188
|
-
recommendation: '비교 가능한 분모 데이터가 부족합니다.
|
|
258
|
+
recommendation: '비교 가능한 분모 데이터가 부족합니다. Engagement·주간 Downloads Detailed·Purchases 리포트가 생성되는지 확인하세요.',
|
|
189
259
|
},
|
|
190
260
|
};
|
|
191
261
|
}
|
|
@@ -214,10 +284,7 @@ export async function getWeeklyInsight(input) {
|
|
|
214
284
|
};
|
|
215
285
|
}
|
|
216
286
|
const reports = await reportGet(`/analyticsReportRequests/${active.id}/reports`, { limit: '200' });
|
|
217
|
-
const
|
|
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 ?? ''));
|
|
287
|
+
const { engagement, downloads, purchases } = selectWeeklyAnalyticsReports(reports.data ?? []);
|
|
221
288
|
if (!engagement && !downloads && !purchases) {
|
|
222
289
|
return {
|
|
223
290
|
status: 'collecting',
|
package/dist/checks/billing.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface BillingEvidence {
|
|
|
4
4
|
module: string;
|
|
5
5
|
version?: string;
|
|
6
6
|
expression?: string;
|
|
7
|
-
source: 'literal' | 'variable' | 'version_catalog' | 'unresolved';
|
|
7
|
+
source: 'literal' | 'variable' | 'version_catalog' | 'transitive' | 'unresolved';
|
|
8
8
|
}
|
|
9
9
|
export interface BillingComplianceResult {
|
|
10
10
|
projectPath: string;
|
|
@@ -13,10 +13,16 @@ export interface BillingComplianceResult {
|
|
|
13
13
|
detectedVersions: string[];
|
|
14
14
|
evidence: BillingEvidence[];
|
|
15
15
|
policy: {
|
|
16
|
-
minimumSupportedMajor: number;
|
|
16
|
+
minimumSupportedMajor: number | null;
|
|
17
17
|
submissionDeadline: string;
|
|
18
18
|
extensionDeadline: string;
|
|
19
19
|
latestKnownMajor: number;
|
|
20
|
+
scheduleCurrent: boolean;
|
|
21
|
+
knownSchedule: Array<{
|
|
22
|
+
major: number;
|
|
23
|
+
submissionDeadline: string;
|
|
24
|
+
extensionDeadline: string;
|
|
25
|
+
}>;
|
|
20
26
|
sourceUrl: string;
|
|
21
27
|
};
|
|
22
28
|
summary: string;
|
|
@@ -27,4 +33,8 @@ export interface BillingComplianceResult {
|
|
|
27
33
|
automaticExecution: false;
|
|
28
34
|
};
|
|
29
35
|
}
|
|
36
|
+
export declare function billingVersionFromPom(pom: string): {
|
|
37
|
+
module: string;
|
|
38
|
+
version: string;
|
|
39
|
+
} | null;
|
|
30
40
|
export declare function checkBillingCompliance(projectPath: string, now?: Date): Promise<BillingComplianceResult>;
|
package/dist/checks/billing.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { fetchWithTimeout } from '../lib/http.js';
|
|
3
4
|
const BILLING_MODULE = /com\.android\.billingclient:billing(?:-ktx)?/;
|
|
4
5
|
const LITERAL_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})/g;
|
|
5
6
|
const VARIABLE_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:\$\{?([A-Za-z_][A-Za-z0-9_.-]*)\}?/g;
|
|
6
7
|
const VERSION_ASSIGNMENT = /(?:^|\s)([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:=|:)\s*["']([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})["']/gm;
|
|
8
|
+
const BILLING_SUPPORT_SCHEDULE = [
|
|
9
|
+
{ major: 5, submissionDeadline: '2024-08-31', extensionDeadline: '2024-11-01' },
|
|
10
|
+
{ major: 6, submissionDeadline: '2025-08-31', extensionDeadline: '2025-11-01' },
|
|
11
|
+
{ major: 7, submissionDeadline: '2026-08-31', extensionDeadline: '2026-11-01' },
|
|
12
|
+
{ major: 8, submissionDeadline: '2027-08-31', extensionDeadline: '2027-11-01' },
|
|
13
|
+
{ major: 9, submissionDeadline: '2028-08-31', extensionDeadline: '2028-11-01' },
|
|
14
|
+
];
|
|
7
15
|
const SKIP_DIRS = new Set([
|
|
8
16
|
'.git',
|
|
9
17
|
'.gradle',
|
|
@@ -34,7 +42,8 @@ async function walk(root, maxDepth = 7) {
|
|
|
34
42
|
else if (entry.isFile()
|
|
35
43
|
&& (entry.name === 'build.gradle'
|
|
36
44
|
|| entry.name === 'build.gradle.kts'
|
|
37
|
-
|| entry.name === 'libs.versions.toml'
|
|
45
|
+
|| entry.name === 'libs.versions.toml'
|
|
46
|
+
|| entry.name === 'package.json')) {
|
|
38
47
|
result.push(path.join(dir, entry.name));
|
|
39
48
|
}
|
|
40
49
|
}
|
|
@@ -45,6 +54,7 @@ async function walk(root, maxDepth = 7) {
|
|
|
45
54
|
function parseCatalog(text) {
|
|
46
55
|
const versions = new Map();
|
|
47
56
|
const libraries = new Map();
|
|
57
|
+
const bundles = new Map();
|
|
48
58
|
let section = '';
|
|
49
59
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
50
60
|
const line = rawLine.replace(/\s+#.*$/, '').trim();
|
|
@@ -61,18 +71,37 @@ function parseCatalog(text) {
|
|
|
61
71
|
versions.set(match[1], match[2]);
|
|
62
72
|
continue;
|
|
63
73
|
}
|
|
74
|
+
if (section === 'bundles') {
|
|
75
|
+
const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*\[([^\]]*)]/);
|
|
76
|
+
if (match) {
|
|
77
|
+
bundles.set(match[1], [...match[2].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]));
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
64
81
|
if (section !== 'libraries')
|
|
65
82
|
continue;
|
|
83
|
+
const shorthand = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*["']([^"']+)["']/);
|
|
84
|
+
if (shorthand) {
|
|
85
|
+
const coordinates = shorthand[2].split(':');
|
|
86
|
+
libraries.set(shorthand[1], {
|
|
87
|
+
module: coordinates.length >= 2 ? `${coordinates[0]}:${coordinates[1]}` : undefined,
|
|
88
|
+
version: coordinates.length >= 3 ? coordinates.slice(2).join(':') : undefined,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
66
92
|
const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*\{(.+)}\s*$/);
|
|
67
93
|
if (!match)
|
|
68
94
|
continue;
|
|
69
95
|
const body = match[2];
|
|
70
|
-
const
|
|
96
|
+
const explicitModule = body.match(/module\s*=\s*["']([^"']+)["']/)?.[1];
|
|
97
|
+
const group = body.match(/group\s*=\s*["']([^"']+)["']/)?.[1];
|
|
98
|
+
const name = body.match(/name\s*=\s*["']([^"']+)["']/)?.[1];
|
|
99
|
+
const module = explicitModule ?? (group && name ? `${group}:${name}` : undefined);
|
|
71
100
|
const version = body.match(/(?:^|,)\s*version\s*=\s*["']([^"']+)["']/)?.[1];
|
|
72
101
|
const versionRef = body.match(/version\.ref\s*=\s*["']([^"']+)["']/)?.[1];
|
|
73
102
|
libraries.set(match[1], { module, version, versionRef });
|
|
74
103
|
}
|
|
75
|
-
return { versions, libraries };
|
|
104
|
+
return { versions, libraries, bundles };
|
|
76
105
|
}
|
|
77
106
|
function collectVariables(text) {
|
|
78
107
|
const result = new Map();
|
|
@@ -87,52 +116,201 @@ function majorOf(version) {
|
|
|
87
116
|
const major = Number.parseInt(version.split('.')[0], 10);
|
|
88
117
|
return Number.isFinite(major) ? major : null;
|
|
89
118
|
}
|
|
119
|
+
export function billingVersionFromPom(pom) {
|
|
120
|
+
for (const match of pom.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
|
|
121
|
+
const block = match[1];
|
|
122
|
+
const group = block.match(/<groupId>\s*([^<]+)\s*<\/groupId>/)?.[1]?.trim();
|
|
123
|
+
const artifact = block.match(/<artifactId>\s*([^<]+)\s*<\/artifactId>/)?.[1]?.trim();
|
|
124
|
+
const version = block.match(/<version>\s*([^<]+)\s*<\/version>/)?.[1]?.trim();
|
|
125
|
+
if (group === 'com.android.billingclient' && /^billing(?:-ktx)?$/.test(artifact ?? '') && version) {
|
|
126
|
+
return { module: `${group}:${artifact}`, version };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
async function nearestNodePackage(packageDir, root) {
|
|
132
|
+
let current = packageDir;
|
|
133
|
+
while (isWithin(root, current)) {
|
|
134
|
+
const candidate = path.join(current, 'node_modules', 'react-native-iap');
|
|
135
|
+
try {
|
|
136
|
+
if ((await fs.stat(candidate)).isDirectory())
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Keep walking toward the project root; workspaces commonly hoist node_modules.
|
|
141
|
+
}
|
|
142
|
+
if (current === root)
|
|
143
|
+
break;
|
|
144
|
+
current = path.dirname(current);
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
async function reactNativeIapEvidence(root, manifestFile, manifestText) {
|
|
149
|
+
let manifest;
|
|
150
|
+
try {
|
|
151
|
+
manifest = JSON.parse(manifestText);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const dependencyGroups = ['dependencies', 'devDependencies', 'optionalDependencies']
|
|
157
|
+
.map((key) => manifest[key])
|
|
158
|
+
.filter((value) => Boolean(value) && typeof value === 'object');
|
|
159
|
+
const declaredVersion = dependencyGroups
|
|
160
|
+
.map((group) => group['react-native-iap'])
|
|
161
|
+
.find((value) => typeof value === 'string');
|
|
162
|
+
if (!declaredVersion)
|
|
163
|
+
return null;
|
|
164
|
+
const relativeManifest = path.relative(root, manifestFile).replace(/\\/g, '/');
|
|
165
|
+
const installedDir = await nearestNodePackage(path.dirname(manifestFile), root);
|
|
166
|
+
if (!installedDir) {
|
|
167
|
+
return {
|
|
168
|
+
file: relativeManifest,
|
|
169
|
+
module: 'com.android.billingclient:billing',
|
|
170
|
+
expression: `react-native-iap ${declaredVersion} is declared but not installed; transitive Billing version is unresolved`,
|
|
171
|
+
source: 'unresolved',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const directCandidates = [
|
|
175
|
+
path.join(installedDir, 'android', 'build.gradle'),
|
|
176
|
+
path.join(installedDir, 'android', 'build.gradle.kts'),
|
|
177
|
+
];
|
|
178
|
+
for (const candidate of directCandidates) {
|
|
179
|
+
try {
|
|
180
|
+
const text = await fs.readFile(candidate, 'utf8');
|
|
181
|
+
const direct = [...text.matchAll(LITERAL_DEPENDENCY)][0];
|
|
182
|
+
if (direct) {
|
|
183
|
+
return {
|
|
184
|
+
file: relativeManifest,
|
|
185
|
+
module: direct[0].slice(0, direct[0].lastIndexOf(':')),
|
|
186
|
+
version: direct[1],
|
|
187
|
+
expression: `react-native-iap ${declaredVersion} native dependency`,
|
|
188
|
+
source: 'transitive',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Newer react-native-iap versions delegate Billing to the OpenIAP Maven artifact.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
let openIapVersion;
|
|
197
|
+
try {
|
|
198
|
+
const versions = JSON.parse(await fs.readFile(path.join(installedDir, 'openiap-versions.json'), 'utf8'));
|
|
199
|
+
if (typeof versions.google === 'string')
|
|
200
|
+
openIapVersion = versions.google;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Older releases may not use OpenIAP; fall through to an unresolved, safe result.
|
|
204
|
+
}
|
|
205
|
+
if (!openIapVersion || !/^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(openIapVersion)) {
|
|
206
|
+
return {
|
|
207
|
+
file: relativeManifest,
|
|
208
|
+
module: 'com.android.billingclient:billing',
|
|
209
|
+
expression: `react-native-iap ${declaredVersion} detected; transitive Billing version is unresolved`,
|
|
210
|
+
source: 'unresolved',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const coordinate = `io.github.hyochan.openiap:openiap-google:${openIapVersion}`;
|
|
214
|
+
try {
|
|
215
|
+
const pomUrl = `https://repo.maven.apache.org/maven2/io/github/hyochan/openiap/openiap-google/${openIapVersion}/openiap-google-${openIapVersion}.pom`;
|
|
216
|
+
const response = await fetchWithTimeout(pomUrl, {}, { timeoutMs: 15_000, maxAttempts: 2 });
|
|
217
|
+
if (response.ok) {
|
|
218
|
+
const resolved = billingVersionFromPom(await response.text());
|
|
219
|
+
if (resolved) {
|
|
220
|
+
return {
|
|
221
|
+
file: relativeManifest,
|
|
222
|
+
module: resolved.module,
|
|
223
|
+
version: resolved.version,
|
|
224
|
+
expression: `react-native-iap ${declaredVersion} -> ${coordinate}`,
|
|
225
|
+
source: 'transitive',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Network failure must not turn a known IAP dependency into not_used.
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
file: relativeManifest,
|
|
235
|
+
module: 'com.android.billingclient:billing',
|
|
236
|
+
expression: `react-native-iap ${declaredVersion} -> ${coordinate}; Maven Billing version lookup failed`,
|
|
237
|
+
source: 'unresolved',
|
|
238
|
+
};
|
|
239
|
+
}
|
|
90
240
|
function policyAt(now) {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
241
|
+
const deadlineEnd = (date) => new Date(`${date}T23:59:59.999Z`);
|
|
242
|
+
const next = BILLING_SUPPORT_SCHEDULE.find((row) => now <= deadlineEnd(row.submissionDeadline));
|
|
243
|
+
const lastExpired = [...BILLING_SUPPORT_SCHEDULE]
|
|
244
|
+
.filter((row) => now > deadlineEnd(row.submissionDeadline))
|
|
245
|
+
.at(-1);
|
|
95
246
|
return {
|
|
96
|
-
minimumSupportedMajor,
|
|
97
|
-
submissionDeadline:
|
|
98
|
-
extensionDeadline:
|
|
99
|
-
latestKnownMajor:
|
|
247
|
+
minimumSupportedMajor: next?.major ?? null,
|
|
248
|
+
submissionDeadline: lastExpired?.submissionDeadline ?? BILLING_SUPPORT_SCHEDULE[0].submissionDeadline,
|
|
249
|
+
extensionDeadline: lastExpired?.extensionDeadline ?? BILLING_SUPPORT_SCHEDULE[0].extensionDeadline,
|
|
250
|
+
latestKnownMajor: BILLING_SUPPORT_SCHEDULE.at(-1).major,
|
|
251
|
+
scheduleCurrent: Boolean(next),
|
|
252
|
+
knownSchedule: BILLING_SUPPORT_SCHEDULE.map((row) => ({ ...row })),
|
|
100
253
|
sourceUrl: 'https://developer.android.com/google/play/billing/deprecation-faq',
|
|
101
254
|
};
|
|
102
255
|
}
|
|
256
|
+
function scheduleForMajor(major) {
|
|
257
|
+
return BILLING_SUPPORT_SCHEDULE.find((row) => row.major === major);
|
|
258
|
+
}
|
|
259
|
+
function catalogScope(file) {
|
|
260
|
+
const parent = path.dirname(file);
|
|
261
|
+
return path.basename(parent) === 'gradle' ? path.dirname(parent) : parent;
|
|
262
|
+
}
|
|
263
|
+
function isWithin(scope, file) {
|
|
264
|
+
const relative = path.relative(scope, file);
|
|
265
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
266
|
+
}
|
|
103
267
|
export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
104
268
|
const root = path.resolve(projectPath);
|
|
269
|
+
let rootStat;
|
|
270
|
+
try {
|
|
271
|
+
rootStat = await fs.stat(root);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
throw new Error(`Android project path does not exist or is not readable: ${root}`);
|
|
275
|
+
}
|
|
276
|
+
if (!rootStat.isDirectory())
|
|
277
|
+
throw new Error(`Android project path is not a directory: ${root}`);
|
|
105
278
|
const files = await walk(root);
|
|
106
279
|
const texts = new Map();
|
|
107
280
|
for (const file of files)
|
|
108
281
|
texts.set(file, await fs.readFile(file, 'utf8'));
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
282
|
+
const catalogs = files
|
|
283
|
+
.filter((file) => path.basename(file) === 'libs.versions.toml')
|
|
284
|
+
.map((file) => ({ file, scope: catalogScope(file), info: parseCatalog(texts.get(file) ?? '') }));
|
|
285
|
+
const variablesByFile = new Map();
|
|
112
286
|
for (const [file, text] of texts) {
|
|
113
287
|
if (path.basename(file) === 'libs.versions.toml')
|
|
114
288
|
continue;
|
|
115
|
-
|
|
116
|
-
allVariables.set(key, value);
|
|
289
|
+
variablesByFile.set(file, collectVariables(text));
|
|
117
290
|
}
|
|
291
|
+
const catalogFor = (file) => catalogs
|
|
292
|
+
.filter((entry) => isWithin(entry.scope, file))
|
|
293
|
+
.sort((left, right) => right.scope.length - left.scope.length)[0]?.info
|
|
294
|
+
?? (catalogs.length === 1 ? catalogs[0].info : undefined);
|
|
295
|
+
const variableFor = (file, key) => [...variablesByFile.entries()]
|
|
296
|
+
.filter(([candidate]) => candidate === file || isWithin(path.dirname(candidate), file))
|
|
297
|
+
.sort(([left], [right]) => path.dirname(right).length - path.dirname(left).length)
|
|
298
|
+
.map(([, variables]) => variables.get(key))
|
|
299
|
+
.find((value) => value !== undefined);
|
|
118
300
|
const evidence = [];
|
|
301
|
+
for (const [file, text] of texts) {
|
|
302
|
+
if (path.basename(file) !== 'package.json')
|
|
303
|
+
continue;
|
|
304
|
+
const transitive = await reactNativeIapEvidence(root, file, text);
|
|
305
|
+
if (transitive && !evidence.some((row) => row.expression === transitive.expression))
|
|
306
|
+
evidence.push(transitive);
|
|
307
|
+
}
|
|
119
308
|
for (const [file, text] of texts) {
|
|
120
309
|
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
|
-
}
|
|
310
|
+
if (path.basename(file) === 'libs.versions.toml' || path.basename(file) === 'package.json') {
|
|
134
311
|
continue;
|
|
135
312
|
}
|
|
313
|
+
const catalog = catalogFor(file);
|
|
136
314
|
for (const match of text.matchAll(LITERAL_DEPENDENCY)) {
|
|
137
315
|
evidence.push({
|
|
138
316
|
file: relative,
|
|
@@ -142,7 +320,7 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
142
320
|
});
|
|
143
321
|
}
|
|
144
322
|
for (const match of text.matchAll(VARIABLE_DEPENDENCY)) {
|
|
145
|
-
const version =
|
|
323
|
+
const version = variableFor(file, match[1]);
|
|
146
324
|
evidence.push({
|
|
147
325
|
file: relative,
|
|
148
326
|
module: match[0].slice(0, match[0].lastIndexOf(':')),
|
|
@@ -151,12 +329,34 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
151
329
|
source: version ? 'variable' : 'unresolved',
|
|
152
330
|
});
|
|
153
331
|
}
|
|
332
|
+
for (const bundleMatch of text.matchAll(/\blibs\.bundles\.([A-Za-z0-9_.-]+)/g)) {
|
|
333
|
+
const bundleAlias = normalizeAlias(bundleMatch[1]);
|
|
334
|
+
const libraryAliases = catalog?.bundles.get(bundleAlias) ?? catalog?.bundles.get(bundleMatch[1]) ?? [];
|
|
335
|
+
for (const libraryAlias of libraryAliases) {
|
|
336
|
+
const normalizedLibraryAlias = normalizeAlias(libraryAlias);
|
|
337
|
+
const lib = catalog?.libraries.get(normalizedLibraryAlias) ?? catalog?.libraries.get(libraryAlias);
|
|
338
|
+
if (!lib?.module || !BILLING_MODULE.test(lib.module))
|
|
339
|
+
continue;
|
|
340
|
+
const version = lib.version ?? (lib.versionRef ? catalog?.versions.get(lib.versionRef) : undefined);
|
|
341
|
+
if (!evidence.some((row) => row.file === relative && row.expression === bundleMatch[0] && row.module === lib.module)) {
|
|
342
|
+
evidence.push({
|
|
343
|
+
file: relative,
|
|
344
|
+
module: lib.module,
|
|
345
|
+
version,
|
|
346
|
+
expression: bundleMatch[0],
|
|
347
|
+
source: version ? 'version_catalog' : 'unresolved',
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
154
352
|
for (const aliasMatch of text.matchAll(/\blibs\.([A-Za-z0-9_.-]+)/g)) {
|
|
353
|
+
if (aliasMatch[1].startsWith('bundles.'))
|
|
354
|
+
continue;
|
|
155
355
|
const alias = normalizeAlias(aliasMatch[1]);
|
|
156
|
-
const lib = catalog
|
|
356
|
+
const lib = catalog?.libraries.get(alias) ?? catalog?.libraries.get(aliasMatch[1]);
|
|
157
357
|
if (!lib?.module || !BILLING_MODULE.test(lib.module))
|
|
158
358
|
continue;
|
|
159
|
-
const version = lib.version ?? (lib.versionRef ? catalog
|
|
359
|
+
const version = lib.version ?? (lib.versionRef ? catalog?.versions.get(lib.versionRef) : undefined);
|
|
160
360
|
if (!evidence.some((row) => row.file === relative && row.expression === aliasMatch[0])) {
|
|
161
361
|
evidence.push({
|
|
162
362
|
file: relative,
|
|
@@ -187,21 +387,33 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
187
387
|
status = 'not_used';
|
|
188
388
|
summary = 'Google Play Billing dependency was not found in the scanned Gradle project.';
|
|
189
389
|
}
|
|
390
|
+
else if (!policy.scheduleCurrent || policy.minimumSupportedMajor === null) {
|
|
391
|
+
status = 'unresolved';
|
|
392
|
+
summary = `The official schedule embedded in this release ends at Billing Library ${policy.latestKnownMajor}; current policy must be refreshed from the source.`;
|
|
393
|
+
actions.push('Check the official Billing deprecation table and update Mimi Seed before relying on this result.');
|
|
394
|
+
}
|
|
190
395
|
else if (majors.some((major) => major < policy.minimumSupportedMajor)) {
|
|
191
396
|
status = 'blocker';
|
|
192
397
|
summary = `Billing Library ${detectedVersions.join(', ')} is below the submission minimum major ${policy.minimumSupportedMajor}.`;
|
|
193
398
|
actions.push(`Upgrade to a supported Billing Library before submitting a new app or update.`);
|
|
194
|
-
|
|
399
|
+
for (const major of [...new Set(majors.filter((value) => value < policy.minimumSupportedMajor))].sort()) {
|
|
400
|
+
const schedule = scheduleForMajor(major);
|
|
401
|
+
actions.push(schedule
|
|
402
|
+
? `Billing Library ${major}: standard deadline ${schedule.submissionDeadline}; extension deadline ${schedule.extensionDeadline} only if Google granted it in Play Console.`
|
|
403
|
+
: `Billing Library ${major}: its deadline predates the embedded official table; no active extension should be assumed.`);
|
|
404
|
+
}
|
|
195
405
|
}
|
|
196
406
|
else if (unresolved || majors.length === 0) {
|
|
197
407
|
status = 'unresolved';
|
|
198
408
|
summary = 'A Billing dependency was found, but at least one version expression could not be resolved statically.';
|
|
199
|
-
actions.push('Resolve the reported Gradle
|
|
409
|
+
actions.push('Resolve the reported Gradle/version catalog expression or install the declared IAP package, then run the check again.');
|
|
200
410
|
}
|
|
201
411
|
else if (majors.some((major) => major === policy.minimumSupportedMajor)) {
|
|
202
412
|
status = 'warning';
|
|
203
413
|
summary = `Billing Library ${detectedVersions.join(', ')} is currently supported but is the next major scheduled for deprecation.`;
|
|
204
|
-
|
|
414
|
+
const nextDeadline = scheduleForMajor(policy.minimumSupportedMajor);
|
|
415
|
+
if (nextDeadline)
|
|
416
|
+
actions.push(`Plan an upgrade before ${nextDeadline.submissionDeadline}.`);
|
|
205
417
|
}
|
|
206
418
|
else {
|
|
207
419
|
status = 'pass';
|
package/dist/checks/risks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { publisher, withEdit } from '../playstore/tools.js';
|
|
2
2
|
import { apiGet } from '../appstore/tools.js';
|
|
3
|
+
import { isNotFound } from '../appstore/http.js';
|
|
3
4
|
const APPSTORE_EDITABLE_STATES = 'PREPARE_FOR_SUBMISSION,DEVELOPER_REJECTED,METADATA_REJECTED,REJECTED';
|
|
4
5
|
export async function checkPlayStoreRisks(auth, packageName, language = 'ko-KR') {
|
|
5
6
|
const risks = [];
|
|
@@ -145,9 +146,19 @@ export async function checkAppStoreRisks(appId, expected = {}) {
|
|
|
145
146
|
'fields[appInfoLocalizations]': 'locale,privacyPolicyUrl,privacyPolicyText',
|
|
146
147
|
'limit': '200',
|
|
147
148
|
}), risks, 'PRIVACY', '개인정보 로컬라이제이션'),
|
|
148
|
-
safeGet(() =>
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
safeGet(async () => {
|
|
150
|
+
try {
|
|
151
|
+
return await apiGet(`/appInfos/${editable.id}/ageRatingDeclaration`, {
|
|
152
|
+
'fields[ageRatingDeclarations]': 'userGeneratedContent,socialMedia,socialMediaAgeRestricted,ageAssurance',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
// A 404 means the declaration is genuinely absent, not that the check is unknown.
|
|
157
|
+
if (isNotFound(error))
|
|
158
|
+
return { data: null };
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}, risks, 'AGE_RATING_SOCIAL', '연령등급 소셜 미디어 응답'),
|
|
151
162
|
]);
|
|
152
163
|
if (localizations) {
|
|
153
164
|
const locs = (localizations.data ?? []);
|
|
@@ -175,7 +186,18 @@ export async function checkAppStoreRisks(appId, expected = {}) {
|
|
|
175
186
|
detail: 'socialMediaAgeRestricted=true는 socialMedia=true인 앱에서만 의미가 있습니다.',
|
|
176
187
|
});
|
|
177
188
|
}
|
|
178
|
-
if (
|
|
189
|
+
if (declaration.socialMediaAgeRestricted === true && declaration.ageAssurance !== true) {
|
|
190
|
+
risks.push({
|
|
191
|
+
level: 'blocker',
|
|
192
|
+
code: 'SOCIAL_MEDIA_AGE_ASSURANCE_MISSING',
|
|
193
|
+
title: '13세 미만 소셜 기능 제한에 연령 확인이 없음',
|
|
194
|
+
detail: 'Apple은 socialMediaAgeRestricted=true인 경우 최소한 Declared Age Range API로 연령 범위를 확인하도록 요구합니다. ageAssurance 응답과 실제 구현을 확인하세요.',
|
|
195
|
+
fixUrl: 'https://developer.apple.com/help/app-store-connect/reference/app-information/age-ratings-values-and-definitions',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (expected.socialMedia !== undefined
|
|
199
|
+
&& declaration.socialMedia !== undefined
|
|
200
|
+
&& declaration.socialMedia !== expected.socialMedia) {
|
|
179
201
|
risks.push({
|
|
180
202
|
level: 'blocker',
|
|
181
203
|
code: 'SOCIAL_MEDIA_CAPABILITY_MISMATCH',
|
|
@@ -184,6 +206,7 @@ export async function checkAppStoreRisks(appId, expected = {}) {
|
|
|
184
206
|
});
|
|
185
207
|
}
|
|
186
208
|
if (expected.socialMediaAgeRestricted !== undefined
|
|
209
|
+
&& declaration.socialMediaAgeRestricted !== undefined
|
|
187
210
|
&& declaration.socialMediaAgeRestricted !== expected.socialMediaAgeRestricted) {
|
|
188
211
|
risks.push({
|
|
189
212
|
level: 'blocker',
|
|
@@ -18,26 +18,14 @@ interface RemoteConfigTemplate {
|
|
|
18
18
|
updateType?: string;
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
-
interface
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
}>;
|
|
21
|
+
interface PagedItems<T> {
|
|
22
|
+
items: T[];
|
|
23
|
+
pages: number;
|
|
24
|
+
truncated: boolean;
|
|
40
25
|
}
|
|
26
|
+
export declare function countStates(items: Array<{
|
|
27
|
+
state?: string;
|
|
28
|
+
}>): Record<string, number>;
|
|
41
29
|
export declare function estimateRemoteConfigDailyCost(fetches: number): number;
|
|
42
30
|
export declare function remoteConfigUsageLevel(fetches: number): 'ok' | 'warning' | 'critical';
|
|
43
31
|
export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
|
|
@@ -87,6 +75,13 @@ export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
|
|
|
87
75
|
date: string;
|
|
88
76
|
fetches: number;
|
|
89
77
|
} | null;
|
|
78
|
+
peak: {
|
|
79
|
+
utilizationPercent: number;
|
|
80
|
+
level: "ok" | "warning" | "critical";
|
|
81
|
+
projectedStandardDailyCostUsd: number;
|
|
82
|
+
date: string;
|
|
83
|
+
fetches: number;
|
|
84
|
+
} | null;
|
|
90
85
|
};
|
|
91
86
|
template: OptionalResult<RemoteConfigTemplate> | {
|
|
92
87
|
available: boolean;
|
|
@@ -103,29 +98,65 @@ export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
|
|
|
103
98
|
parameterGroupCount: number;
|
|
104
99
|
conditionCount: number;
|
|
105
100
|
};
|
|
106
|
-
experiments: OptionalResult<
|
|
101
|
+
experiments: OptionalResult<PagedItems<{
|
|
102
|
+
name?: string;
|
|
103
|
+
state?: string;
|
|
104
|
+
startTime?: string;
|
|
105
|
+
endTime?: string;
|
|
106
|
+
lastUpdateTime?: string;
|
|
107
|
+
definition?: unknown;
|
|
108
|
+
}>> | {
|
|
107
109
|
available: boolean;
|
|
108
110
|
total: number;
|
|
111
|
+
stateCounts: Record<string, number>;
|
|
109
112
|
active: {
|
|
110
|
-
name
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
name: string | undefined;
|
|
114
|
+
displayName: string | undefined;
|
|
115
|
+
state: string | undefined;
|
|
116
|
+
startTime: string | undefined;
|
|
117
|
+
endTime: string | undefined;
|
|
118
|
+
lastUpdateTime: string | undefined;
|
|
116
119
|
}[];
|
|
120
|
+
recent: {
|
|
121
|
+
name: string | undefined;
|
|
122
|
+
displayName: string | undefined;
|
|
123
|
+
state: string | undefined;
|
|
124
|
+
startTime: string | undefined;
|
|
125
|
+
endTime: string | undefined;
|
|
126
|
+
lastUpdateTime: string | undefined;
|
|
127
|
+
}[];
|
|
128
|
+
pages: number;
|
|
129
|
+
truncated: boolean;
|
|
117
130
|
};
|
|
118
|
-
rollouts: OptionalResult<
|
|
131
|
+
rollouts: OptionalResult<PagedItems<{
|
|
132
|
+
name?: string;
|
|
133
|
+
state?: string;
|
|
134
|
+
startTime?: string;
|
|
135
|
+
endTime?: string;
|
|
136
|
+
lastUpdateTime?: string;
|
|
137
|
+
definition?: unknown;
|
|
138
|
+
}>> | {
|
|
119
139
|
available: boolean;
|
|
120
140
|
total: number;
|
|
141
|
+
stateCounts: Record<string, number>;
|
|
121
142
|
active: {
|
|
122
|
-
name
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
143
|
+
name: string | undefined;
|
|
144
|
+
displayName: string | undefined;
|
|
145
|
+
state: string | undefined;
|
|
146
|
+
startTime: string | undefined;
|
|
147
|
+
endTime: string | undefined;
|
|
148
|
+
lastUpdateTime: string | undefined;
|
|
149
|
+
}[];
|
|
150
|
+
recent: {
|
|
151
|
+
name: string | undefined;
|
|
152
|
+
displayName: string | undefined;
|
|
153
|
+
state: string | undefined;
|
|
154
|
+
startTime: string | undefined;
|
|
155
|
+
endTime: string | undefined;
|
|
156
|
+
lastUpdateTime: string | undefined;
|
|
128
157
|
}[];
|
|
158
|
+
pages: number;
|
|
159
|
+
truncated: boolean;
|
|
129
160
|
};
|
|
130
161
|
warnings: string[];
|
|
131
162
|
}>;
|
|
@@ -3,6 +3,14 @@ import { getBillingInfo } from '../billing/tools.js';
|
|
|
3
3
|
const FREE_DAILY_FETCHES = 100_000;
|
|
4
4
|
const FIRST_PAID_TIER_END = 10_000_000;
|
|
5
5
|
const REMOTE_CONFIG_BASE = 'https://firebaseremoteconfig.googleapis.com/v1';
|
|
6
|
+
export function countStates(items) {
|
|
7
|
+
const counts = {};
|
|
8
|
+
for (const item of items) {
|
|
9
|
+
const state = item.state ?? 'UNSPECIFIED';
|
|
10
|
+
counts[state] = (counts[state] ?? 0) + 1;
|
|
11
|
+
}
|
|
12
|
+
return counts;
|
|
13
|
+
}
|
|
6
14
|
async function optionalRequest(auth, url) {
|
|
7
15
|
try {
|
|
8
16
|
const response = await auth.request({ url });
|
|
@@ -15,6 +23,31 @@ async function optionalRequest(auth, url) {
|
|
|
15
23
|
};
|
|
16
24
|
}
|
|
17
25
|
}
|
|
26
|
+
async function optionalPagedRequest(auth, url, field) {
|
|
27
|
+
try {
|
|
28
|
+
const items = [];
|
|
29
|
+
let pageToken;
|
|
30
|
+
let pages = 0;
|
|
31
|
+
do {
|
|
32
|
+
const requestUrl = new URL(url);
|
|
33
|
+
requestUrl.searchParams.set('pageSize', '100');
|
|
34
|
+
if (pageToken)
|
|
35
|
+
requestUrl.searchParams.set('pageToken', pageToken);
|
|
36
|
+
const response = await auth.request({ url: requestUrl.toString() });
|
|
37
|
+
const data = response.data;
|
|
38
|
+
items.push(...(data[field] ?? []));
|
|
39
|
+
pageToken = data.nextPageToken;
|
|
40
|
+
pages += 1;
|
|
41
|
+
} while (pageToken && pages < 10);
|
|
42
|
+
return { available: true, data: { items, pages, truncated: Boolean(pageToken) } };
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
available: false,
|
|
47
|
+
error: error instanceof Error ? error.message : String(error),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
18
51
|
function numericPoint(point) {
|
|
19
52
|
const raw = point.value?.int64Value ?? point.value?.doubleValue ?? 0;
|
|
20
53
|
const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : raw;
|
|
@@ -52,12 +85,17 @@ async function fetchDailyUsage(auth, projectId, days) {
|
|
|
52
85
|
view: 'FULL',
|
|
53
86
|
pageSize: Math.max(days + 2, 10),
|
|
54
87
|
});
|
|
55
|
-
const
|
|
88
|
+
const points = (response.data.timeSeries ?? [])
|
|
56
89
|
.flatMap((series) => series.points ?? [])
|
|
57
90
|
.map((point) => ({
|
|
58
91
|
date: (point.interval?.endTime ?? end.toISOString()).slice(0, 10),
|
|
59
92
|
fetches: numericPoint(point),
|
|
60
|
-
}))
|
|
93
|
+
}));
|
|
94
|
+
const byDate = new Map();
|
|
95
|
+
for (const point of points)
|
|
96
|
+
byDate.set(point.date, (byDate.get(point.date) ?? 0) + point.fetches);
|
|
97
|
+
const rows = [...byDate.entries()]
|
|
98
|
+
.map(([date, fetches]) => ({ date, fetches }))
|
|
61
99
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
62
100
|
return { available: true, data: rows };
|
|
63
101
|
}
|
|
@@ -76,8 +114,8 @@ export async function getRemoteConfigOverview(auth, input) {
|
|
|
76
114
|
const parent = `${REMOTE_CONFIG_BASE}/projects/${encodedProject}/namespaces/${encodedNamespace}`;
|
|
77
115
|
const [template, experiments, rollouts, usage, billing] = await Promise.all([
|
|
78
116
|
optionalRequest(auth, `${parent}/remoteConfig`),
|
|
79
|
-
|
|
80
|
-
|
|
117
|
+
optionalPagedRequest(auth, `${parent}/experiments`, 'experiments'),
|
|
118
|
+
optionalPagedRequest(auth, `${parent}/rollouts`, 'rollouts'),
|
|
81
119
|
fetchDailyUsage(auth, input.projectId, days),
|
|
82
120
|
getBillingInfo(auth, input.projectId)
|
|
83
121
|
.then((data) => ({ available: true, data }))
|
|
@@ -88,8 +126,45 @@ export async function getRemoteConfigOverview(auth, input) {
|
|
|
88
126
|
]);
|
|
89
127
|
const daily = usage.data ?? [];
|
|
90
128
|
const latest = daily.at(-1);
|
|
91
|
-
const
|
|
92
|
-
const
|
|
129
|
+
const peak = daily.reduce((current, point) => !current || point.fetches > current.fetches ? point : current, undefined);
|
|
130
|
+
const withUsageProjection = (point) => ({
|
|
131
|
+
...point,
|
|
132
|
+
utilizationPercent: Number(((point.fetches / FREE_DAILY_FETCHES) * 100).toFixed(2)),
|
|
133
|
+
level: remoteConfigUsageLevel(point.fetches),
|
|
134
|
+
projectedStandardDailyCostUsd: Number(estimateRemoteConfigDailyCost(point.fetches).toFixed(4)),
|
|
135
|
+
});
|
|
136
|
+
const summarizeExperiment = (item) => ({
|
|
137
|
+
name: item.name,
|
|
138
|
+
displayName: item.definition?.displayName,
|
|
139
|
+
state: item.state,
|
|
140
|
+
startTime: item.startTime,
|
|
141
|
+
endTime: item.endTime,
|
|
142
|
+
lastUpdateTime: item.lastUpdateTime,
|
|
143
|
+
});
|
|
144
|
+
const summarizeRollout = (item) => ({
|
|
145
|
+
name: item.name,
|
|
146
|
+
displayName: item.definition?.displayName,
|
|
147
|
+
state: item.state,
|
|
148
|
+
startTime: item.startTime,
|
|
149
|
+
endTime: item.endTime,
|
|
150
|
+
lastUpdateTime: item.lastUpdateTime,
|
|
151
|
+
});
|
|
152
|
+
const activeExperiments = experiments.data?.items
|
|
153
|
+
.filter((item) => item.state === 'RUNNING')
|
|
154
|
+
.map(summarizeExperiment) ?? [];
|
|
155
|
+
const activeRollouts = rollouts.data?.items
|
|
156
|
+
.filter((item) => item.state === 'RUNNING')
|
|
157
|
+
.map(summarizeRollout) ?? [];
|
|
158
|
+
const recentExperiments = [...(experiments.data?.items ?? [])]
|
|
159
|
+
.sort((left, right) => (right.lastUpdateTime ?? right.endTime ?? right.startTime ?? '')
|
|
160
|
+
.localeCompare(left.lastUpdateTime ?? left.endTime ?? left.startTime ?? ''))
|
|
161
|
+
.slice(0, 20)
|
|
162
|
+
.map(summarizeExperiment);
|
|
163
|
+
const recentRollouts = [...(rollouts.data?.items ?? [])]
|
|
164
|
+
.sort((left, right) => (right.lastUpdateTime ?? right.endTime ?? right.startTime ?? '')
|
|
165
|
+
.localeCompare(left.lastUpdateTime ?? left.endTime ?? left.startTime ?? ''))
|
|
166
|
+
.slice(0, 20)
|
|
167
|
+
.map(summarizeRollout);
|
|
93
168
|
return {
|
|
94
169
|
projectId: input.projectId,
|
|
95
170
|
namespace,
|
|
@@ -113,14 +188,8 @@ export async function getRemoteConfigOverview(auth, input) {
|
|
|
113
188
|
available: true,
|
|
114
189
|
metric: 'firebaseremoteconfig.googleapis.com/project/fetch_request_count',
|
|
115
190
|
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,
|
|
191
|
+
latest: latest ? withUsageProjection(latest) : null,
|
|
192
|
+
peak: peak ? withUsageProjection(peak) : null,
|
|
124
193
|
}
|
|
125
194
|
: usage,
|
|
126
195
|
template: template.available
|
|
@@ -135,26 +204,36 @@ export async function getRemoteConfigOverview(auth, input) {
|
|
|
135
204
|
experiments: experiments.available
|
|
136
205
|
? {
|
|
137
206
|
available: true,
|
|
138
|
-
total: experiments.data?.
|
|
207
|
+
total: experiments.data?.items.length ?? 0,
|
|
208
|
+
stateCounts: countStates(experiments.data?.items ?? []),
|
|
139
209
|
active: activeExperiments,
|
|
210
|
+
recent: recentExperiments,
|
|
211
|
+
pages: experiments.data?.pages ?? 0,
|
|
212
|
+
truncated: experiments.data?.truncated ?? false,
|
|
140
213
|
}
|
|
141
214
|
: experiments,
|
|
142
215
|
rollouts: rollouts.available
|
|
143
216
|
? {
|
|
144
217
|
available: true,
|
|
145
|
-
total: rollouts.data?.
|
|
218
|
+
total: rollouts.data?.items.length ?? 0,
|
|
219
|
+
stateCounts: countStates(rollouts.data?.items ?? []),
|
|
146
220
|
active: activeRollouts,
|
|
221
|
+
recent: recentRollouts,
|
|
222
|
+
pages: rollouts.data?.pages ?? 0,
|
|
223
|
+
truncated: rollouts.data?.truncated ?? false,
|
|
147
224
|
}
|
|
148
225
|
: rollouts,
|
|
149
226
|
warnings: [
|
|
150
|
-
...(
|
|
151
|
-
? [`
|
|
227
|
+
...(peak && remoteConfigUsageLevel(peak.fetches) !== 'ok'
|
|
228
|
+
? [`Peak daily fetch usage is ${remoteConfigUsageLevel(peak.fetches)} at ${peak.fetches.toLocaleString()} requests on ${peak.date}.`]
|
|
152
229
|
: []),
|
|
153
|
-
...(
|
|
230
|
+
...(peak && peak.fetches >= FREE_DAILY_FETCHES && billing.available && !billing.data.billingEnabled
|
|
154
231
|
? ['Spark projects risk throttling above 100,000 daily fetches after the applicable grace period; upgrade to Blaze for uninterrupted overage.']
|
|
155
232
|
: []),
|
|
156
233
|
...(!usage.available ? ['Cloud Monitoring usage is unavailable; no fetch count was estimated.'] : []),
|
|
157
234
|
...(!billing.available ? ['Billing plan could not be verified; cost/throttling interpretation is incomplete.'] : []),
|
|
235
|
+
...(experiments.data?.truncated ? ['Experiment list exceeded 1,000 items and was truncated.'] : []),
|
|
236
|
+
...(rollouts.data?.truncated ? ['Rollout list exceeded 1,000 items and was truncated.'] : []),
|
|
158
237
|
],
|
|
159
238
|
};
|
|
160
239
|
}
|
package/dist/registers/checks.js
CHANGED
|
@@ -9,7 +9,7 @@ import { checkBillingCompliance } from '../checks/billing.js';
|
|
|
9
9
|
export function registerChecksTools(server) {
|
|
10
10
|
server.tool('android_check_billing_compliance', [
|
|
11
11
|
'로컬 Android 저장소의 Google Play Billing Library 버전을 읽기 전용으로 탐지하고 제출 정책 마감과 비교합니다.',
|
|
12
|
-
'build.gradle, build.gradle.kts, gradle/libs.versions.toml의 literal·변수·version catalog
|
|
12
|
+
'build.gradle, build.gradle.kts, gradle/libs.versions.toml의 literal·변수·version catalog와 react-native-iap의 OpenIAP 전이 의존성을 검사합니다.',
|
|
13
13
|
'코드를 자동 수정하지 않고 공식 Android CLI Skill 설치 명령과 업그레이드 프롬프트만 반환합니다.',
|
|
14
14
|
].join(' '), {
|
|
15
15
|
projectPath: z.string().optional().describe('검사할 프로젝트 절대경로 (기본: MCP 프로세스 현재 디렉터리)'),
|
package/package.json
CHANGED