@yoonion/mimi-seed-mcp 0.21.4 → 0.21.6

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.
Files changed (34) hide show
  1. package/dist/appstore/auth.d.ts +3 -1
  2. package/dist/appstore/auth.js +29 -4
  3. package/dist/appstore/client.d.ts +1 -0
  4. package/dist/appstore/client.js +10 -59
  5. package/dist/appstore/http.d.ts +5 -0
  6. package/dist/appstore/products.js +3 -15
  7. package/dist/appstore/sales.js +35 -10
  8. package/dist/auth/google-auth.d.ts +0 -13
  9. package/dist/auth/google-auth.js +13 -1
  10. package/dist/core/checks/release-doctor/fcm.d.ts +3 -0
  11. package/dist/core/checks/release-doctor/fcm.js +270 -0
  12. package/dist/core/checks/release-doctor/files.d.ts +16 -0
  13. package/dist/core/checks/release-doctor/files.js +312 -0
  14. package/dist/core/checks/release-doctor/gradle-apply.d.ts +18 -0
  15. package/dist/core/checks/release-doctor/gradle-apply.js +423 -0
  16. package/dist/core/checks/release-doctor/ios-xcode.d.ts +2 -0
  17. package/dist/core/checks/release-doctor/ios-xcode.js +299 -0
  18. package/dist/core/checks/release-doctor/project.d.ts +101 -0
  19. package/dist/core/checks/release-doctor/project.js +828 -0
  20. package/dist/core/checks/release-doctor/target-sdk.d.ts +102 -0
  21. package/dist/core/checks/release-doctor/target-sdk.js +868 -0
  22. package/dist/core/checks/release-doctor/types.d.ts +60 -0
  23. package/dist/core/checks/release-doctor/types.js +3 -0
  24. package/dist/core/checks/release-doctor/util.d.ts +26 -0
  25. package/dist/core/checks/release-doctor/util.js +95 -0
  26. package/dist/core/checks/release-doctor.d.ts +3 -75
  27. package/dist/core/checks/release-doctor.js +9 -3057
  28. package/dist/helpers.d.ts +1 -1
  29. package/dist/helpers.js +19 -12
  30. package/dist/playstore/financials.js +1 -1
  31. package/dist/registers/android.js +1 -1
  32. package/dist/registers/checks.js +1 -1
  33. package/dist/registers/playstore.js +26 -26
  34. package/package.json +2 -2
@@ -43,7 +43,9 @@ export declare function normalizeVendorNumber(vendorNumber?: string): string | u
43
43
  export declare function mergeAppStoreCredentials(existing: AppStoreCredentials | null, primary: AppStoreKey, vendorNumber?: string): AppStoreCredentials;
44
44
  export declare function getAppStoreCredentials(): AppStoreCredentials | null;
45
45
  export declare function saveAppStoreCredentials(creds: AppStoreCredentials): void;
46
- export declare function generateToken(creds: AppStoreCredentials): Promise<string>;
46
+ export declare function generateToken(creds: AppStoreKey): Promise<string>;
47
+ /** 테스트용 — 캐시 초기화. */
48
+ export declare function clearAppStoreTokenCache(): void;
47
49
  export declare function getAuthHeaders(): Promise<Record<string, string> | null>;
48
50
  /**
49
51
  * 매출 리포트용 헤더 — reportsKey 가 있으면 그걸로, 없으면 최상위 키로 폴백.
@@ -86,12 +86,37 @@ export async function generateToken(creds) {
86
86
  .setAudience('appstoreconnect-v1')
87
87
  .sign(key);
88
88
  }
89
+ /** 토큰 수명(20분) 중 이만큼 남으면 새로 서명한다 — 긴 업로드 도중 만료되지 않게. */
90
+ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
91
+ const TOKEN_LIFETIME_MS = 19 * 60 * 1000; // iat 를 60초 당겨 서명하므로 실제 잔여는 19분
92
+ /**
93
+ * 키별 JWT 캐시. 예전엔 요청마다 PKCS8 import + ES256 서명을 다시 했다 (매출 DAILY 조회는 최대 62회 연속).
94
+ * 캐시 키에 privateKey 자체를 포함해 키 교체·재등록 시 자동으로 무효화된다.
95
+ */
96
+ const tokenCache = new Map();
97
+ function cachedToken(key) {
98
+ const cacheKey = `${key.issuerId}\u0000${key.keyId}\u0000${key.privateKey}`;
99
+ const hit = tokenCache.get(cacheKey);
100
+ if (hit && hit.expiresAt - Date.now() > TOKEN_REFRESH_MARGIN_MS)
101
+ return hit.token;
102
+ const token = generateToken(key);
103
+ tokenCache.set(cacheKey, { token, expiresAt: Date.now() + TOKEN_LIFETIME_MS });
104
+ // 서명 실패는 캐시하지 않는다 — 다음 호출이 다시 시도해 원인 메시지를 받게.
105
+ token.catch(() => {
106
+ if (tokenCache.get(cacheKey)?.token === token)
107
+ tokenCache.delete(cacheKey);
108
+ });
109
+ return token;
110
+ }
111
+ /** 테스트용 — 캐시 초기화. */
112
+ export function clearAppStoreTokenCache() {
113
+ tokenCache.clear();
114
+ }
89
115
  export async function getAuthHeaders() {
90
116
  const creds = getAppStoreCredentials();
91
117
  if (!creds)
92
118
  return null;
93
- const token = await generateToken(creds);
94
- return { Authorization: `Bearer ${token}` };
119
+ return { Authorization: `Bearer ${await cachedToken(creds)}` };
95
120
  }
