@yoonion/mimi-seed-mcp 0.13.18 → 0.14.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.
Files changed (56) hide show
  1. package/assets/agent-guide.md +4 -4
  2. package/dist/ai/client.d.ts +8 -0
  3. package/dist/ai/client.js +8 -0
  4. package/dist/ai/notes.js +2 -2
  5. package/dist/ai/review.js +2 -2
  6. package/dist/android/keystore.js +5 -2
  7. package/dist/appstore/auth.js +2 -4
  8. package/dist/appstore/http.js +2 -1
  9. package/dist/appstore/previews.js +11 -3
  10. package/dist/appstore/product-review.js +2 -5
  11. package/dist/appstore/screenshots.js +3 -6
  12. package/dist/appstore/tools.js +9 -8
  13. package/dist/auth/bigquery-auth.js +2 -3
  14. package/dist/auth/constants.js +2 -3
  15. package/dist/auth/google-auth.js +5 -9
  16. package/dist/auth/playstore-auth.js +3 -7
  17. package/dist/ci/config.js +2 -4
  18. package/dist/ci/github.js +2 -1
  19. package/dist/ci/gitlab.js +2 -1
  20. package/dist/facebook/api.js +4 -3
  21. package/dist/facebook/config.d.ts +4 -3
  22. package/dist/facebook/config.js +13 -26
  23. package/dist/facebook/setup.d.ts +2 -1
  24. package/dist/facebook/setup.js +4 -3
  25. package/dist/googleads/config.js +2 -6
  26. package/dist/googleads/tools.js +3 -2
  27. package/dist/instagram/api.js +2 -1
  28. package/dist/jenkins/config.js +2 -4
  29. package/dist/jenkins/credentials.js +7 -6
  30. package/dist/jenkins/http.d.ts +2 -2
  31. package/dist/jenkins/http.js +4 -3
  32. package/dist/jenkins/jobs.js +6 -5
  33. package/dist/lib/atomic-write.d.ts +20 -0
  34. package/dist/lib/atomic-write.js +53 -0
  35. package/dist/lib/http.d.ts +18 -0
  36. package/dist/lib/http.js +58 -0
  37. package/dist/lib/project-manifest.d.ts +1 -1
  38. package/dist/playstore/tools.js +2 -2
  39. package/dist/registers/android.js +40 -22
  40. package/dist/registers/auth.js +1 -1
  41. package/dist/registers/bigquery.js +2 -2
  42. package/dist/registers/checks.js +1 -1
  43. package/dist/registers/facebook.js +16 -10
  44. package/dist/registers/firebase.js +1 -1
  45. package/dist/registers/jenkins.js +6 -6
  46. package/dist/registers/playstore.js +9 -9
  47. package/dist/remote-sync.js +3 -3
  48. package/dist/social/profile-store.js +2 -5
  49. package/dist/social/setup-cli.js +12 -8
  50. package/dist/threads/api.js +3 -2
  51. package/dist/video/files.d.ts +1 -1
  52. package/dist/video/files.js +4 -19
  53. package/dist/video/project.js +2 -2
  54. package/dist/video/providers.js +4 -2
  55. package/dist/video/research.js +2 -2
  56. package/package.json +1 -1
@@ -114,9 +114,9 @@ every credential, which also tells them where to obtain each token
114
114
  `mimi-seed auth meta` opens the combined social setup entry point when the user wants to review or reconnect
115
115
  all three Meta platforms in one pass.
116
116
 
117
- Instagram and Threads can use named local profiles. Save one with
118
- `mimi-seed auth instagram --profile <id>` or `mimi-seed auth threads --profile <id>`, then select it per project
119
- with `.mimi-seed.json` `socialProfiles.instagram` / `socialProfiles.threads`. MCP tools also accept an explicit
117
+ All three Meta platforms can use named local profiles. Save one with
118
+ `mimi-seed auth facebook|instagram|threads --profile <id>`, then select it per project with `.mimi-seed.json` →
119
+ `socialProfiles.facebook` / `.instagram` / `.threads`. MCP tools also accept an explicit
120
120
  `profile`; explicit input wins over the project mapping.
121
121
 
122
122
  Each `mimi-seed auth <cred>` wraps the matching `npx -y @yoonion/mimi-seed-mcp mimi-seed-*-auth` binary, so
@@ -145,7 +145,7 @@ Credentials live under `~/.mimi-seed/` (legacy `~/.preseed/` is still read):
145
145
  | `jenkins.json`, `ci.json` | Jenkins / GitHub-GitLab CI connection |
146
146
  | `google-ads.json` | Google Ads developer token + customer id |
147
147
  | `facebook.json`, `instagram.json`, `threads.json` | Default/legacy Page / account tokens for social post tools |
148
- | `social-profiles/<profile>.json` | Named Instagram/Threads tokens selected by the current project's `.mimi-seed.json` |
148
+ | `social-profiles/<profile>.json` | Named Facebook/Instagram/Threads tokens selected by the current project's `.mimi-seed.json` |
149
149
 
