@yoonion/mimi-seed-mcp 0.3.40 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // mimi-seed-admob — AdMob sub-CLI (계정/앱/광고단위 조회 + v1beta 생성).
3
+ // 사용자 진입점은 `mimi-seed admob <subcommand>`.
4
+ // ⚠️ create-* 는 AdMob v1beta(Limited Access)라 계정 allowlist 미승인 시 403 → 콘솔 수동 폴백.
5
+ import { requireAuth } from '../helpers.js';
6
+ import * as admob from './tools.js';
7
+ import { runDomainCli, requireFlag, flag, CliUsageError } from '../lib/cli-args.js';
8
+ const HELP = `
9
+ 💰 mimi-seed-admob — AdMob attach
10
+
11
+ 사용법:
12
+ mimi-seed admob accounts
13
+ AdMob 계정 목록 (accountId 확인 — accounts/pub-XXXX)
14
+
15
+ mimi-seed admob apps --account <id>
16
+ mimi-seed admob ad-units --account <id>
17
+ 등록된 앱 / 광고 단위 목록
18
+
19
+ mimi-seed admob create-app --account <id> --platform <ANDROID|IOS> --name <displayName>
20
+ [--store-id com.x.y | 123456789]
21
+ 앱 등록 (store-id 주면 스토어 링크). ⚠️ v1beta Limited Access — 403 가능
22
+
23
+ mimi-seed admob create-ad-unit --account <id> --app <appId> --name <name>
24
+ --format <BANNER|INTERSTITIAL|REWARDED|REWARDED_INTERSTITIAL|APP_OPEN|NATIVE>
25
+ 광고 단위 생성. ⚠️ v1beta Limited Access — 403 가능
26
+
27
+ 인증: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth
28
+ `;
29
+ const AD_FORMATS = ['BANNER', 'INTERSTITIAL', 'REWARDED', 'REWARDED_INTERSTITIAL', 'APP_OPEN', 'NATIVE'];
30
+ runDomainCli({
31
+ binName: 'mimi-seed-admob',
32
+ argv: process.argv.slice(2),
33
+ help: HELP,
34
+ handlers: {
35
+ accounts: async () => admob.listAccounts(await requireAuth()),
36
+ apps: async (p) => admob.listApps(await requireAuth(), requireFlag(p, 'account')),
37
+ 'ad-units': async (p) => admob.listAdUnits(await requireAuth(), requireFlag(p, 'account')),
38
+ 'create-app': async (p) => {
39
+ const platform = requireFlag(p, 'platform').toUpperCase();
40
+ if (platform !== 'ANDROID' && platform !== 'IOS') {
41
+ throw new CliUsageError('--platform 은 ANDROID | IOS 여야 합니다.');
42
+ }
43
+ return admob.createApp(await requireAuth(), requireFlag(p, 'account'), platform, requireFlag(p, 'name'), flag(p, 'store-id'));
44
+ },
45
+ 'create-ad-unit': async (p) => {
46
+ const format = requireFlag(p, 'format').toUpperCase();
47
+ if (!AD_FORMATS.includes(format)) {
48
+ throw new CliUsageError(`--format 은 ${AD_FORMATS.join(' | ')} 중 하나여야 합니다.`);
49
+ }
50
+ return admob.createAdUnit(await requireAuth(), requireFlag(p, 'account'), requireFlag(p, 'app'), requireFlag(p, 'name'), format);
51
+ },
52
+ },
53
+ });
@@ -35,5 +35,15 @@ export declare function getNetworkReport(auth: OAuth2Client, accountId: string,
35
35
  day: number;
36
36
  }): Promise<import("googleapis").admob_v1.Schema$GenerateNetworkReportResponse>;
37
37
  export declare function getTodayEarnings(auth: OAuth2Client, accountId: string): Promise<import("googleapis").admob_v1.Schema$GenerateNetworkReportResponse>;
38
- export declare function createApp(auth: OAuth2Client, accountId: string, platform: 'ANDROID' | 'IOS', displayName: string): Promise<any>;
39
- export declare function createAdUnit(auth: OAuth2Client, accountId: string, appId: string, displayName: string, adFormat: 'BANNER' | 'INTERSTITIAL' | 'REWARDED' | 'REWARDED_INTERSTITIAL' | 'APP_OPEN' | 'NATIVE'): Promise<any>;
38
+ export type AdFormat = 'BANNER' | 'INTERSTITIAL' | 'REWARDED' | 'REWARDED_INTERSTITIAL' | 'APP_OPEN' | 'NATIVE';
39
+ /**
40
+ * adFormat → adTypes 파생 (순수 함수 — 테스트 대상).
41
+ * AdMob v1beta adTypes 의 유효값은 'RICH_MEDIA'(텍스트·이미지 등 비동영상)와 'VIDEO' 둘뿐
42
+ * (Schema$AdUnit.adTypes 문서). 동영상 지원 포맷은 VIDEO 를 포함하고,
43
+ * REWARDED_INTERSTITIAL 은 문서상 video-only 다.
44
+ */
45
+ export declare function adTypesForFormat(adFormat: AdFormat): string[];
46
+ export declare function createApp(auth: OAuth2Client, accountId: string, platform: 'ANDROID' | 'IOS', displayName: string,
47
+ /** 스토어에 게시된 앱이면 store ID 로 링크 (Android=패키지명, iOS=App Store 숫자 ID). 없으면 manual(unlinked) 앱. */
48
+ appStoreId?: string): Promise<any>;
49
+ export declare function createAdUnit(auth: OAuth2Client, accountId: string, appId: string, displayName: string, adFormat: AdFormat): Promise<any>;
@@ -91,17 +91,42 @@ export async function getTodayEarnings(auth, accountId) {
91
91
  const today = { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() };
92
92
  return getNetworkReport(auth, accountId, today, today);
93
93
  }
94
- // ─── v1beta: 앱 생성 (Limited Access) ───
95
- export async function createApp(auth, accountId, platform, displayName) {
94
+ /**
95
+ * adFormat → adTypes 파생 (순수 함수 테스트 대상).
96
+ * AdMob v1beta adTypes 의 유효값은 'RICH_MEDIA'(텍스트·이미지 등 비동영상)와 'VIDEO' 둘뿐
97
+ * (Schema$AdUnit.adTypes 문서). 동영상 지원 포맷은 VIDEO 를 포함하고,
98
+ * REWARDED_INTERSTITIAL 은 문서상 video-only 다.
99
+ */
100
+ export function adTypesForFormat(adFormat) {
101
+ switch (adFormat) {
102
+ case 'INTERSTITIAL':
103
+ case 'REWARDED':
104
+ case 'APP_OPEN':
105
+ return ['RICH_MEDIA', 'VIDEO'];
106
+ case 'REWARDED_INTERSTITIAL':
107
+ return ['VIDEO'];
108
+ case 'BANNER':
109
+ case 'NATIVE':
110
+ default:
111
+ return ['RICH_MEDIA'];
112
+ }
113
+ }
114
+ export async function createApp(auth, accountId, platform, displayName,
115
+ /** 스토어에 게시된 앱이면 store ID 로 링크 (Android=패키지명, iOS=App Store 숫자 ID). 없으면 manual(unlinked) 앱. */
116
+ appStoreId) {
96
117
  // v1beta — 대부분 계정에서 403. Google Account Manager 승인 필요.
97
118
  const admobBeta = google.admob('v1beta');
119
+ const requestBody = { platform };
120
+ // manualAppInfo.displayName 은 writable(사용자 제공, AdMob UI 표시명) — linking 후에도 유지된다.
121
+ // linkedAppInfo.displayName 은 output-only(스토어 표시명)라 보내도 무시되므로, store 링크엔 appStoreId 만 넣는다.
122
+ requestBody.manualAppInfo = { displayName };
123
+ if (appStoreId) {
124
+ requestBody.linkedAppInfo = { appStoreId };
125
+ }
98
126
  const res = await admobBeta.accounts.apps.create({
99
127
  auth,
100
128
  parent: accountId,
101
- requestBody: {
102
- platform,
103
- manualAppInfo: { displayName },
104
- },
129
+ requestBody,
105
130
  });
106
131
  return res.data;
107
132
  }
@@ -115,7 +140,7 @@ export async function createAdUnit(auth, accountId, appId, displayName, adFormat
115
140
  appId,
116
141
  displayName,
117
142
  adFormat,
118
- adTypes: ['DISPLAY'],
143
+ adTypes: adTypesForFormat(adFormat),
119
144
  },
120
145
  });
121
146
  return res.data;