96
121
  /**
97
122
  * 매출 리포트용 헤더 — reportsKey 가 있으면 그걸로, 없으면 최상위 키로 폴백.
@@ -105,12 +130,12 @@ export async function getReportsAuthHeaders() {
105
130
  return null;
106
131
  if (creds.reportsKey) {
107
132
  return {
108
- headers: { Authorization: `Bearer ${await generateToken(creds.reportsKey)}` },
133
+ headers: { Authorization: `Bearer ${await cachedToken(creds.reportsKey)}` },
109
134
  source: 'reportsKey',
110
135
  };
111
136
  }
112
137
  return {
113
- headers: { Authorization: `Bearer ${await generateToken(creds)}` },
138
+ headers: { Authorization: `Bearer ${await cachedToken(creds)}` },
114
139
  source: 'default',
115
140
  };
116
141
  }
@@ -18,4 +18,5 @@ export interface AppStoreVerifyResult {
18
18
  */
19
19
  export declare function verifyAppStoreCredentials(creds?: import("./auth.js").AppStoreCredentials | null): Promise<AppStoreVerifyResult>;
20
20
  export declare function apiPatch(path: string, body: unknown): Promise<any>;
21
+ /** 201 Created — 본문에 created entity. 일부 엔드포인트는 204 (→ { ok: true }). */
21
22
  export declare function apiPost(path: string, body: unknown): Promise<any>;
@@ -1,34 +1,14 @@
1
- import { getAuthHeaders, getAppStoreCredentials, generateToken } from './auth.js';
2
- import { friendlyAppStoreError } from './errors.js';
1
+ import { getAppStoreCredentials, generateToken } from './auth.js';
2
+ import { authHeadersOrThrow, apiRequest, V1_BASE } from './http.js';
3
3
  import { fetchWithTimeout } from '../lib/http.js';
4
4
  /**
5
5
  * App Store Connect API v1 래퍼
6
6
  * https://developer.apple.com/documentation/appstoreconnectapi
7
7
  */
8
- const BASE = 'https://api.appstoreconnect.apple.com/v1';
8
+ const BASE = V1_BASE;
9
9
  export async function apiGet(path, params) {
10
- const headers = await getAuthHeaders();
11
- if (!headers)
12
- throw new Error([
13
- '❌ App Store Connect 인증이 필요해.',
14
- '',
15
- '터미널에서 실행:',
16
- ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
17
- '',
18
- 'API Key가 필요해:',
19
- ' App Store Connect > Users and Access > Integrations > Keys',
20
- ].join('\n'));
21
- const url = new URL(`${BASE}${path}`);
22
- if (params) {
23
- for (const [k, v] of Object.entries(params))
24
- url.searchParams.set(k, v);
25
- }
26
- const res = await fetchWithTimeout(url.toString(), { headers });
27
- if (!res.ok) {
28
- const body = await res.text();
29
- throw friendlyAppStoreError(res.status, body);
30
- }
31
- return res.json();
10
+ const query = params ? `?${new URLSearchParams(params).toString()}` : '';
11
+ return apiRequest(BASE, `${path}${query}`, await authHeadersOrThrow(), { method: 'GET' });
32
12
  }