150
150
  Notes that matter in practice:
151
151
 
@@ -1,4 +1,12 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
+ /**
3
+ * mcp-server 가 호출하는 Claude 모델의 SSOT — ai/*, video/* 가 모두 이 값을 쓴다.
4
+ *
5
+ * 예전엔 두 패키지 6개 파일에 리터럴로 흩어져 있어서, 모델 교체가 6곳 수정 + 가드 0 이었다.
6
+ * CLI 쪽 쌍둥이 상수는 `packages/cli/src/ai-model.ts` 이고, 두 값의 일치는
7
+ * `__tests__/ai-model-parity.test.ts` 가 강제한다.
8
+ */
9
+ export declare const AI_MODEL = "claude-haiku-4-5-20251001";
2
10
  export declare const LOCALE_NAMES: Record<string, string>;
3
11
  export declare function requireApiKey(): Anthropic;
4
12
  export declare function parseJsonResponse<T>(text: string): T;
package/dist/ai/client.js CHANGED
@@ -1,4 +1,12 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
+ /**
3
+ * mcp-server 가 호출하는 Claude 모델의 SSOT — ai/*, video/* 가 모두 이 값을 쓴다.
4
+ *
5
+ * 예전엔 두 패키지 6개 파일에 리터럴로 흩어져 있어서, 모델 교체가 6곳 수정 + 가드 0 이었다.
6
+ * CLI 쪽 쌍둥이 상수는 `packages/cli/src/ai-model.ts` 이고, 두 값의 일치는
7
+ * `__tests__/ai-model-parity.test.ts` 가 강제한다.
8
+ */
9
+ export const AI_MODEL = 'claude-haiku-4-5-20251001';
2
10
  export const LOCALE_NAMES = {
3
11
  'ko': '한국어', 'ko-KR': '한국어',
4
12
  'en': '영어', 'en-US': '영어', 'en-GB': '영어',
package/dist/ai/notes.js CHANGED
@@ -1,4 +1,4 @@
1
- import { requireApiKey, parseJsonResponse, LOCALE_NAMES } from './client.js';
1
+ import { requireApiKey, parseJsonResponse, LOCALE_NAMES, AI_MODEL } from './client.js';
2
2
  const TONE_DESCRIPTIONS = {
3
3
  concise: '간결한 버전 (3줄 이내, 불릿 포인트, 사용자 혜택 중심)',
4
4
  detailed: '상세 버전 (주요 변경사항 5~8개, 불릿 포인트, 구체적 기능 설명)',
@@ -34,7 +34,7 @@ ${commitsText}
34
34
 
35
35
  각 tone의 "text" 필드에 실제 릴리즈 노트 내용을 채워주세요.`;
36
36
  const response = await client.messages.create({
37
- model: 'claude-haiku-4-5-20251001',
37
+ model: AI_MODEL,
38
38
  max_tokens: maxTokens,
39
39
  system: '앱 스토어 릴리즈 노트 전문 카피라이터입니다. 커밋 내역을 사용자 친화적인 언어로 변환합니다. 항상 유효한 JSON으로만 응답하세요.',
40
40
  messages: [{ role: 'user', content: prompt }],
package/dist/ai/review.js CHANGED
@@ -1,4 +1,4 @@
1
- import { requireApiKey, parseJsonResponse, LOCALE_NAMES } from './client.js';
1
+ import { requireApiKey, parseJsonResponse, LOCALE_NAMES, AI_MODEL } from './client.js';
2
2
  const TONE_GUIDES = {
3
3
  friendly: '친근하고 따뜻하게. 이모지 1~2개 사용. 감사 인사로 시작.',
4
4
  professional: '정중하고 공식적으로. 문제를 인정하고 해결책을 제시.',
@@ -30,7 +30,7 @@ export async function generateReviewReply(opts) {
30
30
  const sentiment = detectSentiment(reviewText);
31
31
  const langName = LOCALE_NAMES[language] ?? language;
32
32
  const response = await client.messages.create({
33
- model: 'claude-haiku-4-5-20251001',
33
+ model: AI_MODEL,
34
34
  max_tokens: 500,
35
35
  system: `앱 개발자를 대신해 스토어 리뷰에 답변하는 전문가입니다. ${langName}로 150자 이내로 답변하세요. ${TONE_GUIDES[tone] ?? TONE_GUIDES.friendly} 개발자 이름: ${developerName ?? '개발팀'}`,
36
36
  messages: [{
@@ -16,9 +16,12 @@ export function generateKeystore(opts) {
16
16
  const storePassword = randomPassword(20);
17
17
  const keyPassword = randomPassword(20);
18
18
  const keyAlias = 'upload';
19
- const org = opts.org ?? 'Supervlabs';
19
+ // 조직명 기본값은 이름 자체다. 예전엔 한 사설 조직명이 박혀 있어서 이 도구로 만든
20
+ // **모든 사용자의 서명 키**에 남의 조직이 들어갔다. 지역(L/ST)도 Seoul 로 고정돼 있었는데,
21
+ // X.500 에서 선택 항목이므로 값을 지어내는 대신 뺀다 (C 는 사용자가 고를 수 있다).
22
+ const org = opts.org ?? opts.appName;
20
23
  const country = opts.country ?? 'KR';
21
- const dname = `CN=${opts.appName}, OU=Engineering, O=${org}, L=Seoul, ST=Seoul, C=${country}`;
24
+ const dname = `CN=${opts.appName}, OU=Engineering, O=${org}, C=${country}`;
22
25
  const keystorePath = join(tmpdir(), `mimi-seed-ks-${Date.now()}.jks`);
23
26
  try {
24
27
  const result = spawnSync('keytool', [
@@ -2,6 +2,7 @@ import { SignJWT, importPKCS8 } from 'jose';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
+ import { writeCredentialJson } from '../lib/atomic-write.js';
5
6
  // Primary location under ~/.mimi-seed. Legacy ~/.preseed read as fallback
6
7
  // during the rebrand window so existing App Store Connect sessions don't
7
8
  // force a re-setup.
@@ -23,10 +24,7 @@ export function getAppStoreCredentials() {
23
24
  }
24
25
  }
25
26
  export function saveAppStoreCredentials(creds) {
26
- const dir = path.dirname(CONFIG_PATH);
27
- if (!fs.existsSync(dir))
28
- fs.mkdirSync(dir, { recursive: true });
29
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
27
+ writeCredentialJson(CONFIG_PATH, creds);
30
28
  }
31
29
  function normalizePrivateKey(raw) {
32
30
  // Normalize CRLF → LF, strip extra whitespace from lines
@@ -1,5 +1,6 @@
1
1
  import { getAuthHeaders } from './auth.js';
2
2
  import { friendlyAppStoreError } from './errors.js';
3
+ import { fetchWithTimeout } from '../lib/http.js';
3
4
  export const V1_BASE = 'https://api.appstoreconnect.apple.com/v1';
4
5
  export const V2_BASE = 'https://api.appstoreconnect.apple.com/v2';
5
6
  export async function authHeadersOrThrow() {
@@ -15,7 +16,7 @@ export async function authHeadersOrThrow() {
15
16
  return headers;
16
17
  }
17
18
  export async function apiRequest(base, resourcePath, authHeaders, init) {
18
- const response = await fetch(`${base}${resourcePath}`, {
19
+ const response = await fetchWithTimeout(`${base}${resourcePath}`, {
19
20
  ...init,
20
21
  headers: { ...authHeaders, ...(init.headers ?? {}) },
21
22
  });
@@ -12,6 +12,7 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import crypto from 'node:crypto';
14
14
  import { getAuthHeaders } from './auth.js';
15
+ import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
15
16
  const BASE = 'https://api.appstoreconnect.apple.com/v1';
16
17
  async function req(pathOrUrl, init = {}) {
17
18
  const headers = await getAuthHeaders();
@@ -24,7 +25,7 @@ async function req(pathOrUrl, init = {}) {
24
25
  ].join('\n'));
25
26
  }
26
27
  const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${BASE}${pathOrUrl}`;
27
- const res = await fetch(url, { ...init, headers: { ...headers, ...(init.headers ?? {}) } });
28
+ const res = await fetchWithTimeout(url, { ...init, headers: { ...headers, ...(init.headers ?? {}) } });
28
29
  if (!res.ok) {
29
30
  const body = await res.text();
30
31
  throw new Error(`App Store API ${res.status} ${init.method ?? 'GET'} ${pathOrUrl}: ${body}`);
@@ -85,11 +86,18 @@ async function uploadChunks(absPath, ops) {
85
86
  for (const op of ops) {
86
87
  // 파일 전체를 메모리에 올리지 않는다 — 동영상은 수백 MB 가 될 수 있다.
87
88
  const chunk = Buffer.alloc(op.length);
88
- fs.readSync(fd, chunk, 0, op.length, op.offset);
89
+ const bytesRead = fs.readSync(fd, chunk, 0, op.length, op.offset);
90
+ // Buffer.alloc 은 0 으로 채운다 — 짧게 읽힌 걸 모르고 보내면 **0 패딩이 영상 데이터로**
91
+ // 올라가고, PUT 은 성공한 뒤 체크섬 불일치가 한참 뒤 인코딩 실패로 나타난다.
92
+ // 여기서 즉시 멈추는 게 그 추적 불가능한 실패보다 낫다.
93
+ if (bytesRead !== op.length) {
94
+ throw new Error(`파일을 읽는 중 크기가 어긋났다 (offset ${op.offset}: ${op.length} 바이트 요청, ${bytesRead} 읽음). ` +
95
+ '업로드 도중 파일이 바뀌었을 수 있다 — 파일을 확인하고 다시 시도할 것.');
96
+ }
89
97
  const headers = {};
90
98
  for (const h of op.requestHeaders ?? [])
91
99
  headers[h.name] = h.value;
92
- const res = await fetch(op.url, { method: op.method ?? 'PUT', headers, body: chunk });
100
+ const res = await fetchWithTimeout(op.url, { method: op.method ?? 'PUT', headers, body: chunk }, HTTP_TRANSFER_TIMEOUT_MS);
93
101
  if (!res.ok) {
94
102
  throw new Error(`미리보기 업로드 실패 (offset ${op.offset}): ${res.status} ${await res.text()}`);
95
103
  }
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { V1_BASE, V2_BASE, apiRequest, authHeadersOrThrow } from './http.js';
5
+ import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
5
6
  function productResource(productType) {
6
7
  if (productType === 'subscription') {
7
8
  return { base: V1_BASE, type: 'subscriptions', path: '/subscriptions' };
@@ -58,11 +59,7 @@ async function uploadChunks(buffer, operations) {
58
59
  const headers = {};
59
60
  for (const header of operation.requestHeaders)
60
61
  headers[header.name] = header.value;
61
- const response = await fetch(operation.url, {
62
- method: operation.method,
63
- headers,
64
- body: new Uint8Array(chunk),
65
- });
62
+ const response = await fetchWithTimeout(operation.url, { method: operation.method, headers, body: new Uint8Array(chunk) }, HTTP_TRANSFER_TIMEOUT_MS);
66
63
  if (!response.ok) {
67
64
  const body = await response.text();
68
65
  throw new Error(`청크 업로드 실패 (offset=${operation.offset}, length=${operation.length}): ` +
@@ -2,6 +2,7 @@ import { getAuthHeaders } from './auth.js';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
5
6
  /**
6
7
  * App Store Connect API — Screenshot upload
7
8
  *
@@ -29,7 +30,7 @@ async function authHeadersOrThrow() {
29
30
  async function req(pathOrUrl, init = {}) {
30
31
  const headers = await authHeadersOrThrow();
31
32
  const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${BASE}${pathOrUrl}`;
32
- const res = await fetch(url, {
33
+ const res = await fetchWithTimeout(url, {
33
34
  ...init,
34
35
  headers: { ...headers, ...(init.headers ?? {}) },
35
36
  });
@@ -94,11 +95,7 @@ async function uploadChunks(filePath, ops) {
94
95
  const headers = {};
95
96
  for (const h of op.requestHeaders)
96
97
  headers[h.name] = h.value;
97
- const res = await fetch(op.url, {
98
- method: op.method,
99
- headers,
100
- body: slice,
101
- });
98
+ const res = await fetchWithTimeout(op.url, { method: op.method, headers, body: slice }, HTTP_TRANSFER_TIMEOUT_MS);
102
99
  if (!res.ok) {
103
100
  const text = await res.text();
104
101
  throw new Error(`청크 업로드 실패 (offset=${op.offset}, length=${op.length}): ${res.status} ${text}`);
@@ -1,5 +1,6 @@
1
1
  import { getAuthHeaders, getAppStoreCredentials, generateToken } from './auth.js';
2
2
  import { friendlyAppStoreError } from './errors.js';
3
+ import { fetchWithTimeout } from '../lib/http.js';
3
4
  /**
4
5
  * App Store Connect API v1 래퍼
5
6
  * https://developer.apple.com/documentation/appstoreconnectapi
@@ -22,7 +23,7 @@ export async function apiGet(path, params) {
22
23
  for (const [k, v] of Object.entries(params))
23
24
  url.searchParams.set(k, v);
24
25
  }
25
- const res = await fetch(url.toString(), { headers });
26
+ const res = await fetchWithTimeout(url.toString(), { headers });
26
27
  if (!res.ok) {
27
28
  const body = await res.text();
28
29
  throw friendlyAppStoreError(res.status, body);
@@ -57,7 +58,7 @@ export async function verifyAppStoreCredentials() {
57
58
  }
58
59
  let res;
59
60
  try {
60
- res = await fetch(`${BASE}/apps?limit=1`, { headers: { Authorization: `Bearer ${token}` } });
61
+ res = await fetchWithTimeout(`${BASE}/apps?limit=1`, { headers: { Authorization: `Bearer ${token}` } });
61
62
  }
62
63
  catch (e) {
63
64
  return { ok: false, stage: 'api', message: `App Store API 연결 실패: ${e.message}` };
@@ -93,7 +94,7 @@ async function apiPatch(path, body) {
93
94
  '터미널에서 실행:',
94
95
  ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
95
96
  ].join('\n'));
96
- const res = await fetch(`${BASE}${path}`, {
97
+ const res = await fetchWithTimeout(`${BASE}${path}`, {
97
98
  method: 'PATCH',
98
99
  headers: { ...headers, 'Content-Type': 'application/json' },
99
100
  body: JSON.stringify(body),
@@ -115,7 +116,7 @@ async function apiPost(path, body) {
115
116
  '터미널에서 실행:',
116
117
  ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
117
118
  ].join('\n'));
118
- const res = await fetch(`${BASE}${path}`, {
119
+ const res = await fetchWithTimeout(`${BASE}${path}`, {
119
120
  method: 'POST',
120
121
  headers: { ...headers, 'Content-Type': 'application/json' },
121
122
  body: JSON.stringify(body),
@@ -680,7 +681,7 @@ export async function submitVersionForReview(versionId) {
680
681
  catch (error) {
681
682
  if (!isItemAddRejected(error))
682
683
  throw error;
683
- // 2026-07-24 실측 (PenguinRun 2.0.4 재제출): 버전은 PREPARE_FOR_SUBMISSION 인데도
684
+ // 2026-07-24 실측 (실앱 2.0.4 재제출): 버전은 PREPARE_FOR_SUBMISSION 인데도
684
685
  // attach 가 "appStoreVersions ... is not in valid state" 로 거부되는 케이스의 진범은
685
686
  // 거절된 옛 묶음(UNRESOLVED_ISSUES)이 이 버전을 REJECTED 항목으로 물고 있는 것.
686
687
  // 항목을 removed=true 로 풀면 옛 묶음이 COMPLETE 로 정리되고 attach 가 뚫린다.
@@ -727,7 +728,7 @@ export async function submitVersionForReview(versionId) {
727
728
  }
728
729
  // ─── IAP/구독 상품 심사 제출 ───
729
730
  //
730
- // ⚠️ 2026-07-24 실측 (PenguinRun 첫 출시): reviewSubmissionItems 는 appStoreVersion 계열
731
+ // ⚠️ 2026-07-24 실측 (실앱 첫 출시): reviewSubmissionItems 는 appStoreVersion 계열
731
732
  // 관계만 받는다. inAppPurchaseV2 / inAppPurchase / subscription 관계는 전부
732
733
  // ENTITY_ERROR.RELATIONSHIP.UNKNOWN 으로 거부된다 — 즉 App Store Connect 웹의
733
734
  // "버전과 함께 제출할 상품 담기" 는 공개 API 에 존재하지 않는다.
@@ -774,7 +775,7 @@ export async function addProductToReviewSubmission(args) {
774
775
  '**앱 첫 심사** 케이스다. 한 번도 승인된 적 없는 상품은 공개 API 로 심사에 못 넣는다.',
775
776
  '앱 버전을 심사 대기로 만들어도 이 벽은 그대로다 (2026-07-25 재확인).',
776
777
  '',
777
- '실제로 통하는 순서 (PenguinRun 2.0.6 실측):',
778
+ '실제로 통하는 순서 (실앱 2.0.6 실측):',
778
779
  ' 1. appstore_submit_for_review 로 앱 버전을 먼저 제출한다.',
779
780
  ' → 상품은 자동으로 안 딸려간다. 버전 1개짜리 묶음 A 가 생긴다. 이건 정상이다.',
780
781
  ' 2. 그래야 ASC 웹에 상품의 "심사 추가" UI 가 나타난다. 웹에서 상품을 담으면',
@@ -977,7 +978,7 @@ async function apiPostV2(path, body) {
977
978
  if (!headers) {
978
979
  throw new Error('App Store Connect 인증 필요 — npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth');
979
980
  }
980
- const res = await fetch(`${IAP_V2_BASE}${path}`, {
981
+ const res = await fetchWithTimeout(`${IAP_V2_BASE}${path}`, {
981
982
  method: 'POST',
982
983
  headers: { ...headers, 'Content-Type': 'application/json' },
983
984
  body: JSON.stringify(body),
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { getAuthenticatedClient } from './google-auth.js';
6
+ import { writeCredentialFile } from '../lib/atomic-write.js';
6
7
  // BigQuery 전용 서비스 계정 키 저장 위치.
7
8
  // 서비스 계정 인증은 Google Workspace 의 재인증(reauth) 정책에서 면제되므로,
8
9
  // 사용자 OAuth 가 `invalid_rapt` 로 갱신 거부되는 환경에서도 안정적으로 동작한다.
@@ -37,9 +38,7 @@ export function saveBigQueryServiceAccountJson(json) {
37
38
  if (parsed.type !== 'service_account' || !parsed.client_email || !parsed.private_key) {
38
39
  throw new Error('서비스 계정 키 형식이 아닙니다 (type="service_account", client_email, private_key 필요).');
39
40
  }
40
- if (!fs.existsSync(CONFIG_DIR))
41
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
42
- fs.writeFileSync(BQ_SA_PATH, json, { mode: 0o600 });
41
+ writeCredentialFile(BQ_SA_PATH, json);
43
42
  return parsed;
44
43
  }
45
44
  /** 저장된 BigQuery 서비스 계정 키. 없거나 형식 오류면 null. */
@@ -1,3 +1,4 @@
1
+ import { fetchWithTimeout } from '../lib/http.js';
1
2
  const WEB_BASE = process.env.MIMI_SEED_WEB_BASE ?? 'https://mimi-seed.pryzm.gg';
2
3
  let _cached = null;
3
4
  /**
@@ -20,9 +21,7 @@ export async function getMcpOAuthClient() {
20
21
  return _cached;
21
22
  let res;
22
23
  try {
23
- res = await fetch(`${WEB_BASE}/api/mcp-auth-config`, {
24
- signal: AbortSignal.timeout(10_000),
25
- });
24
+ res = await fetchWithTimeout(`${WEB_BASE}/api/mcp-auth-config`, {}, 10_000);
26
25
  }
27
26
  catch (e) {
28
27
  const detail = e instanceof Error ? e.message : String(e);
@@ -8,6 +8,7 @@ import { AuthError, classifyError } from './errors.js';
8
8
  import { openPrivateBrowser } from './browser.js';
9
9
  // 스코프 목록의 SSOT 는 scopes.ts (도메인 → 스코프 매핑). 여기서는 로그인 요청 조립만 한다.
10
10
  import { scopesForDomains, mergeScopeStrings } from './scopes.js';
11
+ import { writeCredentialJson } from '../lib/atomic-write.js';
11
12
  // Primary config dir. Legacy `~/.preseed` is read as a fallback during the
12
13
  // rebrand so existing auth sessions don't force a re-login; new writes go to
13
14
  // the new dir.
@@ -19,11 +20,6 @@ const CREDENTIALS_PATH = path.join(TOKEN_DIR, 'credentials.json');
19
20
  // Default OAuth client for development — users should replace with their own
20
21
  const DEFAULT_CLIENT_ID = ''; // Will be set during auth setup
21
22
  const DEFAULT_CLIENT_SECRET = '';
22
- function ensureDir() {
23
- if (!fs.existsSync(TOKEN_DIR)) {
24
- fs.mkdirSync(TOKEN_DIR, { recursive: true });
25
- }
26
- }
27
23
  export function getStoredCredentials() {
28
24
  if (!fs.existsSync(CREDENTIALS_PATH))
29
25
  return null;
@@ -36,8 +32,7 @@ export function getStoredCredentials() {
36
32
  }
37
33
  }
38
34
  export function saveCredentials(clientId, clientSecret) {
39
- ensureDir();
40
- fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify({ clientId, clientSecret }, null, 2), { mode: 0o600 });
35
+ writeCredentialJson(CREDENTIALS_PATH, { clientId, clientSecret });
41
36
  }
42
37
  export function getStoredTokens() {
43
38
  // Prefer new dir; fall back to legacy ~/.preseed during the rebrand window.
@@ -76,9 +71,10 @@ export function getTokensLastRefreshMs() {
76
71
  return null;
77
72
  }
78
73
  }
74
+ // 원자적 교체가 특히 중요한 지점 — 이 함수는 access_token 만료 5분 전마다 다시 불리고,
75
+ // MCP 서버 인스턴스 여러 개와 CLI 가 같은 tokens.json 을 동시에 노린다.
79
76
  function saveTokens(tokens) {
80
- ensureDir();
81
- fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2), { mode: 0o600 });
77
+ writeCredentialJson(TOKEN_PATH, tokens);
82
78
  }
83
79
  export function createOAuth2Client(clientId, clientSecret) {
84
80
  return new google.auth.OAuth2(clientId, clientSecret, 'http://localhost:9876/callback');
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { PLAY_DEVELOPER_REPORTING_SCOPE } from './scopes.js';
6
+ import { writeCredentialFile } from '../lib/atomic-write.js';
6
7
  const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
7
8
  const SA_DIR = path.join(CONFIG_DIR, 'play-service-accounts');
8
9
  const LEGACY_SA_PATH = path.join(CONFIG_DIR, 'play-service-account.json');
@@ -38,19 +39,14 @@ export function getServiceAccountJson(packageName) {
38
39
  * 레거시 호환 — 단일 SA 저장 (default).
39
40
  */
40
41
  export function saveServiceAccountJson(json) {
41
- if (!fs.existsSync(CONFIG_DIR))
42
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
43
- fs.writeFileSync(LEGACY_SA_PATH, json, { mode: 0o600 });
42
+ writeCredentialFile(LEGACY_SA_PATH, json);
44
43
  }
45
44
  /**
46
45
  * 패키지명에 묶여 SA JSON을 저장. 여러 앱이 다른 GCP 프로젝트일 때 사용.
47
46
  * ~/.mimi-seed/play-service-accounts/{packageName}.json
48
47
  */
49
48
  export function saveServiceAccountJsonForPackage(packageName, json) {
50
- if (!fs.existsSync(SA_DIR))
51
- fs.mkdirSync(SA_DIR, { recursive: true });
52
- const filePath = path.join(SA_DIR, `${packageName}.json`);
53
- fs.writeFileSync(filePath, json, { mode: 0o600 });
49
+ writeCredentialFile(path.join(SA_DIR, `${packageName}.json`), json);
54
50
  }
55
51
  /**
56
52
  * 등록된 패키지별 SA + default(레거시) 정보 요약.
package/dist/ci/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { writeCredentialJson } from '../lib/atomic-write.js';
4
5
  const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
5
6
  const CI_CONFIG_PATH = path.join(CONFIG_DIR, 'ci.json');
6
7
  export function loadCiConfig() {
@@ -12,10 +13,7 @@ export function loadCiConfig() {
12
13
  }
13
14
  }
14
15
  export function saveCiConfig(config) {
15
- if (!fs.existsSync(CONFIG_DIR)) {
16
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
17
- }
18
- fs.writeFileSync(CI_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
16
+ writeCredentialJson(CI_CONFIG_PATH, config);
19
17
  }
20
18
  export function requireCiConfig() {
21
19
  const cfg = loadCiConfig();
package/dist/ci/github.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { fetchWithTimeout } from '../lib/http.js';
1
2
  function base(cfg) {
2
3
  // GitHub Enterprise: host = https://github.example.com → API base = https://github.example.com/api/v3
3
4
  if (cfg.host)
@@ -13,7 +14,7 @@ function headers(token) {
13
14
  };
14
15
  }
15
16
  async function ghFetch(cfg, endpoint, options) {
16
- const res = await fetch(`${base(cfg)}${endpoint}`, {
17
+ const res = await fetchWithTimeout(`${base(cfg)}${endpoint}`, {
17
18
  ...options,
18
19
  headers: { ...headers(cfg.token), ...(options?.headers ?? {}) },
19
20
  });
package/dist/ci/gitlab.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { fetchWithTimeout } from '../lib/http.js';
1
2
  function base(cfg) {
2
3
  return `${cfg.host ?? 'https://gitlab.com'}/api/v4`;
3
4
  }
@@ -12,7 +13,7 @@ function headers(token) {
12
13
  };
13
14
  }
14
15
  async function glFetch(cfg, endpoint, options) {
15
- const res = await fetch(`${base(cfg)}${endpoint}`, {
16
+ const res = await fetchWithTimeout(`${base(cfg)}${endpoint}`, {
16
17
  ...options,
17
18
  headers: { ...headers(cfg.token), ...(options?.headers ?? {}) },
18
19
  });
@@ -1,8 +1,9 @@
1
1
  import { metaApiError } from '../lib/meta-auth.js';
2
+ import { fetchWithTimeout } from '../lib/http.js';
2
3
  const BASE = 'https://graph.facebook.com/v21.0';
3
4
  async function fbPost(pageAccessToken, endpoint, params) {
4
5
  const body = new URLSearchParams({ ...params, access_token: pageAccessToken });
5
- const res = await fetch(`${BASE}${endpoint}`, {
6
+ const res = await fetchWithTimeout(`${BASE}${endpoint}`, {
6
7
  method: 'POST',
7
8
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
8
9
  body: body.toString(),
@@ -23,7 +24,7 @@ async function fbPost(pageAccessToken, endpoint, params) {
23
24
  }
24
25
  async function fbGet(pageAccessToken, endpoint, params = {}) {
25
26
  const qs = new URLSearchParams({ ...params, access_token: pageAccessToken });
26
- const res = await fetch(`${BASE}${endpoint}?${qs}`);
27
+ const res = await fetchWithTimeout(`${BASE}${endpoint}?${qs}`);
27
28
  const text = await res.text();
28
29
  let json;
29
30
  try {
@@ -98,7 +99,7 @@ export async function listAccessiblePages(userAccessToken) {
98
99
  fields: 'id,name,category',
99
100
  access_token: userAccessToken,
100
101
  });
101
- const res = await fetch(`${BASE}/me/accounts?${qs}`);
102
+ const res = await fetchWithTimeout(`${BASE}/me/accounts?${qs}`);
102
103
  const json = await res.json();
103
104
  if (json.error) {
104
105
  throw metaApiError('facebook', res.status, `${json.error.message} (code ${json.error.code})`, json.error.code);
@@ -1,9 +1,10 @@
1
+ import { type SocialConfigOptions } from '../social/profile-store.js';
1
2
  export interface FacebookConfig {
2
3
  pageAccessToken: string;
3
4
  pageId: string;
4
5
  pageName?: string;
5
6
  expiresAt?: string;
6
7
  }
7
- export declare function loadFacebookConfig(): FacebookConfig | null;
8
- export declare function saveFacebookConfig(cfg: FacebookConfig): void;
9
- export declare function requireFacebookConfig(): FacebookConfig;
8
+ export declare function loadFacebookConfig(options?: SocialConfigOptions): FacebookConfig | null;
9
+ export declare function saveFacebookConfig(cfg: FacebookConfig, options?: SocialConfigOptions): void;
10
+ export declare function requireFacebookConfig(options?: SocialConfigOptions): FacebookConfig;
@@ -1,35 +1,22 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'facebook.json');
5
- export function loadFacebookConfig() {
6
- try {
7
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
8
- if (typeof cfg.pageAccessToken !== 'string' || !cfg.pageAccessToken ||
9
- typeof cfg.pageId !== 'string' || !cfg.pageId)
10
- return null;
11
- return cfg;
12
- }
13
- catch {
1
+ import { loadSocialPlatformConfig, resolveSocialConfigTarget, saveSocialPlatformConfig, } from '../social/profile-store.js';
2
+ export function loadFacebookConfig(options = {}) {
3
+ const cfg = loadSocialPlatformConfig('facebook', options);
4
+ if (typeof cfg?.pageAccessToken !== 'string' || !cfg.pageAccessToken ||
5
+ typeof cfg.pageId !== 'string' || !cfg.pageId)
14
6
  return null;
15
- }
7
+ return cfg;
16
8
  }
17
- export function saveFacebookConfig(cfg) {
18
- const dir = path.dirname(CONFIG_PATH);
19
- if (!fs.existsSync(dir)) {
20
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
21
- }
22
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
23
- if (process.platform !== 'win32') {
24
- fs.chmodSync(CONFIG_PATH, 0o600);
25
- }
9
+ export function saveFacebookConfig(cfg, options = {}) {
10
+ saveSocialPlatformConfig('facebook', cfg, options);
26
11
  }
27
- export function requireFacebookConfig() {
28
- const cfg = loadFacebookConfig();
12
+ export function requireFacebookConfig(options = {}) {
13
+ const cfg = loadFacebookConfig(options);
29
14
  if (!cfg) {
15
+ const target = resolveSocialConfigTarget('facebook', options);
16
+ const profileHint = target.profile ? `, profile="${target.profile}"` : '';
30
17
  throw new Error('Facebook 설정이 없습니다.\n' +
31
18
  'facebook_save_config 도구로 먼저 설정해주세요.\n' +
32
- '예: facebook_save_config(pageAccessToken="EAA...", pageId="...")');
19
+ `예: facebook_save_config(pageAccessToken="EAA...", pageId="..."${profileHint})`);
33
20
  }
34
21
  return cfg;
35
22
  }
@@ -1,6 +1,7 @@
1
+ import type { SocialConfigOptions } from '../social/profile-store.js';
1
2
  export interface ConnectResult {
2
3
  ok: boolean;
3
4
  /** 사용자에게 그대로 보여줄 메시지 (MCP 는 content 로 감싸고, CLI 는 그대로 출력). */
4
5
  text: string;
5
6
  }
6
- export declare function connectFacebook(pageAccessToken: string, pageId?: string): Promise<ConnectResult>;
7
+ export declare function connectFacebook(pageAccessToken: string, pageId?: string, options?: SocialConfigOptions): Promise<ConnectResult>;
@@ -4,15 +4,16 @@
4
4
  // 둘 중 하나에만 검증이 있으면 "CLI 로 저장했는데 도구가 못 읽는" 류의 드리프트가 생긴다.
5
5
  import { saveFacebookConfig } from './config.js';
6
6
  import * as api from './api.js';
7
+ import { fetchWithTimeout } from '../lib/http.js';
7
8
  const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
8
- export async function connectFacebook(pageAccessToken, pageId) {
9
+ export async function connectFacebook(pageAccessToken, pageId, options = {}) {
9
10
  let resolvedPageId = pageId;
10
11
  if (!resolvedPageId) {
11
12
  try {
12
13
  const pages = await api.listAccessiblePages(pageAccessToken);
13
14
  if (pages.length === 0) {
14
15
  // 이미 Page Access Token 인 경우 — /me 가 곧 페이지다.
15
- const res = await fetch(`https://graph.facebook.com/v21.0/me?fields=id,name&access_token=${pageAccessToken}`);
16
+ const res = await fetchWithTimeout(`https://graph.facebook.com/v21.0/me?fields=id,name&access_token=${pageAccessToken}`);
16
17
  const data = (await res.json());
17
18
  if (data.error)
18
19
  throw new Error(`${data.error.message} (code ${data.error.code})`);
@@ -40,7 +41,7 @@ export async function connectFacebook(pageAccessToken, pageId) {
40
41
  pageId: resolvedPageId,
41
42
  pageName: page.name,
42
43
  expiresAt: new Date(Date.now() + SIXTY_DAYS_MS).toISOString(),
43
- });
44
+ }, options);
44
45
  return {
45
46
  ok: true,
46
47
  text: [
@@ -1,24 +1,20 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { writeCredentialJson } from '../lib/atomic-write.js';
4
5
  const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
5
6
  const CONFIG_PATH = path.join(CONFIG_DIR, 'google-ads.json');
6
- function ensureDir() {
7
- if (!fs.existsSync(CONFIG_DIR))
8
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
9
- }
10
7
  /** 하이픈 제거 (API는 숫자만 허용) */
11
8
  export function normalizeCustomerId(id) {
12
9
  return id.replace(/-/g, '');
13
10
  }
14
11
  export function saveConfig(cfg) {
15
- ensureDir();
16
12
  const normalized = {
17
13
  ...cfg,
18
14
  customerId: normalizeCustomerId(cfg.customerId),
19
15
  loginCustomerId: cfg.loginCustomerId ? normalizeCustomerId(cfg.loginCustomerId) : undefined,
20
16
  };
21
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(normalized, null, 2), { mode: 0o600 });
17
+ writeCredentialJson(CONFIG_PATH, normalized);
22
18
  }
23
19
  export function loadConfig() {
24
20
  if (!fs.existsSync(CONFIG_PATH))