@yoonion/mimi-seed-mcp 0.3.13 → 0.3.15

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.
@@ -28,13 +28,36 @@ export function saveAppStoreCredentials(creds) {
28
28
  fs.mkdirSync(dir, { recursive: true });
29
29
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
30
30
  }
31
+ function normalizePrivateKey(raw) {
32
+ // Normalize CRLF → LF, strip extra whitespace from lines
33
+ const lines = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
34
+ const header = lines.find(l => l.startsWith('-----BEGIN'));
35
+ const footer = lines.find(l => l.startsWith('-----END'));
36
+ if (!header || !footer)
37
+ return raw; // not PEM, pass through and let importPKCS8 error
38
+ const body = lines
39
+ .filter(l => l && !l.startsWith('-----'))
40
+ .join('');
41
+ // Re-chunk into 64-char lines (standard PEM)
42
+ const chunks = body.match(/.{1,64}/g) ?? [];
43
+ return [header, ...chunks, footer, ''].join('\n');
44
+ }
31
45
  export async function generateToken(creds) {
32
- const key = await importPKCS8(creds.privateKey, 'ES256');
46
+ const normalizedKey = normalizePrivateKey(creds.privateKey);
47
+ let key;
48
+ try {
49
+ key = await importPKCS8(normalizedKey, 'ES256');
50
+ }
51
+ catch (err) {
52
+ throw new Error(`App Store 개인 키 파싱 실패 — ~/.mimi-seed/appstore.json의 privateKey 형식 확인 필요.\n원인: ${err.message}`);
53
+ }
54
+ // Subtract 60s from iat to tolerate local clock running slightly ahead of Apple servers.
55
+ const iat = Math.floor(Date.now() / 1000) - 60;
33
56
  return new SignJWT({})
34
57
  .setProtectedHeader({ alg: 'ES256', kid: creds.keyId, typ: 'JWT' })
35
58
  .setIssuer(creds.issuerId)
36
- .setIssuedAt()
37
- .setExpirationTime('20m')
59
+ .setIssuedAt(iat)
60
+ .setExpirationTime(iat + 20 * 60)
38
61
  .setAudience('appstoreconnect-v1')
39
62
  .sign(key);
40
63
  }
@@ -41,6 +41,15 @@ export declare function updateVersionLocalization(localizationId: string, fields
41
41
  * localizationId를 직접 모를 때 편의용.
42
42
  */
43
43
  export declare function updateVersionWhatsNew(versionId: string, locale: string, fields: LocalizationUpdateFields): Promise<any>;
44
+ export declare function updateReviewNotes(versionId: string, notes: string): Promise<{
45
+ reviewDetailId: string;
46
+ notes: string;
47
+ }>;
48
+ export declare function getReviewNotes(versionId: string): Promise<{
49
+ reviewDetailId: string | null;
50
+ notes: string | null;
51
+ contactEmail: string | null;
52
+ }>;
44
53
  export declare function listBuilds(appId: string): Promise<any>;
45
54
  export declare function listBetaGroups(appId: string): Promise<any>;
46
55
  export declare function getAppInfo(appId: string): Promise<any>;
@@ -185,6 +185,60 @@ export async function updateVersionWhatsNew(versionId, locale, fields) {
185
185
  }
186
186
  return updateVersionLocalization(target.id, fields);
187
187
  }
188
+ // ─── 리뷰어 노트 (appStoreReviewDetail.notes) ───
189
+ export async function updateReviewNotes(versionId, notes) {
190
+ // 1. 기존 reviewDetail 조회
191
+ let reviewDetailId = null;
192
+ try {
193
+ const existing = await apiGet(`/appStoreVersions/${versionId}/appStoreReviewDetail`, {
194
+ 'fields[appStoreReviewDetails]': 'notes,contactFirstName,contactLastName,contactPhone,contactEmail',
195
+ });
196
+ reviewDetailId = existing?.data?.id ?? null;
197
+ }
198
+ catch {
199
+ // 없으면 새로 생성
200
+ }
201
+ if (reviewDetailId) {
202
+ // 2a. 있으면 PATCH
203
+ const updated = await apiPatch(`/appStoreReviewDetails/${reviewDetailId}`, {
204
+ data: {
205
+ type: 'appStoreReviewDetails',
206
+ id: reviewDetailId,
207
+ attributes: { notes },
208
+ },
209
+ });
210
+ return { reviewDetailId, notes: updated?.data?.attributes?.notes ?? notes };
211
+ }
212
+ else {
213
+ // 2b. 없으면 POST (version과 relationship 연결)
214
+ const created = await apiPost('/appStoreReviewDetails', {
215
+ data: {
216
+ type: 'appStoreReviewDetails',
217
+ attributes: { notes },
218
+ relationships: {
219
+ appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
220
+ },
221
+ },
222
+ });
223
+ const newId = created?.data?.id ?? '';
224
+ return { reviewDetailId: newId, notes: created?.data?.attributes?.notes ?? notes };
225
+ }
226
+ }
227
+ export async function getReviewNotes(versionId) {
228
+ try {
229
+ const data = await apiGet(`/appStoreVersions/${versionId}/appStoreReviewDetail`, {
230
+ 'fields[appStoreReviewDetails]': 'notes,contactEmail,demoAccountName,demoAccountRequired',
231
+ });
232
+ return {
233
+ reviewDetailId: data?.data?.id ?? null,
234
+ notes: data?.data?.attributes?.notes ?? null,
235
+ contactEmail: data?.data?.attributes?.contactEmail ?? null,
236
+ };
237
+ }
238
+ catch {
239
+ return { reviewDetailId: null, notes: null, contactEmail: null };
240
+ }
241
+ }
188
242
  // ─── 빌드 ───