@@ -1,4 +1,4 @@
1
- export type AuthErrorCode = 'UNAUTHENTICATED' | 'NO_REFRESH_TOKEN' | 'INVALID_GRANT' | 'RAPT_REQUIRED' | 'INVALID_CLIENT' | 'UNAUTHORIZED_CLIENT' | 'REFRESH_NETWORK_ERROR' | 'REFRESH_UNKNOWN' | 'CALLBACK_PORT_IN_USE' | 'CALLBACK_TIMEOUT' | 'BROWSER_OPEN_FAILED' | 'USER_DENIED' | 'CODE_EXCHANGE_FAILED' | 'TOKEN_RESPONSE_INVALID';
1
+ export type AuthErrorCode = 'UNAUTHENTICATED' | 'NO_REFRESH_TOKEN' | 'INVALID_GRANT' | 'RAPT_REQUIRED' | 'INVALID_CLIENT' | 'UNAUTHORIZED_CLIENT' | 'REFRESH_NETWORK_ERROR' | 'REFRESH_UNKNOWN' | 'INSUFFICIENT_SCOPE' | 'CALLBACK_PORT_IN_USE' | 'CALLBACK_TIMEOUT' | 'BROWSER_OPEN_FAILED' | 'USER_DENIED' | 'CODE_EXCHANGE_FAILED' | 'TOKEN_RESPONSE_INVALID';
2
2
  export interface AuthErrorPayload {
3
3
  code: AuthErrorCode;
4
4
  message: string;
@@ -4,6 +4,8 @@ export interface StoredTokens {
4
4
  refresh_token: string;
5
5
  token_type: string;
6
6
  expiry_date: number;
7
+ /** 공백 구분 부여 스코프. 신규 도구(GA4 등) pre-flight 스코프 검사에 사용. 구 토큰은 undefined. */
8
+ scope?: string;
7
9
  }
8
10
  export declare function getStoredCredentials(): {
9
11
  clientId: string;
@@ -14,6 +14,7 @@ const SCOPES = [
14
14
  'https://www.googleapis.com/auth/androidpublisher',
15
15
  'https://www.googleapis.com/auth/adwords', // Google Ads API (googleads_* tools)
16
16
  'https://www.googleapis.com/auth/webmasters', // Search Console API (gsc_* tools, 사이트맵 제출 포함)
17
+ 'https://www.googleapis.com/auth/analytics.edit', // GA4 Analytics Admin API (ga4_* tools — property/data stream 생성·조회)
17
18
  ];
18
19
  // Primary config dir. Legacy `~/.preseed` is read as a fallback during the
19
20
  // rebrand so existing auth sessions don't force a re-login; new writes go to
@@ -112,6 +113,7 @@ export function getAuthenticatedClient() {
112
113
  ...(newTokens.access_token && { access_token: newTokens.access_token }),
113
114
  ...(newTokens.refresh_token && { refresh_token: newTokens.refresh_token }),
114
115
  ...(newTokens.expiry_date && { expiry_date: newTokens.expiry_date }),
116
+ ...(newTokens.scope && { scope: newTokens.scope }),
115
117
  });
116
118
  }
117
119
  });
@@ -209,6 +211,7 @@ export function startAuth(clientId, clientSecret, options = {}) {
209
211
  refresh_token: tokens.refresh_token,
210
212
  token_type: tokens.token_type ?? 'Bearer',
211
213
  expiry_date: tokens.expiry_date ?? Date.now() + 3600_000,
214
+ ...(tokens.scope && { scope: tokens.scope }),
212
215
  };
213
216
  saveTokens(stored);
214
217
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -327,6 +330,8 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
327
330
  refresh_token: credentials.refresh_token ?? tokens.refresh_token,
328
331
  token_type: credentials.token_type ?? tokens.token_type ?? 'Bearer',
329
332
  expiry_date: credentials.expiry_date ?? Date.now() + 3600_000,
333
+ // refresh 응답은 scope 를 생략할 수 있으므로 기존 값을 보존(blank 방지).
334
+ scope: credentials.scope ?? tokens.scope,
330
335
  };
331
336
  saveTokens(refreshed);
332
337
  return {
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // mimi-seed-firebase — Firebase 프로비저닝 sub-CLI (프로젝트/앱 조회·생성, config 다운로드, GA4 링크).
3
+ // 사용자 진입점은 `mimi-seed firebase <subcommand>`.
4
+ import { requireAuth } from '../helpers.js';
5
+ import * as firebase from './tools.js';
6
+ import { runDomainCli, requireFlag, flag, CliUsageError } from '../lib/cli-args.js';
7
+ const FB_PLATFORMS = ['android', 'ios', 'web'];
8
+ const HELP = `
9
+ 🔥 mimi-seed-firebase — Firebase attach (프로비저닝 + config 산출)
10
+
11
+ 사용법:
12
+ mimi-seed firebase projects
13
+ 내 Firebase 프로젝트 목록
14
+
15
+ mimi-seed firebase apps --project <id> [--platform android|ios|web]
16
+ 프로젝트의 앱 목록 (기본: android+ios+web 전부)
17
+
18
+ mimi-seed firebase create-android --project <id> --package com.x.y --name <displayName>
19
+ mimi-seed firebase create-ios --project <id> --bundle com.x.y --name <displayName>
20
+ 새 앱 등록
21
+
22
+ mimi-seed firebase config --project <id> --app <appId> --platform <android|ios|web>
23
+ ⭐ google-services.json / GoogleService-Info.plist / web config 출력 (attach 페이로드)
24
+
25
+ mimi-seed firebase enable-services --project <id>
26
+ Firebase 기본 서비스 일괄 활성화 (Analytics 포함)
27
+
28
+ mimi-seed firebase link-analytics --project <id> (--account <gaAccountId> | --property <gaPropertyId>)
29
+ GA4 링크 (앱 measurement 자동 활성화) — account(신규 property 생성) 또는 property(기존 연결) 중 하나 필수
30
+
31
+ mimi-seed firebase analytics-details --project <id>
32
+ GA4 링크 상세 (property + stream 매핑)
33
+
34
+ 인증: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth
35
+ `;
36
+ async function listApps(projectId, platform) {
37
+ const auth = await requireAuth();
38
+ // platform 이 지정됐는데 알 수 없는 값이면 조용히 'all' 로 떨어지지 않고 명시적으로 거부.
39
+ if (platform !== undefined && !FB_PLATFORMS.includes(platform)) {
40
+ throw new CliUsageError('--platform 은 android | ios | web 중 하나여야 합니다.');
41
+ }
42
+ if (platform === 'android')
43
+ return firebase.listAndroidApps(auth, projectId);
44
+ if (platform === 'ios')
45
+ return firebase.listIosApps(auth, projectId);
46
+ if (platform === 'web')
47
+ return firebase.listWebApps(auth, projectId);
48
+ const [android, ios, web] = await Promise.all([
49
+ firebase.listAndroidApps(auth, projectId),
50
+ firebase.listIosApps(auth, projectId),
51
+ firebase.listWebApps(auth, projectId),
52
+ ]);
53
+ return { android, ios, web };
54
+ }
55
+ async function getConfig(projectId, appId, platform) {
56
+ const auth = await requireAuth();
57
+ if (platform === 'android')
58
+ return firebase.getAndroidConfig(auth, projectId, appId);
59
+ if (platform === 'ios')
60
+ return firebase.getIosConfig(auth, projectId, appId);
61
+ if (platform === 'web')
62
+ return firebase.getWebConfig(auth, projectId, appId);
63
+ throw new CliUsageError('--platform 은 android | ios | web 중 하나여야 합니다.');
64
+ }
65
+ runDomainCli({
66
+ binName: 'mimi-seed-firebase',
67
+ argv: process.argv.slice(2),
68
+ help: HELP,
69
+ handlers: {
70
+ projects: async () => firebase.listProjects(await requireAuth()),
71
+ apps: async (p) => listApps(requireFlag(p, 'project'), flag(p, 'platform')?.toLowerCase()),
72
+ 'create-android': async (p) => firebase.createAndroidApp(await requireAuth(), requireFlag(p, 'project'), requireFlag(p, 'package'), requireFlag(p, 'name')),
73
+ 'create-ios': async (p) => firebase.createIosApp(await requireAuth(), requireFlag(p, 'project'), requireFlag(p, 'bundle'), requireFlag(p, 'name')),
74
+ config: async (p) => getConfig(requireFlag(p, 'project'), requireFlag(p, 'app'), requireFlag(p, 'platform').toLowerCase()),
75
+ 'enable-services': async (p) => firebase.enableCommonServices(await requireAuth(), requireFlag(p, 'project')),
76
+ 'link-analytics': async (p) => firebase.linkAnalytics(await requireAuth(), requireFlag(p, 'project'), {
77
+ analyticsAccountId: flag(p, 'account'),
78
+ analyticsPropertyId: flag(p, 'property'),
79
+ }),
80
+ 'analytics-details': async (p) => firebase.getAnalyticsDetails(await requireAuth(), requireFlag(p, 'project')),
81
+ },
82
+ });
@@ -49,6 +49,20 @@ export declare function listEnabledServices(auth: OAuth2Client, projectId: strin
49
49
  name: string | null | undefined;
50
50
  title: string | null | undefined;
51
51
  }[]>;
52
+ /**
53
+ * Firebase 프로젝트에 Google Analytics(GA4)를 링크한다.
54
+ * - analyticsAccountId: 그 GA 계정 하위에 GA4 property 를 새로 만들어 링크.
55
+ * - analyticsPropertyId: 기존 GA4 property 에 링크.
56
+ * 둘 중 정확히 하나는 필수다(addGoogleAnalytics API 계약 — 무인자 모드 없음).
57
+ * 앱별 data stream(android/ios measurement)은 링크 후 Firebase 가 자동 생성한다.
58
+ * (반환값은 long-running Operation — 완료까지 수 초 소요될 수 있음.)
59
+ */
60
+ export declare function linkAnalytics(auth: OAuth2Client, projectId: string, opts?: {
61
+ analyticsAccountId?: string;
62
+ analyticsPropertyId?: string;
63
+ }): Promise<import("googleapis").firebase_v1beta1.Schema$Operation>;
64
+ /** 프로젝트의 GA4 링크 상세 — 연결된 analyticsProperty + 앱↔stream 매핑 조회. */
65
+ export declare function getAnalyticsDetails(auth: OAuth2Client, projectId: string): Promise<import("googleapis").firebase_v1beta1.Schema$AnalyticsDetails>;
52
66
  export declare function enableCommonServices(auth: OAuth2Client, projectId: string): Promise<({
53
67
  service: string;
54
68
  status: string;
@@ -158,6 +158,39 @@ export async function listEnabledServices(auth, projectId) {
158
158
  title: s.config?.title,
159
159
  }));
160
160
  }
