@yoonion/mimi-seed-mcp 0.13.16 → 0.13.18
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/README.md +2 -2
- package/assets/agent-guide.md +3 -0
- package/dist/appstore/declarations.js +16 -7
- package/dist/appstore/http.d.ts +8 -0
- package/dist/appstore/http.js +13 -0
- package/dist/appstore/previews.d.ts +41 -0
- package/dist/appstore/previews.js +190 -0
- package/dist/appstore/release.js +2 -2
- package/dist/appstore/testflight.d.ts +89 -0
- package/dist/appstore/testflight.js +199 -0
- package/dist/playstore/tools.d.ts +38 -0
- package/dist/playstore/tools.js +73 -3
- package/dist/registers/appstore.js +324 -0
- package/dist/registers/playstore.js +123 -0
- package/package.json +1 -1
- package/tool-manifest.json +18 -3
package/README.md
CHANGED
|
@@ -78,8 +78,8 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
78
78
|
|
|
79
79
|
| 영역 | 도구 수 | 주요 도구 |
|
|
80
80
|
|------|---------|-----------|
|
|
81
|
-
| App Store Connect |
|
|
82
|
-
| Google Play |
|
|
81
|
+
| App Store Connect | 61 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
|
|
82
|
+
| Google Play | 37 | `playstore_submit_release` / `playstore_promote_release` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
|
|
83
83
|
| Firebase | 20 | `firebase_create_project` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_create_ios_app` |
|
|
84
84
|
| AdMob | 7 | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
|
|
85
85
|
| CI/CD (GitHub Actions · GitLab) | 6 | `ci_trigger_build` / `ci_get_build_status` / `ci_list_workflows` / `ci_cancel_build` |
|
package/assets/agent-guide.md
CHANGED
|
@@ -56,7 +56,10 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
|
|
|
56
56
|
| App Store / TestFlight | `select:appstore_list_apps,appstore_verify_credentials,appstore_get_app,appstore_list_versions,appstore_create_version,appstore_get_metadata,appstore_update_whats_new,appstore_list_builds,appstore_attach_build,appstore_attach_latest_build,appstore_list_beta_groups,appstore_submit_for_review,appstore_check_submission_risks,appstore_plan_release` |
|
|
57
57
|
| App Store release control (after approval) | `select:appstore_release_status,appstore_release_version,appstore_update_release_type,appstore_phased_release,appstore_list_versions` |
|
|
58
58
|
| Pre-submission declarations (both stores) | `select:appstore_get_age_rating,appstore_update_age_rating,appstore_declare_encryption,appstore_get_availability,appstore_set_territory_availability,playstore_upload_data_safety` |
|
|
59
|
+
| TestFlight external testing | `select:appstore_beta_status,appstore_update_beta_review_detail,appstore_update_beta_test_info,appstore_update_whats_to_test,appstore_submit_beta_review,appstore_set_beta_group_build,appstore_add_beta_testers,appstore_notify_beta_testers,appstore_list_beta_groups,appstore_list_builds` |
|
|
59
60
|
| App Store review submission (the bundle) | `select:appstore_list_review_submissions,appstore_add_version_to_review_submission,appstore_remove_review_submission_item,appstore_update_version_string,appstore_cancel_review` |
|
|
61
|
+
| App Store preview videos | `select:appstore_list_previews,appstore_upload_preview,appstore_delete_preview,appstore_get_metadata` |
|
|
62
|
+
| Play post-release recovery | `select:playstore_list_recovery_actions,playstore_create_recovery_action,playstore_deploy_recovery_action,playstore_cancel_recovery_action,playstore_list_tracks` |
|
|
60
63
|
| App Store screenshots | `select:appstore_list_app_info_localizations,appstore_get_metadata,appstore_list_screenshots,appstore_upload_screenshot,appstore_delete_screenshot,appstore_delete_screenshot_set,screenshot_validate` |
|
|
61
64
|
| App Store app info + review notes | `select:appstore_get_app_info,appstore_update_app_info_localization,appstore_create_app_info_localization,appstore_update_localization,appstore_get_review_notes,appstore_update_review_notes` |
|
|
62
65
|
| App Store reviews | `select:appstore_list_reviews,appstore_reply_review,generate_review_reply` |
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// 판매 지역 (appAvailabilityV2 / territoryAvailabilities): 지역별 available·출시일
|
|
6
6
|
//
|
|
7
7
|
// Play 쪽 대응물(데이터 안전 CSV)은 playstore/tools.ts 에 있다 — 자격증명 계통이 달라서 파일을 나눴다.
|
|
8
|
-
import { V1_BASE, V2_BASE, apiRequest, authHeadersOrThrow } from './http.js';
|
|
8
|
+
import { V1_BASE, V2_BASE, apiRequest, authHeadersOrThrow, isNotFound } from './http.js';
|
|
9
9
|
async function get(base, path, params) {
|
|
10
10
|
const headers = await authHeadersOrThrow();
|
|
11
11
|
const query = params ? `?${new URLSearchParams(params).toString()}` : '';
|
|
@@ -33,12 +33,21 @@ async function resolveAppInfoId(appId) {
|
|
|
33
33
|
}
|
|
34
34
|
export async function getAgeRating(appId) {
|
|
35
35
|
const appInfoId = await resolveAppInfoId(appId);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
// 선언 리소스가 아직 없는 앱이 있다 — 그때는 404 다. 에러 대신 "없음"으로 돌려주고
|
|
37
|
+
// updateAgeRating 이 사람이 읽을 안내를 내도록 한다.
|
|
38
|
+
try {
|
|
39
|
+
const data = await get(V1_BASE, `/appInfos/${appInfoId}/ageRatingDeclaration`);
|
|
40
|
+
return {
|
|
41
|
+
appInfoId,
|
|
42
|
+
declarationId: data.data?.id,
|
|
43
|
+
declaration: data.data?.attributes ?? {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (isNotFound(err))
|
|
48
|
+
return { appInfoId, declarationId: undefined, declaration: {} };
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
42
51
|
}
|
|
43
52
|
/**
|
|
44
53
|
* 연령 등급 설문 갱신 (PATCH /v1/ageRatingDeclarations/{id}).
|
package/dist/appstore/http.d.ts
CHANGED
|
@@ -3,3 +3,11 @@ export declare const V2_BASE = "https://api.appstoreconnect.apple.com/v2";
|
|
|
3
3
|
export type AppStoreProductType = 'subscription' | 'consumable' | 'non_consumable';
|
|
4
4
|
export declare function authHeadersOrThrow(): Promise<Record<string, string>>;
|
|
5
5
|
export declare function apiRequest<T>(base: string, resourcePath: string, authHeaders: Record<string, string>, init: RequestInit): Promise<T>;
|
|
6
|
+
/**
|
|
7
|
+
* "리소스 없음"인지 **상태 코드로** 판별한다.
|
|
8
|
+
*
|
|
9
|
+
* friendlyAppStoreError 가 cause.status 에 실제 HTTP 상태를 붙여준다. 메시지 문자열로
|
|
10
|
+
* 404 를 찾으면 본문에 'not found'/'404' 가 섞인 403·409 까지 "없음"으로 삼켜서,
|
|
11
|
+
* 권한 오류가 조용히 빈 결과로 둔갑한다. 상태를 못 읽는 경우에만 문자열로 폴백한다.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isNotFound(err: unknown): boolean;
|
package/dist/appstore/http.js
CHANGED
|
@@ -26,3 +26,16 @@ export async function apiRequest(base, resourcePath, authHeaders, init) {
|
|
|
26
26
|
const text = await response.text();
|
|
27
27
|
return (text ? JSON.parse(text) : { ok: true });
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* "리소스 없음"인지 **상태 코드로** 판별한다.
|
|
31
|
+
*
|
|
32
|
+
* friendlyAppStoreError 가 cause.status 에 실제 HTTP 상태를 붙여준다. 메시지 문자열로
|
|
33
|
+
* 404 를 찾으면 본문에 'not found'/'404' 가 섞인 403·409 까지 "없음"으로 삼켜서,
|
|
34
|
+
* 권한 오류가 조용히 빈 결과로 둔갑한다. 상태를 못 읽는 경우에만 문자열로 폴백한다.
|
|
35
|
+
*/
|
|
36
|
+
export function isNotFound(err) {
|
|
37
|
+
const cause = err?.cause;
|
|
38
|
+
if (typeof cause?.status === 'number')
|
|
39
|
+
return cause.status === 404;
|
|
40
|
+
return /App Store API 404\b/.test(err?.message ?? '');
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface PreviewRow {
|
|
2
|
+
id: string;
|
|
3
|
+
fileName?: string;
|
|
4
|
+
fileSize?: number;
|
|
5
|
+
state?: string;
|
|
6
|
+
previewFrameTimeCode?: string;
|
|
7
|
+
videoUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PreviewSetRow {
|
|
10
|
+
id: string;
|
|
11
|
+
previewType?: string;
|
|
12
|
+
previews: PreviewRow[];
|
|
13
|
+
}
|
|
14
|
+
/** 로케일의 미리보기 세트 + 각 동영상 상태. 업로드 후 인코딩 확인도 여기서 한다. */
|
|
15
|
+
export declare function listPreviewSets(localizationId: string): Promise<PreviewSetRow[]>;
|
|
16
|
+
/**
|
|
17
|
+
* 미리보기 동영상 업로드. `previewFrameTimeCode` 는 포스터 프레임 시각 (HH:MM:SS:FF).
|
|
18
|
+
*
|
|
19
|
+
* Apple 제약(업로드 후 인코딩 단계에서 거부될 수 있는 것들):
|
|
20
|
+
* 길이 15~30초 · previewType 별 해상도 고정 · 로케일·타입당 최대 3개.
|
|
21
|
+
*/
|
|
22
|
+
export declare function uploadPreview(args: {
|
|
23
|
+
localizationId: string;
|
|
24
|
+
previewType: string;
|
|
25
|
+
filePath: string;
|
|
26
|
+
previewFrameTimeCode?: string;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
id: string;
|
|
29
|
+
fileName: string;
|
|
30
|
+
fileSize: number;
|
|
31
|
+
previewType: string;
|
|
32
|
+
state?: string;
|
|
33
|
+
}>;
|
|
34
|
+
export declare function deletePreview(previewId: string): Promise<{
|
|
35
|
+
ok: true;
|
|
36
|
+
id: string;
|
|
37
|
+
}>;
|
|
38
|
+
export declare function deletePreviewSet(setId: string): Promise<{
|
|
39
|
+
ok: true;
|
|
40
|
+
id: string;
|
|
41
|
+
}>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// App Store 제품 페이지 미리보기 **동영상** (appPreviewSets / appPreviews).
|
|
2
|
+
//
|
|
3
|
+
// 업로드 절차는 스크린샷과 같은 4단계다 (screenshots.ts 와 같은 기계):
|
|
4
|
+
// 1. set 확보 POST /appPreviewSets (previewType 별로 하나)
|
|
5
|
+
// 2. 예약 POST /appPreviews → uploadOperations[]
|
|
6
|
+
// 3. 업로드 PUT 각 operation URL (offset/length 로 조각내서)
|
|
7
|
+
// 4. 커밋 PATCH /appPreviews/{id} { uploaded, sourceFileChecksum }
|
|
8
|
+
//
|
|
9
|
+
// 스크린샷과 다른 점: 커밋 뒤 Apple 이 **인코딩**을 돌린다. 그래서 업로드 성공 ≠ 노출 가능이고,
|
|
10
|
+
// assetDeliveryState 를 따로 확인해야 한다. previewFrameTimeCode 로 포스터 프레임도 고른다.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { getAuthHeaders } from './auth.js';
|
|
15
|
+
const BASE = 'https://api.appstoreconnect.apple.com/v1';
|
|
16
|
+
async function req(pathOrUrl, init = {}) {
|
|
17
|
+
const headers = await getAuthHeaders();
|
|
18
|
+
if (!headers) {
|
|
19
|
+
throw new Error([
|
|
20
|
+
'❌ App Store Connect 인증이 필요해.',
|
|
21
|
+
'',
|
|
22
|
+
'터미널에서 실행:',
|
|
23
|
+
' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
|
|
24
|
+
].join('\n'));
|
|
25
|
+
}
|
|
26
|
+
const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${BASE}${pathOrUrl}`;
|
|
27
|
+
const res = await fetch(url, { ...init, headers: { ...headers, ...(init.headers ?? {}) } });
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const body = await res.text();
|
|
30
|
+
throw new Error(`App Store API ${res.status} ${init.method ?? 'GET'} ${pathOrUrl}: ${body}`);
|
|
31
|
+
}
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
return (text ? JSON.parse(text) : null);
|
|
34
|
+
}
|
|
35
|
+
/** 로케일의 미리보기 세트 + 각 동영상 상태. 업로드 후 인코딩 확인도 여기서 한다. */
|
|
36
|
+
export async function listPreviewSets(localizationId) {
|
|
37
|
+
const data = await req(`/appStoreVersionLocalizations/${localizationId}/appPreviewSets` +
|
|
38
|
+
`?include=appPreviews` +
|
|
39
|
+
`&fields[appPreviewSets]=previewType,appPreviews` +
|
|
40
|
+
`&fields[appPreviews]=fileName,fileSize,assetDeliveryState,previewFrameTimeCode,videoUrl`);
|
|
41
|
+
const included = data?.included ?? [];
|
|
42
|
+
return (data?.data ?? []).map((s) => {
|
|
43
|
+
const ids = (s.relationships?.appPreviews?.data ?? []).map((p) => p.id);
|
|
44
|
+
return {
|
|
45
|
+
id: s.id,
|
|
46
|
+
previewType: s.attributes?.previewType,
|
|
47
|
+
previews: included
|
|
48
|
+
.filter((i) => i.type === 'appPreviews' && ids.includes(i.id))
|
|
49
|
+
.map((i) => ({
|
|
50
|
+
id: i.id,
|
|
51
|
+
fileName: i.attributes?.fileName,
|
|
52
|
+
fileSize: i.attributes?.fileSize,
|
|
53
|
+
state: i.attributes?.assetDeliveryState?.state,
|
|
54
|
+
previewFrameTimeCode: i.attributes?.previewFrameTimeCode,
|
|
55
|
+
videoUrl: i.attributes?.videoUrl,
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async function ensurePreviewSet(localizationId, previewType) {
|
|
61
|
+
const sets = await listPreviewSets(localizationId);
|
|
62
|
+
const hit = sets.find((s) => s.previewType === previewType);
|
|
63
|
+
if (hit)
|
|
64
|
+
return hit.id;
|
|
65
|
+
const created = await req('/appPreviewSets', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Content-Type': 'application/json' },
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
data: {
|
|
70
|
+
type: 'appPreviewSets',
|
|
71
|
+
attributes: { previewType },
|
|
72
|
+
relationships: {
|
|
73
|
+
appStoreVersionLocalization: {
|
|
74
|
+
data: { type: 'appStoreVersionLocalizations', id: localizationId },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
return created.data.id;
|
|
81
|
+
}
|
|
82
|
+
async function uploadChunks(absPath, ops) {
|
|
83
|
+
const fd = fs.openSync(absPath, 'r');
|
|
84
|
+
try {
|
|
85
|
+
for (const op of ops) {
|
|
86
|
+
// 파일 전체를 메모리에 올리지 않는다 — 동영상은 수백 MB 가 될 수 있다.
|
|
87
|
+
const chunk = Buffer.alloc(op.length);
|
|
88
|
+
fs.readSync(fd, chunk, 0, op.length, op.offset);
|
|
89
|
+
const headers = {};
|
|
90
|
+
for (const h of op.requestHeaders ?? [])
|
|
91
|
+
headers[h.name] = h.value;
|
|
92
|
+
const res = await fetch(op.url, { method: op.method ?? 'PUT', headers, body: chunk });
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(`미리보기 업로드 실패 (offset ${op.offset}): ${res.status} ${await res.text()}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
fs.closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** 확장자로 추정한 mimeType. Apple 은 없어도 받지만 있으면 처리 실패가 줄어든다. */
|
|
103
|
+
function guessMimeType(fileName) {
|
|
104
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
105
|
+
if (ext === '.mp4')
|
|
106
|
+
return 'video/mp4';
|
|
107
|
+
if (ext === '.mov')
|
|
108
|
+
return 'video/quicktime';
|
|
109
|
+
if (ext === '.m4v')
|
|
110
|
+
return 'video/x-m4v';
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 미리보기 동영상 업로드. `previewFrameTimeCode` 는 포스터 프레임 시각 (HH:MM:SS:FF).
|
|
115
|
+
*
|
|
116
|
+
* Apple 제약(업로드 후 인코딩 단계에서 거부될 수 있는 것들):
|
|
117
|
+
* 길이 15~30초 · previewType 별 해상도 고정 · 로케일·타입당 최대 3개.
|
|
118
|
+
*/
|
|
119
|
+
export async function uploadPreview(args) {
|
|
120
|
+
const { localizationId, previewType, filePath, previewFrameTimeCode } = args;
|
|
121
|
+
if (!path.isAbsolute(filePath))
|
|
122
|
+
throw new Error(`filePath 는 절대경로여야 한다: ${filePath}`);
|
|
123
|
+
if (!fs.existsSync(filePath))
|
|
124
|
+
throw new Error(`파일이 존재하지 않는다: ${filePath}`);
|
|
125
|
+
const fileName = path.basename(filePath);
|
|
126
|
+
const fileSize = fs.statSync(filePath).size;
|
|
127
|
+
if (fileSize === 0)
|
|
128
|
+
throw new Error('빈 파일이다.');
|
|
129
|
+
const setId = await ensurePreviewSet(localizationId, previewType);
|
|
130
|
+
const mimeType = guessMimeType(fileName);
|
|
131
|
+
const reserved = await req('/appPreviews', {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
data: {
|
|
136
|
+
type: 'appPreviews',
|
|
137
|
+
attributes: {
|
|
138
|
+
fileName,
|
|
139
|
+
fileSize,
|
|
140
|
+
...(mimeType ? { mimeType } : {}),
|
|
141
|
+
...(previewFrameTimeCode ? { previewFrameTimeCode } : {}),
|
|
142
|
+
},
|
|
143
|
+
relationships: { appPreviewSet: { data: { type: 'appPreviewSets', id: setId } } },
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
const previewId = reserved.data.id;
|
|
148
|
+
const ops = reserved.data.attributes?.uploadOperations ?? [];
|
|
149
|
+
if (ops.length === 0)
|
|
150
|
+
throw new Error('uploadOperations 가 비어 있다 — Apple 응답 형식 확인 필요.');
|
|
151
|
+
await uploadChunks(filePath, ops);
|
|
152
|
+
// 체크섬은 전체 파일 기준이라 스트리밍으로 계산한다 (메모리에 다 올리지 않는다).
|
|
153
|
+
const md5 = await new Promise((resolve, reject) => {
|
|
154
|
+
const hash = crypto.createHash('md5');
|
|
155
|
+
fs.createReadStream(filePath)
|
|
156
|
+
.on('data', (c) => hash.update(c))
|
|
157
|
+
.on('end', () => resolve(hash.digest('hex')))
|
|
158
|
+
.on('error', reject);
|
|
159
|
+
});
|
|
160
|
+
const committed = await req(`/appPreviews/${previewId}`, {
|
|
161
|
+
method: 'PATCH',
|
|
162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
data: {
|
|
165
|
+
type: 'appPreviews',
|
|
166
|
+
id: previewId,
|
|
167
|
+
attributes: {
|
|
168
|
+
uploaded: true,
|
|
169
|
+
sourceFileChecksum: md5,
|
|
170
|
+
...(previewFrameTimeCode ? { previewFrameTimeCode } : {}),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
id: previewId,
|
|
177
|
+
fileName,
|
|
178
|
+
fileSize,
|
|
179
|
+
previewType,
|
|
180
|
+
state: committed?.data?.attributes?.assetDeliveryState?.state,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export async function deletePreview(previewId) {
|
|
184
|
+
await req(`/appPreviews/${previewId}`, { method: 'DELETE' });
|
|
185
|
+
return { ok: true, id: previewId };
|
|
186
|
+
}
|
|
187
|
+
export async function deletePreviewSet(setId) {
|
|
188
|
+
await req(`/appPreviewSets/${setId}`, { method: 'DELETE' });
|
|
189
|
+
return { ok: true, id: setId };
|
|
190
|
+
}
|
package/dist/appstore/release.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 2. 만들어 둔 버전의 releaseType 을 나중에 바꾸기 (PATCH appStoreVersions)
|
|
7
7
|
// 3. 단계적 출시 시작·일시중지·재개·즉시완료 (phasedRelease)
|
|
8
8
|
// Play 는 userFraction/halted 로 3번이 되는데 iOS 만 비어 있었다.
|
|
9
|
-
import { V1_BASE, apiRequest, authHeadersOrThrow } from './http.js';
|
|
9
|
+
import { V1_BASE, apiRequest, authHeadersOrThrow, isNotFound } from './http.js';
|
|
10
10
|
/** 각 상태가 "지금 출시" 요청을 받을 수 있는지 + 사람이 읽을 설명. */
|
|
11
11
|
const STATE_NOTE = {
|
|
12
12
|
PENDING_DEVELOPER_RELEASE: '심사 통과 후 개발자 출시 대기 — 지금 출시할 수 있다.',
|
|
@@ -60,7 +60,7 @@ async function getPhasedRelease(versionId) {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
catch (err) {
|
|
63
|
-
if (
|
|
63
|
+
if (isNotFound(err))
|
|
64
64
|
return null;
|
|
65
65
|
throw err;
|
|
66
66
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export interface BetaStatus {
|
|
2
|
+
buildId: string;
|
|
3
|
+
internalState?: string;
|
|
4
|
+
externalState?: string;
|
|
5
|
+
note: string;
|
|
6
|
+
autoNotifyEnabled?: boolean;
|
|
7
|
+
submissionState?: string;
|
|
8
|
+
whatsToTestLocales: string[];
|
|
9
|
+
reviewDetail?: {
|
|
10
|
+
id: string;
|
|
11
|
+
complete: boolean;
|
|
12
|
+
missing: string[];
|
|
13
|
+
};
|
|
14
|
+
testInfoLocales?: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 외부 테스트 제출 전 "뭐가 비었는지"를 한 번에 본다.
|
|
18
|
+
* appId 를 함께 주면 앱 단위 항목(심사 정보·테스트 정보)까지 검사한다.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getBetaStatus(args: {
|
|
21
|
+
buildId: string;
|
|
22
|
+
appId?: string;
|
|
23
|
+
}): Promise<BetaStatus>;
|
|
24
|
+
/** 베타 심사 정보 (앱 단위, 단일 리소스). PATCH 만 가능하다 — Apple 이 앱 생성 때 만들어 둔다. */
|
|
25
|
+
export declare function updateBetaReviewDetail(args: {
|
|
26
|
+
appId: string;
|
|
27
|
+
fields: Record<string, string | boolean | undefined>;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
id: string;
|
|
30
|
+
attributes: Record<string, unknown>;
|
|
31
|
+
}>;
|
|
32
|
+
/** 앱 단위 테스트 정보(피드백 이메일·설명 등)를 로케일별로 upsert. */
|
|
33
|
+
export declare function upsertBetaTestInfo(args: {
|
|
34
|
+
appId: string;
|
|
35
|
+
locale: string;
|
|
36
|
+
fields: {
|
|
37
|
+
feedbackEmail?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
marketingUrl?: string;
|
|
40
|
+
privacyPolicyUrl?: string;
|
|
41
|
+
};
|
|
42
|
+
}): Promise<{
|
|
43
|
+
id: string;
|
|
44
|
+
created: boolean;
|
|
45
|
+
locale: string;
|
|
46
|
+
}>;
|
|
47
|
+
/** 빌드 단위 What to Test 를 로케일별로 upsert. */
|
|
48
|
+
export declare function upsertWhatsToTest(args: {
|
|
49
|
+
buildId: string;
|
|
50
|
+
locale: string;
|
|
51
|
+
whatsNew: string;
|
|
52
|
+
}): Promise<{
|
|
53
|
+
id: string;
|
|
54
|
+
created: boolean;
|
|
55
|
+
locale: string;
|
|
56
|
+
}>;
|
|
57
|
+
/** 빌드를 베타 심사에 제출 (외부 테스터 배포 전 필수). */
|
|
58
|
+
export declare function submitBetaReview(buildId: string): Promise<{
|
|
59
|
+
submissionId: string;
|
|
60
|
+
state?: string;
|
|
61
|
+
}>;
|
|
62
|
+
/** 베타 그룹에 빌드를 붙이거나 뗀다. 외부 그룹이면 실제 배포/회수다. */
|
|
63
|
+
export declare function setBetaGroupBuild(args: {
|
|
64
|
+
groupId: string;
|
|
65
|
+
buildId: string;
|
|
66
|
+
action: 'add' | 'remove';
|
|
67
|
+
}): Promise<{
|
|
68
|
+
groupId: string;
|
|
69
|
+
buildId: string;
|
|
70
|
+
action: string;
|
|
71
|
+
}>;
|
|
72
|
+
/** 테스터 초대. 이미 등록된 이메일은 409 가 나므로 개별 결과로 보고한다. */
|
|
73
|
+
export declare function addBetaTesters(args: {
|
|
74
|
+
groupId: string;
|
|
75
|
+
testers: Array<{
|
|
76
|
+
email: string;
|
|
77
|
+
firstName?: string;
|
|
78
|
+
lastName?: string;
|
|
79
|
+
}>;
|
|
80
|
+
}): Promise<Array<{
|
|
81
|
+
email: string;
|
|
82
|
+
ok: boolean;
|
|
83
|
+
testerId?: string;
|
|
84
|
+
error?: string;
|
|
85
|
+
}>>;
|
|
86
|
+
/** 이미 배포된 빌드에 대해 테스터에게 알림을 다시 보낸다. */
|
|
87
|
+
export declare function notifyBetaTesters(buildId: string): Promise<{
|
|
88
|
+
notificationId: string;
|
|
89
|
+
}>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// TestFlight 외부 테스트 — 심사 제출과 배포.
|
|
2
|
+
//
|
|
3
|
+
// 내부 테스터(팀)는 빌드가 처리되면 바로 받지만, **외부 테스터는 Apple 베타 심사를 통과해야** 받는다.
|
|
4
|
+
// 그 심사에 필요한 것이 세 갈래로 흩어져 있다:
|
|
5
|
+
// 앱 단위 betaAppReviewDetail 연락처·데모 계정·심사 노트
|
|
6
|
+
// 앱 단위 betaAppLocalizations 피드백 이메일·앱 설명 (로케일별)
|
|
7
|
+
// 빌드 단위 betaBuildLocalizations What to Test (로케일별)
|
|
8
|
+
// 하나라도 비면 제출이 막히거나 반려된다. 그래서 상태 조회를 "뭐가 비었는지" 중심으로 만든다.
|
|
9
|
+
import { V1_BASE, apiRequest, authHeadersOrThrow, isNotFound } from './http.js';
|
|
10
|
+
/** 외부 빌드 상태 → 사람이 읽을 뜻 + 다음 행동. */
|
|
11
|
+
const EXTERNAL_STATE = {
|
|
12
|
+
PROCESSING: '업로드 처리 중 — 끝날 때까지 기다린다.',
|
|
13
|
+
PROCESSING_EXCEPTION: '처리 실패 — 빌드를 다시 업로드해야 한다.',
|
|
14
|
+
MISSING_EXPORT_COMPLIANCE: '수출 규정 정보 없음 — appstore_declare_encryption 으로 선언하거나 Info.plist 에 ITSAppUsesNonExemptEncryption 을 넣는다.',
|
|
15
|
+
READY_FOR_BETA_SUBMISSION: '베타 심사 제출 가능 — appstore_submit_beta_review.',
|
|
16
|
+
WAITING_FOR_BETA_REVIEW: '베타 심사 대기열.',
|
|
17
|
+
IN_BETA_REVIEW: '베타 심사 진행 중.',
|
|
18
|
+
BETA_APPROVED: '베타 심사 통과 — 외부 그룹에 배포할 수 있다.',
|
|
19
|
+
BETA_REJECTED: '베타 심사 반려 — 사유 확인 후 수정하고 재제출.',
|
|
20
|
+
READY_FOR_BETA_TESTING: '테스트 준비 완료.',
|
|
21
|
+
IN_BETA_TESTING: '외부 테스트 중.',
|
|
22
|
+
EXPIRED: '빌드 만료 — 새 빌드가 필요하다.',
|
|
23
|
+
};
|
|
24
|
+
async function get(path, params) {
|
|
25
|
+
const headers = await authHeadersOrThrow();
|
|
26
|
+
const query = params ? `?${new URLSearchParams(params).toString()}` : '';
|
|
27
|
+
return apiRequest(V1_BASE, `${path}${query}`, headers, { method: 'GET' });
|
|
28
|
+
}
|
|
29
|
+
async function send(method, path, body) {
|
|
30
|
+
const headers = await authHeadersOrThrow();
|
|
31
|
+
return apiRequest(V1_BASE, path, headers, {
|
|
32
|
+
method,
|
|
33
|
+
...(body === undefined
|
|
34
|
+
? {}
|
|
35
|
+
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** to-one 관계는 없을 때 data:null 또는 404 로 온다. */
|
|
39
|
+
async function getOrNull(path) {
|
|
40
|
+
try {
|
|
41
|
+
const r = await get(path);
|
|
42
|
+
return r.data ?? null;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (isNotFound(err))
|
|
46
|
+
return null;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 외부 테스트 제출 전 "뭐가 비었는지"를 한 번에 본다.
|
|
52
|
+
* appId 를 함께 주면 앱 단위 항목(심사 정보·테스트 정보)까지 검사한다.
|
|
53
|
+
*/
|
|
54
|
+
export async function getBetaStatus(args) {
|
|
55
|
+
const { buildId, appId } = args;
|
|
56
|
+
const detail = await getOrNull(`/builds/${buildId}/buildBetaDetail`);
|
|
57
|
+
const submission = await getOrNull(`/builds/${buildId}/betaAppReviewSubmission`);
|
|
58
|
+
const locs = await get(`/builds/${buildId}/betaBuildLocalizations`);
|
|
59
|
+
const externalState = detail?.attributes?.externalBuildState;
|
|
60
|
+
const status = {
|
|
61
|
+
buildId,
|
|
62
|
+
internalState: detail?.attributes?.internalBuildState,
|
|
63
|
+
externalState,
|
|
64
|
+
note: (externalState && EXTERNAL_STATE[externalState]) || '',
|
|
65
|
+
autoNotifyEnabled: detail?.attributes?.autoNotifyEnabled,
|
|
66
|
+
submissionState: submission?.attributes?.betaReviewState,
|
|
67
|
+
whatsToTestLocales: (locs.data ?? [])
|
|
68
|
+
.filter((l) => (l.attributes?.whatsNew ?? '').trim().length > 0)
|
|
69
|
+
.map((l) => l.attributes?.locale ?? '?'),
|
|
70
|
+
};
|
|
71
|
+
if (appId) {
|
|
72
|
+
const rd = await getOrNull(`/apps/${appId}/betaAppReviewDetail`);
|
|
73
|
+
if (rd) {
|
|
74
|
+
const a = rd.attributes ?? {};
|
|
75
|
+
const missing = [];
|
|
76
|
+
for (const f of ['contactFirstName', 'contactLastName', 'contactPhone', 'contactEmail']) {
|
|
77
|
+
if (!a[f])
|
|
78
|
+
missing.push(f);
|
|
79
|
+
}
|
|
80
|
+
if (a.demoAccountRequired && (!a.demoAccountName || !a.demoAccountPassword)) {
|
|
81
|
+
missing.push('demoAccountName/demoAccountPassword (demoAccountRequired=true 인데 비어 있음)');
|
|
82
|
+
}
|
|
83
|
+
status.reviewDetail = { id: rd.id, complete: missing.length === 0, missing };
|
|
84
|
+
}
|
|
85
|
+
const appLocs = await get(`/apps/${appId}/betaAppLocalizations`);
|
|
86
|
+
status.testInfoLocales = (appLocs.data ?? []).map((l) => l.attributes?.locale ?? '?');
|
|
87
|
+
}
|
|
88
|
+
return status;
|
|
89
|
+
}
|
|
90
|
+
/** 베타 심사 정보 (앱 단위, 단일 리소스). PATCH 만 가능하다 — Apple 이 앱 생성 때 만들어 둔다. */
|
|
91
|
+
export async function updateBetaReviewDetail(args) {
|
|
92
|
+
const attributes = Object.fromEntries(Object.entries(args.fields).filter(([, v]) => v !== undefined));
|
|
93
|
+
if (Object.keys(attributes).length === 0)
|
|
94
|
+
throw new Error('바꿀 항목이 없다.');
|
|
95
|
+
const rd = await getOrNull(`/apps/${args.appId}/betaAppReviewDetail`);
|
|
96
|
+
if (!rd)
|
|
97
|
+
throw new Error(`앱 ${args.appId} 의 betaAppReviewDetail 을 찾지 못했다.`);
|
|
98
|
+
await send('PATCH', `/betaAppReviewDetails/${rd.id}`, {
|
|
99
|
+
data: { type: 'betaAppReviewDetails', id: rd.id, attributes },
|
|
100
|
+
});
|
|
101
|
+
const after = await getOrNull(`/apps/${args.appId}/betaAppReviewDetail`);
|
|
102
|
+
return { id: rd.id, attributes: after?.attributes ?? {} };
|
|
103
|
+
}
|
|
104
|
+
/** 앱 단위 테스트 정보(피드백 이메일·설명 등)를 로케일별로 upsert. */
|
|
105
|
+
export async function upsertBetaTestInfo(args) {
|
|
106
|
+
const { appId, locale, fields } = args;
|
|
107
|
+
const existing = await get(`/apps/${appId}/betaAppLocalizations`);
|
|
108
|
+
const hit = (existing.data ?? []).find((l) => l.attributes?.locale === locale);
|
|
109
|
+
const attributes = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
110
|
+
if (hit) {
|
|
111
|
+
await send('PATCH', `/betaAppLocalizations/${hit.id}`, {
|
|
112
|
+
data: { type: 'betaAppLocalizations', id: hit.id, attributes },
|
|
113
|
+
});
|
|
114
|
+
return { id: hit.id, created: false, locale };
|
|
115
|
+
}
|
|
116
|
+
const created = await send('POST', '/betaAppLocalizations', {
|
|
117
|
+
data: {
|
|
118
|
+
type: 'betaAppLocalizations',
|
|
119
|
+
attributes: { ...attributes, locale },
|
|
120
|
+
relationships: { app: { data: { type: 'apps', id: appId } } },
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
return { id: created.data?.id ?? '', created: true, locale };
|
|
124
|
+
}
|
|
125
|
+
/** 빌드 단위 What to Test 를 로케일별로 upsert. */
|
|
126
|
+
export async function upsertWhatsToTest(args) {
|
|
127
|
+
const { buildId, locale, whatsNew } = args;
|
|
128
|
+
const existing = await get(`/builds/${buildId}/betaBuildLocalizations`);
|
|
129
|
+
const hit = (existing.data ?? []).find((l) => l.attributes?.locale === locale);
|
|
130
|
+
if (hit) {
|
|
131
|
+
await send('PATCH', `/betaBuildLocalizations/${hit.id}`, {
|
|
132
|
+
data: { type: 'betaBuildLocalizations', id: hit.id, attributes: { whatsNew } },
|
|
133
|
+
});
|
|
134
|
+
return { id: hit.id, created: false, locale };
|
|
135
|
+
}
|
|
136
|
+
const created = await send('POST', '/betaBuildLocalizations', {
|
|
137
|
+
data: {
|
|
138
|
+
type: 'betaBuildLocalizations',
|
|
139
|
+
attributes: { whatsNew, locale },
|
|
140
|
+
relationships: { build: { data: { type: 'builds', id: buildId } } },
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return { id: created.data?.id ?? '', created: true, locale };
|
|
144
|
+
}
|
|
145
|
+
/** 빌드를 베타 심사에 제출 (외부 테스터 배포 전 필수). */
|
|
146
|
+
export async function submitBetaReview(buildId) {
|
|
147
|
+
const created = await send('POST', '/betaAppReviewSubmissions', {
|
|
148
|
+
data: {
|
|
149
|
+
type: 'betaAppReviewSubmissions',
|
|
150
|
+
relationships: { build: { data: { type: 'builds', id: buildId } } },
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
submissionId: created.data?.id ?? '',
|
|
155
|
+
state: created.data?.attributes?.betaReviewState,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** 베타 그룹에 빌드를 붙이거나 뗀다. 외부 그룹이면 실제 배포/회수다. */
|
|
159
|
+
export async function setBetaGroupBuild(args) {
|
|
160
|
+
const { groupId, buildId, action } = args;
|
|
161
|
+
await send(action === 'add' ? 'POST' : 'DELETE', `/betaGroups/${groupId}/relationships/builds`, {
|
|
162
|
+
data: [{ type: 'builds', id: buildId }],
|
|
163
|
+
});
|
|
164
|
+
return { groupId, buildId, action };
|
|
165
|
+
}
|
|
166
|
+
/** 테스터 초대. 이미 등록된 이메일은 409 가 나므로 개별 결과로 보고한다. */
|
|
167
|
+
export async function addBetaTesters(args) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const t of args.testers) {
|
|
170
|
+
try {
|
|
171
|
+
const created = await send('POST', '/betaTesters', {
|
|
172
|
+
data: {
|
|
173
|
+
type: 'betaTesters',
|
|
174
|
+
attributes: {
|
|
175
|
+
email: t.email,
|
|
176
|
+
...(t.firstName ? { firstName: t.firstName } : {}),
|
|
177
|
+
...(t.lastName ? { lastName: t.lastName } : {}),
|
|
178
|
+
},
|
|
179
|
+
relationships: { betaGroups: { data: [{ type: 'betaGroups', id: args.groupId }] } },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
out.push({ email: t.email, ok: true, testerId: created.data?.id });
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
out.push({ email: t.email, ok: false, error: err.message });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/** 이미 배포된 빌드에 대해 테스터에게 알림을 다시 보낸다. */
|
|
191
|
+
export async function notifyBetaTesters(buildId) {
|
|
192
|
+
const created = await send('POST', '/buildBetaNotifications', {
|
|
193
|
+
data: {
|
|
194
|
+
type: 'buildBetaNotifications',
|
|
195
|
+
relationships: { build: { data: { type: 'builds', id: buildId } } },
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
return { notificationId: created.data?.id ?? '' };
|
|
199
|
+
}
|
|
@@ -168,6 +168,44 @@ export declare function updatePurchaseOptionState(auth: OAuth2Client | JWT, pack
|
|
|
168
168
|
state: string | undefined;
|
|
169
169
|
}[];
|
|
170
170
|
}>;
|
|
171
|
+
export interface RecoveryTargeting {
|
|
172
|
+
/** 전체 사용자 대상. versionCodes / 범위와 함께 쓸 수 없다. */
|
|
173
|
+
allUsers?: boolean;
|
|
174
|
+
/** 특정 버전 코드들 */
|
|
175
|
+
versionCodes?: string[];
|
|
176
|
+
/** 버전 코드 범위 (start~end) */
|
|
177
|
+
versionRange?: {
|
|
178
|
+
start: string;
|
|
179
|
+
end: string;
|
|
180
|
+
};
|
|
181
|
+
/** 지역 코드 (예: KR, US) */
|
|
182
|
+
regions?: string[];
|
|
183
|
+
/** Android SDK 레벨 */
|
|
184
|
+
sdkLevels?: string[];
|
|
185
|
+
}
|
|
186
|
+
export declare function buildTargeting(t: RecoveryTargeting): Record<string, unknown>;
|
|
187
|
+
export declare function listRecoveryActions(auth: OAuth2Client | JWT, packageName: string, versionCode?: string): Promise<{
|
|
188
|
+
id: string | null | undefined;
|
|
189
|
+
status: string | null | undefined;
|
|
190
|
+
createTime: string | null | undefined;
|
|
191
|
+
deployTime: string | null | undefined;
|
|
192
|
+
cancelTime: string | null | undefined;
|
|
193
|
+
targeting: import("googleapis/build/src/apis/androidpublisher/v3.js").androidpublisher_v3.Schema$Targeting | undefined;
|
|
194
|
+
}[]>;
|
|
195
|
+
/** DRAFT 상태로만 만든다 — 실제 배포는 deployRecoveryAction 이 별도로 한다. */
|
|
196
|
+
export declare function createRecoveryAction(auth: OAuth2Client | JWT, packageName: string, targeting: RecoveryTargeting): Promise<{
|
|
197
|
+
id: string | null | undefined;
|
|
198
|
+
status: string | null | undefined;
|
|
199
|
+
targeting: import("googleapis/build/src/apis/androidpublisher/v3.js").androidpublisher_v3.Schema$Targeting | undefined;
|
|
200
|
+
}>;
|
|
201
|
+
export declare function deployRecoveryAction(auth: OAuth2Client | JWT, packageName: string, appRecoveryId: string): Promise<{
|
|
202
|
+
appRecoveryId: string;
|
|
203
|
+
deployed: boolean;
|
|
204
|
+
}>;
|
|
205
|
+
export declare function cancelRecoveryAction(auth: OAuth2Client | JWT, packageName: string, appRecoveryId: string): Promise<{
|
|
206
|
+
appRecoveryId: string;
|
|
207
|
+
cancelled: boolean;
|
|
208
|
+
}>;
|
|
171
209
|
export declare function uploadDataSafety(auth: OAuth2Client | JWT, packageName: string, safetyLabelsCsv: string): Promise<{
|
|
172
210
|
packageName: string;
|
|
173
211
|
bytes: number;
|
package/dist/playstore/tools.js
CHANGED
|
@@ -650,9 +650,79 @@ export async function updatePurchaseOptionState(auth, packageName, productId, pu
|
|
|
650
650
|
})));
|
|
651
651
|
return { productId, purchaseOptionId, action, states };
|
|
652
652
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
653
|
+
export function buildTargeting(t) {
|
|
654
|
+
const targeting = {};
|
|
655
|
+
if (t.allUsers)
|
|
656
|
+
targeting.allUsers = { isAllUsersRequested: true };
|
|
657
|
+
if (t.versionCodes?.length)
|
|
658
|
+
targeting.versionList = { versionCodes: t.versionCodes };
|
|
659
|
+
if (t.versionRange) {
|
|
660
|
+
targeting.versionRange = {
|
|
661
|
+
versionCodeStart: t.versionRange.start,
|
|
662
|
+
versionCodeEnd: t.versionRange.end,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (t.regions?.length)
|
|
666
|
+
targeting.regions = { regionCode: t.regions };
|
|
667
|
+
if (t.sdkLevels?.length)
|
|
668
|
+
targeting.androidSdks = { sdkLevels: t.sdkLevels };
|
|
669
|
+
if (Object.keys(targeting).length === 0) {
|
|
670
|
+
throw new Error('대상(targeting)이 비어 있다 — allUsers / versionCodes / versionRange 중 하나는 필요하다.');
|
|
671
|
+
}
|
|
672
|
+
if (t.allUsers && (t.versionCodes?.length || t.versionRange)) {
|
|
673
|
+
throw new Error('allUsers 와 versionCodes/versionRange 는 함께 쓸 수 없다.');
|
|
674
|
+
}
|
|
675
|
+
return targeting;
|
|
676
|
+
}
|
|
677
|
+
export async function listRecoveryActions(auth, packageName, versionCode) {
|
|
678
|
+
const res = await publisher().apprecovery.list({
|
|
679
|
+
auth,
|
|
680
|
+
packageName,
|
|
681
|
+
...(versionCode ? { versionCode } : {}),
|
|
682
|
+
});
|
|
683
|
+
return (res.data.recoveryActions ?? []).map((a) => ({
|
|
684
|
+
id: a.appRecoveryId,
|
|
685
|
+
status: a.status,
|
|
686
|
+
createTime: a.createTime,
|
|
687
|
+
deployTime: a.deployTime,
|
|
688
|
+
cancelTime: a.cancelTime,
|
|
689
|
+
targeting: a.targeting,
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
692
|
+
/** DRAFT 상태로만 만든다 — 실제 배포는 deployRecoveryAction 이 별도로 한다. */
|
|
693
|
+
export async function createRecoveryAction(auth, packageName, targeting) {
|
|
694
|
+
const res = await publisher().apprecovery.create({
|
|
695
|
+
auth,
|
|
696
|
+
packageName,
|
|
697
|
+
requestBody: {
|
|
698
|
+
remoteInAppUpdate: { isRemoteInAppUpdateRequested: true },
|
|
699
|
+
targeting: buildTargeting(targeting),
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
return {
|
|
703
|
+
id: res.data.appRecoveryId,
|
|
704
|
+
status: res.data.status,
|
|
705
|
+
targeting: res.data.targeting,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
export async function deployRecoveryAction(auth, packageName, appRecoveryId) {
|
|
709
|
+
await publisher().apprecovery.deploy({
|
|
710
|
+
auth,
|
|
711
|
+
packageName,
|
|
712
|
+
appRecoveryId,
|
|
713
|
+
requestBody: {},
|
|
714
|
+
});
|
|
715
|
+
return { appRecoveryId, deployed: true };
|
|
716
|
+
}
|
|
717
|
+
export async function cancelRecoveryAction(auth, packageName, appRecoveryId) {
|
|
718
|
+
await publisher().apprecovery.cancel({
|
|
719
|
+
auth,
|
|
720
|
+
packageName,
|
|
721
|
+
appRecoveryId,
|
|
722
|
+
requestBody: {},
|
|
723
|
+
});
|
|
724
|
+
return { appRecoveryId, cancelled: true };
|
|
725
|
+
}
|
|
656
726
|
// ─── 데이터 안전(Safety Labels) 선언 ───
|
|
657
727
|
//
|
|
658
728
|
// 오랫동안 Console 전용이라고 알려졌지만 API 가 생겼다 (POST applications/{pkg}/dataSafety).
|
|
@@ -5,6 +5,8 @@ import * as appstoreProductReview from '../appstore/product-review.js';
|
|
|
5
5
|
import * as appstoreProductLocalization from '../appstore/product-localization.js';
|
|
6
6
|
import * as appstoreRelease from '../appstore/release.js';
|
|
7
7
|
import * as appstoreDeclarations from '../appstore/declarations.js';
|
|
8
|
+
import * as testflight from '../appstore/testflight.js';
|
|
9
|
+
import * as previews from '../appstore/previews.js';
|
|
8
10
|
import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
|
|
9
11
|
import { requireAppStoreCreds } from '../helpers.js';
|
|
10
12
|
import { buildAppStoreReleasePlan } from '../checks/plan.js';
|
|
@@ -1201,4 +1203,326 @@ export function registerAppstoreTools(server) {
|
|
|
1201
1203
|
}],
|
|
1202
1204
|
};
|
|
1203
1205
|
});
|
|
1206
|
+
// ─── TestFlight 외부 테스트 ───
|
|
1207
|
+
// 내부 테스터는 빌드 처리 후 바로 받지만, 외부 테스터는 Apple 베타 심사를 통과해야 한다.
|
|
1208
|
+
// 심사에 필요한 항목이 앱 단위(심사 정보·테스트 정보)와 빌드 단위(What to Test)로 흩어져 있다.
|
|
1209
|
+
server.tool('appstore_beta_status', [
|
|
1210
|
+
'TestFlight 외부 테스트 제출 전 점검 — 읽기 전용. 빌드의 internal/external 상태,',
|
|
1211
|
+
'베타 심사 제출 상태, What to Test 가 채워진 로케일을 보여준다.',
|
|
1212
|
+
'appId 를 함께 주면 앱 단위 항목(베타 심사 연락처·데모 계정, 테스트 정보 로케일)까지 검사해 빠진 필드를 알려준다.',
|
|
1213
|
+
'외부 배포가 막히면 여기부터 본다 — 대부분 수출 규정 미선언이나 심사 정보 공란이다.',
|
|
1214
|
+
].join(' '), {
|
|
1215
|
+
buildId: z.string().describe('빌드 ID (appstore_list_builds 결과)'),
|
|
1216
|
+
appId: z.string().optional().describe('앱 ID — 주면 앱 단위 항목까지 함께 점검'),
|
|
1217
|
+
}, async ({ buildId, appId }) => {
|
|
1218
|
+
const s = await testflight.getBetaStatus({ buildId, appId });
|
|
1219
|
+
const lines = [
|
|
1220
|
+
`빌드 ${buildId}`,
|
|
1221
|
+
` 내부 상태: ${s.internalState ?? '?'}`,
|
|
1222
|
+
` 외부 상태: ${s.externalState ?? '?'}${s.note ? ` — ${s.note}` : ''}`,
|
|
1223
|
+
s.submissionState ? ` 베타 심사 제출: ${s.submissionState}` : ' 베타 심사 제출: 없음',
|
|
1224
|
+
` What to Test: ${s.whatsToTestLocales.length ? s.whatsToTestLocales.join(', ') : '❌ 비어 있음 (외부 배포 필수)'}`,
|
|
1225
|
+
` 자동 알림: ${s.autoNotifyEnabled === undefined ? '?' : s.autoNotifyEnabled}`,
|
|
1226
|
+
];
|
|
1227
|
+
if (s.reviewDetail) {
|
|
1228
|
+
lines.push(s.reviewDetail.complete
|
|
1229
|
+
? ' 베타 심사 정보: ✅ 채워짐'
|
|
1230
|
+
: ` 베타 심사 정보: ❌ 누락 — ${s.reviewDetail.missing.join(', ')}`);
|
|
1231
|
+
}
|
|
1232
|
+
if (s.testInfoLocales) {
|
|
1233
|
+
lines.push(` 테스트 정보 로케일: ${s.testInfoLocales.length ? s.testInfoLocales.join(', ') : '❌ 없음'}`);
|
|
1234
|
+
}
|
|
1235
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1236
|
+
});
|
|
1237
|
+
server.tool('appstore_update_beta_review_detail', [
|
|
1238
|
+
'TestFlight 베타 심사 정보(연락처·데모 계정·심사 노트)를 채운다 — PATCH /v1/betaAppReviewDetails/{id}.',
|
|
1239
|
+
'앱 단위 단일 리소스이며 외부 테스트 심사 제출 전 필수다. 넘긴 필드만 바뀐다.',
|
|
1240
|
+
'로그인이 필요한 앱이면 demoAccountRequired=true 와 계정/비밀번호를 반드시 함께 넣는다 — 없으면 반려된다.',
|
|
1241
|
+
].join(' '), {
|
|
1242
|
+
appId: z.string().describe('App Store 앱 ID'),
|
|
1243
|
+
contactFirstName: z.string().optional().describe('심사 연락처 이름'),
|
|
1244
|
+
contactLastName: z.string().optional().describe('심사 연락처 성'),
|
|
1245
|
+
contactPhone: z.string().optional().describe('심사 연락처 전화번호'),
|
|
1246
|
+
contactEmail: z.string().optional().describe('심사 연락처 이메일'),
|
|
1247
|
+
demoAccountRequired: z.boolean().optional().describe('심사에 데모 계정이 필요한가'),
|
|
1248
|
+
demoAccountName: z.string().optional().describe('데모 계정 ID'),
|
|
1249
|
+
demoAccountPassword: z.string().optional().describe('데모 계정 비밀번호'),
|
|
1250
|
+
notes: z.string().optional().describe('심사자에게 남길 메모'),
|
|
1251
|
+
}, async ({ appId, ...fields }) => {
|
|
1252
|
+
const r = await testflight.updateBetaReviewDetail({ appId, fields });
|
|
1253
|
+
const changed = Object.keys(fields).filter((k) => fields[k] !== undefined);
|
|
1254
|
+
return {
|
|
1255
|
+
content: [{
|
|
1256
|
+
type: 'text',
|
|
1257
|
+
// 비밀번호는 값을 되읽어 출력하지 않는다 — 채워졌는지만 알린다.
|
|
1258
|
+
text: [`✅ 베타 심사 정보 갱신 (${r.id})`, ...changed.map((k) => k === 'demoAccountPassword' ? ' demoAccountPassword: (설정됨)' : ` ${k}: ${String(r.attributes[k] ?? '')}`)].join('\n'),
|
|
1259
|
+
}],
|
|
1260
|
+
};
|
|
1261
|
+
});
|
|
1262
|
+
server.tool('appstore_update_beta_test_info', [
|
|
1263
|
+
'TestFlight 테스트 정보(피드백 이메일·앱 설명 등)를 로케일별로 저장한다 — betaAppLocalizations upsert.',
|
|
1264
|
+
'해당 로케일이 있으면 PATCH, 없으면 POST 한다.',
|
|
1265
|
+
'외부 테스트에는 feedbackEmail 과 description 이 필요하다.',
|
|
1266
|
+
].join(' '), {
|
|
1267
|
+
appId: z.string().describe('App Store 앱 ID'),
|
|
1268
|
+
locale: z.string().describe('로케일 (예: ko, en-US)'),
|
|
1269
|
+
feedbackEmail: z.string().optional().describe('테스터 피드백 수신 이메일'),
|
|
1270
|
+
description: z.string().optional().describe('테스터에게 보여줄 앱 설명'),
|
|
1271
|
+
marketingUrl: z.string().optional().describe('마케팅 URL'),
|
|
1272
|
+
privacyPolicyUrl: z.string().optional().describe('개인정보처리방침 URL'),
|
|
1273
|
+
}, async ({ appId, locale, ...fields }) => {
|
|
1274
|
+
const r = await testflight.upsertBetaTestInfo({ appId, locale, fields });
|
|
1275
|
+
return {
|
|
1276
|
+
content: [{
|
|
1277
|
+
type: 'text',
|
|
1278
|
+
text: `✅ 테스트 정보 ${r.created ? '생성' : '수정'} — ${r.locale} (${r.id})`,
|
|
1279
|
+
}],
|
|
1280
|
+
};
|
|
1281
|
+
});
|
|
1282
|
+
server.tool('appstore_update_whats_to_test', [
|
|
1283
|
+
'빌드의 What to Test 를 로케일별로 저장한다 — betaBuildLocalizations upsert.',
|
|
1284
|
+
'외부 테스터 배포에는 필수다. 비어 있으면 베타 심사에서 막힌다.',
|
|
1285
|
+
'버전 릴리스 노트(appstore_update_whats_new)와는 별개다 — 이건 TestFlight 전용이다.',
|
|
1286
|
+
].join(' '), {
|
|
1287
|
+
buildId: z.string().describe('빌드 ID'),
|
|
1288
|
+
locale: z.string().describe('로케일 (예: ko, en-US)'),
|
|
1289
|
+
whatsNew: z.string().describe('이번 빌드에서 테스트할 내용'),
|
|
1290
|
+
}, async ({ buildId, locale, whatsNew }) => {
|
|
1291
|
+
const r = await testflight.upsertWhatsToTest({ buildId, locale, whatsNew });
|
|
1292
|
+
return {
|
|
1293
|
+
content: [{
|
|
1294
|
+
type: 'text',
|
|
1295
|
+
text: `✅ What to Test ${r.created ? '생성' : '수정'} — ${r.locale} (${r.id})`,
|
|
1296
|
+
}],
|
|
1297
|
+
};
|
|
1298
|
+
});
|
|
1299
|
+
server.tool('appstore_submit_beta_review', [
|
|
1300
|
+
'빌드를 TestFlight 베타 심사에 제출한다 — POST /v1/betaAppReviewSubmissions.',
|
|
1301
|
+
'외부 테스터에게 배포하려면 이 심사를 통과해야 한다 (내부 테스터는 불필요).',
|
|
1302
|
+
'사전 조건: 외부 상태가 READY_FOR_BETA_SUBMISSION, What to Test 채움, 베타 심사 정보 채움.',
|
|
1303
|
+
'appstore_beta_status 로 먼저 점검할 것. confirm 생략/false 면 현재 상태만 보여주는 dry-run.',
|
|
1304
|
+
].join(' '), {
|
|
1305
|
+
buildId: z.string().describe('빌드 ID'),
|
|
1306
|
+
appId: z.string().optional().describe('앱 ID — dry-run 점검을 앱 단위 항목까지 확장'),
|
|
1307
|
+
confirm: z.boolean().optional().describe('true 명시 시에만 실제 제출'),
|
|
1308
|
+
}, async ({ buildId, appId, confirm }) => {
|
|
1309
|
+
if (!confirm) {
|
|
1310
|
+
const s = await testflight.getBetaStatus({ buildId, appId });
|
|
1311
|
+
const blockers = [];
|
|
1312
|
+
if (s.externalState !== 'READY_FOR_BETA_SUBMISSION') {
|
|
1313
|
+
blockers.push(`외부 상태가 ${s.externalState ?? '?'} — ${s.note || '제출 가능 상태가 아니다'}`);
|
|
1314
|
+
}
|
|
1315
|
+
if (s.whatsToTestLocales.length === 0)
|
|
1316
|
+
blockers.push('What to Test 가 비어 있다');
|
|
1317
|
+
if (s.reviewDetail && !s.reviewDetail.complete) {
|
|
1318
|
+
blockers.push(`베타 심사 정보 누락: ${s.reviewDetail.missing.join(', ')}`);
|
|
1319
|
+
}
|
|
1320
|
+
return {
|
|
1321
|
+
content: [{
|
|
1322
|
+
type: 'text',
|
|
1323
|
+
text: [
|
|
1324
|
+
'🛑 베타 심사 제출 dry-run — 아직 제출하지 않았다.',
|
|
1325
|
+
` 빌드: ${buildId} (${s.externalState ?? '?'})`,
|
|
1326
|
+
blockers.length ? ' 블로커:' : ' 블로커 없음.',
|
|
1327
|
+
...blockers.map((b) => ` - ${b}`),
|
|
1328
|
+
'',
|
|
1329
|
+
blockers.length ? '위 항목을 먼저 해결할 것.' : '제출하려면 confirm: true 로 다시 호출.',
|
|
1330
|
+
].join('\n'),
|
|
1331
|
+
}],
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
const r = await testflight.submitBetaReview(buildId);
|
|
1335
|
+
return {
|
|
1336
|
+
content: [{
|
|
1337
|
+
type: 'text',
|
|
1338
|
+
text: [
|
|
1339
|
+
'✅ 베타 심사 제출',
|
|
1340
|
+
` submissionId: ${r.submissionId}`,
|
|
1341
|
+
` 상태: ${r.state ?? '(응답에 없음)'}`,
|
|
1342
|
+
'진행 상황은 appstore_beta_status 로 확인.',
|
|
1343
|
+
].join('\n'),
|
|
1344
|
+
}],
|
|
1345
|
+
};
|
|
1346
|
+
});
|
|
1347
|
+
server.tool('appstore_set_beta_group_build', [
|
|
1348
|
+
'베타 그룹에 빌드를 붙이거나 뗀다 — POST/DELETE /v1/betaGroups/{id}/relationships/builds.',
|
|
1349
|
+
'⚠️ 외부 그룹에 붙이는 것은 **실제 배포**다 (심사 통과 후에만 가능). 떼면 테스터가 더 이상 받지 못한다.',
|
|
1350
|
+
'groupId 는 appstore_list_beta_groups 결과. confirm 생략/false 면 dry-run.',
|
|
1351
|
+
].join(' '), {
|
|
1352
|
+
groupId: z.string().describe('베타 그룹 ID'),
|
|
1353
|
+
buildId: z.string().describe('빌드 ID'),
|
|
1354
|
+
action: z.enum(['add', 'remove']).describe('붙이기 / 떼기'),
|
|
1355
|
+
confirm: z.boolean().optional().describe('true 명시 시에만 실행'),
|
|
1356
|
+
}, async ({ groupId, buildId, action, confirm }) => {
|
|
1357
|
+
if (!confirm) {
|
|
1358
|
+
return {
|
|
1359
|
+
content: [{
|
|
1360
|
+
type: 'text',
|
|
1361
|
+
text: [
|
|
1362
|
+
`🛑 dry-run — 아직 실행하지 않았다.`,
|
|
1363
|
+
` 그룹 ${groupId} ${action === 'add' ? '←' : '↛'} 빌드 ${buildId}`,
|
|
1364
|
+
action === 'add'
|
|
1365
|
+
? ' 외부 그룹이면 이 순간 테스터에게 배포된다.'
|
|
1366
|
+
: ' 테스터는 이 빌드를 더 이상 설치할 수 없게 된다.',
|
|
1367
|
+
'',
|
|
1368
|
+
'실행하려면 confirm: true 로 다시 호출.',
|
|
1369
|
+
].join('\n'),
|
|
1370
|
+
}],
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
const r = await testflight.setBetaGroupBuild({ groupId, buildId, action });
|
|
1374
|
+
return {
|
|
1375
|
+
content: [{
|
|
1376
|
+
type: 'text',
|
|
1377
|
+
text: `✅ 그룹 ${r.groupId} ${r.action === 'add' ? '에 빌드 추가' : '에서 빌드 제거'} — ${r.buildId}`,
|
|
1378
|
+
}],
|
|
1379
|
+
};
|
|
1380
|
+
});
|
|
1381
|
+
server.tool('appstore_add_beta_testers', [
|
|
1382
|
+
'베타 그룹에 테스터를 초대한다 — POST /v1/betaTesters.',
|
|
1383
|
+
'이메일별로 개별 호출하며, 이미 등록된 주소는 실패로 표시하고 나머지는 계속 진행한다.',
|
|
1384
|
+
'⚠️ 초대 메일이 즉시 발송된다 — 주소를 사용자에게 확인받고 실행할 것. confirm 필요.',
|
|
1385
|
+
].join(' '), {
|
|
1386
|
+
groupId: z.string().describe('베타 그룹 ID (appstore_list_beta_groups 결과)'),
|
|
1387
|
+
testers: z
|
|
1388
|
+
.array(z.object({
|
|
1389
|
+
email: z.string().describe('테스터 이메일'),
|
|
1390
|
+
firstName: z.string().optional(),
|
|
1391
|
+
lastName: z.string().optional(),
|
|
1392
|
+
}))
|
|
1393
|
+
.min(1)
|
|
1394
|
+
.describe('초대할 테스터 목록'),
|
|
1395
|
+
confirm: z.boolean().optional().describe('true 명시 시에만 초대 발송'),
|
|
1396
|
+
}, async ({ groupId, testers, confirm }) => {
|
|
1397
|
+
if (!confirm) {
|
|
1398
|
+
return {
|
|
1399
|
+
content: [{
|
|
1400
|
+
type: 'text',
|
|
1401
|
+
text: [
|
|
1402
|
+
`🛑 dry-run — ${testers.length}명, 아직 초대하지 않았다.`,
|
|
1403
|
+
...testers.map((t) => ` ${t.email}`),
|
|
1404
|
+
'',
|
|
1405
|
+
'실행하려면 confirm: true 로 다시 호출. 초대 메일이 즉시 발송된다.',
|
|
1406
|
+
].join('\n'),
|
|
1407
|
+
}],
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
const results = await testflight.addBetaTesters({ groupId, testers });
|
|
1411
|
+
const ok = results.filter((r) => r.ok).length;
|
|
1412
|
+
return {
|
|
1413
|
+
content: [{
|
|
1414
|
+
type: 'text',
|
|
1415
|
+
text: [
|
|
1416
|
+
`테스터 초대: 성공 ${ok} / 실패 ${results.length - ok}`,
|
|
1417
|
+
...results.filter((r) => !r.ok).map((r) => ` ✗ ${r.email}: ${r.error}`),
|
|
1418
|
+
].join('\n'),
|
|
1419
|
+
}],
|
|
1420
|
+
};
|
|
1421
|
+
});
|
|
1422
|
+
server.tool('appstore_notify_beta_testers', [
|
|
1423
|
+
'이미 배포된 빌드에 대해 테스터에게 알림을 다시 보낸다 — POST /v1/buildBetaNotifications.',
|
|
1424
|
+
'자동 알림(autoNotifyEnabled)이 꺼져 있거나, 배포 후 다시 알리고 싶을 때.',
|
|
1425
|
+
'⚠️ 테스터 전원에게 푸시/메일이 나간다. confirm 필요.',
|
|
1426
|
+
].join(' '), {
|
|
1427
|
+
buildId: z.string().describe('빌드 ID'),
|
|
1428
|
+
confirm: z.boolean().optional().describe('true 명시 시에만 발송'),
|
|
1429
|
+
}, async ({ buildId, confirm }) => {
|
|
1430
|
+
if (!confirm) {
|
|
1431
|
+
return {
|
|
1432
|
+
content: [{
|
|
1433
|
+
type: 'text',
|
|
1434
|
+
text: `🛑 dry-run — 빌드 ${buildId} 의 테스터 전원에게 알림을 보낼 참이다. confirm: true 로 다시 호출.`,
|
|
1435
|
+
}],
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
const r = await testflight.notifyBetaTesters(buildId);
|
|
1439
|
+
return { content: [{ type: 'text', text: `✅ 알림 발송 (${r.notificationId})` }] };
|
|
1440
|
+
});
|
|
1441
|
+
// ─── 제품 페이지 미리보기 동영상 ───
|
|
1442
|
+
// 스크린샷과 같은 4단계 업로드지만, 커밋 후 Apple 인코딩이 남는다 — 업로드 성공 ≠ 노출 가능.
|
|
1443
|
+
const PREVIEW_TYPES = [
|
|
1444
|
+
'IPHONE_67', 'IPHONE_61', 'IPHONE_65', 'IPHONE_58', 'IPHONE_55', 'IPHONE_47', 'IPHONE_40', 'IPHONE_35',
|
|
1445
|
+
'IPAD_PRO_3GEN_129', 'IPAD_PRO_3GEN_11', 'IPAD_PRO_129', 'IPAD_105', 'IPAD_97',
|
|
1446
|
+
'DESKTOP', 'APPLE_TV', 'APPLE_VISION_PRO',
|
|
1447
|
+
];
|
|
1448
|
+
server.tool('appstore_list_previews', [
|
|
1449
|
+
'버전 로케일의 미리보기 동영상 세트를 조회한다 — 읽기 전용.',
|
|
1450
|
+
'세트별 previewType 과 각 동영상의 처리 상태(assetDeliveryState)·포스터 프레임·videoUrl 을 보여준다.',
|
|
1451
|
+
'업로드 직후 인코딩이 끝났는지 확인할 때도 이걸 쓴다.',
|
|
1452
|
+
].join(' '), {
|
|
1453
|
+
localizationId: z
|
|
1454
|
+
.string()
|
|
1455
|
+
.describe('appStoreVersionLocalization ID (appstore_get_metadata 결과의 로케일별 id)'),
|
|
1456
|
+
}, async ({ localizationId }) => {
|
|
1457
|
+
const sets = await previews.listPreviewSets(localizationId);
|
|
1458
|
+
if (sets.length === 0) {
|
|
1459
|
+
return { content: [{ type: 'text', text: '미리보기 세트 없음.' }] };
|
|
1460
|
+
}
|
|
1461
|
+
const lines = [];
|
|
1462
|
+
for (const s of sets) {
|
|
1463
|
+
lines.push(`${s.previewType ?? '?'} (set ${s.id}) — ${s.previews.length}개`);
|
|
1464
|
+
for (const p of s.previews) {
|
|
1465
|
+
lines.push(` ${p.id}: ${p.fileName ?? '?'} · ${p.state ?? '상태 미상'}` +
|
|
1466
|
+
(p.previewFrameTimeCode ? ` · 포스터 ${p.previewFrameTimeCode}` : '') +
|
|
1467
|
+
(p.videoUrl ? ' · 재생 가능' : ''));
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1471
|
+
});
|
|
1472
|
+
server.tool('appstore_upload_preview', [
|
|
1473
|
+
'제품 페이지 미리보기 **동영상**을 업로드한다 — appPreviewSets/appPreviews 4단계 업로드.',
|
|
1474
|
+
'previewType 세트가 없으면 만들고, 예약 → 조각 업로드 → 커밋까지 한 번에 처리한다.',
|
|
1475
|
+
'⚠️ 커밋 후 Apple 인코딩이 남는다 — 성공 응답이 곧 노출 가능은 아니다. appstore_list_previews 로 확인할 것.',
|
|
1476
|
+
'Apple 제약: 길이 15~30초, previewType 별 해상도 고정, 로케일·타입당 최대 3개. 어기면 인코딩 단계에서 거부된다.',
|
|
1477
|
+
'previewFrameTimeCode(HH:MM:SS:FF)로 포스터 프레임을 지정할 수 있다.',
|
|
1478
|
+
'파일은 절대경로로 넘긴다 (대용량이라 조각 업로드하며, 동영상 바이트는 대화에 싣지 않는다).',
|
|
1479
|
+
].join(' '), {
|
|
1480
|
+
localizationId: z.string().describe('appStoreVersionLocalization ID'),
|
|
1481
|
+
previewType: z.enum(PREVIEW_TYPES).describe('미리보기 타입 (기기군)'),
|
|
1482
|
+
filePath: z.string().describe('동영상 파일 절대경로 (.mp4 / .mov / .m4v)'),
|
|
1483
|
+
previewFrameTimeCode: z
|
|
1484
|
+
.string()
|
|
1485
|
+
.optional()
|
|
1486
|
+
.describe('포스터 프레임 시각 HH:MM:SS:FF (예: 00:00:03:00)'),
|
|
1487
|
+
}, async ({ localizationId, previewType, filePath, previewFrameTimeCode }) => {
|
|
1488
|
+
const r = await previews.uploadPreview({ localizationId, previewType, filePath, previewFrameTimeCode });
|
|
1489
|
+
return {
|
|
1490
|
+
content: [{
|
|
1491
|
+
type: 'text',
|
|
1492
|
+
text: [
|
|
1493
|
+
'✅ 미리보기 업로드 완료 (인코딩 대기)',
|
|
1494
|
+
` id: ${r.id}`,
|
|
1495
|
+
` 파일: ${r.fileName} (${(r.fileSize / 1024 / 1024).toFixed(1)} MB)`,
|
|
1496
|
+
` 타입: ${r.previewType}`,
|
|
1497
|
+
` 상태: ${r.state ?? '처리 중'}`,
|
|
1498
|
+
'Apple 인코딩이 끝나야 노출된다 — appstore_list_previews 로 상태를 확인할 것.',
|
|
1499
|
+
].join('\n'),
|
|
1500
|
+
}],
|
|
1501
|
+
};
|
|
1502
|
+
});
|
|
1503
|
+
server.tool('appstore_delete_preview', [
|
|
1504
|
+
'미리보기 동영상 또는 세트를 삭제한다 — DELETE /v1/appPreviews/{id} 또는 /appPreviewSets/{id}.',
|
|
1505
|
+
'⚠️ 되돌릴 수 없다. 세트를 지우면 그 안의 동영상이 모두 사라진다. confirm 필요.',
|
|
1506
|
+
].join(' '), {
|
|
1507
|
+
previewId: z.string().optional().describe('삭제할 미리보기 ID (set 과 둘 중 하나)'),
|
|
1508
|
+
setId: z.string().optional().describe('삭제할 미리보기 세트 ID (안의 동영상 전부 삭제)'),
|
|
1509
|
+
confirm: z.boolean().optional().describe('true 명시 시에만 삭제'),
|
|
1510
|
+
}, async ({ previewId, setId, confirm }) => {
|
|
1511
|
+
if (!previewId && !setId)
|
|
1512
|
+
throw new Error('previewId 또는 setId 중 하나는 필요하다.');
|
|
1513
|
+
if (!confirm) {
|
|
1514
|
+
return {
|
|
1515
|
+
content: [{
|
|
1516
|
+
type: 'text',
|
|
1517
|
+
text: previewId
|
|
1518
|
+
? `🛑 dry-run — 미리보기 ${previewId} 를 삭제할 참이다. confirm: true 로 다시 호출.`
|
|
1519
|
+
: `🛑 dry-run — 세트 ${setId} 와 그 안의 동영상 전부를 삭제할 참이다. confirm: true 로 다시 호출.`,
|
|
1520
|
+
}],
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
const r = previewId
|
|
1524
|
+
? await previews.deletePreview(previewId)
|
|
1525
|
+
: await previews.deletePreviewSet(setId);
|
|
1526
|
+
return { content: [{ type: 'text', text: `✅ 삭제 완료 — ${r.id}` }] };
|
|
1527
|
+
});
|
|
1204
1528
|
}
|
|
@@ -809,4 +809,127 @@ export function registerPlaystoreTools(server) {
|
|
|
809
809
|
}],
|
|
810
810
|
};
|
|
811
811
|
});
|
|
812
|
+
// ─── 앱 복구 (출시 후 긴급 대응) ───
|
|
813
|
+
server.tool('playstore_list_recovery_actions', [
|
|
814
|
+
'앱 복구 액션 목록을 조회한다 — 읽기 전용 (apprecovery.list).',
|
|
815
|
+
'이미 배포된 앱이 치명적으로 망가졌을 때 특정 버전 사용자에게 원격 인앱 업데이트를 밀어 넣는 장치다.',
|
|
816
|
+
'상태(DRAFT/ACTIVE/CANCELED)·생성·배포·취소 시각과 대상을 보여준다.',
|
|
817
|
+
].join(' '), {
|
|
818
|
+
packageName: z.string().describe('패키지명'),
|
|
819
|
+
versionCode: z.string().optional().describe('특정 버전 코드로 필터'),
|
|
820
|
+
}, async ({ packageName, versionCode }) => {
|
|
821
|
+
const auth = await requirePlayStoreAuth(packageName);
|
|
822
|
+
const rows = await playstore.listRecoveryActions(auth, packageName, versionCode);
|
|
823
|
+
if (rows.length === 0)
|
|
824
|
+
return { content: [{ type: 'text', text: '복구 액션 없음.' }] };
|
|
825
|
+
return {
|
|
826
|
+
content: [{
|
|
827
|
+
type: 'text',
|
|
828
|
+
text: rows
|
|
829
|
+
.map((r) => [
|
|
830
|
+
`${r.id}: ${r.status}`,
|
|
831
|
+
r.createTime ? ` 생성 ${r.createTime}` : '',
|
|
832
|
+
r.deployTime ? ` 배포 ${r.deployTime}` : '',
|
|
833
|
+
r.cancelTime ? ` 취소 ${r.cancelTime}` : '',
|
|
834
|
+
` 대상: ${JSON.stringify(r.targeting ?? {})}`,
|
|
835
|
+
].filter(Boolean).join('\n'))
|
|
836
|
+
.join('\n'),
|
|
837
|
+
}],
|
|
838
|
+
};
|
|
839
|
+
});
|
|
840
|
+
server.tool('playstore_create_recovery_action', [
|
|
841
|
+
'앱 복구 액션을 DRAFT 상태로 만든다 — apprecovery.create.',
|
|
842
|
+
'만들기만 하고 배포하지 않는다 (배포는 playstore_deploy_recovery_action).',
|
|
843
|
+
'대상은 allUsers 또는 versionCodes / versionRange 중 하나로 정한다 — allUsers 와 버전 지정은 함께 못 쓴다.',
|
|
844
|
+
'⚠️ 배포 후에는 대상을 만들 때 고른 기준 안에서만 넓힐 수 있다. 범위를 신중히 잡을 것.',
|
|
845
|
+
'이건 롤백이 아니라 "고친 버전으로 강제 업데이트"다 — 먼저 정상 빌드를 올려 둬야 의미가 있다.',
|
|
846
|
+
].join(' '), {
|
|
847
|
+
packageName: z.string().describe('패키지명'),
|
|
848
|
+
allUsers: z.boolean().optional().describe('전체 사용자 대상 (버전 지정과 함께 쓸 수 없음)'),
|
|
849
|
+
versionCodes: z.array(z.string()).optional().describe('대상 버전 코드 목록'),
|
|
850
|
+
versionRangeStart: z.string().optional().describe('대상 버전 범위 시작'),
|
|
851
|
+
versionRangeEnd: z.string().optional().describe('대상 버전 범위 끝'),
|
|
852
|
+
regions: z.array(z.string()).optional().describe('지역 코드 (예: KR, US)'),
|
|
853
|
+
sdkLevels: z.array(z.string()).optional().describe('Android SDK 레벨'),
|
|
854
|
+
confirm: z.boolean().optional().describe('true 명시 시에만 생성. 생략/false 면 계획만 반환.'),
|
|
855
|
+
}, async ({ packageName, allUsers, versionCodes, versionRangeStart, versionRangeEnd, regions, sdkLevels, confirm }) => {
|
|
856
|
+
const targeting = {
|
|
857
|
+
allUsers,
|
|
858
|
+
versionCodes,
|
|
859
|
+
versionRange: versionRangeStart && versionRangeEnd
|
|
860
|
+
? { start: versionRangeStart, end: versionRangeEnd }
|
|
861
|
+
: undefined,
|
|
862
|
+
regions,
|
|
863
|
+
sdkLevels,
|
|
864
|
+
};
|
|
865
|
+
if (!confirm) {
|
|
866
|
+
return {
|
|
867
|
+
content: [{
|
|
868
|
+
type: 'text',
|
|
869
|
+
text: [
|
|
870
|
+
'🛑 dry-run — 아직 만들지 않았다.',
|
|
871
|
+
` 패키지: ${packageName}`,
|
|
872
|
+
` 대상: ${JSON.stringify(targeting)}`,
|
|
873
|
+
'',
|
|
874
|
+
'DRAFT 로 만들려면 confirm: true. 만든 뒤에도 배포는 별도 단계다.',
|
|
875
|
+
].join('\n'),
|
|
876
|
+
}],
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
const auth = await requirePlayStoreAuth(packageName);
|
|
880
|
+
const r = await playstore.createRecoveryAction(auth, packageName, targeting);
|
|
881
|
+
return {
|
|
882
|
+
content: [{
|
|
883
|
+
type: 'text',
|
|
884
|
+
text: [
|
|
885
|
+
'✅ 복구 액션 생성 (DRAFT — 아직 사용자에게 나가지 않았다)',
|
|
886
|
+
` id: ${r.id}`,
|
|
887
|
+
` 상태: ${r.status}`,
|
|
888
|
+
'배포하려면 playstore_deploy_recovery_action.',
|
|
889
|
+
].join('\n'),
|
|
890
|
+
}],
|
|
891
|
+
};
|
|
892
|
+
});
|
|
893
|
+
server.tool('playstore_deploy_recovery_action', [
|
|
894
|
+
'DRAFT 복구 액션을 실제로 배포한다 — apprecovery.deploy.',
|
|
895
|
+
'⚠️ 대상 사용자 기기에 원격 인앱 업데이트가 나간다. 되돌리려면 취소해야 하고, 이미 적용된 기기는 되돌아가지 않는다.',
|
|
896
|
+
'배포 전 playstore_list_recovery_actions 로 대상 범위를 반드시 재확인할 것. confirm 필요.',
|
|
897
|
+
].join(' '), {
|
|
898
|
+
packageName: z.string().describe('패키지명'),
|
|
899
|
+
appRecoveryId: z.string().describe('복구 액션 ID'),
|
|
900
|
+
confirm: z.boolean().optional().describe('true 명시 시에만 배포'),
|
|
901
|
+
}, async ({ packageName, appRecoveryId, confirm }) => {
|
|
902
|
+
if (!confirm) {
|
|
903
|
+
return {
|
|
904
|
+
content: [{
|
|
905
|
+
type: 'text',
|
|
906
|
+
text: `🛑 dry-run — 복구 액션 ${appRecoveryId} 를 배포할 참이다. 대상 사용자에게 원격 업데이트가 나간다. confirm: true 로 다시 호출.`,
|
|
907
|
+
}],
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const auth = await requirePlayStoreAuth(packageName);
|
|
911
|
+
const r = await playstore.deployRecoveryAction(auth, packageName, appRecoveryId);
|
|
912
|
+
return { content: [{ type: 'text', text: `✅ 복구 액션 배포 — ${r.appRecoveryId}` }] };
|
|
913
|
+
});
|
|
914
|
+
server.tool('playstore_cancel_recovery_action', [
|
|
915
|
+
'진행 중인 복구 액션을 취소한다 — apprecovery.cancel.',
|
|
916
|
+
'아직 받지 않은 기기에는 더 이상 나가지 않는다. 이미 적용된 기기는 되돌아가지 않는다.',
|
|
917
|
+
'confirm 필요.',
|
|
918
|
+
].join(' '), {
|
|
919
|
+
packageName: z.string().describe('패키지명'),
|
|
920
|
+
appRecoveryId: z.string().describe('복구 액션 ID'),
|
|
921
|
+
confirm: z.boolean().optional().describe('true 명시 시에만 취소'),
|
|
922
|
+
}, async ({ packageName, appRecoveryId, confirm }) => {
|
|
923
|
+
if (!confirm) {
|
|
924
|
+
return {
|
|
925
|
+
content: [{
|
|
926
|
+
type: 'text',
|
|
927
|
+
text: `🛑 dry-run — 복구 액션 ${appRecoveryId} 를 취소할 참이다. 이미 적용된 기기는 되돌아가지 않는다. confirm: true 로 다시 호출.`,
|
|
928
|
+
}],
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
const auth = await requirePlayStoreAuth(packageName);
|
|
932
|
+
const r = await playstore.cancelRecoveryAction(auth, packageName, appRecoveryId);
|
|
933
|
+
return { content: [{ type: 'text', text: `✅ 복구 액션 취소 — ${r.appRecoveryId}` }] };
|
|
934
|
+
});
|
|
812
935
|
}
|
package/package.json
CHANGED
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": 214,
|
|
4
4
|
"domains": {
|
|
5
5
|
"admob": {
|
|
6
6
|
"label": "AdMob",
|
|
@@ -89,7 +89,18 @@
|
|
|
89
89
|
"appstore_update_age_rating",
|
|
90
90
|
"appstore_declare_encryption",
|
|
91
91
|
"appstore_get_availability",
|
|
92
|
-
"appstore_set_territory_availability"
|
|
92
|
+
"appstore_set_territory_availability",
|
|
93
|
+
"appstore_beta_status",
|
|
94
|
+
"appstore_update_beta_review_detail",
|
|
95
|
+
"appstore_update_beta_test_info",
|
|
96
|
+
"appstore_update_whats_to_test",
|
|
97
|
+
"appstore_submit_beta_review",
|
|
98
|
+
"appstore_set_beta_group_build",
|
|
99
|
+
"appstore_add_beta_testers",
|
|
100
|
+
"appstore_notify_beta_testers",
|
|
101
|
+
"appstore_list_previews",
|
|
102
|
+
"appstore_upload_preview",
|
|
103
|
+
"appstore_delete_preview"
|
|
93
104
|
]
|
|
94
105
|
},
|
|
95
106
|
"auth": {
|
|
@@ -297,7 +308,11 @@
|
|
|
297
308
|
"playstore_update_product",
|
|
298
309
|
"playstore_delete_product",
|
|
299
310
|
"setup_playstore_connection",
|
|
300
|
-
"playstore_upload_data_safety"
|
|
311
|
+
"playstore_upload_data_safety",
|
|
312
|
+
"playstore_list_recovery_actions",
|
|
313
|
+
"playstore_create_recovery_action",
|
|
314
|
+
"playstore_deploy_recovery_action",
|
|
315
|
+
"playstore_cancel_recovery_action"
|
|
301
316
|
]
|
|
302
317
|
},
|
|
303
318
|
"threads": {
|