@yoonion/mimi-seed-mcp 0.3.9 → 0.3.11
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/auth/cli.js +241 -35
- package/dist/auth/errors.d.ts +24 -0
- package/dist/auth/errors.js +152 -0
- package/dist/auth/google-auth.d.ts +26 -0
- package/dist/auth/google-auth.js +125 -7
- package/dist/checks/plan.d.ts +20 -0
- package/dist/checks/plan.js +258 -0
- package/dist/index.js +68 -22
- package/package.json +1 -1
package/dist/auth/cli.js
CHANGED
|
@@ -1,40 +1,246 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { login, getStoredTokens } from './google-auth.js';
|
|
3
|
-
import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './constants.js';
|
|
4
2
|
import readline from 'node:readline';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { startAuth, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
|
|
5
|
+
import { AuthError, classifyError } from './errors.js';
|
|
6
|
+
import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './constants.js';
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const hasFlag = (name) => args.includes(`--${name}`);
|
|
9
|
+
const flagValue = (name) => {
|
|
10
|
+
const i = args.indexOf(`--${name}`);
|
|
11
|
+
if (i < 0)
|
|
12
|
+
return undefined;
|
|
13
|
+
const v = args[i + 1];
|
|
14
|
+
return v && !v.startsWith('--') ? v : undefined;
|
|
15
|
+
};
|
|
16
|
+
function err(msg) {
|
|
17
|
+
process.stderr.write(msg + '\n');
|
|
18
|
+
}
|
|
19
|
+
function printHelp() {
|
|
20
|
+
err(`
|
|
21
|
+
☕ mimi-seed-auth — Google OAuth 인증 CLI
|
|
22
|
+
|
|
23
|
+
사용법:
|
|
24
|
+
mimi-seed-auth # 로그인 (이미 있으면 자동 refresh 시도)
|
|
25
|
+
mimi-seed-auth --refresh # refresh_token으로 갱신만 시도 (브라우저 X)
|
|
26
|
+
mimi-seed-auth --status # 현재 토큰 상태 출력
|
|
27
|
+
mimi-seed-auth --logout # 토큰 삭제
|
|
28
|
+
|
|
29
|
+
옵션:
|
|
30
|
+
--no-browser URL 자동 오픈 안 함 (직접 복붙)
|
|
31
|
+
--timeout <초> 콜백 대기 시간 (기본 600)
|
|
32
|
+
--force 기존 토큰 무시하고 강제 재로그인
|
|
33
|
+
--help 이 도움말
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
function fmtRemaining(ms) {
|
|
37
|
+
if (ms <= 0)
|
|
38
|
+
return '만료됨';
|
|
39
|
+
const min = Math.round(ms / 60000);
|
|
40
|
+
if (min < 60)
|
|
41
|
+
return `${min}분 남음`;
|
|
42
|
+
const hr = Math.round(min / 60);
|
|
43
|
+
if (hr < 48)
|
|
44
|
+
return `${hr}시간 남음`;
|
|
45
|
+
return `${Math.round(hr / 24)}일 남음`;
|
|
46
|
+
}
|
|
47
|
+
function printAuthError(p) {
|
|
48
|
+
err(` 코드: ${p.code}`);
|
|
49
|
+
err(` ${p.message}`);
|
|
50
|
+
if (p.hint)
|
|
51
|
+
err(` → ${p.hint}`);
|
|
52
|
+
if (p.cause && process.env.DEBUG)
|
|
53
|
+
err(` (cause: ${p.cause})`);
|
|
54
|
+
}
|
|
55
|
+
async function cmdStatus() {
|
|
56
|
+
err('');
|
|
57
|
+
err(' ☕ Mimi Seed — 인증 상태');
|
|
58
|
+
err('');
|
|
59
|
+
const r = await ensureFreshAccessToken();
|
|
60
|
+
switch (r.status) {
|
|
61
|
+
case 'fresh':
|
|
62
|
+
err(` ✅ 연결됨 — 토큰 유효 (${fmtRemaining(r.msUntilExpiry)})`);
|
|
63
|
+
err('');
|
|
64
|
+
return 0;
|
|
65
|
+
case 'refreshed':
|
|
66
|
+
err(` ✅ 연결됨 — refresh_token으로 갱신 (${fmtRemaining(r.msUntilExpiry)})`);
|
|
67
|
+
err('');
|
|
68
|
+
return 0;
|
|
69
|
+
case 'expired_refresh_failed':
|
|
70
|
+
err(' ⚠️ 토큰 만료 + 자동 갱신 실패');
|
|
71
|
+
printAuthError(r.error);
|
|
72
|
+
err('');
|
|
73
|
+
return 2;
|
|
74
|
+
case 'unauthenticated':
|
|
75
|
+
err(' ❌ 연결된 계정 없음.');
|
|
76
|
+
printAuthError(r.error);
|
|
77
|
+
err('');
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function cmdRefresh() {
|
|
82
|
+
err('');
|
|
83
|
+
err(' 🔄 refresh_token으로 갱신 시도 중...');
|
|
84
|
+
err('');
|
|
85
|
+
const r = await ensureFreshAccessToken(0); // 무조건 갱신 시도
|
|
86
|
+
switch (r.status) {
|
|
87
|
+
case 'fresh':
|
|
88
|
+
err(` ✅ 토큰 유효 — 갱신 불필요 (${fmtRemaining(r.msUntilExpiry)})`);
|
|
89
|
+
err('');
|
|
90
|
+
return 0;
|
|
91
|
+
case 'refreshed':
|
|
92
|
+
err(` ✅ 갱신 완료 (${fmtRemaining(r.msUntilExpiry)})`);
|
|
93
|
+
err('');
|
|
94
|
+
return 0;
|
|
95
|
+
case 'expired_refresh_failed':
|
|
96
|
+
err(' ❌ refresh 실패');
|
|
97
|
+
printAuthError(r.error);
|
|
98
|
+
err('');
|
|
99
|
+
return 2;
|
|
100
|
+
case 'unauthenticated':
|
|
101
|
+
err(' ❌ 저장된 토큰 없음.');
|
|
102
|
+
printAuthError(r.error);
|
|
103
|
+
err('');
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function cmdLogout() {
|
|
108
|
+
const fs = await import('node:fs');
|
|
109
|
+
const path = await import('node:path');
|
|
110
|
+
const os = await import('node:os');
|
|
111
|
+
const tokenPath = path.join(os.homedir(), '.mimi-seed', 'tokens.json');
|
|
112
|
+
err('');
|
|
113
|
+
if (fs.existsSync(tokenPath)) {
|
|
114
|
+
fs.rmSync(tokenPath, { force: true });
|
|
115
|
+
err(' ✅ 토큰 삭제 완료.');
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
err(' (이미 삭제된 상태)');
|
|
119
|
+
}
|
|
120
|
+
err('');
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
async function cmdLogin() {
|
|
124
|
+
const noBrowser = hasFlag('no-browser');
|
|
125
|
+
const force = hasFlag('force');
|
|
126
|
+
const timeoutSec = parseInt(flagValue('timeout') ?? '600', 10);
|
|
127
|
+
err('');
|
|
128
|
+
err(' ☕ Mimi Seed — Google 계정 연결');
|
|
129
|
+
err('');
|
|
130
|
+
// 1) 기존 토큰이 있으면 silent refresh 먼저 시도
|
|
131
|
+
if (!force) {
|
|
132
|
+
const existing = getStoredTokens();
|
|
133
|
+
if (existing) {
|
|
134
|
+
err(' 🔍 기존 토큰 검사 중...');
|
|
135
|
+
const r = await ensureFreshAccessToken();
|
|
136
|
+
if (r.status === 'fresh' || r.status === 'refreshed') {
|
|
137
|
+
const label = r.status === 'fresh' ? '유효함' : 'refresh_token으로 갱신 완료';
|
|
138
|
+
err(` ✅ 이미 연결됨 (${label}, ${fmtRemaining(r.msUntilExpiry)}).`);
|
|
139
|
+
err('');
|
|
140
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
141
|
+
const answer = await new Promise((res) => rl.question(' 다시 로그인할래? (y/N): ', res));
|
|
142
|
+
rl.close();
|
|
143
|
+
if (answer.toLowerCase() !== 'y') {
|
|
144
|
+
err('');
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (r.status === 'expired_refresh_failed') {
|
|
149
|
+
err(` ⚠️ 토큰 만료 + 자동 갱신 실패 [${r.error.code}] — 재로그인 진행.`);
|
|
150
|
+
if (r.error.cause && process.env.DEBUG)
|
|
151
|
+
err(` cause: ${r.error.cause}`);
|
|
152
|
+
}
|
|
19
153
|
}
|
|
20
154
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
155
|
+
// 2) OAuth 콜백 서버 + URL 발급
|
|
156
|
+
err('');
|
|
157
|
+
err(' 🌐 OAuth 콜백 서버 시작: http://localhost:9876/callback');
|
|
158
|
+
let url;
|
|
159
|
+
let wait;
|
|
24
160
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
161
|
+
const r = startAuth(MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET, {
|
|
162
|
+
timeoutMs: timeoutSec * 1000,
|
|
163
|
+
});
|
|
164
|
+
url = r.url;
|
|
165
|
+
wait = r.wait;
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
err(' ❌ 콜백 서버 시작 실패');
|
|
169
|
+
printAuthError(classifyError(e, { phase: 'login' }));
|
|
170
|
+
err('');
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
// 3) 브라우저 열기 (or URL 출력)
|
|
174
|
+
if (noBrowser) {
|
|
175
|
+
err('');
|
|
176
|
+
err(' 📋 아래 URL을 브라우저에 직접 붙여넣으세요:');
|
|
177
|
+
err('');
|
|
178
|
+
err(' ' + url);
|
|
179
|
+
err('');
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
err(' 🌐 기본 브라우저 자동 열기...');
|
|
183
|
+
try {
|
|
184
|
+
await open(url);
|
|
185
|
+
err(' (실패 시 --no-browser 로 URL 직접 받기)');
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
err(' ⚠️ 브라우저 자동 열기 실패: ' + (e instanceof Error ? e.message : String(e)));
|
|
189
|
+
err(' 📋 직접 열어주세요:');
|
|
190
|
+
err(' ' + url);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// 4) 콜백 대기
|
|
194
|
+
err(` ⏳ Google 승인 대기 중... (timeout ${timeoutSec}s)`);
|
|
195
|
+
// 진행 표시기 — 사용자에게 살아있다는 신호 전달
|
|
196
|
+
const ticker = setInterval(() => process.stderr.write('.'), 5000);
|
|
197
|
+
try {
|
|
198
|
+
await wait;
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
clearInterval(ticker);
|
|
202
|
+
err('');
|
|
203
|
+
err(' ❌ 인증 실패');
|
|
204
|
+
if (e instanceof AuthError) {
|
|
205
|
+
printAuthError(e.payload);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
printAuthError(classifyError(e, { phase: 'login' }));
|
|
209
|
+
}
|
|
210
|
+
err('');
|
|
211
|
+
return 1;
|
|
212
|
+
}
|
|
213
|
+
clearInterval(ticker);
|
|
214
|
+
err('');
|
|
215
|
+
err('');
|
|
216
|
+
err(' ✅ 연결 완료!');
|
|
217
|
+
err('');
|
|
218
|
+
err(' 이제 Claude Code에서 이렇게 쓸 수 있어:');
|
|
219
|
+
err(' "내 Firebase 프로젝트 보여줘"');
|
|
220
|
+
err(' "새 Android 앱 등록해줘"');
|
|
221
|
+
err(' "google-services.json 다운로드해줘"');
|
|
222
|
+
err('');
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
async function main() {
|
|
226
|
+
if (hasFlag('help') || hasFlag('h')) {
|
|
227
|
+
printHelp();
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
let code;
|
|
231
|
+
if (hasFlag('status'))
|
|
232
|
+
code = await cmdStatus();
|
|
233
|
+
else if (hasFlag('refresh'))
|
|
234
|
+
code = await cmdRefresh();
|
|
235
|
+
else if (hasFlag('logout'))
|
|
236
|
+
code = await cmdLogout();
|
|
237
|
+
else
|
|
238
|
+
code = await cmdLogin();
|
|
239
|
+
process.exit(code);
|
|
240
|
+
}
|
|
241
|
+
main().catch((e) => {
|
|
242
|
+
err('');
|
|
243
|
+
err(' ❌ 예외: ' + (e instanceof Error ? e.message : String(e)));
|
|
244
|
+
err('');
|
|
245
|
+
process.exit(1);
|
|
246
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type AuthErrorCode = 'UNAUTHENTICATED' | 'NO_REFRESH_TOKEN' | 'INVALID_GRANT' | 'INVALID_CLIENT' | 'UNAUTHORIZED_CLIENT' | 'REFRESH_NETWORK_ERROR' | 'REFRESH_UNKNOWN' | 'CALLBACK_PORT_IN_USE' | 'CALLBACK_TIMEOUT' | 'BROWSER_OPEN_FAILED' | 'USER_DENIED' | 'CODE_EXCHANGE_FAILED' | 'TOKEN_RESPONSE_INVALID';
|
|
2
|
+
export interface AuthErrorPayload {
|
|
3
|
+
code: AuthErrorCode;
|
|
4
|
+
message: string;
|
|
5
|
+
hint?: string;
|
|
6
|
+
retriable: boolean;
|
|
7
|
+
needsReauth: boolean;
|
|
8
|
+
cause?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare class AuthError extends Error {
|
|
11
|
+
payload: AuthErrorPayload;
|
|
12
|
+
constructor(payload: AuthErrorPayload);
|
|
13
|
+
}
|
|
14
|
+
interface ClassifyContext {
|
|
15
|
+
phase: 'refresh' | 'login';
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 임의의 에러 객체를 AuthErrorPayload로 분류.
|
|
19
|
+
* Google OAuth 응답 형태:
|
|
20
|
+
* { error: 'invalid_grant', error_description: '...' }
|
|
21
|
+
* googleapis 라이브러리는 e.response?.data?.error 또는 e.message에 코드 포함.
|
|
22
|
+
*/
|
|
23
|
+
export declare function classifyError(e: unknown, ctx: ClassifyContext): AuthErrorPayload;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Auth 에러 코드 — CLI/MCP/스크립트 모두 같은 코드를 보고 분기할 수 있도록
|
|
2
|
+
// 단일 source of truth.
|
|
3
|
+
export class AuthError extends Error {
|
|
4
|
+
payload;
|
|
5
|
+
constructor(payload) {
|
|
6
|
+
super(`[${payload.code}] ${payload.message}`);
|
|
7
|
+
this.payload = payload;
|
|
8
|
+
this.name = 'AuthError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 임의의 에러 객체를 AuthErrorPayload로 분류.
|
|
13
|
+
* Google OAuth 응답 형태:
|
|
14
|
+
* { error: 'invalid_grant', error_description: '...' }
|
|
15
|
+
* googleapis 라이브러리는 e.response?.data?.error 또는 e.message에 코드 포함.
|
|
16
|
+
*/
|
|
17
|
+
export function classifyError(e, ctx) {
|
|
18
|
+
const raw = describeError(e);
|
|
19
|
+
const oauthCode = extractOAuthErrorCode(e, raw);
|
|
20
|
+
// Google OAuth 표준 에러 코드 매핑
|
|
21
|
+
if (oauthCode === 'invalid_grant') {
|
|
22
|
+
return {
|
|
23
|
+
code: 'INVALID_GRANT',
|
|
24
|
+
message: 'refresh_token이 더 이상 유효하지 않습니다 (revoke 또는 만료).',
|
|
25
|
+
hint: 'mimi-seed-auth 로 재로그인하세요.',
|
|
26
|
+
retriable: false,
|
|
27
|
+
needsReauth: true,
|
|
28
|
+
cause: raw,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (oauthCode === 'invalid_client') {
|
|
32
|
+
return {
|
|
33
|
+
code: 'INVALID_CLIENT',
|
|
34
|
+
message: 'OAuth client_id/secret이 토큰 발급 당시와 일치하지 않습니다.',
|
|
35
|
+
hint: '일반적으로 발생하지 않음. 환경변수 MIMI_SEED_GOOGLE_CLIENT_ID/SECRET 오버라이드 사용 시 ' +
|
|
36
|
+
'동일 값으로 다시 발급받으세요.',
|
|
37
|
+
retriable: false,
|
|
38
|
+
needsReauth: true,
|
|
39
|
+
cause: raw,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (oauthCode === 'unauthorized_client') {
|
|
43
|
+
return {
|
|
44
|
+
code: 'UNAUTHORIZED_CLIENT',
|
|
45
|
+
message: 'OAuth client에 부여되지 않은 grant_type 또는 scope입니다.',
|
|
46
|
+
hint: 'mimi-seed-auth 로 새로운 동의를 받으세요.',
|
|
47
|
+
retriable: false,
|
|
48
|
+
needsReauth: true,
|
|
49
|
+
cause: raw,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (oauthCode === 'access_denied') {
|
|
53
|
+
return {
|
|
54
|
+
code: 'USER_DENIED',
|
|
55
|
+
message: '브라우저에서 Google 동의를 거부했습니다.',
|
|
56
|
+
hint: '다시 시도하고 모든 권한에 동의해주세요.',
|
|
57
|
+
retriable: true,
|
|
58
|
+
needsReauth: true,
|
|
59
|
+
cause: raw,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// 네트워크 / 시스템 레벨
|
|
63
|
+
if (isNetworkError(e, raw)) {
|
|
64
|
+
return {
|
|
65
|
+
code: 'REFRESH_NETWORK_ERROR',
|
|
66
|
+
message: '구글 토큰 엔드포인트(oauth2.googleapis.com)에 연결할 수 없습니다.',
|
|
67
|
+
hint: '인터넷 연결, 프록시, 방화벽을 확인하세요.',
|
|
68
|
+
retriable: true,
|
|
69
|
+
needsReauth: false,
|
|
70
|
+
cause: raw,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (isPortInUseError(e, raw)) {
|
|
74
|
+
return {
|
|
75
|
+
code: 'CALLBACK_PORT_IN_USE',
|
|
76
|
+
message: 'OAuth 콜백 포트 9876이 이미 사용 중입니다.',
|
|
77
|
+
hint: '해당 포트를 점유 중인 프로세스를 종료하거나 잠시 후 다시 시도하세요.',
|
|
78
|
+
retriable: true,
|
|
79
|
+
needsReauth: false,
|
|
80
|
+
cause: raw,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (/timeout/i.test(raw) && ctx.phase === 'login') {
|
|
84
|
+
return {
|
|
85
|
+
code: 'CALLBACK_TIMEOUT',
|
|
86
|
+
message: '시간 내 Google 콜백을 받지 못했습니다.',
|
|
87
|
+
hint: '브라우저에서 동의 안 하셨거나 자동 열기 실패. ' +
|
|
88
|
+
'`mimi-seed-auth --no-browser`로 URL을 직접 받아 다른 환경에서 여세요.',
|
|
89
|
+
retriable: true,
|
|
90
|
+
needsReauth: false,
|
|
91
|
+
cause: raw,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// 분류 안 됨
|
|
95
|
+
return {
|
|
96
|
+
code: ctx.phase === 'refresh' ? 'REFRESH_UNKNOWN' : 'CODE_EXCHANGE_FAILED',
|
|
97
|
+
message: '인증 처리 중 알 수 없는 오류가 발생했습니다.',
|
|
98
|
+
hint: '재시도 후 동일하면 mimi-seed-auth 로 재로그인하세요.',
|
|
99
|
+
retriable: true,
|
|
100
|
+
needsReauth: ctx.phase === 'refresh',
|
|
101
|
+
cause: raw,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function describeError(e) {
|
|
105
|
+
if (e instanceof Error)
|
|
106
|
+
return e.message;
|
|
107
|
+
if (typeof e === 'string')
|
|
108
|
+
return e;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.stringify(e);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return String(e);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* googleapis OAuth 에러는 다양한 위치에 코드가 박혀 있다:
|
|
118
|
+
* - e.response?.data?.error
|
|
119
|
+
* - e.response?.data?.error_description
|
|
120
|
+
* - e.message (e.g. "invalid_grant")
|
|
121
|
+
* - e.code (네트워크: 'ENOTFOUND', 'ECONNREFUSED')
|
|
122
|
+
*/
|
|
123
|
+
function extractOAuthErrorCode(e, raw) {
|
|
124
|
+
if (e && typeof e === 'object') {
|
|
125
|
+
// @ts-expect-error — 런타임 shape 검사
|
|
126
|
+
const dataErr = e.response?.data?.error;
|
|
127
|
+
if (typeof dataErr === 'string')
|
|
128
|
+
return dataErr;
|
|
129
|
+
}
|
|
130
|
+
// message에 'invalid_grant' 같은 표준 코드가 박혀 있는 경우
|
|
131
|
+
const m = raw.match(/\b(invalid_grant|invalid_client|invalid_request|unauthorized_client|access_denied|unsupported_grant_type)\b/);
|
|
132
|
+
return m?.[1];
|
|
133
|
+
}
|
|
134
|
+
function isNetworkError(e, raw) {
|
|
135
|
+
if (e && typeof e === 'object') {
|
|
136
|
+
// @ts-expect-error — runtime shape 검사
|
|
137
|
+
const code = e.code;
|
|
138
|
+
if (typeof code === 'string' && /^(ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ECONNRESET)$/.test(code)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|getaddrinfo|network/i.test(raw);
|
|
143
|
+
}
|
|
144
|
+
function isPortInUseError(e, raw) {
|
|
145
|
+
if (e && typeof e === 'object') {
|
|
146
|
+
// @ts-expect-error — runtime shape 검사
|
|
147
|
+
const code = e.code;
|
|
148
|
+
if (code === 'EADDRINUSE')
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return /EADDRINUSE|address already in use|포트 9876/i.test(raw);
|
|
152
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AuthErrorPayload } from './errors.js';
|
|
1
2
|
export interface StoredTokens {
|
|
2
3
|
access_token: string;
|
|
3
4
|
refresh_token: string;
|
|
@@ -34,3 +35,28 @@ export declare function startAuth(clientId: string, clientSecret: string, option
|
|
|
34
35
|
* startAuth() 래퍼 — CLI에서 사용.
|
|
35
36
|
*/
|
|
36
37
|
export declare function login(clientId: string, clientSecret: string): Promise<StoredTokens>;
|
|
38
|
+
export type RefreshStatus = {
|
|
39
|
+
status: 'fresh';
|
|
40
|
+
tokens: StoredTokens;
|
|
41
|
+
msUntilExpiry: number;
|
|
42
|
+
} | {
|
|
43
|
+
status: 'refreshed';
|
|
44
|
+
tokens: StoredTokens;
|
|
45
|
+
msUntilExpiry: number;
|
|
46
|
+
} | {
|
|
47
|
+
status: 'expired_refresh_failed';
|
|
48
|
+
tokens: StoredTokens;
|
|
49
|
+
error: AuthErrorPayload;
|
|
50
|
+
} | {
|
|
51
|
+
status: 'unauthenticated';
|
|
52
|
+
error: AuthErrorPayload;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* 저장된 access_token이 만료/곧만료면 refresh_token으로 silent 갱신 시도.
|
|
56
|
+
* - 갱신 성공 시 tokens.json 업데이트
|
|
57
|
+
* - refresh_token 자체가 invalid한 경우 'expired_refresh_failed' 반환
|
|
58
|
+
* - 토큰 자체가 없으면 'unauthenticated'
|
|
59
|
+
*
|
|
60
|
+
* MCP 도구와 CLI 양쪽에서 공유.
|
|
61
|
+
*/
|
|
62
|
+
export declare function ensureFreshAccessToken(marginMs?: number): Promise<RefreshStatus>;
|
package/dist/auth/google-auth.js
CHANGED
|
@@ -4,6 +4,8 @@ import open from 'open';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import os from 'node:os';
|
|
7
|
+
import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './constants.js';
|
|
8
|
+
import { AuthError, classifyError } from './errors.js';
|
|
7
9
|
const SCOPES = [
|
|
8
10
|
'https://www.googleapis.com/auth/firebase',
|
|
9
11
|
'https://www.googleapis.com/auth/cloud-platform',
|
|
@@ -117,6 +119,7 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
117
119
|
prompt: 'consent',
|
|
118
120
|
});
|
|
119
121
|
const wait = new Promise((resolve, reject) => {
|
|
122
|
+
const rejectAuth = (e) => reject(new AuthError(classifyError(e, { phase: 'login' })));
|
|
120
123
|
const server = http.createServer(async (req, res) => {
|
|
121
124
|
try {
|
|
122
125
|
const url = new URL(req.url, `http://localhost:9876`);
|
|
@@ -125,15 +128,58 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
125
128
|
res.end();
|
|
126
129
|
return;
|
|
127
130
|
}
|
|
131
|
+
// Google이 동의 거부 시 ?error=access_denied 로 콜백
|
|
132
|
+
const errParam = url.searchParams.get('error');
|
|
133
|
+
if (errParam) {
|
|
134
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
135
|
+
res.end(`<html><body><h2>❌ 인증 거부됨 (${errParam})</h2></body></html>`);
|
|
136
|
+
try {
|
|
137
|
+
server.close();
|
|
138
|
+
}
|
|
139
|
+
catch { /* noop */ }
|
|
140
|
+
activeAuthServer = null;
|
|
141
|
+
rejectAuth(new Error(errParam));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
128
144
|
const code = url.searchParams.get('code');
|
|
129
145
|
if (!code) {
|
|
130
146
|
res.writeHead(400);
|
|
131
147
|
res.end('No code');
|
|
132
148
|
return;
|
|
133
149
|
}
|
|
134
|
-
|
|
150
|
+
let tokenResponse;
|
|
151
|
+
try {
|
|
152
|
+
tokenResponse = await oauth2Client.getToken(code);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
res.writeHead(500);
|
|
156
|
+
res.end('Code exchange failed');
|
|
157
|
+
try {
|
|
158
|
+
server.close();
|
|
159
|
+
}
|
|
160
|
+
catch { /* noop */ }
|
|
161
|
+
activeAuthServer = null;
|
|
162
|
+
rejectAuth(e);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const tokens = tokenResponse.tokens;
|
|
135
166
|
if (!tokens.access_token || !tokens.refresh_token) {
|
|
136
|
-
|
|
167
|
+
res.writeHead(500);
|
|
168
|
+
res.end('Token response invalid');
|
|
169
|
+
try {
|
|
170
|
+
server.close();
|
|
171
|
+
}
|
|
172
|
+
catch { /* noop */ }
|
|
173
|
+
activeAuthServer = null;
|
|
174
|
+
reject(new AuthError({
|
|
175
|
+
code: 'TOKEN_RESPONSE_INVALID',
|
|
176
|
+
message: 'Google 응답에 access_token 또는 refresh_token이 누락되었습니다.',
|
|
177
|
+
hint: 'OAuth 동의 화면에서 모든 권한에 동의했는지 확인하세요.',
|
|
178
|
+
retriable: true,
|
|
179
|
+
needsReauth: true,
|
|
180
|
+
cause: JSON.stringify({ has_access: !!tokens.access_token, has_refresh: !!tokens.refresh_token }),
|
|
181
|
+
}));
|
|
182
|
+
return;
|
|
137
183
|
}
|
|
138
184
|
const stored = {
|
|
139
185
|
access_token: tokens.access_token,
|
|
@@ -156,18 +202,21 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
156
202
|
resolve(stored);
|
|
157
203
|
}
|
|
158
204
|
catch (err) {
|
|
159
|
-
|
|
160
|
-
|
|
205
|
+
try {
|
|
206
|
+
res.writeHead(500);
|
|
207
|
+
res.end('Auth error');
|
|
208
|
+
}
|
|
209
|
+
catch { /* noop — already responded */ }
|
|
161
210
|
try {
|
|
162
211
|
server.close();
|
|
163
212
|
}
|
|
164
213
|
catch { /* noop */ }
|
|
165
214
|
activeAuthServer = null;
|
|
166
|
-
|
|
215
|
+
rejectAuth(err);
|
|
167
216
|
}
|
|
168
217
|
});
|
|
169
218
|
server.on('error', (err) => {
|
|
170
|
-
|
|
219
|
+
rejectAuth(err);
|
|
171
220
|
});
|
|
172
221
|
server.listen(9876, () => {
|
|
173
222
|
activeAuthServer = server;
|
|
@@ -180,7 +229,7 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
180
229
|
}
|
|
181
230
|
catch { /* noop */ }
|
|
182
231
|
activeAuthServer = null;
|
|
183
|
-
|
|
232
|
+
rejectAuth(new Error(`Auth timeout (${Math.round(timeoutMs / 1000)}s).`));
|
|
184
233
|
}
|
|
185
234
|
}, timeoutMs);
|
|
186
235
|
});
|
|
@@ -196,3 +245,72 @@ export async function login(clientId, clientSecret) {
|
|
|
196
245
|
open(url);
|
|
197
246
|
return wait;
|
|
198
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* 저장된 access_token이 만료/곧만료면 refresh_token으로 silent 갱신 시도.
|
|
250
|
+
* - 갱신 성공 시 tokens.json 업데이트
|
|
251
|
+
* - refresh_token 자체가 invalid한 경우 'expired_refresh_failed' 반환
|
|
252
|
+
* - 토큰 자체가 없으면 'unauthenticated'
|
|
253
|
+
*
|
|
254
|
+
* MCP 도구와 CLI 양쪽에서 공유.
|
|
255
|
+
*/
|
|
256
|
+
export async function ensureFreshAccessToken(marginMs = 60_000) {
|
|
257
|
+
const tokens = getStoredTokens();
|
|
258
|
+
if (!tokens) {
|
|
259
|
+
return {
|
|
260
|
+
status: 'unauthenticated',
|
|
261
|
+
error: {
|
|
262
|
+
code: 'UNAUTHENTICATED',
|
|
263
|
+
message: '저장된 인증 토큰이 없습니다.',
|
|
264
|
+
hint: 'mimi-seed-auth 로 로그인하세요.',
|
|
265
|
+
retriable: false,
|
|
266
|
+
needsReauth: true,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
const msUntilExpiry = (tokens.expiry_date ?? 0) - now;
|
|
272
|
+
if (tokens.expiry_date && msUntilExpiry > marginMs) {
|
|
273
|
+
return { status: 'fresh', tokens, msUntilExpiry };
|
|
274
|
+
}
|
|
275
|
+
if (!tokens.refresh_token) {
|
|
276
|
+
return {
|
|
277
|
+
status: 'expired_refresh_failed',
|
|
278
|
+
tokens,
|
|
279
|
+
error: {
|
|
280
|
+
code: 'NO_REFRESH_TOKEN',
|
|
281
|
+
message: '저장된 토큰에 refresh_token이 없습니다 (offline_access 미발급).',
|
|
282
|
+
hint: 'mimi-seed-auth 로 재로그인하면 prompt=consent로 새 refresh_token이 발급됩니다.',
|
|
283
|
+
retriable: false,
|
|
284
|
+
needsReauth: true,
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
// refresh 시도 — credentials.json 우선, 없으면 내장 클라이언트 사용
|
|
289
|
+
const stored = getStoredCredentials();
|
|
290
|
+
const clientId = stored?.clientId ?? MIMI_SEED_CLIENT_ID;
|
|
291
|
+
const clientSecret = stored?.clientSecret ?? MIMI_SEED_CLIENT_SECRET;
|
|
292
|
+
const client = createOAuth2Client(clientId, clientSecret);
|
|
293
|
+
client.setCredentials({ refresh_token: tokens.refresh_token });
|
|
294
|
+
try {
|
|
295
|
+
const { credentials } = await client.refreshAccessToken();
|
|
296
|
+
const refreshed = {
|
|
297
|
+
access_token: credentials.access_token ?? tokens.access_token,
|
|
298
|
+
refresh_token: credentials.refresh_token ?? tokens.refresh_token,
|
|
299
|
+
token_type: credentials.token_type ?? tokens.token_type ?? 'Bearer',
|
|
300
|
+
expiry_date: credentials.expiry_date ?? Date.now() + 3600_000,
|
|
301
|
+
};
|
|
302
|
+
saveTokens(refreshed);
|
|
303
|
+
return {
|
|
304
|
+
status: 'refreshed',
|
|
305
|
+
tokens: refreshed,
|
|
306
|
+
msUntilExpiry: refreshed.expiry_date - Date.now(),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
return {
|
|
311
|
+
status: 'expired_refresh_failed',
|
|
312
|
+
tokens,
|
|
313
|
+
error: classifyError(e, { phase: 'refresh' }),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { OAuth2Client } from 'google-auth-library';
|
|
2
|
+
export type StepState = 'done' | 'pending' | 'blocked';
|
|
3
|
+
export interface PlanStep {
|
|
4
|
+
state: StepState;
|
|
5
|
+
title: string;
|
|
6
|
+
detail?: string;
|
|
7
|
+
tool?: string;
|
|
8
|
+
irreversible?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function buildPlayStoreReleasePlan(opts: {
|
|
11
|
+
auth: OAuth2Client;
|
|
12
|
+
packageName: string;
|
|
13
|
+
versionCode?: string;
|
|
14
|
+
track: 'production' | 'beta' | 'alpha' | 'internal';
|
|
15
|
+
language: string;
|
|
16
|
+
}): Promise<string>;
|
|
17
|
+
export declare function buildAppStoreReleasePlan(opts: {
|
|
18
|
+
appId: string;
|
|
19
|
+
versionString?: string;
|
|
20
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Release Plan — Play Store / App Store 배포 사전 점검 + AI에게 TodoWrite 지시
|
|
2
|
+
//
|
|
3
|
+
// 이 모듈은 read-only.
|
|
4
|
+
// 실행 도구(playstore_submit_release, appstore_submit_for_review 등)는 호출하지 않고,
|
|
5
|
+
// 현재 상태를 조사해서 markdown 체크리스트로 변환한다.
|
|
6
|
+
// AI 에이전트가 응답을 받아 TodoWrite로 사용자에게 제시한 뒤,
|
|
7
|
+
// 단계별로 기존 *_update_*, *_submit_*, *_check_* 도구를 호출하도록 유도한다.
|
|
8
|
+
import { publisher, withEdit } from '../playstore/tools.js';
|
|
9
|
+
import { apiGet } from '../appstore/tools.js';
|
|
10
|
+
import { checkPlayStoreRisks, checkAppStoreRisks } from './risks.js';
|
|
11
|
+
const TODO_DIRECTIVE = [
|
|
12
|
+
'📋 [AI 지시 — 호출자 에이전트 전용]',
|
|
13
|
+
'',
|
|
14
|
+
'이 응답은 배포 플랜입니다. 다음 절차를 반드시 따르세요:',
|
|
15
|
+
'',
|
|
16
|
+
'1. 아래 체크리스트의 미완료(◻︎) 항목을 TodoWrite 도구로 사용자에게 먼저 보여주세요.',
|
|
17
|
+
'2. 사용자가 진행에 명시적으로 동의하기 전엔 ⚠️ 비가역 도구를 호출하지 마세요',
|
|
18
|
+
' (playstore_submit_release status=completed, playstore_promote_release status=completed,',
|
|
19
|
+
' appstore_submit_for_review).',
|
|
20
|
+
'3. 한 단계 끝날 때마다 TodoWrite로 해당 항목을 completed로 표시하세요.',
|
|
21
|
+
'4. 항목 옆에 "도구:" 가 표기된 경우, 그 도구를 직접 호출해서 단계를 실행하세요.',
|
|
22
|
+
'',
|
|
23
|
+
].join('\n');
|
|
24
|
+
function fmtState(s) {
|
|
25
|
+
return s === 'done' ? '☑︎' : s === 'blocked' ? '⛔' : '◻︎';
|
|
26
|
+
}
|
|
27
|
+
function risksToSteps(risks, toolHint) {
|
|
28
|
+
return risks.map((r) => ({
|
|
29
|
+
state: 'blocked',
|
|
30
|
+
title: `[${r.code}] ${r.title}`,
|
|
31
|
+
detail: r.detail + (r.fixUrl ? ` → ${r.fixUrl}` : ''),
|
|
32
|
+
tool: r.level === 'blocker' ? toolHint : undefined,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
function renderPlan(ctx, steps) {
|
|
36
|
+
const blocked = steps.filter((s) => s.state === 'blocked').length;
|
|
37
|
+
const pending = steps.filter((s) => s.state === 'pending').length;
|
|
38
|
+
const done = steps.filter((s) => s.state === 'done').length;
|
|
39
|
+
const lines = [];
|
|
40
|
+
lines.push(TODO_DIRECTIVE);
|
|
41
|
+
lines.push(`🎯 ${ctx.platform} 배포 플랜`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push(` 대상: ${ctx.identity}`);
|
|
44
|
+
lines.push(` 버전: ${ctx.version}`);
|
|
45
|
+
if (ctx.track)
|
|
46
|
+
lines.push(` 트랙: ${ctx.track}`);
|
|
47
|
+
lines.push('');
|
|
48
|
+
lines.push(` 진행률: ☑︎ ${done} ◻︎ ${pending} ⛔ ${blocked}`);
|
|
49
|
+
if (blocked > 0) {
|
|
50
|
+
lines.push(` ⚠️ 블로커 ${blocked}건 — 먼저 해결해야 제출 가능`);
|
|
51
|
+
}
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push('체크리스트 (TodoWrite로 사용자에게 제시):');
|
|
54
|
+
lines.push('');
|
|
55
|
+
for (const s of steps) {
|
|
56
|
+
const irr = s.irreversible ? ' ⚠️ 비가역' : '';
|
|
57
|
+
lines.push(`${fmtState(s.state)} ${s.title}${irr}`);
|
|
58
|
+
if (s.detail)
|
|
59
|
+
lines.push(` ${s.detail}`);
|
|
60
|
+
if (s.tool)
|
|
61
|
+
lines.push(` 도구: ${s.tool}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push('');
|
|
64
|
+
lines.push('—');
|
|
65
|
+
lines.push('TodoWrite로 위 미완료 항목을 등록한 후, 사용자 확인을 받고 도구를 단계별로 호출하세요.');
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
// ─── Play Store ──────────────────────────────────────────────────────
|
|
69
|
+
export async function buildPlayStoreReleasePlan(opts) {
|
|
70
|
+
const { auth, packageName, versionCode, track, language } = opts;
|
|
71
|
+
const steps = [];
|
|
72
|
+
// 1) 인증 + 트랙 / 빌드 상태 조회 (단일 edit session)
|
|
73
|
+
let trackInfo = {
|
|
74
|
+
hasTargetVersion: false,
|
|
75
|
+
};
|
|
76
|
+
let listingPresent = false;
|
|
77
|
+
try {
|
|
78
|
+
await withEdit(auth, packageName, async (editId) => {
|
|
79
|
+
const [tracks, listing] = await Promise.all([
|
|
80
|
+
publisher().edits.tracks.list({ auth, packageName, editId }).catch(() => null),
|
|
81
|
+
publisher().edits.listings.get({ auth, packageName, editId, language }).catch(() => null),
|
|
82
|
+
]);
|
|
83
|
+
const targetTrack = tracks?.data?.tracks?.find((t) => t.track === track);
|
|
84
|
+
const release = targetTrack?.releases?.[0];
|
|
85
|
+
const codes = release?.versionCodes ?? [];
|
|
86
|
+
trackInfo = {
|
|
87
|
+
hasTargetVersion: versionCode ? codes.includes(versionCode) : codes.length > 0,
|
|
88
|
+
latestVersionCode: codes[codes.length - 1],
|
|
89
|
+
releaseStatus: release?.status ?? undefined,
|
|
90
|
+
};
|
|
91
|
+
listingPresent = !!listing?.data?.title;
|
|
92
|
+
return null;
|
|
93
|
+
});
|
|
94
|
+
steps.push({ state: 'done', title: 'Google Play 인증 + 트랙 조회 성공' });
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
steps.push({
|
|
98
|
+
state: 'blocked',
|
|
99
|
+
title: 'Google Play 인증 또는 트랙 조회 실패',
|
|
100
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
101
|
+
tool: 'mimi_seed_auth_status',
|
|
102
|
+
});
|
|
103
|
+
return renderPlan({ platform: 'Play Store', identity: packageName, version: versionCode ?? 'latest', track }, steps);
|
|
104
|
+
}
|
|
105
|
+
// 2) 빌드 도착 확인
|
|
106
|
+
if (trackInfo.hasTargetVersion) {
|
|
107
|
+
steps.push({
|
|
108
|
+
state: 'done',
|
|
109
|
+
title: `${track} 트랙에 versionCode ${versionCode ?? trackInfo.latestVersionCode} 도착 확인`,
|
|
110
|
+
detail: `현재 release status: ${trackInfo.releaseStatus ?? 'unknown'}`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
steps.push({
|
|
115
|
+
state: 'blocked',
|
|
116
|
+
title: versionCode
|
|
117
|
+
? `${track} 트랙에 versionCode ${versionCode} 없음`
|
|
118
|
+
: `${track} 트랙에 release 없음`,
|
|
119
|
+
detail: 'Gradle / Fastlane / Play Console 업로드를 먼저 완료하세요.',
|
|
120
|
+
tool: 'playstore_list_tracks',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// 3) 메타데이터 / 스크린샷 / 정책 위험 점검
|
|
124
|
+
const risks = await checkPlayStoreRisks(auth, packageName, language);
|
|
125
|
+
if (risks.length === 0) {
|
|
126
|
+
steps.push({
|
|
127
|
+
state: 'done',
|
|
128
|
+
title: '제출 위험 점검 통과 (블로커 0)',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const blockers = risks.filter((r) => r.level === 'blocker');
|
|
133
|
+
const warnings = risks.filter((r) => r.level === 'warning');
|
|
134
|
+
steps.push({
|
|
135
|
+
state: blockers.length > 0 ? 'blocked' : 'pending',
|
|
136
|
+
title: `제출 위험 ${risks.length}건 (블로커 ${blockers.length} / 경고 ${warnings.length})`,
|
|
137
|
+
detail: '아래 항목을 개별 수정 후 다시 plan 호출 권장',
|
|
138
|
+
tool: 'playstore_check_submission_risks',
|
|
139
|
+
});
|
|
140
|
+
steps.push(...risksToSteps(blockers, 'playstore_update_listing'));
|
|
141
|
+
steps.push(...risksToSteps(warnings, 'playstore_update_listing'));
|
|
142
|
+
}
|
|
143
|
+
// 4) 릴리즈 노트
|
|
144
|
+
steps.push({
|
|
145
|
+
state: 'pending',
|
|
146
|
+
title: `릴리즈 노트 등록/갱신 (${language})`,
|
|
147
|
+
detail: 'AI 노트 초안 + 사용자 확인 후 적용',
|
|
148
|
+
tool: 'playstore_update_release_notes (또는 playstore_update_latest_release_notes)',
|
|
149
|
+
});
|
|
150
|
+
// 5) 제출
|
|
151
|
+
const canSubmit = !risks.some((r) => r.level === 'blocker') && trackInfo.hasTargetVersion;
|
|
152
|
+
steps.push({
|
|
153
|
+
state: canSubmit ? 'pending' : 'blocked',
|
|
154
|
+
title: `${track} 트랙 release status 변경 (draft → completed)`,
|
|
155
|
+
detail: canSubmit
|
|
156
|
+
? '⚠️ 비가역에 가까움. 사용자 명시 동의 필수.'
|
|
157
|
+
: '블로커 해결 후 가능',
|
|
158
|
+
tool: 'playstore_submit_release',
|
|
159
|
+
irreversible: true,
|
|
160
|
+
});
|
|
161
|
+
return renderPlan({ platform: 'Play Store', identity: packageName, version: versionCode ?? 'latest', track }, steps);
|
|
162
|
+
}
|
|
163
|
+
// ─── App Store ────────────────────────────────────────────────────────
|
|
164
|
+
export async function buildAppStoreReleasePlan(opts) {
|
|
165
|
+
const { appId, versionString } = opts;
|
|
166
|
+
const steps = [];
|
|
167
|
+
// 1) 편집 가능한 버전 조회
|
|
168
|
+
let versionId;
|
|
169
|
+
let versionState;
|
|
170
|
+
let buildAttached = false;
|
|
171
|
+
try {
|
|
172
|
+
const versions = await apiGet(`/apps/${appId}/appStoreVersions`, {
|
|
173
|
+
'filter[appStoreState]': 'PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW,DEVELOPER_REJECTED',
|
|
174
|
+
...(versionString ? { 'filter[versionString]': versionString } : {}),
|
|
175
|
+
limit: '5',
|
|
176
|
+
}).catch(() => null);
|
|
177
|
+
if (versions?.data?.length) {
|
|
178
|
+
versionId = versions.data[0].id;
|
|
179
|
+
versionState = versions.data[0].attributes?.appStoreState;
|
|
180
|
+
steps.push({
|
|
181
|
+
state: 'done',
|
|
182
|
+
title: `편집 가능한 버전 발견 (${versions.data[0].attributes?.versionString}, state=${versionState})`,
|
|
183
|
+
});
|
|
184
|
+
// build attached?
|
|
185
|
+
const build = await apiGet(`/appStoreVersions/${versionId}/build`).catch(() => null);
|
|
186
|
+
buildAttached = !!build?.data?.id;
|
|
187
|
+
if (buildAttached) {
|
|
188
|
+
steps.push({ state: 'done', title: '버전에 빌드 연결됨' });
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
steps.push({
|
|
192
|
+
state: 'blocked',
|
|
193
|
+
title: '버전에 빌드 미연결',
|
|
194
|
+
detail: 'TestFlight 빌드 처리 완료 후 App Store Connect에서 연결하거나 API로 attach.',
|
|
195
|
+
tool: 'appstore_list_builds',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
steps.push({
|
|
201
|
+
state: 'blocked',
|
|
202
|
+
title: versionString
|
|
203
|
+
? `편집 가능한 ${versionString} 버전 없음`
|
|
204
|
+
: '편집 가능한 버전 없음',
|
|
205
|
+
detail: 'App Store Connect에서 새 버전을 생성하세요.',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
steps.push({
|
|
211
|
+
state: 'blocked',
|
|
212
|
+
title: 'App Store 인증 또는 버전 조회 실패',
|
|
213
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
214
|
+
tool: 'mimi_seed_auth_status',
|
|
215
|
+
});
|
|
216
|
+
return renderPlan({ platform: 'App Store', identity: appId, version: versionString ?? 'latest' }, steps);
|
|
217
|
+
}
|
|
218
|
+
// 2) 메타데이터 / 스크린샷 / 정책 위험 점검
|
|
219
|
+
const risks = await checkAppStoreRisks(appId);
|
|
220
|
+
if (risks.length === 0) {
|
|
221
|
+
steps.push({ state: 'done', title: '제출 위험 점검 통과 (블로커 0)' });
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const blockers = risks.filter((r) => r.level === 'blocker');
|
|
225
|
+
const warnings = risks.filter((r) => r.level === 'warning');
|
|
226
|
+
steps.push({
|
|
227
|
+
state: blockers.length > 0 ? 'blocked' : 'pending',
|
|
228
|
+
title: `제출 위험 ${risks.length}건 (블로커 ${blockers.length} / 경고 ${warnings.length})`,
|
|
229
|
+
tool: 'appstore_check_submission_risks',
|
|
230
|
+
});
|
|
231
|
+
steps.push(...risksToSteps(blockers, 'appstore_update_localization'));
|
|
232
|
+
steps.push(...risksToSteps(warnings, 'appstore_update_localization'));
|
|
233
|
+
}
|
|
234
|
+
// 3) What's New / promotional text / reviewer notes
|
|
235
|
+
steps.push({
|
|
236
|
+
state: 'pending',
|
|
237
|
+
title: "What's New (릴리즈 노트) 등록/갱신",
|
|
238
|
+
detail: '버전별 로컬라이제이션마다 입력',
|
|
239
|
+
tool: 'appstore_update_whats_new',
|
|
240
|
+
});
|
|
241
|
+
steps.push({
|
|
242
|
+
state: 'pending',
|
|
243
|
+
title: 'Promotional Text / Description / Keywords 검토',
|
|
244
|
+
tool: 'appstore_update_localization',
|
|
245
|
+
});
|
|
246
|
+
// 4) 제출
|
|
247
|
+
const canSubmit = versionId && buildAttached && !risks.some((r) => r.level === 'blocker');
|
|
248
|
+
steps.push({
|
|
249
|
+
state: canSubmit ? 'pending' : 'blocked',
|
|
250
|
+
title: '심사 제출 (reviewSubmissions v2)',
|
|
251
|
+
detail: canSubmit
|
|
252
|
+
? '⚠️ 비가역. 제출 후 메타/빌드 변경 불가 (REJECTED 시 재편집).'
|
|
253
|
+
: '블로커 해결 후 가능',
|
|
254
|
+
tool: versionId ? `appstore_submit_for_review (versionId=${versionId})` : 'appstore_submit_for_review',
|
|
255
|
+
irreversible: true,
|
|
256
|
+
});
|
|
257
|
+
return renderPlan({ platform: 'App Store', identity: appId, version: versionString ?? 'latest' }, steps);
|
|
258
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { getAuthenticatedClient,
|
|
5
|
+
import { getAuthenticatedClient, startAuth, ensureFreshAccessToken } from './auth/google-auth.js';
|
|
6
6
|
import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore-auth.js';
|
|
7
7
|
import { getAppStoreCredentials } from './appstore/auth.js';
|
|
8
8
|
import { createGoogleOneTimePurchase, createGoogleSubscription, updateGoogleProduct, deleteGoogleProduct, listGoogleProducts, createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
|
|
@@ -14,6 +14,7 @@ import * as appstore from './appstore/tools.js';
|
|
|
14
14
|
import * as appstoreScreenshots from './appstore/screenshots.js';
|
|
15
15
|
import * as iam from './iam/tools.js';
|
|
16
16
|
import { checkPlayStoreRisks, checkAppStoreRisks, formatRisks } from './checks/risks.js';
|
|
17
|
+
import { buildPlayStoreReleasePlan, buildAppStoreReleasePlan } from './checks/plan.js';
|
|
17
18
|
import { validateAppStoreScreenshots, validatePlayStoreScreenshots, formatValidationResults } from './checks/screenshots.js';
|
|
18
19
|
import { generateReleaseNotesFromCommits, formatGeneratedNotes } from './ai/notes.js';
|
|
19
20
|
import { generateReviewReply, formatReviewReply } from './ai/review.js';
|
|
@@ -1042,6 +1043,42 @@ server.tool('appstore_delete_product', '⚠️ 비가역. App Store IAP 상품
|
|
|
1042
1043
|
}
|
|
1043
1044
|
return { content: [{ type: 'text', text: `✓ ${productId} 삭제 완료` }] };
|
|
1044
1045
|
});
|
|
1046
|
+
// --- 배포 플랜 (read-only, AI에게 TodoWrite 지시) ---
|
|
1047
|
+
server.tool('playstore_plan_release', [
|
|
1048
|
+
'Google Play 배포 플랜 — 인증/빌드/메타데이터/스크린샷/정책 위험을 한 번에 점검하고',
|
|
1049
|
+
'체크리스트로 반환합니다 (read-only, 아무것도 변경하지 않음).',
|
|
1050
|
+
'⚠️ AI 호출자 지시: 응답을 받으면 미완료 항목을 반드시 TodoWrite로 사용자에게 먼저 보여주고,',
|
|
1051
|
+
'사용자 동의 후 단계별로 기존 도구(playstore_update_listing, playstore_update_release_notes,',
|
|
1052
|
+
'playstore_submit_release 등)를 호출하세요. submit_release(status=completed)는 비가역이므로 반드시 명시 동의 필요.',
|
|
1053
|
+
].join(' '), {
|
|
1054
|
+
packageName: z.string().describe('Android 패키지명 (예: gg.pryzm.coffee)'),
|
|
1055
|
+
versionCode: z.string().optional().describe('확인할 versionCode. 미지정 시 트랙 최신 release 검사'),
|
|
1056
|
+
track: z.enum(['production', 'beta', 'alpha', 'internal']).optional().describe('대상 트랙 (기본: production)'),
|
|
1057
|
+
language: z.string().optional().describe('점검할 리스팅 언어 (기본: ko-KR)'),
|
|
1058
|
+
}, async ({ packageName, versionCode, track, language }) => {
|
|
1059
|
+
const auth = requirePlayStoreAuth();
|
|
1060
|
+
const text = await buildPlayStoreReleasePlan({
|
|
1061
|
+
auth,
|
|
1062
|
+
packageName,
|
|
1063
|
+
versionCode,
|
|
1064
|
+
track: track ?? 'production',
|
|
1065
|
+
language: language ?? 'ko-KR',
|
|
1066
|
+
});
|
|
1067
|
+
return { content: [{ type: 'text', text }] };
|
|
1068
|
+
});
|
|
1069
|
+
server.tool('appstore_plan_release', [
|
|
1070
|
+
'App Store 배포 플랜 — 편집 가능한 버전/빌드 attach/메타/스크린샷/정책 위험을 한 번에 점검하고',
|
|
1071
|
+
'체크리스트로 반환합니다 (read-only).',
|
|
1072
|
+
'⚠️ AI 호출자 지시: 응답의 미완료 항목을 반드시 TodoWrite로 사용자에게 먼저 보여주고,',
|
|
1073
|
+
'사용자 동의 후 단계별로 기존 도구(appstore_update_localization, appstore_update_whats_new,',
|
|
1074
|
+
'appstore_submit_for_review 등)를 호출하세요. submit_for_review는 비가역이므로 반드시 명시 동의 필요.',
|
|
1075
|
+
].join(' '), {
|
|
1076
|
+
appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id)'),
|
|
1077
|
+
versionString: z.string().optional().describe('대상 버전 (예: 1.3.0). 미지정 시 가장 최근 편집 가능 버전'),
|
|
1078
|
+
}, async ({ appId, versionString }) => {
|
|
1079
|
+
const text = await buildAppStoreReleasePlan({ appId, versionString });
|
|
1080
|
+
return { content: [{ type: 'text', text }] };
|
|
1081
|
+
});
|
|
1045
1082
|
// --- App Store 심사 제출 (Submit for Review) ---
|
|
1046
1083
|
server.tool('appstore_submit_for_review', [
|
|
1047
1084
|
'App Store 버전을 심사에 제출 — 새 reviewSubmissions API 사용 (옛 /appStoreVersionSubmissions는 2024-01 deprecated).',
|
|
@@ -1130,34 +1167,43 @@ server.tool('mimi_seed_auth_start', [
|
|
|
1130
1167
|
}],
|
|
1131
1168
|
};
|
|
1132
1169
|
});
|
|
1133
|
-
server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인', {}, async () => {
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
content: [{
|
|
1138
|
-
type: 'text',
|
|
1139
|
-
text: '❌ 인증되지 않음.\n\n터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
|
|
1140
|
-
}],
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
|
-
const tokens = getStoredTokens();
|
|
1144
|
-
if (tokens?.expiry_date) {
|
|
1145
|
-
const now = Date.now();
|
|
1146
|
-
const msUntilExpiry = tokens.expiry_date - now;
|
|
1147
|
-
if (msUntilExpiry <= 0) {
|
|
1170
|
+
server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인 (만료 시 refresh_token으로 자동 갱신 시도)', {}, async () => {
|
|
1171
|
+
const r = await ensureFreshAccessToken();
|
|
1172
|
+
switch (r.status) {
|
|
1173
|
+
case 'unauthenticated':
|
|
1148
1174
|
return {
|
|
1149
|
-
content: [{
|
|
1175
|
+
content: [{
|
|
1176
|
+
type: 'text',
|
|
1177
|
+
text: `❌ [${r.error.code}] ${r.error.message}\n` +
|
|
1178
|
+
(r.error.hint ? `→ ${r.error.hint}\n\n` : '\n') +
|
|
1179
|
+
'터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
|
|
1180
|
+
}],
|
|
1150
1181
|
};
|
|
1182
|
+
case 'fresh': {
|
|
1183
|
+
const min = Math.round(r.msUntilExpiry / 60000);
|
|
1184
|
+
return { content: [{ type: 'text', text: `✅ 인증 유효 (${min}분 남음).` }] };
|
|
1151
1185
|
}
|
|
1152
|
-
|
|
1186
|
+
case 'refreshed': {
|
|
1187
|
+
const min = Math.round(r.msUntilExpiry / 60000);
|
|
1153
1188
|
return {
|
|
1154
|
-
content: [{
|
|
1189
|
+
content: [{
|
|
1190
|
+
type: 'text',
|
|
1191
|
+
text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).`,
|
|
1192
|
+
}],
|
|
1155
1193
|
};
|
|
1156
1194
|
}
|
|
1195
|
+
case 'expired_refresh_failed':
|
|
1196
|
+
return {
|
|
1197
|
+
content: [{
|
|
1198
|
+
type: 'text',
|
|
1199
|
+
text: `⚠️ 토큰 만료 + 자동 갱신 실패\n` +
|
|
1200
|
+
` 코드: ${r.error.code}\n` +
|
|
1201
|
+
` ${r.error.message}\n` +
|
|
1202
|
+
(r.error.hint ? ` → ${r.error.hint}\n` : '') +
|
|
1203
|
+
'\n터미널에서 재로그인:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
|
|
1204
|
+
}],
|
|
1205
|
+
};
|
|
1157
1206
|
}
|
|
1158
|
-
return {
|
|
1159
|
-
content: [{ type: 'text', text: '✅ 인증 완료.' }],
|
|
1160
|
-
};
|
|
1161
1207
|
});
|
|
1162
1208
|
// ══════════════════════════════════════════════════
|
|
1163
1209
|
// 제출 위험 점검 (Submission Risk Check)
|
package/package.json
CHANGED