@yoonion/mimi-seed-mcp 0.7.0 → 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/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/googleads/setup-cli.js +70 -21
- package/dist/jenkins/setup-cli.js +75 -22
- package/dist/lib/lang.d.ts +5 -0
- package/dist/lib/lang.js +33 -0
- package/dist/social/setup-cli.js +87 -29
- package/package.json +1 -1
|
@@ -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
|
});
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
27
|
-
console.log(
|
|
28
|
-
console.log(
|
|
29
|
-
console.log(
|
|
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(
|
|
78
|
+
const jsonPath = await ask(M.askPath);
|
|
32
79
|
const trimmedPath = jsonPath.trim().replace(/^["']|["']$/g, '');
|
|
33
80
|
if (!fs.existsSync(trimmedPath)) {
|
|
34
|
-
console.log(
|
|
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(
|
|
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(
|
|
50
|
-
console.log(
|
|
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(
|
|
103
|
+
console.log(M.saved(parsed.client_email));
|
|
57
104
|
console.log('');
|
|
58
|
-
console.log(
|
|
59
|
-
console.log(
|
|
60
|
-
console.log(
|
|
105
|
+
console.log(M.nextTitle);
|
|
106
|
+
console.log(M.nextExample1);
|
|
107
|
+
console.log(M.nextExample2);
|
|
61
108
|
console.log('');
|
|
62
|
-
console.log(
|
|
63
|
-
console.log(
|
|
64
|
-
console.log(
|
|
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(
|
|
63
|
+
console.log(M.title);
|
|
15
64
|
console.log('');
|
|
16
65
|
const existing = loadConfig();
|
|
17
66
|
if (existing) {
|
|
18
|
-
console.log(
|
|
19
|
-
const answer = await ask(
|
|
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(
|
|
26
|
-
console.log(
|
|
27
|
-
console.log(
|
|
28
|
-
console.log(
|
|
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(
|
|
31
|
-
const customerId = await ask(
|
|
32
|
-
const loginCustomerId = await ask(
|
|
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(
|
|
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(
|
|
50
|
-
console.log(
|
|
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(
|
|
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(
|
|
111
|
+
console.log(M.probeFail(msg));
|
|
63
112
|
console.log('');
|
|
64
113
|
if (/scope|insufficient|PERMISSION_DENIED/i.test(msg)) {
|
|
65
|
-
console.log(
|
|
66
|
-
console.log(
|
|
114
|
+
console.log(M.hintScope);
|
|
115
|
+
console.log(M.hintScopeFix);
|
|
67
116
|
}
|
|
68
117
|
else {
|
|
69
|
-
console.log(
|
|
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(
|
|
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(
|
|
66
|
+
console.log(M.title);
|
|
14
67
|
console.log('');
|
|
15
68
|
const existing = loadJenkinsConfig();
|
|
16
69
|
if (existing) {
|
|
17
|
-
console.log(
|
|
18
|
-
const answer = await ask(
|
|
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(
|
|
25
|
-
console.log(
|
|
77
|
+
console.log(M.howToToken);
|
|
78
|
+
console.log(M.remoteOk);
|
|
26
79
|
console.log('');
|
|
27
|
-
const url = await ask(
|
|
28
|
-
const username = await ask(
|
|
29
|
-
const token = await ask(
|
|
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(
|
|
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(
|
|
93
|
+
console.log(M.probeOk(creds.length));
|
|
41
94
|
}
|
|
42
95
|
catch (e) {
|
|
43
|
-
console.log(
|
|
96
|
+
console.log(M.probeFail(e instanceof Error ? e.message : String(e)));
|
|
44
97
|
console.log('');
|
|
45
|
-
console.log(
|
|
46
|
-
console.log(
|
|
47
|
-
console.log(
|
|
48
|
-
console.log(
|
|
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 ?
|
|
58
|
-
const jobAndroid = await ask(
|
|
59
|
-
const jobIos = await ask(
|
|
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(
|
|
67
|
-
console.log(
|
|
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;
|
package/dist/lib/lang.js
ADDED
|
@@ -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
|
+
}
|
package/dist/social/setup-cli.js
CHANGED
|
@@ -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(
|
|
21
|
-
const answer = await ask(
|
|
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(
|
|
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(
|
|
32
|
-
console.log(
|
|
33
|
-
console.log(
|
|
34
|
-
console.log(
|
|
35
|
-
console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
119
|
+
console.log(M.notSaved);
|
|
62
120
|
return result.ok;
|
|
63
121
|
}
|
|
64
122
|
async function setupInstagram() {
|
|
65
123
|
console.log('');
|
|
66
|
-
console.log(
|
|
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(
|
|
72
|
-
console.log(
|
|
73
|
-
console.log(
|
|
74
|
-
console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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