161
+ // ─── Google Analytics(GA4) 링크 ───
162
+ /**
163
+ * Firebase 프로젝트에 Google Analytics(GA4)를 링크한다.
164
+ * - analyticsAccountId: 그 GA 계정 하위에 GA4 property 를 새로 만들어 링크.
165
+ * - analyticsPropertyId: 기존 GA4 property 에 링크.
166
+ * 둘 중 정확히 하나는 필수다(addGoogleAnalytics API 계약 — 무인자 모드 없음).
167
+ * 앱별 data stream(android/ios measurement)은 링크 후 Firebase 가 자동 생성한다.
168
+ * (반환값은 long-running Operation — 완료까지 수 초 소요될 수 있음.)
169
+ */
170
+ export async function linkAnalytics(auth, projectId, opts = {}) {
171
+ const requestBody = {};
172
+ if (opts.analyticsAccountId)
173
+ requestBody.analyticsAccountId = opts.analyticsAccountId;
174
+ if (opts.analyticsPropertyId)
175
+ requestBody.analyticsPropertyId = opts.analyticsPropertyId;
176
+ if (!requestBody.analyticsAccountId && !requestBody.analyticsPropertyId) {
177
+ throw new Error('GA4 링크엔 analyticsAccountId(신규 property 생성) 또는 analyticsPropertyId(기존 property 연결) 중 하나가 필요합니다.');
178
+ }
179
+ const res = await google.firebase('v1beta1').projects.addGoogleAnalytics({
180
+ auth,
181
+ parent: `projects/${projectId}`,
182
+ requestBody,
183
+ });
184
+ return res.data;
185
+ }
186
+ /** 프로젝트의 GA4 링크 상세 — 연결된 analyticsProperty + 앱↔stream 매핑 조회. */
187
+ export async function getAnalyticsDetails(auth, projectId) {
188
+ const res = await google.firebase('v1beta1').projects.getAnalyticsDetails({
189
+ auth,
190
+ name: `projects/${projectId}/analyticsDetails`,
191
+ });
192
+ return res.data;
193
+ }
161
194
  // ─── 편의 함수 ───
