@yoonion/mimi-seed-mcp 0.13.0 → 0.13.2
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/assets/agent-guide.md +7 -1
- package/dist/auth/playstore-auth.js +7 -1
- package/dist/auth/scopes.d.ts +7 -2
- package/dist/auth/scopes.js +13 -2
- package/dist/helpers.d.ts +1 -1
- package/dist/helpers.js +40 -29
- package/dist/instagram/config.d.ts +4 -3
- package/dist/instagram/config.js +13 -26
- package/dist/instagram/setup.d.ts +2 -1
- package/dist/instagram/setup.js +6 -2
- package/dist/lib/project-manifest.d.ts +8 -0
- package/dist/lib/project-manifest.js +22 -0
- package/dist/registers/auth.js +5 -2
- package/dist/registers/instagram.js +14 -8
- package/dist/registers/playstore.js +4 -1
- package/dist/registers/threads.js +27 -14
- package/dist/social/profile-store.d.ts +17 -0
- package/dist/social/profile-store.js +72 -0
- package/dist/social/setup-cli.js +43 -17
- package/dist/threads/config.d.ts +4 -3
- package/dist/threads/config.js +13 -26
- package/dist/threads/setup.d.ts +3 -2
- package/dist/threads/setup.js +10 -4
- package/package.json +1 -1
package/assets/agent-guide.md
CHANGED
|
@@ -93,6 +93,11 @@ every credential, which also tells them where to obtain each token
|
|
|
93
93
|
`mimi-seed auth meta` opens the combined social setup entry point when the user wants to review or reconnect
|
|
94
94
|
all three Meta platforms in one pass.
|
|
95
95
|
|
|
96
|
+
Instagram and Threads can use named local profiles. Save one with
|
|
97
|
+
`mimi-seed auth instagram --profile <id>` or `mimi-seed auth threads --profile <id>`, then select it per project
|
|
98
|
+
with `.mimi-seed.json` → `socialProfiles.instagram` / `socialProfiles.threads`. MCP tools also accept an explicit
|
|
99
|
+
`profile`; explicit input wins over the project mapping.
|
|
100
|
+
|
|
96
101
|
Each `mimi-seed auth <cred>` wraps the matching `npx -y @yoonion/mimi-seed-mcp mimi-seed-*-auth` binary, so
|
|
97
102
|
either form works.
|
|
98
103
|
|
|
@@ -118,7 +123,8 @@ Credentials live under `~/.mimi-seed/` (legacy `~/.preseed/` is still read):
|
|
|
118
123
|
| `bigquery-service-account.json` | BigQuery SA (exempt from Workspace reauth; OAuth is the fallback) |
|
|
119
124
|
| `jenkins.json`, `ci.json` | Jenkins / GitHub-GitLab CI connection |
|
|
120
125
|
| `google-ads.json` | Google Ads developer token + customer id |
|
|
121
|
-
| `facebook.json`, `instagram.json`, `threads.json` | Page / account
|
|
126
|
+
| `facebook.json`, `instagram.json`, `threads.json` | Default/legacy Page / account tokens for social post tools |
|
|
127
|
+
| `social-profiles/<profile>.json` | Named Instagram/Threads tokens selected by the current project's `.mimi-seed.json` |
|
|
122
128
|
|
|
123
129
|
Notes that matter in practice:
|
|
124
130
|
|
|
@@ -2,6 +2,7 @@ import { JWT } from 'google-auth-library';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
+
import { PLAY_DEVELOPER_REPORTING_SCOPE } from './scopes.js';
|
|
5
6
|
const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
|
|
6
7
|
const SA_DIR = path.join(CONFIG_DIR, 'play-service-accounts');
|
|
7
8
|
const LEGACY_SA_PATH = path.join(CONFIG_DIR, 'play-service-account.json');
|
|
@@ -112,7 +113,12 @@ export function getServiceAccountClient(packageName) {
|
|
|
112
113
|
return new JWT({
|
|
113
114
|
email: parsed.client_email,
|
|
114
115
|
key: parsed.private_key,
|
|
115
|
-
|
|
116
|
+
// androidpublisher(edits/리스팅 등) + Developer Reporting(vitals 통계). 통계 도구가
|
|
117
|
+
// Reporting API 를 쓰므로 이 스코프가 없으면 SA 경로에서도 통계만 403 으로 죽는다.
|
|
118
|
+
scopes: [
|
|
119
|
+
'https://www.googleapis.com/auth/androidpublisher',
|
|
120
|
+
PLAY_DEVELOPER_REPORTING_SCOPE,
|
|
121
|
+
],
|
|
116
122
|
});
|
|
117
123
|
}
|
|
118
124
|
catch {
|
package/dist/auth/scopes.d.ts
CHANGED
|
@@ -41,8 +41,8 @@ export declare const AUTH_DOMAINS: {
|
|
|
41
41
|
};
|
|
42
42
|
readonly playstore: {
|
|
43
43
|
readonly label: "Play Store";
|
|
44
|
-
readonly scopes: readonly ["https://www.googleapis.com/auth/androidpublisher"];
|
|
45
|
-
readonly summary: "playstore_* —
|
|
44
|
+
readonly scopes: readonly ["https://www.googleapis.com/auth/androidpublisher", "https://www.googleapis.com/auth/playdeveloperreporting"];
|
|
45
|
+
readonly summary: "playstore_* — OAuth 로 하는 Play Console 작업 + Android vitals 통계";
|
|
46
46
|
};
|
|
47
47
|
readonly googleads: {
|
|
48
48
|
readonly label: "Google Ads";
|
|
@@ -69,6 +69,11 @@ export type AuthDomainId = keyof typeof AUTH_DOMAINS;
|
|
|
69
69
|
/** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
|
|
70
70
|
export declare const DOMAIN_IDS: [AuthDomainId, ...AuthDomainId[]];
|
|
71
71
|
export declare const CLOUD_PLATFORM_SCOPE: "https://www.googleapis.com/auth/cloud-platform";
|
|
72
|
+
/**
|
|
73
|
+
* Play Developer Reporting API(Android vitals 통계) 전용 스코프. androidpublisher 와
|
|
74
|
+
* 별개다 — SA JWT 와 OAuth playstore 도메인 양쪽에 함께 실어야 통계 도구가 동작한다.
|
|
75
|
+
*/
|
|
76
|
+
export declare const PLAY_DEVELOPER_REPORTING_SCOPE = "https://www.googleapis.com/auth/playdeveloperreporting";
|
|
72
77
|
/**
|
|
73
78
|
* 공백 구분 scope 문자열들의 합집합. tokens.json 의 scope 는 누적(monotonic)이어야 하므로
|
|
74
79
|
* 로그인/갱신 시 기존 기록 + 새 응답을 합쳐 저장하는 데 쓴다. undefined/빈 문자열은 무시.
|
package/dist/auth/scopes.js
CHANGED
|
@@ -37,8 +37,14 @@ export const AUTH_DOMAINS = {
|
|
|
37
37
|
},
|
|
38
38
|
playstore: {
|
|
39
39
|
label: 'Play Store',
|
|
40
|
-
scopes: [
|
|
41
|
-
|
|
40
|
+
scopes: [
|
|
41
|
+
'https://www.googleapis.com/auth/androidpublisher',
|
|
42
|
+
// Android vitals 통계(playstore_get_statistics)는 Play Developer Reporting API 를
|
|
43
|
+
// 쓰는데 androidpublisher 와 별개 스코프가 필요하다. 빠져 있으면 통계만 런타임
|
|
44
|
+
// 403(ACCESS_TOKEN_SCOPE_INSUFFICIENT)으로 죽고, 다른 Play 도구는 멀쩡해 원인 추적이 어렵다.
|
|
45
|
+
'https://www.googleapis.com/auth/playdeveloperreporting',
|
|
46
|
+
],
|
|
47
|
+
summary: 'playstore_* — OAuth 로 하는 Play Console 작업 + Android vitals 통계',
|
|
42
48
|
},
|
|
43
49
|
googleads: {
|
|
44
50
|
label: 'Google Ads',
|
|
@@ -70,6 +76,11 @@ export const AUTH_DOMAINS = {
|
|
|
70
76
|
/** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
|
|
71
77
|
export const DOMAIN_IDS = Object.keys(AUTH_DOMAINS);
|
|
72
78
|
export const CLOUD_PLATFORM_SCOPE = AUTH_DOMAINS.gcp.scopes[0];
|
|
79
|
+
/**
|
|
80
|
+
* Play Developer Reporting API(Android vitals 통계) 전용 스코프. androidpublisher 와
|
|
81
|
+
* 별개다 — SA JWT 와 OAuth playstore 도메인 양쪽에 함께 실어야 통계 도구가 동작한다.
|
|
82
|
+
*/
|
|
83
|
+
export const PLAY_DEVELOPER_REPORTING_SCOPE = 'https://www.googleapis.com/auth/playdeveloperreporting';
|
|
73
84
|
function dedupe(scopes) {
|
|
74
85
|
return [...new Set(scopes)];
|
|
75
86
|
}
|
package/dist/helpers.d.ts
CHANGED
|
@@ -18,6 +18,6 @@ export declare const APPSTORE_AUTH_HINT: string;
|
|
|
18
18
|
* 별도 서비스 계정 JSON 을 받지 않아도 대부분의 Play 작업이 가능하다.
|
|
19
19
|
* (서비스 계정은 서버/헤드리스 — onesub 영수증 검증 등 — 용도로 계속 우선 적용.)
|
|
20
20
|
*/
|
|
21
|
-
export declare function requirePlayStoreAuth(packageName?: string): import("google-auth-library").OAuth2Client;
|
|
21
|
+
export declare function requirePlayStoreAuth(packageName?: string, requiredScope?: string): import("google-auth-library").OAuth2Client;
|
|
22
22
|
export declare function requireServiceAccountJson(packageName?: string): string;
|
|
23
23
|
export declare function requireAppStoreCreds(): import("./appstore/auth.js").AppStoreCredentials;
|
package/dist/helpers.js
CHANGED
|
@@ -15,6 +15,39 @@ function formatAuthError(p) {
|
|
|
15
15
|
.filter((l) => l !== '')
|
|
16
16
|
.join('\n');
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* 도구가 요구하는 스코프의 pre-flight 검사. expiry 만 보는 ensureFreshAccessToken 은
|
|
20
|
+
* 스코프 미보유를 못 걸러내므로(런타임 ACCESS_TOKEN_SCOPE_INSUFFICIENT), 저장된 scope 로
|
|
21
|
+
* 결정적인 안내를 던진다. 도메인 선택형 로그인 도입 후에는 "전체 재로그인"이 아니라
|
|
22
|
+
* "--domains <해당 도메인> 으로 추가 부여(기존 권한 유지)"가 올바른 해법이다.
|
|
23
|
+
*
|
|
24
|
+
* scope 가 undefined 인 구 토큰(스코프 추적 도입 전 full-scope 로그인)은 추적 도입
|
|
25
|
+
* 이전부터 있던 스코프는 보유한 게 확실하므로 통과시킨다 — 안 그러면 pre-flight 를 새로
|
|
26
|
+
* 다는 순간 멀쩡한 기존 사용자에게 재로그인을 강제한다. 추적 이후 추가된 스코프(GA4,
|
|
27
|
+
* Play Developer Reporting)만 미보유 확정으로 본다.
|
|
28
|
+
*
|
|
29
|
+
* OAuth 토큰에만 적용된다 — SA JWT 는 자체 scopes 로 토큰을 받으므로 이 검사 대상이 아니다.
|
|
30
|
+
*/
|
|
31
|
+
function assertStoredScope(requiredScope) {
|
|
32
|
+
if (!requiredScope)
|
|
33
|
+
return;
|
|
34
|
+
const scopeStr = getStoredTokens()?.scope;
|
|
35
|
+
const missing = scopeStr === undefined
|
|
36
|
+
? !isPreTrackingScope(requiredScope)
|
|
37
|
+
: !scopeStr.split(' ').filter(Boolean).includes(requiredScope);
|
|
38
|
+
if (!missing)
|
|
39
|
+
return;
|
|
40
|
+
const domainArg = domainsForScope(requiredScope).join(',');
|
|
41
|
+
throw new Error(formatAuthError({
|
|
42
|
+
code: 'INSUFFICIENT_SCOPE',
|
|
43
|
+
message: `이 도구는 추가 권한이 필요해 (${requiredScope}). 현재 로그인에 그 권한이 없어.`,
|
|
44
|
+
hint: domainArg
|
|
45
|
+
? `mimi-seed-auth --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
|
|
46
|
+
: 'mimi-seed-auth 로 재로그인하면 새 권한이 부여돼.',
|
|
47
|
+
retriable: false,
|
|
48
|
+
needsReauth: true,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
18
51
|
/**
|
|
19
52
|
* OAuth 클라이언트 확보 — 호출 전에 access_token 을 사전 갱신하고,
|
|
20
53
|
* refresh_token 이 만료/누락이면 raw googleapis 에러(invalid_grant 등) 대신
|
|
@@ -38,33 +71,7 @@ export async function requireAuth(requiredScope) {
|
|
|
38
71
|
needsReauth: true,
|
|
39
72
|
}));
|
|
40
73
|
}
|
|
41
|
-
|
|
42
|
-
// 스코프 미보유를 못 걸러내므로(런타임 ACCESS_TOKEN_SCOPE_INSUFFICIENT), 저장된 scope 로
|
|
43
|
-
// 결정적인 안내를 던진다. 도메인 선택형 로그인 도입 후에는 "전체 재로그인"이 아니라
|
|
44
|
-
// "--domains <해당 도메인> 으로 추가 부여(기존 권한 유지)"가 올바른 해법이다.
|
|
45
|
-
//
|
|
46
|
-
// scope 가 undefined 인 구 토큰(스코프 추적 도입 전 full-scope 로그인)은 추적 도입
|
|
47
|
-
// 이전부터 있던 스코프는 보유한 게 확실하므로 통과시킨다 — 안 그러면 pre-flight 를 새로
|
|
48
|
-
// 다는 순간 멀쩡한 기존 사용자에게 재로그인을 강제한다. 추적 이후 추가된 스코프(GA4)만
|
|
49
|
-
// 미보유 확정으로 본다.
|
|
50
|
-
if (requiredScope) {
|
|
51
|
-
const scopeStr = getStoredTokens()?.scope;
|
|
52
|
-
const missing = scopeStr === undefined
|
|
53
|
-
? !isPreTrackingScope(requiredScope)
|
|
54
|
-
: !scopeStr.split(' ').filter(Boolean).includes(requiredScope);
|
|
55
|
-
if (missing) {
|
|
56
|
-
const domainArg = domainsForScope(requiredScope).join(',');
|
|
57
|
-
throw new Error(formatAuthError({
|
|
58
|
-
code: 'INSUFFICIENT_SCOPE',
|
|
59
|
-
message: `이 도구는 추가 권한이 필요해 (${requiredScope}). 현재 로그인에 그 권한이 없어.`,
|
|
60
|
-
hint: domainArg
|
|
61
|
-
? `mimi-seed-auth --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
|
|
62
|
-
: 'mimi-seed-auth 로 재로그인하면 새 권한이 부여돼.',
|
|
63
|
-
retriable: false,
|
|
64
|
-
needsReauth: true,
|
|
65
|
-
}));
|
|
66
|
-
}
|
|
67
|
-
}
|
|
74
|
+
assertStoredScope(requiredScope);
|
|
68
75
|
return client;
|
|
69
76
|
}
|
|
70
77
|
export const PLAY_AUTH_HINT = [
|
|
@@ -95,13 +102,17 @@ export const APPSTORE_AUTH_HINT = [
|
|
|
95
102
|
* 별도 서비스 계정 JSON 을 받지 않아도 대부분의 Play 작업이 가능하다.
|
|
96
103
|
* (서비스 계정은 서버/헤드리스 — onesub 영수증 검증 등 — 용도로 계속 우선 적용.)
|
|
97
104
|
*/
|
|
98
|
-
export function requirePlayStoreAuth(packageName) {
|
|
105
|
+
export function requirePlayStoreAuth(packageName, requiredScope) {
|
|
99
106
|
const sa = getServiceAccountClient(packageName);
|
|
100
107
|
if (sa)
|
|
101
108
|
return sa;
|
|
102
109
|
const oauth = getAuthenticatedClient();
|
|
103
|
-
if (oauth)
|
|
110
|
+
if (oauth) {
|
|
111
|
+
// OAuth 폴백일 때만 스코프 pre-flight — SA JWT 는 자체 scopes 로 토큰을 받으므로
|
|
112
|
+
// (getServiceAccountClient 가 reporting 스코프를 이미 싣는다) 해당 없음.
|
|
113
|
+
assertStoredScope(requiredScope);
|
|
104
114
|
return oauth;
|
|
115
|
+
}
|
|
105
116
|
throw new Error(PLAY_AUTH_HINT);
|
|
106
117
|
}
|
|
107
118
|
export function requireServiceAccountJson(packageName) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { type SocialConfigOptions } from '../social/profile-store.js';
|
|
1
2
|
export interface InstagramConfig {
|
|
2
3
|
accessToken: string;
|
|
3
4
|
userId: string;
|
|
4
5
|
expiresAt?: string;
|
|
5
6
|
username?: string;
|
|
6
7
|
}
|
|
7
|
-
export declare function loadInstagramConfig(): InstagramConfig | null;
|
|
8
|
-
export declare function saveInstagramConfig(cfg: InstagramConfig): void;
|
|
9
|
-
export declare function requireInstagramConfig(): InstagramConfig;
|
|
8
|
+
export declare function loadInstagramConfig(options?: SocialConfigOptions): InstagramConfig | null;
|
|
9
|
+
export declare function saveInstagramConfig(cfg: InstagramConfig, options?: SocialConfigOptions): void;
|
|
10
|
+
export declare function requireInstagramConfig(options?: SocialConfigOptions): InstagramConfig;
|
package/dist/instagram/config.js
CHANGED
|
@@ -1,35 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
try {
|
|
7
|
-
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
8
|
-
if (typeof cfg.accessToken !== 'string' || !cfg.accessToken ||
|
|
9
|
-
typeof cfg.userId !== 'string' || !cfg.userId)
|
|
10
|
-
return null;
|
|
11
|
-
return cfg;
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
1
|
+
import { loadSocialPlatformConfig, resolveSocialConfigTarget, saveSocialPlatformConfig, } from '../social/profile-store.js';
|
|
2
|
+
export function loadInstagramConfig(options = {}) {
|
|
3
|
+
const cfg = loadSocialPlatformConfig('instagram', options);
|
|
4
|
+
if (typeof cfg?.accessToken !== 'string' || !cfg.accessToken ||
|
|
5
|
+
typeof cfg.userId !== 'string' || !cfg.userId)
|
|
14
6
|
return null;
|
|
15
|
-
|
|
7
|
+
return cfg;
|
|
16
8
|
}
|
|
17
|
-
export function saveInstagramConfig(cfg) {
|
|
18
|
-
|
|
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 saveInstagramConfig(cfg, options = {}) {
|
|
10
|
+
saveSocialPlatformConfig('instagram', cfg, options);
|
|
26
11
|
}
|
|
27
|
-
export function requireInstagramConfig() {
|
|
28
|
-
const cfg = loadInstagramConfig();
|
|
12
|
+
export function requireInstagramConfig(options = {}) {
|
|
13
|
+
const cfg = loadInstagramConfig(options);
|
|
29
14
|
if (!cfg) {
|
|
15
|
+
const target = resolveSocialConfigTarget('instagram', options);
|
|
16
|
+
const profileHint = target.profile ? `, profile="${target.profile}"` : '';
|
|
30
17
|
throw new Error('Instagram 설정이 없습니다.\n' +
|
|
31
18
|
'instagram_save_config 도구로 먼저 설정해주세요.\n' +
|
|
32
|
-
|
|
19
|
+
`예: instagram_save_config(accessToken="...", userId="..."${profileHint})`);
|
|
33
20
|
}
|
|
34
21
|
return cfg;
|
|
35
22
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConnectResult } from '../facebook/setup.js';
|
|
2
|
+
import type { SocialConfigOptions } from '../social/profile-store.js';
|
|
2
3
|
/** IGAA… = Instagram Login(신규) · EAA… = Facebook Login(FB Page + IG Business 연결 필요) */
|
|
3
4
|
export declare function detectApiType(accessToken: string): string;
|
|
4
|
-
export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean): Promise<ConnectResult>;
|
|
5
|
+
export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions): Promise<ConnectResult>;
|
package/dist/instagram/setup.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// MCP 도구(instagram_save_config)와 setup CLI(mimi-seed-social-auth)가 **공유**하는 구현.
|
|
3
3
|
import { saveInstagramConfig } from './config.js';
|
|
4
4
|
import * as api from './api.js';
|
|
5
|
+
import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
|
|
5
6
|
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
|
6
7
|
/** IGAA… = Instagram Login(신규) · EAA… = Facebook Login(FB Page + IG Business 연결 필요) */
|
|
7
8
|
export function detectApiType(accessToken) {
|
|
8
9
|
return accessToken.startsWith('IGAA') ? 'Instagram Login' : 'Facebook Login';
|
|
9
10
|
}
|
|
10
|
-
export async function connectInstagram(accessToken, userId, assumeIssuedNow = true) {
|
|
11
|
+
export async function connectInstagram(accessToken, userId, assumeIssuedNow = true, options = {}) {
|
|
12
|
+
// 프로필 ID를 네트워크 호출 전에 검증하고, 성공 응답에 실제 저장 대상을 남긴다.
|
|
13
|
+
const target = resolveSocialConfigTarget('instagram', options);
|
|
11
14
|
const apiType = detectApiType(accessToken);
|
|
12
15
|
let resolvedUserId = userId;
|
|
13
16
|
if (!resolvedUserId) {
|
|
@@ -36,11 +39,12 @@ export async function connectInstagram(accessToken, userId, assumeIssuedNow = tr
|
|
|
36
39
|
userId: resolvedUserId,
|
|
37
40
|
expiresAt,
|
|
38
41
|
username: account.username,
|
|
39
|
-
});
|
|
42
|
+
}, options);
|
|
40
43
|
return {
|
|
41
44
|
ok: true,
|
|
42
45
|
text: [
|
|
43
46
|
`✅ Instagram 연결 확인 완료 (${apiType})`,
|
|
47
|
+
` 저장 대상: ${socialTargetLabel(target)}`,
|
|
44
48
|
` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
|
|
45
49
|
` ID: ${account.id}`,
|
|
46
50
|
account.account_type ? ` 타입: ${account.account_type}` : '',
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const MANIFEST_FILENAME = ".mimi-seed.json";
|
|
2
|
+
export declare const SOCIAL_PROFILE_ID_PATTERN: RegExp;
|
|
3
|
+
export type SocialPlatform = 'instagram' | 'threads';
|
|
2
4
|
/** 매니페스트가 인지하는 서비스 id. status 매칭 로직이 이 키를 기준으로 동작한다. */
|
|
3
5
|
export type ManifestServiceId = 'oauth' | 'bigquery' | 'playstore' | 'appstore' | 'jenkins';
|
|
4
6
|
export interface ManifestService {
|
|
@@ -20,6 +22,8 @@ export interface ProjectManifest {
|
|
|
20
22
|
displayName?: string;
|
|
21
23
|
description?: string;
|
|
22
24
|
services?: Partial<Record<ManifestServiceId, ManifestService>>;
|
|
25
|
+
/** 플랫폼별로 ~/.mimi-seed/social-profiles/<id>.json 을 선택한다. */
|
|
26
|
+
socialProfiles?: Partial<Record<SocialPlatform, string>>;
|
|
23
27
|
}
|
|
24
28
|
export interface LoadedManifest {
|
|
25
29
|
manifest: ProjectManifest;
|
|
@@ -34,3 +38,7 @@ export interface LoadedManifest {
|
|
|
34
38
|
export declare function findProjectManifest(startDir?: string, maxDepth?: number): LoadedManifest | null;
|
|
35
39
|
/** 매니페스트 services 를 [id, service] 배열로. 없으면 빈 배열. */
|
|
36
40
|
export declare function manifestServiceEntries(m: ProjectManifest): Array<[ManifestServiceId, ManifestService]>;
|
|
41
|
+
/** 파일명으로 안전하게 사용할 수 있는 공개 프로필 id인지 확인한다. */
|
|
42
|
+
export declare function isValidSocialProfileId(value: string): boolean;
|
|
43
|
+
/** 매니페스트에 지정된 플랫폼 프로필. 잘못된 값은 조용히 무시하지 않고 오류로 막는다. */
|
|
44
|
+
export declare function manifestSocialProfile(m: ProjectManifest, platform: SocialPlatform): string | null;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
export const MANIFEST_FILENAME = '.mimi-seed.json';
|
|
12
|
+
export const SOCIAL_PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
12
13
|
/**
|
|
13
14
|
* startDir 에서 위로 올라가며 첫 번째 `.mimi-seed.json` 을 찾는다.
|
|
14
15
|
* MCP stdio 서버의 cwd 는 보통 프로젝트 루트지만, 하위 폴더에서 실행돼도 잡히게 상향 탐색한다.
|
|
@@ -50,3 +51,24 @@ export function manifestServiceEntries(m) {
|
|
|
50
51
|
.filter((k) => svc[k] != null)
|
|
51
52
|
.map((k) => [k, svc[k]]);
|
|
52
53
|
}
|
|
54
|
+
/** 파일명으로 안전하게 사용할 수 있는 공개 프로필 id인지 확인한다. */
|
|
55
|
+
export function isValidSocialProfileId(value) {
|
|
56
|
+
return SOCIAL_PROFILE_ID_PATTERN.test(value);
|
|
57
|
+
}
|
|
58
|
+
/** 매니페스트에 지정된 플랫폼 프로필. 잘못된 값은 조용히 무시하지 않고 오류로 막는다. */
|
|
59
|
+
export function manifestSocialProfile(m, platform) {
|
|
60
|
+
const profiles = m.socialProfiles;
|
|
61
|
+
if (profiles === undefined)
|
|
62
|
+
return null;
|
|
63
|
+
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) {
|
|
64
|
+
throw new Error(`${MANIFEST_FILENAME}의 socialProfiles는 객체여야 합니다.`);
|
|
65
|
+
}
|
|
66
|
+
const value = profiles[platform];
|
|
67
|
+
if (value === undefined)
|
|
68
|
+
return null;
|
|
69
|
+
if (typeof value !== 'string' || !isValidSocialProfileId(value)) {
|
|
70
|
+
throw new Error(`${MANIFEST_FILENAME}의 socialProfiles.${platform} 값이 올바르지 않습니다. ` +
|
|
71
|
+
'영문자·숫자로 시작하는 1~64자의 영문자/숫자/점/밑줄/하이픈만 사용할 수 있습니다.');
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
package/dist/registers/auth.js
CHANGED
|
@@ -15,6 +15,7 @@ import { metaTokenFreshness } from '../lib/meta-auth.js';
|
|
|
15
15
|
import { readPackageRootText } from '../lib/package-root.js';
|
|
16
16
|
import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
|
|
17
17
|
import { syncRemoteCredentials } from '../remote-sync.js';
|
|
18
|
+
import { resolveSocialConfigTarget } from '../social/profile-store.js';
|
|
18
19
|
import { findProjectManifest, manifestServiceEntries, } from '../lib/project-manifest.js';
|
|
19
20
|
/**
|
|
20
21
|
* tokens.json mtime → "Nd Hh ago" 형식 문자열 + 재인증 권고.
|
|
@@ -188,11 +189,13 @@ export function registerAuthTools(server) {
|
|
|
188
189
|
const fb = loadFacebookConfig();
|
|
189
190
|
lines.push(renderMetaConnection('Facebook', fb, `pageId: ${fb?.pageId ?? ''}`, 'mimi-seed auth facebook'));
|
|
190
191
|
// 8. Instagram
|
|
192
|
+
const igTarget = resolveSocialConfigTarget('instagram');
|
|
191
193
|
const ig = loadInstagramConfig();
|
|
192
|
-
lines.push(renderMetaConnection('Instagram', ig, `userId: ${ig?.userId ?? ''}`,
|
|
194
|
+
lines.push(renderMetaConnection('Instagram', ig, `${igTarget.profile ? `profile: ${igTarget.profile}, ` : ''}userId: ${ig?.userId ?? ''}`, `mimi-seed auth instagram${igTarget.profile ? ` --profile ${igTarget.profile}` : ''}`));
|
|
193
195
|
// 9. Threads
|
|
196
|
+
const threadsTarget = resolveSocialConfigTarget('threads');
|
|
194
197
|
const threads = loadThreadsConfig();
|
|
195
|
-
lines.push(renderMetaConnection('Threads', threads, `userId: ${threads?.userId ?? ''}`,
|
|
198
|
+
lines.push(renderMetaConnection('Threads', threads, `${threadsTarget.profile ? `profile: ${threadsTarget.profile}, ` : ''}userId: ${threads?.userId ?? ''}`, `mimi-seed auth threads${threadsTarget.profile ? ` --profile ${threadsTarget.profile}` : ''}`));
|
|
196
199
|
// 10. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
|
|
197
200
|
// 이전엔 SA 파일만 검사해 OAuth fallback 이 살아있어도 ❌ 로 오표기했다.
|
|
198
201
|
const bqAuth = resolveBigQueryAuth();
|
|
@@ -3,25 +3,29 @@ import { requireInstagramConfig } from '../instagram/config.js';
|
|
|
3
3
|
import { connectInstagram } from '../instagram/setup.js';
|
|
4
4
|
import * as api from '../instagram/api.js';
|
|
5
5
|
import { metaExpiryMessage } from '../lib/meta-auth.js';
|
|
6
|
+
import { SOCIAL_PROFILE_ID_PATTERN } from '../lib/project-manifest.js';
|
|
6
7
|
export function registerInstagramTools(server) {
|
|
8
|
+
const profileSchema = z.string().regex(SOCIAL_PROFILE_ID_PATTERN).optional().describe('저장/사용할 소셜 프로필 ID. 생략 시 .mimi-seed.json의 socialProfiles.instagram, 없으면 기존 기본 설정');
|
|
7
9
|
server.tool('instagram_save_config', [
|
|
8
10
|
'Instagram 토큰을 ~/.mimi-seed/instagram.json (mode 0600)에 저장합니다.',
|
|
9
11
|
'accessToken은 두 형식 모두 자동 감지:',
|
|
10
12
|
' IGAA... = Instagram API with Instagram Login (Meta 신규, FB Page 불필요)',
|
|
11
13
|
' EAA... = Instagram Graph API via Facebook Login (FB Page+IG Business 연결 필요)',
|
|
12
14
|
'userId 미입력 시 토큰으로 자동 조회 (/me 또는 /me/accounts).',
|
|
15
|
+
'profile 지정 시 ~/.mimi-seed/social-profiles/<profile>.json에 저장합니다.',
|
|
13
16
|
'저장 직후 토큰 유효성도 자동 검증.',
|
|
14
17
|
].join(' '), {
|
|
15
18
|
accessToken: z.string().describe('Long-lived access token (60일)'),
|
|
16
19
|
userId: z.string().optional().describe('Instagram Business Account ID (생략 시 자동 조회)'),
|
|
17
20
|
assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
|
|
18
|
-
|
|
21
|
+
profile: profileSchema,
|
|
22
|
+
}, async ({ accessToken, userId, assumeIssuedNow, profile }) => {
|
|
19
23
|
// 구현은 instagram/setup.ts 에 있다 — mimi-seed-social-auth CLI 와 공유한다.
|
|
20
|
-
const result = await connectInstagram(accessToken, userId, assumeIssuedNow);
|
|
24
|
+
const result = await connectInstagram(accessToken, userId, assumeIssuedNow, { profile });
|
|
21
25
|
return { content: [{ type: 'text', text: result.text }] };
|
|
22
26
|
});
|
|
23
|
-
server.tool('instagram_get_account', 'Instagram 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
|
|
24
|
-
const cfg = requireInstagramConfig();
|
|
27
|
+
server.tool('instagram_get_account', 'Instagram 계정 정보 조회 + 저장된 토큰 유효성 검증.', { profile: profileSchema }, async ({ profile }) => {
|
|
28
|
+
const cfg = requireInstagramConfig({ profile });
|
|
25
29
|
const account = await api.getAccount(cfg);
|
|
26
30
|
return {
|
|
27
31
|
content: [{
|
|
@@ -45,8 +49,9 @@ export function registerInstagramTools(server) {
|
|
|
45
49
|
].join(' '), {
|
|
46
50
|
imageUrl: z.string().url().describe('이미지의 public URL (HTTPS 권장)'),
|
|
47
51
|
caption: z.string().describe('캡션 — 해시태그/멘션/줄바꿈 포함 가능'),
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
profile: profileSchema,
|
|
53
|
+
}, async ({ imageUrl, caption, profile }) => {
|
|
54
|
+
const cfg = requireInstagramConfig({ profile });
|
|
50
55
|
const result = await api.postImage(cfg, imageUrl, caption);
|
|
51
56
|
return {
|
|
52
57
|
content: [{
|
|
@@ -67,8 +72,9 @@ export function registerInstagramTools(server) {
|
|
|
67
72
|
].join(' '), {
|
|
68
73
|
imageUrls: z.array(z.string().url()).min(2).max(10).describe('이미지 URL 배열 (2~10장)'),
|
|
69
74
|
caption: z.string().describe('캡션'),
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
profile: profileSchema,
|
|
76
|
+
}, async ({ imageUrls, caption, profile }) => {
|
|
77
|
+
const cfg = requireInstagramConfig({ profile });
|
|
72
78
|
const result = await api.postCarousel(cfg, imageUrls, caption);
|
|
73
79
|
return {
|
|
74
80
|
content: [{
|
|
@@ -4,6 +4,7 @@ import { friendlyPlayError } from '../playstore/errors.js';
|
|
|
4
4
|
import { saveServiceAccountJsonForPackage, listRegisteredServiceAccounts, deleteServiceAccountJsonForPackage, } from '../auth/playstore-auth.js';
|
|
5
5
|
import { createGoogleOneTimePurchase, createGoogleSubscription, updateGoogleProduct, deleteGoogleProduct, listGoogleProducts, } from '@onesub/providers';
|
|
6
6
|
import { requirePlayStoreAuth, requireServiceAccountJson, requireAuth } from '../helpers.js';
|
|
7
|
+
import { PLAY_DEVELOPER_REPORTING_SCOPE } from '../auth/scopes.js';
|
|
7
8
|
import * as iam from '../iam/tools.js';
|
|
8
9
|
import { buildPlayStoreReleasePlan } from '../checks/plan.js';
|
|
9
10
|
import { validatePlayReleaseNotes, formatIssuesForUser } from '../lib/text-validators.js';
|
|
@@ -115,7 +116,9 @@ export function registerPlaystoreTools(server) {
|
|
|
115
116
|
userCohort: z.enum(['OS_PUBLIC', 'APP_TESTERS', 'OS_BETA']).optional().describe('사용자 cohort'),
|
|
116
117
|
timeZone: z.string().optional().describe('날짜 기준 timezone. 기본: America/Los_Angeles (Play Reporting API 샘플/지원 기준)'),
|
|
117
118
|
}, async (args) => {
|
|
118
|
-
|
|
119
|
+
// 통계는 Play Developer Reporting API 를 쓰므로 OAuth 폴백 시 reporting 스코프를
|
|
120
|
+
// 요구한다. 미보유면 generic 403 대신 "--domains playstore 재로그인" 안내가 나간다.
|
|
121
|
+
const auth = requirePlayStoreAuth(args.packageName, PLAY_DEVELOPER_REPORTING_SCOPE);
|
|
119
122
|
const result = await playstore.getStatistics(auth, args.packageName, args);
|
|
120
123
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
121
124
|
});
|
|
@@ -3,33 +3,38 @@ import { loadThreadsConfig, requireThreadsConfig } from '../threads/config.js';
|
|
|
3
3
|
import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
|
|
4
4
|
import * as api from '../threads/api.js';
|
|
5
5
|
import { metaExpiryMessage } from '../lib/meta-auth.js';
|
|
6
|
+
import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
|
|
7
|
+
import { SOCIAL_PROFILE_ID_PATTERN } from '../lib/project-manifest.js';
|
|
6
8
|
export function registerThreadsTools(server) {
|
|
9
|
+
const profileSchema = z.string().regex(SOCIAL_PROFILE_ID_PATTERN).optional().describe('저장/사용할 소셜 프로필 ID. 생략 시 .mimi-seed.json의 socialProfiles.threads, 없으면 기존 기본 설정');
|
|
7
10
|
server.tool('threads_save_config', [
|
|
8
11
|
'Threads 토큰을 ~/.mimi-seed/threads.json (mode 0600)에 저장합니다.',
|
|
9
12
|
'accessToken은 Threads Graph API long-lived 토큰 (약 60일).',
|
|
10
13
|
'userId 미입력 시 토큰으로 자동 조회 (GET /me).',
|
|
14
|
+
'profile 지정 시 ~/.mimi-seed/social-profiles/<profile>.json에 저장합니다.',
|
|
11
15
|
'저장 직후 토큰 유효성도 자동 검증.',
|
|
12
16
|
'Instagram 과 별개 계정·별개 토큰입니다 (threads_basic, threads_content_publish 권한 필요).',
|
|
13
17
|
].join(' '), {
|
|
14
18
|
accessToken: z.string().describe('Threads Graph API long-lived access token (약 60일)'),
|
|
15
19
|
userId: z.string().optional().describe('Threads user ID (생략 시 자동 조회)'),
|
|
16
20
|
assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
|
|
17
|
-
|
|
21
|
+
profile: profileSchema,
|
|
22
|
+
}, async ({ accessToken, userId, assumeIssuedNow, profile }) => {
|
|
18
23
|
// 구현은 threads/setup.ts 에 있다 — mimi-seed-social-auth CLI 와 공유한다.
|
|
19
|
-
const result = await connectThreads(accessToken, userId, assumeIssuedNow);
|
|
24
|
+
const result = await connectThreads(accessToken, userId, assumeIssuedNow, { profile });
|
|
20
25
|
return { content: [{ type: 'text', text: result.text }] };
|
|
21
26
|
});
|
|
22
27
|
server.tool('threads_refresh_token', [
|
|
23
28
|
'저장된 Threads long-lived 토큰을 만료 전에 공식 refresh_access_token endpoint로 갱신합니다.',
|
|
24
29
|
'성공하면 새 토큰과 실제 expires_in을 저장하고 계정을 다시 검증합니다.',
|
|
25
30
|
'이미 만료·철회된 토큰은 갱신할 수 없으므로 mimi-seed auth threads로 재연결하세요.',
|
|
26
|
-
].join(' '), {}, async () => {
|
|
27
|
-
const cfg = requireThreadsConfig();
|
|
28
|
-
const result = await refreshThreadsToken(cfg.accessToken);
|
|
31
|
+
].join(' '), { profile: profileSchema }, async ({ profile }) => {
|
|
32
|
+
const cfg = requireThreadsConfig({ profile });
|
|
33
|
+
const result = await refreshThreadsToken(cfg.accessToken, { profile });
|
|
29
34
|
return { content: [{ type: 'text', text: result.text }], isError: !result.ok };
|
|
30
35
|
});
|
|
31
|
-
server.tool('threads_get_account', 'Threads 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
|
|
32
|
-
const cfg = requireThreadsConfig();
|
|
36
|
+
server.tool('threads_get_account', 'Threads 계정 정보 조회 + 저장된 토큰 유효성 검증.', { profile: profileSchema }, async ({ profile }) => {
|
|
37
|
+
const cfg = requireThreadsConfig({ profile });
|
|
33
38
|
const account = await api.getAccount(cfg);
|
|
34
39
|
return {
|
|
35
40
|
content: [{
|
|
@@ -51,8 +56,9 @@ export function registerThreadsTools(server) {
|
|
|
51
56
|
].join(' '), {
|
|
52
57
|
text: z.string().max(500).describe('게시할 텍스트 (최대 500자, 멘션/해시태그/줄바꿈 가능)'),
|
|
53
58
|
imageUrl: z.string().url().optional().describe('이미지의 public URL (생략 시 텍스트 전용 게시)'),
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
profile: profileSchema,
|
|
60
|
+
}, async ({ text, imageUrl, profile }) => {
|
|
61
|
+
const cfg = requireThreadsConfig({ profile });
|
|
56
62
|
const result = await api.postText(cfg, text, imageUrl);
|
|
57
63
|
return {
|
|
58
64
|
content: [{
|
|
@@ -72,8 +78,9 @@ export function registerThreadsTools(server) {
|
|
|
72
78
|
].join(' '), {
|
|
73
79
|
imageUrls: z.array(z.string().url()).min(2).max(20).describe('이미지 URL 배열 (2~20장)'),
|
|
74
80
|
text: z.string().max(500).describe('캡션 텍스트 (최대 500자)'),
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
profile: profileSchema,
|
|
82
|
+
}, async ({ imageUrls, text, profile }) => {
|
|
83
|
+
const cfg = requireThreadsConfig({ profile });
|
|
77
84
|
const result = await api.postCarousel(cfg, imageUrls, text);
|
|
78
85
|
return {
|
|
79
86
|
content: [{
|
|
@@ -87,11 +94,16 @@ export function registerThreadsTools(server) {
|
|
|
87
94
|
};
|
|
88
95
|
});
|
|
89
96
|
// 진단용 — 현재 저장된 Threads 설정 요약 (facebook_current_config 와 동일한 패턴).
|
|
90
|
-
server.tool('threads_current_config', '현재
|
|
91
|
-
const
|
|
97
|
+
server.tool('threads_current_config', '현재 선택된 Threads 연결 설정을 확인합니다. 프로젝트 매핑 또는 profile 인자를 따릅니다.', { profile: profileSchema }, async ({ profile }) => {
|
|
98
|
+
const options = { profile };
|
|
99
|
+
const target = resolveSocialConfigTarget('threads', options);
|
|
100
|
+
const cfg = loadThreadsConfig(options);
|
|
92
101
|
if (!cfg) {
|
|
93
102
|
return {
|
|
94
|
-
content: [{
|
|
103
|
+
content: [{
|
|
104
|
+
type: 'text',
|
|
105
|
+
text: `❌ Threads ${socialTargetLabel(target)} 미설정 → threads_save_config 또는 mimi-seed auth threads`,
|
|
106
|
+
}],
|
|
95
107
|
};
|
|
96
108
|
}
|
|
97
109
|
return {
|
|
@@ -99,6 +111,7 @@ export function registerThreadsTools(server) {
|
|
|
99
111
|
type: 'text',
|
|
100
112
|
text: [
|
|
101
113
|
'✅ Threads 연결됨',
|
|
114
|
+
` 대상: ${socialTargetLabel(target)}`,
|
|
102
115
|
` @${cfg.username ?? '(username 미저장)'}`,
|
|
103
116
|
` userId: ${cfg.userId}`,
|
|
104
117
|
` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth threads')}`,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SocialPlatform } from '../lib/project-manifest.js';
|
|
2
|
+
export interface SocialConfigOptions {
|
|
3
|
+
/** 명시하면 프로젝트 매니페스트 매핑보다 우선한다. */
|
|
4
|
+
profile?: string;
|
|
5
|
+
/** 매니페스트 상향 탐색 시작점. 기본값은 현재 작업 디렉터리. */
|
|
6
|
+
startDir?: string;
|
|
7
|
+
/** 테스트 및 격리 실행용 홈 디렉터리. */
|
|
8
|
+
homeDir?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SocialConfigTarget {
|
|
11
|
+
filePath: string;
|
|
12
|
+
profile: string | null;
|
|
13
|
+
}
|
|
14
|
+
export declare function resolveSocialConfigTarget(platform: SocialPlatform, options?: SocialConfigOptions): SocialConfigTarget;
|
|
15
|
+
export declare function loadSocialPlatformConfig<T>(platform: SocialPlatform, options?: SocialConfigOptions): T | null;
|
|
16
|
+
export declare function saveSocialPlatformConfig<T extends object>(platform: SocialPlatform, config: T, options?: SocialConfigOptions): SocialConfigTarget;
|
|
17
|
+
export declare function socialTargetLabel(target: SocialConfigTarget): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { findProjectManifest, isValidSocialProfileId, manifestSocialProfile, } from '../lib/project-manifest.js';
|
|
5
|
+
function validateProfileId(profile) {
|
|
6
|
+
if (!isValidSocialProfileId(profile)) {
|
|
7
|
+
throw new Error('소셜 프로필 ID가 올바르지 않습니다. ' +
|
|
8
|
+
'영문자·숫자로 시작하는 1~64자의 영문자/숫자/점/밑줄/하이픈만 사용할 수 있습니다.');
|
|
9
|
+
}
|
|
10
|
+
return profile;
|
|
11
|
+
}
|
|
12
|
+
export function resolveSocialConfigTarget(platform, options = {}) {
|
|
13
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
14
|
+
const root = path.join(homeDir, '.mimi-seed');
|
|
15
|
+
let profile = null;
|
|
16
|
+
if (options.profile !== undefined) {
|
|
17
|
+
profile = validateProfileId(options.profile);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const loaded = findProjectManifest(options.startDir ?? process.cwd());
|
|
21
|
+
if (loaded)
|
|
22
|
+
profile = manifestSocialProfile(loaded.manifest, platform);
|
|
23
|
+
}
|
|
24
|
+
return profile
|
|
25
|
+
? { profile, filePath: path.join(root, 'social-profiles', `${profile}.json`) }
|
|
26
|
+
: { profile: null, filePath: path.join(root, `${platform}.json`) };
|
|
27
|
+
}
|
|
28
|
+
export function loadSocialPlatformConfig(platform, options = {}) {
|
|
29
|
+
const target = resolveSocialConfigTarget(platform, options);
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(fs.readFileSync(target.filePath, 'utf-8'));
|
|
32
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
33
|
+
return null;
|
|
34
|
+
if (target.profile) {
|
|
35
|
+
return parsed[platform] ?? null;
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function saveSocialPlatformConfig(platform, config, options = {}) {
|
|
44
|
+
const target = resolveSocialConfigTarget(platform, options);
|
|
45
|
+
const dir = path.dirname(target.filePath);
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
let value = config;
|
|
48
|
+
if (target.profile) {
|
|
49
|
+
let existing = {};
|
|
50
|
+
if (fs.existsSync(target.filePath)) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(fs.readFileSync(target.filePath, 'utf-8'));
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
54
|
+
throw new Error('객체 형식이 아닙니다.');
|
|
55
|
+
}
|
|
56
|
+
existing = parsed;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new Error(`기존 소셜 프로필 파일을 읽을 수 없어 덮어쓰지 않았습니다: ${target.filePath} ` +
|
|
60
|
+
`(${error instanceof Error ? error.message : String(error)})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
value = { ...existing, [platform]: config };
|
|
64
|
+
}
|
|
65
|
+
fs.writeFileSync(target.filePath, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
66
|
+
if (process.platform !== 'win32')
|
|
67
|
+
fs.chmodSync(target.filePath, 0o600);
|
|
68
|
+
return target;
|
|
69
|
+
}
|
|
70
|
+
export function socialTargetLabel(target) {
|
|
71
|
+
return target.profile ? `프로필 '${target.profile}'` : '기본 프로필';
|
|
72
|
+
}
|
package/dist/social/setup-cli.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// mimi-seed-social-auth instagram → Instagram 만
|
|
12
12
|
// mimi-seed-social-auth threads → Threads 만
|
|
13
13
|
// mimi-seed-social-auth all → 세 플랫폼을 순서대로
|
|
14
|
+
// mimi-seed-social-auth instagram --profile my-app → 명시한 프로필에 저장
|
|
14
15
|
//
|
|
15
16
|
// 주의: connectFacebook / connectInstagram / connectThreads 이 돌려주는 result.text 는 MCP 도구와
|
|
16
17
|
// 공유하는 텍스트라 여기서 번역하지 않는다 (그대로 출력한다).
|
|
@@ -22,6 +23,7 @@ import { connectFacebook } from '../facebook/setup.js';
|
|
|
22
23
|
import { connectInstagram } from '../instagram/setup.js';
|
|
23
24
|
import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
|
|
24
25
|
import { resolveLang } from '../lib/lang.js';
|
|
26
|
+
import { resolveSocialConfigTarget } from './profile-store.js';
|
|
25
27
|
// ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
|
|
26
28
|
const ko = {
|
|
27
29
|
title: ' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram / Threads)',
|
|
@@ -59,6 +61,8 @@ const ko = {
|
|
|
59
61
|
thExistingAction: ' [Enter/y] 기존 토큰 자동 갱신 [r] 새 토큰으로 재연결 [n] 건너뛰기: ',
|
|
60
62
|
thRefreshing: ' 🔄 기존 Threads 토큰 갱신 중...',
|
|
61
63
|
thRefreshFallback: ' 자동 갱신에 실패했습니다. 새 토큰을 입력해 재연결할 수 있습니다.',
|
|
64
|
+
profile: (id) => ` 소셜 프로필: ${id}`,
|
|
65
|
+
invalidArgs: ' 사용법: mimi-seed-social-auth [facebook|instagram|threads|all] [--profile <id>]',
|
|
62
66
|
};
|
|
63
67
|
const en = {
|
|
64
68
|
title: ' 🤖 Mimi Seed — Connect social accounts (Facebook / Instagram / Threads)',
|
|
@@ -96,6 +100,8 @@ const en = {
|
|
|
96
100
|
thExistingAction: ' [Enter/y] refresh current token [r] reconnect with a new token [n] skip: ',
|
|
97
101
|
thRefreshing: ' 🔄 Refreshing the existing Threads token...',
|
|
98
102
|
thRefreshFallback: ' Automatic refresh failed. You can reconnect with a new token.',
|
|
103
|
+
profile: (id) => ` Social profile: ${id}`,
|
|
104
|
+
invalidArgs: ' Usage: mimi-seed-social-auth [facebook|instagram|threads|all] [--profile <id>]',
|
|
99
105
|
};
|
|
100
106
|
const M = resolveLang() === 'en' ? en : ko;
|
|
101
107
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -145,10 +151,14 @@ async function setupFacebook() {
|
|
|
145
151
|
console.log(M.notSaved);
|
|
146
152
|
return result.ok;
|
|
147
153
|
}
|
|
148
|
-
async function setupInstagram() {
|
|
154
|
+
async function setupInstagram(profile) {
|
|
149
155
|
console.log('');
|
|
150
156
|
console.log(M.igHeader);
|
|
151
|
-
const
|
|
157
|
+
const options = { profile };
|
|
158
|
+
const target = resolveSocialConfigTarget('instagram', options);
|
|
159
|
+
if (target.profile)
|
|
160
|
+
console.log(M.profile(target.profile));
|
|
161
|
+
const existing = loadInstagramConfig(options);
|
|
152
162
|
if (existing && !(await confirmReconnect('Instagram', existing.username ?? existing.userId))) {
|
|
153
163
|
return true;
|
|
154
164
|
}
|
|
@@ -164,17 +174,21 @@ async function setupInstagram() {
|
|
|
164
174
|
}
|
|
165
175
|
const userId = await ask(M.igAskUserId);
|
|
166
176
|
console.log(M.verifying);
|
|
167
|
-
const result = await connectInstagram(token, userId || undefined);
|
|
177
|
+
const result = await connectInstagram(token, userId || undefined, true, options);
|
|
168
178
|
console.log('');
|
|
169
179
|
console.log(indent(result.text));
|
|
170
180
|
if (!result.ok)
|
|
171
181
|
console.log(M.notSaved);
|
|
172
182
|
return result.ok;
|
|
173
183
|
}
|
|
174
|
-
async function setupThreads() {
|
|
184
|
+
async function setupThreads(profile) {
|
|
175
185
|
console.log('');
|
|
176
186
|
console.log(M.thHeader);
|
|
177
|
-
const
|
|
187
|
+
const options = { profile };
|
|
188
|
+
const target = resolveSocialConfigTarget('threads', options);
|
|
189
|
+
if (target.profile)
|
|
190
|
+
console.log(M.profile(target.profile));
|
|
191
|
+
const existing = loadThreadsConfig(options);
|
|
178
192
|
if (existing) {
|
|
179
193
|
console.log(M.already('Threads', existing.username ?? existing.userId));
|
|
180
194
|
const action = (await ask(M.thExistingAction)).toLowerCase();
|
|
@@ -182,7 +196,7 @@ async function setupThreads() {
|
|
|
182
196
|
return true;
|
|
183
197
|
if (action !== 'r') {
|
|
184
198
|
console.log(M.thRefreshing);
|
|
185
|
-
const refreshed = await refreshThreadsToken(existing.accessToken);
|
|
199
|
+
const refreshed = await refreshThreadsToken(existing.accessToken, options);
|
|
186
200
|
console.log('');
|
|
187
201
|
console.log(indent(refreshed.text));
|
|
188
202
|
if (refreshed.ok)
|
|
@@ -203,7 +217,7 @@ async function setupThreads() {
|
|
|
203
217
|
}
|
|
204
218
|
const userId = await ask(M.thAskUserId);
|
|
205
219
|
console.log(M.verifying);
|
|
206
|
-
const result = await connectThreads(token, userId || undefined);
|
|
220
|
+
const result = await connectThreads(token, userId || undefined, true, options);
|
|
207
221
|
console.log('');
|
|
208
222
|
console.log(indent(result.text));
|
|
209
223
|
if (!result.ok)
|
|
@@ -217,7 +231,19 @@ function indent(text) {
|
|
|
217
231
|
.join('\n');
|
|
218
232
|
}
|
|
219
233
|
async function main() {
|
|
220
|
-
const
|
|
234
|
+
const args = process.argv.slice(2);
|
|
235
|
+
const profileIndex = args.indexOf('--profile');
|
|
236
|
+
const profile = profileIndex >= 0 ? args[profileIndex + 1] : undefined;
|
|
237
|
+
if (profileIndex >= 0 && !profile) {
|
|
238
|
+
console.error(M.invalidArgs);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
const positional = args.filter((arg, index) => arg !== '--profile' && (profileIndex < 0 || index !== profileIndex + 1));
|
|
242
|
+
if (positional.length > 1 || args.some((arg) => arg.startsWith('--') && arg !== '--profile')) {
|
|
243
|
+
console.error(M.invalidArgs);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
const target = (positional[0] ?? '').toLowerCase();
|
|
221
247
|
console.log('');
|
|
222
248
|
console.log(M.title);
|
|
223
249
|
let ok = true;
|
|
@@ -225,15 +251,15 @@ async function main() {
|
|
|
225
251
|
ok = await setupFacebook();
|
|
226
252
|
}
|
|
227
253
|
else if (target === 'instagram' || target === 'ig') {
|
|
228
|
-
ok = await setupInstagram();
|
|
254
|
+
ok = await setupInstagram(profile);
|
|
229
255
|
}
|
|
230
256
|
else if (target === 'threads' || target === 'th') {
|
|
231
|
-
ok = await setupThreads();
|
|
257
|
+
ok = await setupThreads(profile);
|
|
232
258
|
}
|
|
233
259
|
else if (target === 'all' || target === 'meta') {
|
|
234
260
|
const fb = await setupFacebook();
|
|
235
|
-
const ig = await setupInstagram();
|
|
236
|
-
const th = await setupThreads();
|
|
261
|
+
const ig = await setupInstagram(profile);
|
|
262
|
+
const th = await setupThreads(profile);
|
|
237
263
|
ok = fb && ig && th;
|
|
238
264
|
}
|
|
239
265
|
else {
|
|
@@ -242,19 +268,19 @@ async function main() {
|
|
|
242
268
|
if (c === 'f')
|
|
243
269
|
ok = await setupFacebook();
|
|
244
270
|
else if (c === 'i')
|
|
245
|
-
ok = await setupInstagram();
|
|
271
|
+
ok = await setupInstagram(profile);
|
|
246
272
|
else if (c === 't')
|
|
247
|
-
ok = await setupThreads();
|
|
273
|
+
ok = await setupThreads(profile);
|
|
248
274
|
else if (c === 'b') {
|
|
249
275
|
// 기존 b=Facebook+Instagram 동작을 유지한다.
|
|
250
276
|
const fb = await setupFacebook();
|
|
251
|
-
const ig = await setupInstagram();
|
|
277
|
+
const ig = await setupInstagram(profile);
|
|
252
278
|
ok = fb && ig;
|
|
253
279
|
}
|
|
254
280
|
else if (c === 'a') {
|
|
255
281
|
const fb = await setupFacebook();
|
|
256
|
-
const ig = await setupInstagram();
|
|
257
|
-
const th = await setupThreads();
|
|
282
|
+
const ig = await setupInstagram(profile);
|
|
283
|
+
const th = await setupThreads(profile);
|
|
258
284
|
ok = fb && ig && th;
|
|
259
285
|
}
|
|
260
286
|
else {
|
package/dist/threads/config.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { type SocialConfigOptions } from '../social/profile-store.js';
|
|
1
2
|
export interface ThreadsConfig {
|
|
2
3
|
accessToken: string;
|
|
3
4
|
userId: string;
|
|
4
5
|
expiresAt?: string;
|
|
5
6
|
username?: string;
|
|
6
7
|
}
|
|
7
|
-
export declare function loadThreadsConfig(): ThreadsConfig | null;
|
|
8
|
-
export declare function saveThreadsConfig(cfg: ThreadsConfig): void;
|
|
9
|
-
export declare function requireThreadsConfig(): ThreadsConfig;
|
|
8
|
+
export declare function loadThreadsConfig(options?: SocialConfigOptions): ThreadsConfig | null;
|
|
9
|
+
export declare function saveThreadsConfig(cfg: ThreadsConfig, options?: SocialConfigOptions): void;
|
|
10
|
+
export declare function requireThreadsConfig(options?: SocialConfigOptions): ThreadsConfig;
|
package/dist/threads/config.js
CHANGED
|
@@ -1,35 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
try {
|
|
7
|
-
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
8
|
-
if (typeof cfg.accessToken !== 'string' || !cfg.accessToken ||
|
|
9
|
-
typeof cfg.userId !== 'string' || !cfg.userId)
|
|
10
|
-
return null;
|
|
11
|
-
return cfg;
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
1
|
+
import { loadSocialPlatformConfig, resolveSocialConfigTarget, saveSocialPlatformConfig, } from '../social/profile-store.js';
|
|
2
|
+
export function loadThreadsConfig(options = {}) {
|
|
3
|
+
const cfg = loadSocialPlatformConfig('threads', options);
|
|
4
|
+
if (typeof cfg?.accessToken !== 'string' || !cfg.accessToken ||
|
|
5
|
+
typeof cfg.userId !== 'string' || !cfg.userId)
|
|
14
6
|
return null;
|
|
15
|
-
|
|
7
|
+
return cfg;
|
|
16
8
|
}
|
|
17
|
-
export function saveThreadsConfig(cfg) {
|
|
18
|
-
|
|
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 saveThreadsConfig(cfg, options = {}) {
|
|
10
|
+
saveSocialPlatformConfig('threads', cfg, options);
|
|
26
11
|
}
|
|
27
|
-
export function requireThreadsConfig() {
|
|
28
|
-
const cfg = loadThreadsConfig();
|
|
12
|
+
export function requireThreadsConfig(options = {}) {
|
|
13
|
+
const cfg = loadThreadsConfig(options);
|
|
29
14
|
if (!cfg) {
|
|
15
|
+
const target = resolveSocialConfigTarget('threads', options);
|
|
16
|
+
const profileHint = target.profile ? `, profile="${target.profile}"` : '';
|
|
30
17
|
throw new Error('Threads 설정이 없습니다.\n' +
|
|
31
18
|
'threads_save_config 도구로 먼저 설정해주세요.\n' +
|
|
32
|
-
|
|
19
|
+
`예: threads_save_config(accessToken="...", userId="..."${profileHint})`);
|
|
33
20
|
}
|
|
34
21
|
return cfg;
|
|
35
22
|
}
|
package/dist/threads/setup.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConnectResult } from '../facebook/setup.js';
|
|
2
|
-
|
|
2
|
+
import type { SocialConfigOptions } from '../social/profile-store.js';
|
|
3
|
+
export declare function connectThreads(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions): Promise<ConnectResult>;
|
|
3
4
|
/** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
|
|
4
|
-
export declare function refreshThreadsToken(currentAccessToken: string): Promise<ConnectResult>;
|
|
5
|
+
export declare function refreshThreadsToken(currentAccessToken: string, options?: SocialConfigOptions): Promise<ConnectResult>;
|
package/dist/threads/setup.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// MCP 도구(threads_save_config)와 setup CLI(mimi-seed-social-auth)가 **공유**하는 구현.
|
|
3
3
|
import { saveThreadsConfig } from './config.js';
|
|
4
4
|
import * as api from './api.js';
|
|
5
|
+
import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
|
|
5
6
|
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
|
6
|
-
export async function connectThreads(accessToken, userId, assumeIssuedNow = true) {
|
|
7
|
+
export async function connectThreads(accessToken, userId, assumeIssuedNow = true, options = {}) {
|
|
8
|
+
// 프로필 ID를 네트워크 호출 전에 검증하고, 성공 응답에 실제 저장 대상을 남긴다.
|
|
9
|
+
const target = resolveSocialConfigTarget('threads', options);
|
|
7
10
|
let resolvedUserId = userId;
|
|
8
11
|
if (!resolvedUserId) {
|
|
9
12
|
try {
|
|
@@ -31,11 +34,12 @@ export async function connectThreads(accessToken, userId, assumeIssuedNow = true
|
|
|
31
34
|
userId: resolvedUserId,
|
|
32
35
|
expiresAt,
|
|
33
36
|
username: account.username,
|
|
34
|
-
});
|
|
37
|
+
}, options);
|
|
35
38
|
return {
|
|
36
39
|
ok: true,
|
|
37
40
|
text: [
|
|
38
41
|
'✅ Threads 연결 확인 완료',
|
|
42
|
+
` 저장 대상: ${socialTargetLabel(target)}`,
|
|
39
43
|
` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
|
|
40
44
|
` ID: ${account.id}`,
|
|
41
45
|
expiresAt ? ` 토큰 만료(추정): ${expiresAt.slice(0, 10)}` : '',
|
|
@@ -59,7 +63,8 @@ export async function connectThreads(accessToken, userId, assumeIssuedNow = true
|
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
/** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
|
|
62
|
-
export async function refreshThreadsToken(currentAccessToken) {
|
|
66
|
+
export async function refreshThreadsToken(currentAccessToken, options = {}) {
|
|
67
|
+
const target = resolveSocialConfigTarget('threads', options);
|
|
63
68
|
try {
|
|
64
69
|
const refreshed = await api.refreshAccessToken(currentAccessToken);
|
|
65
70
|
const userId = await api.fetchUserId(refreshed.accessToken);
|
|
@@ -70,11 +75,12 @@ export async function refreshThreadsToken(currentAccessToken) {
|
|
|
70
75
|
userId,
|
|
71
76
|
username: account.username,
|
|
72
77
|
expiresAt,
|
|
73
|
-
});
|
|
78
|
+
}, options);
|
|
74
79
|
return {
|
|
75
80
|
ok: true,
|
|
76
81
|
text: [
|
|
77
82
|
'✅ Threads 토큰 갱신 완료',
|
|
83
|
+
` 저장 대상: ${socialTargetLabel(target)}`,
|
|
78
84
|
` 계정: @${account.username}`,
|
|
79
85
|
` 새 만료일: ${expiresAt.slice(0, 10)}`,
|
|
80
86
|
].join('\n'),
|
package/package.json
CHANGED