@yoonion/mimi-seed-mcp 0.3.25 → 0.3.27

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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * App Store Connect API 에러 친절화.
3
+ *
4
+ * Apple은 표준 JSON 에러 응답을 내려준다:
5
+ * { "errors": [{ "code": "...", "title": "...", "detail": "...",
6
+ * "source": { "pointer": "/data/attributes/whatsNew" } }] }
7
+ *
8
+ * 실제 거부 사례에서 자주 만나는 코드별 hint 를 첨가해
9
+ * "App Store API 409: { errors: [...] }" 같은 raw dump 대신
10
+ * 무엇을 고쳐야 하는지 즉시 보이게 한다.
11
+ *
12
+ * 비표준 body (HTML, plain text, 빈 응답 등) 는 폴백으로 원본 보존.
13
+ */
14
+ /** Apple JSON Error response 구조 (단일 errors 배열). */
15
+ interface AppleError {
16
+ id?: string;
17
+ code?: string;
18
+ status?: string;
19
+ title?: string;
20
+ detail?: string;
21
+ source?: {
22
+ pointer?: string;
23
+ parameter?: string;
24
+ };
25
+ }
26
+ /** Apple 표준 에러 응답 파싱 시도. 실패하면 null 반환. */
27
+ declare function parseAppleErrorBody(body: string): AppleError[] | null;
28
+ /** 단일 Apple error → 한 줄 사람용 표기. */
29
+ declare function formatAppleError(e: AppleError): string;
30
+ /**
31
+ * App Store API 응답 (status + raw body) 을 친절한 Error 로 변환.
32
+ *
33
+ * - 표준 Apple JSON: `App Store API {status}: [CODE] detail @ pointer 💡 hint` 형식.
34
+ * 여러 errors 가 있으면 줄바꿈으로 나열.
35
+ * - 비표준 body: 기존 형식(`App Store API {status}: {body}`) 그대로 보존.
36
+ *
37
+ * `Error.cause` 에 원본 `{ status, body, parsedErrors }` 첨부 — 호출자가 코드별 분기 필요 시 사용.
38
+ */
39
+ export declare function friendlyAppStoreError(status: number, body: string): Error;
40
+ export declare const __testing: {
41
+ CODE_HINTS: Record<string, string>;
42
+ parseAppleErrorBody: typeof parseAppleErrorBody;
43
+ formatAppleError: typeof formatAppleError;
44
+ };
45
+ export {};
@@ -0,0 +1,88 @@
1
+ /**
2
+ * App Store Connect API 에러 친절화.
3
+ *
4
+ * Apple은 표준 JSON 에러 응답을 내려준다:
5
+ * { "errors": [{ "code": "...", "title": "...", "detail": "...",
6
+ * "source": { "pointer": "/data/attributes/whatsNew" } }] }
7
+ *
8
+ * 실제 거부 사례에서 자주 만나는 코드별 hint 를 첨가해
9
+ * "App Store API 409: { errors: [...] }" 같은 raw dump 대신
10
+ * 무엇을 고쳐야 하는지 즉시 보이게 한다.
11
+ *
12
+ * 비표준 body (HTML, plain text, 빈 응답 등) 는 폴백으로 원본 보존.
13
+ */
14
+ /** Apple 에러 코드 → 친절한 hint (실측 거부 사례 기반). */
15
+ const CODE_HINTS = {
16
+ // What's New / localization 텍스트 검증 실패
17
+ INVALID_CHARACTERS: 'HTML 태그·금지 문자가 포함됐어요. 제출 전 lib/text-validators.ts 로 사전 검증 권장.',
18
+ // 버전 상태가 편집 불가 (READY_FOR_SALE / WAITING_FOR_REVIEW 등)
19
+ ENTITY_STATE_INVALID: '버전 상태가 편집 가능 단계가 아니에요 (이미 심사중/출시됨). 새 versionString 으로 appstore_create_version 필요할 수 있어요.',
20
+ // 필수 필드 누락
21
+ ENTITY_ERROR_ATTRIBUTE_REQUIRED: '필수 필드가 빠졌어요 — source.pointer 위치 확인.',
22
+ // 길이 / 형식 위반
23
+ ENTITY_ERROR_ATTRIBUTE_INVALID: '필드 값이 정책 위반(길이/형식). source.pointer 위치 확인.',
24
+ // 빌드 attach 시 빌드를 못 찾음
25
+ NOT_FOUND: '대상 리소스를 찾을 수 없어요 — id/versionId/buildId 가 유효한지, 동일 앱 소속인지 확인.',
26
+ // 권한 부족 (API Key role 문제)
27
+ FORBIDDEN_ERROR: 'API Key 권한 부족 — App Store Connect > Users and Access 에서 키 role 확인.',
28
+ // submit 직후 cancel 시도 (큐 진입 후)
29
+ STATE_ERROR: '현재 상태에서 허용되지 않는 작업. cancel_review 라면 큐 진입 후라서 불가 — 새 versionString 으로 우회.',
30
+ };
31
+ /** Apple 표준 에러 응답 파싱 시도. 실패하면 null 반환. */
32
+ function parseAppleErrorBody(body) {
33
+ if (!body)
34
+ return null;
35
+ try {
36
+ const parsed = JSON.parse(body);
37
+ if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {
38
+ return parsed.errors;
39
+ }
40
+ return null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** 단일 Apple error → 한 줄 사람용 표기. */
47
+ function formatAppleError(e) {
48
+ const code = e.code ?? 'UNKNOWN';
49
+ const detail = e.detail ?? e.title ?? '(no detail)';
50
+ const pointer = e.source?.pointer ?? e.source?.parameter;
51
+ const hint = e.code ? CODE_HINTS[e.code] : undefined;
52
+ const parts = [`[${code}] ${detail}`];
53
+ if (pointer)
54
+ parts.push(`@ ${pointer}`);
55
+ if (hint)
56
+ parts.push(`\n 💡 ${hint}`);
57
+ return parts.join(' ');
58
+ }
59
+ /**
60
+ * App Store API 응답 (status + raw body) 을 친절한 Error 로 변환.
61
+ *
62
+ * - 표준 Apple JSON: `App Store API {status}: [CODE] detail @ pointer 💡 hint` 형식.
63
+ * 여러 errors 가 있으면 줄바꿈으로 나열.
64
+ * - 비표준 body: 기존 형식(`App Store API {status}: {body}`) 그대로 보존.
65
+ *
66
+ * `Error.cause` 에 원본 `{ status, body, parsedErrors }` 첨부 — 호출자가 코드별 분기 필요 시 사용.
67
+ */
68
+ export function friendlyAppStoreError(status, body) {
69
+ const errors = parseAppleErrorBody(body);
70
+ let message;
71
+ if (errors) {
72
+ const formatted = errors.map(formatAppleError).join('\n');
73
+ message = `App Store API ${status}:\n${formatted}`;
74
+ }
75
+ else {
76
+ message = `App Store API ${status}: ${body}`;
77
+ }
78
+ const err = new Error(message);
79
+ // 원본 보존 — 호출자 코드별 retry 분기용.
80
+ err.cause = { status, body, parsedErrors: errors ?? undefined };
81
+ return err;
82
+ }
83
+ // ── 테스트용 export ────────────────────────────────────────────
84
+ export const __testing = {
85
+ CODE_HINTS,
86
+ parseAppleErrorBody,
87
+ formatAppleError,
88
+ };
@@ -26,6 +26,29 @@ export declare function attachBuildToVersion(versionId: string, buildId: string)
26
26
  buildId: string;
27
27
  ok: boolean;
28
28
  }>;
29
+ /**
30
+ * 가장 최신 VALID 빌드를 자동으로 찾아 versionId 에 attach.
31
+ *
32
+ * 흐름:
33
+ * 1. versionId → appId 역추적 (`getVersionAppAndPlatform`).
34
+ * 2. `listBuilds(appId)` 로 최근 10개 빌드 조회 (sort: uploadedDate desc).
35
+ * 3. `processingState === 'VALID'` 필터.
36
+ * 4. `minBuildNumber` 옵션 있으면 buildNumber 숫자 기준 필터.
37
+ * 5. buildNumber 숫자 최대값으로 정렬 → 1개 선택.
38
+ * 6. attach.
39
+ *
40
+ * 사용처: 1.4.x 같은 매 배포마다 `appstore_list_builds` → 수동 탐색 → `appstore_attach_build` 의
41
+ * 3-step 을 한 번에 줄임. 실수로 PROCESSING 중인 빌드를 attach 해서 심사 제출 시점에 깨지는
42
+ * 케이스도 차단.
43
+ */
44
+ export declare function attachLatestValidBuild(versionId: string, opts?: {
45
+ minBuildNumber?: number;
46
+ }): Promise<{
47
+ versionId: string;
48
+ attachedBuildId: string;
49
+ buildNumber: string;
50
+ uploadedDate?: string;
51
+ }>;
29
52
  export declare function getVersionLocalizations(versionId: string): Promise<any>;
30
53
  export interface LocalizationUpdateFields {
31
54
  whatsNew?: string;
@@ -80,6 +103,31 @@ export interface ListCustomerReviewsOptions {
80
103
  }
81
104
  export declare function listCustomerReviews(appId: string, opts?: ListCustomerReviewsOptions): Promise<any>;
82
105
  export declare function createReviewResponse(reviewId: string, responseBody: string): Promise<any>;
106
+ /**
107
+ * submit_for_review dry-run 프리뷰 — 비가역 제출 직전 사용자 확인용.
108
+ *
109
+ * 1.4.x 배포 사고 누적: submit 직후 cancel_review 가 큐 진입으로 막혀 새 versionString
110
+ * bump 으로 우회해야 하는 케이스가 반복됨 (reference_appstore_cancel_review_window).
111
+ * 호출자가 의도한 그 버전·빌드인지 미리 보여줘서 잘못된 versionId 제출 차단.
112
+ */
113
+ export declare function buildSubmitForReviewPreview(versionId: string): Promise<{
114
+ versionId: string;
115
+ versionString?: string;
116
+ state?: string;
117
+ appId: string;
118
+ platform: string;
119
+ attachedBuild?: {
120
+ id: string;
121
+ buildNumber?: string;
122
+ uploadedDate?: string;
123
+ processingState?: string;
124
+ };
125
+ whatsNewByLocale: Array<{
126
+ locale: string;
127
+ excerpt: string;
128
+ length: number;
129
+ }>;
130
+ }>;
83
131
  export declare function submitVersionForReview(versionId: string): Promise<{
84
132
  submissionId: string;
85
133
  appId: string;
@@ -1,4 +1,5 @@
1
1
  import { getAuthHeaders } from './auth.js';
2
+ import { friendlyAppStoreError } from './errors.js';
2
3
  /**
3
4
  * App Store Connect API v1 래퍼
4
5
  * https://developer.apple.com/documentation/appstoreconnectapi
@@ -24,7 +25,7 @@ export async function apiGet(path, params) {
24
25
  const res = await fetch(url.toString(), { headers });
25
26
  if (!res.ok) {
26
27
  const body = await res.text();
27
- throw new Error(`App Store API ${res.status}: ${body}`);
28
+ throw friendlyAppStoreError(res.status, body);
28
29
  }
29
30
  return res.json();
30
31
  }
@@ -44,7 +45,7 @@ async function apiPatch(path, body) {
44
45
  });
45
46
  if (!res.ok) {
46
47
  const text = await res.text();
47
- throw new Error(`App Store API ${res.status}: ${text}`);
48
+ throw friendlyAppStoreError(res.status, text);
48
49
  }
49
50
  // 204 No Content 가능
50
51
  const text = await res.text();
@@ -66,7 +67,7 @@ async function apiPost(path, body) {
66
67
  });
67
68
  if (!res.ok) {
68
69
  const text = await res.text();
69
- throw new Error(`App Store API ${res.status}: ${text}`);
70
+ throw friendlyAppStoreError(res.status, text);
70
71
  }
71
72
  // 201 Created — 본문에 created entity. 일부 엔드포인트는 204
72
73
  const text = await res.text();
@@ -147,6 +148,51 @@ export async function attachBuildToVersion(versionId, buildId) {
147
148
  });
148
149
  return { versionId, buildId, ok: true };
149
150
  }
151
+ /**
152
+ * 가장 최신 VALID 빌드를 자동으로 찾아 versionId 에 attach.
153
+ *
154
+ * 흐름:
155
+ * 1. versionId → appId 역추적 (`getVersionAppAndPlatform`).
156
+ * 2. `listBuilds(appId)` 로 최근 10개 빌드 조회 (sort: uploadedDate desc).
157
+ * 3. `processingState === 'VALID'` 필터.
158
+ * 4. `minBuildNumber` 옵션 있으면 buildNumber 숫자 기준 필터.
159
+ * 5. buildNumber 숫자 최대값으로 정렬 → 1개 선택.
160
+ * 6. attach.
161
+ *
162
+ * 사용처: 1.4.x 같은 매 배포마다 `appstore_list_builds` → 수동 탐색 → `appstore_attach_build` 의
163
+ * 3-step 을 한 번에 줄임. 실수로 PROCESSING 중인 빌드를 attach 해서 심사 제출 시점에 깨지는
164
+ * 케이스도 차단.
165
+ */
166
+ export async function attachLatestValidBuild(versionId, opts) {
167
+ const { appId } = await getVersionAppAndPlatform(versionId);
168
+ const builds = (await listBuilds(appId));
169
+ const valid = builds.filter((b) => b.processingState === 'VALID');
170
+ if (valid.length === 0) {
171
+ throw new Error(`appId=${appId} 에 VALID 빌드가 없어요 (최근 ${builds.length}개 확인). 빌드 PROCESSING 완료 대기 필요.`);
172
+ }
173
+ let candidates = valid;
174
+ if (opts?.minBuildNumber !== undefined) {
175
+ const min = opts.minBuildNumber;
176
+ candidates = valid.filter((b) => Number(b.version) >= min);
177
+ if (candidates.length === 0) {
178
+ throw new Error(`minBuildNumber=${min} 이상 VALID 빌드가 없어요. VALID 빌드: ${valid.map((b) => b.version).join(', ')}`);
179
+ }
180
+ }
181
+ // buildNumber 가 숫자 문자열이라는 가정 (TestFlight 표준). NaN 은 -Infinity 처리해 뒤로.
182
+ candidates.sort((a, b) => {
183
+ const an = Number(a.version);
184
+ const bn = Number(b.version);
185
+ return (isNaN(bn) ? -Infinity : bn) - (isNaN(an) ? -Infinity : an);
186
+ });
187
+ const target = candidates[0];
188
+ await attachBuildToVersion(versionId, target.id);
189
+ return {
190
+ versionId,
191
+ attachedBuildId: target.id,
192
+ buildNumber: target.version,
193
+ uploadedDate: target.uploadedDate,
194
+ };
195
+ }
150
196
  // ─── 로컬라이제이션 (메타데이터) ───
151
197
  export async function getVersionLocalizations(versionId) {
152
198
  const data = await apiGet(`/appStoreVersions/${versionId}/appStoreVersionLocalizations`, {
@@ -426,6 +472,52 @@ async function isVersionAttached(submissionId, versionId) {
426
472
  const items = (data?.data ?? []);
427
473
  return items.some((it) => it?.relationships?.appStoreVersion?.data?.id === versionId);
428
474
  }
475
+ /**
476
+ * submit_for_review dry-run 프리뷰 — 비가역 제출 직전 사용자 확인용.
477
+ *
478
+ * 1.4.x 배포 사고 누적: submit 직후 cancel_review 가 큐 진입으로 막혀 새 versionString
479
+ * bump 으로 우회해야 하는 케이스가 반복됨 (reference_appstore_cancel_review_window).
480
+ * 호출자가 의도한 그 버전·빌드인지 미리 보여줘서 잘못된 versionId 제출 차단.
481
+ */
482
+ export async function buildSubmitForReviewPreview(versionId) {
483
+ const { appId, platform } = await getVersionAppAndPlatform(versionId);
484
+ // 버전 메타: versionString + state
485
+ const versionData = await apiGet(`/appStoreVersions/${versionId}`, {
486
+ 'fields[appStoreVersions]': 'versionString,appStoreState',
487
+ }).catch(() => null);
488
+ const versionString = versionData?.data?.attributes?.versionString;
489
+ const state = versionData?.data?.attributes?.appStoreState;
490
+ // attached build
491
+ let attachedBuild;
492
+ const build = await apiGet(`/appStoreVersions/${versionId}/build`, {
493
+ 'fields[builds]': 'version,uploadedDate,processingState',
494
+ }).catch(() => null);
495
+ if (build?.data?.id) {
496
+ attachedBuild = {
497
+ id: build.data.id,
498
+ buildNumber: build.data.attributes?.version,
499
+ uploadedDate: build.data.attributes?.uploadedDate,
500
+ processingState: build.data.attributes?.processingState,
501
+ };
502
+ }
503
+ const localizations = (await getVersionLocalizations(versionId).catch(() => []));
504
+ const whatsNewByLocale = localizations
505
+ .filter((l) => typeof l.whatsNew === 'string' && l.whatsNew.length > 0)
506
+ .map((l) => ({
507
+ locale: l.locale,
508
+ length: l.whatsNew.length,
509
+ excerpt: l.whatsNew.length > 200 ? `${l.whatsNew.slice(0, 200)}…` : l.whatsNew,
510
+ }));
511
+ return {
512
+ versionId,
513
+ versionString,
514
+ state,
515
+ appId,
516
+ platform,
517
+ attachedBuild,
518
+ whatsNewByLocale,
519
+ };
520
+ }
429
521
  export async function submitVersionForReview(versionId) {
430
522
  const { appId, platform } = await getVersionAppAndPlatform(versionId);
431
523
  // 1. CREATED 상태의 reviewSubmission이 있으면 재사용, 없으면 새로 생성
@@ -0,0 +1,32 @@
1
+ import { JWT } from 'google-auth-library';
2
+ import type { OAuth2Client } from 'google-auth-library';
3
+ export interface ServiceAccountKey {
4
+ type?: string;
5
+ client_email?: string;
6
+ private_key?: string;
7
+ project_id?: string;
8
+ }
9
+ /** 서비스 계정 키 JSON 문자열을 검증 후 저장. 형식이 아니면 throw. */
10
+ export declare function saveBigQueryServiceAccountJson(json: string): ServiceAccountKey;
11
+ /** 저장된 BigQuery 서비스 계정 키. 없거나 형식 오류면 null. */
12
+ export declare function getBigQueryServiceAccountKey(): ServiceAccountKey | null;
13
+ /** 저장된 BigQuery 서비스 계정 키 삭제. 없으면 false. */
14
+ export declare function deleteBigQueryServiceAccountJson(): boolean;
15
+ export type BigQueryAuthSource = 'service-account' | 'user-oauth';
16
+ export interface BigQueryAuth {
17
+ client: OAuth2Client | JWT;
18
+ source: BigQueryAuthSource;
19
+ /** 서비스 계정이면 client_email, 사용자 OAuth면 null. */
20
+ clientEmail: string | null;
21
+ /** 서비스 계정 키의 project_id (있으면). */
22
+ projectId: string | null;
23
+ }
24
+ /**
25
+ * BigQuery 인증 클라이언트 해석. 우선순위:
26
+ * 1. BigQuery 서비스 계정 (~/.mimi-seed/bigquery-service-account.json)
27
+ * 2. 사용자 OAuth (~/.mimi-seed/tokens.json)
28
+ * 둘 다 없으면 null.
29
+ */
30
+ export declare function resolveBigQueryAuth(): BigQueryAuth | null;
31
+ /** BigQuery 인증을 강제. 없으면 안내 메시지와 함께 throw. */
32
+ export declare function requireBigQueryAuth(): BigQueryAuth;
@@ -0,0 +1,100 @@
1
+ import { JWT } from 'google-auth-library';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { getAuthenticatedClient } from './google-auth.js';
6
+ // BigQuery 전용 서비스 계정 키 저장 위치.
7
+ // 서비스 계정 인증은 Google Workspace 의 재인증(reauth) 정책에서 면제되므로,
8
+ // 사용자 OAuth 가 `invalid_rapt` 로 갱신 거부되는 환경에서도 안정적으로 동작한다.
9
+ const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
10
+ const BQ_SA_PATH = path.join(CONFIG_DIR, 'bigquery-service-account.json');
11
+ // jobs.create(쿼리 작업 생성) + 데이터셋 읽기에 필요한 최소 스코프.
12
+ const BQ_SCOPES = [
13
+ 'https://www.googleapis.com/auth/bigquery.readonly',
14
+ 'https://www.googleapis.com/auth/cloud-platform',
15
+ ];
16
+ function safeReadSa(p) {
17
+ try {
18
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
19
+ if (parsed.type === 'service_account' && parsed.client_email && parsed.private_key) {
20
+ return parsed;
21
+ }
22
+ return null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** 서비스 계정 키 JSON 문자열을 검증 후 저장. 형식이 아니면 throw. */
29
+ export function saveBigQueryServiceAccountJson(json) {
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(json);
33
+ }
34
+ catch {
35
+ throw new Error('JSON 파싱 실패 — 올바른 서비스 계정 키 파일이 아닙니다.');
36
+ }
37
+ if (parsed.type !== 'service_account' || !parsed.client_email || !parsed.private_key) {
38
+ throw new Error('서비스 계정 키 형식이 아닙니다 (type="service_account", client_email, private_key 필요).');
39
+ }
40
+ if (!fs.existsSync(CONFIG_DIR))
41
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
42
+ fs.writeFileSync(BQ_SA_PATH, json, { mode: 0o600 });
43
+ return parsed;
44
+ }
45
+ /** 저장된 BigQuery 서비스 계정 키. 없거나 형식 오류면 null. */
46
+ export function getBigQueryServiceAccountKey() {
47
+ if (!fs.existsSync(BQ_SA_PATH))
48
+ return null;
49
+ return safeReadSa(BQ_SA_PATH);
50
+ }
51
+ /** 저장된 BigQuery 서비스 계정 키 삭제. 없으면 false. */
52
+ export function deleteBigQueryServiceAccountJson() {
53
+ if (!fs.existsSync(BQ_SA_PATH))
54
+ return false;
55
+ fs.unlinkSync(BQ_SA_PATH);
56
+ return true;
57
+ }
58
+ function makeJwt(sa) {
59
+ return new JWT({ email: sa.client_email, key: sa.private_key, scopes: BQ_SCOPES });
60
+ }
61
+ /**
62
+ * BigQuery 인증 클라이언트 해석. 우선순위:
63
+ * 1. BigQuery 서비스 계정 (~/.mimi-seed/bigquery-service-account.json)
64
+ * 2. 사용자 OAuth (~/.mimi-seed/tokens.json)
65
+ * 둘 다 없으면 null.
66
+ */
67
+ export function resolveBigQueryAuth() {
68
+ const sa = getBigQueryServiceAccountKey();
69
+ if (sa) {
70
+ return {
71
+ client: makeJwt(sa),
72
+ source: 'service-account',
73
+ clientEmail: sa.client_email ?? null,
74
+ projectId: sa.project_id ?? null,
75
+ };
76
+ }
77
+ const oauth = getAuthenticatedClient();
78
+ if (oauth) {
79
+ return { client: oauth, source: 'user-oauth', clientEmail: null, projectId: null };
80
+ }
81
+ return null;
82
+ }
83
+ const BQ_AUTH_HINT = [
84
+ '❌ BigQuery 인증이 설정되지 않았어.',
85
+ '',
86
+ '방법 1 — 서비스 계정 (권장: Google Workspace 재인증 정책의 영향을 받지 않음):',
87
+ ' npx -y @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth',
88
+ '',
89
+ '방법 2 — Google 계정 OAuth:',
90
+ ' npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
91
+ '',
92
+ '그 다음에 다시 물어봐줘.',
93
+ ].join('\n');
94
+ /** BigQuery 인증을 강제. 없으면 안내 메시지와 함께 throw. */
95
+ export function requireBigQueryAuth() {
96
+ const auth = resolveBigQueryAuth();
97
+ if (!auth)
98
+ throw new Error(BQ_AUTH_HINT);
99
+ return auth;
100
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import readline from 'node:readline';
3
+ import fs from 'node:fs';
4
+ import { saveBigQueryServiceAccountJson, getBigQueryServiceAccountKey, } from './bigquery-auth.js';
5
+ import * as bigquery from '../bigquery/tools.js';
6
+ import { JWT } from 'google-auth-library';
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ const ask = (q) => new Promise((r) => rl.question(q, r));
9
+ async function main() {
10
+ console.log('');
11
+ console.log(' 🤖 Mimi Seed — BigQuery 서비스 계정 연결');
12
+ console.log('');
13
+ console.log(' 서비스 계정 인증은 Google Workspace 의 재인증(reauth) 정책에서');
14
+ console.log(' 면제되므로, OAuth 가 invalid_rapt 로 막히는 환경에서도 동작해.');
15
+ console.log('');
16
+ const existing = getBigQueryServiceAccountKey();
17
+ if (existing) {
18
+ console.log(` ✅ 이미 연결됨 (${existing.client_email})`);
19
+ const answer = await ask(' 다시 설정할래? (y/N): ');
20
+ if (answer.toLowerCase() !== 'y') {
21
+ rl.close();
22
+ return;
23
+ }
24
+ }
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 파일 경로를 입력해');
32
+ console.log('');
33
+ const jsonPath = await ask(' 서비스 계정 JSON 파일 경로: ');
34
+ const trimmedPath = jsonPath.trim().replace(/^["']|["']$/g, '');
35
+ if (!fs.existsSync(trimmedPath)) {
36
+ console.log(` ❌ 파일 없음: ${trimmedPath}`);
37
+ rl.close();
38
+ process.exit(1);
39
+ }
40
+ const json = fs.readFileSync(trimmedPath, 'utf-8');
41
+ let parsed;
42
+ try {
43
+ parsed = saveBigQueryServiceAccountJson(json);
44
+ }
45
+ catch (e) {
46
+ console.log(` ❌ ${e instanceof Error ? e.message : String(e)}`);
47
+ rl.close();
48
+ process.exit(1);
49
+ }
50
+ console.log('');
51
+ console.log(` ✅ 저장 완료! (${parsed.client_email})`);
52
+ if (parsed.project_id)
53
+ console.log(` 서비스 계정 프로젝트: ${parsed.project_id}`);
54
+ console.log('');
55
+ // 선택적 연결 테스트 — projectId 를 입력하면 datasets.list 로 권한 확인.
56
+ const testProject = await ask(` 연결 테스트할 GCP 프로젝트 ID (엔터 시 건너뜀${parsed.project_id ? `, 기본 ${parsed.project_id}` : ''}): `);
57
+ const projectId = testProject.trim() || parsed.project_id || '';
58
+ if (projectId) {
59
+ console.log(` 🔎 ${projectId} 데이터셋 조회 중...`);
60
+ try {
61
+ const jwt = new JWT({
62
+ email: parsed.client_email,
63
+ key: parsed.private_key,
64
+ scopes: [
65
+ 'https://www.googleapis.com/auth/bigquery.readonly',
66
+ 'https://www.googleapis.com/auth/cloud-platform',
67
+ ],
68
+ });
69
+ const datasets = await bigquery.listDatasets(jwt, projectId);
70
+ console.log(` ✅ 접근 OK — 데이터셋 ${datasets.length}개`);
71
+ for (const d of datasets.slice(0, 10))
72
+ console.log(` • ${d.datasetId} (${d.location})`);
73
+ }
74
+ catch (e) {
75
+ const msg = e instanceof Error ? e.message : String(e);
76
+ console.log(` ⚠️ 접근 실패: ${msg}`);
77
+ console.log('');
78
+ console.log(' IAM 역할이 없으면 아래를 실행해 (프로젝트 소유자 계정에서):');
79
+ console.log(` gcloud projects add-iam-policy-binding ${projectId} \\`);
80
+ console.log(` --member="serviceAccount:${parsed.client_email}" --role="roles/bigquery.jobUser"`);
81
+ console.log(` gcloud projects add-iam-policy-binding ${projectId} \\`);
82
+ console.log(` --member="serviceAccount:${parsed.client_email}" --role="roles/bigquery.dataViewer"`);
83
+ }
84
+ }
85
+ console.log('');
86
+ console.log(' 이제 Claude Code 에서:');
87
+ console.log(' "BigQuery 로 GA4 트래픽 분석해줘"');
88
+ console.log('');
89
+ rl.close();
90
+ }
91
+ main();
@@ -1,4 +1,4 @@
1
- export type AuthErrorCode = 'UNAUTHENTICATED' | 'NO_REFRESH_TOKEN' | 'INVALID_GRANT' | 'INVALID_CLIENT' | 'UNAUTHORIZED_CLIENT' | 'REFRESH_NETWORK_ERROR' | 'REFRESH_UNKNOWN' | 'CALLBACK_PORT_IN_USE' | 'CALLBACK_TIMEOUT' | 'BROWSER_OPEN_FAILED' | 'USER_DENIED' | 'CODE_EXCHANGE_FAILED' | 'TOKEN_RESPONSE_INVALID';
1
+ export type AuthErrorCode = 'UNAUTHENTICATED' | 'NO_REFRESH_TOKEN' | 'INVALID_GRANT' | 'RAPT_REQUIRED' | 'INVALID_CLIENT' | 'UNAUTHORIZED_CLIENT' | 'REFRESH_NETWORK_ERROR' | 'REFRESH_UNKNOWN' | 'CALLBACK_PORT_IN_USE' | 'CALLBACK_TIMEOUT' | 'BROWSER_OPEN_FAILED' | 'USER_DENIED' | 'CODE_EXCHANGE_FAILED' | 'TOKEN_RESPONSE_INVALID';
2
2
  export interface AuthErrorPayload {
3
3
  code: AuthErrorCode;
4
4
  message: string;
@@ -28,6 +28,17 @@ export function classifyError(e, ctx) {
28
28
  cause: raw,
29
29
  };
30
30
  }
31
+ if (oauthCode === 'invalid_rapt' || oauthCode === 'rapt_required') {
32
+ return {
33
+ code: 'RAPT_REQUIRED',
34
+ message: 'Google Workspace 재인증(reauth) 정책으로 토큰 갱신이 거부되었습니다 (invalid_rapt).',
35
+ hint: 'mimi-seed-auth 로 재로그인하거나, 재인증 정책의 영향을 받지 않는 서비스 계정 인증을 사용하세요 ' +
36
+ '(BigQuery: mimi-seed-bigquery-auth).',
37
+ retriable: false,
38
+ needsReauth: true,
39
+ cause: raw,
40
+ };
41
+ }
31
42
  if (oauthCode === 'invalid_client') {
32
43
  return {
33
44
  code: 'INVALID_CLIENT',
@@ -128,7 +139,7 @@ function extractOAuthErrorCode(e, raw) {
128
139
  return dataErr;
129
140
  }
130
141
  // message에 'invalid_grant' 같은 표준 코드가 박혀 있는 경우
131
- const m = raw.match(/\b(invalid_grant|invalid_client|invalid_request|unauthorized_client|access_denied|unsupported_grant_type)\b/);
142
+ const m = raw.match(/\b(invalid_grant|invalid_client|invalid_request|unauthorized_client|access_denied|unsupported_grant_type|invalid_rapt|rapt_required)\b/);
132
143
  return m?.[1];
133
144
  }
134
145
  function isNetworkError(e, raw) {
@@ -11,6 +11,13 @@ export declare function getStoredCredentials(): {
11
11
  } | null;
12
12
  export declare function saveCredentials(clientId: string, clientSecret: string): void;
13
13
  export declare function getStoredTokens(): StoredTokens | null;
14
+ /**
15
+ * tokens.json mtime — 마지막 refresh 시각의 근사값.
16
+ * (saveTokens 가 매번 writeFileSync 으로 갱신하므로 mtime ≈ 마지막 갱신/저장.)
17
+ * Google refresh_token 은 7일(미인증 앱) ~ 6개월(인증 앱) 미사용 시 revoke 됨.
18
+ * auth_status 응답 enrichment 에 사용.
19
+ */
20
+ export declare function getTokensLastRefreshMs(): number | null;
14
21
  export declare function createOAuth2Client(clientId: string, clientSecret: string): import("google-auth-library").OAuth2Client;
15
22
  /**
16
23
  * Get authenticated OAuth2 client.
@@ -59,4 +66,9 @@ export type RefreshStatus = {
59
66
  *
60
67
  * MCP 도구와 CLI 양쪽에서 공유.
61
68
  */
69
+ /**
70
+ * 사전 갱신 마진. 기존 60_000(1분)에서 300_000(5분)으로 상향 — Google OAuth access_token 의
71
+ * 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
72
+ * "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
73
+ */
62
74
  export declare function ensureFreshAccessToken(marginMs?: number): Promise<RefreshStatus>;