@yoonion/mimi-seed-mcp 0.3.40 → 0.5.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.
- package/LICENSE +75 -75
- package/README.md +241 -235
- package/dist/admob/cli.d.ts +2 -0
- package/dist/admob/cli.js +53 -0
- package/dist/admob/tools.d.ts +12 -2
- package/dist/admob/tools.js +32 -7
- package/dist/auth/errors.d.ts +1 -1
- package/dist/auth/google-auth.d.ts +2 -0
- package/dist/auth/google-auth.js +12 -7
- package/dist/firebase/cli.d.ts +2 -0
- package/dist/firebase/cli.js +82 -0
- package/dist/firebase/tools.d.ts +14 -0
- package/dist/firebase/tools.js +34 -0
- package/dist/ga4/cli.d.ts +2 -0
- package/dist/ga4/cli.js +63 -0
- package/dist/ga4/tools.d.ts +107 -0
- package/dist/ga4/tools.js +119 -0
- package/dist/googleads/tools.js +54 -54
- package/dist/helpers.d.ts +1 -1
- package/dist/helpers.js +18 -2
- package/dist/index.js +5 -0
- package/dist/lib/cli-args.d.ts +31 -0
- package/dist/lib/cli-args.js +96 -0
- package/dist/lib/google-errors.d.ts +8 -0
- package/dist/lib/google-errors.js +26 -0
- package/dist/lib/project-manifest.d.ts +36 -0
- package/dist/lib/project-manifest.js +52 -0
- package/dist/playstore/errors.js +18 -12
- package/dist/registers/admob.js +4 -3
- package/dist/registers/auth.js +88 -5
- package/dist/registers/firebase.js +14 -0
- package/dist/registers/ga4.d.ts +2 -0
- package/dist/registers/ga4.js +90 -0
- package/dist/registers/playstore.js +4 -4
- package/package.json +8 -2
package/dist/googleads/tools.js
CHANGED
|
@@ -84,20 +84,20 @@ export async function listAccessibleCustomers(auth, cfg) {
|
|
|
84
84
|
}
|
|
85
85
|
// ─── 캠페인 목록 ─────────────────────────────────────────────
|
|
86
86
|
export async function listCampaigns(auth, cfg) {
|
|
87
|
-
const query = `
|
|
88
|
-
SELECT
|
|
89
|
-
campaign.id,
|
|
90
|
-
campaign.name,
|
|
91
|
-
campaign.status,
|
|
92
|
-
campaign.advertising_channel_type,
|
|
93
|
-
campaign.advertising_channel_sub_type,
|
|
94
|
-
campaign.start_date,
|
|
95
|
-
campaign.end_date,
|
|
96
|
-
campaign_budget.amount_micros
|
|
97
|
-
FROM campaign
|
|
98
|
-
WHERE campaign.status != 'REMOVED'
|
|
99
|
-
ORDER BY campaign.name ASC
|
|
100
|
-
LIMIT 200
|
|
87
|
+
const query = `
|
|
88
|
+
SELECT
|
|
89
|
+
campaign.id,
|
|
90
|
+
campaign.name,
|
|
91
|
+
campaign.status,
|
|
92
|
+
campaign.advertising_channel_type,
|
|
93
|
+
campaign.advertising_channel_sub_type,
|
|
94
|
+
campaign.start_date,
|
|
95
|
+
campaign.end_date,
|
|
96
|
+
campaign_budget.amount_micros
|
|
97
|
+
FROM campaign
|
|
98
|
+
WHERE campaign.status != 'REMOVED'
|
|
99
|
+
ORDER BY campaign.name ASC
|
|
100
|
+
LIMIT 200
|
|
101
101
|
`;
|
|
102
102
|
const rows = await search(auth, cfg, query);
|
|
103
103
|
return rows.map((r) => ({
|
|
@@ -113,26 +113,26 @@ export async function listCampaigns(auth, cfg) {
|
|
|
113
113
|
}
|
|
114
114
|
// ─── 캠페인 성과 리포트 ────────────────────────────────────────
|
|
115
115
|
export async function getCampaignReport(auth, cfg, range) {
|
|
116
|
-
const query = `
|
|
117
|
-
SELECT
|
|
118
|
-
campaign.id,
|
|
119
|
-
campaign.name,
|
|
120
|
-
campaign.status,
|
|
121
|
-
campaign.advertising_channel_type,
|
|
122
|
-
campaign.advertising_channel_sub_type,
|
|
123
|
-
metrics.clicks,
|
|
124
|
-
metrics.impressions,
|
|
125
|
-
metrics.cost_micros,
|
|
126
|
-
metrics.conversions,
|
|
127
|
-
metrics.conversions_value,
|
|
128
|
-
metrics.cost_per_conversion,
|
|
129
|
-
metrics.ctr,
|
|
130
|
-
metrics.average_cpc
|
|
131
|
-
FROM campaign
|
|
132
|
-
WHERE campaign.status != 'REMOVED'
|
|
133
|
-
AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
|
|
134
|
-
ORDER BY metrics.cost_micros DESC
|
|
135
|
-
LIMIT 500
|
|
116
|
+
const query = `
|
|
117
|
+
SELECT
|
|
118
|
+
campaign.id,
|
|
119
|
+
campaign.name,
|
|
120
|
+
campaign.status,
|
|
121
|
+
campaign.advertising_channel_type,
|
|
122
|
+
campaign.advertising_channel_sub_type,
|
|
123
|
+
metrics.clicks,
|
|
124
|
+
metrics.impressions,
|
|
125
|
+
metrics.cost_micros,
|
|
126
|
+
metrics.conversions,
|
|
127
|
+
metrics.conversions_value,
|
|
128
|
+
metrics.cost_per_conversion,
|
|
129
|
+
metrics.ctr,
|
|
130
|
+
metrics.average_cpc
|
|
131
|
+
FROM campaign
|
|
132
|
+
WHERE campaign.status != 'REMOVED'
|
|
133
|
+
AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
|
|
134
|
+
ORDER BY metrics.cost_micros DESC
|
|
135
|
+
LIMIT 500
|
|
136
136
|
`;
|
|
137
137
|
const rows = await search(auth, cfg, query);
|
|
138
138
|
return rows.map((r) => ({
|
|
@@ -153,26 +153,26 @@ export async function getCampaignReport(auth, cfg, range) {
|
|
|
153
153
|
}
|
|
154
154
|
// ─── UAC(앱 캠페인) 리포트 ─────────────────────────────────────
|
|
155
155
|
export async function getUacReport(auth, cfg, range) {
|
|
156
|
-
const query = `
|
|
157
|
-
SELECT
|
|
158
|
-
campaign.id,
|
|
159
|
-
campaign.name,
|
|
160
|
-
campaign.status,
|
|
161
|
-
campaign.advertising_channel_sub_type,
|
|
162
|
-
metrics.clicks,
|
|
163
|
-
metrics.impressions,
|
|
164
|
-
metrics.cost_micros,
|
|
165
|
-
metrics.conversions,
|
|
166
|
-
metrics.conversions_value,
|
|
167
|
-
metrics.cost_per_conversion,
|
|
168
|
-
metrics.ctr,
|
|
169
|
-
segments.date
|
|
170
|
-
FROM campaign
|
|
171
|
-
WHERE campaign.advertising_channel_type = 'MULTI_CHANNEL'
|
|
172
|
-
AND campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT', 'APP_CAMPAIGN_FOR_PRE_REGISTRATION')
|
|
173
|
-
AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
|
|
174
|
-
ORDER BY segments.date DESC, metrics.cost_micros DESC
|
|
175
|
-
LIMIT 500
|
|
156
|
+
const query = `
|
|
157
|
+
SELECT
|
|
158
|
+
campaign.id,
|
|
159
|
+
campaign.name,
|
|
160
|
+
campaign.status,
|
|
161
|
+
campaign.advertising_channel_sub_type,
|
|
162
|
+
metrics.clicks,
|
|
163
|
+
metrics.impressions,
|
|
164
|
+
metrics.cost_micros,
|
|
165
|
+
metrics.conversions,
|
|
166
|
+
metrics.conversions_value,
|
|
167
|
+
metrics.cost_per_conversion,
|
|
168
|
+
metrics.ctr,
|
|
169
|
+
segments.date
|
|
170
|
+
FROM campaign
|
|
171
|
+
WHERE campaign.advertising_channel_type = 'MULTI_CHANNEL'
|
|
172
|
+
AND campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT', 'APP_CAMPAIGN_FOR_PRE_REGISTRATION')
|
|
173
|
+
AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
|
|
174
|
+
ORDER BY segments.date DESC, metrics.cost_micros DESC
|
|
175
|
+
LIMIT 500
|
|
176
176
|
`;
|
|
177
177
|
const rows = await search(auth, cfg, query);
|
|
178
178
|
const byId = new Map();
|
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)) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const MANIFEST_FILENAME = ".mimi-seed.json";
|
|
2
|
+
/** 매니페스트가 인지하는 서비스 id. status 매칭 로직이 이 키를 기준으로 동작한다. */
|
|
3
|
+
export type ManifestServiceId = 'oauth' | 'bigquery' | 'playstore' | 'appstore' | 'jenkins';
|
|
4
|
+
export interface ManifestService {
|
|
5
|
+
/** 이 프로젝트에서 필수인지. false(선택)면 미설정이어도 ❌ 대신 정보 표기. */
|
|
6
|
+
required?: boolean;
|
|
7
|
+
/** 사람이 읽을 한 줄 설명. status 출력에 그대로 표시. */
|
|
8
|
+
note?: string;
|
|
9
|
+
projectId?: string;
|
|
10
|
+
dataset?: string;
|
|
11
|
+
packageName?: string;
|
|
12
|
+
keyId?: string;
|
|
13
|
+
issuerId?: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
/** 이 서비스가 워크스페이스 공유 시크릿(provider)으로도 제공되는 경우 그 provider id. */
|
|
16
|
+
workspaceProvider?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ProjectManifest {
|
|
19
|
+
project?: string;
|
|
20
|
+
displayName?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
services?: Partial<Record<ManifestServiceId, ManifestService>>;
|
|
23
|
+
}
|
|
24
|
+
export interface LoadedManifest {
|
|
25
|
+
manifest: ProjectManifest;
|
|
26
|
+
/** 실제로 읽은 파일 절대 경로. */
|
|
27
|
+
filePath: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* startDir 에서 위로 올라가며 첫 번째 `.mimi-seed.json` 을 찾는다.
|
|
31
|
+
* MCP stdio 서버의 cwd 는 보통 프로젝트 루트지만, 하위 폴더에서 실행돼도 잡히게 상향 탐색한다.
|
|
32
|
+
* maxDepth 로 홈 디렉터리 밖까지 무한 상승하는 것을 방지.
|
|
33
|
+
*/
|
|
34
|
+
export declare function findProjectManifest(startDir?: string, maxDepth?: number): LoadedManifest | null;
|
|
35
|
+
/** 매니페스트 services 를 [id, service] 배열로. 없으면 빈 배열. */
|
|
36
|
+
export declare function manifestServiceEntries(m: ProjectManifest): Array<[ManifestServiceId, ManifestService]>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 프로젝트 매니페스트(.mimi-seed.json) 리더.
|
|
2
|
+
//
|
|
3
|
+
// 프로젝트 루트에 커밋된 `.mimi-seed.json` 이 "이 저장소를 mimi-seed 로 다루려면
|
|
4
|
+
// 어떤 서비스 연결이 필요한가"를 선언한다. mimi_seed_status(MCP)와 mimi-seed doctor(CLI)가
|
|
5
|
+
// 이 파일을 읽어, 범용 9종 스캔 대신 "이 프로젝트에서 너한테 빠진 것 + 정확한 셋업 명령"을 보여준다.
|
|
6
|
+
//
|
|
7
|
+
// 팀 온보딩 목적: 새 팀원이 clone → mimi_seed_status 호출 → 안내 따라가기 만으로
|
|
8
|
+
// 자기 머신에 필요한 자격증명을 채울 수 있게 한다.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
export const MANIFEST_FILENAME = '.mimi-seed.json';
|
|
12
|
+
/**
|
|
13
|
+
* startDir 에서 위로 올라가며 첫 번째 `.mimi-seed.json` 을 찾는다.
|
|
14
|
+
* MCP stdio 서버의 cwd 는 보통 프로젝트 루트지만, 하위 폴더에서 실행돼도 잡히게 상향 탐색한다.
|
|
15
|
+
* maxDepth 로 홈 디렉터리 밖까지 무한 상승하는 것을 방지.
|
|
16
|
+
*/
|
|
17
|
+
export function findProjectManifest(startDir = process.cwd(), maxDepth = 8) {
|
|
18
|
+
let dir = path.resolve(startDir);
|
|
19
|
+
for (let i = 0; i <= maxDepth; i++) {
|
|
20
|
+
const candidate = path.join(dir, MANIFEST_FILENAME);
|
|
21
|
+
if (fs.existsSync(candidate)) {
|
|
22
|
+
const parsed = safeParse(candidate);
|
|
23
|
+
if (parsed)
|
|
24
|
+
return { manifest: parsed, filePath: candidate };
|
|
25
|
+
return null; // 파일은 있으나 형식 오류 — 상위로 더 올라가지 않는다(의도 명확).
|
|
26
|
+
}
|
|
27
|
+
const parent = path.dirname(dir);
|
|
28
|
+
if (parent === dir)
|
|
29
|
+
break; // 루트 도달
|
|
30
|
+
dir = parent;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function safeParse(filePath) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
37
|
+
const obj = JSON.parse(raw);
|
|
38
|
+
if (obj && typeof obj === 'object')
|
|
39
|
+
return obj;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** 매니페스트 services 를 [id, service] 배열로. 없으면 빈 배열. */
|
|
47
|
+
export function manifestServiceEntries(m) {
|
|
48
|
+
const svc = m.services ?? {};
|
|
49
|
+
return Object.keys(svc)
|
|
50
|
+
.filter((k) => svc[k] != null)
|
|
51
|
+
.map((k) => [k, svc[k]]);
|
|
52
|
+
}
|
package/dist/playstore/errors.js
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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(
|
|
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);
|
package/dist/registers/admob.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/registers/auth.js
CHANGED
|
@@ -7,7 +7,8 @@ import { loadCiConfig } from '../ci/config.js';
|
|
|
7
7
|
import { loadConfig as loadGoogleAdsConfig } from '../googleads/config.js';
|
|
8
8
|
import { loadFacebookConfig } from '../facebook/config.js';
|
|
9
9
|
import { loadInstagramConfig } from '../instagram/config.js';
|
|
10
|
-
import {
|
|
10
|
+
import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
|
|
11
|
+
import { findProjectManifest, manifestServiceEntries, } from '../lib/project-manifest.js';
|
|
11
12
|
/**
|
|
12
13
|
* tokens.json mtime → "Nd Hh ago" 형식 문자열 + 재인증 권고.
|
|
13
14
|
* Google 정책: 7일 미사용 시 미인증 앱 refresh_token revoke 가능.
|
|
@@ -34,6 +35,54 @@ function formatLastRefreshHint(lastMs) {
|
|
|
34
35
|
}
|
|
35
36
|
return { label };
|
|
36
37
|
}
|
|
38
|
+
// 매니페스트 서비스별 셋업 명령. status/doctor 가 "정확한 다음 명령"으로 안내한다.
|
|
39
|
+
const MANIFEST_FIX = {
|
|
40
|
+
oauth: () => 'mimi_seed_auth_start (Google 로그인 — Firebase/AdMob/Play/GSC/GA4)',
|
|
41
|
+
bigquery: () => 'npx -y @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth (서비스 계정 권장)',
|
|
42
|
+
playstore: (svc) => svc.packageName
|
|
43
|
+
? `setup_playstore_connection(packageName="${svc.packageName}")`
|
|
44
|
+
: 'setup_playstore_connection(packageName=...)',
|
|
45
|
+
appstore: () => 'npx -y @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
|
|
46
|
+
jenkins: () => 'claude mcp add virgm-jenkins -s user (개인 자격증명 — .mcp.json 금지)',
|
|
47
|
+
};
|
|
48
|
+
/** 서비스별 식별자를 한 줄 detail 로 (예: "ads-coffee / analytics_530080532"). */
|
|
49
|
+
function manifestServiceDetail(id, svc) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
if (id === 'bigquery') {
|
|
52
|
+
if (svc.projectId)
|
|
53
|
+
parts.push(svc.projectId);
|
|
54
|
+
if (svc.dataset)
|
|
55
|
+
parts.push(svc.dataset);
|
|
56
|
+
}
|
|
57
|
+
else if (id === 'playstore') {
|
|
58
|
+
if (svc.packageName)
|
|
59
|
+
parts.push(svc.packageName);
|
|
60
|
+
}
|
|
61
|
+
else if (id === 'appstore') {
|
|
62
|
+
if (svc.keyId)
|
|
63
|
+
parts.push(`keyId ${svc.keyId}`);
|
|
64
|
+
}
|
|
65
|
+
else if (id === 'jenkins') {
|
|
66
|
+
if (svc.url)
|
|
67
|
+
parts.push(svc.url);
|
|
68
|
+
}
|
|
69
|
+
return parts.join(' / ');
|
|
70
|
+
}
|
|
71
|
+
/** 매니페스트 한 서비스를 status 라인(들)으로 렌더. 미설정+필수면 명령 힌트를 붙인다. */
|
|
72
|
+
function renderManifestLine(id, svc, connected, required) {
|
|
73
|
+
const label = id.padEnd(10);
|
|
74
|
+
const detail = manifestServiceDetail(id, svc);
|
|
75
|
+
const detailSuffix = detail ? ` (${detail})` : '';
|
|
76
|
+
if (connected)
|
|
77
|
+
return `✅ ${label} — 연결됨${detailSuffix}`;
|
|
78
|
+
if (!required) {
|
|
79
|
+
const note = svc.note ? ` — ${svc.note}` : '';
|
|
80
|
+
return `ℹ️ ${label} — (선택)${detailSuffix}${note}`;
|
|
81
|
+
}
|
|
82
|
+
const fix = MANIFEST_FIX[id](svc);
|
|
83
|
+
const noteLine = svc.note ? `\n ${svc.note}` : '';
|
|
84
|
+
return `❌ ${label} — 미설정${detailSuffix}\n → ${fix}${noteLine}`;
|
|
85
|
+
}
|
|
37
86
|
export function registerAuthTools(server) {
|
|
38
87
|
// ── 전체 연결 상태 진단 ────────────────────────────────────────────────────
|
|
39
88
|
server.tool('mimi_seed_status', [
|
|
@@ -114,10 +163,14 @@ export function registerAuthTools(server) {
|
|
|
114
163
|
else {
|
|
115
164
|
lines.push('❌ Instagram — 미설정 → instagram_save_config (선택)');
|
|
116
165
|
}
|
|
117
|
-
// 9. BigQuery
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
166
|
+
// 9. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
|
|
167
|
+
// 이전엔 SA 파일만 검사해 OAuth fallback 이 살아있어도 ❌ 로 오표기했다.
|
|
168
|
+
const bqAuth = resolveBigQueryAuth();
|
|
169
|
+
if (bqAuth?.source === 'service-account') {
|
|
170
|
+
lines.push(`✅ BigQuery — 서비스 계정 연결됨 (${bqAuth.clientEmail ?? ''})`);
|
|
171
|
+
}
|
|
172
|
+
else if (bqAuth?.source === 'user-oauth') {
|
|
173
|
+
lines.push('⚠️ BigQuery — OAuth fallback (Workspace 재인증 정책 시 끊길 수 있음 → 서비스 계정 권장: mimi-seed-bigquery-auth)');
|
|
121
174
|
}
|
|
122
175
|
else {
|
|
123
176
|
lines.push('❌ BigQuery — 미설정 → npx @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth (선택)');
|
|
@@ -139,6 +192,36 @@ export function registerAuthTools(server) {
|
|
|
139
192
|
else {
|
|
140
193
|
lines.push('', '✅ 필수 연결 모두 완료. 앱 출시 작업을 시작할 수 있습니다.');
|
|
141
194
|
}
|
|
195
|
+
// ── 프로젝트 매니페스트(.mimi-seed.json) 기반 요구사항 ────────────────────
|
|
196
|
+
// 이 저장소가 "무엇을 필요로 하는가"를 선언해두면, 범용 스캔 대신
|
|
197
|
+
// "이 프로젝트에서 너한테 빠진 것 + 정확한 셋업 명령"을 팀원별로 보여준다.
|
|
198
|
+
const loaded = findProjectManifest();
|
|
199
|
+
if (loaded) {
|
|
200
|
+
const oauthOk = oauthResult.status === 'fresh' || oauthResult.status === 'refreshed';
|
|
201
|
+
const playstoreCovers = (pkg) => saCount > 0 &&
|
|
202
|
+
(!pkg || !!saInfo.default || saInfo.perPackage.some((p) => p.packageName === pkg));
|
|
203
|
+
const state = {
|
|
204
|
+
oauth: oauthOk,
|
|
205
|
+
bigquery: !!bqAuth,
|
|
206
|
+
playstore: playstoreCovers(),
|
|
207
|
+
appstore: !!asc,
|
|
208
|
+
jenkins: !!jenkins,
|
|
209
|
+
};
|
|
210
|
+
const projName = loaded.manifest.displayName ?? loaded.manifest.project ?? '이 프로젝트';
|
|
211
|
+
const reqLines = ['', `── ${projName} 요구사항 (.mimi-seed.json) ─────────`];
|
|
212
|
+
let anyMissing = false;
|
|
213
|
+
for (const [id, svc] of manifestServiceEntries(loaded.manifest)) {
|
|
214
|
+
const required = svc.required !== false;
|
|
215
|
+
const connected = id === 'playstore' ? playstoreCovers(svc.packageName) : state[id];
|
|
216
|
+
reqLines.push(renderManifestLine(id, svc, connected, required));
|
|
217
|
+
if (required && !connected)
|
|
218
|
+
anyMissing = true;
|
|
219
|
+
}
|
|
220
|
+
if (anyMissing) {
|
|
221
|
+
reqLines.push('', 'ℹ️ 팀 공유가 목표라면, 각자 로컬 자격증명을 채우는 대신', ' 워크스페이스(https://mimi-seed.pryzm.gg) 초대 → PAT 발급 → 원격 MCP 연결도 가능.');
|
|
222
|
+
}
|
|
223
|
+
lines.push(...reqLines);
|
|
224
|
+
}
|
|
142
225
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
143
226
|
});
|
|
144
227
|
server.tool('mimi_seed_auth_start', [
|