@yoonion/mimi-seed-mcp 0.13.3 → 0.13.5
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/appstore/http.d.ts +5 -0
- package/dist/appstore/http.js +28 -0
- package/dist/appstore/product-localization.d.ts +35 -0
- package/dist/appstore/product-localization.js +105 -0
- package/dist/appstore/product-review.d.ts +2 -1
- package/dist/appstore/product-review.js +1 -28
- package/dist/appstore/tools.d.ts +15 -0
- package/dist/appstore/tools.js +58 -0
- package/dist/playstore/tools.d.ts +35 -0
- package/dist/playstore/tools.js +105 -0
- package/dist/registers/appstore.js +99 -1
- package/dist/registers/playstore.js +73 -1
- package/package.json +2 -2
- package/tool-manifest.json +7 -1
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const V1_BASE = "https://api.appstoreconnect.apple.com/v1";
|
|
2
|
+
export declare const V2_BASE = "https://api.appstoreconnect.apple.com/v2";
|
|
3
|
+
export type AppStoreProductType = 'subscription' | 'consumable' | 'non_consumable';
|
|
4
|
+
export declare function authHeadersOrThrow(): Promise<Record<string, string>>;
|
|
5
|
+
export declare function apiRequest<T>(base: string, resourcePath: string, authHeaders: Record<string, string>, init: RequestInit): Promise<T>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getAuthHeaders } from './auth.js';
|
|
2
|
+
import { friendlyAppStoreError } from './errors.js';
|
|
3
|
+
export const V1_BASE = 'https://api.appstoreconnect.apple.com/v1';
|
|
4
|
+
export const V2_BASE = 'https://api.appstoreconnect.apple.com/v2';
|
|
5
|
+
export async function authHeadersOrThrow() {
|
|
6
|
+
const headers = await getAuthHeaders();
|
|
7
|
+
if (!headers) {
|
|
8
|
+
throw new Error([
|
|
9
|
+
'❌ App Store Connect 인증이 필요해.',
|
|
10
|
+
'',
|
|
11
|
+
'터미널에서 실행:',
|
|
12
|
+
' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
|
|
13
|
+
].join('\n'));
|
|
14
|
+
}
|
|
15
|
+
return headers;
|
|
16
|
+
}
|
|
17
|
+
export async function apiRequest(base, resourcePath, authHeaders, init) {
|
|
18
|
+
const response = await fetch(`${base}${resourcePath}`, {
|
|
19
|
+
...init,
|
|
20
|
+
headers: { ...authHeaders, ...(init.headers ?? {}) },
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
const body = await response.text();
|
|
24
|
+
throw friendlyAppStoreError(response.status, body);
|
|
25
|
+
}
|
|
26
|
+
const text = await response.text();
|
|
27
|
+
return (text ? JSON.parse(text) : { ok: true });
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AppStoreProductType } from './http.js';
|
|
2
|
+
export interface ProductLocalization {
|
|
3
|
+
id: string;
|
|
4
|
+
locale: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
state?: string;
|
|
8
|
+
}
|
|
9
|
+
/** 상품에 등록된 현지화 목록. */
|
|
10
|
+
export declare function listProductLocalizations(args: {
|
|
11
|
+
internalId: string;
|
|
12
|
+
productType: AppStoreProductType;
|
|
13
|
+
}): Promise<ProductLocalization[]>;
|
|
14
|
+
/**
|
|
15
|
+
* 로케일 하나를 upsert 한다 — 있으면 PATCH, 없으면 POST.
|
|
16
|
+
*
|
|
17
|
+
* 호출자가 "이미 있나?"를 먼저 묻지 않아도 되게 한 건 의도적이다. 같은 값을 두 번
|
|
18
|
+
* 넣어도 409 로 깨지지 않아야 재시도가 안전하다.
|
|
19
|
+
*/
|
|
20
|
+
export declare function upsertProductLocalization(args: {
|
|
21
|
+
internalId: string;
|
|
22
|
+
productType: AppStoreProductType;
|
|
23
|
+
locale: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
}): Promise<ProductLocalization & {
|
|
27
|
+
created: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
/** 로케일 하나를 삭제한다. */
|
|
30
|
+
export declare function deleteProductLocalization(args: {
|
|
31
|
+
localizationId: string;
|
|
32
|
+
productType: AppStoreProductType;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
localizationId: string;
|
|
35
|
+
}>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { V1_BASE, V2_BASE, apiRequest, authHeadersOrThrow } from './http.js';
|
|
2
|
+
function localizationResource(productType) {
|
|
3
|
+
if (productType === 'subscription') {
|
|
4
|
+
return {
|
|
5
|
+
type: 'subscriptionLocalizations',
|
|
6
|
+
path: '/subscriptionLocalizations',
|
|
7
|
+
relationship: 'subscription',
|
|
8
|
+
relatedType: 'subscriptions',
|
|
9
|
+
listBase: V1_BASE,
|
|
10
|
+
listPath: (internalId) => `/subscriptions/${internalId}/subscriptionLocalizations`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
type: 'inAppPurchaseLocalizations',
|
|
15
|
+
path: '/inAppPurchaseLocalizations',
|
|
16
|
+
relationship: 'inAppPurchaseV2',
|
|
17
|
+
relatedType: 'inAppPurchases',
|
|
18
|
+
// 일회성 IAP 는 v2 리소스라 목록만 v2 에서 읽는다. 쓰기는 v1 이다.
|
|
19
|
+
listBase: V2_BASE,
|
|
20
|
+
listPath: (internalId) => `/inAppPurchases/${internalId}/inAppPurchaseLocalizations`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function toLocalization(item) {
|
|
24
|
+
return {
|
|
25
|
+
id: item.id,
|
|
26
|
+
locale: item.attributes?.locale ?? '',
|
|
27
|
+
name: item.attributes?.name,
|
|
28
|
+
description: item.attributes?.description,
|
|
29
|
+
state: item.attributes?.state,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** 상품에 등록된 현지화 목록. */
|
|
33
|
+
export async function listProductLocalizations(args) {
|
|
34
|
+
const { internalId, productType } = args;
|
|
35
|
+
const resource = localizationResource(productType);
|
|
36
|
+
const authHeaders = await authHeadersOrThrow();
|
|
37
|
+
const result = await apiRequest(resource.listBase, `${resource.listPath(internalId)}?limit=200`, authHeaders, { method: 'GET' });
|
|
38
|
+
return (result.data ?? []).map(toLocalization);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 로케일 하나를 upsert 한다 — 있으면 PATCH, 없으면 POST.
|
|
42
|
+
*
|
|
43
|
+
* 호출자가 "이미 있나?"를 먼저 묻지 않아도 되게 한 건 의도적이다. 같은 값을 두 번
|
|
44
|
+
* 넣어도 409 로 깨지지 않아야 재시도가 안전하다.
|
|
45
|
+
*/
|
|
46
|
+
export async function upsertProductLocalization(args) {
|
|
47
|
+
const { internalId, productType, locale, name, description } = args;
|
|
48
|
+
if (!name && description === undefined) {
|
|
49
|
+
throw new Error('name 또는 description 중 하나는 있어야 해.');
|
|
50
|
+
}
|
|
51
|
+
const resource = localizationResource(productType);
|
|
52
|
+
const authHeaders = await authHeadersOrThrow();
|
|
53
|
+
const existing = (await listProductLocalizations({ internalId, productType }))
|
|
54
|
+
.find((item) => item.locale.toLowerCase() === locale.toLowerCase());
|
|
55
|
+
if (existing) {
|
|
56
|
+
// 넘긴 필드만 보낸다 — description 을 생략한 호출이 기존 설명을 지우면 안 된다.
|
|
57
|
+
const attributes = {};
|
|
58
|
+
if (name !== undefined)
|
|
59
|
+
attributes.name = name;
|
|
60
|
+
if (description !== undefined)
|
|
61
|
+
attributes.description = description;
|
|
62
|
+
const updated = await apiRequest(V1_BASE, `${resource.path}/${existing.id}`, authHeaders, {
|
|
63
|
+
method: 'PATCH',
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ data: { type: resource.type, id: existing.id, attributes } }),
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
...toLocalization(updated.data ?? { id: existing.id }),
|
|
69
|
+
locale: updated.data?.attributes?.locale ?? existing.locale,
|
|
70
|
+
created: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (!name) {
|
|
74
|
+
throw new Error(`새 로케일(${locale})을 만들려면 name 이 필요해.`);
|
|
75
|
+
}
|
|
76
|
+
const attributes = { locale, name };
|
|
77
|
+
if (description !== undefined)
|
|
78
|
+
attributes.description = description;
|
|
79
|
+
const created = await apiRequest(V1_BASE, resource.path, authHeaders, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
data: {
|
|
84
|
+
type: resource.type,
|
|
85
|
+
attributes,
|
|
86
|
+
relationships: {
|
|
87
|
+
[resource.relationship]: { data: { type: resource.relatedType, id: internalId } },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
...toLocalization(created.data ?? { id: '' }),
|
|
94
|
+
locale: created.data?.attributes?.locale ?? locale,
|
|
95
|
+
created: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** 로케일 하나를 삭제한다. */
|
|
99
|
+
export async function deleteProductLocalization(args) {
|
|
100
|
+
const { localizationId, productType } = args;
|
|
101
|
+
const resource = localizationResource(productType);
|
|
102
|
+
const authHeaders = await authHeadersOrThrow();
|
|
103
|
+
await apiRequest(V1_BASE, `${resource.path}/${localizationId}`, authHeaders, { method: 'DELETE' });
|
|
104
|
+
return { localizationId };
|
|
105
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export type AppStoreProductType
|
|
1
|
+
export type { AppStoreProductType } from './http.js';
|
|
2
|
+
import type { AppStoreProductType } from './http.js';
|
|
2
3
|
/** 기존 IAP/구독 상품의 App Review 노트를 수정한다. */
|
|
3
4
|
export declare function updateProductReviewNote(args: {
|
|
4
5
|
internalId: string;
|
|
@@ -1,34 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import { friendlyAppStoreError } from './errors.js';
|
|
6
|
-
const V1_BASE = 'https://api.appstoreconnect.apple.com/v1';
|
|
7
|
-
const V2_BASE = 'https://api.appstoreconnect.apple.com/v2';
|
|
8
|
-
async function authHeadersOrThrow() {
|
|
9
|
-
const headers = await getAuthHeaders();
|
|
10
|
-
if (!headers) {
|
|
11
|
-
throw new Error([
|
|
12
|
-
'❌ App Store Connect 인증이 필요해.',
|
|
13
|
-
'',
|
|
14
|
-
'터미널에서 실행:',
|
|
15
|
-
' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
|
|
16
|
-
].join('\n'));
|
|
17
|
-
}
|
|
18
|
-
return headers;
|
|
19
|
-
}
|
|
20
|
-
async function apiRequest(base, resourcePath, authHeaders, init) {
|
|
21
|
-
const response = await fetch(`${base}${resourcePath}`, {
|
|
22
|
-
...init,
|
|
23
|
-
headers: { ...authHeaders, ...(init.headers ?? {}) },
|
|
24
|
-
});
|
|
25
|
-
if (!response.ok) {
|
|
26
|
-
const body = await response.text();
|
|
27
|
-
throw friendlyAppStoreError(response.status, body);
|
|
28
|
-
}
|
|
29
|
-
const text = await response.text();
|
|
30
|
-
return (text ? JSON.parse(text) : { ok: true });
|
|
31
|
-
}
|
|
4
|
+
import { V1_BASE, V2_BASE, apiRequest, authHeadersOrThrow } from './http.js';
|
|
32
5
|
function productResource(productType) {
|
|
33
6
|
if (productType === 'subscription') {
|
|
34
7
|
return { base: V1_BASE, type: 'subscriptions', path: '/subscriptions' };
|
package/dist/appstore/tools.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AppStoreProductType } from './http.js';
|
|
1
2
|
export declare function apiGet(path: string, params?: Record<string, string>): Promise<any>;
|
|
2
3
|
export interface AppStoreVerifyResult {
|
|
3
4
|
ok: boolean;
|
|
@@ -167,6 +168,20 @@ export declare function submitVersionForReview(versionId: string): Promise<{
|
|
|
167
168
|
itemAttached: boolean;
|
|
168
169
|
state: any;
|
|
169
170
|
}>;
|
|
171
|
+
export declare function addProductToReviewSubmission(args: {
|
|
172
|
+
appId: string;
|
|
173
|
+
internalId: string;
|
|
174
|
+
productType: AppStoreProductType;
|
|
175
|
+
platform?: string;
|
|
176
|
+
}): Promise<{
|
|
177
|
+
submissionId: string;
|
|
178
|
+
appId: string;
|
|
179
|
+
platform: string;
|
|
180
|
+
internalId: string;
|
|
181
|
+
productType: AppStoreProductType;
|
|
182
|
+
reusedSubmission: boolean;
|
|
183
|
+
itemAttached: boolean;
|
|
184
|
+
}>;
|
|
170
185
|
export declare function cancelVersionReview(versionId: string): Promise<{
|
|
171
186
|
submissionId: string;
|
|
172
187
|
previousState: string;
|
package/dist/appstore/tools.js
CHANGED
|
@@ -650,6 +650,64 @@ export async function submitVersionForReview(versionId) {
|
|
|
650
650
|
state: submitted?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW',
|
|
651
651
|
};
|
|
652
652
|
}
|
|
653
|
+
// ─── IAP/구독을 심사 제출 묶음에 추가 ───
|
|
654
|
+
//
|
|
655
|
+
// App Store Connect 웹의 "심사에 추가" 버튼과 같은 동작이다. 제출하지는 않는다 —
|
|
656
|
+
// 항목만 담고, 실제 제출은 submitVersionForReview 가 한다.
|
|
657
|
+
//
|
|
658
|
+
// 이게 왜 별도로 필요한가: 어떤 앱의 **첫 소모성 IAP** 는 앱 버전과 같은 묶음으로만
|
|
659
|
+
// 심사에 넣을 수 있다. IAP 만 담긴 초안은 "심사에 제출할 수 없음" 으로 막힌다.
|
|
660
|
+
// 그래서 순서가 중요하다 — IAP 를 전부 담은 뒤 버전을 제출해야 한 번에 나간다.
|
|
661
|
+
function productReviewItemRelationship(productType) {
|
|
662
|
+
if (productType === 'subscription') {
|
|
663
|
+
return { key: 'subscription', type: 'subscriptions' };
|
|
664
|
+
}
|
|
665
|
+
return { key: 'inAppPurchaseV2', type: 'inAppPurchases' };
|
|
666
|
+
}
|
|
667
|
+
export async function addProductToReviewSubmission(args) {
|
|
668
|
+
const { appId, internalId, productType } = args;
|
|
669
|
+
const platform = args.platform ?? 'IOS';
|
|
670
|
+
const relationship = productReviewItemRelationship(productType);
|
|
671
|
+
let submissionId = await findOpenReviewSubmission(appId, platform);
|
|
672
|
+
const reusedSubmission = Boolean(submissionId);
|
|
673
|
+
if (!submissionId) {
|
|
674
|
+
const created = await apiPost('/reviewSubmissions', {
|
|
675
|
+
data: {
|
|
676
|
+
type: 'reviewSubmissions',
|
|
677
|
+
attributes: { platform },
|
|
678
|
+
relationships: { app: { data: { type: 'apps', id: appId } } },
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
submissionId = created?.data?.id;
|
|
682
|
+
if (!submissionId) {
|
|
683
|
+
throw new Error(`reviewSubmission 생성 응답에 id가 없어: ${JSON.stringify(created)}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// 같은 상품을 두 번 담으면 Apple 이 409 를 준다. 재시도가 안전하도록 먼저 확인한다.
|
|
687
|
+
const items = await apiGet(`/reviewSubmissions/${submissionId}/items`, { limit: '50' });
|
|
688
|
+
const rows = (items?.data ?? []);
|
|
689
|
+
const alreadyAttached = rows.some((row) => row?.relationships?.[relationship.key]?.data?.id === internalId);
|
|
690
|
+
if (!alreadyAttached) {
|
|
691
|
+
await apiPost('/reviewSubmissionItems', {
|
|
692
|
+
data: {
|
|
693
|
+
type: 'reviewSubmissionItems',
|
|
694
|
+
relationships: {
|
|
695
|
+
reviewSubmission: { data: { type: 'reviewSubmissions', id: submissionId } },
|
|
696
|
+
[relationship.key]: { data: { type: relationship.type, id: internalId } },
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
submissionId,
|
|
703
|
+
appId,
|
|
704
|
+
platform,
|
|
705
|
+
internalId,
|
|
706
|
+
productType,
|
|
707
|
+
reusedSubmission,
|
|
708
|
+
itemAttached: !alreadyAttached,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
653
711
|
// ─── 심사 철회 (Cancel Review) ───
|
|
654
712
|
// WAITING_FOR_REVIEW 상태의 reviewSubmission에만 적용 가능.
|
|
655
713
|
// IN_REVIEW 진입 후에는 Apple API가 거부함 (409).
|
|
@@ -127,6 +127,40 @@ export declare function listSubscriptions(auth: OAuth2Client | JWT, packageName:
|
|
|
127
127
|
basePlans: import("googleapis").androidpublisher_v3.Schema$BasePlan[] | undefined;
|
|
128
128
|
listings: import("googleapis").androidpublisher_v3.Schema$SubscriptionListing[] | undefined;
|
|
129
129
|
}[]>;
|
|
130
|
+
export interface ProductListingInput {
|
|
131
|
+
languageCode: string;
|
|
132
|
+
title?: string;
|
|
133
|
+
description?: string;
|
|
134
|
+
/** 구독 전용. 최대 4개. */
|
|
135
|
+
benefits?: string[];
|
|
136
|
+
}
|
|
137
|
+
interface StoredListing {
|
|
138
|
+
languageCode?: string | null;
|
|
139
|
+
title?: string | null;
|
|
140
|
+
description?: string | null;
|
|
141
|
+
benefits?: string[] | null;
|
|
142
|
+
}
|
|
143
|
+
export declare function updateOneTimeProductListings(auth: OAuth2Client | JWT, packageName: string, productId: string, listings: ProductListingInput[]): Promise<{
|
|
144
|
+
productId: string;
|
|
145
|
+
created: string[];
|
|
146
|
+
updated: string[];
|
|
147
|
+
listings: StoredListing[];
|
|
148
|
+
}>;
|
|
149
|
+
export declare function updateSubscriptionListings(auth: OAuth2Client | JWT, packageName: string, productId: string, listings: ProductListingInput[]): Promise<{
|
|
150
|
+
productId: string;
|
|
151
|
+
created: string[];
|
|
152
|
+
updated: string[];
|
|
153
|
+
listings: StoredListing[];
|
|
154
|
+
}>;
|
|
155
|
+
export declare function updatePurchaseOptionState(auth: OAuth2Client | JWT, packageName: string, productId: string, purchaseOptionId: string, action: 'activate' | 'deactivate'): Promise<{
|
|
156
|
+
productId: string;
|
|
157
|
+
purchaseOptionId: string;
|
|
158
|
+
action: "activate" | "deactivate";
|
|
159
|
+
states: {
|
|
160
|
+
purchaseOptionId: string | undefined;
|
|
161
|
+
state: string | undefined;
|
|
162
|
+
}[];
|
|
163
|
+
}>;
|
|
130
164
|
export type ServiceAccountVerifyResult = {
|
|
131
165
|
ok: true;
|
|
132
166
|
clientEmail: string;
|
|
@@ -138,3 +172,4 @@ export type ServiceAccountVerifyResult = {
|
|
|
138
172
|
message: string;
|
|
139
173
|
};
|
|
140
174
|
export declare function verifyServiceAccountJson(serviceAccountJson: string, packageName: string): Promise<ServiceAccountVerifyResult>;
|
|
175
|
+
export {};
|
package/dist/playstore/tools.js
CHANGED
|
@@ -493,6 +493,111 @@ export async function listSubscriptions(auth, packageName) {
|
|
|
493
493
|
listings: s.listings,
|
|
494
494
|
}));
|
|
495
495
|
}
|
|
496
|
+
// ─── 인앱 상품 / 구독 현지화 ───
|
|
497
|
+
//
|
|
498
|
+
// Play 는 상품 현지화를 listings 배열로 들고 있고, patch 의 updateMask=listings 는
|
|
499
|
+
// **배열을 통째로 갈아끼운다**. 그래서 넘긴 로케일만 실으면 나머지 언어가 조용히
|
|
500
|
+
// 사라진다 — 반드시 현재 값을 읽어 병합한 뒤 써야 한다.
|
|
501
|
+
//
|
|
502
|
+
// regionsVersion.version 은 patch 의 필수 쿼리다. 빼면 400 이다.
|
|
503
|
+
const REGIONS_VERSION = '2022/02';
|
|
504
|
+
/**
|
|
505
|
+
* 들어온 로케일만 덮어쓰고 나머지는 보존한다.
|
|
506
|
+
* 새 로케일은 title 이 있어야 만든다 — Play 가 title 없는 listing 을 거부한다.
|
|
507
|
+
*/
|
|
508
|
+
function mergeListings(current, incoming, opts) {
|
|
509
|
+
const merged = current.map((item) => ({ ...item }));
|
|
510
|
+
const created = [];
|
|
511
|
+
const updated = [];
|
|
512
|
+
for (const next of incoming) {
|
|
513
|
+
const found = merged.find((item) => (item.languageCode ?? '').toLowerCase() === next.languageCode.toLowerCase());
|
|
514
|
+
const target = found ?? { languageCode: next.languageCode };
|
|
515
|
+
if (!found) {
|
|
516
|
+
if (!next.title) {
|
|
517
|
+
throw new Error(`새 로케일(${next.languageCode})을 만들려면 title 이 필요해.`);
|
|
518
|
+
}
|
|
519
|
+
merged.push(target);
|
|
520
|
+
created.push(next.languageCode);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
updated.push(next.languageCode);
|
|
524
|
+
}
|
|
525
|
+
if (next.title !== undefined)
|
|
526
|
+
target.title = next.title;
|
|
527
|
+
if (next.description !== undefined)
|
|
528
|
+
target.description = next.description;
|
|
529
|
+
if (next.benefits !== undefined) {
|
|
530
|
+
if (!opts.allowBenefits) {
|
|
531
|
+
throw new Error('benefits 는 구독 상품에만 쓸 수 있어.');
|
|
532
|
+
}
|
|
533
|
+
target.benefits = next.benefits;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { merged, created, updated };
|
|
537
|
+
}
|
|
538
|
+
export async function updateOneTimeProductListings(auth, packageName, productId, listings) {
|
|
539
|
+
const current = await publisher().monetization.onetimeproducts.get({
|
|
540
|
+
auth, packageName, productId,
|
|
541
|
+
});
|
|
542
|
+
const { merged, created, updated } = mergeListings((current.data?.listings ?? []), listings, { allowBenefits: false });
|
|
543
|
+
const res = await publisher().monetization.onetimeproducts.patch({
|
|
544
|
+
auth,
|
|
545
|
+
packageName,
|
|
546
|
+
productId,
|
|
547
|
+
updateMask: 'listings',
|
|
548
|
+
'regionsVersion.version': REGIONS_VERSION,
|
|
549
|
+
requestBody: { listings: merged },
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
productId,
|
|
553
|
+
created,
|
|
554
|
+
updated,
|
|
555
|
+
listings: (res.data?.listings ?? merged),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
export async function updateSubscriptionListings(auth, packageName, productId, listings) {
|
|
559
|
+
const current = await publisher().monetization.subscriptions.get({
|
|
560
|
+
auth, packageName, productId,
|
|
561
|
+
});
|
|
562
|
+
const { merged, created, updated } = mergeListings((current.data?.listings ?? []), listings, { allowBenefits: true });
|
|
563
|
+
const res = await publisher().monetization.subscriptions.patch({
|
|
564
|
+
auth,
|
|
565
|
+
packageName,
|
|
566
|
+
productId,
|
|
567
|
+
updateMask: 'listings',
|
|
568
|
+
'regionsVersion.version': REGIONS_VERSION,
|
|
569
|
+
requestBody: { listings: merged },
|
|
570
|
+
});
|
|
571
|
+
return {
|
|
572
|
+
productId,
|
|
573
|
+
created,
|
|
574
|
+
updated,
|
|
575
|
+
listings: (res.data?.listings ?? merged),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
// ─── 구매 옵션 상태 (DRAFT ↔ ACTIVE) ───
|
|
579
|
+
//
|
|
580
|
+
// 상품을 만들어도 구매 옵션이 DRAFT 면 앱에서 가격이 안 내려온다.
|
|
581
|
+
// Play Console 의 "활성화" 토글이 이 API 다.
|
|
582
|
+
export async function updatePurchaseOptionState(auth, packageName, productId, purchaseOptionId, action) {
|
|
583
|
+
const key = action === 'activate'
|
|
584
|
+
? 'activatePurchaseOptionRequest'
|
|
585
|
+
: 'deactivatePurchaseOptionRequest';
|
|
586
|
+
const res = await publisher().monetization.onetimeproducts.purchaseOptions.batchUpdateStates({
|
|
587
|
+
auth,
|
|
588
|
+
packageName,
|
|
589
|
+
productId,
|
|
590
|
+
requestBody: {
|
|
591
|
+
requests: [{ [key]: { packageName, productId, purchaseOptionId } }],
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
const products = (res.data?.oneTimeProducts ?? []);
|
|
595
|
+
const states = products.flatMap((p) => (p.purchaseOptions ?? []).map((o) => ({
|
|
596
|
+
purchaseOptionId: o.purchaseOptionId,
|
|
597
|
+
state: o.state,
|
|
598
|
+
})));
|
|
599
|
+
return { productId, purchaseOptionId, action, states };
|
|
600
|
+
}
|
|
496
601
|
export async function verifyServiceAccountJson(serviceAccountJson, packageName) {
|
|
497
602
|
let parsed;
|
|
498
603
|
try {
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import * as appstore from '../appstore/tools.js';
|
|
3
3
|
import * as appstoreScreenshots from '../appstore/screenshots.js';
|
|
4
4
|
import * as appstoreProductReview from '../appstore/product-review.js';
|
|
5
|
+
import * as appstoreProductLocalization from '../appstore/product-localization.js';
|
|
5
6
|
import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
|
|
6
7
|
import { requireAppStoreCreds } from '../helpers.js';
|
|
7
8
|
import { buildAppStoreReleasePlan } from '../checks/plan.js';
|
|
@@ -489,6 +490,64 @@ export function registerAppstoreTools(server) {
|
|
|
489
490
|
}],
|
|
490
491
|
};
|
|
491
492
|
});
|
|
493
|
+
server.tool('appstore_list_product_localizations', 'App Store IAP/구독 상품의 현지화(표시 이름·설명) 목록 조회. locale / name / description / state 반환.', {
|
|
494
|
+
appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
|
|
495
|
+
productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
|
|
496
|
+
productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
|
|
497
|
+
}, async ({ appId, productId, productType }) => {
|
|
498
|
+
const creds = requireAppStoreCreds();
|
|
499
|
+
const products = await listAppleProducts({
|
|
500
|
+
appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
|
|
501
|
+
});
|
|
502
|
+
const product = products.find((item) => item.productId === productId && item.type === productType);
|
|
503
|
+
if (!product) {
|
|
504
|
+
return { content: [{ type: 'text', text: `상품을 찾을 수 없음: ${productId} (${productType})` }] };
|
|
505
|
+
}
|
|
506
|
+
const localizations = await appstoreProductLocalization.listProductLocalizations({
|
|
507
|
+
internalId: product.internalId,
|
|
508
|
+
productType,
|
|
509
|
+
});
|
|
510
|
+
return { content: [{ type: 'text', text: JSON.stringify(localizations, null, 2) }] };
|
|
511
|
+
});
|
|
512
|
+
server.tool('appstore_update_product_localization', 'App Store IAP/구독 상품의 현지화(표시 이름·설명)를 로케일 단위로 upsert — 있으면 수정, 없으면 생성. ' +
|
|
513
|
+
'현지화가 비면 상품이 MISSING_METADATA 에서 안 풀려 심사에 넣을 수 없다 (리뷰 노트·스크린샷과는 별개 리소스). ' +
|
|
514
|
+
'locale 은 App Store 표기(ko, en-US, ja, zh-Hant)를 쓴다. name 30자 / description 45자 제한.', {
|
|
515
|
+
appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
|
|
516
|
+
productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
|
|
517
|
+
productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
|
|
518
|
+
locale: z.string().describe('로케일 (예: ko, en-US, ja, zh-Hant)'),
|
|
519
|
+
name: z.string().max(30).optional().describe('표시 이름 (30자 이하). 새 로케일 생성 시 필수'),
|
|
520
|
+
description: z.string().max(45).optional().describe('설명 (45자 이하). 생략하면 기존 값 유지'),
|
|
521
|
+
}, async ({ appId, productId, productType, locale, name, description }) => {
|
|
522
|
+
const creds = requireAppStoreCreds();
|
|
523
|
+
const products = await listAppleProducts({
|
|
524
|
+
appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
|
|
525
|
+
});
|
|
526
|
+
const product = products.find((item) => item.productId === productId && item.type === productType);
|
|
527
|
+
if (!product) {
|
|
528
|
+
return { content: [{ type: 'text', text: `상품을 찾을 수 없음: ${productId} (${productType})` }] };
|
|
529
|
+
}
|
|
530
|
+
const result = await appstoreProductLocalization.upsertProductLocalization({
|
|
531
|
+
internalId: product.internalId,
|
|
532
|
+
productType,
|
|
533
|
+
locale,
|
|
534
|
+
name,
|
|
535
|
+
description,
|
|
536
|
+
});
|
|
537
|
+
return {
|
|
538
|
+
content: [{
|
|
539
|
+
type: 'text',
|
|
540
|
+
text: [
|
|
541
|
+
`✓ 현지화 ${result.created ? '생성' : '수정'} 완료`,
|
|
542
|
+
`productId: ${productId}`,
|
|
543
|
+
`locale: ${result.locale}`,
|
|
544
|
+
result.name ? `name: ${result.name}` : '',
|
|
545
|
+
result.description ? `description: ${result.description}` : '',
|
|
546
|
+
result.state ? `state: ${result.state}` : '',
|
|
547
|
+
].filter(Boolean).join('\n'),
|
|
548
|
+
}],
|
|
549
|
+
};
|
|
550
|
+
});
|
|
492
551
|
server.tool('appstore_upload_product_review_screenshot', '기존 App Store IAP/구독 상품의 심사용 스크린샷을 reserve → upload → commit. 상품당 1장, 절대 파일 경로 필요.', {
|
|
493
552
|
appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
|
|
494
553
|
productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
|
|
@@ -523,7 +582,46 @@ export function registerAppstoreTools(server) {
|
|
|
523
582
|
}],
|
|
524
583
|
};
|
|
525
584
|
});
|
|
526
|
-
server.tool('
|
|
585
|
+
server.tool('appstore_add_product_to_review', 'App Store IAP/구독을 심사 제출 묶음에 담는다 — App Store Connect 웹의 "심사에 추가" 버튼과 같다. ' +
|
|
586
|
+
'담기만 하고 제출하지는 않는다 (제출은 appstore_submit_for_review). ' +
|
|
587
|
+
'⚠️ 그 앱의 첫 소모성 IAP 는 앱 버전과 같은 묶음으로만 심사에 넣을 수 있다 — IAP 를 전부 담은 뒤 버전을 제출해야 한 번에 나간다. ' +
|
|
588
|
+
'상품 상태가 READY_TO_SUBMIT 이어야 한다 (MISSING_METADATA 면 appstore_update_product_localization 먼저).', {
|
|
589
|
+
appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
|
|
590
|
+
productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
|
|
591
|
+
productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
|
|
592
|
+
platform: z.enum(['IOS', 'MAC_OS', 'TV_OS', 'VISION_OS']).default('IOS').optional()
|
|
593
|
+
.describe('플랫폼 (기본 IOS)'),
|
|
594
|
+
}, async ({ appId, productId, productType, platform }) => {
|
|
595
|
+
const creds = requireAppStoreCreds();
|
|
596
|
+
const products = await listAppleProducts({
|
|
597
|
+
appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
|
|
598
|
+
});
|
|
599
|
+
const product = products.find((item) => item.productId === productId && item.type === productType);
|
|
600
|
+
if (!product) {
|
|
601
|
+
return { content: [{ type: 'text', text: `상품을 찾을 수 없음: ${productId} (${productType})` }] };
|
|
602
|
+
}
|
|
603
|
+
const result = await appstore.addProductToReviewSubmission({
|
|
604
|
+
appId,
|
|
605
|
+
internalId: product.internalId,
|
|
606
|
+
productType,
|
|
607
|
+
platform,
|
|
608
|
+
});
|
|
609
|
+
return {
|
|
610
|
+
content: [{
|
|
611
|
+
type: 'text',
|
|
612
|
+
text: [
|
|
613
|
+
result.itemAttached ? '✓ 심사 묶음에 추가됨' : '이미 묶음에 들어 있음 (변경 없음)',
|
|
614
|
+
`productId: ${productId}`,
|
|
615
|
+
`submissionId: ${result.submissionId}`,
|
|
616
|
+
`기존 묶음 재사용: ${result.reusedSubmission}`,
|
|
617
|
+
'',
|
|
618
|
+
'제출은 아직 안 됐다. 담을 상품을 전부 담은 뒤 appstore_submit_for_review 로 버전과 함께 제출한다.',
|
|
619
|
+
].join('\n'),
|
|
620
|
+
}],
|
|
621
|
+
};
|
|
622
|
+
});
|
|
623
|
+
server.tool('appstore_update_product', 'App Store IAP 상품의 reference name 변경. productId / 유형은 변경 불가. ' +
|
|
624
|
+
'스토어에 보이는 표시 이름·설명은 appstore_update_product_localization 을 쓴다.', {
|
|
527
625
|
appId: z.string().optional().describe('App Store 앱 ID'),
|
|
528
626
|
bundleId: z.string().optional().describe('번들 ID (appId 대신 사용 가능)'),
|
|
529
627
|
productId: z.string().describe('상품 ID'),
|
|
@@ -595,7 +595,79 @@ export function registerPlaystoreTools(server) {
|
|
|
595
595
|
const products = await listGoogleProducts({ packageName, serviceAccountKey: json });
|
|
596
596
|
return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] };
|
|
597
597
|
});
|
|
598
|
-
server.tool('
|
|
598
|
+
server.tool('playstore_update_product_listing', 'Google Play 인앱 상품(소모성/비소모성)의 언어별 제목·설명을 갱신. 넘긴 로케일만 덮어쓰고 나머지 언어는 보존한다. ' +
|
|
599
|
+
'리스팅이 없는 언어의 사용자에게는 앱 기본 언어 리스팅이 대신 보인다 — 번역을 넣어야 그 나라 결제창이 그 나라 말로 나온다. ' +
|
|
600
|
+
'구독은 playstore_update_subscription_listing 을 쓴다.', {
|
|
601
|
+
packageName: z.string().describe('패키지명'),
|
|
602
|
+
productId: z.string().describe('상품 ID (playstore_list_inapp_products 결과)'),
|
|
603
|
+
listings: z.array(z.object({
|
|
604
|
+
languageCode: z.string().describe('언어 코드 (예: ko-KR, en-US, ja-JP, zh-TW)'),
|
|
605
|
+
title: z.string().max(55).optional().describe('제목 (55자 이하). 새 언어 추가 시 필수'),
|
|
606
|
+
description: z.string().max(200).optional().describe('설명 (200자 이하)'),
|
|
607
|
+
})).min(1).describe('갱신할 언어별 리스팅'),
|
|
608
|
+
}, async ({ packageName, productId, listings }) => {
|
|
609
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
610
|
+
const result = await playstore.updateOneTimeProductListings(auth, packageName, productId, listings);
|
|
611
|
+
return {
|
|
612
|
+
content: [{
|
|
613
|
+
type: 'text',
|
|
614
|
+
text: [
|
|
615
|
+
`✓ ${productId} 리스팅 갱신`,
|
|
616
|
+
result.created.length ? `추가된 언어: ${result.created.join(', ')}` : '',
|
|
617
|
+
result.updated.length ? `수정된 언어: ${result.updated.join(', ')}` : '',
|
|
618
|
+
`현재 언어: ${result.listings.map((l) => l.languageCode).join(', ')}`,
|
|
619
|
+
].filter(Boolean).join('\n'),
|
|
620
|
+
}],
|
|
621
|
+
};
|
|
622
|
+
});
|
|
623
|
+
server.tool('playstore_update_subscription_listing', 'Google Play 구독 상품의 언어별 제목·설명·혜택(benefits)을 갱신. 넘긴 로케일만 덮어쓰고 나머지 언어는 보존한다. ' +
|
|
624
|
+
'benefits 는 스토어 구매창에 불릿으로 나오는 항목이라 한 줄에 몰아넣지 말고 항목을 나눠 넣는다 (최대 4개).', {
|
|
625
|
+
packageName: z.string().describe('패키지명'),
|
|
626
|
+
productId: z.string().describe('구독 상품 ID (playstore_list_subscriptions 결과)'),
|
|
627
|
+
listings: z.array(z.object({
|
|
628
|
+
languageCode: z.string().describe('언어 코드 (예: ko-KR, en-US, ja-JP, zh-TW)'),
|
|
629
|
+
title: z.string().max(55).optional().describe('제목 (55자 이하). 새 언어 추가 시 필수'),
|
|
630
|
+
description: z.string().max(200).optional().describe('설명 (200자 이하)'),
|
|
631
|
+
benefits: z.array(z.string().max(40)).max(4).optional()
|
|
632
|
+
.describe('혜택 항목 (각 40자 이하, 최대 4개). 생략하면 기존 값 유지'),
|
|
633
|
+
})).min(1).describe('갱신할 언어별 리스팅'),
|
|
634
|
+
}, async ({ packageName, productId, listings }) => {
|
|
635
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
636
|
+
const result = await playstore.updateSubscriptionListings(auth, packageName, productId, listings);
|
|
637
|
+
return {
|
|
638
|
+
content: [{
|
|
639
|
+
type: 'text',
|
|
640
|
+
text: [
|
|
641
|
+
`✓ ${productId} 구독 리스팅 갱신`,
|
|
642
|
+
result.created.length ? `추가된 언어: ${result.created.join(', ')}` : '',
|
|
643
|
+
result.updated.length ? `수정된 언어: ${result.updated.join(', ')}` : '',
|
|
644
|
+
`현재 언어: ${result.listings.map((l) => l.languageCode).join(', ')}`,
|
|
645
|
+
].filter(Boolean).join('\n'),
|
|
646
|
+
}],
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
server.tool('playstore_update_product_state', 'Google Play 인앱 상품의 구매 옵션을 활성화/비활성화 — Play Console 의 DRAFT → 활성 토글. ' +
|
|
650
|
+
'DRAFT 인 상품은 가격이 앱에 안 내려오므로 만들고 활성화하지 않으면 상점 화면이 비어 보인다. ' +
|
|
651
|
+
'purchaseOptionId 는 playstore_list_inapp_products 의 purchaseOptions[].purchaseOptionId (보통 "base").', {
|
|
652
|
+
packageName: z.string().describe('패키지명'),
|
|
653
|
+
productId: z.string().describe('상품 ID'),
|
|
654
|
+
purchaseOptionId: z.string().default('base').describe('구매 옵션 ID (기본 "base")'),
|
|
655
|
+
action: z.enum(['activate', 'deactivate']).describe('활성화 / 비활성화'),
|
|
656
|
+
}, async ({ packageName, productId, purchaseOptionId, action }) => {
|
|
657
|
+
const auth = requirePlayStoreAuth(packageName);
|
|
658
|
+
const result = await playstore.updatePurchaseOptionState(auth, packageName, productId, purchaseOptionId, action);
|
|
659
|
+
const states = result.states.length
|
|
660
|
+
? result.states.map((s) => `${s.purchaseOptionId}: ${s.state}`).join(', ')
|
|
661
|
+
: '(응답에 상태 없음 — playstore_list_inapp_products 로 확인)';
|
|
662
|
+
return {
|
|
663
|
+
content: [{
|
|
664
|
+
type: 'text',
|
|
665
|
+
text: `✓ ${productId} / ${purchaseOptionId} ${action}\n${states}`,
|
|
666
|
+
}],
|
|
667
|
+
};
|
|
668
|
+
});
|
|
669
|
+
server.tool('playstore_update_product', 'Google Play IAP 상품의 표시 이름 변경 (현재 name 필드만 수정 가능). productId / type / 가격은 변경 불가. ' +
|
|
670
|
+
'언어별 제목·설명은 playstore_update_product_listing / playstore_update_subscription_listing 을 쓴다.', {
|
|
599
671
|
packageName: z.string().describe('패키지명'),
|
|
600
672
|
productId: z.string().describe('상품 ID'),
|
|
601
673
|
productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yoonion/mimi-seed-mcp",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
4
4
|
"description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@anthropic-ai/sdk": "^0.52.0",
|
|
63
63
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
64
|
-
"@onesub/providers": "^0.
|
|
64
|
+
"@onesub/providers": "^0.4.0",
|
|
65
65
|
"googleapis": "^171.4.0",
|
|
66
66
|
"jose": "^5.10.0",
|
|
67
67
|
"open": "^10.1.0",
|
package/tool-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
|
|
3
|
-
"total":
|
|
3
|
+
"total": 183,
|
|
4
4
|
"domains": {
|
|
5
5
|
"admob": {
|
|
6
6
|
"label": "AdMob",
|
|
@@ -68,7 +68,10 @@
|
|
|
68
68
|
"appstore_create_subscription",
|
|
69
69
|
"appstore_list_products",
|
|
70
70
|
"appstore_update_product_review_note",
|
|
71
|
+
"appstore_list_product_localizations",
|
|
72
|
+
"appstore_update_product_localization",
|
|
71
73
|
"appstore_upload_product_review_screenshot",
|
|
74
|
+
"appstore_add_product_to_review",
|
|
72
75
|
"appstore_update_product",
|
|
73
76
|
"appstore_delete_product",
|
|
74
77
|
"appstore_plan_release",
|
|
@@ -273,6 +276,9 @@
|
|
|
273
276
|
"playstore_submit_release",
|
|
274
277
|
"playstore_promote_release",
|
|
275
278
|
"playstore_list_products",
|
|
279
|
+
"playstore_update_product_listing",
|
|
280
|
+
"playstore_update_subscription_listing",
|
|
281
|
+
"playstore_update_product_state",
|
|
276
282
|
"playstore_update_product",
|
|
277
283
|
"playstore_delete_product",
|
|
278
284
|
"setup_playstore_connection"
|