@yoonion/mimi-seed-mcp 0.3.26 → 0.3.27

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,45 @@
1
+ /**
2
+ * App Store Connect API 에러 친절화.
3
+ *
4
+ * Apple은 표준 JSON 에러 응답을 내려준다:
5
+ * { "errors": [{ "code": "...", "title": "...", "detail": "...",
6
+ * "source": { "pointer": "/data/attributes/whatsNew" } }] }
7
+ *
8
+ * 실제 거부 사례에서 자주 만나는 코드별 hint 를 첨가해
9
+ * "App Store API 409: { errors: [...] }" 같은 raw dump 대신
10
+ * 무엇을 고쳐야 하는지 즉시 보이게 한다.
11
+ *
12
+ * 비표준 body (HTML, plain text, 빈 응답 등) 는 폴백으로 원본 보존.
13
+ */
14
+ /** Apple JSON Error response 구조 (단일 errors 배열). */
15
+ interface AppleError {
16
+ id?: string;
17
+ code?: string;
18
+ status?: string;
19
+ title?: string;
20
+ detail?: string;
21
+ source?: {
22
+ pointer?: string;
23
+ parameter?: string;
24
+ };
25
+ }
26
+ /** Apple 표준 에러 응답 파싱 시도. 실패하면 null 반환. */
27
+ declare function parseAppleErrorBody(body: string): AppleError[] | null;
28
+ /** 단일 Apple error → 한 줄 사람용 표기. */
29
+ declare function formatAppleError(e: AppleError): string;
30
+ /**
31
+ * App Store API 응답 (status + raw body) 을 친절한 Error 로 변환.
32
+ *
33
+ * - 표준 Apple JSON: `App Store API {status}: [CODE] detail @ pointer 💡 hint` 형식.
34
+ * 여러 errors 가 있으면 줄바꿈으로 나열.
35
+ * - 비표준 body: 기존 형식(`App Store API {status}: {body}`) 그대로 보존.
36
+ *
37
+ * `Error.cause` 에 원본 `{ status, body, parsedErrors }` 첨부 — 호출자가 코드별 분기 필요 시 사용.
38
+ */
39
+ export declare function friendlyAppStoreError(status: number, body: string): Error;
40
+ export declare const __testing: {
41
+ CODE_HINTS: Record<string, string>;
42
+ parseAppleErrorBody: typeof parseAppleErrorBody;
43
+ formatAppleError: typeof formatAppleError;
44
+ };
45
+ export {};
@@ -0,0 +1,88 @@
1
+ /**
2
+ * App Store Connect API 에러 친절화.
3
+ *
4
+ * Apple은 표준 JSON 에러 응답을 내려준다:
5
+ * { "errors": [{ "code": "...", "title": "...", "detail": "...",
6
+ * "source": { "pointer": "/data/attributes/whatsNew" } }] }
7
+ *
8
+ * 실제 거부 사례에서 자주 만나는 코드별 hint 를 첨가해
9
+ * "App Store API 409: { errors: [...] }" 같은 raw dump 대신
10
+ * 무엇을 고쳐야 하는지 즉시 보이게 한다.
11
+ *
12
+ * 비표준 body (HTML, plain text, 빈 응답 등) 는 폴백으로 원본 보존.
13
+ */
14
+ /** Apple 에러 코드 → 친절한 hint (실측 거부 사례 기반). */
15
+ const CODE_HINTS = {
16
+ // What's New / localization 텍스트 검증 실패
17
+ INVALID_CHARACTERS: 'HTML 태그·금지 문자가 포함됐어요. 제출 전 lib/text-validators.ts 로 사전 검증 권장.',
18
+ // 버전 상태가 편집 불가 (READY_FOR_SALE / WAITING_FOR_REVIEW 등)
19
+ ENTITY_STATE_INVALID: '버전 상태가 편집 가능 단계가 아니에요 (이미 심사중/출시됨). 새 versionString 으로 appstore_create_version 필요할 수 있어요.',
20
+ // 필수 필드 누락
21
+ ENTITY_ERROR_ATTRIBUTE_REQUIRED: '필수 필드가 빠졌어요 — source.pointer 위치 확인.',
22
+ // 길이 / 형식 위반
23
+ ENTITY_ERROR_ATTRIBUTE_INVALID: '필드 값이 정책 위반(길이/형식). source.pointer 위치 확인.',
24
+ // 빌드 attach 시 빌드를 못 찾음
25
+ NOT_FOUND: '대상 리소스를 찾을 수 없어요 — id/versionId/buildId 가 유효한지, 동일 앱 소속인지 확인.',
26
+ // 권한 부족 (API Key role 문제)
27
+ FORBIDDEN_ERROR: 'API Key 권한 부족 — App Store Connect > Users and Access 에서 키 role 확인.',
28
+ // submit 직후 cancel 시도 (큐 진입 후)
29
+ STATE_ERROR: '현재 상태에서 허용되지 않는 작업. cancel_review 라면 큐 진입 후라서 불가 — 새 versionString 으로 우회.',
30
+ };
31
+ /** Apple 표준 에러 응답 파싱 시도. 실패하면 null 반환. */
32
+ function parseAppleErrorBody(body) {
33
+ if (!body)
34
+ return null;
35
+ try {
36
+ const parsed = JSON.parse(body);
37
+ if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {
38
+ return parsed.errors;
39
+ }
40
+ return null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** 단일 Apple error → 한 줄 사람용 표기. */
47
+ function formatAppleError(e) {
48
+ const code = e.code ?? 'UNKNOWN';
49
+ const detail = e.detail ?? e.title ?? '(no detail)';
50
+ const pointer = e.source?.pointer ?? e.source?.parameter;
51
+ const hint = e.code ? CODE_HINTS[e.code] : undefined;
52
+ const parts = [`[${code}] ${detail}`];
53
+ if (pointer)
54
+ parts.push(`@ ${pointer}`);
55
+ if (hint)
56
+ parts.push(`\n 💡 ${hint}`);
57
+ return parts.join(' ');
58
+ }
59
+ /**
60
+ * App Store API 응답 (status + raw body) 을 친절한 Error 로 변환.
61
+ *
62
+ * - 표준 Apple JSON: `App Store API {status}: [CODE] detail @ pointer 💡 hint` 형식.
63
+ * 여러 errors 가 있으면 줄바꿈으로 나열.
64
+ * - 비표준 body: 기존 형식(`App Store API {status}: {body}`) 그대로 보존.
65
+ *
66
+ * `Error.cause` 에 원본 `{ status, body, parsedErrors }` 첨부 — 호출자가 코드별 분기 필요 시 사용.
67
+ */
68
+ export function friendlyAppStoreError(status, body) {
69
+ const errors = parseAppleErrorBody(body);
70
+ let message;
71
+ if (errors) {
72
+ const formatted = errors.map(formatAppleError).join('\n');
73
+ message = `App Store API ${status}:\n${formatted}`;
74
+ }
75
+ else {
76
+ message = `App Store API ${status}: ${body}`;
77
+ }
78
+ const err = new Error(message);
79
+ // 원본 보존 — 호출자 코드별 retry 분기용.
80
+ err.cause = { status, body, parsedErrors: errors ?? undefined };
81
+ return err;
82
+ }
83
+ // ── 테스트용 export ────────────────────────────────────────────
84
+ export const __testing = {
85
+ CODE_HINTS,
86
+ parseAppleErrorBody,
87
+ formatAppleError,
88
+ };
@@ -26,6 +26,29 @@ export declare function attachBuildToVersion(versionId: string, buildId: string)
26
26
  buildId: string;
27
27
  ok: boolean;
28
28
  }>;
29
+ /**
30
+ * 가장 최신 VALID 빌드를 자동으로 찾아 versionId 에 attach.
31
+ *
32
+ * 흐름:
33
+ * 1. versionId → appId 역추적 (`getVersionAppAndPlatform`).
34
+ * 2. `listBuilds(appId)` 로 최근 10개 빌드 조회 (sort: uploadedDate desc).
35
+ * 3. `processingState === 'VALID'` 필터.
36
+ * 4. `minBuildNumber` 옵션 있으면 buildNumber 숫자 기준 필터.
37
+ * 5. buildNumber 숫자 최대값으로 정렬 → 1개 선택.
38
+ * 6. attach.
39
+ *
40
+ * 사용처: 1.4.x 같은 매 배포마다 `appstore_list_builds` → 수동 탐색 → `appstore_attach_build` 의
41
+ * 3-step 을 한 번에 줄임. 실수로 PROCESSING 중인 빌드를 attach 해서 심사 제출 시점에 깨지는
42
+ * 케이스도 차단.
43
+ */
44
+ export declare function attachLatestValidBuild(versionId: string, opts?: {
45
+ minBuildNumber?: number;
46
+ }): Promise<{
47
+ versionId: string;
48
+ attachedBuildId: string;
49
+ buildNumber: string;
50
+ uploadedDate?: string;
51
+ }>;
29
52
  export declare function getVersionLocalizations(versionId: string): Promise<any>;
30
53
  export interface LocalizationUpdateFields {
31
54
  whatsNew?: string;
@@ -80,6 +103,31 @@ export interface ListCustomerReviewsOptions {
80
103
  }
81
104
  export declare function listCustomerReviews(appId: string, opts?: ListCustomerReviewsOptions): Promise<any>;
82
105
  export declare function createReviewResponse(reviewId: string, responseBody: string): Promise<any>;
106
+ /**
107
+ * submit_for_review dry-run 프리뷰 — 비가역 제출 직전 사용자 확인용.
108
+ *
109
+ * 1.4.x 배포 사고 누적: submit 직후 cancel_review 가 큐 진입으로 막혀 새 versionString
110
+ * bump 으로 우회해야 하는 케이스가 반복됨 (reference_appstore_cancel_review_window).
111
+ * 호출자가 의도한 그 버전·빌드인지 미리 보여줘서 잘못된 versionId 제출 차단.
112
+ */
113
+ export declare function buildSubmitForReviewPreview(versionId: string): Promise<{
114
+ versionId: string;
115
+ versionString?: string;
116
+ state?: string;
117
+ appId: string;
118
+ platform: string;
119
+ attachedBuild?: {
120
+ id: string;
121
+ buildNumber?: string;
122
+ uploadedDate?: string;
123
+ processingState?: string;
124
+ };
125
+ whatsNewByLocale: Array<{
126
+ locale: string;
127
+ excerpt: string;
128
+ length: number;
129
+ }>;
130
+ }>;
83
131
  export declare function submitVersionForReview(versionId: string): Promise<{
84
132
  submissionId: string;
85
133
  appId: string;
@@ -1,4 +1,5 @@
1
1
  import { getAuthHeaders } from './auth.js';
2
+ import { friendlyAppStoreError } from './errors.js';
2
3
  /**
3
4
  * App Store Connect API v1 래퍼
4
5
  * https://developer.apple.com/documentation/appstoreconnectapi
@@ -24,7 +25,7 @@ export async function apiGet(path, params) {
24
25
  const res = await fetch(url.toString(), { headers });
25
26
  if (!res.ok) {
26
27
  const body = await res.text();
27
- throw new Error(`App Store API ${res.status}: ${body}`);
28
+ throw friendlyAppStoreError(res.status, body);
28
29
  }
29
30
  return res.json();
30
31
  }
@@ -44,7 +45,7 @@ async function apiPatch(path, body) {
44
45
  });
45
46
  if (!res.ok) {
46
47
  const text = await res.text();
47
- throw new Error(`App Store API ${res.status}: ${text}`);
48
+ throw friendlyAppStoreError(res.status, text);
48
49
  }
49
50
  // 204 No Content 가능
50
51
  const text = await res.text();
@@ -66,7 +67,7 @@ async function apiPost(path, body) {
66
67
  });
67
68
  if (!res.ok) {
68
69
  const text = await res.text();
69
- throw new Error(`App Store API ${res.status}: ${text}`);
70
+ throw friendlyAppStoreError(res.status, text);
70
71
  }
71
72
  // 201 Created — 본문에 created entity. 일부 엔드포인트는 204
72
73
  const text = await res.text();
@@ -147,6 +148,51 @@ export async function attachBuildToVersion(versionId, buildId) {
147
148
  });
148
149
  return { versionId, buildId, ok: true };
149
150
  }
151
+ /**
152
+ * 가장 최신 VALID 빌드를 자동으로 찾아 versionId 에 attach.
153
+ *
154
+ * 흐름:
155
+ * 1. versionId → appId 역추적 (`getVersionAppAndPlatform`).
156
+ * 2. `listBuilds(appId)` 로 최근 10개 빌드 조회 (sort: uploadedDate desc).
157
+ * 3. `processingState === 'VALID'` 필터.
158
+ * 4. `minBuildNumber` 옵션 있으면 buildNumber 숫자 기준 필터.
159
+ * 5. buildNumber 숫자 최대값으로 정렬 → 1개 선택.
160
+ * 6. attach.
161
+ *
162
+ * 사용처: 1.4.x 같은 매 배포마다 `appstore_list_builds` → 수동 탐색 → `appstore_attach_build` 의
163
+ * 3-step 을 한 번에 줄임. 실수로 PROCESSING 중인 빌드를 attach 해서 심사 제출 시점에 깨지는
164
+ * 케이스도 차단.
165
+ */
166
+ export async function attachLatestValidBuild(versionId, opts) {
167
+ const { appId } = await getVersionAppAndPlatform(versionId);
168
+ const builds = (await listBuilds(appId));
169
+ const valid = builds.filter((b) => b.processingState === 'VALID');
170
+ if (valid.length === 0) {
171
+ throw new Error(`appId=${appId} 에 VALID 빌드가 없어요 (최근 ${builds.length}개 확인). 빌드 PROCESSING 완료 대기 필요.`);
172
+ }
173
+ let candidates = valid;
174
+ if (opts?.minBuildNumber !== undefined) {
175
+ const min = opts.minBuildNumber;
176
+ candidates = valid.filter((b) => Number(b.version) >= min);
177
+ if (candidates.length === 0) {
178
+ throw new Error(`minBuildNumber=${min} 이상 VALID 빌드가 없어요. VALID 빌드: ${valid.map((b) => b.version).join(', ')}`);
179
+ }
180
+ }
181
+ // buildNumber 가 숫자 문자열이라는 가정 (TestFlight 표준). NaN 은 -Infinity 처리해 뒤로.
182
+ candidates.sort((a, b) => {
183
+ const an = Number(a.version);
184
+ const bn = Number(b.version);
185
+ return (isNaN(bn) ? -Infinity : bn) - (isNaN(an) ? -Infinity : an);
186
+ });
187
+ const target = candidates[0];
188
+ await attachBuildToVersion(versionId, target.id);
189
+ return {
190
+ versionId,
191
+ attachedBuildId: target.id,
192
+ buildNumber: target.version,
193
+ uploadedDate: target.uploadedDate,
194
+ };
195
+ }
150
196
  // ─── 로컬라이제이션 (메타데이터) ───
151
197
  export async function getVersionLocalizations(versionId) {
152
198
  const data = await apiGet(`/appStoreVersions/${versionId}/appStoreVersionLocalizations`, {
@@ -426,6 +472,52 @@ async function isVersionAttached(submissionId, versionId) {
426
472
  const items = (data?.data ?? []);
427
473
  return items.some((it) => it?.relationships?.appStoreVersion?.data?.id === versionId);
428
474
  }
475
+ /**
476
+ * submit_for_review dry-run 프리뷰 — 비가역 제출 직전 사용자 확인용.
477
+ *
478
+ * 1.4.x 배포 사고 누적: submit 직후 cancel_review 가 큐 진입으로 막혀 새 versionString
479
+ * bump 으로 우회해야 하는 케이스가 반복됨 (reference_appstore_cancel_review_window).
480
+ * 호출자가 의도한 그 버전·빌드인지 미리 보여줘서 잘못된 versionId 제출 차단.
481
+ */
482
+ export async function buildSubmitForReviewPreview(versionId) {
483
+ const { appId, platform } = await getVersionAppAndPlatform(versionId);
484
+ // 버전 메타: versionString + state
485
+ const versionData = await apiGet(`/appStoreVersions/${versionId}`, {
486
+ 'fields[appStoreVersions]': 'versionString,appStoreState',
487
+ }).catch(() => null);
488
+ const versionString = versionData?.data?.attributes?.versionString;
489
+ const state = versionData?.data?.attributes?.appStoreState;
490
+ // attached build
491
+ let attachedBuild;
492
+ const build = await apiGet(`/appStoreVersions/${versionId}/build`, {
493
+ 'fields[builds]': 'version,uploadedDate,processingState',
494
+ }).catch(() => null);
495
+ if (build?.data?.id) {
496
+ attachedBuild = {
497
+ id: build.data.id,
498
+ buildNumber: build.data.attributes?.version,
499
+ uploadedDate: build.data.attributes?.uploadedDate,
500
+ processingState: build.data.attributes?.processingState,
501
+ };
502
+ }
503
+ const localizations = (await getVersionLocalizations(versionId).catch(() => []));
504
+ const whatsNewByLocale = localizations
505
+ .filter((l) => typeof l.whatsNew === 'string' && l.whatsNew.length > 0)
506
+ .map((l) => ({
507
+ locale: l.locale,
508
+ length: l.whatsNew.length,
509
+ excerpt: l.whatsNew.length > 200 ? `${l.whatsNew.slice(0, 200)}…` : l.whatsNew,
510
+ }));
511
+ return {
512
+ versionId,
513
+ versionString,
514
+ state,
515
+ appId,
516
+ platform,
517
+ attachedBuild,
518
+ whatsNewByLocale,
519
+ };
520
+ }
429
521
  export async function submitVersionForReview(versionId) {
430
522
  const { appId, platform } = await getVersionAppAndPlatform(versionId);
431
523
  // 1. CREATED 상태의 reviewSubmission이 있으면 재사용, 없으면 새로 생성
@@ -11,6 +11,13 @@ export declare function getStoredCredentials(): {
11
11
  } | null;
12
12
  export declare function saveCredentials(clientId: string, clientSecret: string): void;
13
13
  export declare function getStoredTokens(): StoredTokens | null;
14
+ /**
15
+ * tokens.json mtime — 마지막 refresh 시각의 근사값.
16
+ * (saveTokens 가 매번 writeFileSync 으로 갱신하므로 mtime ≈ 마지막 갱신/저장.)
17
+ * Google refresh_token 은 7일(미인증 앱) ~ 6개월(인증 앱) 미사용 시 revoke 됨.
18
+ * auth_status 응답 enrichment 에 사용.
19
+ */
20
+ export declare function getTokensLastRefreshMs(): number | null;
14
21
  export declare function createOAuth2Client(clientId: string, clientSecret: string): import("google-auth-library").OAuth2Client;
15
22
  /**
16
23
  * Get authenticated OAuth2 client.
@@ -59,4 +66,9 @@ export type RefreshStatus = {
59
66
  *
60
67
  * MCP 도구와 CLI 양쪽에서 공유.
61
68
  */
69
+ /**
70
+ * 사전 갱신 마진. 기존 60_000(1분)에서 300_000(5분)으로 상향 — Google OAuth access_token 의
71
+ * 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
72
+ * "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
73
+ */
62
74
  export declare function ensureFreshAccessToken(marginMs?: number): Promise<RefreshStatus>;
@@ -60,6 +60,27 @@ export function getStoredTokens() {
60
60
  return null;
61
61
  }
62
62
  }
63
+ /**
64
+ * tokens.json mtime — 마지막 refresh 시각의 근사값.
65
+ * (saveTokens 가 매번 writeFileSync 으로 갱신하므로 mtime ≈ 마지막 갱신/저장.)
66
+ * Google refresh_token 은 7일(미인증 앱) ~ 6개월(인증 앱) 미사용 시 revoke 됨.
67
+ * auth_status 응답 enrichment 에 사용.
68
+ */
69
+ export function getTokensLastRefreshMs() {
70
+ const pathToRead = fs.existsSync(TOKEN_PATH)
71
+ ? TOKEN_PATH
72
+ : fs.existsSync(LEGACY_TOKEN_PATH)
73
+ ? LEGACY_TOKEN_PATH
74
+ : null;
75
+ if (!pathToRead)
76
+ return null;
77
+ try {
78
+ return fs.statSync(pathToRead).mtimeMs;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
63
84
  function saveTokens(tokens) {
64
85
  ensureDir();
65
86
  fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2), { mode: 0o600 });
@@ -189,13 +210,13 @@ export function startAuth(clientId, clientSecret, options = {}) {
189
210
  };
190
211
  saveTokens(stored);
191
212
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
192
- res.end(`
193
- <html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
194
- <div style="text-align:center">
195
- <h1>✅ Mimi Seed 인증 완료!</h1>
196
- <p>이 창을 닫고 Claude Code로 돌아가세요.</p>
197
- </div>
198
- </body></html>
213
+ res.end(`
214
+ <html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
215
+ <div style="text-align:center">
216
+ <h1>✅ Mimi Seed 인증 완료!</h1>
217
+ <p>이 창을 닫고 Claude Code로 돌아가세요.</p>
218
+ </div>
219
+ </body></html>
199
220
  `);
200
221
  server.close();
201
222
  activeAuthServer = null;
@@ -253,7 +274,12 @@ export async function login(clientId, clientSecret) {
253
274
  *
254
275
  * MCP 도구와 CLI 양쪽에서 공유.
255
276
  */
256
- export async function ensureFreshAccessToken(marginMs = 60_000) {
277
+ /**
278
+ * 사전 갱신 마진. 기존 60_000(1분)에서 300_000(5분)으로 상향 — Google OAuth access_token 의
279
+ * 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
280
+ * "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
281
+ */
282
+ export async function ensureFreshAccessToken(marginMs = 300_000) {
257
283
  const tokens = getStoredTokens();
258
284
  if (!tokens) {
259
285
  return {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 릴리스 노트 / What's New 텍스트 사전 검증.
3
+ *
4
+ * 스토어 측 거부 패턴(HTML 마크업, 길이 초과, 백슬래시 가격 표기 등)을
5
+ * API 호출 전에 잡아 round-trip 낭비와 무지한 재시도를 차단.
6
+ *
7
+ * 모든 detection 은 정규식 기반 단순 휴리스틱 — false positive 최소화를
8
+ * 위해 실측으로 거부된 패턴만 포함한다.
9
+ */
10
+ export type ValidationCode = "HTML_TAG" | "LENGTH_EXCEEDED" | "BACKSLASH_PRICE";
11
+ export interface ValidationIssue {
12
+ code: ValidationCode;
13
+ message: string;
14
+ /** 0-based 문자 인덱스. LENGTH_EXCEEDED 에는 없음. */
15
+ position?: number;
16
+ /** 문제 구간 ±10자 잘라낸 문자열 (HTML/BACKSLASH 위치 표시용). */
17
+ excerpt?: string;
18
+ }
19
+ export interface ValidationResult {
20
+ ok: boolean;
21
+ issues: ValidationIssue[];
22
+ }
23
+ /** App Store What's New 검증 — 길이(≤4000) + HTML 태그 거부. */
24
+ export declare function validateAppStoreWhatsNew(text: string): ValidationResult;
25
+ /** Play release notes 검증 — 길이(≤500) + HTML + 백슬래시 가격. */
26
+ export declare function validatePlayReleaseNotes(text: string): ValidationResult;
27
+ /** 사용자 친화 메시지로 포맷. MCP 도구가 거부 응답에 그대로 노출. */
28
+ export declare function formatIssuesForUser(issues: ValidationIssue[]): string;
29
+ export declare const __testing: {
30
+ MAX_APPSTORE_WHATSNEW: number;
31
+ MAX_PLAY_RELEASE_NOTES: number;
32
+ HTML_TAG_PATTERN: RegExp;
33
+ BACKSLASH_PRICE_PATTERN: RegExp;
34
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 릴리스 노트 / What's New 텍스트 사전 검증.
3
+ *
4
+ * 스토어 측 거부 패턴(HTML 마크업, 길이 초과, 백슬래시 가격 표기 등)을
5
+ * API 호출 전에 잡아 round-trip 낭비와 무지한 재시도를 차단.
6
+ *
7
+ * 모든 detection 은 정규식 기반 단순 휴리스틱 — false positive 최소화를
8
+ * 위해 실측으로 거부된 패턴만 포함한다.
9
+ */
10
+ // ── 한계값 (스토어 정책 SSOT) ─────────────────────────────────────
11
+ const MAX_APPSTORE_WHATSNEW = 4000;
12
+ const MAX_PLAY_RELEASE_NOTES = 500;
13
+ // ── 패턴 ──────────────────────────────────────────────────────────
14
+ // HTML 태그: <br>, </tag>, <a href="…"> 모두 매칭. Apple/Play 둘 다 마크업 거부.
15
+ const HTML_TAG_PATTERN = /<\/?[a-zA-Z][^>]*>/g;
16
+ // Play Store 가 홍보 정책으로 거부한 실측 사례: 본문에 '\5000원' 같이
17
+ // 역슬래시 + 숫자 + (선택적 통화 단위) 패턴 (memory: reference_appstore_whatsnew_blacklist).
18
+ const BACKSLASH_PRICE_PATTERN = /\\\d[\d,]*(?:원|won|krw)?/gi;
19
+ // ── 헬퍼 ──────────────────────────────────────────────────────────
20
+ function makeExcerpt(text, pos, radius = 10) {
21
+ const start = Math.max(0, pos - radius);
22
+ const end = Math.min(text.length, pos + radius);
23
+ const left = start > 0 ? "…" : "";
24
+ const right = end < text.length ? "…" : "";
25
+ return `${left}${text.slice(start, end)}${right}`;
26
+ }
27
+ function lintHtmlTags(text) {
28
+ const issues = [];
29
+ HTML_TAG_PATTERN.lastIndex = 0;
30
+ let m;
31
+ while ((m = HTML_TAG_PATTERN.exec(text)) !== null) {
32
+ issues.push({
33
+ code: "HTML_TAG",
34
+ message: `HTML 태그 '${m[0]}' 가 포함됐어요 — App Store / Play 모두 마크업 거부`,
35
+ position: m.index,
36
+ excerpt: makeExcerpt(text, m.index),
37
+ });
38
+ // 무한루프 방어 (제로폭 매치는 사실상 발생 안 하지만 안전망)
39
+ if (m.index === HTML_TAG_PATTERN.lastIndex)
40
+ HTML_TAG_PATTERN.lastIndex++;
41
+ }
42
+ return issues;
43
+ }
44
+ function lintLength(text, limit, label) {
45
+ if (text.length <= limit)
46
+ return [];
47
+ return [
48
+ {
49
+ code: "LENGTH_EXCEEDED",
50
+ message: `${label} 길이 ${text.length}자 — ${limit}자 이하로 줄여주세요`,
51
+ },
52
+ ];
53
+ }
54
+ function lintBackslashPrice(text) {
55
+ const issues = [];
56
+ BACKSLASH_PRICE_PATTERN.lastIndex = 0;
57
+ let m;
58
+ while ((m = BACKSLASH_PRICE_PATTERN.exec(text)) !== null) {
59
+ issues.push({
60
+ code: "BACKSLASH_PRICE",
61
+ message: `'${m[0]}' 처럼 역슬래시 + 숫자(가격) 표기는 Play Store 가 홍보 정책으로 거부할 수 있어요`,
62
+ position: m.index,
63
+ excerpt: makeExcerpt(text, m.index),
64
+ });
65
+ if (m.index === BACKSLASH_PRICE_PATTERN.lastIndex)
66
+ BACKSLASH_PRICE_PATTERN.lastIndex++;
67
+ }
68
+ return issues;
69
+ }
70
+ // ── public API ────────────────────────────────────────────────────
71
+ /** App Store What's New 검증 — 길이(≤4000) + HTML 태그 거부. */
72
+ export function validateAppStoreWhatsNew(text) {
73
+ const issues = [
74
+ ...lintLength(text, MAX_APPSTORE_WHATSNEW, "App Store What's New"),
75
+ ...lintHtmlTags(text),
76
+ ];
77
+ return { ok: issues.length === 0, issues };
78
+ }
79
+ /** Play release notes 검증 — 길이(≤500) + HTML + 백슬래시 가격. */
80
+ export function validatePlayReleaseNotes(text) {
81
+ const issues = [
82
+ ...lintLength(text, MAX_PLAY_RELEASE_NOTES, "Play release notes"),
83
+ ...lintHtmlTags(text),
84
+ ...lintBackslashPrice(text),
85
+ ];
86
+ return { ok: issues.length === 0, issues };
87
+ }
88
+ /** 사용자 친화 메시지로 포맷. MCP 도구가 거부 응답에 그대로 노출. */
89
+ export function formatIssuesForUser(issues) {
90
+ if (issues.length === 0)
91
+ return "검증 통과";
92
+ return issues
93
+ .map((i) => {
94
+ const loc = i.position !== undefined ? ` (pos ${i.position})` : "";
95
+ const ex = i.excerpt ? ` — 근처: ${JSON.stringify(i.excerpt)}` : "";
96
+ return `[${i.code}] ${i.message}${loc}${ex}`;
97
+ })
98
+ .join("\n");
99
+ }
100
+ // 테스트용 export (한계값 단언)
101
+ export const __testing = {
102
+ MAX_APPSTORE_WHATSNEW,
103
+ MAX_PLAY_RELEASE_NOTES,
104
+ HTML_TAG_PATTERN,
105
+ BACKSLASH_PRICE_PATTERN,
106
+ };
@@ -4,6 +4,7 @@ import * as appstoreScreenshots from '../appstore/screenshots.js';
4
4
  import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
5
5
  import { requireAppStoreCreds } from '../helpers.js';
6
6
  import { buildAppStoreReleasePlan } from '../checks/plan.js';
7
+ import { validateAppStoreWhatsNew, formatIssuesForUser } from '../lib/text-validators.js';
7
8
  export function registerAppstoreTools(server) {
8
9
  server.tool('appstore_list_apps', 'App Store Connect 앱 목록 조회', {}, async () => {
9
10
  const apps = await appstore.listApps();
@@ -80,6 +81,25 @@ export function registerAppstoreTools(server) {
80
81
  ],
81
82
  };
82
83
  });
84
+ server.tool('appstore_attach_latest_build', [
85
+ 'App Store 버전에 최신 VALID 빌드를 자동으로 attach — list_builds → 필터 → attach 의 3-step 을 1회로 단축.',
86
+ '내부: versionId 로 appId 역추적 → listBuilds 에서 processingState=VALID 만 필터 → buildNumber 숫자 최대값 선택 → attach.',
87
+ 'PROCESSING 중인 빌드를 실수로 attach 해서 심사 제출 시 깨지는 케이스를 차단.',
88
+ 'minBuildNumber 옵션으로 floor 지정 가능 (예: 1.4.x 빌드만 attach).',
89
+ ].join(' '), {
90
+ versionId: z.string().describe('App Store 버전 ID (appstore_create_version 또는 list_versions 결과)'),
91
+ minBuildNumber: z.number().int().optional().describe('attach 후보 최소 buildNumber (예: 186 — 이전 빌드 무시)'),
92
+ }, async ({ versionId, minBuildNumber }) => {
93
+ const result = await appstore.attachLatestValidBuild(versionId, { minBuildNumber });
94
+ return {
95
+ content: [
96
+ {
97
+ type: 'text',
98
+ text: `✅ 최신 VALID 빌드 #${result.buildNumber} (id=${result.attachedBuildId}) 가 버전 ${versionId} 에 연결됐어.\n\n${JSON.stringify(result, null, 2)}`,
99
+ },
100
+ ],
101
+ };
102
+ });
83
103
  server.tool('appstore_get_metadata', 'App Store 버전 메타데이터 (설명문, 키워드, What\'s New)', { versionId: z.string().describe('버전 ID') }, async ({ versionId }) => {
84
104
  const localizations = await appstore.getVersionLocalizations(versionId);
85
105
  return { content: [{ type: 'text', text: JSON.stringify(localizations, null, 2) }] };
@@ -97,6 +117,19 @@ export function registerAppstoreTools(server) {
97
117
  if (Object.keys(cleaned).length === 0) {
98
118
  throw new Error('수정할 필드를 하나 이상 지정해줘 (whatsNew, description, keywords, promotionalText, supportUrl, marketingUrl).');
99
119
  }
120
+ // whatsNew 가 포함될 때만 사전 lint — 다른 필드(description/keywords)는 별도 정책.
121
+ if (typeof cleaned.whatsNew === 'string') {
122
+ const validation = validateAppStoreWhatsNew(cleaned.whatsNew);
123
+ if (!validation.ok) {
124
+ return {
125
+ content: [{
126
+ type: 'text',
127
+ text: `❌ whatsNew 사전 검증 실패 — API 호출 안 함\n\n${formatIssuesForUser(validation.issues)}\n\n수정 후 다시 호출해주세요.`,
128
+ }],
129
+ isError: true,
130
+ };
131
+ }
132
+ }
100
133
  const result = await appstore.updateVersionLocalization(localizationId, cleaned);
101
134
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
102
135
  });
@@ -139,6 +172,17 @@ export function registerAppstoreTools(server) {
139
172
  locale: z.string().describe('로캘 (예: ko, en-US, ja)'),
140
173
  whatsNew: z.string().describe("'이 버전의 새로운 기능' 텍스트 (4000자 이내)"),
141
174
  }, async ({ versionId, locale, whatsNew }) => {
175
+ // ── 사전 lint — Apple 409 INVALID_CHARACTERS 등 round-trip 낭비 차단.
176
+ const validation = validateAppStoreWhatsNew(whatsNew);
177
+ if (!validation.ok) {
178
+ return {
179
+ content: [{
180
+ type: 'text',
181
+ text: `❌ What's New 사전 검증 실패 — API 호출 안 함\n\n${formatIssuesForUser(validation.issues)}\n\n수정 후 다시 호출해주세요.`,
182
+ }],
183
+ isError: true,
184
+ };
185
+ }
142
186
  const result = await appstore.updateVersionWhatsNew(versionId, locale, { whatsNew });
143
187
  return { content: [{ type: 'text', text: `✅ ${locale} 로캘의 What's New가 업데이트됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
144
188
  });
@@ -431,11 +475,44 @@ export function registerAppstoreTools(server) {
431
475
  '내부 흐름: POST /reviewSubmissions(또는 CREATED 상태 재사용) → POST /reviewSubmissionItems(version attach) → PATCH submitted=true.',
432
476
  'appId와 platform은 versionId에서 자동 조회 — 별도 입력 불필요.',
433
477
  '⚠️ 비가역 작업: 제출 후엔 Apple 심사가 시작되며, 메타데이터/스크린샷/빌드를 더 못 바꿈 (REJECTED/METADATA_REJECTED 시 다시 편집 가능).',
478
+ '안전 가드: confirm 생략/false 시 dry-run preview 만 반환 (versionString·빌드·whatsNew 발췌). 실제 제출은 confirm: true 로 재호출.',
434
479
  '사전 조건: 버전이 PREPARE_FOR_SUBMISSION 또는 DEVELOPER_REJECTED 상태, 빌드 attached, 모든 필수 메타데이터 채워짐.',
435
480
  'appstore_check_submission_risks로 사전 점검 권장.',
436
481
  ].join(' '), {
437
482
  versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
438
- }, async ({ versionId }) => {
483
+ confirm: z.boolean().optional().describe('true 명시 시에만 실제 심사 제출. 생략/false 면 dry-run preview 만 반환 (비가역 사고 차단).'),
484
+ }, async ({ versionId, confirm }) => {
485
+ if (!confirm) {
486
+ // ── dry-run preview — versionString·빌드·whatsNew 발췌를 사용자에게 보여주고 재호출 유도.
487
+ const preview = await appstore.buildSubmitForReviewPreview(versionId);
488
+ const lines = [];
489
+ lines.push('🛑 심사 제출 dry-run — 아직 실제 제출 안 함.');
490
+ lines.push('');
491
+ lines.push(` versionId : ${preview.versionId}`);
492
+ lines.push(` versionString: ${preview.versionString ?? '(조회 실패)'}`);
493
+ lines.push(` state : ${preview.state ?? '(조회 실패)'}`);
494
+ lines.push(` appId : ${preview.appId}`);
495
+ lines.push(` platform : ${preview.platform}`);
496
+ if (preview.attachedBuild) {
497
+ lines.push(` attachedBuild: #${preview.attachedBuild.buildNumber ?? '?'} (id=${preview.attachedBuild.id}, state=${preview.attachedBuild.processingState ?? '?'})`);
498
+ }
499
+ else {
500
+ lines.push(` attachedBuild: ⚠️ 미연결 — appstore_attach_latest_build 필요`);
501
+ }
502
+ if (preview.whatsNewByLocale.length === 0) {
503
+ lines.push(` whatsNew : ⚠️ 등록된 로컬라이제이션 없음`);
504
+ }
505
+ else {
506
+ lines.push(` whatsNew :`);
507
+ for (const wn of preview.whatsNewByLocale) {
508
+ lines.push(` [${wn.locale}] (${wn.length}자) "${wn.excerpt}"`);
509
+ }
510
+ }
511
+ lines.push('');
512
+ lines.push('실제 제출하려면 같은 versionId 로 `confirm: true` 옵션을 추가해 재호출하세요.');
513
+ lines.push('⚠️ 제출 후엔 cancel_review 가 큐 진입(WAITING_FOR_REVIEW) 시점에 막힐 수 있어요 (실측: 1.4.2→3, 1.4.5→6).');
514
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
515
+ }
439
516
  const result = await appstore.submitVersionForReview(versionId);
440
517
  return { content: [{ type: 'text', text: `✅ 버전 ${versionId} 심사 제출 완료 (state: ${result.state}). App Store Connect에서 진행 상태 확인 가능.\n\n${JSON.stringify(result, null, 2)}` }] };
441
518
  });
@@ -1,5 +1,31 @@
1
1
  import { getMcpOAuthClient } from '../auth/constants.js';
2
- import { startAuth, ensureFreshAccessToken } from '../auth/google-auth.js';
2
+ import { startAuth, ensureFreshAccessToken, getTokensLastRefreshMs } from '../auth/google-auth.js';
3
+ /**
4
+ * tokens.json mtime → "Nd Hh ago" 형식 문자열 + 재인증 권고.
5
+ * Google 정책: 7일 미사용 시 미인증 앱 refresh_token revoke 가능.
6
+ * 14일 초과 시 강한 권고, 7일~14일 부드러운 안내.
7
+ */
8
+ function formatLastRefreshHint(lastMs) {
9
+ if (lastMs === null)
10
+ return { label: '(파일 mtime 조회 실패)' };
11
+ const ageMs = Date.now() - lastMs;
12
+ const days = Math.floor(ageMs / 86_400_000);
13
+ const hours = Math.floor((ageMs % 86_400_000) / 3_600_000);
14
+ const label = days > 0 ? `${days}d ${hours}h 전 갱신` : `${hours}h 전 갱신`;
15
+ if (days >= 14) {
16
+ return {
17
+ label,
18
+ recommendation: `⚠️ 14일 이상 미사용 — Google 정책상 refresh_token revoke 위험. 안전을 위해 'mimi_seed_auth_start' 로 재인증 권장.`,
19
+ };
20
+ }
21
+ if (days >= 7) {
22
+ return {
23
+ label,
24
+ recommendation: `ℹ️ 7일 이상 미사용 — 한 번 더 갱신하지 않으면 곧 revoke 가능. 다음 도구 호출이 자동 갱신해주지만, 장기 미사용 예정이면 미리 인증 갱신 권장.`,
25
+ };
26
+ }
27
+ return { label };
28
+ }
3
29
  export function registerAuthTools(server) {
4
30
  server.tool('mimi_seed_auth_start', [
5
31
  'Google OAuth 로그인 링크를 발급하고 백그라운드 콜백 서버를 시작.',
@@ -28,6 +54,9 @@ export function registerAuthTools(server) {
28
54
  });
29
55
  server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인 (만료 시 refresh_token으로 자동 갱신 시도)', {}, async () => {
30
56
  const r = await ensureFreshAccessToken();
57
+ const refreshHint = formatLastRefreshHint(getTokensLastRefreshMs());
58
+ const refreshLine = ` 마지막 갱신: ${refreshHint.label}`;
59
+ const recommendation = refreshHint.recommendation ? `\n\n${refreshHint.recommendation}` : '';
31
60
  switch (r.status) {
32
61
  case 'unauthenticated':
33
62
  return {
@@ -40,14 +69,19 @@ export function registerAuthTools(server) {
40
69
  };
41
70
  case 'fresh': {
42
71
  const min = Math.round(r.msUntilExpiry / 60000);
43
- return { content: [{ type: 'text', text: `✅ 인증 유효 (${min}분 남음).` }] };
72
+ return {
73
+ content: [{
74
+ type: 'text',
75
+ text: `✅ 인증 유효 (${min}분 남음).\n${refreshLine}${recommendation}`,
76
+ }],
77
+ };
44
78
  }
45
79
  case 'refreshed': {
46
80
  const min = Math.round(r.msUntilExpiry / 60000);
47
81
  return {
48
82
  content: [{
49
83
  type: 'text',
50
- text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).`,
84
+ text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).\n${refreshLine}${recommendation}`,
51
85
  }],
52
86
  };
53
87
  }
@@ -59,6 +93,7 @@ export function registerAuthTools(server) {
59
93
  ` 코드: ${r.error.code}\n` +
60
94
  ` ${r.error.message}\n` +
61
95
  (r.error.hint ? ` → ${r.error.hint}\n` : '') +
96
+ `${refreshLine}\n` +
62
97
  '\n터미널에서 재로그인:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
63
98
  }],
64
99
  };
@@ -1,7 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { checkPlayStoreRisks, checkAppStoreRisks, formatRisks } from '../checks/risks.js';
3
3
  import { validateAppStoreScreenshots, validatePlayStoreScreenshots, formatValidationResults } from '../checks/screenshots.js';
4
- import { requireAuth } from '../helpers.js';
4
+ import { requireAuth, requirePlayStoreAuth } from '../helpers.js';
5
+ import * as appstore from '../appstore/tools.js';
6
+ import * as playstore from '../playstore/tools.js';
5
7
  export function registerChecksTools(server) {
6
8
  server.tool('playstore_check_submission_risks', [
7
9
  'Google Play 제출 전 위험 요소를 자동으로 점검합니다.',
@@ -53,4 +55,90 @@ export function registerChecksTools(server) {
53
55
  return { content: [{ type: 'text', text }] };
54
56
  }
55
57
  });
58
+ server.tool('release_status', [
59
+ '양 스토어의 동일 버전 상태를 한 번에 조회 — 1.4.x 배포 시 "Play 는 production?", "ASC 는 심사중?" 을',
60
+ 'list_versions + list_tracks 2회 호출 + grep 으로 합치던 멘탈 부담을 단일 응답으로 줄임.',
61
+ 'App Store: appId 가 있을 때 listVersions 에서 version 일치 항목의 state/releaseType/attached build/createdDate.',
62
+ 'Play Store: packageName 이 있을 때 listTracks 에서 동일 versionName 또는 versionCode 가 있는 모든 트랙의 status/versionCodes.',
63
+ 'appId 또는 packageName 둘 다 비어 있으면 에러. 둘 중 하나만 줘도 그 스토어 상태만 조회 가능.',
64
+ ].join(' '), {
65
+ version: z.string().describe('조회할 버전명 (예: "1.4.9"). App Store versionString + Play release.name 매칭에 사용.'),
66
+ appId: z.string().optional().describe('App Store appId (appstore_list_apps 결과). 없으면 App Store 영역 skip.'),
67
+ packageName: z.string().optional().describe('Play 패키지명 (예: gg.pryzm.coffee). 없으면 Play 영역 skip.'),
68
+ }, async ({ version, appId, packageName }) => {
69
+ if (!appId && !packageName) {
70
+ throw new Error('appId 또는 packageName 중 최소 하나는 지정해야 해요.');
71
+ }
72
+ let appStoreSection = null;
73
+ if (appId) {
74
+ try {
75
+ const versions = (await appstore.listVersions(appId));
76
+ const match = versions.find((v) => v.version === version);
77
+ if (match) {
78
+ // attached build 조회 — listBuilds 까지 가지 않고 단일 versionId 의 build 만.
79
+ // appstore.tools.ts 의 attachBuildToVersion 인접 헬퍼는 외부 노출 X — 여기서는
80
+ // listBuilds(appId) 의 최근 10개에서 매칭 추정. 일치하는 buildNumber 가 없어도 무방.
81
+ const builds = (await appstore.listBuilds(appId).catch(() => []));
82
+ appStoreSection = {
83
+ versionId: match.id,
84
+ version: match.version,
85
+ state: match.state,
86
+ releaseType: match.releaseType,
87
+ createdDate: match.createdDate,
88
+ recentBuilds: builds.slice(0, 3).map((b) => ({
89
+ id: b.id,
90
+ buildNumber: b.version,
91
+ processingState: b.processingState,
92
+ })),
93
+ };
94
+ }
95
+ else {
96
+ appStoreSection = {
97
+ found: false,
98
+ available: versions.slice(0, 10).map((v) => ({ version: v.version, state: v.state })),
99
+ };
100
+ }
101
+ }
102
+ catch (e) {
103
+ appStoreSection = { error: e instanceof Error ? e.message : String(e) };
104
+ }
105
+ }
106
+ // ── Play Store ────────────────────────────────────────
107
+ let playStoreSection = null;
108
+ if (packageName) {
109
+ try {
110
+ const auth = requirePlayStoreAuth(packageName);
111
+ const tracks = await playstore.listTracks(auth, packageName);
112
+ // 트랙별로 version 이 매칭되는 release 찾기.
113
+ // match 기준: release.name === version (보통 "1.4.9" 또는 "1.4.9 (373)")
114
+ // 완전 일치가 없으면 prefix 매칭으로 폴백 (releaseName 표기 변동성 흡수).
115
+ const byTrack = {};
116
+ for (const t of tracks) {
117
+ const exact = t.releases.find((r) => r.name === version);
118
+ const prefix = exact ?? t.releases.find((r) => typeof r.name === 'string' && r.name?.startsWith(`${version} `));
119
+ if (prefix) {
120
+ byTrack[t.track ?? 'unknown'] = {
121
+ releaseName: prefix.name,
122
+ status: prefix.status,
123
+ versionCodes: prefix.versionCodes,
124
+ };
125
+ }
126
+ }
127
+ playStoreSection = Object.keys(byTrack).length > 0
128
+ ? byTrack
129
+ : {
130
+ found: false,
131
+ available: tracks.map((t) => ({
132
+ track: t.track,
133
+ releases: t.releases.map((r) => ({ name: r.name, status: r.status })),
134
+ })),
135
+ };
136
+ }
137
+ catch (e) {
138
+ playStoreSection = { error: e instanceof Error ? e.message : String(e) };
139
+ }
140
+ }
141
+ const result = { version, appStore: appStoreSection, playStore: playStoreSection };
142
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
143
+ });
56
144
  }
@@ -4,6 +4,7 @@ import { saveServiceAccountJsonForPackage, listRegisteredServiceAccounts, delete
4
4
  import { createGoogleOneTimePurchase, createGoogleSubscription, updateGoogleProduct, deleteGoogleProduct, listGoogleProducts, } from '@onesub/providers';
5
5
  import { requirePlayStoreAuth, requireServiceAccountJson } from '../helpers.js';
6
6
  import { buildPlayStoreReleasePlan } from '../checks/plan.js';
7
+ import { validatePlayReleaseNotes, formatIssuesForUser } from '../lib/text-validators.js';
7
8
  export function registerPlaystoreTools(server) {
8
9
  server.tool('playstore_get_app', 'Google Play 앱 상세 정보 조회', { packageName: z.string().describe('패키지명 (예: com.findthem.app)') }, async ({ packageName }) => {
9
10
  const auth = requirePlayStoreAuth(packageName);
@@ -81,19 +82,73 @@ export function registerPlaystoreTools(server) {
81
82
  language: z.string().describe('언어 코드 (예: ko-KR, en-US)'),
82
83
  text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
83
84
  }, async ({ packageName, track, versionCode, language, text }) => {
85
+ // ── 사전 lint — 500자 / HTML / 역슬래시 가격(\5000원) round-trip 차단.
86
+ const validation = validatePlayReleaseNotes(text);
87
+ if (!validation.ok) {
88
+ return {
89
+ content: [{
90
+ type: 'text',
91
+ text: `❌ 릴리스 노트 사전 검증 실패 — API 호출 안 함\n\n${formatIssuesForUser(validation.issues)}\n\n수정 후 다시 호출해주세요.`,
92
+ }],
93
+ isError: true,
94
+ };
95
+ }
84
96
  const auth = requirePlayStoreAuth(packageName);
85
97
  const result = await playstore.updateReleaseNotes(auth, packageName, track, versionCode, language, text);
86
98
  return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode} ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
87
99
  });
88
- server.tool('playstore_update_latest_release_notes', "Google Play 트랙의 최신 릴리스(versionCode 최대) '최근 변경사항' 업데이트 — versionCode를 모를 때 편의용", {
100
+ server.tool('playstore_update_latest_release_notes', [
101
+ "Google Play 트랙의 최신 릴리스(versionCode 최대) '최근 변경사항' 업데이트 — versionCode를 모를 때 편의용.",
102
+ '⚠️ 지정한 단일 트랙에만 적용 — 다른 트랙에는 자동 복사되지 않음 (Google Play 정책: promote_release 시점에 노트 캐리됨).',
103
+ '동일 노트를 여러 트랙에 즉시 반영하려면 syncTracks 옵션 사용 — 지정 트랙들에 대해 순차로 같은 노트 적용.',
104
+ ].join(' '), {
89
105
  packageName: z.string().describe('패키지명'),
90
- track: z.enum(['production', 'beta', 'alpha', 'internal']).describe('릴리스 트랙'),
106
+ track: z.enum(['production', 'beta', 'alpha', 'internal']).describe('1차 적용 트랙'),
91
107
  language: z.string().describe('언어 코드 (예: ko-KR)'),
92
108
  text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
93
- }, async ({ packageName, track, language, text }) => {
109
+ syncTracks: z.array(z.enum(['production', 'beta', 'alpha', 'internal']))
110
+ .optional()
111
+ .describe('추가로 동일 노트 적용할 트랙 배열 (예: ["production"]). 지정 시 1차 track 반영 후 순차 동기화.'),
112
+ }, async ({ packageName, track, language, text, syncTracks }) => {
113
+ const validation = validatePlayReleaseNotes(text);
114
+ if (!validation.ok) {
115
+ return {
116
+ content: [{
117
+ type: 'text',
118
+ text: `❌ 릴리스 노트 사전 검증 실패 — API 호출 안 함\n\n${formatIssuesForUser(validation.issues)}\n\n수정 후 다시 호출해주세요.`,
119
+ }],
120
+ isError: true,
121
+ };
122
+ }
94
123
  const auth = requirePlayStoreAuth(packageName);
95
- const result = await playstore.updateLatestReleaseNotes(auth, packageName, track, language, text);
96
- return { content: [{ type: 'text', text: `✅ ${packageName} ${track} (versionCodes=${JSON.stringify(result.updatedVersionCodes)}) ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
124
+ // 1차 적용 + 결과 누적.
125
+ const primaryResult = await playstore.updateLatestReleaseNotes(auth, packageName, track, language, text);
126
+ const lines = [
127
+ `✅ ${packageName} ${track} (versionCodes=${JSON.stringify(primaryResult.updatedVersionCodes)}) ${language} 노트 반영`,
128
+ ];
129
+ const allResults = { [track]: primaryResult };
130
+ // 추가 트랙 — 1차와 중복은 skip. 한 트랙 실패해도 나머지 트랙은 계속 시도.
131
+ if (syncTracks && syncTracks.length > 0) {
132
+ const targets = syncTracks.filter((t) => t !== track);
133
+ for (const t of targets) {
134
+ try {
135
+ const r = await playstore.updateLatestReleaseNotes(auth, packageName, t, language, text);
136
+ allResults[t] = r;
137
+ lines.push(` ↳ sync ${t} (versionCodes=${JSON.stringify(r.updatedVersionCodes)}) 반영`);
138
+ }
139
+ catch (e) {
140
+ const msg = e instanceof Error ? e.message : String(e);
141
+ allResults[t] = { error: msg };
142
+ lines.push(` ↳ sync ${t} 실패: ${msg}`);
143
+ }
144
+ }
145
+ }
146
+ return {
147
+ content: [{
148
+ type: 'text',
149
+ text: `${lines.join('\n')}\n\n${JSON.stringify(allResults, null, 2)}`,
150
+ }],
151
+ };
97
152
  });
98
153
  server.tool('playstore_list_reviews', 'Google Play 리뷰 목록 조회', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
99
154
  const auth = requirePlayStoreAuth(packageName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.26",
3
+ "version": "0.3.27",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {