@yoonion/mimi-seed-mcp 0.3.39 → 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.
- 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 +5 -0
- 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/helpers.d.ts +1 -1
- package/dist/helpers.js +18 -2
- package/dist/index.js +5 -0
- package/dist/jenkins/credentials.d.ts +4 -0
- package/dist/jenkins/credentials.js +67 -28
- 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/playstore/errors.js +18 -12
- package/dist/registers/admob.js +4 -3
- package/dist/registers/android.js +4 -0
- 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 +5 -2
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];
|
|
@@ -6,5 +6,9 @@ export interface JenkinsCredentialSummary {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function listCredentials(cfg: JenkinsConfig): Promise<JenkinsCredentialSummary[]>;
|
|
8
8
|
export declare function upsertSecretText(cfg: JenkinsConfig, id: string, secret: string, description?: string): Promise<'created' | 'updated'>;
|
|
9
|
+
/**
|
|
10
|
+
* Secret File credential 생성/교체. Jenkins는 파일을 multipart 로 받고
|
|
11
|
+
* json 본문이 "file": "<필드명>" 으로 참조한다 (secretBytes JSON 직접 입력은 불가).
|
|
12
|
+
*/
|
|
9
13
|
export declare function upsertSecretFile(cfg: JenkinsConfig, id: string, fileBase64: string, fileName: string, description?: string): Promise<'created' | 'updated'>;
|
|
10
14
|
export declare function deleteCredential(cfg: JenkinsConfig, id: string): Promise<void>;
|
|
@@ -1,17 +1,45 @@
|
|
|
1
|
+
const FILE_CLASS = 'org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl';
|
|
2
|
+
const TEXT_CLASS = 'org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl';
|
|
1
3
|
function basicAuth(username, token) {
|
|
2
4
|
return 'Basic ' + Buffer.from(`${username}:${token}`).toString('base64');
|
|
3
5
|
}
|
|
4
|
-
|
|
6
|
+
// 도메인(_ = 전역) 레벨 — 목록 조회 / createCredentials 의 베이스
|
|
7
|
+
function storeBase(url) {
|
|
5
8
|
return `${url.replace(/\/$/, '')}/credentials/store/system/domain/_`;
|
|
6
9
|
}
|
|
10
|
+
// 개별 credential 레벨 — 반드시 /credential/<id> 세그먼트 필요 (조회/업데이트/삭제)
|
|
11
|
+
function credentialBase(url, id) {
|
|
12
|
+
return `${storeBase(url)}/credential/${encodeURIComponent(id)}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* CSRF crumb (best-effort). crumb issuer 비활성이거나 API 토큰으로 면제되면 빈 객체.
|
|
16
|
+
* 구버전 Jenkins / 비밀번호 인증 환경에서 POST 403 방지.
|
|
17
|
+
*/
|
|
18
|
+
async function getCrumb(cfg) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${cfg.url.replace(/\/$/, '')}/crumbIssuer/api/json`, {
|
|
21
|
+
headers: { Authorization: basicAuth(cfg.username, cfg.token) },
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return {};
|
|
25
|
+
const data = (await res.json());
|
|
26
|
+
if (data.crumbRequestField && data.crumb) {
|
|
27
|
+
return { [data.crumbRequestField]: data.crumb };
|
|
28
|
+
}
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
7
35
|
async function credentialExists(cfg, id) {
|
|
8
|
-
const res = await fetch(`${
|
|
36
|
+
const res = await fetch(`${credentialBase(cfg.url, id)}/api/json`, {
|
|
9
37
|
headers: { Authorization: basicAuth(cfg.username, cfg.token) },
|
|
10
38
|
});
|
|
11
39
|
return res.ok;
|
|
12
40
|
}
|
|
13
41
|
export async function listCredentials(cfg) {
|
|
14
|
-
const res = await fetch(`${
|
|
42
|
+
const res = await fetch(`${storeBase(cfg.url)}/api/json?depth=1`, {
|
|
15
43
|
headers: { Authorization: basicAuth(cfg.username, cfg.token) },
|
|
16
44
|
});
|
|
17
45
|
if (!res.ok)
|
|
@@ -24,60 +52,67 @@ export async function listCredentials(cfg) {
|
|
|
24
52
|
}));
|
|
25
53
|
}
|
|
26
54
|
export async function upsertSecretText(cfg, id, secret, description = '') {
|
|
27
|
-
const
|
|
28
|
-
|
|
55
|
+
const exists = await credentialExists(cfg, id);
|
|
56
|
+
const payload = {
|
|
29
57
|
credentials: {
|
|
30
58
|
scope: 'GLOBAL',
|
|
31
59
|
id,
|
|
32
60
|
description,
|
|
33
61
|
secret,
|
|
34
|
-
|
|
35
|
-
'
|
|
62
|
+
$class: TEXT_CLASS,
|
|
63
|
+
'stapler-class': TEXT_CLASS,
|
|
36
64
|
},
|
|
37
|
-
}
|
|
38
|
-
const base = credentialsBase(cfg.url);
|
|
39
|
-
const exists = await credentialExists(cfg, id);
|
|
65
|
+
};
|
|
40
66
|
const endpoint = exists
|
|
41
|
-
? `${
|
|
42
|
-
: `${
|
|
67
|
+
? `${credentialBase(cfg.url, id)}/updateSubmit`
|
|
68
|
+
: `${storeBase(cfg.url)}/createCredentials`;
|
|
69
|
+
const crumb = await getCrumb(cfg);
|
|
43
70
|
const res = await fetch(endpoint, {
|
|
44
71
|
method: 'POST',
|
|
45
72
|
headers: {
|
|
46
73
|
Authorization: basicAuth(cfg.username, cfg.token),
|
|
47
74
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
75
|
+
...crumb,
|
|
48
76
|
},
|
|
49
|
-
body: new URLSearchParams({ json: payload }).toString(),
|
|
77
|
+
body: new URLSearchParams({ json: JSON.stringify(payload) }).toString(),
|
|
50
78
|
});
|
|
51
79
|
if (!res.ok && res.status !== 302) {
|
|
52
80
|
throw new Error(`Jenkins credential ${exists ? 'update' : 'create'} 실패 (${res.status})`);
|
|
53
81
|
}
|
|
54
82
|
return exists ? 'updated' : 'created';
|
|
55
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Secret File credential 생성/교체. Jenkins는 파일을 multipart 로 받고
|
|
86
|
+
* json 본문이 "file": "<필드명>" 으로 참조한다 (secretBytes JSON 직접 입력은 불가).
|
|
87
|
+
*/
|
|
56
88
|
export async function upsertSecretFile(cfg, id, fileBase64, fileName, description = '') {
|
|
57
|
-
const
|
|
58
|
-
|
|
89
|
+
const exists = await credentialExists(cfg, id);
|
|
90
|
+
const payload = {
|
|
59
91
|
credentials: {
|
|
60
92
|
scope: 'GLOBAL',
|
|
61
93
|
id,
|
|
62
94
|
description,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
'stapler-class':
|
|
66
|
-
'$class': 'org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl',
|
|
95
|
+
file: 'file0',
|
|
96
|
+
$class: FILE_CLASS,
|
|
97
|
+
'stapler-class': FILE_CLASS,
|
|
67
98
|
},
|
|
68
|
-
}
|
|
69
|
-
const
|
|
70
|
-
|
|
99
|
+
};
|
|
100
|
+
const form = new FormData();
|
|
101
|
+
form.append('json', JSON.stringify(payload));
|
|
102
|
+
const bytes = Buffer.from(fileBase64, 'base64');
|
|
103
|
+
form.append('file0', new Blob([bytes]), fileName);
|
|
71
104
|
const endpoint = exists
|
|
72
|
-
? `${
|
|
73
|
-
: `${
|
|
105
|
+
? `${credentialBase(cfg.url, id)}/updateSubmit`
|
|
106
|
+
: `${storeBase(cfg.url)}/createCredentials`;
|
|
107
|
+
const crumb = await getCrumb(cfg);
|
|
108
|
+
// Content-Type 은 fetch 가 multipart boundary 와 함께 자동 설정 — 수동 지정 금지
|
|
74
109
|
const res = await fetch(endpoint, {
|
|
75
110
|
method: 'POST',
|
|
76
111
|
headers: {
|
|
77
112
|
Authorization: basicAuth(cfg.username, cfg.token),
|
|
78
|
-
|
|
113
|
+
...crumb,
|
|
79
114
|
},
|
|
80
|
-
body:
|
|
115
|
+
body: form,
|
|
81
116
|
});
|
|
82
117
|
if (!res.ok && res.status !== 302) {
|
|
83
118
|
throw new Error(`Jenkins secret file credential ${exists ? 'update' : 'create'} 실패 (${res.status})`);
|
|
@@ -85,9 +120,13 @@ export async function upsertSecretFile(cfg, id, fileBase64, fileName, descriptio
|
|
|
85
120
|
return exists ? 'updated' : 'created';
|
|
86
121
|
}
|
|
87
122
|
export async function deleteCredential(cfg, id) {
|
|
88
|
-
const
|
|
123
|
+
const crumb = await getCrumb(cfg);
|
|
124
|
+
const res = await fetch(`${credentialBase(cfg.url, id)}/doDelete`, {
|
|
89
125
|
method: 'POST',
|
|
90
|
-
headers: {
|
|
126
|
+
headers: {
|
|
127
|
+
Authorization: basicAuth(cfg.username, cfg.token),
|
|
128
|
+
...crumb,
|
|
129
|
+
},
|
|
91
130
|
});
|
|
92
131
|
if (!res.ok && res.status !== 302) {
|
|
93
132
|
throw new Error(`Jenkins credential 삭제 실패 (${res.status})`);
|
|
@@ -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)) {
|
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) {
|
|
@@ -152,6 +152,10 @@ export function registerAndroidTools(server) {
|
|
|
152
152
|
text: [
|
|
153
153
|
'✅ Android upload keystore 생성 완료',
|
|
154
154
|
'',
|
|
155
|
+
'🔒 아래 비밀번호는 채팅 기록에 평문으로 남습니다.',
|
|
156
|
+
' Jenkins 등록을 마친 뒤에는 이 대화/세션을 삭제하는 것을 권장합니다.',
|
|
157
|
+
' keystore 파일과 비밀번호는 분실 시 앱 서명을 영구히 잃으니 별도 안전한 곳에도 백업하세요.',
|
|
158
|
+
'',
|
|
155
159
|
'── 생성된 값 (지금 바로 Jenkins에 등록하세요) ────',
|
|
156
160
|
`keyAlias: ${ks.keyAlias}`,
|
|
157
161
|
`storePassword: ${ks.storePassword}`,
|
|
@@ -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,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']),
|