189
243
  export async function listBuilds(appId) {
190
244
  const data = await apiGet(`/builds`, {
@@ -341,20 +395,16 @@ async function getVersionAppAndPlatform(versionId) {
341
395
  return { appId, platform };
342
396
  }
343
397
  async function findOpenReviewSubmission(appId, platform) {
398
+ // ASC API는 filter[state]=CREATED를 더 이상 허용하지 않음 (READY_FOR_REVIEW, WAITING_FOR_REVIEW 등만 허용).
399
+ // CREATED 상태 submission은 별도로 조회 불가 → 이미 진행 중인 submission만 재사용.
400
+ // 없으면 submitVersionForReview가 새로 생성.
344
401
  const data = await apiGet('/reviewSubmissions', {
345
402
  'filter[app]': appId,
346
403
  'filter[platform]': platform,
347
- 'filter[state]': 'READY_FOR_REVIEW,COMPLETING,UNRESOLVED_ISSUES',
348
- 'limit': '1',
349
- });
350
- // CREATED 상태(아직 제출 안 된)도 별도 조회해서 우선 재사용
351
- const created = await apiGet('/reviewSubmissions', {
352
- 'filter[app]': appId,
353
- 'filter[platform]': platform,
354
- 'filter[state]': 'CREATED',
404
+ 'filter[state]': 'READY_FOR_REVIEW,WAITING_FOR_REVIEW,COMPLETING,UNRESOLVED_ISSUES',
355
405
  'limit': '1',
356
406
  });
357
- return created?.data?.[0]?.id ?? data?.data?.[0]?.id ?? null;
407
+ return data?.data?.[0]?.id ?? null;
358
408
  }
359
409
  async function isVersionAttached(submissionId, versionId) {
360
410
  const data = await apiGet(`/reviewSubmissions/${submissionId}/items`, {
@@ -0,0 +1,29 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ export declare function runQuery(auth: OAuth2Client, projectId: string, query: string, maxResults?: number): Promise<{
3
+ jobComplete: boolean | null | undefined;
4
+ totalRows: string | null | undefined;
5
+ schema: {
6
+ name: string | null | undefined;
7
+ type: string | null | undefined;
8
+ }[];
9
+ rows: {
10
+ [k: string]: any;
11
+ }[];
12
+ }>;
13
+ export declare function listDatasets(auth: OAuth2Client, projectId: string): Promise<{
14
+ datasetId: string | null | undefined;
15
+ location: string | undefined;
16
+ }[]>;
17
+ export declare function listTables(auth: OAuth2Client, projectId: string, datasetId: string): Promise<{
18
+ tableId: string | null | undefined;
19
+ type: string | undefined;
20
+ }[]>;
21
+ export declare function getTableSchema(auth: OAuth2Client, projectId: string, datasetId: string, tableId: string): Promise<{
22
+ tableId: string;
23
+ schema: {
24
+ name: string | null | undefined;
25
+ type: string | null | undefined;
26
+ mode: string | null | undefined;
27
+ description: string | null | undefined;
28
+ }[] | undefined;
29
+ }>;
@@ -0,0 +1,53 @@
1
+ import { google } from 'googleapis';
2
+ const bq = () => google.bigquery('v2');
3
+ // ─── 쿼리 실행 ───
4
+ export async function runQuery(auth, projectId, query, maxResults = 1000) {
5
+ const res = await bq().jobs.query({
6
+ auth,
7
+ projectId,
8
+ requestBody: {
9
+ query,
10
+ useLegacySql: false,
11
+ maxResults,
12
+ timeoutMs: 30000,
13
+ },
14
+ });
15
+ const data = res.data;
16
+ const schema = data.schema?.fields ?? [];
17
+ const rows = (data.rows ?? []).map((row) => Object.fromEntries((row.f ?? []).map((cell, i) => [schema[i]?.name ?? `col${i}`, cell.v])));
18
+ return {
19
+ jobComplete: data.jobComplete,
20
+ totalRows: data.totalRows,
21
+ schema: schema.map((f) => ({ name: f.name, type: f.type })),
22
+ rows,
23
+ };
24
+ }
25
+ // ─── 데이터셋 목록 ───
26
+ export async function listDatasets(auth, projectId) {
27
+ const res = await bq().datasets.list({ auth, projectId });
28
+ return (res.data.datasets ?? []).map((d) => ({
29
+ datasetId: d.datasetReference?.datasetId,
30
+ location: d.location,
31
+ }));
32
+ }
33
+ // ─── 테이블 목록 ───
34
+ export async function listTables(auth, projectId, datasetId) {
35
+ const res = await bq().tables.list({ auth, projectId, datasetId });
36
+ return (res.data.tables ?? []).map((t) => ({
37
+ tableId: t.tableReference?.tableId,
38
+ type: t.type,
39
+ }));
40
+ }
41
+ // ─── 테이블 스키마 ───
42
+ export async function getTableSchema(auth, projectId, datasetId, tableId) {
43
+ const res = await bq().tables.get({ auth, projectId, datasetId, tableId });
44
+ return {
45
+ tableId,
46
+ schema: res.data.schema?.fields?.map((f) => ({
47
+ name: f.name,
48
+ type: f.type,
49
+ mode: f.mode,
50
+ description: f.description,
51
+ })),
52
+ };
53
+ }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import * as playstore from './playstore/tools.js';
13
13
  import * as appstore from './appstore/tools.js';
14
14
  import * as appstoreScreenshots from './appstore/screenshots.js';
15
15
  import * as iam from './iam/tools.js';
16
+ import * as bigquery from './bigquery/tools.js';
16
17
  import { checkPlayStoreRisks, checkAppStoreRisks, formatRisks } from './checks/risks.js';
17
18
  import { buildPlayStoreReleasePlan, buildAppStoreReleasePlan } from './checks/plan.js';
18
19
  import { validateAppStoreScreenshots, validatePlayStoreScreenshots, formatValidationResults } from './checks/screenshots.js';
@@ -830,6 +831,32 @@ server.tool('appstore_update_whats_new', "App Store '이 버전의 새로운 기
830
831
  const result = await appstore.updateVersionWhatsNew(versionId, locale, { whatsNew });
831
832
  return { content: [{ type: 'text', text: `✅ ${locale} 로캘의 What's New가 업데이트됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
832
833
  });
834
+ server.tool('appstore_update_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 등록/수정. versionId 버전에 appStoreReviewDetail.notes를 PATCH하거나 없으면 POST로 생성. 심사 시 리뷰어에게 전달되는 테스트 계정·기능 안내 텍스트 작성에 사용.", {
835
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
836
+ notes: z.string().describe('리뷰어에게 전달할 메모 (테스트 계정, 주요 변경사항, 접근 방법 등). 4000자 이내 권장.'),
837
+ }, async ({ versionId, notes }) => {
838
+ const result = await appstore.updateReviewNotes(versionId, notes);
839
+ return {
840
+ content: [{
841
+ type: 'text',
842
+ text: `✅ 리뷰어 노트가 저장됐어.\n\nreviewDetailId: ${result.reviewDetailId}\n\n${result.notes}`,
843
+ }],
844
+ };
845
+ });
846
+ server.tool('appstore_get_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 조회. 현재 등록된 notes, contactEmail 확인용.", {
847
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
848
+ }, async ({ versionId }) => {
849
+ const result = await appstore.getReviewNotes(versionId);
850
+ if (!result.reviewDetailId) {
851
+ return { content: [{ type: 'text', text: '이 버전에는 아직 리뷰어 노트가 없어. appstore_update_review_notes로 등록해줘.' }] };
852
+ }
853
+ return {
854
+ content: [{
855
+ type: 'text',
856
+ text: `reviewDetailId: ${result.reviewDetailId}\ncontactEmail: ${result.contactEmail ?? '(없음)'}\n\n노트:\n${result.notes ?? '(비어있음)'}`,
857
+ }],
858
+ };
859
+ });
833
860
  server.tool('appstore_list_builds', 'TestFlight 빌드 목록', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
834
861
  const builds = await appstore.listBuilds(appId);
835
862
  return { content: [{ type: 'text', text: JSON.stringify(builds, null, 2) }] };
@@ -1381,9 +1408,73 @@ server.tool('generate_review_reply', [
1381
1408
  return { content: [{ type: 'text', text }] };
1382
1409
  });
1383
1410
  // ══════════════════════════════════════════════════
1411
+ // BigQuery
1412
+ // ══════════════════════════════════════════════════
1413
+ server.tool('bigquery_run_query', 'BigQuery SQL 쿼리 실행 (SELECT). GA4 analytics_* 테이블 분석에 사용.', {
1414
+ projectId: z.string().describe('GCP 프로젝트 ID (예: ads-coffee)'),
1415
+ query: z.string().describe('실행할 StandardSQL 쿼리'),
1416
+ maxResults: z.number().optional().describe('최대 행 수 (기본 1000)'),
1417
+ }, async ({ projectId, query, maxResults }) => {
1418
+ const auth = requireAuth();
1419
+ const result = await bigquery.runQuery(auth, projectId, query, maxResults ?? 1000);
1420
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1421
+ });
1422
+ server.tool('bigquery_list_datasets', 'BigQuery 프로젝트의 데이터셋 목록 조회', {
1423
+ projectId: z.string().describe('GCP 프로젝트 ID'),
1424
+ }, async ({ projectId }) => {
1425
+ const auth = requireAuth();
1426
+ const datasets = await bigquery.listDatasets(auth, projectId);
1427
+ return { content: [{ type: 'text', text: JSON.stringify(datasets, null, 2) }] };
1428
+ });
1429
+ server.tool('bigquery_list_tables', 'BigQuery 데이터셋의 테이블 목록 조회', {
1430
+ projectId: z.string().describe('GCP 프로젝트 ID'),
1431
+ datasetId: z.string().describe('데이터셋 ID (예: analytics_530080532)'),
1432
+ }, async ({ projectId, datasetId }) => {
1433
+ const auth = requireAuth();
1434
+ const tables = await bigquery.listTables(auth, projectId, datasetId);
1435
+ return { content: [{ type: 'text', text: JSON.stringify(tables, null, 2) }] };
1436
+ });
1437
+ server.tool('bigquery_get_table_schema', 'BigQuery 테이블 스키마(컬럼 정보) 조회', {
1438
+ projectId: z.string().describe('GCP 프로젝트 ID'),
1439
+ datasetId: z.string().describe('데이터셋 ID'),
1440
+ tableId: z.string().describe('테이블 ID'),
1441
+ }, async ({ projectId, datasetId, tableId }) => {
1442
+ const auth = requireAuth();
1443
+ const schema = await bigquery.getTableSchema(auth, projectId, datasetId, tableId);
1444
+ return { content: [{ type: 'text', text: JSON.stringify(schema, null, 2) }] };
1445
+ });
1446
+ // ══════════════════════════════════════════════════
1384
1447
  // Start
1385
1448
  // ══════════════════════════════════════════════════
1449
+ // `npx -y @yoonion/mimi-seed-mcp <subcommand>` 처리.
1450
+ // npx는 스코프 패키지의 basename(`mimi-seed-mcp`)을 매치해 이 bin을 실행하므로,
1451
+ // 추가 인자(`mimi-seed-auth` 등)는 여기 argv로 흘러들어온다. 이전엔 MCP 서버가
1452
+ // stdin을 기다리며 영구 hang 됐다 — 이제 sub-CLI로 위임한다.
1453
+ const SUBCOMMANDS = {
1454
+ 'mimi-seed-auth': () => import('./auth/cli.js'),
1455
+ 'mimi-seed-playstore-auth': () => import('./auth/playstore-setup-cli.js'),
1456
+ 'mimi-seed-appstore-auth': () => import('./appstore/setup-cli.js'),
1457
+ };
1386
1458
  async function main() {
1459
+ const sub = process.argv[2];
1460
+ if (sub && SUBCOMMANDS[sub]) {
1461
+ // argv에서 subcommand 토큰 제거 후 sub-CLI 모듈 import — 모듈 top-level이 main() 실행
1462
+ process.argv.splice(2, 1);
1463
+ await SUBCOMMANDS[sub]();
1464
+ return;
1465
+ }
1466
+ if (sub && !sub.startsWith('-')) {
1467
+ console.error([
1468
+ `❌ Unknown subcommand: ${sub}`,
1469
+ '',
1470
+ 'Available:',
1471
+ ...Object.keys(SUBCOMMANDS).map((k) => ` npx -y @yoonion/mimi-seed-mcp ${k}`),
1472
+ '',
1473
+ 'Or run the MCP server (no args):',
1474
+ ' npx -y @yoonion/mimi-seed-mcp',
1475
+ ].join('\n'));
1476
+ process.exit(2);
1477
+ }
1387
1478
  const transport = new StdioServerTransport();
1388
1479
  await server.connect(transport);
1389
1480
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {