@yoonion/mimi-seed-mcp 0.7.0 → 0.8.1

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.
@@ -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(' 🍎 Mimi Seed — App Store Connect 연결');
46
+ console.log(M.title);
10
47
  console.log('');
11
48
  const existing = getAppStoreCredentials();
12
49
  if (existing) {
13
- console.log(` ✅ 이미 연결됨 (Key ID: ${existing.keyId})`);
14
- const answer = await ask(' 다시 설정할래? (y/N): ');
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(' App Store Connect에서 API Key를 만들어야 해:');
57
+ console.log(M.needTitle);
21
58
  console.log(' https://appstoreconnect.apple.com/access/integrations/api');
22
59
  console.log('');
23
- console.log(' 1. "키 생성" 클릭');
24
- console.log(' 2. 이름: Mimi Seed, 역할: Admin');
25
- console.log(' 3. .p8 파일 다운로드 (1회만 가능!)');
60
+ console.log(M.step1);
61
+ console.log(M.step2);
62
+ console.log(M.step3);
26
63
  console.log('');
27
- const issuerId = await ask(' Issuer ID: ');
28
- const keyId = await ask(' Key ID: ');
29
- const p8Path = await ask(' .p8 파일 경로: ');
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(` ❌ 파일 없음: ${trimmedPath}`);
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(' 이제 Claude Code 또는 Codex에서:');
46
- console.log(' "내 앱스토어 앱 목록 보여줘"');
47
- console.log(' "TestFlight 빌드 목록 보여줘"');
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(' 🤖 Mimi Seed — BigQuery 서비스 계정 연결');
64
+ console.log(M.title);
12
65
  console.log('');
13
- console.log(' 서비스 계정 인증은 Google Workspace 의 재인증(reauth) 정책에서');
14
- console.log(' 면제되므로, OAuth 가 invalid_rapt 로 막히는 환경에서도 동작해.');
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(` ✅ 이미 연결됨 (${existing.client_email})`);
19
- const answer = await ask(' 다시 설정할래? (y/N): ');
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(' BigQuery 를 읽을 수 있는 서비스 계정 키 JSON 이 필요해:');
26
- console.log(' 1. GCP Console → IAM & Admin → Service Accounts');
27
- console.log(' 2. 서비스 계정 선택(또는 생성) → Keys → Add Key → JSON');
28
- console.log(' 3. 그 서비스 계정에 프로젝트 IAM 역할 부여:');
29
- console.log(' • roles/bigquery.jobUser (쿼리 작업 생성)');
30
- console.log(' • roles/bigquery.dataViewer (데이터셋 읽기)');
31
- console.log(' 4. 다운로드한 JSON 파일 경로를 입력해');
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(' 서비스 계정 JSON 파일 경로: ');
86
+ const jsonPath = await ask(M.askPath);
34
87
  const trimmedPath = jsonPath.trim().replace(/^["']|["']$/g, '');
35
88
  if (!fs.existsSync(trimmedPath)) {
36
- console.log(` ❌ 파일 없음: ${trimmedPath}`);
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(` ✅ 저장 완료! (${parsed.client_email})`);
104
+ console.log(M.saved(parsed.client_email));
52
105
  if (parsed.project_id)
53
- console.log(` 서비스 계정 프로젝트: ${parsed.project_id}`);
106
+ console.log(M.saProject(parsed.project_id));
54
107
  console.log('');
55
108
  // 선택적 연결 테스트 — projectId 를 입력하면 datasets.list 로 권한 확인.
56
- const testProject = await ask(` 연결 테스트할 GCP 프로젝트 ID (엔터 시 건너뜀${parsed.project_id ? `, 기본 ${parsed.project_id}` : ''}): `);
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(` 🔎 ${projectId} 데이터셋 조회 중...`);
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(` ✅ 접근 OK — 데이터셋 ${datasets.length}개`);
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(` ⚠️ 접근 실패: ${msg}`);
129
+ console.log(M.probeFail(msg));
77
130
  console.log('');
78
- console.log(' IAM 역할이 없으면 아래를 실행해 (프로젝트 소유자 계정에서):');
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(' 이제 Claude Code 또는 Codex에서:');
87
- console.log(' "BigQuery 로 GA4 트래픽 분석해줘"');
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
- 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(`
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 `${min}분 남음`;
144
+ return M.minsLeft(min);
42
145
  const hr = Math.round(min / 60);
43
146
  if (hr < 48)
44
- return `${hr}시간 남음`;
45
- return `${Math.round(hr / 24)}일 남음`;
147
+ return M.hoursLeft(hr);
148
+ return M.daysLeft(Math.round(hr / 24));
46
149
  }
47
150
  function printAuthError(p) {
48
- err(` 코드: ${p.code}`);
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(' ☕ Mimi Seed — 인증 상태');
160
+ err(M.statusTitle);
58
161
  err('');
59
162
  const r = await ensureFreshAccessToken();
60
163
  switch (r.status) {
61
164
  case 'fresh':
62
- err(` ✅ 연결됨 — 토큰 유효 (${fmtRemaining(r.msUntilExpiry)})`);
165
+ err(M.statusFresh(fmtRemaining(r.msUntilExpiry)));
63
166
  err('');
64
167
  return 0;
65
168
  case 'refreshed':
66
- err(` ✅ 연결됨 — refresh_token으로 갱신 (${fmtRemaining(r.msUntilExpiry)})`);
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(' 🔄 refresh_token으로 갱신 시도 중...');
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(` ✅ 토큰 유효 — 갱신 불필요 (${fmtRemaining(r.msUntilExpiry)})`);
191
+ err(M.refreshNotNeeded(fmtRemaining(r.msUntilExpiry)));
89
192
  err('');
90
193
  return 0;
91
194
  case 'refreshed':
92
- err(` ✅ 갱신 완료 (${fmtRemaining(r.msUntilExpiry)})`);
195
+ err(M.refreshDone(fmtRemaining(r.msUntilExpiry)));
93
196
  err('');
94
197
  return 0;
95
198
  case 'expired_refresh_failed':
96
- err(' ❌ refresh 실패');
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(' ☕ Mimi Seed — Google 계정 연결');
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' ? '유효함' : 'refresh_token으로 갱신 완료';
138
- err(` ✅ 이미 연결됨 (${label}, ${fmtRemaining(r.msUntilExpiry)}).`);
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(' 다시 로그인할래? (y/N): ', res));
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(` ⚠️ 토큰 만료 + 자동 갱신 실패 [${r.error.code}] — 재로그인 진행.`);
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(' 🌐 OAuth 콜백 서버 시작: http://localhost:9876/callback');
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(' 📋 아래 URL을 브라우저에 직접 붙여넣으세요:');
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(' (실패 시 --no-browser 로 URL 직접 받기)');
289
+ err(M.openingHint);
187
290
  }
188
291
  catch (e) {
189
- err(' ⚠️ 브라우저 자동 열기 실패: ' + (e instanceof Error ? e.message : String(e)));
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(` ⏳ Google 승인 대기 중... (timeout ${timeoutSec}s)`);
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(' 이제 Claude Code 또는 Codex에서 이렇게 쓸 수 있어:');
220
- err(' "내 Firebase 프로젝트 보여줘"');
221
- err(' "새 Android 앱 등록해줘"');
222
- err(' "google-services.json 다운로드해줘"');
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(' ❌ 예외: ' + (e instanceof Error ? e.message : String(e)));
347
+ err(M.fatal(e instanceof Error ? e.message : String(e)));
245
348
  err('');
246
349
  process.exit(1);
247
350
  });
@@ -2,36 +2,83 @@
2
2
  import { saveServiceAccountJson, getServiceAccountJson } from './playstore-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 — Google Play 서비스 계정 연결',
9
+ already: (email) => ` ✅ 이미 연결됨 (${email})`,
10
+ existingBroken: ' ⚠️ 저장된 서비스 계정이 있지만 파싱 오류',
11
+ reconnect: ' 다시 설정할래? (y/N): ',
12
+ needTitle: ' Google Play 서비스 계정 키 JSON 파일이 필요해:',
13
+ step1: ' 1. Google Cloud Console → IAM & Admin → Service Accounts',
14
+ step2: ' 2. 서비스 계정 선택 → Keys → Add Key → Create new key → JSON',
15
+ step3: ' 3. 다운로드한 JSON 파일 경로를 입력해',
16
+ askPath: ' 서비스 계정 JSON 파일 경로: ',
17
+ noFile: (p) => ` ❌ 파일 없음: ${p}`,
18
+ badJson: ' ❌ JSON 파싱 실패 — 올바른 서비스 계정 키 파일인지 확인해줘',
19
+ notServiceAccount: ' ❌ 서비스 계정 JSON 형식이 아니야.',
20
+ notServiceAccountFields: ' type="service_account", client_email, private_key 필드가 있어야 해.',
21
+ saved: (email) => ` ✅ 저장 완료! (${email})`,
22
+ nextTitle: ' 이제 Claude Code 또는 Codex에서:',
23
+ nextExample1: ' "내 Play 스토어 앱 리스팅 보여줘"',
24
+ nextExample2: ' "Play 구독 상품 목록 보여줘"',
25
+ permWarn: ' ⚠️ Play Console 권한 확인:',
26
+ permStep1: ' Play Console → Users and permissions → 이 서비스 계정 추가',
27
+ permStep2: ' 앱 권한에서 "View financial data" + "Manage store listing" 체크',
28
+ };
29
+ const en = {
30
+ title: ' 🤖 Mimi Seed — Connect a Google Play service account',
31
+ already: (email) => ` ✅ Already connected (${email})`,
32
+ existingBroken: ' ⚠️ A service account is saved, but it failed to parse',
33
+ reconnect: ' Set it up again? (y/N): ',
34
+ needTitle: ' A Google Play service-account key JSON file is required:',
35
+ step1: ' 1. Google Cloud Console → IAM & Admin → Service Accounts',
36
+ step2: ' 2. Pick a service account → Keys → Add Key → Create new key → JSON',
37
+ step3: ' 3. Enter the path to the downloaded JSON file',
38
+ askPath: ' Path to the service-account JSON file: ',
39
+ noFile: (p) => ` ❌ File not found: ${p}`,
40
+ badJson: ' ❌ Failed to parse JSON — check that this is a valid service-account key file',
41
+ notServiceAccount: ' ❌ This is not a service-account JSON.',
42
+ notServiceAccountFields: ' It must contain type="service_account", client_email and private_key fields.',
43
+ saved: (email) => ` ✅ Saved! (${email})`,
44
+ nextTitle: ' Now, in Claude Code or Codex:',
45
+ nextExample1: ' "Show my Play Store listing"',
46
+ nextExample2: ' "List my Play subscriptions"',
47
+ permWarn: ' ⚠️ Check the Play Console permissions:',
48
+ permStep1: ' Play Console → Users and permissions → invite this service account',
49
+ permStep2: ' In the app permissions, check "View financial data" + "Manage store listing"',
50
+ };
51
+ const M = resolveLang() === 'en' ? en : ko;
5
52
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
6
53
  const ask = (q) => new Promise((r) => rl.question(q, r));
7
54
  async function main() {
8
55
  console.log('');
9
- console.log(' 🤖 Mimi Seed — Google Play 서비스 계정 연결');
56
+ console.log(M.title);
10
57
  console.log('');
11
58
  const existing = getServiceAccountJson();
12
59
  if (existing) {
13
60
  try {
14
61
  const parsed = JSON.parse(existing);
15
- console.log(` ✅ 이미 연결됨 (${parsed.client_email ?? '?'})`);
62
+ console.log(M.already(parsed.client_email ?? '?'));
16
63
  }
17
64
  catch {
18
- console.log(' ⚠️ 저장된 서비스 계정이 있지만 파싱 오류');
65
+ console.log(M.existingBroken);
19
66
  }
20
- const answer = await ask(' 다시 설정할래? (y/N): ');
67
+ const answer = await ask(M.reconnect);
21
68
  if (answer.toLowerCase() !== 'y') {
22
69
  rl.close();
23
70
  return;
24
71
  }
25
72
  }
26
- console.log(' Google Play 서비스 계정 키 JSON 파일이 필요해:');
27
- console.log(' 1. Google Cloud Console → IAM & Admin → Service Accounts');
28
- console.log(' 2. 서비스 계정 선택 → Keys → Add Key → Create new key → JSON');
29
- console.log(' 3. 다운로드한 JSON 파일 경로를 입력해');
73
+ console.log(M.needTitle);
74
+ console.log(M.step1);
75
+ console.log(M.step2);
76
+ console.log(M.step3);
30
77
  console.log('');
31
- const jsonPath = await ask(' 서비스 계정 JSON 파일 경로: ');
78
+ const jsonPath = await ask(M.askPath);
32
79
  const trimmedPath = jsonPath.trim().replace(/^["']|["']$/g, '');
33
80
  if (!fs.existsSync(trimmedPath)) {
34
- console.log(` ❌ 파일 없음: ${trimmedPath}`);
81
+ console.log(M.noFile(trimmedPath));
35
82
  rl.close();
36
83
  process.exit(1);
37
84
  }
@@ -41,27 +88,27 @@ async function main() {
41
88
  parsed = JSON.parse(json);
42
89
  }
43
90
  catch {
44
- console.log(' ❌ JSON 파싱 실패 — 올바른 서비스 계정 키 파일인지 확인해줘');
91
+ console.log(M.badJson);
45
92
  rl.close();
46
93
  process.exit(1);
47
94
  }
48
95
  if (parsed.type !== 'service_account' || !parsed.client_email || !parsed.private_key) {
49
- console.log(' ❌ 서비스 계정 JSON 형식이 아니야.');
50
- console.log(' type="service_account", client_email, private_key 필드가 있어야 해.');
96
+ console.log(M.notServiceAccount);
97
+ console.log(M.notServiceAccountFields);
51
98
  rl.close();
52
99
  process.exit(1);
53
100
  }
54
101
  saveServiceAccountJson(json);
55
102
  console.log('');
56
- console.log(` ✅ 저장 완료! (${parsed.client_email})`);
103
+ console.log(M.saved(parsed.client_email));
57
104
  console.log('');
58
- console.log(' 이제 Claude Code 또는 Codex에서:');
59
- console.log(' "내 Play 스토어 앱 리스팅 보여줘"');
60
- console.log(' "Play 구독 상품 목록 보여줘"');
105
+ console.log(M.nextTitle);
106
+ console.log(M.nextExample1);
107
+ console.log(M.nextExample2);
61
108
  console.log('');
62
- console.log(' ⚠️ Play Console 권한 확인:');
63
- console.log(' Play Console → Users and permissions → 이 서비스 계정 추가');
64
- console.log(' 앱 권한에서 "View financial data" + "Manage store listing" 체크');
109
+ console.log(M.permWarn);
110
+ console.log(M.permStep1);
111
+ console.log(M.permStep2);
65
112
  console.log('');
66
113
  rl.close();
67
114
  }
@@ -7,31 +7,80 @@ import readline from 'node:readline';
7
7
  import { loadConfig, saveConfig, normalizeCustomerId } from './config.js';
8
8
  import { listAccessibleCustomers } from './tools.js';
9
9
  import { getAuthenticatedClient } from '../auth/google-auth.js';
10
+ import { resolveLang } from '../lib/lang.js';
11
+ // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
12
+ const ko = {
13
+ title: ' 🤖 Mimi Seed — Google Ads 연결',
14
+ already: (customerId) => ` ✅ 이미 연결됨 (customerId: ${customerId})`,
15
+ reconnect: ' 다시 설정할래? (y/N): ',
16
+ needTitle: ' 필요한 것 2가지:',
17
+ needToken: ' 1. Developer Token — Google Ads → 도구 및 설정 → 설정 → API 센터',
18
+ needTokenWarn: ' ⚠️ 최초 발급은 "테스트" 등급이고, 실계정 조회는 **승인 심사**를 거쳐야 해.',
19
+ needCustomerId: ' 2. Customer ID — Google Ads 우상단 계정 번호 (예: 123-456-7890)',
20
+ askToken: ' Developer Token: ',
21
+ askCustomerId: ' Customer ID (예: 123-456-7890): ',
22
+ askLoginCustomerId: ' MCC(관리자) 계정 ID (선택, 엔터 스킵): ',
23
+ required: ' ❌ Developer Token 과 Customer ID 는 필수야.',
24
+ noOauth: ' ❌ Google OAuth 토큰이 없어 검증할 수 없어. 저장하지 않았어.',
25
+ noOauthFix: ' 먼저: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
26
+ probing: ' 🔎 접근 가능한 고객 계정 조회 중...',
27
+ probeOk: (n) => ` ✅ 접근 OK — 계정 ${n}개`,
28
+ probeFail: (msg) => ` ❌ 검증 실패: ${msg}`,
29
+ hintScope: ' → OAuth 토큰에 `adwords` 스코프가 없을 가능성이 커.',
30
+ hintScopeFix: ' 재인증: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth --force',
31
+ hintOther: ' → Developer Token 등급(테스트/승인)과 Customer ID 를 다시 확인해줘.',
32
+ notSaved: ' 저장하지 않았어 (기존 설정이 있으면 그대로 유지된다).',
33
+ saved: ' ✅ 저장 완료 → ~/.mimi-seed/google-ads.json',
34
+ };
35
+ const en = {
36
+ title: ' 🤖 Mimi Seed — Connect Google Ads',
37
+ already: (customerId) => ` ✅ Already connected (customerId: ${customerId})`,
38
+ reconnect: ' Set it up again? (y/N): ',
39
+ needTitle: ' Two things are needed:',
40
+ needToken: ' 1. Developer Token — Google Ads → Tools and settings → Setup → API Center',
41
+ needTokenWarn: ' ⚠️ A new token starts at "Test" access level; querying real accounts requires **API-Center approval**.',
42
+ needCustomerId: ' 2. Customer ID — the account number at the top right of Google Ads (e.g. 123-456-7890)',
43
+ askToken: ' Developer Token: ',
44
+ askCustomerId: ' Customer ID (e.g. 123-456-7890): ',
45
+ askLoginCustomerId: ' MCC (manager) account ID (optional, Enter to skip): ',
46
+ required: ' ❌ Developer Token and Customer ID are required.',
47
+ noOauth: ' ❌ No Google OAuth token, so this cannot be verified. Nothing was saved.',
48
+ noOauthFix: ' First run: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
49
+ probing: ' 🔎 Listing accessible customer accounts...',
50
+ probeOk: (n) => ` ✅ Access OK — ${n} account(s)`,
51
+ probeFail: (msg) => ` ❌ Verification failed: ${msg}`,
52
+ hintScope: ' → Your OAuth token most likely lacks the `adwords` scope.',
53
+ hintScopeFix: ' Re-authenticate: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth --force',
54
+ hintOther: ' → Double-check the Developer Token access level (test / approved) and the Customer ID.',
55
+ notSaved: ' Nothing was saved (an existing config is left untouched).',
56
+ saved: ' ✅ Saved → ~/.mimi-seed/google-ads.json',
57
+ };
58
+ const M = resolveLang() === 'en' ? en : ko;
10
59
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
60
  const ask = (q) => new Promise((r) => rl.question(q, (a) => r(a.trim())));
12
61
  async function main() {
13
62
  console.log('');
14
- console.log(' 🤖 Mimi Seed — Google Ads 연결');
63
+ console.log(M.title);
15
64
  console.log('');
16
65
  const existing = loadConfig();
17
66
  if (existing) {
18
- console.log(` ✅ 이미 연결됨 (customerId: ${existing.customerId})`);
19
- const answer = await ask(' 다시 설정할래? (y/N): ');
67
+ console.log(M.already(existing.customerId));
68
+ const answer = await ask(M.reconnect);
20
69
  if (answer.toLowerCase() !== 'y') {
21
70
  rl.close();
22
71
  return;
23
72
  }
24
73
  }
25
- console.log(' 필요한 것 2가지:');
26
- console.log(' 1. Developer Token — Google Ads → 도구 및 설정 → 설정 → API 센터');
27
- console.log(' ⚠️ 최초 발급은 "테스트" 등급이고, 실계정 조회는 **승인 심사**를 거쳐야 해.');
28
- console.log(' 2. Customer ID — Google Ads 우상단 계정 번호 (예: 123-456-7890)');
74
+ console.log(M.needTitle);
75
+ console.log(M.needToken);
76
+ console.log(M.needTokenWarn);
77
+ console.log(M.needCustomerId);
29
78
  console.log('');
30
- const developerToken = await ask(' Developer Token: ');
31
- const customerId = await ask(' Customer ID (예: 123-456-7890): ');
32
- const loginCustomerId = await ask(' MCC(관리자) 계정 ID (선택, 엔터 스킵): ');
79
+ const developerToken = await ask(M.askToken);
80
+ const customerId = await ask(M.askCustomerId);
81
+ const loginCustomerId = await ask(M.askLoginCustomerId);
33
82
  if (!developerToken || !customerId) {
34
- console.log(' ❌ Developer Token 과 Customer ID 는 필수야.');
83
+ console.log(M.required);
35
84
  rl.close();
36
85
  process.exit(1);
37
86
  }
@@ -46,36 +95,36 @@ async function main() {
46
95
  const auth = getAuthenticatedClient();
47
96
  if (!auth) {
48
97
  console.log('');
49
- console.log(' ❌ Google OAuth 토큰이 없어 검증할 수 없어. 저장하지 않았어.');
50
- console.log(' 먼저: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth');
98
+ console.log(M.noOauth);
99
+ console.log(M.noOauthFix);
51
100
  rl.close();
52
101
  process.exit(1);
53
102
  }
54
103
  console.log('');
55
- console.log(' 🔎 접근 가능한 고객 계정 조회 중...');
104
+ console.log(M.probing);
56
105
  try {
57
106
  const customers = await listAccessibleCustomers(auth, cfg);
58
- console.log(` ✅ 접근 OK — 계정 ${customers.length}개`);
107
+ console.log(M.probeOk(customers.length));
59
108
  }
60
109
  catch (e) {
61
110
  const msg = e instanceof Error ? e.message : String(e);
62
- console.log(` ❌ 검증 실패: ${msg}`);
111
+ console.log(M.probeFail(msg));
63
112
  console.log('');
64
113
  if (/scope|insufficient|PERMISSION_DENIED/i.test(msg)) {
65
- console.log(' → OAuth 토큰에 `adwords` 스코프가 없을 가능성이 커.');
66
- console.log(' 재인증: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth --force');
114
+ console.log(M.hintScope);
115
+ console.log(M.hintScopeFix);
67
116
  }
68
117
  else {
69
- console.log(' → Developer Token 등급(테스트/승인)과 Customer ID 를 다시 확인해줘.');
118
+ console.log(M.hintOther);
70
119
  }
71
120
  console.log('');
72
- console.log(' 저장하지 않았어 (기존 설정이 있으면 그대로 유지된다).');
121
+ console.log(M.notSaved);
73
122
  rl.close();
74
123
  process.exit(1);
75
124
  }
76
125
  saveConfig(cfg);
77
126
  console.log('');
78
- console.log(' ✅ 저장 완료 → ~/.mimi-seed/google-ads.json');
127
+ console.log(M.saved);
79
128
  console.log('');
80
129
  rl.close();
81
130
  }
@@ -6,65 +6,118 @@
6
6
  import readline from 'node:readline';
7
7
  import { loadJenkinsConfig, saveJenkinsConfig } from './config.js';
8
8
  import { listCredentials } from './credentials.js';
9
+ import { resolveLang } from '../lib/lang.js';
10
+ // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
11
+ const ko = {
12
+ title: ' 🤖 Mimi Seed — Jenkins 연결',
13
+ already: (url) => ` ✅ 이미 연결됨 (${url})`,
14
+ reconnect: ' 다시 설정할래? (y/N): ',
15
+ howToToken: ' API Token 발급: Jenkins → [사용자 이름] → 설정 → API Token → "Add new Token"',
16
+ remoteOk: ' 로컬 Jenkins 가 없어도 회사·원격 서버 URL 을 그대로 쓰면 돼.',
17
+ askUrl: ' Jenkins URL (예: https://jenkins.company.com): ',
18
+ askUser: ' Jenkins 사용자 ID: ',
19
+ askToken: ' Jenkins API Token: ',
20
+ allRequired: ' ❌ URL / 사용자 ID / 토큰은 모두 필요해.',
21
+ probing: ' 🔎 연결 확인 중...',
22
+ probeOk: (n) => ` ✅ 연결 OK — 크리덴셜 ${n}개 조회됨`,
23
+ probeFail: (msg) => ` ❌ 연결 실패: ${msg}`,
24
+ checkTitle: ' 확인할 것:',
25
+ checkUrl: ' • URL 이 Jenkins 대시보드 주소와 같은지 (예: https://jenkins.company.com)',
26
+ checkToken: ' • 비밀번호가 아니라 **API Token** 을 넣었는지',
27
+ checkPerm: ' • 그 계정에 Credentials 조회 권한이 있는지',
28
+ notSaved: ' 저장하지 않았어. 값을 확인하고 다시 실행해줘.',
29
+ keepCurrent: (cur) => ` (현재: ${cur}, 엔터=유지)`,
30
+ keepOptional: ' (선택, 엔터 스킵)',
31
+ askJobAndroid: (keep) => ` Android 빌드 Job 이름${keep}: `,
32
+ askJobIos: (keep) => ` iOS 빌드 Job 이름${keep}: `,
33
+ saved: ' ✅ 저장 완료 → ~/.mimi-seed/jenkins.json',
34
+ savedHint: ' 확인: mimi-seed doctor · 빌드: mimi-seed deploy',
35
+ };
36
+ const en = {
37
+ title: ' 🤖 Mimi Seed — Connect Jenkins',
38
+ already: (url) => ` ✅ Already connected (${url})`,
39
+ reconnect: ' Set it up again? (y/N): ',
40
+ howToToken: ' Get an API Token: Jenkins → [your user name] → Configure → API Token → "Add new Token"',
41
+ remoteOk: ' No local Jenkins needed — a company / remote server URL works just as well.',
42
+ askUrl: ' Jenkins URL (e.g. https://jenkins.company.com): ',
43
+ askUser: ' Jenkins user ID: ',
44
+ askToken: ' Jenkins API Token: ',
45
+ allRequired: ' ❌ URL, user ID and token are all required.',
46
+ probing: ' 🔎 Checking the connection...',
47
+ probeOk: (n) => ` ✅ Connected — ${n} credential(s) listed`,
48
+ probeFail: (msg) => ` ❌ Connection failed: ${msg}`,
49
+ checkTitle: ' Things to check:',
50
+ checkUrl: ' • The URL matches your Jenkins dashboard address (e.g. https://jenkins.company.com)',
51
+ checkToken: ' • You entered an **API Token**, not your password',
52
+ checkPerm: ' • That account is allowed to read Credentials',
53
+ notSaved: ' Nothing was saved. Check the values and run this again.',
54
+ keepCurrent: (cur) => ` (current: ${cur}, Enter = keep)`,
55
+ keepOptional: ' (optional, Enter to skip)',
56
+ askJobAndroid: (keep) => ` Android build job name${keep}: `,
57
+ askJobIos: (keep) => ` iOS build job name${keep}: `,
58
+ saved: ' ✅ Saved → ~/.mimi-seed/jenkins.json',
59
+ savedHint: ' Check: mimi-seed doctor · Build: mimi-seed deploy',
60
+ };
61
+ const M = resolveLang() === 'en' ? en : ko;
9
62
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
63
  const ask = (q) => new Promise((r) => rl.question(q, (a) => r(a.trim())));
11
64
  async function main() {
12
65
  console.log('');
13
- console.log(' 🤖 Mimi Seed — Jenkins 연결');
66
+ console.log(M.title);
14
67
  console.log('');
15
68
  const existing = loadJenkinsConfig();
16
69
  if (existing) {
17
- console.log(` ✅ 이미 연결됨 (${existing.url})`);
18
- const answer = await ask(' 다시 설정할래? (y/N): ');
70
+ console.log(M.already(existing.url));
71
+ const answer = await ask(M.reconnect);
19
72
  if (answer.toLowerCase() !== 'y') {
20
73
  rl.close();
21
74
  return;
22
75
  }
23
76
  }
24
- console.log(' API Token 발급: Jenkins → [사용자 이름] → 설정 → API Token → "Add new Token"');
25
- console.log(' 로컬 Jenkins 가 없어도 회사·원격 서버 URL 을 그대로 쓰면 돼.');
77
+ console.log(M.howToToken);
78
+ console.log(M.remoteOk);
26
79
  console.log('');
27
- const url = await ask(' Jenkins URL (예: https://jenkins.company.com): ');
28
- const username = await ask(' Jenkins 사용자 ID: ');
29
- const token = await ask(' Jenkins API Token: ');
80
+ const url = await ask(M.askUrl);
81
+ const username = await ask(M.askUser);
82
+ const token = await ask(M.askToken);
30
83
  if (!url || !username || !token) {
31
- console.log(' ❌ URL / 사용자 ID / 토큰은 모두 필요해.');
84
+ console.log(M.allRequired);
32
85
  rl.close();
33
86
  process.exit(1);
34
87
  }
35
88
  console.log('');
36
- console.log(' 🔎 연결 확인 중...');
89
+ console.log(M.probing);
37
90
  const cfg = { url: url.replace(/\/+$/, ''), username, token };
38
91
  try {
39
92
  const creds = await listCredentials(cfg);
40
- console.log(` ✅ 연결 OK — 크리덴셜 ${creds.length}개 조회됨`);
93
+ console.log(M.probeOk(creds.length));
41
94
  }
42
95
  catch (e) {
43
- console.log(` ❌ 연결 실패: ${e instanceof Error ? e.message : String(e)}`);
96
+ console.log(M.probeFail(e instanceof Error ? e.message : String(e)));
44
97
  console.log('');
45
- console.log(' 확인할 것:');
46
- console.log(' • URL 이 Jenkins 대시보드 주소와 같은지 (예: https://jenkins.company.com)');
47
- console.log(' • 비밀번호가 아니라 **API Token** 을 넣었는지');
48
- console.log(' • 그 계정에 Credentials 조회 권한이 있는지');
98
+ console.log(M.checkTitle);
99
+ console.log(M.checkUrl);
100
+ console.log(M.checkToken);
101
+ console.log(M.checkPerm);
49
102
  console.log('');
50
- console.log(' 저장하지 않았어. 값을 확인하고 다시 실행해줘.');
103
+ console.log(M.notSaved);
51
104
  rl.close();
52
105
  process.exit(1);
53
106
  }
54
107
  // deploy 가 트리거할 잡 이름 (선택) — 같은 파일에 함께 저장한다.
55
108
  // 재설정(토큰 교체 등) 시 엔터로 넘기면 **기존 값을 유지한다** — 조용히 지워버리면
56
109
  // 다음 `mimi-seed deploy` 가 "job 이 설정되지 않았습니다" 로 죽는다.
57
- const keep = (cur) => (cur ? ` (현재: ${cur}, 엔터=유지)` : ' (선택, 엔터 스킵)');
58
- const jobAndroid = await ask(` Android 빌드 Job 이름${keep(existing?.jobAndroid)}: `);
59
- const jobIos = await ask(` iOS 빌드 Job 이름${keep(existing?.jobIos)}: `);
110
+ const keep = (cur) => (cur ? M.keepCurrent(cur) : M.keepOptional);
111
+ const jobAndroid = await ask(M.askJobAndroid(keep(existing?.jobAndroid)));
112
+ const jobIos = await ask(M.askJobIos(keep(existing?.jobIos)));
60
113
  saveJenkinsConfig({
61
114
  ...cfg,
62
115
  jobAndroid: jobAndroid || existing?.jobAndroid,
63
116
  jobIos: jobIos || existing?.jobIos,
64
117
  });
65
118
  console.log('');
66
- console.log(' ✅ 저장 완료 → ~/.mimi-seed/jenkins.json');
67
- console.log(' 확인: mimi-seed doctor · 빌드: mimi-seed deploy');
119
+ console.log(M.saved);
120
+ console.log(M.savedHint);
68
121
  console.log('');
69
122
  rl.close();
70
123
  }
@@ -0,0 +1,5 @@
1
+ export type Lang = 'ko' | 'en';
2
+ export declare const DEFAULT_LANG: Lang;
3
+ export declare function isLang(v: unknown): v is Lang;
4
+ /** 우선순위: 환경변수 > ~/.mimi-seed/settings.json > 기본값(ko). 파일이 없거나 깨져도 ko 로 폴백. */
5
+ export declare function resolveLang(home?: string): Lang;
@@ -0,0 +1,33 @@
1
+ // 인터랙티브 setup bin 의 출력 언어.
2
+ //
3
+ // CLI 쪽 `packages/cli/src/settings.ts` 의 resolveLang 과 **같은 규칙**이어야 한다:
4
+ // MIMI_SEED_LANG 환경변수 > ~/.mimi-seed/settings.json { lang } > 'ko'.
5
+ //
6
+ // 환경변수가 1순위인 이유: CLI 마법사가 이 패키지의 setup bin 을 spawn 할 때 MIMI_SEED_LANG 을
7
+ // 물려준다. 마법사와 자식 프로세스의 언어가 어긋나면 온보딩 도중에 언어가 뒤섞인다.
8
+ //
9
+ // MCP 도구의 description / 도구 출력 텍스트는 여기 대상이 아니다 — 그건 사람이 아니라 LLM 이
10
+ // 읽는 인터페이스다. 이 모듈은 **터미널에 찍히는 사람용 문자열**에만 쓴다.
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ export const DEFAULT_LANG = 'ko';
15
+ export function isLang(v) {
16
+ return v === 'ko' || v === 'en';
17
+ }
18
+ /** 우선순위: 환경변수 > ~/.mimi-seed/settings.json > 기본값(ko). 파일이 없거나 깨져도 ko 로 폴백. */
19
+ export function resolveLang(home = os.homedir()) {
20
+ const env = process.env.MIMI_SEED_LANG?.toLowerCase();
21
+ if (isLang(env))
22
+ return env;
23
+ try {
24
+ const raw = fs.readFileSync(path.join(home, '.mimi-seed', 'settings.json'), 'utf-8');
25
+ const saved = JSON.parse(raw).lang;
26
+ if (isLang(saved))
27
+ return saved;
28
+ }
29
+ catch {
30
+ // 파일 없음 / 권한 없음 / JSON 깨짐 — 전부 기본값으로.
31
+ }
32
+ return DEFAULT_LANG;
33
+ }
@@ -9,82 +9,140 @@
9
9
  // mimi-seed-social-auth → 무엇을 연결할지 물어봄
10
10
  // mimi-seed-social-auth facebook → Facebook 만
11
11
  // mimi-seed-social-auth instagram → Instagram 만
12
+ //
13
+ // 주의: connectFacebook / connectInstagram 이 돌려주는 result.text 는 MCP 도구와 공유하는
14
+ // 텍스트라 여기서 번역하지 않는다 (그대로 출력한다).
12
15
  import readline from 'node:readline';
13
16
  import { loadFacebookConfig } from '../facebook/config.js';
14
17
  import { loadInstagramConfig } from '../instagram/config.js';
15
18
  import { connectFacebook } from '../facebook/setup.js';
16
19
  import { connectInstagram } from '../instagram/setup.js';
20
+ import { resolveLang } from '../lib/lang.js';
21
+ // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
22
+ const ko = {
23
+ title: ' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram)',
24
+ already: (label, detail) => ` ✅ ${label} 이미 연결됨 (${detail})`,
25
+ reconnect: ' 다시 설정할래? (y/N): ',
26
+ skipped: ' 건너뜀.',
27
+ cancelled: ' 취소됨.',
28
+ verifying: ' 🔎 토큰 검증 중...',
29
+ notSaved: '\n (검증 실패 — 저장하지 않았어.)',
30
+ which: '\n 무엇을 연결할까? [f] Facebook [i] Instagram [b] 둘 다: ',
31
+ fbHeader: ' ── Facebook 페이지 ──',
32
+ fbHowTo: ' Page Access Token 발급:',
33
+ fbStep1: ' 1. Meta 앱 → Graph API Explorer',
34
+ fbStep2: ' 2. 권한: pages_show_list, pages_manage_posts, pages_read_engagement',
35
+ fbStep3: ' 3. User Token 생성 → /me/accounts 호출 → 그 페이지의 access_token (EAA…)',
36
+ fbNote: ' (long-lived 토큰 권장. pageId 는 토큰에서 자동 조회돼.)',
37
+ fbAskToken: ' Page Access Token (EAA…): ',
38
+ fbAskPageId: ' Page ID (선택, 엔터 시 자동 조회): ',
39
+ fbPickPage: '\n 어느 페이지를 쓸까? Page ID: ',
40
+ igHeader: ' ── Instagram ──',
41
+ igHowTo: ' Long-lived 토큰 두 형식 모두 지원 (자동 감지):',
42
+ igIgaa: ' • IGAA… — Instagram Login (Meta 신규, Facebook 페이지 불필요)',
43
+ igEaa: ' • EAA… — Facebook Login (IG **비즈니스** 계정 + FB 페이지 연결 필요)',
44
+ igNote: ' (userId 는 토큰에서 자동 조회돼. 토큰 수명은 약 60일.)',
45
+ igAskToken: ' Access Token (IGAA… 또는 EAA…): ',
46
+ igAskUserId: ' Instagram Business Account ID (선택, 엔터 시 자동 조회): ',
47
+ };
48
+ const en = {
49
+ title: ' 🤖 Mimi Seed — Connect social accounts (Facebook / Instagram)',
50
+ already: (label, detail) => ` ✅ ${label} already connected (${detail})`,
51
+ reconnect: ' Set it up again? (y/N): ',
52
+ skipped: ' Skipped.',
53
+ cancelled: ' Cancelled.',
54
+ verifying: ' 🔎 Verifying the token...',
55
+ notSaved: '\n (Verification failed — nothing was saved.)',
56
+ which: '\n What do you want to connect? [f] Facebook [i] Instagram [b] both: ',
57
+ fbHeader: ' ── Facebook Page ──',
58
+ fbHowTo: ' Get a Page Access Token:',
59
+ fbStep1: ' 1. Meta app → Graph API Explorer',
60
+ fbStep2: ' 2. Permissions: pages_show_list, pages_manage_posts, pages_read_engagement',
61
+ fbStep3: " 3. Generate a User Token → call /me/accounts → take that page's access_token (EAA…)",
62
+ fbNote: ' (A long-lived token is recommended. pageId is looked up from the token.)',
63
+ fbAskToken: ' Page Access Token (EAA…): ',
64
+ fbAskPageId: ' Page ID (optional, Enter to look it up): ',
65
+ fbPickPage: '\n Which page should I use? Page ID: ',
66
+ igHeader: ' ── Instagram ──',
67
+ igHowTo: ' Both long-lived token shapes are supported (auto-detected):',
68
+ igIgaa: ' • IGAA… — Instagram Login (new Meta flow, no Facebook Page needed)',
69
+ igEaa: ' • EAA… — Facebook Login (needs an IG **business** account linked to an FB Page)',
70
+ igNote: ' (userId is looked up from the token. Tokens last about 60 days.)',
71
+ igAskToken: ' Access Token (IGAA… or EAA…): ',
72
+ igAskUserId: ' Instagram Business Account ID (optional, Enter to look it up): ',
73
+ };
74
+ const M = resolveLang() === 'en' ? en : ko;
17
75
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
18
76
  const ask = (q) => new Promise((r) => rl.question(q, (a) => r(a.trim())));
19
77
  async function confirmReconnect(label, detail) {
20
- console.log(` ✅ ${label} 이미 연결됨 (${detail})`);
21
- const answer = await ask(' 다시 설정할래? (y/N): ');
78
+ console.log(M.already(label, detail));
79
+ const answer = await ask(M.reconnect);
22
80
  return answer.toLowerCase() === 'y';
23
81
  }
24
82
  async function setupFacebook() {
25
83
  console.log('');
26
- console.log(' ── Facebook 페이지 ──');
84
+ console.log(M.fbHeader);
27
85
  const existing = loadFacebookConfig();
28
86
  if (existing && !(await confirmReconnect('Facebook', existing.pageName ?? existing.pageId))) {
29
87
  return true;
30
88
  }
31
- console.log(' Page Access Token 발급:');
32
- console.log(' 1. Meta 앱 → Graph API Explorer');
33
- console.log(' 2. 권한: pages_show_list, pages_manage_posts, pages_read_engagement');
34
- console.log(' 3. User Token 생성 → /me/accounts 호출 → 그 페이지의 access_token (EAA…)');
35
- console.log(' (long-lived 토큰 권장. pageId 는 토큰에서 자동 조회돼.)');
89
+ console.log(M.fbHowTo);
90
+ console.log(M.fbStep1);
91
+ console.log(M.fbStep2);
92
+ console.log(M.fbStep3);
93
+ console.log(M.fbNote);
36
94
  console.log('');
37
- const token = await ask(' Page Access Token (EAA…): ');
95
+ const token = await ask(M.fbAskToken);
38
96
  if (!token) {
39
- console.log(' 건너뜀.');
97
+ console.log(M.skipped);
40
98
  return true;
41
99
  }
42
- let pageId = await ask(' Page ID (선택, 엔터 시 자동 조회): ');
43
- console.log(' 🔎 토큰 검증 중...');
100
+ let pageId = await ask(M.fbAskPageId);
101
+ console.log(M.verifying);
44
102
  let result = await connectFacebook(token, pageId || undefined);
45
103
  // 토큰이 여러 페이지에 닿으면 connectFacebook 은 목록만 주고 저장하지 않는다.
46
104
  // 다시 실행하라고 내보내지 말고, 여기서 바로 골라 받는다.
47
105
  if (!result.ok && result.text.includes('여러 페이지에')) {
48
106
  console.log('');
49
107
  console.log(indent(result.text));
50
- pageId = await ask('\n 어느 페이지를 쓸까? Page ID: ');
108
+ pageId = await ask(M.fbPickPage);
51
109
  if (!pageId) {
52
- console.log(' 건너뜀.');
110
+ console.log(M.skipped);
53
111
  return true;
54
112
  }
55
- console.log(' 🔎 토큰 검증 중...');
113
+ console.log(M.verifying);
56
114
  result = await connectFacebook(token, pageId);
57
115
  }
58
116
  console.log('');
59
117
  console.log(indent(result.text));
60
118
  if (!result.ok)
61
- console.log('\n (검증 실패 — 저장하지 않았어.)');
119
+ console.log(M.notSaved);
62
120
  return result.ok;
63
121
  }
64
122
  async function setupInstagram() {
65
123
  console.log('');
66
- console.log(' ── Instagram ──');
124
+ console.log(M.igHeader);
67
125
  const existing = loadInstagramConfig();
68
126
  if (existing && !(await confirmReconnect('Instagram', existing.username ?? existing.userId))) {
69
127
  return true;
70
128
  }
71
- console.log(' Long-lived 토큰 두 형식 모두 지원 (자동 감지):');
72
- console.log(' • IGAA… — Instagram Login (Meta 신규, Facebook 페이지 불필요)');
73
- console.log(' • EAA… — Facebook Login (IG **비즈니스** 계정 + FB 페이지 연결 필요)');
74
- console.log(' (userId 는 토큰에서 자동 조회돼. 토큰 수명은 약 60일.)');
129
+ console.log(M.igHowTo);
130
+ console.log(M.igIgaa);
131
+ console.log(M.igEaa);
132
+ console.log(M.igNote);
75
133
  console.log('');
76
- const token = await ask(' Access Token (IGAA… 또는 EAA…): ');
134
+ const token = await ask(M.igAskToken);
77
135
  if (!token) {
78
- console.log(' 건너뜀.');
136
+ console.log(M.skipped);
79
137
  return true;
80
138
  }
81
- const userId = await ask(' Instagram Business Account ID (선택, 엔터 시 자동 조회): ');
82
- console.log(' 🔎 토큰 검증 중...');
139
+ const userId = await ask(M.igAskUserId);
140
+ console.log(M.verifying);
83
141
  const result = await connectInstagram(token, userId || undefined);
84
142
  console.log('');
85
143
  console.log(indent(result.text));
86
144
  if (!result.ok)
87
- console.log('\n (검증 실패 — 저장하지 않았어.)');
145
+ console.log(M.notSaved);
88
146
  return result.ok;
89
147
  }
90
148
  function indent(text) {
@@ -96,7 +154,7 @@ function indent(text) {
96
154
  async function main() {
97
155
  const target = (process.argv[2] ?? '').toLowerCase();
98
156
  console.log('');
99
- console.log(' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram)');
157
+ console.log(M.title);
100
158
  let ok = true;
101
159
  if (target === 'facebook' || target === 'fb') {
102
160
  ok = await setupFacebook();
@@ -105,7 +163,7 @@ async function main() {
105
163
  ok = await setupInstagram();
106
164
  }
107
165
  else {
108
- const which = await ask('\n 무엇을 연결할까? [f] Facebook [i] Instagram [b] 둘 다: ');
166
+ const which = await ask(M.which);
109
167
  const c = which.toLowerCase();
110
168
  if (c === 'f')
111
169
  ok = await setupFacebook();
@@ -117,7 +175,7 @@ async function main() {
117
175
  ok = fb && ig;
118
176
  }
119
177
  else {
120
- console.log(' 취소됨.');
178
+ console.log(M.cancelled);
121
179
  }
122
180
  }
123
181
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {