@yoonion/mimi-seed-mcp 0.6.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -9
- package/dist/appstore/setup-cli.js +52 -15
- package/dist/auth/bigquery-setup-cli.js +76 -23
- package/dist/auth/cli.js +156 -53
- package/dist/auth/playstore-setup-cli.js +67 -20
- package/dist/facebook/setup.d.ts +6 -0
- package/dist/facebook/setup.js +72 -0
- package/dist/googleads/setup-cli.d.ts +2 -0
- package/dist/googleads/setup-cli.js +135 -0
- package/dist/index.js +3 -0
- package/dist/instagram/setup.d.ts +4 -0
- package/dist/instagram/setup.js +70 -0
- package/dist/jenkins/config.d.ts +11 -1
- package/dist/jenkins/config.js +14 -1
- package/dist/jenkins/setup-cli.d.ts +2 -0
- package/dist/jenkins/setup-cli.js +128 -0
- package/dist/lib/lang.d.ts +5 -0
- package/dist/lib/lang.js +33 -0
- package/dist/registers/auth.js +6 -5
- package/dist/registers/facebook.js +5 -75
- package/dist/registers/instagram.js +5 -65
- package/dist/social/setup-cli.d.ts +2 -0
- package/dist/social/setup-cli.js +191 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -42,17 +42,29 @@ npx -y @yoonion/mimi-seed-mcp mimi-seed-auth
|
|
|
42
42
|
|
|
43
43
|
브라우저가 열리면 Google 계정으로 로그인. 토큰은 `~/.mimi-seed/tokens.json`에 저장되고 자동 갱신됨.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
나머지 계정은 **마법사 하나로** 연결하는 게 가장 빠릅니다 — 뭐가 빠졌는지 보여주고, 각 토큰을 어디서
|
|
46
|
+
발급받는지도 알려줍니다:
|
|
46
47
|
|
|
47
48
|
```bash
|
|
48
|
-
npx
|
|
49
|
-
npx -y @yoonion/mimi-seed-mcp mimi-seed-playstore-auth # Google Play 서비스 계정 JSON
|
|
50
|
-
npx -y @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth # BigQuery (Crashlytics export 등)
|
|
49
|
+
npx mimi-seed setup
|
|
51
50
|
```
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
개별로 실행하고 싶다면 (전부 대화형):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx -y @yoonion/mimi-seed-mcp mimi-seed-appstore-auth # App Store Connect (API Key)
|
|
56
|
+
npx -y @yoonion/mimi-seed-mcp mimi-seed-playstore-auth # Google Play 서비스 계정 JSON
|
|
57
|
+
npx -y @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth # BigQuery
|
|
58
|
+
npx -y @yoonion/mimi-seed-mcp mimi-seed-jenkins-auth # Jenkins (저장 전 서버 프로브)
|
|
59
|
+
npx -y @yoonion/mimi-seed-mcp mimi-seed-googleads-auth # Google Ads (저장 전 실제 호출로 검증)
|
|
60
|
+
npx -y @yoonion/mimi-seed-mcp mimi-seed-social-auth # Facebook / Instagram
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
각 자격증명을 **어디서 어떻게 발급받는지**는 [`docs/credentials.md`](../../docs/credentials.md) 참고.
|
|
64
|
+
|
|
65
|
+
- **App Store Connect**: Users and Access → Integrations에서 API Key 생성 후 Issuer ID / Key ID / .p8 경로 입력 → `~/.mimi-seed/appstore.json` (.p8은 **1회만** 다운로드됨)
|
|
66
|
+
- **Google Play**: 서비스 계정은 **선택** — OAuth 토큰이 `androidpublisher` 스코프를 갖고 있어 로컬 작업은 그대로 됩니다. CI/헤드리스에서만 필요.
|
|
67
|
+
- **BigQuery**: 선택 — OAuth로도 동작하며, Workspace 재인증 정책에 막힐 때만 서비스 계정이 필요합니다.
|
|
56
68
|
|
|
57
69
|
AI 기능(릴리즈 노트 생성, 리뷰 답변)을 쓰려면:
|
|
58
70
|
|
|
@@ -66,8 +78,8 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
66
78
|
|
|
67
79
|
| 영역 | 도구 수 | 주요 도구 |
|
|
68
80
|
|------|---------|-----------|
|
|
69
|
-
| App Store Connect |
|
|
70
|
-
| Google Play |
|
|
81
|
+
| App Store Connect | 34 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
|
|
82
|
+
| Google Play | 29 | `playstore_submit_release` / `playstore_promote_release` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
|
|
71
83
|
| Firebase | 20 | `firebase_create_project` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_create_ios_app` |
|
|
72
84
|
| AdMob | 7 | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
|
|
73
85
|
| CI/CD (GitHub Actions · GitLab) | 6 | `ci_trigger_build` / `ci_get_build_status` / `ci_list_workflows` / `ci_cancel_build` |
|
|
@@ -2,34 +2,71 @@
|
|
|
2
2
|
import { saveAppStoreCredentials, getAppStoreCredentials } from './auth.js';
|
|
3
3
|
import readline from 'node:readline';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import { resolveLang } from '../lib/lang.js';
|
|
6
|
+
// ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
|
|
7
|
+
const ko = {
|
|
8
|
+
title: ' 🍎 Mimi Seed — App Store Connect 연결',
|
|
9
|
+
already: (keyId) => ` ✅ 이미 연결됨 (Key ID: ${keyId})`,
|
|
10
|
+
reconnect: ' 다시 설정할래? (y/N): ',
|
|
11
|
+
needTitle: ' App Store Connect에서 API Key를 만들어야 해:',
|
|
12
|
+
step1: ' 1. "키 생성" 클릭',
|
|
13
|
+
step2: ' 2. 이름: Mimi Seed, 역할: Admin',
|
|
14
|
+
step3: ' 3. .p8 파일 다운로드 (1회만 가능!)',
|
|
15
|
+
askIssuerId: ' Issuer ID: ',
|
|
16
|
+
askKeyId: ' Key ID: ',
|
|
17
|
+
askP8Path: ' .p8 파일 경로: ',
|
|
18
|
+
noFile: (p) => ` ❌ 파일 없음: ${p}`,
|
|
19
|
+
done: ' ✅ 연결 완료!',
|
|
20
|
+
nextTitle: ' 이제 Claude Code 또는 Codex에서:',
|
|
21
|
+
nextExample1: ' "내 앱스토어 앱 목록 보여줘"',
|
|
22
|
+
nextExample2: ' "TestFlight 빌드 목록 보여줘"',
|
|
23
|
+
};
|
|
24
|
+
const en = {
|
|
25
|
+
title: ' 🍎 Mimi Seed — Connect App Store Connect',
|
|
26
|
+
already: (keyId) => ` ✅ Already connected (Key ID: ${keyId})`,
|
|
27
|
+
reconnect: ' Set it up again? (y/N): ',
|
|
28
|
+
needTitle: ' You need to create an API Key in App Store Connect:',
|
|
29
|
+
step1: ' 1. Click "Generate API Key"',
|
|
30
|
+
step2: ' 2. Name: Mimi Seed, Access: Admin',
|
|
31
|
+
step3: ' 3. Download the .p8 file (you can only download it ONCE!)',
|
|
32
|
+
askIssuerId: ' Issuer ID: ',
|
|
33
|
+
askKeyId: ' Key ID: ',
|
|
34
|
+
askP8Path: ' Path to the .p8 file: ',
|
|
35
|
+
noFile: (p) => ` ❌ File not found: ${p}`,
|
|
36
|
+
done: ' ✅ Connected!',
|
|
37
|
+
nextTitle: ' Now, in Claude Code or Codex:',
|
|
38
|
+
nextExample1: ' "Show my App Store apps"',
|
|
39
|
+
nextExample2: ' "List the TestFlight builds"',
|
|
40
|
+
};
|
|
41
|
+
const M = resolveLang() === 'en' ? en : ko;
|
|
5
42
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
6
43
|
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
7
44
|
async function main() {
|
|
8
45
|
console.log('');
|
|
9
|
-
console.log(
|
|
46
|
+
console.log(M.title);
|
|
10
47
|
console.log('');
|
|
11
48
|
const existing = getAppStoreCredentials();
|
|
12
49
|
if (existing) {
|
|
13
|
-
console.log(
|
|
14
|
-
const answer = await ask(
|
|
50
|
+
console.log(M.already(existing.keyId));
|
|
51
|
+
const answer = await ask(M.reconnect);
|
|
15
52
|
if (answer.toLowerCase() !== 'y') {
|
|
16
53
|
rl.close();
|
|
17
54
|
return;
|
|
18
55
|
}
|
|
19
56
|
}
|
|
20
|
-
console.log(
|
|
57
|
+
console.log(M.needTitle);
|
|
21
58
|
console.log(' https://appstoreconnect.apple.com/access/integrations/api');
|
|
22
59
|
console.log('');
|
|
23
|
-
console.log(
|
|
24
|
-
console.log(
|
|
25
|
-
console.log(
|
|
60
|
+
console.log(M.step1);
|
|
61
|
+
console.log(M.step2);
|
|
62
|
+
console.log(M.step3);
|
|
26
63
|
console.log('');
|
|
27
|
-
const issuerId = await ask(
|
|
28
|
-
const keyId = await ask(
|
|
29
|
-
const p8Path = await ask(
|
|
64
|
+
const issuerId = await ask(M.askIssuerId);
|
|
65
|
+
const keyId = await ask(M.askKeyId);
|
|
66
|
+
const p8Path = await ask(M.askP8Path);
|
|
30
67
|
const trimmedPath = p8Path.trim().replace(/^["']|["']$/g, '');
|
|
31
68
|
if (!fs.existsSync(trimmedPath)) {
|
|
32
|
-
console.log(
|
|
69
|
+
console.log(M.noFile(trimmedPath));
|
|
33
70
|
rl.close();
|
|
34
71
|
process.exit(1);
|
|
35
72
|
}
|
|
@@ -40,11 +77,11 @@ async function main() {
|
|
|
40
77
|
privateKey,
|
|
41
78
|
});
|
|
42
79
|
console.log('');
|
|
43
|
-
console.log(
|
|
80
|
+
console.log(M.done);
|
|
44
81
|
console.log('');
|
|
45
|
-
console.log(
|
|
46
|
-
console.log(
|
|
47
|
-
console.log(
|
|
82
|
+
console.log(M.nextTitle);
|
|
83
|
+
console.log(M.nextExample1);
|
|
84
|
+
console.log(M.nextExample2);
|
|
48
85
|
console.log('');
|
|
49
86
|
rl.close();
|
|
50
87
|
}
|
|
@@ -4,36 +4,89 @@ import fs from 'node:fs';
|
|
|
4
4
|
import { saveBigQueryServiceAccountJson, getBigQueryServiceAccountKey, } from './bigquery-auth.js';
|
|
5
5
|
import * as bigquery from '../bigquery/tools.js';
|
|
6
6
|
import { JWT } from 'google-auth-library';
|
|
7
|
+
import { resolveLang } from '../lib/lang.js';
|
|
8
|
+
// ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
|
|
9
|
+
const ko = {
|
|
10
|
+
title: ' 🤖 Mimi Seed — BigQuery 서비스 계정 연결',
|
|
11
|
+
whySa1: ' 서비스 계정 인증은 Google Workspace 의 재인증(reauth) 정책에서',
|
|
12
|
+
whySa2: ' 면제되므로, OAuth 가 invalid_rapt 로 막히는 환경에서도 동작해.',
|
|
13
|
+
already: (email) => ` ✅ 이미 연결됨 (${email})`,
|
|
14
|
+
reconnect: ' 다시 설정할래? (y/N): ',
|
|
15
|
+
needTitle: ' BigQuery 를 읽을 수 있는 서비스 계정 키 JSON 이 필요해:',
|
|
16
|
+
step1: ' 1. GCP Console → IAM & Admin → Service Accounts',
|
|
17
|
+
step2: ' 2. 서비스 계정 선택(또는 생성) → Keys → Add Key → JSON',
|
|
18
|
+
step3: ' 3. 그 서비스 계정에 프로젝트 IAM 역할 부여:',
|
|
19
|
+
roleJobUser: ' • roles/bigquery.jobUser (쿼리 작업 생성)',
|
|
20
|
+
roleDataViewer: ' • roles/bigquery.dataViewer (데이터셋 읽기)',
|
|
21
|
+
step4: ' 4. 다운로드한 JSON 파일 경로를 입력해',
|
|
22
|
+
askPath: ' 서비스 계정 JSON 파일 경로: ',
|
|
23
|
+
noFile: (p) => ` ❌ 파일 없음: ${p}`,
|
|
24
|
+
saved: (email) => ` ✅ 저장 완료! (${email})`,
|
|
25
|
+
saProject: (p) => ` 서비스 계정 프로젝트: ${p}`,
|
|
26
|
+
askTestProject: (fallback) => ` 연결 테스트할 GCP 프로젝트 ID (엔터 시 건너뜀${fallback ? `, 기본 ${fallback}` : ''}): `,
|
|
27
|
+
probing: (projectId) => ` 🔎 ${projectId} 데이터셋 조회 중...`,
|
|
28
|
+
probeOk: (n) => ` ✅ 접근 OK — 데이터셋 ${n}개`,
|
|
29
|
+
probeFail: (msg) => ` ⚠️ 접근 실패: ${msg}`,
|
|
30
|
+
grantHint: ' IAM 역할이 없으면 아래를 실행해 (프로젝트 소유자 계정에서):',
|
|
31
|
+
nextTitle: ' 이제 Claude Code 또는 Codex에서:',
|
|
32
|
+
nextExample: ' "BigQuery 로 GA4 트래픽 분석해줘"',
|
|
33
|
+
};
|
|
34
|
+
const en = {
|
|
35
|
+
title: ' 🤖 Mimi Seed — Connect a BigQuery service account',
|
|
36
|
+
whySa1: ' Service-account auth is exempt from the Google Workspace reauth policy,',
|
|
37
|
+
whySa2: ' so it still works in environments where OAuth is blocked by invalid_rapt.',
|
|
38
|
+
already: (email) => ` ✅ Already connected (${email})`,
|
|
39
|
+
reconnect: ' Set it up again? (y/N): ',
|
|
40
|
+
needTitle: ' A service-account key JSON that can read BigQuery is required:',
|
|
41
|
+
step1: ' 1. GCP Console → IAM & Admin → Service Accounts',
|
|
42
|
+
step2: ' 2. Pick (or create) a service account → Keys → Add Key → JSON',
|
|
43
|
+
step3: ' 3. Grant that service account these project IAM roles yourself:',
|
|
44
|
+
roleJobUser: ' • roles/bigquery.jobUser (create query jobs)',
|
|
45
|
+
roleDataViewer: ' • roles/bigquery.dataViewer (read datasets)',
|
|
46
|
+
step4: ' 4. Enter the path to the downloaded JSON file',
|
|
47
|
+
askPath: ' Path to the service-account JSON file: ',
|
|
48
|
+
noFile: (p) => ` ❌ File not found: ${p}`,
|
|
49
|
+
saved: (email) => ` ✅ Saved! (${email})`,
|
|
50
|
+
saProject: (p) => ` Service-account project: ${p}`,
|
|
51
|
+
askTestProject: (fallback) => ` GCP project ID to test against (Enter to skip${fallback ? `, default ${fallback}` : ''}): `,
|
|
52
|
+
probing: (projectId) => ` 🔎 Listing datasets in ${projectId}...`,
|
|
53
|
+
probeOk: (n) => ` ✅ Access OK — ${n} dataset(s)`,
|
|
54
|
+
probeFail: (msg) => ` ⚠️ Access failed: ${msg}`,
|
|
55
|
+
grantHint: ' If the IAM roles are missing, run this (as a project owner):',
|
|
56
|
+
nextTitle: ' Now, in Claude Code or Codex:',
|
|
57
|
+
nextExample: ' "Analyze GA4 traffic with BigQuery"',
|
|
58
|
+
};
|
|
59
|
+
const M = resolveLang() === 'en' ? en : ko;
|
|
7
60
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
8
61
|
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
9
62
|
async function main() {
|
|
10
63
|
console.log('');
|
|
11
|
-
console.log(
|
|
64
|
+
console.log(M.title);
|
|
12
65
|
console.log('');
|
|
13
|
-
console.log(
|
|
14
|
-
console.log(
|
|
66
|
+
console.log(M.whySa1);
|
|
67
|
+
console.log(M.whySa2);
|
|
15
68
|
console.log('');
|
|
16
69
|
const existing = getBigQueryServiceAccountKey();
|
|
17
70
|
if (existing) {
|
|
18
|
-
console.log(
|
|
19
|
-
const answer = await ask(
|
|
71
|
+
console.log(M.already(existing.client_email));
|
|
72
|
+
const answer = await ask(M.reconnect);
|
|
20
73
|
if (answer.toLowerCase() !== 'y') {
|
|
21
74
|
rl.close();
|
|
22
75
|
return;
|
|
23
76
|
}
|
|
24
77
|
}
|
|
25
|
-
console.log(
|
|
26
|
-
console.log(
|
|
27
|
-
console.log(
|
|
28
|
-
console.log(
|
|
29
|
-
console.log(
|
|
30
|
-
console.log(
|
|
31
|
-
console.log(
|
|
78
|
+
console.log(M.needTitle);
|
|
79
|
+
console.log(M.step1);
|
|
80
|
+
console.log(M.step2);
|
|
81
|
+
console.log(M.step3);
|
|
82
|
+
console.log(M.roleJobUser);
|
|
83
|
+
console.log(M.roleDataViewer);
|
|
84
|
+
console.log(M.step4);
|
|
32
85
|
console.log('');
|
|
33
|
-
const jsonPath = await ask(
|
|
86
|
+
const jsonPath = await ask(M.askPath);
|
|
34
87
|
const trimmedPath = jsonPath.trim().replace(/^["']|["']$/g, '');
|
|
35
88
|
if (!fs.existsSync(trimmedPath)) {
|
|
36
|
-
console.log(
|
|
89
|
+
console.log(M.noFile(trimmedPath));
|
|
37
90
|
rl.close();
|
|
38
91
|
process.exit(1);
|
|
39
92
|
}
|
|
@@ -48,15 +101,15 @@ async function main() {
|
|
|
48
101
|
process.exit(1);
|
|
49
102
|
}
|
|
50
103
|
console.log('');
|
|
51
|
-
console.log(
|
|
104
|
+
console.log(M.saved(parsed.client_email));
|
|
52
105
|
if (parsed.project_id)
|
|
53
|
-
console.log(
|
|
106
|
+
console.log(M.saProject(parsed.project_id));
|
|
54
107
|
console.log('');
|
|
55
108
|
// 선택적 연결 테스트 — projectId 를 입력하면 datasets.list 로 권한 확인.
|
|
56
|
-
const testProject = await ask(
|
|
109
|
+
const testProject = await ask(M.askTestProject(parsed.project_id ?? ''));
|
|
57
110
|
const projectId = testProject.trim() || parsed.project_id || '';
|
|
58
111
|
if (projectId) {
|
|
59
|
-
console.log(
|
|
112
|
+
console.log(M.probing(projectId));
|
|
60
113
|
try {
|
|
61
114
|
const jwt = new JWT({
|
|
62
115
|
email: parsed.client_email,
|
|
@@ -67,15 +120,15 @@ async function main() {
|
|
|
67
120
|
],
|
|
68
121
|
});
|
|
69
122
|
const datasets = await bigquery.listDatasets(jwt, projectId);
|
|
70
|
-
console.log(
|
|
123
|
+
console.log(M.probeOk(datasets.length));
|
|
71
124
|
for (const d of datasets.slice(0, 10))
|
|
72
125
|
console.log(` • ${d.datasetId} (${d.location})`);
|
|
73
126
|
}
|
|
74
127
|
catch (e) {
|
|
75
128
|
const msg = e instanceof Error ? e.message : String(e);
|
|
76
|
-
console.log(
|
|
129
|
+
console.log(M.probeFail(msg));
|
|
77
130
|
console.log('');
|
|
78
|
-
console.log(
|
|
131
|
+
console.log(M.grantHint);
|
|
79
132
|
console.log(` gcloud projects add-iam-policy-binding ${projectId} \\`);
|
|
80
133
|
console.log(` --member="serviceAccount:${parsed.client_email}" --role="roles/bigquery.jobUser"`);
|
|
81
134
|
console.log(` gcloud projects add-iam-policy-binding ${projectId} \\`);
|
|
@@ -83,8 +136,8 @@ async function main() {
|
|
|
83
136
|
}
|
|
84
137
|
}
|
|
85
138
|
console.log('');
|
|
86
|
-
console.log(
|
|
87
|
-
console.log(
|
|
139
|
+
console.log(M.nextTitle);
|
|
140
|
+
console.log(M.nextExample);
|
|
88
141
|
console.log('');
|
|
89
142
|
rl.close();
|
|
90
143
|
}
|
package/dist/auth/cli.js
CHANGED
|
@@ -4,20 +4,12 @@ import open from 'open';
|
|
|
4
4
|
import { startAuth, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
|
|
5
5
|
import { AuthError, classifyError } from './errors.js';
|
|
6
6
|
import { getMcpOAuthClient } from './constants.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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(`
|
|
7
|
+
import { resolveLang } from '../lib/lang.js';
|
|
8
|
+
// ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
|
|
9
|
+
// 여기 있는 건 전부 **터미널에 찍히는 사람용 문자열**이다. errors.ts 가 만드는
|
|
10
|
+
// code/message/hint 는 라이브러리 텍스트라 그대로 출력한다.
|
|
11
|
+
const ko = {
|
|
12
|
+
help: `
|
|
21
13
|
☕ mimi-seed-auth — Google OAuth 인증 CLI
|
|
22
14
|
|
|
23
15
|
사용법:
|
|
@@ -31,21 +23,132 @@ function printHelp() {
|
|
|
31
23
|
--timeout <초> 콜백 대기 시간 (기본 600)
|
|
32
24
|
--force 기존 토큰 무시하고 강제 재로그인
|
|
33
25
|
--help 이 도움말
|
|
34
|
-
|
|
26
|
+
`,
|
|
27
|
+
expired: '만료됨',
|
|
28
|
+
minsLeft: (n) => `${n}분 남음`,
|
|
29
|
+
hoursLeft: (n) => `${n}시간 남음`,
|
|
30
|
+
daysLeft: (n) => `${n}일 남음`,
|
|
31
|
+
errCode: (code) => ` 코드: ${code}`,
|
|
32
|
+
statusTitle: ' ☕ Mimi Seed — 인증 상태',
|
|
33
|
+
statusFresh: (left) => ` ✅ 연결됨 — 토큰 유효 (${left})`,
|
|
34
|
+
statusRefreshed: (left) => ` ✅ 연결됨 — refresh_token으로 갱신 (${left})`,
|
|
35
|
+
statusExpired: ' ⚠️ 토큰 만료 + 자동 갱신 실패',
|
|
36
|
+
statusNone: ' ❌ 연결된 계정 없음.',
|
|
37
|
+
refreshTrying: ' 🔄 refresh_token으로 갱신 시도 중...',
|
|
38
|
+
refreshNotNeeded: (left) => ` ✅ 토큰 유효 — 갱신 불필요 (${left})`,
|
|
39
|
+
refreshDone: (left) => ` ✅ 갱신 완료 (${left})`,
|
|
40
|
+
refreshFailed: ' ❌ refresh 실패',
|
|
41
|
+
refreshNoToken: ' ❌ 저장된 토큰 없음.',
|
|
42
|
+
logoutDone: ' ✅ 토큰 삭제 완료.',
|
|
43
|
+
logoutAlready: ' (이미 삭제된 상태)',
|
|
44
|
+
loginTitle: ' ☕ Mimi Seed — Google 계정 연결',
|
|
45
|
+
loginChecking: ' 🔍 기존 토큰 검사 중...',
|
|
46
|
+
loginValid: '유효함',
|
|
47
|
+
loginRefreshed: 'refresh_token으로 갱신 완료',
|
|
48
|
+
loginAlready: (label, left) => ` ✅ 이미 연결됨 (${label}, ${left}).`,
|
|
49
|
+
loginAgain: ' 다시 로그인할래? (y/N): ',
|
|
50
|
+
loginExpiredRelogin: (code) => ` ⚠️ 토큰 만료 + 자동 갱신 실패 [${code}] — 재로그인 진행.`,
|
|
51
|
+
serverStart: ' 🌐 OAuth 콜백 서버 시작: http://localhost:9876/callback',
|
|
52
|
+
serverFail: ' ❌ 콜백 서버 시작 실패',
|
|
53
|
+
pasteUrl: ' 📋 아래 URL을 브라우저에 직접 붙여넣으세요:',
|
|
54
|
+
openingBrowser: ' 🌐 기본 브라우저 자동 열기...',
|
|
55
|
+
openingHint: ' (실패 시 --no-browser 로 URL 직접 받기)',
|
|
56
|
+
openFail: (msg) => ` ⚠️ 브라우저 자동 열기 실패: ${msg}`,
|
|
57
|
+
openManually: ' 📋 직접 열어주세요:',
|
|
58
|
+
waiting: (sec) => ` ⏳ Google 승인 대기 중... (timeout ${sec}s)`,
|
|
59
|
+
authFailed: ' ❌ 인증 실패',
|
|
60
|
+
done: ' ✅ 연결 완료!',
|
|
61
|
+
nextTitle: ' 이제 Claude Code 또는 Codex에서 이렇게 쓸 수 있어:',
|
|
62
|
+
nextExample1: ' "내 Firebase 프로젝트 보여줘"',
|
|
63
|
+
nextExample2: ' "새 Android 앱 등록해줘"',
|
|
64
|
+
nextExample3: ' "google-services.json 다운로드해줘"',
|
|
65
|
+
fatal: (msg) => ` ❌ 예외: ${msg}`,
|
|
66
|
+
};
|
|
67
|
+
const en = {
|
|
68
|
+
help: `
|
|
69
|
+
☕ mimi-seed-auth — Google OAuth CLI
|
|
70
|
+
|
|
71
|
+
Usage:
|
|
72
|
+
mimi-seed-auth # log in (tries a silent refresh if a token exists)
|
|
73
|
+
mimi-seed-auth --refresh # only refresh with refresh_token (no browser)
|
|
74
|
+
mimi-seed-auth --status # print the current token status
|
|
75
|
+
mimi-seed-auth --logout # delete the token
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--no-browser Do not open the URL automatically (copy-paste it yourself)
|
|
79
|
+
--timeout <sec> How long to wait for the callback (default 600)
|
|
80
|
+
--force Ignore the existing token and force a re-login
|
|
81
|
+
--help This help
|
|
82
|
+
`,
|
|
83
|
+
expired: 'expired',
|
|
84
|
+
minsLeft: (n) => `${n} min left`,
|
|
85
|
+
hoursLeft: (n) => `${n} hr left`,
|
|
86
|
+
daysLeft: (n) => `${n} days left`,
|
|
87
|
+
errCode: (code) => ` code: ${code}`,
|
|
88
|
+
statusTitle: ' ☕ Mimi Seed — Auth status',
|
|
89
|
+
statusFresh: (left) => ` ✅ Connected — token valid (${left})`,
|
|
90
|
+
statusRefreshed: (left) => ` ✅ Connected — refreshed with refresh_token (${left})`,
|
|
91
|
+
statusExpired: ' ⚠️ Token expired + automatic refresh failed',
|
|
92
|
+
statusNone: ' ❌ No connected account.',
|
|
93
|
+
refreshTrying: ' 🔄 Trying to refresh with refresh_token...',
|
|
94
|
+
refreshNotNeeded: (left) => ` ✅ Token still valid — no refresh needed (${left})`,
|
|
95
|
+
refreshDone: (left) => ` ✅ Refreshed (${left})`,
|
|
96
|
+
refreshFailed: ' ❌ Refresh failed',
|
|
97
|
+
refreshNoToken: ' ❌ No stored token.',
|
|
98
|
+
logoutDone: ' ✅ Token deleted.',
|
|
99
|
+
logoutAlready: ' (already deleted)',
|
|
100
|
+
loginTitle: ' ☕ Mimi Seed — Connect your Google account',
|
|
101
|
+
loginChecking: ' 🔍 Checking the existing token...',
|
|
102
|
+
loginValid: 'valid',
|
|
103
|
+
loginRefreshed: 'refreshed with refresh_token',
|
|
104
|
+
loginAlready: (label, left) => ` ✅ Already connected (${label}, ${left}).`,
|
|
105
|
+
loginAgain: ' Log in again? (y/N): ',
|
|
106
|
+
loginExpiredRelogin: (code) => ` ⚠️ Token expired + automatic refresh failed [${code}] — re-logging in.`,
|
|
107
|
+
serverStart: ' 🌐 Starting the OAuth callback server: http://localhost:9876/callback',
|
|
108
|
+
serverFail: ' ❌ Failed to start the callback server',
|
|
109
|
+
pasteUrl: ' 📋 Paste this URL into your browser:',
|
|
110
|
+
openingBrowser: ' 🌐 Opening your default browser...',
|
|
111
|
+
openingHint: ' (if that fails, use --no-browser to get the URL)',
|
|
112
|
+
openFail: (msg) => ` ⚠️ Could not open the browser: ${msg}`,
|
|
113
|
+
openManually: ' 📋 Please open it yourself:',
|
|
114
|
+
waiting: (sec) => ` ⏳ Waiting for Google approval... (timeout ${sec}s)`,
|
|
115
|
+
authFailed: ' ❌ Authentication failed',
|
|
116
|
+
done: ' ✅ Connected!',
|
|
117
|
+
nextTitle: ' Now you can say things like this in Claude Code or Codex:',
|
|
118
|
+
nextExample1: ' "Show my Firebase projects"',
|
|
119
|
+
nextExample2: ' "Register a new Android app"',
|
|
120
|
+
nextExample3: ' "Download google-services.json"',
|
|
121
|
+
fatal: (msg) => ` ❌ Exception: ${msg}`,
|
|
122
|
+
};
|
|
123
|
+
const M = resolveLang() === 'en' ? en : ko;
|
|
124
|
+
const args = process.argv.slice(2);
|
|
125
|
+
const hasFlag = (name) => args.includes(`--${name}`);
|
|
126
|
+
const flagValue = (name) => {
|
|
127
|
+
const i = args.indexOf(`--${name}`);
|
|
128
|
+
if (i < 0)
|
|
129
|
+
return undefined;
|
|
130
|
+
const v = args[i + 1];
|
|
131
|
+
return v && !v.startsWith('--') ? v : undefined;
|
|
132
|
+
};
|
|
133
|
+
function err(msg) {
|
|
134
|
+
process.stderr.write(msg + '\n');
|
|
135
|
+
}
|
|
136
|
+
function printHelp() {
|
|
137
|
+
err(M.help);
|
|
35
138
|
}
|
|
36
139
|
function fmtRemaining(ms) {
|
|
37
140
|
if (ms <= 0)
|
|
38
|
-
return
|
|
141
|
+
return M.expired;
|
|
39
142
|
const min = Math.round(ms / 60000);
|
|
40
143
|
if (min < 60)
|
|
41
|
-
return
|
|
144
|
+
return M.minsLeft(min);
|
|
42
145
|
const hr = Math.round(min / 60);
|
|
43
146
|
if (hr < 48)
|
|
44
|
-
return
|
|
45
|
-
return
|
|
147
|
+
return M.hoursLeft(hr);
|
|
148
|
+
return M.daysLeft(Math.round(hr / 24));
|
|
46
149
|
}
|
|
47
150
|
function printAuthError(p) {
|
|
48
|
-
err(
|
|
151
|
+
err(M.errCode(p.code));
|
|
49
152
|
err(` ${p.message}`);
|
|
50
153
|
if (p.hint)
|
|
51
154
|
err(` → ${p.hint}`);
|
|
@@ -54,25 +157,25 @@ function printAuthError(p) {
|
|
|
54
157
|
}
|
|
55
158
|
async function cmdStatus() {
|
|
56
159
|
err('');
|
|
57
|
-
err(
|
|
160
|
+
err(M.statusTitle);
|
|
58
161
|
err('');
|
|
59
162
|
const r = await ensureFreshAccessToken();
|
|
60
163
|
switch (r.status) {
|
|
61
164
|
case 'fresh':
|
|
62
|
-
err(
|
|
165
|
+
err(M.statusFresh(fmtRemaining(r.msUntilExpiry)));
|
|
63
166
|
err('');
|
|
64
167
|
return 0;
|
|
65
168
|
case 'refreshed':
|
|
66
|
-
err(
|
|
169
|
+
err(M.statusRefreshed(fmtRemaining(r.msUntilExpiry)));
|
|
67
170
|
err('');
|
|
68
171
|
return 0;
|
|
69
172
|
case 'expired_refresh_failed':
|
|
70
|
-
err(
|
|
173
|
+
err(M.statusExpired);
|
|
71
174
|
printAuthError(r.error);
|
|
72
175
|
err('');
|
|
73
176
|
return 2;
|
|
74
177
|
case 'unauthenticated':
|
|
75
|
-
err(
|
|
178
|
+
err(M.statusNone);
|
|
76
179
|
printAuthError(r.error);
|
|
77
180
|
err('');
|
|
78
181
|
return 1;
|
|
@@ -80,25 +183,25 @@ async function cmdStatus() {
|
|
|
80
183
|
}
|
|
81
184
|
async function cmdRefresh() {
|
|
82
185
|
err('');
|
|
83
|
-
err(
|
|
186
|
+
err(M.refreshTrying);
|
|
84
187
|
err('');
|
|
85
188
|
const r = await ensureFreshAccessToken(0); // 무조건 갱신 시도
|
|
86
189
|
switch (r.status) {
|
|
87
190
|
case 'fresh':
|
|
88
|
-
err(
|
|
191
|
+
err(M.refreshNotNeeded(fmtRemaining(r.msUntilExpiry)));
|
|
89
192
|
err('');
|
|
90
193
|
return 0;
|
|
91
194
|
case 'refreshed':
|
|
92
|
-
err(
|
|
195
|
+
err(M.refreshDone(fmtRemaining(r.msUntilExpiry)));
|
|
93
196
|
err('');
|
|
94
197
|
return 0;
|
|
95
198
|
case 'expired_refresh_failed':
|
|
96
|
-
err(
|
|
199
|
+
err(M.refreshFailed);
|
|
97
200
|
printAuthError(r.error);
|
|
98
201
|
err('');
|
|
99
202
|
return 2;
|
|
100
203
|
case 'unauthenticated':
|
|
101
|
-
err(
|
|
204
|
+
err(M.refreshNoToken);
|
|
102
205
|
printAuthError(r.error);
|
|
103
206
|
err('');
|
|
104
207
|
return 1;
|
|
@@ -112,10 +215,10 @@ async function cmdLogout() {
|
|
|
112
215
|
err('');
|
|
113
216
|
if (fs.existsSync(tokenPath)) {
|
|
114
217
|
fs.rmSync(tokenPath, { force: true });
|
|
115
|
-
err(
|
|
218
|
+
err(M.logoutDone);
|
|
116
219
|
}
|
|
117
220
|
else {
|
|
118
|
-
err(
|
|
221
|
+
err(M.logoutAlready);
|
|
119
222
|
}
|
|
120
223
|
err('');
|
|
121
224
|
return 0;
|
|
@@ -125,20 +228,20 @@ async function cmdLogin() {
|
|
|
125
228
|
const force = hasFlag('force');
|
|
126
229
|
const timeoutSec = parseInt(flagValue('timeout') ?? '600', 10);
|
|
127
230
|
err('');
|
|
128
|
-
err(
|
|
231
|
+
err(M.loginTitle);
|
|
129
232
|
err('');
|
|
130
233
|
// 1) 기존 토큰이 있으면 silent refresh 먼저 시도
|
|
131
234
|
if (!force) {
|
|
132
235
|
const existing = getStoredTokens();
|
|
133
236
|
if (existing) {
|
|
134
|
-
err(
|
|
237
|
+
err(M.loginChecking);
|
|
135
238
|
const r = await ensureFreshAccessToken();
|
|
136
239
|
if (r.status === 'fresh' || r.status === 'refreshed') {
|
|
137
|
-
const label = r.status === 'fresh' ?
|
|
138
|
-
err(
|
|
240
|
+
const label = r.status === 'fresh' ? M.loginValid : M.loginRefreshed;
|
|
241
|
+
err(M.loginAlready(label, fmtRemaining(r.msUntilExpiry)));
|
|
139
242
|
err('');
|
|
140
243
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
141
|
-
const answer = await new Promise((res) => rl.question(
|
|
244
|
+
const answer = await new Promise((res) => rl.question(M.loginAgain, res));
|
|
142
245
|
rl.close();
|
|
143
246
|
if (answer.toLowerCase() !== 'y') {
|
|
144
247
|
err('');
|
|
@@ -146,7 +249,7 @@ async function cmdLogin() {
|
|
|
146
249
|
}
|
|
147
250
|
}
|
|
148
251
|
else if (r.status === 'expired_refresh_failed') {
|
|
149
|
-
err(
|
|
252
|
+
err(M.loginExpiredRelogin(r.error.code));
|
|
150
253
|
if (r.error.cause && process.env.DEBUG)
|
|
151
254
|
err(` cause: ${r.error.cause}`);
|
|
152
255
|
}
|
|
@@ -154,7 +257,7 @@ async function cmdLogin() {
|
|
|
154
257
|
}
|
|
155
258
|
// 2) OAuth 콜백 서버 + URL 발급
|
|
156
259
|
err('');
|
|
157
|
-
err(
|
|
260
|
+
err(M.serverStart);
|
|
158
261
|
let url;
|
|
159
262
|
let wait;
|
|
160
263
|
try {
|
|
@@ -166,7 +269,7 @@ async function cmdLogin() {
|
|
|
166
269
|
wait = r.wait;
|
|
167
270
|
}
|
|
168
271
|
catch (e) {
|
|
169
|
-
err(
|
|
272
|
+
err(M.serverFail);
|
|
170
273
|
printAuthError(classifyError(e, { phase: 'login' }));
|
|
171
274
|
err('');
|
|
172
275
|
return 1;
|
|
@@ -174,25 +277,25 @@ async function cmdLogin() {
|
|
|
174
277
|
// 3) 브라우저 열기 (or URL 출력)
|
|
175
278
|
if (noBrowser) {
|
|
176
279
|
err('');
|
|
177
|
-
err(
|
|
280
|
+
err(M.pasteUrl);
|
|
178
281
|
err('');
|
|
179
282
|
err(' ' + url);
|
|
180
283
|
err('');
|
|
181
284
|
}
|
|
182
285
|
else {
|
|
183
|
-
err(
|
|
286
|
+
err(M.openingBrowser);
|
|
184
287
|
try {
|
|
185
288
|
await open(url);
|
|
186
|
-
err(
|
|
289
|
+
err(M.openingHint);
|
|
187
290
|
}
|
|
188
291
|
catch (e) {
|
|
189
|
-
err(
|
|
190
|
-
err(
|
|
292
|
+
err(M.openFail(e instanceof Error ? e.message : String(e)));
|
|
293
|
+
err(M.openManually);
|
|
191
294
|
err(' ' + url);
|
|
192
295
|
}
|
|
193
296
|
}
|
|
194
297
|
// 4) 콜백 대기
|
|
195
|
-
err(
|
|
298
|
+
err(M.waiting(timeoutSec));
|
|
196
299
|
// 진행 표시기 — 사용자에게 살아있다는 신호 전달
|
|
197
300
|
const ticker = setInterval(() => process.stderr.write('.'), 5000);
|
|
198
301
|
try {
|
|
@@ -201,7 +304,7 @@ async function cmdLogin() {
|
|
|
201
304
|
catch (e) {
|
|
202
305
|
clearInterval(ticker);
|
|
203
306
|
err('');
|
|
204
|
-
err(
|
|
307
|
+
err(M.authFailed);
|
|
205
308
|
if (e instanceof AuthError) {
|
|
206
309
|
printAuthError(e.payload);
|
|
207
310
|
}
|
|
@@ -214,12 +317,12 @@ async function cmdLogin() {
|
|
|
214
317
|
clearInterval(ticker);
|
|
215
318
|
err('');
|
|
216
319
|
err('');
|
|
217
|
-
err(
|
|
320
|
+
err(M.done);
|
|
218
321
|
err('');
|
|
219
|
-
err(
|
|
220
|
-
err(
|
|
221
|
-
err(
|
|
222
|
-
err(
|
|
322
|
+
err(M.nextTitle);
|
|
323
|
+
err(M.nextExample1);
|
|
324
|
+
err(M.nextExample2);
|
|
325
|
+
err(M.nextExample3);
|
|
223
326
|
err('');
|
|
224
327
|
return 0;
|
|
225
328
|
}
|
|
@@ -241,7 +344,7 @@ async function main() {
|
|
|
241
344
|
}
|
|
242
345
|
main().catch((e) => {
|
|
243
346
|
err('');
|
|
244
|
-
err(
|
|
347
|
+
err(M.fatal(e instanceof Error ? e.message : String(e)));
|
|
245
348
|
err('');
|
|
246
349
|
process.exit(1);
|
|
247
350
|
});
|