162
195
  const COMMON_SERVICES = [
163
196
  'firebase.googleapis.com',
@@ -167,6 +200,7 @@ const COMMON_SERVICES = [
167
200
  'fcm.googleapis.com',
168
201
  'identitytoolkit.googleapis.com',
169
202
  'cloudresourcemanager.googleapis.com',
203
+ 'firebaseanalytics.googleapis.com', // GA4(Firebase Analytics) — linkAnalytics 전에 활성화
170
204
  ];
171
205
  export async function enableCommonServices(auth, projectId) {
172
206
  const results = [];
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // mimi-seed-ga4 — GA4 Analytics Admin/Data API sub-CLI.
3
+ // 사용자 진입점은 `mimi-seed ga4 <subcommand>` (cli 패키지가 npx 로 이 bin 을 호출).
4
+ import { requireAuth } from '../helpers.js';
5
+ import * as ga4 from './tools.js';
6
+ import { runDomainCli, requireFlag, flag, flagList } from '../lib/cli-args.js';
7
+ const HELP = `
8
+ 📊 mimi-seed-ga4 — GA4(Google Analytics 4) attach
9
+
10
+ 사용법:
11
+ mimi-seed ga4 accounts
12
+ 접근 가능한 GA 계정 + property 요약 (accountId/propertyId 확인)
13
+
14
+ mimi-seed ga4 properties --account <id>
15
+ 계정 하위 GA4 property 목록
16
+
17
+ mimi-seed ga4 create-property --account <id> --name <displayName>
18
+ [--timezone Asia/Seoul] [--currency KRW]
19
+ 새 GA4 property 생성
20
+
21
+ mimi-seed ga4 create-stream --property <id> --platform <web|android|ios> --name <name>
22
+ [--uri https://...] [--package com.x.y] [--bundle com.x.y]
23
+ data stream 생성 (web→measurementId, app→firebaseAppId)
24
+
25
+ mimi-seed ga4 streams --property <id>
26
+ data stream 목록
27
+
28
+ mimi-seed ga4 report --property <id> --start 28daysAgo --end today
29
+ [--dimensions date,country] [--metrics activeUsers,eventCount]
30
+ GA4 Data API 리포트
31
+
32
+ ⚠️ analytics.edit 스코프 필요 — 처음이면 재로그인:
33
+ npx -y @yoonion/mimi-seed-mcp mimi-seed-auth
34
+ `;
35
+ runDomainCli({
36
+ binName: 'mimi-seed-ga4',
37
+ argv: process.argv.slice(2),
38
+ help: HELP,
39
+ handlers: {
40
+ accounts: async () => ga4.listAccountSummaries(await requireAuth(ga4.GA4_SCOPE)),
41
+ properties: async (p) => ga4.listProperties(await requireAuth(ga4.GA4_SCOPE), requireFlag(p, 'account')),
42
+ 'create-property': async (p) => ga4.createProperty(await requireAuth(ga4.GA4_SCOPE), {
43
+ accountId: requireFlag(p, 'account'),
44
+ displayName: requireFlag(p, 'name'),
45
+ timeZone: flag(p, 'timezone'),
46
+ currencyCode: flag(p, 'currency'),
47
+ }),
48
+ 'create-stream': async (p) => ga4.createDataStream(await requireAuth(ga4.GA4_SCOPE), requireFlag(p, 'property'), {
49
+ platform: requireFlag(p, 'platform'),
50
+ displayName: requireFlag(p, 'name'),
51
+ defaultUri: flag(p, 'uri'),
52
+ packageName: flag(p, 'package'),
53
+ bundleId: flag(p, 'bundle'),
54
+ }),
55
+ streams: async (p) => ga4.listDataStreams(await requireAuth(ga4.GA4_SCOPE), requireFlag(p, 'property')),
56
+ report: async (p) => ga4.runReport(await requireAuth(ga4.GA4_SCOPE), requireFlag(p, 'property'), {
57
+ startDate: requireFlag(p, 'start'),
58
+ endDate: requireFlag(p, 'end'),
59
+ dimensions: flagList(p, 'dimensions'),
60
+ metrics: flagList(p, 'metrics'),
61
+ }),
62
+ },
63
+ });
@@ -0,0 +1,107 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ /** GA4 도구가 요구하는 OAuth 스코프 — requireAuth() pre-flight 검사에 사용. */
3
+ export declare const GA4_SCOPE = "https://www.googleapis.com/auth/analytics.edit";
4
+ export type Ga4Auth = OAuth2Client;
5
+ export type DataStreamPlatform = 'web' | 'android' | 'ios';
6
+ /** '123' | 'accounts/123' → 'accounts/123' */
7
+ export declare function normalizeAccountName(accountId: string): string;
8
+ /** '123' | 'properties/123' → 'properties/123' */
9
+ export declare function normalizePropertyName(propertyId: string): string;
10
+ /**
11
+ * dataStreams.create 응답에서 클라이언트가 실제로 필요로 하는 식별자를 평탄화한다.
12
+ * - web stream → measurementId (G-XXXX, gtag/web 연동용)
13
+ * - app stream → firebaseAppId (Firebase 앱과 자동 링크된 경우)
14
+ */
15
+ export declare function flattenDataStream(d: {
16
+ name?: string | null;
17
+ type?: string | null;
18
+ displayName?: string | null;
19
+ webStreamData?: {
20
+ measurementId?: string | null;
21
+ defaultUri?: string | null;
22
+ } | null;
23
+ androidAppStreamData?: {
24
+ firebaseAppId?: string | null;
25
+ packageName?: string | null;
26
+ } | null;
27
+ iosAppStreamData?: {
28
+ firebaseAppId?: string | null;
29
+ bundleId?: string | null;
30
+ } | null;
31
+ }): {
32
+ name: string | null;
33
+ type: string | null;
34
+ displayName: string | null;
35
+ measurementId: string | null;
36
+ firebaseAppId: string | null;
37
+ };
38
+ /** data stream 생성 요청 본문 조립 (순수 함수 — 테스트 대상). */
39
+ export declare function buildDataStreamBody(opts: {
40
+ platform: DataStreamPlatform;
41
+ displayName: string;
42
+ defaultUri?: string;
43
+ packageName?: string;
44
+ bundleId?: string;
45
+ }): {
46
+ type: string;
47
+ displayName: string;
48
+ webStreamData: {
49
+ defaultUri: string | undefined;
50
+ };
51
+ androidAppStreamData?: undefined;
52
+ iosAppStreamData?: undefined;
53
+ } | {
54
+ type: string;
55
+ displayName: string;
56
+ androidAppStreamData: {
57
+ packageName: string | undefined;
58
+ };
59
+ webStreamData?: undefined;
60
+ iosAppStreamData?: undefined;
61
+ } | {
62
+ type: string;
63
+ displayName: string;
64
+ iosAppStreamData: {
65
+ bundleId: string | undefined;
66
+ };
67
+ webStreamData?: undefined;
68
+ androidAppStreamData?: undefined;
69
+ };
70
+ /** 접근 가능한 GA 계정 + 각 계정의 property 요약. accountId/propertyId 를 찾는 시작점. */
71
+ export declare function listAccountSummaries(auth: Ga4Auth): Promise<import("googleapis").analyticsadmin_v1beta.Schema$GoogleAnalyticsAdminV1betaAccountSummary[]>;
72
+ /** 계정 하위 GA4 property 목록. accountId: '123' 또는 'accounts/123'. */
73
+ export declare function listProperties(auth: Ga4Auth, accountId: string): Promise<import("googleapis").analyticsadmin_v1beta.Schema$GoogleAnalyticsAdminV1betaProperty[]>;
74
+ export declare function createProperty(auth: Ga4Auth, opts: {
75
+ accountId: string;
76
+ displayName: string;
77
+ timeZone?: string;
78
+ currencyCode?: string;
79
+ }): Promise<import("googleapis").analyticsadmin_v1beta.Schema$GoogleAnalyticsAdminV1betaProperty>;
80
+ export declare function createDataStream(auth: Ga4Auth, propertyId: string, opts: {
81
+ platform: DataStreamPlatform;
82
+ displayName: string;
83
+ defaultUri?: string;
84
+ packageName?: string;
85
+ bundleId?: string;
86
+ }): Promise<{
87
+ raw: import("googleapis").analyticsadmin_v1beta.Schema$GoogleAnalyticsAdminV1betaDataStream;
88
+ name: string | null;
89
+ type: string | null;
90
+ displayName: string | null;
91
+ measurementId: string | null;
92
+ firebaseAppId: string | null;
93
+ }>;
94
+ export declare function listDataStreams(auth: Ga4Auth, propertyId: string): Promise<{
95
+ name: string | null;
96
+ type: string | null;
97
+ displayName: string | null;
98
+ measurementId: string | null;
99
+ firebaseAppId: string | null;
100
+ }[]>;
101
+ export interface RunReportParams {
102
+ startDate: string;
103
+ endDate: string;
104
+ dimensions?: string[];
105
+ metrics?: string[];
106
+ }
107
+ export declare function runReport(auth: Ga4Auth, propertyId: string, params: RunReportParams): Promise<import("googleapis").analyticsdata_v1beta.Schema$RunReportResponse>;
@@ -0,0 +1,119 @@
1
+ import { google } from 'googleapis';
2
+ /**
3
+ * Google Analytics 4 — Admin API(v1beta) + Data API(v1beta) 래퍼.
4
+ *
5
+ * Admin API 로 GA4 property/data stream 을 직접 생성·조회한다. Firebase Analytics 를
6
+ * enable 하면 GA4 property 가 자동 생성되지만, 그 경로는 property 이름/타임존/통화나
7
+ * web data stream(measurement ID, G-XXXX) 을 제어할 수 없다. 풀 컨트롤이 필요하면 이 모듈을 쓴다.
8
+ *
9
+ * 인증은 mimi-seed OAuth 클라이언트(`requireAuth()`)를 그대로 사용 — `analytics.edit` 스코프 필요.
10
+ * (스코프 추가 후 기존 사용자는 1회 재로그인: `npx -y @yoonion/mimi-seed-mcp mimi-seed-auth`)
11
+ */
12
+ const admin = () => google.analyticsadmin('v1beta');
13
+ const data = () => google.analyticsdata('v1beta');
14
+ /** GA4 도구가 요구하는 OAuth 스코프 — requireAuth() pre-flight 검사에 사용. */
15
+ export const GA4_SCOPE = 'https://www.googleapis.com/auth/analytics.edit';
16
+ // ─── 경로 정규화 (순수 함수 — 테스트 대상) ───
17
+ /** '123' | 'accounts/123' → 'accounts/123' */
18
+ export function normalizeAccountName(accountId) {
19
+ const id = accountId.trim();
20
+ return id.startsWith('accounts/') ? id : `accounts/${id}`;
21
+ }
22
+ /** '123' | 'properties/123' → 'properties/123' */
23
+ export function normalizePropertyName(propertyId) {
24
+ const id = propertyId.trim();
25
+ return id.startsWith('properties/') ? id : `properties/${id}`;
26
+ }
27
+ /**
28
+ * dataStreams.create 응답에서 클라이언트가 실제로 필요로 하는 식별자를 평탄화한다.
29
+ * - web stream → measurementId (G-XXXX, gtag/web 연동용)
30
+ * - app stream → firebaseAppId (Firebase 앱과 자동 링크된 경우)
31
+ */
32
+ export function flattenDataStream(d) {
33
+ return {
34
+ name: d.name ?? null,
35
+ type: d.type ?? null,
36
+ displayName: d.displayName ?? null,
37
+ measurementId: d.webStreamData?.measurementId ?? null,
38
+ firebaseAppId: d.androidAppStreamData?.firebaseAppId ?? d.iosAppStreamData?.firebaseAppId ?? null,
39
+ };
40
+ }
41
+ /** data stream 생성 요청 본문 조립 (순수 함수 — 테스트 대상). */
42
+ export function buildDataStreamBody(opts) {
43
+ switch (opts.platform) {
44
+ case 'web':
45
+ return {
46
+ type: 'WEB_DATA_STREAM',
47
+ displayName: opts.displayName,
48
+ webStreamData: { defaultUri: opts.defaultUri },
49
+ };
50
+ case 'android':
51
+ return {
52
+ type: 'ANDROID_APP_DATA_STREAM',
53
+ displayName: opts.displayName,
54
+ androidAppStreamData: { packageName: opts.packageName },
55
+ };
56
+ case 'ios':
57
+ return {
58
+ type: 'IOS_APP_DATA_STREAM',
59
+ displayName: opts.displayName,
60
+ iosAppStreamData: { bundleId: opts.bundleId },
61
+ };
62
+ }
63
+ }
64
+ // ─── 계정/속성 디스커버리 ───
65
+ /** 접근 가능한 GA 계정 + 각 계정의 property 요약. accountId/propertyId 를 찾는 시작점. */
66
+ export async function listAccountSummaries(auth) {
67
+ const res = await admin().accountSummaries.list({ auth, pageSize: 200 });
68
+ return res.data.accountSummaries ?? [];
69
+ }
70
+ /** 계정 하위 GA4 property 목록. accountId: '123' 또는 'accounts/123'. */
71
+ export async function listProperties(auth, accountId) {
72
+ const res = await admin().properties.list({
73
+ auth,
74
+ filter: `parent:${normalizeAccountName(accountId)}`,
75
+ pageSize: 200,
76
+ });
77
+ return res.data.properties ?? [];
78
+ }
79
+ // ─── property / data stream 생성 (attach 핵심) ───
80
+ export async function createProperty(auth, opts) {
81
+ const res = await admin().properties.create({
82
+ auth,
83
+ requestBody: {
84
+ parent: normalizeAccountName(opts.accountId),
85
+ displayName: opts.displayName,
86
+ timeZone: opts.timeZone ?? 'America/Los_Angeles',
87
+ currencyCode: opts.currencyCode ?? 'USD',
88
+ },
89
+ });
90
+ return res.data; // { name: 'properties/XXXX', displayName, ... }
91
+ }
92
+ export async function createDataStream(auth, propertyId, opts) {
93
+ const res = await admin().properties.dataStreams.create({
94
+ auth,
95
+ parent: normalizePropertyName(propertyId),
96
+ requestBody: buildDataStreamBody(opts),
97
+ });
98
+ return { ...flattenDataStream(res.data), raw: res.data };
99
+ }
100
+ export async function listDataStreams(auth, propertyId) {
101
+ const res = await admin().properties.dataStreams.list({
102
+ auth,
103
+ parent: normalizePropertyName(propertyId),
104
+ pageSize: 200,
105
+ });
106
+ return (res.data.dataStreams ?? []).map((d) => flattenDataStream(d));
107
+ }
108
+ export async function runReport(auth, propertyId, params) {
109
+ const res = await data().properties.runReport({
110
+ auth,
111
+ property: normalizePropertyName(propertyId),
112
+ requestBody: {
113
+ dateRanges: [{ startDate: params.startDate, endDate: params.endDate }],
114
+ dimensions: (params.dimensions ?? []).map((name) => ({ name })),
115
+ metrics: (params.metrics ?? ['activeUsers', 'eventCount']).map((name) => ({ name })),
116
+ },
117
+ });
118
+ return res.data;
119
+ }
package/dist/helpers.d.ts CHANGED
@@ -8,7 +8,7 @@ export { ensureFreshAccessToken };
8
8
  * 기존엔 만료 시 각 OAuth 도구(firebase/admob/iam/googleads/checks)가
9
9
  * googleapis GaxiosError 를 그대로 노출 → 사용자는 "재로그인하라"는 안내를 못 받았다.
10
10
  */
11
- export declare function requireAuth(): Promise<import("google-auth-library").OAuth2Client>;
11
+ export declare function requireAuth(requiredScope?: string): Promise<import("google-auth-library").OAuth2Client>;
12
12
  export declare const PLAY_AUTH_HINT: string;
13
13
  export declare const APPSTORE_AUTH_HINT: string;
14
14
  /**
package/dist/helpers.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getAuthenticatedClient, ensureFreshAccessToken } from './auth/google-auth.js';
1
+ import { getAuthenticatedClient, ensureFreshAccessToken, getStoredTokens } from './auth/google-auth.js';
2
2
  import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore-auth.js';
3
3
  import { getAppStoreCredentials } from './appstore/auth.js';
4
4
  export { ensureFreshAccessToken };
@@ -22,7 +22,7 @@ function formatAuthError(p) {
22
22
  * 기존엔 만료 시 각 OAuth 도구(firebase/admob/iam/googleads/checks)가
23
23
  * googleapis GaxiosError 를 그대로 노출 → 사용자는 "재로그인하라"는 안내를 못 받았다.
24
24
  */
25
- export async function requireAuth() {
25
+ export async function requireAuth(requiredScope) {
26
26
  const result = await ensureFreshAccessToken();
27
27
  if (result.status === 'unauthenticated' || result.status === 'expired_refresh_failed') {
28
28
  throw new Error(formatAuthError(result.error));
@@ -37,6 +37,22 @@ export async function requireAuth() {
37
37
  needsReauth: true,
38
38
  }));
39
39
  }
40
+ // 신규 스코프(GA4 analytics.edit 등)는 변경 전 발급된 토큰엔 없다. expiry 만 보는
41
+ // ensureFreshAccessToken 은 이를 못 걸러내므로(런타임 ACCESS_TOKEN_SCOPE_INSUFFICIENT),
42
+ // 저장된 scope 로 pre-flight 검사해 결정적인 재로그인 안내를 던진다.
43
+ // (scope 가 undefined 인 구 토큰은 미보유로 간주 → 재로그인 유도.)
44
+ if (requiredScope) {
45
+ const granted = (getStoredTokens()?.scope ?? '').split(' ').filter(Boolean);
46
+ if (!granted.includes(requiredScope)) {
47
+ throw new Error(formatAuthError({
48
+ code: 'INSUFFICIENT_SCOPE',
49
+ message: `이 도구는 추가 권한이 필요해 (${requiredScope}). 기존 로그인에 없어서 1회 재로그인이 필요해.`,
50
+ hint: 'mimi-seed-auth 로 재로그인하면 새 권한이 부여돼.',
51
+ retriable: false,
52
+ needsReauth: true,
53
+ }));
54
+ }
55
+ }
40
56
  return client;
41
57
  }
42
58
  export const PLAY_AUTH_HINT = [
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { registerInstagramTools } from './registers/instagram.js';
16
16
  import { registerFacebookTools } from './registers/facebook.js';
17
17
  import { registerGoogleAdsTools } from './registers/googleads.js';
18
18
  import { registerGscTools } from './registers/gsc.js';
19
+ import { registerGa4Tools } from './registers/ga4.js';
19
20
  import { registerJenkinsTools } from './registers/jenkins.js';
20
21
  import { registerAndroidTools } from './registers/android.js';
21
22
  import { registerPrompts } from './prompts.js';
@@ -41,6 +42,7 @@ registerInstagramTools(server);
41
42
  registerFacebookTools(server);
42
43
  registerGoogleAdsTools(server);
43
44
  registerGscTools(server);
45
+ registerGa4Tools(server);
44
46
  registerJenkinsTools(server);
45
47
  registerAndroidTools(server);
46
48
  registerPrompts(server);
@@ -54,6 +56,9 @@ const SUBCOMMANDS = {
54
56
  'mimi-seed-playstore-auth': () => import('./auth/playstore-setup-cli.js'),
55
57
  'mimi-seed-appstore-auth': () => import('./appstore/setup-cli.js'),
56
58
  'mimi-seed-bigquery-auth': () => import('./auth/bigquery-setup-cli.js'),
59
+ 'mimi-seed-firebase': () => import('./firebase/cli.js'),
60
+ 'mimi-seed-admob': () => import('./admob/cli.js'),
61
+ 'mimi-seed-ga4': () => import('./ga4/cli.js'),
57
62
  };
58
63
  async function main() {
59
64
  const sub = process.argv[2];
@@ -0,0 +1,31 @@
1
+ export interface ParsedArgs {
2
+ /** 첫 위치 인자 — 서브커맨드 (예: 'create-property') */
3
+ command: string | undefined;
4
+ /** --name value 형태의 값 플래그 */
5
+ flags: Record<string, string>;
6
+ /** --force 형태의 불리언 플래그 */
7
+ bools: Set<string>;
8
+ }
9
+ /** process.argv.slice(2) 같은 토큰 배열을 파싱. */
10
+ export declare function parseArgs(argv: string[]): ParsedArgs;
11
+ /** 값 플래그 조회 — 없으면 fallback. */
12
+ export declare function flag(p: ParsedArgs, name: string, fallback?: string): string | undefined;
13
+ /** 필수 값 플래그 — 없으면 친절 에러 throw. */
14
+ export declare function requireFlag(p: ParsedArgs, name: string): string;
15
+ /** 쉼표 구분 값 → 배열 ('a, b ,c' → ['a','b','c']). 없으면 undefined. */
16
+ export declare function flagList(p: ParsedArgs, name: string): string[] | undefined;
17
+ /** 사용법 위반 — main()에서 잡아 도움말과 함께 exit 1. */
18
+ export declare class CliUsageError extends Error {
19
+ }
20
+ /** 결과 JSON 을 stdout 으로 출력 (stderr 는 진행/안내용). */
21
+ export declare function printJson(data: unknown): void;
22
+ /**
23
+ * 도메인 sub-CLI 공통 러너. requireAuth → 핸들러 매핑 실행 → JSON 출력 → exit code.
24
+ * 핸들러는 ParsedArgs 를 받아 출력할 데이터를 반환한다.
25
+ */
26
+ export declare function runDomainCli(opts: {
27
+ binName: string;
28
+ argv: string[];
29
+ help: string;
30
+ handlers: Record<string, (p: ParsedArgs) => Promise<unknown>>;
31
+ }): Promise<void>;
@@ -0,0 +1,96 @@
1
+ // 도메인별 sub-CLI(mimi-seed-firebase / -admob / -ga4)가 공유하는 최소 argv 파서.
2
+ // `mimi-seed <domain> <subcommand> [--flag value] [--bool]` 형태를 파싱한다.
3
+ // 실제 사용자 진입점은 cli 패키지의 `mimi-seed <domain> ...` 이고, 그게 npx 로
4
+ // 이 bin 들을 stdio 그대로 호출한다(auth.ts:runMcpBin 패턴과 동일).
5
+ /** process.argv.slice(2) 같은 토큰 배열을 파싱. */
6
+ export function parseArgs(argv) {
7
+ const flags = {};
8
+ const bools = new Set();
9
+ let command;
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const tok = argv[i];
12
+ if (tok.startsWith('--')) {
13
+ const eq = tok.indexOf('=');
14
+ if (eq !== -1) {
15
+ // --key=value — 값이 '--'로 시작해도 안전하게 전달.
16
+ flags[tok.slice(2, eq)] = tok.slice(eq + 1);
17
+ continue;
18
+ }
19
+ const key = tok.slice(2);
20
+ const next = argv[i + 1];
21
+ // 다음 토큰이 또 다른 플래그(--)면 값으로 소비하지 않는다 → 값 누락을 bool 로 기록.
22
+ if (next !== undefined && !next.startsWith('--')) {
23
+ flags[key] = next;
24
+ i++;
25
+ }
26
+ else {
27
+ bools.add(key);
28
+ }
29
+ }
30
+ else if (command === undefined) {
31
+ command = tok;
32
+ }
33
+ }
34
+ return { command, flags, bools };
35
+ }
36
+ /** 값 플래그 조회 — 없으면 fallback. */
37
+ export function flag(p, name, fallback) {
38
+ return p.flags[name] ?? fallback;
39
+ }
40
+ /** 필수 값 플래그 — 없으면 친절 에러 throw. */
41
+ export function requireFlag(p, name) {
42
+ const v = p.flags[name];
43
+ if (v === undefined || v === '') {
44
+ if (p.bools.has(name)) {
45
+ throw new CliUsageError(`--${name} 에 값이 필요합니다 (값 없이 단독으로 쓰였습니다). 값이 '--'로 시작하면 --${name}=값 형태로 쓰세요.`);
46
+ }
47
+ throw new CliUsageError(`--${name} 가 필요합니다.`);
48
+ }
49
+ return v;
50
+ }
51
+ /** 쉼표 구분 값 → 배열 ('a, b ,c' → ['a','b','c']). 없으면 undefined. */
52
+ export function flagList(p, name) {
53
+ const v = p.flags[name];
54
+ if (!v)
55
+ return undefined;
56
+ return v.split(',').map((s) => s.trim()).filter(Boolean);
57
+ }
58
+ /** 사용법 위반 — main()에서 잡아 도움말과 함께 exit 1. */
59
+ export class CliUsageError extends Error {
60
+ }
61
+ /** 결과 JSON 을 stdout 으로 출력 (stderr 는 진행/안내용). */
62
+ export function printJson(data) {
63
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
64
+ }
65
+ /**
66
+ * 도메인 sub-CLI 공통 러너. requireAuth → 핸들러 매핑 실행 → JSON 출력 → exit code.
67
+ * 핸들러는 ParsedArgs 를 받아 출력할 데이터를 반환한다.
68
+ */
69
+ export async function runDomainCli(opts) {
70
+ const p = parseArgs(opts.argv);
71
+ if (!p.command || p.command === 'help' || p.bools.has('help') || p.bools.has('h')) {
72
+ process.stderr.write(opts.help + '\n');
73
+ process.exit(p.command ? 0 : 1);
74
+ }
75
+ const handler = opts.handlers[p.command];
76
+ if (!handler) {
77
+ process.stderr.write(`❌ 알 수 없는 서브커맨드: ${p.command}\n\n` + opts.help + '\n');
78
+ process.exit(1);
79
+ }
80
+ try {
81
+ const result = await handler(p);
82
+ printJson(result);
83
+ process.exit(0);
84
+ }
85
+ catch (e) {
86
+ if (e instanceof CliUsageError) {
87
+ process.stderr.write(`❌ ${e.message}\n\n` + opts.help + '\n');
88
+ }
89
+ else {
90
+ process.stderr.write(`❌ ${e instanceof Error ? e.message : String(e)}\n`);
91
+ if (process.env.DEBUG && e instanceof Error)
92
+ process.stderr.write((e.stack ?? '') + '\n');
93
+ }
94
+ process.exit(1);
95
+ }
96
+ }
@@ -1,5 +1,13 @@
1
1
  export declare function extractHttpStatus(e: unknown): number | undefined;
2
2
  export declare function rawMessage(e: unknown): string;
3
+ /**
4
+ * Google API 에러에서 구조화된 실제 사유를 추출한다. GaxiosError는
5
+ * `response.data.error.{message,errors[].reason}` 에 진짜 원인을 담는데,
6
+ * 친절화 레이어가 이를 버리고 일반 메시지("권한 없음")로 덮으면, 권한이 멀쩡한데도
7
+ * 권한 문제로 오진하게 된다 (예: 같은 SA로 이미지 업로드는 되는데 listings.update만 403).
8
+ * 추출 실패 시 undefined.
9
+ */
10
+ export declare function googleErrorDetail(e: unknown): string | undefined;
3
11
  /** invalid_grant / 만료 refresh_token / 부족 scope → 재로그인 안내 메시지. 아니면 null. */
4
12
  export declare function authReauthMessage(text: string): string | null;
5
13
  export declare function withCause(err: Error, cause: unknown): Error;
@@ -26,6 +26,32 @@ export function rawMessage(e) {
26
26
  return String(e);
27
27
  }
28
28
  }
29
+ /**
30
+ * Google API 에러에서 구조화된 실제 사유를 추출한다. GaxiosError는
31
+ * `response.data.error.{message,errors[].reason}` 에 진짜 원인을 담는데,
32
+ * 친절화 레이어가 이를 버리고 일반 메시지("권한 없음")로 덮으면, 권한이 멀쩡한데도
33
+ * 권한 문제로 오진하게 된다 (예: 같은 SA로 이미지 업로드는 되는데 listings.update만 403).
34
+ * 추출 실패 시 undefined.
35
+ */
36
+ export function googleErrorDetail(e) {
37
+ if (!e || typeof e !== 'object')
38
+ return undefined;
39
+ const any = e;
40
+ const apiErr = any.response?.data?.error;
41
+ const parts = [];
42
+ if (apiErr?.message)
43
+ parts.push(String(apiErr.message));
44
+ const reasons = apiErr?.errors ?? any.errors;
45
+ if (Array.isArray(reasons)) {
46
+ for (const r of reasons) {
47
+ const bit = [r?.reason, r?.message].filter(Boolean).join(': ');
48
+ if (bit && !parts.some((p) => p.includes(bit)))
49
+ parts.push(bit);
50
+ }
51
+ }
52
+ const joined = parts.join(' | ').trim();
53
+ return joined.length ? joined : undefined;
54
+ }
29
55
  /** invalid_grant / 만료 refresh_token / 부족 scope → 재로그인 안내 메시지. 아니면 null. */
30
56
  export function authReauthMessage(text) {
31
57
  if (/invalid_grant|invalid_rapt|rapt_required|unauthorized_client|ACCESS_TOKEN_SCOPE_INSUFFICIENT|insufficient.*scope/i.test(text)) {
@@ -2,17 +2,23 @@
2
2
  // App Store 의 friendlyAppStoreError 와 대칭 — Play 쪽엔 없던 친절화 레이어.
3
3
  // 신규 사용자가 가장 자주 막히는 두 케이스(서비스 계정 권한 403, edit 세션 충돌)에
4
4
  // 구체적 복구 단계를 붙인다. 인식 못 한 에러(도메인 에러 포함)는 원본 보존.
5
- import { extractHttpStatus, authReauthMessage, withCause } from '../lib/google-errors.js';
6
- // playstore_verify_service_account 403 안내와 동일한 체크리스트.
7
- const PERMISSION_403 = [
8
- '❌ Google Play 권한 부족 (403).',
9
- '원인: 인증은 됐지만 Play Console에서 이 계정/서비스 계정에 권한이 없어요.',
10
- '',
11
- '1. Play Console 사용자 및 권한(Users and permissions)',
12
- '2. Google 계정(또는 서비스 계정 이메일)을 초대 / 권한 부여',
13
- '3. 권한에 "앱 정보 관리" (재무 데이터 필요 "재무 데이터 보기") 부여',
14
- '4. 권한 적용까지 ~5분 대기 후 재시도 (너무 빨리 시도하면 계속 403)',
15
- ].join('\n');
5
+ import { extractHttpStatus, authReauthMessage, withCause, googleErrorDetail } from '../lib/google-errors.js';
6
+ // 403 안내. raw Google 사유를 먼저 보여주고, 권한이 멀쩡한데 특정 작업만 거부되는
7
+ // 케이스(같은 SA로 이미지 업로드는 되는데 listings.update만 403 등)를 위해
8
+ // "다른 쓰기가 되면 권한 문제가 아니다"라는 단서를 붙인다. detail 은 googleErrorDetail() 결과.
9
+ function permission403Message(detail) {
10
+ return [
11
+ ' Google Play 403 요청이 거부됐어요.',
12
+ detail ? `Google 사유: ${detail}` : '',
13
+ '⚠️ 같은 계정/서비스 계정으로 다른 쓰기(예: 이미지 업로드)가 된다면 권한 문제가 아닐 수 있어요 — 위 Google 사유 또는 앱 상태·정책·작업별 제한을 먼저 확인하세요.',
14
+ '',
15
+ '정말 권한 문제라면:',
16
+ '1. Play Console → 사용자 및 권한(Users and permissions)',
17
+ '2. 이 Google 계정(또는 서비스 계정 이메일)을 초대 / 권한 부여',
18
+ '3. 앱 권한에 "앱 정보 관리"(Manage store presence) 부여',
19
+ '4. 권한 적용까지 ~5분 대기 후 재시도',
20
+ ].filter((l) => l !== '').join('\n');
21
+ }
16
22
  export function friendlyPlayError(e, packageName) {
17
23
  const text = e instanceof Error ? e.message : String(e);
18
24
  const status = extractHttpStatus(e);
@@ -20,7 +26,7 @@ export function friendlyPlayError(e, packageName) {
20
26
  if (reauth)
21
27
  return withCause(new Error(reauth), e);
22
28
  if (status === 403 || /PERMISSION_DENIED|insufficient permission|forbidden/i.test(text)) {
23
- return withCause(new Error(PERMISSION_403), e);
29
+ return withCause(new Error(permission403Message(googleErrorDetail(e))), e);
24
30
  }
25
31
  if (status === 404 || /NOT_FOUND|not found/i.test(text)) {
26
32
  return withCause(new Error(`❌ 패키지${packageName ? ` (${packageName})` : ''}를 이 개발자 계정에서 찾을 수 없어요.\n→ packageName 철자와 계정 권한을 확인하세요.`), e);
@@ -35,14 +35,15 @@ export function registerAdmobTools(server) {
35
35
  const report = await admob.getNetworkReport(auth, accountId, { year: startYear, month: startMonth, day: startDay }, { year: endYear, month: endMonth, day: endDay });
36
36
  return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] };
37
37
  });
38
- server.tool('admob_create_app', 'AdMob에 새 앱 등록 (v1beta — Limited Access)', {
38
+ server.tool('admob_create_app', 'AdMob에 새 앱 등록 (v1beta — Limited Access). appStoreId 를 주면 스토어 게시 앱과 링크(Android=패키지명, iOS=App Store 숫자 ID), 없으면 manual(unlinked) 앱.', {
39
39
  accountId: z.string().describe('AdMob 계정 ID'),
40
40
  platform: z.enum(['ANDROID', 'IOS']).describe('플랫폼'),
41
41
  displayName: z.string().describe('앱 이름'),
42
- }, async ({ accountId, platform, displayName }) => {
42
+ appStoreId: z.string().optional().describe('스토어 링크용 ID Android는 패키지명(com.x.y), iOS는 App Store 숫자 ID'),
43
+ }, async ({ accountId, platform, displayName, appStoreId }) => {
43
44
  const auth = await requireAuth();
44
45
  try {
45
- const result = await admob.createApp(auth, accountId, platform, displayName);
46
+ const result = await admob.createApp(auth, accountId, platform, displayName, appStoreId);
46
47
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
47
48
  }
48
49
  catch (err) {
@@ -144,4 +144,18 @@ export function registerFirebaseTools(server) {
144
144
  const services = await firebase.listEnabledServices(auth, projectId);
145
145
  return { content: [{ type: 'text', text: JSON.stringify(services, null, 2) }] };
146
146
  });
147
+ server.tool('firebase_link_analytics', 'Firebase 프로젝트에 Google Analytics(GA4) 링크 → 앱별 measurement 자동 활성화. analyticsAccountId(그 계정에 GA4 property 신규 생성) 또는 analyticsPropertyId(기존 property 링크) 중 하나 필수. 먼저 firebase_enable_common_services 로 firebaseanalytics 활성화 권장. property 이름/web stream 까지 직접 제어하려면 ga4_create_property/ga4_create_data_stream 사용.', {
148
+ projectId: z.string().describe('Firebase 프로젝트 ID'),
149
+ analyticsAccountId: z.string().optional().describe('GA 계정 ID (신규 property 생성 위치) — 예: 123456'),
150
+ analyticsPropertyId: z.string().optional().describe('기존 GA4 property ID 에 링크할 경우'),
151
+ }, async ({ projectId, analyticsAccountId, analyticsPropertyId }) => {
152
+ const auth = await requireAuth();
153
+ const result = await firebase.linkAnalytics(auth, projectId, { analyticsAccountId, analyticsPropertyId });
154
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
155
+ });
156
+ server.tool('firebase_get_analytics_details', '프로젝트의 GA4 링크 상세 — 연결된 analyticsProperty + 앱↔data stream 매핑 조회.', { projectId: z.string().describe('Firebase 프로젝트 ID') }, async ({ projectId }) => {
157
+ const auth = await requireAuth();
158
+ const details = await firebase.getAnalyticsDetails(auth, projectId);
159
+ return { content: [{ type: 'text', text: JSON.stringify(details, null, 2) }] };
160
+ });
147
161
  }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerGa4Tools(server: McpServer): void;
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod';
2
+ import * as ga4Raw from '../ga4/tools.js';
3
+ import { requireAuth } from '../helpers.js';
4
+ import { friendlyGoogleError } from '../lib/google-errors.js';
5
+ // firebase register 와 동일한 프록시 — raw GaxiosError(SERVICE_DISABLED / 403 /
6
+ // ACCESS_TOKEN_SCOPE_INSUFFICIENT)를 "다음에 뭘 할지" 메시지로 변환. 특히 GA4 는
7
+ // analytics.edit 스코프를 새로 추가했으므로 기존 사용자는 재로그인 안내가 필수.
8
+ const ga4 = new Proxy(ga4Raw, {
9
+ get(target, prop, receiver) {
10
+ const orig = Reflect.get(target, prop, receiver);
11
+ if (typeof orig !== 'function')
12
+ return orig;
13
+ return (...args) => {
14
+ try {
15
+ const out = orig(...args);
16
+ if (out && typeof out.then === 'function') {
17
+ return out.catch((err) => {
18
+ throw friendlyGoogleError(err);
19
+ });
20
+ }
21
+ return out;
22
+ }
23
+ catch (err) {
24
+ throw friendlyGoogleError(err);
25
+ }
26
+ };
27
+ },
28
+ });
29
+ const PROPERTY_DESC = "GA4 property ID — '123456789' 또는 'properties/123456789'";
30
+ export function registerGa4Tools(server) {
31
+ server.tool('ga4_list_account_summaries', '접근 가능한 Google Analytics 계정 + 각 계정의 GA4 property 요약. accountId / propertyId 를 확인할 때 먼저 호출. analytics.edit 스코프 필요(없으면 재로그인 안내).', {}, async () => {
32
+ const auth = await requireAuth(ga4Raw.GA4_SCOPE);
33
+ const summaries = await ga4.listAccountSummaries(auth);
34
+ return { content: [{ type: 'text', text: JSON.stringify(summaries, null, 2) }] };
35
+ });
36
+ server.tool('ga4_list_properties', '특정 GA 계정 하위 GA4 property 목록.', { accountId: z.string().describe("GA 계정 ID — '123' 또는 'accounts/123'") }, async ({ accountId }) => {
37
+ const auth = await requireAuth(ga4Raw.GA4_SCOPE);
38
+ const properties = await ga4.listProperties(auth, accountId);
39
+ return { content: [{ type: 'text', text: JSON.stringify(properties, null, 2) }] };
40
+ });
41
+ server.tool('ga4_create_property', '새 GA4 property 생성. 생성 후 ga4_create_data_stream 으로 플랫폼별 stream(웹=measurement ID, 앱=firebaseAppId)을 붙인다.', {
42
+ accountId: z.string().describe("GA 계정 ID — '123' 또는 'accounts/123'"),
43
+ displayName: z.string().describe('property 표시 이름 (예: SpeakReward)'),
44
+ timeZone: z.string().optional().describe("IANA 타임존 (기본 'America/Los_Angeles', 예: 'Asia/Seoul')"),
45
+ currencyCode: z.string().optional().describe("ISO 4217 통화코드 (기본 'USD', 예: 'KRW')"),
46
+ }, async ({ accountId, displayName, timeZone, currencyCode }) => {
47
+ const auth = await requireAuth(ga4Raw.GA4_SCOPE);
48
+ const property = await ga4.createProperty(auth, { accountId, displayName, timeZone, currencyCode });
49
+ return { content: [{ type: 'text', text: JSON.stringify(property, null, 2) }] };
50
+ });
51
+ server.tool('ga4_create_data_stream', 'GA4 property 에 data stream 생성. web → measurementId(G-XXXX) 반환, android/ios → firebaseAppId 반환(Firebase 링크 시). RN 앱은 android/ios 스트림 사용.', {
52
+ propertyId: z.string().describe(PROPERTY_DESC),
53
+ platform: z.enum(['web', 'android', 'ios']).describe('스트림 플랫폼'),
54
+ displayName: z.string().describe('스트림 표시 이름'),
55
+ defaultUri: z.string().optional().describe('web 전용 — 사이트 URL (예: https://example.com)'),
56
+ packageName: z.string().optional().describe('android 전용 — 패키지명 (예: com.example.app)'),
57
+ bundleId: z.string().optional().describe('ios 전용 — Bundle ID (예: com.example.app)'),
58
+ }, async ({ propertyId, platform, displayName, defaultUri, packageName, bundleId }) => {
59
+ const auth = await requireAuth(ga4Raw.GA4_SCOPE);
60
+ const stream = await ga4.createDataStream(auth, propertyId, {
61
+ platform,
62
+ displayName,
63
+ defaultUri,
64
+ packageName,
65
+ bundleId,
66
+ });
67
+ return { content: [{ type: 'text', text: JSON.stringify(stream, null, 2) }] };
68
+ });
69
+ server.tool('ga4_list_data_streams', 'GA4 property 의 data stream 목록 (measurementId / firebaseAppId 평탄화).', { propertyId: z.string().describe(PROPERTY_DESC) }, async ({ propertyId }) => {
70
+ const auth = await requireAuth(ga4Raw.GA4_SCOPE);
71
+ const streams = await ga4.listDataStreams(auth, propertyId);
72
+ return { content: [{ type: 'text', text: JSON.stringify(streams, null, 2) }] };
73
+ });
74
+ server.tool('ga4_run_report', 'GA4 Data API 리포트 (활성 사용자·이벤트 등). dimensions/metrics 는 쉼표 구분 GA4 API 이름.', {
75
+ propertyId: z.string().describe(PROPERTY_DESC),
76
+ startDate: z.string().describe("시작일 (YYYY-MM-DD 또는 'NdaysAgo', 'today')"),
77
+ endDate: z.string().describe("종료일 (YYYY-MM-DD 또는 'today')"),
78
+ dimensions: z.string().optional().describe('쉼표 구분 dimension (예: date,country). 생략 시 전체 합계'),
79
+ metrics: z.string().optional().describe('쉼표 구분 metric (기본 activeUsers,eventCount)'),
80
+ }, async ({ propertyId, startDate, endDate, dimensions, metrics }) => {
81
+ const auth = await requireAuth(ga4Raw.GA4_SCOPE);
82
+ const report = await ga4.runReport(auth, propertyId, {
83
+ startDate,
84
+ endDate,
85
+ dimensions: dimensions ? dimensions.split(',').map((d) => d.trim()).filter(Boolean) : undefined,
86
+ metrics: metrics ? metrics.split(',').map((m) => m.trim()).filter(Boolean) : undefined,
87
+ });
88
+ return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] };
89
+ });
90
+ }
@@ -46,7 +46,7 @@ export function registerPlaystoreTools(server) {
46
46
  const listing = await playstore.getListing(auth, packageName, language);
47
47
  return { content: [{ type: 'text', text: JSON.stringify(listing, null, 2) }] };
48
48
  });
49
- server.tool('playstore_update_listing', 'Google Play 스토어 리스팅 수정 (제목, 설명문 변경)', {
49
+ server.tool('playstore_update_listing', 'Google Play 스토어 리스팅 수정 (제목, 설명문 변경). ⚠️ Play API 편집과 Console 동시 편집은 충돌 — Console에서 같은 리스팅을 편집·미게시 중이면 그 변경이 덮어써질 수 있음.', {
50
50
  packageName: z.string().describe('패키지명'),
51
51
  language: z.string().describe('언어 코드 (예: ko-KR, en-US)'),
52
52
  title: z.string().optional().describe('앱 제목 (30자 이내)'),
@@ -115,7 +115,7 @@ export function registerPlaystoreTools(server) {
115
115
  const images = await playstore.listImages(auth, packageName, language, imageType);
116
116
  return { content: [{ type: 'text', text: JSON.stringify(images, null, 2) }] };
117
117
  });
118
- server.tool('playstore_upload_image', 'Google Play 리스팅 이미지 단일 업로드 (기존 이미지 유지). featureGraphic 1024x500 / icon 512x512 / phoneScreenshots 320~3840px', {
118
+ server.tool('playstore_upload_image', 'Google Play 리스팅 이미지 단일 업로드 (기존 이미지 유지). featureGraphic 1024x500 / icon 512x512 / phoneScreenshots 320~3840px. ⚠️ 이 작업은 edit을 commit하므로 Play Console의 미게시(저장 전) 리스팅 변경을 덮어쓸 수 있음 — Console에서 같은 앱을 편집 중이면 먼저 저장·게시하거나 텍스트도 update_listing으로 처리하세요.', {
119
119
  packageName: z.string().describe('패키지명'),
120
120
  language: z.string().describe('언어 코드'),
121
121
  imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
@@ -125,7 +125,7 @@ export function registerPlaystoreTools(server) {
125
125
  const result = await playstore.uploadImage(auth, packageName, language, imageType, filePath);
126
126
  return { content: [{ type: 'text', text: `✅ 업로드 완료\n${JSON.stringify(result, null, 2)}` }] };
127
127
  });
128
- server.tool('playstore_delete_all_images', 'Google Play 리스팅 특정 imageType의 이미지 전체 삭제 (교체 전 정리)', {
128
+ server.tool('playstore_delete_all_images', 'Google Play 리스팅 특정 imageType의 이미지 전체 삭제 (교체 전 정리). ⚠️ commit이 Play Console의 미게시 리스팅 변경을 덮어쓸 수 있음.', {
129
129
  packageName: z.string().describe('패키지명'),
130
130
  language: z.string().describe('언어 코드'),
131
131
  imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
@@ -134,7 +134,7 @@ export function registerPlaystoreTools(server) {
134
134
  const result = await playstore.deleteAllImages(auth, packageName, language, imageType);
135
135
  return { content: [{ type: 'text', text: `✅ 전체 삭제\n${JSON.stringify(result, null, 2)}` }] };
136
136
  });
137
- server.tool('playstore_replace_images', 'Google Play 리스팅 이미지 일괄 교체 (한 edit 세션: deleteall → 순서대로 upload → commit). 스크린샷 5~8장 한 번에 교체 시 효율적. 업로드 순서가 스토어 노출 순서', {
137
+ server.tool('playstore_replace_images', 'Google Play 리스팅 이미지 일괄 교체 (한 edit 세션: deleteall → 순서대로 upload → commit). 스크린샷 5~8장 한 번에 교체 시 효율적. 업로드 순서가 스토어 노출 순서. ⚠️ commit이 Play Console의 미게시 리스팅 변경을 덮어쓸 수 있음.', {
138
138
  packageName: z.string().describe('패키지명'),
139
139
  language: z.string().describe('언어 코드'),
140
140
  imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.40",
3
+ "version": "0.4.0",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,10 @@
8
8
  "mimi-seed-auth": "dist/auth/cli.js",
9
9
  "mimi-seed-appstore-auth": "dist/appstore/setup-cli.js",
10
10
  "mimi-seed-playstore-auth": "dist/auth/playstore-setup-cli.js",
11
- "mimi-seed-bigquery-auth": "dist/auth/bigquery-setup-cli.js"
11
+ "mimi-seed-bigquery-auth": "dist/auth/bigquery-setup-cli.js",
12
+ "mimi-seed-firebase": "dist/firebase/cli.js",
13
+ "mimi-seed-admob": "dist/admob/cli.js",
14
+ "mimi-seed-ga4": "dist/ga4/cli.js"
12
15
  },
13
16
  "files": [
14
17
  "dist",