33
13
  /**
34
14
  * appstore.json 자격증명 유효성 단계별 검증 (creds → sign → auth → api).
@@ -85,46 +65,17 @@ export async function verifyAppStoreCredentials(creds = getAppStoreCredentials()
85
65
  };
86
66
  }
87
67
  export async function apiPatch(path, body) {
88
- const headers = await getAuthHeaders();
89
- if (!headers)
90
- throw new Error([
91
- '❌ App Store Connect 인증이 필요해.',
92
- '',
93
- '터미널에서 실행:',
94
- ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
95
- ].join('\n'));
96
- const res = await fetchWithTimeout(`${BASE}${path}`, {
68
+ return apiRequest(BASE, path, await authHeadersOrThrow(), {
97
69
  method: 'PATCH',
98
- headers: { ...headers, 'Content-Type': 'application/json' },
70
+ headers: { 'Content-Type': 'application/json' },
99
71
  body: JSON.stringify(body),
100
72
  });
101
- if (!res.ok) {
102
- const text = await res.text();
103
- throw friendlyAppStoreError(res.status, text);
104
- }
105
- // 204 No Content 가능
106
- const text = await res.text();
107
- return text ? JSON.parse(text) : { ok: true };
108
73
  }
74
+ /** 201 Created — 본문에 created entity. 일부 엔드포인트는 204 (→ { ok: true }). */
109
75
  export async function apiPost(path, body) {
110
- const headers = await getAuthHeaders();
111
- if (!headers)
112
- throw new Error([
113
- '❌ App Store Connect 인증이 필요해.',
114
- '',
115
- '터미널에서 실행:',
116
- ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
117
- ].join('\n'));
118
- const res = await fetchWithTimeout(`${BASE}${path}`, {
76
+ return apiRequest(BASE, path, await authHeadersOrThrow(), {
119
77
  method: 'POST',
120
- headers: { ...headers, 'Content-Type': 'application/json' },
78
+ headers: { 'Content-Type': 'application/json' },
121
79
  body: JSON.stringify(body),
122
80
  });
123
- if (!res.ok) {
124
- const text = await res.text();
125
- throw friendlyAppStoreError(res.status, text);
126
- }
127
- // 201 Created — 본문에 created entity. 일부 엔드포인트는 204
128
- const text = await res.text();
129
- return text ? JSON.parse(text) : { ok: true };
130
81
  }
@@ -1,5 +1,10 @@
1
1
  export declare const V1_BASE = "https://api.appstoreconnect.apple.com/v1";
2
2
  export declare const V2_BASE = "https://api.appstoreconnect.apple.com/v2";
3
+ /**
4
+ * 타입 없는 ASC 응답 — apiGet/apiPost/apiPatch 가 예전부터 `res.json()`(any)을 그대로 돌려줬고
5
+ * 호출부가 그 형태에 기대고 있다. 새 코드는 apiRequest<T> 에 구체 타입을 넘길 것.
6
+ */
7
+ export type UntypedAscJson = any;
3
8
  export type AppStoreProductType = 'subscription' | 'consumable' | 'non_consumable';
4
9
  export declare function authHeadersOrThrow(): Promise<Record<string, string>>;
5
10
  export declare function apiRequest<T>(base: string, resourcePath: string, authHeaders: Record<string, string>, init: RequestInit): Promise<T>;
@@ -1,31 +1,19 @@
1
1
  // App Store Connect — 인앱 구매(IAP v2)·자동 갱신 구독 생성.
2
2
  // appstore/tools.ts 가 이 모듈을 그대로 re-export 한다 — 호출부는 tools.js 경로를 계속 쓴다.
3
- import { getAuthHeaders } from './auth.js';
3
+ import { apiRequest, authHeadersOrThrow, V2_BASE } from './http.js';
4
4
  import { apiGet, apiPost } from './client.js';
5
- import { fetchWithTimeout } from '../lib/http.js';
6
5
  import { encodePathSegment } from '../lib/url-path.js';
7
6
  // ─── 인앱 구매 (IAP) 생성 ───
8
7
  // 2023년 출시된 IAP v2 API (POST /v2/inAppPurchases) 사용.
9
8
  // 흐름: (1) IAP draft 생성 → (2) 로컬라이제이션 → (3) priceSchedule → (4) submission(선택).
10
9
  // 가격은 territory별 pricePoint ID 기반 — IAP를 만든 뒤 GET /pricePoints로 조회해 매칭.
11
10
  // 자동 제출(submission)은 권장 가이드라인 (스크린샷·리뷰 노트 등)이 충족돼야 통과.
12
- const IAP_V2_BASE = 'https://api.appstoreconnect.apple.com/v2';
13
11
  async function apiPostV2(path, body) {
14
- const headers = await getAuthHeaders();
15
- if (!headers) {
16
- throw new Error('App Store Connect 인증 필요 — npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth');
17
- }
18
- const res = await fetchWithTimeout(`${IAP_V2_BASE}${path}`, {
12
+ return apiRequest(V2_BASE, path, await authHeadersOrThrow(), {
19
13
  method: 'POST',
20
- headers: { ...headers, 'Content-Type': 'application/json' },
14
+ headers: { 'Content-Type': 'application/json' },
21
15
  body: JSON.stringify(body),
22
16
  });
23
- if (!res.ok) {
24
- const text = await res.text();
25
- throw new Error(`App Store v2 ${res.status}: ${text}`);
26
- }
27
- const text = await res.text();
28
- return text ? JSON.parse(text) : { ok: true };
29
17
  }
30
18
  async function findClosestPricePoint(resourceUrl, territory, targetPrice) {
31
19
  // resourceUrl 예: '/inAppPurchases/{id}/pricePoints' 또는 '/subscriptions/{id}/pricePoints'
@@ -152,6 +152,28 @@ export function eachDate(startDate, endDate, maxDays) {
152
152
  }
153
153
  /** DAILY 는 하루에 리포트 하나라, 범위 조회는 날짜별 호출을 합치는 수밖에 없다. */
154
154
  const MAX_DAILY_RANGE = 62;
155
+ const SALES_FETCH_CONCURRENCY = 4;
156
+ /** 순서를 보존하며 최대 `limit` 개씩 동시에 실행한다. 하나라도 실패하면 그 에러로 reject. */
157
+ async function mapWithConcurrency(items, limit, fn) {
158
+ const out = new Array(items.length);
159
+ let next = 0;
160
+ let failed = false;
161
+ const worker = async () => {
162
+ // 하나가 실패하면 결과 전체가 버려지므로 새 요청을 더 시작하지 않는다.
163
+ while (!failed && next < items.length) {
164
+ const i = next++;
165
+ try {
166
+ out[i] = await fn(items[i]);
167
+ }
168
+ catch (error) {
169
+ failed = true;
170
+ throw error;
171
+ }
172
+ }
173
+ };
174
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
175
+ return out;
176
+ }
155
177
  export async function getSalesReport(options) {
156
178
  const vendorNumber = resolveVendorNumber(options.vendorNumber);
157
179
  const frequency = options.frequency ?? 'DAILY';
@@ -162,22 +184,25 @@ export async function getSalesReport(options) {
162
184
  const datesWithData = [];
163
185
  const datesWithoutData = [];
164
186
  const rows = [];
165
- for (const reportDate of reportDates) {
166
- const result = await fetchReport('/salesReports', {
167
- 'filter[frequency]': frequency,
168
- 'filter[reportType]': options.reportType ?? 'SALES',
169
- 'filter[reportSubType]': options.reportSubType ?? 'SUMMARY',
170
- 'filter[vendorNumber]': vendorNumber,
171
- 'filter[reportDate]': reportDate,
172
- 'filter[version]': options.version ?? '1_0',
173
- });
187
+ // 일자별 리포트는 서로 독립이라 동시에 받는다. 상한은 ASC 레이트 리밋(시간당 ~3600)에 비해
188
+ // 충분히 낮게 두고, 결과는 날짜 순서를 유지해 합친다. 429 는 fetchWithTimeout 이 재시도한다.
189
+ const results = await mapWithConcurrency(reportDates, SALES_FETCH_CONCURRENCY, (reportDate) => fetchReport('/salesReports', {
190
+ 'filter[frequency]': frequency,
191
+ 'filter[reportType]': options.reportType ?? 'SALES',
192
+ 'filter[reportSubType]': options.reportSubType ?? 'SUMMARY',
193
+ 'filter[vendorNumber]': vendorNumber,
194
+ 'filter[reportDate]': reportDate,
195
+ 'filter[version]': options.version ?? '1_0',
196
+ }));
197
+ results.forEach((result, i) => {
198
+ const reportDate = reportDates[i];
174
199
  if (result.notFound || result.rows.length === 0)
175
200
  datesWithoutData.push(reportDate);
176
201
  else {
177
202
  datesWithData.push(reportDate);
178
203
  rows.push(...result.rows);
179
204
  }
180
- }
205
+ });
181
206
  const lines = rows.map(toSalesLine);
182
207
  const proceedsByCurrency = {};
183
208
  let paidUnits = 0;
@@ -101,19 +101,6 @@ export type RefreshStatus = {
101
101
  status: 'unauthenticated';
102
102
  error: AuthErrorPayload;
103
103
  };
104
- /**
105
- * 저장된 access_token이 만료/곧만료면 refresh_token으로 silent 갱신 시도.
106
- * - 갱신 성공 시 tokens.json 업데이트
107
- * - refresh_token 자체가 invalid한 경우 'expired_refresh_failed' 반환
108
- * - 토큰 자체가 없으면 'unauthenticated'
109
- *
110
- * MCP 도구와 CLI 양쪽에서 공유.
111
- */
112
- /**
113
- * 사전 갱신 마진. 기존 60_000(1분)에서 300_000(5분)으로 상향 — Google OAuth access_token 의
114
- * 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
115
- * "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
116
- */
117
104
  export declare function ensureFreshAccessToken(marginMs?: number, profile?: string): Promise<RefreshStatus>;
118
105
  /**
119
106
  * 저장된 로그인이 어느 Google 계정인지. 로그인 때 기록한 값을 우선 쓰고, 기록이 없는 구 토큰은
@@ -439,7 +439,19 @@ export function startAuth(clientId, clientSecret, options = {}) {
439
439
  * 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
440
440
  * "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
441
441
  */
442
- export async function ensureFreshAccessToken(marginMs = 300_000, profile) {
442
+ // 같은 프로세스에서 병렬 툴 호출이 동시에 만료 임박을 보면 각자 refresh 하고 tokens.json 을
443
+ // 경쟁적으로 덮어썼다. 진행 중인 갱신을 프로필별로 공유해 refresh 를 1회로 합친다.
444
+ const inflightRefresh = new Map();
445
+ export function ensureFreshAccessToken(marginMs = 300_000, profile) {
446
+ const key = `${profile ?? ''}\u0000${marginMs}`;
447
+ const running = inflightRefresh.get(key);
448
+ if (running)
449
+ return running;
450
+ const promise = ensureFreshAccessTokenOnce(marginMs, profile).finally(() => inflightRefresh.delete(key));
451
+ inflightRefresh.set(key, promise);
452
+ return promise;
453
+ }
454
+ async function ensureFreshAccessTokenOnce(marginMs, profile) {
443
455
  const tokens = getStoredTokens(profile);
444
456
  if (!tokens) {
445
457
  return {
@@ -0,0 +1,3 @@
1
+ import type { ProjectFile, ReleaseDoctorFinding } from './types.js';
2
+ export declare const FCM_HINT: RegExp;
3
+ export declare function fcmFindings(files: ProjectFile[], sources: ProjectFile[], root: string, now: Date): Promise<ReleaseDoctorFinding[]>;
@@ -0,0 +1,270 @@
1
+ // Release Doctor Firebase Cloud Messaging legacy API / firebase-admin topic check.
2
+ // Internal to Release Doctor — import the public API (scanReleaseDoctor, report types) from ../release-doctor.js.
3
+ import path from 'node:path';
4
+ import { lockedPackageVersion, lockfileDirectories, readText } from '../lockfile.js';
5
+ import { unique } from './util.js';
6
+ // Firebase Cloud Messaging legacy surfaces.
7
+ // - Legacy HTTP/XMPP send (fcm/send) was deprecated 2023-06-20 and shut down from 2024-07-22; the replacement is
8
+ // the HTTP v1 API.
9
+ // - Instance ID server APIs (iid.googleapis.com, incl. legacy topic management) and device group management
10
+ // (fcm.googleapis.com/fcm/notification and its aliases) are decommissioned 2027-09-29 (FCM troubleshooting FAQ).
11
+ // - firebase-admin (Node) 14.5.0 moved subscribeToTopic/unsubscribeFromTopic off Instance ID onto the FCM v1 topic
12
+ // subscription API and added deprecated *Legacy escape hatches that still use Instance ID. The FAQ lists Node
13
+ // Admin SDK <= 14.4.0 as impacted. firebase-admin 14.x declares engines.node >= 22.
14
+ const FCM_LEGACY_SEND_SOURCE = 'https://firebase.google.com/docs/cloud-messaging/send/v1-api';
15
+ const FCM_DEPRECATION_SOURCE = 'https://firebase.google.com/docs/cloud-messaging/troubleshooting#fcm-26-deprecation';
16
+ const FCM_INSTANCE_ID_DECOMMISSION = '2027-09-29';
17
+ const FIREBASE_ADMIN_TOPICS_SOURCE = 'https://github.com/firebase/firebase-admin-node/releases/tag/v14.5.0';
18
+ const FIREBASE_ADMIN_TOPICS_VERSION = [14, 5, 0];
19
+ const FIREBASE_ADMIN_14_NODE = '22';
20
+ const FCM_LEGACY_SEND = /\b(?:fcm\.googleapis\.com\/fcm\/send|gcm-http\.googleapis\.com\/gcm\/send)\b/;
21
+ const FCM_INSTANCE_ID = /\biid\.googleapis\.com\b/;
22
+ const FCM_DEVICE_GROUP = /\bfcm\.googleapis\.com\/(?:fcm\/|gcm\/|iid\/)?notification\b/;
23
+ const FCM_LEGACY_TOPIC_METHOD = /\b(?:subscribeToTopicLegacy|unsubscribeFromTopicLegacy)\b/;
24
+ export const FCM_HINT = new RegExp([FCM_LEGACY_SEND, FCM_INSTANCE_ID, FCM_DEVICE_GROUP, FCM_LEGACY_TOPIC_METHOD]
25
+ .map((re) => re.source).join('|'));
26
+ function compareVersions(left, right) {
27
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
28
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
29
+ if (difference !== 0)
30
+ return difference;
31
+ }
32
+ return 0;
33
+ }
34
+ function parseExactVersion(value) {
35
+ const match = value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
36
+ return match ? match.slice(1, 4).map((part) => Number.parseInt(part, 10)) : undefined;
37
+ }
38
+ /**
39
+ * True when no version allowed by the npm range reaches `target` (its highest satisfiable version is lower),
40
+ * false when some allowed version reaches it, undefined when the range is not a plain semver range.
41
+ */
42
+ function rangeStaysBelow(range, target) {
43
+ const alternatives = range.split('||').map((part) => part.trim());
44
+ for (const alternative of alternatives) {
45
+ if (alternative === '' || alternative === '*' || /^(?:x|X|latest)$/.test(alternative))
46
+ return false;
47
+ let upper;
48
+ const tighten = (candidate) => {
49
+ if (!upper || compareVersions(candidate.version, upper.version) < 0)
50
+ upper = candidate;
51
+ };
52
+ const hyphen = alternative.match(/^(\S+)\s+-\s+(\S+)$/);
53
+ const comparators = hyphen
54
+ ? [`>=${hyphen[1]}`, `<=${hyphen[2]}`]
55
+ : alternative.replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1').split(/\s+/);
56
+ for (const comparator of comparators) {
57
+ const match = comparator.match(/^(\^|~|>=|<=|>|<|=|v)?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:[-+][\w.-]*)?$/);
58
+ if (!match)
59
+ return undefined;
60
+ const [, operator = '', majorPart, minorPart, patchPart] = match;
61
+ const wild = (part) => part === undefined || /^[xX*]$/.test(part);
62
+ if (wild(majorPart)) {
63
+ if (operator === '<' || operator === '<=')
64
+ return undefined;
65
+ continue;
66
+ }
67
+ const major = Number.parseInt(majorPart, 10);
68
+ const minor = wild(minorPart) ? undefined : Number.parseInt(minorPart, 10);
69
+ const patch = wild(patchPart) ? undefined : Number.parseInt(patchPart, 10);
70
+ const partialCeiling = () => minor === undefined
71
+ ? { version: [major + 1, 0, 0], inclusive: false }
72
+ : patch === undefined
73
+ ? { version: [major, minor + 1, 0], inclusive: false }
74
+ : { version: [major, minor, patch], inclusive: true };
75
+ if (operator === '>' || operator === '>=')
76
+ continue;
77
+ if (operator === '<')
78
+ tighten({ version: [major, minor ?? 0, patch ?? 0], inclusive: false });
79
+ else if (operator === '<=' || operator === '' || operator === '=' || operator === 'v')
80
+ tighten(partialCeiling());
81
+ else if (operator === '~')
82
+ tighten(minor === undefined ? partialCeiling() : { version: [major, minor + 1, 0], inclusive: false });
83
+ else if (operator === '^') {
84
+ tighten(major > 0 || minor === undefined
85
+ ? { version: [major + 1, 0, 0], inclusive: false }
86
+ : minor > 0 || patch === undefined
87
+ ? { version: [0, minor + 1, 0], inclusive: false }
88
+ : { version: [0, 0, patch + 1], inclusive: false });
89
+ }
90
+ }
91
+ if (!upper)
92
+ return false;
93
+ const reaches = upper.inclusive
94
+ ? compareVersions(upper.version, target) >= 0
95
+ : compareVersions(upper.version, target) > 0;
96
+ if (reaches)
97
+ return false;
98
+ }
99
+ return true;
100
+ }
101
+ async function firebaseAdminEvidence(files, root) {
102
+ const result = [];
103
+ for (const file of files.filter((candidate) => /(?:^|\/)package\.json$/.test(candidate.relative)
104
+ && !candidate.relative.split('/').includes('node_modules'))) {
105
+ let manifest;
106
+ try {
107
+ manifest = JSON.parse(file.text);
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ const declared = ['dependencies', 'devDependencies']
113
+ .map((key) => manifest[key])
114
+ .map((group) => (group && typeof group === 'object' ? group['firebase-admin'] : undefined))
115
+ .find((value) => typeof value === 'string');
116
+ if (!declared)
117
+ continue;
118
+ const packageDir = path.dirname(file.absolute);
119
+ // Ancestors up to the scan root, then on to the repository root (a --path into a workspace monorepo).
120
+ const directories = await lockfileDirectories(packageDir, root);
121
+ // 1. The installed copy (hoisted installs put it in an ancestor's node_modules).
122
+ let resolved;
123
+ for (const directory of directories) {
124
+ const text = await readText(path.join(directory, 'node_modules', 'firebase-admin', 'package.json'));
125
+ if (!text)
126
+ continue;
127
+ try {
128
+ const version = JSON.parse(text).version;
129
+ if (typeof version === 'string' && parseExactVersion(version)) {
130
+ resolved = { file: file.relative, version, source: 'installed' };
131
+ break;
132
+ }
133
+ }
134
+ catch {
135
+ // Keep looking.
136
+ }
137
+ }
138
+ // 2. The nearest lockfile's resolution.
139
+ if (!resolved) {
140
+ for (const directory of directories) {
141
+ const version = (await lockedPackageVersion('firebase-admin', packageDir, directory, declared))?.version;
142
+ if (version && parseExactVersion(version)) {
143
+ resolved = { file: file.relative, version, source: 'lockfile' };
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ if (resolved) {
149
+ if (compareVersions(parseExactVersion(resolved.version), FIREBASE_ADMIN_TOPICS_VERSION) < 0) {
150
+ result.push({ ...resolved, major: parseExactVersion(resolved.version)[0] });
151
+ }
152
+ continue;
153
+ }
154
+ // 3. The declared range, flagged only when no version it allows reaches 14.5.0.
155
+ if (rangeStaysBelow(declared, FIREBASE_ADMIN_TOPICS_VERSION) === true) {
156
+ const major = declared.match(/\d+/)?.[0];
157
+ result.push({ file: file.relative, version: declared, source: 'declared', major: major ? Number.parseInt(major, 10) : undefined });
158
+ }
159
+ }
160
+ return result;
161
+ }
162
+ function listFiles(files) {
163
+ const names = unique(files.map((file) => file.relative));
164
+ return names.length > 3 ? `${names.slice(0, 3).join(', ')} (+${names.length - 3})` : names.join(', ');
165
+ }
166
+ export async function fcmFindings(files, sources, root, now) {
167
+ const findings = [];
168
+ const today = now.toISOString().slice(0, 10);
169
+ const decommissioned = today >= FCM_INSTANCE_ID_DECOMMISSION;
170
+ const scheduled = decommissioned ? 'decommissioned' : 'decommissions';
171
+ const scheduledKo = decommissioned ? '종료했습니다' : '종료합니다';
172
+ const legacySend = sources.filter((file) => FCM_LEGACY_SEND.test(file.text));
173
+ if (legacySend.length > 0) {
174
+ findings.push({
175
+ code: 'FCM_LEGACY_SEND_API',
176
+ severity: 'warning',
177
+ title: 'Code calls the shut-down legacy FCM send endpoint',
178
+ detail: `Found fcm/send in ${listFiles(legacySend)}. Firebase deprecated the legacy HTTP and XMPP send APIs in June 2023 and began shutting them down on 2024-07-22, so these push requests no longer deliver.`,
179
+ action: 'Send through the FCM HTTP v1 API (projects.messages.send) or a Firebase Admin SDK instead.',
180
+ file: legacySend[0].relative,
181
+ sourceUrl: FCM_LEGACY_SEND_SOURCE,
182
+ ko: {
183
+ title: '종료된 레거시 FCM 발송 엔드포인트를 호출하는 코드',
184
+ detail: `${listFiles(legacySend)}에서 fcm/send를 찾았습니다. Firebase는 레거시 HTTP·XMPP 발송 API를 2023년 6월 지원 중단하고 2024-07-22부터 종료했으므로 이 푸시 요청은 전달되지 않습니다.`,
185
+ action: 'FCM HTTP v1 API(projects.messages.send) 또는 Firebase Admin SDK로 발송하세요.',
186
+ },
187
+ });
188
+ }
189
+ const instanceId = sources.filter((file) => FCM_INSTANCE_ID.test(file.text));
190
+ if (instanceId.length > 0) {
191
+ findings.push({
192
+ code: 'FCM_INSTANCE_ID_API',
193
+ severity: decommissioned ? 'warning' : 'info',
194
+ title: decommissioned
195
+ ? 'Code calls the decommissioned Instance ID server API'
196
+ : 'Code calls the deprecated Instance ID server API',
197
+ detail: `Found iid.googleapis.com in ${listFiles(instanceId)}. Firebase ${scheduled} the Instance ID server APIs (token info, legacy topic management, batch import) on ${FCM_INSTANCE_ID_DECOMMISSION}.`,
198
+ action: 'Manage topics with the FCM topic subscription API, and validate tokens with an FCM v1 send using validate_only.',
199
+ file: instanceId[0].relative,
200
+ sourceUrl: FCM_DEPRECATION_SOURCE,
201
+ ko: {
202
+ title: decommissioned
203
+ ? '종료된 Instance ID 서버 API를 호출하는 코드'
204
+ : '지원 중단된 Instance ID 서버 API를 호출하는 코드',
205
+ detail: `${listFiles(instanceId)}에서 iid.googleapis.com을 찾았습니다. Firebase는 Instance ID 서버 API(토큰 정보, 레거시 토픽 관리, 일괄 가져오기)를 ${FCM_INSTANCE_ID_DECOMMISSION}에 ${scheduledKo}.`,
206
+ action: '토픽은 FCM 토픽 구독 API로 관리하고, 토큰 검증은 validate_only를 켠 FCM v1 발송으로 대체하세요.',
207
+ },
208
+ });
209
+ }
210
+ const deviceGroup = sources.filter((file) => FCM_DEVICE_GROUP.test(file.text));
211
+ if (deviceGroup.length > 0) {
212
+ findings.push({
213
+ code: 'FCM_DEVICE_GROUP_API',
214
+ severity: decommissioned ? 'warning' : 'info',
215
+ title: decommissioned
216
+ ? 'Code calls the decommissioned FCM device group management API'
217
+ : 'Code calls the deprecated FCM device group management API',
218
+ detail: `Found fcm/notification in ${listFiles(deviceGroup)}. Firebase ${scheduled} device group management and device group tokens on ${FCM_INSTANCE_ID_DECOMMISSION}.`,
219
+ action: 'Send to individual registration tokens (or topics) with the FCM v1 API, following Firebase\'s guide for migrating off device groups.',
220
+ file: deviceGroup[0].relative,
221
+ sourceUrl: FCM_DEPRECATION_SOURCE,
222
+ ko: {
223
+ title: decommissioned
224
+ ? '종료된 FCM 기기 그룹 관리 API를 호출하는 코드'
225
+ : '지원 중단된 FCM 기기 그룹 관리 API를 호출하는 코드',
226
+ detail: `${listFiles(deviceGroup)}에서 fcm/notification을 찾았습니다. Firebase는 기기 그룹 관리와 기기 그룹 토큰을 ${FCM_INSTANCE_ID_DECOMMISSION}에 ${scheduledKo}.`,
227
+ action: 'Firebase의 기기 그룹 이전 가이드에 따라 FCM v1 API로 개별 등록 토큰(또는 토픽)에 발송하세요.',
228
+ },
229
+ });
230
+ }
231
+ const legacyTopic = sources.filter((file) => FCM_LEGACY_TOPIC_METHOD.test(file.text));
232
+ if (legacyTopic.length > 0) {
233
+ findings.push({
234
+ code: 'FCM_LEGACY_TOPIC_METHODS',
235
+ severity: decommissioned ? 'warning' : 'info',
236
+ title: 'Code uses the deprecated firebase-admin *Legacy topic methods',
237
+ detail: `Found subscribeToTopicLegacy / unsubscribeFromTopicLegacy in ${listFiles(legacyTopic)}. These escape hatches keep using the Instance ID API, which Firebase ${scheduled} on ${FCM_INSTANCE_ID_DECOMMISSION}.`,
238
+ action: 'Call subscribeToTopic / unsubscribeFromTopic instead; since firebase-admin 14.5.0 they use the FCM v1 topic subscription API.',
239
+ file: legacyTopic[0].relative,
240
+ sourceUrl: FIREBASE_ADMIN_TOPICS_SOURCE,
241
+ ko: {
242
+ title: '지원 중단된 firebase-admin *Legacy 토픽 메서드를 사용하는 코드',
243
+ detail: `${listFiles(legacyTopic)}에서 subscribeToTopicLegacy / unsubscribeFromTopicLegacy를 찾았습니다. 이 메서드는 Instance ID API를 계속 사용하며, Firebase는 이 API를 ${FCM_INSTANCE_ID_DECOMMISSION}에 ${scheduledKo}.`,
244
+ action: 'subscribeToTopic / unsubscribeFromTopic을 사용하세요. firebase-admin 14.5.0부터 FCM v1 토픽 구독 API를 사용합니다.',
245
+ },
246
+ });
247
+ }
248
+ const outdated = await firebaseAdminEvidence(files, root);
249
+ if (outdated.length > 0) {
250
+ const first = outdated[0];
251
+ const sourceLabel = { installed: 'installed', lockfile: 'lockfile', declared: 'declared range' }[first.source];
252
+ const sourceLabelKo = { installed: '설치됨', lockfile: 'lockfile', declared: '선언 범위' }[first.source];
253
+ const nodeNote = first.major !== undefined && first.major < 14;
254
+ findings.push({
255
+ code: 'FIREBASE_ADMIN_LEGACY_TOPIC_TRANSPORT',
256
+ severity: decommissioned ? 'warning' : 'info',
257
+ title: 'firebase-admin before 14.5.0 manages topics through the Instance ID API',
258
+ detail: `${first.file} uses firebase-admin ${first.version} (${sourceLabel}). From 14.5.0, subscribeToTopic / unsubscribeFromTopic use the FCM v1 topic subscription API; earlier versions call the Instance ID API, which Firebase ${scheduled} on ${FCM_INSTANCE_ID_DECOMMISSION}. Only topic subscription calls are affected.`,
259
+ action: `Upgrading firebase-admin to 14.5.0 or later is the migration; the method names stay the same. Run your topic subscription tests after upgrading.${nodeNote ? ` firebase-admin 14 requires Node.js ${FIREBASE_ADMIN_14_NODE} or later.` : ''}`,
260
+ file: first.file,
261
+ sourceUrl: FCM_DEPRECATION_SOURCE,
262
+ ko: {
263
+ title: 'firebase-admin 14.5.0 이전 버전은 Instance ID API로 토픽을 관리함',
264
+ detail: `${first.file}의 firebase-admin 버전은 ${first.version}(${sourceLabelKo})입니다. 14.5.0부터 subscribeToTopic / unsubscribeFromTopic은 FCM v1 토픽 구독 API를 쓰고, 이전 버전은 Instance ID API를 호출합니다. Firebase는 이 API를 ${FCM_INSTANCE_ID_DECOMMISSION}에 ${scheduledKo}. 토픽 구독 호출만 영향을 받습니다.`,
265
+ action: `firebase-admin을 14.5.0 이상으로 올리는 것이 마이그레이션입니다. 메서드 이름은 그대로이며, 업그레이드 후 토픽 구독 테스트를 실행하세요.${nodeNote ? ` firebase-admin 14는 Node.js ${FIREBASE_ADMIN_14_NODE} 이상이 필요합니다.` : ''}`,
266
+ },
267
+ });
268
+ }
269
+ return findings;
270
+ }
@@ -0,0 +1,16 @@
1
+ import type { ProjectFile } from './types.js';
2
+ export declare const MAX_SOURCE_FILES = 4000;
3
+ export declare const EXCLUDED_EVIDENCE_DIRS: Set<string>;
4
+ export declare const SAMPLE_ONLY_DIRS: Set<string>;
5
+ export declare const TEST_TARGET_DIR: RegExp;
6
+ /** Codes whose check could not decide; see `coverage.unresolved`. */
7
+ export declare const UNRESOLVED_CODES: Set<string>;
8
+ export declare function walk(root: string, maxSourceFiles?: number, maxDepth?: number): Promise<{
9
+ files: ProjectFile[];
10
+ fcmSources: ProjectFile[];
11
+ sourceScanTruncated: boolean;
12
+ /** Convention-build roots that could not be read completely. */
13
+ conventionTruncated: string[];
14
+ }>;
15
+ /** Convention-build sources the Target API net reads: scripts and plugin sources in every JVM build language. */
16
+ export declare const CONVENTION_SOURCE_FILE: RegExp;